4、编写第一个RISC-V裸机程序:启动代码与链接脚本、简单的C程序(点灯)、编译生成.elf与.hex文件
好,咱们开始动手了。
前面聊了那么多理论,什么指令集、流水线、中断……说实话,不写一行代码,这些东西都是空中楼阁。我个人习惯是,先让板子上的灯亮起来,心里才踏实。
这一章,我们就来走通RISC-V裸机开发最基础的一条路:启动代码 → 链接脚本 → C程序 → 编译输出。你跟着我走一遍,后面再调试复杂程序,心里就有底了。
4.1 裸机程序到底在干什么?
先想一个问题:我们平时在Linux或者Windows上写程序,双击就能跑。为什么到了嵌入式里,就得搞什么启动代码、链接脚本?
说白了,操作系统帮我们做了三件事:
- 初始化硬件(比如设置栈指针、清零BSS段)
- 加载程序(把代码从硬盘搬到内存)
- 跳转到main()
裸机环境下,没人帮你干这些。你得自己写。这就是启动代码(startup code)存在的意义。
核心逻辑:裸机程序 = 启动代码 + 链接脚本 + 你的C代码。三者缺一不可。
我在项目中遇到过好几次,有人把启动代码写错了,结果程序死活跑不起来。查了半天,发现是栈指针没初始化。嗯,这种坑踩过一次就记住了。
4.2 启动代码:从复位向量到C环境
启动代码通常用汇编写。为什么?因为C语言依赖栈,而栈指针在C运行前必须设置好。这是个鸡生蛋蛋生鸡的问题,只能用汇编解决。
一个最简的RISC-V启动代码,核心就做三件事:
- 设置栈指针(sp = _stack_top)
- 清零BSS段(把未初始化的全局变量清0)
- 跳转到main()
下面是我常用的模板,你直接拿去用:
# startup.S
.section .text.startup
.globl _start
_start:
# 设置栈指针
la sp, _stack_top
# 清零BSS段
la t0, _bss_start
la t1, _bss_end
bge t0, t1, bss_done
bss_loop:
sw zero, 0(t0)
addi t0, t0, 4
blt t0, t1, bss_loop
bss_done:
# 跳转到main
call main
# 如果main返回,就死循环
j .
小技巧:我习惯把启动代码放在.text.startup段,这样链接脚本里可以单独控制它的位置。有些调试器要求复位向量必须在0地址,这样安排最灵活。
4.3 链接脚本:告诉编译器怎么摆
链接脚本,说白了就是一张“摆放图”。你告诉链接器:代码放哪里,数据放哪里,栈放哪里。
为什么需要它?因为RISC-V的地址空间是固定的。比如你的Flash从0x80000000开始,RAM从0x90000000开始。链接脚本就是把这些信息告诉编译器。
下面是一个最简的链接脚本,配合上面的启动代码使用:
/* link.ld */
OUTPUT_ARCH(riscv)
ENTRY(_start)
MEMORY
{
flash (rx) : ORIGIN = 0x80000000, LENGTH = 1M
ram (rwx) : ORIGIN = 0x90000000, LENGTH = 128K
}
SECTIONS
{
.text : {
*(.text.startup)
*(.text)
} > flash
.rodata : {
*(.rodata)
} > flash
.data : {
*(.data)
} > ram AT > flash
.bss : {
_bss_start = .;
*(.bss)
_bss_end = .;
} > ram
_stack_top = ORIGIN(ram) + LENGTH(ram);
}
注意看最后一行:_stack_top 被定义在RAM的末尾。栈是向下生长的,所以栈顶放在最高地址。这个细节我曾经搞反过,结果程序一调用函数就崩。你想想看,栈指针指向了RAM的起始地址,一压栈就把代码区覆盖了……
避坑指南:我曾经在项目中把.data段的加载地址(LMA)和运行地址(VMA)搞混了。结果全局变量初始化全乱套。记住:> ram AT > flash 的意思是“运行时在RAM,但初始值存在Flash里”。
4.4 简单的C程序:点灯
好了,启动代码和链接脚本都有了。现在写个最简单的C程序,让LED亮起来。
假设你的FPGA开发板上,LED连接在GPIO的bit 0上。GPIO基地址是0x10000000。控制寄存器偏移0x00,数据寄存器偏移0x04。
// main.c
#define GPIO_BASE 0x10000000
#define GPIO_OUT 0x04
void main(void)
{
volatile unsigned int *gpio = (unsigned int *)GPIO_BASE;
// 设置GPIO为输出
gpio[0] = 0x01;
while (1) {
gpio[GPIO_OUT >> 2] = 0x01; // 点亮LED
// 这里可以加延时,但为了简单,先不搞
}
}
注意我用了 volatile 关键字。为什么?因为编译器可能会优化掉对GPIO的写操作——它觉得你写了一个值又没读,纯属浪费。但对外设来说,写操作就是命令。不加volatile,灯可能不亮。
4.5 编译与生成:.elf和.hex
代码写完了,怎么变成FPGA能用的文件?
我用的是RISC-V GNU工具链。编译命令如下:
# 编译汇编启动代码
riscv32-unknown-elf-gcc -c startup.S -o startup.o
# 编译C程序
riscv32-unknown-elf-gcc -c main.c -o main.o -march=rv32im -mabi=ilp32
# 链接
riscv32-unknown-elf-gcc startup.o main.o -T link.ld -o led.elf -nostartfiles -nostdlib
# 生成hex文件(用于Vivado加载)
riscv32-unknown-elf-objcopy -O ihex led.elf led.hex
# 生成bin文件(用于直接烧写)
riscv32-unknown-elf-objcopy -O binary led.elf led.bin
这里有几个参数要解释一下:
-nostartfiles:告诉编译器不要链接默认的启动文件,我们用自己写的。-nostdlib:不用标准库。裸机下没有操作系统,标准库很多函数用不了。-march=rv32im:指定指令集。我用的核支持乘除法扩展,所以加了M。
个人经验:我习惯先用 riscv32-unknown-elf-objdump -d led.elf 反汇编一下,看看生成的代码对不对。特别是检查复位向量地址是不是指向了_start。这一步能提前发现很多链接脚本的问题。
4.6 知识体系图
下面这张图,把整个流程串起来了。你一看就明白:
从这张图可以看得很清楚:三个源文件经过编译链接,生成一个.elf文件。然后通过objcopy转成.hex,供Vivado初始化BRAM使用。或者直接用.elf加载到调试器里单步调试。
4.7 验证:灯亮了没?
把生成的led.hex文件加载到Vivado的Block Memory Generator里,综合、实现、生成比特流,下载到FPGA。如果一切正常,LED应该常亮。
如果灯没亮,别慌。按这个顺序排查:
- 检查复位向量:反汇编看0地址是不是指向_start。
- 检查栈指针:在启动代码里加个断点,看sp的值是不是等于_stack_top。
- 检查GPIO地址:确认你的FPGA设计里GPIO基地址是不是0x10000000。
- 检查volatile:如果优化等级开高了,编译器可能把写操作优化掉。
我曾经踩过的坑:有一次灯死活不亮,查了两天。最后发现是链接脚本里RAM的起始地址写错了,比实际硬件地址大了0x1000。程序加载到了错误的位置,当然跑不起来。从那以后,我每次都会用 readelf -l led.elf 确认各段的加载地址。
好了,第一个RISC-V裸机程序就这样跑起来了。虽然只是点个灯,但整个工具链、启动流程、链接脚本的配合关系,你已经亲手验证了一遍。后面再写复杂程序,无非是在这个框架里加功能而已。
公众号:蓝海资料掘金营,微信deep3321