汇编参考指南:x86-64 / ARM64 / RISC-V 三架构深入

全景:汇编是什么,三家怎么分

先建立地图:你的代码到机器码之间隔着哪几层、x86 / ARM / RISC-V 各自统治哪些地盘、同一条 C 语句在三家分别长什么样;再过一遍三家共享的「公共子集」:寄存器、内存模型、寻址、标志、控制流、栈、ABI、系统调用、链接与重定位、向量。后面每个架构章,都是把这份骨架逐条放大。

汇编不是「另一门编程语言」,而是机器码的人类可读形式——CPU 执行二进制编码,汇编只是给每条编码起了个助记名。它也是这摞抽象层里 ISA(指令集架构)那一层的语言。

汇编 = 机器码的 1:1 文本

  • .s 文件里除了指令还有伪指令.globl.section——给汇编器的指示,不产生机器码);剩下的每条指令都近乎 1:1 翻译成机器码,objdump -d 的输出左边是字节、右边是助记符,两列一一对应;
  • ISA 之下还有戏:一条指令可能被拆成多个 μop、和前后几十条指令乱序并行执行。所以「手写汇编一定更快」在今天基本不成立,快慢要靠 perf 量,不能靠数指令条数

今天还要学它的理由,以及正确姿势

  • 调试崩溃:release 版没有源码行号时,崩溃点的汇编和寄存器值是唯一线索;读编译器输出:确认内联、向量化有没有真的发生;安全与逆向:漏洞利用、固件分析只有这一层视角;
  • 实战里 90% 是「读」而不是「写」——别急着背指令表,先学会把一小段 C 和它的汇编对上号。三种架构不必都精通,深入一种、能看懂另外两种是性价比最高的目标。
别把「学汇编」等同于「手写汇编」:今天写整段汇编的场合极少,而读汇编几乎天天用得上——读崩溃现场、读编译器优化结果、读别人的二进制。目标定成「读得懂」,投入产出比高得多。
起步只要三条命令:gcc -S x.c 看编译器产出的汇编、objdump -d a.out 看机器码与助记符并排、gdb 单步。

汇编最忌只看不跑。下面四件工具全部免费、五分钟上手,本页每一张卡的内容都能用它们亲手验证;本卡只负责把工具认全,四条命令怎么连起来跑(含 gdb 的单步工作流)全在 02 章。

四件工具

  • Compiler Explorer(godbolt.org):浏览器里写 C/C++/Rust,实时看到汇编,还能同屏切换 x86-64 / ARM64 / RISC-V 编译器与 -O0/-O2 优化等级——学本页的头号工具,对比章的所有例子都能在这复现。
  • gcc -S / clang -S:本地把 C 编成 .s 文件,看真实工具链的完整输出(含汇编伪指令、节区声明)。
  • objdump -d:反汇编现成的二进制,机器码字节和汇编并排显示——「汇编 ↔ 机器码 1:1」在这里亲眼可见。
  • gdblayout asm 显示汇编视图,si 逐条单步,info registers 看每条指令如何改变寄存器——把静态的指令表变成动态的「机器状态演化」。
同一段 C,-O0-O2 生成的汇编面目全非:-O0 冗长但逐行贴合源码,-O2 会重排、内联、把变量塞进寄存器。对照学习务必固定用 -O0,否则「C 和汇编对不上」不是你的错,是优化器把语句结构揉碎了。
没有 ARM / RISC-V 机器不影响动手:godbolt 直接选对应编译器「只读」;想真的跑,Linux 下 qemu-user + 交叉工具链几分钟装好——后面三个架构章的「完整程序示例」卡都给了具体命令。

本页后面三章分别深入三种 ISA。先认识它们各自统治的地盘和性格,再决定先读哪一章——这比按顺序硬啃高效得多。

一张世界地图

架构你在哪见到它性格标签本页章节
x86-64你的 PC、绝大多数云服务器CISC(复杂指令集)· 变长编码 · 四十年兼容包袱第03章
ARM64全部手机、Apple Silicon Mac、AWS Gravitonload-store · 定长 32 位 · 能效见长第04章
RISC-VMCU / 嵌入式起家,正进入数据中心开放免费 · 模块化 · 教科书级规整第05章

本页怎么读

  • 先读完本章:搞清汇编今天还值得学什么、以及三家各自的世界;接着读 01 章,那是三家共享的骨架(寄存器、寻址、栈、ABI……),每个概念都三路对照。
  • 接着在上手章跑通 hello:三条命令得到一个活的进程——后面读到的每个概念,都能回到那 8 条指令上单步验证。上手章只要求你把别人写好的一段跑起来、拆开、单步看;「自己写」的地基(数据与标签设施、读输入的程序、与 C 混编、内联汇编)在 06 章,读完任一架构章就能去。
  • 然后三选一深入:不必都精通,深入一种、能看懂另外两种(选型建议见本章第一卡的 tip)。
  • 第 07 章横向对比把三家钉在同一张表上;没深入的两章当参考书,用到再查。
「ARM 汇编」是个陷阱词。手机与 Apple Silicon 上是 ARM64(AArch64),而网上大量老资料讲的是 32 位的 AArch32 / Thumb——两者寄存器、指令、编码都不同。RISC-V 同样有 RV32/RV64 之分和一堆可选扩展。找资料时先确认它说的是哪一代、哪个位宽。
一台设备上常常不止一种 ISA:手机主核是 ARM64,但 SSD 主控、耳机芯片很可能是 RISC-V。「哪个架构赢了」是伪问题——它们在不同的生态位共存,就像卡车、轿车和自行车。

机器模型:三家共享的骨架

三种架构的指令名字各不相同,底下这套模型却是同一套:寄存器、内存与对齐、寻址方式、标志位、栈帧、调用约定、系统调用、链接。这一章把它们逐个讲清,每个概念都三路对照——先有这套骨架,后面三个架构章才只是在同一张骨架上换名字。

a = b + c;(局部 int 变量,-O0 编译)分别交给三家,每行汇编都指向本章后面的一张概念卡——这张卡是全章的索引,读完概念卡再回来看一遍,会全部对上号。

三段输出

# x86-64(3 条:CISC 允许内存操作数)
mov  eax, DWORD PTR [rbp-8]    # 把 b 从栈装入寄存器
add  eax, DWORD PTR [rbp-12]   # 直接加上内存里的 c
mov  DWORD PTR [rbp-4], eax    # 结果存回 a

# ARM64(4 条:load-store,运算不碰内存)
ldr  w8, [sp, #8]              # 装入 b
ldr  w9, [sp, #12]             # 装入 c
add  w8, w8, w9                # 寄存器相加
str  w8, [sp, #4]              # 存回 a

# RISC-V(4 条:同为 load-store)
lw   a4, -20(s0)               # 装入 b
lw   a5, -24(s0)               # 装入 c
addw a5, a4, a5                # 寄存器相加
sw   a5, -28(s0)               # 存回 a

从三段里读出的门道

  • x86 用 3 条而 RISC 用 4 条:CISC 允许指令直接拿内存当操作数,RISC(精简指令集)只许 load/store 碰内存(→ 内存模型卡、寻址模式卡)。
  • 三家都围着寄存器转——数据先装进来、算完再存回去(→ 寄存器卡)。
  • [rbp-8][sp, #8]-20(s0) 写法不同,本质全是「基址 + 偏移」,且基址都是栈指针 / 帧指针——局部变量住在栈上(→ 栈与栈帧卡)。
  • eaxw8addw 里的玄机:它们都是 64 位寄存器的 32 位视图 / 32 位运算,因为 int 是 32 位(→ 各架构章的寄存器组卡)。

拿去 godbolt 复现

  • 必须用 -O0 才能看到这个教学版本。开 -O2 后变量直接住进寄存器、这条语句甚至可能整个消失(结果在编译期就算好了)——这个落差本身就是重要一课:优化器眼里没有「语句」,只有数据流
别据这三段数指令条数来比性能。x86 用 3 条、RISC 用 4 条,只是「CISC 允许内存操作数」的表象——那条带内存操作数的 x86 指令在内部照样要拆成多个微操作。
和 05 章的分工:这里是一条语句的概念索引,那边才并排看单条语句的表达力与完整函数的调用现场。

寄存器是 CPU 内部、访问延迟最低的少量存储单元。汇编的大部分工作就是在「寄存器 ↔ 内存」之间搬运数据并对寄存器做运算。

两类寄存器

  • 通用寄存器 (GPR):存放操作数和地址。
  • 专用寄存器:程序计数器(PC/RIP,下一条指令地址)、栈指针(SP)、状态/标志寄存器、链接寄存器(ARM/RV 保存返回地址)。

三架构对照

x86-64ARM64RISC-V
16 个 GPR(RAX…RDI, R8–R15)31 个 GPR(X0–X30)+ 零寄存器 XZR32 个 GPR(x0–x31),x0 恒为 0
有标志寄存器 RFLAGS有状态位 NZCV无标志寄存器
RIP 不可直接当 GPR 写SP / PC 独立极致规整
写「窄」子寄存器的副作用三家不一致,最坑。x86-64 里写 eax(32 位)会把 rax 高 32 位清零,但写 alax(8/16 位)只改低位、保留高位——这个「部分寄存器」行为常制造隐藏的旧数据残留。ARM64 写 w0 同样清零 x0 高 32 位。
「零寄存器」(ARM 的 XZR、RISC-V 的 x0) 是 RISC 的妙招:读它永远得 0,写它直接丢弃。于是 movnop、比较等都能用它拼出来,省掉一堆专用指令。x86 为何没有?它寄存器本就稀缺(只 16 个),舍不得钉死一个恒为 0;且靠 xor eax,eax 这类专用短编码就能达到同等便利。零寄存器是 RISC 在寄存器充裕、定长编码下的取舍。

内存是线性、按字节编址的一维数组。两个最容易出错的细节:

两个易踩的细节

  • 字节序 (endianness):多字节数据在内存里的排列顺序。小端 (little-endian) 低位字节放低地址——x86-64、以及默认配置的 ARM64 / RISC-V 都用小端。大端则相反(网络字节序是大端)。
  • 对齐 (alignment):访问 N 字节数据时,地址最好是 N 的倍数。x86 容忍非对齐访问(但更慢);ARM/RISC-V 对某些访问可能要求对齐,否则出错或走慢路径。
; 小端示例:0x12345678 存到地址 A
地址:   A    A+1   A+2   A+3
字节: 0x78  0x56  0x34  0x12   ; 低位在前
字节序只影响「多字节标量在内存里怎么排」,影响寄存器里的值、位运算结果、也不影响数组元素或字符串字符的先后顺序——初学者常误以为小端会把数组「倒过来」,并不会。真正的坑在跨机器传数据(网络、文件):必须显式转成约定字节序(网络序是大端),否则大小端机器互读同一段字节会得到乱序整数。
三架构默认都是小端(x86-64 固定小端,ARM64/RISC-V 默认小端、少数配置可切大端)。记忆钩子:小端 = 「低地址放低位字节」,低对低。想快速判本机字节序,把 0x01 存进一个 int、看它占的首字节是不是 01

同样是「word」,在不同架构里大小不同——这是初学者最常被坑的地方:

位数对照

位数x86-64ARM64RISC-V
8 位byte (b)byte (b)byte (b)
16 位word (w)halfword (h)halfword (h)
32 位dword (d)word (w)word (w)
64 位qword (q)doubleword (x/d)doubleword (d)
x86 的 "word" = 16 位(历史遗留,源于 8086 时代 16 位就是一个字),但 ARM/RISC-V 的 "word" = 32 位——它们是后来才设计的架构,以 32 位为自然字长、没有 16 位的历史包袱。看到 lw(RISC-V load word)是加载 32 位,别想成 16 位。
记忆钩子:x86 的尺寸名锚定 8086——word 永远是那个「原初的」16 位字,后来的 32/64 位只能往上叠成 dwordqword;ARM/RISC-V 没有历史包袱,直接把 word 定义成 32 位。所以读指令宽度后缀前,先认是哪家架构再解释字母。

「操作数从哪来」是指令的核心。寻址模式不是硬件的花样炫技——每一种都对应高级语言里一类具体结构,编译器就是照这张映射表把 C 翻译成访存的。

模式 ↔ 它服务的语言结构

  • 立即数:常量编码在指令里 ← 源码里的字面常量;
  • 寄存器:操作数在寄存器里 ← 被寄存器分配选中的局部变量(开优化后的常态);
  • 基址 + 偏移 [base + disp] ← 结构体字段与栈上局部变量;
  • 基址 + 变址 × 比例 [base + idx*4]数组下标,比例就是元素字节数。

CISC/RISC 分野

  • x86 一条指令就能做 [rdi + rsi*4 + 8]RISC-V 得先把地址算进寄存器再访存——寻址能力是两种设计哲学最直观的体现。
x86 的比例因子只能取 1/2/4/8,正好覆盖 1/2/4/8 字节的元素;元素大小不是 2 的幂时(比如 12 字节结构体的数组),编译器得先用乘法或 lea 算偏移,一条寻址搞不定。RISC-V 基础指令集根本没有变址寻址,一律拆成算地址 + 访存两步。
认地址表达式有个通用套路:不管是 x86 的 [rdi+rsi*4+8]、ARM 的 [x1, x2, lsl #2] 还是 RISC-V 的两步计算,最终都在算 base + index×scale + disp。看到就问「哪个是数组基址、哪个是下标、scale 是不是元素字节数」,对应的 C 结构(数组、结构体字段)立刻浮现。

程序如何决定「跳还是不跳」?这里三家分歧很大,是理解架构哲学的关键点:

三架构对照

x86-64 · 标志驱动ARM64 · 标志驱动RISC-V · 无标志
cmp a,b 设置 RFLAGS (ZF,SF,CF,OF…)cmp x0,x1 设置 NZCV没有标志寄存器
再用 je/jg/jb 据标志跳再用 b.eq/b.lt 跳;另有 cbz/cbnz 免标志比较与分支合一blt a0,a1,label
有符号与无符号比较用的是不同的条件码,是最隐蔽的 bug 之一。x86 的 jg/jl(signed)看的标志组合和 ja/jb(unsigned)不同;ARM 同理,b.gt/b.lt(signed)对 b.hi/b.lo(unsigned)。选错会在数值跨越符号边界时判反——例如把 0xFFFFFFFF 当 -1 还是当四十多亿,结论相反。
RISC-V 取消标志位是有意为之:标志是一种「隐藏的全局状态」,会给流水线和乱序执行制造依赖。把比较塞进分支指令里,状态更显式、硬件更简单。

高级语言的全部控制结构——if/else、while、switch、函数调用与 return——到了机器层只剩下面四类原语。编译器的核心工作之一,就是把结构化控制流「拍平」成跳转与标签。

四类控制流

  • 无条件跳转:x86 jmp / ARM b / RISC-V j
  • 条件分支:依据标志或直接比较。
  • 函数调用:跳转 + 保存返回地址。x86 call 把返回地址压栈;ARM bl 和 RISC-V jal 把返回地址存入链接寄存器(LR / ra)。
  • 返回:x86 ret 从栈弹出地址;ARM ret 跳到 LR;RISC-V ret = jalr x0, ra, 0
RISC 上的坑:叶子函数(不再调用别人)可以不保存返回地址寄存器、省一次访存;但只要函数体内出现任何 bljal,却忘了在序言里把 LRra 压栈,返回时就会跳到被内层调用覆盖后的错误地址——典型的「函数返回到奇怪地方」崩溃。x86 因为 call 自动把返回地址压栈,没有这个坑。
「返回地址放哪」是 CISC/RISC 的分野:x86 默认压栈(访存),RISC 默认进寄存器(更快)。但 RISC 嵌套调用时,必须在函数序言里手动把 LR/ra 压栈保存,否则会被内层调用覆盖。

栈是一块由 栈指针 (SP) 管理的内存区域,向下增长(从高地址往低地址)。每次函数调用会建立一个栈帧,存放返回地址、保存的寄存器和局部变量。

为什么向下?栈从高地址往下、堆从低地址往上,两者相向增长,中间留一大片未用空间由双方共享——谁先用完谁先撞上,最大化地利用整个地址空间。

三个关键概念

  • 序言 (prologue):进入函数时下移 SP 分配空间、保存需要保护的寄存器(含 LR/返回地址)。
  • 尾声 (epilogue):恢复寄存器、还原 SP、返回。
  • 帧指针 (FP):固定指向当前帧的参照点,便于调试回溯。x86 用 RBP,ARM 用 X29,RISC-V 用 s0/fp
; 栈布局(高地址在上)
┌──────────────┐ 高地址
│  调用者的帧   │
├──────────────┤
│  返回地址     │
│  保存的 FP    │ ← FP 指向这里
│  局部变量     │
│  ...          │ ← SP 指向栈顶(最低)
└──────────────┘ 低地址  (栈向下增长 ↓)
栈「向下增长」,但栈帧内部仍按地址从低到高排布、SP 指向最低地址,所以 [sp, #off] 的正偏移是往帧内更高地址走。初学者容易把「压栈」想成地址增大——恰恰相反:push 让 SP 减小,pop 让它增大。
ARM64 常用一条 stp x29, x30, [sp, #-16]! 就同时完成「SP 下移 16 字节 + 保存 FP + 保存 LR」(! 是 pre-index 先减后存),尾声再对称地 ldp x29, x30, [sp], #16 恢复。认 ARM 序言,先找这一对 stpldp

ABI(应用二进制接口)规定了函数之间如何配合,是「让分别编译的代码能互相调用」的契约。四个核心问题:

四个核心问题

  • 参数怎么传:优先用寄存器,超出数量再压栈。
  • 返回值在哪:通常在某个固定寄存器。
  • 谁保存谁caller-saved(易失) 的寄存器,被调用函数可随意改;callee-saved 的,函数若要用必须先存后恢复。
  • 栈对齐:三家都要求调用点 16 字节对齐。

三架构对照

角色x86-64 (SysV)ARM64 (AAPCS)RISC-V
整数参数rdi,rsi,rdx,rcx,r8,r9x0–x7a0–a7
返回值rax (:rdx)x0 (:x1)a0 (:a1)
返回地址栈上x30 (LR)x1 (ra)
callee-savedrbx,rbp,r12–r15x19–x28s0–s11
栈 16 字节对齐的坑在 x86-64 尤其隐蔽:ABI 要求执行 call 那一刻 rsp 是 16 的倍数,而 call 会压入 8 字节返回地址,于是函数入口处 rsp ≡ 8 (mod 16)。序言里必须再调整(一次 push rbp、或 sub rsp, 8)把它对回来,否则调用用到 SSE 的 libc 函数时会因未对齐访问而崩溃。
记 caller/callee-saved 有个钩子:名字带 s(saved)的寄存器——RISC-V s0–s11、ARM 习惯把 x19–x28 叫 saved——是 callee-saved,能跨函数调用存活;参数与临时寄存器(at 系列、x86 的 rdi 等)是 caller-saved,一次 call 就可能被改。想让某个值跨过一次调用还在,要么放进 callee-saved、要么调用前自己压栈。

用户态程序通过特殊指令陷入内核,请求 I/O、退出等服务。约定与普通函数调用不同

三架构对照

x86-64ARM64RISC-V
指令syscallsvc #0ecall
调用号raxx8a7
参数rdi rsi rdx r10 r8 r9x0–x5a0–a5

注意 x86 第 4 参数是 r10 不是 rcx

系统调用号在不同架构、不同 OS 上各不相同。例:Linux 下 write 在 x86-64 是 1,在 ARM64/RISC-V 都是 64;exit 在 x86-64 是 60,在 ARM64/RISC-V 都是 93(ARM64/RISC-V 共用 Linux 通用 syscall 表)。
syscall 的寄存器约定和普通函数调用不完全一样,最容易忘的是 x86-64 第 4 个参数走 r10 而非函数调用的 rcx——因为 syscall 指令本身要用 rcx 存返回地址、r11 存标志,会被内核覆盖。返回值在 rax,负值通常表示 -errno

汇编器一次只看一个文件,凡是「此刻还不知道的地址」——别的文件里的函数、还没定稿的节区基址——都在 .o留一个洞,并附一张待办单(重定位条目),链接器最后统一填。本页反复出现的 %hi/%lo@PLT、「重定位溢出」,源头全在这张单子上。

三家的重定位长相

  • x86-64:藏在 .o 里(R_X86_64_PC32),汇编源码里看不见;
  • ARM64adrp + :lo12: 两条配对;
  • RISC-V%hi(sym) / %lo(sym) 直接写在汇编源码里——别家藏起来的东西它摆在明面上,所以它的汇编最适合理解重定位。

两种典型链接错误

  • undefined reference:有洞、但没人定义那个符号——汇编期不查未定义,账都记到链接期;
  • relocation truncated to fit:洞的位数装不下最终距离,比如 32 位相对寻址跨了 ±2 GB;
  • @PLT:调共享库函数时链接期仍不知道地址,先跳 PLT 蹦床、真实地址由动态链接器填进 GOT——读反汇编时当「经中转的外部调用」即可。
别把「汇编通过」当「地址已定」。.o 里所有跨文件的地址都是占位值,反汇编 .o 时必须配 -r 把重定位标注出来,否则会被占位的 0 误导。
readelf -r 是「这个 .o 依赖外界什么」的精确清单。链接报 undefined reference 时第一步就该看它——拼写错误在这里一眼现形

SIMD(单指令多数据)让一条指令同时对多个数据做相同运算,是多媒体、科学计算、机器学习性能的关键。

三架构对照

x86-64ARM64RISC-V
MMX→SSE→AVX→AVX-512NEON (Advanced SIMD)V 扩展(向量)
XMM(128)/YMM(256)/ZMM(512)V0–V31,128 位v0–v31,可伸缩长度
+ 掩码寄存器 k0–k7+ SVE 可伸缩向量设计上类似 SVE

趋势

  • 三家近年都转向「可伸缩向量」思路:AVX-512 仍是固定宽度,但 ARM SVE / RISC-V V 是长度无关 (VLA) 的——同一份代码可在不同向量宽度的机器上运行。
定长 SIMD(SSE/AVX/NEON)把向量宽度写死在指令和寄存器名里(xmm=128、ymm=256),要用更宽的指令集就得重写代码;而 ARM SVE 与 RISC-V V 扩展是「长度无关(VLA)」的,同一份代码自适应硬件向量宽度。把这两类等同看待,是移植时的常见误判。
多数场景不必手写向量汇编:先靠编译器自动向量化(-O3 -march=native),需要精细控制时用 intrinsics(x86 的 <immintrin.h>、ARM 的 <arm_neon.h>)——既接近汇编性能又保留可读性。手写留给自动向量化搞不定的热点。

上手:跑通你的第一段汇编

读汇编之前先跑一段汇编。用一个只打印一行字的 hello.s,把「汇编 → 链接 → 运行」整条流水线在你自己的机器上走通:三条命令跑起来、逐行拆掉每个字、按报错出现的时期分诊、再用 gdb 逐条指令看机器状态。这一章只要求你读懂并跑通别人写好的一段;自己动手写的地基(数据与标签设施、读输入的程序、与 C 混编)在 06 章。全章命令在任何 Linux / WSL 上原样可复制,工具全部随 gcc 一起装好(唯 llvm-mc 的交叉验证可选装)。

汇编入门最大的门槛不是指令,而是「怎么跑起来」——没有 main、没有 print,连退出都要自己动手。

准备环境(一次性)

  • 任何 Linux 发行版或 Windows 的 WSL 都行:sudo apt install build-essential gdb——汇编器 as 和链接器 ld 随 gcc 一起装上,不用单独找。
  • 把右侧代码存成 hello.s.s 是汇编源码的通用后缀)。

三条命令

$ as hello.s -o hello.o     # 汇编:文本 → 机器码(目标文件)
$ ld hello.o -o hello       # 链接:目标文件 → 可执行文件
$ ./hello                   # 运行
hello, asm
$ echo $?                   # 看退出码——就是 exit 系统调用的参数
0

两步各自的产物都值得摸一下:file hello.o 显示 relocatable(半成品),file hello 显示 executable——同样的机器码,差的是地址和入口(下一卡展开)。

为什么不用 gcc 一步到位

gcc 当然也能编汇编文件,但它会自动带上 C 运行时(crt):一个它自己的 _start、一套 main 之前的初始化。用裸的 as + ld,二进制里每一个字节都是你写的——这正是汇编层学习要的确定性。(直接 gcc hello.s 会报什么错,第 3 卡分诊表里有。)

# hello.s —— x86-64 Linux,GAS 汇编器,Intel 语法
.intel_syntax noprefix      # 用 Intel 语法(默认是 AT&T)
.globl _start               # 把入口符号导出给链接器

.section .rodata            # 只读数据节
msg:    .ascii "hello, asm\n"

.section .text              # 代码节
_start:
    mov rax, 1              # 系统调用号 1 = write
    mov rdi, 1              # 参数1:fd 1 = 标准输出
    lea rsi, [rip + msg]    # 参数2:缓冲区地址
    mov rdx, 11             # 参数3:长度(字节数)
    syscall                 # 陷入内核
    mov rax, 60             # 系统调用号 60 = exit
    xor rdi, rdi            # 退出码 0
    syscall
本卡只在 Linux / WSL 上成立。macOS 的系统调用号、符号名(_main 带下划线)、二进制格式(Mach-O)全都不同,Windows 原生更是另一套——照抄会一路报错。
本章选 x86-64 是因为它多半就是你手上这台机器——零门槛真跑。三个架构章末尾各有一张「完整程序示例」卡,ARM64 / RISC-V 版的 hello 用 qemu 一样能跑,命令都在那里。

hello.s 里真正让 CPU 干活的指令只有 8 条,其余全是给汇编器看的脚手架。把文件里的每个词分进三类——指令、伪指令、标签——这个文件就完全透明了。

三类词

类别例子去向
指令movleasyscall1:1 变成机器码,CPU 执行
伪指令(. 开头).globl.section.ascii指示汇编器怎么干,不生成指令
标签(: 结尾)_start:msg:给地址起名字,汇编后只剩地址

几个关键行

  • .section .rodata / .text数据和代码分放到不同节区——链接后 .text 可执行不可写、.rodata 只读,往 msg 写会段错误;
  • .globl _start:把符号导出给链接器,否则 ld 找不到入口;
  • _start 是内核直接跳进来的第一条指令,没有 main、没有运行时替你收尾,所以必须自己调 exit
.ascii 不会自动在末尾补 0 字节(.asciz.string 才会)。这里没事,因为 write 按 rdx 里的长度输出、不找结尾 0;但长度得自己数对——把 mov rdx, 11 改成 64,程序照常退出码 0,只是把 msg 后面相邻内存的垃圾字节一起打了出来:数错长度不报错,直接给错行为
readelf -S hello.oreadelf -S hello 各跑一遍对比:节区同名,但 .o 里地址全是 0(还没定),可执行文件里才有真地址——「链接器决定地址」亲眼可见。

报错出现在哪一期,比报错文本本身信息量更大——同一个手误,打错指令名汇编期就拦住,打错标签名却要到链接期才炸。

汇编期(as 报的,带行号,最好修)

  • 指令打错(movv rax, 1)→ Error: no such instruction,带行号,最好修;
  • 运行期没有报错可读时用 gdblayout asm 开指令窗口、si 单步、info registersx/8xg $rsp 看栈——看下一条 → 执行 → 看它改了什么,把 hello 的 8 条指令走一遍,机器状态就不抽象了。
  • 寄存器打错报的不是「bad register」而是 ambiguous operand size——Intel 语法里汇编器把不认识的词当成了内存符号,看到这条先怀疑拼写

链接期与运行期

  • 标签打错 → 汇编期静默通过(汇编器以为那是别的文件里的外部符号),ld 才报 undefined reference——「汇编通过」离「能链接」还有距离;
  • 忘写 exit → 执行流冲出代码末尾,撞上垃圾字节,段错误;
  • 运行期没有行号,只有信号:退出码减 128 就是信号编号
「汇编通过」离「能链接」还差一步:标签拼错时汇编器以为那是别的文件里的外部符号,静默放行,ld 才报 undefined reference
关键词就是分诊依据:no such instruction / ambiguous operand 是汇编期,undefined reference 是链接期,Segmentation fault 是运行期。

x86-64 深入

桌面与服务器的主流架构(Intel / AMD),CISC 的代表。变长指令编码、强寻址、丰富的历史层积。这一章覆盖编码结构、完整指令家族、标志/条件码、字符串指令、SSE/AVX、原子与内存模型、System V ABI 与系统调用。本章示例统一用 Intel 语法(目标在左);与 AT&T 的对照见后面「AT&T vs Intel 语法」卡。

16 个 64 位通用寄存器,每个都能按 64/32/16/8 位宽度访问其低位部分。

寄存器宽度与传统用途

6432168(低)用途
RAXEAXAXAL累加器 / 返回值
RBXEBXBXBL基址 (callee-saved)
RCXECXCXCL计数 / 第4参数
RDXEDXDXDL数据 / 第3参数
RSIESISISIL源 / 第2参数
RDIEDIDIDIL目标 / 第1参数
RBPEBPBPBPL帧指针 (callee-saved)
RSPESPSPSPL栈指针
R8–R15R8D…R8W…R8B…扩展寄存器

其它寄存器

  • 遗留高字节 AH/BH/CH/DH(仅低 4 个寄存器有,且不能与 REX 前缀同时使用)。
  • RIP(指令指针,只能 PC 相对寻址)、RFLAGS(标志)。
  • 段寄存器:平坦模型下 CS/DS/ES/SS 基本不用,但 FS/GS 仍用于线程局部存储 (TLS)(如 mov rax, fs:[0])。
  • 向量:XMM0–15 / YMM / ZMM0–31,掩码 k0–k7;遗留 x87/MMX。
部分寄存器规则:写 32 位子寄存器(mov eax, 1)会清零高 32 位;但写 8/16 位(mov al, 1保留高位不变。后者会与旧值产生「假依赖」,可能触发部分寄存器停顿 (partial register stall)。这也是编译器爱用 movzx / xor eax,eax 的原因。
遗留高字节寄存器 ah/bh/ch/dh 与新增的 spl/bpl/sil/dilr8-r15 互斥:凡需要 REX 前缀的指令都不能命名 ah。movb %ah, %r8b 直接报「can't encode 'ah' in an instruction requiring REX」。

x86-64 指令长度 1–15 字节不等。理解编码结构能解释为什么反汇编要对齐、为什么解码器是 CPU 前端的瓶颈。

一条指令的组成(从左到右)

[遗留前缀] [REX] [opcode] [ModR/M] [SIB] [disp] [imm]
  0–4       0–1    1–3      0–1      0–1   0/1/2/4  0/1/2/4/8

各字段

  • 遗留前缀66(操作数 16 位)、67(地址 32 位)、F0(lock)、F2/F3(rep/repne 或 SSE 标量)、段超越。
  • REX (0x40–0x4F):64 位模式特有。REX.W=64 位操作数;REX.R/X/B 把寄存器字段从 3 位扩到 4 位,以访问 R8–R15。
  • ModR/Mmod·reg·r/m,决定操作数是寄存器还是内存、以及哪种寻址。
  • SIBscale·index·base,编码 [base + index*scale + disp]
  • VEX / EVEX:AVX(2–3 字节 VEX) / AVX-512(4 字节 EVEX) 的新前缀,提供三操作数与掩码。
单条指令最长 15 字节是硬上限,靠堆叠冗余前缀超出就触发 #UD#GP。REX 前缀还必须紧贴 opcode(排在所有遗留前缀之后),位置放错会被解码成完全不同的指令。
objdump -d 里同一段字节从不同偏移反汇编会得到不同指令——变长编码使 x86 没有「指令边界对齐」,这正是花式 gadget / 混淆的温床,也是定长 RISC 想避免的。

x86 有两套汇编写法,看同一段反汇编时必须分清。

对照

项目IntelAT&T (Linux 默认)
寄存器rax%rax
立即数5$5
操作数顺序op 目标, 源op 源, 目标
大小由寄存器推断助记符后缀 b/w/l/q
内存[rbx+rcx*4+8]8(%rbx,%rcx,4)
; Intel:目标在左
mov  rax, 5
mov  rax, [rbx+rcx*4+8]

; AT&T:源在左,%寄存器 $立即数,l/q 后缀
movq $5, %rax
movq 8(%rbx,%rcx,4), %rax
最经典的坑是操作数顺序相反:AT&T 的 mov $1, %rax 等于 Intel 的 mov rax, 1——把源当目标读会得出完全反的语义。AT&T 还靠助记符后缀 b/w/l/q 定宽度,立即数写进内存这类无法从寄存器推断宽度的场合漏后缀会直接报错。
GNU 工具链默认 AT&T;objdump -M intel 可切 Intel。NASM、Windows、Intel SDM 用 Intel。本章示例统一用 Intel 语法

x86 的内存操作数公式是它最强的特性之一。

有效地址

有效地址 = base + index * scale + disp   (scale ∈ {1,2,4,8})
mov eax, [rdi + rsi*4]      ; arr[i],4=sizeof(int)

LEA:不访存的算术

  • lea 只算地址、不访存,编译器拿它当「免费的乘加器」。
lea rax, [rdi + rsi*2]      ; rax = rdi + 2*rsi
lea rax, [rdi + rdi*4]      ; rax = 5 * rdi

RIP 相对寻址(PIC 关键)

  • 64 位下访问全局变量用 [rip + disp],使代码位置无关,是现代 PIE(位置无关可执行文件)的默认。
mov eax, [rip + global_x]   ; 相对当前指令取全局
lea rdi, [rip + msg]        ; 取字符串地址
lea 只计算有效地址、不访存也不设标志位,别拿它当 mov(真解引用)或 add(会改标志)的等价物。RIP 相对寻址仅 64 位可用,位移是 32 位有符号,目标超出 ±2GB 就得改用 movabs 装绝对地址。
lea 当免费乘加器:lea rax, [rdi + rdi*4] 一条算出 5×rdi,lea rax, [rdi + rsi + 8] 做三数相加且不动标志位。注意 scale 只能取 1/2/4/8。

mov 家族负责寄存器、内存与立即数之间的搬运,配合 movzx/movsx 处理位宽变化,其中一组符号扩展指令专为除法准备被除数。

传送

  • mov 寄存器/内存/立即数互传(不能内存→内存)。movabs 传 64 位立即数。
  • movzx 零扩展、movsx 符号扩展(窄→宽);movsxd 专做 32→64 符号扩展。
  • xchg 交换(对内存操作数隐含 lock,有性能代价);bswap 字节翻转(改字节序);cmovcc 条件传送(无分支)。

面向除法的符号扩展(为什么存在)

  • 存在的理由:idiv 的被除数是双倍宽的 RDX:RAX(见下一张卡),做有符号除法前必须先把符号位填满高半部——这组指令专为此而生。
  • cbw/cwde/cdqe:把 AL→AX→EAX→RAX 符号扩展。
  • cwd/cdq/cqo:把 AX/EAX/RAX 符号扩展进 DX:AX / EDX:EAX / RDX:RAX,为 idiv 准备被除数高位。
movzx eax, byte ptr [rdi]   ; 加载 1 字节,零扩展到 32 位
movsxd rax, esi             ; 32 位有符号 → 64 位
cqo                          ; rax 符号扩展进 rdx:rax
idiv rcx                     ; (rdx:rax)/rcx
mov 不能内存到内存——movq (%rsi), (%rdi) 被 GNU as 拒绝(binutils 2.46 报「operand type mismatch for `movq'」),必须借寄存器中转。64 位立即数只有 movabs 能直接装入;普通 mov r64, imm32 会把 32 位立即数符号扩展到 64 位。
加载窄数据时优先用 movzxmovsx 一步扩展到目标宽度,别先 mov 窄再手动补高位。32 位有符号扩到 64 位有专门的 movsxd(AT&T 写作 movslq)。

加减乘除俱全,但乘法和除法会隐式占用 RDX:RAX 这对寄存器,是这一族里最容易踩的地方。

加减

  • add/sub、带进位/借位 adc/sbb(多精度运算)、inc/dec不更新 CF)、neg

乘法(三种形式)

  • mul src(无符号):单操作数,RDX:RAX = RAX × src
  • imul src(有符号):同上全宽乘。
  • imul dst, src / imul dst, src, imm:双/三操作数,只保留低位结果(常用)。

除法(最易错)

  • div/idiv src:被除数是 RDX:RAX,商→RAX、余→RDX。
  • 除前必须先把高位填好:无符号 xor edx,edx,有符号 cqo
  • 除以 0、或商溢出 → #DE 异常(程序崩溃)。
imul rax, rbx, 10       ; rax = rbx * 10(三操作数,只留低 64 位)
mul  rcx                 ; rdx:rax = rax * rcx(无符号全宽)
xor  edx, edx            ; 无符号除法前清高位
div  rsi                 ; rax = (rdx:rax)/rsi,rdx = 余数
忘记在 idiv 前执行 cqo(或在 div 前清零 RDX)是经典崩溃:RDX 里的残留值会被当成被除数高 64 位,导致 #DE 或错误结果。
单操作数 mul/imul src 得到双倍宽结果 RDX:RAX;只要低位就用双/三操作数形式 imul dst, src[, imm],它不碰 RDX。另外 inc/dec 不更新 CF,靠进位判断的计数循环要改用 add/sub

位运算、移位与位扫描计数指令,多数会改写标志位,test/and 常被用来只置标志而不保留结果。

逻辑

  • and/or/xor/nottest=按位与但只设标志。xor eax,eax 是清零惯用法。

移位/循环

  • shl/shr 逻辑左右移、sar 算术右移(保符号)。
  • rol/ror 循环移位、rcl/rcr 带 CF 循环。
  • shld/shrd 双精度移位(拼接两寄存器)。

位扫描与计数

  • bt/bts/btr/btc 测试/置/清/翻位;bsf/bsr 找最低/高位 1;popcnt 数 1 的个数;lzcnt/tzcnt 前导/尾随零计数。
  • BMI1/BMI2andn, blsi, blsr, bextr, bzhi, pext/pdep, mulx, shlx/sarx/rorx——无标志影响的现代位操作。
test rax, rax          ; 置标志,常配 jz 判断 rax 是否为 0
and  eax, 0xFF           ; 取低 8 位
shl  rax, 3              ; rax *= 8(逻辑左移)
sar  rax, 2              ; 算术右移(保符号)
popcnt rcx, rax         ; 统计置 1 的位数
移位计数会被掩码:32 位操作数只取低 5 位、64 位取低 6 位,所以 shl eax, 32 实际移 0 位(原样不动)而非清零。逻辑右移 shr 补 0、算术右移 sar 补符号位,有符号数除以 2 的幂必须用 sar
判断寄存器是否为 0 用 test rax, raxcmp rax, 0 编码更短;对 2 的幂取模用 and、乘除 2 的幂用 shl/sar,都比通用乘除快。

算术逻辑指令的结果副产品记录在 RFLAGS 里,cmp/test 之后的条件码驱动所有 jcc/setcc/cmovcc。

主要标志

标志含义
ZF结果为 0
SF结果为负(符号位)
CF进位/借位(无符号溢出)
OF有符号溢出
PF / AF奇偶 / 辅助进位
DF方向(字符串指令)

cmp / test 后的条件码(jcc / setcc / cmovcc 通用)

类型条件后缀依据
相等e/neZF
有符号g/ge/l/leSF, OF, ZF
无符号a/ae/b/beCF, ZF
单标志s/ns, c/nc, o/no, p/npSF/CF/OF/PF

cmp a,ba-b 只设标志;同一组后缀可拼到 j(跳)、set(置 0/1)、cmov(条件传送)。

有符号用 jg/jl…,无符号用 ja/jb…,用错是经典 bug——把大的无符号值误判成负数。原理:无符号比较只看借位 CF,有符号比较看 SF 与 OF 是否一致cmp 一次把两套标志都设好,选哪个 jcc 后缀,就是在选看哪一套。
条件码后缀速记:有符号比较用 g/l(greater/less),无符号用 a/b(above/below),相等 e/ne 两者通用。cmp a, b 算的是 a−b,所以 jg 读作「a 大于 b 则跳」。

jmp/call/ret 构成分支与函数调用,间接跳转(jmp/call 寄存器或内存)支撑虚函数表、函数指针与 switch 跳转表。

跳转与调用

  • jmp:直接 (rel8/rel32) 或间接 (jmp rax / jmp [rax]);条件 jcc
  • call:把返回地址压栈再跳;ret 弹出返回地址跳回;ret imm16 顺带清理栈上参数。
  • 间接 call rax 实现虚函数表、函数指针。

安全相关

  • retpoline:用 call/ret 改写间接分支以缓解 Spectre v2。
  • CET(控制流强制技术)endbr64 作为间接跳转的合法落点(防 ROP/JOP,即返回 / 跳转导向编程攻击);影子栈校验 ret
call func           ; push 下一条地址; jmp func
jmp  [rax + rcx*8]  ; 间接跳转表(switch)
ret                 ; pop rip
callret 全靠栈传递返回地址,任何让 ret 时 rsp 指向错误值的操作(压栈没配平、局部数组越界覆盖返回地址)都会跳飞。ret 本质是「弹栈顶到 rip」,栈顶被篡改即被劫持,这正是 ROP 攻击的原理。
call 压入 8 字节返回地址、ret 弹出跳回,二者必须严格配平。间接跳转表配合 endbr64 落点才能通过 CET 校验,现代编译器默认在间接分支目标处插它。

一族用隐含寄存器批量操作内存的指令,配 rep 前缀可硬件加速 memcpy/memset/strlen。

隐含寄存器

  • RSI、目标 RDI、计数 RCXDF 控制方向(cld 递增 / std 递减)。
  • 操作:movs(传)、stos(存)、lods(取)、scas(扫描)、cmps(比较),后缀 b/w/d/q。

前缀

  • rep 重复 RCX 次;repe/repzrepne/repnz 在相等/不等时提前停止(配 scas/cmps)。
cld
rep movsb            ; memcpy(rdi, rsi, rcx)
mov al, 0
repne scasb          ; strlen:扫描到 '\0'
方向标志 DF 是持久的全局状态std 设成递减后若不 cld 复位,之后所有字符串指令都反向执行。这类指令还隐式吃掉 RSI/RDI/RCX,调用前放在这些寄存器里的值会被覆盖。
rep movsbrep stosb 在现代 CPU 上有 ERMSB/FSRM 微码优化,是编译器 memcpy/memset 的常见落点;使用前先 cld 把 DF 清 0 确保正向遍历。

Linux/macOS 的调用约定(Windows x64 不同:参数 rcx,rdx,r8,r9 + 32 字节 shadow space)。

寄存器分类

类别寄存器
整数参数 1–6 (INTEGER)rdi rsi rdx rcx r8 r9
浮点参数 (SSE)xmm0–xmm7
返回值rax(:rdx) / xmm0(:xmm1)
易失 (caller-saved)rax rcx rdx rsi rdi r8–r11
callee-savedrbx rbp r12–r15

进阶规则

  • 聚合体分类:≤16 字节的 struct 按 8 字节分块判定为 INTEGER/SSE 走寄存器,否则整体走栈 (MEMORY)。
  • 大返回值:调用者分配空间、把隐藏指针放 rdi(其余参数后移),函数把该指针回填 rax
  • 变参函数al 须存「用了几个向量寄存器」。
  • 红区:rsp 下方 128 字节,叶函数可直接用而不调 rsp。仅叶函数敢用——它不再 call,没有后续压栈会覆盖这块;一旦调用别的函数、或信号处理器抢占(内核在此按 ABI 会避开红区,但你自己的嵌套调用不会),这片区域就会被踩。调用点 rsp 须 16 字节对齐。
caller/callee-saved 别记反:只有 rbx/rbp/r12-r15 是 callee-saved(被调用者负责保存),其余(含全部参数寄存器与 r10/r11)都是 caller-saved,call 之后随时可能被改。另外 Windows x64 ABI 完全不同(参数走 rcx,rdx,r8,r9 + 32 字节 shadow space),别把 Linux 约定套上去。
整数参数顺序 rdi, rsi, rdx, rcx, r8, r9、返回值 rax。凡是要跨越 call 存活的值,放进 callee-saved 寄存器(rbx/rbp/r12-r15),否则被调函数可能覆盖。

序言建立栈帧、尾声将其拆除,同时要保证在下一次 call 时栈指针满足 16 字节对齐。

要点

  • 开优化时常省略帧指针(用 rsp 直接寻址)以多出一个 rbp 可用——调试看不到 rbp 链多半因此。
  • 对齐:call 压入 8 字节返回地址,故函数入口 rsp ≡ 8 (mod 16),序言常 sub rsp, N 补齐到 16。
my_func:
    push rbp            ; 保存调用者帧指针
    mov  rbp, rsp       ; 建立本帧
    sub  rsp, 32        ; 局部变量(并维持 16 对齐)
    ; ... [rbp-8] 等访问局部 ...
    leave               ; = mov rsp,rbp ; pop rbp
    ret
关键对齐坑:call 压入 8 字节返回地址后,函数入口处 rsp ≡ 8 (mod 16)。若在调用其它函数前没把 rsp 补到 16 字节对齐,被调函数里的对齐向量访存(如 movaps)会直接 #GP 崩溃。
标准序言是 push rbp; mov rbp, rsp; sub rsp, N,尾声用 leave; ret(leave 等于 mov rsp, rbp; pop rbp)一步还原。开 -O 优化常省略帧指针以腾出 rbp,调试时看不到完整 rbp 链多半因此。

标量浮点和 SIMD 都运行在 XMM/YMM/ZMM 寄存器上,助记符后缀用 p/s 区分打包与标量、s/d 区分单双精度。

寄存器层级

  • XMM0–15(128) ⊂ YMM(256) ⊂ ZMM0–31(512),AVX-512 另有掩码 k0–k7

标量 / 打包 / 对齐

  • 标量浮点也走这里:movss/movsdaddsd/mulsd(单个 float/double)。
  • 打包:addps(4×f32)、addpd(2×f64)…后缀 p=packed、s/d=单/双精度。
  • 对齐版 movaps/movdqa 要求 16/32 字节对齐(否则 #GP);非对齐版 movups/movdqu

AVX 注意

  • VEX 三操作数、非破坏性:vaddps ymm0, ymm1, ymm2;写 XMM 会清零 YMM/ZMM 高位。
  • SSE↔AVX 混用有转换罚时,跨界处插 vzeroupper
  • vfmadd…(FMA 乘加)、broadcast、gather/scatter;AVX-512 支持掩码 {k1}{z} 与内嵌广播。
addsd  xmm0, xmm1            ; 标量 double 相加
vaddps ymm0, ymm1, ymm2     ; 8×float,非破坏性三操作数
对齐版 movapsmovdqa 要求 16 字节(YMM 为 32 字节)对齐,地址没对齐直接 #GP——不确定就用非对齐版 movupsmovdqu。SSE 与 AVX 代码混跑有状态切换罚时,退出 AVX 段前插 vzeroupper
后缀速记:addss(标量单精度)、addsd(标量双精度)、addps(4×单精度打包)、addpd(2×双精度打包);p=packed、s/d=单/双精度。AVX 版本统一加 v 前缀并采用三操作数、非破坏性形式。

lock 前缀把读-改-写变成原子操作,x86-TSO 是一种较强的内存模型,唯一允许的重排是写之后的读(store→load)。

原子指令

  • lock 前缀使 add/and/or/inc/xadd/cmpxchg… 成为原子读-改-写。
  • xchg [m], r 隐含 lockcmpxchg(CAS,比较并交换) 与 cmpxchg16bxadd(fetch-and-add)。

内存序:x86-TSO(强序)

  • x86 内存模型很强:普通读不与读重排、写不与写重排,唯一允许的是「写后读」因 store buffer 而重排(store→load)。
  • lock 指令是全屏障;显式屏障 mfence/lfence/sfence
  • 非临时存储 movnt… 绕过缓存,需 sfence 保证可见。
spin:
    mov  eax, 0
    mov  ecx, 1
lock cmpxchg [lock_var], ecx  ; if(*p==0) *p=1; 原子 CAS
    jne  spin                  ; 失败则自旋
lock 只对读-改-写内存指令(add/and/xchg/cmpxchg/xadd 等)有意义,加到 mov 这类非 RMW 指令上会在执行时触发 #UD。x86 是强序但不等于免同步:store buffer 仍允许「写后读」重排,需要顺序时靠 mfence 或任一 lock 指令兜底。
无锁 CAS 用 lock cmpxchg:期望值放 rax,相等则写入新值并置 ZF=1,否则把内存现值载回 rax。xchg 对内存操作数隐含 lock,无需再显式加前缀。

调用号入 rax,参数 rdi, rsi, rdx, r10, r8, r9,执行 syscall,返回值在 rax(参数寄存器与 r10 陷阱见 core 章系统调用卡的三架构对照)。

x86 特有

  • syscall 指令本身会破坏 rcx(存返回 rip)与 r11(存 rflags)——这是它区别于普通 call 的关键副作用。
  • 返回值约定:落在 -4095 ~ -1 区间表示 -errno(错误码取负),需自行判别。
; write(1, msg, 13); exit(0)
mov rax, 1        ; sys_write
mov rdi, 1        ; stdout
lea rsi, [rip+msg]
mov rdx, 13
syscall
mov rax, 60       ; sys_exit
xor edi, edi
syscall
syscall 指令自身会覆盖 rcx(存返回 rip)和 r11(存 rflags),前后别指望这两个寄存器保值。且第 4 个参数走 r10 而非普通函数调用约定里的 rcx——这是内核 ABI 与用户态 ABI 的关键差异,套错会传错参数。
x86 的特权级叫 ring 0–3:内核跑在 ring 0,用户程序跑在 ring 3(ring 1/2 在现代 OS 里基本闲置)。syscall 正是从 ring 3 切入 ring 0 的受控入口,sysret 原路返回——对应 ARM64 的异常级别 EL0→EL1、RISC-V 的 U→S 特权级切换。

按功能分类的 x86-64 常用指令一览,配合前面各卡当作速查索引使用。

速查表

类别指令作用
传送mov / movzx / movsx / lea复制 / 扩展 / 算地址
push / pop / leave压/出栈 / 拆帧
算术add sub adc sbb neg加减(带进位/借位)
乘除imul mul idiv div乘 / 除(隐含 rdx:rax)
逻辑and or xor not test位运算 / 测试
移位shl shr sar rol ror移位 / 循环
比较cmp test设标志(不写结果)
控制流jmp jcc call ret跳转 / 调用 / 返回
条件setcc cmovcc条件置位 / 传送
字符串movs stos scas + rep批量内存操作
原子lock xchg cmpxchg xadd原子 RMW
系统syscall陷入内核
本速查表按 Intel 语法(目标在左)书写;同一条指令在 GNU 默认的 AT&T 语法下操作数顺序相反、寄存器带 %、立即数带 $,读 objdump 反汇编时别照抄这里的顺序。
xor eax, eax 清零:比 mov eax,0 编码更短、且打断依赖链,编译器几乎总这么写。

前面拆的都是零件,这里给两段能直接汇编、运行的完整程序,好看清一整段 x86-64 汇编到底长什么样。Hello World 展示「数据段 + _start 入口 + 系统调用」的骨架;factorial 是个叶函数,展示循环、条件分支与调用约定(参数进 edi、结果出 eax)。

怎么跑起来

  • 汇编 + 链接:nasm -f elf64 hi.asm && ld hi.o -o hi(Intel/NASM 语法)。
  • 程序从 _start 起步、不链接 libc,所以退出必须自己调 sys_exit,否则会「跑飞」。

NASM ↔ GAS(Intel) 方言速查

本卡与下一卡是 NASM 语法,上手章全程是 GAS 的 Intel 模式——同为「Intel 操作数序」,写法却处处小异。对照着换,两边的代码就能互相搬运:

做什么NASM(本卡)GAS Intel(上手章)
导出符号global _start.globl _start
注释;#
节区section .data.section .data
定义数据db / dw / dd / dq.byte / .word / .long / .quad
串长常量len equ $ - msg.equ len, . - msg(「当前地址」是 .,同效)
访存宽度byte [rdi]byte ptr [rdi]
RIP 相对[rel msg][rip + msg]
本地标签.loop:(点前缀,从属于上一个普通标签,可在不同父标签下重名复用).L 前缀(名字需全文件唯一);要重名复用选数字标签 1:/1b

gcc/as 只认 GAS、nasm 只认 NASM,喂反了报错千奇百怪——上手章分诊卡里「寄存器名打错被当成符号」的错位就是 GAS-Intel 特色。

; ===== Hello, World!  (Linux x86-64, NASM) =====
section .data
msg:    db  "Hello, World!", 10     ; 末尾 10 = 换行符
len     equ $ - msg                 ; $ 为当前地址,相减得串长

section .text
global _start
_start:
    mov  rax, 1         ; sys_write
    mov  rdi, 1         ; fd = 1 (stdout)
    lea  rsi, [rel msg] ; buf = msg
    mov  rdx, len       ; count = 串长
    syscall

    mov  rax, 60        ; sys_exit
    xor  rdi, rdi       ; 退出码 0
    syscall

; ===== int factorial(int n)  迭代阶乘 =====
; 入参 n 在 edi,返回值在 eax(System V 约定)
factorial:
    mov  eax, 1         ; result = 1
    cmp  edi, 1
    jle  .done          ; n <= 1 时直接返回 1
.loop:
    imul eax, edi       ; result *= n
    dec  edi            ; n--
    cmp  edi, 1
    jg   .loop          ; n > 1 继续循环
.done:
    ret
_start 不经过 libc,函数返回无处可去,必须自己调 sys_exit(rax=60)收尾,直接 ret 会跳到无效地址崩溃。此处用 NASM/Intel 语法,若改用 gcc 汇编 AT&T 文件,操作数顺序与前缀都要相应改写。
跑起来之后花五分钟走完观察闭环:strace ./hi 能看到 write 和 exit 两次系统调用(验证 syscall 卡的约定);gdb ./hibreak _start + layout asm + si 单步,用 info registers 盯着 rax/rdi 被逐条填上——比再多读三张卡都有用。

factorial 只在寄存器里算数;这两段则走内存,才是循环 + 指针的日常汇编。strlen 逐字节扫描到 0 字节(指针遍历);array_sum 用「基址 + 变址×8」一条指令取 long 元素(数组下标)。参数按 System V 进 rdi/rsi,结果出 rax

; ===== size_t strlen(const char *s)  求字符串长度 =====
; s 在 rdi,返回长度到 rax
strlen:
    xor  rax, rax           ; len = 0
.next:
    cmp  byte [rdi+rax], 0  ; s[len] 是 0 吗?
    je   .done
    inc  rax                ; len++
    jmp  .next
.done:
    ret

; ===== long array_sum(const long *a, long n)  数组求和 =====
; a 在 rdi,n 在 rsi,返回和到 rax
array_sum:
    xor  rax, rax           ; sum = 0
    xor  rcx, rcx           ; i = 0
.sum:
    cmp  rcx, rsi
    jge  .end               ; i >= n 就结束
    add  rax, [rdi+rcx*8]   ; sum += a[i](元素 8 字节)
    inc  rcx                ; i++
    jmp  .sum
.end:
    ret
cmp byte [rdi+rax], 0 里的 byte 大小前缀不能省:内存与立即数都无法推断操作数宽度。更糟的是省了不一定报错——NASM 3.01 把 cmp [rdi+rax], 0 静默编成 word(2 字节)比较(objdump 可见 66 前缀),这份 strlen 会越过单个 0 字节继续扫、读越界;GAS 的 Intel 语法才报「ambiguous operand size for `cmp'」拦下来。换成 int 数组时,变址比例也要从 ×8 改成 ×4,否则会跨步读错元素。
元素是 8 字节 long,所以变址比例 ×8;换 int 数组就是 ×4。想跑:按 06 章「和 C 互相调用」卡的方向一——本卡是 NASM 语法,加一行 global array_sumnasm -f elf64 sum.asm 编出 .o,再写个带原型的 main.c 用 gcc main.c sum.o 链接(或先改写成 GAS 再 gcc main.c sum.s);跑起来后到 gdb 里 si 单步看 rax 逐次累加。

ARM64 (AArch64) 深入

手机、Apple Silicon、云服务器的底层。规整的 load-store RISC,定长 4 字节指令。本章覆盖寄存器与编码、寻址、立即数构造、带移位/扩展的算术、bitfield 位操作、条件选择与 ccmp、AAPCS64、PAC/BTI、NEON/SVE、LSE 原子与内存模型、系统调用与异常级别,末尾把上手章的 argv 程序搬过来动手重写。

AArch64 有 31 个 64 位通用寄存器 X0–X30,每个都带一个低 32 位的 W 视图,外加零寄存器、栈指针等特殊角色。

通用寄存器

  • X0–X30:31 个 64 位 GPR;W0–W30 是各自低 32 位视图,写 Wn 清零 Xn 高 32 位
  • 编码值 31 的双重身份:在数据运算里是零寄存器 XZR/WZR(读 0 写弃),在 load/store 与地址运算里是栈指针 SP。所以「没有第 32 个普通寄存器」却仍称 31 个 GPR。
  • X30 = LR(链接寄存器,bl 写入);X29 = FP(帧指针,约定);PC 不可直接读写。

角色保留 / 状态 / 向量

  • X18 平台寄存器(某些 OS 保留,勿用);X16/X17 = IP0/IP1(链接器 veneer 可破坏)。
  • PSTATE:NZCV + DAIF(中断屏蔽)等;系统寄存器经 MRS/MSR 访问(如 TLS 基址 TPIDR_EL0)。
  • SIMD/FP:V0–V31(128 位),控制 FPCR/FPSR
Wn 会把 Xn 的高 32 位清零,别指望高位残留还在。编码值 31 一码两义:数据运算里是零寄存器,访存/地址运算里却是 SP,同一条汇编换个指令含义就变了。
需要常数 0 直接用 XZR/WZR(读恒为 0),省一条清零指令;不想要的结果写进 WZR 丢弃即可。

所有 A64 指令都是 4 字节、自然对齐,字段位置固定,解码极简——与 x86 变长形成鲜明对比。

带来的取舍

  • 优点:取指/解码简单、并行宽、无指令边界歧义、分支预测友好。
  • 代价:32 位里塞不下任意大立即数。于是有了 bitmask 逻辑立即数编码、movz/movk 分段构造大常量、adrp+add 拼地址等机制(见下文)。
别指望像 x86 一条 mov 装进任意立即数——32 位编码塞不下大常量。mov x0, #0x12345678 这种非位掩码模式的立即数会直接汇编报错,得靠 movz/movk 分段拼。
定长 4 字节且自然对齐,意味着 PC、返回地址、任何跳转目标天生 4 字节对齐,反汇编也能从任一 4 字节边界稳定起解。

只有 LDR/STR 家族能访问内存(load-store 架构)。

宽度与扩展

  • ldrb/ldrh/ldr(8/16/32·64);符号扩展加载 ldrsb/ldrsh/ldrsw

寻址(务必分清)

ldr x0, [x1, #8]            // 偏移:x1+8 读,x1 不变
ldr x0, [x1, #8]!           // 前变址:x1+=8 再读
ldr x0, [x1], #8            // 后变址:先读 再 x1+=8
ldr x0, [x1, x2, lsl #3]    // 寄存器+移位偏移
ldr x0, [x1, w2, sxtw #2]   // 32 位变址符号扩展
ldr x0, =0x12345678         // 字面量池(PC 相对)

成对访存 / 独占

  • ldp/stp 一次读写一对寄存器(栈操作主力,无 push/pop)。
  • ldxr/stxr 独占访问 + ldar/stlr 获取/释放(见原子卡)。
后变址 [x1], #8 是「先用旧地址、再改指针」,容易和前变址记反。ldp/stp 的立即偏移必须是元素大小的整数倍且有范围(x 寄存器为 8 的倍数、[-512, 504]),随手写个 #7 会汇编报错。
前变址 [x1, #8]! 与后变址 [x1], #8 都会把新地址写回 x1;这正是 stp/ldp[sp, #-16]![sp], #16 模拟 push/pop 的机制。

定长编码塞不下任意常量,A64 用几套机制拼出来。

构造大常量

  • mov 立即数其实是 movz/movn/movk 的别名。
  • movz 置一个 16 位段(其余清零);movk 保留其余、插入一个 16 位段;movn 取反。最多 4 条拼出 64 位。
  • 逻辑指令用 bitmask 立即数(按位重复模式编码),所以 and x0,x0,#0xfff 这类能一条搞定。

取地址(PIC)

  • adr ±1MB PC 相对;adrp + add 取 ±4GB 内符号的页地址再加页内偏移。
  • 访问全局经 GOT(全局偏移表):adrp + ldr
movz x0, #0x1234, lsl #16
movk x0, #0x5678          // x0 = 0x12345678
adrp x1, msg              // 符号所在页
add  x1, x1, :lo12:msg    // + 页内偏移
mov 只接受能用 movz/movn 单段或位掩码模式编码的立即数;mov x0, #0x12345678 两者都不满足,会汇编报错,须 movz+movk 拼或用 ldr =。取符号地址别用 mov,要用 adradrp+add
装任意 64 位常量最省心的是伪指令 ldr x0, =value:汇编器把值放进字面量池、展开成一条 PC 相对 ldr(展开为 ldr x0, .Ltmp 加一条 .xword),代价是多一次访存。

A64 的算术逻辑指令能把一次移位或扩展内联进第二操作数,且不少常用助记符其实是别名。

内联移位/扩展(A64 的优雅处)

  • 第二操作数可内联一个移位或扩展add x0, x1, x2, lsl #3 直接算 x1 + (x2<<3)
  • add/sub 与设标志版 adds/subs;带进位 adc/sbc(多精度)。
  • 常用别名:cmp=subs … xzrcmn=addstst=ands … xzrneg/mvn

乘除

  • mul/madd/msub/mneg;加宽 smull/umull/smulh/umulh
  • sdiv/udiv 只有商、没有求余指令——余数用 msub x2, x1, x3, x0(即 x0 - x1*x3)算。
sdiv x2, x0, x1     // q = x0 / x1
msub x3, x2, x1, x0 // r = x0 - q*x1(取余)
没有取余指令,x0 % x1sdiv 求商再 msub 回算余数(msub x3, x2, x1, x0x0 - q*x1);urem/srem 助记符根本不存在(报错)。有符号除法用 sdiv、无符号用 udiv,别混。
add x0, x1, x2, lsl #3 把移位并进一条指令,省掉独立的 lsl;只有 adds/subs(及 cmp/cmn/tst)会更新 NZCV,普通 add/sub 不动标志。

A64 把移位与位段操作统一到 bitfield 机制上——lsl/lsr/asr/ror 全是 ubfm/sbfm/extr 的别名。

移位都是别名

  • lsl/lsr/asr/ror 实为 ubfm/sbfm/extr 的别名——A64 把移位统一到位域机制上。

位域抽取/插入

  • ubfx/sbfx 抽取位段(零/符号扩展);bfi/bfxil 插入位段;ubfiz/sbfiz
  • clz/cls 前导零/符号计数;rbit 位翻转;rev/rev16/rev32 字节翻转(改字节序)。
  • 逻辑 and/orr/eor/bic(and not)/orn/eon
ubfx x0, x1, #4, #8   // 取 x1[11:4] 这 8 位,零扩展到 x0
rev  w0, w0            // 32 位字节序翻转(大小端互转)
ubfx/sbfx 的两个立即数是「起始位, 位宽」而非「起始, 结束」——ubfx x0, x1, #4, #8 取的是 x1[11:4],宽度写错就取错位段。读无符号字段务必用 ubfx,用 sbfx 会把最高位当符号扩展。
抽取位段直接一条 ubfx(零扩展)或 sbfx(符号扩展),不必手写 shift+and;rev 一条完成整字节序翻转,做大小端互转很方便。

NZCV 四个标志位驱动条件后缀、条件选择(csel 系列)与条件比较(ccmp)。

NZCV 与条件分支

标志含义
N / Z负 / 零
C / V进位(无符号) / 溢出(有符号)

条件后缀:eq ne,无符号 hs(cs) lo(cc) hi ls,有符号 ge lt gt le,单标志 mi pl vs vc

免分支的条件指令(性能关键)

  • csel/csinc/csinv/csneg 条件选择;别名 cset/csetm/cinc/cinv/cneg
  • ccmp/ccmn 条件比较:不分支地把 &&/|| 串成一连串比较(编译 if(a&&b) 的利器)。
  • cbz/cbnz 为零/非零跳(免 cmp);tbz/tbnz 测单个位跳。
无符号比较用 hs/lo/hi/ls、有符号用 ge/lt/gt/le,两套用反会在负数或边界值上判错(把有符号 lt 误写成无符号 lo 是典型)。只有 adds/subs/cmp/cmn/tst 写标志,普通 add 不改 NZCV,紧跟的 b.cond 读到的还是旧标志。
AArch32 几乎每条指令都能条件执行;AArch64 取消了通用谓词执行,改为少量 csel 系列。这让编码更规整、分支预测更友好。

A64 各类分支的立即数范围不同,函数调用靠链接寄存器传返回地址。

分支与调用

  • b 无条件跳(±128MB);b.cond/cbz(±1MB)/tbz(±32KB) 范围较小。
  • bl 带链接跳转(返回地址→X30);blr/br 间接(寄存器目标);ret 默认跳 X30。
  • 尾调用直接用 b(不写 LR)。

超范围调用

  • 目标超出 bl 的 ±128MB 时,链接器插入 veneer/蹦床(借助 X16/X17)做中转。
bl 把返回地址写进 X30(LR) 而不是压栈,所以非叶函数在下一次 bl 前必须自己保存 LR,否则被覆盖、返回跑飞。各分支范围差别大(b ±128MB、b.cond/cbz ±1MB、tbz ±32KB),目标太远要靠链接器插 veneer 中转。
尾调用直接用 b target(不写 LR、不建栈帧);ret 默认从 X30 返回,目标在寄存器里的间接调用/跳转用 blr/br

AAPCS64 规定参数怎么传、返回值放哪、以及哪些寄存器由谁负责保存。

寄存器分工

寄存器角色
X0–X7整数/指针参数;X0(:X1) 返回值
X8间接返回地址 (大结构 sret)
X9–X15caller-saved 临时
X16/X17IP0/IP1(veneer 可破坏)
X18平台保留
X19–X28callee-saved
X29 / X30FP / LR
V0–V7 / V8–V15浮点参数 / 低 64 位 callee-saved

要点

  • 返回值整数 X0(128 位 X1:X0),浮点 V0。
  • HFA/HVA:全同类型的浮点/向量聚合(≤4 个)可整体走 V0–V7。
  • SP 须 16 字节对齐;超额参数走栈。
把 caller-saved(X0–X15)和 callee-saved(X19–X28)记反是经典 bug——活跃值放进 X9 跨一次 bl,被调用方合法地把它覆盖掉。SP 在公开边界必须 16 字节对齐,且 V8–V15 只有低 64 位是 callee-saved,高位不保证保留。
前 8 个整数/指针参数走 X0–X7、浮点走 V0–V7,返回值在 X0/V0;需要跨越 bl 存活的值放 callee-saved X19–X28(自己负责存恢复),纯临时值用 X9–X15

标准序言用 stp 保存 FP/LR 并把 X29 串成帧记录链表,PAC/BTI 则是现代的返回地址与间接分支防护。

序言/尾声与帧记录

  • 典型序言 stp x29,x30,[sp,#-N]! ; mov x29,sp:保存 FP/LR 并建立帧。
  • X29 形成帧记录链表,供调试器栈回溯。叶函数不调用别的函数,可不保存 LR、直接 ret

现代安全特性

  • PAC(指针认证)paciasp 在序言给 LR 加密签名,autiasp 在尾声校验——防 ROP 篡改返回地址。
  • BTI(分支目标识别)bti c 作为间接分支的合法落点。
my_func:
    paciasp                       // 签名 LR(PAC)
    stp x29, x30, [sp, #-16]!      // 存 FP/LR
    mov x29, sp
    // ... 函数体 ...
    ldp x29, x30, [sp], #16        // 恢复
    autiasp                       // 校验 LR
    ret
非叶函数必须保存 LR,否则内层 bl 会覆盖 X30,导致返回跳错地址。
paciasp/autiasp/bti c 都编码在 HINT(NOP) 空间(paciasp = hint #25),不支持的老核心当空操作跳过——加了它同一份二进制照样能在旧 CPU 上跑,无需为兼容单独构建。

NEON 是 AArch64 固定 128 位的 SIMD,V 寄存器按「排布」后缀被解释成不同的通道数与元素宽度。

寄存器视图

  • V0–V31(128 位)按排布解释:.16b/.8b.8h/.4h.4s/.2s.2d;标量视图 Bn/Hn/Sn/Dn/Qn,单通道 v0.s[2]

常用操作

  • 算术 add/mul/fmla(乘加)/fmlsabs/neg/smax/umin、比较 cmeq
  • 访存 ld1/st1结构化 ld2/ld3/ld4(自动解/交织,如 RGBA),ld1r 复制广播。
  • 归约 addv/smaxv,成对 addp;查表 tbl/tbx
ld1  {v0.4s}, [x0]         // 载入 4×int32
add  v2.4s, v0.4s, v1.4s  // 同时加 4 个
addv s3, v2.4s            // 横向求和归约
操作数后缀决定通道数和元素宽(.4s=4×32 位、.16b=16×8 位),运算两侧排布必须一致;写错后缀(如 .2s 只覆盖低 64 位半个寄存器)结果全错。NEON 宽度固定 128 位,想写宽度无关的可移植向量代码得用 SVE。
结构化访存 ld2/ld3/ld4 会在读入时自动解交织(如把 RGBA 拆成四条分离通道)、st2/st3/st4 写回时再交织,处理交错数据省去手工重排。

SVE 的向量长度到运行时才确定(128~2048 位、128 的倍数),靠谓词按通道掩码,让同一份代码适配任意实现宽度。

SVE 的机制:谓词驱动

  • Z0–Z31 长度运行时决定(128~2048 位,128 的倍数),谓词 P0–P15 做按通道掩码。
  • 核心手法是 whilelt p0.s, x0, x1:一条指令按「当前索引 < 上界」生成循环谓词,自动掩掉越界的尾部通道;配 incb/cntb 步进,全程无需知道向量宽度。(可伸缩向量的总述见 core 章 SIMD 卡。)

能力

  • 谓词化执行、聚集/散布 (gather/scatter)、首错加载 ffr(安全向量化含边界的循环)。
  • SVE2 把这套能力扩展到通用(多媒体/密码学)。
loop:
    ld1w  {z0.s}, p0/z, [x1, x2, lsl #2]
    add   z0.s, z0.s, z1.s
    st1w  {z0.s}, p0, [x3, x2, lsl #2]
    incw  x2
    whilelt p0.s, x2, x4     // 自动收尾,无需标量尾循环
    b.first loop
别把 SVE 当固定宽度写——向量长度未知,步进要用 incb/incw(按真实 VL)而非常量字节数。加载 ld1w {z0.s}, p0/z, .../z 是把非活跃通道清零、/m 才是保留原值,用错会读到脏数据;且这些指令需 +sve,在纯 v8.0 核上不存在。
一条 whilelt p0.s, x, x 按「索引 < 上界」生成循环谓词、自动掩掉越界的尾部通道,配 incw/incb 步进,就省掉了单独的标量收尾循环。

ARM 是弱内存序架构:硬件可大幅重排访存,需用获取/释放语义和屏障约束。

原子原语

  • LL/SCldxr/stxr 独占对,循环重试;获取/释放变体 ldaxr/stlxr
  • LSE 原子(ARMv8.1+):单指令 ldadd/ldset/ldclr/ldeor/swp/cas/casp,加序后缀 a/l/al——比 LL/SC 循环高效。
  • 获取/释放访存:ldar(acquire) / stlr(release)。

屏障

  • dmb(数据内存屏障,常用域 ish/ishst/ishld)、dsb(更强)、isb(指令同步,改 CSR/自修改代码后)。
// 原子自增(LSE)
mov  w1, #1
ldaddal w1, w2, [x0]      // *x0 += 1,全序,旧值→w2
ARM 是弱内存序,不加序约束硬件会重排,普通 ldr/str 之间没有顺序保证。LSE 原子需 ARMv8.1+(缺 +lse 会报 requires: lse),要兼容老核心仍得回退到 LL/SC;且 ldxrstxr 之间别夹访存或分支,否则独占监视器可能被清、stxr 永远失败。
能用 LSE 单指令原子(ldaddal/cas/swp)就别写 ldxr/stxr 重试循环,前者更短且无活锁风险;跨线程同步优先用 ldar/stlr 的获取/释放语义,通常不必手写 dmb

AArch64 Linux 通过 svc 陷入内核,异常级别 EL0–EL3 构成从用户态到安全监控的特权模型。

系统调用

  • 调用号入 x8,参数 x0–x5,执行 svc #0,返回值在 x0(错误为 -errno)。

异常级别(特权模型)

  • EL0 用户 / EL1 内核 / EL2 虚拟化 / EL3 安全监控。
  • svc/hvc/smc 分别陷入 EL1/EL2/EL3;eret 从异常返回;系统寄存器经 mrs/msr
// write(1, msg, 13); exit(0) · Linux/AArch64
mov x8, #64       // sys_write
mov x0, #1
adr x1, msg
mov x2, #13
svc #0
mov x8, #93       // sys_exit
mov x0, #0
svc #0
调用号入 X8 而非参数寄存器,放错寄存器就调错号;svc 的立即数 #0 不是调用号、几乎总为 0。系统调用号还随架构而异(AArch64 里 write=64、exit=93,与 x86-64 不同),别照抄 x86 的号。
系统调用号进 X8(不是 x86-64 的 rax),参数用 X0–X5svc #0 后返回值在 X0;失败时 X0-errno(负数),判负即出错。

按功能分类的 ARM64 常用指令一览,配合前面各卡当作速查索引使用(与 x86 章速查卡同构,方便对照)。

速查表

类别指令作用
传送mov / movz / movk / movn寄存器复制 / 分段装立即数
访存ldr str ldrb ldrsw ldp stp加载 / 存储 / 成对存取
取地址adr adrpPC 相对 / 页地址
算术add sub adds subs madd msub加减(s=置标志)/ 乘加减
乘除mul smulh umulh sdiv udiv乘 / 高位乘 / 除(无余数指令)
逻辑and orr eor mvn tst位运算 / 测试
移位lsl lsr asr ror移位(也可作操作数修饰)
位域ubfx sbfx bfi clz rbit rev位段抽取/插入 / 计数 / 反转
比较cmp cmn ccmp设 NZCV(不写结果)/ 条件比较
控制流b b.cond bl blr br ret跳转 / 调用 / 间接 / 返回
紧凑分支cbz cbnz tbz tbnz判零 / 判位,一条完成比较+跳
条件csel csinc cset cinc免分支的条件选择族
原子ldxr/stxr、ldaddal casal (LSE)独占对 / 单条原子 RMW
屏障dmb dsb isb内存 / 同步 / 指令屏障
系统svc #0陷入内核
ARM64 立即数不是想写多大就多大:add/sub 只收 12 位(可再左移 12),逻辑指令的立即数必须是「重复位模式」编码得出来的值——and x0, x0, #0x12345678 直接被拒(clang 报 expected compatible register or logical immediate)。装大常量用 movz/movk 分段拼或从字面量池加载,见本章「立即数与地址生成」卡。
寄存器名就是宽度开关:x0 是 64 位、w0 是同一寄存器的低 32 位——x86 靠指令后缀或操作数尺寸区分的事,ARM64 全写在寄存器名上,读起来更直白。

两段完整的 AArch64 程序,看清 ARM 汇编长什么样。Hello World 展示「数据段 + _start + svc 系统调用」的骨架;factorial 是叶函数,展示循环、条件分支与 AAPCS64 调用约定(参数进 w0、结果出 w0)。

怎么跑起来

  • 汇编 + 链接:aarch64-linux-gnu-as hi.s -o hi.o && ld hi.o -o hi(GAS 语法,注释用 //)。
  • 定长 4 字节指令、load-store 架构:运算只在寄存器间进行,访存单独用 ldr/str
// ===== Hello, World!  (Linux AArch64, GAS) =====
.data
msg:    .ascii "Hello, World!\n"
.set    len, . - msg               // . 为当前地址,相减得串长

.text
.global _start
_start:
    mov  x8, #64        // sys_write
    mov  x0, #1         // fd = 1 (stdout)
    adr  x1, msg        // buf = msg
    mov  x2, #len       // count = 串长
    svc  #0

    mov  x8, #93        // sys_exit
    mov  x0, #0         // 退出码 0
    svc  #0

// ===== int factorial(int n)  迭代阶乘 =====
// 入参 n 在 w0,返回值在 w0(AAPCS64)
factorial:
    mov  w1, w0         // n 挪到 w1
    mov  w0, #1         // result = 1
    cmp  w1, #1
    b.le .Ldone         // n <= 1 时直接返回 1
.Lloop:
    mul  w0, w0, w1     // result *= n
    sub  w1, w1, #1     // n--
    cmp  w1, #1
    b.gt .Lloop         // n > 1 继续循环
.Ldone:
    ret
_start 里必须用 exit 系统调用(x8=93)退出,不能 ret——没有调用者、X30 无效会崩。串长用 .set len, . - msg 让汇编器算,别手写死;adr x1, msg 只覆盖 ±1MB,数据段离代码太远时要换成 adrp+add
手边没有 ARM 机器也能跑:sudo apt install qemu-user gcc-aarch64-linux-gnu 之后按上面的命令交叉汇编,qemu-aarch64 ./hi 直接运行;qemu-aarch64 -g 1234 ./higdb-multiarch 还能单步观察。只想读不想跑,godbolt 选 ARM64 gcc 即可。

factorial 只碰寄存器;这两段走内存,展示 load-store 架构下循环 + 指针的写法。strlenldrb 逐字节读、cbz 判零(比较与分支合一);array_sum 把「×8」比例移位塞进 ldr 一条访存。参数进 x0/x1,结果出 x0

// ===== size_t strlen(const char *s)  求字符串长度 =====
// s 在 x0,返回长度到 x0
strlen:
    mov  x1, x0            // 记住起始指针
.next:
    ldrb w2, [x0]          // 取一字节(零扩展)
    cbz  w2, .done         // 为 0 则结束
    add  x0, x0, #1        // 指针 +1
    b    .next
.done:
    sub  x0, x0, x1        // 长度 = 当前 - 起始
    ret

// ===== long array_sum(const long *a, long n)  数组求和 =====
// a 在 x0,n 在 x1,返回和到 x0
array_sum:
    mov  x2, xzr           // sum = 0
    mov  x3, xzr           // i = 0
.sum:
    cmp  x3, x1
    b.ge .end              // i >= n 就结束
    ldr  x4, [x0, x3, lsl #3]  // x4 = a[i](比例 ×8 塞进 load)
    add  x2, x2, x4        // sum += a[i]
    add  x3, x3, #1        // i++
    b    .sum
.end:
    mov  x0, x2            // 返回 sum
    ret
ldr x4, [x0, x3, lsl #3]lsl #3 是按 8 字节元素缩放,换成 int 数组必须改 lsl #2,否则跨步错乱、越界读。ldrb 读一字节是零扩展进整个寄存器(高位被清),别以为只写低 8 位;循环里 x1 是元素个数 n、不是字节数。
ldr x4, [x0, x3, lsl #3]lsl #3 就是元素大小 8(2³);int 数组用 lsl #2cbz/b.ge 体现 ARM 把常见比较直接编进分支。

上手章的第二个程序(把命令行参数各打一行)值得在每个架构上重写一遍:骨架不变——栈上取 argc/argv、外层遍历指针数组、内层扫长度、两次 write——变的全是本架构的性格。右侧是 ARM64 版,qemu 跑通;逐段和 x86 版对照,比背对照表牢固得多。

与 x86 版逐点对照

做什么x86-64 版ARM64 版
取 argcmov r12, [rsp]ldr x19, [sp](sp 不能当通用寄存器乱用,但作基址访存没问题)
逐字节看cmp byte ptr [rsi+rdx], 0——访存 + 比较一条完成ldrb w3, [x1, x2] 先取进寄存器,再 cbz——load-store 架构不许运算碰内存
判 0 跳转cmp 设标志 + jecbz 一条搞定:判零并跳,不经过标志位
取数据地址lea rsi, [rip + nl] 一条adrp 页基址 + add :lo12: 页内偏移,两条拼(本章立即数与地址卡)
系统调用号进 rax,syscall;write=1号进 x8svc #0write=64、exit=93——号表和 x86 不同!

跑起来(交叉工具链,)

$ aarch64-linux-gnu-as args.s -o args.o
$ aarch64-linux-gnu-ld args.o -o args
$ qemu-aarch64 ./args hello world
./args
hello
world

工具链与 qemu 的安装命令在本章「完整程序示例」卡的 tip;qemu-aarch64 -g 1234gdb-multiarch 可单步。

本架构的加练

  • 内层扫描改用后索引ldrb w3, [x4], #1——读完指针自动 +1,循环体从「取、判、加、跳」四行缩成「取、判跳」两行。但注意 sub x2, x4, x1 得到的是长度 + 1:post-index 是「先用旧地址、再改指针」,读到终止符那一次也把指针步进了,得再 sub x2, x2, #1 修正(漏了这条会把结尾的 0 字节一起 write 出去,终端上肉眼看不出、od -c 才现形)。这个 off-by-one 正是 post-index 语义最好的教学现场。
  • 上手章「第二个程序」卡的四个练习在这里原样成立;做「打印 argc」时会遇到除法——ARM64 没有 x86 那套隐式寄存器纠葛,udiv 求商、msub 一条算回余数。
// args.s —— 把每个命令行参数各打一行(AArch64 Linux)
.globl _start

.section .rodata
nl:     .ascii "\n"

.section .text
_start:
    ldr  x19, [sp]           // argc
    add  x20, sp, #8         // &argv[0]

next:
    cbz  x19, done           // 参数打完了吗?(判零并跳,一条)
    ldr  x1, [x20]           // x1 = 当前参数字符串
    mov  x2, #0              // x2 = 长度
1:  ldrb w3, [x1, x2]        // load-store:先把字节取进寄存器
    cbz  w3, 2f
    add  x2, x2, #1
    b    1b
2:  mov  x0, #1              // write(1, x1, x2)
    mov  x8, #64             // sys_write(ARM64 号表)
    svc  #0
    mov  x0, #1              // write(1, nl, 1)
    adrp x1, nl              // 页基址…
    add  x1, x1, :lo12:nl    // …加页内偏移
    mov  x2, #1
    mov  x8, #64
    svc  #0
    add  x20, x20, #8        // 下一个指针
    sub  x19, x19, #1
    b    next

done:
    mov  x0, #0
    mov  x8, #93             // sys_exit
    svc  #0
系统调用号不跨架构:x86-64 的 write=1/exit=60,ARM64 是 write=64/exit=93(它和 RISC-V 同用新的 generic 号表,x86-64 背的是历史老表)。把 x86 的号硬带过来,svc 执行的是完全不同的调用——轻则返回 -ENOSYS/参数不合法报错,重则语义整个错位还不报错。号表以 asm-generic/unistd.h(arm64/riscv 共用)为准。
本页三家在 Linux 上共同的约定:内核只改写返回值寄存器(这里是 x0),其余全部保留——x86-64 额外毁掉 rcx/r11 是 syscall 指令借它们保存返回现场的机制使然,不是通例。所以这一版连「跨 syscall 保值」都不用刻意安排。

RISC-V (RV64) 深入

开源、模块化、极致精简的 RISC。无标志位、无专用栈指令、靠伪指令补足便利。本章覆盖寄存器与 ABI、六种编码格式、扩展体系、整数/乘除/原子/浮点/向量指令、压缩指令、立即数与地址、控制转移、CSR 与特权架构、调用约定、内存模型与系统调用,末尾把上手章的 argv 程序搬过来动手重写。以 64 位 RV64 为准。

32 个通用寄存器 x0–x31。汇编里几乎总用 ABI 别名(a0、sp、ra),更易读。

寄存器表

编号ABI角色保存方
x0zero恒为 0(读0写弃)
x1ra返回地址caller
x2sp栈指针callee
x3 / x4gp / tp全局 / 线程指针不适用 *
x5–x7t0–t2临时caller
x8s0 / fp保存 / 帧指针callee
x9s1保存callee
x10–x17a0–a7参数 (a0,a1 兼返回值)caller
x18–x27s2–s11保存callee
x28–x31t3–t6临时caller

gp / tp 为何标「不适用」

  • 它们不属于 caller/callee 保存范畴:gp(全局指针)在程序启动时、tp(线程指针)在线程创建时由运行时一次设定,之后普通函数视其为不可变的全局常量,既不传参也不覆盖,自然无所谓谁保存。

浮点寄存器

  • f0–f31(ABI 名 ft0–11 / fs0–11 / fa0–7),需 F/D 扩展。控制状态在 fcsr
  • 没有标志寄存器pc 独立。
x0(zero) 恒为 0,对它的写入被硬件静默丢弃——想拿它当临时寄存器暂存、或把计算结果误存进 x0 都不会报错,只会悄悄丢数据。
记 caller/callee 分界的钩子:t*(临时) 与 a*(参数) 是 caller-saved,s*(saved) 与 sp 是 callee-saved——字母 s 就是「saved by callee」。s0 同时是帧指针 fp

基础指令定长 32 位,只有 6 种格式,字段位置高度固定。

格式

格式用途示例
R寄存器-寄存器add, sub, sll
I立即数 / 加载 / jalraddi, lw, jalr
S存储sw, sd, sb
B条件分支beq, bne, blt
U高 20 位立即数lui, auipc
J跳转jal

立即数为何「打乱重排」

  • B/J 格式的立即数比特看似乱序,实为刻意:让 rs1/rs2/funct3 等字段在所有格式里位置不变,且立即数符号位永远在第 31 位——硬件可少用多路选择器、符号扩展更省。
  • 所有立即数都符号扩展
数值与偏移类立即数(addi/lw/分支/lui/jal)一律符号扩展;移位量 shamt 与 CSR 立即数是无符号,属例外。想构造无符号大常量不能指望 addi 的立即数被零扩展,得自己用 lui/移位拼。
不用背 B/J 格式那套错位的立即数位序——汇编器和反汇编器替你拼。真正要记的只有一条:rs1/rs2/rd 字段在所有格式里位置固定。

RISC-V = 「一个精简基础 + 一组可选扩展」,芯片按需组合,名字直接拼出来。

基础与扩展

记号含义
RV32I / RV64I32/64 位整数基础(RV32E 为 16 寄存器嵌入版)
M / A乘除 / 原子
F / D / Q单 / 双 / 四精度浮点
C压缩指令(16 位)
B位操作(Zba+Zbb+Zbs)
V向量
Zicsr / ZifenceiCSR 访问 / 取指屏障
G= IMAFD + Zicsr + Zifencei

命名

  • RV64GC = 64 位 + 通用 + 压缩(Linux 应用处理器常见);RV32IMAC(嵌入式常见)。
  • 应用类还有 RVA22/RVA23 等 profile 规定必备扩展集。
扩展是可选的:为 RV64GC 编出的带浮点/压缩指令的二进制,放到只有 RV64IMAC(无 F/D)的核上会触发非法指令异常。用 -march 明确目标,别假设 F/D/V 都在。
读 ISA 字符串就能知道芯片能力:RV64GC 拆开就是 RV64 + G(IMAFD+Zicsr+Zifencei) + C。目标平台上查 /proc/cpuinfoisa 行即可确认实际扩展。

基础整数指令集 RV32I/RV64I 只有几十条:算术、逻辑、移位、访存、比较,全部围绕寄存器展开,是其余一切扩展的地基。

运算

  • 上位常量 lui(高 20 位)、auipc(PC+高 20 位)。
  • 寄存器-寄存器 add sub and or xor sll srl sra slt sltu;带立即数加 i 后缀 addi andi … slti sltiu无 subi,用 addi 负数)。
  • 访存 lb/lh/lw/ld(+u 无符号)、sb/sh/sw/sd

RV64 的 32 位字运算

  • 对 C 的 int 要用带 w 后缀addw/subw/sllw/sraw/addiw:只算低 32 位并符号扩展回 64 位。
  • int 当 64 位用 add 是常见错误。
addi a0, a0, 5      # a0 += 5
addw a0, a1, a2     # 32 位加,结果符号扩展到 64
slt  a0, a1, a2     # a0 = (a1 < a2) ? 1 : 0(有符号)
RV64 上处理 C 的 int(32 位)必须用带 w 后缀的 addw/subw/sllw/addiw:只算低 32 位再符号扩展回 64 位。误用 64 位的 add/sll 会在溢出或移位时留下错误的高 32 位。
没有 subi——立即数减法直接用 addi 加负数(addi a0, a0, -5);这也是「基础集尽量精简、能省则省」思路的体现。

12 位有符号立即数范围 −2048~2047,更大的常量/地址要拼。

常量与 PC 相对

  • 32 位常量:lui(高 20) + addi(低 12)。因为 addi 把低 12 位符号扩展,当低位最高位为 1 时需对高位 +1 修正——汇编器的 %hi/%lo 重定位会自动处理。
  • 位置无关:auipc(PC+高 20) + addi/ld;访问全局经 GOT 用 auipc + ld
  • 伪指令 li(任意立即数) / la(取地址) 自动展开成上述序列——它们和后文的 mv/ret/j 一样都是伪指令(汇编器展开、非真实指令),完整清单见本章「伪指令」卡。
li   a0, 0x12345678   # → lui + addi(汇编器展开)
la   a1, msg          # → auipc + addi,PC 相对取地址
li 绝不总是 lui+addi 两条,且展开序列本身因汇编器而异li a0, 0xdeadbeef(bit31 置位)在 RV64 下 llvm-mc 展开成 lui+slli+addi 三条,GNU as(binutils 2.46)却是 lui+addiw+slli+addi 四条li a0, 2048 llvm-mc 给 li+slli(带移位)、GNU as 给 lui+addiw(不带)。加载完整 64 位常量最多要八条(交替位模式的最坏情况,llvm-mc)。按「两条」估代码大小或指令数会错——要知道确切几条,用上面 tip 的方法喂给你实际用的那个汇编器看。
拿不准某个 li/la 到底展开成几条,直接喂给汇编器看:printf 'li a0,大数\n' | llvm-mc --triple=riscv64 会打印真实展开,别凭「li=两条」估算。

RISC-V 只有两条跳转指令(jal/jalr)加六条分支,没有标志位、没有延迟槽,比较与跳转被合成到同一条指令里。

只有两条跳转

  • jal rd, off(J,±1MB):跳转并把返回地址存 rd(通常 ra)。
  • jalr rd, rs1, off(I):寄存器间接跳转——实现 ret、函数指针、超远调用。

条件分支(无标志、直接比较两寄存器)

  • beq bne blt bge bltu bgeu(B,±4KB)。
  • 无分支延迟槽(不同于 MIPS)。
  • j=jal x0ret=jalr x0,ra,0call=auipc+jalrbgt=交换操作数的 blt(伪指令)。
blt  a0, a1, less   # if(a0 < a1) goto less
call func           # auipc ra,…; jalr ra,…
ret                 # jalr x0, ra, 0
分支/跳转范围有限:beq 等 B 型只有 ±4KB,jal 只有 ±1MB。但超程汇编器不报错:条件分支被静默松弛成反转分支+长跳(见 core 章链接卡);jal 超 ±1MB 也静默通过,到链接期才由 ld 报 relocation truncated to fit: R_RISCV_JAL——那时得改用 callauipc+jalr)这类能覆盖全地址空间的序列。
无条件跳转/返回/调用全是 jal/jalr 的伪指令包装——ret 就是 jalr x0, ra, 0j 就是 jal x0callauipc+jalr。读反汇编时对应回去就不会迷路。

RISC-V 的便利性大量来自伪指令——汇编器展开成真实指令,让基础指令集保持极简。

常见伪指令

伪指令展开含义
li rd, immlui + addi加载立即数
la rd, symauipc + addi取地址
mv rd, rsaddi rd, rs, 0寄存器复制
nopaddi x0, x0, 0空操作
not / negxori,-1 / sub x0取反 / 取负
j / ret / calljal x0 / jalr / auipc+jalr跳 / 返回 / 调用
beqz / bnezbeq/bne …, x0与零比较跳
seqz / snezsltiu / sltu是否为零置位

大量伪指令都借助 x0 (zero)——这正是零寄存器存在的意义。

表里的展开是「典型」而非「唯一」:li 视立即数可展开成 1~6 条(见「立即数」卡),call/tail 还会插入重定位。写汇编时别假设「一条伪指令 = 一条机器指令」,指令计数与 PC 偏移要以真实展开为准。
几乎每条伪指令都借 x0nop=addi x0,x0,0j=jal x0beqz=beq …,x0mv=addi rd,rs,0——看懂这层就能把反汇编还原成人读得懂的形式,也印证了零寄存器的价值。

M 扩展补上乘法与除法:低位积、高位积、商、余数各有专用指令,RV64 还提供只算 32 位并符号扩展的字版本。

指令

  • 乘:mul(低 64)、mulh/mulhu/mulhsu(高 64,组合得 128 位积)。
  • 除:div/divu(商)、rem/remu(余)。RV64 字版 mulw/divw/divuw/remw/remuw

边界语义(不陷阱、结果有定义)

  • 除以 0:商为全 1(-1),余为被除数(不抛异常,软件自查)。
  • 有符号溢出(MIN / -1):商为 MIN、余为 0。
mul  a0, a1, a2    # 低 64 位积
mulh a3, a1, a2    # 高 64 位(有符号)→ 128 位结果
除零和 MIN/-1 溢出不抛异常也不陷入div 除零返回 -1rem 返回被除数、溢出商为 MIN。这与 x86 div 触发 #DE 异常正相反,除数为零必须靠软件自己先判。
要 128 位全宽积得配对用 mul(取低 64)+ mulh/mulhu/mulhsu(取高 64)——按两个操作数的符号性选对 mulh 变体,混用会得到错误的高位。

A 扩展提供原子读-改-写:一套 LR/SC(保留加载 + 条件存储)用来构造 CAS,一套 AMO 单条指令完成常见原子运算。

两套机制

  • LR/SClr.w/lr.d(load-reserved) + sc.w/sc.d(store-conditional),sc 成功返回 0、失败非 0,循环重试构造 CAS。
  • AMOamoswap/amoadd/amoand/amoor/amoxor/amomin/amomax(+u),单指令原子读-改-写。

内存序后缀

  • .aq(acquire) / .rl(release) / .aqrl(顺序一致),控制与周围访存的可见性。
# 原子自增
li      t0, 1
amoadd.w.aqrl zero, t0, (a0)  # *a0 += 1

# CAS 自旋(LR/SC)
retry:
lr.w    t1, (a0)
bne     t1, a1, fail
sc.w    t2, a2, (a0)
bnez    t2, retry
LR/SC 之间只能放极少量指令、不能夹带内存访问或过多分支,否则保留会被清除、sc 永远失败甚至活锁。此外裸 AMO 是宽松序,需要获取/释放语义时别漏加 .aq/.rl 后缀。
sc(store-conditional)成功返回 0、失败返回非 0,与「0 为假」的直觉相反——CAS 自旋要用 bnez 判失败后重试。

F/D 扩展带来独立的 f0–f31 浮点寄存器与单/双精度运算;比较结果仍写回整数寄存器,延续「无标志」哲学。

运算

  • 独立浮点寄存器 f0–f31fadd/fsub/fmul/fdiv/fsqrt(.s/.d),融合乘加 fmadd/fmsub/fnmadd/fnmsub
  • fcvt(int↔float、s↔d 转换)、fmv(按位搬)、fsgnj/fmin/fmax、分类 fclass

比较与状态(仍无分支标志)

  • feq/flt/fle 把 0/1 写进整数寄存器,再用普通分支——与「无标志」哲学一致。
  • 舍入模式由指令 rm 域或 frm CSR 指定(rne/rtz/rdn/rup/rmm);异常累积在 fflags。窄值在宽寄存器里以 NaN-boxing 存放。
fadd.d fa0, fa1, fa2
flt.s  a0, fa1, fa2    # a0 = (fa1 < fa2),结果进整数寄存器
窄值放进宽浮点寄存器用 NaN-boxing(高位填 1):把一个 .s 单精度值直接当 .d 双精度用(或反之)不会自动换算,会读到 NaN 或垃圾。跨精度必须显式 fcvt.d.s/fcvt.s.d
浮点比较 feq/flt/fle 把 0/1 结果写进整数寄存器,再接普通整数分支——没有独立的浮点条件码,和整数世界一样直接比较。

RVV(RISC-V Vector,1.0 已冻结)与 ARM SVE 一样是长度无关 (VLA) 的:同一份二进制在不同向量宽度的实现上都能跑,无需为宽度重编译。

寄存器与配置

  • v0–v31 向量寄存器,物理长度 VLEN 由实现决定;v0 兼作掩码寄存器。
  • vtypevsetvli 设定元素宽度 e8/e16/e32/e64 与寄存器组合 LMULm1…m8 把多个寄存器连成更长的逻辑向量,或 mf2… 分数化)。
  • vl:本次实际处理的元素数,由 vsetvli 按「剩余元素数」自动取 min(剩余, VLMAX)

strip-mining(免标量尾循环)

  • 每轮用 vsetvli 领取一段、处理、把剩余数减去 vl,循环到 0——不需要标量收尾循环,与 SVE 的 whilelt 异曲同工。
  • 访存 vle32.v/vse32.v(单位步长)、vlse(跨步)、vluxei(索引 gather/scatter);运算 vadd.vv/.vx/.vi、浮点乘加 vfmacc、归约 vredsum;掩码执行加 , v0.t
# z[i] = x[i] + y[i],元素数 a2,无需知道向量宽度
loop:
    vsetvli t0, a2, e32, m1   # t0 = min(a2, VLMAX),按 32 位元素配置
    vle32.v v0, (a0)          # 载入一段 x
    vle32.v v1, (a1)          # 载入一段 y
    vadd.vv v2, v0, v1        # 逐元素相加
    vse32.v v2, (a3)          # 存回 z
    slli    t1, t0, 2          # 字节步进 = vl * 4
    add     a0, a0, t1
    add     a1, a1, t1
    add     a3, a3, t1
    sub     a2, a2, t0         # 剩余元素数 -= vl
    bnez    a2, loop           # 还有剩余就继续
VLA 代码不能假设 VLEN/vl 的具体值——手写常量步进(如「每轮固定 4 个元素」)会在别的向量宽度实现上算错,步进必须用 vsetvli 返回的实际 vl。另外 v0 兼作掩码寄存器,用掩码时别把数据占用到 v0
RVV 与 SVE 是同一思路的两种落地:SVE 用 whilelt 生成谓词掩掉尾部,RVV 用 vsetvli 每轮领取一段。可伸缩向量的总述见 core 章 SIMD 卡与对比章。

C 扩展把高频指令编成 16 位,与 32 位指令自由交织,用更小的代码体积换取更好的取指带宽和 I-cache 命中。

16 位编码

  • 把高频指令编成 16 位:c.addi / c.li / c.lw / c.sw / c.mv / c.jr / c.beqz / c.add…,每条 1:1 映射到一条 32 位指令。
  • 部分压缩形式只能用受限寄存器集(x8–x15)或受限立即数范围。

效果

  • 代码体积通常省 ~25–30%,对取指带宽与 I-cache 友好;可与 32 位指令自由交织。
  • 启用 C 后指令对齐降为 2 字节jal 等的对齐假设随之变化。
压缩形式限制多:多数 c.* 只能用 x8–x15 这 8 个寄存器、立即数范围也窄;且启用 C 后指令对齐降到 2 字节,任何「指令必然 4 字节对齐」的手写假设(跳转目标计算、代码扫描)都会失效。
不用手写 c.*——汇编器会自动把符合条件的普通指令压成 16 位,只要开 -march=…c。反汇编里看到 c. 前缀就是被压缩过的那条。

控制状态寄存器(CSR)与 M/S/U 特权级构成系统编程接口:陷入、中断、分页、计时全靠读写 CSR。

CSR 访问(Zicsr)

  • csrrw/csrrs/csrrc(+i 立即数版) 读改写控制状态寄存器;伪指令 csrr/csrw

特权级与陷入

  • 模式:M(机器) / S(监管) / U(用户)(外加 H 虚拟化)。
  • 关键 CSR:mstatus/sstatus、陷入向量 mtvec/stvecmepc/sepcmcause/mtval、中断 mie/mip、分页 satp
  • ecall(环境调用) / ebreak(断点) 触发陷入,mret/sret 返回,wfi 等待中断。
  • 「无标志」的延伸:处理器状态都显式放在寄存器/CSR 里,没有隐藏的全局标志。
CSR 访问受特权级保护:在 U 态触碰 M/S 态 CSR(或未实现的 CSR)会触发非法指令异常。用户态汇编基本只能读少数计数器(cycle/time/instret),系统 CSR 必须在对应特权级下操作。
单条 CSR 指令能一次「读旧值 + 写新值」:csrrw rd, csr, rs 原子交换,置位/清位用 csrrs/csrrc;只读或只写场景用伪指令 csrr/csrw 更清晰。

RISC-V 调用约定(LP64/LP64D)规定整数参数走 a0–a7、返回值走 a0(:a1),并把寄存器分成 caller/callee 两类保存。

约定

  • 整数参数 a0–a7、浮点参数 fa0–fa7;返回值 a0(:a1) / fa0(:fa1)
  • caller-saved:ra, t0–t6, a0–a7;callee-saved:sp, s0–s11, fs0–fs11。SP 须 16 字节对齐、向下增长。
  • 与 ARM 同理,非叶函数必须在序言把 ra 压栈(见 core 章控制流卡)——RISC-V 无 PAC,就是朴素的 sd ra。叶函数可省。
my_func:
    addi sp, sp, -16     # 开栈帧
    sd   ra, 8(sp)      # 存返回地址(非叶必须)
    sd   s0, 0(sp)      # 存 callee-saved
    # ... 函数体 ...
    ld   ra, 8(sp)
    ld   s0, 0(sp)
    addi sp, sp, 16
    ret
两个高频坑:非叶函数忘了在序言 sd ra,一旦内部再调用就覆盖返回地址、ret 跑飞;以及 sp 必须保持 16 字节对齐并向下增长,随手 addi sp, sp, -12 会破坏对齐、坑到被调用方。
记忆钩子:t*(temp) 和 a*(arg) 是 caller-saved(被调用者可随意覆盖),s*(saved) 和 sp 是 callee-saved(用了就得先存后恢复)。

RISC-V 采用弱内存序模型 RVWMO,跨线程可见性靠显式 fence 约束;系统调用则通过 ecall 陷入内核。

RVWMO(弱内存序)

  • 默认弱序,用 fence pred, succ 约束(如 fence rw, rw 全屏障,fence.tso 近 x86 强序)。
  • 原子上的 .aq/.rl 提供获取/释放语义。
  • fence.i 在自修改代码后同步取指;sfence.vma 刷新 TLB。

系统调用

  • 调用号 a7,参数 a0–a5,执行 ecall,返回值 a0。Linux 下与 ARM64 共用通用 syscall 号(write=64, exit=93)。
# write(1, msg, 13); exit(0) · Linux/RISC-V
li   a7, 64       # sys_write
li   a0, 1
la   a1, msg
li   a2, 13
ecall
li   a7, 93       # sys_exit
li   a0, 0
ecall
默认弱序,多核共享数据不能靠「源码顺序」保证可见——要 fence rw, rw 或带 .aq/.rl 的原子。另外自修改/JIT 生成代码后必须 fence.i 才能取到新指令,否则可能执行旧的 I-cache 内容。
系统调用约定:调用号放 a7、参数放 a0–a5、执行 ecall、返回值回 a0——RISC-V 与 ARM64 共用同一套 generic syscall 号(write=64、exit=93)。

按功能分类的 RISC-V 常用指令一览(RV64GC 口径),配合前面各卡当作速查索引使用(与 x86 / ARM64 章速查卡同构,方便对照)。

速查表

类别指令作用
传送(伪)mv li la复制 / 装立即数 / 取地址(汇编器展开)
访存lb lh lw ld / lbu lhu lwu / sb sh sw sd按宽度加载(u=零扩展)/ 存储
取地址lui auipc高 20 位立即数 / PC+高 20 位
算术add addi sub addw subw加减(i=立即数,w=32 位截断)
比较置位slt slti sltu sltiu小于则置 1(无标志位的替代品)
乘除 (M)mul mulh div rem divu remu乘 / 高位乘 / 除 / 取余
逻辑and or xor andi ori xori位运算
移位sll srl sra slli srli srai逻辑左/右移、算术右移
分支beq bne blt bge bltu bgeu一条完成「比较+跳转」
跳转jal jalr、j / call / ret(伪)带链接跳转 / 间接 / 常用伪指令
原子 (A)lr.w/d sc.w/d、amoadd amoswap…LR/SC 对 / 单条原子 RMW
浮点 (F/D)flw fld fsw fsd、fadd.d fmul.d、fcvt.*浮点访存 / 运算 / 转换
CSR (Zicsr)csrr csrw csrrw csrrs(前两者为伪)读写控制状态寄存器
屏障fence fence.i内存序 / 指令流同步
系统ecall ebreak陷入内核 / 断点
I/S/B 型立即数只有 12 位有符号(−2048~2047)addilw 的偏移超了就得先 luili 拼进寄存器再用。另外没有 subi——写 addi rd, rs, -n;没有独立 cmp——比较并入分支或用 slt 族。从 x86/ARM 带来的「这条指令应该存在」直觉,在 RISC-V 上要先过一遍「基础集真有吗」的怀疑。
读反汇编时想知道某条是不是伪指令展开的产物,给 objdump 加 -M no-aliasesmv/li/j/ret 全部还原成 addi/jalr 真身——速查表里标了「伪」的行,用这招一验便知。

两段完整的 RV64 程序,看清 RISC-V 汇编长什么样。Hello World 展示「数据段 + _start + ecall」的骨架;factorial 是叶函数,展示循环、条件分支与调用约定(参数进 a0、结果出 a0)。注意 RISC-V 没有标志位,比较与分支被合成到一条指令里。

怎么跑起来

  • 汇编 + 链接:riscv64-linux-gnu-as hi.s -o hi.o && ld hi.o -o hi(GAS 语法,注释用 #)。
  • li / la / mv / ret 都是伪指令,汇编器会展开成真实指令——这正是 RISC-V 靠伪指令补足书写便利的体现。
# ===== Hello, World!  (Linux RV64, GAS) =====
.data
msg:    .string "Hello, World!\n"   # .string 自动补结尾 \0

.text
.globl _start
_start:
    li   a7, 64         # sys_write
    li   a0, 1          # fd = 1 (stdout)
    la   a1, msg        # buf = msg
    li   a2, 14         # count
    ecall

    li   a7, 93         # sys_exit
    li   a0, 0          # 退出码 0
    ecall

# ===== int factorial(int n)  迭代阶乘 =====
# 入参 n 在 a0,返回值在 a0
factorial:
    li   t0, 1          # result = 1
    li   t1, 1          # 常数 1,供比较用
    ble  a0, t1, done   # n <= 1 直接返回
loop:
    mul  t0, t0, a0     # result *= n
    addi a0, a0, -1     # n--
    bgt  a0, t1, loop   # n > 1 继续循环
done:
    mv   a0, t0         # 返回值 → a0
    ret
write 的长度参数(例中 li a2, 14)必须和字符串真实字节数对上——"Hello, World!\n" 恰好 14;数错会截断输出或读越界。.string 自动补的 \0 不计入 write 长度;用 _start 而非 main 时还得自己 ecall 退出,否则返回到无效地址会崩。
不必等 RISC-V 硬件:sudo apt install qemu-user gcc-riscv64-linux-gnuqemu-riscv64 ./hi 直接跑,配 -g 1234 + gdb-multiarch 可单步。想更进一步,写一个自己的 RV32I 模拟器只要几百行——见末章路线图。

factorial 只碰寄存器;这两段走内存。RISC-V 最纯粹:没有变址寻址,取 a[i]显式三步——slli 算偏移 → add 加基址 → ld 访存。strlenlbu 读字节、beqz 判零。参数进 a0/a1,结果出 a0

# ===== size_t strlen(const char *s)  求字符串长度 =====
# s 在 a0,返回长度到 a0
strlen:
    mv   t0, a0           # 记住起始指针
.next:
    lbu  t1, 0(a0)        # 取一字节(无符号)
    beqz t1, .done        # 为 0 则结束
    addi a0, a0, 1        # 指针 +1
    j    .next
.done:
    sub  a0, a0, t0       # 长度 = 当前 - 起始
    ret

# ===== long array_sum(const long *a, long n)  数组求和 =====
# a 在 a0,n 在 a1,返回和到 a0
array_sum:
    li   t0, 0            # sum = 0
    li   t1, 0            # i = 0
.sum:
    bge  t1, a1, .end     # i >= n 就结束
    slli t2, t1, 3        # t2 = i*8(显式算偏移)
    add  t3, a0, t2       # &a[i] = 基址 + 偏移
    ld   t4, 0(t3)        # t4 = a[i]
    add  t0, t0, t4       # sum += a[i]
    addi t1, t1, 1        # i++
    j    .sum
.end:
    mv   a0, t0           # 返回 sum
    ret
RISC-V 没有变址寻址,取 a[i] 必须显式「slli 算偏移 + add 加基址」——移位量要与元素大小对齐(long 8 字节用 slli …, 3int 用 2)。照搬 strlen 的字节步进(+1)到宽元素数组会逐字节乱走、读到错位数据。
对比 x86 一条 mov rax, [rdi+rcx*8]、ARM 一条 ldr:同样取一个数组元素,RISC-V 用了 slli+add+ld 三条——这正是 07 章「同一段 C,三种汇编」卡例二指令数 1:1:3 的由来。

同一个程序的第三副面孔。RISC-V 版最能暴露「极简」的代价与味道:没有变址寻址、没有标志位、取地址和装 32 位常量起步就是两条拼(更大的还得加码,见本章 li/la 卡)——但每一行都直白到能对着六种编码格式口算机器码。qemu 跑通。

与 x86 版逐点对照

做什么x86-64 版RISC-V 版
逐字节看cmp byte ptr [rsi+rdx], 0 一条add t0, a1, a2 + lbu t1, 0(t0):基础指令集没有 reg+reg 寻址,地址自己加——01 章寻址模式卡 pitfall 的现场兑现
判 0 跳转cmp + je(走标志位)beqz t1, 2f:无标志位,比较进分支;beqz 是 beq t1, x0 的伪指令——零寄存器的日常用法
取数据地址lea rsi, [rip + nl]la a1, nl,汇编器展开成两条(objdump auipc a1,0x0 + addi a1,a1,44——偏移值依本程序布局而定,改过代码数字就会变)
系统调用号进 rax,syscall;write=1号进 a7ecallwrite=64、exit=93——与 ARM64 同用 generic 号表

跑起来(交叉工具链,)

$ riscv64-linux-gnu-as args.s -o args.o
$ riscv64-linux-gnu-ld args.o -o args
$ qemu-riscv64 ./args hello world
./args
hello
world

工具链与 qemu 的安装命令在本章「完整程序示例」卡的 tip。

本架构的加练

  • 数指令:内层扫描 RV 用 5 条(add/lbu/beqz/addi/j)、x86 用 4 条(cmp/je/inc/jmp)——把这「多一条」讲给自己听:变址寻址省的就是那条 add。再想一层:多一条不等于更慢,微架构里 x86 那条复合指令同样拆 μop(01 章主线卡的老话)。
  • 上手章「第二个程序」卡的四个练习原样成立;做「打印 argc」正好用上 M 扩展:divu 求商、remu 求余,各一条、没有隐式寄存器。
# args.s —— 把每个命令行参数各打一行(RV64 Linux)
.globl _start

.section .rodata
nl:     .ascii "\n"

.section .text
_start:
    ld   s1, 0(sp)           # argc
    addi s2, sp, 8           # &argv[0]

next:
    beqz s1, done            # 参数打完了吗?(无标志位:判零即跳)
    ld   a1, 0(s2)           # a1 = 当前参数字符串
    li   a2, 0               # a2 = 长度
1:  add  t0, a1, a2          # 没有变址寻址:地址自己加
    lbu  t1, 0(t0)
    beqz t1, 2f
    addi a2, a2, 1
    j    1b
2:  li   a0, 1               # write(1, a1, a2)
    li   a7, 64              # sys_write(generic 号表)
    ecall
    li   a0, 1               # write(1, nl, 1)
    la   a1, nl              # 展开成 auipc + addi
    li   a2, 1
    li   a7, 64
    ecall
    addi s2, s2, 8           # 下一个指针
    addi s1, s1, -1
    j    next

done:
    li   a0, 0
    li   a7, 93              # sys_exit
    ecall
这段代码用 s1/s2 当计数器,在裸 _start 里没问题——没有调用者,callee-saved 的义务无从谈起。但若把它改造成被 C 调用的函数(上手章混编卡的路线),s 寄存器必须先存后用、用完恢复,否则悄悄毁掉调用者的值——调用约定卡的规矩从「进了函数」那一刻起立刻生效。
x86 版(上手章)、ARM64 版(04 章)、这一版并排打开,就是 06 章对照表的可执行版——每一行差异都能跑给你看。内核这边同样只改写返回值寄存器 a0,其余全保留。

写汇编:数据、标签与混合编程

上手章跑通的是一段现成代码。这一章把「自己写」的地基铺上:汇编器提供的数据与标签设施、一个真正读输入的程序、与 C 双向调用的混合编程、嵌进 C 的内联汇编。它排在三个架构章之后是有原因的——混合编程绕不开寄存器分工与调用约定,得先认得一种架构,才谈得上把自己的代码和别人的接上。示例统一用 x86-64 GAS,ARM64/RISC-V 的对应写法见各自章的「完整程序示例」卡。

hello.s 只用到了 .ascii 一个数据指令。要写更大的程序,得把汇编器给你的「脚手架」认全:数据怎么定义、缓冲区放哪、标签怎么起名。这套设施属于 GAS(GNU 汇编器),三个架构通用。

数据定义全家

指令占多大说明
.byte 0x411 字节单字节
.word / .long / .quad2 / 4 / 8 字节(x86)整数
.ascii "hi"按内容字符串,补结尾 0
.asciz "hi"(= .string内容 + 1自动补结尾 0
.skip 64(= .zero64 字节填 0 的空间
LEN = 3(= .equ LEN, 30 字节汇编期常量,不进内存
  • 注意 .word 的宽度跟着各架构对「word」的定义走:x86 上 2 字节、ARM64 / RISC-V 上 4 字节(llvm-mc 三个 triple 各汇编一次可验:0700 vs 07000000)——就是 01 章「word 的三种含义」那张表在数据指令上的重演。

.bss:不占文件的内存

  • 初始化为 0 的大缓冲区放 .section .bss:它在 ELF 里是 NOBITS 节——只记录「要多大」、不含内容,加载时由内核整段清零。readelf -S 看得到 .bss 标着 NOBITS,而 .text/.rodata 是 PROGBITS。
  • 对比:64 字节缓冲区放 .data,可执行文件就真的大 64 字节;放 .bss 一个字节都不占。
  • 放哪个节区的口诀:只读数据 → .rodata,可写且有初值 → .data,可写且全 0 → .bss。放错方向不对称:该去 .bss 的进了 .data 只是浪费体积,往 .rodata 写则运行期段错误。

标签的三档可见性(nm)

  • 普通标签loop2:):进符号表,nm 显示小写 t(本文件私有);加 .globl 变大写 T,导出给链接器。
  • .L 前缀.Lloop:):汇编器内部消化,符号表里没有——编译器 -S 输出里满屏的 .L2.L3 就是它,既不污染符号表也不怕重名冲突。
  • 数字标签1:):可无限复用;跳转写 1f(forward,往下找最近的 1:)或 1b(backward,往上找)。适合「用完即扔」的短循环,省得给三行代码起名字。
# 一段浓缩示例(as + ld 可直接汇编)
.intel_syntax noprefix

.section .rodata
msg:    .asciz "hi\n"          # 自动补结尾 0(.ascii 不补)
nums:   .quad  1, 2, 3         # 3 个 64 位整数
.equ    LEN, 3                 # 汇编期常量,不占内存

.section .bss                  # 未初始化数据:不占文件体积
.balign 8                      # 对齐到 8 字节(别写 .align,见 pitfall)
buf:    .skip  64              # 64 字节缓冲区,加载时清零

.section .text
.globl _start
_start:
    mov  rcx, LEN              # 常量直接当立即数用
1:  dec  rcx                   # 数字标签:用完即扔
    jnz  1b                    # 1b = 往回找最近的「1:」
    mov  rax, 60
    xor  rdi, rdi
    syscall
.align N 的含义随架构变:x86 上是「对齐到 N 字节」,ARM64/RISC-V 上是「对齐到 2 的 N 次幂字节」——同一行 .align 4,x86 对齐到 4,另两家对齐到 16(先放 1 个 .byte.align 4,llvm-mc 三个 triple 各汇编一次,下一字节分别落在偏移 4 和 16,亲眼可验)。跨架构写汇编一律用语义无歧义的 .balign(按字节)或 .p2align(按 2 的幂),把歧义扼杀在拼写里。
想看这套设施的「工业用法」,随便拿个 C 文件 gcc -O2 -S.L 标签、.p2align.section .rodata.str1.1 全在里面——编译器就是用这套设施写汇编的,它的输出是最好的范文。

hello 是「输出一个常量」,第二个程序该接收输入了。裸 _start 没有 main(argc, argv)——参数在哪?内核放在了栈上。这个程序把每个命令行参数各打一行:外层循环、内层扫描、跨系统调用保值全用上,是第一个「结构完整」的手写程序。

进程第一条指令执行时,栈长这样

[rsp]          argc                    ← 参数个数(含程序名)
[rsp+8]        argv[0] ──→ "./args"   ← 每格一个字符串指针
[rsp+16]       argv[1] ──→ "hello"
  ...
[rsp+8*argc+8] 0                       ← NULL:argv 的结束标志
(其后)       envp[0], envp[1], … 0   ← 环境变量,同样以 NULL 收尾
  • gdb 可直接看到:starti 停在第一条指令,x/5gx $rsp 第一格是 3(./args hi there 共三个参数),随后三个指针加一个 0;x/s 任一指针能看到字符串本体。
  • 这是 ABI 规定的进程初始状态,三架构同构:ARM64/RISC-V 上同样是 sp 指着 argc。

程序的三层结构(对照右侧代码)

  • 外层循环:r12 拿 argc 倒计数、r13 沿指针数组每轮 +8——遍历的是「指针的数组」,不是字符串本身。
  • 内层扫描1:2f 的小循环逐字节找结尾 0 算长度——就是 03 章「实例」卡那个 strlen 循环的现场版。
  • 跨 syscall 保值syscall 只改 rax(返回值)、rcx、r11(内核暂存 rip/rflags),所以 rsi/rdx 里的参数穿过第一次 write 仍然活着;真正要长命的计数器放进了 r12/r13。

改一改:四个热身练习

  • ① 跳过 argv[0] 只打用户参数(r13 初值多加 8、r12 少数 1——两行)。
  • ② 参数之间打空格、最后才换行(把换行的 write 挪出循环)。
  • ③ 打印 argc 的数值——比想象中难:数字转字符串要「除 10 取余、倒序收集」,写完你会真正理解「CPU 眼里没有十进制」。
  • ④ 继续遍历 envp(argv 的 NULL 之后就是)——这次没有计数,只能靠「读到 NULL 停」。

每道只改/加几行,但动手前都得先答一个问题:此刻每个寄存器里是什么。

# args.s —— 把每个命令行参数各打一行(x86-64 Linux, GAS Intel)
.intel_syntax noprefix
.globl _start

.section .rodata
nl:     .ascii "\n"

.section .text
_start:
    mov  r12, [rsp]          # argc
    lea  r13, [rsp + 8]      # &argv[0]

next:
    test r12, r12            # 参数打完了吗?
    jz   done
    mov  rsi, [r13]          # rsi = 当前参数字符串
    xor  rdx, rdx            # rdx = 长度,从 0 数起
1:  cmp  byte ptr [rsi + rdx], 0
    je   2f
    inc  rdx
    jmp  1b
2:  mov  rax, 1              # write(1, rsi, rdx)
    mov  rdi, 1
    syscall                  # rsi/rdx 不被 syscall 破坏
    mov  rax, 1              # write(1, nl, 1)
    mov  rdi, 1
    lea  rsi, [rip + nl]
    mov  rdx, 1
    syscall
    add  r13, 8              # 下一个指针
    dec  r12
    jmp  next

done:
    mov  rax, 60             # exit(0)
    xor  rdi, rdi
    syscall
_start 不是被 call 进来的:栈顶是 argc,不是返回地址——这是它和普通函数的根本区别。在 _start 里写 ret,CPU 会把 argc 当跳转地址、直奔地址 3 而段错误;退出只能走 exit 系统调用。第 3 卡「忘写 exit 会跑飞」的另一面就是它:_start 从来无处可返回。
./args hello world 的第一行输出是 ./args——argv[0] 是程序自己的路径。这个 C 里「大家都知道」的约定,在汇编层看得最透:它就是栈上第一个字符串指针,和其它参数毫无区别。写顺之后去 04/05 章找同名「动手」卡——同一程序的 ARM64/RISC-V 版并排对照,一次看清三家分歧的实际后果。

实战里手写汇编的主流形态不是独立程序,而是嵌在 C 工程里:性能关键的一个函数用 .s 写、其余交给 C;或者反过来,汇编借 libc 的现成轮子——printf 可比裸 syscall 拼字符串方便得多。两个方向各给一个最小闭环,编译命令都只有一行。

方向一:C 调汇编函数

/* main.c —— C 侧只需要一行原型声明 */
#include <stdio.h>
long array_sum(const long *a, long n);   /* 实现在 sum.s 里 */
int main(void) {
    long a[] = {1, 2, 3, 4, 5};
    printf("sum = %ld\n", array_sum(a, 5));
}

$ gcc -Wall main.c sum.s -o prog && ./prog   # gcc 认 .s,自动喂给 as
sum = 15
  • sum.s 就是 02 章「实例」卡的 array_sum 换成本章一直在用的 GAS 写法(; 注释换 #byte 补成 byte ptr),再加一行 .globl array_sum 导出符号。想沿用那卡的 NASM 原文也行:导出行写 global array_sumnasm -f elf64 sum.asm 编出 .o,再 gcc main.c sum.o 链接(同样 sum = 15)。两条路线都通,但别混着写——gcc 的 .s 只认 GAS。
  • 两边唯一的契约是 ABI:参数怎么进(rdi/rsi)、结果怎么出(rax)、谁负责保存什么——这正是 03 章 System V ABI 卡值得啃透的原因。C 编译器不检查你的汇编守不守约,签错了不报错,直接运行期乱值。

方向二:汇编调 printf(对照右侧代码,三个关键点)

  • 入口叫 main 不叫 _start:让 gcc 把 crt 带上——_start 归它、libc 初始化归它;你的 main 是被 call 进来的,结尾 ret 有处可去。
  • xor eax, eax:变参函数的约定——al 里要装「用了几个向量寄存器传参」,没传浮点就必须是 0。
  • push rbx:main 入口处 rsp ≡ 8 (mod 16)(call 压了 8 字节返回地址),随手压一个 8 字节,call printf 那一刻 rsp 就回到 16 字节对齐——ABI 的硬要求(01 章调用约定卡)。

三个出错现场(gcc 15.2 / glibc 2.43)

  • 删掉 push/pop 破坏对齐 → 带 %f 的 printf 直接段错误,gdb 显示崩在 printf 内部movaps(要求 16 字节对齐的向量访存)。「崩在别人家里」是混编 bug 的典型长相:现场在 libc,凶手是你的栈。
  • 传了 %f(xmm0)却把 eax 清成 0 → 打出 pi = 0.000000:不崩、不报错,值就是错的。al 必须如实报向量寄存器个数。
  • 用绝对地址取符号(mov rsi, offset who)→ 链接期报 relocation R_X86_64_32S against `.rodata' can not be used when making a PIE object; recompile with -fPIE:gcc 默认生成位置无关可执行文件,取地址一律写 lea reg, [rip + sym](core 章链接与重定位卡的现场版)。
# hi2.s —— 汇编写 main,printf 由 libc 提供(gcc hi2.s -o hi2 && ./hi2)
.intel_syntax noprefix
.globl main

.section .rodata
fmt:    .asciz "%s: %ld chars\n"
who:    .asciz "assembly"

.section .text
main:
    push rbx                     # 凑回 16 字节对齐(内容不重要,位置重要)
    lea  rdi, [rip + fmt]        # 参数1:格式串
    lea  rsi, [rip + who]        # 参数2:%s
    mov  rdx, 8                  # 参数3:%ld
    xor  eax, eax                # 变参约定:al = 向量寄存器个数 = 0
    call printf
    xor  eax, eax                # return 0
    pop  rbx
    ret                          # 回到 crt——main 是被 call 进来的
后缀大小写有讲究:小写 .s 直接进汇编器,大写 .S 先过 C 预处理器。反了不一定报错——x86 GAS 里 # 是行注释,.s 文件里的 #define N 3静默当成注释mov rax, N 里的 N 变成未定义符号、被编成一次内存加载,汇编期零报错,链接期才炸——gcc 默认 PIE 下报的还是上一段那种 relocation R_X86_64_32S against undefined symbol `N'… recompile with -fPIE,根源却是符号未定义(-no-pie 时才是朴素的 undefined reference)。要用 #include#ifdef 的汇编文件,必须叫 .S 并交给 gcc 驱动。
这张卡顺手补全了 02 章「实例:strlen 与数组求和」的跑法:加一行 .globl、写个带原型的 main.c、gcc main.c xxx.s 一条命令。ARM64/RISC-V 同理:照 04/05 章「完整程序示例」卡装好交叉工具链后,把 gcc 换成 aarch64-linux-gnu-gcc 等前缀版、用 qemu 跑,契约换成各自章的调用约定卡。

混合编程的第三种形态:不单开 .s 文件,直接把一两条指令嵌进 C 函数——内核的原子操作、rdtsc 计时、开关中断都这么写。GCC 的 extended asm 语法是 asm(模板 : 输出 : 输入 : clobber),本质是你和寄存器分配器签的一份合同:指令由你写,寄存器由它派。

语法:占位符 + 约束

long x = 40, y = 2;
asm("add %1, %0"     /* 模板:%0 %1 是占位符(AT&T 序:add src, dst) */
    : "+r"(x)        /* 输出 %0:「+」= 又读又写,「r」= 派个寄存器 */
    : "r"(y));       /* 输入 %1:派另一个寄存器 */
/* x == 42:编译器挑寄存器、装值、执行完把 %0 写回 x */
  • 常用约束就几个:"r" 任意通用寄存器、"m" 内存、"i" 立即数;输出前缀 =(只写)或 +(读写)。
  • 要指定具体寄存器用约束字母:"a"=rax、"d"=rdx…——rdtsc 把时间戳固定放 edx:eax,正好 asm("rdtsc" : "=a"(lo), "=d"(hi)) 接住。

合同思维:编译器只知道你申报的

  • 不解析模板文本——那串指令对它是黑盒。全部情报来自三张清单:输出(我会写什么)、输入(我要读什么)、clobber(我顺手毁了什么:寄存器名、条件码 "cc"、以及影响面最大的 "memory")。
  • 漏报的后果不是报错,而是优化器基于错误情报做出「正确」的决策——见下面的。

出错现场(gcc 15.2, -O2)

  • 没写 volatile、输出恰好没人用 → 整条 asm 被删:asm("rdtsc" : "=a"(lo), "=d"(hi)) 的结果不使用,objdump 里 rdtsc 零出现。有副作用(计时、端口 I/O、开关中断)的 asm 必须 asm volatile
  • 通过指针写了内存、没报 "memory"asm("movq $42, (%0)" : : "r"(p)) 之后打印 *p:-O0 得 42,-O2 得旧值 1——编译器不知道内存变了,把它记得的旧值常量折叠了。「一开优化就坏」的怪 bug 很多源于此。
  • 漏报寄存器 clobber 最隐蔽:循环里偷偷 xor ecx, ecx 不申报,结果照样正确——编译器这次恰好没把活跃值放 rcx。它不当场炸,等哪天寄存器分配变了才炸。
/* 计时:rdtsc 把 64 位时间戳放在 edx:eax */
static unsigned long tsc(void) {
    unsigned lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((unsigned long)hi << 32) | lo;
}

/* 通过指针写内存:必须申报 "memory" */
asm("movq $42, (%0)" : : "r"(p) : "memory");

/* 模板里写死寄存器要双百分号,并列进 clobber */
asm volatile("xor %%ecx, %%ecx" : : : "rcx");
模板文本是「方言敏感」的:默认按 AT&T 写(add %1, %0 是 src → dst),若整个文件用 -masm=intel 编译,同一份模板会被按 Intel 语法重新解释——上面的 add 例子照常编译通过,结果却从 42 静默变成 40(操作数顺序的意义反了)。跨方言的模板要用 {att 版|intel 版} 双写语法,或保证模板方言和编译选项一致。
别背约束表——"r""m""=r""+r" 加 volatile 和 "memory" 覆盖日常九成。往深处走读 GCC 官方文档 Extended Asm 一节,再把内核源码的用例(arch/x86/include/asm/ 下满地都是)丢进 godbolt 逐个验证——那是最好的进阶教材。

三架构横向对比

把同一件事在三种汇编里并排看,是把「死记硬背」变成「理解差异」的最快方式。

同一段 C 编到三种 ISA,最能直观看出 CISC↔RISC 的光谱:谁把复杂寻址塞进一条指令,谁拆成最朴素的几步。

例一:整数相加

int add(int a, int b) { return a + b; }
x86-64ARM64RISC-V
lea eax, [rdi+rsi]
ret
; 用 lea 当加法
add w0, w0, w1
ret
addw a0, a0, a1
ret
# w 后缀=32位

例二:数组取元素

int get(int *p, long i) { return p[i]; }
x86-64 · 1 条ARM64 · 1 条RISC-V · 3 条
mov eax, [rdi+rsi*4]
ret
ldr w0, [x0, x1, lsl #2]
ret
slli a1, a1, 2
add a0, a0, a1
lw a0, 0(a0)
ret

最能体现 CISC↔RISC 光谱:x86 一条指令内嵌「基址+变址×4」;ARM 把比例移位塞进 load;RISC-V 最纯粹——显式「算偏移→加基址→访存」三步。指令数 1 : 1 : 3(不计三家共有的 ret)。

别用「指令条数」直接推性能。取数组元素 x86 一条、ARM 一条、RISC-V 三条,但那三条(slliaddlw)都是单周期简单指令,流水线上未必更慢;x86 那条复合寻址的 mov 背后也要拆成多个 μop。条数体现的是「ISA 表达力」,不是「快慢」。
本章的正确打开方式:在 godbolt 同屏开三个编译器窗格(x86-64 gcc、ARM64 gcc、RISC-V gcc,都开 -O2),把你自己写的函数丢进去横向读。亲手对比十个函数,胜过背一百行对照表。

上一卡的单条语句看的是「指令表达力」,完整函数看的是另一件事:循环怎么组织、调用怎么保存现场、序言尾声三家各自的习惯。同一个 sum_squares 编到三家(x86 用 gcc 15、ARM64/RISC-V 用 clang 21,均 -O1,godbolt 可复现)。

源函数

long square(long x);              // 外部函数——逼出真实的 call
long sum_squares(long n) {
    long s = 0;
    for (long i = 1; i <= n; i++) s += square(i);
    return s;
}

三份汇编(节选主干)

x86-64 (gcc -O1)ARM64 (clang -O1)RISC-V (clang -O1)
sum_squares:
  push r12
  push rbp
  push rbx
  test rdi, rdi
  jle  .L4
  mov  r12, rdi
  mov  ebx, 1
  mov  ebp, 0
.L3:
  mov  rdi, rbx
  call square@PLT
  add  rbp, rax
  add  rbx, 1
  cmp  r12, rbx
  jge  .L3
.L1:
  mov  rax, rbp
  pop  rbx
  pop  rbp
  pop  r12
  ret
.L4:
  mov  ebp, 0
  jmp  .L1
sum_squares:
  stp  x29, x30, [sp, #-48]!
  str  x21, [sp, #16]
  stp  x20, x19, [sp, #32]
  mov  x29, sp
  cmp  x0, #1
  b.lt .LBB0_3
  mov  x19, x0
  mov  x20, xzr
  mov  w21, #1
.LBB0_2:
  mov  x0, x21
  bl   square
  subs x19, x19, #1
  add  x20, x0, x20
  add  x21, x21, #1
  b.ne .LBB0_2
  b    .LBB0_4
.LBB0_3:
  mov  x20, xzr
.LBB0_4:
  mov  x0, x20
  ldp  x20, x19, [sp, #32]
  ldr  x21, [sp, #16]
  ldp  x29, x30, [sp], #48
  ret
sum_squares:
  addi sp, sp, -32
  sd   ra, 24(sp)
  sd   s0, 16(sp)
  sd   s1, 8(sp)
  sd   s2, 0(sp)
  li   s0, 0
  blez a0, .LBB0_3
  addi s2, a0, 1
  li   s1, 1
.LBB0_2:
  mv   a0, s1
  call square
  addi s1, s1, 1
  add  s0, s0, a0
  bne  s1, s2, .LBB0_2
.LBB0_3:
  mv   a0, s0
  ld   ra, 24(sp)
  ld   s0, 16(sp)
  ld   s1, 8(sp)
  ld   s2, 0(sp)
  addi sp, sp, 32
  ret

读什么(比指令名重要)

  • 现场保存三种风格:x86 三条 push(每条顺手动 rsp);ARM64 一次性 sp -= 48 并用 stp 成对存取;RISC-V 手工 addi sp,-32 加逐个 sd——最朴素也最直白。
  • 返回地址在哪:x86 的序言里根本看不到它——call 隐式压栈了;ARM64/RISC-V 必须显式保存 x30/ra,因为函数体里还要 bl/call square,链接寄存器会被覆盖。三家对照,「返回地址压栈 vs 存寄存器」从口诀变成看得见的东西。
  • 为什么全用被调用者保存寄存器:循环变量 s、i、n 分别住进 rbx/rbp/r12x19-x21s0-s2——它们要跨 call 存活,而 caller-saved 寄存器会被 square 随意破坏。两类寄存器的分工(core 章 ABI 卡)在这里落地。
  • 连循环方向都是编译器的自由:x86 版向上数到 n,ARM64 版被 clang 改成倒计数subs 顺便置标志,省一条 cmp),RISC-V 版向上数到 n+1。逻辑等价,形态随后端习惯。
三份输出绑定「特定编译器 + -O1」。换编译器或换 -O2,形态会大变:gcc 与 clang 连循环方向都选得不同,-O2 还可能向量化或展开。把某一份背成「标准答案」,下次见到别家输出会以为自己没学会——要背的是上面四条「读什么」,不是具体指令序列。
这张卡兑现的是 core 章「一条 C 语句的三种译法」末尾的约定:那里一条语句建索引,这里完整函数看惯用法。下一步把你自己的函数丢进 godbolt,同屏开三个 -O1 窗格照这个清单读一遍。

三种架构给同一个「角色」起了不同的名字——认名字之前先认角色,映射建立起来,陌生汇编就不再陌生。

对照表

角色x86-64ARM64RISC-V
第1参数rdix0a0
第2参数rsix1a1
返回值raxx0a0
栈指针rspspsp (x2)
帧指针rbpx29 (fp)s0/fp (x8)
返回地址(在栈上)x30 (lr)ra (x1)
程序计数器rippcpc
零寄存器xzr/wzrzero (x0)
GPR 数量1631 (+zr)32 (含 x0)
别把某一家的寄存器名当成通用。典型错误:把 x86 的 raxrdi 套到 ARM/RISC-V;或把 RISC-V 的 x0(zero)、ARM 的 xzr 当普通寄存器——它们恒为 0,写入被直接丢弃。还有 x86 返回地址压在栈上,而 ARM/RISC-V 存在 lrra 寄存器里,读栈帧时别找错地方。
记「角色」而非「名字」——读陌生汇编先问「这个寄存器扮演什么角色」,再套三家映射(第一参数 x86 rdi ↔ ARM x0 ↔ RV a0)。注意 ARM/RISC-V 的第一参数和返回值同用一个寄存器(x0a0),而 x86 是 rdi 传入、rax 返回,两个不同寄存器。

同一件事三家指令名不同,套路却一致;学会一家的「操作分类」,另两家按图索骥即可。

对照表

操作x86-64ARM64RISC-V
寄存器复制movmovmv (伪)
addaddadd / addi
从内存加载mov r,[m]ldrlw / ld
存到内存mov [m],rstrsw / sd
比较cmpcmp(并入分支)
相等则跳cmp+jecmp+b.eqbeq
无条件跳jmpbj (伪)
函数调用callbljal / call
返回retretret (伪)
压栈pushstp …[sp,#-16]!addi sp+sd
原子加lock xaddldaddal (LSE)amoadd.w
系统调用syscallsvc #0ecall
别以为 RISC-V 也有独立的 cmp。RISC-V 既无比较指令也无条件码,比较直接并入分支:beqblt 一条完成「比较+跳转」。x86/ARM 才是「先 cmp 置标志、再条件跳」两步。把 x86 的 cmp+je 硬套到 RISC-V 会找不到对应指令——cmp a0, a1 在 riscv64 上直接报「unrecognized instruction」。
认出「伪指令」。RISC-V 表里 mvjret 都是伪指令,由汇编器翻译成真实指令——mv a0, a1 实际编码成 addi a0, a1, 0。读反汇编时看到 addi rd, rs, 0 就要认出它其实是一次寄存器复制。

指令和寄存器只是表象,三家真正的分野在设计哲学——CISC 还是 RISC、强序还是弱序、有没有条件码。

对照表

维度x86-64ARM64RISC-V
类型CISCRISCRISC
指令长度变长 1–15 字节定长 4 字节定长 4 (压缩 2)
访存算术可直接访存load-storeload-store
标志位有 (RFLAGS)有 (NZCV)
返回地址压栈LR 寄存器ra 寄存器
内存序强序 (TSO)弱序弱序 (RVWMO)
寻址能力极强中等最简
授权专有专有(授权)开源免费
主要场景桌面/服务器移动/苹果/云嵌入/教学/未来
学习难度高(包袱重)低(最规整)
别把三家的内存序和条件码当成一样。x86 是强序(TSO),ARM/RISC-V 是弱序——同一段多线程代码在 x86 上「碰巧能跑对」,移植到 ARM 可能因缺内存屏障而出错;RISC-V 更是连条件码(RFLAGS/NZCV)都没有。以为「练熟 x86 的直觉就到处通用」,是跨架构时最大的坑。
一句话抓住三家性格:x86 历史包袱重、变长指令、算术能直接访存(CISC);ARM64 定长精简、主打移动与苹果/云;RISC-V 最规整、开源免费、适合入门与教学。挑学习顺序不妨从 RISC-V 起步(规则最少),再看 ARM,最后啃 x86。

从这里到精通:路线图

读汇编的地图铺完了,剩下的路要亲手读写才能走通。最后这一章给出收尾路线:难度递进的练习路径、按阶段的资料清单,以及一条自测标准。

对照表看完只是「认识」了三种汇编,精通要靠亲手读写。下面的路径按难度递进,每一步都有明确产出。

练习路径(难度递进)

  • ① 读编译器的汇编:每天把一个自己写的小函数丢进 godbolt,对照读 -O0-O2 的输出,直到编译器的每个选择(强度削减、内联、cmov 化)都不再意外。这是第一个月的主线,也是性价比最高的练习。
  • ② 手写并打擂台:手写 strlen、memcpy、阶乘这类小函数(起步姿势与跑法在 06 章:第二个程序 → 混合编程),和编译器 -O2 的版本比指令数、比性能——多数时候你会输,输在哪,哪就是下一个知识点
  • ③ 触碰系统边界:把三个架构章的 Hello World 全部亲手跑通,用 strace 验证系统调用;再写一个自己的 crt0(替代 libc 启动代码)或 QEMU 裸机 hello——理解「main 开始之前发生了什么」。
  • ④ 从读写到生成:写一个 RV32I 模拟器(几百行 C/Rust,配合 COA 页),或给玩具语言写一个输出汇编的编译器后端 / 最小 JIT——能「生成」汇编,才算真正内化了这一层。

资料(按阶段)

  • CS:APP 第 3 章(程序的机器级表示):读编译器输出的最佳系统教程,x86-64 视角,配套 bomb lab 是绝佳练习。
  • Agner Fog 优化手册 + uops.info:x86 指令延迟与微架构行为的事实标准。
  • 官方手册:Intel SDM、ARM Architecture Reference Manual、RISC-V 官方 spec 与《RISC-V 手册》(Patterson & Waterman)——所有争议的最终裁判,学会查它们本身就是一项技能。
  • Compiler Explorer:不只是工具——Matt Godbolt 的多场 CppCon 演讲(如 What Has My Compiler Done for Me Lately?)本身就是绝佳的读汇编教程。
  • GCC 内联汇编(extended asm):入门(约束符、clobber、volatile 与三个出错现场)已在 06 章「内联汇编」卡展开;进阶读 GCC 官方文档 Extended Asm 一节,再把内核源码里的用例丢进 godbolt 逐个验证。
学习路径上最常见的两个误区:一是「只背指令表」——助记符一查即得,真正的门槛在调用约定、栈帧布局、寻址方式和编译器的优化套路;二是「学会一个架构就以为都会了」——把 x86 的强序、条件码、复合寻址直觉搬到 ARM/RISC-V 上,多半会栽跟头。把力气花在读真实汇编、吃透 ABI 上,而不是背助记符。
一个自测标准:拿到 20 行陌生的 -O2 汇编,你能否还原出它的 C 逻辑、说出编译器做了哪些优化、并大致预测它在另外两个架构上长什么样?能做到,这一页就毕业了——剩下的精通来自日常工作里每一次「顺手看一眼汇编」。