先来看一个程序
#include
int main()
{
printf("Hello World!\n");
return 0;
}
这个程序可以说是每一个程序员写出来的第一个程序。可是它到底是怎么在一个计算机上运行起来呢?
众所周知,计算机是不认识这些单词的,计算机只能识别0101的二进制文件。所以我们首要的任务就是把这个c语言文件转换成一个二进制文件。
我们这里只讨论编译过程,屏蔽硬件知识。
罗马不是一天建成的,编译(bulid)的过程也不是一步完成的。
一个源文件编译1成可执行文件的过程可以分成四个步骤:预编译、编译2、汇编、链接
若是从高级语言的bulid过程来看,这四个步骤是从前到后的顺序,但是如果从发展历史来看,越靠后资历越老。鉴于大多数资料可能都是从前往后的顺序来讲的,那我们反其道而行之,我们先从爷爷辈的来讲,简单的贯穿一下发展历史。然后在自顶向下再来将一遍。
这个是资历最老的一个过程,也是最容易被忽略的。
很久以前,人们没有磁盘,没有高级语言,甚至没有汇编语言。那个时候的程序员怎么写程序呢?
对,用010101,用计算机能看懂的二进制来写程序,不同的01组合表示不同的指令。那当时也没有磁盘这些东西,他们写到哪里呢?-写到纸带上,打个孔就代表0,否则为1。如图,可见当时的程序员和现在的程序员不是一个概念了…(牛X)
一个程序并不是一个人来完成的(况且全都是二进制,一个人写要死啊),而是将程序分成不同的模块,由不同的人来编写,这一点和我们现在编程也是一样的。
三个人写,那么就有三条纸带,那么现在!链接的概念就来了,暂且不管他是用胶水还是用啥高级的东西,总之得需要把这三条纸带连起来,这就是链接。
说完链接,我们还需要在引入一个概念叫做链接器
链接器后来有些程序员觉得,每次都要手动链接,这也太麻烦了,于是他们就造了一个可以自动链接的机器,就叫链接器。
要将链接器,我们就需要拓展一下知识,学过计算机组成原理的同学都知道,计算机有一条指令叫做jump,也就是跳转指令。没学过计组也没关闭,毕竟你现在知道了。
让我们穿梭时空,看看当时上文中三个程序员写了个啥程序。我们用伪指令来看一下。
程序员A
1 一个操作
2 跳转到第6行
3 一个操作
4 跳转到第9行
5 一个操作
程序员B
6 一个操作
7 跳转到3
8 一个操作
程序员C
9 一个操作
10 一个操作
所以你现在明白了链接就是把1-10行连接起来。
有的同学可能会问:A在写程序的时候怎么知道,第2行是要跳转到第6行呢?
A刚开始写的时候当然不知道,他也是等B写完之后又把这个6,以及后面的9加上的。
程序写好之后并不是一成不变的,B某一天发现自己程序里面的一个BUG
于是呢,他就需要修改一个他的那一部分,他就在第6行之前又新加了一行,成了这样:
6 一个操作
7 一个操作
8 跳转到3
9 一个操作
相应的C的编号就要向后移动。
虽然B的bug改完了,但是由于从第6行开始编号都往后移动了,而且程序里面存在这许多跳转语句。A就不得不修改他的跳转语句。例如将第二行修改为:跳转到7
这个重新计算跳转目标地址的过程也就是我们常说的重定位
来来来,不要忘了我们这一节的主题是什么。链接器
由于繁琐的重定位,程序员表示十分崩溃,所以就有了可以自动重定位的链接器。
关于链接,我们暂时先了解这么多。至于链接器是怎么自动重定位的,我们先把汇编看了再来说。
汇编汇编语言是比机器语言更高一级的语言。
机器语言中跳转指令是0001,实在是太难记了,我们能不能用个好记一点的东西呢。于是汇编语言就诞生了,它用 jmp(jump)这三个字母来表示跳转指令,当输入jmp的时候汇编器会将其翻译成0001,这样一来就大大提高了可读性和效率。
汇编语言诞生的另一个原因就和链接有关系了,这也是链接器的工作原理。
上文中,我们说到了链接器是用来自动重定位的。
当B修改了他的代码之后,链接器在链接的时候怎么知道该把A从的地址重定位到哪里呢? 答案是:不知道。
但是我们现在有了汇编语言,我们不再需要用0001 0110(后四位表示地址,0110也就是6),这个指令来跳转了。
我们只需要在第六行设置个标签,比如说为label_b,然后A中的跳转指令就为 jump label_b。汇编器在翻译的时候就知道label_b是一个地址,那么到底在哪呢?这就不是汇编器该操心的事了,这就是链接器的活了。
链接器在链接的时候就会去找label_b的地址,然后给填进去。
这样一来,无论怎么修改代码,都不需要人为的重定位了。
上面主要是讲了汇编和链接器的一些关系和汇编的由来。
回到主题:汇编就是用简单的助记符来记住那些难记的机器指令。
编译与预编译编译就是把高级语言经过一系列复杂过程最终编译成汇编语言。
至于它是怎么编译的,这就有衍生出了一个专门的课程,叫做编译原理。我们暂且不用管。
至此我们差不多将编程语言的发展历史串了一遍。
那么我们回到本文的主题——一个程序是如何运行起来的。
我们带着代码一点一点的剖析,来看一看每一个过程到底完成了哪些事。
现代的编译器把编译、链接的细节都隐藏起来了,我们只需要点击一下鼠标就可以完成从源代码到可执行文件的转变。
我们要做的就是一层一层给它剥开,相信看完了下面这些这些内容相信能给你打开新世界的大门。
预编译
预编译要做那些事情呢?
将#include 包含的所有文件内容包含进来。 去除注释 处理预编译指令 处理#define宏定义来看一个C文件hello.c
#include
int add(int a,int b);
#define Max 1
//main 函数
int main()
{
int a=0;
int b=Max;
int c=add(a,b);
printf("%d\n",c);
return 0;
}
(细心的同学可能发现了,add函数只定义了,但是没有实现啊!别急别急,我后面还有用呢)
linux 下执行命令:gcc -E hello.c -o hello.i
预处理之后的结果:传送门
代码量过大,容易引起不适。
我们只看主要的代码
# 2 "hello.c"
int add(int a,int b);
int main()
{
int a=0;
int b=1;
int c=add(a,b);
printf("%d\n",c);
return 0;
}
第一行是预处理自动加上的,是一条注释信息,可以忽略。
我们可以看到这就是预处理之后的结果,省略的一大坨就是stdio.h头文件的内容。
ok,这就是预编译的功能。
编译Linux下调用命令:gcc -S hello.i -o hello.s
至于这个编译器到底是怎么编译的?我们在这里无需关系,这就是编译原理的事情了。
总之,它把我们预编译之后的文件编译成了汇编语言文件。
看不懂没关系,我们不用深究里面写了写什么。
.file "hello.c"
.text
.section .rodata
.LC0:
.string "%d\n"
.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
subq $16, %rsp
movl $0, -12(%rbp)
movl $1, -8(%rbp)
movl -8(%rbp), %edx
movl -12(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call add@PLT
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0"
.section .note.GNU-stack,"",@progbits
可能有的同学会问,那个add()函数没有定义是怎么编译成功的呢?
在编译器上编译会报错的啊!
上面我们提到过,编译器把所有过程都隐藏了起来,点击编译的时候,它实际上完成了从预编译到最终链接的全部过程。
我们现在所说的编译只是其中的一个过程,只要符合c语言的语法语义,就可以编译成功。
至于哪个函数有没有实现,到底在哪实现的,并不是编译器关心的事情,那是链接器关心的事情。
提一嘴,众所周知,C语言的入口是从main函数开始的。
如果可以自己写一个C语言的链接器,那么C语言的入口就可以我们自己来定义了。
汇编没什么特别要说的,就是按照规则把汇编语言翻译成机器语言。
Linux 命令: gcc -c hello.c -o hello.o
此时我们得到的hello.o仍然不是一个可执行文件而是一个中间文件,叫做目标文件。为什么?
add()函数啊!我们上面调用了add函数,但是我们并不知道这个函数在哪
我们新建一个文件add.c
int addd(int a,int b){
return a+b;
}
按照上面步骤得到它的目标文件 add.o
剩下的事不就和我们上面讲的3个程序员的故事差不多了吗。
我们只需要把两个文件链接起来,告诉hello.o文件add函数的地址在哪(重定位),不就好了。
使用链接器 ld 将两个目标文件链接起来。
Linux:ld hello.o add.o -o hello.out
但是使用上述命令会报错,为什么呢?Linux系统,链接目标文件生成可执行文件比我们想象的要复杂许多,因为生成一个C++可执行文件,需要依赖很多系统库和相关的目标文件,比如C语言库libc.a。使用g++ -v命令可以查看最后一行collect2使用的命令选项,进而了解生成可执行文所需的相关依赖。3
g++ -v hello.c add.o
看不懂没关系,往下看就好。
Using built-in specs.
COLLECT_GCC=g++
COLLECT_LTO_WRAPPER=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper
OFFLOAD_TARGET_NAMES=nvptx-none
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-linux-gnu
Configured with: ../src/configure -v --with-pkgversion='Ubuntu 7.5.0-3ubuntu1~18.04' --with-bugurl=file:///usr/share/doc/gcc-7/README.Bugs --enable-languages=c,ada,c++,go,brig,d,fortran,objc,obj-c++ --prefix=/usr --with-gcc-major-version-only --program-suffix=-7 --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-libmpx --enable-plugin --enable-default-pie --with-system-zlib --with-target-system-zlib --enable-objc-gc=auto --enable-multiarch --disable-werror --with-arch-32=i686 --with-abi=m64 --with-multilib-list=m32,m64,mx32 --enable-multilib --with-tune=generic --enable-offload-targets=nvptx-none --without-cuda-driver --enable-checking=release --build=x86_64-linux-gnu --host=x86_64-linux-gnu --target=x86_64-linux-gnu
Thread model: posix
gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)
COMPILER_PATH=/usr/lib/gcc/x86_64-linux-gnu/7/:/usr/lib/gcc/x86_64-linux-gnu/7/:/usr/lib/gcc/x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/7/:/usr/lib/gcc/x86_64-linux-gnu/
LIBRARY_PATH=/usr/lib/gcc/x86_64-linux-gnu/7/:/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/:/usr/lib/gcc/x86_64-linux-gnu/7/../../../../lib/:/lib/x86_64-linux-gnu/:/lib/../lib/:/usr/lib/x86_64-linux-gnu/:/usr/lib/../lib/:/usr/lib/gcc/x86_64-linux-gnu/7/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-shared-libgcc' '-mtune=generic' '-march=x86-64'
/usr/lib/gcc/x86_64-linux-gnu/7/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/7/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/7/lto-wrapper -plugin-opt=-fresolution=/tmp/ccSXMzne.res -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lc -plugin-opt=-pass-through=-lgcc_s -plugin-opt=-pass-through=-lgcc --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/7/../../../x86_64-linux-gnu/Scrt1.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/7/crtbeginS.o -L/usr/lib/gcc/x86_64-linux-gnu/7 -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/7/../../../../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/7/../../.. hello.o h2.o -lstdc++ -lm -lgcc_s -lgcc -lc -lgcc_s -lgcc /usr/lib/gcc/x86_64-linux-gnu/7/crtendS.o /usr/lib/gcc/x86_64-linux-gnu/7/../../../x86_64-linux-gnu/crtn.o
COLLECT_GCC_OPTIONS='-v' '-shared-libgcc' '-mtune=generic' '-march=x86-64'
可以看到倒数第二行collect2使用的命令参数,collect2 是 ld 的一个封装。
我们只要把collet2的参数复制到ld后面就可以了,然后在加上 -o hello.out。
就完成了链接,现在hello.out 就是一个可以运行起来的文件了!
本节就到这里,至于这个可执行文件是怎么运行起来的,请关注我下一篇吧~
学艺不精,能力有限。如有纰漏,多多指教!抱拳了
此处编译意思为将源文件转换从二进制文件的整个过程,其实用build更好,使用编译是为了便于理解 ↩︎
此处的编译为build的一个中间过程 ↩︎
参考资料:https://dablelv.blog.csdn.net/article/details/88094902 ↩︎