第一章 温故而知新

Posted by Firef1y on April 3, 2020

1.1 从Hello World说起

简单的事物背后往往蕴含着复杂的机制

  • 程序为什么要被编译器编译了之后才可以运行
  • 编译器在把C语言程序转换成可以执行的机器码的过程中做了什么,怎样做的
  • 最后编译出来的可执行文件里面是什么?除了机器码还有什么?它们是怎么存放的,怎么组织的?
  • #include是什么意思?把stdio.h包含进来意味着什么?C语言库又是什么?它怎么实现的?
  • 不同的编译器(Microsoft VC、GCC)和不同的硬件平台(x86、SPARC、MIPS、ARM)以及不同的操作系统(Windows、Linux、UNIX、Solaris),最终编译出来的结果一样吗?为什么?
  • Hello World是怎么运行起来的?操作系统是怎么装载它的?它从哪里开始执行,到哪结束?main函数之前发生了什么?main函数结束发生了什么?
  • 如果没有操作系统,Hello World可以运行吗?如果要在一台没有操作系统的机器上运行Hello World需要什么?应该怎么实现?
  • printf是怎么实现的?它为什么可以有不定数量的参数?为什么它能够在终端输出字符串?
  • Hello World程序在运行时,它在内存中是什么样子的

我们会从最基本的编译、静态链接到操作系统如何装载程序、动态链接及运行库和标准库的实现,甚至一些操作系统的机制,力争深入浅出地将这些问题剥开,最终使得这些程序运行背后的机制形成一个非常清晰而流畅的脉络。

1.2 万变不离其宗

对于系统程序开发者来说,计算机多如牛毛的硬件设备中,有三个部件最为关键,它们分别是中央处理器CPU、内存和I/O控制芯片;对于普通应用程序开发者,只关心CPU;对于一些高级平台的开发者,连CPU都不需要关心,因为这些平台为他们提供了一个通用的计算机抽象

早期计算机没有复杂的图形功能,CPU频率不高,和内存一样,所以它们都是直接连接在同一个总线上。I/O设备速度慢一些,为了协调I/O设备与总线之间的速度,也为了CPU和能够和I/O设备进行通信,每个设备都会有一个I/O控制器。

image

后来,CPU频率提升,内存速度跟不上,于是产生了与内存频率一致的系统总线,CPU采用倍频的方式与系统总线进行通信。图形化操作系统普及,图形芯片需要跟CPU和内存之间大量交换数据,慢速的I/O总线无法满足图形设备,为了协调CPU、内存和高速图形设备,专门设计了北桥芯片。

高速设备和低俗设备都接在北桥芯片上过于复杂,所以又设计了南桥芯片,磁盘、USB、键盘、鼠标等设备都接在南桥上,由南桥将它们汇总后连接到北桥。20世纪90年代,PC机的系统总线采用PCI结构,低速设备采用ISA总线。 image

人们总是希望计算机越来越快,然而2004年以来,CPU频率再没有质的提高,人们在制造CPU的工艺方面达到了物理极限。

人们开始想办法从另一个角度提高CPU的速度,增加CPU的数量。对称多处理器(SMP,Symmetrical Multi-Processing)。由于我们的程序并不是都能分解成若干个完全不相关的子问题,所以速度的提高与CPU的数量是难以正比的。在处理大量相互独立的请求时,多处理器能最大化地发挥威力。例如,大型的数据库、网络服务器。

在个人电脑中,多处理器是比较奢侈的行为。厂商考虑将多个处理器打包出售,共享比较昂贵的缓存部件,只保留多个核心,这就是多核处理器(Multi-core Processor)。多核和对称多处理器的差别在于缓存。

拓展阅读:Free Lunch is Over http://www.gotw.ca/publications/concurrency-ddj.htm

1.3 站得高,望得远

系统软件:管理计算机本真的软件

平台性的系统软件:操作系统内核、驱动程序、运行库和数以千计的系统工具

用于程序开发的系统软件:编译器、汇编器、链接器等开发工具和开发库

Any problem in computer science can be solved by another layer of indirectoin.

计算机系统软件体系结构的设计要点 image

每个层次进行相互通信的通信协议:接口。下层是接口的提供者,上层是接口的使用者。理论上层次之间只要遵循这个接口,任何一层都可以被修改或者替换。

硬件和应用程序之间的层次称为中间层。每个中间层都是对它下面那层的包装与扩展。中间层的存在使得应用程序和硬件之间保持相对独立,80386芯片和DOS系统设计的软件仍可以在多核处理器和Windows Vista下运行。这归功于两方面:

  • 硬件和操作系统本身保持了向后兼容性
  • 层次结构设计方式

虚拟机技术即是在硬件和操作系统之间增加一层虚拟层。

从层次结构上看,开发工具与应用程序属于同一层次,因为它们都是用一个接口,操作系统应用编程接口(Application Programming Interface)。接口的提供者是运行库。Linux下的Glibc库提供POSIX的API;Windows的运行库提供Windows API。

运行库使用操作系统提供的系统调用接口(System call Interface),系统调用接口在实现中以软件中断(Software Interrupt)的方式提供。

操作系统是硬件接口的使用者,硬件的接口决定了操作系统内核,这个接口被称作硬件规格。

操作系统做什么

  • 提供抽象的接口
  • 管理硬件资源

CPU资源昂贵,尽力不让CPU空闲下来,编写监控程序,充分利用CPU,即多道程序(Multiprogramming)。提高了CPU利用率,但是调度策略粗糙,程序之间部分轻重缓急。

分时系统(Time-Sharing System)诞生,每个程序运行一段时间后主动让出,如果霸占着不放,也没有办法。系统中任何一个程序死循环会导致系统死机。

多任务系统(Multi-tasking),找一个管家–操作系统。时间片用尽,操作系统剥夺CPU的资源。应用程序以进程的形式运行在比操作系统更低的级别。

当成熟的操作系统出现以后,硬件逐渐被抽象为一系列概念。在UNIX中,硬件设备的访问形式跟普通文件形式一样;在Windows系统中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象,磁盘被抽象成了普通文件系统。

程序员从硬件细节解放出来,交给硬件驱动程序完成,硬件驱动可以看作是操作系统的一部分,跟操作系统内核一起运行在特权级,但与操作系统内核之间又有独立性。硬件驱动程序由硬件生产厂商开发,遵循操作系统提供的接口和框架。

一个文件读取的例子

文件系统管理着磁盘中文件的存储方式。

Linux系统下有一个文件“/home/user/test.dat”,长度为8000字节。前4096字节存储在磁盘的1000号到1007号扇区,第4097字节到第8000字节共3904字节存储在磁盘的2000号到2007号扇区,剩下的192字节无效。 image

硬盘结构介绍,硬盘基本存储单元为扇区,每个扇区512字节。一个硬盘有很多盘片,每个盘片分两面,每面按照同心圆划分磁盘,每个磁道划分若干扇区。每个磁道拥有相同数量的扇区,外围的磁道密度比内圈稀疏。每个磁道拥有不同数量的扇区,计算起来麻烦。所以使用LBA方式(Logical Block Address),即整个硬盘中所有的扇区从0开始编号,扇区编号称为逻辑扇区号。

文件系统收到read系统调用请求,判断文件前4096个字节位于磁盘的1000号到1007号逻辑扇区。然后文件系统向硬盘驱动发出读取请求,磁盘驱动程序收到请求向硬盘发送硬件命令。向硬件发送I/O命令,最常见的是通过读写I/O端口寄存器来实现。

内存不够怎么办

多任务系统出现,如何将计算机上有限的物理内存分配给多个程序使用? 通常存在三方面问题:

  • 地址空间不隔离,恶意程序可以改写其他程序的内存数据
  • 内存使用效率低,内存不够时,需要大量的数据换入换出。
  • 程序运行的地址不确定,程序每次装入物理内存的位置不确定

解决方法:增加中间层,使用一种间接的地址访问方法——虚拟地址。

作为普通的程序,它需要的是一个简单的执行环境,有一个单一的地址空间、有自己的CPU。地址空间分为两种:虚拟地址空间和物理地址空间。每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效地做到了进程的隔离

分段,基本思想是把一段与程序所需要的内存空间大小的虚拟空间映射到某个物理地址空间。映射过程由软件设置,比如操作系统来设置这个映射函数,实际的地址转换由硬件完成。 image

分段的问题解决了隔离问题和程序运行不确定问题。 程序A和程序B被映射到了两块不同的物理空间区域,它们之间没有任何重叠。 程序被分配到物理地址的哪个区域,对程序来说是透明的,只需按照虚拟地址编写程序。

然而,分段并不能解决内存使用效率的问题。分段的内存区域映射还是按照程序为单位,粒度较大,内存不足还是整个程序换出。考虑到程序的局部性原理,提出更小粒度的内存分割和映射方法。

分页

把地址空间人为地等分成固定大小的页

image Process1的VP2和VP3不在内存中,但是当进程用到这两个页时,硬件会捕获到这个消息,即页错误,然后操作系统接管进程,负责将VP2和VP3从磁盘读出来装入内存,然后将内存中的这两个页与VP2和VP3建立映射关系。

每个页可以设置权限属性,起到保护作用

虚拟存储的实现需要依靠硬件的支持,MMU(Memory Management Unit)进行页映射,集成在CPU内部 image

众人拾柴火焰高

线程,轻量级进程(Lightweight Process),是程序执行流的最小单元

线程私有

  • 栈(函数参数、局部变量)
  • 线程局部存储(Thread Local Storage,TLS)
  • 寄存器

线程共享

  • 全局变量
  • 堆上的数据
  • 函数里的静态变量
  • 程序代码
  • 打开的文件

一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果

条件:

  • 不使用任何静态或全局非const变量
  • 不使用任何静态或全局非const指针
  • 仅依赖于调用方提供的参数
  • 不依赖任何单个资源的锁
  • 不调用任何不可重用函数

可重入使并发安全的强力保障,一个可重入函数可以在多线程环境下放心使用

用户态线程库的实现:

1.一对一模型

image

优点

  • 线程之间的并发是真正的并发,一个线程阻塞不影响其他线程

缺点

  • 操作系统限制了内核线程的数量,所以一对一模型的用户线程数量受限制
  • 上下文切换的开销大,用户线程的执行效率下降

2.多对一模型

image

  • 切换速度块,开销小
  • 线程数量不受限制
  • 处理器的增多对多对一模型的线程性能没有显著帮助
  • 一个用户线程阻塞,所有线程无法执行

3.多对多模型

image

结合了多对一模型和一对一模型的特点