找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2137

积分

0

好友

299

主题
发表于 昨天 14:24 | 查看: 1| 回复: 0

引言:一个让 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 会切换到其他任务
  • 应用层看到的是线性逻辑,好写好调

这是驱动开发的最高境界:让调用者感知不到异步的存在,却享受异步带来的高效。

总结

回到开头那个温湿度传感器的例子。问题不在于“用了阻塞”,而在于“用错了阻塞”——在需要异步的地方死等,自然会卡死界面。

一句话总结:阻塞是“刚性”的,异步是“柔性”的。 刚性适合确定性强的场景,柔性适合需要响应外界变化的场景。

作为嵌入式工程师,我们要在用户体验(流畅度)和代码复杂度之间找到那个平衡点。既不能为了追求“异步正确”把代码写成天书,也不能为了省事一路死等让用户骂娘。

下次设计接口时,先问自己三个问题:

  1. 这个操作要多久?
  2. 代码运行在什么上下文?
  3. 异步带来的复杂度值得吗?

想清楚这三点,答案自然就出来了。




上一篇:WinBoat:基于容器化在Linux桌面无缝运行Windows应用的17.9k Star开源方案
下一篇:前端文件下载的多种实现方案:从a标签到Blob对象详解
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-18 18:12 , Processed in 0.233564 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表