引言:一个让 UI 卡死的温湿度传感器
上周帮同事排查一个诡异的 Bug:用户点击屏幕上的“读取温湿度”按钮,整个界面就像被施了定身咒——卡住大约 500ms,触摸哪里都没反应,直到数据刷出来才恢复正常。
测试的同事很困惑:“是不是屏幕驱动有问题?”
我接过代码一看,问题根本不在屏幕,而是藏在这个看起来人畜无害的函数里:
uint8_t DHT11_Read(void)
{
// 等待起始信号...
while(PIN_READ() == 0); // 死等,直到引脚变高
while(PIN_READ() == 1); // 死等,直到引脚变低
// 读取40位数据...
for(int i = 0; i < 40; i++) {
while(PIN_READ() == 0); // 又是死等
delay_us(30);
// ...
}
return humidity;
}
看到没?满屏的 while(PIN_READ() == X);——这就是典型的“死等”。CPU 在这几百毫秒里啥也不干,就盯着一根引脚发呆,屏幕刷新、按键响应统统靠边站。
问题来了:阻塞式代码写起来确实爽,逻辑一条线走到底,但运行起来就是灾难;异步式代码效率高,但写起来像一盘散落的面条,到处是回调和状态跳转。
到底怎么选?别急,今天我就给你3条黄金法则,让你下次设计接口时不再纠结。
一、阻塞式 (Blocking):简单粗暴的“独占者”
先给阻塞下个接地气的定义:调用我,不把活干完,我就不让你走。
函数不 return,调用方就只能干等着。听起来很霸道,但有时候这种霸道恰恰是必要的。
两种“阻塞”,天差地别
这里要纠正一个常见误区——很多人把“阻塞”和“低效”划等号,其实不对。阻塞分两种:
1. 死等 (Busy Wait)
while(flag == 0); // CPU 满负荷空转
这种写法,CPU 一直在跑循环,功耗拉满,其他任务全部饿死。这是裸机开发的大忌,能不用就别用。
2. 挂起等待 (Yield/Sleep)
xSemaphoreTake(sem, portMAX_DELAY); // 让出 CPU,去干别的
vTaskDelay(pdMS_TO_TICKS(100)); // 主动休眠
这种阻塞不一样——当前任务虽然卡住了,但 CPU 会切换到其他任务继续干活。这是 RTOS 鼓励的阻塞方式,本质上是一种“优雅的等待”。
什么时候该选阻塞?
别被吓到,阻塞不是洪水猛兽,以下场景反而必须用阻塞:
场景一:初始化阶段
void Sensor_Init(void)
{
// 发送复位命令
send_reset_cmd();
// 必须等传感器复位完成,否则后续操作全废
delay_ms(50); // 这里阻塞是合理的
// 读取校准参数...
}
系统刚启动,还没进主循环,此时其他任务压根不存在。为了保证硬件就绪,老老实实等着就行。
场景二:极短的原子操作
读一个寄存器、I2C 发送 1 个字节,耗时在微秒级别。这种情况下,上下文切换的开销可能比等待时间还长,用异步反而得不偿失。
场景三:逻辑强依赖
下一步操作必须依赖上一步的结果,且中间无法插入其他工作。这时候硬拆成异步,只会让代码变成一团乱麻。
二、异步式 (Asynchronous):随叫随到的“多面手”
异步的定义也很简单:我先答应你,活我慢慢干,干完了再通知你。
调用立即返回,不耽误你干别的事。等结果出来了,通过回调、标志位或者事件来告诉你。
异步的三种常见套路
套路一:轮询式 (Polling)
// 主循环里不断询问
void main_loop(void)
{
while(1) {
if(UART_DataReady()) { // 有数据吗?
uint8_t data = UART_Read();
process(data);
}
update_screen(); // 不耽误刷屏
handle_buttons(); // 不耽误响应按键
}
}
每次循环问一嘴,有数据就处理,没有就跳过。简单粗暴,但胜在可控。
套路二:回调式 (Callback)
void UART_RxCallback(uint8_t *data, uint16_t len)
{
// 中断触发时自动执行
ring_buffer_push(data, len);
xSemaphoreGiveFromISR(data_ready_sem, NULL);
}
// 注册回调
UART_Receive_IT(rx_buf, BUF_SIZE, UART_RxCallback);
只有事件发生(中断来了)才执行,平时完全不占 CPU。这是事件驱动的典型写法。
套路三:DMA——异步的极致
// CPU 只负责启动传输,剩下的交给硬件
HAL_UART_Receive_DMA(&huart1, rx_buf, 100);
// CPU 该干嘛干嘛,数据自己就搬过来了
DMA 是硬件帮你搬数据,CPU 甚至不知道传输正在进行。这是异步的终极形态。
什么时候必须选异步?
场景一:慢速 I/O 操作
Flash 擦除动辄几十毫秒,网络请求几百毫秒起步,电机转到位可能要好几秒。这些操作如果用阻塞,系统直接“假死”。
来看个对比:
// 阻塞版:擦除期间系统卡死
void Flash_Erase_Blocking(uint32_t addr)
{
start_erase(addr);
while(!erase_done()); // 卡住 50ms
}
// 异步版:主循环照常运行
typedef enum { IDLE, ERASING, DONE } FlashState;
FlashState flash_state = IDLE;
void Flash_Erase_Start(uint32_t addr)
{
start_erase(addr);
flash_state = ERASING;
}
void Flash_Poll(void)
{
if(flash_state == ERASING && erase_done()) {
flash_state = DONE;
// 可以触发回调或设置标志
}
}
异步版虽然代码多了点,但主循环不卡顿,用户体验天壤之别。
场景二:不确定何时发生的事件
按键什么时候按下?串口什么时候收到数据?传感器什么时候准备好?这些事件没法预测,只能用异步来等。
三、灵魂拷问:决策的“黄金三角”法则
说了这么多,具体到某个接口设计时,到底怎么快速判断?记住这三条法则就够了。
法则一:时间阈值法 (The Time Rule)
给自己定一条线,比如 100 微秒 或 1 毫秒(根据你的系统实时性要求调整)。
| 操作耗时 |
选择 |
理由 |
| < 阈值 |
阻塞 |
简单即正义,切换开销可能更大 |
| > 阈值 |
异步 |
性能至上,别让用户等 |
举个例子:读一个 GPIO 状态?阻塞。等待网络响应?异步。没那么多弯弯绕绕。
法则二:上下文环境法 (The Context Rule)
代码运行在哪里,决定了你能用什么。
在中断里 (ISR)?
严禁任何形式的延时和阻塞!中断里待太久,会导致其他中断丢失,系统可能直接跑飞。只能用异步——设个标志位,让主循环去处理。
// 错误示范
void EXTI_IRQHandler(void)
{
delay_ms(10); // 作死!
}
// 正确示范
volatile uint8_t button_pressed = 0;
void EXTI_IRQHandler(void)
{
button_pressed = 1; // 设标志,立即返回
}
在高优先级任务里?
慎用阻塞。如果高优先级任务长时间占着 CPU,低优先级任务就会被“饿死”,系统响应变得不可预测。
在初始化代码里?
大胆用阻塞,安全第一。反正这时候还没有其他任务在跑,等就等呗。
法则三:复杂度权衡法 (The Complexity Rule)
这条最容易被忽视,但往往最关键——不要为了异步而异步。
如果为了把一个简单的 LED 闪烁改成异步,你引入了状态机、回调函数、事件队列,最后代码量翻了三倍,维护起来一塌糊涂……那还不如老老实实用 RTOS 的 osDelay() 阻塞一下。
代码是给人看的,不是给编译器炫技的。能用简单方案解决的问题,别搞复杂。想要学习更系统的架构设计思想,可以到 云栈社区 交流探讨。
四、高阶技巧:用“同步”的皮,包“异步”的芯
讲到这里,你可能会问:有没有两全其美的方案?既能让应用层写起来像同步一样简单,底层又是全异步的?
有。这就是 RTOS 给我们的“魔法”。
// 驱动层:底层用 DMA + 中断(异步)
void UART_Transmit(uint8_t *data, uint16_t len)
{
HAL_UART_Transmit_DMA(&huart1, data, len); // 启动 DMA,立即返回
xSemaphoreTake(tx_done_sem, portMAX_DELAY); // 挂起等待完成
}
// 中断回调:传输完成时释放信号量
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
xSemaphoreGiveFromISR(tx_done_sem, NULL);
}
// 应用层:用起来跟阻塞一样简单
void app_send_message(void)
{
UART_Transmit(msg, strlen(msg)); // 写起来像阻塞
// 但实际上 CPU 在等待期间去干别的了
}
这招的精髓在于:
- 底层用 DMA + 中断,数据传输时 CPU 完全空闲
- 上层用信号量阻塞,但这是“挂起式阻塞”,CPU 会切换到其他任务
- 应用层看到的是线性逻辑,好写好调
这是驱动开发的最高境界:让调用者感知不到异步的存在,却享受异步带来的高效。
总结
回到开头那个温湿度传感器的例子。问题不在于“用了阻塞”,而在于“用错了阻塞”——在需要异步的地方死等,自然会卡死界面。
一句话总结:阻塞是“刚性”的,异步是“柔性”的。 刚性适合确定性强的场景,柔性适合需要响应外界变化的场景。
作为嵌入式工程师,我们要在用户体验(流畅度)和代码复杂度之间找到那个平衡点。既不能为了追求“异步正确”把代码写成天书,也不能为了省事一路死等让用户骂娘。
下次设计接口时,先问自己三个问题:
- 这个操作要多久?
- 代码运行在什么上下文?
- 异步带来的复杂度值得吗?
想清楚这三点,答案自然就出来了。