FreeRTOS 在 RISC-V 上是如何“点火“的 —— 从 main() 到第一个任务的完整链路

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.hconfigSUPPORT_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 宏做的事:

  1. 读全局变量 pxCurrentTCB------此刻指向最高优先级任务(调度器启动前内核已经选定);
  2. 从 TCB 的第一个成员取出该任务的栈顶指针(FreeRTOS 约定 TCB 首成员就是 pxTopOfStack,汇编可以直接 load_x sp, 0(t1) 拿到);
  3. 栈帧第 0 个字 → 写入 mepc(将来 mret 要跳去的地址,即任务函数入口);
  4. 栈帧第 29 个字 → 写入 mstatus(任务运行时的状态字,含中断使能);
  5. 依次恢复 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

三个精妙之处:

  1. 参数传递 :任务原型是 void task(void *arg),但任务永不"被调用"。port 把 pvParameters 直接放进了未来恢复的 a0(RISC-V 第一个参数寄存器)------任务一睁眼,参数就在手里。
  2. 中断的自动开启 :mstatus 预置 MPIE=1,所以 mret 执行后 MIE 自动置 1。第 2 节末尾那句"interrupts will automatically get re-enabled when the first task starts to run"指的就是这个。
  3. 任务"返回"的下场 :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,炸点在 xQueueSemaphoreTakexPortYield 的返回处。加两行汇编强制 MPP=机器模式,问题消失。

这个案例的价值在于:任务必须永远运行在 M 模式,而 RISC-V 的 mret 语义会悄悄破坏这一点。凡是"软件模拟 trap"的地方(tickless 唤醒、主动 yield),都要检查 mstatus 的这几个位。


标准 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 层维护着三个不变式,理解了它们就看懂了所有移植代码:

  1. 每个任务(含第一个)都活在同一个假象里pxPortInitialiseStack 造现场,portasmRESTORE_REGISTERS + mret 现场还原。启动即切换,切换即启动,一套代码。
  2. 中断开关的两层结构mie 管源(软件按需开)、mstatus.MIE 管总闸(保存在任务栈帧里,随上下文一起切换)。谁该开中断,由"即将运行的任务"决定,而不是由切换动作决定。
  3. 异常返回的语义必须完整mret 会消费 MPIE/MPP。凡软件模拟 trap(yield、tickless 唤醒)的地方,都必须自己把这些位补对------MPP 那个 bug 就是忘了这条的代价。

而这套机制的底层哲学其实非常朴素:调度器的"启动",不过是第一次"切换"

相关推荐
张宇Joaquin27 分钟前
鲲鹏统一并行加速库KUPL--众核并行能力介绍
java·开发语言·网络
736c33 分钟前
C语言-数组 07
c语言·开发语言
宸津-代码粉碎机1 小时前
AI攻防战升级!基于Spring AI构建Java应用自动免疫安全体系
java·大数据·开发语言·人工智能·python·安全·spring
2601_963749102 小时前
越华环保集团数字化污水治理:端边云采集架构与平台对接实现
java·大数据·架构
xcLeigh2 小时前
Go入门:整数类型的溢出与安全边界
java·安全·golang
LorryJovens3 小时前
【LAAP科研】CEN因果等价网络理论---从双缝干涉到因果等价
开发语言·php
源码宝3 小时前
SpringBoot+Vue2 诊所门诊管理系统,完整前后端源码,可直接部署上线
java·源码·his系统·门诊系统·程序代码·诊所系统·门诊his
yychen_java3 小时前
九:Text-to-SQL 智能数据查询与 Human-in-the-Loop 人机协作
java·人工智能·架构
Brilliantwxx3 小时前
【Linux】 进程(10) 进程控制深度解析:创建、终止与等待
linux·运维·服务器·c语言·开发语言·网络