从源文件到可执行文件
平常在编程时候,我们在编辑器里面敲出一行行代码,最后得到了源文件。例如c语言最后得到后缀.c的文件,c++最后得到后缀.cpp的文件。在集成开发工具(IDE)中,例如微软的Visual Studio或者JetBrains的Clion,我们只需要点击运行,就可以得到输出结果。好像从源文件一步走到了可执行文件,然后执行得到结果。
但是在linux系统中,特别是没有图形界面的linux系统,是没有集成开发工具使用的,所以我们需要自己完成从源文件到可执行文件的每一步。
gcc/g++就是完成这些工作的编译器,其中gcc可以编译c语言源文件,g++可以编译c++和c语言的源文件。
预编译
从源文件到可执行文件的第一步就是预编译。“预”的意思就是正式编译前的准备阶段,预编译过程主要处理那些源代码文件中以#开始的预编译指令,比如#include,#define等。预编译阶段做的工作主要分为如下几步:
将所有的#define删除,并且展开所有的宏定义。例如
#define _MAX 100
那么在预编译阶段之后,所有的_MAX就会被替换成100。
处理所有条件预编译指令,例如#if,#ifdef,#elif,#else,#endif等。
处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。当被包含文件中还包含其他文件时,这一步就会递归执行,直到所有的预编译指令展开。
删除所有注释//和/**/。
添加行号和文件名标识,以便于编译时编译器产生调试用的行号信息以及用于编译时产生编译错误或警告时能够显示行号。
保留所有的#pragma编译器指令,因为编译器需要使用他们。
在预编译阶段完成之后,将会得到后缀为.i的文件,此时.i文件已经不包含任何宏定义,因为所有的宏都已经被展开,并且包含的头文件也已经被展开。
当我们怀疑宏定义或者头文件有错误时,可以查看.i文件来寻找、确定问题。
例如简单的 Hello World代码
#include <stdio.h>
int main() {
printf("hello world!\n");
return 0;
}
执行预编译指令(预编译指令需要参数 -E)
gcc -E hello.c -o hello.i
之后会得到一个hello.i文件,查看该文件会得到
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "hello.c"
//中间省略800行
extern int __uflow (FILE *);
extern int __overflow (FILE *, int);
# 873 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 3 "hello.c"
int main() {
printf("hello world!\n");
return 0;
}
此文件多达八九百行,主要原因就是stdio.h头文件被展开并插入到源文件中
编译
编译阶段就是把预处理完成后的文件进行一系列的词法分析、语法分析、语义分析以及优化后生成相应的汇编文件。简而言之,编译阶段就是检查错误,没有错误就生成汇编文件。为什么要生成汇编文件?我们都知道机器可以执行的指令叫做机器码,为什么不直接生成机器码而要生成汇编语言呢?因为从高级语言到机器语言需要一个中间人,把高级语言翻译成机器语言。对于c/c++语言,这个翻译器就是用汇编写的,也只接受汇编语言,输出机器可以执行的机器语言。
同时,使用机器语言或者汇编语言完成功能,其过程是极其繁琐和枯燥的,开发效率是极其低下的。所以人们就希望有一个程序可以将高级语言转变成汇编语言,或者将汇编语言转变成机器语言,编译器和汇编器就应运而生。当拥有编译器和汇编器之后,我们只需要输入高级语言,就可以得到可执行的机器语言,十分方便和快捷。
执行编译指令(编译指令需要参数 -S)
gcc -S hello.i -o hello.s
如此以来,就可以将预编译阶段得到的hello.i编译成hello.s文件。
.file "hello.c"
.text
.section .rodata
.LC0:
.string "hello world!"
.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
leaq .LC0(%rip), %rdi
call puts@PLT
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.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:
以上就是编译hello.i文件得到的hello.s汇编文件。
上面说过,将高级语言转化成汇编语言是极其繁琐和复杂的,所以这一步也是从源文件到可执行文件各步骤中耗时最长,消耗资源最多的一个阶段。
汇编
汇编阶段就是将上一步得到的汇编输出文件翻译成机器语言。汇编语言是十分接近硬件的语言,每一个汇编语句都对应一条机器指令,或者说,汇编语言是人们为了方便记忆和使用机器指令创造的机器指令的别名。所以将汇编语言翻译成机器语言比较简单,只需要将一一对应的两种语言转换一下就好了。
执行汇编指令(汇编指令需要参数 -c)
gcc -c hello.s -o hello.o
如此便生成了目标文件hello.o。
链接
链接是一个很复杂的过程,主要分为静态链接和动态链接过程。
在编程过程中,我们习惯于将实现某些小功能的小模块封装起来,得到很多的源代码文件。在编译过程中,这些文件就会得到一个又一个的目标文件。而完成实现一个程序需要这些目标文件一起使用,所以需要将这些文件链接起来,去服务于主程序,这就是链接的目的。
链接的过程主要包括了
地址和空间的分配。
符号决议
重定位
完成上述步骤之后就会最终得到我们想要的可执行文件。链接中的各个步骤将在后面的文章中详细介绍。
执行链接指令
gcc hello.o -o hello
如此一来,就将hello.o和别的必须的库文件链接生成了可执行文件 hello,运行hello,就会得到输出
hello world!
至此,就走完了从源文件到可执行文件的全过程。
下面简单介绍一下静态链接和动态链接
静态链接
静态链接简而言之就是,链接过程中生成静态库,再在程序需要某些库的时候将库文件插入到程序之中,相当于将封装的模块重新插入到程序之中,使得程序能够正常执行。可以看出,经过静态链接的文件已经获得了所以执行所需的必要条件,所以直接就可以运行了,同时这种特性也使得静态链接文件执行起来非常迅速。但是静态链接也有一个缺点,就是不同的文件需要同一个模块时,每一个程序都会拷贝一份该模块,使得该模块在内存中存在大量的副本,非常消耗内存。
动态链接
为了解决静态链接对内存的浪费问题,动态链接应运而生。简单地讲,动态链接就是不对那些组成程序的目标文件进行链接,在程序运行时才对其进行链接,把链接过程推迟到了运行时,同时模块也不会在每个程序中都复制一份副本,而是执行时各个程序交替使用同一模块。如此一来,对内存的消耗就会大大减小。
编译常用的参数
下面总结一些常用参数及作用
参数 | 作用 |
-E | 生成预编译文件 |
-S | 生成汇编文件 |
-c | 只做预编译,编译,汇编处理,生成目标文件 |
-I | 指定头文件所在目录 |
-g | 编译时添加调试语句(为gdb调试做准备) |
-Wall | 显示所有警告信息 |
-D | 动态注册宏定义 |
欢迎访问我的个人博客www.chanaizz.cc了解更多!