C语言Hello World 第二重

本节将为你揭开程序从文本文件到输出这个过程的神秘面纱。


1. 源码文件到可执行文件

C语言Hello World 第二重_第1张图片
Paste_Image.png

通过预处理、编译、汇编、链接四个步骤,生成了a.out这个默认可执行目标文件,要想在Unix系统上执行该文件,在shell中输入

unix> ./a.out

Shell是一个命令解释器,它是与系统内核交互的窗口。
当前输入命令不是系统内置命令,Shell就会假设这是一个可执行文件,它将加载并运行这个文件。最终的效果就是Shell会打印出“Hello World”。

补充:Linux的目标文件格式有4种,分别是** a.out COFF PE ELF **

2. 硬件体系中加载、执行a.out的过程

下面在系统的整体硬件组成架构中,简单分析下“Hello World”从文本文件到屏幕上输出的过程:

C语言Hello World 第二重_第2张图片
Paste_Image.png
C语言Hello World 第二重_第3张图片
Paste_Image.png
C语言Hello World 第二重_第4张图片
Paste_Image.png
C语言Hello World 第二重_第5张图片
Paste_Image.png

3. 从软件层来剖析a.out的执行流程

当Shell加载并运行a.out的时候,并不是直接操作键盘、显示器、磁盘或者主存,而是依靠操作系统提供的服务。操作系统可以理解为应用程序和硬件之间的一层软件:

C语言Hello World 第二重_第6张图片
Paste_Image.png

操作系统提供两个基本功能:

  • 防止硬件被失控的程序滥用
  • 向应用程序提供简单一致的机制来控制复杂而通常大相径庭的低级硬件设备

操作系统通过几种抽象概念来实现这两个功能--进程虚拟存储器文件

C语言Hello World 第二重_第7张图片
Paste_Image.png

3.1 进程

当a.out被加载到内存执行的时候,操作系统会提供一种假象,好像系统只有这一个程序在执行,并且独占处理器、主存和I/O设备。这个假象是通过进程的概念来实现的。

进程是操作系统对一个正在运行的程序的一种抽象。
进程的地址空间模型如下图:

C语言Hello World 第二重_第8张图片
Paste_Image.png

实际上系统中会有多个进程,它们通过上下文(进程运行所需的所有状态信息)切换交替获取CPU执行时间片,直到执行结束,因为在单处理器系统中,任意时刻只能执行一个进程代码。

以a.out在Shell中执行为例:

C语言Hello World 第二重_第9张图片
Paste_Image.png

此时有两个并发进程 Shella.out,开始只有Shell进程在运行,即等待用户的命令行输入。当输入./a.out命令,并敲击回车,Shell发现不是内置命令,会认为是一个可执行文件。此时会调用fork()拷贝父进程,并在子进程中调用execve()系统调用,删除子进程现有的虚拟存储器段,并创建新的代码、数据、堆和栈段。新的堆和栈初始化为零。通过虚拟地址空间映射到可执行文件页的大小片段。执行a.out这个进程,完毕后,控制权交还给Shell进程。

在此详细阐述一下上面黑体字的过程:

  • fork
    当fork()被当前进程调用,内核为新进程分配一个全局唯一的PID,并对父进程做一个原样拷贝。既然是拷贝父进程,那进程如何拥有独立的进程空间呢?*原因是当fork调用中,虽然是父进程的原样拷贝,但是它将两个进程中的每个页面都标记为只读,并将两个进程中的每个区域结构都标记为私有的写时拷贝,当这两个进程中的任一一个发起写操作时,写时拷贝机制就会创建新的页面,因此,也就为每个进程保持了私有的地址空间抽象概念。 *
    同样的用一幅图来表述写时拷贝机制:
  1. 首先两个进程都映射了私有的写时拷贝对象


    C语言Hello World 第二重_第10张图片
    Paste_Image.png
  2. 比如当子进程进行写操作时,写时拷贝机制生效,新的页面创建

C语言Hello World 第二重_第11张图片
Paste_Image.png
  • execve
    上面黑体字部分可以用一幅图来表述:


    C语言Hello World 第二重_第12张图片
    Paste_Image.png

    execve()函数在当前进程中加载并运行包含在可执行目标文件a.out中的程序,用a.out程序有效替代了当前程序。加载并运行a.out需要以下几个步骤:

  1. 删除已存在的用户区域。删除当前进程虚拟地址的用户部分中的已存在的区域结构。
  2. 映射私有区域。为新程序的文本、数据、bss和栈区域创建新的区域结构。所有这些新的区域都是私有的、写时拷贝的。文本和数据区域被映射为a.out文件中的文本和数据区。bbs区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区域也是请求二进制零的,初始长度为零。上图Linux运行时存储器映像 概括了私有区域的不同映射。
  3. 映射共享区域。如果a.out程序与共享对象(或目标)链接,比如标准库C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中共享区域内。
  4. 设置程序计数器(PC)。execve做的最后一件事就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。
    下一次调度到这个进程时,它将从这个入口点开始执行。Linux讲根据需要换入代码和数据页面。

3.2 虚拟存储器

要理解虚拟存储器,需要先明白三个概念:物理地址虚拟地址地址空间

  • 物理地址
    计算机的主存被组织成一个由M个连续的自己大小的单元组成的数组,每个字节都有一个唯一的物理地址。

    C语言Hello World 第二重_第13张图片
    Paste_Image.png

    上图所示是直接从地址4开始读取4个字节(比如32位系统中的一条指令)
    上图中的物理地址空间大小是2的M次方。

  • 虚拟地址
    虚拟地址是CPU生成的,这个地址在被传送到存储器之前,会先转换成物理地址。CPU芯片上有一个 存储器管理单元(Memory Management Unit, MMU) 专门负责地址翻译。

    C语言Hello World 第二重_第14张图片
    Paste_Image.png

    虚拟地址空间的大小是2的N次方,由系统的的地址总线条数决定,或32,或64位。

那为何要使用虚拟存储器呢?
想像一下,如果每个进程直接使用物理存储器,共享主存会带来存储器管理的各种挑战,比如进程增多需要的存储器就越大,进程之间的相互读写对方的地址空间,引起程序运行的失败等等。

为了更加有效地管理存储器并且少出错,现代系统提供了对主存的抽象概念--虚拟存储器
虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互,它为每个进程提供一个大的、一致的和私有的地址空间。
虚拟存储器提供了3个重要的能力:

  • 它将主存看成是一个存储在磁盘上的地址空间的告诉缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用主存。*
    此处与cache有个相似的特点,就是程序的局部性决定了这种方式的有效性。而且主存与磁盘之间还有个交换区(swap),它用来存放被替换出来的页面。*
  • 它为每个进程提供了一致的地址空间,从而简化了存储器管理。
  • 它保护了灭个进程地址空间不被其它进程破坏。

虚拟存储器虽然对程序员是透明的,也就说程序员不必关心虚拟器的存在,它会默默地为你工作。但是我们也要明白虚拟存储器的细节与原理,因为 虚拟存储器贯穿了程序执行的流程;虚拟存储器提供了强大的功能,比如它能帮助你理解NIO;帮助你理解段错误为何会发生。

虚拟存储器很复杂,具体细节可以参考 操作系统概念深入理解计算机系统 这两本书。

3.3 文件

文件就是存放在磁盘上的实体,比如hello.c就是磁盘上的一个文本文件,a.out就是磁盘上的二进制文件。程序在执行过程中,虚拟存储器机制会根据需要从磁盘上的文件加载所需的数据。
一个进程可以打开多个文件,并且持有它们的文件句柄。

这第二重境界,相信能帮助你揭开一个程序从文本文件到屏幕上输出这个过程的神秘面纱。

参考资料:

  1. 操作系统概念第7版
  1. 深入理解计算机系统
  2. Linux内核剖析0.12

你可能感兴趣的:(C语言Hello World 第二重)