简介
本篇介绍基本的汇编语言和对应的C语言结构。包括if,while和switch,以及最重要的函数function。
NOTE本节的汇编语法以MASM-x86 64架构举例
汇编语言
指令集
CPU是用来处理数据的。其处理数据所依赖的方式是执行对应的机器指令(微指令)。而指令集就是CPU对外暴露的接口。它定义了可用的机器指令,寄存器合集,寻址方式和异常与中断语义。
因此,不同指令集的CPU使用不同的机器语言,这也对应了不同汇编语言。
目前常见的架构是:x86/MIPS/ARM以及对应的64位版本
汇编与机器语言
CPU实际执行的是机器语言(所对应的微指令),即执行的是二进制代码,类似于:55 81 ec 00 01 00 00 89 e5
这些代码是基于对应架构的,即x86/arm/arch64/PowerPC等的机器代码均不相同,这表明CPU使用的寄存器/操作内存的方法在不同架构间也是不同的。
而汇编代码,是对应机器语言的简化形式,用字符来标记某些特定的指令,简化写法。
同时,不同的汇编语言可以由不同汇编器生成同一种机器语言。例如:AT&T和MASM和INTEL语法均可由不同汇编器生成x86架构的机器代码。
汇编入门
计算机基本存储架构:CPU->寄存器->缓存->内存->外存
也就是说,当CPU需要数据时,先从寄存器中获取,如果没有,在缓存中寻找并放入寄存器。如果缓存未找到,就在内存中寻找内存。内存数据可以直接放入寄存器,也可能会用某种缓存刷新算法放入缓存。如果内存也没有,则会通过换页算法将外存内容放入内存,再放入寄存器供CPU使用。
因此,作为CPU执行的接口,肯定会提供操作寄存器和内存的指令来实现读写。同时,CPU最主要的功能是运算,因此也会有运算相关的指令。
以下,就先从寄存器开始讲起。
寄存器
在x86 架构中,提供了许多寄存器供软件使用。分为:
-
通用寄存器
-
段寄存器
-
指令指针寄存器
-
标志寄存器
-
控制寄存器
-
调试寄存器
-
浮点寄存器
-
系统寄存器
一般来说:汇编中常见的寄存器是通用寄存器,指令指针寄存器,标志寄存器和浮点寄存器。所以本文仅介绍以上寄存器。
CPU是通过外总线和内存进行数据交换。而总线宽度表明了当前计算机是多少位(C语言指针那一节说过)。因此,寄存器的大小和总线宽度相关。
通用寄存器
在16位的时候,存在这些通用寄存器:
AX,BX,CX,DX,SI,DI,BP,SP这几个寄存器每个都有一个16位长度用来存放数据。且其中的内容均可自定义,因此,被叫做通用寄存器。为了实现更精准的按字节操作,又为前4个寄存器提供了l和h后缀(比如AL和BH,low和high)用来访问寄存器低8位和高8位。(详细内容见后表)。
其中A,B,C,D无实际意义。
SI指sorce index 源地址索引寄存器,
DI指destination index 目的地址索引寄存器,
BP指base pointer 栈基地址指针寄存器
SP指stack pointer 栈指针寄存器
在32位时代,为寄存器添加了E前缀,表示extend来说明这是扩展为32位的寄存器。比如:EAX,EBX,ECX,EDX,ESI,EDI,ESP,EBP
在64位时代,为寄存器添加了R前缀,表示register来说明这是64位寄存器(也许Intel也不知道该用什么表示64位了)。同时从8个通用寄存器扩展到了16个(后面的命名就很简单了,显得很草率)。特别的,新添加的寄存器只支持了低16位的低8位的访问,没有支持高8位的访问。也许是为了整齐一点,为之前的SI,DI,BP,SP也添加了低8位访问。
通用寄存器表格如下:
| 64位寄存器 | 32位别名 | 16位别名 | 8位别名 | 用途说明 |
|---|---|---|---|---|
| RAX | EAX | AX | AL/AH | 累加器,常用于算术运算结果 |
| RBX | EBX | BX | BL/BH | 基址寄存器,常用于存储数据地址 |
| RCX | ECX | CX | CL/CH | 计数寄存器,常用于循环、移位操作 |
| RDX | EDX | DX | DL/DH | 数据寄存器,常用于乘除法、I/O |
| RSI | ESI | SI | SIL | 源索引寄存器,常用于字符串/数组操作 |
| RDI | EDI | DI | DIL | 目的索引寄存器,常用于字符串/数组操作 |
| RBP | EBP | BP | BPL | 基址指针,常用于栈帧管理 |
| RSP | ESP | SP | SPL | 栈指针,指向当前栈顶 |
| R8 | R8D | R8W | R8B | |
| R9 | R9D | R9W | R9B | |
| R10 | R10D | R10W | R10B | |
| R11 | R11D | R11W | R11B | |
| R12 | R12D | R12W | R12B | |
| R13 | R13D | R13W | R13B | |
| R14 | R14D | R14W | R14B | |
| R15 | R15D | R15W | R15B |
其中,32位时只能使用前8个寄存器且没有64位别名,16位时只可使用前8个寄存器且没有64位和32位别名。同时可以看到上面有一个用途说明,这是说明他们可能和某些汇编指令的输入和输出有关。
指令指针寄存器
在x86架构中,是IP寄存器,Instruction pointer 指令指针寄存器。存放一个地址(所以大小和位数相同)。始终指向下一条指令的地址。同时,由于不是通用寄存器,不可以以正常的方式读写,也不能使用。不过在64位时,添加了RIP相对寻址的方式来使用地址。
标志寄存器
每个机器代码运行后,都会产生一个标志表示这个代码的执行结果。
举个例子:add ax,bx表示a += b,此时ax应当是其原值+bx的值。但是如果ax是0xffff,bx是0x1,最后的ax会变为0,因为ax最大保存16位,高位无法继续进位了,因此执行后就会设置CF标志为1(carry flag),表示运算结果发生了高位进位。
整个flag组是一个32位的寄存器,被叫做rflags

具体含义如下:
| Flag | 中文名 | 含义 |
|---|---|---|
| CF | 进位标志 | 无符号加法产生进位或减法产生借位 |
| PF | 奇偶标志 | 结果最低字节中 1 的个数为偶数时置位 |
| AF | 辅助进位标志 | 低半字节(位3→位4)有进/借位,用于 BCD 运算 |
| ZF | 零标志 | 运算结果为 0 时置位 |
| SF | 符号标志 | 结果最高位(符号位)为 1 时置位,表示负数(补码) |
| TF | 陷阱标志 | 单步调试模式(TF=1 时逐条执行) |
| IF | 中断允许标志 | 控制可屏蔽中断是否被响应(1=允许) |
| DF | 方向标志 | 字符串指令处理方向(0=递增,1=递减) |
| OF | 溢出标志 | 有符号运算结果超出可表示范围时置位 |
| IOPL | I/O 特权级 | 两位字段,表示当前任务的 I/O 权限级别 |
| NT | 嵌套任务 | 任务切换时指示嵌套任务链 |
| RF | 恢复标志 | 用于调试,控制某些异常的重新触发 |
| VM | 虚拟 8086 模式 | 指示处理器处于虚拟 8086 模式 |
| AC | 对齐检查 | 启用内存对齐检查 |
| VIF / VIP | 虚拟中断标志 / 虚拟中断挂起 | 用于虚拟化环境管理中断 |
| ID | 标识标志 | 允许通过 CPUID 指令检测处理器特性 |
浮点寄存器
一般寄存器都是32位或者64位,但是浮点寄存器可以达到128位或以上:
XMM,YMM,ZMM寄存器组中XMM015是128位。YMM015是256位。ZMM0~31是512位。
这个寄存器在浮点计算和向量赋值时经常使用。
基本语法
上面看完了基本的寄存器,这里来看CPU如何使用这些内容吧
一般来说,一条汇编语句分为操作码和操作数,操作码一定有,操作数不一定。
以下是数据传输指令:(这里只是介绍)
mov dest,src;表示将src放入dest中,dest可以是寄存器也可以是地址。mov eax,0x1;mov rax,rbx;mov rax,[rbx];将rbx的内容作为地址,在地址中取值放到rax中。
lea rax,[rbx];第二操作数必须存在地址,将第二操作数的地址放到第一操作数中(因为用mov相当于自动解引用)
movzx rax,0x1;自动零扩展0x1到64位movsx rax,0x1;自动带符号扩展0x1到64位基本运算:
add rax,3;sub rax,rbx;mul ebx;高位在edx,低位在eaxidiv ecx;商在eax,余数在edx
xor rax,rax;and rbx,rax;
inc rax;rax++dec rbx;rbx--
shl rax,1;rax<<1 shift leftshr rax,1;rax>>1 shift right
sal rax,1;算数左移,shift arithmetic left,左移补0,高位进CFsar rax,1;算数右移,shift arithmetic right,右移,高位不变,低位进CF
; 以下格式同上rol;循环左移 rotate leftror;循环右移 rotate rightrcl;带进位的循环左移 rotate through carry leftrcr;带进位的循环右移 rotate through carry right栈操作相关:
sp寄存器在多数情况下都指向栈顶,使用push和pop可以操作sp寄存器
;将数据入栈,sp寄存器自动减少push rax;push 1;
;将数据出栈,sp寄存器自动增加pop rax;大致最基础的就这么多
控制流修改部分在结构里面一起说吧。
常见结构
顺序结构就不需要说了,因为只要没有改变rip的值就是顺序的。
选择结构
常见的选择结构是使用cmp配合条件跳转指令实现选择
if(x>0){ x=0;}else{ x=1;}mov rax,[rsp+0x10];假设x局部变量的位置在rsp+0x10处,此处将x放入raxcmp rax,0;cmp指令假装执行x-0并设置对应标志位。jg A;jmp if greater 如果rax大于0则rip跳转到对应位置执行,跳转条件由上方标志位设置mov rax,1jmp B;直接跳转A:mov rax,0;B:mov [rsp+0x10],rax;写回局部变量到内存另一种实现方式是条件mov
mov rax,[rsp+0x10];xor rcx,rcx;将rcx置零mov rdx,1;
cmp rax,0;cmovg rdx,rcx;条件mov指令,conditional move if greater 如果rax大于0,就将rcx移入rdx。
mov [rsp+0x10],rdx;将rdx写入循环结构
一般使用cx作为计数器配合jnz使用,可在开头也可在结尾。
void map_pos_to_zero(const long *a, long *b, size_t n) { for (size_t i = 0; i < n; ++i) { long x = a[i]; if (x > 0) b[i] = 0; else b[i] = 1; }} test rcx, rcx jz .done
.loop: mov rax, [rdi] ; rax := a[i] xor r8, r8 ; r8 := 0 (then value) mov r9, 1 ; r9 := 1 (else value) cmp rax, 0 ; compare x,0 (signed) cmovg r9, r8 ; if x>0 (signed) r9 := 0 else r9 stays 1 mov [rsi], r9 ; b[i] := r9
add rdi, 8 ; 增加目的指针索引 add rsi, 8 ; 增加源地址索引 dec rcx;该代码在rcx等于0时置ZF为1 jnz .loop;ZF不为1时跳转.done:SWITCH
跳转多时使用跳转表实现:
switch (v) { case 10: goto case10; case 11: goto case11; case 12: goto case12; case 13: goto case13; case 14: goto case14; case 15: goto case15; default: goto default_case;}
case10: return;
case11: return;
case12: return;
case13: return;
case14: return;
case15: return;
default_case: return;} mov rax, rdi ; rdi = switch value sub rax, 10 ; 基为10 cmp rax, 5 ; 如果大于15直接跳到default ja .L_default lea rbx, [rel .jump_table];获取表入口 mov rdx, [rbx + rax*8] ; jmp rdx;jmp可以用寄存器索引,寄存器值为目标地址。
.jump_table: dq .L_case0 dq .L_case1 dq .L_case2 dq .L_case3 dq .L_case4 dq .L_case5函数结构
函数是基于栈的,此处的函数结构,是指如何调用,如何传参,如何平栈,如何存取局部变量。
调用方法
汇编中,调用一个函数使用call和ret。
call是指,压入rip的值到栈顶,然后rip设置到对应的函数开头。 ret是指,将栈顶的值设置到rip。
通过这种方法来实现函数的切换。
调用约定
在x86 64 windows下,只有一种调用约定,即前4个参数放在rcx,rdx,r8,r9,剩余的参数从右至左逆序入栈。被调用者清理堆栈。同时,如果函数有返回值,则均放在rax寄存器中。
类似于这样:
; 假设参数为 a1..a6(均 64-bit),调用 foo(a1,a2,a3,a4,a5,a6) sub rsp, 10 ; 先减出来,之后使用mov入栈 mov rcx, rax ; a1 -> RCX mov rdx, rbx ; a2 -> RDX mov r8, rcx ; a3 -> R8 mov r9, rsi ; a4 -> R9 mov qword [rsp+8], r8 ; push a6 (最右) 到栈 (offset 32 为第6) mov qword [rsp], rdx ; push a5 到栈 ; 注意:上面用 mov 写入栈代替 push 以保持对齐/效率 call foo ; 继续操作函数结构
该结构为常见结构,但是也不一定需要完全遵循。
foo: push rbp ; 保存调用者函数的栈基址 mov rbp, rsp ; 设置栈基址为当前栈顶 sub rsp, 32 ; 局部空间(保持对齐),使用了多少最后就恢复多少。用于存放局部变量 push rbx ; 保存非易失寄存器,这里只保存想保存的,顺序可以和上一个交换,主要目的是最后返回上一个栈帧的时候可以还原寄存器 ; 函数主要逻辑 ; 如果有 pop rbx ; 还原寄存器 add rsp, 32 ; 减回栈 pop rbp ; 还原栈基址 ret ; 返回不同的编译器可能会额外对栈和局部变量区进行处理。比如MSVC会在执行函数具体指令前,将栈内容全部填充为0xCC。
基本的内容就这么多,本节的内容建议打开IDA一边看对应的结构一边理解。
部分信息可能已经过时









