【CSAPP笔记】5. 汇编语言——数据

本博客对于汇编的介绍基于32位机器的Intel x86系列处理器和IA32指令集,也涉及少部分x86-64。由于汇编知识相对复杂,这里只做简单介绍和记录,详细请参照书本!

数据格式

下面这张表格中体现了C语言基本数据类型和IA32的对应表示。

C语言中的声明 Intel 数据类型 汇编代码后缀 大小(字节)
char 字节 b(byte) 1
short w(word) 2
int 双字 l(long) 4
long int 双字 l 4
long long int “四字” - 4
char * 双字 l 4
float 单精度 s 4
double 双精度 l 8
long double 扩展精度 t 10 or 12

大多数的常用数据类型是用双字形式存储的。短整数short、普通整数int和长整数long int的区别是“短”和“普通”整数是固定的 2 和 4 字节,而“长”整数使用的是机器的全字长,因为是32位机器,所以这里 long int 是4字节。

long long是由8个字节来表示的,在硬件上 IA32 是不支持的这种数据类型的(x86-64可以)。

指针char *是使用机器的全字长的,因为指针是存储地址的变量。地址和字长有关,字长(word size)表明整数和指针数据的标称大小(nominal size)。在没有涉及到存储器的细节时,我们会把存储器看成一个非常大的数组,所以字长决定一个很重要的属性是虚拟空间的最大大小(每一个字节都要用一个唯一的数字来标识,而这个数字是要编码的)。对于一个字长为 w 位的机器而言,虚拟地址的范围是0 ~ $(2^w)-1$,程序最多访问$2^w$ 个字节。

单精度和双精度浮点数是 4 和 8 字节,不多说。扩展精度long double的大小为10或12字节。

汇编代码后缀的意思是大多数汇编代码指令的后面带有一个字符后缀,表明操作数的大小。例如:movb(传送字节)、movw(传送字)、movl(传送双字)。注意:用 l 表示double和 4 字节整数不会产生歧义,因为浮点数使用的是一套完全不同的指令和寄存器。

寄存器

一个 IA32 中央处理器单元(CPU,central processing unit)包含一组8个存储32位值的寄存器(register),可以看到寄存器的数量是很少的。下图显示了这八个寄存器的简单表示。它们的名字都以%e开头,实际上它们另有特殊的名字。

在大多数情况下,前六个寄存器可以看成通用寄存器,对它们的使用没有限制(“大多数情况”下分具体情况,具体情况也是用特殊的寄存器)。最后两个寄存器(%ebp%esp)保存指向程序的栈中重要位置的指针,所以只有根据栈管理的标准才能修改这两个寄存器的值。

可以看到,字节操作指令可以独立地写更短的地位字节。这是为了向后兼容(backwards compatiblity),也就是能让更早的代码正常地工作。下面是x86-64的寄存器,以%r开头的是64位寄存器,可以看到它也是向后兼容的。

数据传送: MOV 指令

将数据从一个位置复制到另一个位置是最频繁使用的指令。我们把指令分成指令类,一类指令执行的操作是一样的,只不过操作数的大小不同。

MOV指令 效果
mov S, D 传送字节,S → D
movw S, D 传送字
movl S, D 传送双字
MOVS指令 效果
movsbw S, D 将做了符号扩展的字节传送到字,S → D
movsbl S, D 将做了符号扩展的字节传送到双字
movswl S, D 将做了符号扩展的字传送到双字
MOVZ指令 效果
movzbw S, D 将做了零扩展的字节传送到字,S → D
movzbl S, D 将做了零扩展的字节传送到双字
movzwl S, D 将做了零扩展的字传送到双字

例如 MOV 是一类指令,代表传输数据,根据传送的是字节、字还是双字,分为三种指令:movbmovwmovl

mov 移动的数据的大小和目的位置的大小是一样的。与 MOV 不同,MOVSMOVZ 指令类是将一个较小的数据放到一个较大的数据位置。扩展方式分为符号扩展或者零扩展两种方式。如果要将一个较小的数据放到一个较大的数据位置,要么使用扩展版本的指令,要么就选择正确的 mov 后缀。不能将较大的数据放在一个较小的数据位置。

下面要介绍一个非常重要的概念——寻址。

寻址

大多数指令有一个或多个操作数(operand),操作数可能指代要引用的数据值、数据来源、或者要存放的目标位置。形成操作数的有效地址的过程,被称为寻址(addressing)。

操作数可以被分为三个类型:

  1. 立即数(immediate)

    也就是一个常数值,书写方式是一个美元符号后面跟着一个用标准C表示法表示的整数,例如$0x1F

  2. 寄存器(register)

    表示某个寄存器的内容。用符号 $ E_a $ 来表示任意寄存器a,用引用 $ R[E_a] $ 来表示它的值。这是将寄存器集合看成一个数组 R,用寄存器的名称作为索引。

  3. 存储器引用(memory)

    它根据一个计算出来的地址(称为有效地址)访问某个存储器的位置。因为我们是将存储器看成一个很大的字节数组。我们用M[Addr]表示对存储器中的字节值的引用。存储器寻址的内容就多了,包括:绝对寻址、间接寻址(对啦就是熟悉的指针!)、基址+偏移量寻址、变址寻址、比例变址寻址。在这里除了下面的程序中设计到的寻址方法之外的就不一一讲了。

接下来,对下图中的一些寻址方式做简单介绍:

MOV 的第一个操作数是源S,S要么是一个寄存器,要么是一个立即数,要么是一个存储器位置。第二个操作数是目的地D,只能是寄存器或者存储器位置。(注,图中的movq 的后缀 q 是四字的意思,是 x86-64 中支持64位数据的写法。)

①:第一条指令的第一个操作数是 $0x4 ,说明这是一个立即数,也就是常数。第二个数是寄存器的名字,这种寻址方式叫做:寄存器寻址。对应的C语言就是为一个局部变量赋常数值。

②:第二条指令与第一条不同的是第二个操作数的寄存器两边加上了括号。这其是是间接寻址的意思。形如 $(E_a)$ 的格式代表的操作数值是 $M[R[E_a]]$,也就是说,此时寄存器里存的是一个指针,也就是一个地址,括号可以看成一个C语言的间接引用运算符*。

③:两个寄存器直接的数据传递。

④:把一个局部变量的值复制到一个指针指向的内容。

⑤:把指针指向的内容赋给局部变量temp

这里有一点需要注意的是:不允许 MOV指令的两个操作数同时为存储器地址。也许你会感到奇怪:把存储器中一个地方的值传送到另一个地方不是很正常吗为什么没有这条指令?一开始对这个有疑惑是很正常的,这种操作需要两条指令——第一条指令将原值加载到寄存器中,第二条指令将该寄存器里的值写到目的存储器位置。

剩下的寻址方法这里就不详细介绍了,贴张图给大家看吧:

数据传送示例

(图表来自于CSAPP课程网站,本人添加了一点小小的说明性信息)分析下面这个交换函数的汇编代码,有助于理解寄存器、存储器、寻址、地址等各种概念。

变量 xpyp 是指针,内容是地址,用寄存器 %rdi%rsi 存放。临时变量 t0t1 用寄存器 %rax%rdx 存放。调用 swap 函数后,可以看到在左边的 memory 一列,0x1200x130 里存放了数据 123456,参数 xpyp 已经存到了对应的寄存器。

通过间接寻址,将存储器里的值存放到临时变量 t0t1 对应的寄存器里。

交换后,* xp 的值和 * yp 的值成功对调。

前两节的小结

  • 了解处理器、指令集的历史和发展。
  • 了解C语言、机器语言、汇编语言的区别。
    学习汇编语言能让我们理解更多细节,例如寄存器等等……理解编译器需要为C语言代码做的工作——将一些高级语言的表示方法编译为一些更为基本的指令。
  • 汇编语言基础:寄存器、操作数、数据移动、寻址。

See Also