Allwinner异构SoC RISC-V协核Bring-up实战:从复位到双核通信
发布时间:2026/10/4 12:56:46 作者:尧图编辑部 阅读量:1,286

“Bringing Up” 这个词玩嵌入式的人基本都懂但真正上手做一次才会发现纸上谈兵和把硬件弄活之间隔着一条巨大的鸿沟。上篇 Part 1 讲的是环境准备和 SoC 的基本启动路径这次 Part 2 我直接给出一套完整可复现的方法把 Allwinner SoC 里藏在 ARM 主核旁边的 RISC-V 协核从一条复位线开始带到能跑自己的固件、能和 Linux 主系统交换数据、能把日志打出来的状态。这套流程我在 H 系列板子上完整跑过文章里所有代码和命令都是实际能用上的。文章适合正在做 Allwinner 平台低功耗管理、安全启动、外设辅助功能的嵌入式工程师也适合想理解“异构 SoC 到底怎么 bring-up”的初学者。先说清楚一件事这里说的 SoC 不是手机天梯图里那种拼跑分的芯片天梯图容易让人把 SoC 理解成一个大封装 CPU实际上 SoC 内部永远有一堆你看不见的处理器核这次我们就要把一个“看不见”的核正式点亮。1. 异构架构拆解先搞清楚这块 RISC-V 到底是干嘛的1.1 异构不是大小核很多人一听“异构”就想到 ARM 的 big.LITTLE那是同构处理器的大小核组合指令集完全一致只是算力和功耗不同。Allwinner 这种异构不是这个概念。它在 ARM 主处理器旁边塞了一个独立的 RISC-V 核这个核和 ARM 之间没有共享指令集也没有统一 cache它就是一颗“协处理器”。协核的角色通常是电源管理、安全启动、低功耗待机维护或者做一些轻量外设的辅助控制。它和主核的关系更像“管家”而不是“打手”。这种设计在嵌入式 SoC 里越来越常见。Allwinner 不是第一家这么干的但它的量产板和开源 boot 资料相对多适合拿来练手。华为海思、瑞芯微的部分产品也有类似结构只是资料没 Allwinner 这么透明。理解了这一点再做 bring-up 就不容易犯方向性错误你不是要让 RISC-V 去跑 Linux 里的应用而是要让它作为独立子系统先能独立运行再通过共享内存和中断和主系统协作。1.2 Part 1 到 Part 2 之间还差什么Part 1 做完板子已经能完成最基础的电源、时钟初始化和 Boot ROM 引导。但 Boot ROM 只是把启动链的第一段代码搬进 SRAM 而已RISC-V 协核这个时候还停在复位状态时钟门控也没打开更别说运行固件。Part 2 的目标很明确搭好一套适合裸机固件的 RISC-V 交叉编译工具链写一个最小固件让协核启动后先执行一段可验证的动作从 ARM 侧把固件加载进协核的 SRAM 并解除复位建立共享内存和门铃机制实现双核通信让协核能输出日志方便后续开发和排障。我最终把每个目标对应到一次真实的板级操作上跑通以后再回过来整理成文。整个过程花了两周中间踩了不少坑后面第五部分会集中讲。2. 工具链与最小固件让 RISC-V 先跑起来2.1 选型为什么是 bare-metal 工具链给协核写固件工具链推荐riscv64-unknown-elf-gcc而不是riscv64-linux-gnu-gcc。前者是 bare-metal 工具链没有内置 libc 的运行时依赖编译出来的镜像只有一个纯裸机 ELF链接脚本完全自己控制后者默认带 glibc 和多层启动 crt生成的固件会隐式调用一堆运行时初始化在只有几百 KB SRAM 的协核环境里既臃肿又容易出问题。安装很简单Ubuntu 上直接sudo apt install gcc-riscv64-unknown-elf gdb-multiarch装完验证一下riscv64-unknown-elf-gcc -v注意看输出里的--with-archrv64imac类似的配置。如果你的协核是 32 位实现Allwinner 一些低功耗协核确实是 rv32工具链也支持编译时显式指定-marchrv32imac -mabiilp32即可。我强烈建议从-marchrv64imac或rv32imac起步不要一上来就开gc或f扩展因为很多 SoC 的 RISC-V 协核只实现了整数指令和压缩指令浮点扩展可能压根没有或需要额外配置。编译器默认开了浮点而硬件没有固件跑起来第一个浮点指令就非法指令异常这种错误最难查。2.2 启动代码与链接脚本写法协核上电后不会像 Linux 那样有一堆引导程序替你准备环境一切都从复位向量开始。你需要自己写一段 startup完成三件事设置栈指针、清 BSS、跳转到 C 的 main。我把这段写得尽量通用.section .text.start .globl _start _start: la sp, _stack_top csrr a0, mhartid bnez a0, .Lwait la a0, _bss_start la a1, _bss_end .Lbss: bgeu a0, a1, .Lbss_done sd zero, 0(a0) addi a0, a0, 8 j .Lbss .Lbss_done: call main .Lwait: wfi j .Lwait第一行la sp, _stack_top最容易写漏。栈指针如果没指到有效 RAM后面任何一次函数调用都会写穿内存表现出来就是固件“莫名其妙跑飞”。BSS 清零也是可选的但如果固件里有静态变量还依赖初值这步不能省。mhartid判断是为了兼容多核启动的情况即使协核只有一个 hart至少不破坏将来扩展的可能。链接脚本是整个 bring-up 最关键的一步。协核只能访问它自己地址空间里的 SRAMARM 主核那边的大内存它不一定看得到。你需要把代码段、数据段、栈全部安排到协核私有的 SRAM 里。参考这个模板OUTPUT_ARCH(riscv) ENTRY(_start) BASE 0x44000000; /* 占位地址换成你手册里协核 SRAM 基址 */ SECTIONS { . BASE; .text : { *(.text.start) *(.text*) } .rodata : { *(.rodata*) } .data : { *(.data*) } .bss : { *(.bss*) *(COMMON) } . ALIGN(16); . . 0x1000; /* 预留 1KB 栈空间 */ _stack_top .; }注意BASE这个地址我刻意用占位符。不同型号的 Allwinner SoC协核 SRAM 基址差别很大一定以你手里那颗芯片的用户手册《Memory Map》章节为准。实在找不到可以反编译官方 boot0 的启动代码看它往哪个地址搬运 RISC-V 固件那个地址就是正确答案。2.3 用 MMIO 验证核心真的在执行第一步固件不需要复杂功能只需要一个能观测的执行事实。我选的方法让协核往一块 ARM 和 RISC-V 都能访问的共享 SRAM 写一个幻数ARM 侧再读出来。如果读到的值等于预设说明协核确实跑起来了。主程序很简单#define SHARED_BASE 0x44101000UL #define MAGIC_ADDR (SHARED_BASE 0x100) #define MAGIC_VALUE 0x52563235UL /* RV25 */ void main(void) { *(volatile unsigned long *)MAGIC_ADDR MAGIC_VALUE; while (1) { asm volatile(wfi); } }这个写幻数的动作本质上是第一个 MMIO 操作。很多新手一上来就让协核操作 GPIO、UART结果搞不清是固件没跑还是外设没配。先写共享内存可以避开时钟和外设初始化这两大坑聚焦到“核心是否在动”这件事上。编译命令riscv64-unknown-elf-gcc -marchrv64imac -mabilp64 -mcmodelmedany \ -nostdlib -nostartfiles -T link.ld startup.S main.c -o fw.elf riscv64-unknown-elf-objcopy -O binary fw.elf fw.bin这里-mcmodelmedany很关键。RISC-V 默认的 medlow 代码模型只支持绝对值 2GB 以内的寻址如果 SRAM 基址在高位地址空间不写medany链接会直接报 relocation 错误。3. 从 ARM 侧把固件送进协核3.1 手动加载先用一条命令验证工具链和固件都就绪之后剩下的核心动作是把编译好的fw.bin写到协核的 SRAM再去操作复位寄存器让它开始执行。第一步先用最原始的方式验证不走内核直接在 ARM 的 Linux 用户态用/dev/mem操作物理地址。先看协核 SRAM 基址和复位寄存器在 ARM 侧对应的物理地址。这里以我板子上的占位值演示实际以手册为准# 假设 SRAM 物理地址 0x44000000复位寄存器物理地址 0x0300a000 # 先把固件写进 SRAM用 dd 写 /dev/mem 不直接支持任意 offset这里示意用自写工具 ./fw_load fw.bin 0x44000000 # 解除复位写 1 到复位寄存器的 bit0 devmem 0x0300a000 32 1把固件搬进 SRAM 用dd没那么方便/dev/mem的 write 接口对地址对齐有要求。我写了一个 30 行的小工具思路是 mmap 目标物理地址的页面然后直接把 bin 文件内容拷贝进去#include stdio.h #include fcntl.h #include sys/mman.h #include string.h #include unistd.h #include stdlib.h int main(int argc, char **argv) { if (argc ! 4) { fprintf(stderr, usage: %s file.bin phys_addr size\n, argv[0]); return -1; } unsigned long phys strtoul(argv[2], NULL, 0); unsigned long size strtoul(argv[3], NULL, 0); int mfd open(/dev/mem, O_RDWR | O_SYNC); if (mfd 0) { perror(open /dev/mem); return -1; } unsigned long page_off phys ~0xfffUL; void *map mmap(NULL, size (phys - page_off), PROT_READ | PROT_WRITE, MAP_SHARED, mfd, page_off); if (map MAP_FAILED) { perror(mmap); return -1; } FILE *f fopen(argv[1], rb); fread((char *)map (phys - page_off), 1, size, f); fclose(f); msync(map, size (phys - page_off), MS_SYNC); munmap(map, size (phys - page_off)); close(mfd); return 0; }编译后用 fw.bin 实际加载然后再去读共享内存的幻数地址devmem 0x44101100 64能读出0x52563235就说明协核已经被点亮并且执行到了 main。我在这个验证点上专门卡了一天原因后面说总之如果读到全零先别怀疑固件去查复位寄存器。3.2 一个可复用的轻量加载工具手动验证通过后我自己写了一个一次性的 bash 脚本封装整个流程方便反复调试#!/bin/bash set -e FW${1:-fw.bin} SRAM_PHYS0x44000000 RESET_PHYS0x0300a000 ./fw_load $FW $SRAM_PHYS $(stat -c%s $FW) devmem $RESET_PHYS 32 1 sleep 0.1 magic$(devmem 0x44101100 64) echo magic $magic这个脚本的价值不在代码本身而在于它把“加载-复位-验证”三个动作固化成固定顺序。每次改固件重新编译跑一遍脚本3 秒内就知道新固件有没有把核心跑挂。这比每次敲一长串命令高效得多。后续要做的把fw_load的物理地址改成从设备树读取把 devmem 替换成内核提供的 remoteproc 接口那就是产品级方案了。3.3 走 Linux remoteproc 的正确姿势长期跑手动加载不现实协核固件应该纳入系统管理。Linux 内核的 remoteproc 框架就是干这个的。比较理想的状况是 SoC 厂商提供标准驱动但 Allwinner 的官方 mainline 对 RISC-V 协核支持还不完整实际开发往往要自己写一个极简 platform driver。骨架大概是这样static int rproc_fw_load(struct rproc *rproc, const struct firmware *fw) { void __iomem *sram_base ioremap(COPROC_SRAM_PHYS, SZ_256K); memcpy_toio(sram_base, fw-data, fw-size); writel(1, COPROC_RESET_REG); iounmap(sram_base); return 0; } static int rproc_ops_start(struct rproc *rproc) { /* 复用 fw_load */ } static int rproc_ops_stop(struct rproc *rproc) { writel(0, COPROC_RESET_REG); /* 复位协核 */ return 0; }驱动注册后固件放/lib/firmware/通过 sysfs 接口echo start /sys/class/remoteproc/remoteproc0/state就能启动协核。如果不想自己造轮子可以直接在设备树里把协核描述成一个remoteproc节点内存区域用sram节点声明。这里有个设计取舍手动加载适合 bring-up 阶段remoteproc 适合稳定后集成。不要一开始就在 remoteproc 上死磕先把基本执行验证通再谈框架否则会把“我固件有问题”和“我驱动写错了”混在一起排障难度翻倍。4. 双核通信共享内存、门铃与日志4.1 缓存一致性那些事协核能跑起来只是第一步真正的产品要的是 ARM 和 RISC-V 协作干活。最合理的通信方式就是共享内存两个核都能访问一块物理 SRAM通过约定格式交换消息。但这里有一个嵌入式新手几乎必踩的坑——缓存一致性。ARM Cortex-A 核的缓存是写回式的CPU 往内存写数据时先写进 cache满足一定条件才回写 DRAM/SRAM。如果 ARM 侧写一块共享内存后用普通读的方式通知 RISC-V 去读RISC-V 很可能读到旧数据或者 AMP 环境下 ARM 访问 RISC-V 刚写的数据时读到 stale cache。解决办法有几个。最省事的是把共享内存区映射成 uncached 或 device 内存。在 Linux 里用pgprot_noncached做 mmap或者在设备树中把这块 SRAM 节点配置成no-cacheable。另一种是显式做 cache 维护操作void *va ioremap(SHARED_PHYS, SHARED_SIZE); /* 写完通知 RISC-V 之前刷 cache */ dma_map_single(dev, va, SHARED_SIZE, DMA_BIDIRECTIONAL); /* 或使用 DCACHE 清理指令 */我在验证的时候偷懒直接用 devmem 读写共享地址因为 devmem 内部用的是 noncached 映射所以没遇到一致性问题。后来换成内核驱动没加 cache 维护ARM 侧怎么读都是旧值调试了很久。最终方案是把共享区固定映射为 write-through一劳永逸代价是写入性能稍差但协核通信本来就不会有高带宽需求。4.2 一个简单的握手协议共享内存里跑什么数据格式建议从最简单的结构体开始。我实际用的协议是这个#define MSG_MAGIC 0x4D534731 #define MSG_CMD_SET 0x01 #define MSG_CMD_ACK 0x80 struct ipc_msg { uint32_t magic; uint32_t cmd; uint32_t seq; uint32_t payload[4]; uint32_t done; };工作流程是 ARM 侧先填magic、cmd、seq、payload把done清 0然后写一个门铃寄存器通知 RISC-V。RISC-V 收到门铃后读消息执行处理写回结果把done置 1。ARM 侧看到done 1就认为请求完成。门铃的方式有讲究。如果 SoC 支持两个核之间互相触发中断用硬件中断最好大多数 Allwinner 低成本芯片没有现成的核间中断线只能退而求其次用轮询门铃寄存器。轮询的代价是延迟不稳定但协核本来就是做后台管理几十微秒的延迟完全可以接受。我在实现里用了volatile加上 barrier/* 写完消息字段后必须保证顺序可见 */ __sync_synchronize(); WRITE_ONCE(msg-done, 1);__sync_synchronize()在 ARM 上会生成dmb指令在 RISC-V 上生成fence保证写操作的顺序不会被编译器或 CPU 乱序掉。这个细节很多人忽略结果就是双核各写各的顺序完全错乱。4.3 让 RISC-V 输出日志的三种办法协核上连个调试串口都没有怎么确认它在干什么我用过三种办法从简到繁列出来按你的阶段选择。方式优点缺点适用阶段共享 SRAM 环形日志缓冲区最简单不依赖外设ARM 侧周期性 dump不能实时要手动拉取bring-up 初期独立 UART 输出实时性好能看到完整调用流需要给协核配置时钟和引脚复用工作量大功能联调阶段JTAG 单步调试能看寄存器、单步源码、设置断点Allwinner 通常锁 JTAG开启麻烦疑难杂症排障我最推荐先做环形日志缓冲区。实现不超过 50 行#define LOG_BUF_BASE 0x44102000 #define LOG_BUF_SIZE 0x1000 static uint32_t log_pos; void log_write(const char *s) { char *buf (char *)LOG_BUF_BASE; while (*s) { buf[log_pos (LOG_BUF_SIZE - 1)] *s; } }ARM 侧每隔一段时间 dump 一下LOG_BUF_BASE附近内存就能看到协核的打印。好处是不需要碰引脚配置不需要额外的驱动纯内存操作。等系统功能稳定后再考虑把日志通道升级成 UART 或者把数据通过 IPC 消息发给 Linux 的dmesg这样产品运行阶段也能拿到协核日志。5. 典型故障与排障记录5.1 固件写进去了但核心无响应这是所有做 bring-up 的人第一个遇到的拦路虎。现象很典型fw_load成功复位寄存器也写了但幻数地址读出来永远是 0。我排查的顺序供参考确认复位寄存器写对了。很多 SoC 的复位逻辑是1表示复位0表示运行写反了等于让核心一直住在复位状态。查手册是最笨但最有效的办法。确认固件里栈顶地址没有指向不存在的内存。链接脚本算出的_stack_top如果在 SRAM 范围之外启动后第一次函数调用就写穿核心会跳转到某个非法地址运气好停在wfi运气不好死循环。确认时钟门控打开了。协核所在电源域和时钟域经常默认是关闭的需要操作 CCUClock Control Unit寄存器把模块时钟打开。我踩的就是这个坑——复位寄存器一直没问题但时钟没开核心压根没有时钟信号自然一动不动。5.2 跑两步就进异常能读到幻数之后接着写复杂固件很快就遇到跑两步就进异常的问题。常见原因有两个。一个是编译器默认的指令集扩展和实际硬件不匹配。如果你把-march定成rv64gc但硬件没有浮点单元代码里任何浮点常量都会触发非法指令异常。解决方法是确认 SoC 协核的真实 ISA最低从rv64imac起步需要浮点再说。另一个原因是 RISC-V 的 PMPPhysical Memory Protection——如果上游 boot 或安全固件给协核设了内存保护区域协核访问被 PMP 禁止的地址会直接触发异常。排查办法是看 RISC-V 的mtvec有没有设置并在异常处理入口挂一个 GPIO 翻转确认实际进入了 trap。5.3 共享内存数据“丢”了前文已经详细说过缓存一致性问题这里补一个最常见的表象RISC-V 侧写共享变量ARM 侧一直读旧值或者反过来。排查时先确认映射属性再看有没有做 cache flush。还有一个隐蔽问题两个核同时对同一个变量做非原子访问。我发生过 RISC-V 写 4 字节的done标志时ARM 侧正好读到了中间状态结果认为消息没完成直接超时。解决方法是把标志访问改成volatile加单字节读写让每次更新都是原子可见的。现象可能原因快速排查办法幻数为 0复位极性错、时钟没开、栈指针错查 CCU/复位寄存器、查链接脚本栈位置跑几步进异常ISA 扩展不符、PMP 限制objdump -d检查指令、读 mtvec 和 mcause共享数据旧值缓存一致性改成 noncached 映射或显式 flush门铃到达但消息滞后乱序访问插入fence/dmbbarrier日志乱码环形缓冲区位置冲突确认 LOG_BUF_BASE 没有和固件代码段重叠5.4 调试手段与开源验证工具真正定位问题只靠串口打印效率太低我会配合三样东西。第一是反汇编riscv64-unknown-elf-objdump -d fw.elf确认链接结果里每条指令的地址是否符合预期尤其是_start是否真的放在BASE地址。第二是 QEMU 或 Renode 这类开源模拟器把固件丢进去先跑一遍能验证纯逻辑错误跟硬件解耦。现在很多 RISC-V 核验证都开源化了如果你自己做过 RISC-V CPU 设计对这种“加载二进制到仿真内存再跑”的流程应该很熟bring-up 的思路是完全一样的。第三才是 JTAG因为不少 Allwinner 芯片默认把 JTAG 锁住要在安全引导配置里开权限耗时很大不是第一选择。最后还想说的一个习惯每次 bring-up 到一个稳定状态我都会做两件事把这时的固件二进制和工具链版本签一个 git tag并在 README 里记下加载地址、复位寄存器值、时钟配置这几个“魔数”。因为后面一旦改了任何一项回归验证时很容易分不清是代码问题还是环境配置漂移。这套流程后续其实还可以继续扩展比如把协核接入 Linux 的 CPU hotplug/cpufreq 做真正的大小核调度或者让它接管深睡唤醒。等我把低功耗调度这部分跑出稳定结果大概率会出 Part 3在那之前先把这一套流程在你的板子上跑通有问题欢迎在评论区对表排查。