4. 裸机调试环境搭建:链接脚本与启动代码分析、QEMU模拟器搭建ARMv7调试环境、裸机程序编译与下载

好,咱们进入第四章。这一章是动手前的最后一步准备工作。说白了,就是要把你的电脑变成一个能跑ARMv7裸机程序的“虚拟开发板”。

我见过不少工程师,拿到一块新板子,第一件事就是往里头烧程序。结果呢?灯不亮、串口没输出,连是代码问题还是硬件问题都分不清。裸机调试环境,就是帮你把“变量”降到最低——先让代码在模拟器里跑通,再上真实硬件。

4.1 链接脚本:告诉编译器你的程序怎么摆

链接脚本(Linker Script)是编译器生成最终可执行文件的“布局图”。它决定了代码段、数据段、堆栈段分别放在内存的哪个位置。

ARMv7的裸机程序,通常需要自己定义内存布局。因为你的程序可能跑在SRAM里,也可能跑在Flash里,甚至可能从某个特定地址启动。

核心概念:链接脚本控制的是.text(代码)、.rodata(只读数据)、.data(已初始化全局变量)、.bss(未初始化全局变量)的存放位置。

下面是一个典型的ARMv7裸机链接脚本,我习惯把它命名为link.ld:

ENTRY(_start)

SECTIONS
{
    . = 0x00000000;  /* 起始地址,QEMU的ARMv7从0x0启动 */

    .text : {
        *(.text._start)   /* 启动代码必须放在最前面 */
        *(.text*)
    }

    .rodata : {
        *(.rodata*)
    }

    .data : {
        *(.data*)
    }

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

    .stack : {
        . = ALIGN(8);
        . += 0x10000;     /* 64KB栈空间 */
        __stack_top = .;
    }
}

这里有几个关键点:

  • ENTRY(_start):告诉链接器,程序的入口是_start标签。
  • . = 0x00000000:设置当前地址为0x0。QEMU模拟的ARMv7处理器,上电后从0x0开始取指。
  • __bss_start / __bss_end:这两个符号在启动代码里要用到,用来清零BSS段。
  • __stack_top:栈顶地址,启动代码里设置栈指针时会用到。

我的习惯:我会在链接脚本里把栈空间单独划出来,而不是放在BSS段里。这样调试时看内存布局更清晰,栈溢出也更容易发现。

4.2 启动代码:从复位到main()的“隐形桥梁”

启动代码(Startup Code)是处理器复位后执行的第一段程序。它负责做三件事:

  1. 设置栈指针(SP)
  2. 清零BSS段
  3. 跳转到main()函数

ARMv7的启动代码通常用汇编写。为什么?因为C语言依赖栈,而栈还没设置好。这是个典型的“先有鸡还是先有蛋”的问题。

下面是我常用的启动代码,保存为startup.s:

.global _start
_start:
    /* 设置栈指针 */
    ldr sp, =__stack_top

    /* 清零BSS段 */
    ldr r0, =__bss_start
    ldr r1, =__bss_end
    mov r2, #0
bss_loop:
    cmp r0, r1
    bhs bss_done
    str r2, [r0], #4
    b bss_loop
bss_done:

    /* 跳转到main() */
    bl main

    /* 如果main()返回,死循环 */
halt:
    b halt

这段代码的逻辑很简单:

  • ldr sp, =__stack_top:把栈指针指向我们之前定义的栈顶。
  • 清零BSS段:从__bss_start到__bss_end,每个字(4字节)写0。
  • bl main:跳转到C语言的main()函数。
  • halt:如果main()返回了,就死循环。裸机程序没有操作系统来回收资源。

我曾经踩过的坑:有一次我忘了在启动代码里设置栈指针,结果main()函数里第一条指令就触发了异常。调试了半天才发现是SP指向了0x0。从那以后,我每次写启动代码都会先检查SP的值。

4.3 QEMU模拟器:你的免费ARMv7开发板

QEMU是一个开源的模拟器,可以模拟多种处理器架构。对于ARMv7,QEMU提供了qemu-system-arm这个工具。

我选择QEMU的原因很简单:

  • 免费:不需要买开发板就能开始调试。
  • 可调试:QEMU内置GDB服务器,可以单步调试。
  • 可重现:每次运行结果都一样,不会因为硬件差异出问题。

安装QEMU(以Ubuntu为例):

sudo apt-get install qemu-system-arm

验证安装:

qemu-system-arm --version

QEMU支持的ARMv7开发板有很多,比如vexpress-a9、realview-pbx-a9。我个人习惯用vexpress-a9,因为它内存布局简单,调试起来方便。

4.4 裸机程序编译与下载

现在我们把所有东西串起来。假设你有一个简单的main.c:

void main(void)
{
    volatile unsigned int *uart = (unsigned int *)0x10009000;
    *uart = 'H';
    *uart = 'e';
    *uart = 'l';
    *uart = 'l';
    *uart = 'o';
    while(1);
}

编译步骤:

  1. 汇编启动代码:arm-none-eabi-as -o startup.o startup.s
  2. 编译C代码:arm-none-eabi-gcc -c -o main.o main.c
  3. 链接:arm-none-eabi-ld -T link.ld -o program.elf startup.o main.o
  4. 生成二进制文件:arm-none-eabi-objcopy -O binary program.elf program.bin

在QEMU上运行:

qemu-system-arm -M vexpress-a9 -kernel program.bin -nographic

这里-M vexpress-a9指定开发板型号,-kernel program.bin指定要运行的二进制文件,-nographic表示不使用图形界面。

调试模式:如果想用GDB调试,加上-s -S参数。QEMU会启动一个GDB服务器,监听在端口1234上。

qemu-system-arm -M vexpress-a9 -kernel program.bin -nographic -s -S

然后在另一个终端启动GDB:

arm-none-eabi-gdb program.elf
(gdb) target remote localhost:1234
(gdb) break main
(gdb) continue

4.5 本章知识结构图

下面这张图展示了裸机调试环境的完整流程:

裸机调试环境搭建流程 裸机调试环境搭建流程 链接脚本 link.ld 启动代码 startup.s C程序 main.c 汇编 arm-none-eabi-as 编译 arm-none-eabi-gcc -c 链接 arm-none-eabi-ld -T link.ld 生成二进制 arm-none-eabi-objcopy QEMU运行 qemu-system-arm -M vexpress-a9 -kernel program.bin

4.6 常见问题与避坑指南

问题 原因 解决方法
QEMU启动后无输出 串口地址配置错误 检查vexpress-a9的UART基地址是否为0x10009000
程序跑飞 栈指针未正确设置 在启动代码中检查ldr sp, =__stack_top
BSS段未清零 链接脚本中__bss_start和__bss_end符号未定义 在链接脚本中显式声明这两个符号
GDB无法连接 QEMU未以-s -S参数启动 加上-s -S参数,并检查端口1234是否被占用

我曾经踩过的坑:有一次我编译了一个程序,在QEMU上跑得好好的,但烧到真实开发板上就死机。后来发现是链接脚本里的起始地址写死了0x0,而真实开发板的Flash起始地址是0x08000000。从那以后,我每次换平台都会先确认内存映射。