读书笔记:高级C/C++编译技术

高级C/C++编译技术 Advanced C and C++ Compiling Milan Stevanovic

PDF下载

内容简介

与纯粹讲理论与技术细节的书不同,本书尽量使用通俗易懂的语言。
1 - 4 章 程序生命周期
5 章 静态库
6 - 11 章 动态库,库文件定位,符号处理
12 - 14 章 实践方面的总结

译者序

就编程语言来说,无论抽象到何种层次。最终都会回到本质--机器代码。
C语言可以有逻辑抽象,又可以直接控制硬件。
C语言核心简单,不因为语法构造而隐藏性能消耗。

前言

第1章 多任务操作系统基础 1

1.1 一些有用的抽象概念 1

| I/O设备  |  主存  |    CPU   | 操作系统 |
<- 字节流 ->
<-   虚拟内存     -><- 指令集 ->
<-          进程             ->
<-              虚拟机                 ->

1.2 存储器层次结构与缓存策略 2

  • 存储器技术导致处理器性能障碍--处理器存储器鸿沟

1.3 虚拟内存 3

进程的抽象表示正在运行的程序
多任务操作系统并发多个程序(web,音乐)
虚拟内存原则:

  • 程序内存固定(进程)容量$2^N$字节(通常32位)与物理内存无关。
  • 正在运行的程序完整内存布局会保存在硬盘中。只有即将被执行的部分内存(代码和数据)才会加载到物理内存页中。

1.4 虚拟地址 5

为所有程序采用简单统一的地址模式好处:

  • 简化链接过程
  • 简化加载过程
  • 简化内存分配机制

1.5 进程的内存划分方案 5

不同的多任务操作系统拥有不同的内存布局。对于linux进程的虚拟内存映射来说,遵循

  • 代码节:供CPU执行的机器码指令(.text节)
  • 数据节:供CPU操作的数据。初始化数据(.data节)。未初始化数据(.bss节)和只读数据(.rdata节)会保存在分离节中。
  • 堆:动态内存分配的区域
  • 栈:为各个函数提供独立的空间。
  • 内核区域:特定进程的环境变量存放在该区域。

参考:内存模型Anatomy of a Program in Memory

1.6 二进制文件、编译器、链接器与装载器的作用 6

  • 程序的二进制文件包含了程序运行过程中的内存映射布局的细节。
  • 链接器创建了二进制文件的整体框架。
  • 装载器将二进制可执行文件的节信息载入进程内存映射结构中。
    1.7 小结 7

第2章 程序生命周期阶段基础 8

2.1 基本假设 8
2.2 编写代码 9

2.3 编译阶段 11

2.3.1 基本概念 11

2.3.2 相关概念 11

  • 编译:高级语言源代码翻译成低级语言
  • 交叉编译:编译生成可在其他平台(CPU, 系统)运行

2.3.3 编译的各个阶段 12

1 预处理
#include将头文件包含入代码
#if排除特定部分代码
2 语言分析:删注释与不必要的空格。文本提取单词

  • 词法分析:源码分割成单词
  • 语法分析:单词连成单词序列,检查合理性
  • 语义分析
    3 汇编阶段:转换成CPU指令集
    4 优化阶段:寄存器使用率最小化
    5 代码生成阶段:转换成机器指令

2.3.4 目标文件属性 23

  • 符号(symbol):程序中的函数地址,数据(全局变量)
  • 构建程序:将多个独立的源代码的节拼接到一个可执行文件中。
  • 堆和栈完全在运行时确定

2.3.5 编译过程的局限性 24

不同源码文件建立联系的方式:

  • 函数调用
  • 外部变量
    为什么要链接?不同文件的函数,变量相对内存地址在拼接前是不确定的。

2.4 链接 26

2.4.1 链接阶段 26

  1. 重定位。单独目标文件的节拼接到程序内存映像(image),需要做地址范围转换。
  2. 解析引用。计算拼接后引用的实际image里的地址。
    2.4.2 链接器视角 31

2.5 可执行文件属性 33

2.5.1 各种节的类型 34

ELF规范

2.5.2 各种符号类型 36

第3章 加载程序执行阶段 37

3.1 shell的重要性 37

shell程序文件:sh, bash, tcsh
shell查找顺序:

  1. 文件名匹配
  2. PATH环境变量目录
    3.2 内核的作用 39

3.3 装载器的作用 39

3.3.1 装载器视角下的二进制文件(节与段) 39

装载器不需了解各个节的内部结构,只需将节复制到进程内存映射中。
3.3.2 程序加载阶段 40
3.4 程序执行入口点 43
3.4.1 装载器查找入口点 43
3.4.2 _start()函数的作用 43
3.4.3 __libc_start_main()函数的作用 44
3.4.4 栈和调用惯例 44

第4章 重用概念的作用 46

4.1 静态库 46

多种方式可以将目标文件集合用到其他项目:

  1. 通常方案将目标文件复制一份过去。
  2. 将这些目标文件打包成单个二进制--静态库。

静态库是可逆的,可以解包还原回目标文件。

4.2 动态库 48

在分析多任务系统时,都需强调一个重要概念:无论并行多少任务,特定的系统资源(声卡,网卡)总是唯一的,必须共享。
每个应用都重复访问资源的控制代码(静态库)效率极低。更高效的动态链接。

4.2.1 动态库和共享库 49

举例:每个打印文件的应用都要包含完整的打印代码栈(驱动)。如果驱动变更,应用就重新编译,否则多个驱动版本产生混乱。解决方案就是在动态库中提供通用功能,应用运行时加载。
位置无关代码(PIC)技术使得动态库仅需加载一次,其他应用同样可用。动态库就设计成了共享库。从此两个概念可以等价。
虚拟内存技术为PIC实现奠定基础,基本思想:共享库实际物理内存固定。

4.2.2 动态链接详解 51

1、构建动态库。本质上与可执行文件相同,唯一区别是动态库缺少启动例程(startup routine)
2、可执行文件靠信任只查找符号。例:你告诉一个人寄信就去报摊购买邮票,别人是靠信任接收了你的建议。
设计模式:只构建一个,其他依靠加载。如:设计范式(插件)
3、运行时装载和符号解析。
4.2.3 Windows平台中动态链接的特点 54

4.2.4 动态库的特点 56

静态库只是目标文件集合,不需要链接操作阶段。
动态库与可执行文件特性更相似:

  1. 有完整的构建过程。
  2. 动态库可以链接其他库。

4.2.5 应用程序二进制接口(ABI) 56

函数接口:函数指针的结构
API:软件模块提供给客户端的接口
ABI:提供给客户端的是二进制文件
动态链接的ABI:

  1. 构建阶段。客户二进制检查动态库的外部符号,如函数指针ABI
  2. 运行时动态库ABI与构建时的ABI须一致。

4.3 静态库和动态库对比 57

4.3.1 导入选择条件的差异 57

1、静态库的导入选择条件

链接前bin文件:
b = ?

lib:
a ○-⬜
b ○-⬜
c ○-⬜

链接后bin文件:只拷贝了需要的代码
b = b ●-⬛

客户二进制不会把整个静态库内容链接,只会链接需要的符号
客户二进制文件字节因静态库相关代码而体积增加。
2、动态库的导入选择条件

链接前bin文件:
b = ?

lib:
a ○-⬜
b ○-⬜
c ○-⬜

链接后bin文件:只拷贝了需要的符号,但运行时不管是否用到整个动态库代码都被加载。
    a ○-⬜
b = b ●-⬜
    c ○-⬜

客户二进制只选包含在符号表中需要的动态库符号。

4.3.2 部署难题 59

需求:将部署包的大小降低到最低限度
做法:当静态库数量少时采用静态链接。当运行环境操作系统本身自带了所需动态库时就用动态链接。
4.4 一些有用的类比 61

4.5 结论:二进制复用概念所产生的影响 63

代码分发给其他开发者使用,交付二进制和头文件。

  1. SDF(软件开发包 software dev kit)
  2. 引擎。如ffmpeg, avisynth。客户可自行对接不同的GUI展现

第5章 使用静态库 64

归档器(archiver)工具生成静态库

5.1 创建静态库 64

5.1.1 创建Linux静态库 64

linux平台静态库文件以lib开头,文件扩展名.a
ar工具功能:http://linux.die/net/man/1/ar

  • 从库文件中删除多个目标文件
  • 从库文件中替换多个目标文件
  • 从库文件中提取多个目标文件
  • 将多个目标文件打包进静态库
    5.1.2 创建Windows静态库 65
    5.2 使用静态库 65

5.3 静态库设计技巧 66

5.3.1 丢失符号可见性和唯一性的可能性 66

5.3.2 静态库使用禁忌 67

  • 当链接一个静态库需包含其他动态库
  • 功能需要存在一个单例。如:日志工具

5.3.3 静态库链接的具体规则 68

  • 依次链接静态库,每次一个
  • 从传递给链接器的最后一个静态库开始(makefile),反向逐个链接
  • 链接器详细检索,只有实际需要的符号才被链接
    5.3.4 将静态库转换成动态库 68

5.3.5 静态库在64位Linux平台上的问题 68

  • 静态库链接到共享库需要用 -fPIC
    现实中遇到就只能拿源码重新生成动态库

第6章 设计动态链接库:基础篇 70

6.1 创建动态链接库 70

6.1.1 在Linux中创建动态库 70

构建过程至少需要:

  • -fPIC编译器选项
  • -shared链接器选项
    动态库以lib前缀,.so扩展名
    6.1.2 在Windows中创建动态链接库 72

6.2 设计动态库 75

6.2.1 设计二进制接口 75

C++的问题
问题1:C++使用了更加复杂的符号命名

  • C++中函数通常属于类(即:方法),链接器必须标明函数从属信息
  • C++重载,一个类的不同方法靠不同函数参数区分。链接器必须包含函数参数信息。
    解决方法:
    名称修饰技术:链接器将函数名从属信息,参数列表组合生成符号名。通常从属信息为前缀,签名信息为后缀。
    但任然存在的问题根源:不同链接器名称修饰标准不统一。
    C风格函数:extern "C"告知链接器不要修饰成C++符号名。

问题2:静态初始化顺序
C语言,链接器在.data节预留空间给初始化数据。
C++中,对象初始化用构造函数。链接器会根据继承链执行构造函数顺序。
例:a类作用初始化网络要再b类网络发消息前,解决方案:声明成静态变量这样在第一次遇到变量定义才会初始化。

6.2.2 设计应用程序的二进制接口 79

设计程序二进制接口规则:

  1. 用C风格函数实现
  2. 完整ABI头文件
  3. 利用命名空间
  4. 只对外提供必要的符号
    6.2.3 控制动态库符号的可见性 82
    6.2.4 完成链接需要满足的条件 94

6.3 动态链接模式 94

6.3.1 加载时动态链接 95

动态库是预先加载的,从程序启动到程序结束都需要特定的动态库的功能才能正常工作。
在编译阶段准备:

  • 动态库的导出头文件,其中定义了ABI接口信息。
    在链接阶段准备:
  • 项目所需的动态库列表
  • 客户二进制所需的动态库路径
  • 可选的链接器选项。

6.3.2 运行时动态链接 95

根据运行时的实际情况或用户配置选择性加载必要的特定动态库。很多情况下设计要求多个具有相同ABI动态库并存。典型例子是支持多语言时加载特定用户语言的资源(字符串、菜单项和帮助文件)的动态库。
在编译阶段准备:

  • 动态库的导出头文件,其中定义了ABI接口信息。
    在链接阶段准备:
  • 至少提供动态库文件名。动态库路径解析依赖于一组管理规则。
目的 Linux API函数
加载库 dlopen()
查找符号 dlsym()
卸载库 dlclose()
错误报告 dlerror()

6.3.3 比较两种动态链接模式 98

两种动态库链接模式在本质上没有太多区别。
采用静态加载的动态库也可以在运行时进行动态加载。唯一区别是静态加载需要满足构建阶段就提供动态库的路径。

第7章 定位库文件 99

将库文件路径传递给链接器

7.1 典型用例场景 99

7.1.1 开发用例场景 99

7.1.2 用户运行时用例场景 100

  • 简单情况。直接一个可执行文件给用户,用户加到PATH环境变量可直接执行。
  • 复杂情况。可执行文件和一些动态库和工具。
    运行时库文件定位规则仅适用动态库。静态库在链接阶段集成。

7.2 构建过程中库文件的定位规则 101

7.2.1 Linux平台构建过程中的库文件定位规则 101

1、静态库 = lib+<库名>+.a 链接器使用<库名>
2、动态库 = lib + <库名>+.so + <版本> <版本> = < M >.< m >.< p >
共享库 soname = lib + <库名>+.so + <主版本>

gcc -share <obj> -W1, -soname, libfoo.so.1
W1: 一次性完成编译链接过程
readelf -d libfoo.so

4、库文件定位规则:

  • 库文件分两部分:目录路径和库文件名
  • 目录路径加到-L后并传递给链接器。仅在链接器有效
  • 库文件名添加到 -l后并传递给链接器。嵌入到二进制库文件
gcc main.o -L ../sharelib -lworkdemo -o demo
             [路径]        [库名]

7.2.2 Windows构建过程中的库文件定位规则 105

7.3 运行时动态库文件的定位规则 109

常见实践:如果开发人员只提供动态库文件名,操作系统定位库文件对加载动态库更有意义。
最后,我们要知道:无论动态库是静态加载还是运行时加载,程序执行都必须借助于运行时定位库文件。

7.3.1 Linux运行时动态库文件的定位规则 110

动态库文件搜索算法的优先级:
1、预加载库。

  • export LD_PRELOAD
  • /etc/ld.so.preload
    2、LD_LIBRARY_PATH环境变量
    3、ldconfig缓存。/etc/ld.so.conf
    4、默认库文件路径。/lib/usr/lib
    7.3.2 Windows运行时动态库文件的定位规则 114
    7.4 示例:Linux构建时与运行时的库文件定位 115

第8章 动态库的设计:进阶篇

8.1 解析内存地址的必要性

指令须使用精确的内存地址

  • 数据访问指令(mov等)需拿内存操作数的地址
// 地址装载到eax中
mov eax, ds:0xBFD1000
  • 子程序调用(call和jmp等)需要代码段中的函数地址。
// 调用地址的函数
call 0x0A120034 eax, ds:0xBFD1000

某些情况下,局部作用域(static)静态变量可用相对偏移解析符号的地址

8.2 引用解析中的常见问题

动态库加载到进程内存映射中,需要地址转换成进程内存。通常无法预先确定。
可执行文件格式规范限定了用于加载动态库的地址范围。
链接器和装载器执行的地址转换操作

  • 链接器初始处于“空白”态。目标文件均不包含解析引用。链接器有很大自由解析引用地址
  • 装载器输入的二进制已经有引用的解析。
    地址转换会使得链接器嵌入的地址失效。装载时地址重定位(LTR)
    8.3 地址转换引发的问题 122
    8.3.1 情景1:客户二进制程序需要知道动态库符号地址 122
    8.3.2 情景2:被装载的库不需要知道其自身符号地址 123
    8.4 链接器-装载器协作 124
    8.4.1 总体策略 125
    8.4.2 具体技术 126
    8.4.3 链接器重定位提示概述 127

8.5 链接器-装载器协作实现技术

8.5.1 装载时重定位(LTR)

8.5.2 位置无关代码(PIC)

原理:用指针的指针替代指针。全局偏移表存储每个未解析符号的地址。

  1. 链接器在全局偏移表中固定位置存符号地址。动态库代码节(函数)
  2. 装载器确定实际符号地址后修补.got节
[      链接阶段确定              ]    [  加载阶段确定  ]   
指令mov访问  --> 全局偏移表函数地址 --> 内存中实际符号地址
CPU根据.got节中修补后的函数地址找内存实际的函数

第9章 动态链接时的重复符号处理

9.1 重复的符号定义

典型的重复符号场景

  1. C语言中一个头文件多次包含
  2. C++只有函数名,参数列表都相同才算重复
    9.2 重复符号的默认处理 137

9.3 在动态库链接过程中处理重复符号

9.3.1 处理重复符号问题的一般策略

原理:强化符号与其特定模块的从属关系。
命名空间是一种最好的解决方法,但要借助C++编译器。
9.3.2 链接器解析动态库重复符号的模糊算法准则 143
9.4 特定重复名称案例分析 144
9.4.1 案例1:客户二进制文件符号与动态库ABI函数冲突 144
9.4.2 案例2:不同动态库的ABI符号冲突 147
9.4.3 案例3:动态库ABI符号和另一个动态库局部符号冲突 151
9.4.4 案例4:两个未导出的动态库符号冲突 153
9.5 小提示:链接并不提供任何类型的命名空间继承 161

第10章 动态库的版本控制

10.1 主次版本号与向后兼容性

10.1.1 主版本号变更

适用情况:

  • 对已提供功能进行修改
  • ABI变更

10.1.2 次版本号变更

适用情况:

  • 依赖老版本的客户二进制程序不需要重新编译

10.1.3 修订版本号

适用情况:

  • 主要的代码变更都在内部

10.2 Linux动态库版本控制方案

10.2.1 基于soname的版本控制方案

常用动态库升级方法:
准备软连接的复杂性

ln -s <file path> <softlink path>

开发过程中使用软链接实现

gcc -shared -l:libxyz.so.1 -o <app>
  1. pkg-config实现软链接文件
  2. 较一般的方法是在makefile添加软链接

10.2.2 基于符号的版本控制方案 169
10.3 Windows动态库版本控制 190
10.3.1 DLL版本信息 191
10.3.2 指定DLL版本信息 192
10.3.3 查询并获取DLL版本信息 193

第11章 动态库:其他主题

11.1 插件

插件利用动态链接实现。
插件模型:

  • 添加或删除插件不需要重新编译主程序
  • 功能模块应该使用类似于运行时加载机制
  • 在运行时无论插件是否可用系统都正常运行
    满足以上需求的设计:
  • 插件以动态库的方式实现。插件标准化接口
  • 应用程序加载插件,通过配置插件路径

11.1.1 导出规则

C++插件架构对外提供C链接约定的函数接口

11.1.2 一些流行的插件架构

  • 图像处理 Adobe Photoshop
  • 软件集成开发环境(IDE)

11.2 提示和技巧

11.2.1 使用动态库的实际意义

  1. 隔离功能以实现快速开发,只需编译动态库
  2. 在运行时进行快速替换
    11.2.2 其他主题 205

第12章 Linux工具集 211

12.1 快速查看工具 211

12.1.1 file实用程序 211

file(http://linux.die.net/man/1/file)查看文件类型

12.1.2 size实用程序 212

size(http://linux.die.net/man/1/size)查看字节长度

12.2 详细信息分析工具 212

binutils工具集合(www.gnu.org/software/binutils)获取有关二进制文件属性的详细信息

12.2.1 ldd 212

ldd(http://linux.die.net/man/1/ldd)显示二进制文件启动需要静态加载的动态库列表(即加载时依赖项)
链接器会将直接依赖项的列表写入ELF文件中。
用更安全的方法代替ldd

objdump -p /path/to/program | grep NEEDED
redelf -d /path/to/program | grep NEEDED

12.2.2 nm 214

nm(http://linux.die.net/man/1/nm)可以列出二进制文件的符号列表
最有用的10个nm命令:www.thegeekstuff.com/2012/03/linum-nm-command

12.2.3 objdump 215

objdump(http://linux.die.net/man/1/objdump)支持ELF等50中格式,反汇编功能。

objdump -f 解析ELF头
objdump -h 列出并查看节信息
objdump -t 列出所有符号
objdump -T 列出动态符号
objdump -p 查看动态节
objdump -R 查看重定位节
objdump -s -j <节名> 查看节中的数据
readelf -p 列出并查看段

12.2.4 readelf 223

readelf(http://linux.die.net/man/1/readelf)

readelf -h 解析ELF头
readelf -S 列出并查看节信息
readelf --symbols 列出所有符号
readelf --dyn-syms 列出动态符号
readelf -d 查看动态节
readelf -r 查看重定位节
readelf -x <节名> 查看节中的数据
readelf --segments 列出并查看段

12.3 部署阶段工具 229

12.3.1 chrpath 229

chrpath(http://linux.die.net/man/1/chrpath)修改rpath

12.3.2 patchelf 230

patchelf(http://nixos.org/patchelf.html)修改rpath

12.3.3 strip 231

strip(http://linux.die.net/man/1/strip)清除动态加载过程中不需要的库文件符号。

12.3.4 ldconfig 231

ldconfig(http://linux.die.net/man/8/ldconfig)指定装载器运行时库所有路径。

  • /etc/ld.so.conf
  • /etc/ld.so.cache

12.4 运行时分析工具 232

12.4.1 strace 232

strace(http://linux.die.net/man/1/strace)跟踪由进程产生的系统调用与进程接收的信号。

12.4.2 addr2line 233

addr2line(http://linux.die.net/man/1/addr2line)可以将运行时地址转换成源码信息和行号。有助于分析程序崩溃信息。

addr2line -C -f -e /libxyz.so 0000d8cc6

需要构建参数:-g -O0

12.4.3 gdb(GNU调试器) 233

  • set disassembly-flavor <intel | att>
  • disassembe <函数名>

调用反汇编命令时,可以使用如下符号:

  • /r:显示额外的汇编指令十六进制代码
  • /m:在汇编指令中插入C/C++代码行
(gdb) dissemble /mr main

12.5 静态库工具 234

第13章 平台实践 238

13.1 链接过程调试 238

设置LD_DEBUG

export LD_DEBUG=<option>

撤销操作

unset LD_DEBUG

13.2 确定二进制文件类型 239

readelf -h <binary> | grep Type
objdump -f <binary>

文件类型:

  • EXEC可执行文件
  • DYN共享目标文件
  • REL可重定位文件

13.3 确定二进制文件入口点 240

13.3.1 获取可执行文件入口点 240

readelf -h <binary> | grep Entry
objdump -f <binary> | grep start

13.3.2 获取动态库入口点 240
13.4 列出符号信息 241
13.5 查看节的信息 242
13.5.1 列出所有节的信息 242
13.5.2 查看节的信息 242
13.6 查看段的信息 243
13.7 反汇编代码 244
13.7.1 反汇编二进制文件 244
13.7.2 反汇编正在运行的进程 244
13.8 判断是否为调试构建 244
13.9 查看加载时依赖项 245
13.10 查看装载器可以找到的库文件 245

13.11 查看运行时动态链接的库文件 245

13.11.1 strace实用程序 245
13.11.2 LD_DEBUG环境变量 246

13.11.3 /proc/<PID>/maps文件 246

记录进程执行信息。
小提示:应用程序可能很快执行结束,来不及检查进程的内存映射。解决方法是gdb然后断点在main函数即可。
13.11.4 lsof实用程序 247

13.11.5 通过编程方式查看 248

打印出进程加载的库文件信息。程序代码中调用dl_iterate_phdr()。动态库可以加载时链接,也可以dlopen()函数动态加载。
13.12 创建和维护静态库 251
第14章 Windows工具集 252
14.1 库管理器(lib.exe) 252
14.1.1 使用lib.exe处理静态库 253
14.1.2 使用lib.exe处理动态库(导入库生成工具) 257
14.2 dumpbin实用程序 258
14.2.1 确定二进制文件类型 258
14.2.2 查看DLL的导出符号 258
14.2.3 查看节的信息 259
14.2.4 反汇编代码 262
14.2.5 确定是否使用了调试模式构建 263
14.2.6 查看加载时依赖项 265
14.3 Dependency Walker工具 265

posted @ 2024-08-12 09:07  liqinglucky  阅读(37)  评论(0编辑  收藏  举报