Linux程序的执行过程 - STEMHA's Blog

Linux程序的执行过程

我们需要知道什么?

进程的概念在UNIX中的表现就是一组程序竞争系统资源的行为。
内核是如何通过程序文件的内容建立进程的执行上下文?
一个程序执行的上下文到底是什么呢?

虽然将一组指令装入内存让 CPU 执行看起来不是大问题,但内核还必须处理以下几方面的问题:

  • 不同的可执行文件格式。Linux 可在 64 位版本的机器上执行 32 位可执行代码。
  • 共享库。很多可执行文件并不包含执行程序所需的所有代码,而是期望内核在运行时从共享库中加载函数。
  • 执行上下文的其它信息。这包括命令行参数环境变量

程序是以可执行文件(executable file)的形式存放在磁盘上的,可执行文件既包括被执行函数的目标代码,也包括这些函数所使用的数据
程序中的很多函数是所有程序员都可使用的服务例程,它们的目标代码包含在所谓“库”的特殊文件中:

  • 实际上,一个库函数的代码或被静态地拷贝到可执行文件(静态库)
  • 或在运行时被连接到进程(共享库,因为它们的代码由很多独立的进程所共享)。

当装入并运行一个程序时,用户可以提供影响程序执行方式的两种信息:

  1. 命令行参数:用户在shell提示符下紧跟文件名输入的就是命令行参数。
  2. 环境变量:环境变量(例如HOME和PATH)是从shell继承来的,但用户在装入并运行程序前可以修改任何环境变量。

接下来各部分的内容
可执行文件:解释一个程序的执行上下文是什么。
可执行格式:提及一些 Linux 所支持的可执行格式,并说明 Linux 如果改变它的“个性”以执行其它操作系统所编译的程序。
exec 函数:描述执行一个新程序的进程所需的系统调用。

可执行文件

进程可以定义为执行上下文。这也意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等。
可执行文件是一个普通文件,描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。

执行过程示例

假如一位用户想在当前目录下面显示文件(我们知道用ls命令就可以),用户在shell提示符下只需要简单的敲出外部命令/bin/ls就可以了。这时候具体会发生什么呢?

  1. 命令shell会创建一个新的进程,新的进程又会调用系统调用execve(),其中传递的一个参数就是ls可执行文件的全路径名(本例子中是/bin/ls)
  2. sys_exec()找到相应的文件,检查可执行的格式,并根据存放在其中的信息修改当前进程的上下文。
  3. 因此,当这个系统调用中止的时候,新的进程开始执行存放在可执行文件中的代码。本例子中是执行目录显示。

当一个进程开始新的程序的时候,它的执行上下文会发生很大的变化。因为进程的前一个计算执行期间所获得的大部分资源会被抛弃,但是进程的 PID 不改变,并且新的计算从前一个计算继承所有打开的文件描述符。

  • 比方上面的示例,当进程开始执行/bin/ls时候,它用execve()系统调用传递来的新参数代替shell的参数,并获得一个新的shell环境。
  • 然后从父进程继承来的所有的页(并通过写时复制机制实现共享)被释放,以便在一个新的用户态空间执行新的计算,甚至进程的特权都可能改变。
  • 当前进程的PID是不会改变的,而且新的计算会继承从之前计算所有打开的文件描述符,当然这些文件描述符是之前执行execve()系统调用时还没有自动关闭的描述符。

进程的信任状和权能

进程的信任状

作用

  • 信任状把进程与一个特定的用户或用户组捆绑在一起。
  • 信任状在多用户系统上特别重要,因为信任状可以决定每个进程能做什么,不能做什么,保证了用户个人数据的完整性,也保证了系统整体上的稳定性。
  • 信任状的使用需要进程数据结构方面给予支持,也需要被保护的资源方面给与支持。
    • 比如文件,当某个进程试图访问一个文件,VFS总是根据文件的拥有者和进程的信任状所建立的许可权检查访问的合法性。

进程描述符中的信任状字段

进程的信任状存放在进程描述符的几个字段中:

1
2
3
4
5
uid,gid            用户和组的实际标识符
euid,egid 用户和组的有效标识符
fsuid,fsgid 文件访问的用户和组的有效标识符
groups 补充的组标识符
suid,sgid 用户和组保存的标识符

说明:

  • euid(effective user ID):当进程执行时间, 操作系统会对euid进行识别, 以此来判断到底用什么权限来执行这个进程.
  • 值为0的uid指定给root用户,值为0的gid指定给root超级组。只要有关进程的信任状存放了一个零值,则内核将放弃权限检查,始终允许这个进程做任何事情,如涉及系统管理或硬件处理的那些操作,而这些操作对于非特权进程是不允许的。
  • setuid是类unix系统提供的一个标志位
    • 其实际意义是set一个process的euid为这个可执行文件或程序的拥有者(比如root)的uid, 也就是说当setuid位被设置之后, 当文件或程序(统称为executable)被执行时, 操作系统会赋予文件所有者的权限, 因为其euid是文件所有者的uid.
    • setuid的方法是使用Linux的chmod指令,我们都习惯给予一个文件类似“0750” “0644” 之类的权限,它们的最高位0就是setuid的位置
    • 比如 chmod 4750 文件名

当一个进程被创建时,总是继承父进程的信任状。

  • 不过,这些信任状以后可以被修改,这发生在当进程开始执行一个新程序时,或者当进程发出合适的系统调用时。
  • 通常情况下,进程的uid, euid, fsuid及suid字段具有相同的值。然而,当进程执行setuid程序时,即可执行文件的setuid标志被设置时,euid和fsuid字段被置为这个文件拥有者的标识符。几乎所有的检查都涉及这两个字段中的一个:fsuid用于与文件相关的操作,而euid用于其他所有的操作。这也同样适用于组标识符的gid、egid、fsgid及sgid字段。

如何使用fsuid字段

考虑一下当用户想改变口令时的情况。所有的口令都存放在一个公共文件中,但用户不能直接编辑这样的文件,因为它是受保护的。因此使用如下操作:

  1. 户调用一个名为/usr/bin/passwd的系统程序,它可以设置setuid标志,而且它的拥有者是超级用户。
  2. 当shell创建的进程执行这样一个程序时,进程的euid和fsuid字段被置为0,即超级用户的PID。
  3. 现在,这个进程可以访问这个文件,因为当内核执行访问控制表时在fsuid字段发现了值。
  4. 当然,/usr/bin/passwd程序除了让用户改变自己的口令外,并不允许做其他任何事情。

系统调用怎样影响进程信任状

从Unix的历史发展可以得出一个教训,即setuid程序是相当危险的:恶意用户可以以这样的方式触发代码中的一些bug,从而强迫setuid程序执行程序的最初设计者从未安排的操作。这可能常常危及整个系统的安全。
为了减少这样的风险,Linux与所有现代Unix操作系统一样,让进程只有在必要时才获得setuid特权,并在不需要时取消它们。可以证明,当使用数个保护级别来实现用户应用程序时,这种特点是很有用的。
进程描述符包含一个suid字段,在setuid程序执行以后在该字段中正好存放有效标识符(euid和fsuid)的值。进程可以通过setuid()、setresuid()、setfsuid()和setreuid()系统调用改变有效标识符。

下表显示了这些系统调用是怎样影响进程的信任状的。
如果调用进程还没有超级用户特权,即它的euid字段不为0,那么,只能用这些系统调用来设置在这个进程的信任状字段已经有的值。
例如,一个普通用户进程可以通过调用系统调用setfsuid()强迫它的fsuid值为500,但这只有在其他信任状字段中有一个字段已经有相同的值500时才行。

设置信任状的系统调用

setuid(e) setuid(e)
字段 euid = 0 euid != 0 setresuid(u,e,s) setresuid(u,e) setresuid(f)
uid 设置为e 不改变 设置为u 设置为u 不改变
euid 设置为e 设置为e 设置为e 设置为e 不改变
fsuid 设置为e 设置为e 设置为e 设置为e 设置为f
suid 设置为e 不改变 设置为s 设置为e 不改变

为了理解四个用户ID字段之间的关系,考虑setuid()系统调用的效果。

  • 这些操作是不同的,这取决于调用者进程的euid字段是否被置为0(即进程有超级用户特权)或被置为一个正常的UID。
  • 如果euid字段为0,这个系统调用就把调用进程的所有信任状字段(uid, euid, fsuid及suid)置为参数e的值。超级用户进程因此就可以删除自己的特权而变为由普通用户拥有的一个进程。
    • 例如,在用户登录时,系统以超级用户特权创建一个新进程,但这个进程通过调用setuid()系统调用删除自己的特权,然后开始执行用户login shell程序。
  • 如果euid字段不为0,那么这个系统调用只修改存放在euid和fsuid中的值,让其他两个字段保持不变。
  • 当运行setuid程序来提高和降低进程有效权限时(这些权限存放在euid和fsuid字段),该系统调用的这种功能是非常有用的。

进程的权能(可选看)

这个在linux中不怎么用。
“权能(capability )”一词引人进程信任状的另一种模型。Linux内核支持POSIX权能,一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作。这个模型不同于传统的“超级用户VS普通用户”模型,在后一种模型中,一个进程要么能做任何事情,要么什么也不能做,这取决于它的有效UID。
在Linux内核中已包含了很多权能。权能的主要优点是,任何时候每个进程只需要有限种权能。因此,即使有恶意的用户发现一种利用有潜在错误的程序的方法,他也只能非法地执行有限个操作类型。

命令含参数和 shell 环境

当用户键入一个命令时,为满足这个请求而装入的程序可以从shell接收一些命令行参数(command-line argument)
例如:

  • 当用户键入命令:$ ls -l /usr/bin以获得/usr/bin目录下的全部文件列表时,shell进程创建一个新进程执行这个命令。
  • 这个新进程装入/bin/ls可执行文件。在这样做的过程中,从shell继承的大多数执行上下文被丢弃,但三个单独的参数ls、-l和/usr/bin依然保持。
  • 一般情况下,新进程可以接收任意多个参数。传递命令行参数的约定依赖于所用的高级语言。

传递命令行参数

在C语言中,程序的main()函数把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下列原型形式化地表示了这种标准格式:

1
int main(int argc,char *argv[])

回到前面的例子:

  • 当/bin/ls程序被调用时,argc的值为3, argv[0]指向”ls”字符串,argv[1]指向”-l”字符串,而argv[2]指向”/usr/bin”字符串。
  • argv数组的末尾处总以空指针来标记,因此,argv[3]为NULL。
  • 在C语言中,传递给main()函数的第三个可选参数是包含环境变量的参数

传递环境变量参数

环境变量用来定制进程的执行上下文,由此为用户或其他进程提供通用的信息,或者允许进程在执行execve()系统调用的过程中保持一些信息。
为了使用环境变量,main ()可以声明如下:

1
int main(int argc,char *argv(),char *envp[])

envp参数指向环境串的指针数组,形式如下:

1
VAR_NAME=something

说明:

  • VAR_NAME表示一个环境变量的名字,而“=”后面的子串表示赋给变量的实际值。
  • envp数组的结尾用空指针标记,就像argv数组。envp数组的地址存放在C库的environ全局变量中。
  • 命令行参数和环境串都存放在用户态堆栈中,正好位于返回地址之前

下图显示了用户态堆栈的底部单元。环境变量位于栈底附近正好在一个长整数0(即图中的NULL)之后。

用户态堆栈模型

每个高级语言的源码文件都是经过几个步骤才转化为目标文件的(.o文件),目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。

  • 目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址,例如库函数或同一程序中的其他源代码文件。
  • 这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。
  • 链接程序还分析程序所用的库函数,并以本章后面所描述的方式把它们粘合成可执行文件。
  • 大多数程序,甚至是最小的程序都会利用C库。

例如下面只有一行的C程序:

1
void main(void){}

尽管这个程序没有做任何事情,但还是需要做很多工作来建立执行环境,并在程序终止时杀死这个进程。尤其当main()函数终止时,C编译程序把exit_group()函数插入到目标代码中。程序通常通过C库中的封装例程调用系统调用,C编译器亦如此。

任何可执行文件除了包括对程序的语句进行编译所直接产生的代码外,还包括一些“粘合”代码来处理用户态进程与内核之间的交互。这样的粘合代码有一部分存放在C库中。
除了C库,Unix系统中还包含很多其他的函数库。一般的Linux系统通常就有几百个不同的库。

传统Unix系统中的所有可执行文件都是基于静态库(static library)的。这意味着链接程序所产生的可执行文件不仅包括原程序的代码,还包括程序所引用的库函数的代码。静态库的一大缺点是它们占用大量的磁盘空间。因为每个静态链接的可执行文件都复制库代码的某些部分。
现代Unix系统利用共享库(shared library)。可执行文件不用再包含库的目标代码,而仅仅指向库名。当程序被装入内存执行时,一个名为动态链接器(dynamic linker,也叫ld.so)的程序就专注于分析可执行文件中的库名,确定所需库在系统目录树中的位置,并使执行进程可以使用所请求的代码。
进程也可以使用dlopen()库函数在运行时装入额外的共享库。

共享库的优点:

  • 共享库对提供文件内存映射的系统尤为方便,因为它们减少了执行一个程序所需的主内存量。
  • 当动态链接程序必须把某一共享库链接到进程时,并不拷贝目标代码,而是仅仅执行一个内存映射,把库文件的相关部分映射到进程的地址空间中。这就允许共享库机器代码所在的页框被使用同一代码的所有进程共享。如果程序是静态链接的,那么共享是不可能的。

共享库的缺点:

  • 动态链接的程序启动时间通常比静态链接的程序长。
  • 动态链接的程序的可移植性也不如静态链接的好,因为当系统中所包含的库版本发生变化时,动态链接的程序运行时就可能出现问题。

如何编译的时候只使用静态链接:

用户可以始终请求一个程序被静态地链接。例如,GCC编译器提供-static选项,即告诉链接程序使用静态库而不是共享库。

程序段和进程的线性区

从逻辑上说,Unix程序的线性地址空间传统上被划分为几个叫做段(segment)的区间:

正文段

  • 包含程序的可执行代码。

己初始化数据段

  • 包含已初始化的数据,也就是初值存放在可执行文件中的所有静态变量和全局变量(因为程序在启动时必须知道它们的值)。

未初始化数据段(bss段)

  • 包含未初始化的数据,也就是初值没有存放在可执行文件中的所有全局变量,因为程序在引用它们之前才赋值,历史上把这个段叫做bss段。

堆栈段

  • 包含程序的堆栈,堆栈中有返回地址、参数和被执行函数的局部变量。

每个mm_struct内存描述符都包含一些字段来标识相应进程特定线性区的作用:
start_code,end_code:程序的源代码所在线性区的起始和终止线性地址,即可执行文件中的代码。
start_data,end_data:程序的初始化数据所在线性区的起始和终止线性地址,正如在可执行文件中所指定的那样。这两个字段指定的线性区大体上与数据段对应。
start_brk,brk:存放线性区的起始和终止线性地址,该线性区包含动态分配给进程的内存区。有时把这部分线性区叫做堆。
start_stack:正好在main()的返回地址之上的地址。更高的地址被保留(栈向低地址增长)。
arg_start,arg_end:命令行参数所在的堆栈部分的起始地址和终止地址。
env_start,env_end:环境串所在的堆栈部分的起始地址和终止地址。

可执行格式

Linux标淮的可执行格式是ELF(Executable and Linking Format),它由Unix系统实验室开发并在Unix世界相当流行。著名的Unix操作系统都把ELF作为它们的主要可执行格式。
Linux的旧版支持另一种名叫Assembler OUTput Format (a.out)的格式。因为现在ELF非常实用,因此已经很少用a.out格式。

Linux支持很多其他不同格式的可执行文件。

  • 在这种方式下,Linux能运行为.其他操作系统所编译的程序,如MS-DOS的EXE程序。
  • 有几种可执行格式,如Java或bash脚本,是与平台无关的。

类型为linux_binfmt的对象所描述的可执行格式实质上提供以下三种方法:

  • load_binary 通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境
  • load_shlib 用于动态地把一个共享库捆绑到一个已经在运行的进程,这是由uselib()系统调用激活的。
  • core_dump 在名为core的文件中存放当前进程的执行上下文。这个文件通常在进程接收到缺省操作为“dump”的信号时被创建,格式取决于被执行程序的可执行类型。

所有的linux binfmt对象都处于一个单向链表中。

  1. 链表第一个元素的地址:存放在formats变量中。
  2. 插入和删除元素:通过调用register_binfmt()unregister_binfmt()函数在链表中插入和删除元素。
    • 在系统启动期间,为每个编译进内核的可执行格式都执行register_binfmt()函数。
    • 当实现了一个新的可执行格式的模块正被装载时,也执行这个函数,当模块被卸载时,执行unregister_binfmt()函数。
  3. 在formats链表中的最后一个元素:是对解释脚本的可执行格式进行描述的一个对象。

这种格式只定义了load_binary方法。其相应的load_script()函数检查这种可执行文件是否以两个#!字符开始。如果是,这个函数就把第一行的其余部分解释为另一个可执行文件的路径名,并把脚本文件名作为参数传递以执行它。

自定义的可执行格式

Linux允许用户注册自己定义的可执行格式:

对这种格式的识别或者通过存放在文件前128字节的魔数,或者通过表示文件类型的扩展名。

例如,MS-DOS的扩展名由“.”把三个字符从文件名中分离出来:.exe扩展名标识可执行文件,而.bat扩展名标识shell脚本。

如何执行

当内核确定可执行文件是自定义格式时,它就启动相应的解释程序。
解释程序运行在用户态,读入可执行文件的路径名作为参数,并执行计算。
例如,包含Java程序的可执行文件就由Java虚拟机(如//usr/lib/Java/bin/Java)来解释。

建立自定义可执行格式

这种机制与脚本格式类似,但功能更加强大,这是因为它对自定义格式不加任何限制。
注册一个新格式,就必须在binfmt_misc文件系统注册文件内写人一个字符串,其格式为:

1
 name:type:offset:string:mask:interpreter:flags

每个字段的含义如下:

1
2
3
4
5
6
7
name:         新格式的标识符。
type: 识别类型(M表示魔数,E表示扩展)。
offset: 魔数在文件中的起始偏移量。
string:  以魔数或者以扩展名匹配的字节序列。
mask:    用来屏蔽掉string中的一些位的字符串。
interpreter:  解释程序的完整路径名。
flags:     可选标志,控制必须怎样调用解释程序。

例如,超级用户执行的下列命令将使内核识别出Microsoft Windows的可执行格式:

1
echo :DOSWin:M:O:MZ:Oxff:/usr/bin/wine:’>/proc/sys/fs/binfmt misc/register

Windows可执行文件的前两个字节是魔数MZ,由解释程序/usr/bin/wine执行这个可执行文件。

执行域

Linux的一个巧妙的特点就是能执行其他操作系统所编译的程序。当然,只有内核运行的平台与可执行文件包含的机器代码对应的平台相同时这才是可能的。对这些“外来”程序提供两种支持:

  • 模拟执行(emulated execution):程序中包含的系统调用与POSIX不兼容时才有必要执行这种程序。
  • 原样执行(native execution):只有程序中所包含的系统调用完全与POSIX兼容时才有效。

模拟执行的例子

Microsoft MS-DOS和Windows程序是被模拟执行的,因为它们包含的API不能被Linux所认识,因此不能原样执行。像DOSemu或Wine这样的模拟程序被调用来把每个API调用转换为一个模拟的封装函数调用,而封装函数调用又使用现有的Linux系统调用。

原样执行的例子

另一方面,不用太费力就可以执行为其他操作系统编译的与POSIX兼容的程序,因为与POSIX兼容的操作系统都提供了类似的API。

  • 内核必须消除的细微差别通常涉及如何调用系统调用或如何给各种信号编号。这种信息存放在类型为exec_domain的执行域描述符中。

进程可以指定它的执行域:

  • 通过设置进程描述符personality字段,以及把相应exec_domain数据结构的地址存放到thread_info结构exec_domain字段来实现的。
  • 进程可以通过发布一个personality()系统调用来改变它的个性(personality)。

程序员通常不希望直接改变其程序的个性;相反,应该通过建立进程的执行上下文的“粘合”代码来发出personality()系统调用。//待定

灵活线性布局(可选看)

灵活线性区布局(flexible memory region lagout)在内核版本2.6.9中引人。
实际上,每个进程均是按照用户态堆栈预期的增长量来进行内存布局的。但是仍然可以使用老的经典布局(主要用于当内核无法限制进程用户态堆栈的大小时)。

下表是80x86结构的默认用户态地址空间为例描述了这两种布局,地址空间最大可以到3GB。布局之间只在文件内存映射与匿名映射时线性区的位置上有区别。

x86结构的线性布局:

线性区种类 经典布局 灵活布局
正文段(ELF) 开始于:0x08048000 开始于:0x08048000
数据与bss段 开始于:紧接正文段之后 开始于:紧接正文段之后
开始于:紧接数据与bss段之后 开始于:紧接数据与bss段之后
文件内存映射与匿名线性区 开始于:0x40000000(该地址对应整个用户地址空间的1/3),库连续往高地址追加 开始于:紧接用户态堆栈尾(最小地址),库连续往低地址追加
用户态堆栈 开始于:OxC0000000并向低地址增长 开始于:OxC0000000并向低地址增长

在经典布局下,这些区域从整个用户态地址空间的1/3开始,通常在地址0x40000000。新的区域往更高线性地址追加,因此,这些区域往用户态堆栈方向扩展。
相反的是,在灵活布局中,文件内存映射与匿名映射的线性区是紧接用户态堆栈尾的。新的区域往更低线性地址追加,因此,这些区域往堆的方向扩展。因为堆栈也是连续往低地址追加的。

当内核能通过RLIMIT_STACK资源限制来限定用户态堆栈的大小时,通常使用灵活布局这个限制确定了为堆栈保留的线性地址空间大小。但是这个空间大小不能小于128MB或大于2.5GB。另外,如果RLIMIT_STACK资源限制设为无限(infinity),或者系统管理员将sysctl_legacy_va_layout变量设为1(通过修改/proc/sys/vm/legacy_va_layout文件或调用相应的sysctl()系统调用实现),内核无法确定用户态堆栈的上限,就仍然使用经典线性区布局。

引入灵活布局的主要优点在于:可以允许进程更好地使用用户态线性地址空间。

  • 在经典布局中,堆的限制是小于1GB,而其他线性区可以使用到约2GB(减去堆栈大小)。
  • 在灵活布局中,没有这些限制,堆和其他线性区可以自由扩展,可以使用除了用户态堆栈和程序用固定大小的段以外的所有线性地址空间。

参考资料

深入理解Linux内核中文版(第三版)
linux内核学习之七 可执行程序的装载和运行
Linux中的setuid简介
进程的虚拟内存,物理内存,共享内存
进程虚拟内存空间—线性区的数据结构
linux下的三种可执行文件格式的比较
linux 如何运行一个可执行文件
深入理解Linux内核(完整版)-笔记

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×