3. 裸机程序基础:启动流程、链接脚本与内存映射

各位同学,欢迎来到第三章。前两章我们把开发环境搭好了,板子也能点亮了。但说实话,那都是「Hello World」级别的热身。从这一章开始,我们要真正深入 RISC-V 处理器的底层——裸机程序。

裸机程序,说白了就是没有操作系统,你的代码直接跑在 CPU 上。你写的每一行 C 代码,最终都要变成 CPU 能理解的指令。而 CPU 上电后第一件事是什么?它怎么知道从哪里开始执行?你的全局变量放在哪里?堆栈又在哪里?这些,就是本章要解决的问题。

我个人习惯,学一个新架构,先看它的启动流程。这就像你拿到一本新书,先看目录和前言,而不是直接翻到最后一章。

3.1 启动流程:Reset Vector 与 Trap 处理

RISC-V 处理器上电后,PC(程序计数器)会被硬件强制设置为一个固定地址——Reset Vector。对于蜂鸟 E203,这个地址通常是 0x8000_0000(取决于具体实现,但咱们的板子就是这个值)。

为什么是这个地址?因为 FPGA 的片上 BRAM 或 Flash 被映射到了这个位置。CPU 从这里取第一条指令,然后一条一条往下执行。

核心要点:Reset Vector 是 CPU 的「出生点」。你写的代码必须被链接到这个地址,否则 CPU 上电后找不到入口。

我在项目中遇到过一个问题:同事把 Reset Vector 配错了,结果板子怎么按复位键都没反应。查了半天,发现是链接脚本里起始地址写成了 0x8000_0100。差 256 个字节,CPU 就「迷路」了。

除了正常启动,还有一个重要概念——Trap 处理。Trap 包括异常(比如除零、非法指令)和中断(比如定时器、外部按键)。RISC-V 规定,发生 Trap 时,CPU 会跳转到 mtvec 寄存器指向的地址。这个地址通常是一个叫 trap_handler 的函数。

嗯,这里要注意:蜂鸟 E203 默认的 Trap 入口是 0x8000_0004(紧挨着 Reset Vector)。所以你的启动代码里,通常会在 Reset Vector 处放一条跳转指令,然后紧接着放 Trap 处理函数的地址。

一个典型的启动流程是这样的:

  1. 上电复位:PC = 0x8000_0000
  2. 执行第一条指令:通常是跳转到 _start 标签
  3. 初始化全局指针:设置 gp 寄存器,指向全局变量区
  4. 初始化堆栈指针:设置 sp 寄存器,指向栈顶
  5. 清零 BSS 段:把未初始化的全局变量清零
  6. 调用 main():进入你的 C 程序

你想想看,如果没有这些初始化步骤,你的全局变量可能是随机值,堆栈可能指向非法地址——程序一跑就崩。

小技巧:调试启动流程时,可以在 Reset Vector 处放一条 ebreak 指令(断点),然后用调试器单步执行,观察 PC 和寄存器的变化。

3.2 链接脚本:Linker Script 详解

链接脚本,说白了就是告诉链接器:你的代码段放哪里,数据段放哪里,堆栈又放哪里。它是裸机程序的「地图」。

蜂鸟 E203 的链接脚本通常叫 e203.lds。我带你拆解一下核心部分:

OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY
{
    flash (rx)  : ORIGIN = 0x80000000, LENGTH = 256K
    ram   (rwx) : ORIGIN = 0x80040000, LENGTH = 64K
}

SECTIONS
{
    .text : {
        *(.text._start)
        *(.text*)
    } > flash

    .rodata : {
        *(.rodata*)
    } > flash

    .data : {
        *(.data*)
    } > ram AT > flash

    .bss : {
        *(.bss*)
    } > ram
}

我来解释几个关键点:

  • ENTRY(_start):指定入口点是 _start 标签,也就是 Reset Vector 要跳转的地方。
  • MEMORY 区域:定义了 flash(只读,起始 0x8000_0000)和 ram(读写,起始 0x8004_0000)。
  • .text 段:代码段,放在 flash 里。注意第一行 *(.text._start),确保启动代码在最前面。
  • .data 段:已初始化的全局变量。运行时在 ram 里,但初始值要放在 flash 里(AT > flash)。启动代码需要把这些值从 flash 拷贝到 ram。
  • .bss 段:未初始化的全局变量,启动代码要清零。

我曾经踩过一个坑:忘记在链接脚本里给堆栈预留空间,结果 sp 指向了 .bss 段末尾。程序一调用函数,堆栈就把全局变量覆盖了。查了两天才发现是链接脚本的问题。

警告:链接脚本的 LENGTH 必须和实际硬件匹配。如果你把 ram 大小写大了,链接器不会报错,但运行时访问超出范围的地址会触发异常。

3.3 内存映射:Memory Map 全景

内存映射,就是 CPU 的地址空间里,每个地址对应什么外设或存储器。蜂鸟 E203 的内存映射大致如下:

起始地址 结束地址 区域 说明
0x0000_0000 0x0000_0FFF 系统保留 CLINT、PLIC 等控制寄存器
0x1000_0000 0x1000_0FFF GPIO 通用输入输出
0x1000_1000 0x1000_1FFF UART 串口控制器
0x8000_0000 0x8003_FFFF Flash 代码存储(256KB)
0x8004_0000 0x8004_FFFF RAM 数据存储(64KB)

你写裸机程序时,访问外设就是直接读写这些地址。比如往 UART 的数据寄存器(假设地址 0x1000_1000)写一个字节,串口就会发送出去。

我个人习惯,在代码里用宏定义把这些地址封装起来:

#define UART_BASE_ADDR   0x10001000
#define UART_TXDATA      (*(volatile uint32_t *)(UART_BASE_ADDR + 0x00))

void uart_putchar(char c) {
    UART_TXDATA = c;
}

注意那个 volatile 关键字——没有它,编译器可能会优化掉你的写操作,因为编译器觉得这个地址「没被使用过」。

3.4 动手写一个简单的裸机程序

理论说完了,咱们动手写一个。这个程序的功能:上电后通过串口输出 "Hello, E203!",然后循环闪烁 LED。

首先,启动汇编文件 start.S:

.section .text._start
.globl _start
_start:
    /* 设置全局指针 */
    la gp, __global_pointer$

    /* 设置堆栈指针 */
    la sp, _sp

    /* 清零 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:

    /* 拷贝 .data 段 */
    la t0, __data_start
    la t1, __data_end
    la t2, __data_load
    bge t0, t1, data_done
data_loop:
    lw t3, 0(t2)
    sw t3, 0(t0)
    addi t0, t0, 4
    addi t2, t2, 4
    blt t0, t1, data_loop
data_done:

    /* 跳转到 main */
    call main

    /* 如果 main 返回,死循环 */
loop:
    j loop

然后,C 文件 main.c:

#include <stdint.h>

#define GPIO_BASE   0x10000000
#define GPIO_OUT    (*(volatile uint32_t *)(GPIO_BASE + 0x00))
#define GPIO_DIR    (*(volatile uint32_t *)(GPIO_BASE + 0x04))

#define UART_BASE   0x10001000
#define UART_TXDATA (*(volatile uint32_t *)(UART_BASE + 0x00))
#define UART_STATUS (*(volatile uint32_t *)(UART_BASE + 0x04))

void uart_send(char c) {
    /* 等待发送缓冲区空 */
    while (!(UART_STATUS & 0x01));
    UART_TXDATA = c;
}

void uart_send_str(const char *str) {
    while (*str) {
        uart_send(*str++);
    }
}

void delay(volatile uint32_t count) {
    while (count--);
}

int main() {
    /* 初始化 GPIO:LED 引脚设为输出 */
    GPIO_DIR |= (1 << 5);  /* 假设 LED 接在 GPIO5 */

    uart_send_str("Hello, E203!\r\n");

    while (1) {
        GPIO_OUT ^= (1 << 5);  /* 翻转 LED */
        delay(5000000);
    }

    return 0;
}

最后,链接脚本 e203.lds 把这两个文件链接到一起。编译命令大致是:

riscv-none-elf-gcc -march=rv32imc -mabi=ilp32 -nostartfiles -T e203.lds start.S main.c -o main.elf

注意 -nostartfiles 选项——告诉编译器不要链接标准启动文件,我们自己提供 _start。

调试建议:第一次跑裸机程序时,先用 objdump -d main.elf 反汇编,确认 _start 确实在地址 0x8000_0000 处。我每次写新板子的启动代码,都会先做这一步。

3.5 本章知识结构图

下面这张 SVG 图总结了本章的核心知识点和它们之间的关系:

裸机程序启动流程与内存映射 上电复位 Reset Vector (0x8000_0000) 启动代码 (_start) 初始化步骤 设置 gp 寄存器 设置 sp 堆栈 清零 BSS 段 拷贝 .data main() 函数 链接脚本决定各段在内存中的位置

这张图把整个启动流程串起来了。从上电复位开始,经过 Reset Vector、启动代码的初始化步骤,最后进入你的 main 函数。而链接脚本和内存映射,决定了这些代码和数据具体放在哪里。