分析Linux系统的执行过程
- 一、阅读理解task_struct数据结构
- 二、分析fork函数对应的内核处理过程do_fork
- 三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
- 四、理解编译链接的过程和ELF可执行文件格式
- 五、编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接
- 六、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解
- 七、特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
- 八、理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
- 九、使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
- 十、分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
- 总结
原创作品转载请注明出处 https://github.com/mengning/linuxkernel/
作者:136
一、阅读理解task_struct数据结构
内核信息:linux-3.18.6
——下载
进程是处于执行期的程序以及它所管理的资源(如打开的文件、挂起的信号、进程状态、地址空间等等)的总称。Linux内核通过一个被称为进程描述符的task_struct结构体来管理进程,即进程描述符或进程控制块(PCB),这个结构体包含了一个进程所需的所有信息。它定义在linux-3.18.6/include/linux/sched.h
文件中。
1. 进程状态
代码语言:javascript复制volatile long state;
定义了一个 long 型的进程的状态state
,取值范围如下所示。
#define TASK_RUNNING 0 //进程要么正在执行,要么正要准备执行
#define TASK_INTERRUPTIBLE 1 //进程被阻塞(睡眠),直到某个条件变为真
#define TASK_UNINTERRUPTIBLE 2 //与TASK_INTERRUPTIBLE类似,但不能通过接受一个信号来唤醒以外
#define __TASK_STOPPED 4 //进程被停止执行
#define __TASK_TRACED 8 //进程被debugger等进程监视
/* in tsk->exit_state */
#define EXIT_ZOMBIE 16 //进程的执行被终止,但是其父进程还没有使用wait()等系统调用来获知它的终止信息(僵尸模式)
#define EXIT_DEAD 32 //进程的最终状态
/* in tsk->state again */
#define TASK_DEAD 64
#define TASK_WAKEKILL 128
#define TASK_WAKING 256
2. 进程标识符
代码语言:javascript复制pid_t pid;
pid_t tgid; #一个线程组中的所有线程使用和该线程组的领头线程(该组中的第一个轻量级进程)相同的PID
在CONFIG_BASE_SMALL配置为0的情况下,PID的取值范围是0到32767,即系统中的进程数最大为32768个。
3. 进程内核栈
代码语言:javascript复制void *stack;
进程通过alloc_thread_info函数分配它的内核栈,通过free_thread_info函数释放所分配的内核栈。
代码语言:javascript复制static inline struct thread_info *alloc_thread_info(struct task_struct *tsk)
{
#ifdef CONFIG_DEBUG_STACK_USAGE
gfp_t mask = GFP_KERNEL | __GFP_ZERO;
#else
gfp_t mask = GFP_KERNEL;
#endif
return (struct thread_info *)__get_free_pages(mask, THREAD_SIZE_ORDER);
}
static inline void free_thread_info(struct thread_info *ti)
{
free_pages((unsigned long)ti, THREAD_SIZE_ORDER);
}
4. 其他参数
代码语言:javascript复制//标记
unsigned int flags; /* per process flags, defined below */
//进程亲属关系的成员
struct task_struct *real_parent; /* real parent process */
struct task_struct *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children; /* list of my children */
struct list_head sibling; /* linkage in my parent's children list */
struct task_struct *group_leader; /* threadgroup leader */
//进程调度
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;
unsigned int policy;
cpumask_t cpus_allowed;
//进程地址空间
struct mm_struct *mm, *active_mm;
#ifdef CONFIG_COMPAT_BRK
unsigned brk_randomized:1;
#endif
#if defined(SPLIT_RSS_COUNTING)
struct task_rss_stat rss_stat;
#endif
//信号处理
/* signal handlers */
struct signal_struct *signal; //指向进程的信号描述符
struct sighand_struct *sighand; //指向进程的信号处理程序描述符
sigset_t blocked, real_blocked; //blocked表示被阻塞信号的掩码,real_blocked表示临时掩码
sigset_t saved_sigmask; /* restored if set_restore_sigmask() was used */
struct sigpending pending; //存放私有挂起信号的数据结构
unsigned long sas_ss_sp; //信号处理程序备用堆栈的地址
size_t sas_ss_size; //表示堆栈的大小
int (*notifier)(void *priv); //指向的函数来阻塞进程的某些信号
void *notifier_data; //notifier所指向的函数可能使用的数据
sigset_t *notifier_mask; //位掩码
还有其他成员的代码分析,可以参阅博客:https://www.cnblogs.com/zxc2man/p/6649771.html
。
二、分析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;
// ...
//copy PCB and return a created task_struct point
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);
//get pid of task struct
pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid);
if (clone_flags & CLONE_PARENT_SETTID)
put_user(nr, parent_tidptr);
//if use vfork
if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}
//add child process to queue and ready to get CPU
wake_up_new_task(p);
// ...
//if set a 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
,复制父进程task_struct
来创建一个新进程,要给新进程分配一个新的内核堆栈;然后调用wake_up_new_task
将子进程加入调度器,为之分配 CPU,如果是VFORK
,则父进程等待子进程完成 exec 替换自己的地址空间。
1. copy_process()
流程
- 调用
dup_task_struct()
复制当前的 task_struct,检查进程数是否超过限制; - 初始化自旋锁、挂起信号、CPU 定时器等;
- 调用
sched_fork
初始化进程数据结构,并把进程状态设置为 TASK_RUNNING,复制所有进程信息,包括文件系统、信号处理函数、信号、内存管理等; - 调用
copy_thread()
初始化子进程内核栈,为新进程分配并设置新的pid。
2. dup_task_struct()
流程
- 调用
alloc_task_struct_node()
分配一个 task_struct 节点; - 调用
alloc_thread_info_node()
分配一个 thread_info 节点,其实是分配了一个thread_union联合体,将栈底返回给 ti; - 最后将栈底的值 ti 赋值给新节点的栈。
3. copy_thread
的流程
- 获取子进程寄存器信息的存放位置
- 对子进程的
thread.sp
赋值,将来子进程运行,这就是子进程的esp寄存器的值。 - 如果是创建内核线程,那么它的运行位置是
ret_from_kernel_thread
,将这段代码的地址赋给thread.ip
,之后准备其他寄存器信息,退出 - 将父进程的寄存器信息复制给子进程。
- 将子进程的eax寄存器值设置为0,所以fork调用在子进程中的返回值为0.
- 子进程从
ret_from_fork
开始执行,所以它的地址赋给thread.ip
,也就是将来的eip寄存器。
4. 新进程从ret_from_fork
处开始执行
dup_task_struct
中为其分配了新的堆栈copy_process
中调用了sched_fork
,将其置为TASK_RUNNINGcopy_thread
中将父进程的寄存器上下文复制给子进程,这是非常关键的一步,这里保证了父子进程的堆栈信息是一致的。- 将
ret_from_fork
的地址设置为eip寄存器的值,这是子进程的第一条指令。
总的来说fork、vfork和clone三个系统调用都可以创建一个新进程,而且都是通过调用do_fork来实现进程的创建;具体过程如下:
代码语言:javascript复制fork() -> sys_clone() -> do_fork() -> dup_task_struct() -> copy_process() -> copy_thread() -> ret_from_fork()
三、使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
在上一次的基础上添加一个Fork()
函数,来调用do_fork
内核处理函数,函数代码如下。
int Fork(int argc, char *argv[])
{
int pid;
/* fork another process */
pid = fork();
if (pid<0)
{
/* error occurred */
fprintf(stderr,"Fork Failed!");
exit(-1);
}
else if (pid==0)
{
/* child process */
printf("This is Child Process, and my pid is %dn", pid);
}
else
{
/* parent process */
printf("This is Parent Process, and my pid is %dn", pid);
/* parent will wait for the child to complete*/
wait(NULL);
printf("Child Complete!n");
}
}
将程序保存为test_fork.c
文件,并修改 Makefile 文件,重新编译 MenuOS。
$ make rootfs #重新编译
输入“fork”,执行我们的Fork()
函数,可以看到输出了相应的信息。
确认程序无误之后,就可以进行调试了。
代码语言:javascript复制$ qemu -kernel linux-5.0.1/arch/x86/boot/bzImage -initrd rootfs.img -S -s -append nokaslr
$ gdb vmlinux #启动gdb调试
(gdb)b sys_clone #设置断点
(gdb)b _do_fork
(gdb)b dup_task_struct
(gdb)b copy_process
(gdb)b copy_thread
b ret_from_for
(gdb)target remote:1234 #设置远程链接
(gdb)c #continue,跳到端点处
(gdb)s #step,一步步调试
程序首先停止在系统调用__se_sys_clone
函数,一步步调试,分别进入 _do_fork、dup_task_struct、copy_process、copy_thread、ret_from_for 断点处。
建议使用内核Linux-3.18.6
来进行实验,这里用到的 5.0.1 内核有些函数名可能发生了变化。
四、理解编译链接的过程和ELF可执行文件格式
ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。
- 动态链接库(Dynamic Linked Library): Windows为应用程序提供了丰富的函数调用,这些函数调用都包含在动态链接库中。其中有3个最重要的DLL,Kernel32.dll,它包含用于管理内存、进程和线程的各个函数;User32.dll,它包含用于执行用户界面任务(如窗口的创建和消息的传送)的各个函数;GDI32.dll,它包含用于画图和显示文本的各个函数。
- 静态库(Static Library): 函数和数据被编译进一个二进制文件(通常扩展名为.LIB)。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件(.EXE文件)。
五、编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接
第一步:先编辑一个test.c
代码语言:javascript复制#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("test for linux...");
return 0;
}
第二步:生成预处理文件hello.cpp(预处理负责把include的文件包含进来及宏替换等工作)
代码语言:javascript复制$ gcc -E -o test.cpp test.c -m32
第三步:编译成汇编代码hello.s
代码语言:javascript复制$ gcc -x cpp-output -S -o test.s test.cpp -m32
第四步:编译成目标代码,得到二进制文件hello.o
代码语言:javascript复制$ gcc -x assembler -c test.s -o test.o -m32
第五步:链接成可执行文件hello,(它是二进制文件)
代码语言:javascript复制$ gcc -o test test.o -m32
第六步:运行一下./hello
代码语言:javascript复制$ ./test
我们也可以静态编译,(是完全把所有需要执行所依赖的东西放到程序内部)
代码语言:javascript复制$ gcc -o test.static test.o -m32 -static
hello.static 也是ELF格式文件,运行一下hello.static ./hello.static。发现hello.static 比 hello 占的空间大的多。
六、使用gdb跟踪分析一个execve系统调用内核处理函数do_execve ,验证您对Linux系统加载可执行程序所需处理过程的理解
代码语言:javascript复制$ b do_execve
由跟踪结果可知,当调用新的可执行程序时,会先进入内核态调用do_execve处理函数,并使用堆栈对原来的现场进行保护。然后,根据返回的可执行文件的地址,对当前可执行文件进行覆盖。由于返回地址为调用可执行文件的main函数入口,所以可以继续执行该文件。
七、特别关注新的可执行程序是从哪里开始执行的?为什么execve系统调用返回后新的可执行程序能顺利执行?对于静态链接的可执行程序和动态链接的可执行程序execve系统调用返回时会有什么不同?
- 新的可执行程序通过修改内核堆栈 eip 作为新程序的起点,从 new_ip 开始执行后start_thread把返回到用户态的位置从 int 0x80 的下一条指令变成新加载的可执行文件的入口位置。当执行到 execve 系统调用时,进入内核态,用 execve() 加载的可执行文件覆盖当前进程的可执行程序。
- 当 execve 系统调用返回时,返回新的可执行程序的执行起点(main 函数),所以 execve 系统调用返回后新的可执行程序能顺利执行。execve 系统调用返回时,如果是静态链接,elf_entry 指向可执行文件规定的头部(main 函数对应的位置 0x8048***);如果需要依赖动态链接库,elf_entry 指向动态链接器的起点。动态链接主要是由动态链接器 ld 来完成的。
八、理解Linux系统中进程调度的时机,可以在内核代码中搜索schedule()函数,看都是哪里调用了schedule(),判断我们课程内容中的总结是否准确;
- 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
- 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
九、使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
代码语言:javascript复制$ b schedule
$ b pick_next_task
$ b context_switch
$ b __switch_to
由以上跟踪结果可以得知,在进行进程间的切换时,各处理函数的调用顺序如下:pick_next_task -> context_switch -> __switch_to 。由此可以得出,当进程间切换时,首先需要调用pick_next_task函数挑选出下一个将要被执行的程序;然后再进行进程上下文的切换,此环节涉及到“保护现场”及“现场恢复”;在执行完以上两个步骤后,调用__switch_to进行进程间的切换。
十、分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
- 函数的调用关系:
schedule() --> context_switch() --> switch_to --> __switch_to()
- 汇编代码分析
asm volatile("pushflnt" /* 保存当前进程的标志位 */
"pushl %�pnt" /* 保存当前进程的堆栈基址EBP */
"movl %%esp,%[prev_sp]nt" /* 保存当前栈顶ESP */
"movl %[next_sp],%%espnt" /* 把下一个进程的栈顶放到esp寄存器中,完成了内核堆栈的切换,从此往下压栈都是在next进程的内核堆栈中。 */
"movl $1f,%[prev_ip]nt" /* 保存当前进程的EIP */
"pushl %[next_ip]nt" /* 把下一个进程的起点EIP压入堆栈 */
__switch_canary
"jmp __switch_ton" /* 因为是函数所以是jmp,通过寄存器传递参数,寄存器是prev-a,next-d,当函数执行结束ret时因为没有压栈当前eip,所以需要使用之前压栈的eip,就是pop出next_ip。 */
"1:t" /* 认为next进程开始执行。 */
"popl %�pnt" /* restore EBP */
"popfln" /* restore flags */
/* output parameters 因为处于中断上下文,在内核中
prev_sp是内核堆栈栈顶
prev_ip是当前进程的eip */
: [prev_sp] "=m" (prev->thread.sp),
[prev_ip] "=m" (prev->thread.ip), //[prev_ip]是标号
"=a" (last),
/* clobbered output registers: */
"=b" (ebx), "=c" (ecx), "=d" (edx),
"=S" (esi), "=D" (edi)
__switch_canary_oparam
/* input parameters:
next_sp下一个进程的内核堆栈的栈顶
next_ip下一个进程执行的起点,一般是$1f,对于新创建的子进程是ret_from_fork*/
: [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)
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 系统中的fork
系统调用。fork
会创建一个新的进程,加载文件并进行执行。在这个过程中,涉及到了两个进程之间的切换。我们依然使用前一篇文章的环境,对fork
系统调用进行调试,来完成这个分析。当我们调用fork
函数的时候,产生了软中断,通过int 0x80陷入内核,进入sys_call
函数,并且通过eax传递系统调用号参数。然后,通过sys_call_table
来寻找相应的系统调用函数进行执行。在这里是sys_clone
。这个是我们的第一个断点。
对于进程的切换,主要有两部分需要理解,一个是进程切换的时机,一个是schedule
函数的调用过程。对于进程切换的时机,中断处理以后是其中一个时机,内核线程也可以进程schedule
函数的调用,来完成进程的切换。对于schedule
函数。大致的过程是: 首先,进程x在执行,假设执行的ip是 ip_prev.
进入中断处理,以及进程切换的时机以后,先保持自己的内核栈信息,包括 flags 以及 ebp,自己的 ip 会保存在 thread 结构体中。这里的 ip 设置成了标号1的位置。也就是,如果进程切换了,下次回到这个进程的时候,会执行标号1的位置开始执行,回复 flags 以及 ebp。所以这里的保持flags/ebp 和恢复是对应。对于新的进程开始执行的位置,如果是像fork
这样的创建的新进程,从 thread.ip 中取出来的就是ret_from_fork
,如果是之前运行过的进程,就如上面说的,进入标号1的位置开始执行。这里的的保持 ip 和恢复 ip 也是配套的。