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

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

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

对于不是专门从事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函数执行完成后,执行与以前相反的操做,进行系统调用结束进程。

相关文章
相关标签/搜索