1. 这不是“重传协议”,而是MCU资源受限场景下的生存策略
你有没有遇到过这样的情况:用GD32F103跑RT-Thread,串口发一条指令给外围模块,对方没回ACK,你立刻重发——结果第二次发出去的瞬间,第一次的ACK突然跳出来,系统直接卡死?或者更糟:重发三次后终于超时,但此时定时器中断里又触发了一次重发,任务栈被反复压入,最后硬 fault?这不是代码写错了,是把PC端TCP重传那一套,生搬硬套到MCU上撞了南墙。
“基于RTOS的无应答重发机制”这个标题,表面看是个通信容错方案,实则是一套在48KB Flash、20KB RAM、主频72MHz的硬约束下,用RTOS原语(信号量、定时器)构建的轻量级状态机。它不追求RFC标准,不兼容任何网络协议栈,只解决一个具体问题:当外设响应不可靠(比如老式打印机耗材芯片、工业传感器、国产触摸屏IC),且MCU没有足够资源跑LwIP或完整协议栈时,如何让一次关键操作(如写Flash参数、触发电机启停)真正落地?
关键词里没写,但所有实测过的人都知道:这套机制的核心矛盾从来不是“怎么重发”,而是“重发时系统不能瘫痪”。RT-Thread的rt_timer_create和rt_sem_take调用看似简单,但一旦在中断里误用信号量、定时器超时回调里调用阻塞API、或多个任务共用同一套重发上下文——轻则丢包,重则整个RTOS调度器失序。我去年在给某医疗设备做GD32F103移植时,就因为没处理好滴答定时器与重发定时器的优先级冲突,导致心电图采样中断被延迟12ms,差点触发FDA合规审查。所以这篇不是讲“如何实现重发”,而是讲在MCU有限资源下,用RTOS原语构建可预测、可调试、不崩盘的重发逻辑。适合正在用STM32F103/GD32F103/CH32V203跑RT-Thread或FreeRTOS,且需要对接非标外设的开发者。如果你还在用裸机while(1)轮询定时器计数器,这篇能帮你省下至少3天调试时间。
2. 为什么必须放弃“超时+重发”的直觉设计?
绝大多数初学者看到“无应答重发”,第一反应是:开个定时器,到期没收到ACK就重发。这在PC端开发中完全正确,但在MCU上,这个直觉会埋下三个致命陷阱。我们逐个拆解,用实际代码片段说明问题根源。
2.1 定时器中断里调用信号量的“静默崩溃”
假设你这样写:
// 错误示范:在定时器回调里直接take信号量 static void resend_timeout_handler(void* parameter) { struct resend_ctx* ctx = (struct resend_ctx*)parameter; // 试图在这里获取信号量,触发重发 rt_sem_take(ctx->ack_sem, RT_WAITING_FOREVER); // ⚠️ 危险! send_command(ctx->cmd); }问题在哪?rt_sem_take是阻塞API,而定时器回调运行在中断上下文。RTOS规定:中断服务程序(ISR)中禁止调用任何可能引起任务切换或阻塞的API。RT-Thread文档明确标注rt_sem_take的调用限制为“仅限线程上下文”。实测结果:GD32F103上这段代码编译能过,但运行时PendSV_Handler异常向量被意外触发,SCB->ICSR寄存器显示VECTACTIVE=0(无活动异常),系统看似正常,实则调度器已停止更新rt_system_tick,所有rt_thread_delay任务永久挂起。这种问题不会报错,只会让你花两天时间怀疑硬件晶振不准。
正确做法是:中断里只做最轻量的事——置位标志或唤醒通知。例如:
// 正确方案:中断里只发事件,由线程处理 static void resend_timeout_handler(void* parameter) { struct resend_ctx* ctx = (struct resend_ctx*)parameter; // 发送事件通知线程处理 rt_event_send(ctx->event, RESEND_TIMEOUT_EVENT); }然后在线程里统一处理:
// 线程主循环 while(1) { uint32_t recv_set; rt_event_recv(ctx->event, RESEND_TIMEOUT_EVENT | ACK_RECEIVED_EVENT, RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &recv_set); if (recv_set & RESEND_TIMEOUT_EVENT) { if (ctx->retry_count < MAX_RETRY) { send_command(ctx->cmd); ctx->retry_count++; // 重新启动定时器 rt_timer_start(ctx->resend_timer); } else { handle_failure(ctx); } } }2.2 “重发窗口”与“ACK窗口”的时间竞争
另一个常见错误是认为“重发定时器超时=没收到ACK”,从而忽略信号传播的物理延迟。以STM32通过USART1连接某国产温控模块为例:模块响应时间标称50ms,但实测波动在30~120ms之间。如果你设置重发定时器为60ms,会出现什么?
- 第0ms:发送命令
- 第30ms:模块开始处理,但尚未返回
- 第60ms:定时器超时,重发命令
- 第90ms:第一次ACK到达(此时重发命令已发出)
- 第120ms:第二次ACK到达(对应重发命令)
结果:系统收到两条ACK,但ack_sem只被take一次,第二次ACK被丢弃;更严重的是,如果ACK解析逻辑有状态依赖(如序列号校验),第二次ACK可能被误判为乱序包,触发错误状态机。我遇到的真实案例是:某客户产线设备因该问题,在高温环境下批量复位——根本原因是温控模块在70℃时响应延迟增至110ms,而固件重发阈值固定为80ms。
解决方案是引入双窗口机制:
resend_timeout:触发重发的阈值(设为最大预期延迟的1.5倍,如120ms×1.5=180ms)ack_window:ACK有效接收窗口(从发送时刻起算,设为resend_timeout + 20ms,即200ms)
关键点在于:ACK只能在ack_window内被接受,超时后到达的ACK一律丢弃。这需要记录每次发送的时间戳:
// 使用MCU内部RTC或SysTick获取时间戳 ctx->send_timestamp = rt_tick_get(); // 获取当前tick数(1ms精度) send_command(ctx->cmd); // 在ACK处理函数中 uint32_t now = rt_tick_get(); uint32_t elapsed = (now >= ctx->send_timestamp) ? (now - ctx->send_timestamp) : (0xFFFFFFFF - ctx->send_timestamp + now); if (elapsed <= ACK_WINDOW_MS) { // ACK_WINDOW_MS = 200 rt_event_send(ctx->event, ACK_RECEIVED_EVENT); } // 否则直接丢弃,不作任何处理提示:
rt_tick_get()返回的是系统tick计数,非绝对时间。若需更高精度(如us级),需启用DWT_CYCCNT寄存器(Cortex-M3/M4支持),但要注意GD32F103部分型号需先解锁调试寄存器。
2.3 多任务并发时的上下文污染
当多个外设(如同时控制电机和读取温湿度传感器)共用同一套重发机制时,最容易犯的错是共享resend_ctx结构体。例如:
// 全局变量,被多个任务访问 struct resend_ctx g_ctx; void motor_task_entry(void* param) { g_ctx.cmd = MOTOR_START_CMD; start_resend(&g_ctx); // 启动重发 } void sensor_task_entry(void* param) { g_ctx.cmd = READ_TEMP_CMD; start_resend(&g_ctx); // 覆盖了motor的cmd! }结果:电机任务刚发完指令,传感器任务一执行,g_ctx.cmd就被覆盖,重发时发送的是读温度指令而非电机启动指令。这种bug在单任务测试时绝不会暴露,只有多任务并行时才随机出现。
根治方法是每个通信通道独占一套上下文:
// 为每个外设分配独立ctx struct resend_ctx motor_ctx; struct resend_ctx sensor_ctx; // 初始化时分别创建 rt_sem_init(&motor_ctx.ack_sem, "motor_ack", 0, RT_IPC_FLAG_FIFO); rt_sem_init(&sensor_ctx.ack_sem, "sensor_ack", 0, RT_IPC_FLAG_FIFO); // 任务中使用各自ctx start_resend(&motor_ctx); // 电机任务 start_resend(&sensor_ctx); // 传感器任务内存代价极小:一个resend_ctx结构体约40字节(含信号量、定时器句柄、重试计数等),10个外设也仅占400字节RAM,远低于为“节省内存”而引发的调试成本。
3. 四层状态机:让重发逻辑可预测、可调试
裸机开发常用switch-case实现状态机,但在RTOS环境下,必须将状态流转与任务调度、事件通知深度耦合。我们设计的四层状态机不是为了炫技,而是解决三个实际痛点:1)避免状态遗漏导致无限重发;2)提供清晰的调试入口点;3)支持动态调整重试策略。状态定义如下:
| 状态码 | 名称 | 触发条件 | 关键动作 | 调试观察点 |
|---|---|---|---|---|
STATE_IDLE | 空闲态 | 初始化完成或上一次操作成功结束 | 清空重试计数,关闭定时器 | ctx->state == STATE_IDLE且ctx->retry_count == 0 |
STATE_SENDING | 发送中 | send_command()被调用 | 记录时间戳,启动重发定时器 | ctx->send_timestamp更新,rt_timer_is_started(ctx->resend_timer)返回true |
STATE_WAITING_ACK | 等待应答 | 定时器未超时,ACK未到达 | 静默等待事件 | ctx->state == STATE_WAITING_ACK且rt_event_recv阻塞中 |
STATE_RETRYING | 重试中 | 定时器超时且重试次数未达上限 | 递增计数,重新发送 | ctx->retry_count递增,send_command()被再次调用 |
3.1 状态迁移的原子性保障
状态变更必须在临界区保护,否则多任务并发时可能出现状态撕裂。例如:
// 错误:非原子操作 ctx->state = STATE_SENDING; ctx->send_timestamp = rt_tick_get(); rt_timer_start(ctx->resend_timer);若在第二行和第三行之间被高优先级任务抢占,send_timestamp已更新但定时器未启动,导致后续ACK判断永远失败。
正确写法(以RT-Thread为例):
// 使用临界区保护状态更新 rt_base_t level = rt_hw_interrupt_disable(); // 关中断 ctx->state = STATE_SENDING; ctx->send_timestamp = rt_tick_get(); rt_timer_start(ctx->resend_timer); rt_hw_interrupt_enable(level); // 开中断FreeRTOS用户应使用taskENTER_CRITICAL()/taskEXIT_CRITICAL()。注意:关中断时间必须极短(<10us),上述三行代码在72MHz GD32F103上实测耗时约1.2us,安全。
3.2 状态机与事件驱动的协同
状态机本身不主动推进,而是由事件驱动。核心事件流如下:
graph LR A[发送命令] --> B[进入STATE_SENDING] B --> C[启动定时器] C --> D[进入STATE_WAITING_ACK] D --> E{收到ACK?} E -->|是| F[进入STATE_IDLE] E -->|否| G[定时器超时] G --> H[进入STATE_RETRYING] H --> I{重试次数<MAX?} I -->|是| J[重新发送] J --> D I -->|否| K[进入STATE_IDLE并上报失败]注意:此流程图仅为逻辑示意,实际代码中不依赖mermaid渲染,所有状态迁移均通过
rt_event_recv返回的事件掩码判断。
关键实现细节:rt_event_recv必须使用RT_EVENT_FLAG_OR标志,允许同时等待多个事件(ACK到达、超时、手动取消)。例如:
// 等待任意一个事件 uint32_t recv_set; rt_event_recv(ctx->event, ACK_RECEIVED_EVENT | RESEND_TIMEOUT_EVENT | CANCEL_EVENT, RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &recv_set); switch(ctx->state) { case STATE_WAITING_ACK: if (recv_set & ACK_RECEIVED_EVENT) { ctx->state = STATE_IDLE; ctx->retry_count = 0; } else if (recv_set & RESEND_TIMEOUT_EVENT) { if (ctx->retry_count < MAX_RETRY) { ctx->state = STATE_RETRYING; send_command(ctx->cmd); ctx->retry_count++; rt_timer_start(ctx->resend_timer); } else { ctx->state = STATE_IDLE; report_failure(ctx); } } break; }3.3 调试桩:让状态机“开口说话”
生产环境禁用printf,但调试阶段必须有可观测性。我们在状态变更处插入轻量日志:
#define DBG_STATE(fmt, ...) \ do { \ if (RT_DEBUG_LEVEL >= RT_DEBUG_INFO) { \ rt_kprintf("[RESEND %s] %s: " fmt "\n", \ ctx->name, state_name(ctx->state), ##__VA_ARGS__); \ } \ } while(0) static const char* state_name(enum resend_state state) { switch(state) { case STATE_IDLE: return "IDLE"; case STATE_SENDING: return "SENDING"; case STATE_WAITING_ACK: return "WAITING_ACK"; case STATE_RETRYING: return "RETRYING"; default: return "UNKNOWN"; } } // 在状态变更处调用 DBG_STATE("retry_count=%d", ctx->retry_count);配合RT-Thread的rt_kprintf重定向到串口或SEGGER RTT,无需额外调试器即可实时查看状态流转。实测发现:某次客户现场问题,日志显示STATE_RETRYING连续出现7次,但retry_count始终为1——根源是send_command()函数内部未清零ctx->retry_count,导致每次重发都从1开始计数。这种细节,只有状态日志能快速暴露。
4. 定时器选型与滴答精度的实战博弈
MCU上可用的定时器资源丰富,但并非所有都适合重发机制。我们对比三种主流方案在GD32F103上的实测表现:
| 定时器类型 | 实现方式 | 精度 | 中断负载 | 适用场景 | 我的实测结论 |
|---|---|---|---|---|---|
| SysTick | 内核定时器,RT-Thread默认tick源 | 1ms(默认) | 低(已存在) | 通用场景 | 首选:无需额外配置,与RTOS tick同步,避免时钟漂移 |
| 基础定时器(TIM2/TIM3) | 通用定时器,需手动配置时基 | 可达10us | 中(新增中断) | 需要us级精度 | 次选:当ACK窗口需<5ms时启用,但需确保中断优先级高于SysTick |
| RTC闹钟 | 低功耗定时器,依赖LSE/LSI | 1s(最小) | 极低 | 电池供电设备的长周期重发 | 不适用:精度不足,无法满足通信级重发需求 |
4.1 为什么SysTick是默认最优解?
RT-Thread的rt_tick_get()本质就是读取SysTick的VAL寄存器。其优势在于:
- 零配置成本:RT-Thread初始化时已启用SysTick,无需额外
RCC_Enable或NVIC_SetPriority; - 天然同步:所有RTOS API(
rt_thread_delay,rt_sem_take)都基于SysTick,避免不同定时器源导致的时钟漂移; - 中断复用:SysTick中断已存在,重发逻辑复用该中断,不增加系统中断负载。
但SysTick有隐藏陷阱:其重装载值(RELOAD)决定tick精度。GD32F103默认SysTick配置为1ms tick(即RELOAD=72000,假设系统时钟72MHz)。若你修改了RT_TICK_PER_SECOND宏(如改为1000Hz),必须同步更新RELOAD值,否则rt_tick_get()返回值失真。我在移植Zephyr RTOS到CH32V203时踩过此坑:Zephyr默认10ms tick,但未修改SysTick RELOAD,导致k_uptime_get()返回值比实际快10倍,重发定时器提前10倍触发。
验证方法:用逻辑分析仪抓取SysTick中断引脚(需映射到GPIO),测量中断间隔是否等于预期值。
4.2 当必须用通用定时器时:TIMx的避坑清单
某些场景确实需要更高精度,例如:
- 与高速SPI外设通信,ACK需在200us内返回;
- 使用PWM模拟特定协议(如NEC红外),时序要求us级。
此时选用TIM2(基础定时器)为例,关键配置步骤:
// 1. 使能时钟 rcu_periph_clock_enable(RCU_TIMER2); // 2. 配置时基(目标:10us精度) timer_parameter_struct timer_initpara; timer_struct_para_init(&timer_initpara); timer_initpara.prescaler = 71; // PSC=71 → 72MHz/(71+1)=1MHz timer_initpara.alignedmode = TIMER_COUNTER_EDGE; timer_initpara.counterdirection = TIMER_COUNTER_UP; timer_initpara.period = 9; // PERIOD=9 → 1MHz/(9+1)=100kHz → 10us/tick timer_initpara.clockdivision = TIMER_CKDIV_DIV1; timer_init(TIMER2, &timer_initpara); // 3. 配置中断(优先级必须高于SysTick!) nvic_irq_enable(TIMER2_IRQn, 1, 0); // 抢占优先级1,子优先级0注意:GD32F103的SysTick默认抢占优先级为0(最高),因此TIM2中断优先级必须设为1或更低,否则TIM2中断会被SysTick抢占,导致重发定时器不准。
实测数据:在72MHz主频下,TIM2配置为10us精度时,实测误差<0.5us;但若优先级设置错误(设为0),误差飙升至±15us,直接导致ACK窗口判断失效。
4.3 时间戳的跨平台移植技巧
rt_tick_get()返回的是tick数,非绝对时间。若需计算绝对耗时(如记录某次重发耗时),必须转换为毫秒:
uint32_t get_elapsed_ms(uint32_t start_tick, uint32_t end_tick) { uint32_t delta; if (end_tick >= start_tick) { delta = end_tick - start_tick; } else { // 处理tick溢出(假设tick为32位无符号) delta = 0xFFFFFFFFUL - start_tick + end_tick + 1; } return delta; // 因为1tick=1ms,直接返回毫秒数 }FreeRTOS用户需替换为xTaskGetTickCount(),并注意其返回类型为TickType_t,需用portTICK_PERIOD_MS转换:
// FreeRTOS等效实现 TickType_t start_tick = xTaskGetTickCount(); // ... 执行操作 ... TickType_t end_tick = xTaskGetTickCount(); uint32_t elapsed_ms = (end_tick - start_tick) * portTICK_PERIOD_MS;5. 信号量与事件的抉择:何时用哪个?
RTOS中信号量(Semaphore)和事件集(Event)都能实现同步,但在此机制中,它们承担完全不同的角色。混淆二者是导致系统不稳定的主要原因。
5.1 信号量:专用于“单一确定性事件”的同步
信号量的核心语义是资源计数。在重发机制中,它唯一合法的用途是:表示“ACK已到达”这一确定性事件。因为ACK要么来,要么不来,不存在“部分到达”的概念。
正确用法:
// 初始化:初始值为0,表示无ACK rt_sem_init(&ctx->ack_sem, "ack_sem", 0, RT_IPC_FLAG_FIFO); // ACK到达时(在中断或线程中) rt_sem_release(&ctx->ack_sem); // 值+1 // 等待ACK(仅在线程中) rt_err_t result = rt_sem_take(&ctx->ack_sem, timeout_ms); if (result == RT_EOK) { // 成功收到ACK } else if (result == -RT_ETIMEOUT) { // 超时 }关键原则:信号量只能由一个生产者(ACK接收逻辑)释放,一个消费者(重发线程)获取。若多个外设共用同一信号量,必须加锁保护,否则计数错乱。
5.2 事件集:处理“多源、多条件”的复杂同步
当需要同时等待多个事件(ACK到达、用户取消、超时、错误中断)时,信号量力不从心。事件集的RT_EVENT_FLAG_OR模式天然支持:
// 定义事件掩码 #define ACK_RECEIVED_EVENT (1 << 0) #define RESEND_TIMEOUT_EVENT (1 << 1) #define CANCEL_EVENT (1 << 2) // 等待任意一个事件发生 uint32_t recv_set; rt_event_recv(ctx->event, ACK_RECEIVED_EVENT | RESEND_TIMEOUT_EVENT | CANCEL_EVENT, RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &recv_set); if (recv_set & ACK_RECEIVED_EVENT) { // 处理ACK } else if (recv_set & RESEND_TIMEOUT_EVENT) { // 处理超时 } else if (recv_set & CANCEL_EVENT) { // 处理取消 }事件集的优势在于:每个事件源独立置位,互不干扰。即使ACK和超时事件在同一毫秒内到达,recv_set也会同时包含两个位,不会丢失。
5.3 绝对禁止的混合用法
以下写法是重大隐患:
// ❌ 危险:在中断里调用rt_sem_take void usart_irq_handler(void) { if (usart_flag_get(USART0, USART_FLAG_RBNE) != RESET) { uint8_t data = usart_data_receive(USART0); if (data == ACK_BYTE) { rt_sem_take(&ctx->ack_sem, RT_WAITING_FOREVER); // 中断里阻塞! } } }正确替代方案:
// ✅ 中断里只发事件 void usart_irq_handler(void) { if (usart_flag_get(USART0, USART_FLAG_RBNE) != RESET) { uint8_t data = usart_data_receive(USART0); if (data == ACK_BYTE) { rt_event_send(ctx->event, ACK_RECEIVED_EVENT); // 轻量操作 } } }提示:RT-Thread事件集支持在中断中安全调用
rt_event_send,因其内部使用临界区保护,不涉及任务切换。
6. 实战部署:GD32F103+RT-Thread的完整代码骨架
以下代码基于RT-Thread 4.1.0,已在GD32F103VET6(1024KB Flash, 128KB RAM)上实测通过。重点展示可直接复制粘贴的初始化、发送、状态管理三段核心代码,省略硬件驱动细节(USART初始化等)。
6.1 上下文结构体与初始化
#include <rtthread.h> #include <rtdevice.h> // 重发上下文结构体 struct resend_ctx { char* name; // 调试标识名 uint8_t cmd[64]; // 待发送命令缓冲区 uint16_t cmd_len; // 命令长度 rt_event_t event; // 事件集句柄 rt_timer_t resend_timer; // 重发定时器 rt_sem_t ack_sem; // ACK信号量(备用,仅用于简单场景) enum resend_state state; // 当前状态 uint32_t send_timestamp; // 发送时间戳(tick) uint8_t retry_count; // 已重试次数 uint8_t max_retry; // 最大重试次数 }; // 初始化函数 rt_err_t resend_ctx_init(struct resend_ctx* ctx, const char* name, uint8_t max_retry) { if (!ctx || !name) return -RT_ERROR; ctx->name = (char*)name; ctx->max_retry = max_retry; ctx->retry_count = 0; ctx->state = STATE_IDLE; // 创建事件集 ctx->event = rt_event_create(name, RT_IPC_FLAG_FIFO); if (ctx->event == RT_NULL) { return -RT_ERROR; } // 创建重发定时器(软件定时器) ctx->resend_timer = rt_timer_create( name, resend_timeout_handler, (void*)ctx, 100, // 100ms初始超时(可动态调整) RT_TIMER_FLAG_ONE_SHOT | RT_TIMER_FLAG_SOFT_TIMER ); if (ctx->resend_timer == RT_NULL) { rt_event_delete(ctx->event); return -RT_ERROR; } // 创建ACK信号量(可选) rt_sem_init(&ctx->ack_sem, name, 0, RT_IPC_FLAG_FIFO); return RT_EOK; } // 反初始化 void resend_ctx_deinit(struct resend_ctx* ctx) { if (!ctx) return; rt_timer_delete(ctx->resend_timer); rt_event_delete(ctx->event); rt_sem_detach(&ctx->ack_sem); }6.2 发送与状态推进函数
// 发送命令并启动重发机制 rt_err_t start_resend(struct resend_ctx* ctx, const uint8_t* cmd, uint16_t len, uint32_t timeout_ms) { if (!ctx || !cmd || len == 0 || len > sizeof(ctx->cmd)) { return -RT_ERROR; } // 复制命令到上下文 rt_memcpy(ctx->cmd, cmd, len); ctx->cmd_len = len; // 进入发送状态(临界区保护) rt_base_t level = rt_hw_interrupt_disable(); ctx->state = STATE_SENDING; ctx->send_timestamp = rt_tick_get(); ctx->retry_count = 0; rt_hw_interrupt_enable(level); // 发送命令(此处调用你的USART发送函数) usart_transmit_cmd(cmd, len); // 启动重发定时器 rt_timer_control(ctx->resend_timer, RT_TIMER_CTRL_SET_TIME, &timeout_ms); rt_timer_start(ctx->resend_timer); // 进入等待状态 ctx->state = STATE_WAITING_ACK; return RT_EOK; } // ACK接收处理函数(在USART接收中断或线程中调用) void handle_ack_received(struct resend_ctx* ctx) { if (!ctx || ctx->state != STATE_WAITING_ACK && ctx->state != STATE_RETRYING) { return; // 不在等待状态,忽略ACK } uint32_t now = rt_tick_get(); uint32_t elapsed = (now >= ctx->send_timestamp) ? (now - ctx->send_timestamp) : (0xFFFFFFFFUL - ctx->send_timestamp + now); // 只在ACK窗口内接受 if (elapsed <= ACK_WINDOW_MS) { rt_event_send(ctx->event, ACK_RECEIVED_EVENT); // 同时释放信号量(双重保障) rt_sem_release(&ctx->ack_sem); } // 超出窗口的ACK直接丢弃,不作任何处理 }6.3 重发线程主循环
// 重发线程入口函数 void resend_thread_entry(void* parameter) { struct resend_ctx* ctx = (struct resend_ctx*)parameter; while(1) { uint32_t recv_set; // 等待事件(ACK、超时、取消) rt_err_t result = rt_event_recv(ctx->event, ACK_RECEIVED_EVENT | RESEND_TIMEOUT_EVENT | CANCEL_EVENT, RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR, RT_WAITING_FOREVER, &recv_set); if (result != RT_EOK) continue; switch(ctx->state) { case STATE_WAITING_ACK: if (recv_set & ACK_RECEIVED_EVENT) { // 成功收到ACK DBG_STATE("ACK received, elapsed=%dms", (uint32_t)(rt_tick_get() - ctx->send_timestamp)); ctx->state = STATE_IDLE; ctx->retry_count = 0; // 通知上层业务 on_command_success(ctx); } else if (recv_set & RESEND_TIMEOUT_EVENT) { // 超时,准备重发 if (ctx->retry_count < ctx->max_retry) { ctx->state = STATE_RETRYING; DBG_STATE("timeout, retrying... (count=%d)", ctx->retry_count); usart_transmit_cmd(ctx->cmd, ctx->cmd_len); ctx->retry_count++; // 重置定时器(指数退避可在此处实现) uint32_t new_timeout = 100 * (1 << ctx->retry_count); // 100ms, 200ms, 400ms... rt_timer_control(ctx->resend_timer, RT_TIMER_CTRL_SET_TIME, &new_timeout); rt_timer_start(ctx->resend_timer); } else { ctx->state = STATE_IDLE; DBG_STATE("max retry reached, giving up"); on_command_failure(ctx); } } break; case STATE_RETRYING: // 重发后仍处于STATE_RETRYING,等待下一次事件 // 此处可添加重发间隔抖动,避免多设备同时重发碰撞 break; default: // 其他状态不处理事件 break; } } } // 创建重发线程示例 int main(void) { struct resend_ctx motor_ctx; // 初始化上下文 resend_ctx_init(&motor_ctx, "motor", 3); // 创建重发线程(优先级设为10,高于普通任务) rt_thread_t tid = rt_thread_create("resend_motor", resend_thread_entry, &motor_ctx, 512, 10, 10); if (tid != RT_NULL) { rt_thread_startup(tid); } // 启动重发 uint8_t start_cmd[] = {0x01, 0x02, 0x03}; start_resend(&motor_ctx, start_cmd, sizeof(start_cmd), 150); return 0; }7. 老司机的12条血泪经验
这些不是教科书里的理论,而是我在GD32F103、STM32F103、CH32V203上累计调试237个外设通信模块后,用烧坏的3块开发板换来的经验。每一条都对应一个真实翻车现场。
永远不要相信外设手册的响应时间:某国产触摸IC手册写“响应时间≤20ms”,实测高温下达85ms。我的对策:在产线测试阶段,用逻辑分析仪抓100次响应时间,取P95值(95%分位数)作为
resend_timeout基准,再乘以1.3安全系数。重发定时器的超时值必须动态可调:固定100ms在实验室可行,但产线环境温湿度变化会导致外设响应漂移。我在
resend_ctx中增加了timeout_ms字段,通过串口指令AT+RESEND=200实时修改,避免每次改固件。ACK校验必须包含序列号或时间戳:曾遇到某打印机耗材芯片,连续两次发送相同命令,它返回相同的ACK(无序列号)。结果重发后收到旧ACK,系统误判成功。解决方案:在命令末尾追加1字节递增序列号,ACK中回传该序列号。
在
send_command()里加入发送前自检:GD32F103的USART发送寄存器有时会卡死(硬件bug)。我在发送前添加:if (usart_flag_get(USART0, USART_FLAG_TC) == RESET) { // 发送完成标志未置位,强制复位USART usart_deinit(USART0); usart_init(USART0, &usart_config); }重试次数达到上限后,必须执行硬件复位或状态隔离:某客户设备在重试3次失败后,继续发送其他命令,结果外设进入不可恢复的busy状态。现在我的
on_command_failure()会调用reset_peripheral()函数,拉低外设复位引脚100ms。为每个外设分配独立的重发线程:不要图省事用一个线程管所有外设。曾因电机任务阻塞导致温湿度传感器ACK丢失,最终发现是线程栈溢出(
rt_thread_create时栈大小设为256字节,实际需512)。在
resend_timeout_handler里添加看门狗喂狗:软件