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 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。