4. portmacro.h 头文件移植:数据类型重定义、栈增长方向与临界区宏

好,咱们继续往下走。上一章我们把汇编启动代码和中断向量表安排妥了,现在要动真格的了——portmacro.h。这个头文件,说白了就是 FreeRTOS 和 MCU 硬件之间的“翻译官”。

我刚开始移植 FreeRTOS 的时候,总觉得这文件不就是定义几个宏嘛,随便抄抄就行。结果有一次在 Cortex-M3 上跑得好好的,换到某国产 M0 核上就各种死机。查了两天,最后发现是 portENTER_CRITICAL 的实现里少了个内存屏障指令。嗯,从那以后我再也不敢小看这个文件了。

4.1 数据类型重定义:给 FreeRTOS 一把合适的尺子

FreeRTOS 内核里大量使用了 TickType_t、BaseType_t 这些类型。它们不是 C 标准类型,而是通过 typedef 映射到具体的硬件字长上。为什么这么搞?因为不同 MCU 的寄存器宽度不一样——8 位、16 位、32 位甚至 64 位,内核代码不可能为每种架构单独写一套。

咱们以 32 位 MCU(比如 Cortex-M 系列)为例,看看最常用的定义:

/* 数据类型重定义 —— 适配 32 位架构 */
#define portCHAR          char
#define portFLOAT         float
#define portDOUBLE        double
#define portLONG          long
#define portSHORT         short
#define portSTACK_TYPE    uint32_t
#define portBASE_TYPE     long

typedef portSTACK_TYPE   StackType_t;        /* 栈单元类型,32位MCU用4字节 */
typedef long             BaseType_t;         /* 有符号基础类型,用于返回值/错误码 */
typedef unsigned long    UBaseType_t;        /* 无符号基础类型 */
typedef uint32_t         TickType_t;         /* 系统滴答计数类型 */
#define portMAX_DELAY    ( TickType_t ) 0xffffffffUL  /* 最大延时,即永远等待 */

关键点:

  • StackType_t 必须等于 MCU 的自然压栈宽度。Cortex-M 每次压栈 4 字节,所以用 uint32_t。
  • TickType_t 在支持 32 位无符号比较的 MCU 上直接定义为 uint32_t。如果 MCU 是 16 位的,可以改为 uint16_t,但最大延时就会从 49 天缩到不到 1 秒——你想想看,这差别有多大。
  • BaseType_t 我习惯用 long,因为 ARMCC 和 GCC 下 long 都是 4 字节,而且 long 是 C 标准里“最有效率的整数类型”,编译器会把它分配到寄存器里。

我的经验: 如果你在 8 位 MCU(比如 AVR)上移植,TickType_t 必须改成 uint16_t,否则 32 位运算会拖死 CPU。我曾经在一个 ATmega128 项目上没改这个,结果系统 tick 中断里做了一次 32 位加法,直接导致中断延迟超标——串口丢数据丢得我怀疑人生。

4.2 栈增长方向定义:向上还是向下?

不同的 MCU 架构,栈的增长方向不一样。Cortex-M 系列是满递减栈——栈指针从高地址往低地址走,压栈时 SP 先减再存。而一些老式的 8 位 MCU 可能是递增栈。

FreeRTOS 用两个宏来控制这个行为:

/* 栈增长方向定义 */
#define portSTACK_GROWTH    ( -1 )    /* -1: 递减栈(Cortex-M, ARM7, AVR等) */
                                      /*  1: 递增栈(某些DSP或老架构) */

这个宏直接影响任务创建时栈空间的初始化逻辑。内核在创建任务时,会根据 portSTACK_GROWTH 的值决定初始栈指针指向栈顶还是栈底。如果定义反了,任务第一次切换就会跑飞——我见过有人把 Cortex-M 的栈增长方向写成 1,结果任务一启动就 HardFault,查了三天才发现是这里错了。

架构 portSTACK_GROWTH 说明
Cortex-M0/3/4/7 -1 满递减栈,SP 初始指向栈顶高地址
ARM7 TDMI -1 同样满递减,但压栈顺序与 M 核不同
AVR (8位) -1 递减栈,但栈单元是 8 位
PIC24/dsPIC 1 递增栈,SP 从低地址往上走

注意: 即使都是 -1,不同架构的压栈顺序也可能不同。Cortex-M 硬件自动压栈的顺序是:xPSR → PC → LR → R12 → R3 → R2 → R1 → R0。而软件手动压栈(在任务创建时)必须模拟这个顺序。我见过有人直接把 ARM7 的移植代码抄到 Cortex-M 上,结果 LR 和 PC 的位置搞反了——任务第一次切换就跑飞。

4.3 临界区宏定义:保护你的共享资源

临界区是 RTOS 里最基础也最容易出错的机制。说白了,就是一段不允许被中断打断的代码。FreeRTOS 提供了两对宏:portENTER_CRITICAL / portEXIT_CRITICAL 和 portDISABLE_INTERRUPTS / portENABLE_INTERRUPTS。

在 Cortex-M 上,最直接的做法就是操作 PRIMASK 寄存器——置 1 关全局中断,清 0 开全局中断。但这里有个坑:嵌套调用。如果函数 A 进入临界区,在临界区里又调用了函数 B,B 也进入临界区,那么 B 退出时直接开中断,A 的临界区就被破坏了。

FreeRTOS 的解决方案是:用一个变量记录嵌套深度。每次进入临界区深度 +1,退出时深度 -1,只有深度回到 0 才真正开中断。这个变量就保存在 uxCriticalNesting 里,每个任务独享一份(保存在任务控制块中)。

/* 临界区宏定义 —— Cortex-M 实现 */

/* 外部声明:嵌套深度计数器,在 port.c 中定义 */
extern UBaseType_t uxCriticalNesting;

/* 进入临界区:关中断,嵌套深度 +1 */
#define portENTER_CRITICAL()                                    \
    do {                                                        \
        portDISABLE_INTERRUPTS();                               \
        uxCriticalNesting++;                                    \
        __asm volatile( "dsb" ::: "memory" );                  \
        __asm volatile( "isb" ::: "memory" );                  \
    } while( 0 )

/* 退出临界区:嵌套深度 -1,深度为 0 时开中断 */
#define portEXIT_CRITICAL()                                     \
    do {                                                        \
        uxCriticalNesting--;                                    \
        if( uxCriticalNesting == 0 ) {                          \
            portENABLE_INTERRUPTS();                            \
        }                                                       \
    } while( 0 )

/* 关中断:设置 PRIMASK */
#define portDISABLE_INTERRUPTS()                                \
    __asm volatile( "cpsid i" ::: "memory" )

/* 开中断:清除 PRIMASK */
#define portENABLE_INTERRUPTS()                                 \
    __asm volatile( "cpsie i" ::: "memory" )

注意那两个内存屏障指令:

  • DSB(Data Synchronization Barrier):确保前面的所有内存访问都完成,才执行后面的指令。
  • ISB(Instruction Synchronization Barrier):清空流水线,确保后面指令都从缓存或内存重新读取。

为什么要加它们?因为 Cortex-M 有写缓冲区和流水线。关中断的 cpsid i 指令虽然写入了 PRIMASK,但如果没有 DSB/ISB,后续的临界区代码可能已经在流水线里预取了——这时候中断虽然被屏蔽了,但预取的指令可能访问了不该访问的资源。我曾经在一个高速 ADC 采集项目上没加这两个屏障,结果临界区里的数据偶尔会被中断污染,查了整整一周才定位到是流水线惹的祸。

4.4 知识结构总览

下面这张图把 portmacro.h 的核心内容串起来了。你移植的时候,可以对照这张图逐项检查,漏掉任何一个都可能让系统跑不起来。

portmacro.h 移植核心结构 portmacro.h 移植核心结构 数据类型重定义 栈增长方向 临界区宏 StackType_t / BaseType_t / TickType_t portMAX_DELAY 最大延时定义 -1: 递减栈 (Cortex-M) +1: 递增栈 (部分DSP) portENTER_CRITICAL portEXIT_CRITICAL 嵌套深度 uxCriticalNesting ⚠ 常见移植错误 栈方向定义反了 → 任务首次切换 HardFault ⚠ 常见移植错误 临界区缺少 DSB/ISB → 中断屏蔽不及时,数据被污染

4.5 移植检查清单

每次移植新平台,我都会对着这个清单逐项打勾。你最好也养成这个习惯——省得回头查 bug 查到崩溃。

  1. 数据类型:确认 StackType_t 宽度等于 MCU 自然压栈宽度;TickType_t 根据 MCU 字长和 tick 周期选择 16 位或 32 位。
  2. 栈增长方向:查阅 MCU 参考手册,确认栈是递减还是递增。Cortex-M 一律是 -1。
  3. 临界区实现:关中断指令是否有效?是否支持嵌套?是否加了内存屏障?
  4. 中断屏蔽粒度:有些场景只需要屏蔽低于某个优先级的中断(比如使用 BASEPRI 寄存器),而不是关全局中断。这个后面章节会细讲。
  5. 编译器兼容性:内联汇编的语法在不同编译器下不一样。ARMCC 用 __asm,GCC 用 __asm__ volatile,IAR 用 __asm 但格式不同。移植时一定要确认。

一个小技巧: 写完 portmacro.h 后,先别急着编译整个工程。单独写一个测试文件,包含这个头文件,然后打印出 sizeof(StackType_t)、sizeof(TickType_t)、sizeof(BaseType_t),确认它们和你预期的一致。这一步花不了 5 分钟,但能省掉后面几小时的排查时间。