《程序员的自我修养》读书总结

9,081 阅读17分钟

最初买《程序员的自我修养》这本书,只因为在京东买书差一些钱,不够用优惠券。买回来以后的很长一段时间,我都以为这本书只是程序员用来调侃和自黑的。不过翻读了第一章以后,我就发现自己错的太离谱。我觉得即使一个不使用C/C++,甚至是写解释性语言(如JS等)的程序员,也有必要抽空读一读这本书。作为使用OC或Swift的iOS开发者,我认为这本书是必读的。

所以这篇文章会简单梳理一下《程序员的自我修养》这本书的脉络结构,如果时间有限,又想快速阅读这本书,可以先看看这篇文章。标注了页号的地方表示详细知识可以在给出的页数获取详细的知识。为了简化问题,有些地方会省略一些原文中的细节,一切为了保证读者快速了解这本书。

对于不是专门从事C和底层开发的程序猿来说,个人认为完整的看完本书的所有内容是不太现实,也不太必要的。这本书中有两大部分的知识点对于新手来说非常有必要了解:

  1. 一段源代码是怎么变成最后可执行的程序的
  2. 一个进程,在内存中是什么样的

带着这两个问题去读书,收获会更大。在阅读原书之前,这里有几个相关内容的总结,我尽可能用简单的语言介绍某些知识背景。即使不能完全看懂,也有利于读书时的理解。

从源码到程序

程序最初的存在形式是源代码,也就是若干个.c文件。它要想变成一个可执行的程序,需要以下几个步骤:

  1. 预编译(P39):负责这一步工作的叫“预编译器”。它主要负责处理所有的#define宏定义;所有的预编译指令,比如#if#endif等。接下来会递归处理#include指令,用被包含的文件替换这个预编译指令。.c文件经过预编译,变为.i文件。

  2. 编译(P42):这一步由编译器负责,主要又由词法分析、语法分析、语义分析、优化和生成汇编代码五个部分:

    • 词法分析:识别源代码中的各种括号、数字、标点等。比如有(但没有),这一步就能发现错误
    • 语法分析:这一步会生成语法树,比如2+4就是一颗根节点为+,左右叶子节点分别为24的语法树。如果你只是写2+,在这一步就会报错。
    • 语义分析:这一步主要考虑类型声明、匹配和转换。比如你写2 * "3"在这一步就会报错
    • 中间语言生成:这一步会生成平台无关的三地址码,比如2 + 3会写成t1 = 2 + 3,同时也会把这样在编译期就可以确定的表达式进行优化
    • 目标代码生成:编译器根据三地址码生成依赖于目标机器的目标机器代码,也就是汇编语言。

    .i文件经过编译,得到汇编文件,后缀是.s

  3. 汇编(P40):这一步由汇编器负责,将汇编语言转换成机器可以执行的语言(完全由0和1组成).汇编文件经过汇编,变成目标文件,后缀为.o

  4. 链接(P41):这一步是这本书的重点。之前的几个步骤,都是以.c文件为基本单位,一个.c源代码文件最终被汇编,生成目标文件。这一步就是处理如何把多个目标文件链接起来。

    考虑一个.c文件中,用到了另一个.c文件中的变量或函数。在编译这个文件时,我们无法在编译期确定这个变量或函数的地址。只有在把所有目标文件链接起来以后,才能确定。链接器主要负责地址重分配、符号名称绑定和重定位。

从源代码到程序的运行要做的远远不止编译,很多时候我们说“把程序编译一下”,是不准确的。不过编译确实是整个流程中最复杂的部分。

软件调用层次

我们把整个计算机调用结构分为四层:

  1. 最上层是应用层。不管是浏览器、游戏,还是我们使用的各种开发工具,如Xcode,VS,汇编器自身等,都属于这一范畴。
  2. 第二层是操作系统的运行库。我们在程序里调用系统API,比如文件读写,就是调用了第二层提供的相应服务。这种调用通过操作系统的API完成,它沟通了应用层和操作系统的运行库。这也就是为什么不管是在Mac还是Windows上编程,我们都可以调用printf()fread()等函数。因为不同的操作系统的运行库提供了不同底层的实现,但对应用层提供的API总是一样的。
  3. 第三层是操作系统内核。操作系统的运行库通过系统调用(System Call)调用系统内核提供的函数。比如fread属于API,它在Linux下会调用read()这个系统调用,而在Windows下会调用ReadFile()这个系统调用。应用程序可以直接调用系统调用,但是这样一来,我们需要考虑各个操作系统下系统调用的不同,而且系统调用由于更加底层,实现起来也就更加困难。最关键的是,系统调用是通过中断来完成的,涉及到堆栈的保存与恢复,频繁的系统调用会影响性能。
  4. 第四层是硬件层。程序无法直接访问这一层,只有操作系统的内核,通过硬件厂商提供的接口才能访问。

这四层之间的关系如下图所示:

层次关系

虚拟地址空间

在程序运行的过程中,最重要的概念就是虚拟地址空间。所谓的虚拟地址空间,是指应用程序自己认为,自己所处的地址空间。它区别于物理地址空间。后者是真实存在的,比如电脑有一根8G的内存条,物理地址空间就是0~8Gb。CPU的MMU负责把虚拟地址转换成物理地址。

引入虚拟地址的第一个好处是,程序员不再关心真实的物理内存空间是什么样的,理论上来说,程序员有几乎无限大的虚拟内存空间可用,最后只要建立虚拟地址和物理地址的对应关系即可。另一方面,操作系统屏蔽了物理内存空间的细节,进程无法访问到操作系统禁止访问的物理地址,也不能访问到别的进程的地址空间,这大大增强了程序安全性。

由虚拟地址空间引申出来的分页(Paging)技术,大大提高了内存的使用效率。要想运行一个程序,不再需要把整个程序都放入内存中执行,我们只要保证将要执行的页在内存中即可,如果不存在则导致页错误。

关于地址空间的理解非常重要,书中有很多关于内存、和地址的描述,需要我们自己分析这是虚拟地址还是物理地址。如果分析错了,理解问题会比较麻烦。

链接与重定位

我们把foo函数定义在另一个文件中,然后在main.c中调用这个函数,单独编译main.c后代码如下:

……
0000000000000024	callq	0x29
0000000000000029	xorl	%ecx, %ecx
……

可以看到,本该调用foo函数的地方,我们直接调用了下一条命令,但是当main.ofoo.o链接起来后,就变成了:

0000000100000f30	pushq	%rbp
0000000100000f31	movq	%rsp, %rbp
0000000100000f34	movl	$0x7b, %eax
0000000100000f39	movl	%edi, -0x4(%rbp)
0000000100000f3c	movl	%esi, -0x8(%rbp)
0000000100000f3f	popq	%rbp
//以上为foo函数实现
……
0000000100000f74	callq	0x100000f30
0000000100000f79	xorl	%ecx, %ecx
……

这时候foo函数的位置就正确设置了。原因在于在main.c这个编译模块单独编译时,编译器无法确定foo的位置,只好临时用下一条指令的位置代替一下。

链接器在链接过程中,就是要对这样的符号进行重定位。在重定位时,main.o中有foo函数经过修饰的符号名,同样的符号名在foo.o中也有,于是两者一拍即合,就这样被链接器连在了一起。0x29这个临时的调用地址被更新成了0x100000f30。这个过程类似于拼图游戏,程序在链接时就是处理各种各样类似的问题,当所有编译模块都按照符号名完整的链接起来时,程序也就可以开始运行了。

书中花了不少篇幅介绍目标文件的组成结构,其中很多都是为了重定位而准备的。一旦明白了重定位的原理和过程,在阅读相关内容时就会轻松很多。

知识概要

最后列出一部分知识点的简要概括和他们在书中的位置,方便读者参考:

###静态链接部分

这一部分主要是讨论多个.c文件怎么通过静态链接,得到一个静态库。

  • P58

    目标文件中分为若干个段,比如.text段存放代码,.data段存放存放已初始化的全局变量和局部静态变量,.bss段存放未初始化的全局变量和局部静态变量,除此以外目标文件还有很多其他的段。

  • P70

    Linux下的目标文件还有一个ELF文件头,用于汇总这个目标文件的各种信息,其中包括了ELF魔数、机器字节长度、数据存储方式、版本、运行平台、ABI版本,重定位类型、硬件平台及版本、入口地址、段表位置、段的数量等。

  • P74

    段表其实是一个数组,其中每一个元素都是结构体。结构体里面有段的名称、类型、加载地址、相对于文件头的偏移量,段的大小,链接信息等。

  • P79

    目标文件中还有一个重定位表。需要重定位的信息都记录在这个表里面。.text段中所有需要重定位的信息,都放在.rel.text段中。

  • P81

    在链接时,我们把函数名和变量都称为符号。每一个函数、变量都有自己独特的符号名,这样在链接时才能把它们对应起来。不同的语言有自己的符号修饰规则。UNIX下的C,编译出来的符号名前面加“_”,如函数foo在编译之后的结果为_foo。

  • P86

    C++的namespace就是用来避免符号名冲突。C++有一套自己的符号名修饰规则,可以通过c++filt命令还原被修饰过的符号名(demangle)。一旦了解了符号名的修饰规则,在写iOS时遇到undefined symbolduplicate symbol name的报错,就非常好检查了。

  • P92

    符号分为强符号,和弱符号。强符号不可名称重复,弱符号(未初始化的全局变量)可以有符号名相同。对符号名的引用分为强引用和弱引用,强引用表示如果找不到符号定义会报错,弱引用不报错,默认为0或某个特殊值。

  • P99

    链接过程一般分为两步,首先地址分配,然后符号解析并重定位。

    由于不同的目标文件,可能含有相同的段,所以在链接过程中,我们可以合并相似段,这就是地址分配。

    合并完成后,所有符号的位置都可以唯一确定,此时可以就开始重定位工作了。链接完成后,我们就得到了静态库。

  • P118

    静态库可以看做一组目标文件的集合,同一个静态库中的不同目标文件可能相互依赖,不同的静态库也可以相互依赖。

  • P127

    链接控制脚本控制链接器的运行,将目标文件和库文件转化为可执行文件。链接控制脚本由链接脚本语言写成。可以认为的控制程序入口,某几个段合并,某几个段舍弃等

动态装载

这一部分主要是讨论经过链接后,可执行文件如何装载到内存中

  • P153

    有两种典型的动态装载方法:覆盖装入和页映射。覆盖装入允许互不依赖的两个模块共同享有同一块内存,在使用中互相替换。速度较慢,用时间换空间。我们常用的方案是页映射,把程序虚拟的内存空间分成多个页,由专门的页装载管理器负责管理虚拟页和物理内存中页的对应关系。

  • P157

    创建进程三步骤:首先进程自己的创建物理空间。设置好虚拟空间中各个页到物理空间里的页的映射关系(这一步可能在页错误之后发生)、然后建立虚拟空间与可执行文件的映射关系。Linux下,目标文件的每个段都有自己在虚拟内存中的位置,这叫虚拟内存区域(VMA, Virtual Memory Area),表示它装载在虚拟内存中的地址,最后指令寄存器设置为可执行文件入口。

  • P159

    进程创建后,只有物理页与虚拟页的对应关系,但是真正的指令和数据还没有放入物理页中,物理页的内存处于未分配状态。一旦访问到这个物理页,就会发生页错误。

    发生页错误时,操作系统立刻根据物理内存的页与虚拟内存的页的对应关系,找到这个页对应的虚拟内存,然后再查询每个段的VMA,就可以找这个页面在可执行文件中的偏移量。这时候操作系统先为物理页分配内存空间,然后把可执行文件中的数据和指令写入物理页,最后建立物理页和虚拟页联系即可。然后进程从发生页错误的地方重新执行。

  • P169

    可执行文件有很多Section,它们的大小各不相同,但有些小于页的大小,导致了空间浪费(不能连续存储不同的section是因为可能会有两个权限不同的section在同一个页中)。由于操作系统不关心每个Section的具体作用,但是关心它们的读写权限(是否可读、可写、可执行),所以往往把具有权限的Section合并成一个Segment

  • P172:

    进程运行后,操作系统会初始化进程的堆栈,其中存放了环境变量和命令行参数。这些参数被传给main函数(argc和argv两个参数对应参数数量和参数数组)

动态链接

  • P181

    动态链接把程序按模块拆分成若干个相对独立的部分,模块之间的链接推迟到运行时。ELF的动态链接文件成为“动态共享对象(DSO)”,后缀为“.so”。动态链接的过程由动态链接器完成。动态链接可以节约内存(多个进程共享内存中的某一个模块)、方便升级(静态链接的每一个模块都会影响整个可执行文件)。

  • P188:

    由于动态共享对象会被多个程序使用,导致它在虚拟地址空间中的位置难以确定。不同模块的目标装载地址如果有相同的,那么同时导入这两个模块就会出问题。如果都不一样也不行,因为可能存在的模块太多了。没有那么多内存。所以动态共享对象需要在装载时重定位。

  • P191:

    装载时重定位会导致无法在多个进程间共享,目前采用的方案是地址无关代码技术。动态对象中的地址引用分为模块内部和外部,指令引用和数据引用,两两组合成四种。对于模块内部的指令或数据引用,采用相对偏移调用的方法。

  • P195:

    把地址相关需要重定位的部分放到数据段中,同时建立全局偏移表(GOT)。用.got和.got.plt表分别处理数据和函数引用。

  • P200:

    当函数第一次被用到的时候才重定位,从而提高程序运行速度。这种方法被称为延迟绑定(Lazy Binding)。Linux维护一个PLT(Procedure Linkage Table)来保存符号名和真实地址之间的对应关系

  • P208:

    动态链接中有两个重定位表.rel.dyn和.rel.plt分别对应.rel.text和.rel.data。前者对数据引用(.got)进行修正,后者对函数引用(.got.plt)进行修正。

  • P214:

    动态链接器是一个特殊共享对象,它不依赖于任何动态共享文件,且自己的重定位工作由自己完成。通过一段被称为自举(Bootstrap)的特殊代码,不用到任何静态或全部变量,完成这项工作

内存与库

  • P286:

    i386处理器下,栈顶有esp寄存器定位,由于栈向下生长,压栈使得栈顶地址减小

  • P287:

    栈保存了函数调用所需要的维护信息,被称为堆栈帧(Stack Frame)或活动记录,包含了函数的返回地址和函数,临时变量以及保存的上下文。ebp是帧指针指向活动记录的某一个固定位置。

  • P294:

    函数的调用方和被调用方要遵守同一个“调用惯例”。默认的cdecl惯例要求函数参数以从右到左的顺序入栈,由函数调用方负责参数的出栈。

  • P301:

    函数返回值的获取:如果是四个字节,放在eax中。4-8字节的返回值通过eax(低位)和edx(高位)联合存储。查过8字节的返回值,把返回值在栈中存放的地址放到eax中。

  • P306:

    栈上的数据在函数返回时就会被释放,全局地、动态的申请内存的方式是利用堆。如果由操作系统管理堆,由于总是进行系统调用,性能开销比较大,所以一般由应用程序“批发”一大块内存空间,然后自己进行内存管理。

  • P311:

    堆并不总是向上生长(如Windows的HeapCreate系列),调用malloc有可能产生系统调用(取决于进程预申请的空间是否足够),堆内存在进程结束后被操作系统回收,堆内存在虚拟地址空间中连续,在物理空间中可能不连续

  • P314:

    堆分配三种算法:空闲链表(简单,记录长度的字节容易被数组越界破坏)、位图(速度快(容易命中cache),稳定性好(不容易数组越界),易管理,会产生碎片,位图有可能过大)、对象池(针对固定大小的分配空间)

  • P319:

    创建进程后,操作系统把控制权交给运行库的某个入口函数,然后开始堆的构造,启动I/O,创建线程,进行全局变量构造等。然后调用main函数,main函数执行完成后,执行与之前相反的操作,进行系统调用结束进程。