从源程序到二进制文件
- 程序的编译过程,其实就是将我们编写的C源程序翻译成CPU能够识别和运行的二进制机器指令的过程
- 编译器在编译程序时会根据这些函数声明对我们的源程序进行语法检查:检查实参类型、返回结果类型和函数声明的类型是否匹配。
readelf-h
命令主要用来获取可执行文件的头部信息,主要包括可执行文件运行的平台、软件版本、程序入口地址,以及program headers
、section header
等信息。
可执行文件 ELF 组成
组成部分 | 描述 |
---|---|
ELF header | 用来描述文件类型、要运行的处理器平台、入口地址等信息。当程序运行时,加载器会根据此文件头来获取可执行文件的一些信息。 |
program header table | 程序头表,描述了文件中各个段的布局。 |
.init | 初始化代码段,初始化C程序运行所依赖的环境,如内存堆栈的初始化等。 |
.text | 函数翻译成二进制指令 |
.rodata | 只读数据段(定义的一些字符串、printf函数打印的字符串常量) |
.data | 数据段,包含程序中会改变的数据。 |
.bss | 未初始化的全局变量和静态变量会放置在BSS段中,在可执行文件中BSS段不占用空间。 程序运行时,加载器会根据这些信息在内存中紧挨着数据段的后面为 BSS 段开辟一片存储空间,为各个变量分配存储单元。 |
.symtab | 符号表,包含程序中所有符号的信息。 |
.debug | 调试信息段,保存可执行文件中每一条二进制指令对应的源码位置信息。。 |
.line | 行号信息段,用于调试时映射代码到源代码行。 |
.strtab | 字符串表,包含符号表中符号名称的字符串。 |
section header table | 节头表,描述了文件中各个节的布局。 |
程序的整个编译流程主要分为以下几个阶段:预处理、编译、汇编、链接。
- 在一个多文件的
C
项目中,编译器是以C源文件为单位进行编译 - 预处理器:将源文件
main.c
经过预处理变为main.i
。 - 编译器:将预处理后的
main.i
编译为汇编文件main.s
。 - 汇编器:将汇编文件
main.s
编译为目标文件main.o
(生成可重定位的目标文件,不可执行) - 链接器:将各个目标文件
main.o
、sub.o
链接,重定位成可执行文件a.out
。
目标文件一般可以分为
3
种:
- 可重定位的目标文件(
relocatable files
)。 - 可执行的目标文件(
executable files
)。 - 可被共享的目标文件(
shared object files
)。
可被共享的目标文件一般以共享库的形式存在,在程序运行时需要动态加载到内存,跟应用程序一起运行
预处理过程
常见预处理命令
- 头文件包含:
#include
。 - 定义一个宏:
#define
。 - 条件编译:
#if
、#else
、#endif
。 - 编译控制:
#pragma
。 - 通过条件编译可以让代码兼容不同的处理器架构和平台,以最大限度地复用公用代码
通过
#pragma
预处理命令可以设定编译器的状态,指示编译器完成一些特定动作。
#pragma pack([n])
:指示结构体和联合成员的对齐方式。#pragma message("string")
:在编译信息输出窗口打印自己的文本信息。#pragma warning
:有选择地改变编译器的警告信息行为。#pragma once
:在头文件中添加这条指令,可以防止头文件多次编译。
预处理流程
- 头文件展开:将
#include
包含的头文件内容展开到当前位置。 - 宏展开:展开所有的宏定义,并删除
#define
。 - 条件编译:根据宏定义条件,选择要参与编译的分支代码,其余的分支丢弃。
- 删除注释。
- 添加行号和文件名标识:编译过程中根据需要可以显示这些信息。
- 保留
#pragma
命令:该命令会在程序编译时指示编译器执行一些特定行为。
程序的编译
编译阶段主要分两步
- 第一步:编译器调用一系列解析工具,去分析这些C代码,将C源文件编译为汇编文件
- 第二步:通过汇编器将汇编文件汇编成可重定位的目标文件。
从C文件到汇编文件
一个汇编文件是以段为单位来组织程序的:
- 代码段、数据段、
BSS
段等,各个段之间相互独立。我们可以使用AREA
或.section
伪操作来定义一个段。 - 汇编指令就是二进制指令的助记符,唯一的差异就是汇编语言的程序结构需要使用各种伪操作来组织。汇编文件经过汇编器汇编后,处理掉各种伪操作命令,就是二进制目标文件。
编译流程:
- 词法分析
- 语法分析
- 语义分析
- 中间代码生成
- 汇编代码生成
- 目标代码生成
- 通过有限状态机解析并识别这些字符流,将源程序分解为一系列不能再分解的记号单元——
token
。 token
是字符流解析过程中有意义的最小记号单元- 经过词法扫描器扫描分析后,就分解成了8个
token
:“sum”“=”“a”“+”“b”“/”“c”“;”
,很多C语言初学者在编写程序时,不小心输入了中文符号、圆角/半角字符导致编译出错,其实就发生在这个阶段。 - 语法分析工具在对token序列分析过程中,如果发现不能构建语法上正确的语句或表达式,就会报语法错误:
syntax error
。如果程序语句后面少了一个语句结束符分号或者在for循环中少了一个分号,报的错误都属于这种语法错误。 - 中间码一般和平台是无关的
- 使用
arm-linux-gnueabi-gcc-S
命令或反汇编可执行文件,即可看到汇编代码的具体实现
汇编过程
汇编过程是使用汇编器将前一阶段生成的汇编文件翻译成目标文件。
- 汇编器的主要工作就是参考
ISA
指令集,将汇编代码翻译成对应的二进制指令,同时生成一些必要的信息,以section
的形式组装到目标文件中,后面的链接过程会用到这些信息。 main.o
和sub.o
是不可执行的,属于可重定位的目标文件,它们要经过链接器重定位、链接之后,才能组装成一个可执行的目标文件a.out
。
通过编译生成的可重定位目标文件,都是以零地址为链接起始地址进行链接
- 在每个可重定位目标文件中,函数或变量的地址其实就是它们在文件中相对于零地址的偏移
- 重定位:链接器将各个目标文件组装在一起后,我们需要重新修改各个目标文件中的变量或函数的地址。
- 重定位表:把需要重定位的符号收集起来,以
.section
的形式保存到每个可重定位目标文件中 - 符号表:一个文件中的所有符号,无论是函数名还是变量名,无论其是否需要重定位,我们一般也会收集起来,以
.section
的形式添加到每一个可重定位目标文件中
符号表与重定位表
符号表和重定位表是非常重要的两个表,这两个表为链接过程提供各种必要的信息
- 在整个编译过程中,符号表主要用来保存源程序中各种符号的信息,包括符号的地址、类型、占用空间的大小等。
- 符号表本质上是一个结构体数组,在
ARM
平台下,定义在Linux
内核源码的/arch/arm/include/asm/elf.h
文件中。
typedef struct elf32_sym {
Elf32_Word st_name; // 符号名,字符串表中的索引
Elf32_Addr st_value; // 符号对应的值
Elf32_Word st_size; // 符号大小,如int类型数据符号=4
unsigned char st_info; // 符号类型和绑定信息
unsigned char st_other;// 保留字段,通常未使用
Elf32_Half st_shndx; // 符号所在的段
} Elf32_Sym;
符号值本质上是一个地址
- 可以是绝对地址,一般出现在可执行目标文件中;
- 也可以是一个相对地址,一般出现在可重定位目标文件中。
符号的类型
OBJECT
:对象类型,一般用来表示我们在程序中定义的变量。FUNC
:关联的是函数名或其他可引用的可执行代码。FILE
:该符号关联的是当前目标文件的名称。SECTION
:表明该符号关联的是一个section,主要用来重定位COMMON
:表明该符号是一个公用块数据对象,是一个全局弱符号,在当前文件中未分配空间。TLS
:表明该符号对应的变量存储在线程局部存储中。NOTYPE
:未指定类型,或者目前还不知道该符号类型。
引用的其他文件中定义的函数和全局变量
- 在声明后,编译器就会认为你引用的这个全局变量或函数可能在其他文件、库中定义,在编译阶段暂时不会报错。
- 在后面的链接过程中,链接器会尝试在其他文件或库中查找你引用的这个符号的定义,如果真的找不到才会报错,此时的错误类型是链接错误。
- 编译器在给每个目标文件生成符号表的过程中,如果在当前文件中没有找到符号的定义,也会将这些符号搜集在一起并保存到一个单独的符号表中,以待后续填充,这个符号表就是重定位符号表。
- 在
.o
中会使用一个重定位表.rel.text
来记录这些需要重定位的符号
链接过程
- 链接主要分为
3
个过程:分段组装、符号决议和重定位。
分段组装
链接过程的第一步,就是将各个目标文件分段组装
- 程序在链接程序时需要指定一个链接起始地址,链接开始地址一般也就是程序要加载到内存中的地址
- 通过链接脚本指定程序的链接地址和各个段的组装顺序。
- 链接脚本本质上是一个脚本文件。在这个脚本文件里,不仅规定了各个段的组装顺序、起始地址、位置对齐等信息,同时对输出的可执行文件格式、运行平台、入口地址等信息做了详细的描述,并最终将这些信息以
section
的形式保存到可执行文件的ELF Header
中 - 程序运行时,加载器首先会解析可执行文件中的
ELF Header
头部信息,验证程序的运行平台和加载地址信息,然后将可执行文件加载到内存中对应的地址,程序就可以正常运行。 GCC
编译器的默认链接脚本在/usr/lib/scripts
目录下- 在一个由带有
MMU
的CPU
搭建的嵌入式系统中,程序的链接起始地址往往都是一个虚拟地址,程序运行过程中还需要地址转换,通过MMU
将虚拟地址转换为物理地址,然后才能访问内存
符号决议
使用符号决议规则解决多个相同的全局变量名和函数名
- 一山不容二虎。
- 强弱可以共存。
- 体积大者胜出。
- 函数名、初始化的全局变量是强符号,而未初始化的全局变量则是弱符号。
- 当强弱符号共存时,强符号会覆盖掉弱符号,链接器会选择强符号作为可执行文件中的最终符号。
COMMON
-未初始化的全局变量
- 在程序编译期间,编译器在分析每个文件中时,并不知道该符号在链接阶段是被采用还是被丢弃
- 因此在程序编译期间,未初始化的全局变量并没有被直接放置在
BSS
段中,而是将这些弱符号放到一个叫作COMMON
的临时块中,在符号表中使用一个未定义的COMMON
来标记,在目标文件中也没有给它们分配存储空间。 - 在链接期间,链接器会比较多个文件中的弱符号,选择占用空间最大的那一个,作为可执行文件中的最终符号,此时弱符号的大小已经确定,并被直接放到了可执行文件的
BSS
段中 GNU C
编译器在ANSI C
语法标准的基础上扩展了一系列C语言语法,如提供了一个__attribute__
关键字用来声明符号的属性。
// 声明一个具有弱链接属性的全局变量n,并初始化为100
__attribute__((weak)) int n = 100;
// 声明一个具有弱链接属性的函数fun
// 如果在其他地方没有定义fun,链接器会忽略这个声明
__attribute__((weak)) void fun();
强引用、弱引用
- 在一个程序中,我们可以定义多个函数和变量,变量名和函数名都是符号,这些符号的本质,或者说这些符号值,其实就是地址
- 我们通过符号去调用一个函数或访问一个变量,通常称之为引用(
reference
),强符号对应强引用,弱符号对应弱引用。
在引用一个符号之前可以先判断该符号是否存在(定义)
- 若对一个符号的引用为强引用,链接时找不到其定义,链接器将会报未定义错误;
- 若对一个符号的引用为弱引用,链接时找不到其定义,则链接器不会报错,运行时才会出错。
重定位
- 经过符号决议,我们解决了链接过程中多文件符号冲突的问题
- 符号表中的每个符号值,也就是每个函数、全局变量的地址,还是原来各个目标文件中的值,还都是基于零地址的偏移。链接器将各个目标文件重新分解组装后,各个段的起始地址都发生了变化。
- 重定位的核心工作就是修正指令中的符号地址,是链接过程中的最后一步,也是最核心、最重要的一步
- 需要重定位的符号在指令代码中的偏移地址
offset
,链接器修正指令代码中各个符号的值时要根据这个地址信息才能从茫茫的二级制代码中找到它们。 - 重定位地址= 新的段地址 + 段内偏移
程序的安装
程序的运行过程,其实就是处理器根据
PC
寄存器中的地址,从内存中不断取指令、翻译指令和执行指令的过程
- 内存
RAM
的优点是支持随机读写,因此可以支持CPU
随机读取指令。 ROM
中存储的数据断电后不会消失,常用来保存程序的指令和数据,但ROM
不支持随机存取,因此程序运行时,会首先将指令和数据从ROM
加载到RAM
,然后CPU
到RAM
中取指令。
程序安装的本质
软件安装的过程其实就是将一个可执行文件安装到
ROM
的过程。
- 在
Linux
环境下,我们一般将可执行文件直接复制到系统的官方路径/bin
、/sbin
、/usr/bin
下,程序运行时直接从这些系统默认的路径下去查找可执行文件,将其加载到内存运行。
在Linux下制作软件安装包
- Linux操作系统一般可分为两派:Redhat系和Debian系。
- Redhat系使用
RPM
包管理机制,而Debian系,像Debian、Ubuntu等操作系统则使用deb
包管理机制。 - 在Debian和Ubuntu环境下,软件压缩包一般为
deb
格式。
ELF 与 BIN/HEX
程序的运行分两种情况:
- 一种是在有OS的环境下执行一个应用程序;
- 一种是在无OS的环境下执行一个裸机程序。
- 在
Linux
环境下,可执行文件是ELF
格式,而在裸机环境下执行的程序一般是BIN/HEX
格式。 BIN/HEX
文件是纯指令文件ELF
文件除了基本的代码段、数据段,还有文件头、符号表、program header table
等用来辅助程序运行的信息。
操作系统环境下的程序运行
- 一个装有操作系统的计算机系统,当执行一个应用程序时,首先会运行一个叫作加载器的程序。
- 段头表中记录的是如何将可执行文件加载到内存的相关信息,包括可执行文件中要加载到内存中的段、入口地址等信息。
section header table
- 可重定位目标文件因为是不可执行的,不需要加载到内存中,所以段头表这个
section
在可定位目标文件中不是必须存在的,是可选的。 - 而在一个可执行文件中,加载器要加载程序到内存,要依赖段头表提供的信息,因此段头表是必需的。
在
Linux
环境下运行的程序一般都会被封装成进程,参与操作系统的统一调度和运行
Shell
环境下运行一个程序,Shell
终端程序一般会先fork
一个子进程,创建一个独立的虚拟进程地址空间- 接着调用
execve
函数将要运行的程序加载到进程空间:通过可执行文件的文件头,找到程序的入口地址,建立进程虚拟地址空间与可执行文件的映射关系,将PC
指针设置为可执行文件的入口地址,即可启动运行。 - 程序的入口地址=编译时的链接地址+一定偏移(程序头等会占用一部分空间)
- 不同的编译器有不同的链接起始地址。在
Linux
环境下,GCC
链接时一般以0x08040000
为起始地址开始存放代码段, ARM GCC
交叉编译器一般以0x10000
为链接起始地址。紧挨着代码段,从一个4KB
边界对齐的地址处开始存放数据段。紧挨着数据段,就是BSS
段。BSS
段后面的第一个4KB地址对齐处,就是程序中使用malloc()/free()
申请的堆空间
程序链接时的链接地址其实都是虚拟地址
- 对于每一个运行的进程,
Linux
内核都会使用一个task_struct
结构体来表示,多个结构体通过指针构成链表。操作系统基于该链表就可以对这些进程进行管理、调度和运行。
裸机环境下的程序运行
- 在操作系统环境下,我们可以通过加载器将程序的指令加载到内存中,然后
CPU
到内存中取指运行。
在一个裸机平台下,系统上电后,没有程序运行的环境,我们需要借助第三方工具将程序加载到内存,然后才能正常运行
- 在一个嵌入式
Linux
系统中,Linux
内核镜像的运行其实就是裸机环境下的程序运行。 - Linux内核镜像一般会借助
U-boot
这个加载工具将其从Flash
存储分区加载到内存中运行,U-boot
在Linux启动过程中扮演了“加载器”的角色。 - 其自身也和Linux内核镜像一样,存储在
NAND/NOR
分区上。 U-boot
启动过程中,不仅要完成本身代码的自复制:将自身代码从存储分区复制到内存中,还要完成自身代码的重定位,一般具备这种功能的代码我们称之为自举。
程序入口main()函数分析
编译器在编译一个工程时,默认的程序入口是
_start
符号,而不是main
。
- 符号
main
是一个约定符号,它用来告诉编译器在一个项目中哪里是程序的入口点。
没有栈C语言无法运行,在运行
main()
函数之前必须先运行一段汇编代码来初始化堆栈环境
- 设置好堆栈指针后,这部分代码还要继续初始化一些环境,如初始化
data
段的内容,初始化static
静态变量和global
全局变量,并给BSS
段的变量赋零值。 - 这部分代码属于
C
运行库(C Running Time,CRT
)中的代码
进入main()函数之前的一系列初始化操作
- C语言运行的基本堆栈环境、进程环境。
- 动态库的加载、释放、初始化、清理等工作。
- 向
main()
函数传参argc
、argv
,调用main(
)函数执行。 - 在
main(
)函数退出后,调用exit()
函数,结束进程的运行。
在ARM交叉编译器安装目录的lib
目录下一个叫作crt1.o
的目标文件,这个文件其实就是由汇编初始化代码编译生成的,是CRT
的一部分。在链接过程中,链接器会将crt1.o
这个目标文件和项目中的目标文件组装在一起,生成最终的可执行文件。
从程序入口地址_start
开始的汇编代码的核心工作
- 初始化C语言运行依赖的栈环境,并设置栈指针
- 在嵌入式系统裸机环境下,系统上电后要初始化时钟、内存,然后设置堆栈指针
- 保存一些上下文环境后就可以直接跳到第一个C语言入口函数:
__libc_start_main
。
__libc_start_main
函数的大致流程
- 首先设置程序运行的进程环境,加载共享库,解析用户输入的参数,将参数传递给
main()
函数,最后调用main()
函数运行。 main()
函数运行结束后,再调用exit
函数结束整个进程。
链接静态库
库分为静态库和动态库
- 静态库:在编译时,链接器会将我们引用的函数代码或变量,链接到可执行文件里,和可执行程序组装在一起。
- 动态库:在编译阶段不参与链接,不会和可执行文件组装在一起,而是在程序运行时才被加载到内存参与链接,因此又叫作动态链接库
静态库的本质其实就是可重定位目标文件的归档文件
- 使用
AR
命令就可以将多个目标文件打包为一个静态库。 - 编译参数大写的
L
表示要链接的库的路径,小写的l
表示要链接的库名字。链接时库的名字要去掉前后缀,如libtest.a
,链接时要指定的库名字为test
。
用
ar
命令制作静态库
-c
:禁止在创建库时产生的正常消息。-r
:如果指定的文件已经在库中存在,则替换它。-s
:无论库是否更新都强制重新生成新的符号表。-d
:从库中删除指定的文件。-o
:对压缩文档成员进行排序。-q
:向库中追加指定文件。-t
:打印库中的目标文件。-x
:解压库中的目标文件- 编译器是以源文件为单位编译程序的,链接器在链接过程中逐个对目标文件进行分解组装
静态库的缺点
- 生成的可执行文件体积较大,当多个程序引用相同的公共代码时,这些公共代码会多次加载到内存,浪费内存资源
- 链接时会将静态库中源文件中的所有函数都组装到可执行文件中,导致可执行文件体积增大(解决方式:将每个函数单独使用一个源文件实现)
动态链接
动态链接对静态链接做了一些优化
- 对一些公用的代码,如库,在链接期间暂不链接,而是推迟到程序运行时再进行链接。
- 在程序运行时才参与链接的库被称为动态链接库。
- 程序运行时,除了可执行文件,这些动态链接库也要跟着一起加载到内存,参与链接和重定位过程,否则程序可能就会报未定义错误,无法运行
动态链接的好处
- 节省内存资源:加载到内存的动态链接库可以被多个运行的程序共享,使用动态链接可以运行更大的程序、更多的程序,升级也更加简单方便。
- 一个软件采用动态链接,版本升级时主程序的业务逻辑或框架不需要改变,只需要更新对应的
.dll
或.so
文件,简单方便,避免了用户重复安装卸载软件 - 在
Windows
平台的.dll
文件就是动态链接库,需要和可执行文件一起安装到系统中;程序运行前会首先把它们加载到内存,链接成功后程序才能运行。 - Linux环境下以
.so
问后缀。
动态链接的流程
在Linux环境下,当我们运行一个程序时
- 操作系统首先会给程序
fork
一个子进程,接着动态链接器被加载到内存,操作系统将控制权交给动态链接器,让动态链接器完成动态库的加载和重定位操作,最后跳转到要运行的程序 - 动态链接器本身也是一个动态库,即
/lib/ld-linux.so
文件。动态链接器被加载到内存后,会首先给自己重定位,然后才能运行。 - 动态链接器解析可执行文件中未确定的符号及需要链接的动态库信息,将对应的动态库加载到内存,并进行重定位操作
加载地址
库动态链接需要考虑的一个重要问题是加载地址。
- 一个静态链接的可执行文件在运行时,一般加载地址等于链接地址,而且这个地址是固定的。
- 动态链接采用装载时重定位,动态链接库被加载到内存后,目标文件的起始地址也发生了变化,需要重定位。
- 一个可执行文件对动态链接库的符号引用,要等动态链接库加载到内存后地址才能确定,然后对可执行文件中的这些符号修改即可
与地址无关的代码
如果想让我们的动态库放到内存的任何位置都可以运行,都可以被多个进程共享,一种比较好的方法是将我们的动态库设计成与地址无关的代码。
- 需要被修改的指令(符号)和数据在每个进程中都有一个副本,互不影响各自的运行。
- 与地址无关的代码实现也很简单,编译代码时加上
-fPIC
参数即可。PIC
是Position-Independent Code
的简写,即与地址无关的代码。加上-fPIC
参数生成的指令,实现了代码与地址无关,放到哪里都可以执行。 - 在模块内部,对函数和全局变量的引用要避免使用绝对地址,一般可以使用相对跳转代替
以
ARM
平台为例,可以采用相对寻址来实现。
ARM
有多种寻址方式,其中有一种叫相对寻址,以PC
为基址,以当前指令和目标地址的差作为偏移量,两者相加的地址即操作数的有效地址。ARM汇编中的B
、BL
、ADR
、ADR
L等指令都是采用相对寻址实现的。
全局偏移表
当动态库作为第三方模块被不同的应用程序引用时,库中的一些绝对地址符号(如函数名)将不可避免地被多次调用,需要重定位
- 每个应用程序将引用的动态库(绝对地址)符号收集起来,保存到一个表中,这个表用来记录各个引用符号的地址。当程序在运行过程中需要引用这些符号时,可以通过这个表查询各个符号的地址。这个表被称为全局偏移表(
Global Offset Table
,GOT
)。 GOT
表以section
的形式保存在可执行文件中- 编译阶段就已经确定
- 运行需要引用动态库中的函数时,根据被加载的实际地址更新
GOT
表中的各个符号(函数)的地址
好处
- 在内存中只需要加载一份动态库,当不同的程序运行时,只要修改各自的
GOT
表,它们引用的符号都可以指向同一份动态库,就可以达到不同程序共享同一个动态库的目标
延迟绑定
- 与地址无关”这一技术在
ARM
平台可以使用相对寻址来实现 ARM
相对寻址的本质其实就是寄存器间接寻址,只不过基址更换为PC而已。
可执行文件一般都采用延迟绑定
- 程序在运行时,并不急着把所有的动态库都加载到内存中并进行重定位。当
- 动态库中的函数第一次被调用到时,才会把用到的动态库加载到内存中并进行重定位,既节省了内存,又可以提高程序的运行速度
- 动态链接器的主要工作就是加载动态库到内存中并进行重定位操作
- 指令代码中每一个使用动态链接的符号
<x@plt>
,都被保存在过程链接表(Procedure Linkage Table
,PLT
,以.plt
为后缀)中。过程链接表其实就是一个跳转指令,它无法单独工作,要和GOT
表相关联,协同工作
过程链接表
PLT
- 过程链接表
PLT
本质上是一个数组,每一个在程序中被引用的动态链接库函数,都在数组中对应其中一项,跳转到GOT
表中的对应项。PLT
表中有两个特殊项,PLT[0]
会关联到动态链接器的入口地址,而PLT[1]
则会关联到初始化函数:__libc_start_main()
,该函数会初始化C
语言运行的基本环境;
C
标准库其实就是以动态共享库的封装形式保存在Linux
系统中的
- 各个应用程序在引用
printf
这个符号时,就会启动动态链接器,将这份代码映射到各自进程的地址空间,更新各自GOT
表中printf()
函数的实际地址,然后通过查询GOT
表找到printf()
函数在内存中的实际地址,就可通过间接访问跳转执行。
共享库
- 现在大多数软件都是采用动态链接的方式开发的,不仅可以节省内存空间,升级维护也比较方便。
- 加载到内存中并进行动态链接
- 动态链接器在查找共享库的过程中,除了到系统默认的路径(
/lib
、/usr/lib
)下查找,也会到用户指定的一些路径下去查找,用户可以在/etc/ld.so.conf
文件中添加自己的共享库路径 - 有时候我们也可以使用
LD_LIBRARY_PATH
环境变量临时改变共享库的查找路径 - 多个共享库的路径添加到这个环境变量中,各个路径用
冒号
隔开。
插件的工作原理
很多软件为了扩展方便,具备通用性,普遍都支持插件机制
- 主程序的逻辑功能框架不变,各个具体的功能和业务以动态链接库的形式加载进来。这样做的好处是软件发布以后不用重新编译,可以直接通过插件的形式来更新功能,实现软件升值。
插件的本质其实就是共享动态库
- 主程序框架引用的外部模块符号,运行时以动态链接库的形式加载进来并进行重定位,就可以直接调用
Linux
提供了专门的系统调用接口,支持显式加载和引用动态链接库
- 加载动态链接库
```c
void *dlopen (const char *filename, int flag);
void *Handle = dlopen ("./libtest.so", RTLD_LAZY);
dlopen()
函数返回的是一个void*
类型的操作句柄,我们通过这个句柄就可以操作显式加载到内存中的动态库
第二个参数是打开标志位,经常使用的标记位
RTLD_LAZY
:解析动态库遇到未定义符号不退出,仍继续使用RTLD_NOW
:遇到未定义符号,立即退出。RTLD_GLOBAL
:允许导出符号,在后面其他动态库中可以引用
- 获取动态对象的地址
void *dlsym(void *handle, char *symbol);
void (*funcp)(int, int);
funcp = (void(*)(int, int)) dlsym(Handle, "myfunc");
dlsym()
函数根据动态链接库句柄和要引用的符号,返回符号对应的地址
- 关闭动态链接库
int dlclose(void *Handle);
- 该函数会将加载到内存的共享库的引用计数减一,当引用计数为0时,该动态共享库便会从系统中被卸载
- 动态库错误函数
const char *dlerror(void);
- 当动态链接库操作函数失败时,
dlerror
将返回出错信息。若没有出错,则dlerror
的返回值为NULL
- 示例:将
sub.c
中的函数封装成一个插件(动态共享库),然后在main()
函数中显式加载并调用它们。
#gcc sub.c -shared -fPIC -o libtest.so
#gcc main.c -ldl
#./a.out
Linux内核模块运行机制
Linux
内核实现支持模块的动态加载和运行
- 如果你实现了一个内核模块并打算运行它,你并不需要重启系统,直接使用
insmod
命令加载即可 hello.ko
内核模块的运行原理其实和共享库的运行机制一样,都是在运行期间加载到内存,然后进行一系列空间分配、符号解析、重定位等操作。hello.ko
文件本质上和静态库、动态库一样,是一个可重定位的目标文件。hello.ko
和动态库的不同之处在于:一个运行在内核空间,一个运行在用户空间。
insmod
命令加载一个内核模块时,基本流程如下:
kernel
/module.c
/init_module
.- 复制到内核:
copy_module_from_user
。 - 地址空间分配:
layout_and_allocate
。 - 符号解析:
simplify_symbols
。 - 重定位:
apply_relocations
。 - 执行:
complete_formation
。
Linux内核编译和启动分析
- 操作系统为应用程序提供了运行的进程环境和调度管理
操作系统自身是如何运行和启动的
- 通过
U-boot
加载Linux
内核镜像uImage
到内存的不同位置,观察Linux
内核启动流程
Linux
内核镜像uImage
的编译流程
Linux
内核是在裸机环境下启动的,在启动过程中并没有ELF
文件的执行环境,需要将ELF
文件转换为BIN/HEX
格式的纯二进制指令文件。编译器会调用objcopy
命令删除vmlinux
可执行文件中不必要的section
,只保留代码段、数据段等必要的section
,将ELF
格式的vmlinux
文件转换为原始的二进制内核镜像Image
Image
是纯指令文件,可以在裸机环境下运行,但自身体积比较大(一般几十兆以上)
BootLoader
- 使用
U-boot
引导内核的嵌入式平台通常会对zImage
进一步转换,给它添加一个64
字节的数据头,用来记录镜像文件的加载地址、入口地址、文件大小、CPU
架构等信息 - 我们可以使用
U-boot
提供的mkimage
工具将zImage
镜像转换为uImage
# mkimage -A arm -O linux -T kernel -C none -a 0x60003000 -e 0x60003000 -d zImage uImage
mkimage
常用的一些参数说明如下。
-A
:指定CPU
架构类型。-O
:指定操作系统类型。-T
:指定image
类型。-C
:采用的压缩方式有none
、gzip
、bzip2
等。-a
:内核加载地址。-e
:内核镜像入口地址。
U-boot
提供了bootm
机制来启动内核的运行。bootm
会解析uImage
文件中64
(0x40
)字节的数据头,解析出指定的加载地址,并和自己的启动参数进行对比
- 发现
bootm
参数地址和编译时-a
指定的加载地址0x60003000
相同,就会直接跳过数据头 - 如果
bootm
发现自己的参数地址和-a
指定的加载地址0x60003000
不同,它会把去掉64
字节数据头的内核镜像zImage
复制到编译时-a
指定的加载地址处,然后跳到该地址执行
因为
Image
镜像链接时使用的是虚拟地址,所以在运行Linux
内核的C
语言函数之前,首先会运行一段汇编代码来初始化堆栈环境,使能MMU
- 运行入口:
arch/arm/kernel/head.S
。 - 使能
MMU
:__create_page_tables()
。 - 跳入
C
语言函数:__mmap_switched
/start_kernel()
。
U-boot重定位分析
U-boot
比较有意思,不仅充当“加载器”的角色,引导Linux
内核镜像运行,还充当了“链接器”的角色,完成自身代码的复制及重定位。
现在的
ARM
SoC一般会在芯片内部集成一块ROM
,在ROM
上会固化一段启动代码
系统上电后,会首先运行固化在芯片内部的
ROMCODE
代码
- 初始化存储接口、建立存储映射
- 根据
CPU
外部管脚或eFuse
值来判断系统的启动方式 - 如果我们设置系统从
NOR Flash
启动,那么这段代码就会将NOR Flash
映射到零地址,然后系统复位,CPU
跳到U-boot
中断向量表中的第一行代码,即NOR Flash
中的第一行代码去执行。 - 除了
SDRAM
和NOR Flash
支持随机读写,可以直接运行代码,其他Flash
存储器是不支持直接运行代码的,只能将代码复制到内存中执行。 - 因为此时系统刚上电,内存还没有初始化,所以系统一般会先将
NAND Flash
或SD
卡中的一部分代码(前4KB
)复制到芯片内部的SRAM
中去执行,映射SRAM
到零地址,然后在这4KB
代码中进行各种初始化、代码复制、重定位等工作,最后PC
指针才跳到SDRAM
内存中去执行代码 - 在一个嵌入式系统中,无论采用哪种启动方式,为提高运行效率,
U-boot
在启动过程中,都会将存储在ROM上
的自身代码复制到内存中重定位,然后跳转到内存SDRAM
中去执行。 - 系统上电复位,
ARM
首先会跳到中断向量表执行复位程序,reset
复位程序定义在start.S
汇编文件中,PC
指针会跳转到start.S
文件执行该程序
系统上电复位程序主要执行
- 设置
CPU
为SVC
模式。 - 关闭
Cache
,关闭MMU
。 - 设置看门狗、屏蔽中断、设置时钟、初始化
SDRAM
。 reset
复位程序会调用不同的子程序完成各种初始化,reset
最后会跳到crt0.S
中的_main
汇编子程序执行
;arch/arm/lib/crt0.S
ENTRY(_main) ; 定义程序入口点为 _main
ldr sp, =(CONFIG_SPL_STACK) ; 将 CONFIG_SPL_STACK 地址加载到栈指针 sp
bl board_init_f_alloc_reserve ; 调用 board_init_f_alloc_reserve 函数
bl board_init_f_init_reserve ; 调用 board_init_f_init_reserve 函数
bl board_init_f ; 调用 board_init_f 函数
ldr r0, [r9, #GD_RELOCADDR] ; 从全局数据结构中加载重定位地址到 r0
b relocate_code ; 跳转到 relocate_code 标签
ldr r0, =__bss_start ; 将 BSS 段起始地址加载到 r0
ldr r3, =__bss_end ; 将 BSS 段结束地址加载到 r3
subs r2, r3, r0 ; 计算 BSS 段长度,结果存入 r2
bl memset ; 调用 memset 函数,清零 BSS 段
ldr pc, =board_init_r ; 跳转到 board_init_r 函数
ENDPROC(_main) ; 定义程序结束点
在
_main
中主要执行
- 初始化
C
语言运行环境、堆栈设置。 - 各种板级设备初始化、初始化
NAND Flash
、SDRAM
。 - 初始化全局结构体变量
GD
,在GD
里有U-boot
实际加载地址。 - 调用
relocate_code
,将U-boot
镜像从Flash
复制到RAM
- 从
Flash
跳到内存RAM
中继续执行程序。 BSS
段清零,跳入bootcmd
或main_loop
交互模式。
调用
relocate_code
实现代码的复制与重定位操作
relocate_code
在relocate.S
汇编文件中定义,它会首先将U-boot
自身的代码段、数据段从Flash
复制到RAM
,然后根据重定向符号表,对内存中的代码进行重定位。
U-boot
会被内核镜像复制到内存中的什么地址
U-boot
可以根据硬件平台实际RAM
的大小灵活设置加载地址,并保存在全局数据gd->relocaddr
中- 更大程度地适配不同大小的内存配置、不同的启动方式和不同的链接地址
内核镜像一般会加载到内存的低端地址,
U-boot
一般被加载到内存的高端地址
- 一是防止
U-boot
在复制内核镜像到内存时覆盖掉自己 - 二是
U-boot
可以一直驻留在内存中,当我们使用reboot
软重启Linux
系统时,还可以回跳到U-boot
执行
在
relocate_code
中,复制镜像的核心代码:
;arch/arm/lib/relocate.S
ENTRY(relocate_code) ; 定义程序入口点为 relocate_code
ldr r1, =__image_copy_start ; 将 __image_copy_start 地址加载到 r1
subs r4, r0, r1 ; 计算重定位偏移量,结果存入 r4
beq relocate_done ; 如果偏移量为0,跳转到 relocate_done
ldr r2, =__image_copy_end ; 将 __image_copy_end 地址加载到 r2
copy_loop:
ldmia r1!, {r10-r11} ; 从源地址 [r1] 复制数据到寄存器 r10-r11,并更新 r1
stmia r0!, {r10-r11} ; 将寄存器 r10-r11 的数据复制到目标地址 [r0],并更新 r0
cmp r1, r2 ; 比较源地址 r1 和源结束地址 r2
blo copy_loop ; 如果 r1 小于 r2,继续循环
relocate_done:
; 重定位完成,后续代码省略
U-boot
分别使用两个零长度数组__image_copy_start
和__image_copy_end
来标记U-boot
中要复制到内存中的指令代码段- 在复制之前,要判断链接地址
__image_copy_start
和保存在R0
中的实际加载地址gd->relocaddr
是否相等,如果相等,则跳过复制过程
__image_copy_start
在链接脚本U-boot.lds
中的位置
ENTRY(_start) ; 定义程序入口点为 _start
SECTIONS
{
. = 0x00000000; ; 设置当前地址为 0x00000000
.text : {
*(__image_copy_start) ; 将 __image_copy_start 标记的内容放入 .text 段
*(.vectors) ; 将 .vectors 段的内容放入 .text 段
arch/arm/cpu/armv7/start.o (.text*) ; 将特定对象文件中的 .text 段内容放入 .text 段
*(.text*) ; 将所有其他 .text 段的内容放入 .text 段
}
.data : {
*(.data*) ; 将所有 .data 段的内容放入 .data 段
}
...
; 其他段定义省略
__image_copy_end : {
*(__image_copy_end) ; 将 __image_copy_end 标记的内容放入其自己的段
}
...
; 其他段定义继续省略
}
U-boot
复制到内存后,还需要对其重定位,然后才能跳到RAM
中运行- 动态链接库为了让多个进程共享,使用了
-fpic
参数编译,生成了与位置无关的代码+GOT
表的形式 - 与位置无关的代码采用相对寻址,无论加载到内存中的任何地方都可以运行
GOT
表放到数据段中,位置是固定不变的,当程序要访问动态库中的绝地地址符号时,可先通过相对寻址跳到GOT
表中查找该符号的真实地址,然后跳过去执行即可
U-boot
的重定位操作和动态链接库类似,采用与地址无关代码+符号表的形式来完成重定位操作
- 符号表中保存的是代码中引用的绝对符号地址,如全局变量的地址、函数的地址等
- 符号表紧挨着代码段,位置在编译时就已经固定死了,程序访问全局变量时,可先通过相对寻址跳到符号表,在符号表中找到变量的真实地址,然后就可以直接访问变量
U-boot
在启动过程中,调用relocate_code
将自身镜像复制到内存的0x3000
地址处- 重定位后,符号表中全局变量i的地址就更新为在内存中的真实地址
0x3500
了,PC
指针跳到内存执行后就可以根据符号表中的地址正常访问变量i
常用的binutils工具集
GNU
工具集主要用来协助程序的编译、链接、调试过程,支持不同格式的文件相互转换,以及针对特定的处理器做优化等。
常用的
binutils
工具
工具名 | 用途 |
---|---|
nm | 列出目标文件中的符号 |
size | 列出目标文件的各个段的大小和总大小,如代码段、数据段等 |
addr2line | 将程序地址翻译成文件名和行号 |
objcopy | section复制、删除 |
objdump | 显示目标文件的信息、反汇编 |
readelf | 显示有关ELF文件的信息 |
readelf
是我们比较常用的命令,主要用来查看二进制文件的各个section
信息。
参数 | 说明 |
---|---|
-a | 读取所有符号表的内容 |
-h | 读取ELF文件头 |
-l | 显示程序头表(可执行文件,目标文件无该表) |
-S | 读取节头表(section headers) |
-s | 显示符号表 |
-e | 显示目标文件所有的头信息 |
-n | 显示node段的信息 |
-r | 显示relocate段的信息 |
-d | 显示dynamic section信息 |
-g | 显示section group的信息 |
objdump
主要用来反汇编,将可执行文件的二进制指令反汇编成汇编文件。
参数 | 说明 |
---|---|
-x | 输出目标文件的所有 header 信息 |
-t | 输出目标文件的符号表 |
-h | 输出目标文件的节头表信息 |
-j section | 仅反汇编指定的 section |
-S | 将代码段反汇编的同时,将反汇编代码和源码交替显示 |
-D | 对二进制文件进行反汇编,反汇编所有的 section |
-d | 反汇编代码段 |
-f | 显示文件头信息 |
-s | 显示目标文件的全部 header 信息,以及它们对应的十六进制文件代码 |
objcopy
命令主要用来将一个文件的内容复制到另一个目标文件中,对目标文件实行格式转换
参数 | 说明 |
---|---|
-R name | 从文件中删除所有名为 name 的段 |
-S | 不从源文件复制重定位和符号信息到输出目标文件 |
-g | 不从源文件复制调试符号到输出目标文件 |
-j section | 只复制指定的 section 到输出文件 |
-K symbol | 从源文件复制名为 symbol 的符号,其他不复制 |
-N symbol | 不从源文件复制名为 symbol 的符号 |
-L symbol | 将符号 symbol 文件内部局部化,外部不可见 |
-W symbol | 将符号 symbol 转为弱符号 |
如果我们想将一个
ELF
文件转换为BIN
文件,则可以使用下面的命令:
# arm-linux-gnueabi-objcopy -O binary -R .comment -S uboot uboot.bin
-O binary
:输出为原始的二进制文件。-R.comment
:删除section.comment
。-S
:重定位、符号表等信息不要输出到目标文件U-boot.bin
中。
将一个二进制的
BIN
文件转换为十六进制的HEX
文件
# objdump -I binary -O ihex U-boot.bin U-boot.hex