本文将介绍如何在 RISC-V 环境下,编写一个最简的裸金属(bare-metal)程序,该程序不依赖于操作系统的支持,计算机在启动后直接跳转到该程序开始执行。本文的目的主要是作为一个程序模板,可以对其进行扩展成为一个完整的操作系统或是常驻内存中的固件服务。

实现原理

要实现计算机在启动后立马跳转到该程序执行,需要明确一点:计算机启动后执行的第一条指令是什么?或者说,PC 初始值是什么?答案根据平台的不同可能存在差异,我们的测试环境为 qemu-system-riscv64 模拟器的 virt 模型,其初始 PC 为 0x80000000。那么我们便要编写链接脚本,将需要程序的入口点链接到该地址处。

还有第二个问题:程序入口可以直接是 C 程序吗?答案是不行,至少绝大部分情况下不行。C 代码编译之后,局部作用域内变量的保存依赖于栈,因此我们必须准备好一片连续的内存区域(栈空间),并在进入 C 环境前将栈指针寄存器(SP)指向该内存区域的最高地址处(因为栈从高地址向低地址增长)。

至于栈空间的分配,通常有两种方式。首先可以编写链接脚本进行预留:

OUTPUT_ARCH("riscv")
ENTRY(_entry)

MEMORY {
    RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}

SECTIONS
{
	...
    
    /* 预留 4KB 栈空间 */
    .stack (NOLOAD) : {
        . = ALIGN(16);
        _stack_start = .;
        . += 4K;  /* 4KB 栈空间 */
        _stack_end = .;
    } > RAM
}

还有一种比较巧妙的方法:直接声明一个大小为约定的栈空间大小的数组,并在入口处将 SP 设置为该数组的起始地址。

#define STACK_SIZE 4096
char __stack[STACK_SIZE] __attribute__((aligned(16)));

而对于全局变量(符号)的寻址,有时还会借助 GP 寄存器进行 GP 相对寻址,因此还需要在入口处对其进行设置。我们在这里不考虑,编译时采用 -mcmodel=medany,仅使用 PC 相对寻址。

完整代码

Makefile

CROSS_COMPILE = riscv64-unknown-elf-

CC = $(CROSS_COMPILE)gcc
LD = $(CROSS_COMPILE)ld
OBJCOPY = $(CROSS_COMPILE)objcopy
OBJDUMP = $(CROSS_COMPILE)objdump

CFLAGS = -Wall -Werror -fno-omit-frame-pointer -ggdb -gdwarf-2
CFLAGS += -MD 
CFLAGS += -mcmodel=medany
CFLAGS += -fno-common -nostdlib
CFLAGS += -fno-stack-protector
CFLAGS += -I.

LDFLAGS = -T linker.ld

SRCS_C := $(wildcard *.c)
SRCS_S := $(wildcard *.S)
OBJS := $(SRCS_C:.c=.o) $(SRCS_S:.S=.o)

TARGET = firmware
BIN = $(TARGET).bin

.PHONY: all clean

all: $(BIN)

%.o: %.c
	$(CC) $(CFLAGS) -c $< -o $@

%.o: %.S
	$(CC) $(CFLAGS) -c $< -o $@

$(TARGET).elf: $(OBJS) linker.ld
	$(LD) $(LDFLAGS) -o $@ $(OBJS)

$(BIN): $(TARGET).elf
	$(OBJCOPY) -O binary $< $@
	$(OBJDUMP) -d -S $< > $(TARGET).asm
	$(OBJDUMP) -t $< | sed '1,/SYMBOL TABLE/d; s/ .* / /; /^$$/d' > $(TARGET).sym

clean:
	rm -f *.o *.elf *.bin *.asm *.sym *.d

entry.S

.section .text.entry
.global _entry
_entry:
        # sp = stack0 + (hartid * 4096)
        la sp, stack0
        li a0, 1024 * 4
        csrr a1, mhartid
        addi a1, a1, 1
        mul a0, a0, a1
        add sp, sp, a0
        
        # 只让 core0 执行 start
        # 其余核心自旋
        csrr t0, mhartid
        bnez t0, spin

        # jump to start() in main.c
        call start
spin:
        wfi
        j spin

def.h

/* 支持的最大 CPU 核心数 */
#define NCPU 4

/* 内存映射的串口寄存器地址 (QEMU virt 机器) */
#define UART_BASE 0x10000000
#define UART_TXDATA (*(volatile uint32_t *)(UART_BASE + 0x0))

main.c

#include <stdint.h>
#include "def.h"

__attribute__ ((aligned (16))) char stack0[4096 * NCPU];

/* 发送单个字符到串口 */
void uart_putchar(char c) {
    UART_TXDATA = c;
}

/* 发送字符串到串口 */
void uart_puts(const char *str) {
    while (*str) {
        uart_putchar(*str++);
    }
}

void start() {
    /* 输出启动消息 */
    uart_puts("===== RISC-V Baremetal Program =====\n");
    uart_puts("Hello World!\n");

    /* 停机循环 */
    while (1) {
        asm volatile("wfi");  /* 等待中断 */
    }
}

linker.ld

OUTPUT_ARCH("riscv")
ENTRY(_entry)

MEMORY {
    RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}

SECTIONS
{
    .text : {
        *(.text.entry)
        *(.text .text.*)
    } > RAM

    .rodata : {
        . = ALIGN(16);
        *(.srodata .srodata.*)
        . = ALIGN(16);
        *(.rodata .rodata.*)
    } > RAM

    .data : {
        . = ALIGN(16);
        *(.sdata .sdata.*)
        . = ALIGN(16);
        *(.data .data.*)
    } > RAM

    .bss : {
        . = ALIGN(16);
        *(.sbss .sbss.*)
        . = ALIGN(16);
        *(.bss .bss.*)
    } > RAM
}

QEMU 启动命令

qemu-system-riscv64 \
    -machine virt \
    -bios none \
    -kernel firmware.elf \
    -m 128M \
    -smp 4 \
    -nographic \
    -monitor telnet:localhost:7106,server,nowait,nodelay