☰
全志SoC异构RISC-V协核系统级Bring-up实战:从remoteproc到mailbox
2026/10/3 12:15:51 网站建设 项目流程

上一篇文章里,我花了很大篇幅讲怎么把全志 SoC 里那颗 RISC-V 协处理器先“弄醒”——交叉编译工具链、最小固件、下载到 SRAM、点亮一颗测试 GPIO。当时觉得协核能跑裸机程序,这件事就算啃下来了。结果真正往系统里集成的时候才发现,Part 1 那套玩法只是热身。裸机点灯和“让整个系统把协核管起来、用起来、跑稳”之间,隔着时钟复位、内存划分、设备树抽象、核间通信一长串坑。这篇文章是 Bringing Up Heterogeneous RISC-V on Allwinner SoCs 的第二部分,重点不放在怎么编译固件,而是讲从“固件能执行”到“RISC-V 核被 Linux 真正接管并参与业务”这个过程里,我实际踩过的一些判断和取舍,适合正在做全志平台异构多核移植、或者想把协处理器调度起来做低功耗/音频方案的工程师参考。

1. 先分清两种“跑起来”:裸机点灯和系统接管是两码事

1.1 Part 1 的终点,恰好是 Part 2 的起点

Part 1 结束时的状态大概是这样的:通过调试口或者 FEL 模式,把编译好的 C906/E907 固件塞进 SRAM 或者某个固定地址,协核的 PC 指过去,然后串口打印、GPIO 翻转都正常。这一步解决的是“这条 RISC-V 内核有没有坏、编译链对不对、启动地址有没有概念”。

但这里有一个容易被忽略的隐患:这种验证完全绕过了 Linux 和被调试的 SoC 本身。固件活在“裸环境”里,复位逻辑、时钟树、电源域全是默认状态或者 bootloader 顺手配好的。一旦板子冷启动,协核固件就消失得干干净净;主核 Linux 对这颗协核的存在一无所知。

Part 2 要解决的核心问题,就是把这个“一次性验证”变成“系统级资源”。也就是说,A 核上的 Linux 要能完成这几件事:

  • 加载 RISC-V 固件镜像到指定内存
  • 释放协核的复位、配好时钟
  • 能主动启动和停止协核
  • 能在两个核之间传递消息和数据

做到这一步,这颗 RISC-V 核才算真正“接入系统”,而不是一个只能在调试环境下跑的试验品。

1.2 从点灯验证到异构协同,中间隔着一张“资源清单”

第一阶段验证只涉及 CPU 核心本身,而系统级 bring-up 要过问的东西多得多。我习惯把这件事拆成四层来看:

层次对应 Linux 框架实际作用
基础资源clock framework / reset controller协核时钟开关、复位释放/拉起的时序
内存蓝图reserved-memory 机制提前把固件和通信缓冲从 Linux 分配器中隔离出来
设备抽象remoteproc 子系统把协核封装成一个“外设”,提供加载、启动、停止接口
通信管道mailbox + 共享内存A 核与 R 核之间的信令通道和数据通道

这四个层次差不多是所有异构多核平台的通用套路。TI 的 DSP、STM32 的 Cortex-M4、i.MX 的 Cortex-M7,还有全志的这颗 RISC-V 协核,在 Linux 侧的抽象思路基本一致。如果你之前接触过别的平台 remoteproc,再看全志这边会非常亲切;如果没接触过,也建议直接从这套框架入手,而不是自己写一堆 ioctl 驱动去管理协核。

我的习惯是先把这四层分别打通,再考虑合到一起。每一层单独验证时都只动一个变量,这样出问题的时候定位快很多。

2. 内存地盘怎么划:reserved-memory 里的门道

2.1 为什么固件不能随便放在 Linux 能用的内存里

很多人第一次写 remoteproc 固件时会想:我直接把固件放到一个数组里,运行时拷到某个内存地址,不就行了吗?逻辑上没错,但你得保证这块内存不会被 Linux 内核碰。

Linux 内核的页分配器管理着整个 DRAM。如果你只是随便拿一个物理地址放协核固件,过一会儿这个页面可能被 page cache 回收、被 DMA 缓冲覆盖,甚至被进程 mmap 掉。协核从内存取指令的时候读到的已经不是你的固件了,表现出来就是各种不着边际的跑飞、异常指令、卡死在奇怪地址。

所以第一步,必须在设备树里用reserved-memory把一块物理内存“圈”出来,让 Linux 页面分配器在任何情况下都不会去碰它。这一步不是可选项,是必修课。

2.2 no-map 与普通 reserved 的区别

设备树里预留内存有两种常见写法。一种只写reg,Linux 会把它保留但不能被分配,同时还会建立页表映射;另一种是额外加上no-map;属性,意思是“这块内存保留且不要建立内核页表映射”。

在异构协核场景里,我基本都会用no-map。原因有两个:

  • 避免 Linux 侧的缓存别名问题。如果 Linux 对这块内存建立了 cacheable 映射,而 RISC-V 核通过自己的总线和 cache 去访问,两边看到的同一个物理地址可能落在不同缓存状态里,读出来是脏数据。
  • 减少访问路径上的不确定性。不映射之后,Linux 侧要访问共享内存就必须走memremap这种显式映射,配合 cache 维护操作,行为可控得多。

下面是一份我在全志平台上用过的 reserved-memory 节点示例,地址和大小按实际方案调整,这里只演示结构:

reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; rproc_mem: rproc-memory@40000000 { no-map; reg = <0x0 0x40000000 0x0 0x00800000>; }; };

注意no-map和reg必须配套。有些 SDK 版本里遗漏了no-map,Linux 照样能启动,但协核固件跑起来之后莫名其妙丢数据、挂文件系统,查半天都查不到原因。

2.3 链接地址和加载地址必须“对号入座”

内存划好之后,下一个坑就在固件编译这一步。remoteproc 加载 ELF 镜像时,会逐个读取段里面的p_paddr,把数据写到对应的物理地址上。也就是说,固件里的链接地址必须和你预留的内存地址一致,否则镜像搬进去之后,入口地址、栈指针、全局变量全都不对位。

我之前在 R329 平台上就吃过一次亏。交叉编译时用的链接脚本把代码段放在芯片内部 SRAM 地址,下载验证倒是没问题,因为引导阶段就是朝那个地址放的。可一旦切到 remoteproc 模式,内核把镜像加载到预留的 DRAM 地址,而 ELF 头部里记录的入口还是 SRAM 地址,协核一启动就跳到一个空内存区域,打印都没来得及就挂了。

链接脚本的核心思想很简单:让代码段的加载地址和运行地址都落在预留内存里。下面是一个简化版的 RISC-V 64 位链接脚本示例:

OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { RAM (rwx) : ORIGIN = 0x40000000, LENGTH = 6M } SECTIONS { .text : { *(.text*) } > RAM .rodata : { *(.rodata*) } > RAM .data : { *(.data*) } > RAM .bss : { *(.bss*) } > RAM }

编译的时候再配合:

riscv64-unknown-elf-gcc -march=rv64imafdc -mabi=lp64d -nostdlib -T link.ld -o firmware.elf start.o main.o

你可以用riscv64-unknown-elf-readelf -l firmware.elf检查每个段的物理地址,确认它们全部落在0x40000000到0x40600000这个范围内。这个习惯我一直保留下来,每换一次板子或者每改一次内存规划,都会先读一遍段表,再往系统里集成。

3. 让 Linux 把 RISC-V 核当外设管起来:remoteproc 与设备树的最小配置

3.1 remoteproc 到底管了哪些事

remoteproc 是 Linux 内核里专门管理辅助处理器的框架。它在概念上把这些协核当成一个“远程处理器”外设。驱动程序负责:

  • 从文件系统读取固件镜像
  • 按照 ELF 段表把内容加载到指定物理内存
  • 配置复位、时钟、电源等资源
  • 触发处理器运行
  • 在需要时请求处理器停止并善后

对于第一次接触的人来说,最好把 remoteproc 想象成一个“带电源管理的固件加载器”。它不负责核间通信,核间通信是另外一套框架或者自定义驱动的事。但只要你用 remoteproc 把协核拉起来,后面无论做消息传递还是共享内存交互,都会舒服很多。

全志主线内核里对这颗 RISC-V 协核的 remoteproc 支持并不完整,不同 SDK 的差异也很大。实际操作中,我一般先看官方 SDK 里带的驱动和设备树,把最小可用的部分抄出来,再根据自己的内核版本适配。

3.2 一份能工作的最小设备树

下面这份设备树节点是一个比较典型的 RISC-V 协核 remoteproc 配置。不同板子的 compatible、时钟、复位命名会有差异,但结构上大差不差:

c906_rproc: remoteproc@0 { compatible = "allwinner,sunxi-remoteproc"; memory-region = <&rproc_mem>; firmware-name = "c906_fw.elf"; mboxes = <&msgbox 0>, <&msgbox 1>; clocks = <&r_ccu CLK_RISCV>; resets = <&r_ccu RST_RISCV>; };

逐个解释一下关键属性:

  • memory-region:指向之前预留的那块内存,remoteproc 会把固件加载到这里。
  • firmware-name:要加载的固件文件名,内核会从/lib/firmware 下找。
  • mboxes:给协核配套的 mailbox 通道,一般至少两个通道,一个用于发消息给协核,一个用于接收协核发来的消息。
  • clocks和resets:协核的时钟和复位信号,remoteproc 在启动和停止时会动态操作这两项。

这里我先不展开 mailbox 的细节,因为它是独立的一个大话题。先把节点加上,保证 remoteproc 能找到固件、能复位、能启动。编译设备树后,如果一切正常,你会在内核里看到:

remoteproc remoteproc0: c906_fw.elf is available remoteproc remoteproc0: registered remoteproc

对应 sysfs 节点也会出现:

ls /sys/class/remoteproc/remoteproc0/

可以看到 state、firmware 等文件,这就说明系统已经认识这颗协核了。

3.3 echo start 之后可能发生的三种“假启动”

配置完设备树,第一件事就是手动启动协核试试:

echo start > /sys/class/remoteproc/remoteproc0/state

这个命令看着简单,实际成功率并不高。我总结过三种最常见的失败表现:

第一种:状态变成了 running,但协核没有任何动作,串口没打印、GPIO 没翻转。这种多半是复位没有真正释放。设备树里写了resets属性,但驱动没有调用复位框架的reset_control_deassert,或者复位控制器本身和硬件信号不匹配。排查方法很简单:用 devmem 直接读复位寄存器,确认协核对应的 bit 是不是被拉低了。

第二种:启动瞬间有动作,但马上卡死。这种往往是固件入口地址的问题。ELF 里记录的e_entry和_start符号实际所在地址对不上,或者代码段被加载到了错误的位置。用readelf -h firmware.elf看一眼入口地址,再对比 link.ld 里的内存布局。

第三种:协核看似在跑,但表现极不稳定,跑几秒钟就挂。优先级最高的是检查时钟频率对不对。全志芯片里协核的默认时钟可能来自一个未配置的 PLL,频率完全不是你想要的值。如果固件里有串口打印,而串口波特率是按固定 PLL 算出来的,就会看到乱码或者完全没有输出。这种“假启动”最容易误判,我建议固件里一上来就主动翻转 GPIO,用示波器确认实际运行节奏,别依赖串口。

4. 双核对话:mailbox 的信令和共享内存的数据

4.1 为什么我不建议用轮询共享内存标志位来传数据

协核跑起来之后,最开心的事就是在两个核之间传点东西。很多第一次做异构的人会直接写一个共享内存结构体,A 核往某个标志位写 1,协核轮询这个标志位变化,然后去读数据。

我从实际项目里得到的一个教训是:轮询标志位虽然简单,但它把 CPU 时间全浪费在忙等上,而且延迟不可控。协核跑的是 RTOS 或者裸机循环,一轮轮询去读内存、判断标志,可能几微秒到几十微秒才响应一次。主核这边如果发的是关键指令,这个延迟没法接受。

更合适的做法是 mailbox 做信令、共享内存做数据的组合。mailbox 本质是一个可以触发硬件中断的消息队列机制,它解决的问题是“通知”。A 核写完共享内存后,敲一下 mailbox 的门铃,协核立即进中断去处理;反过来协核要回数据,也通过 mailbox 通知 A 核。共享内存只承担数据本身,不承担“有没有新消息”这个状态判断。

用一句话概括:mailbox 是门铃,共享内存是留言板。门铃响了,你才去看留言板,而不是一直坐在留言板前等。

4.2 mailbox 通道的配对和常见翻车

全志平台上的 mailbox 硬件单元一般有多个通道,每个通道对应一个中断。设备树里配置mboxes时,索引号的定义必须和驱动实现严格对应。

比如:

mboxes = <&msgbox 0>, <&msgbox 1>;

这里第一个通道我通常约定为“主核发送用”,第二个通道约定为“协核发送、主核接收用”。具体哪个通道对应硬件上的哪条中断线,要看芯片手册和驱动源码。翻车最多的场景就是两个通道配反了,导致 A 核发消息时,B 核收不到中断,或者收的是另一个通道的残留数据。

我排查的时候会先打开中断日志,看协核的 mailbox 中断号有没有被 Linux 正常注册。如果没有触发,就把通道顺序调换再试。这个验证非常快,推荐优先做。

配合使用时,发送流程大概是这样的:

  1. A 核写共享内存数据结构
  2. 写 barrier,确保之前的写入对协核可见
  3. 触发 mailbox 发送寄存器
  4. 协核收到中断,读取共享内存
  5. 协核处理完后写回应,再通过 mailbox 触发 A 核中断

接收流程同理。两边的驱动都需要维护好收发状态机,不要把消息解析逻辑散落在中断处理函数里,否则后面加业务功能时很难维护。

4.3 共享内存结构设计与 cache 一致性实操

共享内存里的数据结构,我建议从最简单的开始:一个头部加数据区。

struct rproc_msg { uint32_t magic; // 固定魔数,用于校验 uint32_t len; // 数据长度 uint32_t flags; // 状态标志 uint32_t payload[]; // 数据内容 };

第一版不需要设计复杂的环形缓冲区,因为你还不知道双方的读写节奏。先用单消息槽验证通路,跑通后再扩展。

cache 一致性是这里最隐蔽的一个坑。如果 RISC-V 协核的总线不带硬件一致性协议,共享内存的读写会经过各自的 cache。A 核写完数据后,数据可能还停在 CPU cache 里没有刷到 DRAM,协核读到的是旧数据;反过来协核写的数据,A 核去读时也可能读到 cache 里的老副本。

解决手段有几种:

  • 在 Linux 侧对共享内存做显式 cache 维护,写完后dma_sync_single_for_device,读取前dma_sync_single_for_cpu。
  • 在内核侧用memremap映射这块 no-map 内存时,选择非 cacheable 属性。
  • 在协核裸机侧关闭对应内存区域的 cache,或者手动执行 fence 指令。

我第一个版本图省事,直接把共享内存区域映射成 cacheable,结果出现间歇性丢消息。后来把所有 cacheable 映射全部去掉,传输才稳定下来。性能损耗在这种低频率信令场景下完全可接受,稳定压倒一切。

5. 先跑谁后跑谁:两种启动模型与资源争夺

5.1 模型 A:Linux 先起,remoteproc 后加载协核

这是最主流也最好排查的模式。Linux 启动完成后,文件系统就绪,/lib/firmware 里有固件,然后你手动或通过脚本触发协核启动。

优点很直接:

  • 内核所有资源管理框架都已就绪,时钟、复位、电源域都能正确引用
  • 固件放在文件系统里,换固件版本只需要替换文件,不用重新烧 bootloader
  • 如果协核跑挂了,可以随时 stop 再 start,不需要整机复位

缺点是协核启动时间偏晚。如果协核承担的是低功耗待机唤醒、传感器常时监控这类任务,Linux 起完才让协核上班,就错失了最需要它发挥作用的窗口期。

5.2 模型 B:在 bootloader 阶段就把协核跑起来

有些方案会在 uboot 启动阶段直接把协核固件从存储设备拷贝到 SRAM 或 DRAM,释放复位,让协核先于 Linux 运行。

这种模式适合低功耗唤醒场景:系统深度睡眠时主核完全不跑,协核保持运行,监听唤醒事件。但代价是调试难度明显上升。bootloader 里没有完整的内存管理,固件加载地址、资源初始化全都要手工保证,而且 Linux 起来之后对协核的状态一无所知,如果两边要握手,还得额外设计一套“bootloader 启动过协核”的信息传递机制。

两种模型的取舍我列在下面:

维度Linux 先起模型bootloader 阶段启动模型
调试便利性高,sysfs 直接控制低,问题定位依赖打印
启动时间协核上班晚协核最早可用
固件更新替换文件系统内固件需要更新 bootloader 镜像
低功耗场景不适合做常时监控适合做唤醒源管理
系统崩溃恢复可单独重启协核通常要整机重启

我的建议是先用模型 A 完成所有功能验证,等协核业务稳定了,再评估是否有必要迁移到模型 B。直接上模型 B 的话,一旦出问题,你根本分不清是固件的问题还是 bootloader 初始化顺序的问题。

5.3 电源域和复位的联动坑

全志平台有一个容易踩的细节:协核和主核可能共用某个电源域,或者协核挂在低功耗域里,但这个域的电源由 PMIC 统一控制。如果你在设备树里给 remoteproc 配了power-domains,而驱动在停止协核时把电源域切掉了,可能会把别的模块也一起断电,表现就是主核某些外设莫名其妙丢失。

我在做 R329 平台时遇到过这种情况:一执行echo stop,协核停了,结果同一电源域下的音频模块也跟着嗝屁了。查了半天才发现电源域引用配错了,或者更准确地说,这个电源域本来就不该由 remoteproc 单独管理。

处理办法有两个方向:

  • 查芯片手册的电源域拓扑,确认协核和哪些模块共享电源,如果共享就别在设备树里暴露给 remoteproc 单独控制。
  • 如果你只是想让协核能跑起来,暂不考虑深睡功耗,最简单粗暴的方案是一开始就让电源域常开,remoteproc 只管复位和时钟,不管电源。

我的建议是 bring-up 阶段以功能为目标,电源域不要玩太花,先保住稳定,再抠功耗。

5.4 一个翻车现场的复盘:内核崩在启动协核的瞬间

最后分享一个实际排查场景。当时在 H618 平台上一执行echo start,整个内核直接 panic,日志里看到的是访问非法地址的异常栈。

第一反应是内存预留不够或者地址冲突,但检查 reserved-memory 后发现固件地址没有和内核镜像重叠。后来把设备树里 remoteproc 节点的属性逐一删减,发现只要去掉clocks属性,启动协核就不崩。

再去翻时钟驱动代码,发现这个时钟的引用计数在驱动 probe 阶段没有被正确增加,remoteproc 启动时 clock framework 执行了 enable 开关,底层访问了尚未初始化的 PLL 寄存器,直接导致总线错误。

这类问题的排查链路,我的建议是一层一层隔离:

  1. 去掉所有通信相关属性,只保留memory-region和firmware-name,验证纯加载启动
  2. 加上resets,验证复位释放
  3. 加上clocks,观察是否触发时钟框架错误
  4. 最后加mboxes,进入通信验证

每加一个属性都重新测试一遍。不要一次全配齐,否则任何一环出错,都要面对一堆变量的叠加影响。

6. 从“能跑”到“跑得稳”:验证手段与稳定性建议

6.1 三颗灯法:从点灯验证升级到交通灯协议

协核固件跑起来之后,第一步验证往往还是点灯。但与其只点一颗常亮灯,不如把 GPIO 翻转频率升级成一种简单的状态协议:

  • 协核启动完成:GPIO 保持高电平
  • 协核收到 A 核消息:GPIO 翻转一次
  • 协核内部错误:GPIO 输出特定频率的脉冲,比如 10Hz 方波

这样不需要接调试器,一个 LED 加一个示波器就能判断协核是活着、在工作、还是已经挂了。我在很多 bring-up 阶段都靠这个方式快速确认状态,比每次去翻串口日志方便得多。

6.2 长时间压力测试与统计

点灯通过只是开始。真正让协核接入业务之前,我会做一个简单的 ping-pong 测试,A 核每隔 10ms 发一条消息给协核,协核收到后立刻回一条,A 核统计总发送条数和超时条数。

跑 24 小时之后,如果丢包率是零,且 mailbox 中断计数没有溢出,这个通信链路基本可以信任。如果有偶发丢失,优先怀疑 cache 一致性维护不到位,再检查 mailbox 通道中断是不是被别的驱动抢占了优先级。

另外一个容易被忽略的项目是温度。异构协核和主核封装在一起,高负载下发热明显。如果协核长时间满频运行,可能触发热控降频,导致通信延迟抖动翻倍。压力测试时要多留意/sys/class/thermal下的温度读数。

6.3 调试手段补充:打印、寄存器、陷阱和习惯

没有 JTAG 的情况下,全志平台协核调试主要靠串口打印和寄存器读取。固件里多埋打印点、多用十六进制寄存器值输出,比什么都强。协核的串口映射关系要提前确认,有些芯片协核串口的基地址和主核串口完全不同,复用不当会导致打印落在错误的外设上。

如果你用 devmem 去读寄存器验证协核外设状态,注意避开 reserved-memory 区域。直接读共享内存物理地址,在 no-map 模式下看不到预期内容,容易误判。

我从多次 bring-up 里形成的个人习惯是:任何硬件行为都先用寄存器确认,再谈上层框架。固件一跑通,马上把复位寄存器、时钟寄存器、mailbox 状态寄存器的初始值全部 dump 一遍存档。后面改设备树或者调驱动,随时可以做前后对比。这套流程放在全志的 R329、H618 或者其它 A+H 异构平台上都通用,核心方法论不是某颗芯片特有的东西。

写到最后想多说一句:在异构 bring-up 这件事上,真正决定进度的往往不是代码能力,而是“怀疑到验证”的速度。每次只改一个变量,改完用示波器或者寄存器确认结果,看起来慢,实际上是最快的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询