☰
RT-Thread AT组件移植FreeRTOS实战:IPC原语替换与URC机制保留
2026/10/20 4:49:35 网站建设 项目流程

简介:面向基于 STM32/ARM 单片机、具备一定 FreeRTOS 基础的中高级嵌入式开发者,该资源将 RTThread 生态中的 AT 组件改造成可运行于 FreeRTOS 的移植代码,解决 GSM/GPRS、蓝牙等物联网模块的 AT 命令串口通信在两种 RTOS 间的复用问题。压缩包共 9 个文件,由 5 个 C 文件和 4 个头文件构成,覆盖 UART 驱动、AT 核心逻辑、数组队列、工具函数以及 STM32F4 中断例程(stm32f4xx_it.c),各模块以独立文件组织,便于对照源码理解硬件驱动如何适配,也方便在现有工程中替换或二次修改。包体仅 18KB,轻量紧凑,当前已有 3413 人学习下载。读者不仅可以拿到一套可运行的代码骨架,还能从具体文件实现中看到消息队列、事件标志组、互斥锁、定时器模拟和串口驱动等 RTOS 移植要点的落地方式,帮助少走弯路,适合正在将 RTThread 组件迁移到自家项目的开发者参考。

1. 把rtthread的at组件搬到freeRTOS:无线模组协议栈不用重写的移植方案

某开发者的项目要从RT-Thread整体切到FreeRTOS,卡住的第一关不是任务调度,而是原来那套rtthread的at组件和IPC机制绑得太死——信号量、邮箱、互斥量散落在收发模块的各个角落,直接搬过来编译能过,一上电就卡死在信号量上。这篇文章记录的是把at组件在freeRTOS上的完整移植修改代码:信号量换掉、邮箱换队列、URC解析机制原样保留,在STM32平台配合ESP8266模块跑通了TCP连接和固件升级。适合正在做FreeRTOS加串口模组的开发者,也适合想搞明白AT指令框架内部设计的人。整个替换过程没有玄学,每一个改动都能找到对应的原语关系。

2. 组件解剖:先看清AT客户端的工作链路与三条换血线

别急着动代码,先把at组件在RT-Thread上到底怎么运转搞清楚,移植才能有条理。AT组件本质上是一个可复用的AT指令客户端,它对外提供命令发送、响应等待、URC上报三组能力。这三组能力分别挂在不同的IPC原语上,移植的核心工作就是把这些原语逐个替换成FreeRTOS对应的机制。

2.1 AT组件内部"发命令、等响应、收URC"的运转链路

命令发送走的是at_obj_exec_cmd这类封装接口,它把格式化好的字符串通过串口发出去,然后阻塞等待模组返回OK或ERROR。这个等待是靠信号量完成的:AT客户端里有一个resp_sem,发送命令的线程take这个信号量,串口数据解析线程在收到结束符时give。一来一回就是典型的任务间同步。

URC识别是另一条通路。模组随时可能主动推送+IPD、+CWJAP这类数据,和当前命令的响应没有关系。AT组件维护了一张urc_table,接收解析线程每收到一行数据,就去表里做前缀匹配,命中后调用对应的回调函数。这张表的匹配和分发逻辑完全不依赖操作系统,可以原封不动搬到FreeRTOS里。

响应数据和URC解析完以后,还需要把整包数据送给业务任务。原版用的是邮箱rt_mailbox,FreeRTOS没有邮箱这个概念,最接近的替代品是队列QueueHandle_t。这个替换是移植中最容易出细节问题的地方,后面第3章会专门讲。

2.2 移植前先列清单:IPC、内存、时间三条换血线

IPC是最大头。RT-Thread的信号量、互斥量、邮箱、事件集,都要替换成FreeRTOS对应物。AT组件里实际用到的只有三种:信号量(等响应)、互斥量(保护串口)、邮箱(投递数据包)。FreeRTOS这边分别对应二值信号量、递归互斥量、队列。

内存这条线也要理清楚。RT-Thread用rt_malloc分配AT客户端和缓冲区,FreeRTOS里要换成pvPortMalloc。如果工程里只在固定任务里创建客户端,也可以直接用静态数组,彻底绕开堆分配的不确定性。我一般建议先用pvPortMalloc保持原代码结构,跑通以后再考虑是否改成静态内存。

时间线最容易踩坑。rt_sem_take的超时参数单位是RT-Thread的tick,FreeRTOS的xSemaphoreTake用的是TickType_t,两边时钟节拍不一定一致。RT-Thread默认tick是1000Hz,FreeRTOS的configTICK_RATE_HZ常见配置是100Hz或者1000Hz。不做换算的话,会出现"等1秒结果半秒就超时"这种只能靠调试器抓出来的诡异现象。

2.3 文件级映射:哪些文件直接拷贝、哪些文件必须重写

以典型at组件源码为例,涉及的文件大概有at_client.c、at_client.h、at_uart.c、at_device.c以及设备目录下的esp8266.c、m26.c这些。我在实际移植时的处理方式如下:

源文件处理方式原因
rt_ringbuffer.c / rt_ringbuffer.h直接拷贝纯环形缓冲数据结构,不依赖OS API
at_device.c / at_device_esp8266.c基本不动只调用AT客户端公开接口,不直接碰IPC
at_client.c重点改造信号量、互斥量、邮箱全部集中在这里
at_uart.c底层重写串口中断处理要改成FreeRTOS FromISR接口

表里最值得琢磨的是at_client.c。这个文件里有rt_sem_create、rt_mutex_create、rt_mailbox_create三个创建函数,以及对应的take/release/send操作。把它们一对一换成FreeRTOS写法,整个移植就完成了一大半。第3章逐行展示这些代码的修改过程。

3. 核心代码修改对照:从rt_sem到FreeRTOS信号量的逐行推敲

这一章是整篇移植文章的重头戏。我会按"原代码是什么样的、改成什么样、为什么这样改"的顺序,把AT客户端里所有涉及IPC的代码过一遍。

3.1 结构体里的IPC成员怎么重新定义

先看原版结构体。RT-Thread的AT客户端结构体里挂着信号量、互斥量、邮箱三个成员:

/* 原版:RT-Thread 上的 at_client 结构体(节选) */ struct at_client { rt_uint8_t *buffer; rt_size_t buffer_size; rt_uint16_t recv_len; rt_sem_t resp_sem; /* 响应等待信号量 */ rt_mutex_t lock; /* 串口发送互斥锁 */ rt_mailbox_t recv_mb; /* 数据包投递邮箱 */ struct rt_ringbuffer rx_rb; /* 接收环形缓冲 */ struct at_urc *urc_table; rt_uint16_t urc_size; };

改成FreeRTOS版本,核心变化是把三个OS对象类型换成FreeRTOS句柄:

#include "FreeRTOS.h" #include "semphr.h" #include "queue.h" /* 用队列替代邮箱时,投递的是这样一个消息结构 */ typedef struct at_response { uint8_t *data; uint16_t len; } at_response_t; /* 适配后的 at_client 结构体 */ struct at_client { uint8_t *buffer; uint32_t buffer_size; uint16_t recv_len; SemaphoreHandle_t resp_sem; /* 二值信号量:数据到达时 give */ SemaphoreHandle_t lock; /* 递归互斥量:保护串口发送 */ QueueHandle_t recv_q; /* 队列:替代原 recv_mb 邮箱 */ struct rt_ringbuffer rx_rb; /* 环形缓冲保留原实现 */ struct at_urc *urc_table; uint16_t urc_size; };

代码里的struct rt_ringbuffer是纯数据结构的环形缓冲,本身不依赖RT-Thread内核,直接保留。lock从普通互斥量改成递归互斥量这一点容易被忽略,原因在后面第3.3节会展开。

3.2 二值信号量原语替换:创建、获取、释放

原版初始化代码里,信号量的创建是rt_sem_create,释放是rt_sem_release。FreeRTOS对应的是xSemaphoreCreateBinary和xSemaphoreGive。

/* 原版:RT-Thread 创建响应信号量 */ at->resp_sem = rt_sem_create("at_resp", 0, RT_IPC_FLAG_FIFO); /* 适配后:FreeRTOS 创建二值信号量 */ at->resp_sem = xSemaphoreCreateBinary(); if (at->resp_sem == NULL) { /* 创建失败,一般是堆内存不足 */ return -1; }

等待响应的代码原本是rt_sem_take(at->resp_sem, timeout),改成FreeRTOS后变成:

/* 等待模组返回 OK / ERROR,timeout 单位需要从 RT-Thread tick 换算 */ if (xSemaphoreTake(at->resp_sem, at_rt_timeout_to_ticks(timeout)) != pdPASS) { /* 超时,命令发送失败 */ return AT_ERR_TIMEOUT; }

二值信号量的语义在这里正好合适。命令发出后,这个信号量是空的,接收线程收到结束符时give一次,等待线程就能被唤醒。需要注意的是,二值信号量不会累计计数,如果连续收到多个结束符,信号量仍然只有0和1两个状态。AT响应等待的语义只需要一次唤醒,所以二值信号量够用。如果业务场景里需要累计计数,应该换成xSemaphoreCreateCounting,这是后话。

3.3 递归互斥量:为什么锁必须是递归的

原版AT组件用rt_mutex_take保护串口发送,因为AT命令的封装函数存在嵌套调用。比如发送"AT+CIPSTART"时,外层函数拿到了锁,内部又调用了一个需要锁的辅助函数,普通互斥量在同一个线程里二次获取会直接死锁。RT-Thread的互斥量本身支持递归获取,而FreeRTOS的普通互斥量不支持,所以这里必须用递归版本。

/* 适配后:创建递归互斥量 */ at->lock = xSemaphoreCreateRecursiveMutex(); if (at->lock == NULL) { return -1; } /* 发送命令前加锁 */ xSemaphoreTakeRecursive(at->lock, portMAX_DELAY); /* 往串口写数据,这一步是实际硬件操作 */ uart_write_bytes(at->uart, cmd, cmd_len); /* 发送完释放锁 */ xSemaphoreGiveRecursive(at->lock);

这里的portMAX_DELAY表示无限等待,和原版的RT_WAITING_FOREVER语义一致。实测中如果把递归互斥量换成普通互斥量,命令嵌套一深,板子就会卡死在锁上,调试器一看是两个任务互相等锁,非常典型。

3.4 队列替换邮箱:AT响应按包投递给业务任务

原版用rt_mailbox把AT解析出来的数据包投递给业务任务,邮箱一次只能放一个rt_ubase_t大小的数据。FreeRTOS的队列更灵活,可以放结构体。这里定义了一个at_response_t来装数据指针和长度:

/* 原版:rt_mb_send 投递数据包 */ rt_mb_send(at->recv_mb, (rt_ubase_t)pkg); /* 适配后:xQueueSend 投递消息结构体 */ static int at_post_response(struct at_client *at, uint8_t *data, uint16_t len) { at_response_t msg = { data, len }; BaseType_t ret = xQueueSend(at->recv_q, &msg, 0); return (ret == pdPASS) ? 0 : -1; }

接收侧的业务任务用xQueueReceive取数据:

at_response_t msg; if (xQueueReceive(at->recv_q, &msg, portMAX_DELAY) == pdPASS) { /* 把数据交给上层业务处理 */ handle_at_packet(msg.data, msg.len); }

队列长度我配置成4,原因是一次命令交互过程中最多堆积几条响应消息。如果队列太深浪费内存,太浅会导致业务任务处理不过来时丢消息。这个值可以根据业务任务的处理速度调整。

3.5 Tick换算:超时参数不能直接搬

这是最容易翻车的地方。RT-Thread默认tick是1ms,rt_sem_take的timeout参数单位是tick。FreeRTOS的tick频率由configTICK_RATE_HZ决定,如果是100Hz,一个tick就是10ms。直接把timeout传进去,等于超时时间被放大10倍或者缩小10倍。

/* Tick 换算函数:把 RT-Thread tick 超时转成 FreeRTOS tick */ static TickType_t at_rt_timeout_to_ticks(uint32_t rt_timeout) { if (rt_timeout == RT_WAITING_FOREVER) { return portMAX_DELAY; } /* RT-Thread 默认 tick 1000Hz,FreeRTOS 按实际配置换算 */ #if (configTICK_RATE_HZ == 1000) return (TickType_t)rt_timeout; #else return (TickType_t)(rt_timeout * 1000 / configTICK_RATE_HZ); #endif }

我在移植时把工程里的configTICK_RATE_HZ统一配成1000Hz,和RT-Thread保持一致,换算函数就直接走第一个分支。如果工程里因为省电需求必须用100Hz,那这个换算函数就是唯一需要改的地方。这里加一个#if预编译分支,工程切配置的时候不用回头改业务代码。

4. URC解析与接收任务:让模组主动上报的数据接得住

URC机制在整个移植里改动最小,但却是最容易出问题的部分。模组主动上报的+IPD数据是不请自来的,如果接收线程没有及时处理,数据就会堆积在环形缓冲里,后续命令的响应被淹没,表现出来就是"发AT命令经常丢返回"。

4.1 URC表结构和前缀匹配逻辑

原版AT组件的URC表是一个结构体数组,每个表项包含一个前缀字符串和对应的回调函数:

/* URC 表项定义 */ struct at_urc { const char *prefix; /* 前缀,例如 "+IPD" */ void (*func)(const char *data, uint32_t size); };

接收解析线程每收到一行完整数据,就遍历这张表做匹配。原版实现用的是strstr模糊匹配,我在移植时改成了memcmp前缀匹配,原因稍后说。

/* URC 匹配:按前缀精确匹配,避免误命中 */ static int at_urc_check(struct at_client *at, const char *line, uint32_t len) { for (uint16_t i = 0; i < at->urc_size; i++) { const struct at_urc *u = &at->urc_table[i]; uint32_t plen = strlen(u->prefix); /* 整行长度必须不小于前缀长度 */ if (len >= plen && memcmp(line, u->prefix, plen) == 0) { /* 命中 URC,调用业务回调 */ if (u->func) { u->func(line, len); } return 0; } } return -1; }

改成前缀匹配的原因是实际踩过坑。ESP8266的+IPD后面会跟数据长度,比如+IPD,4:ABCD,用strstr匹配+IPD没问题。但有些模组会上报+IPD_EXT这类扩展消息,strstr会把+IPD也命中,导致回调收到一条不属于自己的数据。用memcmp做前缀匹配,+IPD_EXT就不会被+IPD这一项误捕获。

4.2 接收任务的处理循环怎么写

接收任务是整个AT组件的心脏。它等待串口中断给出的信号量,然后从环形缓冲里逐字节读出数据,按行切分,先做URC匹配,再做命令响应匹配:

/* FreeRTOS 接收任务主体 */ static void at_recv_task(void *arg) { struct at_client *at = (struct at_client *)arg; uint8_t ch; for (;;) { /* 等待串口中断 give 信号量 */ xSemaphoreTake(at->resp_sem, portMAX_DELAY); /* 把环形缓冲里的数据逐字节取出 */ while (rt_ringbuffer_getchar(&at->rx_rb, &ch) == 0) { at_parse_char(at, ch); } } }

at_parse_char内部维护一个行缓冲,遇到\r或\n就认为一行数据结束,然后把这一行交给URC匹配和响应解析两个模块处理。这个函数在RT-Thread版本里本身就存在,移植时不需要改动,因为它只操作内存缓冲和调用解析函数,不涉及任何OS API。

任务创建用xTaskCreate,优先级和栈大小是关键:

xTaskCreate(at_recv_task, "at_recv", 1024, (void *)&at_client, 5, NULL);

任务栈大小我给了1024字(4KB)。AT解析过程有局部数组和格式化操作,栈给太小会出现随机死机,给太大浪费RAM。ESP8266场景下1024字够用,如果后续要解析大型+IPD数据包,建议按数据包大小重新评估。

4.3 串口中断的FromISR改造

原版AT组件的串口中断处理函数直接调rt_sem_release,RT-Thread的信号量释放函数在中断上下文是可以用的。FreeRTOS则必须使用带FromISR后缀的API:

/* 适配后:串口中断里用 FromISR 版本 */ void USARTx_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; uint8_t ch; while (uart_rx_data_available()) { ch = uart_read_byte(); /* 数据写入环形缓冲,这一步不需要 FromISR,因为 rt_ringbuffer 本身是纯数据结构,单生产者单消费者场景下天然安全 */ rt_ringbuffer_putchar(&at_client.rx_rb, ch); /* 唤醒接收任务,必须用 FromISR 版本 */ xSemaphoreGiveFromISR(at_client.resp_sem, &xHigherPriorityTaskWoken); } /* 如果唤醒的任务优先级高于当前任务,立即切换 */ portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }

这里有个性能细节:每收到一个字节就give一次信号量,会带来不必要的上下文切换开销。我一般会在中断里先检查是否收到换行符\n,只有收到换行符时才give信号量,这样接收任务一次醒来就能处理一整行数据。这个优化不影响正确性,但能让CPU占用率明显下降,尤其在+IPD大流量数据场景。

5. 避坑与排查:FreeRTOS上跑AT组件最容易翻车的五个地方

移植完以后调试了整整一周,把几个典型的翻车现场记录下来。每条都是"现象、原因、解决"的结构,照着排查能省不少时间。

5.1 现象:上电后第一个AT命令就卡死

发AT命令后任务永远阻塞在等待响应的地方,调试器暂停能看到任务停在xSemaphoreTake上。

原因是二值信号量创建后的初始状态是空的,也就是"无信号"。如果初始化时没有搞清楚状态,有些移植者会在创建后习惯性give一次,导致第一个命令不需要等待就立刻往下走,逻辑错乱。反过来,如果接收线程没有正确启动,信号量永远是空的,命令就会卡死。

解决方法是检查两处:一是确认at_recv_task真的被xTaskCreate创建了,二是确认串口中断的xSemaphoreGiveFromISR在收到\r或\n时确实执行了。我习惯在创建信号量后加一个断言,配合调试器看信号量句柄是否有效。

5.2 现象:能收到OK,但执行带超时的AT命令时报超时

现象是部分命令正常,部分命令不稳定,有时候能过有时候超时。

原因是tick换算没有统一。AT组件里有的超时参数默认是RT-Thread tick,有的直接是秒数乘1000换算的,混在一起后,FreeRTOS的configTICK_RATE_HZ配置一变,超时时间就对不上了。

解决办法是全局搜索rt_sem_take和xSemaphoreTake,把所有超时参数的来源统一走at_rt_timeout_to_ticks函数。同时强制规定,业务层传入的超时一律用毫秒单位,不让上层感知底层tick是多少。

5.3 现象:串口中断一进就死机,死在configASSERT

系统直接卡死在configASSERT(xQueueGiveFromISR之类的断言上,或者完全跑飞。

原因是中断服务函数里调用了非FromISR结尾的信号量API。RT-Thread的信号量release在中断里是安全的,但FreeRTOS里普通版本的xSemaphoreGive会触发调度器相关的临界区操作,在中断上下文直接崩。

解决方法是把中断里所有IPC操作全部换成FromISR版本,并且把xHigherPriorityTaskWoken参数传出来,最后调用portYIELD_FROM_ISR。排查时可以直接搜xSemaphoreGive(和xQueueSend(,看看有没有出现在中断处理函数里。

5.4 现象:AT任务一活跃,其他低优先级任务全部饿死

网络任务和传感器任务响应变慢,看调度状态是AT接收任务长期霸占CPU。

原因是AT接收任务优先级设得太高。早期的配置里我给了优先级7,而业务任务只有3。AT接收任务一旦有数据就持续解析,空闲时也不让出CPU,把低优先级任务全部饿死。

解决方法是把AT接收任务优先级降到5以下,同时确保它在没有数据时阻塞在xSemaphoreTake上而不是忙等。FreeRTOS的优先级数字越大优先级越高,实践下来AT接收任务比普通业务任务高1到2级就够了,不要让它在数值上形成碾压。

5.5 现象:+IPD大包数据经常被截断,URC回调收到的数据不完整

现象是TCP收大块数据时,URC回调拿到的数据长度总是比实际短一截,有时候数据还会乱序。

原因是环形缓冲rx_rb的容量不够。ESP8266的+IPD数据包可以一次推几百字节,如果环形缓冲只有128字节,中断写入速度快于接收任务读取速度,数据就会被覆盖。

解决方法是把rx_rb的缓冲区尺寸从128字节扩到512字节以上,同时把接收任务优先级调高,保证它能在中断写入下一批数据前把缓冲清空。如果数据量再大,就要考虑DMA加空闲中断的方案,绕开逐字节中断的瓶颈。

6. 二次开发:给AT组件加一条自定义命令并接入业务任务

移植跑通只是开始,实际项目里最常遇到的是要加自己的AT命令,比如用AT指令查询模组信号强度、重启模组、进入透传模式。以加一条查询ESP8266信号强度的命令AT+CWJAP?为例,整个改造流程可以在不碰at_client.c核心逻辑的前提下完成。

先在业务代码里封装一个发送函数,复用AT组件的命令发送接口:

/* 查询当前连接的信号强度 */ int esp8266_get_signal(int *rssi) { static const char *cmd = "AT+CWJAP?\r\n"; struct at_response resp; /* 发命令并等待模组返回,超时2000ms */ if (at_obj_exec_cmd(&at_client, &resp, cmd) != RT_AT_OK) { return -1; } /* 模组返回 +CWJAP:"SSID","PASSWORD",rssi */ *rssi = parse_rssi_from_response(resp.buffer, resp.len); return 0; }

URC上报类命令则走注册回调的路线。比如模组主动上报+IPD数据,业务层只需要关心数据到达事件:

/* 注册UDP数据到达回调 */ static void udp_data_handler(const char *data, uint32_t size) { /* 把数据转发到业务处理任务 */ at_response_t msg = { (uint8_t *)data, (uint16_t)size }; xQueueSend(g_udp_queue, &msg, 0); } static const struct at_urc urc_table[] = { { "+IPD", udp_data_handler }, };

URC表在初始化时挂到AT客户端结构体上,接收线程解析到+IPD前缀时会自动调用udp_data_handler。业务任务从g_udp_queue中取数据,整个链路不需要修改at_client.c的任何一个函数。

验证这套改造是否可靠,我习惯用回环测试:把ESP8266配置成TCP Server,用PC端工具发一条自定义字符串,看URC回调里能不能完整收到。分别测小包、大包、连续包三种情况,连续包最能暴露队列深度和环形缓冲容量的问题。

从那以后我每次移植OS组件,都会先把"原语对应关系表"列出来再动手,信号量、互斥量、队列、时间单位逐个确认,再也不靠试错来踩坑。这套AT组件的移植代码后来又被我用到了另一个4G模组项目上,URC表和命令封装几乎是零改动,只换了底层的串口驱动。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询