3. 开发环境搭建:交叉编译工具链、模拟器环境(QEMU)、GDB调试器配置

做嵌入式软件集成测试,环境搭建是第一道坎。很多新手上来就写代码,结果编译不过、跑不起来、调试不了,最后发现是工具链没配好。我个人习惯先把环境理清楚,再动手写桩函数。这一章咱们就把交叉编译工具链、QEMU模拟器和GDB调试器这三样东西一次性搞定。

核心要点:嵌入式开发环境与桌面开发最大的区别在于——你写的代码不是在当前机器上跑的,而是要在目标硬件上运行。所以你需要一套“交叉”工具链,一个能模拟目标硬件的环境,以及一个能远程调试的调试器。

3.1 交叉编译工具链

交叉编译,说白了就是在一台机器(宿主机)上编译出能在另一台机器(目标机)上运行的代码。我刚开始做嵌入式时总觉得这步很麻烦,后来发现只要选对工具链,其实就几条命令的事。

3.1.1 工具链的选择

常见的嵌入式交叉编译工具链有几种:

工具链 适用架构 特点
arm-none-eabi-gcc ARM Cortex-M/R 无操作系统,裸机开发
arm-linux-gnueabihf-gcc ARM Cortex-A 带Linux系统,硬浮点
riscv64-unknown-elf-gcc RISC-V RISC-V裸机/新lib
aarch64-linux-gnu-gcc ARM64 64位ARM Linux

咱们课程以ARM Cortex-M3为例,所以选用 arm-none-eabi-gcc。这个工具链在Ubuntu上安装很简单:

sudo apt-get update
sudo apt-get install gcc-arm-none-eabi
sudo apt-get install gdb-multiarch  # 多架构GDB,后面调试用

安装完后验证一下:

arm-none-eabi-gcc --version
arm-none-eabi-gdb --version

看到版本号就说明装好了。嗯,这里要注意——不同版本的gcc对浮点处理有差异,我建议统一用9.x以上版本,避免一些奇怪的编译问题。

3.1.2 编写第一个交叉编译程序

写一个最简单的C程序,验证工具链是否正常工作:

// test.c
#include <stdint.h>

volatile uint32_t counter = 0;

void delay(void) {
    for (volatile uint32_t i = 0; i < 100000; i++);
}

int main(void) {
    while (1) {
        counter++;
        delay();
    }
    return 0;
}

编译命令:

arm-none-eabi-gcc -c -mcpu=cortex-m3 -mthumb -o test.o test.c

这里几个参数我解释一下:

  • -mcpu=cortex-m3:指定目标CPU为Cortex-M3
  • -mthumb:使用Thumb指令集(Cortex-M只支持Thumb)
  • -c:只编译不链接

我在项目中遇到过有人忘了加 -mthumb,结果编译出来的代码在模拟器里跑不起来,折腾了半天才发现是指令集不匹配。

小技巧:可以用 arm-none-eabi-objdump -d test.o 查看反汇编,确认生成的指令是Thumb还是ARM。如果看到4字节的指令,大概率是ARM模式,Cortex-M3跑不了。

3.2 QEMU模拟器环境

没有真实硬件怎么办?QEMU就是你的救星。它能模拟出完整的ARM Cortex-M3开发板,包括中断控制器、定时器、UART等外设。

3.2.1 QEMU安装与配置

安装QEMU的ARM系统模拟版本:

sudo apt-get install qemu-system-arm

验证安装:

qemu-system-arm --version

QEMU支持多种ARM开发板模型。咱们用 lm3s6965evb 这个板子,它是TI的Cortex-M3开发板,外设齐全,非常适合做集成测试。

3.2.2 编写链接脚本与启动文件

要让编译出来的程序在QEMU上跑,还需要两个东西:链接脚本和启动文件。

链接脚本(link.ld):

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

SECTIONS
{
    .text : {
        *(.isr_vector)
        *(.text*)
        *(.rodata*)
    } > FLASH

    .data : {
        *(.data*)
    } > RAM AT > FLASH

    .bss : {
        *(.bss*)
    } > RAM
}

启动文件(startup.c):

// 最简单的启动文件,只处理复位和NMI
extern int main(void);

void Reset_Handler(void) {
    main();
    while (1);
}

void Default_Handler(void) {
    while (1);
}

// 中断向量表
__attribute__((section(".isr_vector")))
void (* const g_pfnVectors[])(void) = {
    (void (*)(void))0x20010000,  // 栈顶地址
    Reset_Handler,               // 复位向量
    Default_Handler,             // NMI
    Default_Handler,             // HardFault
    // ... 其他中断向量
};

编译并链接:

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -T link.ld -o test.elf startup.c test.c

3.2.3 在QEMU上运行

启动QEMU并加载我们的程序:

qemu-system-arm -M lm3s6965evb -kernel test.elf -nographic

参数说明:

  • -M lm3s6965evb:指定开发板模型
  • -kernel test.elf:加载ELF文件
  • -nographic:不使用图形界面,用终端交互

如果一切正常,QEMU会启动并运行你的程序。虽然这个程序没有输出,但你可以通过后面讲的GDB来验证它是否在跑。

避坑指南:我曾经遇到过QEMU启动后直接退出的情况,后来发现是链接脚本里栈顶地址设错了。Cortex-M3的栈顶地址必须指向RAM的末尾,比如RAM从0x20000000开始、大小64KB,栈顶就是0x20010000。设错了系统一启动就进HardFault。

3.3 GDB调试器配置

光能跑还不够,调试才是关键。GDB配合QEMU可以实现源码级调试,让你像调试桌面程序一样调试嵌入式代码。

3.3.1 启动GDB服务器

QEMU内置了GDB服务器功能,启动时加上 -s 和 -S 参数:

qemu-system-arm -M lm3s6965evb -kernel test.elf -nographic -s -S
  • -s:在端口1234上启动GDB服务器
  • -S:启动时暂停CPU,等待GDB连接

你会看到QEMU启动后停在等待状态,没有任何输出。这时候它就在等GDB连上来。

3.3.2 连接GDB

打开另一个终端,启动GDB:

gdb-multiarch test.elf

在GDB命令行中执行:

(gdb) target remote localhost:1234
(gdb) monitor reset
(gdb) continue

这时候程序就开始跑了。但更常用的做法是设置断点再运行:

(gdb) target remote localhost:1234
(gdb) break main
(gdb) continue
(gdb) step
(gdb) print counter
(gdb) info registers

我个人习惯在调试时先 break main,然后 continue,这样能确保程序从复位向量正常执行到main函数。如果直接 continue,有时候会因为启动代码的问题直接跑飞。

3.3.3 调试技巧与配置

为了让调试更顺手,我建议在用户目录下创建 .gdbinit 文件:

# .gdbinit
set architecture arm
set arm force-mode thumb
target remote localhost:1234
monitor reset
break main
continue

这样每次启动GDB时,它会自动连接QEMU、复位、停在main函数入口。省得每次敲一堆命令。

调试小技巧:用 layout asm 可以同时查看源码和汇编,对理解C代码如何映射到Thumb指令很有帮助。我在调试中断相关问题时,经常用这个命令来确认中断向量表是否正确。

3.4 环境验证:一个完整的测试流程

把上面所有步骤串起来,做一个完整的验证。咱们写一个带UART输出的程序,确保整个链路都通:

// uart_test.c
#include <stdint.h>

// lm3s6965的UART0基地址
#define UART0_BASE 0x4000C000
#define UART0_DR   (*((volatile uint32_t *)(UART0_BASE + 0x000)))
#define UART0_FR   (*((volatile uint32_t *)(UART0_BASE + 0x018)))

void uart_putc(char c) {
    while (UART0_FR & (1 << 5));  // 等待发送缓冲区空
    UART0_DR = c;
}

void uart_puts(const char *s) {
    while (*s) {
        uart_putc(*s++);
    }
}

int main(void) {
    uart_puts("Hello from QEMU!\n");
    while (1);
    return 0;
}

编译、链接、运行:

arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -T link.ld -o uart_test.elf startup.c uart_test.c
qemu-system-arm -M lm3s6965evb -kernel uart_test.elf -nographic -serial mon:stdio

加上 -serial mon:stdio 后,QEMU会把UART输出重定向到终端。你应该能看到:

Hello from QEMU!

看到这行输出,说明你的交叉编译工具链、QEMU模拟器和UART外设全部正常工作。这就是咱们后续做桩函数开发和接口调试的基础环境。

环境验证清单:

  1. 交叉编译器能编译ARM Cortex-M3代码
  2. QEMU能加载并运行ELF文件
  3. GDB能远程连接到QEMU并设置断点
  4. UART输出能正常显示在终端

这四步都过了,环境就算搭好了。后面写桩函数、做集成测试,全在这个环境里跑。

3.5 本章知识体系

下面这张图把本章的核心内容串起来了,方便你对照检查:

开发环境搭建知识体系 开发环境搭建知识体系 交叉编译工具链 QEMU模拟器 GDB调试器 arm-none-eabi-gcc 链接脚本 启动文件 lm3s6965evb UART外设 -serial mon:stdio target remote break/step .gdbinit 三者协同工作:工具链编译 → QEMU运行 → GDB调试 这是嵌入式软件集成测试的基础设施 验证通过:Hello from QEMU!

这张图把三个核心模块的关系讲清楚了。工具链负责编译,QEMU负责运行,GDB负责调试。三者缺一不可,而且必须协同工作。我建议你按照这个结构,一步步把环境搭起来,每个环节都验证通过再往下走。