拆开全志Allwinner T527的数据手册,大多数人第一眼盯上的都是那几颗Cortex-A55和Cortex-A75,我反而对藏在SoC下层的玄铁RISC-V核心更感兴趣。这颗核心在公开宣传里通常只有一句“安全协处理器”带过,但真正把它当独立主控用起来的人非常少。这篇是“Unlocking the XuanTie RISC-V Core on Allwinner T527”系列的第一篇,目标很直接:在不破坏原有安全启动功能的前提下,把这颗核心“借”出来,让它跑我们自己编译的裸机程序,并且能和主核交换数据。适合手里有T527开发板、想做异构多核实验,或者对RISC-V裸机开发感兴趣的嵌入式Linux玩家。整个探索过程我会分成三条主线来讲:核心到底挂在哪个地址空间、怎么给它解锁时钟和复位、第一个固件怎么构建以及如何跟A核通信。
1. 先看清 T527 里这颗 RISC-V 核心到底是什么来头
1.1 玄铁 C906 的关键参数
从公开手册和固件特征来判断,T527 里的这颗核心属于平头哥玄铁 C906 这一档,扩展组合是 RV64IMAFDC,也就是整数、乘除、原子、单精度浮点和双精度浮点都带,还可能有可选的向量扩展。对裸机开发来说,最需要记住的是三点。
第一,它默认是小端模式启动。玄铁核本身可以配置成大端或小端,但全志的集成方式是小端,所以链接脚本和编译参数里都不需要额外处理字节序。第二,C906 的异常模型和标准 RISC-V 保持一致,mstatus、mtvec、mepc、mcause 这些 CSR 都在,早期的 trap 处理可以完全按照标准规范来写。第三,它虽然带 MMU,但裸机阶段我建议直接把satp清零,跑在物理地址模式。这样省掉页表,调试时看到的地址就是真实总线地址,不用来回换算。
这三个参数直接决定了你的工具链选项和启动汇编写法。网上很多 RISC-V 教程默认你用 qemu 或者开发板,但内置在 SoC 里的核心有一个很大的不同:没有现成的固件加载器帮你初始化环境,复位之后它直接取指执行,栈指针、BSS 清零、向量表都要你自己来。
1.2 它在 SoC 内部的位置和角色
从系统框图来看,这颗核心挂在 R_BUS 域里。全志的惯用做法是:A 核跑 Linux,RISC-V 核做安全岛或者实时控制岛。也就是说它并不参与 Linux 的日常计算任务,而是被厂商固件当作一个独立的主控来用,可能负责安全启动校验、密钥管理、待机唤醒这类工作。
我们解锁它,不是要让 Linux 直接“调度”它,而是要在另一个核的世界里给它喂固件,让它和 A 核并行工作。它有自己的缓存和私有 SRAM,也有和片外 DDR 通信的路径,所以严格来说是一个完整的处理器,不是某个外设。理解这一点很重要:后续所有开发都可以沿用“独立单片机”的思维,而不是“Linux 驱动外设”的思维。
1.3 为什么 Linux 侧默认看不见它
很多人第一个反应是去查/proc/cpuinfo,但你当然看不到它。原因是 C906 不在 ARMv8 的 CPU 拓扑里,它更像是挂在系统总线上的一台从设备。正常的访问路径是:主核准备好固件,放到约定地址,然后通过系统控制寄存器给核心上电、开时钟、去复位。
因此“解锁”这个词的本质,就是我们要自己接管这几步操作。厂商 BootROM 可能会在启动早期把它管理起来,也可能直接放着不用,不同批次和不同开发板的表现还不一样。我在 T527 上观察到的默认状态是:核心的时钟被关闭,复位线保持拉低,对应 SRAM 区域在总线层面对非安全世界的访问也有限制,需要先做权限切换。这三点串起来就是解锁的全部内容。
2. 三条线索定位核心:手册、设备树与固件
2.1 从芯片手册锁定 SRAM 与系统控制寄存器
第一步永远是翻手册。重点看“Memory Map”和“System Control”两章。
我的找法非常机械,先定位关键词C906、RISC-V或Security Core,找到核心对应的 SRAM 区域。全志这颗核心通常有一个私有 SRAM,几十 KB 到一两百 KB,这就是裸机固件的安身之处。接着找C906 soft reset和C906 clock gate相关的寄存器,一般在 CCU 模块里,带RISC_V或RV前缀。
如果你手里的手册版本比较老,里面可能直接写的是E906之类的代号。别被名字骗了,本质上是同一类东西。真正确认的方法是看寄存器描述里有没有提到“RISC-V core”或“C906”字样。把下面这几项记到一份表格里,后续所有代码都围绕它们展开:
| 项目 | 手册章节建议 | 备注 |
|---|---|---|
| SRAM 基址和大小 | Memory Map | 决定固件的链接地址 |
| 核心时钟门控寄存器 | CCU | 决定核心能不能取指 |
| 核心复位控制寄存器 | CCU | 决定核心从哪开始跑 |
| SRAM 访问权限控制 | SMPU/SRAM Controller | 决定主核能不能读写 |
| 可能的邮箱中断号 | Device Tree / GIC | 后续通信用 |
2.2 设备树里可能留下的痕迹
如果你用的是厂商的 Linux BSP,不要急着自己发明寄存器地址,先翻 DTS。不少全志 T5 系列的内核里都会有类似下面这样的节点,虽然节点名字可能是c906、rv_core或security-core,但功能上都是把核心的寄存器基址和中断号暴露给驱动。
下面这段是形状示例,T527 上的真实地址要以你自己拿到的 DTS 和手册为准:
rv_core: rv-core@2c000000 { compatible = "allwinner,sunxi-rv-core"; reg = <0x0 0x2c000000 0x0 0x40000>, <0x0 0x07000400 0x0 0x100>; resets = <&ccu RST_RISCV_CORE>; clocks = <&ccu CLK_RISCV_CORE>; };看到resets和clocks两个属性,基本可以确定这颗核心由 CCU 管理。只要把这两个属性对应的寄存器摸清楚,解锁就完成了大半。另外留意interrupts属性,后面做邮箱中断时要用到。
2.3 从厂商固件里提取入口向量
手册和 DTS 只能告诉我们“核心在哪里”,但厂商固件能告诉我们“核心怎么启动”。这一步经常被跳过,但它其实是最有价值的一条线索。
我习惯的做法是把整个升级固件拉下来,用binwalk拆开,找到里面比较大的独立镜像块。很多全志固件里都会有一段 RISC-V 可执行程序,用file命令可以直接得到类似ELF 64-bit LSB executable, UCB RISC-V的输出。
拿到 ELF 之后不要急着反汇编整个程序,先做两件事:
- 用
riscv64-unknown-elf-readelf -h查入口地址,确认厂商固件是在哪个基址上运行的; - 用
riscv64-unknown-elf-readelf -S看.text段的对齐方式,推测核心对向量表对齐的要求。
这一步非常有用。我最初调试 T527 时,就是因为没看厂商固件,盲目把代码链到自认为对的 SRAM 地址,核心复位后一点反应都没有。看了固件入口地址之后才明白,这颗核心的启动地址其实就是它私有 SRAM 区域的起始地址。早期版本厂商固件甚至可以从字符串或者符号表里看到内部模块名,对我们理解它的软件架构很有帮助。
3. 解锁实操:电源、时钟、复位和访问权限
3.1 先把供电和时钟打开
硬件上如果核心默认没有供电,后面的复位操作全是白搭。在很多全志设计里,RISC-V 核心和一部分 SRAM 共用电源域,因此需要先确保对应的 PMIC 或 SoC 内部 LDO 处于输出状态。
如果全程使用内核驱动,最稳妥的方式是通过设备模型的power-domains属性间接管理,简单粗暴一点也可以在模块初始化里用iomap操作 PMIC 寄存器。这里有一个必须遵守的时序:电源先于时钟,时钟先于复位。顺序反了可能出现总线错误,而且这个错误往往只发生在第一次操作核心的时候,很难排查。
我踩过一次很深的坑:电源域没确认就急着去写 CCU 时钟寄存器,结果readl直接返回 0xFFFFFFFF,整个内核模块 insmod 的时候卡死。后来加了dev_pm_domain_attach()才解决。建议你们在解锁前先确认电源域状态,这个步骤看着不起眼,却能省掉一晚上的崩溃调试。
3.2 CCU 寄存器的操作要点
CCU 里通常有一组 32 位寄存器,每一位控制一颗核心的时钟门或复位线。读取时用readl,写入时用writel,这个过程放在内核模块里不能再简单:
static void __iomem *ccu_base; static void rv_core_enable(void) { u32 val; val = readl(ccu_base + CLK_RISCV_CORE); val |= BIT(0); writel(val, ccu_base + CLK_RISCV_CORE); val = readl(ccu_base + RST_RISCV_CORE); val &= ~BIT(0); writel(val, ccu_base + RST_RISCV_CORE); }注意复位寄存器通常是高有效表示复位,去复位就是清位,但不同芯片也有反逻辑的情况,务必以手册为准。我实际操作中发现,有些 BSP 已经把时钟和复位在设备树里暴露成了 reset controller,那么更省事的做法是直接调用reset_control_deassert()接口。不过手动操作的好处是能一步步观察效果,排查问题时更清楚。
3.3 SRAM 访问权限:最容易卡住的一环
这一步是解锁过程中最容易卡住的地方。就算时钟和复位都操作对了,一旦 SRAM 区域在总线层面对主核访问受限,memcpy_toio就写不进去;就算强行写,读回来也全是 0xFF 或者直接触发总线错误。
全志很多 SoC 都有内存保护单元,可以把某一整块 SRAM 的访问权限配给特定 CPU。解锁前需要把对应 SRAM 区域的主核读写权限打开。操作路径一般是找到 SMPU 或 SRAM Controller 里的“slave port access control”寄存器,把影响目标 SRAM 的位设置为允许 A 核访问。
这个寄存器位置每个 SoC 差异很大,但判断方法是一致的:
- 读 SRAM 某个地址,如果总是全 0xFF 或全 0x00,先怀疑供电和时钟;
- 如果供电时钟正常但读写失败,再怀疑 SMPU 权限;
- 如果 SMPU 权限已开但还是失败,最后检查是不是被安全世界隔离了。
T527 上我最终是修改了MEM_ACCESS_CTL相关位才让ioremap之后的地址可读可写。这一步没有捷径,只能对着手册的安全控制章节逐位核对。
3.4 加载固件并释放复位
当电源、时钟、权限都准备好,剩下就是两件事:把固件二进制拷进 SRAM,再去复位。
void *rv_sram = ioremap(RV_SRAM_BASE, RV_SRAM_SIZE); memcpy_toio(rv_sram, rv_firmware_bin, sizeof(rv_firmware_bin)); __iowmb(); // 释放复位 writel(0, ccu_base + RST_RISCV_CORE);__iowmb()是很多人会漏掉的关键屏障。拷贝固件后如果不做内存屏障,复位释放后核心可能读到一半的数据,直接在第一条指令上跑飞。核心启动后,最简单的验证方法是让固件在共享 SRAM 的固定位置写一个 magic 值,主核这边循环读。等不到 magic 就按前面的顺序倒查:权限、时钟、复位、链接地址。
4. 第一个裸机程序:工具链与 link.ld 细节
4.1 裸机工具链为什么选 riscv64-unknown-elf
如果你用厂商 SDK,里面可能自带编译脚本。但自己写裸机程序时,我更推荐riscv64-unknown-elf-前缀的工具链,而不是riscv64-linux-gnu-。原因很实在:前者是裸机导向的,链接时不会引入 Linux 的启动文件、动态库和平台头文件,编译参数更干净。
下载安装之后,先确认工具链目标架构:
riscv64-unknown-elf-gcc -v # Target: riscv64-unknown-elf riscv64-unknown-elf-gcc -march=rv64imafdc -mabi=lp64d -O2 -nostdlib \ -ffreestanding -c main.c -o main.o这里-march=rv64imafdc表示启用整数、乘除、原子、单精度浮点和双精度浮点。C906 在 T527 上是完整实现了 F/D 扩展的,可以放心用。如果你看到cc1: error: ABI conflict之类的报错,多半是-march里的扩展和-mabi不匹配,比如用了rv64i却指定lp64d,改成对应组合就行。
4.2 link.ld 里最容易翻车的三个点
网上搜risc-v link.ld能找到一大堆模板,但给 SoC 内置核写链接脚本有三个点必须自己确认。
第一个是入口符号,一定要写ENTRY(_start)。C906 复位后直接跳到自己 SRAM 的起始地址,不会帮你初始化栈,不会帮你清 bss。如果入口符号不明确,链接器可能把第一条指令放到奇怪的位置。第二个是 VMA 和 LMA 的关系。对这种“把代码直接放到 SRAM 运行”的场景,VMA 等于 LMA,把段地址指向 SRAM 基址即可。下面是一份我实测能用的模板:
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { sram (rwx) : ORIGIN = 0x20000000, LENGTH = 128K } SECTIONS { . = ORIGIN(sram); .text : { KEEP(*(.text.entry)) *(.text*) } > sram .rodata : { *(.rodata*) } > sram .data : { *(.data*) } > sram .bss (NOLOAD) : { __bss_start = .; *(.bss*); __bss_end = .; } > sram . = ALIGN(0x1000); __vector_base = .; . += 0x1000; __stack_top = .; }第三个点是栈顶和向量表对齐。玄铁 C906 的异常向量表在使能向量模式时需要按入口间隔排列,为了省事我直接把mtvec基址对齐到 4KB,向量区间预留 4KB。栈顶放在向量区后面,确保栈不会往代码段反向生长。
上面ORIGIN = 0x20000000只是示例,T527 上真实 SRAM 基址请以手册为准,不要照抄。
4.3 启动汇编与最小 trap 框架
裸机_start只需要做四件事:确认 hartid、设栈指针、清 bss、调 main。再补一个 trap stub,防止中断异常直接跑飞。
.section .text.entry .globl _start _start: csrr t0, mhartid bnez t0, 1f # 多核环境只让 hart0 继续 la sp, __stack_top # 清 bss la t0, __bss_start la t1, __bss_end 2: bgeu t0, t1, 3f sd zero, 0(t0) addi t0, t0, 8 j 2b 3: call main 1: wfi j 1btrap stub 可以很轻量,但至少要保存现场再跳转分发的 C 函数:
.section .text .globl trap_entry trap_entry: addi sp, sp, -16*8 sd x1, 0(sp) sd x2, 8(sp) sd x3, 16(sp) # 保存必要寄存器后,在这里读取 mcause 做分发, # 或者直接把 mcause 和 mepc 传给 C 函数 csrr a0, mcause csrr a1, mepc call handle_trap ld x1, 0(sp) ld x2, 8(sp) ld x3, 16(sp) addi sp, sp, 16*8 mret想省事的话,第一版可以先在main里只做一件事:往共享内存写递增计数。先把“核心能跑起来”验证了,再引入中断。我甚至建议第一版不要开任何中断,把全部注意力放在链路搭建上。
4.4 把二进制塞进去:从 objcopy 到内核模块
编译完成后,把 ELF 转成纯二进制:
riscv64-unknown-elf-objcopy -O binary rv_core.elf rv_core.binrv_core.bin的大小不要超过 SRAM 容量,也不要覆盖共享内存约定区域。然后把数组声明进内核模块,或者干脆用request_firmware从文件系统加载。推荐request_firmware,因为调试周期短。每改一次固件,只要把文件拷到/lib/firmware/再重新加载模块即可,不用重新编译模块本体。
经常有人问为什么核心跑不起来,最后发现是强制用request_firmware时固件路径拼错了。这里建议在加载前后都打印一下firmware->size,确认固件确实被读到,再继续往下查。
5. 给主核发消息:共享内存和同步细节
5.1 为什么第一版先用共享内存
异构核之间通信有很多现成框架,比如 RPMsg、OpenAMP。但在验证“核心能不能跑”的第一版里,我强烈建议只用一个简单的共享内存结构体:
struct rv_msg { uint32_t magic; // 0x52564300 uint32_t cmd; uint32_t payload[8]; uint32_t ack; };结构体放在 SRAM 的固定偏移,比如固件代码段结束之后。A 核写cmd,RISC-V 核读到后处理,再把结果写到payload并置位ack。A 核这边拿轮询读ack。没有任何框架,却最容易排查问题。
为什么不做 RPMsg?因为 RPMsg 功能完善,但一旦出问题,你分不清是核心没起来、mailbox 没配、还是共享内存布局错了。先把裸协议跑通,再迁移到成熟框架,总成本反而最低。
5.2 内存序和编译优化是最大的坑
共享内存在两个核之间,编译器会做优化,CPU 也会做乱序。主核写完cmd之后,必须保证写操作真的落到内存,RISC-V 核才能看到。主核侧最简单的做法是加wmb(),RISC-V 侧用fence rw, rw。C 代码里直接写内联汇编:
static inline void rv_fence(void) { __asm__ __volatile__("fence rw, rw" ::: "memory"); }读取方也要配合volatile,防止编译器把读缓存到寄存器:
while (volatile_read_ack(&msg->ack) == 0) ;我第一次实测时,主核这边数据已经写了,RISC-V 核的循环里读到的永远是旧值。加上fence和volatile之后立刻正常。这是这颗核心最容易迷惑新手的点:不是时序太慢,而是内存序没处理好。
5.3 从轮询升级到邮箱中断
轮询验证通过后,再考虑真正的邮箱。全志在很多 A 系列 SoC 里提供软件中断或 mailbox 寄存器,RISC-V 核写一个特定寄存器,主核那边就会收到一个 SPI 中断,中断号可以在设备树里查到。
邮箱驱动的关键是把地址配成“主核能写、RISC-V 核能读”的共享区域,否则会出现“RISC-V 核发了中断但主核没感知”的现象。此时优先查两件事:中断号在 GIC 里是否被 mask,以及邮箱寄存器所在的电源域有没有被关掉。
6. 复盘踩过的坑:哪些做法值得固定下来
6.1 把解锁步骤脚本化,别凭记忆敲寄存器
调试过程中我经历了反复加载、反复开电源、反复去复位。每一次都手动敲devmem太痛苦。建议整理成两个文件:一个内核模块,负责把解锁步骤封装成 init 函数;一个加载脚本,负责把固件放到/lib/firmware/并加载模块,把共享内存的关键地址打成日志。
最终我手里的脚本大概五六十行,没有任何优雅设计,但每次断电重启都能在十秒内回到“核心起来、共享内存看到 magic”的状态。嵌入式调试里,“可重复”比“简洁”重要得多。这一步做完再回头写驱动就从容多了。
6.2 这些经验可以平移到哪里
全志采用玄铁 RISC-V 核心的 SoC 不止 T527,T507、V853、F133 等都有类似布局。本文的方法论并不依赖某一颗芯片的精确寄存器,而是依赖一套排查顺序:先手册找 SRAM 和 CCU,再 DTS 找现有驱动,再固件提取入口向量,最后按“电源、时钟、权限、复位”的顺序解锁。这套顺序在类似架构的 SoC 上基本是通用的。
我在这个过程中最大的体会是,解锁并不是最难的部分,难的是在目标核上跑逻辑时,同时兼顾两个核的公序内存模型。每次写共享数据都要停下来想一遍另一个核看到的是什么,这种思维切换才是异构开发的真正门槛。
下一部分我会重点讲怎么把 FreeRTOS 移植到这颗核心上,以及怎么用向量计算单元跑一些轻量级信号处理任务。如果你手头正好有 T527 开发板,建议先把手动点亮核心的流程走通,再来一起折腾操作系统级的东西。