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 符号确定)
判定标准:
- 连续多次执行
xp /1xw 0x10000000,观察到值在0x00000000和0x00000001之间交替变化,说明定时器中断周期性触发且 ISR 正确翻转 GPIO。 - 执行
xp /1gd 0x02004000两次,第二次值比第一次大,且差值约为 1000000(100ms × 10MHz),证明 mtimecmp 被正确更新。 - 程序不崩溃、不进入死循环,说明中断入口和异常分发逻辑正确。
关键点说明
- 中断使能顺序:必须先设置
mtvec,再设置mtimecmp,最后使能mie和mstatus,避免在入口未就绪时中断触发。 - 中断清除机制:RISC-V 定时器中断没有显式清除操作,通过将
mtimecmp设置为未来值来“屏蔽”当前中断。若不清除,中断会立即再次触发形成死循环。 - 中断属性:
__attribute__((interrupt))告知编译器该函数为中断服务例程,自动保存/恢复上下文并使用mret返回。 - WFI 指令:主循环使用
wfi(Wait For Interrupt)暂停 CPU,中断到来时自动唤醒,降低无效功耗。