芯外拾遗第二篇:编译、工具链、烧录,你真的搞懂了吗?

引言

不知不觉正式学习嵌入式已经两年了。但是正式学习uboot的时候发现自己对于编译器、工具链这些内容只是懂了个大概,雾里看花,朦胧不清。

于是想要重新深入了解一下这个过程, 相信这种情况不止我一人,于是写一篇文章补上这些知识点。

第一章:开始之前 - 为什么需要这些工具

1.CPU能识别什么

RK3588S芯片的核心是ARM Cortex-A76和A55处理器。这些处理器内部是由晶体管组成的数字电路。

数字电路只有两种状态:高电平和低电平,对应二进制的1和0。

处理器的工作方式是:

  1. 从特定位置读取一串二进制数字
  2. 根据这串数字的特定位模式,激活内部对应的电路
  3. 电路执行相应操作(比如两个数相加、把数据写入内存等)
  4. 读取下一串二进制数字,重复这个过程

这一串串的二进制数字就是机器指令。每条指令都是固定长度的二进制编码。

ARM64架构规定每条指令占32位(4字节)。例如:

  • 0xD2800000 这个32位数字代表"把0加载到X0寄存器"
  • 0x91000400 这个32位数字代表"X0寄存器的值加上0x10"

CPU只能直接识别和执行这种固定格式的二进制编码,无法识别其他任何形式的内容。


2.C代码是什么形式

U-Boot源代码是文本文件,内容是这样的:

c 复制代码
int add(int a, int b) {
    return a + b;
}

这是ASCII编码的字符序列。字符'i'的ASCII码是0x69,字符'n'的ASCII码是0x6E,字符't'的ASCII码是0x74。

这些ASCII编码对CPU来说毫无意义。CPU不理解"int"、"return"这些词汇,也不理解C语言的语法规则。

把这个文本文件直接拷贝到芯片Flash里,CPU读取到的是一串表示字符的编码,而不是可执行的指令编码。CPU会把它们当作指令尝试执行,但因为格式不符合指令规范,会导致非法指令异常。


3.需要什么样的转换

现在有两个东西:

  1. C源代码:人类能理解的文本,表达了程序逻辑
  2. 机器指令:CPU能执行的二进制编码

需要建立从1到2的转换过程。

这个转换必须完成以下任务:

任务1:语法分析和语义理解

理解int a表示定义一个整数变量,a + b表示加法运算,return表示返回结果。

任务2:指令选择

把加法操作a + b转换成对应的ARM指令。需要知道ARM指令集中哪条指令能完成加法,编码是什么。

任务3:寄存器分配

变量ab需要存储在寄存器中,要决定用哪个寄存器。

任务4:生成二进制编码

根据选定的指令和操作数,生成最终的32位二进制编码。

这个完整的转换过程需要一个专门的程序来完成。
这个程序就是编译器


4.转换过程不是一步完成的

假设有3个C文件:main.cserial.ctimer.c

如果让编译器一次性处理所有文件并直接生成最终可执行的二进制代码,会遇到问题:

问题1: main.c中调用了serial.c中的函数uart_puts()。编译器生成函数调用指令时,指令编码中必须包含目标函数的地址。但是:

  • 编译main.c时,只看到这一个文件,不知道uart_puts()的代码会被放在内存的什么位置
  • 即使已经编译完serial.c,也只知道函数在该文件内的相对位置
  • 只有当所有文件的代码拼接在一起后,才能计算出每个函数在最终程序中的绝对地址

问题2: 如果修改了serial.c,必须重新编译所有文件。大型项目有上千个文件,每次改动一个文件就全部重新编译,耗时太长。

解决方案:分阶段转换

阶段1: 单独处理每个C文件,生成中间形式的文件。这个中间文件已经包含了机器指令,但函数调用指令中的目标地址是空白的,同时做一个标记"这里需要填入某个函数的地址"。这个中间文件称为目标文件,扩展名.o

阶段2: 所有目标文件都生成后,启动另一个程序,读取所有.o文件,把它们的代码按顺序拼接,计算每个函数在拼接后的最终地址,然后根据之前的标记把正确的地址填入空白处,组装成完整的可执行程序。这个程序称为链接器

这样修改serial.c后,只需要重新编译这一个文件生成新的serial.o,然后重新链接即可,其他.o文件不需要重新生成。


5.可执行文件还不能直接烧写

链接器生成的可执行文件包含:

  • 机器指令代码
  • 程序用到的常量数据
  • 调试信息(函数名、变量名、源代码行号对应关系)
  • 符号表(记录每个函数和变量的名字和地址)
  • 文件头信息(描述这个文件的格式、入口地址等)

这些内容按照特定格式组织成一个文件。Linux系统上这种格式称为ELF。

但芯片上电启动时:

  1. CPU从固定地址(比如0x0000)开始读取
  2. 直接把读到的内容当作指令执行
  3. CPU不理解ELF格式,不会解析文件头,不会跳过调试信息

如果把ELF文件直接烧写到Flash,CPU读到的第一个字节是ELF文件头的魔数0x7F,会被当作指令执行,导致错误。

需要从ELF文件中提取纯机器指令和数据部分,去掉所有额外信息,生成纯二进制文件。这个提取操作由工具objcopy完成。


6.完整的工具链

现在梳理需要的工具:

1. 编译器((cc1))

把C代码转换成汇编代码。

2. 汇编器(as)

处理汇编语言文件(有些启动代码直接用汇编写),生成目标文件.o。编译器内部也会调用汇编器。

3. 链接器(ld)

读取所有.o文件,计算地址,生成ELF格式的可执行文件。

4. 格式转换工具(objcopy)

从ELF文件提取纯二进制机器码,生成.bin文件用于烧写。

5. 分析工具(objdump、readelf)

读取ELF或.o文件,显示内部信息,用于检查编译结果是否正确、调试问题。

这一组工具合在一起称为工具链。

RK3588S需要的工具链名称前缀是aarch64-linux-gnu-

  • aarch64-linux-gnu-gcc:编译器
  • aarch64-linux-gnu-as:汇编器
  • aarch64-linux-gnu-ld:链接器
  • aarch64-linux-gnu-objcopy:格式转换工具
  • aarch64-linux-gnu-objdump:反汇编和分析工具

7.本章总结

需要这些工具的根本原因:CPU只能执行特定格式的二进制机器指令,C源代码无法直接被CPU识别。

转换过程分多个阶段,每个阶段需要专门的工具:

  1. C代码→目标文件(编译器)
  2. 目标文件→ELF可执行文件(链接器)
  3. ELF文件→纯二进制镜像(objcopy)
  4. 二进制镜像→烧写到Flash→CPU执行

第二章:准备工具链 - 交叉编译环境的构建

1.编译器本身也是程序

编译器的作用是把C代码转换成机器指令。但编译器本身也是一个程序,它也需要在某个CPU上运行。

你的开发电脑用的是x86处理器(Intel或AMD)。如果在电脑上安装Ubuntu系统,系统自带的编译器gcc是一个x86程序。这个编译器运行时:

  • CPU从内存读取编译器的指令(x86指令)
  • 执行这些指令,完成编译工作
  • 输出结果:目标代码

关键问题:这个编译器输出什么架构的目标代码?


2.本地编译器的行为

系统自带的gcc被设计为:运行在x86上,生成x86指令

当你用它编译一个C程序:

bash 复制代码
gcc hello.c -o hello

生成的hello程序包含的是x86机器指令。这个程序只能在x86处理器上运行。

你可以验证:

bash 复制代码
file hello

输出:

复制代码
hello: ELF 64-bit LSB executable, x86-64, ...

这种"编译器运行的架构"和"生成代码的架构"相同的情况,称为本地编译。


3.问题:本地编译器无法满足需求

现在的情况:

  • 你的开发电脑是x86架构
  • 目标开发板RK3588S是ARM64架构

如果用x86本地编译器编译U-Boot:

  • 编译器在x86电脑上正常运行
  • 生成的是x86指令
  • 把这个程序烧写到RK3588S芯片
  • ARM处理器无法识别x86指令,程序无法运行

需要一个特殊的编译器:运行在x86上,但生成ARM64指令。


4.交叉编译器的概念

这种"编译器运行的架构"和"生成代码的架构"不同的编译器,称为交叉编译器。

交叉编译器的特点:

  • 它本身是x86程序,在你的x86电脑上运行
  • 它输出的是ARM64指令,用于目标开发板

这个过程称为交叉编译。


5.为什么不直接在开发板上编译

理论上可以在RK3588S开发板上安装Linux系统和本地ARM编译器,直接在板子上编译U-Boot。

但实际不可行:

原因1:U-Boot是启动前的代码

U-Boot负责初始化硬件、加载操作系统。它运行在系统启动之前,此时还没有Linux环境。你不能依赖Linux系统来编译启动它的程序,这是逻辑矛盾。

原因2:开发板性能有限

RK3588S虽然性能不错,但编译大型项目(U-Boot、Linux内核)非常耗时。开发电脑的CPU性能、内存容量、存储速度都远超开发板,编译速度快得多。

原因3:开发效率

所以标准做法是:在高性能的x86电脑上用交叉编译器编译,生成ARM程序,然后烧写到开发板。


5.工具链的构成

编译过程不只需要编译器,还需要配套的其他工具。这些工具必须协同工作:

工具1:预处理器(cpp)

处理C代码中的#include#define等预处理指令,生成纯C代码。

工具2:编译器(gcc)

把C代码转换成汇编代码(人能读的指令助记符)。

工具3:汇编器(as)

把汇编代码转换成机器码,生成目标文件.o

工具4:链接器(ld)

把多个目标文件组合成可执行文件。

工具5:C标准库

提供printfmemcpy等标准函数的实现。链接时需要把这些函数的代码链接进来。

工具6:辅助工具

  • objcopy:格式转换
  • objdump:反汇编查看
  • strip:删除调试信息减小文件大小
  • ar:创建静态库文件

这一整套配套工具称为工具链。

工具链中的所有工具都必须目标一致:生成相同架构的代码。 不能用x86的链接器链接ARM的目标文件,格式不兼容。


6.工具链的命名规则

交叉工具链的每个工具都有统一的名称前缀,格式是:

复制代码
<架构>-<厂商>-<操作系统>-<ABI>-<工具名>

实际使用中,常见的简化格式:

复制代码
<架构>-<操作系统>-<ABI>-<工具名>

对于RK3588S:

  • 架构:aarch64(ARM 64位)
  • 操作系统:linux
  • ABI:gnu

所以工具链前缀是aarch64-linux-gnu-

完整的工具名称:

复制代码
aarch64-linux-gnu-gcc          # 编译器
aarch64-linux-gnu-g++          # C++编译器
aarch64-linux-gnu-as           # 汇编器
aarch64-linux-gnu-ld           # 链接器
aarch64-linux-gnu-ar           # 静态库工具
aarch64-linux-gnu-objcopy      # 格式转换工具
aarch64-linux-gnu-objdump      # 反汇编工具
aarch64-linux-gnu-strip        # 符号剥离工具
aarch64-linux-gnu-readelf      # ELF文件分析工具

7.如何选择正确的工具链

RK3588S的处理器规格:

  • Cortex-A76(大核)和Cortex-A55(小核)
  • ARMv8.2-A架构
  • 64位处理器

关键判断:

  1. 位宽 :64位 → 必须用aarch64,不能用arm(32位)
  2. 指令集:ARMv8 → 支持A64指令集
  3. 运行环境 :虽然U-Boot是裸机程序,但使用Linux工具链的ABI规范 → 选linux-gnu

错误的选择:

  • arm-linux-gnueabi-gcc:这是32位ARM工具链,生成的是32位指令
  • arm-none-eabi-gcc:这是32位裸机工具链
  • x86_64-linux-gnu-gcc:这是x86工具链

正确的选择:

  • aarch64-linux-gnu-gcc:64位ARM,Linux ABI规范

8.工具链的获取方式

有三种获取方式:

方式1:从Linux发行版的软件仓库安装

Ubuntu/Debian系统:

bash 复制代码
sudo apt-get update
sudo apt-get install gcc-aarch64-linux-gnu

这会自动安装整个工具链。

优点:安装简单,版本稳定

缺点:版本可能不是最新的

方式2:使用芯片厂商提供的工具链

Rockchip官方可能提供预编译的工具链包,包含针对RK系列芯片优化的编译选项。

优点:针对芯片优化

缺点:需要手动下载和配置环境变量

方式3:自己编译工具链(不推荐新手)

使用crosstool-ng等工具从源码编译整个工具链。

优点:完全可定制

缺点:复杂,耗时长,容易出错

推荐方式:使用发行版软件仓库安装,简单可靠。


9.验证工具链安装

安装完成后验证:

bash 复制代码
# 检查编译器是否存在
which aarch64-linux-gnu-gcc

# 查看版本
aarch64-linux-gnu-gcc --version

# 检查能否正常工作
echo 'int main() { return 0; }' > test.c
aarch64-linux-gnu-gcc test.c -o test
file test

最后一条命令的输出应该包含:

复制代码
test: ELF 64-bit LSB executable, ARM aarch64, ...

确认输出是ARM aarch64架构,说明工具链正确。


10.本章总结

交叉编译的根本原因:开发电脑和目标开发板的处理器架构不同

需要在x86电脑上安装特殊的编译器,这个编译器:

  • 自己是x86程序,能在开发电脑上运行
  • 输出ARM64指令,供目标开发板使用

编译需要一整套配套工具,这套工具称为工具链。工具链的所有组件必须目标架构一致。

RK3588S需要的工具链前缀:aarch64-linux-gnu-

通过Linux发行版的软件仓库可以方便地安装完整的工具链。

下一章将详细讲解编译过程:C源文件如何被处理成目标文件。


第三章:编译过程 - 从源码到目标文件

编译的起点和目标

起点:一个C语言源文件,比如main.c,内容是ASCII编码的文本。

目标:一个包含机器指令的目标文件main.o,内容是二进制机器码。

这个转换过程分为4个步骤。为什么要分步骤?因为每一步解决一个特定问题,分步处理比一次性处理更清晰、更容易实现。


步骤1:预处理 - 展开宏和包含文件

遇到的问题

C源文件中有这样的内容:

c 复制代码
#include <stdio.h>
#include "serial.h"

#define UART_BASE 0xFEB50000
#define BUFFER_SIZE 256

int main(void) {
    char buffer[BUFFER_SIZE];
    uart_init(UART_BASE);
    return 0;
}

这些#include#define不是C语言的语法,而是预处理指令。

#include <stdio.h>表示"把stdio.h文件的全部内容插入到这里"。但这个文件在哪里?内容是什么?

#define BUFFER_SIZE 256表示"后面出现的BUFFER_SIZE都替换成256"。但源文件本身并没有执行替换。

编译器的C语法解析器只能处理纯C代码,无法理解这些预处理指令。需要先把这些指令处理掉,生成纯C代码。

预处理器的工作

预处理器读取源文件,执行以下操作:

操作1:处理#include

找到对应的头文件,把整个文件内容读取出来,插入到#include那一行的位置。

如果头文件里又包含其他头文件,递归处理,直到所有#include都被替换成实际内容。

操作2:处理#define

在代码中查找所有出现的宏名称,替换成定义的内容。

char buffer[BUFFER_SIZE]; 替换成 char buffer[256];
uart_init(UART_BASE); 替换成 uart_init(0xFEB50000);

操作3:处理条件编译
#ifdef#ifndef#if等指令用于根据条件选择性地包含或排除某段代码。预处理器根据当前定义的宏,决定保留哪些代码,删除哪些代码。

操作4:删除注释
///* */之间的注释内容对编译没有意义,全部删除。

预处理的输出

预处理后生成的文件仍然是文本文件,但已经是纯C代码,没有任何预处理指令。

可以手动查看预处理结果:

bash 复制代码
aarch64-linux-gnu-gcc -E main.c -o main.i

-E参数表示只执行预处理。生成的main.i文件非常大,因为所有头文件的内容都被插入进来了。


步骤2:编译 - 从C代码到汇编代码

遇到的问题

预处理后的纯C代码仍然是文本形式:

c 复制代码
int add(int a, int b) {
    return a + b;
}

需要转换成机器指令。但是直接生成32位的二进制编码(比如0xD2800000)有两个问题:

问题1:二进制码人类无法读懂

如果编译器直接生成二进制,开发者无法检查生成的指令是否正确。调试时也无法理解程序在做什么。

问题2:优化和调整困难

编译器需要在多个可能的指令序列中选择最优的。如果直接操作二进制编码,逻辑复杂且容易出错。

需要一个中间形式:既接近机器指令,又保持一定的可读性。

汇编语言的引入

设计一种文本格式的指令表示法:

  • 每条指令用一个助记符表示(比如ADD表示加法,MOV表示数据移动)
  • 操作数用寄存器名称表示(比如X0X1
  • 用十进制或十六进制数字表示立即数

上面的add函数可以表示为:

assembly 复制代码
add:
    add  x0, x0, x1    // x0 = x0 + x1
    ret                 // 返回

这种表示法就是汇编语言。它和机器指令一一对应:

  • add x0, x0, x1对应机器码0x8B010000
  • ret对应机器码0xD65F03C0

但汇编语言是文本,人能读懂。

编译器的工作

编译器(狭义的编译器,特指gcc的编译阶段)读取C代码,执行:

工作1:语法分析

解析C代码的语法结构,识别出变量定义、函数调用、控制流语句等。

工作2:语义分析

检查代码是否符合语义规则。比如变量使用前是否定义,函数参数类型是否匹配等。

工作3:中间代码生成

把C语句转换成编译器内部使用的中间表示形式,方便后续优化。

工作4:优化

对中间代码进行优化,比如删除无用代码、合并相同计算、调整指令顺序等,提高执行效率。

工作5:目标代码生成

把优化后的中间代码转换成目标架构的汇编代码。这一步需要:

  • 选择合适的指令序列实现每个操作
  • 为变量分配寄存器
  • 按照ABI规范安排函数调用的参数传递

编译的输出

编译阶段输出的是汇编代码文件,扩展名通常是.s

可以手动生成汇编代码查看:

bash 复制代码
aarch64-linux-gnu-gcc -S main.c -o main.s

-S参数表示只执行到编译阶段,不进行汇编。


步骤3:汇编 - 从汇编代码到机器码

遇到的问题

汇编代码仍然是文本文件:

assembly 复制代码
add:
    add  x0, x0, x1
    ret

CPU无法执行文本。需要把汇编指令转换成对应的二进制编码。

这个转换相对简单,因为汇编指令和机器指令是一一对应的关系。每条汇编指令都有固定的编码规则。

汇编器的工作

汇编器读取汇编文件,执行:

工作1:指令编码

根据ARM指令集手册,把每条汇编指令转换成对应的32位二进制编码。

add x0, x0, x1 → 查表得知这是数据处理指令,操作码是0x8B,操作数编码... → 最终得到0x8B010000

工作2:符号记录

记录每个函数名、全局变量名对应的位置。比如add函数的代码从文件的第0字节开始。这些信息称为符号表。

工作3:重定位信息记录

如果汇编代码中有对其他函数的调用,此时不知道目标函数的地址,记录下"这里需要填入某个函数的地址"这个信息。

汇编的输出

汇编器生成目标文件,扩展名.o。这是一个二进制文件,包含:

  • 机器指令的二进制编码
  • 符号表
  • 重定位信息
  • 一些元数据(比如这是什么格式的文件、哪些段是代码段等)

可以手动生成目标文件:

bash 复制代码
aarch64-linux-gnu-gcc -c main.c -o main.o

-c参数表示编译到目标文件为止,不进行链接。


通常的编译命令

实际操作时,通常不需要手动执行每个步骤。编译器会自动完成预处理、编译、汇编:

bash 复制代码
aarch64-linux-gnu-gcc -c main.c -o main.o

这一条命令内部自动执行了:

  1. 预处理:main.c → 临时的.i文件
  2. 编译:.i文件 → 临时的.s文件
  3. 汇编:.s文件 → main.o

中间的临时文件自动删除,只保留最终的目标文件。


目标文件的内容

目标文件.o是二进制文件,不能用文本编辑器打开查看。它按照特定格式组织数据。

格式标准

Linux系统使用ELF(Executable and Linkable Format)格式。目标文件是ELF格式的一种。

ELF文件分为多个段(Section):

段1:.text段

存储编译后的机器指令代码。这是程序的可执行部分。

段2:.data段

存储已初始化的全局变量和静态变量。比如:

c 复制代码
int global_var = 100;

这个变量的初始值100存储在.data段。

段3:.bss段

存储未初始化的全局变量和静态变量。比如:

c 复制代码
int buffer[1024];

这个数组占4096字节,但不需要在文件里保存4096个0。只需要记录"这里有个4096字节的空间"即可。程序加载时会自动分配并清零。

段4:.rodata段

存储只读数据,比如字符串常量。

c 复制代码
const char *msg = "Hello";

字符串"Hello"存储在.rodata段。

段5:.symtab段

符号表。记录每个函数名、变量名和它们的地址。比如:

复制代码
函数名: add
地址: 0x0000(相对地址)
大小: 8字节
类型: 函数

段6:.rel.text段

重定位信息表。记录哪些位置需要在链接时填入其他符号的地址。

查看目标文件内容

使用readelf工具查看ELF文件结构:

bash 复制代码
aarch64-linux-gnu-readelf -h main.o    # 查看文件头
aarch64-linux-gnu-readelf -S main.o    # 查看段表
aarch64-linux-gnu-readelf -s main.o    # 查看符号表

使用objdump反汇编查看指令:

bash 复制代码
aarch64-linux-gnu-objdump -d main.o

输出类似:

复制代码
main.o:     file format elf64-littleaarch64

Disassembly of section .text:

0000000000000000 <add>:
   0:   8b010000        add     x0, x0, x1
   4:   d65f03c0        ret

能看到地址、机器码、对应的汇编指令。


为什么目标文件还不能运行

目标文件包含了机器指令,但还不能直接运行,原因:

原因1:地址是相对的

目标文件中的地址从0开始编号,这只是在本文件内的相对位置。最终程序需要把多个目标文件的代码拼接在一起,地址会变化。

原因2:外部符号未解析

如果main.o调用了uart_puts()函数,但这个函数在serial.o中定义,那么调用指令中的目标地址还是空的,需要链接时填入。

原因3:缺少启动代码

程序运行需要一些初始化工作,比如设置栈指针、初始化全局变量。这些启动代码通常在单独的目标文件中,需要链接进来。

原因4:缺少库函数

如果代码使用了printf等标准库函数,需要把库函数的实现链接进来。

这些问题由链接器解决。


本章总结

C源文件转换成目标文件分3个步骤:

预处理:展开宏和头文件,删除注释,生成纯C代码。

编译:把C代码转换成汇编代码。这一步完成语法分析、优化、指令选择等复杂工作。

汇编:把汇编代码转换成机器码,生成目标文件。

目标文件是ELF格式的二进制文件,包含机器指令、数据、符号表、重定位信息等。

目标文件的地址是相对的,外部符号未解析,所以还不能直接运行,需要链接。

下一章将讲解链接过程:如何把多个目标文件组合成完整的可执行程序。


第四章:链接过程 - 组装完整程序

1.链接要解决的问题

现在有多个目标文件:main.oserial.otimer.o。每个文件都包含机器指令,但存在问题。

问题1:代码相互调用但地址未知

main.o中的代码调用serial.o中的uart_puts()函数。

main.o的机器码中,调用指令是这样的:

复制代码
地址0x0010: BL  0x????????    // 调用uart_puts,但目标地址未知

指令编码的目标地址部分是空的,因为编译main.c时不知道uart_puts()会被放在什么位置。

main.o的符号表记录了这个问题:

复制代码
重定位记录:
位置: 0x0010
类型: R_AARCH64_CALL26(表示这是个函数调用需要重定位)
符号: uart_puts(需要这个函数的地址)

问题2:每个目标文件的地址从0开始

三个目标文件中,代码的地址都从0开始:

  • main.o:代码从0x0000到0x00FF(假设占256字节)
  • serial.o:代码从0x0000到0x01FF(假设占512字节)
  • timer.o:代码从0x0000到0x007F(假设占128字节)

这些代码最终要放到同一块内存中运行,不可能都从地址0开始。需要重新分配地址。

问题3:缺少启动代码

C程序运行前需要做初始化:

  • 设置栈指针寄存器SP,让函数调用能正常工作
  • 清零.bss段(未初始化的全局变量)
  • 如果代码需要从Flash复制到RAM,要执行复制操作

这些操作由启动代码完成。启动代码通常是汇编编写的,也编译成目标文件,需要和其他目标文件组合。

问题4:需要标准库函数

如果代码使用了memcpyprintf等函数,这些函数的实现在C标准库中。标准库是预先编译好的目标文件集合,需要把用到的函数链接进来。

链接器的任务就是解决这4个问题。


2.链接器的工作流程

链接器读取所有目标文件,执行一系列操作,生成最终的可执行文件。

操作1:收集所有段

链接器扫描每个目标文件,提取各个段的内容:

main.o提取:

  • .text段:main函数的指令
  • .data段:main中定义的全局变量
  • .bss段:main中未初始化的全局变量
  • 符号表和重定位信息

serial.o提取:

  • .text段:uart_init、uart_puts等函数的指令
  • .data段、.bss
  • 符号表和重定位信息

timer.o提取:

  • 类似的段

操作2:合并相同类型的段

把所有目标文件的.text段拼接成一个大的.text段:

复制代码
新.text段 = main.o的.text + serial.o的.text + timer.o的.text

同样合并所有.data段、所有.bss段。

这样就把分散的代码组织到一起了。

操作3:分配最终地址

合并后,确定每个段在内存中的位置。

这个位置由链接脚本指定。链接脚本是一个配置文件,告诉链接器:

  • 代码段从地址0x00200000开始
  • 数据段从地址0x00300000开始
  • 栈从地址0x00400000开始

假设链接脚本规定代码从0x00200000开始,那么:

  • main.o的代码占256字节,地址范围:0x00200000~0x002000FF
  • serial.o的代码占512字节,地址范围:0x00200100~0x002002FF
  • timer.o的代码占128字节,地址范围:0x00200300~0x0020037F

现在每个函数都有了确定的最终地址。比如uart_puts()函数在serial.o中的相对地址是0x0080,最终地址就是0x00200100 + 0x0080 = 0x00200180。

操作4:符号解析

链接器建立一个全局符号表,记录所有函数和全局变量的最终地址:

复制代码
符号名          最终地址
main            0x00200000
uart_init       0x00200100
uart_puts       0x00200180
timer_init      0x00200300
global_count    0x00300000(数据段中的变量)

操作5:重定位

现在地址都确定了,链接器回到之前留空的位置,填入正确的地址。

main.o在地址0x0010处有一条调用uart_puts()的指令,之前地址是空的:

复制代码
BL  0x????????

链接器查符号表,得知uart_puts的最终地址是0x00200180。计算调用指令和目标函数的地址差(ARM的跳转指令使用相对偏移),修改指令:

复制代码
BL  0x00200180    (实际编码是相对偏移,这里简化表示)

所有重定位记录中标记的位置都这样处理,填入正确地址。

操作6:生成可执行文件

把合并后的段、更新后的指令、符号表等信息,按照ELF可执行文件的格式组织,写入输出文件。

这个文件就是链接后的可执行程序,比如u-boot(不带扩展名)。


3.链接命令的执行

实际编译U-Boot时,链接过程是自动进行的。

如果手动链接,命令类似:

bash 复制代码
aarch64-linux-gnu-ld -T u-boot.lds main.o serial.o timer.o -o u-boot

参数说明:

  • -T u-boot.lds:指定链接脚本文件
  • main.o serial.o timer.o:输入的目标文件列表
  • -o u-boot:输出的可执行文件名

通常用gcc命令,它会自动调用链接器:

bash 复制代码
aarch64-linux-gnu-gcc main.o serial.o timer.o -o u-boot -T u-boot.lds

4.ELF可执行文件的结构

链接生成的u-boot文件仍然是ELF格式,但和目标文件.o有区别。

文件头

ELF文件开头是一个固定格式的文件头,包含:

魔数 :4字节 0x7F 'E' 'L' 'F',标识这是ELF文件。

架构信息:指明这是64位ARM程序。

入口地址:程序第一条指令的地址,比如0x00200000。CPU跳转到这个地址开始执行。

段表位置:段表(Section Header Table)在文件中的偏移。

程序头表位置:程序头表(Program Header Table)在文件中的偏移。

段(Section)

和目标文件类似,可执行文件也有多个段:

.text:所有代码指令

.rodata:只读数据(字符串常量等)

.data:已初始化的全局变量

.bss:未初始化的全局变量(文件中不占空间,只记录大小)

.symtab:符号表

.strtab:字符串表(存储符号名称的字符串)

可以查看段信息:

bash 复制代码
aarch64-linux-gnu-readelf -S u-boot

程序头(Program Header)

可执行文件多了程序头表,目标文件没有。

程序头表告诉操作系统或加载器:如何把文件内容加载到内存。

每个程序头描述一个段在内存中的布局:

  • 这个段在文件中的偏移
  • 这个段在内存中的地址
  • 这个段的大小
  • 这个段的权限(可读、可写、可执行)

比如:

复制代码
类型: LOAD
文件偏移: 0x1000
内存地址: 0x00200000
文件大小: 2048字节
内存大小: 2048字节
权限: 可读、可执行

这表示把文件偏移0x1000处的2048字节加载到内存地址0x00200000,权限设为可读可执行(代码段)。

可以查看程序头:

bash 复制代码
aarch64-linux-gnu-readelf -l u-boot

符号表

记录所有函数和全局变量的名称和地址。

用于调试:调试器能通过符号表知道某个地址对应的函数名,方便打断点和查看调用栈。

用于反汇编:objdump反汇编时,能显示函数名而不只是地址。

如果不需要调试,可以用strip工具删除符号表,减小文件大小:

bash 复制代码
aarch64-linux-gnu-strip u-boot -o u-boot-stripped

5.为什么ELF文件不能直接烧写

ELF文件包含了完整的可执行代码,但不能直接烧写到芯片Flash,原因:

原因1:文件包含非代码内容

ELF文件包含:

  • 文件头(几十字节)
  • 段表(记录每个段的元数据)
  • 程序头表
  • 符号表
  • 字符串表
  • 调试信息

这些信息用于描述文件结构,帮助操作系统加载程序,但芯片启动时不需要。

原因2:芯片启动流程不理解ELF格式

芯片上电后,CPU从固定地址(比如0x0)开始读取,直接把读到的内容当作指令执行。

CPU不会解析ELF格式,不会跳过文件头,不会根据程序头表加载数据。

如果把ELF文件直接烧写到Flash地址0,CPU读到的第一个字节是0x7F(ELF魔数的第一个字节),会被当作指令执行。0x7F不是有效的ARM指令编码,导致非法指令异常。

原因3:地址空间对应关系

ELF文件中的代码可能期望运行在地址0x00200000,但Flash的起始地址是0x0。

如果直接烧写,代码在Flash中的实际位置和它期望的运行地址不一致,所有的地址引用都会出错。

需要的格式

芯片需要的是纯二进制镜像:

  • 只包含机器指令和数据
  • 没有文件头、段表等元数据
  • 文件的第一个字节就是第一条指令
  • 按照代码在内存中的布局顺序排列

这种纯二进制文件的生成由objcopy工具完成。


6.本章总结

链接器解决4个问题:

  1. 把多个目标文件的代码拼接在一起
  2. 分配最终的内存地址
  3. 解析符号,建立全局符号表
  4. 填入之前留空的地址(重定位)

链接后生成ELF格式的可执行文件,包含:

  • 机器指令和数据
  • 文件头(包含入口地址)
  • 段表(描述各个段)
  • 程序头表(描述如何加载到内存)
  • 符号表(用于调试)

ELF文件因为包含额外的元数据,且芯片启动时不理解ELF格式,所以不能直接烧写。

需要从ELF文件提取纯机器码,生成二进制镜像文件。

下一章将讲解格式转换过程:如何从ELF生成可烧写的二进制镜像。


第五章:格式转换 - 生成可烧写的镜像

芯片上电后发生什么

链接器生成了u-boot这个ELF文件,包含了所有代码和数据。现在需要把它放到芯片上运行。

芯片上电瞬间,内部电路开始工作:

动作1:复位

所有寄存器被设置为初始值。其中最关键的是PC寄存器(程序计数器),它被硬件自动设置为一个固定地址。

对于RK3588S,这个固定地址是芯片内部BootROM的起始位置(比如0xFFFF0000)。

动作2:从BootROM执行

CPU开始从PC寄存器指向的地址读取指令并执行。BootROM里面是芯片厂商烧录的启动代码,这段代码会:

  • 初始化最基本的硬件(时钟、部分内存控制器)
  • 检测启动设备(从SD卡启动还是从eMMC启动)
  • 从启动设备的固定位置读取数据

动作3:加载外部代码

BootROM从启动设备(比如SD卡)的特定偏移位置(比如第64个扇区)开始读取数据,读取固定的字节数(比如读取512KB),把这些字节复制到内存或SRAM的某个地址(比如0x00200000)。

动作4:跳转执行

BootROM修改PC寄存器的值,跳转到刚才加载数据的起始地址(0x00200000)。CPU开始从这个地址执行指令。

关键:BootROM把读到的字节直接当作指令执行,不做任何解析或转换。


ELF文件如果直接烧写会怎样

假设把u-boot这个ELF文件直接写到SD卡的第64个扇区。

BootROM从第64个扇区读取数据,复制到内存地址0x00200000,然后跳转到这个地址执行。

此时CPU从地址0x00200000读取4个字节,作为第一条指令:

ELF文件的开头是文件头,前4个字节是魔数:

复制代码
0x7F 0x45 0x4C 0x46

这4个字节的二进制是:

复制代码
01111111 01000101 01001100 01000110

CPU把这32位当作ARM指令解码。但ARM指令集中,这个编码不对应任何有效指令。

结果:CPU触发非法指令异常,程序无法运行。

即使侥幸这4个字节碰巧是有效指令,后面的文件头、段表等内容也不是指令,同样会导致执行错误。


芯片需要什么格式的数据

从上面的过程可以看出,芯片需要的数据必须满足:

要求1:第一个字节就是第一条指令的第一个字节

不能有任何文件头或元数据在前面。CPU读到什么就执行什么。

要求2:按照内存布局顺序排列

如果代码在内存中从地址0x00200000开始,数据在地址0x00300000开始,那么文件中应该是:

  • 偏移0:代码段的所有字节
  • 偏移0x100000(假设代码占1MB):数据段的所有字节

文件中的位置和内存中的位置要保持对应。

要求3:只包含需要加载到内存的内容

符号表、调试信息、段表这些辅助信息不需要加载到内存,不应该包含在烧写文件中。

这种只包含纯机器码和数据、没有任何元数据的文件,称为二进制镜像或二进制映像。扩展名通常是.bin


如何从ELF提取纯机器码

ELF文件的结构是:

复制代码
[文件头]
[程序头表]
[.text段的内容:机器指令]
[.rodata段的内容:只读数据]
[.data段的内容:初始化的变量]
[段表]
[符号表]
[字符串表]
...

需要做的操作:

操作1:识别需要加载的段

查看程序头表,找到所有类型为LOAD的程序头。这些程序头指明了哪些段需要加载到内存。

通常需要加载的段:

  • .text:代码
  • .rodata:只读数据
  • .data:已初始化的变量

不需要加载的段:

  • .symtab:符号表(运行时用不到)
  • .strtab:字符串表
  • .bss:未初始化的变量(这个段在内存中需要,但文件中不占空间)

操作2:提取这些段的内容

根据程序头表中记录的文件偏移和大小,读取每个段的实际数据。

比如:

  • .text段在文件偏移0x1000处,大小0x10000字节,读取这0x10000字节
  • .rodata段在文件偏移0x11000处,大小0x2000字节,读取这0x2000字节

操作3:按内存地址顺序排列

查看每个段的目标内存地址,按地址从小到大排序。

如果:

  • .text的内存地址是0x00200000
  • .rodata的内存地址是0x00210000
  • .data的内存地址是0x00212000

那么在输出文件中的顺序就是:.text.rodata.data

操作4:处理地址间隙

如果两个段的内存地址之间有空隙,需要在输出文件中填充0。

比如.text结束在0x0020FFFF,.rodata从0x00210000开始,中间没有空隙。但如果.rodata从0x00211000开始,中间有0x1000字节的空隙,需要在输出文件中插入0x1000个0字节。

这样保证文件中的位置和内存中的位置始终对应。

操作5:写入输出文件

把提取和排列好的数据写入新文件,这就是纯二进制镜像。

完成这个提取和转换操作的工具,叫做objcopy。


objcopy工具的使用

objcopy是工具链的一部分,名称是:

复制代码
aarch64-linux-gnu-objcopy

基本用法:

bash 复制代码
aarch64-linux-gnu-objcopy -O binary u-boot u-boot.bin

参数说明:

  • -O binary:指定输出格式为纯二进制(binary)
  • u-boot:输入文件(ELF格式)
  • u-boot.bin:输出文件(二进制格式)

执行这条命令后,u-boot.bin就是可以烧写到芯片的纯二进制镜像。


二进制镜像文件的特点

生成的u-boot.bin文件:

特点1:文件开头直接是第一条指令

用十六进制编辑器打开,第一个字节就是第一条ARM指令的第一个字节。没有任何文件头。

特点2:文件大小等于需要加载的内容大小

如果代码段64KB、数据段4KB,文件大小就是68KB(可能还要加上段之间的间隙填充)。

特点3:文件失去了结构信息

无法从文件本身看出哪部分是代码、哪部分是数据。只是一串连续的字节。

特点4:可以直接按字节写入存储设备

把文件内容逐字节写到SD卡的特定位置,写入的数据就是将要加载到内存的数据。


对比ELF和二进制镜像

以一个简单程序为例:

ELF文件u-boot

复制代码
大小:2.5MB
包含:文件头、程序头、代码(1MB)、数据(64KB)、符号表(1MB)、调试信息(0.4MB)

二进制镜像u-boot.bin

复制代码
大小:1.1MB
包含:代码(1MB)、数据(64KB)、段间隙填充(几KB)

二进制镜像比ELF小很多,因为去掉了所有调试和符号信息。

ELF文件的用途:

  • 开发调试时使用
  • 可以用objdump反汇编查看
  • 可以用gdb调试器设置断点
  • 包含丰富的符号信息,方便定位问题

二进制镜像的用途:

  • 烧写到芯片
  • 体积小,只包含运行必需的内容
  • 没有调试信息,无法直接调试(需要结合ELF文件)

两个文件通常都要保留。ELF用于开发调试,.bin用于实际烧写。


辅助工具的作用

在开发过程中,还需要其他工具来检查和分析。

objdump - 反汇编和查看工具

用途1:查看生成的指令是否正确

bash 复制代码
aarch64-linux-gnu-objdump -d u-boot > disasm.txt

生成反汇编文件,能看到每条指令的地址、机器码、汇编助记符。

示例输出:

复制代码
0000000000200000 <_start>:
  200000:   d53800a0    mrs x0, mpidr_el1
  200004:   92401c00    and x0, x0, #0xff
  200008:   b4000040    cbz x0, 200010 <_start+0x10>

能看到_start函数的第一条指令在地址0x200000,机器码是0xd53800a0,对应汇编指令mrs x0, mpidr_el1

用途2:检查函数是否被正确放置

如果链接脚本指定某个函数必须在特定地址,用objdump查看反汇编,确认函数确实在那个地址。

用途3:查看某个地址处的代码

程序运行出错,知道出错的地址是0x205438,用objdump找到这个地址对应的是哪个函数的哪条指令。

readelf - ELF文件分析工具

用途1:查看段信息

bash 复制代码
aarch64-linux-gnu-readelf -S u-boot

输出所有段的名称、地址、大小、类型。

用途2:查看程序头表

bash 复制代码
aarch64-linux-gnu-readelf -l u-boot

查看哪些段会被加载到内存,每个段的内存地址和权限。

用途3:查看符号表

bash 复制代码
aarch64-linux-gnu-readelf -s u-boot

列出所有函数和全局变量的名称和地址。

这些工具的使用时机

编译完成后:用readelf查看ELF文件结构,确认段的地址和大小是否符合预期。

转换成.bin前:用objdump反汇编,检查关键函数的指令是否正确。

程序运行出错时:根据错误信息中的地址,用objdump查找对应的代码位置。

优化程序大小时:用readelf查看哪个段占用空间大,分析能否优化。


本章总结

链接生成的ELF文件包含代码、数据,还包含文件头、符号表等元数据。

芯片启动时,BootROM从存储设备读取数据,直接当作指令执行,不理解ELF格式。

需要从ELF文件提取纯机器码和数据,去掉所有元数据,生成二进制镜像文件。

这个提取转换过程由objcopy工具完成:

bash 复制代码
aarch64-linux-gnu-objcopy -O binary u-boot u-boot.bin

生成的.bin文件:

  • 文件开头直接是第一条指令
  • 只包含需要加载到内存的内容
  • 按内存地址顺序排列
  • 可以直接烧写到芯片

开发中同时保留ELF和.bin两个文件:

  • ELF用于调试分析(有符号信息)
  • .bin用于烧写到芯片(纯机器码)

辅助工具objdump和readelf用于查看和验证编译结果。

下一章将讲解烧写与启动流程:二进制镜像如何写入芯片,CPU如何找到并执行代码。


第六章:烧写与启动 - 代码如何进入芯片并执行

Flash存储器的作用

现在有了u-boot.bin这个二进制镜像文件,在电脑的硬盘上。开发板断电后,内存中的数据会全部丢失。

需要一种断电后数据不丢失的存储器件,把代码永久保存在开发板上。这样每次上电都能自动加载执行。

RK3588S开发板通常使用两种非易失性存储器:

存储器1:eMMC

一种嵌入式的闪存芯片,焊接在开发板上。容量通常8GB~128GB。结构类似SD卡,但直接焊在板子上。

存储器2:SD卡

可插拔的存储卡,通过SD卡槽连接到开发板。容量灵活选择。

这两种存储器的物理实现都是闪存(Flash),断电后数据保持不变。


Flash存储器的内部组织

Flash内部的存储单元排列成数组,每个单元存储1字节数据。

为了读写方便,这些存储单元被分组管理。最小的读写单位不是1字节,而是一个"块"。

扇区(Sector):Flash的基本操作单位。

eMMC和SD卡的扇区大小通常是512字节。

每个扇区有一个编号,从0开始递增:

  • 扇区0:字节地址0~511
  • 扇区1:字节地址512~1023
  • 扇区2:字节地址1024~1535
  • 以此类推

读写操作以扇区为单位:

  • 读取时,指定扇区号,读取整个512字节
  • 写入时,指定扇区号,写入整个512字节

无法单独修改扇区内的某一个字节,必须读取整个扇区、修改、再写回整个扇区。


芯片如何访问Flash

RK3588S芯片和eMMC/SD卡之间通过硬件接口连接。

芯片内部有存储控制器(eMMC控制器或SD控制器),负责和Flash芯片通信。

CPU通过编程这些控制器,发送读写命令:

读取操作流程:

  1. CPU向控制器寄存器写入扇区号,比如64
  2. CPU向控制器寄存器写入读命令
  3. 控制器通过物理总线向Flash发送读取请求
  4. Flash芯片读取第64扇区的512字节数据
  5. Flash通过总线把数据传回控制器
  6. 控制器把数据存入CPU可访问的缓冲区
  7. CPU从缓冲区读取这512字节

写入操作流程类似,但需要先擦除再写入(Flash的物理特性)。


烧写的本质操作

烧写就是把u-boot.bin文件的内容,写入到Flash的特定扇区。

文件系统的写入(比如把文件拷贝到U盘)和烧写的区别:

文件系统写入:

  • 操作系统管理存储空间
  • 文件有目录结构、文件名、元数据
  • 操作系统知道文件放在哪些扇区

烧写:

  • 直接指定扇区位置
  • 把文件的字节逐个写入这些扇区
  • 没有文件名、没有目录,只是一片连续的原始数据

烧写后,Flash的某段扇区中存储的就是程序的机器码,但没有任何标记说明"这里有个文件叫u-boot.bin"。


为什么烧写到特定位置

芯片上电后,BootROM会从固定的扇区位置读取数据。

这个位置是芯片厂商规定的,不同芯片不同:

  • 某些芯片从扇区0开始读取
  • RK3588系列芯片从扇区64开始读取(具体位置可能根据启动模式不同而变化)

如果烧写位置不对,BootROM读不到数据,芯片无法启动。

所以烧写时必须把u-boot.bin写到BootROM期望的扇区位置。

RK3588的U-Boot通常烧写到SD卡或eMMC的偏移32KB处(第64扇区,因为64×512=32768字节)。


烧写工具和方法

把二进制文件写到存储设备的特定扇区,需要使用烧写工具。

方法1:在Linux下用dd命令烧写SD卡

dd是Linux的磁盘读写工具,能直接操作扇区。

把SD卡插入电脑,假设设备名是/dev/sdb

烧写命令:

bash 复制代码
sudo dd if=u-boot.bin of=/dev/sdb seek=64 bs=512 conv=fsync

参数含义:

  • if=u-boot.bin:输入文件(input file)
  • of=/dev/sdb:输出设备(output file),这里是SD卡
  • seek=64:跳过前64个块,从第64个扇区开始写
  • bs=512:块大小512字节
  • conv=fsync:写入后同步,确保数据真正写入存储设备

这条命令把u-boot.bin的内容从SD卡的第64扇区开始写入。

如果文件是200KB,占用400个扇区,那么写入后:

  • 扇区64~463存储的是u-boot.bin的内容
  • 其他扇区不受影响

方法2:用专用烧写工具

Rockchip提供的烧写工具:

  • rkdeveloptool(Linux下的命令行工具)
  • RKDevTool(Windows下的图形界面工具)

这些工具封装了烧写细节,操作更简单:

bash 复制代码
rkdeveloptool db rk3588_loader.bin
rkdeveloptool wl 64 u-boot.bin

第一条命令加载loader到芯片的RAM中(通过USB连接)。

第二条命令把u-boot.bin写到扇区64。

方法3:在U-Boot环境下烧写

如果开发板已经运行了旧版本的U-Boot,可以通过U-Boot的命令烧写新版本。

通过网络或USB把新的u-boot.bin传输到内存,然后用U-Boot的命令写入eMMC:

复制代码
mmc dev 0
mmc write ${loadaddr} 0x40 0x2000

0x40是十六进制的64(扇区号),0x2000是要写入的扇区数量(根据文件大小确定)。


芯片上电后的启动流程

烧写完成后,把SD卡插入开发板,给开发板上电。

步骤1:CPU复位

开发板通电,电源电路稳定后,向RK3588芯片的复位引脚发送复位信号。

复位信号使芯片内部所有电路恢复到初始状态:

  • 所有寄存器被设置为复位值
  • PC寄存器被设置为复位向量地址

RK3588的复位向量地址指向芯片内部的BootROM起始地址,这是一块只读存储器,位于芯片内部,地址通常是0xFFFF0000(具体值由芯片设计决定)。

步骤2:执行BootROM代码

CPU从PC寄存器指向的地址开始取指令执行。

BootROM中的代码是芯片出厂时厂商烧录的,用户无法修改。这段代码完成最基础的初始化:

初始化1:配置系统时钟

设置CPU运行在一个安全的时钟频率,保证后续操作稳定。

初始化2:初始化SRAM

芯片内部有小容量的SRAM(比如几百KB),不需要初始化就能使用。BootROM简单检查SRAM可用。

初始化3:检测启动模式

读取特定的硬件引脚或寄存器,确定从哪个设备启动。

RK3588支持多种启动模式:

  • 从SD卡启动
  • 从eMMC启动
  • 从SPI Flash启动
  • 从USB启动(下载模式,用于开发调试)

根据硬件配置(比如拨码开关、引脚电平),BootROM决定启动设备。

假设检测到从SD卡启动。

初始化4:初始化SD卡控制器

配置SD卡控制器寄存器,使控制器能够和SD卡通信。

发送SD卡初始化命令序列(SD协议规定的),使SD卡进入可读写状态。

步骤3:从SD卡加载代码

BootROM从SD卡读取数据:

读取操作1:读取扇区64开始的数据

向SD控制器发送读命令,指定起始扇区64。

SD卡把扇区64的512字节传输回芯片。

读取操作2:检查数据头部

RK芯片的BootROM期望数据开头有特定的标识(比如魔数0xFB或特定的头部结构)。

BootROM检查读取的数据是否包含这个标识。如果没有,说明这个位置没有有效的启动代码,尝试其他启动设备或报错。

如果标识正确,继续。

读取操作3:读取完整的启动代码

根据头部信息,知道启动代码的总大小(比如200KB)。

继续读取后续的扇区,直到把完整的代码读取完毕。

读取操作4:复制到内存

把读取的数据复制到芯片内部SRAM或DDR内存的特定地址(比如0x00200000)。

复制过程是逐字节进行的:

  • 读取的第1个字节写入地址0x00200000
  • 读取的第2个字节写入地址0x00200001
  • 以此类推

步骤4:验证和跳转

验证操作(可选):

某些芯片的BootROM会计算加载的代码的校验和(比如CRC),和头部记录的校验和对比,确保数据完整无损。

如果校验失败,停止启动。

跳转操作:

BootROM把PC寄存器的值修改为刚才复制数据的起始地址(0x00200000)。

CPU的下一次取指令,就会从0x00200000读取。

此时,CPU开始执行U-Boot的第一条指令。


U-Boot的第一条指令

u-boot.bin文件的第一个字节,就是U-Boot代码的第一条指令的第一个字节。

这个位置通常是U-Boot的启动入口,代码标签是_start

用objdump查看u-boot的反汇编,能看到:

复制代码
0000000000200000 <_start>:
  200000:   14000008    b   200020 <reset>

第一条指令是b 200020,这是一个跳转指令,跳转到地址0x200020处的reset函数。

CPU执行这条指令后,PC变成0x200020,开始执行reset函数的代码。

reset函数的任务:

  • 关闭中断
  • 设置CPU模式(比如从EL3切换到EL2或EL1)
  • 初始化栈指针SP
  • 清零.bss段
  • 调用C语言编写的初始化函数

一步步完成硬件初始化、设备驱动初始化,最终进入U-Boot的命令行界面或自动启动Linux内核。


整个流程的串联

从编写代码到CPU执行,完整流程:

阶段1:编写源代码

用C语言编写U-Boot的各个功能模块,保存为.c.h文件。

阶段2:预处理

编译器展开宏、包含头文件,生成纯C代码。

阶段3:编译

编译器把C代码转换成汇编代码,完成语法分析、优化、指令选择。

阶段4:汇编

汇编器把汇编代码转换成机器码,生成目标文件.o

阶段5:链接

链接器把所有目标文件组合,分配地址,解析符号,生成ELF可执行文件u-boot

阶段6:格式转换

objcopy从ELF文件提取纯机器码,生成二进制镜像u-boot.bin

阶段7:烧写

用烧写工具把u-boot.bin写入SD卡或eMMC的特定扇区(比如从扇区64开始)。

阶段8:上电启动

  • 芯片复位,PC指向BootROM
  • BootROM初始化基本硬件
  • BootROM从SD卡读取扇区64开始的数据
  • BootROM把数据复制到内存地址0x00200000
  • BootROM跳转到0x00200000
  • CPU执行U-Boot的第一条指令
  • U-Boot初始化硬件,加载Linux内核或进入命令行

每个阶段解决特定问题,前一阶段的输出是后一阶段的输入,形成完整的工具链和流程。


本章总结

二进制镜像需要烧写到Flash存储器的特定位置,这个位置由芯片的BootROM决定。

Flash以扇区为单位组织,RK3588通常从扇区64读取启动代码。

烧写操作是把文件内容逐字节写入Flash的指定扇区,不通过文件系统。

芯片上电后:

  1. CPU复位,执行BootROM代码
  2. BootROM初始化硬件,检测启动设备
  3. BootROM从启动设备的固定位置读取数据
  4. BootROM把数据复制到内存
  5. BootROM跳转到内存地址,CPU开始执行用户代码

整个流程从C源代码开始,经过预处理、编译、汇编、链接、格式转换、烧写,最终到CPU执行机器指令,每个步骤解决特定问题,环环相扣。

至此,从编译器选择到代码执行的完整过程讲解完毕。


补充知识点1: 工具链命名的三个关键部分详解

第一部分:架构(aarch64)

为什么需要区分架构

不同公司设计的处理器,内部电路结构完全不同。

举例:实现"两个数相加"这个功能

Intel的x86处理器:

  • 内部有专门的加法器电路
  • 通过特定的控制信号激活这个电路
  • 这个控制信号对应的二进制编码是 0x01

ARM公司的处理器:

  • 同样有加法器电路,但电路设计不同
  • 激活电路需要不同的控制信号
  • 对应的二进制编码是 0xE0800000

结果:同样是加法操作,两种处理器需要的二进制编码完全不同。

一个处理器能识别的所有指令的编码规则,形成了这个处理器的指令集。不同设计的处理器有不同的指令集。

为了区分这些不同的指令集,需要给它们起名字。这个名字就是"架构"标识。


ARM架构的演进

ARM公司从1985年开始设计处理器,经历了多个版本:

ARMv1到ARMv7:32位时代

  • 每条指令32位(4字节)
  • 寄存器宽度32位,能直接处理的最大数值是2^32-1
  • 能直接访问的内存地址空间4GB
  • 指令集名称:ARM指令集
  • 工具链架构标识:arm

ARMv8:进入64位时代

  • 2011年发布ARMv8架构
  • 支持两种运行模式:
    • AArch64模式:64位模式,每条指令32位,寄存器64位
    • AArch32模式:兼容模式,可以运行旧的32位程序
  • 64位模式下的指令集是全新设计的,称为A64指令集
  • 能直接访问的地址空间理论上达到2^64字节

命名的含义:

  • aarch64 = ARM Architecture 64-bit
  • 明确指定使用ARMv8的64位模式

为什么必须是aarch64

RK3588S芯片的处理器核心:

  • Cortex-A76(大核)
  • Cortex-A55(小核)

这两种核心都是ARMv8.2-A架构,支持64位运行。

如果用错了架构标识会怎样:

使用arm-linux-gnu-gcc(32位):

  • 编译器生成32位ARM指令(ARMv7的指令编码)
  • RK3588S的处理器虽然能兼容运行32位指令,但:
    • 无法发挥64位性能优势
    • 地址空间受限在4GB
    • 某些硬件特性无法使用(比如大于4GB的内存)

正确做法:使用aarch64-linux-gnu-gcc

  • 生成A64指令集的64位指令
  • 充分利用64位寄存器
  • 能访问完整的内存空间
  • 匹配芯片的实际工作模式

第二部分:操作系统(linux)

这个标识的真实含义

工具链名称中的"操作系统"部分不是指编译器运行在什么系统上 ,而是指:编译器生成的程序,在运行时将要面对什么样的系统环境

这涉及程序和系统之间的交互接口。


程序需要和系统交互什么

即使是裸机程序,在运行过程中也可能需要某些"系统级"的支持。

场景1:异常处理

程序运行出错(比如访问了非法地址),CPU会产生异常。异常发生时:

  • CPU需要跳转到特定的异常处理代码
  • 异常处理代码的地址存储在特殊的寄存器中
  • 编译器需要知道异常向量表的格式规范

场景2:系统调用

如果程序要调用操作系统功能(比如打开文件、创建线程),需要:

  • 触发CPU从用户态切换到内核态
  • 通过特定指令(比如ARM的SVC指令)
  • 参数传递遵循特定规则:哪些寄存器传参数,哪个寄存器传系统调用号

场景3:C库函数调用

代码中使用printfmalloc等标准C库函数,这些函数最终可能需要:

  • 调用操作系统接口(printf需要写文件)
  • 使用系统提供的内存管理服务(malloc需要申请内存)

这些交互的规则和接口定义,在不同操作系统上是不同的。


Linux系统调用接口的特点

Linux定义了一套系统调用接口:

接口特点:

  • 使用SVC指令触发系统调用
  • 系统调用号放在寄存器X8
  • 参数依次放在X0-X7寄存器
  • 返回值在X0寄存器

编译器生成的C库代码,会按照这套规则来实现printfmalloc等函数。


U-Boot是裸机程序,为什么还用linux标识

这是最容易混淆的地方。关键在于理解:工具链中的"linux"不是强制要求有Linux内核存在,而是指遵循Linux定义的ABI规范

原因1:C库的完整性

aarch64-linux-gnu-gcc工具链自带的C库(glibc),实现了完整的C标准库函数。U-Boot代码中大量使用这些函数:

  • 字符串处理:strcpystrlen
  • 内存操作:memcpymemset
  • 格式化输出:printfsprintf

这些函数的实现遵循Linux ABI规范。U-Boot虽然是裸机程序,但可以直接使用这些函数的实现代码,因为底层的调用约定是兼容的。

原因2:调用约定的统一性

Linux ABI规范不仅定义了系统调用接口,还定义了:

  • 函数参数如何传递(前8个参数用X0-X7寄存器)
  • 返回值放在哪里(X0寄存器)
  • 哪些寄存器需要调用者保存
  • 哪些寄存器需要被调用者保存
  • 栈帧的组织方式

这些规则和是否有操作系统无关,纯粹是代码之间如何协作的约定。

U-Boot使用这套约定后:

  • 不同源文件编译的代码能正确互相调用
  • 能使用工具链提供的C库
  • 和后续加载的Linux内核使用相同的约定,交接时兼容

原因3:生态兼容性

Linux生态的工具链最成熟、最完善:

  • 编译器优化做得最好
  • C库功能最完整
  • 第三方库支持最多
  • 文档和社区支持最全

使用Linux工具链意味着可以直接复用这些资源,而不需要自己实现所有基础功能。


对比:裸机专用工具链

存在专门的裸机工具链:aarch64-none-elf

区别:

aarch64-none-elf-gcc

  • "none"表示没有操作系统
  • 自带的C库是newlib(最小化实现)
  • 功能精简,很多高级功能不支持
  • 需要自己实现更多底层功能

aarch64-linux-gnu-gcc

  • 自带完整的glibc
  • 功能全面,开箱即用
  • 遵循Linux ABI规范,但不强制需要Linux内核

U-Boot选择后者的原因:功能需求复杂,需要完整的C库支持。


第三部分:ABI(gnu)

ABI规定的具体内容

ABI(Application Binary Interface,应用程序二进制接口)是一份详细的技术规范文档,规定了二进制代码层面的协作规则。


规则1:函数调用约定

问题场景:

文件A的函数调用文件B的函数,并传递3个参数。两个文件分别编译后,机器码如何配合?

ABI规定:

  • 参数1 → X0寄存器
  • 参数2 → X1寄存器
  • 参数3 → X2寄存器
  • 如果参数超过8个,剩余参数通过栈传递
  • 返回值 → X0寄存器(如果是64位以内的值)

编译器行为:

  • 编译文件A时,生成的调用代码把参数放入X0、X1、X2
  • 编译文件B时,生成的函数代码从X0、X1、X2读取参数
  • 两段代码能正确配合,因为遵循相同规则

如果违反规则:

假设编译器A把参数放在X0、X1、X2,编译器B从X3、X4、X5读取,结果参数错乱,程序崩溃。


规则2:寄存器保存责任

ARM64有31个通用寄存器(X0-X30)。函数调用时,寄存器中可能有重要数据。

问题:谁负责保存这些数据?

ABI规定:

调用者保存寄存器(X0-X18):

  • 函数可以随意修改这些寄存器
  • 调用者如果需要保留数据,必须在调用前自己保存

被调用者保存寄存器(X19-X29):

  • 函数如果要使用这些寄存器,必须先保存原值
  • 函数返回前必须恢复这些寄存器

举例:

c 复制代码
int func1(int a) {
    int x = a + 5;      // x可能存在X19
    int y = func2(x);   // 调用func2
    return x + y;       // 返回后x的值必须还在
}

因为X19是被调用者保存寄存器,func2必须保证不破坏X19的值。如果func2需要用X19,必须先把原值压栈保存,返回前恢复。这样func1返回后x的值依然正确。


规则3:数据类型大小和对齐

ABI规定基本数据类型的大小:

  • char: 1字节
  • short: 2字节
  • int: 4字节
  • long: 8字节(64位系统)
  • long long: 8字节
  • 指针: 8字节(64位系统)

对齐规则:

  • 2字节数据必须存储在偶数地址
  • 4字节数据必须存储在4的倍数地址
  • 8字节数据必须存储在8的倍数地址

为什么需要对齐:

CPU从内存读取数据时,通常一次读取4字节或8字节。如果数据未对齐,CPU需要多次读取并拼接,效率低。某些处理器甚至会因未对齐访问产生异常。

编译器行为:

编译器在分配变量地址时,自动插入填充字节保证对齐。

例如:

c 复制代码
struct {
    char a;    // 1字节
    int b;     // 4字节
} s;

编译器会在a后面插入3字节填充,保证b从4的倍数地址开始。


规则4:栈的组织方式

函数调用时使用栈来存储局部变量、保存寄存器、传递多余参数。

ABI规定:

  • 栈向低地址方向增长(栈顶地址递减)
  • 栈指针(SP寄存器)必须保持16字节对齐
  • 每个函数的栈帧包含:返回地址、保存的寄存器、局部变量

为什么必须16字节对齐:

ARM64的某些指令(如向量加载/存储指令)要求操作数16字节对齐。如果栈未对齐,使用这些指令会出错。


规则5:系统调用接口(如果有OS)

当程序运行在操作系统上时,需要调用OS提供的服务。

Linux ABI规定:

  • 使用SVC #0指令触发系统调用
  • 系统调用号 → X8寄存器
  • 参数1-6 → X0-X5寄存器
  • 返回值 → X0寄存器
  • 错误码 → 负数返回值

编译器生成的系统调用包装函数遵循这个规则。

虽然U-Boot不运行在Linux上,但C库中某些函数的实现框架遵循这套规则,只是在裸机环境下相关功能可能是空实现或自己实现的版本。


"gnu"的含义

ABI规范有多个组织制定了不同版本:

GNU组织的ABI规范:

  • 最广泛使用
  • Linux系统默认采用
  • glibc(GNU C Library)按照这个规范实现
  • 特点:功能完整,符合POSIX标准

其他ABI变种:

  • gnueabi:GNU的旧版EABI(嵌入式ABI)
  • gnueabihf:硬件浮点版本
  • musl:使用musl C库的ABI

aarch64-linux-gnu中的"gnu"表示遵循GNU组织制定的ABI规范,使用glibc作为C标准库。


三部分的协同关系

这三个标识不是孤立的,而是层层约束:

aarch64(架构):

  • 决定生成什么指令集的机器码
  • 决定寄存器的数量和宽度
  • 决定基本的指令编码规则

linux(操作系统):

  • 在架构基础上,进一步规定系统调用接口
  • 决定使用哪个C库的实现
  • 影响某些库函数的行为

gnu(ABI):

  • 在操作系统基础上,细化函数调用约定
  • 规定数据类型大小和对齐
  • 规定寄存器使用规则

三者共同确定了编译器的行为模式,保证生成的代码能正确运行在目标平台上。


总结

aarch64-linux-gnu-这个前缀的含义:

  1. aarch64:生成ARMv8 64位架构的A64指令集
  2. linux:遵循Linux平台的ABI规范,使用完整的glibc
  3. gnu:采用GNU组织制定的标准,包括调用约定、数据布局等细节规则

这三个标识共同确定了工具链的目标环境,缺一不可。

补充知识点2:什么是C标准库

C语言标准的规定

C语言是一个标准化的语言。标准文档(比如C99、C11)规定了:

语言特性:

  • 关键字:intifwhile
  • 语法规则:如何写函数、如何定义变量

标准库函数: 文档明确规定了一组必须提供的函数,包括:

输入输出(stdio.h):

  • printf:格式化输出
  • scanf:格式化输入
  • fopen:打开文件
  • fclose:关闭文件
  • freadfwrite:读写文件

字符串处理(string.h):

  • strcpy:复制字符串
  • strlen:计算字符串长度
  • strcmp:比较字符串
  • strcat:连接字符串

内存管理(stdlib.h):

  • malloc:申请内存
  • free:释放内存
  • calloc:申请并清零内存
  • realloc:重新分配内存

数学函数(math.h):

  • sincostan:三角函数
  • sqrt:平方根
  • pow:幂运算
  • log:对数

其他功能:

  • 字符处理(ctype.h)
  • 时间日期(time.h)
  • 错误处理(errno.h)
  • 等等...

这一整套标准规定的函数集合,称为C标准库。


C标准库只是规范,不是实现

重要的是:C标准只规定了这些函数的功能、参数、返回值,并没有提供实际的代码实现

标准库就像一份"合同":

  • 规定了printf这个函数必须存在
  • 规定了它的功能:格式化输出
  • 规定了参数格式:printf(const char *format, ...)
  • 但没有给出具体怎么实现

实际的实现代码,由不同组织或公司提供。


glibc是什么

多个C标准库实现

既然C标准只规定功能,不提供实现,那么就有多个组织实现了C标准库:

glibc(GNU C Library):

  • GNU组织开发
  • Linux系统的默认C库
  • 功能最完整,性能好
  • 体积较大(因为功能全面)

musl libc:

  • 轻量级C库
  • 代码简洁,体积小
  • 适合嵌入式系统
  • 某些Linux发行版(如Alpine Linux)使用它

uClibc / uClibc-ng:

  • 专门为嵌入式系统设计
  • 体积非常小
  • 可配置,可以裁剪不需要的功能

newlib:

  • 专门为嵌入式和裸机环境设计
  • 功能精简
  • 很多交叉编译工具链使用它

BSD libc:

  • BSD系统(FreeBSD、OpenBSD等)使用的C库
  • 许可证不同(BSD许可证,比GPL宽松)

Microsoft C Runtime:

  • Windows系统使用的C库
  • 实现了C标准,但有些扩展功能
  • 文件名:msvcrt.dll

glibc的特点

特点1:完整性 glibc实现了完整的C标准库功能,还提供了很多扩展功能:

  • POSIX标准的函数(Linux特有)
  • 多线程支持(pthread)
  • 网络编程接口
  • 动态链接支持

特点2:性能优化 glibc对常用函数做了大量优化:

  • memcpymemset等内存操作函数使用了CPU的SIMD指令加速
  • 针对不同CPU架构有专门优化的版本
  • malloc的内存分配算法经过多年优化

特点3:广泛使用 几乎所有Linux发行版都使用glibc:

  • Ubuntu、Debian、Fedora、CentOS等
  • 最成熟、测试最充分
  • 生态系统完善

特点4:体积较大 因为功能全面,glibc的体积比其他C库大:

  • 完整的glibc库文件可能有2-3MB
  • 对于资源受限的嵌入式系统,可能过大

"工具链自带glibc"是什么意思

编译过程中C库的作用

你的代码调用printf时:

c 复制代码
printf("Hello\n");

编译器生成的是一条函数调用指令,跳转到printf函数的地址。

但问题:printf的实现代码在哪里?


两种链接方式

静态链接: 链接时,把C库中函数的完整实现代码复制到你的程序里。

结果:

  • 你的程序文件包含了printf的完整代码
  • 程序体积变大
  • 程序独立运行,不依赖外部文件

动态链接: 链接时,只在你的程序中记录"需要调用库中的printf"这个信息。

结果:

  • 你的程序文件不包含printf的代码
  • 程序体积小
  • 运行时,操作系统加载C库文件(比如libc.so.6),程序从库文件中调用printf
  • 程序依赖C库文件,如果库文件缺失,程序无法运行

"自带"的含义

当你安装交叉编译工具链aarch64-linux-gnu-gcc时,工具链包中包含:

  1. 编译器、链接器等工具程序
  2. 一份完整的glibc库文件

这份glibc库文件的特点:

  • 是ARM64架构编译的(不是x86)
  • 放在工具链安装目录下
  • 链接时,链接器会自动使用这份库

"自带"的意思:工具链安装包里已经包含了编译好的glibc库文件,不需要你另外下载或编译。


具体的文件位置

安装工具链后,库文件通常在:

复制代码
/usr/aarch64-linux-gnu/lib/

这个目录下有:

  • libc.so.6:动态链接的C库主文件
  • libc.a:静态链接的C库文件
  • libm.so.6:数学库
  • libpthread.so.0:多线程库
  • 等等...

编译U-Boot时,链接器会从这个目录找需要的库文件,把相关函数的实现代码链接进来。


相关概念和词汇

库的类型

静态库(Static Library):

  • 文件扩展名:.a(Linux)或.lib(Windows)
  • 本质:一堆.o目标文件打包在一起
  • 使用:链接时复制代码到你的程序
  • 优点:程序独立,不依赖外部文件
  • 缺点:程序体积大,浪费磁盘和内存

动态库(Dynamic Library / Shared Library):

  • 文件扩展名:.so(Linux)或.dll(Windows)
  • 本质:可执行的二进制文件,包含函数实现
  • 使用:运行时加载,多个程序共享同一个库文件
  • 优点:程序体积小,节省空间,库升级时不需要重新编译程序
  • 缺点:程序依赖库文件,库缺失或版本不匹配会导致程序无法运行

库文件的命名规则(Linux)

格式:

复制代码
lib<名字>.so.<主版本号>.<次版本号>.<修订号>

例如:

复制代码
libc.so.6.2.33
  • lib:固定前缀
  • c:库的名字(C标准库)
  • .so:动态库
  • 6:主版本号(API发生不兼容变化时递增)
  • 2.33:次版本号和修订号(glibc的具体版本)

常见的库名字:

  • libc:C标准库
  • libm:数学库(math.h的实现)
  • libpthread:POSIX线程库
  • libstdc++:C++标准库
  • libssl:OpenSSL加密库
  • libz:zlib压缩库

运行时库(Runtime Library)

含义: 程序运行时需要的库文件。

对于C程序,运行时库主要就是C标准库(glibc)。

运行时库的作用:

  • 提供标准函数的实现
  • 提供程序启动和退出的初始化代码
  • 提供内存分配器(malloc/free)
  • 提供线程支持

每种语言都有自己的运行时库:

  • C语言:glibc(Linux)、msvcrt.dll(Windows)
  • C++:libstdc++(GCC)、libc++(Clang)
  • Java:JRE(Java Runtime Environment)
  • Python:Python解释器本身

ABI兼容性

问题场景: 你的程序用旧版本glibc(比如2.31)编译,目标系统上安装的是新版本glibc(比如2.35)。程序能运行吗?

ABI兼容性保证: glibc承诺:新版本向后兼容旧版本

意思是:

  • 用glibc 2.31编译的程序,可以在glibc 2.35上运行
  • 反过来不行:用2.35编译的程序,不能在2.31上运行(可能使用了新函数)

版本号的意义: 程序文件中会记录"需要glibc 2.31或更高版本"。操作系统检查本地glibc版本,如果太旧,拒绝运行程序。


工具链中C库的选择

不同工具链使用不同的C库:

aarch64-linux-gnu-gcc:

  • 使用glibc
  • 功能完整
  • 适合需要完整C标准库的项目(如U-Boot)

aarch64-none-elf-gcc:

  • 使用newlib
  • 功能精简
  • 适合资源极度受限的裸机程序

aarch64-linux-musl-gcc:

  • 使用musl libc
  • 体积小,性能好
  • 适合静态链接的独立程序

头文件和库文件的关系

头文件(.h):

  • 包含函数声明、类型定义、宏定义
  • 给编译器看的
  • 编译时需要
  • 例如:stdio.h声明了printf函数

库文件(.a 或 .so):

  • 包含函数的实际实现代码(二进制机器码)
  • 给链接器看的
  • 链接时需要
  • 例如:libc.so.6包含printf的实现代码

关系:

c 复制代码
#include <stdio.h>    // 头文件,让编译器知道printf的参数和返回值类型
printf("Hello\n");    // 编译器生成调用指令
                      // 链接器从libc.so.6中找到printf的实现代码

工具链"自带"的是:头文件和库文件都包含

头文件在:

复制代码
/usr/aarch64-linux-gnu/include/

库文件在:

复制代码
/usr/aarch64-linux-gnu/lib/

总结

库(Library): 预先实现好的函数代码集合,供程序调用。

C标准库: C语言标准规定的必须提供的函数集合,包括输入输出、字符串处理、内存管理等。

glibc(GNU C Library): GNU组织实现的C标准库,功能最完整,Linux系统的默认C库。

工具链自带glibc: 工具链安装包中包含了编译好的glibc库文件(包括头文件和二进制库),链接时自动使用。

相关概念:

  • 静态库(.a):链接时复制代码
  • 动态库(.so):运行时加载
  • 运行时库:程序运行时需要的库
  • ABI兼容性:库版本升级的兼容性保证

其他C库实现: musl、newlib、uClibc、BSD libc等,各有特点和适用场景。

相关推荐
稚辉君.MCA_P8_Java5 小时前
Java 基本数据类型 - 四类八种
java·linux·后端·mysql·架构
虚伪的空想家5 小时前
HUAWEI A800I A2 aarch64架构Ubuntu服务器鲲鹏920开启 IOMMU/SMMU 硬件虚拟化功能
linux·服务器·ubuntu
咚璟6 小时前
博客内容目录
嵌入式
赖small强6 小时前
[Linux] 内核链表实现详解
linux·内核链表·双向循环链表·list.h·list_head
laocooon5238578866 小时前
运行当前位置,显示文件全名,检查是否扩展名多次重叠
stm32·单片机·嵌入式硬件
Linux技术芯6 小时前
浅谈kswapd按照什么原则来换出页面的底层原理
linux
獭.獭.6 小时前
Linux -- 线程控制
linux·pthread·线程分离·线程取消·线程局部存储·lwp·线程栈
feng_blog66886 小时前
环形缓冲区实现共享内存
linux·c++
chen36736 小时前
嵌入式AI Arm_linux_第一个Demo_让IPU跑起来
linux·arm开发·人工智能