如果你和我一样,最初以为gcc就是那一条把.c文件变成可执行文件的命令,那这篇文章值得看下去。真正进入系统底层之后你会发现,gcc背后是一整套编译工具集,而nasm这个编译器走的又是另一条完全不同的路线——它不碰 C 语言,只做一件事:把人类可读的汇编指令翻译成机器码。这篇内容想把 GCC 编译工具集和 nasm 编译器都讲明白,包括它们各自管什么、怎么协作、以及我在实际使用中踩过哪些坑。
1. GCC不只是gcc命令:工具集的真实组成
很多人会把“GCC”理解成“一个编译器”,这不算错,但不够准确。GCC 的全称是 GNU Compiler Collection,它是一条完整的编译工具链,而你在终端里敲的gcc,只是这条链上的“指挥员”。
1.1 工具链里的每个角色
我习惯把 GCC 工具集比作一个维修车间:gcc是前台接待员,真正干活的是车间里的各个专业工种。平时你只和接待员打交道,但出问题的时候,必须知道该找哪个工种。
| 工具 | 实际作用 | 类比 |
|---|---|---|
gcc | 编译驱动,负责调度其他工具 | 前台/项目经理 |
cpp | 预处理器,展开宏和头文件 | 材料预处理员 |
cc1/cc1plus | 真正的编译器前端,把 C/C++ 转成汇编 | 设计师 |
as | GNU 汇编器,把汇编转成机器码 | 组装工 |
ld | 链接器,把多个目标文件拼成可执行文件 | 总装线 |
ar | 静态库打包工具 | 打包员 |
nm | 查看目标文件中的符号表 | 质检员 |
objdump | 反汇编、查看二进制细节 | X光机 |
readelf | 查看 ELF 文件结构 | 档案管理员 |
strip | 去掉调试符号,缩小体积 | 精简员 |
原则上放进表格的每一个工具都值得单独讲,但这里只挑最核心的——gcc、cpp、cc1、as、ld——给你讲透工作流。
1.2 gcc命令到底在干什么
当你输入gcc -o hello hello.c的时候,gcc本身不参与任何真正意义上的“编译”,它做的只是读取参数、判断语言类型、然后按顺序调用其他程序。想看到这一幕,最直接的办法是加-v参数:
gcc -v hello.c -o hello终端会打出一大段日志,里面能看到它依次调用了cc1、as、collect2(ld的包装器)。我第一次看到这些输出时,才意识到原来“编译”这件事不是一个程序独立完成的。
这个理解为什么重要?因为实际项目里遇到的很多诡异问题,都和“工具链的某个环节没有正常工作”有关:
- 链接找不到函数定义时,问题往往出在
ld那一层,而不是你的语法。 - 宏展开结果不对时,问题往往在
cpp那一层。 - 汇编代码报错时,问题往往在
as那一层。
如果你只会对着.c文件发愁,排查范围就会变得很大;一旦知道哪一层出了问题,定位范围立刻缩小了。
2. 一个源文件到可执行文件:四个阶段与中间产物
GCC 的编译过程大致可以分成四个阶段:预处理、编译、汇编、链接。每个阶段都有对应的命令和产物,我在学习时把中间产物一个个打开看过,理解立刻深了一层。
2.1 预处理:把代码“摊开”
假设有这样一个最简单的文件:
#include <stdio.h> #define VALUE 42 int main() { printf("Hello %d\n", VALUE); return 0; }执行:
gcc -E hello.c -o hello.i-E表示只做预处理。打开hello.i你会发现,#include <stdio.h>被替换成了 stdio.h 的全部内容,宏VALUE被替换成了42。也就是说,预处理阶段做的事就是“无脑展开”,不关心语法对不对,只负责把源文件里的#include、#define、#ifdef等指令处理掉。
2.2 编译:从 C 代码到汇编
继续执行:
gcc -S hello.i -o hello.s这一步会生成一个.s文件,里面是汇编代码。你可以打开看,里面会有movl、call这类指令,也会看到main这个全局标签。这就是cc1前端真正发挥价值的阶段:它做词法分析、语法分析、语义分析、中间表示生成、优化,最后才输出汇编。
值得注意的是,默认生成的.s是 AT&T 语法,不是 Intel 语法。这个差异后面讲 nasm 时还会提到,很多人第一次被吓到就是在这一步。
2.3 汇编:从汇编代码到目标文件
执行:
gcc -c hello.s -o hello.o这里的核心是as汇编器。它会逐条把汇编指令翻译成机器码,并生成一个.o目标文件。目标文件还没有被链接,里面的函数地址是“悬空”的,等着后续处理。
你可以用这个命令快速了解目标文件:
nm hello.o readelf -h hello.onm能看到符号表,比如main是 T 类型(文本段中的全局符号);readelf -h能看到文件头里的 ELF 类型、入口地址等信息。
2.4 链接:把碎片拼成整体
执行:
gcc hello.o -o hello这一步是ld链接器的活,它要做的事情包括:把多个.o文件合并、解析符号引用、分配最终地址、处理动态库依赖。此时才会把printf的实现从 C 库中找出来,绑定到你的程序里。
如果不用gcc驱动,手动链接一个最简单的汇编目标文件是这样:
ld hello.o -o hello但一旦程序调用了printf这类 C 库函数,直接ld就会面临两个麻烦:需要手动指定 C 库路径,还需要指定动态链接器路径。这也是为什么很多时候我们愿意用gcc做链接驱动,而不是直接上ld。
3. 为什么还要用nasm:两个汇编器的思考方式完全不同
既然 GCC 工具链里已经有as这个汇编器了,为什么还要专门学 nasm?这是我被问过很多次的问题。答案不是“哪个更好”,而是“它们的语法哲学完全不同”。
3.1 Intel 语法与 AT&T 语法的对比
as默认使用 AT&T 语法,而这个语法继承自老式 Unix 系统,和大部分 CPU 厂商文档里的 Intel 语法差异很大。同样是“把 ebx 的值复制到 eax”,两种写法是这样的:
| 操作 | Intel 语法(nasm) | AT&T 语法(as) |
|---|---|---|
| 寄存器复制 | mov eax, ebx | movl %ebx, %eax |
| 立即数赋值 | mov eax, 0x10 | movl $0x10, %eax |
| 内存读取 | mov eax, [ebx] | movl (%ebx), %eax |
| 带偏移寻址 | mov eax, [ebx+4] | movl 4(%ebx), %eax |
AT&T 语法有几个容易混淆的点:寄存器名字前面要加%,立即数前面要加$,指令助记符后面还要带后缀(b、w、l、q分别表示字节、字、双字、四字)。Intel 语法明显更接近硬件手册的原始描述,读起来更自然。
3.2 nasm的设计目标和优势
nasm 全称是 The Netwide Assembler,它的定位从诞生那天就很明确:做一个“可移植、跨平台、语法干净的 x86 汇编器”。它不绑定某个特定操作系统,也不强制你接受某种特定格式。
它的优势可以归纳成几条:
- 统一语法:无论你写 16 位实模式、32 位保护模式还是 64 位长模式,语法规则几乎一致。
- 支持多种输出格式:
bin、elf32、elf64、coff、macho、win32、win64都可以直接生成。 - 伪指令丰富:
section、global、extern、db、dw、dd、resb、equ、times这些让代码组织起来非常舒服。 - 文档质量好:官方手册对每条指令、每个伪指令都有清晰说明。
一个很实际的使用场景是操作系统开发。在写引导扇区时,你希望直接输出一个原始二进制文件,nasm 一个-f bin就搞定了,而 GAS 那一套要绕不少弯路。
3.3 GAS也能改成Intel语法,但为什么我还是选nasm
严格来说 GAS 也支持-masm=intel或者在汇编文件里写.intel_syntax,切换成 Intel 语法。但实测下来,GAS 的 Intel 语法支持的完整性、跨版本稳定性都不如 nasm 来得痛快,而且很多工具链内部的汇编片段仍然会生成 AT&T 风格代码。如果你想写一套“能反复使用、不随平台漂移”的汇编代码,nasm 是更省心的选择。
4. nasm实操:从汇编代码到可执行文件
理论说再多,不写一个跑起来的程序都是空谈。这一节带你在 Linux x86_64 环境下,用 nasm 从零写一个可执行文件。
4.1 环境准备
如果你还没有安装 nasm,先装它:
sudo apt install nasm或者用你发行版对应的包管理器。装完后验证版本:
nasm -v我当前使用的环境是 x86_64 Linux,所以后面的例子都以elf64输出格式为例。
4.2 第一个程序:用系统调用直接输出文本
新建一个文件hello.asm,内容如下:
section .data msg db "Hello, NASM!", 10 len equ $-msg section .text global _start _start: mov rax, 1 ; 系统调用号:sys_write mov rdi, 1 ; 文件描述符:stdout mov rsi, msg ; 缓冲区地址 mov rdx, len ; 写入长度 syscall mov rax, 60 ; 系统调用号:sys_exit xor rdi, rdi ; 退出码 0 syscall这里没有调用任何 C 库函数,直接通过系统调用和内核交互。需要注意几个细节:
db "Hello, NASM!", 10表示声明一组字节,10是换行符的 ASCII 码。len equ $-msg是 nasm 里非常常用的技巧,$表示当前位置,减去msg的地址就是字符串长度。global _start把_start标签导出为全局符号,链接器要以它作为程序入口。
4.3 编译和链接
执行两条命令:
nasm -f elf64 hello.asm -o hello.o ld hello.o -o hello第一条命令把汇编源码汇编成 64 位 ELF 目标文件,第二条命令用ld链接成可执行文件。运行:
./hello终端输出Hello, NASM!。如果漏写global _start,链接时会报“无法找到入口符号”之类的错误,这是初学最容易踩的坑之一。
4.4 再写一个带循环和计算的程序
单纯输出文本不足以体现汇编的乐趣,我们来写一个从 1 加到 10 的程序:
section .data msg db "Sum: %d", 10, 0 section .text global main extern printf main: push rbp mov rbp, rsp xor ebx, ebx ; 用 ebx 存累加结果 mov ecx, 1 ; 计数器从 1 开始 loop_start: add ebx, ecx inc ecx cmp ecx, 10 jle loop_start lea rdi, [rel msg] ; 格式化字符串地址 mov esi, ebx ; 要打印的数字 xor eax, eax ; 可变参数函数需要清零 eax call printf mov eax, 0 pop rbp ret注意这次我没有用_start,而是用了main,因为调用了printf,必须借助 C 运行时环境的初始化,直接用ld会比较麻烦。正确的编译方式是:
nasm -f elf64 sum.asm -o sum.o gcc sum.o -o sum这里就能看到 GCC 工具集和 nasm 的结合点:nasm 负责产出目标文件,gcc负责链接 C 库并处理运行时初始化的细节。
5. 让GCC和nasm在同一程序里共事
真正到项目里,很少有人写一整个程序都是汇编。常见做法是:业务逻辑用 C,热点代码或硬件相关操作用汇编,然后链接到一起。这一节重点讲这两种语言的混编方法。
5.1 C代码调用汇编函数
先写一个纯汇编函数,接收两个整数,返回它们的和:
section .text global add_func add_func: lea eax, [rdi + rsi] retC 侧代码:
#include <stdio.h> extern int add_func(int a, int b); int main() { int c = add_func(3, 5); printf("result = %d\n", c); return 0; }编译链接:
nasm -f elf64 add.asm -o add.o gcc main.c add.o -o app ./app这里最关键的是理解 x86_64 下的 System V 调用约定:函数参数不是靠栈传,而是优先使用寄存器。前六个整数参数依次放在rdi、rsi、rdx、rcx、r8、r9,返回值放在rax。
所以我在汇编函数里直接用rdi和rsi,算术完把结果放eax返回即可。
5.2 汇编代码调用C库函数
反过来,汇编也可以反过来调用 C 库,前面那个printf例子就是典型。这里要提醒几个容易出错的小地方:
extern必须声明:nasm 里调用外部函数,必须先写extern printf。- 格式字符串不能丢在栈上:通常放在
.data或.rodata段。 - 可变参数前的
eax要清零:调用printf这类函数时要保证al等于向量寄存器数量,通常就是xor eax, eax。 - 64位下要用位置无关寻址:在
lea指令里加rel修饰,如lea rdi, [rel msg]。
如果漏掉rel,在开启了 PIE(位置无关可执行文件)的默认编译环境里,很容易出现段错误,因为你的代码访问的绝对地址并不真实有效。
5.3 栈对齐问题
x86_64 的 ABI 要求:在调用一个函数之前,rsp必须按 16 字节对齐。如果没有遵守这个规则,当被调用的函数内部使用movaps这类需要对齐的 SSE 指令时,程序会直接崩溃。
初学者最容易犯的错误是在main里随意push之后没有pop就call。比如:
main: push rbp mov rbp, rsp sub rsp, 8 ; 可能破坏对齐 ... call printf解决办法很简单:每次压栈尽量成对出现,或者用sub rsp, 16之类的操作保证总偏移量是 16 的倍数。这个坑我用printf时遇到过不止一次,排查到最后往往就是多压了一个寄存器。
6. 这些坑我踩过:排错思路与常用检查命令
最后这部分是实操笔记的精华,记录我经常遇到的汇编与链接问题,以及对应的排错路径。
6.1 链接时报“找不到入口点”
如果用的是_start,却忘记global _start,链接器会报:
ld: warning: cannot find entry symbol _start; defaulting to ...如果用的是main,却用ld而非gcc链接,也可能因为缺少 C 运行时初始化代码而产生各种怪问题。我的经验是:纯汇编程序用_start+ld;涉及 C 库直接用main+gcc,不要混搭。
6.2 printf格式字符串可以显示,但程序崩溃
这种常见于汇编里写了:
mov rdi, fmt如果可执行文件是 PIE 的,fmt的绝对地址并不存在于进程内存空间的预期位置,运行到printf时会段错误。改成lea rdi, [rel fmt]通常能解决。
6.3 调用约定记错导致结果完全不对
参数顺序和寄存器记反是新手高发问题。简单记法:
- 第一个整数参数:
rdi - 第二个整数参数:
rsi - 第三个整数参数:
rdx - 第四个整数参数:
rcx
返回值固定放rax。如果函数还要返回 64 位整数,直接mov rax, ...即可。
6.4 排错时的三板斧
遇到“编译过了但跑起来结果不对”,我一般按这个顺序检查:
| 命令 | 用途 |
|---|---|
nm add.o | 确认目标文件里的符号存在且类型正确 |
readelf -s add.o | 看符号表和全局性 |
objdump -d add.o | 反汇编目标文件,确认机器码和意图一致 |
gdb ./app | 单步调试,观察寄存器值变化 |
比如nm add.o如果显示你的函数前面不是T而是U,说明它被当作未定义符号了,那就去检查global声明。objdump -d则是确认你写的汇编真的变成了预期的那几条机器码,而不是因为某种指令集限制被悄悄改掉了。
6.5 别忽略指令集和运行环境的匹配
64 位系统上偶尔能看到有人直接抄一段 32 位汇编,编译时用了nasm -f elf却希望链接成能在 x86_64 系统上运行的程序,结果必然是一堆奇怪的报错。这里我的建议很简单:做汇编学习或者项目开发之前,先写清楚目标平台,x86_64 就用elf64,32 位就用elf32,千万别默认“反正都是 x86 差不多”。没那么轻松,差一个字节的机器码,最终崩溃的方式都不同。
个人经验是,把汇编纳入日常调试工具链之后,很多高级语言层面看不懂的性能问题都会变得透明起来。GCC 工具集负责把成熟的工程化流程管起来,nasm 负责在需要精确控制机器指令时给你自由度。两者搭配,你既能享受高级语言的开发效率,又能在关键路径上把每一条指令握在自己手里。