1. 为什么Allwinner的异构RISC-V值得折腾聊到RISC-V bring-up很多人第一反应是QEMU或者便宜的开发板但真正让我觉得有嚼劲的是把RISC-V核心塞进一个原本为ARM设计的SoC里——Allwinner的芯片就是这类玩法的典型代表。这事的价值在于Allwinner的SoC出货量巨大从电视盒子到平板、从路由到工控都有它的身影资料相对丰富但异构RISC-V的bring-up资料却少得可怜。多数公开内容只讲了“能启动”或者“跑通了Linux”至于__怎么让RISC-V核和ARM核协同工作、内存怎么分配、中断怎么路由、riscv__编译宏怎么处理、链接脚本怎么写__这些实操层面的东西几乎没人系统讲过。我写这篇Part 2目的就是补上这些空白。Part 1里我聊过基础的环境搭建和启动流程这篇会重点拆解异构场景下的核心细节从link.ld的编写到中断路由从内存隔离到多核启动顺序全部基于我在Allwinner H系列和V系列上的实际调试经验。适合谁来读如果你手上正好有一块带RISC-V协处理器的Allwinner板子或者你打算在自己的ARM SoC里集成一个RISC-V核做异构计算又或者你单纯想理解异构系统bring-up的底层逻辑这篇文章都能帮你省掉大量踩坑时间。2. 异构bring-up的整体思路拆解2.1 先搞明白异构SoC的启动顺序异构SoC最迷惑人的一点就是启动顺序。Allwinner的芯片通常在片内ROM里固化了一段BootROM代码上电后BootROM会先加载ARM核的引导程序因为BootROM默认把ARM核当作主核系统初始化、DDR训练、电源管理这些都由ARM侧完成。RISC-V核在启动初期实际上处于复位状态它既不参与DDR初始化也不负责加载固件而是等待ARM侧通过寄存器操作把它“唤醒”。这个设计初看有点反直觉但仔细想想很合理RISC-V核作为协处理器如果一上电就参与系统初始化那就要在RISC-V侧重复实现一套DDR训练和时钟初始化的逻辑工程量翻倍不说调试难度也直线上升。在实际操作中我建议把异构bring-up分成四个阶段来看第一阶段ARM侧完成基础初始化包括时钟、DDR、UART确认ARM Linux能正常启动第二阶段ARM侧通过寄存器映射和中断控制器初始化把RISC-V核的时钟和复位解除第三阶段RISC-V核运行独立的固件通过共享内存与ARM侧通信确认基本的数据通路第四阶段在RISC-V侧运行轻量级系统RTOS或裸机程序完成与ARM侧的联合任务调度这个顺序看起来简单但每一步都会有无数细节问题。举个例子第一阶段的DDR训练参数如果ARM侧没有配置好RISC-V核被唤醒后访问DDR会直接挂死而且这种挂死很难定位因为RISC-V侧看不到任何打印。2.2 为什么不能直接照搬ARM侧的启动方案我在Part 1里看到有人直接在RISC-V核上跑ARM版的U-Boot这是不可能的——指令集完全不同二进制根本不兼容。但更深层的问题是即使你给RISC-V核编译了一个U-Boot它也不知道怎么初始化DDR因为这些初始化代码通常写在ARM侧的ATF或者U-Boot里RISC-V核根本碰不到。更现实的做法是RISC-V核从一开始就定位为“应用处理器”不负责系统级的初始化。它的启动固件可以简单到只有几个功能配置自己的异常向量表、初始化UART用于调试打印、初始化共享内存区域、配置中断控制器然后进入一个主循环等待ARM侧的命令。这里特别要提醒一个容易踩的坑RISC-V核的启动地址。在Allwinner的异构SoC里RISC-V核通常从一个固定的SRAM地址开始执行这个地址由硬件决定不能像ARM核那样通过BootROM的跳转逻辑灵活配置。所以你需要仔细查阅芯片手册找到RISC-V核的复位向量地址然后在link.ld里把代码段放到正确的位置。2.3 异构系统的内存视角谁在用哪块内存异构系统里最常见的混乱就是内存使用冲突。ARM侧跑着Linux内核会管理整个DDR空间如果RISC-V核直接往某个DDR地址写数据很有可能会覆盖Linux正在使用的内存页导致系统崩溃。解决办法是用硬件隔离或者软件约定。硬件隔离通常通过IOMMU或者内存保护单元来实现但在Allwinner这类成本敏感的SoC上往往没有完整的IOMMU支持所以更常用的做法是软件约定ARM侧在设备树里预留一段固定的DDR内存通过reserved-memory节点标记Linux内核就不会去使用这段内存RISC-V侧通过link.ld把堆、栈、共享内存区全部放在这段预留内存里ARM侧和RISC-V侧通过共享内存里的固定偏移地址进行数据交换比如偏移0x00放命令字、偏移0x04放状态字这种做法简单可靠但有个前提你必须在memory layout上做到严格统一。RISC-V侧的固件编译时用到的地址必须和ARM侧设备树里预留的地址完全一致差一个字节都会导致访问越界。我在调试时习惯先用一个8KB的共享内存区足够放命令和状态信息后续需要更大数据传输时再单独分配DMA缓冲区。初期不建议用太复杂的通信协议直接用简单的环形缓冲区就好等功能验证通过后再升级到rpmsg或者共享内存文件系统。3. 核心细节拆解link.ld、编译链与固件设计3.1 RISC-V的link.ld到底该怎么写写RISC-V的链接脚本是我这次bring-up里花时间最多的地方之一。ARM的链接脚本大家可能写过不少但RISC-V有些差异需要注意。先用我最常用的一个模板说明OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { ITCM (rx) : ORIGIN 0x00000000, LENGTH 64K DTCM (rwx) : ORIGIN 0x20000000, LENGTH 128K SHM (rw) : ORIGIN 0x48000000, LENGTH 8K } SECTIONS { .text : { KEEP(*(.text._start)) *(.text*) } ITCM .rodata : { *(.rodata*) } ITCM .data : { *(.data*) } DTCM .bss : { *(.bss*) } DTCM .shm : { *(.shm*) } SHM .stack : { __stack_top .; } DTCM }这里有几个关键点第一_start是整个固件的入口硬件复位后第一条指令就是执行它。所以_start必须放在链接后地址的最开始位置这就是KEEP(*(.text._start))的作用——如果不加KEEP链接器可能认为这个函数没有被引用而将其优化掉。第二OUTPUT_ARCH(riscv)必须和你的编译器目标架构一致。如果你是64位的RISC-V就是riscv:rv6432位就是riscv:rv32。写错了链接器不会直接报错但生成的二进制可能没法在目标核上执行。第三内存区域的划分思路是可执行代码放ITCM可读写数据放DTCM跨核共享数据放SHM。这种物理隔离的好处是RISC-V核的代码即使因为bug跑飞了也不会直接破坏共享内存里的关键数据。通常我会把共享内存区定义成单独的section然后在C代码里用__attribute__((section(.shm)))来声明共享变量。这样编译器会自动把这些变量放到共享内存区不需要手动计算地址。3.2 编译参数里容易被忽略的细节RISC-V交叉编译的命令行参数比ARM要稍微复杂一些。我这里用riscv64-unknown-elf-gcc举例riscv64-unknown-elf-gcc -marchrv64gc -mabilp64 -mcmodelmedany -O2 -nostdlib -ffreestanding -c start.S -o start.o riscv64-unknown-elf-gcc -marchrv64gc -mabilp64 -mcmodelmedany -O2 -nostdlib -ffreestanding -c main.c -o main.o riscv64-unknown-elf-ld -T link.ld start.o main.o -o firmware.elf riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin-marchrv64gc表示目标架构是64位RISC-Vgc是通用组合包含乘法除法原子操作和浮点等标准扩展。如果你的目标核不支持浮点就要把gc精简掉改成rv64imac否则编译器生成了浮点指令硬件执行时会触发非法指令异常。-mcmodelmedany是我强烈建议加的。这个选项使用PC相对寻址来访问全局数据和代码可以让程序在任意地址运行非常适用于固件被加载到不确定地址的场景。如果不加这个选项生成的代码可能包含绝对地址访问一旦固件运行地址和链接地址不一致就会直接崩。-nostdlib和-ffreestanding也是必须的。前者告诉链接器不要链接标准的C运行库后者告诉编译器你的代码运行在裸机环境没有操作系统支持。如果你忘了加链接时大概率会报找不到_exit之类的符号。3.3 构建RISC-V固件的最小框架固件本身不需要太复杂。我通常设计成下面这样start.S.section .text._start .globl _start _start: /* 关闭中断配置栈指针 */ csrw mstatus, zero la sp, __stack_top /* 跳转到C入口 */ call main 1: wfi j 1bmain.c#include stdint.h #define SHM_BASE 0x48000000 #define SHM_CMD 0x00 #define SHM_STATUS 0x04 #define SHM_DATA 0x08 __attribute__((section(.shm))) volatile uint32_t cmd __attribute__((aligned(4))); __attribute__((section(.shm))) volatile uint32_t status __attribute__((aligned(4))); void uart_init(void); void uart_putc(char c); int main(void) { uart_init(); uart_putc(R); uart_putc(V); uart_putc(\n); status 0x1; /* 告诉ARM侧RISC-V已就绪 */ while (1) { switch (cmd) { case 0x1: status 0x2; cmd 0x0; break; default: break; } } }这段代码的核心逻辑就两个上电后先通过UART打印一个标识然后把状态字置为1告诉ARM侧“我活着”。之后进入一个命令循环等待ARM侧通过共享内存下发命令。这里有个细节特别值得注意共享内存变量的定义。我在C语言层面用了volatile和aligned属性这是因为编译器可能会对共享内存变量做缓存优化导致ARM侧写入的值没能被RISC-V侧及时看到。加volatile后每次访问都会直接从内存读取避免读到寄存器缓存里的旧值。4. 实操过程在Allwinner H616上的完整bring-up记录4.1 环境准备与工具链选型我这次用的是Allwinner H616这颗SoC内置了一个玄铁C906 RISC-V核官方SDK里带了工具链但我建议优先使用社区维护的版本因为官方工具链偶尔会有一些老bug。工具链方面我用的组合是riscv64-unknown-elf-gcc 12.2.0用于编译RISC-V裸机固件OpenOCD 0.12.0用于调试RISC-V核U-Boot 2023.04用于引导ARM侧Linux主机环境是Ubuntu 22.04交叉编译工具链安装很简单直接用apt装就行但如果需要最新版本建议去RISC-V工具链官网下载预编译包。4.2 启动流程的完整实现整个bring-up过程我用了一个简单的时序图来理解把整个操作拆成以下步骤ARM侧先加载U-Boot并启动LinuxLinux启动后加载一个自定义的内核模块内核模块通过寄存器操作将RISC-V核的复位信号解除RISC-V核开始从固定地址执行固件RISC-V核打印启动信息并在共享内存中写入状态位ARM侧轮询共享内存确认RISC-V核启动成功第3步的寄存器操作是让我折腾最久的部分。不同的Allwinner芯片RISC-V核的复位控制寄存器地址和位定义都不一样。H616的寄存器手册里这个地址在系统控制模块区域但具体偏移量我记不清了Debug的时候全靠试错。这也引出一个建议不要在寄存器地址上过度依赖手册直接把SDK里给的寄存器定义打印出来对照实测值看这样最不容易出错。4.3 共享内存的实际测试在我的验证流程里共享内存通信的测试是我认为最关键的部分。我先在RISC-V侧写了一个自增计数器然后ARM侧每隔一秒读一次看看数值是否连续递增。ARM侧测试代码C语言伪代码void *map mmap(0x48000000, 0x2000, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_FIXED, fd, 0); volatile uint32_t *cmd (volatile uint32_t *)(map 0x00); volatile uint32_t *status (volatile uint32_t *)(map 0x04); while (1) { *cmd 0x1; printf(Status: %u\n, *status); sleep(1); }这里要特别注意mmap的MAP_FIXED标志。如果不用MAP_FIXED内核可能会把这块内存映射到别的虚拟地址导致你写入的物理地址和你预期的对不上。虽然这个例子里用的是物理地址映射但在更复杂的场景下确实会遇到。4.4 实际遇到的具体问题记录这次bring-up过程中我遇到并解决了以下几个典型问题第一个问题是链接脚本的__stack_top没有正确初始化。我在start.S里直接用了la sp, __stack_top但链接脚本里的栈段定义在.bss之后而.bss在启动时尚未清零栈指针指向的地址正好落在未初始化的内存区域导致第一次函数调用就崩了。解决办法是在start.S里先清零.bss段再设置栈指针。第二个问题是共享内存变量的缓存一致性问题。H616的RISC-V核没有硬件缓存一致性协议RISC-V侧往共享内存写数据后ARM侧读到的可能是旧数据。最初的解决方法是在RISC-V侧每次写入后用内联汇编执行fence指令确保数据落盘到内存。后续在数据量变大后我改用了无缓存的内存映射区域。第三个问题是设备树里reserved-memory节点配置错误。我在设备树里预留了0x48000000这段内存但忘了在memory节点里把这段区域排除掉导致Linux内核还是把它当作普通内存管理很快就出现页表冲突。修正设备树后问题才消失。5. 常见问题排查与避坑指南5.1 RISC-V核上电后不执行任何代码这是最常见的现象。先从最基础的三点排查检查复位信号是否真的解除了检查RISC-V核的供电和时钟检查启动地址是否和link.ld里定义的地址一致其中最容易忽略的是第三点。我见过有人把_start编译到了0x00010000但硬件复位后从0x00000000取指结果自然是什么都不执行。处理办法是在调试器里检查PC寄存器的初始值确认和链接地址一致。5.2 UART输出乱码或没有输出如果你能看到乱码说明RISC-V核已经在执行代码问题出在波特率或者UART时钟配置上。RISC-V核的UART时钟源可能和ARM侧不一样所以波特率的计算方法也不同。如果你什么都看不到先排查RISC-V核用的UART引脚是否和ARM侧冲突。不要假设RISC-V核的调试串口和ARM侧是同一个很多SoC上它们是分开的需要额外的引脚复用配置。5.3 共享内存读写不一致这个问题在我调试过程中出现的频率最高。可能的解决方案有给共享内存变量加volatile修饰禁止编译器优化在RISC-V侧的关键写入操作后加fence指令确认共享内存区的物理地址没有被页表重映射确认ARM侧设备树里预留内存配置无误简单说先软后硬先排除编译器和缓存问题再排查硬件层面的路由。5.4 中断路由问题如果你需要在RISC-V侧使用中断比如接收ARM侧的命令通知那就需要仔细检查中断控制器的配置。Allwinner的异构SoC里RISC-V核往往有一个自己的PLIC但IRQ号不一定从0开始而是有一个偏移。这个偏移值通常可以在芯片手册的中断列表里查到但说实话写成什么样都有有时候参考代码比手册管用。另外要注意ARM侧的中断控制器和RISC-V侧的PLIC没有共享路由逻辑ARM侧不能通过SGI的方式去触发RISC-V核的一个中断。想要实现核间中断通常的办法是通过共享内存写寄存器然后让RISC-V核轮询或者通过GPIO触发外部中断。5.5 踩坑速查表问题现象可能原因解决思路RISC-V核不执行复位未解除、启动地址错误检查复位寄存器和PC初值UART无输出引脚复用冲突、时钟配置错误确认调试串口引脚和波特率UART乱码波特率不匹配根据实际时钟源重新计算分频共享内存值不变缓存一致性问题加fence或使用无缓存映射控制器寄存器写不进访问权限未配置检查RISC-V核的访问权限位Linux启动后卡死内存预留配置错误检查设备树的reserved-memory代码跑飞无规律栈溢出或异常未处理扩大栈空间添加异常捕获6. 折腾完之后的一些体会这次H616的异构RISC-V bring-up前前后后花了我将近两个星期大部分时间都用在了问题定位上真正写代码的时间其实很少。但正因为走了这些弯路才对异构系统有了更深的理解。我个人最大的收获是不要把异构bring-up当成“在ARM SoC上跑一个RISC-V程序”而要把它当作“两个独立处理器之间的协同系统设计”。启动顺序、内存布局、中断路由、通信协议这些都是需要在设计阶段就确定的等代码写完了再改代价会成倍增加。另外一个非常实际的建议是给RISC-V核准备一个JTAG调试通道。我用OpenOCD连接RISC-V核后定位问题的速度直线提升。如果没有JTAG你可能会在“我写的代码逻辑错了”和“RISC-V核根本没跑起来”之间反复猜忌那种感觉真的很崩溃。最后再分享一个调试小技巧在RISC-V固件里我把共享内存的状态字定义成一个16位的周期递增计数器ARM侧每读一次就校验一次这样能第一时间发现缓存一致性和内存映射的问题。一旦发现连续读到的值相同基本可以断定是缓存问题直接去改内存映射配置就行。异构bring-up这条路往前走一步是深渊但跨过去之后就是海阔天空。希望这篇Part 2能帮你少踩几个坑。