从整理上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
327
原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
阅读并理解 task_struct 数据结构
代码位于 http://codelab.shiyanlou.com/source/xref/linux-3.18.6/include/linux/sched.h#1235
该结构体定义了 Linux 操作系统中的 PCB
其中最重要的项有:
- state:描述进程状态,-1 表示不可运行,0 表示可运行,大于 0 表示停止运行
- stack:描述进程栈
- usage:描述进程是否被使用
- flags:描述进程的标志
- prio,static_prio,normal_prio:描述进程优先级
- sched 相关结构体:描述进程调度相关数据
- policy:描述进程策略
- cpu 相关结构体:描述进程的 cpu 相关数据
- tasks,children 等:存储所有当前进程的子进程
- mm,active_mm,vmacache:描述进程内存分配与缓存相关数据
- exit_state,exit_code,exit_signal 等:描述进程退出状态
- pid:描述进程 id
- real_parent,parent:描述父进程 PCB
- start_time,real_start_time 等 time 相关数据:描述进程运行时间
- fs,files:描述进程文件系统信息与打开的文件相关信息
- signal,blocked 等数据:描述进程调度状态信息
- lock 等数据:描述进程加锁状态信息
通过阅读该数据结构,可以更深刻的体会到操作系统相关知识在具体实践中的应用
分析 fork 函数对应的内核处理过程 do_fork
代码位于 http://codelab.shiyanlou.com/source/xref/linux-3.18.6/kernel/fork.c#1623
long do_fork(unsigned long clone_flags, unsigned long stack_start, unsigned long stack_size, int __user *parent_tidptr, int __user *child_tidptr) { struct task_struct *p; int trace = 0; long nr; if (!(clone_flags & CLONE_UNTRACED)) { if (clone_flags & CLONE_VFORK) trace = PTRACE_EVENT_VFORK; else if ((clone_flags & CSIGNAL) != SIGCHLD) trace = PTRACE_EVENT_CLONE; else trace = PTRACE_EVENT_FORK; if (likely(!ptrace_event_enabled(current, trace))) trace = 0; } p = copy_process(clone_flags, stack_start, stack_size, child_tidptr, NULL, trace); if (!IS_ERR(p)) { struct completion vfork; struct pid *pid; trace_sched_process_fork(current, p); pid = get_task_pid(p, PIDTYPE_PID); nr = pid_vnr(pid); if (clone_flags & CLONE_PARENT_SETTID) put_user(nr, parent_tidptr); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } wake_up_new_task(p); if (unlikely(trace)) ptrace_event_pid(trace, pid); if (clone_flags & CLONE_VFORK) { if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); } put_pid(pid); } else { nr = PTR_ERR(p); } return nr; }
首先根据标志位 clone_flags 判断是否要对该函数进行追踪
然后复制当前进程结构体,指针 p 指向新创建的进程结构体
判断 p 是否存在,若不存在,返回错误 nr = PTR_ERR(p)
若存在,则进一步执行 fork 处理
先从新进程结构体中取出进程 pid,并将其设置为返回值 nr
接着根据 clone_flags 设置父进程 id
然后判断否是 vfork,若不是,不做处理;若是,要确保父进程在子进程完成初始化后才能运行
将子进程加入调度队列
处理 vfork 调用中父进程等待子进程,确保子进程先运行
使用 gdb 跟踪分析一个 fork 系统调用内核处理函数 do_fork
使用 MenuOS 中的 test_fork.c 代替 test.c 启动 MenuOS
其中 Fork 测试函数如下
int Fork(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"); } }
在 GDB 中打以下断点
b sys_clone b dup_task_struct b do_fork b copy_process b copy_thread b ret_from_fork
运行结果
其中调试过程如下
观察实验结果,Fork 函数的调用过程为 sys_clone->do_fork->copy_process->dup_task_struct->copy_thread->ret_form_fork
理解编译链接的过程和 ELF 可执行文件格式
整个编译链接过程如下
源文件 .c/.cpp 经过预处理,编译,汇编之后生成 .o,所有的 .o 链接起来共同构成可执行文件
预处理:主要是做一些代码文本的替换工作(该替换是一个递归逐层展开的过程)
编译:把预处理完的文件进行一系列词法分析(lex)、语法分析(yacc)、语义分析及优化后生成汇编代码,这个过程是程序构建的核心部分
汇编:汇编代码->机器指令
链接:这里讲的链接,严格说应该叫静态链接。多个目标文件、库->最终的可执行文件(拼合的过程)
ELF 格式可执行文件为 Linux 可执行文件格式,包括三种主要的类型:可执行文件、可重定向文件、共享库
一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象
一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件
一个共享库文件保存着代码和合适的数据,用来被不同的两个链接器链接
编程使用 exec* 库函数加载一个可执行文件
编写一个 .c 文件
#include <stdio.h> int main() { printf("Hello World!\n"); return 0; }
再编写一个 exec* 测试文件
#include <unistd.h> int main() { char *argv[] = {"./test", "test", (char *)0}; char *envp[] = {0}; execve("./test", argv, envp); return 0; }
将这两个文件编译成可执行文件
gcc test.c -o test
gcc exec.c -o exec
运行 exec 即可打印出 Hello World!
使用 gdb 跟踪分析一个 execve 系统调用内核处理函数 do_execve
在之前的 MenuOS 的 Fork 函数中加入 execlp("/bin/ls",“ls”,NULL);
在 GDB 中打以下断点
b do_execve b sys_execve b do_execve_common b exec_biniprm b search_binary_hanlder b load_elf_binary
运行结果
观察实验结果,调用顺序为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread()
理解Linux系统中进程调度的时机
调用schedule()的位置:
中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度
搜索 schedule() 调用文件
可发现在网络,驱动,文件系统,加锁,内核相关处理中多次调用 schedule 函数
使用 gdb 跟踪分析一个 schedule() 函数
使用 gdb 打断点
b schedule b pick_next_task b context_switch b __switch_to
运行结果如下
可以看出 schedule 调用 _schedule,_schedule 调用 pick_next_task,context_switch 函数,context_switch 函数调用 __switch_to
分析switch_to中的汇编代码
asm volatile("pushfl\n\t" /* 保存当前进程的标志位 */ "pushl %%ebp\n\t" /* 保存当前进程的堆栈基址EBP */ "movl %%esp,%[prev_sp]\n\t" /* 保存当前栈顶ESP */ "movl %[next_sp],%%esp\n\t" /* 把下一个进程的栈顶放到esp寄存器中,完成了内核堆栈的切换,从此往下压栈都是在next进程的内核堆栈中。 */ "movl $1f,%[prev_ip]\n\t" /* 保存当前进程的EIP */ "pushl %[next_ip]\n\t" /* 把下一个进程的起点EIP压入堆栈 */ __switch_canary "jmp __switch_to\n" /* 因为是函数所以是jmp,通过寄存器传递参数,寄存器是prev-a,next-d,当函数执行结束ret时因为没有压栈当前eip,所以需要使用之前压栈的eip,就是pop出next_ip。 */ "1:\t" /* 认为next进程开始执行。 */ "popl %%ebp\n\t" /* restore EBP */ "popfl\n" /* restore flags */ /* output parameters 因为处于中断上下文,在内核中 prev_sp是内核堆栈栈顶 prev_ip是当前进程的eip */ : [prev_sp] "=m" (prev->thread.sp), [prev_ip] "=m" (prev->thread.ip), //[prev_ip]是标号 "=a" (last), /* clobbered output registers: */ "=b" (ebx), "=c" (ecx), "=d" (edx), "=S" (esi), "=D" (edi) __switch_canary_oparam /* input parameters: next_sp下一个进程的内核堆栈的栈顶 next_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/ : [next_sp] "m" (next->thread.sp), [next_ip] "m" (next->thread.ip), /* regparm parameters for __switch_to(): */ [prev] "a" (prev), [next] "d" (next) __switch_canary_iparam : /* reloaded segment registers */ "memory"); } while (0)
首先在当前进程 prev 的内核栈中保存 esi,edi 及 ebp 寄存器的内容
然后将 prev 的内核堆栈指针 ebp 存入 prev->thread.esp 中
把将要运行进程 next 的内核栈指针 next->thread.esp 置入 esp 寄存器中将 popl 指令所在的地址保存在 prev->thread.eip 中
这个地址就是 prev 下一次被调度通过 jmp 指令(而不是call指令)转入一个函数 __switch_to() 恢复 next 上次被调离时推进堆栈的内容
从现在开始,next 进程就成为当前进程而真正开始执行.
总结
本次实验加深了我对Linux系统的执行过程的理解
其中 Linux 通过复制父进程来创建一个新进程,通过调用 do_fork 来实现并为每个新创建的进程动态地分配一个 task_struct 结构
fork() 函数被调用一次,但返回两次
可以通过 fork,复制一个已有的进程,进而产生一个子进程
而 schedule() 函数实现进程调度,context_ switch 完成进程上下文切换,switch_ to 完成寄存器的切换