引言
写完一个 RISC-V CPU 之后,下一个问题是:怎么让它跑起 C 程序?这需要一整套工具链——编译器把 C 变成指令,链接器把指令和数据摆放到正确的地址,启动代码在 main 之前初始化运行环境,调试器让你能单步跟踪。
裸机(bare-metal)开发的特殊之处在于没有操作系统兜底。在 Linux 上,main 函数之前有一大堆准备工作是内核和 C 运行库替你做的:设置栈指针、清零 BSS 段、初始化堆、准备 argc/argv。裸机上这些全部要自己写。一个常见的现象是:程序编译链接都通过了,但一运行就挂——原因往往就是栈指针没设、BSS 没清零,或者中断向量表位置不对。
本文按「工具 → 启动 → 链接 → 调试」的顺序展开,覆盖从 riscv64-unknown-elf-gcc 的安装到 OpenOCD 连接 FPGA 的完整流程。前置阅读是 RISC-V 指令集架构详解
与 RISC-V 外设与中断控制器
,前者提供汇编与 CSR 知识,后者提供外设寄存器定义。
目录
- 工具链构成与安装
- 目标三元组与 ABI
- 启动流程:从复位到 main
- 链接脚本详解
- crt0 与运行环境初始化
- 编译与链接选项
- trap 向量表与中断入口
- newlib 与 picolibc:printf 从哪来
- OpenOCD 与 GDB 调试
- 在 FPGA 上运行:比特流与程序加载
- Makefile 组织与构建流程
- 常见问题排查清单
1. 工具链构成与安装
RISC-V 裸机工具链由这些部分组成:
| 组件 | 作用 | 典型命令 |
|---|---|---|
| binutils | 汇编器、链接器、目标文件工具 | riscv64-unknown-elf-as/ld/objdump/objcopy |
| GCC | C/C++ 编译器 | riscv64-unknown-elf-gcc |
| newlib | 嵌入式 C 运行库 | 随工具链提供 |
| GDB | 调试器 | riscv64-unknown-elf-gdb |
| OpenOCD | 片上调试器(JTAG) | openocd |
| QEMU | 系统/用户态模拟器 | qemu-system-riscv32 |
安装方式:
# 方式 1:官方预编译包(推荐):解压后加入 PATH
tar -xzf riscv64-unknown-elf-gcc-*.tar.gz -C /opt/
export PATH=/opt/riscv/bin:$PATH
# 方式 2:发行版包管理器
sudo apt install gcc-riscv64-unknown-elf binutils-riscv64-unknown-elf
# 方式 3:QEMU 模拟(无需硬件)
sudo apt install qemu-system-misc
# 验证
riscv64-unknown-elf-gcc --version && riscv64-unknown-elf-objdump --version
多架构(multilib) 是常见坑:工具链可能只编译了部分 ABI 的库。用 riscv64-unknown-elf-gcc --print-multi-lib 查看支持的组合,如果缺你需要的(如 rv32imac/ilp32),就要换工具链或自己编译。
2. 目标三元组与 ABI
目标三元组(target triplet)决定了工具链生成什么代码:
riscv64-unknown-elf-gcc 中,riscv64 是默认 64 位(可被 -march/-mabi 覆盖成 32 位),unknown 表示厂商无关,elf 表示裸机(无操作系统)。关键编译选项:-march=rv32imac(32 位 + 乘除 + 原子 + 压缩)、-mabi=ilp32(32 位 int/long/pointer,无浮点 ABI)、-mcmodel=medany(任意位置,PC 相对寻址)、-msmall-data-limit=8(小数据段阈值)。
ABI 必须与指令集匹配:
| ABI | 含义 | 对应 march |
|---|---|---|
| ilp32 | int/long/pointer 均 32 位 | rv32i 系列 |
| ilp32e | 嵌入式精简 ABI(仅 16 个寄存器) | rv32e |
| ilp32f / ilp32d | 浮点参数用 F/D 寄存器传递 | 带 F/D |
| lp64 / lp64d | 64 位版本 | rv64i / rv64gc |
-march 与 -mabi 不匹配会直接报错,例如 -march=rv32i -mabi=ilp32d(ABI 要求 D 扩展但指令集没有)会链接失败。
3. 启动流程:从复位到 main
裸机程序的启动链条:
上电复位 → CPU 从复位向量取第一条指令(通常 0x00000000)
→ 执行 crt0:设置 sp → 设置 gp → 清零 BSS → 拷贝 .data 到 RAM
→ 设置 mtvec → 调用 main()
→ main() 返回后:进入死循环
这个顺序不能乱。特别是栈指针必须在任何函数调用之前设置好——包括 crt0 自己如果用 C 写的话。所以标准做法是用一小段汇编设置栈,然后跳到 C 函数。
# crt0.S:启动代码
.section .text.start
.globl _start
_start:
la sp, _stack_top # 1. 设置栈指针(栈顶定义在链接脚本里)
.option push
.option norelax
la gp, __global_pointer$ # 2. 设置全局指针(-msmall-data-limit 优化用)
.option pop
la t0, _bss_start # 3. 清零 BSS 段
la t1, _bss_end
1: bgeu t0, t1, 2f
sw zero, 0(t0)
addi t0, t0, 4
j 1b
2: call main # 4. 调用 main
3: j 3b # 5. main 返回后停机
4. 链接脚本详解
链接脚本(.ld)描述内存布局:哪个段放在哪个地址、符号定义在哪里。
/* link.ld:RV32 裸机链接脚本 */
OUTPUT_ARCH("riscv")
ENTRY(_start)
MEMORY {
ROM (rx) : ORIGIN = 0x00000000, LENGTH = 64K /* BootROM */
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M /* DDR */
}
SECTIONS {
.text : { *(.text.start) *(.text .text.*) } > ROM /* 启动代码放最前 */
.rodata : { *(.rodata .rodata.*) } > ROM
.data : { /* 初值存 ROM,运行时拷贝到 RAM */
_data_start = .; *(.data .data.*) _data_end = .;
} > RAM AT > ROM
_data_load = LOADADDR(.data);
.bss : { _bss_start = .; *(.bss .bss.*) *(COMMON) _bss_end = .; } > RAM
.stack (NOLOAD) : { /* 栈:从 RAM 末尾往下增长 */
. = ALIGN(16); _stack_bottom = .; . += 16K; _stack_top = .;
} > RAM
_heap_start = .;
}
三个关键机制:> RAM AT > ROM 让 .data 段运行地址在 RAM、加载地址在 ROM,启动代码要从 _data_load 拷贝到 _data_start;NOLOAD 让 .bss 和 .stack 不占二进制空间;链接脚本里定义的符号(_bss_start 等)会被 C/汇编引用,是启动代码与链接脚本之间的契约。
5. crt0 与运行环境初始化
完整的 C 运行环境初始化(含 .data 拷贝):
// crt0.c:C 版本的启动代码(栈已在汇编里设好)
extern char _data_start[], _data_end[], _data_load[];
extern char _bss_start[], _bss_end[];
void crt0_init(void) {
char *src = _data_load, *dst = _data_start;
while (dst < _data_end) *dst++ = *src++; // 1. 拷贝 .data:ROM → RAM
for (dst = _bss_start; dst < _bss_end; dst++) // 2. 清零 .bss
*dst = 0;
// 3. 初始化堆:heap_start = _heap_start, heap_end = _stack_bottom
}
为什么必须清零 BSS:C 标准规定未初始化的全局变量初值为 0。如果不清零,它们会是内存里的随机值——这类 bug 极其难查,因为它依赖于上电时的内存内容。
堆与栈的方向:栈通常从高地址向低地址增长(sp 递减),堆从低地址向高地址增长。两者相向而行,中间是空闲区。裸机上通常不实现动态内存分配,或者用一个极简的 sbrk。
6. 编译与链接选项
完整的编译链接命令:
MARCH="-march=rv32imac -mabi=ilp32 -mcmodel=medany"
# 编译与汇编
riscv64-unknown-elf-gcc -c -o main.o main.c $MARCH -ffreestanding -nostdlib -Os -Wall
riscv64-unknown-elf-gcc -c -o crt0.o crt0.S $MARCH
# 链接
riscv64-unknown-elf-gcc -o firmware.elf $MARCH \
-T link.ld -nostartfiles -Wl,--gc-sections crt0.o main.o
# 生成反汇编与二进制
riscv64-unknown-elf-objdump -d firmware.elf > firmware.dis
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin
riscv64-unknown-elf-size firmware.elf
关键选项:-ffreestanding 告诉编译器「没有标准运行环境」;-nostdlib / -nostartfiles 不链接标准启动文件和库(用我们自己的 crt0);-Wl,--gc-sections 删除未引用的段,配合 -ffunction-sections -fdata-sections 可显著减小固件体积;-Os 优化体积;-mcmodel=medany 让代码和数据在 ±2GB 内用 PC 相对寻址。
7. trap 向量表与中断入口
mtvec 指向 trap 入口。直接模式下所有 trap 跳到一个地址,软件再分发:
.section .text.trap
.globl trap_entry
.align 4 # mtvec 低 2 位是模式位,入口必须 4 字节对齐
trap_entry:
addi sp, sp, -64 # 保存上下文(见外设与中断章节)
sw ra, 0(sp)
sw t0, 4(sp)
# ...
csrr t0, mcause
blt t0, zero, handle_irq # 最高位 1 → 中断
csrr a0, mepc # 异常:传参后交给 C 处理
csrr a1, mcause
call exception_handler
j trap_exit
handle_irq:
andi a0, t0, 0x3ff
call irq_dispatch
trap_exit:
lw ra, 0(sp) # 恢复上下文
# ...
addi sp, sp, 64
mret
设置向量表:
void trap_init(void) {
extern void trap_entry(void);
// 直接模式:mtvec 低 2 位为 0
asm volatile ("csrw mtvec, %0" :: "r"((uintptr_t)trap_entry));
}
注意 mtvec 的低 2 位是模式位:写地址时地址必须 4 字节对齐(低 2 位为 0 表示直接模式,为 1 表示向量模式)。如果入口函数地址没对齐,模式位会被意外设置。
8. newlib 与 picolibc:printf 从哪来
裸机上调用 printf 需要 C 运行库。两个主流选择:
| 库 | 特点 | 体积 |
|---|---|---|
| newlib | 功能全、成熟、体积大 | 大(几十 KB) |
| newlib-nano | 精简版,去掉宽字符和浮点 | 中 |
| picolibc | 专为嵌入式设计、体积小 | 小(几 KB) |
裸机上 printf 要输出到串口,必须实现系统调用桩(syscall stubs):
// 把 newlib 的底层 I/O 接到 UART
#include <sys/stat.h>
#include <unistd.h>
int _write(int fd, const char *buf, int len) {
for (int i = 0; i < len; i++) uart_putc(buf[i]); // 逐字节写串口
return len;
}
int _read(int fd, char *buf, int len) { buf[0] = uart_getc(); return 1; }
int _close(int fd) { return -1; }
int _fstat(int fd, struct stat *st) { st->st_mode = S_IFCHR; return 0; }
int _isatty(int fd) { return 1; }
int _lseek(int fd, int off, int whence) { return 0; }
void _exit(int code) { while (1) {} } // 裸机没有退出
void *_sbrk(int incr) { /* 极简堆实现 */ }
要点:
_write是 printf 的终点:newlib 的printf最终调用_write。实现它就能用printf。- 浮点 printf 体积大:
printf("%f", ...)会链接进几十 KB 的浮点格式化代码。不需要浮点时用-u _printf_float相关选项排除,或改用newlib-nano。 - 缓冲问题:newlib 默认行缓冲,但裸机上
_isatty返回 1 时可能被设为无缓冲。调试时加\n或调用fflush(stdout)。
9. OpenOCD 与 GDB 调试
OpenOCD 连接 JTAG 调试器(如 FT2232、CMSIS-DAP)到目标芯片,提供 GDB 服务:
# 启动 OpenOCD:指定调试器配置和目标配置,监听 3333 端口供 GDB 连接
openocd -f interface/ftdi/olymex-arm-usb-tiny-h.cfg -f target/riscv.cfg \
-c "adapter speed 1000"
# GDB 侧连接与调试
riscv64-unknown-elf-gdb firmware.elf
(gdb) target extended-remote localhost:3333
(gdb) monitor reset halt # 复位并停机
(gdb) load # 下载程序到目标
(gdb) break main; continue
(gdb) info registers # 查看寄存器(含 CSR)
(gdb) x/16x 0x80000000 # 查看内存
(gdb) monitor reg pc # 通过 OpenOCD 读 PC
调试要点:
monitor前缀:把命令直接传给 OpenOCD,可用的命令有reset、halt、resume、reg、flash等。- 查看 CSR:GDB 对 RISC-V CSR 的支持依赖 OpenOCD 的寄存器定义文件,没有的话用
monitor命令或直接读内存映射。 - 硬件断点有限:RISC-V 的硬件断点通常只有 2~4 个,超出时 GDB 会自动改用软件断点(写
ebreak指令)。 - JTAG 时钟要匹配:
adapter speed设太高会连接不稳定,从 1000kHz 起调。
10. 在 FPGA 上运行:比特流与程序加载
完整流程:
# 1. 生成比特流:综合 → 实现 → .bit(Vivado/Quartus)
# 2. 烧录 FPGA 配置
openocd -f fpga.cfg -c "init; pld load 0 top.bit; exit"
# 3. 把固件转成可加载格式:bin 用于内存加载,hex 用于 $readmemh 初始化 BRAM
riscv64-unknown-elf-objcopy -O binary firmware.elf firmware.bin
riscv64-unknown-elf-objcopy -O ihex firmware.elf firmware.hex
# 4. 通过 GDB 加载到内存并运行
riscv64-unknown-elf-gdb firmware.elf -ex "target extended-remote localhost:3333" \
-ex "load" -ex "continue"
两种程序加载方式:
- 固化到 BRAM:把
firmware.hex用$readmemh在综合时初始化指令 BRAM。优点是上电即运行,缺点是改程序要重新综合。 - 运行时加载:通过 JTAG 或串口把程序下载到 RAM。迭代快,适合开发阶段。
// 用 $readmemh 初始化指令存储器(综合时固化程序)
initial begin
$readmemh("firmware.hex", imem);
end
11. Makefile 组织与构建流程
一个可用的裸机 Makefile 骨架:
ARCH = rv32imac
ABI = ilp32
CC = riscv64-unknown-elf-gcc
OBJCOPY = riscv64-unknown-elf-objcopy
CFLAGS = -march=$(ARCH) -mabi=$(ABI) -mcmodel=medany \
-ffreestanding -nostdlib -Os -Wall \
-ffunction-sections -fdata-sections
LDFLAGS = -T link.ld -nostartfiles -Wl,--gc-sections
SRCS = crt0.S main.c uart.c
OBJS = $(SRCS:.c=.o)
OBJS := $(OBJS:.S=.o)
firmware.elf: $(OBJS) link.ld
$(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS)
riscv64-unknown-elf-size $@
firmware.bin: firmware.elf
$(OBJCOPY) -O binary $< $@
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
%.o: %.S
$(CC) $(CFLAGS) -c -o $@ $<
dis: firmware.elf
riscv64-unknown-elf-objdump -d $< > firmware.dis
debug: firmware.elf
riscv64-unknown-elf-gdb $< -ex "target extended-remote localhost:3333"
clean:
rm -f *.o firmware.elf firmware.bin firmware.dis
实践要点:
-Wl,--gc-sections配合-ffunction-sections -fdata-sections:能把固件体积减少 30%~50%,对 BRAM 有限的 FPGA 很重要。-MMD -MP生成依赖文件:头文件改了能自动触发重编译。- CI 里加
size检查:超过 BRAM 容量时立即失败,避免综合阶段才发现。
12. 常见问题排查清单
| 现象 | 原因 | 排查方法 |
|---|---|---|
| 程序一运行就挂 | 栈指针未设置 | 反汇编确认 _start 第一条是 la sp, ... |
| 全局变量初值不对 | BSS 未清零或 .data 未拷贝 | 检查 crt0 的拷贝循环与链接脚本符号 |
printf 无输出 | 未实现 _write 或 UART 未初始化 | 在 _write 里打断点 |
| 中断不响应 | mtvec 未设置或模式位错误 | monitor reg mtvec 检查低 2 位 |
| 链接报 undefined reference | 缺少 -nostdlib 或 multilib 不匹配 | 检查链接命令与 --print-multi-lib |
| 固件超过 BRAM | 未用 gc-sections 或 printf 浮点 | size 查看各段大小,去掉浮点 printf |
| GDB 连不上 | JTAG 时钟过高或配置错 | 降低 adapter speed,检查接线 |
| 程序跑飞 | 未对齐访问触发异常 | 检查 mcause 编码(4/6 为地址非对齐) |
调试的通用方法:先让 LED 闪起来(验证时钟、复位、GPIO 通路),再让串口输出一个字符(验证 UART 与分频),然后才能调试复杂逻辑。跳过这两步直接调试中断和 DMA,会浪费大量时间在基础问题上。
权衡取舍
| 决策点 | 选项 A | 选项 B |
|---|---|---|
| C 库 | newlib:功能全、体积大 | picolibc:小巧、功能够用 |
| printf | 完整版:支持浮点、几十 KB | 精简版:只支持整数、几 KB |
| 程序加载 | 固化 BRAM:上电即跑 | JTAG 下载:迭代快 |
| 启动代码 | 纯汇编:精确可控 | C + 汇编:易读、需先设栈 |
| 链接脚本 | 手写:完全控制 | 工具生成:省事、需检查 |
| 调试 | GDB + OpenOCD:功能全 | 串口打印:简单、无侵入 |
常见坑清单
- 忘记设置栈指针:任何函数调用前必须设好
sp,否则第一次call就崩溃。 - BSS 未清零:未初始化的全局变量是随机值,这类 bug 依赖上电内存内容,极难复现。
.data未从 ROM 拷贝到 RAM:已初始化变量读到的是 ROM 里的内容或垃圾,需AT > ROM+ 拷贝循环。mtvec地址未 4 字节对齐:低 2 位被当作模式位,trap 跳到错误地址。-march与-mabi不匹配:链接阶段报错,ABI 与指令集扩展必须一致。- multilib 缺失:工具链没编译对应 ABI 的库,链接报 undefined reference。
- 未实现
_write却用 printf:链接通过但运行无输出,或链接报_write未定义。 - printf 浮点拖大固件:
%f会引入几十 KB 代码,BRAM 不够时链接失败。 - JTAG 时钟过高:连接不稳定、下载失败,从 1000kHz 逐步上调。
- 硬件断点超限:RISC-V 通常只有 2~4 个硬件断点,超出后行为异常,需改用软件断点。
-Wl,参数位置:链接器参数顺序影响结果,-Wl,前缀参数要放在对象文件之后。
小结
RISC-V 裸机开发的完整链条是:工具链生成正确指令 → 链接脚本摆好内存布局 → 启动代码建立 C 运行环境 → trap 向量表接管异常 → 调试器让你看见发生了什么。每一环都有明确的契约(符号名、ABI、地址对齐),任何一环出错都会表现为「程序不工作」这种笼统的现象。
调试裸机问题的通用策略是自底向上:先验证时钟和复位(LED),再验证最简单的输出通路(串口单字符),最后才调试复杂逻辑。这个顺序能把问题域逐步缩小,避免在基础环境不可靠的情况下排查上层逻辑。
下一步建议阅读 FPGA 加速器:矩阵乘与 CNN ,把裸机能力用到驱动加速器上;或者进入 芯片设计流程与 EDA 工具链 ,看整个 SoC 如何从 RTL 走向流片。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。