尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从C代码到机器码:用gcc与objdump查看编译过程与反汇编实战

从C代码到机器码:用gcc与objdump查看编译过程与反汇编实战 1. 从一段 C 代码说起CPU 真的认识它吗很多初学 C 语言的同学脑子里都有这样一个印象我用 C 语言写了个add函数然后程序就能算出两个数的和看起来 C 语言像是“能直接和 CPU 对话”的语言。但实际情况并非如此。CPU 不认识 C 语言它只认识一种东西——机器码。机器码是 CPU 指令集中每一条指令的二进制编码也是程序在内存中真正被取指、译码、执行的那一列字节。举个例子。你在代码里写int add(int a, int b) { return a b; }这段代码在你的源码文件里本质上是一个文本文件。CPU 没有办法把字符a、b、直接翻译成“把两个寄存器的值加在一起”这个动作。中间必须经过编译器把文本翻译成 CPU 认识的二进制指令序列也就是机器码。因此从源码到程序运行真正的执行路径是这样的C 源码 → 预处理 → 编译生成汇编 → 汇编生成目标文件 → 链接 → 可执行文件 → CPU 取指执行机器码可以看到机器码位于链条的末端。而汇编代码只是“人类可读的机器码助记符”它离 CPU 非常近但仍然不是 CPU 直接认识的东西。本文就以一个简单的加法函数为例带你在 Windows 环境下完整走一遍从 C 源码到机器码的观察过程。你会亲眼看到a b这样一个看似简单的表达式在机器码层面到底变成了什么。适合以下读者阅读正在学 C 语言想了解程序编译过程的新手。做了几年上位机或应用开发想向下看一层原理的开发者。对 Windows 底层执行机制感兴趣想上手做实验的爱好者。读完之后你会掌握C 语言编译的四个阶段分别做了什么。如何用 gcc 在 Windows 下生成汇编、目标文件和可执行文件。如何用 objdump / dumpbin 反汇编看到机器码的真实样子。机器码、寄存器、指令后缀这些概念在实战中对应什么现象。2. Windows 下的实验环境准备2.1 为什么在 Windows 上做实验很多底层教程默认使用 Linux因为 Linux 自带的工具链gcc、objdump、readelf一应俱全。但 Windows 也是大量 C 语言开发者工作的平台尤其是做上位机、驱动、嵌入式工具链、工业软件的朋友很多项目还是跑在 Windows 上。Windows 下做这类实验并不困难只要准备好工具链体验和 Linux 差别不大。下面提供两种最常用的方案。2.2 方案一安装 MinGW-w64推荐MinGW-w64 是 Windows 平台上一套开源的 GCC 工具链包含gccC 语言编译器。gC 编译器。as汇编器。ld链接器。objdump反汇编和查看目标文件的利器。gdb调试器。你可以从 MinGW-w64 的官方发布渠道或通过 MSYS2 来安装。比较方便的方式是安装 MSYS2然后在 MSYS2 终端里执行pacman -S mingw-w64-x86_64-gcc安装完成之后把 MinGW-w64 的bin目录加入系统环境变量Path。例如我的安装路径是C:\msys64\mingw64\bin添加完成后打开一个新的命令提示符窗口执行gcc --version如果能输出版本信息说明环境配置成功。2.3 方案二使用 Visual Studio 开发者命令行如果你已经在使用 Visual Studio那么可以不安装 gcc直接使用 Visual Studio 自带的cl.exe编译器和dumpbin.exe工具。打开 Visual Studio 的“开发者 PowerShell”或“开发者命令提示符”就可以直接使用cl test_add.c dumpbin /DISASM test_add.obj需要说明的是Visual Studio 的cl.exe生成的机器码、COFF 目标文件格式和objdump看到的符号描述会有一些差异但核心原理是一样的。本文后面的示例主要基于 gcc objdump因为这套组合在 Windows 和 Linux 上都能用更方便读者跨平台复用。2.4 验证工具是否可用打开命令行分别执行gcc --version objdump --version如果都能正常输出就说明工具链已经准备好。为了避免版本差异造成的干扰本文的实验不指定特别新的编译器版本以稳定为主。不同版本的 gcc 生成的汇编细节会有细微差别但指令结构和机器码本质不会变。3. C 程序从源码到机器码的四个阶段在动手看机器码之前有必要把编译过程的四个阶段理清楚。很多初学者以为gcc test.c一下就把程序变成可执行文件了实际上中间发生了四步处理。3.1 预处理阶段处理 #include 和 #define预处理阶段由预处理器完成。它主要负责展开#include包含的头文件。处理#define宏替换。处理条件编译指令比如#ifdef、#ifndef。删除注释。这一步的输出仍然是一个文本文件只不过内容比原文件庞大得多。可以使用gcc -E test_add.c -o test_add.i生成的test_add.i文件仍然是 C 代码文本CPU 依然不认识它。3.2 编译阶段生成汇编代码编译阶段把预处理后的 C 代码转化为汇编代码。汇编代码是给人看的、带助记符的文本——例如mov、add、ret等。命令如下gcc -S test_add.c -o test_add.s打开test_add.s你会看到类似这样的内容add: pushq %rbp movq %rsp, %rbp movl %edi, -4(%rbp) movl %esi, -8(%rbp) movl -4(%rbp), %edx movl -8(%rbp), %eax addl %edx, %eax popq %rbp ret注意如果你在 Windows 的 MinGW 环境下生成汇编代码的具体注释、指令后缀可能会有差异比如可能看到 32 位寄存器版本。但核心逻辑是一样的——编译器把a b变成了一系列寄存器操作。3.3 汇编阶段生成目标文件汇编器负责把汇编代码翻译成机器码生成目标文件。目标文件里已经是二进制数据了CPU 可以识别其中的指令字节但还不能直接运行因为它还没有完成地址重定位和外部符号解析。命令如下gcc -c test_add.c -o test_add.o此时如果用编辑器打开test_add.o会看到一堆乱码这正是二进制机器码的表现形式。3.4 链接阶段生成可执行文件链接器把目标文件与运行时库、启动代码等合并在一起完成符号解析和重定位最终生成可执行文件。命令如下gcc test_add.o -o test_add.exe到这里程序才能被 Windows 装载并运行。注意可执行文件里包含的依然是机器码而不是汇编代码也不是 C 语言。这四个阶段的完整对应关系可以用下表总结阶段命令输入输出内容预处理gcc -E.c.i展开后的 C 源码编译gcc -S.i.s汇编代码汇编gcc -c.s.o机器码目标文件链接gcc -o.o.exe可执行文件4. 实战让加法函数的机器码现出原形理论讲完现在进入本文的核心环节——写一个加法函数然后亲眼看到它的机器码。4.1 编写一个最简示例程序先在某个工作目录下创建文件test_add.c内容如下// 文件路径当前工作目录/test_add.c // 最简单的加法函数方便观察机器码 int add(int a, int b) { return a b; } int main(void) { int x 10; int y 20; int z add(x, y); return z; }这个程序包含两个函数add我们的核心观察对象执行两个 int 相加。main调用 add并把结果作为返回值。之所以包含main是因为最终要生成可执行文件并且可以正常运行。如果只留 add 函数没有 main链接时会报错因为可执行程序必须有一个入口点。编译生成可执行文件gcc test_add.c -o test_add.exe运行一下确认程序本身没有逻辑问题。虽然程序没有打印输出但可以直接执行不影响我们观察机器码。test_add.exe运行完命令后命令行没有报错就说明程序正常退出。4.2 用 -S 参数查看汇编输出接下来生成汇编文件gcc -S test_add.c -o test_add.s然后用文本编辑器打开test_add.s。不同平台、不同版本的编译器输出可能略有差别但大概率你会看到类似这样的内容.file test_add.c .text .globl add .def add; .scl 2; .type 32; .endef .seh_proc add add: pushq %rbp .seh_pushreg %rbp movq %rsp, %rbp .seh_setframe %rbp, 0 subq $16, %rsp .seh_endprologue movl %ecx, 16(%rbp) movl %edx, 24(%rbp) movl 16(%rbp), %edx movl 24(%rbp), %eax addl %edx, %eax addq $16, %rsp popq %rbp ret .seh_endproc .def main; .scl 2; .type 32; .endef .seh_proc main main: pushq %rbp .seh_pushreg %rbp movq %rsp, %rbp .seh_setframe %rbp, 0 subq $48, %rsp .seh_endprologue movl $10, -4(%rbp) movl $20, -8(%rbp) movl -8(%rbp), %edx movl -4(%rbp), %eax movl %edx, %r8d movl %eax, %edx movl %edx, %ecx call add movl %eax, -12(%rbp) movl -12(%rbp), %eax addq $48, %rsp popq %rbp ret注意这里出现了一些 Windows 特有的指令标记比如.seh_proc、.seh_pushreg、.seh_setframe。这些是 Windows 平台 SEH结构化异常处理所需的栈帧信息说明编译器在生成代码时已经考虑了 Windows 的调用约定和异常处理机制。从汇编代码中我们能看到add函数的执行逻辑压栈保存调用者栈帧基址%rbp。建立新的栈帧。把参数a存入栈上16(%rbp)的位置参数b存入24(%rbp)。从栈上把两个参数分别加载到寄存器。执行addl指令把两个寄存器中的值相加。恢复栈帧返回。这就是a b在底层的基本面貌。一条看似简单的加法实际涉及栈、寄存器、内存访问等多层操作。4.3 用 -c 参数生成目标文件编译生成目标文件gcc -c test_add.c -o test_add.o现在test_add.o是一个二进制文件。如果你直接打开它看到的会是乱码。这是正常的因为里面存放的就是机器码和符号信息。4.4 用 objdump 反汇编目标文件反汇编目标文件objdump -d test_add.oobjdump -d会把机器码翻译成可读的汇编文本。输出可能长这样test_add.o: file format pe-x86-64 Disassembly of section .text: 0000000000000000 add: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 10 sub $0x10,%rsp 8: 89 4d 10 mov %ecx,0x10(%rbp) b: 89 55 14 mov %edx,0x14(%rbp) e: 8b 55 10 mov 0x10(%rbp),%edx 11: 8b 45 14 mov 0x14(%rbp),%eax 14: 01 d0 add %edx,%eax 16: 48 83 c4 10 add $0x10,%rsp 1a: 5d pop %rbp 1b: c3 ret 000000000000001c main: 1c: 55 push %rbp 1d: 48 89 e5 mov %rsp,%rbp 20: 48 83 ec 30 sub $0x30,%rsp 24: c7 45 fc 0a 00 00 00 movl $0xa,-0x4(%rbp) 2b: c7 45 f8 14 00 00 00 movl $0x14,-0x8(%rbp) 32: 8b 55 f8 mov -0x8(%rbp),%edx 35: 8b 45 fc mov -0x4(%rbp),%eax 38: 41 89 d0 mov %edx,%r8d 3b: 89 c2 mov %eax,%edx 3d: 89 d1 mov %edx,%ecx 3f: e8 00 00 00 00 call 0x44 main0x28 44: 89 45 f4 mov %eax,-0xc(%rbp) 47: 8b 45 f4 mov -0xc(%rbp),%eax 4a: 48 83 c4 30 add $0x30,%rsp 4e: 5d pop %rbp 4f: c3 ret这里请你注意看最左侧的一列比如14: 01 d0 add %edx,%eax这一行中14是这条指令在目标文件中的偏移地址。01 d0是这条指令的机器码。add %edx, %eax是把这条机器码翻译成人类可读的汇编助记符。也就是说addl %edx, %eax这条汇编指令在机器码层面就是最纯粹的01 d0两个字节。4.5 反汇编可执行文件目标文件虽然包含机器码但很多地址还没有重定位。我们可以进一步反汇编可执行文件看看链接之后机器码有什么变化objdump -d test_add.exe这次输出会多很多包括很多系统库相关的函数。我们可以只看add函数。由于可执行文件的地址已经经过重定位每条指令前面的地址不再是从 0 开始而是一个真实的虚拟地址例如0000000140001000 add: 140001000: 55 push %rbp 140001001: 48 89 e5 mov %rsp,%rbp 140001004: 48 83 ec 10 sub $0x10,%rsp 140001008: 89 4d 10 mov %ecx,0x10(%rbp) 14000100b: 89 55 14 mov %edx,0x14(%rbp) 14000100e: 8b 55 10 mov 0x10(%rbp),%edx 140001011: 8b 45 14 mov 0x14(%rbp),%eax 140001014: 01 d0 add %edx,%eax 140001016: 48 83 c4 10 add $0x10,%rsp 14000101a: 5d pop %rbp 14000101b: c3 ret从目标文件到可执行文件add 函数的机器码几乎没变。因为 add 函数内部没有引用外部符号不需要重定位。变化最大的其实是main函数里的call add指令它的目标地址会被修正为 add 函数在可执行文件中的真实地址。4.6 读懂一条机器指令从字节到含义现在我们把目光聚焦到add函数最核心的一条指令上14: 01 d0 add %edx,%eax这条指令的机器码是01 d0。为什么01 d0就代表add %edx, %eax这里需要简单了解一下 x86-64 指令编码规则。x86 指令的编码非常灵活指令长度从 1 个字节到 15 个字节不等。01 /r是ADD r/m32, r32这一形式的基础操作码01表示 ADD 指令作用是把源操作数加到目标操作数上。d0是 ModRM 字节。它经过编码之后指定了源寄存器是%edx目标操作数是%eax且寻址方式为寄存器直接寻址。当你看到01 d0这两个字节时CPU 就知道这是一条 ADD 指令。目标操作数是寄存器%eax。源操作数是寄存器%edx。把%edx加到%eax上。这个过程就是 CPU 的“译码”阶段。CPU 通过指令编码表把01 d0解释成微操作然后交给执行单元完成加法运算。再来看ret指令它的机器码是c3只有一个字节。这条指令负责从当前栈帧恢复返回地址然后把控制权交还给调用者。就是这么简单的一字节指令在可执行文件里随处可见。4.7 用 Visual Studio 工具链验证可选如果你正好安装了 Visual Studio也可以用 dumpbin 工具观察同一段代码的反汇编结果。在开发者命令行中执行cl /c test_add.c dumpbin /DISASM test_add.objVisual Studio 生成的机器码细节与 gcc 不同尤其是参数传递方式因为 MSVC 默认使用 Windows x64 调用约定参数传递顺序和前 4 个整型参数的寄存器选择有差异。但步骤和思想完全一致。这就引出一个重要结论同一段 C 代码在不同编译器、不同平台、不同优化选项下生成的机器码可能是不同的。但 C 语言的语义是确定的——a b就是两个 int 相加如何用指令实现交给编译器去决策。4.8 加入优化选项再看一眼如果你关心性能可以给编译器加上优化选项gcc -O2 -S test_add.c -o test_add_O2.s再看生成的汇编会发现add函数变得非常短甚至可能直接被内联到main里。因为编译器发现 add 函数逻辑非常简单函数调用开销大于计算本身于是直接把加法指令嵌到了 main 函数中。这时如果再反汇编你会发现原来独立的add函数区域可能不存在了或者只剩一个跳转 stub。这就是优化编译的典型表现编译器会把机器码组织成“最适合 CPU 执行”的样子而不是“最适合人阅读”的样子。5. 常见问题与排查思路在实际动手做上述实验时你可能会遇到一些环境或工具上的坑。这里整理几个高频问题。5.1 gcc 不是内部或外部命令现象在命令提示符里输入gcc --version系统提示“不是内部或外部命令”。原因MinGW-w64 的 bin 目录没有添加到系统环境变量 Path 中或者添加后没有重新打开命令行窗口。排查步骤确认 gcc.exe 实际存在于哪个目录例如C:\msys64\mingw64\bin\gcc.exe。右键“此电脑” → 属性 → 高级系统设置 → 环境变量。在系统变量 Path 中添加对应 bin 目录。重新打开命令提示符。避免方法配置完环境变量后用where gcc命令可以查看 gcc 的完整路径确认是否生效。5.2 源码注释或字符串在命令行显示乱码现象代码文件是 UTF-8 编码里面写了中文注释用 gcc 编译后没有出错但在命令行或编辑器里显示乱码。原因Windows 命令行的默认代码页通常是 GBK而源码文件可能是 UTF-8。两者不一致导致显示乱码。排查步骤用支持编码切换的编辑器如 VS Code、Notepad打开源码确认实际编码。如果源码中包含中文字符串建议统一使用 UTF-8 编码并在源码文件头部不做多余声明。在命令行里可以执行chcp 65001切换代码页为 UTF-8。注意C 语言本身对源码编码没有强制要求但字符串字面量的真实字节会直接影响运行输出。建议团队统一源码编码避免跨平台协作时出现乱码。5.3 objdump 输出中看不懂地址和助记符现象objdump 反汇编输出的内容很多看不懂0x10(%rbp)这种写法是什么意思。解释0x10(%rbp)表示以%rbp寄存器的值加上偏移量0x10作为内存地址。%eax、%edx是 32 位通用寄存器。%rbp、%rsp是 64 位通用寄存器分别用作栈帧基址和栈顶指针。sub $0x10, %rsp表示栈顶指针向下移动 16 字节也就是在栈上分配了 16 字节的局部空间。建议先学一点 x86-64 汇编基础掌握常用寄存器、栈帧、常用指令再回头看反汇编就会清晰很多。5.4 为什么反汇编里看到很多 call 指令却找不到我的函数现象反汇编可执行文件时满屏都是系统库函数定位不到自己的add函数。原因可执行文件包含启动代码、C 运行时库、其他依赖库的代码在objdump -d默认输出中会混在一起。解决方案objdump -d test_add.exe | grep -A 20 add:在 Windows 命令行里如果不想用 grep也可以把反汇编输出重定向到文本文件再用编辑器搜索addobjdump -d test_add.exe disasm.txt然后打开disasm.txt搜索add:。5.5 为什么不同编译器生成的机器码不一样现象同一段add函数gcc 和 MSVC 生成的汇编不一样机器码更不一样。原因C 语言标准只规定了程序的行为不规定编译后的指令布局。不同编译器采用不同的调用约定、寄存器分配策略、优化策略自然会生成不同机器码。理解这恰恰说明机器码是“平台相关 编译器相关”的。你在 x86-64 的 Windows 上编译出的机器码不能直接复制到 ARM 处理器上运行必须重新编译。这就是为什么 Windows 上的 exe 通常无法直接运行在 Linux 或 macOS 上。6. 从机器码反观 C 语言与工程实践理解了“C 语言代码 ≠ 机器码”之后很多曾经觉得抽象的概念会变得具体起来。6.1 指针为什么是底层编程绕不开的概念C 语言中指针的本质就是内存地址。而机器码中大量指令都涉及内存寻址例如mov 0x10(%rbp), %edx。所以指针不是 C 语言凭空发明的语法而是对机器寻址能力的一种抽象。当你写出*p时编译器的核心任务之一就是生成一条从内存读取数据的机器指令。理解这一点之后再去思考指针数组、二级指针、函数指针就不会觉得它们只是语法题而会自然地联想到它们背后的内存模型。6.2 为什么编译器会优化掉你“自认为必须执行”的代码当你编译时加了-O2编译器会分析每个变量的定义和使用情况。如果一个变量只被赋值没有任何后续读取编译器可能直接把它删除不生成任何机器码。从 C 语言层面看你确实写了代码但从机器码层面看这段代码是空的。这也是很多新手在写空循环、写无副作用表达式时发现调试行为跟预期不一致的根本原因。6.3 缓冲区溢出、栈溢出与机器码的关系当你在 C 语言中越界写数组本质上是向栈上的非法内存地址写入了机器码数据。攻击者如果精心构造这些数据就可能覆盖返回地址让 CPU 执行攻击者指定的机器码。这就是很多安全漏洞的底层原理。从机器码视角看安全你会更明白为什么现代编译器会加入栈保护检查、为什么系统有 DEP数据执行保护、为什么 64 位程序比 32 位程序更难利用某些漏洞。6.4 遇到崩溃时如何用调试器观察到机器码层当程序崩溃时调试器如 WinDbg、x64dbg往往会停在出错的指令处并显示对应机器码。如果你能看懂一点汇编排查崩溃的效率会明显提高。例如访问空指针时你会看到类似mov (%rax), %ecx的指令。如果%rax为 0CPU 就会触发访问违规。此时从 C 语言层面定位到问题再从机器码层面理解为什么触发整个排查过程就比较完整了。6.5 底层实验的安全边界本文涉及的所有操作都只是在本地编译器、本地可执行文件层面进行观察完全不涉及系统内核或驱动程序修改。如果你未来继续深入 Windows 底层开发比如调试内核、编写驱动、使用 WinDbg 附加到系统进程请务必注意只在你自己有授权的机器上进行实验。不要在未备份的虚拟机或生产环境上直接操作。不要在未授权的情况下分析或修改其他软件的可执行文件。涉及系统级调试时优先使用隔离的虚拟机环境。6.6 后续学习路线如果你对 Windows 底层编程和机器码产生了兴趣可以参考下面的路线继续深入x86/x64 汇编语言基础掌握常用寄存器、常用指令、栈帧、调用约定。PE 文件格式理解 Windows 可执行文件的结构知道.text、.data、.rdata段分别存放什么。调试器工具学习 x64dbg、WinDbg 的基本用法到汇编级去观察程序执行。反汇编与逆向分析基础结合 C 语言和汇编的对应关系尝试分析简单函数的机器码。C 语言编译原理看编译器是如何处理变量、函数调用、结构体、虚函数表的。调用约定研究 x64 下__fastcall等调用约定对参数传递和栈帧布局的影响。如果你更偏应用层开发不需要成为反汇编专家但上述内容对排查崩溃、理解优化代码、分析性能问题都很有帮助。7. 小结与动手建议本文的核心主线只有一条你写的 C 语言代码CPU 并不认识。CPU 真正执行的是经过编译器、汇编器、链接器层层加工之后的机器码。对于int add(int a, int b) { return a b; }这个函数机器码层面的核心指令可能就是add %edx, %eax对应字节01 d0。回顾一下本篇的关键要点C 源码必须经过预处理、编译、汇编、链接四个阶段才能变成可执行文件。汇编是机器码的可读形式机器码才是 CPU 真正执行的指令字节。Windows 下用 gcc objdump 就能完成从 C 源码到机器码的观察实验。同一段 C 代码在不同编译器、不同优化选项下机器码可能完全不同。理解机器码不是让你手写指令而是帮助你建立从高级语言到底层执行之间的认知桥梁。强烈建议你动手做一遍实验。不需要太多前置知识只要装好 gcc把上面test_add.c的代码复制下来依次执行gcc -S test_add.c -o test_add.s gcc -c test_add.c -o test_add.o objdump -d test_add.o gcc test_add.c -o test_add.exe objdump -d test_add.exe观察每个步骤的输出文件再尝试修改代码比如把两个参数改成三个把加法改成乘法看看机器码对应的指令字节发生了哪些变化。如果你在实验过程中对某条指令、某个输出片段感到困惑建议先查一下 x86 指令编码表或者用调试器单步执行看寄存器的实时变化。理解了机器码之后再回头看 C 语言你会获得一种全新的、更踏实的掌控感——这对后续学习数据结构、操作系统、编译原理、逆向分析都有长远帮助。
返回列表