Linux 0.11启动过程分析(一)
Linux 0.11 fork 函数(二)
Linux0.11 缺页处理(三)
Linux0.11 根文件系统挂载(四)
Linux0.11 文件打开open函数(五)
Linux0.11 execve函数(六)
Linux0.11 80X86知识(七)
Linux0.11 内核体系结构(八)
当一个程序使用 fork 函数创建了一个子进程时,通常会在子进程中调用 exec() 簇函数之一以加载执行另一个新程序。此时子进程的代码、数据段(包括堆、栈内容)将完全被新程序的替换掉,并在子进程中开始执行新程序。execve 函数的主要功能为:
在 execve() 执行过程中,系统会清掉 fork() 复制的原程序的页目录和页表项,并释放对应页面。系统仅为新加载的程序代码重新设置进程数据结构中的信息,申请和映射了命令行参数和环境参数块所占的内存页面,以及设置了执行代码执行点。此时内核并不从执行文件所在块设备上加载程序的代码和数据。当该过程返回时即开始执行新的程序,但一开始执行肯定会引起缺页异常中断发生。因为代码和数据还未被从块设备上读入内存。此时缺页异常处理程序会根据引起异常的线性地址在主内存区为新程序申请内存页面(内存帧),并从块设备上读入引起异常的指定页面。同时还为该线性地址设置对应的页目录项和页表项。这种加载执行文件的方法称为需求加载(Load on demand)。
另外,由于新程序是在子进程中执行,所以该子进程就是新程序的进程。新程序的进程ID就是该子进程的进程ID。同样,该子进程的属性也就成为了新程序进程的属性。而对于已打开文件的处理则与每个文件描述符的执行时关闭(close on exec)标志有关。进程中每个打开的文件描述符都有一个执行时关闭标志。在进程控制结构中是使用一个无符号长整数 close_on_exec 来表示的。它的每个比特位表示对应每个文件描述符的该标志。若一个文件描述符在 close_on_exec 中的对应比特位被设置,那么执行 execve() 时该描述符将被关闭,否则该描述符将始终处于打开状态。除非我们使用了文件控制函数 fcntl 特别地设置了该标志,否则内核默认操作在 execve 执行后仍然保持描述符的打开状态。
execve 函数有大量对命令行参数和环境空间的处理操作,参数和环境空间共可有MAX_ARG_PAGES 个页面,总长度可达 128KB 字节。在该空间中存放数据的方式类似于堆栈操作,即从假设的 128KB 空间末端处逆向开始存放参数或环境变量字符串的。在初始时,程序定义了一个指向该空间末端(128KB-4 字节)处空间内偏移值 p ,该偏移值随着存放数据的增多后退,下图中可以看出,p 明确地指出了当前参数环境空间还剩余多少可用空间。copy_string 函数用于从用户内存空间拷贝命令行参数和环境字符串到内核空闲页面中。
在执行完 copy_string 函数之后,再通过执行第333行语句,p 将被调整为从进程逻辑地址空间开始处算起的参数和环境变量起始处指针,见下图中所示的 。方法是把一个进程占用的最大逻辑空间长度 64M 减去参数和环境变量占用的长度(128KB - p)。接下来部分还将使用 create_tables 函数来存放参数和环境变量的一个指针表,并且将再次向左调整为指向指针表的起始位置处。再把所得指针进行页面对齐,最终得到初始堆栈指针 sp 。
create_tables 函数用于根据给定的当前堆栈指针值 p 以及参数变量个数值 argc 和环境变量个数 envc,在新的程序堆栈中创建环境和参数变量指针表,并返回此时的堆栈指针值,再把该指针进行页面对齐处理,最终得到初始堆栈指针 sp。创建完毕后堆栈指针表的形式如下:
函数 do_execve() 最后返回时会把原调用系统中断程序在堆栈上的代码指针 eip 替换为指向新执行程序的入口点,并将栈指针替换为新执行文件的栈指针 esp 。此后这次系统调用的返回指令最终会弹出这些栈中数据,并使得CPU去执行新执行文件。这个过程如下图所示。图中左半部分是进程逻辑 64MB 的空间还包含原执行程序时的情况;右半部分是释放了原执行程序代码和数据并且更新了堆栈和代码指针时的情况。其中阴影(彩色)部分中包含代码或数据信息。进程任务结构中的 start_code 是CPU线性空间中地址,其余几个变量值均是进程逻辑空间中的地址。
main.c 中 init 函数调用了 execve 函数。
void init(void) {
// ...
execve("/bin/sh", argv_rc, envp_rc);
// ...
}
execve也是一个系统调用,其响应函数定义在 kernel/system_call.s 中 200 行处为 sys_execve 。
.align 2
sys_execve:
lea EIP(%esp),%eax
pushl %eax
call do_execve
addl $4,%esp
ret
m_inode 描述了 i 节点信息
struct m_inode {
unsigned short i_mode; // 文件类型和属性(rwx位)
unsigned short i_uid; // 用户id(文件拥有者标识符)
unsigned long i_size; // 文件大小(字节数)
unsigned long i_mtime; // 修改时间(自1970.1.1:0算起,单位秒)
unsigned char i_gid; // 组 id(文件拥有者所在的组)
unsigned char i_nlinks; // 文件目录项链接数
unsigned short i_zone[9]; // 直接(0-6)、间接(7)或双重间接(8)逻辑块号
/* these are in memory also */
struct task_struct * i_wait; // 等待该i节点的进程
unsigned long i_atime; // 最后访问时间
unsigned long i_ctime; // i节点自身修改时间
unsigned short i_dev; // i节点所在的设备号
unsigned short i_num; // i节点号
unsigned short i_count; // i节点被使用的次数,0表示该i节点空闲
unsigned char i_lock; // 锁定标志
unsigned char i_dirt; // 已修改(脏)标志
unsigned char i_pipe; // 管道标志
unsigned char i_mount; // 安装标志
unsigned char i_seek; // 搜寻标志(lseek时)
unsigned char i_update; // 更新标志
};
struct d_inode {
unsigned short i_mode;
unsigned short i_uid;
unsigned long i_size;
unsigned long i_time;
unsigned char i_gid;
unsigned char i_nlinks;
unsigned short i_zone[9];
};
m_inode 结构体中 i_mode 描述了文件类型和属性(rwx位),具体如下图:
该函数在文件 fs/exec.c 中。
int do_execve(unsigned long * eip,long tmp,char * filename,
char ** argv, char ** envp)
{
struct m_inode * inode;
struct buffer_head * bh;
struct exec ex;
unsigned long page[MAX_ARG_PAGES];
int i,argc,envc;
int e_uid, e_gid;
int retval;
int sh_bang = 0;
unsigned long p=PAGE_SIZE*MAX_ARG_PAGES-4;
if ((0xffff & eip[1]) != 0x000f)
panic("execve called from supervisor mode");
for (i=0 ; i<MAX_ARG_PAGES ; i++) /* clear page-table */
page[i]=0;
if (!(inode=namei(filename))) /* get executables inode */
return -ENOENT;
argc = count(argv);
envc = count(envp);
restart_interp:
if (!S_ISREG(inode->i_mode)) { /* must be regular file */
retval = -EACCES;
goto exec_error2;
}
// 下面检查当前进程是否有权运行指定的执行文件。即根据执行文件i节点中的属性,看看本
// 进程是否有权执行它。在把执行文件i节点的属性字段值取到i中后,我们首先查看属性中
// 是否设置了“设置-用户-ID”(set-user-id)标志和“设置-组-ID”(set-group-id)标志。
// 这两个标志主要是让一般用户能够执行特权用户(如超级用户root)的程序,例如改变密码的
// 程序 passwd 等。如果set-user-id标志置位,则后面执行进程的有效用户ID(euid)就设置
// 成执行文件的用户ID,否则设置成当前进程的 euid。如果执行文件set-group-id被置位的话,
// 则执行进程的有效组ID(egid)就设置为执行文件的组ID。否则设置成当前进程的egid。
i = inode->i_mode;
e_uid = (i & S_ISUID) ? inode->i_uid : current->euid;
e_gid = (i & S_ISGID) ? inode->i_gid : current->egid;
// 现在根据进程的euid和egid和执行文件的访问属性进行比较。如果执行文件属于运行进程的用户
// ,则把文件属性值i右移6位,此时其最低3位是文件宿主的访问权限标志。否则的话如果执行文件
// 与当前进程的用户属于同组,则使属性值最低3位是执行文件组用户的访问权限标志。否则此时
// 属性字最低3位就是其他用户访问该执行文件的权限。
// 然后我们根据属性字i的最低3比特值来判断当前进程是否有权限运行这个执行文件。如果选出的
// 相应用户没有运行该文件的权利(位0是执行权限),并且其他用户也没有任何权限或者当前用户
// 不是超级用户,则表明当前进程没有权利运行这个执行文件。于是置不可执行出错码,并跳转到
// exec_error2处去做退出处理
if (current->euid == inode->i_uid)
i >>= 6;
else if (current->egid == inode->i_gid)
i >>= 3;
if (!(i & 1) &&
!((inode->i_mode & 0111) && suser())) {
retval = -ENOEXEC;
goto exec_error2;
}
// 程序执行到这里,说明当前进程有运行指定执行文件的权限。因此从这里开始我们需要取出
// 执行文件头部数据并根据其中的信息来分析设置运行环境,或者运行另一个shell程序来执行
// 脚本程序。首先读取执行文件第1块数据到高速缓冲块中。并复制缓冲块数据到ex中。如果
// 执行文件开始的两个字节是字符“#!”,则说明执行文件是一个脚本文本文件。如果想运行
// 脚本文件,我们就需要执行脚本文件的解释程序(例如shell程序)。通常脚本文件的第一行
// 文本为“#!/bin/bash”。它指明了运行脚本文件需要的解释程序。运行方法是从脚本文件
// 第1行(带字符“#!”)中取出其中的解释程序名及后面的参数(若有的话),然后将这些参数
// 和脚本文件名放进执行文件(此时是解释程序)的命令行参数空间中。在这之前我们当然需要
// 先把函数指定的原有命令行参数和环境字符串放到 128KB 空间中,而这里建立起来的命令行
// 参数则放到他们前面位置处(因为是逆向放置)。最后让内核执行脚本文件的解释程序。
// 下面就是在设置好解释程序的脚本文件名等参数后,取出解释程序的i节点并跳转到204行去
// 执行解释程序。由于我们需要跳转到执行过的代码204行去,因此在下面确认并处理了脚本文件
// 之后需要设置一个禁止再次执行下面的脚本处理代码标志sh_bang。在后面的代码中该标志
// 也用来表示我们已经设置好执行文件的命令行参数,不要重复设置。
if (!(bh = bread(inode->i_dev,inode->i_zone[0]))) {
retval = -EACCES;
goto exec_error2;
}
ex = *((struct exec *) bh->b_data); /* read exec-header */
if ((bh->b_data[0] == '#') && (bh->b_data[1] == '!') && (!sh_bang)) {
/*
* This section does the #! interpretation.
* Sorta complicated, but hopefully it will work. -TYT
*/
char buf[1023], *cp, *interp, *i_name, *i_arg;
unsigned long old_fs;
// 从这里开始,我们从脚本文件中提取解释程序名及其参数,并把解释程序名、解释程序的参数
// 和脚本文件名组合放入环境参数块中。首先复制脚本文件头 1 行字符' #!' 后面的字符串到 buf
// 中,其中含有脚本解释程序名(例如/bin/sh),也可能还包含解释程序的几个参数。然后对
// buf 中的内容进行处理。删除开始的空格、制表符。
strncpy(buf, bh->b_data+2, 1022);
brelse(bh);
iput(inode);
buf[1022] = '\0';
if ((cp = strchr(buf, '\n'))) {
*cp = '\0';
for (cp = buf; (*cp == ' ') || (*cp == '\t'); cp++);
}
if (!cp || *cp == '\0') {
retval = -ENOEXEC; /* No interpreter name found */
goto exec_error1;
}
// 此时我们得到了开头是脚本解释程序名的一行内容(字符串)。下面分析该行。首先取第一
// 字符串,它应该是解释程序名,此时 i_name 指向该名称。若解释程序名后还有字符,则它们应
// 该是解释程序的参数串,于是令 i_arg 指向该串。
interp = i_name = cp;
i_arg = 0;
for ( ; *cp && (*cp != ' ') && (*cp != '\t'); cp++) {
if (*cp == '/')
i_name = cp+1;
}
if (*cp) {
*cp++ = '\0';
i_arg = cp;
}
/*
* OK, we've parsed out the interpreter name and
* (optional) argument.
*/
// 现在我们要把上面解析出来的解释程序名 i_name 及其参数 i_arg 和脚本文件名作为解释程
// 序的参数放进环境和参数块中。不过首先我们需要把函数提供的原来一些参数和环境字符串
// 先放进去,然后再放这里解析出来的。例如对于命令行参数来说,如果原来的参数是"-arg1
// -arg2"、解释程序名是"bash"、其参数是"-iarg1 -iarg2"、脚本文件名(即原来的执行文
// 件名)是"example.sh",那么在放入这里的参数之后,新的命令行类似于这样:
// "bash -iargl -iarg2 example.sh -argl -arg2"
// 这里我们把 sh_bang 标志置上,然后把函数参数提供的原有参数和环境字符串放入到空间中。
// 环境字符串和参数个数分别是 envc 和 argc-1 个。少复制的一个原有参数是原来的执行文件。
// 名,即这里的脚本文件名。[[?? 可以看出,实际上我们不需要去另行处理脚本文件名,即这
// 里完全可以复制 argc 个参数,包括原来执行文件名(即现在的脚本文件名)。因为它位于同
// 一个位置上 ]]。注意!这里指针 p 随着复制信息增加而逐渐向小地址方向移动,因此这两个
// 复制串函数执行完后,环境参数串信息块位于程序命令行参数串信息块的上方,并且 p 指向
// 程序的第 1 个参数串。copy_strings()最后一个参数(0)指明参数字符串在用户空间。
if (sh_bang++ == 0) {
p = copy_strings(envc, envp, page, p, 0);
p = copy_strings(--argc, argv+1, page, p, 0);
}
/*
* Splice in (1) the interpreter's name for argv[0]
* (2) (optional) argument to interpreter
* (3) filename of shell script
*
* This is done in reverse order, because of how the
* user environment and arguments are stored.
*/
// 接着我们逆向复制脚本文件名、解释程序的参数和解释程序文件名到参数和环境空间中。
// 若出错,则置出错码,跳转到 exec_error1。另外,由于本函数参数提供的脚本文件名
// filename 在用户空间,而这里赋予 copy_strings()的脚本文件名指针在内核空间,因此。
// 这个复制字符串函数的最后一个参数(字符串来源标志)需要被设置成 1。若字符串在
// 内核空间,则 copy_strings()的最后一个参数要设置成 2,如下面的第 276、279 行。
p = copy_strings(1, &filename, page, p, 1);
argc++;
if (i_arg) {
p = copy_strings(1, &i_arg, page, p, 2);
argc++;
}
p = copy_strings(1, &i_name, page, p, 2);
argc++;
if (!p) {
retval = -ENOMEM;
goto exec_error1;
}
/*
* OK, now restart the process with the interpreter's inode.
*/
// 最后我们取得解释程序的 i 节点指针,然后跳转到 204 行去执行解释程序。为了获得解释程
// 序的 i 节点,我们需要使用 namei() 函数,但是该函数所使用的参数(文件名)是从用户数
// 据空间得到的,即从段寄存器 fs 所指空间中取得。因此在调用 namei()函数之前我们需要
// 先临时让 fs 指向内核数据空间,以让函数能从内核空间得到解释程序名,并在 namei()
// 返回后恢复 fs 的默认设置。因此这里我们先临时保存原 fs 段寄存器(原指向用户数据段)
// 的值,将其设置成指向内核数据段,然后取解释程序的 i 节点。之后再恢复 fs 的原值。并
// 跳转到 restart_interp(204 行)处重新处理新的执行文件 -- 脚本文件的解释程序。
old_fs = get_fs();
set_fs(get_ds());
if (!(inode=namei(interp))) { /* get executables inode */
set_fs(old_fs);
retval = -ENOENT;
goto exec_error1;
}
set_fs(old_fs);
goto restart_interp;
}
// 此时缓冲块中的执行文件头结构数据已经复制到了 ex 中。 于是先释放该缓冲块,并开始对
// ex 中的执行头信息进行判断处理。对于 Linux 0.11 内核来说,它仅支持 ZMAGTC 执行文件格
// 式,并且执行文件代码都从逻辑地址 0 开始执行,因此不支持含有代码或数据重定位信息的
// 执行文件。当然,如果执行文件实在太大或者执行文件残缺不全,那么我们也不能运行它。
// 因此对于下列情况将不执行程序:如果执行文件不是需求页可执行文件(ZMAGIC)、或者代
// 码和数据重定位部分长度不等于 0、或者(代码段+数据段+堆)长度超过 50MB、或者执行文件
// 长度小于 (代码段+数据段+符号表长度+执行头部分)长度的总和。
brelse(bh);
if (N_MAGIC(ex) != ZMAGIC || ex.a_trsize || ex.a_drsize ||
ex.a_text+ex.a_data+ex.a_bss>0x3000000 ||
inode->i_size < ex.a_text+ex.a_data+ex.a_syms+N_TXTOFF(ex)) {
retval = -ENOEXEC;
goto exec_error2;
}
// 另外,如果执行文件中代码开始处没有位于 1 个页面(1024 字节)边界处,则也不能执行。
// 因为需求页(Demand paging)技术要求加载执行文件内容时以页面为单位,因此要求执行
// 文件映像中代码和数据都从页面边界处开始。
if (N_TXTOFF(ex) != BLOCK_SIZE) {
printk("%s: N_TXTOFF != BLOCK_SIZE. See a.out.h.", filename);
retval = -ENOEXEC;
goto exec_error2;
}
// 如果 sh_bang 标志没有设置,则复制指定个数的命令行参数和环境字符串到参数和环境空间。
// 中。若 sh_bang 标志已经设置,则表明是将运行脚本解释程序,此时环境变量页面已经复制,
// 无须再复制。同样,若 sh_bang 没有置位而需要复制的话,那么此时指针 p 随着复制信息增
// 加而逐渐向小地址方向移动,因此这两个复制串函数执行完后,环境参数串信息块位于程序
// 参数串信息块的上方,并且 p 指向程序的第 1 个参数串。事实上,p 是 128KB 参数和环境空
// 间中的偏移值。因此如果 p=0,则表示环境变量与参数空间页面已经被占满,容纳不下了。
if (!sh_bang) {
p = copy_strings(envc,envp,page,p,0);
p = copy_strings(argc,argv,page,p,0);
if (!p) {
retval = -ENOMEM;
goto exec_error2;
}
}
/* OK, This is the point of no return */
/* OK,下面开始就没有返回的地方了 */
// 前面我们针对函数参数提供的信息对需要运行执行文件的命令行参数和环境空间进行了设置,
// 但还没有为执行文件做过什么实质性的工作,即还没有做过为执行文件初始化进程任务结构
// 信息、建立页表等工作。现在我们就来做这些工作。由于执行文件直接使用当前进程的"驱
// 壳",即当前进程将被改造成执行文件的进程,因此我们需要首先释放当前进程占用的某些
// 系统资源,包括关闭指定的已打开文件、占用的页表和内存页面等。然后根据执行文件头结
// 构信息修改 当前进程使用的局部描述符表 LDT 中描述符的内容,重新设置代码段和数据段描
// 述符的限长,再利用前面处理得到的 e_uid 和 e_gid 等信息来设置进程任务结构中相关的字
// 段。最后把执行本次系统调用程序的返回地址 eip 指向执行文件中代码的起始位置处。这。
// 样当本系统调用退出返回后就会去运行新执行文件的代码了。注意,虽然此时新执行文件代。
// 码和数据还没有从文件中加载到内存中,但其参数和环境块已经在 copy_strings() 中使用
// get_free_page(分配了物理内存页来保存数据,并在 change_ldt()函数中使用 put_page()
// 放到了进程逻辑空间的末端处。 另外,在 create_tables(中也会由于在用户栈上存放参数
// 和环境指针表而引起缺页异常,从而内存管理程序也会就此为用户栈空间映射物理内存页。
// 这里我们首先放回进程原执行程序的 i 节点,并且让进程 executable 字段指向新执行文件。
// 的 i 节点。然后复位原进程的所有信号处理句柄。[[但对于 SIG_IGN 句柄无须复位,因此在
// 322 与 323 行之间应该添加 1 条 if 语句:if (current->sa[i].sa_handler != SIG_IGN)]]
// 再根据设定的执行时关闭文件句柄(close_on_exec)位图标志,关闭指定的打开文件,并
// 复位该标志。
if (current->executable)
iput(current->executable);
current->executable = inode;
for (i=0 ; i<32 ; i++)
current->sigaction[i].sa_handler = NULL;
for (i=0 ; i<NR_OPEN ; i++)
if ((current->close_on_exec>>i)&1)
sys_close(i);
current->close_on_exec = 0;
// 然后根据当前进程指定的基地址和限长,释放原来程序的代码段和数据段所对应的内存页表
// 指定的物理内存页面及页表本身。此时新执行文件并没有占用主内存区任何页面,因此在处
// 理器真正运行新执行文件代码时就会引起缺页异常中断,此时内存管理程序即会执行缺页处
// 理而为新执行文件申请内存页面和设置相关页表项,并且把相关执行文件页面读入内存中。
// 如果"上次任务使用了协处理器"指向的是当前进程,则将其置空,并复位使用了协处理器
// 的标志。
free_page_tables(get_base(current->ldt[1]),get_limit(0x0f));
free_page_tables(get_base(current->ldt[2]),get_limit(0x17));
if (last_task_used_math == current)
last_task_used_math = NULL;
current->used_math = 0;
// 然后我们根据新执行文件头结构中的代码长度字段 a_text 的值修改局部表中描述符基址和。
// 段限长,并将 128KB 的参数和环境空间页面放置在数据段末站。执行下面语句之后,p 此时
// 更改成以数据段起始处为原点的偏移值,但仍指向参数和环境空间数据开始处,即已转换成
// 为栈指针值。然后调用内部函数 create_tables()在栈空间中创建环境和参数变量指针表,
// 供程序的 main()作为参数使用,并返回该栈指针。
p += change_ldt(ex.a_text,page)-MAX_ARG_PAGES*PAGE_SIZE;
p = (unsigned long) create_tables((char *)p,argc,envc);
// 接着再修改进程各字段值为新执行文件的信息。即令进程任务结构代码尾字段 end_code 等。
// 于执行文件的代码段长度 a_text;数据尾字段 end_data 等于执行文件的代码段长度加数
// 据段长度(a_data + a_text);并令进程堆结尾字段 brk = a_text + a_data + a_bss。
// brk 用于指明进程当前数据段(包括未初始化数据部分)末端位置。然后设置进程栈开始字
// 段为栈指针所在页面,并重新设置进程的有效用户 id 和有效组 id。
current->brk = ex.a_bss +
(current->end_data = ex.a_data +
(current->end_code = ex.a_text));
current->start_stack = p & 0xfffff000;
current->euid = e_uid;
current->egid = e_gid;
// 如果执行文件代码加数据长度的末端不在页面边界上,则把最后不到 1 页长度的内存空间初
// 始化为零。[[ 实际上由于使用的是 ZMAGIC 格式的执行文件,因此代码段和数据段长度均是
// 页面的整数倍长度,因此 343 行不会执行,即(i&0xfff) = 0。这段代码是 Linux 内核以前
// 版本的残留物:) ]]
i = ex.a_text+ex.a_data;
while (i&0xfff)
put_fs_byte(0,(char *) (i++));
// 最后将原调用系统中断的程序在堆栈上的代码指针替换为指向新执行程序的入口点,并将栈
// 指针替换为新执行文件的栈指针。此后返回指令将弹出这些栈数据并使得 CPU 去执行新执行
// 文件,因此不会返回到原调用系统中断的程序中去了。
eip[0] = ex.a_entry; /* eip, magic happens :-) */ /* eip,魔法起作用了*/
eip[3] = p; /* stack pointer */ /* esp,堆栈指针 */
return 0;
exec_error2:
iput(inode); // 放回 i 节点。
exec_error1:
for (i=0 ; i<MAX_ARG_PAGES ; i++)
free_page(page[i]); // 释放存放参数和环境串的内存页面。
return(retval); // 返回出错码。
}
接下来程序,由于缺页,则会进入缺页处理程序。详细参考 Linux0.11 缺页处理(三)。