MIT6.S081_2020_Lab6:init-starting-sh-bug-原因分析
一、BUG的表现
在Lab 6 实验中,遇见了这个现象

只要按下回车,就会输出init: starting sh,这个BUG看起来很有意思。
二、结论
先说结论:该BUG的产生原因:在之前lab5 实验时,usertest中存有一项测试stacktest报错:

stacktest源代码如下:
1 | // user/usertests.c |
stacktest这段代码是一个用于检测堆栈溢出的函数。在函数中,首先使用fork()创建一个子进程,然后在子进程中获取当前栈指针,减去页面大小(PGSIZE),并尝试读取这个地址的内容来引发一个异常(trap)(为什么会发生异常可以参考:stacktest)。如果发生异常,子进程会返回1给父进程*(代码第15行:exit(1)),父进程等待子进程的返回值(wait(&xstatus)),如果返回值为-1,stacktest测试通过,否则测试失败。,返回值是-1的奥秘可以从注释中(kernel killed child?)*看出,内核的中断处理函数应该检测到子进程的越界访问行为,并将子进程kill掉。
所以在lab5 实验的usertrap函数中加入一段代码,检测发生trap的虚拟地址是否位于栈指针之下,如果是,将进程终止:
1 | if(PGROUNDUP(p->trapframe->sp) - 1 >= va ){ |
之后测试stacktest成功:

Lab6实验紧跟着Lab5实验,同样需要借助异常处理机制,同样需要利用页面错误(page fault),scause的值都为13或15,并且都需要通过usertest检测,所以自作聪明在Lab6usertrap代码中加入了这段代码导致的BUG发生:
1 | if(PGROUNDUP(p->trapframe->sp) - 1 >= va ){ |
把这段代码去掉就可以了
为什么去掉这段代码可以通过stacktest?
相比于Lazy allocation实验,copy on write实验对权限检测的要求更加严格,以下代码保证正常运行:
1 | if(oldpte==0|| (*oldpte & PTE_V)==0 ||(*oldpte & PTE_U)==0){ |
三、探究BUG发生的过程
3.1有关函数提前梳理
由于每个人编写的Copy-On-Write的代码都不一样,本人的代码执行结果可能是独特的,并且最终的结果含有大量推测
由于每个人编写的Copy-On-Write的代码都不一样,本人的代码执行结果可能是独特的,并且最终的结果含有大量推测
由于每个人编写的Copy-On-Write的代码都不一样,本人的代码执行结果可能是独特的,并且最终的结果含有大量推测
由于本BUG发生时现象过于神奇,即使问题已经得到了解决,我仍然决定详细跟踪研究,来探究该BUG发生的详细流程。
需要对Fork 函数,Exec函数,Wait函数,Exit函数的使用有所理解:
3.1.1 Fork函数
1 | // kernel/proc.c |
fork会拷贝当前进程的内存,并创建一个新的进程,这里的内存包含了进程的指令和数据。之后,我们就有了两个拥有完全一样内存的进程。fork系统调用在两个进程中都会返回,在原始的进程中,fork系统调用会返回大于0的整数,这个是新创建进程的ID。而在新创建的进程中,fork系统调用会返回0。所以即使两个进程的内存是完全一样的,我们还是可以通过fork的返回值区分旧进程和新进程。
举个例子:
1 | int main(){ |
假设原本进程的PID为2,那么该程序运行结果为:
1 | origin pid= 2 |
3.1.2 Exec 函数
1 | // kernel/exec.c |
以下内容均复制自3.8 代码:exec
exec是创建地址空间的用户部分的系统调用。它使用一个存储在文件系统中的文件初始化地址空间的用户部分。
exec(kernel/exec.c:13)使用namei (kernel/exec.c:26)打开指定的二进制path,这在第8章中有解释。然后,它读取ELF头。Xv6应用程序以广泛使用的ELF格式描述,定义于(kernel/elf.h)。ELF二进制文件由ELF头、struct elfhdr(kernel/elf.h:6),后面一系列的程序节头(section headers)、struct proghdr(kernel/elf.h:25)组成。每个proghdr描述程序中必须加载到内存中的一节(section);xv6程序只有一个程序节头,但是其他系统对于指令和数据部分可能各有单独的节。
Note
ELF文件格式:在计算机科学中,是一种用于二进制文件、可执行文件、目标代码、共享库和核心转储格式文件。ELF是UNIX系统实验室(USL)作为应用程序二进制接口(Application Binary Interface,ABI)而开发和发布的,也是Linux的主要可执行文件格式。ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。实际上,一个文件中不一定包含全部内容,而且它们的位置也未必如同所示这样安排,只有ELF头的位置是固定的,其余各部分的位置、大小等信息由ELF头中的各项值来决定。
第一步是快速检查文件可能包含ELF二进制的文件。ELF二进制文件以四个字节的“幻数”0x7F、“E”、“L”、“F”或ELF_MAGIC开始(kernel/elf.h:3)。如果ELF头有正确的幻数,exec假设二进制文件格式良好。
exec使用proc_pagetable (kernel/exec.c:38)分配一个没有用户映射的新页表,使用uvmalloc (kernel/exec.c:52)为每个ELF段分配内存,并使用loadseg (kernel/exec.c:10)将每个段加载到内存中。loadseg使用walkaddr找到分配内存的物理地址,在该地址写入ELF段的每一页,并使用readi从文件中读取。
使用exec创建的第一个用户程序/init的程序节标题如下:
1 | # objdump -p _init |
程序节头的filesz可能小于memsz,这表明它们之间的间隙应该用零来填充(对于C全局变量),而不是从文件中读取。对于/init\,filesz是2112字节,memsz是2136字节,因此uvmalloc分配了足够的物理内存来保存2136字节,但只从文件/init\中读取2112字节。
现在exec分配并初始化用户栈。它只分配一个栈页面。exec一次将参数中的一个字符串复制到栈顶,并在ustack中记录指向它们的指针。它在传递给main的argv列表的末尾放置一个空指针。ustack中的前三个条目是伪返回程序计数器(fake return program counter)、argc和argv指针。
exec在栈页面的正下方放置了一个不可访问的页面,这样试图使用超过一个页面的程序就会出错。这个不可访问的页面还允许exec处理过大的参数;在这种情况下,被exec用来将参数复制到栈的函数copyout(kernel/vm.c:355) 将会注意到目标页面不可访问,并返回-1。
在准备新内存映像的过程中,如果exec检测到像无效程序段这样的错误,它会跳到标签bad,释放新映像,并返回-1。exec必须等待系统调用会成功后再释放旧映像:因为如果旧映像消失了,系统调用将无法返回-1。exec中唯一的错误情况发生在映像的创建过程中。一旦映像完成,exec就可以提交到新的页表(kernel/exec.c:113)并释放旧的页表(kernel/exec.c:117)。
exec将ELF文件中的字节加载到ELF文件指定地址的内存中。用户或进程可以将他们想要的任何地址放入ELF文件中。因此exec是有风险的,因为ELF文件中的地址可能会意外或故意的引用内核。对一个设计拙劣的内核来说,后果可能是一次崩溃,甚至是内核的隔离机制被恶意破坏(即安全漏洞)。xv6执行许多检查来避免这些风险。例如,if(ph.vaddr + ph.memsz < ph.vaddr)检查总和是否溢出64位整数,危险在于用户可能会构造一个ELF二进制文件,其中的ph.vaddr指向用户选择的地址,而ph.memsz足够大,使总和溢出到0x1000,这看起来像是一个有效的值。在xv6的旧版本中,用户地址空间也包含内核(但在用户模式下不可读写),用户可以选择一个与内核内存相对应的地址,从而将ELF二进制文件中的数据复制到内核中。在xv6的RISC-V版本中,这是不可能的,因为内核有自己独立的页表;loadseg加载到进程的页表中,而不是内核的页表中。
内核开发人员很容易省略关键的检查,而现实世界中的内核有很长一段丢失检查的历史,用户程序可以利用这些检查的缺失来获得内核特权。xv6可能没有完成验证提供给内核的用户级数据的全部工作,恶意用户程序可以利用这些数据来绕过xv6的隔离。
有关exec系统调用,有一些重要的事情,
- exec系统调用会保留当前的文件描述符表单。所以任何在exec系统调用之前的文件描述符,例如0,1,2等。它们在新的程序中表示相同的东西。
- 通常来说exec系统调用不会返回,因为exec会完全替换当前进程的内存,相当于当前进程不复存在了,所以exec系统调用已经没有地方能返回了。
举个例子:
1 | int main(){ |
在这段代码中,子进程使用exec函数执行了echo函数。如果exec执行成功,那么第7行与第8行的代码不会被执行。只有exec执行失败,这两行代码才会执行。但是exec执行成功后两个进程之间的父子关系并没有改变,exec执行后echo函数的代码的结尾依旧有exit(0)代码,所以父进程的wait(&status);依旧可以获取子进程的退出状态
3.1.3 Exit函数与Wait函数
Exit 与Wait函数通常用于父子进程之间传递参数。
Exit的源代码(代码中有个人分析):
1 | // Exit the current process. Does not return. |
Exit(*kernel/proc.c*:333)记录退出状态码(status),释放一些资源, **将所有子进程提供给init进程 ** ,在父进程处于等待状态时唤醒父进程,将调用方标记为僵尸进程(zombie),并永久地让出CPU。最后的顺序有点棘手。退出进程必须在将其状态设置为ZOMBIE并唤醒父进程时持有其父进程的锁,因为父进程的锁是防止在wait中丢失唤醒的条件锁。子级还必须持有自己的p->lock,否则父级可能会看到它处于ZOMBIE状态,并在它仍运行时释放它。锁获取顺序对于避免死锁很重要:因为wait先获取父锁再获取子锁,所以exit必须使用相同的顺序。
Exit调用一个专门的唤醒函数wakeup1,该函数仅唤醒父进程,且父进程必须正在wait中休眠(*kernel/proc.c*:598)。在将自身状态设置为ZOMBIE之前,子进程唤醒父进程可能看起来不正确,但这是安全的:虽然wakeup1可能会导致父进程运行,但wait中的循环在scheduler释放子进程的p->lock之前无法检查子进程,所以wait在exit将其状态设置为ZOMBIE(kernel/proc.c:386)之前不能查看退出进程。
回答来自chatgpt
1.Linux 在杀死一个进程时为什么要让子进程重新设置父进程?
Linux 在杀死一个进程时需要让子进程重新设置父进程是为了确保子进程不会成为孤儿进程。当父进程被终止时,操作系统会将孤儿进程的新父进程设置为 init 进程(PID 为 1 的进程),这样可以确保孤儿进程仍然能够被正确地回收和管理。因此,在杀死一个进程时,需要让子进程重新设置父进程,以避免产生孤儿进程。
2.为什么要唤醒父进程?
在Linux中,当一个子进程终止时,通常会向其父进程发送一个 SIGCHLD 信号。父进程通常会通过调用 wait() 系统调用来等待和处理子进程的终止状态。这样做可以确保父进程能够及时得知子进程的退出状态,以便进行适当的清理和处理。
因此,在杀死一个进程时,需要唤醒父进程,使其有机会处理子进程的终止状态,并采取必要的操作,比如回收资源或记录日志等。这种通信机制可以确保进程间的协同工作和资源管理。
Wait函数的源代码
1 | // Wait for a child process to exit and return its pid. |
父进程调用Wait函数等待子进程的退出。Wait函数会死循环(:14)扫描进程表直到发现子进程(:21)
wait(&state)函数返回退出的的子进程的PID号,并将退出的状态标志保存在state中,如果一个父进程拥有多个子进程,wait(0)只能检测到一个子进程的退出,要检测其他子进程的退出状态,父进程需要调用 wait函数多次,或者使用循环来处理所有子进程的退出状态。
3.1.4 Initcode,PID=1 的进程
1 | char *argv[] = { "sh", 0 }; |
initcode函数作为XV6操作系统中用户级别第一个进程(PID=1),其主要作用是保证shell进程存在.
注意:initcode函数大部分时间运行在第二层死循环中c:30-43,由于操作系统运行过程中肯定会杀死不少进程,而进程被杀死时,其子进程的父进程会被设置为initcode (原因),所以wpid得到的不一定是shell程序的pid号,需要if条件句进行判断。
当判断出来shell进程停止运行了,initcode需要重新运行shell程序。
3.1.5 shell程序
shell程序的代码比较多,先贴出来main函数代码:
1 | int |
c:16之前为文件描述符的相关设置,之后为shell程序的逻辑关键,可以看出:
每次循环shell程序从缓冲区buf中读取指令c:16,使用fork函数,生成一个子进程,子进程执行指令,shell程序自身等待子进程的结束后进入下一次循环。正常情况下,shell程序一直处于循环之中,不会中止。可以在未曾修改过代码的XV6系统中(也就是说,initcode,与shell程序都正常运转),修改fork函数,输出父进程与子进程的pid号;可以看到输出结果符合预期:

3.2正式开始分析BUG
3.2.1 到底发生了什么?
将发生BUG的代码,进行修改,让fork函数执行时,输出父进程与子进程的pid号;同时在中断处理函数中的错误代码进行修改,在杀死进程前输出被杀死进程的pid号;initcode第一层循环开始是输出----------------------------,最终输出结果如下:

从图中可以看出,shell进程fork出的子进程在BUG发生后终止了运行,shell程序紧接着也停止了运行。initcode检测到了shell程序的中止,重新启动shell并输出:init: starting sh
3.2.2 shell进程在哪里发生了错误?
通过广泛使用printf函数,与GDB调试工具,来定位BUG产生的地区:
1 | //为 shell程序增加了大量的printf |

分析结果,主要的问题是 while(getcmd(buf, sizeof(buf)) >= 0) 这段代码,就如同BUG的现象一样奇特,这段代码在shell程序第一次读取指令时不会报错,而第二次读取指令时会报错。
为出错的getcmd函数增加printf函数:
1 | int |
输出结果如下:

最终定位到memset(buf, 0, nbuf);这段代码,这段代码第一次执行没有问题,第二次执行会报错。
查看memset的源代码,无法提供太多有用的信息
1 | void* |
shell fork后的子进程cow发生在哪
通过寻找shell开始崩溃的代码方法一样,最终发现子进程发生错误的指令位于:main->runcmd(parsecmd(buf))->parsecmd(buf)->parseline(&s, es)->parsepipe(ps, es)->parseexec(ps, es)->execcmd()->malloc(sizeof(*cmd))
1 | void* |
malloc与memset都为高频使用的函数,这两个代码出错的可能性极低,我更愿意相信是之前的地方存在逻辑错误,只是到了这里爆发了出来
3.2.3 内核中发生了什么?
在usertrap中增加更多的printf代码,当发生cow错误后输出”cow\n”:
此图为有BUG的输出结果:

此图为正常运行的数据结果:

对比可以看出:
1.initcode fork出来shell程序时并没有发生cow
2.shell程序fork并执行cmd时发生了三次cow,第二次cow导致了子进程的崩溃, 第三次cow导致shell程序的崩溃,而之前我们发现报错的直接原因是memset与malloc这两个高频使用的函数。
3.3 以下皆为推论

Fork函数后面常常跟exec函数,该函数的一大特点便是完全替换当前进程的内存,所以将栈指针下方皆视为非法地址,在遇到exec函数的情况下,即使不当场报错,也可能对后续的函数产生不良影响。同时,部分进程需要对局部变量进行修改,而局部变量就位于栈指针下,随便从其他实验借来的代码没有考虑到这些情况导致BUG的发生。
原本我预期可以清楚地分析出BUG的所在,但是整个过程相比于自己平时手搓的代码DEBUG还是太复杂了,最终只是推论解释这个BUG。但是为了解决这个BUG,我仍进行了相当长时间的思考,我认为即使结果没有达到预期,我中间的思维过程也不会白白浪费掉,所以我决定将思路记录下来。




