全景:汇编是什么,三家怎么分
先建立地图:你的代码到机器码之间隔着哪几层、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」在这里亲眼可见。
- gdb:
layout asm显示汇编视图,si逐条单步,info registers看每条指令如何改变寄存器——把静态的指令表变成动态的「机器状态演化」。
-O0 与 -O2 生成的汇编面目全非:-O0 冗长但逐行贴合源码,-O2 会重排、内联、把变量塞进寄存器。对照学习务必固定用 -O0,否则「C 和汇编对不上」不是你的错,是优化器把语句结构揉碎了。qemu-user + 交叉工具链几分钟装好——后面三个架构章的「完整程序示例」卡都给了具体命令。本页后面三章分别深入三种 ISA。先认识它们各自统治的地盘和性格,再决定先读哪一章——这比按顺序硬啃高效得多。
一张世界地图
| 架构 | 你在哪见到它 | 性格标签 | 本页章节 |
|---|---|---|---|
| x86-64 | 你的 PC、绝大多数云服务器 | CISC(复杂指令集)· 变长编码 · 四十年兼容包袱 | 第03章 |
| ARM64 | 全部手机、Apple Silicon Mac、AWS Graviton | load-store · 定长 32 位 · 能效见长 | 第04章 |
| RISC-V | MCU / 嵌入式起家,正进入数据中心 | 开放免费 · 模块化 · 教科书级规整 | 第05章 |
本页怎么读
- 先读完本章:搞清汇编今天还值得学什么、以及三家各自的世界;接着读 01 章,那是三家共享的骨架(寄存器、寻址、栈、ABI……),每个概念都三路对照。
- 接着在上手章跑通 hello:三条命令得到一个活的进程——后面读到的每个概念,都能回到那 8 条指令上单步验证。上手章只要求你把别人写好的一段跑起来、拆开、单步看;「自己写」的地基(数据与标签设施、读输入的程序、与 C 混编、内联汇编)在 06 章,读完任一架构章就能去。
- 然后三选一深入:不必都精通,深入一种、能看懂另外两种(选型建议见本章第一卡的 tip)。
- 第 07 章横向对比把三家钉在同一张表上;没深入的两章当参考书,用到再查。
机器模型:三家共享的骨架
三种架构的指令名字各不相同,底下这套模型却是同一套:寄存器、内存与对齐、寻址方式、标志位、栈帧、调用约定、系统调用、链接。这一章把它们逐个讲清,每个概念都三路对照——先有这套骨架,后面三个架构章才只是在同一张骨架上换名字。
把 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)写法不同,本质全是「基址 + 偏移」,且基址都是栈指针 / 帧指针——局部变量住在栈上(→ 栈与栈帧卡)。eax、w8、addw里的玄机:它们都是 64 位寄存器的 32 位视图 / 32 位运算,因为 int 是 32 位(→ 各架构章的寄存器组卡)。
拿去 godbolt 复现
- 必须用 -O0 才能看到这个教学版本。开
-O2后变量直接住进寄存器、这条语句甚至可能整个消失(结果在编译期就算好了)——这个落差本身就是重要一课:优化器眼里没有「语句」,只有数据流。
寄存器是 CPU 内部、访问延迟最低的少量存储单元。汇编的大部分工作就是在「寄存器 ↔ 内存」之间搬运数据并对寄存器做运算。
两类寄存器
- 通用寄存器 (GPR):存放操作数和地址。
- 专用寄存器:程序计数器(
PC/RIP,下一条指令地址)、栈指针(SP)、状态/标志寄存器、链接寄存器(ARM/RV 保存返回地址)。
三架构对照
| x86-64 | ARM64 | RISC-V |
|---|---|---|
| 16 个 GPR(RAX…RDI, R8–R15) | 31 个 GPR(X0–X30)+ 零寄存器 XZR | 32 个 GPR(x0–x31),x0 恒为 0 |
| 有标志寄存器 RFLAGS | 有状态位 NZCV | 无标志寄存器 |
| RIP 不可直接当 GPR 写 | SP / PC 独立 | 极致规整 |
eax(32 位)会把 rax 高 32 位清零,但写 al/ax(8/16 位)只改低位、保留高位——这个「部分寄存器」行为常制造隐藏的旧数据残留。ARM64 写 w0 同样清零 x0 高 32 位。mov、nop、比较等都能用它拼出来,省掉一堆专用指令。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 ; 低位在前0x01 存进一个 int、看它占的首字节是不是 01。同样是「word」,在不同架构里大小不同——这是初学者最常被坑的地方:
位数对照
| 位数 | x86-64 | ARM64 | RISC-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) |
lw(RISC-V load word)是加载 32 位,别想成 16 位。word 永远是那个「原初的」16 位字,后来的 32/64 位只能往上叠成 dword/qword;ARM/RISC-V 没有历史包袱,直接把 word 定义成 32 位。所以读指令宽度后缀前,先认是哪家架构再解释字母。「操作数从哪来」是指令的核心。寻址模式不是硬件的花样炫技——每一种都对应高级语言里一类具体结构,编译器就是照这张映射表把 C 翻译成访存的。
模式 ↔ 它服务的语言结构
- 立即数:常量编码在指令里 ← 源码里的字面常量;
- 寄存器:操作数在寄存器里 ← 被寄存器分配选中的局部变量(开优化后的常态);
- 基址 + 偏移
[base + disp]← 结构体字段与栈上局部变量; - 基址 + 变址 × 比例
[base + idx*4]← 数组下标,比例就是元素字节数。
CISC/RISC 分野
- x86 一条指令就能做
[rdi + rsi*4 + 8];RISC-V 得先把地址算进寄存器再访存——寻址能力是两种设计哲学最直观的体现。
lea 算偏移,一条寻址搞不定。RISC-V 基础指令集根本没有变址寻址,一律拆成算地址 + 访存两步。[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 |
jg/jl(signed)看的标志组合和 ja/jb(unsigned)不同;ARM 同理,b.gt/b.lt(signed)对 b.hi/b.lo(unsigned)。选错会在数值跨越符号边界时判反——例如把 0xFFFFFFFF 当 -1 还是当四十多亿,结论相反。高级语言的全部控制结构——if/else、while、switch、函数调用与 return——到了机器层只剩下面四类原语。编译器的核心工作之一,就是把结构化控制流「拍平」成跳转与标签。
四类控制流
- 无条件跳转:x86
jmp/ ARMb/ RISC-Vj。 - 条件分支:依据标志或直接比较。
- 函数调用:跳转 + 保存返回地址。x86
call把返回地址压栈;ARMbl和 RISC-Vjal把返回地址存入链接寄存器(LR / ra)。 - 返回:x86
ret从栈弹出地址;ARMret跳到 LR;RISC-Vret=jalr x0, ra, 0。
bl/jal,却忘了在序言里把 LR/ra 压栈,返回时就会跳到被内层调用覆盖后的错误地址——典型的「函数返回到奇怪地方」崩溃。x86 因为 call 自动把返回地址压栈,没有这个坑。栈是一块由 栈指针 (SP) 管理的内存区域,向下增长(从高地址往低地址)。每次函数调用会建立一个栈帧,存放返回地址、保存的寄存器和局部变量。
为什么向下?栈从高地址往下、堆从低地址往上,两者相向增长,中间留一大片未用空间由双方共享——谁先用完谁先撞上,最大化地利用整个地址空间。
三个关键概念
- 序言 (prologue):进入函数时下移 SP 分配空间、保存需要保护的寄存器(含 LR/返回地址)。
- 尾声 (epilogue):恢复寄存器、还原 SP、返回。
- 帧指针 (FP):固定指向当前帧的参照点,便于调试回溯。x86 用
RBP,ARM 用X29,RISC-V 用s0/fp。
; 栈布局(高地址在上)
┌──────────────┐ 高地址
│ 调用者的帧 │
├──────────────┤
│ 返回地址 │
│ 保存的 FP │ ← FP 指向这里
│ 局部变量 │
│ ... │ ← SP 指向栈顶(最低)
└──────────────┘ 低地址 (栈向下增长 ↓)[sp, #off] 的正偏移是往帧内更高地址走。初学者容易把「压栈」想成地址增大——恰恰相反:push 让 SP 减小,pop 让它增大。stp x29, x30, [sp, #-16]! 就同时完成「SP 下移 16 字节 + 保存 FP + 保存 LR」(! 是 pre-index 先减后存),尾声再对称地 ldp x29, x30, [sp], #16 恢复。认 ARM 序言,先找这一对 stp/ldp。ABI(应用二进制接口)规定了函数之间如何配合,是「让分别编译的代码能互相调用」的契约。四个核心问题:
四个核心问题
- 参数怎么传:优先用寄存器,超出数量再压栈。
- 返回值在哪:通常在某个固定寄存器。
- 谁保存谁:caller-saved(易失) 的寄存器,被调用函数可随意改;callee-saved 的,函数若要用必须先存后恢复。
- 栈对齐:三家都要求调用点 16 字节对齐。
三架构对照
| 角色 | x86-64 (SysV) | ARM64 (AAPCS) | RISC-V |
|---|---|---|---|
| 整数参数 | rdi,rsi,rdx,rcx,r8,r9 | x0–x7 | a0–a7 |
| 返回值 | rax (:rdx) | x0 (:x1) | a0 (:a1) |
| 返回地址 | 栈上 | x30 (LR) | x1 (ra) |
| callee-saved | rbx,rbp,r12–r15 | x19–x28 | s0–s11 |
call 那一刻 rsp 是 16 的倍数,而 call 会压入 8 字节返回地址,于是函数入口处 rsp ≡ 8 (mod 16)。序言里必须再调整(一次 push rbp、或 sub rsp, 8)把它对回来,否则调用用到 SSE 的 libc 函数时会因未对齐访问而崩溃。s0–s11、ARM 习惯把 x19–x28 叫 saved——是 callee-saved,能跨函数调用存活;参数与临时寄存器(a/t 系列、x86 的 rdi 等)是 caller-saved,一次 call 就可能被改。想让某个值跨过一次调用还在,要么放进 callee-saved、要么调用前自己压栈。用户态程序通过特殊指令陷入内核,请求 I/O、退出等服务。约定与普通函数调用不同:
三架构对照
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| 指令 | syscall | svc #0 | ecall |
| 调用号 | rax | x8 | a7 |
| 参数 | rdi rsi rdx r10 r8 r9 | x0–x5 | a0–a5 |
注意 x86 第 4 参数是 r10 不是 rcx。
write 在 x86-64 是 1,在 ARM64/RISC-V 都是 64;exit 在 x86-64 是 60,在 ARM64/RISC-V 都是 93(ARM64/RISC-V 共用 Linux 通用 syscall 表)。r10 而非函数调用的 rcx——因为 syscall 指令本身要用 rcx 存返回地址、r11 存标志,会被内核覆盖。返回值在 rax,负值通常表示 -errno。汇编器一次只看一个文件,凡是「此刻还不知道的地址」——别的文件里的函数、还没定稿的节区基址——都在 .o 里留一个洞,并附一张待办单(重定位条目),链接器最后统一填。本页反复出现的 %hi/%lo、@PLT、「重定位溢出」,源头全在这张单子上。
三家的重定位长相
- x86-64:藏在
.o里(R_X86_64_PC32),汇编源码里看不见; - ARM64:
adrp+: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-64 | ARM64 | RISC-V |
|---|---|---|
| MMX→SSE→AVX→AVX-512 | NEON (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) 的——同一份代码可在不同向量宽度的机器上运行。
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_main 带下划线)、二进制格式(Mach-O)全都不同,Windows 原生更是另一套——照抄会一路报错。hello.s 里真正让 CPU 干活的指令只有 8 条,其余全是给汇编器看的脚手架。把文件里的每个词分进三类——指令、伪指令、标签——这个文件就完全透明了。
三类词
| 类别 | 例子 | 去向 |
|---|---|---|
| 指令 | mov、lea、syscall | 1: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.o 与 readelf -S hello 各跑一遍对比:节区同名,但 .o 里地址全是 0(还没定),可执行文件里才有真地址——「链接器决定地址」亲眼可见。报错出现在哪一期,比报错文本本身信息量更大——同一个手误,打错指令名汇编期就拦住,打错标签名却要到链接期才炸。
汇编期(as 报的,带行号,最好修)
- 指令打错(
movv rax, 1)→Error: no such instruction,带行号,最好修; - 运行期没有报错可读时用 gdb:
layout asm开指令窗口、si单步、info registers、x/8xg $rsp看栈——看下一条 → 执行 → 看它改了什么,把 hello 的 8 条指令走一遍,机器状态就不抽象了。 - 寄存器打错报的不是「bad register」而是
ambiguous operand size——Intel 语法里汇编器把不认识的词当成了内存符号,看到这条先怀疑拼写。
链接期与运行期
- 标签打错 → 汇编期静默通过(汇编器以为那是别的文件里的外部符号),
ld才报undefined reference——「汇编通过」离「能链接」还有距离; - 忘写
exit→ 执行流冲出代码末尾,撞上垃圾字节,段错误; - 运行期没有行号,只有信号:退出码减 128 就是信号编号。
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 位宽度访问其低位部分。
寄存器宽度与传统用途
| 64 | 32 | 16 | 8(低) | 用途 |
|---|---|---|---|---|
| RAX | EAX | AX | AL | 累加器 / 返回值 |
| RBX | EBX | BX | BL | 基址 (callee-saved) |
| RCX | ECX | CX | CL | 计数 / 第4参数 |
| RDX | EDX | DX | DL | 数据 / 第3参数 |
| RSI | ESI | SI | SIL | 源 / 第2参数 |
| RDI | EDI | DI | DIL | 目标 / 第1参数 |
| RBP | EBP | BP | BPL | 帧指针 (callee-saved) |
| RSP | ESP | SP | SPL | 栈指针 |
| R8–R15 | R8D… | 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。
mov eax, 1)会清零高 32 位;但写 8/16 位(mov al, 1)保留高位不变。后者会与旧值产生「假依赖」,可能触发部分寄存器停顿 (partial register stall)。这也是编译器爱用 movzx / xor eax,eax 的原因。ah/bh/ch/dh 与新增的 spl/bpl/sil/dil 及 r8-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/M:
mod·reg·r/m,决定操作数是寄存器还是内存、以及哪种寻址。 - SIB:
scale·index·base,编码[base + index*scale + disp]。 - VEX / EVEX:AVX(2–3 字节 VEX) / AVX-512(4 字节 EVEX) 的新前缀,提供三操作数与掩码。
#UD/#GP。REX 前缀还必须紧贴 opcode(排在所有遗留前缀之后),位置放错会被解码成完全不同的指令。objdump -d 里同一段字节从不同偏移反汇编会得到不同指令——变长编码使 x86 没有「指令边界对齐」,这正是花式 gadget / 混淆的温床,也是定长 RISC 想避免的。x86 有两套汇编写法,看同一段反汇编时必须分清。
对照
| 项目 | Intel | AT&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), %raxmov $1, %rax 等于 Intel 的 mov rax, 1——把源当目标读会得出完全反的语义。AT&T 还靠助记符后缀 b/w/l/q 定宽度,立即数写进内存这类无法从寄存器推断宽度的场合漏后缀会直接报错。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)/rcxmov 不能内存到内存——movq (%rsi), (%rdi) 被 GNU as 拒绝(binutils 2.46 报「operand type mismatch for `movq'」),必须借寄存器中转。64 位立即数只有 movabs 能直接装入;普通 mov r64, imm32 会把 32 位立即数符号扩展到 64 位。movzx/movsx 一步扩展到目标宽度,别先 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/not;test=按位与但只设标志。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/BMI2:
andn, 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 的位数shl eax, 32 实际移 0 位(原样不动)而非清零。逻辑右移 shr 补 0、算术右移 sar 补符号位,有符号数除以 2 的幂必须用 sar。test rax, rax 比 cmp 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/ne | ZF |
| 有符号 | g/ge/l/le | SF, OF, ZF |
| 无符号 | a/ae/b/be | CF, ZF |
| 单标志 | s/ns, c/nc, o/no, p/np | SF/CF/OF/PF |
cmp a,b 算 a-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 ripcall/ret 全靠栈传递返回地址,任何让 ret 时 rsp 指向错误值的操作(压栈没配平、局部数组越界覆盖返回地址)都会跳飞。ret 本质是「弹栈顶到 rip」,栈顶被篡改即被劫持,这正是 ROP 攻击的原理。call 压入 8 字节返回地址、ret 弹出跳回,二者必须严格配平。间接跳转表配合 endbr64 落点才能通过 CET 校验,现代编译器默认在间接分支目标处插它。一族用隐含寄存器批量操作内存的指令,配 rep 前缀可硬件加速 memcpy/memset/strlen。
隐含寄存器
- 源
RSI、目标RDI、计数RCX;DF 控制方向(cld递增 /std递减)。 - 操作:
movs(传)、stos(存)、lods(取)、scas(扫描)、cmps(比较),后缀 b/w/d/q。
前缀
rep重复 RCX 次;repe/repz与repne/repnz在相等/不等时提前停止(配 scas/cmps)。
cld
rep movsb ; memcpy(rdi, rsi, rcx)
mov al, 0
repne scasb ; strlen:扫描到 '\0'std 设成递减后若不 cld 复位,之后所有字符串指令都反向执行。这类指令还隐式吃掉 RSI/RDI/RCX,调用前放在这些寄存器里的值会被覆盖。rep movsb/rep 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-saved | rbx rbp r12–r15 |
进阶规则
- 聚合体分类:≤16 字节的 struct 按 8 字节分块判定为 INTEGER/SSE 走寄存器,否则整体走栈 (MEMORY)。
- 大返回值:调用者分配空间、把隐藏指针放
rdi(其余参数后移),函数把该指针回填rax。 - 变参函数:
al须存「用了几个向量寄存器」。 - 红区:rsp 下方 128 字节,叶函数可直接用而不调 rsp。仅叶函数敢用——它不再
call,没有后续压栈会覆盖这块;一旦调用别的函数、或信号处理器抢占(内核在此按 ABI 会避开红区,但你自己的嵌套调用不会),这片区域就会被踩。调用点 rsp 须 16 字节对齐。
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
retcall 压入 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/movsd、addsd/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,非破坏性三操作数movaps/movdqa 要求 16 字节(YMM 为 32 字节)对齐,地址没对齐直接 #GP——不确定就用非对齐版 movups/movdqu。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隐含 lock;cmpxchg(CAS,比较并交换) 与cmpxchg16b;xadd(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 指令兜底。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
syscallsyscall 指令自身会覆盖 rcx(存返回 rip)和 r11(存 rflags),前后别指望这两个寄存器保值。且第 4 个参数走 r10 而非普通函数调用约定里的 rcx——这是内核 ABI 与用户态 ABI 的关键差异,套错会传错参数。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 | 陷入内核 |
%、立即数带 $,读 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 ./hi 里 break _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:
retcmp 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,否则会跨步读错元素。long,所以变址比例 ×8;换 int 数组就是 ×4。想跑:按 06 章「和 C 互相调用」卡的方向一——本卡是 NASM 语法,加一行 global array_sum,nasm -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,同一条汇编换个指令含义就变了。XZR/WZR(读恒为 0),省一条清零指令;不想要的结果写进 WZR 丢弃即可。所有 A64 指令都是 4 字节、自然对齐,字段位置固定,解码极简——与 x86 变长形成鲜明对比。
带来的取舍
- 优点:取指/解码简单、并行宽、无指令边界歧义、分支预测友好。
- 代价:32 位里塞不下任意大立即数。于是有了 bitmask 逻辑立即数编码、
movz/movk分段构造大常量、adrp+add拼地址等机制(见下文)。
mov 装进任意立即数——32 位编码塞不下大常量。mov x0, #0x12345678 这种非位掩码模式的立即数会直接汇编报错,得靠 movz/movk 分段拼。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,要用 adr 或 adrp+add。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 … xzr、cmn=adds、tst=ands … xzr、neg/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 % x1 得 sdiv 求商再 msub 回算余数(msub x3, x2, x1, x0 即 x0 - 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 读到的还是旧标志。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–X15 | caller-saved 临时 |
| X16/X17 | IP0/IP1(veneer 可破坏) |
| X18 | 平台保留 |
| X19–X28 | callee-saved |
| X29 / X30 | FP / LR |
| V0–V7 / V8–V15 | 浮点参数 / 低 64 位 callee-saved |
要点
- 返回值整数 X0(128 位 X1:X0),浮点 V0。
- HFA/HVA:全同类型的浮点/向量聚合(≤4 个)可整体走 V0–V7。
- SP 须 16 字节对齐;超额参数走栈。
X0–X15)和 callee-saved(X19–X28)记反是经典 bug——活跃值放进 X9 跨一次 bl,被调用方合法地把它覆盖掉。SP 在公开边界必须 16 字节对齐,且 V8–V15 只有低 64 位是 callee-saved,高位不保证保留。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
retbl 会覆盖 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(乘加)/fmls、abs/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 loopincb/incw(按真实 VL)而非常量字节数。加载 ld1w {z0.s}, p0/z, ... 里 /z 是把非活跃通道清零、/m 才是保留原值,用错会读到脏数据;且这些指令需 +sve,在纯 v8.0 核上不存在。whilelt p0.s, x, x 按「索引 < 上界」生成循环谓词、自动掩掉越界的尾部通道,配 incw/incb 步进,就省掉了单独的标量收尾循环。ARM 是弱内存序架构:硬件可大幅重排访存,需用获取/释放语义和屏障约束。
原子原语
- LL/SC:
ldxr/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,全序,旧值→w2ldr/str 之间没有顺序保证。LSE 原子需 ARMv8.1+(缺 +lse 会报 requires: lse),要兼容老核心仍得回退到 LL/SC;且 ldxr 与 stxr 之间别夹访存或分支,否则独占监视器可能被清、stxr 永远失败。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 #0X8 而非参数寄存器,放错寄存器就调错号;svc 的立即数 #0 不是调用号、几乎总为 0。系统调用号还随架构而异(AArch64 里 write=64、exit=93,与 x86-64 不同),别照抄 x86 的号。X8(不是 x86-64 的 rax),参数用 X0–X5,svc #0 后返回值在 X0;失败时 X0 是 -errno(负数),判负即出错。按功能分类的 ARM64 常用指令一览,配合前面各卡当作速查索引使用(与 x86 章速查卡同构,方便对照)。
速查表
| 类别 | 指令 | 作用 |
|---|---|---|
| 传送 | mov / movz / movk / movn | 寄存器复制 / 分段装立即数 |
| 访存 | ldr str ldrb ldrsw ldp stp | 加载 / 存储 / 成对存取 |
| 取地址 | adr adrp | PC 相对 / 页地址 |
| 算术 | 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 | 陷入内核 |
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。sudo apt install qemu-user gcc-aarch64-linux-gnu 之后按上面的命令交叉汇编,qemu-aarch64 ./hi 直接运行;qemu-aarch64 -g 1234 ./hi 配 gdb-multiarch 还能单步观察。只想读不想跑,godbolt 选 ARM64 gcc 即可。factorial 只碰寄存器;这两段走内存,展示 load-store 架构下循环 + 指针的写法。strlen 用 ldrb 逐字节读、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
retldr 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 #2。cbz/b.ge 体现 ARM 把常见比较直接编进分支。上手章的第二个程序(把命令行参数各打一行)值得在每个架构上重写一遍:骨架不变——栈上取 argc/argv、外层遍历指针数组、内层扫长度、两次 write——变的全是本架构的性格。右侧是 ARM64 版,qemu 跑通;逐段和 x86 版对照,比背对照表牢固得多。
与 x86 版逐点对照
| 做什么 | x86-64 版 | ARM64 版 |
|---|---|---|
| 取 argc | mov r12, [rsp] | ldr x19, [sp](sp 不能当通用寄存器乱用,但作基址访存没问题) |
| 逐字节看 | cmp byte ptr [rsi+rdx], 0——访存 + 比较一条完成 | ldrb w3, [x1, x2] 先取进寄存器,再 cbz——load-store 架构不许运算碰内存 |
| 判 0 跳转 | cmp 设标志 + je | cbz 一条搞定:判零并跳,不经过标志位 |
| 取数据地址 | lea rsi, [rip + nl] 一条 | adrp 页基址 + add :lo12: 页内偏移,两条拼(本章立即数与地址卡) |
| 系统调用 | 号进 rax,syscall;write=1 | 号进 x8,svc #0;write=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 1234 配 gdb-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 #0svc 执行的是完全不同的调用——轻则返回 -ENOSYS/参数不合法报错,重则语义整个错位还不报错。号表以 asm-generic/unistd.h(arm64/riscv 共用)为准。syscall 指令借它们保存返回现场的机制使然,不是通例。所以这一版连「跨 syscall 保值」都不用刻意安排。RISC-V (RV64) 深入
开源、模块化、极致精简的 RISC。无标志位、无专用栈指令、靠伪指令补足便利。本章覆盖寄存器与 ABI、六种编码格式、扩展体系、整数/乘除/原子/浮点/向量指令、压缩指令、立即数与地址、控制转移、CSR 与特权架构、调用约定、内存模型与系统调用,末尾把上手章的 argv 程序搬过来动手重写。以 64 位 RV64 为准。
32 个通用寄存器 x0–x31。汇编里几乎总用 ABI 别名(a0、sp、ra),更易读。
寄存器表
| 编号 | ABI | 角色 | 保存方 |
|---|---|---|---|
| x0 | zero | 恒为 0(读0写弃) | — |
| x1 | ra | 返回地址 | caller |
| x2 | sp | 栈指针 | callee |
| x3 / x4 | gp / tp | 全局 / 线程指针 | 不适用 * |
| x5–x7 | t0–t2 | 临时 | caller |
| x8 | s0 / fp | 保存 / 帧指针 | callee |
| x9 | s1 | 保存 | callee |
| x10–x17 | a0–a7 | 参数 (a0,a1 兼返回值) | caller |
| x18–x27 | s2–s11 | 保存 | callee |
| x28–x31 | t3–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 都不会报错,只会悄悄丢数据。t*(临时) 与 a*(参数) 是 caller-saved,s*(saved) 与 sp 是 callee-saved——字母 s 就是「saved by callee」。s0 同时是帧指针 fp。基础指令定长 32 位,只有 6 种格式,字段位置高度固定。
格式
| 格式 | 用途 | 示例 |
|---|---|---|
| R | 寄存器-寄存器 | add, sub, sll |
| I | 立即数 / 加载 / jalr | addi, 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/移位拼。rs1/rs2/rd 字段在所有格式里位置固定。RISC-V = 「一个精简基础 + 一组可选扩展」,芯片按需组合,名字直接拼出来。
基础与扩展
| 记号 | 含义 |
|---|---|
| RV32I / RV64I | 32/64 位整数基础(RV32E 为 16 寄存器嵌入版) |
| M / A | 乘除 / 原子 |
| F / D / Q | 单 / 双 / 四精度浮点 |
| C | 压缩指令(16 位) |
| B | 位操作(Zba+Zbb+Zbs) |
| V | 向量 |
| Zicsr / Zifencei | CSR 访问 / 取指屏障 |
| G | = IMAFD + Zicsr + Zifencei |
命名
RV64GC= 64 位 + 通用 + 压缩(Linux 应用处理器常见);RV32IMAC(嵌入式常见)。- 应用类还有 RVA22/RVA23 等 profile 规定必备扩展集。
RV64GC 编出的带浮点/压缩指令的二进制,放到只有 RV64IMAC(无 F/D)的核上会触发非法指令异常。用 -march 明确目标,别假设 F/D/V 都在。RV64GC 拆开就是 RV64 + G(IMAFD+Zicsr+Zifencei) + C。目标平台上查 /proc/cpuinfo 的 isa 行即可确认实际扩展。基础整数指令集 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(有符号)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 x0、ret=jalr x0,ra,0、call=auipc+jalr、bgt=交换操作数的blt(伪指令)。
blt a0, a1, less # if(a0 < a1) goto less
call func # auipc ra,…; jalr ra,…
ret # jalr x0, ra, 0beq 等 B 型只有 ±4KB,jal 只有 ±1MB。但超程汇编器不报错:条件分支被静默松弛成反转分支+长跳(见 core 章链接卡);jal 超 ±1MB 也静默通过,到链接期才由 ld 报 relocation truncated to fit: R_RISCV_JAL——那时得改用 call(auipc+jalr)这类能覆盖全地址空间的序列。jal/jalr 的伪指令包装——ret 就是 jalr x0, ra, 0、j 就是 jal x0、call 是 auipc+jalr。读反汇编时对应回去就不会迷路。RISC-V 的便利性大量来自伪指令——汇编器展开成真实指令,让基础指令集保持极简。
常见伪指令
| 伪指令 | 展开 | 含义 |
|---|---|---|
| li rd, imm | lui + addi | 加载立即数 |
| la rd, sym | auipc + addi | 取地址 |
| mv rd, rs | addi rd, rs, 0 | 寄存器复制 |
| nop | addi x0, x0, 0 | 空操作 |
| not / neg | xori,-1 / sub x0 | 取反 / 取负 |
| j / ret / call | jal x0 / jalr / auipc+jalr | 跳 / 返回 / 调用 |
| beqz / bnez | beq/bne …, x0 | 与零比较跳 |
| seqz / snez | sltiu / sltu | 是否为零置位 |
大量伪指令都借助 x0 (zero)——这正是零寄存器存在的意义。
li 视立即数可展开成 1~6 条(见「立即数」卡),call/tail 还会插入重定位。写汇编时别假设「一条伪指令 = 一条机器指令」,指令计数与 PC 偏移要以真实展开为准。x0:nop=addi x0,x0,0、j=jal x0、beqz=beq …,x0、mv=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 除零返回 -1、rem 返回被除数、溢出商为 MIN。这与 x86 div 触发 #DE 异常正相反,除数为零必须靠软件自己先判。mul(取低 64)+ mulh/mulhu/mulhsu(取高 64)——按两个操作数的符号性选对 mulh 变体,混用会得到错误的高位。A 扩展提供原子读-改-写:一套 LR/SC(保留加载 + 条件存储)用来构造 CAS,一套 AMO 单条指令完成常见原子运算。
两套机制
- LR/SC:
lr.w/lr.d(load-reserved) +sc.w/sc.d(store-conditional),sc 成功返回 0、失败非 0,循环重试构造 CAS。 - AMO:
amoswap/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, retrysc 永远失败甚至活锁。此外裸 AMO 是宽松序,需要获取/释放语义时别漏加 .aq/.rl 后缀。sc(store-conditional)成功返回 0、失败返回非 0,与「0 为假」的直觉相反——CAS 自旋要用 bnez 判失败后重试。F/D 扩展带来独立的 f0–f31 浮点寄存器与单/双精度运算;比较结果仍写回整数寄存器,延续「无标志」哲学。
运算
- 独立浮点寄存器
f0–f31。fadd/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域或frmCSR 指定(rne/rtz/rdn/rup/rmm);异常累积在fflags。窄值在宽寄存器里以 NaN-boxing 存放。
fadd.d fa0, fa1, fa2
flt.s a0, fa1, fa2 # a0 = (fa1 < fa2),结果进整数寄存器.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兼作掩码寄存器。- vtype:
vsetvli设定元素宽度e8/e16/e32/e64与寄存器组合 LMUL(m1…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 # 还有剩余就继续VLEN/vl 的具体值——手写常量步进(如「每轮固定 4 个元素」)会在别的向量宽度实现上算错,步进必须用 vsetvli 返回的实际 vl。另外 v0 兼作掩码寄存器,用掩码时别把数据占用到 v0。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/stvec、mepc/sepc、mcause/mtval、中断mie/mip、分页satp。 ecall(环境调用) /ebreak(断点) 触发陷入,mret/sret返回,wfi等待中断。- 「无标志」的延伸:处理器状态都显式放在寄存器/CSR 里,没有隐藏的全局标志。
cycle/time/instret),系统 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
retsd 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
ecallfence 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 | 陷入内核 / 断点 |
addi/lw 的偏移超了就得先 lui/li 拼进寄存器再用。另外没有 subi——写 addi rd, rs, -n;没有独立 cmp——比较并入分支或用 slt 族。从 x86/ARM 带来的「这条指令应该存在」直觉,在 RISC-V 上要先过一遍「基础集真有吗」的怀疑。-M no-aliases:mv/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
retwrite 的长度参数(例中 li a2, 14)必须和字符串真实字节数对上——"Hello, World!\n" 恰好 14;数错会截断输出或读越界。.string 自动补的 \0 不计入 write 长度;用 _start 而非 main 时还得自己 ecall 退出,否则返回到无效地址会崩。sudo apt install qemu-user gcc-riscv64-linux-gnu 后 qemu-riscv64 ./hi 直接跑,配 -g 1234 + gdb-multiarch 可单步。想更进一步,写一个自己的 RV32I 模拟器只要几百行——见末章路线图。factorial 只碰寄存器;这两段走内存。RISC-V 最纯粹:没有变址寻址,取 a[i] 要显式三步——slli 算偏移 → add 加基址 → ld 访存。strlen 用 lbu 读字节、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
reta[i] 必须显式「slli 算偏移 + add 加基址」——移位量要与元素大小对齐(long 8 字节用 slli …, 3、int 用 2)。照搬 strlen 的字节步进(+1)到宽元素数组会逐字节乱走、读到错位数据。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 | 号进 a7,ecall;write=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_start 里没问题——没有调用者,callee-saved 的义务无从谈起。但若把它改造成被 C 调用的函数(上手章混编卡的路线),s 寄存器必须先存后用、用完恢复,否则悄悄毁掉调用者的值——调用约定卡的规矩从「进了函数」那一刻起立刻生效。写汇编:数据、标签与混合编程
上手章跑通的是一段现成代码。这一章把「自己写」的地基铺上:汇编器提供的数据与标签设施、一个真正读输入的程序、与 C 双向调用的混合编程、嵌进 C 的内联汇编。它排在三个架构章之后是有原因的——混合编程绕不开寄存器分工与调用约定,得先认得一种架构,才谈得上把自己的代码和别人的接上。示例统一用 x86-64 GAS,ARM64/RISC-V 的对应写法见各自章的「完整程序示例」卡。
hello.s 只用到了 .ascii 一个数据指令。要写更大的程序,得把汇编器给你的「脚手架」认全:数据怎么定义、缓冲区放哪、标签怎么起名。这套设施属于 GAS(GNU 汇编器),三个架构通用。
数据定义全家
| 指令 | 占多大 | 说明 |
|---|---|---|
.byte 0x41 | 1 字节 | 单字节 |
.word / .long / .quad | 2 / 4 / 8 字节(x86) | 整数 |
.ascii "hi" | 按内容 | 字符串,不补结尾 0 |
.asciz "hi"(= .string) | 内容 + 1 | 自动补结尾 0 |
.skip 64(= .zero) | 64 字节 | 填 0 的空间 |
LEN = 3(= .equ LEN, 3) | 0 字节 | 汇编期常量,不进内存 |
- 注意
.word的宽度跟着各架构对「word」的定义走:x86 上 2 字节、ARM64 / RISC-V 上 4 字节(llvm-mc 三个 triple 各汇编一次可验:0700vs07000000)——就是 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 的幂),把歧义扼杀在拼写里。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_sum,nasm -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 驱动。.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");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-64 | ARM64 | RISC-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)。
slli/add/lw)都是单周期简单指令,流水线上未必更慢;x86 那条复合寻址的 mov 背后也要拆成多个 μop。条数体现的是「ISA 表达力」,不是「快慢」。-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/r12、x19-x21、s0-s2——它们要跨 call 存活,而 caller-saved 寄存器会被 square 随意破坏。两类寄存器的分工(core 章 ABI 卡)在这里落地。 - 连循环方向都是编译器的自由:x86 版向上数到 n,ARM64 版被 clang 改成倒计数(
subs顺便置标志,省一条 cmp),RISC-V 版向上数到 n+1。逻辑等价,形态随后端习惯。
三种架构给同一个「角色」起了不同的名字——认名字之前先认角色,映射建立起来,陌生汇编就不再陌生。
对照表
| 角色 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
| 第1参数 | rdi | x0 | a0 |
| 第2参数 | rsi | x1 | a1 |
| 返回值 | rax | x0 | a0 |
| 栈指针 | rsp | sp | sp (x2) |
| 帧指针 | rbp | x29 (fp) | s0/fp (x8) |
| 返回地址 | (在栈上) | x30 (lr) | ra (x1) |
| 程序计数器 | rip | pc | pc |
| 零寄存器 | 无 | xzr/wzr | zero (x0) |
| GPR 数量 | 16 | 31 (+zr) | 32 (含 x0) |
rax/rdi 套到 ARM/RISC-V;或把 RISC-V 的 x0(zero)、ARM 的 xzr 当普通寄存器——它们恒为 0,写入被直接丢弃。还有 x86 返回地址压在栈上,而 ARM/RISC-V 存在 lr/ra 寄存器里,读栈帧时别找错地方。rdi ↔ ARM x0 ↔ RV a0)。注意 ARM/RISC-V 的第一参数和返回值同用一个寄存器(x0/a0),而 x86 是 rdi 传入、rax 返回,两个不同寄存器。同一件事三家指令名不同,套路却一致;学会一家的「操作分类」,另两家按图索骥即可。
对照表
| 操作 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
| 寄存器复制 | mov | mov | mv (伪) |
| 加 | add | add | add / addi |
| 从内存加载 | mov r,[m] | ldr | lw / ld |
| 存到内存 | mov [m],r | str | sw / sd |
| 比较 | cmp | cmp | (并入分支) |
| 相等则跳 | cmp+je | cmp+b.eq | beq |
| 无条件跳 | jmp | b | j (伪) |
| 函数调用 | call | bl | jal / call |
| 返回 | ret | ret | ret (伪) |
| 压栈 | push | stp …[sp,#-16]! | addi sp+sd |
| 原子加 | lock xadd | ldaddal (LSE) | amoadd.w |
| 系统调用 | syscall | svc #0 | ecall |
cmp。RISC-V 既无比较指令也无条件码,比较直接并入分支:beq/blt 一条完成「比较+跳转」。x86/ARM 才是「先 cmp 置标志、再条件跳」两步。把 x86 的 cmp+je 硬套到 RISC-V 会找不到对应指令——cmp a0, a1 在 riscv64 上直接报「unrecognized instruction」。mv/j/ret 都是伪指令,由汇编器翻译成真实指令——mv a0, a1 实际编码成 addi a0, a1, 0。读反汇编时看到 addi rd, rs, 0 就要认出它其实是一次寄存器复制。指令和寄存器只是表象,三家真正的分野在设计哲学——CISC 还是 RISC、强序还是弱序、有没有条件码。
对照表
| 维度 | x86-64 | ARM64 | RISC-V |
|---|---|---|---|
| 类型 | CISC | RISC | RISC |
| 指令长度 | 变长 1–15 字节 | 定长 4 字节 | 定长 4 (压缩 2) |
| 访存 | 算术可直接访存 | load-store | load-store |
| 标志位 | 有 (RFLAGS) | 有 (NZCV) | 无 |
| 返回地址 | 压栈 | LR 寄存器 | ra 寄存器 |
| 内存序 | 强序 (TSO) | 弱序 | 弱序 (RVWMO) |
| 寻址能力 | 极强 | 中等 | 最简 |
| 授权 | 专有 | 专有(授权) | 开源免费 |
| 主要场景 | 桌面/服务器 | 移动/苹果/云 | 嵌入/教学/未来 |
| 学习难度 | 高(包袱重) | 中 | 低(最规整) |
从这里到精通:路线图
读汇编的地图铺完了,剩下的路要亲手读写才能走通。最后这一章给出收尾路线:难度递进的练习路径、按阶段的资料清单,以及一条自测标准。
对照表看完只是「认识」了三种汇编,精通要靠亲手读写。下面的路径按难度递进,每一步都有明确产出。
练习路径(难度递进)
- ① 读编译器的汇编:每天把一个自己写的小函数丢进 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 逐个验证。