程序是怎么执行的
Docker 是一个建立在操作系统+编译器基础之上的系统,所以了解操作系统,编译器以及程序运行机制对我们理解 Docker 来说非常重要。本文是一个自己的体会,有很多不精确的地方,目的是希望大家多关注低层,多修炼内功,多读好书。
一直想写篇文章来说明在程序运行过程中操作系统都干了些什么事。下面我试着说明:
首先,任何程序都是有格式的,所谓无规矩不成方圆,任何美的,精巧的事物都是精密组织的,程序也一样。我之前用的最多的是c#与 java,有趣的是,当时很多人嘲笑 java 与c#们一直在用脚本写程序,大概在他们眼里c与c++才是真正的程序。但是,现实就是现实,其实我们都是在一个叫做虚拟机的程序下写托管代码,它掌握着程序的编译,链接,加载,映射与最终执行与终止。它就是操作系统,准确的讲是操作系统+编译器。他们是真正的元虚拟机。
然后我来解释下如何运行一个程序:
程序是精巧与复杂的,熟悉它以后你也会觉得它是脆弱的,因为只要有一个 bit 发生错误,整个系统就会崩溃。这个系统就是执行文件格式,在 linux 下叫 elf(executable linkable format)而 windows 下叫 pe (portable execute)。我想写操作系统第一步就是制定这个规则,不然一切都没有规律。所以我想 linus 牛,但是 ken tomason 有过之而无不及,毕竟你是在人家基础之上发展而来的,计算机世界就是如此没办法,谁让你在人家下面呢?
我以 linux 系统为例,简单讲讲程序由编译链接装载与执行。elf 文件格式分为很多段—section,总体分为只读可执行的代码段与可读可写的数据段。.txt 就是典型的代码段,.data .rodata .symbl .rel .got .plt 都是数据段。那么,编译器负责将程序员写的程序,编译成 elf 文件,代码,注视,代码行对应机器码信息,就是调试信息啦会进去 .txt .code .comment .debug 段,常量与静态变量进入 .data .rodata .bss。接下来,编译器将引用的头文件中的代码(特指静态编译)与引用的 glibc 中的库函数打包(链接)到整个可执行文件中,然后在 elf 文件中设置文件头信息,如段表位置,程序入口位置等信息。当然,这里不得不提的是符号表,与重定位表,他们是整个程序最终能跑起来的关键。gcc 是靠符号,或者说程序是靠符号来链接的,不管是函数还是变量,都是符号而已,所以从侧面讲,写程序跟写文章没啥区别。程序就像个图书馆,每个函数与变量都是书,链接程序好比在图书馆看书,当你看到一个点时,就会叫你去某某位置拿另一本书,翻到特定位置开始继续读,如果没找到就会爆出链接错误。而重定位表就是一次性讲所有对需要跳转的位置进行更改,以确保程序中不存在没有拿到手的书。
好,现在程序已经链接好了,接下来就是操作系统进行装载与执行了。当然这是静态的链接,动态链接会稍微复杂,会写很多,这里不讨论。操作系统会打开 elf 文件的装载视图,它能根据装载视图的段表—segment 这跟 section 在中文都是段,没办法!这个视图是将数据与代码分开的,相似 section 链接在一起,所以数量也比 section 少很多,目的是在装载时节约内存。因为,段映射到内存是要地址对齐的,如按照地址 4096(一般簇大小为 4k)整除来对齐,这样做是有好处的,能减少内存碎片,加快磁盘读写速度,磁盘最小扇区 512byte,所以整数倍读取能少一次寻址,当然效率更高。这在游戏引擎,数据库设计领域比较多见,毕竟 io 是最大瓶颈,所以再这程序时也要考虑对象占用内存大小是否是操作系统最小簇的整数倍来判断一个程序是否是高人所做。
回来,操作系统会最先读取可执行的文件头,因为里面有运行程序的信息,如段表位置,程序入口,程序类型等。对于操作系统最重要的是段表与程序入口。其中段表就是 elf 中有多少段,每个段在文件中的偏移,入口则是常说得 main 函数的虚拟地址。这里就出现一个问题,程序非得以 main 函数开始吗?其实看出来了,不用!只是 gcc 认定符号 main 为c语言的入口,其他程序照抄罢了,当然你可以加入编译条件更改入口即可。gcc 是 stallman 写的,他是个黑客,全世界只要运行c的地方,他都能黑,呵呵。
好了,操作系统在读取可执行程序头时做了三件事:1.创建虚拟内存空间来容纳一个进程,2.根据文件头内容建立程序虚拟内存地址与 elf 文件的映射关系表,vma(virtual memory area)结构,3.初始化程序的栈空间与堆空间。下面解释下这三个过程。
1,虚拟内存。虚拟内存是编译器与操作系统的一个约定。任何程序在编译无链接时得地址都是虚拟地址。为什么要用虚拟地址这个问题说来话长。话说在很久以前,大家都很穷,都没内存,但是要运行的程序很多,系统不可能为每个程序分配单独的内存,同时领导还要求同时所有程序都要运行,咋办呢?办法总比问题多,咱可以分时嘛,你上完 cpu 我再上,但是大家各自在用 cpu 时,其他只能看着,直到一个人说"下一个",这个人不管在干嘛都得放弃,让其他人用 cpu。这样对所有人都公平,而且每个人在用 cpu 是能感觉到 cpu 只被它独有,用户体验还挺好。所以一次解决可所有问题。而,这个组织人,就是那个喊“下一个”的家伙就是操作系统。那,说这么多,跟虚拟地址有啥关系呢?其实仔细想想如果大家都是用物理地址,而彼此在运行时都独占系统资源,那前一个程序修改了我的数据咋办,得了,都由操作系统说了算吧,它做内存映射的维护,大家都用统一的地址空间,但是运行时映射到不同的物理内存互不干扰来。所以你可以看到所有 linux 程序都从相同的虚拟地址开始执行。
2. 建立内存到文件得映射。我们知道,程序都不是一次性加载到内存的,而是一段段的,这是由著名的 copy on write 规则约束而来的。而这一段也是规定好大小的一般是操作系统簇的大小,也叫一页。当程序运行过程中发现某个数据在内存中没有则会报一个页读取错误,并触发操作系统的缺页中断。这时就要靠操作系统通过读取 elf 文件头建立的从文件系统到虚拟内存的映射来获取了。它等于是程序运行时到程序得一个索引结构,存储了运行时程序虚拟内存地址到文件地址的对应表。
3. 好了,第三步最简单,就是操作系统载人 main 函数后面跟的那个 char argc 与 char*argv 了。他们是程序启动参数。还要载入程序运行的环境变量,栈空间,堆空间,也就是静态数据与全局变量部分。然后把程序执行寄存器指向程序开始的地方。开始执行!看似简单,但是很复杂的过程开始了!
好了,这就是简单的程序如何被操作系统执行的简单描述,当然这只是静态链接程序的加载,动态链接稍微复杂点。原理差不多,呵呵。