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 知识体系:虚拟原型搭建的核心逻辑
下面这张图,是我梳理的整个搭建流程的核心逻辑。你照着这个思路走,就不会乱:
这张图把整个流程分成了四步:环境准备 → 编写程序 → 编译链接 → 运行调试。每一步都有对应的工具和知识点。你照着这个框架走,就不会漏掉任何环节。
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!」的输出。如果遇到问题,回头看看避坑指南,大概率能解决。
记住,虚拟原型调试的核心就是:先让最小的系统跑起来,再逐步添加功能。别一上来就想模拟整个产品,那会把自己搞崩溃的。