8 bit(位)=1 byte(字节),32位机器上1word=4byte=32bit,64位机器上1word=8byte=64bit。
数据都是一串位组成的,区分不同数据类型的方式就是通过上下文(context)。
关于c语言的执行过程:
1.预处理器将源文件.c转化成修改后的源文件.i
2.编译器将.i编译成汇编程序.s
3.汇编器将汇编程序转化为可重定位目标程序.o
4.链接器加入printf.o文件,生成可执行目标文件
计算机中的各类存储器读取速度排名(存储量和读取速度反相关):寄存器>L1高速缓存>L2>L3>主存>本地磁盘>云端
操作系统控制硬件:通过抽象中间层
1.进程:对正在运行的程序的抽象。CPU通过上下文切换的交错机制实现并发运行。
2.线程:一个进程可以由多个线程作为执行单元组成。多线程比多进程更易共享数据。
3.虚拟存储器:对主存储器的抽象,每个进程都将其视作一致的存储器,称为虚拟地址空间。
4.文件:存储的字节序列。
5.虚拟机:对整个计算机(包括操作系统、处理器和程序)的抽象。
本章重点:浮点数的表示方式;整数的表示和运算。
机器级程序将存储器视为一个非常大的字节数组,即虚拟存储器。存储器的每个字节都由唯一的数字来标识,称为地址。其集合称为虚拟地址空间。
关于进制转换(二进制、十进制、十六进制):
1.十进制转x进制:不断除x取余,将余数标于一旁;从最后一个商开始向上串联所有余数,即二进制结果。
2.二进制和十六进制互转:十六进制的每位数展开成二进制,即可得到一个四位二进制数,串联即为二进制结果;反之同理。
对于一个字长为w位的机器,虚拟地址的范围为 0 ~ 2 w − 1 0~2^w-1 0~2w−1,最多访问 2 w 2^w 2w个字节。
多字节对象被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。
一个数据的不同有效字节分配到地址有两种方式:
1.小端法:最低有效字节在前,如一个整数的个位,放在最小地址处。
2.大端法:反之。
计算机编码就是一串01组成的二进制数,每一位上都只能是二者之一,这就为直接的数位操作提供了方便。
按位运算:&、|、~、^
~即直接反转01,&实际上有置1的作用,|有置0的作用,^是异或运算,当两者不同时为真。
掩码运算:可以从一个字中选出指定的位,掩去不要的码。
例:0xFF是低位字节掩码,即取出一个字的低位字节(8bit)。
位移运算:对二进制数直接进行操作。
左移运算:向左移动k位,即丢弃最高的k位,并在右端补上k个0 。
右移运算:去掉右端k位,然后
逻辑右移:在左端补上k个0 。
算数右移:在左端补上k个最高位有效值。
无符号整数只能逻辑右移,有符号整数一般用算数右移。位运算的优先级低于四则运算。
64位机器c语言数据类型范围
C语言数据类型 | 最小值 | 最大值 | 字节数 |
---|---|---|---|
char | -128 | 127 | 1 |
unsigned char | 0 | 255 | 1 |
short | -32768 | 32767 | 2 |
unsigned short | 0 | 65535 | 2 |
int | -2147483648 | 2147483647 | 4 |
unsighed int | 0 | 4294967295 | 4 |
long(32位机器上和int一样) | -9223372036854775808 | 9223372036854775807 | 8(32位机器上4字节) |
unsigned long | 0 | 18446744073709551615 | 8(32位机器上4字节) |
long long | -9223372036854775807 | 9223372036854775807 | 8 |
unsigned long long | 0 | 18446744073709551615 | 8 |
float | − 3.4 × 1 0 − 38 -3.4\times10^{-38} −3.4×10−38 | 3.4 × 1 0 − 38 3.4\times10^{-38} 3.4×10−38 | 4 |
double | − 1.7 × 1 0 308 -1.7\times10^{308} −1.7×10308 | 1.7 × 1 0 308 1.7\times10^{308} 1.7×10308 | 8 |
long double | − 1.2 × 1 0 − 4932 -1.2\times10^{-4932} −1.2×10−4932 | 1.2 × 1 0 − 4932 1.2\times10^{-4932} 1.2×10−4932 | 16 |
int转float会损失精度。
C语言强制类型转换,必须先改变大小再改变符号。
无符号编码: B 2 U w ( x ⃗ ) = ∑ i = 0 w − 1 x i 2 i B2U_w(\vec x)=\sum\limits_{i=0}^{w-1}x_i 2^i B2Uw(x)=i=0∑w−1xi2i
补码编码(有符号编码最常用的一种编码):将字的最高有效位解释为负权: B 2 T w ( x ⃗ ) = − x w − 1 2 w − 1 + ∑ i = 0 w − 2 x i 2 i B2T_w(\vec x)=-x_{w-1}2^{w-1}+\sum\limits_{i=0}^{w-2}x_i2^i B2Tw(x)=−xw−12w−1+i=0∑w−2xi2i
补码的范围是不对称的,最小值没有对应的正数,即 ∣ T m i n ∣ = ∣ T m a x ∣ + 1 \mid T_{min}\mid=\mid T_{max}\mid+1 ∣Tmin∣=∣Tmax∣+1
其他有符号编码:
反码: B 2 O w ( x ⃗ ) = − x w − 1 ( 2 w − 1 − 1 ) + ∑ i = 0 w − 2 x i 2 i B2O_w(\vec x)=-x_{w-1}(2^{w-1}-1)+\sum\limits_{i=0}^{w-2}x_i2^i B2Ow(x)=−xw−1(2w−1−1)+i=0∑w−2xi2i
原码: B 2 S w ( x ⃗ ) = ( − 1 ) x w − 1 ∑ i = 0 w − 2 x i 2 i B2S_w(\vec x)=(-1)^{x_w-1}\sum\limits_{i=0}^{w-2}x_i2^i B2Sw(x)=(−1)xw−1i=0∑w−2xi2i
无符号整数和有符号整数的转换:
原则:转换前后位值不变,只是解释位值的方式改变了。
补码编码转无符号编码: T 2 U w ( x ⃗ ) = x w − 1 2 w + x = { x + 2 w , x < 0 x , x ≥ 0 T2U_w(\vec x)=x_{w-1}2^w+x=\begin{cases}x+2^w,&x<0\\x,&x\geq 0\end{cases} T2Uw(x)=xw−12w+x={ x+2w,x,x<0x≥0
无符号编码转补码编码: U 2 T w ( x ⃗ ) = − x w − 1 2 w + x = { x , x < 2 w − 1 x − 2 w , x ≥ 2 w − 1 U2T_w(\vec x)=-x_{w-1}2^w+x=\begin{cases}x,&x<2^{w-1}\\x-2^w,&x\geq2^{w-1}\end{cases} U2Tw(x)=−xw−12w+x={ x,x−2w,x<2w−1x≥2w−1
无符号转补码出现负数时的一个简单计算方法是取反后加1。即 x ( T ) = − ( ∼ x ( U ) + 1 ) x_{(T)}=-(\sim x_{(U)}+1) x(T)=−(∼x(U)+1)
大部分机器默认使用补码,要想声明一个无符号常量,需要加上后缀字符’U‘。
C语言中,若一个运算中一个运算数是有符号而另一个是无符号的,会隐式地将有符号转为无符号数并且假设这两个数都是非负的进行计算。
扩展数字:保持数值不变的同时,增加整数的字长。
无符号数扩展:(零扩展)直接在开头添加若干个0。
有符号数扩展:(符号扩展)在开头添加若干个最高有效位。
截断数字:直接丢弃高位的若干位,可能会改变数字的值。对于将一个w位数字转化为k位数字的情况:
无符号数的截断结果: B 2 U k ( [ x k − 1 , x k − 2 , . . . , x 0 ] ) = B 2 U w ( [ x w − 1 , x w − 2 , . . . , x 0 ] ) m o d 2 k B2U_k([x_{k-1},x_{k-2},...,x_0])= B2U_w([x_{w-1},x_{w-2},...,x_0])mod2^k B2Uk([xk−1,xk−2,...,x0])=B2Uw([xw−1,xw−2,...,x0])mod2k
有符号数的截断结果: B 2 U k ( [ x k − 1 , x k = 2 , . . . , x 0 ] ) = U 2 T w ( B 2 U w ( [ x k − 1 , x k = 2 , . . . , x 0 ] ) ) m o d 2 k B2U_k([x_{k-1},x_{k=2},...,x_0])=U2T_w(B2U_w([x_{k-1},x_{k=2},...,x_0]))mod2^k B2Uk([xk−1,xk=2,...,x0])=U2Tw(B2Uw([xk−1,xk=2,...,x0]))mod2k
二进制数的除法和十进制数有些类似,下面举一个例子:
二进制模运算的一个常用公式: a ( 2 ) % 2 n = a ( 2 ) & ( 2 n − 1 ) a_{(2)}\% 2^n=a_{(2)}\& (2^n-1) a(2)%2n=a(2)&(2n−1)
无符号和补码的加法、乘法在位模式上的操作是一样的,都是先计算再通过截断防止溢出。
无符号加法: x + w u y = { x + y , x + y < 2 w x + y − 2 w , 2 w ≤ x + y 2 w + 1 x+ _w^uy=\begin{cases}x+y&,x+y<2^w\\x+y-2^w&,2^w\leq x+y\ 2^{w+1}\end{cases} x+wuy={ x+yx+y−2w,x+y<2w,2w≤x+y 2w+1
这种加法叫模数加法,结果就相当于减去一个2^w。这里的取模本质上就是上面提到的截断:无符号加法和可能导致位数+1,因此截断最高的一位。因此模数加法是abel群(是封闭的,有单位元,每个元素都有逆元,满足交换律)。
模数加法有单位元0,且每一个元素都有一个加法逆元 − w u x = { x , x = 0 2 w − x , x > 0 -^u_wx=\begin{cases}x,&x=0\\2^w-x,&x>0\end{cases} −wux={ x,2w−x,x=0x>0
补码加法: x + w t y = { x + y − 2 w , 2 w − 1 ≤ x + y x + y , − 2 w − 1 ≤ x + y < 2 w − 1 x + y + 2 w , x + y < − 2 w − 1 x+ _w^ty=\begin{cases}x+y-2^w,&2^{w-1}\leq x+y\\x+y,&-2^{w-1}\leq x+y<2^{w-1}\\x+y+2^w,&x+y<-2^{w-1}\end{cases} x+wty=⎩⎪⎨⎪⎧x+y−2w,x+y,x+y+2w,2w−1≤x+y−2w−1≤x+y<2w−1x+y<−2w−1
补码的非: − w t x = { − 2 w − 1 , x = − 2 w − 1 − x , x > − 2 w − 1 -^t_wx=\begin{cases}-2^{w-1},&x=-2^{w-1}\\-x,&x>-2^{w-1}\end{cases} −wtx={ −2w−1,−x,x=−2w−1x>−2w−1
乘法:
乘以常数:由于机器上乘法指令耗时比加法和位级运算多一个数量级,所以机器内部将机器内部将其用加法和位级运算代替。
这个过程其实就是左移k位。基于上面的思路,编译器会把常数表示成2的幂的和差再参与乘法,这样就将常数乘法转化为加法和位级运算。
不幸的是,除法不能从2的幂推广到任意常数。
二进制小数:类比十进制小数,不难得出小数位代表的是当前位上的数乘以2的负幂。
这样的定点表示法不能有效地表示很大的数字。
十进制小数转二进制小数的方法:将十进制小数不断乘2,每一次的积的整数位作为一个小数位(不是1就是0),不断向后延伸直至小数部分为0或位数足够。
IEEE浮点表示:一种类似“科学计数法”的表示二进制小数的方法:格式为 V = ( − 1 ) s × M × 2 E V=(-1)^s\times M\times2^E V=(−1)s×M×2E
在64位机器中,s、exp、frac字段分别为1位、11位、52位。
依照阶码字段exp的情况,可以将浮点编码分成三种情况:
规格化值的尾数设计成1+f,可以额外获得一个精度位;将非规格化值的阶码值设计成1-Bias而不是-Bias,就可以实现最大非规格化数到最小规格化数的平滑转变。
舍入方法:浮点数取整的方法,IEEE定义了四种方式:
舍入方式 | 说明 |
---|---|
向偶数舍入 | 将数字向上或向下舍入,使结果的最低有效位是偶数 |
向零舍入 | 正数向下,负数向上,使得舍入后绝对值小于原数字 |
向下舍入 | 舍入后小于原数字 |
向上舍入 | 舍入后大于原数字 |
若以“舍入到最近”为前提,则应该先考虑最接近的舍入结果,在居中情况下再考虑方向。
将偶数舍入法应用在二进制小数之中,只有这种情况下才有效:这个值是上取和下取结果的中间值,即舍入位置的后面是10000……;否则,见0下取,见1上取。
浮点数加法不具有结合性和分配性,但是满足单调性:对于任意a,b,x,除了NaN,都有 x + a ≥ x + b x+a\geq x+b x+a≥x+b
浮点数乘法不不具有交换性、结合性、分配性。但是浮点乘法满足单调性:
a > b ∧ c ≥ 0 ⇒ a ∗ c ≥ b ∗ c a ≥ b ∧ c ≤ 0 ⇒ a ∗ c ≤ b ∗ c a ≠ N a N ⇒ a ∗ a ≥ 0 a>b\land c\geq 0\Rightarrow a*c\geq b*c\\a\geq b\land c\leq0\Rightarrow a*c\leq b*c\\a\neq NaN\Rightarrow a*a\geq 0 a>b∧c≥0⇒a∗c≥b∗ca≥b∧c≤0⇒a∗c≤b∗ca=NaN⇒a∗a≥0
本章重点:c程序的机器级代码;异质数据结构。
计算机执行机器代码,程序员编写高级语言代码,二者之间有一过渡即汇编代码。汇编代码是机器代码的文本表示,方便人类读取。以下主要基于两种机器语言:Intel IA 32和x86-64 。主要的篇幅将用于介绍Intel IA 32,再拓展至x86-64 。主要讨论GCC在Linux上编译c代码的情形。
Linux使用了平坦寻址方式,使程序员将整个存储空间看作一个大的字节数组。
机器级代码:计算机系统使用抽象来隐藏细节,两种抽象尤其重要。
指令集体系结构(ISA):定义处理器状态、指令的格式,以及每条指令对状态的影响。将程序的行为描述成好像每条指令按顺序执行。
虚拟地址空间:提供的存储器看上去是一个非常大的字节数组。
Intel IA 32中不被c语言包括的处理器状态:
程序计数器(称为PC,用%eip表示):指示将要执行下一条指令在存储器中的地址。
整数寄存器:包含8个命名的位置,分别存储32位的值。
条件码寄存器保存最近执行的算术或逻辑指令的状态信息,实现控制或数据流中的条件变化。
Intel用术语“字”表示16位数据类型,32位数据为“双字”,64位数据为“四字”。
汇编指令末尾用于表示数据大小的字母:
c语言数据类型 | 汇编代码后缀 |
---|---|
char | b |
short | w |
int | l |
long int | l |
char* | l |
float | s |
double | l |
long double | t |
一个IA 32中央处理单元包含一组8个存储32位位值的寄存器,用于存储整数和指针。他们的名字都以%e开头,但各不相同。其中前四个寄存器的两个低位字节都有独立命名。
寄存器是CPU暂存指令、数据和地址的电脑存储器。寄存器的存贮容量有限,读写速度非常快。在计算机体系结构里,寄存器存储了在已知时间点所作计算的中间结果,通过快速地访问数据来加速计算机程序的运行。
在平坦寻址中,对寄存器特殊性的需求已经极大降低,多数情况下前6个寄存器都可以看作通用。
寄存器名字(32bit) | 字节3、4(16bit) | 字节2(8bit) | 字节1(8bit) | 寄存器类型 |
---|---|---|---|---|
%eax |
%ax |
%ah |
%al |
调用者保存寄存器 |
%ecx |
%cx |
%ch |
%cl |
调用者保存寄存器 |
%edx |
%dx |
%dh |
%dl |
调用者保存寄存器 |
%ebx |
%bx |
%bh |
%bl |
被调用者保存寄存器 |
%esi |
%si |
被调用者保存寄存器 | ||
%edi |
%di |
被调用者保存寄存器 | ||
%esp |
%sp |
只有根据栈管理的标准惯例才能修改 | 栈顶指针 | |
%ebp |
%bp |
只有根据栈管理的标准惯例才能修改 | 帧指针 |
操作数指示符:指示出一个操作中要引用的源数据值,以及放置结果的目标位置。来源可以是常数、寄存器或存储器,目标位置可以是寄存器或存储器。操作数的格式有以下几种:
立即数:即常数值。‘$’后面跟一个32位以内的数值。
寄存器:表示某个寄存器的内容。用Ea表示任意寄存器a,用引用R[Ea]来表示它的值(将寄存器集合看作一个数组R,用寄存器标识符作为索引)。
存储器引用:根据计算出的地址访问存储器位置。因为存储器被视为一个很大的数组,用符号Mb[Addr]表示对存储在存储器中从地址Addr开始的b个字节值的引用(常省略下标b)
下表展示了多种不同的寻址模式:
类型 | 格式 | 操作数值 | 备注 |
---|---|---|---|
立即数 | $Imm | Imm | 立即数寻址 |
寄存器 | Ea | R[Ea] | 将寄存器集合视为数组R,Ea表示任意一个寄存器 |
存储器 | Imm | M[Imm} | 将存储器视为很大的数组M,放入地址常数表绝对寻址 |
存储器 | (Ea) | M[R[Ea]] | 间接寻址 |
存储器 | Imm(Eb) | M[Imm+R[Eb] | (基址+偏移量)寻址 |
存储器 | (Eb,Ei) | M[R[Eb]+R[Ei] | 变址寻址 |
存储器 | Imm(Eb,Ei) | M[Imm+R[Eb]+R[Ei] | 变址寻址 |
存储器 | (,Ei,s) | M[R[Ei]•s] | s为地址转换比例,比例变址寻址 |
存储器 | Imm(,Ei,s) | M[Imm+R[Ei]•s] | 比例变址寻址 |
存储器 | (Eb,Ei,s) | M[R[Eb]+R[Ei]•s] | 比例变址寻址 |
寄存器 | Imm(Eb,Ei,s) | M[Imm+R[Eb]+R[Ei]•s] | 比例变址寻 |
数据传送指令:将数据从一个位置复制到另一个位置的指令是最频繁使用的指令。操作数表示的通用性使一条数据传送指令即可完成。不同的指令被分成三类:
指令 | 效果 | 描述 |
---|---|---|
MOV S,D |
S–>D | 传送 |
movb |
传送字节 | |
movw |
传送字 | |
movl |
传送双字 | |
MOVS S,D |
S(符号拓展)->D | 传送符号扩展的字节 |
movsbw |
将做了符号拓展的字节传送到字 | |
movsbl |
将做了符号拓展的字节传送到双字 | |
movswl |
将做了符号扩展的字传送到双字 | |
MOVZ S,D |
S(零扩展)->D | 传送零扩展的字节 |
movzbw |
将做了零扩展的字节传送到字 | |
movzbl |
将做了零扩展的字节传送到双字 | |
movzwl |
将做了零扩展的字传送到双字 | |
pushl S | R[%esp]<-R[%esp]-4; M[R[%esp]]<-S | 将双字压栈 |
popl D | D<-M[R[%esp]]; R[%esp]<-R[%esp]+4 | 将双字出栈 |
数据传送指令的比较:
指令名称 | 操作效果 |
---|---|
movb |
仅改变目标字节,不影响其他三个字节 |
movsbl |
改变目标字节后,将其他三个字节根据源字节的最高位设为全1或全0 |
movzbl |
改变目标字节后,将其他三个字节设为全0 |
整数运算和逻辑操作:加载有效地址,一元操作,二元操作,移位。
指令 | 效果 | 类别 |
---|---|---|
leal S,D |
D<-&S | 加载有效地址 |
INC D |
D<-D+1 | 一元操作符 |
DEC D |
D<-D-1 | |
NEG D |
D<- -D | |
NOT D |
D<- ~D | |
ADD S,D |
D<- D+S | 二元操作符 |
SUB S,D |
D<- D-S | |
IMUL S,D |
D<-D*S | |
XOR S,D |
D<-D^S(异或) | |
OR S,D |
D<-D|S | |
AND S,D |
D<-D&S | |
SAL k,D |
D<-D<移位 |
|
SHL k.D |
D<-D<移位量可以是一个立即数,也可以放在单字节寄存器元素%cl |
|
SAR k,D |
D<-D>>(A)k(算数右移) | |
SHR k,D |
D<-D>>(L)k(逻辑右移) |
leal的作用是加载有效地址,但是更常见的用法是当%edx的值为x时,leal 7(%edx,%edx,5),%eax是把%eax的值设置为5x+7。
汇编和C语言实现整数操作的方式会有不同。例如:
c: z*=48; assembly: leal (%eax,%eax,2) sall$4,%eax
把一个常数乘法拆分成小乘法和移位操作。
特殊的算术操作
指令 | 效果 | 描述 | 说明 |
---|---|---|---|
imull S |
R[%edx]:R[%eax]<-S•R[%eax] | 有符号全64位乘法 | 要求一个参数在寄存器%eax内,另一个作为指令的源操作数给出,结果分拆后,高32位装入%edx,低32位装入%eax。 |
mull S |
R[%edx]:R[%eax]<-S•R[%eax] | 无符号全64位乘法 | 计算结果的高32位放在%edx,低32位放在%eax。 |
cltd |
R[%edx]:R[%eax]<-SignExtended(R[%eax]) | 转为四字 | 将%eax符号扩展到%edx |
idivl S |
R[%edx]<-R[%edx]:R[%eax]mod S;R[%eax]<-R[%edx]:R[%eax]÷S | 有符号除法 | 除数作为操作数给出,将商存储在寄存器%eax,余数存储在%edx |
divl S |
R[%edx]<-R[%edx]:R[%eax]mod S;R[%eax]<-R[%edx]:R[%eax]÷S | 无符号除法 | 除数作为操作数给出,将商存储在寄存器%eax,余数存储在%edx |
对于imull:作为一元操作符时才体现符号,作为二元操作符时并不能体现符号。
机器代码控制执行顺序的基本机制:测试数据值,根据测试结果改变控制流或数据流。
CPU维护了一组单个位的条件码寄存器,常用的条件码:
CF
:进位标志。最近的操作使最高位产生了进位,可以用来检查无符号操作数的溢出。
ZF
:零标志。最近的操作得出的结果为0。
SF
:符号标志。最近的操作得到的结果为负数。
OF
:溢出标志。最近的操作导致一个补码溢出(正溢出或负溢出)。
除了leal指令不改变任何条件码(因为它是进行地址计算的),所有整数运算指令都会设置条件码。例如XOR将进位和溢出标志设置为0;移位操作将进位标志设置为最后一个被移出的位,溢出标志设置为0;INC和DEC设置溢出和零标志,不改变进位标志。
有两类指令只设置条件码不改变其他寄存器的值:比较和测试。
指令 | 基于 | 对字节 | 对字 | 对双字 | 说明 |
---|---|---|---|---|---|
CMP S2,S1 |
S1-S2 | cmpb | cmpw | cmpl | 如果两个操作数相等,将零标志设置成1,根据其他的标识可以确定大小关系。 |
TEST S2,S1 |
S1&S2 | testb | testw | testl | 如果结果是0,将零标志设置成1,根据其他的标识可以确定具体。 |
事实上,在改变条件码方面,
- CMP和SUB的功能是一样的:当两个操作数相等时,把零标志设置为1;
- TEST和AND的行为是一样的:两个操作数相等,用于检查操作数的正负;其中一个操作数是掩码,用于指示哪些位应该被测试。
访问条件码:一般的指令不会直接读取条件码,一般条件码的作用有以下三点:
第一种方式调用一套SET指令,这些指令的不同后缀指明了它们所考虑的条件码的组合。
指令 | 同义名 | 效果 | 设置条件 |
---|---|---|---|
sete D |
setz | D<–ZF |
相等(设置为0) |
setne D |
setnz | D<–~ZF |
不等 |
sets D |
D<–SF |
负数 | |
setns D |
D<–~SF |
非负数 | |
setg D |
setnle | D<–~(SF^OF)&~ZF |
大于(有符号) |
setge D |
setnl | D<–~(SF^OF) |
大于等于(有符号) |
setl D |
setnge | D<–SF^OF |
小于(有符号) |
setle D |
setng | D<–(SF^OF)|ZF |
小于等于(有符号) |
seta D |
setnbe | D<–CF&ZF |
超过(无符号) |
setae D |
setnb | D<–~CF |
超过或相等(无符号) |
setb D |
setnae | D<–CF |
低于(无符号) |
setbe D |
setna | D<–CF|ZF |
低于或相等(无符号) |
SET的典型行为是:执行比较指令,根据计算t=a-b的结果设置条件码。例如:计算C语言a
;a is in %edx,b is in %eax cmpl %eax,%edx setl %al %al是%eax的低位字节,这里设置为0或1 movzbl %al,%eax 将%eax的高24位清零
第二种方式调用一套jump指令,跳转到标记为label的目的地。
指令 | 同义名 | 跳转条件 | 描述 |
---|---|---|---|
jmp Label |
1 |
直接跳转 | |
jmp *Operand |
1 |
间接跳转(operand指的是一个存储位置) | |
je Label |
jz | ZF |
相等(0) |
jne Label |
jnz | ~ZF |
不相等 |
js Label |
SF |
负数 | |
jns Label |
~SF |
非负数 | |
jg Label |
jnle | ~(SF^OF)&~ZF |
大于(有符号) |
jge Label |
jnl | ~(SF^OF) |
大于或等于(有符号) |
jl Label |
jnge | SF^OF |
小于(有符号) |
jle Label |
jng | (SF^OF)|ZF |
小于或等于(有符号) |
ja Label |
jnbe | ~CF&~ZF |
超过(无符号) |
jae Label |
jnb | ~CF |
超过或相等(无符号) |
jb Label |
jnae | CF |
低于(无符号) |
jbe Label |
jna | CF|ZF |
低于或相等(无符号) |
比较指令和条件跳转指令往往联合使用:对于
cmpq source,destination ja .L2
可以理解为“当
destination
超过source
”时跳转,即“后比前”。跳转指令编码一般是PC相对的,即目标指令的地址,是由跳转指令的下一条指令的地址再加上一个地址偏移量得到的(这表明此时的程序计数器的值是下一条指令的地址)。地址偏移量是跳转指令的机器编码的最高的那个字节,所代表的补码数。
利用这种跳转机制,可以实现C语言的许多控制结构。
条件分支:比较操作数(做出判断)——设置条件码——调用跳转指令——到达程序尾执行栈完成代码。
注意:跳转条件和if分支条件往往逻辑上是相反的。
循环:
逆向工程循环的最大障碍是找到程序值和寄存器之间的映射关系。有些时候这种映射关系很直接,但是很多时候GCC会为了优化代码而自顾自地引入新值,或是为了减少寄存器的使用率而试图将多个值映射到同一个寄存器。
数据的条件转移:是控制的条件转移的代替方法,通过流水线获得高性能,采用精密的分支预测逻辑试图猜测每条跳转指令是否会执行。
确定分支预测错误的惩罚:
如果没有预测错误,执行代码的时间是T1,预测错误的处罚是T2。预测错误的概率为p,则执行代码的平均时间为T3=T1+pT2;p=0.5时平均时间为T4,那么可以求得预测错误惩罚为T2=2(T4-T1)。
条件转移就是计算出所有可能的值,再调用一套条件传送指令CMOV,根据条件传送特定的一个。关于这种处理机制的优势何在,还需要后续的知识来加以说明。
指令 | 同义名 | 传送条件 | 描述 |
---|---|---|---|
cmove S,R |
cmovz | ZF |
相等(0) |
cmovne S,R |
cmovnz | ~ZF |
不相等 |
cmovs S,R |
SF |
负数 | |
cmovns S,R |
~SF |
非负数 | |
cmovg S,R |
cmovnle | ~(SF^OF)&~ZF |
大于(有符号) |
cmovge S,R |
cmovnl | ~(SF^OF) |
大于或等于(有符号) |
cmovl S,R |
cmovnge | SF^OF |
小于(有符号) |
cmovle S,R |
cmovng | (SF^OF)|ZF |
小于或等于(有符号) |
cmova S,R |
cmovnbe | ~CF&~ZF |
超过(无符号) |
cmovas S,R |
cmovnb | ~CF |
超过或相等(无符号) |
cmovb S,R |
cmovnae | CF |
低于(无符号) |
cmovbe S,R |
cmovna | CF|ZF |
低于或相等(无符号) |
switch语句的实现:GCC会根据具体的条件规模确定策略。
当条件数较少(少于3个),编译器视为简单的条件分支,参见条件分支部分内容。
条件数较多(多于4个),编译器将建立一个跳转表。跳转表就是一种查找结构(数组),把case的值转换成索引,查找要去的目标地址(数组元素)。跳转的一般步骤:
switch2:
addq $1,%rdi;补齐,说明最小标号是-1
cmpq $8,%rdi;处理默认情况,此处说明大于8-1=7的都是默认,最大标号是7
ja .L2 ;说明.L2是默认位置
jmp *.L4(,%rdi,8);.L4是跳转表,其中可以看出哪些标号不存在。
条件数多且标号不连续时,编译器还会将之拆分为小表,这里不予讨论。
%esp
:栈指针,值为当前栈顶位置。对%esp的操作一般发生在:push(-4),pop(+4),申请栈内存。
%ebp
:帧指针,值为当前栈帧的头位置。
%eip
:程序计数器。
会修改栈内值的操作有:push/pop/修改被调用者保存寄存器。
一个过程调用即将数据和控制从代码的一部分传递到另一部分,为过程变量分配空间并在结束时释放这些空间。典型的场景就是函数调用。
机器用栈传递过程参数、存储返回信息、保存寄存器信息用于恢复以及本地存储。为单个过程分配的那部分栈称为栈帧。栈帧的典型结构为:
提示:
过程调用中对寄存器的使用不仅仅是帧栈指针,还会调用其他6个寄存器进行临时存储。当调用“调用者保存寄存器(%eax、%edx、%ecx)”时,被调用者可以覆盖这些寄存器而不用担心破坏调用者的数据;当调用“被调用者保存寄存器(%ebx、%esi、%edi)”时,被调用者必须在覆盖这些寄存器之前先把值入栈保存,并在返回前恢复,因为调用者或更早的调用者可能今后用到这些值。
栈总是向低地址方向增长,因此pushl指令就是将栈指针%esp减小,而popl就是增加栈指针。
上面的栈可以理解为“函数栈”,每次调用函数时就会有一个函数帧入栈,调用结束出栈。一帧代表一个函数,更早的帧意味着更早的调用者,也即函数嵌套的更前者。
%ebp存储当前帧的帧首地址,而每一帧的帧首都存储着上一帧的帧首地址。这样巧妙的连环结构保证了每一级函数都能找到上一级函数的所在地。
过程调用和返回指令:
指令 | 描述 |
---|---|
call Label |
直接调用:把返回地址入栈,再跳转到过程起始处、 |
call *Operand |
间接调用 |
leave |
为返回准备栈 |
ret |
从过程调用中返回 |
过程调用的关键节点
caller()
调用过程:先将被调用函数的参数入栈,再将下一条指令的地址入栈作为返回地址。
注意传入参数和返回地址都属于调用者栈帧。
mov ...
call func_add
foo()
被调用过程:将caller()
时的%ebp
值入栈,然后将此时的%esp
值赋给%ebp
,这样固定的过程使得%ebp
总是指向old ebp
的位置。之后将被调用者保存寄存器入栈保存。之后在栈上保存局部变量。
pushl %ebp
movl %esp,%ebp
pushl %ebx
...
foo()
结束调用:
leave
ret
leave
相当于mov ebp,esp
和pop ebp
,ret
弹出返回地址并将其赋给eip
递归调用:递归调用实际上就是call这个函数自身。逐层的调用情况见下图:
对于数据类型T和整型常数N,声明 { T A[N] } 后产生如下效果:在存储器中分配一个L*N字节的连续区域(L是数据类型T的大小,单位是字节);标识符A是数组开头的指针,这个指针的值是起始位置a的地址。
嵌套数组(高维数组):仍然是以线性形式存储。以二维数组举例,可以视为每个元素都是等长一维数组的一个数组。多个中括号指示不同“维度”的下标。从第一个中括号依次往下是更“高维”的下标。
对于已声明的e二维数组D [R] [C] ,D [i] [j] 的存储器地址为:d+L(C*i+j)
GCC对定长多维数组的优化:对数组的每一维配备一个指针,对每一层分别进行访问(即不同的偏移量和不同的起始位置)。
GCC对变长数组的优化:当下标中有未知数n时,不能用加法和移位来代替乘法,必须用乘法指令伸展n倍。在一些循环的场景中,编译器会进行类似定长数组的优化。
结构struct:声明创建一个数组类型,将可能不同类型的对象聚合到一个对象中。结构的各个组成部分用名字来引用。类似数组,结构的所有组成成分都放在存储器的一段连续区域中,而指向结构的指针就是结构第一个字节的地址。编译器会维护每个结构类型的信息,即每个字段的字节偏移。
结构的各个字段的选取完全是在编译时处理的,机器代码不包含关于字段声明或字段名字的信息。
联合union:规避C语言的类型系统,允许以多种类型来引用一个对象(即不同字段引用相同的存储器模块)。对于字段较少的数据结构,这样的联合带来的节省很小;联合还可以用于访问相同位的不同数据类型,即用一种数据类型来存储,再用另一种类型来访问它。
机器代码缺乏类型信息,它只是在初始位置一定偏移的地方,确定了一个位模式。
数据对齐:计算机对基本数据类型的合法地址做出限制,要求地址必须是某个值K(通常是2、4、6、8)的倍数,以简化形成处理器和存储器系统之间接口的硬件设计。
Linux的对齐策略是,2字节数据类型的地址必须是2的倍数,更大数据类型的地址必须是4的倍数。
windows的对齐策略是,任何K字节基本对象的地址必须是K的倍数。特别地,double和long long地地址必须是8的倍数。
IA32的一个惯例是,确保每个帧栈的长度都是16字节的整数倍。
数据类型:指针的大小是8个字节,long的大小也是8个字节。
寄存器的升级:每个寄存器扩容为64位。
操作符:有些许的改变,但是可以辨识出差不多的意思。
更严格的对齐规则:对于任何需要K字节的数据类型,它的起始地址必须是K的倍数。
栈调用:有诸多不同,下面稍加列举:
本章重点:功能单元的性能和数据流优化。
现代编译器运用复杂精细的算法确定一个程序中计算的是什么值,以及它们是如何使用的。以此利用一些机会简化表达式,例如在几个不同的地方使用同一个计算,将第一个给定计算执行的次数等等。但是这种优化是有局限性的:
引入度量标准**“每元素的周期数”(CPE)**作为一种表示程序性能并指导我们改进代码的方法。
借由“向量合并运算”说明优化手段
GCC的-o1优化:
void combine1(vec_ptr v,data_t *dest){
long int i;
*dest=IDENT;
for(i=0;i<vec_length(v);i++){
data_t val;
get_vex_element(v,i,&val);
*dest=*dest OP val;
}
}
代码移动提高循环效率:
void combine2(vec_ptr v,data_t *dest){
long int i;
long int length=vec_length(v);
*dest=IDENT;
for(i=0;i<length;i++){
data_t val;
get_vec_element(v,i,&val);
*dest=*dest OP val;
}
}
识别出vec_length(v)这个计算要执行多次并且计算结果不会改变,因而将这个计算移到循环前面。
很多时候这样的代码会有隐藏的渐进低效率,即数据量很大时性能占用飙升。
减少过程调用
void combine3(vec_ptr v,data_t *dest){
long int i;
long int length = vec_length(v);
data_t *data=get_vec_start(v);
*dest=IDENT;
for(i=0;i<length;i++){
*data=*dest OP data[i];
}
}
结果表明这样的性能提升十分有限,也就是说combine2的反复检查边界对性能的影响很小。
消除不必要的存储器引用
void combine4(vec_ptr v,data_t *dest){
long int i;
long int length=vec_length(v);
data_t *data=get_vec_start(v);
data_t acc =IDENT;
for(i=0;i<length;i++){
acc=acc OP data[i];
}
*dest=acc;
}
combine3将合并运算的值积累在*dest所指的位置。在一次迭代中,程序读出dest指向的值,计算得到结果后再写回dest所指的位置。这之中显然有无用的读写,因为每次计算的结果就是下一次要读的值。方法就是像combine4一样设置一个积累器acc,只有在循环结束后才放回dest位置。因此每次迭代从两次读和一次写变成一次读。
用T(old)/T(new)表示相对性能,即性能提升了多少倍。
每个运算都是由两个周期计数值刻画:一个是延迟,表示一个功能从发起到结束需要的总时间;一个是发射时间,表示一个操作平均到每个连续的同类型运算上的时钟周期数。随着字长的增加和运算复杂程度提升,延迟会增加;发射时间体现了功能单元的流水线化程度,发射时间为1的功能单元称为完全流水线化的。
表达发射时间的另一种方法是指明这个功能单元的最大吞吐量,即为发射时间的倒数,表明了单位时间内能处理的最多的操作数。一个完全流水线化的功能单元吞吐量最大,每个时钟周期一个运算。
延迟界限给出了任何必须按照严格顺序完成合并运算的函数所需要的最小CPE值,吞吐量界限给出了CPE的最小界限。
我们使用程序的数据流展现不同操作之间的数据相关是如何限制执行顺序,从而形成关键路径的。实测可知测量值和除了整数求和以外的延迟界限一样,这说明函数的性能正是被整数乘法和浮点数操作限制。
如何理解流水线对延迟界限的突破:以浮点数乘法为例,一次浮点数乘法需要三个周期的运算。
周期Ⅰ 周期Ⅱ 周期Ⅲ 周期Ⅳ 阶段1 运算一(1) 运算二(1) 运算三(1) 运算四(1) 阶段2 运算一(2) 运算二(2) 运算三(2) 阶段3 运算一(3) 运算二(3) 通过把一个运算的3个阶段拆分且紧凑化各个运算,我们可以近似认为一个周期处理了一个运算,也就是完全流水线化。
在形成循环的代码片段中,将寄存器分为四类:
寄存器类型 | 特点 |
---|---|
只读寄存器 | 只用作源值,可以作为数据,也可以用来计算存储器地址。 |
只写寄存器 | 用作数据传送操作。 |
局部寄存器 | 在循环内部被修改和使用,迭代之间不相关。 |
循环寄存器 | 既作为源值又作为目的,一次迭代产生的值会在另一次迭代中用到。 |
**循环寄存器之间的操作链决定了限制性能的数据相关。**以combine4为例,它的循环执行部分的汇编代码如下:
.L488:
mulss (%rax,%rdx,4),%xmm0
addq $1,%rdx
cmpq %rdx,%rbp
jg .L488
只保留循环寄存器的链,可以看到迭代之间有两个数据相关:%xmm0(acc)和%rdx(i)
由于单精度乘法的延迟为4周期,整数加法的延迟为1周期,因此mul的链需要4n个周期执行,而add的链需要n个周期执行。浮点乘法器是制约来源。
其他性能因素:测试值可能比预测值慢,例如这里的整数加法。这说明,数据流表示的关键路径提供的只是程序需要周期数的下界,还有其他因素可能限制性能。包括可用的功能单元的数量、任何一步中功能单元之间能够传递的数据值的数量以及其他操作供应数据跟不上等等。
**总结:**combine4的关键路径长L*n,是由对acc的连续更新造成的,这条路径将CPE限制为最多L。我们的目的就是通过调整操作的结构,增强并行性,使得唯一的限制变成吞吐量界限。
循环展开就是那空间换时间的操作,通过增加每次迭代的元数个数来减少循环的迭代次数。它减少了不直接有助于程序结果的操作。
单变量k×1展开不能降低程序的CPE。
使用多个积累变量:对于可结合和可交换的合并运算来说,可以通过将一组合并运算分割成两个或更多的部分,并在最后合并结果来提高性能。
重新结合变换:用括号改变元素的结合顺序。例如:
acc=acc * data[i] * data[i+1]
acc=acc * (data[i] * data[i+1])
后者打破了数据相关,是一种 2 × 1 a 2\times 1a 2×1a的展开方式。事实上 k × 1 a k\times 1a k×1a循环展开并重新结合的效果类似 k × k k\times k k×k的展开,接近吞吐量界限。
加载操作和存储操作(即读和写)都可以在完全流水线化的环境中工作。
内存产生数据相关的情况就是加载操作从最近的一个存储位置读取时(即先写后读)产生的写/读相关。
本章重点:高速缓存的结构和命中。
静态RAM:双稳态存储器,只要有电就会永远保存其值。
动态RAM:电容充电存储器,对干扰很敏感,电压扰乱后不可恢复。
传统DRAM:一个DRAM芯片中的单元被分成d个超单元,每个超单元有 ω \omega ω个DRAM单元,故一个 d × ω d\times\omega d×ω的DRAM总共存储了 d ω d\omega dω位的信息。超单元被组织成r行c列的矩形阵列,这里rc=d。故每个超单元有形如(i,j)的地址。DRAM芯片被连接到内存控制器电路,内存控制器依次传入行和列的信息i和j,DRAM传出相应内容作为回应。
内存模块:一个内存模块封装了多个DRAM芯片(例如8个),当传入行列信息(i,j)时,每个DRAM输出它的(i,j)单元的一个字节,8个字节组成一个字,返回给内存控制器。
增强DRAM
非易失性存储器:大体上都被称为只读存储器(尽管有些可以写),ROM。具体有PROM,EPROM,闪存等。固态硬盘就是基于闪存的磁盘驱动器。存储在ROM中的程序称为固件。
主存:CPU和主存之间的数据传送(读和写)都是通过一系列总线事务完成的。基本结构如下:
C P U ( 的 总 线 接 口 ) ⟷ 系 统 总 线 I / O 桥 ⟷ 内 存 总 线 主 存 CPU(的总线接口)\stackrel{系统总线}\longleftrightarrow I/O桥\stackrel{内存总线}\longleftrightarrow 主存 CPU(的总线接口)⟷系统总线I/O桥⟷内存总线主存
磁盘存储:每个磁盘表面是一组称为磁道的同心圆,每个磁道被换分为一组扇区,每个扇区包含数量相等的数据位,编码在扇区的磁性材料中。
磁 盘 容 量 = 每 扇 区 的 字 节 数 × 每 磁 道 的 平 均 扇 区 数 × 每 表 面 的 磁 道 数 × 每 盘 的 表 面 数 × 每 磁 盘 的 盘 片 数 磁盘容量=每扇区的字节数\times 每磁道的平均扇区数\times每表面的磁道数\times每盘的表面数\times每磁盘的盘片数 磁盘容量=每扇区的字节数×每磁道的平均扇区数×每表面的磁道数×每盘的表面数×每磁盘的盘片数
磁盘操作:读/写头定位于盘面的任一指定磁道上,称为寻道。对扇区的访问时间主要由三部分组成:
寻道时间 T m a x − s e e k T_{max-seek} Tmax−seek
旋转时间 T a v g − r o t a t i o n = 1 2 T m a x − r o t a t i o n = 1 R P M T_{avg-rotation}=\frac{1}{2}T_{max-rotation}=\frac{1}{RPM} Tavg−rotation=21Tmax−rotation=RPM1
传送时间 T a v g − t r a n s f e r = 1 R P M × 1 每 磁 道 的 平 均 扇 区 数 T_{avg-transfer}=\frac{1}{RPM}\times\frac{1}{每磁道的平均扇区数} Tavg−transfer=RPM1×每磁道的平均扇区数1
其中寻道时间和旋转视角为主,且大致相同。
固态硬盘:一个闪存由B个块的序列组成,每个块由P页组成。数据是以页为单位读写的,只有一页所属的块整个被擦除(全设为1)之后,才能写这一页。反复写会磨损闪存块,所以闪存翻译层试图以平均磨损来延长每个块的寿命。
编写良好的计算机程序倾向于引用邻近于最近引用过的数据或者反复引用,这称为局部性原理。前者称为空间局部性,后者称为时间局部性。
在程序数据引用和取指令两个环节都会涉及局部性:
程序数据引用:在一个连续向量中每k个元素访问一次,称为具有步长为k的引用模式。k=1时称为顺序引用模式,具有良好的空间局部性。随着步长的增加,空间局部性下降,
取指令:循环的指令按照连续的内存顺序存储,故具有良好的空间局部性;因为被执行多次,所以具有良好的时间局部性。
存储器层次结构的中心思想一般存储设备越大,访问速度越慢。所以将第k层相对更快更小的存储设备作为第k+1层的更大更慢的存储设备的缓存。
存储器被划分成连续的数据对象组,称为块。
缓存命中:当程序需要第k+1层的某个数据对象d时,首先在当前存储在第k层的一个块中寻找d。如果d刚好缓存在第k层,则称为缓存命中。
缓存不命中:上述过程如果没有在第k层找到d,即为缓存不命中。此时第k层的缓存从第k+1层缓存中取出包含d的那个块放入第k层的对应位置。如果第k层缓存已经满了,就会覆盖显存的一个块,称为替换或驱逐。不命中的情况有:
注:读写都会造成缓存,原因都是想避免越级直接访问低级存储器。
为了弥补CPU和主存之间访问速度的巨大差距,在寄存器文件和主存之间插入一个小的SRAM高速缓存,称为L1高速缓存(一级缓存)。
参数 | 关联 | 含义 |
---|---|---|
t | t就是标记位的位数,也即高速缓存行中的标记位 | 标记这一行是否包含这个字 |
s | s就是组号,被解释为一个无符号整数 | 在众多高速缓存组中确定组 |
b | b就是有效存储字节中的个数,故每一个b都指示了一个块偏移 | 找到对应缓存行以后通过偏移找到对应字节 |
根据行数E的不同,高速缓存被分为不同的类。每组只有一行的高速缓存称为直接映射高速缓存。
高速缓存确定一个请求是否命中并且抽取被请求的字的过程分为三步:组选择、行匹配、字抽取。组选择和行匹配的逻辑在上面的表格和下图中已经说明清楚。有效位和标记位都成功匹配称为高速缓存命中。命中后按照块偏移找到对应字节,否则进行行替换。
若组中都是有效行,就必须驱逐一行。在直接映射高速缓存中,就是用新取出的行替换当前的行。
冲突不命中的情况:当程序访问大小为2的幂的数组时,直接映射高速缓存通常会发生冲突不命中。例如对于以下程序:
float dotprod(float x[8],float y[8]){
float sum=0.0;
int i;
for(i=0;i<8;i++)
sum+=x[i]*y[i];
return 0;
}
即使高速缓存有足够的空间,x[i]和y[i]还是被映射到了相同的组。每次x或y的加载都会覆盖原先加载的另一个y或x的缓存,导致高速缓存反复加载和驱逐重复的高速缓存组。解决的方法很简单,那就是在x的末尾填充B个字节,这样x[i]和y[i]刚好错开一个块,就避免了反复横跳。
放松E=1的限制,每一组可以有多行,一个 1 < E < C B 1
E = C B E=\frac{C}{B} E=BC时称为全相联高速缓存,即所有行在同一组中。此时不需要组索引,地址只分配了一个标记和一个块偏移。
对于程序的核心循环部分,要尽量减少缓存的不命中率。如果一个高速缓存的块大小为B字节,则一个步长为k个字的引用模式将在每次循环迭代中不命中
min ( 1 , 字 长 × k B ) \min(1,\frac{字长\times k}{B}) min(1,B字长×k)
次,也就是不命中率。当B=16,字长为4字节,k=1时,不命中率为1/4。
编写高速缓存友好代码的两个原则:
本章重点:符号解析策略。
链接的目的是实现分离编译,将一个大的应用程序分成更小更好管理的模块。
静态链接:通过符号解析和重定位将一系列可重定位目标文件转为一个完全链接的、可以加载和运行的可执行目标文件。
可重定位目标文件:
ELF头 | 生成该文件的系统的字的大小和字节顺序、解释信息 |
---|---|
.text | 已编译程序的机器代码 |
.rodata | 程序中的只读数据 |
.data | 已经初始化的全局和静态变量(局部变量在栈里) |
.bss | 占位符,表示未初始化或初始化为0的全局或静态变量 |
.symtab | 程序中定义和引用的函数、全局变量符号表 |
.rel.text | .text节中位置的列表,标记了需要重定位的地方 |
.rel.data | .data需要重定位的地方 |
.debug | 调试符号表 |
.line | 行号与机器指令的映射 |
.strtab | |
节头部表 | 描述目标文件 |
可执行目标文件:
ELF头 | |
---|---|
段头部表 | 将连续的文件节映射到运行时内存段 |
.init | ①(只读内存段)代码段 |
.text | ① |
.rodata | ① |
.data | ②(读/写内存段)数据段 |
.bss | ② |
.symtab | ③不加载到内存的符号表和调试信息 |
.debug | ③ |
.line | ③ |
.strtab | ③ |
节头部表 | ③ |
.symtab符号表中包含全局符号、局部静态符号。例如全局变量(包括其他模块定义的)、局部静态变量、函数。
符号表里的符号可能来自的节:.text
、.data
、COMMON
。
COMMON
和.bss
的区别:COMMON
是伪节,存放未初始化的全局变量;.bss
是未初始化的静态变量、初始化为0的全局变量和静态变量。
链接器解析符号就是将每个符号引用与输入的可重定位目标文件中的符号表中的一个确定的符号关系关联起来。当编译器遇到一个不属于当前模块定义的符号时,会假设该符号是其他模块定义的,结果是生成一个链接器符号表条目,并把它交给链接器处理。
解析多重定义的全局符号:函数和已经初始化的全局变量是强符号,未初始化的全局变量是弱符号。解析时遵循下面的规则:
一个源文件内部的变量有以下四种:
#include
static int a;//全局静态变量
int b; //全局非静态变量
int main(){
static int c;//局部静态变量
int d;//局部非静态变量
}
全局&局部:表明变量的作用域,全局变量的作用域是源程序,局部变量的作用域是所在函数。当函数内部出现和全局变量同名的局部变量时,优先选择局部变量。
static
的作用:static
可以在同层级的其他部分间隐藏一个变量或函数:静态全局变量不能被其他模块引用;static
将数据存储在静态数据区,因此在两次调用同一函数时,其内部的static
变量的值是连续的。
重定位就是合并输入模块,并且为每个符号分配运行时地址。重定位分为两步:
可重定位目标文件中的重定位条目:用于告诉链接器如何修改这些符号引用。例如:
r.offset=0xa
r.symbol=array
r.type=R_X86_64_32
r.append=0
链接器重定位符号引用的两种情况:
foreach section s{
foreach relocation entry r{
refptr=s+r.offset;
//重定位PC相对引用:PC(当前指令的下一条指令地址)+偏移量——>PC,重定位引用偏移量。
if(r.type==R_X86_64_PC32){
refaddr=ADDR(s)+r.offset;
*refptr=(unsigned)(ADDR(r.symbol)+r.append-refaddr);
}
//重定位绝对引用:直接修改引用。
if(r.type==R_X86_64_32)
*refptr=(unsigned)(ADDR(r.symbol)+r.append);
}
}
所有的编译系统都支持将所有目标模块打包成为一个文件,称为静态库,可以作为链接器的输入。当链接器构造一个输出的可执行文件时,只复制静态库中被引用的目标模块。
在Linux系统中,静态库以一种称为存档的特殊文件格式存放在磁盘中,文件名后缀是.a
。
链接器使用静态库解析引用的过程:按照命令行上从左往右的顺序,扫描可重定位目标文件和存档文件,此过程中链接器维护3个集合:、
各个库一般被放在命令行结尾。对于由引用关系的两个库,定义库应该在引用库后面;当出现交叉引用时,可以重复放置库。
本章重点:多进程、父子进程的特点。
系统需要能够对状态的变化作出反应,即使这些变化不是被内部程序变量捕获,也不和程序执行相关。现代系统通过使控制流发生突变来对这些情况作出反应,这些突变成为异常控制流。
异常就是控制流的突变,系统中可能的每种类型的异常都分配了一个唯一的非负整数的异常号。
操作系统启动时分配和初始化一张成为异常表的跳转表。当处理器检测到发生了一个事件,并确定了相应的异常号k,就通过异常表的条目k,转到相应的处理程序。异常处理程序运行在内核模式下, 这意味着它对所有资源都有完全的访问权限。
类别 | 原因 | 异步/同步 | 返回行为 |
---|---|---|---|
中断 | 来自I/O设备的信号 | 异步 | 总是返回到下一条指令 |
陷阱 | 有意的异常 | 同步 | 总是返回到下一条指令 |
故障 | 潜在可恢复的错误 | 同步 | 可能返回到当前指令 |
终止 | 终止 | 同步 | 不返回 |
异常允许操作系统内核提供进程概念。进程就是一个执行中的程序的实例。进程提供给应用程序关键的抽象:
逻辑控制流:一系列程序计数器PC的值,对应于包含在程序的可执行目标文件中的指令,或是动态链接到程序的共享对象的指令。这个PC值的序列称为逻辑控制流。如下图,三个逻辑流的执行是交替的,看上去就像一个物理控制流被分成了三个逻辑流。
两个逻辑流在时间上重叠,称为并发。并发过程中的交替顺序是无法确定的。
进程轮流执行称为多任务。
私有地址空间:进程为每个程序提供私有地址空间,其他进程不能读写。以下是一个典型的进程地址空间组织结构。
模式切换:处理器用某个控制寄存器中的位模式来设置模式。设置该位后进程运行在内核模式,否则运行在用户模式。用户模式必须通过系统调用接口间接地访问内核代码和数据。
上下文切换:内核为每个进程维护一个上下文。上下文就是内核重新启动一个被抢占的进程所需要的状态,即一系列对象的值。
fork()
父进程可以调用fork()
函数创建子进程。在父进程中,fork()
返回子进程的PID,而子进程中,fork()
返回0。父子进程并发执行,地址空间独立,共享文件。
waitpid()
当一个进程终止时,内核把进程保持在一种已终止的状态中,直到被父进程回收。父进程可以调用waitpid()
函数来等待子进程终止或停止。下面是waitpid()
的函数原型:
pid_t waitpid(pid_t pid, int *statusp, int options)
默认情况下(options=0),waitpid()
会挂起调用它的进程,直到它的等待集合中有一子进程终止。pid
确定等待集合的成员,pid>0表示等待集合是一个单独的子进程,进程ID就是pid;pid=-1,说明等待集合是父进程所有的子进程;statusp指向子进程的状态。
sleep()
一个进程被挂起一段时间
pause
该函数让调用函数休眠,直到收到一个信号。
execve()
加载并运行一个新程序。函数原型如下:
int execve(const char *filename, const char *argv[], const char *envp[])
argv
是参数列表,envp
是环境变量列表。execve()
调用一次,并不返回。execve()
加载了filename,调用启动代码。