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)是处理器复位后执行的第一段程序。它负责做三件事:
- 设置栈指针(SP)
- 清零BSS段
- 跳转到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);
}
编译步骤:
- 汇编启动代码:
arm-none-eabi-as -o startup.o startup.s - 编译C代码:
arm-none-eabi-gcc -c -o main.o main.c - 链接:
arm-none-eabi-ld -T link.ld -o program.elf startup.o main.o - 生成二进制文件:
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 本章知识结构图
下面这张图展示了裸机调试环境的完整流程:
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。从那以后,我每次换平台都会先确认内存映射。