1. 问题现象
最近在调试一个基于 FreeRTOS 和 Modbus 的嵌入式项目时,发现一个奇怪的现象:长按开发板上的复位键,松开后立即按按键,按键的响应变得非常迟钝。原本按下按键 LED 会立刻翻转,现在却要等好几秒才有反应。更诡异的是,如果等待一段时间(比如 10 秒)再按,按键又恢复正常了。
2. 复现步骤
-
系统正常运行,按键响应正常。
-
用手指长按复位键(硬件复位)约 2 秒后松开。
-
立即按下任意按键,观察 LED 或串口输出,发现响应极慢(延迟数秒)。
-
等待约 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 接收,让串口从错误中恢复。
cs
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:
cs
#define _RESPONSE_TIMEOUT 10000 // 响应超时 10ms(原可能为 1000000 微秒)
#define _BYTE_TIMEOUT 10000 // 字节超时 10ms
注意:这里的单位是微秒(µs),所以 10000 微秒 = 10 毫秒。缩短超时时间后,一次失败的 Modbus 通信最多等待 20 毫秒,大大减小了阻塞时间。
4.3 主控发出请求前增加延时
主控获得 Modbus 锁之后,先延时 20 毫秒再发送请求,给从机足够的时间退出超时等待状态,进入新一轮的帧接收。
修改前的代码片段(简化):
cs
xSemaphoreTake(g_ch1_Lock, portMAX_DELAY);
modbus_set_slave(ctx, 2);
rc = modbus_read_input_registers(ctx, 0, 2, vals);
xSemaphoreGive(g_ch1_Lock);
修改后:
cs
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);
在需要写从机的代码块中也做了同样的处理:
cs
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 节 增加容错代码