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 段。
我曾经踩过的坑: 第一次写链接脚本时,忘了定义 __bss_start 和 __bss_end。结果全局变量默认值全是乱的,程序跑起来各种诡异。排查了两天才发现是 BSS 段没清零。嗯,这个教训很深刻。

2.3 启动代码(start.S)分析

启动代码,说白了就是芯片上电后执行的第一段程序。它用汇编写的,因为这时候 C 语言环境还没准备好。

启动代码要干三件事:

  1. 设置栈指针(SP),这样才能调用 C 函数
  2. 清零 BSS 段
  3. 跳转到 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() 返回。
一个小细节: 栈指针的设置其实有讲究。我习惯把栈放在 DDR 的高地址区域,这样栈向下增长时不会踩到代码段。如果你把栈设得太低,递归调用深了就可能把代码覆盖掉——我见过有人这么干过,结果程序跑着跑着就飞了。

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。
注意: 如果你的系统是 64 位的,可能需要安装 32 位库支持。否则链接时会报错。命令是: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 线,重新上电。如果一切正常,板子就会运行我们的程序了。

验证小技巧: 第一次跑裸机程序,怎么知道它有没有运行?最简单的方法是在 main() 里写一个循环,让某个 GPIO 引脚翻转,然后用示波器或者逻辑分析仪看波形。如果没有仪器,也可以让 LED 灯闪烁——前提是你板子上有 LED 且接好了 GPIO。

2.6 本章小结

这一章我们干了四件事:

  • 搭建了最小工程目录,就四个文件
  • 写了链接脚本,把代码安排在 0x80000000
  • 写了启动代码,设置栈、清 BSS、跳 main
  • 写了 Makefile,一键编译生成 bin 文件

你现在已经拥有了一个可用的裸机工程模板。后面每一章,我们都会在这个模板上添加新功能。所以这一章的内容,我建议你反复理解,尤其是链接脚本和启动代码——它们是整个裸机开发的基石。

下一章,我们要点亮板子上的第一个 LED。嗯,这才是嵌入式开发的真正开始。