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.初始化程序的栈空间与堆空间。下面解释下这三个过程。