只由ASCII字符构成的文件称为文本文件,所有其他文件都称为二进制文件。
Amdal定律:系统加速比,要想显著加速整个系统必须提升全系统相当大的部分的速度(P16)。
ANSI标准(1989年,亦即C89,和C90几乎一样)->C90->C99->C11。
Unicode编码、UTF-8编码和ASCII码。
超线程:有时称为同时多线程,是英特尔推出的一项技术,旨在提高中央处理器(CPU)性能和效率,它允许单个物理CPU核心同时执行两个线程,从而在逻辑上将一个物理核心模拟成两个逻辑核心,这使得操作系统和应用程序可以同时调度和执行多个线程,提高了系统的多任务处理能力。超线程技术的核心思想是在物理CPU核心的基础上,增加一层逻辑层面的线程调度和执行单元。通过利用多余的资源,如指令调度器、寄存器堆和执行单元,超线程可以在一个时钟周期内执行两个不同的线程指令流,从而提高了处理器的利用率。然而,值得注意的是,超线程并不是真正的将单个核心变成两个独立的核心,而是通过共享一些资源来实现并行执行。
超标量:是一种处理器架构,旨在通过同时执行多条指令来提高处理器的性能。与传统的标量处理器不同,超标量处理器可以在一个时钟周期内同时执行多条指令(即处理器可以达到比一个周期一条指令更快的执行速率),从而更有效地利用处理器的资源。
文件是对IO设备的抽象,虚拟内存是对程序存储器的抽象,进程是对一个正在运行的程序的抽象,虚拟机是对计算机(包括操作系统、处理器和程序)的抽象。
Amdahl定律:当我们对系统的某个部分加速时,其对系统整体性能的影响取决于该部分的重要性和加速程度。
整数的运算是可以结合的,得到的结果是一致的。而浮点运算是不可结合的,如:(3.14+1e20)-1e20求得的结果是0.0,而3.14+(1e20-1e20)的结果是3.14。
ANSI C标准、C89、C90、C99、C11,可以基于不同的命令行选项,依照不同版本的C语言规则来编译程序,如:gcc -std=c11 prog.c。
字长决定的最重要的是系统参数就是虚拟地址空间的最大大小,32位程序与64位程序的区别是在于该程序是如何编译的。
大端法:高位低地址;小端法:低位低地址。网络字节序为大端法,大多数Intel兼容机采用小端法。
在实际编码中,字符集和编码方式通常是由编译器、操作系统和编程环境共同决定的,常见字符集如Unicode,常见的编码方式如UTF-8,UTF-8是一种变长编码方式,它是兼容ASCII码的,采用1-4个字节来编码字符。
对于布尔向量的布尔运算,有:a|(b&c)=(a|b)&(a|c)、a&(b|c)=(a&b)|(a&c)、(a^b)^a=b(^是异或运算)。
C语言中的逻辑运算认为所有非零的参数都表示TRUE,而参数0表示FALSE,逻辑运算只会返回0或者1。如果在逻辑运算中,第一个参数求值就能确定表达式的结果,那么逻辑运算符就不会对第二个参数求值。
左移都是在右侧补0,而算术右移是在左侧补上最高有效位的值,逻辑右移则是在左侧补0,C标准中并没有规定采用逻辑右移还是算术右移,但一般编译器对有符号数都是采用算术右移。
补码的数学定义及解释(相当于将符号位乘以负的2^(w-1)再与后面的每一位按位权相乘并求和)(P45),补码的范围是不对称的,|Min|=|Max|+1,符号位为1其余为0即为Min。
C语言中的强制类型转换:对于有符号数和无符号数之间的转换,当它们的字长相同时(比如都为4字节),强制类型转换的结果是保持位值不变,只是改变了解释这些位的方式(P49)。而从整型到浮点型的强制类型转换,(字节)内存中的信息会相应发生转变。
C语言在执行一个运算时,如果它的一个运算符是有符号的,而另一个运算符是无符号的,那么有符号参数会被隐式地转换为无符号数,并假设两个数都是非负的,来执行这个运算。这种变化对于标准的算术运算来说无多大差异,但是对于>(大于号)和<(小于号)会导致错误结果。如:设int用32位补码表示,考虑-1<0U预期结果应该是true,但隐式转换变为4294967295U<0U,此时输出结果为false,出错(参考P58练习题2.26)。
在C头文件中将32位有符号的Min32定义为(-2147483647-1)的原因:-2147483648被编译器看成是一个常量表达式,而不是一个常量。所以-2147483648被理解为一个“-”号和一个常量值2147483648。对于“-”,是对原值补码进行“取反加1”操作。由于2147483648超出了有符号常量的表示范围,所以会被匹配成unsigned int(在ISO C90标准中,匹配顺序为int, long int, unsigned, unsigned long int),再取反+1仍然为2147483648(二进制形式为10000000 00000000 00000000 00000000),所以最后INT_MIN的值就是unsigned int 2147483648,显然不对。故定义为(-2147483647-1)(P53)。
(对于各种整型)在将一个较小的数据类型转换为较大的数据类型时,无符号数采用零扩展,有符号数采用符号扩展。但是从一个数据大小到另一个数据大小的转换,以及无符号和有符号数字之间的转换的相对顺序能够影响一个程序的行为,如将short转换为unsigned,是先进行short到unsigned short的转换,还是先进行short到int的转换。一般是先改变大小,载进行有符号和无符号之间的转变(P56)。
(对于各种整型)在将一个较大的数据类型转换为较小的数据类型时,直接截断高位,剩下的低位按照转换后的类型解释。
无符号加法的正常情况和溢出情况,有符号补码加法的正溢出、正常和负溢出情况(P63)。
无符号乘法也是将乘法运算的结果截断,补码乘法也是截断,无符号和补码乘法的位级等价性:也就是说,对于确定位级表示的x和y,不论是作无符号数相乘还是补码相乘,截断(取模)之后的(结果的)位级表示是一样的(P67)。
整数乘法中乘以一个常数时,一般会将乘法运算转换成左移运算和加减法运算。整数除法在除以一个2的幂(无法推广到任意常数)时,可以将除法运算转换为右移运算(无符号数用逻辑右移,有符号数用算术右移),除法运算在被除数为负数时要加上一个偏置才能保证最后的结果是向零取整((x<0 ? x+(1<
IEEE754标准用V=(-1)^s× M× 2^E的形式来表示一个数。在阶码既不是全0又不是全1时,表示规格化数,此时尾数的小数点前默认添1,阶码(阶码用移码表示)偏置为e-Bias(其中e为阶码对应的无符号数,Bias=2k-1-1),如对于单精度32位(1个符号位,8个阶码位,23个尾数位)产生的指数范围在-126~127(不包括全0或全1);在阶码全为0时表示非规格化数,用于表示比较小的数或0,此时尾数的小数点前默认不添1,偏置为1-Bias(这样设置偏置是为了从非规格化值平滑地转换到规格化值);在阶码全为1时,表示特殊值,此时若尾数为全0表示无穷大(符号位为0表示正无穷,符号位为1表示负无穷),若尾数不为全0表示NaN值。浮点数的加法和乘法由于溢出和舍入的情况存在而不满足结合律,如(3.14+1e10)-1e10==0而不等于3.14+(1e10-1e10)==3.14(P85)。
浮点运算只能近似地表示实数运算,一般采用向偶数舍入:即向最近的值舍入,如果待舍入的值刚好是两个可能值的中间结果时,它将数字向上或者向下舍入,使得结果的最低有效数字是偶数(对于二进制中的向偶数舍入,即使得最低有效位为0)。其他舍入方式如:向上舍入、向下舍入、向零舍入(P83)。
x86汇编语法主要有两种:Intel和AT&T, 在intel的官方文档中使用intel语法,Windows也使用intel语法;UNIX平台的汇编器一直使用AT&T语法,gcc使用的gas(GNU assembler)默认产生的文本形式汇编指令都是AT&T语法,以下内容也是基于AT&T语法的。AT&T和Intel语法没有好坏之分,只是语法有差异而已,尽管这两种汇编语言在语法上有一定的差异,但所基于的硬件知识是相同的。
IA32是x86-64的前身,是Intel1985年提出的,x86-64可以向后兼容IA32。
gcc -O0 source.c -o output命令中-On选项用于控制代码优化级别,n为0表示无优化,这个选项会关闭大多数编译器优化,通常用于调试目的,以便生成易于调试的目标代码,编译速度较快,但生成的代码可能不够高效;为1表示基本优化,这个选项启用了一些基本的优化,但不会引入太多的复杂性,它可以提高代码的性能,同时保持较好的调试支持;为2表示更高级的优化,这个选项启用了更多的编译器优化,可能会生成更高效的代码,但也可能增加编译时间,通常这是默认的优化级别;为3表示最高级别的优化,这个选项启用了所有可用的编译器优化,可能会显著提高代码的性能,但编译时间可能会更长;为s表示优化代码大小,这个选项旨在减小生成的目标代码的大小,而不是最大化性能,适用于对代码大小有要求的场景,如嵌入式系统;为g表示进行一种适度的优化,主要用于改善代码的可读性和调试性,编译器会尽量保留变量名、函数名和源代码结构,以便在调试时更容易理解和定位问题,生成的汇编代码更接近原始源代码。
程序计数器(即PC,在x86-64中用%rip表示)给出下一跳指令在内存中的地址。
汇编代码不区分有符号数或无符号整数,不区分各种类型的指针,甚至不区分指针和整数。
x86-64的虚拟地址是由64位的字来表示的,在目前的实现中,这些地址的高16位必须设置为0,所以一个地址实际上能够指定的是248或256TB范围内的一个字节。
反汇编指令:objdump -d myobj.o(其中myobj.o为需要反汇编的目标文件),这个指令用于将目标文件反汇编成为汇编代码(P115)。
汇编语言中,以”.”开头的行都是指导汇编器和链接器工作的伪指令,通常可以忽略这些行。例如.align
AT&T汇编和Intel汇编的区别:Intel代码省略了指示大小的后缀、Intel代码省略了寄存器名字前面的‘%’符号、Intel代码用不同的方式来描述内存中的位置、在带有多个操作数的指令的情况下列出操作数的顺序相反。AT&T汇编语法数据格式所加后缀见P119。
x86-64的CPU包含16个存储64位值的通用目的寄存器:%rax、%rbx、%rcx、%rdx、%rsi、%rdi、%rsp、%rbp、%r8、%r9、%r10、%r11、%r12、%r13、%r14、%r15,所有16个寄存器的低位都可以作为字节、字或双字来访问。当指令以寄存器作为目标时,若生成的结果小于8字节,生成1字节或2字节的指令会保持剩下的字节不变,生成4字节的指令会把高位4个字节置为0(如movl,P124)。
操作数可以是立即数(如:$123表示立即数123)、寄存器(如ra表示R[ra]中的值)或内存引用,内存引用通用形式为:Imm(rb,ri,s),即M[Imm+R[rb]+R[ri]*s],其中Imm为立即数偏移,s为比例因子(只能取1、2、4或8)(P121)。
MOV指令:MOV 源S,目D。通过后缀来指明传送长度:movb、movw、movl、movq,可以是:立即数->寄存器、立即数->内存单元、寄存器->寄存器、寄存器->内存单元、内存单元->寄存器,但不能是内存单元->内存单元。在源为一个立即数时,常规的movq指令只能以表示32位补码数字的立即数作为源操作数(以寄存器或内存单元为源时可以是64位传送),然后把这个值符号扩展得到64位的值,放到目标位置。movabsq指令能够以任意64位立即数值作为源操作数,并且只能以寄存器作为目的(P123)。将较小的源值复制到较大的目的时可使用movz(零扩展)或movs(符号扩展),这两个指令都只能以寄存器或内存单元为源,以寄存器为目的。cltq指令只能作用于%eax将其符号扩展到%rax。
%rsp寄存器总是指向栈顶,x86-64的栈是由高地址向低地址方向增长的,入栈操作pushq S,出栈操作popq D。
算术和逻辑操作被分为四种:加载有效地址、一元操作、二元操作和移位。其中,加载有效地址指令为leaq S,D(即&S->D,其目的操作数必须是一个寄存器)。其他三类指令都可以有b、w、l、q不同后缀分别表示字节、字、双字、四字。一元操作有:自增INC、自减DEC、取负NEG、取反NOT(一元操作的操作数可以是寄存器或者内存单元)。二元操作有:加ADD、减SUB、乘IMUL、异或XOR、或OR、与AND(二元操作的第一个操作数可以是立即数、寄存器或内存单元,第二个操作数可以是寄存器或内存单元)。移位操作有:左移SHL或SAL、逻辑右移SHR、算术右移SAR(移位操作的第一个操作数为移位量,可以是一个立即数或者放在单字节寄存器%cl中,若没有第一个操作数,即不指定移位量则默认移动一位;第二个操作数可以是寄存器或者内存单元。x86-64中移位操作对w位长的数据值进行操作时,移位量是由%cl寄存器的低m位决定的,这里2m=w)(P129)。
一般的乘法指令imul有两个操作数:imul S,D,两个操作数都是64位,结果放在D中,按照64位截断。x86-64还提供了两······························条不同的“单操作数”乘法,mul S(无符号乘法)和imul S(有符号补码乘法),这两个指令的另一个操作数默认(必须)放在寄存器%rax中,结果共128位(即8字,也即16字节),高64位放在%rdx中,低64位放在%rax中。
有符号除法指令idiv S和无符号除法指令div S都是将被除数的高64位放在%rdx中,低64位放在%rax中,除数由操作数S给出,除法运算的结果的商放在%rax中,余数放在%rdx中。如果被除数只需要64位,则可以将被除数放在%rax中,然后将%rdx置零(无符号除法)或置符号位(有符号除法,可以用指令cqto实现,这个指令没有任何操作数,默认将%rax中的内容符号扩展到%rdx:%rax)(P134)。
条件码寄存器:描述了最近的算术或逻辑运算操作的属性,其中,CF:进位标志,可用来检查无符号操作的溢出;ZF:零标志位,用来检查最近的操作结果是否为0;SF:符号标志位,检查最近操作结果是否为负数;OF:溢出标志,检查最近的操作是否导致补码溢出(正溢出或负溢出)。leaq操作不会改变任何标志位,其他运算对标志位影响见P136。
CMP指令和TEST指令只设置条件码而不改变其他寄存器,CMP指令用来比较(CMP S1,S2即S2-S1,类似于SUB),TEST指令用来进行相与操作(TEST S1,S2即S1&S2,类似于AND),这两个指令也可在后面加b、w、l、q后缀表明目标字长。
SET指令可以根据条件码的某种组合,将一个字节设置为全0或全1,如:sete D、sets D等,其中操作数D可以是一个字节的内存单元或者寄存器的最低字节,命令后面所跟的后缀代表不同的条件码组合,而不是代表字长。
跳转指令可以是无条件跳转和条件跳转,无条件跳转指令为:jmp 标号(直接跳转)或jmp 操作数(间接跳转,操作数可以是寄存器或内存单元,写法是*后面跟一个操作数指示符),如:jmp .L1、jmp *%rax。条件跳转(只能是直接跳转,跳转到某一标号处)如:je、jne、js、jns等,它们是根据条件码寄存器各位的值来确定是否跳转的,和SET指令一样(P139)。跳转指令的编码方法:可以用4个字节直接指定目标,即直接给出“绝对地址”,但更常用的是用1、2或4个字节给出目标指令的地址与紧跟在跳转指令后面那条指令地址之间的差作为编码。
由于流水线的使用,条件跳转指令(如je、jne、js、jns等)效率低下(可能因为分支预测出错而回退),相比较而言,条件传送指令(使用数据的条件转移)指令效率更高,常见的条件传送指令如cmove、cmovne、cmovs、cmovns等,指令形式为CMOV S,D源可以是寄存器或者内存单元,目的只能是寄存器。源和目的可以是字、双字或四字,但不支持单字节条件传送,编译器可以通过目的寄存器的长度判断传送的字长(mov指令相当于无条件传送指令)(P146)。并不是所有的条件表达式都可以用条件传送指令来编译,当两个表达式中的任意一个可能产生错误或有副作用(可理解为有多余的语句时),就不能用条件传送指令来编译。另外。条件传送也不总是会提高代码的效率,比如两个表达式的求值需要大量计算时(P148)。
循环do-while循环编译成汇编语言的形式为,直接执行循环体然后执行测试表达式决定是否循环。while循环编译成汇编语言的形式有两种,第一种是“跳转到中间”:直接无条件跳转到循环结尾的测试条件语句,再决定是否继续循环;第二种是“guarded-do”:先判断测试条件语句一次,再按照do-while的方式编译(P152)。for循环编译成汇编语言时先将其转换为等价的while循环,再从while循环的两种编译方式种选择其中的一种。
GCC中"&&"操作符代表的含义是获得Label的地址(如:&&Label),返回的数据类型是“void *”,实现动态goto的方法呢就是将所有Label事先存到一个地址数组中,然后根据程序运行过程中的中间结果进行判断去具体跳转到哪个位置(利用跳转表)。当然Label是在函数内有效,所以必须把这个数组定义到Label的函数内才能使用这种方法(P160)。
过程调用需要利用栈帧存储的信息有:被保存的寄存器的值、局部变量、调用的子过程用到的参数(一般返回地址在call汇编指令执行时被存储到栈顶,栈是由高地址向低地址增长的)(P165),在程序调用结束前需要释放栈帧。
X86-64在函数调用时,可以通过寄存器(分别是rdi、rsi、rdx、rcx、r8、r9)最多传递6个整型(即整数和指针)参数,如果一个函数有大于6个参数,则超出的部分就要通过栈来传递,通过栈传递参数时,所有的数据大小都要向8的倍数对齐(P169)。
X86-64中,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器,也就是在调用者调用被调用者时,需要被调用者将这些寄存器中的值根据需要保存到栈中,防止这些信息丢失。所有其他寄存器,除了栈指针%rsp都被分类为调用者保存寄存器(P173)。
联合可以用来访问不同数据类型的位模式,即在访问不同的联合的成员时,他们的位级表示是一模一样的,只是解释方式不同而已(P188)。
数据对齐:X86-64的对齐原则是:任何K字节的基本对象的地址必须是K的倍数(X86-64最大对齐为8字节)。也可以用.align n显示约定后面的数据的对齐大小。一般对于结构体来说,不仅结构体内部成员要满足对齐要求,而且结构体末尾可能也需要填充一些空白字节来保证在使用结构体数组时也能满足对齐要求(P190)。结构体的对齐要求通常取决于其最长成员的对齐要求。
GDB调试相关命令(P194)。C语言对于数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中,对越界数组元素的写操作会破坏存储在栈中的状态信息,可能会导致严重的后果。比如,可能会导致存储的返回地址被覆盖,那么ret指令会导致程序跳转到一个完全意想不到的位置。很多常用的库函数,包括strcpy、strcat、sprintf等都不需要告诉它们目标缓冲区的大小,就产生一个字节序列,这样的情况会导致缓冲区溢出漏洞(P196)。
对抗缓冲区溢出攻击的三种方式:1.栈随机化:使得栈的位置在程序每次运行时都有变化;2.栈破坏检测:在GCC产生的代码中加入栈保护者机制(金丝雀值,又叫哨兵,该值是程序每次运行时随机产生的);3.限制可执行代码区域(P200)。
void *alloca(size_t size); alloca函数是一个在栈上分配内存的函数,它允许在运行时动态地分配一块内存空间,该内存空间在函数返回时会被自动释放。size是要分配的字节数。
x86-64的浮点体系结构:从MMX、SSE到AVX(以下基于AVX2),AVX浮点体系结构允许数据存储在16个YMM寄存器中(名字为%ymm0~%ymm15,用%xmm0~%xmm15来表示低128位,一般用%xmm0来存储函数返回的浮点值,所有%xmm寄存器都是调用者保存的),每个YMM寄存器为256字节(P210)。浮点相关指令见P206,和整数操作不同,AVX浮点操作不能以立即数作为操作数,编译器必须为所有的常量值分配和初始化存储空间。
硬件描述语言HCL(Hardware Control Language),例如verilog语言。HCL表达式与C语言表达式不同之处有:C中非零值都被看成是TRUE,而HCL的逻辑门只对应于0和1;C的表达式可能只被部分求值,而HCL没有部分求值这条规则(P258)。
HCL的情况表达式包括选择表达式和整数表达式,只有第一个(选择表达式)求值为1的情况会被选中(注意和C语言中的switch语句对比)(P260)。
指令处理的阶段:取指、译码、执行、访存、写回、更新PC(P264)。
组合逻辑和时序逻辑:组合逻辑仅仅根据当前的输入产生输出,没有记忆或存储的能力。输出仅仅取决于当前的输入状态,而不受过去的输入状态或系统的历史影响。组合逻辑电路通常由门电路(如与门、或门、非门等)组成,这些门之间没有存储元件。时序逻辑具有存储能力,能够记住过去的输入状态。这是通过触发器、寄存器等存储元件实现的。时序逻辑的操作受到时钟信号的控制。触发器在时钟的上升沿或下降沿触发,从而同步系统的各个部分。由于存储元件的存在,时序逻辑可能引入信号传播延迟,导致时序问题,如时序冲突和时序失真。
现代处理器延迟一般以皮秒计算(10-12s)。在一个典型的计算机流水线中(如五级流水线),指令或数据在执行过程中通过多个阶段,每个阶段执行不同的操作。流水线寄存器用于在这些阶段之间传递数据,以便每个阶段可以独立地执行其任务(P283)。
流水线的局限性:不一致的划分:每个阶段的时间延迟是不同的,运行时钟的速率是由最慢的阶段的延迟限制的;流水线过深,收益反而下降:因为流水线寄存器存储数据也有一定延迟(P286)。
流水线冒险:数据相关:一条指令要使用上一条指令的计算结果;控制相关:一条指令要确定下一条指令的位置,例如在执行跳转、调用或返回指令时(P295)。避免数据冒险的方法有:用暂停来避免数据冒险,暂停时处理器会停止流水线中一条或多条指令,直到冒险条件不再满足;用转发来避免数据冒险,将结果值直接从一个流水线阶段传到较早阶段(又称为数据旁路技术)(加载/使用数据冒险无法单纯通过转发来解决,因为它到访存阶段才获取到要写入寄存器的数据)。控制冒险可以通过插入气泡的方式来解决(即暂停流水线)(P306)。
PIPE流水线控制逻辑:加载/使用冒险(产生一个气泡):在一条从内存中读出一个值的指令和一条使用该值的指令之间,流水线必须暂停一个周期;处理ret(产生三个气泡):流水线必须暂停直到ret指令到达写回阶段;预测错误的分支(产生两个气泡):在分支逻辑发现不应该选择分支之前,分支目标处的几条指令已经进入流水线了。必须取消这些指令,并从跳转指令后面的那条指令开始取指;异常:当一条指令导致异常,需要禁止后面的指令更新程序员的可见的状态,并且在异常指令到达写回阶段时,停止执行(P314、P322)。
CPI:表示执行一个指令所需的时钟周期数,较低的CPI值通常表示更高效的计算机体系结构。IPC:是CPI的倒数,表示每个时钟周期内执行的指令数。
PIPE流水线整体控制逻辑实现(P320)。
编译器优化时需要考虑两个指针是否指向同一个地址,这种情况称为“内存别名使用”(P343)。第二个妨碍优化的因素是函数调用,优化时要考虑函数有没有副作用。由于编译器无法得知是否会发生上述两种情况,编译器一般都保守地默认有上述两种情况而只进行适当的优化。
CPE(Clocks Per Element)即每个元素的时钟周期数,这通常用于衡量算法或操作在处理数据结构的每个元素时所需的平均时钟周期数(即循环中循环变量n每加1所增加的周期数,并不止循环次数,例如循环展开会减少循环次数但n不变,P345)。
程序优化方法:消除循环的低效率:又称为代码移动,包括识别要执行多次(例如再循环里)但是计算结果不会改变的计算;减少过程调用:消除循环中的函数调用;消除不必要的内存引用:即减少读写内存的次数来提高性能(P355)。
指令级并行:在实际的处理器中对多条指令同时求值。限制程序最大性能的因素:延迟界限:在下一条指令开始之前,这条指令必须结束,即指令必须严格按照顺序执行;吞吐量界限:刻画了处理器功能单元的原始计算能力(一般,在访存没有限制,指令之间没有数据相关时,吞吐量就等于发射时间除以容量)。超标量:可以在每个时钟周期中执行多个操作(即多个指令)。乱序:指令的执行顺序不一定要与它们在机器级程序中的顺序一致。指令控制单元(ICU):负责从内存中读出指令序列,并根据这些序列生成一组针对程序数据的基本操作。执行单元(EU):执行上述基本操作(P357)。
利用数据流可以分析现代处理器上执行的程序性能,关键路径是执行一组机器指令所需时钟周期数的一个下界。计算n个单元的乘积或者和需要大约L*n+K个时钟周期,这里L是合并运算(即循环部分)的延迟,而K表示调用函数和初始化以及终止循环的开销,因此CPE就等于延迟界限L(对于必须按照严格顺序执行完成合并运算的函数来说)。
延迟:表示完成运算所需要的总时间。发射时间:表示连续两个同类型的运算之间需要的最小时钟周期数。容量:表示能够执行该运算的功能单元数量(P361)。
循环展开:是一种程序变换,它减少了不直接有助于程序结果的操作数量,例如循环索引计算和条件分支,还提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量(P367)。
通常,只有保持能够执行该操作的所有功能单元的流水线都是满的,程序才能达到这个操作的吞吐量界限。对于延迟L,容量为C的操作而言,就是要求循环展开因子k>=C*L(大于等于L,即保证至少每L条指令之间没有数据相关,可以每周期发射一条指令,充分利用流水线。大于等于C即充分利用每一套硬件功能单元,P371)。
在执行kXk的循环展开变换时,必须考虑是否要保留原始函数功能,例如对于补码运算,运算是可结合和可交换的,但是对于浮点数乘法和加法,就是不可结合或交换的,所以一般编译器对后者不进行这种展开优化,防止出错(P373)。
重新结合变换(即改变循环部分元素的结合方式,对于浮点运算这种改变结合方式的优化方法必须要考虑是否会造成程序错误)也能降低CPE的值,本质就是减少指令之间的数据相关(P374)。
用向量指令达到更高的并行度,即SIMD(Single-instruction multiple-data):单指令多数据(P377)。
其他一些影响CPE的限制因素:寄存器溢出:一旦循环变量的数量超过了可用寄存器的数量,程序就必须在栈上分配一些变量,即需要访问内存导致CPE增大;分支预测错误:常见的循环结束的分支通常被预测为选择分支(即继续执行循环),这样只有在最后一次才会预测错误,这种分支是高度可预测的,对于难预测的分支常常采用条件传送代替条件转移(即用cmove等指令代替je等指令)(P381)。
写/读相关:一个内存读的结果依赖于一个最近的内存写,会导致CPE增加(P383)。
Unix的程序剖析工具GPROF,例如对于c程序prog.c依次执行一下shell命令:gcc -Og -pg prog.c -o prog(-Og确保能正确跟踪函数调用,-pg是一个性能分析选项,它启用 gprof 收集程序的性能数据,以便进行分析)、./prog file.txt(该指令会生成一个文件gmon.out)、gprof prog(会输出每个函数花费的时间,被调用的次数等信息)(P389)。
随机访问存储器(易失性的):静态RAM:即SRAM,它将每一个位存储在一个双稳态存储单元内,不易受到外界干扰,只要有电就会一直保持它的值,速度快,但价格昂贵,一般用于高速缓存。动态RAM:即DRAM,对干扰非常敏感,需要不断刷新,速度相对较慢,但价格便宜,一般用于主存(P401)。
非易失性存储器:一般被称为只读存储器ROM,如:PROM(只能被编程一次)、EPROM(紫外线可擦除)、EEPROM(带电可擦除)(P404)。磁盘存储(P408)。固态硬盘(SSD):是一种基于闪存的技术,相对于磁盘,它的随机读写较快,但是写比读慢(因为写之前需要按块擦除),且固态硬盘容易磨损,所以一般都会加入平均磨损控制单元保证其寿命(P415)。
总线:系统总线连接CPU和I/O桥(如Intel的北桥),内存总线连接I/O桥和内存(如Intel的南桥)。连接I/O设备的总线:通用串行总线(USB):连接键盘鼠标等;图形卡(或适配器)连接显示器;主机总线适配器连接一个或多个磁盘(两个最常见的磁盘接口如SATA和SCSI,后者更快)。PCI:PCI是一种并行总线架构,数据在多个并行信号线上传输,传统PCI的带宽相对较低,受限于并行传输的特性,最大理论带宽在几百MB/s范围内。PCIe:PCIe提供了更高的带宽,每个lane的带宽通常在GB/s级别。同时,PCIe可以通过增加lane的数量来进一步提高带宽,例如,PCIe 0和PCIe 4.0标准分别支持每个lane 8 GT/s和16 GT/s(P412)。
磁盘、SSD、DRAM、SRAM性能的提升慢于CPU,多核的引入更快地提升了CPU的性能(P416)。
局部性:重复引用相同变量的程序有良好的时间按局部性;对于具有步长k的引用模式的程序,步长越小,空间局部性越好,步长为1的引用模式具有很好的空间局部性(如按0、1、……的顺序访问数组成员);对于取指令来说,循环有好的时间和空间局部性,循环体越小,循环迭代次数越多,局部性越好(P420)。
冲突不命中:指的是高速缓存中的块被频繁替换(冲突),导致相同的缓存位置被不同的数据反复覆盖;容量不命中:工作集大小超过缓存大小时(P424)。用地址的中间几位来作组索引的原因是:防止程序的空间局部性导致冲突不命中频繁发生(P433)。
直接映射高速缓存:高速缓存中的每个组中只有一个块,所以每个主存块只能映射到高速缓存中那个特定的缓存行;组相连高速缓存:每个主存块可以映射到缓存中的多个位置,这些位置被组织成一组;全相连高速缓存:每个主存块可以映射到缓存中的任意一个位置,而不是被限制在特定的组或位置(P433)。
读吞吐量:一个程序从存储系统中读取数据的速率称为读吞吐量,或称为读带宽,通常以MB/s为单位。存储器山:存储器系统的性能不是一个数字就能描述的,相反它是一座时间和空间局部性的山,这座山的上升高度差别可以超过一个数量级(P447)。
链接:是将各种代码和数据片段收集并组合成为一个单一文件的过程,这个文件可被加载(复制)到内存并执行。链接可以执行于编译时、加载时或者运行时(P464)。
gcc:预处理器cpp(main.c->main.i)->编译器cc1(main.i->main.s)->汇编器(通常情况下,汇编器生成的是可重定位目标文件。可重定位目标文件包含了汇编源代码经过汇编器处理后生成的机器代码、数据以及与符号(如变量和函数名)相关的信息。)as(main.s->main.o)->链接器ld(生成可执行文件)。
静态链接器(如Linux的ld):以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。链接器必须完成两个任务:1符号解析:目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即static变量),符号解析的目的是将每个符号引用正好和一个符号定义关联起来;2重定位:编译器和汇编器生成从地址0开始的代码和数据节,链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置(P466)。
链接器的行为可以因实现和目标平台的不同而有所差异。有些链接器生成的目标文件可能包含相对地址,而另一些可能包含绝对地址。一些系统和体系结构可能会使用绝对地址的目标文件,这意味着在链接时已经知道程序将加载到内存中的确切位置。这种情况下,目标文件中的地址是固定的,不需要在加载时进行重定位。然而,大多数现代系统和链接器更倾向于使用相对地址,以保持目标文件的可移植性和可重定位性。这种情况下,在加载时,操作系统或加载器会执行地址重定位,将相对地址转换为实际的绝对内存地址。
目标文件的三种形式:1可重定位目标文件:包含编译后的机器代码、数据和符号表,但地址信息是相对的,而不是绝对的。这使得可重定位目标文件可以在链接时与其他可重定位目标文件合并,形成一个可执行文件(静态库是一组编译过的、可重定位的目标文件的集合,这些文件被打包在一起形成一个库文件);2可执行目标文件:包含完全链接后的二进制代码,可以直接在操作系统上运行。地址信息是绝对的,已经考虑了内存的布局;3共享目标文件(动态库文件通常被称为共享目标文件):包含可以被多个程序共享的代码和数据,通常在运行时动态加载到内存中。这种形式允许共享库的代码被多个程序使用,减小程序的总体大小。
ELF文件(Executable and Linkable Format)即可执行可链接格式文件,这是Linux和Unix系统使用的格式,Windows使用的是PE格式。
ELF可重定位目标文件的内容(在ELF头部和节区头部表之间都是节):ELF头(描述系统字的大小、字节顺序、目标文件的类型(可执行还是可重定位等)、节头部表偏移、节头部表中条目的大小和数量等信息)、.text(存储机器代码)、.rodata(只读数据,如字符串常量等)、.data(已初始化的全局和静态变量)、.bss(未初始化的静态变量,初始化为0的静态变量和全局变量,未初始化的全局变量放在COMMON中(P469))、.symtab(符号表,存放函数和全局变量信息,不包括局部变量的条目)、.rel.text(包含.text节的重定位条目)、.rel.data(包含.data节的重定位条目)、.debug、.line、.strtab(字符串表,内容包括.symtab和.debug中的符号表,即以null结尾的字符串构成的序列)(P467)。
符号表.symtab:包括三类不同符号:本模块内定义并能被其他模块引用的全局符号,对应于非静态函数和全局变量、由其他模块定义并被本模块调用的全局符号,对应于其他模块中定义的非静态函数和全局变量、只能被本模块定义和引用的局部符号,对应于静态的函数和全局变量(P468)。
GNU READELF是一个查看目标文件内容的很方便的工具。
对于C++中的重载函数,编译器会将每个唯一的方法和参数列表组合编码成一个对链接器来说唯一的名字,例如:Foo::bar(int,long)被编码成bar__3Fooil。
链接器解析有多重定义的全局符号(以下是对全局符号而言)的规则:不允许有多个同名的强符号;如果有一个强符号和多个弱符号同名,则选择强符号;如果有多个弱符号同名,则随机选择其中的某个弱符号(其中,强符号是指函数和已经初始化的全局变量,弱符号是指未初始化的全局变量)(P471)。这也是为什么将未初始化的全局变量放在COMMON节中,方便链接器区分强弱(P474)。
静态库可以用AR工具来制作(P476),链接器运行时只会复制静态库中那些在源文件中用到的函数模块到可执行文件中,其他未用到的模块不会复制。在使用静态库时,根据链接器对符号引用解析算法的实现方式,在链接命令中库文件名需要放在源文件名(在该源文件引用了前面库文件中的符号时)的后面,否则会链接报错(P478)。
重定位:链接器将所有模块中同类型的节合并成同一类型的新的聚合节,并修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址,即实际运行时的内存地址(.rodata段不用重定位)(链接器生成的地址通常是虚拟地址,而不是物理地址)(P479)。
ELF可执行文件:与ELF可重定位文件类似,不同点在于:ELF头部还包括程序的入口点,添加了一个.init节,该节定义了一个_init函数,程序初始化代码会调用它,因为可执行文件是完全链接的,所以不再需要.rel节。当可执行文件被加载进内存时,会分成两个连续的段:只读内存段包括ELF头、段头部表(即程序头部表)、.init、.text、.radata;读/写内存段包括.data、.bss(剩下的.symtab、.debug等节是不会被加载进内存的)。
加载:在shell中输入指令运行可执行程序时,加载器会将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序中的第一条指令或者入口点来运行该程序(跳到_start函数的地址执行,然后调用系统函数__libc_start_main初始化执行环境,再调用用户层的main函数)(P484)。
Linux x86-64系统中,代码段总是从地址0x400000处开始,后面是数据段。运行时堆在数据段之后,通过调用 malloc 库往上增长。堆后面的区域是为共享模块保留的。用户栈总是从最大的合法用户地址(248-1)开始,向较小内存地址增长。栈上的区域,从地址248开始,是为内核中的代码和数据保留的,所谓内核就是操作系统驻留在内存的部分(以上只是相对位置不变,例如实际栈的位置是随机化的,以此来防止恶意代码攻击)。
利用共享库的动态链接:共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接,是由一个叫做动态链接器(dynamic linker)的程序来执行的。基本的思路是当创建可执行文件时,静态执行一些链接,然后在程序加载时,动态完成链接过程(此时,没有任何动态库的代码和数据节真的被复制到可执行文件中。反之,链接器复制了一些重定位和符号表信息,它们使得运行时可以解析对动态库中代码和数据的引用)。当加载器加载和运行可执行文件时,它加载部分链接的可执行文件,接着它注意到该文件包含一个 .interp 节,这一节包含动态链接器的路径名,动态链接器本身就是一个共享目标(如在 Linux 系统上的 ld-linux.so),加载器不会像它通常所做地那样将控制传递给应用,而是加载和运行这个动态链接器(P487)。
Linux系统为动态链接器提供了简单的接口,允许应用程序在运行时加载和链接共享库,具体见P487的dlopen、dlsym、dlclose和dlerror函数。
位置无关代码PIC(Position-Independent Code):GCC中可以用-fpic选项编译获得。
打桩:Linux 链接器支持一个很强大的技术,称为库打桩,它允许截获对共享库函数的调用,取而代之执行自己的代码。使用打桩机制,可以追踪对某个特殊库函数的调用次数,验证和追踪它的输入和输出值,或者甚至把它替换成一个完全不同的实现。打桩可以发生在编译时、链接时或当程序被加载和执行的运行时,具体实现方式见P492。
对同一个目标模块中符号的引用,编译器通过运用以下事实来生成对全局变量的PIC引用:无论我们在内存中的何处加载一个目标模块(包括共享目标模块),数据段与代码段的距离总是保持不变。因此,代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对内存位置是无关的。编译器利用了这个事实,在数据段开始的地方创建一个表,叫做全局偏移量表(Global Offset Table,GOT)。在GOT中,每个被这个目标模块引用的全局数据目标都有一个8字节条目,编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址。每个引用全局目标的目标模块都有自己的 GOT。假设程序调用一个由共享库定义的函数。编译器没有办法预测这个函数的运行时地址,因为定义它的共享模块在运行时可以加载到任意位置。GNU编译系统使用了一种很有趣的技术来解决这个问题,称为延迟绑定,将过程地址的绑定推迟到第一次调用该过程时。延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是:GOT和过程链接表(Procedure Linkage Table,PLT)。如果一个目标模块调用定义在共享库中的任何函数,那么它就有自己的GOT和PLT。GOT是数据段的一部分,而PLT是代码段的一部分。
异常:系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号,其中一些号码是由处理器的设计者分配的,其他号码是由操作系统内核(操作系统常驻内存的部分)的设计者分配的。前者的示例包括被零除、缺页、内存访问违例、断点以及算术运算溢出。后者的示例包括系统调用和来自外部 I/O 设备的信号。在系统启动时(当计算机重启或者加电时),操作系统分配和初始化一张称为异常表的跳转表,使得表目 k 包含异常 k 的处理程序的地址。
异常的分类:1中断:中断是异步发生的,是来自处理器外部的 I/O 设备的信号的结果,硬件中断的异常处理程序常常称为中断处理程序,在当前指令完成执行之后,处理器注意到中断引脚的电压变高了,就从系统总线读取异常号,然后调用适当的中断处理程序。当处理程序返回时,它就将控制返回给下一条指令;2陷阱和系统调用:陷阱是有意的异常,是执行一条指令的结果,陷阱最重要的用途是在用户程序和内核之间提供一个像过程一样的接口,叫做系统调用。用户程序经常需要向内核请求服务,比如读一个文件(read)、创建一个新的进程(fork),加载一个新的程序(execve),或者终止当前进程(exit)。为了允许对这些内核服务的受控的访问,处理器提供了一条特殊的 “syscall n” 指令,当用户程序想要请求服务 n 时,可以执行这条指令。执行 syscall 指令会导致一个到异常处理程序的陷阱,这个处理程序解析参数,并调用适当的内核程序;3故障:故障由错误情况引起,它可能能够被故障处理程序修正。当故障发生时,处理器将控制转移给故障处理程序。如果处理程序能够修正这个错误情况,它就将控制返回到引起故障的指令,从而重新执行它。否则,处理程序返回到内核中的 abort 例程,abort 例程会终止引起故障的应用程序(如缺页故障);4终止:终止是不可恢复的致命错误造成的结果,通常是一些硬件错误(P504)。
Linux的系统调用:Linux 提供几百种系统调用,当应用程序想要请求内核服务时可以使用,包括读文件、写文件或是创建一个新进程。每个系统调用都有一个唯一的整数号,对应于一个到内核中跳转表的偏移量(这个跳转表和异常表不一样)。C 程序用 syscall 函数可以直接调用任何系统调用。然而,实际中几乎没必要这么做。对于大多数系统调用,标准 C 库提供了一组方便的包装函数(比如read()、write()等)。这些包装函数将参数打包到一起,以适当的系统调用指令陷入内核,然后将系统调用的返回状态传递回调用程序。
进程为每个程序提供它自己的私有地址空间。一般而言,和这个空间中某个地址相关联的那个内存字节是不能被其他进程读或者写的,从这个意义上说,这个地址空间是私有的(P510)。
用户模式和内核模式:处理器通常是用某个控制寄存器中的一个模式位来限制一个应用可以执行的指令以及它可以访问的地址空间范围。当设置了模式位时,进程就运行在内核模式中,一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统中的任何内存位置。没有设置模式位时,进程就运行在用户模式中。用户模式中的进程不允许执行特权指令,比如停止处理器、改变模式位,或者发起一个 I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据,用户程序必须通过系统调用接口间接地访问内核代码和数据。运行应用程序代码的进程初始时是在用户模式中的,进程从用户模式变为内核模式的唯一方法是通过诸如中断、故障或者陷入系统调用这样的异常。当异常发生时,控制传递到异常处理程序,处理器将模式从用户模式变为内核模式。处理程序运行在内核模式中,当它返回到应用程序代码时,处理器就把模式从内核模式改回到用户模式(P510)。Linux 提供了一种聪明的机制,叫做 /proc 文件系统,它允许用户模式进程访问内核数据结构的内容。/proc 文件系统将许多内核数据结构的内容输出为一个用户程序可以读的文本文件的层次结构。比如,你可以使用 / proc 文件系统找出一般的系统属性,比如 CPU 类型(/proc/cpuinfo)。
上下文切换:1保存当前进程的上下文,2恢复某个先前被抢占的进程被保存的上下文,3将控制传递给这个新恢复的进程。sleep 系统调用可显式地请求让调用进程休眠。一般而言,即使系统调用没有阻塞,内核也可以决定执行上下文切换,而不是将控制返回给调用进程。
fork新创建的子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的,读时共享,写时复刻)一份副本,包括代码和数据段、堆、共享库以及用户栈。子进程还获得与父进程任何打开文件描述符相同的副本,这就意味着当父进程调用 fork 时,子进程可以读写父进程中打开的任何文件。父进程和新创建的子进程之间最大的区别在于它们有不同的 PID。
僵尸进程:一个终止了但还未被回收的进程;孤儿进程:如果一个父进程终止了,内核会安排 init 进程成为它的孤儿进程的养父。init 进程的 PID 为 1,是在系统启动时由内核创建的,它不会终止,是所有进程的祖先。如果父进程没有回收它的僵死子进程就终止了,那么内核会安排 init 进程去回收它们。
unsigned int sleep(unsigned int secs); sleep函数将一个进程挂起一段指定的时间。int pause(void); 该函数让调用函数休眠,直到该进程收到一个信号。int execve(const char *filename, const char *argv[],const char *envp[]);execve 函数加载并运行可执行目标文件 filename,且带参数列表 argv 和环境变量列表 envp。只有当出现错误时,例如找不到 filename,execve 才会返回到调用程序。所以,与 fork—次调用返回两次不同,execve 调用一次并从不返回。char *getenv(const char *name); getenv 函数在环境数组中搜索字符串 “name=value”。如果找到了,它就返回一个指向 value 的指针,否则它就返回 NULL。int setenv(const char *name, const char *newvalue, int overwrite); setenv 会用 newvalue 代替 oldvalue,但是只有在 overwirte 非零时才会这样。如果 name 不存在,那么 setenv 就把 “name=newvalue” 添加到数组中。void unsetenv(const char *name); 如果环境数组包含一个形如 “name=oldva1ue” 的字符串,那么 unsetenv 会删除它(P522)。
信号的使用:SIGKILL和SIGSTOP信号既不能被捕获(调用信号处理函数称为捕获信号),也不能被忽略。未决信号集和阻塞信号集。可以利用kill发信号(P530)。信号的接收和处理:对于大多数信号都可以通过signal函数来自定义信号处理程序,信号处理程序可以被其他信号处理程序中断。信号处理函数在处理完信号后如果不是终止等情况,则会返回原程序中继续执行程序(P532)。
信号的阻塞:1隐式阻塞机制:内核默认阻塞任何当前处理程序正在处理信号类型的待处理的信号。假设程序捕获了信号 s,当前正在运行该信号处理程序 S。如果此时再发送给该进程一个信号 s,那么直到处理程序 S 返回,s 会变成待处理而没有被接收。另外,如果一个进程有一个类型为k的待处理信号,那么任何接下来发送到这个进程的类型为k的信号都不会排队等待;它们只是被简单地丢弃(多次产生则只记录一次)。一个进程可以有选择性地阻塞接收某种信号。当一种信号被阻塞时,它仍可以被发送,但是产生的待处理信号不会被接收,直到进程取消对这种信号的阻塞。2显式阻塞机制:应用程序可以使用 sigprocmask 函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。
编写信号处理函数应注意的问题:1安全的信号处理:如果处理程序和主程序并发地访问同样的全局数据结构,那么结果可能就不可预知,而且经常是致命的。2.正确的信号处理:信号的一个与直觉不符的方面是未处理的信号是不排队的。因为 pending 位向量中每种类型的信号只对应有一位,所以每种类型最多只能有一个未处理的信号。因此,如果两个类型 k 的信号发送给一个目的进程,而因为目的进程当前正在执行信号 k 的处理程序,所以信号 k 被阻塞了,那么第二个信号就简单地被丢弃了;它不会排队。关键思想是如果存在一个未处理的信号就表明至少有一个信号到达了。3可移植的信号处理(P540)。
int sigsuspend(const sigset_t *mask); sigsuspend 函数暂时用 mask 替换当前的阻塞集合,然后挂起该进程,直到收到一个信号(需是当前未屏蔽的信号),而对应的信号处理要么是运行一个处理程序,要么是终止该进程。如果它的行为是终止,那么该进程不从 sigsuspend 返回就直接终止。如果它的行为是运行一个处理程序,那么 sigsuspend 从处理程序返回,恢复调用 sigsuspend 时原有的阻塞集合。该函数在一次调用中实现了对信号屏蔽字的修改和进程的挂起操作,避免了信号屏蔽字的临时修改不一致的问题(P545)。
非本地跳转:C 语言提供了一种用户级异常控制流形式,称为非本地跳转(non local jump),它将控制直接从一个函数转移到另一个当前正在执行的函数,而不需要经过正常的调用—返回序列。非本地跳转是通过int setjmp(jmp_buf env);和void longjmp(jmp_buf env, int retval);函数来提供的。setjmp 函数在 env 缓冲区中保存当前调用环境,以供后面的 longjmp 使用,并返回 0o 调用环境包括程序计数器、栈指针和通用目的寄存器。longjmp 函数从 env 缓冲区中恢复调用环境,然后触发一个从最近一次初始化 env 的 setjmp 调用的返回。然后 setjmp 返回,并带有非零的返回值 retval(P547)。
任意时刻虚拟页面可以被分为3类:1未分配的:虚拟内存系统还未分配(或者创建)的页。未分配的块没有任何数据和它们相关联,因此也就不占用任何磁盘空间。2缓存的:当前已缓存在物理内存中的已分配页。3未缓存的:未缓存在物理内存中的已分配页。
页表:存放在物理内存中的一种数据结构,页表将虚拟页映射到物理页。操作系统负责维护页表的内容,以及在磁盘与 DRAM 之间来回传送页。CPU 芯片上叫做内存管理单元(Memory Management Unit,MMU)的专用硬件,利用存放在主存中的页表来动态翻译虚拟地址。
操作系统为每个进程提供了一个独立的页表,因而也就是一个独立的虚拟地址空间,多个虚拟页面可以映射到同一个共享物理页面上。按需页面调度和独立的虚拟地址空间的结合,对系统中内存的使用和管理造成了深远的影响。特别地,虚拟内存简化了链接和加载、代码和数据共享,以及应用程序的内存分配(P566)。
发生缺页时,MMU 触发一次异常,传递 CPU 中的控制到操作系统内核中的缺页异常处理程序。缺页处理程序确定出物理内存中的牺牲页,如果这个页面已经被修改了,则把它换出到磁盘。缺页处理程序页面调入新的页面,并更新内存中的页表条目。而后缺页处理程序会返回到原来的进程,再次执行导致缺页的指令(P569)。
TLB:即Translation Lookaside Buffer,可翻译为“地址转换后援缓冲器”,也可简称为“快表”。 TLB就是页表的Cache,属于MMU的一部分,其中存储了当前最可能被访问到的页表项,其内容是部分页表项的一个副本。处理器在取指或者执行访问内存指令的时候都需要进行地址翻译,即把虚拟地址翻译成物理地址。而地址翻译是一个漫长的过程,从而产生严重的开销。为了提高性能,便在MMU中增加一个TLB的单元,把地址翻译关系保存在这个高速缓存中,从而省略了对内存中页表的访问(P571)。
多级页表:从两个方面减少了内存要求。第一,如果一级页表中的一个页表条目是空的,那么相应的二级页表就根本不会存在。这代表着一种巨大的潜在节约,因为对于一个典型的程序,4GB 的虚拟地址空间的大部分都会是未分配的。第二,只有一级页表才需要总是在主存中;虚拟内存系统可以在需要时创建、页面调入或调出二级页表,这就减少了主存的压力;只有最经常使用的二级页表才需要缓存在主存中(P572)。
Linux 为每个进程维护了一个单独的虚拟地址空间,内核虚拟内存(地址248-1以上的部分)包含内核中的代码和数据结构,内核虚拟内存的某些区域被映射到所有进程共享的物理页面,例如每个进程共享内核的代码和全局数据结构。内核虚拟内存的其他区域包含每个进程都不相同的数据。Linux将虚拟内存组织成一些区域(也叫做段)的集合。一个区域就是已经存在着的(已分配的)虚拟内存的连续片,这些页是以某种方式相关联的。例如,代码段、数据段、堆、共享库段,以及用户栈都是不同的区域。每个存在的虚拟页面都保存在某个区域中,而不属于某个区域的虚拟页是不存在的,并且不能被进程引用。内核为系统中的每个进程维护一个单独的任务结构(源代码中的task_struct),任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。对于mm_struct结构中的pgd和mmap字段,pgd指向第一级页表(页全局目录)的基址,而mmap指向一个vm_area_structs(区域结构)的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。Linux缺页异常处理时步骤为:1检查虚拟地址是否合法,即该地址是否在某个区域结构定义的区域内;2检查试图进行的内存访问是否合法,即进程是否有读、写或者执行这个区域内页面的权限;3此刻,内核知道了这个缺页是由于对合法的虚拟地址进行合法的操作造成的,它会选择一个牺牲页面,如果这个牺牲页面被修改过,那么就将它交换出去,换入新的页面并更新页表。当缺页处理程序返回时,CPU重新启动引起缺页的指令,这条指令将再次发送原来的虚拟地址到MMU。这次,MMU就能正常地翻译地址,而不会再产生缺页中断了。
Linux 通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping),虚拟内存区域可以映射到两种类型的对象中的一种:1Linux 文件系统中的普通文件:一个区域可以映射到一个普通磁盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页面的初始内容。因为按需进行页面调度,所以这些虚拟页面没有实际交换进入物理内存,直到 CPU 第一次引用到页面(即发射一个虚拟地址,落在地址空间这个页面的范围之内)。如果区域比文件区要大,那么就用零来填充这个区域的余下部分(当一个文件被映射到虚拟内存时,操作系统会为其分配一定的虚拟内存区域,这个虚拟内存区域大小可能大于实际的文件大小);2匿名文件:一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。CPU 第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就将这个页面换出来,用二进制零覆盖牺牲页面并更新页表,将这个页面标记为是驻留在内存中的。注意在磁盘和内存之间并没有实际的数据传送。因为这个原因,映射到匿名文件的区域中的页面有时也叫做请求二进制零的页。无论在哪种情况中,一旦一个虚拟页面被初始化了,它就在一个由内核维护的专门的交换文件之间换来换去。交换文件也叫做交换空间或者交换区域,在任何时刻,交换空间都限制着当前运行着的进程能够分配的虚拟页面的总数(P583)。
私有对象使用一种叫做写时复制(copy-on-write)的巧妙技术被映射到虚拟内存中。一个私有对象开始生命周期的方式基本上与共享对象的一样,在物理内存中只保存有私有对象的一份副本。对于每个映射私有对象的进程,相应私有区域的页表条目都被标记为只读,并且区域结构被标记为私有的写时复制。只要没有进程试图写它自己的私有区域,它们就可以继续共享物理内存中对象的一个单独副本。然而,只要有一个进程试图写私有区域内的某个页面,那么这个写操作就会触发一个保护故障。当故障处理程序注意到保护异常是由于进程试图写私有的写时复制区域中的一个页面而引起的,它就会在物理内存中创建这个页面的一个新副本,更新页表条目指向这个新的副本,然后恢复这个页面的可写权限,当故障处理程序返回时,CPU 重新执行这个写操作,现在在新创建的页面上这个写操作就可以正常执行了。通过延迟私有对象中的副本直到最后可能的时刻,写时复制最充分地使用了稀有的物理内存。(fork函数生成子进程就是利用了写时复制)
动态内存分配分为:1显式分配器:要求应用显式地分配块并显式地释放任何已分配的块。例如C语言调用malloc函数来分配一个块,并调用free函数来释放一个块;2隐式分配器:要求应用显式地分配块,并要求分配器检测一个已分配块何时不再被程序所使用,那么就释放这个块,隐式分配器也叫做垃圾收集器。
void *malloc(size_t size); 允许程序在运行时请求一块指定大小的内存空间,并返回一个指向该内存块起始位置的指针。被分配的内存块中的内容是未初始化的,即其中的值是不确定的。void *calloc(size_t num_elements, size_t element_size); 作用是分配num_elements * element_size个字节的内存,并返回一个指向分配内存起始位置的指针。该内存块中的每个字节都被初始化为零。void *sbrk(intptr_t incr); 函数通过将内核的brk指针(brk指向当前进程的堆的顶部)增加incr来扩展和收缩堆。如果成功,它就返回brk的旧值,否则,它就返回-1,并将errno设置为ENOMEM。如果incr为零,那么sbrk就返回brk的当前值。用一个为负的incr来调用sbrk是合法的,而且很巧妙,因为返回值(brk的旧值)指向距新堆顶向上abs(incr)字节处(P588)。
(虚拟)内存的分配策略:隐式空闲链表和显式空闲链表,分离的空闲链表(分为简单分离存储(每个大小类的空闲链表包含大小相等的块)、分离适配(分配器维护着一个空闲链表的数组,每个空闲链表是和一个大小类相关联的,并且被组织成某种类型的显式或隐式链表,C标准库中提供的GNU malloc包就是釆用的这种方法)、伙伴系统(是分离适配的一种特例,其中每个大小类都是2的幂))(P604)。
垃圾收集器:是一种动态内存分配器,它自动释放程序不再需要的已分配块。这些块被称为垃圾,在一个支持垃圾收集的系统中,垃圾收集器定期识别垃圾块,并相应地调用 free,将这些块放回到空闲链表中。垃圾收集器将内存视为一张有向可达图,在任何时刻,不可达节点对应于垃圾,是不能被应用再次使用的。垃圾收集器的角色是维护可达图的某种表示,并通过释放不可达节点且将它们返回给空闲链表,来定期地回收它们。
C语言常见与内存有关的错误:1间接引用坏指针,如:错误写出scanf("%d", val)而不是scanf("%d", &val);2读未初始化的内存,如:malloc分配堆时并没有初始化;3允许栈缓冲区溢出;4假设指针和它们指向的对象是相同大小的;5造成错位错误;6引用指针,而不是它所指向的对象,如果不太注意C操作符的优先级和结合性,我们就会错误地操作指针,而不是指针所指向的对象;7误解指针运算;8引用不存在的变量;9引用空闲堆块中的数据;10引起内存泄漏(P610)。
RIO(Robust I/O,健壮的 I/O)包:它会自动为你处理上文中所述的不足值(即read一次没有读取到期望的字节数)。在像网络程序这样容易出现不足值的应用中,RIO 包提供了方便、健壮和高效的 I/O。RIO 提供了两类不同的函数:无缓冲的输入输出函数、带缓冲的输入函数(这些函数内部还是调用read、write函数)(P626)。
int stat(const char *filename, struct stat *buf);和int fstat(int fd, struct stat *buf);函数可以读取文件元数据(即文件相关信息)。DIR *opendir(const char *name);、struct dirent *readdir(DIR *dirp);和int closedir(DIR *dirp);函数用来读取目录信息(P633)。
共享文件:文件描述符表、文件打开表和v-node表三者之间的关系。I/O重定向:Linuxshell提供了I/O重定向操作符,允许用户将磁盘文件和标准输入输出联系起来。
C语言定义了一组高级输入输出函数,称为标准I/O库,为程序员提供了Unix I/O的较高级别的替代。这个库(libc)提供了打开和关闭文件的函数(fopen和fclose)、读和写字节的函数(fread和fwrite)、读和写字符串的函数(fgets和fputs),以及复杂的格式化的I/O函数(scanf和printf)。标准I/O库将一个打开的文件模型化为一个流。对于程序员而言,一个流就是一个指向FILE类型的结构的指针。每个ANSI C程序开始时都有三个打开的流stdin、stdout和stderr,分别对应于标准输入、标准输出和标准错误。类型为FILE的流是对文件描述符和流缓冲区的抽象,流缓冲区的目的就是使开销较高的Linux I/O系统调用的数量尽可能得小(P638)。
Unix I/O 模型是在操作系统内核中实现的。应用程序可以通过诸如open、close、lseek、read、write和stat这样的函数来访问UnixI/O。较高级别的标准I/O函数都是基于(使用)Unix I/O函数来实现的。在程序中使用这些函数的基本指导原则:只要有可能就使用标准 I/O、不要使用scanf来读二进制文件、对网络套接字的I/O使用Unix I/O(P639)。
每台因特网主机都有本地定义的域名localhost,这个域名总是映射为回送地址127.0.0.1。可以使用hostname命令来确定本地主机的实际域名,可以用Linux的nslookup程序(输入命令nslookup 域名 即可查看对应的IP地址)来探究DNS映射的一些属性,这个程序能展示与某个IP地址对应的域名(P650)。
一个套接字是连接的一个端点,每个套接字都有相应的套接字地址,是由一个因特网地址和一个 16 位的整数端口组成的,用“地址:端口”来表示。从Linux内核的角度来看,一个套接字就是通信的一个端点。从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件。
getaddrinfo函数将主机名、主机地址、服务名和端口号的字符串表示转化成套接字地址结构。getnameinfo函数和getaddrinfo是相反的,将一个套接字地址结构转换成相应的主机和服务名字符串(P660)。
高级的辅助函数int open_clientfd(char *hostname, char *port);建立与服务器的连接,该服务器运行在主机hostname上,并在端口号port上监听连接请求。高级的辅助函数int open_listenfd(char *port); 在服务器创建一个监听描述符,准备好接收连接请求。这两个函数是对getaddrinfo、socket、bind等函数的包装(P661)。
Web客户端和服务器之间的交互用的是一个基于文本的应用级协议,叫做HTT(hypertext Transfer Protocol,超文本传输协议),Web服务器以两种不同的方式向客户端提供内容:1取一个磁盘文件,并将它的内容返回给客户端,磁盘文件称为静态内容,而返回文件给客户端的过程称为服务静态内容;2运行一个可执行文件,并将它的输出返回给客户端。运行时可执行文件产生的输出称为动态内容,而运行程序并返回它的输出到客户端的过程称为服务动态内容。每条由Web服务器返回的内容都是和它管理的某个文件相关联的。这些文件中的每一个都有一个唯一的名字,叫做URL,可执行文件的URL可以在文件名后包括程序参数。“?”字符分隔文件名和参数,而且每个参数都用“&”字符分隔开。URL后缀中的最开始的那个 “/” 不表示Linux的根目录。相反,它表示的是被请求内容类型的主目录。例如,可以将一个服务器配置成这样:所有的静态内容存放在目录/usr/httpd/html下,而所有的动态内容都存放在目录/usr/httpd/cgi-bin下。最小的URL后缀是“/”字符,所有服务器将其扩展为某个默认的主页,例如/index.html。这解释了为什么简单地在浏览器中键入一个域名就可以取出一个网站的主页。浏览器在URL后添加缺失的“/”,并将之传递给服务器,服务器又把“/”扩展到某个默认的文件名(P667)。
现代操作系统提供了三种基本的构造并发程序的方法:1进程(即父子进程)。用这种方法,每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间通信(interprocesscommunication,IPC)机制;2 I/O 多路复用(如select)。在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间;3线程。线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。你可以把线程看成是其他两种方式的混合体,像进程流一样由内核进行调度,而像 I/O。多路复用流一样共享同一个虚拟地址空间(P681)。
对于在父、子进程间共享状态信息,进程有一个非常清晰的模型:共享文件表,但是不共享用户地址空间。进程有独立的地址空间既是优点也是缺点。这样一来,一个进程不可能不小心覆盖另一个进程的虚拟内存,这就消除了许多令人迷惑的错误一一这是一个明显的优点。另一方面,独立的地址空间使得进程共享状态信息变得更加困难。为了共享信息,它们必须使用显式的 IPC(进程间通信)机制。基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和 IPC 的开销很高(P684)。
基于 I/O 多路复用的事件驱动编程的优缺点:事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制。例如,我们可以设想编写一个事件驱动的并发服务器,为某些客户端提供它们需要的服务,而这对于基于进程的并发服务器来说,是很困难的。另一个优点是,一个基于 I/O 多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问该进程的全部地址空间。这使得在流之间共享数据变得很容易。事件驱动设计一个明显的缺点就是编码复杂,基于事件的设计另一个重要的缺点是它们不能充分利用多核处理器(P690)。
线程内存模型:一组并发线程运行在一个进程的上下文中。每个线程都有它自己独立的线程上下文,包括线程 ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。每个线程和其他线程一起共享进程上下文的剩余部分。这包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的,线程也共享相同的打开文件的集合。从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的。另一方面,任何线程都可以访问共享虚拟内存的任意位置。如果某个线程修改了一个内存位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。因此,寄存器是从不共享的,而虚拟内存总是共享的。各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问的。我们说通常而不是总是,是因为不同的线程栈是不对其他线程设防的。所以,如果一个线程以某种方式得到一个指向其他线程栈的指针,那么它就可以读写这个栈的任何部分(P696)。
多线程的C程序中变量根据它们的存储类型被映射到虚拟内存:1全局变量:全局变量是定义在函数之外的变量。在运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用;2本地自动变量:本地自动变量就是定义在函数内部但是没有static属性的变量。在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。即使多个线程执行同一个线程例程时也是如此;3本地静态变量:本地静态变量是定义在函数内部并有static属性的变量。和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例(P697)。
一个函数被称为线程安全的,当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。可重入函数:当它们被多个线程调用时,不会引用任何共享数据。可重入函数集合是线程安全函数的一个真子集。如果所有的函数参数都是传值传递的(即没有指针),并且所有的数据引用都是本地的自动栈变量(即没有引用静态或全局变量),那么函数就是显式可重入的,也就是说,无论它是被如何调用的,都可以断言它是可重入的。如果把假设放宽松一点,允许显式可重入函数中一些参数是引用传递的(即允许它们传递指针),那么我们就得到了一个隐式可重入的函数,也就是说,如果调用线程小心地传递指向非共享数据的指针,那么它是可重入的(P718)。
Unix风格的错误处理函数:像fork、wait这样Unix早起开发出来的函数(以及一些较老的Posix函数)的函数返回值既包括错误代码,也包括有用的结果;Posix风格的错误处理函数:许多较新的Posix函数,例如Pthread函数,只用返回值来表明成功(0)或者失败(非0),任何有用的结果都返回在通过引用传递进来的函数参数中;GAI风格的错误处理(附录A)。
gdb中x/s 0x402400表示查看地址0x402400处存储的以 null 结尾的字符串。
gdb中disas命令用于查看当前执行的程序或者函数的汇编代码。不带参数时,会显示当前执行位置附近的汇编代码;disas
gdb中,step命令会在源代码级别上逐行执行程序,并且如果当前行包含函数调用,会进入到该函数中执行。stepi命令会在汇编级别上逐条执行程序的汇编指令。
在gdb中,layout asm命令会在gdb的显示窗口中打开一个汇编代码视图,其中包含了当前代码的汇编指令列表,以及相应的源代码。在这个视图中,你可以看到每行代码对应的汇编指令,以及它们在内存中的地址。