可执行文件
在第一章中我们把进程定义为“执行上下文”。这就意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等等。
可执行文件是一个普通文件,它描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。
假定一位用户想在当前目录下显示文件,他知道在shell提示符下只要简单地敲入外部命令/bin/ls(注1)就可得到这个结果。
命令shell创建一个新进程,新进程又调用系统调用execve()(参看本章后面的“exec函数”一节),其中传递的一个参数就是ls可执行文件的全路径名,
在本例中即/bin/ls。
sys_execve()服务例程找到相应的文件,检查可执行格式,并根据存放在其中的信息修改当前进程的执行上下文。
因此,当这个系统调用终止时,新进程开始执行存放在可执行文件中的代码,也就是执行目录显示。
当进程开始执行一个新程序时,它的执行上下文发生很大的变化,这是因为在进程的前一个计算执行期间所获得的大部分资源会被抛弃。
在前面的例子中,当进程开始执行/bin/ls时,它用execve()系统调用传递来的新参数代替shell的参数,
并获得一个新的shell环境(参见后面的“命令行参数和shell环境”一节);
从父进程继承的所有页(并通过写时复制机制实现共享)被释放,以便在一个新的用户态地址空间开始执行新的计算;
甚至进程的特权都可能改变(参看后面的“进程的信任状和权能”一节)。
然而,进程的PID不改变,并且新的计算从前一个计算继承所有打开的文件描述符,
当然这些文件描述符是在执行execve()系统调用时还没有自动关闭的描述符(注2)。
进程的信任状和权能
从传统上看,Unix系统与每个进程的一些信任状(credential)相关,信任状把进程与一个特定的用户或用户组捆绑在一起。
信任状在多用户系统上尤为重要,因为信任状可以决定每个进程能做什么,不能做什么,
这样既保证了每个用户的个人数据的完整性,也保证了系统整体上的稳定性。
信任状的使用既需要在进程的数据结构方面给予支持,也需要在被保护的资源方面给予支持。
文件就是一种显而易见的资源。因此,在Ext2文件系统中,每个文件都属于一个特定的用户,并被捆绑于某个用户组。
文件的拥有者可以决定对某个文件允许哪些操作,以在文件的拥有者、文件的用户组及其他所有用户之间做出区别。
当某个进程试图访问一个文件时,VFS总是根据文件的拥有者和进程的信任状所建立的许可权检查访问的合法性。
进程的信任状存放在进程描述符的几个字段中,如表20-1所示。
这些字段包括系统中用户和用户组的标识符,与之可以相比较的通常是存放在所访问文件索引节点中的标识符。
值为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字段发现了值0。
当然,usr/bin/passwd程序除了让用户改变自己的口令外,并不允许做其他任何事情。
从Unix的历史发展可以得出一个教训,即setuid程序是相当危险的:
恶意用户可以以这样的方式触发代码中的一些编程错误(bug),从而强迫setuid程序执行程序的最初设计者从未安排的操作。
这可能常常危及整个系统的安全。为了减少这样的风险,Linux与所有现代Unix操作系统一样,让进程只有在必要时才获得setuid特权,并在不需要时取消它们。
可以证明,当以几个保护级别实现用户应用程序时,这种特点是很有用的。
进程描述符包含一个suid字段,在setuid程序执行以后在该字段中正好存放有效标识符(euid和fsuid)的值。
进程可以通过setuid()、setresuid()、setfsuid()和setreuid()系统调用改变有效标识符(注3)。
表20-2显示了这些系统调用是怎样影响进程的信任状的。
请注意,如果调用进程还没有超级用户特权,即它的euid字段不为0,那么,只能用这些系统调用来设置在这个进程的信任状字段已经有的值。
例如,一个普通用户进程可以通过调用系统调用setfsuid()强迫它的fsuid值为500,但这只有在其他信任状字段中有一个字段已经有相同的值500时才行。
为了理解四个用户ID字段之间的复杂关系,让我们考虑一下setuid()系统调用的效果。
这些操作是不同的,这取决于调用者进程的euid字段是否被置为0(即进程有超级用户特权)或被置为一个正常的UID。
如果euid字段为0,这个系统调用就把调用进程的所有信任状字段(uid、euid、fsuid 及suid)置为参数e的值。
超级用户进程因此就可以删除自己的特权而变为由普通用户拥有的一个进程。
例如,在用户登录时,系统以超级用户特权创建一个新进程,但这个进程通过调用setuid()系统调用删除自己的特权,然后开始执行用户的login shell程序。
如果euid字段不为0,那么这个系统调用只修改存放在euid和fsuid中的值,让其他两个字段保持不变。
当运行setuid程序来提高和降低进程有效权限时(这些权限存放在euid和fsuid字段),该系统调用的这种功能是非常有用的。
进程的权能
POSIX.le草案(现已撤销)用“权能(capability)”一词引入进程信任状的另一种模型。Linux内核支持POSIX权能,但是大部分Linux的发行版本不用它。
一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作。
这个模型不同于传统的“超级用户VS普通用户”模型,在后一种模型中,一个进程要么能做任何事情,要么什么也不能做,这取决于它的有效UID。
如表20-3所示,在Linux 内核中已包含了很多权能。
权能的主要优点是,任何时候每个进程只需要有限种权能。
因此,即使有恶意的用户发现一种利用有潜在错误的程序的方法,他也只能非法地执行有限个操作类型。
例如,假定一个有潜在错误的程序只有CAP_SYS_TIME权能。
在这种情况下,利用其错误的恶意用户只能在非法地改变实时时钟和系统时钟方面获得成功。她并不能执行任何其他特权的操作。
不管是VFS还是Ext2文件系统目前都不支持权能模型,所以,当进程执行一个可执行文件时,无法把这个文件与本该强加的一组权能联系起来。
然而,进程可以分别用capget()和capset()系统调用显式地获得和降低它的权能。例如,完全可以通过修改login程序只保留其权能的一个子集而删除其他权能。
事实上,Linux内核已经考虑权能。例如,让我们考虑一下nice()系统调用,它允许用户改变进程的静态优先级。
在传统的模型中,只有超级用户才能提升一个优先级,内核因此应该检查调用进程描述符的euid字段是否为0。
然而,Linux内核定义了一个名为CAP_SYS_NICE的权能,就正好对应着这种操作。
内核通过调用capable()函数并把CAP_SYS_NICE值传给这个函数来检查这个标志的值。
正是由于一些“兼容性小巧程序”已被加入到内核代码中,这种方法才起作用。
每当一个进程把euid和fsuid字段设置为0时(或者通过调用表20-2中的一个系统调用,或者通过执行超级用户所拥有的setuid程序),内核就设置进程的所有权能,
以便使所有的检查成功。
类似地,当进程把euid和fsuid字段重新置为进程拥有者的实际UID时,内核检查进程描述符中的keep_capabilities标志,
并在该标志设置时删除进程的所有权能。进程可以调用Linux专有的prct1()系统调用来设置和重新设置keep_capabilities标志。
Linux安全模块框架
在Linux 2.6中,权能是与Linux安全模块(LSM)框架紧密结合在一起的。简单地说,LSM框架允许开发人员定义几种可以选择的内核安全模型。
每个安全模型是由一组安全钩(security hook)实现的。安全钩是由内核调用的一个函数,用于执行与安全有关的重要操作。钩函数决定一个操作是否可以执行。
钩函数存放在security_operations类型的表中。当前使用的安全模型钩表地址存放在security_ops变量中。
内核默认使用dummy_security_ops表实现最小安全模型。表中的每个钩函数实际上去检查相应的权能(如果有)是否允许,否则无条件返回0(允许操作)。
例如,stime()和settimeofday()函数的服务例程在改变系统日期时间之前调用settime安全钩。
durmmy_security_ops表指向相应的函数,而该函数约束自己去检查当前进程是否有CAP_SYS_TIME的权能,并相应地返回0或者-EPERM。
Linax内核更复杂的安全模型已经开发出来。一个广为人知的例子是由美国国家安全局开发的securlty_Enhanced Linux(SELinux)。
命令行参数和shell环境
当用户键入一个命令时,为满足这个请求而装入的程序可以从shell接收一些命令行参数(command-line argument)。例如,当用户键入命令:
s ls -1 /usr/bin
以获得在/usr/bin目录下的全部文件列表时,shell进程创建一个新进程执行这个命令。
这个新进程装入/bin/ls可执行文件。
在这样做的过程中,从shell继承的大多数执行上下文被丢弃,但三个单独的参数ls、-1和/usr/bin依然保持。一般情况下,新进程可以接收任意多个参数。
传递命令行参数的约定依赖于所用的高级语言。
在C语言中,程序的main()函数把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下列原型形式化地表示了这种标准格式:
int main(int argc,char *argv[])
再回到前面的例子,当/bin/ls程序被调用时,argc的值为3,argv[0]指向ls字符串,argv[1]指向-1字符串,而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全局变量中。
命令行参数和环境串都存放在用户态堆栈中,正好位于返回地址之前(参见第十章的“参数传递”一节)。
图20-1显示了用户态堆栈的底部单元。注意,环境变量位于栈底附近正好在一个0长整数之后。
库
每个高级语言的源码文件都是经过几个步骤才转化为目标文件的,目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。
目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址(例如库函数或同一程序中的其他源代码文件)。
这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。
链接程序还分析程序所用的库函数,并以本章后面所描述的方式把它们粘合成可执行文件。
大多数程序,甚至是最小的程序都会利用C库。例如,请看下面只有一行的C程序:
void main(void){}
尽管这个程序没有做任何事情,但还是需要做很多工作来建立执行环境(参见本章后面的“exec函数”一节),
并在程序终止时杀死这个进程(参见第三章的“撤消进程”一节)。尤其是当main()函数终止时,C编译程序把exit_group()函数插入到目标代码中。
从第十章我们知道,程序通常通过C库中的封装例程调用系统调用。
C编译器亦如此。任何可执行文件除了包括对程序的语句进行编译所直接产生的代码外,还包括一些“粘合”代码来处理用户态进程与内核之间的交互。
这样的粘合代码有一部分存放在C库中。
除了C库,Unix系统中还包含很多其他的函数库。
一般的Linux系统通常就有几百个不同的库。这里仅仅列举其中的两个:
数学库libm包含浮点操作的基本函数,而X11库libX1l收集了所有X11窗口系统图形接口的基本底层函数。
传统Unix系统中的所有可执行文件都是基于静态库(static library)的。
这就意味着链接程序所产生的可执行文件不仅包括原程序的代码,还包括程序所引用的库函数的代码。
静态库的一大缺点是:它们占用大量的磁盘空间。的确,每个静态链接的可执行文件都复制库代码的某些部分。
现代Unix系统利用共享库(shared library)。可执行文件不用再包含库的目标代码,而仅仅指向库名。
当程序被装入内存执行时,一个名为动态链接器(dynamic linker,也叫ld.so)的程序就专注于分析可执行文件中的库名,确定所需库在系统目录树中的位置,
并使执行进程可以使用所请求的代码。进程也可以使用dlopen()库函数在运行时装入额外的共享库。
共享库对提供文件内存映射的系统尤为方便,因为它们减少了执行一个程序所需的主内存量。
当动态链接程序必须把某一共享库链接到进程时,并不拷贝目标代码,而是仅仅执行一个内存映射,把库文件的相关部分映射到进程的地址空间中。
这就允许共享库机器代码所在的页框被使用同一代码的所有进程共享。显然,如果程序是静态链接的,那么共享是不可能的。
共享库也有一些缺点。动态链接的程序启动时间通常比静态链接的程序长。
此外,动态链接的程序的可移植性也不如静态链接的好,因为当系统中所包含的库版本发生变化时,动态链接的程序运行时就可能出现问题。
用户可以始终请求一个程序被静态地链接。例如,GCC编译器提供-static选项,即告诉链接程序使用静态库而不是共享库。
程序段和进程的线性区
从逻辑上说,Unix程序的线性地址空间传统上被划分为几个叫做段(segment)(注4)的区间:
正文段
包含程序的可执行代码。
已初始化数据段
包含已初始化的数据,也就是初值存放在可执行文件中的所有静态变量和全局变量(因为程序在启动时必须知道它们的值)。
未初始化数据段(bss段)
包含未初始化的数据,也就是初值没有存放在可执行文件中的所有全局变量(因为程序在引用它们之前才赋值);历史上把这个段叫做bss段。
堆栈段
包含程序的堆栈,堆栈中有返回地址、参数和被执行函数的局部变量。
每个mm_struct内存描述符(参见第九章中的“内存描述符”一节)都包含一些字段来标识相应进程特定线性区的作用:
start_code,end_code
程序的源代码所在线性区的起始和终止线性地址,即可执行文件中的代码
start_data,end_data
程序的初始化数据所在线性区的起始和终止线性地址,正如在可执行文件中所指定的那样。这两个字段指定的线性区大体上与数据段对应。
start_brk,brk
存放线性区的起始和终止线性地址,该线性区包含动态分配给进程的内存区(参看第九章的“堆的管理”一节)。有时把这部分线性区叫做堆(heap)。
start_stack
正好在main()的返回地址之上的地址。如图20-1所示,更高的地址被保留(回想一下,栈是向低地址增长)。
arg_start,arg_end
命令行参数所在的堆栈部分的起始地址和终止地址。
env_start,env_end
环境串所在的堆栈部分的起始地址和终止地址。
注意,共享库和文件的内存映射使得基于程序段的进程地址空间分类有点过时,因为每个共享库被映射到与前面所讨论的线性区不同的线性区。
灵活线性区布局
灵活线性区布局(flexible memory region lagout)在内核版本2.6.9中引入:
实际上,每个进程是按照用户态堆栈预期的增长量来进行内存布局的。
但是仍然可以使用老的经典布局(主要用于:当内核无法限制进程用户态堆栈的大小时)。
表20-4以80x86结构的默认用户态地址空间为例描述了这两种布局,地址空间最大可以到3GB。
正如你所看到的,布局之间只在文件内存映射与匿名映射时线性区的位置上有区别。
在经典布局下,这些区域从整个用户态地址空间的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(要减去堆栈大小)。
在灵活布局中,这些限制没有了,堆和其他线性区可以自由扩展,可以使用除了用户态堆栈和程序用固定大小的段以外的所有线性地址空间。
现在,一个实用的小试验很有启发意义。让我们录入和编译下面的C程序;
#include
#include
include
int main()
{
char cmd[32];
brk((void *)0x8051000);
sprintf(cmd,"cat /proc/self/maps");
system(cmd);
return 0;
}
实际上,程序将它的进程堆变大(参见第九章“堆的管理”一节),然后在/proc特殊文件系统下读入maps文件,该文件产生进程自身的线性区清单。
让我们对堆栈大小不加任何限制并运行程序:
(由于C编译器的版本不同与程序链接方式不同,见到的结果可能略有不同。)前两个十六进制数表示线性区的范围,后面是权限标志。
最后面是线性区映射的文件的有关信息,如果有信息就是:文件内的开始偏移量、块设备号、索引节点号和文件名。
请注意,列出的所有区域是由私有内存映射实现的(权限列的p字母)。这并不奇怪,因为这些线性区是只为进程提供数据而存在的。
当执行指令时,进程可以修改这些线性区的内容,但是与它们相关的磁盘文件会保持不变。私有内存映射就具有如此作用。
从0x8048000开始的线性区是与/tmp/memorylayout文件的0~4095字节部分对应的内存映射。
而相应的权限表示是可执行的(它包含了目标代码)、只读的(因为指令在执行期间是不改变的,因此不可写)和私有的。这很正确,这是程序正文段的映射区域。
从0x8049000开始的线性区也是与/tmp/memorylayout文件的0~4095字节部分对应的另一个内存映射。
这个程序太小,以至于程序的正文、数据和bss段都在同一个文件页里。因此,包含数据段和bss段的线性区与上一个线性区在线性地址空间是重叠的。
第三个线性区包含进程的堆。注意,它在线性地址0x8051000处终止,传递给brk()系统调用的就是该地址。
接下来从0x40000000和0x40014000开始的两个线性区,分别对应这个系统ELF共享库(/lib/ld-2.3.2.so)动态链接程序的正文段和数据、bss段。
动态链接程序决不单独执行,它总是以内存映射的方式映射到执行其他程序的进程地址空间内。从0x40015000 开始的匿名线性区已由动态链接程序分配。
在这个系统上,C库正好存放在文件/lib/libc-2.3.2.so中。
C库的正文段和数据、bss段被映射到从0x4002f000地址开始的两个线性区。
还记得私有区域所在的页框,只要没被修改,就可以通过写时复制机制在几个进程间共享。
因此,因为正文段是只读的,所示包含C库执行代码的页框几乎在所有当前运行进程间共享(除了静态链接程序)。从0x4015b000开始的匿名线性区已由C库分配。
从Oxbffeb000到0xc0000000的匿名内存区对应于用户态堆栈。我们在第九章“缺页异常处理程序”一节已讨论过堆栈是如何在必要时自动地向低地址方向扩展的。
最后,从Oxffffe000开始的单页匿名线性区包含进程的vsyscall页,
当发出系统调用和从信号处理程序返回时会访问该区域(参见第十章“通过sysenter指令发出系统调用”一节和第十一章“捕获信号”一节)。
现在我们对用户态堆栈大小施加限制后再运行该程序
我们注意到布局发生了变化,即在最高堆栈地址之上为动态链接程序映射了一个约128MB的区域。
而且,因为C库的线性区在稍后创建,所以就得到一个较低的线性地址。
执行跟踪
执行跟踪(execution tracing)是一个程序监视另一个程序执行的一种技术。被跟踪的程序一步一步地执行,直到接收到一个信号或调用一个系统调用。
执行跟踪由调试程序(debugger)广泛使用,当然还使用其他技术(包括在被调试程序中插入断点及运行时访问它的变量)。
与往常一样,我们将集中讨论内核怎样支持执行跟踪,而不讨论调试程序怎样工作。
在Linux中,通过ptrace()系统调用进行执行跟踪,这个系统调用能处理如表20-5所示的命令。
设置了CAP_SYS_PTRACE权能的进程可以跟踪系统中的任何进程(除了init)。
相反,没有CAP_SYS_PTRACE权能的进程P只能跟踪与P有相同属主的进程。此外,两个进程不能同时跟踪一个进程。
ptrace()系统调用修改被跟踪进程描述符的parent字段以使它指向跟踪进程,因此,跟踪进程变为被跟踪进程的有效父进程。
当执行跟踪终止时,也就是当以PTRACE_DETACH命令调用ptrace()时,
这个系统调用把p_pptr设置为real_parent 的值,恢复被跟踪进程原来的父进程(参见第三章的“进程之间的关系”一节)。
与被跟踪程序相关的几个监控事件为:
1.一条单独汇编指令执行的结束
2.进入系统调用
3.退出系统调用
4.接收到一个信号
当一个监控的事件发生时,被跟踪的程序停止,并且将SIGCHID信号发送给它的父进程。
当父进程希望恢复子进程的执行时,就使用PTRACE_CONT、PTRACE_SINGLESTEP 和PTRACE_SYSCALL命令中的一条命令,
这取决于父进程要监控哪种事件。
PTRACE_CONT命令只继续执行,子进程将一直执行到收到另一个信号。
这种跟踪是通过进程描述符的ptrace字段中的PF_PTRACED标志实现的,而这个标志的检查是由do_signal()函数进行的(参看第十一章中的“传递信号”一节)。
PTRACE_SINGLESTEP命令强迫子进程执行下一条汇编语言指令,然后又停止它。
这种跟踪是基于80×86机器的eflags寄存器的TF陷阱标志而实现的。
当这个标志为1时,在任一条汇编语言指令之后正好产生一个“Debug”异常。
相应的异常处理程序只是清掉这个标志,强迫当前进程停止,并发送SIGCHLD信号给父进程。
注意,设置TF标志并不是特权操作,因此用户态进程即使在没有ptrace()系统调用的情况下,也能强迫单步执行。
内核检查进程描述符中的PT_DTRACE标志,以跟踪子进程是否通过ptrace()进行单步执行。
PTRACE_SYSCALL命令使被跟踪的进程重新恢复执行,直到一个系统调用被调用。
进程停止两次,第一次是在系统调用开始时,第二次是在系统调用终止时。
这种跟踪是利用进程描述符中的TIF_SYSCALL_TRACE标志实现的。
这个标志是在进程thread_info 结构的flags字段中,并在system_call()汇编语言的函数中被检查(参见第十章“通过int $O×80指令发出系统调用”一节)。
也可以利用Intel Pentium处理器的一些调试特点来跟踪进程。例如,父进程使用PTRACE_POKEUSR命令为子进程设置dr0,…,dr7调试寄存器的值。
当由某调试寄存器监控的事情发生时,CPU产生“Debug”异常,异常处理程序然后挂起被调试的进程并给父进程发送SIGCHLD信号。
可执行格式
Linux标准的可执行格式是ELF(Executable and Linking Format),它由Unix系统实验室开发并在Unix世界相当流行。
几个著名的Unix操作系统(如System VRelease 4和Sun的Solaris 2)都把ELF作为它们的主要可执行格式。
Linux的旧版支持另一种名叫Assembler OUTput Format(a.out)的格式,实际上,在Unix世界有好几种版本使用这种格式。
因为现在ELF非常实用,因此已经很少用a.out 格式。
Linux支持很多其他不同格式的可执行文件。
在这种方式下,Linux能运行为其他操作系统所编译的程序,如MS-DOS的EXE程序,或BSD Unix的COFF可执行格式。
有几种可执行格式,如Java或bash脚本,是与平台无关的。
由类型为linux_binfmt的对象所描述的可执行格式实质上提供以下三种方法:
load_binary
通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境。
load_shlib
用于动态地把一个共享库捆绑到一个已经在运行的进程,这是由uselib()系统调用激活的。
core_dump
在名为core的文件中存放当前进程的执行上下文。
这个文件通常是在进程接收到一个缺省操作为“dump”的信号时被创建的,
其格式取决于被执行程序的可执行类型(参见第十一章的“传递信号之前所执行的操作”一节)。
所有的linux_binfmt对象都处于一个单向链表中,第一个元素的地址存放在formats 变量中。
可以通过调用register_binfmt()和unregister_binfmt()函数在链表中插入和删除元素。
在系统启动期间,为每个编译进内核的可执行格式都执行register_binfmt()函数。
当实现了一个新的可执行格式的模块正被装载时,也执行这个函数,当模块被卸载时,执行unregister_binfmt()函数。
在formats链表中的最后一个元素总是对解释脚本(interpreted script)的可执行格式进行描述的一个对象。
这种格式只定义了load_binary方法。其相应的load_script()函数检查这种可执行文件是否以两个#!字符开始。
如果是,这个函数就把第一行的其余部分解释为另一个可执行文件的路径名,并把脚本文件名作为参数传递以执行它(注5)。
Linux允许用户注册自己定义的可执行格式。对这种格式的识别或者通过存放在文件前128字节的魔数,或者通过表示文件类型的扩展名。
例如,MS-DOS的扩展名由“.”把三个字符从文件名中分离出来:.exe扩展名标识可执行文件,而.bat扩展名标识shell脚本。
当内核确定可执行文件是自定义格式时,它就启动相应的解释程序(interpreterprogram)。
解释程序运行在用户态,读入可执行文件的路径名作为参数,并执行计算。例如,包含Java程序的可执行文件就由Java虚拟机(如/usr/lib/java/bin/java)来解释。
这种机制与脚本格式类似,但功能更加强大,这是因为它对自定义格式不加任何限制。
要注册一个新格式,就必须在binfmt_misc特殊文件系统(通常在/proc/sys/fs/binfmt_misc)的注册文件内写入一个字符串,其格式如下:
:name:type:offset:string:mask:interpreter;flags
这里,每个字段的含义如下:
name
新格式的标识符。
type
识别类型(M表示魔数,E表示扩展)。
offset
魔数在文件中的起始偏移量。
string
以魔数或者以扩展名匹配的字节序列。
mask
用来屏蔽掉string中的一些位的字符串。
interpreter
解释程序的完整路径名。
flags
可选标志,控制必须怎样调用解释程序。
例如,超级用户执行的下列命令将使内核识别出MicrosoftWindows的可执行格式:
$ echo ·:Doswin:M:0:MZ:0xff:/usr/bin/wine:'>/proc/sys/fs/binfmt_misc/register
Windows可执行文件的前两个字节是魔数MZ,由解释程序/usr/bin/wine执行这个可执行文件。
执行域
在第一章已提到,Linux的一个巧妙的特点就是能执行其他操作系统所编译的程序。
当然,只有内核运行的平台与可执行文件包含的机器代码对应的平台相同时这才是可能的。对这些“外来”程序提供两种支持:
1.模拟执行(emulated execution):程序中包含的系统调用与POSIX不兼容时才有必要执行这种程序。
2.原样执行(native execution):只有程序中所包含的系统调用完全与POSIX兼容时才有效。
Microsoft MS-DOS和Windows程序是被模拟执行的,因为它们包含的API不能被Linux 所认识,因此不能原样执行。
像DOSemu或Wine这样的模拟程序(出现在上一节末尾的例子中)被调用来把每个API调用转换为一个模拟的封装函数调用,
而封装函数调用又使用现有的Linux系统调用。因为模拟程序主要是作为用户态的应用程序来执行,因此我们在此不做进一步的讨论。
另一方面,不用太费力就可以执行为其他操作系统编译的与POSIX兼容的程序,
因为与POSIX兼容的操作系统都提供了类似的API(尽管实际上并不总是这种情况,但API应该相同)。
内核必须消除的细微差别通常涉及如何调用系统调用或如何给各种信号编号。
这种信息存放在类型为exec_domain的执行域描述符(execution domain descriptor)中。
进程可以指定它的执行域,这是通过设置进程描述符的personality字段,
以及把相应exec_domain数据结构的地址存放到thread_info结构的exec_domain字段来实现的。
进程可以通过发布一个叫做personality()的系统调用来改变它的个性(personality);表20-6列出了这个系统调用的参数所接收的典型值。
程序员通常不希望直接改变其程序的个性;相反,应该通过建立进程的执行上下文的“粘合”代码来发出Personality()系统调用(参见下一节)。
exec函数
Unix系统提供了一系列函数,这些函数能用可执行文件所描述的新上下文代替进程的上下文。
这样的函数名以前缀exec开始,后跟一个或两个字母,因此,家族中的一个普通函数被当作exec函数来引用。
每个函数的第一个参数表示被执行文件的路径名。路径名可以是绝对路径或是当前进程目录的相对路径。
此外,如果路径名中不包含“/”字符,execlp()和execvp()函数就在PATH环境变量指定的所有目录中搜索这个可执行文件。
除了第一个参数,execl()、execlp()和execle()函数包含的其他参数个数都是可变的。
每个参数指向一个字符串,这个字符串是对新程序命令行参数的描述,正如函数名中“1”字符所隐含的一样,这些参数组织成一个列表(最后一个值为NULL)。
通常情况下,第一个命令行参数复制可执行文件名。
相反,execv()、execvp()和execve()函数指定单个参数的命令行参数,正如函数名中的“v”字符所隐含的一样,这单个参数是指向命令行参数串的指针向量地址。
数组的最后一个元素必须存放NULL值。
execle()和execve()函数的最后一个参数是指向环境串的指针数组的地址;数组的最后一个元素照样必须为NULL。
其他函数对新程序环境参数的访问是通过C库定义的外部全局变量environ进行的。
所有的exec函数(除execve()外)都是C库定义的封装例程,并利用了execve()系统调用,这是Linux所提供的处理程序执行的唯一系统调用。
sys_execve()服务例程接收下列参数:
1.可执行文件路径名的地址(在用户态地址空间)。
2.以NULL结束的字符串指针数组的地址(在用户态地址空间)。每个字符串表示一个命令行参数。
3.以NULL结束的字符串指针数组的地址(也在用户态地址空间)。每个字符串以NAME=value形式表示一个环境变量。
sys_execve()把可执行文件路径名拷贝到一个新分配的页框。
然后调用do_execve()函数,传递给它的参数为指向这个页框的指针、指针数组的指针及把用户态寄存器内容保存到内核态堆栈的位置。
do_execve()依次执行下列操作:
1. 动态地分配一个linux_binprm数据结构,并用新的可执行文件的数据填充这个结构
2. 调用path_lookup()、dentry_open()和path_release(),以获得与可执行文件相关的目录项对象、文件对象和索引节点对象。
如果失败,则返回相应的错误码。
3. 检查是否可以由当前进程执行该文件,再检查索引节点的i_writecount字段,以确定可执行文件没被写入;
把-1存放在这个字段以禁止进一步的写访问。
4. 在多处理器系统中,调用sched_exec()函数来确定最小负载CPU以执行新程序,并把当前进程转移过去(参见第七章)。
5. 调用init_new_context()检查当前进程是否使用自定义局部描述符表,参见第二章“Linux LDT”一节)。如果是,函数为新程序分配和准备一个新的LDT。
6. 调用prepare_binprm()函数填充linux_binprm数据结构,这个函数又依次执行下列操作:
a.再一次检查文件是否可执行(至少设置一个执行访问权限)。
如果不可执行,则返回错误码(因为带有CAP_DAC_OVERRIDE权能的进程总能通过检查,
所以第3步中的检查还不够。参见本章前面“进程的信任状和权能”一节)。
b.初始化linux_binprm结构的e_uid和e_gid字段,考虑可执行文件的setuid和setgid标志的值。
这些字段分别表示有效的用户ID和组ID。也要检查进程的权能(在本章前面的“进程的信任状和权能”一节中介绍了兼容性技巧)。
c.用可执行文件的前128字节填充linux_binprm结构的buf字段。这些字节包含的是适合于识别可执行文件格式的一个魔数和其他信息。
7. 把文件路径名、命令行参数及环境串拷贝到一个或多个新分配的页框中(最终,它们会被分配给用户态地址空间)。
8. 调用search_binary_handler()函数对formats链表进行扫描,并尽力应用每个元素的load_binary方法,把linux_binprm数据结构传递给这个函数。
只要load_binary方法成功应答了文件的可执行格式,对formats的扫描就终止。
9. 如果可执行文件格式不在formats链表中,就释放所分配的所有页框并返回错误码-ENOEXEC,表示Linux不认识这个可执行文件格式。
10.否则,函数释放linux_binprm数据结构,返回从这个文件可执行格式的load_binary方法中所获得的代码。
可执行文件格式对应的load_binary方法执行下列操作(我们假定这个可执行文件所在的文件系统允许文件进行内存映射并需要一个或多个共享库):
1.检查存放在文件前128字节中的一些魔数以确认可执行格式。如果魔数不匹配,则返回错误码-ENOEXEC。
2.读可执行文件的首部。这个首部描述程序的段和所需的共享库。
3.从可执行文件获得动态链接程序的路径名,并用它来确定共享库的位置并把它们映射到内存。
4.获得动态链接程序的目录项对象(也就获得了索引节点对象和文件对象)。
5.检查动态链接程序的执行许可权。
6.把动态链接程序的前128字节拷贝到缓冲区。
7.对动态链接程序类型执行一些一致性检查。
8.调用flush_old_exec()函数释放前一个计算所占用的几乎所有资源。这个函数又依次执行下列操作:
a.如果信号处理程序的表为其他进程所共享,那么就分配一个新表并把旧表的引用计数器减1;
而且它将进程从旧的线程组脱离(参见第三章“标识一个进程”一节)。这是通过调用de_thread()函数完成的。
b. 如果与其他进程共享,就调用unshare_files()函数拷贝一份包含进程已打开文件的files_struct结构。
c.调用exec_mmap()函数释放分配给进程的内存描述符、所有线性区及所有页框,并清除进程的页表。
d. 将可执行文件路径名赋给进程描述符的comm字段。
e.调用flush_thread()函数清除浮点寄存器的值和在TSS段保存的调试寄存器的值。
f.调用flush_signal_handlers()函数,用于将每个信号恢复为默认操作,从而更新信号处理程序的表。
g.调用flush_old_files()函数关闭所有打开的文件,
这些打开的文件在进程描述符的files->close_on_exec字段设置了相应的标志(参见第十二章中的“与进程相关的文件”一节)(注6)。
现在,我们已经不能返回了:如果真出了差错,这个函数再不能恢复前一个计算。
9.清除进程描述符的PF_FORKNOEXEC标志。这个标志用于在进程创建时设置进程记账,在执行一个新程序时清除进程记账。
10.设立进程新的个性,即设置进程描述符的personality字段
11.调用arch_pick_mmap_layout(),以选择进程线性区的布局(参见本章前面“程序段与进程的线性”一节)。
12.调用setup_arg_pages()函数为进程的用户态堆栈分配一个新的线性区描述符,并把那个线性区插入到进程的地址空间。
setup_arg_pages()还把命令行参数和环境变量串所在的页框分配给新的线性区。
13.调用do_mmap()函数创建一个新线性区来对可执行文件正文段(即代码)进行映射。
这个线性区的起始线性地址依赖于可执行文件的格式,因为程序的可执行代码通常是不可重定位的。
因此,这个函数假定从某一特定逻辑地址的偏移量开始(因此就从某一特定的线性地址开始)装入正文段。
ELF程序被装入的起始线性地址为0x08048000。
14.调用do_mmap()函数创建一个新线性区来对可执行文件的数据段进行映射。
这个线性区的起始线性地址也依赖于可执行文件的格式,因为可执行代码希望在特定的偏移量(即特定的线性地址)处找到它自己的变量。
在ELF程序中,数据段正好被装在正文段之后。
15.为可执行文件的其他专用段分配另外的线性区,通常是无。
16.调用一个装入动态链接程序的函数。如果动态链接程序是ELF可执行的,这个函数就叫做load_elf_interp()。
一般情况下,这个函数执行第12~14步的操作,不过要用动态链接程序代替被执行的文件。
动态链接程序的正文段和数据段在线性区的起始线性地址是由动态链接程序本身指定的;
但它们处于高地址区(通常高于0x40000000),这是为了避免与被执行文件的正文段和数据段所映射的线性区发生冲突(参见前面的“程序段和进程的线性区”一节)。
17.把可执行格式的linux_binfmt对象的地址存放在进程描述符的binfmt字段中。
18. 确定进程的新权能。
19.创建特定的动态链接程序表并把它们存放在用户态堆栈,如图20-1所示,这些表处于命令行参数和指向环境串的指针数组之间。
20.设置进程的内存描述符的start_code、end_code、start_data 、end_data、start_brk、brk及start_stack字段。
21.调用do_brk()函数创建一个新的匿名线性区来映射程序的bss段(当进程写入一个变量时,就触发请求调页,进而分配一个页框)。
这个线性区的大小是在可执行程序被链接时就计算出来的。因为程序的可执行代码通常是不可重新定位的,
因此,必须指定这个线性区的起始线性地址。在ELF程序中,bss段正好装在数据段之后。
22.调用start_thread()宏修改保存在内核态堆栈但属于用户态寄存器的eip和esp的值,以使它们分别指向动态链接程序的入口点和新的用户态堆栈的栈顶。
23.如果进程正被跟踪,就通知调试程序execve()系统调用已完成。
24 返回0值(成功)。
当execve()系统调用终止且调用进程重新恢复它在用户态的执行时,执行上下文被大幅度改变,调用系统调用的代码不复存在。
从这个意义上看,我们可以说execve()从未成功返回。取而代之的是,要执行的新程序已被映射到进程的地址空间。
但是,新程序还不能执行,因为动态链接程序还必须考虑共享库的装载(注7)。
尽管动态链接程序运行在用户态,但我们还要在这里简要概述一下它是如何运作的。
它的第一个工作就是从内核保存在用户态堆栈的信息(处于环境串指针数组和arg_start 之间)开始,为自己建立一个基本的执行上下文。
然后,动态链接程序必须检查被执行的程序,以识别哪个共享库必须装入及在每个共享库中哪个函数被有效地请求。
接下来,解释器发出几个mmap()系统调用来创建线性区,以对将存放程序实际使用的库函数(正文和数据)的页进行映射。
然后,解释器根据库的线性区的线性地址更新对共享库符号的所有引用。
最后,动态链接程序通过跳转到被执行程序的主入口点而终止它的执行。从现在开始,进程将执行可执行文件的代码和共享库的代码。
你可能已注意到,执行程序是一个相当复杂的活动,它涉及内核设计的很多方面,
如进程抽象、内存管理、系统调用及文件系统。这会使你认识到:Linux真是一个杰作!