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字节
我的经验:估算完再翻一倍作为安全余量,尤其是产品初期。等稳定运行一段时间后,再用实际监测数据来精调。我曾经在一个电机控制项目里,估算的栈大小是512字节,实际跑起来峰值用到480字节——差点就溢出了。

3.2 栈溢出原理

栈溢出到底是怎么发生的?咱们从底层看。

STM32的栈是“向下生长”的。也就是说,栈指针SP从高地址往低地址移动。每个线程在创建时,RT-Thread会分配一块连续内存作为栈空间,并记录栈顶和栈底。

/* 栈内存布局示意 */
高地址
+------------------+  ← 栈底 (stack_start + stack_size)
|                  |
|  已使用的栈空间   |
|                  |
+------------------+  ← 当前SP位置
|                  |
|  空闲栈空间       |
|                  |
+------------------+  ← 栈顶 (stack_start)
低地址

当线程不断调用函数、定义局部变量时,SP不断下移。一旦SP越过栈顶边界,写入的数据就会覆盖到相邻内存区域——可能是另一个线程的栈,也可能是全局变量区,甚至是RT-Thread的内核数据。

为什么会这样?因为ARM Cortex-M的硬件并不强制检查栈边界。它只管往SP指向的地址写数据,至于那个地址是不是合法的栈空间,它不关心。这就是为什么栈溢出往往不会立即触发异常,而是先“静默破坏”一段时间,等被破坏的数据被使用时才突然崩溃。

注意:栈溢出的典型症状是“随机死机”。今天跑3小时没问题,明天跑10分钟就挂了。因为破坏的数据不一定马上被用到,这给定位带来了很大困难。

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行出了问题。但注意,这个地址只是“案发现场”,不一定是“犯罪根源”。栈溢出的破坏可能发生在更早的时候,只是到这一行才触发异常。

我的习惯:拿到PC值后,先看它是不是在栈空间附近。如果PC指向了0x2000xxxx(SRAM区域),那基本可以断定是栈溢出导致函数返回地址被篡改,程序跑飞到数据区了。

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在数据刚被破坏时就抓住了它。

注意:assert只在调试版本(DEBUG)中生效。发布版本(RELEASE)中,所有assert都会被预处理器移除,不会影响性能。所以放心大胆地用。

3.5 实战:栈溢出检测的完整方案

说了这么多,咱们来一个完整的检测方案。我把它总结成三步:

  1. 静态估算:用最坏路径法估算栈大小,翻倍设初值
  2. 动态监测:用ulog定期输出栈使用率,设置告警阈值
  3. 边界守卫:在栈顶和栈底填充魔术字,定期检查是否被改写

第三步的魔术字方法,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值。这样,即使系统最终崩溃,你也有线索可查。

线程栈分析与溢出检测流程 1. 静态估算 最坏路径法估算栈大小 2. 动态监测 ulog定期输出栈使用率 3. 边界守卫 魔术字检查栈顶/栈底 栈溢出检测 魔术字被改写 HardFault触发 CPU进入异常处理 现场信息提取 PC/LR/SP寄存器分析 定位到具体代码行 addr2line + ulog日志 + assert断言

嗯,这套方案我在三个量产项目里验证过,效果都不错。最典型的一个案例是:某数据采集终端,运行一周后必死机。加上栈监测后发现,有个线程在特定数据包长度下会递归调用过深,栈使用率飙到95%。把栈从512字节调到1024字节后,问题彻底解决。

记住,栈溢出检测不是一次性的工作。产品迭代过程中,每增加一个功能、每修改一个调用链,都要重新评估栈大小。把监测代码留在固件里,即使发布版本也可以保留ulog的WARN以上级别输出——这点开销,换来的稳定性保障,绝对值。