先交代一个背景:上一篇我们完成了串口通信、工具链验证、最小固件编译,板子底子算是垫稳了。这一篇直接进入正题,把大家最关心的那一段补齐——异构RISC-V核到底怎么在全志SoC上跑起来。以我手边这块基于Allwinner D1-H(玄铁C906,RV64)的板子为例,它内部不止有RISC-V主核,还挂着DSP和其他协处理单元,天然就是一个异构系统。所谓bring-up,不是单纯把某个核点亮,而是要让不同架构、不同特权级别、不同时钟域的核心按预定顺序启动,并最终在同一个操作系统下协同工作。
这部分内容很杂,涉及到链接脚本、OpenSBI、U-Boot、设备树、Linux内核配置,还有一堆藏在寄存器手册里的细节。写这篇文章的目的,就是把我实际操作中整理的流程和踩过的坑全部分享出来,给后面要碰Allwinner RISC-V平台的人减少一些掉头发的时间。如果你是准备在嵌入式方向深挖的工程师,或者正在做异构计算的系统适配,这篇内容应该能帮你省下不少看手册的功夫。
1. 异构启动的整体蓝图
1.1 异构系统的两个层次
先明确一下"异构"这个词在我们这个语境里到底指什么。以D1-H为例,主计算核心是基于玄铁C906的RISC-V处理器,它支持RV64IMAFDC指令集,带MMU,能跑Linux。同时片上还有DSP核心用于音频/信号处理,以及各类专用控制器。这种"通用计算核+专用处理器"的组合,是当前嵌入式SoC的主流形态,也是异构计算的基础。
另一种异构形态更隐蔽:部分Allwinner芯片里藏着一颗完全不面向用户的小型RISC-V控制核,负责电源管理、安全启动、低功耗监控这类后台工作。这颗核平时不会被Linux感知,但在系统上电初期可能扮演重要角色。这篇文章主要围绕前一种形态展开,也就是把跑Linux的C906主核拿起来,并让系统里其他核各归其位。
这里的关键认知是:异构不是"多核同一个架构",而是"多个核各有各的指令集、特权模型和中断视图"。因此bring-up的第一步,不是急着写驱动,而是先把每颗核的定位、启动顺序、通信方式在纸面上画出来。
1.2 全志平台的启动链路
全志SoC的启动链条大致是:BootROM → 固化引导代码 → SPL/引导固件 → U-Boot → OpenSBI(或等效的M-mode固件)→ Linux内核。放在RISC-V平台上,M-mode固件通常由OpenSBI承担,它负责初始化硬件、提供SBI调用接口,并把系统切换到S-mode让U-Boot和Linux运行。
有些朋友可能会问,U-Boot本身也能裸跑在M-mode,为什么还要中间插一个OpenSBI?原因在于Linux内核需要SBI提供的运行时服务,比如定时器、中断委托、远程核唤醒。如果让U-Boot直接交权给Linux,这些基础设施就得Linux自己重新初始化一遍,既有风险又重复劳动。OpenSBI的存在,相当于把M-mode的"管理权"和S-mode的"运行权"分离开来。
另外要特别注意,全志的DDR初始化通常发生在SPL阶段,这部分代码依赖厂商闭源库或者逆向工程后的补丁。在异构场景下,如果RISC-V核和DSP核也需要访问DDR,必须在SPL阶段就把DDR控制器的所有rank和通道配置好。这一点直接影响后续从核是否能正确读写内存,非常关键。
1.3 启动顺序的设计思路
异构系统启动顺序的设计,核心原则是"先主后从"。先把主控制核拉起来,让它具备调度和资源管理能力,再逐个唤醒从核。以D1-H为例,C906主核先运行,OpenSBI完成基础初始化后进入Linux;DSP和协处理器则通过Linux侧的 remoteproc 框架或者专用驱动,在系统运行阶段按需加载固件启动。
选择这种顺序有几个实际原因。第一,从核启动往往需要依赖主核配置时钟、电源域和中断路由,如果从核先跑,它可能连时钟都没准备好。第二,从核固件通常存放在文件系统里或者预留在特定内存分区,需要主核OS的存储和文件系统支持才能加载。第三,从核运行过程中发生异常,需要主核作为管理方来重启或复位。
在Part 1里面我们已经验证过工具链和串口,到了这一步,主核固件的编译输出应该已经能正常跑到U-Boot。下一步就是处理链接脚本,因为从裸机固件到正式启动流程,每一个核的固件都离不开内存布局的精确控制。
2. 链接脚本:给RISC-V核画好地图
2.1 为什么link.ld是bring-up的第一个坎
链接脚本(通常叫link.ld)做的事情,本质上是告诉链接器"代码放在哪里、数据放在哪里、堆栈在哪里、入口是什么"。对于x86或者ARM平台,很多开发者习惯了使用默认链接脚本,不太关心这些细节。但RISC-V异构核的固件启动场景下,默认脚本几乎必然出错,必须手动编写。
原因有两个。第一,RISC-V核的启动地址由硬件复位向量决定,代码必须放在复位向量对应的地址上,或者至少保证复位向量处有一条跳转指令到代码入口。第二,不同核之间的内存空间可能是共享的,也可能是私有的。如果链接脚本把两个核的数据段放在同一块物理内存上,互相踩坏就麻烦了。
我自己的经验是,拿到一颗新核,第一件事就是打开数据手册找两样东西:复位地址和内存映射表。复位地址决定入口位置的摆放,内存映射表决定哪些地址可以被代码段占用。这两样确定之前,不要急着写C代码,不然链接出来的固件烧进去就是黑的,连串口都没有输出。
2.2 一个可用的C906固件链接脚本
下面这个脚本,是我在D1-H平台上验证过的一个简化版本,适合做裸机或者极简固件的内存布局参考。它把代码放在0x40000000(DDR起始区域),为每个核单独划分了栈空间,同时对BSS段做了对齐处理。
OUTPUT_ARCH("riscv:cv64") ENTRY(_start) SECTIONS { . = 0x40000000; .text : { KEEP(*(.text._start)) *(.text .text.*) } .rodata : { *(.rodata .rodata.*) } .data : { *(.data .data.*) } . = ALIGN(16); .bss : { __bss_start = .; *(.bss .bss.*) *(COMMON) __bss_end = .; } . = ALIGN(4096); .stack : { __stack_top = .; . = . + 0x10000; } . = ALIGN(16); __heap_start = .; __heap_end = 0x40080000; }有几个点需要展开说明。
ENTRY(_start)定义了链接后的入口符号,但这只是告诉链接器"入口在哪",真正决定处理器从哪里开始执行的是硬件复位向量。如果芯片复位后直接跳到0x40000000,那么第一条指令必须是_start标签处的内容;如果不是,就需要保证复位向量处有一条跳板指令。
__bss_start和__bss_end这两个符号是给C启动代码用的。C运行时(crt0)在调用main之前,要把BSS段清零,否则未初始化的全局变量会带着随机值进入程序,这是嵌入式开发初学者最容易忽略的问题之一。
栈空间的划分尤其重要。异构系统里每个核都应该有独立的栈,否则两个核共用一个栈区域,子程序调用一多就互相覆盖,表现为运行一段时间后随机崩溃。分配栈的时候,我还习惯在栈底放一个固定的魔数,比如0xDEADBEEF,启动后检查栈溢出是否踩到了这个魔数。
2.3 链接脚本的常见错误
第一类是LMA与VMA混淆。链接脚本里AT关键字用来指定加载地址(LMA),如果固件是直接从Flash加载到内存执行,LMA和VMA通常一致;如果涉及解压或者拷贝加载,就必须区分。RISC-V平台上很多固件是XIP(就地执行)的,这里相对简单,但一旦引入加密启动或者分段加载,就要小心。
第二类是内存区域重叠。默认生成脚本把整个RAM都分配出去,两个固件如果都按默认脚本链接,烧写后数据段就可能重叠。我调试一个RISC-V核和DSP核通信时,遇到过DSP固件把C906内核的数据段覆盖掉的情况,排查半天才发现是链接脚本里两个段恰好指向同一段DDR物理地址。解决办法是给每个核的链接脚本划定专属内存窗口。
第三类是缺少对齐。RISC-V里某些指令和异常向量地址有严格的对齐要求,比如mtvec要求按4字节对齐,但在启用压缩指令时还有更严格的对齐约束。如果链接脚本里没有显式ALIGN,链接器放置的内容可能不满足对齐条件,结果会出现"指令执行到一半跳飞"这类诡异问题。
链接脚本这块是bring-up的地基,地基歪了后面全歪。接下来要讲的是怎么把固件和引导流程串起来,让RISC-V核在U-Boot和OpenSBI的配合下进入Linux。
3. U-Boot与OpenSBI的协同启动
3.1 OpenSBI在链路中的位置
在RISC-V的正式启动流程中,OpenSBI扮演M-mode固件的角色。它要做的事情包括:解析并处理来自S-mode的异常和中断请求、提供定时器服务、管理CPU热插拔、维护SBI调用接口。对Linux内核来说,OpenSBI是一个硬件抽象层,内核不需要直接操作M-mode下才可见的CSR寄存器,而是通过SBI调用来完成特权操作。
全志平台的OpenSBI编译并不复杂,配置好交叉编译器后,指定平台和设备树源文件即可。我建议在编译OpenSBI时打开详细日志,这样第一阶段的打印信息会更齐全。很多时候RISC-V核启动卡住,OpenSBI的日志能直接告诉我们卡在哪个初始化步骤,省去盲猜的时间。
有一点要特别说明:OpenSBI的固件既可以被U-Boot加载,也可以直接链入U-Boot镜像。两种方式各有优劣,前者灵活,后者简单。我在调试初期倾向于链入方式,这样只需关注一个镜像的加载,排查起来更直接;等到框架稳定后再切换到分离方式,便于单独升级OpenSBI。
3.2 U-Boot对RISC-V异构的支持
U-Boot对RISC-V的支持已经比较成熟,但它默认只关注"当前正在执行的这枚核"。如果你的SoC同时存在多个核,U-Boot启动阶段通常只让主核运行,从核停留在固件里提前写好的自旋等待状态。等到U-Boot加载完成设备树和内核镜像,再通过特定机制把从核入口信息传出去。
在U-Boot的设备树里,每颗CPU都应该有对应的节点,包括它的reg属性(CPU编号)、riscv,isa属性(支持的指令集)、mmu-type属性(MMU类型)。从核如果在同一簇内,通常共享一个时钟域和电源域;如果不在同一簇,还要额外描述它们的中断控制器连接关系。
D1-H的C906核走的是标准RISC-V流程,U-Boot本身可以编译为S-mode运行。U-Boot启动Linux时,会通过bootm命令加载kernel镜像和FDT,然后调用OpenSBI的sbi_hart_start扩展来把控制权转交给内核的入口。这一步如果成功,串口上会连续输出U-Boot和Linux的双重启动日志,是bring-up过程中的重要里程碑。
3.3 从核唤醒的三种机制
异构系统中,从核唤醒通常有三种方式,需要根据硬件条件选择。
第一种是自旋表(spin-table)。主核在约定的内存地址写入从核的启动跳转地址,从核在上电后进入一段循环,不断读取该地址,一旦发现非零就跳过去执行。这种机制简单可靠,是SMP启动的传统做法,缺点是浪费从核的电力和总线带宽。
第二种是PSCI/SCMI这类固件标准调用。主核通过SBI调用向OpenSBI发出"启动CPU编号X"的请求,OpenSBI负责把从核从复位状态拉起来并设置入口。这种方式更规范,也方便在运行时管理CPU开关,但依赖固件对特定硬件的支持。
第三种是硬件邮箱(mailbox)中断。主核向从核发送硬件中断,从核的中断服务程序收到事件后,再从规定的内存区域读取启动信息。这种方式实时性好,但需要为从核专门编写中断处理逻辑。
在D1-H上,我实测下来,OpenSBI的PSCI风格远程启动最干净。内核把启动入口和上下文写到约定寄存器结构,SBI调用触发,OpenSBI完成剩余工作,整个过程不需要从核的固件参与太多逻辑。从核固件只需要在上电后进入一个简单的自旋循环即可。
3.4 实操:从主核跳转到从核入口
以C906从核的汇编入口为例,下面这段代码是典型的自旋等待结构。注意RELEASE_ADDR这个宏要替换成实际约定的物理地址,而且这个地址在系统启动期间不能被其他模块占用。
.section .text.entry .align 6 .globl _start _start: /* 关闭中断并设置栈 */ csrwi mstatus, 0 csrw mie, zero la sp, __stack_top wait: li t0, RELEASE_ADDR ld t1, 0(t0) beqz t1, wait jalr t1 loop: j loop主核侧的Linux初始化代码会将RELEASE_ADDR指向的内存写入从核入口的物理地址,然后执行一个屏障指令,确保写入操作对其他核可见。从核检测到非零值后,读取该地址并跳转执行。
这里有一个我踩过的坑:RELEASE_ADDR如果放在带cache的普通内存里,从核可能因为cache一致性延迟而迟迟读不到主核写入的值。解决方案有几种,最直接的是把该地址所在的页面映射为设备内存(device memory,不使用cache),或者使用cache flush指令在主核写入后立即刷掉缓存行。实际调试时,我给这块区域配置了不带cache的属性,问题立刻消失。
从核进入Linux后,内核会为其创建CPU热插拔状态机,依次完成栈初始化、页表切换、中断接管等步骤。下面我们进入Linux侧的适配环节。
4. Linux侧的适配与验证
4.1 设备树里如何描述RISC-V核
Linux内核通过设备树来了解硬件信息,在RISC-V平台上也不例外。设备树里的cpus节点需要罗列所有可启动的CPU,包括它们的ID、指令集、MMU属性和时钟关系。一个典型的C906 CPU节点像这样:
cpus { #address-cells = <1>; #size-cells = <0>; timebase-frequency = <24000000>; cpu0: cpu@0 { device_type = "cpu"; compatible = "riscv"; reg = <0>; riscv,isa = "rv64imafdc"; mmu-type = "riscv,sv39"; clocks = <&ccu CLK_RISCV_CORE>; operating-points-v2 = <&cpu_opp_table>; }; };timebase-frequency决定RISC-V定时器的频率,这个参数必须和SoC实际供给的timebase时钟一致,否则内核里所有基于时间戳的调度都会出错。我遇到过把24MHz写成25MHz的情况,系统跑起来后,sleep(1)实际只睡了0.96秒,一开始还以为是触摸不稳定,最后查出来是设备树时钟频率写错了。
riscv,isa属性要准确描述核的指令集。C906支持RV64IMAFDC,其中C代表压缩指令扩展,F和D代表单精度和双精度浮点。如果漏掉了某个扩展,虽然内核也能启动,但某些浮点运算路径会走软件模拟,性能损失明显。
operating-points-v2指向一个频率电压表节点,用来描述CPU的运行点和对应的电压域。如果启动阶段不需要DVFS,可以先不接这个节点,等系统稳定后再补上。
4.2 内核配置与编译选项
编译Linux内核前,需要确保CONFIG_RISCV、CONFIG_SMP、CONFIG_RISCV_SBI、CONFIG_SERIAL_EARLYCON等选项正确开启。其中SMP支持是让从核被识别的关键。如果只开UP(单核)配置,系统永远只会使用一个核,另一颗核即使硬件上已经跑起来,也不会被纳入调度范围。
内核启动时如果在日志中看到 "CPU for hart 0 is not available" 这样的信息,一般说明CPU节点状态字段不对。每个CPU节点的status属性如果不是 "okay",内核就会跳过那颗CPU。这个问题在复用其他平台设备树文件时特别容易踩到。
此外,还要注意CONFIG_RISCV_ISA_C这个选项,它控制内核是否启用压缩指令。C906支持压缩指令,但如果你开启了它,而某颗配套的RISC-V核不支持,就会出现非法指令异常。异构平台如果同时存在多种RISC-V核,应该以兼容性最低的那颗核为准来设置ISA选项,或者使用动态检测机制。
4.3 启动验证步骤
一切配置完成,重新编译内核和U-Boot后,进入验证阶段。我习惯按下面几个步骤来做:
第一步,确认主核启动。串口出现Linux内核版本信息和 "SMP: Total of 2 CPUs activated" 类似的日志,说明主核和从核都被内核识别了。
第二步,检查/proc/cpuinfo。RISC-V平台会打印每个CPU的ISA信息。对比不同核的isa字符串,确认它们是否一致。如果显示 "rv64imafdc" 说明CPU在正常状态。
第三步,跑一个简单的并行负载。比如用stress-ng或者自己写一段多线程程序,观察CPU亲和性是否正常工作。还可以通过echo 1 > /sys/devices/system/cpu/cpu1/online和echo 0 > .../offline测试CPU热插拔。RISC-V平台上CPU热插拔依赖OpenSBI远程启动支持,如果这两条命令能正常开关从核,说明整条链路已经打通。
第四步,做一次稳定性测试。长期运行高负载程序,观察系统是否会出现死锁、中断风暴或者内存不一致。这一步时间跨度长,但非常必要,它能发现一些只在特定时序下才出现的偶发问题。
4.4 性能与功能验证
系统跑起来之后,除了稳定性,还要验证基于RISC-V特性的一些功能点。比如浮点性能,用简单的矩阵运算对比有FPU和没有FPU的差距;比如中断响应延迟,通过中断计数器来评估PLIC分发是否正常;再比如内存带宽,交叉编译一个lmbench或者stream工具,确认cache策略没有明显的性能回退。
这些验证的价值在于,它们能暴露一些"系统没挂但性能不对"的深层问题。比如Cache映射方式配置错误时,功能测试可能完全通过,但跑stream时数据带宽只有预期的三分之一。这类问题一旦上线后再发现,排查成本会比现在高出很多。
5. 常见问题与排查技巧实录
5.1 问题速查表
下面表格整理了我在bring-up阶段遇到频率最高的几类问题,以及对应的排查思路。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 从核完全没有执行 | 时钟源未使能,或复位被长时间拉低 | 检查时钟控制寄存器,确认从核的复位释放位 |
| 从核执行到一半死循环 | 入口地址写错,或者跳转地址处代码未加载 | 用OpenOCD读取PC寄存器,比对预期入口地址 |
| 串口日志中断 | 早期串口驱动与U-Boot驱动冲突,或引脚复用错误 | 检查pinmux配置,确认串口引脚没有被其他外设占用 |
| 系统启动后随机崩溃 | 多处内存布局重叠,或从核栈溢出 | 检查各固件链接脚本的内存窗口,加入栈溢出魔数检测 |
| 中断无法触发 | PLIC中断网关未使能,或者CPU中断位未打开 | 依次检查PLIC enable、pending、claim寄存器 |
| 长时间运行后卡死 | 从核cache一致性未维护 | 在共享内存区域使用无cache或强一致属性 |
每一条看起来简单,实际排查起来都够喝一壶的。下面挑两个展开细说。
5.2 从核PC跑到奇怪地址
这个问题出现时,OpenOCD读到的从核PC值往往是一个明显不合理的地址,比如0x0、0xffffffff,或者在内存空洞里。常见原因之一是链接脚本没有把入口段放在复位向量位置,导致从核在复位向量处读到的是数据或空指令,然后一路顺着乱码跑下去。
另一种可能是从核在被唤醒时,还没有完成自己的C运行时初始化,比如全局指针(global pointer)寄存器gp没有设置。RISC-V代码在访问全局变量时,大量依赖gp寄存器做偏移寻址。如果gp值是0,第一次访问全局变量就会触发异常。这种问题的典型现象是,从核跳转后第一条指令能执行,但走到任何涉及全局变量的地方就死掉。
排查这类问题,我强烈建议打开OpenSBI的分支预测和异常日志打印,并给从核固件增加一个简单的LED闪烁或者GPIO翻转函数,放在每一个关键初始化步骤之后。通过观察硬件信号,就能判断程序到底跑到了哪个阶段。
5.3 中断路由的坑
RISC-V的中断控制器PLIC与ARM GIC在概念上相似,但细节差别很大。PLIC把多个外部中断源集中起来,按优先级分配给多个目标HART。每个HART有自己的中断使能寄存器和抢占阈值寄存器。如果只配置了PLIC而忘记设置HART的中断使能位,中断根本不会到达CPU。
还有一个常见的坑是RISC-V的中断委托机制。M-mode的OpenSBI需要把S-mode的外部中断通过sie寄存器委托给Linux,Linux才能直接处理外设中断。如果委托关系配置不正确,外设中断会被M-mode的OpenSBI拦截,而OpenSBI默认不处理具体外设中断,从外部看起来就是"中断丢了"。
排查中断问题,我建议先写一个最小化的裸机中断测试程序,绕过Linux和OpenSBI的复杂交互,直接在M-mode下配置PLIC和CPU中断位,用一个按键触发外部中断,确认硬件链路本身是通的。之后再逐步把复杂度加回来。
5.4 Cache一致性的隐性陷阱
异构核之间如果通过DDR通信,而CPU核又带有Cache,很容易出现通信数据"看不见"的问题。比如主核往某地址写入一批数据,从核在DSP里读取时,读到的可能是旧数据。
硬件层面,如果SoC的片上总线是缓存一致性互连(如CCI/CMN之类),软件不需要额外处理;如果只是简单的总线桥接,那就必须通过软件手段维护一致性。全志中低端SoC大多属于后者,软件要做好两件事:写数据后执行屏障和Cache清理,读数据前执行Cache失效。
我在实际代码里,对核间通信的共享内存区域,直接映射为设备内存属性(device memory),这样CPU访问时不经过Cache,代价是性能有所下降。但对于消息传递这类低频操作,性能损失完全可以接受,换来的是极简单的实现和调试体验。
6. 调试工具链与实测心得
6.1 OpenOCD与JTAG实战
bring-up没有JTAG,就像蒙着眼睛开车。OpenOCD支持RISC-V调试模块,连接好JTAG之后,可以用halt暂停从核、读取reg pc查看执行位置、用load_image把固件加载到指定内存地址。这些操作在遇到"程序莫名其妙卡死"的问题时尤其有用。
一个建议是,至少给调试固件保留一个JTAG引脚功能,不要在正式版里为了节省引脚就把它关掉。你能想象出一次问题,产品已经出货,却因为没留调试接口,只能靠串口反复盲改代码的场景吗。
6.2 实测数据记录
从核启动时间、中断响应延迟、核间消息传递耗时,这三项是最值得记录的指标。启动时间影响系统恢复速度,中断延迟影响实时性能,消息传递耗时影响异构任务的调度效率。建议把每次修改前后的数据都记录下来,形成自己的性能基线。这样后续版本升级后,只要看数据对比,就能快速判断改动有没有引入性能回退。
6.3 从bring-up到产品化的扩展路径
异构RISC-V平台bring-up的成功,只是万里长征第一步。后续的工作还有很多:为从核编写实际业务固件、设计核间通信协议、配置电源域和DVFS策略、做安全启动和固件签名、优化启动时间。在系统层面,还需要考虑如何利用RISC-V的可扩展指令集,为特定算法定制加速指令,在指令层实现性能突破。
在这些扩展中,每一步都会反过头来对初始的bring-up代码提出修改要求。所以我把启动过程的每一段代码都做了细致的注释和模块划分,就是为了将来能快速定位和替换某一个环节,而不是牵一发动全身。
这段bring-up经历走到最后,我个人体会最深的一点是:异构系统调试,本质上是一个"拆解信任链"的过程。你不能一开始就相信任何一层软件已经正确,只能从最底层的裸机点灯开始,一砖一瓦地把信任建立起来。链接脚本确认了,再信任链接脚本;OpenSBI日志正常了,再信任OpenSBI;Linux打印出所有CPU激活信息,再信任整个软件栈。每次只引入一个变量,其他全部保持已知状态。这种看似笨拙的方法,反而是RISC-V异构平台bring-up中最高效的路径。
最后再分享一个小技巧:如果你的平台支持QEMU模拟,我强烈建议先用QEMU把整个启动链路跑通一遍,哪怕模拟器和真实SoC存在差异。QEMU里能稳定通过的开机流程,拿到真板上调试时,问题范围可以大幅缩小,你只需要关注硬件差异部分,而不是从头开始排错。这套"先模拟、后真机"的思路,帮我避开过很多低级错误。