第一部分 简介
计算机硬件组成
其中最关键的三个部件为:cpu(中央处理器) 内存 硬盘IO控制芯片
总线的作用
协调高速和低速缓存,因为IO设备(键盘、鼠标等)比cpu、内存的速度要慢很多,因此,为了协调IO设备与总线之间的速度,保证cpu和 IO设备之间的正常高效通信。
单核、多核处理器与多线程
单核即单个cpu执行(通过不断的上下文切换实现并发多线程执行),多核即多个cpu同时执行(实现真正的并发多线程执行)
线程的访问权限:一个线程由线程ID、当前指令指针、寄存器集合、栈组成,是自己独有的,各个线程之间可以共享程序内存空间(包括代码段、数据段、堆等)
线程调度:由于在单核处理器下的多线程实际上是通过线程调度实现的,所谓线程调度即不断在单核处理器上切换不同线程的行为
线程调度方式:轮转法(各个 线程轮流执行一小段时间,带来的问题是有些UI线程会被卡住)、优先级调度(决定线程按照什么顺序轮流执行。存在的问题是对某些优先级低的线程会出现饿死的现象,在它执行之前,总有高优先级的线程需要执行)
中间层作用,什么是硬件接口
计算机领域的任何问题都可以通过增加一个间接的中间层来解决
硬件接口可以理解为驱动程序操作硬件的接口,硬件的生产厂商负责提供硬件规格,操作系统和驱动程序的开发者通过阅读硬件规格文档规定的各种硬件接口标准编写操作系统和驱动程序。
当成熟的操作系统出现后,操作系统开发者不可能为每个硬件开发一个驱动程序,这些驱动程序的开发工作通常由硬件生产厂商完成,同时操作系统开发者为硬件生产厂商提供一系列接口和框架,凡事按照这个接口和框架开发的驱动程序都可以在该操作系统上使用。
所以,驱动程序就类似于操作系统和硬件之间的中间层。
cpu、内存、隔离、线程相关理论
我们知道当程序读写磁盘时,cpu会空闲下来,因此如果能充分利用起空闲下来的cpu非常重要,为了充分提高cpu的利用率,对于cpu的合理使用经历了如下几个阶段:
1.通过监控程序把其它正在等待cpu资源的程序启动,能够把cpu资源充分利用起来,这种监控程序被称为多道程序(它的缺点在于程序之间的调度策略太粗躁,程序之间不分轻重缓急),2.进一步改进又出现了分时系统(每个程序运行一段时间后都主动让出cpu给其它程序,但是,当一个程序进行一个很耗时的计算一直霸占着cpu不放的时候,其它程序只能等着,整个系统像死机一样,如一个成功程序进入一个while(1)的死循环,会导致整个系统死机)、3.多任务系统(cpu由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会获取cpu,这种分配方式也被称为抢占式)。
早期的计算机,程序是直接运行在物理内存上的,存在的一个问题即如何将计算机上有限的物理内存分配给多个程序使用,同时为了解决直接访问物理内存造成的地址空间不隔离(所有程序的进程都直接访问物理地址)、内存使用效率低(程序执行时会将整个程序装入内存执行,当内存空间不够时,会出现大量的换入换出)、程序运行地址不确定问题(程序每次运行时,分配的内存空闲区域都是不确定的,而编写程序时,目的地址很多是固定的, 这些地址都涉及到重定位问题)。对于以上问题的解决办法即增加中间层,使用一种间接的虚拟地址访问方法,只要能够妥善的控制虚拟地址到物理地址的映射过程,就可以保证任意一个程序(进程)能够访问的物理内存区域跟另一个程序(进程)相互不重叠,达到地址空间隔离的效果。因为一旦一个程序进程分配好了虚拟地址范围后,当他访问这个地址范围之外的地址都是无效的,只有访问自己的地址空间才是有效的。
基于前面增加中间层的思想,达到提高内存使用效率以及达到程序进程隔离的目的,经历了一下几个阶段。1.最初人们考虑使用分段的方法(即把一段与程序所需要的内存空间大小的整个虚拟空间完全映射到某个物理地址空间,但是分段对内存区域的映射还是按照程序(进程)为单位,如果内存不足,换入换出到磁盘的是整个程序,这样会造成大量的磁盘访问操作,严重影响速度)。2.进一步改进使用分页的方式(根据程序的局部性原理,当程序运行时,在某个时间段内,只是频繁的用到了一小部分数据,因此,人们想到了更小粒度的内存分割和映射的方法,即把虚拟地址空间人为的分成固定大小的页,通常是4kb为一页,页的大小由硬件来决定,物理空间也是同样的方法,同时操作系统还可以设置页的访问权限,对页进行保护,几乎所有的硬件都采用一个叫MMU的部件来进行页映射,通过页映射解决上面内存使用相关问题)。
多线程访问全局变量、堆数据的线程安全问题,解决办法实现原子操作以及同步访问,同步访问实现最常见的即加锁,锁的等级又分成二元信号量(只存在占用和非占用两种状态,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放)、互斥量(与二元信号量相比,只能由获取互斥量的线程来释放互斥量)、临界区(与互斥量类似,区别在于仅限于本进程,二元信号量和互斥量在系统任何进程都是可见的)、读写锁(存在读共享的和写独占的两种状态)、条件变量(一个条件变量可以被多个线程等待,当等待事件发生时,条件变量被唤醒,等待线程都恢复执行)。
多线程开发还需要注意编译器的一些优化技术带来的过度优化问题,如编译器为了提高变量速度将一个变量缓存到寄存器中而不写回,以及为了效率儿交换毫不想干的两条指令的执行顺序等,这些问题可以试图通过使用volatile关键字、设置barrier指令(c++里面)等防止寄存器变量不写回,以及执行顺序被交换。
第二部分 静态链接
预编译、编译、汇编、链接的定义
预编译:处理以#开头的预编译指令,如#include #define,展开所有的宏定义,输出以 .i 结尾的文件,还可以对经过预编译处理的文件检查它的宏定义是否正确, 头文件包含的内容是否正确等
编译:对代码进行词法分析(通过一个扫描器对源代码进行扫描,同时将这些非空字符序列标记为相应类型,如标识符、左方括号、复制等类型)、语法分析(对前面扫描器产生的记号进行语法层面分析,产生语法树,如一般赋值符号作为根结点,左子树是赋值表达式的左边,右子树是赋值表达式的右边,叶子结点一般就是最小的表达式),语义分析(由语义分析器来完成,包括动态语义和静态语义,静态语义通常包括声明、类型匹配、类型转换等,如果发现有错误,编译器检查阶段会报错,动态语义通常是运行期出现的语义相关的错误,如0作为除数是一个运行期语义错误)及优化(也可理解为中间语言生成,如2+6表达式可以由编译器优化,因为在编译期阶段它的值可以被确定下来了),然后生成相应的汇编代码文件,最好生成 .s 结尾的文件
汇编:将汇编代码变成机器可以执行的指令,每一条汇编语句都对应一条机器指令,相当于汇编器的汇编过程只需要根据汇编指令和机器指令的对照表一一翻译就可以了,最后生成一个.o目标文件
链接:将前面生成的一大推目标文件链接起来(分析各个目标文件中定义的符号,以及这些符号的引用关系,然后把所有外部符号的引用链接起来),包含地址与空间分配、符号决议、重定义等步骤,得到最终的可执行文件 .out
目标文件与可执行文件的区别
目标文件从结构上说已经是编译后的可执行文件,只是还没有经过链接的过程,其中有些符号的地址还没有进行调整,它本身已经是按照可执行文件来存储了,只是在结构上与可执行文件稍有不同,总结目标文件就是源代码编译后但未进行链接的那些中间文件。可执行文件格式则包含了程序编译、链接、转载、执行的各个方面
ELF文件格式可以表示哪些类型
可执行文件,共享目标文件,核心转储文件(进程意外退出时,系统可以将改进程的地址空间的内容及终止时的一些其他信息转储到核心转储文件),在linux下,可以通过命令 [ file 命令目录]来查看可执行文件类型,如下所示
➜ TestFiles git:(feature/IFST-925) ✗ file TestSystemVersionJudgeChecker.o
TestSystemVersionJudgeChecker.o: Mach-O 64-bit object x86_64 //object 目标文件
➜ ~ file /bin/bash
/bin/bash: Mach-O 64-bit executable x86_64 //executabl 可执行文件
目标文件是如何存储的,它由哪些段组成,每个段的作用,以及这样分段的原因
目标文件的存储,以ELF文件的存储为例,如下所示,文件开头是一个“文件头”,文件头描述整个文件的文件属性,包括文件是否可执行,是静态链接还是动态链接,以及入口地址(可执行文件)、目标硬件、目标操作系统、段表等信息,其中段表是一个描述文件中各个段的数组,段表描述了文件中各个段在文件中的偏移位置及段的属性等,从段表里面可以得到每个段的所有信息,文件头后面就是各个段的内容了。
.text 段保存的是程序的指令,也可以称为.code段
.data 段保存程序的全局变量、局部静态变量等
.bss 段为未初始化的全局变量、局部静态变量预留位置,它并没有内容,在文件中也不占据空间(对于ELF文件是这样)
总体来说,程序源代码被编译以后主要分成两个段:程序指令和程序数据,代码段属于程序指令,而数据段和.bss段属于程序数据
除了以上系统自定义的段之外,我们还可以自定义段,并且通过GCC的扩展机制将某些代码放到指定的段里面去,实现特定功能,如下所示,在全局变量或函数之前加上
attribute((section(“name”)))属性,可以将相应的变量或函数放到以"name"作为段名的段中
__attribute__((section("FOO"))) int global = 42;
__attribute__((section("BAR"))) void foo()
之所以这样分段的原因,1、数据区域对于进程来说是可读写的,而指令区域对于进程来说是只读的,当程序被装载时,数据和指令分别被映射到两个虚存区域,这两个虚存区域可以被分别设置成可读写和只读,这样可以防止程序的指令被有意或无意的改写。2、指令和数据分开存放对缓存命中率提高有帮助。3、当系统中运行了该程序的多个副本时,它们的指令都是一样的,所以程序中只需要保存一份该程序的指令部分。这种共享指令对动态库可以节省大量的空间
通过命令分析某个目标文件的段结构
gcc -c test.c //将c文件编译,生成目标文件test.o
因为书中使用的是linux下binutils的objdump工具来查看目标文件的结构,而mac下没有这个工具,同时mac下可以使用ctools命令替代,输出的结构和linux下的结构有一些不一样
➜ otoolProgram git:(master) ✗ size SimpleSection.o //size命令用于查看文件的代码段、数据段、objc段(mac下是objc段)的长度,dec是前面所有段长度的和的十进制,hex是长度和的十六进制
__TEXT __DATA __OBJC others dec hex
212 12 0 64 288 120
➜ otoolProgram git:(master) ✗ otool -dV SimpleSection.o //-d 参数表示data段,-V将所有包含指令的段反汇编
SimpleSection.o:
Contents of (__DATA,__data) section
0000000000000068 54 00 00 00 55 00 00 00
➜ otoolProgram git:(master) ✗ otool -tV SimpleSection.o //-t 参数表示text段,-V将所有包含指令的段反汇编,对照下面的汇编结果,可以看到,.text包含的是SimpleSection里面的两个函数func1和main的指令
SimpleSection.o:
(__TEXT,__text) section
_func1:
0000000000000000 pushq %rbp
0000000000000001 movq %rsp, %rbp
0000000000000004 subq $0x10, %rsp
0000000000000008 leaq 0x61(%rip), %rax ## literal pool for: "%d\n"
000000000000000f movl %edi, -0x4(%rbp)
0000000000000012 movl -0x4(%rbp), %esi
0000000000000015 movq %rax, %rdi
0000000000000018 movb $0x0, %al
000000000000001a callq _printf
000000000000001f movl %eax, -0x8(%rbp)
0000000000000022 addq $0x10, %rsp
0000000000000026 popq %rbp
0000000000000027 retq
0000000000000028 nopl _func1(%rax,%rax)
_main:
0000000000000030 pushq %rbp
0000000000000031 movq %rsp, %rbp
0000000000000034 subq $0x10, %rsp
0000000000000038 movl $_func1, -0x4(%rbp)
000000000000003f movl $0x1, -0x8(%rbp)
0000000000000046 movl _main.static_var(%rip), %eax
000000000000004c addl _main.static_var2(%rip), %eax
0000000000000052 addl -0x8(%rbp), %eax
0000000000000055 addl -0xc(%rbp), %eax
0000000000000058 movl %eax, %edi
000000000000005a callq _func1
000000000000005f movl -0x8(%rbp), %eax
0000000000000062 addq $0x10, %rsp
0000000000000066 popq %rbp
0000000000000067 retq
在Xcode中查看某个文件的汇编代码的,通过菜单Product→Perform Action→Assemble 再选择相应源文件即可
结合ELF文件结构深入了解目标文件段的结构
//Header
➜ otoolProgram git:(master) ✗ otool -h SimpleSection.o //-h 输出文件头信息,magic魔数:用于确定文件的类型,操作系统在加载可执行文件时会确认魔数是否正确,如果不正确会拒绝加载
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777223 3 0x00 1 4 672 0x00002000
Section Header Table: 保存所有段的基础属性的结构,编译器、连接器、装载器都是依据段表来定位和访问各个段的属性的
String Tables :保存段中用到的字符串(段名类型等,通过字符串表即相当于可以解析整个ELF文件),因为字符串的长度往往是不固定的,用固定的结构(大小)来表示比较困难,常见的做法是把字符串存放到一个表(如链表),然后使用字符串在表中的偏移来引用字符串
Symbol Tables: 在链接时,将函数和变量统称为符号,符号表就是用来存放这些符号的,每一个目标文件都会有一个对应的符号表,符号值对变量和函数来说就是它们的地址。
符号表中的符号可以分为以下类型:
1.本目标文件的全局符号 2.本目标文件引用的其它目标文件的全局符号 3.段名 4.局部符号 5.行号信息,其中最重要前面两个全局符号,因为链接过程只关心全局符号的相互粘合,可以通过nm命令查看文件的符号表
➜ otoolProgram git:(master) ✗ nm SimpleSection.o
0000000000000000 T _func1
0000000000000068 D _global_init_var
0000000000000004 C _global_uninit_var
0000000000000030 T _main
000000000000006c d _main.static_var
0000000000000120 b _main.static_var2
U _printf
(6)理解用于链接过程的符号(函数和变量)
在链接时,目标文件之间相互拼合实际上是目标文件之间对地址的引用,即对函数和变量的符号地址的引用
(7)函数签名的作用
函数签名包含了一个函数的信息,即函数名、参数类型、它所在的类、名称空间等其它信息,用于识别不同的函数,就如同识别不同的人一样
c++中命名空间的作用:解决多模块的符号冲突问题
未完待续
(1)为什么要有空间和地址的分配,如何分配
对于连接器来说,在考虑如何将多个输入目标文件的各个段合并到输出文件时,首先应该考虑的就是如何输出文件中的空间分配给输入目标文件。常见的分配方式有两种,一是按序叠加,即将输入的目标文件按序叠加起来,带来的问题是最后的输出文件将会有成百上千个零散的段,会造成内存空间大量的内部碎片,二是相似段合并,将相同性质的段合并到一起,此方法的连接器一般采用两步链接,第一步、空间和地址分配,第二步、符号解析与重定位
(2)哪些符号需要重定位,重定位过程
(3)commom块的含义,用于解决什么问题(应用场景)
(4)区分ABI与API
(5)描述printf函数的链接过程
(6)脱离操作系统的一些硬件等相关的输入输出程序,输出的地址往往需要指定具体段的信息,那是怎样实现的呢?
(7)控制整个链接过程的方法有哪些,为什么链接控制脚本的方式是最强大的?
(8)通过一个hello word程序演示链接脚本控制过程,这个链接脚本是操作系统的一部分吗?
(1)动态链接解决了静态链接的哪两个问题,问题产生的原因是什么,动态链接是如何解决的
(2)动态链接的缺点是什么