2. 从软件角度来看,CPU是一个执行各种计算机指令(Instruction Code)的逻辑机器,这里的计算机指令被称为机器语言(Machine Language)
说明1:汇编语言
汇编语言与机器码一一对应,是给程序员看的机器码
说明2:不同的CPU机器语言不同
CPU各自支持的机器语言称为计算机指令集(Instruction Set),不同的CPU支持的指令集不同(e.g. X86 & ARM架构)
说明3:存储程序型计算机(Stored-program Computer)
一个计算机程序不可能只有一条指令,而是由成千上万条指令组成。但是CPU中不可能一直存放所有指令,所以计算机程序平时是存储在存储器中
现代计算机基于以下两个重要准则构建,
① 指令用数的形式表示
② 和数据一样,程序存储在存储器中,并且可以读写
这就是存储程序的概念
tips:在没有存储程序型计算机之前,工程师发明了一种称为Plugborad Computer的计算设备(可直译为"插线板计算机"),这种计算机使用不同的电线连接不同的插口,从而完成各种计算任务
加减乘除
2. 数据传输类指令
给变量赋值、在内存里读写数据
3. 逻辑类指令
逻辑上的与或非
4. 条件分支指令
对应if-else语句
5. 无条件跳转指令
函数调用的实现
说明1:指令集设计目标
可方便硬件和编译器的设计,且使性能最佳,同时使成本和功耗最低
说明2:计算机硬件设计的三条基本原则
① 简单源于规整
在RISC架构中,要求指令定长,操作数个数固定
② 越小越快
有效利用寄存器对于提高程序性能极为重要,当寄存器数量不足时,编译器会尽量将最常用的变量保持在寄存器中,而将其他变量放在存储器中
③ 优秀的设计需要适宜的折中方案
计算机指令中使用到的寄存器和立即数都需要编码在指令的机器码中,所以对可用的寄存器个数、立即数的表示范围以及指令的格式之间需要折中
比如在32位ARM中有一种模式下可使用16个寄存器,就需要占用机器码中的4位。而要增加寄存器,就需要在机器码中占用更多位
也叫指令地址寄存器(Instruction Address Register),用来存放下一条要执行的计算机指令的内存地址
2. 指令寄存器(Instruction Register)
用来存放当前正在执行的指令
4. 状态寄存器(Status Register)
由一组标志位构成,存放CPU进行算术或逻辑运算后的CPU状态
C语言代码如下,
#include
#include
#include
int main(void)
{
int a = 0;
int r = 0;
srand(time(NULL));
r = rand() % 2;
if (r == 0)
a = 1;
else
a = 2;
return 0;
}
对应的汇编代码如下,
使用比较 + 条件跳转指令实现条件语句,其中,
C语言代码如下,
int main(void)
{
int a = 0;
int i = 0;
for (i = 0; i < 3; ++i)
a += i;
return 0;
}
对应的汇编代码如下,
在实现循环语句时也使用了比较 + 条件跳转,与条件语句不同的是,实现循环语句时是回向跳转(backward)
C语言代码如下,
int main(void)
{
int a = 0;
int b = 0;
switch (a) {
case 0:
b = 0;
break;
case 1:
b = 1;
break;
default:
b = 2;
break;
}
return 0;
}
对应的汇编代码如下,
可见实现分支语句时依然使用了比较 + 条件跳转
参考如下的C语言代码,
int add(int a, int b)
{
return a + b;
}
int main(void)
{
int x = 5;
int y = 10;
int z = add(x, y);
return 0;
}
在函数调用过程中需要解决返回函数调用点之后继续运行的问题(即函数调用返回问题),解决思路有如下几种,
此时不再需要处理函数调用返回问题,但是如果出现函数A调用函数B,而函数B又调用函数A,则会出现无止境的替换
2. 使用寄存器记录函数返回地址
ARM架构中的lr寄存器就是用于保存bl指令的返回地址,但是函数的调用层次并没有数量上的限制,所以只记录一级函数返回地址是不够的。但是CPU中的寄存器数量有限,不可能记录每一级函数的返回地址
说明:类似ARM架构中的lr寄存器属于硬件对函数调用的支持
3. 使用函数栈
在内存中开辟一段空间,构造函数栈这种后进先出的数据结构
参考示例的汇编代码如下,
说明:此处反汇编的是可执行文件而不是目标文件,如果反汇编目标文件,函数调用处是调用下一条指令,而不是调用add函数
增加这点备注,主要是因为极客时间课程中是反汇编目标文件,但显然是不行滴
在使用call指令实现函数调用时,内部会完成如下两个步骤,
main函数栈的变化如下图所示,
执行流进入add函数后,push rbp + mov rbp, rsp用于建立add函数栈帧,如下图所示,
建立栈帧的核心操作为,
说明:从实现中可见,其实栈底指针rbp并不是必需的。在栈帧中引入栈底指针rbp的方便性在于对函数中所有栈内的变量的引用,都具有相同的偏移基地址,这点从反汇编代码中很容易得知
在当前示例中pop rbp用于撤销栈帧,当函数栈更为复杂时,完整的撤销栈帧指令为mov rsp, rbp + pop rbp,如下图所示,
撤销栈帧的核心操作为,
可见撤销栈帧之后,栈的就恢复到了调用call指令后的状态
调用ret指令实现函数返回时,就是将当前栈顶保存的函数返回地址pop到PC寄存器,使得执行流返回add函数调用点之后继续执行
至此,栈恢复到调用add函数之前的状态
说明1:函数栈帧构成
① 函数返回地址
② 函数局部变量
③ 函数参数中不够用寄存器传递的部分
说明2:为何产生stack overflow
原因:线程栈空间有限
① 函数调用层次太多(e.g. 很深的递归调用)
② 在栈中创建体积很大的变量(e.g. 巨大的局部数组)
说明3:查看线程栈的限制
使用ulimit -a命令可以查看shell启动进程的资源限制
内联意味着把可以复用的程序指令在调用处完全展开,如果一个内联函数在很多地方被调用,就会被展开多次,进而增加程序占用的空间
内联函数是一种用空间换时间的策略
参考如下C语言代码,为使用递归方式求阶乘
int fact(int n)
{
if (n < 1)
return 1;
else
return n * fact(n - 1)
}
int main(void)
{
int n = 0;
n = fact(3);
printf("fact(3) = %d\n", n);
return 0;
}
对应的反汇编代码如下,
从中注意2点,
实例共设置如下2个文件,
// add_lib.c
int add(int a, int b)
{
return a + b;
}
// link_example.c
#include
extern int add(int a, int b);
int main(void)
{
int a = 10;
int b = 5;
int c = add(a, b);
printf("c = %d\n", c);
return 0;
}
首先来分析下二者编译出的目标文件的反汇编代码,
1. add_lib.o
2. link_example.o
目标文件的反汇编代码有如下2点值得注意,
① 目标文件的地址都是从0开始
② 对函数的调用均被编译为call 下一条指令的地址
下面再来分析下链接之后生成的可执行文件的反汇编代码,
经过链接之后,对函数的调用已经能够正确解析,并且会跳转到函数的链接地址处运行
Linux中的可执行文件、目标文件和动态库均为ELF(Executable and Linkable File Format)格式,即可执行与可链接文件格式。ELF文件不仅存放了指令,还包含很多数据
ELF文件格式将各种信息以段(Section)的方式组织,重要组件如下,
表示文件的基本属性,比如文件类型(e.g. 可执行文件,目标文件)、对应的CPU、OS信息以及文件中其他段的大小和位置
2. 代码段(Code Section)
保存程序编译出的机器指令
3. 数据段(Data Section)
保存程序中设置好的初始化信息
4. 重定位表(Relocation Table)
保存当前ELF文件中尚未确定地址的符号,比如link_example.o中调用了add和printf函数,就会保存在重定位表中
5. 符号表(Symbol Table)
保存当前ELF中定义的符号及对应的地址
1. 链接器会扫描所有输入的目标文件,然后把所有符号表信息收集起来,构成一个全局的符号表
2. 扫描重定位表,把所有不确定跳转地址的符号根据全局符号表进行一次修正
3. 把所有目标文件的对应段进行一次合并,生成最终的可执行代码
说明1:为什么引入链接器
假设没有链接器,则所有源码只能放在同一个源文件中,且每次修改后都要重新编译,所以引入链接器是很自然地选择
说明2:可执行程序包含符号表和调试信息,对可执行程序进行strip操作,可以剥离这些信息,减小文件体积
但代价是后续调试比较困难,因为无法解析地址和符号的对应关系
readelf命令可显示ELF文件的各种信息,具体使用方式可见man手册,下面以本节编译生成的可执行文件为例,显示其中的相关Section
1. 文件头部信息(-h)
2. 符号表信息(-s)
3. 重定位表信息(-r)
说明:可见printf仍在重定位表中,这是因为printf函数所在的glibc是以动态库的方式存在,所以在链接之后仍然无法确定其跳转地址。该函数需要在加载时才能定位,后续章节将会说明
1. Linux的可执行文件为ELF格式,Windows的可执行文件为PE(Portable Executable Format)格式
2. 两种OS的加载器只能识别自身支持的文件格式
3. 如果Linux中有能够解析PE格式的加载器,就可以在Linux中执行Windows应用程序,比如Wine
4. 如果Windows中有能够解析ELF格式的加载器,也可以在Windows中执行Linux应用程序,比如WSL(Windows Subsystem for Linux)
当然,这里还要求二者CPU的体系架构相同,比如都是X86架构,这样才能确保指令集相同
5.1.1 内存连续
执行指令时,PC寄存器是顺序递增,所以指令需要在内存连续存储
说明:在引入操作系统的内存管理之后,这里的内存连续是指虚拟地址上的连续,物理地址不一定连续
由于操作系统会同时加载多个程序运行,如果由程序自己决定实际加载的内存地址,则可能出现不同程序指定的地址冲突
说明1:我们把指令中用到的地址称作虚拟内存地址(Virtual Memory Address);把实际内存地址称作物理内存地址(Physical Memory Address)
说明2:引入虚拟地址之后,任何程序都可以使用相同的链接地址,操作系统只需要维护一个虚拟内存到物理内存的映射表即可
由于是连续的内存地址空间,所以只要维护映射关系的起始地址和对应的空间大小即可
tips:其实这就是页表的概念
在物理内存中找出一段和ELF文件大小匹配的连续地址空间,然后将ELF文件从磁盘加载到物理内存并运行指令
这样使用相同虚拟地址的程序被加载到不同的物理内存中运行,程序本身不再需要关心具体的物理地址问题
分段方案存在内存碎片(Memory Fragmentation)问题,当物理内存中连续的物理内存不足以加载ELF文件时,即使总的剩余内存足够也无法加载程序运行
内存碎片问题的解决方案就是内存交换,即将正在运行的其他程序先写入磁盘,然后再读回内存,并在再次读回时处理内存碎片问题。安装Linux操作系统时分配的swap分区就是用于内存交换的
内存交换方案的缺点是存在性能瓶颈,因为磁盘的访问速度要远低于内存访问速度,在交换大量内存时开销很大
1. 内存分页方案的核心在于化整为零,以固定大小的页(Page)为单位进行内存的分配,Linux中通常设置为4KB
可以使用getconf PAGE_SIZE命令查看当前系统的页大小
2. 化整为零的好处是降低了对物理连续内存的要求
一页之内的物理地址要求连续,但是不同页之间的物理地址不要求连续
3. 当物理内存不足时,分页方案也需要内存交换,但是以页为单位,开销较小
4. 在实际使用分页方案时,并不需要一次性把程序都加载到物理内存中,在进行虚拟内存页和物理内存页的映射之后,并不真的将页加载到物理内存,而是在需要用到对应虚拟内存页中的指令和数据时,再将数据从磁盘加载到物理内存中
操作系统通过缺页异常(Page Fault)实现上述机制,而且甚至连虚拟内存和物理内存之间的映射也是在发生缺页时才建立的
问题:物理内存又是如何与磁盘上的内容对应起来的呢 ?
学习过Linux内核原理之后会发现,其实是先分配虚拟地址空间,然后将磁盘文件映射到进程的虚拟地址空间中,最后才在缺页时分配物理页面
5. 通过引入虚拟内存、分页映射和内存交换,我们可以运行远大于实际物理内存的程序。而程序本身不再需要考虑对应的真实地址、程序加载、内存管理等问题,任何程序可以将内存作为一块完整而连续的空间来使用
根据函数库加载时机的不同,函数库分为静态库和动态库
如果是动态库,还需要将其放置到指定目录或导出其路径,以便应用程序运行时加载
创建函数库头文件,在其中声明函数库提供的全局变量和函数
// func_lib.h
#ifndef FUNC_LIB_H
#define FUNC_LIB_H
extern int a;
void func1(void);
void func2(void);
void func3(void);
#endif
创建函数库源文件,定义函数库提供的全局变量,并实现函数库的所有函数
// func_lib.c
#include
#include "func_lib.h"
int a = 10;
void func1(void)
{
printf("func1\n");
}
void func2(void)
{
printf("func2\n");
}
void func3(void)
{
printf("func3\n");
}
创建静态库或动态库均需要先将库的源文件编译为目标文件
使用ar工具创建静态库
下面编写测试用例验证该静态库
// main.c
#include
#include "func_lib.h"
int main(void)
{
printf("a = %d\n", a);
func1();
func2();
return 0;
}
编译时,注意链接静态库的选项
其中-L . 是将当前目录添加到gcc的库文件搜索路径中
补充:关于gcc中-I选项的使用
在测试程序中,可使用#include
注意:如果要建立动态库,可以在编译目标文件时(.c --> .o)就添加-fPIC选项
此时可以再次编译之前的测试程序,在同时有动态库 & 静态库时,gcc会优先以动态库的方式进行链接。如果要指定使用静态库,可以使用-static选项禁止使用动态库
此时直接运行可执行程序会出错,提示打开动态库失败。从这里可以看出动态库和静态库的不同,动态库在程序运行时仍然需要,而且要正确布署,以确保程序运行时可以找到并加载他
说明1:使用ldd命令(print shared library dependencies)可以查看应用程序对动态库的依赖
可见此时应用程序依赖的libfunc_lib.so并未正确布署,所以无法打开
说明2:动态库的3种布署方式
由于编译时指定动态库的作用仅仅是添加动态库标记,为了在程序运行时能够找到动态库,有如下3种布署方式
① 将动态库拷贝到系统搜索库的目录,如/lib或/usr/lib
② 修改环境变量(这种方式在嵌入式开发中常用)
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:所需动态库目录
可见在将动态库目录导出到环境变量后,ldd即可查找到应用程序所依赖的动态库
③ 修改脚本
在/etc/ld.so.conf.d目录中的任一文件中加入新建动态库的路径(修改这些文件需要sudo权限)
修改后需要运行sudo ldconfig命令更新设置,更新后使用ldd命令查看应用程序动态库依赖,可见已能找到动态库路径
注意:由于嵌入式环境中大多不支持ldconfig机制,所以这种方法在嵌入式开发中并不常用
补充:还有一种使用dlopen使用动态库的方式,这种方式将对动态库的解析推迟到运行时
静态链接虽然解决了开发阶段的代码复用问题,但是在存储和运行时存在占用磁盘和内存的问题。以libc库为例,几乎所以有应用程序都会使用,不可能让每个应用程序都将libc静态链接一份
与此同时,应用程序可能只使用libc库中的很小一部分程序,但是仍然需要将整个静态库链接到应用程序中,也是一种浪费
在动态链接的过程中,要链接的不是存储在磁盘上的目标文件代码,而是加载到内存中的共享库(Shared Libraries)
说明1:共享库的存在形式
① 在Windows中,共享库以.dll文件形式存在,即Dynamic Link Library
② 在Linux中,共享库以.so文件形式存在,即Shared Object
说明2:应用程序共享的是共享库中的代码段,数据段并不共享,而是每个进程各自有一份拷贝
链接动态库时,要求编译出来的共享库文件的机器码必须是地址无关的。因为对于所有动态链接共享库的程序,虽然共享库使用的是同一段物理内存地址,但是映射到不同进程中时,却可能是不同的虚拟内存地址
因为每个进程使用的动态库不同,我们无法人为划定每个进程虚拟地址空间的分布
1. 动态库内部的变量和函数调用比较容易解决,使用相对地址(Relative Address)即可
2. 相对地址即各指令中使用到的内存地址不是一个绝对的地址空间,而是一个相对于当前指令偏移量的内存地址(通过相对于PC寄存器计算)
由于动态库的代码段和数据段在加载时需要重定位,因此对函数调用和对全局变量的引用通常不是PIC的
在实现过程中需要依赖2种新建的数据结构,即全局偏移量表(Global Offset Table,GOT)和过程链接表(Procedure Linkage Table,PLT)
7.3.1.1 原理思路
无论将动态库加载到内存的什么位置,数据段总是被分配成紧随在代码段之后(这点可参考进程布局),因此代码段中的任何指令和数据段中的任何变量之间的距离都是一个运行时常量,与代码段和数据段的绝对存储器位置无关
7.3.1.2 实现分析
编译器为每个引用全局数据的目标模块创建GOT,在GOT中,每个被目标模块引用的全局数据对象都有一个条目。编译器还为GOT中的每个条目生成一个重定位记录,在加载时,动态链接器会重定位GOT中的每个条目,使得他包含正确的绝对地址
说明:上述说明参考了《深入理解计算机系统》,但是在反汇编中并没有看到类似机制的实现,而是直接使用了全局变量a在可执行文件中的链接地址(在.bss段)
可以推断此处的地址重定位是动态链接器完成的
在PIC函数调用中,又引入了PLT,其目的是实现延迟绑定(lazy binding),即将函数地址的绑定推迟到第一次调用该函数时
下面结合反汇编代码进行说明
1. 对动态库函数的调用被转移到PLT
2. PLT中再转移到GOT
3. 初始GOT再转移到地址绑定过程
根据上图,func1@plt标号跳转到了0x804a000地址中存储的内存位置(有一次间接引用),我们看下初始GOT中该位置的内容
可见该位置的内容为0x08048426,也就是func1@plt中的push语句。也就是在第一次调用动态库中的func1函数时,会将0x0压入栈,然后跳转到0x8048410处执行
4. 实现地址绑定
根据《深入理解计算机系统》,GOT实际上是一个数组,其中前3项内容固定如下,
GOT[0]:.dynamic段的地址,此处为0x08049f20,和反汇编代码是一致的
GOT[1]:链接器的标识信息
GOT[2]:动态链接器中的入口点
从实现地址绑定的代码分析,就是将GOT[1]的内容压入堆栈,然后跳转到GOT[2]的位置运行。该过程中,将在GOT[3]中填入正确的func1绝对地址
在完成地址绑定之后,后续的动态库函数调用过程就可以直接在GOT的对应条目中找到正确的函数地址
个人推测:在链接过程中,动态链接器会修改目标模块的_GLOBAL_OFFSET_TABLE_中的内容,因为编译后GOT[1]和GOT[2]中的内容均为0
实验:为了验证上面的推测,我们在程序中打印GOT表的相关内容,如下图所示,
打印结果如下图所示,
该打印与之前的分析和推测是一致的,
① 加载动态库之后,GOT[1]和GOT[2]的值被修改
② 第一次调用func1前后,GOT[3]的值被修改,即第一次调用之后,GOT[3]中将保存func1函数正确的绝对地址
总体而言,动态库以如下流程实现对函数的调用
说明:根据上文分析,在目前的Linux动态库链接中,只会链接实际使用的函数
现代计算机都是用0和1组成的二进制来表示所有的信息,本节主要说明整数和字符串的二进制表示
1011b = -1 * 2^3 + 0 * 2^2 + 1 * 2^1 + 1 * 2^0 = -8 + 2 + 1 = -5
对于n位补码,若最高位为1,表示-1 * 2^(n - 1),因此只要最高位为1,则整个整数必定是负数
因为后面所有位为1的和,只有2^(n - 1) - 1,所以全1的二进制补码表示整数-1
1. 整数0只有一个编码
在原码表示法中,0有两个编码(即+0和-0),但是在补码表示法中只有全0编码表示整数0
2. 使用补码表示负数,不做任何特殊处理,可以实现正负数的加法
以-5 + 1和-5 + 6为例,
也就是说加法和减法可以使用相同的电路完成运算
说明1:有符号与无符号整数
程序有时候需要处理一些可以是正也可以是负的数(e.g. 一般的算数运算),有时候需要处理一些仅能是正的数(e.g. 内存地址)。一些编程语言反映了这个区别,例如C语言中使用int和unsigned int加以区分
说明2:取数指令的符号扩展
对取数指令(e.g. ARM中的ldr指令)而言,当加载的内存数据长度小于寄存器长度时,有符号数和无符号数是有区别的
取回有符号数需要使用符号位填充寄存器的所有剩余位,称为符号扩展,其目的是在寄存器中放入整数的正确表示方式
取回无符号数只是简单地用0来填充数据左侧的剩余位,因为这种形式的数是没有符号位的
符号扩展之所以是正确的,是因为二进制补码表示的正数实际上在左侧有无限多个0,而负数在左侧有无限多个1,只是为了适应硬件的宽度,数的前导被隐藏了,符号扩展只是简单地恢复了其中一部分
说明3:快速计算二进制补码数值的方法
方法:按位取反再加1
原理:在二进制补码中一个数和他按位取反的结果相加,一定是全1,也就是十进制中的-1。因此x + x(取反) = -1,即-x = x(取反) + 1
示例:以上文中的带符号4位二进制数1011为例,表示十进制中的-5。对齐取反加1为0101,表示十进制中的5
字符集:可表示的字符集合,比如Unicode字符集包含150种语言的14万个不同字符
字符编码:如何使用二进制表示字符集中的字符,比如对于Unicode字符集可以使用UTF-8 / UTF-16 / UTF-32进行编码
说明:数值的二进制序列化存储
使用字符串表示数字是按位表示的,以最大的32位有符号整数的最大值2147483647为例。如果使用整数表示法,只需要32位;如果使用字符串表示,一共有10个字符,按ASCII码每个字符8位计算,共需要80位,比整数表示法占用更多空间
这就是为什么在存储数据时要采用二进制序列化,而不是简单以文本格式存储的原因
使用Unicode编码记录文本时,对于一些遗留的老字符集内的文本,在Unicode中可能并不存在,于是Unicode会统一把这些字符记录为U+FFFD编码,如果用UTF-8编码存储,就是\xef\xbf\xbd。如果连续两个这样的字符放在一起,就是\xef\xbf\xbd\xef\xbf\xbd
如果用GB2312字符编码进行解码,得到的就是"锟斤拷"
Visual Studio调试器默认使用MBCS字符集,"烫"在该字符集中的编码为0xcccc,而0xcccc恰好是未初始化内存的初始值
于是在读到没有赋值的内存地址时,得到的字符就是"烫"
说明:字符串的表示
字符通常被组合为字符数量可变的字符串,表示一个字符串的方式有3种选择,
① 保留字符串的第1个位置用于给出字符串的长度(Java使用)
② 附加一个带有字符串长度的变量,如在结构体中
③ 字符串最后的位置用一个字符来标识其结尾(C使用)
假如来自不同线程的两个访存请求访问同一个地址,他们连续出现,并且至少其中一个是写操作,那么这两个存储访问形成数据竞争(data race)
在多处理器中实现同步需要一组硬件原语,提供对存储单元进行原子读和原子写的能力
原子交换原语(atomic exchange或atomic swap)是建立基本同步机制的一种方式,这个原语是将寄存器中的一个值和存储器中的一个值相互交换,下面给出示例过程,
说明:同步方案改进
交换原语要求存储器的读写操作是单条不可被中断的指令,一方面给处理提设计带来挑战,另一方面代价也太大(因为需要在此期间锁住对内存的访问)
一种可行的方式是使用指令对,其中第二条指令返回一个表明这对指令是否原子执行的标志值,根据这个标志,程序可以在指令序列被打断后进行重试
下面以ARMv7体系结构的原子操作为例,
此处引入ldrex & strex指令对,并且在判断出指令序列被打断之后进行重试