计算机系统漫游
#include <stdio.h>
int main() {
printf("hello world!\n");
return 0;
}
信息是位 + 上下文
hello程序文件名为hello.c,源程序实际上是由0,1组成的位(又称比特)序列,8个位被组织成一组,称为字节。每个字节其实就是程序中的某些文本字符。
像hello.c这种文件只由ASCII字符构成的文件称为文本文件,所有其他程序都称为二进制文件。
hello.c文件的表示方法说明了一种思想:系统中所有的信息——包括磁盘文件、内存中的程序、内存中内存中存放用户数据以及网上传输的数据,都是由一串比特表示的。区分不同数据对象的唯一方法就是我们读到这些数据对象时的上下文(在不同的上下文中,一个比特序列可能表示一个整数、浮点数、字符串或机器指令)
C从一开始就是作为一种用于Unix系统的程序语言开发出来的,大部分Unix的内核(操作系统的核心部分),以及所有支撑工具和函数库都是用C语言编写的,Unix几乎全部是用C语言编写的,它可以很方便地移植到新的机器上
程序被其他程序翻译成不同格式
为了能在系统上运行hello.c程序,每条C语句都必须被其他程序转换为一系列低级机器语言指令,然后这些指令按照一种称为可执行目标程序的格式打好包,并且以二进制磁盘文件的形式存放起来。目标程序也成为可执行目标文件
在Unix系统上,从源文件到目标文件的转化是由编译器驱动程序完成的
linux> gcc -o hello hello.c
GCC编译器驱动程序读取源程序文件hello.c,并且把它翻译成一个可执行的目标文件hello。
翻译阶段可分为四个阶段完成:
- 预处理器
- 编译器
- 汇编器
- 链接器
-
预处理阶段
预处理(cpp)根据以字节#开头的命令,修改源氏的C程序。比如hello.c第一行的#include<stdio.h>命令告诉预处理器读取系统头文件stdio.h的内容,并且把它直接插入程序文本中。结果就得到了另一个C程序,通常是以.i作为文件扩展名
在预编译的过程中,主要处理源代码中的预处理指令,引入头文件,去除注释,处理所有的条件编译指令,宏的替换,添加行号,保留所有的编译器指令。
这就是预编译后得到的结果
所以当进行预编译以后的文件中将不再存在宏,所有的宏都已经被替代。当我们我们想要判断宏是否正确或者头文件包含是否正确的时候,我们也可以通过预编译来查看。
-
编译阶段
编译器(ccl)将文本文件hello.i分一成文本文件hello.s,它包含一个汇编语言程序,该程序包含函数main的定义
1. main:
2. subq $8,%rsp
3. movl $.LC0, %edi
4. call puts
5. movl $0, %eax
6. addq $8, %rsp
7. ret
定义中2-7行的每条语句都以一种文本格式描述了一条低级机器语言指令。汇编语言是很有用的,它为不同高级语言的不同编译器提供了通用的输出语言
从结果中我们可以看出,编译阶段得到的文件中的内容是汇编的代码
-
汇编阶段
接下来汇编器(as)将hello.c翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o中,hello.o是一个二进制文件,它包含的17个字节是函数main的指令编码
-
链接阶段
因为hello程序调用了printf函数,它是每个C编译器都提供的标准库中的一个函数。printf函数存在一个名为printf.o的单独的预编译好了的目标文件中,而这个文件必须以某种方式合并到我们的hello.o程序中。链接器(ld)就负责处理这种合并,结果就得到hello文件,它是一个可执行目标文件(可执行文件),可以被加载到内存中,由系统执行
链接,其实就是将二进制文件链接称为一个可执行的指令。
链接所完成的任务是合并段表,然后把符号表合并并且对符号表进行重定位。所谓合并段表,源代码编译生成的a.out会包含很多段,数据段文本段bss段等等,这些段是合并出来的,在编译过程中划分出来出来的,不同的数据会对应到不同的段中,在.o文件中其实已经发生了分段。
符号表合并和重定位说的是最后只生成了一个符号表,这个符号表是由前面汇编形成的多个符号表进行合并。在这里不在同一个符号表的符号,要对他们进行重定位。
补充知识点
-
什么是可重定位程序
编译器编译后产生的目标文件是可重定位的程序模块,并不能直接运行,链接就是把目标文件和其他分别进行编译生成的程序模块(如果有的话)及系统提供的标准库函数连接在一起,生成可运行的可执行文件的过程。
重定位是链接器在完成符号解析后(知道了各个输入模块的代码段和数据段的大小)的一个步骤,其作用顾名思义就是重新定位,确定比如指令,全局变量等在运行时的存储器地址。
-
什么是可执行目标文件
简单的说可执行目标文件是能够直接双击运行的
可执行文件是指计算机的操作系统能够解释并执行的二进制代码文件。最初的可执行文件包括代码段、数据段、堆栈段和扩展段等。代码段存放了计算机的执行指令,即CPU要进行的操作指令,数据段存放了CPU要用到的数据,堆栈段则存放了与寄存器有关的信息等等。现在你觉得许多文件都能够被计算机执行,是因为操作系统已经做得比较人性化,例如你双击一个文本文件(*.txt),该文件即被打开,这是因为操作系统调用了Notepad.exe这个可执行文件打开了这个文本文件。实际上,被执行的文件是Notepad.exe,而文本文件只是Notepad.exe的执行目标。
系统的硬件组成
-
总线
贯穿整个系统的是一组电子管道,称为总线 ,它携带信息字节,并负责在各个部件之间传递,通常总线被设计成传送定长的字节块,就是字(word),字中的字节数(字长)是一个基本的系统参数,现在大多数机器字长有4个字节(32位)或8字节(64位)
-
I/O设备
每个I/O设备都通过一个控制器或者适配器和I/O总线相连,控制器是I/O设备本身或者系统的主印制电路板(主板)上的芯片组,而适配器则是一块插在主板插槽上的卡,无论如何,它们的功能都是为了在I/O总线和I/O设备之间传输数据
-
主存
主存是一个临时存储设备,在处理器执行程序的时候,用来存放程序和程序处理的数据,从物理上说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上说,存储器是一个线型的字节数组,每个字节都有其唯一的索引(数组索引),这些地址是从零开始的,一般来说,组成程序的每条机器指令都由不同数量的字节构成,比如在运行Linux的x86-64机器上,short类型的数据需要2个字节,int和float类型需要4个字节,而long和double需要8个字节
-
处理器
中央处理单元(CPU),简称处理器,是解释(或运行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC都指向主存中某条机器语言指令(即含有该条指令的地址)
处理器看上去是一个非常简单的指令执行模型来操作的,这个模型是由指令集架构决定的,在这个模型中,指令按照严格的顺序执行,而执行一条指令包含一系列的步骤,处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻
这样简单的操作并不多,它们围绕着主存、寄存器文件和算数/逻辑单元(ALU)进行。寄存器文件是一个小的存储设备,由一些单个字长的寄存器组成,每个寄存器都有唯一的名字, ALU计算新的数据和地址值。下面是一些简单操作的例子,CPU在指令的要求下可能会执行这些操作
- 加载:从主存复制一个字节或者一个字到寄存器,以覆盖寄存器原来内容
- 存储:从寄存器赋值一个字节或者一个字到主存的某个位置,以覆盖这个位置上原来的内容
- 操作:把两个寄存器的内容复制到ALU,ALU对这两个字做算术运算,并将结果存放到一个寄存器中,以覆盖该寄存器中原来的内容
- 跳转:从之灵本身中抽取一个字,并将这个字复制到程序计数器(PC)中,以覆盖PC中原来的值
运行hello程序
初始时,shell程序执行他的指令,等待我们输入一个命令。当我们在键盘上输入字符串"./hello"后,shell程序将字符逐一读入寄存器,再把它存放在内存中
当我们在键盘上敲击回车的时候,shell程序就知道我们已经结束了命令的输入。然后shell执行一系列指令来加载可执行的hello文件,这些指令将hello目标文件中的代码和数据从磁盘赋值到主存。数据包括最终将会被输出的字符串"hello,world\n"
一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令,这些指令将"hello,world\n"字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上
高速缓存
上面的例子揭示了一个重要问题,系统化了大把时间把信息从一个地方挪动到另一个地方,hello程序从最初的在磁盘上,当程序加载时,它们被复制到主存,当处理器运行程序的时候,指令又从主存复制到了处理器上,这些复制就是开销,减慢了程序真正的工作
根据机械原理,较大的存储设备要比较小的存储设备运行得慢,而快速设备的造价远高于同类的低速设备,类似的,一个典型的寄存器文件只存储几百字节的信息,而主存中可存放几十亿字节,然而,处理器从寄存器文件中读数据比从主存中读取数据快乐至少100倍
高速缓存存储器:作为暂时的集结区域,存放处理器近期可能会需要的信息
位于处理器芯片上的L1高速缓存的容量可以达到数万字节,访问速度几乎和访问寄存器文件一样快,一个容量为数十万的到数百万的L2高速缓存通过一条特殊的总线连接到处理器,进程访问L2高速缓存的时间要比访问L1高速缓存的时间长5倍,但是这仍比访问主存的时间快5~10倍,L1和L2高速缓存是用一种叫做静态随机访问存储器(SRAM)的硬件技术实现的,这些高速缓存的速度快是因为利用了高速缓存的局部性原理,即程序具有访问局部区域里的代码和数据的趋势,通过让高速缓存里存放尽可能经常访问的数据,大部分的内存操作都能在快速的高速缓存中完成
存储设备形成层次结构
在处理器和一个较大较慢的设备(例如主存)之间插入一个更小更快的存储设备(例如高速缓存)的想法已经成为了一个普遍的观念。
在这个层次结构中,从上至下,设备的访问速度越来越慢,但是容量越来越大,并且每字节的造价也越来越便宜,寄存器文件在层次结构中位于最顶部,也就是第0级或者记为L0
存储器层次结构的主要思想是上一层的存储器作为第一层存储器的高级缓存,比如寄存器文件就是L1的高速缓存,L1就是L2的高速缓存以此类推
操作系统管理硬件
当shell加载和运行hello的时候,以及hello程序输出自己的信息的时候,shell和hello程序都没有直接访问键盘、显示器、磁盘或者主存,取而代之的是,它们依靠操作系统提供的服务,我们可以把操作系统看成是应用程序和硬件之间插入的一层软件,所有应用程序对硬件的操作尝试,都必须经过操作系统
操作系统的两个基本功能
- 防止硬件被失控的应用程序滥用
- 向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能,文件是对I/O设备的抽象表示,虚拟内存是对主存和磁盘I/O的抽象表示,进程则是对处理器、主存和I/O设备的抽象表示
进程
像hello这样的程序在现代系统上运行的时候,操作系统会提供一种假象,就好像系统上只有这个程序在运行。程序看上去是独占地使用处理器、主存和I/O设备,处理器看上去就像在不间断地处理一条接一条地执行程序中的指令,即改程序的代码和数据是系统内存中唯一的对象
进程是操作系统对一个正在进行的程序的一种抽象,在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件,而并发运行,则是说一个进程的指令和另一个进程的指令是相互交替执行的,在大多数系统中,需要运行的进程数量是多于可以运行它们的CPU个数的,传统系统在一个时刻只能执行一个程序,而现今的多核处理器同时可以执行多个程序,无论是在单核还是多核的系统中,一个CPU看上去都像是在并发地执行多个经常,这是通过处理器在进程间切换来实现的,操作系统实现这种交错执行的机制称为上下文切换
操作系统保持跟踪进程运行所需的所有状态信息,这种状态就是上下文,包括许多信息,比如PC和寄存器文件的当前值,以及主存的内容。在任何一个时刻,单处理器系统都只能执行一个进程的代码,当操作系统决定要把控制权从当前进程转移到某个新进程的时候,就会进行上下文切换,即保存当前的进程上下文,恢复新进程的上下文,然后将控制权传递到新进程,新进程就会从它上次停止的地方开始
举一个shell和hello两个进程并发的例子,最开始,只有shell进程在运行,即等待命令行上的输入,当我们让他运行hello程序时,shell通过调用一个专门的函数,即系统调用
系统调用会将控制权传递给操作系统,操作系统保存shell进程的上下文,创建一个新的hello进程及其上下文,然后将控制权传递给hello进程,hello进程终止后,操作系统恢复shell进程的上下文,并将控制权传回给它,shell进程会继续等待下一个命令行输入
从一个进程到另一个进程的转换是由操作系统内核管理的。内核是操作系统代码常驻主存的部分,当应用程序需要操作系统的某些操作的时候,比如读写文件,它就执行一条特殊的系统调用(system call)命令,将控制权传递给内核,然后内核执行被请求的操作并返回应用程序,内核不是一个独立的进程。相反它是系统管理全部进程所用代码和数据结构的集合
线程
一个进程实际可以由多个称为线程的执行单元构成,每个线程运行在进程的上下文中,并共享同样的代码和全局数据,县城成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说都比进程更高效,当有多处理器可用的时候,多线程也是一种使得程序可以运行得更快的方法
虚拟内存
虚拟内存是一个抽象概念,它为每个进程提供了一个假象,即每个进程都在独占的使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间
在Linux系统中,地址空间最上面的区域是保留给操作系统中代码和数据的,这对所有进程来说都是一样,地址空间底部区域存放用户进程定义的代码和数据,上图中地址是从下向上增大的
- 程序代码和数据
对所有进程来说,代码是从同一固定的地址开始,紧接着的适合C区安居变量相对应的数据位置,代码和数据区是直接是直接按照可执行目标文件的内容初始化的 - 堆
代码和数据区在进程一开始运行时就被指定了大小,与此不同,当调用像malloc和free这样的C标准函数的时候,堆可以在运动时动态的拓展和收缩 - 共享库
大约在地址空间的中间部分是一块用来存放C标准库和数学库这样的共享库的代码和数据的区域 - 栈
位于用户虚拟地址空间顶部的是用户栈,编译器用它来实现函数调用,和堆一样,栈在程序执行期间可以动态扩展和收缩,当调用一个函数的时候栈就会被扩展,一个函数返回的时候栈就会收缩 - 内核虚拟内存
地址空间顶部是为内核保留的,不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数,它们必须通过内核来执行这些操作,虚拟内存的运作基本思想是把一个进程虚拟内存的内容存储在磁盘上,然后用主存作为磁盘的高速缓存
并发和并行
我们用的术语并发是一个通用的概念,指一个同时具有多个活动的系统;而术语并行指的是用并发来使一个系统运行得更快,并行可以在计算机系统的多个抽象层次上运用
线程级并发
传统意义上线程级别上的并发只是模拟出来的,是通过是一台计算机在它正在执行的进程间快速切换来实现的,就好像一个杂耍艺人保持多颗杂技球在空中飞舞一样
指令级并行
在较低的抽象层次上,现代处理器可以同时执行多条指令的属性叫做指令级并行,如果处理器可以达到比一个周期一个指令更快的执行速率就称之为超标量处理器