熟悉汇编会对代码的运行过程有一个深入的认知,这里简单总结下汇编的一个整体结构,汇编代码是如何协调CPU与内存、CPU寄存器的作用、高级语言的功能其ASM的实现本质等。

1. 环境搭建

软件 说明
centos 操作系统 编译执行环境
nasm 汇编代码编译器
gcc 编译链接工具生成可执行代码

需要安装汇编代码的编译器 nasm ,nasm会将汇编代码 *.asm 编辑成中间对象 *.o ,想要得到可执行文件还需要gcc对中间文件进行link操作,具体操作方式如下

1
2
# 将汇编代码编译成中间码
nasm -g -f elf "source.asm" -o "dist.o"

其中 -g 是为了生成调试符号,这样就可以用gdb进行代码调试,-f elf 是用来指定生成的中间代码格式,这里指定生成的是 elf 这个是在unix系统中用的格式,其他支持的格式暂未了解

1
2
# 将中间码链接包装成可执行文件
gcc -m32 "dist.o" -o "dist"

这里使用的是 gcc编译,其中的-m32指的是按照32位CPU指令 x86 架构的格式编译 (在mac上未能成功,mac已经不支持x86的编译,这里是在centos中操作的)

2. 用gdb调试汇编代码

为了验证一些汇编指令具体做了什么事情,可以通过 gdb 对代码断点调试,查看中间态验证命令的作用,这里终结了常用的gdb命令以及基础用法

基本用法

gdb ${可执行文件} 启动gdb并打开需要调试的文件 (记得编译的时候使用参数 -g 保留调试符号 否则无法用gdb调试)

set disassembly-flavor intel 将汇编设置为intel格式方便阅读

disas main gdb提供的反汇编指令(disassemble),其中的 main作为参数是函数名称

常用断点调试命令

命令 简写 作用
run r 开始执行代码
break ${num/address} b 设置断点 可以 break 12 指定代码行 也可以 break *0x11 指定指令内存地址
stepi si CPU指令级别的单步调试 若遇函数调用则进入函数内部
nexti ni CPU指令级别的单步调试 若遇函数调用不会进入函数内部
continue c 执行代码到下一个断点
info i 查看断点/寄存器/变量等信息
list l 查看源代码
quit q 退出gdb
step/next s/n 参考指令 stepi/nexti 区别是 这两个指令并非CPU指令级别的单步,而是代码行级别的

3. 寄存器和内存的通信

汇编的作用就是操作CPU进行计算,CPU在计算的时候需要存储计算的值,这些值的存储位置就是CPU的寄存器,复杂的计算还需要内存辅助数据的存储,这里看下内存与CPU寄存器的配合方式

####关于寄存器

CPU的通用寄存器有 eax ebx ecx edx 等,不同架构提供的通用寄存器数量和名称有区别,这些通用寄存器就是可以给用户侧(汇编代码)自由使用的寄存器,其他的非通用寄存器如 eip esp eflags等是为了CPU实现一些功能用的,不会开放给用户侧使用

其中寄存器 eax 通常会当作饭回值使用,比如我们的可执行文件执行完成饭回的值 (echo $?) 其实就是 执行完成后寄存器 eax当时的值

一个简单的汇编代码,运行结果是 28

1
2
3
4
5
gloabl main
main:
mov eax, 20 ; 给eax寄存器赋值
add eax, 8 ; 给eax的值 +8
ret ; 结束 返回了eax中的值

内存操作

代码在加载的时候需要将数据加载到内存,CPU在进行计算的时候会将内存的数据读入到寄存器(通过汇编mov执行)

1
2
3
4
5
global main
main:
mov eax, [myvar] ; 将内存数据写入到寄存器
section .data ; 数据区
myvar dd 12 ; 定义内存变量(常亮)值

其中定义内存值的方式是 变量名称 dd 具体的值 其中的 dddefine double word 的意思 占用32bit(4字节),还可以定义为 dw define word 是占用16bit(2字节),使用的时候就是通过汇编指令写入到CPU寄存器

4.汇编指令与寄存器

基本运算

常用的计算指令有 ADD SUB MUL DIV 分别对应 加 减 乘 除,其中指令 CMP 也是计算指令,两数比较其实就是两个数字相减。CMP质量主要配合指令 JMP有条件跳转 进行使用。

指令跳转

指令跳转相关的操作指令有 JMP CALL RET ,其中指令 RET 是直接返回调用处,配合指令 CALL 完成函数调用。JMPCALL 的区别是,CALL 指令在执行完成后 会返回到 跳转处继续向下执行,但是JMP不会调转回来。

数据传输指令

常用的数据传输指令有 MOV``PUSH``POP,其中的MOV指令是将数据复制到CPU寄存器或读取CPU寄存器中的值,PUSH``POP这两个指令是将寄存器中的值存储到一个栈结构中,这个功能可以实现高级语言中的局部变量功能(因为CPU中的变量存储在寄存器中,而寄存器是全局可用的,稍微复杂的计算就会导致寄存器中的值覆盖)

还有两个用于实现原子操作的指令 XCHG CMPXCHG ,其功能是将汇编中的值与寄存器中的值进行交换,CMPXCHG 是要先比较再交换

5.高级语言功能的实现

判断循环的实现

判断的实现

实现条件判断需要用到的指令有 CMPJMP ,先看JMP指令,JMP直接将指令执行顺序切换到了其他位置,如下代码执行结果是 6

1
2
3
4
5
6
7
8
main:
mov eax, 10
jmp other
mov eax, 20 ; jmp 指令跳转后不会返回来继续执行这条命令
ret
other:
mov eax, 6
ret

其实CPU在实现JMP指令功能的时候用到了一个重要的寄存器 EIP ,EIP记录了执行执行的位置,改变EIP中的指令位置就可以实现 JMP 指令的功能。

若想实现条件判断的功能需要用到 CMP 指令 和有条件的 JMP 指令,有条件的JMP指令如 JE 比较指令结果是等于跳转 、JG比较指令结果是大于跳转 、JL比较指令结果是小于跳转等,对于指令的执行 CMP 的比较结果需要存储,这就涉及到另一个重要的寄存器 EFLAGS ,在指令进行计算的时候产生的结果会影响到EFLAGS寄存器中各个标志位的值,CMP进行相减运算后同样会影响EFLAGS寄存器,有条件跳转就可以通过这个寄存器决定是否需要跳转到其他指令位置

如下代码

1
2
3
4
5
6
7
8
int a = 10;
int b = 0;
if (a > 20) {
b = 10;
} else {
b = 20;
}
return b + 10;

使用汇编改写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
global main
main:
mov eax, 10
mov ebx, 0
cmp eax, 20
jg other
mov ebx, 20
jmp end ; 这里需要调过 other分支 执行后面的代码指令
other:
mov ebx, 10
end:
add ebx, 10
mov eax, ebx
ret

上面汇编还是需要 jmp 指令跳回到主逻辑中,这样对代码的阅读是不便的,高级语言在做这一步翻译的时候往往会将代码逻辑的判断进行一个倒置操作

循环的实现

循环的实现相对就简单了,可以通过指令JMP相互调用实现循环,通过上面的判断实现循环的结束

1
2
3
4
5
int a = 0;
for (int i = 0; i < 10; i++) {
a = a + i;
}
return a;

对应的汇编代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main:
mov eax, 0
mov ebx, 0
_do:
; 判断停止条件
cmp ebx, 10
jg _end
; 执行具体逻辑
add eax, ebx
inc ebx
; 循环实现
jmp _do
_end: ; 结束部分
ret

函数调用的实现

函数调用相对简单 可以使用CALL指令直接实现函数的调用,CPU在实现CALL指令的功能用到了寄存器 ESP 这是一个栈(stack)寄存器,这样就能实现函数之间的层层调用 ,ESP寄存器中会记录(入栈)每个函数的调用地址,调用结束寄存器出栈就能获得上层调用的指令地址,这样就实现了调用的返回。函数涉及到的入参、返回值就需要使用通用寄存器来实现,当调用场景交复杂的话,这些寄存器作为全局可读写的参数存储介质,很容易被覆盖参数,这时候要实现函数变量中的局部变量,就要用到指令 PUSH POP 对寄存器的值进行入栈、出栈操作。

如下一个例子可以观察到变量被覆盖的情况

1
2
3
4
5
int eax = 1
func fibo(int eax) {
if (eax > 2) return 1;
return fibo(eax + 1) + eax
}

对应的汇编代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
main:
mov eax, 1
mov ebx, 0
fibo:
cmp eax, 2
jg end

push ebx ; 寄存器入栈 保存当前变量的值

mov ebx, eax
add eax, 1
call fibo
add eax, ebx

pop ebx ; 寄存器出栈 取出上次 push 时候保存的值

ret
end:
mov eax, 1
ret

正确的运行结果应该是 4 可以尝试将寄存器 ebx 进行出栈入栈的操作注释掉看看,运行结果确是 5,可以分析下这个结果的得出原因其实就是ebx 在call的调用中其中的值被覆盖了,倒置调用完成回来的时候的 +n 操作,其中的n已经被改变了,具体可以将汇编过程展开来看

第一层 第二层 第三层 EAX EBX
mov eax, 1 mov ebx, 0 1 0
cmp eax, 2 jg end [false]
mov ebx, eax 1 1
add eax, 1 2 1
cmp eax, 2 jg end [false]
mov ebx, eax 2 2
add eax, 1 3 2
cmp eax, 2 jg end [true]
mov eax, 1 1 2
add eax, ebx 3 2
add eax, ebx 5 2

这里出错的主要点在 最后一步,此时的ebx应该是 1 而不是被第二层、第三层修改调的 2 ,要达到既窜正确需要在后面调用层进入前把这个值给存储起来,也就是 PUSH 操作,等需要回调完成需要这个值的时候将这个值POP出来参与运算。

CMPXCHG指令

交换指令 XCHG eax, ebx 就是简单的交换 eax 与 ebx 的值,CMPXCHG ebx, ecx 需要先让 ebx 与 eax 进行比较,相等的话才会将ecx 的值赋值给 ebx (ecx的值不变),不相等的话会将ebx的值赋值给eax,等同如下代码逻辑

1
2
3
4
5
6
7
8
int a = 4
void cmpxchg(b, c) {
if (b == a) {
b = c
} else {
a = b
}
}

对应的汇编代码如下

1
2
3
4
5
6
mov eax, 4
mov ebx, 3
mov ecx, 5
cmpxchg ebx, ecx ; ebx == eax ? ebx = ecx : eax = ebx
; mov eax, ebx
ret

分析其实现原子赋值的过程,假设我们需要将变量b的值从4改为5,那么对应的汇编代码过程应该是

1
2
3
4
5
6
mov ebx, [b] ; 变量b的值存储到寄存器 ebx [eax = 0, ebx = 4]
mov eax, ebx ; 此时ebx的值为老值 [eax = 4, ebx = 4]
mov ecx, 5
; 中间存在ebx的值可能被修改的指令
cmpxchg ecx, ebx ; 若ebx的值未修改 [eax = 4, ebx = 4] -> [eax = 4, ebx = 5]
; 若ebx的值被修改 [eax = 4, ebx = 10] -> [eax = 10, ebx = 10]

若eax与ebx的相等则说明修改不成功,若相等则修改成功

1
2
3
cmp, eax, ebx
je 失败
成功

参考 汇编入门