ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

异构RISC-V核bring-up实战:基于remoteproc的固件加载与通信

异构RISC-V核bring-up实战:基于remoteproc的固件加载与通信 这篇把“异构 RISC-V 核”从“看得见”推进到“跑得起来”。如果你读过 Part 1应该已经在自己手里的 Allwinner SoC 上确认过 RISC-V 核心的存在也知道它在地址空间里的粗略位置。Part 2 的目标很直接让主核ARM通过 remoteproc 框架把一个裸机固件加载进这颗 RISC-V 小核让它正常复位、执行、初始化串口最后通过共享内存和 mailbox 与主核建立双向通信。整个过程会涉及链接脚本、设备树、驱动加载、固件编译和一系列调试技巧我会把每一步的做法、参数选取逻辑和踩过的坑都拆开讲适合正在做异构多核 bring-up 的嵌入式开发、BSP 工程师或者对 RISC-V 协处理器感兴趣的朋友参考。1. 异构 RISC-V 核心的软件架构与整体方案1.1 先搞清楚这颗核在系统里的角色Allwinner 这几年的 SoC 里集成 RISC-V 小核并不是让它跟 ARM 大核组成 SMP而是典型的非对称多处理AMP架构。也就是说ARM 核心跑 Linux负责复杂的业务逻辑和系统管理RISC-V 小核跑裸机程序或者一个轻量 RTOS负责功耗管理、传感器采集、低功耗待机唤醒或者做一些对实时性要求较高的辅助工作。从主核的视角看这颗 RISC-V 核更像是一个“可编程外设”而不是一个平等的处理器启动、停止、加载固件都受主核控制。理解这一点很关键因为它决定了后续所有软件设计的方向。如果目标是 SMP就得让两个核心共享同一个内核镜像、统一调度但异构核通常 ISA 都不同一个 32 位 RISC-V 小核和一个 64 位 ARM 核根本没有办法跑同一个操作系统镜像。所以核间协作必然走“主从模式”主核管理从核的生命周期从核执行主核派发的任务二者通过共享内存和中断进行数据交换。1.2 为什么选 remoteproc 而不是裸写寄存器很多第一次接触异构核的朋友第一反应是直接操作寄存器把固件拷到内存写一下复位控制寄存器释放核心完事。这样做确实能跑但工程上几乎不会这么干。原因有三个第一固件加载、地址解析、ELF 装载这些事看起来简单实际要处理各种边界条件裸写容易出 bug第二主核侧需要随时感知从核的运行状态异常时要能复位重拉这些逻辑需要框架支撑第三后续如果要用 rpmsg 做核间通信没有 remoteproc 做资源管理virtio 那一套根本没法落地。Linux 的 remoteproc 框架就是为这个场景设计的。它负责解析固件、把 ELF 中的各个段加载到约定内存、配置从核启动地址、触发启动还提供了标准的 sysfs 接口比如/sys/class/remoteproc/remoteproc0/state开发者可以像操作设备一样 start、stop 从核。另一个好处是它自带固件管理机制firmware 文件放在/lib/firmware下加载失败或者崩溃后有统一的错误处理路径。如果你完全不用 remoteproc而是自己在驱动里写一套固件加载逻辑那你会发现自己其实是在重复实现 remoteproc 的功能而且实现得还不一定有它完善。所以我的建议很明确优先用内核框架只有当你对框架有足够的理解、并且确认它无法满足你的定制需求时才考虑绕开它。1.3 软件栈组成与版本选型一次完整的异构核 bring-up涉及的软件组件比想象中多。主核侧需要一块带 remoteproc 支持的 Linux 内核通常厂商 BSP 已经默认打开了相关配置如果没有需要检查CONFIG_REMOTEPROC、CONFIG_RPMSG、CONFIG_VIRTIO这些选项。从核侧需要一个独立的交叉编译工具链小核多数是 32 位 RISC-V一般用riscv32-unknown-elf-系列如果 core 是 64 位的则用riscv64-unknown-elf-系列这个必须跟芯片的实际 ISA 匹配选错了编译出的固件一启动就 illegal instruction。从核的固件可以是一个裸机程序也可以是一个 FreeRTOS。bring-up 阶段我强烈建议先用裸机把串口打印、共享内存读写这两个基本功跑通再考虑引入 RTOS 做任务调度。上来直接上 RTOS一旦出问题你很难分清是硬件地址配置错了、中断路由错了还是 RTOS 本身的移植问题。工具链之外还要准备设备树编译器dtc、ELF 分析工具readelf、nm以及一个能读写内存的调试手段。Allwinner 这类 SoC 通常有完整的 BSP 内核源码里面往往携带了 RISC-V 核的示例固件工程和链接脚本。别看这些示例代码写得简陋它们提供的寄存器地址和内存布局信息比芯片手册里的散乱描述更有参考价值我每次 bring-up 都会先翻一遍厂商的 BSP。2. 固件开发链接脚本、启动地址与最小裸机程序2.1 链接脚本参数的决定逻辑裸机固件开发的第一步不是写代码而是确定内存布局。RISC-V 小核通常有自己私有的 TCMTightly Coupled Memory也可能会映射到 SoC 的 SRAM 或者外部 DRAM 的一段保留区域。你需要从芯片手册的 memory map 表格里找到三样东西小核的指令存储基地址、数据存储基地址、以及外设寄存器区域的基地址。大多数 Allwinner 异构核的 BSP 里会给出一个 link.ld你直接基于它改就行。但如果你手头只有上游内核源码那可以从设备树里找线索通常 SoC 的 RISC-V 核节点会带reg属性描述它的寄存器控制接口而从核要执行的固件地址则需要自己根据内存保留区域推算。下面是一个典型的 RISC-V 小核链接脚本框架OUTPUT_ARCH(riscv) ENTRY(_start) BASE_ADDR 0x40000000; /* 示例地址务必替换为实际芯片手册中的值 */ STACK_SIZE 0x4000; MEMORY { ITCM (rx) : ORIGIN BASE_ADDR, LENGTH 128K DTCM (rw) : ORIGIN BASE_ADDR 0x20000, LENGTH 128K SHARED (rw): ORIGIN 0x48000000, LENGTH 1M } SECTIONS { .text : { KEEP(*(.text.entry)) *(.text*) *(.rodata*) } ITCM .data : { *(.data*) } DTCM .bss (NOLOAD) : { __bss_start .; *(.bss*) *(COMMON) __bss_end .; } DTCM _stack_top ORIGIN(DTCM) LENGTH(DTCM); }这个脚本里有几个关键点必须注意。ENTRY(_start)决定了 ELF 头里的入口地址remoteproc 加载固件时会根据这个入口配置从核的启动 PC。MEMORY区域中的 LENGTH 必须和芯片实际的 TCM/SRAM 容量一致超出这个范围的链接行为会导致固件加载后被截断或者直接加载失败。SHARED区域用来放与主核交互的共享数据这部分需要和主核侧的 reserved-memory 节点严格对应地址差一个字节都会出大问题。_stack_top的初始化尤其容易被忽略。RISC-V 架构没有硬件自动压栈的机制必须由启动代码显式设置栈指针。栈顶放在数据段末尾是一个安全的选择如果栈向下增长可以覆盖整个 DTCM 区域而不会碰到代码段。千万不要把栈顶放在 ITCM 区域或者共享内存区域前者会导致栈溢出时破坏指令后者会直接污染核间通信数据。2.2 最小启动代码从复位向量到 main链接脚本只是骨头启动汇编是让这颗核真正“活过来”的起点。复位后 RISC-V 核心会从复位向量地址取第一条指令这个地址由芯片的复位控制器决定通常就是 ITCM 的起始地址也就是链接脚本里_start所在的位置。你要做的是在这一小段汇编里完成最基本的运行环境准备设置栈指针、关闭中断、清 BSS、然后跳转 C 语言的 main 函数。.section .text.entry .globl _start _start: csrr t0, mhartid li t1, 0 beq t0, t1, 1f /* 如果不是 hart 0进入低功耗等待 */ wfi j _start 1: li t0, 0 csrw mstatus, t0 csrw mie, t0 la sp, _stack_top la t0, __bss_start la t1, __bss_end 2: bge t0, t1, 3f sw zero, 0(t0) addi t0, t0, 4 j 2b 3: call main 4: wfi j 4b几个细节值得展开说。mhartid读取当前 hart ID这一步在异构核场景里很重要因为有些 SoC 的 RISC-V 复合体里不只一个 hart如果固件被同时加载到多个 hart 上执行你总不希望它们都去跑 main。mstatus清零是关闭全局中断避免在环境还没准备好时就收到异常中断导致跑飞。mie清零是确保所有中断源都被屏蔽。清 BSS 是 C 语言运行环境的基本要求所有未初始化全局变量默认应该是 0这一步必须要做。我把__bss_start和__bss_end暴露为链接脚本的符号汇编里直接用la就能拿到地址但要注意链接脚本里的符号如果前面没有下划线某些工具链可能会自动添加前缀导致链接错误统一用这种带下划线的命名能少踩坑。main 函数里第一件事是验证基本运行环境是否正常最简单的验证方式是写入共享内存魔数。比如在共享区域首地址写两个固定的 32 位数值主核侧轮询这个地址一旦读到魔数就说明小核已经成功跑起来并通过了链接脚本中的地址映射。这个“魔数握手”比直接调串口更稳妥因为串口驱动里还有 GPIO 复用、时钟使能、波特率配置等一系列变量串口调不通不代表核心没跑起来。2.3 工具链和编译选项里的坑交叉编译 RISC-V 固件时最容易踩的坑是架构和 ABI 不匹配。Allwinner 内部的这种小核一般配置为rv32imac或者类似的基础指令集组合既没有 F/D 浮点也没有向量扩展。如果你用默认的-marchrv32g去编译生成的指令里可能包含小核根本不支持的指令加载后直接触发 illegal instruction 异常。建议在 Makefile 里显式指定CROSS_COMPILE riscv32-unknown-elf- CFLAGS -marchrv32imac -mabiilp32 -Os -ffreestanding -nostdlib LDFLAGS -T link.ld -nostartfiles-ffreestanding告诉编译器你的环境没有标准库不要把memcpy、memset之类的东西隐式链接进来。-nostdlib和-nostartfiles是裸机工程的标配否则链接器会尝试寻找_start以外的启动文件。用-Os优化是为了减小固件体积小核的 TCM 通常只有几十到几百 KB优化体积比优化速度更重要。链接完成后一定要用readelf -e riscv_fw.elf检查程序头。重点确认每个段的 LMA 和 VMA 是否符合预期。曾经遇到过一次很隐蔽的问题代码编译出来放在 TCM 没问题但数据段的 LMA 在 ITCM 里VMA 在 DTCM 里而 remoteproc 加载 ELF 时只按 LMA 搬运数据没有执行“从加载地址复制到运行地址”的初始化结果固件跑起来后所有全局变量都是垃圾值。解决方法是让链接脚本里的 data 段用AT重新定位同时确保启动汇编里有对应的拷贝循环。3. 主核侧 bring-up设备树、remoteproc 驱动与加载流程3.1 设备树里如何描述一个异构核心主核侧的设备树是 remoteproc 框架的“眼睛”节点描述得不对驱动根本不会 probe。一个典型的 RISC-V 从核节点至少包含以下几部分compatible字符串、寄存器控制接口、firmware-name、memory-region以及可选的中断和 mailbox 属性。reserved-memory { #address-cells 2; #size-cells 2; ranges; rproc_reserved: rproc48000000 { no-map; reg 0x0 0x48000000 0x0 0x01000000; }; }; riscv0: riscv0 { compatible allwinner,sun50i-rproc, remoteproc; reg 0x0 0x00000000 0x0 0x1000; reg-names control; firmware-name riscv_fw.elf; memory-region rproc_reserved; mboxes msgbox 0; mbox-names rproc-mbox; status okay; };reserved-memory区域里的no-map属性特别关键。它告诉内核这块内存不要做页表映射、不要被 buddy 子系统管理否则 Linux 可能把小块不用的 DRAM 分配给其他驱动一旦从核固件写到那里主核侧会看到神秘的内存踩踏。no-map会带来一些 cache 一致性上的坑后面我会专门讲。memory-region指向预留内存区域remoteproc 框架在加载固件时会优先把 ELF 段放到这个区域内。如果固件比较大超过了预留区域的长度框架会返回错误。所以预留内存的大小不能拍脑袋我一般把预留区域设为固件预期大小的 4 倍以上留够共享内存和 vring 的空间。mboxes属性定义了与从核通信的信箱通道这里依赖厂商提供 msgbox 驱动如果 BSP 里没有现成的 mailbox 驱动可以用一个普通的 GPIO 中断先顶上只要能产生中断就行。3.2 从 sysfs 到核心启动的完整流程设备树节点就绪、内核开启 remoteproc 支持、固件放到/lib/firmware/riscv_fw.elf之后启动从核其实就三条命令的事。echo start /sys/class/remoteproc/remoteproc0/state不要小看这一行背后发生的事情。remoteproc 驱动会先 request_firmware 读取 ELF 文件逐个解析 program header然后调用dma_alloc_coherent或者直接使用预留内存区域分配内存接着把各个段按 LMA 拷贝到目标地址再根据 ELF 的入口地址配置从核的启动 PC最后通过复位控制寄存器把从核从复位状态释放出来。这一套流程里任何一步失败都可以在dmesg里看到对应的错误信息排查时先看固件是否成功加载再看启动 PC 是否写对了。如果state文件都不存在说明 remoteproc 设备没有 probe 成功大概率是设备树节点 compatible 不匹配或者memory-region对应的 reserved-memory 没有解析成功。这时候不要急着改驱动先用ls /proc/device-tree确认设备树节点内容是否被正确编译进去再检查内核日志里有没有remoteproc相关的 error。从核启动完成后你会看到小核的串口输出固件版本信息或者共享内存里出现魔数。如果什么都没发生先别怀疑固件回忆一下复位控制器的操作是否真的生效。有些芯片的复位控制不是简单的写一两个寄存器位还涉及时钟门控和电源域的控制顺序错了核心根本不会跑。建议查一下 BSP 里从核复位函数的实现看它是不是先使能时钟、再去复位、最后写启动地址。3.3 通信验证先握手再上 rpmsg很多教程会直接让你上 rpmsg但我的经验是 bring-up 的第一阶段不要碰 rpmsg。rpmsg 依赖 virtio、共享内存的 vring、中断通知等一系列机制任何一个环节出错都不容易定位。先用裸共享内存做握手打通通路成本低、见效快。主核侧可以用一小段内核模块轮询预留内存区域static void check_remote(void) { void __iomem *base ioremap(0x48000000, SZ_4K); if (ioread32(base) 0xA5A5A5A5) pr_info(remote core alive\n); iounmap(base); }这一步验证通过之后再考虑引入 rpmsg。实际上从核侧实现一个最小的 virtio 设备端工作量相当大需要实现 virtqueue 的分配、缓冲区描述符的管理、avail/used ring 的更新和中断通知。如果你用的是 FreeRTOS可以找 OpenAMP 的移植示例裸机的话就得手写或者从其他工程移植。总之核心思想不变先把低层通路搞清楚再往上叠加协议。4. 踩坑记录地址错位、固件跑飞与调试方法4.1 常见问题速查表我把带过的几个项目里反复出现的 bring-up 问题整理成一张表基本上覆盖了 80% 的故障场景现象常见原因排查思路从核完全没有执行任何指令复位控制位没写对或者固件加载失败检查 dmesg 中的 remoteproc 日志确认 ELF 段是否加载到目标地址检查复位控制寄存器的位定义固件跑飞到随机地址链接脚本里的 LMA 与实际加载地址不一致用 readelf 对比 ELF 段的 LMA 与 reserved-memory 区域检查启动汇编的栈指针主核读共享内存始终是旧值缓存一致性没处理主核或从核侧存在 cache 延迟主核用 ioremap_wc 或者 dma_alloc_coherent从核侧如果有 DCache需要执行 fence 或关闭 DCache从核运行一会儿就死掉看门狗没关或者访问了未使能的外设区域在启动代码早期关闭小核看门狗确认外设时钟已使能mailbox 中断不触发中断号配置错误或 irq domain 对不上检查设备树 mboxes 通道号和 BSP 里 msgbox 驱动的 channel 定义逐位对比4.2 没有 JTAG 的裸核调试技巧大多数 Allwinner SoC 不会把 RISC-V 小核的 JTAG 调试口引出来这意味着你不能像调试 ARM 主核那样打断点、看寄存器。这种情况下最可靠的手段是“内存打点法”在固件执行的每个关键阶段往共享内存的固定偏移写入不同的魔数主核侧每隔几百毫秒把这块内存 dump 出来看走到哪一步。/* 从核固件侧 */ #define DBG_STAGE_ENTRY 0x11110001 #define DBG_STAGE_MAIN 0x11110002 #define DBG_STAGE_UART 0x11110003 volatile uint32_t *dbg (uint32_t *)(SHARED_BASE 0x100); *dbg DBG_STAGE_ENTRY; /* ... 执行一段逻辑后 ... */ *dbg DBG_STAGE_MAIN;主核侧用简单的 devmem 工具或者一个小模块去读这个地址。读到的魔数停留在哪个阶段就说明代码卡在哪一步。这个技巧帮我在没有仿真器的情况下解决过至少三个疑难问题比盲目改代码重新烧写试错快得多。另一个技巧是在启动汇编的每个跳转指令前都放一个魔数写入命令粒度可以细到每条关键指令代价是固件体积变大但调试阶段完全值得。串口也是重要的调试手段。如果小核有独立的 UART 引脚可以用我建议在一开始就把串口打印调通。打印函数不需要很完善只要能输出一个字符就够了。有些 SoC 的 UART 时钟和 GPIO 复用默认配置是坏的需要先查 BSP 里主核侧对 UART 的初始化再照搬到从核侧。调串口需要一个稳定的波特率源如果时钟树上某个 PLL 没使能输出就是乱码这时候用示波器看 TX 引脚有没有电平翻转比反复改波特率快得多。4.3 缓存一致性是异构核的隐形杀手异构核之间的共享内存最隐蔽的问题是缓存一致性。ARM 主核的 Linux 侧访问共享内存时数据可能停留在 L2 cache 里根本没有写到 DRAMRISC-V 小核如果也有 cache它读到的又是旧数据。表现为小核明明往内存写了数据主核读却始终是零。解决思路有几个层次。最简单的办法是让主核访问共享内存时使用不带 cache 的映射比如ioremap_wc或者设备树里的dma-coherent属性。从核侧如果是简单的小核通常没有复杂的 cache 层级但还是需要确保没有开 DCache或者在写共享内存后执行 RISC-V 的fence指令。别小看这条指令很多从核死锁问题就是少了它。更高层次的解决方案是使用 Linux 的 DMA APIdma_alloc_coherent分配的内存天然是 cache 一致的但前提是设备树节点的memory-region指向的预留内存和驱动里实际使用的内存是同一块。我踩过一次坑设备树里预留了 16MB驱动里却用kmalloc分配了固件缓冲区结果固件被加载到普通内存从核写回的结果根本不在预留区域内主核侧去读预留区域当然一片空白。后来统一用dma_alloc_coherent才解决。4.4 启动时序与看门狗的连锁反应还有一个容易被忽略的问题是启动时序。主核 Linux 可能在从核固件加载完成前就把从核释放了此时从核的 TCM 还是空的取出来的第一条指令就是全 0会被解码为 illegal instructionRISC-V 核立刻跳进异常入口。如果异常向量表没配好就直接跑飞。正确做法是先确认固件所有段都已经写入目标内存确认 ELF 加载状态再触发复位释放。另外很多 SoC 的小核自带看门狗默认是运行的。主核加载固件需要一段时间如果从核在固件真正运行前就触发看门狗复位你会看到小核永远在启动、复位、再启动、再复位的死循环。解决方式是在主核侧先把从核的看门狗屏蔽或者在小核固件里最早期代码马上停止看门狗顺序不能反。有些芯片的看门狗寄存器需要特定解锁序列写一次不生效这种情况要仔细读手册中关于 watchdog key 的描述。到这里异构 RISC-V 核 bring-up 的完整链路已经走了一遍。我个人在实际操作中最深的体会是这类问题卡住的时候往往不是因为某一个知识点太难而是地址空间、复位时序、中断路由、缓存一致性这几个层面的信息混在一起让人抓不住重点。我的习惯是把问题拆成四层一次只处理一层每层都设一个明确的验证信号。先让核动起来再让它说话最后才让它干活。只要每层都有了可靠的验证手段整个 bring-up 就只是时间问题。这篇先聊到这后面有机会再展开 rpmsg 通道的数据调优和低功耗场景下的核间协作策略。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进