
简介针对STM32等ARM单片机平台这份代码实现了RTThread AT组件在FreeRTOS上的完整移植与修改适合希望在FreeRTOS工程中直接复用AT指令框架的嵌入式开发者常用于物联网模块、GSM/4G联网等串口通信场景。资源包共9个文件含5个C源码与4个头文件覆盖AT核心解析逻辑、环形队列缓存、串口驱动适配层及中断处理等关键模块压缩包仅18KB结构精简便于按模块直接替换。目前已有3413人学习下载。代码针对FreeRTOS的任务创建、软件定时器、互斥信号量、事件标志组和消息队列做了替换与对接同时保留RTThread风格的AT解析流程可帮助开发者清晰理解两套RTOS在任务调度、中断保护、线程安全及串口通信上的差异直接参考其驱动与队列适配方式能显著减少自行移植的调试时间也方便进一步扩展其他组件。1. 在 STM32 上把 RT-Thread 的 AT 组件移植到 FreeRTOS先看这颗硬骨头能不能啃做单片机开发的人迟早会遇到这么一件事业务代码全压在 FreeRTOS 上外设驱动、任务划分、内存规划都调顺了结果要接一个 4G Cat.1 或 WiFi 模组翻官方 demo 却发现人家用的是 RT-Thread 的 AT 组件。重写一套 AT 指令收发不难难的是把超时、锁、接收解析这些边角料处理干净。与其自己从零搓一个不成熟的 AT client不如把 RT-Thread 现成的 AT 组件拆出来移植到 FreeRTOS 上跑。这个思路听上去大胆实际操作起来也就改五六个文件核心是搞清楚它依赖了什么系统接口、每种接口用什么 FreeRTOS 原语去替换。本文就把这套移植改动的完整过程、映射关系和我踩过的坑一并写透适合正在 STM32 上做模组接入、又不愿意整体换 RTOS 的开发者。2. 为什么 AT 组件能移植它和内核之间只有三层薄耦合动手改代码之前先得弄明白一个关键问题AT 组件明明长在 RT-Thread 里凭什么能拔出来插进 FreeRTOS答案不在 AT 协议本身而在这套组件的设计边界上。2.1 组件内部的分层结构哪些文件一行都不用动RT-Thread 的 AT 组件大致分成三个层次最上层是 at_client / at_obj 这套 API负责把发一条命令、等一个响应这件事封装成几个函数中间是 at_parser负责把串口进来的字节流按行切分、识别 OK/ERROR/URC最底层是一组对操作系统的调用包括信号量、互斥量、线程创建、延时和 tick 获取。这里最值得庆幸的是at_parser 处理的是纯字节流和状态机它不直接调用任何 RTOS 接口完全靠传入的缓冲区和函数指针工作。也就是说只要底层把数据喂对它、超时时间给对它中间这一大坨解析逻辑是不用改的。实际移植中at_command 表、URC 表、AT response 的解析状态机这些核心代码几乎原样保留。我遇到的大多数移植工程真正改动量集中在两个文件一个 osal 适配层把 rt_sem_take、rt_mutex_create 之类翻译成 FreeRTOS API另一个是串口设备抽象层把 RT-Thread 的 rt_device_read 换成单片机自己的 UART 驱动。这两块搞定整个组件就跑起来了。2.2 耦合点逐个拆解互斥、通知、时间、设备抽象既然要移植就得把 AT 组件对操作系统的依赖点全部列出来一条条找替代方案。按我的经验不外乎下面四类第一类是互斥锁。AT 组件用互斥量来保证多条 AT 指令不会并发发送、响应不会互相串。RT-Thread 里的 rt_mutex 有优先级继承FreeRTOS 的互斥量只要用 xSemaphoreCreateRecursiveMutex 创建也具备互斥行为。第二类是线程阻塞与唤醒。接收线程在等串口数据时会被挂起数据到了信号量释放它才继续。RT-Thread 用 rt_sem_takeFreeRTOS 可以用二值信号量也可以用更省 RAM 的任务通知。第三类是时间获取。AT 组件判断响应超时用的是 rt_tick_get() 返回的 tick 计数。FreeRTOS 对应的是 xTaskGetTickCount()但有个大坑两个系统的 tick 频率可能不一样换算不对会把 3 秒超时变成 0.75 秒。第四类是设备读写。RT-Thread 的设备框架提供了 rt_device_read 和 rx_indicate 回调机制可以注册一个串口收到数据的回调然后由组件内部去读。FreeRTOS 没有设备模型这块得自己写一个桥接函数。2.3 为什么不直接整体迁到 RT-Thread一个现实的取舍有人会问既然 AT 组件是 RT-Thread 的干脆整个工程迁到 RT-Thread 不就完了这个思路在某些场景成立但在另一些场景会亏得很惨。FreeRTOS 的工程通常由 CubeMX 生成外设初始化、中断优先级、时钟树全部配好业务代码里几十个线程的优先级和栈大小都调过了整体迁移意味着所有驱动要重挂设备框架、所有线程要改用 RT-Thread 的创建方式、所有 IPC 要换成 RT-Thread 的信号量——这是一整个工程的返工。反过来把 AT 组件外迁到 FreeRTOS改动面只有上表那四类接口。说白了AT 组件本质上是一个阻塞等待 超时判断 串口读写的状态机它不挑调度器只挑接口。既然 FreeRTOS 提供了等价的互斥量、信号量、延时和 tick那它就能被翻译过去。这就是移植工作的理论支点不是把 AT 组件魔改成另一个东西而是给它造一个模拟 RT-Thread 系统调用的适配层让组件本身毫无感知地在 FreeRTOS 上跑。理解这一点遇到任何奇怪的编译错误第一反应就不是去翻解析逻辑而是检查适配层 API 是否漏了。3. 逐层改造适配层、串口桥接、线程迁移全流程理论立住了下面进入实操。我在某款 Cortex-M4 内核单片机上把 RT-Thread 的 AT 组件给 FreeRTOS 用整个改动过程分四个步骤每一步都有对应代码和参数说明。3.1 写一个 osal_port 适配层把 RT-Thread API 翻译成 FreeRTOS 原语第一步是建一个 osal_port.h 和 osal_port.c把 AT 组件源码里出现的 rt_sem_xxx、rt_mutex_xxx、rt_thread_mdelay 等接口逐一桥接。这个文件负责语法糖层面的翻译让 AT 组件的源文件在不改代码的情况下完成编译。/* osal_port.h —— 只列出 AT 组件实际用到的接口 */ #include FreeRTOS.h #include semphr.h #include task.h typedef SemaphoreHandle_t rt_sem_t; typedef SemaphoreHandle_t rt_mutex_t; /* 创建二值信号量RT-Thread 的 value 初始值可在 0/1 选 组件内部仅用二值语义因此直接映射 FreeRTOS 二值信号量 */ static inline rt_sem_t rt_sem_create(const char *name, uint32_t value, uint8_t flag) { (void)name; (void)flag; SemaphoreHandle_t sem xSemaphoreCreateBinary(); if (sem ! NULL value ! 0) { xSemaphoreGive(sem); } return sem; } static inline void rt_sem_take(rt_sem_t sem, uint32_t timeout) { TickType_t ticks (timeout RT_WAITING_FOREVER) ? portMAX_DELAY : pdMS_TO_TICKS(timeout); xSemaphoreTake(sem, ticks); } static inline void rt_sem_release(rt_sem_t sem) { BaseType_t needYield pdFALSE; /* 中断上下文必须走 FromISR 版本否则直接断言 */ if (xPortIsInsideInterrupt()) { xSemaphoreGiveFromISR(sem, needYield); portYIELD_FROM_ISR(needYield); } else { xSemaphoreGive(sem); } }这段代码里有三个参数细节需要注意一是 rt_sem_create 的 value 参数RT-Thread 原生支持计数信号量但 AT 组件里的 sem 只用 0/1 两种状态所以直接用 xSemaphoreCreateBinary 加一次 give 模拟初始值即可。二是在 rt_sem_release 里我用 xPortIsInsideInterrupt() 判断当前是否处于中断上下文这是因为 AT 组件发起信号量释放的位置既有线程上下文接收任务主动唤醒解析任务也有串口中断回调数据到达时释放而 FreeRTOS 明确要求中断里必须用 FromISR 版本搞混了行为完全不可预测。三是超时参数RT-Thread 的 RT_WAITING_FOREVER 对应 FreeRTOS 的 portMAX_DELAY毫秒级的超时用 pdMS_TO_TICKS 换算。3.2 串口设备桥接把 HAL 库中断接到信号量上FreeRTOS 没有 rt_device 框架所以原版 AT 组件里 rt_device_read、rt_device_set_rx_indicate 这些调用得换成自己写的串口驱动。我的做法是把接收部分做成一个中断收字节 环形缓冲 信号量通知的组合。/* uart_bridge.c —— 接收侧的桥接实现 */ static SemaphoreHandle_t s_rx_sem; static uint8_t s_rx_ringbuf[512]; /* 环形缓冲覆盖高速响应收数 */ static volatile uint16_t s_rx_head, s_rx_tail; /* 在串口空闲中断或 DMA 接收回调里调用 */ void uart_rx_isr_handler(uint8_t byte) { uint16_t next (s_rx_head 1) % sizeof(s_rx_ringbuf); BaseType_t needYield pdFALSE; /* 环形缓冲满则丢弃本次数据串口 AT 场景下宁可丢包不可覆盖 */ if (next ! s_rx_tail) { s_rx_ringbuf[s_rx_head] byte; s_rx_head next; } /* 唤醒 at_recv_thread中断里必须用 FromISR 版本 */ xSemaphoreGiveFromISR(s_rx_sem, needYield); portYIELD_FROM_ISR(needYield); }这段代码的要点在于环形缓冲的容量设计和满处理策略。我这里给了 512 字节是因为某些模组返回长响应比如信号量查询带上所有邻居小区信息时单条 URC 能到 400 字节左右512 可以容纳两三条指令的返回。满了之后我选择丢弃而不是覆盖旧数据核心原因是 AT 指令协议本质上是一问一答丢掉新数据会导致超时重发但覆盖旧数据会把响应切碎产生更难排查的粘包问题两条都痛但后者更难查。信号量 xSemaphoreGiveFromISR 这一步是整个移植里最容易写错的地方。很多刚从 RT-Thread 转过来的开发者下意识就在中断里调 xSemaphoreGive结果要么编译不过、要么运行十几分钟后任务卡死。原因在于 FreeRTOS 维护任务调度状态时不能在中断上下文里做上下文切换必须通过 FromISR 加 portYIELD_FROM_ISR 把切换动作推迟到中断退出时。3.3 接收线程改造用任务通知替代信号量原版 AT 组件有一个 at_recv_thread负责从串口设备读数据喂给解析器。它的核心逻辑就是永久阻塞等信号量拿到就去读设备。在 FreeRTOS 里这个线程可以直接用任务通知Task Notification改写比二值信号量还轻量。/* at_recv_thread 移植写法任务通知版 */ static void at_recv_thread(void *param) { uint32_t notify_value; for ( ;; ) { /* 用任务通知等唤醒pdTRUE 表示收到通知后清计数 */ notify_value ulTaskNotifyTake(pdTRUE, portMAX_DELAY); /* 中断里多次 give 会累计计数此处循环取完缓冲所有数据 */ while (notify_value 0) { /* 把环形缓冲中的数据取出送给 at_parser 处理 */ while (s_rx_head ! s_rx_tail) { uint8_t c s_rx_ringbuf[s_rx_tail]; s_rx_tail (s_rx_tail 1) % sizeof(s_rx_ringbuf); at_parser_input_byte(c); /* 组件原版解析函数 */ } notify_value--; } } }这里用任务通知而不用信号量有两个实际好处一是省 RAM每个二值信号量要独占一块内核对象内存任务通知是直接挂在 TCB 里的一个通知能省下几十字节二是自然支持数据累积语义中断多次 give 会累积成大于 1 的通知值信号量没有计数能力中断来两次信号量还是 1可能会导致把一次数据丢在缓冲里等下一次唤醒。但注意 ulTaskNotifyTake(pdTRUE, ...) 的 pdTRUE 参数它表示退出时清空通知值。如果改成 pdFALSE那通知值会一直维持非零状态下次进来直接不阻塞地空转这就是很多人移植完发现 CPU 占用率飙到 100% 的原因——清计数的语义在阻塞逻辑里至关重要。3.4 指令串行化与超时判断tick 换算别硬套AT 组件的指令发送部分靠互斥量保证互斥每发一条指令会等待指定响应、超时自动放弃。FreeRTOS 的互斥量直接用 xSemaphoreCreateRecursiveMutex 和 xSemaphoreTakeRecursive xSemaphoreGiveRecursive 即可参数上没有太大悬念真正容易翻车的是超时判断。AT 组件的原始代码里超时判断常见写法是拿 rt_tick_get() 做起点循环检查当前 tick 减起点是否大于某阈值。移植到 FreeRTOS 要等价替换/* 超时判断的移植写法 */ TickType_t start_tick xTaskGetTickCount(); TickType_t timeout_ticks pdMS_TO_TICKS(3000); /* 3 秒响应超时 */ while (xTaskGetTickCount() - start_tick timeout_ticks) { /* 检查响应是否到达、解析是否完成 */ if (at_parser_response_ready()) { break; } vTaskDelay(pdMS_TO_TICKS(10)); }这里最关键的一个参数是 pdMS_TO_TICKS(3000)。如果直接写成 3000在 configTICK_RATE_HZ 为 250Hz 的系统里等到的就是 12 秒而不是 3 秒反过来如果拿另一个工程的 1000Hz tick 配置直接套3000 个 tick 只相当于 3 秒是对的但一旦改了 tick 频率整个工程都错乱。我建议移植时给 AT 组件单独留一个宏统一写成 ms 单位内部一律走 pdMS_TO_TICKS千万别在组件源码里裸写数值。线程优先级也要顺手调一次RT-Thread 数字越小优先级越高FreeRTOS 是数字越大优先级越高所以原版的线程优先级参数要倒过来换算。我一般把 at_recv_thread 定在 FreeRTOS 中优先级让它在有数据就处理、没数据就睡的节奏下既不会饿死业务任务也不会在数据洪峰时抢占太多 CPU。你真设得太高URC 推送会把低优先级业务任务彻底饿住这是调度策略上很隐蔽的一刀。4. 移植避坑清单四个最容易翻车的地方理论推演再顺真刀真枪上板子跑的时候还是得掉几次坑。下面这四条是我给不同项目移植同一套组件时反复遇到的每一条都按现象、原因、解决三个步骤记录给你当排查手册用。4.1 串口中断里释放信号量编译没错但任务偶发卡死现象板子刚跑起来一切正常AT 指令收发顺畅但运行十几分钟后接收线程不再响应任何指令调试器挂上去发现 at_recv_thread 永久阻塞在调用 xSemaphoreTake 的地方而信号量计数始终为 0。原因串口中断回调里用了普通版本的 xSemaphoreGive。FreeRTOS 对中断上下文调用的 API 有严格限制普通 give 内部会访问调度器相关的临界资源在中断里执行轻则破坏临界区状态重则直接触发断言失败或让任务调度状态错乱表现形式就是看门狗超时或者任务永远卡死。解决中断回调一律改用 xSemaphoreGiveFromISR并检查 xPortIsInsideInterrupt() 别在非中断路径里也走 FromISR 版本。我的习惯是把当前是否在中断上下文的检查封装进 osal 适配层的 rt_sem_release 里这样 AT 组件源码碰不到这个隐患所有调用统一走适配层。4.2 tick 频率不统一3 秒超时变成了 0.75 秒现象模组响应本身很快但 AT 组件频繁报超时错误偶尔又出现响应还没到就判定失败的诡异情况整个收发节奏完全错乱。原因原版代码按 RT-Thread 默认的 1ms tick 写死了 tick 数值FreeRTOS 的 configTICK_RATE_HZ 在 CubeMX 生成的工程里默认可能是 1000也可能是 100 或 250。如果配置为 1000 就一切正常改成 100 之后 3 秒变成了 0.03 秒指令还没发完就被判超时。解决在适配层里强制做一次换算所有的超时时间都从毫秒经 pdMS_TO_TICKS 转成 tick不直接在使用处写 tick 数字。这个坑最麻烦的地方在于它不带报错只表现为行为偏差遇到这类问题第一件事就是打印 configTICK_RATE_HZ 确认当前配置再检查超时宏的换算链路。4.3 动态内存分配导致长时间运行后 AT 偶发失败现象设备连续运行数小时后AT 指令偶尔出现响应吞了一半的情况重启后又恢复正常且故障出现频率随运行时间增加而上升。原因FreeRTOS 的 heap_4 和 RT-Thread 自带堆的分配策略、碎片行为并不同AT 组件里如果有动态分配的对象长时间高频收发下内存碎片会逐渐上升一次较大的分配失败后组件会回退到异常分支表现为解析中断、丢失收数。附带还有一个隐蔽因素部分 FreeRTOS 工程为省内存把 heap_4 的大小设得很小AT 组件的 512 字节接收环形缓冲加解析缓冲一起分配可能直接把堆吃光。解决把 AT 组件涉及的关键缓冲区改成静态分配环形缓冲直接定义成全局数组、信号量互斥量都在适配层初始化时创建并持有句柄不进堆。这样无论在 heap_4 只有多少剩余内存的情况下AT 通道的收数路径都不会受动态分配影响。如果你一定要动态分配记住至少给 heap 预留 4KB 以上富余并且跑完 72 小时老化测试再验收。4.4 DMA 半满中断处理不及时长响应最后一个字符始终收不到现象短指令一切正常一旦模组返回长响应比如查询基站信息最后一个字符总是不见解析出的响应末尾丢失导致响应校验失败。原因DMA 双缓冲模式下半满中断触发时会拷贝数据到环形缓冲但拷贝期间新的数据仍在往 DMA 缓冲里写。若在中断里处理时间过长或有更高优先级中断打断新数据会覆盖未读区域造成丢尾。另外还常见一种情况DMA 接收配置的缓冲长度刚好卡在长响应的边界上最后几个字节在下一轮 DMA 传输里但下一个半满中断要等缓冲再次半满才触发导致尾部滞留。解决DMA 通道上使用半满 全满 溢出三事件全处理任何一个事件都做一次数据搬运并让半满中断处理和业务线程临界区相互保护。更省心的替代方案是放弃 DMA改成逐字节中断收数配合环形缓冲CPU 开销略高但在 115200 波特率下完全可接受换来的是不会再出现尾部滞留这种玄学问题。5. 验证移植成果用 DWT 做响应时延测量移植完了不能只靠看起来能跑下结论我一般会做一轮系统化的响应时延测量把 AT 指令从发出到收到 OK 的完整链路耗时量化出来。这个数据能同时验证三件事串口收数路径有没有丢字节、解析线程的调度延迟是否在可接受范围、信号量中断通知链路是否畅通。Cortex-M 内核自带 DWT 周期计数器用起来比逻辑分析仪还方便直接在代码里读寄存器就能拿到纳秒级的起始时间。/* 用 DWT 周期计数器测 AT 指令响应时延 */ void dwt_enable(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } uint32_t dwt_get_cycles(void) { return DWT-CYCCNT; } /* 在某条 AT 指令的发送和响应完成处各取一次时间 */ uint32_t start dwt_get_cycles(); at_obj_exec_cmd(at_obj, ATCSQ, 3 * 1000); uint32_t end dwt_get_cycles(); /* 主频 72MHz 时cycles 除以频率得秒数 */ float elapsed_ms (float)(end - start) / 72.0f / 1000.0f;这里 DWT-CYCCNT 的计数器在调试器复位后可能不自动使能所以必须在初始化代码里显式设置 TRCENA 和 CYCCNTENA 两个位。主频 72MHz 时一个周期约为 13.9ns3 秒的测量窗口会有 2 亿多个周期uint32_t 能覆盖不用担心溢出。我在几次实际移植中测到的数据大致是ATCSQ 这类基础指令的完整往返时延在 80ms 到 200ms 之间其中串口收数和解析耗时低于 2ms其余几乎全是模组内部处理时间。如果测出来的收数解析路径耗时超过 10ms基本可以断定移植层有调度问题——优先检查阻塞路径是否走了信号量而不是任务通知或中断里是否长时间关了中断。还有一个进阶技巧值得尝试给 at_recv_thread 设置一个可调的优先级参数然后分别测多个优先级下的响应时延观察差异。我自己在某次调试时发现一个反直觉的现象接收线程优先级下降后整体时延并没有显著变差反而是业务任务在高负载下的卡顿明显减少。这说明此前优先级设得太高AT 响应本身对实时性要求并不苛刻完全可以把接收线程压到中等优先级把 CPU 让给真正有硬实时要求的任务。移植完了做这么一轮测量和调参整个系统才算真正验收通过。那次因为把 at_recv_thread 优先级调得太高导致业务任务被长期饿住的教训我到现在还记得排查了整整两天最后靠打印每个任务的运行时间占比才定位到调度策略上。从那以后我每次移植都强制先量一遍 DWT 时延、再核对一遍优先级换算确认这两项没问题才敢把代码合进工程。这套流程也推荐给你希望帮到你。本文还有配套的精品资源点击获取