《程序员的自我修养》读书笔记——一个程序是如何运行起来的一:源代码怎么变成可执行文件

Rhoda ·
更新时间:2024-09-21
· 914 次阅读

先来看一个程序

#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语法时会接触到的。

来看一个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 ↩︎


作者:(羽翼)



运行 程序 可执行文件 源代码 读书 程序员

需要 登录 后方可回复, 如果你还没有账号请 注册新账号