学号239
原创作品,转载请注明出处。
本实验资源来源: https://github.com/mengning/linuxkernel/
实验环境:
1,VMware Workstation Pro
2,Ubuntu 18.04 虚拟机
一、阅读理解task_struct数据结构
代码来源:http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235
该结构部分代码:
struct task_struct {
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack;
atomic_t usage;
unsigned int flags; /* per process flags, defined below */
unsigned int ptrace;
#ifdef CONFIG_SMP
struct llist_node wake_entry;
int on_cpu;
struct task_struct *last_wakee;
unsigned long wakee_flips;
unsigned long wakee_flip_decay_ts;
int wake_cpu;
#endif
int on_rq;
int prio, static_prio, normal_prio;
unsigned int rt_priority;
const struct sched_class *sched_class;
struct sched_entity se;
struct sched_rt_entity rt;
#ifdef CONFIG_CGROUP_SCHED
struct task_group *sched_task_group;
#endif
struct sched_dl_entity dl;
#ifdef CONFIG_PREEMPT_NOTIFIERS
/* list of struct preempt_notifier: */
struct hlist_head preempt_notifiers;
#endif
#ifdef CONFIG_BLK_DEV_IO_TRACE
unsigned int btrace_seq;
#endif
unsigned int policy;
int nr_cpus_allowed;
cpumask_t cpus_allowed;
...
}
在阅读这个结构体之前,我们必须了解进程与程序的区别,进程是程序的一个执行的实例,为了管理进程,操作系统必须对每个进程所做的事情进行清楚的描述,为此,操作系统使用数据结构来代表处理不同的实体,这个数据结构就是通常所说的进程描述符或进程控制块(PCB),在linux操作系统下这就是task_struct结构 ,它包含了这个进程的所有信息,在任何时候操作系统都能够跟踪这个结构的信息.
其中大概包含了以下内容:
PCB |
---|
进程的标识符 |
进程状态 |
调度优先级 |
内存指针 |
上下文数据 |
…… |
进程的执行需要配合PCB,比如通过PCB的 mm可以查看进程在内存中的空间,里面有可能存的是数据段,也有可能是代码段;可以通过 files里的句柄,对被进程打开的文件进行读写。
二、分析fork函数对应的内核处理过程do_fork
fork、vfork、clone三个系统调用都可以创建一个新进程,而且都是通过调用do_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。
三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
使用内核5.0启动Menu OS
cd LinuxKernel
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
make rootfs
打开gdb开始调试并打上以下断点:
1.sys_clone
2.do_fork
3.dup_task_struct
4.copy_process
5.copy_thread
6.ret_from_fork
7.alloc_thread_info_node
开始调试
运行结果:
在do_fork函数中,以ret_from_fork函数为执行起点,复制父进程的内存堆栈和数据,并修改某些参数实现子进程的定义和初始化,创建子进程的工作完成后,通过sys_call exit函数退出并pop父进程的内存堆栈,实现新进程的创建工作。
四、理解编译链接的过程和ELF可执行文件格式
从源文件Hello.c编译链接成Hello.out,需要经历如下步骤:
ELF可执行文件格式:
ELF文件格式包括三种主要的类型:可执行文件、可重定向文件、共享库:
1.一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
2.一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件。
3.一个共享库文件保存着代码和合适的数据,用来被不同的两个链接器链接。
五、程序装载
编程使用exec*库函数加载一个可执行文件
在之前的fork程序中加入
execlp("/bin/ls",“ls”,NULL);
接着使用gdb跟踪do_execve:
可以看出,调用顺序为sys_execve()->do_execve()->do_execveat_common()->__do_execve_file()->prepare_binprm()->search_binary_handler()->load_elf_binary()->start_thread()。
六、进程调度
使用gdb跟踪一个schedule()函数:
可以看出 schedule调用_schedule,_schedule调用pick_next_task,context_switch函数,context_switch函数调用__switch_to
switch_to中的汇编代码:
do {
unsigned long ebx, ecx, edx, esi, edi;
asm volatile("pushfl\n\t" /* save flags */
"pushl %%ebp\n\t" /* save EBP */
"movl %%esp,%[prev_sp]\n\t" /* save ESP */
"movl %[next_sp],%%esp\n\t" /* restore ESP */
"movl $1f,%[prev_ip]\n\t" /* save EIP */
"pushl %[next_ip]\n\t" /* restore EIP */
__switch_canary
"jmp __switch_to\n" /* regparm call */
"1:\t"
"popl %%ebp\n\t" /* restore EBP */
"popfl\n" /* restore flags */
/* output parameters */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip),
"=a" (last),
/* clobbered output registers: */
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
__switch_canary_oparam
/* input parameters: */
: [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)
可以看出进程切换过程如下:
1.在当前进程prev的内核栈中保存esi,edi及ebp寄存器的内容。
2.将prev的内核堆栈指针ebp存入prev->thread.esp中。
3.把将要运行进程next的内核栈指针next->thread.esp置入esp寄存器中
4.将popl指令所在的地址保存在prev->thread.eip中,这个地址就是prev下一次被调度
5.通过jmp指令转入一个函数__switch_to()
6.恢复next上次被调离时推进堆栈的内容。从现在开始,next进程就成为当前进程而真正开始执行。
六、总结
(1)Linux通过复制父进程来创建一个新进程,通过调用do_fork来实现并为每个新创建的进程动态地分配一个task_struct结构。fork()函数被调用一次,但返回两次。可以通过fork,复制一个已有的进程,进而产生一个子进程。
(2)Linux的进程调度基于分时技术和进程的优先级,内核通过调用schedule()函数来实现进程调度,其中context_switch宏用于完成进程上下文切换,它通过调用switch_to宏来实现关键上下文切换。
(3)进程上下文切换需要保存切换进程的相关信息(thread.sp和thread.ip);中断上下文的切换是在一个进程的用户态到一个进程的内核态,或从进程的内核态到用户态,切换进程需要在不同的进程间切换,但一般进程上下文切换是套在中断上下文切换中的。