0. 先看全景
一个典型的 FreeRTOS 应用的 main() 长这样:
c
int main(void)
{
hardware_init(); /* 时钟、GPIO、外设......厂商代码 */
xTaskCreate(led_task, "led", 128, NULL, 2, NULL); /* 建任务 */
vTaskStartScheduler(); /* 启动调度器,永不返回 */
for( ;; ); /* 到这里就是出事了 */
}
vTaskStartScheduler() 之后再也不会回来。这句话的字面意思,正是本文要解释的全部内容 :调度器启动的过程,本质上是一次"乾坤大挪移"------CPU 从"跑 main 的裸机世界"被切换到"跑任务的世界",而执行这次切换的每一步关键代码,都在 port 层(移植层)里。
FreeRTOS 的目录结构把这件事体现得很清楚:
freertos-V5/
├── tasks.c ← 内核:调度器逻辑(与架构无关)
└── portable/
├── MemMang/memory_impl.c ← 静态内存回调(架构无关,应用提供)
└── GCC/RISC-V/
├── port.c ← 架构初始化:定时器、启动调度器
├── portASM.S ← 上下文切换、中断入口(汇编)
└── portmacro.h ← 移植接口宏定义
内核(tasks.c)负责"决定谁该运行",port 层负责"让 CPU 真的切过去"。下面按时间顺序,把这条链上的每个站点讲清楚。
1. 铺垫:静态内存回调 ------ 空闲任务的内存从哪来
调度器启动时有一个绕不开的问题:空闲任务(Idle Task)是内核强制创建的(优先级 0、永远存在),应用代码没有机会像创建普通任务那样把内存传给它。
FreeRTOS 的解决方案是一组回调接口。当 FreeRTOSConfig.h 中 configSUPPORT_STATIC_ALLOCATION = 1 时,内核会调用应用提供的这个函数来"索取"空闲任务的内存:
c
/* portable/MemMang/memory_impl.c */
void vApplicationGetIdleTaskMemory( StaticTask_t **ppxIdleTaskTCBBuffer,
StackType_t **ppxIdleTaskStackBuffer,
uint32_t *pulIdleTaskStackSize )
{
PRIVILEGED_DATA static StaticTask_t xIdleTaskTCB;
PRIVILEGED_DATA static StackType_t uxIdleTaskStack[ configMINIMAL_STACK_SIZE ];
*ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
*ppxIdleTaskStackBuffer = uxIdleTaskStack;
*pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
}
几个值得注意的细节:
- 必须
static。注释写得很直白:如果不加 static,变量分配在栈上,函数一退出内存就没了,而空闲任务要永远活着。PRIVILEGED_DATA宏把变量放进特权数据段(MPU 相关端口使用)。 - 三个参数都是"出参" 。这就是为什么 TCB 的参数类型是
StaticTask_t **(二级指针):内核手里有一个StaticTask_t *pxIdleTaskTCB变量等着被赋值,想修改一个指针变量,就必须把它的地址传进来。*ppxIdleTaskTCBBuffer = &xIdleTaskTCB;解引用一次,写的就是内核那个指针本身。 - 栈大小单位是"字"不是字节 。
configMINIMAL_STACK_SIZE = 128在 32 位核上实际是 512 字节。
同文件里还有一个结构完全相同的 vApplicationGetTimerTaskMemory(),服务于软件定时器任务(configUSE_TIMERS = 1 时必需)。这套静态方案的好处:零动态分配、无碎片、内存占用在链接期即可确定 ------这也是文件头注明 "copied from https://www.freertos.org/a00110.html" 官方模板的原因。
2. 内核侧的准备:vTaskStartScheduler() 做了什么
vTaskStartScheduler() 的主体在 tasks.c 中,按顺序做了这几件事(关键代码见 tasks.c:2000-2099:
c
/* 1. 创建空闲任务 ------ 静态分配路径 */
vApplicationGetIdleTaskMemory( &pxIdleTaskTCBBuffer, &pxIdleTaskStackBuffer,
&ulIdleTaskStackSize );
xIdleTaskHandle = xTaskCreateStatic( prvIdleTask, configIDLE_TASK_NAME,
ulIdleTaskStackSize, NULL,
portPRIVILEGE_BIT,
pxIdleTaskStackBuffer, pxIdleTaskTCBBuffer );
/* 2. (可选)创建定时器服务任务 */
/* 3. 关中断 ------ 防止启动过程中 tick 提前到来 */
portDISABLE_INTERRUPTS();
/* 4. 初始簿记 */
xNextTaskUnblockTime = portMAX_DELAY;
xSchedulerRunning = pdTRUE;
xTickCount = ( TickType_t ) configINITIAL_TICK_COUNT;
/* 5. 交给 port 层点火 */
if( xPortStartScheduler() != pdFALSE )
{
/* 永远到不了这里 */
}
注意第 3 步的注释:"The stacks of the created tasks contain a status word with interrupts switched on so interrupts will automatically get re-enabled when the first task starts to run." ------此时此刻任务还没跑,但中断关闭这件事已经和"任务恢复时的 mstatus"绑定了。这是后文 pxPortInitialiseStack 的伏笔,请记住它。
xTaskCreateStatic 内部会走到 port 层的另一个函数:
c
/* tasks.c:1049 */
pxNewTCB->pxTopOfStack = pxPortInitialiseStack( pxTopOfStack, pxTaskCode, pvParameters );
这个函数的任务:把一片空白内存伪装成"这个任务刚刚被中断打断"的样子。我们在第 5 节详细展开,因为它正是"第一个任务能被启动"的全部秘密。
3. port.c:xPortStartScheduler() ------ 架构级初始化
从这里开始进入 RISC-V port 层:
c
BaseType_t xPortStartScheduler( void )
{
/* ① 校验:mtvec 必须是直接模式(低 2 位为 0)、ISR 栈对齐 */
volatile uint32_t mtvec = 0;
__asm volatile( "csrr %0, mtvec" : "=r"( mtvec ) );
configASSERT( ( xISRStackTop & portBYTE_ALIGNMENT_MASK ) == 0 );
/* ② 配置 tick 定时器 */
vPortSetupTimerInterrupt();
/* ③ 开中断源:mie 的 bit7(机器定时器) + bit11(外部中断) */
__asm volatile( "csrs mie, %0" :: "r"(0x880) );
/* ④ 点火,不返回 */
xPortStartFirstTask();
return pdFAIL; /* 只有崩溃才会执行到 */
}
三个步骤各自的含义:
① ISR 栈
RISC-V port 的中断服务程序使用独立的栈 (xISRStack),不复用被打断任务的栈。两种供给方式:
- 定义
configISR_STACK_SIZE_WORDS:静态数组; - 否则:用链接脚本导出的
__freertos_irq_stack_top------复用 main() 用过的栈。调度器启动后 main 永远不会回来,它的栈正好可以"废物利用"。
configCHECK_FOR_STACK_OVERFLOW > 2 时还会用 0xee 填充 ISR 栈做溢出检测(特意不用任务栈的 0xa5,避免和内核的栈填充值混淆)。
② tick 定时器
vPortSetupTimerInterrupt() 是个 weak 函数,允许厂商替换。默认实现操作 RISC-V 标准 mtime/mtimecmp:
c
ullNextTime = 读当前 mtime; /* 高/低字两次读、防抖 */
ullNextTime += uxTimerIncrementsForOneTick; /* = CPU_HZ / TICK_RATE_HZ */
*pullMachineTimerCompareRegister = ullNextTime; /* 写 mtimecmp → 开始倒计时 */
ullNextTime += uxTimerIncrementsForOneTick; /* 预备下一次 */
其中 pullMachineTimerCompareRegister 的地址按 mhartid(硬件线程号)偏移------多核时每个核有自己的比较寄存器。
③ mie vs mstatus
这里只打开了中断源开关 mie(哪些中断允许申请),但全局开关 mstatus.MIE 仍然是 0。所以此刻依然不会真的进中断------真正的"总闸"要等 mret 指令根据恢复的 mstatus 值来合上。这个两层开关结构贯穿整个启动流程。
4. portASM.S:xPortStartFirstTask() ------ 永不返回的"点火"
真正的一锤定音在汇编里:
asm
xPortStartFirstTask:
la t0, trap_entry
csrw mtvec, t0 /* ① mtvec 指向 FreeRTOS 的 trap 入口 */
csrci 0x7D0, 2 /* ② 清 Andes mmisc_ctl 的向量模式位 */
lui t0, 0xC4000 /* ③ 清 PLIC reg_irq_feature 的 VECTORED 位 */
lw t1, 0(t0)
andi t1, t1, -3
sw t1, 0(t0)
csrci mstatus, 8 /* ④ 确保全局中断关闭 */
portasmRESTORE_REGISTERS /* ⑤ 恢复第一个任务的全部上下文 */
mret /* ⑥ "返回"进任务 */
①③④ Telink/Andes 定制:必须关掉向量中断分发
启动代码(cstartup_flash.S)为了提升中断性能,使能了 Andes 扩展的向量分发模式 (mmisc_ctl bit1 + PLIC reg_irq_feature bit1)。问题在于:向量模式下,mtime 中断会被硬件直接派发到 mtvec 基址 + 中断号×4 的向量槽,根本到不了 trap_entry------调度器从此收不到 tick,系统假死。
所以 port 层在这里做了三件事:把 mtvec 重新指向 trap_entry,并关掉两处向量使能位。注释特别说明这是竞态安全 的:此刻 mstatus.MIE=0,改这些寄存器不会有中断插进来。
⑤⑥ 核心魔法:"伪造一次异常返回"
portasmRESTORE_REGISTERS 宏做的事:
- 读全局变量
pxCurrentTCB------此刻指向最高优先级任务(调度器启动前内核已经选定); - 从 TCB 的第一个成员取出该任务的栈顶指针(FreeRTOS 约定 TCB 首成员就是
pxTopOfStack,汇编可以直接load_x sp, 0(t1)拿到); - 栈帧第 0 个字 → 写入
mepc(将来mret要跳去的地址,即任务函数入口); - 栈帧第 29 个字 → 写入
mstatus(任务运行时的状态字,含中断使能); - 依次恢复 x1--x31 全部通用寄存器。
然后 mret:RISC-V 的"从 trap 返回"指令,CPU 跳到 mepc,并按 mstatus.MPIE 恢复中断开关。PC 从此落在任务函数的第一行,仿佛这个任务一直在运行、只是刚刚从中断里返回。
到这里,main() 的世界正式终结:它的栈被(可选地)移交给 ISR 使用,它的返回地址再也没人关心。这也解释了 vTaskStartScheduler() 为什么"永不返回"------不是逻辑上不返回,而是返回路径(ra 寄存器和调用栈)已经被整个换掉了。
5. 关键伏笔回收:pxPortInitialiseStack ------ 任务的"出生证"
第 2 节留了一个问题:新建任务的栈凭什么能被 portasmRESTORE_REGISTERS 恢复?答案是任务创建时就被刻意布置成了"刚被中断打断"的假象。:
asm
pxPortInitialiseStack:
/* a0=栈顶, a1=任务函数, a2=任务参数(RISC-V ABI) */
csrr t0, mstatus
andi t0, t0, ~0x8 /* 清 MIE:恢复瞬间中断是关的 */
li t1, 0x188
slli t1, t1, 4 /* 0x1880 = MPIE | MPP=11(机器模式) */
or t0, t0, t1
addi a0, a0, -portWORD_SIZE
store_x t0, 0(a0) /* 栈顶:mstatus */
addi a0, a0, -(22 * portWORD_SIZE)
store_x a2, 0(a0) /* 任务参数 → 将来恢复进 a0 */
addi a0, a0, -(6 * portWORD_SIZE)
store_x x0, 0(a0) /* ra ← portTASK_RETURN_ADDRESS */
/* ...为芯片私有寄存器留位... */
addi a0, a0, -portWORD_SIZE
store_x a1, 0(a0) /* 栈底:任务函数入口地址(→ mepc) */
ret
栈帧从高地址到低地址依次是(完整布局见 portASM.S:411-441 的注释块):
高地址 │ mstatus ← MPIE=1, MPP=11:mret 后中断自动打开、保持机器模式
│ x31 ... x11 ← 全 0(ABI 约定 caller-saved,初值无所谓)
│ x10 (a0) ← pvParameters!任务参数就从这进
│ x9 ... x5 ← 初值 0
│ ra ← prvTaskExitError(任务函数"返回"时跳这,死循环报错)
│ [芯片私有寄存器]
低地址 │ pxCode ← 任务函数地址,恢复时写入 mepc
三个精妙之处:
- 参数传递 :任务原型是
void task(void *arg),但任务永不"被调用"。port 把pvParameters直接放进了未来恢复的a0(RISC-V 第一个参数寄存器)------任务一睁眼,参数就在手里。 - 中断的自动开启 :mstatus 预置
MPIE=1,所以mret执行后MIE自动置 1。第 2 节末尾那句"interrupts will automatically get re-enabled when the first task starts to run"指的就是这个。 - 任务"返回"的下场 :ra 被填成
prvTaskExitError------任务函数绝不该返回,谁写了return谁就会掉进这个断言死循环,把 bug 暴露出来。
这套设计的哲学:第一个任务的启动和后续的任务切换走的是完全相同的代码路径。 xPortStartFirstTask 没有"启动任务"的专用逻辑,它只是"恢复一个已经布置好的现场"。少一条特殊路径,就少一个出 bug 的角落。
6. 世界开始运转:trap_entry 与两种切换
任务跑起来之后,port 层的工作变成两件事:响应 tick 和 切换任务 。两者汇合于同一个入口 trap_entry:
6.1 中断驱动的切换(被动)
mtime 计数到 mtimecmp 触发机器定时器中断,硬件跳到 mtvec(即 trap_entry):
asm
trap_entry:
portasmSAVE_REGISTERS /* 保存全部现场到当前任务栈,sp 存进 TCB */
csrr a0, mcause
srli a2, a0, __riscv_xlen-1 /* mcause 最高位=1 表示异步中断 */
beq a2, x0, handle_synchronous
/* 判定 mcause == 0x80000007:机器定时器中断 */
/* ...更新 64 位 mtimecmp(32 位核要拆高低字、防回绕)... */
load_x sp, xISRStackTop /* 切到 ISR 栈再调 C 函数 */
jal xTaskIncrementTick /* 内核记账、唤醒到期任务 */
beqz a0, processed_source /* 没人被唤醒就不切换 */
jal vTaskSwitchContext /* 内核选出下一个 pxCurrentTCB */
processed_source:
portasmRESTORE_REGISTERS /* 此时 pxCurrentTCB 可能已换人 */
mret
值得注意的两个细节:
- 切到 ISR 栈再调 C 函数 :任务栈深度不可控,C 函数调用链统一压在专用 ISR 栈上,任务栈只保存纯粹的寄存器现场,深度恒定(
30 * portWORD_SIZE)。 xTaskIncrementTick的返回值决定是否切换 :tick 到了但没有任何任务被唤醒,就免去一次vTaskSwitchContext的开销。FreeRTOS 在每个环节都抠性能。
6.2 任务主动让出(主动)
任务调用 vTaskDelay()/xQueueReceive() 等阻塞 API,最终走到 portYIELD(),展开成对 xPortYield 的调用:
asm
xPortYield:
csrrci t0, mstatus, 8 /* 关中断,留档原 MIE */
andi t0, t0, 8
slli t0, t0, 4 /* 原 MIE → 挪到 MPIE 位 */
li t1, 0x80
csrc mstatus, t1
csrs mstatus, t0 /* 手工构造 MPIE,因为没走硬件 trap */
portasmSAVE_REGISTERS
load_x sp, xISRStackTop
jal vTaskSwitchContext
portasmRESTORE_REGISTERS /* 直接 fall through */
mret
注意这里没有 trap 发生,硬件不会帮忙设置 MPIE/MPP,所以开头几行手工把原 MIE 搬到 MPIE 位------模拟硬件 trap 对 mstatus 做的事。这是软件触发上下文切换和中断触发的唯一差别。
6.3 一个真实踩过的坑:MPP 位
portasmRESTORE_REGISTERS 里有一段本仓库加的修复:
asm
/* 恢复 mstatus 前强制 MPP=0b11 */
li t1, 0x1800
or t0, t0, t1
csrw mstatus, t0
注释记录了完整案情:mret 执行后,硬件会把 MPP(mstatus 的 12:11 位,表示 trap 前的特权级)复位到最低权限模式;而 xPortYield 保存 mstatus 时没经过硬件 trap,MPP 位不会被刷新,存的是过期值 0。恢复这个过期值后,mret 会把任务降级到 U 模式 ,PMP(物理内存保护)随即挡住 flash 取指------症状是 mcause=1(取指异常)、mdcause=2,炸点在 xQueueSemaphoreTake 里 xPortYield 的返回处。加两行汇编强制 MPP=机器模式,问题消失。
这个案例的价值在于:任务必须永远运行在 M 模式,而 RISC-V 的 mret 语义会悄悄破坏这一点。凡是"软件模拟 trap"的地方(tickless 唤醒、主动 yield),都要检查 mstatus 的这几个位。
7. Telink 定制部分:低功耗与异常现场
标准 port 之外,本仓库在 port.c 末尾追加了厂商定制:
| 函数 | 职责 |
|---|---|
vPortRestoreTick() |
低功耗唤醒后:用 stimer 差值 + vTaskStepTick() 补偿睡眠期间错过的 tick,再重设 mtimecmp 防立即触发 |
vPortSuppressTicksAndSleep_i() |
tickless idle 的 C 侧入口(配合 portASM.S 中的同名汇编包装,负责保存/恢复现场) |
vPortRestoreTask() |
唤醒路径:关 mstatus.MIE → 恢复 tick → vPortRestoreActiveTask() 重置 IDLE 任务栈 |
vPortDumpException() |
异常兜底:打印 mcause/mepc/mtval/mstatus/mdcause/mxstatus 后死循环 |
两个值得展开的设计:
vPortDumpException 必须放在 RAM 。它的注释解释了原因:trap_entry 本身在 RAM 中运行,而 RISC-V 的 jal(近跳转)偏移量有限,从 RAM 够不到 flash------这个函数若在 flash 里,异常处理根本调不到它。同时它刻意只用裸 printf、不碰任何 FreeRTOS 原语:能触发异常的任务可能正持有任何锁,此刻调度器已不可信,再做任何同步操作都是二次灾难。
调用关系 :trap_entry 判定同步异常(mcause 高位=0)后,把 mcause/mepc/mstatus/mtval/mdcause/mxstatus 六个 CSR 塞进 a0--a5 调用 vPortDumpException------汇编管现场,C 管输出,分工干净。
8. 把整条链串起来
main() [应用]
├─ 硬件初始化
├─ xTaskCreate / xTaskCreateStatic [内核] ──┐
│ └─ pxPortInitialiseStack [port] │ 伪造"刚被中断"的栈帧
├─ vTaskStartScheduler() [内核] │
│ ├─ vApplicationGetIdleTaskMemory [应用] │ 空闲任务的静态内存
│ ├─ xTaskCreateStatic(prvIdleTask) │ 空闲任务入列
│ ├─ portDISABLE_INTERRUPTS() [port] │ 先关总闸
│ └─ xPortStartScheduler() [port] ←┘
│ ├─ vPortSetupTimerInterrupt() 配 mtimecmp
│ ├─ csrs mie, 0x880 开中断源
│ └─ xPortStartFirstTask() [portASM]
│ ├─ mtvec ← trap_entry 接管中断
│ ├─ 关闭 Andes 向量分发 (Telink 定制)
│ ├─ portasmRESTORE_REGISTERS 从 pxCurrentTCB 恢复现场
│ └─ mret ────────────▶ 第一个任务的第一条指令
│ (main 的世界终结)
▼ 运行期
trap_entry ◀── mtime tick(被动切换) / xPortYield(主动切换)
├─ portasmSAVE_REGISTERS → 当前任务栈
├─ xTaskIncrementTick / vTaskSwitchContext → 选出下一个任务
└─ portasmRESTORE_REGISTERS → mret → 下一个任务
9. 总结:port 层的三个"不变式"
回看整条链,RISC-V port 层维护着三个不变式,理解了它们就看懂了所有移植代码:
- 每个任务(含第一个)都活在同一个假象里 :
pxPortInitialiseStack造现场,portasmRESTORE_REGISTERS + mret现场还原。启动即切换,切换即启动,一套代码。 - 中断开关的两层结构 :
mie管源(软件按需开)、mstatus.MIE管总闸(保存在任务栈帧里,随上下文一起切换)。谁该开中断,由"即将运行的任务"决定,而不是由切换动作决定。 - 异常返回的语义必须完整 :
mret会消费 MPIE/MPP。凡软件模拟 trap(yield、tickless 唤醒)的地方,都必须自己把这些位补对------MPP 那个 bug 就是忘了这条的代价。
而这套机制的底层哲学其实非常朴素:调度器的"启动",不过是第一次"切换"。