操作系统—GCC与编译全流程
GCC与编译全流程
文章目录
GCC与编译全流程
1.GCC是什么?
GCC全称GNU Compiler Collections,也就是FSF(自由软件基金会)的GNU推出的一个编译器集合,很有意思的是,GNU早期是为了实现一个与Unix一致,但是完全自由的操作系统而生的,所以GNU采取了一个递归命名,它的全称为GNU is Not Unix。
实际上,目前的GCC已经成为了C/C++编译器领域的一霸(还有基于LLVM的Clang和微软的MSVC),不过事实上GCC并不是GNU C Compiler,这个编译器集合包括的能够编译的语言远比我们想的多:C, C++, Objective-C, Fortran, Ada, Go, and D(来自GCC官网的介绍),不过现在主要还是拿它来编译C/C++。
所以背景已经了解完了,GCC是一个编译器集合,它可以帮助我们完成编译一个C语言程序的全部流程,如果只是为了得到一个可执行文件,我们只需要简单地输入这样一条指令:
gcc hello.c -o hello
这里指定了hello.c作为需要编译的源代码文件,然后用“-o + 名字”指定最终编译出的可执行文件的名字,所以接下来我们就应该分析一下整个编译流程了。
2.编译全流程
(1).GCC到底做了哪些事情?
我当然能够随手一搜就搜到编译流程主要是:预处理、编译、汇编、链接,但问题是,除了Warning和Error以外一般gcc即便编译成功了都十分沉默,一句话不说的,我怎么直观地知道gcc到底做了什么呢?
这些细节当然还是有方法能够获得的,比如首先是gcc的编译参数–verbose,它实际上已经足够帮我们看到编译的最关键步骤了,比如我们可以输入:
gcc hello.c --verbose &| vim -
# 或者
gcc hello.c --verbose &> hello.txt
上面的指令在后台执行,然后让vim读取stdin的输入,之后将gcc的编译日志管道到vim的输入当中,这样就可以在vim当中看到编译日志了,当然这种不方便保存,可以通过后面一条将日志重定向到hello.txt当中,这样就可以保存编译日志的细节了:
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none:amdgcn-amdhsa
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 11.4.0-1ubuntu1~22.04' --with-bugurl=file:///usr/share/doc/gcc-11/README.Bugs --enable-languages=c,ada,c++,go,brig,d,fortran,objc,obj-c++,m2 --prefix=/usr --with-gcc-major-version-only --program-suffix=-11 --program-prefix=x86_64-linux-gnu- --enable-shared --enable-linker-build-id --libexecdir=/usr/lib --without-included-gettext --enable-threads=posix --libdir=/usr/lib --enable-nls --enable-bootstrap --enable-clocale=gnu --enable-libstdcxx-debug --enable-libstdcxx-time=yes --with-default-libstdcxx-abi=new --enable-gnu-unique-object --disable-vtable-verify --enable-plugin --enable-default-pie --with-system-zlib --enable-libphobos-checking=release --with-target-system-zlib=auto --enable-objc-gc=auto --enable-multiarch --disable-werror --enable-cet --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none=/build/gcc-11-XeT9lY/gcc-11-11.4.0/debian/tmp-nvptx/usr,amdgcn-amdhsa=/build/gcc-11-XeT9lY/gcc-11-11.4.0/debian/tmp-gcn/usr --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu --with-build-config=bootstrap-lto-lean --enable-link-serialization=2
Thread model: posix
Supported LTO compression algorithms: zlib zstd
gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04)
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
/usr/lib/gcc/x86_64-linux-gnu/11/cc1 -quiet -v -imultiarch x86_64-linux-gnu hello.c -quiet -dumpdir a- -dumpbase hello.c -dumpbase-ext .c -mtune=generic -march=x86-64 -version -fasynchronous-unwind-tables -fstack-protector-strong -Wformat -Wformat-security -fstack-clash-protection -fcf-protection -o /tmp/cchifw1I.s
GNU C17 (Ubuntu 11.4.0-1ubuntu1~22.04) version 11.4.0 (x86_64-linux-gnu)
compiled by GNU C version 11.4.0, GMP version 6.2.1, MPFR version 4.1.0, MPC version 1.2.1, isl version isl-0.24-GMP
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
ignoring nonexistent directory "/usr/local/include/x86_64-linux-gnu"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/11/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-linux-gnu/11/../../../../x86_64-linux-gnu/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/x86_64-linux-gnu/11/include
/usr/local/include
/usr/include/x86_64-linux-gnu
/usr/include
End of search list.
GNU C17 (Ubuntu 11.4.0-1ubuntu1~22.04) version 11.4.0 (x86_64-linux-gnu)
compiled by GNU C version 11.4.0, GMP version 6.2.1, MPFR version 4.1.0, MPC version 1.2.1, isl version isl-0.24-GMP
GGC heuristics: --param ggc-min-expand=100 --param ggc-min-heapsize=131072
Compiler executable checksum: 50eaa2331df977b8016186198deb2d18
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a-'
as -v --64 -o /tmp/cc3Gumbl.o /tmp/cchifw1I.s
GNU assembler version 2.38 (x86_64-linux-gnu) using BFD version (GNU Binutils for Ubuntu) 2.38
COMPILER_PATH=/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/
LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/11/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/11/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'
/usr/lib/gcc/x86_64-linux-gnu/11/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/11/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/11/lto-wrapper -plugin-opt=-fresolution=/tmp/ccyTXV0s.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_s --build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed -dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/11/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/11 -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/11/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/11/../../.. /tmp/cc3Gumbl.o -lgcc --push-state --as-needed -lgcc_s --pop-state -lc -lgcc --push-state --as-needed -lgcc_s --pop-state /usr/lib/gcc/x86_64-linux-gnu/11/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/11/../../../x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64' '-dumpdir' 'a.'
编译日志非常长,但是我们仍然能够从中获取到很多关键信息,虽然我没有找到cpp,但是有几条信息比较关键:
/usr/lib/gcc/x86_64-linux-gnu/11/cc1 -quiet ...
as -v --64 -o /tmp/cc3Gumbl.o /tmp/cchifw1I.s
GNU assembler version 2.38 (x86_64-linux-gnu) ...
/usr/lib/gcc/x86_64-linux-gnu/11/collect2 -plugin /usr/lib/gcc/ ...
首先是cc1,它是真正的,狭义上的编译器,它的工作是将C的代码转换为汇编语言代码;后面的两行调用了as,也就是assembler,将上一步生成的汇编代码文件通过as转换为目标代码文件(.o文件);而最后一步调用了collect2完成了最后的链接工作,不过真正的链接器ld的细节可以通过:
gcc hello.c -Wl,--verbose >& hello.txt
来查看,-Wl会将后面传入的参数传入链接器,所以我们能够从新的日志当中提取出这些信息:
GNU ld (GNU Binutils for Ubuntu) 2.38
Supported emulations:
elf_x86_64
elf32_x86_64
...
/usr/bin/ld: mode elf_x86_64
attempt to open /usr/lib/gcc/x86_64-linux-gnu/11/ ...
所以编译的最后还会调用ld完成目标代码的链接工作,这一步之后才会生成可执行文件,所以我们大概就清楚GCC在整个流程当中到底做了些什么了,接下来我们就应该研究一下它们的细节了。
(2).预处理
I.预处理会做什么
上面的日志当中没有显示出预处理的细节,但是它的确是编译会进行的第一步,这一步会处理掉我们常用的很多东西,这一步需要使用cpp,即c preprocessor,C预编译器,我们常用的以#开头的各种语句都会在这一步被处理掉,例如:
#define A 1
#define B 2
int main()
{
int a = A, b = B;
return 0;
}
# 在bash中执行
cpp a.c -o a.i
在使用cpp完成预处理之后得到的结果是这样:
# 0 "a.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "a.c"
int main()
{
int a = 1, b = 2;
return 0;
}
我们刚刚加入的两条宏定义在这里被直接通过复制粘贴的方式进行了替换,这也就是预处理器做的最重要的工作:它会将我们不想做的重复操作在编译前全部完成替换,比如我们:
#include <stdio.h>
它会在预处理阶段把这一千来行代码全都复制粘贴过来,如果写的是C++,包含了iostream,这里可能会有个几万行代码,真的很夸张。
II.预处理器主要包含什么?
预处理器主要包含这样几个指令:
#include ...
#define ...
#undef ...
#ifdef ...
#else
#endif
#pragma
#error
在这里我就不细究它们的细节了,不过我们总是能利用预处理器在C语言里得到一些很有意思的事情,比如实际上cpp知道自己处理的文件叫什么名字:
#include <stdio.h>
int main()
{
printf("%s\n", __FILE__);
return 0;
}
很不错,它编译出来之后打印出了自己的名字:
还有包括__LINE__之类的宏能做到一些很神奇的事情,比如这个去掉一个printf可能就会报错的代码:
#include <stdio.h>
#include <assert.h>
void func()
{
printf("Dare you delete this line?\n");
assert(__LINE__ % 2 == 0);
}
int main()
{
func();
return 0;
}
现在还正常,所以我决定不删掉这一行,我加一行,会发生什么呢?
好吧,加一行也直接被断言中止了,实际上这个程序,哪怕在前面删一个空行它都会报错,所以我们可以用这种方式来捉弄一下我们的同学。
III.宏的一些魔法
其实预处理器对于我们来说最神奇的还是宏,它是最基本的直接替换的预处理器,比如我们可以简单写出:
#define A 1
那么在未来所有的单独出现的A都会在编译期被直接替换成1,所以我们发展出了内联函数的前辈—宏函数,比如大家经常写的:
#define MAX(a, b) ((a) > (b)) ? (a) : (b)
#define ABS(a) ((a) > 0) ? (a) : -(b)
这都是比较基本的宏,那么在此基础上还有宏操作符#和##能发挥一些神奇的作用,比如:
#define STR(a) #a
#define CONCAT(a, b) a##b
int main()
{
char* s0 = STR(nihao);
char* s1 = CONCAT(nihao, shijie);
return 0;
}
预处理之后的结果如下:
# 0 "c.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "c.c"
int main()
{
char* s0 = "nihao";
char* s1 = nihaoshijie;
return 0;
}
单独一个#会将传入的东西全部转换为字符串字面量,而##则会将前后的两个参数直接拼接到一起,所以我们总是能玩出一些很有意思的东西,比如:
#include <stdio.h>
#define CALL(x) func##x()
void func1() { printf("I'm func1\n"); }
void func2() { printf("I'm func2\n"); }
void func3() { printf("I'm func3\n"); }
int main()
{
CALL(1);
CALL(2);
CALL(3);
return 0;
}
我们用一条简单的宏替换指令完成了三个函数的统一调用,这可要比函数指针数组来的方便多了:
但是C的预处理器有一个巨大的问题:宏替换只能发生一次,因此如果我们希望通过一些递归调用的方式完成编译期的运算就做不到了,而正是因为这一点,C语言的宏并不是图灵完备的,而C++的模板最终实现了递归展开,从而达成了图灵完备。
不过还是有一些魔法能够让C语言做有限次数的递归展开,比如之前我的室友在写《编译原理》作业的时候遇到这样一个问题:
怎么样把一个枚举类型每一个变量的名字打印出来呢?
好问题,一个比较简单的解决方法是:
#include <stdio.h>
enum {
AAA, BBB, CCC, DDD
};
const char* enames[] = {
"AAA", "BBB", "CCC", "DDD"
};
int main()
{
printf("%s\n", enames[AAA]);
return 0;
}
这个实现,真的是相当的不优雅呢,每一次如果要添加一个枚举值就要两边同时增加,有没有什么办法能解决这个问题呢?
我实在是不想再运行期解决这个问题,所以查阅资料之后发现这样一段非常神奇的代码:
#include <iostream>
#define PARENS ()
// Rescan macro tokens 256 times
#define EXPAND(arg) EXPAND1(EXPAND1(EXPAND1(EXPAND1(arg))))
#define EXPAND1(arg) EXPAND2(EXPAND2(EXPAND2(EXPAND2(arg))))
#define EXPAND2(arg) EXPAND3(EXPAND3(EXPAND3(EXPAND3(arg))))
#define EXPAND3(arg) EXPAND4(EXPAND4(EXPAND4(EXPAND4(arg))))
#define EXPAND4(arg) arg
#define FOR_EACH(macro, ...) \
__VA_OPT__(EXPAND(FOR_EACH_HELPER(macro, __VA_ARGS__)))
#define FOR_EACH_HELPER(macro, a1, ...) \
macro(a1) \
__VA_OPT__(FOR_EACH_AGAIN PARENS (macro, __VA_ARGS__))
#define FOR_EACH_AGAIN() FOR_EACH_HELPER
#define ENUM_CASE(name) case name: return #name;
#define MAKE_ENUM(type, ...) \
enum type { \
__VA_ARGS__ \
}; \
constexpr const char * \
to_cstring(type _e) \
{ \
using enum type; \
switch (_e) { \
FOR_EACH(ENUM_CASE, __VA_ARGS__) \
default: \
return "unknown"; \
} \
}
MAKE_ENUM(NAME, A, B, C, D);
using namespace std;
int main()
{
cout << to_cstring(A) << to_cstring(B) << to_cstring(C) << endl;
return 0;
}
它能够让C++的预处理器支持256次递归展开的过程,并且还能优雅地只修改一个MAKE_ENUM里面的参数来实现字符串和枚举值的同步增加:
...
# 2 "enums.cpp" 2
# 35 "enums.cpp"
# 35 "enums.cpp"
enum NAME { A, B, C, D };
constexpr const char * to_cstring(NAME _e)
{
using enum NAME;
switch (_e) {
case A: return "A";
case B: return "B";
case C: return "C";
case D: return "D";
default: return "unknown";
}
};
using namespace std;
int main()
{
cout << to_cstring(A) << to_cstring(B) << to_cstring(C) << endl;
return 0;
}
前面的代码太多就省略了,预处理出来的结果不太好看我也整理了一下格式,于是就有了这段很优雅的代码,它会自动展开,生成一个枚举,之后再生成获取对应名字的一个函数,很神奇,但我实在是没有读懂它的原理。
(3).编译
I.基本流程
如果是广义上说的编译,那其实包括了四个流程,但是狭义上说,在C语言当中,编译就是编译器cc1将预处理后的代码完成语法分析、词法分析等一大堆过程,最终生成一个汇编代码文件的过程,所以我们可以直接自己尝试使用cc1完成这个编译操作:
#include <stdio.h>
int main()
{
printf("Hello, world!\n");
return 0;
}
首先用cpp完成预处理流程生成.i文件,然后再用cc1完成汇编工作生成.s文件
它会输出一些编译的分析日志,然后对应生成的汇编代码如下:
.file "hello.i"
.text
.section .rodata
.LC0:
.string "Hello, world!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@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
II.编译优化
主要内容还是在main标签下的内容,实际上也是简单的函数栈帧建立,然后中间调用puts函数完成打印工作,这就是编译流程做的工作了,实际上这一步我们可以尝试写一点有意思的代码,比如:
#include <stdio.h>
int main()
{
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
printf("sum = %d\n", sum);
return 0;
}
如果我们附加-O0参数进行编译,那得到的汇编代码是这样的(截取了核心部分):
...
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $0, -4(%rbp)
movl $1, -8(%rbp)
jmp .L2
.L3:
movl -8(%rbp), %eax
addl %eax, -4(%rbp)
addl $1, -8(%rbp)
.L2:
cmpl $100, -8(%rbp)
jle .L3
movl -4(%rbp), %eax
movl %eax, %esi
...
一眼看上去不是很显然,但是可以明显看到jle指令的使用,所以这串代码完成了一个循环加法的流程,这和我们的代码实现是基本一致的,但是假设这个时候我们在cc1编译的时候打开O1优化,那么代码会变成:
...
main:
.LFB0:
.cfi_startproc
subq $8, %rsp
.cfi_def_cfa_offset 16
movl $100, %eax
.L2:
subl $1, %eax
jne .L2
movl $5050, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
addq $8, %rsp
.cfi_def_cfa_offset 8
ret
.cfi_endproc
...
main的代码变短了,而且你可能发现了一个不和谐的东西:
movl $5050, %esi
很离谱,编译器识别出了你尝试从1加到100,然后直接把这个值算出来了,作为一个立即数直接存入参数寄存器,哇哦,这样一来,运行时期的效率就有明显的提升了呢。
III.一点例子
这里就要提到我之前经历过的一个很有意思的例子了,当时我在尝试演示一个Race Condition问题的代码的时候出了一些问题,代码是这样:
#include <stdio.h>
#include <pthread.h>
#define NUMS 10
int sum = 0;
void* T()
{
for (int i = 0; i < 1000000; i++) {
sum++;
}
pthread_exit(NULL);
}
int main()
{
pthread_t thr[NUMS];
for (int i = 0; i < NUMS; i++) {
pthread_create(&thr[i], NULL, T, NULL);
}
for (int i = 0; i < NUMS; i++) {
pthread_join(thr[i], NULL);
}
printf("sum = %d\n", sum);
return 0;
}
在我以习惯的编译命令编译运行之后,得到的结果是这样:
这真的很奇怪啊! 明明结果应该是一个不确定的数字的,为什么都是整的百万呢?在我百思不得其解的时候,我的天才同学想到一个可能:是不是编译器优化了? 我才终于想起来我用的编译命令是:
gcc a.c -o a --save-temps -O2 -pthread
问题就出在这个-O2身上,我们可以看一看它的汇编代码:
T:
.LFB24:
.cfi_startproc
endbr64
pushq %rax
.cfi_def_cfa_offset 16
popq %rax
.cfi_def_cfa_offset 8
xorl %edi, %edi
subq $8, %rsp
.cfi_def_cfa_offset 16
addl $1000000, sum(%rip)
call pthread_exit@PLT
.cfi_endproc
我这里把T函数的指令取出来了,这下我算是知道发生什么事情了:这个循环加法被优化成直接给sum加100万了,于是虽然会出现race condition让结果达不到1000万,但是结果也一定是整百万的,所以我赶紧改成了-O0,于是:
这就对了,所以这也是比较直观的,可能会遇到编译优化带来程序行为异常的地方。
(4).汇编
汇编这一步的操作实际上更像是我们尝试用写过的一门语言去执行写另一门语言要做的事情,简单说就是我们的C语言实际上在编译步骤就已经不复存在了,进入汇编阶段的时候,我们做的就是把汇编代码转换成机器代码了,如果我们不写C而直接写汇编代码,实际上也可以直接通过汇编器完成后面的工作,也就是说,从这一步开始,我们处理的对象就已经不再是C语言了,而是汇编语言代码。
所以我们还是用这段汇编代码来完成汇编工作,这是之前打印hello,world经过编译过程生成的汇编代码文件:
.file "hello.i"
.text
.section .rodata
.LC0:
.string "Hello, world!"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
leaq .LC0(%rip), %rax
movq %rax, %rdi
call puts@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
然后用as进行汇编操作,可以得到对应的目标代码文件,到这一步实际上就已经是机器语言代码了,我们可以用objdump来反汇编得到对应的汇编代码:
其实我们会发现,反汇编重新得到的汇编代码要比前面我们送进汇编器的代码精简很多,它能够更加精简地向我们展示编译出来的文件当中真正启动关键作用的部分是什么。
我们还可以用file命令查看一下hello.o这个文件的详情:
所以hello.o是一个x86-64架构的可重定位的64位ELF文件,我们用hexdump -C来查看一下hello.o:
真的是朴实无华的ELF头,它是真的在最初的几个字节保存了ELF几个字母对应的二进制编码,查阅资料后得知:可重定位文件包含了在编译过程中生成的机器代码、符号表、重定位信息等数据。与可执行文件相比,可重定位目标文件并不包含绝对地址,而是使用相对地址和符号引用来表示各个代码段之间的关系。所以这个阶段的文件只是可重定向文件,在没有完成链接步骤之前,是不可以执行的。
(5).链接
这就是,最后一步了…我们终于走了一串C语言代码变成可执行文件前的最后一步了,链接器会帮助我们把某个程序正常运行需要的所有库(静态库、动态库)、当前项目的所有可执行文件等等统统链接起来,形成一个真正可以直接用来执行的文件,在这里我们还是沿用前一步中已经获得的hello.o文件,这么做:
gcc hello.o -o hello
这里用回gcc而不是collect2或者ld的原因是:我并不知道我可能要链接的库在哪,所以专业的事情还是交给gcc做吧,所以得到的可执行文件我们可以再用objdump反汇编一下:
然后代码长了很多很多,跟刚才的objdump -d hello.o的结果相去甚远,实际上这也就是链接工作的复杂所在,为了让一个函数能够在当前系统环境下运行,我们可能需要调用很多的库,链接器做的也就是这件事:让你需要用的函数能够被程序找到,如果是静态链接则直接放入可执行文件,如果是动态链接则要保证你能找得到在哪,不过关于动态链接的细节我暂时还不明确,未来我应该还会继续探究。
再用file和hexdump看看hello这个文件:
这个文件是64位可执行的ELF文件,采取动态链接方式,我们可以生成一个静态链接和一个动态链接的版本对比一下它们的大小:
可以发现,动态链接版本的hello占用15888字节(大约15.5KB),而静态链接的版本则占用900344字节(大约879.2KB),其实最主要的区别就在于静态链接会将需要用到的库放入可执行文件当中,因此生成的可执行文件可能会非常大。
哦对了,还有一件事:
一个鲜活的Hello, world! 在此时终于呈现在你的面前了,你是否会发出一些感慨呢?我们早期学习编程的过程中在IDE中随手一点的编译运行,背后竟然还有这么多这么复杂的过程。
(6).说到这里,为什么我们要用gcc呢?
我想到这儿其实问题的答案已经很明显了:cpp、cc1、as、ld这几条命令的确是可以让我们手动执行,但是这样的编译过程很明显更方便一点:
gcc hello.c -o hello
作为一个完备的编译器,它帮我们做完了我们可能需要完成的全部操作,直接就可以得到一个可执行文件,我们也可以给它附加非常非常多参数来适应我们的要求,比如:
gcc hello.c -E -o hello.i # 得到预处理结果
gcc hello.c -S -o hello.s # 得到编译结果
gcc hello.s -c -o hello.o # 得到汇编结果
gcc hello.o -o hello # 得到可执行文件
# 以及更多
gcc hello.c -o hello --save-temps -Wall -Werror -Wl,--verbose -fsanitize=address
gcc真的为我们提供了非常多可以用的编译指令,让我们能够以更加轻松的方式完成对于程序的编译流程,fsanitize选项甚至可以帮助我们在编译期检查各种各样可能出现的问题,这极大增强了开发人员在编译过程中发现代码漏洞的能力。
3.还有别的选择吗?
当然,编译器不能一家独大,实际上基于LLVM的Clang/Clang++以及微软主推的MSVC都是目前市场上非常流行的编译器组件,C/C++的标准化委员会实际上不存在一个官方编译器,所有的编译器都是在标准推出之后由编译器厂商自主实现的,所以委员会定的标准,编译器厂商不一定听;委员会没有定的标准,编译器厂商也可能自己会加。
一个比较常见的例子就是__attribute__(),这是独属于GCC的编译指令,可以通过这一系列指令完成对于编译器的控制,比如要求禁止内联等等,这是一个对于编译器的强制要求,有的时候:
inline void func()
{
printf("I'm a simple function!\n");
}
编译器可能不会听你的,把这个函数作为一个内联函数像是宏函数一样粘贴到你调用它的位置,但你要是用__attribute__((noinline)),编译器是肯定不会把你的函数作为内联函数进行优化的。
4.杂谈
(1).自己编译一个GCC
这事儿我还真做过,还做过不止一次,实际上GCC的代码量很大,编译它的工作大约需要消耗半个小时左右,其实已经不算长了,因为让人难以想象的是:Chromium内核的编译可能需要6~8个小时,它甚至比编译Linux内核要用的时间要长得多。
它现在在我的WSL上安然地跑着,帮助我完成很多工作:
其实过程相当简单,甚至我说和Lab0编译risc-v工具链的流程都没有什么区别,你只要从gcc.gnu.org的git源下载到最新的源码,然后按照指南进行make即可。
(2).构建系统和Makefile
最后还想提一提构建系统和Makefile,编译一个大型项目对于C/C++理论上讲是个很头痛的过程,假设一个项目的依赖关系特别复杂,假设修改过一个文件,要想完成整个项目的重新编译就要耗费相当大的经历,因此构建系统基本上就是来完成这个工作的:我们作为程序员把程序的依赖关系理清楚,后面再想编译,就全部交给构建系统就好了。
早期出现的是Makefile,要说它是构建系统,我觉得它更像一个自动化工具,它实际上只会完成你让它做的事情,但是这并没有阻碍它成为一个良好的构建工具,例如:
a : a.c
gcc a.c -o a --save-temps -O2 -ggdb
clean:
rm a.o a.s a.i a
就这样我就可以通过make a && ./a的方式一键编译运行了,想要清楚掉编译产生的文件也只需要make clean即可。不过大家还是嫌Makefile太麻烦,于是出现了cmake、qmake、xmake、ninja等等一系列的各种构建系统,它们能够帮助我们更方便地完成项目依赖关系的构建。
总结
这一次的作业实质上探讨了一下GCC编译器的一些细节以及编译一个C程序会经历的预处理、编译、汇编和链接的全部流程,这也说明,编译器或许的确也是人类程序员智慧的结晶,毕竟归根结底,即便是操作系统,也不过是在机器上运行的一条条指令构成的程序罢了,当我们用C语言实现一个操作系统的时候,我们还是需要使用编译器来帮助我们完成全部的编译工作。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)