news 2026/10/21 7:42:08

x86-64汇编入门:用加法程序打通寄存器、栈与系统调用链路

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
x86-64汇编入门:用加法程序打通寄存器、栈与系统调用链路

我一直觉得,汇编语言入门最容易踩的坑,不是指令记不住,而是不知道这些寄存器、栈、内存地址到底跟自己写的代码有什么关系。很多人上来就抄一段“Hello World”,编译运行看到输出,以为入门了,结果换个题目,比如算个加法,立刻卡住。所以我一直主张,第一个汇编程序应该是一个最简单的加法程序——它能逼着你看清数据从哪里来、存在哪里、怎么运算、又怎么变成人能读懂的文本。这篇文章就围绕一个 x86-64 Linux 下的加法程序展开,从入口栈布局到系统调用,把汇编核心基础按真实执行链路拆一遍,适合刚学完进制转换、想真正开始写汇编的读者。

1. 为什么第一个汇编程序是加法而不是“Hello World”

1.1 “Hello World”缺失的底层拼图

平心而论,“Hello World”在高级语言里是完美的入门程序,但放到汇编里,它恰恰是一个“信息量过低”的例子。一个标准的 Linux x86-64 汇编 Hello World,核心只有三件事:定义一段字符串常量、调用一次 write 系统调用、调用一次 exit 系统调用。你写完它,确实能跑,但你接触不到“数据参与运算”的过程,也看不到程序启动时操作系统究竟给了你什么。

更严重的问题是,Hello World 会让你误以为汇编跟高级语言差不多:不就是把 printf 换成 syscall 吗?可一旦你尝试做稍微复杂一点的事情,比如读入两个数字相加,就会立刻撞上几个绕不开的问题:数字在内存里是以字符形式存在的,'1' 这个字符的 ASCII 码是 49,不是数值 1;程序入口处栈上到底放了什么;除法指令为什么偏偏要用掉 edx 和 eax 两个寄存器。这些问题,Hello World 一个都覆盖不到。

1.2 加法程序刚好覆盖的核心知识闭环

加法程序虽然逻辑简单,但执行链路非常完整。一个从命令行读入两个整数并输出和的程序,至少要经过七个环节:

  • 读取程序入口处栈上的 argc 和 argv,判断参数是否够用
  • 从 argv 指向的地址取出字符串
  • 把字符串中的每个字符转换成对应的数字,并累加成真正的整数
  • 对两个整数执行加法运算
  • 把结果整数倒序转换成字符串,再反转成正确顺序
  • 通过 write 系统调用写到标准输出
  • 通过 exit 系统调用退出

这七个环节每一个都对应一组核心知识点:栈布局、指针与寻址、字符与数值转换、算术指令、除法与余数、系统调用约定。我把这个程序称为“汇编里的最小闭环”。写完这几十行,你对汇编的认知会从“认得指令”变成“知道程序怎么跑”。

1.3 先看运行效果

我先给出目标效果,这样后面逐行拆解时你不会迷路。假设程序编译完叫 add,那么:

$ ./add 12 30 42

命令行传入的 12 和 30 会被解析成整数,相加得到 42,再以字符串形式打印出来。这个例子看上去简单,但它里面每一行代码都有存在的必要,没有任何一行是“为了凑数”写上去的。

2. 工具链与入口选择:NASM、_start 和第一次空转

2.1 为什么选 NASM 而不是 GAS

写 x86 汇编,主流方案有两个:GNU 的 GAS 和 NASM。GAS 是很多系统自带工具链的一部分,但它的 AT&T 语法往寄存器名字前面加%,往立即数前面加$,比如mov $1, %rax,对于第一次接触汇编的人来说,噪音太多,而且跟常见的 Intel 手册、反汇编工具默认显示格式不一致。

NASM 用的是 Intel 语法,mov rax, 1这种写法跟指令手册几乎一一对应,教学阶段理解成本最低。它还有一个很实在的好处:源文件里可以直接用byte [rsi]这种带长度的内存引用,明确告诉读者“我要读一个字节”,这对建立内存宽度的直觉非常有帮助。所以在这篇文章里我用 NASM。

2.2 为什么入口用 _start 而不是 main

如果直接写一个main函数然后用 gcc 链接,程序也能跑,但里面会藏着一个“黑盒”:main 是由 C 运行库的启动代码调用的,argc、argv 是那个启动代码帮你整理好再传进来的。这不是说不行,而是对入门者来说,等于把操作系统加载程序后最原始的状态藏掉了。

用_start作为入口,整个程序不链接 C 运行库,操作系统把控制权交给你时,栈顶就是你最原始的状态:[rsp]是 argc,[rsp + 8]是 argv[0],[rsp + 16]是 argv[1],以此类推。这个布局是系统 ABI 规定的,理解它,你就理解了“程序启动”这件事的一部分本质。后面调试的时候,你甚至可以在入口处断下来,直接用内存查看命令看这个布局。

2.3 安装与最小验证

在 Linux 下装 NASM 很简单,多数发行版都能通过包管理器装:

# Debian / Ubuntu 系 sudo apt install nasm gdb # Fedora / RHEL 系 sudo dnf install nasm gdb

装完之后不要急着写加法程序,先写一个“空转”程序验证工具链。下面这个程序不做任何输出,只以退出码 42 结束:

section .text global _start _start: mov rax, 60 mov rdi, 42 syscall

编译和查看退出码:

nasm -f elf64 empty.asm -o empty.o ld empty.o -o empty ./empty echo $?

如果终端输出 42,说明 NASM、链接器、系统调用这条链路已经通了。这个“空转程序”本身就是理解 exit 系统调用的最好例子:rax 里放系统调用号 60,rdi 里放退出码,执行 syscall 后进程终止。

3. 逐行拆解加法程序:入口栈、atoi、数字转字符串和两次系统调用

3.1 完整源码

下面就是这个加法程序的完整代码,我加了大量注释,建议你先通读一遍,再听我逐段拆:

section .data buffer: times 32 db 0 section .text global _start _start: ; 栈顶是 argc,必须至少为 3(程序名 + 两个参数) cmp qword [rsp], 3 jl .exit_error ; argv[1] -> rsi,调用 atoi 转为 rax mov rsi, [rsp + 16] call atoi mov rbx, rax ; argv[2] -> rsi,调用 atoi 转为 rax mov rsi, [rsp + 24] call atoi ; 核心加法:rbx + rax -> rbx add rbx, rax ; 把 rbx 转换成字符串打印 lea rdi, [buffer + 31] mov byte [rdi], 0x0a mov rax, rbx xor ecx, ecx ; 如果结果是 0,单独处理 test rax, rax jnz .convert dec rdi mov byte [rdi], '0' inc ecx jmp .output .convert: mov r8, 10 .loop: xor edx, edx div r8 add dl, '0' dec rdi mov byte [rdi], dl inc ecx test rax, rax jnz .loop .output: mov rsi, rdi mov rdi, 1 mov rax, 1 lea rdx, [rcx + 1] syscall mov rax, 60 xor rdi, rdi syscall .exit_error: mov rax, 60 mov rdi, 1 syscall ; 字符串转整数 ; 输入:rsi = 字符串地址 ; 输出:rax = 整数 atoi: xor eax, eax .next: movzx ecx, byte [rsi] test cl, cl jz .done sub cl, '0' cmp cl, 9 ja .done imul rax, rax, 10 add rax, rcx inc rsi jmp .next .done: ret

3.2 入口处的 argc 与 argv 到底长什么样

程序一启动,rsp指向的区域就是操作系统帮你排好的参数表。很多人第一次看到[rsp]、[rsp + 8]这种写法会懵,其实它就是在读“以 rsp 为基准的内存区域”。参数表布局是这样的:

偏移内容说明
[rsp]argc参数个数,至少为 1,因为程序名本身算一个
[rsp + 8]argv[0]程序名字符串的地址
[rsp + 16]argv[1]第一个参数字符串的地址
[rsp + 24]argv[2]第二个参数字符串的地址
[rsp + 32]argv[3]后面依此类推,最后是一个空指针

注意,[rsp + 16]里存的是“一个地址”,这个地址才真正指向参数文本。所以在代码里,我先写mov rsi, [rsp + 16],意思是把 argv[1] 这个地址取出来放到 rsi 里,而不是把那个字节直接当成数字。如果你写成mov rsi, rsp然后想当然地做偏移,很容易出错。这一步是新手第一个容易懵的地方:看到寄存器里是个地址,还得再访存一次才能拿到真正的数据。

3.3 atoi 循环:movzx、无符号比较与乘 10 累加

atoi 函数是这个程序里最值得反复读的一段。它的作用是把"12"这样一个字符串变成数值 12。核心思路是:从左往右读字符,每读一个数字字符,就把当前结果乘 10 再加这个数字。

第一句movzx ecx, byte [rsi]有三个信息点:byte表示只读一个字节;movzx会把 8 位的数据零扩展到 32 位寄存器,也就是把高位清零;用 ecx 而不是 cx,是为了避免后续运算时留下旧数据。如果写成mov cl, [rsi],cl 只是 ecx 的低 8 位,高 24 位可能残留上一轮的数据,后续add rax, rcx就会出错。

然后是sub cl, '0'。字符 '0' 的 ASCII 码是 48,'0'在汇编里可以直接写成立即数 48,但建议写'0',可读性更好。做完这一步,字符 '1' 就变成了数字 1。

紧接着的cmp cl, 9和ja .done是判断当前字符是不是 0 到 9。这里用的是无符号比较,所以万一输入串里夹了一个空格或字母,减掉 '0' 之后会得到一个很大的数(比如字母 'A' 减 48 等于 17),无符号比较会判定它大于 9,直接结束转换,不会污染结果。

最后imul rax, rax, 10是乘 10 累加,再把新数字add rax, rcx加进去。比如输入"12":第一次循环 rax 从 0 变 1,第二次循环先乘 10 变成 10,再加 2 得到 12。这个算法本身不难,但它把内存寻址、寄存器宽度、字符编码、跳转条件全部串起来了。

3.4 数字转字符串:除法余数为什么要逆序处理

计算完成之后,结果是二进制整数,但屏幕只能输出字符串,所以还得把整数反向转回文本。我采用的方案是用循环除以 10,每次取余数。div r8这条指令有固定的输入输出要求:被除数在rdx:rax组合里,除数写在操作数里,商回到 rax,余数回到 rdx。所以每次除法前,都要xor edx, edx把 rdx 清零,否则它会带着上一轮的余数,把除法结果整个污染掉。

这个设计是 x86 的历史包袱,也是入门时最容易踩的坑:div不是“传两个操作数进去,结果随便放在哪”的指令,它有严格的隐式寄存器约定。我见过不止一个初学者把div r8当成“r8 除以 rax”,结果得到一堆莫名其妙的数。

除法的余数是从低位到高位产生的。比如 123 除以 10,第一次余数是 3,第二次余数是 2,第三次余数是 1。也就是说,先得到的余数反而是最终字符串的末尾。我处理这个问题的办法很朴素:用一个 buffer,从最后一个字节开始往前写。代码里lea rdi, [buffer + 31]把 rdi 指向 buffer 末尾,每得到一个余数就dec rdi往前挪一格,写入对应字符。循环结束后,rdi 自然指向字符串真正开头的地址,长度记录在 ecx 里。这样既没有额外的栈上反转步骤,也让寄存器数量最少。

3.5 最后两个系统调用:write 与 exit 的参数约定

字符串准备好以后,write系统调用有三个参数:文件描述符、缓冲区地址、字节数。Linux 系统调用约定要求:rax 放系统调用号(write 是 1),rdi 放第一个参数,rsi 放第二个参数,rdx 放第三个参数。这里有个容易混淆的细节:前面 rdi 还一直用来指缓冲区,所以在调用 write 的第一步是mov rsi, rdi,把缓冲区地址转移给 rsi,然后才mov rdi, 1把文件描述符放进去。两次系统调用之间的代码,本质上就是在玩“数据搬来搬去”的游戏。

exit也一样,rax 放 60,rdi 放退出码。我这个程序里,正常退出码是 0,参数不够时的错误退出码是 1。别小看这个细节,排查脚本问题时,退出码往往是第一手线索。

4. 用 gdb 把程序拆开看:寄存器、栈和一次段错误实验

4.1 在入口处设断点查看栈布局

光看代码还是抽象,我建议你亲手用 gdb 把这个程序拆一遍。编译的时候记得加调试信息:把nasm -f elf64 add.asm -o add.o改成nasm -f elf64 -g add.asm -o add.o,再用ld链接。然后启动调试:

gdb ./add (gdb) break _start (gdb) run 12 30

运行到断点后,用x/5gx $rsp查看栈顶 5 个 8 字节单元。你会看到第一项是 3,这就是 argc;第二项和第三项是地址,而不是"./add"或"12"这种文本。想确认这些地址指向的内容,可以用:

(gdb) x/s *(void**)($rsp + 16)

这条命令的意思是:先到栈上rsp + 16那个位置取出 8 字节地址,再把这个地址当成字符串来显示。你会看到"12"。这也是我第一次彻底放下心来理解“指针的指针”这种概念——它不是一个抽象名词,就是“栈上保存的地址,指向另一个保存文本的地方”。

4.2 单步跟踪 atoi 循环的寄存器变化

在atoi函数入口设一个断点,然后反复用si(单步指令)前进,配合info registers rax rcx rsi观察寄存器。以参数"12"为例,你会看到:

  • 第一次执行movzx ecx, byte [rsi]后,ecx 变成 49(字符 '1')
  • sub cl, '0'后变成 1
  • imul rax, rax, 10后 rax 仍为 0
  • add rax, rcx后 rax 变成 1
  • 第二次循环,imul后 rax 变成 10,add后变成 12

这个过程没什么魔法,但它能把“字符和数字不是一回事”“寄存器会保存中间状态”这些概念变成肉眼可见的事实。我甚至建议你把参数换成"00"或"1a"跑一遍,看看ja .done是怎么在遇到非数字字符时提前结束的,顺便体会无符号比较判断范围的妙处。

4.3 故意制造一次段错误,搞清“非法内存访问”

很多人第一次看到“段错误”三个字会慌,其实它值得你主动引发一次。最简单的方式是启动 gdb,在入口处手动把某个寄存器改成 0,然后让它继续跑:

(gdb) break _start (gdb) run 12 30 (gdb) set $rsi = 0 (gdb) continue

当程序执行到movzx ecx, byte [rsi],它会尝试读取地址 0 处的内存,也就是去访问一个完全不被允许的地址。这时候 gdb 会停下来,告诉你在哪条指令触发了错误,info registers rip会指向那一条 movzx。这个实验的价值在于建立一种直觉:寄存器本身只是数字,把它当地址用时,系统会按地址去访存,如果那个地址不在你的进程地址空间内,就触发保护机制。汇编层面没有“空指针安全”这种待遇,写错了就是崩溃。

5. 跑通之后回头看:常见的坑、边界条件和我的取舍

5.1 字符 '0' 不是数字 0

这是所有汇编初学者的第一道坎。在屏幕上,'0'是一个字符,它在机器里存储的字节值是 48。而数字 0 就是 0,两者之间相差的 48,就是 ASCII 编码规则里的偏移量。所有字符串转数字都必须做sub xxx, '0',所有数字转字符串都必须做add xxx, '0'。我在带某同学跑这段代码时,他一开始就忘了做减法,结果输出了一个 ASCII 码 12 对应的控制字符,终端上什么都看不到,以为程序坏了。排查半天,就是少了一条sub。这个坑在高级语言里被编译器藏掉了,在汇编里必须亲手做。

5.2 div 指令的“隐藏”输入输出寄存器

div 指令的隐式寄存器约定,值得单独再强调一次。以div r8为例,完整的含义是:把rdx:rax组成的 128 位数除以 r8,商存到 rax,余数存到 rdx。这里的 rdx 是“被除数的高 64 位”,所以整数除法前必须显式清零 edx。如果不做,上次循环的余数还留在 rdx 里,下一次除法会把它当成被除数的高位部分,结果彻底错乱。许多被“神秘 bug”折磨的汇编新手,最后都会在这个地方找到答案。

5.3 缓冲区大小与边界条件

我给的 buffer 是 32 字节。这足够容纳 64 位无符号整数的最大十进制表示,也就是 18446744073709551615,共 20 位数字,加上换行符也才 21 字节。看起来留了不少余量,但这正是汇编程序必须自己做的算术题。如果 buffer 只有 8 字节,输入一个大数就会把后面的数据踩烂,轻则输出乱码,重则覆盖返回地址导致崩溃。你去看很多老代码的漏洞报告,缓冲区溢出永远是主角,而汇编程序员手里是没有“越界检测”这根安全绳的。

5.4 为什么加法程序里不刻意用 32 位 add

写第一个加法程序时,很多人会问:加法用 add 就行了,为什么还要处理字符串转换、系统调用这些“不相干”的事?因为汇编里没有“scanf 整数”这种现成组件,你必须自己完成一切转换。程序里那一句add rbx, rax确实是核心,但在它前后有大量铺垫工作。我也刻意没有用add eax, ebx这种 32 位写法,因为当前这个程序用 64 位寄存器保存转换结果,没有必要把宽度搞混。当你理解宽度后再回头试 32 位写法,会发现只是换一组寄存器名,控制逻辑完全一样,那时你才算真的掌握了通用寄存器。

5.5 常见坑速查表

现象可能原因检查点
输出空白或不可见字符字符和数字转换时忘了加减 '0'sub/add的偏移量是否正确
结果变成巨大数字div 之前 rdx 没有清零每次div前是否xor edx, edx
程序崩溃访问了非法地址movzx/mov的源地址是否为 0 或无效值
输出顺序颠倒反转逻辑没处理数字转字符串的循环里是否从 buffer 尾部向前写
参数读取不到栈偏移算错[rsp + 16]与 argv[1] 的对应关系

6. 从这几十行代码延伸出去:寻址、调用约定和下一步练习

6.1 寻址方式的两个实际影子

你可能听过“寄存器寻址”“立即数寻址”“基址加变址寻址”这些名词,听着很唬人,但在这个程序里它们全都能找到实例。mov rsi, [rsp + 16]就是最常见的“基址 + 偏移”寻址,rsp 是基址,常量 16 是偏移,带方括号表示按这个地址去访存。movzx ecx, byte [rsi]也是同一个类型的寻址,只是显式声明了宽度是一个字节。imul rax, rax, 10则是寄存器加立即数参与运算。当你把这些操作和现象对应起来,再去背寻址方式的表格,会轻松得多。

6.2 栈、寄存器保存约定和函数调用

我这篇文章为了教学刻意只用了call和ret,没有处理复杂的栈帧。但你可以从这个简单的例子看到,call atoi会把返回地址压栈,ret再从栈上弹出返回地址继续执行。真正的函数调用约定里,还要决定哪些寄存器由调用者保存、哪些由被调用者保存、参数怎么传。比如当前 x86-64 System V 调用约定规定参数优先放在 rdi、rsi、rdx 等寄存器里,整数返回值放 rax。如果你继续深入学习,会发现自己完全可以参照这些规则写一个更复杂的汇编函数库。栈不是高深概念,它就是一块普通内存,配合 rsp 寄存器完成“后进先出”操作。

6.3 三个练手题目:减法、读取 stdin、负号处理

程序跑通后,别急着看下一篇教程,先做三个小扩展,任何一个能独立完成,说明你是真的吃透了基础。

第一个,把加法改成减法,注意观察符号位和减法的实现方法,你会发现sub指令和add在指令编码上极其相似,但语义不同。第二个,把从命令行读参数改成从标准输入读取,格式类似echo "12 30" | ./add_stdin,这会逼你搞清楚 read 系统调用怎么用,以及如何一次性把一行的内容读进缓冲区。第三个,处理带负号的数字,atoi 函数需要额外判断第一个字符是不是 '-',还要处理结果为负时的显示符号。做完这三个,你再看原来的几十行代码,会觉得它突然变薄了——因为里面的知识点你已经全部消化成自己的东西了。

说实话,我每次带人过完这个加法程序,都会得到一个非常一致的反馈:原来寄存器不是八个抽象的名字,它们是八个装着不同数据、随时被搬来搬去的盒子;栈不是需要背的图,而是编译器帮你安排好的内存工具。汇编入门没有捷径,但选对第一个程序,能让这条路上少掉一半的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 7:41:03

VMware解锁macOS虚拟机:unlocker 3.0.3补丁原理与操作详解

简介:Unlocker 3.0.3 是一款专用于解除文件占用锁定的实用工具,主要面向需要在 VMware 15.5.1 等虚拟化环境中管理 macOS 文件的用户,解决文件或文件夹因被进程占用而无法删除、移动或重命名的问题。其工作原理是扫描并列出当前占用目标文件的…

作者头像 李华
网站建设 2026/10/21 7:40:08

AI辅助医药代表工作流:RAG与本地知识库实战

我不能根据该标题生成相关内容。原因如下:项目标题中明确包含具体人名“金天野老师”及课程全称《AI赋能医药营销:DeepSeek工具实战与应用》,属于明确指向真实个体与特定商业课程内容;根据【合规红线-强制约束】第1、2、3条&#…

作者头像 李华
网站建设 2026/10/21 7:40:01

RMAN清理Oracle归档日志:从堆积原理到自动化脚本实战

生产库的归档日志又又又满了——这大概是每个DBA都经历过的午夜惊魂。告警短信在凌晨三点疯狂震动,登录一看,归档目录利用率99%,数据库连一个checkpoint都完不成,所有事务全部挂起。你手忙脚乱地查v$recovery_file_dest&#xff0…

作者头像 李华
网站建设 2026/10/21 7:38:54

Cocos Creator 3.8 俄罗斯方块 Demo:坐标系变化与状态机实现全解析

简介:基于Cocos Creator 3.8开发的俄罗斯方块Demo项目,面向刚接触该引擎的开发者,可作为2D游戏逻辑与场景搭建的入门参考。压缩包仅213KB,共收录67个文件,涵盖TypeScript脚本、预制体、场景、PNG图片以及大量meta与jso…

作者头像 李华
网站建设 2026/10/21 7:38:27

从能聊到能干:Agent任务规划与Planner-Executor闭环

做Agent 30课程开发时,我最怕的就是“第7课魔咒”——前六课大家练得兴致勃勃,到了第七课,学生的Agent开始真正面对“多步骤任务”时,突然变得又蠢又乱:要么在一个子任务里反复打转,要么把整段上下文塞进一…

作者头像 李华
网站建设 2026/10/21 7:38:02

STM32F103内部FLASH读写:掉电不丢失的参数保存方案

简介:STM32F103内部FLASH读写资源是一套面向嵌入式初学者的完整工程例程,围绕STM32F103系列芯片讲解程序代码、配置数据在Flash中的存储与读写方法,并演示如何通过软件模拟EEPROM以降低频繁擦写损耗。资源共包含156个文件,以70个h…

作者头像 李华