学号后三位为478
原创作品转载请注明出处 : https://github.com/mengning/linuxkernel/
一、实验环境
Linux-5.0.1
VMware Workstation Pro
Ubuntu 14.04
二、实验目的
从整体上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换。
三、实验内容
1.task_struct数据结构
为了管理进程,操作系统必须对每个进程所做的事情进行清楚的描述,为此,操作系统使用数据结构来代表处理不同的实体,这个数据结构就是通常所说的进程描述符或进程控制块PCB。关键代码和注释如下:
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;//所属进程组的主进程
2.分析fork函数对应的内核处理过程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;
}
do_fork():-调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息;初始化vfork的完成处理信息(如果是vfork调用);调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行;如果是vfork调用,需要阻塞父进程,直到子进程执行exec。
copy_process():检查各种标志位(已经省略);调用dup_task_struct复制一份task_struct结构体,作为子进程的进程描述符;检查进程的数量限制;初始化定时器、信号和自旋锁; 初始化与调度有关的数据结构,调用了sched_fork,这里将子进程的state设置为TASK_RUNNING;复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等;调用copy_thread,这又是关键的一步,这里设置了子进程的堆栈信息;为子进程分配一个pid; 设置子进程与其他进程的关系,以及pid、tgid等。这里主要是对线程做一些区分。
copy_thread():获取子进程寄存器信息的存放位置;对子进程的thread.sp赋值,将来子进程运行,这就是子进程的esp寄存器的值; 如果是创建内核线程,那么它的运行位置ret_from_kernel_thread,将这段代码的地址赋给thread.ip,之后准备其他寄存器信息,退出;将父进程的寄存器信息复制给子进程;将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0;子进程从ret_from_fork开始执行,所以它的地址赋给thread.ip,也就是将来的eip寄存器。
3.使用gdb跟踪分析一个fork系统调用内核处理函数do_fork()
(1)启动MenuOS
cd LinuxKernel
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
make rootfs
(2)gdb调试
gdb
gdb>file LinuxKernel/linux-5.0.1/vmlinux
gdb>target remote:1234
(3)设置断点
b sys_clone
b _do_fork
b dup_task_struct
b copy_process
4.理解编译链接的过程和ELF可执行文件格式
(1)编译链接的过程
(2)ELF可执行文件格式
一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件。
一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
一个共享object文件保存着代码和合适的数据,用来被不同的两个链接器链接。
5.编程使用exec*库函数加载一个可执行文件
调用链:sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread()
6.进程调度的时机
中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
7.使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
首先设置断点,schedul函数选择一个新的进程来运行,并调用context_switch进行上下文的切换。context_switch首先调用switch_mm切换CR3,然后调用宏switch_to来进行硬件上的上下文切换。
8.特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
调用关系:schedule() --> context_switch() --> switch_to --> switch_to()
switch_to实现了进程之间的切换:
首先在当前进程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系统的执行过程的理解:
在调度时机方面,内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度。
schedule()函数实现进程调度,context switch完成进程上下文切换,switch to完成寄存器的切换。
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。