前前后后花了3个月的闲余时间,认真研读了这本书,知道这本书名,源于进入58时组长交代的一个分享任务——《iOS的内存管理机制》。在研读WWDC相关章节时,对里面的虚拟内存和物理内存的相互转换、物理内存的占用和回收、堆栈的管理等内容,有很多疑问,在网上搜索相关解答时,发现线索最终都指向了《程序员的自我修养》。自己首先找了一份电子版,阅读了第一部分的内容后,有种《桃花源记》中 “初极狭,才通人。复行数十步,豁然开朗” 的感觉,立即毫不犹豫的买了纸质版。
关于内容,书的开头写的很明白:“描述一个应用程序在编译、链接和运行时刻发生的各种事项:代码指令是如何保存的、库文件如何与应用程序代码静态链接,应用程序如何装载到内存中并开始运行,动态链接如何实现,C/C++运行库如何工作,以及操作系统提供的系统服务是如何被调用的。” 内容以Linux和Windows两个系统平台的实现为例,详尽讲解了它们实现的是什么、为什么的问题。
计算机结构
计算机整体分为硬件部分和软件部分。比如大家最关注的内存、CPU、硬盘、显示器等属于硬件部分。而具体使用它的各种程序:办公三件套、IDE、游戏等,则属于软件部分。从最初的软件开始运行,到最终具体的硬件执行,中间使用层层的结构进行传递,这些层次的划分和组织过程,构成了完整的计算机结构。
历史演变
1 结构
计算机硬件部分的核心包括中央处理器(CPU)、内存和I/O控制芯片,作为程序开发者,最多关注的是内存。开始时,CPU频率和内存频率相似,二者连接在同一总线。后来CPU频率大幅提高,人们开始设计北桥系统,使用PCI总线连接CPU和内存、高速图像设备,南桥使用ISA总线连接键盘、鼠标等低速设备,然后通过PCIBridge和北桥相连。2 内存
刚开始的程序直接运行在物理内存中,但是存在程序可能越界访问的错误,以及多个程序运行时存在的频繁换入、换出问题,后来通过虚拟内存的方式对物理内存进行抽象,解决了该问题。3 操作系统
为了匹配运行速率不断提高的CPU,依次出现多道作业系统、分时任务系统和多任务操作系统。操作系统做了两件事情:1 为程序运行提供抽象的接口;2 管理硬件资源。硬件资源多入牛毛,操作系统为了适配不同的硬件,为硬件提供一系列接口和框架,由硬件生产厂家负责驱动程序的实现。4 线程
在CPU运行频率提高到4GHz以后,开始进入了瓶颈期,为了继续提升其计算速度,人们采用了集成多个CPU的方式。通过多线程,可能将一些耗时的任务进行拆分,分别在不同的CPU上进行计算,提高效率。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。多线程执行过程中,涉及到线程安全,通过加锁的方式实现不同线程的同步执行。具体实现方式包括3种:1 信号量;2 互斥量;3 临界区。
静态链接
程序从编译到运行:预编译、编译、汇编、链接、运行。预编译阶段进行宏展开、文件替换、注释删除等;编译阶段进行词法分析、语法分析(成为表达式语法树)、语义分析(添加各表达式语法树节点的类型),然后编译成中间代码(IR),最后对中间代码进行优化,比如减少变量、合并指令等;汇编阶段将优化后的中间代码,转变为机器码,成为目标文件;链接阶段将不同的目标文件进行合并,对模块间的变量进行重定位等,最终成为可执行文件,以供运行。
可执行文件本身是一个文件,可执行是它的格式。文件内容是16进制的机器码,其通过段(Section)的方式来组织。可以分为头部、代码段、数据段、BSS 、段表、字符串表和符号表等。通过头部信息,可以确定可执行文件的类型、运行环境、文件机器字节长度、入口地址等信息。代码段存储代码指令,大部分是函数的具体实现。数据段中存储初始化的全局变量、局部变量等。BSS段中存放未初始化的全局变量,其在可执行文件中的大小为0(可以理解为只标识了其起始地址,大小记录在段表中,比较费解,这点也困扰了我好久)。段表记录段的个数和每个段的详情,比如段的名称、大小等信息。字符串表记录文件中所有的字符串信息。符号表存放符号的名称(字符串下标)、所在的段(段表中的下标)、类型、大小等信息。个人认为符号表是理解可执行文件的核心,程序中所定义的函数名、变量名,在可执行文件中是一个个全局唯一的符号,其名称存放在字符串表,其值对应了具体的可执行文件的地址,链接过程就是对它们的有效替换,最终生成了具备可执行能力的目标文件。
静态链接是链接器将不同的目标文件组织起来的过程。比如将目标文件a.o和b.o中的二进制内容,按照.text(a.o)+.text(b.o)、.bss(a.o)+.bss(b.o)等链接为一个统一的整体。在合并的文件中,不同函数和变量的虚拟地址已经确定,使用确定的新值来更新符号表段的内容。接下来依据各个段的重定位表,将引用的符号内容,替换为符号表段中确定的新地址值。自此,一个完成的目标文件已经完成。
最终目标文件的内容形式,由编译器、链接器来决定,编译器和链接器在不同硬件和平台上的实现又不一致。所以不同硬件和平台上生成的二进制目标文件无法相互兼容。为了解决这个问题,人们试图建立目标文件的统一抽象模式,比如BFU库,首先将源代码文件编译为BFU格式的文件,然后由BFU转换为适配不同硬件和平台的目标文件。这样,如果新增一种平台和硬件,只要在BFD库中添加支持即可。
装载与动态链接
1 装载
装载是可执行文件映射到虚拟内存空间的过程。操作系统来实现装载过程,然后由内核态切换到用户态,执行程序。原始的装载方式直接将程序装载到物理内存中,需要开发者管理物理内存的具体分配,比较繁琐。现在的操作系统首先把虚拟内存和物理内存划分为大小相同的页,然后通过页映射的方式实现虚拟内存到物理内存的装载,二者通过硬件MMA实现快速的地址转换。为了适配分页要求,映射的虚拟空间需要对可执行文件中的内容进行合理组织,比如相似段的合并、按照页大小进行放置、BSS空间的分配等,这个过程操作系统通过将不同的Section组织成Segment来实现。2 动态链接
静态链接出的可执行文件,存在大量相同的依赖库,如果在运行每个进程时,都为这些依赖库分配单独的物理空间,会照成巨大浪费。除此之外,如果依赖中的某个子库进行了更新,那么静态链接的可执行文件也要随之更新,否者就无法使用新功能。为此,动态链接方式应运而生。
动态链接实现相同子库的共享,需要将子库编译为地址无关的形式:对内部变量数据、函数引用改为相对地址寻址方式,对外部变量、函数引用采用全局偏移表(Gobal Offet Table)的间接引用方式。其数据段、BSS段、GOT等部分,在各进程的虚拟空间中创建共享库的副本,适应变化;将地址无关的指令和数据部分进行共享,节省空间。
相比静态链接,动态链接库将可执行文件与依赖子库的的链接过程放到加载阶段,执行时通过GOT的间接方式寻找指令,速度会降低,是操作系统使用时间换取空间的方式。为了节省动态加载占用的时间,操作系统动态加载时采用延迟绑定,在函数第一次被使用时才对引用变量和地址等进行重定位。
库与运行库
通过对C语言运行库 (Runtime) 的实现,让我对运行时的理解更加深刻:它是构建在C语言和操作系统API之间的桥梁,让开发程序的人专注于使用C语言进行逻辑实现,不用关心操作系统相关的问题(进程创建销毁、堆栈管理、图形操作、网络使用、文件管理等)和不同操作系统实现的差异(Linux和Windows平台等)。比如我们常用的函数printf,实际是通过C语言的 Runtime (CRT), 最终调用了操作系统的命令行输出功能,在Windows平台上, CRT 通过调用 Winows API,在Linux平台上通过write函数来实现。推而广之,CRT是高级语言C对系统调用的封装方式,OCRT(OC Runtime)是Object-C对iOS系统的调用方式的封装,ART(Android Runtime)是Java对Android操作系统的封装。一切高级语言都有运行时,通过运行时实现了对底层操作系统的各种封装,让程序开发者愉快地(无脑地)专注于应用实现。运行时支持跨平台(操作系统时),使用该高级语言创建的程序,就能在不同的操作系统上运行。
最后
作为iOS开发者,在内存管理方面,最基本的要求是避免内存泄漏,对其认识从理论上可以解释很清楚,但是具体到系统的内存布局,比如虚拟内存的大小、物理内存的占用计算等,认知往往很模糊。对最终编译的可执行文件MachO,通过MachOView可以看到具体的文件头和段信息,但是对于具体信息的含义,以及如此组织的原因更是一头雾水。具体开发时,涉及到动态库和静态库的具体区别,除了能够说出静态库相比动态库是以空间换时间外,其他的区别就不清楚了。对应用启动时的介绍,不止一遍听过递归加载依赖的动态库,然后进行Rebasing和Rebing,但是对具体的实现过程就云里雾里了...等等很多疑问,在这本书中都得到了解答。
这本书我先通读了一遍,利用假期又精度了一遍,通过不同章节间的相互印证、对实例的反复思考,对整个计算机体系和程序的编译、装载和运行有了更近一步的认识,就像江湖传言中的易筋经,颇有被打通了任督二脉的感觉。虽说具体编程技术日新月异,但是这种沉淀的心法,却稳如基石,以后还需要不断学习,不断领悟。