2. ARMv7架构基础
说实话,很多初学者一上来就急着写代码,结果卡在环境搭建上。我见过太多人花了一周时间,就为了把第一个LED点亮——不是代码写不对,而是工具链没配好。这一章,咱们就把这些基础打牢。
为什么需要交叉编译工具链?
你想想看,你的电脑是x86架构,而ARM芯片是ARM架构。x86的指令,ARM不认识。反过来也一样。所以我们需要一套工具,在x86电脑上生成ARM能执行的机器码。这就是交叉编译工具链。
说白了,就是「在A平台上编译,在B平台上运行」。我刚开始做嵌入式时,总觉得这步很麻烦,后来发现,没有它,你连个空程序都跑不起来。
核心概念:交叉编译工具链 = 编译器 + 汇编器 + 链接器 + 调试器 + 库文件。对于ARMv7裸机开发,我们最常用的是 arm-none-eabi- 系列。
安装交叉编译工具链
我个人习惯用 arm-none-eabi-gcc,因为它专门针对裸机(无操作系统)环境。安装方式很简单,我列几种常见方法:
| 方法 | 命令/步骤 | 适用场景 |
|---|---|---|
| apt安装(Ubuntu/Debian) | sudo apt install gcc-arm-none-eabi |
快速上手,版本可能较旧 |
| ARM官网下载 | 下载 gcc-arm-none-eabi-*.tar.bz2 并解压 |
需要最新版本,手动配置PATH |
| 包管理器(macOS) | brew install arm-none-eabi-gcc |
macOS用户推荐 |
安装完成后,验证一下:
arm-none-eabi-gcc --version
arm-none-eabi-objdump --version
arm-none-eabi-objcopy --version
如果能看到版本号,恭喜你,第一步走通了。
小技巧:我曾经在Windows上折腾过一整天,最后发现用WSL(Windows Subsystem for Linux)最省心。直接在WSL里装Ubuntu,然后apt安装,跟Linux环境一模一样。
Makefile编写入门
裸机开发不像IDE那样点一下按钮就编译。你需要自己写Makefile。别怕,其实核心就几行。
一个最简单的Makefile长这样:
# 编译器
CC = arm-none-eabi-gcc
# 编译选项
CFLAGS = -mcpu=cortex-a7 -mthumb -Wall -O0 -g
# 链接选项
LDFLAGS = -T link.lds -nostartfiles -nostdlib
# 源文件
SRCS = main.c startup.c
# 目标文件
OBJS = $(SRCS:.c=.o)
# 最终输出
TARGET = main.elf
all: $(TARGET)
$(TARGET): $(OBJS)
$(CC) $(LDFLAGS) -o $@ $^
%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<
clean:
rm -f $(OBJS) $(TARGET)
嗯,这里要注意几个关键点:
-mcpu=cortex-a7:指定CPU型号。你用的芯片是什么,就写什么。我见过有人用cortex-m3的芯片,却写了cortex-a7的参数,结果编译出来的代码跑不起来。-mthumb:使用Thumb指令集。ARMv7支持ARM和Thumb两种模式,裸机开发通常用Thumb,代码密度更高。-nostartfiles -nostdlib:裸机没有操作系统,所以不需要标准启动文件和库。这两个选项告诉编译器:别给我加那些东西,我自己来。-T link.lds:指定链接脚本。这个我们马上讲。
避坑指南:我曾经犯过一个低级错误——忘了加 -nostartfiles。结果编译器自动链接了glibc的启动代码,而裸机环境下根本没有操作系统来加载这些代码,程序直接跑飞。排查了整整两天才找到原因。
链接脚本(.lds)解析
链接脚本,说白了就是告诉链接器:你的代码该放哪,数据该放哪。对于裸机开发,这步至关重要。因为芯片上电后,从哪个地址开始执行,完全由链接脚本决定。
一个典型的ARMv7裸机链接脚本:
OUTPUT_FORMAT("elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text :
{
*(.isr_vector) /* 中断向量表 */
*(.text) /* 代码段 */
*(.rodata) /* 只读数据 */
_etext = .;
} > FLASH
.data :
{
_sdata = .;
*(.data)
_edata = .;
} > RAM AT > FLASH
.bss :
{
_sbss = .;
*(.bss)
_ebss = .;
} > RAM
}
我来拆解一下:
ENTRY(_start):指定程序入口。芯片上电后,从_start开始执行。这个符号通常在启动文件(startup.s)里定义。MEMORY:定义芯片的物理内存布局。FLASH从0x08000000开始,大小512KB;RAM从0x20000000开始,大小128KB。这些地址要查芯片手册,不同芯片不一样。SECTIONS:定义各个段放在哪个内存区域。.text:代码段,放在FLASH里。因为代码是只读的,不需要修改。.data:已初始化的全局变量。注意写法:> RAM AT > FLASH。意思是运行时在RAM里,但初始值存储在FLASH里。上电后需要从FLASH拷贝到RAM。.bss:未初始化的全局变量。运行时在RAM里,上电后需要清零。
关键点:链接脚本里定义的符号(如 _sdata、_edata、_sbss、_ebss)可以在C代码中直接引用。启动文件就是靠这些符号来完成数据拷贝和BSS清零的。
知识结构总览
我把这一章的核心逻辑画成了一张图,方便你理解整个流程:
实战:从零编译一个最小程序
光说不练假把式。咱们来写一个最小的裸机程序,验证环境是否配好了。
第一步:写启动文件 startup.s
.global _start
_start:
/* 设置栈指针 */
ldr sp, =0x20010000
/* 跳转到main函数 */
bl main
/* 死循环 */
loop:
b loop
第二步:写主函数 main.c
void main(void)
{
/* 这里先什么都不做,后面章节再点亮LED */
while(1);
}
第三步:写链接脚本 link.lds(就用上面那个)
第四步:写Makefile(也用上面那个)
第五步:编译
make
如果一切顺利,你会看到 main.elf 文件生成。用 arm-none-eabi-objdump -d main.elf 可以反汇编查看生成的机器码。
验证小技巧:用 arm-none-eabi-size main.elf 查看各段大小。如果 .text 段只有几十个字节,说明编译成功了。如果 .text 段有几千字节,八成是忘了加 -nostartfiles,编译器把glibc的启动代码链接进来了。
常见问题与避坑
- 问题:编译时报错 "undefined reference to `_start'"
原因:链接脚本里指定了ENTRY(_start),但启动文件里没有定义_start符号。检查 startup.s 里有没有.global _start。 - 问题:编译时报错 "cannot find -lgcc"
原因:某些情况下需要链接libgcc。在LDFLAGS里加上-lgcc即可。 - 问题:程序烧录后不运行
原因:检查链接脚本里的内存地址是否与芯片手册一致。我遇到过有人把FLASH地址写成了0x00000000,结果芯片根本不认。
我曾经踩过的坑:有一次我写链接脚本,把RAM的起始地址写错了,结果程序一运行就访问了非法地址,触发HardFault。查了三天,最后用调试器单步跟踪才发现是地址问题。从那以后,我每次写链接脚本都会反复核对芯片手册。