汇编学习

一、汇编语言是什么?

我们知道,CPU 只负责计算,本身不具备智能。你输入一条指令(instruction),它就运行一次,然后停下来,等待下一条指令。

这些指令都是二进制的,称为操作码(opcode),比如加法指令就是00000011编译器的作用,就是将高级语言写好的程序,翻译成一条条操作码。

对于人类来说,二进制程序是不可读的,根本看不出来机器干了什么。为了解决可读性的问题,以及偶尔的编辑需求,就诞生了汇编语言。

汇编语言是二进制指令的文本形式,与指令是一一对应的关系。比如,加法指令00000011写成汇编语言就是 ADD。只要还原成二进制,汇编语言就可以被 CPU 直接执行,所以它是最底层的低级语言。

二、来历

最早的时候,编写程序就是手写二进制指令,然后通过各种开关输入计算机,比如要做加法了,就按一下加法开关。后来,发明了纸带打孔机,通过在纸带上打孔,将二进制指令自动输入计算机。

为了解决二进制指令的可读性问题,工程师将那些指令写成了八进制。二进制转八进制是轻而易举的,但是八进制的可读性也不行。很自然地,最后还是用文字表达,加法指令写成 ADD。内存地址也不再直接引用,而是用标签表示。

这样的话,就多出一个步骤,要把这些文字指令翻译成二进制,这个步骤就称为 assembling,完成这个步骤的程序就叫做 assembler。它处理的文本,自然就叫做 aseembly code。标准化以后,称为 assembly language,缩写为 asm,中文译为汇编语言。每一种 CPU 的机器指令都是不一样的,因此对应的汇编语言也不一样。目前最常见的 x86 汇编语言,即 Intel 公司的 CPU 使用的那一种。

三、寄存器

学习汇编语言,首先必须了解两个知识点:寄存器和内存模型。

先来看寄存器。CPU 本身只负责运算,不负责储存数据。数据一般都储存在内存之中,CPU 要用的时候就去内存读写数据。但是,CPU 的运算速度远高于内存的读写速度,为了避免被拖慢,CPU 都自带一级缓存和二级缓存。基本上,CPU 缓存可以看作是读写速度较快的内存。

但是,CPU 缓存还是不够快,另外数据在缓存里面的地址是不固定的,CPU 每次读写都要寻址也会拖慢速度。因此,除了缓存之外,CPU 还自带了寄存器(register),用来储存最常用的数据。也就是说,那些最频繁读写的数据(比如循环变量),都会放在寄存器里面,CPU 优先读写寄存器,再由寄存器跟内存交换数据。

image-20230328144102980

寄存器不依靠地址区分数据,而依靠名称。每一个寄存器都有自己的名称,我们告诉 CPU 去具体的哪一个寄存器拿数据,这样的速度是最快的。有人比喻寄存器是 CPU 的零级缓存。

四、寄存器的种类

早期的 x86 CPU 只有8个寄存器,而且每个都有不同的用途。现在的寄存器已经有100多个了,都变成通用寄存器,不特别指定用途了,但是早期寄存器的名字都被保存了下来。一般寄存器:AX、BX、CX、DX,索引暂存器:SI、DI,堆叠、基底暂存器:SP、BP。EAX、ECX、EDX、EBX:为ax,bx,cx,dx的延伸,各为32位元
ESI、EDI、ESP、EBP:为si,di,sp,bp的延伸,32位元。eax, ebx, ecx, edx, esi, edi, ebp, esp等都是X86 汇编语言中CPU上的通用寄存器的名称,是32位的寄存器。如果用C语言来解释,可以把这些寄存器当作变量看待。

这些32位寄存器有多种用途,但每一个都有“专长”,有各自的特别之处。

EAX 是”累加器”(accumulator), 它是很多加法乘法指令的缺省寄存器。

EBX 是”基地址”(base)寄存器, 在内存寻址时存放基地址。

ECX 是计数器(counter), 是重复(REP)前缀指令和LOOP指令的内定计数器。

EDX 则总是被用来放整数除法产生的余数。

ESI/EDI分别叫做”源/目标索引寄存器”(source/destination index),因为在很多字符串操作指令中, DS:ESI指向源串,而ES:EDI指向目标串.

​ push ebp ;保存当前ebp
 mov ebp,esp ;EBP设为当前堆栈指针
 sub esp, xxx ;预留xxx字节给函数临时变量.
 …  

这样一来,EBP 构成了该函数的一个框架, 在EBP上方分别是原来的EBP, 返回地址和参数. EBP下方则是临时变量. 函数返回时作 mov esp,ebp/pop ebp/ret 即可.

ESP 专门用作堆栈指针,被形象地称为栈顶指针,堆栈的顶部是地址小的区域,压入堆栈的数据越多,ESP也就越来越小。在32位平台上,ESP每次减少4字节。

我们常常看到 32位 CPU、64位 CPU 这样的名称,其实指的就是寄存器的大小。32 位 CPU 的寄存器大小就是4个字节。

五、内存模型:Heap

寄存器只能存放很少量的数据,大多数时候,CPU 要指挥寄存器,直接跟内存交换数据。所以,除了寄存器,还必须了解内存怎么储存数据。

程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。这段内存有起始地址和结束地址,比如从0x10000x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。

程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc命令),系统就会从预先分配好的那段内存之中,划出一部分给用户,具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。举例来说,用户要求得到10个字节内存,那么从起始地址0x1000开始给他分配,一直分配到地址0x100A,如果再要求得到22个字节,那么就分配到0x1020

这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。它由起始地址开始,从低位(地址)向高位(地址)增长。Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。

六、内存模型:Stack

除了 Heap 以外,其他的内存占用叫做 Stack(栈)。简单说,Stack 是由于函数运行而临时占用的内存区域。

请看下面的例子。

1
2
3
4
int main() {
int a = 2;
int b = 3;
}

上面代码中,系统开始执行main函数时,会为它在内存里面建立一个帧(frame),所有main的内部变量(比如ab)都保存在这个帧里面。main函数执行结束后,该帧就会被回收,释放所有的内部变量,不再占用空间。

如果函数内部调用了其他函数,会发生什么情况?

1
2
3
4
5
int main() {
int a = 2;
int b = 3;
return add_a_and_b(a, b);
}

上面代码中,main函数内部调用了add_a_and_b函数。执行到这一行的时候,系统也会为add_a_and_b新建一个帧,用来储存它的内部变量。也就是说,此时同时存在两个帧:mainadd_a_and_b。一般来说,调用栈有多少层,就有多少帧。

等到add_a_and_b运行结束,它的帧就会被回收,系统会回到函数main刚才中断执行的地方,继续往下执行。通过这种机制,就实现了函数的层层调用,并且每一层都能使用自己的本地变量。

所有的帧都存放在 Stack,由于帧是一层层叠加的,所以 Stack 叫做栈。生成新的帧,叫做”入栈”,英文是 push;栈的回收叫做”出栈”,英文是 pop。Stack 的特点就是,最晚入栈的帧最早出栈(因为最内层的函数调用,最先结束运行),这就叫做”后进先出”的数据结构。每一次函数执行结束,就自动释放一个帧,所有函数执行结束,整个 Stack 就都释放了。

Stack 是由内存区域的结束地址开始,从高位(地址)向低位(地址)分配。比如,内存区域的结束地址是0x8000,第一帧假定是16字节,那么下一次分配的地址就会从0x7FF0开始;第二帧假定需要64字节,那么地址就会移动到0x7FB0

七、CPU 指令

7.1 一个实例

了解寄存器和内存模型以后,就可以来看汇编语言到底是什么了。下面是一个简单的程序example.c

1
2
3
4
5
6
7
int add_a_and_b(int a, int b) {
return a + b;
}

int main() {
return add_a_and_b(2, 3);
}

gcc 将这个程序转成汇编语言。

1
$ gcc -S example.c

上面的命令执行以后,会生成一个文本文件example.s,里面就是汇编语言,包含了几十行指令。这么说吧,一个高级语言的简单操作,底层可能由几个,甚至几十个 CPU 指令构成。CPU 依次执行这些指令,完成这一步操作。

example.s经过简化以后,大概是下面的样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
_add_a_and_b:
push %ebx //esp-4
mov %eax, [%esp+8] // 取2
mov %ebx, [%esp+12] // 取3
add %eax, %ebx //a+b值存在eax中
pop %ebx //ebx弹出栈,esp+4
ret //退出程序执行

_main:
push 3 //压栈,整型占4字节,esp栈顶指针-4
push 2 //压栈,esp-4
call _add_a_and_b //调用add函数
add %esp, 8 //esp+8 保存在esp中
ret //退出程序执行

可以看到,原程序的两个函数add_a_and_bmain,对应两个标签_add_a_and_b_main。每个标签里面是该函数所转成的 CPU 运行流程。

每一行就是 CPU 执行的一次操作。它又分成两部分,就以其中一行为例。

1
push   %ebx

这一行里面,push是 CPU 指令,%ebx是该指令要用到的运算子。一个 CPU 指令可以有零个到多个运算子。

下面我就一行一行讲解这个汇编程序,建议读者最好把这个程序,在另一个窗口拷贝一份,省得阅读的时候再把页面滚动上来。

7.2 push 指令

根据约定,程序从_main标签开始执行,这时会在 Stack 上为main建立一个帧,并将 Stack 所指向的地址,写入 ESP 寄存器。后面如果有数据要写入main这个帧,就会写在 ESP 寄存器所保存的地址。

然后,开始执行第一行代码。

1
push   3

push指令用于将运算子放入 Stack,这里就是将3写入main这个帧。

虽然看上去很简单,push指令其实有一个前置操作。它会先取出 ESP 寄存器里面的地址,将其减去4个字节,然后将新地址写入 ESP 寄存器。使用减法是因为 Stack 从高位向低位发展,4个字节则是因为3的类型是int,占用4个字节。得到新地址以后, 3 就会写入这个地址开始的四个字节。

1
push   2

第二行也是一样,push指令将2写入main这个帧,位置紧贴着前面写入的3。这时,ESP 寄存器会再减去 4个字节(累计减去8)。

img

7.3 call 指令

第三行的call指令用来调用函数。

1
call   _add_a_and_b

上面的代码表示调用add_a_and_b函数。这时,程序就会去找_add_a_and_b标签,并为该函数建立一个新的帧。

下面就开始执行_add_a_and_b的代码。

1
push   %ebx

这一行表示将 EBX 寄存器里面的值,写入_add_a_and_b这个帧。这是因为后面要用到这个寄存器,就先把里面的值取出来,用完后再写回去。

这时,push指令会再将 ESP 寄存器里面的地址减去4个字节(累计减去12)。

7.4 mov 指令

mov指令用于将一个值写入某个寄存器。

1
mov    %eax, [%esp+8] 

这一行代码表示,先将 ESP 寄存器里面的地址加上8个字节,得到一个新的地址,然后按照这个地址在 Stack 取出数据。根据前面的步骤,可以推算出这里取出的是2,再将2写入 EAX 寄存器。

下一行代码也是干同样的事情。

1
mov    %ebx, [%esp+12] 

上面的代码将 ESP 寄存器的值加12个字节,再按照这个地址在 Stack 取出数据,这次取出的是3,将其写入 EBX 寄存器。

7.5 add 指令

add指令用于将两个运算子相加,并将结果写入第一个运算子。

1
add    %eax, %ebx

上面的代码将 EAX 寄存器的值(即2)加上 EBX 寄存器的值(即3),得到结果5,再将这个结果写入第一个运算子 EAX 寄存器。

7.6 pop 指令

pop指令用于取出 Stack 最近一个写入的值(即最低位地址的值),并将这个值写入运算子指定的位置。

1
pop    %ebx

上面的代码表示,取出 Stack 最近写入的值(即 EBX 寄存器的原始值),再将这个值写回 EBX 寄存器(因为加法已经做完了,EBX 寄存器用不到了)。

注意,pop指令还会将 ESP 寄存器里面的地址加4,即回收4个字节。

7.7 ret 指令

ret指令用于终止当前函数的执行,将运行权交还给上层函数。也就是,当前函数的帧将被回收。

1
ret

可以看到,该指令没有运算子。

随着add_a_and_b函数终止执行,系统就回到刚才main函数中断的地方,继续往下执行。

1
add    %esp, 8 

上面的代码表示,将 ESP 寄存器里面的地址,手动加上8个字节,再写回 ESP 寄存器。这是因为 ESP 寄存器的是 Stack 的写入开始地址,前面的pop操作已经回收了4个字节,这里再回收8个字节,等于全部回收。

1
ret

最后,main函数运行结束,ret指令退出程序执行。


指令

1
2
3
4
mov ax bx //取bx寄存器的值送到ax
mov ax 18 //ax = 18
add ax bx //ax = ax+bx
add ax 7 //ax = ax+7

字节:记为byte,一个字节由8个bit组成。

字:记为word,一个字由两个字节组成,这两个字节分别成为这个字的高位字节和低位字节。

1.基础知识

1.1 机器语言

机器语言是0101,汇编语言是机器语言的助记符,每种cpu都有自己的汇编指令集。

image-20230724162413568

1.2 单位

1Byte(B) = 8 bit, 1 Word = 2 Byte, 1KB = 1024 B, 1 MB = 1024 KB, 1 GB = 1024 MB, 1 TB = 1024GB

DW 是定义2字节空间的意思。DW属于汇编的一个伪指令,DW定义字类型变量,一个字数据占2个字节单元,读完一个,偏移量加2。

DB定义字节类型变量,一个字节数据占1个字节单元,读完一个,偏移量加1。

DD定义双字类型变量,一个双字数据占4个字节单元,读完一个,偏移量加4。

基本数据度量单位:
1 BYTE(字节)= 8 BIT(比特)
WORD=2BYTE=16BIT
DWORD= 4 BYTE = 32 BIT
QWORD= 8BYTE = 64 BIT
数据存储度量单位:
1KB= 1024 BYTE = 8192 BIT
1MB = 1024KB
1GB = 1024MB
1TB = 1024GB

取值范围:
BYTE=有符号:-128至127|无符号:0至255
WORD =有符号: -32768 至32767 |无符号: 0至65535
DWORD =有符号: -2,147 ,483,648至2, 147 ,483,647|无符号: 0至4,294,967 ,295
QWORD=有符号: -9,223,372,036,854,775,808 至9,223,372,036,854,775 807

1.3 CPU 对存储器的读写

1.地址总线 2.数据总线 3.控制总线 4.I/O总线

地址总线的宽度决定了cpu的寻址能力,数据总线的宽度决定了cpu与其他器件进行数据传输时的一次数据传输量;控制总线的宽度决定了cpu对系统中其他器件的控制能力。

1.4 各存储器芯片

存储器都与cpu的主线相连,cpu对他们进行读或写的时候通过控制线发出读写命令。

  • 随机存储器

用于存放供CPU使用的绝大部分程序和数据,主随机存储器一般由两个位置上的RAM组成,装在主板上的RAM和插在扩展插槽上的RAM。

  • 装有BIOS(Basic Input/Output System,基本输入/输出系统)的ROM

BIOS是由主板和各类接口卡(如显卡、网卡等)厂商提供的软件系统,可以通过它利用该硬件设备进行最基本的输入输出。在主板和某些接口卡上插有存储相应BIOS的ROM。例如,主板上的ROM中存储着主板的BIOS(通常称为系统BIOS);显卡上的ROM中存储着显卡的BIOS;如果网卡上装有ROM,那其中就可以存储网卡的BIOS。

  • 接口卡上的RAM

某些接口卡需要对大批量输入、输出数据进行暂时存储,在其上装有RAM。最典型的是显示卡上的RAM,一般称为显存。显示卡随时将显存中的数据向显示器上输出。换句话说,我们将需要显示的内容写入显存,就会出现在显示器上。

2.寄存器

x86/x64架构的CPU中的寄存器

2.1 什么是寄存器?

寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果以及一些CPU运行需要的信息。

x86架构CPU走的是复杂指令集(CISC) 路线,提供了丰富的指令来实现强大的功能,与此同时也提供了大量寄存器来辅助功能实现。

2.2通用寄存器

首先是通用寄存器,这些的寄存器是程序执行代码最最常用,也最最基础的寄存器,程序执行过程中,绝大部分时间都是在操作这些寄存器来实现指令功能。

所谓通用,即这些寄存器CPU没有特殊的用途,交给应用程序“随意”使用。注意,这个随意,我打了引号,对于有些寄存器,CPU有一些潜规则,用的时候要注意。

  • eax: 通常用来执行加法,函数调用的返回值一般也放在这里面
  • ebx: 数据存取
  • ecx: 通常用来作为计数器,比如for循环
  • edx: 读写I/O端口时,edx用来存放端口号
  • esp: 栈顶指针,指向栈的顶部
  • ebp: 栈底指针,指向栈的底部,通常用ebp+偏移量的形式来定位函数存放在栈中的局部变量
  • esi: 字符串操作时,用于存放数据源的地址
  • edi: 字符串操作时,用于存放目的地址的,和esi两个经常搭配一起使用,执行字符串的复制等操作

在x64架构中,上面的通用寄存器都扩展成为64位版本,名字也进行了升级。当然,为了兼容32位模式程序,使用上面的名字仍然是可以访问的,相当于访问64位寄存器的低32位。

rax rbx rcx rdx rsp rbp rsi rdi

除了扩展原来存在的通用寄存器,x64架构还引入了8个新的通用寄存器:

r8-r15

在原来32位时代,函数调用时,那个时候通用寄存器少,参数绝大多数时候是通过线程的栈来进行传递(当然也有使用寄存器传递的,比如著名的C++ this指针使用ecx寄存器传递,不过能用的寄存器毕竟不多)。

进入x64时代,寄存器资源富裕了,参数传递绝大多数都是用寄存器来传了。寄存器传参的好处是速度快,减少了对内存的读写次数。

当然,具体使用栈还是用寄存器传参数,这个不是编程语言决定的,而是编译器在编译生成CPU指令时决定的,如果编译器非要在x64架构CPU上使用线程栈来传参那也不是不行,这个对高级语言是无感知的。

2.2标志寄存器

标志寄存器,里面有众多标记位,记录了CPU执行指令过程中的一系列状态,这些标志大都由CPU自动设置和修改:

  • CF 进位标志(1进0不进)
  • PF 奇偶标志(1的个数为偶数是1,否则为0)
  • ZF 零标志(0为1)
  • SF 符号标志(有符号数,负数为1)
  • OF 补码溢出标志(1溢出,0不溢出)
  • TF 跟踪标志(1为单步状态,0为正常状态)
  • IF 中断标志(允许处理可屏蔽中断为1)
  • ······

image-20230724165348353

在x64架构下,原来的eflags寄存器升级为64位的rflags,不过其高32位并没有新增什么功能,保留为将来使用。

2.3 指令寄存器

eip: 指令寄存器可以说是CPU中最最重要的寄存器了,它指向了下一条要执行的指令所存放的地址,CPU的工作其实就是不断取出它指向的指令,然后执行这条指令,同时指令寄存器继续指向下面一条指令,如此不断重复,这就是CPU工作的基本日常。

而在漏洞攻击中,黑客想尽办法费尽心机都想要修改指令寄存器的地址,从而能够执行恶意代码。

同样的,在x64架构下,32位的eip升级为64位的rip寄存器。

2.4 段寄存器

段寄存器与CPU的内存寻址技术紧密相关。

早在16位的8086CPU时代,内存资源宝贵,CPU使用分段式内存寻址技术:

image-20230724165637000

16位的寄存器能寻址的范围是64KB,通过引入段的概念,将内存空间划分为不同的区域:分段,通过段基址+段内偏移段方式来寻址。

这样一来,段的基地址保存在哪里呢?8086CPU专门设置了几个段寄存器用来保存段的基地址,这就是段寄存器段的由来。

段寄存器也是16位的。

段寄存器有下面6个,前面4个是早期16位模式就引入了,到了32位时代,又新增了fs和gs两个段寄存器。

  • cs: 代码段
  • ds: 数据段
  • ss: 栈段
  • es: 扩展段
  • fs: 数据段
  • gs: 数据段

段寄存器里面存储的内容与CPU当前工作的内存寻址模式紧密相关。

当CPU处于16位实地址模式下时,段寄存器存储段的基地址,寻址时,将段寄存器内容左移4位(乘以16)得到段基地址+段内偏移得到最终的地址。

当CPU工作于保护模式下,段寄存器存储的内容不再是段基址了,此时的段寄存器中存放的是段选择子,用来指示当前这个段寄存器“指向”的是哪个分段。

注意我这里的指向打了引号,段寄存器中存储的并不是内存段的直接地址,而是段选择子,它的结构如下:

image-20230724165705393

16个bit长度的段寄存器内容划分了三个字段:

  • PRL: 特权请求级,就是我们常说的ring0-ring3四个特权级。
  • TI: 0表示用的是全局描述符表GDT,1表示使用的是局部描述符表LDT。
  • Index: 这是一个表格中表项的索引值,这个表格叫内存描述符表,它的每一个表项都描述了一个内存分段。

这里提到了两个表,全局描述符表GDT和局部描述符表LDT,关于这两个表的介绍,下面介绍描述符寄存器时再详述,这里只需要知道,这是CPU支持分段式内存管理需要的表格,放在内存中,表格中的每一项都是一个描述符,记录了一个内存分段的信息。

保护模式下的段寄存器和段描述符到最后的内存分段,通过下图的方式联系在一起:

image-20230724165837724

通用寄存器、段寄存器、标志寄存器、指令寄存器,这四组寄存器共同构成了一个基本的指令执行环境,一个线程的上下文也基本上就是这些寄存器,在执行线程切换的时候,就是修改它们的内容。

控制寄存器

控制寄存器是CPU中一组相当重要的寄存器,我们知道eflags寄存器记录了当前运行线程的一系列关键信息。

那CPU运行过程中自身的一些关键信息保存在哪里呢?答案是控制寄存器!

image-20230724165946931

32位CPU总共有cr0-cr4共5个控制寄存器,64位增加了cr8。他们各自有不同的功能,但都存储了CPU工作时的重要信息:

  • cr0: 存储了CPU控制标记和工作状态
  • cr1: 保留未使用
  • cr2: 页错误出现时保存导致出错的地址
  • cr3: 存储了当前进程的虚拟地址空间的重要信息——页目录地址
  • cr4: 也存储了CPU工作相关以及当前人任务的一些信息
  • cr8: 64位新增扩展使用

其中,CR0尤其重要,它包含了太多重要的CPU信息,值得单独关注一下:

image-20230724170117455

一些重要的标记位含义如下:

PG: 是否启用内存分页

AM: 是否启用内存对齐自动检查

WP: 是否开启内存写保护,若开启,对只读页面尝试写入时将触发异常,这一机制常常被用来实现写时复制功能

PE: 是否开启保护模式

除了CR0,另一个值得关注的寄存器是CR3,它保存了当前进程所使用的虚拟地址空间的页目录地址,可以说是整个虚拟地址翻译中的顶级指挥棒,在进程空间切换的时候,CR3也将同步切换。

调试寄存器

在x86/x64CPU内部,还有一组用于支持软件调试的寄存器。

调试,对于我们程序员是家常便饭,必备技能。但你想过你的程序能够被调试背后的原理吗?

程序能够被调试,关键在于能够被中断执行和恢复执行,被中断的地方就是我们设置的断点。那程序是如何能在遇到断点的时候停下来呢?

对于一些解释执行(PHP、Python、JavaScript)或虚拟机执行(Java)的高级语言,这很容易办到,因为它们的执行都在解释器/虚拟机的掌控之中。

而对于像C、C++这样的“底层”编程语言,程序代码是直接编译成CPU的机器指令来执行的,这就需要CPU来提供对于调试的支持了。

对于通常的断点,也就是程序执行到某个位置下就停下来,这种断点实现的方式,在x86/x64上,是利用了一条软中断指令:int 3来进行实现的。

注意,这里的int不是指高级语言里面的整数,而是表示interrupt中断的意思,是一条汇编指令,int 3则表示中断向量号为3的中断。

在我们使用调试器下断点时,调试器将会把对应位置的原来的指令替换为一个int 3指令,机器码为0xCC。这个动作对我们是透明的,我们在调试器中看到的依然是原来的指令,但实际上内存中已经不是原来的指令了。

顺便提一句,两个0xCC是汉字【烫】的编码,在一些编译器里,会给线程的栈中填充大量的0xCC,如果程序出错的时候,我们经常会看到很多烫烫烫出现,就是这个原因。

言归正传,CPU在执行这条int 3指令时,将自动触发中断处理流程(虽然这实际上不是一个真正的中断),CPU将取出IDTR寄存器指向的中断描述符表IDT的第3项,执行里面的中断处理函数。

而这个中断描述符表,早在操作系统启动之初,就已经提前安排好了,所以执行这条指令后,操作系统的中断处理函数将介入,来处理这一事件。

后面的过程就多了,简单来说,操作系统会把触发这一事件的进程冻结起来,随后将这一事件发送到调试器,调试器拿到之后就知道目标进程触发断点了。这个时候,咱们程序员就能通过调试器的UI交互界面或者命令行调试接口来调试目标进程,查看堆栈、查看内存、变量都随你。

如果我们要继续运行,调试器将会把之前修改的int 3指令给恢复回去,然后告知操作系统:我处理完了,把目标进程解冻吧!

上面简单描述了一下普通断点的实现原理。现在思考一个场景:我们发现一个bug,某个全局整数型变量的值老是莫名其妙被修改,但你发现有很多线程,很多函数都有可能会去修改这个变量,你想找出到底谁干的,怎么办?

这个时候上面的普通断点就没办法了,你需要一种新的断点:硬件断点

这时候就该调试寄存器登场表演了。

image-20230724170358424

在x86架构CPU内部,提供了8个调试寄存器DR0~DR7。

DR0~DR3:这是四个用于存储地址的寄存器

DR4~DR5:这两个有点特殊,受前面提到的CR4寄存器中的标志位DE位控制,如果CR4的DE位是1,则DR4、DR5是不可访问的,访问将触发异常。如果CR4的DE位是0,则DR4和DR5将会变成DR6和DR7的别名,相当于做了一个软链接。这样做是为了将DR4、DR5保留,以便将来扩展调试功能时使用。

DR6:这个寄存器中存储了硬件断点触发后的一些状态信息

DR7:调试控制寄存器,这里面记录了对DR0-DR3这四个寄存器中存储地址的中断方式(是对地址的读,还是写,还是执行)、数据长度(1/2/4个字节)以及作用范围等信息

通过调试器的接口设置硬件断点后,CPU在执行代码的过程中,如果满足条件,将自动中断下来。

回答前面提出的问题,想要找出是谁偷偷修改了全局整形变量,只需要通过调试器设置一个硬件写入断点即可。

描述符寄存器

所谓描述符,其实就是一个数据结构,用来记录一些信息,‘描述’一个东西。把很多个描述符排列在一起,组成一个表,就成了描述符表。再使用一个寄存器来指向这个表,这个寄存器就是描述符寄存器

在x86/x64系列CPU中,有三个非常重要的描述符寄存器,它们分别存储了三个地址,指向了三个非常重要的描述符表。

gdtr: 全局描述符表寄存器,前面提到,CPU现在使用的是段+分页结合的内存管理方式,那系统总共有那些分段呢?这就存储在一个叫全局描述符表(GDT)的表格中,并用gdtr寄存器指向这个表。这个表中的每一项都描述了一个内存段的信息。

ldtr: 局部描述符表寄存器,这个寄存器和上面的gdtr一样,同样指向的是一个段描述符表(LDT)。不同的是,GDT是全局唯一,LDT是局部使用的,可以创建多个,随着任务段切换而切换(下文介绍任务寄存器会提到)。

image-20230724170435177

GDT和LDT中的表项,就是段描述符,描述了一个内存分段的信息,其结构如下:

image-20230724170448034

一个表项占据8个字节(32位CPU),里面存储了一个内存分段的诸多信息:基地址、大小、权限、类型等信息。

除了这两个段描述符寄存器,还有一个非常重要的描述符寄存器:

idtr: 中断描述符表寄存器,指向了中断描述符表IDT,这个表的每一项都是一个中断处理描述符,当CPU执行过程中发生了硬中断、异常、软中断时,将自动从这个表中定位对应的表项,里面记录了发生中断、异常时该去哪里执行处理函数。

image-20230724170521092

IDT中的表项称为Gate,中文意思为,因为这是应用程序进入内核的主要入口。虽然表的名字叫中断描述符表,但表中存储的不全是中断描述符,IDT中的表项存在三种类型,对应三种类型的门:

  • 任务门
  • 陷阱门
  • 中断门

image-20230724170544891

三种描述符中都存储了处理这个中断/异常/任务时该去哪里处理的地址。三种门用途不一,其中中断门是真正意义上的中断,而像前面提到的调试指令int 3以及老式的系统调用指令int 2e/int 80都属于陷阱门。任务门则用的较少,要了解任务门,先了解下任务寄存器。

任务寄存器

现代操作系统,都是支持多任务并发运行的,x86架构CPU为了顺应时代潮流,在硬件层面上提供了专门的机制用来支持多任务的切换,这体现在两个方面:

  • CPU内部设置了一个专用的寄存器——任务寄存器TR,它指向当前运行的任务。

image-20230724170626282

定义了描述任务的数据结构TSS,里面存储了一个任务的上下文(一系列寄存器的值),下图是一个32位CPU的TSS结构图:

image-20230724170748458

x86CPU的构想是每一个任务对应一个TSS,然后由TR寄存器指向当前的任务,执行任务切换时,修改TR寄存器的指向即可,这是硬件层面的多任务切换机制。

这个构想其实还是很不错的,然而现实却打了脸,包括Linux和Windows在内的主流操作系统都没有使用这个机制来进行线程切换,而是自己使用软件来实现多线程切换。

所以,绝大多数情况下,TR寄存器都是指向固定的,即便线程切换了,TR寄存器仍然不会变化。

注意,我这里说的的是绝大多数情况,而没有说死。虽然操作系统不依靠TSS来实现多任务切换,但这并不意味着CPU提供的TSS操作系统一点也没有使用。还是存在一些特殊情况,如一些异常处理会使用到TSS来执行处理。

下面这张图,展示了控制寄存器、描述符寄存器、任务寄存器构成的全貌:

image-20230724170828192

模型特定寄存器

从80486之后的x86架构CPU,内部增加了一组新的寄存器,统称为MSR寄存器,中文直译是模型特定寄存器,意思是这些寄存器不像上面列出的寄存器是固定的,这些寄存器可能随着不同的版本有所变化。这些寄存器主要用来支持一些新的功能。

随着x86CPU不断更新换代,MSR寄存器变的越来越多,但与此同时,有一部分MSR寄存器随着版本迭代,慢慢固化下来,成为了变化中那部分不变的,这部分MSR寄存器,Intel将其称为Architected MSR,这部分MSR寄存器,在命名上,统一加上了IA32的前缀。

这里选取三个代表性的MSR简单介绍一下:

  • IA32_SYSENTER_CS
  • IA32_SYSENTER_ESP
  • IA32_SYSENTER_EIP

这三个MSR寄存器是用来实现快速系统调用

在早期的x86架构CPU上,系统调用依赖于软中断实现,类似于前面调试用到的int 3指令,在Windows上,系统调用用到的是int 2e,在Linux上,用的是int 80

软中断毕竟还是比较慢的,因为执行软中断就需要内存查表,通过IDTR定位到IDT,再取出函数进行执行。

系统调用是一个频繁触发的动作,如此这般势必对性能有所影响。在进入奔腾时代后,就加上了上面的三个MSR寄存器,分别存储了执行系统调用后,内核系统调用入口函数所需要的段寄存器、堆栈栈顶、函数地址,不再需要内存查表。快速系统调用还提供了专门的CPU指令sysenter/sysexit用来发起系统调用和退出系统调用。

在64位上,这一对指令升级为syscall/sysret

总结

以上就是全部要介绍的寄存器了,需要说明一下的是,这并不是x86CPU全部所有的寄存器,除了这些,还存在XMM、MMX、FPU浮点数运算等其他寄存器。

汇编指令

官方文档:Intel x86 Opcode Table and Reference (shell-storm.org)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
---------- 一、数据传输指令 ----------------------------------------------------  
它们在存贮器和寄存器、寄存器和输入输出端口之间传送数据.
1. 通用数据传送指令.
MOV 传送字或字节.
MOVSX 先符号扩展,再传送.
MOVZX 先零扩展,再传送.
PUSH 把字压入堆栈.
POP 把字弹出堆栈.
PUSHAAX,CX,DX,BX,SP,BP,SI,DI依次压入堆栈.
POPADI,SI,BP,SP,BX,DX,CX,AX依次弹出堆栈.
PUSHADEAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI依次压入堆栈.
POPADEDI,ESI,EBP,ESP,EBX,EDX,ECX,EAX依次弹出堆栈.
BSWAP 交换32位寄存器里字节的顺序
XCHG 交换字或字节.(至少有一个操作数为寄存器,段寄存器不可作为操作数)
CMPXCHG 比较并交换操作数.(第二个操作数必须为累加器AL/AX/EAX)
XADD 先交换再累加.(结果在第一个操作数里)
XLAT 字节查表转换.----BX指向一张256字节的表的起点,AL为表的索引值(0-255,即0-FFH);返回AL为查表结果.([BX+AL]->AL)
2. 输入输出端口传送指令.
IN I/O端口输入. ( 语法: IN 累加器, {端口号│DX} )
OUT I/O端口输出. ( 语法: OUT {端口号│DX},累加器 )输入输出端口由立即方式指定时, 其范围是 0-255; 由寄存器 DX 指定时,其范围是 0-65535.
3. 目的地址传送指令.
LEA 装入有效地址.例: LEA DX,string ;把偏移地址存到DX.
LDS 传送目标指针,把指针内容装入DS.例: LDS SI,string ;把段地址:偏移地址存到DS:SI.
LES 传送目标指针,把指针内容装入ES.例: LES DI,string ;把段地址:偏移地址存到ES:DI.
LFS 传送目标指针,把指针内容装入FS.例: LFS DI,string ;把段地址:偏移地址存到FS:DI.
LGS 传送目标指针,把指针内容装入GS.例: LGS DI,string ;把段地址:偏移地址存到GS:DI.
LSS 传送目标指针,把指针内容装入SS.例: LSS DI,string ;把段地址:偏移地址存到SS:DI.
4. 标志传送指令.
LAHF 标志寄存器传送,把标志装入AH.
SAHF 标志寄存器传送,把AH内容装入标志寄存器.
PUSHF 标志入栈.
POPF 标志出栈.
PUSHD 32位标志入栈.
POPD 32位标志出栈.
---------- 二、算术运算指令 ----------------------------------------------------
ADD 加法.
ADC 带进位加法.
INC1.
AAA 加法的ASCII码调整.
DAA 加法的十进制调整.
SUB 减法.
SBB 带借位减法.
DEC1.
NEG 求反(以0 减之).
CMP 比较.(两操作数作减法,仅修改标志位,不回送结果,相同为1).
AAS 减法的ASCII码调整.
DAS 减法的十进制调整.
MUL 无符号乘法.结果回送AHAL(字节运算),或DXAX(字运算),
IMUL 整数乘法.结果回送AHAL(字节运算),或DXAX(字运算),
AAM 乘法的ASCII码调整.
DIV 无符号除法.结果回送:商回送AL,余数回送AH, (字节运算);或 商回送AX,余数回送DX, (字运算).
IDIV 整数除法.结果回送:商回送AL,余数回送AH, (字节运算);或 商回送AX,余数回送DX, (字运算).
AAD 除法的ASCII码调整.
CBW 字节转换为字. (把AL中字节的符号扩展到AH中去)
CWD 字转换为双字. (把AX中的字的符号扩展到DX中去)
CWDE 字转换为双字. (把AX中的字符号扩展到EAX中去)
CDQ 双字扩展. (把EAX中的字的符号扩展到EDX中去)
---------- 三、逻辑运算指令 ----------------------------------------------------
AND 与运算.
OR 或运算.
XOR 异或运算.
NOT 取反.
TEST 测试.(两操作数作与运算,仅修改标志位,不回送结果).
SHL 逻辑左移.
SAL 算术左移.(=SHL)
SHR 逻辑右移.
SAR 算术右移.(=SHR)
ROL 循环左移.
ROR 循环右移.
RCL 通过进位的循环左移.
RCR 通过进位的循环右移.
以上八种移位指令,其移位次数可达255次.
移位一次时, 可直接用操作码. 如 SHL AX,1.
移位>1次时, 则由寄存器CL给出移位次数.
MOV CL,04 SHL AX,CL
---------- 四、串指令 ----------------------------------------------------------
DS:SI 源串段寄存器 :源串变址.
ES:DI 目标串段寄存器:目标串变址.
CX 重复次数计数器.
AL/AX 扫描值.
D标志 0表示重复操作中SIDI应自动增量; 1表示应自动减量.
Z标志 用来控制扫描或比较操作的结束.
MOVS 串传送.( MOVSB 传送字符. MOVSW 传送字. MOVSD 传送双字. )
CMPS 串比较.( CMPSB 比较字符. CMPSW 比较字. )
SCAS 串扫描.把ALAX的内容与目标串作比较,比较结果反映在标志位.
LODS 装入串.把源串中的元素(字或字节)逐一装入ALAX中.( LODSB 传送字符. LODSW 传送字. LODSD 传送双字. )
STOS 保存串.是LODS的逆过程.
REPCX/ECX<>0时重复.
REPE/REPZ 当ZF=1或比较结果相等,且CX/ECX<>0时重复.
REPNE/REPNZ 当ZF=0或比较结果不相等,且CX/ECX<>0时重复.
REPC 当CF=1CX/ECX<>0时重复.
REPNC 当CF=0CX/ECX<>0时重复.
---------- 五、程序转移指令 ----------------------------------------------------
1. 无条件转移指令 (长转移)
JMP 无条件转移指令
CALL 过程调用
RET/RETF 过程返回.
2. 条件转移指令 (短转移,-128到+127的距离内)( 当且仅当(SF XOR OF)=1时,OP1<OP2 )
JA/JNBE 不小于或不等于时转移.
JAE/JNB 大于或等于转移.
JB/JNAE 小于转移.
JBE/JNA 小于或等于转移.
以上四条,测试无符号整数运算的结果(标志C和Z).
JG/JNLE 大于转移.
JGE/JNL 大于或等于转移.
JL/JNGE 小于转移.
JLE/JNG 小于或等于转移.
以上四条,测试带符号整数运算的结果(标志S,O和Z).
JE/JZ 等于转移.
JNE/JNZ 不等于时转移.
JC 有进位时转移.
JNC 无进位时转移.
JNO 不溢出时转移.
JNP/JPO 奇偶性为奇数时转移.
JNS 符号位为 "0" 时转移.
JO 溢出转移.
JP/JPE 奇偶性为偶数时转移.
JS 符号位为 "1" 时转移.
3. 循环控制指令(短转移)
LOOP CX不为零时循环.
LOOPE/LOOPZ CX不为零且标志Z=1时循环.
LOOPNE/LOOPNZ CX不为零且标志Z=0时循环.
JCXZ CX为零时转移.
JECXZ ECX为零时转移.
4. 中断指令
INT 中断指令
INTO 溢出中断
IRET 中断返回
5. 处理器控制指令
HLT 处理器暂停, 直到出现中断或复位信号才继续.
WAIT 当芯片引线TEST为高电平时使CPU进入等待状态.
ESC 转换到外处理器.
LOCK 封锁总线.
NOP 空操作.
STC 置进位标志位.
CLC 清进位标志位.
CMC 进位标志取反.
STD 置方向标志位.
CLD 清方向标志位.
STI 置中断允许位.
CLI 清中断允许位.
---------- 六、伪指令 ----------------------------------------------------------
DW 定义字(2字节).
PROC 定义过程.
ENDP 过程结束.
SEGMENT 定义段.
ASSUME 建立段寄存器寻址.
ENDS 段结束.
END 程序结束.
---------- 七、处理机控制指令:标志处理指令 ------------------------------------
CLC 进位位置0指令
CMC 进位位求反指令
STC 进位位置为1指令
CLD 方向标志置1指令
STD 方向标志位置1指令
CLI 中断标志置0指令
STI 中断标志置1指令
NOP 无操作
HLT 停机
WAIT 等待
ESC 换码
LOCK 封锁
========== 浮点运算指令集 ======================================================
---------- 一、控制指令(带9B的控制指令前缀F变为FN时浮点不检查,机器码去掉9B)----
FINIT 初始化浮点部件 机器码 9B DB E3
FCLEX 清除异常 机器码 9B DB E2
FDISI 浮点检查禁止中断 机器码 9B DB E1
FENI 浮点检查禁止中断二 机器码 9B DB E0
WAIT 同步CPU和FPU 机器码 9B
FWAIT 同步CPU和FPU 机器码 D9 D0
FNOP 无操作 机器码 DA E9
FXCH 交换ST(0)和ST(1) 机器码 D9 C9
FXCH ST(i) 交换ST(0)和ST(i) 机器码 D9 C1iii
FSTSW ax 状态字到ax 机器码 9B DF E0
FSTSW word ptr mem 状态字到mem 机器码 9B DD mm111mmm
FLDCW word ptr mem mem到状态字 机器码 D9 mm101mmm
FSTCW word ptr mem 控制字到mem 机器码 9B D9 mm111mmm

FLDENV word ptr mem mem到全环境 机器码 D9 mm100mmm
FSTENV word ptr mem 全环境到mem 机器码 9B D9 mm110mmm
FRSTOR word ptr mem mem到FPU状态 机器码 DD mm100mmm
FSAVE word ptr mem FPU状态到mem 机器码 9B DD mm110mmm

FFREE ST(i) 标志ST(i)未使用 机器码 DD C0iii
FDECSTP 减少栈指针1->0 2->1 机器码 D9 F6
FINCSTP 增加栈指针0->1 1->2 机器码 D9 F7
FSETPM 浮点设置保护 机器码 DB E4
---------- 二、数据传送指令 ----------------------------------------------------
FLDZ0.0装入ST(0) 机器码 D9 EE
FLD11.0装入ST(0) 机器码 D9 E8
FLDPI 将π装入ST(0) 机器码 D9 EB
FLDL2T 将ln10/ln2装入ST(0) 机器码 D9 E9
FLDL2E1/ln2装入ST(0) 机器码 D9 EA
FLDLG2 将ln2/ln10装入ST(0) 机器码 D9 EC
FLDLN2 将ln2装入ST(0) 机器码 D9 ED

FLD real4 ptr mem 装入mem的单精度浮点数 机器码 D9 mm000mmm
FLD real8 ptr mem 装入mem的双精度浮点数 机器码 DD mm000mmm
FLD real10 ptr mem 装入mem的十字节浮点数 机器码 DB mm101mmm

FILD word ptr mem 装入mem的二字节整数 机器码 DF mm000mmm
FILD dword ptr mem 装入mem的四字节整数 机器码 DB mm000mmm
FILD qword ptr mem 装入mem的八字节整数 机器码 DF mm101mmm

FBLD tbyte ptr mem 装入mem的十字节BCD数 机器码 DF mm100mmm

FST real4 ptr mem 保存单精度浮点数到mem 机器码 D9 mm010mmm
FST real8 ptr mem 保存双精度浮点数到mem 机器码 DD mm010mmm

FIST word ptr mem 保存二字节整数到mem 机器码 DF mm010mmm
FIST dword ptr mem 保存四字节整数到mem 机器码 DB mm010mmm

FSTP real4 ptr mem 保存单精度浮点数到mem并出栈 机器码 D9 mm011mmm
FSTP real8 ptr mem 保存双精度浮点数到mem并出栈 机器码 DD mm011mmm
FSTP real10 ptr mem 保存十字节浮点数到mem并出栈 机器码 DB mm111mmm

FISTP word ptr mem 保存二字节整数到mem并出栈 机器码 DF mm011mmm
FISTP dword ptr mem 保存四字节整数到mem并出栈 机器码 DB mm011mmm
FISTP qword ptr mem 保存八字节整数到mem并出栈 机器码 DF mm111mmm

FBSTP tbyte ptr mem 保存十字节BCD数到mem并出栈 机器码 DF mm110mmm

FCMOVB ST(0),ST(i) <时传送 机器码 DA C0iii
FCMOVBE ST(0),ST(i) <=时传送 机器码 DA D0iii
FCMOVE ST(0),ST(i) =时传送 机器码 DA C1iii
FCMOVNB ST(0),ST(i) >=时传送 机器码 DB C0iii
FCMOVNBE ST(0),ST(i) >时传送 机器码 DB D0iii
FCMOVNE ST(0),ST(i) !=时传送 机器码 DB C1iii
FCMOVNU ST(0),ST(i) 有序时传送 机器码 DB D1iii
FCMOVU ST(0),ST(i) 无序时传送 机器码 DA D1iii
---------- 三、比较指令 --------------------------------------------------------
FCOM ST(0)-ST(1) 机器码 D8 D1
FCOMI ST(0),ST(i) ST(0)-ST(1) 机器码 DB F0iii
FCOMIP ST(0),ST(i) ST(0)-ST(1)并出栈 机器码 DF F0iii
FCOM real4 ptr mem ST(0)-实数mem 机器码 D8 mm010mmm
FCOM real8 ptr mem ST(0)-实数mem 机器码 DC mm010mmm

FICOM word ptr mem ST(0)-整数mem 机器码 DE mm010mmm
FICOM dword ptr mem ST(0)-整数mem 机器码 DA mm010mmm
FICOMP word ptr mem ST(0)-整数mem并出栈 机器码 DE mm011mmm
FICOMP dword ptr mem ST(0)-整数mem并出栈 机器码 DA mm011mmm

FTST ST(0)-0 机器码 D9 E4
FUCOM ST(i) ST(0)-ST(i) 机器码 DD E0iii
FUCOMP ST(i) ST(0)-ST(i)并出栈 机器码 DD E1iii
FUCOMPP ST(0)-ST(1)并二次出栈 机器码 DA E9
FXAM ST(0)规格类型 机器码 D9 E5
---------- 四、运算指令 --------------------------------------------------------
FADD 把目的操作数 (直接接在指令后的变量或堆栈缓存器) 与来源操作数 (接在目的操作数后的变量或堆栈缓存器) 相加,并将结果存入目的操作数
FADDP ST(i),ST 这个指令是使目的操作数加上 ST 缓存器,并弹出 ST 缓存器,而目的操作数必须是堆栈缓存器的其中之一,最后不管目的操作数为何,经弹出一次后,目的操作数会变成上一个堆栈缓存器了
FIADD FIADD 是把 ST 加上来源操作数,然后再存入 ST 缓存器,来源操作数必须是字组整数或短整数形态的变数

FSUB
FSUBP
FSUBR 减数与被减数互换
FSUBRP
FISUB
FISUBR

FMUL
FMULP
FIMUL

FDIV
FDIVP
FDIVR
FDIVRP
FIDIV
FIDIVR

FCHS 改变 ST 的正负值

FABSST 之值取出,取其绝对值后再存回去。

FSQRTST 之值取出,开根号后再存回去。

FSCALE 这个指令是计算 ST*2^ST(1)之值,再把结果存入 ST 里而 ST(1) 之值不变。ST(1) 必须是在 -3276832768 (-215215 )之间的整数,如果超过这个范围计算结果无法确定,如果不是整数 ST(1) 会先向零舍入成整数再计算。所以为安全起见,最好是由字组整数载入到 ST(1) 里。

FRNDINT 这个指令是把 ST 的数值舍入成整数,FPU 提供四种舍入方式,由 FPU 的控制字组(control word)中的 RC 两个位决定
RC 舍入控制
00 四舍五入
01 向负无限大舍入
10 向正无限大舍入
11 向零舍去

汇编学习
http://example.com/2023/03/27/汇编学习/
作者
luo
发布于
2023年3月27日
许可协议