3、搭建第一个虚拟原型环境:基于QEMU搭建ARM Cortex-M3虚拟平台、编译与运行裸机程序

好,咱们开始动手了。

前面聊了那么多虚拟原型的理念,我知道你心里肯定在想:「别扯虚的,到底怎么搭?」

这一章,我就带你亲手搭一个能跑起来的虚拟平台。咱们选的是ARM Cortex-M3,用QEMU模拟。为什么选M3?因为简单、经典、坑少。我当年第一次接触嵌入式虚拟化,就是从M3入的门,至今还记得那个LED闪烁起来时的心情——嗯,虽然只是模拟的LED,但那种「我控制了一个虚拟世界」的感觉,挺上头的。

本章目标:在PC上搭建QEMU + ARM Cortex-M3环境,编写并运行一个裸机程序,验证虚拟平台正常工作。

3.1 环境准备:你需要什么?

说白了,你只需要三样东西:

  • 一台装了Linux的电脑(Ubuntu 20.04/22.04 我都试过,没问题)
  • QEMU模拟器(带ARM系统模式支持)
  • ARM交叉编译工具链(gcc-arm-none-eabi)

我个人习惯用apt直接装,省事。你如果喜欢从源码编译QEMU,也行,但第一次建议别折腾,先跑起来再说。

# 安装QEMU(包含ARM支持)
sudo apt-get install qemu-system-arm

# 安装ARM交叉编译工具链
sudo apt-get install gcc-arm-none-eabi

# 验证安装
qemu-system-arm --version
arm-none-eabi-gcc --version

看到版本号输出来,环境就齐了。我曾经在某个项目里因为QEMU版本太旧,死活跑不起来Cortex-M3的板子,折腾了两天才发现是版本问题。所以,建议你用2020年之后的版本。

3.2 认识我们的目标板:STM32F103

QEMU内置了对多种ARM开发板的支持。咱们选的是「lm3s6965evb」——这是TI的Cortex-M3评估板。为什么选它?因为QEMU对它的支持最成熟,外设模拟得也最全。

等一下,你可能会问:「我实际项目用的是STM32F103,能用QEMU模拟吗?」

答案是:可以,但需要额外配置。QEMU官方没有直接支持STM32F103,但有社区补丁。咱们先用lm3s6965evb上手,原理通了,换芯片只是改改链接脚本和寄存器地址的事。

小提示:lm3s6965evb和STM32F103都是Cortex-M3内核,指令集完全兼容。区别在于外设地址映射不同。咱们先学内核调试,外设后面再聊。

3.3 编写第一个裸机程序

好,开始写代码。裸机程序,说白了就是没有操作系统,直接跑在硬件上的程序。咱们的目标很简单:让板子上的LED闪烁。

但QEMU模拟的lm3s6965evb没有真实的LED引脚,怎么办?

嗯,这里有个技巧:我们可以用QEMU的「semihosting」特性,把调试信息输出到主机的终端上。或者更简单——直接往UART写数据,QEMU会把UART输出重定向到终端。

我写了一个最小化的启动代码和主程序,你直接拿去用:

// main.c
// 一个简单的Cortex-M3裸机程序
// 功能:通过UART输出"Hello, Virtual World!"

// 内存映射基地址(lm3s6965evb)
#define UART0_BASE 0x4000C000
#define UARTFR     (*(volatile unsigned int *)(UART0_BASE + 0x18))
#define UARTDR     (*(volatile unsigned int *)(UART0_BASE + 0x00))

// 简单延时函数
void delay(volatile int count) {
    while (count--);
}

// UART发送一个字符
void uart_putc(char c) {
    // 等待发送缓冲区空
    while (UARTFR & (1 << 3));
    UARTDR = c;
}

// UART发送字符串
void uart_puts(const char *str) {
    while (*str) {
        uart_putc(*str++);
    }
}

int main(void) {
    uart_puts("Hello, Virtual World!\r\n");
    
    while (1) {
        uart_puts("LED ON\r\n");
        delay(1000000);
        uart_puts("LED OFF\r\n");
        delay(1000000);
    }
    
    return 0;
}

等等,这代码能直接跑吗?

不能。因为还缺一个关键的启动文件——中断向量表和复位处理程序。Cortex-M3上电后,会从0x00000000地址读取栈指针,从0x00000004地址读取复位向量。没有这个,CPU不知道从哪里开始执行。

下面是最小化的启动代码:

// startup.c
// Cortex-M3最小启动代码

// 栈顶地址(我们给分配8KB栈空间)
extern int __stack_top;

// 声明main函数
extern int main(void);

// 中断向量表
__attribute__((section(".vectors")))
void (* const vector_table[])(void) = {
    (void (*)(void)) &__stack_top,  // 0x00: 栈顶指针
    (void (*)(void)) main,          // 0x04: 复位向量
    0, 0, 0, 0, 0, 0, 0, 0,        // 其他中断暂时填0
    0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 0,
};

// 复位处理函数(实际上直接跳转到main)
void Reset_Handler(void) {
    main();
    while (1);
}

注意:中断向量表必须放在链接脚本指定的起始地址。咱们的链接脚本会把.vectors段放在0x00000000。如果你自己写链接脚本,千万别搞错偏移量——我见过有人把复位向量放在0x08,结果CPU一直复位循环,查了两天才发现。

3.4 链接脚本与编译

有了源码,还需要一个链接脚本,告诉链接器代码段、数据段放在哪里。对于Cortex-M3裸机程序,链接脚本长这样:

/* link.ld */
MEMORY
{
    FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 256K
    RAM   (rw) : ORIGIN = 0x20000000, LENGTH = 64K
}

SECTIONS
{
    .vectors : {
        *(.vectors)
    } > FLASH

    .text : {
        *(.text)
        *(.text*)
    } > FLASH

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

    .bss : {
        __bss_start = .;
        *(.bss)
        *(.bss*)
        __bss_end = .;
    } > RAM

    .stack : {
        __stack_top = . + 0x2000;
    } > RAM
}

然后编译:

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -nostartfiles -T link.ld \
    main.c startup.c -o firmware.elf

# 生成二进制文件(QEMU需要)
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin

这里有几个关键参数:

  • -mcpu=cortex-m3:指定CPU为Cortex-M3
  • -mthumb:使用Thumb指令集(Cortex-M3只支持Thumb)
  • -nostartfiles:不使用标准启动文件,我们自己写
  • -T link.ld:使用自定义链接脚本

3.5 运行与调试

编译成功后,用QEMU运行:

qemu-system-arm -M lm3s6965evb -kernel firmware.bin -nographic

你应该会看到终端上不断输出:

Hello, Virtual World!
LED ON
LED OFF
LED ON
LED OFF
...

看到这个输出,恭喜你——你的第一个虚拟原型环境搭建成功了!

如果想调试,可以加上-s -S参数,然后启动GDB连接:

# 终端1:启动QEMU并等待GDB连接
qemu-system-arm -M lm3s6965evb -kernel firmware.elf -nographic -s -S

# 终端2:启动GDB
arm-none-eabi-gdb firmware.elf
(gdb) target remote localhost:1234
(gdb) break main
(gdb) continue

我个人习惯在调试时加上-d in_asm参数,可以实时看到CPU执行的指令流,对理解Cortex-M3的启动过程特别有帮助。

调试技巧:如果程序跑飞了,先检查中断向量表的前两个word是不是对的。用info registers看PC指针,如果PC=0xFFFFFFFF或者PC=0,基本就是向量表的问题。

3.6 知识体系:虚拟原型搭建的核心逻辑

下面这张图,是我梳理的整个搭建流程的核心逻辑。你照着这个思路走,就不会乱:

虚拟原型搭建核心流程 1. 环境准备 QEMU安装 交叉编译工具链 目标板选择 2. 编写裸机程序 3. 编译 & 链接 → QEMU运行 GDB调试

这张图把整个流程分成了四步:环境准备 → 编写程序 → 编译链接 → 运行调试。每一步都有对应的工具和知识点。你照着这个框架走,就不会漏掉任何环节。

3.7 避坑指南

最后,分享几个我踩过的坑:

  • QEMU版本问题:我曾经用Ubuntu 18.04自带的QEMU 2.11,结果lm3s6965evb的UART模拟有bug,输出乱码。升级到QEMU 5.0+就好了。
  • 链接脚本地址错误:有一次我把RAM起始地址写成了0x20001000,结果程序一运行就hard fault。Cortex-M3的RAM必须从0x20000000开始,这是架构规定的。
  • 忘记加-mthumb:Cortex-M3只支持Thumb指令集,如果不加这个参数,编译器会生成ARM指令,CPU执行时直接触发异常。
  • 中断向量表对齐:向量表必须4字节对齐。我见过有人把向量表放在非对齐地址,结果复位后PC指针错乱。

嗯,这一章的内容就到这。你按照上面的步骤操作,应该半小时内就能看到「Hello, Virtual World!」的输出。如果遇到问题,回头看看避坑指南,大概率能解决。

记住,虚拟原型调试的核心就是:先让最小的系统跑起来,再逐步添加功能。别一上来就想模拟整个产品,那会把自己搞崩溃的。


专注资料整理