文章封面图

Essay2026 / 03 / 15

初入汇编-编译过程与基础汇编指令

本文以C语言与x86-64为基底,开始讲述程序底层的汇编表示。内容包括,编译器工作流程,一个实际的例子,基本的汇编代码知识与汇编指令

1 编译器工作


我们通过输入命令 gcc -o hello.exe hello.c,让gcc编译器将我们的hello.c转变为hello.exe.这其中,gcc实际上需要其他工具来完成这一个过程。该过程一共有四步,每一步都有相应地程序执行。 预编译 处理你的#include等指令,做一些文本替换的活。 编译 把预编译过来的代码文件翻译成汇编代码。此时你的代码依然为文本形式。 汇编 将汇编代码文件翻译为二进制代码。 链接 将二进制代码链接,最终形成可执行文件. 其中在上述的gcc编译命令中,-O指令可指定优化级别,不同优化级别产生的汇编代码是有一定不同的。

2 开始我们的尝试


现在,打开你的IDE,输入下面这段C代码,文件名为 mstore.c

long mult2(long ,long);
void mulstore(long x,long y,long *dest){
    long t = mult2(x ,y);
    *dest = t;
}

然后,执行gcc -Og -S mstore.c -Og指定编译器的优化级别,使得得到的汇编代码适合我们学习 -S指定gcc执完编译该步后输出汇编文件mstore.s。它是文本文件,你可以直接打开它。 该汇编文件一般有下面这几行代码,每一行都代表一条机器指令。

mulstore:
 pushq %rbx
 movq %r8, %rbx
 call mult2
 movl %eax, (%rbx)
 addq $32, %rsp
 popq %rbx
 ret

其中如果让编译器继续往下执行到汇编这步,输入 gcc -Og -c mstore.c,则会生成目标文件mstore.o。其内部就存储着机器执行的二进制码。其中核心的是下面这个14字节的序列 53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3 但它是机器读的,不是人类读的。我们需要反汇编器(disassembler) 来将二进制码翻译回汇编代码。 执行 objdump -d mstore.o,将会输出以下内容

Disassembly of section .text:

0000000000000000 <mulstore>:
   0:   53                      push   %rbx
   1:   48 83 ec 20             sub    $0x20,%rsp
   5:   4c 89 c3                mov    %r8,%rbx
   8:   e8 00 00 00 00          call   d <mulstore+0xd>
   d:   89 03                   mov    %eax,(%rbx)
   f:   48 83 c4 20             add    $0x20,%rsp
  13:   5b                      pop    %rbx

现在你应该关注到一个点,就是 机器执行的指令是不定长的。从1到15字节不等。其中常用简单的指令会短,不常用复杂的指令会长

3 读懂我们的汇编


刚才我们最接近系统底层且能读懂的内容就是汇编代码。 那么打开我们刚刚的mstore.s。忽略掉那些以.开头的行,得到的汇编代码如下

注解:以 . 开头的都是伪指令,他们是起指导汇编器与编译器的作用

mulstore:
 pushq %rbx
 movq %r8, %rbx
 call mult2
 movl %eax, (%rbx)
 addq $32, %rsp
 popq %rbx
 ret

其中每一行代表一条机器指令。我们接下来将学习汇编代码基础知识。最后读懂汇编代码与找到相应的C语言代码之间的映射

3.1 数据格式

在刚才的汇编代码中,push,mov,add等指令都带有一个字母的后缀。这个后缀表示数据的格式。其中不同后缀对应的数据格式如下

C声明Intel数据类型汇编代码后缀大小(字节)
char字节b(yte)1
shortw(ord)2
int双字l(ong)4
long四字q(uad)8
char*四字q8
float单精度s4
double双精度l8

注解:虽然int与double采用同一套后缀l,但是执行他们的指令与寄存器是完全不同的

3.2 寄存器命名

一个x86-64CPU提供一组16个存储64位值的通用目的寄存器。在汇编代码中,如%rbx,%r8,%eax都是在表示相应的寄存器。寄存器是存在与CPU内的高级其中命名对应的寄存器关系详见CSAPP:120页

3.3 操作数

函数要参数,指令就要操作数 操作数有三种 立即数(immediate) 存常量,格式为$数值 寄存器(register) 寄存器的值,格式如 %rax或rax 内存值 内存的值,通过寻址与指定长度选取相应的值。 格式为 立即值 (寄存器1,寄存器2,2的幂次) 如12(%rax,%rdx,4)表示的内存地址为 12 + %rax + 4*%rdx.

3.4 传送数据指令

现在我们解释完了数据格式的表示,寄存器,指令要的操作数。终于能够讲讲指令了。 完成数据移动操作的指令为 MOV指令MOV S,D 将S的数值复制到D中。 例子:

movl $0x4050,%eax    将立即值0x4050传入寄存器eax 4字节
movw %hp,%sp         将寄存器hp值传入寄存器sp 2字节
movb (%rdi,%rcx),$al 将内存(%rdi,%rcx)值传入寄存器al 1字节
movq $114514,(%rsp)  将立即值114514传入内存(%rsp) 8字节
movq %rax,-12(%rbp)  将寄存器rax值传入内存-12(%rbp) 8字节

其中,对于小数据(char,short)传到大数据(int,long)的过程,还有两种指令 MOVZ指令MOVZ S,RMOVS指令MOVS S,R MOVZ类指令遵循零扩展原则,较小得值传到较大值时,剩余位用0填充 MOVS类指令遵顼符号填充原则,较小值传到较大值时,剩余位用源的符号位进行填充 这两类指令后面跟两个数据长度标识。 例子

movzbw 0xFF,%dx    byte --> word 将0xFF复制到寄存器dx 1字节到2字节
movswl %dx,%eax    word --> long将寄存器dx的值复制到eax中 2字节到4字节

3.5 算术逻辑运算指令

x86-64的算数逻辑运算指令表如下 alt text 这里按照分类分为 加载有效地址一元运算符二元运算符移位

3.5.1 加载有效地址

Load Effective Address(lea) 格式为lea S D S为内存地址,D为寄存器。起作用为将左侧内存地址的值赋给右侧寄存器,实际上就是一种特化的mov指令。在64位系统中,地址长度8字节,所以lea指令为leaq。在32位系统中,地址长度为4字节,所以lea指令为leal。 这条命令有两个作用,一个是将内存地址加载到寄存器,还有就是实现简单的算数运算 例子:

设x在%rdi,y在%rsi,z在%rdx
leaq 0x123456789ABCDEF,%rax  左侧立即值直接赋给rax
leaq (%rdx,%rdx,4),%rdx      实现z = z + 4z  
leaq (%rdi,%rsi,4),%rdx      实现z = x + 4y

再比如说下面这段C代码,编译后由三条leaq指令实现

long scale(long x,long y,long z){
    long t = x + 4*y + 12*z;
    return t;
}
设x --> %rdi  y --> %rsi   z --> %rdx     t --> %rax
scale:
    leaq (%rdi,%rsi,4),%rax   t = x + 4y 
    leaq (%rdx.%rdx,2),%rdx   z = z + 2z
    leaq (%rax,%rdx,4),%rax   t = t + 4z
    ret

3.5.2 一元二元操作

一元操作符的操作数即是源又是目的 二元操作符由两个操作数,第一个是源,第二个即使源也是目的 例子:

设 x -- > rax ,y --> rcx , z --> (%rax)
addq %rcx,%rax    y =  x + y
subq %rcx,(%rax)  z = z - y
incq %rax         x = x + 1
decq %rcx         y = y - 1

3.5.3 移位操作

移位操作的格式为 移位指令 移动位数,操作数 其中,移位指令有 SAL算术左移,SHL逻辑左移,SAR算术右移,SHR逻辑右移

3.6 控制

接下来我们将要理解C的控制语句if,while,for的汇编实现

3.6.1 条件判断

汇编层面的核心指令为 CMP指令,TEST指令,SET指令 要理解这三个指令,需要了解一个预备知识 条件码(condition code) CPU内部还维护着一组单个位的条件码寄存器。它们记录着最近的算术或者逻辑操作的属性。常用的有CF(进位标志),ZF(零标志),SF(符号标志),OF(溢出标志)。所以在上述的算术逻辑运算指令执行后,也会相应地更新这些寄存器。如果要进行条件判断记录,就要在其寄存器内容还未改变前读取寄存器数据。 具体例子如 执行addq %rax %rex后,加法发生溢出,那么OF就会被设置为1.此时如果使用能够读取条件码的指令,我们就可以判断刚才的加法有没有发生溢出 CMP指令 CMP指令接受两个操作数,然后更新寄存器。其操作就是让两个数相减,但是不输出减法结果,只修改条件码寄存器。 TEST指令 TEST指令接受两个操作数,然后更新寄存器,其操作就是让两个数进行位级与运算(A & B),但是不输出结果,只修改条件码寄存器。其中test指令用的最多的用途如下

设x --- > rdi
testq %rdi,%rdi  TEST同一寄存器
sete %al        判断x == 0
setg %al        判断x > 0
setle %al       判断x <= 0

SET指令 SET指令根据标志位寄存器的值,进行不同的运算,将返回值存储在寄存器的低八位上。

3.6.2 跳转指令

跳转的核心指令为 JMP类指令 为了方便讲解,直接上代码

无条件判断的jmp指令有直接跳转与间接跳转两种模式
jmp L1       直接跳转,跳转至L1位置
jmp *%rax    间接跳转,从寄存器或者内存读取到跳转目标
jmp *(%rex)  间接跳转的写法就是*跟一个操作数指示符

然后,jmp类指令还存在有条件判断的。其种类与set类指令相同,通过读取条件码寄存器并运算,通过运算结果检验条件是否成立,在条件成立时发生跳转。 下面为例子

int x ---> %rax , int y ---> %rex
cmpl y,x    比较x,y
je L1       在x == y情况下,跳转至L1
jl L2       在x > y的情况下,跳转至L2
L1:
    movq %rax %rex
L2:
    movq %rex %rax

3.6.3 条件传送

条件传送指令为 CMOV指令