现代计算机存储和处理的信息以二值信号表示,程序对于计算机而言就是一串二进制数据流,以流水线的方式进入CPU进行运算。主要在;CPU与内存之间进行数据传递。本文将从程序源码的结构与表现形式开始,到编译生成可执行文件,再到执行文件的加载,最终到执行文件的运行整个过程进行梳理。
大多数计算使用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 文件
可以看到,这14个字节分成若干组,左边是一条指令,右边是等价的汇编语言。
程序中包含过程、控制
过程
是软件中一种重要的抽象。它提供了一种封装代码的方式,用一组制定的参数和一个可选的返回值实现了某一功能。然后,可以再程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明计算的是哪些值,过程会对程序状态产生什么样的影响。不同编程语言中,过程的形式多样;函数(funciton)、方法(method)、子例程(subroutine)、处理函数(handler)等等。
要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括一下一个或多个机制:
x86-64的过程实现包括一组特殊的指令和一些对机器资源(寄存器和程序内存)使用的约定规则。
控制
程序中的控制逻辑,例如条件语句if else, 循环for do-while等。机器级指令的执行,有两种方式实现条件控制,一种将控制条件进行传递,一种是将不同条件计算结构进行传递。后一种方式在现代计算机中能提高程序运行的效率,代码中的指令都是按照在程序中出现的次序,顺序执行的,使用jump指令可以改变一组机器代码指令的执行顺序,从而实现条件操作。
为了实现条件控制,CPU中维护了一组单个位的条件码(condition code)
寄存器,它们描述了最近的算数或逻辑操作的属性。可以通过检测这些寄存器来执行条件分支指令,通常条件码有,CF:进位标志;ZF:零标志。SF: 符号标志;OF:溢出标志。
运行时调用栈
大多数语言过程调用机制采用栈数据结构提供的后进先出的内存管理原则。过程P调用过程Q的过程,如果上图所示。
预处理阶段,主要是修改原始程序,例如将#include
命令告诉预处理读取系统stdio.h的文件,并将它直接插入到程序文本中。结果得到的另一个C程序,以.i作为扩展名;
编译阶段,编译器ccl将文本hello.i翻译成文本hello.s,它包含一个汇编语言程序;
汇编阶段,汇编器将.s文件编译成一个二进制的文件,把这些指令打包成一种叫做可重定位的目标程序的格式,并将结果保存在目标文件.o文件中。
链接阶段,将各种代码和数据片段手机并组合并成可以执行的目标文件,简称可执行文件,可以被加载到内存中,由系统执行。
本节主要讨论链接阶段。链接可以执行与编译时,即将源代码翻译成机器代码时;可以执行与加载时,即程序被加载器加载到内存时;可以执行与运行时,也就是由应用程序来执行。
链接器是的分离编译(separate compilation)成为可能。我们不用将一个大型的应用程序组织成一个巨大的源文件,而时可以把它分解为更小、更好管理的模块。理解链接的工作原理可以帮助我们避免一些危险的编程错误、理解语言的作用域规则、理解一些重要概念(加载、运行程序、虚拟内存、分页、内存映射)、有助于理解共享库。
为了构造可执行的文件,链接器必须完成两个主要任务:
目标文件分为三种:
可执行的目标文件,通过加载器,加载到内存,共CPU调用运行。
进程是执行中程序的一个具体实例,程序总是运行在某个进程的上下文中。
进程提供了给应用程序的关键抽象:
为了使操作系统内核提供一个无懈可击的进程抽象,处理器必须提供一种机制,限制一个应用可以执行的指令以及它可以访问的地址空间范围。处理器通常是用某个控制寄存器的一个模式为来提供这种功能,该寄存器描述了进程当前享有的特权。
进程运行有两种模式:
1. 内核模式(超级用户模式)
2. 用户模式
当设置了模式位时,进程就运行在内核模式中(超级用户模式)。一个运行在内核模式的进程可以执行指令集中的任何指令,并且可以访问系统的任何内存位置。
没有设置模式位时,进程运行在用户模式中。用户模式中的进程不允许执行特权指令(privileged instruction),比如停止处理器、改变模式位、或者发起一个I/O操作。也不允许用户模式中的进程直接引用地址空间中内核区内的代码和数据。任何这样的尝试都会导致致命的保护故障。反之,用户程序必须通过系统调用接口间接地访问内核代码和数据。
操作系统内核使用一种称为上下文切换的较高层形式的异常控制流来实现多任务。内核为每个进程维持一个上下文明。上下文就是内核重新启动一个被抢占的进程所需状态。进程切换包含三个操作
当进程A开始读取磁盘文件时,会通过执行系统调用read陷入到内核。内核中的陷进处理程序请求来自磁盘控制器的DMA传输,并且安排在磁盘控制器完成从磁盘到内存的数据传输后,磁盘终端处理器。
每个执行的程序,即系统中的进程,进程总可以处于下面三种状态:
加载并运行程序
当使用execve函数在当前进程的上下文中加载并运行一个新程序。
(fork是在父进程下,创建一个新的上下文运行子进程)
#include
int execve(const char *filename, const char *argv[], const char *envp[]);
当使用execve加载filename之后,启动代码设置栈,并将控制传递给新程序的主函数。
用户栈的典型组织结构:
信号
除了操作系统利用异常来支持进程上下文切换的异常控制流形式,另外一种更高层次的软件形式的异常,成为Linux信号,它运行进程和内核中断其他进程。
一个信号就是一条小消息,它通知进程系统中发生了一个某种类型的事件。每种信号类型都对应于某种系统事件。底层的硬件异常是由内核异常处理程序处理的,正常情况下,对于用户进程而言是不可见的。信号提供了一种机制,通知用户进程发生了这些异常。比如,当进程在前台运行时,你键入Ctrl+C,那么内核就会发送一个SIGINT信号强制终止它。当一个子进程终止或者停止时,内核会发送一个SIGCHLD信号给父进程。
传送一个信号到目的进程是由两个不同步骤组成的
一个发出而没有被接收的信号,叫做处理信号,在任何时刻,一种类型至多只会由一个待处理信号。重复发送在等待的信号,将会被内核抛弃。
linux 提供两种阻塞机制,隐式和显式
通过本文我们阐述了,程序在计算机种运行的一些基本概念、逻辑流、内存等。然后,系统种程序往往不是独立运行的,不仅仅是包含最小限度的输入和输出。在现实世界中,应用程序利用操作系统提供的服务来与I/O设备以及其他程序通信。
下一篇,详细探讨Unix操作系统提供的基本I/O服务,以及如何用这些服务来构造应用程序,例如Web客户端和服务器。