文章目录
计算机系统
第一章:计算机系统漫游
1.1 信息就是位+上下文
位:
计算机中数据储存的基本单位,源程序实际上就是由0和1组成的位序列,8位等于一个字节,系统中的所有信息都是由一串比特位来表示的。
上下文:
系统中的信息表达方式都十分类似,所以难以区分。帮助我们区分信息类型的方法就是观察信息所在的上下文(类似语境)不同上下文中的相同含义也会不同,计算机的理解方式也不同。
1.2 程序被其他程序翻译成不同的格式
源程序文件变成可执行文件需要经历四个阶段:预处理阶段 编译阶段 汇编阶段 链接阶段。
1:预处理阶段
预处理器根据以字符# 开头的命令,修改原始的C程序。
程序扩展名由 **.c ** -> .i。
2:编译阶段
汇编语言为不同语言的不同编译器提供了通用的输出语言。
程序扩展名由 .i -> .s
3:汇编阶段
汇编器将.s翻译成机器语言指令,并把这些指令打包成一种可重定位目标程序,将结果保存在.o中。
程序扩展名由 .s -> .o
4:链接阶段
目标文件中出现的函数(例如:printf函数ut)它们都存在于一个单独编译好的文件中,链接器就负责将函数所在文件与目标文件合并在一起,处理之后便得到一个可执行目标文件。
程序扩展名由 .o -> 可执行文件
1.3 了解编译系统工作的益处
1. 优化程序性能,提高程序运行速度
2. 理解链接时出现的错误,便于及时解决问题,节省时间
3. 避免安全漏洞
1.4 处理器读并解释存储在内存中的指令
1. 总线
贯穿整个系统的一组电子管道(总线通常被设计成传送定长的字节块)
2. I/O设备
系统与外界世界的联系通道(每个I/O设备都通过一个适配器或控制器与I/O总线相连)
3. 主存
临时存储设备,由一组动态随机存取存储器芯片组成
4. 处理器
中央处理单元,简称处理器。是解释(或执行)存储在内存中的指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC都指向内存中的某条机器指令。
1.5 高速缓存至关重要
一般处理器从寄存器文件中读数据比从主存中读数据几乎快100倍。针对这种处理器与主存之间的差异,我们采用告诉缓存作为暂时的集结区域,可以将常用的一些数据存放在告诉缓存中,使程序的性能提高一个数量级。
1.6 存储设备形成层次结构
1.7 操作系统管理硬件
1. 操作系统的两个基本功能:
1. 防止硬件被失控的应用程序滥用。
2. 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。
2. 操作系统的几个抽象概念:
-
进程
进程是操作系统对一个正在运行的程序的抽象,在一个系统上可以同时运送多个进程,看上去每个进程都在独立地占用硬件,但实际上每个进程的指令是交错执行的。
操作系统保持跟踪进程所需的所有状态信息,也就是上下文。在任何一个时刻,切换进程都要通过转换上下文来实现。
进程之间的转换是通过操作系统内核进行管理的,内核是操作系统代码常驻主存的部分。当应用程序需要操作系统的某些操作时,它就执行一条特殊的系统调用指令,将控制权交给内核。
内核不是一个独立的进程,而是系统管理全部进程所用代码和数据结构的集合。
一个进程实际上可以由多个线程执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。采用多线程的方法可以使程序运行更加高效。
-
虚拟内存
虚拟内存不同于物理内存,它看上去好像每个进程都在独立地使用内存,每个进程看到的内存都是一致的,称为虚拟地址空间。
分区:
程序代码和数据。
堆:当使用malloc和free函数时可以用于动态的分配内存空间。
共享库:用于存放C标准库和数学库这样的共享库的一片区域。
栈:也是一片可以动态扩展的区域。
内核虚拟空间
-
文件
文件是字节序列,每个输入输出设备都可以看成文件。他为应用程序提供了一个统一的视图,来看待系统中可能含有的所有各式的输入输出设备。
1.8 系统之间利用网络通信
系统不是一个孤立的硬件和软件的集合体,世界上现代系统通常通过网络和其他系统连接到一起。网络可以看成一个I/O设备,可以将信息从一台设备复制到另一台设备。
1.9 并发和并行
顺序:上一个开始执行的任务完成后,当前任务才能开始执行并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行
并发:无论上一个开始执行的任务是否完成,当前任务都可以开始执行
串行:有一个任务执行单元,从物理上就只能一个任务、一个任务地执行
并行:有多个任务执行单元,从物理上就可以多个任务一起执行
1.10 计算机系统中的抽象
- 文件是对I/O设备的抽象
- 虚拟内存是对出现处理器的抽象
- 进程是对一个正在运行的程序的抽象
- 虚拟机是对整个计算机的抽象
第二章:信息的表示和处理
2.1 消息存储
2.1.1 十六进制
一个字节由8位组成。二进制有32个数,十进制转化为二进制很麻烦。所有用十六进制表示。一个十六进制数可以表示4个二进制数。
2.1.2 寻址和字节顺序
多字节对线都被存储为连续的字节序列,对象的地址为所用的字节中最小的地址。
大端法:最高有效字节存储在高地址的方法。
小端法:最高有效字节存储在低地址的方法。
2.2 整数表示
2.2.1 整数的分类
有符号数和无符号数。
2.2.2 C语言中的转换
强制类型转换时,数的位表示不变。
C语言执行一个运算时,一个无符号数和有符号数执行运算,会将有符号数强制转换成无符号数。
2.2.3 扩展一个数字的位表示
无符号数的零扩展:前补0。
补码数的符号扩展:前补符号位。
截断无符号数:一个数x,将其截断为k位的结果为:x‘= x mod 2^k。
截断补码数值:先按无符号数截断,再转为补码形式。
2.3 整数加法
2.3.1 无符号数加法
二进制加法,会溢出。
加法逆元原理:无符号数求反。
2.3.2 补码加法
存在正溢出和负溢出。
对TMin来说,自己是自己加法的逆,其他任何数都有-x作为加法的逆。
对任何数x,都有-x=~x+1。
2.4 浮点数
2.4.1 IEEE浮点表示
公式:V=(-1)^s * M * 2^E
s符号
M尾数是一个二进制小数
E阶码对浮点数加权,权重是2的E次幂
单精度浮点数(float)是1位s,8位阶码,23位尾码。
双精度浮点数(double)是1位s,11位阶码,52位尾码。
规范化的值:
E=e-Bias。Bias是对n位E来说是2^(n-1)-1。float的Bias是127,double是1023。
当二进制小数点再最高有效位的左边是,会补上一个1,这也叫做隐含的以1开头表示。
非规格化数的值:
当阶码域全0时,所表示的数是非规格化形式,这是E=1-Bias,M=f是小数字段的值,不包含隐含的1。
非规格化数表示的是那些非常接近于0的数。
特殊值:
无穷和NaN。
非规格化数和规格化数的平滑转变,归功于非规格化数的E=1-Bias。
2.4.2 舍入
四舍六入五向偶。
第三章:程序的机器级表示
3.1 数据格式
Inter称呼16位数字类型为“字(word)”32位是双字,64位是四字。
3.2 访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器,用来存储数据类型和指针。
3.2.1 操作数指示符
操作数分为:
- 立即数——表示常数值
- 寄存器——表示某个寄存器的内容
- 内存引用——它会根据计算出来的地址访问某个内存位置。
内存引用寻址有很多方式:
3.2.2 数据传送指令
MOV类:
MOV的五种可能组合:
movl $0x4050,%eax 立即数->寄存器 4 bytes
movw %bp,%sp 寄存器->寄存器 2 bytes
movb (%rdi,%rcx),%al 内存->寄存器 1 bytes
movb $17,(%rsp) 立即数->内存 1 bytes
movq %rax,-12(%rbp) 寄存器->内存 ` 8 bytes
零扩展和符号扩展:
3.2.3 压入和弹出栈数据
栈:后进先出,通过push将数据压入栈中,通过pop将数据弹出。
栈向下增长,栈顶元素的地址是所有栈元素地址中最低的。
3.3 算术和逻辑操作
3.3.1 加载有效地址
lea:加载有效地址。
即将有效地址写入目的操作数。
可以为内存引入指针。
mov和lea:
lea eax,[eax+2*eax]的效果是eax = eax + eax * 2
mov edx [ebp+16]的效果是edx=*(ebp+16)
3.3.2 一元和二元操作
第二组中的操作是一元操作,只有一个操作数,既是源操作数又是目的操作数,既可以是寄存器也可以是内存位置。
第三组中的操作是二元操作,第二个操作数既是源操作数又是目的操作数。第一个操作数可以是立即数也可以是寄存器和内存,第二个操作数可以是寄存器也可以是内存位置。
3.3.3 移位操作
逻辑左移和算术左移,没有区别,都是右边补0。
逻辑右移:左边补0。
算术右移:左边补符号位。
3.3.4 特殊的算术操作
3.4 控制
3.4.1 条件码
除了整数寄存器,CPU还维护者一组单个位的条件码寄存器
他们描述了最近的逻辑或算术操作的属性
3.4.2 SET指令
3.4.3 跳转指令
3.4.4 实现条件分支
-
条件控制
当条件满足时,程序按照一种方式进行,不满足时切换。
int diff(int a,int b) { if(a<b) return b-a; else return a-b; }
-
条件传送
同时计算程序的两种结果,再根据程序是否满足选择其中的一种。
int diff(int a,int b) { int x1=a-b; int x2=b-a; if(a<b) return x2; else return x1; }
3.4.5 循环
C语言代码
do{}while()语句
long fact_do(long n){
long result = 1;
do {
result *= n;
n = n - 1;
} while (n > 1);
return result;
}
其对应的汇编代码
fact_do:
movl $1,%eax
.L2:
imulq %rdi,%rax
subq $1,%rdi
cmpq $1,%rdi
jg .L2
rep:ret
C语言代码while()语句
long fact_while(long n){
long result = 1;
while (n > 1){
result *= n;
n = n - 1;
}
return result;
}
对应的汇编代码
fact_while:
movl $1,%eax
jmp ,L5
.L6:
imulq %rdi,%rax
subq $1,%rdi
.L5:
cmp $1,%rdi
jg .L6
rep:ret
3.4.6 switch语句
C语言描述
void switch(long x,long n,long* dest){
long val = x;
switch(n){
case 100:
val *= 13;
break;
case 102:
val += 10;
case 103:
val += 11;
case 104:
case 106:
val *= val;
break;
dafault:
val = 0;
}
*dest = val;
}
汇编语言描述
#void switch(long x,long n,long* dest)
#x in %rdi y in %rsi dest in %rdx
switch:
subq $100,%rsi #index = n-100
cmpq $6 ,%rsi #compare index:6
ja .L8 #if > ,goto loc_def
jmp *.L4(,%rsi,8) #跳转到&.L4+%rsi*8(一个指针占8个字节)
.L3:
leaq (%rdi,%rdi,2),%rax # 3*x
leaq (%rdi,%rax,4),%rdi # 13*x
jmp .L2
.L5:
addq $10 ,%rdi #val=val+10
.L6:
addq $11 ,%rdi #val=val+11
jmp .L2
.L7:
imulq %rdi,%rdi #val=val*val
jmp ,L2
.L8:
movl $0 ,%edi #val=0
.L2:
movq %rdi,(%rdx) #*dest=val
ret
3.5 进程
进程是软件中一种很重要的抽象。为了讨论方便,假设过程P调用过程Q,Q执行后返回P。这些动作包括以下几个机制:
- 传递控制。进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面的那条指令地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
- 分配和释放内存。在开始时,Q可能需要为局部变量分配空间空间,而在返回前,又必须释放这些存储空间。
3.5.1 运行时栈
- 压栈: 函数参数压栈,返回地址压栈
- 跳转: 跳转到函数所在代码处执行
- 执行: 执行函数代码
- 返回: 平衡堆栈,找出之前的返回地址,跳转回之前的调用点之后,完成函数调用
下面我们看一下函数调用指令call:
0x210000 call swap
0x210005 mov ecx,eax
我们可以把它理解为2个指令:
push 0x210005 //call指令的下一条指令地址作为本次函数调用的返回地址压栈
jmp swap //然后使用jmp指令修改指令指针寄存器EIP,使cpu执行swap函数的指令代码
Leave等价于:
movl %ebp %esp
popl %ebp
Ret指令的内部操作是:
1. 栈顶字单元出栈,其值赋给IP寄存器。
实例:
#include<stdio.h>
int add(int x,int y)
{
int z;
z=x+y;
if(z>=10)
return z;
add(x,y);
}
int main(){
int a=2,b=3;
int c=add(a,b);
return 0;
}
汇编:
#add函数
0x0000555555555129 <+0>: endbr64
0x000055555555512d <+4>: push %rbp
0x000055555555512e <+5>: mov %rsp,%rbp
0x0000555555555131 <+8>: sub $0x20,%rsp
0x0000555555555135 <+12>: mov %edi,-0x14(%rbp)
0x0000555555555138 <+15>: mov %esi,-0x18(%rbp)
0x000055555555513b <+18>: mov -0x14(%rbp),%edx
0x000055555555513e <+21>: mov -0x18(%rbp),%eax
0x0000555555555141 <+24>: add %edx,%eax
0x0000555555555143 <+26>: mov %eax,-0x4(%rbp)
0x0000555555555146 <+29>: cmpl $0x9,-0x4(%rbp)
0x000055555555514a <+33>: jle 0x555555555151 <add+40>
0x000055555555514c <+35>: mov -0x4(%rbp),%eax
0x000055555555514f <+38>: jmp 0x555555555160 <add+55>
0x0000555555555151 <+40>: mov -0x18(%rbp),%edx
0x0000555555555154 <+43>: mov -0x14(%rbp),%eax
0x0000555555555157 <+46>: mov %edx,%esi
0x0000555555555159 <+48>: mov %eax,%edi
0x000055555555515b <+50>: callq 0x555555555129 <add>
0x0000555555555160 <+55>: leaveq
0x0000555555555161 <+56>: retq
#main函数
0x0000555555555162 <+0>: endbr64
0x0000555555555166 <+4>: push %rbp
0x0000555555555167 <+5>: mov %rsp,%rbp
0x000055555555516a <+8>: sub $0x10,%rsp
0x000055555555516e <+12>: movl $0x2,-0xc(%rbp)
0x0000555555555175 <+19>: movl $0x3,-0x8(%rbp)
0x000055555555517c <+26>: mov -0x8(%rbp),%edx
0x000055555555517f <+29>: mov -0xc(%rbp),%eax
0x0000555555555182 <+32>: mov %edx,%esi
0x0000555555555184 <+34>: mov %eax,%edi
0x0000555555555186 <+36>: callq 0x555555555129 <add>
0x000055555555518b <+41>: mov %eax,-0x4(%rbp)
0x000055555555518e <+44>: mov $0x0,%eax
0x0000555555555193 <+49>: leaveq
0x0000555555555194 <+50>: retq
3.6数组分配与访问
3.6.1 基本原则
movl (%rdx,%rcx,4), %eax//rcx是i,rdx是首地址,计算的是地址xE+4i的值
3.6.2 指针运算
3.6.3 嵌套数组
假设一个二维数组的一维长度是C,一个元素长度为L
&D[i][j] = xD + L( C*i + j )
汇编将A[i][j]复制到%eax寄存器里:
leaq (%rsi,%rsi,2), %rax //3*i
leaq (%rdi,%rax,4), %rax //xA+12i
movl (%rax,%rdx,4), %eax //M[xA+12i+4j]
一个可变长数组A[n][n]必须在n参数后面,这样函数在遇到这个数组时就可以计算出数组的维度
int var_ele(long n,int A[n][n],long i,long j){
return A[i][j];
}
var_ele:
imulq %rdx, %rdi //n*i
leaq (%rsi,%rdi,4), %rax//xA+4(n*i)
movl (%rax,%rcx,4), %eax//M[XA+4(n*i)+4*j]
ret
3.7 异质的数据结构
3.7.1 结构体
struct rec{
int i;
int j;
int a[2];
int *p;
};
这个结构体包括了4个字段;两个4字节int、一个由两个int元素组成的数组和一个8字节的整型指针,总共24字节。
若要r->i复制给r->j
movl (%rdi), %eax //Get r->i
movl %eax, 4(%rdi) //存储到r->j
3.7.2 数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须必须是某个值K(通常是2、4、8)的倍数。这种对齐限制简化了形成处理器和内存系统之间的硬件设计。
对齐原则:任何K字节的基本对象的地址必须是K的倍数。
如下
编辑器在汇编代码中放入命令**.align 8**,指明了全局数据所需的对齐。保证了后面的数据的起始地址是8的倍数。
第四章:处理器体系结构
4.1.1 程序员可见的状态
- 寄存器RF:共有十五个,去掉了%r15。
- 其中%rsp作为栈指针
- 条件码CC:ZF SF OF 少了 CF
- 程序计数器PC
- 内存DMEM:用虚拟地址引用内存位置,看成一个很大的字节数组
- 状态码Stat:表明程序执行的总体状态 正常运行、异常(ex.读取非法内存地址)
4.1.2 Y86-64指令
- X86-64指令集的子集
- 只含有8字节整数操作(字长64位)
–
- X86-64的movq指令分为了irmovq,rrmovq,mrmovq和rmmovq
- i是立即数immediate,r是内存memory,r是寄存器register
- 一个字母是源操作数,一个字母是目的操作数
- 不允许内存传送到内存,也不允许立即数到内存
–
-
有四个整数操作指令
OPq:addq,subq,andq,xorq
-
只对寄存器数据进行操作
-
会设置条件码
–
- 有7个跳转指令jxx:jmp,jlem,jl,je,jne,jge,jg。
- 根据分支指令的类型和条件代码来设置选择分治
- 分支条件和x86-64相同
–
-
6个条件传送指令:
cmovle,cmovl,comve,comvne,comvge,comvg
–
- call指令会返回地址入栈,然后调到目的地址。ret指令会从这样的调用中返回。
- pushq,popq实现入栈和出栈
- halt指令停滞指令执行,相当于x86-64的hlt指令。执行该指令会导致处理器暂停,并将Stat状态码设置为HLT。
4.1.3 指令编码
首字节区分不同指令。首字节高位表示指令代码,低位表示功能。代码的值从0x0到0xB。功能的值只在同一组指令共享代码时起作用。
15个寄存器都在一个相对应范围在0x0到0xE之间。编号和x86-64相同
有个指令只有一个字节长,比如halt,nop,ret等。
有的指令因为需要操作数,则会更长一些。原因有如下两种:
- 可能附加的寄存器指示符字节。用于指定1~2个寄存器,被称为rA和rB。可以用于源寄存器和目的寄存器或用于地址计算的基址寄存器。
- 有些指令会附加一个8字节常数constant word。
- 可以作为irmovq的立即数数据
- 可以作为rmmovq和mrmovq的地址指示符的偏移量。
- 分支指令和调用指令的目的地址。(绝对地址,而不用PC的相对寻址)
- 采用小端法编码。指令按照反汇编格式书写的时候,这些字节就以相反的顺序出现。
让我们尝试生成下述汇编指令的字节码。
rmmovq %rsp,0x123456789abcd(%rdx)
4 0 4 2 cd ab 89 67 45 23 01 00
4.1.4 Y86-64异常
状态码Stat有四种可能值
- 程序执行正常
- 处理器执行了halt指令
- 处理器试图从一个非法内存地址都或者写
- 遇到非法的指令代码
第五章:优化程序性能
5.1 优化程序编译器的能力与局限性
void twiddle1(long *xp,long *yp)
{
*xp+=*yp;
*xp+=*yp;
}
void twiddle2(long *xp,long *yp)
{
*xp+=2* *yp;
}
二者功能几乎相同,对比发现
- 对于函数1,进行了六次读写操作,而对于函数2只进行了三次独写操作
- 那么编译器会不会将函数1优化成函数2呢?
答案是不会,
如果xp与yp指向相同的地址,就会出现问题
5.2 表示程序性能
引入计量标准:每元素周期数(CPE)
4GHz,时钟周期为时钟频率的倒数(0.25ns)
用时钟周期表示,计量值指示了运行指令的个数
void psum1(float a[],float p[],long n)
{
long i;
p[0]=a[0];
for(i=1;i<n;i++)
p[i]=p[i-1]+a[i];
}
void psum2(float a[],float p[],long n)
{
long i;
p[0]=a[0];
for(i=1;i<n-1;i+=2)
{
float mid_val=p[i-1]+a[i];
p[i] = mid_val;
p[i+1]=mid_val+a[i+1];
}
if(i<n)
p[i]=p[i+1]+a[i];
}
循环展开法:每次迭代的元素个数不同
5.3 消除循环的低效率
void combine1(vec_ptr v,data_t *dest)
{
long i;
*dest=IDENT;
for(i=0;i<vec_length(v);i++)
{
data_t val;
get_vec_element(v,i,&val);
*dest=*dest OP val;
}
}
void combine2(vec_ptr v,data_t *dest)
{
long i;
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;
}
}
计算长度的函数只要调用一次就好,所以在开始时进行一次,然后存储下来就好。减少结果重复的不必要运算。
编译器会尝试执行代码移动,但它有时候难以发现函数的副作用,因而编译器的优化会很谨慎。
5.4 减少不必要的内存引用
data_t *get_vec_start(vec_ptr v)
{
return v->data;
}
void combine3(vec_ptr v, data_t *dest)
{
long i;
long length=vec_length(v);
data_t *data=get_vec_start(v);
*dest=IDENT;
for(i=0;i<length;i++){
*dest = *dest OP data[i];
}
}//消除了循环中的函数调用,直接使用数组
void combine4(vec_ptr v, data_t *dest)
{
long i;
long length=vec_length();
data_t *data=get_vec_start(v);
data_t acc=IDENT;
for(i=0;i<length;i++){
acc=acc OP data[i];
}
*dest=acc;
}//指针运算:寄存器与内存之间多次读写,非常耗时
//临时变量用寄存器保存累计值,效率显著提高
5.7 理解现代处理器
- 生成代码应该考虑到对目标处理器进行调整
- 现代微处理器了不起的功绩之一是:使得多条指令可以并行的运行,同时又呈现出一种简单顺序执行的表象
5.7.1 整体操作
-
寄存器重命名
指令执行发生例外或转移指令猜测错误而取消后面的指令时可以保证现场的精确
思路:当一条指令写一个结果寄存器时不直接写道这个结果寄存器,而是先写道一个中间寄存器过渡一下,当这个指令提交的时候再写到结果寄存器中
-
寄存器的更新
仅当指令退役时才发生。指令退役的两种情况:
- 指令操作完成,且所有分支预测被认为正确
- 某个分支预测错误,清空指令,放弃运算结果
5.7.2 功能单元的性能
延迟:完成运算需要的总时间
发射:两个同类型运算间隔时间
发射时间为1:流水线化的功能单元(如浮点加法包含三个阶段,三个周期延迟,处理指数值,对小数相加,进行舍入)各阶段完成一部分运算,对于不同操作数,只需要完成自己的部分。
吞吐量:发射时间倒数
利用延迟界限和吞吐量界限描述程序最大性能
- 延迟界限:代码中数据相关限制了处理器利用指令级并行的能力,给出了任何必须按照严格顺序完成合并运算的函数所需的最小PCE值
- 吞吐量界限: 刻画了处理器功能单元的原始计算能力,这个界限是程序性能的终极限制,给出了CPE的最小界限
5.7.3 处理器操作抽象模型
.L25:
vmulsd (%rdx), %xmm0, %xmm0
addq $8 ,%rdx
cmpq %rax, %rdx
jne ,L25
数据流图:
某些操作产生值可以不对应于寄存器,如load,cmp
乘法路径为关键路径,延迟更长
5.8 循环展开
通过赞加每次迭代计算的元素的数量,减少循环的迭代次数
从两个反面提高程序性能:
- 减少了不直接有助于程序结构的操作的数量,例如循环索引计算和条件分支
- 提供了一些方法,可以进一步变化代码,减少整个计算中关键路径上的操作数量
void combine5(vec_ptr v, data_t *dest)
{
long i;
long length=vec_length(v);
long limit=length-1;
data_t *data=get_vec_start(v);
data_t acc=IDENT;
for(i=0;i<limit;i+=2){
acc=(acc OP data[i]) OP data[i+1];
}
for(;i<length;i++){
acc=acc OP data[i];
}
*dest=acc;
}
5.9 提高并行性
目前为止,代码还没有能够利用上功能单元在加法和乘法”流水线“的优势
原因:需要等待前一个周期算出结果才能开始下一个周期的运算,会有一个合并操作的延迟
5.9.1 多个累计变量
void combine6(vec_ptr v, data_t *dest)
{
long i;
long length=vec_length(v);
long limit=length-1;
data_t *data=get_vec_start(v);
data_t acc0=IDENT;
data_t acc1=IDENT;
for(i=0;i<limit;i+=2){
acc0=acc0 OP data[i];
acc1=acc1 OP data[i+1];
}
for(;i<length;i++){
acc0=acc0 OP data[i];
}
*dest=acc0 OP acc1;
}
两个累计变量,一个算奇数乘积,一个算偶数乘积,最后将两者乘起来。
同时,还运用了循环展开,称为2*2的循环展开
5.9.2 重新结合变换
//combine7
acc=(acc OP data[i]) OP data[i+1];
//变换为
acc=acc OP (data[i] OP data[i+1]);
对应关键路径如下:
每次迭代内的第一个乘法不需要等待上一个迭代的累计值就可以运行
5.10 代码优化小结
5.11 一些限制因素
关键路径指明执行程序所需时间基本下限
若程序中有某条完整的数据链,链上所有延迟之和为T,那么至少需要T个周期才能执行完。
吞吐量指明了程序执行时间下限
N个某种运算的计算,C个执行单位,发射时间为I,则吞吐量下限为:N*I/C
5.11.1 寄存器溢出
并行度过大,超过寄存器数量,这时候某些值就会被放到内存中去(通常在运行堆栈中),这时候由于存取过程,使用时间反而更长。
5.11.2 分支预测和预测错误处罚
分支预测并且预测错误,处理器必须丢弃所有投机执行的结果,在正确的位置重新开始取指令
通用原则:
-
不要过分关心可预测的分支
现代处理器的分支预测逻辑非常善于辨别不同的分支指令有规律的模式和长期的趋势,只要不是分支错误,通常不需要太多关注,例如合并函数结束循环的分支通常会被预测为选择分支,只在最后一次成为运算错误被处罚。
-
书写适合用条件传送实现的代码
使用功能式的风格实现函数,例如三元运算。
命令式的程序风格:
//正常程序
void minmax1(long a[],long b[],long n)
{
long i;
for(i=0;i<n;i++){
if(a[i]>b[i]){
long t=a[i];
a[i]=b[i];
b[i]=t;
}
}
}
//命令式
void minmax1(long a[],long b[],long n)
{
long i;
for(i=0;i<n;i++){
long min=a[i]<b[i]?a[i]:b[i];
long max=a[i]<b[i]?b[i]:a[i];
a[i]=min;
b[i]=max;
}
}
5.12 理解内存性能
5.12.1 加载的性能
所有的现代处理器都包含一个或多个高速缓存(cache)存储器,以对这样少量的存储器提供快速的访问。
我们知道,现代处理器有专门的功能单元来执行加载和存储操作,这些单元有内部的缓冲区来保存未完成的内存操作请求集合。
typedef struct ELE{
struct ELE *next;
long data;
}list_ele, *list_ptr;
long list_len(list_ptr ls){
long len=0;
while(ls){
len++;
ls=ls->next;
}
return len;
}
//链表函数。其性能受限于加载操作的延迟
直到上一次迭代完成,下一次迭代才能开始。函数的CPE完全由加载时间的延迟来决定。
5.12.2 存储的性能
我们分析了大部分对存储器的引用都是加载操作的函数,也就是从存储位置读到寄存器中。
与之对应的是存储操作:它将一个寄存器写到存储器。这个操作的性能,尤其是与加载操作的相互关系,包括了一些很细微的问题。
写/读相关:一个存储器读的结果依赖于一个最近的存储器写。写/读相关导致处理速度下降。
存储单元包含一个存储缓冲区,他包含已经被发射到存储单元而又没完成的存储操作的地址和数据,这里的完成包括更新数据高速缓存。提供这样一个缓冲区,使得一系列存储操作不必等待每个操作都更新高速缓存就能够执行。
当一个加载操作发生时,它必须检查存储缓冲区中的条目,看看有没有地址相匹配。如果有地址相匹配(在写的字节与在读的字节有相同的地址)它就取出相应的数据条目作为加载操作的结果。
.L3:
movq %rax, (%rsi)
movq (%rdi), %rax
addq $1 , %rax
subq $1 , %rdx
jne .L3
s _addr操作的地址计算必须在s_data操作之前。此外 movq (%rdi), %rax 需要的load操作必须检查所有未完成的存储操作的地址,这个操作与s_addr操作形成了数据相关。
虚弧线表示这个相关是有条件的:
- 当两个操作地址不同,那么两个操作可以独立运行;
- 当两个操作地址相同,load操作必须等到s_data操作完成并将结果放到存储缓冲区中。
5.13 性能提高技术(算法)
- 高级设计。为遇到的问题选择合适的算法和数据结构。避免使用那些会渐进的产生糟糕性能的算法或编码技术。
- 基本编码原则。避免限制优化的因素,这样编译器就能产生高效的代码。
- 消除连续的函数调用。在可能时,将计算移到循环外。考虑有选择的妥协程序的模块性来获得更大的效率。
- 消除不必要的存储器引用。引入临时变量来保存中间结果。
- 低级优化
- 展开循环,降低开销,并且使得进一步的优化成为可能
- 通过使用例如多个累计变量和重新组合等技术,找到提高指令级并行。
- 用功能的风格重写条件操作,使得编译采用条件数据传送。
5.14 确认和消除性能瓶颈(结合硬件)
在处理大程序的时候很难定位需要优化的地方,就需要使用到代码剖析程序。
程序剖析可以在现实的基准数据上运行实际程序的同时,进行剖析。
剖析程序GPROF可以确定每个函数花费多少CPU时间。计算每个函数被调用的次数。
第六章:存储器层次结构
存储器系统是一个具有不同容量、成本和访问时间的存储设备的层次结构。CPU寄存器保存着最常用的数据。
靠近CPU的小的、快速的高速缓存存储器作为一部分存储在相对慢速的主存储器中数据和指令的缓冲区域。
主存缓存存储在容量较大的、慢速磁盘上的数据,而这些磁盘常常又作为存储在通过网络连接的其他机器的磁盘或磁带上的数据的缓冲区域。
6.1 存储技术
6.1.1 随机访问存储器
分为静态RAM和动态RAM
- 静态:双稳态,只要有电,数据不会变,抗干扰
- 动态:将数据存储为对电容的充电,对干扰非常敏感,周期性地刷新
传统的DRAM
DRAM芯片中的单元被分成d个**超单元**,每个超单元都由w个DRAM单元组成。一个d X w的DRAM总共存储了d*w位信息。超单元被组织成一个r行c列的长方形阵列,这里rc=d。每个超单元有形如(i,j)的地址。
信息通过称为引脚的外部连接器流入和流出芯片。
内存模块:
几种增强的DRAM:
- 快页模式DRAM(FPM DRAM):不舍弃同行其他列数据
- 扩展数据输出DRAM:比FPM DRAM更快(缩短了读取行和列数据的间隔)
- 同步DRAM:CPU发出的读取指令到达内存控制器和到达CPU芯片的时间几乎同步
- 双倍速率同步DRAM(DDR SDRAM):通过提高有效带宽的很小的预取缓冲区的大小来划分的:DDR(2位),DDR2(4位),DDR4(8位)。
- 视频RAM:允许在同一时间被所有硬件访问
非易失性存储器:ROM
- 可编程ROM
- 可擦写ROM
- 闪存ROM
存储在ROM中的程序称为固件(比如计算机主板上的基本输入/输出系统BIOS(Basic Input/output System))
6.1.2 磁盘存储
磁盘构造
磁盘由多个盘片(platter)构成,每个盘片都有两面。盘片表面覆盖着磁性材料用于记录信息,盘片中央有一个旋转主轴控制盘片以固定的旋转速率(rotational rate)旋转,旋转速度是磁盘性能的一个参数,通常为5400~15000转每分。
磁盘的每个盘面是由一组称之为磁道(track)同心圆构成,每个磁道又被划分为多个扇区(sector)。每一个扇区中包含相等的数据位(通常为512字节),数据编码在磁盘的磁性材料中。扇区之间有一些间隙(gap)分隔开,间隙中不存储数据位,间隙存储用来标识扇区的格式化位。
另外还可以用柱面(cylinder)来描述多个盘片驱动器的构造,柱面指的是所有盘片表面上到主轴中心距离相等的磁道集合。比如磁盘有3个盘片,6个面,每个面上距离中心距离相等的磁道称之为一个柱面。
磁盘容量
一个磁盘上可以记录的最大位数称为它的最大容量,或者简称为容量。磁盘容量是由以下技术因素决定的:
- 记录密度:磁道一英寸的段中可以放入的位数。
- 磁道密度:从盘片中心出发半径上一英寸的段内可以有的磁道数。
- 面密度:记录密度与磁道密度的乘积。
假如某个磁盘有5个盘片,每个扇区512字节,每个面20000条磁道,每条磁道平均300个扇区,磁盘容量计算如下:
磁盘容量=512字节x300扇区x20000磁道x2表面x5盘片=30 720 000 000字节=30.72G
磁盘操作
- 寻道时间:读/写头找到磁道的时间
- 旋转时间:从找到磁道开始到读/写头找到扇区的时间
- 传送时间:读/写头读写完整个扇区的时间
磁盘控制器与逻辑磁盘块
一个B个扇区大小的逻辑块序列,编号为0~B-1。磁盘封装中有一个小的硬件、固件设备,称为磁盘控制器,维护着逻辑块号和实际(物理)磁盘扇区之间的映射关系。
当操作系统想要执行一个I/O操作时,例如读一个磁盘扇区的数据到主存,操作系统会发送一个命令到磁盘控制器,让它读某个逻辑块号。控制器上的固件执行一个快速表查找,将一个逻辑块号翻译成一个(盘面,磁道,扇区)的三元组,这个三元组唯一标识了对应的物理扇区。控制器上的硬件会解释这个三元组,将读/写头移动到适当的柱面,等待扇区移动到读/写头下,将读/写头感知到的位放到控制器上的一个小缓冲区中,然后将它们复制到主存中。
连接IO设备
- 通用串行总线
- 图形卡
- 主机总线适配器
访问磁盘:
地址空间有一块IO设备通信用的地址
6.1.3 固态硬盘
固态硬盘(SSD)是一种基于闪存的存储技术。
SSD:读比写更快。
一个闪存由B个块组成,一个块有P页。数据是以页为单位读写的,只有一页所属的块整个背擦除后,才能写这一页。不过一旦一个块被擦除了,块中每一个页都可以不需要再进行擦除就写一次。闪存块会在擦除过程中磨损。
随机访问很快,因为SSD是由半导体存储器构成,没有移动的部件,所以随机访问的时间比旋转磁盘要快,能耗更低。
随机写很慢,有两个原因。首先擦除块需要相对较长的时间。其次,如果写操作试图修改一个包含已经有数据的页p,那么这个块中的其他有用数据都要被复制到一个新的块中,然后才能对这个块重写。
6.1.4 存储技术趋势
- 不同的存储技术有不同价格和性能这种
- 不同存储技术的价格和性能属性以截然不同的速率变化着
- REAM和磁盘的性能滞后于CPU的性能
6.2 局部性
一个编写良好的计算机程序常常具有良好的局部性。也就是,它们倾向于引用邻近于其他最近引用过的数据的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理。
局部性原理有两种:时间局部性和空间局部性
在一个具有良好时间局部性的程序中,被引用过一次的内存位置很可能在不远的将来再被多次引用。在一个具有良好的空间局部性的程序中,如果一个内存位置被引用了一次,那么程序很可能在不远的将来引用附近的一个内存位置。
6.2.1 对程序数据引用的局部性
int sumarrayows(int a[M][N])
{
int i, j, sum = 0;
for(i=0;i<M;i++){
for(j=0;j<N;j++){
sum += a[i][j];
}
}
}
上面就是一个具有良好局部性的程序,因为是按照数组元素在内存中的存储顺序来访问他们。
这种步长(两次内存访问位置之间的距离)为1的引用模式被称为顺序引用模式。
如果将i和j位置互换,则空间局部性就会变得很差。
6.2.2 取指令的局部性
因为程序指令是存放在内存中的,CPU必须取出这些指令,所以我们能够评价一个程序关于取指令的局部性。for循环里的指令是按照连续的内存顺序执行的,因此循环具有良好的空间局部性。
6.2.3 局部性小结
- 重复引用相同变量的程序具有良好的局部性。
- 对于具有步长为k的引用模式的程序,步长越小,空间局部性越好。
- 对于取指令来说,循环具有良好的时间和空间局部性。循环体越小,循环迭代次数越多,局部性越好。
6.3 存储器层次结构
基于两个事实:
- 不同存储技术的访问时间差很大,CPU和主存之间的速度差距在增大。
- 良好的程序具有良好的局部性。
人们想到一种组织存储器系统的方法:存储器层次结构。
6.3.1 存储器结构中的缓存
存储器层次结构的中心思想,对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。依次类推,直到最小的缓存:CPU寄存器组。
-
缓存命中
当程序需要第k+1层的某个对象d时,它首先在当前存储在第k层的一个块中查找d。如果d刚好缓存在第k层中,就是我们所说的缓存命中。例如,一个有良好时间局部性的程序可以从块14中读出一个数据对象,得到一个对k层的缓存命中。
-
缓存不命中
另一方面,如果第k层中没有缓存对象d,那么就是我们所说的缓存不命中。当发生缓存不命中时,第k层的缓存从第k+1层缓存中取出包含d的那个块,如果第k层的缓存已经满了,可能就会覆盖现存的一个块。
覆盖一个现存的块的过程称为替换或者驱逐这个块。被驱逐的这个块有时也被称为牺牲块。决定该替换哪个块时由缓存的替换策略来控制的。
-
缓存不命中的种类
-
强制性不命中/冷不命中
第k层缓存是空的则这一层叫冷缓存,如果空的缓存存入了一个数据,就叫冷缓存的暖身。
硬件缓存往往使用很严格的放置策略,这个策略将k+1层的某个块限制在第k层块的一个小的子集中。如上图第k+1层的0、4、8、12会映射到第k层的块0。设置一个第k+1层的块i必须放在第k层的块i mod 4中这样的放置策略。
-
冲突不命中:映射的缓存块一样。
-
容量不命中:程序通常是按照一系列阶段(如循环)来运行的,每个阶段访问缓存块的某个相对稳定不变的集合称为这个阶段的工作集。当工作集的大小超过缓存的大小时,缓存会经历容量不命中。换句话说,就是缓存小了,不能处理这个工作集。
-
6.4 高速缓存存储器
早期计算机系统的存储器层次结构只有三层:CPU寄存器,DRAM主存储器和磁盘存储
由于CPU和主存之间逐渐增大的差距。系统设计者被迫在CPU寄存器文件和主存之间插入了一个小的SRAM高速缓存寄存器,称为L1高速缓存(一级缓存)。
6.4.1 通用的高速缓存存储器组织结构
将一个存储器以字节为单位划分为S个组, E个高速缓存行, B个字节数据块;
在一个高速缓存行中,有一个有效位指明这行数据是否具有意义, 还有t = m-(b+s) 个标记位,指明当前存储在这个行中的块的身份。(例如, 上一层的块0, 4, 8, 12)都可以存在这一层的块0上,标志位指出具体是哪一块存在这里);
高速缓存结构使用(S,E, B, m)来描述。 高速缓存的大小C是所有块的综合 = S×E×B;
当一条加载指令指示CPU从主存地址A读取一个字,他将这个地址发送给高速缓存;
参数S, B将地址分为三部分。组索引指明在哪一个组S; 然后在这个组中找寻与标记相匹配的那一个高速缓存行E,最后根据块偏移在高速缓存行中找到具体的字。
6.4.2 直接映射高速缓存
每个组中只含有一个高速缓存行的结构, 称为直接映射高速缓存。
-
直接映射高速缓存中的组选择
-
直接映射高速缓存中的行匹配
-
直接映射高速缓存中的字选择
-
直接映射高速缓存中的行替换
如果缓存不命中,那么它需要从存储器层次结构中的下一层取出被请求的块,然后将新的块存储在组索引位指示的组中的一个高速缓存行中。对于高速映射缓存来说,每个组只包含有一行,替换策略非常简单:用新取出的行代替当前的行。
-
综合:运行中的直接映射高速缓存
- 标记位和索引位连起来唯一地标识了内存中的每个块。
- 因为有8个内存块,但是只有4个高速缓存,所以多个块会映射到同一个高速缓存。
- 映射到同一个高速缓存的块由标记位唯一地标识
6.4.3 组相联高速缓存
直接映射高速缓存中冲突不命中造成的问题源于每个组只有一行。组相联高速缓存放松了这个限制。每个组有E行的高速缓存通常称为E路高速缓存。
组相联高速缓存中的组选择
组相联高速缓存中的行匹配和字选择
组相联高速缓存不命中时的行替换
在缓存不命中时,高速缓存必须将内存中取出这个字的块然后进行行替换,如果有空行直接替换空行。
如果没有,则运用一些复杂的策略(利用局部性原理):最不常使用策略和最近少使用策略进行行替换。
6.4.4 全相联高速缓存
组相联高速缓存中的行匹配和字选择
6.4.5 有关写的问题
假设我们要写一个已经缓存了的字w(写命中),在高速缓存更新了它的w的副本之后,怎么更新w在层次结构中紧接着低一层的副本?
直写:就是立即将w的高速缓存块写回到紧接着的低一层中。虽然简单,但是每次写都会引起总线流量
写回:尽可能地推迟更新,只有当替换算法要驱逐这个块时,才把它写到紧接着的低一层中。由于局部性,写回能显著地减少总线流量,但是缺点是增加了复杂性。高速缓存必须为每个高速缓存行维护一个额外的修改位,表明这个高速缓存块是否被修改过。
如何处理写不命中?
写分配:加载相应的低一层中的块到高速缓存,然后更新这个高速缓存块
非写分配:避开高速缓存,直接将这个字写到低一层中。
直写高速缓存通常是非写分配的。写回高速缓存通常是写分配的
6.4.6 高速缓存参数的性能影响
- 不命中率:在一个程序执行或程序的一部分执行期间,内存引用不命中的比率。不命中数量/引用数量
- 命中率:1-不命中率
- 命中时间:从高速缓存传送一个字到CPU的时间,包括组选择、行确认、字选择的时间。
- 不命中处罚:由于不命中所需要的额外的时间。
-
高速缓存大小的影响
一方面:较大的高速缓存可能提高命中率。
另一方面:较大的高速缓存可能增加命中时间。
-
块大小的影响
一方面:较大的块能利用程序可能存在的空间局部性,提高命中率。
另一方面:块越大,高速缓存行越小,这会损害时间局部性比空间局部性好的程序的命中率。
-
相联度的影响
高的相联度降低了高速缓存由于出现冲突不命中出现抖动的可能性,但会造成更高的成本,还会增加命中时间和不命中处罚。
-
写策略的影响
直写高速缓存比较容易实现,而且能使用独立于高速缓存的写缓冲区,用来更新内存。
写回引起的传送少。
6.5 编写高速缓存友好的代码
核心在于利用局部性。
- 让最常见的情况运行得快。
- 尽量减小循环内部的缓冲不命中数量
第七章:链接
链接是将各种代码和数据片段收集并组合成为一个单一文件的过程。
在现代系统中,链接是由叫做链接器的程序自动执行的。
7.1 编译器驱动程序
int sum(int *a,int n)
{
int i,s=0;
for(i=0;i<n,i++){
s += a[i];
}
return s;
}
int array[2]={1,2};
int main()
{
int val=sum(array,2);
return val;
}
-
预处理命令
gcc -E hello.c -o hello.o
cpp hello.c > hello,i
经过预编译处理后,得到的是预处理文件,他还是一个可读的文本文件,但不包含任何宏定义。
-
编译过程
编译过程就是将预处理后得到的预处理文件进行语法分析、词法分析、语义分析、优化后,生成汇编代码文件。
用来进行编译处理的程序称为编译程序。(编译器)
编译命令:
- gcc -S hello.i -o hello.s
- gcc -S hello.c -o hello.s
- /user/lib/gcc/i486-linus-gnu/4.1/cc1 hello.c
-
汇编程序(汇编器)用来将汇编语言源程序转换为机器命令序列(机器语言程序)
汇编命令:
- gcc -c hello.s -o hello.o
- gcc -c hello.c -o hello.o
- as hello.s -o hello.o(as是一个汇编器)
-
预处理、编译和汇编三个阶段针对一个模块(一个*.c文件)进行处理,得到对应的一个可重定位目标文件(一个*.o文件)
链接过程将多个重定位目标文件合并以生成可执行目标文件
链接命令:
- gcc -static -o myproc main.o test.o
- ld -static -o myproc main.o test.o
-static表示静态链接,如果不知道-o选项,则可执行文件名为a.out
7.2 静态链接
-
静态链接器以一组可重定位目标文件为输入,生成一个完全链接的、可以加载和运行的可执行目标文件为输出。
-
输入的可重定位目标文件由各种不同的代码的数据节(.section)组成,每一节都是一个连续的字节序列。
-
指令在一节中,初始化了的全局变量在另一节中,而未初始化的变量有在另外一节中。
为了构造可执行文件,链接器必须完成两个主要任务:
- 符号解析:目标文件定义和引用符号
- 确定符号引用关系(符号解析)
- 重定位:把每一个符号定义与一个内存位置关联起来。
- 合并相关.o文件
- 确定每个符号的地址
- 在指令中填入新地址
每个符号对应一个函数、一个全局变量或者一个static变量
- 子程序(函数)起始地址和变量起始地址是符号定义(definition)
- 调用子程序(函数或过程)和使用变量即是符号的引用(reference)
- 一个模块定义的符号可以被另一个模块引用
- 最终须链接(即合并),合并时须在符号引用处填入定义处的地址
7.3 目标文件
目标文件有三种形式:
- 可重定位目标文件
- 可执行目标文件
- 共享目标文件(一种特殊的可重定位目标文件)
编译器cc1和汇编器as生成可重定位目标文件,链接器ld生成可执行目标文件
各个系统的目标文件格式各不相同,unix系统默认格式是ELF,windos系统是PE格式。
7.4 可重定位目标文件
7.5 符号和符号表
每个可重定位目标模块m都有一个符号表.symtab。它包含m定义和引用的符号的信息。
在链接器的上下文中,有三种符号:
- 由模块m定义并能被其他模块引用的全局符号
- 由其他模块定义并能被模块m引用的全局符号
- 只能被模块m定义和引用的局部符号,他们对应于带static属性的C函数和全局变量。
符号表不包括对应于本地非静态程序变量的任何符号。这些符号在栈中管理。
符号是由汇编器构造的,使用编译器输出到汇编语言.s文件中的符号。.symtab节中包含ELF符号表。
- 每个符号都被分配到目标文件的某个节中,由结构体中的section字段表示,该字段是一个到节点头部表的索引。
- 有三个特殊的伪节,他们在节头部表的索引中是没有条目的:
- ABS表示不该被重定位的符号
- UNDEF表示未定义的符号
- COMMON表示还未被分配位置的未初始化的数据(.bss)。此时,value字段给出对齐要求,size给出最小大小。
7.6 符号解析
链接器解析符号引用的方法是将每个引用与它输入的可重定位目标文件的符号表中的一个确定的符号定义关联起来。
对那些和引用定义在相同模块中的局部符号的引用,符号解析是非常简单的。
当编译器遇到一个不是在当前模块中定义的符号时,会假设该符号是在其他模块中定义的,生成一个链接器符号条目,并把它交给链接器处理。
但如果任何输入模块中都找不到,就会输出错误信息。
另外,多个目标文件可能会定义相同名字的全局符号。链接器要么标志出有一个错误,要么以找某种方法选出一个定义。
7.6.1 链接器如何解析多重定义的全局符号
在编译时,编译器会向汇编器输出每个全局符号,或者是强符号,或者是弱符号。而汇编器会把这个消息隐含地编码在可重定位目标文件的符号表里。
- 强符号:函数和已初始化的全局变量。
- 弱符号:未初始化的全局变量。
Linux链接器按照以下规则处理多重定义的符号名:
- 不允许有多个同名的强符号
- 如果一个强符号和多个弱符号同名,那么选择强符号
- 如果多个弱符号同名,从中任选一个
7.6.1 与静态库链接
迄今为止,我们都是假设链接器读取一组可重定位目标文件,并把它们链接起来,形成一个输出的可执行文件。
实际上,所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,称为静态库。它可以作为链接器的输入。
当链接器构造一个输出的可执行文件时,它只复制静态库里被应用程序引用的目标模块。
静态链接对象:
- 多个可重定位目标模块(.o文件)
- 静态库(标准库,自定义库)(.a文件,其中包含多个.o模块)
相关函数可以被编译为独立的目标模块,然后封装成一个单独的静态库文件,应用程序可以通过在命令行上指定单独的文件名字来使用这些在库中定义的函数。
- 一方面减少了可执行文件在磁盘和内存中的大小。
- 另一方面,应用程序员只需要包含较少的库文件的名字。
在linux系统中,静态库以一种称为存档(archive)的特殊文件格式存放在磁盘中。有一个头部用来描述每个成员目标文件的大小。
7.6.3 链接器如何使用静态库来解析引用
在符号解析阶段,链接器从左到右按照它按照它们在百年一起驱动器命令行上的顺序来扫描可重定位目标文件和存档文件。(命令行中的.c文件会被自动翻译成.o文件)
在这次扫描中,链接器会维护三个集合:
- E将被合并以组成可执行文件的所有目标文件集合
- U当前所有未解析的引用符号的集合
- D当前所有定义符号的集合
//myproc1.c
#include <stdio.h>
void myfunc1(){
printf("This is myfunc1!\n");
}
//myproc2.c
#include <stdio.h>
void myfunc2(){
printf("This is myfunc2!\n");
}
gcc -c myproc1.c myproc2.c//生成两个可重定位目标文件
ar rcs mylib.a myproc1.o myproc2.o//将两个可重定位目标文件封装成静态库mylib.a
调用关系:main->myfunc1->printf
gcc -c mian.c
gcc -static -o myproc main.o ./mylib.a
//main.c
void myfunc1(void);
int main()
{
myfunc1();
return 0;
}
- 开始E、U、D为空,首先扫描main.o,把它加入E中,同时myfunc1加入U,main加入D
- 接着扫描mylib.a,将U中所有符号与mylib.a中所有目标模块(myproc1.o myproc2.o)依次匹配,发现myproc1.o中定义了myfunc1,故myproc1.o加入E,myfunc1从U转移到D
- 在myproc1.o中发现还有没解析符号printf,将其加到U。不断在mylib.a的各个模块进行迭代以匹配U中的符号。知道U、D都不再改变。
- 此时U中只有一个未解析符号printf,而D中有main和myfunc1。因为模块myproc2.o没有加入E中,因而被抛弃。
- 接着,扫描默认的库文件lib.a,发现其目标模块printf.o定义了printf,于是printf从U移到D,并将printf.o移到E,同时把它定义的所有符号加入D,而所有未解析的符号加入U。处理完libc.a时,U一定是空的。
关于库的一般准则是将他们放在命令行的结尾。
- 如果各个库的成员相互独立(也就是说没有成员引用另一个成员定义的符号),那么可以以任意顺序放置
- 如果库之间不是相互独立的,则要对他们今进行排序。保证前面调用的函数后面库中一定被定义了的,如果需要满足需求,可以重复库。
例如:
foo.c调用了libx.c和libz.a中的函数,而这两个库又都调用了liby.a中的函数,则:
gcc foo.c libx.a libz.a liby.a
如果foo.c调用libx.a中的函数,libx.a调用了liby.a的函数,liby.a也调用了libx.a的函数,则:
gcc foo.c libx.a liby.a libx.a
7.7 重定位
在符号解析中,代码中的每个符号引用和正好一个符号定义关联起来。此时,链接器就知道他的输入目标模块中的代码节和数据节的确切大小。
在重定位步骤中,将合并输入模块,并为每个符号分配运行时地址。
由两步组成:重定义和符号定义、重定位节中的符号引用。
7.7.1 重定位条目
当汇编器遇到对最终位置未知的目标引用时,就会生成一个重定位条目。
代码的重定位条目放在rel.text中
已初始化数据的重定位条目放在.rel.data中
- R_X86_64_PC32。重定位一个使用32位PC相对地址的引用。
- R_X86_64_32。重定位一个使用32位绝对地址的引用。
//main.c
int sum(int *a,int n);
int array[2]={1,2};
int main()
{
int val=sum(array,2);
return val;
}
//sum.c
int sum(int *a,int n)
{
int i,s=0;
for(i=0;i<n;i++){
s+=a[i];
}
return s;
}
7.7.2 重定位符号引用-重定位PC相对引用
假定:
- 可执行文件中main函数对应的机器代码从0x4004d0开始(ADDR(.text))
- sum紧跟main后,它的机器代码首地址为0x4004e8(ADDR(sum))
则重定位后call指令的机器代码是什么?(修改偏移量)
转移地址=PC+偏移量
PC=0x4004d0+0x13=0x4004e3(call后那条指令的地址)
偏移量=转移地址(sum)- PC
=0x5
可执行文件中,会显示e8 05 00 00 00(小端法)
7.7.2 重定位符号引用-重定位绝对引用
直接将array绝对地址给放在第四行机器指令的后面
假设:
- 链接器已经确定array的地址为0x601018(在.data中)
那么可执行文件中,会修改为:bf 18 10 60 00
7.8 可执行目标文件
我们的C程序已经从一开始的一组ascii文本文件转化为了一个二进制文件,并且包含加载程序到内存并运行它的所有信息。
- 包含代码、数据(已初始化.data和未初始化.bss)
- 定义的所有变量和函数已有确定地址(虚拟地址空间中的地址)
- 符号引用处已被重定位,以指向所引用的定义符号
- 没有文件扩展名或默认a.out
- 可被CPU直接执行,指令地址和指令给出的操作数地址都是虚拟地址
7.9 加载可执行目标文件
运行可执行目标文件prog,可以再命令行中输入./prog。
通过调用某个驻留在存储器中称为加载器loader的操作系统代码来运行它。
任何linux程序都可以通过调用execve函数来调用加载器。
加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。
- 每个linux程序都有一个运行时内存映像。
- 在linux x86-64系统中,代码总是从地址0x400000开始,然后是数据段。
- 运行时堆在数据段之后,通过调用malloc库往上增长。
- 用户栈总是从最大的合法用户地址(2^48-1)开始,向较小内存地址增长。
- 从2^48开始,是为内核kernel中的代码和数据保留的。
7.10 动态链接共享库
静态库的缺点:
- 库函数被包含在每个运行进程的代码段中,对于并发运行上百个进程的系统,造成极大的主存资源浪费。
- 库函数被合并在可执行目标中,磁盘上存放着数千个可执行文件,造成磁盘空间的极大浪费。
- 程序员需关注是否有函数库的新版本出现,并须定期下载、重新编译和链接。更新困难,使用不便。
解决方案:Shared Libraries(共享库)
- 是一个目标文件,包含有代码和数据
- 从程序中分离出来,磁盘和内存都只有一个备份
- 可以动态地在装入时或运行时被加载到任意的内存地址,并和一个在内存中的程序链接
Window称其为动态链接库(.dll)
Linux称其为动态共享对象(.so)
自定义一个动态共享库文件
gcc -c myproc1.c myproc2.c
gcc -shared -fPIC -o mylib.so myproc1.o myproc2.o
- PIC位置无关代码
- 保证共享库代码的位置是不确定的
- 即使共享库代码的长度发现变化,也不影响调用它的程序
- shared选项
- 指示链接器创建一个共享的目标文件
加载时动态链接:
gcc -c main.c
gcc -o myproc main.o ./mylib.so
lib.so无需明显指出
- 加载myproc时,加载器发现在其程序头表中**.interp段**,其中包含了动态链接器路径名ld -linux.so,因而加载器根据指定路径加载并启动动态链接器运行。动态链接器完成相应的重定位工作后,再把控制权交myproc,启动其第一条指令执行。
7.11 从应用程序中加载和链接共享库
- 即运行时动态链接
- 可通过动态链接接口提供的函数再运行时进行动态连接类UNIX系统中的动态链接器接口定义了相应函数,其头文件为.dlfcn.h
#include <stdio.h>
#include <dlfcn.h>
int main()
{
void *handle;
void (*myfunc1);
char *error;
/*动态装入包含函数myfunc1()的共享库文件*/
handle=dlopen("./mylib.so",RTLD_LAZY);
if(!handle){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
/*获得一个指向函数myfunc1()的指针myfunc1* */
myfunc1=dlsym(handle,"myfunc1");
if((error=dlerror())!=NULL){
fprintf(stderr,"%s\n",error;
exit(1);
}
/*现在可以像调用其他函数一样调用函数myfunc1()*/
myfunc1();
/*关闭(卸载)共享库文件*/
if(dlclose(handle)<0){
fprintf(stderr,"%s\n",dlerror());
exit(1);
}
return 0;
}
7.12 位置无关代码
无论我们在内存中的何处加载一个目标模块,数据段与代码段的距离总是保持不变。因此,代码段中和数据段中任何变量之间的距离都是一个运行时常量与代码段和数据段的绝对内存位置是无关。
共享库中有四种引用情况:
- 模块内的过程调用、跳转,采用PC相对偏移寻址
- 模块内数据访问,如模块内的全局变量和静态变量
- 模块外的过程调用、跳转
- 模块外的数据访问,如外部变量的访问
- 后两种无法直接确定位置,需要用到PIC。
PIC数据引用
- 在数据段开始的地方创建一个表GOT(结构体数组)
- GOT:全局偏移量表
- 在GOT中,每个被这个目标模块引用的全局数据目标(过程或者全局变量)都有一个8字节条目。编译器还会为GOT每个条目生成一个重定位信息(在rel.data节)
- 在加载时。动态链接器会重定位GOT中的每个条目,使得它包含目标的正确的绝对地址
- 每个引用全局目标的目标模块都有自己的GOT
PIC函数调用
- 在GOT表中加入一个函数名(如printf),但为了减小代码量和减少寄存器使用溢出,我们采用”延迟绑定“技术。
- 将过程地址的绑定推迟到第一次调用该过程时
- 原先是在动态链接过程中进行重定位绑定
- 会用到两个数据结构:
- GOT
- PIT过程链接表
GOT是.data节一部分,开始三项固定
- GOT[0]:.dynamic节首地址,该节包含动态链接器的基本信息
- GOT[1]:动态链接器的表示信息
- GOT[2]:动态链接器延迟绑定代码的入口地址
- 调用的共享库函数都有GOT项,如GOT[3]对应ext
PLT是.textk节的一部分,结构数组,每项16字节
- 除PLT[0]外,其余项对应一个共享库函数,如PLT[1]对应ext函数