现在的位置: 首页 > 综合 > 正文

Linux学习笔记 – 程序的执行(一)

2013年10月18日 ⁄ 综合 ⁄ 共 8250字 ⁄ 字号 评论关闭

进程用来表示正在运行的一组程序竟争系统资源的行为。程序和进程之间的联系,表现在如何在程序文件的内容上建立起进程执行上下文。除了把一组指令装入内存并让CPU执行外,内核还须灵活处理以下几方面的问题:

  •  不同的可执行文件格式。
  •  共享库:很多可执行文件并不包含执行程序所需的所有代码,而是期望内核在运行时从共享库中加载函数。
  •  执行上下文的其他信息:这包括程序员熟悉的命令行参数与环境变量。

    程序是以可执行文件的形式存放在磁盘上的,可执行文件既包括被执行函数的目标代码,也包括这些函数所使用的数据。程序中的很多函数是所有程序员都可使用的服务例程,它们的目标代码包含在 “库”的特殊文件中。实际上,一个库函数的代码或被静态地拷贝到可执行文件中(静态库),或在运行时被连接到进程(共享库,因为它们的代码由很多独立的进程所共享)。当装入并运行一个程序时,用户可以提供影响程序执行方式的两种信息:命令行参数和环境变量。用户在shell提示符下紧跟文件名输入的就是命令行参数。环境变量是从shell继承来的,但用户在装入并运行程序前可以修改任何环境变量。

 

可执行文件

    我们把进程定义为“执行上下文”。这就意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等等。可执行文件是一个普通文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。假定执行显示当前目录下文件的命令ls。命令shell创建一个新进程,新进程调用系统调用execve(),其中传递的一个参数就是可执行文件的全路径名,即/bin/ls ,sys_execve()服务例程找到相应的文件,检查可执行格式,并根据存放在其中的信息修改当前进程的执行上下文。因此,当这个系统调用终止时,新进程开始执行存放在可执行文件中的代码,也就是执行目录显示。当进程开始执行一个新程序时,它的执行上下文发生很大的变化,这是因为在进程的前一个计算执行期间所获得的大部分资源会被抛弃。当进程开始执行/bin/ls时,它用execve()系统调用传递来的新参数代替shell的参数,并获得一个新的shell环境。从父进程继承的所有页(并通过写时拷贝机制实现共享)被释放,以便在一个新的用户态地址空间开始执行新的计算。甚至进程的特权都可能改变。然而,进程的PID不改变,并且新的计算从前一个计算继承所有打开的文件描述符,当然这些文件描述符是在执行execve()系统调用时还没有自动关闭的描述符。

 

进程的信任状和权能

    系统与每个进程的一些信任状相关,信任状把进程与一个特定的用户或用户组捆绑在一起。信任状在多用户系统上尤为重要,因为信任状可以决定每个进程能做什么,不能做什么,这样既保证了每个用户的个人数据的完整性,也保证了系统整体上的稳定性。

    信任状的使用既需要在进程的数据结构方面给予支持,也需要在被保护的资源方面给予支持。文件就是一种显而易见的资源。在Ext2文件系统中,每个文件都属于一个特定的用户,并被捆绑于某个用户组。文件的拥有者可以决定对某个文件允许哪些操作,以在文件的拥有者、文件的用户组及其他所有用户之间做出区别。当某个进程试图访问一个文件时,VFS总是根据文件的拥有者和进程的信任状所建立的许可权检查访问的合法性。进程的信任状存放在进程描述符的几个字段中。这些字段包括系统中用户和用户组的标识符,与之可以相比较的通常是存放在所访问文件索引节点中的标识符。

传统的进程信任状

名字 说明
uid,gid                         用户和组的实际标识符
euid, egid 用户和组的有效标识符
fsuid, fsgid 文件访问的用户和组的有效标识符
groups 补充的组标识符
suid, sgid 用户和组保存的标识符

    值为0的UID指定给root超级用户,而值为0的用户GID指定给root超级组。只要有关进程的信任状存放了一个零值,则内核将放弃权限检查,始终允许这个进程做任何事情,如涉及系统管理或硬件处理的那些操作,而这些操作对于非特权进程是不允许的。

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

    对于如何使用fsuid字段,考虑一下当用户想改变口令时的情况。所有的口令都存放在一个公共文件中,但用户不能直接编辑这样的文件,因为它是受保护的。因此,用户调用一个名为/usr/bin/passwd的系统程序,它可以设置setuid标志,而且它的拥有者是超级用户。当shell创建的进程执行这样一个程序时,进程的euid和fsuid字段被置为0,即超级用户的PID。现在,这个进程可以访问这个文件,因为当内核执行访问控制表时在fsuid字段发现了值。当然,/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字段),该系统调用的这种功能是非常有用的。

 

进程的权能

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

    不管是VFS还是Ext2文件系统目前都不支持权能模型,所以,当进程执行一个可执行文件时,无法把这个文件与本该强加的一组权能联系起来。然而,进程可以分别用capget()和capset()系统调用显式地获得和降低它的权能。

    事实上,Linux内核已经考虑权能。例如nice()系统调用,它允许用户改变进程的静态优先级。在传统的模型中,只有超级用户才能提升一个优先级,内核因此应该检查调用进程描述符的euid字段是否为0。然而,Linux内核定义了一个名为CAP_SYS_NICE的权能,就正好对应着这种操作。内核通过调用capable()函数并把CAP_SYS_NICE值传给这个函数来检查这个标志的值。

    正是由于一些“兼容性小巧程序”已被加入到内核代码中,这种方法才起作用。每当一个进程把euid和fsuid字段设置为0时(或者通过调用上述表格中的一个系统调用,或者通过执行超级用户所拥有的setuid程序),内核就设置进程的所有权能,以便使所有的检查成功。类似地,当进程把euid和fsuid字段重新置为进程拥有者的实际UID时,内核检查进程描述符中的keep_capabilities标志,并在该标志设置时删除进程的所有权能。进程可以调用Linux专有的prctl()系统调用来设置和重新设置keep_capabilities标志。

 

Linux安全模块框架

    在Linux 2.6中,权能是与Linux安全模块(LSM)框架紧密结合在一起的。简单地说,LSM框架允许开发人员定义几种可以选择的内核安全模型。每个安全模型是由一组安全钩实现的。安全钩是由内核调用的一个函数,用于执行与安全有关的重要操作。钩函数决定一个操作是否可以执行。钩函数存放在security_operations类型的表中。当前使用的安全模型钩表地址存放在security_ops变量中。内核默认使用dummy_security_ops表实现最小安全模型。表中的每个钩函数实际上去检查相应的权能(如果有)是否允许,否则无条件返回0(允许)。

    例如,stime()和settimeofday()函数的服务例程在改变系统日期时间之前调用settime安全钩。dummy_security_ops表指向相应的函数,而该函数约束自己去检查当前进程是否有CAP_SYS_TIME的权能,并相应地返回0或者-EPERM。Linax内核更复杂的安全模型已经开发出来。

 

命令行参数和shell环境

    当用户键入一个命令时,为满足这个请求而装入的程序可以从shell接收一些命令行参数(command-line argument)。例如,当用户键入命令:$ ls -l /usr/bin以获得/usr/bin目录下的全部文件列表时,shell进程创建一个新进程执行这个命令。这个新进程装入/bin/ls可执行文件。在这样做的过程中,从shell继承的大多数执行上下文被丢弃,但三个单独的参数ls、-l和/usr/bin依然保持。一般情况下,新进程可以接收任意多个参数。传递命令行参数的约定依赖于所用的高级语言。在C语言中,程序的main()函数把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下列原型形式化地表示了这种标准格式:

    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 ()可以声明如下:

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

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

    VAR NAME=something

    这里,VAR_NAME表示一个环境变量的名字,而“=”后面的子串表示赋给变量的实际值。envp数组的结尾用空指针标记,就像argv数组。envp数组的地址存放在C库的environ全局变量中。命令行参数和环境串都存放在用户态堆栈中,正好位于返回地址之前。下图显示了用户态堆栈的底部单元。环境变量位于栈底附近正好在一个长整数0(即图中的NULL)之后。

用户态堆栈模型

 

    每个高级语言的源码文件都是经过几个步骤才转化为目标文件的,目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址,例如库函数或同一程序中的其他源代码文件。这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。链接程序还分析程序所用的库函数,并以本章后面所描述的方式把它们粘合成可执行文件。大多数程序,甚至是最小的程序都会利用C库。例如下面只有一行的C程序:

    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

    环境串所在的堆栈部分的起始地址和终止地址。

 

灵活线性区布局

    灵活线性区布局(flexible memory region lagout)在内核版本2.6.9中引人。实际上,每个进程均是按照用户态堆栈预期的增长量来进行内存布局的。但是仍然可以使用老的经典布局(主要用于当内核无法限制进程用户态堆栈的大小时)。下表是80x86结构的默认用户态地址空间为例描述了这两种布局,地址空间最大可以到3GB。布局之间只在文件内存映射与匿名映射时线性区的位置上有区别。在经典布局下,这些区域从整个用户态地址空间的1/3开始,通常在地址0x40000000。新的区域往更高线性地址追加,因此,这些区域往用户态堆栈方向扩展。

x86结构的线性布局

线性区种类 经典布局 灵活布局
正文段(ELF) 开始于:0x08048000 开始于:0x08048000
数据与bss段 开始于:紧接正文段之后 开始于:紧接正文段之后
开始于:紧接数据与bss段之后 开始于:紧接数据与bss段之后

文件内存映射与匿名线性区

开始于:0x40000000(该地址对应整个用户地址空间的1/3),库连续往高地址追加

开始于:紧接用户态堆栈尾(最小地址),库连续往低地址追加

用户态堆栈 开始于:OxC0000000并向低地址增长 开始于:OxC0000000并向低地址增长

 

    相反的是,在灵活布局中,文件内存映射与匿名映射的线性区是紧接用户态堆栈尾的。新的区域往更低线性地址追加,因此,这些区域往堆的方向扩展。因为堆栈也是连续往低地址追加的。

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

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

抱歉!评论已关闭.