程序的运行视图

   在上一部分,介绍了可执行文件的形成,以及可执行文件中都有什么东西。从CPU角度来讲,我们是用内存中的编译和链接程序,对磁盘上的工程文件进行处理,这种处理本身是在内存中完成的。处理完成后生成一个可执行文件,将其存放到磁盘上。对于大工程,最终可能会生成多个文件,比如还有各种库和资源文件。通过如此的编译加链接过程,代码最终完成了华丽转身。在这一部分,我们就来看看,转身后的代码,是如何布局到内存中并动态变化的,即程序完整的加载和运行的过程,包括加载器如何将磁盘上的可执行相关文件加载入内存,以及程序在内存运转过程中产生变化的情景展示。

 

   如上图,磁盘上的源代码文件,经过内存中的编译和链接程序处理,变成可执行程序文件,回到磁盘上。磁盘上的可执行文件,经过内存中的加载器加载处理,变成运行的进程。

   为了更好的展示这种变化,我们从不同的角度来看待这种变化。

   第一角度,物理视图

   正如名称中的物理二字,这部分我们通过展示实际加载过程及初步的运行环境的建立过程,看可执行程序运行的复杂过程,主要看内存、磁盘、CPU之间的关系。

   程序加载执行,可以通过多种方式触发,比如点击一个快捷图标启动程序,这是图形系统最常见的启动方式。可以在命令行里输入要执行的程序名,由Shell来触发执行,还可以是在一个程序的执行过程中,通过系统调用接口,启动另一个程序。无论那种方式,大部分都需要直接或者间接的给出程序的名字及位置。当然,如果基于当前进程通过fork分道扬镳,这样父子进程是共用同一份代码段的。不过这里,我们讨论一般情况。

   知道要运行的程序在磁盘什么位置,叫什么名字后,就可以通过系统调用接口来启动程序了。将路径和名称作为参数,传给exec系统调用。该系统调用就从路径所指定的磁盘位置,找到可执行程序文件,加载执行程序。

   所谓加载,主要就是将可执行文件中的内容,从磁盘拷贝到内存中。这个拷贝不是没有规则的从头到尾拷贝,因为一方面,可执行文件中的一些部分,并不参与真正的代码运行,而只是为方便加载器加载程序而提供辅助信息;另一方面,可执行文件分成很多区段,在内存中需要重新映射,并不一定物理连续(对于Linux系统,可以通过/proc/进程ID/mmap文件,查看进程的虚拟内存空间使用),可以说大部分情况下都是非连续的。为了完成程序的加载,操作系统加载器首先需要依赖可执行文件中的辅助信息,比如,辅助重定位的段,链接库信息等。通过先读取这些信息,完成依赖库的加载,共享库的映射,以及需要时的重定位地址值的确定等。一旦程序具备了运行条件,就可以将控制权交给代码,即将CPU的PC指针跳转到代码入口处,开始真正执行用户代码逻辑。如果执行过程中,发现代码不在内存中,则通过缺页中断完成缺失指令的加载;反过来,如果内存紧张,则可以将暂时用不到的内存空间数据搬移到外部存储中,释放相应的内存空间(映射关系),从而实现小内存运行大程序。其实,通过这段描述也看到了,因为虚拟内存技术的普遍使用,很多情况下,重定位信息在加载过程中是不去确定的,而是在运行过程中,根据调用需要再来确定。所以,查找库位置的辅助模块,需要始终伴随着程序的运行,与整个生命周期同在。如下图:

 

   来自网络的更详细的图如下:

 

   一旦操作系统将控制权交给程序本身,程序所实现的逻辑就开始变现了。但是,就如上图所展示的,程序的代码和数据并非全部都载入内存了,这是拷贝规则要注意的。这在操作系统进程和内存部分已经介绍过了。为了在有限的内存中,运行多进程,操作系统实现了虚拟内存机制,这可以让程序不用全部载入内存时,就可以执行。所以,实际内存中,并没有将程序的所有可用部分都载入。

   其实,这部分,有一些上一部分留下的尾巴。比如,程序真的是从主函数开始执行的吗?知道了编译器和连接器在中间横插一杆子后,我们其实就明白了,一切都有可能。它们完全可以在运行之前,插入自己的代码,反正,跳到用户程序时,从主函数开始就行了。像C++里面,全局对象在主函数开始之前可能就已经创建好了。这与我们的直观感觉不符,但这就是现实,是编译器为了实现C++的概念要求,完成了这一动作。了解了这一点,有助于我们理解语言中的一些定义、一些概念的实现,更好的理论结合实际。这种额外的处理,并不影响程序自身的逻辑流程。

   到目前为止的讨论,离实际情况还有一段距离,因为有很多的细节都被忽略了。这其中,最关键的就是操作系统的核心功能,即进程管理和内存管理。加载一个程序,操作系统需要为其创建一个进程结构,加以管理,同时,也需要在内存管理上,提供映射管理,保证进程虚拟内存空间的创建。而且,在程序运行过程中,不可避免要使用操作系统提供的服务,比如文件系统相关的,网络相关的等等。这一过程,也少不了在操作系统启动部分所介绍的有关进程运行环境建立所要经过的步骤。下面,就补充这块前奏内容,完善程序的加载运行。

   系统调用加载程序过程中,操作系统在真正加载前,所做的工作细节包括了:进程数据结构的建立,也即档案的建立;运行环境的建立,同Shell进程建立过程;基于fork拷贝操作,共享操作系统很多内容,涉及到内存中操作系统数据段许多结构的修改。除此以外,还有虚拟内存的相关内容,包括:编译器为代码分配的起始地址;整个进程空间的内存映射结构等。

   整个前奏建立进程档案和虚拟内存空间的过程总结如下图:(待细化)

 

   到此,才完成一个程序运行所需的环境建立的整个过程。此时,再来看CPU控制指针跳到程序入口,整个脉络和流程,就清晰了。程序获取控制权后,开始运行。运行过程中,如果发现某些符号还没有绑定真实的地址,那么加载器就辅助做这些工作,绑定后,再接着程序的流程执行;同样,运行过程中,有些内容还没有载入内存页,就触发缺页中断,由操作系统协助完成所需内存页的加载,然后程序继续接着运行。如此来看,程序的运行过程,也是磕磕绊绊的感觉啊。

   除了这些外部因素导致程序流程打断外,程序内部自身也避免不了流程的切换,这就是对操作系统服务的使用。除非是极其简单的程序,一个具有完整功能的程序,除了自身的逻辑实现外,很难避免对操作系统服务的使用。之前也介绍了,操作系统服务是通过软中断实现的,一旦调用了操作系统服务接口,程序流程就切换到操作系统的代码区了。由操作系统完成接口的逻辑后,再通过中断返回的方式,跳转到程序代码里接着运行。操作系统对上层所有的进程而言,看着就像一个全局共享库。

   以上,就是我们从实际的物理视角来看待程序的运行。看着“碎片化”零零乱乱的物理内存页通过映射表虚拟成了完整的内存空间,在这个完整的内存空间中,不仅有我们程序的代码、数据,也有共享的许多库,比如标准C库,C++库,还有负责加载动态库的动态加载库,还有堆栈区域,当然还有操作系统内核的代码段,数据段和堆栈等等。所有这些都虚拟分布在线性空间(也是一段一段的),而实际分布在非连续(可以想象为零碎的)物理页中。这就是物理视角下的内存中的程序。

   第二角度,逻辑视图

   如果每次要分析一个程序,都要将整个过程如上面完整的画出来,那就太麻烦了。而且,纠结于操作系统的细节中,会影响对程序功能的专注度。再说了,让代码部分存在于内存,部分存在于磁盘,也不利于理解程序功能。了解操作系统的细节,有助于我们理解程序的加载运行过程,一旦理顺了这个过程,也就打消了我们心中的疑惑,从而,就可以将更多的精力投入功能上的实现分析。毕竟,整个加载的过程就像是一个通道,打通了,就可以关注通道另一边的世界。这也符合认知的过程。

   多说一些。操作系统做了这些复杂繁琐的工作,了解了,就是打通了通路,实际问题的分析,还是更应该倾向于抽象和逻辑角度。但是,反过来看,了解通路,有助于对现实复杂问题的分析,并借助于通路,形成自己易于理解的过程和脑海中的图像。其中,一个是逻辑框图,一个是线性的CPU加内存的顺序执行图,将二者关联,各自发挥各自的优势,从而更有利于问题的解决。这就是从抽象回到实际,再从实际回到抽象。

   基于上述认识,后面,就不再添加操作系统的细节,而是认为CPU所看到的是整个都存在于内存中的程序,忽略磁盘上那部分的存在。进一步,将操作系统部分也拿出来,做为CPU的包装,如此一来,一个逻辑的程序运行图,就展现在我们面前了。

 

   此时,块里面还有代码段,数据段等,这一级别,是中间演变的一个过程。

   第三角度,抽象视图

   既然第二级扔掉了操作系统的很多细节,这一次,再多扔点。干脆将CPU想象成自己的大脑,大脑里装载了所有的逻辑,而内存中,就真的如这里的纸张,只保存需要记录的数据,而且,数据也可以还原成它们在编写代码时的样子,即数据结构。磁盘,也可以想象成别的东西,如此一来,整个运行的视图,就可以抽象为下面的样子了。

 

   这是更接近于语言和人认识世界的方法。

   一边是大脑模拟的CPU,读着指令带;一边是内存,操作着数据结构。

   如果分析过程中,有些部分不好理解,就可以从第三级回到第二级,如果还不行,就从第二级回到第一级,借助底层细节来深化理解。

   接着继续我们的讨论。

   第四角度,进一步的扩展抽象视图

   我们前面所介绍的内容,有一个问题,就是没有看到程序本身的复杂。即使对于一个超级简单的程序,比如只有一句printf打印的c程序,操作系统加载运行它的过程,也是一步都不能省略的。这过程,就已经自带复杂性了。当我们扔掉这其中的细节,逐步进入程序本身之后,单纯的抽象视图确实可以对理解程序带来帮助,但是随着程序复杂性的提升,这一级视图也逐步显现出过于偏底层的特性。

   不过,一个好的迹象是,即使是再复杂的程序,其复杂性也多限于程序本身,在操作系统层面,也就是物理视图和逻辑视图来看,跟最简单的程序差异倒不大,不会因为程序本身复杂,导致加载运行复杂。无非是多几个动态库之类的。

   那么,抛开了上述加载部分,程序本身的复杂性该怎么解决?这需要抓住一个关键点--人自身。因为无论是多复杂的程序,目前来看,都是人写的,所以人理解事物的过程,也应该是适用于对程序的理解的。这其中的基础就是后面架构介绍中强调多少次都不为过的抽象。语言本身也在朝着这个方向努力。比如,人对自身的认识,包括了系统、器官、组织、细胞...等等多种概念。创造这些概念,就是为了便于分解和组装。从系统往细胞走,就是分解,从细胞往系统走,就是组装。心脏和皮肤都是由细胞构成的,但是构成它们的细胞又有不同。除了人自身,人对物质世界的其他事物的认识,也是类似的。这种抽象化模块化的认知方式,也是复杂程序构造的基础。

   回到问题本身,显然,对于复杂程序,我们不能再把关注点过于聚焦到某一个数据结构上了,而是像对其他事物的认知一样,需要进一步的扔掉过细的东西,看到更本质的一面,以此抽象成概念,展现在彼此沟通的链路上。这样,才能有全局观。我们对操作系统本身的学习就是一个很好的例子。只有这样,才能跟底层运行结合起来,做到立体的掌握。在串行的二进制执行基础上,立体出并行的多模块并发。就好比在内存中将程序设计中的元素再组装一次,或者说换个角度来看,可以想像成内存中的一个一个积木。网上的这个漫画就有点这层意思:(来自http://turnoff.us/)

 

   再比如浏览器,内部构成十分庞大复杂,我们是没法用数据结构去理解它的。但是,我们可以将其中的一些功能模块想像成独立的块,积木,最后组装成你所理解的浏览器。这一级别,每个人都可以有自己的理解,也就是便于自己的理解方式,只要能向下一级别对应即可。下面这是本人在调试Android浏览器层时,为便于理解,画的一幅图:

 

   这样安排,忽略了很多细节,需要注意。希望在遇到问题时,能够先从整体上把握,把所有流程都想通后,再顺势深入细节,枝叶。

   第四视角,基本使用的是想像法,想象程序的运行,想象CPU忙忙碌碌的干这干那...CPU可以做内存的搬运工,可以做显存的搬运工,你可以实现几乎你想要的所有东西,创新的空间非常大,可以说只有想不到,没有做不到。因此,这是一个非常有意思的事情,也可以变成非常有意义的一件事。

   到这里,就可以引出下一章节内容:程序的动态扩展。所谓复杂是一种必然。

   就像include在程序设计中的作用,许多系统级的设计都是由小的设计模块通过组合、嵌套等工序膨胀起来的。这是走向庞大和复杂化的必然要求。下面我们就看看,一个程序如何作为平台支持自身的不断扩展,以适应未来的需求,迎合商业的需要。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙赤子

你的小小鼓励助我翻山越岭

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值