一块 RISC-V MCU 是怎么启动的: 内存保护、中断与从 Flash 直接执行
MCU (Microcontroller Unit, 微控制器) 内部有几种存储: 对外访问的 Flash (存固件)、片上 SRAM (跑数据)、可选的 DDR (大内存)。比存储分配更隐蔽的是, 不同架构对它们的访问方式和保护机制完全不同: ARM 的 Cortex-M 和新兴的 RISC-V 内核, 在内存保护与中断入口上就是两套机制。
本文以RISC-V 内核 MCU 为例, 讲四件事: 内存保护有几种、中断入口在 RISC-V 上什么样、为什么复位入口不在 SRAM、片上 SRAM 为什么拆两块。每部分先讲通用原理, 再落到该芯片的实测数据 (map 文件与 ELF 头)。结论先行:
- 内存保护三件套: ARM 用 MPU, RISC-V 用 PMP, 都能查"内存能不能访问", 但机制和寄存器不同
- 现代 RISC-V MCU 没有硬件向量表, 中断统一跳一个入口, 软件判断原因再分发
- CPU 复位后第一段代码通常从 Flash 直接执行 (XIP), 不先拷到 RAM
- 片上 SRAM 拆成"代码区 + 数据区", 是为代码只读、数据可写, 权限分开更安全
一、内存保护: MPU, PMP
1.1 为什么要内存保护
程序跑飞 (指针取错、数组越界) 可能把别的内存写坏, 甚至改掉自己的代码。内存保护就是在硬件上加一道闸: 每次访问检查"这块地址这个 CPU 能不能访问、能不能写、能不能执行", 违反就下 trap, 不让它继续错。这道闸靠硬件控制器, 软件绕不过。
1.2 ARM 的 MPU (对照)
Cortex-M3/M4/M7/M33 基本都内置 MPU (Memory Protection Unit, 内存保护单元)。硬件条件:
- 8 到 16 个 region (区域) 寄存器, 每个描述一段地址
- region 大小是 2 的幂, 32B 到 4GB; 每 region 又能切成 8 小块单独允/禁
- 权限三档: 只读 RO、读写 RW、不可访问 NO ACCESS
- 有特权 (Privileged) / 非特权 (Unprivileged) 双态, 用户程序跑非特权级
MPU 只判"能不能访问", 不做地址翻译, 把内存切成几块各自设权限。注意老型号 (Cortex-M0, 部分 M0+) 出厂没有 MPU, 靠 ARM 那套保护拿不到。
1.3 RISC-V 的 PMP
RISC-V 的对等物是 PMP (Physical Memory Protection, 物理内存保护), 特权架构标准机制:
- 一组 CSR 描述 region, 常见 8 到 16 个
- region 用 NAPOT 编码: 自然对齐的 2 的幂区间, 一条 CSR 同时给基址和大小
- 直接判物理地址, 不做翻译、不是页表
- 权限是 R (读) / W (写) / X (执行) 组合, 可锁定 L 防止被特权代码改
一次访问的判定:
| 维度 | MPU (ARM) | PMP (RISC-V) |
|---|---|---|
| region 描述 | 基址+大小两个寄存器 | NAPOT 一条带出两者 |
| 权限 | RO / RW / NO ACCESS | R / W / X / L 组合 |
| 地址映射 | 无 | 无 |
| 特权态 | CONTROL 双态 | 异常/中断切特权级 |
实测 (e906) : 对固件 ELF 跑 readelf -h 可确认架构:
arduino
系统架构: RISC-V 标志: 0x5, RVC, double-float ABI
再查 rtconfig.h 有 ARCH_RISCV32、ARCH_RISCV_FPU_D, 与 BSP 目录 risc-v/t-head/e906 互相印证。但实测该固件 rtconfig.h没有 RT_USING_MEM_PROTECTION, 也没有 pmpaddr/pmpcfg 初始化------出厂是"裸奔"态, 全跑在特权级, 没有硬件隔离。这是很多 MCU 固件的出厂常态; 要启用得自己适配 RISC-V 的 PMP, 不是套 ARM 的宏。所以"RTOS 有没有内存保护", ARM 和 RISC-V 的答案底层硬件不是一回事。
二、中断入口: 一张表, 还是一个门
2.1 ARM: 一张硬件表
Cortex-M 在启动时把一张"函数指针数组"放 Flash, 叫向量表: 栈顶、复位入口、NMI、各外设中断各占一项。哪个中断来了硬件直接查表跳转, 表址固定或由 SCB->VTOR 指定。
2.2 RISC-V: 一个门, 软件分发
RISC-V 反过来: 不给每个中断单独表项, 只留一个入口 。CPU 响应中断/异常时一律跳到 mtvec 寄存器指向的同一处, 把"为什么被打断"写进 mcause, 剩下交给软件:
三点: 所有中断/异常共用一入口、没有表; 表址存 CSR (mtvec), 运行期由系统初始化写入; 分发用的"软向量表" (中断号到处理函数数组) 是软件维护的。移植时别把 ARM 的 NVIC 写法直接搬来。
实测 (e906): 从 map 文件搜符号看入口排布:
arduino
.text 0x0800d6c4 Reset_Handler ; CPU 复位入口
.text 0x0800dae8 PendSV_Handler ; 上下文切换
.text 0x0800dc5e entry ; RTOS 首个线程 main
trap_entry 在 risc-v/t-head/e906/, 把入口写进 mtvec 的是 rt_hw_interrupt_init。调试器可读 mtvec (CSR 335/0x14F) 看它指向哪。
三、为什么复位入口在 Flash, 不在 SRAM
3.1 XIP: 直接从 Flash 执行
很多 MCU 复位入口落在 Flash 区, 这是 XIP (execute in place, 原地执行): CPU 取指直接读 Flash, 不先拷进 RAM。常见做法是让 CPU 透过 cache 控制器读挂在 SPI 总线上的外部 Flash, 于是"Flash 代码区"是一块连续地址, 内容住在 Flash, 断电不丢。
3.2 为什么 SRAM 当不了入口
把代码放 SRAM 跑更快, 为什么不一开始就在 SRAM? 因为 SRAM 上电内容未知 , 不能保证是可执行指令; 要它能用, 得先有人把 Flash 拷进来, 可这个"搬运工"自己也是程序、也得被取指执行------鸡生蛋问题。所以复位后必须有一段能在 Flash 直接执行的 Reset_Handler, 由它初始化 SRAM、拷贝、再跳主程序。入口一定在能 XIP 的 Flash 区, 不在 SRAM。
3.3 复位后的顺序
实测 (e906) : map 里 .text 起始是 Flash 页边界对齐, 利于 cache 预取:
ini
.text 0x0800d6c0 __stext = . ; 代码区起始
0x0800d6c4 Reset_Handler ; 段内 +4, CPU 入口
这块在链接脚本叫 CACHE, 但不是"一组 CPU 缓存", 是芯片经 cache 控制器做 XIP 访问外部 SPI Flash 的窗口------整个应用加 RTOS 内核都装得下, 这是 10KB SRAM 做不到的。
四、片上 SRAM 为什么拆两块
链接脚本里, 代码放在一块只读 SRAM (标 XR, 可执行+只读), 数据放另一块可写 SRAM (标 XRW)。既然都是片上 RAM, 拆开为何?
物理层面: 芯片里本来就是两块独立 SRAM, 地址不连续, 链接脚本只能分两个段, 硬件决定的。
权限层面: 拆开可给不同区设不同权限。代码区只读, 指针跑飞也写不进代码段, 防自改、防恶意数据注入; 数据区可执行+可写, 放堆、栈、全局变量。配合 MPU/PMP 能把 region 设得更细更安全。两块 SRAM 是硬件约束, 权限分开是主动取舍。
实测 (e906):
| SRAM_TEXT | SRAM_DATA | |
|---|---|---|
| 地址 | 0x04000000 | 0xa0005000 |
| 大小 | 10KB | 120KB |
| 内容 | 函数指令 (.text) | 数据/栈 (.data, .bss) |
| 属性 | xr (可执行+只读) | xrw (可读写) |
两块地址不连续、容量差 12 倍, 各对应一块物理 SRAM。高频数据、DMA 缓冲用 SRAM_DATA; 海量数据 (视频、文件系统) 才落片外 DDR。
五、怎么读 link map 文件
链接器输出 map 文件, 记录"每个地址放了什么", 是排查"代码放哪、Flash/RAM 用多少、符号是否重复"的第一手凭证。以真实固件的 map (rt-thread-YYYY-MM-DD-HH-MM-SS.map) 为例拆解。
5.1 三大部分
由前到后三块, 各回答一个问题:
- Archive member included to satisfy reference by file (symbol) --- "用到的函数从哪个静态库拉来的"
- Memory Configuration --- "芯片有哪些地址区间, 各多大"
- Linker script and memory map --- "每个段/符号落在哪个地址"
5.2 归档成员引入表
开头形如:
bash
/opt/.../libc.a (lib_a-fopen.o)
/home/.../dm_whd_tool.o (fopen)
含义: dm_whd_tool.o 调了 fopen, 链接器从 libc.a 拉进 lib_a-fopen.o。静态库按需拉取, 只有被引用的函数才进镜像。能解释"没写调用怎么镜像里也有这个库函数"。
5.3 Memory Configuration (内存布局)
这是内存分区总纲:
objectivec
Name Origin Length Attributes
CACHE 0x0800d6c0 0x007f0000 xr
SRAM_TEXT 0x04000000 0x00002800 xr
SRAM_DATA 0xa0005000 0x0001d800 xrw
DDR_TEXT 0x20000000 0x00100000 xr
DDR_DATA 0x8d400000 0x00080000 rw
DM_MEM 0x8d480000 0x00008000 rw
RTE_MEM 0x8d488000 0x00038000 rw
VIDEO_MEM 0x8d4c0000 0x00004000 rw
DDR_HEAP 0x8d500000 0x02a00000 rw
列含义: Name 段名 (链接脚本 LD 定义); Origin 起始地址; Length 长度; Attributes 允许操作 (R 读 / W 写 / X 执行 / C 除非截断否则禁写)。观察得出:
- CACHE: 名字像缓存, 实是 XIP 窗口 (见第三章), 整个固件代码装这
- SRAM_TEXT / SRAM_DATA: 两块片上 SRAM, 一可执行只读、一可读写 (见第四章)
- DM_MEM / RTE_MEM / VIDEO_MEM: 给不同模块划的独立段, 防互相越界
- DDR_HEAP (约 42MB, rw): 最大可写区, 运行时堆,
malloc从这拿
5.4 Linker memory map (具体到符号)
xml
.text 0x0800d6c0 . = ALIGN (0x4)
0x0800d6c0 __stext = .
0x0800d6c4 Reset_Handler <startup_gcc.o>
0x0800dae8 PendSV_Handler <interrupt_gcc.o>
0x0800dc5e entry <components.o>
格式 符号名 地址 <来自哪个 .o>。可见 Reset_Handler 在 0x0800d6c4、来自启动汇编, 后面紧跟 RTOS entry; 地址越靠后越大, 看代码区从低到高排布。查符号直接搜名字; 查段内存看地址增量。
5.5 常用三件事
- Flash 占用 :
CACHE/DDR_TEXT里.text+.rodata末尾地址减起始 - RAM 占用 :
SRAM_DATA/DDR_DATA/DDR_HEAP里.data+.bss末尾减起始 - 重复符号 : 第三部分搜同一符号出现两次, 说明两个
.o重复定义
六、常见误区小结
- "RTOS 有内存保护" ≠ 每块芯片都有: 依赖 MPU/PMP, 老 ARM 型号和部分廉价 RISC-V 型号没有这硬件, 软件绕不过
- ARM 和 RISC-V 向量表不是一回事: ARM 是硬件表, RISC-V 单入口+软件分发, 移植别直接搬
- 代码区不在 SRAM 旁: XIP 下复位代码住外部 Flash, 透过 cache 窗口被当可执行地址访问
- SRAM 拆两块既是物理事实也是安全设计: 两块独立 SRAM + "代码只读" 设权限