第一章:
WINDOWS有标准的进程线程概念,而LINUX刚开始不明确,只有任务这一概念,任务是只有一个线程的进程,多个任务共享内存,则可以视为线程。fork函数并不立即复制原任务内存,也是写时复制,就是有写操作才会真的复制。
多线程需要同步,就是一个线程访问数据未结束的时候其他数据不能对同一数据进行访问。方法就是用锁,具体有:二元信号量,互斥量,临界区,读写锁,条件变量。
CPU优化会打乱指令的顺序,在多线程下会产生问题,许多体系结构的CPU提供barrier指令,组织barrier后面的指令和之前的指令交换。
用户态的多线程对应的内核态的线程数会更少,可能用户态3个线程内核态只有一个。一般使用API或系统调用的线程都为一对一的线程。多对一线程有高效的上下文切换,但是一个线程阻塞时其他都阻塞了。多对多模型较好。
第二章:
程序预编译处理#define、头文件等,递归处理。编译器编程成汇编代码。汇编器将汇编转成机器代码,得到目标文件。最后链接器链接得到可执行文件。因为没链接前,可能会用到别的模块的变量、函数,但又不知道运行时的绝对地址,其汇编、机器码是随便定一个地址,在最终链接时链接器会去改成真实地址,这个过程也叫做重定位。
中间有讲编译器干的事:词法分析、语法分析、语义分析、中间代码生成、目标代码生成与优化等。
第三章:
可执行文件格式,WINDOWS下是PE,LINUX下是ELF。ELF格式有4类:1.可重定位文件(LINUX的.o,WINDOWS的.obj);2.可执行文件(LINUX无固定后缀,WINDOWS的.exe);3.共享目标文件(LINUX的.so,WINDOWS的DLL);4.核心转储文件,当进程意外终止时,系统可以将该进程的地址空间的内容及终止时的一些其他信息转储到其中。
ELF文件中机器指令放在代码段,全局变量静态变量放在数据段,未定义的全局、静态变量存在bss段。分段的好处有:1.局部原理性,提高CPU缓存命中率。2.可以设置代码段为只读,防止代码段被改。3.可以共享指令这种只读区域,节省内存空间。
ELF文件中有代码段,数据段,bss段(有时只预留符号),只读数据段(存放字符串常量、只读变量,如加const修饰的变量)。有时编译器会把字符串常量放到数据段。静态变量赋值为0的话,编译器优化会将其视为未初始化,因为未初始化的静态变量值就是0,然后放到bss段中。
ELF文件还有很多段,甚至可以有几个相同段名的段。ELF头部讲了一堆内容,跳过了...ELF文件遵守相同的标准,但不同机器的ELF文件还是不同,ELF文件里有表示平台属性的成员。绝对段的属性的是段的类型和标志位。重定位表存了重定位的信息。
后面一堆ELF符号之类的,没细看跳过...
编译器会对变量、函数名进行处理修饰,WINDOWS下的C编译器对所有变量名前加下划线_。C++编译器也是修饰处理相同名字的函数、变量,但是跟C不一样,所以C++编译器编译C代码时要加extern "C",具体还有一些细节,这些技巧在系统头文件中几乎都会被用到。提到了强符号弱符号及其规则。需要调试时,编译器编译时在目标文件中加入了很多debug的信息,这往往比程序的代码和数据本身大好几倍。所以发布时应该去掉调试信息。
第四章:
可执行目标文件的空间地址的概念有两个含义,一是文件内的空间位置,一是加载时的虚拟内存空间地址。bss段只有在加载时才会分配具体的空间,在目标文件内只是记录大小。静态链接分两步:1.扫描所有目标文件,合并所有相同段,收集所有符号建立全局符号表。2.符号解析与重定位,调整地址。每个需要重定位的段都有对应的重定位表,比如代码段.text对应有个.rel.text段。
后面讲重定位时寻址方式,什么绝对相对近址远址。然后讲COMMON块,说了一堆强符号弱符号的。好像未定义的全局变量放在COMMON块而不是BSS,跟未定义的静态变量不一样。哦,是编译后放在COMMON,链接后放在BSS,因为是弱符号,可能别的目标文件中的同名变量的字节更多,只有链接后才能确定字节大小。关于这种未定义全局变量存在的现象,可能是早期程序员经常忘记在前面加extern...
C++编译链接时会对模板等产生的重复代码进行消除。编译器提供了函数级别链接选项,当链接器用到某个目标文件的某个函数时,就把它合并到输出文件中,其他没用的函数则抛弃。这个优化减慢编译链接过程,因为要计算各个函数之间的依赖关系。有些操作必须在main函数之前被执行,比如C++全局对象的析构、构造函数,因此ELF定义了两种特殊段。.init段存进程的初始化代码指令,.fini存main函数退出之后执行的指令。
ABI是比API标准更底层的一个标准。MSVC和GCC编译出来的目标文件是不一样的,一个是PE格式一个是ELF格式,一个链接器要链接他们就要知道他们的ABI。文中列了C和C++的ABI细节,这些细节面试考的几率很大,比如继承类的内存分布、标准库的细节,RTTI如何实现,异常的产生捕获机制,如何调用虚函数,vtable的内容和分布形式......ABI不兼容的问题长期存在,特别是C++ ABI。
链接时链接器会在静态库文件中找到相关的目标文件进行链接。加-static静态链接,否则会默认的进行动态链接。文中搞了个汇编版的hello world,直接用汇编执行系统调用,貌似这样就可以设置入口函数,不用main,然后链接时用-e参数设置入口函数。链接器根据链接脚本进行链接,系统有默认的文件,也可以自己写链接脚本,主要就是控制各个段的输出。BFD库是用于处理不同平台的不同目标文件格式的,将不同格式的目标文件转成同一种标准。
第五章
WINDOWS下的二进制文件格式是PE,源自COFF,和EFL文件一样,也是由文件头及后面的若干个段再加上文件末尾的符号表、调试信息等内容。其中有个.drectve段,提供给连接器的参数,比如告诉编译器要链接某个静态库。.debug开头的都是调试信息段。符号表存储符号大小、所在位置、类型(变量或函数)、可见范围(局部、全局)等信息。
PE兼容DOS的可执行文件格式MZ,但是DOS执行PE文件时,按照其中的MZ格式部分进入程序入口,其实并不是PE真正的入口,然后执行一段DOS下可运行的小代码,即输出'This program cannot be run in DOS'后退出。WINDOWS装载PE时,往往需要很快找到装载所需的数据结构,如导入表、导出表、资源、重定位表等。这些常用的数据的位置、长度保存在叫数据目录的结构中。
第六章
32位系统虚拟地址空间只有4G,LINUX分配3G给进程,高位的1G留给操作系统,WINDOWS默认2G给进程。程序使用超过4G的物理内存,可以用PAE、AWE等方法。由于磁盘比内存便宜,而且容量大,并且由于局部性原理,往往会将内存中不常用的部分放到磁盘里,要用时再从磁盘调过来。有两种方法,这个清华大学OS公开课也有讲过,一个是古老的程序员自己定的,设计出模块树结构,将不会同时运行的模块分到同一块内存中,一个运行时另一个放磁盘中。这个怎么设计最优的方案,貌似可以出个算法题,也很可能是NP难问题,以后有兴趣可以细想下。还有就是虚拟存储机制的页映射,也就是现在普遍的做法。页替换算法有好多,OS公开课有说到,其中细节还是挺多的,比如什么算法会有bleady现象什么的。
进程执行时,操作系统创建一个独立的虚拟地址空间,读取可执行文件头建立虚拟地址空间与可执行文件的映射关系,将CPU的指令寄存器设置成可执行文件的入口地址,启动执行。可执行文件在装载时实际上是被映射的虚拟空间,所以又被叫做映像文件。启动运行这步在操作系统层面上比较复杂,涉及内核堆栈和用户堆栈的切换、CPU运行权限的切换。
操作系统通过给进程空间划分VMA来管理进程的虚拟空间,基本原则是将相同权限、映像文件的映射成一个VMA,一个进程基本上可以分为代码VMA、数据VMA、堆VMA、栈VMA。这应该就是进程中所谓的代码段什么的,估计是想跟ELF中的XX段区分吧。可以写个程序测试下malloc最大能分配到多少空间,LINUX下大概是2.9GB,WINDOWS大概是1.5GB,每次测试结果可能不同,因为操作系统使用了随机地址空间分布的技术,主要是出于安全考虑,怕攻击者猜到程序地址。
将不同段映射到虚拟内存空间时,最方便的是不同段用不同的页,但是这样会造成浪费,比如所有段都很小,一个页就能存下,却要分成多个页。对于这种情况,将虚拟内存空间映射到物理内存时会进行调整。文中说不同段之间的页在虚拟空间里有两份但在实际物理内存里只有一份,应该是映射过去时调整的吧。
LINUX下除了ELF还有JAVA、shell、脚本文件等可执行文件格式,因此加载程序时要先读入判断文件格式的字节。文中讲了ELF、PE文件的加载过程,没细看。
第七章
动态链接就是运行时需要用到时再加载某个模块,如果某个共享模块已经在内存中了,则不用加载了,这个过程应该包括重定位。这可以节省内存,也有利于软件版本更新,同时还加强了程序兼容性,比如printf()在不同系统下用到的动态链接库不一样。
装载时重定位是解决动态模块中有绝对地址引用的办法之一,但是指令(代码?)不能被多个进程共享,数据部分也不行(不同进程里变量值不同?),这跟静态链接差不多了,速度较快,跟静态链接相比可能就是省磁盘空间吧。还有个方法就是把指令中的某些部分提取出来复制多个副本...跟地址无关(代码段)的就只有一个副本,地址无关代码技术(PIC)大概就是这意思,这比装载时重定位省了内存,因为可以共享代码段,但是运行速度较慢。模块间数据访问、调用、跳转时要用到全局偏移表(GOT)。还有延迟绑定技术PLT,没细看。
装载动态链接的可执行文件时和静态链接的前面步骤类似,但是在静态链接的文件控制权给可执行文件时,动态链接的是操作系统启动动态链接器,进行初始化、链接操作,然后才把控制权转交给可执行文件入口地址。ELF文件中有几个用于动态链接的段,比如记录动态链接器路径的。还讲了动态符号表、动态链接重定位。进程初始化时,堆栈里保存了关于进程执行环境和命令行参数等信息,也还保存了动态链接器所需的一些辅助信息数组。
动态链接先启动动态链接器,然后装载所有需要的共享对象,最后重定位和初始化。当装载的不同模块中有相同符号(函数、变量名)时,先装载的符号会覆盖后装载的。动态链接器本身就是个程序,其本身是静态链接的,
前面不懂哪里漏了,LINUX下程序入口是__start什么的,还有调用main()函数什么的。
第八章
保持共享库ABI(二进制接口)兼容性不容易,因为编译器、操作系统、硬件平台不同都会造成影响。特别是C++,文中列了好多规范,并说遵守这些规范还不能保证兼容。LINUX采用SO-NAME规范命名共享库文件,主版本号一样的共享库文件兼容。编译链接的时候只要在命令行输入文件名,不用.so.6.2.1这些后缀可以,编译器会根据当前环境找到最新的库,默认情况是找动态库。
共享库主版本号机制可能会发生问题,因为只向前兼容,若某个程序依赖新版本的共享库却用了主版本号一样但次版本号更旧的共享库,则可能会发生一些符号不存在的情况而出错。为此还有个符号版本机制,没细看。
动态链接的模块所依赖的模块路径存在某个段中,该路径一般是相对路径,动态链接器会在/lib、/usr/lib和配置文件制定的目录中查找,还有根据环境变量找。
第九章
WINDOWS允许将DLL段设为共享,可用于进程通信。和ELF不同,DLL需要显示提示编译器导出哪些符号。
讲了一堆导入导出的...没细看。
DLL貌似没用指令地址无关技术,如果被多个进程共享就要在内存复制多份,但是会比ELF快,因为少了GOT。
WINDOWS系统有很多系统的DLL,WINDOWS在进程空间中专门分了一块区域分配给这些DLL,所以这些DLL装载时不需要再重定位。
新的DLL覆盖旧DLL时容易发生错误,特别是C++。比如一个类新加了变量,占用的字节增多了,但是原来该类的某个对象占用较少的字节,后面跟着的不是该对象的字节,当前类却可以对那些字节进行操作,会造成错误。(文中有个例子,应该是这意思。)
文中推荐《COM本质论》,列出了很多编写C++的指导意见,比如不要使用STL,不要使用异常等。
WINDOWS API 是以DLL的形式提供的,而不是像LINUX那样是系统调用。
第十章
介绍了函数调用的过程,这个CSAPP有讲过。函数返回值用eax寄存器存,如果5到8个字节,就再搭配一个寄存器,再长的话,比如返回结构体或对象,会使用一个临时的栈上内存区域作为中转,然后eax寄存器存指向那段数据的指针,然后函数返回后进行拷贝。返回对象会有非常多的开销,所以C++中要尽量避免。
linux下申请内存的系统调用有两个,一个是brk(),一个是mmap()。brk实际上是加大数据段的范围,多增加的部分拿来用。mmap就是申请一段连续内存,但是申请的起始地址和大小都要是页大小的倍数。malloc申请的最大内存空间不能超过空闲物理内存+空间交换空间的和,假设足够的话,LINUX下动态链接的话,大概为2.9GB,因为用户空间3GB,高位有栈、共享库占了一些空间,共享库放在栈空间下,还有一些零碎的内存段。因为向系统申请内存每次都要页大小的倍数,而且较慢,所以往往都是预先申请一大段,然后由堆管理器管理分配。
文中简要介绍了几种管理内存方式,就是申请一大段后怎么分配管理。
第十一章
程序刚开始执行的不是main函数,而是一个入口函数,对运行库和程序运行环境进行初始化,包括堆、I/O、线程、全局变量构造等等,然后调用main函数,main函数返回后回到入口函数进行清理工作,包括全局变量析构、堆销毁、关闭I/O等,然后进行系统调用结束进程。
程序运行依赖运行库CRT,一个程序不同obj或DLL文件使用不同DLL的话,静态链接问题不大,但是动态链接比较复杂。两个DLL A和B使用不同CRT的话,不能在A中申请内存然后在B中释放,因为分属于不同CRT,即拥有不同的堆。在A中打开的文件不能在B中使用,因为依赖CRT的文件操作部分。
最初的CRT设计没考虑多线程环境,因此C/C++运行库在多线程环境下吃了不少苦头,很多函数都是线程不安全的,比如malloc、printf。在多线程版本的运行库中,线程不安全的函数内部会自动进行加锁,如malloc、printf...
线性局部存储TLS可以定义TLS的全局变量,每个线程都会有该变量的副本。
CRT中占最大比重的不是初始化、多线程、全局构造析构等,而是I/O部分。
第十二章
让程序等待一段时间,要用系统调用控制定时器,用for(i=0;i<100000;i++);这样的话不同CPU运行速度不一样。用现代机器玩某些老游戏会不会觉得游戏进行得比较快?
不同系统的系统调用不同,可以多加层来提高兼容性,运行库就是在更高层的。但是为了保证互相通用,运行库只能取各平台之间功能的交集。比如WINDOWS支持图形和用户交互系统,而LINUX不是原生支持的(通过XWINDOWS),那么CRT就把这部分功能省了。系统调用是通过中断来执行的,方法有轮询,系统时不时查看,还有发送信号给系统。产生中断后,系统调用相应的中断处理程序。中断处理程序执行完后系统继续执行之前的代码。系统调用要切换堆栈,由用户态的切换到内核态,其实就是ESP的值所在的栈空间。中断发生时,CPU切入内核栈,找到当前进程的内核栈(每个进程都有自己的),在内核栈中压入之前的用户栈信息。系统调用的参数依次放到6个寄存器中。好像CSAPP里有讲参数太多时怎么放。
WINDOWS在系统调用上增加API层很大的原因也是为了兼容性,同时以DLL导出函数的形式。微软在WINDOWS2000前维护两条产品线:WINDOWS 9X和WINDOWS NT,使用的是不同系统调用,但是API相同。后来合并成WINDOWS2000因为API层也比较顺利。不管内核如何改变接口,只要维持API层面接口不变,理论上所有的应用程序都不用重新编译就可以正常运行(编译成汇编后调用API?)。这也是WINDOWSAPI存在的主要原因。
第十三章
介绍实现一个mini CRT,没看。