《程序员的自我修养》读书笔记
第一章小结
- 计算机硬件结构从总线型演进到南桥北桥,南桥负责处理低速设备如磁盘、键盘,北桥负责处理高速设备,如 cpu、内存等
- 计算机软件体系架构主要分四层,应用软件、操作系统 api(如glibc库)、系统调用、硬件
- 多道程序演进为多进程、多任务系统
- 设备驱动程序一般由硬件厂商提供,但是要遵循操作系统提供的接口和框架
- 早期的内存问题,如何将有限的物理内存分配给多个程序使用
- 地址空间不隔离:直接分配物理内存可能会导致内存数据被非法篡改,引入安全问题
- 内存使用效率低:内存不足时想要运行新程序,就需要将一些程序的数据从内存转移到磁盘,且只能全部转移
- 程序运行的地址不确定:程序每次需要装入运行时,都需要从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定的
- 地址空间隔离的问题使用虚拟地址空间来解决,然后通过某种方式映射虚拟地址到物理地址,最初的方式是分段,这种方式虽然能够解决上述的问题 1 和问题 3,但是无法解决问题 2,后续引入了分页的方式,可以将一些不常用的页放到磁盘里存储,大大提高了内存利用效率
- 线程的访问权限
- 共享:全局变量、堆上的数据、函数里的静态变量、程序代码、文件句柄
- 私有:栈、线程局部存储、寄存器
- 运行的线程数量和处理器数量的关系(并行与并发)
- 多进程的写时复制
- 线程安全与线程同步(同步原语)
- 可重入函数的概念
- 不使用任何(局部)静态或全局的非 const 变量
- 不返回任何(局部)静态或全局的非 const 变量的指针
- 仅依赖于调用方提供的参数
- 不依赖于任何单个资源的锁
- 不调用任何不可重入的函数
- 指令重排导致 double-check 的单例写法可能出错,使用内存屏障,保证屏障之前的指令不会被交换到屏障之后
- 操作系统一般提供了内核线程的支持,然而实际使用线程是用户线程,一般有三种对应模型
- 一对一模型:用户线程与内核线程一一对应(缺点:用户线程数量受限,上下文切换开销大)
- 多对一模型:多个用户线程映射到一个内核线程上,线程切换由用户态代码进行(缺点:一个用户线程阻塞会导致关联的其它线程无法运行)
- 多对多模型:有效缓解了上述阻塞的问题,但是性能上的提升幅度不如一对一模型高
第二章小结
程序源代码到可执行文件主要经历了四个步骤:预处理、编译、汇编、链接
1. 预处理(预编译)
主要处理那些源代码文件中的以 “#” 开始的预编译指令,gcc 中可以使用 -E 参数单独进行预编译 gcc -E test.c -o test.i
,主要处理规则如下:
- 将所有的 “#define” 删除,并且展开所有的宏定义
- 处理所有的条件预编译指令,比如 “#if” 、”#ifdef”、”#elif”、”#else”、”endif”
- 处理 “#include” 预编译指令,将被包含的文件插入到该预编译指令的位置(递归进行)
- 删除所有的注释
- 添加行号和文件标识,如
#2 "hello.c" 2
,以便编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号 - 保留所有的 #pragma 编译器指令,因为编译器需要使用它们
2. 编译
编译过程就是把预处理完的文件进行一系列的词法分析、语法分析、语义分析及优化后生成相应的汇编代码文件(核心步骤),gcc 中也可以单独进行编译gcc -S test.i -o test.s
,也支持直接从源文件编译成汇编文件,实际上 gcc 这个命令只是一些后台程序的包装,它会根据不同的参数要求去调用预编译编译程序、汇编器、链接器3. 汇编
汇编器是将汇编代码转变为机器可以执行的指令,gcc 中通过gcc -c test.s -o test.o
可以调用汇编器进行汇编处理得到目标文件4. 链接
把每个源代码的模块独立地编译,然后按照需要将它们“组装”起来,这个组装模块的过程就是链接。链接的过程主要包括了地址和空间分配、符号决议和重定位这些步骤,在引用其它模块的变量和函数时,由链接器来帮助你找到正确的目标地址
gcc 强制链接动态库或静态库
第三章小结
1. 目标文件
目标文件就是源代码编译后但未进行链接的那些中间文件(.obj or .o)
目标文件从结构上将,它是已经编译后的可执行文件格式,只是还没有经过链接的过程,其中可能有些符号或有些地址还没有被调整;本身就是按照可执行文件格式存储的,只是跟真正的可执行文件在结构上稍有不同
- 可执行文件格式:主要是 Windows 下的
PE
和 Linux 的ELF
,它们都是COFF
格式的变种 - 除了可执行文件,动态链接库及静态链接库文件都按照可执行文件格式存储;静态链接库稍有不同,它是把很多目标文件捆绑在一起形成一个文件,再加上一些索引,可以简单理解为一个包含有很多目标文件的文件包
ELF 文件标准里把系统中采用 ELF 格式的文件归为 4 类:
ELF 文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件(Relocatable File) | 这类文件包含了代码和数据,可以用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类 | .o 或 .obj 文件 |
可执行文件(Executable File) | 这类文件包含了可以直接执行的程序 | /bin/bash 文件 |
共享目标文件(Shared Object File) | 这种文件包含了代码和数据,可以在以下两种情况下使用。一种是链接器可以使用这种文件跟其它的可重定位文件和共享目标文件链接,产生新的目标文件。第二种是动态链接器可以将几个这种共享目标文件与可执行文件结合,作为进程映像的一部分来运行 | .so 或 .dll |
核心转储文件(Core Dump File) | 进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件 | core 文件 |
可以通过 file 命令查看相应的文件格式:
# with debug_info 表示携带调试信息, 可以通过 strip 命令去除
root@VM-0-6-ubuntu:~/gdb_learn/src# file test.o
test.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
root@VM-0-6-ubuntu:~/gdb_learn/src# file ../build/libsvc_add.so
../build/libsvc_add.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=40e0fffdeda7d7bf85f697f06df7e55d77bc8fbb, with debug_info, not stripped
COFF 在目标文件里面引入了 “段” 的机制,不同的目标文件可以拥有不同数量及不同类型的 “段”
一个 ELF 被分为了多个段,可以通过命令 objdump -h xxx.o
来查看目标文件有哪些段以及各个段的大小,一般有代码段 .text
用于存放源代码编译后的机器指令,数据段 .data
用于存放全局变量和局部静态变量数据,未初始化的则存放在 .bss
段里,其只是为未初始化的全局变量和静态变量预留位置,它并没有内容,所以在文件中也不占据空间;可以通过 size
命令来查看 ELF 文件的这三个段的大小
root@VM-0-6-ubuntu:~/gdb_learn/src# size SimpleSection.o
text data bss dec hex filename
219 8 4 231 e7 SimpleSection.o
总体来说程序源代码编译后主要分成两种段:程序指令和程序数据,代码段属于程序指令,而数据段和 .bss
段属于程序数据,之所以将程序指令和程序数据分开存放,有如下几个原因:
- 程序被装载后,数据和指令分别被映射到两个虚存区域,其中数据区域可读写,而指令区域对于进程来说是只读的,因此分开可以将权限分别设置,防止程序的指令被改写
- 现代 CPU 的缓存一般都被设计成数据缓存和指令缓存分离,因此程序的指令和数据被分开存放对 CPU 的缓存命中率提高有好处
- 当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中只需要保存一份该程序的指令部分
- 实例程序
SimpleSection.c
int printf(const char* format, ...); int global_init_var = 84; int global_uninit_var; void func1(int i) { printf("%d\n", 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; }
除了最基本的三个段以外,还有只读数据段.rodata
、注释信息段.comment
和堆栈提示段.note.GNU-stack
还有其余段,其中.bss
段不带 CONTENTS 说明该段在文件中不存在
相关内容可查看
目标文件(.o)结构的简单了解_..o_消逝者的博客-CSDN博客2. 数据段
通过 objdump 的 “-s” 参数可以将所有段的内容以十六进制的方式打印出来,”-d”参数可以将所有包含指令的段反汇编
- 如
objdump -s -d SimpleSection.o
的输出如下:
3. 数据段和只读数据段
.data
段保存的是那些已经初始化了的全局变量和局部静态变量.rodata
段存放的是只读数据,一般是程序里面的只读变量和字符串常量
但是有些时候编译器会把字符串常量存放到 .data
段,而不会单独存放在 .rodata
段
4. BSS 段
.bss
段存放的是未初始化的全局变量和局部静态变量,如上述代码有两个变量就是存放 .bss
段,其实更准确的说法是 .bss
段为它们预留了空间
但通过符号表可以看到,只有 static_var2
被存放在了 .bss
段,而全局静态变量却没有被存放在任何段,只是一个未定义的 “COMMOM” 符号
这其实是跟不同的语言与不同的编译器实现有关,有些编译器会将全局未初始化变量存放在目标文件 .bss
段,有些则不放,只是预留一个未定义的全局变量符号,等到最终链接成可执行文件的时候再为 .bss
段分配空间
值得一提的是,当你将局部静态变量赋值为 0 时,此变量有可能存放在 .bss
段而非 .data
,因为 0 可以认为是未初始化的,所以被优化掉了可以放在 .bss
段,这样可以节省磁盘空间,因为 .bss
段不占磁盘空间
5. 其他段
除了 .text
、.data
、.bss
这三个最常用的段之外,还有很多其他的段,如 .comment
存放的是编译器版本信息,.note
存放额外的编译器信息等等
这些段的名字都是由 .
作为前缀,表示这些段的名字是系统保留的,应用程序也可以使用一些非系统保留的名字作为段名,但是应用程序自定义的段名不能使用 .
作为前缀,否则容易跟系统保留段名冲突
如果我们要将一个二进制文件,如图片、MP3 音乐作为目标文件中的一个段,可以使用 objcopy
工具,对于这个段,我们可以在程序里面直接声明并使用它们
同时,如果想让指定变量放在指定段,GCC 提供了一个扩展机制,可以指定变量所处的段
__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo() {}
6. ELF 文件结构描述
一个 ELF 文件包括 ELF 文件头、各种段、段表、ELF 中辅助的结构如字符串表、符号表
6.1 文件头
可以通过 readelf
命令来详细查看 ELF 头文件
从输出可以看出 ELF 文件头中定义了 ELF 魔数、文件机器字节长度、数据存储方式、版本、运行平台、ABI 版本、ELF 重定位类型、硬件平台、硬件平台版本、入口地址、程序头入口和长度、段表的位置和长度及段的数量等
ELF 文件头结构及相关常数被定义在 /usr/include/elf.h
里
ELF 魔数:用来标识 ELF 文件的平台属性,可以确认文件的类型,操作系统加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载
文件类型:表示 ELF 文件类型,可重定位文件、可执行文件、共享目标文件
6.2 段表
段表是 ELF 文件中除了文件头以外最重要的结构,它描述了 ELF 的各个段的信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限及段的其他属性
编译器、链接器和装载器都是依靠段表来定位和访问各个段的属性的
可以通过 readelf -S
来查看 ELF 文件的段
输出的结果就是 ELF 文件段表的内容,可以看到段的类型、标志位、链接信息等
6.3 重定位表
链接器在处理目标文件时,需要对目标文件中某些部位进行重定位,即代码段和数据段中那些对绝对地址的引用的位置
6.4 字符串表
字符串表用来保存普通的字符串,比如符号的名字;段表字符串用来保存段表中用到的字符串,最常见的就是段名
7. 链接的接口——符号
链接过程的本质就是要把多个不同的目标文件之间相互“粘”到一起,或者说像玩具积木一样,可以拼装成一个整体。在链接中,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的地址的引用。
我们将函数和变量统称为符号,函数名或变量名就是符号名
每一个目标文件都会有一个相应的符号表,这个表里面记录了目标文件中所用到的所有符号,每个定义的符号有一个对应的值,叫做符号值,对于变量和函数来说,符号值就是它们的地址,它们有可能是以下类型中的一种:
- 定义在本目标文件的全局符号,可以被其他目标文件引用
- 在本目标文件中引用的全局符号,却没有定义在本目标文件(外部符号)
- 段名,这种符号往往由编译器产生,它的值就是该段的起始地址
- 局部符号,这类符号只在编译单元内部可见
- 行号信息,即目标文件指令与源代码中代码行的对应关系,它也是可选的
可以使用很多工具来查看 ELF 文件的符号表,如 readelf、objdump、nm 等nm SimpleSection.o
或 readelf -s SimpleSection.o
8. ELF 符号表结构
ELF 文件中的符号表往往是文件中的一个段,段名一般叫 .symtab
符号表是一个结构体的数组,每个结构体对应一个符号,这个数组的第一个元素为无效的“未定义”符号,结构体包含的成员信息有符号类型和绑定信息,符号所在的段,符号值等
9. 特殊符号
当我们使用 ld 作为链接器来链接生成可执行文件时,它会为我们定义很多特殊的符号,这些符号并没有在你的程序中定义,但是你可以直接声明并引用它,称之为特殊符号,这些符号是被定义在 ld 链接器的链接脚本中的
__executable_start:该符号为程序起始地址,不是入口地址,是程序最开始的地址
__etext 或 _etext 或 etext:该符号为代码段结束地址,即代码段最末尾的地址
_edata 或 edata:该符号为数据段结束地址,即数据段最末尾的地址
_end 或 end:该符号为程序结束地址
#include <stdio.h> extern char __executable_start[]; extern char etext[], _etext[], __etext[]; extern char edata[], _edata[]; extern char end[], _end[]; int main() { printf("Executable Start %X\n", __executable_start); printf("Text End %X %X %X\n", etext, _etext, __etext); printf("Data End %X %X\n", edata, _edata); printf("Executable End %X %X\n", end, _end); return 0; }
10. 符号修饰与函数签名
同一种语言编写的目标文件有可能产生符号冲突,当程序很大时,不同的模块由多个部门开发,它们之间的命名规范如果不严格,则有可能导致冲突。因此 c++ 引入了名称空间的方法来解决这个问题
c++ 里面支持函数重载,因此只依靠函数名无法很好的区分,为了支持这些特性,引入了符号修饰或符号改编的机制
但是由于不同编译器采用不同的名字修饰方法,必然会导致由不同编译器编译产生的目标文件无法正常相互链接,这是导致不同编译器之间不能互相操作的主要原因之一
C++ 为了与 C 兼容,在符号的管理上,有一个用来声明或定义一个 C 的符号的extern "C"
关键字用法
C++ 编译器会将在extern "C"
的大括号内部的代码当做 C 语言的代码处理extern "C" { int func(int); int var; } extern "C" int gvar; // 兼容写法 #ifdef __cplusplus extern "C" { #endif void *memset(void*, int, size_t); #ifdef __cplusplus } #endif
11. 强符号与弱符号
多个目标文件中含有相同名字全局符号的定义,那么这些目标文件链接的时候将会出现符号重复定义的错误,
multiple definition of 'xxx'
这种符号的定义可以被称为强符号,有些符号的定义可以被称为弱符号,对于 C/C++ 语言来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量为弱符号,可以通过 GCC 的__attribute__((weak))
来定义任何一个强符号为弱符号
针对强弱符号的概念,链接器就会按如下规则处理与选择被多次定义的全局符号:不允许强符号被多次定义
如果一个符号在某个目标文件中是强符号,在其他文件中都是弱符号,那么选择强符号
如果一个符号在所有目标文件中都是弱符号,那么选择其中占用空间最大的一个
对外部目标文件的符号引用在目标文件被最终链接成可执行文件时,它们需要被正确决议,如果没有找到该符号的定义,链接器就会报符号未定义错误,这种被称为强引用,还有一种弱引用,处理弱引用符号时,如果该符号有定义,则链接器将该符号的引用决议,如果该符号未被定义,则链接器对于该引用不报错
参考文章:C语言:attribute((weak)) 弱符号与__attribute__ ((weakref())弱引用_#define func_weakref(func_name) attribute((wea_R-QWERT的博客-CSDN博客
一般对于未定义的弱引用,链接器默认其为 0,或是一个特殊的值,以便程序代码能够识别,在 GCC 中,我们可以通过使用 __attribute__((weakref))
这个扩展关键字来声明对一个外部函数的引用为弱引用
// 引用外部的 foo, 然后本地使用(必须是静态函数)
__attribute__ ((weakref("foo"))) static void foo_wref();
int main() {
if (foo_wref) foo_wref();
}
这种弱符号和弱引用对于库来说十分有用,比如库中定义的弱符号可以被用户定义的强符号所覆盖,从而使得程序可以使用自定义版本的库函数;或者程序可以对某些扩展功能模块的引用定义为弱引用,当我们将扩展模块与程序链接在一起时,功能模块就可以正常使用;如果我们去掉了某些功能模块,那么程序也可以正常链接,只是缺少了相应的功能,这使得程序的功能更加容易裁剪和组合,例如支持多线程版本和单线程版本:
#include <stdio.h>
#include <pthread.h>
// gcc pthread.c -Wl,--no-as-needed,-rpath='.' -lpthread libsvc_add.so -o pt (多线程版本)
// gcc pthread.c -o pt (单线程版本)
__attribute__((weak)) extern int pthread_create(pthread_t *,
const pthread_attr_t *, void *(*)(void *),
void *);
__attribute__((weak)) extern int pthread_join(pthread_t __th, void **__thread_return);
__attribute__((weak)) extern int add(int, int);
void* func(void* arg) {
if (add) {
printf("1 + 2 = %d\n", add(1, 2));
} else {
printf("no add function\n");
}
return NULL;
}
int main() {
if (pthread_create) {
printf("This is multi-thread version!\n");
pthread_t mythread;
pthread_create(&mythread, NULL, func, NULL);
pthread_join(mythread, NULL);
} else {
printf("This is single-thread version!\n");
}
return 0;
}
12. 调试信息
在 GCC 编译时加上 -g
参数,编译器就会在产生的目标文件里面加上调试信息,目标文件里会多很多 “debug” 相关的段,调试信息在目标文件和可执行文件中中占用很大的空间,往往比程序的代码和数据本身大好几倍,所以当我们开发完程序并要将它发布的时候,需要把这些对用户没有用的调试信息去掉,以节省大量的空间。在 Linux 下,可以使用 strip
命令来去掉 ELF 文件中的调试信息
第四章小结
1. 静态链接的过程
对于链接器来说,整个链接过程就是将几个输入目标文件加工后合并成一个输出文件。可执行文件中的数据段和代码段都是由输入的目标文件中合并而来的,一般来说可以想到两种合并方式,第一种是按序叠加,但是这种方式在有很多输入文件的情况下,输出文件将会有很多零散的段,因为每个段都需要有一定的地址和空间对齐要求。还有一种方式是相似段合并,将相同性质的段合并到一起,一般来说分为两步:
- 空间与地址分配:扫描所有的输入目标文件,获得各个段的长度、属性和位置,并且将输入目标文件中的符号表中所有的符号定义和符号引用收集起来,统一放到一个全局符号表。这一步中,链接器将能够获得所有输入目标文件的段长度,并且将它们合并,计算出输出文件中各个段合并后的长度与位置,并建立映射关系
- 符号解析与重定位:使用上面第一步中收集到的所有信息,读取输入文件中段的数据、重定位信息,并且进行符号解析与重定位、调整代码中的地址等
代码 a.c
:
extern int shared;
int main() {
int a = 100;
swap(&a, &shared);
}
代码 b.c
:
int shared = 1;
void swap(int *a, int *b) {
int tmp = *a;
*a = *b;
*b = tmp;
}
首先分别编译成目标文件: gcc -fno-stack-protector -c a.c b.c
然后将它们链接起来:ld a.o b.o -e main -o ab
- -e main 表示将 main 函数作为程序入口,ld 链接器默认的程序入口为 _start
- -o ab 表示链接输出文件名为 ab,默认为 a.out
使用 objdump 查看链接前后的地址分配情况如下:
a.o
b.o
ab
VMA 表示虚拟内存地址,LMA 表示加载内存地址,可以看到链接器目标文件中所有段的 VMA 都是 0,因为虚拟空间还没有被分配,等到链接之后,可执行文件中的各个段都被分配到了相应的虚拟地址
由于各个符号在段内的相对位置是固定的,因此知道了段的虚拟地址,也就得到了各符号的地址,主要是通过指令的偏移来计算的,对于函数调用这类的指令,需要进行重定位,有一个重定位表的结构专门用来保存这些与重定位相关的信息
在链接器扫描完所有的目标文件之后,所有这些未定义的符号都应该能够在全局符号表中找到,否则链接器就报符号未定义错误
未初始化的全局变量会被当作弱符号处理,GCC 的 “-fno-common” 允许我们把所有未初始化的全局变量不以 COMMON 块的形式处理,或者使用扩展
int global __attribute__((nocommon));
2. C++ 相关问题
重复代码消除:例如当一个模板在多个编译单元同时实例化成相同的类型的时候,必然会生成重复的代码,如果将这些重复的代码都保留下来,会有以下几个问题:
- 空间浪费
- 地址较易出错。有可能两个指向同一个函数的指针会不相等
- 指令运行效率较低
一个比较有效的做法就是将每个模板的实例代码都单独地存放到一个段里,每个段只包含一个模板实例,这种方法虽然能够基本上解决代码重复的问题,但还是存在一些问题。比如相同名称的段可能拥有不同的内容,这可能由于不同的编译单元使用了不同的编译器版本或者编译优化选项,导致同一个函数编译出来的实际代码有所不同。那么这种情况下链接器可能会做出一个选择,那就是随意选择其中任何一个副本作为链接的输入,然后同时提供一个警告信息
函数级别链接:一个目标文件可能包含成千上百个函数,如果我们只想使用其中的某个函数,那就不得不把整个地址链接进来,这样的后果是链接输出文件会变得很大,所有用到的没用到的变量和函数都一起塞进到了输出文件,因此有些编译器提供了函数级别链接,作用是让所有的函数都像前面模板函数一样,单独保存到一个段里面
全局构造与析构:C++的全局对象构造函数在 main 函数之前被执行,析构函数在 main 之后执行;为此,ELF 还定义了两种特殊的段:
.init
:该段里面保存的是可执行指令,它构成了进程的初始化代码.fini
:该段保存着进程终止代码指令3. C++ 与 ABI
如果要使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI(Application Binary Interface)。ABI 是指二进制层面的接口,ABI 的兼容程度比 API 要更为严格,API 相同并不表示 ABI 相同
影响 ABI 的因素非常多,硬件、编程语言、编译器、链接器、操作系统等都会影响 ABI,对于 C 语言的目标代码来说,以下几个方面会决定目标文件之间是否为二进制兼容:内置类型(如int、float、char 等)的大小和在存储器中的放置方式(大端、小端、对齐方式等)
组合类型(如 struct、union、数组等)的存储方式和内存分布
外部符号(external-linkage)与用户定义的符号之间的命名方式和解析方式,如函数名 func 在 C 语言的目标文件中是否被解析成外部符号 _func
函数调用方式,比如参数入栈顺序、返回值如何保持等
堆栈的分布方式,比如参数和局部变量在堆栈里的位置,参数传递方法等
寄存器的使用约定,函数调用时哪些寄存器可以修改,哪些要保存,等等
C++ 在语言层面对 ABI 的影响又增加了很多额外的内容:
- 继承类体系的内存分布,如基类,虚基类在继承类中的位置等
- 指向成员函数的指针的内存分布,如何通过指向成员函数的指针来调用成员函数,如何传递 this 指针
- 如何调用虚函数,vtable 的内容和分布形式,vtable 指针在 object 中的位置等
- template 如何实例化
- 外部符号的修饰
- 全局对象的构造和析构
- 异常的产生和捕获机制
- 标准库的细节问题,RTTI 如何实现
- 内联函数访问细节
第五章小结
Windows 下的可执行文件、动态链接库等都使用 PE 文件格式,PE 文件格式是 COFF 文件格式的改进版本,增加了 PE 文件头、数据目录等一些结构,使得能满足程序执行时的需求
第六章小结
1. 进程虚拟地址空间
可执行文件只有装载到内存以后才能被 CPU 执行,程序(或者狭义上讲可执行文件)是一个静态的概念,它就是一些预先编译好的指令和数据集合的一个文件;进程则是一个动态的概念,它是程序运行时的一个过程,很多时候把动态库叫做运行时也有一定的含义
物理内存的使用主要分为操作系统使用和用户使用,用户不直接操作物理内存地址,而是由操作系统提供虚拟内存地址
虚拟地址空间的大小由计算机的硬件平台决定,具体说是由 CPU 的位数决定了虚拟地址空间的地址大小,从硬件层面上来讲,原先的 32 位地址线只能访问最多 4 GB 的物理内存。但是自从扩展至 36 位地址线之后,Intel 修改了页映射的方式,使得新的映射方式可以访问到更多的物理内存。Intel 把这个地址扩展方式叫做 PAE(Physical Address Extension)
扩展了物理地址空间后,32 虚拟地址空间要想访问额外的物理内存,操作系统可以通过提供一个窗口映射的方法,把这些额外的内存映射到进程的地址空间中来
2. 装载的方式
程序运行时是有局部性原理的,所以我们可以将程序最常用的部分驻留在内存中,而将一些不太常用的数据存放在磁盘里面,这就是动态装入的基本原理。
覆盖装入和页映射是两种很典型的动态装载方法,它们所采用的思想都差不多,原则上都是利用了程序的局部性原理。动态装入的思想是程序用到哪个模块,就将哪个模块装入内存,如果不用就暂时不装入,存放在磁盘中
覆盖装入的方法把挖掘内存潜力的任务交给了程序员,程序员在编写程序的时候必须手工将程序分割成若干块,然后编写一个小的辅助代码来管理这些模块何时应该驻留内存而何时应该被替换掉
页映射将内存和所有磁盘中的数据和指令按照“页”的单位划分成若干个页,以后所有的装载和操作的单位都是页,一般一页的大小是 4K,通过页映射的方式将虚拟地址的页映射到对应的物理地址,如果当前内存中的页满了,此时再分配新的页就需要淘汰一些已存在的页到磁盘中
3.进程的建立
创建一个独立的虚拟地址空间
读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
将CPU的指令寄存器设置成可执行文件的入口地址,启动运行
将 ELF 文件中的段映射到内存的过程中,为了节省空间,相同权限的段合并到一起当作一个段进行映射,通过 readefl -l xxx
命令可以查看 ELF 合并后的段
第七章小结
静态链接的缺点:
- 浪费内存空间:假如多个进程依赖同一个静态库,会在内存中存在多份目标文件的副本
- 模块更新困难:静态库依赖的目标文件如果有更新的话,整个程序就需要重新链接、发布给客户
动态链接的优点:
- 不对那些组成程序的目标文件进行链接,等到程序要运行时才进行链接,把链接这个过程推迟到了运行时再进行
- 当我们要升级程序库或程序共享的某个模块时,理论上只要简单地将旧的目标文件覆盖掉,而无需将所有的程序再重新链接一遍
动态链接的缺点:
- 动态链接会导致程序在性能的一些损失,但是对动态链接的链接过程可以进行优化,使得动态链接的性能损失尽可能地小
在静态链接时,整个程序最终只有一个可执行文件,它是一个不可以分割的整体;但是在动态链接下,一个程序被分成了若干个文件,有程序的主要部分,即可执行文件和程序所依赖的共享对象,很多时候我们也把这些部分称为模块,即动态链接下的可执行文件和共享对象都可以看作是程序的一个模块,动态链接由动态链接器来完成,在系统开始运行可执行文件之前,首先会把控制权交给动态链接器,由它完成所有的动态链接工作以后再把控制权交给主程,然后开始执行
共享对象的最终装载地址在编译时是不确定的,而是在装载时,装载器根据当前地址空间的空闲情况,动态分配一块足够大小的虚拟地址空间给相应的共享对象,共享对象在编译时不能假设自己在进程虚拟地址空间中的位置;动态链接模块被装载映射至虚拟空间后,指令部分是在多个进程空间之间共享的,由于装载时重定位的方法需要修改指令,所以没有办法做到同一份指令被多个进程共享,因为指令被重定位后对于每个进程来讲是不同的,如果 GCC 编译时只使用 -shared 选项,则是使用装载时重定位的方法
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势,为了解决这一问题,地址无关代码的技术被提出,基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本,可以通过 GCC 的编译选项 -fPIC 来生成地址无关的代码
-fpic 和 -fPIC:这两个参数从功能上来讲完全一样,都是指示 GCC 产生地址无关代码,唯一的区别是 -fPIC 产生的代码要大,而 -fpic 产生的代码相对较小,而且较快;但是由于地址无关代码都是跟硬件平台相关的,不同的平台有着不同的实现,-fpic 在某些平台上会有一些限制,比如全局符号的数量或者代码的长度等,而 -fPIC 没有这样限制,因此为了方便起见,绝大部分情况下都使用 -fPIC 参数来产生地址无关代码
PIC与PIE:地址无关代码技术除了可以用在共享对象上面,它也可以用于可执行文件,一个以地址无关方式编译的可执行文件被称作地址无关可执行文件(PIE),产生 PIE 的参数为 -fPIE 或 -fpie
动态链接比静态链接慢的主要原因是动态链接下对于全局和静态的数据访问都要进行复杂的 GOT 定位,然后间接寻址;对于模块间的调用也要先定位 GOT,然后再进行间接跳转;另外一个减慢运行速度的原因是动态链接的链接工作在运行时完成,即程序开始执行时,动态链接器都要进行一次链接工作
延迟绑定(PLT):如果一开始就把所有的函数链接好实际上是一种浪费,所以 ELF 采用了一种叫延迟绑定的做法,基本思想就是当函数第一次被用到时才进行绑定(符号查找、重定位等),如果没有用到则不进行绑定
显式运行时链接:支持动态链接的系统往往都支持一种更加灵活的模块加载方式,叫做显式运行时链接,有时候也叫做运行时加载。也就是让程序自己在运行时控制加载指定的模块,并且可以在不需要该模块时将其卸载:
- 当程序需要用到某个插件或者驱动的时候,才将相应的模块装载进来,而不需要从一开始就将他们全部装载进来,从而减少了程序启动时间和内存使用
- 可以在运行的时候重新加载某个模块,这样使得程序本身不必重新启动而实现模块的增加、删除、更新等
主要涉及到四个函数:打开动态库(dlopen)、查找符号(dlsym)、错误处理(dlerror)以及关闭动态库(dlclose)
dlopen(3) - Linux manual page
第八章小结
1. 共享库兼容性
共享库版本的更新可能会导致接口的更改或删除,这可能导致依赖于该共享库的程序无法正常运行。在最简单的情况下,共享库的更新可以被分为两类:
- 兼容更新。所有的更新只是在原有的共享库基础上添加一些内容,所有原有的接口都保持不变
- 不兼容更新。共享库更新改变了原有的接口,使用该共享库原有接口的程序可能不能运行或运行不正常
这里的接口指的是二进制接口,即 ABI。导致 C 语言的共享库 ABI 改变的行为主要有如下 4 个:
- 导出函数的行为发生改变,也就是说调用这个函数以后产生的结果与以前不一样,不再满足旧版本规定的函数行为准则
- 导出函数被删除
- 导出数据的结构发生变化,比如共享库定义的结构体变量的结构发生改变:结构成员删除、顺序改变或其他引起结构内存布局变化的行为(不过通常来讲,往结构体的尾部添加成员不会导致不兼容,当然这个结构体必须是共享库内部分配的,如果是外部分配的,在分配该结构体时必须考虑成员添加的情况)
- 导出函数的接口发生变化,如函数返回值、参数被更改
很多因素会导致 ABI 的不兼容,比如不同版本的编译器、操作系统和硬件平台等
2. 共享库版本命名
Linux 有一套规则来命名系统中的每一个共享库,它规定共享库的命名规则必须如下:
libname.so.x.y.z
最前面使用前缀 “lib” 、中间是库的名字和后缀 “.so”,最后面跟着的是三个数字组成的版本号。”x” 表示主版本号,”y” 表示次版本号,”z” 表示发布版本号
- 主版本号表示库的重大升级,不同主版本号的库之间是不兼容的,依赖于旧的主版本号的程序要改动相应的部分,并且重新编译,才可以在新版的共享库中运行;或者,系统必须保留旧版的共享库,使得那些依赖于旧版共享库的程序能够正常运行
- 次版本号表示库的增量升级,即增加一些新的接口符号,且保持原来的符号不变。在主版本号相同的情况下,高的次版本号的库向后兼容低的次版本号的库。一个依赖于旧的次版本号共享库的程序,可以在新的此版本号共享库中运行,因为新版中保留了原来的所有接口,并且不改变它们的定义和含义
- 发布版本号表示库的一些错误的修正、性能的改进等,并不添加任何新的接口,也不对接口进行改进。相同主版本号、次版本号的共享库,不同的发布版本号之间完全兼容,依赖于某个发布版本号的程序可以在任何一个其它发布版本号中正常运行,而无需做任何修改
当然并不是所有 Linux 的库都遵循这个命名规则,如 glibc 的 libc-x.y.z.so
3. SO-NAME
程序中必须包含被依赖的共享库的名字和主版本号,因为主版本号不同的库是完全不兼容的,对于库 libxxx.so.2.3.4 来说,它的 SO-NAME 就是 libxxx.so.2。在 Linux 系统中,系统会为每个共享库在它所在的目录创建一个跟 SO-NAME 相同的并且指向它的软链接
这个软链接会指向目录中版本号相同、次版本号和发布版本号最新的共享库,这样保证了所有的以 SO-NAME 为名的软链接都指向系统中最新版的共享库。建立 SO-NAME 为名字的软连接的目的是,使得所有依赖某个共享库的模块,在编译、链接和运行时,都使用共享库的 SO-NAME,而不使用详细的版本号
Linux 中提供了一个工具叫做 “ldconfig”,当系统中安装或更新一个共享库时,就需要运行这个工具,它会遍历所有的默认共享库目录,比如 /lib、/usr/lib 等,然后更新所有的软链接,使它们指向最新版的共享库;如果安装了新的共享库,那么 ldconfig 会为其创建相应的软链接,具体命令是 ldconfig -p
4. 符号版本机制
动态链接器在进行动态链接时,只进行主版本号的判断,即只判断 SO-NAME,如果某个被依赖的动态库 SO-NAME 与系统中存在的实际共享库 SO-NAME 一致,那么系统就认为接口兼容,而不再进行兼容性检查。这样就会出现一个问题,当某个程序依赖于较高的次版本号的共享库,而运行于较低次版本号的共享库系统时,就可能产生缺少某些符号的错误。对于这个问题,现代的系统通过一种更加精巧的方式来解决,那就是符号版本机制。
Linux 下的 Glibc 从版本 2.1 之后开始支持一种叫做基于符号 的版本机制的方案。这个方案的基本思路是让每个导出和导入的符号都有一个相关联的版本号,它的实际做法类似于名称修饰的方法。Linux 系统下共享库的符号版本机制并没有被广泛应用,主要使用共享库符号版本机制的是 Glibc 软件包中所提供的共享库
GCC 在 Solaris 系统中的符号版本机制的基础上还提供了两个扩展。第一个扩展是,除了可以在符号版本脚本中指定符号的版本之外,GCC 还允许使用一个叫做 “.symver“ 的汇编宏指令来指定符号的版本,这个汇编宏指令可以被用在 GAS 汇编中,也可以在 GCC 的 C/C++ 源代码中以嵌入汇编指令的模式使用;第二个扩展是 GCC 允许多个版本的同一个符号存在于一个共享库中
Linux 下指定符号版本可以使用链接器脚本,如下:
VERSION_1.2 {
global:
old_printf;
new_printf;
local:
*;
};
这样就指定了符号的版本为 VERSION_1.2,链接命令为 gcc -shared -fPIC print_so.c -Wl,--version-script=libprint.map -o libprintf.so
5. 共享库构造和析构函数
GCC 提供了共享库的构造和系统函数支持,如果有多个构造函数,可以通过优先级参数指定顺序
#include <stdio.h>
void __attribute__((constructor)) init_func();
void __attribute__((destructor)) fini_func();
void init_func() {
puts("init");
}
int foo(int a, int b) {
return a + b;
}
void fini_func() {
puts("fini");
}
第九章小结
Windows 下的动态链接
- Windows 下的 DLL 文件和 EXE 文件实际上是一个概念,它们都是有 PE 格式的二进制文件,稍微有些不同的是 PE 文件头部中有个符号位表示该文件是 EXE 或是 DLL,而 DLL 文件的扩展名不一定是
.dll
- DLL 的设计目的与共享对象有些出入,DLL 更加强调模块化,使得各种模块之间能够松散地组合、重用和升级,Windows 平台上大量的大型软件都通过升级 DLL 的形式进行自我完善
进程地址空间和内存管理
- 32 位版本的 Windows 开始支持进程拥有独立的地址空间,一个 DLL 在不同的进程中拥有不同的私有数据副本
- 在 ELF 中,由于代码段是地址无关的,所以它可以实现多个进程之间共享一份代码,但是 DLL 的代码却不是地址无关的,所以它只是在某些情况下可以被多个进程间共享
基地址和 RVA
- 当一个 PE 文件被装载时,其进程地址空间中的起始地址就是基地址,相对地址就是一个地址相对于基地址的偏移
DLL 共享数据段
- Windows 允许将 DLL 的数据段设置成共享的,即任何进程都可以共享该 DLL 的同一份数据段,也就是一个 DLL 中有两个数据段,一个进程间共享,另外一个私有
- 这种进程间共享方式也产生了一定的安全漏洞,因为任意一个进程都可以访问这个共享的数据段,因此这种 DLL 共享数据段来实现进程间通信应该尽量避免
DLL 导入导出
- 在 ELF 中,共享库中所有的全局函数和变量在默认情况下都可以被其他模块使用,也就是说 ELF 默认导出所有的全局符号。但是在 DLL 中需要显式地告诉编译器,否则默认所有符号都不导出
- 对于一些支持 Windows 平台的编译器,我们可以通过
__declspec
属性关键字来修饰某个函数或者变量,使用__declspec(dllexport)
表示该符号是从本 DLL 导出的符号,使用__declspec(dllimport)
表示该符号是从别的 DLL 导入的符号 - 在 C++ 中,如果希望导出的符号符合 C 语言的符号修饰规范,那么必须在这个符号的定义之前加上
extern "C"
以防止 C++ 编译器进行符号修饰 - 除了使用
__declspec
扩展关键字指定导入导出符号之外,也可以使用.def
文件来声明导入导出的符号,使用它的好处是可以为符号设置别名
使用 DLL
创建 DLL 并使用 MSVC 编译器生成 Math.dll
extern "C" { __declspec(dllexport) double Add(double a, double b) { return a + b; } __declspec(dllexport) double Sub(double a, double b) { return a - b; } __declspec(dllexport) double Mul(double a, double b) { return a * b; } }
可以使用
dumpbin
工具看到 DLL 的导出符号,命令dumpbin /EXPORTS Math.dll
- 在 C++ 源文件中使用 Math.dll
#include <iostream> using namespace std; extern "C" { __declspec (dllimport) double Sub(double a, double b); } int main(void) { cout << Sub(2.0, 1.0) << endl; return 0; }
使用模块定义文件
使用
.def
可以不用在 Math.cpp 中使用__declspec(dllexport)
导出符号,而是导出文件中指定的符号,下面导出Div
符号LIBRARY MATH EXPORTS Div
Math.cpp
中符号定义如下extern "C" { double Div(double a, double b) { return a / b; } }
使用 CMake 生成动态库时加上
Math.def
add_library(Math SHARED Math.cpp Math.def)
使用
.def
文件来描述 DLL 文件的导出属性好处在于可以控制导出符号的符号名。除了 C++ 程序以外,C 语言的符号也有可能被修饰,比如 MSVC 支持集中函数的调用规范,__cdecl
、__stdcall
、__fastcall
,默认情况下 MSVC 把 C 语言的函数当做__cdecl
类型,这种情况下它对该函数不进行任何符号修饰。但是一旦使用其他的函数调用规范时,MSVC 编译器就会对符号名进行修饰,比如使用使用__stdcall
调用规范的函数 Add 就会被修饰成_Add@16
使用
.def
文件可以将导出函数重新命名,比如当 Add 函数采用__stdcall
时,我们可以使用如下的.def
文件LIBRARY Math EXPORTS Add=_Add@16
当一个 DLL 被多个语言编写的模块使用时,采用这种方法导出一个函数往往会很有用。比如 VB 采用的是
__stdcall
的函数调用规范,这个规范也是大多数 Windows 下编程语言所支持的通用调用规范,那么作为一个能够被广泛使用的 DLL 最好采用__stdcall
的函数调用规范
DLL 显式运行时链接
- Windows 提供了三个 API 支持 DLL 的运行时链接
#include <iostream> #include "windows.h" using namespace std; using Func = double(*)(double, double); int main() { Func function; double result; // Load DLL HINSTANCE hinstLib = LoadLibrary("Math.dll"); if (hinstLib == NULL) { cout << "ERROR: unable to load DLL\n"; return 1; } // Get function address function = (Func)GetProcAddress(hinstLib, "Add"); if (function == NULL) { cout << "ERROR: unable to find DLL function\n"; FreeLibrary(hinstLib); return 1; } // Call function result = function(1.0, 2.0); // Unload DLL file FreeLibrary(hinstLib); // Display result cout << "Result = " << result << endl; return 0; }
C++ 编写动态链接库
在 Windows 平台下(有些意见对 Linux/ELF 也有效),要尽量遵循以下指导意见:
- 所有的接口函数都应该是抽象的。所有的方法都应该是纯虚的。(或者是 inline 的方法也可以)
- 所有的全局函数都应该使用 extern “C” 来防止名字修饰的不兼容。并且导出函数的都应该是 __stdcall 调用规范的。这样即使用户本身的程序是默认以 __cdecl 方式编译的,对于 DLL 的调用也能够正确。
- 不要使用 C++ 标准库 STL。
- 不要使用异常。
- 不要使用虚析构函数。可以创建一个 destroy() 方法并且重载 delete 操作符并且调用 destroy()。
- 不要在 DLL 里面申请内存,而且在 DLL 外释放(或者相反)。不同的 DLL 和可执行文件可能使用不同的堆,在一个堆里面申请内存而在另一个堆里面释放会导致错误。比如,对于内存分配相关的函数不应该是 inline 的,以防止它在编译时被展开到不同的 DLL 和可执行文件。
- 不要在接口中使用重载方法。因为不同的编译器对于 vtable 的安排可能不同。
第十章小结
程序的内存布局
一般来讲,应用程序使用的内存空间里有如下“默认”的区域:
- 栈:栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。栈通常在用户空间的最高地址处分配,通常有数兆字节的大小
- 堆:堆是用来容纳应用程序动态分配的内存区域,当程序使用 malloc 或 new 分配内存时,得到的内存来自堆里。堆通常存在于栈的下方(低地址方向,栈向低地址增长,堆向高地址增长),在某些时候,堆也可能没有固定统一的存储区域。堆一般比栈大很多,可以有几十至数百兆字节的容量
- 可执行文件映像:这里存储着可执行文件在内存里的映像,由装载器在装载时将可执行文件的内存读取或映射到这里
- 保留区:保留区并不是一个单一的内存区域,而是对内存中受到保护而禁止访问的内存区域的总称,例如,大多数操作系统里,极小的地址通常都是不允许访问的,如 NULL
程序出现段错误的一般原因:非法指针解引用造成的错误
- 指针初始化为 NULL,之后却没有给它一个合理的值就开始使用指针
- 没有初始化栈上的指针,指针的值是一般会是一个随机数,之后就直接开始使用指针
栈与调用惯例
栈
栈栈程序运行中具有举足轻重的地位。最重要的,栈保存了一个函数调用所需要的维护信息,这常常被称为堆栈帧或活动记录。堆栈帧一般包括如下几方面内容:
- 函数的返回地址和参数
- 临时变量:包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 保存的上下文:包括在函数调用前后需要保持不变的寄存器
调用惯例
函数的调用方和被调用方对于函数如何调用必须要有一个明确的约定,只有双方都遵守同样的约定,函数才能被正确地调用,这样的约定就称为调用惯例。一个调用惯例一般会规定如下几个方面的内容
- 函数参数的传递顺序和方式
- 栈的维护方式
- 名字修饰的策略
在 C 语言里,存在着多个调用惯例,而默认的调用惯例是 cdecl
。任何一个没有显示指定调用惯例的函数都默认是 cdecl
惯例(注:_cdecl
是非标准关键字,在不同的编译器里可能有不同的写法,例如在 gcc 里就不存在 _cdecl
这样的关键字,而是使用 __attribute__((cdecl))
几项主要的调用惯例如下:
调用惯例 | 出栈方 | 参数传递 | 名字修饰 |
---|---|---|---|
cdecl | 函数调用方 | 从右至左的顺序压参数入栈 | 下划线+函数名 |
stdcall | 函数本身 | 从右至左的顺序压参数入栈 | 下划线+函数名+@+参数的字节数 |
fastcall | 函数本身 | 头两个 DWORD(4 byte)类型或者占更少字节的参数被放入寄存器,其他剩下的参数按从右到左的顺序压入栈 | @+函数名+@+参数的字节数 |
pascal | 函数本身 | 从左至右的顺序压参数入栈 | 较为复杂,见 pascal 文档 |
x86 Function Attributes (Using the GNU Compiler Collection (GCC))
堆与内存惯例
堆是一块的内存空间,常常占据整个虚拟空间的绝大部分。在这片空间里,程序可以请求一块连续的内存,并自由地使用,这块内存在程序主动放弃之前都会一直保持有效
glibc 的 malloc 函数是这样处理用户的空间请求的:对于小于 128KB 的请求来说,它会在现有的堆空间里面,按照分配算法为它分配一块空间并返回;对于大于 128KB 的请求来说,它会使用 mmap() 函数为它分配一块匿名空间,然后在这个匿名空间中为用户分配空间
堆相关知识点:
- 不能连续释放堆里的同一片内存
- 堆不总是向上增长,Windows 里不一定遵守这个规律
- 系统批发的空间不够用了,它就会通过系统调用或者 API 向操作系统进货
- 进程结束后,malloc 分配的内存会自动释放。因为当进程结束以后,所有与进程相关的资源,包括进程的地址空间、物理内存、打开的文件、网络链接等都被操作系统关闭或者收回,所以无论 malloc 申请了多少内存,进程结束以后都不存在了
- malloc 分配的内存空间在虚拟空间是连续的,物理空间不一定连续
C++ 的 new:
#include <iostream>
#include <memory>
class Test {
public:
Test() { std::cout << "Test()" << std::endl; }
~Test() { std::cout << "~Test()" << std::endl; }
void func() { std::cout << "func()" << std::endl; }
};
int main(int argc, char* argv[]) {
// c++ new 的步骤, 分配内存->执行构造函数->执行析构函数->释放内存
//void *mem = operator new(sizeof(Test));
void *mem = malloc(sizeof(Test));
Test *t = new(mem) Test();
t->~Test();
free(mem);
//operator delete(mem);
Test *tp = new(std::nothrow) Test; // 内存不足不抛异常
tp->func();
delete tp;
return 0;
}
第十一章小结
入口函数和程序初始化
操作系统装载程序之后,首先运行的代码并不是 main 的第一行,而是某些别的代码,这些代码负责准备好 main 函数执行所以需要的环境,并且负责调用 main 函数,这时候你才可以在 main 函数里放心大胆地写各种代码:申请内存、使用系统调用、触发异常、访问 I/O。一个典型的程序运行步骤大致如下:
- 操作系统在创建进程后,把控制权交到了程序的入口,这个入口往往是运行库中的某个入口函数。
- 入口函数对运行库的程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造,等等。
- 入口函数在完成初始化之后,调用 main 函数,正式开始执行程序主体部分。
- main 函数在执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量析构、堆销毁、关闭 I/O 等,然后进入系统调用结束进程
运行库与 I/O
对于计算机来说,I/O 代表了计算机与外界的交互,交互的对象可以是人或其他设备。而对于程序来说,I/O 涵盖的范围还要宽广一些。一个程序的 I/O 指代了程序与外界的交互,包括文件、管道、网络、命令行、信号等。更广义地讲,I/O 指代任何操作系统理解为 “文件” 的事务。
线程局部存储实现
假设我们要在线程中使用一个全局变量,但希望这个全局变量是线程私有的,而不是所有线程共享的。这时候就需要用到线程局部存储(TLS,Thread Local Storage)。一旦一个全局变量被定义成 TLS 类型的,那么每个线程都会拥有这个变量的一个副本,任何线程对该变量的修改都不会影响其他线程中该变量的副本。
// GCC
__thread int number;
// MSVC
__declspec(thread) int number;
以上的方式叫做隐式 TLS,即程序员无需关心 TLS 变量的申请、分配赋值和释放,编译器、运行库还有操作系统已经将这一切悄悄处理妥当了。还有一种叫显式 TLS 的方法,这种方法是程序员需要手工申请 TLS 变量,并且每次访问该变量时都要调用相应的函数得到变量的地址,并且在访问完成之后需要释放该变量。Linux 下相对应的库函数为 pthread 库中的 pthread_key_create()、pthread_getspecific()、pthread_setspecific() 和 pthread_key_delete()。
现对于隐式的 TLS 变量,显示的 TLS 变量的使用十分麻烦,而且有诸多限制,显式 TLS 的诸多缺点已经使得它越来越不受欢迎了,我们并不推荐使用它。
第十二章小结
系统调用介绍
系统调用是应用程序(运行库也是应用程序的一部分)与操作系统内核之间的接口,它决定了应用程序是如何与内核打交道的。所以操作系统的系统调用往往从一开始定义后就基本不做改变,而仅仅是增加新的系统调用接口,以保持向后兼容。
系统调用的弊端
大部分操作系统的系统调用都有的两个特点:
- 使用不便。操作系统提供的系统调用接口往往过于原始,如果没有进行很好的包装,使用起来不方便。
- 各个操作系统之间的系统调用不兼容。首先 Windows 系统和 Linux 系统之间的系统调用就基本上完全不同,虽然它们的内容很多都一样,但是定义和实现大不一样。即使是同系列的操作系统的系统调用都不一样,比如 Linux 和 UNIX 就不相同。
运行库作为系统调用与程序之间的一个抽象层可以保持着这样的特点:
- 使用简便。因为运行库本身就是语言级别的,它一般都设计相对比较友好。
- 形式统一。运行库有它的标准,叫做标准库,凡是所有遵循这个标准的运行库理论上都是相互兼容的,不会随着操作系统或编译器的变化而变化。
运行库时库将不同的操作系统的系统调用包装为统一固定的接口,使得同样的代码,在不同的操作系统下都可以直接编译,并产生一致的效果。这就是源代码级上的可移植性。
系统调用原理
操作系统一般是通过中断来从用户态切换到内核态。通常意义上,中断有两种类型,一种称为硬件中断,这种中断来自于硬件的异常或者其他事件的发生;另一种称为软件中断,软件中断通常是一条指令,带有一个参数记录中断号,使用这条指令用户可以手动触发某个中断并执行其中断处理程序。
由于中断号是很有限的,操作系统不会舍得用一个中断号来对应一个系统调用,而更倾向于用一个或少数几个中断号来对应所有的系统调用。例如 Linux x86 使用 int 0x80 来触发所有的系统调用。
在实际执行中断向量表中的第 0x80 号元素所对应的函数之前,CPU 首先还要进行栈的切换。在 Linux 中,用户态和内核态使用的是不同的栈,两者各自负责各自的函数调用,互不干扰。
附录
字节序(Byte Order)
计算机中通常采用的字节存储机制主要有两种:大端(Big-endian)和小端(Little-endian)
小端(大部分 PC 使用的字节序):低位字节存放在低地址
大端(网络通信使用的字节序):高位字节存放在低地址
#include <stdio.h>
#include <arpa/inet.h>
union {
int digit;
char arr[4];
} st;
int main() {
st.digit = 0x12345678;
puts("Little Endian");
for (int i = 0; i < 4; i++) {
printf("address:%p %02x \n", &st.arr[i], st.arr[i]);
}
st.digit = htonl(st.digit);
puts("Big Endian");
for (int i = 0; i < 4; i++) {
printf("address:%p %02x \n", &st.arr[i], st.arr[i]);
}
return 0;
}