上帝模式看程序从出生到死亡

111862021-5e9d18823bc285bb

进程的一生

GitHub : Jerry4me


前言

我们中有许多程序员打码几年还没有搞清楚一个程序从源代码 -> 可执行程序 -> 执行 -> 死亡, 经历了什么变化. 他们只知道, 编译, 链接, 运行…由于强大的IDE已经帮我们把这些过程屏蔽掉了, 我们不知道底层他们干了什么. 但是我们只有明白这些运行机制和机理, 才能解决一些莫名其妙的错误, 提升性能瓶颈.

笔者在看了>这本书后决定把这些过程用比较简单易懂的文字叙述出来, 如有不对的地方还请各位指出, 谢谢~


目录

预编译
编译
汇编
目标文件
链接
可执行文件
装载
动态链接(需要的话)
运行
死亡
小知识


编译

编译又分为 预处理(Preprocessing), 编译(Compilation)汇编(Assembly).

预编译

预编译过程主要处理源代码文件那些#开头的预编译指令

编译

编译过程可分为6部 : 扫描, 语法分析, 语义分析, 源代码优化, 代码生成和目标代码优化.

汇编

汇编器将汇编代码转换成机器可以执行的指令, 输出目标文件. 该过程比较简单, 就是翻译代码.

经过上述多个步骤, 源代码终于被编译成了目标文件. 这个目标文件肚子里又卖的是什么药呢? 我们接着看~

目标文件

由于不同的操作系统下, 目标文件, 可执行文件等都有些出入. 本文是用Linux系统下的ELF文件作为例子

编译之后生成的目标文件内容肯定少不了机器指令代码, 数据等. 不过除了这些之外, 目标文件还包括了链接时所需的一些信息, 而目标文件将这些信息按照不同的属性, 以段(Section)来存储.

Question :
为什么要把数据和指令分开呢? 经典的冯诺依曼体系不是不分指令还是数据的吗?

Answer :
1 : 当程序被装载后, 数据和指令被映射到两个虚存区域. 数据区域对于进程而言, 是可读写的, 而指令区域则只可读. 这样方便分别设置他们的权限, 防止程序指令被恶意修改
2 : 把指令和数据分开有利于提高程序的局部性, 对于提高CPU缓存命中率有帮助
3 : 最重要的原因, 当系统中运行着多个该程序的副本时, 他们的指令都是一样的, 所以进程之间能共享指令和其他只读数据, 而数据区域则为进程私有. 如果系统中运行了数百个进程, 可以想象共享为我们节省了多少空间

这里插一句 : 其实不是可执行文件才才按照执行文件的格式存储. 什么意思呢? 除了可执行文件之外, 目标对象, 动态链接库, 静态链接库也按照可执行文件的格式存储. 某种程度上他们也是可执行文件. 所以我们可以把他们视为同一类文件

目标文件有什么

ELF文件头(ELF Header)

包含了整个文件的基本属性

段表(Section Header Table)

描述了ELF文件包含的所有段的信息

重定位表

链接器在处理目标文件时, 要对目标文件中某些符号进行重定位, 即代码段和数据段那些对绝对地址引用的符号. 这些重定位信息就记录在重定位表中.

符号表

在链接中, 我们将函数和变量统称为符号(Symbol), 函数名和变量名称为符号名(Symbol Name). 符号表记录着该目标文件所用到的所有符号, 每个符号都有一个对应的值, 符号值(Symbol Value), 对于函数和变量来说, 符号值就是他们的地址.

强符号与弱符号, 强引用与弱引用

如果在目标文件A和目标文件B都定义了一个全局变量global, 并将他们都初始化. 那么链接的时候就会报multiple definition of 'global'的错误. 这种符号就是强符号. 默认所有符号都是强符号, 可以使用GCC的__attribute__ ((weak))定义一个弱符号.

强符号与弱符号的规则 :

符号引用被最终链接的时候必须要被正确决议, 如果没有找到该符号的定义, 就会报符号未定义错误undefined symbol of xxx, 这种称为强引用(Strong Reference). 而弱引用(Weak Reference)则被处理的时候如果未定义, 不报错, 链接器会默认其为0或者是一个特殊值. 默认都是强引用, 可以使用GCC的__attribute__ ((weakref))定义一个弱引用.

弱符号和弱引用的作用 :

符号修饰和函数签名

很久之前, 编译器编译源代码产生目标文件时, 符号名与相应的变量和函数的名字是一样的, 例如函数foo, 经过编译后对应的符号名也是foo, 那么久会产生冲突, 例如要使用Fortran语言编写的目标文件, 一链接就会报错. 为了解决这种冲突, 规定C语言的全局变量和函数经编译后, 符号名前加上_, 此时foo编译后符号名为_foo. 但是还是不能完全解决C语言源文件之间链接产生的问题, 因为大家都有下划线啊! 于是C++开始设计的时候就考虑到了这个问题, 衍生出了命名空间(Name Space).

在C++中, int func()int func(int)int func(float)是三个不一样的函数, 这里我们引用一个术语函数签名(Function Signature), 函数签名包括一个函数的信息, 包括函数名, 参数类型, 所在的类和命名空间等其他信息. 于是, 以上三个函数编译后各自的符号名均不一样但是有规律可循.

链接

很久很久以前, 人们把所有代码写在一个文件中, 到后来, 人类已经没有能力维护这个程序了. 于是人们把代码根据功能或性质划分为不同的模块. 于是, 将这些模块拼接起来的过程就叫 : 链接

不知道大家看完上述的编译过程有没有这么一个疑问 : 如果编译的时候编译器不知道一个外部符号的地址, 怎么办? 答案就是不管, 先放一边, 等到链接的时候再把地址修正, 这就是重定位该做的事.

链接过程包括 : 地址和空间分配(Address and Storage Allocation), 符号决议(Symbol Resolution)重定位(Relocation).

静态链接

最基本的静态链接过程 : 把各个目标文件(.o文件)和库(Library)一起链接形成可执行文件.

那么他们每个文件中的段是怎么合并起来呢?

ELF用的就是相似段合并 : a的.text和b的.text合并, a的.data与b的.data合并, 其他段类似.

符号决议和重定位

符号地址的确定

121862021-3ea8d5f769171c0b
符号地址的确定.png

重定位表

每个需要被重定位的段都有一个与之相对应的重定位表, 如.text段对应.rel.text

根据重定位表中每个符号的信息, 找到每个符号对应的目标对象文件, 再根据偏移(offset)确定其绝对地址(或相对地址).

动态链接

为什么有了静态链接还需要动态链接?

动态链接怎么解决以上静态链接的短板?

程序可扩展性和兼容性

动态链接是否完美无缺

动态链接的基本实现

动态链接是不是直接使用目标文件(.o文件)进行链接呢? 理论上可行, 但实际有区别. 由于动态链接的情况下, 进程的虚拟地址空间的分布会比静态链接更为复杂, 还有一些存储管理啊, 内存共享, 进程线程等机制也会有变化.

Linux系统下, ELF动态链接文件为动态共享对象(DSO, Dynamic Shared Objects), 简称共享对象, 一般以.so为后缀

Windows系统下, 称为动态链接库(Dynamical Linking Library), 就是我们常见的.dll为后缀的文件.

也就是说, 动态链接在这个阶段, 实际上是把目标文件和.so文件(或.dll文件)进行链接.

What? 为什么不是把全部.o文件链接起来? 实际上动态链接主要工作并不是在链接这个阶段做的, 否则跟静态链接有什么区别, 何来的动态? 对吧. 其主要工作是在程序被装载进内存的时候. 那么这个阶段的链接有啥用?

还记得链接要完成的三件事情吗? 地址和空间分配, 符号决议和重定位.

对的没错说的就是你 -> 符号决议. 这个.so动态库的作用就在于此. 它是用来告诉链接器 : “哥们, 这个符号采取的是动态链接, 在这里你就别管它地址是多少了, 等程序被装载进内存的时候自然有人负责的啦.”

于是, 链接就这么结束了, 可执行文件就这么被生成了咯. 剩下的动态链接工作在下面装载的时候由动态链接器完成.

可执行文件

我们前面说过, 可执行文件也就是目标文件, 其实没什么不一样. 略过

装载

程序想要运行起来, 就必须被装载进内存中才能被CPU调度到.

131862021-112e08ba37849785
页映射和页装载.png

事实上, 可执行文件并不是直接与物理内存直接映射的, 否则也没有虚拟内存什么事了对吧. 而且程序直接访问物理内存有几个坏处 : 地址空间不隔离, 内存使用效率低, 程序运行的地址不确定等.. 实际上, CPU发出的Virtual Address经过MMU(Memory Management Unit)转换成physical Address之后才能访问物理内存

从操作系统的角度看可执行文件的装载

创建一个独立的虚拟地址空间

这一步所做的是虚拟空间与物理内存的映射关系. 分配一个页目录(Page Directory), 页映射关系可以等到后面程序发生页错误再设置

读取可执行文件, 建立可执行文件与虚拟空间的映射关系

这一步做的是虚拟空间与可执行文件的映射关系. 当发生页错误时, 操作系统从物理内存中分配一个物理页, 然后将该”缺页”从磁盘中读到内存中, 再设置虚拟页和物理页的内存映射关系. 操作系统捕捉到页错误时, 它应该知道程序当前所需要的页在可执行文件中的哪一个位置, 这就是可执行文件和虚拟空间的映射关系.

将CPU的指令寄存器设置成可执行文件的入口地址, 启动运行!

这一步操作系统执行一条跳转指令跳到可执行文件的入口地址( 不是main函数, 不是main函数, 不是main函数, 重要的事情说三遍!!! ). 实际上并没有那么简单, 到程序能成功运行还差许多步骤. 这里只是将这些过程屏蔽了.

VMA

虚拟内存中分为许多个段(Segment), 每一个段就成为VMA(Virtual Memory Area). 还记得目标文件我们说过的段(Section)吗, 此段非彼段, 这里我们就说英文吧. 目标文件中的多个操作权限相同的Section在这里要被合并成一个个Segment, 再装载进程序的虚拟内存中. 如图所示 :

141862021-f0f3a07d2e202628
ELF可执行文件与进程虚拟空间映射关系.png

进程运行在内存中的的VMA布局就如图所示 :

151862021-8140a240ee2a3ede
常见进程的虚拟空间.png
页错误

执行完上面那些步骤, 实际上可执行文件的真正指令和数据都还没被装入到内存中. 可执行文件只是与虚拟内存建立了映射关系. 当真正执行指令的时候, 会发现虚拟内存中的页面为空, 这时候就产生页错误(Page Fault). 进程将控制权交给操作系统, 操作系统由上面所说Page Directory, 找到空页面所在的VMA, 计算出相应的页面在可执行文件中的偏移(Offset), 然后在物理内存中分配一个物理页面, 将进程中该虚拟页与物理页之间建立映射关系, 再把控制权交回给进程, 进程从刚才页错误的位置重新开始执行.

动态链接器

还记得之前链接的时候如果是动态链接的话, 那么我们会把符号决议和重定位推迟到加载时进行吗? 如果该程序采取的是动态链接, 那么可执行文件装载完之后, 动态链接器就要闪亮登场了!!!

启动动态链接器本身 -> 装载所有需要的动态库 -> 重定位和初始化

启动动态链接器本身

动态链接器本身也是一个动态库, 其他普通动态库的重定位工作由动态链接器来完成, 那么动态链接器的重定位又由谁来完成? 它可否依赖于其他动态库?

这是一个先有鸡还是先有蛋的问题, 为了解决该问题, 动态链接器必须有些特殊 :

这样, 动态链接器必须在启动时有一段精巧的代码完成这项工作而又不能用到全局和静态变量, 这就是自举(Bootstrap).

装载所有需要的动态库

完成bootstrap后, 动态链接器把ELF文件和链接器本身的符号表都合并到一个表中, 称为全局符号表(Global Symbol Table)中. 然后链接器开始寻找ELF文件所依赖的动态库, 一个个遍历下去, 直到所有动态库都被加载进来.

重定位和初始化

装载完所有需要的动态库后, 动态链接器开始重新遍历ELF文件和每个动态库的重定位表, 把每个需要被重定位的位置进行修正.

完成这些工作后, 链接器就可以松一口气, 把进程的控制权交还给程序的入口并且开始运行了.

特殊的动态链接

延迟绑定

我们知道, 有一些函数或者一些用户比较少用的功能模块, 也许到程序结束运行都不会用到, 那么如果程序运行的时候也把这些一并链接的话, 这实际是一种浪费, 无用功. 所以才有了这个延迟绑定 : 当函数第一次被用到时才进行绑定(符号查找, 重定位等), 没用到则不绑定.

显式运行时链接(Explicit Run-time Linking)

也叫运行时加载. 也就是让程序自己在运行时控制加载指定的模块, 并且可以在不需要该模块的时候将其卸载. 这种动态库往往被叫做动态装载库(Dynamic Loading Library).

这种加载方式对于需要长期运行的程序来说具有很大的优势, 最常见的便是Web服务器程序.

运行

上面我们说到, 动态链接器的任务完成之后就会把控制权交回给程序的入口, 那么这个所谓的程序入口, 是一个什么家伙呢? 我们创建一个命令行项目看看

首先, 这个程序入口肯定不是main函数, 你看他还有参数啊哥们!! 那就是别的函数传给他的玩意!

在执行main函数以前, 程序需要初始化运行环境, 初始化堆栈, I/O, 线程等等. 这通通在一个我们称之为入口函数或入口点(Entry Point)的地方完成. 等初始化之后, 才轮到main函数出场. main函数结束后, 回到入口函数, 进行清理工作, 然后进行系统调用结束进程.

内存

终于讲到内存了, 关于内存那就是程序永恒的话题, 各种内存管理, 泄露问题让程序员头疼不已啊…

每个进程内存空间内都有以下默认的区域 :

于是有了以下这个经典的进程内存布局图 :

161862021-f5e79a0c71adedc1
进程内存布局图.png

栈是程序中最重要的概念之一, 没有栈就没有函数, 没有局部变量, 栈遵循FILO(First In Last Out)规则.

栈保存了一个函数调用所需要的维护信息, 称为堆栈帧(Stack Frame). 堆栈帧一般包括如下几个方面的内容 :

一个堆栈帧用两个寄存器划定范围 : ebpesp.

esp寄存器 : 始终指向栈的顶部

ebp寄存器 : 指向堆栈帧的一个固定位置, 又称帧指针(Frame Pointer).

171862021-7e1ba8b07c352099

堆栈帧.png

函数的返回地址 : ebp-4

压入栈中的参数地址 : 分别是 ebp-8, ebp-12等示参数的数量和大小而定.

ebp所直接指向的数据是调用该函数前ebp的值, 这样在函数返回的时候, ebp可以通过读取这个值恢复到调用前的值.

i386下的函数调用流程

  • 把所有或一部分参数压入栈中, 如果有其他参数没有入栈, 那么使用某些特定的寄存器传递
  • 把当前指令的下一条指令的地址压入栈中
  • 跳转到函数体执行

其中第2, 3步由指令call一起执行. 伪代码如下

把ebp压入栈中, 是为了在函数返回的时候便于恢复以前的ebp值. 那为什么保存一些寄存器呢, 有一些编译器可能要求某些寄存器在调用前后保持不变. 于是在函数返回时, 代码就恰好相反

调用惯例

毫无疑问, 函数的调用方与函数被调用方对函数如何调用必须有着相同的理解, 否则将会出现错乱. 如

这种对函数的约定称为调用惯例(Calling Convention) , 内容如下 :

在C语言中默认的调用惯例是cdecl

下面我们用一个例子来形容这个调用惯例.

代码 :

流程如下 :

181862021-250c95bca722bc95

调用惯例实例.png

相对于栈而言, 堆更加复杂, 程序随时可能发出申请内存和释放内存的指令, 而申请的内存的大小也大小不一. 下面介绍堆的工作原理.

为什么需要堆? 什么是堆?

如果只有栈, 那么函数返回的时候栈上的数据就会全部被pop掉, 无法将数据传给函数外部. 这样的话全局变量则无法动态地产生与销毁

相对于栈, 堆是一块巨大的空间, 占用了程序大多数的虚拟空间. 在这里, 程序可以自由地申请和释放内存空间. 如 :

既然是申请内存空间, 那么这个过程完全可以丢给操作系统去做. “喂! 操作系统, 我这里需要xxx字节的内存, 快给我分配一下..”, 想象下我们操作系统多进程并发的情况, 这显然非常低效. 所以应该一次性向操作系统申请一块适当大小的堆空间. 就像你爸一次性给你一个月的零花钱而不用你每天张手跟他要零花钱一样.

堆管理

怎么向堆申请空间呢? 我们知道用malloc函数, 却不知道其背后做了什么.

程序的确是通过malloc向堆申请空间, 而我们清楚的知道, 如果每次malloc都向操作系统申请的话很影响效率, Linux下通过mmap()函数向操作系统申请一块堆空间(Windows下为VitualAlloc() ), 以后malloc时就会从这里索取需要的空间, 只有这里的堆空间又不足了, 堆才会向操作系统再申请多一块堆空间.

内存泄漏

假设程序员总是向堆申请内存空间, 使用完后又不及时释放(free)掉, 这就会造成内存泄漏. 操作系统不会自动回收堆空间, 因为它不知道这一块内存到底是不是有人在用啊. 于是久而久之, 操作系统能用的内存空间就会越来越少, 我们就会感到越来越卡. 这个时候往往重启一下电脑(手机), 这种情况就会改善. 就是这个原因啦.

多线程

线程相对于进程而言, 其访问权限就没那么多约束, 一般来说线程与线程共享整个进程内存的所有数据, 线程甚至可以访问其他线程的堆栈(比较少见)

线程私有

线程之间共享(进程所有)

用户线程和内核线程之间的关系 :

死亡

进程的生命终究走到了尽头, 从main函数return之后就回到入口函数处, 回到梦开始的地方, 把所有资源一一释放掉, 然后转身走开, 不带走一片云彩… 有缘再见~


小知识

API与ABI

编译器优化 和 CPU的动态调度换序

编译器优化

即便是短短的一条x++;的代码, 翻译成汇编语言之后也是需要几条来执行. 那么编译器为了优化代码, 有时候会将一个变量缓存到寄存器而不立即写回(时间局部性原理), 又或者调整这些指令的顺序… 所以多线程下没有绝对的安全(上锁也不例外)

CPU的动态调度换序

CPU的为了优化有时也会乱序执行代码, 也就是说不是一行接着一行执行代码, 那么在一些情况下也会有安全漏洞, 例如单例模式下, 有可能取到的是一个未初始化的对象.

解决办法 :

变长参数

我们最熟悉不过的带变长参数的函数就是int printf(const char *format, ...); 我们知道, 除了第一个参数外, 还可以追加任意数量, 任意类型的参数.

我们用一个简单的函数来说明这种变长参数的实现原理. 如 : int sum(int num, ...);

当我们调用 int n = sum(1, 3, 5, 7);时, 按道理我们只能用num来访问1这个参数, 其他参数访问不了. 但是多亏了C语言默认的cdecl调用惯例的自右向左压栈的传递方式. 此时函数内部的堆栈如下 :

191862021-a30c23beff9867f3

变长参数.png

由于其他的几个参数在num的高地址方向, 所以我们可以间接利用num来访问其他的那几个参数. 而printf函数接收的参数变量类型不一致, 所以比这个要复杂得多得多

格式化

对于一些人来说, 对硬盘格式化就是硬盘的数据全部都没啦..

这种观点完全错误!!! 硬盘上装的是什么? 0和1的序列.. 实际上操作系统是根据一张表, 在表中找到你要找的文件在硬盘中的具体位置, 然后再到硬盘中访问.

201862021-1c919cd2724fb2f4

表.png

格式化的本质就是把这张表的内容全擦除掉, 这样操作系统就忘记了你的文件放在哪里了. 尽管文件还是在原来的地方, 他也找不到了.

现在有种方法就是格式化就把这些区域全部用0或者用1填充.

所以大家的SD卡啊, 硬盘啊, 宁愿破坏再扔掉也不要轻易交给别人啊!!!

打赏支持我写出更多好文章,谢谢!

打赏作者

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

1 3 收藏 评论

关于作者:Jerry4me

广工大-大三生 iOS Dev 个人主页 · 我的文章 · 9 ·     

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部