进程的概念在UNIX中的表现就是一组程序竞争系统资源的行为。
内核是如何通过程序文件的内容建立进程的执行上下文?
一个程序执行的上下文到底是什么呢?
虽然将一组指令装入内存让 CPU 执行看起来不是大问题,但内核还必须处理以下几方面的问题:
不同的可执行文件格式
。Linux 可在 64 位版本的机器上执行 32 位可执行代码。共享库
。很多可执行文件并不包含执行程序所需的所有代码,而是期望内核在运行时从共享库中加载函数。执行上下文的其它信息
。这包括命令行参数
与环境变量
。程序是以可执行文件(executable file)
的形式存放在磁盘上的,可执行文件
既包括被执行函数的目标代码
,也包括这些函数所使用的数据
。
程序中的很多函数是所有程序员都可使用的服务例程,它们的目标代码包含在所谓“库”的特殊文件中:
静态地拷贝到可执行文件
(静态库
)运行时被连接到进程
(共享库
,因为它们的代码由很多独立的进程所共享)。当装入并运行一个程序时,用户可以提供影响程序执行方式的两种信息:
命令行参数
:用户在shell提示符下紧跟文件名输入的就是命令行参数。环境变量
:环境变量(例如HOME和PATH)是从shell继承来的,但用户在装入并运行程序前可以修改任何环境变量。接下来各部分的内容
可执行文件:解释一个程序的执行上下文是什么。
可执行格式:提及一些 Linux 所支持的可执行格式,并说明 Linux 如果改变它的“个性”以执行其它操作系统所编译的程序。
exec 函数:描述执行一个新程序的进程所需的系统调用。
进程可以定义为执行上下文。这也意味着进行特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容等。可执行文件
是一个普通文件,描述了如何初始化一个新的执行上下文,也就是如何开始一个新的计算。
假如一位用户想在当前目录下面显示文件(我们知道用ls命令就可以),用户在shell提示符下只需要简单的敲出外部命令/bin/ls
就可以了。这时候具体会发生什么呢?
当一个进程开始新的程序的时候,它的执行上下文会发生很大的变化。因为进程的前一个计算执行期间所获得的大部分资源会被抛弃,但是进程的 PID 不改变,并且新的计算从前一个计算继承所有打开的文件描述符。
进程的信任状存放在进程描述符的几个字段中:
1 | uid,gid 用户和组的实际标识符 |
说明:
euid(effective user ID)
:当进程执行时间, 操作系统会对euid进行识别, 以此来判断到底用什么权限来执行这个进程
.当一个进程被创建时,总是继承父进程的信任状。
进程执行setuid程序
时,即可执行文件的setuid标志被设置时,euid和fsuid字段被置为这个文件拥有者的标识符。几乎所有的检查都涉及这两个字段中的一个:fsuid
用于与文件相关的操作,而euid
用于其他所有的操作。这也同样适用于组标识符的gid、egid、fsgid及sgid字段。考虑一下当用户想改变口令时的情况。所有的口令都存放在一个公共文件中,但用户不能直接编辑这样的文件,因为它是受保护的。因此使用如下操作:
从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()系统调用的效果。
超级用户进程因此就可以删除自己的特权而变为由普通用户拥有的一个进程。
这个在linux中不怎么用。
“权能(capability )”一词引人进程信任状的另一种模型。Linux内核支持POSIX权能,一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作。这个模型不同于传统的“超级用户VS普通用户”模型,在后一种模型中,一个进程要么能做任何事情,要么什么也不能做,这取决于它的有效UID。
在Linux内核中已包含了很多权能。权能的主要优点是,任何时候每个进程只需要有限种权能。因此,即使有恶意的用户发现一种利用有潜在错误的程序的方法,他也只能非法地执行有限个操作类型。
当用户键入一个命令时,为满足这个请求而装入的程序可以从shell接收一些命令行参数(command-line argument)
。
例如:
在C语言中,程序的main()函数把传递给程序的参数个数和指向字符串指针数组的地址作为参数。下列原型形式化地表示了这种标准格式:
1 | int main(int argc,char *argv[]) |
回到前面的例子:
包含环境变量的参数
。环境变量
用来定制进程的执行上下文,由此为用户或其他进程提供通用的信息,或者允许进程在执行execve()系统调用的过程中保持一些信息。
为了使用环境变量,main ()可以声明如下:
1 | int main(int argc,char *argv(),char *envp[]) |
envp参数
指向环境串的指针数组,形式如下:
1 | VAR_NAME=something |
说明:
envp数组的地址
存放在C库的environ全局变量
中。环境串
都存放在用户态堆栈
中,正好位于返回地址之前。下图显示了用户态堆栈的底部单元。环境变量位于栈底附近正好在一个长整数0(即图中的NULL)之后。
每个高级语言的源码文件都是经过几个步骤才转化为目标文件的(.o文件),目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。
例如下面只有一行的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段)
堆栈段
每个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_binfmt的对象
所描述的可执行格式
实质上提供以下三种方法:
load_binary
通过读存放在可执行文件中的信息为当前进程建立一个新的执行环境。load_shlib
用于动态地把一个共享库捆绑到一个已经在运行的进程,这是由uselib()系统调用
激活的。core_dump
在名为core
的文件中存放当前进程的执行上下文。这个文件通常在进程接收到缺省操作为“dump”的信号时被创建,格式取决于被执行程序的可执行类型。所有的linux binfmt对象
都处于一个单向链表中。
register_binfmt()
和unregister_binfmt()
函数在链表中插入和删除元素。register_binfmt()
函数。unregister_binfmt()
函数。这种格式只定义了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 | name: 新格式的标识符。 |
例如,超级用户执行的下列命令将使内核识别出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()系统调用。//待定
灵活线性区布局(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()系统调用实现),内核无法确定用户态堆栈的上限,就仍然使用经典线性区布局。
引入灵活布局的主要优点在于:可以允许进程更好地使用用户态线性地址空间。
深入理解Linux内核中文版(第三版)
linux内核学习之七 可执行程序的装载和运行
Linux中的setuid简介
进程的虚拟内存,物理内存,共享内存
进程虚拟内存空间—线性区的数据结构
linux下的三种可执行文件格式的比较
linux 如何运行一个可执行文件
深入理解Linux内核(完整版)-笔记
Update your browser to view this website correctly. Update my browser now