1. 先接上 Part 1 的线:这一篇要解决什么问题
1.1 上次把大核跑通,这次轮到小核
Allwinner 异构 RISC-V bring-up 这个系列写到 Part 2,说明 Part 1 那点铺垫已经不够用了。Part 1 我主要讲了怎么确认芯片型号、怎么理解这颗 SoC 上 ARM 主核的启动链路(BROM → SPL → U-Boot),以及怎么在串口上看到 U-Boot 正常打印。做完这些,芯片的“大路”算是通了:大核能跑、DDR 能访问、基础外设能操作。
但异构芯片的价值,恰恰不在大核上。以 V853 这类方案为例,除了 Cortex-A7 之外,芯片内部还挂着一颗 T-Head E907,这是个 RISC-V 小核。主核上跑 Linux,小核被设计用来干那些不适合 Linux 干的活:低功耗待机管理、音频编解码辅助、传感器融合预处理,甚至安全启动的协助。Part 2 要做的,就是让这颗小核从“datasheet 上存在”变成“实际能跑、能通信”。
所以这篇不是什么高深的理论文章,就是一份怎么把 RISC-V 副核从零拉起来的实战记录。我会把工具链搭建、链接脚本、启动汇编、固件加载、核间通信、问题排查这几个环节全部过一遍。整个过程不依赖厂商闭源 SDK 的魔法,而是基于开源工具链和寄存器操作,你能在自己的板子上一步步复现。
1.2 这一篇的三个具体目标
在动手之前,我先把目标拆清楚,避免越写越散。第一,把 RISC-V 副核的固件工程搭起来,包括交叉工具链的选择、link.ld 链接脚本的编写、启动汇编的写法,这是所有调试验证的基础。第二,用 ARM 主核把固件搬运到副核能访问的内存,配置时钟和复位控制寄存器,释放复位让副核真正跑起来。第三,建立一套主核和副核之间的共享内存通信机制,先从最简单的“我活着”状态开始,再到命令收发。
这篇文章适合两类人。一类是手里正好有 Allwinner 异构方案的板子,比如 V853、T113-S3、R329 这类 A7 + RISC-V 组合的,可以照着流程直接复现。另一类是没有对应板子,但想理解异构双核 bring-up 通用套路的朋友。工具链怎么选、链接脚本为什么容易翻车、共享内存协议怎么写、缓存一致性怎么处理,这些经验放到任何 ARM + RISC-V 双核组合上都成立,不局限于 Allwinner。
2. 先搞清楚对手:Allwinner 异构方案里的 RISC-V 到底是个什么角色
2.1 厂商为什么要往 SoC 里塞一个 RISC-V 小核
很多人第一次接触“异构 SoC”这个概念,是从手机芯片的“大核小核”开始的。但 Allwinner 这类做嵌入式/边缘 AI 方案的厂商,塞 RISC-V 小核的思路不太一样。手机上的大小核是同架构、不同性能档位,而 Allwinner 是直接把两种指令集架构放进一颗芯片:ARM 主核负责跑 Linux 和复杂业务,RISC-V 小核负责跑裸机固件或 RTOS。
这么设计的理由很实际。低功耗待机场景下,整个 Linux 系统没有必要保持运行,主核可以进入深睡眠,由一颗功耗极低的 RISC-V 核来维持基本功能,比如检测唤醒事件、维持 RTC 计时、轮询传感器。还有就是实时性要求高的场景,Linux 的调度延迟不可控,把中断密集、对抖动敏感的任务放到小核上更稳妥。另外还有一个隐形理由:RISC-V 核配合独立 TCM 内存,天然适合做安全启动或安全服务的载体,和主核在物理上隔离,出了问题也不会把整个系统拖垮。
我个人的体会是,异构 bring-up 最容易犯的错,就是把小核当成“另一个 Cortex-M”来对待。实际上它在总线矩阵里的位置、复位时序、时钟来源、和主核共享的外设,每一样都值得先翻手册确认,再动手写代码。尤其是总线拓扑,小核能不能访问 DDR、能不能访问某个外设寄存器,往往决定了你的固件设计。
2.2 几款典型芯片的异构结构对比
我在实际项目里接触过的 Allwinner 异构方案,大致可以分成三类。第一类是 R329 这种 ARM A53 + RISC-V DSP 的组合,RISC-V 侧偏重音频/AI 加速。第二类是 V853、T113-S3 这种 Cortex-A7 + E907 的组合,这也是本文主要讨论的对象。第三类是 H616/H618 这类,主核是 Cortex-A53,内部还有一个 RISC-V 安全协处理器,早期主要跑 BROM 固件。
这三种方案的 bring-up 路径不完全一样,但共性很强:先确认 RISC-V 核在复位后的默认状态,再确认它的程序入口是怎么决定的,最后确认它和主核共享哪些内存和寄存器。下表整理了几个关键差异点:
| 芯片 | ARM 侧 | RISC-V 侧 | 主要定位 | RISC-V 启动方式 |
|---|---|---|---|---|
| D1/D1s | 无(全 RISC-V) | C906 | 主核 | 常规启动流程 |
| V853 | Cortex-A7 | E907 | 辅助/低功耗 | ARM 释放复位 |
| T113-S3 | 双 Cortex-A7 | E907 | 辅助/实时任务 | ARM 释放复位 |
| R329 | 双 Cortex-A53 | RISC-V DSP | 音频/AI | ARM/SPL 加载 |
| H616/H618 | 四 Cortex-A53 | E906 安全核 | 安全启动辅助 | BROM/固件接管 |
项目标题里说的 heterogeneous RISC-V on Allwinner SoCs,指的就是后面几行的场景:一颗 ARM 主核和一颗 RISC-V 副核共存,需要你手动把副核“拉起来”。这和 D1 那种整颗芯片都是 RISC-V 的情况有本质区别——D1 只要管好一条启动链,而异构方案里有两条启动链,它们之间存在依赖关系。
2.3 上电瞬间,RISC-V 核看到的是什么样的世界
搞清楚复位后的初始状态,是 bring-up 的第一个关键点。对于 V853/T113 这类芯片,E907 在芯片上电后会处于复位状态,程序计数器停在复位向量地址,但能不能取指、能访问哪些内存,取决于 ARM 侧有没有把时钟和总线权限配好。这一点和独立 MCU 完全不一样——MCU 上电自己就跑,异构 SoC 的副核上电后是“锁住”的,必须由主核解锁。
内存视角也很重要。E907 通常有自己的 TCM(Tightly Coupled Memory),也有对片内 SRAM 的访问权限,但对 DDR 的访问往往要等 ARM 侧完成 DRAM 初始化之后才可用。第一次 bring-up 我强烈建议只跑 TCM/SRAM,不碰 DDR,这样能隔离掉一堆内存初始化、缓存一致性相关的问题。
还有一个容易忽略的点:RISC-V 小核的异常向量和中断控制器。E907 这类 T-Head 内核,中断模型和 SiFive 的标准 CLINT/PLIC 略有差异,外部中断可能直接映射到特定的中断号。这意味着你不能照抄一份其他 RISC-V 芯片的 trap 代码就指望能用,得根据目标核的编程手册确认 mtvec 的设置方式和外部中断的路由。这些细节到了第五阶段调试时都会变成“坑”,提前了解能省很多时间。
3. 固件工程三件套:工具链、链接脚本、启动汇编
3.1 工具链选择与 Makefile 搭建
RISC-V 小核的固件是裸机程序,没有操作系统帮忙,所以工具链要选“裸机”风格的。我用的是 riscv64-unknown-elf 工具链配合 multilib,通过 -march 参数指定为 rv32imac、-mabi 指定为 ilp32。E907 是 RV32 内核,指令集通常包含整数乘法除法、原子操作和压缩指令,但不一定带浮点单元,所以 -march=rv32imac 是最稳的选择。
如果你不想折腾 multilib,也可以直接用 riscv32-unknown-elf 的专用工具链,或者用 Buildroot 编译一套 riscv32-nommu 的交叉工具链。工具链版本之间差异不大,关键是要保证 -march 和 -mabi 和目标核匹配,否则编译出来的指令集不对,芯片一跑就进 illegal instruction。
下面是我常用的 Makefile 骨架,麻雀虽小五脏俱全:
CROSS := riscv64-unknown-elf- CC := $(CROSS)gcc OBJCOPY := $(CROSS)objcopy OBJDUMP := $(CROSS)objdump CFLAGS := -march=rv32imac -mabi=ilp32 -nostdlib -fno-common -ffreestanding -O2 -Wall LDFLAGS := -Tlink.ld -nostdlib OBJS := startup.o main.o mailbox.o firmware.elf: $(OBJS) $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) firmware.bin: firmware.elf $(OBJCOPY) -O binary $< $@ firmware.lst: firmware.elf $(OBJDUMP) -D $< > $@ %.o: %.S $(CC) $(CFLAGS) -c -o $@ $< %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< clean: rm -f *.o *.elf *.bin *.lst编译完后,我会习惯性地用riscv64-unknown-elf-objdump -D firmware.elf看一遍反汇编,重点确认第一条指令是不是从预期地址开始、跳转目标是否正确、有没有意外生成浮点指令。这习惯帮我抓出过好几次 march/mabi 配置错误,算是个低成本高回报的检查手段。
3.2 link.ld:给一颗裸核画好“内存地图”
网上搜“riscv link.ld”能翻到一大堆示例,但十有八九拿过来不能直接用。原因很简单:RISC-V 裸机固件的链接脚本必须回答三个问题——代码放哪、数据放哪、栈放哪,而这三个问题的答案完全取决于目标芯片的内存布局。Allwinner 给 E907 分配的 TCM 基地址、大小、是否支持读写,都写在芯片手册的 memory map 章节里,不同芯片可能差很远。
我的链接脚本一般这样写:
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { TCM (rwx) : ORIGIN = 0x200000, LENGTH = 128K SHMEM (rw) : ORIGIN = 0x400000, LENGTH = 64K } SECTIONS { .text : { *(.text.init) *(.text*) } > TCM .rodata : { *(.rodata*) } > TCM .data : { _data_start = .; *(.data*) _data_end = .; } > TCM .bss (NOLOAD) : { _bss_start = .; *(.bss*) *(COMMON) _bss_end = .; } > TCM .stack (NOLOAD) : { . = ALIGN(16); . = . + 8K; _stack_top = .; } > TCM _end = .; }这里面的 ORIGIN 地址只是示意,实际要以你的芯片手册为准。几个容易翻车的细节说一下。一是_start必须最先被链接到 TCM 的基地址,因为副核释放复位后就是从那个固定地址开始取指的,所以我用*(.text.init)保证启动汇编排在最前面。二是 BSS 段要标记NOLOAD,并且要在启动代码里手动清零,不然全局变量的初始值全是垃圾。三是栈要单独划一段,并且要按 16 字节对齐,RISC-V 的 ABI 对栈对齐有要求,没对齐跑到函数调用就崩。
3.3 启动汇编:第一行指令之前的事
启动汇编是整个固件里最不能出错的部分,因为它不依赖任何运行时环境,全靠手动把该设的寄存器设好。我的 startup.S 大概长这样:
.section .text.init .globl _start _start: /* 关闭所有中断 */ csrw mie, zero csrw mstatus, zero /* 建立栈指针 */ la sp, _stack_top /* 设置异常向量表 */ la t0, trap_entry csrw mtvec, t0 /* 刷新指令缓存,防止加载器写入的固件是旧数据 */ fence.i /* 清零 BSS 段 */ la a0, _bss_start la a1, _bss_end 1: bgeu a0, a1, 2f sw zero, 0(a0) addi a0, a0, 4 j 1b 2: /* 关闭 U/I/S 模式相关的中断委托,这里只用 M 模式 */ csrw medeleg, zero csrw mideleg, zero /* 跳转到 main */ call main 1: j 1b几个要点解释一下。fence.i这行很多人会漏掉:固件是由 ARM 核搬运到 TCM 的,如果不做指令缓存同步,RISC-V 核可能取到旧的、缓存过的指令,表现出来就是寄存器值看起来对,但程序行为完全不可控。这个我在第五部分会再展开。
medeleg和mideleg清零也很关键。如果你不打算在固件里支持 U 模式,最好把异常和中断全部留在 M 模式处理,避免出现“中断来了但不知道去哪”的尴尬。当然如果你的方案需要跑类 RTOS 用户态,那就得另说了。
3.4 编译阶段最常见的三个错误
第一次搭这个工程的人,基本都会在我标出的三个位置踩坑。第一个是编译参数和内核不匹配,最常见的是用-march=rv64imac -mabi=lp64去编一个 RV32 核心的固件,编译期可能不报错,但一上板就 illegal instruction。第二个是漏了-nostdlib,导致链接器尝试链接主机端库函数,报一堆乱七八糟的 undefined reference,其实你只需要一个裸机程序。第三个是链接脚本里 TCM 地址写错,固件被链接到错误基地址,副核释放复位后从真正的 TCM 基地址取指,拿到的字节全是乱的。
还有一个值得单独说的:不要开-O0裸奔。固件跑在 TCM 里,容量本来就紧张,-O0会生成大量加载/保存指令,既浪费空间又拖慢执行。-O2在裸机环境里很稳,配合-fno-common避免公共变量段落到意想不到的位置。如果你担心优化引入 bug,可以先在 QEMU 的 riscv32 virt 机器上跑一遍逻辑测试,再放到真实芯片上。
4. 把固件灌进去:加载、释放复位与核间通信
4.1 时钟与复位控制:解锁副核的第一步
固件编好之后,接下来就是主核侧的工作。首先要找到这一颗芯片的 CCU(Clock Control Unit)和 RST(Reset)相关寄存器,把 RISC-V 核的时钟门打开、把它的复位状态释放掉。这一步说难不难,说简单也容易翻车——有些芯片的时钟使能和复位释放写在同一组寄存器里,而且有先后顺序要求。
我处理 V853/T113 这类芯片的经验是:先使能时钟,等待几个周期,再解除复位。如果反过来,可能触发异常状态,表现为副核看起来“没反应”。具体寄存器位号每颗芯片不同,但命名规律通常类似,手册里的RISCV_CLK_GATE和RISCV_RST_CTRL搜一下就找得到。如果你手头没有完整寄存器手册,另一个办法是去翻厂商闭源 SDK 里跑 RISC-V 固件的示例代码,比如 Tina SDK 里的rtos相关目录,里面的初始化顺序基本就是芯片设计方推荐的顺序。
4.2 搬运固件与内存一致性处理
复位释放后,副核第一时间会从 TCM 基地址取指,所以固件必须在此之前完整落到 TCM 里。这里有个常见问题:如果你的固件体积大于 TCM,或者你图省事想直接放到 DDR 里运行,那就得确保 DDR 已经被正确初始化,而且 ARM 侧的缓存不会把数据“藏住”。
我的建议是第一次 bring-up 用最朴素的方式:把固件放到 TCM。ARM 核通过普通内存映射访问 TCM,写完加一个DSB ISH内存屏障,确保写操作真正到达 TCM 而不是停留在写缓冲,然后再释放复位。如果固件必须放 DDR,那 ARM 侧在写完固件后要做 cache clean,RISC-V 侧启动时做 cache invalidate 或fence.i,两边配合起来才不容易出数据错乱。
另外提一个实用检查方法:ARM 侧写完固件后,读回 TCM 内容做 CRC 或逐字节比对,确认和编译出的.bin一致,再释放复位。这一步看起来笨,但能快速区分“固件没加载进去”和“固件加载了但跑不起来”两类问题,调试效率提高一大截。
4.3 共享内存协议设计:从“我活着”到“干活”
副核跑起来之后,主核怎么知道它活了?最简单的办法是在约定的共享内存地址放一个魔法数。我在项目里常用的结构是这样:
/* 共享内存区,放在 ARM 和 RISC-V 都能访问的 SRAM */ #define SHM_BASE 0x400000 #define SHM_MAGIC 0xB0B0C0DE #define SHM_MAGIC_ACK 0xDEADC0DE struct amp_shm { volatile uint32_t magic; volatile uint32_t cmd; volatile uint32_t status; volatile uint32_t counter; volatile char logbuf[512]; };主核先把共享内存区域清零,写入期望的魔法数,然后释放复位。副核启动后第一件事就是检查魔法数,如果匹配,就把自己的魔法数改成 ACK 并递增 counter。主核轮询 counter,发现它变了,就说明副核已经跑起来并且能看到共享内存。这个“魔法数握手”是核间通信的最基本形态,几乎所有后续功能都建立在这套机制上。
在此基础上,可以逐步扩展命令协议:主核写 cmd 字段,然后给副核发一个软件中断;副核收到中断就读取 cmd、执行、写回 status。比这更复杂的 OpenAMP 远程处理器框架也是同样的思路,只不过把共享内存换成了 vring,把命令换成了 virtio 消息。
4.4 主核侧与副核侧的代码骨架
主核侧的核心逻辑大概是这样:
static void amp_load_firmware(void) { extern uint8_t _binary_firmware_bin_start[]; extern uint8_t _binary_firmware_bin_end[]; /* 1. 将固件复制到 TCM */ memcpy((void *)TCM_BASE, _binary_firmware_bin_start, _binary_firmware_bin_end - _binary_firmware_bin_start); /* 2. 初始化共享内存 */ volatile struct amp_shm *shm = (void *)SHM_BASE; memset((void *)shm, 0, sizeof(*shm)); shm->magic = SHM_MAGIC; /* 3. 内存屏障,确保写操作对副核可见 */ dsb(ISH); /* 4. 释放 RISC-V 核复位 */ writel(RISCV_RST_RELEASE, RST_BASE + RISCV_RST_CTRL); /* 5. 等待副核握手 */ while (shm->magic != SHM_MAGIC_ACK) { udelay(10); printf("waiting for riscv core...\n"); } printf("riscv core alive, counter=%lu\n", shm->counter); }副核侧 main 函数对应的逻辑就是先读共享内存、写 ACK、然后进入主循环:
int main(void) { volatile struct amp_shm *shm = (void *)SHM_BASE; if (shm->magic != SHM_MAGIC) { /* 魔法数不对,说明共享内存位置或初始化时序有问题 */ while (1); } shm->magic = SHM_MAGIC_ACK; shm->counter++; while (1) { if (shm->cmd != 0) { switch (shm->cmd) { case CMD_LED_ON: gpio_set(1); break; case CMD_LED_OFF: gpio_set(0); break; } shm->status = shm->cmd; shm->cmd = 0; } wfi(); } }注意副核侧所有共享内存字段都加了volatile,这是必须的。编译器可能把多次读取优化成一次,导致副核永远看不到主核新写入的命令。如果你以后引入 RTOS,还要考虑多个任务同时访问共享内存的互斥,但第一步裸机轮询模式可以先把协议调通再想这些。
5. 调试实录:两次“核消失”和一张问题速查表
5.1 实战一:寄存器写了,副核就是不跑
第一次在 V853 上尝试释放 E907 复位,我遇到过一个让人很抓狂的现象:固件确定已经写进 TCM,复位寄存器也写了,但副核一点反应都没有——共享内存里魔法数纹丝不动。我先怀疑是固件有问题,反汇编看了三遍启动代码,没问题;又怀疑是 TCM 基地址写错了,对照手册确认也没错。
后来查了一圈,发现问题出在时钟使能上。这颗 SoC 的 RISC-V 核有两个独立控制点:一个控制总线时钟,一个控制核心时钟,而我只打开了其中一个。核心时钟没开的时候,寄存器操作看起来“成功”了,实际上内核根本没有时钟,自然不会跑。把两个时钟位都置上,再配合复位释放,副核立刻活了。
这个经历给两个教训:第一,释放复位前,务必确认时钟树所有相关节点都已使能,不要只看名字带 RST 的寄存器;第二,在没有仿真器和 trace 的情况下,可以用共享内存魔法数作为副核“是否跑到 main 之前”的探针,如果再配合 GPIO 翻转,能把调试粒度切得更细。就这样从“完全无反应”逐渐缩小范围,比瞎猜快得多。
5.2 实战二:跑起来了,但数据是花的
第二次是在 T113-S3 上,副核倒是跑起来了,魔法数也能收到,但和主核通信传数据时,偶尔出现随机损坏。一开始我怀疑是传输协议的问题,加了好几层校验,还是有概率出错。后来把共享内存从 DDR 挪到片内 SRAM,问题直接消失了。
原因就是缓存一致性。我之前图省事把共享内存放到了 DDR,ARM 侧写数据后虽然加了 DSB,但 CPU 缓存还有一份 stale 的数据,副核读取时拿到的是 DDR 里的旧值或脏数据。片内 SRAM 在 Allwinner 这套方案里通常是不带缓存的,ARM 和 RISC-V 两侧访问的都是同一个物理存储,天然一致,省掉一堆 cache maintenance 的麻烦。
这个经历让我养成了两个习惯。一是异构核间通信的共享内存,优先选片内 SRAM,容量不够再考虑 DDR,但一定要把 cache 操作写对。二是共享内存字段全部使用 32 位自然对齐,避免跨越总线宽度边界,这样可以减少一个总线上的字节序/原子性风险点。
5.3 常见问题速查表
把这一路遇到的典型问题整理成表格,方便你调试时快速定位方向:
| 现象 | 优先排查方向 | 常见原因 | 解决建议 |
|---|---|---|---|
| 副核完全无反应 | 时钟、复位、固件 | 核心时钟未使能;固件未落到正确地址 | 先读回固件校验;再查时钟复位寄存器 |
| 跑飞、PC 跳到异常地址 | 栈、启动汇编 | 栈指针没设置;BSS 未清零 | 单步反汇编启动代码;确认 _stack_top |
| Illegal instruction | 工具链参数 | -march/-mabi 与内核不匹配 | 统一 rv32imac/ilp32;查反汇编有无浮点指令 |
| 共享数据随机损坏 | 缓存一致性、共享内存位置 | 放 DDR 未做 cache 操作 | 挪到片内 SRAM,或补 cache clean/invalidate |
| 中断进不来 | 中断使能、路由 | mstatus.MIE 没开;外部中断号映射错 | 先轮询再中断;仔细读中断控制器手册 |
| 看门狗复位 | 喂狗机制 | 调试时主核没喂狗导致全局复位 | 定位阶段先关闭或延长看门狗超时 |
| 取指拿到旧数据 | 指令缓存 | 固件搬运后未做 fence.i | 启动开头加 fence.i;搬运后从 ARM 侧也做同步 |
这张表不是全能的,但它覆盖了异构 bring-up 里频率最高的几类问题。遇到新问题的时候,我的习惯是先判断“哪个环节最可疑”:复位之前是加载问题,复位之后是运行问题,通信之后是一致性问题。按这个顺序排查,效率最高。
5.4 调试自检清单
最后给一份可以在每次实验前过一遍的自检清单。第一,固件 .bin 是不是最新的,编完有没有重新复制到烧写工具或代码里,这个问题听起来弱智,但真的反复发生。第二,TCM 基地址和链接脚本的 ORIGIN 是否一致,不一致必然起不来。第三,共享内存区域的地址在 ARM 侧和 RISC-V 侧看到的是不是同一个物理地址,有些芯片的总线矩阵会做地址重映射。第四,复位释放之前,要不要先确认副核的中断控制器处于干净状态,历史 pending 的中断有时会让副核一启动就进 trap。第五,串口日志和 GPIO 指示能省出大量瞎猜时间,哪怕是点个灯都行。
6. 从“能跑”到“能干”:后续集成方向与我的体会
6.1 用 Linux 的 remoteproc 框架把它接起来
如果只是验证固件逻辑,主核侧用 U-Boot 里的裸机代码手动加载就够了。但产品化阶段,你不可能让 Linux 主核在用户态直接用寄存器去释放一个协处理器,这时候就要请出 Linux 的 remoteproc 框架。它的工作就是把“加载固件、释放复位、维护生命周期、处理异常”这些事情抽象成标准接口,让应用侧只需要打开一个设备节点就能和副核通信。
具体到 Allwinner 方案,你可以在 Linux 侧写一个简单的 remoteproc 驱动,把前面讲的时钟/复位/共享内存操作封装进去。固件可以放在文件系统里,也可以编译进内核的 firmware 目录,由驱动的rproc->ops->start()回调负责加载和释放。再配合 rpmsg/virtio,主核和副核之间就能使用标准消息通道,而不再是一堆裸指针和魔法数。
这一步的工程量不小,但收益也明显:副核变成 Linux 下一个管理良好的设备,掉线可以恢复,固件可以热更新,业务侧完全不用关心底层细节。我个人建议在主核侧先把裸机通信协议调通之后,再动手接 remoteproc,不要一步到位,因为 remoteproc 的报错信息往往会掩盖底层时序问题。
6.2 适合放给小核干的活
副核真正发挥价值,是在它接管了主核不擅长的任务之后。我实际试过这么几类:一类是低功耗待机管理,主核进入 suspend 后,副核轮询按键和传感器,有事件才唤醒主核,这一套能显著降低待机功耗。另一类是实时性要求高的 IO 处理,比如特定协议的波形采集,副核用 GPIO 中断加 TIMER,抖动比 Linux 侧小得多。还有一类是系统健康监控,副核周期性读取电源电压、温度,在 Linux 无响应时执行恢复动作,相当于一个低成本硬件看门狗加监控器。
音频相关任务也是常见选择,部分 Allwinner 方案本身就带音频 DSP 的 RISC-V 核,把音频编解码的一部分搬到小核,能释放主核算力。但这类任务需要音频框架层面的配合,纯裸机开发的工作量比前几类大不少。如果你刚把副核跑通,建议先做低功耗唤醒或健康监控这类边界清晰的任务,积累信心也积累代码。
6.3 我的一些个人体会
从 Part 1 到 Part 2,最深的感受是异构 bring-up 的本质不是“写代码”,而是“建立确定性的可观测过程”。代码本身没多少行,难的是每一步都能确认前一环已经正确完成:固件确实在 TCM 里、时钟确实在翻转、复位确实释放了、共享内存确实两边看到的是同一个地址。当你把这一连串“确实”建立起来,副核自然就跑起来了。
最后分享一个小技巧:在处理异构芯片时,预留一个 GPIO 作为副核的心跳输出,副核每次主循环翻转一次。这个 GPIO 用逻辑分析仪一看,就能直观判断副核是否在跑、主循环周期是多少,比任何日志都直观。我第一次调通握手协议时,盯着逻辑分析仪上的方波看了好久——那一刻才真正觉得,这颗 RISC-V 小核“活”了。希望这篇记录也能帮你早点看到属于你的方波。