程序员的自我修养——链接、装载与库
1.温故而知新——操作系统概念
- 北桥:连接高速芯片
- 系统调用接口:以软件中断的方式提供,如Linux使用0x80号中断作为系统调用接口
- 多任务系统:进程隔离
- 设备驱动
- 直接使用物理内存的弊端
- 地址空间不隔离
- 内存使用效率低
- 程序运行的地址不确定
- 分段
- 分页
- 线程:轻量级进程LWP,是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针PC,寄存器集合和堆栈组成。通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包含代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)
- CPU密集型(很少等待的线程)和IO密集型(频繁等待的线程)
- 优先级调度下,线程的优先级改变的方式
- 用户指定优先级
- 根据进入等待状态的频繁程度提升或降低优先级
- 长时间得不到执行而被提升优先级
- 线程安全
- 原子操作
- 同步和锁
- 信号量:允许多个线程并发访问,可以被任意线程获取并释放
- 互斥量:谁获取,谁释放
- 临界区:只有创建的进程可见。
- 读写锁
- 条件变量
- 被重入:一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数被重入的两种情况:
- 多个线程同时执行这个函数
- 函数自身(可能经过多层调用之后)调用自身
- 一个函数要成为可重入函数,必须具有的特点:
- 不使用任何(局部)静态或全局的非const变量
- 不返回任何(局部)静态或全局的非const变量的指针
- 仅依赖于调用方提供的参数
- 不依赖于任何单个资源的锁(mutex等)
- 不调用任何不可重入的函数
- 可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。
- 过度优化:volatile关键字试图阻止过度优化,它可以做两件事
- 阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回
- 阻止编译器调整操作volatile变量的指令顺序。
2.编译和链接
- 预编译 gcc -E hello.c -o hello.i
- 删除所有的 “#define”,展开宏定义
- 处理所有的条件编译 “#if”等
- 处理“#include”预编译指令,将被包含的文件插入到该预编译指令的位置。过程为递归过程,也就说包含的文件可能还包含其他文件
- 删除所有的注释“//”和“/* */”
- 添加文件和文件名标识,比如#2 “hello.c” 2,以便于编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告能够显示行号。
- 保留所有的#pragma编译器指令,因为编译器需要使用它们
- 编译:把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。gcc -S hello.i -o hello.s
- 汇编:将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令.
- as hello.s -o hello.o
- gcc -c hello.s -o hello.o
- gcc -c hello.c -o hello.o // 直接从源代码输出目标文件
- 链接器ld生产目标文件
编译器
定义其他模块的全局变量和函数在最终运行时的绝对地址都要在最终链接的时候才能确定。所以现代的编译器可以将一个源代码文件编译成一个未链接的目标文件,然后由链接器最终将这些目标文件链接起来形成可执行文件。
- 编译过程一般可分为6步:扫描、语法分析、语义分析、源代码优化、代码生成和目标代码优化
- 源代码程序被输入到
扫描器(scanner)
,它进行简单的词法分析,运用一种有限状态机(Finite State Machine)将源代码的字符序列分割成一系列的记号(Token)。- lex程序可以实现词法扫描,它会按照用户之前描述好的词法规则将输入分割成一个个记号。
语法分析
:语法分析器将对由扫描器产生的记号进行语法分析,从而产生语法树- 整个过程采用上下文无关语法的分析手段
- yacc可以根据用户给定的语法规则对输入记号序列进行解析,从而构建一颗语法树
语义分析
:语义分析器完成,仅仅完成对表达式的语法层面的分析,但是它并不了解这个语句是否真正有意义。比如两个指针做乘法运算是没有意义的,但是这个语句在语法上是合法的。- 编译器所能分析的语义是静态语义(Static Semantic),所谓静态语义是指在编译器可以确定的语义,与之对应的是动态语义就是只有运行期才能确定的语义
- 静态语义通常包括声明和类型的匹配,类型的转换。比如当一个浮点型的表达式赋值给一个整型的表达式时,其中隐含了一个浮点型到整型转换的过程,语义分析过程中需要完成这个步骤。动态语义一般指在运行期出现的语义相关的问题,比如将0作为除数是一个运行期语义错误。
- 中间代码生成:源代码优化,包含此及以上过程属于编译器前端
- 源代码级优化器会在源代码级别进行优化
- 中间代码类型:三地址码和P-代码
- 在语法树上做优化比较困难,所以源代码优化器往往将整个语法树转换为中间代码,它是语法树的顺序表示。
- 中间代码使得编译器可以被分为前端和后端。编译器前端负责产生机器无关的中间代码,编译器后端将中间代码转换成目标机器代码。对于跨平台编译器,可以针对不同平台使用同一个前端和针对不同机器平台的数个后端。
- 代码生成:代码生成器将中间代码转换成目标机器代码,这个过程十分依赖于目标机器,因为不同的机器有着不同的字长、寄存器、整数数据类型和浮点数数据类型等
- 目标代码优化:目标代码优化器对目标代码进行优化,比如选择合适的寻址方式、使用位移来代替乘法运算、删除多余的指令等。
静态链接
- 链接过程
- 地址和空间分配
- 符号决议
- 重定位
- 每个模块的源代码文件(.c)经过编译器编译成目标文件(object file,一般扩展名为.o或.obj),目标文件和库一起链接形成最终可执行文件。
3.目标文件里有什么
- 目标文件.o(windows .obj)、可执行文件、动态链接库.so(windows DLL)、静态链接库.a(windows .lib)都是按照可执行文件格式ELF(windows为PE)存储的。
- ELF类型:file hello。Linux下file可看文件类型
- 可重定位文件 Relocatable File
- 可执行文件 Executable File
- 共享目标文件 Shared Object File
- 核心转储文件 Core Dump File
- .bss:未初始化的全局变量和局部静态变量一般放在.bss的段中,它只是为这两种变量预留位置,不占空间。
- 总体来说,程序源代码被编译以后主要分成两种段:程序指令和程序数据。代码段属于程序指令,而数据段和.bss属于程序数据。
- 数据和指令分开的好处:
- 当成程序装载时,数据和指令分别被映射到两个虚拟区域。由于数据区域可读写,而指令区域对于进程是只读的,可以分别设置权限来控制两个区域。
- 对于现代CPU,缓存(Cache)体系一般被设计成数据缓存和指令缓存分离,所以程序设计分开有助于提高CPU的缓存命中率。
- 最重要的原因是,当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份程序的指令部分。节约大量内存。
- 只编译不链接 gcc -c SimpleSection.c
int printf(const char *format, ...);
int global_init_var = 84;
int global_uninit_var;
void func1(int i)
{
printf("%%d
", i);
}
int main(void)
{
static int static_var = 85;
static int static_var2;
int a = 1;
int b;
func1(static_var + static_var2 + a + b);
return a;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
查看文件结构和内容:objdump -h SimpleSection.o。(-x查看更多)
$ objdump -h SimpleSection.o
SimpleSection.o: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000055 0000000000000000 0000000000000000 00000040 2**0
CONTENTS, ALLOC, LOAD, RELOC, READONLY, CODE
1 .data 00000008 0000000000000000 0000000000000000 00000098 2**2
CONTENTS, ALLOC, LOAD, DATA
2 .bss 00000004 0000000000000000 0000000000000000 000000a0 2**2
ALLOC
3 .rodata 00000004 0000000000000000 0000000000000000 000000a0 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .comment 0000002d 0000000000000000 0000000000000000 000000a4 2**0
CONTENTS, READONLY
5 .note.GNU-stack 00000000 0000000000000000 0000000000000000 000000d1 2**0
CONTENTS, READONLY
6 .eh_frame 00000058 0000000000000000 0000000000000000 000000d8 2**3
CONTENTS, ALLOC, LOAD, RELOC, READONLY, DATA
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- Linux有一个size命令可查看ELF文件代码段、数据段、bss段的长度
$ size SimpleSection.o
text data bss dec hex filename
177 8 4 189 bd SimpleSection.o
- 1
- 2
- 3
- .text代码段:objdump -s -d SimpleSection.o。-s参数将所有段的内容以16进制打印,-d将包含指令的段反汇编
- .rodata只读数据段:一般是程序里的只读变量(如const修饰的变量)和字符串常量
- .comment注释信息段:编译器版本信息
- .strtab:String Table 字符串表,用于存储ELF文件用到的各种字符串
- .symtab:Symbol Table 符号表,记录目标文件用到的所有符号
- .shstrtab:Section String Table 段名表,保存段表中用到的字符串,最常见的就是段名
- ELF文件头:readelf -h SimpleSection.o
- ELF段表:readelf -S SimpleSection.o。保存段的基本信息的结构
- .rel.text是针对“.text”段的重定位表;.rel.data是针对“.data”段的重定位表
- 在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)
- 符号表中的符号分类:
- 全局符号:定义在本目标文件的全局符号
- 外部符号:引用的全局符号,去没有定义在本目标文件
- 段名,由编译器产生,它的值就是该段的起始地址,如.text
- 局部符号,只在编译单元内可见。调试器可以使用这些符号来分析程序或崩溃的核心转储文件。它们对链接过程没有作用,链接器往往忽略它们。
- 行号信息:目标文件指令与源代码中代码行的对应关系。
- nm SimpleSection.o
- readelf -s SimpleSection.o
$ readelf -s SimpleSection.o
Symbol table '.symtab' contains 16 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000000 0 FILE LOCAL DEFAULT ABS SimpleSection.c
2: 0000000000000000 0 SECTION LOCAL DEFAULT 1
3: 0000000000000000 0 SECTION LOCAL DEFAULT 3
4: 0000000000000000 0 SECTION LOCAL DEFAULT 4
5: 0000000000000000 0 SECTION LOCAL DEFAULT 5
6: 0000000000000004 4 OBJECT LOCAL DEFAULT 3 static_var.1965
7: 0000000000000000 4 OBJECT LOCAL DEFAULT 4 static_var2.1966
8: 0000000000000000 0 SECTION LOCAL DEFAULT 7
9: 0000000000000000 0 SECTION LOCAL DEFAULT 8
10: 0000000000000000 0 SECTION LOCAL DEFAULT 6
11: 0000000000000000 4 OBJECT GLOBAL DEFAULT 3 global_init_var
12: 0000000000000004 4 OBJECT GLOBAL DEFAULT COM global_uninit_var
13: 0000000000000000 34 FUNC GLOBAL DEFAULT 1 func1
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND printf
15: 0000000000000022 51 FUNC GLOBAL DEFAULT 1 main
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 弱符号与强符号:编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号。attribute((weak)) 来定义任何一个强符号为弱符号
- 强引用:对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们须要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误。
- 弱引用:如果引用符号有定义,则链接器将该符号的引用决议;如果该符号未被定义,则链接器对于该引用不会报错__attribute__((weakref))
4.静态链接
- gcc -c a.c b.c
- ld a.o b.o -e main -o ab
// a.c
extern int shared;
int main(){
int a = 100;
swap(&a,&shared);
}
// b.c
int shared = 1;
void swap(int *a,int *b){
*a ^= *b ^=*a^=*b;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 链接器两步链接:
- 空间与地址分配。扫描所有的输入目标文件,获得它们的各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系。
- 符号解析与重定位。使用上面第一步收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等,这是链接过程的核心,特别是重定位过程。
- 对于可重定位的ELF文件来说,它必须包含有重定位表,用来描述如何修改相应的段里的内容。objdump -r a.o
- 符号解析:重定位过程也伴随着符号的解析过程,每个目标文件都可能定义一些符号,也可能引用到定义在其他目标文件的符号。链接器会查找所有输入目标文件的符号表组成的全局符号表,找到相应的符号后进行重定位。查看a.o的符号表:readelf -s a.o
- Linux系统下一版程序的入口是“_start”,这个函数是Linux系统库(Glibc)的一部分,当我们的程序与Glibc库链接在一起形成最终可执行文件以后,这个函数就是程序的初始化部分的入口,程序初始化部分完成一系列初始化过程之后,会调用main函数来执行程序主体。在main函数执行完成以后,返回到初始化部分,它进行一些清理工作,然后结束进程。
- ELF文件定义了两个特殊的段,用于在main之前和之后执行,如C++的构造和析构函数
- .init 该段里面保存的是可执行指令,它构成了进程的初始化代码。因此,当一个程序开始运行时,在main函数调用之前,Glibc的初始化部分安排执行这个段中的代码
- .fini 该段保存着进程终止代码指令。因此,当一个程序的main函数正常退出时,Glibc会安排执行这个段中的代码。
- 静态库可以简单看成一组目标文件的集合,即很多目标文件经过压缩后形成的文件。
- 查看压缩包里的目标文件列表:ar -t libc.a
- 查看符号表 objdump -t libc.a
- 链接过程控制,ld脚本
5.Windows PE/COFF
- windows下的可执行文件和目标文件格式PE/COFF。它们与ELF非常相似,它们都是基于段的结构的二进制文件格式。Windows下最常见的目标文件格式就是COFF文件格式,微软的编译器产生的目标文件都是这种格式。COFF文件有一个很有意思的段叫“.drectve段”,这个段中保存的是编译器传递给链接器的命令行参数,可以通过这个段实现指定运行库等功能。
- Windows下的可执行文件、动态链接库等都使用PE文件格式,PE文件格式是COFF文件格式的改进版本,增加了PE文件头、数据目录等一些结构,使得能够满足程序执行时的需求。
6.可执行文件的装载与进程
- 动态装载方法:原则上利用程序的局部性原理,思想是程序用到哪个模块就将哪个模块装入内存,如果不用久暂时不装入,存放在磁盘中。
- 覆盖装入:由程序员编写管理器分配内存,决定哪些内存可以驻留和丢弃。在没有虚拟内存之前使用,现在此方法已废弃,除了嵌入式开发
- 页映射:当前的装载方式。
- 进程的建立:
- 创建一个独立的虚拟地址空间。在Linux上,就是分配一个页目录。
- 读取可执行文件头,并且建立虚拟地址空间与可执行文件的映射关系。Linux把进程虚拟空间的一个段叫做虚拟内存区域(VMA)。
- 将CPU的指令寄存器设置成可执行文件的入口地址,启动执行
- 随着程序的执行,页错误会不断地发生,操作系统也会为进程分配相应的物理页面来满足进程执行的需求。
- 对于相同权限的段,把它们合并到一起当做一个段进行映射。
- 查看不同段的映射情况 readelf -l /bin/cp
7.动态链接
- 静态链接的弊端
- 空间浪费
- 静态链接对程序的更新、部署和发布的影响,当依赖的任何静态文件更新后,程序需要重新编译下发
- 动态链接解决了上述的两种问题
- 动态链接的思想是把链接推迟到运行时再进行。磁盘和内存中只存放一份动态链接库文件,不但节约内存,还可以减少物理页面的换入换出,也增加了CPU缓存的命中率,因为不同进程间的数据和指令访问都集中在了同一个共享模块上。
- 动态链接可以在程序运行时动态加载各种程序模块,也叫插件(Plug-in)。例如摸个产品,它按照一定的规则制定好程序的接口,其他开发者可以按照这个接口编写符合要求的动态链接文件,实现程序的扩展。
- 动态链接的基本思想是把程序按照模块拆分成各个相对独立的部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有的程序模块链接成一个单独的可执行文件。
- Linux中,ELF动态链接文件被称为动态共享对象,简称共享对象,一般扩展名为“.so”;Windows上称为动态链接库,“.dll”
- 动态链接库生成 gcc -fPIC -shared -o Lib.so lib.c
- 编译 gcc -o Program1 Program1.c ./Lib.so
- 静态链接:链接时重定位;动态链接,装载时重定位,它不能让动态链接库指令在多个进程间共享
- 地址无关代码(PIC,Position-independent Code)把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。
- 装载时重定位和地址无关代码是解决绝对地址引用问题的两个方法,装载时重定位的缺点是无法共享代码段,但是它的运行速度较快;而地址无关代码的缺点是运行速度稍慢,但它可以实现代码在各个进程间的共享。
- 全局偏移表GOT:当代码需要引用全局变量时,可以通过GOT中相对应的项间接引用
- ELF将GOT拆分成了两个表叫做“.got”和“.got.plt”。其中“.got”用来保存全局变量引用的地址,“.got.plt”用来保存函数引用的地址,也就是说,所有对于外部函数的引用全部被分离出来放到了“.got.plt”中。
- 如何区分一个DSO是否为PIC:readelf -d foo.so|grep TEXTREL
- 如果上面的命令有任何输出,那么foo.so就不是PIC的,否则就是PIC,PIC的DSO是不会包含任何代码段重定位表的,TEXTREL表示代码段重定位表地址。
- 延迟绑定PLT,当函数第一次被用到时才进行绑定(符号查找、重定位等)
- 动态链接程序执行步骤
- 1.操作系统读取可执行文件的头部,检查文件的合法性,然后从头部中的“Program Header”中读取每个“Segment”的虚拟地址、文件地址和属性,并将它们映射到进程虚拟空间的相应位置。此时不能直接将控制权交给可执行文件,因为它依赖很多共享对象,这时可执行文件的许多外部符号引用还是无效地址的状态。
- 2.启动一个动态链接器(ld.so)。
- 3.将控制权交给动态链接器的入口地址(与可执行文件一样,共享对象也有入口地址)。当动态链接器得到控制权之后,它开始执行一系列自身的初始化操作,然后根据当前的环境参数,开始对可执行文件进行动态链接工作。
- 4.当动态链接工作完成之后,动态链接器会将控制权转交到可执行文件的入口地址,程序开始执行
- 链接器查看 :
$ objdump -s Program1|grep -A3 interp
Contents of section .interp:
400238 2f6c6962 36342f6c 642d6c69 6e75782d /lib64/ld-linux-
400248 7838362d 36342e73 6f2e3200 x86-64.so.2.
Contents of section .note.ABI-tag:
$ readelf -l Program1|grep interpreter
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- “.dynamic” 段查看 :readelf -d Program1
- 保存了动态链接器所需要的基本信息,比如依赖于那些共享对象、动态链接符号表的位置、动态链接重定位表的位置、共享对象初始化代码的地址等。
- 查看程序依赖的那些共享库:ldd Program1
- 动态符号表“.dynsym”只保存与动态链接相关的符号。
- 动态链接的基本步骤
- 启动动态链接器本身
- 装载所有需要的共享对象
- 重定位和初始化
- 动态库和共享对象在文件格式上没有什么区别。
- 主要区别是共享对象是由动态链接器在程序启动之前负责装载和连接的,这一系列步骤都由动态链接器自动完成,对于程序本身是透明的。
- 而动态库的装载则是通过一系列由动态链接器提供的API,具体地讲共有4个函数,dopen dlsym(查找符号) dlerror dlclose
8.Linux共享库的组织
- 共享库命名 libname.so.x.y.z
- x为主版本号:重大升级,不同主版本之间是不兼容的
- y为次版本号:增量升级,即增加一些新的接口符号,且保持原来的符号不变,在主版本号相同的情况下,高的次版本号兼容低的次版本号
- z为发布版本号:对库的一些错误的修正、性能的改进,并不添加任何新的接口,也不对接口进行更改。
- SO-NAME:即共享库去掉文件名的次版本号和发布版本号,保留主版本号。例如共享库libfoo.2.6.1,它的SO-NAME为libfoo.so.2。SO-NAME软连接会指向目录中主版本号相同、次版本号和发布版本号最新的共享库。
- 链接名:比如需要链接一个libXXX.so.2.6.1的共享库,只需要在编译器命令行里面指定-lXXX即可,可省略其他部分,编译器会根据当前环境,在系统中的相关路径(往往由-L参数指定)查找最新版本的“XXX”库。
- 对于 -lc,如果ld使用“-static”参数时,“-lc”会查找libc.a,如果使用“-Bdynamic”(默认情况),它会查找最新版本libc.so.x.y.z
- 符号版本:让每一个导入导出的符号都有一个相关联的版本号。比如VERS_1.1,VERS_1.2,每一次次版本升级,我们都可以给那些在新的次版本号中添加的全局符号打上相应的标记。
- 假设我们当前有6个版本,1.6,在1.3版本中添加了函数foo_bar,如果我们使用函数foo_bar,并且所有的符号在1.3里都存在,那么应用程序实际依赖的就是1.3版本,而不是1.6,只要系统安装的共享库大于等于1.3版本,程序就能正常运行。
- 共享库系统路径:FHS标准
- /lib:存放系统最关键和基础的共享库,比如动态链接器、C语言运行库、数学库等。这些库主要是/bin和/sbin用到的库
- /usr/lib:非系统运行时所需要的关键性的共享库,主要是一些开发时用到的共享库,这些库一般不会被用户的程序或shell脚本直接用到
- /usr/local/lib:跟操作系统本身不十分相关的库,主要是一些第三方的应用程序的库。
- Linux使用ldconfig为共享目录下的共享库创建、删除或更新相应的SO-NAME,让SO-NAME能指向正确的共享库文件,并且还会收集这些SO-NAME,集中存放在/etc/ld.so.cache文件中,建立一个SO-NAME缓存,加快动态链接器查找共享库的速度。
- Linux装载或查找共享对象(目标文件)的顺序
- 由环境变量LD_LIBRARY_PATH指定的路径,相当于GCC里的参数“-L”
- 由路径缓存文件/etc/ld.so.cache指定的路径
- 默认的共享库目录,先/usr/lib 然后/lib
- LD_PRELOAD环境变量可以预先装载一些共享库或者目标文件。正常情况下应该避免使用
- LD_DEBUG=files ./Program1;打印整个装载过程
- “bindings” 显示动态链接的符号绑定过程
- “libs” 显示共享库的查找过程
- “versions” 显示符号的版本依赖关系
- “reloc” 显示重定位过程
- “symbols” 显示符号表的查找过程
- “statistics” 显示动态链接过程中的各种统计信息
- “all” 显示以上所有信息
- “help” 显示上面的各种可选值的帮助信息
- 创建共享库
- gcc -shared -W1,-soname,my_soname -o library_name source_files library_files
- gcc -shared -W1,-soname,libfoo.so.1 -o libfoo.so.1.0.0 libfoo1.c libfoo2.c -lbar1 -lbar2
- 清除符号信息 strip libfoo.so
- 安装共享库,复制到/lib或/usr/lib 中,运行ldconfig即可
- 建立SO_NAME:ldconfig -n shared_library_directory
- gcc -shared -W1,-soname,my_soname -o library_name source_files library_files
- 共享库构造和析构函数
- void attribute((constructor)) init_func(void);
- void attribute((destructor)) init_func(void);
- 它们可以指定优先级,数字越少优先级越高 void attribute((constructor(10))) init_func(void);
9.Windows下的动态链接
- 基地址:对于任何一个PE文件来说,它偶有一个优先装载的基地址
- 相对地址RVA(relative virtual address)
- DLL的符号默认是不导出的,需要显式地告诉编译器,当我们在程序中使用DLL导出的符号时,这个过程被称为导入(import)。
- 声明某个函数为DLL导出函数的办法:一种方式是函数使用__declspec(dllexport) 修饰,另一种是采用 .def 文件
- cl /LDd Math.c 会生成四个文件
- Math.dll
- Math.obj
- Math.exp:链接器在创建DLL时的临时文件
- Math.lib:描述Math.dll的导出符号,又被称为导入库
- 编译TestMath.c为可执行文件
- cl /c TestMath.c
- link TestMath.obj Math.lib
10.内存
- 程序的内存布局
- 栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址处分配,通常有数M字节大小
- 堆:容纳应用程序动态分配的内存区域,当程序使用malloc或new分配内存时,得到的内存来自于堆里。堆通常位于栈的下方(低地址方向),在某些时候,堆也可能没有固定统一的存储区域。
- 可执行文件映像:存储可执行文件在内存中的映像
- 保留区:并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称
- 动态链接映射区:用于映射装载的动态链接库
- 栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧(Stack Frame)或活动记录(Active Record)。堆栈帧一般包含以下几部分:
- 函数的返回地址和参数
- 临时变量:包含函数的非静态局部变量以及编译自动生成的其他临时变量
- 保存的上下文:包括函数调用前后需要保持不变的寄存器
- esp指向当前栈的顶端。ebp寄存器称为帧指针,指向函数活动记录的一个固定位置。ebp可以用来获取参数,释放函数
i386函数调用的流程
- 把所有或一部分参数压入栈在,如果有其他参数没有入栈,那么使用某些特定的寄存器传递
- 把当前指令的下一条指令的地址压入栈中(返回地址)
- 跳转到函数体执行
- push ebp:把ebp压入栈中(old esp)
- mov ebp,esp:ebp = esp(这时ebp指向栈顶,而此时的栈顶就是old esp)
- 【可选】sub esp,XXX:在栈上分配XXX字节的临时空间
- 【可选】push XXX:如有必要,保存名为XXX的寄存器
- 函数执行
- 执行完成结尾,【可选】pop XXX:如有必要,恢复保存过的寄存器(可重复多个)
- mov esp,ebp:恢复ESP同时回收局部变量空间
- pop ebp:从栈中恢复保存的ebp的值
- ret:从栈中取得返回地址,并挑战到该位置
- 调用惯例:cedcl ;stdcall;fastcall;pascal
- 函数参数的传递顺序和方式:通过栈和寄存器的方式
- 栈的维护方式:参数的压栈和出栈由调用方还是函数本身完成
- 名字修饰的策略:为了链接的时候对调用惯例进行区分,调用惯例要对函数本身的名字进行修饰。不同的调用惯例有不同的名字修饰策略。
- 函数返回值传递:函数将返回值存储在eax中,如果大于4字节,则采用edx联合
typedef struct big_thing
{
char buf[12800];
} big_thing;
big_thing return_test()
{
big_thing b;
b.buf[0] = 0;
return b;
}
int main(void)
{
big_thing n = return_test();
return 0;
}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 返回值的步骤:
- 首先main函数在栈上额外开辟一片空间,并将这片空间的一部分作为传递返回值的临时对象,这里称为temp
- 将temp对象的地址作为隐藏参数传递给return_test函数。
- return_test函数将数据拷贝给temp对象,并将temp对象的地址用eax传出。
- return_test返回之后,main函数将eax指向的temp对象的内容拷贝给n。
- C语言如果返回对象尺寸太大,返回时会使用一个临时的栈上内存区间作为中转,结果返回值对象会拷贝两次。不到万不得已,不要轻易返回大尺寸的对象。 ps:在centos 64位机器上,其实没有使用临时的栈内存,直接把return_test复制给变量n了
00000000004005a6 <return_test>:
4005a6: 55 push %%rbp
4005a7: 48 89 e5 mov %%rsp,%%rbp
4005aa: 48 81 ec 10 32 00 00 sub $0x3210,%%rsp
4005b1: 48 89 bd f8 cd ff ff mov %%rdi,-0x3208(%%rbp)
4005b8: c6 85 00 ce ff ff 00 movb $0x0,-0x3200(%%rbp)
4005bf: 48 8b 85 f8 cd ff ff mov -0x3208(%%rbp),%%rax
4005c6: 48 89 c1 mov %%rax,%%rcx
4005c9: 48 8d 85 00 ce ff ff lea -0x3200(%%rbp),%%rax
4005d0: ba 00 32 00 00 mov $0x3200,%%edx
4005d5: 48 89 c6 mov %%rax,%%rsi
4005d8: 48 89 cf mov %%rcx,%%rdi
4005db: e8 d0 fe ff ff callq 4004b0 <memcpy@plt> ; 给n赋值
4005e0: 48 8b 85 f8 cd ff ff mov -0x3208(%%rbp),%%rax
4005e7: c9 leaveq
4005e8: c3 retq
00000000004005e9 <main>:
4005e9: 55 push %%rbp
4005ea: 48 89 e5 mov %%rsp,%%rbp
4005ed: 48 81 ec 00 32 00 00 sub $0x3200,%%rsp
4005f4: c6 85 03 ce ff ff 64 movb $0x64,-0x31fd(%%rbp)
4005fb: 48 8d 85 00 ce ff ff lea -0x3200(%%rbp),%%rax
400602: 48 89 c7 mov %%rax,%%rdi ; rdi作为隐藏参数传递
400605: b8 00 00 00 00 mov $0x0,%%eax
40060a: e8 97 ff ff ff callq 4005a6 <return_test>
40060f: b8 00 00 00 00 mov $0x0,%%eax
400614: c9 leaveq
400615: c3 retq
400616: 66 2e 0f 1f 84 00 00 nopw %%cs:0x0(%%rax,%%rax,1)
40061d: 00 00 00
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 对于堆内存,程序向操作系统申请一块适当大小的堆空间,然后由程序自己管理这块空间,而具体来讲,管理这堆空间分配的往往是程序的运行库。
- Linux提供两种堆空间分配
- brk系统调用:设置进程数据段的结束地址,即它可以扩大或者缩小数据段(Linux下数据段和BSS合并在一起称为数据段)
- mmap系统调用:作用是向操作系统申请一段虚拟地址空间,当然这块虚拟地址空间可以映射到某个文件,当它不将地址空间到某个文件时,我们又称这块空间为匿名空间(Anonymous),匿名空间就可以拿来做堆空间。
- 堆分配算法:
- 空闲链表
- 位图
- 对象池:几种不同的固定大小的块组成
- glibc堆分配,对于小于64字节的采用类似对象池的方法,对于大于512字节的采用最佳适配算法,对于64字节到512字节的,会采取上述方法中的最佳折中策略;对于大于128KB的申请,它会使用mmap机制直接向操作系统申请空间
11.运行库
- 一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交给了程序的入口,这个入口往往是运行库中的某个入口函数
- 入口函数对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等
- 入口函数完成初始化之后,调用main函数,正式开始执行程序主体部分
- main函数执行完毕之后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程
- MSVC的I/O初始化
- 建立打开文件表
- 如果能够继承自父进程,那么父进程获取继承的句柄
- 初始化标准输入输出
- 一个C语言运行库大致包含了如下功能
- 启动和退出:包括入口函数及入口函数所依赖的其它函数等
- 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现
- I/O:I/O功能的封装和实现
- 堆:
- 语言实现
- 调试
- 多线程下:
- 线程私有:局部变量,函数的参数,TLS数据(线程局部存储)
- 进程所有(共享):全局变量,堆上的数据,函数里的静态变量,程序代码,打开文件
- glibc全局构造与解析
- _start -> __libc_start_main -> __libc_csu_init -> _init -> __do_global_ctors_aux[属于gcc,不属于glibc]
12.系统调用与API
- Linux通过0x80中断作为系统调用的入口,Windows以0x2E号中断
- 操作系统一般通过中断interrupt来从用户态切换到内核态。
- 中断是一个硬件或软件发出的请求,要求CPU暂停当前的工作转手去处理更加重要的事情。
- 中断一般有两个属性
- 中断号:从0开始
- 中断处理程序ISR
- 中断向量表:第n项包含指向第n号中断处理程序的指针。
- 当中断到来时,CPU会根据中断的中断号,在中断向量表中找到对应的中断处理程序,并调用它
- 中断有两种类型
- 硬件中断:来自于硬件的异常或其他事件的发生,如电源掉电、键盘被按下等
- 软件中断:通常是一条指令(i386下的int),带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。
- 系统调用中断:在Linux中,以int 0x80为例,系统调用号由eax传入。用户将系统调用号放入eax,然后使用int 0x80调用中断,中断服务程序就可以从eax里取得系统调用号,进而调用对应的函数。
- 系统调用具体实现
- 触发中断
- 切换堆栈。寄存器SS保存当前栈所在的页
- 切换到内核栈:保存当前的ESP和SS的值,将ESP设置为内核栈的值
- 切换到用户栈:恢复原来的ESP和SS的值,这些值保存在内核栈上。
- 中断处理程序:当int指令合理地切换了栈之后,程序的流程就切换到了中断向量表中记录的0x80号中断处理程序
- 新型系统调用机制:sysenter sysexit
- 调用sysenter之后,系统会直接跳转到由某个寄存器指定的函数执行,并自动完成特权级转换、堆栈切换等功能
- Windows API:Win32 Win64 调用中间层NTDLL.dll,它再调用 interrupt 0x2e
13.运行库实现
推荐阅读