深入理解计算机系统
- 计算机系统漫游
- 信息就是位+上下文
- 使用 ASCII 字符构成的文件称为文本文件,其他所有文件称为二进制文件;
- ASCII 字符使用一个单字节的整数,来代表每个字符;
- 计算中的所有信息,都是用一串字节(即byte, 比特)来表示的;在不同的上下文中,同样一串字节会被翻译成不同的信息;因此,需要保证上下文正确,才能获取正确的信息;(有些值用4字节表示,有些用8字节表示;当提取的时候,需要提取多长的一段范围,则写在了另外一个位置的某些字节中,所以,事实上,所谓的类型在底层是不存在的,它会被转换成对一定长度的位操作,不同的类型,需要操作的位长度不相同);
- 计算机对数据的模拟,由于位数的限制,只能是有限接近于真实值;
- 程序编译指将程序翻译成不同的格式,编译过程如下:
- 预处理器:将源文件(即源程序,格式为文本文件)hello.c 进行预处理修改(例如插入引用的文件的内容),得到 hello.i
- 编译器:将 hello.i 编译成汇编程序 hello.s;
- 汇编器:将汇编程序 hello.s 翻译成机器指令 hello.o
- 链接器:将 hello.o 依赖的其他文件链接起来,最后得到 hello 程序(可执行目标文件)(由于操作系统使用进程对硬件进行了抽象,让程序感觉好像自己是独占内存一样,所以程序本身可以不用关心是否会与其他程序发生内存使用的冲突)
- 疑问:如果在预处理阶段已经插入了其他文件,为什么此处还需要链接其他文件?
- 答:因为有可能插入的只是一个声明文件 xxx.h,而不包含实现文件 xxx.c,所以需要链接;如果插入的是 xxx.c 文件,则不需要链接;
- 疑问:如果在预处理阶段已经插入了其他文件,为什么此处还需要链接其他文件?
- 了解编译系统工作原理的好处
- 优化程序性能;
- 理解链接时发生的错误;
- 避免安全漏洞;
- shell 是一个命令行解释器
- 它等待用户输入命令,然后执行它,如果输入的命令不是内置的命令,则它会把输入当作一个可执行文件的名称,尝试加载并执行该可执行文件,然后等待可执行文件运行终止;
- 终止后,等待用户输入下一个命令;
- 系统的硬件组成
- 总线:总线通过连接各个不同的硬件零部件,实现它们之间的信息传递;每次传递固定长度的字节,早期是4个字节 (32位),现在是8个字节(64位)(好奇这些固定长度的字节的格式,是否有什么统一的规律?答:没有规律;如何解读由各个硬件自己控制)
- I/O 设备:系统通过 I/O 设备实现与外界的连接,每个 I/O 设备,通过控制器或适配器连接到总线上;控制器与适配器的区别,主要在于封装方式不同:
- 控制器:封装在主板(主印刷电路板)上的芯片组,或者封装内置在 I/O 设备中;
- 适配器:一般是插在主板上的卡(估计也可以焊在上面);
- 操作系统将 I/O 设备抽象为文件给应用程序调用,而所谓的驱动程序是一个函数,当应用程序向代表 I/O 设备的文件描述符写入数据时,内核会调用相应的驱动程序函数,传递数据给该函数进行处理,从而实现跟硬件设备的通信;
- 主存:一个临时存储设备,在运行程序过程中,用来存储程序以及程序使用的数据;
- 物理上:主存是由一组 DRAM 组成的;
- DRAM:动态随机访问存储器,Dynamic Random Access Memory,里面有电容
- 电容:存储电荷的容器;电容按存储的电荷多少,分别表示 0 或 1
- 问:什么是电感?答:电感全称是电磁感应,它用于表示电场的变化引起磁场变化的现象;
- 电荷:带电粒子;带正电的叫正电荷,带负电的叫负电荷;
- 逻辑上:主存就像是一个字节数组
- 通过数组索引可以读取数组内存储的数据;此处的索引即主存的地址;(为主存建立索引的工作是由来完成的呢,操作系统?答:确切的说,应该是内存的驱动程序在翻译索引,将其关联到对应的位置)
- 不同的数据类型,占用的字节数量不同;在 linux x86-64 的机器上,short 占用2字节,int,char 占用 4 字节,long, double 占用 8 字节;在有些机器上,char 只占用一个字节;
- 物理上:主存是由一组 DRAM 组成的;
- 处理器
- 中央处理器 CPU 由三部分组成,分别是:控制单元、存储单元(寄存器)、算术逻辑单元(ALU);
- 寄存器
- 数据寄存器:存储从主存中加载的数据;
- 指令寄存器:存储从主存中加载的指令;(听上去主存的指令和数据好像是分开的?答:确实是分开存储的)
- 程序计数器:存储当前正在执行的指令;当指令执行结束后,指向下一条指令;
- 运行 Hello 程序的过程
- 常规:
- shell 接收到用户的指令输入,将指令发给 CPU
- 事实上 shell 获得用户输入后,还有一个调用相应的内置函数或可执行文件,得到指令,再发给 CPU 的过程;
- CPU 解读指令,从磁盘读取可执行文件到主存
- CPU 从主存加载指令和数据到 CPU 进行计算(这么说来,加载到主存的可执行文件貌似是由指令+数据组成的?答:没错)
- CPU 将计算结果输出到 I/O 设备,如屏幕;
- shell 接收到用户的指令输入,将指令发给 CPU
- 通过使用 DMA (直接存储器存取)技术,第 1 步和第 2 步可以合并成一步,shell 收到指令后,直接从磁盘加载数据到主存,不需要经过 CPU;
- DMA 原理:相当于在硬件内部,实现一个小型的 CPU,这个 CPU 被允许对内存进行访问和数据传输,这样可以减少 CPU 在传输数据任务上的占用(由各个IO 设备的内置小型 CPU 分担了 CPU 的部分工作量)
- DMA 问题:由于 DMA 的引入,有可能发生 CPU 的缓存,与主存的数据不一致,因此需要引入冲突解决机制(一般有同调和非同调两种方案)
- 常规:
- 高速缓存
- 由于 CPU 寄存器、主存、磁盘三者的访问速度差距很大,寄存器大约比主存快100倍,主存大约比磁盘快1000万倍,而且这种速度的差异还在增大;因此,通过引入高速缓存的技术来减少这种差距;
- 原理:在寄存器和内存之间,增加高速缓存模块,提前将 CPU 可能会用到的数据放在里面;
- 缓存模块的访问速度比寄存器慢5倍,但比主存快5-10倍
- 缓存模块存储的容量大概是寄存器的100倍,从而实现速度和容量之间的一种兼顾平衡;
- 缓存模块使用 SRAM(静态随机访问存储器)
- SRAM:静态的意思是通电后,即保存的数据会一直稳定存在,只有断电的情况下才会消失;
- DRAM:使用电容来代表位信息,而电容存在放电现象,因此需要周期性的通电,避免保存的信息丢失,所以称为动态;
- 缓存模块可以分多级,如 L1, L2, L3(L0 用来指代寄存器),越前面的速度越快,但容量越小,用来存储更常用的数据;
- 原理:在寄存器和内存之间,增加高速缓存模块,提前将 CPU 可能会用到的数据放在里面;
- 程序可以考虑利用高速缓存的存在,来提高程序的运行速度;
- 晶体管:有三个极,可以起到放大信号、开关信号、调节信号的作用;
- 晶体管貌似对应《编码》里面的继电器?然后由晶体管可以组成逻辑门,通过逻辑门阵列,实现 CPU 的指令集;
- 后来查询了一下,发现晶体管比继电器更高级一点,它具备开关、放大、稳压等几个功能,而单个继电器只具备开关功能,需要多个继电器组合,才能具备以上功能;
- 由于 CPU 寄存器、主存、磁盘三者的访问速度差距很大,寄存器大约比主存快100倍,主存大约比磁盘快1000万倍,而且这种速度的差异还在增大;因此,通过引入高速缓存的技术来减少这种差距;
- 存储设备形成的层次结构:在整个金字塔中,上一层设备,作为下一层设备的高速缓存;
- 操作系统:管理硬件
- 在应用程序和处理器/主存/IO设备之间,增加了一层抽象,这层抽象即操作系统,它实现了应用程序与硬件之间的对接,目的有二:
- 防止硬件被失控的应用程序滥用;
- 让应用程序对硬件的访问变得更简单,不需要去了解硬件的实现细节;对于相同的操作系统,即使在不同类型的机器上,访问方式都是一样的;
- 操作系统通过以下几个抽象概念实现以上两个目地;
- 文件:对 I/O 设备的抽象,即文件 = I/O 设备;
- 虚拟内存:对 I/O 设备 + 主存的抽象,即虚拟内存 = 主存 + 文件;数据只可能存在于两种地方,要么在主存里面,要么在文件里面,网络也是一种文件;(但貌似对文件数据的访问,需要将数据从文件加载到主存中?答:没错)
- 指令集:对处理器的抽象,即指令集 = 处理器
- 进程:对 I/O 设备 + 主存 + 处理器的抽象,即进程 = 虚拟内存 + 指令集;(因此,貌似对于应用程序来说,就是在不断做“指令+虚拟内存”的操作,如果应用程序关心的是虚拟内存,那么操作系统就要实现物理内存与虚拟内存的对应?答:没错,CPU 要处理地址翻译的工作);
- 虚拟机:即虚拟机 = 操作系统 + 进程;
- 进程
- 操作系统通过进程制造一层抽象,使得程序能够独占并调用所有硬件资源的假象(这样可以简化程序的编写,让其不用关心具体硬件的实现);
- 进程使得并发运行成为可能,即两个程序的指令,是可以交错使用硬件资源的(通过在进程间切换实现);(对于单核处理器来说,进程并发或许只是一种伪并发?嗯,没错,不过由于 CPU 很快,这种机制才能实现多任务)
- 操作系统需要保持进程切换所需要的上下文信息;当从一个进程切换到另外一个进程时,由操作系统的内核来管理(貌似主要是要做好虚拟内存与物理内存的对应工作?不仅如此,还需要保存一堆其他信息,包括程序计数器、寄存器值等,这样再切换回来的时候,才能恢复现场,从中断处继续处理下一条指令);
- 线程
- 线程是操作系统内核能够进行运算调度的最小单位;一条线程其实是进程中某一个单一顺序的逻辑控制流;
- 每个线程都有自己的线程上下文,包括唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码等;
- 在现代系统中,一个进程,是可以由多个线程组成的;每个线程执行不同的任务;
- 这些线程都运行在进程的相同的上下文中,它们共享同样的代码和全局数据
- 如何提供线程的抽象?C 语言中有相应的库函数(估计其他语言也有)
- 或许可以理解为,在同一个进程中,线程之间面对的是同一个虚拟内存,所以它们的数据是可以共享的;而如果是进程间的通信,则面对的是不同的虚拟内存,需要操作系统参与才能实现数据的通讯
- 不同进程之间如何实现通信?有多种方式,包括信号、套接字、内存映射;
- 由于线程共享数据比进程更快更方便,因此,利用线程机制,也可以提高程序的并发处理能力;
- 当存在多核处理器时,线程就可以派上用场了
- 如果只有单核处理器,则多线程也没用
- 现在想想,感觉可能还是有点用,因为通过引入线程池队列,如果程序是非阻塞的,则线程也可以交替使用单核心,因为虽然只有单核心,但可以引用多个寄存器和计数器,共享单核心的算术逻辑单元;
- 后来有了超线程技术,也使得多线程变得有用;
- 但线程也带来一个问题,即一个对象可能已经被某个线程销毁了,而其他线程并不知道;
- 咖啡店的比喻
- 顾客到咖啡店购买自己想喝的某种咖啡;咖啡店分为前台和后台,前台负责接单和结账,后台负责制作,它们是两个团队;后台团队进入了工业4.0,已经由以前的人工制作咖啡,升级了流水线全自动超高速咖啡制作机,它的速度很快,远远超过前台小妹的速度;
- 解决方案之一是可以多招几个前台,由于她们处理不同客户的需求;
- 虚拟内存
- 虚拟内存是一种抽象,它为程序提供一种独占内存的假象;
- 地址从低到高分别是:程序代码和数据、堆、共享库、栈、内核虚拟内存
- 其中堆和栈是可以动态变大或变小的;
- 今天才有点明白共享库是什么;大多数程序都会用到一些第三方编写好的库代码;很多这类型的库是通用的,因为每个程序拷贝一份显然在存储效率上并不划算;因此,操作系统提供了一种共享机制,程序可以通过动态链接,使用共享库;好处是可以减少程序本身占用的体积;坏处是当库不存在时,程序不能运行;
- 还有其他的好处包括可以简化程序的更新;
- 文件
- 文件即是字节的系列(即字节数组),所有的 I/O 设备,都可以看成是文件;这个概念非常强大,它提供了一种统一的抽象,使得程序对数据访问变得统一和简单,不需要了解不同硬件的具体实现技术;
- 在应用程序和处理器/主存/IO设备之间,增加了一层抽象,这层抽象即操作系统,它实现了应用程序与硬件之间的对接,目的有二:
- 系统之间利用网络通信
- 网络也可以当作是一个 I/O 设备,系统通过访问这个 I/O 设备,实现数据的交换;
- 重要主题
- Amdahl 定律:系统某个部分性能的提高,对整体性能的提高,取决于该部分原来耗时的占比和加速程度
- s = 1 / (( 1 - a ) + a/k) = Told / Tnew
- 其中:a 指占比,k 指提升比例, s 指加速比
- 并发和并行
- 线程级并发:正常情况下一个 CPU 内核只能处理一个线程,但通过引入多个寄存器和计数器,可以模拟出一个 CPU 内核处理两个线程,两个线程共享一个算术逻辑单元 ALU,ALU 在寄存器和计数器之间切换,使得寄存器和计数器在加载的数据的时候,ALU 不会出现空闲等待;线程级的并发,仅是硬件提供支持,同时还需要应用程序面向线程并发进行开发,才能用上硬件的并发功能;
- 指令级并行:早期 CPU 执行一条指令需要多个时钟周期,现在 CPU 已经支持一个时钟周期执行多个指令(超标量处理器);实现的原理是通过将指令执行操作进行标准分段,如果有多条指令,都存在相同的操作段,则对这些指令相同的段进行一次性批量操作;(据说这种方式叫流水线设计)(可以相像成财务去银行存钱,老板说要存100元,出纳可以单独跑一次,但如果有其他主管也说要存钱,出纳跑一次可以存多笔钱)(当指令之间存在依赖关系时, CPU 内部需要实现数据转发的机制)
- 单指令、多数据并行:一条 CPU 指令,产生多个并行的数据操作,例如一条指令同时对 8 对浮点数进行加法操作;这种设计可以用来提高图像、视频、声音等场景的计算速度;(貌似这是显卡 GPU 的工作原理)(CPU 里面也有一个 SSE 或 AVE 指令集来实现这些功能);
- 抽象的重要性
- CPU 的指令集架构即是对 CPU 操作的一种抽象;它可以让编程变得更简单,不用去关心 CPU 底层的硬件实现细节;
- Amdahl 定律:系统某个部分性能的提高,对整体性能的提高,取决于该部分原来耗时的占比和加速程度
- 信息就是位+上下文
- 信息的表示和处理
- 信息存储
- 综述
- 为了充分有效的利用内存,对于不同的数据类型(正数、负数、小数)我们采用了不同的编码表示方法,例如无符号编码(正数)、补码编码(负数)、浮点数编码(小数),因此,这也使得对不同类型的数据进行运算时,需要先将它们转换成相同的数据类型之后才能计算,否则会出错;因为不同的编码方式,意味着信息有不同的上下文;
- 由于信息是以二进制的形式来存储和处理的,以及计算机只能以有限位来模拟数据,这使得一些在数学中成立的规律(例如交换律,结合律等),在有限位的二进制计算中,不一定适用;计算过程中有可能会产生溢出;
- 大量计算机的安全漏洞,都是因为计算机在做算术运算时的一些细节引起的(事实上,计算机的本质,就是使用二进制进行位运算,所以安全由运算规则引发,也是可以理解的)
- 内存的最小物理单元是位,但最小可寻址的单元是字节(大多数计算机以8个位为一个字节);
- 对于应用程序来,内存就像一个巨大的字节数组;每个字节有一个内存地址,所有字节地址的集合,组成一个虚拟地址空间;
- 事实上应用程序面对的是一个虚拟内存;由操作系统来管理虚拟内存和物理内存之间的映射;
- 十六进制表示法
- 一个字节的值域:二进制是 00000000 - 11111111,十进制是 0 - 255,十六进制是 00 - FF,
- 在 C 语言中,使用 0x 或 0X 开头来表示十六进制的值;
- 字数据大小
- 计算机有一个字长的属性,这个属性决定了其能够处理的最大的虚拟地址空间(因为跟寻址有关系),以前的计算机一个字长的字节数是32位(4个字节),新的计算机则以64位为主(8个字节);对于32位计算机, 232 位约等于 4 * 109 个字节(即约4GB),即32位计算机的最大内存上限是4GB;
- 今天终于知道字长原来是跟 CPU 的指令集设计有关,指令就像函数,接收立即数或者地址做为参数,而参数的长度是有限制的,所以也就导致了最大寻址空间的问题;
- 程序可以编译成32位,也可以编译成64位,对于64位计算机来说,二者都可以运行;但对于32位的计算机来说,则无法运行64位的程序;
- 计算机有一个字长的属性,这个属性决定了其能够处理的最大的虚拟地址空间(因为跟寻址有关系),以前的计算机一个字长的字节数是32位(4个字节),新的计算机则以64位为主(8个字节);对于32位计算机, 232 位约等于 4 * 109 个字节(即约4GB),即32位计算机的最大内存上限是4GB;
- 寻址和字节顺序
- 在几乎所有的计算机上面,多字节对象,在内存中都是连续存储的
- 貌似这样寻址效率应该会比较高一些
- 好奇对于 C 语言中的可变长度数组,如何初始化空间进行存储?答:刚发现 C 中的可变长度数组并不是真正动态的长度,而只是动态分配,即一开始先不分配空间,等收到长度参数后,再分配空间;一旦分配完成后,长度就是固定的了,不能添加超过长度的数据,不然会越界;
- 字节存储顺序
- 小端法:最低的有效字节在前面;
- 刚发现小端法,不是指单个字节内部的小端,而是多个字节顺序的小端,例如一个整数由4个字节组成,小端法表示的 0x73 ff ff ff,它对应的大端法为 0x ff ff ff 73,注意:在单个字节内部,73仍然是73,而不是37;
- 大端法:最高的有效字节在前面;
- 小端法:最低的有效字节在前面;
- 对于单台计算机,使用哪种字节存储顺序对于用户和程序员都无关紧要(除非去阅读机器级代码,或者需要读取每个字节内存储的信息);但对于网络中的计算机,则可能引发错误;因此,通过在网络协议中引入传输标准,来解决这个问题;
- 但是,貌似已编译完成的机器代码,无法在不同存储顺序的机器之间移植,需要重新编译?
- C 语言使用括号来做为强制类型转换运算符(很好奇之前的书好像没提到),示例:(byte_pointer) &a,表示不管 a 之前是什么类型,现在转换为 byte_pointer 类型;
1. 所谓的强制类型转换,或许也可以理解为对同一段二进制信息,换一种解读方式;
- 在几乎所有的计算机上面,多字节对象,在内存中都是连续存储的
- 表示字符串
- C 语言中,字符串表示为一个以 null 结束的字符数组;因此,这也使得字符串的长度,会增加1,即那个不可见的 null 占据一个长度单位;
- 在字符串的设计上,C 语言的设计很糟糕,当非法字符串没有 null 结束符时,字符串的读取动作无法停止,导致会产生未定义行为;
- 当代码被编译器翻译成机器代码时,所有类型的信息将丢失,转换成相应的位操作(指令+数据),因此,二进制程序一般很难在不同的操作系统和机器上进行移植;32位和64位完全不同,windows 和 linux 也会不同;
- 但是相同操作系统相同位处理器的情况下,则可以移植;
- C 语言中,字符串表示为一个以 null 结束的字符数组;因此,这也使得字符串的长度,会增加1,即那个不可见的 null 占据一个长度单位;
- 布尔代数简介
- 共有四种,分别是与运算,或运算,取反运算,异或运算(当 a 为 0, b 为 1 或者 a 为 1, b 为 0 时,才取真,否则取假)
- C 语言中的位级运算
- C 语言支持按位布尔运算,运算符包括 &, |, ^, ~, <<, >>;
- a^(a^b) = b;
- 掩码运算:通过对目标位的按位操作,达到屏蔽指定位而实现需求;
- 按位与:用来实现保留指定位的值,其他位的值置为 0 的需求;
- 与 1 的与运算,会保留原来的值;
- 与 0 的与运算,会将原来的值置 0;
- 按位或:可实现把部分位置为 1,同时保留某些位;即原为 1 的位保持不变,原为 0 的位变成 1;
- 与 1 的或运算,会将位的值置为1;
- 与 1 的与运算,会保留位的原值;
- 按位异或:可实现把部分位反转,同时保留某些位;
- 与 1 的异或运算,会将原来的值取反;即原为 1 的位会变成 0,原为 0 的位会变成 1;(这么说,异或可以用来做取反运算,效果同取反运算)
- 与 0 的异或运算,会保留原来的值;即原为 0 的位仍为 0,原为 1 的位仍为 1;(感觉这里的效果同 1 的与运算)
- 通过指定位数的掩码,例如 111000,则前三位取反,后三位保留;
- 按位取反:对参与计算的二进制取反(注:这是一个一元运算符)(可以用 1 做异或运算);
- 左移运算:将二进制位左移,高位丢弃,低位补0;如果数值比较小,丢弃的高位只有0没有1,则左移相当于乘以 2 的 n 次方;
- 右移运算:将二进制位右移,低位丢弃,高位补0或1,如果最高位是0则补0,如果是1则补1;
- 如果左端全部补 0,称为逻辑右移;
- 如果左端补原最高位的有效值,称为算术右移(据说算术右移对有符号的整数运算有很大的用处,待了解有什么用?莫非用于除法?)
- 由于 C 语言没有明确规定,当进行右移运算时,使用哪种右移方法,导致存在歧义的可能性;不过据说所有的编译器,对有符号数,都统一使用算术右移的方式;而对于无符号数,则使用逻辑右移的方式;
- 取反运算:0变1,1变0;两次取反运算,会得到原来的数值;
- 原码:首位为符号位,0表示正,1表示负,余下位为绝对值的二进制位;
- 由于首位用来存放符号,原来1个字节8位能表示的范围就改变了,原是 0
255,现在变成 -128127 了;
- 由于首位用来存放符号,原来1个字节8位能表示的范围就改变了,原是 0
- 补码:正数与原码相同;负数的符号位不变(即首位为1),余下位为绝对值的二进制位取反,然后加1;示例如下:
- 正 100 的原码和补码都为 01100100;
- 负 100 的原码为 11100100;
- 负 100 的补码为 10011011 + 1 = 10011100;
- 几乎所有的计算机,都使用补码来实现有符号的整数;在补码中,整数被分为两个子范围,一段是负整数(最左位以1开头),一段是非负整数(最左位以0开头);计算机从内存读取二进制位,需要进行整数的还原操作,如果最左位是0,则原封不动的取出;如果最左位是1,则取出后,做一次取补运算(有两种计算方法,简便的方法是最左位不变,其他位取反后加1)(原理:一个值做两次取补运算后,会得到原来的值);问:为什么取出来后,需要进行取补运算的还原操作呢?貌似可以不用的嘛,直接参与计算不就好了?
- 相对于原码表示法,补码表示法中,只有一种 0,原码表示法则有正 0 和负 0 两种 0;
- 取补运算(假设原来的数是正数)
- 方法1:从最右开始复制位,直到有1被复制,然后余下的位做取反运算;
- 示例:00110100 取补后 为 11001100
- 方法2:先进行取反运算,然后加1;
- 示例 00110100 取反得到 11001011,加 1 后得到 11001100
- 方法1:从最右开始复制位,直到有1被复制,然后余下的位做取反运算;
- 补码的优点:对于两个非负整数相加,直接按位相加;对于两个非负整数想减,例如 a - b,则转换为 a + (-b),即取 b 的补码然后相加(总结:不管正负,只需考虑加法;对于减法,对减数取补后,变成做加法运算)
- 当使用补码与另外一个数直接相加时,由于补码的高位经常是 ff,所以加法的过程中,一旦进1,将使得补码前面的高位 ff 全部变成0,使得进1无效,从而实现了减法的效果;
- 综述
- 整数表示
- 整型数据类型
- 貌似计算机的二进制运算中,只有加法、乘法和取反,没有减法(原因:为了简便,将减法当成正数和负数的加法来操作)
- 乘法和除法其实是通过移位来实现;
- 有符号整数的范围,比无符号整数的范围多1;比如 signed char 取值范围为 -128~127 合计 256 个数,unsigned char 取值范围为 0 ~255,合计 255 个数;原因:0 是非负数,因此正数部分扣去零后,就少了一个;
- C 标准要求取值范围对称;
- 貌似计算机的二进制运算中,只有加法、乘法和取反,没有减法(原因:为了简便,将减法当成正数和负数的加法来操作)
- 由于不同机器的整数范围不同,因此在编码的时候,就需要考虑程序在不同机器上面的可移植性,尤其是在使用互联网进行数据传输时;为了解决这个问题,C99 标准引入了 <stdint.h> 头文件,并在文件中规定了各种类型名称的宏;这样,当使用这个宏名称对变量进行定义时,编译器根据不同的机器,就能够转化成正确的数据长度,确保了可移植性;这一点上,Java 就做得比较好,它明确规定了不同整数类型的取值范围和补码表示方式,确保了可移植性;
- 事实上 Java 是通过虚拟机来实现的可移植性,跟类型貌似无关;
- 强制类型转换并没有改变位的值,而是改变了解释这些位的方式;(因此强制类型转换存在出错的可能性)
- 一些原理
- 无符号整数的编码是唯一的,B2U 函数是双射的;
- 补码编码是唯一的,函数 B2T 是双射的;
- 补码转化为无符号数,函数 T2U
- 当 x >= 0 时,T2Uw(x) = x;
- 当 x < 0 时,T2Uw(x) = x + 2w,例如4位补码 -3 转为无符号数时,等于 -3 + 24 = 13;
- 补码与无符号数的转换效果,其实它们之间有一段重合,然后不管是哪个方向的转换,在于将不重合的那段,移动到另外一头(数学运算即是加上 2w 或减去 2w );
- 在 C 语言中,默认数字都是有符号的,如果要创建无符号数,需要专门用后缀 u 进行标记;
- C 允许在有符号和无符号之间进行转换,默认的原则是底层位保持不变,改变的是解释方式,即上下文;(重合的部分没有损失,不重合的部分会出错)
- 当将一种类型的表达式赋值给另外一种类型的变量时,会隐式发生转换的动作;
- 但隐式转换存在出错的可能性;
- 当表达式中既包含有符号数,也包含无符号数,会触发 C 的隐式转换,它会将有符号转成无符号,并假设它们都是非负的,然后进行计算;对于算术运算来说,这种隐式转换不会带来差异,但对于关系运算来说,有可能会出现错误的结果;
- 数字的位扩展,使用场合:当某个数据类型的位数太少,不够使用时,可以将其位数进行扩展;
- 零扩展:在最高位加 0,常用于无符号数;
- 符号扩展:添加最高位的有效值,常用于补码数的扩展;
- 由于负数才需要取补运算,因此新增的 1 在取补后为 0,因此最高位有效扩展不会改变原来的值?真的吗?真的;
- 有疑问,负数本身即是用补码表示的,当它进行符号扩展时,会发生什么?刚测试了一下,貌似不会改变原来的值;因为是用1来扩展高位;
- 当进行数据类型转换时,例如 short -> unsigned,由于 short 只有2字节,而 unsigned 有 4 字节,转换过程实际分成了 2 步进行,第一步先将 short 变成 int,得到 4 字节的长度,之后再将 int 转换成 unsigned;这里面藏着一个顺序问题,如果顺序是先转换成 unsigned short 再转换成 unsigned int,则得到的结果会有所不同;前者的转换顺序是 C 语言标准要求的规则;
- 一个十六进制数用4个位表示,从 8 开始到 F,其第 1 位都是 1;因此,在做算术右移运算时,左侧补 1 会有不同的结果
- 例如:0x81 右移24位会得到 0xFFFFFF81;而 0x71 右移 24 位会得到 0x00000071;
- 对数字进行扩展,不会改变原来的值,但如果是对数字进行截断,则很有可能会改变原来的值;
- 对无符号数进行截断,得到的结果即是截断后剩下的位数的结果;
- 对有符号数(补码)进行截断,其结果分成两步得到,第一步先做无符号截断,然后再使用 U2T 做无符号转补码;,即 U2T(B2U( x mod 2k))
- 无符号数 0 与有符号数 1 相减,即 unsigned 0 - 1,会触发隐式类型转换,首先减去 1 被视为负 -1,得到负1 的补码,然后转成无符号,再与无符号 0 相加,最终会得到一个无符号的 UMax;
- 两个无符号数相减,结果永远大于等于0;因为无符号数的负数的补码,由于仍然是无符号数,所以也是正的;
- 无符号数是某些场合是非常有用的(位的布尔标记,实现模运算和多精度运算等),但由于隐式类型转换的存在,会为错误的发生埋下隐患,得到好处的同时,也要付出代价;除了 C 语言外,绝大多数语言并不支持无符号数;(貌似C++也是支持的)
- 整型数据类型
- 整数运算
- 无符号加法
- 由于绝大多数编程语言使用有限精度的运算(LISP 支持无限精度),使得运算结果超出精度限制时,会产生溢出,溢出的判断标准,加法:如果计算结果小于原始值,则发生了溢出;最后的计算结果为和减去 2w 后的结果;C 语言不会对计算溢出发出警告;
- 模数加法:对两个数相加后进行求模运算,例如 9 + 9 后对 10 求模得到 8;有一个单位元 0,每个元素有一个加法逆元;
- 阿贝尔群:又称为可交换群,满足运算不依赖于其顺序的群,其推广了整数集合的加法运算;其基本研究对象是模和向量空间;
- 代数结构:在一种或多种运算下,封闭的一个或多个集合,例如群,环,域,模,向量空间,格,域代数等;抽象代数即是对代数结构的研究;
- 加法逆元:对于任意的元素 n,存在另外一个元素 -n,使得其与 n 的相加结果为 0;
- 在 C 语言中,由于没有布尔类型,因此返回 True 实际上是返回整数 1,返回 False 实际上是返回整数 0;
- 示例:unsigned sum = x + y;
- return sum >= x;
- 示例:unsigned sum = x + y;
- 补码加法
- 补码表示法由于有符号的存在,当正、负溢出时,结果就会滚到符号的另外一边去了;
- 补码加法与无符号加法的位数表示是相同的,因此补码加法可以通过先将补码转成无符号,相加得到结果后,再转回补码的方式进行;
- 溢出判断:如果 x, y 都大于0,和却小于0,则发生正溢出;如果x, y 都小于0,和却大于0,则发生负溢出;
- int sum = x + y;
- int neg_over = x > 0 && y > 0 && sum < 0;
- int pos_over = x < 0 && y < 0 && sum > 0;
- return !neg_over && !pos_over;
- 补法的非:补码的非,除了 Tmin 是它自身外,其他数的非都是它的加法逆元;
- 无符号乘法:将一个无符号数截断为 w 位,相当于对这个无符号数求 2w 模;
- 补码乘法
- 同无符号乘法,区别在于最后需要将结果转成补码表示;
- 补码乘法和无符号乘法具有位级等价性(其实结果是不同的,但由于存在模 2w 截断,导致最后结果相同)
- 乘以常数
- 如果是乘以 2 的幂,则 x * 2k 可以表示为 将 x 的位模式在右侧增加 k 个 0;
- 无符号数和补码数,与 2 的 k 次幂的乘法,不管是否是否溢出,都等于原数左移 k 位;
- 当乘以常数时,例如乘以 14,可以将 14 拆解为多个 2 的幂的加法来完成,例如 23 + 22 + 21,这样就可以将乘法变成加法来提高计算速度;14 的位模式为 1110,两种计算方式为
- ( x << n) + (x << n -1 ) + …… + (x << m)
- (x << (n + 1)) - (x << m)
- 除以2的幂
- 除以 2 的幂的无符号数除法:等同于逻辑右移,向零舍入(由于没有无符号没有负数,向零舍入与向下舍入貌似效果一样);
- 除以 2 的幂的补码,向下舍入:算术右移;
- 除以 2 的幂的补码,向上舍入:增加偏置量,算术右移;等于给 x 增加 y - 1,这样就可以确保向上舍入了;
- 对于使用算术右移的补码除法:(x < 0 ? (x + (1 << k) - 1) : x) >> k
- 由于 C 语言中有无符号类型,当不小心将有符号与无符号进行一起运算时,会触发强制类型转换,并可能出现预想不到的结果;因此,使用 C 语言中的无符号数时,要非常小心,如果可以的话,尽量不要用;
- 无符号加法
- 浮点数
- 二进制小数
- 对于十进制数,小数点左边的值可以表示为10的正幂,小数点右边的值可以表示为 10 的负幂;
- 对于二进制,10 则替换为 2,左边为2的正幂,右边为2的负幂;小数点左移一位表示整个数乘2,右移一位表示整个数除2;
- 就像十进制不能精确的表示1/3 之样的数,二进制也不能够精确的表示 1/5 这样的数,它只能通过增加位数达到尽量近似的表示;
- 定点表示法:小数点左边的数,用相应的二进制表示;右边的数,则使用二进制分子/2n 来表示
- 缺点:不能很有效的表示非常大的数;例如对于 5 * 2100,需要在 5 的二进数 101 后面,跟上100个零,一般的64位机器都不够用了;
- IEEE 浮点表示
- 格式:V = (-1)s * M * 2E,即将数字转化成近似 x * 2y 的格式来表示实数(优点是可以表示很长的小数位,缺点是越长的时候,精确度下降越多)
- 符号位(sign):s 为 sign 缩写,用来表示正数 (s 为 0) 或负数 (s 为 1);
- 尾数(significand):M 是一个二进制小数,它的范围是 1
2 - ε(规格化值),或者是 01 - ε(非规格化值);- 此处的 ε 是指多少?在数学里面, ε 表示非常小的意思;
- 读音 Epsilon,第五个希腊字母,跟英文的 e 顺序相同,原表示简单的、单一的意思;它的大写就跟英文 E 一模一样了;
- 貌似是用来表示小数段,所以叫二进制小数?
- 此处的 ε 是指多少?在数学里面, ε 表示非常小的意思;
- 阶码(exponent,或许也叫指数):E 的作用是对浮点数加权,这个权重是 2 的 E 次幂(可能是负数);
- 单精度,用8位表示,故E 的取值范围为 -127~127;
- 双精度,用11位表示,故E 的取值范围为 -1022~1023;
- 在不同的数据类型中,符号位总是只用一个位来表示,但尾数和阶码占用的位数则会不同;
- 单精度:阶码 E 使用 8 位,尾数 M 使用 23 位;
- 双精度:阶码 E 使用 11 位;尾数 M 使用 52 位
- 三种情形
- 规格化:指数位不全为 0,也不全为 1;
- 此时 E = e - Bias(使用偏置值来表示,暂时不知道这么做的用意?后来发现是为了实现平滑过渡);
- 假设指数位有 k 位
- e 的位表示为 ek-1ek-2…e1e0,因此,e 是一个无符号数,其取值范围在 1 ~ 2K-1
- Bias = 2k-1 - 1,因此,对于单精度结果为 127,对于双精度结果为 1023;
- 此时 M = 1 + f, 而 f = 0. fn-1 fn-2…… f1 f0,所以最终 M = 1. fn-1 fn-2…… f1 f0
- f 的取值范围在 0 ~ 2-n ~ 1 之间,即 0~(1 - ε) 之间,无限靠近 1,但达不到 1 ;
- 因此 M 的取值范围为 1~(2 - ε )之间
- 此处 M 中的 1 是在规则标准中隐含的,并不需要实体的位进行表示;
- 非规格化:指数位全为 0,小数位不全为 0;
- 此时 E = 1 - Bias
- 仍然使用偏置值来表示 E,但使用了 1 而不是 0,因为这种做法可以保证非规格化数和规格化数之间可以实现平滑过渡;
- 此时 M = f,而不是 1 + f,因此,在这种情况下就有办法表示 0 了(在规格化的情形下,由于 M 总是大于 1,所以表示不了 0);
- 此时 E = 1 - Bias
- 特殊值
- 无穷大:指数位全为 1,小数位全为 0;
- 此时根据符号位的情况,可以用来表示正无穷大和负无穷大;
- 还有一个好处,是当两个非常大的数相乘时,我们可以返回结果,表示得到了 无穷大,能够用来表示出现溢出;
- NaN:指数位全为 1,小数位不为 0;
- 表示得到了一个非数,即 not a number;有某应用中,可以用这个值来表示未初始化的数据;
- 无穷大:指数位全为 1,小数位全为 0;
- 规格化:指数位不全为 0,也不全为 1;
- 注意,最后 V 的结果是 M * 2E,因此注定了分布的不均匀,而是越靠近 0 的位置,能够精确表示的数越多,越远离 0 则越少
- 貌似这也非常合理,因为浮点数本来就是要用来表示小数的;
- 格式:V = (-1)s * M * 2E,即将数字转化成近似 x * 2y 的格式来表示实数(优点是可以表示很长的小数位,缺点是越长的时候,精确度下降越多)
- 数字示例
- 对于 IEEE 浮点表示法
- 当用来表示正数时,所有位表示从无符号数的角度进行解读,它们会呈现升序排列;因此,用于整数排序的函数,同样也可以用于正的浮点数;
- 当用来表示负数时,由于开头有1,因为会呈现降低排列;
- 发现单精度或者双精度,其能表示的最大整数,要比无符号整数类型要大得多,前者为 (2 - ε) * 2127,后者为 (2 - ε) * 21023;而最大的无符号整数仅为 264;
- 不过并不能用它来替代整数类型,因为它们并不能精确表示整数,只能近似;
- 对于 IEEE 浮点表示法
- 舍入
- 四种舍入方式
- 向偶数舍入:出现中间值时,确保舍入结果的最低有效位为偶数;好处:可以用来避免统计偏差;
- 注意,是最低有效位,因此这个方法也可以用于小数的舍入;
- 向零舍入:正数向下,负数向上;
- 向下舍入
- 向上舍入
- 向偶数舍入:出现中间值时,确保舍入结果的最低有效位为偶数;好处:可以用来避免统计偏差;
- 四种舍入方式
- 浮点运算
- 浮点加法不具有结合性;因为存在舍入,所以先计算哪部分将导致不同的结果;但浮点加法可交换,且有逆元(无穷和 NaN 除外);
- 浮点加法具有单调性,即对于任意 a >= b,x + a >= x + b 仍然成立;
- 浮点乘法也不具备可交换性;原因;可能发生溢出,或者由于舍入导致失去精度;
- 浮点乘法也不具备可分配性;原因:同上;
- C 语言中的浮点数
- 由于 C 语言没有要求使用 IEEE 浮点表示法,因此缺乏标准的方法来改变舍入方式,或者得到无穷和 NaN 值;
- 小结:必须非常小心的使用浮点运算,因为它不具备整数运算的结合律;
- 二进制小数
- 信息存储
- 程序的机器级表示
- 综述
- 通过了解程序的机器代码表示(阅读汇编代码),能够更容易的发现程序的性能瓶颈;
- 程序编码
- GCC 工作过程
- 预处理器插入文件 -> 编译器将源码转成汇编代码 -> 汇编器将汇编代码转成机器代码 -> 链接器连接机器码使用到的库文件,生成可执行文件;
- 疑问:源文件引用了多份其他文件,它们是单独编译后链接,还是插入源文件一起编译?答:单独编译后再链接;
- 编译器生成的 .o 文件,源代码被转换成了目标代码二进制文件,但还是还未填入全局内存地址
- 好奇什么时候填入呢?答:链接的时候填入;
- 链接器的任务之一,是为函数调用找到匹配的函数的可执行代码的地址;
- 预处理器插入文件 -> 编译器将源码转成汇编代码 -> 汇编器将汇编代码转成机器代码 -> 链接器连接机器码使用到的库文件,生成可执行文件;
- 机器级编程提供了两种重要的抽象
- 指令集架构:提供了机器级程序的格式和行为,定义了包括处理器的状态、指令的格式、指令对状态的影响等;
- 虚拟内存:机器级程序使用的内存是虚拟内存,它提供了一个非常庞大的专用的字节数组;(操作系统会负责将虚拟地址映射到实际的内存地址中)
- 寄存器
- 计数寄存器:用来保存待执行的下一条指令的内存地址;
- 这里的内存地址貌似应该是虚拟内存的地址?答:是的
- 整数寄存器:用来保存地址或整型数据;(据说有16个命名的位置)(从%rax 到 %rap)
- 根据地址使用长度的不同,同一个地址有不同的名称;
- 条件码寄存器:用来保存最近执行算术或逻辑指令的状态信息,用来实现条件控制;
- 向量寄存器:用来存储一个或多个的整数或浮点数值;(与整数寄存器的具体区别是什么?貌似用于 SSE 流计算,即单条指令批量处理多个数据)
- 思考
- 寄存器或许应该也算是存储系统的一部分,假设整个程序由指令+数据组成的话,那么指令和数据都需要有存储的空间,在这个存储空间中,每条指令和数据,都有相应的地址;指令的执行是有顺序的,那么它最终反映到程序中,即翻译成的机器代码,每执行完一条指令,要如何才知道下一条指令在哪里?需要有一套机制,让每条指令执行完毕后,能够知道下一条指令的地址;或许,指令的地址,本身就是一种写死的结果?它根本就不需要寻找,而是写死在代码中的?貌似有点道理的样子;只要内存中有一部分用来存储指令的内容,而且它们的地址在整个程序运行的期间不发生变化,那么指令的地址写死在机器代码中貌似就是可行的;但是需要有另外的位置存储状态信息,这样在程序执行过程中,可以根据状态信息做分支管理;
- 答:实际上通过虚拟内存 + 物理地址翻译的机制来实现;每个应用程序的编译的时候,使用虚拟内存进行编址;
- 每执行一条指令的时候,CPU 都会计算下一条指令的地址,并存入程序计数器;同时整数运算会设置条件码寄存器,用于分支判断;
- 实际情况:绝大多数指令是顺序执行的,因为下一条指令的地址,即计数寄存器的值+1;如果不是顺序执行,则当前指令需要给出下一条指令的地址;(当前指令不一定给出下一条指令的地址,但是可以从指令类型计算出来,或者直接得到,例如 jmp/call 等指令)
- 寄存器或许应该也算是存储系统的一部分,假设整个程序由指令+数据组成的话,那么指令和数据都需要有存储的空间,在这个存储空间中,每条指令和数据,都有相应的地址;指令的执行是有顺序的,那么它最终反映到程序中,即翻译成的机器代码,每执行完一条指令,要如何才知道下一条指令在哪里?需要有一套机制,让每条指令执行完毕后,能够知道下一条指令的地址;或许,指令的地址,本身就是一种写死的结果?它根本就不需要寻找,而是写死在代码中的?貌似有点道理的样子;只要内存中有一部分用来存储指令的内容,而且它们的地址在整个程序运行的期间不发生变化,那么指令的地址写死在机器代码中貌似就是可行的;但是需要有另外的位置存储状态信息,这样在程序执行过程中,可以根据状态信息做分支管理;
- 计数寄存器:用来保存待执行的下一条指令的内存地址;
- x86-64 的指令长度从1-15个字节不等;常用的较短,不常用的较长;
- 指令以某个特定的值开头,例如53,同时它还代表了指令的长度格式,这也意味着在它的长度之后,才是第二条指令的开始;
- 但 CPU 取指的时候,是按固定长度取的,译码后会将多余的部分舍弃;
- 指令末尾的 q 表示长度指示符(不同数据类型的字节长度),在大多数情况下可以省略;
- 为什么需要用 q 来表示长度?莫非是用来操作数据用的?
- 指令存储在哪里?印象中一开始也是在程序代码块中,之后是否单独加载到指令寄存器中?
- 貌似没有指令寄存器,而是使用计数寄存器;
- 答:指令就是代码,从磁盘加载后,它存储在内存中;
- 正常情况下一条指令应该是要由四部分组成的:操作码、操作数地址、操作结果存储地址、下条指令地址;为了压缩指令的长度以节省空间,其中的下条指令地址,转交给了计数寄存器进行管理
- 那它是如何实现管理的呢?
- 答:正常情况下,指令是按顺序执行的,因此每条指令的地址是挨着的,因此下一条指令的地址,就是当前指令在计数寄存器中的地址加1;这就是计数寄存器的名称由来;当出现条件分支时,就会用指令中的地址替换刷新计数寄存器中的地址;这么一来,对于条件判断的指令,它的内容中的下条指令地址就是必须的了;
- 操作码对应具体操作,而每个具体的操作,又决定了需要的地址数量,共有五种常见情况
- 三地址:前两个分别是第一操作数和第二操作数地址,第三个是结果存储地址;
- 双地址:第一个是第一操作数地址,第二个是第二操作数地址兼结果存储地址;
- 单地址:第一操作数地址;在指令操作码中暗含第二操作数和结果存储地址;
- 零地址:在指令操作码中暗含了三个地址(一般使用在堆栈型计算机中,用堆栈顶部的两个单元作为第一操作数和第二操作数;堆栈顶做为结果存储地址);
- 可变地址:根据操作码的情况,匹配有多个地址;
- 貌似将机器代码加载到寄存器的时候,不是一条一条加载的,而是以16字节为一个单位进行加载的,有待确认一下;
- 如果是真的,那么就可以翻译编译有时候会额外插入一些多余的无效指令,以凑成16个字节了;(其实也不用插入多余的空指令,CPU 可以从当前指令计算出下一条指令在哪里)
- 那它是如何实现管理的呢?
- 指令以某个特定的值开头,例如53,同时它还代表了指令的长度格式,这也意味着在它的长度之后,才是第二条指令的开始;
- 生成可执行文件时,需要对一组目标文件使用链接器进行链接,并要求这组目标文件中,至少一个文件包括一个 main 函数;
- 如果没有 main 函数,生成的估计就不叫做可执行文件,而叫做库文件了;
- 编译过程中,最后一步的链接动作,它的工作就是将编译后的目标文件,与静态库或者动态库建立链接;这样程序就可以正常运行了, 因为源码中用到的库的函数,由于有了链接,就可以找到函数地址并执行;
- 有趣的是,据说如果生成的机器代码不足16字节,还会在末尾加上 90(nop)来凑够16字节,即以16个字节为一个处理单位;
- 为什么一定要16字节呢?难道是因为指令的长度为1-15个字节?答:为了减少从内存读写的次数,统一按固定的单位进行读写;
- 汇编代码有多种表述的格式,包括AT&T,Intel,Microsoft 等;
- 反汇编器可以用来将机器代码的文件,转换成较为方便阅读的汇编代码格式;
- 刚发现 GCC 支持在 C 源代码中嵌套汇编代码,以实现对机器一些低级特性的访问;另外还可以通过汇编代码编写整个函数和文件,然后在链接阶段,将它们与 C 的代码链接起来使用;
- GCC 工作过程
- 数据格式
- 由于最早是从16位衍化到64位的原因,当时16位称为一个字(word,Intel 的术语,两个字节等于一个字),所以现在32位只好称为双字,64位称为4字;这些字的单位,是用来表示数据类型的长度的;但现在更通用的说法,貌似不是使用字表示,而是使用字节来表示数据类型的长度;但是这个“字”的单位,仍然在汇编代码中沿用;在汇编代码最后一个字符的后缀中,经常使用字来表示操作的数据长度;
- 数据格式跟指令格式是不同的;
- 不同的数据类型,有不同的数据长度,在汇编指令中使用不同的后缀
- char -> b, short -> w, int -> l, long -> q, char * -> q, float -> s, double -> l
- byte, word, long word, quad word, single, long single;
- 字节,字,双字,四字,单精,双精;
- movb, movw, movl, movq 分别表示传送1个字节,1个字,2个字,4个字
- 字母 L 的小写同时用来表示4字节的整数和8字节的双精度,但由于浮点数的指令和寄存器与整数完全不同,因而不会产生歧义
- 由于最早是从16位衍化到64位的原因,当时16位称为一个字(word,Intel 的术语,两个字节等于一个字),所以现在32位只好称为双字,64位称为4字;这些字的单位,是用来表示数据类型的长度的;但现在更通用的说法,貌似不是使用字表示,而是使用字节来表示数据类型的长度;但是这个“字”的单位,仍然在汇编代码中沿用;在汇编代码最后一个字符的后缀中,经常使用字来表示操作的数据长度;
- 访问信息
- x86-64 位的 CPU 的整数寄存器模块中(用来存储整数和指针),有16个寄存器,每个可以存储64位,编号分别为从 %rax 到 %rbp,另外还有8个为 %r8 到 %r15;
- 早期只有8个寄存器,编码是从 ax 到 bp;
- rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8, r9, r10, r11, r12, r13, r14, r15;
- 后缀 x, i, p 不知是否有什么含义?p 貌似是指针的意思;
- sp 貌似指栈指针;用来指明运行时栈的结束位置?
- %r 表示寄存器的意思(r 估计应该代表寄存器的单词 register)
- 每个寄存器有64个位;整数寄存器模块中有16个寄存器,意味着这个模块总共有 16 * 64 = 1024 位可以用,8位为1个字节,因此貌似有 128 字节;
- 由于这16个寄存器各有自己的编号,所以当和指令的操作码配合使用时,感觉好像将它们当作指针在操作的感觉;更有意思的是,为了实现历史兼容,每个寄存器的全64位、低32位,低16位,低8位,都有自己的名字;也就是每个寄存器实际上有4个名字,分别代表它身上不同长度的部位;
- 早期只有8个寄存器,编码是从 ax 到 bp;
- 汇编代码中的操作数指示符 operand:用来指出一个操作中,需要使用到的源数据值,以及结果的存放位置;
- 源数据值通常使用常数给出,也可以是从内存或寄存器中读取;
- 三种操作数类型
- 直接数(立即数,表示常数值)immediate,用 Imm 表示
- 寄存器(引用,表示某个寄存器中的内容) register,用 R[r] 表示其值
- 内存(引用,表示内存中的内容)memory,用 M[addr] 表示其值;
- 寻址模式的计算公式为 Imm + R[rb] + R[ri] * s,用来计算操作数的值;
- 有立即数寻址,寄存器寻址,直接寻址,间接寻址、变址寻址,比例寻址等类型
- 不同的类型对应不用的格式,包括
- $Imm, ra, Imm, (ra), Imm(rb), (rb, ri), Imm(rb, ri), (, ri, s), Imm(, ri, s), (rb, ri, s), Imm(rb, ri, s)
- 最常用的格式是 Imm(rb, ri, s),其他格式都是这个格式的特殊情况;
- 以上都是汇编代码的格式,好奇这个格式在机器代码中是如何体现的呢?是否直接翻译成了绝对地址?还是怎么处理?
- 答:汇编的格式和机器指令的格式基本一样,只是汇编中的符号,替换成了二制制的数字和地址;
- $Imm, ra, Imm, (ra), Imm(rb), (rb, ri), Imm(rb, ri), (, ri, s), Imm(, ri, s), (rb, ri, s), Imm(rb, ri, s)
- 规律:
- r 和 s 只能写在括号里面
- Imm 如果不直接用,则用来做加法的;
- rb 用来加,ri 和 s 用来乘
- Imm 带美元符,直接用其值,否则是指内存
- ra 在括号外面是寄存,在括号里面是内存;
- 凡有带括号,都是内存,即使用间接寻址;(其实只有两种不是内存)
- 数据传送指令
- 数据传递指令有很多种,它们可以分成不同的类;每一类执行相同的操作,只是操作数大小不同(例如有些是单字,有些是双字);包括 MOV 类,MOVZ 类,MOVS 类
- MOV 类:不改变目的位置的高位(除了 movl 外)
- MOVZ 类:源小目大;目的位置高位进行零扩展:即高位置0;目的地仅限寄存器,不能是内存;
- MOVS 类:源小目大;目的位置高位进行符号扩展:即高位置为符号位的复制;目的地仅限寄存器,不能是内存
- 在 X86-64 中,不允许将数据从内存移动到内存,需要先到寄存器中转后,才能到内存,即需要两条指令才能完成(暂时不知道这么做的原因是什么?现在知道了,为了让指令做流水线化处理,在一定数量的时钟周期内处理完毕,同时避免依赖)
- movl 有点例外,它在传送双字的时候,会把高位设置为 0;其他几个 movb, movw, movq 都不会有这种效果,即不会对高位做额外的操作;
- movq 由于是操作四字的数值,因此它的源操作数的立即数只能是4字节32位表示的补码数字;而 movabsq 则能以8字节64位的立即数做为源操作数;
- MOV 类是长度不做变化的移动,如果源和目的的长度不同,需要使用扩展类的指令;除非源为直接值;
- 当源为直接值,且使用 movabsq 时,相当于初始化的动作了;
- rax -> eax -> ax -> al ,分别对应 64位 -> 32位 -> 16位 -> 8位
- MOVZ 和 MOVS 类的指令,最后两个字符分别用来表示源和目的的大小;
- 例如 movzbw,mlvsbw 等;
- 还有一条特殊的指令是 cltq,它的特殊之外在于没有源操作数,也没有目的操作数,这两个操作数是暗含的固定值,源是 %eax,目的是 %rax,使用符号扩展的方式;
- 突然发现,操作码最后表示长度的字符,跟操作数的位数需要是匹配的
- 示例:movl %rax, (%rsp),在这条指令中,操作码 movl 最后一个字符是 l ,表示它要移动双字长度的数据,但 %rax 却是四字长度的地址,这样会产生不匹配的错误,因此,要么将操作码改成 movq,要么将第一操作数 %rax 改成 %eax;
- 另外目的地址的长度也是需要跟操作码表示的长度匹配,例如操作码的目的长度为 q,则目的操作数的长度不能小于 q;因此例如 movl %eax, %rdx 是错误的,因为操作码要移动双字,但目的操作数 %rdx 是四字的;
- 貌似以上规则只适用于寄存器,不适用于内存;因为内存地址永远都只有一种表示方式和长度,不像寄存器不同位长度还有不同的名字;
- 内存寻址模式中,使用的都是四字长度的寄存器名称;例如应为 (%rbx) 而不是 (%ebx)
- 什么时候应该使用零扩展,什么时候应该使用符号扩展?
- 总结:源值有符号就使用符号扩展,源值无符号就使用零扩展;
- 当源值有符号,使用符号扩展可以使得符号得到保留;使用零扩展可能改变源值;
- 当源值无符号,使用零扩展会保留源值;使用符号扩展可能会改变源值;
- 只有源小目标大的时候,才需要扩展,如果是一样大,或者源大目标小,则不需要使用扩展,直接移动;
- 总结:源值有符号就使用符号扩展,源值无符号就使用零扩展;
- 数据传递指令有很多种,它们可以分成不同的类;每一类执行相同的操作,只是操作数大小不同(例如有些是单字,有些是双字);包括 MOV 类,MOVZ 类,MOVS 类
- 数据传送示例
- C 语言中的指针,其实就是地址,它是一个整数值;我们可以把它存储在寄存器中,假设为 %rax;所谓的指针解引用,其实就是使用这个整数值,去访问对应的内存地址,例如 (%rax),并进行相应的操作,例如将数据移入;
- 压入和弹出栈数据
- 据说栈在函数调用过程中起到至关重要的作用,很好奇它是如何发生的;
- 栈并不让人陌生,它只是一种抽象数据结构,只在一端写入和弹出,遵循先入后出的原则;
- 栈存储在内存中的某个区域,然后由于它是向下增长,所以越新的元素,它的地址反而越低,越早的元素地址越高;
- 如何控制栈的大小?
- 新的指令
- pushq, popq:将操作数压入或弹出栈;只有一个操作数,压入时代表源,弹出时代表目的地;push 暗含目的地址存储在 %rsp(这个寄存器条目通常用来存储栈指针)
- subq:减,两个操作数,第一个表示要减少的值,第二个表示被减少的值;
- 所谓的弹出,其本后真正的动作并不是把栈顶数据擦除,而只是改变栈顶指针到了原栈顶元素之后的第二个元素;原栈顶位置的元素,只要没有被其他操作覆盖,其里面存储的值会一直在;
- x86-64 位的 CPU 的整数寄存器模块中(用来存储整数和指针),有16个寄存器,每个可以存储64位,编号分别为从 %rax 到 %rbp,另外还有8个为 %r8 到 %r15;
- 算术和逻辑操作
- 共有四种操作类型,分别为加载有效地址、一元操作、二元操作、移动操作;
- 所谓的加载有效地址操作,它其实并不是去引用内存的值,而只是取得内存地址,然后存放到寄存器中;指令格式为 leaq S, D
- 这个指令非常有欺骗性,例如假设 x 在 %rdi,那么 leaq 7(%rdi) %rax 的真实意思是先计算出 %rdi + 7 的值,假设为 tmp,然后并不到内存中去引用 M[tmp],而是直接将 tmp 写入到 %rax 中;这个指令表示上只是移动数据,但它间接使用了一些计算,因为寻址模式会自动触发一些运算;
- 一元操作只有一个操作数,这个操作数既是源,也是目的;它可以是一个寄存器,也可以是一个内存位置;
- 指令包括: INC, DEC, NEG, NOT
- 二元素操作有两个操作,其中第二个操作既是源也是目的;
- 指令包括:ADD, SUB, IMUL, XOR, OR, AND
- 移位操作:
- SAL:算术左移,低位补0;
- SHL:逻辑左移,效果同SAL,不知为何要重复两个一样功能?莫非只是为了对称?
- SAR:算术右移;
- SHR:逻辑右移;
- 注意,加载“移位量”参数到寄存器后,固定使用%rcx 寄存器,并且在访问时,只访问其最低位字节里的内容,即 %cl;
- 特殊的算术操作
- 64位的乘法和除法指令:imulq, mulq, cqto, divq, idivq;
- 两个64位数的乘法,结果会达到128位,因此,可以通过将结果拆分成两段存储,来间接实现效果;其中的低位段存储乘积的64位取模,高位存储乘数即可;
- 128位除法实现原理:也是将数据拆分成高位和低位两段,分别存储在两个寄存器中,然后进行运算;
- 对于除法,多数64位的除法并不会涉及到128位,所以高位的寄存器一般全部置0或者置为符号位;运算指令同128位;
- 复制符号位时,会用到一条特殊的指令 cqto,它没有显式的操作数,而是隐式读取 %rax 的符号位,然后复制到 %rdx 的所有位上面;
- 控制
- 正常情况下,指令是按顺序执行的;但实际编程中,根据条件会有代码执行的分支;机器代码的机制为:测试数据值,然后根据测试结果改变控制流或者数据流;在汇编中,可通过 jump 指令来实现指令执行顺序的跳转;
- 条件码
- 条件码存放在单个位的寄存器中(这么说,这些寄存器都很小?答:是的)
- 常用的条件码有:
- 进位 CF,carry flag
- 零位 ZF,zero flag
- 溢出 OF, overflow flag
- 符号 SF, symbol flag
- 除了加载地址的指令外,几乎所有的算术和逻辑指令,据说都会设置条件码(猜测是因为它们都有可能产生各种需要判断的情况)
- 还有一些指令专门改变条件码,但不改变其他寄存器,例如 CMP 比较系列和 TEST 测试系列;
- CMP 系列指令的行为同 SUB 减法指令,区别在于它只改变条件码,不改变其他寄存器中的内容;
- TEST 系列指令的行为同 ADD 加法指令,区别在于它只改变条件码,不改变其他寄存器中的内容;
- 访问条件码
- 条件码一般不直接读取,常见有三种使用方式:
- 根据条件码组合,将某个字节置为0或1,即 SET 系列指令;
- SET 指令的后缀不是表示操作长度,而是表示条件码组合类型;例如 setl 表示 set less,setb 表示 set below;
- 有些有符号数和无符号数的 SET 指令是一样的,但也有一些 SET 指令对应单独的有符号或者无符号版本,根据操作数的长度,加上符号类型,可以大致推断操作数的原始类型;
- 根据条件码跳转到程序的某个部分;
- 根据条件码传送数据;
- 根据条件码组合,将某个字节置为0或1,即 SET 系列指令;
- 条件码一般不直接读取,常见有三种使用方式:
- 跳转指令
- 汇编代码中,使用标记(label) 来指示要跳转的位置(感觉有点像 C 语言中的 GOTO),然后汇编代码转成目标代码时,跳转语句中的标记会被替换成跳转的目标位置的地址;
- 跳转指令 jmp 可以以寄存器地址为跳转目标,也可以以寄存器中存储的内存地址的解引用内容做为跳转目标;前者叫做直接跳转,后者叫做间接跳转;
- 除了 jmp 是无条件跳转外,其他跳转指令都是带条件的,而且它们只能是直接跳转;条件后缀的格式同 set 指令;在跳转前会判断条件,只有当条件码满足时条件要求时,才会跳转;
- 大于和超过,小于和低于之间的区别是什么?区别莫非在于操作数的顺序不同?
- 跳转指令的编码
- 跳转指令的编码有两种
- 一种是相对编码(常用),以目标地址和当前下一条指令的地址的差,做为跳转目标;
- 使用时,将跳转目标的值,加上下一条指令的地址值,即可得到目标地址的值;
- 事实上,它也关联了计数寄存器的设计,因为计数寄存器中,刚好也存着下一条指令的地址;
- 另一种是绝对编码,以4个字节(32位)来表示目标地址的绝对值;
- 一种是相对编码(常用),以目标地址和当前下一条指令的地址的差,做为跳转目标;
- 相对于绝对编码的方式,相对编码的好处是写法更简洁,而且当我们将目标代码拷贝到内存中的不同位置时,程序也能够不受影响的正常运行,非常方便;
- 突然明白为什么32位的机器最大内存只能有4个G了,因为 CPU 的计数器用来存储地址时,只有32位,那么意味着它最大能够存放 4G 对应的地址长度;
- rep ret 的指令组合是一种空操作,用来避免 ret 成为条件指令的跳转目标;它可以让代码更加安全(AMD 的设计)
- 刚发现编译器的作者需要直接跟 CPU 的指令集打交道;不知这中间是否有一层抽象,让编译器作者可以在更高的抽象层次上面工作?有,这一层抽象就是指令集架构 ISA;
- 上一条指令和下一条指令的间距,跟上一条指令的编码长度有关,即上条指令假设使用了2个字节来编码,则下条指令的地址在当前地址加2;如果使用3字节,则在当前地址加3;
- 刚发现小端法,不是指单个字节内部的小端,而是整个类型多个字节顺序的小端;
- 跳转指令的编码有两种
- 用条件控制来实现条件分支
- 对于 C 语言中的 if-else 条件分支,汇编代码中的处理办法是针对 if-else 的两个部分,各产生对应的代码块,然后在代码块之间插入条件分支,根据条件判断结果,进行分支的跳转;很像 C 语言中的 goto 风格;
- 刚发现 C 语言中的短路求值,原来跟编译器生成的代码有关系,对于 C 语言中的复合判断语句,在汇编代码中,其实有对应多个判断指令,当某个判断指令结果不通过时,就会直接跳转到结果指令,从而实现了短路;
- 对于 cmp 指令,操作数的顺序,与后续的 jump 指令的条件后缀,是相反的关系,例如
- cmpq $-3 %rdi
- jge .L2
- 此处 ge 表示当 -3 大于等于 %rdi 时,所以,编译成 C 语言时应为 %rdi < -3 或者 -3 >= %rdi 时;
- 用条件传送来实现条件分支
- 条件控制机制的优点是简单且通用,缺点是低效,因为这是单线程的;条件传送机制则可以通过并行弥补缺陷,但使用的场景有限;
- 条件传送机制会计算所有的分支,最后根据条件判断,传送其中一个分支的结果;由于现代处理器采用“流水线”机制,实现了多个指令的并行执行,因而条件传送有可能获得更高的性能;
- 流水线机制:一条指令的执行,被拆分为多个步骤,包括从内存读取指令(取指),确定指令类型(译码),从内存读取数据(访存),执行算术运算(执行),向内存写入数据等(写回);不同指令之间,这些步骤是大致相同的,因此,通过批量处理多条指令的同一个步骤,就间接实现了指令级的并发;
- 在条件控制机制下,处理器会尽量精密的预测哪个分支被执行的概率更大,然后挑选一个,按流水线批量处理;但它的判断有可能不对,这个时候,批量处理的东西没用,需要更改位置,做下一批的处理,前面的预处理就浪费了;
- 在条件传送机制下,处理器则根本不用判断,直接批量处理指令的各个步骤即可,从而在平均水平上,实现了更高的性能;
- 条件传送指令固定有两个操作数,分别代表源和目的地,后缀同 SET 和 JMP 系列,但它不支持单字节的传送,只支持16位,32位和64位的传送;
- 无条件传送的指令,操作数的长度做为指令的后缀;但有条件传送的后缀则没有指定长度,怎么办?答:汇编器通过目的地操作数的名字,来推断长度;因此,不管操作数的长度是多少,有条件传送的指令名称都是一样的;
- 条件传送的局限性:
- 只能使用在条件分支不会产生副作用的场景下;
- 如果每个分支都需要大量的计算,则并行处理有可能并不划算;
- 循环
- do-while 循环
- 理解汇编代码和原始代码之间的关系,重点是找出变量值和寄存器之间的映射关系;
- 逆向工程通用策略:
- 循环之前如何初始化寄存器;
- 循环中如何更新寄存器;
- 循环后如何使用寄存器;
- 结构
- loop:
- body-statement
- t = test-expr;
- if(t) goto loop;
- loop:
- while 循环
- 一般有两种翻译方式,一种是跳到中间(jump to middle), 一种是 guarded-do,即先检测条件,当条件符合的时候,再进入循环
- 当使用较高优化等级的时候,编译器会使用第二种策略;
- 一般有两种翻译方式,一种是跳到中间(jump to middle), 一种是 guarded-do,即先检测条件,当条件符合的时候,再进入循环
- for 循环
- for 循环其实可以翻译成 while 循环,同样可以有两种翻译方式,取决于优化的等级;
- 循环中的 continue 同样使用 goto 来实现,区别在于,应该跳转到条件更新的语句,而不是直接重新开始循环,遗漏更新条件,否则可能会导致无限循环;
- do-while 循环
- switch 语句
- 使用跳转表来实现;
- GCC 引入了一个新的符号 && 来获取代码块的地址,做为跳转表的元素;使用的时候,通过数组下标获得地址,然后按地址进行跳转;
- 重复的情况使用相同的代码标号;
- 缺失的情况使用默认标号;
- 使用星号表示间接跳转,示例:jmp *.L4(, %rsi, 8);
- 过程
- 实现机制:假设调用者为 P,被调用的函数为 Q
- 传递控制:将程序计数器设置为 Q 的起始地址;当调用结束后,计数器重新设置为 P 下一条指令的地址;
- 在这个过程中,会为 Q 新开一个栈,栈顶存储着 P 下一条指令的地址;
- 印象中 CPU 内部还自己维护了一个栈,用来快速获取 ret 指令的返回地址;
- 出栈的时候,会将栈顶的地址弹出,并相应修改计数器的值,以便跳转到原来 P 中断处的下一条指令继续完成 P 的执行;
- 在这个过程中,会为 Q 新开一个栈,栈顶存储着 P 下一条指令的地址;
- 传递数据:P 能够向 Q 提供一个多个的参数,Q 能够向 P 返回一个值;
- 分配和释放内存:调用 Q 的时候,为其局部变量分配空间;调用结束后,释放这些空间;
- 传递控制:将程序计数器设置为 Q 的起始地址;当调用结束后,计数器重新设置为 P 下一条指令的地址;
- 运行时栈
- 多数语言主使用栈数据结构,来实现传递控制、传递数据、分配和释放空间等工作
- 通过在栈里面存储相关的信息来实现,例如返回地址、参数值,局部变量值、寄存器值等;
- 然后在机器代码中,去读取这些栈中保存的信息,实现过程的控制;
- 感觉每个栈桢很像当前运行状态的一个快照;看来寄存器需要和内存紧密配合,才能完成复杂的控制;
- 由于内存速度比寄存器慢得多,有没有可能将栈放置在处理器的多级缓存中?不过貌似这个缓存也不够大;
- 一般栈地址从高地址向低地址的方向进行增长;每个调用过程占用地址中的一个段,这个段叫做栈桢;
- 当过程所需的存储空间超过寄存器的存储限制时,就会触发在栈上分配新空间;
- 当使用完这个分配的空间后,通过栈顶的返回地址跳回调用前的位置,实现栈的销毁;实际上栈的数据并没有销毁,但由于栈顶的位置变化了,所以相当于被销毁了;
- 栈顶的地址,是存储在寄存器 %rsp 中的;当跳转回调用函数 P 的下一条指令时,应该如何修改 %rsp 的值,以便让其指向 P 的栈顶(如有)?
- 好奇在分配新栈的时间,里面很有可能是存着以前的旧数据的,那么是否需要一个初始化的动作,将旧数据清空销毁掉?还是说,有写入直接覆盖,没写入当作没有使用?
- 不需要销毁,首先栈桢只分配占用所需空间,那么意味着空间利率为100%;所以每个内存地址里面的内容都会被改写,从而实现覆盖;
- 当使用完这个分配的空间后,通过栈顶的返回地址跳回调用前的位置,实现栈的销毁;实际上栈的数据并没有销毁,但由于栈顶的位置变化了,所以相当于被销毁了;
- 大多数过程的栈桢都是定长的
- 意思是在过程开始时,整个栈有多大,就已经定下来了,在过程中间,整个栈的大小是不会变化的;
- 每个过程只会分配自己所需要的空间,以提高内存使用效率;有些简单的过程甚至都不用分配内存空间,直接在寄存器中就可以完成操作;
- 好奇什么情况下不是定长的?
- 栈溢出是否跟这个定长规则有关?
- 并不是每个函数调用都需要使用栈桢,例如只要它的参数少于6个,就可以通过寄存器实现数据传递;或者它不需要在过程中调用其他函数;
- 多数语言主使用栈数据结构,来实现传递控制、传递数据、分配和释放空间等工作
- 转移控制
- 通过指令 call,将程序计数器(PC, program counter) 设置为被调用函数的地址;并将当前过程下一条指令的地址 A 放入到栈顶;这样当 Q 调用结束时,可以使用指令 ret 从栈中弹出地址 A,并设置 PC 中的值为 A,实现返回控制的效果;
- 如果 ret 是用来弹出地址,那返回值应该如何处理呢?
- 调用很像跳转,通过标号(直接跳转)或者操作数指示符(间接跳转)来指定新的位置;
- 通过指令 call,将程序计数器(PC, program counter) 设置为被调用函数的地址;并将当前过程下一条指令的地址 A 放入到栈顶;这样当 Q 调用结束时,可以使用指令 ret 从栈中弹出地址 A,并设置 PC 中的值为 A,实现返回控制的效果;
- 数据传送
- 大多数情况下,函数调用使用寄存器来实现参数传递;即将被调用函数 Q 需要的参数复制一份放在寄存器中,这样 Q 的过程开始后,就可以直接使用寄存器的这些参数了;当 Q 有返回值的时候,也同样使用寄存器将返回值传递给 P 使用;
- 不过寄存器最多只能用来传递最多6个的整形参数(整数或者指针);超过的部分,只能通过栈来传递;
- 通过栈来传递参数时,所有的数据大小都按8的位数对齐(8个字节,64位);
- 假设函数有 n 个参数,则其中的 7 ~ n 号参数存储在栈桢中;
- 栈桢中数据的存储数据为(从顶到底方向,也即从低到高方向):返回地址(即栈顶)、参数构造区,局部变量,被保存的寄存器;
- 在参数构造区中,7 到 n 号参数的顺序按从顶到底的方向排列;因此,可以通过栈顶指针 + 8 的倍数偏移,来实现对参数的访问;
- 由于参数大小有64位的限制,猜测对于容器类型数据(例如数组或结构),参数区域实际存储的是指针,数据实际存储在局部变量区域中;
- 使用寄存器传递参数,是有顺序要求的,即参数的位置和寄存器,是一一对应的;
- 栈上的局部存储
- 通过减少 %rsp 的值,就可以实现栈的分配;通过增加 %rsp 的值, 就可以实现栈的销毁;(因为内存地址分配按从高到低的顺序)
- 如何知道应该增加多少和减少多少?
- 假设已经知道增加多少,那么在返回过程时,是否直接写死应掉减去多少?
- 经查询,发现栈的创建和销毁,是直接写死在机器代码中的;通过 sub 和 add 指令直接操作 %rsp 的值来实现;
- 通过减少 %rsp 的值,就可以实现栈的分配;通过增加 %rsp 的值, 就可以实现栈的销毁;(因为内存地址分配按从高到低的顺序)
- 寄存器中的局部存储空间
- 为避免被调用函数,覆盖了调用函数在寄存器中保存的数值,对于寄存器的使用,是有惯例的;其中有部分被划分为被调用者保存的寄存器(%rbx, %rbp, %r12~%r15),调用过程中不得更改;如需要更改,则需要保存一份到栈中,调用结束后,从栈中弹出,将寄存器还原成原来的值;
- 除了被调用者保存的寄存器以及栈指针之外的其他寄存器,都分类为调用者保存的寄存器,即对于这些寄存器,调用者自身需要承担保存的责任,而不是由被调用者进行保存;
- 这些寄存器是公用的,所以下一个被调用的过程也会使用它们,因此,在进入下一个过程之前,调用者本身应该先自己保存好这些寄存器里面的值(如果需要的话)
- 递归过程
- 由于每次新建的栈桢,都可以有自己的局部存储保存私有状态信息,因此它可以做到每次调用之间都不会相互干扰,从而能够实现递的过程;
- 实现机制:假设调用者为 P,被调用的函数为 Q
- 数组分配和访问
- 基本原则
- T A[N],N 个 T 类型的元素,假设每个 T 类型元素的长度为 L,则总长度为 L * N;
- 通过 (Rb, Ri, s)的寻址模式即可访问所有元素;原因:比例因子 s 为 1, 2, 4, 8,刚好足够覆盖所有的基本类型;
- 指针运算
- 指针运算会根据数据元素,自动按类型的长度进行等比换算(即按比例自动伸缩),例如对于 int a[10], a[3] 的指针运算表达式为 x + 3*4;
- 指针类型本身长度为8个字节;
- 嵌套的数组
- 整体原则同普通数组,使用行优先的模式;可以通过 A[i][j] 来访问第 i 行第 j 列的元素;
- 定长数组
- 对于定长数组,编译器可以实现优化,例如通过变换为指针引用,以及将 for 循环变换为 while 循环,来简化实现过程;
- 变长数组
- C99 引入了对变长数组的支持,例如
- int var_ele(long n, int A[n][n], long i, long j) {…}
- 在这个函数声明中,多了一个参数 n,而且它同时做为数组的长度参数,意味着不同的 n 时数组的长度是跟着变化的;
- 这也使得在汇编代码中,对于数组地址索引的计算,不再使用定长数组的固定值递增法,而是使用乘法(对于有些处理器,乘法会带来性能的惩罚);
- 不过有些编译器,会将步长提高算好存起来,这样就可以递增这个算好的步长,从而实现避免使用乘法;
- C99 引入了对变长数组的支持,例如
- 基本原则
- 异质的数据结构
- 结构
- 编译器负责维护结构成员的类型信息,指示每个成员的字节偏移量;因此,结构内部成员的访问,可以通过结构指针加上偏移量来实现;
- 结构各个字段的访问完全是在编译阶段进行处理的,在机器代码中,将只剩下地址,不再包含结构字段的类型和名字等信息;
- 联合
- 在一定的条件下,使用联合可以节省空间(不过感觉大部分情况下这种节省没必要,因为多数时候空间不是问题);
- 另外联合还可以用来访问不同数据类型的位模式;
- 通过强制类型转换来实现;同样的位,使用不同的解读方式,得到不同的值;
- 当使用联合,将不同类型的数据结合到一起时,需要特别注意一下小端机器和大端机器的表现会不同;
- 注:大小端的差异不是单字节内部,而是多字节的排列;
- 数据对齐
- 为了简化处理器与内存之间的接口设计,以处理器访问内存的效率,一般通过将数据对象的内存地址设置为某个长度的倍数;
- 这些说,貌似只要在栈桢的起始位置,将地址设置为8的倍数,貌似就可以保证对齐的实现?
- 即使没有对齐,处理器也能够正常工作,只是内存访问效率可能会降低,原来一次访问成功,变成需要访问两次,才能取到完整的数据;
- 对齐是由编译器来实现的,处理器本身并没有强制要求(除了某些型号的 SSE 指令外,它要求数据需要是16的倍数);
- 原因:处理器对内存的访问,并不是精确到个位的,而是以8为倍数进行访问,因此,需要保证访问的命中率,才能够减少访问的次数;命中以后,处理器会根据情况,对取到的数据进行偏移,以获取最终结果;
- 原因:处理器指令集的长度,无法全部用来表示地址空间,需要至少保留2位给指令名或者立即数,所以地址空间的可表示位置就少了两位,表示位从倒数第3位开始,倒数2位默认为0,不进行表示;这也决定了处理器给出的地址值总是8的倍数;因此,访问数据时,需要提高这个以8为倍数的地址的命中率,即刚好能覆盖到完整的数据,而不是只覆盖到一半,不然就得取两次进行拼接了;
- 为了简化处理器与内存之间的接口设计,以处理器访问内存的效率,一般通过将数据对象的内存地址设置为某个长度的倍数;
- 结构
- 在机器级程序中将控制与数据结合起来
- 理解指针
- 每个指针都有一个类型,它表明指针指向的是哪一类对象;指针并非机器代码的组成,它仅是编程语言本身提供的一种抽象,用来避免寻址错误;
- 每个指针都有一个值;这个值是一个地址(指定类型的对象存储的地址)(指针自己本身在内存中也有一个地址);
- & 运算符用于创建指针;机器代码一般用 leaq 指令来实现;
- 运算符用来引用指针,其结果获得一个值;
- 数组与指针联系紧密;二者都可以用来引用;
- 对指针的强制类型转换,只改变它的类型,不会改变它的值;而类型的改变,会导致在做指针移动的时候,伸缩量的单位不同;
- 例如假设有 char *p 指针
- 对于 p + 7,因为 char 只有一个字节,所以伸缩量为 1 字节, 它的伸缩为 p + 7 * 1;
- 对于 (int *)p + 7,由于 p 被强制转换成 int 类型的指针,因此它的伸缩量变成了4,所以新地址的计算结果变成了 p + 28 字节, p 的值并没有改变;
- 对于 int *(p + 7),新地址的计算结果仍为 p + 7 字节
- 例如假设有 char *p 指针
- 指针也可以指向函数;
- 函数指针的值,是该函数在机器代码中第一条指令的地址;
- 使用 GDB 调试器
- 通过设置断点,我们可以让程序在执行到断点位置的时候停下来,然后把控制权转给我们;接下来我们可以选择用各种命令查看程序的状态信息(如寄存器和内存位置等),也可以单步跟踪运行每一条指令,也可以快进到下一条指令处;
- GDB 的命令并不好记,不常用的话,经常遗忘;可以考虑它的图形化扩展 DDD,更加方便易用一些;
- 内存越界引用和缓冲区溢出
- 原因:C 对于数组的引用不做边界检查,再加上局部变量和状态信息(如寄存器值、返回地址等)都保存在栈中,那么,当出现越界访问和改写时,就会破坏栈桢中的局部变量和状态信息,导致程序出现异常,或者可以让程序运行它原来不可能去运行的程序;
- 缓冲区溢出:在栈中分配了一个空间,用来保存某个字符串,结果字符的长度超过了空间的大小。因此在保存的时候,会覆盖原来不应该去访问的空间,破坏了里面原本有用的数据;
- 缓冲区溢出表示程序试图访问比栈中可访问空间更大的空间;
- 堆栈溢出是程序试图在堆栈中分配过大过多的数据,导致堆栈空间不够用;
- 感觉堆栈溢出跟内存泄漏差不多一个意思
- 攻击原理:先输入一串包含运行代码的字符串,骗过检查;然后将某个栈桢的返回地址覆盖成为可运行代码第一指令的地址,然而在返回时,触发代码的调用;
- 对抗缓冲区溢出攻击
- 栈随机化
- 原理:攻击代码存放在某个地方,为了使它能够运行,需要另外有一个指针指向它,以便程序可以跳转到攻击代码的位置;为了得到这个指针,就需要知道攻击代码的栈地址;在早期,程序的栈地址非常容易预测;如果攻击者可以确定一些常见的 Web 服务器所使用的栈空间,就可以实施这类型的攻击;
- 对抗方法:栈地址随机化
- 原理:在程序开始前,先分配一个随机大小的栈空间(假设为 n ),这段空间不使用,但它会导致后续的所有程序的栈地址出现变化,使得程序的栈地址空间呈现随机化的状态,使得攻击者不容易猜中;
- 攻击者会使用空操作雪橇的方式,来暴力枚举地址,所以 n 需要有足够的大小,才能够增加枚举的难度;但这种方法的缺点是会造成内存空间的浪费;
- 栈破坏检测
- 原理:在某段程序开始运行前,生成一个随机值,放入栈中;等该段程序运行结束返回时,从栈中取回该值,查询是否跟原值一致;如果不一致,表示栈出现了破坏,马上中止程序运行并进行报错;
- 限制可执行代码区域
- 原理:将部分内存区域标记为可读可写,但不可执行,这样可以限制攻击者向系统插入可执行代码的能力,因为即使插入了,也无法被执行;
- 历史:早期的 x86 体系读和执行两种访问,使用同一个位标示来控制,使得要执行执行控制很麻烦,会有很大的性能损失;现在新的 AMD64 处理器,引入了新的位标志来单独控制执行权限,检查工作可以由硬件完全,性能上不再有损失;(貌似就是虚拟地址的最后3位里面的内容?)
- 栈随机化
- 支持变长栈桢
- 当函数中有局部变量的大小不固定时,例如根据参数会变长的数组,那么编译器就需要使用桢指针(基指针)技术来实现变长栈桢;
- 原理:使用 %rbp 来保存桢的基指针,接下来的栈空间分配,围绕这个基指针展开,依次分配空间,这样就不需要提前指定栈顶,实现了变长;
- 那要如何知道当前的栈顶在哪里呢?莫非仍在 %rsp 中?
- 这个基指针是给谁用的呢?貌似是给编译器用的;当局部变量的大小固定时,编译生成的指令,可以使用绝对地址,但是如果大小不固定,则编译器不能生成绝对地址的指令,而是要先保存一份基指针,然后根据创建的局部变量大小,加上基指针,得到每次的栈顶指针,然后将它放入 %rsp 中;
- 理解指针
- 浮点代码
- 背景
- SIMD,单指令多数据模式,single instructin multi data;目的:提高操作速度,一条指令,可以实现对多个数据的并行操作;基于 SIMD 模式不断增加出新的扩展(extention),达到更强大的功能;
- MMX,多媒体扩展,Multi Media extention;寄存器组叫 MM,64位
- SSE,流式 SIMD 扩展,stream SIMD extention;寄存器组叫 XMM,128位
- AVX,高级向量扩展,advanced vector extention;寄存器组叫 YMM,256位;
- 每个 YMM 可以存放 8 个32位值,4个64位值;这些值可以整数,也可以是浮点数
- 在编译命令中添加选项 -mavx2,可以显式的要求 gcc 编译器生成 AVX2 代码;
- SIMD,单指令多数据模式,single instructin multi data;目的:提高操作速度,一条指令,可以实现对多个数据的并行操作;基于 SIMD 模式不断增加出新的扩展(extention),达到更强大的功能;
- 浮点传送和转换操作
- 引用内存的指令(叫标量指令),意味着这些指令只会对单个数据值进行操作,而不是对一组数据值进行操作
- 引用内存的方式跟整数相同,也是基于偏移量,基址寄存器,变址寄存器,伸缩因子组合的方式;
- 移动指令:vmovss, vmovsd;vmovaps, vmovapd;
- 转换指令:
- 浮点数转整数:vcvttss2si, vcvttsd2si, vcvttss2siq, vcvttsd2siq; 使用截断的方法,向零取整;
- 整数转浮点数:vcvtsi2ss, vcvtsi2sd, vcvtsi2ssq, vcvtsi2sdq;
- 使用少见的三操作数指令格式;两个源,一个目的;其中第二个操作数的源可以忽略,它的值一般跟目的操作数是一样的;
- 单精度转双精度:通常的直觉是使用 vcvttss2sdq 指令,但 GCC 生成的指令却是 vunpcklps + vcvtps2pd 的组合,最终的结果低位与前者相同,区别在于后者还复制了一份低位的值到高位(暂且还不知道 GCC 为什么这么做)
- 双精度转单精度:GCC 同样有不一样的处理方式;通常的直觉是使用 vcvtsd2ss,但 GCC 却使用了 vmovddup + vcvtpd2psx 的组合,最终结果是高低位各一份副本(让人很奇怪的做法);
- 据说 GCC 的这种做法叫做标准的双指令序列,哈哈哈哈,用途暂时不明;
- 在实际运算过程中,一个函数如果有不同类型的数据,如整数和浮点数,则它可能会同时用到通用寄存器和多媒体寄存器;
- 过程中的浮点代码
- XMM 寄存器使用规则
- 所有的 XMM 寄存器都是调用者保存的;被调用者可以直接覆盖;
- 使用 %xmm0 ~ %xmm7 共8个寄存器来传递函数的参数,多出的参数使用栈传递;
- %xmm0 用来存放函数调用的返回值;
- 当函数的参数同时包含指针、整数、浮点数等多种类型时,其中指针和整数使用通用寄存器传递,浮点数使用 MMX 寄存器传递;
- XMM 寄存器使用规则
- 浮点运算操作
- 规则:源操作数可以是寄存器,也可以是内存,但目的操作数必须是寄存器;
- 单精度:vaddss, vsubss, vmulss, vdivss, vmaxss, vminss, sqrtss;
- 双精度:vaddsd, vsubsd, vmulsd, vdivsd, vmaxsd, vminsd, sqrtsd;
- 定义和使用浮点常数
- 对于浮点常数,AVX 不能实现以立即数做为操作数;因此,需要先将浮点常数存储在内存中,然后在使用的时候,再从内存读入;
- 在汇编中,读取内存中保存的浮点常数很特别,示例如下
- .LC2 // 首先使用一个标号来定位位置,以下是一个浮点常数 1.8
- .long 3435973837 // 浮点常数被拆分成低位和高位两段,这段是低位4字节(32位),此处的十进数对应的二进制数为 0xcccccccd
- .long 1073532108 // 这段是高位4字节(32位),此处的十进制数对应的二进制数为 0x3ffccccc
- 对于小端法的机器,高位在左,低位在右
- 对于双精度浮点数,根据 IEEE 表示法,由1个符号位,11个阶码位,加上52个小数位组成;
- 高位的 3ff 即为前12位,后面的 ccccc 属于小数位,加上低位4字节的 cccccccd ,共组成 ccccccccccccd 的小数部分 M;
- 高位 3ff ,即2个0加上10个1,相当于 2的10次方减1,因此对应的十进制为 1023,加上偏移量 1023,刚好得到阶码的值为 0,2的0次方为 1;
- 最终结果为 M * 1 = M;
- .LC2 // 首先使用一个标号来定位位置,以下是一个浮点常数 1.8
- 在浮点代码中使用位级操作
- 异或操作和与操作
- 单精度:vxorps, vandps
- 双精度:vxorpd, vandpd
- 异或操作和与操作
- 浮点比较操作
- 比较两个浮点数,并设置条件码表示比较结果;
- 单精度:ucomiss s1, s2
- 双精度:ucomisd s1, s2
- 涉及三个条件码
- 零标志位 ZF
- 进位标志位 CF
- 奇偶标志位 PF:在整数运算中很少用,有浮点运算中,当两个操作数有一个为 NaN 时,就会设置该标志位;
- 比较结果
- s2 < s1,CF 标志位为 1,其他两个为0
- s2 = s1,ZF 标志位为 1,其他两个为0
- s2 > s1,三个标志位都为 0
- 无序时,三个标志位都为1(当任意操作数为 NaN 时,即出现无序的状态,表示不可比较)
- 跳转指令会根据标志位的值组合,进行相应的条件跳转;
- 比较两个浮点数,并设置条件码表示比较结果;
- 对浮点代码的观察结论
- 浮点运算的机器代码的风格与整数运算大致相同;
- 对于混合类型的运算,情况会变得复杂一些;
- AVX2 有能力进行并行操作,因此编译器的开发人员也致力于将标量代码自动转化成并行代码;但目前更可靠的办法,还是使用 C 语言的扩展来实现;
- 背景
- 综述
- 处理器体系结构
- Y86-64 指令集体系结构
- 程序员可见的状态
- 15个程序寄存器:64位,其中的 %rsp 有固定用途,其他14个没有特定用途和值(事实上在 x86 中是有的)
- 3个条件码:1位,ZF\SF\OF,它们保存着最近的算术指令或逻辑指令所造成的影响的信息;
- ZERO 零, SYMBOL 符号, OVERFLOW 溢出; FLAG 标志;
- 1个程序计数器:存放当前正在执行的指令的地址;
- 内存:此处为虚拟内存,一个巨大的字节数组;
- 程序状态 stat:表明程序执行的总体状态,正常运行或者出现某种异常;
- Y86-64 指令
- 4个传送指令 XXmov,分别为 irmov, rrmov, rmmov, mrmov;
- 4个整数操作指令 OPq,分别为 addq, subq, andq, xorq;
- 7个跳转指令 jXX,分别为 jmp, jle, jl, je, jne, jge, jg;
- 6个条件传送指令 cmovXX,分别为 cmovle, cmovl, cmove, cmovne, cmovge, cmovg;只有条件码满足条件时,才会真正更新;
- 1个调用指令 call,将返回地址入栈,然后跳转到目的地址;
- 1个返回指令 ret ,将返回地址出栈,跳转到返回地址;
- 2个栈操作指令,pushq 入栈,popq 栈;
- 1个停止指令 halt,暂停整个系统;
- 指令编码
- 每条指令的第一个字节表示指令的类型,该字节分成两个部分,高4位表示代码部分(其实是指令大类),低4位是功能部分(其实是指令的小类);
- 如果指令需要两个寄存器指示符,由于只有15个寄存器,因此可以只需用高4位表示一个寄存器的代码,低4位表示另外一个寄存器的代码;
- 如果指令只需要一个寄存器指示符,则其中有一个 4 位用值 0xF 来表示“此处无寄存器”;
- 偏移量编码使用8字节的常数来表示;
- 立即数编码使用4字节的常数来表示;
- 核心目的:确保每条指令的字节数是固定的,这样只要知道字节序列的入口,就可以正确解析出所有的指令;
- 程序状态 stat
- AOK 正常操作;
- HLT 遇到执行 halt 指令;
- ADR 遇到非法地址;
- INS 遇到非法指令;
- Y86-64 程序
- 整数运算指令不支持立即数,只支持寄存器,因此需要先将立即数存入某个寄存器;
- andq 和 subq 运算可以用来设置条件码;
- pushq %rA 指令会将 %rA中的值压入栈中;同时它会改变寄存器 %rsp 中的值,使其指向新的栈顶指针的位置;
- popq %rA 指令则会将栈中的栈弹出到 %rA;同时它会改变寄存器 %rsp 中的值,使其指向新的栈顶指针的位置;
- 程序员可见的状态
- 逻辑设计和硬件控制语言 HCL
- 大多数数字电路使用信号线上的高电压和低电压来代表不同的位值,高电压代表 1,低电压代表 0;
- 实现一个数字系统的三个组成部分
- 实现对位进行操作的函数的组合逻辑;
- 存储位的存储单元;
- 控制存储单元更新的时钟信号;
- 时钟周期的长短是可以自主调节的,调节目标是长度足够让一条指令通过指定的阶段;原因在于电信号通过逻辑门阵列是需要时间的;
- 硬件控制语言 HCL(Hardware control language),用来描述处理器设计中的控制逻辑;
- 硬件描述语言 HDL(Hardware Description Language),用来描述硬件结构;目前较常用的是 Verilog;
- 逻辑合成程序可以将 HDL 语言描述的内容自动转变成有效的电路设计;
- 目前有开源的工具可以将 HCL 转成 Verilog(HDL),再将它与基本硬件单元的 Verilog 代码结合起来,就能产生完整的 HDL 描述,之后就可以基于它合成实际能够工作的微处理器;
- 逻辑门:它是数字电路的基本计算单元,它可以根据输入的位值(1个或 n 个),进行布尔运算,然后输出一个运算结果;逻辑门总是处于活动的状态,即当输入产生变化,输出就会很快发生变化;
- 将很多个逻辑门组成一个网,就能构造出计算块(computional block),称为组合电路;它必须遵守如下规则
- 每个逻辑门的输入必须连接到以下三种之一
- 一个系统输入(即主输入)
- 某个存储器单元的输出;
- 某个逻辑门的输出;
- 两个或多个逻辑门的输出不能连接在一起;
- 网必须是无环的;
- 每个逻辑门的输入必须连接到以下三种之一
- 组合电路
- bool eq = (a && b) || (!a && !b);
- bool xor = (!a && b) || (a && !b);
- 多路复用器 MUX(multiplexor),它会根据输入 s 的不同值得到 a 或 b;
- bool out = (s && a) || (!s && b)
- 由逻辑门组合而成的组合逻辑电路,与 C 语言中的逻辑表达式很像,都是用布尔操作对输入进行计算的函数; 不过逻辑电路的输入只有 0 和 1 两种,不像 C 语言中可以是任意整数;
- 字级运算的组合电路:对输入的字的每个位进行计算,得到输出的字的每个位;
- 多路复用函数可以用情况表达式(case expression)来描述
- [
- select1: expr1;
- select2: expr2;
- …..
- select3: expr3;
- ]
- [
- 算术逻辑单元的组合电路可以通过 2 个数据输入 + 1个控制输入来完成,控制输入用来设置运算的类型;
- 集合关系:判断某个信号是否属于某个信号集合;iexpr in {iexpr1, iexpr2, …, iexpr3};
- Y86-64 的顺序实现 SEQ
- 将处理组织成段
- 按流水线分段,目的:充分利用硬件的性能,实现更高的处理效率
- 阶段组成
- 取指
- 根据 PC 程序计数器中存储的地址,从内存中读取指令字节;
- 根据当前取出的指令字节的长度,计算出下一条指令的地址 valP,
- 从指令中抽取内容,包括:指令代码 icode、指令功能 ifun、寄存器指示符 rA 或 rB、常数 valC 等;
- 译码
- 从寄存器中读入操作数;得到 valA 或/和 valB;要么从 rA 或 rB 中读,要么从 %rsp 中读;
- 执行
- 算术逻辑单元 ALU 执行指令指明的操作
- 执行动作可能有:
- 计算内存引用的地址,得到值 valE;
- 增加或减少栈指针,得到值 valE;
- 设置条件码,得到信号 Cnd;
- 访存
- 将数据写入内存,或者从内存中读取数据;读出的值记为 valM;
- 写回
- 将结果写入寄存器文件,最多可以写两个;
- 更新 PC
- 将 PC 设置为下一条指令的地址 valP;
- 取指
- SEQ 硬件结构
- 组成:数据线、时钟寄存器、硬件单元(当作黑盒子处理)、控制逻辑块(实现控制逻辑,在不同硬件单元之间传递数据,并操作这些单元,使得对每个不同的指令执行指定的运算)
- SEQ 的时序
- 原则:从不回读;即一条指令的成功执行,不需要依赖当前指令执行后的结果;如果需要自我依赖的话,就麻烦了,无法实现时序控制;
- 组成:
- 组合逻辑:不需要任何时序或控制,只要输入发生变化,值就通过逻辑门网络传播;
- 随机访问存储器:包括寄存器文件、指令内存、数据内存;同上,根据地址进行输入得到输出(对于大电路,需要使用特殊的时钟电路来模拟这个效果);
- 时钟寄存器:程序计数器和条件码寄存器;
- 需要明确时序控制的四个硬件单元:程序计数器、条件码寄存器、数据内存、寄存器文件;
- 每个时钟周期分为上沿和下沿,处于上沿时,会更新硬件单元;进入下沿时,会更新组合逻辑;再进入下一个上沿时,更新硬件单元,以此类推,不断反复;
- SEQ 阶段的实现
- 取指阶段
- 从内存一次性读入10个字节(为什么是10个字节整?因为 Y86 指令集设计的最大指令长度为10字节);
- 第一个字节解释为指令字节,并分成两个4位的数,对应 icode 和 ifun
- 根据 icode 得到三个信号
- instr_valid:指令是否合法
- need_regids:是否有操作数
- need_valC:是否有常数
- 根据 need_regids 和 need_valC 计算下一条指令的地址,供下一个周期读入指令使用;
- 根据 need_regids 从余下的9个字节中,提取 rA、rB 和 valC;
- 译码和写回阶段
- 寄存器文件有4个端口,两个读(A 和 B),两个写(E 和 M);
- 每个端口由两个连接组成,一个是地址连接,一个是数据连接;
- 地址连接是一个寄存器 ID;
- 数据连接是一组64位的线路;即可用于输入(写端口),也可用于输出(读端口);
- 如果某个地址端口标记为 0xF,则表示不需要访问寄存器文件;
- srcA 的硬件描述示例
- word srcA = [
- icode in { IRRMOVQ, IRMMOVQ, IOPQ, IPUSHQ } : rA;
- icode in { IPOPQ, IRET } : RRSP;
- 1 : RNONE; // 表示不需要访问寄存器
- ]
- word srcA = [
- 执行阶段
- 算术逻辑单元,根据 alufun 信号的设置,对输入进行加、减、和、异或的运算,得到输出 valE;
- 每次运行时,还会产生三个与条件码相关的信号,根据 icode 判断是否需要更新条件码寄存器;
- 访存阶段
- 根据 icode 判断执行读还是写操作
- 若为读,则数据内存单元将存储从内存中读取的值,得到 valM;
- 访存的最后阶段还会更新状态码 stat,它的依据来源于 icode、imem_error、instr_valid、dmem_error 的信号;
- 如果在此里更新了状态码,那么要让它发挥用途的话,理论上应该在某个地方对它进行检查;直觉上应该是在下一条指令开始之前做检查工作?
- 更新 PC 阶段
- 新的 PC 可能是 valC、valM 或者 valP,需要依据指令类型和条件码进行判断;
- 取指阶段
- SEQ 顺序设计的小结
- 优点:可以用很少量的硬件单元和一个时钟来控制计算的顺序;
- 缺点:性能太差,时钟必须设置得很慢,这样才能够让信号在一个时钟周期内传播到所有的阶段;吞吐量太小;
- 将处理组织成段
- 流水线的通用原理
- 流水线设计的优点:可以大大的提高吞吐量;
- 流水线设计的挑战在于,系统的吞吐量会受到最慢阶段的速度的限制;而有些硬件单元,例如 ALU 和内存,很难被划分成多个延迟较小的单元;
- 流水线过深虽然可以进一步提高吞吐量,但由于引入过多的中间寄存器,而这些寄存器是会增加延迟成本的,所以最后汇总下来并不一定更划算;
- Y86-64 的流水线实现
- 重新安排计算阶段
- 将 PC 的计算移动到一个周期的开始,即时钟上沿;并增加状态寄存器,用来保存上一条的计算结果,然后依据该结果计算新的 PC;
- 在新的设计中,PC 不再是一个实体的硬件单元,而是根据状态码寄存器的内容,动态计算的结果;这里面隐含着一个概念,在逻辑上存在的事物,在设计中并不一定需要真实的映射硬件,它也可以是一种动态生成的方式;只要确保它能够得到正确的结果即可;同一种逻辑行为,可以有很多种物理实现方式;
- 状态单元的引入,称为电路重定时技术,它可以同步不同硬件单元间的延迟;
- 增加流水线寄存器
- 由于现在每个阶段单独执行在一个时钟周期中,因此需要在每个阶段之间增加流水线寄存器,以便保存每个周期的处理结果;
- 由于有5个阶段,因此也刚好对应需要5个流水线寄存器,分别为 F, D, E, M, W;
- 对信号进行重新排列和标号
- 不同阶段的流水线寄存器,会加上该阶段的大写字母前缀,以方便唯一识别;例如:F_stat
- 对于各个阶段刚刚计算出来的结果(尚未存入流水线计算器),则加上小写的字母以唯一识别,例如 f_stat;
- 在 PIPE- 的设计中,将 SEQ 设计中的 Data 模块移除,改成并入 valA 模块;原因:如果某个阶段的处理结果,有可能在后续阶段使用,流水线寄存器就需要确保携带它们穿入余下的阶段;但如果有某些信号是互斥的,则可以将它们合并成一个信号,这样一来可以减少流水器寄存器的数理,更加高效的利用有限的硬件单元;
- 预测下一个 PC
- 困境:虽然将指令分阶段处理提高了处理吞吐量,但是有些指令是依赖于前一条指令的处理结果的,例如条件跳转和 ret;当它们进入流水线时,前面的指令可能还没有处理好,这个时候导致它们需要处于等待状态,从而破坏了流水线的节奏;
- 应对办法:直接根据某个设定的策略,预测下一个分支,进行流水线处理;大多数情况下,例如 call 和 jmp 无条件跳转,这种预测绝对是正确 ;但对于条件跳转和 ret,有可能存在预测错误的情况,因此,还需要再补充一个错误处理机制进行完善;
- 预测策略
- always taken:永远选择;命中率约50%;
- backwad taken:反向选择,当分支地址比下一条地址低时,就选择分支;反之不选择分支,命中率约65%;原理:循环的结束判断后置;
- forward not-taken:正向不选择,同上;
- 对于 ret 是没有办法使用预测的,因为无法预测,它总是会返回栈顶的字,但里面存着什么是不确定的,有无限种可能;
- 对于 Y86 处理器,此时唯一的处理办法只能是暂停流水线处理新的指令,一直等到 ret 指令通过写回阶段;
- 但是对于 x86 处理器,它通过在处理器增加一个硬件栈,每次有 call 调用指令进来的时候,就把它计算出来的返回地址压入这个硬件栈中,然后在 ret 指令进来的时候,就从硬件栈中弹出栈底的值,做为预测值;这种策略的准确性很高,但偶尔也会出现不命中的情况,所以提供恢复机制是不可少的;这个硬件栈对程序员不可见;
- 流水线冒险
- 当相邻的指令之间存在数据依赖或者控制依赖时,按时之前的设计,将会出现错误。因为新的指令还不能等到前一条指令的处理结果;因此,将出现两种冒险,即 data hazard 和 control hazard;
- 用暂停来避免数据冒险
- 原理:在对新一条指令取指和译码后,通过检查,发现它存在数据冒险,此时通过插入一个气泡,或者插入一个 nop 指令,迫使当前指令不会进入下一个周期,而是在当前周期再重复执行一次;待下次执行的时候,再次检查是否存在冒险,以此类推,甚至冒险消失;
- 优点:设计和实现起来非常简单
- 缺点:性能下降很多;
- 用转发来避免数据冒险
- 原理:由于数据冒险都发生在执行阶段,所以将执行阶段的结果、M 和 W 流水线寄存器的结果转发到执行阶段之前,并通过判断操作的源寄存器 ID 和转发的寄存器 ID 值是否一致,判断是否需要使用转发值,从而实现提前获取上一条指令的计算结果,而无须暂停进行等待;
- 加载/使用数据冒险
- 缘起:对内存的读写发生在 M 阶段,而 M 阶段发生在 E 阶段之后,如果紧跟其后的下一条指令,需要使用当前指令从内存中读取的值,此时就会出现加载数据冒险,因为在 M 读到值后再转发已经来不及了,下一条指令已经通过了 E 阶段;
- 解决办法:使用暂停+转发结合的技术;当发现有加载数据冒险时,就插入一个气泡,推迟下一条指令一个时钟周期;这个技术称为加载互锁 load interlock;
- 避免控制冒险
- 控制冒险:即无法根据取值阶段的结果,计算得到下一条指令的地址,这种情形只发生在两种指令身上:ret 返回指令和 jmpXX 条件跳转指令;
- 对于 ret 指令,没有什么好的办法,只能通过让流水线暂停三个时钟周期,等待 ret 的返回结果,之后才能获得下一条指令的正确地址,并继续流水线处理;
- 对于 jmpXX 条件跳转,一开始先做预测,待条件判断的指令到达 E 阶段后,即可得到判断结果,此时可以知道之前的预测是否正确;如果预测错误,则提前处理的两个时钟周期的指令只能废弃,并从另外一条分支的指令继续流水线;虽然会浪费两个时钟周期的处理结果,但是由于预处理的两条指令都还未到达 E 阶段,所以对它们的预处理并不会给后续的指令带来影响,可以直接丢弃,而无须做其他工作;
- 异常处理
- 在完整的处理器设计中,当出现异常时,处理器会继续调用异常处理程序(exception handler),这个程序属于操作系统的一部分;
- 细节问题
- 由于流水线的设计,导致可能存在多条指令同时触发异常;此时的原则是最深的那条指令触发的异常优先级最高,选择它汇报给操作系统;
- 由于提前做出分支预测,读取了一条跳转后的位置的指令,并触发了异常,但随后发现预测错误,原本就不应该去取那条指令;
- 一条指令触发了一个异常,此时开始调用异常处理指令,但后续的指令在异常指令完成之前,改变了系统的部分状态;
- 解决方案:当处于访存或者写回阶段的指令触发异常时,应限制后续的指令更新条件码寄存器和数据内存;
- 当一条指令触发了异常,此时会更新流水线寄存器中的状态码,但不中断流水线,只是限制不得更新条件码寄存器和数据内存;直到该条指令携带的异常状态码到达最后的写回阶段时,由于它是第一个到达的异常指令,此时停止程序执行,控制转移给异常处理程序;
- 对于该条指令之后的指令,虽然它们进入了流水线,但随后都会将它们的状态信息取消;(后续有两种可能性,从中断处再次执行触发异常的指令,或者直接执行下一条指令,取决于异常的种类)
- PIPE 各阶段的实现
- PC 选择和取指阶段,现在有了三个选择源
- 当分支预测错误时:取值 M_valA;
- 当ret 指令进入写回时:取值 W_valM
- 当其他情况:取值 F_predPC
- 译码和写回阶段
- 此阶段需要接收很多后续阶段流水线寄存器转发过来的值,并进行判断决定是否采用;
- 判断依据:转发值所处的流水线阶段越早,则采用的优先级越高;因为越靠近当前指令的指令的状态越新;
- 在写回阶段,需要判断当有气泡的时候,仍然保持 stat 状态值为正常;
- 执行阶段
- 当前面的指令出现异常时,执行阶段的指令不得更新条件码寄存器,因此在更新前,需要判断一下前面的指令是否已经出现异常;
- 访存阶段
- PC 选择和取指阶段,现在有了三个选择源
- 流水线控制逻辑
- 特殊控制情况所期望的处理,包括:加载/使用数据冒险的处理、ret 的处理、预测错误分支的处理、异常处理;
- 发现特殊控制条件:将四种特殊情况的触发条件用 HCL 代码来表示;
- 流水线控制机制:给每个流水线寄存器增加两个信号输入,分别为暂停信号和气泡信号;正常状态下,两个信号都为0;当暂停信号为1时,寄存器禁止更新,并保存当前值,以形成阻塞的效果;当气泡信号为1时,触发寄存器的复位,值更新为硬件设计师预测配置好的复位值,这个值对于不同阶段的寄存器有所不同,相同的是能得到气泡空指令穿过流水线的效果;
- 控制条件组合
- 多数冒险是互斥的,只有两种情况可能同时出现,即
- 组合A:跳转冒险 + ret 冒险;
- 组合B:加载冒险 + ret 冒险;
- 对于组合A,由于我们总是选择跳转,所以不会马上遇到 ret 冒险,所以它跟普通的跳转预测错误的处理没有区别;
- 组合 B 需要进行特殊处理,因为按原来的处理机器,此时处理器会对两种冒险都给出处理方案,即暂停 ret 同时添加气泡,但实际上只需要采取一种处理方案即可;
- 多数冒险是互斥的,只有两种情况可能同时出现,即
- 控制逻辑实现
- 前面涉及的各种情况的处理方案,最终需要落地为一个新的逻辑单元(即流水线控制逻辑),它接收各个流水线寄存器发送过来的信号,并进行判断和给出新的反馈信号;
- 在完成了Y86的逻辑设计后,进行形式化的测试是必不可少的,它有助于发现设计中隐藏的问题;同时利用逻辑合成工具,可以将设计翻译成实际的逻辑电路;之后,利用可编程的门阵列硬件(FPGA),就可以试运行 Y86 的程序了;
- Y86-64 模拟器原理:将逻辑块的 HCL 描述翻译成 C 代码,编译这些代码,并与模拟代码的其他部分进行链接,这样就可以模拟出实际的处理器工作过程;
- 性能分析
- 一般使用 CPI(cycles per instructin)每指令周期数来衡量整体性能;这个值是流水线平均吞吐量的倒数,时间单位是处理器的时钟周期;
- 添加特殊情况处理机制,不可避免会带来惩罚导致性能下降;不同异常的发生概率是不同的,因此对于更准确 CPI 的计算应纳入三种异常的发生概率
- CPI = 1.0 + lp + mp + rp
- lp: load penalty
- mp: mispredicted branch penalty
- rp: return penalty;
- 此处 CPI 不小于 1,但如果引入乱序设计, CPI 有可能会小于 1,届时一般使用它的倒数即 IPC(每时钟周期的指令数) 来衡量性能
- 未完成的工作
- 多周期的指令:部分复杂的指令需要多周期才能完成,例如乘法、除法、浮点运算;为了避免处理这些复杂指令时造成等待,一般使用额外的整数和浮点运算单元来处理这些指令,这些可以当随后的指令得以并发执行;但需要增加相应的同步机制,以避免出现冒险;
- 与存储系统的接口
- 从物理磁盘读取数据无法在一个时钟周期内完成,而是需要上百万个时钟周期;通过引入多级高速缓存机制和虚拟地址翻译的TLB缓存机制,利用时间和空间局部性,大部分时候可以在一个时钟周期内读取写数据;但偶尔的缓存不命中,就不可避免的需要让流水线进入暂停的状态;
- 当被引用的存储位置是在磁盘而不是在内存中的时候,会触发缺页异常,之后会调用操作系统的异常处理程序(需要几百个时钟周期),将数据从磁盘加载到内存中(需要几百万个时钟周期),然后再从原来中断的指令处重新开始执行;
- 当前的微处理器设计:PIPE 是一个单周期单指令的流水线化设计,目前最新的设计方式引入了单周期多指令的乱序执行技术,它可以实现超标量操作,并行的取指、译码和执行多条指令(即指令级并行);但在很多使用嵌入式系统的设备中,PIPE 这类设计仍有广泛用途,因为它更简单,从而控制了低成本和低功耗;
- 小结
- 在设计更深和更多并行性的系统中,如何正确处理异常将会是一个有非常大挑战的问题;
- 指令集体系架构(ISA)在处理器功能以及如何实现这些功能之间提供了一层抽象;对于处理器外部的软件,它们只需要关心处理器提供的功能接口,而无须关心它的底层是如何实现的;
- 设计处理器必须非常谨慎小心,并使用系统化的测试,因为在硬件生产出来后,如果发现细微的错误,是无法进行更正的,代价非常惨重;
- 重新安排计算阶段
- Y86-64 指令集体系结构
- 优化程序性能
- 优化编译器的能力和局限性
- 编写高效程序的方法
- 选择合适的算法和数据结构;
- 编写能够让编译器翻译成高效目标代码的源代码;
- 将一个大的计算任务拆分多个小任务并行计算;
- 当函数存在副作用会修改全局变量时,对函数的调用将会很难被编译器优化;
- 用内联函数替换函数调用也是一种优化方法,但是它的局限性是会导致 GDB 调试器进行追踪或设置断点失效,同时代码剖析工具也很可能会失效;
- 编写高效程序的方法
- 表示程序性能:通过每元素的周期数 CPE (cycles per element)来表示程序性能;它特别适用于循环类的计算,即一组元素中平均需要的周期数;
- 程序示例:确定哪些代码变换会显著提高性能的最好方法是实验加上分析:反复的尝试不同的方法,并进行测量,并检查底层的汇编代码以确定性能瓶颈;
- 消除循环的低效率:如果有某个值的计算在循环过程中是不同变的,则应该将该计算放到循环开始前进行,而是每次循环的时候都重复计算一次,没有意义还降低了性能;
- 减少过程调用:减少过程调用有可能会降低程序的模块性和易读性,在确定一定需要这么做的时候,再采取这种行为比较好;
- 消除不必要的内存引用:从内存是读取数据的代价是很大的,有时候通过增加一个临时变量保存每次循环计算的结果,最后再将其写入内存,可以减少每次循环出现的不必要的内存引用;
- 理解现代处理器
- 有两个界限跟处理器的性能有关
- 延迟界限:指一系列指令的执行需要严格按照顺序,即下一条指令开始之前,当前这条指令必须结束;该界限会限制程序的性能;
- 吞吐量界限:处理器功能单元的原始计算能力;
- 乱序设计主要由两个部分组成
- 指令控制单元:ICU,Instruction Control Unit;
- 执行单元:EU,Execution Unit;
- 乱序设计的原理:一次取多条指令,按流水线阶段拆分为多批同类操作,并行处理,通过寄存器重命名机制共享彼此的结果(另一种形式的转发机制),从而能够解决顺序依赖问题;
- 不同功能单元(例如加法、乘法、除法等算术运算)所需要的时钟周期不同;它包含三个方面的指标,分别为:
- 延迟:完成运算所需要的总时间;
- 发射:两个连续的同类型运算之间需要的最小时钟周期数;
- 容量:能够执行该运算的功能单元数量;
- 不同操作之间的数据相关,会限制它们的执行顺序,这些限制会形成数据流中的关键路径;但关键路径表示的只是程序所需周期数的下界,还有其他一些因素会限制性能,包括可用的运算单元数量,以及运算单元之间能够传递数据值的数量;
- 吞吐量是基本的限制,它决定了程序性能的上界;程序优化变换的目标就是尽量接近这个上界;
- 有两个界限跟处理器的性能有关
- 循环展开
- 循环展开可以减少循环的迭代次数,但是它并不一定会带来性能的提升,因为单次循环内部的运算次数增加了,所以总的运算次数并没有改变;
- 当将编译器的优化等级调整到足够高时,编译器都会例行公事的进行循环展开;
- 提高并行性
- 虽然硬件中的部分运算单元有多个,但如果程序的每次结算依赖上一条指令的结果,便不能充分利用多个运算单元提高性能;除非拆分成多个累积变量,当然,运算本身要满足结合律和交换律才行;
- 当将程序变换为 k * k 的循环展开时,就有可能充分提高这种多个运算单元的并行性;当循环展开因子 k >= 容量C * 延迟L 时,最有可能保持能够执行该操作的所有功能单元的流水线都是满的;
- 由于浮点的乘法和加法是不可结合的,因此如果数组中的元素存在某种特殊的不连续性,例如偶数位很大,奇数位很小,那么它就有可能导致在循环展开的计算过程中,出现上溢或下溢;导致循环展开后的计算结果不正确;因此,编译器不会对浮点乘法和加法做这种冒险式的展开,我们需要确定数据连续的情况下,手工完成展开的工作;
- 另一个提高单次循环内部运算性能的办法是考虑重新结合,例如 (acc * data[i]) * data[i + 1] 变换为 acc * (data[i] * data[i + 1]);因为每次循环的关键点在于 acc 的相关性,在第1种形式中,acc 参与了再次运算,因为在关键路径上面,每次循环有两个 acc 参与的 mul 运算,这样下一个循环需要做出2个周期的等待;而在第2种形式中,acc 在每次循环中只需要参与一次计算,下一个循环只需做出一次等待,因为 data[i] * data[i+1] 的运算在每次循环中是独立的,所以它们是可以并行的,但 acc 则不行;
- 最后一种提升性能的办法是利用 Intel 处理器的向量指令功能(即 SSE 指令,流SIMD 扩展,最新版本为 AVX) ,它很像 GPU 的做法,通过一条指令,对多个数据进行并行处理;要使用 SSE 指令,貌似需要在源码中按某种形式来编写;
- 优化合并代码的结果小结:常规优化有可能提升 8-10 倍的性能,而 SIMD 优化则有可能提升 100-200 倍的性能;
- 一些限制因素
- 假设运算数量为N,功能单元容量为 C,发射时间为T,则完成所有运算需要的周期为 (N / C) * I;
- 寄存器溢出:虽然使用循环并行多变量累积的方式能够提高性能,但是当需要累积的变量数量超过了可用的寄存器数量时,超出的部分就只能存储到内存中(或者高速缓存中),此时反而会带来性能的下降,这种现象称为寄存器溢出;
- 分支预测和预测错误处罚
- 在之前设计的Y86处理器中,对于预测错误的分支,直接抛弃,并载入正确的分支指令重新计算,这会带来惩罚;这种机制称为条件控制转移;
- 在最新的 x86 设计中,使用同时计算两个分支的指令,然后在条件判断结果出来后,传送其中一个分支的计算结果,这样就避免了错误的时间惩罚,代价是要增加运算单元才行;用空间换时间的思想;这种机制称为条件控制传送;
- 是否启用条件传送机制,生成相应的机器代码,对于程序员来说不是可控的;但这里面有一些规律可循;通过寻找规律、核对汇编代码、测试性能等实验,可以找到方法;大体规律在于将命令式的代码风格转变为功能式的代码风格,例如
- 命令式风格
- if a[i] > b[i]:
- tmp = a[i];
- a[i] = b[i];
- b[i] = tmp;
- if a[i] > b[i]:
- 功能性风格
- max = a[i] > b[i] ? a[i] : b[i];
- min = a[i] > b[i] ? b[i] : a[i];
- a[i] = max;
- b[i] = min;
- 命令式风格
- 理解内存性能
- 加载的性能
- 现代处理器有专门的功能单元来执行加载和存储操作,这些单元有内部的缓冲区来缓存未完成的操作请求集合;例如 Intel i7 有两个加载单元(每个可缓存72个读请求),一个存储单元(可缓存42个写请求);
- 假设有2个加载单元,则对于每个被计算的元素需要加载 k 个值的场景,不可能获得低于 k/2 的 CPE;
- 存储的性能
- 对于 movq %rax (%rsi) 指令,它实际上是拆分两个指令在执行的,两个指令是独立执行的(当二者指向同一地址时,它们需要有先后顺序,才能完成匹配的工作)
- 一个是 s_addr 指令,用来计算存储地址,它会在缓冲区创建一个条目,,并且设置该条目的地址字段;
- 一个是 s_data 指令,用来设置条目的数据字段;
- 对于内存操作,如果读取和写入的地址不同,它们就可以并行计算;但如果读取和写入是同一个地址,则它们会产生并冲突,需要确定先后执行顺序才能得到正确的结果,因此会增加关键路径上面的节点,增加了更多的时钟周期,从而降低了性能;
- 对于 movq %rax (%rsi) 指令,它实际上是拆分两个指令在执行的,两个指令是独立执行的(当二者指向同一地址时,它们需要有先后顺序,才能完成匹配的工作)
- 加载的性能
- 应用:性能提高技术
- 优化程序性能的基本策略
- 高级设计:选择合适的算法和数据结构;避免使用渐进糟糕性能的算法或编码技术;
- 基本编码原则:
- 消除连续的函数调用:如果性能是第一位的,则有时候需要牺牲一下模块性;
- 消除不必要的内存引用:引入临时变量保存中间结果,只在最后再写入目的位置;
- 低级优化:
- 循环展开;
- 使用多个累积变量和重新结合技术;
- 用功能性风格重写条件操作,使得编译能够采用条件传送;
- 优化程序性能的基本策略
- 确认和消除性能瓶颈
- 程序剖析
- Unix 系统有一个内置工具 GPROF 可以用来做程序剖析,它会产生两种信息
- 每个函数执行花费的时间;
- 每个函数被调用的次数;
- GPROF 使用步骤
- 编译时加上 -pg 和 -Og 选项,前者用来开启 pg,后者用来指定优化等级,避免 GCC 过度优化改变了原程序的结构;示例:gcc -Og -pg prog.c -o prog
- 运行时加上 file.txt 参数,示例: ./prog file.txt
- 调用 GPROF 来分析 gmon.out 中的数据,示例:gprof prog
- GPROF 的原理是利用操作系统的中断机制,并比照中断前后的函数调用情况,来判断函数调用所花费的时间;这种方法非常机智,不过也有缺点:
- 函数的调用发生在两次调用之间的时候,就不会被统计到;或者函数调用刚好界于上次中断末尾,并结束于下调用开始后不久,则此时计算的时间是按一个中断计算的,有点偏多了;因此,GPROF 对于运行时间很短的程序来说,统计不太精确;但对于运行时间较长的程序来说,它的统计准确;
- 如果编译器对函数进行内联,则 GPROF 就统计不到了;
- 默认情况下,不会显示库函数的调用情况;
- Unix 系统有一个内置工具 GPROF 可以用来做程序剖析,它会产生两种信息
- 使用剖析程序来指导优化
- 选择不同的排序算法之间有巨大的性能差异,例如 Quicksort 的时间复杂度约为 O(nlogn);
- 由于链表只能顺序查找,因此链表在头部插入和尾部插入是有区别的;使用尾部插入,使得越经常出现的元素倾向于出现在头部,因此其查找变短,性能越好;
- 哈希表的桶的数量是一方面的考虑因素,另一方面是需要选择合适的哈希函数,使得样本能够平均分布到各个桶,避免大量的桶空闲;
- 程序剖析
- 优化编译器的能力和局限性
- 存储器层次结构
- 存储技术
- 随机访问存储器
- 静态RAM
- 将每个位存储在一个双稳定特性的存储器单元中;每个单元由6个晶体管组成(因此,相对 DRAM 体积更大,造价更高,功耗也更大)
- 双稳态:只要通电,就可以永远保持在两个不同电压配置(或状态)之一,其他任何状态都是不稳定的;有点像是一个倒放的钟摆;
- 静态 RAM 比动态 RAM 的访问速度快,主要用于高速缓存;个人桌面电脑一般只有几 M 的 静态 RAM,但会有几 G 的动态RAM;
- 动态RAM
- 将每个位存储为对一个电容的充电;每个单元由一个电容+一个晶体管组成,因此 DRAM 可以制造得非常密集;
- 电容非常小,约只有30*10-15 法拉;
- 对干扰非常敏感,甚至光线都会改变它;相机的传感器本质上即是 DRAM 单元的阵列;
- 在大约10-100毫秒内,电容就会失去电荷;解决方法:通过周期性的读出数据,并重新刷新每个位
- 处理器的时钟周期以纳秒为单位,所以能够在数据丢失前获取数据;
- 有些操作系统还会增加纠错码,比如原来64位的字,用72位来编码,多出的8位用来核对数据是否出现错误位)
- 将每个位存储为对一个电容的充电;每个单元由一个电容+一个晶体管组成,因此 DRAM 可以制造得非常密集;
- 传统的DRAM
- 每个 DRAM 单元表示一个位,w 个单元组成一个超单元;多个超单元组成一个芯片单元;
- 假设一个芯片单元有 d 个超单元,这 d 个超单元会被组成阵列,例如 r 行 c 列,其中 r * c = d;这样每个超单元就获得了一个行列表示的地址
- 假设一个超单元由8个单元组成,即有 8 个位表示,则刚好可以用来存储一个字节的数据;
- 信息通过引脚(pin)传入和传出;例如可以用2个针脚来传地址,8个针脚来传数据;还有一些针脚用来传输控制信息;
- 外部先与内存控制器通信,内存控制器再与芯片单元通信;
- 行列地址不是同时发送的,而是先发放行地址 RAS,再发列地址 CAS;因此,行列使用的是相同的针脚在传送信息;
- 访问过程:
- 内存控制器先发行地址,DRAM 芯片将整行的数据复制到内部行缓冲区,
- 内存控制器再发列地址,DRAM 芯片从内部行缓冲区读出对应的超单元中的内容,发回给内存控制器;
- 二维阵列的优缺点
- 优点:行列地址共同一套针脚,减少了针脚的数量;
- 缺点:数据分两次访问,增加了访问时间;
- 每个 DRAM 单元表示一个位,w 个单元组成一个超单元;多个超单元组成一个芯片单元;
- 内存模块
- DRAM 芯片封装在内存模块中;内存模块则插在主板上;
- DIMM:240个针脚的双列直插内存模块,dual inline memory module;
- 假设内存模块有8个 DRAM 芯片,每个 DRAM 芯片可以存储 8M 字节,则内存模块总共可以存储 8 * 8 = 64M 字节;
- 对于一个64位的信息,它并不是存储在单个 DRAM 芯片中,而是拆分成 8 个字节,分布存储在8 个 DRAM 芯片中,每个芯片存储一个字节,对应一个超单元;(听起来像是分布式存储,哈哈哈哈,其实机械硬盘也是这样的,只是分不同的盘面)
- 访问的时候,同时访问这 8 个DRAM 芯片的相同行列地址的超单元,各取得一个字节的信息,然后再合并起来,成为8字节64位的信息;
- 增强的DRAM
- 快页模式:fast page mode,FPM DRAM
- 重复利用内部行缓冲区,读取同一行中连续的超单元;以前的四次 RAS/CAS 变成一次 RAS + 4次 CAS;
- 扩展数据输出:EDO DRAM,extended data output;
- 对 FPM 的增强,允许 CAS 的信号在时间上更紧密一点;
- 同步 DRAM:SDRAM,synchronous DRAM
- 常规的、FPM、EDO 等模式下,内存控制器的通信信号是异步的,而 SDRAM 使用同步信号,结果是可以更快的输出数据;
- 双倍数据速率同步:DDR SDRAM,double data-rate synchronous
- 对 SDRAM 的一种增强;使用两个时钟沿作为控制信号,从而使 DRAM 的速度翻倍;
- 不同类型的 DDR 区别在于预取缓冲区的大小不同,2位,4位,8位等;
- 视频 RAM:VDAM,video RAM
- 专门用在图形系统的桢缓冲区中;VRAM 允许对内存并行的读和写,从而可以使得写下一次更新的值时,读取缓冲区中的内容刷新屏幕;
- 快页模式:fast page mode,FPM DRAM
- 非易失性存储器
- PROM:可编程 ROM,但只能编程一次;
- EPROM:可擦写可编程 ROM,可擦写达1000次
- EEPROM:电子可擦写可编程 ROM,可擦写10万次;
- 闪存:基于 EEPROM;
- 固态硬盘:基于闪存;
- 访问主存
- 处理器通过总线访问主存;
- 访问使用一系列的步骤,总称为总线事务,可分为读事务和写事务两大类;
- 总线是一组并行的导线,可以携带数据、地址和控制信号;
- 事实上,CPU 并不是通过总线直连内存模块,而是先经过 I/O 桥,即 CPU 通过系统总线连到 I/O 桥,I/O 桥再通过内存总线连接到内存模块;即它们中间有一个翻译官,这个翻译官负责翻译以不同类型的总线信号;从而让多种类型的硬件设备可以通信;
- 静态RAM
- 磁盘存储
- 磁盘构造
- 当磁盘有多个盘片时,多个盘片的相同磁道,组成一个柱面;
- 盘片可以双面存储;每一面由多个呈同心圆的磁道多成;
- 每个磁道分成相等大小的扇区;每个扇区中间有间隙;间隙不存储数据,而是用来标示磁道的格式化位置;
- 每个扇区通常存储 512 个字节,也即 4096 位;
- 盘片以固定的速率旋转;盘片上有磁性材料,用来存放数据;
- 磁盘容量
- 容量由两个因素决定
- 记录密度:磁道上1英寸的段,可以存储的位数;
- 磁道密度:从盘片中心发出,1英寸半径的段内的磁道数量
- 面密度 = 记录密度 * 磁道密度
- 容量由两个因素决定
- 磁盘操作
- 通过读写头来读写磁道上的数据;每个盘面都有一个读写头,但是所有读写头都连接到同一个传动臂上面,因此它们是一致行动的,每次都读写同一个柱面;
- 这么说来,数据分布存储在相同柱面上,然后读写后再拼接,就跟内存模块中的超单元一样,貌似会更快?
- 磁盘以扇区大小的块为单位来读写数据,也即 512 字节为单位;
- 扇区的访问时间由三部分组成
- 寻道时间:定位到目标磁道的时间,平均约 3-9ms
- 旋转时间:定位到目标扇区的时间,平均约为单圈转速时间的一半;对于7200转的磁盘,约为 4ms;
- 传送时间:跟转速和单个磁道的扇区数量有关;平均约为单圈转速时间除以磁盘扇区数量,约为 0.02 ms;
- 通过读写头来读写磁道上的数据;每个盘面都有一个读写头,但是所有读写头都连接到同一个传动臂上面,因此它们是一致行动的,每次都读写同一个柱面;
- 逻辑磁盘块
- 磁盘通过抽象一层虚拟的逻辑块系列,与实际的盘面、磁盘、扇区进行映射;简化了操作系统对数据访问;操作系统并不直接盘片打交道,而是发送逻辑块的序号,然后磁盘控制器会翻译成(盘面、磁道、扇区)的三元地址组;
- 操作系统是如何知道某个数据在磁盘上的逻辑块序号的?这个信息保存在哪里?
- 猜测有可能保存在文件的头部信息中,里面存放着一个文件在磁盘中的起始位置,然后当程序通过虚拟地址加载数据时,触发缺页异常,然后根据偏移量,加上磁盘上文件起始位置,得到数据的逻辑块序号;
- 操作系统是如何知道某个数据在磁盘上的逻辑块序号的?这个信息保存在哪里?
- 磁盘通过抽象一层虚拟的逻辑块系列,与实际的盘面、磁盘、扇区进行映射;简化了操作系统对数据访问;操作系统并不直接盘片打交道,而是发送逻辑块的序号,然后磁盘控制器会翻译成(盘面、磁道、扇区)的三元地址组;
- 连接 I/O 设备
- PCI 总线:外围设备互连总线,Peripheral Component Interconnect,它是一种 I/O 总线;这个总线管理各式各样的 I/O 设备,包括显卡、监视器、鼠标、键盘等;
- 其他总线还有 CPU 总线、内存总线;不同总线之间,通过 I/O 桥进行互连对接;
- 有三种类型的设备会连接到 I/O 总线
- USB:通用串行总线,Universal Serial Bus;它是一个中转机构;各种外围 I/O 设备通过它连接到 I/O 总线上;
- 图形卡:它负责代表 CPU 绘制像素并发送给显示器进行显示;
- 主机总线适配器:负责将磁盘连接到 I/O 总线;
- 早期的 PCI 总线是设备共享的,即一个时刻只能有一台设备访问这些线路;现在则以 PCIe 总线为主流,它的吞吐率达到 16GB/s,比如早期 PCI 的 533 MB/s 快很多;
- PCI 总线:外围设备互连总线,Peripheral Component Interconnect,它是一种 I/O 总线;这个总线管理各式各样的 I/O 设备,包括显卡、监视器、鼠标、键盘等;
- 访问磁盘
- CPU 使用内存映射 I/O 的技术,来与 I/O 设备进行通信;
- 例如在一个磁盘读事务中,过程如下
- CPU 先给磁盘发出3条指令,包括磁盘读指令及其参数,要读取的磁盘源地址,要存储的目标内存地址;
- 发完后,由于磁盘要很久后才能干完,CPU 会挂起当前操作,干其他事情去了;
- 发完这3条指令,到磁盘干完活之间的时间间隔,CPU 差不够可以再处理1600万条指令,所以在 CPU 眼里,磁盘干活的速度像个蜗牛一样;
- 磁盘接收到这三条指令中,从源地址中取出数据,然后直接存放到目标内存地址中,不需要发给 CPU 进行中转(即 DMA 技术,Direct Memory Access)
- 磁盘在数据安全存放到内存中以后,会发断一个中断信号给 CPU,告知自己工作已经完成;
- CPU 收到中断信号后,会停止当前的工作,跳转到此前中断的的工作处,继续完成原来的操作;
- CPU 先给磁盘发出3条指令,包括磁盘读指令及其参数,要读取的磁盘源地址,要存储的目标内存地址;
- 磁盘构造
- 固态硬盘
- 固态硬盘基于闪存技术,其内部其实是对多个闪存芯片的封装,并增加一个翻译层(类似磁盘控制器的角色);
- 一个闪存由多个块组成,一个块由 32-128 个页组成,一个页大小一般为 512字节-4KB之间;数据以页为单位进行读写;
- 对于一个页,只有它所属的块,整个都被擦除了以后,该页才能被写入;
- 一个块大约在10万次重复写之后,就损坏不能用了;
- 闪存的实现原理是什么?
- 存储技术趋势
- 存储变得越来越便宜,访问时间变得越来越快;
- 相对容量成本的变化速度,访问时间的提高速度相对很慢;
- CPU 与主存之间的速度差距在变大;
- 随机访问存储器
- 局部性
- 对程序数据引用的局部性
- 当数据被顺序访问时,它就会呈现出空间局部性;
- 取指令的局部性
- 如果指令是被顺序读取的,则具有空间局部性;
- 如果指令是重复执行的,则具有时间局部性;
- 局部性小结
- 重复引用相同变量的程序,具有时间局部性;
- 对于具有步长为 k 的引用模式的程序,k 越小,程序的空间局部性越好;
- 对于取指令来说,顺序指令和重复指令分别具有空间和时间上的局部性;
- 对程序数据引用的局部性
- 存储器层次结构
- 存储器结构中的缓存
- 设计思路:对于每个 k,位于 k 层的存储设备,做为 k + 1 层设备的缓存;
- 相邻层之间的数据传输使用相同大小的块为单位;不相邻层则块大小可能不同;
- 当程序需要 k + 1 层的某个数据时,优先从第 k 层开始查找;如果找不到,则意味缓存未命中,然后再到 k + 1 层查找;
- 这么说,数据的查找是从高到低逐级往下的,而数据的复制貌似则是从低到高逐级往上的?
- 最初状态时,缓存是空的,那么此时叫做冷缓存;接下来当出现几次反复访问进行热身后,冷缓存将不会再出现;
- 为了提高查找的效率,缓存一般采用某种数据放置策略,例如求模策略;
- 不过这种策略也有弱点,当程序刚好反复访问某个模数相同的块时,则会出现冲突不命中,但实际上缓存是够用的;
- 当缓存不够大时,则会出现容量不命中;即缓存无法容纳当前反复访问的数据工作集的全部数据;
- 不同层的缓存,是由不同的硬件或软件来管理的
- L0 层,即寄存器,是由编译器来管理的 ;
- L1~L3 层,即高速缓存,是由硬件来管理的;
- DRAM 主存:由操作系统和 CPU 上的地址翻译器来共同管理;
- 本地磁盘:由类似 AFS 或 NFS 这类的分布式文件软件进行管理;
- 总结:缓存无处不在,由于程序存在空间和时间上的局部性,导致缓存是一种行之有效的策略;
- 存储器结构中的缓存
- 高速缓存存储器
- 通用的高速缓存存储器组织结构
- 早期CPU 和 主存之间是直接通信的,后来由于二者的性能差距不断拉大,设计者被迫陆续引入多个高速缓存来做对接;
- 高速缓存内存的划分方法
- 分成 s 个组,每组有 e 行,每行有 m 位,其中1个有效位,t 个标记位,s 个索引位,b 个字节位;
- 有效位仅用来表示块中的数据是否有意义?
- 总共存储空间为 = s * e * b;
- t 个标记位是主存地址 m 位的一个子集;所以,给定 m,可以得到 t;
- s 个索引位也是主存地址 m 位的一个子集,因此给定m ,也可以得到 s;
- 这么看来,高速缓存的地址空间为 2(t + s) 那么多;但它的空间大小则为 s * e * b;
- 这里面有一个事情需要特别注意,组数、行数、块数都是实打实的映射,但标记位却没有,也就是说,会有多个相同标记位的地址,共享某组某行,因此,从这里实现了以更小的缓存,映射更大的主存;
- 当给定一个主存地址时,就可以通过解析地址,找到高速缓存中的映射位置,但如何知道映射位置有值呢?然后正好是想要的值?何时设置有效位?
- 首先,假设这个主存地址为 A,它由 m 个位组成,接下来 m 个位将从左到右分成三段,第一段是行的标记位,t 个;第二段是组的索引位,s个;第三段是块的偏移位,b 个;即 m = tsb 组成;
- 通过第 s 组的第 t 行的有效位是否设置为 1 判断是否有效;
- 通过地址最后 b 个位的值,得到在块中的偏移量,然后去读取相应位置的值即可得到结果;
- 当数据从主存加载到高速缓存中的时候,就会设置有效位为 1;
- CPU 需要一个字,但高速缓存从内存中取数据时,会读取并加载一个块的数据,然后再抽取其中的一个字给 CPU;
- 这样当 CPU 下次读取一个相邻的字时,就很有可能被预加载的块命中;
- 读取数据三部曲:组选择,行匹配,字选择;
- 直接映射高速缓存
- 根据每个组中的高速缓存行数,高速缓存被分为不同的种类;当每组只有一行时,叫做直接映射高速缓存;
- 块中有 2b 个字节,因此刚好可以放置主存地址最后 b 个位对应的所有数据,当然,需要知道偏移值才能读出来,而这个偏移值,刚好就是地址中 b 位的值;
- 当数据的长度刚好跟块的长度相同时,有可能会出现数据抖动;
- 高速缓存使用中间位索引的原因在于,这样可以避开内存上连续数组的数据,被映射到相同的组,导致出现抖动,即每次只有一个组被使用,大幅度降低了使用效率;
- 组相联高速缓存
- 一个组中有多行;这样使用是行匹配的时候,需要检查每个行的标记位了,直到找到为止;
- 看上去好像没有更快,但貌似可以减少抖动的概率;
- 未命中的时候,如何做行替换?
- 最简单粗暴的办法是使用随机替换策略;
- 高级的策略则统计使用频率,替换低频的行;但这需要增加硬件来实现这种功能,成本上升;
- 对程序员来说,很难在代码中影响这个策略;
- 一个组中有多行;这样使用是行匹配的时候,需要检查每个行的标记位了,直到找到为止;
- 全相联高速缓存
- 这种结构只有一个组,然后包含所有行;
- 可是这种样就没有组索引了?那不就跟将索引位放到头部去的效果是一样的?
- 而且这样一来,组内的标记位匹配的工作量也非常大,平均每次都需要遍历一半?
- 以上问题导致这种全相联的结构,只适合用来做非常小的高速缓存,例如虚拟内存中的翻译备用缓冲器(用来缓存页表项,不懂干嘛的?答:用来翻译虚拟内存地址到物理地址用的)
- 这种结构只有一个组,然后包含所有行;
- 有关写的问题
- 两种写策略
- 马上写:直写,write through,在更新高速缓存后,马上更新它的下一层的副本;缺点:消耗总线流量;
- 晚点写:写回,write back,尽可能推迟更新的时间点,在需要被覆盖的时候,再写到下一层;优点:总线流量少;缺点:增加了复杂度,需要增多一个位,记录是否当前数据是否被修改过;若未修改,直接写;若已修改,先复制到下一层,然后再写覆盖(有点像海浪一样,由后浪推动前浪向前写回);
- 处理写不中的两种策略
- 写分配:将未命中的快,加载到缓存中,然后更新它;优点:尽量利用空间局部性的程序特点;缺点:消耗流量;(一般和晚点写配合使用)
- 非写分配:将未命中的块,直到写到下一层中,抛弃对高速缓存的更新;(一般和马上写配合使用)
- 整体来说,晚点写+写分配是大势所趋;因此随着制造技术的提高,以前的高复杂度变得不再复杂,成本下降,性能提升;
- 两种写策略
- 一个真实的高速缓存层次结构的解剖
- 其实高速缓存分两种,一种用来存指令 i-cache,一种用来存数据 d-cache;这样分开是有道理的,因为指令一般是只读的,如果能够同时提供指令和数据CPU,性能也会更好;
- 有些高速缓存同时包含指令和数据,称为统一的高速缓存 unified cache;
- 高速缓存参数的性能影响
- 想要提高高速缓存的性能,是一项非常精细的工作,它需要对日常常见的大量代码,进行计算的模拟,然后找出最有效的方案;
- 影响高速缓存性能的参素包括:缓存大小、块大小、相联度、写策略;每一种参数值的选择,都是一把双刃剑,得到的同时,也会失去一些东西;
- 大缓存命中率高,但跑得慢;
- 块越大,命中率高,但不命中的惩罚成本也高,因为每次要复制更大的数据;
- 相联度高,降低了抖动概率,但增加了匹配时间;
- 写策略:越到低层,越需要使用晚点写,而不是马上写;因为马上写消耗的总线流量多;
- 通用的高速缓存存储器组织结构
- 编写高速缓存友好的代码
- 基本原则
- 让最常见的情况运行得最快;
- 尽量减小每个循环内部的缓存不命中数量
- 方法
- 对局部变量的反复引用是好的;
- 步长为1的引用模式是好的;
- 基本原则
- 综合:高速缓存对程序性能的影响
- 存储器山
- 读取数据的速度,有多种叫法,读吞吐量,或者读带宽;它们都是一回事;
- 例如 s 秒内,读了 n 个字节,则速度为 n / s;一般以 MB/s 为单位;
- 存储系统的性能由多个因素造成,它是一座时间和空间局部性的山;山的上升高度差别可以达到一个数量级;
- 读取数据的速度,有多种叫法,读吞吐量,或者读带宽;它们都是一回事;
- 重新排列循环以提高空间局部性
- 与内存访问次数相比,不命中率将会是一个更好的性能指标;
- 通过重新排列循环,提高空间局部性,可以得到很大的性能回报;可达40倍;
- 在程序中利用局部性
- 将注意力放在内循环上面,因为大部分内存访问都发生在内循环中,它们对性能的影响最大;
- 按步长1来顺序读取内存中连续存放的数据;
- 一旦存储器读入了一段数据,就要尽可能使用它完成所有相关的计算,以增加时间局部性;
- 存储器山
- 存储技术
- 链接
- 概念
- 链接其实质是将多个代码片段和数据片段组成成为一个单一文件的过程,以便这个单一文件可以被加载到内存中运行;
- 在以下三个阶段中的任何一个:编译时、加载时、运行时,都有可能发生链接的工作;
- 链接的好处在于方便让应用程序的代码实现模块化,当某个模块的代码有更新时,只需要重新编译该模块,并重新执行链接的工作,就可以完成整个应用程序的更新,而无需重新编译整个程序的所有模块;
- 编译器驱动程序
- 用户通过编译器系统提供的编译器驱动程序,来调用预处理器、编译器、汇编器和链接器;
- 编译过程
- 预处理器(cpp):将源程序文件 main.c 翻译成一个 ASCII 码的中间文件 main.i;
- 编译器(ccl):将 main.i 翻译成一个 ASCII 汇编语言文件 main.s;
- 汇编器(as):将 main.s 翻译成一个二进制的可重定位目标文件 main.o;
- 链接器(ld):将 main.o 以及其他 .o 文件,以及一些必要的系统目标文件合并起来,创建一个可执行目标文件;
- 静态链接
- 目标文件由代码和数据节(section) 组成;更通俗的说,目标文件纯粹是字节块的集合,有些块包含程序代码,有些块包含程序数据,而其他块则包含用于引导链接器和加载器的数据结构;
- 为了构造可执行文件,链接器需要做两个工作
- 符号解析:每个符号对应一个函数、全局变量或一个静态变量;符号解析的工作,即是将符号引用和符号定义关联起来;
- 重定位:目标文件里面有很多代码和数据节,链接器将每个符号定义与一个内存位置关联起来,然后再修改符号引用的地方,使它们指向这个内存位置
- 目标文件中,有各种字节块,链接器的责任,就是为这些块分配新位置,然后修改原位置信息为新分配的位置信息,确保所有的东西能够连接起来;
- 目标文件
- 三种形式
- 可重定位目标文件,relocatable file,由编译器和汇编器生成
- 可执行目标文件,executable file,由链接器生成;
- 共享目标文件,shared file,由编译器和汇编器生成
- 不同操作系统的目标文件格式不太相同
- LINUX 使用 ELF 格式,可执行可链接,Executable and Linkable Format;
- Win 使用 PE 格式,可移植可执行,Portable and Execuable;
- Mac 使用 Mach-O 格式;
- 三种形式
- 可重定位目标文件:由编译器和汇编器生成
- 它由以下几部分组成
- ELF 头
- 以16 字节序列开始:包含系统的字大小、字节顺序等信息;(固定长度)
- 余下部分用于帮助链接器解析目标文件的信息,内容包括
- ELF 头的大小(声明了ELF 头的长度,由此知道接下来的第二个节从哪里开始)
- 目标文件的类型(固定长度)
- 机器类型(固定长度)
- 节头部表的偏移量(固定长度,同时声明了节头部表的位置,从这个信息可以找到节头部表)
- 节头部表中的条目大小和数量;(固定长度,同时声明了节头部表的长度,知道在哪里结束)
- .text:已编译程序的机器代码(或许可以理解为一堆指令序列?)
- .rodata:只读数据,例如 printf 语句中的格式串,或者 switch 语句中的跳转表
- .data:已初始化的全局变量和静态变量;编译器在 .data 和 .bss 节中,为这些变量分配空间,并在符号表中创建一个有唯一名字的本地链接器符号;
- .bss:未初始化的静态变量,或者初始化为 0 的全局变量和静态变量(理论上好像应该是空的?虽然没有值,但分配了存储空间)
- 英文 block storage start,表示块存储的开始,或者应叫 better save space;
- .symtab:符号表,用来存放在程序中定义和引用的函数和全局变量的信息,但没有局部变量的条目;可以使用 STRIP 命令去掉它;
- 局部变量在程序运行过程中由栈进行管理,不属于链接器要负责的范围;
- 符号表实质是一个条目的数组;每个条目由如下字段组成:(如何知道符号表的条目数量?)
1.
1. name 是字符串表中的字节偏移,指向符号的以 NULL 结尾的字符串名字;(这里只能存整数,而不是名字的字符串,这样才能保证固定长度)
2. value 是距定义目标的节的超始位置的偏移;对于可执行文件来说,该值则变成一个绝对地址;
3. binding 表示符号是本地的还是全局的;
4. section 表示该符号属于目标文件的哪个节,它的值是节头部表的索引;有三种类型的伪节,它们在节头部表中没有条目,分别是
1. ABS:代表不该被重定位的符号
2. UNDEF:表示未定义的符号
3. COMMON:表示还未被分配位置的未初始化的数据目标;对于这类型的符号,value 字段的值变成是对齐要求,size 字段的值为最小的大小;
1. COMMON 代表未初始化的全局变量
2. .bss 存放的是未初始化的静态变量,以及初始化为 0 的全局变量和静态变量; - 符号表提供了关于符号的丰富的信息,包括该符号定义在哪个节,它在节中的位置(以偏移量计算)、它的名字在字符串表中的位置(以偏移量计算)、符号类型(函数或者数据);
- .rel.text:这个节用来存放那些被当前模块引用的外部符号
- .text 节中引用的外部符号的列表;当链接器将可重定位目标文件和其他文件组合时,需要修改这个表;以便给每个 .text 节中引用的外部符号分配新位置;
- 一般来说,任何调用外部函数和引用全局变量的指令,都需要修改位置;引用本地函数的指令则不用修改(本地函数貌似应该在符号表,而不是在 rel.text 表?)
- 为什么不用?因为本地的函数或指令,是根据本地所在节的偏移量计算的;
- 注意:可执行目标文件并不需要重定位信息,因此不包含这个节,除非编译的时候,显式通过编译选项指定包含;
- .rel.data:被模块引用或定义的所有全局变量的重定位信息;
- 一般来说,任何已经初始化的全局变量,如果它的初始值是一个全局变量地址或者外部定义函数的址,则都需要被修改;
- .debug,调试符号表
- 条目包括:
- 程序中定义的局部变量和类型定义;
- 程序中定义和引用的全局变量;
- 原始的 C 源文件;
- 注:只有使用 -g 选项编译时,才会有这个表;
- 条目包括:
- .line:原始 C 源程序中的行号,与 .text 节中机器指令之间的映射;只有使用 -g 选项编译时,才会有这个表;
- .strtab:字符串表,以 null 结尾的字符串序列,其内容包括
- .systab 和 .debug 节中的符号表
- 节头部中的节名字
- 节头部表
- 不同节的位置和大小都是由节头部表描述的;
- 每个节都有一个固定大小的条目;
- 由于节的数量和每个节的长度是不固定的,因此只有当节的信息都确认下来后,才有可能生节头部表,因此节头部表只能放在最后;然后在 ELF 头中用偏移量来指向它
- ELF 头
- 好奇每个可重定位目标文件都有上面的这些信息,当它们合并成一个可执行目标文件后,是否上面这些信息大部分都消失了?
- 答:会合并不同目标文件中相同节的信息
- 它由以下几部分组成
- 符号和符号表:包括在目标文件定义和引用的符号的信息;
- 三种不同的符号
- 由当前模块定义,并能够被其他模块引用的全局符号,对应于 C 源代码文件中的函数和全局变量;
- 由其他模块定义,并被当前模块引用的全局符号,也叫外部符号;
- 只被当前模块定义和引用的局部符号;对应 C 源代码文件中的 static 函数和变量;
- C 语言中的 static 可以间接的实现 C++ 和 Java 中的 private 功能,保护一些本地变量不被外部访问;多使用这个功能对变量进行保护和隐藏是很好的编程习惯;
- 符号表是由汇编器基于 .s 文件中的符号构造出来的;
- .s 文件,是编译过程第二步中,编译器基于 .i 文件生成的;
- 可重定位目标文件中,.symtab 节中包含 ELF 符号表;这些符号表是一些条目组成的数组;
- 所谓的条目,其实就是一个结构 struct;struct 中固定的字段,每个字段存储一些相应的信息,包括:
- name,符号名,不过在条目中存的值并非字符,不然长度不可控,它实际存的是字符串表的偏移值,有点像是一个指针;
- size,符号大小,单位为字节;
- type,符号类型,如函数或者数据
- binding,符号作用域,如全局或者局部;
- value,符号地址,如节偏移量,或者绝对地址;
- section,节头部表的索引下标,每个符号都会分配一个节,并在节头部表中记录,section 即存储着节头部表记录的索引下标值;
- 莫非遵循如下查找顺序:符号表->节头部表表->节?
- 所谓的条目,其实就是一个结构 struct;struct 中固定的字段,每个字段存储一些相应的信息,包括:
- 有三个特殊的伪节,它们在节头部表中并没有条目,
- 它们分别是:
- ABS:代表不该被重位的符号
- UNDEF:代表未定义的符号,即本模块中引用,但在其他地方定义的符号;缩写为 UND
- COMMON:代表未初始化的全局变量
- 如果它们没在节头部表中,那么如何找到这些伪节呢?
- 它们分别是:
- readelf 命令可以用来查看可重定位目标文件内的信息
- 它使用 Ndx 整数索引值来表示每个节;
- 三种不同的符号
- 符号解析
- 链接器解析符号的工作原理,是将某个位置的符号引用,与某个模块的符号表中的某个符号定义关联起来;
- 链接器对模块中局部符号的关联会很简单,因为编译器会为它们生成唯一的名字;
- 对于全局符号的关联则比较棘手;编译器会假设该全局符号在其他模块中定义,然后为它生成一条链接器符号表条目,之后的事情则交给链接器进行处理;如果链接器在所有模块中都找不到该符号的定义,则会抛出错误,例如 undefined reference to “foo”;也即编译器是不管符号在哪里定义的,它统统假设它们正确定义了,因此在生成目标文件时,编译器是不会报错的;
- 解析多重定义的全局符号的方法
- 前提:编译器会为每个符号做上强弱标志,然后汇编器会将强弱信息隐含编码在符号表里面,输出给之后链接器解读;
- 分类
- 函数和已定义的全局变量是强符号
- 未定义的全局变量是弱符号;(这也是编译器将其分类到 COMMON 伪节中的原因,目的在于将处理权交给链接器)
- 规则
- 不允许有多个同名的强符号
- 如果有一个强符号和多弱符号同名,选择强符号;
- 如果有多个弱符号同名,选择任意一个弱符号;
- 注意:规则2和规则3会带来不易觉察的错误,即两个模块中定义的同名符号,被模块中的某个函数意外修改,出现了预料之外的行为;
- 因此应尽量避免命名使用全局变量,如果要用,也尽量在声明的时候赋予初始值,这样出现同名会及早抛出错误;
- 使用编译选项 -Werror 可以将警告转成错误,引起重视;
- 使用编译选项 -GCC-fno-common 会触发对多重定义的全局符号进行报错;
- 多个可重定位的目标文件可被打包成一个存档文件(即静态库)时,以 .a 为后缀;它有一个头部,用来描述每个成员文件的大小和位置;
- 当链接器使用静态库生成可执行文件时,它只会选择其中被应用程序引用的模块,而不会全部选择,这个机制非常重要,有如下好处:
- 它使得打包结果不会很大,而且不会每个程序都存在大量重复的内容,不必占用过多内存;以及库的改动不会影响现有程序,除非现有程序要用到库中新加入的函数,才需要重新编译;
- 程序员对库的引用的工作尽量少;因为引用一个库就可以包含很多内容,不必手工引用很多文件;
- ISO C99 中常用的静态库
- libc.a 库中,包含以下常用函数:atoi, printf, scanf, stcpy, rand 等;不管是否显式声明,这个库在编译程序时,都是会隐式的发给链接器的;
- libm.a 库中,包含以下常用浮点函数,如 sin, cos, sqrt 等;
- 对静态库的引用有两种方式
- 绝对地址方式:gcc main.c /usr/lib/libm.a /usr/lib/libc.a
- 文件名+文件夹方式:gcc main.c -l c,m -L/usr/lib
- gcc -static 参数可以用来告诉编译器,要构造一个完全链接的可执行文件,可以直接加载到内存中运行 ,在加载时无须进一步的链接;
- 问:对动态库是否也适用?
- 答:查了一下官方文档,貌似这个选项专用于共享库,原文为 “on systems that support dynamic linking, this prevents linking with the shared liabraries. On other systems, this option has no effect”; (待测试一下使用效果)
- 链接器的工作过程是这样的(这个过程害死了无数人): 检查命令行中列出的文件,假设命令行为 gcc foo.c libx.a liby.a libz.a
- 如果发现有 .c 文件,先将所有 .c 的文件翻译成 .o 文件,确保最后只剩下 .o 文件和 .a 文件;
- 从左到右开始扫描,进行符号解析工作;
- 建立三个空的集合
- E 文件集合:集合中的文件后续用来合并成可执行文件;
- U 符号集合:用来存放在当前目标文件中引用,但没有定义的符号;
- D 符号集合:用来存放 E 集合的文件中那些已经定义的符号;
- 从左到右依次扫描每一个文件,假设当前扫描到的文件为 f
- 如果 f 是一个目标文件
- 将 f 放入 E 集合中;
- 将 f 中定义的符号添加到 D 符号集合中
- 将 f 中引用却未定义的符号,添加到 U 符号集合中;
- 如果 f 是一个存档文件(即库文件,由一个或多个成员目标文件组成),假设第一个成员文件叫 m
- 匹配:检查 U 集合中未定义的符号是否在 m 的符号表中,
- 如果没有,抛弃 m,继续扫描 f 中的下一个成员文件;
- 如果有
- 将 m 加入 E 集合中;
- 将 m 中定义的符号添加到 D 符号集合中
- 将 m 中引用却未定义的符号,添加到 U 符号集合中;
- 重复:继续扫描 f 中的下一个成员文件,重复上一步的匹配过程,直到 U 和 D 都不再发生变化;
- 结果:所有在 f 中但却不包含在 E 集合中的成员目标文件,抛弃;
- 匹配:检查 U 集合中未定义的符号是否在 m 的符号表中,
- 如果 f 是一个目标文件
- 最后:当链接器扫描完所有的输入文件后
- 如果 U 非空,则抛出错误,表示存在未定义的引用,并终止;
- 如果 U 为空,则合并并重定位 E 集合中的文件,构建输出可执行文件;
- 建立三个空的集合
- 库的引用规则
- 惯例:将库文件放在命令行的末尾,即在所有 .c 和 .o 文件的后面;
- 如果所有库之间相互独立,那么天下太平,回家睡觉;
- 如果所有库之间存在引用,那么需要排列这些库的顺序,
- 确保被引用的库排在引用者的后面;
- 如果二者相互引用,则重复输入库名,例如假设 foo 引用 x 库,x 库引用了y 库,y 库又引用了 x 库,即 foo -> x -> y -> x
- gcc foo.c libx.a liby.a libx.a
- 链接器解析符号的工作原理,是将某个位置的符号引用,与某个模块的符号表中的某个符号定义关联起来;
- 重定位
- 重定位有两步
- 重定位节和符号定义
- 将各个模块中,相同类型的节,合并成一个同类型的大节(聚合节);
- 问:合并的时候,如何确定顺序?
- 答:貌似除了主函数所在的节需要放在前面,其他的位置貌似无所谓?只要分配好内存位置后,更新符号表即可?好奇当主函数执行完了以后,后续还有其他代码节怎么办?如何知道程序已经结束了?哦,想起来了,主函数的末尾强制有一个 return,它会导致跳出;
- 将运行时的内存地址,赋给聚合节、聚合节中的每个小节,以及小节中的每个符号;最后,程序中的每条指令和每个变量都将拥有唯一的运行时的内存地址;
- 将各个模块中,相同类型的节,合并成一个同类型的大节(聚合节);
- 重定位节中的符号引用
- 修改代码节和数据节中对符号的引用,使它们指向正确的运行时内存地址;
- 问:所谓的变量初始化赋值,在机器代码中是怎么一回事呢?最终结果貌似应该是某个内存地址,有一个值;估计这跟数据节里面的内容有关;
- 修改代码节和数据节中对符号的引用,使它们指向正确的运行时内存地址;
- 重定位节和符号定义
- 重定位条目
- 汇编器在生成目标模块时,并不知道模块引用的函数和数据将来在内存中的地址;但它会为它们生成一个重定位条目,指示后续的链接器,要为这些重定位条目中的函数和数据安排地址;
- 代码的重定位条目放在 .rel.text 节中
- 已初始化数据的重定位条目放在 .rel.data 节中;
- 重定位条目是一个 Elf64_Rela 结构,它有一个字段 type 用来标识重定位的类型,最基本的有两种
- R_X86_64_PC32:它表示按程序计数器中的值,进行偏移重定位;
- 例如有个大的局部函数中,包含另外一个小的局部函数,则对小局部函数的引用,是否是相对大函数进行偏移?
- R_X86_64_32:它表示按绝对地址进行重定位;
- R_X86_64_PC32:它表示按程序计数器中的值,进行偏移重定位;
- gcc 默认使用 32 位寻址的重定位,这也意味着程序最大不能超过 2 GB;如果会超过,需要使用 -mcmode 选项告诉 gcc 新的寻址模式;
- 汇编器在生成目标模块时,并不知道模块引用的函数和数据将来在内存中的地址;但它会为它们生成一个重定位条目,指示后续的链接器,要为这些重定位条目中的函数和数据安排地址;
- 重定位符号引用列表
- 前提:每个节和每个符号已经被分配了地址;
- 步骤
- 遍历重定位条目表,对每一条重定位条目:
- 获取它的节偏移值,加上节地址,得到它的位置;
- 获取它的引用类型
- 如果是绝对引用,直接替换为符号的内存地址;
- 如果是按计数器引用,替换为符号的内存地址减去节地址,再减去节中的偏移地址;
- 完成遍历,结束;
- 遍历重定位条目表,对每一条重定位条目:
- 总结:重定位是一个很有意思的过程;在合并生成可执行文件,可重定位的目标文件,并不知各个符号最终的内存地址;因此,对于每一处的符号引用,它先在重定位模块中为其生成一个条目;待有了最终运行时的地址后,它遍历这个条目列表,将每一处的符号引用,替换为正确的运行时地址;
- 重定位有两步
- 可执行目标文件
- 可执行目标文件仍由各种节组成,大部分节跟可重定位目标文件一样
- 增加的新节:段头部表、init 节;
- 去掉的旧节:.rel.text, .rel.data,因为可执行目标文件是完全链接的,所以原来的引用表不再需要了;
- 为了让目标文件更快的加载到内存中,链接器在为目标文件生成虚拟内存地址的时候,会使用一定的对齐规则;
- 这也间接导致了目标文件中的各个节,不一定是连续的,中间可能因为对齐而产生一些缝隙;
- 另外为避免缓冲区溢出攻击,地址空间随机化也会产生空隙;不过这个空隙倒不是节之间的空隙,而是与起始位置的空隙;
- 可执行目标文件仍由各种节组成,大部分节跟可重定位目标文件一样
- 加载可执行目标文件
- 加载:将程序复制到内存中
- Linux 系统使用 execve 函数来调用加载器;
- 每个 Linux 程序都有一个运行时的虚拟内存镜像
- 代码段(只读代码段)总是从 0x400000 的位置开始;接下来是读/写段,然后是堆
- 不同程序的堆的起始位置不是固定的,取决于它前面两个段的大小;
- 堆的地址是从低往高走的,那如何设置上限以避免溢出呢?答:按目前的了解,貌似没有上限,因此会存在堆溢出;
- 刚发现这个读写数据段和代码段分开很重要,因为这样可以实现让读写段私有,而代码段共享,从而能够实现共享库在多个进程间共享代码,节省内存;
- 内核内存总是从 248 的位置开始,往高处方向走;
- 栈总是从 248-1 的位置开始,然后从高往低走
- 貌似这样可以避免栈溢出对内核内存部分带来的破坏?
- 代码段(只读代码段)总是从 0x400000 的位置开始;接下来是读/写段,然后是堆
- 在加载过程中,没有任何实际从磁盘到内存的数据复制,而只是做好了映射的工作,一直要等到 CPU 调用一个被映射的虚拟页面时,才会启动复制工作;
- 貌似这个机制在 CPU 和 GPU 的协同计算时,也是这么用的
- 加载:将程序复制到内存中
- 动态链接共享库
- 共享库在使用的时候,可以被加载到任意的内存位置,然后跟内存中的程序链接起来,链接的工作由动态链接器来完成;
- 两种不同的共享方式
- 在文件系统中,一个共享库只有一个 .so 文件,所有引用该库的程序,共享这个文件;
- 在内存中,一个共享库的 .text 节的一个副本,可以被不同的运行中的进程共享;(这种共享方式貌似有点奇怪?)
- 使用动态库时,在生成程序的可执行文件时,并不会复制一份共享库的代码,而只会复制它的一些重定位和符号表信息,以便在动态加载时,可以解析对动态库的引用;
- 可执行文件中会增加一个 interp 节,它包含动态链接器的路径名(因为动态链接器本身也是一个共享库)
- 如果加载器发现可执行文件包含共享库,则会按其中保存的路径名调用动态链接器,之后动态链接器完成以下工作
- 重定位共享库文件和数据到某个虚拟内存段;(相当于将共享库的代码和数据与内存中的代码和数据映射关联起来)
- 重定位程序中所有由共享库定义的符号的引用;(在上一步的工作完成后,得到了共享库的当前正确地址,根据这个地址,重新定位原执行文件中的引用符号的地址)
- 从应用程序中加载和链接共享库
- 共享库的两个有用场景
- 软件分发:共享库得软件更新变得轻量和简单,每次只需更新局部的共享库即可,对用户更加友好;
- 高性能的 Web 服务器:将一些动态功能做成共享库,在响应请求的时候,按需调用;避免使用子进程的开销;
- 感觉现在由于前后端分享,貌似这种用法也不多了;
- 不过另外一个好处是不需要停止服务器,即可以完成功能更新,实现热部署,这点倒是不错哦;
- Linux 系统还提供了一组函数,用来实现在代码中使用动态链接器加载共享库;定义在头文件 <dlfcn.h> 中
- dlopen, dlsym, dlclose, dlerror;
- 注意如果要使用以上四个函数,编译的时候需要加上 -rdynamic 选项,以及添加共享库 -ldl
- 示例:gcc -rdynamic -o prog prog.c -ldl
- Java 通过 JNI 接口实现跟 C 或 C++ 代码的对接;它的原理是将 C 代码编译成共享库,然后使用系统中 <dlfcn.h> 提供的函数,实现对共享库的调用;
- 估计 Python 也是使用相同的方法来实现对接工作;
- 共享库的两个有用场景
- 位置无关的代码
- 为了让多个进程共享一份共享库代码,需要共享库加载到内存中时,是位置无关的,这样可以避免内存管理的难题;
- 通过 -fPIC 选项显式要求 GNU 编译器将源文件编译为位置无关的代码;
- PIC:position independent code;
- PIC 数据引用
- 原理:在数据段开始的地方,增加一个 GOT 全局偏移量表(每个条目8字节),对于每个被引用的全局数据变量,在这个表中增加一个相应的条目;编译时,由于代码段跟数据段的距离是固定的,所以代码段中引用全局变量的地方,设置为对应的条目的偏移量地址,然后这个条目对应的全局地址是空的,等到实际加载时,由动态链接器往各条目写入全局绝对地址,这样代码段就可以间接引用到这个全局地址了;
- PIC 函数调用
- 函数调用使用了和数据引用不太一样的机制:延迟绑定技术(lazy binding),除了原来的 GOT,又增加了一个 PLT(过程链接表,procedure linkage table,每个条目16字节)
- 这个技术很有意思,也有点小复杂,过程是这样的
- 首次调用库函数时,调用动态链接器,获得库函数的全局地址,然后存放在 GOT 表中,然后引用库函数;
- 再次调用库函数时,直接在 GOT 表中得到之前存放的库函数全局地址,直接引用库函数;
- 库打桩机制
- 不知道为什么叫做库打桩这个名称,它其实就是对某个库函数做一个包装函数,让程序在调用这个库函数时,实际调用的是包装函数,这样我们就可以在包装函数内部做一些我们想实现的事情,同时也不影响库函数的正常使用;这种方式可以给调试带来很大的便利;
- 这个很有可能就是 valgrind 内存跟踪的实现机制;
- 打桩可以发生在各个阶段,比如编译时、链接时、加载时、运行时等;
- 编译时打桩
- 在头文件中定义宏来实现函数替换;
- gcc -DCOMPILETIME -c mymalloc.c
- 增加编译选项 -DCOMPILETIME 编译可重定位目标文件;
- gcc -I. -o intc int.c mymalloc.o
- 通过 -I 选项指定头文件的搜索位置,这样编译器就会优先使用本地的同名头文件进行编译,而不是标准位置的头文件;
- 链接时打桩
- gcc -DLINKTIME -c mymalloc.c
- 增加编译选项 -DLINKTIME编译可重定位目标文件;
- gcc -c int.c
- gcc -Wl,–wrap, malloc -Wl,–wrap,free -o intl int.o mymalloc.o
- 使用 -Wl 选项来告知编译器对某些函数进行替换(起到了宏的效果)
- gcc -DLINKTIME -c mymalloc.c
- 运行时打桩
- 原理:动态链接器在解析共享库之前,会检查环境变量 LD_PRELOAD 的值,如果有值,会先根据其中的路径列表,优先解析和加载里面的动态库的函数;
- 优点:编译和链接时打桩,需要有源代码文件或者目标文件,运行时打桩,则只需要知道函数名称即可;
- 过程
- gcc -DRUNTIME -shared -fPIC -o mymalloc.so mymalloc.c -ldl
- 构建含有包装函数的共享库
- gcc -o intr int.c
- 编译主程序;
- LD_PRELOAD=”./mymalloc.so” ./intr
- 运行主程序时指定环境 LD_PRELOAD 的值为包含包装函数共享库
- gcc -DRUNTIME -shared -fPIC -o mymalloc.so mymalloc.c -ldl
- LD_PRELOAD 可以用来对任何可执行程序调用的库函数进行打桩;
- 编译时打桩
- 不知道为什么叫做库打桩这个名称,它其实就是对某个库函数做一个包装函数,让程序在调用这个库函数时,实际调用的是包装函数,这样我们就可以在包装函数内部做一些我们想实现的事情,同时也不影响库函数的正常使用;这种方式可以给调试带来很大的便利;
- 处理目标文件的工具
- GNU binutils 工具包里面的有很多工具可以用来处理目标文件,了解关于目标文件的更多信息;
- AR:静态库处理工具,包括打包、提取、插入、删除成员文件等;
- STRINGS:列出目标文件中可打印的字符串;
- STRIP:删除目标文件中的符号表;
- NM: 列出目标文件符号表中的符号;
- SIZE:列出目标文件中节的大小和名字;
- READELF:显示目标文件的结构;
- OBJDUMP:显示一个目标文件的所有信息;可以用来反汇编 .text 节中的二进制指令;
- LDD:列出目标文件所有用到的共享库名称;
- GNU binutils 工具包里面的有很多工具可以用来处理目标文件,了解关于目标文件的更多信息;
- 概念
- 异常控制流
- 异常
- 从当前指令到下一条指令的过渡,称为控制转移;一系列的控制转移,组成控制流;系统除了对程序内部的状态变化做出反应外,还需要对系统状态的变化做出反应;系统通过使控制流发生突变来对系统状态变化做出反应,这种系统突变称之为异常控制流(EFC,Exceptional Control Flow);
- ECF 是系统用来实现 I/O、进程和虚拟内存的基本机制;
- 应用程序通过使用一个叫做陷阱(trap)或者系统调用(system call)的 ECF 异常控制流形式,来向操作系统申请服务;
- ECF 是实现并发的基本机制;实现并发的三种机制(进程、线程、I/O 多路复用)都跟 ECF 有关;
- 非本地跳转:规避通常的调用/返回栈规则的跳转方式;非本地跳转是一种应用层的 ECF;在 C 语言中可使用 setjmp 和 longjmp 函数来实现;其原理是先预保存环境在系统缓存中,然后在条件满足的时候,触发跳转到这个保存在缓存中的环境;
- 进程和信号位于应用和操作系统的交界之处;异常则位于硬件和操作系统的交界之处;
- 异常是异常控制流(ECF)的一种形式,它一部分由硬件实现,一部分由操作系统实现;
- 异常是控制流中的突变,用来响应处理器状态中的某些变化;
- 状态变化被称为事件;
- 在任何情况下,当处理器检测到有事件发生时,它就会通过一张叫做异常表(exception table)的跳转清单,进行一个间接过程调用(异常),到一个专门设计用来处理这类事件的操作系统子程序(异常处理程序 exception handler);当异常处理程序完成处理后,根据引起异常事件的类型,会发生以下三种情况中的一种
- 处理程序将控制返回给中断时的当前指令 Icurr
- 处理程序将控制返回给中断时的下一条指令 Inext
- 处理程序终止被中断的程序;
- 异常处理
- 系统中每种可能发生的异常都分配了一个非负整数的异常号(exception number);
- 处理器设计者分配的异常:除零、缺页、内存访问违例、断点、算术运算溢出等;
- 操作系统内核设计者分配的异常:系统调用、来自外部 I/O 设备的信号等;
- 异常表:表目 k 包含异常 k 的处理程序的地址;异常表的起始地址放在异常表基址寄存器中;
- 异常处理类似于普通的过程调用,但也有不同之处,包括
- 返回地址不确定:可能是返回被中断程序的当前指令,也可能是下一条指令;
- 它会将额外的处理器状态压入到栈中
- 当控制从用户程序转移到内核时,压入内核栈而非用户栈;
- 对资源具有完全的访问权限:因为它运行在内核模式下;
- 系统中每种可能发生的异常都分配了一个非负整数的异常号(exception number);
- 异常的类别
- 中断 interrupt
- 中断不是由处理器内部的任何一条指令触发的,而是来自处理器外部的 I/O 设备信号的结果;从这个意义上来说,它是异步的,即它不是执行当前指令的结果,其他三种类型的异常都是同步发生的,因此它们属于故障指令 faulting instruction;
- 过程:
- I/O 设备向处理器芯片的一个引脚发信号,并将异常号放到系统总线上,来触发中断,异常号则用来标识引起中断的设备;
- 在当前指令处理完成后,处理器注意到中断引脚的电压变高,就从系统总线中读取异常号,然后调用对应的中断处理程序;
- 当中断处理程序返回时,处理器将控制返回给下一条指令,原被中断的程序继续运行;
- 陷阱 trap 和系统调用
- 陷阱是有意的异常,是执行一条指令的结果;陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,称为系统调用;
- 用户程序经常需要向内核请求各种服务,因此处理器设计了一条 syscall n 的特殊指令,用户程序可以通过这条指令请求服务 n;
- 执行 syscall 指令会生成一个到异常处理程序的陷阱,这个程序解析参数,并调用适当的内核程序;
- 故障 fault
- 故障由错误情况引起,即执行当前指令的时候,发生了一个故障;此时控制传递给故障处理程序,若程序能够修复故障,则将控制返回给原触发故障的指令,重新执行指令;若不能修复,则返回控制到内核中的 abort 例程,它会终止引起故障的应用程序;
- 经典的故障示例是缺页异常;
- 对于缺页异常,貌似它的故障指令应该是由处理器发出的,因为处理器在收到程序的读取某个虚拟内存地址的指令时,会发现源操作数的标记位为未缓存,然后触发异常;
- 终止 abort
- 终止是不可恢复的致使错误造成的结果,通常是一些硬件错误;终止处理程序从不会将控制返回给应用程序;
- 中断 interrupt
- Linux/x86-64 系统中的异常
- 虽然在 C 语言中,通过 syscall 函数可以进行系统调用,但实际更多是使用 C 标准库中提供的包装函数进行调用,这些包装函数以及被包装的系统调用在本书中统称为系统级函数;
- 所有到 Linux 系统调用的参数都是使用通用寄存器来传递的,而不是通过栈传递;
- 进程
- 进程的经典定义就是一个执行中程序的实例;系统中的每个程序都运行在某个进程的上下文中;
- 上下文是由各种状态组成的,包括内存中的代码和数据、栈、通用寄存器中的内容、程序计数器、环境变量,以及打开文件描述符的集合;
- 逻辑控制流
- 进程为每个程序提供了一种逻辑流的抽象,让程序感觉自己好像在独占处理器,而不是将处理器的物理控制流暴露给应用程序;
- 每个进程执行它的流的一部分,然后被抢占(preempted,暂时挂起),然后轮到其他进程;
- 抢占的机制是什么?一般由系统内核来控制,它自己会有周期性的中断机制,然后进行调度;
- 并发流
- 一个逻辑流在执行时间上与另一个流重叠,称为并发流(concurrent flow);
- 一个进程与其他进程轮流运行的概念称为多任务(multitasking);
- 一个进程执行它的控制流的一部分的每一时间段称为时间片(time slice);因此,多任务也叫做时间分片(time slicing);
- 多个流并发执行的现象称为并发(concurrency);
- 如果两个流并发的运行在不同的处理器核或者计算机上,那么称它们为并行流(parallel flow);
- 并发流的概念跟处理器内核数或者计算机台数无关,只要两个进程的执行时间有重叠,就可以称它们为并发流;
- 私有地址空间
- 地址空间组成,从低处的 0x00400000 开始到高分别为
- 只读代码段:.init, .text, .rodata
- 读/写段:.data, .bss
- 运行时堆:堆顶指针 brk
- 共享库的内存映射区域
- 运行时栈:栈顶指针 %esp
- 内核虚拟内存:代码、数据、堆、栈;此部分对应用程序的代码不可见
- 地址空间组成,从低处的 0x00400000 开始到高分别为
- 用户模式和内核模式
- 处理器通过“控制寄存器”的模式位限制了应用程序的进程可以访问的地址空间范围以及可以执行的指令;
- 当未设置控制位时,进程运行在用户模式下,应用程序必须通过系统调用的接口,来间接的访问内核代码和数据,否则会触发保护故障;
- 之前一直好奇异常处理程序的指令存在哪里,突然想到有可能如下:一开始它们是由操作系统携带进来的,在加载操作系统后,从磁盘复制到了内存中;然后在开启一个新的进程时,进程中的虚拟内存的内核段就会映射物理内存中的异常处理程序;
- 当设置了控制位后,进程就会运行在内核模式下(也即超级用户模式),此时进程可以访问任意的内存位置,并可以执行指令集中的任何指令;
- 进程初始运行在用户模式下,要从用户模式变成内核模式的唯一办法是通过异常机制,即中断、陷阱或故障,此时控制会转移到异常处理程序,然后处理器将模式转变为内核模式,以便异常处理程序运行在内核模式下;当需要将控制返回给应用程序时,处理器再把模式切换回用户模式;
- Linux 系统通过 /proc 文件系统,允许用户模式下的进程访问内核数据结构中的内容; /proc 文件系统将内核数据结构输出为一个可以读的文本文件的层次结构;2.6 之后版本的 Linux 还引入了 /sys 文件系统,它输出系统总线和设备的额外的低层信息;
- 上下文切换
- 上下文是内核重新启动一个被抢占进程所需要的状态,它由一些对象的值组成,这些对象包括通用目的寄存器、浮点寄存器、程序计数器、状态寄存器、内核栈、用户栈,以及各种内核数据结构(如包含虚拟地址映射信息的页表、包含有关当前进程信息的进程表、包含已打开文件信息的文件表);
- 调度:内核决定抢占某个正在运行的进程,并重新开始一个之前运行的进程;由内核代码中的调度器来完成这个动作
- 调度三板斧:保存、恢复、转移控制;
- 每个进程都有自己的虚拟内存,因此两个不同进程很可能会使用某一个相同的虚拟内存地址,当 CPU 收到某条指令操作某个虚拟内存地址时,它完全不知道应该映射到哪个物理内存地址,但是,通过为每个进程创建一张单独的映射物理内存的页表,就可以实现正确的地址翻译;因为,需要使用一个页表寄存器,来表明当前应该使用哪一张页表进行翻译工作;所以,对于调度来说,除了修改程序计数器外,还需要修改页表寄存器,才能真正的完成控制转移的工作;
- 所有的系统都有周期性的中断机制,例如1毫秒或10毫秒;内核根据周期性的中断信号,可以判断当前进程已经进行到足够长的时间,然后它就会强制进行切换;
- 由于异常处理程序是运行在内核模式下的,所以整个调度的过程,实际是反复在用户模式和内核模式之间切换; 从一个进程到另外一个进程的切换发生在内核模式的运行期间;
- 当某个应用程序进程 A 进行系统调用发生阻塞时,内核会让其进入休眠,并切换到其他进程 B 进行处理;直到阻塞的工作已经完成并发出中断信号后,此时如果内核判断 B 进程已经运行了足够长的时间,它将切换回 A 进程;
- 系统调用错误处理
- 对函数调用是否出错进行检查是良好的编程习惯,它有助于在第一时间暴露问题,减少调试的时间;
- 有两种处理错误的方法
- 方法一:使用宏,简化错误检查的代码行数,例如 Shaw 关于调式宏的做法;
- 方法二:使用错误包装处理函数,编写一个大写字母开头的同名函数,增加错误检查代码;这样在函数调用的地方,改用包装函数,将使得代码更加简洁(此方法推荐使用)
- 进程控制
- 获取进程 ID
- #include <sys/types.h>
- #include <unistd.h>
- pid_t getpid(void); // 获取当前进程的 pid
- pid_t getppid(void); // 获取当前进程的父进程的 pid
- 创建和终止进程
- 进程仅有三种状态
- 运行:正在执行,或者等待调度后执行;
- 暂停:被挂起,直到收到信号后才会开始执行,不会被调度;
- 终止:永远停止了;
- 终止进程的三种办法
- 收到终止进程的信号
- 从主程序 main 中返回;
- 调用 exit 函数终止
- #include <stdlib.h>
- void exit(int status);
- 通过调用 fork 函数可以用来创建子进程
- #include <sys/types.h>
- #include <unistd.h>
- pid_t fork(void);
- fork 是一个非常特别的函数,它被调用一次,却会返回两次,原因在于,当它被调用时,子进程会复制一份父进程的地址空间,因此这个时候同一个返回值变量 pid,产生了两个分身,一个在父进程中,一个在子进程中;父进程中 pid 变量的值为子进程的真正的 pid 号,子进程的 pid 号为 0;
- 因此,可以通过判断 pid 变量是否为0,来有选择的执行那些需要在子进程中执行的代码;
- 当程序中有多个 fork 时,要特别小心;子进程的创建将可能出现指数变化,而不是线性变化;
- 父子进程是并发进行的独立进程,所以它们各自的代码的执行顺序是不确定的;
- 父子进程拥有相同但独立的地址空间;
- 父子进程之间共享文件符描述表,因此可以对同一份文件进行操作;
- 进程仅有三种状态
- 回收子进程
- 当一个进程终止时,它只是变成了终止的状态,但并没有马上被回收和清除;要一直等到它的父进程对它进行回收时,它才会彻底的消失;在未回收前,该进程称为僵死进程;
- 在系统启动时,会创建一个 pid 为 1 的 init 进程,它是所有进程的祖先;如果一个父进程终止时,没有回收它自己的子进程,那么内核会安排 init 进程回收子进程;
- 如果子进程仍在运行,则子进程会变成一个后台守护进程(daemon)(搞不好创建守护进程的方法,就是通过一个父进程创建它,然后杀死这个父进程,使新创建的进程变成孤儿进程,这个时候它就会是一个守护进程了?)
- 一个进程可以通过调用 waitpid 函数来等待它的子进程终止或停止;
- #include <sys/types.h>
- #include <sys/wait.h>
- pid_t waitpid(pid_t pid, int *statusp, int options);
- 参数 pid:用来设置等待集合,当 pid > 0 时,等待集合只有一个指定的子进程,它 进程 ID 为 pid;当 pid = -1 时,等待集合是父进程创建的所有子进程;
- 参数 statusp:用来存放导致子进程中止的各种状态信息;
- 参数 options:设置等待的行为特征,例如不同的等待方式;支持位组合
- 默认情况下(当 options = 0时),waitpid 会挂起调用者进程的执行,直到它的等待集合(wait set) 中的一个子进程终止。如果等待集合中的一个进程在调用时即已经终止了,则 waitpid 将马上返回;不管哪种情况,当 waitpid 返回的时候,它都会返回那个导致它返回的进程的 PID 值;此时,终止的子进程已经被回收并被内核清除;
- 如果调用进程没有子进程,或者 waitpid 函数被某个信号中断,则会返回 -1,并设置 errno 为 ECHILD 或 EINTR;
- pid_t wait(int *statusp) 是简化版,等同于 waitpid(-1, &status, 0);
- 当父进程有多个子进程时,可以通过指定对应的子进程 PID 来进行有顺序控制的等待;
- 让进程休眠
- #include <unistd.h>
- unsigned int sleep(unsigned int secs); // 将进程挂起一段指定的时间;当时间到了后,它会返回 0;如果 sleep 函数被某个信号中断导致过早返回,则它返回的不是 0,而是剩余时间;
- int pause(void); // 让调用进程进入休眠状态,直到收到信号后再重新启动;
- 加载并运行进程
- #include <unistd.h>
- int execve(const char *filename, const char *argv[ ], const char *envp[ ]);
- filename: 可执行目标文件的路径
- argv: 指向参数字符串的数组指针;
- envp: 指向环境变量字符串的数据指针;
- 调用 fork 时,会创建一个子进程,并返回两次;
- 调用 execve 时,则直接对原进程虚拟地址空间中的用户数据进行覆盖,但会继承原来的文件描述符,除非调用出错,不然它不会返回任何结果;
- 调用 execve 时,它会调用执行操作系统内核中提供的加载器代码,将可执行文件的代码节和数据节从磁盘复制到内存中,并将控制转移到代码节的入口点(即主函数 main),开始执行里面的指令;
- 主函数:int main(int argc, char *argv[ ], char *envp[ ]);
- 当一个新程序开始时,用户栈的典型组织如下,从栈底开始往栈顶方向:
- 以 null 结尾的环境变量字符串;
- 以 null 结尾的参数变量字符串;
- 以 NULL 结尾的环境变量指针数组;
- 以 NULL 结尾的参数变量指针数组;
- libc_start_main 的栈桢;
- main 的未来的栈桢;
- argc, argv, envp 三者的值分别存储在寄存器 %rdi, %rsi, $rdx 中;它们指向以上栈中相应的位置;
- 操作环境变量的函数
- #include <stdlib.h>
- char *getenv(const char *name);
- 用来搜索查询环境变量 name 的值,若没有找到,则返回 NULL;
- 这里特别有意思的是,环境变量的键值对是一起存储的,它们以等号 = 分隔,即存成 “name=value” 的形式;
- int setenv(const char *name, const char *newvalue, int overwrite); // 用来替换旧值,若旧值不存在,则直接创建新值;
- void unsetenv(const char *name); // 用来删除环境变量;
- 程序与进程的区别
- 程序只是一堆代码和数据,它们通常存储于磁盘中,在运行前会加载到内存中;
- 进程是进行中的程序的一个实例,程序总是需要运行某个进程的上下文中;
- 利用 fork 和 execve 运行程序
- shell 的基本工作原理即是使用 fork 和 execve 来运行程序;
- 过程:
- 等待用户输入命令行
- 解析命令行,判断为内置命令或要求调用可执行文件,判断前台运行(等待子进程结束)或后台运行(不等待)
- 使用 fork + execve 在子进程中运行相应的程序;
- 获取进程 ID
- 信号
- 异常是由硬件和内核共同处理的,其中有部分对用户进程是不可见的;Linux 信号则通过软件的形式,提高了一种更高层次的抽象的异常机制,它是一条小消息,用来通知进程系统发生了某个事件;
- 每种信号类型都对应某种系统事件,借助信号机制,进程和内核之间,进程与进程之间可以实现相互通信;
- 信号术语
- 发送信号:内核通过更新目的进程上下文中的某个状态变量值,来给进程发送一个信号;
- 接收信号:当目的进程对信号做出反应时,它就接收了信号;
- 待处理信号:当信号发出后,但未被接收前的状态;
- 同一种类型的信号,最多只会有一个待处理的信号,后续发过来的同种类型的信号,会被丢弃,不会进入待处理;
- 一个进程可以有选择的对某种信号进行阻塞,当阻塞时,信号仍然可以发出,但不会被接收,直到取消阻塞为止;
- 所谓的不会被接收,或许只是相当于进程不对信号做出反应?
- 一个待处理信号最多只会被接收一次;内核通过一个位向量来维护待处理信号和阻塞信号的集合;
- 当信号被接收后,估计进程会对信号位向量进行重置更新;
- 发送信号
- 每个进程都属于一个进程组;它用一个正整数的 ID 来标识
- #include <unistd.h>
- pid_t getpgrp(void); // 用来获取当前进程的进程组 ID;
- pid_t setpgid(pid_t pid, pid_t pgid); // 用来设置某个进程所属的进程组ID;
- /bin/kill 程序可以用来向另外的进程发送任意的信号
- 示例1:linux> /bin/kill 9 15213 // 表示给进程 15213 发送信号 9
- 示例2:linux> /bin/kill -9 15213 // 此处信号使用负数,它表示给进程组 15213 发送信号 9
- 用键盘发送信号
- 在 Unix Shell 中,在对命令行进行求值时,shell 会为其创建进程,不过在 shell 中是以作业(job) 的概念来表示进程;
- 在键盘上输入 CTRL + C 或者 CTRL + Z 会发送相应的信号给 shell 前台进程组中的每个成员;
- 用 kill 函数发送信号
- #include <sys/types.h>
- #include <signal.h>
- int kill(pid_t pid, int sig);
- 进程可以通过调用 kill 函数来给其他进程或者自己发送信号;
- 用 alarm 函数发送信号
- #include <unistd.h>
- unsigned int alarm(unsigned int secs);
- alarm 函数只能用来给自己发送 SIGALRM 信号;如果调用时,前面有闹钟未到点,之前的闹钟会被清除,并会返回之前闹钟的剩余时间;如果前面没有未到点的闹钟,则会返回0;
- 每个进程都属于一个进程组;它用一个正整数的 ID 来标识
- 接收信号
- 当内核将进程 p 从内核模式切换到用户模式时,内核会检查进程的待处理且未阻塞的信号集合,如果非空,则一般会选择最小的那个信号发给进程 p,并强制其接收;进程收到信号后会触发某种处理行为,处理完成后,控制转移到原 p 进程中待处理的下一条指令;
- 每个信号类型有一个预定的默认行为,下面四种之一
- 终止进程
- 终止进程并转储内存
- 挂起进程等待 SIGCONT 信号后重启;
- 忽略信号
- 调用 signal 函数可以为信号设置不同的处理程序
- #include <signal.h>
- typedef void (*sighandler_t)(int);
- sighandler_t signal(int signum, sighandler_t handler);
- 若 handler 为 SIG_IGN 则表示忽略信号;若为 SIG_DFL 则表示使用默认行为;否则应为用户自定义的信息处理程序;
- 同一个信号处理程序,可以用来处理多个不同类型的信号;
- 信号处理程序可以被其他信号中断,中断行为同主程序,逐级向下转移,处理好了后,再逐级向上转移;
- 可以在处理前,先暂时阻塞所有信号,待处理后,再进行恢复,这样就可以避免信号处理程序被中断;
- 调用信号处理程序即“捕获信号”;执行信号处理程序即“处理信号”;
- 阻塞和解除阻塞信号
- 隐式阻塞机制:如果信号处理程序正在处理某个信号类型,则当收到新的同类型信号 b 时,内核是默认阻塞的,会将 b 放入待处理信号的集合;如果此时再有同类型的 c 信号进来,因为已经有 b 在待处理集合中,c 信号会被简单丢弃;
- 显式阻塞机制;应用程序使用 sigprocmask 函数和它的辅助函数,明确的阻塞和解除阻塞选定的信号;
- 编写信号处理程序
- 挑战
- 由于信号处理程序与主程序以及其他信号处理程序共享全局变量且并发运行,因此不可避免存在相互干扰的问题;
- 如何接收信号、何时接收信号的规则常常有违人的直觉;
- 不同的系统有不同的信号处理语义;
- 安全的信号处理原则
- 处理程序要尽可能简单;例如只是简单设置标志位并立即返回;所有与接收信号相关的处理都由主程序执行;
- 在信号处理程序中只调用异步信号安全的函数,即要么这个函数是可重入的(只使用局部变量),要么它不能被信号处理程序中断;
- 保存和恢复 errno:如果处理程序要返回,则在进入处理程序后,用一个局部变量保存 errno,然后在返回的时候恢复它;如果处理程序不返回,则不需要这么做;
- 原因:在调用异步信号安全的函数时,它们在出错时可能会设置 errno;因此,通过恢复机制,可避免处理程序干扰主程序中依赖于 errno 的代码
- 阻塞所有的信号,保护对共享全局数据结构的访问:当主程序和信号处理程序有共享全局数据结构时,则在访问该数据结构时,应暂时阻塞所有的信号,以便在读取一半的时候,数据不小心被修改了;
- 貌似也可以通过红绿灯检查的机制,来实现访问加锁;
- 用 volatile 声明全局变量:用来告诉编译器不要缓存这个变量,避免某个全局变量被编译器优化成缓存了,导致读取不到该变量的更新值;
- 用 sig_atomic_t 类型来声明标志:由 C 提供的这个类型的变量,它的读和写会是原子性的(不可中断的)的;但是,原子性只适合于单条指令的数据更新,不适用于多条指令组成的数据更新;
- 正确的信号处理
- 由于信号的不排队机制,因此应该避免使用信号来对其他进程中的事件进行计数,不然结果并不准确;
- 可移植的信号处理
- 不同操作系统对系统处理的定义不同,导致相同方法在不同操作系统中具备不同的效果;
- 通过引入一个 signal 的包装函数,在包装函数中使用 sigaction 函数,明确指定具体的一种信号处理语义,从而达到统一的效果;
- 挑战
- 同步流以避免讨厌的并发错误
- 由于父进程和子进程是并发执行的,因此无法保证它们的执行顺序;为了确保它们能够按照某种指定的顺序执行,可以通过暂时阻塞某种类型信号,保证某个操作全部执行完毕后,再解除对该信号的阻塞,这样可以使得一些依赖于该信号的操作,不会提前发生;
- 显式的等待信号
- 可以通过 while 循环或者 pause 或者 sleep 的方式来等待信号,但是它们都有各自的缺点
- while 需要一直消耗资源;
- pause 则会产生竞争,即 while 开始后 pause 开始前如果收到信号,会导致 pause 后永远无法再收到信号,因为信号一直阻塞在待处理集合中;
- sleep 则性能太差了,它的单位是按秒
- nanosleep 虽然可以将单位做到纳秒,但是由于无法确定信号发生的间隔时间,间隔太小,浪费资源;间隔太大,性能不好;
- 解决办法是引入 sigsuspend 函数
- #include <signal.h>
- int sigsuspend(const sigset_t *mask);
- 它的工作原理是先使用 mask 集合替代当前的 block 集合,这样可以放信号进来;待信号进来后,捕获它并恢复到以前的 block 集合;也就是说,不管信号是 suspend 之前发生还是之后发生,都会被捕获,不存在竞争;而且由于 suspend 的等待是阻塞的,它不会消耗资源,不存在性能问题;
- 可以通过 while 循环或者 pause 或者 sleep 的方式来等待信号,但是它们都有各自的缺点
- 非本地跳转
- 通常情况下,函数的调用和返回是通过栈来进行的,即进栈和出栈;C 语言提供了一种可以跨栈的跳转方式,叫非本地跳转(nonlocal jump);它可以将控制从一个函数直接转移到另一个当前正在执行的函数,而不需经过栈;它的一个使用场景就是深层嵌套函数的跳出立即返回;
- 通过 setjump 和 longjump 两个函数配套使用来实现非本地跳转的功能;
- #include <setjmp.h>
- int setjmp(jmp_buf env);
- 用于在 env 缓冲区中保存当前的调用环境,包括程序计数器、栈指针、通用目的寄存器;
- setjmp 会返回两次,第一次发生调用后;第二次发生在对应的 longjmp 被调用后;
- setjmp 返回的值不能被赋予某个变量,只能直接使用,无法通过变量储存;
- int sigsetjmp(sigjmp_buf env, int savesigs); // 用于信号处理的版本
- void longjmp(jmp_buf env, int retval); // 用来从 env 缓冲区恢复调用环境,并触发一个 setjmp 函数的返回,返回值为 retval;
- void siglongjmp(sigjmp_buf env, int retval); // 用于信号处理的版本
- longjmp 的跳转由于绕过了栈,因此可能存在内存泄露的隐患,因为在中间栈过程中创建分配的数据结构可能没有回收,它们原本可能在中间函数末尾的代码中进行回收的;
- 非本地跳转还有另外一个使用场景是让信号处理程序跳转到某个指定的位置,而不是原指令中断处;例如可以用来处理用户的键盘中断信号;但是,由于 setjmp 本身不是信号异步安全的,而 longjmp 又可以跳转到任意的代码位置,因此,有必要确保 longjmp 跳转后的位置之后中的代码,是信号异步安全的;
- Java 和 C++ 实现了更高抽象层次的 setjmp 和 longjmp 版本,分别类似其中的 catch 子句和 throw 子句;
- 操作进程的工具
- STRACE:打印一个正在运行的程序和它的子程序做出各自系统调用的记录;
- 经过测试,执行一个简单 hello world 的 C 程序,涉及的系统调用包括
- execve 加载代码
- brk 移动栈指针分配和回收内存
- access 检查文件权限
- openat 加载链接器动态库
- mmap 映射虚拟内存
- read 读取文件描述符中的内容
- mprotect 保护内存段;
- close 关闭文件描述符
- fstat 获取文件状态;
- munmap 取消虚拟内存映射
- write 输出内容到文件描述符中
- exit_group 结束所有线程;
- 经过测试,执行一个简单 hello world 的 C 程序,涉及的系统调用包括
- PS:列出当前系统中的进程(包括僵死进程);
- TOP:打印当前进程资源使用的信息;
- PMAP:显示进程的内存映射;
- /proc:一个虚拟文件系统,用来输出内核数据结构的内容,用户程序可以读取这些内容;
- STRACE:打印一个正在运行的程序和它的子程序做出各自系统调用的记录;
- 异常
- 虚拟内存
- 物理和虚拟寻址
- 物理寻址:CPU 指令中的地址即为内存的物理地址,通过内存总线直接给内存控制器进行寻址;
- 虚拟寻址:CPU 指令中的地址是虚拟地址,需要先翻译成物理地址后,才能发给内存控制器寻址;
- 实现原理:CPU 芯片上有个 MMU 内存管理单元的硬件,同时配合操作系统存放在内存中的查询表(页表, page table),来完成翻译的工作;
- 页表负责保存虚拟地址和物理地址之间的映射关系;
- 这个机制有三个重要的功能
- 通过虚拟页映射磁盘上的文件,当试图访问这些块时,触发缺页异常,然后再从磁盘读入数据;
- 简化了内存管理,从而简化了链接、进程间共享数据、进程的内存分配,以及程序加载;
- 通过在页表条目加入保护位,实现了内存保护;
- 地址空间
- 地址空间:一个非负的整数集合;
- 虚拟地址是由 CPU 生成的;物理地址空间则对应物理内存,因此每个物理地址是唯一的,但它可以被多个虚拟地址映射;
- 虚拟内存作为缓存工具
- 虚拟内存,在概念上是存储在磁盘上的连续的数组;
- 注意:在存储器层次结构中,磁盘的层级是低于内存的;因此,内存可以视为磁盘的上一层缓存;
- 磁盘上的数据被切割成块,这些块作为与更高一层的主存之间的传输单元;
- 每个块称为一个虚拟页 visual page VP,物理内存也被分割物物理页 physical page PP,二者的大小是一样的,P = 2p;
- 在 CPU 指令里,只有虚拟内存,物理内存也是不存在的,它只是逻辑上的缓存,CPU 除了寄存器外,只跟虚拟内存打交道
- 而实际磁盘上的所有数据,有些是在物理磁盘上,有些是在网络上,有些是在物理内存中,但 CPU 不管它们在哪里;CPU 指令中的地址,全部是虚拟内存地址;
- 虚拟页面由三个集合组成
- 未分配:VM 系统未分配的页,不占空间,也无数据
- 未缓存:VM 系统已分配的页,未缓存,有数据,不占空间;
- 已缓存:VM 系统已分配的页,已缓存,有数据,占空间;
- 由于物理磁盘与 DRAM 之间巨大的访问时间差,所以对物理磁盘的操作,总是使用写回,而非直写;同时,由于不命中惩罚的开销很大,所以虚拟页一般设计的比较大(4KB - 2MB),以降低不命中的概率;
- DRAM 缓存使用全相联的结构,那么意味着缓存中的任意一行,都可以存放任意一个虚拟页;完全是无序的;同样意味着替换策略的选择变得很重要;
- 看上去,页表是固定顺序排列的,即根据索引号计算偏移量,就可以得到对应的条目。但这样一条,貌似这个页表需要挺大的哦;
- 假设存储一个物理地址需要8字节,那么这个页面岂不是要耗掉等大的内存?
- 是很大,不过可以通过多级页表来解决;
- 假设存储一个物理地址需要8字节,那么这个页面岂不是要耗掉等大的内存?
- 缺页:即缓存不命中;
- 处理过程:
- 根据页表有效位发现未缓存,触发缺页异常;
- 内核收到异常信号,调用缺页异常处理程序
- 假设物理内存未满,下一步;
- 假设物理内存已满,选择一个牺牲页,查找其数据是否修改,
- 若已修改,将其复制到磁盘;下一步;
- 若未修改,下一步;
- 缺页异常处理程序前往磁盘复制数据,存放到物理内存中,更新页表,然后返回信号;
- CPU 收到信号,继续原中断的进程,执行原导致缺页的指令,再次前往页表查询物理内存地址,随后到高速缓存中读取数据;
- 目前的计算机都采用按需页面调度,即仅在发生不命中时,才会去换入页面;而不会尝试预测不命中,然后提前换入;
- 处理过程:
- 分配页面
- 当调用 malloc 指令时,内核在虚拟内存上分配空间,然后更新页表,使其指向这个空间;
- 但此时对应的物理地址应该仍是空的,一直到等到需要写入数据时,页表的物理地址栏才会有值;
- 当调用 malloc 指令时,内核在虚拟内存上分配空间,然后更新页表,使其指向这个空间;
- 当程序小于物理内存时,虚拟内存将工作得很好,不容易发生频繁的页面调度(即抖动 thrashing),原因在于程序的局部性 locality 特性,这个特性使得程序总是趋向于访问某个较小的特定页面上的数据集合,叫工作集 working set,或者常驻集合 resident set;
- linux 下有个工具 getrusage 函数,可以用来查看缺页的数量;
- 虚拟内存,在概念上是存储在磁盘上的连续的数组;
- 虚拟内存作为内存管理工具
- 实际上操作系统为每个进程分配了一个独立的页表和独立的虚拟地址空间
- 这个机制有很大的作用,有如下的好处
- 简化链接:链接器在生成可执行文件的时候,可以使用统一的格式为每个可执行文件分配虚拟地址,不必考虑其他程序,简化了链接器的工作;
- 简化加载:加载器为目标文件的代码节和数据节分配虚拟页,把它们标记为未缓存,然后更新页表条目,使其指向目标文件中对应的位置;
- 但加载器并不实际复制数据;数据的实际复制,要等到数据被引用时,发现未缓存触发缺页异常时,才会发生;
- 简化共享:每个进程私有自己的数据,但操作系统通过页表的映射,可以为它们之间实现物理上的共享;
- 操作系统如何知道某些文件已经被其他进程加载到物理内存中了?通过缓存的机制来判断,因为在加载文件时,进程最初给出的是虚拟地址,此地址之后被翻译成对应物理磁盘上的某个位置,得到磁盘物理地址,然后此物理地址里面的内容如果在之前已经被其他进程加载过,就会出现在缓存中(即物理内存和高速缓存),所以当前进程就不需要重复加载磁盘上的文件了,直接返回缓存中的内容即可;
- 简化内存分配:在虚拟地址空间中,内存可以是连续分配的;但在物理空间中,它们其实可以随机分散存储;
- 虚拟内存作为内存保护工具
- 通过在页表中添加许可位,可以很方便的实现权限控制,例如角色位、读位、写位;
- 当进程尝试访问没有权限的条目时,就会触发段错误 segmentation fault;
- 地址翻译
- 刚发现页表并不需要映射物理内存为的每个字节,而只需要映射每个页即可;而一个页有 4KB-2MB,所以其实页表要比物理内存的容量小得多;至少差距4000-2百万倍;
- CPU 中有一个寄存器用来保存页表在内存中的地址(位置);有了这个地址,就可以读取内存中的页表了;
- 这个寄存器叫做页表基址寄存器, PTBR page table base register;
- CPU 对 SRAM 的访问,可以使用虚拟地址,也可以使用物理地址,目前大多数操作系统都使用物理寻址;因为这种方式使得进程间共享数据变得很方便;
- 但物理寻址带来的问题是访问 SRAM 之前,需要先使用 MMU 内存管理器对虚拟地址进行翻译,这需要消耗时间,尤其是没有命中的时候,就会导致需要从内存读取两次数据;(为什么需要读两次数据?答:第一次根据虚拟地址从内存中读页表获取物理地址,第二次根据物理地址,从内存中读取目标数据)
- 为了克服这个缺点,MMU 引入了一个关于 PTE 的小缓存,来加快自己的翻译工作;
- PTE:page table entry,页表上面的条目;
- 这个小缓存叫做 TLB,翻译后备缓冲器,translation lookaside buffer;
- 这个机制再次利用了局部性原理对 PTE 翻译工作进行缓存加速;
- 多级页表
- 背景:页表占用的空间不小,32位地址空间,4KB 页面,4字节PTE的系统,单级页表的话,约需要 4MB 的空间;对于 64 位地址空间,就更大了,对它进行缓存显然成本很高,为了应对这个问题,引入了多级页表的方法;
- 多页表的技术非常有意思,它的原理是对虚拟地址空间进行分段,每段的大小固定,然后每段用一个小页表来映射,最后再用一个上级的小页表来映射所有下级的小页表;
- 优点:无效条目得以尽量挤掉;
- 缺点:每一次的翻译工作都需要两次(两级页表)或多次查找(多级页表)
- 对于 4GB 的内存,二级页表的一页有1024个条目,可以映射 1024 个页,每个页 4KB,因此二级页表一个页可以映射 4MB地址空间,而一级页表的一个页的 1024个PTE,就可以映射全部 4GB 地址空间了;
- 感觉多级页表的地址分段,跟折半查询的思路有点类似;
- 一级页表 1024 个条目使用 4KB 空间,二级页表 1024 个条目使用 4KB 空间,这么合计起来只需要使用 8KB 的空间?那相对于单级页表设计需要 4MB 的空间,其提升还是相当惊人的;
- 通过 TLB 缓存技术,单次访问时间成本其实很小,因此实际上可以通过设计为很多级的页表结构,来降低对页表的缓存空间要求;
- Core i7 处理器设计为4级;
- CPU 运行一条数据加载指令的过程
- 指令给出的地址是虚拟地址,先通过 MMU 取出虚拟地址中的 TLB 标记位和索引位,然后到 TLB 缓存查询,若命中则返回 PPN
- PPN 和虚拟地址的 VPO 合并得到物理地址 PA,然后发给高速缓存;
- 高速缓存从物理地址 PA 中读取缓存的标记位和索引位,检查是否命中
- 若命中,根据 PA 的偏移位,从缓存块中得到数据,返回给CPU;
- 若不命中,则触发异常,前往主存中复制数据到缓存;
- 案例研究
- 每个进程都有自己的私有页表层次结构;当分配了页后,页表便开始驻留在内存中;同时也有一份缓存在 TLB 中;这样 CPU 在进行进程切换的时候,通过重置 CR3 控制寄存器的值,让其指向每个进程第一级页表的起始位置;
- 如果 TLB 满了后,貌似就要有人做出牺牲了;
- 页表中的条目总共有64位(这么说有8个字节),其中有40位用来表示物理基地址的最高40位,其他的主要用来表示各类信息,包括权限、缓存策略、页大小、有效位、引用位、修改位等;
- 修改位:若页发生修改,则被牺牲进行覆盖前,需要先写回数据;
- 引用位:用于内核实现页替换算法;
- 在多级页表中,假设虚拟地址有48位,其中36位为 VPN(剩下12位刚好可以表示4KB 一个页的数据),采用四级页表,则这36位 VPN 会被分成4段,每段9位,每一段都为上一级的偏移量;
- CPU 在实际工作中,MMU 和高速缓存其实在同时工作,前者负责翻译得到 PPN 的值,后者负责从缓存中查找相同 PPO 对应的标记位的值,最后当二者都得到结果后,即可进行匹配;它们其实不是顺序执行的,而是并行的工作;这种方式提高了性能;
- 每个进程都有自己的虚拟地址空间,在栈以上的部分,属于内核虚拟内存,其中存放着:
- 内核代码和数据:每个进程都相同,它们会被映射到相同的物理内存页面;
- 物理内存:每个进程都相同;
- 这里是啥意思?答:这个设计非常有趣,虚拟内存中有一个连续的段,完全映射物理内存,二者大小一样;
- 为什么需要这个东西?答:为内核访问物理内存中任何特定的位置,提供了一种非常便利的方法;例如访问页表
- 怎么个便利法?当某些设备需要执行映射内存的 I/O 操作时,可将这些设备映射到特定的物理内存位置;如果没有这个机制的话,则操作系统自动提供的位置将会是随机的;
- 与进程相关的数据结构:每个进程不同,包括页表、task结构、mm结构、内核栈等;
- 此部分记录着整个虚拟内存的结构信息;
- 每个进程使用一个 task_struct 来记录所有运行该进程的相关信息,例如 PID、指向用户栈的指针、可执行目标文件的名字、程序计数器等
- task_struct 中有一个条目指向 mm_struct,mm_struct 用来描述虚拟内存的当前状态;
- mm_struct 中有两个字段
- pgd:指向第一级页表的基址;当 CPU 运行当前进程时,就会加载 pgd 的值到 CR3 寄存器中;
- mmap:指向一个链表,这个链表由 vm_area_structs 组成;
- 每个 vm_area_structs 描述了虚拟内存中某个区域(段)的信息,包括如下字段
- vm_start:区域起点;
- vm_end:区域终点
- vm_prot:权限
- vm_flags:一些标记信息,包括区域内的页面是否与其他进程共享,当前进程是否私有等;
- vm_next:指向下个 vm_area_structs
- 缺页处理程序的工具步骤
- 判断虚拟地址是否合法:即是否存在于区域结构链接表中的某个起始范围中;若非法,则抛出段错误;
- 由于链表的查找开销很大,实际上 Linux 系统会构造一个树,然后在树里面查找,这样快得多;
- 判断权限是否满足:即当前进程是否有读写这个区域的权限;若非法,则抛出保护异常;
- 判断虚拟地址是否合法:即是否存在于区域结构链接表中的某个起始范围中;若非法,则抛出段错误;
- 每个进程都有自己的私有页表层次结构;当分配了页后,页表便开始驻留在内存中;同时也有一份缓存在 TLB 中;这样 CPU 在进行进程切换的时候,通过重置 CR3 控制寄存器的值,让其指向每个进程第一级页表的起始位置;
- 内存映射
- Linux 通过将某个虚拟内存区域(段)与某个磁盘上的对象映射起来,以便实现虚拟内存中的内容初始化
- 这个过程称为 memory mapping;不过貌似此时应该没有实现数据加载,加载要到缺页异常时才会触发;
- 这个映射信息保存在哪里?理论上要有一个文件描述符,指向磁盘上的这个对象
- 虚拟内存区域可以映射两种对象
- Linux 文件系统中的普通文件;
- 匿名文件:它是由内核创建的,并不预先存在于文件系统中;一开始初始化为零(请求二进制零的页)
- 一直要等到这个匿名文件被 CPU 引用时,才会将它写入物理内存;
- 据说当一个虚拟页面被初始化以后,就会被一个由内核维护的的专门的交换文件(也叫交换空间或交换区域)之间换来换去,啥意思?
- 写时复制:这个技术很有意思,当一个对象被标记为私有对象时,它仍然可以被多个进程映射并读取,仅当出现有一个进程尝试写它时,才会触发复制一本副本出来;然后实际上是数据是写到副本中的,而不是原来的对象;这样不会影响其他进程对原文件的读取(貌似这样可以最大程度的减少复制的工作,可以得到真正产生需要时再复制)
- 某个进程对共享对象的写操作,对其他进程是可见的;对私有对象的写操作,则是不可见的,因为事实上它并不是写在原对象上,而是写入了原对象的副本上(这个副本要到写时才会创建,即写时复制技术)
- fork 函数工作原理:
- 当调用 fork 函数时,内核会复制一份当前进程的 mm_struct、区域结构链表和页表的副本,这样两个进程就实现了数据的共享;但这些共享的数据都被标记成只读的,并且区域结构都会被标记为写时复制;当任意一个进程尝试写数据时,才会触发写时复制(亦即此时才会新开辟一片物理内存,映射到新进程的虚拟内存段);
- execve 函数:内核通过这个函数来调用加载器,把可执行文件加载到内存中,工作步骤如下:
- 删除当前进程虚拟地址中的用户部分已经存在的区域结构
- 用户部分即虚拟地址空间中非内核的部分;
- 映射私有区域:为新程序的用户部分创建新的区域结构,标记为私有、写时复制(这么说在没有写之前,还是可以读到原来的数据的;不过不是在第一步的时候就删除了吗?还是说所谓的删除只是逻辑上的一种标记?);
- 映射共享区域:假设有链接共享库,则会触发调用动态链接器,链接共享对象,然后映射到虚拟空间的共享区域中;
- 设置程序计数器:以便让下一指令的地址指向新进程的代码区域的入口点;
- 删除当前进程虚拟地址中的用户部分已经存在的区域结构
- 使用 mmap 函数创建用户级的内存映射
- void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
- start 指虚拟内存中的起始位置,一般设置为 NULL,由内核自行决定位置;这个参数对内核仅为参考意义,它不一定遵守;
- length 要映射的内容长度;
- prot 权限:
- PROT_EXEC 可执行
- PROT_READ 可读
- PROT_WRITE 可写
- PROT_NONE 不可访问
- flags:被映射对象的描述信息
- MAP_ANON 匿名对象
- MAP_PRIVATE 私有对象
- MAP_SHARED 共享对象
- fd 文件描述符:它指向一个磁盘文件
- offset:磁盘文件中的字节偏移量
- 若函数调用成功,将返回新区域的地址;
- 貌似此函数应该还会有更新页表的动作;
- void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
- 使用 munmap 删除虚拟内存的区域
- int munmap(void *start, size_t length)
- start 指的是原 mmap 返回的虚拟内存区域的地址;
- 当虚拟内存区域被删除后,后续对它的引用会导致段错误;
- int munmap(void *start, size_t length)
- Linux 通过将某个虚拟内存区域(段)与某个磁盘上的对象映射起来,以便实现虚拟内存中的内容初始化
- 动态内存分配
- 概念
- 创建虚拟内存区域除了使用 mmap 外,还可以使用动态内存分配器 dynamic memory allocator,在堆 heap 上创建空间来完成
- 堆的位置一般紧接着未初始化的 .bss 节之后,内核会为每个进程创建维护一个 brk 变量,指向堆的顶部位置;
- 注意堆的地址空间是从小到大向上生长的,所以堆顶的地址值是最大的;
- 但栈不同,栈的地址空间是从大到小向下生长的,所以栈顶的地址值其实是最小的;
- 有两种风格的分配器
- 显式分配器:要求程序员显式的回收已分配的资源
- 在 C 中使用 malloc 和 free;在 C++ 中使用 new 和 delete;
- 隐式分配器:由分配器自行检测资源不同使用,并释放回收;
- 所以隐式分配器也叫做垃圾回收器 garbage collector;自动释放回收的动作称为垃圾回收 garbage collection;
- LISP, ML, Java 等语言使用隐式分配器风格;
- 显式分配器:要求程序员显式的回收已分配的资源
- malloc 和 free 函数
- 位于标准库头文件中 <stdlib.h>
- void *malloc(size_t size)
- 若成功则返回指向虚拟内存中已分配的块的指针;若失败则返回 NULL;
- 在编译代码时,若为 32 位模式,则 malloc 返回的地址为 8 的倍数;若为 64 位模式,则地址为 16 的倍数;
- 目的:地址对齐,以便减少获取数据的访问次数;
- malloc 分配的块并未初始化数据,若想初始化,应使用 calloc 函数;
- 若要改变已经分配的块的大小,则使用 realloc 函数;
- malloc 的实现方式有两种
- 方法一:通过 mmap 和 munmap 函数;
- 方法二:通过 sbrk 函数:void *sbrk(intptr_t incr)
- 原理:它通过改变内核中 brk 变量的值,在移动堆顶部的指针位置,从而获得或释放相应的空间;
- 若分配成功,返回 brk 的旧值;若分配失败,返回 -1;
- incr 可以是正值,也可以是负值;当 incr 是负值时,相当于释放空间了
- 当 incr 是负值时,若成功,返回的是 brk 的旧值,意味着这个值与新堆顶的值相距 abs(incr) 字节的距离;
- void free(void *ptr)
- free 函数的设计有缺陷,因为它没有返回值,意味着我们不知道它成功完成任务了没有;当我们传给它的地址参数是一个非法值时,它会产生未定义的行为;
- 为什么要使用动态分配内存?
- 因为有些数据是在运行过程中,由外部传入的,但我们不知道外界传入的数据大小,没有办法提前为它们分配空间;若提前硬编码分配,太大会浪费空间,太小则有时会不够用;
- 因此,我们只好等数据传入前,根据数据大小,临时动态分配相应的空间;这样就可以比较灵活应对多种情况;
- 分配器的要求和目标
- 要求
- 只使用堆
- 立即响应请求
- 支持任意请求序列
- 不修改已分配的块;
- 块对齐;
- 目标
- 最大化吞吐量,即请求处理速度最大化;
- 最大化内存使用率:虚拟空间看似无限,实则受物理内存和交换空间的限制;
- 分配器的两个目标是相互冲突的,好的分配器设计方案需要在二者间取得平衡;
- 要求
- 碎片
- 碎片现象(fragmentation) 会导致内存使用率降低;此时虽然有未使用的内存,但却无法响应分配请求;
- 两种碎片形式
- 内部碎片:分配的块的大小,比请求的大,即多分配一点(一般是为了满足对齐要求)
- 外部碎片:所有空闲加起来大于请求,但却没有单独一个空闲足够大能够满足请求;
- 外部碎片难以量化和预测,分配器一般使用启发式策略来管理块,即尽量维持少量的大空闲块,而不是维持大量的小空闲块;
- 实现问题,很有挑战性,需要考虑的事情如下:
- 如何记录空闲块;
- 如何选择一个合适的空闲块来放置新分配的块;
- 如何处理一个空闲块被部分使用之后的剩余部分;
- 如何处理一个刚被释放的块;
- 隐式空闲链表
- 一个块由头部(一个字)、有效荷载、填充(可选)等三部分组成;
- 双字对齐要求使得块的大小的最后3位固定为0,因此可以用这3个位来编码其他信息,最低位用来表示是否分配;
- 最后需要一个标记已分配且大小为0的块来表示终止;
- 分配器通过遍历块链表,来获知已分配块和空闲块,并进行块的分配;
- 链表的优点是实现起来很简单;缺点是每次分配都需要遍历,导致搜索时间呈现为块数量的线性函数,块越多,搜索时间越久;
- 双字对齐要求使得最小的块大小为两字节,即使只要求分配一个字节;
- 放置已分配的块,三种分配策略
- 第一次匹配:从链接起始处开始查询;
- 优点:大空闲块后置;
- 缺点:头部集中较多的小空闲块,增加了后续的搜索时间;
- 下一次匹配:从上一次查询结束的地方,开始查询;
- 优点:比首次匹配搜索时间少一些;
- 缺点:内存利用率较低;
- 最佳匹配;遍历整个链接,寻找最佳匹配的空闲块;
- 优点:内存利用率高;
- 缺点:费时;
- 第一次匹配:从链接起始处开始查询;
- 分割空闲块:找到匹配的空闲块后有两种选择分配方法:
- 分割
- 不分割:缺点是内部碎片多,除非搜索策略是最佳匹配;
- 获取额外的堆内存
- 分配器优先从内核已给的堆内存中寻找空闲块进行分配工作;
- 如果找不到,就尝试合并所有空闲块进行分配;
- 如果合并后仍然不够,就调用 sbrk 函数让内核分配额外的堆内存;
- 这么说来,堆内存在程序运行过程中,是由内核来管理的,按需分配,而不是由程序自己自行预安排;
- 很好奇虚拟内存的分配,会如何影响到物理内存的分配?是否每个程序有义务要做好自己的内存最大化利用,尽量在少的内存空间中完成尽量多的事情?如何来保证这个机制的执行?
- 合并空闲块
- 当出现两个相邻的空闲块时,分配器需要作合并动作,不然会产生一些假碎片;
- 两种合并策略
- 立即合并
- 优点:简单明了,可以在常数时间内完成;
- 缺点:在某些请求模式下,会触发抖动,即块会反复的合并,然后马上分割;例如在某个循环中,反复的分配和释放某个固定长度的块,就有可能导致这个释放和相邻的块产生反复的合并;
- 推迟合并:直到某个分配请求失败,再扫描整个堆,合并所有的空闲块;
- 立即合并
- 带边界标记的合并:假设释放的块为当前块
- 合并下一个相邻空闲块最简单,只需要简单的将后续块的大小,加到前置块的头部的大小值上即可完成;
- 合并前面的空闲块
- 脚部:由于使用了链表结构,导致无法前向搜索,所以这里的设计,很机智的在每个块的尾部,新引入了一个字的脚部;脚部保留着和头部一样信息;这样一来,就可以通过往前定位一个字,得到前面块的信息,从而为合并前面块提供了巨大的便利;
- 缺点:增加了内存开销;
- 优化方法:如果前面是已分配的块,则并不能合并它;所以对于已分配的块,可以不用存储脚部,而是在其当前块的低位,预留一个位,用来表示前面块的是否已分配的信息;这样就可以节省很多内存,仅空闲块需要脚部;而空闲块本来就是没有使用的内存,所以也不存在内存占用;
- 脚部:由于使用了链表结构,导致无法前向搜索,所以这里的设计,很机智的在每个块的尾部,新引入了一个字的脚部;脚部保留着和头部一样信息;这样一来,就可以通过往前定位一个字,得到前面块的信息,从而为合并前面块提供了巨大的便利;
- 综合:实现一个简单的分配器
- 需要定义的函数包括:初始化、堆扩展、块释放、块合并、块分配等;
- 需要定义的宏包括:字大小、生成头部、读取一个字、写入一个字、获取大小、获取分配位、获取头部指针、获取脚部指针、获取下一个块指针、获取前一个块指针;
- 显式空闲链表
- 由于空闲块中的数据不再使用,因此可以在里面增加一个双向链表,用来记录上一个和下一个空闲块的位置信息;这样在寻找空闲块的时候,就简化为在所有空闲块中寻找即可,不需要在已分配块中寻找;
- 代价是:释放一个块所需要的时间就可能不再是一个常数了,因为需要维护双向链接,除非选择合适的排序策略;
- 后进先出(LIFO):last in first out,每次新释放的块,都放在双向链表的开始处;
- 优点:这样可以确保块释放时间仍是一个常数;
- 缺点:内存利用率较低;
- 按地址顺序排序,每个空闲的地址都小于它后继的空闲块地址
- 缺点:需要线性的时间定位合适的前驱;
- 优点:内存利用率比较高
- 后进先出(LIFO):last in first out,每次新释放的块,都放在双向链表的开始处;
- 显式链表的缺点:每个块现在需要保存四种信息,因此块的最小值需要更大一些;潜在的增加了内存碎片的程度;
- 分离的空闲链表
- 对于空闲块链表的搜索,与空闲块的数量呈线性关系;为了尽量提高性能,另一种方法是让分配器维护多组空闲块链表;每组之间按大小分类,分类的方法有很多种;总的原则是让空闲块的搜索变成根据需要的块大小,到对应的组中去搜索;
- 两种基本的分享存储的方法
- 简单分离存储:每个大小类,都包含相等大小的块,块大小即是类的上限;块不分割,不合并;
- 优点:分配和释放都可以常数时间完成;无须头部和脚部、无须分配位标记、最小块大小是一个字,内存利用率高
- 缺点:容易造成很多内部碎片,某些情况下,由于不合并策略,甚至会造成很多外部碎片;
- 分离适配:此方法通过让分配器单独维护一个空闲链表的数组来实现;数组中的每个空闲链表成员,和一个大小类相关联,并被组织成某种类型的显式或隐式链表,每个链表包含潜在大小的不同的块,这些块是大小类的成员;GNU malloc 包即是使用这种方法
- 优点:搜索时间较少,因为被限定在了局部块中,而不是整个堆;内存利用率也上升,近似于最佳匹配的内存利用率;
- 好奇这个数组存储在哪里?
- 伙伴系统(buddy system)
- 它是分离适配的一种特殊,特殊在其块的大小统一设置为2的幂;当请求某个块时,先将块的大小向上舍入到最接近的2的幂,然后去链表中搜索;余下的跟分离适配一样;
- 优点:快速搜索,快速合并;
- 缺点:由于硬性规定大小需要是2的幂,会造成较多的内部碎片;
- 使用场景:不太适合于通用场景的机器,但对于已知块的大小一定会是2的幂的机器,则非常有吸引力;
- 简单分离存储:每个大小类,都包含相等大小的块,块大小即是类的上限;块不分割,不合并;
- 概念
- 垃圾收集
- 垃圾收集器的基本知识
- 垃圾收集器将内存视为一张有向可达图(reachablility graph),它由一组根节点和很多堆节点组成,每个堆节点对应堆中的一个已分配的块,每个根节点则存储着指向堆中的指针(其位置可以是寄存器、栈或者全局变量等)
- 当存在一条从根节点出发,并到达 p 节点的路径时,我们称 p 节点时可达的;任何时刻下,如果 p 是不可达的,则它便是垃圾;垃圾收集器定期检查可达图里面的各个节点,当发现它是不可达,就释放它并返回给空闲链表;
- 由于 ML、JAVA 等语言对指针的使用有严格的规定(主要是因为指针在内存中存储有类型信息),因此其可达图的维护很精确,可以起到垃圾回收的效果;C/C++ 对指针的使用不够严格(没有类似信息,依赖上下文),导致其可达图的维护有错,不能起到完全的垃圾回收效果,被称为保守的垃圾收集器;
- 收集器可以按需提供服务,或者也可以作为一个独立的线程,定期更新可达图和回收垃圾;
- Mark&Sweep 垃圾收集器
- 由两个阶段组成
- 标记(mark)阶段:mark 函数
- 给定一个指向堆中的指针,返回其在堆中所在的块 b 的起始位置的指针;
- 若返回 NULL,结束;
- 若返回非 NULL,检查块是否已标记,
- 若已标记过,结束;
- 若未标记过,进行标记
- 读取 b 块的长度
- 遍历 b 块中的每一个字,递归调用 mark 函数,确保 b 块中的每个字,若指向其他块,则其他块也纳入检查范围;
- 给定一个指向堆中的指针,返回其在堆中所在的块 b 的起始位置的指针;
- 清除(sweep)阶段:sweep 函数
- 遍历堆中的每一个块
- 若已标记,清除标记;
- 若未标记,检查是否已分配
- 若已分配,释放它;
- 若未分配,结束,开始检查下一个块;
- 遍历堆中的每一个块
- 标记(mark)阶段:mark 函数
- 由两个阶段组成
- C 程序的保守 Mark&Sweep
- C 的标记和清除模式不得不保守,因为在 C 的设计中,它没有机制保存类型信息,不同类型的变量,在内存中可能存储着相同的值,导致仅看内存中的值,无法区分类型,有可能这个值是一个 int 类型,也有可能是一个指针类型;这就导致 C 语言对 Mark&Sweep 中的 isPtr 函数的实现需要如下另外设计;
- 将已分配块的集合,维护成一棵平衡二叉树,在左子树中所有块,都放在较小的地址处;右子树中的所有块,都放在较大的地址处;
- 在块的头部中,再多增加两个字段,每个字段指向某个已分配的块的头部;
- 调用 isPtr 时,它就用这棵树来查找,它根据块头部的大小字段,来判断当前的指针参数 p 是否在块中;如果在,则不释放;如果不在,则释放;
- 但这种方法有缺点,它可以核对哪些块从根节点出发是可达的,不会误删除有用的块,但它会错过一些其实已经不可达的块,导致增加了一些外部碎片的可能性;
- C 的标记和清除模式不得不保守,因为在 C 的设计中,它没有机制保存类型信息,不同类型的变量,在内存中可能存储着相同的值,导致仅看内存中的值,无法区分类型,有可能这个值是一个 int 类型,也有可能是一个指针类型;这就导致 C 语言对 Mark&Sweep 中的 isPtr 函数的实现需要如下另外设计;
- 垃圾收集器的基本知识
- C 程序中常见的与内存有关的错误
- 间接引用坏指针
- 读未初始化的内存
- 例如错误的假设堆内存被初始化为零,事实上可以使用 calloc 来初始化为 0;
- 允许栈缓冲区溢出
- 例如使用 gets 函数读取字符串,它不限制长度,更好的做法应该是使用 fgets 函数;
- 假设指针和它们指向的对象是相同大小的
- sizeof(int*) 和 sizeof(int) 是完全不一样的;
- 造成错位错误,即越界错误
- 引用指针,而不是它所指向的对象
- 指针的解引用运算符优先级很低,比常见的算术运算符低,所以应谨慎的使用括号来避免错误;
- 误解指针运算
- 指针的自增自减是以其自身指向对象的类型的大小来进行的,而不是字节为单位;
- 引用不存在的变量
- 例如返回栈中创建的变量的指针,而事实上这个栈中的变量在出栈的时候,就已经无效了,它非常短的时间内可能可以使用,便很快会被覆盖,从而造成不易察觉的错误;
- 引用空闲堆块中的数据
- 分配,释放,然后又出现了再次引用;
- 引起内存泄漏
- 忘了回收分配的内存,随着程序不断运行(例如服务器上的程序),最终会导致内存被用爆,导致了内存泄漏;
- 物理和虚拟寻址
- 系统级 I/O
- Unix I/O
- 输入/输出 I/O 是主存和外部设备之间复制数据的过程;
- 所有的 I/O 都被模型化为文件,而一个 Linux 文件就是一个由 m 个字节组成的序列;所有的输入和输出都被当作对文件的读和写;
- Unix I/O 是 Linux 内核提供的接口;
- 打开文件:当应用程序请求打开某个文件时,内核会返回一个非负整数的文件描述符,用来在后续的操作中标识这个文件;内核会记录关于这个被打开文件的所有信息,而应用程序只需记住这个描述符;
- Linux shell 创建的每个进程开始时都有三个打开的文件,分别为标准输入、标准输出、标准错误,描述符分别为 0,1, 2;头文件<unistd.h> 中定义了三个宏变量名称来代替它们,分别为 STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO;
- 改变当前的文件位置:通过 seek 函数可以改变在文件中的位置;内核会通过一个变量记住当前的文件位置,初始值为 0;
- 读写文件:对 m 字节的文件读取 k 字节的数据,当 k > m 时,会触发 EOF (end of file) 的条件;应用程序可以检测到这个条件,但实际上文件的末尾处并没有 EOF 符号
- 好奇是如何触发的?
- 关闭文件:应用程序请求关闭文件后,内核会恢复描述符到可用池中,并释放文件打开时创建的数据结构;
- 这么说内核在打开文件时,创建了一个数据结构,来临时存储文件的相关信息?
- 文件
- 每个 Linux 文件都有一个类型(type) 来标识它在系统中的角色,常见的文件类型有:
- 普通文件:可以包含任意类型的数据;
- 文本文件:由文本行组成的序列,每行的末尾有一个换行符 \n;每行的内容只由 ASCII 或 Unicode 字符组成;
- 二进制文件:非文本文件的,都叫做二进制文件;
- 目录文件:包含一组链接的文件;每个链接都将一个文件名映射到一个文件;这个文件可能是另外一个目录;每个目录中,即使是空的,也至少包含两个条目,分别是 “.” 和 “..”,即一个点和两个点,它们分别代表当前目录的链接和父目录的链接;
- 套接字文件:用来与另外一个进程进行跨网络通信的文件;
- 这么说,使用套接字的进程间通信,实际上是在做文件读写?是的
- 其他文件类型:命名通道(named pipe),符号链接(symbolic link),字符和块设备(character and block device)等;
- 消息队列估计应该也是一种文件类型;
- 普通文件:可以包含任意类型的数据;
- Linux 内核将所有文件都组织成一个目录层次结构,由根目录确定(以 “/“ 表示根目录),每个文件都是根目录的直接或间接的后代;
- 看起来每次创建一个新目录时,实际上做了两件事,一个是在父目录的文件中,增加一个链接;一个是在当前目录文件中,增加“.” 和 “..”两个链接;
- 每个进程都有一个当前工作目录(current working directory),是其上下文的一部分;
- 这个上下文信息存在哪里呢?会不会是虚拟地址空间中的内核区?
- 目录层次结构中的位置用路径名(pathname) 来指定
- 绝对路径:从根节点开始的路径;
- 相对路径:从当前目录开始的路径;
- 每个 Linux 文件都有一个类型(type) 来标识它在系统中的角色,常见的文件类型有:
- 打开和关闭文件
- 进程使用 open 函数打开一个已存在的文件,或者创建一个新的文件;
- int open(char *filename, int flags, mode_t mode);
- open 函数将 filename 与一个文件描述符进行关联,并且返回代表这个文件描述符的数字;
- flags 参数用来指示如何访问这个文件,它可以是一位掩码,也可以是多个掩码的或,例如 open(“foo.txt”, O_WRONLY | O_APPEND, 0);
- 掩码的或运算相当于多个条件的叠加;
- mode 参数指定了新文件的访问权限;
- 当使用带 mode 参数的 open 函数来创建一个新文件时,文件的访问权限会被设置为 mode & ~umask;所以设置 umask 的值,对于进行权限控制是有必要的;
- 每个进程都有一个 umask,通过调用 umask 函数可以设置它的值,例如
- #define DEF_UMASK S_IWGRP | S_IWOTH
- umask(DEF_UMASK)
- 进程使用 open 函数打开一个已存在的文件,或者创建一个新的文件;
- 读和写文件
- 通过调用 read 和 write 函数,可以对文件进行输入和输出操作;
- 通过调用 lseek 函数,可以显式的改变文件中的当前位置;
- read 返回的读取字符数量有可能比要求的少,但这并不表示错误,原因可能如下:
- 文件剩下的字符数没有那么多了,比如剩下20,读取50,则只能返回20;
- 从终端读取文本行:由于每次读一行,而一行的字节数可能没有预期的那么多;
- 读写网络套接字:此时由于存在内部缓冲约束和较长的网络延迟,因此会返回不足值;
- size_t 与 ssize_t 的区别:前者是 unsigned long,后者是有符号的 long,适用于可能返回负值的场景;
- 用 RIO 包健壮地读写
- RIO 的全称为 robust IO;它提供两种函数
- 不缓冲的输入输出函数:直接在内存和文件之间传送数据,特别是适用于在网络场景中读写数据;
- 带缓冲的输入输出函数:用于从文件中读取文本行和二进制数据;
- 有些文件既有文本行也有二进制数据,例如 HTTP 响应;
- RIO 的目标:对标准库提供的输入输出函数二次封装,使其更加健壮;
- RIO 的无缓冲输入输出函数
- ssize_t rio_readn(int fd, void *usrbuf, size_t n);
- ssize_t rio_writen(int fd, void *usrbuf, size_t n);
- 优点:
- 支持分批读取或写入;
- 若意外中断,可通过循环再次尝试读写,直到成功;(是否可以用来实现网络下载的断点续传?)
- RIO 的带缓冲的输入输出函数
- rio_read 表面上来看,功能跟系统的 read 函数一样,区别在于多了一层结构(rio_t)做为缓冲进行中转,这么设计的目的在于控制每次读取的字符数量;让 readline 有机会在不用读完整行每一个字节的情况下,跳到下一行进行读取;
- 通过 rio_readlineb 调用 rio_read 可以实际单行只读取有限数量的字符;每行末尾增加 NULL 做为结束;
- RIO 的全称为 robust IO;它提供两种函数
- 读取文件元数据
- 可以通过调用 stad 函数和 fstad 函数来获取文件的元数据信息
- #include <unistd.h>
- #include <sys/stat.h>
- int stad(char *filename, struct stat *buf);
- int fstad(int fd, struct stat *buf);
- 在元数据 stat 结构中,有一个 st_mode 字段用来表示文件类型, <sys/stat.h> 定义了宏谓词(用法有点类似函数)来表示检查它们的值
- S_ISREG,是否为普通文件
- S_ISDIR,是否为目录文件;
- S_ISSOCK,是否为套接字文件;
- 可以通过调用 stad 函数和 fstad 函数来获取文件的元数据信息
- 读取目录内容
- 可以调用 readdir 函数来读取目录的内容
- #include <sys/types.h>
- #include <dirent.h>
- DIR *opendir(const char *dirname);
- opendir 函数打开一个目录,返回一个指向目录流的指针;
- 目录流是一个条目有序列表的抽象;此处为目录项的列表;
- struct dirent *readdir(DIR *dirp);
- 每次调用 readdir 都是返回指向下一个目录项的指针;若出错则返回 NULL,并设置 errno;
- 由于没有更多的目录项时,也是返回 NULL;因此若要检查是否出错,还需要检查 errno 是否被修改了;
- 目录项的结构如下
- struct dirent {
- ino_t d_ino; // 节点编号,表示文件位置?
- char d_name[256]; // 文件名称;
- }
- struct dirent {
- 每次调用 readdir 都是返回指向下一个目录项的指针;若出错则返回 NULL,并设置 errno;
- int closedir(DIR *dirp); // 关闭目录流
- 可以调用 readdir 函数来读取目录的内容
- 共享文件
- 内核用三个相关的数据结构来表示打开的文件
- 描述符表:这张表是每个进程私有的;每一个描述符的表项,会指向文件表上的一个表项;
- 文件表:这张是所有进程共享的;每个表项包含的信息包括:当前的文件位置(即光标位置)、被引用计数(即有多少个进程指向它)、指向某条 v-node 表项的指针;
- 每当有一个进程,关闭某个文件时,该文件在文件表上对应的表项会减少一次引用计数;当这个引用计数减至0时,内核就会删除这个表项;
- v-node 表:这张表也是所有进程共享的;
- 每个表项包含文件stat 结构的大部分信息(目测是用来存放文件的元数据的)
- 在同一个进程中,允许对相同的文件打开两次,此时会出现两个文件描述符,同时文件表上面也有两个表项,因此有趣的是,两个表项可以有各自的光标位置,这意味着读写可以同时进行,互不干扰;
- 什么时候在不同进程之间,会共用同一个文件表的表项?估计这事应该不会发生,因为即使在同一个进程内部,对一个文件打开两次,都会生成两条文件表项,那么不同进程就不太可能会共用同一个表项了;
- 不过有一种情况下会共享,即父子进程,因为 fork 子进程的时候,复制了一份父进程的描述符表,导致两个进程会共享相同的文件表表项;
- 所以,仅在父子进程都关闭了各自的文件描述符,使得文件表项的引用计数为0时,才会触发内核删除相应的文件表项;
- 貌似这个时候也要特别小心,因为父子进程对同一个文件的读写存在冲突的可能?
- 内核用三个相关的数据结构来表示打开的文件
- I/O 重定向
- 通过调用 dup2 函数可以实现重定向
- #include <unistd.h>
- int dup2(int oldfd, int newfd);
- 原理:复制 oldfd 到新 newfd,覆盖 newfd 之前的内容;若 newfd 之前已经打开,则复制前会先关闭;
- 通过调用 dup2 函数可以实现重定向
- 标准 I/O
- C 语言的标准 I/O 库将一个打开的文件模型化为一个流;
- 流是一个指向 FILE 数据类型的指针;它其实是对文件描述符和流缓冲区的抽象;流缓冲区的目的,和 RIO 读缓冲区的目的是一样的,即将多个单次单字节的读取,转换成每次读取一段内容到缓冲区,然后再从缓冲区读取单个字节;这样可以避免频繁调用开销较高的 Linux I/O 系统函数;
- 综合:我该使用哪些 I/O 函数,基本原则:
- 只要有可能就尽量使用标准库的 I/O 函数;
- 不要使用 scanf 或 rio_readlineb 来读二进制文件;因为这两个函数是专门设计用来读取文本文件的;若用于二进制文件,会出现诡异的错误;
- 对网络套接字应该使用 RIO 函数;
- 原因:标准 I/O 函数将文件抽象为流,它被设计成全双工的,即能够在同一个流上执行输入和输出,背后的原理是通过调用 Unix I/O 的 lseek 来重置文件的光标位置;但对于网络套接字,使用 lseek 是非法的,因此导致标准 I/O 函数在读写套接字文件时带来问题;
- 由于 RIO 中没有格式输入输出的 scanf 和 printf 函数,所以需要通过 sscanf 和 sprintf 配合 rio_writen 和 rio_readlineb 叠加实现即可;
- sprintf -> rio_wirten
- rio_readlinb -> sscanf
- Unix I/O
- 网络编程
- 客户端-服务器模型
- 每个网络应用都是基于此模型;在这个模型中,一个应用由一个服务器进程 + n 个客户端进程组成;服务器管理某种资源,并通过操作这些资源为客户端提供服务;
- 此模型中的基本操作是事务,一个事务由四步组成:
- 当客户端需要一个服务时,给服务器发送一个请求;
- 服务器收到请求后,进行解析,根据需要操作资源;
- 服务器给客户端发送一个响应,并等待下一次请求;
- 客户端收到响应并进行处理;
- 网络
- 在系统内核眼里,网络只是一种 I/O 设备,是数据源和数据接收方,就像一个文件一样;
- 从网络上接收到数据,先到网络适配器(即网卡),然后通过 I/O 总线和内存总线,复制数据到内存中;这个过程一般使用 DMA 传送(即数据不经过寄存器);
- 同样,数据也以相同的方式,从内存复制到网络中;
- 物理上来说,网络是一个按照地理位置远近组成的层次系统,最低层是局域网(LAN,local area network);
- 最流行的局域网技术是 Ethernet 以太网
- 一个以太网段包括一些电缆和一个集线器;
- 每条电缆使用相同的带宽;电缆用来连接主机的网络适配器和集线器的端口;
- 集线器将其收到的数据不加分辨的复制到每个端口上;因此,每台主机都可以看到每个位;
- 每个适配器都有一个全球唯一的48位地址(即 mac 地址),这个地址出厂时写入存储在适配器中;
- 一台主机可以发送一段(即一桢 frame)的位数据,到这个网段内的其他任何主机;
- 每个桢包含一些固定数量的头部位,用来标识此桢的源地址和目标地址,以及此桢的长度;
- 头部位之后就是有效载荷(payload),即数据;
- 网段内的每台主机都可以看到这个桢,但只有目标地址的主机会读取它;
- 再使用一些电缆 + 多个网桥盒子,就可以将多个以太网段组合成一个更大的桥接以太网;(现在网桥已经被交换机取代)
- 网桥盒子与网桥盒子之间的带宽,可以和集线器到网桥的带宽不同;
- 网桥比集线器要聪明一些,它不会复制数据到所有端口,导致占用不必要的带宽;它内置了一个算法,可以根据源地址和目标地址,有选择性的复制数据给外部其他网桥(发送时),或者给内部某个集线器(接收时);如果是以太网段内的通信,它就不复制数据,而是由集线器完成复制的工作;
- 继续使用一些电缆 + 多个路由器盒子(一种特殊的计算机),就可以组成更大互联网络 internet;
- 每个 LAN 连接到路由器的一个端口;然后路由器和路由器之间则可以使用点到点的 WAN(广域网) 连接
- 互联网络最大的特点是可以让不同硬件实现的局域网和广域网可以兼容通信;它通过定义一组协议,要求所有的硬件遵守这组协议来实现,协议包括两个部分:
- 命名机制:即统一的地址编码规则,这样可以唯一标识已经物理连接的每一台主机;
- 传送机制:数据打包方式,让包裹在每一站都可以被正确识别和处理;每个数据包由包头和有效载荷组成;
- 包头的格式(位数)是统一的,这样才能被正确识别;里面的信息包括源地址、目标地址、包大小等信息;
- 数据传输过程
- 主机 A 的客户端做系统调用,从虚拟地址空间复制数据到内核缓冲区(此步目测并不需要发生实际的物理内存数据复制);
- 主机 A 的协议软件,给数据添加互联网包头和 LAN1 桢头,创建一个 LAN1 的桢,然后传送此桢数据到主机的 LAN1 适配器(网卡);
- 互联网包头包含主机 B 的寻址信息;
- LAN1 桢头则可以寻址到路由器的 LAN1 适配器;
- 每一次封装,原数据都作为有效载荷,然后再加上该层封装的头部;
- 主机的 LAN1 适配器复制该桢到网络上;
- 路由器的 LAN1 适配器从电缆上读取到发过来的桢,然后传送到内置的协议软件;
- 协议软件去除 LAN1 头部;得到里面的有效载荷;协议软件读取到目标地址;根据该地址,以它为索引读取自己的路由表,得到下一站的地址为路由器的 LAN2 适配器;
- 协议软件给互联包加上 LAN2 头部做为新桢头,传送此桢到路由器的 LAN2 适配器
- 路由器的 LAN2 适配器复制此桢到网络上;
- 主机 B 的 LAN2 适配器在电缆上读取桢,将它传送到自己的协议软件;
- 主机 B 的协议软件去掉 LAN2 桢头和互联网包头,得到有效数据,存储在内核缓冲区;
- 主机 B 的服务端做系统调用,从内核缓冲区复制数据到虚拟空间;
- 全球 IP 因特网
- 因特网的客户端和服务端都混合使用套接字接口函数和 Unix I/O 函数来进行通信;
- TCP/IP 是一个协议族;
- IP 协议提供基本的命名方法和传送机制,但它不太可靠,传递数据包的时候,可能丢失或者重复;
- UDP 稍微扩展了 IP 协议,这样包可以在进程之间传送,而不是主机之间;
- TCP 是构建在 IP 之上的复杂协议,它提供了进程间可靠的全双工连接;
- IP 地址
- IP 地址是一个32位无符号整数,网络程序将 IP 地址存在一个结构中(据说将这个标量数据存放在结构中,为后续带来很多麻烦,是个设计失误,暂不知为何)
- 不同主机有不同的字节顺序,小端法或者大端法,TCP/IP 协议统一规定了标准的网络字节顺序(大端法);在头文件 <arpa/inet.h> 中,Unix 提供函数实现这种转换;
- IP 地址通常使用点分十进制来表示;在 Linux 中,可以在 shell 中使用 hostname -i 来查看本机的点分十进制 IP 地址;
- 在头文件 <arpa/inet.h> 中,有相应的二进制 IP 地址和点分十进制转换的函数;
- IP 地址有很多保留段,用于某些特殊的场景,不用于公网的主机间通信,例如 192.168.. 段;
- 因特网域名
- 域名是一个层次结构,从右向左,分别是一级域名、二级域名、子域名;一级域名由 ICANN 组织进行维护;二级域名按照申请者先来后到的顺序进行分配;子域名则由申请者自己内部管理;
- 1988 年以前,域名与 IP 地址的映射由 HOSTS.TXT 文件进行手工维护,之后由分布在全球的数据库进行维护(DNS,domain name system)
- DNS 由上百万的条目组成,每个条目都是一个域名到 IP 地址的映射;
- Linux 有个 nslookup 函数可以用来查看某个域名对应的 IP;
- localhost 这个名字,为引用同一台机器上的客户端和服务端之间的通信,提供了一种便利和可移植的方式;方便调试;
- 因特网连接
- 一个套接字是连接的一个端点,每个套接字有相应的套接字地址,它由一个 IP 地址 + 一个16位端口号组成;
- 当客户端发起一个请求时,内核会在客户端自动分配一个临时的端口进行通信;服务端的端口则一般是固定的,有些常用的服务有约定俗成的端口编号;
- Linux 中的文件 /etc/services 里面可查询常用服务和端口的映射;
- 查看命令示例:cat /etc/services
- Linux 中的文件 /etc/services 里面可查询常用服务和端口的映射;
- 一个连接是由它两端的套接字地址唯一确定的,称为一个套接字对 socket pair;
- cliaddr: cliport, servaddr: servport;
- 套接字接口
- 套接字接口是一组函数,它们和 Unix I/O 函数结合起来,用以创建网络应用;
- 套接字地址结构
- 从内核的角度看,一个套接字就是通信的一个端点;从应用程序的角度看,一个套接字就是一个有相应文件描述符的打开的文件;
- 套接字地址存放在类型为 sockaddr_in 的16字节结构中;
- struct sockaddr_in {
- uint16_t sin_family; // 协议族
- uint16_t sin_port; // 端口号
- struct in_addr sin_addr; // IP 地址
- unsigned char sin_zero[8];
- };
- connect/bind/accept 等函数需要一个指向与协议相关的套接字地址结构的指针,因此,在没有 void* 指针的时代,通过另外定义了一个通用的地址结构(generic socket address structure) 来实现;在使用前,将 sockaddr_in 结构强制转换为 sockaddr 结构;
- socket 函数
- 此函数用来创建一个套接字描述符;对于内核来说是创建一个用于通信的端点;对于应用程序来说是创建一个用于通信的文件描述符,可向它写数据,也可以从它读数据;
- int socket(int domain, int type, int protocol);
- 参数说明
- domain:地址类型
- AF_INET:使用 IPv4 地址;
- AP_INET6:使用 IPv6 地址;
- AP_LOCAL
- type:连接类型
- SOCK_STREAM,双向连接可依赖的流连接(数据如有损坏或丢失,会重新发送,即 TCP),优点是准确完整,缺点是效率慢
- SOCK_DGRAM,不连续不可依赖的数据包连接,即 UDP,优点是效率快,缺点是不完整,可用于完整度要求不高的场景,例如语音\视频聊天;
- (其他略)
- protocol:协议类型
- PF_UNIX, PF_LOCAL, AF_UNIX, AF_LOCAL,进程间的通信协议;
- PF: protocol family,用来设置协议
- AF: address family,用来初始化地址;
- PF_INET:IPv4 网络协议
- PF_INET6:IPv6 网络协议
- (其他略)
- PF_UNIX, PF_LOCAL, AF_UNIX, AF_LOCAL,进程间的通信协议;
- domain:地址类型
- socket 函数返回的文件描述符仅是部分打开的,还不能用于读写,因为还没有连接,后续还需要再做一点打开套接字的工作;
- 此函数用来创建一个套接字描述符;对于内核来说是创建一个用于通信的端点;对于应用程序来说是创建一个用于通信的文件描述符,可向它写数据,也可以从它读数据;
- connect 函数
- 客户端通过调用 connect 函数来建立和服务器的连接;
- 估计这是浏览器首次发起请求时在做的工作;
- int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);
- connect 函数是阻塞的,一直到连接成功或者发生错误;当它成功后, 由 socket 函数创建的 clientfd 文件描述符就可以用于读写了;
- 貌似在浏览器里面会给它设置过期时间,即尝试一段时间仍然连接不上后,就会报错;
- 对于 socket 和 connect 函数,更好的办法是使用对它们进行封装的 getaddrinfo 函数;
- 客户端通过调用 connect 函数来建立和服务器的连接;
- bind 函数
- 服务端使用 bind/listen/accept 函数来和客户端建立连接;
- int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- bind 用来告诉内核将 addr 中的服务端套接字地址和套接字描述符联系起来;
- listen 函数
- 连接请求需要由客户端发起,服务端只能被动的等待请求;
- 当使用 socket 创建套接字描述符,内核默认它是客户端(即主动套接字类型),需要使用 listen 函数告诉内核,这个描述符是服务端的,从而将一个主动套接字转化为一个监听套接字;
- int listen(int sockfd, int backlog);
- backlog 参数用来设定内核可接受的连接请求的排队数量,超过这个数量时,内核会拒绝请求,一般设为较大的值,例如 1024;
- accept 函数
- 服务端通过调用 accept 函数等待来自客户端的连接请求到达监听描述符;accept 也是阻塞的,当它成功时,会返回一个已连接描述符(新的套接字),这个已连接描述符可被 Unix I/O 函数用来与客户端进行通信
- int accept(int listenfd, struct sockaddr *addr, int *addrlen);
- 注意,此处的 addr 用来存放客户端的套接字地址,以便能够正确返回响应;
- 监听描述符是作为连接请求的一个端点,它通常只被创建一次,存在于服务端的整个生命周期;
- 连接描述符是针对单个客户端的单个连接端点,服务端每接收到一个客户端请求时,就创建一次;它的生命周期仅限于为某个客户端服务的连接过程中;服务结束后,它就回收了;
- 监听描述符和连接描述符背后其实是两个不同的套接字;
- 服务端通过调用 accept 函数等待来自客户端的连接请求到达监听描述符;accept 也是阻塞的,当它成功时,会返回一个已连接描述符(新的套接字),这个已连接描述符可被 Unix I/O 函数用来与客户端进行通信
- 主机名和服务名的转换
- getaddinfo 可将主机名、主机地址、服务名和端口号的字符串转化成套接字地址结构;
- int getaddrinfo(const char *host, const char *service, const struct addrinfo *hints, struct addrinfo **result);
- host 可以是域名,也可以是数字地址,如点分十进制的 IP 地址)
- service 可以是服务名(如 http),也可以是十进制端口号;
- hints 是一个可选参数,它用来控制返回的 result 格式,它的结构同 result 一样,所以叫做 hints 提示,表示提示要返回的 result 结构样式;
- 它有8个字段,但只能设置其中四个,剩下四个需要设置为 0;因此在使用中,一般先用 memset 将整个结构清零,然后再有选择的设置部分字段的值;
- struct addrinfo {
- int ai_flags;
- 位掩码,通过或运算可以组合多个选项;常用的有如下几个
- AI_ADDRCONFIG,当主机使用 IPv4 时,返回 IPv4 地址;使用 IPv6 时返回 IPv6 地址;
- AI_CANONNAME,第一个 addrinfo 结构中的 ai_canonname 默认为 NULL,若设置此标志,则指示将 ai_canonname 指向 HOST 的权威名字;
- AI_NUMERICSERV,强制参数 service 为端口号;
- AI_PASSIVE,此标志告诉 getaddrinfo 函数返回的套接字地址可能服务器用作监听套接字;此时,参数 host 应为 NULL,且得到的套接字地址结构中的地址字段会是通配符地址(wildcard address),以便告诉内核这个服务器程序会接受发送到该主机的所有 IP 地址请求;
- 位掩码,通过或运算可以组合多个选项;常用的有如下几个
- int ai_family; // 可以用来控制返回的是 IPv4 还是 IPv6 地址;
- int ai_socktype;
- 默认情况下,getaddrinfo 最多可返回与 host 关联的三个 addrinfo 数据结构(它们组成链表),每个的 socktype 不同,分别是连接、数据报、原始套接字;
- 如果将 ai_socktype 设置为 SOCK_STREAM,则限制 getaddrinfo 最多只返回一个 addrinfo 结构;该结构的套接字地址可以作为连接的一个端点;
- int ai_protocol;
- 余下4个略
- int ai_flags;
- }
- void freeaddrinfo(struct addrinfo *result);
- 其中 result 是一个链表结构,每个节点是一个包含一个指针,指向一个套接字地址结构;
- int getaddrinfo(const char *host, const char *service, const struct addrinfo *hints, struct addrinfo **result);
- getnameinfo 函数
- 作用与 getaddrinfo 函数相反,它是将一个套接字地址转换成相应的主机和服务名字符串;
- int getnameinfo(const struct sockaddr *sa, socklen_t salen, char *host, size_t hostlen, char *service, size_t servlen, int flags);
- getaddinfo 可将主机名、主机地址、服务名和端口号的字符串转化成套接字地址结构;
- 套接字接口的辅助函数
- open_clientfd 函数:对 getaddrinfo, socket, connect 三个函数进行了包装;
- open_listenfd 函数:对 getaddrinfo, socket, bind, listen 四个函数进行了包装,同时通过 setsocket 函数去除服务器的重启30秒等待的限制;
- 以上两个函数在 <csapp.h> 中,如有需要,可以从书中拷贝;
- htons, htonl, ntohs, ntohl,其中的 n 表示 net,h 表示 host,s 表示 short,l 表示 long,这几个函数用来实现主机和网络之间的字节顺序转换(因为网络的传输标准使用大端法,主机常为小端法,所以需要转换);
- echo 客户端和服务器示例
- EOF 本质上只一种触发的条件判断;在实际的数据中并没 EOF 字符;
- 服务器端通过无限循环等待来自客户端的连接请求;每次循环对应一个请求,请求连接成功后,调用相应的函数为客户端服务,服务完成后,关闭连接描述符;
- Web 服务器
- Web 基础
- HTTP 协议与常规的文件检索服务 FTP 的不同之外在于其可以使用 HTML 标记语言,而 HTML 中可以包含指向其他资源的指针;
- Web 内容
- 对于 Web 客户端和服务器,内容本质上是一个某种 MIME 类型的字节序列
- MIME:multipurpose internet mail extensions,多用途的网际邮件扩充协议;
- 常见 MIME 类型包括:
- text/html
- text/plain
- application/postscipt
- image/jpeg
- Web 服务器以两种不同的方式向客户端提供内容
- 取一个磁盘文件返回给客户端:磁盘文件称为静态内容,此过程称为服务静态内容;
- 执行一个可执行文件,将执行结果返回给客户端;可执行文件的输出称为动态内容,此过程称为服务动态内容;
- 猜测现在的 WEB 框架,可能只有一个可执行文件,然后根据传入的参数,跳转到各个视图函数的位置并执行相关代码;估计此处要涉及 CGI 即通用网关接口,由于Web 服务器程序传递相关数据给 Web 程序,并由 Web 程序返回结果给 Web 服务器程序;
- 每条内容都是和 Web 服务器管理的某个文件关联的,这些文件有一个唯一的名字,称为 URL,统一资源定位符,uniform resource locator;
- 在 URL 中,使用 ? 分隔文件名和参数,使用 & 分隔多个参数;
- / 默认为被请求内容的主目录;所有服务器默认会把它扩展为某个默认的主页的文件名;当用户没有写 / 时,浏览器在请求时,会自动加上;
- 对于 Web 客户端和服务器,内容本质上是一个某种 MIME 类型的字节序列
- HTTP 事务
- HTTP 标准要求每个文件行由回车+换行两个字符来结束;
- 一个 HTTP 请求的构成:一个请求行,后面跟着零个或多个的请求报头,再跟随一个空的文本行来终止报头,空行之后接请求体(如有);
- 一个请求行的格式:method URI version,示例:GET / HTTP/1.1
- 最新的HTTP version 是1.1,相对于1.0 版本,增加了缓冲和安全方面的高级特性,同时还支持在一条持久连接(persistent connection)上执行多个事务;
- 一个 HTTP 响应的组成:一个响应行,后面跟着零个或多个的响应报头,再跟随一个空的文本行来终止报头;之后再跟随一个响应主体;
- 一个响应行的格式:version status-code status-message,示例:HTTP/1.1 200 OK
- Content-Type 用来告知浏览器响应主体的内容的 MIME 类型;
- Content-Length 用来告知响应主体的字节大小;
- 服务动态内容
- 客户端如何将参数传递给服务器
- GET 请求:在 URI 中传递参数,用问号 “?” 分隔文件名和参数;
- POST 请求:在请求主体中传递参数;
- 服务器如何将参数传递给子进程
- 服务器收到请求后,调用 fork 函数创建一个子进程
- 子进程将环境变量 QUERY_STRING 设置为参数字符串;
- 如果多个子进程都设置相同的环境变量,如何解决并发的问题?答:貌似每个进程的环境变量是独享的,因为在创建子进程时,初始化虚拟地址空间的时候,会为传入的环境变量参数在栈上分配空间;同时子进程默认会继承父进程的所有变量,在创建的过程中允许添加、修改和删除;
- 由于子进程会继承父进程的所有变量,并且添加自己的环境变量,因此每增加一级子进程,正常情况下环境变量会带有父级及以上进程的所有环境变量,导致环境变量越来越多;
- 子进程调用 execve 函数执行 /cgi-bin/adder 程序
- adder 即为一种 CGI 程序
- 许多 CGI 程序使用 Perl 脚本编写脚本,因此也称为 CGI 脚本;
- adder 程序在运行时,使用 Linux 的 getenv 函数来读取环境变量;
- 示例:getenv(“QUERY_STRING”);
- 对于 POST 请求,则需要从请求主体中读取参数;
- 由于 CGI 程序运行在子进程的上下文中,它能够访问所有在调用 execvc 函数之前就存在的打开文件和环境变量;
- 在创建子进程后,父进程会调用 wait 函数,该函数是阻塞的;当子进程终止的时候,会回收操作系统分配给子进程的资源;
- 服务器如何将其他信息传递给子进程
- CGI 程序会定义大量的环境变量,一个 CGI 程序在运行时会设置这些环境变量;
- 常见的环境变量有
- QUERTY_STRING,请求参数
- SERVER_PORT,父进程监听的端口
- REQUEST_METHOD,请求方法
- REMOTE_HOST,客户端的域名
- REMOTE_ADDR,客户端的 IP 地址(点分十进制)
- CONTENT_TYPE,请求体的 MIME 类型
- CONTENT_LENGTH,请求体的字节大小;
- 子进程将它的输出发送到哪里
- CGI 程序默认会将它生成的动态内容发送到标准输出;因此,子进程在加载并运行 CGI 程序前,需要使用 Linux 的 dup2 函数先将标准输出重定向到已连接描述符;这样 CGI 输出的动态内容,就会直接送达客户端;
- 由于父进程不知道子进程生成的内容类型和大小,因此子进程需要负责生成 CONTENT_TYPE 和 CONTENT_LENGTH 的信息作为响应报头,以及表示报头终止的空行;
- 客户端如何将参数传递给服务器
- Web 基础
- 综合:TINY Web 服务器
- execvc 函数
- int execve(const char *filename, char *const argv[ ], char *const envp[ ])
- filename: 字符串,用来表示要执行的文件的路径
- argv:数组,用来传递参数给执行文件;
- envp:数组,传递新环境变量给执行文件
- 例如可以使用全局变量 environ(包含在头文件 <unistd.h> 中)
- int execve(const char *filename, char *const argv[ ], char *const envp[ ])
- execvc 函数
- 客户端-服务器模型
- 并发编程
- 概述
- 如果逻辑控制流在时间上是重叠的,那么它们就是并发的;
- Linux 信号处理程序可以用来响应异步事件;
- 应用级并发场景
- 访问慢速的 I/O 设备
- 人机交互:每个用户请求某种操作,一个独立的并发逻辑流被创建来执行这个操作;
- 通过推迟操作以降低延迟:将多个操作推迟以合并为一个,例如动态内存分配器的合并操作;
- 服务多个网络客户端;
- 在多核处理器上进行并行计算;
- 三种基本的构建并发程序的方法
- 进程:每个逻辑控制流都是一个独立的进程,由内核来调度和维护;由于每个进程有独立的虚拟地址空间,因此控制流之间的通信需要使用某种显式的进程间通信机制(IPC);
- 各种 IPC 进程间通信的方法
- 管道 pipe
- 特点:只能单向传输数据,它是一种特殊类型的文件,A 进程向里面写入数据,B 进程从里面读取数据;当管道中没有数据时,B 进程会阻塞,直到读到数据后才会返回;若想实现双向传输,可以通过建立两条管道;
- 管道的实现是建立一块内存缓冲区,A/B 进程从缓冲区中读写数据;
- 管道只允许父子进程之间使用,因为它没有名字,其他进程找不到它;
- 只允许发送无格式字符流;
- 命名管道 named pipe
- 改进点:允许没有亲缘关系的进程进行通信;
- 缺点:需要在进程间同步名字,同时貌似存在同步和阻塞的问题;
- 信号 signal
- 有点像是系统发出的广播
- 数据承载力很差,只承载一定长度内的数据;
- 消息队列 message queue
- 它实际上是内核缓冲区中的一个命名的链表结构,而且是由于是链表,可承载内容是柔性的,因此可以承载较多数据(最大是 8192 字节);
- 与命名管道的异同
- 相同点:允许没有亲缘关系的进程进行通信;
- 不同点
- 消息队伍可以独立于进程而单独存在,因此不需要在进程间同步管道的名字;
- 可以避免命名管道存在的同步和阻塞问题,不需要由进程自己来提供同步的方法;
- 接收消息的进程可以基于消息类型有选择的接收数据,不像管道得接收全部数据;
- 共享内存
- 原理:每个进程将自己虚拟地址映射的物理地址共享给其他进程进行映射,这样该物理内存的变动,都可以被所有进程共享读取;
- 优点:性能比命名管道和消息队列好一点,因为它不涉及写,或者最多写一次,其他两种都需要涉及至少一次写(最多两次)以及数据结构转换;
- 缺点:本身没有同步机制,需要进程自己控制;
- 套接字
- 原理:通过建立客户端和服务端的抽象,让不同进程之间实现相互访问;
- 优点:可以实现跨机器之间的通信;
- 缺点:性能比较差一些;
- 管道 pipe
- 各种 IPC 进程间通信的方法
- I/O 多路复用:应用程序在一个进程的上下文中显式的调度它们自己的逻辑流;逻辑流被模型化为状态机;数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态;
- 线程:线程是运行在单一进程上下文中的某一个顺序逻辑流,由内核进行调度;它有点像前面两种的混合体,像进程一样由内核进行调度,又像 I/O 多路复用一样共享一个虚拟地址空间;
- 因为线程是由内核调度的,顺序不可控,因此也引入了同步的难题;同时线程切换也需要一点点时间,导致性能没有 I/O 多路复用好;
- 进程:每个逻辑控制流都是一个独立的进程,由内核来调度和维护;由于每个进程有独立的虚拟地址空间,因此控制流之间的通信需要使用某种显式的进程间通信机制(IPC);
- 基于进程的并发编程
- 原理:为每个客户端请求创建一个子进程来为客户端提供服务;
- 优缺点
- 优点:每个进程拥有独立的地址空间,不同进程不会覆盖彼此的数据;
- 缺点:进程间共享状态或数据比较麻烦,所以性能较差,因为进程控制和 IPC 的开销比较高;
- 基于 I/O 多路复用的并发编程:I/O multiplexing
- 原理:使用 select 函数,要求内核挂起进程,只有在一个或多个 I/O 事件发生后,才将控制返回给应用程序(通过调用 select 函数实现)
- 创建一个描述符的位集合(fdset),以及它的一个子集叫做“准备好集合”(ready set,初始每个位都为0);select 函数以 fdset 和 ready set 为参数
- select 的执行是阻塞的,它会一直等到 fdset 中有一个位所表示的描述符准备好时,才会写入结果到 ready set 并返回
- 当 select 返回后,我们就可以调用宏来判断 ready set 具体是哪个位所代表的描述符准备好了,然后调用不同的函数进行处理;
- 事件驱动原理
- 将逻辑流模型化为状态机;
- 一个状态机由一组状态、输入事件和转移组成;
- 转移是指根据:输入状态 + 输入事件 => 输出新的状态;
- 如果输出的新的状态跟输入状态一样,则称为自循环;
- 将逻辑流模型化为状态机;
- 事件驱动服务器
- 对于每个客户端,服务器为其生成一个状态机,并将该状态机和已连接描述符关联起来;
- 每个状态机都有
- 一个状态:等待描述符准备好,以便可以读;
- 一个输入事件:描述符准备好了;
- 一个转移:从描述符读取一个文本行;
- 感觉转移有点像是动作;当某种事件发生的时候,就去做某种动作;
- 服务器 I/O 多路复用,借助 select 函数检测输入事件的发生,当某个已连接描述符准备好了后,服务器就为相应的状态机执行转移(从描述符中读取内容,并对内容进行处理);
- 优缺点
- 优点
- I/O 多路复用是编写并发事件驱动程序的基础,它使得基于事件驱动的服务器变得容易实现;
- 每个逻辑流共享单一进程中的虚拟地址空间,因此共享数据很容易;
- 性能比较好,因为省去了进程或线程切换的开销;
- 缺点
- 编码复杂,需要比基于进程并发的程序,多写很多代码;随着并发粒度的减小,复杂度还会上升;
- 若某个恶意的客户端故意只发送部分文本行,之后中止,则服务器会处于等待,导致服务停止;(是否可以通过设置最大等待时间来解决这个问题?)
- 不能充分利用多核处理器;(或许此时可以开启多个进程来规避这个缺点?即有多少核心就相应开启多少个进程,这样每个核可以负责一个进程)
- 优点
- 原理:使用 select 函数,要求内核挂起进程,只有在一个或多个 I/O 事件发生后,才将控制返回给应用程序(通过调用 select 函数实现)
- 基于线程的并发编程
- 线程:一条线程是指运行在进程上下文中的某一个顺序的逻辑流;线程由内核自动调度;每个线程都有自己的线程上下文,包括唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码等;
- 所有运行在同一个进程里的多个线程共享进程的虚拟地址空间;
- 线程执行模型
- 跟进程有点类似,每个进程在开始生命周期时都是单一线程(主线程),在某一时刻,主线程创建了一个对等线程;
- 当主线程执行一个慢速系统调用时,控制权就会转移到对等线程,直到对等线程出现慢速调用,然后转回控制权;
- 这样可以实现对 CPU 的充分利用,避免 CPU 处于等待;
- 线程的上下文要比进程小得多,因此它的切换会比进程快得多;
- 线程之间不是父子关系,而是平等的关系,这也意味着一个线程可以杀死它的任何对等线程,或是等待它的任意对等线程终止;每个对等线程共享相同的数据;
- POSIX 线程
- Posix 线程是在 C 程序中处理线程的一个标准接口,在头文件<pthread.h> 中(貌似链接时需要使用动态库?);
- 通过 posix 标准接口,可以实现线程的创建和销毁;
- 它的创建感觉跟创建子进程有点小类似;
- 终止线程的方法
- 当顶层的线程例程返回时,线程会隐式的终止;
- 通过调用 pthread_exit 函数,终止当前线程
- 如果是主线程调用这个函数,则它会等待所有其他对等线程终止后,再终止自己和整个进程;
- 通过调用 pthread_cancel 函数,传递线程 ID 作为参数,它会终止该 ID 代表的线程;
- 当某个线程中调用 exit 函数时,会终止整个进程和所有线程;
- int pthread_join(pthread_t thread, void **retval);
- 以阻塞的方式等待指定的线程结束;当函数返回时,线程的资源被收回;
- 多线程程序中的共享变量
- 对于不同线程来说,它们有独立的栈、程序计数器、通用寄存器和条件码,进程上下文的剩下部分则是共享的,包括整个虚拟地址空间,即只读的代码节、数据区域(如全局变量、静态变量等)、堆、动态库、打开文件集合等;但一个线程无法访问另外一个线程的寄存器值;即寄存器不共享,但虚拟内存是共享的;
- 通常每个线程只访问自己的栈,但如果它能够拿到其他线程的栈指针,那它就能够实现访问;
- 用信号量同步线程
- 线程的优点是线程间共享变量十分方便,只需要传递指针就可以了,或者使用全局变量或静态变量,缺点是有可能引发同步错误,因此它需要引入信号量机制来控制访问顺序;
- 进度图:将 n 个并发线程的执行模型化为一条 n 维笛卡尔空间中的轨迹线;
- 信号量:具有非负整数值的全局变量,只有两种特殊的操作来处理
- P 操作:用来检测信号;
- 如果 s 非零,则 P 将 s 减 1,并且立即返回;(如果信号灯是绿色,通过斑马线,将电量减 1,结束操作)
- 如果 s 为零,则挂起这个线程,直到 s 变为非零;(如果信号灯是红色,进入等待)
- V 操作:用来释放信号
- 它会将 s 加 1(将信号灯电量加1,让其变成绿灯)
- 如果有任何线程的 P 操作在等待,则 V 还会重启其中任意一个线程,以便这些线程继续完成它们挂起的 P 操作;(如果之前有人在等待红灯,在 V 把灯变成绿灯后,任意选择其中一个人进行通知);
- P 操作:用来检测信号;
- 信号量具备一个属性,即 s 永远不可能为 0,这个属性也叫做信号量不变性;这个特性可以用来规划禁止区(或许也可以理解为交通路段的禁行区);
- 用信号量来实现互斥的原理:将每个共享变量(或者一组相关的共享变量),与一个信号量 s (初始为 1)联系起来,然后用 P 操作和 V 操作将相应的临界区围出来,避免线程进入;
- 进度图的局限性:无法处理多核处理器的场景,只能用来处理单核处理器;
- 利用信号量来调度共享资源
- 除了用于互斥,信号量还可用来调度资源,例如一个线程用信号量来通知另外一个线程,某个条件已经为真了(即通知现在已是绿灯,请快速通行);
- 生产者-消费者问题:涉及可用槽、可读数据、互斥锁等三个信号灯;
- 读者-写者问题:不管是读优先还是写优先,都有可能造成饥饿问题,即某个线程可能处于长久的等待;
- 除了信号量以外,线程间的同步机制还有很多种,例如更高抽象级别的 JAVA 监控器,C 语言中的 Pthreads 接口;
- 服务器可以为每个请求临时生成一个线程,当请求结束后,就销毁线程;但这样频繁的创建和销毁线程的性能开销也不小,因此,进一步优化的做法是预先生成多个工作线程;然后将每个请求统一放入请求池中进行缓冲,之后某个线程空闲后,就去池中取一个请求进行处理和响应;
- I/O 多路复用并非编写事件驱动的服务端程序的唯一方法,事实上通过使用连接池 + 预线程化,配合信号量+互斥锁的生产者和消费者模式,也可以模拟出状态机,从而实现事件驱动;
- 据说 node.js, nginx, tornado 都是基于 I/O 多路复用,好奇它们为什么不使用线程?莫非有什么顾虑?答案:原来是因为相比线程,I/O 多路复用的性能明显更好;
- 使用线程提高并行性
- 线程并非越多越好,当线程数超过了处理器的内核数量时,每个内核都会至少在处理一个线程,多出的线程需要等待,由于还需要考虑线程切换的开销,此时性能反而会下降一点;
- 后面 CPU 衍化出了超线程技术,原理在于单个 CPU 内核在处理某个线程的任务时,它的所有硬件单元不一定全部都处于工作状态,可能有少部分硬件单元处于闲置;为了让它们也工作起来,最大化利用 CPU 内的硬件单元,就给它们安排处理另外一个线程的活;当然,这样的代价是 CPU 的设计变得更加复杂了,成本会上升不少;在大部分场景下,一般设计为单核双线程,因为再加线程的话,带来的性能提升并不足以覆盖由此带来的成本提升;当然,在某些非常特殊的计算场景中,有可能单核 n 线程存在一定的使用价值;但绝大多数情况下,是得不偿失的;
- 其他并发问题
- 线程安全
- 当一个函数被不同线程反复调用时,都一直能够产生正确的结果,则称其为线程安全的;要编写这种函数必须非常的小心;
- 四种线程不安全的函数
- 不保护共享变量的函数
- 修复:可以通过引入P/V 操作来进行保护
- 代价:减慢了程序的运行时间;
- 保持跨越多个调用的状态的函数:例如伪随机数生成器
- 修复:需要改写这个函数,让它不依赖上次调用的状态,而是依赖调用者传入的参数
- 代价:如果这个函数被很多地方调用,修改涉及的工作量将很大;
- 返回指向静态变量的指针的函数:每次调用将结果保存在某个指针指向的位置,导致结果可能会被不同线程反复覆盖;
- 修复
- 方法一:修改源码,由调用者传递结果存放的地址,而不是使用静态变量;
- 方法二:不修改源码,但引入互斥锁,将源函数包装一个线程安全的新函数,并将返回的结果复制到一个私有的位置;
- 修复
- 调用线程不安全函数的函数:此种情况调用者不一定线程不安全,取决于它怎么写,有无引入保护等;
- 不保护共享变量的函数
- 可重入性
- 可重入函数的特点是它不引用任何的共享数据;
- 可重入性的函数一定是线程安全的,但线程安全的函数不一定是可重入的函数;
- 当可重入函数的参数是值传递时,它是显式可重入的;如果参数是引用传递(即指针),则它是隐式可重入的;
- 对于隐式可重入,如果调用者小心的传递非共享数据的指针,那么它仍然是可重入的
- 在线程化的程序中使用已存在的库函数
- 大多数 Linux 函数,包括定义在 C 标准库中的函数,都是线程安全的,只有一小部分例外,这部分函数由于历史原因,无法重写,但是 Linux 系统同时也提供了这些线程不安全函数的可重入版本;可重入版本的函数名总是以 “_r” 结尾,在编程的时候,应该尽可能使用它们;
- 竞争
- 如果一个线程的正确执行,依赖于另外一个线程到达某种状态,那么这两个线程之间存在竞争关系;因为线程是由内核来调度的,我们完全无法确定它们能否按期待到达设定的状态;
- 例如:在创建新线程时,给它传入一个主线程的变量的指针作为参数,而在主线程的执行过程中,会对这个变量进行修改,那么主线程和新创建的线程之间就会存在竞争关系;
- 如果一个线程的正确执行,依赖于另外一个线程到达某种状态,那么这两个线程之间存在竞争关系;因为线程是由内核来调度的,我们完全无法确定它们能否按期待到达设定的状态;
- 死锁
- 信号量虽然好用,但是它也引入了另外一个头疼的新问题,即当P 和 V 操作顺序不当时,可能会引发死锁;此时每个线程会在等待一个永远不会发生的 V 操作,导致陷入僵局;
- 例如有两个信号量分别为 s 和 t,它们都有自己的禁止区,如果它们的禁止区出现重叠,就有可能会触发死锁;
- 避免死锁的规则:给定所有互斥操作一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的;
- 信号量虽然好用,但是它也引入了另外一个头疼的新问题,即当P 和 V 操作顺序不当时,可能会引发死锁;此时每个线程会在等待一个永远不会发生的 V 操作,导致陷入僵局;
- 线程安全
- 概述
- 问题集
- 加载并执行可执行文件,站在虚拟内存的角度,究竟发生了什么?
- 编译器编译源文件,生成可执行目标文件,生成了代码节、数据节等内容;代码节中的内容是指令,它们是按顺序排列的,它们中间可能有跳转指令,会跳转到其他位置的指令;它们也有一些包含引用,引用数据节中的数据,由于代码节和数据节是连续存储的,因此这种引用可以通过偏移量获得;
- 加载文件的本质,其实是将数据拷贝一份副本,从磁盘的位表示,转移到 DRAM 主存的位表示;当某部分数据在内存中是连续存储时,那么引用数据只需要使用偏移量即可;
- 在编译器的眼里,它使用的是虚拟内存地址空间;所以它认为空间是独占且连续的;
- 整个可执行目标文件的每一行,都是有虚拟地址的,因为它们的分布是有规律的,而且都是从零开始的;所以,即使是按偏移量取值,实际上也是在当前指令的虚拟地址基础上增加的偏移量;
- 分布规律包括:代码节的起始位置,栈的起始位置,内核文件的起始位置等;
- 对指令的加载,实际上也是通过 CR3 寄存器指向第一条指令入口点的虚拟地址来实现的;
- 当内核创建进程的时候,就会生成页表,为可执行目标文件中的虚拟地址生成对应的物理地址映射表;
- 加载并执行可执行文件,站在虚拟内存的角度,究竟发生了什么?
深入理解计算机系统
https://ccw1078.github.io/2018/12/08/深入理解计算机系统/