3. RT-Thread内核基础与调试视角
线程栈,说白了就是每个线程的“私人工作台”。你在线程里定义的局部变量、函数调用时的返回地址、CPU现场保存,全堆在这个工作台上。工作台太小,东西一多就掉地上——栈溢出了。我见过太多产品死机、重启、跑飞,最后定位到根因就是栈溢出。这一章,咱们就把栈这件事彻底讲透。
- 掌握线程栈大小的估算方法
- 理解栈溢出的底层原理
- 学会通过HardFault定位栈相关问题
- 熟练使用ulog和assert进行运行时检测
3.1 线程栈大小计算
很多新手喜欢拍脑袋定栈大小。比如“256字节够了吧?”或者“先设个1024,不行再加”。这种做法在简单项目里也许能蒙混过关,但一旦上了产品,就是定时炸弹。
栈大小由三部分决定:
| 组成部分 | 说明 | 典型占比 |
|---|---|---|
| 局部变量 | 函数内定义的自动变量、数组、结构体 | 40%~60% |
| 函数调用链 | 嵌套调用的返回地址、参数、寄存器现场 | 20%~30% |
| 中断嵌套 | 中断服务函数抢占线程时的现场保存 | 10%~20% |
| RT-Thread内部开销 | 线程切换、定时器回调等框架层消耗 | 5%~10% |
我个人习惯用“最坏路径法”来估算。什么意思?就是找出这个线程里嵌套最深、局部变量最大的那条调用链,然后往上加余量。
举个例子:
/* 假设你的线程入口函数长这样 */
static void my_thread_entry(void *parameter)
{
uint8_t big_buffer[256]; // 256字节
struct can_frame frames[10]; // 假设每个frame 16字节,共160字节
while (1) {
process_data(big_buffer); // 调用深度+1
send_frames(frames); // 调用深度+1,内部可能还有一层
rt_thread_mdelay(100);
}
}
/* 估算过程 */
// 局部变量: 256 + 160 = 416字节
// 调用链: 假设最深3层,每层约40字节 = 120字节
// 中断嵌套: 预留128字节
// RT-Thread开销: 约64字节
// 合计: 416 + 120 + 128 + 64 = 728字节
// 余量(30%): 728 * 1.3 ≈ 950字节
// 取整: 1024字节
3.2 栈溢出原理
栈溢出到底是怎么发生的?咱们从底层看。
STM32的栈是“向下生长”的。也就是说,栈指针SP从高地址往低地址移动。每个线程在创建时,RT-Thread会分配一块连续内存作为栈空间,并记录栈顶和栈底。
/* 栈内存布局示意 */
高地址
+------------------+ ← 栈底 (stack_start + stack_size)
| |
| 已使用的栈空间 |
| |
+------------------+ ← 当前SP位置
| |
| 空闲栈空间 |
| |
+------------------+ ← 栈顶 (stack_start)
低地址
当线程不断调用函数、定义局部变量时,SP不断下移。一旦SP越过栈顶边界,写入的数据就会覆盖到相邻内存区域——可能是另一个线程的栈,也可能是全局变量区,甚至是RT-Thread的内核数据。
为什么会这样?因为ARM Cortex-M的硬件并不强制检查栈边界。它只管往SP指向的地址写数据,至于那个地址是不是合法的栈空间,它不关心。这就是为什么栈溢出往往不会立即触发异常,而是先“静默破坏”一段时间,等被破坏的数据被使用时才突然崩溃。
3.3 HardFault定位
当栈溢出最终导致非法访问(比如访问了不存在的地址、执行了非法指令),CPU就会触发HardFault。这时候,第一件事不是复位,而是把现场信息读出来。
RT-Thread默认提供了HardFault的钩子函数,你可以在board.c或fault_handler.c中找到它。当异常发生时,它会打印出关键的寄存器信息:
/* HardFault时打印的信息示例 */
psr: 0x61000000
pc: 0x08001234
lr: 0x08005678
r12: 0x00000000
r3: 0x20000100
r2: 0x20000200
r1: 0xdeadbeef
r0: 0x00000000
/* 关键信息解读 */
// pc: 发生异常时的程序计数器,指向出错的指令地址
// lr: 返回地址,告诉你这个函数是谁调进来的
// r0-r3: 函数参数和临时变量
// psr: 程序状态寄存器,包含中断状态等信息
拿到PC值后,怎么定位到代码?用addr2line工具:
# 在命令行中执行
arm-none-eabi-addr2line -e rtthread.elf 0x08001234
# 输出类似
# /home/user/project/app/thread.c:123
这样你就知道是thread.c的第123行出了问题。但注意,这个地址只是“案发现场”,不一定是“犯罪根源”。栈溢出的破坏可能发生在更早的时候,只是到这一行才触发异常。
3.4 ulog与assert使用
光靠HardFault事后分析,效率太低了。更好的做法是在运行时主动检测栈的健康状态。RT-Thread提供了两大利器:ulog和assert。
3.4.1 ulog:灵活的日志系统
ulog是RT-Thread的日志框架,支持多级别输出、颜色标记、过滤等功能。我建议你在每个线程的循环里加上栈使用率的打印:
#include <ulog.h>
static void my_thread_entry(void *parameter)
{
while (1) {
/* 获取当前线程的栈使用情况 */
rt_uint8_t usage = rt_thread_self()->stack_used;
/* 使用ulog输出,级别设为INFO */
ulog_output(LOG_INFO, "my_thread", "stack usage: %d%%", usage);
/* 如果超过80%,提升到WARN级别 */
if (usage > 80) {
ulog_output(LOG_WARN, "my_thread",
"stack usage high: %d%%", usage);
}
rt_thread_mdelay(1000);
}
}
ulog的级别从高到低分别是:LOG_ERROR、LOG_WARN、LOG_INFO、LOG_DEBUG。你可以通过命令行动态调整过滤级别,非常方便。
3.4.2 assert:契约式检查
assert是C标准库就有的东西,但RT-Thread对它做了增强。当assert条件不满足时,它会打印出文件名、行号、函数名,然后主动触发断点或HardFault。
/* 在关键操作前后加assert */
void critical_function(void *data)
{
/* 断言参数不为空 */
RT_ASSERT(data != RT_NULL);
/* 断言当前在中断上下文之外 */
RT_ASSERT(rt_interrupt_get_nest() == 0);
/* 执行关键操作 */
// ...
/* 断言操作结果合理 */
RT_ASSERT(result > 0);
}
assert的好处是“早发现、早报告”。与其让栈溢出慢慢破坏数据,不如在破坏发生的瞬间就停下来。我见过一个项目,加了3个assert之后,原本每周复现一次的bug变成了每次必现——因为assert在数据刚被破坏时就抓住了它。
3.5 实战:栈溢出检测的完整方案
说了这么多,咱们来一个完整的检测方案。我把它总结成三步:
- 静态估算:用最坏路径法估算栈大小,翻倍设初值
- 动态监测:用ulog定期输出栈使用率,设置告警阈值
- 边界守卫:在栈顶和栈底填充魔术字,定期检查是否被改写
第三步的魔术字方法,RT-Thread其实已经内置了。在创建线程时,内核会在栈空间填充0xDEADBEEF之类的标记。你可以用rt_thread_control()来检查:
/* 检查栈是否溢出 */
rt_err_t check_stack_overflow(rt_thread_t thread)
{
rt_uint32_t *stack_top;
rt_uint32_t i;
stack_top = (rt_uint32_t *)thread->stack_addr;
/* 检查栈顶的8个字是否还是初始值 */
for (i = 0; i < 8; i++) {
if (stack_top[i] != 0xDEADBEEF) {
return -RT_ERROR; // 栈溢出了!
}
}
return RT_EOK;
}
把这个检查放在低优先级线程里,每隔几秒跑一次。一旦发现栈顶被改写,立刻用ulog输出ERROR级别的日志,并记录当时的PC值。这样,即使系统最终崩溃,你也有线索可查。
嗯,这套方案我在三个量产项目里验证过,效果都不错。最典型的一个案例是:某数据采集终端,运行一周后必死机。加上栈监测后发现,有个线程在特定数据包长度下会递归调用过深,栈使用率飙到95%。把栈从512字节调到1024字节后,问题彻底解决。
记住,栈溢出检测不是一次性的工作。产品迭代过程中,每增加一个功能、每修改一个调用链,都要重新评估栈大小。把监测代码留在固件里,即使发布版本也可以保留ulog的WARN以上级别输出——这点开销,换来的稳定性保障,绝对值。