RISC-V异构SoC Bring Up实战:链接脚本、启动交接与Cache一致性

📅 发布时间:2026/10/3 6:50:06
RISC-V异构SoC Bring Up实战:链接脚本、启动交接与Cache一致性
继续Bringing Up Heterogeneous RISC-V on Allwinner SoCs这个系列。上一篇我们把异构架构的整体思路捋了一遍包括玄铁C906这类RISC-V协处理核在全志SoC系统视图里的地位、主核与从核之间的总线拓扑、以及工具链选型。这一篇要往深处扎专门聊真正动手bring up时最磨人的三件事链接脚本怎么写得让RISC-V核能跑、启动交接的过程里有哪些隐藏时序坑、以及cache和中断这两个最容易被忽视却能卡死调试的领域。文章里所有内容都来自我在实际开发板上的操作经验不是单纯的理论搬运。1. 从link.ld开始RISC-V核为什么不能沿用ARM核的链接脚本很多人在Part 1阶段就栽在这里。异构SoC里的RISC-V核通常不是跑Linux而是跑一个独立的裸机固件或RTOS小系统。它和ARM主核共享同一片物理内存但各有各的地址视图各有各的外设映射。这时候如果你图省事直接拿ARM核的链接脚本改一改就编RISC-V固件大概率会得到一块跑不起来的固件。因为链接脚本的核心职责是决定代码放哪、数据放哪、栈放哪、入口在哪而ARM核的链接脚本里那些段定义、内存区域划分完全不是为RISC-V指令集设计的。1.1 内存区域划分的底层逻辑先看一个典型的全志异构SoC内存布局。以我手头这块板子为例片上SRAM分成好几块其中有一块专门留给RISC-V核做启动代码运行地址区间大概是0x00100000到0x004FFFFF这类4MB左右的区域DRAM则是从0x40000000起始的较大空间。链接脚本的第一步就是告诉链接器这块RISC-V核能碰哪些物理内存以及固件默认加载到哪。OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { SRAM (rwx) : ORIGIN 0x00100000, LENGTH 4M DRAM (rwx) : ORIGIN 0x40000000, LENGTH 256M } SECTIONS { . ORIGIN(SRAM); .start : { *(.text.start) } SRAM .text : { *(.text*) } SRAM .rodata : { *(.rodata*) } SRAM .data : { *(.data*) } SRAM .bss (NOLOAD) : { __bss_start .; *(.bss*) __bss_end .; } SRAM . ALIGN(16); .stack (NOLOAD) : { __stack_top . 2M; } SRAM }这段脚本看起来简单但有几个点必须强调OUTPUT_ARCH(riscv)必须写。有些工具链默认架构不是RISC-V不写这句链接时会产生一堆匪夷所思的relocation错误。ENTRY(_start)指定的是入口符号不是入口地址。RISC-V裸机固件的入口通常用汇编文件定义比如你写一个start.S在里面放一个全局符号_start链接脚本入口指向它。入口地址最终由_start这个符号落在哪个地址决定。栈的定义有很多种写法这里我用了在SRAM里分配2MB空间作为栈把栈顶地址算出来的常见做法。启动汇编里会取__stack_top赋给sp寄存器。我自己踩过的一个坑栈空间分配得离代码段太近又没做地址对齐RISC-V核一开始压栈就把.data段初始化的变量冲掉了表现为所有全局变量在main里读出来全是垃圾值。排查了很久才发现是栈覆盖了数据段后来在内存布局里给栈和bss之间留出了一个明确的guard区间。1.2 为什么link.ld强调对齐和gp全局指针RISC-V和ARM在链接层面有个明显差异RISC-V对gp全局指针寄存器有依赖。合入到链接脚本里通常要提供一个__global_pointer$符号把gp定位到.data段附近的一个合适位置。编译器会生成以gp为基址的short offset访存指令访问全局变量时用gp 12位立即数这样比每次取地址再访问快很多。这个问题如果不处理或者gp初始化位置不对最直接的表现是程序编译能过、链接能过但一跑起来访问某些全局变量就异常值总是不对。典型做法是在启动汇编里加一段gp初始化.option norelax la gp, __global_pointer$ .option relax同时链接脚本里把__global_pointer$放在.data段的中间附近. ALIGN(16); PROVIDE(__global_pointer$ . 0x800); .data : { *(.data*) } SRAM这里. 0x800是根据gp相对寻址的12位偏移范围正负各2048字节来的让gp落在数据段中间这样所有全局变量都能落在gp的寻址范围内。如果你有特别大的全局数据结构就得考虑要不要把它单独放一段避免超出gp偏移范围。除了gpRISC-V的链接脚本里还经常要处理rela段重定位表。虽然裸机固件一般不做动态重定位但如果你打算让ARM侧把固件加载到任意地址再运行那就要考虑PIC位置无关代码和对应的重定位表布局。否则你就老老实实让固件固定加载到链接脚本里写死的物理地址上。固定地址的做法更省事也是多数allwinner异构SoC方案里推荐的。1.3 用readelf验证链接结果链接完后别急着去烧录先花十秒钟确认输出符合预期riscv64-unknown-elf-readelf -h firmware.elf riscv64-unknown-elf-readelf -l firmware.elf riscv64-unknown-elf-objdump -d firmware.elf | head -50重点看三点入口地址是不是你预期的SRAM起始地址Entry point是否等于_start符号地址LOAD段的VMA虚拟地址和LMA加载地址是否和你的MEMORY配置吻合。在异构场景里ELF的LOAD段地址其实就是最终要搬运到的物理地址ARM侧加载固件时可以用readelf直接解析出来不用自己记忆。2. 启动交接ARM侧搬运、复位时序与RISC-V入口三件套到了这一步RISC-V固件已经编出来了接下来要让ARM核把这份固件接生出来。很多人以为就是把固件拷到内存、然后让RISC-V核跳过去跑实际上在Allwinner这类异构SoC上完整流程至少包含三套动作加载固件到内存、配置RISC-V核的入口和复位控制、以及释放复位后的观测确认。三个动作缺一不可。2.1 ARM侧如何加载RISC-V固件全志的大多数异构SoCRISC-V核没有独立的bootrom流程它本质上是被主核通常是Arm核拉起的。所以ARM侧需要承担一个loader的角色。我的习惯是用一个Python小脚本在编译后自动解析ELF的LOAD段生成一份C头文件里面包含段起始地址、长度和字节数据ARM侧启动驱动直接引用#!/usr/bin/env python3 import subprocess, struct, sys elf_file sys.argv[1] output_header sys.argv[2] out subprocess.check_output([riscv64-unknown-elf-readelf, -l, elf_file]) sections [] # 简化解析LOAD段取出物理地址和文件偏移等 # ...这一段实际做的时候要注意解读ELF的LOAD段通常有R E可读可执行和RW可读可写两类对应的物理地址往往不同——比如代码段落在SRAM数据段可能是给DRAM里的拷贝预留的。ARM侧搬移时要把每个LOAD段都搬到对应地址只搬PT_LOAD类型的段忽略PT_NULL和其他调试段。搬完之后也许顺手做个校验和源数据比对一下最省事的是搬完后再从头读一遍内存并做CRC别信搬了就一定搬对了。2.2 复位时序里的隐藏坑这是整个bringup过程里最容易卡住的一环。Release RISC-V核的复位并不是简单地写一个寄存器位就行。在我调试的板子上控制RISC-V复位的寄存器在系统控制模块里同时还会牵扯到时钟门控和电源域状态。正确的操作顺序应该是先把RISC-V核的时钟门控打开确保CPU可以取指再配置RISC-V核的异常向量基址寄存器如果SoC有提供或者确认复位后CPU默认从哪个地址取指然后把准备好的入口地址写到复位向量寄存器如果有的话或者通过硬件strap决定最后才是释放复位。一个非常常见的坑ARM侧先释放了复位然后才去配置某些寄存器结果RISC-V核已经开始从错误地址取指执行了一堆随机内存数据自然就挂死了。你要做的是先准备好一切再松手而不是松开手再追着调。释放复位后RISC-V核对内存的访问路径上还可能有cache或总线buffer的延迟所以ARM侧不要立即往共享内存里写数据最好等RISC-V核给出我已经起来的信号后面会讲怎么给信号最稳妥是等几十毫秒再访问共享区。2.3 RISC-V侧入口汇编三件套RISC-V核的启动汇编跟我见过的ARM启动汇编功能上是类似的但要做的三件事顺序很关键设置sp栈指针为__stack_top初始化gp全局指针清零.bss段并把.data段从LOAD地址拷贝到运行时地址如果LMA和VMA不一样才需要。启动汇编示意.section .text.start .globl _start _start: .option norelax la gp, __global_pointer$ .option relax la sp, __stack_top la a0, __bss_start la a1, __bss_end 1: bge a0, a1, 2f sw zero, 0(a0) addi a0, a0, 4 j 1b 2: call main我把GP初始化放在第一行是因为部分启动代码尤其涉及libgcc辅助函数时会用到全局变量不先设好gp直接进C代码容易出现地址错乱。bss清零也不建议省别假设初始加载器已经帮你清零了——实际上很多情况下DRAM或SRAM里的初始内容完全不可预测。清除bss前也不要假设自己的栈一定是干净的。顺便说一句如果你看到RISC-V核在main里crash但反汇编却发现地址完全不对先检查是不是_start那个section被链接到了太靠后的位置导致入口附近有一段不可写的内存。把.text.start放最前面并在链接脚本里显式放好能省掉很多烦心事。3. cache一致性、共享内存与外设访问权限的边界问题异构SoC里一排cache line引发的惨案永远比你预想的更多。全志这套异构系统ARM核有自己的一二级cacheRISC-V核也有自己的icache和dcache二者通过总线交叉互联去访问同一片物理内存。如果两边同时读写一块共享内存但各自cache里还留着旧数据那么你看到的现象就是ARM侧明明写进去了RISC-V侧怎么读都是旧值或者反过来。3.1 共享内存区域的属性怎么配置最安全当我初次在R329上调试C906和ARM核的共享通信时遇到过一个让我反复抓狂的问题mailbox一块区域ARM写一个new message标志RISC-V轮询却几乎要等很久才看到标志变化而且偶尔还读不到。后来我查了系统总线配置发现问题根源在于这块共享内存被映射成了cacheable属性。RISC-V核读它时数据会被cache吸纳后续轮询全是读缓存里的老值。解决方案不复杂把共享内存区域在页表或系统内存映射寄存器里显式标记为non-cacheable。对于C906这类支持MMU的RISC-V核你要在页表项里清掉A、C等缓存相关位如果SoC本身带内存属性寄存器memory attribute region就直接把对应物理区间配置为device or non-cacheable。但是全部设为non-cacheable也不能无脑用。如果RISC-V核的整个代码段和栈都被设置成non-cacheable性能会明显下降虽然裸机小系统感受不深但做网络或信号处理的场景会很难受。更精细的做法是共享内存单独划一块区域映射为non-cacheable代码和私有数据保持cacheable。以我用的系统为例具体配置是物理地址留出0x4001F000到0x40020000一个4KB页做shm页表项里把这块设置成non-cacheable其余区域走正常cacheable。才几KB空间但通信效率和可靠性都上来了。3.2 fence指令和DMA缓冲的边界另一个一致性麻烦来自DMA。假设ARM侧通过DMA把一个buffer搬运到RISC-V核可以读取的区域。在ARM侧DMA写完内存后如果RISC-V侧cpu cache里还有旧数据那CPU后续读内存取到的可能是旧数据。更隐蔽的是RISC-V核的DMA engine和它自己的L1 cache之间有不一致常见解决办法是让DMA描述符和缓冲区放在固定的non-cacheable区域里或者在启动DMA传输前CPU主动执行cache flush/invalidate操作。RISC-V这边有个轻量指令fence.i它负责同步指令流保证先前写入内存的指令数据对之后的取指可见。当ARM侧把RISC-V固件代码搬运到SRAM后如果RISC-V核的icache还残留旧状态比如这块区域以前被当作数据访问过那它取指时可能拿到旧字节。所以从头执行新固件时最好在入口代码里放一条fence.i把指令缓存和内存同步一下。我自己甚至会在start.S里连续放两条fence.i中间加一个nop——极端情况下单条fence在部分内核实现里有重放限制两条更稳。3.3 外设访问权限别放得太宽全志的异构SoC通常带一个系统控制寄存器组用来定义哪个总线master能访问哪些外设。别犯把RISC-V核能访问的外设窗口开得过大的错误。我见过一个案例RISC-V核错误访问了一片保留地址导致总线事务异常连带把ARM核的外设访问也阻塞了最后整个系统像死机一样找问题找了半天。正确做法是给RISC-V核只开放它必须访问的资源比如UART寄存器、mailbox、GPIO、共享内存其余的访问一律mask掉。哪怕你觉得不设限制更省事也要防范bug路径上对总线的污染——健壮性就是在这些细节里攒出来的。4. 中断路由RISC-V的CLINT/PLIC和mailbox接线方式异构系统里中断这件事必须一开始就想清楚绝不能等到两个核都跑起来才考虑。原因很简单ARM核的中断Controller是GICRISC-V核这边则是CLINT加PLIC两套机制在中断号分配、触发方式、优先级模型上完全不同。全志SoC在设计时会做一定适配但最终还是需要软件来把外设中断源配给某一个核。4.1 CLINT和PLIC的分工逻辑RISC-V核的中断基本分三类本地中断software interrupt、timer interrupt走CLINT每个hart独立外部中断外设中断走PLIC由PLIC汇总后发给某个hart异常/调试中断走CSR机制不依赖CLINT/PLIC。在baremetal环境下我建议你先不急着把所有外设中断都拿到RISC-V核上处理。第一步只把mailbox的中断路由过去其余外设一律留给ARM核。这样能极大缩小调试范围。配置PLIC时要注意中断ID全志芯片手册里会给一张表说明每个外设线对应第几个PLIC中断ID。初期不熟悉时很多人的做法是把中断使能位全设成1然后看PLIC的pending寄存器来推断实际中断号——这招在把设备树或者外设驱动搞乱之后特别高效值得一试。4.2 mailbox在异构通信中的工作流全志方案里ARM核和RISC-V核之间最常见的通信方式是mailbox配合共享内存里的环形缓冲或固定消息槽。mailbox基本是一个一方写寄存器触发对方中断的机制A核向mailbox的某个寄存器写入目标核编号和消息ID硬件把这条事件转成对方核的中断B核在中断ISR里读取mailbox状态清pending位然后从共享内存取数据。这里有个容易忽略的细节mailbox中断的pending位和status位必须按正确顺序读取和清除。我碰到过一次RISC-V核的ISR先清了pending再读status结果status已经被清掉了导致消息永远读不出来。后来改成先读status、保存内容、再清pending问题就消失了。调试这类问题最快的工具就是在ISR里放一个GPIO翻转用示波器看中断响应的时序逻辑分析仪则能捕获报文内容。4.3 中断服务程序的安全区写法异构核的ISR尤其是RISC-V裸机环境的ISR有几条经验值得写下来进入ISR先保存全部寄存器状态别指望编译器帮你做完整的压栈操作在C里写的ISR函数最好用__attribute__((interrupt))告诉编译器这是中断处理函数它会自动保存/恢复上下文。在ISR里不要调用不可重入的函数比如printf如果内部有锁而主流程也在打印一个中断嵌套就可能死锁。ISR里操作共享变量时如果能用atomic或者关中断保护就上保护mailbox的flag变量尤其如此。如果外设的中断打开得早而RISC-V核的PLIC还没完成初始化中断可能在PLIC初始化期间触发导致开不了机。所以早期代码里建议先完全屏蔽PLIC的所有中断、完成CLINT和PLIC初始化、再按需打开具体外设中断。5. 让RISC-V核开口说话串口复用、GPIO探针与常见启动失败定位启动完成没有、固件跑到哪一步了、内存有没有修错这些信息如果你没法快速拿到后面的调试就会变成盲人摸象。在异构环境下让RISC-V核输出一条可见的、可信的启动信息是我每次bring up必做的第一件事。方法有很多种但每一种都有坑。5.1 串口输出独立UART最好复用UART要小心如果SoC给RISC-V核分配了独立UART那是最好的情况。直接在固件里初始化UART、写一个简单的putchar打印一行hello world整个bringup的自信就起来了。但很多全志SoC里UART是主核和从核共用的这时print会乱掉因为两个核的时钟初始化、波特率配置可能互相干扰。我踩过的坑是这样的RISC-V核初始化UART时直接往它的UART基址寄存器里写配置ARM核那边也在跑自己的bootloader两边的波特率配置不一样一个115200一个921600结果一边在发数据另一边UART FIFO被撑满两边都挂。解决办法要么是两边的UART配置做统一由ARM侧在release reset前就把波特率设好RISC-V核只用一个极简的直写FIFO的print函数所有参数都不自己调要么就用Linux kernel里的早期printk的思路给RISC-V核做一个只写不读的fifo输出函数——不读状态、不查波特率直接把字符往tx fifo里塞。后者在调试早期最管用哪怕配置偶尔不对也总能碰巧发出可辨识的乱码至少能确认核在跑。5.2 两个GPIO探针的廉价调试法如果UART实在不可靠我还有一招百试不爽的绝活在启动代码里放两个GPIO翻转点。一个放在_start入口处表示CPU开始取指了一个放在main函数开始处表示bss清零和基本初始化完成再把GPIO的输出引到示波器或者万用表上。如果第一个点有高低变化而第二个点没有说明问题出在启动汇编阶段如果两个点都没有那就是固件压根没被正确加载或入口就跳错地方了。用这招我定位过一次特别隐蔽的问题RISC-V核的启动代码被放在了可执行区域但因为ARM侧搬运时把固件内容copy到了一段被ECC校验占用的内存区间导致RISC-V核每次取指都拿到错数据。我通过GPIO探针发现第一个翻转点一直没生效才知道数据都没放对地方而不是CPU本身没启动。5.3 常见失败模式速查表下表是我在实际调试中整理出的启动失败对照表能帮你迅速缩小问题范围现象最可能的原因快速验证手段RISC-V核完全无执行迹象固件没搬到指定地址复位未释放用调试器读内存确认加载地址有代码检查复位控制寄存器的状态位入口地址跑飞PC在奇怪的地方链接脚本ENTRY或入口符号没匹配异常向量表未正确设置反汇编ELF确认entry point在启动汇编加GPIO探针全局变量读出来是垃圾值gp指针没初始化bss没清零栈覆盖了数据段在汇编里检查gp和sp在main入口打印bss段首个变量共享内存轮询不到新值cacheable属性错误没有加fence检查共享区域映射属性轮询循环前后加fence中断触发但ISR不执行PLIC没使能中断ID配错中断被屏蔽读PLIC pending寄存器确认外设中断是否到达查中断向量表mailbxo消息读出来是空/异常状态位清早了共享内存地址错位在ISR里加GPIO翻转确认时序检查共享区起始地址是否穿透页表5.4 一套能长期用的裸机print框架最后分享一下我现在一直在用的极简RISC-V print框架它足够小可以直接嵌进启动初期代码里不需要依赖任何库#define UART_BASE 0x01C28000 #define UART_TX 0x00 #define UART_LSR 0x14 #define LSR_THRE 0x20 void early_putc(char c) { volatile unsigned int *lsr (unsigned int *)(UART_BASE UART_LSR); volatile unsigned int *thr (unsigned int *)(UART_BASE UART_TX); while (!(*lsr LSR_THRE)); *thr c; } void early_puts(const char *s) { while (*s) early_putc(*s); }这个函数能用的前提是UART已在复用模式下、时钟已开、波特率已由ARM侧设置好。它能让你在不初始化任何东西的情况下完成能看到输出这个里程碑。一旦这个输出里出现了hello riscv整个RISC-V核的启动链路就算打通了剩下的优化和功能外设可以慢慢加。结语前的一点体会异构RISC-V的bring up说到底就是个不断缩小怀疑圈的过程。链接脚本错了改链接脚本cache不一致调内存属性中断不触发翻PLIC配置。每一步都有章可循但每一步的坑都藏在细节里。我个人的体会是不到万不得已不要在大片上跑一个完整的RTOS先用一个二十行的裸机固件把启动链路收通确认CPU、内存、UART、中断、mailbox这五件事全部正常再往里面装系统、装驱动。那时候遇到的每个问题都会被限制在一个清晰可控的小范围内。另外我在实际使用中发现把readelf、objdump和一份内存映射表打印出来贴在工位旁比什么都管用——异构系统调试时你脑子里最不能糊弄的就是对象在哪个地址上。