文章目录
1hello.c
#include <stdio.h>
int main()
{
printf("hello, world")
return 0;
}
1.1信息就是位加上下文
-
信息在计算机系统中的表示方式是通过比特(bit)来实现的。比特是表示信息的最小单位,可以是0或1。八个比特组成一个字节(byte),这是计算机处理数据的基本单位。
-
程序的生命周期从源程序(源文件)开始的,源程序(如hello.c)是程序员通过编辑器创建并保存的文本文件。这些文件本质上是由一串比特(0和1)组成的。根据文件内容的不同,可以将文件分为两类:
-
文本文件:只包含ASCII字符的文件。例如,其他都是二进制文件。源代码文件hello.c就是文本文件。
-
信息的本质是一串比特,而区分不同数据对象的唯一方法在于读取这些比特时的上下文。
1.2程序被其他程序翻译成不同格式
- 源程序到目标程序的步骤:预处理,编译,汇编,链
-
预处理:
预处理器根据 # 开头的命令修改原始的 c 程序。比如根据 #include<stdio.h> 命令把头文件 stdio.h 的内容直接插入到程序文件中,是对文本文件的修改,得到hello.i。 -
编译阶段:
将hello.c翻译成汇编语言的文本文件。得到hello.s。 -
汇编阶段
汇编器(as)将hello.s翻译成机器语言指令打包到可重定位目标程序,得到hello.o(二进制文件)。 -
链接阶段
hello程序中,printf 函数是一个标准 C 库函数,存于 printf.o 中,这是一个单独的预编译好了的目标文件。链接器将printf.o与汇编得到的hello.o(二进制文件)合并就得到可执行目标文件。
注:
- 四个步骤对应的后缀:ciso
- 四个步骤对应的gcc选项:ESco(后面选项包括了前面的步骤,例如-o直接生成可执行文件,./hello即可运行)
- 前两个步骤对应的文件类型是文本类型
- 汇编语言为不同高级语言和编译器提供了通用的输出语言
- 只有机器指令可以被计算机直接运行
1.3 了解编译系统如何工作是大有用处的
- 优化程序性能
- 理解链接时出现的错误
- 避免安全漏洞
1.4 处理器读并解释储存在内存中的指令
- shell 是一个命令行解释器,它输出一个提示符(>>),等待输入一个命令行,然后执行命令。如果输入的第一个单词是可执行文件的名字,就运行该文件。
注:
- 输入第一个单词后面的是相应的选项(char* argv[]),
例如
gcc -o hello hello.c
其他命令也是可执行文件,被配置在相应系统的环境变量文件中)
1.4.1 系统的硬件组成
- 由总线、I/O 设备、处理器、主存储器四个部分组成
-
总线:一次可以传输一个定长的字节块,称为字。64位系统即总线一次可以传输 64 位比特(8字节),64位总线的一个字是 8 字节。32位总线的一个字是 4字节。
-
I/O (输入/输出)设备
每个 I/O 设备通过一个控制器或适配器与 I/O 总线相连。
控制器是 I/O 设备本身或主板上的芯片组,适配器则是一块插在主板上的卡。 -
主存
主存是由一组动态随机存取存储器(DRAM)组成的。
从逻辑上看,存储器是一个线性的字节数组,每个字节都有唯一的地址(就像数组的下标)。 -
处理器(CPU)
处理器是解释存储在主存中指令的引擎。
处理器的核心是一个程序计数器(PC)程序计数器大小为一个字,存储CPU即将执行的下一条指令的地址。(类似指针)
处理器不断执行程序计数器指向的指令。每执行一条,程序计数器更新一次,指向下一条指令。(不一定是下一条) -
处理器会按照指令执行模型(指令集架构)解释指令中的位并执行相应操作。操作是围绕主存、寄存器文件、算数/逻辑单元(ALU)进行的。
寄存器文件:单个字长,有唯一的名字。
ALU(算术/逻辑单元):计算新的数据和地址值。 -
指令集架构:每条机器指令的效果
微体系架构:处理器实际上是如何实现的
磁盘属于i/o设备
1.4.2 运行 hello 程序
- 执行目标文件时,shell 程序将磁盘目标文件中的字符逐个读入寄存器(流),然后放到主存中。之后处理器就开始执行目标文件的机器语言指令。
(利用直接存储器存取(DMA)技术可以不通过寄存器,直接将数据从磁盘到达内存。)
以输出打印 hello world 为例,处理器将 hello world 的字节复制到寄存器文件,再复制到显示设备,最终显示在屏幕上。
1.5 高速缓存至关重要
系统设计者需要使这些复制操作尽可能快的完成。
较大的存储设备比较小的存储设备运行的慢。
高速设备的价格远高于低速设备。
设计者使用高速缓存(cache)用来解决处理器与主存间存取速度的差异。让高速缓存里存放可能经常访问的数据,大部分的内存操作都在高速缓存中快速完成。
1.6 存储设备形成层次结构
套娃高速缓存的思想:上一层的存储器作为低一层的高速缓存。
从上到下,容量更大,运行更慢,每字节价格更便宜。
1.7操作系统管理硬件
所有应用对硬件的操作都必须通过操作系统
操作系统的两个基本功能:
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂的低级硬件设备
操作系统通过三个基本的抽象概念实现这两个功能:
- 文件:对I/O 设备的抽象表示
- 虚拟内存:对主存和磁盘的抽象表示
- 进程:对处理器、主存和 I/O 设备的抽象表示
1.7.1 进程
- 进程:操作系统正在运行的一个程序实体的一种抽象。看起来好像只有这一个程序在运行。
- 并发运行:一个进程的指令和另一个进程的指令是交错执行的。一个系统可以同时运行多个进程,实际上这些进程是并发运行的。
- 操作系统通过上下文切换来实现并发运行。上下文是跟踪进程运行所需的所有状态信息,可能存在于PC、寄存器文件、主存等地方。
任何时刻,单处理器只能执行一个进程的代码。
操作系统内核是操作系统代码常驻主存的部分,从一个进程到另一个进程的转换是由内核管理的。
内核不是一个独立的进程,是系统管理全部进程所用代码和数据结构的集合。
1.7.2 线程
- 一个进程可以由多个线程组成,每个线程都运行在进程的上下文,共享同样的代码和全局数据。
多线程之间比多进程之间更容易共享数据,且线程一般来说比进程更高效。 - 并发:x核n*x线程处理器,将不同的线程分配到多个处理器中,使程序运行更快。
1.7.3 虚拟内存
虚拟内存使每个进程都以为自己独占了主存。每个进程看到的内存都是一致的,即虚拟地址空间。
在linux中,每个进程看到的虚拟地址空间由以下几个部分组成:
地址:… ffff ffff(最大地址)
地址:0000 0000 …(最低地址)
地址从低到高:
程序代码和数据
对所有进程来说,代码都是从同一个固定地址开始,紧接着是与全局变量对应的数据区。代码和数据区都是按照可执行文件的内容初始化的。代码和数据区在进程开始运行时就被指定了大小。
-
堆(向上增长)
而运行时堆是根据 malloc 和 free 函数的调用在运行时动态地扩展和收缩的。 -
共享库
地址空间的中间部分,用来存放像C 标准库、数学库等都属于共享库的代码和数据。内容是映射进来而非这个进程独有。 -
栈
编译器用它来实现函数调用(push/pop)。当调用函数时,栈增长,从函数返回时,栈收缩。栈空间并不算大。 -
内核虚拟内存
应用程序不可读写此区域,但可以申请系统调用,运行内核代码定义的函数,保证计算机安全。
1.7.4 文件
文件就是字节序列,仅此而已。
每个 I/O 设备,包括磁盘、键盘、显示器、网络,都可以看成是文件。
将它们抽象为文件,提供统一的接口。
linux 的“一切皆文件”思想。
1.8 系统之间利用网络通信
现代系统通常通过网络和其他系统连接到一起。
从一个单独的系统来看,网络可以视为一个 I/O 设备。
通过socket文件复制信息到另外一台主机。
1.9 重要主题
系统是硬件和系统软件互相交织的组合体。
1.9.1 Amdahl 定律
Amdahl 定律的主要观点:要优化整个系统,必须提升全系统中最重要的部分和可加速程度最大的部分。
1.9.2 并发和并行
两个需求:1.计算机做的更多。2.运行的更快
并发:一个通用的概念,指一个同时具有多个活动的系统。(比如分屏敲代码)
并行:用并发来使系统运行得更快。
1.9.2.1.线程级并行
传统意义上的并发执行是通过单处理器在进程间快速切换模拟出来的。
超线程又称同时多线程,它允许一个 CPU 执行多个控制流。 CPU 有的硬件有多个备份,比如程序计数器和寄存器文件,而其他硬件只有一份,比如浮点算术运算单元。(多头蛇:脑子上的器官有多个但是身子只有一个)
常规 CPU 需要约 20000 个时钟周期来切换线程,超线程 CPU 可以在单个周期的基础上切换线程,比如一个线程在等待数据装在到高速缓存,CPU 就可以去执行另一个线程。
4 核 8 线程处理器可以让8 个线程都并行执行。
多处理器从两方面提高性能:
- 减少了执行多个任务时,频繁切换,模拟并发的开销。
- 如果程序以多线程方式书写,可以提高运行速度。
1.9.2.2. 指令级并行
-
同时执行多条指令的属性称为指令级并行。
每条指令从开始到结束一般需要 20 个或更多的时钟周期,通过指令级并行,可以实现每个周期 2~4 条指令的执行速率。 -
流水线:将执行一条指令的活动分为不同的步骤,将硬件组织的工作组成一系列的阶段,每个阶段执行一个步骤,而这些阶段可以并行操作。例如分成3个步骤。
1 2 3 1 2 3
1 2 3
如果比一个周期一条指令更快,就称为超标量处理器,现在一般都是超标量。
1.9.2.3. 单指令、多数据并行
在最低层次上,现代处理器允许一条指令产生多个可以并行执行的操作,称为单指令、多数据并行,即 SIMD 并行。这种并行处理方式在处理大量相同类型的数据时非常有效,例如图像处理、信号处理和科学计算。
1.9.3 计算机系统中抽象的重要性
文件是对I/O设备的抽象
虚拟内存是对程序存储器的抽象
进程是对一个正在运行的程序的抽象
虚拟机是对整个计算机的抽象
2信息的表示和处理
-
计算机使用二值信号存储和表示信息(bit)
-
三种最重要的数字表示:
·基于传统的二进制表示法的无符号编码
·常用于表示有符号整数的补码编码
·以2为基数来表示实数的浮点数编码 -
当计算结果太大以至于不能表示时,就会产生溢出。
-
浮点数表示的精度有限,浮点运算是不可结合的。
浮点数由于其精度有限,运算时可能会产生舍入误差。所以浮点运算是不具有结合律的,即
(𝑎+𝑏)+𝑐不一定等于 𝑎+(𝑏+𝑐) -
整数的表示范围小但精确,浮点数表示的范围大但是是近似的。
2.1信息存储
-
大多数的计算机使用8位组成一个字节,作为最小的可寻址的内存单位。尽管计算机处理器可能在更大块(如字、双字)的基础上进行操作,但内存的基本单位是字节。
-
机器级程序不包含关于数据类型的信息:
在机器级别(也称为汇编级别),程序指令只处理原始的二进制数据,不区分数据的类型。数据类型的解释由高级编程语言和编译器来处理的,在机器代码中,所有数据只是字节序列。 -
指针的值是某个存储块的第一个字节的虚拟地址,虚拟地址是一个逻辑地址,由操作系统和硬件共同管理,通过内存管理单元(MMU)映射到物理内存地址。
-
每个程序对象可以视为一个字节块,程序本身就是一个字节序列
2.1.1 十六进制表示法
使用16个符号来表示数值:0-9表示0到9,A-F表示10到15。十六进制表示法的优势在于它与计算机内部使用的二进制表示法之间的转换非常方便。
假设我们有一个十六进制数 0x2F3,我们可以将其转换为二进制数:
0x····2·····F·····3
= 0010·1111·0011
0x2F3=001011110011
将十六进制数转换为十进制数,可以使用权重法,将十进制数转换为十六进制数,可以使用除基取余法。(现实中使用百度法和熟能生巧法)
2.1.2 字数据大小
-
每个计算机有对应的字长,由虚拟地址空间大小决定,所以字长决定了虚拟地址空间的大小。(总线传输的字是单次传输单位的位数,这里的字长是地址空间的位数)
-
32位机器的虚拟地址空间最高为4GB,64 位字长的虚拟地址空间最高16EB,所以32位机器的指针类型长度为4字节,64 位机器的指针类型长度为 8 字节。(现代64位处理器通常并不会使用完整的64根地址线,够用就行)
-
int32_t和 int64_t类型分别为 4 字节和 8 字节,不受机器影响。使用确定大小的整数类型很有用。对 32 位和 64 位机器而言,char、short、int、long long 长度都是一样的,为 1,2,4,8。long 的长度不一样。float 和 double 的长度一样,分别为 4,8
-
char 类型可以是有符号的(signed)或无符号的(unsigned),取决于编译器的实现和具体的使用场景。大多数情况下,程序对 char 是有符号还是无符号并不敏感,除非进行明确的数值比较或运算。
-
使用大小确定的数据类型可以提高程序的可移植性
2.1.3 寻址和字节顺序
-
跨越多字节的对象,其地址是它所使用字节中的最小地址。(起始地址)
-
大端法和小端法
小端法:数字的低位字节存储在内存的低地址处。
大端法:数字的高位字节存储在内存的低地址处。
例如,对于一个32位整数 0x12345678
小端法的存储顺序如下:
地址: 0x00 0x01 0x02 0x03
数据: 0x78 0x56 0x34 0x12
大端法的存储顺序如下:
地址: 0x00 0x01 0x02 0x03
数据: 0x12 0x34 0x56 0x78
-
大多数 Intel 系统使用小端法,理解字节序对于编写跨平台和网络通信程序非常重要。小端法和大端法的选择会影响到数据的存储和传输方式,在涉及多字节数据时,必须明确系统和协议的字节序。通过编写检测字节序的程序,可以确保数据在不同系统之间的一致性和正确性。
-
检测当前系统的字节序
#include <stdio.h>
int main() {
unsigned int x = 0x12345678;
//前面提过,取出的一定是最小的地址,也就是低地址
char *c = (char*)&x;
if (*c == 0x78)
printf("小端机\n");
if (*c == 0x12) {
printf("大端机\n");
return 0;
}
2.1.4 表示字符串
-
C 语言字符串是以 null 字符结尾的字符数组,即 ‘\0’
-
文本数据比二进制数据具有更强的平台独立性
-
ASCII 字符适合英文文档。Unicode(UTF-8)使用 4 字节表示字符,常用的字符只需要 1 或 2 个字节。所有 ASCII 字符在 UTF-8 中是一样的。JAVA 使用 UTF-8 来编码字符串。(C语言也有支持UTF-8的库函数)
2.1.5 表示代码
-
二进制代码是不兼容的,很难在不同机器和操作系统组合下进行移植。
-
从机器的角度看,程序就是一个字节序列。机器没有关于原始源程序的任何信息。
2.1.6 布尔代数
-
布尔代数是在 0 和 1 基础上的定义,主要用于处理二值变量(真(True)和假(False))。
布尔变量:布尔变量取值仅为 True 或 False 的变量,通常用 1 表示 True,用 0 表示 False。 -
布尔运算:布尔代数中的基本运算包括:
与(AND):也称为逻辑乘积,用符号 ∧ 或 · 表示。只有当两个操作数都为 True 时,结果才为 True。
或(OR):也称为逻辑和,用符号 ∨ 表示。只要有一个操作数为 True,结果就为 True。
非(NOT):也称为逻辑否定,用符号 ¬ 或 ! 表示。将 True 变为 False,将 False 变为 True。 -
把字节看作是一个长为 8 的位向量,我们可以对字节进行各种位运算
2.1.7 C 语言中的位级运算
-
C语言支持按位布尔运算
-
按位与(AND)运算符:&
用于将两个数的每一位进行逻辑与运算。
只有当两个对应位都为 1 时,结果位才为 1,否则为 0。 -
按位或(OR)运算符:|
用于将两个数的每一位进行逻辑或运算。
只要有一个对应位为 1,结果位就为 1,否则为 0。 -
按位异或(XOR)运算符:^
用于将两个数的每一位进行逻辑异或运算。
当两个对应位不同,结果位为 1;相同,结果位为 0。 -
按位取反(NOT)运算符:~
用于将一个数的每一位进行取反运算。
0 变为 1,1 变为 0。
2.1.8 C 语言中的逻辑运算
-
逻辑与(AND)运算符:&&
只有当两个操作数都为真时,结果才为真。
如果第一个操作数为假,则不再计算第二个操作数(短路求值)。 -
在这个例子中,由于 a 为 0,表达式 a && (b / a > 1) 中的第二个条件 (b / a > 1) 不会被计算,从而避免了除零的错误。
#include <stdio.h>
int main()
{
int a = 0;
int b = 5;
if (a && (b / a > 1)) printf("ture\n");
else printf("false\n");
return 0;
}
-
逻辑或(OR)运算符:||
只要有一个操作数为真,结果就为真。
如果第一个操作数为真,则不再计算第二个操作数(短路求值)。 -
逻辑非(NOT)运算符:!
将操作数的布尔值取反。
如果操作数为真,结果为假;如果操作数为假,结果为真。
2.1.9 C 语言中的移位运算
- 左移运算符(<<)
左移运算符将一个数的所有位向左移动指定的位数,右边补 0。 - 右移运算符(>>)
右移运算符将一个数的所有位向右移动指定的位数。右移时,左边补 0(逻辑右移)或符号位(算术右移)。
2.2整数表示
- 两种表示方法:无符号表示与补码表示
- 很多语言比如Java 就只支持有符号数,C语言的默认类型是有符号数
2.2.1 整数数据类型
- C语言中的整数类型和定义的每种数据类型所能表示的最小范围。
- 所以在16位机器下,int的大小可能是两字节
- C语言标准定义的数据范围是对称的,实际情况通常是负数范围比正数范围大1
2.2.2 无符号数的编码
- 如果一个整数类型有4个位,我们将其看作是一个4位的位向量
比如:1011这个四位整数
1101 —> [1,1,0,1] = 1 x 2 ^ 3 + 1 x 2 ^ 2 + 0 x 2 ^ 2 + 1 x 2 ^ 0
= 8 + 4 + 0 + 1 = 13;
- 无符号表示、补码表示与数据的映射都是双射,即一一对应。
2.2.3 补码编码
- 补码的定义实际就是将符号位解释为负权。
- 比如:1011这个四位整数
1101 —> [1,1,0,1] = -1 x 2 ^ 3 + 1 x 2 ^ 2 + 0 x 2 ^ 2 + 1 x 2 ^ 0
= -8 + 4 + 0 + 1 = -3;
其实唯一变化的就是第一位数的含义。 - 无符号表示、补码表示与数据的映射都是双射,即一一对应。
2.2.4 有符号数和无符号数之间的转换
- C语言中,这些转换大多是从位级角度而不是数的角度。
所以在有符号数与无符号数之间进行强制类型转换的结果是保持位值不变,只改变解释位的方式。
- 补码 x 转无符号x(x是一个w位的数)
x >= 0时,值不变
x < 0,转换后的值为 2 ^ w + x(也就是一个很大的正数,因为有符号数中的负标志位变成了2 ^ w) - 无符号数 x 转补码
x < 2^(w-1),值不变
x >= 2^(w-1),转换后的值为 x - 2 ^ w
这种情况下,x 的第一位值是1,转换后这个很大的数变成负数.
2.2.5 C 语言中的有符号数和无符号数
- 有符号数
有符号数可以表示正数、负数和零。C 语言中的有符号整数类型包括 signed char、short、int、long 和 long long。(char是模糊的,所以加signed,2.1中有提过)
有符号整数的范围取决于位宽和补码表示。对于一个
𝑤位的有符号整数,其范围是:
−2 ^ (𝑤−1) 到 2 ^ (𝑤−1) − 1(最大负数:1000 0000) - 无符号数
无符号数只能表示非负整数。C 语言中的无符号整数类型包括 unsigned char、unsigned short、unsigned int、unsigned long 和 unsigned long long。
𝑤位的无符号整数,其范围是:
0 到 2 ^ 𝑤 − 1 - C语言的计算规则
整型提升:如果操作数的类型小于 int,提升到 int 或 unsigned int。
无符号数优先:如果一个操作数是无符号的,另一个是有符号的且无符号数的类型大于或等于有符号数的类型,那么有符号数会被转换为无符号数。
2.2.6 扩展一个数字的位表示
- 无符号数的零扩展(Zero Extension),即在最高位前加 0。
- 有符号数的符号扩展(Sign Extension),即在最高位前加最高有效位的值(符号位)。如果这个数本身是正数,符号位也就是0,也就和无符号一样了。
2.2.7 截断数字
- 截断无符号整数:直接丢弃高位的比特。
- 截断有符号整数:截断操作同样是丢弃高位的比特,但需要注意截断后的符号位应与截断前的一致。对这个数取余(模运算)。
2.2.8关于有符号数和无符号数的建议
1.大多数时候,数值运算中很多的隐性转换难以察觉,决不使用无符号数
2. 另一方面,仅仅将字看作位的集合时,无符号数是非常有用的比如Linux系统中的信号就是位图(当作布尔值),一些数学上的模运算等等。
2.3整数运算
2.3.1 无符号加法
- 结果(正)溢出或正常
- C 语言不会将溢出作为错误发出信号
- 溢出的结果:结果会模上 2 ^ w ,最终结果小于两个加数
- 两个无符号整数 𝑎和 𝑏,如果 𝑎+𝑏 小于 𝑎或𝑏,则发生了溢出。
if (result < a || result < b)
2.3.2 补码加法
- 结果正溢出,正常,负溢出
- 如果两个正数相加结果为负数,则发生正溢出。
如果两个负数相加结果为正数,则发生负溢出。
2.3.3 补码的非
- 非(NOT)运算是一种简单的按位取反操作,非运算会将其二进制表示的每一位从0变为1,1变为0。
- 补码非的位级表示:对每一位求补(就是取反,0变1,1变0),结果再加 1
- 计算补码非的第二种方法:假设 k 是最右边的 1 的位置,对 k 左边的所有位取反
2.3.4 无符号乘法
- 结果需要2w位来表示才能不溢出
- C语言中无符号乘法的结果 (x * y) %(2 ^ w)(发生截断,2.2.7.2)
2.3.5 补码乘法
- 确定操作数的符号:检查两个操作数的符号位。符号不同,结果为负;符号相同,结果为正。
- 将操作数转换为正数:如果操作数是负数,将其转换为正数(取补码:取反并加1)。
- 执行无符号乘法:对转换后的正数进行无符号乘法。
- 处理结果的符号:根据1确定的结果符号,如果结果应为负数,将乘积取补码。
- 可以认为补码乘法和无符号乘法的位级表示是一样的
2.3.6 乘以常数
- 大多数机器上,整数乘法需要 10 个或更多的时钟周期,而加法、减法、位级运算和移位只需要 1 个时钟周期
- 编译器尝试用移位和加法或减法运算的组合来代替常数因子的乘法,以提高效率。
左移 k 位等于乘以 2^k 的无符号乘法
x * 14 = (x<<3) + (x<<2) + (x<<1)
y * 14 = (x<<4) - (x<<1)
x * 14 = (x <<3)+(x<<2)+(x<<1) = (x<<4) -(x<<1)
判断如何移动的方式很简单:14 的位级表示为 1110,所以分别左移 3,2,1。或者取反14表示为0001,左移4减去左移1。
2.3.7 除以 2 的幂
- 大多数机器上,整数除法更慢,需要 30 个或更多的时钟周期。
- IEEE754标准的四种舍入模式
向偶数舍入:四舍五入优先,xx.5向偶数舍入
4.6 = 5,-4.5 = -4。
向0舍入:直接截尾即可,4.5=4,-4.5=-4。
向上舍入:正数进位,负数截位。4.1=5,-4.9=-4。
向下舍入:负数进位,正数截位。4.9=4, -4.1=-4;
-
无符号数的右移运算
逻辑右移操作将数的所有位向右移动,并在左边补 0。
例如5>>1 (0000 0101>>1) = 0000 0010 = 2;
对于正数,算术右移和逻辑右移的效果是相同的,因为高位补 0 不影响结果。但对于负数,算术右移保留了符号位(即最高位),这意味着结果将保持负数的性质,并且向下舍入(和5/2的结果一致)。 -
有符号数的算术右移运算
算术右移操作将数的所有位向右移动,并在左边补上符号位(即原来的最高位)。对于有符号数,算术右移的结果相当于进行除法运算后向下舍入(即向负无穷舍入)。
假设我们有一个负数 x=−5:
x=−5 的补码表示是1111 1011
x>>1(算术右移 1 位):1111 1011→1111 1101 结果是十进制的 -3,正好是 并向下舍入。 -
向零舍入:使用公式
(x+(1<<k)−1)>>k 可以实现除法运算并向零舍入。
这个公式的作用是将数加上一个偏移量,然后再进行右移操作,从而实现向零舍入的效果。这个偏移量是 (1<<k)−1,它在右移操作之前调整了数值,使得结果向零舍入。
假设我们有一个有符号数
x=−5 并且 k=1:
(x+(1<<k)−1)>>k=(−5+2−1)>>1=−4>>1
−4 的补码表示是 1111 1100
−4>>1(算术右移 1 位): 1111 1100→1111 1110 结果是十进制的 -2,这与
−5÷2并向零舍入的结果一致。
2.3.8 关于整数运算的最后思考
- 补码使用了与无符号算术运算相同的位级实现,包括加法、减法、乘法甚至除法。都有完全一样或非常类似的位级行为
- 不同语言对整数的定义并不完成相同,比如JAVA中没有无符号的整数,python和c在负数除法的取整方式不同,使用过程中需要注意消除这些隐性的差别带来的不确定性结果。
2.4 浮点数
- 浮点表示对形如V = x * 2 ^ y的有理数进行编码, 对于非常大,非常接近零,近似值计算是很有用的。
2.4.1 二进制小数
- 二进制小数是使用二进制(基数为2)表示的小数。与十进制小数类似,二进制小数由整数部分和小数部分组成,但它们的基数是2,而不是10。
- 小数的二进制表示法只能表示那些能写为 x * 2 ^ w 的数,其他的值只能近似表示。
2.4.2 IEEE 浮点表示
- IEEE 浮点标准的表示形式为:V = (-1) ^ S * M * 2 ^ E,它分为三部分:
-
- 符号:S 决定是负数还是正数
-
- 尾数:M是一个二进制小数,范围是 1~2-ε 或 0~1-ε(一个很小的数(0)到一个很接近1的数)
-
- 阶码:E 的作用是对浮点数加权
- 对这些值编码:
- 一个单独的符号位编码直接编码 S
- k 位的阶码字段 exp 编码 E;float 中 k=8,double 中 k=11
- n 位的小数字段 frac 编码 M;float 中 n=23,double 中 n=52
- 根据exp的值分为三种情况:
- 规格化的值:阶码E(exp)字段即不全为 0 也不全为 1 时属于规格化值(0001~1110)
阶码字段解释方式:E = exp - (2^(k-1)-1);
也就是阶码的值是:E = exp - Bias(偏置值)
其中exp是无符号数,Bias在单精度是127,双精度是1023。
小数字段解释方式:frac被解释为f,(0 <= f <1)。
尾数M = 1 + f(隐含的以一开头的表示,获得一个额外的精度位) - 非规格化的值:非规格化值(也称为次正规化值)当阶码字段全为0时的特殊情况。并且阶码 E 的值被解释为1 - Bias,而不是0 - Bias。
阶码字段解释方式:E = 1 - (2^(k-1)-1) ,与规格化值中 e = 1 时的 E 相同
小数字段解释方式:M = f(失去隐藏的1开头,与e = 1时的值刚好相差一位精度)
- 总的来说,非规格化值是浮点数表示中的一种特殊形式,首先用于表示非常小的数值(0和接近0.0的数)。还能避免数值突然下落到零,并在接近零的范围内提供更高的精度。(逐渐溢出)
- 特殊值:阶码字段全为 1的前提,分两种情况:
小数字段全为 0:表示无穷(根据S判断是正无穷还是负无穷)
小数字段非零:表示NaN(Not a Number)。比如 ∞-∞ 的结果就返回 NaN
2.4.3数字示例
- 最大非规格化数到最小规格化数的过渡是平滑的。
通过将E定义为1 - Bias 而不是 - Bias,补偿非规格化数的尾数没有隐藏的1(与2.4.2.2相印证) - 浮点数能够使用正数排序函数来排序,即浮点数的位级表示当用整数方式来解释时是顺序的(正数升序负数降序)。
- 浮点数可表示的数的分布是不均匀的,越接近零时越稠密
- 浮点数的尾数M决定了表示的精度,阶码E决定了表示的范围大小
2.4.4 舍入
- IEEE754标准的四种舍入模式
向偶数舍入:四舍五入优先,x.5向偶数舍入,4.6 = 5,-4.5 = -4。
向0舍入:直接截尾即可,4.5=4,-4.5=-4。
向上舍入:正数进位,负数截位。4.1=5,-4.9=-4。
向下舍入:负数进位,正数截位。4.9=4, -4.1=-4;
默认的方法是找到最接近的匹配,其他三种可用于计算上下界。
向偶数舍入可以计算一组数的平均数时避免统计偏差。
2.4.5 浮点运算
- 浮点运算的结果需要进行舍入(可能丢失一定精度),运算单元会适当偷懒,保证计算结果得到一个正确的舍入结果即可。
-IEEE 标准中1/-0 = -∞,1/+0 = +∞ - 浮点运算可交换不可结合。如果 a>=b,则 x+a >= x+b
- 浮点乘法在加法上不具备分配性,满足以下单调性(不为NaN)
a>=b且c>=0 —> ac >= bc
a>=b且c<=0 —> ac <= bc - 缺乏结合性和分配性会使一些简单问题变得很复杂,编译器优化浮点运算时大多倾向于保守,即使是很轻微的影响。
2.4.5.1浮点加法的步骤
- 对齐指数(Exponent Alignment)
比较两个浮点数的指数,使它们具有相同的指数。这通常需要将指数较小的那个数的尾数右移(即除以2的幂,因为大数迁就小数大概率损失精度更多),直到两个数的指数相同。 - 尾数相加(Mantissa Addition)
在指数对齐后,直接对尾数进行加法或减法(取决于符号位)。在此过程中,需要考虑尾数的符号位。 - 规格化(Normalization)
加法后的结果可能不是规格化的浮点数。规格化的浮点数要求尾数的最高有效位(左边隐藏的第一位)为1。规格化过程包括:
如果尾数的最高有效位不是1,则需要左移尾数并相应地减少指数(阶码E)。
如果尾数的最高有效位是1,则可能需要右移尾数并相应地增加指数。 - 舍入(Rounding)
由于尾数的位数有限,加法后的尾数可能需要舍入(四种舍入方式)。 - 处理特殊情况
在浮点加法中,还需要处理一些特殊情况,例如:
上溢(Overflow):结果超出浮点数的表示范围。
下溢(Underflow):结果小于浮点数的最小可表示值。
非数(NaN):例如,两个无穷大的操作数相减。
无穷大(Infinity):例如,一个有限数加上无穷大。
2.4.5.2浮点乘法的步骤
- 符号位处理(Sign Calculation)
首先,确定结果的符号。两个浮点数的符号位逻辑乘(异或操作)得到最终的符号位。 - 指数相加(Exponent Addition)
去掉两个数阶码的偏移量,得到指数,将两个浮点数的指数相加,并减去一个偏移量(bias)。
在IEEE 754标准中,单精度浮点数的偏移量为127,双精度浮点数的偏移量为1023。 - 尾数相乘(Mantissa Multiplication)
将两个浮点数的尾数相乘。尾数通常是隐含一个1的(即隐含的最高有效位为1),所以在实际计算时需要考虑这一点。 - 规格化(Normalization)
乘法后的结果可能不是规格化的浮点数。规格化过程同加法运算。 - 舍入(Rounding)
由于尾数的位数有限,乘法后的尾数可能需要舍入(四种舍入方式)。 - 处理特殊情况
同加法运算。
2.4.6 C 语言中的浮点数
#include <stdio.h>
#include <math.h>
#include <fenv.h>
int main()
{
float f = 3.14f;//单精度
double d = 3.14;//双精度
long double ld = 3.14L;//不小于double的精度,通常为12个字节(96位)或16个字节(128位)
double pos_inf = INFINITY;//无穷大
double neg_inf = -INFINITY;//无穷小
double nan_val = NAN;//NaN
// 设置舍入模式为向零舍入
fesetround(FE_TOWARDZERO);
// 获取当前舍入模式
int rounding_mode = fegetround();
return 0;
}
- 类型转换
- int 到 float
不会溢出:float的范围通常比int大,因此int到float的转换不会导致溢出。
可能舍入:由于float的精度有限,某些大的整数在转换为float时可能会丢失精度。 - int 或 float 到 double
不会溢出:double的范围和精度都比int和float大,因此这种转换不会导致溢出。
不会舍入:由于double具有更高的精度,int或float转换为double时不会丢失精度。 - double 到 float(大转小)
可能溢出:double的范围比float大,因此double值在转换为float时可能会溢出。
可能舍入:由于float的精度低于double,double值在转换为float时可能会丢失精度。 - float 或 double 到 int
向零舍入:转换时会向零舍入,即截断小数部分。
可能溢出:如果浮点数的值超出了int的表示范围,会导致溢出。
接近零时也可能溢出:例如,负的浮点数在转换为int时可能会得到一个负整数。
溢出结果:如果转换导致溢出,结果是未定义的,可能会得到一个意外的值(Inter微处理器的[10…00],但这取决于具体的实现和平台)。
2.5小结
- 计算机中的整数和浮点数本质上也是一位位的数据,不同语言中也有不同的特性结合本身的隐藏的特性,编程时一定要避免不确定性的行为,小心类型转换和浮点数的运算。
3 程序的机器级表示
- 计算机执行机器代码,编译器基于编程语言的规则、目标机器的指令集,操作系统遵循的惯例生成机器代码。
- 汇编代码是机器代码的文本表示。高级代码可移植性较好,而汇编代码与特定机器密切相关。
- 现在不要求使用汇编语言编制程序,能够阅读和理解编译器转化的汇编语言的细节和方式,并分析代码中隐含的低效率。
- 精通细节是理解更深和更基本概念的先决条件。
3.1 历史观点
- Intel处理器系列俗称x86,每个后续处理器都是向下兼容的(所以指令集中会有一些奇怪的东西),x86(64位)。
- 摩尔定律: 晶体管数目18个月翻一番。
3.2 程序编码
- 使用较高级别优化的代码会严重变形(和源代码的格式),机器代码和初始源代码之间的关系难以理解。实际中,从程序性能考虑,较高级别的优化是较好的选择(O2用的比较多)。
- 汇编器产生的目标代码是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。链接之后才形成可执行代码,可执行代码是机器代码的第二种形式。
3.2.1 机器级代码
- 对机器级编程尤为重要的两种抽象
1.指令集架构:定义了处理器状态、指令的格式、指令对状态的影响。
- 虚拟地址:机器级程序使用虚拟地址,即将内存看成一个按字节寻址的数组。
- 一些通常对语言级隐藏的处理器状态(机器级可见)
- 程序计数器(PC):下一条执行指令的地址
- 整数寄存器文件:保存临时数据或重要的程序状态
- 条件码寄存器:最近执行的算术或逻辑指令的状态信息
- 一组向量寄存器:保存一个或多个整数或浮点数值
- 机器代码和汇编代码中不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数。
- 因为虚拟内存的大小通常比较大,程序实际使用和访问的内存大小通常远小于虚拟内存看起来的大小。所以在任意的时刻,只有有限的虚拟内存是合法的,操作系统负责管理虚拟内存(通过表翻译为实际的物理地址)。
- 一条机器指令只执行一个非常基本的操作。
3.2.2 代码示例
#include <stdio.h>
// 声明 multstore 函数
void multstore(long x, long y, long *dest);
// 声明 mult2 函数
long mult2(long a, long b);
int main() {
long d;
multstore(2, 3, &d);
printf("2 * 3 --> %ld\n", d);
return 0;
}
// 定义 multstore 函数
void multstore(long x, long y, long *dest) {
*dest = mult2(x, y);
}
// 定义 mult2 函数
long mult2(long a, long b) {
long s = a * b;
return s;
}
gcc -S a.c -o multstore.s
//部分汇编,不同优化等级和环境产生的不一样
//这个和书上差别有亿点大
63 mult2:
64 .LFB2:
65 .cfi_startproc
66 pushq %rbp
67 .cfi_def_cfa_offset 16
68 .cfi_offset 6, -16
69 movq %rsp, %rbp
70 .cfi_def_cfa_register 6
71 movq %rdi, -24(%rbp)
72 movq %rsi, -32(%rbp)
73 movq -24(%rbp), %rax
74 imulq -32(%rbp), %rax
75 movq %rax, -8(%rbp)
76 movq -8(%rbp), %rax
77 popq %rbp
78 .cfi_def_cfa 7, 8
79 ret
80 .cfi_endproc
- -S选项产生汇编代码
- 反汇编是根据机器代码反推出汇编的,逆向和一些安全漏洞分许就会用到这个
- 机器代码与反汇编表示的特性:
- x86-64 的指令长度范围为 1~15 字节。常用指令和操作数少的指令所需字节少。
- 指令格式设计方式为:可以将字节唯一的解码成机器指令。
- 反汇编器基于机器代码文件中的字节序列确定汇编代码,与源代码和编译时的汇编代码无关
- 指令结尾的 ‘q’ 是大小指示符,大多数情况下可以省略。
- 从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。
3.2.3 关于格式的注解
81 .LFE2:
82 .size mult2, .-mult2
83 .ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
84 .section .note.GNU-stack,"",@progbits
像这样的汇编代码,以 ‘.’ (点) 开头的行是指导汇编器和链接器工作的伪指令。我们一般忽略它们。
- 在汇编语言中,Intel 和 AT&T 是两种主要的语法格式它们在指令格式、操作数顺序、寄存器命名等方面有显著的区别。
- Intel 语法: 目的操作数在前,源操作数在后。
AT&T 语法: 源操作数在前,目的操作数在后。 - 操作数大小
Intel 语法: 操作数大小由操作码决定,不需要额外的后缀。
AT&T 语法: 使用后缀来指明操作数大小(b 表示字节,w 表示字,l 表示双字,q 表示四字)。 - 寄存器命名
Intel 语法: 寄存器名称直接使用。
AT&T 语法: 寄存器名称前面加 % 符号。 - 立即数
Intel 语法: 立即数不需要前缀。
AT&T 语法: 立即数前面加 $ 符号
- 还有一些符号上的小差距,总的来说两者操作数顺序恰好相反,
- 我个人觉得Intel语法在许多方面更加简洁
- 有些C语言访问不到的机器特性,我们可以考虑包含(asm伪指令)或者链接一部分汇编指令来优化程序。
3.3 数据格式
汇编代码指令最后一个字符的后缀:movb, movw, movl, movq。
这里说的都是整数,浮点数使用一组完全不同的指令和寄存器,“l”既可以表示四字节整数,也可以表示8字节的双精度浮点数。
3.4 访问信息
- 名称
- 起初的8086只有8个16位的寄存器:%ax到%bp (r是特殊的栈指针)
- 后面IA32架构,扩展至32位,前缀一个e,也就是%eax到%ebx
- x86-64架构,扩展至16个64位,自带一个r,大小由尾缀决定,编号也挺草率的,几个版本主打一个风格迥异。
- 低位操作的规则:
- 将寄存器作为目标位置时,生成字节和字的指令会保持剩下的字节不变。(放字节,字(2字节)的时候就不改其他位的值了)
- 生成双字的指令会把高位四字节置为 0。(32位扩展的一部分内容)
- 16个寄存器的作用
a:返回值
s:栈指针
d, s, d, c, 8, 9:第 1 到第 6 个参数
b,bp, 12~15:被调用者保存
10, 11:调用者保存
3.4.1 操作数指示符
- 三种主要的操作数类型:
- 立即数 (Immediate),表示常数值
考研好像是Intel格式
//Intel 语法示例
mov eax, 10 ; 将立即数 10 移动到寄存器 eax
add eax, 5 ; 将立即数 5 加到寄存器 eax
//AT&T 语法示例
movl $10, %eax ; 将立即数 10 移动到寄存器 eax
addl $5, %eax ; 将立即数 5 加到寄存器 eax
- 寄存器 (Register),使用寄存器中的全部位或者低位的内容
//Intel 语法示例
mov eax, ebx ; 将寄存器 ebx 的值移动到寄存器 eax
add eax, ecx ; 将寄存器 ecx 的值加到寄存器 eax
//AT&T 语法示例
movl %ebx, %eax ; 将寄存器 ebx 的值移动到寄存器 eax
addl %ecx, %eax ; 将寄存器 ecx 的值加到寄存器 eax
- 内存引用 (Memory Reference),寻址,可以是直接地址、间接地址或基于寄存器的地址计算。带了()或者[],和解引用指针很像。
//Intel 语法示例
mov eax, [ebx] ; 将内存地址 [ebx] 的值移动到寄存器 eax
mov [ecx + 4], edx ; 将寄存器 edx 的值移动到内存地址 [ecx + 4]
add eax, [esi + edi*4] ; 将内存地址 [esi + edi*4] 的值加到寄存器 eax
//AT&T 语法示例
movl (%ebx), %eax ; 将内存地址 (%ebx) 的值移动到寄存器 eax
movl %edx, 4(%ecx) ; 将寄存器 edx 的值移动到内存地址 4(%ecx)
addl (%esi, %edi, 4), %eax ; 将内存地址 (%esi, %edi, 4) 的值加到寄存器 eax
- 最后一种最常用也最重要(其他格式是它的一个特例)
- Imm(rb, ri, s)
- Imm(立即数偏移) + R[rb] (基址) + R[ri] (变址)s (比例因子)
- s 只能是 1,2,4,8 中的一个
3.4.2 数据传送指令
- 简单的四种mov指令
movb, movw, movl,movq:传送字节、字、双字、四字 - movabsq(move absolute quadword):传送绝对的四字。用于将一个 64 位的立即数传送到一个 64 位寄存器中。用于初始化寄存器或处理大数,机器码九字节(1+8),较大。
- mov的五种组合:
- 立即数到寄存器 (Immediate to Register)
将一个立即数传送到一个寄存器中。 - 立即数到内存 (Immediate to Memory)
将一个立即数传送到一个内存位置中。 - 寄存器到寄存器 (Register to Register)
将一个寄存器的值传送到另一个寄存器中。 - 内存到寄存器 (Memory to Register)
将一个内存位置的值传送到一个寄存器中。 - 寄存器到内存 (Register to Memory)
将一个寄存器的值传送到一个内存位置中。
- 示例
; Intel 语法
; 立即数到寄存器
mov eax, 10
; 立即数到内存
mov [var1], 20
; 寄存器到寄存器
mov ebx, eax
; 内存到寄存器
mov ecx, [var1]
; 寄存器到内存
mov [var2], ebx
; AT&T 语法
; 立即数到寄存器
movl $10, %eax
; 立即数到内存
movl $20, var1
; 寄存器到寄存器
movl %eax, %ebx
; 内存到寄存器
movl var1, %ecx
; 寄存器到内存
movl %ebx, var2
- 将较小的源值复制到较大的目的地使用movz或者movs
他们的后缀字符第一个指定源的大小,第二个指定目的大小
movz,将剩余部分填充为0。
movs,将剩余部分填充为符号位。
3.4.3 数据传送示例
- 3.4.2已经示范差不多了
- 局部变量通常保存在寄存器中。
- 函数返回指令 ret 返回的值为寄存器 rax 中的值
- 强制类型转换可通过 mov 指令实现的。
- 当指针存在寄存器中时,a = p 的汇编指令为: mov (rdi), rax
3.4.4 压入和弹出栈数据
- 栈:向下增长(所以压栈时减[%rsp]),后进先出
- push:压栈
- pop:出栈
- %rsp:(64位) 栈指针,栈顶元素的地址
- 指令尾缀代表操作的大小(bwlq)
- 其实压栈操作等价于先减栈指针值,再将指定寄存器的值写入栈,反之,出栈先读出栈顶数据到指定寄存器,在加栈指针的值。而push,pop只被编码为一个字节即可完成这两步需要8个字节指令大小的操作。
- 使用 mov 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。
3.5 算术和逻辑操作
- 操作相同操作数大小不同:大多数操作都分成了指令类(操作不同大小操作数的变种)。
比如ADD由addb,addw,addl,addq四条加法指令组成。 - 算术和逻辑操作根据作用和形式分为四种:
- 加载有效地址
leaq S, D:将 S 的地址保存到 D 中,D 必须是寄存器 - 一元操作(一个操作数)
- 二元操作(加减乘,与或异或,没有除法)
- 移位
3.5.1 加载有效地址
- leaq 实际上是 movq 指令的变形。LEA(Load Effective Address)指令在x86汇编语言中用于计算内存地址并将结果存储在寄存器中。
例如
LEA EAX, [EBX + 4]
这个指令将EBX寄存器的值加4,然后将结果存储到EAX寄存器中。
- 编译器发现leaq 可以灵活的描述普通的运算操作。目的操作数必须是一个寄存器。
例如
leaq 7(rdi, rsi, 3), rax 注:x in rdi,y in rsi
此操作实际上等于将 x+3*y+7 的结果存入目标寄存器rax中。
3.5.2 一元和二元操作
- 一元操作中的操作数既是源又是目的
- 二元操作中的第二个操作数既是源又是目的(intel x86格式是第一个)。(C语言中的x-=y)
sub s,d —> d-=s - 第一个操作数可以是立即数,寄存器,内存位置。第二个操作数不是寄存器而是内存地址时,要先从内存读出值,执行操作后再把结果写回去。
3.5.3 移位操作
- 左移SAL SHL,效果相同,右边填0
- 算术右移SAR 逻辑右移SHR
- 先给出移位量,第二项给出要移位的数
salq $4, %rax 将寄存器rax中的值左移4位
sarq %cl, %rax 算术右移(寄存器%cl的值)位
- 当移位量大于目的数的长度时,移位量由寄存器的低位值来作为真实的移位量(salb最多移7位,salw最多15位。)
3.5.4讨论
- 大多数指令既可以用于无符号数也可以用于补码的运算,只有右移运算区分有符号数,这个特性使补码运算成为有符号数整数运算的原因之一。
- 汇编代码指令和C简单的源代码对应很紧密
long arith(long x, long y, long z)
{
long t1 = x ^ y;
long t2 = z * 48;
long t3 = t1 & 0x0F0F0F0F;
long t4 - t2 -t3;
return t4;
}
arith:
# 参数 x 在 %rdi
# 参数 y 在 %rsi
# 参数 z 在 %rdx
xorq %rsi, %rdi # t1 = x ^ y
leaq (%rdx,%rdx,2), %rax# 3 * z
salq $4, %rax # t2 = 16*(3z)
andq $0x0F0F0F0F, %rdi # t3 = t1 & 0x0F0F0F0F
subq %rdi, %rax # return t2 - t3
ret # 返回值在 %rax 中
3.5.5 特殊的算术操作
- 两个 64 位数的乘积需要 128 位来表示,x86-64指令集可以有限的支持对 128 位数的操作,包括乘法和除法。
- 128 位数需要两个寄存器来存储,移动时也需要两个 movq 指令来移动扩展时对于有符号数和无符号数采用了不同的指令(cqto有符号)。
- 小端机器(高位高地址),乘积放在了指针指向的16字节处。别忘了汇编也要取决于具体机器和指令集,汇编反映了这个计算的过程,细节实现取决于更底层(好像有个五级,M4高级语言级,M3汇编级,M2操作系统级,M1机器语言级,M0微指令级)。
#include <inttypes.h>
typedef unsigned __int128 uint128_t;//GCC提供的128位整数支持
void store_uprod(uint128_t *dest, uint64_t x, uint64_t y) {
*dest = x * (uint128_t) y;
}
void store_uprod(uint128_t *dest, uint64_t x, uint64_t y)
dest in %rdi, x in %rsi, y in %rdx
1 store_uprod:
2 movq %rsi, %rax // 将 x 复制到乘数寄存器
3 mulq %rdx // 乘以 y
4 movq %rax, (%rdi) // 将低 8 字节存储到 dest
5 movq %rdx, 8(%rdi) // 将高 8 字节存储到 dest+8
6 ret
- 128位除法:idivl将寄存器%rdx和%rax中的128位作为被除数,除数作为指令的操作数给出,商存在%rax,余数存在%rdx中
- 64位除法:除数放在%rax,%rdx的位全0代表无符号运算,否则根据%rax中的符号位决定,商存在%rax,余数存在%rdx中
void remdiv(long x, long y,
long *qp, long *rp) {
long q = x / y;
long r = x % y;
*qp = q;
*rp = r;
}
void remdiv(long x, long y, long *qp, long *rp)
x in %rdi, y in %rsi, qp in %rdx, rp in %rcx
1 remdiv:
2 movq %rdx, %r8 // 复制 qp 到 r8
3 movq %rdi, %rax // 将 x 移动到除数的低 8 字节
4 cqto // 将低 8 字节符号扩展到高 8 字节
5 idivq %rsi // 用 y 除以 x
6 movq %rax, (%r8) // 将商存储到 qp
7 movq %rdx, (%rcx) // 将余数存储到 rp
8 ret
- 乘法结果大(指针/地址),除法初始大(两个寄存器)
3.6 控制
- 程序的顺序结构是直线代码,也就是指令一条接着一条执行。
选择和循环结构要求有条件的执行 - 机器代码提供两种低级机制来实现有条件的行为:
1.条件指令:测试数据值,然后根据测试的结果来改变控制流或数据流
2.跳转指令:使用 jump 指令进行跳转
3.6.1 条件码
- CPU维护着一组单个位的条件码寄存器,描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令。
- 最常用的条件码:
CF:进位标志, 最近的操作使最高位产生了进位。可以用来检查无符号数的溢出
ZF:零标志。 最近的操作的结果为 0
SF:符号标志。 最近的操作的结果为负数
OF:溢出标志。 最近的操作导致了补码溢出 - leaq 指令不改变条件码,其余的所有算术和逻辑指令都会设置条件码。
- 两类特殊指令,只设置条件码不更新目的寄存器:
1.CMP:除了不更新目的寄存器外与 SUB指令的行为相同,可以用来比较操作数的大小关系
2.TEST:除了不更新目的寄存器外与 AND指令的行为相同,可用来比较两个操作数是否相等
3.6.2 访问条件码
- 通常不直接读取条件码,使用条件码的三种方法:
1.根据条件码的某种组合,将一个字节设置为0或者1
2.条件跳转到程序的某个其他部分
3.有条件地传送数据 - SET指令(中的一类),其不同后缀表示它们考虑的条件码组合。(条件码由CMP,TEST指令或者其他算术逻辑运算产生)目的操作数是低位单字节寄存器元素之一或一个字节的内存位置。set 会将该字节设置为 0 或 1
- 后缀及设置条件记忆:
- set:e,相等设0,s,为负数设1,加n就是非0非负数的意思
- setn:g,a大于等于,l,b小于等于,加e代表仅小于,a,b无符号
- 比较大小的条件组合指令也需要分别处理有符号和无符号操作。
3.6.3 跳转指令
- 有条件的跳转指令和SET指令的尾缀差不多
- 直接跳转:跳转目标作为指令的一部分,汇编中”jump.print“,单纯的程序跳转,不依赖栈(call和ret)
- 间接跳转:跳转目标从内存或寄存器中读出,汇编中”jump *%rax“,软链接库和浮动程序
- 条件跳转只能是直接跳转
3.6.4 跳转指令的编码
- 跳转指令最常用的是PC相对的(PC-ralative):根据程序计数器(PC)的当前值,结合偏移量计算目标地址。第二种是给出”绝对地址“
- 相对寻址的例子:跳转至L2.判断条件,跳转至L3,循环
movq %rdi, %rax
jmp .L2
.L3:
sarq %rax
.L2:
testq %rax, %rax
jg .L3
rep; ret
- 反汇编:
0: 48 89 f8 mov %rdi,%rax
3: eb 03 jmp 8 <loop+0x8> #跳转至8
5: 48 d1 f8 sar %rax
8: 48 85 c0 test %rax,%rax
b: 7f f8 jg 5 <loop+0x5> #跳转至5
d: f3 c3 repz retq #这里的rep是空操作避免ret成为条件跳转指令的目标而不能正常返回
被链接后,指令会被重定位到不同的位置,通过PC跳转仍然可以对应,目标代码无需改变就可以移动到内存的其他位置。
3.6.5 用条件控制来实现条件分支
- 结合有条件跳转和无条件跳转实现条件表达式
- C语言中的goto语句类似于汇编中的无条件跳转
- 在 C 语言中,if-else 语句的通用形式模板如下:
这test-expr取值为 0或者为非 0。两个分支语句中(then-statement 或 else-statement)只会执行一个。
if (test-expr)
then-statement
else
else-statement
- 汇编实现通常会使用下面这种形式,这里,我们用 C 语法来描述控制流:
t = test-expr;
if (!t)
goto false; //有条件
then-statement
goto done; //无条件
false:
else-statement
done:
3.6.6 用条件传送来实现条件分支
- 控制的条件转移:条件满足时沿一条执行路径执行,否则走另一条路径
- 数据的条件转移:根据特定条件决定数据的传输或赋值
提前将两种结果的数据计算好,根据条件是否满足用一条简单的条件传送指令来实现它,这样更符合现代处理器的性能特性 - 流水线(4,5章),一条指令的处理需要经过一系列的阶段,重叠连续指令来获得高性能,遇到分支时需要分支确定后才决定往哪边走
- 处理器采取精密的分支预测逻辑来猜测跳转指令是否执行(90%以上),如果预测失误就将浪费15-30个时钟周期,容易预测时调用函数大约8个周期,随机时大约是17.5周期
- 提前将数据准备好,即使准备数据需要时间,使控制流不依赖于数据,使处理器保持流水线是满的
- 条件传送指令(CMOV)允许在不使用条件跳转的情况下,根据某个条件来决定是否进行数据传送。
- C语言例子
//三目运算符
v = test-expr ? then-expr : else-expr;
//条件控制
if (!test-expr)
goto false;
v = then-expr;
goto done;
false:
v = else-expr;
done:
//条件传送
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;
- 虽然条件传送与现代处理器更契合,但需要关注两个注意事项
- 我们对then-expr,else-expr都进行了计算而他们可能产生错误条件或者副作用例如,我们判断指针是否为空,然后进行引用计算。。。。
- 如果对两个分支的求值需要大量计算,那开销比条件控制还大,编译器需要考虑这些,但编译器不具有充足的信息支持它做出正确判断只有表达式非常易于计算时,才会使用条件传送。编译器是趋向保守的,许多预测错误的开销大于较复杂的计算时,GCC还是使用条件控制
3.6.7 循环
1. do-while:执行循环体,测试表达式。测试为真,再执行一次循环。
//do-while 语句的通用形式
do
body-statement
while (test-expr);
//通用形式可以被翻译成如下所示的条件和 goto 语句
loop:
body-statement
t = test-expr;
if (t)
goto loop;
- 逆向工程循环
理解汇编代码与原始源代码的关系,关键是找到程序值和寄存器之间的映射关系。对复杂的程序来说,编译器常常会重组计算,有些C代码中的变量在机器代码中没有对应的值;而有时,机器代码中又会引入源代码中不存在的新值。此外,编译器还常常试图将多个程序值映射到一个寄存器上,来最小化寄存器的使用率。
逆向工程循环的一个通用策略。看看在循环之前如何初始化寄存器,在循环中如何更新和测试寄存器,以及在循环之后又如何使用寄存器。这些步骤中的每一步都提供了一个线索,组合起来就可以解开谜团。其中有些情况很明显是编译器能够优化代码(指令重排,内联函数),而有些情况很难理解编译器为什么要使用那些奇怪的策略。GCC的有些变化,非但不能带来性能好处,反而可能降低代码性能。(寄存器分配)
2. while:测试表达式,执行循环体。测试表达式,测试为真,再执行一次循环。
//while 语句的通用形式
while (test-expr)
body-statement
//1.jump to middle
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
//2.guarded-do(优化级别O1)
//初始条件不成立就跳过,编译器常常可以优化初始的测试(提前知道第一次是否满足)
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if(t)
goto loop;
done:
3. for循环:初始表达式,测试条件,更新表达式
//for循环的通用形式
for(init-expr; test-expr; update-expr)
body-statement
//等价于
init-expr;
while (test-expr){
body-statement
update-expr;
}
//1.jump to middle
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if (t)
goto loop;
//2.guarded-do(优化级别O1)
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if(t)
goto loop;
done:
- for循环中的continue通常是goto实现,跳转到update部分,防止死循环
- C语言中的三种形式的所有循环,都可以用一种简单的策略实现,产生一个或多个条件分支的代码。控制的条件转移提供了将循环翻译成机器代码的基本机制。
3.6.8 switch语句
- switch根据一个整数索引值进行多重分支,通过使用**跳转表(jump table)**使实现更高效,跳转表是一个数组,第i项是一个代码段的地址,当开关(分支)数量较多,值的跨度较小时,就会使用跳转表, switch的核心就是跳转表
//c
void switch_eg(long x, long n, long *dest)
{
long val = x;
switch (n) {
case 100:
val *= 13;
break;
case 102:
val += 10;
/* Fall through */
case 103:
val += 11;
break;
case 104:
case 106:
val *= val;
break;
default:
val = 0;
}
*dest = val;
}
//c过程
void switch_eg_impl(long x, long n, long *dest)
{
/* Table of code pointers */
static void *jt[7] = {
&&loc_A, &&loc_def, &&loc_B,
&&loc_C, &&loc_D, &&loc_def,
&&loc_D
};
unsigned long index = n - 100;
long val;
if (index > 6)
goto loc_def;
/* Multiway branch */
goto *jt[index];
loc_A: /* Case 100 */
val = x * 13;
goto done;
loc_B: /* Case 102 */
x = x + 10;
/* Fall through */
loc_C: /* Case 103 */
val = x + 11;
goto done;
loc_D: /* Cases 104, 106 */
val = x * x;
goto done;
loc_def: /* Default case */
val = 0;
done:
*dest = val;
}
//汇编
switch_eg:
subq $100, %rsi /* Compute index = n - 100 */
cmpq $6, %rsi /* Compare index:6 */
ja .L8 /* If >, goto loc_def */
jmp *.L4(,%rsi,8) /* Goto *jt[index] */
.L3:
leaq (%rdi,%rdi,2), %rax /* loc_A: 3*x */
leaq (%rdi,%rax,4), %rdi /* val = 13*x */
jmp .L2 /* Goto done */
.L5:
addq $10, %rdi /* loc_B: x = x + 10 */
.L6:
addq $11, %rdi /* loc_C: val = x + 11 */
jmp .L2 /* Goto done */
.L7:
imulq %rdi, %rdi /* loc_D: val = x * x */
jmp .L2 /* Goto done */
.L8:
movl $0, %edi /* loc_def: val = 0 */
.L2:
movq %rdi, (%rdx) /* done: *dest = val */
ret /* Return */
//跳转表
.section .rodata /*只读数据*/
.align 8 /* Align address to multiple of 8*/
.L4:
.quad .L3 /* Case 100: loc_A */
.quad .L8 /* Case 101: loc_def */
.quad .L5 /* Case 102: loc_B */
.quad .L6 /* Case 103: loc_C */
.quad .L7 /* Case 104: loc_D */
.quad .L8 /* Case 105: loc_def */
.quad .L7 /* Case 106: loc_D */
3.7 过程
- 过程是软件中一种很重要的抽象。用一组指定的参数和一个可选的返回值实现某种功能,可以在程序的不同的地方调用这个函数。隐藏某个行为的具体实现。过程的形式:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等。
- 对过程的机器级支持,假设过程 P 调用过程 Q,Q 执行后返回 P。这些动作包括下面一个或多个机制:
1. 传递控制。进入过程 Q 时,程序计数器被设为 Q 代码的起始地址,在返回时,要将程序计数器设置为 P 中调用 Q 的后面那条指令的地址。
2. 传递数据。P 必须能够向 Q 提供一个参数,Q 必须能够向 P 返回一个值。
3. 分配和释放内存。开始时,Q 可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。(栈) - 遵循最低要求策略:只实现上述机制每个过程所必须的那些
3.7.1 运行时栈
- 当Q执行时,P及其上的调用链是被挂起的,需要为Q的局部变量分配存储空间,或设置Q到另一个的过程调用。Q返回时,释放。
- 程序用栈管理过程所需的空间,栈和寄存器存放传递控制,数据和分配内存的信息(被放在栈尾)
- P调用Q时,参数设定后将返回地址压入栈,Q扩展栈的边界,大多数的栈帧是定长的,有些过程需要变长的栈帧
- 过程的栈帧是过程需要的存储空间超过寄存器存放的大小时,才在栈上分配,如果小于等于6个参数,且不调用其他函数(叶子过程)的函数通常不需要栈帧
3.7.2 转移控制
- 将P转移到Q函数只需要将PC设置为Q代码的起始地址。而返回时,处理器需要记录它需要继续P的继续执行的位置。
- call命令:将地址A(紧跟着call的下一条指令)压入栈中,将PC设置为Q代码的起始地址。可以是直接的,也可以是间接的(*)。
- ret命令:从栈中弹出地址A,并将PC设置为A
3.7.3 数据传送
-** 调用一个过程时**,也要将数据作为参数传递,从过程返回可能包含一个返回值,大部分过程间的数据传送是通过寄存器传递的。
六个寄存器传递di,si,dx,cx,8,9,一个寄存器返回ax
- 如果函数传递的参数大于六个整形参数,就需要栈来传递,所有的数据大小向8(字节)的倍数对齐
第七个参数位于栈顶
3.7.4 栈上的局部存储
- 当寄存器不够存放所有的本地数据,或者对一个局部变量使用地址运算符,或者局部变量是数组或者结构时,在栈帧上存放局部变量
3.7.5 寄存器中的局部存储空间
- 寄存器组是唯一被所有过程共享的资源,在给定时刻只有一个过程是活动的,必须确保当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器值。
- 被调用者保存寄存器(Callee-saved registers):是指在函数调用过程中,如果被调用的函数需要使用这些寄存器,那么它必须在使用这些寄存器之前将它们的原始值保存起来(通常保存在栈上),在函数返回之前再恢复这些寄存器的值。这样就保证了调用者在调用函数前后,这些寄存器的值保持不变。
3.7.6 递归过程
- 递归调用是函数调用自身的情况,每次递归调用都会创建一个新的栈帧。即使是递归调用,栈帧的分配和释放规则依然适用,确保每次调用都有独立的状态信息。
- 函数调用过程
调用者(caller)保存自身需要保存的寄存器值(调用者保存寄存器)。
调用者将参数传递给被调用者(callee)。
调用者执行 call 指令,将返回地址压入栈。
被调用者创建新的栈帧,保存被调用者保存寄存器的值。
被调用者执行函数体。 - 函数返回过程
被调用者恢复被调用者保存寄存器的值。
被调用者销毁栈帧。
被调用者执行ret 指令,跳转到返回地址。
调用者恢复调用者保存寄存器的值。(第一次是被调用者)
调用者继续执行。
3.8 数组分配和访问
3.8.1 基本原则
- C语言对于数据类型 T 和整型常数 N,声明如下:
T A[N];
起始位置表示为xA(地址)
首先,它在内存中分配一个
L⋅N 字节的连续区域,L 是数据类型 T 的大小(单位为字节)。其次,标识符 A作为指向数组开头的指针,这个指针的值就是 x A。可以用0~N-1 的整数索引来访问该数组元素。第i个数组元素会被存放在地址为 x A+L⋅i 的地方。 - 假设 E 是一个 int 型的数组,而我们想计算 E[i],在此,E 的地址存放在寄存器 %rdx 中,而 i 存放在寄存器 %rcx 中
movl (%rdx,%rcx,4),%eax
3.8.2 指针运算
- C 语言允许对指针进行运算, p 是一个指向类型为 T 的数据的指针,p + i 的值为 xp +L⋅i,这里 L 是数据类型 T 的大小。
- &Expr 是给出该对象地址的一个指针。对于一个表示某个对象地址的表达式 AExpr,*AExpr 给出该地址处的值。因此,表达式 A[i] 等同于表达式 *(A + i),它计算第 i 个数组元素的地址,然后访问这个内存位置。
假设整型数组 E 的起始地址和整数索引 i 分别存放在寄存器 %rdx 和 %rcx 中
3.8.3 嵌套的数组
int A[5][3];
//等价于
typedef int row3_t[3];
row3_t A[5];
- 嵌套的数组在内存中也是连续的
- xA,i,j分别存在%rdi,%rsi,%rdx中。
将A [i] [j] 复制到%eax
leaq (%rsi,%rsi,2), %rax # 计算 3i
leaq (%rdi,%rax,4), %rax # 计算 x_A + 12i
movl (%rax,%rdx,4), %eax # 从 M[x_A + 12i + 4j] 读取数据
3.8.4 定长数组
- C语言编译器能优化定长多维数组,O1时的一些优化
- 去掉整数索引:它将所有的数组引用都转换成了指针间接引用
- 生成指针Aptr:指向数组A的行i中连续的元素
- 生成指针Bptr:指向数组B的列k中连续的元素
- 生成指针Bend:当需要终止循环时,它会等于Bptr的值。Bend的值是假想中B的列k的第(n+1)个元素的地址,由C表达式&B[N][k]给出。
- 例子:
/* Compute i,k of fixed matrix product */
int fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k) {
long j;
int result = 0;
for (j = 0; j < N; j++)
result += A[i][j] * B[j][k];
return result;
}
//优化后
/* Compute i,k of fixed matrix product */
int fix_prod_ele_opt(fix_matrix A, fix_matrix B, long i, long k) {
int *Aptr = &A[i][0]; /* Points to elements in row i of A */
int *Bptr = &B[0][k]; /* Points to elements in column k of B */
int *Bend = &B[N][k]; /* Marks stopping point for Bptr */
int result = 0;
do {
result += *Aptr * *Bptr; /* Add next product to sum */
Aptr++; /* Move Aptr to next column */
Bptr += N; /* Move Bptr to next row */
} while (Bptr != Bend); /* Test for stopping point */
return result;
}
3.8.5 变长数组
- C99的变长数组允许数组的维度是一个表达式,分配时才计算。由于分配时才计算,所以在leap指令前需要需要一次乘法指令(imul),影响性能。循环访问时编译器可以优化,识别步长,避免直接应用乘法。