学号131原创作品转载请注明出处
本实验来源 https://github.com/mengning/linuxkernel/
实验环境
Ubuntu 18.04 虚拟机
VMware Workstation Pro 15.0.2 for Windows
实验要求
- 阅读理解task_struct数据结构http://codelab.shiyanlou.com/xref/linux-3.18.6/include/linux/sched.h#1235;
- 分析fork函数对应的内核处理过程do_fork,理解创建一个新进程如何创建和修改task_struct数据结构;
- 使用gdb跟踪分析一个fork系统调用内核处理函数do_fork ,验证您对Linux系统创建一个新进程的理解,特别关注新进程是从哪里开始执行的?为什么从那里能顺利执行下去?即执行起点与内核堆栈如何保证一致。
- 理解编译链接的过程和ELF可执行文件格式;
- 编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接;
- 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解;
- 特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
- 理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
- 使用gdb跟踪分析一个schedule()函数 ,验证您对Linux系统进程调度与进程切换过程的理解;
- 特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系;
- 撰写一篇博客(署真实姓名或学号最后3位编号),并在博客文章中注明“原创作品转载请注明出处 + https://github.com/mengning/linuxkernel/ ”,博客内容的具体要求如下:
- 题目自拟,内容围绕Linux系统的执行过程进行;
- 博客中需要使用实验截图
- 博客内容中需要仔细分进程创建、可执行文件的加载和进程执行进程切换
- 总结部分需要阐明自己对Linux系统的执行过程的理解。
- 博客URL提交到https://github.com/mengning/linuxkernel/issues/32 截止日期3月26日24:00
实验步骤
1.阅读理解task_struct数据结构
在了解PCB是什么之前,我们先了解一下进程到底是什么。它和程序又有什么区别?
程序:二进制可执行文件,是一个机器代码指令和数据的集合,存储在硬盘里,是一个静态的实体。 //指令+数据
进程:是操作系统对一个正在运行的程序的一种抽象,将二进制可执行文件加载到内存里。 //指令+数据+PCB
对于多道程序系统来说,其内存中可能存在着多个进程,为了方便管理这些进程,操作系统内核为每个进程都建立了一个结构体来保存与其相关的信息。这个结构体就是PCB,也就是进程控制块,它是进程实体的一部分,存在于进程的高1G空间。
Linux系统是采用链式方式来组织PCB的,对于不同的状态建立起一个进程队列。在Linux内核中,使用一个名为task_struct的结构体来描述PCB.
task_struct结构体包含了以下内容:
PCB中存了进程的标识符、进程状态、调度优先级、内存指针、上下文数据、信号量、审计信息等诸多关键信息。进程的执行需要配合PCB,比如通过PCB的 mm可以查看进程在内存中的空间,里面有可能存的是数据段,也有可能是代码段;可以通过 files里的句柄,对被进程打开的文件进行读写。
PS:Linux系统中,所有的进程共有的祖先进程是init进程(pid=1),init进程创建其他的进程,其他进程继而进一步创建其子进程,最终实现一个进程树。
2.分析fork函数对应的内核处理过程
Fork函数开始调用后,就开始调用函数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处理了以下内容:
1. 调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息。
2. 初始化vfork的完成处理信息(如果是vfork调用)
3. 调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行。
4. 如果是vfork调用,需要阻塞父进程,知道子进程执行exec。
下文提及的copy_process、copy_thread和dup_task_struct由于原代码过长读者可以自行去
https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.0.1.tar.xz
下载内核代码进行查看此处不再贴出
进程创建的关键copy_process其函数实现的具体功能流程如下:
1. 检查各种标志位(已经省略)
2. 调用dup_task_struct复制一份task_struct结构体,作为子进程的进程描述符。
3. 检查进程的数量限制。
4. 初始化定时器、信号和自旋锁。
5. 初始化与调度有关的数据结构,调用了sched_fork,这里将子进程的state设置为TASK_RUNNING。
6. 复制所有的进程信息,包括fs、信号处理函数、信号、内存空间(包括写时复制)等。
7. 调用copy_thread,这又是关键的一步,这里设置了子进程的堆栈信息。
8. 为子进程分配一个pid
9. 设置子进程与其他进程的关系,以及pid、tgid等。这里主要是对线程做一些区分。
接下来是dup_task_struct、copy_thread两个函数。前者为子进程分配了内核栈空间,而后者让子进程复制了父进程的上下文信息,并且让子进程从ret_from_fork处开始执行。
最后是新进程的执行
新进程从ret_from_fork处开始执行,子进程的运行是由这几处保证的:
1. dup_task_struct中为其分配了新的堆栈
2. copy_process中调用了sched_fork,将其置为TASK_RUNNING
3. copy_thread中将父进程的寄存器上下文复制给子进程,这是非常关键的一步,这里保证了父子进程的堆栈信息是一致的。
4. 将ret_from_fork的地址设置为eip寄存器的值,这是子进程的第一条指令。
这边利用《深入linux内核架构》中早期的do_fork流程图来简要的进行说明
3.使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
断点的设置如下:
sys_clone
do_fork
dup_task_struct
copy_process
copy_thread
ret_from_fork
alloc_thread_info_node
具体分析:
调试的整个过程的流程图如下:
具体的函数功能在上文的分析fork函数对应的内核处理过程中已经全部详细介绍了。有兴趣的可以去上文查看。
【Q1】执行起点与内核堆栈如何保持一致?
在ret_from_fork之前,也就是在copy_thread()函数中:
*childregs = *current_pt_regs();
该句将父进程的regs参数赋值到子进程的内核堆栈,*childregs的类型为pt_regs,里面存放了SAVE ALL中压入栈的参数。故在之后的RESTORE ALL中能顺利执行下去。
【Q2&3】新进程是从哪里开始执行的?为什么从哪里能顺利执行下去?
//函数copy_process中的copy_thread()//
int copy_thread(unsigned long clone_flags, unsigned long sp,
unsigned long arg, struct task_struct *p)
{
...
*childregs = *current_pt_regs();
childregs->ax = 0;
if (sp)
childregs->sp = sp;
p->thread.ip = (unsigned long) ret_from_fork;
...
}
//子进程执行ret_from_fork//
ENTRY(ret_from_fork)
CFI_STARTPROC
pushl_cfi %eax
call schedule_tail
GET_THREAD_INFO(%ebp)
popl_cfi %eax
pushl_cfi $0x0202 # Reset kernel eflags
popfl_cfi
jmp syscall_exit
CFI_ENDPROC
END(ret_from_fork)
这里说明两个问题:
1.为什么 fork 在子进程中返回0?原因是childregs->ax = 0;
这段代码将子进程的 eax 赋值为0。
2.p->thread.ip = (unsigned long) ret_from_fork;这句代码将子进程的 ip 设置为 ret_form_fork 的首地址,因此子进程是从 ret_from_fork 开始执行的。
因此,函数copy_process中的copy_thread()决定了子进程从系统调用中返回后的执行。
4.理解编译链接的过程和ELF可执行文件格式
1.从源文件Hello.c编译链接成Hello.out,需要经历如下步骤:
2.ELF可执行文件格式
一个可重定位(relocatable)文件保存着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者是一个共享文件。
一个可执行(executable)文件保存着一个用来执行的程序;该文件指出了exec(BA_OS)如何来创建程序进程映象。
一个共享object文件保存着代码和合适的数据,用来被不同的两个链接器链接。
3.流程图:(sys_execve() > do_execve() > do_execveat_common > search_binary_handler() > load_elf_binary())
5. 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解
1.根据上文的execve的流程图设置以下断点:
sys_execv
load_elf_binary
要注意,不要在一开始的时候便设置断点,否则启动将异常困难。
2.在MenuOS执行exec后,中断情况如下:
具体分析:
在do_execve中真正地实现了对程序的执行。这个函数定义了一个名为linux_binprm的结构体来存储所要运行进程的信息。
struct linux_binprm{
char buf[BINPRM_BUF_SIZE]; //保存可执行文件的头128字节
struct page *page[MAX_ARG_PAGES];
struct mm_struct *mm;
unsigned long p; //当前内存页最高地址
int sh_bang;
struct file * file; //要执行的文件
int e_uid, e_gid; //要执行的进程的有效用户ID和有效组ID
kernel_cap_t cap_inheritable, cap_permitted, cap_effective;
void *security;
int argc, envc; //命令行参数和环境变量数目
char * filename; //要执行的文件的名称
char * interp; //要执行的文件的真实名称,通常和filename相同
unsigned interp_flags;
unsigned interp_data;
unsigned long loader, exec;
};
然后用do_open_exec()来打开文件,加载文件头部,把环境变量和参数调入至调用函数中。list_for_each_entry()尝试寻找可以解析当前可执行文件的代码。实际上调用的是load_elf_binary。
load_elf_binary:
加载elf类型文件的handler是load_elf_binary(),它先读入ELF文件的头部,根据ELF文件的头部信息读入各种数据(header information)。再次扫描程序段描述表,找到类型为PT_LOAD的段,将其映射(elf_map())到内存的固定地址上。如果没有动态链接器的描述段,把返回的入口地址设置成应用程序入口。完成这个功能的是start_thread(),start_thread()并不启动一个线程,而只是用来修改了pt_regs中保存的PC等寄存器的值,使其指向加载的应用程序的入口。这样当内核操作结束,返回用户态的时候,接下来执行的就是应用程序了。
【Q1】新的可执行程序是从哪里开始执行的?
当sys_execve()系统调用从内核态返回到用户态时,EIP寄存器直接跳转到ELF程序的入口地址。
【Q2】对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
静态链接:elf_entry指向可执行文件的头部,一般是main函数,是新程序执行的起点,一般地址为0x8048XXX的位置。
动态链接:elf_entry指向ld即动态链接器的起点load_elf_interp。
6. 使用gdb调试一个schedule()函数
分别在schedule,pick_next_task,context_switch,__switch_to设置断点
1、首先在schedule处停下来
2、继续执行,到__schedule中的关键函数pick_next_task停下
可以看出 schedule调用_schedule,_schedule调用pick_next_task,context_switch函数,context_switch函数调用__switch_to。
7、仔细分析switch_to中的汇编代码
#define switch_to(prev, next, last)
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)
分析:
这个宏实现了进程之间的真正切换:
首先在当前进程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进程就成为当前进程而真正开始执行。
8、进程上下文及与中断上下文切换的关系
进程上下文切换需要保存切换进程的相关信息(thread.sp和thread.ip);中断上下文的切换是在一个进程的用户态到一个进程的内核态,或从进程的内核态到用户态,切换进程需要在不同的进程间切换,但一般进程上下文切换是套在中断上下文切换中的。例如,系统调用作为中断陷入内核,,调用schedule函数发生进程上下文切换,系统调用返回,完成中断上下文的切换。
实验总结
1.“Linux系统创建一个新进程”的理解
Linux通过复制父进程来创建一个新进程,通过调用do_fork来实现并为每个新创建的进程动态地分配一个task_struct结构。为了把内核中的所有进程组织起来,Linux提供了几种组织方式,其中哈希表和双向循环链表方式是针对系统中的所有进程(包括内核线程),而运行队列和等待队列是把处于同一状态的进程组织起来。
fork()函数被调用一次,但返回两次。
可以通过fork,复制一个已有的进程,进而产生一个子进程,新进程几乎但不完全与父进程相同。子进程得到和父进程用户级虚拟地址空间相同的一份拷贝,包括代码段,数据段和bss段,堆以及用户栈。子进程还获得和父进程任何打开文件描述符相同的拷贝,最大的区别就是在于他们拥有不同的PID.
2.“Linux内核装载和启动一个可执行程序”
linux通过sys_execve()系统调用从文件系统中读取、识别并加载elf。
调用sys_execve后,执行过程:
do_execve -> do_execve_common -> exec_binprm->load_elf_binary()->sys_close
根据elf的库类型,elf_entry是不一样的。load_elf_binary通过解析器将不同的入口地址写入。
3.对“Linux系统一般执行过程”的理解
1.在调度时机方面,内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度。
2.schedule()函数实现进程调度,context_ switch完成进程上下文切换,switch_ to完成寄存器的切换。
3.用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
参考资料
《Linux内核设计与实现》第三版
https://www.cnblogs.com/paperfish/p/5333529.html