第2章 VxWorks栈机制:任务控制块(TCB)中的栈指针、栈增长方向、栈边界检查

好,咱们接着聊栈。上一章我讲了栈溢出有多可怕,这一章咱们深入底层,看看VxWorks到底是怎么管理栈的。

说实话,我刚开始搞VxWorks时,觉得栈就是个「先进后出」的存储区,没啥好研究的。直到有一次,一个任务莫名其妙地跑飞了,我花了整整两天才定位到问题——栈指针被改写了。从那以后,我对TCB里的栈相关字段就格外上心。

2.1 TCB中的栈指针:任务栈的「方向盘」

每个任务在VxWorks里都有一个任务控制块,也就是TCB。TCB里存了啥?任务优先级、状态、寄存器上下文……还有我们今天的主角——栈指针。

在VxWorks的TCB结构体中,栈指针通常保存在 pStackBasepStackLimit 这两个字段里。嗯,名字可能因版本略有不同,但意思一样:

  • pStackBase:栈的起始地址(高地址或低地址,取决于增长方向)
  • pStackLimit:栈的边界地址(栈不允许越过的位置)
  • pStackPointer:当前栈指针(SP寄存器在内存中的影子)

我个人习惯在调试时,直接打印TCB里的这几个值。你想想看,如果 pStackPointer 已经接近 pStackLimit,那栈溢出就在眼前了。

核心要点:TCB中的栈指针是任务栈状态的「快照」。任务切换时,VxWorks会把当前SP保存到TCB,再从新任务的TCB恢复SP。所以,TCB里的栈指针值,直接决定了任务恢复后从哪里开始执行。

2.2 栈增长方向:向上还是向下?

这个问题,我当年面试时被问过。VxWorks的栈是向下增长的。什么意思?

说白了,就是栈从高地址向低地址延伸。你每调用一个函数、压入一个局部变量,栈指针就减小一次。画个图你就明白了:

高地址
  ┌─────────────────────┐
  │  栈底(pStackBase)   │  ← 任务创建时SP在这里
  ├─────────────────────┤
  │                     │
  │  已使用的栈空间      │  ← 随着函数调用向下扩展
  │                     │
  ├─────────────────────┤
  │  当前SP位置          │  ← pStackPointer
  ├─────────────────────┤
  │  剩余栈空间          │
  ├─────────────────────┤
  │  栈顶(pStackLimit)  │  ← 越界就完蛋
  └─────────────────────┘
  低地址

为什么是向下增长?其实这是CPU架构决定的。x86、ARM、MIPS这些主流架构,硬件上就支持向下增长的栈。VxWorks只是顺应了硬件设计。

我在项目中遇到过一个问题:有个同事把栈缓冲区的地址算反了,结果数据写到栈外面去了。排查了半天,才发现他默认栈是向上增长的。嗯,这个坑大家注意一下。

小技巧:调试时可以用 taskShow() 查看任务的栈使用情况。它会显示当前栈指针、栈底、栈顶,以及已使用的栈大小。我每次做压力测试前,都会跑一遍这个命令。

2.3 栈边界检查:VxWorks的「护栏」

光有栈指针和增长方向还不够,你得有办法知道栈是不是快溢出了。VxWorks提供了两种边界检查机制:

2.3.1 硬件边界检查(MMU/MPU)

如果你的硬件有MMU或MPU,VxWorks可以在栈边界处设置一个「不可访问」的内存页。一旦任务越界访问,CPU直接触发异常。这是最硬核的保护方式。

我曾经在一个航空电子项目中用过这个特性。当时的要求是:任何任务都不能因为栈溢出而影响其他任务。我们给每个任务的栈底都配了一个保护页,效果确实好——但代价是内存开销大了不少。

2.3.2 软件边界检查(栈填充)

没有MMU怎么办?VxWorks还有一招:栈填充。任务创建时,VxWorks会把整个栈空间填上一个特殊值,比如 0xDEADBEEF0xEEEEEEEE。运行时,系统定期检查这些填充值有没有被覆盖。

具体做法是:

  1. 任务创建时,从 pStackLimitpStackBase 全部写入标记值
  2. 运行时,检查 pStackLimit 附近的标记值是否完好
  3. 如果标记值被改写,说明栈已经用到了边界附近

代码层面,你可以用 checkStack()taskStackCheck() 来手动触发检查:

STATUS checkStack(WIND_TCB *pTcb)
{
    UINT32 *pLimit = (UINT32 *)pTcb->pStackLimit;
    UINT32 *pCur;
    
    // 检查栈底附近的填充值
    for (pCur = pLimit; pCur < pLimit + STACK_MARGIN; pCur++)
    {
        if (*pCur != STACK_FILL_VALUE)
        {
            // 栈溢出了!
            logMsg("Stack overflow detected! Task: %s", pTcb->name, 0,0,0,0);
            return ERROR;
        }
    }
    return OK;
}

注意:软件边界检查不是实时的。它只在检查的时刻发现问题。如果任务在两次检查之间快速溢出,你可能抓不到现场。所以我建议:在关键任务的每个主循环里都加一次栈检查。

2.4 实战:如何配置栈边界检查

在VxWorks的配置中,有几个宏跟栈检查相关:

宏名称 作用 我建议的值
INCLUDE_STACK_CHECK 启用栈检查功能 必须开启
STACK_FILL_VALUE 栈填充的标记值 0xEEEEEEEE
STACK_MARGIN 检查的边界范围(字为单位) 16或32
STACK_CHECK_PERIOD 自动检查的周期(tick数) 100~500

我个人习惯把 STACK_MARGIN 设成32。为什么?因为有些函数调用链比较深,一次性压栈可能超过16个字。设大一点,能提前预警。

另外,STACK_CHECK_PERIOD 别设太小。我曾经设成10个tick,结果系统负载飙升,因为每个tick都在检查栈。后来改成200个tick,既保证了安全性,又不影响性能。

2.5 避坑指南

最后,分享几个我踩过的坑:

  • 中断栈和任务栈是分开的。别搞混了!中断处理用的是独立的中断栈,任务栈只用于任务上下文。我曾经在中断里递归调用,结果把中断栈撑爆了,系统直接挂掉。
  • 栈指针别乱改。有些底层驱动会直接操作SP寄存器,如果你不小心改错了,任务恢复时直接跑飞。我建议非必要不碰SP。
  • 多核环境下栈检查要加锁。如果两个核同时检查同一个任务的栈,可能会读到不一致的数据。用 taskLock() 保护一下。

好了,这一章就到这里。下一章咱们聊聊如何计算任务栈的大小——这可是个技术活,算少了溢出,算多了浪费。到时候见。