程序在运行的时候先通过分段(segmentation)的方式将虚拟地址空间与真实的物理内存地址空间进行一一的映射,但是这种方式每次换入换出的是整个程序,导致IO变大,更具局部性原理,可以采用分页(Paging)来解决. 分页就是将地址空间人为的等分成固定大小的页,每页的大小(4KB或者4MB)由操作系统确定。 几乎所有操作系统都采用4KB的分页,那么对于一个32位的程序来说,最多只能有4GB/4KB = 1048576
页, 物理空间也是同样的分法。但是当真实物理空间不够虚拟空间的页数的时候,真正有效的空间以真实内存空间为准。页的映射是由MMU部件来完成的。MMU一般集成在CPU内部。
外设使用总线地址,CPU使用物理地址。x86平台上,物理地址和总线地址相同。
线程可以分为IO密集型线程和CPU密集型线程
,当CPU密集型线程的优先级较高的时候,可能会导致低优先级的线程被饿死,而IO密集型线程获得较高优先级的时候,由于大部分时间是处于等待状态,所以不叫不容易造成其他线程的饿死。 如果一个线程长时间得不到执行,调度系统会逐步提升它的优先级让它执行。
线程还可以分为抢占线程和不抢占线程,抢占线程是那些时间片用完之后会被剥夺执行权的线程,不可抢占线程是那些除非执行完毕否则不能剥夺执行权的线程。时至今日,非抢占线程已经十分罕见。
windows对于进程和线程的实现如同教科书一般的标准,但是在linux中其实并没有严格的线程和进程的概念,Linux中所有的执行实体都被成为任务Task,每一个任务概念上类似于一个单线程的进程,但是它的不同任务之间可以选择共享内存空间,因此在实际意义上共享了同一内存空间的多个任务构成了一个进程。
为了保证多线程环境下操作的原子性,有以下几种办法:
6.1 锁
6.2 二元信号量和多元信号量
6.3 互斥量,和上面两个的区别是哪个线程建立的互斥量就必须由哪个线程去释放
6.4 临界区,和上面几种的区别是,哪个进程创建临界区的锁,就由哪个进程获取,对其他进程不可见。
6.5 读写锁,有两种获取方式独占式和共享式
,区别如下
读写锁状态 | 以共享方式获取 | 以独占方式获取 |
---|---|---|
自由 | 成功 | 成功 |
共享 | 成功 | 等待 |
独占 | 等待 | 等待 |
可重入线程是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。
三种线程模型
8.1 一对一模型
8.2 多对一模型
8.3 多对多模型
程序编译运行的流程是 预编译-编译-汇编-链接, 命令分别是:
gcc -E xx.c -o xx.i
gcc -S xx.i -o xx.s 或者(cc1 xx.i)
gcc -c xx.s -o xx.o 或者(as xx.s -s xx.o)
以及 ld xxx.o xxxx.o xxxx.o等
目标文件即.obj文件或者.o文件,本质上是函数的集合,用于重定位, 他们的内部函数和变量的存储方式和真正的可执行文件一样只是在结构上稍有不同。
PC流行的PE文件格式和ELF文件格式都是COEF格式的变种。
bss段只是为全局变量和局部静态变量预留位置,在elf文件中不占空间。
x86的cpu中字节序采用小端模式存储(所以elf文件中变量的存储采用小端), arm架构的cpu中采用大端,网络字节序(TCP/IP)采用大端传输。
elf文件主要包含,代码段(.text)和数据段(.data, .rodata, .bss), 未初始化的全局变量和和未初始化的局部静态变量被存放在.bss段(有时候未初始化的全局变量也会存放在符号表中)。 字符串一般在.rodata段,也有的在.data段。值得一提的是赋值为0的局部静态变量也会被认为是未赋值从而放置在bss 段而不是data段。 attribute 命令可以在代码中指定变量或者函数存放在elf文件的那一段
reaelf -h xx.o可以输出elf文件的文件头信息。 ELF文件最开始的16个字节代表了ELF文件的平台属性,其中前四个字节是ELF文件的魔数,不同平台的魔术不同(ELF的魔数是0x7f, 0x45, 0x4c, 0x46 即 DEL控制符,‘E’,‘L’,‘F’ PE/COEF的魔数是0x01, 0x07, 即’M’,‘Z’), 操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。
第5个字节是用来标识文件类型,0x01是32位的,0x02是64位的。第6个字节表示字节序是大段或者小端,第七个字节表示ELF的主版本,一般是1。后面9个字节没有指定,表示可扩展。
ELF文件中段表的位置由e_shoff成员决定,即(Start of section headers的值决定段表的起始位置)。 readelf -S xx.o 可以显示真正的段表结构。
elf文件中段表是其中的一个段,段表里面存储了其他各个段的起始地址和大小还有其他一些信息。
符号表里面存储了全局变量,全局函数和行号和用于调式和核心转储的局部符号,行号等信息,链接器在链接的时候只关注全局函数和变量。 可重定位文件中包含的局部信息对其他重定位文件来说都是不可见的,只有全局函数和变可见。可以用nm来查看elf文件的符号结果。 符号表也是elf文件中的一个段,段名一般叫做.symtab
readelf -s xx.o可以打印输出elf包含的符号表的信息。 分别有符号的类型,值(函数或者变量的地址),大小,绑定信息(局部,全局,弱引用), Ndx表示符号所在的段的下标,该下标可以通过readelf -a xx.o 看到。 值得注意的是符号表中第一个符号,即下标为0的符号永远是一个未定义的符号。
对于STT_SECTION类型的符号,它们的符号名没有显示,其Ndx所对应的段名也就是这里的符号名, 因为他们是段名符号。可以通过 objdump -t 看到这种段名符号
特殊符号是由ld链接器定义的,程序中只需要申明就可以使用,程序在最终链接的时候会自动转化为正确的值,例如__executeable_start, __etext或_etext或etext, _edata或者edata, _end或者end等等。
为了防止函数和全局变量在各文件之间的命名冲突。但是随着操作系统和编译器的分化,GCC已经不用在符号前面加_但是windows平台下的编译器还保持着前面加_这样的传统。此外GCC在windows平台下下编译器例如cywin和mingw还保持着这样的传统。GCC本身可以通过编译器选项-fleading-underscore
或者-fno-leading-underscore
来打开或关闭是否在C语言符号前加下划线。
函数签名是C++引入的区别不同类,命名空间等不同作用域名中相同名称的成员的机制 binutils提供的 c++filt
命令可以解析一个函数修饰后名称对应的真正的函数签名。一般规则是对于在命名空间或者类的中的变量和函数其前面一般是_ZN
开头,以E
(+)i/f/d结尾。最后值得说明的是不同平台的编译器的对同一函数的函数签名可能是不同的。 VC++的函数签名方法没用向外公开,但是其UndecorateSymbolName()的api可以将修饰后的名称转换成函数签名。
由于不同编译器采用不同的名字修饰方法,所以导致了不同编译器产生的目标文件无法正常的相互链接,这也是导致不同编译器之间不能互操作的主要原因之一。
目标文件即OBJ文件是跨平台的
extern "C"
{}语句会导致受作用的变量和函数名在修饰之后采用的是C语言的格式而不是C++. 对于同一个变量或者函数,C++和C语言的修饰不一样,为了让C++能正确引用并使用C语言的符号,通常在声明这个函数的时候会先判断当前的编译单元是C还是C++即使用下面的语句
#ifdef __cplusplus
extern "C"{
#endif
void *memset {void *, int , size_t};
#ifdef __cplusplus
}
#endif
这种技巧几乎出现在任何系统头文件中(源文件已经判断了C或者cpp所以不需要这样写)
14.1 不允许强符号重复定义
14.2 如果一个符号在某个目标文件中是强符号,其他目标文件中是弱符号,则选择强符号
14.3 如果一个符号在多个目标文件中都是若符号,则选择占用空间最大的那个。
弱符号和链接器的COMMON块概念的联系很紧密
15 强引用和弱引用: 如果引用的一个库中的符号没用被定义,则链接时候会报为定义错误,这是强引用
, 这种情况不报错的就属于弱引用
。 弱引用和弱符号对库十分有用,因为这样用户可以自定义库中函数,也可以在去掉了某些模块之后程序依然可以正常链接,着使得程序更容易裁剪和组合。
16 使用gcc/g++ -g
参数可以在目标文件中保存调试信息,ELF文件的标准调试信息格式是DWARF
, 目前是 DWARF 3
, 微软的调试信息标准格式叫CodeView
. 调试信息通常数倍于ELF文件本身的内容, 发布时候必须去掉。 在Linux下,可以使用strip去掉ELF文件中的调试信息
空间地址分配有按序叠加和相似段合并两种方法,一般都使用相似段合并的方法。最后的可执行文件当中包含了可重定位的.o文件里面的所有指令。
Linux下,ELF可执行文件默认地址从0x08048000开始分配。生成的可执行elf文件中的.text段是各个.o文件的.text段大小之和
elf文件中需要重定位的段都有一个相对于重定位段(表), 利用objdump -r xx.o
可以查看目标文件的重定位表。
对于弱符号,即未初始化的全局变量,由于在编译成目标文件之后,编译器不能确定其大小,所以将其放在COMMON块中,但是当链接器分析完各个目标文件之后就可以确定其大小,从而将其放在BSS段,所以总体来看,未初始化的全局变量还是放在BSS段的。可以在GCC编译的时候使用fno-common
指定未赋值的全局变量不在COMMON块中, 也可可以在代码中写__attribbute__ ((nocommon))
将其当成强符号处理。
重复代码消除,例如C++的模板技术使得模板可以在多个源文件中别实例化但是编译器并不能知道它在多处被同一种数据类型实例化,所以现在主流编译器例如GNU 的做法是在每一个目标文件中对于一个模板的同一种实例化使用一种相同的名称,这样在链接阶段,链接器会检查这些重复的段并只保留一份。GCC把这种段叫"Link Once"
命名为".gnu.linkonce.name"
. VC++叫做COMDAT
,
这种做法的一个潜在的问题是,当编译器对不同的编译单元使用不同的编译优化选项的时候,可能会使得相同名称的段有不同的内容,编译器的做法是随意选择一个作为链接的输入且提供警告信息。
函数级别链接: 通常的链接过程都是文件或者编译单元级别的链接,但是当只需要使用某个目标为见中的一个函数或变量的时候,就需要全部包含该文件,导致体积很大,编译器为此专门提供了函数级别的链接,与重复代码消除和相似,编译器将所有函数都想模板函数一样单独保存到一个段中,需要的时候再将其包含到输出文件,其他的则直接抛弃,这虽然较小的最终文件的体积但是由于段的数目增减,减慢了编译和链接的过程。GCC使用-fdata-sections
和-ffunction-sections
可以将变量或者函数分别保存到独立的段中。
全局构造与析构: 全局对象的构造在main函数之前执行,全局对象的析构在main函数之后哦执行,Linux下的入口函数是_start,用于在main执行前进行初始化。为此,ELF文件提供了两个特殊的段.init
和.fini
。其中.init
中保存的指令是main执行之前Glibc的初始化部分, .fini
中是main函数正常退出之后Glibc会安排执行的代码。
为了使得不同平台的目标文件兼容,即可以相互链接,这些文件必须有一直的ABI(Application Binary Interface)
,即二进制兼容,ABI内容包括符号修饰标准,变量内存布局,函数调用方式等等。厂商不希望用户看见自己的源代码所以会提供二进制版本,所以二进制兼容在大型项目中变得很重要。目前编译器的两大阵营 VISUAL C++和GNU 的GCC各执己见互不兼容。ABI兼容问题还有待解决
一个静态库文件(.a)是由许多.o文件合并而来的,linux下使用ar -t xx.a
可以查看.a文件中包含的.o文件。在windows平台下可以使用lib /LIST xx.lib
查看
可以使用objdump -t xx.a | grep xxx
查找特定的目标文件。使用gcc -static --verbose -fno-builtin hello.c
可以将编译链接过程的中间步骤打印出来, 即使我们写的代码非常简单,这也是一个非常长的依赖关系。这个过程会链接部分会显示collect2
这是ld的一个包装,会调用ld
BFD(Binary File Descriptor library)
是基于所有硬件平台(不同的处理器和目标文件格式)的一个抽象层,基于BFD可以不用关心具体的硬件格式,而进行统一操作,因为BFD中已经包含了这些CPU和可执行文件的格式信息,ubuntu下BFD软件包的名字叫binutils-dev
.
windows上的目标文件为COEF格式,而可执行文件是PE格式,PE又是COEF格式衍生出来的, 所以将这类文件统称为PE/COEF
格式
64位的Windows中对PE文件格式做了一点小小的修改,叫做PE32+格式,只是将32位的字段换成了64位而已。
VC++有一些对C/C++的专用拓展,使用cl编译的时候,可以使用 /Za来禁用这些拓展,也可以在程序中使用宏__STDC__
来查看VC++是否禁用了这些语法拓展
和GNU对象,Windows上的cl就是gcc, link就是ld, dumpbin就是objdump,
PE中有两个ELF文件中不存在的段,分别是.drectve
和 .debug$S
. .drectve
段是编译器传递给链接器的指令,其中的flags表示了他的特点。 .debug$S
表示的是符号相关的调试信息。其中有原始文件信息和编译器信息
可以在cl命令中通过/ZI来关掉默认C库的链接指令
PE文件为了兼容DOS的MZ文件结构在PE文件中加入了DOS的相关设置,所以将windows下的可执行文件在DOS上运行的时候会输出"This program cannot be run in DOS"
程序和进程的区别,程序就是菜谱,是一个静态概念, 进程就是菜,是一个动态概念。
程序的寻址空间由CPU的位数决定,所以32位下的程序寻址空间是 2 32 b i t 2^{32} bit 232bit即4GB, 64位下是17179869184 GB
, 32位下C语言指针的长度是4字节,64位系统下长度是8字节.
对于一个32位的程序,寻址空间虽然是4GB,但是程序并不能全部使用,例如在Linux下,1GB是留给操作系统的,剩下的3GB给进程,且这3GB内存程序也不能完全使用,还有一部分给其他用途; 在Windows上,默认情况下2GB留给系统,2GB留给进程, 但是可以通过修改winows根目录下Boot.ini文件调整内存分配和linux下一样。
1995年Pentium Pro CPU使用PAE(Physical Address Extension),将地址线扩充到了36位,所以理论上计算机可以寻址的空间变成了64G, 但是进程的寻址空间仍然是4G(32位系统下指针是4个字节)
为了使应用程序能使用超过32位的内存,Windows上可以使用AWE(Address Window Extension)的方式, 在Linux上可以使用mmap(),但是这只是一种补救32地址线的方法, 在原来16位的DOS上也曾有过这样的做法。
readelf -S xx
可以输出可执行文件中的section,而readelf -l xx
可以输出可执行文件中的Segment(即程序头表),即怎样被装入进程空间, Segment中只有类型是LOAD的部分会被映射,这部分在装载之后又会被映射到两段VMA,分别是可读可写的部分和可读可执行的部分,args和argv[]
两个参数就是从这里传递进来的,分别表示命令参数的数量和指向命令行传入参数的指针数组。fork() -> execve() -> sys_execve()【系统调用,用于参数检查和复制】 -> do_execve()【读取文件头部的128字节,决定执行程序,如果第一行是#!则会解析这之后的字符串,以确定解释器的路径,例如#!/usr/bin/python】->load_elf_binary()->do_execve() -> sys_execve()【从内核态返回用户态】
gcc -fPIC -shared -o Lib.so Lib.c
, 共享目标文件so的装载地址是从0开始的,所以会在运行时确定地址。_declspec(dllimport)
用于指定来自外部或者内部。readelf -d xx. so | grep TEXTREL
如果没有任何输出则是PIC的,因为TEXTREL表示代码段重定位表地址,PIC不存在这个地址。lib/ld.linux.so.2
, 其他*nix系统可能会有差异, 这个路径是一个软链接,真正的文件是Glibc库的一部分,升级Glibc库也会升级动态链接器,但是软连接总是指向动态链接器文件,不需要手动修改。.hash
可以用命令readelf -sD xx.so
查看动态符号表和它的哈希表。第七章7.6节以后略去不写