问题
调试 JIT 尤其是 difftest 的时候,在初期每次都要重新编译,烧录 sd 卡然后启动等待报错,再 debug。这个迭代周期非常多。如果每次都这样做,那么开发周期将会非常漫长,因此在 qemu 中直接跑 JIT 会大大加快开发进度。
显示设备
qemu 一般都带显示设备,但是我们的游戏设计要在 hdmi 这种设备播放,因此最好能找一个支持帧缓冲的 qemu 设备就最好了。好消息是真有这个设备:bochs-display。bochs-display 是 QEMU 内建的虚拟显卡,不需要另外安装驱动或插件。只要你的 qemu-system-loongarch64 编译时包含默认的显示设备(绝大多数发行版包都带),命令里的 -device bochs-display,... 就能直接用。可快速自检:
qemu-system-loongarch64 -device help | grep bochs-display 能看到条目即已内置。
- Guest 侧只需按我们代码里的 MMIO/FBDRAW 驱动访问寄存器,无需额外驱动。
在非 QEMU(真实板卡)路径,io_write(AM_GPU_FBDRAW, xpad, ypad, canvas, SCR_W, SCR_H,true) 走到 am_gpu_fbdraw 的 “#else” 分支,流程是:
- pixels 指向一块连续的 ARGB32 缓冲(NES 画面 256×240)。
- 驱动获得 HDMI 显存基址 fb = hdmi_get_fb_pointer()(分辨率固定 1920×1080,RGB565)。
- 先做边界检查(x/y+w/h 不能越 1920×1080)。
- 逐行把 ARGB32 转换成 RGB565:
- R = (argb >> 8) & 0xF800,G = (argb >> 5) & 0x07E0,B = (argb >> 3) & 0x001F。
- 合成为 16 位写入目标帧缓冲。
- 内联汇编加速内层循环,fb_row 每行步长 1920。
- 如果 sync == true(NES 提交时传的是 true),调用
hdmi_swap_buffers() 执行双缓冲翻转;所以真实硬件是双缓冲,避免撕裂。
- 与 QEMU 路径不同,这里确实需要色彩转换为 RGB565。
QEMU 路径就简单多了:bochs-display 暴露线性 32 位帧缓冲,格式与 pixels 相同(ARGB32),所以直接逐像素拷贝即可,不用做色彩转换或缓冲切换。
- 上层 fce_update_screen() 只调用一次 am_gpu_fbdraw/qemu_fb_blit 生成整帧,拷贝完成后 QEMU/GTK 读取同一块内存展示。
- 没有硬件 VSync,理论上有撕裂风险,但 NES 分辨率低、单次拷贝快,实际肉眼几乎看不到。如果需要更稳,可在 NES 侧做软件双缓冲或用 memcpy 整帧写入以缩短窗口。
程序地址布局
程序能否正确运行,很大程度上取决于链接脚本的正确配置。QEMU 和真实硬件的内存布局有显著差异,因此需要两套不同的链接脚本。
非 QEMU 的内存映像(真实硬件)
真实硬件使用 linker.ld,内存布局如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| 512MB DDR 内存布局: ┌─────────────────────────────────────────────────────────┐ │ 0x00000000 - 0x1CFFFFFF : RAM (464MB) │ │ ├── .text.entry (0x00000000) : 启动代码 │ │ ├── .exception (0x00000040) : 异常/中断入口 │ │ ├── .text : 代码段 │ │ ├── .rodata : 只读数据 │ │ ├── .data : 已初始化数据 │ │ ├── .bss : 未初始化数据 │ │ └── heap : 堆 (向上增长至 0x1CFFFFFF) │ ├─────────────────────────────────────────────────────────┤ │ 0x1D000000 - 0x1EFFFFFF : Stack (32MB, 向下增长) │ ├─────────────────────────────────────────────────────────┤ │ 0x1F000000 - 0x1FFFFFFF : Framebuffer (16MB) │ │ ├── BUFFER_A (0x1F000000) : 双缓冲 A │ │ ├── BUFFER_B (0x1F400000) : 双缓冲 B │ │ └── BUFFER_S (0x1F800000) : Shell 终端缓冲 │ ├─────────────────────────────────────────────────────────┤ │ 0x20000000 - 0x20000FFF : Boot ROM (4KB, BRAM) │ └─────────────────────────────────────────────────────────┘
|
关键配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| /* linker.ld 核心配置 */ MEMORY { RAM (rwx) : ORIGIN = 0x00000000, LENGTH = 464M }
/* 启动代码必须在地址 0x00 */ . = 0x00000000; .text.entry : { KEEP(*(.text.start)) . = 0x40; /* 填充到 0x40,为异常入口预留空间 */ } > RAM
/* 异常入口紧随其后,地址 0x40 */ .exception : { KEEP(*(.exception)) } > RAM
/* 堆和栈区域 */ _heap_start = .; _heap_end = 0x1D000000; _stack_bottom = 0x1D000000; _stack_top = 0x1F000000;
/* Framebuffer 区域 */ _framebuf_base = 0x1F000000; _framebuf_end = 0x20000000;
|
QEMU 版本的内存映像
QEMU virt 机器使用 linker_qemu.ld,内存布局有所不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| QEMU virt 机器内存布局: ┌─────────────────────────────────────────────────────────┐ │ 0x00100000 - 0x00200000 : FDT (设备树,QEMU 自动放置) │ ├─────────────────────────────────────────────────────────┤ │ 0x00200000 - 0x0DFFFFFF : RAM (约 220MB) │ │ ├── .text.entry (0x00200000) : 启动代码 │ │ ├── .exception (ALIGN 64) : 异常/中断入口 │ │ ├── .text : 代码段 │ │ ├── .rodata : 只读数据 │ │ ├── .data : 已初始化数据 │ │ ├── .bss : 未初始化数据 │ │ └── heap : 堆 (向上增长至 0x0E000000) │ ├─────────────────────────────────────────────────────────┤ │ 0x0E000000 - 0x0EFFFFFF : Stack (16MB, 向下增长) │ ├─────────────────────────────────────────────────────────┤ │ 0x0F000000 - 0x0FFFFFFF : Framebuffer (16MB, 内存模拟) │ ├─────────────────────────────────────────────────────────┤ │ 0x1FE001E0 : UART (ns16550a) │ ├─────────────────────────────────────────────────────────┤ │ 0x20000000 : PCI ECAM 配置空间 │ ├─────────────────────────────────────────────────────────┤ │ 0x40000000 : PCI MMIO 空间 (bochs-display) │ └─────────────────────────────────────────────────────────┘
|
关键配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| /* linker_qemu.ld 核心配置 */
/* 程序起始地址必须避开 FDT */ _PROGRAM_START = 0x00200000;
MEMORY { RAM (rwx) : ORIGIN = 0x00200000, LENGTH = 256M }
/* 启动代码从 0x00200000 开始 */ . = _PROGRAM_START; .text.entry : { KEEP(*(.text.start)) } > RAM
/* 异常入口需要 64 字节对齐 */ .exception ALIGN(64) : { KEEP(*(.exception)) } > RAM
/* 堆和栈区域 */ _heap_start = .; _heap_end = 0x0E000000; _stack_bottom = 0x0E000000; _stack_top = 0x0F000000;
/* Framebuffer (QEMU 无 HDMI,用内存模拟) */ _framebuf_base = 0x0F000000; _framebuf_end = 0x10000000;
|
两者对比
| 项目 |
真实硬件 |
QEMU |
| 程序起始地址 |
0x00000000 |
0x00200000 |
| 异常入口地址 |
0x00000040 |
ALIGN(64) |
| 堆结束地址 |
0x1D000000 |
0x0E000000 |
| 栈顶地址 |
0x1F000000 |
0x0F000000 |
| Framebuffer |
硬件 HDMI |
内存模拟 |
| UART 地址 |
0x1FE001E0 |
0x1FE001E0 (相同) |
PS2、中断等
外设驱动是 QEMU 和真实硬件差异最大的部分。需要通过条件编译 #ifdef QEMU_RUN 来区分两种环境。
非 QEMU 情况(真实硬件)
真实硬件上,PS2 键盘通过自定义的 MMIO 接口访问:
1 2 3 4 5 6 7 8 9 10 11 12 13
| #define PS2_BASE 0x1FD0F050 #define PS2_DATA_REG (*(volatile uint32_t *)0x1FD0F050) #define PS2_STATUS_REG (*(volatile uint32_t *)0x1FD0F054) #define PS2_CTRL_REG (*(volatile uint32_t *)0x1FD0F058)
#define PS2_STATUS_VALID (1 << 0) #define PS2_STATUS_PARITY_ERR (1 << 1)
#define PS2_CTRL_ENABLE (1 << 1) #define PS2_CTRL_INT_ENABLE (1 << 2)
|
中断处理流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void ps2_irq_handler(trap_frame_t *tf) { while (bsp_ps2_data_available()) { uint8_t scancode = (uint8_t)bsp_ps2_read(); int next = (kb_head + 1) % KB_BUF_SIZE; if (next != kb_head) { kb_buffer[kb_head] = scancode; kb_head = next; } } }
irq_register(IRQ_PS2, ps2_irq_handler); bsp_ps2_init(1);
|
QEMU 情况
QEMU virt 机器没有 PS2 控制器,因此需要禁用 PS2 相关代码:
1 2 3 4 5 6 7 8 9 10 11
| #ifdef QEMU_RUN
#define PS2_BASE 0x0FD0F050 #define PS2_DATA_REG (*(volatile uint32_t *)0x0FD0F050) #define PS2_STATUS_REG (*(volatile uint32_t *)0x0FD0F054) #else
#define PS2_BASE 0x1FD0F050 ... #endif
|
在 main.c 中跳过 PS2 初始化:
1 2 3 4 5 6 7 8
| static void system_init(void) { #ifndef QEMU_RUN irq_register(IRQ_PS2, ps2_irq_handler); bsp_ps2_init(1); #endif ... }
|
中断系统对比
| 中断源 |
真实硬件 |
QEMU |
| 定时器 (IRQ_TIMER) |
支持 |
支持 |
| PS2 键盘 (IRQ_PS2) |
支持 |
不支持 |
| UART (IRQ_UART) |
支持 |
支持 |
| 软中断 (SWI0) |
支持 |
支持 |
中断初始化代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| void trap_init(void) { for (int i = 0; i < IRQ_MAX; i++) { irq_handlers[i] = default_irq_handler; }
irq_handlers[IRQ_SWI0] = swi0_irq_handler;
uint32_t entry_addr; asm volatile ("la.local %0, trap_entry" : "=r"(entry_addr)); write_csr_eentry(entry_addr);
uint32_t ecfg = ECFG_LIE_SWI0 | ECFG_LIE_HWI1 | ECFG_LIE_HWI2; write_csr_ecfg(ecfg); }
|
启动流程
非 QEMU 版本(真实硬件)
真实硬件的启动流程:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌─────────────────────────────────────────────────────────┐ │ 1. FPGA 上电,从 Boot ROM (0x20000000) 加载启动代码 │ │ └── Boot ROM 将程序从 SD 卡加载到 RAM (0x00000000) │ ├─────────────────────────────────────────────────────────┤ │ 2. 跳转到 _start (0x00000000) │ │ └── start.S: 设置栈指针、清零 BSS、调用 main │ ├─────────────────────────────────────────────────────────┤ │ 3. main() 执行系统初始化 │ │ ├── terminal_init() : HDMI 终端初始化 │ │ ├── heap_init() : 堆内存初始化 │ │ ├── trap_init() : 中断系统初始化 │ │ ├── sched_init() : 调度器初始化 │ │ ├── bsp_ps2_init(1) : PS2 键盘初始化(使能中断) │ │ └── timer_init() : 定时器初始化 │ ├─────────────────────────────────────────────────────────┤ │ 4. 创建 shell 任务,启动定时器,进入调度循环 │ └─────────────────────────────────────────────────────────┘
|
QEMU 版本
QEMU 的启动流程有所不同:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| ┌─────────────────────────────────────────────────────────┐ │ 1. QEMU 加载 ELF 文件到内存 (0x00200000) │ │ └── 使用 -kernel 参数直接加载,无需 Boot ROM │ ├─────────────────────────────────────────────────────────┤ │ 2. 跳转到 _start (0x00200000) │ │ └── start.S: 设置栈指针、清零 BSS、调用 main │ ├─────────────────────────────────────────────────────────┤ │ 3. main() 执行系统初始化(QEMU 模式) │ │ ├── heap_init() : 堆内存初始化 │ │ ├── trap_init() : 中断系统初始化 │ │ ├── sched_init() : 调度器初始化 │ │ ├── qemu_fb_init() : bochs-display 初始化 │ │ └── timer_init() : 定时器初始化 │ │ 注意:跳过 terminal_init() 和 bsp_ps2_init() │ ├─────────────────────────────────────────────────────────┤ │ 4. 创建 shell 任务,启动定时器,进入调度循环 │ └─────────────────────────────────────────────────────────┘
|
启动代码 start.S 是通用的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| /* start.S - 通用启动代码 */ .section .text.start .globl _start
_start: /* 1. 设置栈指针(使用链接脚本定义的 __stack_top) */ la.local $sp, __stack_top
/* 2. 清零 BSS 段 */ la.local $t0, __bss_start la.local $t1, __bss_end .L_clear_bss: beq $t0, $t1, .L_bss_done st.w $zero, $t0, 0 addi.w $t0, $t0, 4 b .L_clear_bss
.L_bss_done: /* 3. 调用 main */ bl main
/* 4. main 返回后死循环 */ .L_halt: b .L_halt
|
条件编译机制
通过 Makefile 传递 QEMU_RUN 宏来区分编译目标:
1 2 3 4 5 6 7 8 9 10 11
| ifdef QEMU_RUN CFLAGS += -DQEMU_RUN endif
ifdef QEMU_RUN LDSCRIPT := $(SCRIPTS_DIR)/linker_qemu.ld else LDSCRIPT := $(SCRIPTS_DIR)/linker.ld endif
|
编译命令:
1 2 3 4 5
| make clean && make
make clean && make QEMU_RUN=1
|
QEMU Framebuffer 驱动详解
bochs-display 是 QEMU 内置的 PCI 显卡设备,需要通过 PCI 配置空间来初始化。
PCI 设备扫描
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| #define PCI_ECAM_BASE 0x20000000 #define BOCHS_VGA_VENDOR 0x1234 #define BOCHS_VGA_DEVICE 0x1111
static inline uint32_t pci_ecam_addr(int bus, int dev, int func, int reg) { return PCI_ECAM_BASE | (bus << 20) | (dev << 15) | (func << 12) | reg; }
static int pci_find_bochs_display(int *out_bus, int *out_dev) { for (int bus = 0; bus < 1; bus++) { for (int dev = 0; dev < 32; dev++) { uint16_t vendor = pci_read16(bus, dev, 0, PCI_VENDOR_ID); uint16_t device = pci_read16(bus, dev, 0, PCI_DEVICE_ID); if (vendor == BOCHS_VGA_VENDOR && device == BOCHS_VGA_DEVICE) { *out_bus = bus; *out_dev = dev; return 0; } } } return -1; }
|
VBE 寄存器配置
bochs-display 使用 VBE (VESA BIOS Extensions) 寄存器来配置显示模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| #define VBE_DISPI_INDEX_XRES 0x01 #define VBE_DISPI_INDEX_YRES 0x02 #define VBE_DISPI_INDEX_BPP 0x03 #define VBE_DISPI_INDEX_ENABLE 0x04
#define VBE_DISPI_ENABLED 0x01 #define VBE_DISPI_LFB_ENABLED 0x40
int qemu_fb_set_resolution(uint16_t width, uint16_t height) { vbe_write(VBE_DISPI_INDEX_ENABLE, VBE_DISPI_DISABLED); vbe_write(VBE_DISPI_INDEX_XRES, width); vbe_write(VBE_DISPI_INDEX_YRES, height); vbe_write(VBE_DISPI_INDEX_BPP, 32); vbe_write(VBE_DISPI_INDEX_ENABLE, VBE_DISPI_ENABLED | VBE_DISPI_LFB_ENABLED);
fb_info.width = width; fb_info.height = height; fb_info.bpp = 32; fb_info.pitch = width * 4; return 0; }
|
Framebuffer 初始化流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| int qemu_fb_init(void) { int bus, dev;
if (pci_find_bochs_display(&bus, &dev) < 0) { printf("[QEMU_FB] bochs-display not found\n"); return -1; }
uint32_t bar0 = pci_read32(bus, dev, 0, PCI_BAR0) & ~0xF; uint32_t bar2 = pci_read32(bus, dev, 0, PCI_BAR2) & ~0xF;
if (bar0 == 0) pci_write32(bus, dev, 0, PCI_BAR0, 0x40000000); if (bar2 == 0) pci_write32(bus, dev, 0, PCI_BAR2, 0x41000000);
uint16_t cmd = pci_read16(bus, dev, 0, PCI_COMMAND); pci_write16(bus, dev, 0, PCI_COMMAND, cmd | 0x03);
qemu_fb_set_resolution(640, 480);
fb_info.initialized = true; return 0; }
|
AM 适配层的 QEMU 分支
am_adapter.c 中通过条件编译区分 QEMU 和真实硬件:
1 2 3 4 5 6 7 8 9 10 11 12
| void am_gpu_fbdraw(int x, int y, uint32_t *pixels, int w, int h, bool sync) { #ifdef QEMU_RUN qemu_fb_blit(x, y, pixels, w, h); (void)sync; #else volatile uint16_t *fb = hdmi_get_fb_pointer(); if (sync) hdmi_swap_buffers(); #endif }
|
QEMU 运行命令
编译完成后,使用以下命令启动 QEMU:
1 2 3 4 5 6 7 8
| qemu-system-loongarch64 \ -M virt \ -m 256M \ -cpu la464 \ -kernel build/xos.elf \ -nographic \ -device bochs-display \ -serial mon:stdio
|
参数说明:
| 参数 |
说明 |
-M virt |
使用 virt 虚拟机器 |
-m 256M |
分配 256MB 内存 |
-cpu la464 |
使用 LA464 CPU 模型 |
-kernel |
直接加载 ELF 文件 |
-nographic |
无图形界面(串口输出) |
-device bochs-display |
添加 bochs 显卡 |
-serial mon:stdio |
串口重定向到终端 |
UART 配置差异
UART 地址在 QEMU 和真实硬件上相同(0x1FE001E0),但时钟频率不同:
1 2 3 4 5 6 7 8
| #ifdef QEMU_RUN #define UART_CLK 100000000 #else #define UART_CLK 75000000 #endif
#define UART_BASE 0x1FE001E0
|
波特率计算:
总结
QEMU 仿真的优势
| 方面 |
真实硬件 |
QEMU 仿真 |
| 编译-测试周期 |
编译→烧录SD卡→上电→等待 |
编译→直接运行 |
| 调试能力 |
有限(UART 打印) |
GDB 远程调试 |
| 迭代速度 |
慢(分钟级) |
快(秒级) |
| 硬件依赖 |
需要 FPGA 板卡 |
仅需 PC |
移植要点清单
- 链接脚本:调整程序起始地址(避开 FDT)
- 外设驱动:条件编译区分 QEMU/硬件
- 显示设备:使用 bochs-display 替代 HDMI
- 中断系统:禁用不支持的外设中断
- 时钟配置:调整 UART 时钟频率
文件结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| scripts/ ├── linker.ld ├── linker_qemu.ld └── common.mk
bsp/ ├── start.S ├── src/ │ ├── uart.c │ ├── ps2.c │ ├── hdmi.c │ └── qemu_fb.c └── include/ ├── uart.h ├── ps2.h └── qemu_fb.h
software/xos_pro_max/ ├── src/ │ ├── main.c │ └── litenes/ │ └── am_adapter.c └── Makefile
|
通过 QEMU 仿真,JIT 和 difftest 的开发调试效率大幅提升,从原来的”编译-烧录-等待-调试”循环缩短为”编译-运行”,极大加快了开发迭代速度。