文章目录
计算机系统是硬件和系统软件互相交织的集合体。
hello程序
hello.c
#include <stdio.h>
int main()
{
printf("hello, world\n");
return 0;
}
我们通过跟踪hello
程序的生命周期来开始对系统的学习:从它被程序员创建开始,到在系统上运行,输出简单的消息,然后终止。
信息就是位 + 上下文
hello
程序的生命周期是从源程序(源文件)开始的,即程序员通过编辑器创建并保存的文本文件hello.c
。源程序实际上就是一个由0
和1
组成的位(比特、bit
)序列。8
个位被组织成一组,称为字节(byte
)。
ASCII
标准用一个唯一的单字节大小的整数值表示一个字符,如字符#
对应的数值是35
,字符i
对应的数值是105
。hello.c
的ASCII
文本表示如下图所示:
像hello.c
这样可以通过标准字符集(如ASCII
)解析的文件称为文本文件,所有其他文件都称为二进制文件。
系统中的所有信息——包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如在不同的上下文中,一串相同的字节序列可能表示一个整数、浮点数、字符串或机器指令。
通常情况下,机器表示的数值是实际数值的有限近似值。
程序被其他程序翻译成不同的格式
hello.c
是能够被人读懂的文本文件,但为了在系统上运行它,每条C
语句都必须被其他程序转化为一系列的低级机器语言指令(能被机器读懂)。然后这些指令按照一种称为可执行目标程序的格式打好包,并以二进制文件(可执行目标文件)的形式存放在磁盘。
在Unix
系统上,从源文件到目标文件的转化是由编译器驱动程序(简称编译器)完成的:
在这里,gcc
编译器读取hello.c
并生成hello
的过程可分为四个阶段:预处理、编译、汇编、链接。与此相关的预处理器、编译器、汇编器和链接器一起构成了编译系统。
预处理阶段
预处理器(cpp
)根据以字符#
开头的命令,修改原始的C
程序。比如#include <stdio.h>
命令告诉cpp
读取系统头文件stdio.h
里的内容,并把它插入程序文本中相应的位置。结果就得到了另一个以.i
作为文件扩展名的C
程序。
编译阶段
编译器(ccl
)将文本文件hello.i
翻译成文本文件hello.s
,它包含一个汇编语言程序。该程序包含函数main
的定义:
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
上述每条汇编语句都文本的形式描述了一条低级机器语言指令。不同高级语言的不同编译器针对某个确定的CPU
架构输出统一的汇编语言。
汇编阶段
汇编器(as
)将hello.s
翻译成机器语言指令,把这些指令打包成一种叫做可重定位目标程序的格式,并将结果保存在目标文件hello.o
中。hello.o
中包含的是函数main
的指令编码,如果在文本编辑器中打开该二进制文件,会看到一堆乱码。
链接阶段
hello
程序调用了printf
函数,它是每个C
编译器都提供的标准C库中的一个函数。printf
函数存在于一个名为printf.o
的单独的预编译好了的目标文件中,这个文件必须以某种方式合并到hello.o
中,否则main
函数将无法调用printf
函数。
链接器(ld
)就负责处理这种合并,结果就得到hello
文件,它是一个可执行目标文件(简称可执行文件),平时存储在磁盘上,运行时被加载到内存,由系统执行。
了解编译系统如何工作是大有益处的
优化程序性能
了解编译器将不同的C
语句转化为机器代码的方式,有助于我们写出更高效的代码。如:
- 一个
switch
语句是否总比一系列的if-else
高效? - 一个函数调用的开销有多大?
while
循环比for
循环更有效吗?- 指针引用比数组索引更有效吗?
- 为什么将循环求和的结果放到一个本地变量中,会比将其放到一个通过引用传递过来的参数中,运行起来快很多呢?
- 为什么我们只是简单地重新排列一下算术表达式中的括号就能让函数运行得更快?
理解链接时出现的错误
一些最令人困扰的程序错误都和链接有关,尤其是构建大型软件系统的时候。如:
- 链接器报告说它无法解析一个引用,这是什么意思?
- 静态变量和全局变量的区别是什么?
- 在不同的
C
文件中定义了名字相同的两个全局变量会发生什么? - 静态库和动态库的区别是什么?
- 命令行上排列库的顺序有什么影响?
- 为什么有些链接错误直到运行时才会出现?
避免安全漏洞
缓冲区溢出是造成软件安全漏洞的主要原因,学习安全编程的第一步就是理解数据和控制信息存储在程序栈上的方式(这种方式由编译器决定)及其会引起的后果。
处理器读并解释储存在内存中的指令
现在,hello.c
源程序已经被编译系统翻译成了可执行目标文件hello
,并被存放在磁盘上。要想在Unix
系统上运行该可执行文件,我们将它的文件名输入到称为shell
的应用程序中。
shell
是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。
在此例中,shell
加载并运行hello
程序,然后等待程序终止,随后再输出一个提示符,等待输入下一个命令行。
系统的硬件组成
要理解hello
程序运行时发生了什么,我们需要了解一下典型系统的硬件组织。
总线
贯穿整个系统的是一组电子管道,称作总线(bus
),它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word
)。字的字节数(即字长)是一个基本的系统参数,各系统不尽相同,如32
位系统的字长是4
字节,64
位系统的字长是8
字节。
I/O设备
I/O
(输入/输出)设备是系统与外部世界的联系通道。图中包括4
个I/O
设备:作为输入设备的鼠标、键盘,作为输出设备的显示器,即能输入又能输出、长期存储数据和程序的磁盘驱动器(简称磁盘)。hello
程序就存放在磁盘上。
每个I/O
设备都通过一个控制器或适配器与I/O
总线相连。控制器是I/O
设备本身或者系统的主印制电路板(即主板)上的芯片组,适配器则是一块插在主板插槽上的卡(可插拔),它们的功能都是在I/O
总线和I/O
设备之间传递信息。
主存
主存(即内存)是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。每条程序指令所占用的字节数不尽相同,每个数据变量所占用的字节数也不尽相同。
物理上来说,主存是由一组动态随机访问存储器(DRAM
)芯片组成的。逻辑上来说,主存是一个线性的字节数组,每个字节都有其唯一的地址(数组索引),地址从0
开始。
处理器
中央处理单元(CPU
),简称处理器,是执行存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(寄存器),称为程序计数器(PC
),PC
始终指向CPU
执行的当前指令或下一条指令。从系统通电开始,直到系统断电,处理器一直在不断地执行PC
指向的指令,再更新PC
,执行下一条指令。
处理器是按照一个简单的指令执行模型工作的,这个模型由其指令集架构(如x86_64
,armv8
)决定。
执行指令时,处理器从PC
指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC
。
这样的简单操作围绕着主存、寄存器文件、算术逻辑单元(ALU
)进行。寄存器文件是一个小的存储设备,由一些单字长的寄存器组成,每个寄存器都有唯一的名字。ALU
计算新的数据和地址。CPU
在执行一条指令时,可能会做这些简单操作: