提示:make a compiler 系列
x86汇编器编程
一、基于GNU汇编器的编程
1.GNU汇编器
1.1GNU由来
GNU是一个操作系统,其内容软件完全以GPL方式发布。这个操作系统是GNU计划的主要目标,名称来自GNU’s Not Unix!的递归缩写。GNU的创始人,理查德·马修·斯托曼(自由软件之父),将GNU视为“达成社会目的技术方法”。
1.2GNU汇编器
一般来说,UNIX 的 C 编译器将 C 语言代码转换为汇编语言代码,汇编语言再经过汇编器处理,得到目标文件(object file)。之所以用汇编语言作为转换中介,是因为机器语言难以阅读。cbc 沿用 UNIX 的做法,其编译器输出汇编语言的代码。汇编器选用 Linux 上广泛使用的GNU as。GNU as 由 GNU 提供,包含在名为 binutils 的包下。gcc 所使用的汇编器也是 GNU as。
二、GNU汇编器的语法
GNU as 的代码由指令、汇编伪操作、标签和注释这 4 个要素组成
。通常除注释外,每一个要素单独占用一行。
2.1 指令
指令(instruction)是直接由 CPU 负责处理的命令。行首缩进,并且不以点“.”
开始的行都是指令行。下面举几个指令的具体例子。
movl %esp, %ebp
pushl %ecx
subl $16, %esp
指令由标识命令种类的助记符(mnemonic)和作为参数的操作数(operand)组成。以指令movl %esp
, %ebp
为例,movl
为助记符,%esp
和 %ebp
这 2 个是操作数。有多个操作数时以逗号来分割。
2.2汇编伪操作
以点“.”开头,末尾没有冒号“:”的行都是汇编伪操作(directive)行。例如 .file
"hello.c"
、.text
、.globl main
都是汇编伪操作。下面再举一些汇编伪操作的例子。
.string "Hello, World!"
.text
.globl main
.type main, @function
汇编伪操作是由汇编器,而非 CPU 负责处理的指令。一般用于在目标文件中记录元数据(meta data)或者设定指令的属性等。例如 .string 是用来定义字符串常量的汇编伪操作,.text 是提示代码段的汇编伪操作。
因为 .string、.text 和 .globl 行首的缩进不同,所以可能会被误认为是不同类型的语法关键字。这只是 gcc 输出代码的习惯而已,无论是否有行首缩进,都不会影响汇编伪操作的运行结果。
2.3标签
以冒号“:”结尾的行都是标签行。例如 .LCO:
或 main:
。使用标签的例子如下所示。
.LC0:
.string "Hello, World!"
标签具有为汇编伪操作生成的数据或者指令起别名(标上符号)的功能,这样就可以在文件的其他地方调用通过标签定义的符号。例如上述代码就是为 .string 汇编伪指令定义的字符串标上符号 .LC0。
汇编语言中可用于名字(符号)的字符范围比 C 语言广,字母
、数字
、“_”
、“$”
以及“.”
都可以使用。
2.4注释
GNU as 可以使用两种注释,即单行注释和块注释。
mov $1, %eax # 将 eax 寄存器置为 1
mov $0, %eax /* 所有内存
所有寄存器
将所有指令的值置为 0
然后我也返回 0 */
ret
2.5助记符后缀
先来说一下指令的助记符后缀(mnemonic suffix)。刚才我们提到了 movl
和 subl
为助记
符。更准确地说,mov
和 sub
为助记符,末尾的 l
是后缀。l
是long
的缩写,表示作为操作
对象的数据的大小。l
是表示数据的大小为 32 位
的后缀。
2.6各种操作数
指令的参数(操作数)有如下 4 种。
- 立即数
立即数(immediate value)是 C 语言中的字面量。机器语言中,立即数以整数的形
式出现,能够高速访问。像$27
这样,立即数用$
来标识。立即数有 8 位、16 位和 32 位。
其次,寄存器当然也能作为操作数。GNU 汇编器规定寄存器必须以 % 开头,例如 eax 寄存器写作 %eax。
2.寄存器
3. 直接内存引用
直接内存引用(direct memory reference)是直接访问固定内存地址的方式。GNU 汇编器会将任何立即数都解释成内存地址并访问。例如,若只写 0 的话,就会访问 0 地址。
比起立即数,更常用的是使用符号(symbol)直接访问内存。例如 .LC0 的意思是访问符号 .LC0 所指向的地址。符号在汇编和链接的过程中会被置换为立即数(内存地址),因此对于CPU 来说,使用符号和直接编写立即数没有差别。
4. 间接内存引用
间接内存引用(indirect memory reference)是将寄存器的值解释为内存地址并访问的方式。
2.7间接内存引用
间接内存引用中最复杂、最通用的就是下面这样的形式。disp
、base
、index
、scale
中的任何一者都可以省略。如下代码,由易到难有4中访问情况,下面一一阐明。
(%eax)
4(%eax)
(%ebx, %eax, 4)
disp(base, index, scale)
- 最简单的间接内存访问:
即只指定基地址(base)
的形式。上述表达式将 eax 寄存器
中的数据作为内存地址来访问内存。如果将(C 语言的)变量 var
的地址赋给 %eax
,那么 (%eax)
就是变量 var
的值。
- 带有 disp 的形式。
上述形式的间接内存引用是在 %eax 寄存器的数据的基础上加上 disp 的 4,以此作为内存地址进行访问。在 C 语言中,这就相当于访问如下所示的结构体 point 中的成员 y 时的情况。
- 使用 index 和 scale 的情况。
上述形式的间接内存引用所访问的是 %ebx 寄存器的值加上“%eax 寄存器的值 ×4”后得到的地址。这种形式相当于 C 语言中的数组访问。
- 将上述所有形式合到一起
即访问地址为 disp + (base + index * scale) 的内存。base 和 index 为寄存器,disp 为立即数(包括符号),scale 必须是 1、2、4、8 之中的任意立即数。
2.8 x86指令概要
三、传输指令
本节涉及到的传输指令如下表
3.1 mov指令
mov 是在寄存器或内存之间传输数据,或者将立即数加载到寄存器或内存的指令。(这里说的传输,指的是复制,并不是剪切)
mov 的第 1 操作数表示传输“源”,第 2 操作数表示传输“目标”。例如“mov 内存 , 寄存器”表示将内存中的值加载到寄存器。
3.2 push 指令和 pop 指令
push 指令将数据压栈。具体来说,将 esp 寄存器减去压栈的数据的大小,再将数据存储到esp 寄存器所指向的地址。(esp 是stack pointer)
pop 指令将数据出栈并写入寄存器。具体来说,将数据从 esp 寄存器所指向的地址加载到寄存器,再将 esp 寄存器加上出栈的数据的大小。
3.3 lea指令
lea 指令将地址加载到寄存器。lea 是 Load Effective Address(实效地址加载)的简称。“lea 内存 , 寄存器”
将内存对应的地址加载到寄存器。
例如下面的 mov 指令表示将ebx 寄存器加 4 后的值作为内存地址进行访问,并将数据加载到 eax 寄存器中。
movl 4(%ebx), %eax
另一方面,将上述语句中的 mov 指令替换为 lea 指令,如下所示。该语句表示将 ebx 寄存器加上 4 后的值保存到 eax。
leal 4(%ebx), %eax
3.4 movsx 指令和 movzx 指令
movsx
和 movzx
是将数据从位宽较小的变量扩展为位宽较大的变量的指令。例如在编译 C语言(C♭)的类型转换时就会用到上述指令。
movsx
指令将 8 位或 16 位的数据进行符号扩展并加载到寄存器。movzx
指令将 8 位或 16位的数据进行零扩展并加载到寄存器。
3.5 符号扩展和零扩展
这里的扩展指的是将低位(如8位)数据扩展成高位数据(如16位)
- 如果需要扩展的数据是无符号数据,则对数据进行零扩展,只需要在数据前面补零,使得数据位数满足要求即可。
- 如果需要扩展的数据时有符号的,就需要对数据进行符号扩展
–原数据的最高位为 0 时在高位补 0(和零扩展相同)
–原数据的最高位为 1 时在高位补 1
四、算术运算指令
涉及到的算术指令有
4.1 add指令
add 指令将第 1 操作数和第 2 操作数相加,并将结果写入第 2 操作数。
4.2 进位标志
由于寄存器和存储器都有位宽的限制,因此在进行加法运算时就有可能发生溢出。例如 8位的整数 156 和 8 位的整数 100 相加,运算结果的宽度就超过 8 位,发生溢出。
运算结果发生溢出的话,CPU 的标志寄存器 eflags 中的进位标志(Carry Flag,CF)就会被置位,即被设置为 1。
4.2 sub指令
sub 指令用第 2 操作数减去第 1 操作数,并将结果写入第 2 操作数。
4.3 imul 指令
imul 指令将第 1 操作数和第 2 操作数相乘,并将结果写入第 2 操作数。
另外,2 操作数形式的 imul 指令不支持宽度为 8 位的寄存器和存储器作为操作数。只有当2 个操作数都为 16 位或 32 位时才能进行运算。
4.4 idiv 指令和 div 指令
idiv 是有符号数的除法运算指令,div 是无符号数的除法运算指令。被除数由 edx 寄存器和 eax 寄存器拼接而成,除数由第 1 操作数指定,计算结果的商和余数分别写入 eax 寄存器和edx 寄存器。运算时被除数、商和余数的数据的位宽是不一样的,详细内容请参考表.
4.5 inc 指令
inc 指令将第 1 操作数加 1,相当于 C 语言中的 ++。
4.6 dec 指令
dec 指令将第 1 操作数减 1,相当于 C 语言中的 --。
4.7 neg 指令
neg 指令将第 1 操作数的符号进行反转,相当于 C 语言中的一元运算符 -。
五、位运算指令
所涉及的位运算指令如表所示。
5.1 and 指令
and 指令将第 2 操作数和第 1 操作数进行按位与(bitwise AND
运算,并将结果写入第 2操作数,相当于 C 语言中的 &=
运算符。
5.2 or 指令
or 指令将第 2 操作数和第 1 操作数进行按位或(bitwise OR)
运算,并将结果写入第 2 操作数,相当于 C 语言中的 |= 运算符。
5.3 xor 指令
xor 指令将第 2 操作数和第 1 操作数进行按位异或(bitwise exclusive OR)
运算,并将结果写入第 2 操作数,相当于 C 语言中的 ^=
运算符。
5.4 not 指令
not 指令将第 1 操作数按位取反(bitwise NOT)
,并将结果写入第 1 操作数,相当于 C 语言中的“~”
运算符。
5.5 sal 指令
sal 指令将第 2 操作数按照第 1 操作数指定的位数进行左移操作,并将结果写入第 2 操作数。移位之后空出的低位补 0。相当于 C 语言中的 <<= 运算符。
5.6 sar 指令
sar 指令将第 2 操作数按照第 1 操作数指定的位数进行右移操作,并将结果写入第 2 操作数。移位之后空出的高位进行符号扩展。相当于 C 语言中的 >>= 运算符。
5.7 shr 指令
shr 指令将第 2 操作数按照第 1 操作数指定的位数进行右移操作,并将结果写入第 2 操作数。移位之后空出的高位进行零扩展。相当于 C 语言中对无符号数进行操作的 >>= 运算符。
六、流程的控制
讲解实现 if 或 while 这样的流程控制语句时所使用的指令,如下表所示。
6.1 jmp 指令
jmp 指令将程序无条件地跳转到第 1 操作数指定的位置。最常用的做法是使用由标签定义的符号(symbol),写成“jmp 符号”。
movl $1, %eax # then 部分
jmp end_if0 # 跳转到 if 语句的末尾
else0:
movl $4, %eax # else 部分
end_if0:
pushl %eax # if 语句后面的语句
call printf
可以将 jmp 视作设置指令指针(eip 寄存器)的指令。例如上述例子中的 jmp end_if0,汇编后的机器语言会将 end_if0 替换为程序代码的地址(数据)。通过将 jmp_end_if0 的地址设置到 eip 寄存器来控制程序的流程。
6.2 条件跳转指令(jz、jnz、je、jne、……)
条件跳转指令很多,这里只以jnz
指令为例
jnz
指令是 Jump if Not Zero 的简称,因此仅当标志寄存器 eflags
中的 ZF(Zero Flag)
为 0 时才实施跳转。
movl y 的地址 , %ecx # 将变量 y 的值加载到 ecx
movl x 的地址 , %eax # 将变量 x 的值加载到 eax
cmp %ecx, %eax # 比较并设置标志位
jnz lab # 不相等的话跳转到 lab
# 其他指令
lab:
# 其他指令
cmp 指令是比较两个数据并设置 eflags 的各个标志位的指令。在 x 和 y 的值不相等的情况下,如果调用 cmp 指令,ZF 就会被置 0,从而执行跳转。
反之,在 x 和 y 相等的情况下,如果调用 cmp 指令,ZF 就会被置 1,跳转不会被执行,直接执行 jnz 后面的指令。
还有其他的条件跳转指令和对应的zflag
标志如下表所示
仅看跳转条件的标志位可能无法理解指令的意图,请结合指令的名称来理解。例如 ja
(Jump if Above),可以想象它表示“当 cmp 的第 2 操作数比第 1 操作数大(above)时跳转”。表示数据的大小时有 above/below 和 greater/less 这 2 套方式,above/below 用于无符号数的比较并跳转,greater/less 用于有符号数的比较并跳转。
另外,像 jz
和je
、jnz
和 jne
等,这些只是完全相同的指令的不同名称。根据使用场景选用适当的指令名称即可。
ZF
、CF
等标志位可以通过下面讲解的 cmp
指令或 test
指令进行设置。
6.3 cmp 指令
cmp
指令通过比较第 2 操作数减去第 1 操作数的差,根据结果设置标志寄存器 eflags
中的标志位。cmp
指令本质上和 sub
指令相同,只是 cmp
指令不会改变操作数的值。
操作数和所设置的标志位之间的关系如下表 所示。
6.4 test 指令
test
指令通过比较第 1 操作数和第 2 操作数的逻辑与(bitwise AND),根据结果设置标志寄存器 eflags
中的标志位。test
指令本质上和and
指令相同,只是 test
指令不会改变操作数的值。
test
指令执行后 CF
和 OF
通常会被清 0,并根据运算结果设置 ZF
和 SF
。运算结果为 0时 ZF 被置为 1,SF 和最高位的值相同。
test
指令可用于检查特定的位是否被置位等。以 C 语言为例,在检查 if(flags & EOF_FLAG)
这样的条件时,就可以使用 test 指令。
6.5 标志位获取指令(SETcc)
获取标志位的指令有很多,这里仅以 sete
指令为例,介绍一下其使用方法,其他的标志位获取指令的用法和 sete
完全相同。
6.6 call 指令
call
指令会调用由第 1 操作数指定的函数。最常见的做法是利用符号将代码写成 call printf
或 call f
的形式。还可以使用寄存器中设置的函数指针,通过 call *%eax
进行函数调用。
使用 call 指令的例子如下所示。
call printf # 调用通过符号(立即数)定义的 printf 函数
call *%eax # 利用 eax 中设置的函数指针进行函数调用
具体来说,call 指令可以分解为以下 2 个指令。
pushl $next_insn
jmp 第 1 操作数
next_insn:
也就是说,将 call 指令的下一条指令的地址压栈,再跳转到第 1 操作数指定的地址。这样函数就能通过跳转到栈上的地址从子函数返回。
6.7 ret 指令
ret
指令用于从子函数返回。x86 架构的 Linux 中是将函数的返回值设置到 eax 寄存器并返回的。
使用 ret 指令的例子如下所示。
ret # 从子函数返回
ret 指令等价于下面这样的处理。
popl %eip
也就是说,将先前 call 指令压栈的“call 指令下一条指令的地址”弹出栈,并设置到指令指针中。这样程序就能正确地返回到调用子函数的地方。
总结
下一章跟大家一起学习函数与变量