第三讲:数字表示和寄存器
第三讲:数字表示和寄存器
这一讲的内容将涵盖不同数字表示形式之间的转换、通用寄存器、附加专用寄存器。并且会以一个课堂项目小组项目结束。
数字的表示
虽然我们都熟悉十进制数的表示方法,但回顾一下如何工作可能会有所帮助。 假设我们有一个十进制数,例如 15386
。则该数字的数值可以由下式给出
我们从右边第一个数字开始,乘以 10 的 0 次方, 每一个位置的数字乘以10的这个位置的次方(最右边的位置为0),再进行累加求和。
其他系统基本上以相同的方式工作,唯一改变的是每个位置上的数字有多少个可能的值:
- 对于二进制而言,只有2种选择, 0或者1。所以二进制的基数是2。
- 对于十六进制而言, 有16种选择,0,1,2,...8,9,a, b , ... e, f。因此十六进制的基数是16。
- 对于八进制而言,有8种选择,0,1,2, ... 6,7,所以八进制的基数是8。
其他进制转换为10进制
将一个二进制数1001011b
转换为十进制,我们这样计算:
将一个十六进制数0x1a2b
转换为十进制,我们这样计算:
八进制也是类似的。八进制平常使用很少,这里不再讲解。
10进制转换为其他进制
将十进制值转换为二进制或十六进制相对而言麻烦一些。我们首先以探索二进制如何转换为十进制,然后将其推广到十六进制如何转换为十进制。
要将十进制值(例如 1234)转换为二进制,我们采用的方法是除以 2,然后查看余数。
1234除以2等于617, 余数为0,这成为低位,我们使用 617 作为下一阶段的输入:
xxxxxxxxxx0
我们再次将 617 除以 2 并查看余数,余数为1,所以我们将下一个较高位设置为 1,并使用 617/2 = 308 作为下一个周期的输入:
xxxxxxxxx10
继续这个过程我们得到:
输入 | 余数 | 二进制数 |
---|---|---|
1234 | 0 | __________0 |
617 | 1 | _________10 |
308 | 0 | ________010 |
154 | 0 | _______0010 |
77 | 1 | ______10010 |
38 | 0 | _____010010 |
19 | 1 | ____1010010 |
9 | 1 | ___11010010 |
4 | 0 | __011010010 |
2 | 0 | _0011010010 |
1 | 1 | 10011010010 |
由于, 我们可以停止计算。如果需要,我们可以通过将二进制值转换回十进制来验证我们的工作。
类似的过程可用于将十进制转换为十六进制,只不过我们除以 16。例如,将 1234 转换为十六进制:
输入 | 余数 | 二进制数 |
---|---|---|
1234 | 2 | 0x___2 |
77 | 13 = d | 0x__d2 |
4 | 4 | 0x4d2 |
每次除以 16 显然比除以 2 更快地得到 0。
二进制算术
无符号二进制加法
无符号二进制加法(即两个正数)的工作方式类似于正常的十进制数的加法,只是只有两个数字,因此1 + 1 = 10,进位为1。例如:
1001011
+ 1100101
────────────
1+1=10,所以我们把0添加到结果中,然后将1添加到进位中。
1
1001011
+ 1100101
────────────
0
接着由于1+1=10, 我们在将0放入结果,把1放入进位中:
11
1001011
+ 1100101
────────────
00
再一次由于1+1=10, 我们在将0放入结果,把1放入进位中:
111
1001011
+ 1100101
────────────
000
再一次计算:
1111
1001011
+ 1100101
────────────
0000
1 + 0 + 0 = 1,所以不需要进位:
1111
1001011
+ 1100101
────────────
10000
0 + 1 = 1:
1111
1001011
+ 1100101
────────────
110000
最后 1 + 1 = 10,进位的 1 落入结果中:
1111
1001011
+ 1100101
────────────
10110000
检验一下我们的工作,两个加数分别是75和101,答案应该是176,是正确的。
这个例子也说明了有时会出现的一个问题:两个n位值相加的结果可能有n+1位。例如,如果我们添加两个字节,结果可能无法容纳一个字节!我们稍后会看到 CPU 如何处理这种情况。
111111
01011011 = 91
+ 01110110 = 118
────────────
11010001 = 209
无符号数字的减法
减法遵循类似的模式,但用"借"而不是进位。例如:
110110
- 100001
────────────
- 1 = -1,所以我们从下一列借用 1(即,我们正在执行 10 - 1 = 1)
110102
- 100001
────────────
1
这里我们进行了一个小小的改动,把借入的数字写成"2",2-1 = 1。同样,借入的数字(10 = 2)变成0。
0 - 0 = 0:
110100
- 100001
────────────
01
1 - 0 = 1:
110110
- 100001
────────────
101
0 - 0 = 0:
110110
- 100001
────────────
0101
1 - 0 = 1:
110110
- 100001
────────────
10101
1 - 1 = 0(我们可以去掉答案中最前面的0):
110110
- 100001
────────────
010101
检查我们的工作:顶部是 54,底部是 33,结果是 21,这是正确的。
有时我们可能需要连续多次借用:
1 1 1 0 0 0
- 1 0 0 0 1 1
─────────────────────
第一步是 2 - 1 = 1,额外的 1 是从下一列借来的。
-1
1 1 1 0 0 2
- 1 0 0 0 1 1
─────────────────────
1
但下一列是 -1 + 0 - 1。所以我们再次借用下一列,得到 -1 + 10 - 1 = 0:
-1 -1
1 1 1 0 2 2
- 1 0 0 0 1 1
─────────────────────
0 1
我们必须继续借用,直到找到一个已设置的数字:
-1 -1 -1
1 1 1 2 2 2
- 1 0 0 0 1 1
─────────────────────
0 1 0 1 0 1
如果底部值大于顶部值会发生什么?
1
011
- 100
────────
-1 111
从算术上讲,这表示 3 - 4 = 7,并有一个额外的借位。我们得到的答案实际上是如果有另一列可以借用的话我们会得到的结果(即,如果我们完成了 1011 - 100,则正确的结果是 7).
我们只能尝试借用一个不存在的 1。这与将两个值相加但结果不相符的情况相反;这里我们需要一个不存在的额外 1。正如我们稍后将看到的,CPU 对这两种情况的处理方式类似,通过设置一个标志来指示在最近的加法/减法操作中发生了进位/借位。
-1-1 -1-1
0 1 1 1 0 1 1 0 = 118
- 0 1 0 1 1 0 1 1 = 91
───────────────────
0 0 0 1 1 0 1 1 = 27
操作数
每条汇编指令都有许多"操作数"。最大的指令有三个操作数,大多数有两个或一个,有些(如系统调用)没有操作数。每个操作数可以是以下内容之一(有一些限制,具体取决于具体指令)。
- register的名字,例如
rax
。 - 一个常量,例如 60 或 msg。(
msg
作为一个标签,其实也是一个常量。汇编器计算字符串的首地址,并将实际数值写入指令中)。常量操作数在汇编语言术语中称为立即数。 请注意,由于汇编器可以进行算术运算,因此形如4 * msg + 1
这样的立即数,它的值在汇编阶段也是已知的。(你不能做像rax + 1
这样的事情,因为寄存器 rax 的值在程序运行之前是不知道的。) - 内存直接查找。
[msg]
给出内存中地址msg
处的值。即给出字符串的前 8 个字节。 - 内存间接查找。
[rax]
可以给出一个内存中的值。该值的地址存储在rax
寄存器中。有几种不同形式的内存间接操作数,它们允许以自然的方式访问数组和结构。后续我们会研究这个部分。
通常, 当我们使用一个内存操作时,我们需要给出一个尺寸限定符, 例如 byte [msg]
,代表msg
位置的第一个字节,qword [rax]
代表rax
指向的内存地址出的8个bytes的内容。尺寸限定符通常是可选的,因为它可以从其他操作数中推断出来。不过在实际中,总是添加尺寸限定符是一个很好的习惯, 这会帮助找到尺寸不匹配的错误。
通常,内存操作数被分组在一起,一般来说,只要允许内存直接操作数,也允许内存间接操作数。因此我们可以通过R(register), I(Immediate)和 M(memory)的组合来描述允许的操作符的类型。如果我们说给定的操作数是 RM
,则意味着它支持寄存器和内存操作数,但不支持立即数。
大多数指令都有以下限制:
- 对于双操作数指令,两者的大小必须相同。 (有专门的指令用于在不同大小的操作数之间进行转换。)某些指令仅对特定大小进行操作。
- 许多指令只能读取小于 qword 的内存值。
mov
和其他一些指令是唯一支持访问qword [addr]
的指令。 - 仅
mov
支持 64 位立即数。 - 一条指令中的两个操作数不能都是内存。
- 目标操作数不能是立即数。
寄存器和内存
寄存器占据内存层次结构的最高层。它们就在 CPU 的内部,可以通过指令直接访问,因此是存储您正在使用的值的最快位置。另一方面,由于它们在物理上必须靠近CPU,因此不能占用太多空间;x86-64有16个64位通用寄存器,16个128位浮点/SIMD寄存器,以及一些特殊用途寄存器。
通用寄存器
通用寄存器的排列方式是将全部 64 位划分为低 32 位、低 16 位和低(有时是高)8 位。例如,对于 rax:

只有寄存器 rax
、rbx
、rcx
和 rdx
允许通过 ah
、bh
、ch
和 dh
访问最低字中的高字节(低16bit中的高8bit),因此它实际上位于整个寄存器的中间)。使用它们的时间/地点有一些限制(在 64 位模式下)。
64-bits | 低32bits | 低16bits | 低8bits | 描述 |
---|---|---|---|---|
rax | eax | ax | al | 系统调用的操作码和返回值,累加器 |
rbx | ebx | bx | bl | 基址寄存器 |
rcx | ecx | cx | cl | 循环计数, 系统调用临时寄存器 |
rdx | edx | dx | dl | 系统调用的第3个参数;双字累加 |
rsi | esi | si | sil | 系统调用的第2个参数;源索引 |
rdi | edi | di | dil | 系统调用的第1个参数;目的索引 |
rbp | ebp | bp | bpl | 栈底指针寄存器 |
rsp | esp | sp | spl | 栈顶指针寄存器 |
r8 | r8d | r8w | r8b | 系统调用的第5个参数 |
r9 | r9d | r9w | r9b | 系统调用的第6个参数 |
r10 | r10d | r10w | r10b | 系统调用的第4个参数 |
r11 | r11d | r11w | r11b | 系统调用的临时寄存器 |
... | ... | ... | ... | ... |
r15 | r15d | r15w | r15b | ... |
当我们讨论函数时,我们会看到每个寄存器都属于一个额外的分类:
当我们调用一个函数时,我们是函数调用者负责在需要时保存寄存器("caller-preserved register"),还是被调用的函数负责在需要时保存寄存器("callee-preserved register")? 该问题在后续章节中也会提到。
rsp
和rbp
虽然是通用寄存器,但是通常用于管理栈。rsp
指向栈顶,而 rbp
(基指寄存器)通常向当前函数堆栈帧的开头。rsp
不应用于其他用途,但 rbp
并非严格禁止使用。请注意,rsp
指向栈顶部的元素,而不是其上方的空白空间。
rax
称为累加器,并且一些指令隐式地使用它作为目的地。同样,rbx
有时称为基址寄存器,rcx
称为计数寄存器,rdx
称为双字累加器
。有一些指令将会隐式地使用它们,但是大多数情况下,您可以将它们用于任何场景。
"隐式使用"的意思是有些指令虽然没有显示指明任何寄存器,但是实际操作了这些寄存器。 例如要用rax
除以rbx
,指令是:
idiv rbx
然后将除法写回 rax
,将余数写回 rdx
,即使它没有提及它们。
rsi
和 rdi
是某些字符串操作隐式使用的源索引和目标索引,但您可以在其他上下文中将它们用作通用寄存器。
当需要双 qword(128 位)值时,通常将其存储在rax
和 rdx
的组合中,其中高 qword 存储在 rdx
中(写为 rdx:rax)。我们将通过乘法和除法来了解这一点。
SIMD/浮点寄存器被命名为 xmm0
到 xmm15
,并与名为 fpr0
-fpr7
的浮点寄存器共享空间,并且只能与特殊浮点/SIMD 指令一起使用。 (通常这些指令以 f 或 p 开头;例如,fadd 是浮点加法。)它们不能被普通指令访问。
系统调用的使用
系统调用将通过 rax
返回错误代码,这意味着系统调用返回后 rax
的值可能不是您设置的值。类似地,两个临时寄存器 rcx
和 r11
被系统调用覆盖。
第二字节寄存器
寄存器 rax
、rbx
、rcx
和 rdx
是唯一允许通过 ah
、bh
、ch
和 dh
访问第二个字节的寄存器。但是,这些寄存器的使用有一些限制:它们不能与任何使用 REX 前缀的指令一起使用,而所有"新"64 位指令都使用 REX 前缀。(REX前缀后面章节会详细讲解)。
因此,任何使用 x86-64 新添加的功能的指令都无法使用 *h
寄存器。包括:
mov ah, sil
- rsi、rdi、rsp、rbp 的低字节版本是由 x86-64 添加的,因此需要使用 REX 前缀。mov r8b, ah
- 同样,r8 到 r15 是由 x86-64 添加的。mov ah, byte [rax]
- 使用 rax 的完整 qword 宽度,即使作为地址,也需要 REX 前缀。- 转换不同大小值的指令无法从 *h 转换为任何新的 64 位寄存器。
我们不会太多使用 *h
寄存器,因此您可能不会遇到这些限制,但它们值得注意。
系统调用使用的寄存器
正如我们所看到的,一些寄存器是由系统调用专门使用的:rax
用于系统调用代码,然后 rdi
、rsi
、rdx
、r10
、r8
和 r9
按顺序用于系统调用的参数。没有任何系统调用需要超过六个参数。如果系统调用返回一个值,则该值将以 rax
形式表示。负值通常表示有错误。
系统调用本身可以覆盖 rcx
和 r11
寄存器中的值,但它将保留所有其他寄存器。如果您将 rcx
或 r11
与系统调用一起使用,则应该记住这一点:在系统调用之前放入寄存器中的值在系统调用之后可能不存在!
此用法不是 x86-64 程序集的官方部分,而只是写入 System-V Unix ABI 规范的约定,该规范描述了在 x86-64 Unix 系统上运行的程序如何与操作系统交互。除此之外,规范还规定每个进程的地址空间是 48 位,并且每个进程的 .text
部分都从 0x400000 开始映射。
mov指令
汇编语言最基本的指令就是mov
, 它将数据从一个位置移动(内存、寄存器、立即数)到另一个位置(内存、寄存器)。它具有一下的形式:
mov destination, source
其中目的操作数可以是寄存器或内存,源操作数可以是寄存器、内存或立即数。
目的操作数和源操作数的大小必须相同,并且源操作数和目的操作数不能同为内存类型。
需要记住的重要一点是,在 64 位模式下,mov
是唯一支持 qword 立即数操作数的指令。所有其他指令只有在已加载到寄存器中时才能对 64 位值进行操作。因此,大多数对立即值的 qword 操作都以 mov
开头。例如,您不能直接将 64 位常量添加到寄存器:
add rax, some_huge_constant
您必须将常量移动到寄存器中,然后添加它:
mov rbx, some_huge_constant
add rax, rbx
一种特殊情况是源/目标是双字(32 位)值,例如,
mov eax, ebx
在这种情况下,并且仅在这种情况下,rax
的高位双字被隐式设置为 0。设置 ax
或 ah/al
时不会发生这种清零。 (此行为不仅适用于 mov,还适用于许多其他指令。例如xor eax, ebx
会将 rax
的高位双字清零。)
交换寄存器的值
交换(交换)两个寄存器(或一个寄存器和一个内存位置)中的值是一种常见的操作,因此为其提供了专用指令:
xchg a, b
交换位置 a 和 b 中的值。两者都可以是寄存器或内存,但两者不能同时是内存,并且都不能是立即值(出于明显的原因)。这使我们能够交换值,而无需第三个"临时"寄存器。
与 mov
,xchg
一样,32 位寄存器(eax
、ebx
等)上的 xchg 会将高位双字隐式清零。
清除寄存器
将寄存器设置为 0 最简单的方法是:
mov reg, 0
稍微更有效的方法是将寄存器与其自身进行异或:
xor reg, reg
请记住,XOR 的结果为 1,当且仅当其输入之一为 1 时。如果我们将一个值与其自身进行 XOR,则每对进行 XOR 的位要么是 (0,0) (0 XOR 0 = 0 ) 或 (1,1) (1 XOR 1 = 0),因此结果为所有位均为 0。
xor
的操作码比带有立即数的 mov
更小,因此可以更快地加载到 CPU 中;它还允许 CPU 执行许多其他方式无法实现的数据流优化。最终,你的大脑会自动将 xor reg, reg
翻译成 reg = 0
,但我并不特别关心你使用哪个。
特殊用途的寄存器
以下寄存器用于特定目的,由 CPU 强制执行。您要么无法将一般数据放入其中,要么无法从中取出数据。通常必须使用专门的指令(不是 mov)来访问它们。
rip 指令指针指向下一条要执行的指令(即紧接在这条指令之后的指令)。低 32 位可作为 eip 访问,低位字可作为 ip 访问,但由于地址始终是 64 位,因此这并不是特别有用。分支指令直接修改rip。
rflags寄存器:标志寄存器的每一位都有不同的含义,并且根据某些操作的结果设置或取消设置各种标志。当我们了解测试和条件操作时,我们将深入研究标志寄存器。通常你不需要担心访问标志寄存器,因为它是由相关操作自动设置和测试的。
标志寄存器的组织方式为:
Bit 0 2 4 6 7 10 11 21 Purpose Carry(CF) Parity(PF) Adjust(AF) Zero(ZF) Sign(SF) Overflow Direction(DF) Identification (ID) - 如果先前的加法/减法运算以进位(或借用)1 结束,则 CF 被置位。
- 如果最后一次操作产生偶数个 1,则设置 PF。
- 如果最后一次 BCD 加法/减法运算以进位结束,则 AF 被置位。稍后我们将讨论 BCD。
- 如果最后一个有符号算术运算溢出(回绕),则设置 OF。
- DF 确定重复字符串操作移动的方向(递增或递减)。当我们查看字符串操作时,我们会看到它的用途。
- ID 指示 cpuid 指令的存在。所有现代 x86 CPU 都支持该指令,因此我们可以忽略该标志。
通常我们不需要担心检查标志寄存器,因为有专用的条件指令(例如分支、移动)仅在设置/未设置特定标志时才执行。如果您想设置/清除特定标志,可以使用
st*
/cl*
系列指令来执行此操作。例如,stc
将进位标志设置为 true,而clc
将其清除(将其设置为 false)数据段寄存器
ds
、es
、ss
、fs
和gs
以及代码段寄存器cs
在(非内核)x86-64 代码中没有用处,但您也不应该将它们用于您自己的目的。 (Windows 和 Linux 都使用fs
和gs
来指向线程本地存储,但这不是标准,只是约定。)它们控制进程的内存如何映射到系统的内存地址空间。控制寄存器
cr0
到cr15
在用户模式代码中根本无法访问;它们控制 CPU 是否运行在受保护(内核)或不受保护(用户)模式,是否运行在 16 位、32 位或 64 位模式等。还有一些额外的寄存器与内存管理、调试断点、内部性能参数等有关。其中大多数对我们来说没有用,而且许多用户代码无论如何都无法访问。
普通的 mov
指令通常不能用来操作这些寄存器。相反,存在专门的指令来获取/设置它们的值。
小组作业
为了让你可以更加熟悉汇编语言, 这里是我们的小组作业:
编写一个汇编程序,提示用户输入姓名,打印 What is your name?然后接受最多255个字符的输入,然后打印出Hello,name,nice to meet you!随后是换行符。
您必须同时使用 SYS_WRITE
(= 1) 和 SYS_READ
(= 0) 系统调用。使用以下 .data
部分:
section .data
prompt: db "What is your name?"
prompt_len: equ $-prompt
buffer: times 255 db '!'
resp1: db "Hello, "
resp1_len: equ $-resp1
resp2: db ", nice to meet you!", 10
resp2_len: equ $-resp2
buffer
是传递给SYS_READ
调用的输入缓冲区。它由 255 个组成!
组成。请注意,SYS_READ
将返回在 rax
中读取的实际字节数,然后在打印缓冲区内容时必须使用该字节数。(如果输入的长度错误,您会看到用户名被截断,或者在其末尾添加!!!!。)
SYS_READ
和 SYS_WRITE
的fd参数是一个文件描述符,一个标识文件或流的数字。始终可用的标准文件描述符是:
文件描述符 | 含义 |
---|---|
0 | 标准输入 |
1 | 标准输出 |
2 | 标准错误 |
因此,您将从 FD #0
进行 SYS_READ
,然后从 FD #1
进行 SYS_WRITE
(就像我们之前所做的那样)。
不要忘记使用 SYS_EXIT (= 60)
系统调用来结束您的程序,以优雅地结束您的程序!
附录
课程资源
课程原文:https://staffwww.fullcoll.edu/aclifton/cs241/lecture-registers-simple-loops.html
caller saved vs callee saved
Caller Saved寄存器在函数调用的时候不会保存。
Callee Saved寄存器在函数调用的时候会保存。
这里的意思是,一个Caller Saved寄存器可能被其他函数重写。假设我们在函数a中调用函数b,任何被函数a使用的并且是Caller Saved寄存器,调用函数b可能重写这些寄存器。我认为一个比较好的例子就是Return address寄存器(注,保存的是函数返回的地址),你可以看到rax寄存器是Caller Saved,这一点很重要,它导致了当函数a调用函数b的时侯,b会重写Return address。所以基本上来说,任何一个Caller Saved寄存器,作为调用方的函数要小心可能的数据可能的变化;任何一个Callee Saved寄存器,作为被调用方的函数要小心寄存器的值不会相应的变化。