进程创建、可执行文件的加载和进程执行进程切换。
操作系统是如何工作的
学号531
原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/
实验环境
使用实验楼的虚拟机打开shell
实验楼链接: https://www.shiyanlou.com/courses/195
进程描述的数据结构task_struct
-
进程是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
-
进程控制块PCB——task_struct:
PCB在linux内核中定义为task_struct结构体,
并在 http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235; 源文件中实现。 -
关键参数如下:
volatile long state; //表示进程状态
void *stack; //进程所属堆栈指针
unsigned int rt_priority;//进程优先级
int exit_state;//退出时状态
pid_t pid;//进程号,作为进程的全局标识符
pid_t tgid;//进程组号
struct task_struct __rcu *real_parent;//父进程
struct list_head children;//子进程
struct list_head sibling;//兄弟进程
struct task_struct *group_leader;//所属进程组的主进程
fork函数对应的内核处理过程do_fork
- Linux提供三个创建进程的系统调用,do_fork()、vfork()无参数的,clone()带参数的。
分析do_fork()内容:
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;
// ...
// 复制进程描述符,返回创建的task_struct的指针
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);
// 取出task结构体内的pid
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
// 如果使用的是vfork,那么必须采用某种完成机制,确保父进程后运行
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
// 将子进程添加到调度器的队列,使得子进程有机会获得CPU
wake_up_new_task(p);
// ...
// 如果设置了 CLONE_VFORK 则将父进程插入等待队列,并挂起父进程直到子进程释放自己的内存空间
// 保证子进程优先于父进程运行
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;
}
-
得到它的实际处理内容如下:
a: 调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
b:初始化vfork的完成处理信息(如果是vfork调用)
c:调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
d:如果是vfork调用,需要阻塞父进程,知道子进程执行exec。 -
进程的建立过程顺序大致为:fork() ,sys_clone() ,do_fork() , dup_task_struct() , copy_process() , copy_thread(), ret_from_fork()
使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
- 输入命令如下:
这样就启动了MenuOS:
2. 使用gdb进行调试
qemu-system-i386 -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd rootfs.img -s -S -append nokaslr
gdb
(gdb) file linux-3.18.6/vmlinux
(gdb) target remote:1234
(gdb) b sys_clone
(gdb) b do_fork
(gdb) b dup_task_struct
(gdb) b copy_process
(gdb) b copy_thread
分别在sys_clone、do_fork、dup_task_struct、copy_process和copy_thread函数调用处加上断点:
可以得到如下的结果:
可执行文件的加载
- ELF即:Executable and Linking Format. 意为可执行可关联的文件。
加载流程:execve –> do_execve –> search_binary_handle –> load_binary。 - 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接.
- 编写一个helloworld.c程序,再对源文件进行编译链接生成可执行文件。
- 利用gdb和qemu工具来跟踪分析,首先给do_execve函数打上断点,
进行跟踪可以得到以下结果:
显然,当调用新的可执行程序时,优先进入内核态调用do_execve处理函数,并使用堆栈对原来的现场进行保护。然后,根据返回的可执行文件的地址,对当前可执行文件进行覆盖。由于返回地址为调用可执行文件的main函数入口,所以可以继续执行该文件。
跟踪分析schedule函数
各处理函数的调用顺序如下:pick_next_task -> context_switch -> __switch_to 。由此可以得出,当进程间切换时,首先需要调用pick_next_task函数挑选出下一个将要被执行的程序;然后再进行进程上下文的切换,此环节涉及到“保护现场”及“现场恢复”;在执行完以上两个步骤后,调用__switch_to进行进程间的切换。
分析switch_to中的汇编代码
asm volatile("pushfl\n\t" //保存当前进程的标志寄存器内容
"pushl %%ebp\n\t" //保存堆栈基址寄存器内容
"movl %%esp,%[prev_sp]\n\t" // 保存栈顶指针
"movl %[next_sp],%%esp\n\t" // 将下一个进程的栈顶指针放到esp寄存器中,切换内核堆栈
"movl $1f,%[prev_ip]\n\t" // 保存当前进程的eip
"pushl %[next_ip]\n\t" //将下一个进程的eip压栈
__switch_canary
"jmp __switch_to\n"
"1:\t" //next进程开始执行
"popl %%ebp\n\t" //恢复堆栈基址
"popfl\n" //恢复PSW
// output parameters
/* prev_sp是内核堆栈栈顶,prev_ip是当前进程的eip */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip), //[prev_ip]是标号
"=a" (last),
"=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),
[prev] "a" (prev),
[next] "d" (next)
__switch_canary_iparam
: /* reloaded segment registers */
"memory");
switch_to的主要内容包括:在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。将prev的内核堆栈指针ebp存入prev->thread.esp中。把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中。将popl指令所在的地址保存在prev->thread.eip中。
总结
总的来说,此次实验加深了对进程创建,文件加载,进程执行和切换的理解。内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度。schedule()函数实现进程调度,context_ switch完成进程上下文切换,switch_ to完成寄存器的切换。用户态进程不能进行主动调度,只在中断处理过程中进行调度。