一文了解汇编语言

汇编语言是什么

在计算机中,所有的程序本质上都是01的序列串。很久以前,计算机使用带孔的纸带来编写程序,其中有孔和无孔代表1和0,如下图所示。

但是这种方式,对人类来说不太直观,因此非常容易出错。为了解决这个问题,人们发明了汇编语言(Assembly Language)。汇编语言使用文本符号来代表处理器指令,由于和人类的自然语言比较接近,所以很容易看懂,也很容易书写。

如何生成汇编语言代码

arduino 复制代码
#include <stdio.h>
int main(void){
    printf("Hello World");
    return 0;
}

以上面的代码为例,我们使用下面的命令来生成对应的汇编代码。

ini 复制代码
gcc -O0 -Wall -S -fno-asynchronous-unwind-tables -fcf-protection=none hello.c

在Linux上生成的汇编代码内容如下:

perl 复制代码
	.file	"hello.c"
	.text
	.section	.rodata
.LC0:
	.string	"Hello World"
	.text
	.globl	main
	.type	main, @function
main:
	pushq	%rbp
	movq	%rsp, %rbp
	leaq	.LC0(%rip), %rax
	movq	%rax, %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	popq	%rbp
	ret
	.size	main, .-main
	.ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
	.section	.note.GNU-stack,"",@progbits

注意:在不同的系统上由于采取的语法不同因此生成的汇编代码可能是不同的。这里的汇编代码采用了 AT&T 语法(GAS 默认) ,而不是 Intel 语法 。具体区别可看X86汇编语言风格比较: AT&T 和 Intel 风格 - 知乎

获取汇编代码后,我们可以直接修改当前的汇编代码,然后使用如下的命令来生成可执行文件,这样就可以验证我们修改代码的正确性了。

csharp 复制代码
gcc -o add add.s

汇编语言的组成元素

汇编代码通常包含指令伪指令操作数寄存器标签注释六种元素。这里以上面生成的汇编代码为例,我们来看看这六种元素。

指令

指令(instruction)是指直接由 CPU 进行处理的命令 。比如 movqcall 等都是指令,关于这些指令的作用后面再进行介绍。

perl 复制代码
movq	%rax, %rdi
movl	$0, %eax
call	printf@PLT

需要注意的是,movqmovl 指令中的后缀表示操作数的位数。后缀一共有 b, w, l, q 四种,分别代表 8 位、16 位、32 位和 64 位。

伪指令

汇编语言中,伪指令是以"."开头,末尾没有冒号":"的给汇编器处理的命令。伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息。需要注意:伪指令不是真正的 CPU 指令,就是写给汇编器的。每种汇编器的伪指令也不同,要查阅相应的手册。

arduino 复制代码
.file	"hello.c"
.text 
.section .rodata
.globl	main
.type	main, @function
.size	main, .-main
.ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
.section	.note.GNU-stack,"",@progbits

如上所示:

  • .file就是一个伪指令, 该指令是gcc中用于指定汇编语言所在的C源文件名称。
  • .text 告诉汇编器把之后的内容放入文本节(text section),即Linux的ELF格式中的文本段(也称为代码段)。程序指令和常量数据存储在文本段。操作系统将该段设为只读,避免程序修改其中的内容。
  • .section .rodata .section 表示后续代码/数据所属的段,而 .rodata 表示只读数据段。
  • .globl 表示全局可用,因此在其他文件中定义的函数也能够引用此名称
  • .type 有两个参数:main和@function。这使得标识符main作为函数名被记录在目标文件中。
  • .size 计算汇编此函数main所产生的机器码的大小(以字节为单位)。其中 .-main是计算表达式,表示当前位置(.)到 main 起始位置的字节数,即 main 函数的大小。
  • .ident.section 可能是为了在用户报告bug的时候为gcc的开发人员提供信息。没

操作数

在上面的代码示例中,类似%rax$0-4(%rbp)的被指令操作的格式叫做操作数。操作数,一般有四种格式,分别为立即数、寄存器、直接内存访问和间接内存访问

操作数 立即数 寄存器 直接内存访问 间接内存访问
区别 立即数以 $ 开头,比如 $40,表示数字40,而不是内存地址 寄存器一般以 % 开头,比如 %rax 直接内存访问是只有一个数字,比如 40, 这时它表示内存地址 带有括号,比如(%rbp),它是指 %rbp 寄存器的值所指向的地址

间接内存访问的完整形式是:偏移量(基址,索引值,字节数) 这样的格式。其地址是: 基址 + 索引值 * 字节数 + 偏移量

举例来说:

  • 8(%rbp),是比 %rbp 寄存器的值加 8。
  • -8(%rbp),是比 %rbp 寄存器的值减 8。
  • (%rbp, %eax, 4)的值,等于 %rbp + %eax*4。这个地址格式相当于访问 C 语言中的数组中的元素,数组元素是 32 位的整数,其索引值是 %eax,而数组的起始位置是 %rbp。其中字节数只能取 1,2,4,8 四个值。

寄存器

x86-64 架构的 CPU 里有很多寄存器,我们在代码里最常用的是 16 个 64 位的通用寄存器,分别是:

perl 复制代码
%rax,%rbx,%rcx,%rdx,%rsi,%rdi,%rbp,%rsp, %r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。

上述寄存器都是通用寄存器,可以用于各种操作。但是为了方便软件的编写,对这些寄存器做了约定。比如:

  • %rax 除了其他用途外,通常在函数返回的时候,把返回值放在这里。
  • %rsp 作为栈指针寄存器,指向栈顶。
  • %rdi%rsi%rdx%rcx%r8%r9 给函数传整型参数,依次对应第 1 参数到第 6 参数。
  • 如果程序要使用 %rbx%rbp%r12%r13%r14%r15 这几个寄存器,是由被调用者负责保护的,也就是写到栈里,在返回的时候要恢复这些寄存器中原来的内容。其他寄存器的内容,则是由调用者(Caller)负责保护,如果不想这些寄存器中的内容被破坏,那么要自己保护起来。

需要注意上面这些寄存器的名字都是 64 位的名字,对于每个寄存器,我们还可以只使用它的一部分,并且另起一个名字。比如对于 %rax,如果使用它的前 32 位,就叫做 %eax,前 16 位叫 %ax,前 8 位(0 到 7 位)叫 %al,8 到 15 位叫 %ah。同一寄存器使用不同部分的名称如下:

标签

标签以冒号":"结尾,用于对伪指令生成的数据或指令做标记。

lua 复制代码
.LC0:
	.string	"Hello World"
	.text
	.globl	main
	.type	main, @function

比如,上面的 .LC0: 就是一个标签,用来对字符串 "Hello World" 进行标记。

注释

在汇编语言中,我们可以使用 # 来表示注释。

汇编语言实现数字运算

这里以加法为例,介绍如何使用汇编来实现数字相加的效果。代码示例如下:

arduino 复制代码
#include <stdio.h>
int main(void){
    int a = 1;
    int b = 2;
    int c = a + b;
    printf("c = %d", c);
    return 0;
}

上述的代码编译后的汇编代码如下:

perl 复制代码
	.file	"add.c"
	.text
	.section	.rodata
.LC0:
	.string	"c = %d"
	.text
	.globl	main
	.type	main, @function
main:
    # ➊函数调用都需要设置栈指针
    pushq	%rbp       # 把调用者的栈帧底部地址保存起来
    movq	%rsp, %rbp # 把调用者的栈帧顶部地址, 设置为本栈帧的底部
    subq	$16, %rsp  # 扩展栈

    # ➋实现加法逻辑的汇编代码
    movl	$1, -12(%rbp)
    movl	$2, -8(%rbp)
    movl	-12(%rbp), %edx
    movl	-8(%rbp), %eax
    addl	%edx, %eax
    movl	%eax, -4(%rbp)

    # ➌调用 printf
    movl	-4(%rbp), %eax
    movl	%eax, %esi
    leaq	.LC0(%rip), %rax
    movq	%rax, %rdi
    movl	$0, %eax
    call	printf@PLT
    
    movl	$0, %eax
    leave
    ret
    .size	main, .-main
    .ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
    .section	.note.GNU-stack,"",@progbits

➊代码部分作用是设置栈指针;➌代码部分则是调用 printf 代码。这些地方后面都会介绍,这里了解即可。

➋代码部分则是实现加法逻辑的核心代码。这里主要涉及两个指令 movadd

mov 指令

mov 指令用于传递数据。格式为:

scss 复制代码
mov 源(寄存器|内存|立即数), 目的(寄存器|内存)

它的功能是把源位置的值赋值给目的的位置。比如 movl $1, -12(%rbp) 是把 %rbp 寄存器的值减去12后的位置赋值为 1。这个可以看作成C语言中的赋值语句 int a = 1;

add 指令

add 指令用于做加法运算,格式为:

scss 复制代码
add 源(寄存器|内存|立即数), 目的(寄存器|内存)

它的功能是把源位置的值与目的位置的值相加,并把结果赋值到目的位置。比如addl -8(%rbp), %eax 会把%rbp-8 地址的值加到 %eax

加法运算逻辑

perl 复制代码
movl	$1, -12(%rbp)    # 初始化变量a为1
movl	$2, -8(%rbp)     # 初始化变量b为2
movl	-12(%rbp), %edx  # 将a的值加载到edx寄存器
movl	-8(%rbp), %eax   # 将b的值加载到eax寄存器
addl	%edx, %eax       # 执行加法运算:eax = eax + edx
movl	%eax, -4(%rbp)   # 将结果存入变量c(-4(%rbp))

了解了 movadd 指令的功能,其加法运算的逻辑就非常清楚了,具体解释如上代码所示。

至于其他的减法、乘法、除法运算,也是一样的逻辑。不同的是需要使用不同的运算指令。比如,减法指令 sub、乘法指令 mul、除法指令 div。具体示例可以看div 指令(除法指令)和 mul 指令(乘法指令)

汇编语言实现逻辑控制

这里以if为例,介绍如何使用汇编来实现if逻辑判断的效果。代码示例如下:

arduino 复制代码
#include <stdio.h>
int main(void){
    int variable = 1;
    if (variable) {
      printf("true");
    } else {
      printf("false");
    }
    return 0;
}

上述的代码编译后的汇编代码如下:

perl 复制代码
    .file	"if.c"
    .text
    .section	.rodata
.LC0:
    .string	"true"
.LC1:
    .string	"false"
    .text
    .globl	main
    .type	main, @function
main:
    pushq	%rbp
    movq	%rsp, %rbp
    subq	$16, %rsp
    # 赋值 -4(%rbp) 地址的值为 1
    movl	$1, -4(%rbp)
    # 条件判断逻辑
    cmpl	$0, -4(%rbp)
    je	.L2
    # 条件为真时,执行的逻辑
    leaq	.LC0(%rip), %rax
    movq	%rax, %rdi
    movl	$0, %eax
    call	printf@PLT
    # 无条件跳转到.L3标签处
    jmp	.L3
.L2:    # 条件为假时,执行的逻辑
    leaq	.LC1(%rip), %rax
    movq	%rax, %rdi
    movl	$0, %eax
    call	printf@PLT
.L3:
    movl	$0, %eax
    leave
    ret
    .size	main, .-main
    .ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
    .section	.note.GNU-stack,"",@progbits

从上面的代码,可以看到出现了三个新的指令,分别为 cmpljejmp

cmpl 和 je 指令

cmpl 指令会将两个操作数相减,但计算结果并不保存,只是根据计算结果改变eflags寄存器中的标志位。 如果两个操作数相等,则计算结果为0,eflags中的ZF位置1。

je则是一个条件跳转指令,它检查eflags中的ZF位,ZF位为1则发生跳转,ZF位为0则不跳转继续执行下一条指令。

je 指令类似的命令还有:

  • jle: <=
  • jge: >=
  • jl: <
  • jg: >
  • jne: !=

jmp 指令

jmp 指令是汇编语言中的无条件跳转指令。jmp 指令主要用于改变程序的执行流程。它可以让 CPU 跳转到指定的地址去执行指令,而不是按照原来的顺序执行下一条指令。

IF逻辑实现

了解了这三个指令,汇编代码中的if逻辑就非常简单了。与IF实现类似,其 whilefor 等功能也是由类似的指令实现的。

汇编语言实现方法调用

调用栈

函数调用时,都会涉及到栈。在汇编语言中,栈是通过 push 和 pop 这对指令来管理的。在 C 语言生成的代码中,一般用 %rbp 寄存器指向栈帧的底部,而 %rsp 则指向栈帧的顶部。关于函数的调用栈,其固定的格式如下所示:

perl 复制代码
# 开始调用函数,设置栈指针
pushq	%rbp	     # 把调用者的栈帧底部地址保存起来  
movq	%rsp, %rbp   # 把调用者的栈帧顶部地址,设置为本栈帧的底部

# 函数的逻辑
... 

# 函数调用完成, 恢复栈指针为原来的值
popq	%rbp         # 恢复调用者栈帧的底部数值

上述代码的操作过程的效果如下图所示:

需要注意 push指令等同于下面两条指令,我们可以不用 push 指令,而是运行下面两条指令

perl 复制代码
subq $8, %rsp        # 把 %rsp 的值减 8,也就是栈增长 8 个字节,从高地址向低地址增长
movq %rbp, (%rsp)    # 把 %rbp 的值写到当前栈顶指示的内存位置

相对的,pop 指令等价于下面两条指令:

perl 复制代码
movq (%rsp), %rbp    # 把栈顶位置的值恢复回 %rbp,这是之前保存在栈里的值。
addq $8, %rsp        # 把 %rsp 的值加 8,也就是栈减少 8 个字节

函数参数和局部变量

上面介绍了汇编代码中调用栈的格式,下面我们来看看具体的函数调用的汇编代码。

arduino 复制代码
#include <stdio.h>
void call(){
  // empty block
}

int fun(int a, int b){
    call();
    return a+b;
}
 
int main(void){
    printf("fun: %d\n", fun(1,2));
    return 0;
} 

以上面的代码为例,它的汇编代码如下所示:

perl 复制代码
    .file	"func.c"
    .text
    .globl	call
    .type	call, @function
call: # 空方法
    pushq	%rbp
    movq	%rsp, %rbp
    nop
    popq	%rbp
    ret
    .size	call, .-call
    .globl	fun
    .type	fun, @function
fun:
    pushq	%rbp
    movq	%rsp, %rbp
    # ➊扩展栈
    subq	$8, %rsp
    # 给变量赋值,比如 a = 1, b = 2
    movl	%edi, -4(%rbp)
    movl	%esi, -8(%rbp)
    # 初始化返回值为0
    movl	$0, %eax
    # ➌调用call方法
    call	call
    # 执行加法逻辑
    movl	-4(%rbp), %edx
    movl	-8(%rbp), %eax
    addl	%edx, %eax
    leave
    # ➎ret 返回结果
    ret
    .size	fun, .-fun
    .section	.rodata
.LC0:
    .string	"fun: %d\n"
    .text
    .globl	main
    .type	main, @function
main:
    pushq	%rbp
    movq	%rsp, %rbp
    # ➋把参数值放到约定的寄存器
    movl	$2, %esi
    movl	$1, %edi
    call	fun
    movl	%eax, %esi
    leaq	.LC0(%rip), %rax
    movq	%rax, %rdi
    movl	$0, %eax
    # ➍调用printf方法
    call	printf@PLT
    movl	$0, %eax
    popq	%rbp
    ret
    .size	main, .-main
    .ident	"GCC: (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0"
    .section	.note.GNU-stack,"",@progbits

➊代码的作用是扩展栈的空间,用来保存局部变量。如下图所示:

➋代码的作用会把方法需要的参数放到寄存器中。在x86-64架构下约定了6个寄存器来传递参数,它们分别为:

注意,如果参数数量超过6个的话,我们要再加上栈来传参。

汇编语言的方法调用

汇编语言的函数调用可以简单地分为三种:文件内函数地调用、其他模块的函数调用、系统调用。

  • 文件内函数地调用

这里以上面的 fun 函数调用为例,代码如下所示:

perl 复制代码
# 调用方:设置参数
movl	$2, %esi
movl	$1, %edi
# 调用方:调用函数
call	fun

# 函数内部
# 从寄存器中取出变量并赋值,比如 a = 1, b = 2
movl	%edi, -4(%rbp)
movl	%esi, -8(%rbp)
# 初始化返回值为0
movl	$0, %eax
# 返回结果
ret
  • 其他模块的函数调用

这里以 printf 方法的调用为例,代码示例如下:

perl 复制代码
# 准备参数调用 printf
# 将 fun 的返回值存入 %esi(对应%d,即printf的第二个参数)
movl    %eax, %esi      
# 加载格式化字符串 "fun: %d\n" 的地址到 rax寄存器
leaq    .LC0(%rip), %rax 
# 将格式字符串地址存入 %rdi(第一个参数)
movq    %rax, %rdi      
movl    $0, %eax
# 调用 printf 函数(通过 PLT)
call    printf@PLT      
# 返回结果
ret
  • 系统调用

具体看 x86_64汇编之六:系统调用(system call)_asm syscall-CSDN博客

参考

相关推荐
coding随想16 天前
从“裸奔”到“穿盔甲”:C、C++和汇编语言的江湖地位大揭秘
c++·汇编语言
CYRUS_STUDIO2 个月前
Frida Stalker Trace 指令跟踪&寄存器变化监控
android·逆向·汇编语言
CYRUS_STUDIO3 个月前
Unidbg Trace 反 OLLVM 控制流平坦化(fla)
android·逆向·汇编语言
CYRUS_STUDIO3 个月前
基于 Unicorn 实现一个轻量级的 ARM64 模拟器
android·逆向·汇编语言
Ronin-Lotus4 个月前
嵌入式硬件篇---常用的汇编语言指令
单片机·嵌入式硬件·职场和发展·c·汇编语言
CYRUS_STUDIO4 个月前
使用 AndroidNativeEmu 调用 JNI 函数
android·逆向·汇编语言
我是菜鸡163845 个月前
Arm64 中 B跳转汇编的使用是如何实现的
汇编语言
坤虫debug5 个月前
面试官:你会不会汇编?啊?我会不会编?
汇编语言
Terasic友晶科技5 个月前
第22篇 基于ARM A9处理器用汇编语言实现中断<四>
fpga开发·汇编语言·de1-soc开发板·按键和定时器中断