T527 这颗芯片在嵌入式圈子里讨论度不低,但绝大多数人盯着的都是那四个 Cortex-A55 大核,很少有人注意到它内部还藏着一颗玄铁(XuanTie)RISC-V 协处理核。我之前在评估 T527 做边缘计算方案时,也是先把 Linux 跑起来再说,直到某天在一个国产化项目的需求文档里看到一行“需具备 RISC-V 异构计算能力”,才重新把目光放回到这颗被冷落的核上。这颗核不是摆设,它是平头哥的 C906,RV64 架构,带 MMU,能干不少正经活。这篇是系列的第一篇,重点是把这颗 RISC-V 核“唤醒”的全过程:确认它在系统里的位置、理解启动方式、搭好交叉编译环境、跑通第一个裸机程序,并且把它从“能跑”推进到“能通信”。适合手里有 T527 开发板、想折腾异构核的嵌入式开发者和体系结构爱好者。
1. 为什么要在 T527 上折腾这颗 RISC-V 核
1.1 异构计算不是噱头,是真能分担任务的
做嵌入式这么久,我对“异构计算”这个词一直比较谨慎,因为很多芯片宣传页上的异构,实际用起来要么文档稀碎,要么工具链难产,最后沦为跑分软件里的一行字。但 T527 的这颗 C906 不同,它是一颗完整的 64 位 RISC-V 核心,不是那种只能做安全隔离的小控制器,而是可以独立运行程序、访问 DDR、甚至能跑轻量级 RTOS 的通用处理器核。
在 T527 上,A55 主核跑 Linux 处理复杂业务逻辑、网络协议栈、AI 推理调度,C906 从核可以承担三类典型任务:
- 实时控制类:电机控制、传感器采集、PWM 输出这类对中断延迟敏感的工作,放在自带缓存的 RISC-V 核上,响应时间比在 Linux 里跟调度器抢时间靠谱得多。
- 低功耗常驻任务:系统待机时 A55 可以进低功耗状态,让 C906 保持一个轻量循环去监听唤醒事件,功耗能降一个量级,这是手机平台常驻协处理核的成熟思路。
- 安全与隔离场景:两颗核跑在独立的地址空间里,可以通过硬件隔离做关键数据的独立处理,甚至把一部分安全逻辑物理上隔离在 A55 的 Linux 之外。
这些场景不是画饼,C906 的运算能力和外设访问权限完全可以支撑。真正的前提是:你得先能把它跑起来。
1.2 主从核的协作模式:先理解 AMP 而非 SMP
很多人一提多核就想到 SMP——多个核跑同一个操作系统,共享内存,靠原子操作和自旋锁同步。但 T527 的 A55 和 C906 走的是另一条路:AMP(非对称多处理)。在这种模式下,两颗核各自运行独立的程序(比如 A55 跑 Linux,C906 跑裸机或 RTOS),通过共享内存和中断进行协作,彼此之间没有调度器层面的耦合。
这里有个非常关键的技术点:T527 系统上电后,默认只有 A55 作为主核启动并引导 Linux,而 C906 处于复位状态,需要主核侧主动“释放”它。从 A55 Linux 的角度看,C906 更像一个“可加载固件的外设”,你可以通过两种方式让它复位退出并跳转到指定地址执行:
- U-Boot 阶段加载:在 U-Boot 命令行下直接把固件拷贝到 DDR 的某个地址,然后通过特定命令释放 C906 复位。这种方式适合调试阶段,改完代码重新下载就行,不碰内核。
- Linux 运行时加载:通过内核的 remoteproc 框架,把固件作为资源加载并启动 C906。这是产品化路径,适合正式部署。
Part 1 我先采取第一种方式,把流程跑通,理解启动链路之后再往 remoteproc 方向走。
2. 先摸清 C906 的“脾气”:架构、内存与工具链
2.1 C906 是什么水平的核
C906 属于平头哥玄铁系列,是一条 9 级流水线、单发射、顺序执行的 64 位 RISC-V 内核,指令集基线是 RV64IMAFDC,也就是整数、乘除、原子操作、单双精度浮点都有,还支持压缩指令。对于裸机开发来说,这套指令集已经很够用了,不需要额外扩展。
它的 Level 1 Cache 是可配置的,在 T527 上的具体配置以芯片手册为准,一般来说 32KB 左右的 I-Cache 和 D-Cache 起步。比较关键的是它自带 MMU,所以理论上你可以给它跑一个完整的 Linux,但我们做裸机开发时会把 MMU 关掉,直接用物理地址访问外设,省去页表配置的复杂度,开发效率最高。
技术规格上要特别注意一点:C906 支持 RISC-V 标准的中断控制器 PLIC(Platform-Level Interrupt Controller),以及核内可编程中断控制器 CLINT。这意味着写中断处理时不涉及黑科技,全是标准规范里的套路。
2.2 内存视图:它眼里看到的世界是什么样的
在裸机开发里,最大的坑往往不是代码本身,而是地址映射。C906 作为从核,它访问的物理地址空间和 A55 是共享的,也就是说 A55 能用 DDR,C906 也能访问。这在方便数据交换的同时,也带来一个责任:你不能随便写内存,写坏了 A55 那边的数据就是事故。
从系统地址布局看,典型 T527 方案中 DDR 的高端地址或特定区域会被保留给 RISC-V 核使用,具体基地址需要查阅《Allwinner T527 用户手册》的“内存映射”章节。这是动手前的必修课,我见过不少人直接默认从 0x40000000 开始写裸机代码,结果 A55 侧数据被踩得面目全非,排查了一晚上才发现是地址撞车。
稳妥的做法是:在 DDR 里划一块区域专门留给 C906,比如 A55 的 Linux 内核通过 device tree 里的reserved-memory节点把这部分内存预留出来,让 Linux 永远不会分配给普通进程。裸机固件放在这块预留内存里,加载地址、链接地址、运行地址全部对齐到同一个值。
2.3 工具链选型:别用错交叉编译器
为 C906 编译裸机代码,首选是平头哥官方提供的Xuantie-900-gcc工具链,它的目标三元组通常是riscv64-unknown-elf-gcc。如果搞不到官方工具链,用通用的riscv64-unknown-elf-gcc也可以,因为 C906 的指令集是标准的,不需要特殊的 vendor 扩展。
注意不要用riscv64-linux-gnu-gcc编译裸机程序,这套工具链是给跑 Linux 的 user space 用的,编译时会隐式链接 Linux 的 C 库,生成的可执行文件依赖操作系统的系统调用接口,没有操作系统就跑不了。裸机必须用-elf后缀的“裸金属工具链”,编译产物直接是固件镜像,不依赖任何内核。
我实际测试下来,平头哥 GCC 10.x 系列在编译 C906 代码时优化效果最好,建议用-O2配合-march=rv64imafdc -mabi=lp64d这两个参数。
3. 动手前必须吃透的链接脚本:RISC-V 裸机的基石
3.1 链接地址错一位,整块板子陪你熬夜
热词里反复出现 “risc-v link.ld”,这是有道理的。RISC-V 裸机开发中,链接脚本是最重要也最容易出错的文件。很多人第一回跑裸机程序,代码编译零错误,一上板就“死机”,串口没反应,JTAG 也连不上,十有八九就是链接地址和实际加载地址不一致。
我们得先明确三个地址的关系。**链接地址(Link Address)**是编译器生成代码时假定的运行地址,跳转指令、内存访问、绝对寻址都会按这个地址来算;**加载地址(Load Address)**是固件实际被放到内存里的位置;**运行地址(Run Address)**是 CPU 真正取指执行时的地址。如果这三个值不一致,程序一跑就废,因为所有绝对跳转都指向了错误的位置。
我们的操作策略很明确:把三地址统一。在 U-Boot 里把固件加载到预留内存区域的起始地址(比如CONFIG_RISCV_FIRMWARE_BASE),然后让 C906 从同一地址开始执行。链接脚本里的ORIGIN就写这个地址,一分一毫都不能差。
3.2 一个能用的 link.ld 应该是这样的
我从一个实际验证过的工程里抽出来一个可用的链接脚本骨架,结构拆开讲。
OUTPUT_ARCH(riscv) ENTRY(_start) MEMORY { /* 这里的 ORIGIN 必须和 U-Boot 里 go 命令跳转的地址一致 */ DDR (rwx) : ORIGIN = 0x48000000, LENGTH = 4M } SECTIONS { .text : { KEEP(*(.text._start)) *(.text*) *(.rodata*) . = ALIGN(16); _etext = .; } > DDR .data : { _data_start = .; *(.sdata*) *(.data*) . = ALIGN(16); _data_end = .; } > DDR .bss : { _bss_start = .; *(.sbss*) *(.bss*) *(COMMON) . = ALIGN(16); _bss_end = .; } > DDR . = ALIGN(16); _end = .; /* 栈顶放在镜像末尾,向下增长 */ _stack_top = _end + 0x1000; }三点经验:
KEEP(*(.text._start))的写法很关键。把启动汇编的入口函数单独放在代码段最前面,确保第一条指令就是复位入口,链接器不会乱排。_stack_top不是符号变量,是地址常量。它代表栈顶地址本身,取它的地址就是取数值。对应到汇编里用la sp, _stack_top初始化栈指针。.bss段必须由启动代码清零。加载固件时 DDR 里的初始值是不可预测的,不把未初始化全局变量清成零,后面访问全局变量就是毒数据。
这里的 ORIGIN 我用 0x48000000 做示例地址,真实板子以你查到的 T527 手册预留内存地址为准。拿到原文后第一件事就是核对这个值。
3.3 启动汇编与 C 语言的交接
有了链接脚本,接着是最小启动代码。RISC-V 的裸机启动汇编比 ARM 简单,没有那么多特权模式切换,核心动作就三个:设栈、清 bss、跳 main。
.section .text._start .globl _start _start: /* 关闭全局中断,避免初始化过程中被打断 */ csrci mstatus, 0x8 /* 设置栈指针,链接脚本里定义的栈顶 */ la sp, _stack_top /* 清零 bss 段 */ la t0, _bss_start la t1, _bss_end 1: bgeu t0, t1, 2f sd zero, 0(t0) addi t0, t0, 8 j 1b 2: /* 跳转到 C 主函数 */ call main 1: /* 主函数返回后死循环兜底 */ wfi j 1b这段代码没有半点多余。csrci mstatus, 0x8是把全局中断使能位MIE清零,裸机初始化过程中串口都没配好,中断进来只有死机一条路。.bss清零用sd一次写 8 字节,效率比sb高一个量级。
用一个循环兜底是为了防止 main 函数 return 后 PC 不知道跑到哪里去。加了wfi指令还能顺带让核进入低功耗等待,比空转j优雅得多。
4. 实操:环境搭建与第一个裸机固件
4.1 开发环境的具体搭建步骤
先说环境。我在 Ubuntu 22.04 上实测,整个搭建过程十分钟以内,前提是网络畅通。
安装裸机工具链,推荐直接从平头哥官网下载预编译的Xuantie-900-gcc,解压后配置环境变量即可:
wget https://occ.t-head.cn/community/download # 以实际下载页为准 tar -xzf Xuantie-900-gcc-elf-newlib-x86_64.tar.gz export PATH=$PWD/Xuantie-900-gcc/bin:$PATH验证工具链是否可用,跑一下版本号即可确认:
riscv64-unknown-elf-gcc -v顺便检查一下工具链支持的架构扩展,确定它能生成rv64imafdc指令集的代码:
riscv64-unknown-elf-gcc -march=rv64imafdc -mabi=lp64d -print-libgcc-file-name如果这条命令能打印出 libgcc 的路径,说明工具链支持该指令集,可以编译裸机程序。
4.2 一个最小程序的完整工程结构
我建议按下面的目录结构组织工程,为后面的复杂项目打底:
xuantie-c906-demo/ ├── start.S ├── link.ld ├── main.c ├── uart.c ├── uart.h └── Makefilemain.c最核心的逻辑是从串口打印一行信息。这里要提个醒:C906 访问片上外设用的基地址,一定是从 T527 手册里面查来的,不同封装、不同复用配置下 UART 的基址和时钟源可能不一样。我写代码时用宏定义把基址提出来,方便按板子适配。
#define UART_BASE 0x05000000UL // 以 T527 手册实际 UART 基地址为准 static void uart_putc(char c) { volatile unsigned int *dr = (unsigned int *)(UART_BASE + 0x00); volatile unsigned int *sr = (unsigned int *)(UART_BASE + 0x14); /* 等待发送 FIFO 不满 */ while (*sr & (1U << 1)) ; *dr = c; } void uart_puts(const char *s) { while (*s) uart_putc(*s++); } int main(void) { uart_puts("Hello from XuanTie C906 on T527!\r\n"); /* 死循环,防止 main 返回 */ while (1) ; return 0; }这个版本没有做中断、没有开 DMA,纯粹验证“核活了+串口能输出”。我第一次上板时跑的就是这个,看到串口打印出字符串的那一刻,心里那块石头才落地,因为后面的所有复杂功能都是建立在这条输出之上的。
4.3 Makefile 与编译参数
Makefile 的核心编译参数要盯紧,指令集架构和 ABI 不匹配是编译期最常见的翻车点:
CROSS := riscv64-unknown-elf- CC := $(CROSS)gcc OBJCOPY := $(CROSS)objcopy CFLAGS := -march=rv64imafdc -mabi=lp64d -O2 -ffreestanding -nostdlib -fno-builtin LDFLAGS := -T link.ld -nostdlib -static all: firmware.bin start.o: start.S $(CC) $(CFLAGS) -c start.S -o $@ main.o: main.c $(CC) $(CFLAGS) -c main.c -o $@ uart.o: uart.c $(CC) $(CFLAGS) -c uart.c -o $@ firmware.elf: start.o main.o uart.o $(CC) $(LDFLAGS) $^ -o $@ firmware.bin: firmware.elf $(OBJCOPY) -O binary firmware.elf $@ clean: rm -f *.o *.elf *.bin几个参数的用途说明一下:
-ffreestanding:告诉编译器这是一个独立环境程序,不依赖宿主操作系统,printf 这类标准库函数不会被依赖。-nostdlib:不链接标准启动代码和标准 C 库,链接入口完全由我们自己控制。-fno-builtin:防止编译器自作主张用内建函数替换你的代码。-static:静态链接,生成独立的、不依赖动态库的固件。
编译完检查一下生成的反汇编,确认入口第一句就是_start:
riscv64-unknown-elf-objdump -d firmware.elf | head -30如果第一行不是<_start>:,说明链接脚本的ENTRY没生效或者KEEP没写对,返回去检查。
5. 上板加载:从 U-Boot 到“Hello World”
5.1 U-Boot 下的加载命令序列
编译出firmware.bin之后,把文件放到 tftp 服务器或者 SD 卡里,进入 U-Boot 命令行。以 SD 卡 + fatload 的方式举例:
U-Boot# fatload mmc 0:1 0x48000000 firmware.bin U-Boot# go 0x48000000go命令告诉 CPU 直接跳转到指定地址执行,不传参、不检查格式,非常适合裸机调试。RAM 里的地址要和 link.ld 的ORIGIN完全一致,差一位都是跑飞。
注意一个细节:T527 的 U-Boot 一般默认只启动了 A55,C906 此时还处于复位状态。go命令本身不会自动释放 C906 的复位,你需要先通过特定寄存器序列把它的复位释放掉。不同板卡 BSP 对这个的处理方式不尽相同,有些板子在 Linux 内核启动时会顺便初始化好两颗核之间的关系,有些则需要手动操作。
我建议先查一下你的板卡 BSP 的 U-Boot 源码,搜索c906或者riscv相关代码,确认有没有现成的释放流程。这一块如果文档缺失,最直接的办法是用 JTAG 连接 C906,直接在调试器里控制复位并设置 PC。这也是为什么我强烈建议有调试器就插上调试器,没有调试器裸调从核真的是地狱难度。
5.2 运行结果与验证手段
一切顺利的话,串口上会打印出:
Hello from XuanTie C906 on T527!如果没看到输出,按下面顺序排查:
- 串口硬件占用:C906 打印用的 UART 可能和 A55 的 console 是同一个外设,两边同时用会产生竞争。调试阶段最好的办法是让 A55 先别拿这个串口打印,或者用另一个空闲 UART。
- 时钟门控:很多 SoC 的外设默认在时钟门控关闭状态,C906 代码访问 UART 寄存器前需要先打开对应时钟。这步要在启动汇编里配,或者由 A55 侧提前配好。
- 加载地址错误:
go的地址和 link.ld 的 ORIGIN 不一致,程序已经在内存里跑飞了。用 JTAG 读 PC 就能立刻确认。
5.3 从“能打印”到“能干活”:共享内存的预热
串口打印说明核已经跑起来了,但真正的产品功能离不开数据交换。这里先留一个最简单的通道设计思路:在两颗核之间划一块共享内存,C906 往里写数据,A55 侧的 Linux 应用通过mmap映射同一块物理内存来读取。
共享内存区域建议选在 Linux 的reserved-memory里定义。在 device tree 里预留之后,A55 侧任何程序都无法通过正常内存管理接触到这块区域,就避免了双方踩踏。C906 侧只需要把共享区地址定义为一个宏,启动后在固定的偏移位置写一个魔数,A55 侧轮询这个魔数是否变化来判断 C906 是否活着。这是 Part 1 能做的最大闭环,满足了“有计算、有通信”的验证目标。
6. 上板运行时的经典翻车现场与排查手册
6.1 我踩过的几个坑,列成速查表
以下问题都是我实际调试过程中遇到过的,按出现频率排序:
| 现象 | 大概率原因 | 排查方法 |
|---|---|---|
| 串口无任何输出 | C906 未释放复位,程序根本没跑 | JTAG 连上读 PC,停在 0 则确认复位未释放 |
| 串口输出乱码 | UART 波特率配置错误 | 检查 UART 时钟源分频系数,用示波器量 TX 引脚电平宽度 |
| 串口输出一次后死机 | 未等发送 FIFO 空就返回,或者栈溢出 | 检查 uart_putc 的等待条件,确认栈指针初始化无误 |
| 程序好像跑起来了但行为诡异 | bss 段没清零,全局变量初始值不确定 | 在 main 入口断点查全局变量值,清零逻辑看汇编 |
| 一执行 go 命令整个系统挂 | 链接地址写到了 Linux 正在用的内存 | 检查预留内存区域是否在 device tree 里正确配置 |
| 反汇编第一句不是 _start | link.ld 里 KEEP 没生效 | 确认.text._start段名和汇编里.section完全一致 |
6.2 两条保命级的调试建议
如果你只是想把代码跑起来,上面的内容已经够了。但如果你想继续做下去,下面两条建议值得尽早知道。
第一条,把 JTAG 调试环境配好再动手。裸机开发没有调试器等于蒙眼狂奔。用 JTAG 连上 C906 之后,你可以直接控制核心的复位、设置 PC 值、单步执行、读写寄存器,任何“程序到底跑没跑”的争论都能一句话解决。T527 的调试接口在 BSP 文档里有明确说明,配套的 OpenOCD 配置通常也能在社区找到。
第二条,先在 QEMU 上验证一遍再上板。QEMU 支持 RISC-V virt 平台,你几乎可以不改任何代码就把这个裸机程序跑起来,串口也能看到输出。上板前先在模拟器上确认链接脚本正确、内存布局合理、启动流程通畅,能过滤掉一半以上的低级错误。我就是靠这个习惯,把上板后的调试时间从三小时压缩到了二十分钟。
第三条,工具链版本要锁定。不用的 GCC 版本对启动代码的处理有细微差别,比如新版本可能要求更完整的功能属性声明,或者对.section的解析更严格。同样一段 start.S,在 GCC 10 下编译正常,在 GCC 12 下可能直接报错。建议把官方工具链的版本固定住,不要轻易升级,裸机工程的工具链“能用就不动”。
Part 1 到这里,核心流程已经走通了:从理解 T527 的异构架构,到搭好工具链,再到写链接脚本、启动代码、UART 输出,最后在 U-Boot 下把固件加载到预留内存并跳转执行。个人体会最深的一点是,这套流程里真正花时间的不是写代码,而是搞清楚地址映射和复位控制链路。很多开发者在从核开发上栽跟头,不是代码能力问题,是对 SoC 级的内存规划缺乏整体意识。C906 这颗核的潜力很大,裸机点亮只是第一步,后续往 RTOS、往 remoteproc、往核间通信协议方向走都非常值得投入。下一篇可以聊聊怎么把 FreeRTOS 迁移到 C906 上,以及如何用共享内存和 Mailbox 建立一套稳定的核间通信通道,这些都是真正产品化的必经之路。