4. QEMU RISC-V 固件启动汇编与 C 入口衔接

任务说明

本案例在 QEMU RISC-V 32 位虚拟机上配置机器定时器(mtime)中断,并编写对应的中断服务例程(ISR)。目标是通过定时器周期性触发中断,在中断服务例程中翻转 GPIO 输出状态,从而验证完整的中断处理链路:外设事件 → PLIC/CLINT 路由 → CPU 异常入口 → 软件分发 → ISR 执行。

硬件背景与中断路径

RISC-V 特权架构中,机器定时器中断由 CLINT(Core Local Interruptor)模块产生,中断号 7(Machine Timer Interrupt)。当 mtime 寄存器值大于等于 mtimecmp 时,CLINT 向 CPU 发送中断信号。CPU 在以下条件同时满足时响应:mstatus.MIE=1(全局中断使能)、mie.MTIE=1(定时器中断局部使能)、当前特权级为 M 模式且 mstatus.MPIE 允许嵌套。响应后 CPU 跳转到 mtvec 指向的入口,并清除 mstatus.MIE 防止嵌套。

本案例采用直接中断模式(mtvec.MODE=0),所有异常和中断统一跳转到同一入口,由软件读取 mcause 寄存器区分中断来源。定时器中断的 mcause 值为 0x80000007(最高位置 1 表示中断,低 12 位为 7)。

代码实现

以下代码在 QEMU virt 机器上运行,使用 OpenSBI 作为固件,因此 CPU 启动后处于 S 模式。但定时器中断属于 M 模式资源,需要通过 ecall 委托给 S 模式处理。为简化教学,本案例直接使用 M 模式运行裸机代码(通过 -bios none 启动),完整展示从零配置中断的流程。

// timer_isr.c - QEMU RISC-V 定时器中断入门
#include <stdint.h>

// 内存映射寄存器地址(QEMU virt 机器)
#define CLINT_BASE      0x02000000UL
#define MTIME           (*(volatile uint64_t *)(CLINT_BASE + 0xBFF8))
#define MTIMECMP        (*(volatile uint64_t *)(CLINT_BASE + 0x4000))

// GPIO 寄存器(virt 机器 GPIO 控制器)
#define GPIO_BASE       0x10000000UL
#define GPIO_OUTPUT_VAL (*(volatile uint32_t *)(GPIO_BASE + 0x00))
#define GPIO_OUTPUT_EN  (*(volatile uint32_t *)(GPIO_BASE + 0x08))

// 控制状态寄存器访问宏
#define READ_CSR(reg) ({ unsigned long __tmp; \
    __asm__ volatile("csrr %0, " #reg : "=r"(__tmp)); __tmp; })
#define WRITE_CSR(reg, val) \
    __asm__ volatile("csrw " #reg ", %0" :: "r"(val))

// 中断相关位定义
#define MSTATUS_MIE     (1 << 3)   // 全局中断使能
#define MIE_MTIE        (1 << 7)   // 定时器中断使能
#define MCAUSE_TIMER    0x80000007UL // 定时器中断原因

// 定时器频率(QEMU virt 默认 10MHz)
#define TIMER_FREQ      10000000UL
#define TIMER_INTERVAL  (TIMER_FREQ / 10)  // 100ms 中断一次

// 中断标志,供主循环查询
volatile uint32_t g_timer_irq_count = 0;

// 定时器中断服务例程
void timer_isr(void) {
    // 清除中断:将 mtimecmp 设置为当前 mtime + 间隔
    MTIMECMP = MTIME + TIMER_INTERVAL;
    
    // 翻转 GPIO 输出(LED 闪烁效果)
    static uint32_t led_state = 0;
    led_state ^= 1;
    GPIO_OUTPUT_VAL = led_state;
    
    // 记录中断次数
    g_timer_irq_count++;
}

// 通用异常/中断入口
__attribute__((interrupt))
void trap_entry(void) {
    uint32_t mcause = READ_CSR(mcause);
    if ((mcause & 0x80000000UL) && ((mcause & 0xFF) == 7)) {
        // 是定时器中断
        timer_isr();
    } else {
        // 其他异常:死循环等待调试
        while (1);
    }
}

// 初始化定时器中断
void timer_init(void) {
    // 1. 设置中断入口
    WRITE_CSR(mtvec, (uint32_t)trap_entry);
    
    // 2. 设置首次比较值:当前时间 + 间隔
    MTIMECMP = MTIME + TIMER_INTERVAL;
    
    // 3. 使能定时器中断局部使能
    WRITE_CSR(mie, READ_CSR(mie) | MIE_MTIE);
    
    // 4. 全局中断使能
    WRITE_CSR(mstatus, READ_CSR(mstatus) | MSTATUS_MIE);
}

// GPIO 初始化:将 GPIO 0 配置为输出
void gpio_init(void) {
    GPIO_OUTPUT_EN = 0x1;  // 使能 GPIO0 输出
    GPIO_OUTPUT_VAL = 0x0; // 初始输出低电平
}

// 主函数
int main(void) {
    gpio_init();
    timer_init();
    
    // 主循环:等待中断发生
    while (1) {
        // 空循环,实际工作由中断驱动
        __asm__ volatile("wfi");  // 等待中断,降低功耗
    }
    
    return 0; // 不会到达
}

链接脚本与启动文件

由于使用 -bios none 直接运行裸机代码,需要提供链接脚本将代码放置在 QEMU 默认的复位地址 0x80000000,并准备最小启动文件。

// start.S - 最小启动文件
.section .text.init
.globl _start
_start:
    // 设置栈指针(链接脚本定义栈顶)
    la sp, _stack_top
    // 跳转到 C 语言 main
    call main
    // 不应返回,若返回则死循环
1:  j 1b
// link.ld - 链接脚本
OUTPUT_ARCH(riscv)
ENTRY(_start)

SECTIONS
{
    . = 0x80000000;
    .text : {
        *(.text.init)
        *(.text)
    }
    .rodata : { *(.rodata) }
    .data : { *(.data) }
    .bss : { *(.bss) }
    
    . = ALIGN(16);
    . = . + 0x1000;  /* 4KB 栈空间 */
    _stack_top = .;
}

编译与运行

使用 RISC-V 交叉编译工具链编译并运行:

# 编译
riscv32-unknown-elf-gcc -march=rv32im -mabi=ilp32 -nostdlib -ffreestanding \
    -T link.ld -o timer_isr.elf start.S timer_isr.c

# 在 QEMU 中运行(无图形界面,观察 GPIO 输出)
qemu-system-riscv32 -machine virt -bios none -kernel timer_isr.elf \
    -nographic -monitor none -serial stdio

验证方法

本案例的验证通过 QEMU 的 monitor 接口观察 GPIO 寄存器变化。在 QEMU 运行后,按 Ctrl+A 然后按 C 进入 monitor 模式,执行以下命令:

# 查看 GPIO 输出值(应周期性翻转 0x0 与 0x1)
xp /1xw 0x10000000

# 查看 mtimecmp 值(应随时间递增)
xp /1gd 0x02004000

# 查看中断计数(通过内存地址 0x80001000 附近,需根据 ELF 符号确定)

判定标准:

  1. 连续多次执行 xp /1xw 0x10000000,观察到值在 0x00000000 和 0x00000001 之间交替变化,说明定时器中断周期性触发且 ISR 正确翻转 GPIO。
  2. 执行 xp /1gd 0x02004000 两次,第二次值比第一次大,且差值约为 1000000(100ms × 10MHz),证明 mtimecmp 被正确更新。
  3. 程序不崩溃、不进入死循环,说明中断入口和异常分发逻辑正确。
定时器中断处理链路 CLINT mtime 计数器 mtimecmp 比较器 mtime ≥ mtimecmp RISC-V CPU (M模式) mtvec → trap_entry mcause = 0x80000007 mstatus.MIE = 1 mie.MTIE = 1 GPIO 输出寄存器 0x10000000 中断请求 (IRQ7) 跳转 mtvec 翻转输出 软件处理流程 读取 mcause 判断中断号=7 更新 mtimecmp 翻转 GPIO mret 返回主循环 WFI 等待中断 关键寄存器: mtvec=0x80000000 | mcause=0x80000007 | mstatus.MIE=1 | mie.MTIE=1

关键点说明

  • 中断使能顺序:必须先设置 mtvec,再设置 mtimecmp,最后使能 mie 和 mstatus,避免在入口未就绪时中断触发。
  • 中断清除机制:RISC-V 定时器中断没有显式清除操作,通过将 mtimecmp 设置为未来值来“屏蔽”当前中断。若不清除,中断会立即再次触发形成死循环。
  • 中断属性:__attribute__((interrupt)) 告知编译器该函数为中断服务例程,自动保存/恢复上下文并使用 mret 返回。
  • WFI 指令:主循环使用 wfi(Wait For Interrupt)暂停 CPU,中断到来时自动唤醒,降低无效功耗。