一、实验内容
结合中断上下文切换和进程上下文切换分析Linux内核一般执行过程
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
二、实验环境配置
此实验在实验二的基础上进行,实验配置同实验二。
三、调用fork系统调用分析上下文切换
1、查看对应的系统调用函数
从inux-5.4.34/arch/x86/entry/syscalls/syscall_32.tbl中找到要调用的fork系统调用。(由于我的虚拟机为32位所以我查找syscall_32.tbl文件,如果是64位的虚拟机则查找syscall_64.tbl文件)
查看linux-5.4.34/kernel/fork.c能够看出_do_fork函数被系统调用sys_clone调用。sys_clone是120号系统调用。
2、编写系统调用的代码test.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char * argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid < 0)
{
/* error occurred */
fprintf(stderr,"Fork Failed!");
exit(-1);
}
else if (pid == 0)
{
/* child process */
printf("This is Child Process!\n");
}
else
{
/* parent process */
printf("This is Parent Process!\n");
/* parent will wait for the child to complete*/
wait(NULL);
printf("Child Complete!\n");
}
}
函数运行结果如下:
3、fork()函数
在不知道进程的概念的时候,看到整个代码中的if_else语句我们会认为只会有一个执行,要么执行if,要么执行else,但是当调用了fork()函数后,if和else中的内容竟然都被执行了。fork()又叫计算机程序设计中的分叉函数,它可以建立一个新的进程,把当前的进程分为父进程和子进程,fork调用一次返回两次,这两个返回分别带回他们各自的返回值,在父进程中的返回值是子进程的pid,子进程中的返回值是0,所以可以通过返回值来判断进程是子进程还是父进程。
而且fork将运行着的程序分为两个几乎完全一样的进程,每个进程都启动一个从代码的同一位置开始执行的线程,这两个进程中的线程继续执行,所以也就产生了上述if和else块中的内容都被执行的结果。
4、gdb跟踪fork系统调用过程
使用gcc对上述test.c文件进行静态编译,生成可执行文件test
在rootfs文件中重新打包在busybox-1.31.1中生成内存根文件系统镜像rootfs.cpio.gz
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
在busybox-1.31.1文件中纯命令行启动qemu
qemu-system-x86_64 -kernel ../arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
在linux-5.4.34中打开另一个新的终端,运行如下代码,在sys_clone、_do_fork、copy_process、copy_thread_tls处打上断点,并在断点处输入bt查看详细调用过程。
gdb vmlinux (gdb) target remote:1234
5、fork()内核处理过程
Linux下用于创建进程的API有三个fork,vfork和clone,这三个函数分别是通过系统调用sys_fork,sys_vfork以及sys_clone实现的(基于X86架构)这三个系统调用都是通过do_fork实现的,只不过是传入了不同的参数,fork系统调用过程如下图:
下面看一下do_fork的代码:
1 long do_fork(unsigned long clone_flags, 2 unsigned long stack_start, 3 unsigned long stack_size, 4 int __user *parent_tidptr, 5 int __user *child_tidptr) 6 { 7 struct task_struct *p; 8 int trace = 0; 9 long nr; 10 11 /* 12 * Determine whether and which event to report to ptracer. When 13 * called from kernel_thread or CLONE_UNTRACED is explicitly 14 * requested, no event is reported; otherwise, report if the event 15 * for the type of forking is enabled. 16 */ 17 if (!(clone_flags & CLONE_UNTRACED)) { 18 if (clone_flags & CLONE_VFORK) 19 trace = PTRACE_EVENT_VFORK; 20 else if ((clone_flags & CSIGNAL) != SIGCHLD) 21 trace = PTRACE_EVENT_CLONE; 22 else 23 trace = PTRACE_EVENT_FORK; 24 25 if (likely(!ptrace_event_enabled(current, trace))) 26 trace = 0; 27 } 28 29 p = copy_process(clone_flags, stack_start, stack_size, 30 child_tidptr, NULL, trace); 31 /* 32 * Do this prior waking up the new thread - the thread pointer 33 * might get invalid after that point, if the thread exits quickly. 34 */ 35 if (!IS_ERR(p)) { 36 struct completion vfork; 37 struct pid *pid; 38 39 trace_sched_process_fork(current, p); 40 41 pid = get_task_pid(p, PIDTYPE_PID); 42 nr = pid_vnr(pid); 43 44 if (clone_flags & CLONE_PARENT_SETTID) 45 put_user(nr, parent_tidptr); 46 47 if (clone_flags & CLONE_VFORK) { 48 p->vfork_done = &vfork; 49 init_completion(&vfork); 50 get_task_struct(p); 51 } 52 53 wake_up_new_task(p); 54 55 /* forking complete and child started to run, tell ptracer */ 56 if (unlikely(trace)) 57 ptrace_event_pid(trace, pid); 58 59 if (clone_flags & CLONE_VFORK) { 60 if (!wait_for_vfork_done(p, &vfork)) 61 ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); 62 } 63 64 put_pid(pid); 65 } else { 66 nr = PTR_ERR(p); 67 } 68 return nr; 69 }
这段代码涉及到很多工作的处理,但是整个创建进程是在第29行copy_process()这个函数实现的,整个过程实现如下:
(1)p = dup_task_struct(current); 为新进程创建一个内核栈、thread_iofo和task_struct,这里完全copy父进程的内容,所以到目前为止,父进程和子进程是没有任何区别的。
(2)为新进程在其内存上建立内核堆栈
(3)对子进程task_struct任务结构体中部分变量进行初始化设置,检查所有的进程数目是否已经超出了系统规定的最大进程数,如果没有的话,那么就开始设置进程描诉符中的初始值,从这开始,父进程和子进程就开始区别开了。
(4)把父进程的有关信息复制给子进程,建立共享关系
(5)设置子进程的状态为不可被TASK_UNINTERRUPTIBLE,从而保证这个进程现在不能被投入运行,因为还有很多的标志位、数据等没有被设置
(6)复制标志位(falgs成员)以及权限位(PE_SUPERPRIV)和其他的一些标志
(7)调用get_pid()给子进程获取一个有效的并且是唯一的进程标识符PID
(8)return ret_from_fork;返回一个指向子进程的指针,开始执行
四、调用execve系统调用分析上下文切换
1、查看对应的系统调用函数
从inux-5.4.34/arch/x86/entry/syscalls/syscall_32.tbl中找到要调用的fork和execve系统调用。(由于我的虚拟机为32位所以我查找syscall_32.tbl文件,如果是64位的虚拟机则查找syscall_64.tbl文件)。sys_execve为第11号系统调用。
2、gdb跟踪execve系统调用
在busybox-1.31.1文件中纯命令行启动qemu
qemu-system-x86_64 -kernel ../arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
此时会停留在如下图界面不动。
在linux-5.4.34中打开另一个新的终端,运行如下代码,在sys_execve、_do_execve、__do_execve_file处打上断点,并在断点处输入bt查看详细调用过程
gdb vmlinux (gdb) target remote:1234
3、execve()内核处理过程
系统调用execve()的内核入口为sys_execve,代码如下:
asmlinkage int sys_execve(struct pt_regs regs) { int error; char * filename; filename = getname((char *) regs.ebx); error = PTR_ERR(filename); if (IS_ERR(filename)) goto out; error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, ®s); if (error == 0) current->ptrace &= ~PT_DTRACE; putname(filename); out: return error; }
regs.ebx保存着系统调用execve的第一个参数,即可执行文件的路径名。因为路径名存储在用户空间中,这里要通过getname拷贝到内核空间中。getname在拷贝文件名时,先申请了一个page作为缓冲,然后再从用户空间拷贝字符串。为什么要申请一个页面而不使用进程的系统空间堆栈?首先这是一个绝对路径名,可能比较长,其次进程的系统空间堆栈大约为7K,比较紧缺,不宜滥用。用完文件名后,在函数的末尾调用putname释放掉申请的那个页面。
sys_execve的核心是调用do_execve函数,传给do_execve的第一个参数是已经拷贝到内核空间的路径名filename,第二个和第三个参数仍然是系统调用execve的第二个参数argv和第三个参数envp,它们代表的传给可执行文件的参数和环境变量仍然保留在用户空间中。
do_execve主要流程如下:忽略掉异常情况的处理
五、Linux系统的一般执行过程
最一般的情况:正在运行的用户态进程X切换到运行用户态进程Y的过程
(1)发生中断,完成下面两个步骤
save cs:eip/esp/eflags(current) to kernel stack load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack)
(2)save_all,保存现场,这里已经进入内核中断的处理过程
(3)中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换。
(4)标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)。
(5)restore_all恢复现场
(6)继续运行用户态进程Y