这是一篇笔记,包含了一位汇编初学者的学习经历。该笔记是基于amd64架构的汇编语言。

传统计算机是什么?

传统计算机的工作原理基于冯·诺依曼(Von Neumann)架构,能够执行程序指令、进行数据处理和存储信息的电子设备。我们这次的重点是cpu,一种基于二进制运算的庞大逻辑门集合。相信看到这篇文章的人应该不大可能在使用量子计算机等新型计算机XD

  • 运算器(Arithmetic Logic Unit, ALU)
    运算器是CPU中负责执行基本算术运算(如加、减、乘、除)和逻辑运算(如与、或、非)的组件。它是CPU进行数据处理的核心部分,能够对输入的数据进行各种操作并产生结果。

  • 控制器(Control Unit, CU)
    控制器负责从内存中读取指令,并根据指令的要求控制其他部件的工作。它管理指令的解码、执行以及结果的存储。控制器还负责协调CPU内部各部分之间的通信和同步。

  • 寄存器(Registers)
    寄存器是CPU内部的小型存储单元,用于临时存储数据和指令。常见的寄存器包括程序计数器(PC)、指令寄存器(IR)、指令译码器(ID)和控制信号发生器等。寄存器帮助CPU快速访问和处理数据。

  • 缓存(Cache)
    指令缓存用于存储最近使用的指令,以提高指令访问速度。通过减少对主内存的访问次数,指令缓存显著提升了CPU的性能。

  • 内存(Memory)
    内存分为代码存储区和数据存储区。代码存储区存放程序指令,而数据存储区则存放程序运行所需的数据。

什么是汇编?

简单来说汇编语言是二进制代码的一个可读性较强的表示形式。它使用助记符(容易记住的词汇)来表示二进制指令,这样更方便人类理解和操作。每一条汇编指令对应于一条特定的二进制指令。汇编语言之所以被称为“Assembly”,是因为它是被汇编屁话(而非编译)成二进制代码的。

寄存器

寄存器是存储器层次结构中最靠近CPU的部分,因此访问速度非常快。寄存器文件(Register File)作为处理器中的快速缓冲区,用于存放运算和操作的中间结果,从而显著提高了数据处理的速度。不过因为成本原因,寄存器个数是是有限的。大概如下。

8085: a, c, d, b, e, h, l
8086: ax, cx, dx, bx, sp, bp, si, di
x86: eax, ecx, edx, ebx, esp, ebp, esi, edi
amd64: rax, rcx, rdx, rbx, rsp, rbp, rsi, rdi, r8, r9, r10, r11, r12, r13, r14, r15
arm: r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11, r12, r13, r14

寄存器通常与架构的字宽相同。 在64位架构上大多数寄存器将容纳64位(8字节)。

在计算机发展过程中,考虑到兼容性问题,上一个架构的寄存器通常会被保留,现在我们也可以使用这些寄存器。

我们可以通过mov rax, rbx的方法把rbx的值给rax 也可以通过[]让值作为指针指向对应地址,同时其中也可以存在计算公式(此处为了方便把地址简化为数字),比如:

mov rax, [777]

rax的值变变为666

mov [rax], 233

地址666的内容变为233

mov rax, 0                  # 将 rax 设置为 0
mov rbx, [rsp+rax*8]         # 从栈指针 rsp 位置读取一个 qword(8 字节)
inc rax                      # 将 rax 增加 1
mov rcx, [rsp+rax*8]         # 从栈上读取紧挨着上一个值的下一个 qword
    地址   │   内容              地址    |   内容
+────────────────────+      +────────────────────+
│   777   │    666   │      |    777   |   666   |
+────────────────────+ ---> +────────────────────+
|   666   |    33    |      |    666   |   233   |
+────────────────────+      +────────────────────+
tips:如果你写入一个32位部分寄存器(例如,eax),CPU会将寄存器的其余部分清零!

这一做法是出于(信不信由你)性能原因。

这会将rax设置为0xffffffffffff0539:

mov rax, 0xffffffffffffffff

mov ax, 0x539

这会将rax设置为0x0000000000000539:

mov rax, 0xffffffffffffffff

mov eax, 0x539

寄存器中有些特殊存在,比如rsp它包含一个内存区域的地址,用于存储临时数据(sp = 栈指针)和rip它包含将要执行的下一条指令的内存地址(ip = 指令指针)。

 汇编常见指令

汇编语言的语法本身并不复杂,但这并不意味着学习汇编很简单。

指令

数学表达

add rax, rbx

rax = rax + rbx

sub ebx, ecx

ebx = ebx - ecx

imul rsi, rdi

rsi = rsi * rdi

inc rdx

rdx = rdx + 1

dec rdx

rdx = rdx - 1

neg rax

rax = 0 - rax

not rax

rax = ~rax

and rax, rbx

rax = rax & rbx

or rax, rbx

rax = rax | rbx

xor rcx, rdx

rcx = rcx ^ rdx

shl rax, 10

rax = rax << 10

shr rax, 10

rax = rax >> 10

sar rax, 10

rax = rax >> 10

ror rax, 10

rax = (rax >> 10) | (rax << 54)

rol rax, 10

rax = (rax << 10) | (rax >> 54)

新增一条
div reg,将发生以下情况:

    rax = rdx:rax / reg
    rdx = 余数

第一个程序

❯ vi hello.s
.intel_syntax noprefix #指定使用 Intel 语法,并禁用前缀(即不使用百分号 `%` 作为寄存器前缀)
.global _start         #声明全局符号 _start,告诉链接器程序入口是 _start
_start:                #程序从此开始
mov rdi, 42            #将数字 42 存入寄存器 rdi。rdi 通常用于传递第一个参数给系统调用。此时,我们传递给系统调用的是退出状态码 42
mov rax, 60            #将数字 60 存入寄存器 rax。rax 寄存器通常用于指定系统调用的编号。在 Linux 中,60 是退出系统调用(`exit`)的编号
syscall                #执行系统调用。在这里,它会调用 Linux 内核的 `exit` 系统调用,将程序退出,并返回值 42
❯ as -o hello.o hello.s
❯ ld -o hello hello.o
❯ ./hello
❯ echo $?                                                                                    
42

syscall是一种调用指令,通过这个指令我们可以和系统进行交互,我们可以想象在系统中有一张调用表格,而设置rax的值则是选择对应系统调用的函数的编号。rdi rsi ...则是对应的输入参数。举个例子:

从标准输入读取100个字节到栈中:
n = read(0, buf, 100);

mov rdi, 0      # 第一个参数,表示标准输入文件描述符
mov rsi, rsp    # 将数据读取到栈上
mov rdx, 100    # 第三个参数,表示要读取的字节数
mov rax, 0      # read() 系统调用号0
syscall         # 执行系统调用

附录:Linux x86-64 ABI顺序


    rdi: 第一个参数
    rsi: 第二个参数
    rdx: 第三个参数
    r10: 第四个参数
    r8: 第五个参数
    r9: 第六个参数

内存

寄存器是昂贵的,并且我们可用的寄存器数量有限。 我们需要一个地方来存储大量数据,并在需要时能够快速访问它。 这个地方就是系统内存。处理器内存是按线性方式寻址的。 起始地址:0x10000(出于安全考虑不从0x00开始) 结束地址:0x7fffffffffff(出于架构/操作系统的目的) 每个内存地址引用内存中的一个字节。这意味着可以寻址的内存高达127TB!当然,你并没有127 TB的RAM……但没关系,因为这全都是虚拟内存!进程的内存是由操作系统分配的,同时进程也可以向操作系统请求更多的内存。截图 2025-01-19 22-31-55.png

栈的用处有许多,比如说暂时储存数据,push可以把数据入栈,而pop则是弹出数据,顺序就像给弹夹装弹和子弹弹出一样,“先来后到”。

mov rax, 0xc001ca75
push rax
push 0xb0bacafe  # 警告:即使在64位x86架构上,你也只能推送32位立即数...
push rax

值可以从栈中弹出(到任意寄存器)。

pop rbx  # 将 rbx 设置为 0xc001ca75
pop rcx  # 将 rcx 设置为 0xb0bacafe

而内存增长的方向是从高地址到低地址的,push会使rsp指向的值减小8位(bit)。同时数据在其中也是以字节为单位逆位储存的(其中的bit并不会被翻转)。

lea是少数可以直接访问指针的命令。而rip是可以直接访问的指针。

lea rax, [rip]         # 将下一条指令的地址加载到 rax 中
lea rax, [rip+8]       # 将下一条指令的地址加上 8 字节后的地址加载到 rax 中

mov rax, [rip]          # 从下一条指令地址指向的位置加载 8 字节
mov [rip], rax          # 将 8 字节写入下一条指令的位置