RT-Thread 系统开发指南
编制日期 2026-10-05
面向 Cortex-M / RISC-V 的 RT-Thread 系统层开发规范 · 聚焦内核、线程、IPC、内存、组件、工程架构与调试 · 不含 BSP 与外设驱动
一句话结论:RT-Thread 的系统层设计,本质是用「优先级 + 阻塞 + 对象容器」把 CPU 与共享资源按截止时间分配出去。
它比 FreeRTOS 多了一套内核对象容器与自动初始化框架,让「模块注册」变成编译期行为,代价是初始化顺序必须显式设计。
线上故障最集中的三处:栈容量不足、中断里用了非 xxx_isr 的 API、拿信号量当互斥量导致优先级反转。管住这三处,系统稳定性解决八成。
这份文档解决什么
本文只覆盖 RT-Thread 系统层面:内核启动链路、线程与调度、IPC 选型、内存三方案、定时器、rtconfig.h 配置、FinSH / ulog 等系统组件、低功耗、健壮性设计与调试手段。 凡涉及具体外设的实现(SPI / I2C / UART / 定时器 / PIN 设备驱动、文件系统 DFS、网络协议栈的驱动适配)均归 BSP 与设备驱动文档,本文只在必要处说明「线程侧该如何与它协作」。
使用约定
- API 与宏以 RT-Thread 4.x / 5.x 为准,目标以 Cortex-M3/M4/M33 为主;RISC-V 与 M0/M0+ 的差异在对应小节单独标注。
- 代码示例只保留系统层逻辑,凡涉及具体寄存器与外设的部分统一写成
/* BSP 提供 */注释。 - 标 红线 的条目为强制约束,标 警惕 的为高频踩坑点,标 建议 的为经验推荐值。
- 文中出现的默认数值(栈大小、优先级、阈值)均以官方 rtconfig.h 模板与内核源码为参考;不同 BSP 与版本可能微调,落地前请以工程内的
rtconfig.h与内核源码为准。 - 芯片参数、时钟频率、外设行为一律以官方 datasheet 与 RT-Thread 官方文档 / 源码为准。
1 · 系统架构与内核对象模型
这一章解决「系统切成几块、每块放什么、内核对象是怎么被管起来的」。RT-Thread 与 FreeRTOS 最大的结构性差异就在对象模型上,理解了它,后面的 list_sem / ps 为什么能列出所有对象就自然清楚了。
1.1 四层分层模型
RT-Thread 是类 UNIX 风格的分层 RTOS:内核提供线程、IPC、内存、定时器,组件层提供 FinSH / ulog / POSIX / 软件包,BSP 层提供设备驱动与 libcpu 移植。分层的核心目的与 FreeRTOS 一致——让内核源码保持零修改,业务与内核之间只通过 rtconfig.h 与 API 交互。
- ① 应用业务层:业务线程、状态机、消息处理。每个线程只做一类事,通过 IPC 交互,不直接读写别人的全局变量。
- ② 系统组件层:FinSH、ulog、POSIX、libc、软件包。它们运行在内核之上、与业务平级,靠自动初始化宏挂进系统。
- ③ 内核层:
rt-thread/src下的调度器、IPC、内存、定时器与对象容器。红线 不修改任何内核源文件,只通过rtconfig.h配置。 - ④ BSP / 驱动层:libcpu 移植(PendSV / SysTick / 上下文栈帧)、设备模型与外设驱动。本指南不涉及。
直接散落调用 rt_mq_send 的项目,半年后必然出现:超时参数五花八门、返回值无人判断、想加统计无从下手。
封装层只需 4 类接口——msg_post(dst,id,buf,len,timeout)、msg_recv(mq,buf,len,timeout)、sync_give(h)、sync_take(h,timeout)——内部统一记录失败次数、统一 RT_ASSERT、统一默认超时。见 16.4 消息中心模式。
1.2 内核对象容器 rt_object
这是 RT-Thread 的标志性设计:所有内核对象(线程、信号量、互斥量、事件集、邮箱、消息队列、内存池、定时器、设备…)都从一个公共基类 struct rt_object 派生,并被挂进一张按类型分类的对象容器表(rt_object_container[])。
struct rt_object {
char name[RT_NAME_MAX]; /* 对象名,调试与 FinSH 列出时用 */
rt_uint8_t type; /* 对象类型:Thread / Semaphore / Mutex / ... */
rt_uint8_t flag; /* 标志位:静态对象 / 动态对象 */
rt_list_t list; /* 挂在容器链表上的节点 */
};
/* 派生方式:把 rt_object parent 作为第一个成员,即可安全强转 */
struct rt_thread {
struct rt_object parent; /* 必须是第一个成员 */
/* ... 栈指针、优先级、状态、链表 ... */
};
- 可枚举:FinSH 的
ps、list_sem、list_mq、list_timer就是遍历这张表实现的,不需要额外登记。 - 可按名查找:
rt_object_find(name, type)让模块之间可以通过字符串解耦引用(代价是字符串比较,别在高频路径用)。 - 类型安全:
rt_object_get_type()可在封装层做入参校验,把「把信号量句柄传给互斥量 API」这类错误在运行时挡住。
| 对象类型 | 枚举值含义 | 静态创建 / 脱离 | 动态创建 / 删除 | FinSH 查看命令 |
|---|---|---|---|---|
| Thread | 线程 | rt_thread_init / detach | rt_thread_create / delete | ps |
| Semaphore | 信号量 | rt_sem_init / detach | rt_sem_create / delete | list_sem |
| Mutex | 互斥量 | rt_mutex_init / detach | rt_mutex_create / delete | list_mutex |
| Event | 事件集 | rt_event_init / detach | rt_event_create / delete | list_event |
| MailBox | 邮箱 | rt_mb_init / detach | rt_mb_create / delete | list_mb |
| MessageQueue | 消息队列 | rt_mq_init / detach | rt_mq_create / delete | list_mq |
| MemPool | 内存池 | rt_mp_init / detach | rt_mp_create / delete | list_mempool |
| Timer | 定时器 | rt_timer_init / detach | rt_timer_create / delete | list_timer |
| Device | 设备对象 | (驱动侧注册) | rt_device_create | list_device |
静态对象由调用者提供结构体(通常是全局 / static 变量),用 rt_xxx_init() 初始化、rt_xxx_detach() 脱离;动态对象由内核从堆里分配,用 rt_xxx_create()、rt_xxx_delete()。
两者 API 行为完全一致,只是内存来源与生命周期不同。量产项目默认走静态,理由见 8.5 与 17.1。
1.3 与 FreeRTOS 的架构差异
如果团队里有 FreeRTOS 背景,下表能快速对齐心智模型。完整对照见附录 D。
| 维度 | RT-Thread | FreeRTOS | 对开发的影响 |
|---|---|---|---|
| 内核对象管理 | 统一对象容器,可按名查找、可枚举 | 无容器,对象句柄需自行管理 | RT-Thread 调试可见性更好,代价是每对象多约 12~16 B |
| 初始化框架 | 自动初始化宏(6 级),编译期排序 | 无,需手动在 main 里依次调用 | RT-Thread 要显式设计依赖顺序(2.4) |
| 内存方案 | 动态堆(小内存 / SLAB)+ 内存池 + memheap | heap_1~heap_5 | RT-Thread 原生提供内存池与多区域堆 |
| IPC 种类 | sem / mutex / event / mailbox / messagequeue | queue / sem / mutex / eventgroup / stream&message buffer / task notify | FreeRTOS 有任务通知(更轻);RT-Thread 有邮箱(4 字节,比队列省) |
| 线程通知 | 无独立任务通知机制,用邮箱或信号量替代 | Task Notification(最轻量) | RT-Thread 高频唤醒场景建议用邮箱 + 静态池 |
| 命令行与日志 | 内置 FinSH + ulog | 需外接 CLI / 第三方日志 | RT-Thread 开箱即用,但要记得裁剪(10.5) |
| 调度 | 位图就绪表 + 同优先级时间片轮转 | 优先级链表 + 同优先级轮转 | 行为基本一致,RT-Thread 优先级数默认 32 |
| 构建系统 | scons + Kconfig(menuconfig) | Makefile / CMake 为主 | RT-Thread 裁剪靠 Kconfig,需熟悉 scons |
不少资料写「RT-Thread 默认 RT_TICK_PER_SECOND = 100」,这是把 FreeRTOS 的 configTICK_RATE_HZ 默认值套过来了。
RT-Thread 官方模板的默认节拍是 1000(1 ms 粒度)。后果很实际:rt_thread_mdelay(1) 在 1000 Hz 下是 1 ms,在 100 Hz 下会被向上取整为 10 ms;定时器精度、超时判定全部随之变化。动手前先确认工程里的实际值。
1.4 线程划分原则
线程划分是最重要也最不可逆的一步。经验准则:线程不宜过多,每个线程单一职责,按时间特性而非按功能模块拆。
- 先按时间特性切:硬周期(采样、控制)与突发(通信、按键)必须分开;周期不同就不是一个线程。
- 再按功能独立性切:两个功能无共享状态、可独立测试,就可以分开。
- 高优先级线程必须短:只做「取数据 → 判条件 → 投递消息 / 置事件」,耗时活交给低优先级线程。
- 禁止一个大 while(1) 塞满所有业务:那等于把裸机超级循环搬进 RTOS,抢占优势全部失效。
- 同优先级可合并:时间特性相同、逻辑耦合紧密的,合并比拆开更省栈、更少同步。
- 一次性创建,运行期不删:见 3.1 与 3.6,这是量产项目的默认姿态。
| 线程类型 | 时间特性 | 建议优先级(32 级) | 单次执行上限 | 典型错误 |
|---|---|---|---|---|
| 硬实时控制 | 固定周期,抖动敏感 | 0 ~ 4 | < 1 ms | 在循环里做浮点滤波,拖垮全系统 |
| 中断后置处理 | 突发,由 ISR 触发 | 5 ~ 8 | < 5 ms | 把处理逻辑写在 ISR 里 |
| 通信协议栈 | 突发 / 半周期 | 8 ~ 12 | < 10 ms | 在协议回调里做阻塞等待 |
| 周期采集 | 固定周期 | 10 ~ 16 | < 5 ms | 用 rt_thread_mdelay 做周期,误差累积 |
| 业务状态机 | 事件驱动 | 12 ~ 18 | < 20 ms | 状态机里塞满阻塞调用,外部事件不响应 |
| 日志 / 存储 | 低频,可延迟 | 18 ~ 24 | 可长 | 与通信线程抢同一把锁且顺序不一致 |
| UI / 慢速轮询 | 低频 | 20 ~ 26 | < 20 ms | 忙等延时,占满 CPU,功耗降不下来 |
| FinSH 命令行 | 交互 | 20(默认) | — | 栈给太小,敲长命令就溢出 |
| 系统监控 / 喂狗 | 固定周期 | 24 ~ 28(不最低) | < 1 ms | 优先级设为最高,死锁时照样喂狗,掩盖故障 |
| 空闲线程 idle | 无其他线程就绪时 | 31(RT_THREAD_PRIORITY_MAX-1) | — | 在空闲钩子里做阻塞操作 |
几十 KB RAM 的 Cortex-M3/M4 上,6~12 个业务线程是舒适区间(不含 idle / timer / tshell)。超过 20 个时,栈总量与调度开销明显上升,人的理解成本更是急剧增加——此时应反思是不是按「功能模块」而不是按「时间特性」拆的。
1.5 优先级分档方法论
优先级的本质是截止时间(deadline)的紧急程度,不是功能的重要程度。日志很重要,但可以晚 100 ms 写,优先级就该低。
- 速率单调(Rate Monotonic, RM):周期越短,优先级越高。一批纯周期线程的理论最优静态分配。
- 截止时间优先(Deadline Monotonic):按「从触发到必须完成的时限」排序,比 RM 更通用,适合混合突发线程的场景。
- 留出空档:不要 0/1/2/3 连续排满,给每档之间留 2~3 级空位,方便后期插入新线程而不用整体重排。
| 档位(32 级制) | 定位 | 约束 |
|---|---|---|
| 0 ~ 4 | 硬实时闭环:电机 / 电源控制、紧急停机 | 执行时间必须 < 1 ms,禁止任何阻塞调用 |
| 5 ~ 8 | 中断后置处理:把 ISR 里的事搬出来做 | 只做搬运与解析,禁止长耗时运算 |
| 8 ~ 12 | 通信协议栈:接收、组包、重传 | 禁止在回调里阻塞,超时必须有限 |
| 10 ~ 20 | 业务主体:状态机、采集、算法 | 这是绝大多数业务线程的落点 |
| 20 ~ 26 | 日志、存储、UI、FinSH | 可延迟,允许被长时间抢占 |
| 24 ~ 28 | 系统监控与喂狗 | 优先级不能最高,否则死锁被掩盖 |
| 31 | 空闲线程(RT_THREAD_PRIORITY_MAX-1) | 禁止阻塞;只能做快速统计与低功耗进入 |
警惕 时间片轮转不会给低优先级线程公平份额:只要高优先级线程一直就绪,低优先级线程就一直得不到运行(饿死)。因此高优先级线程里绝不能有忙等循环。
2 · 启动流程与自动初始化
RT-Thread 把「模块注册」做成了编译期行为:INIT_APP_EXPORT(fn) 一行就把函数挂进启动链。便利背后是硬约束——依赖关系必须由等级显式表达,顺序错了就是随机死机。
2.1 rtthread_startup 全链路
上电后,RT-Thread 通过 ARM 编译器的 $Sub$$main 机制,在用户 main() 之前插入 rtthread_startup()。完整链路如下:
int rtthread_startup(void)
{
rt_hw_interrupt_disable(); /* 1. 关中断,启动期不允许打扰 */
rt_hw_board_init(); /* 2. BSP:时钟、中断、堆地址、串口、SysTick */
rt_show_version(); /* 3. 打印版本号(RT_DEBUG 下) */
rt_system_heap_init(HEAP_BEGIN, HEAP_END); /* 4. 初始化系统堆 */
rt_scheduler_init(); /* 5. 调度器数据结构 */
rt_system_timer_init(); /* 6. 定时器链表(HARD 模式) */
rt_thread_idle_init(); /* 7. 空闲线程(最低优先级) */
rt_hw_spin_lock_init(); /* SMP 下才需要 */
rt_system_scheduler_start(); /* 8. 之后:自动初始化 + main 线程 → 启动调度 */
return 0;
}
rt_system_heap_init(begin, end) 的两个地址由 BSP 提供,常见做法是把链接脚本里 bss 段结束到 RAM 末尾的整块空间交给内核堆。这意味着:
- 堆大小 = RAM 总量 − 静态数据 − 栈 − 各线程栈,是算出来的,不是配出来的;
- 若 BSP 用的是
rt_memheap或多段内存(如片外 SRAM),需要额外调用rt_memheap_init()注册(见 8.3)。
2.2 $Sub$$main 与 main 线程
很多初学者的疑问是「我的 main() 去哪了」。答案是:你的 main() 变成了 main 线程的入口函数。
/* libcpu / components 中的钩子(ARMCC / GCC 均支持) */
extern int $Super$$main(void);
int $Sub$$main(void)
{
rtthread_startup(); /* 1. 先把内核跑起来 */
return $Super$$main(); /* 2. 再进入用户 main —— 此时它已是 main 线程 */
}
| 项 | 默认值 / 行为 | 说明 |
|---|---|---|
| main 线程优先级 | RT_MAIN_THREAD_PRIORITY(默认 RT_THREAD_PRIORITY_MAX / 3,32 级时为 10) | 中高档。业务线程若希望比 main 更先跑,优先级要小于它 |
| main 线程栈 | RT_MAIN_THREAD_STACK_SIZE(常见 2048 字节) | 在 main 里定义大数组极易溢出;业务重活应另建线程 |
| main 返回之后 | 线程退出并被回收(动态创建) | 红线 不要让 main 返回:用 while(1) 阻塞在事件上,或末尾 rt_thread_mdelay |
| main 里能不能创建线程 | 可以,但不推荐 | 业务线程建议用 INIT_APP_EXPORT 注册,与 main 解耦 |
- 栈不够:main 线程栈默认 2 KB 左右,一旦在里面写协议解析或放局部缓冲,极易溢出,且溢出位置离现场很远,很难查。
- 初始化顺序不可控:main 线程与
INIT_APP_EXPORT模块是并发竞争关系——main 优先级为 10,比它低的 APP 模块还没跑完,main 就可能已经去用它们的资源了。
推荐做法:main 只做「等待系统就绪 → 打印启动完成 → 阻塞」,所有业务用 INIT_APP_EXPORT 注册。
2.3 自动初始化六等级
RT-Thread 用链接器段排序实现自动初始化:宏把函数指针放进指定的段,启动时按段顺序依次调用。等级从低到高:
/* 典型业务模块注册方式 */
static int app_msg_center_init(void)
{
msg_center_init(); /* 建消息队列 */
rt_thread_create("msg", ...); /* 建业务线程 */
return RT_EOK;
}
INIT_APP_EXPORT(app_msg_center_init); /* 挂到等级 6 */
/* 需要参数的初始化(5.x 支持传参) */
INIT_APP_EXPORT_WITH_PARAM(fn, param);
- 纯软件、不依赖任何外设(算法表、状态机初始化)→
INIT_COMPONENT_EXPORT或INIT_ENV_EXPORT。 - 要打开设备 / 访问总线 → 必须在
INIT_DEVICE_EXPORT之后,即INIT_COMPONENT_EXPORT及以上。 - 要用 FinSH / ulog 打印 → 必须在
INIT_COMPONENT_EXPORT之后。 - 业务线程与消息队列 →
INIT_APP_EXPORT,这是绝大多数业务模块的落点。
2.4 组件依赖顺序设计
等级只解决「大类」的顺序,模块之间的细粒度依赖需要自己设计。三步法:
- 画依赖表:列出每个模块「初始化时依赖谁」。例:日志模块依赖 ulog 后端;业务线程依赖消息队列;协议栈依赖 UART 设备 + 消息队列。
- 映射等级:被依赖者等级必须严格小于依赖者。同等级内的先后不可依赖(链接顺序不确定)。
- 加运行时守卫:跨等级边界的调用点加
RT_ASSERT(handle != RT_NULL),顺序错了立刻暴露,而不是跑飞。
| 依赖场景 | 错误做法 | 正确做法 |
|---|---|---|
| 业务线程要发日志 | 线程放 INIT_COMPONENT_EXPORT,ulog 还没起来 | 线程放 INIT_APP_EXPORT,日志后端放 COMPONENT 或更早 |
| 两个线程共享一个消息队列 | 两个线程都在 INIT_APP_EXPORT,谁先跑不确定 | 队列创建放 INIT_ENV_EXPORT,两个线程都放 INIT_APP_EXPORT |
| 模块 A 的初始化要用模块 B 的设备 | A、B 同等级 | B 放 DEVICE,A 放 COMPONENT 及以上 |
| 线程创建后立刻 send 消息 | 接收方线程还没建好 | 接收方先建;或发送用超时 + 重试,队列容量预留 |
| 初始化里调用 rt_thread_mdelay | delay 依赖调度器已启动 | 自动初始化阶段调度器尚未启动,见下方红线 |
INIT_*_EXPORT 注册的函数,全部在 调度器启动之前执行。此时调用 rt_thread_mdelay()、rt_sem_take()、rt_mq_recv() 等会触发调度的 API,行为未定义——轻则该函数挂起导致整个启动卡死,重则调度器数据结构被破坏。
正确的做法:初始化函数只做「创建对象」,把「等待 / 交互」放到线程入口里做。
2.5 启动期典型故障
| 现象 | 根因 | 定位与处置 |
|---|---|---|
| 串口无任何输出,程序像没跑 | rt_hw_board_init 里串口未初始化;或堆地址越界导致后续初始化崩 | 在 board_init 最前面点灯 / 打一个字符;核对链接脚本中的 HEAP_BEGIN/END |
| 打印版本后死机 | 堆空间太小或地址非法,第一个 rt_malloc 就崩 | 核对 RAM 大小与 bss 占用;用 RT_ASSERT 捕获;调小静态缓冲 |
| 卡在某个 INIT 函数里 | 初始化函数里调用了阻塞 API | 见上方红线;把阻塞逻辑搬进线程 |
| main 里的代码没执行 | 前面某个 INIT_APP_EXPORT 函数没返回;或 main 线程栈溢出 | 逐个注释定位;加大 RT_MAIN_THREAD_STACK_SIZE |
| ps 看不到自己创建的线程 | 线程创建失败(返回 RT_NULL)或创建后立即退出 | 判断 rt_thread_create 返回值;检查入口函数是否直接 return |
| list_sem / list_mq 列不出对象 | 未开启 RT_USING_FINSH 对应的 list 命令,或对象名超长 | 检查 rtconfig.h 与对象 name 长度(RT_NAME_MAX) |
在串口还没起来时,最可靠的调试手段是点灯 + 读复位状态寄存器:在 board_init 各阶段翻转一个 GPIO,用示波器量脉宽即可定位卡在哪一步。等串口可用后,立刻打开 ulog 与 RT_DEBUG_INIT,让初始化过程可打印。
3 · 线程管理
线程创建只是一次函数调用,但栈给多大、静态还是动态、删不删,决定了半年后会不会在客户现场死机。
3.1 静态 init / 动态 create
RT-Thread 为每个内核对象都提供了静态与动态两套 API,命名规律统一:rt_xxx_init/detach(静态)与 rt_xxx_create/delete(动态)。
/* ====== 动态:内核从堆里分配 TCB 与栈 ====== */
rt_thread_t rt_thread_create(const char *name,
void (*entry)(void *parameter),
void *parameter,
rt_uint32_t stack_size, /* 单位:字节 */
rt_uint8_t priority, /* 0 最高 */
rt_uint32_t tick); /* 时间片长度(Tick 数) */
rt_err_t rt_thread_delete(rt_thread_t thread);
/* ====== 静态:TCB 与栈由调用者提供 ====== */
rt_err_t rt_thread_init(struct rt_thread *thread,
const char *name,
void (*entry)(void *parameter),
void *parameter,
void *stack_start,
rt_uint32_t stack_size,
rt_uint8_t priority,
rt_uint32_t tick);
rt_err_t rt_thread_detach(rt_thread_t thread);
RT-Thread 的 stack_size 单位是字节;FreeRTOS 的 usStackDepth 单位是 word(Cortex-M 上 1 word = 4 字节)。从 FreeRTOS 迁移时直接照搬数字会把栈配小 4 倍,是最隐蔽的迁移坑之一。
例:rt_thread_create("t", f, RT_NULL, 1024, 10, 20) 表示 1 KB 栈、优先级 10、时间片 20 个 Tick。
/* 静态线程:量产项目的默认写法(编译期即可从 map 文件看到占用,无堆碎片) */
#define THREAD_SAMPLE_PRIO 12
#define THREAD_SAMPLE_STACK 1024 /* 字节 */
#define THREAD_SAMPLE_TIMESLICE 10
static struct rt_thread g_tSample; /* TCB:全局 / static */
ALIGN(RT_ALIGN_SIZE)
static rt_uint8_t g_tSampleStack[THREAD_SAMPLE_STACK]; /* 栈:全局 / static */
static rt_thread_t g_hSample = RT_NULL;
static void sample_entry(void *parameter)
{
for (;;) {
/* 业务代码,禁止 return */
}
}
int sample_thread_init(void)
{
rt_err_t err = rt_thread_init(&g_tSample, "sample",
sample_entry, RT_NULL,
&g_tSampleStack[0], sizeof(g_tSampleStack),
THREAD_SAMPLE_PRIO, THREAD_SAMPLE_TIMESLICE);
if (err != RT_EOK) return err;
g_hSample = &g_tSample;
return rt_thread_startup(g_hSample) == RT_EOK ? 0 : -1; /* init 之后必须 startup */
}
INIT_APP_EXPORT(sample_thread_init);
rt_thread_init() 只是把线程挂到对象容器并置为 INIT 状态,不会加入就绪表;必须再调 rt_thread_startup() 才会进入 READY。
rt_thread_create() 内部已完成这两步,返回的句柄即可运行。这是两套 API 最容易漏的一步。
| 对比项 | rt_thread_init(静态) | rt_thread_create(动态) |
|---|---|---|
| 内存来源 | 调用者提供的全局 / static 数组 | 内核堆(rt_malloc) |
| 失败可能 | 参数合法即成功(返回 RT_EOK) | 堆不足时返回 RT_NULL |
| 是否需 startup | 需要额外调用 rt_thread_startup | 不需要,创建即就绪 |
| 碎片 | 无 | 反复创建删除会碎片化 |
| RAM 可见性 | 编译期可从 map 文件核对 | 运行时才知道够不够 |
| 删除 / 脱离 | rt_thread_detach(不回收内存) | rt_thread_delete(回收到堆,由 idle 完成) |
| 适用 | 量产产品、功能安全项目的默认选择 | 原型验证、线程数可变的场景 |
警惕 不推荐在运行期频繁创建 / 删除线程。反复从堆里切块与回收会留下无法合并的碎片;需要「临时干活」的场景,改成常驻线程 + 消息队列唤醒(阻塞时不占 CPU),或用静态线程池。
3.2 线程状态机
RT-Thread 的状态枚举与 FreeRTOS 有一个关键区别:阻塞与挂起共用一个状态。
- INIT:已创建但尚未 startup(静态 init 后的状态)。
- READY:在就绪表中等待被调度。
- RUNNING:占用 CPU,同一时刻单核上只有一个。
- SUSPEND:等待 IPC / 延时,或被
rt_thread_suspend()显式挂起。前者会被事件自动唤醒,后者只能靠rt_thread_resume()。 - CLOSE:已退出,等待回收(动态线程由空闲线程回收 TCB 与栈)。
rt_thread_suspend() 挂起的线程不会因为任何事件自动恢复,必须显式 rt_thread_resume()。漏掉就是永久卡死,而且 ps 里看起来只是「状态为 suspend」,不仔细看不出来。
需要「等待某个条件」时,一律用带超时的 IPC(信号量 / 事件集 / 消息队列),不要用 suspend/resume 手工控制。
3.3 栈大小估算与水位
栈必须留余量,因为最坏路径往往不在正常测试里出现(最深调用链 + 最坏中断嵌套 + printf 同时发生)。
- 先给宽松值:例如 1024 字节,先跑起来。
- 跑满所有路径:异常分支、错误打印、最坏中断并发、参数边界。
- 读水位:FinSH 执行
ps,看 sp 列显示的使用百分比;或用下方代码自算。 - 反推配置值:
已用 = stack_size − 剩余;目标stack_size ≈ 已用 × 1.3,且剩余 ≥ 25%。 - 回归锁死:写成具名宏(如
THREAD_SAMPLE_STACK 1024),并在监控线程里周期性检查,低于阈值上报。
/* 自算栈使用(成员名以所用版本内核源码为准) */
static rt_size_t stack_used(rt_thread_t th)
{
if (th == RT_NULL) return 0;
/* sp 指向当前栈顶;栈区间 [stack_addr, stack_addr + stack_size) */
return (rt_size_t)((char *)th->stack_addr + th->stack_size - (char *)th->sp);
}
/* 开启 RT_USING_OVERFLOW_CHECK 后,内核提供栈溢出检查接口 */
#ifdef RT_USING_OVERFLOW_CHECK
if (rt_thread_stack_check(th) != RT_EOK) { fault_record("STACK_OVERFLOW", th->name); }
#endif
| 栈消耗来源 | 典型量级(Cortex-M4) | 说明 |
|---|---|---|
| 线程切换上下文 | 64 ~ 72 B | R4-R11 + R0-R3 + R12 + LR + PC + xPSR |
| 函数调用帧 | 每层 16 ~ 160 B | 参数多、局部数组大时更贵 |
| printf / sprintf 家族 | 300 ~ 800 B | 最贵的单项,依赖 libc 实现 |
| 浮点格式化 %f / strtod | 200 ~ 500 B | 建议禁用或改用轻量实现 |
| 浮点上下文(FPU) | 额外约 68 B | 惰性压栈下的扩展帧 |
| 最坏中断嵌套帧 | 32 B × 嵌套层数 | 硬件自动压栈部分 |
| 局部大数组 | 等于其 sizeof | 头号杀手,见下方红线 |
void f(void){ rt_uint8_t buf[512]; ... } 一次性吃掉 512 B 栈,若该函数还被嵌套调用、或同时发生中断,栈瞬间见底。
规则:大于 64 字节的缓冲一律声明为 static 或全局;如果该缓冲会被并发访问,改用内存池或消息队列传递。
- 开发期打开
RT_USING_OVERFLOW_CHECK,并让监控线程周期性检查水位,而不是只在启动时看一次。 - 栈大小写成具名宏并在文档里登记,禁止散落的魔法数字。
- FinSH 线程、main 线程、timer 线程的栈同样要核对——它们的默认值不一定够用(尤其是打开 ulog 异步输出后)。
3.4 常用 API 与规则
| API | 作用 | 规则与注意 |
|---|---|---|
| rt_thread_mdelay(ms) | 毫秒级相对延时 | 最常用;延时起点是调用时刻,循环累积会漂移 |
| rt_thread_delay(tick) | 按系统节拍延时 | tick 数;等价于 mdelay 的底层版本 |
| rt_thread_sleep(tick) | 按节拍睡眠 | 与 delay 语义基本一致,按工程习惯二选一 |
| rt_thread_yield() | 让出 CPU 给同优先级就绪线程 | 同优先级无就绪线程时立刻返回,不会白白等一个 Tick |
| rt_thread_startup(h) | 把线程置为就绪 | 仅静态 init 后需要;对已运行线程调用会出错 |
| rt_thread_suspend(h) / resume(h) | 挂起 / 恢复 | 慎用,无超时兜底;见 3.2 红线 |
| rt_thread_control(h, cmd, arg) | 控制线程:改优先级 / 关闭 / 启动 | cmd 见 RT_THREAD_CTRL_*;运行期改优先级会打乱优先级设计 |
| rt_thread_self() | 取当前线程句柄 | 常用于「谁在跑」的日志与统计 |
| rt_thread_find(name) | 按名字查找线程 | 字符串比较,别在高频路径调用 |
| rt_thread_delete(h) / detach(h) | 删除 / 脱离 | 删除前必须自己释放持有的资源与 IPC |
固定周期线程的正确写法。RT-Thread 内核没有与 vTaskDelayUntil 完全等价的通用 API(部分版本提供 rt_thread_periodic_delay(),以所用内核源码为准),推荐用绝对时刻补偿自行实现,避免周期漂移:
void sample_entry(void *parameter)
{
rt_tick_t last = rt_tick_get(); /* 基准时刻 */
const rt_tick_t period = rt_tick_from_millisecond(10);/* 10 ms 周期 */
for (;;) {
rt_tick_t now = rt_tick_get();
rt_int32_t wait = (rt_int32_t)(last + period - now); /* 有符号比较,可正确处理回绕 */
if (wait > 0) {
rt_thread_delay((rt_tick_t)wait);
last += period; /* 正常:按基准推进,不累积误差 */
} else {
last = now; /* 过载:重新对齐,并上报一次超时 */
g_sample_overrun++;
}
sample_once(); /* 必须远短于 period */
}
}
若 sample_once() 的执行时间超过周期,上面的写法会退化成「立刻再跑一次」,线程变成 100% 占用,而其他一切看起来都正常。必须把 g_sample_overrun 这样的计数暴露给监控线程 / FinSH,否则过载永远是隐形的。
while (flag == 0) { } 或 while (1) { rt_thread_mdelay(1); } 会让线程永远占用 CPU:同优先级的拿不到时间片,更低优先级的彻底饿死,功耗也降不下来。
一切等待都必须走阻塞 API:信号量、互斥量、事件集、邮箱、消息队列、rt_thread_mdelay。
3.5 空闲线程与钩子
空闲线程(idle)由内核在启动阶段创建,优先级固定为 RT_THREAD_PRIORITY_MAX - 1(32 级时为 31),永远处于就绪状态,保证调度器总有线程可跑。
- 职责:回收被 delete 的线程资源(TCB / 栈);提供空闲钩子;低功耗进入点。
- 空闲钩子:
rt_thread_idle_sethook(hook),只在 CPU 空闲时执行,可做 CPU 占用统计、进入低功耗、喂狗之外的轻量巡检。 - 红线 空闲钩子里不能阻塞:一旦钩子阻塞,资源回收与低功耗都会停摆,系统会「假死」——所有业务线程都在等,而 idle 卡住了。
static rt_uint32_t g_idle_cnt = 0;
static void idle_hook(void)
{
g_idle_cnt++; /* 只做极轻量统计,禁止阻塞、禁止长耗时 */
/* pm_enter(); —— 低功耗进入点由 BSP / PM 组件提供,本文不展开 */
}
int idle_hook_init(void)
{
rt_thread_idle_sethook(idle_hook);
return 0;
}
INIT_COMPONENT_EXPORT(idle_hook_init);
固定窗口内统计 g_idle_cnt 的增量,与「idle 满速时的理论增量」相比,即可得到粗略的 CPU 空闲率。精度不如硬件计数器,但不需要任何额外外设,在资源紧张的项目里很实用(见 15.4)。
3.6 线程设计五原则
- 单一职责:一个线程只处理一类业务,通过 IPC 与其他线程交互,不直接读写别人的全局变量。
- 高优先级线程执行时间尽可能短:只做判定与投递,耗时活交给低优先级线程。
- 禁止忙等:一切等待走阻塞 API;禁止
while(1)空轮询。 - 局部大数组不进栈:大于 64 B 的缓冲声明为 static / 全局,或改用内存池。
- 一次性创建、运行期不删:系统启动后不再动态创建 / 删除线程,是最省心也最省 RAM 的架构。
static void xxx_entry(void *parameter)
{
/* 1. 初始化本线程私有资源(失败要能安全退出或重试) */
/* 2. 通知外界「我已就绪」(可选:发一个信号量 / 事件) */
for (;;) {
/* 3. 阻塞等待事件(带超时) */
/* 4. 处理事件(短) */
/* 5. 异常自恢复(超时 / 失败计数,超过阈值复位或降级) */
}
/* 永不 return */
}4 · 调度机制
理解调度器怎么切线程,才能回答「为什么我的高优先级线程会被卡住 10 ms」这类问题。
4.1 优先级就绪表与位图
RT-Thread 的调度器由一张按优先级分桶的链表数组 + 一个位图组成:
/* 概念结构(具体定义见内核源码 src/scheduler.c) */
rt_list_t rt_thread_priority_table[RT_THREAD_PRIORITY_MAX]; /* 每级一条就绪链表 */
rt_uint32_t rt_thread_ready_priority_group; /* 位图:bit i = 优先级 i 有就绪线程 */
/* 取最高优先级:一次 __rt_ffs 即可,O(1) 且与线程总数无关 */
highest = __rt_ffs(rt_thread_ready_priority_group) - 1;
next = rt_list_entry(rt_thread_priority_table[highest].next, struct rt_thread, tlist);
调度复杂度是 O(1),与线程数量无关——这是 RT-Thread 在几十个线程的工程里依然能保持稳定响应延迟的原因。相应地,优先级数量直接决定这张表的静态 RAM 开销(见 4.5)。
4.2 抢占与时间片轮转
| 机制 | 触发条件 | 行为 |
|---|---|---|
| 抢占 | 高优先级线程变为就绪(IPC 释放、ISR 唤醒、定时器到期) | 当前线程立刻被挂起,高优先级线程马上运行 |
| 时间片轮转 | 同优先级有多个就绪线程,且当前线程时间片耗尽 | 当前线程 remaining_tick 归零 → 重置时间片 → 移到链表尾 → 运行下一个 |
| 主动让出 | rt_thread_yield() | 同优先级内切换;若无同优先级就绪线程则立即返回继续运行 |
| 阻塞让出 | 调用带超时的 IPC 或 delay | 移出就绪表,进入 SUSPEND,事件到达或超时后回到就绪表末尾 |
/* rt_thread_create 的最后一个参数就是时间片长度(Tick 数) */
rt_thread_create("worker", worker_entry, RT_NULL,
1024, /* 栈:字节 */
15, /* 优先级:数值越小越高 */
20); /* 时间片:20 个 Tick(1000 Hz 下 = 20 ms) */
警惕 两个易错点:
- 时间片只在同优先级之间生效:低优先级线程不会因为「高优先级用了太久」而得到补偿,只要高优先级一直就绪,它就一直饿死。
- 时间片长度不是周期:它是「同优先级连续占用 CPU 的最长时长」,线程阻塞时会立刻让出,不消耗完整个时间片。
4.3 调度器锁与 rt_schedule
| API | 作用 | 约束 |
|---|---|---|
| rt_enter_critical() / rt_exit_critical() | 锁调度器:禁止线程切换,但不关中断 | 可嵌套;中断照常响应,因此 ISR 里仍能调 xxx_isr 版本号 Rev 1.4 |
| rt_schedule() | 主动触发一次调度判定 | 应在线程上下文调用;中断里要用 rt_schedule_isr 的等价路径(由内核内部处理) |
| rt_hw_interrupt_disable() / enable() | 关 / 开全局中断 | 最强也最贵,见第 12 章 |
| rt_interrupt_enter() / leave() | 标记中断嵌套,供内核判定上下文 | BSP 的 ISR 入口/出口调用;业务代码一般不需要 |
rt_enter_critical() 只阻止线程切换,不阻止中断,也不阻止另一个核(SMP)。因此它只能保护「线程之间」的短临界资源,不能保护「线程与 ISR 之间」的共享数据——后者必须关中断或用信号量。
另外,调度器锁期间不能调用任何可能阻塞的 API:一旦阻塞,锁永远解不开,系统死锁。
4.4 Cortex-M 上下文切换
与 FreeRTOS 一致:Cortex-M 上的切换由 SysTick(判定该不该切)与 PendSV(真正执行切换)协作完成,PendSV 被设为最低优先级,保证所有 ISR 处理完后再切换。
一次上下文切换 ≈ 保存/恢复 R4-R11 与栈指针操作,Cortex-M3/M4 上约 1~3 µs(与编译优化、栈位置、是否有 FPU 上下文有关)。1000 Hz Tick 下,即使每个 Tick 都切换,开销占比也很小。
真正的 CPU 杀手从来不是切换本身,而是过长的临界区、高频无意义的 Tick 唤醒与ISR 里做重活。
4.5 优先级数量与开销
| RT_THREAD_PRIORITY_MAX | 就绪表静态 RAM | 适用 | 说明 |
|---|---|---|---|
| 8 | 约 8 × 8 B = 64 B | 极小资源 MCU(几 KB RAM) | 档位太少,业务一复杂就容易挤在同一档 |
| 32(默认) | 约 32 × 8 B = 256 B | 绝大多数项目 | 推荐:档位够用,开销可忽略 |
| 256 | 约 256 × 8 B = 2 KB | 大型系统 / 多子系统混跑 | 位图退化为字数组,多一次内存访问;2 KB 对小 RAM 不划算 |
- 默认保持 32,不要为了「省 RAM」降到 8——64 B 与 256 B 的差别远小于档位不足带来的架构扭曲。
- 只有在「多个第三方组件各自定义优先级区间」的大型系统里才考虑 256。
- 改这个宏会改变空闲线程优先级(RT_THREAD_PRIORITY_MAX - 1)与
RT_MAIN_THREAD_PRIORITY,务必同步复核所有优先级分配。