编译流程与ELF文件
GCC编译流程
先看一段最简单的 C 代码:
c
#include <stdio.h>
int data = 10;
int main(void) {
printf("%d\n", data);
return 0;
}
GCC 编译 4 个阶段,每一步都生成一个新文件:
| 阶段 | 命令 | 作用 | 输出文件 |
|---|---|---|---|
| 预处理 | gcc -E test.c -o test.i | 处理头文件、宏定义、条件编译 | 预处理后的 .i 文件 |
| 编译 | gcc -S test.i -o test.s | 词法 / 语法分析,翻译成汇编指令 | 汇编 .s 文件 |
| 汇编 | as test.s -o test.o | 把汇编指令转成机器码 | 可重定位目标文件 .o |
| 链接 | ld -o test test.o -lc | 合并 .o + 库文件,解决地址引用 | 可执行二进制文件 |
ELF文件
不管是 .o 目标文件,还是最终的 test 可执行文件,它们都遵循 ELF(Executable and Linkable Format)标准格式,Linux 系统里的可执行文件几乎都是这个格式。它的结构可以分成三大块:
① ELF 文件头(ELF header):整个文件的"身份证"
包含:文件类型(32/64 位)、目标架构(比如 AArch64)、程序入口地址、段头 / 程序头的位置和数量等基础信息。
bash
rlk@rlk:test$ readelf -h elf_read
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Shared object file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 14744 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
② 程序头表(Program header table):给操作系统用的"加载说明书"
它告诉内核:把文件里的哪些部分加载到内存的什么位置、需要什么权限(读 / 写 / 执行)。只有可执行文件才有完整的程序头,目标文件一般没有。
③ 段头表(Section header table):给链接器 / 调试器用的"文件目录"
它记录了文件里所有"段(section)"的信息,比如段的名字、在文件中的偏移、大小、读写权限等。
bash
rlk@rlk:test$ readelf -S elf_read
[16] .text PROGBITS 0000000000001060 00001060
0000000000000195 0000000000000000 AX 0 0 16
[18] .rodata PROGBITS 0000000000002000 00002000
0000000000000008 0000000000000000 A 0 0 4
[25] .data PROGBITS 0000000000004000 00003000
0000000000000014 0000000000000000 WA 0 0 8
[26] .bss NOBITS 0000000000004014 00003014
0000000000000004 0000000000000000 WA 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
l (large), p (processor specific)
ELF里常见的段
你可以把这些段理解成文件里的"不同分区",每个分区存不同类型的数据:
| 段名 | 全称 | 作用 | 权限 |
|---|---|---|---|
| .text | 代码段 | 存放你写的代码编译后的机器指令,比如 main 函数、printf 函数的指令 | 只读 + 可执行 |
| .rodata | 只读数据段 | 存放程序里的常量,比如字符串 "hello world"、宏定义的常量 | 只读 |
| .data | 数据段 | 存放已初始化的全局变量 / 静态变量,比如你代码里的 int data = 10; | 可读 + 可写 |
| .bss | 未初始化数据段 | 存放未初始化的全局变量 / 静态变量,运行时才会分配内存并清零 | 可读 + 可写 |
| .symtab | 符号表 | 存放函数、变量的符号信息,链接器用它解决外部引用,调试器也靠它定位符号 | - |
| .rel.text/.rel.data | 重定位段 | 存放链接时需要修改的地址信息,比如外部函数调用的地址占位符 | - |
| .debug | 调试段 | 存放调试信息,比如变量名、行号,gdb 调试时会用到 | - |
目标文件和可执行文件的区别
① .o 目标文件
- 多个 .o 文件是相互独立的,每个文件里的函数 / 变量地址都是 0(还没确定最终地址)
- 没有程序头表,段的起始地址都是 0
- 有重定位段,记录了所有需要链接时修改的地址
② test 可执行文件
- 链接器把多个 .o 文件合并,给每个段分配了最终的虚拟地址,比如 .text 段的地址是 0x4002E0
- 生成了完整的程序头表,告诉操作系统怎么加载这个程序到内存
- 解决了所有外部符号引用,重定位段的任务已经完成,部分可执行文件会去掉重定位信息
一个简单的汇编程序
下面通过一个完整的 ARM64 汇编程序,把前面讲的 ELF 段、符号表、编译链接流程串起来。这段代码定义了两个全局变量,把它们相加后用 printf 打印出来。
asm
.section .data
.align 3
my_data1: .quad 100
my_data2: .word 50
print_data: .string "data: %d\n"
.align 3
.section .text
.global main
main:
stp x29, x30, [sp, #-16]!
ldr x5, =my_data1
ldr x2, [x5]
ldr x6, =my_data2
ldr x3, [x6]
add x1, x2, x3
ldr x0, =print_data
bl printf
ldp x29, x30, [sp], #16
ret
代码解析
① 数据段定义(第 2-13 行)
asm
.section .data
.align 3
my_data1: .quad 100 // 64位变量,值为100,地址8字节对齐
my_data2: .word 50 // 32位变量,值为50
print_data: .string "data: %d\n" // 格式化字符串
这部分定义了程序运行时需要的数据,全部会被存放在 .data 段中。
② 代码段主体(第 14-33 行)
asm
.section .text
.global main
main:
stp x29, x30, [sp, -16]! // 保存栈帧和返回地址(进入函数前的标准操作)
// 加载 my_data1 的值到 x2
ldr x5, =my_data1 // 把 my_data1 的地址加载到 x5
ldr x2, [x5] // 从 x5 地址读取 64 位数据到 x2 → x2 = 100
// 加载 my_data2 的值到 x3
ldr x6, =my_data2 // 把 my_data2 的地址加载到 x6
ldr x3, [x6] // 从 x6 地址读取 32 位数据到 x3 → x3 = 50
add x1, x2, x3 // x1 = x2 + x3 = 100 + 50 = 150(printf的第二个参数)
ldr x0, =print_data // 把格式化字符串地址加载到 x0(printf的第一个参数)
bl printf // 调用C库的printf函数,打印 "data: 150"
ldp x29, x30, [sp], 16 // 恢复栈帧和返回地址
ret // 函数返回
这部分代码的核心逻辑非常清晰:保存函数调用上下文(栈帧、返回地址);读取两个全局变量的值并相加;调用 printf 打印结果;恢复上下文并返回。
符号表解读
使用 readelf -s test 命令用来查看 ELF 文件的符号表,里面记录了所有符号的地址、类型、绑定属性等信息。
① 关键符号解读
| 符号名 | 地址 | 类型 | 作用 |
|---|---|---|---|
| my_data1 | 0x411008 | LOCAL | 数据段中定义的 64 位变量,值为 100 |
| my_data2 | 0x411010 | LOCAL | 数据段中定义的 32 位变量,值为 50 |
| print_data | 0x411018 | LOCAL | 只读数据段中的格式化字符串 |
| main | 0x4002B0 | GLOBAL | 程序入口函数,链接器通过这个符号找到入口 |
| _edata / _end | 0x411020 | GLOBAL | 链接器自动生成的符号,标记数据段 / 程序的结束地址 |
② 为什么这些符号的地址不一样?
main 在 .text 段,地址从 0x400000 开始;my_data1/my_data2 在 .data 段,地址从 0x411000 开始。这是因为链接器把不同段分配到了不同的虚拟地址区域,代码段和数据段分开存储,保护程序安全。
汇编语法
注释
GNU 汇编支持两种注释方式:
① 单行注释
//:和 C 语言一样,注释从 // 到行尾#:也可以注释整行或行尾,是汇编里更常见的写法
② 多行注释
/* ... */:和 C 语言一样,中间内容跨越多行也会被当成注释
符号(Symbol)
符号是汇编里的核心概念,相当于给地址起的名字,链接器、调试器都靠它工作。
① 符号的构成规则
- 只能由以下字符组成:大小写字母(a-z, A-Z)、数字(0-9)、下划线 _、点号 .、美元符号 $
② 符号的作用
- 标记代码 / 数据的地址,比如 main:、my_data:
- 链接器靠它解决跨文件引用(比如 C 调用汇编函数)
- 调试器靠它定位代码位置
③ 符号的分类
| 类型 | 声明方式 | 作用 | 特点 |
|---|---|---|---|
| 全局符号 | .global 符号名 | 可以被其他文件引用,比如 main 函数 | 会出现在 ELF 符号表中 |
| 本地符号 | 通常用 .L 前缀,比如 .Lloop | 只在当前文件内部使用 | 不会出现在符号表,调试时不可见 |
本地标签(Local Label)
① 什么是本地标签?
- 用 0~99 的数字定义,比如 1:、2:
- 只在当前汇编文件内有效,可以重复定义
- 配合 f(forward,向前)和 b(backward,向后)使用
② f 和 b 怎么用?
- Nf:向前搜索,找后面最近的 N: 标签(向前就是向下)
- Nb:向后搜索,找前面最近的 N: 标签(向后就是向上)
常用的伪指令
伪指令不是真正的机器指令,而是给汇编器看的预处理命令,只在汇编阶段起作用,不会生成可执行代码。它的作用包括:
- 定义符号、数据、段
- 控制汇编流程(条件编译、循环)
- 定义宏(重复代码模板)
.align 对齐伪指令
作用:让代码 / 数据按指定字节数对齐,提升 CPU 访问效率,避免未对齐异常。
语法:.align n , fill \[, maxskip]
- n:对齐参数,在 ARM64 中表示按 2^n 字节对齐。比如 .align 2 → 按 2^2=4 字节对齐;.align 5 → 按 32 字节对齐。
- fill:填充字节,省略时默认用 0,代码段默认用 nop。
- maxskip:对齐时最多跳过的字节数,超过则不执行对齐。
例子解析
asm
.align 2 // 按4字节对齐,默认填充0/nop
.align 5,0,100 // 按32字节对齐,填充0,最多跳过100字节
.align 5,0,8 // 按32字节对齐,但最多跳过8字节,不足则不执行
数据定义伪指令
用来在 .data/.rodata 段定义数据,直接把值编译进目标文件。
| 伪指令 | 作用 | 大小 |
|---|---|---|
| .byte | 定义 8 位数据 | 1 字节 |
| .hword/.short | 定义 16 位数据 | 2 字节 |
| .word/.long/.int | 定义 32 位数据 | 4 字节 |
| .quad | 定义 64 位数据 | 8 字节 |
| .float/.double | 定义浮点数 | 4/8 字节 |
| .ascii | 定义字符串,不自动加 \0 | 按字符数 |
| .asciz/.string | 定义字符串,自动加 \0 | 字符数 + 1 |
① .rept/.endr:重复执行
用来重复一段代码 / 数据定义,减少重复输入。
asm
.rept 3 // 重复3次
.long 0 // 定义3个0
.endr
等价于:
asm
.long 0
.long 0
.long 0
② .equ:给符号赋值
相当于 C 语言的 #define,给符号定义一个常量值。
asm
.equ my_data1, 100 // my_data1 = 100
.equ my_data2, 50 // my_data2 = 50
main:
ldr x2, =my_data1 // 加载100
ldr x3, =my_data2 // 加载50
add x1, x2, x3 // x1 = 150
与段相关的伪指令
用来组织代码和数据到不同的 ELF 段中,链接器会根据段属性分配内存。
① .section:声明段
格式:.section name, "flags"
- name:段名,比如 .text(代码段)、.data(数据段)
- flags :段属性(可组合)
- a:可分配
- w:可写
- x:可执行
- M:可合并
- S:包含零终止字符串
例子:
asm
.section .idmap.text, "awx" // 声明一个可分配、可写、可执行的段
② .pushsection/.popsection:临时切换段
可以临时切换到另一个段,执行完后再切回原来的段,适合在函数中间插入不同段的代码。
asm
.section .text
.global atomic_add
atomic_add:
......
ret // 属于.text段
.pushsection ".idmap.text", "awx" // 临时切换到新段
.global atomic_set
atomic_set:
1: ldxr x2, [x1]
orr x2, x2, x0
strx w3, x2, [x1]
cbnz w3, 1b
ret // 属于.idmap.text段
.popsection // 切回原来的.text段
宏定义伪指令 .macro/.endm
用来定义汇编宏,相当于"代码模板",调用时会展开成具体指令,适合减少重复代码。
① 基本宏定义
asm
.macro add_data p1 p2 // 宏名 + 参数
mov x5, \p1 // 参数前加反斜杠\
mov x6, \p2
add x1, x5, x6
.endm
// 调用宏
add_data x2, x3 /*展开为:mov x5, x2; mov x6, x3; add x1, x5, x6*/
② 宏参数默认值
可以给参数设置默认值,调用时省略则使用默认值。
asm
.macro add_data p1=0 p2
mov x5, \p1
mov x6, \p2
add x1, x5, x6
.endm
add_data , x3 // p1用默认值0,x1=0+x3
add_data x2, x3 // p1=x2,x1=x2+x3
③ 必传参数 .req
在参数后加 .req,表示调用时必须传值,否则编译报错。
asm
.macro add_data_1 p1:req p2
mov x5, \p1
mov x6, \p2
add x1, x5, x6
.endm
add_data_1 , x3 // 报错:缺少必传参数p1
④ 宏参数拼接技巧
用 \() 告诉汇编器宏参数的结束位置,实现字符串拼接。
asm
.macro kernel_ventry, el, label
b el\()\el\()_\label // 拼接成 el1_irq 这样的标签
.endm
// 调用
kernel_ventry 1, irq // 展开为:b el1_irq
基础伪指令:.global 和 .include
① .global(也写作 .globl)
作用:定义一个全局符号,可以是函数,也可以是全局变量。核心用途:让其他文件(比如 C 代码)可以调用或引用这个符号,链接器能找到它。
asm
.global main // 声明main为全局符号,链接器会把它作为程序入口
.global my_var // 声明my_var为全局变量,C代码可以用extern int my_var引用
② .include
作用:在汇编文件中引入头文件,和 C 语言的 #include 用法一样。
asm
#include "asm/uart.h" // 引入uart寄存器定义的头文件
#include "base.h" // 引入基础宏定义
条件编译伪指令
这些指令是汇编器在预处理阶段处理的,用来实现条件编译,根据条件决定哪些代码被编译,哪些被忽略。
① 基础条件结构:.if / .else / .endif
语法:和 C 语言的 #if/#else/#endif 几乎一样。
asm
.if CONFIG_BOARD_PI3B // 只有定义了CONFIG_BOARD_PI3B时,这段代码才会被编译
.word 0x1234
.else // 否则编译这段
.word 0x5678
.endif
② 符号存在判断:.ifdef / .ifndef
| 指令 | 作用 |
|---|---|
| .ifdef symbol | 如果 symbol 被定义了,就编译后面的代码 |
| .ifndef symbol | 如果 symbol 没有被定义,就编译后面的代码 |
asm
// 如果定义了DEBUG,就开启调试代码
.ifdef DEBUG
.global debug_print
debug_print:
ret
.endif
③ 字符串比较:.ifc / .ifeqs
作用:判断两个字符串是否相等。
- .ifc string1, string2:如果两个字符串相等,编译后面的代码
- .ifeqs string1, string2:和 .ifc 完全等价,只是别名
asm
.ifc "arm64", "arm64" // 字符串相等,编译这段
.arch armv8-a
.endif
④ 数值表达式判断:.ifeq / .ifge / .ifle / .ifnc
这些指令用来判断数值表达式的值,汇编器会先计算表达式的值,再判断条件。
| 指令 | 判断条件 | 说明 |
|---|---|---|
| .ifeq expression | 表达式的值为 0 | 等于 0 时编译 |
| .ifge expression | 表达式的值 ≥ 0 | 大于等于 0 时编译 |
| .ifle expression | 表达式的值 ≤ 0 | 小于等于 0 时编译 |
| .ifnc expression | 表达式的值 ≠ 0 | 不等于 0 时编译(和 .ifeq 相反) |
asm
.equ MY_VAL, 10
.ifeq MY_VAL - 10 // 10-10=0,条件成立,编译这段
.word 0
.endif
.ifge MY_VAL - 5 // 10-5=5≥0,条件成立,编译这段
.word 1
.endif
AArch64 特性
AArch64 汇编器的特有命令选项
这些是 as 汇编器的命令行参数,用来控制目标架构、端序、ABI 等编译行为:
| 选项 | 说明 |
|---|---|
| -EB | 大端(big-endian)模式编码 |
| -EL | 小端(little-endian)模式编码(ARM 默认) |
| -mabi=abi | 指定 ABI,AArch64 常用 lp64(64 位指针),决定生成 ELF64 格式文件 |
| -mcpu=processor | 指定目标处理器,如 cortex-a72,汇编器会检查指令是否兼容 |
| -march=architecture | 指定目标架构,如 armv8.1-a,也可搭配扩展(如 +simd) |
| -mverbose-error | 显示详细错误信息 |
| -mno-verbose-error | 关闭详细错误信息 |
AArch64 重定位与 ADRP 指令
这是 AArch64 中最核心的地址加载方式,专门解决 64 位地址的重定位问题。
① 什么是 ADRP?
ADRP(Address of Page)是 PC 相对寻址指令,它的特点:
- 以当前 PC 为基准,按 4KB 页对齐加载地址
- 寻址范围:±4GB(足够覆盖绝大多数场景)
- 加载的是标签所在页的基地址,而不是标签本身的地址
② 如何加载完整地址?
因为 ADRP 只加载页基地址,需要用 #:lo12: 来取页内偏移,组合出完整地址:
asm
adrp x0, foo // 加载foo所在页的基地址(按4KB对齐)
add x0, x0, #:lo12:foo // 加上foo在页内的偏移,得到foo的完整地址
假设 foo 的地址是 0x8000100,4KB 页基地址是 0x8000000:
- ADRP 加载 0x8000000
- #:lo12:foo 提供偏移 0x100
- 最终 x0 = 0x8000100
AArch64 特有的伪指令
这些伪指令专门为 ARM64 架构设计,用来简化汇编代码:
| 伪指令 | 作用 |
|---|---|
| .arch name | 设置目标体系结构,如 .arch armv8-a |
| .arch_extension name | 添加 / 移除架构扩展,如 .arch_extension simd |
| .bss | 切换到 .bss 段(未初始化数据段) |
| .cpu name | 设置目标处理器,如 .cpu cortex-a72 |
| .dword/.xword | 插入 64 位数据(和 .quad 等价) |
| .even | 输出对齐到下一个偶数字节边界 |
| .inst expressions | 将表达式作为指令插入,常用于手动定义机器码 |
| .req name, register | 给寄存器定义别名,如 foo .req w0,之后可以用 foo 代替 w0 |
LDR 伪指令
GNU 汇编器为 AArch64 提供了 ldr reg, =expr 伪指令,用来加载任意地址或常量。
① 伪指令 vs 真实指令
伪指令:ldr x1, =my_label
汇编器会把它翻译成:
- 把 my_label 的值放到文字池(literal pool)
- 用一条 PC 相对的 ldr 指令从文字池读取值到寄存器
② 两种用法的区别
asm
// 用法1:加载常量(伪指令)
#define MY_CONST 0x30
ldr x1, =MY_CONST // 汇编器把0x30放进文字池,再用ldr加载
// 用法2:加载数据地址 + 读取数据
my_data: .quad 0x100
ldr x1, =my_data // 伪指令:把my_data的地址加载到x1
ldr x2, [x1] // 真实指令:从x1地址读取数据,x2=0x100
第一条 ldr x1, =my_data 是伪指令,加载的是 my_data 的链接地址;第二条 ldr x2, x1 是真实的内存加载指令,读取 my_data 存储的值。