目录

1. 问题现象

2. 复现步骤

3. 原因分析

3.1 硬件复位的影响

3.2 主控重启后的通信行为

3.3 UART 错误状态

3.4 从机的超时等待

4. 解决方案

4.1 添加 UART 错误恢复代码

4.2 调整 libmodbus 的超时时间

4.3 主控发出请求前增加延时

5. 效果验证

6. 总结


1. 问题现象

最近在调试一个基于 FreeRTOS 和 Modbus 的嵌入式项目时,发现一个奇怪的现象:长按开发板上的复位键,松开后立即按按键,按键的响应变得非常迟钝。原本按下按键 LED 会立刻翻转,现在却要等好几秒才有反应。更诡异的是,如果等待一段时间(比如 10 秒)再按,按键又恢复正常了。

2. 复现步骤

  1. 系统正常运行,按键响应正常。

  2. 用手指长按复位键(硬件复位)约 2 秒后松开。

  3. 立即按下任意按键,观察 LED 或串口输出,发现响应极慢(延迟数秒)。

  4. 等待约 10 秒后再次按键,响应恢复正常。

3. 原因分析

3.1 硬件复位的影响

按下复位键时,主控 MCU 会立即重启,但挂在同一串口总线上的传感器模块(例如 Modbus 从机)可能没有复位。从机可能还处在之前通信的某个中间状态,比如正在等待主机响应超时,或者串口接收缓冲区卡在了错误状态。

3.2 主控重启后的通信行为

主控重启后,程序会重新初始化外设并开始运行。在任务中,通常会周期性地通过 Modbus 读取传感器数据。如果从机处于异常状态,Modbus 请求就会失败,而 libmodbus 默认的超时时间很长(原始值可能是 1 秒或更长),并且会重试。这导致主控卡在 Modbus 通信上,占用大量 CPU 时间,按键扫描任务的执行频率被严重拉低,从而感觉按键响应变慢。

3.3 UART 错误状态

更严重的情况是,复位过程中串口可能产生了帧错误、溢出错误等,导致 UART 外设进入错误中断,如果没有合理的恢复机制,后续的 DMA 接收将无法正常工作,所有串口通信都会失败。

3.4 从机的超时等待

从机在收到一个不完整的请求后,会启动超时计时,等待下一个字符或帧结束。如果主控复位后立即发送新请求,从机可能还在超时等待中,导致新请求被忽略。

4. 解决方案

针对以上原因,我们增加了三处容错代码,彻底解决了按键响应慢的问题。

4.1 添加 UART 错误恢复代码

当 UART 发生错误时,HAL 库会调用 HAL_UART_ErrorCallback 回调函数。我们在该回调中重新初始化 UART 并重新启动 DMA 接收,让串口从错误中恢复。

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    PUART_Data data = NULL;

    if (huart == &huart1) data = &g_uart1_data;
    if (huart == &huart2) data = &g_uart2_data;
    if (huart == &huart4) data = &g_uart4_data;

    if (data)
    {
        // 重新初始化 UART
        HAL_UART_DeInit(data->huart);
        HAL_UART_Init(data->huart);

        // 重新启动 DMA + IDLE 接收
        HAL_UARTEx_ReceiveToIdle_DMA(data->huart, data->rx_buf, RX_BUF_LEN);
    }
}

这样,一旦串口出现错误,硬件会自动恢复,不会一直卡在错误状态。

4.2 调整 libmodbus 的超时时间

libmodbus 内部有两个重要的超时参数:响应超时字节超时。默认值可能偏大(比如 1 秒),我们将其改小,避免长时间等待。

修改 Middlewares\Third_Party\libmodbus\modbus-private.h

#define _RESPONSE_TIMEOUT 10000   // 响应超时 10ms(原可能为 1000000 微秒)
#define _BYTE_TIMEOUT     10000   // 字节超时 10ms

注意:这里的单位是微秒(µs),所以 10000 微秒 = 10 毫秒。缩短超时时间后,一次失败的 Modbus 通信最多等待 20 毫秒,大大减小了阻塞时间。

4.3 主控发出请求前增加延时

主控获得 Modbus 锁之后,先延时 20 毫秒再发送请求,给从机足够的时间退出超时等待状态,进入新一轮的帧接收。

修改前的代码片段(简化):

xSemaphoreTake(g_ch1_Lock, portMAX_DELAY);
modbus_set_slave(ctx, 2);
rc = modbus_read_input_registers(ctx, 0, 2, vals);
xSemaphoreGive(g_ch1_Lock);

修改后:

xSemaphoreTake(g_ch1_Lock, portMAX_DELAY);
vTaskDelay(20);                      // 延时 20ms,让从机退出超时
modbus_set_slave(ctx, 2);
rc = modbus_read_input_registers(ctx, 0, 2, vals);
xSemaphoreGive(g_ch1_Lock);

在需要写从机的代码块中也做了同样的处理:

if (xSemaphoreTake(g_xBinarySemaphoreENV, 500) == pdTRUE)
{
    xSemaphoreTake(g_ch1_Lock, portMAX_DELAY);
    vTaskDelay(20);
    modbus_set_slave(ctx, 2);
    rc = modbus_write_bits(ctx, 0, 5, &g_mb_mapping->tab_bits[6]);
    xSemaphoreGive(g_ch1_Lock);
}

这样,即使从机还在超时等待中,20ms 后它也会退出并准备好接收新请求。

5. 效果验证

加入以上三点容错代码后,反复测试长按复位后立即按键,按键响应立刻恢复正常,再也没有延迟现象。通过串口日志观察,Modbus 通信在复位后第一次请求可能会失败(因为从机可能还在复位中),但很快就能恢复,并且每次失败仅消耗几十毫秒,不会影响按键扫描。

6. 总结

这个问题的本质是系统复位后外设状态不一致导致的通信故障,进而影响了实时性。嵌入式系统中,硬件复位并不等于所有外设都同步复位,因此我们需要在软件层面做好容错设计:

  • 错误恢复机制:UART、I2C 等外设发生错误时,要有自动恢复的代码。

  • 合理的超时设置:通信协议的超时不能太长,否则会拖累整个系统。

  • 给从机留缓冲时间:主从机之间要有一定的时序宽容度,避免“一复位就猛攻”。

通过这个案例,我深刻体会到:容错代码不是可有可无的补丁,而是保证系统健壮性的核心。希望这篇笔记能帮助自己日后复习,也能给遇到类似问题的朋友一点启发。


参考资料:百问网《嵌入式实战教程》7.8.2 节 增加容错代码

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐