前言:
在Linux开发领域,gcc/g++ 是构建C/C++程序的基石工具链,其能力直接决定了代码从文本到可执行文件的转化效率与质量。无论是初学者调试第一个“Hello World”,还是资深工程师优化大型项目,深入理解编译器的核心机制与编译流程,都是突破开发瓶颈、驾驭工程复杂性的必经之路。
关键词:编译流程、头文件搜索路径、符号表、静态库(.a)、动态库(.so)
本讲将围绕 gcc/g++的编译四阶段(预处理、编译、汇编、链接) 展开,详解各环节的作用与底层逻辑。
让我们开始吧!
目录
一、gcc
1.1啥是gcc、g++
1.11gcc、g++概念
Linux中的gcc是由GNU推出的一款功能强大的、性能优越的多平台编译器。gcc编译器能将C、C++语言源程序和目标程序编译、连接成可执行文件。
1.12 gcc与g++区别
那么gcc和g++的区别又是什么呢?
只要是 GCC 支持编译的程序代码,都可以使用 gcc 命令完成编译。可以这样理解,gcc 是 GCC 编译器的通用编译指令,因为根据程序文件的后缀名,gcc 指令可以自行判断出当前程序所用编程语言的类别。
但如果使用 g++ 指令,则无论目标文件的后缀名是什么,该指令都一律按照编译 C++ 代码的方式编译该文件。也就是说,对于 .c 文件来说,gcc 指令以 C 语言代码对待,而 g++ 指令会以 C++ 代码对待。但对于 .cpp 文件来说,gcc 和 g++ 都会以 C++ 代码的方式编译。
1.2 安装GCC
在 Ubuntu 系统中,可以使用以下命令安装 GCC:
1 sudo apt update
2 sudo apt install build-essential
//build-essential 软件包包含了 GCC 及其相关的工具和库。
1.3 基本语法
GCC 的基本语法如下:
gcc [options] [source files] [object files] [libraries]
- options:编译选项,控制编译过程的行为(如优化、警告、调试等)。
- source files:需要编译的 C/C++ 源代码文件(如
.c
、.cpp
)。- object files:已编译的中间文件(如
.o
或.obj
),直接参与链接阶段。【这个在日常学习当中比较少见,一般是当编写的项目较大时,可单独编译部分模块为目标文件,后续仅重新编译修改过的文件以节省时间。】- libraries :链接所需的静态库(
.a
)或动态库(.so
/.dll
)
1.4 gcc的选项
注意:在选项的讲解当中会存在关于文件编译的知识,这些编译知识会在后面有所介绍,并且主要的选项的介绍场景都在第二大节中。
一些gcc和g++常用的选项:
- -E 只完成预处理【这个不生成文件,你需要把它重定向到一个输出文件里面】
- -S 编译到汇编阶段
- -c 编译到目标代码
- -o 对编译结果进行打包并重命名一个新的文件
- -static 此选项对生成的文件采用静态链接
- -g 生成调试信息。GNU 调试器可利用该信息。
- -shared 此选项将尽量使用动态库,所以生成文件比较小,但是需要系统由动态库.
- -O0 -O1 -O2 -O3 编译器的优化选项的4个级别,-O0表示没有优化,-O1为缺省值,-O3优化级别最高
- -w 不生成任何警告信息。
- -Wall 生成所有警告信息
1.5 一个简单的测试样例
gcc编译的基本语法与选项就是上面的那种格式,那么具体该如何使用呢?我们通过一个C语言的代码来试一下
#include<stdio.h>
#include<math.h>
int main()
{
printf("10+20=%d\n",10+20);
return 0;
}
写入之后通过以下命令编译该程序
gcc test.c -o test.exe -std=c99
(test.c就是我们需要编译的文件,test.exe就是我们重命名后的文件名,编译好后的可执行文件就是这个)
实际运行结果。
- 关于选项的实际书写的位置是不影响结果的。
- -std=c99是指明编译器编译时遵循的语言标准。
二、文件的编译阶段
背景知识
在我们讲解完GCC的几个常用选项及其背后原理之后,我们再来补充了解一个背景知识,这个在我们之前用VS学习C语言和C++的时候是学过的,那就是——我们所编写的代码是如何经编译器处理一步步生成一个可执行程序并得到执行的?
主要是分四步走:
1. 预处理——预处理功能主要包括宏替换,去注释,条件编译等
2. 编译——生成汇编
3. 汇编——生成机器可识别代码
4. 连接——生成可执行文件或库文件
接下来我们就通过在Linux环境下进行测试程序编译的四个过程:预处理,编译,汇编,链接
。
2.1文件的预处理
在预处理的过程中我们的程序会有以下变化: 头文件的展开、条件编译、宏的替换和注释的删除
等操作。
下面我们就在Linux环境下通过一个简单的C语言程序来演示这个过程:
#include<stdio.h>
#define HOME
#ifndef HOME
int main()
{
printf("你很好啊,这是一段测试代码关于条件编译的代码\n");
return 0;
}
#else
int main()
{
printf("HOME:%d\n",HOME);
printf("你好啊,这是一段测试代码关于条件编译,宏替换,头文件展开,注释删除\n");
return 0;
}
#endif
我们使用
gcc -E pretreatment.c -o test.i
打开编译形成的test.i文件发现,文件变大,说明了头文件展开,根据我们的条件编译语法发现,没有要求编译的部分,被剪掉了。宏也被替换了。
2.2 文件编译
编译的过程其实是将我们的C语言程序翻译成汇编语言的过程。
此过程包括以下几个步骤:
- 词法分析:将源代码分解成 tokens(词法单元)。
- 语法分析:根据语言的语法规则检查语句的正确性。
- 语义分析:检查程序的语义,例如变量是否已定义、类型是否匹配等。
以确定代码的实际要做的工作,在检查无误后,gcc 把代码翻译成汇编语言。
在这里我们可以使用 ‘’-S‘’ 选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。我们可以直接使用在预处理后生成的.i文件来进行操作,也可以在源代码文件上操作。
命令:gcc -S 源文件 -o 目标文件名.s
gcc -S test.c -o test.s
运行结果:
我们需要注意的是:编译产生的文件一般以.s
为后缀,下面我们查看一下新生成的.s文件的内容
.file "test.c"
.text
.section .rodata
.LC0:
.string "10+20=%d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $30, %esi
leaq .LC0(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.4.0"
.section .note.GNU-stack,"",@progbits
.section .note.gnu.property,"a"
.align 8
.long 1f - 0f
.long 4f - 1f
.long 5
0:
.string "GNU"
1:
.align 8
.long 0xc0000002
.long 3f - 2f
2:
.long 0x3
3:
.align 8
4:
2.3文件的汇编(动静态库)
汇编阶段的任务是将汇编语言代码转换为机器码。GCC 使用汇编器(如 as
)将 .s
文件转换为目标文件(.o
文件)。目标文件是二进制格式,包含了机器码和必要的符号信息。
相关选项:
gcc -c 源文件/源文件.s -o 目标文件名.o
链接后的文件内容展示
当然我们发现完全读不通,我们利用也可以使用od
指令来看一下文件中的二进制指令,这里就不再演示了。
2.3文件的链接
最后一步是链接。链接器(如 ld)将一个或多个目标文件和所需的库文件(如标准库)结合起来,生成最终的可执行文件。链接器的主要任务包括:
- 符号解析:在目标文件之间解决函数和变量的引用。
- 地址分配:为代码和数据分配内存地址。
链接的结果是一个可执行的二进制文件,通常以无扩展名或 .out 扩展名表示。
gcc 源文件.o -o 目标文件名
这里就生成了一个名字为pertreatment.exe的可执行程序。当我们直接执行它时就可以生成我们最终想要的结果:
链接是这几步里面最需要讲解的,因为它涉及到一个叫函数库的概念,函数库我们在下面详细讲解吧。
2.4 总结
GCC 的编译过程可以概括为以下几个步骤:
- 预处理:处理宏和头文件,生成
.i
文件。- 编译:将
.i
文件转换为.s
汇编文件。- 汇编:将
.s
文件转换为.o
目标文件。- 链接:将
.o
文件和库文件链接,生成可执行文件。
这里关于编译时的选项有个记忆技巧,看左上角的”ESc“,我们的选项恰好对应;关于编译后的文件后缀”.iso“记忆,而它恰好就是 光盘 的 镜像文件的后缀,ISO文件一般以iso为扩展名。
三、动态库与静态库
在我们代码中,会有printf等库函数,我们并没有对它们的实现,而且预编译"stdio.h"中,也只有对它的声明,没有对它的实现,那么这些函数是在哪进行实现的呢?
其实系统把这些函数实现都被做到名为 libc.so.6 的库文件中去了,在没有特别指定时,gcc 会到
系统默认的搜索路径“/usr/lib”下进行查找,也就是链接到 libc.so.6 库函数中去,这样就能实现函
数“printf”了,而这也就是链接的作用
我们可以查看路径”usr/lib“中所有的函数:
函数库分为静态库和动态库两种
- 静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了。其后缀名一般为“.a”
- 动态库与之相反,在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为“.so”,如前面所述的 libc.so.6 就是动态库。
- gcc 在编译时默认使用动态库。完成了链接之后,gcc 就可以生成可执行文件
3.1 动态库
动态库:
- 动态库在编译链接时并没有把库文件的代码加入到可执行文件中,而是在程序执行时由运行时链接文件加载库,这样可以节省系统的开销。动态库一般后缀名为“so”,如前面所述的libc.so.6 就是动态库。gcc 在编译时默认使用动态库。
动态链接
- 链接的时候,如果是动态链接,找到动态库,然后拷贝动态库中我们需要的代码的地址到我们自己的可执行程序中相关的位置。
我们可以使用ldd命令
—查看依赖的动态库列表
同样我们也可以使用file指令,它可以帮助我们查看我们所调用的库类型
补充知识:
- 库的本质也是文件,静态库的格式一般是:libxxxxxx.a,静态库的前缀是以lib为前缀,.a为后缀。
- 而动态库的一般格式则是:libxxxxxx.so,动态库的前缀是以lib为前缀,.so为后缀。
- 中间的xxxxxx是库的名称。
3.1 静态库
静态库:
静态库是指编译链接时,把库文件的代码全部加入到可执行文件中,因此生成的文件比较大,但在运行时也就不再需要库文件了。其后缀名一般为“.a”。
静态链接
链接的时候如果是静态链接,找到静态库,然后拷贝静态库中我们需要的代码的到我们自己的可执行程序中相关的位置。
与动态库不同的是:C和C++静态库需要我们自己去安装。
gcc在编译时默认使用的是动态链接,要想使其静态链接,需要在编译时在后面加上-static
gcc 源文件.c -o 目标文件名 -static
这里我们可以看到静态链接的方式最终链接形成的可执行程序所占用的内存非常大。
这个也不难理解,这是因为静态链接直接拷贝了静态库中的代码到我们的可执行程序中了。
3.3 总结
下面让我们对比一下静态库/静态链接和动态库/动态链接的优缺点:
- 静态链接成功,我们的程序不需要依赖任何库,自己就可以独立运行。
- 动态链接成功,我们的程序还是需要依赖动态库,一旦动态库缺失,我们的程序便无法运行。
- 静态库由于是自生拷贝的问题,所以比较浪费空间。
- 动态库因为可以做到被大家所共享方法,所以真正的实现永远都是在库中。程序内部只有地址,比较节省空间。
- 静态库VS动态库:Linux默认使用的是动态链接和动态库。
总结 :
GCC 是 Linux 中强大的编译工具,灵活且功能丰富。熟练掌握 GCC 的使用,有助于提升编程效率,并使得开发过程更加顺畅。希望本文能为你提供一个良好的起点,帮助你开始使用 GCC 编译器。
我们下期见!