2. 裸机工程结构:创建最小工程目录、链接脚本详解、启动代码分析、Makefile编写、编译与烧录验证
好,咱们正式开始动手了。
上一章我们把开发环境搭好了,板子也能通电了。但说实话,光有个环境啥也干不了。这一章我们要做的,就是搭建一个最小可用的裸机工程。说白了,就是让芯片上电后,能跑我们写的第一行代码。
我刚开始学全志芯片时,最头疼的就是工程结构。官方SDK动辄几个G,里面一堆看不懂的文件夹。后来我悟了——裸机开发,其实只需要四个文件:启动代码、链接脚本、主程序、Makefile。今天就带你把这四个东西搞明白。
2.1 创建最小工程目录
我个人习惯把工程组织得很干净。你想想看,如果文件乱放,等工程大了你自己都找不到东西。这里我推荐一个极简结构:
f1c200s_baremetal/
├── start.S # 启动代码,汇编写的
├── main.c # C语言主程序
├── f1c200s.lds # 链接脚本,告诉编译器怎么安排内存
└── Makefile # 一键编译
就这四个文件?对,就这四个。别觉得少,一个完整的裸机程序,核心骨架就是这些。等后面我们要加驱动、加库,再慢慢扩充目录结构。
build/ 文件夹,专门放编译产生的 .o 文件和 .bin 文件。这样源码目录始终干干净净。
2.2 链接脚本(.lds)详解
链接脚本这东西,很多初学者直接跳过。但我得说一句:不懂链接脚本,你就不算真正懂嵌入式。
链接脚本的作用是什么?简单讲,就是告诉链接器:你的代码应该放在内存的哪个位置,数据又该放哪里。
F1C200s 的内存布局是这样的:芯片内部有 64MB 的 DDR2 内存,地址从 0x80000000 开始。我们的程序烧录到 SPI Flash 里,上电后由 BROM(芯片内部固件)把程序拷贝到 DDR 的 0x80000000 处执行。
来看我写的这个链接脚本:
/* f1c200s.lds */
OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x80000000;
.text : {
*(.text)
*(.text.*)
}
.rodata : {
*(.rodata)
*(.rodata.*)
}
.data : {
*(.data)
*(.data.*)
}
.bss : {
__bss_start = .;
*(.bss)
*(.bss.*)
*(COMMON)
__bss_end = .;
}
}
我来逐行解释一下:
- OUTPUT_FORMAT / OUTPUT_ARCH:指定输出格式为 ARM 小端模式。F1C200s 是 ARM926EJ-S 内核,用这个没错。
- ENTRY(_start):告诉链接器,程序的入口点是
_start这个符号。这个符号我们在 start.S 里定义。 - . = 0x80000000:设置当前地址为 0x80000000。意思就是:接下来的代码都从这个地址开始放。
- .text 段:放代码。所有 .c 和 .S 文件编译出来的指令都放这里。
- .rodata 段:放只读数据,比如字符串常量。
- .data 段:放已初始化的全局变量。
- .bss 段:放未初始化的全局变量。注意我定义了
__bss_start和__bss_end,这两个符号在启动代码里要用到——用来清零 BSS 段。
2.3 启动代码(start.S)分析
启动代码,说白了就是芯片上电后执行的第一段程序。它用汇编写的,因为这时候 C 语言环境还没准备好。
启动代码要干三件事:
- 设置栈指针(SP),这样才能调用 C 函数
- 清零 BSS 段
- 跳转到 main() 函数
来看代码:
/* start.S */
.global _start
_start:
/* 设置栈指针 */
ldr sp, =0x81000000
/* 清零 BSS 段 */
ldr r0, =__bss_start
ldr r1, =__bss_end
mov r2, #0
bss_loop:
cmp r0, r1
bge bss_done
str r2, [r0], #4
b bss_loop
bss_done:
/* 跳转到 main() */
bl main
/* 如果 main() 返回,就死循环 */
halt:
b halt
逐行分析:
- ldr sp, =0x81000000:把栈指针设在 0x81000000。为什么是这个地址?因为我们的代码从 0x80000000 开始,DDR 总共 64MB(0x80000000 ~ 0x83FFFFFF)。我把栈顶放在 0x81000000,也就是偏移了 16MB 的位置,给栈留了 48MB 的空间,绰绰有余。
- 清零 BSS 段:从 __bss_start 到 __bss_end,每个字(4字节)写 0。这是 C 语言标准要求的——未初始化的全局变量必须为 0。
- bl main:跳转到 main() 函数。注意用的是 bl(带链接的跳转),这样 main() 返回后还能回到这里。
- halt 循环:如果 main() 返回了,就死循环在这里。嵌入式程序一般不会让 main() 返回。
2.4 Makefile 编写
Makefile 就是我们的编译脚本。每次敲一长串 gcc 命令太累了,用 Makefile 一键搞定。
# Makefile
CROSS_COMPILE = arm-linux-gnueabi-
CC = $(CROSS_COMPILE)gcc
LD = $(CROSS_COMPILE)ld
OBJCOPY = $(CROSS_COMPILE)objcopy
TARGET = f1c200s_demo
LDS = f1c200s.lds
all: $(TARGET).bin
$(TARGET).bin: $(TARGET).elf
$(OBJCOPY) -O binary $< $@
$(TARGET).elf: start.o main.o
$(LD) -T $(LDS) -o $@ $^
start.o: start.S
$(CC) -c -o $@ $<
main.o: main.c
$(CC) -c -o $@ $<
clean:
rm -f *.o *.elf *.bin
.PHONY: all clean
解释一下关键点:
- CROSS_COMPILE:交叉编译工具链的前缀。我们用的是
arm-linux-gnueabi-,这个在第一章已经装好了。 - 编译流程:.S 和 .c 文件先编译成 .o 文件,然后链接成 .elf 文件,最后用 objcopy 转换成纯二进制 .bin 文件。
- -T $(LDS):指定链接脚本。没有这个参数,链接器会用默认的脚本,那程序就跑不起来了。
- clean:清理编译产物。我习惯每次重新编译前先 make clean。
sudo apt install libc6-i386。我当初在 Ubuntu 64 位上折腾了半天才发现是这个原因。
2.5 编译与烧录验证
好,文件都准备好了,我们来编译一下:
$ make
arm-linux-gnueabi-gcc -c -o start.o start.S
arm-linux-gnueabi-gcc -c -o main.o main.c
arm-linux-gnueabi-ld -T f1c200s.lds -o f1c200s_demo.elf start.o main.o
arm-linux-gnueabi-objcopy -O binary f1c200s_demo.elf f1c200s_demo.bin
没有报错的话,你会得到一个 f1c200s_demo.bin 文件。这就是我们要烧录的二进制文件。
烧录到 SPI Flash 的命令:
$ sunxi-fel -p spiflash-write 0x00000000 f1c200s_demo.bin
这个命令用到了我们第一章装的 sunxi-fel 工具。它的意思是:通过 FEL 模式,把 bin 文件写到 SPI Flash 的 0x00000000 地址(也就是 Flash 的开头)。
烧录完成后,断开 USB 线,重新上电。如果一切正常,板子就会运行我们的程序了。
2.6 本章小结
这一章我们干了四件事:
- 搭建了最小工程目录,就四个文件
- 写了链接脚本,把代码安排在 0x80000000
- 写了启动代码,设置栈、清 BSS、跳 main
- 写了 Makefile,一键编译生成 bin 文件
你现在已经拥有了一个可用的裸机工程模板。后面每一章,我们都会在这个模板上添加新功能。所以这一章的内容,我建议你反复理解,尤其是链接脚本和启动代码——它们是整个裸机开发的基石。
下一章,我们要点亮板子上的第一个 LED。嗯,这才是嵌入式开发的真正开始。