程序是怎么运行的
大家印象里的程序员"大佬"是什么样子的,是在代码堆中日日夜夜的奋战,还是写出一个个优雅的程序,而这些无疑是我们所向往的,但是在成为"大佬"之前,有一些更为现实的问题摆在我们面前,计算机里的程序到底是什么?简单来说,程序就是一组有序的指令集合,它告诉计算机的硬件——尤其是中央处理器(CPU)——在什么时候、做什么操作。这些指令以二进制形式存储在硬盘、固态硬盘等存储设备上,当我们需要运行时,操作系统会把它从磁盘加载到内存中,然后让 CPU 逐条读取并执行。
我们以一个最简单的C语言程序为例:
#include <stdio.h>
int main()
{
printf("hello, world\n");
return 0;
}
为啥我们输入了这段代码屏幕就会出现一个"Hello world"
编译链接的阶段
首先我们要知道,计算机看不懂我们的自然语言,所以需要有一个中间人把我们的程序翻译成计算机可以看懂的语言,这个中间人就叫编译器,
编译器是怎么完成从源文件到目标文件的转换呢?
如图:
预处理阶段根据以字符#开头的命令,对原始的C程序进行文本层面的修改。预处理器的核心工作包括以下几项:
- 头文件展开:处理
#include指令,将指定的头文件内容完整地插入到当前源文件中。例如#include <stdio.h>会让预处理器找到系统头文件stdio.h,将其全部内容复制到#include所在位置。 - 宏替换:处理
#define指令,将程序中出现的宏名替换为定义的文本。例如#define PI 3.14,后续所有PI都会被替换为3.14。 - 条件编译:根据
#if、#ifdef、#ifndef、#else、#elif、#endif等指令,决定哪些代码块参与后续编译,哪些被跳过。。 - 注释删除:移除源文件中的所有注释(
//单行注释和/* */多行注释),用空格替换,使编译器只看到干净的代码。 - 添加行号和文件名标识:预处理器还会在展开后的文本中插入行号和文件名信息,方便编译器在后续阶段报告错误时准确定位到原始源文件的位置。
预处理后,源文件中的#include、#define等预处理指令全部消失,生成一个纯粹的、可供编译器直接解析的C语言源文件(通常以.i为扩展名)。
编译阶段将.i文件翻译成了.s的汇编文件。
汇编阶段将编译生成的.s汇编文件进一步翻译成机器指令,生成目标文件(通常以.o或.obj为扩展名).
链接阶段是编译过程的最后一步,它将汇编阶段生成的多个目标文件(.o或.obj)以及所需的库文件组合在一起,生成一个完整的可执行文件。
这些完成之后,我们的程序就可以正式开始运行了,回到我们刚才的疑问:
我们的"hello World"是怎么显示在屏幕上的?
计算机系统的硬件组成

这是一个典型的计算机系统的硬件组成,下面我们来逐一介绍一下图中的硬件设备:
1.总线
贯穿整个系统的是一组电子管道,称作总线,它携带信息字节并负责在各个部件间传递。通常总线被设计成传送定长的字节块,也就是字(word)。字中的字节数(即字长)是一个基本的系统参数,各个系统都不尽相同。现在的大多数机器字长要么是4个字节(32位),要么是8个字节(64位)。
2.I/O设备
I/O(输入/输出)设备是系统与外部世界的联系通道。每个I/O设备都通过一个控制器或适配器与I/O总线相连。区别主要在于它们的封装方式。控制器是I/O设备本身或者系统的主印制电路板(称为主板)上的芯片组。而适配器则是一块插在主板插槽上的卡。
3.主存
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。从物理上来说,主存是由一组动态随机存取存储器(DRAM)芯片组成的。从逻辑上来说,存储器是一个线性的字节数组,每个字节都有其唯一的地址(数组索引),这些地址是从零开始的。一般来说,组成程序的每条机器指令都由不同数量的字节构成。与C程序变量相对应的数据项的大小是根据类型变化的。比如,运行在Linux的x86-64机器上,short类型的数据需要2个字节,int和float类型需要4个字节,而long或double类型需要8个字节。
4.处理器
中央处理单元(CPU ),简称处理器,是解释(或执行)存储在主存中指令的引擎。处理器的核心是一个大小为一个字的存储设备(或寄存器),称为程序计数器(PC)。在任何时刻,PC都指向主存中的某条机器语言指令(即含有该条指令的地址)。
从系统通电开始,直到系统断电,处理器一直在不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。在上面的模型中,指令按照严格的顺序执行,而执行一条指令包括执行一系列的步骤。处理器从程序计数器指向的内存处读取指令,解释指令中的位,执行该指令指示的简单操作,然后更新PC,使其指向下一条指令,而这条指令并不一定和在内存中刚刚执行的指令相邻。
运行程序
当我们输入./hello命令时,shell程序会读取我们的输入到寄存器中,然后再把他读取到主存中去:

当我们输入完成后,shell会经历一系列的指令,将我们的目标文件的代码和数据放到主存中去:
一旦目标文件hello中的代码和数据被加载到主存,处理器就开始执行hello程序的main程序中的机器语言指令。这些指令将hello,world/n字符串中的字节从主存复制到寄存器文件,再从寄存器文件中复制到显示设备,最终显示在屏幕上。

通过这个小例子我们可以看出来,计算机运行程序的本质是将一个程序在系统中挪来挪去,很明显这是一笔巨大的开销,为了提高计算机的性能人们又引入了cache(高速缓存)

通过在高速缓存中存放可能经常访问的数据,大部分内存操作都可以在高速缓存中完成
(注意:cache可以通过一些算法来记录可能经常访问的数据如LRU算法等)
所以缓存之间的关系如图所示:

操作系统
通过刚刚这个例子我们发现,我们的应用程序并没有直接和计算机硬件进行交互,是通过操作系统来进行的,如图:

操作系统有两个基本功能:1.防止硬件被失控的应用程序滥用;2.向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。
进程
进程是操作系统对一个正在运行的程序的一种抽象。在一个系统上可以同时运行多个进程,而每个进程都好像在独占地使用硬件。而并发运行是说一个进程的指令和另一个进程的指令是交错执行的。在大多数系统中,需要运行的进程数是多于可以运行它们的CPU个数的。这个通过处理器在进程间切换 来实现的。操作系统实现这种交错执行的机制称为上下文切换。

线程
一个进程实际上可以由多个称为线程的执行单元组成,每个线程都运行在进程的上下文中,并共享同样的代码和全局数据。由于网络服务器中对并行处理的需求,线程成为越来越重要的编程模型,因为多线程之间比多进程之间更容易共享数据,也因为线程一般来说比进程更高效。
虚拟内存
虚拟内存是一个概念,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。每个进程看到的内存都是一致的,称为虚拟地址空间。
每个进程看到的虚拟地址空间由大量准确定义的区构成,每个区都有专门的功能。
程序代码和数据:对所有的进程来说,代码是从同一固定地址开始,紧接着的是和C全局变量相对应的数据位置。代码和数据区是直接按照可执行目标文件的内容初始化的。
堆:代码和数据区在进程一开始运行时就被指定了大小,当调用像malloc和free这样的C标准库函数时,堆可以在运行时动态地扩展和收缩。
共享库:大约在地址空间的中间部分是一块用来存放像C标准库和数据库这样的共享库的代码和数据的区域。
栈:编译器用它来实现函数调用。和堆一样,用户栈在程序执行期间可以动态地扩展和收缩。每当我们调用一个函数时,栈就会增长;从一个函数返回时,栈就会收缩。
内核虚拟内存:地址空间顶部的区域是为内核保留的。不允许应用程序读写这个区域的内容或者直接调用内核代码定义的函数。相反,它们必须调用内核来执行这些操作。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)