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 的核心内容串起来了。你移植的时候,可以对照这张图逐项检查,漏掉任何一个都可能让系统跑不起来。
4.5 移植检查清单
每次移植新平台,我都会对着这个清单逐项打勾。你最好也养成这个习惯——省得回头查 bug 查到崩溃。
- 数据类型:确认
StackType_t宽度等于 MCU 自然压栈宽度;TickType_t根据 MCU 字长和 tick 周期选择 16 位或 32 位。 - 栈增长方向:查阅 MCU 参考手册,确认栈是递减还是递增。Cortex-M 一律是 -1。
- 临界区实现:关中断指令是否有效?是否支持嵌套?是否加了内存屏障?
- 中断屏蔽粒度:有些场景只需要屏蔽低于某个优先级的中断(比如使用
BASEPRI寄存器),而不是关全局中断。这个后面章节会细讲。 - 编译器兼容性:内联汇编的语法在不同编译器下不一样。ARMCC 用
__asm,GCC 用__asm__ volatile,IAR 用__asm但格式不同。移植时一定要确认。
一个小技巧: 写完 portmacro.h 后,先别急着编译整个工程。单独写一个测试文件,包含这个头文件,然后打印出 sizeof(StackType_t)、sizeof(TickType_t)、sizeof(BaseType_t),确认它们和你预期的一致。这一步花不了 5 分钟,但能省掉后面几小时的排查时间。