程序是怎么从代码到执行的

一个月多月没发文章了,虽然有几篇在草稿箱里一直删改,但是太水.没好意思发出来.关于这个文章的问题是早就想问了的,但是以前一直基础不够,弄不明白.为什么我们见到的东西能够跟二进制联系起来。这学期刚好学了体系结构,也看CSAPP,总算可以说是算比较清楚的理解了程序从编译到运行的整个过程的了.于是写个文章整理整理这些知识.

                                           

如上图所示,基本经过这么几个过程.当然主要是指C/C++这种语言,对于java这种虚拟机上运行的语言还多一层虚拟机的翻译工作.

很多书上其实都画过类似的这种图,但是说明性的东西都太具体了,还有什么预编译和链接这种东西,像我刚学C的时候根本就不明白意思.不过现在是搞清楚了.继续主题.首先高级语言是肯定不能直接执行的.它写成的代码必须通过编译器先转换成更底层的语言,如汇编才能完成编译工作.当然汇编也是不能工作的.因为它还是不能让机器看懂.机器只能看懂二进制代码.所以还要被汇编器编译成机器码(二进制代码)才能被CPU所执行.CPU又是怎么接收到二进制代码运行的呢?通过内存.即操作系统执行自己的代码,先把要执行的二进制代码放到内存的某个位置中,调整好执行过程中的上下文环境,然后将程序开始执行的位置的地址送到CPU中,CPU就开始从内存中读出二进制代码,解释其含义,根据代码控制CPU上的管脚(你把CPU拿下来就知道了,上面全是针一样的东西)电平的高低,给电脑周围的各种设备传递一串高低电平,从而达到控制电脑的过程.其中,编译器和汇编器也是个程序,它负责高级语言向低级语言的转化.并且操作系统也是程序,也是可以你的编译器编译出来的~好多同学觉得操作系统要专门的工具写,但其实GCC就可以,只要了解相关规范,掌握相应的编译连接过程就可以写了.只要电脑加电,系统会被硬件自动加载进内存执行的~

说道这里,大体上已经明白程序是怎么运行了的吧?当然我要写的肯定不止这点.下面我会对以上一些过程进行更详细解说~

1.高级语言是如何编程可执行文件的?

这个过程其实要拆分成很多个过程,如果你用的windows下的VC或VS或许根本就不用知道这一点.但是学linux并且用过gcc的应该就要清楚一些.

1.1.预处理

简单来说就是把你的头文件加到你写的代码中.把一些你定义的宏让编译器识别出来并记下来.并对代码中用到宏的地方进行直接替换,这样可能还有的陌生.当然还有特定编译器的特定宏指令,比如#program.但这个不同编译器定义不同,所以具体用到什么再学吧..这里提一点,头文件通常包含一个#ifndef和#define同一个宏.这个意思其实就是为了避免重复包含同样的函数或变量.像C这种语言又不支持重载,所以必须要加这两句,这样就只可能包含一次头文件信息了.

1.2.编译

其实我觉得叫翻译也可以.就是把源文件解释成机器能执行的东西.将其翻译成汇编.这个可以具体在学编译原理的时候学一下.多看看编译出来的汇编,自然就可以了解到编译器是按什么样的格式来来组织汇编代码了.当然有的大神多研究了这些东西,所以成了反汇编,逆向的大神,有兴趣的可以去研究下.

1.3.汇编

这个过程就很简单了,汇编就是一些机器码的高级表示而已.把符号替换成二进制指令罢了.但是要指出的是,汇编完了之后并不是操作系统认可的可执行的文件.而是.o文件.这个文件是不能直接执行的.还需要链接这个过程.注意这里你是看不到二进制代码的.不是按照字符0和1来存放的,而是直接就是二进制的形式存放在磁盘中.所以如果你用文本格式打开,就是乱码.这里需要注意一下,在汇编代码中的一些标记符号仍然存在于其中.不一定是在可执行段,但是这时已经将不同功能的代码分开存放了(其实编译的时候已经分开了).并且文件中还存有一些相关的代码符号.为后面的链接工作做好基础.

1.4.链接

这个过程就可以生成可执行文件了.也开始涉及到静态库和动态库的差别了.过程其实主要就是通过之前.o文件中的符号信息,将不同文件中的东西统一起来.这么说可能还是有点抽象.我还是举个例子.好比说你在一个文件中定义了main函数.然后包含了头文件stdio.但你的代码之中并不含有print这个函数的实现的.编译器通过你的头文件只能找到printf的声明.然后给这个函数分配一个虚拟的地址(函数也是有地址的,你真的了解了计算机怎么工作就知道了为什么了).但是这个函数具体的实现在哪里呢?编译器并不需要知道.它只用保证你的命名什么的没有冲突就行了.之后找到具体实现的二进制代码段就得靠链接了.我之前说过.o文件中还包含有符号信息对吧?那么链接就是统一两个.o文件中的地址问题.让他们能够组合起来.

举个例子好了.假设你main里面用到了printf.编译器给这个函数一个地址,假设是0x00000001好了.但是在编译printf具体实现的时候,编译器给它的地址是0x00000002.如果你生硬的把这两个.o文件组合到一起,那么main里面调用printf就会找错位置了.所以之前保留符号信息是为了统一地址.这里其实还涉及到虚拟地址的问题.下面我再具体的讲解.

这里还有静态库和动态库.静态库就是把一堆不能执行的.o文件组合起来.提供给其他程序来链接,在执行前就组合到可执行文件中去.动态库也是这样,但并不是组合到可执行文件中,而是留一个地址,在运行的时候,通过动态加载器把内容加载到内存,通过虚拟地址映射转换,让原来留的入口地址可以找到动态库.这样一来动态库就可以运行了.相对来说静态库速度更快,但是也更耗磁盘.动态库相对速度慢,但磁盘消耗少,并且在web应用中,动态网站就是通过动态连接库来实现的.因为它可以动态的改变执行的内容.并且对于网游什么的这个也更有用.如果用静态库的话程序就定死了.而如果用动态库,那么还可以方便的打补丁.只用替换动态库文件的映射即可.对于动态库而言,其内部的地址都是相对地址,这样就可以方便的让操作系统加载到不同的进程虚拟地址空间中去了.但相对静态库而言,动态库更容易被黑客攻击.

顺便说一下,main函数在很多人眼里都是很圣神的...其实对于CPU就是一段程序而已.为了链接器提供一个程序默认起点,所以就有从main函数开始的习惯了.

最后,这以上全部分配的都是逻辑地址.

2.可执行文件是如何被操作系统加载的?

其实过程往简单了说也很简单.在linux下首先bash解释命令fork出一个子进程,然后更新子进程的上下文环境,再通过exec等函数来把你的可执行文件通过ELF格式加载到内存中.这样一个进程就可以开始工作了.但是往细了说,还是有很多需要学习的地方的.个人认为要真正理解这个过程,关键在于虚拟存储器.所以先说说虚拟存储器吧.

2.1.虚拟存储器

去年刚学操作系统了解到这个玩意.以前学linux编程也没在意这东西,毕竟如果你仅仅是操作根本不用管这个.但学了之后,我对这个东西很有疑惑.因为这东西在书本上面隐藏了太多的细节了.所以学的不太明白.不过经过多本经典外国书的熏陶,这个概念还算清楚.借助linux我来说说这个过程.

首先,每个进程的地址空间实质上是相互独立的.为什么只有一个内存,怎么才能独立呢?通过MMU.也就是所说的逻辑地址映射到物理地址的过程.通过这个逻辑地址,操作系统调度MMU,来把这个虚拟地址翻译成物理地址.下面我就来仔细讲一下到底是怎么实现的.



上了这个图,我估计很多人就非常清楚内存管理了吧.首先通过CPU将虚拟地址送到TLB快速页表和页表查询(实际上还有个分段的过程,但其实大部分32位系统都直接把所有段都弄成0为基址,4GB为段大小,这样相当于就没有段了,但是这个部件仍然会被使用),然后通过页表找到物理地址,然后再通过cache来加载内存中的数据.如果内存中没有相应的页,那么就缺页中断,操作系统来用算法淘汰旧的页,更新新页到内存.再将其给cache.其实存储系统往简单了说,内存就是磁盘的一个缓存而已,cache又是内存的缓存.注意这里一直是操作虚拟地址的.所以你printf一个指针,实际上也是逻辑地址而不是物理地址.当然其实经过这么多转换,你也没多大必要知道物理地址.而这里页式转换也就是在所谓的MMU中进行的.页表是在程序加载的时候生成的.操作系统可以控制页表,从而控制地址的映射.国内教科书为了方便讲,喜欢说linux是页式管理内存的.但其实只要是x86上的linux都是段页式的,不过段基址都是0而已.当然,可能有人会怀疑多个页表转换是否会映射到同一内存地址.首先答案是肯定的,动态链接就是这个原理.


这里还有一点就是虚拟地址高地址是页地址,低地址直接送到cache.这么搞也是有原因的!因为可以先通过低地址在cache中寻找合适的项先赛选出来!再用页地址来匹配,所以又增加了一点速度!只能说咱们搞IT的真是脑袋好啊...如果你写过X86的kernel的话,那么你应该知道还有一个页目录的概念.这个目录呢,其实就是页表的一部分地址分出来的.

那么每个进程的虚拟地址空间是怎样的呢?下面还是以linux为例:



这个图在我的其他文章里面也用到了.其实内核将自己一部分代码映射到了.这里用户栈实际上是有大小上限的.linux下好像是2M.你可以搞个main自己递归自己来玩玩~而且这个图也说明了共享库的位置.但是很多人可能会疑问共享库函数使用哪个的栈.我记得好像在哪里看到过,应该是在用户栈中.并且函数传参过程也是在用户栈中.毕竟都把共享库映射到同一地址空间中了.

对于新手而言可能还不了解栈和堆的作用,我就简单说一下.栈就是存放局部变量的地方.而堆则是运行过程中程序对内存可以自由控制的地方.对于C程序而言,说细一点.bss只存放未初始化的全局变量和静态变量..data段则存放常量和初始化了的全局变量以及静态变量..text段则存储代码指令.


这里说一下为什么要分代码段和数据段.实际上这是系统为了实现更好的内存管理.所采取的一种编译手段.将其分开之后,在内存中分别用两个页面来保存其内容.这样对于共享数据的程序来说,只用分享同一个页面就可以了,并且这样之后代码段的分页就可以不断的轮换.从而在一定程度上可以减少一个程序在内存中的页面数.如果数据和代码是混在一起的那么就不能随便把页写到磁盘去了.当然对于汇编程序而言确实可以把数据段分散开来写,但编译器为了配合操作系统则会统一起来.还有一点就是,实际上在Linux上fork之后,对于只读段,是父子进程共享的!并且Linux采取了写时复制的策略,当你用数据段时,只有执行了写操作,才会分配数据段的页.效率比原来提高了不少.


2.2.虚拟存储器是怎么与存储系统合作的呢?

看到这里应该有点明白了吧.虚拟存储器提供的全部都是虚拟地址.链接过程把不同的段分配到不同的虚拟地址空间.所以这样一来,只用提供相关的符号信息,链接就可以找到相应的段来映射到进程的地址空间中了.并且对于每个进程,linux会有一个链表的结构来管理每一个段的信息.这样就可以保证段的识别了.同时也就实现了所谓的段页式管理了~~现代操作系统基本上都是通过页式来模拟段式,虽说在底层仍然有段寄存器被初始化了.但是初始化的基址为0,大小为4GB(32位系统),所以也就谈不上用硬件的方式来段式管理了..

顺带解释一下映射.我个人感觉mmap底层的实现就是靠的修改页表这个数据结构来实现的.当然这还涉及到磁盘到内存的映射.所以我个人觉得有复杂一点.以后看了linux源码解析再来确定吧~


顺带一说,对于虚拟存储器来说,实际上基本和CPU无关.重要的是把CPU的逻辑地址翻译成物理地址的过程并不完全由CPU来执行.而是CPU通过控制一些外围硬件来实现的.当然这一点对于程序员来说基本可以不用了解,不过你要深入一点学系统的话,应该会碰到这个疑问的.


2.3.操作系统如何加载程序呢?

这个就涉及到很多底层知识了.我尽量讲的清晰简单一点.首先,你把执行程序的文件地址告诉shell(也就是中间件之上的那个与用户交互的程序),shell就会在内部fork一个子进程.然后修改其子进程环境变量.再通过exec等系列函数来把从父进程继承过来的代码段等删去.将你可执行文件解析出来各种段,通过操作系统提供的加载程序来把文件中的可执行段映射到子进程的虚拟地址空间中.这样一来,只要按照正常的进程来搞定就行了.对于.bss段的话,一般直接映射成全为0的段.这样.bss段就不必存放在可执行文件中,缩小了磁盘占用的空间.并且bss段,在Linuxz中实际上是有一个纯0的页的,所有bss段如果没开始写入数据,会一直映射到这个页面.只有向bss段写入数据,操作系统才会分配一个可写的页给bss段.具体的还有一些实际的过程没有仔细的去探究,不过你对系统是如何执行程序已经有了个大致的了解..有兴趣的可以使用strace来跟踪进程的运行.


2.4 系统调用是如何执行的呢?

这个其实就是通过软中断.x86的CPU提供了陷阱这种类型的指令.一执行这个指令就会进入异常状态.然后去内核安排好的了地址中寻找用来执行的程序.实际上就是一种中断.并且切换了内核的状态,从用户态到内核态.并且执行程序,这个程序都是系统设置好的.无法更改.只要是在内核态下的程序基本上都很难入侵.我想这也是为什么Linux的宏内核一定程度上比windows的微内核安全的原因(虽说这两个系统安全评级在一个等级....).


3.CPU是如何执行程序的呢?

其实这个过程也比较复杂,我在这里就只简单介绍一下这个微观层面的过程吧.

3.1.操作系统在这里起到一个什么样的角色?

由于自己写过一个不成形的kernel..对这也有一定的了解.实际上进程的时间分片执行基本是用中断和陷阱来实现的.实际上操作系统运行一个程序进行了以下过程:

进程运行---->中断进入操作系统---->操作系统保存维护进程的结构体(CPU可以识别这个结构体)---->操作系统选择调度下一个进程----->操作系统读出进程信息,设置下一个中断----->运行下一个进程.

所以,操作系统和运行的程序的权力交接是通过中断的方式实现的.也就是在操作系统的控制下实现的.在单CPU环境下,运行进程时,操作系统并没有运行.

实际上CPU提供了很强大的编程接口给操作系统开发者.像程序计数器这种记录程序运行状态的地址就是通过一个叫TSS段来记录的,并且CPU有规定什么寄存器存在这个结构体的哪个位置.进程运行的状态信息很多信息都存在TSS中.而且CPU可以在进程切换的时候自动加载这些信息.

总而言之,操作系统的角色就像是任务分配者,CPU就像苦力.CPU只知道工作,但是必须由操作系统来控制其工作的环境,上下文,权限等.设置的最后一步就是从TSS中读出进程的PC寄存器,然后跳入到PC中.这里肯定有人认为会产生寄存器的重叠使用的问题,但其实寄存器都在PC跳转之前存到TSS结构中了.也就不必担心数据找不回来和被串改了.


3.2.CPU是如何运行程序的呢?

这里就涉及很多很复杂的东西了.简单来说,CPU保证指令的顺序执行结果.但是在内部并不是顺序执行.

可以看我下面这篇博文:

http://blog.csdn.net/meiboyu/article/details/26500305


附:

图形界面是如何运行的.

对于底层硬件来说,有一块内存区域是专门用来给图形界面用的.对于命令行式的界面,你往里面写ascii码,就可以在屏幕上对应位置显示出其符号.每个内存地址都映射到屏幕上矩阵中的一个位置.对于我们常见的图形界面,基本和命令行一样,但写的是像素点.由三原色的具体数据决定点上显示的颜色.不同的显示模式有不同的写像素点的方法.我就不展开了.在Linux下,会有一个X windows的底层界面管理的一套内容.并且提供一个X库用来提供一些基本的图形元素描述和对图形资源的管理.在Xwindows之上就是我们熟知的GTK+和QT了.桌面有GTK+开发了Gnome.QT开发了KDE.具体详细资料,可以参考<现代操作系统>中IO的那一章.我也是这几天才开始看,有很多地方细节不太清楚,所以就不多说了.


第一个编译器是如何做出来的?

实际上,人们不是一开始就有那么完整的一套编译过程的。人们先用机器码,编出汇编器,再用汇编器写出C语言编译器的。到了C之后,其余语言就很容用C来写编译器了。当然,也有一些语言用自己来写自己的编译器,其实也是可以的。只要最终翻译成汇编代码,编译器编写语言不重要。

有兴趣的可以看看这个问题http://www.zhihu.com/question/19934285


写了这么多,总算是写完了.当然,很多细节部分还是没有写到,但是基本我想整理的东西都整理到了.如果你的专业知识足够让你看完,那么你对程序是怎么运作的应该有个大致的了解了.我写的过程中也对这个过程印象更加深刻了~还是得多写这种文章啊~

PS:<现代操作系统>真是本好书啊!好多系统实现细节部分都提到了.感觉以前看的4本书操作系统书都比不上这本.对于操作系统有兴趣的可以看一看这本书.

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页