C/C++ 进阶:深入解析 GCC:从源码到可执行程序的魔法四步曲
引言
距离上一篇博客更新已经过去了大概一两周的时间,而对于 Linux 系统的基本指令以及 Shell 编程的学习其实基本讲解完毕,Linux基础一块的知识就将告一段落了,如果有细节性的知识,我也会及时分享给各位,作为一名正在攀登 Linux C/C++ 开发这座高峰的学习者,最近系统性地学习了 GCC 这个至关重要的工具。它绝不仅仅是一个简单的“编译器命令”,而是一个驱动我们代码变成可执行程序的强大引擎。理解其内部流程:预处理、编译、汇编、链接,对于写出高效、可调试的代码至关重要。而本文即介绍 GCC 这个工具是如何将我们的源码转换为一个可执行性程序的,详细拆解 GCC 编译的每一步。希望能帮助到同样在学习路上的你,也方便自己日后回顾。
1. 初识 GCC:开源的编译基石
1.1 什么是 GCC
GCC的全称是:GNU Compiler Collection (GNU 编译器套件),它的主要作用是将高级语言(C, C++, Objective-C, Fortran, Ada, Go 等)编写的源码翻译为计算机底层能够理解与执行的机器码。或者是转换为更为底层的语言,如汇编语言。
GCC 支持多种操作系统(Linux、Windows、macOS等),同时它并不只是简单的编译器, 更多是一个驱动程序。它本身并不完成所有编译工作,而是根据你给的源代码类型(.c
, .cpp
等)和参数,智能地调用后台真正的预处理器、编译器、汇编器和链接器等工具来完成整个构建流程。
1.2 GCC 编译流程
当我们编写好了一个源代码文件之后,之前我都不知道程序是如何运行起来的,经过了这一段时间的学习,才对C/C++程序的运行有了一定的理解,当我们编写好了源文件,gcc 将程序编译分为预处理→编译→汇编→链接四个步骤。
1.3 POSIX标准
是由 IEEE (电气和电子工程师协会)指定的一组标准,全称为:可移植操作系统接口(Portable Operating System Interface),定义了不同的操作系统(尤其是类Unix系统)应该为应用程序提供的相同的接口 (API) 和服务。
该标准的核心目的就是促进应用软件与多种类型的操作系统之间的兼容性以及可移植性,也就是说,只要遵循 POSIX 标准编写的程序,理论上可以在任何兼容 POSIX 的操作系统上编译和运行。
1.3.1 POSIX 标准具体内容
系统调用和库内容:定义了操作系统应提供的核心服务,如文件的系统操作、进程管理和线程控制。
Shell 和系统工具:规定了标准命令行接口和一系列基本工具,如 awk 、 echo 等。
程序线程接口 :包含语言、函数等接口规范,使程序能够在任何遵循 POSIX 标准的操作系统中运行。
1.4 安装 GCC
讲了这么多,我们又应该如何安装 GCC 呢?下面我们以 Ubantu22.04 版本的 Linux 操作系统为例,
安装 GCC 的指令如下:
sudo apt install gcc
根据提示输入y即可。
安装好 GCC 后,我们可以通过如下命令检查安装的gcc版本
gcc --version
2. 揭秘 GCC 编译流程:从 .c/.cpp
到可执行文件的四步舞曲
2.1 实例引入
首先我们在我们 /home/~ 目录下创建一个实例目录
mkdir study_helloworld
cd study_helloworld
然后我们在目录下编写三个文件,实现最基础的打印 helloworld 的功能。
2.1.1编写源文件
1. main.c
#include "hello.h"int main()
{say_hello();return 0;
}
2. hello.h
#ifndef __HELLO_H__
#define __HELLO_H__void say_hello();#endif
3. hello.c
#include "hello.h"
#include <stdio.h>void say_hello()
{printf("Hello world!\n");
}
我们可以采用如下命令编译可执行文件并执行:
gcc main.c hello.c -o main
./main
可以看到成功输出了 hello world。
其实我们在输入 gcc main.c hello.c -o main
这样一条简单的命令时,背后隐藏着四个精妙的步骤。
2.2 步骤的详细介绍
2.2.1 预处理
预处理的主要任务是对源代码进行文本层次的加工处理,将它们转换成编译器可以识别的形式。
主要进行的操作如下:
头文件包含 (
#include
): 将被#include
指定的头文件(.h
)内容完整地复制并插入到#include
指令所在的位置。形成“单一”的、庞大的源文件。宏展开 (
#define
): 查找源代码中所有通过#define
定义的宏,并将其原地替换为定义的值或代码片段。条件编译 (
#ifdef
,#if
,#endif
,#else
,#elif
): 根据指定的条件(通常是宏定义是否存在或值)决定保留或删除某部分代码块。常用于平台适配、功能开关。删除注释: 移除所有单行 (
//
) 和多行 (/* ... */
) 注释,减少后续处理负担。
被进行了预处理的源文件仍然是纯文本文件,其拓展名一般为.i
(C) 或 .ii
(C++),同时我们也可以单独进行预处理操作:
# 输出预处理后的C代码到hello.i
gcc -E hello.c -o hello.i# 输出预处理后的C代码到main.i
gcc -E main.c -o main.i
-E:Expand(展开)的缩写,该参数指定gcc执行预处理操作
.i:intermediate(中间的)的缩写,预处理后的源文件通常以.i作为后缀。
执行了上述指令之后我们便可以查看生成的 main.i 的预处理文件
你会看到所有头文件都被塞进来、宏都被替换掉、注释消失了、条件编译后的代码保留下来了。预处理器处理后的文件通常会比原始源文件大,因为它会展开宏和包含其他文件的内容。
2.2.2 编译
该步骤的主要任务是将预处理后的源代码(.i
/ .ii
)翻译成特定处理器架构的汇编语言代码。
主要进行的操作是
语法分析: 检查代码是否符合 C/C++ 语言的语法规则。遇到语法错误会在此阶段报错(
syntax error
)。语义分析: 进行更深入的检查,确保代码在逻辑上是有意义的。遇到类型不匹配、未声明标识符等问题会在此阶段报错。
词法分析: 将源代码拆分成有意义的单词,如关键字、标识符、运算符、常量等。
生成中间表示: 编译器内部会将代码转换成一种或多种中间表示形式,便于进行优化和分析。
生成汇编代码: 将优化后的中间表示转换为目标处理器架构的汇编指令。这些指令是机器指令的人类可读(勉强可读)的助记符形式。
经过编译处理之后的文件成为汇编文件后缀名为.s
,这也是一个纯文本文件,你可以用文本编辑器打开查看,里面是像 movl
, call
, addq
这样的汇编指令。
执行下面的命令对刚刚生成的预处理文件进行单独编译操作:
# 将预处理后的C代码编译成汇编代码hello.s
gcc -S hello.i -o hello.s# 将预处理后的C代码编译成汇编代码main.s
gcc -S main.i -o main.s
-S:Source(源代码)的缩写,该参数指定gcc将预处理后的源码编译为汇编语言。
.s:Assembly Source(汇编源码)的缩写,通常编译后的汇编文件以.s作为后缀。
可以看到里面的内容都是汇编语言的代码。
2.2.3 汇编
该步骤的主要任务是将汇编语言文件翻译成机器指令,并打包成特定格式的目标文件 (Object File)。
在这一过程中所进行的主要操作是:
逐行解析: 读取汇编文件中的每一条指令和数据定义。
生成机器码: 将每条汇编指令一对一地翻译成对应处理器架构的二进制机器指令。这是 CPU 真正能直接执行的代码。
处理数据: 为程序中定义的全局变量、静态变量分配初始存储空间或预留空间。
生成符号表: 创建目标文件内部的符号表 (Symbol Table)。这个表记录了该文件中定义的符号(如函数名、全局变量名)及其位置(地址),以及该文件中引用但未在此文件中定义的符号。
生成重定位信息: 记录文件中那些在链接阶段才能确定最终地址的位置。这些位置在目标文件中是临时的或为0的,需要链接器后续修正。
经过汇编操作的文件是目标文件,通常扩展名为 .o
(Linux/Unix) 或 .obj
(Windows)。注意这里不同的操作系统的后缀名不同,这是一个二进制文件,包含机器指令,但还不是最终可运行的程序。
执行下面的命令对刚刚生成的汇编文件进行单独汇编操作:
gcc -c main.s -o main.o
gcc -c hello.s -o hello.o
-c:可以被理解为Compile or Assemble(编译或汇编),该参数可以指定gcc将汇编代码翻译为机器码,但不做链接。此外,该参数也可以用于将.c文件直接处理为机器码,同样不做链接。
-o:Object的缩写,通常汇编得到的机器码文件以.o为后缀。
到这里,生成的已经是二进制文件了,就不可以使用文本编辑器直接查看该文件了。可以通过下面指令查看 main.o 的内容。
objdump -s main.o
2.2.4 链接
这个阶段由链接器完成,该步骤的主要任务是将一个或者多个目标文件,以及所需的库文件组合到一起,通过解析符号之间引用关系、分配最终的内存地址,生成一个完整的、可直接加载到内存中执行的可执行性文件或者库文件。可以说该步骤的内容最为复杂。
下面介绍三种不同的链接方式:
1. 静态链接 (-static
或默认链接 .a
文件):
将静态库中实际被用到的目标文件代码完整地拷贝到最终的可执行文件中。优点:程序独立性强,运行时不需要库文件存在。缺点:可执行文件体积大,库更新需重新链接整个程序。
gcc -static main.o hello.o -o main
-static:该参数指示编译器进行静态链接,而不是默认的动态链接。使用这个参数,GCC会尝试将所有用到的库函数直接链接到最终生成的可执行文件中,包括C标准库(libc)、数学库(libm)和其他任何通过代码引用的外部库。
2. 动态链接
将动态库 (共享库,.so
文件) 中符号的引用信息记录到可执行文件中。运行时由操作系统的动态链接器 负责在程序加载或运行时,将所需的动态库加载到内存,并完成最终的重定位(地址绑定)。优点:可执行文件小,节省内存(多个程序可共享同一份库代码),库更新方便。缺点:程序运行时依赖库文件存在且版本兼容。
方式一:
gcc main.o hello.o -o main
没有添加-static关键字,gcc默认执行动态链接,即glibc库文件没有包含到可执行文件中。
3. 混合链接
有时候需要用到某些静态库静态链接,有时候有需要动态链接,混合链接则结合二者的优点。
执行下面的指令可以将hello.o编译为静态链接库libhello.a
ar crv libhello.a hello.o
# ar:归档命令,用于处理静态库文件。
# crv: ar命令的选项
# c:创建归档文件
# r:替换归档文件中现有的文件或者向归档文件中添加新文件。
# v:详细模式(verbose mode)
结语:
理解 GCC 编译的四步流程(预处理->编译->汇编->链接)是 Linux C/C++ 开发者的一项基本功。之后我会相继介绍 Makefile 文件的编写,C/C++的动态链接库与静态链接库等区别。最初我只知道点击编辑器上边的 run 按钮就能运行程序,现在明白了这背后精妙的四步转换。多动手实践,,是巩固这些知识的最佳途径希望这篇博客能对正在学习 C/C++ 编程的同学有所帮助,如有错误或不足之处,欢迎在评论区留言指正。