【深入理解计算机系统】程序是如何运行的

程序是如何运行的

现代计算机存储和处理的信息以二值信号表示,程序对于计算机而言就是一串二进制数据流,以流水线的方式进入CPU进行运算。主要在;CPU与内存之间进行数据传递。本文将从程序源码的结构与表现形式开始,到编译生成可执行文件,再到执行文件的加载,最终到执行文件的运行整个过程进行梳理。

1 程序的结构与表现形式

大多数计算使用8位的块,即字节(byte),作为最小的可寻址的内存单元。程序对象,即程序数据、指令和控制信息的字节集合,编译器和系统运行时将存储空间划分成更可管理的单元来存储程序对象。

计算机执行机器代码,用字节序列编码低级的操作,包括处理数据、管理内存、读写存储设备上的数据、以及利用网络通信。程序源码会经过编译器生成机器代码,编译器基于编程语言的规则、目标机器的指令集合和操作系统遵循的惯例,经过一系列的阶段生成机器代码。汇编代码是机器代码的文本表示,给出程序中的每一条指令。

计算机系统使用了多种不同形式的抽象,利用抽象模型来隐藏实现的细节。对于机器级编程来说,两个重要的抽象:
1. 指令集架构(Instruction Set Architecture, ISA) 定义机器级别格式和行为,处理器状态、指令的格式,以及每条指令对状态的影响。
2. 虚拟内存地址,程序使用的内存地址是虚拟地址,提供内存模型看上去是一个非常打的字节数组。实际上又许多个硬件存储器和操作系统软件组合起来。

以C语言为例,编写程序mstore.c

long mult2(long, long);

void multistore(long x, long y, long *dest) {
    long t = mult2(x, y);
    *dest = t;
} 

经过gcc编译器,产生一个汇编文件mstore.s

multstore:
    pushq %rbx
    movq %rdx, %rbx
    call mult2
    movq %rax, (%rbx)
    popq %rbx
    ret

上面代码中每行对于一条机器指令,比如, pushq指令应该将%rbx的内容压入程序栈中。

再将改mstore.c编译并汇编成目标代码文件mstore.o,该二进制文件中,又一段14个字节的序列,它的十六进制表示为:

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

为了弄清这些14个字节表示的含义,可以通过objdump 反汇编 该mstore.o 文件
【深入理解计算机系统】程序是如何运行的_第1张图片

可以看到,这14个字节分成若干组,左边是一条指令,右边是等价的汇编语言。

程序中包含过程、控制

过程
是软件中一种重要的抽象。它提供了一种封装代码的方式,用一组制定的参数和一个可选的返回值实现了某一功能。然后,可以再程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明计算的是哪些值,过程会对程序状态产生什么样的影响。不同编程语言中,过程的形式多样;函数(funciton)、方法(method)、子例程(subroutine)、处理函数(handler)等等。

要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括一下一个或多个机制:

  1. 传递控制。再进入过程Q的时候,程序计数器必须被设置位Q的代码的起始地址,然后在返回时,要把程序计数器设置位P中调用Q后面那条指令的地址。
  2. 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值
  3. 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。

x86-64的过程实现包括一组特殊的指令和一些对机器资源(寄存器和程序内存)使用的约定规则。

控制
程序中的控制逻辑,例如条件语句if else, 循环for do-while等。机器级指令的执行,有两种方式实现条件控制,一种将控制条件进行传递,一种是将不同条件计算结构进行传递。后一种方式在现代计算机中能提高程序运行的效率,代码中的指令都是按照在程序中出现的次序,顺序执行的,使用jump指令可以改变一组机器代码指令的执行顺序,从而实现条件操作。

为了实现条件控制,CPU中维护了一组单个位的条件码(condition code) 寄存器,它们描述了最近的算数或逻辑操作的属性。可以通过检测这些寄存器来执行条件分支指令,通常条件码有,CF:进位标志;ZF:零标志。SF: 符号标志;OF:溢出标志。

运行时调用栈
【深入理解计算机系统】程序是如何运行的_第2张图片
大多数语言过程调用机制采用栈数据结构提供的后进先出的内存管理原则。过程P调用过程Q的过程,如果上图所示。

2 程序代码的编译过程

这里写图片描述

预处理阶段,主要是修改原始程序,例如将#include 命令告诉预处理读取系统stdio.h的文件,并将它直接插入到程序文本中。结果得到的另一个C程序,以.i作为扩展名;
编译阶段,编译器ccl将文本hello.i翻译成文本hello.s,它包含一个汇编语言程序;
汇编阶段,汇编器将.s文件编译成一个二进制的文件,把这些指令打包成一种叫做可重定位的目标程序的格式,并将结果保存在目标文件.o文件中。
链接阶段,将各种代码和数据片段手机并组合并成可以执行的目标文件,简称可执行文件,可以被加载到内存中,由系统执行。

本节主要讨论链接阶段。链接可以执行与编译时,即将源代码翻译成机器代码时;可以执行与加载时,即程序被加载器加载到内存时;可以执行与运行时,也就是由应用程序来执行。

链接器是的分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织成一个巨大的源文件,而时可以把它分解为更小、更好管理的模块。理解链接的工作原理可以帮助我们避免一些危险的编程错误、理解语言的作用域规则、理解一些重要概念(加载、运行程序、虚拟内存、分页、内存映射)、有助于理解共享库。

为了构造可执行的文件,链接器必须完成两个主要任务:

  1. 符号解析(symbol resolution)。 目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量。符号解析的目的时将每个符号引用正好和一个符号定义关联起来;
  2. 重定位(relocaiotn)。编译器和汇编器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得他们指向内存这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。

目标文件分为三种:

  1. 可重定位目标文件,包含二进制代码和数据,其形式在编译时可以与其他可重定位目标文件合并起来,创建一个可执行目标文件;
  2. 可执行目标文件,包含二进制代码和数据,其形式可以被直接复制到内存并执行;
  3. 共享目标文件,一种特殊类型的可重定位目标文件,可以在加载或者运行时被动态地加载进内存并链接。

3 执行文件

可执行的目标文件,通过加载器,加载到内存,共CPU调用运行。

进程是执行中程序的一个具体实例,程序总是运行在某个进程的上下文中。

进程提供了给应用程序的关键抽象:

  1. 一个独立的逻辑控制流,程序计数器PC值序列叫做逻辑控制流,每个PC值对应可执行目标文件中的指令,或者是包含在运行是动态链接到程序的共享对象中的指令。
  2. 一个私有的地址空间,进程位每个程序提供一种假象,好像它独占地使用系统地址空间。例如,在一台64位地址的机器上,地址空间是264 个可能地址的集合。进程为每个程序提供它自己的私有地址空间。一般而言,其他进程是不能访问该进程的地址空间所关联的内存字节。
    每个私有地址空间有相同通用的结构,如下图所示
    【深入理解计算机系统】程序是如何运行的_第3张图片
    地址空间底部是保留给用户程序的,包括通常的代码、数据、堆和栈段。代码段总是从地址0x00400000开始。地址空间顶部保留给内核(操作系统常驻内存的部分)。地址空间的这部分包含内核在代表进程执行指令时使用的代码、数据和栈。

为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器的一个模式为来提供这种功能,该寄存器描述了进程当前享有的特权。

进程运行有两种模式:
1. 内核模式(超级用户模式)
2. 用户模式

当设置了模式位时,进程就运行在内核模式中(超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统的任何内存位置。

没有设置模式位时,进程运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位、或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。

操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文明。上下文就是内核重新启动一个被抢占的进程所需状态。进程切换包含三个操作

  1. 保存当前进程的上下文
  2. 恢复某个先前被抢占的进程被保存的上下文
  3. 将控制传递给这个新恢复的进程

【深入理解计算机系统】程序是如何运行的_第4张图片

当进程A开始读取磁盘文件时,会通过执行系统调用read陷入到内核。内核中的陷进处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘终端处理器。

每个执行的程序,即系统中的进程,进程总可以处于下面三种状态:

  1. 运行,进程要么在CPU上执行,要么在等待被执行且最终会被内核调度;
  2. 停止,进程的执行被挂起,且不会被调度,当收到SIGSTOP、SIGTSTP、SIGTTIN或者SIGTTOU信号时,进制就会停止,并且 保持停止知道它收到一个SIGCONT信号,在这个时刻,进程再一次开始运行。
  3. 终止,进程永远地停止了。进程会因为三种原因终止:1)收到进程终止的信号,2)从主程序返回;3)调用exit函数。

加载并运行程序

当使用execve函数在当前进程的上下文中加载并运行一个新程序。
(fork是在父进程下,创建一个新的上下文运行子进程)

#include 
int execve(const char *filename, const char *argv[], const char *envp[]);

当使用execve加载filename之后,启动代码设置栈,并将控制传递给新程序的主函数。
用户栈的典型组织结构:

【深入理解计算机系统】程序是如何运行的_第5张图片

信号
除了操作系统利用异常来支持进程上下文切换的异常控制流形式,另外一种更高层次的软件形式的异常,成为Linux信号,它运行进程和内核中断其他进程。

一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对于用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,当进程在前台运行时,你键入Ctrl+C,那么内核就会发送一个SIGINT信号强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD信号给父进程。

传送一个信号到目的进程是由两个不同步骤组成的

  1. 发送信号,内核通过更新目的进程上下文种的某个状态,发送一个信号给目的进程。发送信号可以由如下两种原因:1)内核检测到一个系统事件,比如除零错误。2)一个进程调用kill,显示要求内核发送一个信号给目的进程。一个进程可以发送信号给它自己
  2. 接受信号,当目的进程被内核强波以某种方式对信号的发送做出反应时,它就接收了信号。进程可以忽略这个信号,终止或者通过之心一个称为信号处理程序的用户曾函数捕获这个信号。

一个发出而没有被接收的信号,叫做处理信号,在任何时刻,一种类型至多只会由一个待处理信号。重复发送在等待的信号,将会被内核抛弃。
linux 提供两种阻塞机制,隐式和显式

  1. 隐式,即内核默认会阻塞当前处理程序接受到的待处理信号,正好与该待处理信号类型相同的信号已经被该处理程序所捕获。
  2. 显示阻塞机制,应用程序可以使用singprocmask函数和它的辅助函数,明确地阻塞和解除阻塞选定的信号。

通过本文我们阐述了,程序在计算机种运行的一些基本概念、逻辑流、内存等。然后,系统种程序往往不是独立运行的,不仅仅是包含最小限度的输入和输出。在现实世界中,应用程序利用操作系统提供的服务来与I/O设备以及其他程序通信。

下一篇,详细探讨Unix操作系统提供的基本I/O服务,以及如何用这些服务来构造应用程序,例如Web客户端和服务器。

你可能感兴趣的:(软件系统架构与开发环境)