1. 计算机系统漫游
还是从hello world
开始(hello.c
):
#include <stdio.h>
int main()
{
printf("hello, world\n");
}
逐步介绍它的整个生命周期: 被程序员创建 -> 到在系统上运行 -> 输出简单的消息 -> 然后终止.
1.1 信息就是位 + 上下文
大部分的现代操作系统都使用ASCII
标准来表示文本字符, 即使用一个单字节大小的整数值来表示每个字符. 具体来说:
- 第一个字节的整数值是35, 对应的是字符"#";
- 第二个字节的整数值是105, 对应的是字符"i";
- 每个文本行以不可见的字符(整数值为10), 换行符, "n"结尾.
此外, 由ASCII
字符构成的文件成为文本文件, 所有其他文件都称为二进制文件.
C编程语言的起源
- C语言是贝尔实验室的Dennis Ritchie于1969-1973年创建;
- C语言与Unix操作系统关系密切;
- C语言小而简单;
- C语言是为实践目的而设计的;
- C语言是系统级编程的首选, 同时它也非常适用于应用级程序的编写;
- C语言的指针是造成困惑和程序错误的一个常见原因;
- C语言还缺乏对非常有用的抽象(类、对象和异常)的显式支持.
1.2 程序被其他程序翻译成不同的格式
为了在系统上运行C程序, 需要把每条C语句转化为低级机器语言指令, 然后这些指令按照一种称为可执行目标程序的格式打好包, 并以二进制磁盘文件的形式存放起来. 目标程序也称为可执行目标文件.
在Unix系统上, 从源文件到目标文件的转化是由编译器驱动程序完成的:
unix> gcc -o hello hello.c
GCC编译器读取源文件hello.c
, 并翻译成一个可执行目标文件hello
. 这个翻译的过程由4个阶段完成:
hello.c 1 2 3 4 hello
源程序(文本) --> 预处理器 --> 编译器 --> 汇编器 --> 链接器 --> 可执行目标程序(二进制)
(cpp) ^ (ccl) ^ (as) ^ (ld)
| | |
| | |
hello.i hello.s hello.o
被修改的 汇编 printf.o
源程序 程序 可重定位目标程序
(文本) (文本) (二进制)
运行这4个阶段的程序(预处理器cpp, 编译器ccl, 汇编器as和链接器ld)一起构成了编译系统(compilation system).
GNU 项目
- GCC是GNU(GNU’s Not Unix)项目开发出来的众多有用的工具之一;
- GNU项目已开发出一个包含Unix操作系统所有主要部件的环境, 但内核除外; 该环境包括EMACS编辑器, GCC编译器, GDB调试器, 汇编器, 链接器, 处理二进制文件的工具等;
- GCC编译器支持许多不同的语言, 能够针对不同的机器生成代码;
- Linux受欢迎在很大程序上归功于GNU工具, 因为它们给Linux内核提供了环境.
1.3 了解编译系统如何工作是大有益处的
对于像hello.c
的简单程序, 直接依赖编译系统生成正确有效的机器代码即可, 但有以下原因促使我们程序员必须了解编译系统是如何工作的:
优化程序性能
为了在C程序中做出好的编码选择, 需要了解一些机器代码以及编译器将不同的C语句转化为机器代码的方式.
- switch VS if-then-else
- 函数调用开销
- while VS for
- 指针引用 VS 数组索引
- 循环求和结果放在本地变量 VS 结果放在引用传递过来的参数
理解链接时出现的错误
- 链接器报告无法解析一个引用
- 静态变量 VS 全局变量
- 不同的C文件定义同名全局变量
- 静态库 VS 动态库
- 在命令行上排列库的顺序会有什么影响
避免安全漏洞
学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式会引起的后果.
- 理解堆栈原理
- 避免缓冲区溢出
1.4 处理器读并解释存储在存储器中的指令
hello.c
源程序已经被翻译为可执行目标文件hello
并存放在磁盘上. 要想在Unix上运行它, 把文件名输入到Shell
(外壳)的应用程序中:
unix> ./hello
hello, world
unix>
- Shell是一个命令行解析器, 它输出一个提示符, 等待你输入一个命令行, 然后执行这个命令;
- 如果该命令不是内置的外壳命令, 外壳会假设这是一个可执行文件的名字, 加载并运行之.
1.4.1 系统的硬件组成
1.总线(贯穿整个系统的电子管道)
- 携带信息字节并负责在各个部件间传递;
- 通常设计成传送定长的字节块, 也就是字(word);
- 字中的字节数(即字长)是一个基本的系统参数, 在各个系统中不尽相同;
- 大多数机器有的是4个字长(32位), 有的是8(64位);
- 为了讨论方便, 假定字长为4个字节, 并且总线每次只传送1个字.
2.I/O设备(系统与外部世界的联系通道)
- 每个I/O设备都通过一个控制器或适配器与I/O总线相连;
- 控制器和适配器之间的区别主要在于它们的封装方式;
- 控制器是置于I/O设备本身的或者系统的主板上的芯片组;
- 适配器是一块插在主板插槽上的卡.
3.主存(临时存储设备)
- 由一组动态随机存取存储器(DRAM)芯片组成;
- 逻辑上, 存储器是一个线性的字节数组, 每个字节都有其唯一的地址(数组索引), 地址从零开始;
- 组成程序的每条机器指令都由不同数量的字节构成.
4.处理器(解释或执行存储在主存中指令的引擎)
- 处理器的核心是一个字长的存储设备(或寄存器), 称为程序计数器(PC);
- 在任何时刻, PC都指向主存中的某条机器语言指令(即含有该条指令中的地址);
- 从系统通电到断电, 处理器一直在执行程序计数器指向的指令, 再更新程序计数器, 使其指向下一条指令.
1.4.2 运行hello程序
当我们从键盘输入./hello
后, 外壳程序将字符逐一读入寄存器, 再把它存放到存储器中:
从键盘上读取hello命令
CPU
|-----------------------|
| PC 寄存器文件 <-> ALU |
| ^ | 存储器总线
| | | |
| v 系统总线 v
| 总线接口 <-----------------> I/O桥 ----> 主存储器 "hello"
|-----------------------| ^
|
==================== IO总线 =====================|=|=|=======
^ | | 扩展槽, 留待网络
| | | 适配器一类的设备
USB控制器 图形适配器 磁盘控制器 使用
^ | | |
| | | |
键盘 鼠标 显示器 磁盘
^
|
用户输入
"hello"
敲击回车键后, 外壳程序知道命令输入已结束, 接着执行一系列的指令来加载可执行的hello
文件, 将hello
目标文件中的代码和数据(hello world\n
)从磁盘复制到主存.
利用直接存储器存取(DMA)技术, 数据可以不通过处理器而直接从磁盘到达主存:
从磁盘加载可执行文件到主存
CPU
|-----------------------|
| PC 寄存器文件 --- ALU |
| ^ | 存储器总线
| | | |
| v 系统总线 v
| 总线接口 <-----------------> I/O桥 ----> 主存储器 "hello"
|-----------------------| ^
|
==================== IO总线 =====================|=|=|=======
| | ^ 扩展槽, 留待网络
| | | 适配器一类的设备
USB控制器 图形适配器 磁盘控制器 使用
^ | | |
| | | |
键盘 鼠标 显示器 磁盘
当目标文件中的代码和数据被加载到主存, 处理器就开始执行hello
程序的main
程序中的机器语言指令. 这些指令将hello, world\n
字符串中的字节从主存复制到寄存器文件, 再从寄存器文件中复制到显示设备, 最终显示在屏幕上.
将输出字符串从内存写到显示器
CPU
|-----------------------|
| PC 寄存器文件 --- ALU |
| ^ | 存储器总线
| | | |
| v 系统总线 v
| 总线接口 <-----------------> I/O桥 ----> 主存储器 "hello"
|-----------------------| |
|
==================== IO总线 =====================|=|=|=======
^ | | 扩展槽, 留待网络
| v | 适配器一类的设备
USB控制器 图形适配器 磁盘控制器 使用
^ ^ | |
| | v |
键盘 鼠标 显示器 磁盘
1.5 高速缓存至关重要
系统花费了大量的时间把信息从一个地方挪到另一个地方:
- hello程序的机器指令: 磁盘->主存->处理器;
- 数据"hello worldn": 磁盘->主存->显示设备.
这些复制操作就是开销, 减缓了程序"真正"的工作. 因此系统的设计者的一个主要目标就是使得这些复制操作尽可能快地完成.
高速缓存存储器
CPU
|-----------------------------|
| 高速缓存 <-> 寄存器 <-> ALU |
| 存储器 文件 | 存储器总线
| ^ ^ | |
| | | | |
| v v 系统总线 v
| 总线接口 <-----------------> I/O桥 ----> 主存储器
|-----------------------------|
- 较大的存储设备要比较小的存储设备运行得慢, 而快速设备的造价远高于同类的低速设备;
- 系统设计者采用了更小更快的存储设备, 即高速缓存存储器(简称高速缓存);
- 它作为暂时的集结区域, 存放处理器近期可能会需要的信息;
- L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现.
1.6 存储设备形成层次结构
从上至下, 访问速度越来越慢, 容量越来越大, 每字节造价越来越便宜.
- L0: 寄存器 - CPU寄存器保存来自高速缓存存储器的字
- L1: L1高速缓存(SRAM) - L1高速缓存保存取自L2高速缓存的高速缓存行
- L2: L2高速缓存(SRAM) - L2高速缓存保存取自L3高速缓存的高速缓存行
- L3: L3高速缓存(SRAM) - L3高速缓存保存取自主存的高速缓存行
- L4: 主存(DRAM) - 主存保存取自本地硬盘的磁盘块
- L5: 本地二级存储(本地磁盘) - 本地磁盘保存取自远程网络服务器上磁盘的文件
- L6: 远程二级存储(分布式文件系统, Web服务器)
1.7 操作系统管理硬件
我们可以把操作系统看成是应用程序和硬件之间插入的一层软件, 所有应用程序对硬件的操作尝试都必须通过操作系统:
计算机系统的分层视图
|-------------------------|
| 应用程序 | \
|-------------------------| -> 软件
| 操作系统 | /
|-------------------------|
| 处理器 | 主存 | I/O设备 | ---> 硬件
|-------------------------|
操作系统有两个基本功能:
- 防止硬件被失控的应用程序滥用;
- 向应用程序提供简单一致的机制来控制复杂而又通常大相径庭的低级硬件设备.
操作系统通过几个基本的抽象概念(进程, 虚拟存储器和文件)来实现这两个功能.
操作系统提供的抽象表示
|-------------------------|
| 进程 |
| -----------------|
| | 虚拟存储器 |
| | ----------|
| | | 文件 |
| | | |
| 处理器 | 主存 | I/O设备 |
|-------------------------|
Unix和Posix
- 20世纪60年代是大型, 复杂操作系统盛行的年代, 如IBM的OS/360和Honeywell的Multics系统;
- 贝尔实验室曾经是Multics项目的最初参与者, 但是因为项目复杂和缺乏进展于1969年退出;
- 之后一组贝尔实验室的研究人员(Ken Thompson, Dennis Ritchie, Doug Mcllroy & Joe Ossanna)从1969年开始在DEC PDP-7计算机上完全用机器语言编写了一个简单得多的系统;
- 1970年Brian Kernighan命名为"Unix";
- 1973年用C语言重新编写内核;
- 1974年开始对外发布.
发布之后不同的Unix厂商加入新的, 往往不兼容的特性来使它们的程序与众不同, 也带来很多麻烦, 为了阻止这种趋势, IEEE(电气和电子工程师协会)开始努力标准化Unix的开发, 后来由 Richard Stallman命名为"Posix", 称为Posix标准.
1.7.1 进程
- 进程是操作系统对一个正在运行的程序的一种抽象;
- 在一个系统上可以同时运行多个进程, 而每个进程都好像在独占地使用硬件;
- 单/多核系统中, 一个CPU看上去都像是在并发地执行多个进程, 这是通过处理器在进程间切换来实现的, 这种交错执行的机制称为上下文切换.
- 操作系统保持跟踪进程运行所需的所有状态信息, 这种状态, 也就是上下文. 它包含许多信息, 例如PC和寄存器文件的当前值, 以及主存的内容;
- 在任一时刻, 单处理器都只能执行一个进程的代码;
- 当操作系统决定要把控制权从当前进程转移到某个新进程时, 就会进行上下文切换(保存当前上下文, 恢复新进程上下文, 然后控制权传递到新进程, 新进程从上次停止的地方开始);
进程的上下文切换(从上到下表示时间的流逝)
| 进程A | 进程B |
| | | | - 用户代码
read -> | v | |
| -- \| | - 内核代码(上下文切换)
| |\ -- |
| | | | - 用户代码
磁盘中断 -> | v |
| |/ -- | - 内核代码(上下文切换)
| /| |
read返回 -> | |-- | | - 用户代码
| v | |
1.7.2 线程
- 一个进程实际上可以由多个称为线程的执行单元组成;
- 每个线程都运行在进程的上下文中, 并共享同样的代码和全局数据;
- 一般来说线程比进程更高效;
- 当有多处理器的时候, 多线程也是一种使程序更快的方法.
1.7.3 虚拟存储器
- 虚拟存储器是一个抽象概念, 它为每个进程提供了一个假象: 每个进程都在独占地使用主存;
- 每个进程看到的是一致的存储器, 称为虚拟地址空间;
- 在Linux中, 地址空间最上面的区域是为操作系统中的代码和数据保留的;
- 地址空间的底部区域存放用户进程定义的代码和数据.
进程的虚拟地址空间(地址从下往上增大)
|---------------------------------------------|
|---------------------------------------------|
| 内核虚拟存储器(用户代码不可见的存储器) |
|---------------------------------------------|
| 用户栈(运行时创建的) |
|---------------------------------------------|
| ^ |
| | |
| v |
|---------------------------------------------|
| 共享库的存储器映射区域(printf函数) |
|---------------------------------------------|
| ^ |
| | |
|---------------------------------------------|
| 运行时堆(在运行时由malloc创建的) |
|---------------------------------------------|
| 读/写数据 | \
|---------------------------------------------| -> 从hello可执行文件加载进来的
| 只读的代码和数据 | /
|---------------------------------------------| 0x08048000(32) / 0x00400000(64)
|---------------------------------------------| 0x0
从最低的地址开始, 逐步向上介绍:
1.程序代码和数据:
- 对于所有的进程来说, 代码是从同一固定地址开始, 紧接着的是和C全局变量相对应的数据位置;
- 代码和数据区是直接按照可执行目标文件的内容初始化的.
2.堆:
- 代码和数据区后紧随着的是运行时堆;
- 代码和数据区是在进程一开始运行就被规定了大小, 与此不同, 当调用如malloc和free这样的C标准库函数时, 堆可以在运行时动态地扩展和收缩.
3.共享库:
- 大约在地址空间的中间部分是一块用来存放像C标准库和数学库这样共享库的代码和数据的区域;
- 共享库的概念非常强大, 也相当难懂.
4.栈:
- 位于用户虚拟地址空间顶部的是用户栈, 编译器用它来实现函数调用;
- 和堆一样, 用户栈在程序执行期间可以动态地扩展和收缩;
- 调用函数时栈增长, 函数返回时栈收缩.
5.内核虚拟存储器:
- 内核总是驻留在内存中, 是操作系统的一部分;
- 地址空间顶部的区域是为内核保留的, 不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数.
虚拟存储器的运作需要硬件和操作系统软件之间精密复杂的交互, 包括对处理器生成的每个地址的翻译. 其基本思想是把一个进程虚拟存储器的内容存储在磁盘上, 然后用主存作为磁盘的高速缓存.
1.7.4 文件
- 文件就是字节序列, 仅此而已;
- 文件这个简单而精致的概念拥有极其丰富的内涵, 它向应用程序提供了一个统一的视角, 来看待系统中可能含有的所有I/O设备.
1.8 系统之间利用网络通信
- 现代系统经常通过网络和其他系统连接到一起. 从一个单独的系统看, 网络可视为一个I/O设备;
- 当系统从主存将一串字节复制到网络适配器时, 数据流经过网络到达另一台机器, 而不是其他地方;
- 相似地, 系统可以读取从其他机器发送来的数据, 并把数据复制到自己的主存.
1.9 重要主题
系统是硬件和系统软件互相交织的集合体, 它们必须共同协作以达到运行应用程序的最终目的. 我们在此强调几个贯穿计算机系统所有方面的重要概念.
1.9.1 并发和并行
- 并发(concurrency)是一个通用的概念, 指一个同时具有多个活动的系统;
- 并行(parallelism)指的是用并发使一个系统运行得更快. 并行可以在计算机系统的多个抽象层次上运用.
1.线程级并发
- 传统意义上, 这种并发执行只是模拟出来的, 是通过正在执行的进程间快速切换的方式实现的, 这种配置称为单处理器系统;
- 当构建一个由单操作系统内核控制的多处理器组成的系统时, 就得到了一个多处理器系统;
- 超线程, 有时称为同时多线程(simultaneous multi-threading), 是一项允许一个CPU执行多个控制流的技术.
Intel Core i7的组织架构, 4个处理器核集成到一个芯片上
处理器封装包
|----------------------------------------------|
|----------------------------------------------|
| 核0 | ... | 核3 |
|-------------------| |-------------------|
| 寄存器 | | 寄存器 |
| | | | | |
| L1数据 L1指令 | | L1数据 L1指令 |
| 高速缓存 高速缓存 | | 高速缓存 高速缓存 |
| | | | | | | |
| L2统一的高速缓存 | | L2统一的高速缓存 |
|-------------------| |-------------------|
| | | |
| L3统一的高速缓存(所有的核共享) |
| | |
|----------------------|-----------------------|
|----------------------|-----------------------|
|
主存
2.指令级并行
- 在较低的抽象层次上, 现代处理器可以同时执行多条指令的属性称为指令级并行;
- 使用流水线(pipelining)实现指令级并行;
- 如果处理器可以达到比一个周期一条指令更快的执行速率, 就称之为超标量(superscalar)处理器, 大多数现代处理器都支持超标量操作.
3.单指令, 多数据并行
在最低层次上, 许多处理器拥有特殊的硬件, 允许一条指令产生多个可以并行执行的操作, 即SIMD并行. 例如较新的Intel和AMD处理器都具有并行地对4对单精度浮点数(C数据类型float)做加法的指令.
1.9.2 计算机系统中抽象的重要性
抽象的使用是计算机科学中最为重要的概念之一.
操作系统提供的一些抽象
|--------------------------------------|
| 虚拟机 |
| ----------------------------|
| | |
| | 进程 |
| |---------------------------|
| |指令级结构| 虚拟存储器 |
| | | ----------|
| | | | 文件 |
| | | | |
| 操作系统 | 处理器 | 主存 | I/O设备 |
|--------------------------------------|
注: 计算机系统中的一个重大主题就是提供不同层次的抽象表示, 来隐藏实际实现的复杂性.
- 在处理器里, 指令集结构提供了对实际处理器硬件的抽象;
- 文件是对I/O的抽象;
- 虚拟存储器是对程序存储器的抽象;
- 进程是对一个正在运行的程序的抽象;
- 虚拟机则是对整个计算机(包括操作系统、处理器和程序)的抽象.