CMU 15-213 Review

去年年初在一家小公司实习时读完了《深入理解计算机系统》,读完只惊呼一本技术书籍竟然可以兼顾深度和广度地囊括计算机系统的方方面面,下至浮点数的二进制编码,汇编语言,上到网络编程,并发编程。但那时,我把每个章节作为相互独立的内容来看。今年瘟疫期间重新看完了这本书对应的课程视频,才以更全面的视角来理解这门课程。

这门课程是为CMU计算机专业的低年级本科生开设,作为计算机系统方向的导论课。从书名出发可以理解这门课程开设的用意。可惜中文版的翻译《深入理解计算机系统》并不算准确。原书名是Computer System: from A Programmer Perspective,字面翻译作《计算机系统:从程序员的视角》。而“程序员视角”恰好是这本书的精华所在。

在计算机封装的世界里,复杂的东西被包装得漂亮又方便。但抽象偶尔失灵的时候,我们仍然需要打开封装的盒子,钻进复杂性中一探究竟。可是每一层抽象中无穷无尽的细节容易使我们疲于奔命。如何站在高处从“程序员的视角”,简单扼要的了解计算机系统各个层面的运作方式,以及这些原理如何帮助我们写代码,正是这门课程的价值所在。举一个例子,这门课程没有向其他汇编课程一样教授如何用汇编语言写冒泡排序,而是从编译器的角度讲了gcc会产生怎样的汇编代码,所以我们可以如何优化程序。另一个例子是利用计算机存储器体系来优化程序性能。这些底层的知识最终都落脚到我们应该写怎样的代码,这就是用“程序员视角”看待系统。

接下来是本课程的各个章节的重点梳理:

数据的表示

“计算机使用二进制存储数据”可以算作21世纪的常识了。现代计算机整数和浮点数采用不同的编码方式,但因为巧妙的设计,两个浮点数也可以当作整数送进比较器中进行比较。浮点数和整数的溢出处理方式不同,前者采用特殊值±inf.表示,后者溢出类似取模操作。正是整数溢出的特性使负数补码的加减乘除不需要额外的处理就得到正确的结果。

整数在C语言中分为有符号和无符号两种,若整数表达式中存在一个无符号数,则另一个数会自动转型成无符号数。因为这个隐式转型,可能导致下面这种难以察觉的bug:

for (int i=n-1;i-sizeof(char)>=0;i--) {
    // ... infinite loop
}

计算机能表示的浮点数是数轴上一个个离散的点,分布情况如下图,在0附近是等距的,到达一定位置后,越靠近无穷间距则越大。

数轴

trick:如何打印浮点数的二进制编码?利用union

汇编语言

C语言的?:三目运算符被编译成一种叫条件转移的汇编代码结构。另一种if语句生成的叫作条件跳转。由于现代处理器流水线设计,条件转移的效率更高。但是条件转移也存在问题,即条件转移的两个结果值都会被计算,如果其中一个是递归函数调用,则造成死循环;如果其中一个是有副作用/耗时的函数调用,也会导致不好的结果。

C语言的switch利用跳转表实现。跳转表存储在内存中的只读数据段,swtich中变量作为索引,元素项是各个case的代码地址。这样就解释了为什么C语言只能用整数作为switch的变量。

C语言的结构体的数据有对齐要求,一是内部对齐,即每个变量对齐自身大小的地址;二是外部对齐,即结构体长度对齐最大变量大小的倍数。所以,结构体中变量的声明顺序对结构体的大小有影响。

存储体系

计算机的存储体系呈金字塔结构,顶层的存储器昂贵快速,底层的便宜容量大。体系中,下一级存储器都可以看作上一级的缓存。缓存利用了程序的局部性原理。局部性可以分为时间局部性和空间局部性。时间局部性指程序访问一个变量后,不久的将来会重复访问这个变量;空间局部性指程序访问一个变量后,不久的将来会访问该变量附近的变量。所以,写具有空间和时间局部性的代码,可以提高程序的性能。正是由于空间局部性,缓存将数据划分呈多个字节组成的单元,在高速缓存中称作块(Block),在虚拟内存中称作页(Page)。多个数组运算中,一个提高局部性的方法是分块。

链接

链接是生成可执行性文件的最后一步,作用是整合多个目标文件。可执行文件采用elf编码。得益于虚拟内存提供的私有地址空间,可执行文件可以被直接加载(从磁盘上映射)到内存中。

链接同时使库的存在变成可能。库在链接过程才引入程序。库分为静态库和动态库。静态库(.a)实际是多个目标文件的打包;动态库(.so)在可执行文件加载到内存才动态链接。动态库的代码会被映射到进程的共享库段。由于虚拟内存的存在,多个进程共享同样的链接库代码。利用库打桩技术可以在C语言中实现类似装饰器的功能。

异常控制流

与由程序状态改变引起跳转的条件语句不同,异常控制流是由系统状态改变引起的。系统利用异常控制流实现如中断,程序上下文切花,缺页故障,段错误和信号等功能。中断指由芯片引脚电平变化引起的异常,比如键盘按下,定时器过期等;陷阱指程序有意引发的异常,用于系统转换到内核态进行系统调用;故障指程序错误引发的异常,有可恢复的异常如缺页,也有不可恢复的如除0;信号是完全软件实现的,用于通知进程某种系统状态的改变。

程序员可以自定义信号处理函数来处理信号。信号可以阻塞。但是信号不能用作统计某事件的发生次数,因为新信号会覆盖待处理的信号。由于信号处理函数与主函数是并发的,也存在竞争等并发问题,可以用阻塞信号和可重入函数解决该问题。

虚拟内存

虚拟内存为进程提供了私用的地址空间,起缓存、内存管理和保护等三个作用。虚拟内存由操作系统和硬件协作实现。操作系统在内存某处存储一个多级页表,页表中记录虚拟内存到物理内存的地址映射,处理器中某个寄存器指向该地址,进程切换时更改寄存器的值即可实现地址空间切换。页表每项有权限的标记,如读写执行,防止代码注入攻击。由专门硬件负责地址翻译,根据页表计算出物理地址,传递给主存获取数据。

内存分配器

介绍了三种内存分配器,隐式,显式和多列表。内存分配器的吞吐量和内存利用率是两个不可兼得的目标。

网络编程和并发编程

网络编程的套路:

网络编程接口

三种服务器模型,多进程,事件驱动和多线程。

并发编程介绍了并发经典问题,竞争和死锁。可以利用信号量和线程安全函数解决。并发模型:生产者-消费者,读写模型。注意多核上的多线程的性能不一定好过单核单线程的性能,代码的写法起到很大影响,因为多线程存在线程调度和同步等额外开销。