深入理解 Linux 内核—程序的执行

释放双眼,带上耳机,听听看~!

尽管把一组指令装入内存并让 CPU 执行看起来不是大问题,但内核还必须灵活处理以下几方面的问题:

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

程序是以可执行文件的形式存放在磁盘上的,可执行文件既包括被执行函数的目标代码,也包括这些函数所使用的数据。
程序中的很多函数是可使用的服务例程,它们的目标代码包含在所谓“库”的特殊文件中。
实际上,一个库函数的代码或被静态地拷贝到可执行文件中(静态库),或在运行时被连接到进程(共享库,因为它们的代码由很多独立的进程共享)。

当装入并运行一个程序时,用户可以提供影响程序执行的方式的两种信息:命令行参数和环境变量。

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

可执行文件

进程被定义为“执行上下文”,意味着特定的计算需要收集必要的信息,包括所访问的页,打开的文件,硬件寄存器的内容。
可执行文件是一个普通文件,它描述了如何初始化一个新的执行上下文,即如何开始一个新的计算。
进程开始执行一个新程序时,其执行上下文变化较大,因为进程的前一个计算执行期间所获得的大部分资源会被抛弃。
但进程的 PID 不改变,并且新的计算从前一个计算继承所有打开的文件描述符。

进程的信任状和权能

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

信任状的使用既需要在进程的数据结构方面给予支持,也需要在被包含的资源方面给与支持。

进程的信任状存放在进程描述符的几个字段中。
值为 0 的 UID 指定给 root 超级用户,值为 0 的 GID 指定给 root 超级组。3
3
只要有关进程的信任状存放了一个零值,则内核将放弃权限检查,始终运行该进程做任何事情。

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

Linux 让进程只有在必要时才获得 setuid 特权,并在不需要时取消它们。
进程描述符包含一个 suid 字段,在 setuid 程序执行以后在该字段中正好存放有效标识符(euid 和 fsuid)的值。
进程可以通过 setuid()、setresuid()、setfsuid() 和 setreuid() 以改变有效标识符。
setuid() 调用的效果取决于调用者进程的 euid 字段是否被置为 0(即进程有超级用户特权)或被设置为一个正常的 UID。
如果 euid 字段为 0,setuid() 就把调用进程的所有信任状字段(uid、euid、fsuid 及 suid)置为参数 e 的值。
超级用户进程因此就可以删除自己的特权而变为普通用户拥有的一个进程。

如果 euid 字段不为 0,则 setuid() 只修改存放在 euid 和 fsuid 中的值,让其它两个字段保持不变。
当运行 setuid 程序来提高和降低进程有效权限时(这些权限存放在 euid 和 fsuid 字段),setuid() 非常有用。

进程的权能

一种权能仅仅是一个标志,它表明是否允许进程执行一个特定的操作或一组特定的操作。

权能的主要优点是,任何时候每个进程只需要有限种权能。
因此,即使有恶意的用户发现一种利用有潜在错误的程序的方法,也智能非法地执行有限个操作类型。

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

在 Linux 内核定义了一个名为 CAP_SYS_NICE 的权能,可检测调用进程描述符的 euid 字段是否为 0。
内核通过调用 capable() 并把 CAP_SYS_NICE 值传给该函数来检查该标志的值。

每当一个进程把 euid 和 fsuid 字段设置为 0 时,内核就设置进程的所有权能,以便所有的检查成功。
当进程把 euid 和 fsuid 字段重新设置为进程拥有者的实际 UID 时,内核检查进程描述符种的 keep_capabilities 标志,
并在该标志设置时删除进程的所有权能。
进程可调用 Linux 专有的 prctl() 来设置和重新设置 keep_capabilities 标志。

Linux 安全模块框架

在 Linux 2.6 中,权能是与 Linux 安全模块(LSM)框架紧密结合在一起的。
LSM 框架允许定义几种可选择的内核安全模型。

每个安全模型是由一组安全钩实现的。
安全钩是由内核调用的一个函数,用于执行与安全有关的重要操作。
钩函数决定一个操作是否可以执行。

钩函数存放在 security_operations 类型的表中。
当前使用的安全模型钩表地址存放在 security_ops 变量中。
内核默认使用 dummy_security_ops 表实现最小安全模型。
表中的每个钩函数实际上检查相应的权能是否允许,否则无条件返回 0(允许操作)。

命令含参数和 shell 环境

当用户键入一个命令时,为满足该请求而装入的程序可从 shell 接收一些命令行参数。
如当用户键入命令,以获得在 /usr/bin 目录下的全部文件列表时,shell 进程创建一个新进程执行该命令。
该新进程装入 /bin/ls 可执行文件。
该过程中,从 shell 继承的大多数执行上下文被丢弃,但三个单独的参数 ls、-l 和 /usr/bin 依然保持。
一般情况下,新进程可接收任意多个参数。

传递命令行参数的约定依赖于所用的高级语言。
在 C 语言中,程序的 main() 把传递给程序的参数个数和指向字符串指针数组的地址作为参数。
下列原型形式化地表示了该标准格式:


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

当 /bin/ls 被调用时,argc 的值为 3,argv[0] 指向 ls 字符串,argv[1] 指向 -l 字符串,argv[2] 指向 /usr/bin 字符串。
argv 数组的末尾处总以空格来标记,因此,argv[3] 为 NULL。

在 C 语言中,传递给 main() 的第三个可选参数是包含环境变量的参数。
环境变量用来定制进程的执行上下文,由此为用户或其它进程提供通用的信息,或者允许进程在执行 execve() 的过程中保持一些信息。

为了使用环境变量,main() 可声明如下:


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

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

VAR_NAME = something

VAR_NAME 表示一个环境变量的名字,“=”后面的子串表示赋给变量的实际值。
envp 数组的结尾用一个空指针标记,就像 argv 数组。
envp 数组的地址存放在 C 库的 environ 全局变量中。

命令行参数和环境串都存放在用户态堆栈中,正好位于返回地址之前。
环境变量位于栈底附近正好在一个 0 长整数之后。

每个高级语言的源码文件都是经过几个步骤才转化为目标文件的,目标文件中包含的是汇编语言指令的机器代码,它们和相应的高级语言指令对应。
目标文件并不能被执行,因为它不包含源代码文件所用的全局外部符号名的线性地址。
这些地址的分配或解析是由链接程序完成的,链接程序把程序所有的目标文件收集起来并构造可执行文件。
链接程序还分析程序所用的库函数,并把它们粘合成可执行文件。

大多数程序,甚至是最小的程序都会利用 C 库。如下列 C 程序:


1
2
3
1void main(void){}
2
3

需要做很多工作来建立执行环境,并在程序终止时杀死该进程,尤其当 main() 终止时,C 编译程序把 exit_group() 函数插入到目标代码中。

程序通常通过 C 库中的封装例程调用系统调用。C 编译器亦如此。
任何可执行文件除了包括对程序的语句进行编译所直接产生的代码外,还包括一些“粘合”代码来处理用户态进程与内核之间的交互。
这样的粘合代码有一部分存放在 C 库中。

除了 C 库,Unix 系统中还包含很多其它的库函数。
这就意味着链接程序所产生的可执行文件不仅包括源程序的代码,还包括程序所引用的库函数的代码。
静态库的一大缺点是:它们占用大量的磁盘空间。
的确,每个静态链接可执行文件都复制库代码的某些部分。

现代 Unix 系统利用共享库。
可执行文件不再包含库的目标代码,而仅仅指向库名。
当程序被装入内存执行时,一个名为动态连接器的程序就专注于分析可执行文件中的库名,确定所需库在系统目录树中的位置,并使执行进程可使用所请求的代码。
进程也可以调用 dlopen() 库函数在运行时装入额外的共享库。

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

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

用户可以始终请求一个程序被静态地链接。

程序段和进程的线性区

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

  • 正文段,包含程序的可执行代码。
  • 已初始化数据段,包含已初始化的数据,也就是初值存放在可执行文件中的所有静态变量和全局变量。
  • 未初始化数据段(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,环境串所在的堆栈部分的起始地址和终止地址。

灵活线性区布局

每个进程按照用户态堆栈预期的增长量来进行内存布局。
但当内核无法限制用户态堆栈的大小时,仍然可以使用老的经典布局。
80×86 中默认的用户态地址空间最大可以到 3 GB。

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

当内核能通过 RLIMIT_STACK 资源限制来限定用户态堆栈的大小时,通常使用灵活布局。
该限制确定了为堆栈保留的线性地址空间大小。
该空间不能小于 128MB 或大于 2.5GB。

另外,如果 RLIMIT_STACK 资源限制设为无限,或系统管理员将 sysctl_legacy_va_layout 变量设为 1(通过修改 /proc/sys/vm/legacy_va_layout 文件或调用相应的 sysctl() 实现),内核无法确定用户态堆栈的上下,就仍然使用经典线性布局。

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

执行跟踪

执行跟踪是一个程序监视另一个程序执行的一种技术。
被跟踪的程序一步一步地执行,直到接收到一个信号或调用一个系统调用。
执行跟踪由调试程序广泛使用。

在 Linux 中,通过 ptrace() 进行执行跟踪。
设置了 CAP_SYS_PTRACE 权 能的进程可以跟踪系统中的任何进程。
相反,没有 CAP_SYS_PTRACE 权能的进程 P 只能跟踪与 P 有相同属主的进程。
此外,两个进程不能跟踪同一进程。

ptrace() 修改被跟踪进程描述符的 parent 字段以使它指向跟踪进程,因此,跟踪进程变为被跟踪进程的有效父进程。
当执行跟踪终止时,即当以 PTRACE_DETACH 命令调用 ptrace() 时,该系统调用把 p_pptr 设置为 real_parent 的值,
恢复被跟踪进程原来的父进程。

与被跟踪程序相关的几个监控事件:

  • 一条单独汇编指令执行的结束。
  • 进入系统调用。
  • 退出系统调用。
  • 接收到一个信号。

当一个监控的事件发生时,被跟踪的程序停止,并且将 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() 汇编语言函数中检查。

可执行格式

Linux 标志的可执行格式是 ELF(Executable and Linking Format)。

有几种可执行格式与平台无关,如 bash 脚本。
类型为 linux_binfmt 的对象所描述的可执行格式实质上提供以下三种方法:

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

信号时被创建,其格式取决于被执行程序的可执行类型。

所有的 linux_binfmt 对象都处于一个单向链表中,第一个元素的地址存放在 formats 变量中。
可通过调用 register_binfmt() 和 unregister_binfmt() 在链表中插入和删除元素。
在系统启动期间,为每个编译进程可执行的模块正被装载时,也执行该函数,当模块被卸载时,执行 unregister_binfmt()。

在 formats 链表中的最后一个元素总是对解释脚本的可执行格式进行描述的一个对象。
这种格式只定义了 load_binary 方法。
相应的 load_script() 函数检查这种可执行文件是否以 #! 字符开始。
如果是,该函数就把第一行的其余部分解释为另一个可执行文件的路径名,并把普通脚本文件名作为参数传递以执行它。

Linux 允许用户注册自己定义的可执行格式。
对这种格式的失败或者通过文件前 128 字节的魔数,或者通过表示文件类型的扩展名。
如 MS-DOS 的扩展名由“.” 把三个字符从文件名中分离出来:
.exe 标识扩展名标识可执行文件,而 .bat 扩展名标识 shell 脚本。

当内核确定可执行文件是自定义格式时,它就启动相应的解释程序。
解释程序运行在用户态,读入可执行文件的路径名作为参数,并执行计算。

这种机制与脚本格式类似,但功能更强大,因为它对自定义格式不加任何限制。
要注册一个新格式,就必须在 binfmt_misc 特殊文件系统(通常 /proc/sys/fs/binfmt_misc)的注册文件中写入一个字符串,格式如下:

:name:type:offset:string?interpreter:flags

每个字段含义如下”

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

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

$echo ‘DOSWin:M:0:MZ:0xff:/usr/bin/win:’ > /proc/sys/fs/binfmt_mis/register

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

执行域

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

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

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

另一方面,不用太费力就可以执行其它操作系统编译的与 POSIX 兼容的程序,因为与 POSIX 兼容的操作系统都提供了类似 API。
内核必须消除的细微差别通常涉及如何调用系统调用或如何给各种信号编号。
这种类型存放在类型为 exec_domain 的执行域描述符中。

进程可以指定它的执行域,这是通过设置进程描述符的 personality 字段,以及把相应 exec_domain 的地址存放到 thread_info 的 exec_domain 字段来实现。
进程可通过发布 personality() 来改变它的个性。

exec 函数

以前缀 exec 开始的函数能用可执行文件所描述的新上下文代替进程的上下文。

每个函数的第一个参数表示被执行文件的路径名。
如果路径吗不包含”/“字符,execlp() 和 execvp() 就在 PATH 环境变量所指定的所有目录中搜索该可执行文件。

除了第一个参数,execl()、execlp() 和 execle() 包含的其它参数格式都是可变的。
每个参数指向一个字符串,该字符串是对新程序命令行参数的描述,正如 函数名中“l”字符所隐含的那样,这些参数组织成一个列表(最后一个值为 NULL)。
通常,第一个命令行参数复制可指向文件名。
相反,execv()、execvp() 和 execve() 指定单个参数的命令行参数,正如函数名中“v”字符所隐含的那样,该单个参数是指向命令行参数串的指针向量地址。
数组的最后一个元素就必须存放 NULL 值。

execle() 和 execve() 的最后一个参数是指向环境串的指针数组的地址;
数组的最后一个元素照样必须是 NULL。
其它函数对新程序环境参数的访问是通过 C 库定义的外部全局变量 environ 进行的。

所有的 exec 函数(除 execve() 外)都是 C 库定义的封装函数例程,并利用了 execve(),这是 Linux 所提供的
处理程序执行的唯一系统调用。

sys_execve() 服务例程的参数:

  • 可执行文件路径名的地址(在用户态地址空间)。
  • 以 NULL 结束的字符串指针数组的地址(在用户态地址空间)。每个字符串表示一个命令行参数。
  • 以 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() 检查当前进程是否使用自定义局部描述符表。如果是,函数为新程序分配和准备一个新的 LDT。
  6. 调用 prepare_binprm() 函数填充 linux_binprm 数据结构,该函数又依次执行下列操作:

a. 再一次检查文件是否可执行(只是设置一个执行访问权限)。如果不可执行,则返回错误码。
b. 初始化 linux_binprm 结构的 e_uid 和 e_gid 字段,考虑可执行文件的 setuid 和 setgid 标志的值。
这些字段分别表示有效的用户 ID 和组 ID。也要检查进程的权能。
c. 用可执行文件的前 128 字节填充 linux_binprm 结构的 buf 字段。
这些字节包含的是适合于可执行文件格式的一个魔数和其它信息。

  1. 把文件名、命令行参数及环境串拷贝到一个或多个新分配的页框中(最终,它们会被分配给用户态地址空间的)。
  2. 调用 search_binary_handler() 对 formats 链表进行扫描,并尽力应用每个元素的 load_binary 方法,把 linux_binprm

传递给该函数。只要 load_binary 方法成功应答了文件的可执行格式,对 formats 的扫描就终止。

  1. 如果可执行文件格式不在 formats 链表中,就释放所分配的所有页框并返回错误码 -ENOEXEC,表示 Linux 不认识该可执行文件格式。
  2. 否则,函数释放 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 字段设置了
相应的标志。现在,已经不能返回了:如果真出了差错,该函数不能恢复前一个计算。

  1. 清除进程描述符的 PF_FORKNOEXEC 标志。该标志用于在进程创建时设置进程记账,在执行一个新程序时清除进程记账。
  2. 建立进程新的个性,即设置进程描述符的 personlity 字段。
  3. 调用 arch_pick_mmap_layout(),以选择进程线性区的布局。
  4. 调用 setup_arg_pages() 为进程的用户态堆栈分配一个新的线性区描述符,并把该线性区插入到进程的地址空间。

setup_arg_pages() 还把命令行参数和环境变量串所在的页框分配给新的线性区。

  1. 调用 do_mmap() 创建一个新线性区来对可执行文件正文段(即代码)进行映射。

该线性区的起始地址依赖于可执行文件的格式,因为程序的可执行代码通常是不可重定位的。
因此,该函数假定从某一特定逻辑地址的偏移量开始(因此就从某一特定的线性地址开始)装入正文段。
ELF 程序被装入的起始线性地址为 0x0804800。

  1. 调用 do_mmap() 创建一个新线性区来对可执行文件的数据进行映射。

该线性区的起始线性地址也依赖于可执行文件的格式,因为可执行代码希望在特定的偏移量(即特定的线性地址)处找到自己的变量。在 ELF 程序中,数据段正好装在正文段之后。

  1. 为可执行文件的其它专用段分配另外的线性区,通常是无。
  2. 调用一个装入动态链接程序的函数。如果动态链接程序是 ELF 可执行的,该函数就叫做 load_elf_interp()。

一般情况下,该函数执行第 12 ~ 14 步的操作,不过要用动态链接程序代替被执行的文件。
动态链接程序的正文段和数据段在线性区的起始地址是由动态链接程序本身指定的;但它们处于高地址区(通常高于 0x40000000),这是为了避免与被执行文件的正文段和数据段所映射的线性区发生冲突。

  1. 把可执行格式的 linux_binfmt 对象的地址存放在进程描述符的 binfmt 字段中。
  2. 确定进程的新权能。
  3. 创建特定的动态链接程序表示并把它们存放在用户态堆栈,这些表处于命令行参数和指向环境串的指针数组之间。
  4. 设置进程的内存描述符的 start_code、end_code、start_data、end_data、start_brk、brk 及 sstart_stack 字段。
  5. 调用 do_brk() 创建一个新的匿名线性区来映射程序的 bss 段(当进程写入一个变量时,就触发请求调页,进而分配一个

页框)。该线性区的大小是在可执行程序被链接时就计算出来的。
因为程序的可执行代码通常是不可重新定位的,因此,必须指定该线性区的起始线性地址。
在 ELF 程序中,bss 段正好装在数据段之后。

  1. 调用 start_thread() 宏修改保存在内核态堆栈但属于用户态寄存器的 eip 和 esp 的值,以使它们分别指向动态链接

程序的入口点和新的用态堆栈的栈顶。

  1. 如果进程正被跟踪,就通知调试程序 execve() 已完成。
  2. 返回 0 值(成功)。

当 execve() 终止且调用进程重新恢复它在用户态的执行时,执行上下文被大幅度改变,调用系统调用的代码不复存在。
从这个意义上看,execve() 从未成功返回。取而代之的是,要执行的新程序已被映射到进程的地址空间。

但是,新程序还不能执行,因为动态链接程序还必须考虑共享库的装载。

动态链接程序运行在用户态,其运作方式:
第一个工作是从内核保存在用户态堆栈的信息(处于环境串指针数组和 arg_start 之间)开始,为自己建立一个基本的执行上下文。
然后,动态链接程序必须检查被执行的程序,以识别哪个共享库必须装入及在每个共享库中哪个函数被有效地请求。
接下来,解释器发出几个 mmap() 来创建线性区,以对将存放程序实际使用的库函数(正文和数据)的页进行映射。
然后,解释器根据库的线性区的线性地址更新对共享库符号的所有引用。
最后,动态链接程序通过跳转到被执行程序的主入口点而终止它的执行。
从现在开始,进程将执行可执行文件的代码和共享库的代码。

给TA打赏
共{{data.count}}人
人已打赏
安全运维

WordPress网站专用docker容器环境带Waf

2020-7-18 20:04:44

安全运维

运维安全-Gitlab管理员权限安全思考

2021-9-19 9:16:14

个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索