从问题原理、官方仓库现状到完整移植与验证
适用实例:AS32X601(RV32IMAFDC)
在带有 F/D 扩展的 RISC-V MCU 上,IAR 能生成浮点指令,并不意味着 FreeRTOS 会自动保护浮点寄存器。只要移植层没有把 f0~f31 和 fcsr 纳入任务上下文,两个浮点任务之间的一次正常抢占,就可能把计算现场悄悄破坏。
本文以 AS32X601 为完整实例,为 FreeRTOS Kernel V10.5.1 的 IAR RISC-V 端口补充浮点上下文保存与恢复。实现同时覆盖 F、D 扩展,并采用 mstatus.FS 驱动的条件读写,避免纯整数任务在每次切换时都访问 32 个浮点寄存器。
本文实例基于 FreeRTOS Kernel V10.5.1、IAR RISC-V 和 AS32X601(RV32IMAFDC)。V11.3.0 已在官方 GCC RISC-V 端口加入 FPU 上下文保存,但同版本 IAR RISC-V 端口没有对应实现,因此旧版 IAR 工程仍需自行补齐移植层。本文代码不能不经核对直接套用到其他端口。
目录
- [1. 为什么必须保存浮点上下文](#1. 为什么必须保存浮点上下文)
- [2. FreeRTOS 官方仓库现状](#2. FreeRTOS 官方仓库现状)
- [3. 理解 mstatus.FS](#3. 理解 mstatus.FS)
- [4. 本文采用的方案](#4. 本文采用的方案)
- [5. 在 IAR 端口中加入 FPU 上下文](#5. 在 IAR 端口中加入 FPU 上下文)
- [6. 栈空间核算](#6. 栈空间核算)
- [7. IAR 工程配置](#7. IAR 工程配置)
- [8. 如何验证保存与恢复](#8. 如何验证保存与恢复)
- [9. 使用边界和升级建议](#9. 使用边界和升级建议)
- [10. 总结](#10. 总结)
1. 为什么必须保存浮点上下文
1.1 一个典型的故障过程
假设系统中有两个可抢占任务,它们都进行浮点运算:

任务 A 的 C 变量看起来都在自己的栈中,但编译器会把中间结果长时间保留在浮点寄存器中。若上下文切换只保存 x1~x31、mepc 和 mstatus,任务 B 便能无意中修改任务 A 的浮点现场。
需要特别区分两件事:
- 工具链支持 FPU :编译器和汇编器能够生成 fadd、fmul、fsw、fsd 等指令。
- RTOS 保护 FPU 上下文 :任务切出时保存 FP 寄存器,任务切入时恢复该任务自己的值。
前者不会自动带来后者。浮点运算在裸机单线程程序中正常,只能说明硬件和工具链可用,不能证明多任务切换是安全的。
1.2 为什么这种问题很难复现
浮点上下文缺失通常不是每次都出错。它要求任务恰好在一个浮点值仍驻留寄存器时被抢占,并且另一个执行流随后覆盖了相关寄存器。因此,下面这些变化都可能让现象暂时消失:
- 修改编译优化级别,导致寄存器分配变化;
- 增加日志,使调度时序和寄存器活跃区间变化;
- 降低 tick 频率,减少关键窗口内被抢占的概率;
- 测试任务实际使用软浮点库函数,而没有生成预期的硬件 FP 指令;
- 浮点常量被编译器提前折叠,运行时根本没有发生浮点计算。
所以正确的验证方法不是"打印几次结果都正常",而是主动制造高频抢占、确认反汇编存在 FP 指令,并长时间比较数值结果。
2. FreeRTOS 官方仓库现状
截至 2026 年 7 月 22 日,官方仓库存在明确的版本和工具链边界:
|--------------------|----------------------------|
| 内核与端口 | RISC-V FPU 上下文状态 |
| V10.5.1 GCC RISC-V | 未集成 |
| V10.5.1 IAR RISC-V | 未集成 |
| V11.3.0 GCC RISC-V | 已集成,通过 configENABLE_FPU 启用 |
| V11.3.0 IAR RISC-V | 在该标签的端口源码中未发现等价实现 |
官方依据如下:
- FreeRTOS Kernel V11.3.0 发布说明明确列出新增 RISC-V FPU context save support。
- V11.3.0 GCC RISC-V portContext.h包含 configENABLE_FPU、FS 掩码、F/D 访存宏,以及 f0~f31 和 fcsr 的保存恢复代码。
- V11.3.0 IAR RISC-V portContext.h没有对应的 configENABLE_FPU 和 FP 上下文宏。
- V10.5.1 GCC RISC-V portContext.h及 V10.5.1 IAR RISC-V portContext.h均没有该功能。
这里对 IAR 的判断是对指定 Git 标签源码的检查结果,不应扩展为"FreeRTOS 永远不支持 IAR FPU",也不能把 GCC V11.3.0 的结论写成"所有 RISC-V 工具链端口都已支持"。
2.1 官方 V11.3.0 GCC 端口怎么做
官方 GCC 端口的核心思路是:
- 通过 configENABLE_FPU == 1 显式启用。
- 使用编译器提供的 __riscv_flen 选择 flw/fsw 或 fld/fsd。
- 根据 mstatus.FS 判断当前 FP 状态是否为 Dirty。
- Dirty 时才向下扩展任务 SP,保存 f0~f31 与 fcsr;非 Dirty 时不创建 FP 帧。
- 无论 FP 帧是否存在,当前保存 SP 都以 mepc、mstatus 两个公共 word 开头,恢复代码据此读取保存的 FS。
- Dirty 上下文落栈后把硬件当前 FS 标为 Clean,但栈中保存的原始 FS 仍保持 Dirty。
官方并不会扫描任务代码,也不会自动扩大 xTaskCreate() 请求的任务栈。它只在上下文切换时观察硬件 FS:纯整数任务的已保存上下文没有 FP 帧;可能执行硬件浮点的任务仍需由用户按最坏情况留足栈空间。
本文采用相同的公共帧头和 FS 自描述可变帧算法,同时做两项必要适配:IAR 汇编器用全局唯一的保存/恢复子程序避免宏内重复标签;AS32X601 的 RV32D 帧增加 4 字节对齐修正,保证 fld/fsd 使用自然对齐地址。因此,本文实现应称为"官方风格的 IAR 适配",而不是 GCC 文件的逐字复制。
3. 理解 mstatus.FS
RISC-V mstatus 的 FS 字段位于 bit 14:13,用于描述浮点单元状态:
|------------|------------|----------------|--------------|
| FS | 名称 | 含义 | 切换策略 |
| 00 | Off | 浮点状态关闭 | 不访问 FP 寄存器 |
| 01 | Initial | 已启用,但尚无需要保存的修改 | 跳过保存 |
| 10 | Clean | FP 状态与已保存状态一致 | 跳过保存 |
| 11 | Dirty | FP 状态已被修改 | 保存完整 FP 上下文 |
当任务执行会改变浮点状态的指令后,符合规范的硬件会把 FS 标记为 Dirty。上下文切换代码因此不需要扫描任务代码,也不需要要求每个浮点任务手动调用登记函数,只需检查 FS。
需要保护的 FP 上下文包括:
- f0~f31:32 个浮点寄存器;
- fcsr:包含浮点舍入模式和累计异常标志。
只保存 f0~f31 而遗漏 fcsr,会让不同任务之间的舍入模式或异常状态互相污染。
3.1 __riscv_flen决定保存宽度****
工具链通常根据目标 ISA 定义 __riscv_flen:
- __riscv_flen == 32:F 扩展,使用 fsw/flw,每个 FP 寄存器保存 4 字节;
- __riscv_flen == 64:D 扩展,使用 fsd/fld,每个 FP 寄存器保存 8 字节;
- 未定义:当前构建不包含 F/D 浮点扩展,FP 代码应被排除。
__riscv_flen 描述浮点寄存器宽度,不等同于 __riscv_xlen。RV32 核心同样可以实现 64 位宽的 D 扩展浮点寄存器。
4. 本文采用的方案
本文采用"mstatus.FS 自描述的可变 FP 帧 ":
- 通过 configENABLE_FPU == 1 显式启用,并用 __riscv_flen 选择 F/D 保存宽度。
- 整数上下文统一以 mepc、mstatus 两个公共 word 开头,整数寄存器从 slot 2 开始。
- 新任务只创建整数帧,把 FS 初始化为 Initial,不预留 FP 帧。
- 任务切出时检查当前 FS;只有 Dirty 才执行 sp -= portFPU_CONTEXT_SIZE 并保存完整 FP 状态。
- 任务切入时读取当前保存帧 slot 1 中的 FS;只有 Dirty 才恢复 FP 状态并抬高 SP。
- 非 Dirty 路径既不读写 FP 寄存器,也不移动 SP。
|------------|------------------------------------------|----------------------------------|
| 属性 | 本文 IAR V10.5.1 适配 | 官方 V11.3.0 GCC 方案 |
| 启用条件 | configENABLE_FPU == 1 加 IAR __riscv_flen | configENABLE_FPU == 1 加编译器 ISA 宏 |
| 帧存在性 | 保存的 mstatus.FS | 保存的 mstatus.FS |
| 非 Dirty 时 | 不访问 FP 寄存器,不移动 SP | 不访问 FP 寄存器,不移动 SP |
| Dirty 时 | 动态压入 f0~f31 与 fcsr | 动态压入 f0~f31 与 fcsr |
| 汇编组织 | IAR 全局子程序,避免宏标签重复 | GCC 汇编宏 |
| RV32D 对齐 | AS32X601 增加 4 字节尺寸修正 | 使用官方通用布局 |
__riscv_flen 仍然只是移植层的编译期 ISA 能力宏,不是"当前任务使用了浮点"的运行时标志。真正按任务区分上下文的是硬件 FS;FreeRTOS 不扫描任务函数,也不会替用户调整栈深度。
5. 在 IAR 端口中加入 FPU 上下文
下面以 FreeRTOS V10.5.1 的 IAR RISC-V 目录为例。开始修改前,请先备份以下文件:
本文假定端口已经定义:
- portWORD_SIZE:RV32 下为 4;
- store_x/load_x:整数 word 的保存和加载伪指令;
- portcontextSAVE_CONTEXT_INTERNAL 和 portcontextRESTORE_CONTEXT:原有整数上下文宏;
- pxCurrentTCB:当前任务 TCB 指针;
- CSR_MSTATUS:mstatus 的 CSR 定义。
如果你的端口命名不同,应先完成符号映射,不要直接粘贴。
5.1 在 portContext.h定义 F/D 相关宏****
将下面代码放在整数寄存器宽度与 store_x/load_x 定义之后:

configENABLE_FPU 必须同时对 IAR C 编译器和汇编预处理器可见。启用它却没有选择 F/D 目标 ISA时直接报错,避免生成"配置声称有 FPU、汇编器却没有 FP 指令"的半有效固件。
5.2 接入整数上下文保存宏
官方可变帧成立的前提是:TCB 中保存的 SP 无论是否带有 FP 帧,都指向相同格式的公共头。为此,整数寄存器从 slot 2 开始,slot 0/1 留给 mepc/mstatus:

非 Dirty 时 vPortFPUSave 原样返回,公共头就是整数帧的 slot 0/1;Dirty 时它先下移 SP,公共头便写在新 FP 帧的最低地址。两种情况下 TCB 都直接保存当前 SP。
5.3 修改 trap wrapper 并按保存的 FS 恢复
旧固定帧实现会在 trap wrapper 中额外执行一次 sp -= portWORD_SIZE。采用公共头后必须删除这次下移,直接写 slot 0:
如果端口在统一 freertos_risc_v_trap_handler 中保存 mepc,同样删除原来的额外 SP 下移,其余中断分发逻辑保持不变。
恢复入口先读公共头。t3 保存 slot 1 的原始值,并且 FP 恢复子程序不得破坏它:

先写入清除了 MIE 的保存 mstatus,是为了让后续 fld/flw 在合法 FS 状态下执行,同时避免整数上下文尚未恢复完成就立即响应新中断。
5.4 新任务只构造整数帧
官方可变帧方案不会在创建任务时猜测它将来是否执行浮点代码,也不会给每个任务预留 FP 帧。新任务只建立 31-word 整数上下文,并把初始 mstatus.FS 设为 Initial。它第一次运行时没有 FP 数据需要恢复;真正执行 FP 指令后,硬件把 FS 推进到 Dirty,下一次切出才动态压入 FP 帧。
因此初始栈与纯整数任务的已保存栈使用同一布局:
任务产生 Dirty 后才出现第二种布局,见下图:
pxPortInitialiseStack 的关键是一次性分配公共整数帧,并按 5.2 节的相同 slot 写入任务入口、初始状态和参数:

其余整数寄存器槽可以保持端口原有的调试填充值,也可以清零;它们不影响 FP 帧判定。特别注意:这里没有 portFPU_CONTEXT_SIZE 的减法,也没有初始化 f0~f31 或 fcsr。
pxPortInitialiseStack 在不同 FreeRTOS/IAR 端口中的任务返回地址和调试填充值可能不同。移植时保留原端口语义,但 slot 编号必须与 5.2、5.3 节完全一致。
5.5 调整 xPortStartFirstTask
AS32X601 示例端口使用 ret 启动第一个任务,而不是让它先经过普通的 portcontextRESTORE_CONTEXT。由于新任务一定只有整数帧,这条特殊启动路径不应调用 FP 恢复,也不应跳过 FP 空间;它直接按公共整数布局取出入口和寄存器:

这里只是 AS32X601 现有 ret 首任务策略的适配;任务一旦运行并发生普通切换,后续都走 5.3 节的统一 mret 恢复路径。如果你的端口本来就用统一恢复宏启动首任务,则不要再保留这段特例。
5.6 完整实现 vPortFPUSave
IAR 汇编宏多次展开时不能自动唯一化内部标签,因此仍使用全局唯一的子程序。入口 t3 保存原始 mstatus;非 Dirty 路径直接返回,不移动 SP:

由于 ra 已进入整数上下文,函数内部使用 call/ret 不会丢失任务返回地址。t3 也已经作为 x28 保存,子程序只使用 t0/t1 检查 FS,并保留原始 Dirty 值供调用者写入公共头。
5.7 完整实现 vPortFPURestore
恢复入口的 SP 指向公共头,t3 是 slot 1 中保存的 mstatus。非 Dirty 路径直接返回;Dirty 路径恢复完整 FP 状态并把 SP 抬回整数帧:

RV32F 的 132 字节增量会让 f31/fcsr 复用旧整数帧已经空出的 slot 0/1;RV32D 的 268 字节 AS32 修正则把 fcsr 保持在对齐后的 FP 帧内。两者恢复后 SP 都准确回到整数帧基址。
5.8 芯片扩展宏不要重复保存
AS32X601 示例中的 portasmSAVE_ADDITIONAL_REGISTERS 和 portasmRESTORE_ADDITIONAL_REGISTERS 是空宏。如果其他芯片已经通过这两个宏保存厂商扩展状态,不能假设它们可原样保留:本文调用它们时 SP 已位于 FP 帧边界,必须重新审核宏使用的是相对偏移还是自行移动 SP,并保证保存与恢复次序严格相反。
在本文方案中,FPU 已由 portContext.h/portASM.s 直接处理,不要再在 chip-specific 宏中预留或保存第二份 FP 帧,否则会形成重复上下文并破坏栈布局。
6. 栈空间核算
AS32X601 是 RV32,portWORD_SIZE=4。可变帧方案的额外开销取决于任务保存时的 FS:
RV32F 的 132B 看起来小于"32 × 4B + 4B fcsr + 8B 公共头"的 140B,是因为压入 FP 帧后,最高处的 f31/fcsr 复用了原整数帧的 slot 0/1。RV32D 不能机械套用同一差值:在 AS32X601 的 124B 整数帧之后,选择 268B 增量可使 f0 的 base+8 地址保持 8 字节对齐,并让公共头仍位于新 SP 的 slot 0/1。
FreeRTOS 不会扫描每个任务的机器码来判断它是否使用浮点,也不会自动增大 xTaskCreate() 的 usStackDepth。所谓"按需"只发生在切换现场:保存的 mstatus.FS == Dirty 才压入 FP 帧;新任务和未变 Dirty 的纯整数任务不占这段额外空间。
因此栈预算应按任务分类:
- 明确不会生成 FP 指令的纯整数任务,可以按整数端口原有方法测量;
- 任何可能直接或间接执行 FP 指令的任务,都必须在最坏栈深基础上再容纳 132B(F)或 268B(D);
- 日志格式化、数学库和第三方回调也可能让一个"看起来是整数"的任务变成 FP 用户;
- Idle/Timer 任务只有在其调用链确实使用 FP 时才需要这段余量。
任务恢复 FP 数据后,硬件会在加载寄存器或后续浮点计算时再次把 FS 置为 Dirty,所以活跃的 FP 任务通常会在后续每次切出/切入都承担完整 FP 保存恢复成本。可变帧主要避免纯整数任务的空间和访存开销,不等于只保存一次。
不要照抄一个"通用安全值"。建议先显著放大相关任务栈,开启溢出检测,在目标优化级别和最长调用链下运行压力测试,再依据 uxTaskGetStackHighWaterMark() 加项目规定的安全裕量回调。
7. IAR 工程配置
在 IAR 中打开:
Project → Options → General Options → Target

选择与芯片真实能力一致的 ISA/FPU 配置。AS32X601 示例工程的项目文件记录为:
RV32IMAFDC
其中 F 表示单精度扩展,D 表示双精度扩展;选择 D 后,工具链走 __riscv_flen == 64 路径。不要因为本文代码支持 D,就在只有 F 扩展的硬件上强行选择 D,否则程序会在执行 fld/fsd 等指令时触发非法指令异常。
同时在 FreeRTOSConfig.h(或工程统一预定义宏)中显式启用:
#define configENABLE_FPU 1
该宏必须对 C 编译和 portASM.s 的汇编预处理同时可见。只选择 ISA 而不启用 configENABLE_FPU,业务代码仍可能生成 FP 指令,但移植层不会保存它们;只启用宏而不选择 F/D ISA,则应由 5.1 节的编译期检查立即报错。
配置完成后需要确认三件事:
- 汇编器处理 portASM.s 时确实定义了 configENABLE_FPU == 1;
- 汇编器确实定义了与目标一致的 __riscv_flen;
- C 文件和汇编文件使用一致的 ISA 设置,不能一边按 F/D 编译业务代码,另一边把移植层当作无 FPU 构建。
ISA 选择与 C ABI 也不是同一概念。ISA 决定硬件指令是否可用,ABI 决定函数参数、返回值和寄存器保存约定。即使工程采用整数 ABI,只要编译器在函数内部生成 FP 指令,任务切换仍然必须保护真实使用到的 FP 状态。
8. 如何验证保存与恢复
8.1 先开启栈保护
在 FreeRTOSConfig.h 中开启第二级栈溢出检查:
并在应用代码中实现 hook:
如果使用软件定时器,还要同步检查 configTIMER_TASK_STACK_DEPTH;它经常直接引用 configMINIMAL_STACK_SIZE,不能只增大用户任务栈。
8.2 三任务抢占测试
下面的测试使用两个不同的浮点递推任务和一个纯整数任务。volatile 输入用于阻止编译器在编译期算完全部表达式;每轮运算后主动 yield,扩大任务切换覆盖浮点活跃区间的概率。

TEST_TASK_STACK_WORDS=512 只是给本测试留出较宽裕的起点,不是所有产品的推荐常数。运行后应通过调试器观察:
- ulFpErrorCount 应始终为 0;
- uxMinFreeA、uxMinFreeB 和 uxMinFreeInteger 应保留可接受余量;
- 两个浮点任务的 mstatus.FS 会进入 Dirty;
- 纯整数任务自己的 FS 不应因其业务代码变成 Dirty。
还应在任务刚切出后检查 TCB 保存的 SP。公共头在两种布局中都保持一致:SP+0 是 mepc,SP+4 是保存的 mstatus。
- 纯整数任务:保存的 FS 应为 Initial 或 Clean,TCB SP 直接指向整数帧,保存路径没有额外移动 SP;
- 浮点任务:保存的 FS 应为 Dirty,TCB SP 指向动态 FP 帧的公共头;
- 对同一保存点比较 Dirty 与非 Dirty 路径,RV32F 的 SP 差应为 132B,AS32X601 RV32D 的 SP 差应为 268B;
- 恢复后 SP 必须回到进入 trap 前的位置,不能随切换次数持续漂移。
8.3 一定要检查反汇编
C 测试只有在确实生成硬件浮点指令时才有意义。请在 IAR Disassembly 窗口或列表文件中确认浮点任务包含相应指令,例如:
还要观察 taskYIELD() 前后的局部变量是否有机会保持在 FP 寄存器。如果编译器把 x 在 yield 前主动写回栈,测试仍能验证基本调度路径,但降低了暴露"未保存 FP 寄存器"问题的能力。这时可以调整优化级别、增加运算链,或在实验代码中使用工具链支持的寄存器约束。
8.4 判定标准
完成以下检查后,才能认为移植通过:
9. 使用边界和升级建议
9.1 ISR 中使用 FPU
本文解决的是任务上下文。中断发生后,任务上下文通常在 ISR 入口保存;但 ISR 是否允许使用 FPU、嵌套中断是否会再次覆盖 FP 状态、ISR 返回时 FS 如何处理,取决于完整 trap 入口设计。
最稳妥的规则是:在没有专门验证前,普通 ISR 不使用浮点运算,也避免会间接生成浮点代码的 %f 格式化、数学库或回调。若产品必须在 ISR 中使用 FPU,应单独设计和测试 ISR FP 上下文协议,不能把本文任务测试当作证明。
9.2 ISA 与 ABI 要分别核对
- ISA/FPU 选项决定 fadd、flw、fld 等指令是否存在;
- ABI 决定跨 C 函数调用时哪些参数和寄存器由谁保存;
- FreeRTOS 的抢占式任务切换不等同于普通函数调用,最终仍以端口汇编保存了哪些寄存器为准。
9.3 其他 RISC-V 芯片
迁移到其他芯片时至少核对:
- 核心是 RV32 还是 RV64;
- 实现 F、D 中的哪一种;
- mstatus.FS 是否按目标特权架构版本实现;
- 栈对齐要求是否仍为本文假设;
- 是否存在需要通过 portasmSAVE_ADDITIONAL_REGISTERS 处理的厂商扩展状态。
本文的 33/67-word Dirty 增量来自 AS32X601 的 RV32 公共头与对齐设计,不可直接推广到 RV64 或不同整数栈布局。
9.4 升级 FreeRTOS 时不要叠加两套实现
若未来切换到 V11.3.0 或更新版本的 GCC RISC-V 官方 FPU 端口,应整体采用并验证官方 portContext.h、portASM.S、配置宏和栈布局。本文虽沿用官方可变帧思路,但包含 IAR 语法和 AS32X601 RV32D 对齐适配;不要把两套保存宏叠加,否则极易出现重复保存和 SP 偏移冲突。
若继续使用 IAR,也要在每次升级后重新检查官方 IAR RISC-V 目录,因为本文关于"尚未集成"的结论绑定到明确的 Git 标签和调查日期,不能永久化。
9.5 本文未覆盖的场景
以下场景需要独立设计:
- RISC-V Vector 扩展上下文;
- SMP 或多 hart 调度;
- Supervisor/User mode 任务切换;
- 嵌套中断中的 FPU 使用;
- 与芯片安全扩展、DSP 或自定义协处理器状态共同保存。
10. 总结
在 IAR 工程中启用 RISC-V F/D 指令,只完成了"可以计算"这一步。要让 FreeRTOS 多任务安全使用 FPU,还必须让移植层保存 f0~f31、fcsr,并让公共头、任务切出和任务切入三条路径使用完全一致的布局。
本文方案适用于以 FreeRTOS V10.5.1 IAR RISC-V 端口为基础的 AS32X601 工程:
- 通过 __riscv_flen 同时覆盖 F/D;
- 通过保存的 mstatus.FS == Dirty 决定是否动态压入和恢复 FP 帧;
- 新任务与非 Dirty 任务不预留 FP 帧,Dirty 时 F 增加 33 words/132B、D 增加 67 words/268B;
- 通过固定格式的 mepc/mstatus 公共头保持两种栈布局可判定、SP 可逆;
- 通过双浮点任务、纯整数任务、反汇编和栈高水位共同验证。
最后再强调一次官方边界:FreeRTOS V11.3.0 已在 GCC RISC-V 端口集成 FPU 上下文保存;对于 IAR,应以你实际使用版本的官方标签源码为准。在采用本文代码前,先画清楚自己的栈帧,再逐项核对 SP------底层上下文切换中,一个 word 的误差就足以把问题变成随机崩溃。