结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程
实验要求:
- 以fork和execve系统调用为例分析中断上下文的切换
- 分析execve系统调用中断上下文的特殊之处
- 分析fork子进程启动执行时进程上下文的特殊之处
- 以系统调用作为特殊的中断,结合中断上下文切换和进程上下文切换分析Linux系统的一般执行过程
execve系统调用:
execve执行过程:
Linux系统提供了execl、execlp、execle、execv、execvp、execve等6个用以加载执行一个可执行文件的库函数,这些库函数统称为exec函数,差异在于对命令行参数和环境变量参数的传递方式不同。exec函数都是通过execve系统调用进入内核,对应的系统调用内核处理函数为sys_execve或__x64_sys_execve,它们都是通过调用do_execve来具体执行加载可执行文件的工作。
调用关系为:
sys_execve() 或__x64_sys_execve
-> do_execve()
–> do_execveat_common()
-> __do_execve_file 构建binprm结构体
-> exec_binprm()
-> search_binary_handler() 解析当前可执行文件的入口
-> load_elf_binary() 校验文件,加载文件到内存,映射到进程的地址空间
-> start_thread()
抽象来说,execve执行主要有以下几步:
- 当前进程在执行
- 执行到execve系统调用时陷入内核态
- 在内核态中加载可执行文件,把当前进程的可执行程序给覆盖掉
- execve返回新的可执行程序的起点
- 新的可执行文件执行
execve与一般系统调用的区别:
execve加载新的可执⾏程序,进而覆盖了父进程的上下⽂环境,在内核中帮我们重新布局了新的用户态执行环境。
对于静态链接与动态链接,确定新的可执行程序的起点的方式不一样,对于静态链接的程序,堆栈上的返回地址会修改为程序入口点的地址,使得程序可以从main函数的位置开始执行;对于动态链接的程序,需要ld链接好动态链接库再从main函数开始执行。
区别于fork,execve 函数执行成功后不会返回,而且代码段、数据段、bss段和调用进程的栈会被加载进来的程序覆盖掉(fork为有返回,申请新的资源创建进程)。
fork系统调用:
fork执行过程:
fork系统调用创建了一个子进程,子进程复制了父进程中所有的进程信息,包括内核堆栈、进程描述符等,子进程作为一个独立的进程也会被调度。
fork的返回值有三种情况,分别为负值,表示子进程创建失败;0值,返回到新创建的子进程;正值返回父进程。
Linux中创建进程可以通过forK、vfork、clone三个系统调用来创建。
//kernel/fork.c
#ifdef __ARCH_WANT_SYS_FORK
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};
return _do_fork(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}
#endif
#ifdef __ARCH_WANT_SYS_VFORK
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return _do_fork(&args);
}
#endif
#ifdef __ARCH_WANT_SYS_CLONE
#ifdef CONFIG_CLONE_BACKWARDS
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
unsigned long, tls,
int __user *, child_tidptr)
#elif defined(CONFIG_CLONE_BACKWARDS2)
SYSCALL_DEFINE5(clone, unsigned long, newsp, unsigned long, clone_flags,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#elif defined(CONFIG_CLONE_BACKWARDS3)
SYSCALL_DEFINE6(clone, unsigned long, clone_flags, unsigned long, newsp,
int, stack_size,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#else
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
#endif
{
struct kernel_clone_args args = {
.flags = (clone_flags & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (clone_flags & CSIGNAL),
.stack = newsp,
.tls = tls,
};
if (!legacy_clone_args_valid(&args))
return -EINVAL;
return _do_fork(&args);
}
#endif
从上述代码中可以看出,三个可以用于创建新进程的系统调用,实际上都是通过_do_fork函数来实现的,虽然在实现上有细微的差别,但我们先不考虑,主要关注_do_fork的实现。以下是其代码逻辑(具体代码较为繁琐,因此主要分析代码结构,具体代码可以在kernel/fork.c中找到):
long _do_fork(struct kernel_clone_args *args) {
//复制进程描述符和执行时所需的其他数据结构
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
wake_up_new_task(p);//将子进程添加到就绪队列
return nr;//返回子进程pid(父进程中fork返回值为子进程的pid)
}
copy_process函数的主要执行步骤如下:
static __latent_entropy struct task_struct *copy_process( struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args) {
//复制进程描述符task_struct、创建内核堆栈等
p = dup_task_struct(current, node);
/* copy all the process information */
shm_init_task(p);
...
// 初始化子进程内核栈和thread
retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p, args->tls);
...
return p;//返回被创建的子进程描述符指针
}
这其中又包含了两个重要的函数dup_task_struct、copy_thread_tls。其中dup_task_struct用于复制进程描述符task_struct、创建内核堆栈等;copy_thread_tls用于初始化⼦进程内核栈、设置⼦进程pid等。
综上,进程创建的主要过程如下:
-
进入forK、vfork、clone等系统调用
-
调用_do_fork内核函数:
- copy_process
复制进程描述符和执⾏时所需的其他数据结构- dup_task_struct
复制进程描述符task_struct、创建内核堆栈等 - copy_thread_tls
初始化⼦进程内核栈、设置⼦进程pid等
- dup_task_struct
- wake_up_new_task
将⼦进程添加到就绪队列
- copy_process
-
系统调用返回
fork与一般系统调用的区别:
除了0号进程是通过硬件编码生成的,其余所有的进程均是由fork系统调用创建。
运行fork系统调用后,原进程在内核里面变成了父子两个进程,父进程正常运行fork系统调用返回到用户态,fork出来的子进程也要从内核里返回到用户态。即fork函数存在两次返回。
其中父进程的操作与一般的系统调用无异,子进程在创建之后从起始点ret_from_fork开始执行,但对于父子进程的执行顺序,并没有严格的规定。
以下分别为一般系统调用的内核堆栈、fork系统调用的内核堆栈。增加的struct inactive_task_frame中主要用于保存fork⼦进程的进程上下⽂。


中断上下文切换和进程上下文切换
CPU对处于内核态根据上下文环境进一步细分,有以下三种状态:
(1)内核态,运行于进程上下文,内核代表进程运行于内核空间。
(2)内核态,运行于中断上下文,内核代表硬件运行于内核空间。
(3)用户态,运行于用户空间。
所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
所谓的“ 中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
同时需要明确,进程调度仅在以下三种情况下发生:
- 用户进程上下文中主动调用特定的系统调用进入中断上下文,系统调用返回用户态之前进行进程调度。
- 内核线程或可中断的中断处理程序,执行过程中发生中断进入中断上下文,在中断返回前进行进程调度。
- 内核线程主动调用schedule函数进行进程调度
进程上下文切换:
内核挂起正在CPU上运行的进程,并恢复执行以前挂起的某个进程,这种行为被称为进程切换,任务切换或进程上下文切换。
进程上下文包含了进程执行需要的所有信息:
- 用户地址空间:包括程序代码、数据、用户堆栈等。
- 控制信息:进程描述符、内核堆栈等。
- 进程的CPU上下文,相关寄存器的值。
所以切换进程,实际上就是变更进程上下文。
中断上下文切换:
中断上下文切换主要是指如下过程:
- 中断源发出中断请求
- CPU响应这个请求,现行程序被中断
- 转至中断服务程序,直到中断服务程序执行完毕
- CPU再返回原来的程序继续执行
中断上下文的切换包含了用户态与内核态的转换。中断上下文在中断执行过程中也是必不可少。
Linux系统的一般执行过程:
-
正在运行的⽤户态进程X。
-
发生中断(包括异常、系统调用等),CPU完成load cs:rip(entry of a specific ISR),即跳转到中断处理程序⼊⼝。
-
中断上下文切换,具体包括如下⼏点:
-
swapgs指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了⼀个快照。
-
rsp point to kernel stack,加载当前进程内核堆栈栈顶地址到RSP寄存器。快速系统调⽤是由系统调用入口处的汇编代码实现⽤户堆栈和内核堆栈的切换。
-
save cs:rip/ss:rsp/rflags:将当前CPU关键上下⽂压⼊进程X的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的。
-
此时完成了中断上下⽂切换,即从进程X的⽤户态到进程X的内核态。
-
中断处理过程中或中断返回前调⽤了schedule函数,其中完成了进程调度算法选择next进程、进程地址空间切换、以及switch_to关键的进程上下⽂切换等。
-
switch_to调用了__switch_to_asm汇编代码做了关键的进程上下问文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(本例假定为进程Y)的内核堆栈,并完成了进程上下⽂所需的指令指针寄存器状态切换。之后开始运⾏进程Y(这⾥进程Y曾经通过以上步骤被切换出去,因此可以从switch_to下⼀⾏代码继续执⾏)。
-
中断上下文恢复,与(3)中断上下⽂切换相对应。注意这⾥是进程Y的中断处理过程中,而(3)中断上下⽂切换是在进程X的中断处理过程中,因为内核堆栈从进程X切换到进程Y了
-
为了对应中断上下⽂恢复的最后⼀步单独拿出来(6的最后⼀步即是7)iret - pop cs:rip/ss:rsp/rflags,从Y进程的内核堆栈中弹出(3)中对应的压栈内容。此时完 成了中断上下⽂的切换,即从进程Y的内核态返回到进程Y的⽤户态。注意快速系统调⽤返回sysret与iret的处理略有不同。
-
继续运行用户态进程Y。

浙公网安备 33010602011771号