cv.cm

编码 · 编程语言

汇编语言入门教程(x86-64,NASM)

阅读约 11 分钟

简短回答

汇编语言是处理器机器指令的可读形式:每行通常对应一条指令,如 mov rax, 1 或 add rax, rbx。你直接操作寄存器、内存地址和栈,没有变量、类型,也没有循环,除非用跳转自己搭出来。本教程在 Linux 上使用 x86-64 和 NASM 汇编器:编写 .asm 文件,用 nasm -f elf64 汇编,再用 ld 链接。

打开进制转换

汇编是什么,谁在用

每个处理器家族都有自己的指令集,因此也有自己的汇编语言。x86-64(也称 AMD64)运行在大多数台式机和服务器上;ARM(AArch64)运行在大多数手机、Apple 芯片的 Mac 和许多服务器上。汇编器把汇编翻译成机器码,链接器再把结果合并成可执行文件。

如今很少有程序完全用汇编编写。它用于操作系统内核和引导程序、性能关键的例程(密码学、编解码器、数学库)、嵌入式固件、编译器开发、安全研究和逆向工程。更多时候,开发者阅读汇编是为了弄清编译器生成了什么,或者调试崩溃。

安装 NASM 并运行 Hello, world

在 Debian 或 Ubuntu 上,用 sudo apt install nasm binutils 安装工具(binutils 提供链接器 ld)。这个程序通过系统调用直接调用 Linux 内核,所以不需要 C 库。

; hello.asm - x86-64 Linux, NASM syntax
section .data
    msg     db "Hello, world!", 10   ; 10 = newline
    msglen  equ $ - msg              ; length computed by the assembler

section .text
    global _start

_start:
    mov     rax, 1          ; system call number 1 = write
    mov     rdi, 1          ; file descriptor 1 = stdout
    mov     rsi, msg        ; address of the bytes
    mov     rdx, msglen     ; number of bytes
    syscall

    mov     rax, 60         ; system call number 60 = exit
    xor     rdi, rdi        ; exit code 0
    syscall
nasm -f elf64 hello.asm -o hello.o
ld hello.o -o hello
./hello
# Hello, world!

section .data 存放已初始化的数据,section .text 存放代码,_start 是 ld 查找的入口点。注释以 ; 开头。如果没有最后的 exit 系统调用,处理器会继续执行后面的任何字节,程序就会崩溃。

寄存器与数据大小

寄存器是 CPU 内部小而极快的存储位置。x86-64 有 16 个 64 位通用寄存器。每个寄存器也可以只使用较小的部分。

寄存器常见用途较小的部分
rax返回值、系统调用号eax(32 位)、ax(16 位)、al(8 位)
rdi, rsi, rdx前三个函数参数或系统调用参数edi, esi, edx
rcx, r8, r9第四到第六个函数参数ecx, r8d, r9d
rbx, rbp, r12–r15必须在函数调用后保持不变的值(被调用者保存)ebx, r12d
rsp栈指针:栈顶的地址不要用来存放数据
rip指令指针(只会被跳转、调用、返回改变)不能直接写入

内存中的数据用 db(字节,8 位)、dw(字,16 位)、dd(双字,32 位)和 dq(四字,64 位)声明。未初始化的缓冲区放在 section .bss 中,用 resb、resq 等。写入 eax 这样的 32 位寄存器时,也会清除 rax 的高 32 位,这就是 xor eax, eax 成为把 rax 置零的常用方法的原因。

基本指令与控制流

大多数指令先写目的操作数,再写源操作数:add rax, rbx 表示 rax = rax + rbx。没有 if 或 while 语句;你用 cmp 比较值,它会设置标志位,然后使用条件跳转。

指令含义
mov rax, rbx把 rbx 复制到 rax
add, sub, imul, inc, dec整数算术
cmp rax, 10比较(计算 rax - 10,只设置标志位)
je, jne, jl, jg, jle, jge相等、不相等、小于、大于时跳转……(有符号)
jmp label无条件跳转
push, pop把一个值压入栈,再取出
call, ret调用函数,从函数返回
; sum.asm - add 1..10, return the result as the exit code
section .text
    global _start

_start:
    xor     rax, rax        ; sum = 0
    mov     rcx, 1          ; i = 1
.loop:
    cmp     rcx, 10
    jg      .done           ; if i > 10, leave the loop
    add     rax, rcx        ; sum += i
    inc     rcx             ; i++
    jmp     .loop
.done:
    mov     rdi, rax        ; exit code = sum
    mov     rax, 60         ; exit
    syscall
nasm -f elf64 sum.asm -o sum.o && ld sum.o -o sum
./sum; echo $?
# 55

以点开头的标签,如 .loop,属于前一个普通标签的局部标签。jl 和 jg 把值当作有符号数;jb 和 ja 是无符号版本。

内存寻址与数组

方括号表示“这个地址处的内存”。地址可以由基址寄存器、乘以 1、2、4 或 8 的变址寄存器和一个常量组合而成:[base + index*scale + offset]。这使得数组访问只需一条指令。

section .data
    nums    dq 3, 1, 4, 1, 5         ; five 64-bit integers
    count   equ ($ - nums) / 8

section .text
    global _start

_start:
    xor     rax, rax                 ; total = 0
    xor     rcx, rcx                 ; index = 0
.next:
    add     rax, [nums + rcx*8]      ; total += nums[index]
    inc     rcx
    cmp     rcx, count
    jl      .next
    mov     rdi, rax                 ; exit code 14
    mov     rax, 60
    syscall

mov rax, nums 加载地址,而 mov rax, [nums] 加载存放在那里的值。lea rsi, [rbx + 8] 只计算地址,不读取内存。当大小无法从寄存器推断时,要明确写出:mov qword [rbx], 0。

函数与调用约定

在 Linux 上,函数遵循 System V AMD64 ABI。整数参数放在 rdi、rsi、rdx、rcx、r8、r9 中;返回值在 rax 中。函数必须保持 rbx、rbp、r12–r15 和 rsp 不变,并且在 call 之前 rsp 必须是 16 的倍数。遵循这些规则,汇编就能调用 printf 这样的 C 函数,C 也能调用汇编。

; square.asm - define a function and call printf from the C library
    extern  printf
    global  main

section .data
    fmt     db "%ld squared is %ld", 10, 0

section .text
square:                         ; long square(long x)
    mov     rax, rdi
    imul    rax, rdi
    ret

main:
    push    rbx                 ; save rbx; also aligns rsp to 16
    mov     rbx, 7
    mov     rdi, rbx
    call    square              ; rax = 49
    lea     rdi, [rel fmt]      ; argument 1: format string
    mov     rsi, rbx            ; argument 2
    mov     rdx, rax            ; argument 3
    xor     eax, eax            ; varargs: no vector registers used
    call    printf wrt ..plt
    xor     eax, eax            ; return 0 from main
    pop     rbx
    ret

section .note.GNU-stack noalloc noexec nowrite progbits
nasm -f elf64 square.asm -o square.o
gcc square.o -o square
./square
# 7 squared is 49

用 gcc 链接会加入 C 运行时,由它调用 main。[rel fmt] 和 wrt ..plt 生成位置无关代码,这是现代 Linux 发行版默认使用的。最后一行把栈标记为不可执行,并避免链接器警告。

错误处理

汇编没有异常。失败的 Linux 系统调用会在 rax 中返回一个负的错误号(例如 -2,即 ENOENT,“没有这样的文件”),所以每次调用之后都要检查符号。C 库函数通过返回值和 errno 报告错误。硬件故障,比如读取无效地址或除以零,会通过信号终止程序(段错误或浮点异常)。

    mov     rax, 2              ; system call 2 = open
    lea     rdi, [rel path]     ; path is a zero-terminated string
    xor     esi, esi            ; flags: O_RDONLY
    syscall
    test    rax, rax
    js      .open_failed        ; negative result = -errno
    mov     r12, rax            ; success: rax is the file descriptor

一个完整的小程序:以十进制打印一个数

打印数字意味着要自己把它转换成数位。这个程序计算 1 + 2 + ... + 100,反复除以 10 从右往左得到各位数字,并用一次系统调用把它们写出去。

section .bss
    buf     resb 32

section .text
    global _start

_start:
    xor     rax, rax
    mov     rcx, 100
.sum:
    add     rax, rcx
    loop    .sum                ; rcx -= 1, repeat while rcx != 0
    lea     rsi, [buf + 31]
    mov     byte [rsi], 10      ; newline at the end of the buffer
    mov     rbx, 10
.digit:
    xor     rdx, rdx            ; div uses rdx:rax as the dividend
    div     rbx                 ; rax = quotient, rdx = remainder
    add     dl, '0'             ; remainder to ASCII digit
    dec     rsi
    mov     [rsi], dl
    test    rax, rax
    jnz     .digit
    mov     rdx, buf + 32
    sub     rdx, rsi            ; length = end - start
    mov     rax, 1              ; write(1, rsi, rdx)
    mov     rdi, 1
    syscall
    mov     rax, 60             ; exit(0)
    xor     rdi, rdi
    syscall
; prints 5050

AT&T 与 Intel 语法,以及 ARM

同一条 x86-64 指令可以用两种语法书写。NASM 以及大多数 Intel 和 Microsoft 文档使用 Intel 语法。GNU 汇编器(as)、GCC 的输出和 objdump 默认使用 AT&T 语法;objdump -d -M intel 和 gcc -S -masm=intel 可切换到 Intel 语法。

方面Intel (NASM)AT&T (GNU as)
操作数顺序mov rax, 1(先写目的操作数)movq $1, %rax(先写源操作数)
寄存器与常量rax, 5%rax, $5
内存操作数[rbx + rcx*8 + 16]16(%rbx,%rcx,8)
操作数大小mov qword [rbx], 0movq $0, (%rbx)(后缀 b、w、l、q)

ARM 处理器使用不同的指令集。AArch64 有 31 个 64 位通用寄存器(x0–x30)、固定的 32 位指令编码,以及加载/存储设计:算术只作用于寄存器,内存用 ldr 和 str 访问。在 Linux 上,系统调用号放在 x8 中,用 svc #0 发起调用。

// AArch64 Linux, GNU as syntax: exit(0)
    mov     x0, #0          // exit status
    mov     x8, #93         // system call 93 = exit
    svc     #0

接下来读什么

  • NASM 手册,介绍伪指令、宏和输出格式。
  • Intel 64 and IA-32 Architectures Software Developer's Manuals,其中有每条指令的确切行为。
  • System V AMD64 ABI 文档,介绍调用约定和栈布局。
  • 用 gcc -O2 -S -masm=intel(或在线的编译器浏览器)编译小的 C 函数,然后阅读生成的汇编。

常见问题

汇编语言现在还值得学吗?

值得,它有助于理解计算机如何工作、调试崩溃、阅读编译器输出、做安全工作和嵌入式开发。如今用汇编编写整个应用程序很少是实际可行的。

x86 汇编和 ARM 汇编一样吗?

不一样。它们是不同的指令集,寄存器、指令和编码都不同。概念(寄存器、内存、跳转、调用约定)是相通的,但代码不能通用。

NASM 和 GAS 有什么区别?

NASM 是使用 Intel 语法的独立汇编器。GAS 即 GNU 汇编器,被 GCC 使用,默认采用 AT&T 语法,不过用 .intel_syntax noprefix 也可以接受 Intel 语法。

这段代码能在 Windows 或 macOS 上运行吗?

不能原样运行。指令在任何 x86-64 系统上都相同,但系统调用号、可执行文件格式和调用约定不同。Windows 使用不同的调用约定,Apple 芯片的 Mac 使用 ARM 而不是 x86-64。

全部编码教程