汇编基础入门
熟悉汇编会对代码的运行过程有一个深入的认知,这里简单总结下汇编的一个整体结构,汇编代码是如何协调CPU与内存、CPU寄存器的作用、高级语言的功能其ASM的实现本质等。
1. 环境搭建
软件 | 说明 |
---|---|
centos | 操作系统 编译执行环境 |
nasm | 汇编代码编译器 |
gcc | 编译链接工具生成可执行代码 |
需要安装汇编代码的编译器 nasm
,nasm会将汇编代码 *.asm
编辑成中间对象 *.o
,想要得到可执行文件还需要gcc对中间文件进行link
操作,具体操作方式如下
1 | # 将汇编代码编译成中间码 |
其中 -g
是为了生成调试符号,这样就可以用gdb
进行代码调试,-f elf
是用来指定生成的中间代码格式,这里指定生成的是 elf
这个是在unix系统中用的格式,其他支持的格式暂未了解
1 | # 将中间码链接包装成可执行文件 |
这里使用的是 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 | gloabl main |
内存操作
代码在加载的时候需要将数据加载到内存,CPU在进行计算的时候会将内存的数据读入到寄存器(通过汇编mov执行)
1 | global main |
其中定义内存值的方式是 变量名称 dd 具体的值
其中的 dd
是 define double word
的意思 占用32bit(4字节),还可以定义为 dw
define word 是占用16bit(2字节),使用的时候就是通过汇编指令写入到CPU寄存器
4.汇编指令与寄存器
基本运算
常用的计算指令有 ADD SUB MUL DIV 分别对应 加 减 乘 除,其中指令 CMP 也是计算指令,两数比较其实就是两个数字相减。CMP质量主要配合指令 JMP有条件跳转 进行使用。
指令跳转
指令跳转相关的操作指令有 JMP
CALL
RET
,其中指令 RET
是直接返回调用处,配合指令 CALL
完成函数调用。JMP
和 CALL
的区别是,CALL
指令在执行完成后 会返回到 跳转处继续向下执行,但是JMP
不会调转回来。
数据传输指令
常用的数据传输指令有 MOV``PUSH``POP
,其中的MOV
指令是将数据复制到CPU寄存器或读取CPU寄存器中的值,PUSH``POP
这两个指令是将寄存器中的值存储到一个栈结构中,这个功能可以实现高级语言中的局部变量功能(因为CPU中的变量存储在寄存器中,而寄存器是全局可用的,稍微复杂的计算就会导致寄存器中的值覆盖)
还有两个用于实现原子操作的指令 XCHG
CMPXCHG
,其功能是将汇编中的值与寄存器中的值进行交换,CMPXCHG
是要先比较再交换
5.高级语言功能的实现
判断循环的实现
判断的实现
实现条件判断需要用到的指令有 CMP
和 JMP
,先看JMP指令,JMP直接将指令执行顺序切换到了其他位置,如下代码执行结果是 6
1 | main: |
其实CPU在实现JMP指令功能的时候用到了一个重要的寄存器 EIP
,EIP记录了执行执行的位置,改变EIP中的指令位置就可以实现 JMP 指令的功能。
若想实现条件判断的功能需要用到 CMP
指令 和有条件的 JMP
指令,有条件的JMP指令如 JE
比较指令结果是等于跳转 、JG
比较指令结果是大于跳转 、JL
比较指令结果是小于跳转等,对于指令的执行 CMP
的比较结果需要存储,这就涉及到另一个重要的寄存器 EFLAGS
,在指令进行计算的时候产生的结果会影响到EFLAGS
寄存器中各个标志位的值,CMP进行相减运算后同样会影响EFLAGS
寄存器,有条件跳转就可以通过这个寄存器决定是否需要跳转到其他指令位置
如下代码
1 | int a = 10; |
使用汇编改写
1 | global main |
上面汇编还是需要 jmp 指令跳回到主逻辑中,这样对代码的阅读是不便的,高级语言在做这一步翻译的时候往往会将代码逻辑的判断进行一个倒置
操作
循环的实现
循环的实现相对就简单了,可以通过指令JMP相互调用实现循环,通过上面的判断实现循环的结束
1 | int a = 0; |
对应的汇编代码
1 | main: |
函数调用的实现
函数调用相对简单 可以使用CALL指令直接实现函数的调用,CPU在实现CALL指令的功能用到了寄存器 ESP
这是一个栈(stack)寄存器,这样就能实现函数之间的层层调用 ,ESP寄存器中会记录(入栈)每个函数的调用地址,调用结束寄存器出栈就能获得上层调用的指令地址,这样就实现了调用的返回。函数涉及到的入参、返回值就需要使用通用寄存器来实现,当调用场景交复杂的话,这些寄存器作为全局可读写的参数存储介质,很容易被覆盖参数,这时候要实现函数变量中的局部变量,就要用到指令 PUSH
POP
对寄存器的值进行入栈、出栈操作。
如下一个例子可以观察到变量被覆盖的情况
1 | int eax = 1 |
对应的汇编代码如下
1 | main: |
正确的运行结果应该是 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 | int a = 4 |
对应的汇编代码如下
1 | mov eax, 4 |
分析其实现原子赋值的过程,假设我们需要将变量b的值从4改为5,那么对应的汇编代码过程应该是
1 | mov ebx, [b] ; 变量b的值存储到寄存器 ebx [eax = 0, ebx = 4] |
若eax与ebx的相等则说明修改不成功,若相等则修改成功
1 | cmp, eax, ebx |
参考 汇编入门