从整理上理解进程创建、可执行文件的加载和进程执行进程切换,重点理解分析fork、execve和进程切换
学号:339
原创作品转载请注明出处+ https://github.com/mengning/linuxkernel/
实验环境: 实验楼https://www.shiyanlou.com/courses/195
实验要求
- 阅读理解task_struct数据结构;
- 分析fork函数对应的内核处理过程do_fork,使用gdb跟踪分析一个fork系统调用内核处理,函数do_fork;
- 理解编译链接的过程和ELF可执行文件格式;
- 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve;
- 使用gdb跟踪分析一个schedule()函数;
- 分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系;
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;//所属进程组的主进程
- 在linux操作系统下这就是task_struct结构 ,所属的头文件#include <sched.h>每个进程都会被分配一个task_struct结构,它包含了这个进程的所有信息,在任何时候操作系统都能够跟踪这个结构的信息。保存进程信息的数据结构叫tast_struct,进程的信息可以通过/proc系统文件夹查看。
2. 分析fork函数对应的内核处理过程do_fork,使用gdb跟踪分析一个fork系统调用内核处理,函数do_fork
Linux提供三个创建进程的系统调用:do_fork(); vfork(); clone()
- fork是重量级调用,它建立了父进程的一个完整副本,然后为子进程执行。
- vfork类似于fork,但并不创建父进程数据的副本。相反,父子进程之间共享数据。
- clone产生线程,可以对父子进程之间的共享、复制进行精确控制。
- fork、vfork和close系统调用的入口分别是sys_fork、sys_vfork和sys_clone函数。以上函数从寄存器中取出由用户定义的信息,并调用与体系结构无关的do_fork函数进行进程的复制
- 实际处理
a. 调用copy_process,将当期进程复制一份出来为子进程,并且为子进程设置相应地上下文信息
b. 初始化vfork的完成处理信息
c. 调用wake_up_new_task,将子进程放入调度器的队列中,此时的子进程就可以被调度进程选中,得以运行
d. 如果是vfork调用,需要阻塞父进程,直到子进程执行exec
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;
}
3. 理解编译链接的过程和ELF可执行文件格式
//main.c
int add(int a,int b);
static int si;//.bss
extern int buf[];
int *copy = &buf[0];//.rel.data
int main()
{
int a = 3;
int b = 5;
int c = add(a,b);//.rel.text
char *s = "hello c";//.rodata
static int si;//.bss
return 0;
}
//add.c
int buf[2];
int add(int a,int b)
{
return (a+b);
}
//makefile(为了简化讨论,makefile文件中没有添加-g选项)
all:main
main:main.o add.o
gcc -o main main.o add.o -m32
main.o:main.c
gcc -c main.c -m32
add.o:add.c
gcc -c add.c -m32
clean:
rm -rf *.o main
源代码(.c .cpp .h)经过c预处理器(cpp)后生成.i文件,编译器(cc1、cc1plus)编译.i文件后生成.s文件,汇编器(as)汇编.s文件后生成.o文件,链接器(ld)链接.o文件生成可执行文件。
gcc是对cpp、cc1(cc1plus)、as、ld这些后台程序的包装,它会根据不同的参数要求去调用后台程序。
4. 使用gdb跟踪分析一个execve系统调用内核处理函数do_execve
- 利用gdb和qemu工具来跟踪分析,首先给do_execve函数打上断点,当调用新的可执行程序时,会先进入内核态调用do_execve处理函数,并使用堆栈对原来的现场进行保护。
- 然后,根据返回的可执行文件的地址,对当前可执行文件进行覆盖。由于返回地址为调用可执行文件的main函数入口,所以可以继续执行该文件。
5. 使用gdb跟踪分析一个schedule()函数
先对schedule,pick_next_task,context_switch和__switch_to设置断点,在进行进程间的切换时,各处理函数的调用顺序如下:pick_next_task -> context_switch -> __switch_to 。由此可以得出,当进程间切换时,首先需要调用pick_next_task函数挑选出下一个将要被执行的程序;然后再进行进程上下文的切换,此环节涉及到“保护现场”及“现场恢复”;在执行完以上两个步骤后,调用__switch_to进行进程间的切换。
6. 分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
//schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换
next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部
context_switch(rq, prev, next);//进程上下文切换
//switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程
#define switch_to(prev, next, last) \
do { \
/* \
* Context-switching clobbers all registers, so we clobber \
* them explicitly, via unused output variables. \
* (EAX and EBP is not listed because EBP is saved/restored \
* explicitly for wchan access and EAX is the return value of \
* __switch_to()) \
*/ \
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 */ \ 将当前进程栈顶保存prev->thread.sp
"movl %[next_sp],%%esp\n\t" /* restore ESP */ \ 讲下一个进程栈顶保存到esp中
"movl $1f,%[prev_ip]\n\t" /* save EIP */ \ 保存当前进程的eip
"pushl %[next_ip]\n\t" /* restore EIP */ \ 将下一个进程的eip压栈,next进程的栈顶就是他的的起点
__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)
通过系统调用,用户空间的应用程序就会进入内核空间,由内核代表该进程运行于内核空间,这就涉及到上下文的切换,用户空间和内核空间具有不同的地址映射,通用或专用的寄存器组,而用户空间的进程要传递很多变量、参数给内核,内核也要保存用户进程的一些寄存器、变量等,以便系统调用结束后回到用户空间继续执行.
所谓的进程上下文,就是一个进程在执行的时候,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容,当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的进程上下文,以便再次执行该进程时,能够恢复切换时的状态,继续执行。