学号:476
实验资源:https://github.com/mengning/linuxkernel/
一、实验要求
二、实验内容
1、task_struct数据结构
task_struct是进程控制块PCB的数据结构,为了方便管理进程准备,里面包含了内核所需要了解的进程的信息。
下面对整个task_struct所定义的进程控制信息进行分析:
2、进程的创建
fork、vfork、clone三个系统调用都可以创建一个新进程,而且都是通过调用do_fork来实现的
1)子进程被创建后继承了父进程的资源。
2)子进程共享父进程的虚存空间。
3)写时拷贝 (copy on write):子进程在创建后共享父进程的虚存内存空间,写时拷贝技术允许父子进程能读相同的物理页。只要两者有一个进程试图写一个物理页,内核就把这个页的内容拷贝到一个新的物理 页,并把这个新的物理页分配给正在写的进程
4)子进程在创建后执行的是父进程的程序代码。
对于父进程 fork 返回子进程号,对于子进程 fork 返回 0 ,fork 先是调用 find_empty_process 为子进程找到一个空闲的任务号,然后调用 copy_process 复制进程, fork 返回 copy_process 的返回值 last_pid ,也就是子进程号。所以fork()实际上是一次调用,两次返回。
fork函数创建新进程是通过下面的一系列函数实现的:
fork()-->sys_clone()-->do_fork()-->copy_process()-->dup_task_struct()-->copy_thread()-->ret_from_fork()
- 用户空间的寄存器、用户态堆栈等信息在切换到内核态的上下文时保存在内核栈中,父进程在内核态(dup_task_struct)复制出子进程,但子进程作为一个独立的进程,之后被调度运行时必须有一个指令地址,进程切换时,ip地址及当前内核栈的位置esp都存在于thread_info中,由copy_thread设置其thread.ip指向ret_from_fork作为子进程执行的第一条语句,并完成了内核态到用户态的切换。
- 进程创建由系统调用来建立新进程,归根结底都是调用do_fork来实现。do_fork主要就是调用copy_process。
- copy_process()主要完成进程数据结构,各种资源的初始化。初始化方式可以重新分配,也可以共享父进程资源,主要根据传入clone_flags参数来确定。将task_struct结构体分配给子进程,并为其分配pid,最后将其加入可运行队列中。
- dup_task_struct()为子进程获取进程描述符
- copy_thread()函数将父进程内核栈复制到子进程中,同时设置子进程调度后执行的第一条语句地址为do_frok返回,并将保存返回值的寄存器eax值置为0,因此子进程返回为0,而父进程继续执行之后的初始化,最后返回子进程的pid(tgid)
- 总结:通过调用do_fork来实现进程的创建;
复制父进程PCB-task_struct来创建一个新进程,给新进程分配一个新的内核堆栈;
修改复制过来的进程数据,比如pid、进程链表等、执行copy_process和copy_thread;
成功创建进程
3、使用gdb跟踪分析内核处理函数do_fork()
和之前实验步骤相同启动MenuOS,给创建进程相关的函数打断点观察运行过程:
接下来追踪每一步:
代码追踪结果和上述分析结果相同。
4、理解编译链接的过程和ELF可执行文件格式
1)linux系统中,可执行程序一般要经过预处理、编译、汇编、链接、执行等步骤。
2)c代码,经过预处理,变成汇编代码;经过汇编器,变成目标代码;连接成可执行文件;加载到内核中执行。
3)编译连接流程:execve->do_execve->search_binary_handle->load_binary
4)编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接:
首先编辑一个hello.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Hello World!\n");
return 0;
}
按照上述2)中步骤生成预处理文件、汇编代码、目标代码和可执行文件:
gcc -E -o hello.cpp hello.c -m32
gcc -x cpp-output -S -o hello.s hello.cpp -m32
gcc -c hello.s -o hello.o -m32
gcc -o hello hello.o -m32
./hello
也可以通过静态编译,把所有需要执行所依赖的东西放到程序内部
gcc -o hello.static hello.o -m32 -static
./hello.static
静态链接方式:在程序运行之前完成所有的组装工作,生成一个可执行的目标文件;
动态链接方式:在程序已经为了执行被装载入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝。
5、使用gdb跟踪分析内核处理函数do_execve:
6、理解linux系统中进程调度的时机(查看schedule()函数)
中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();
内核线程直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,内核线程既可以主动调度也可以被动调度;
用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
7、使用gdb跟踪分析一个schedule()函数
分别在schedule函数、pick_next_task函数、context_switch函数和__switch_to函数处打断点并继续执行观察函数调用的过程
发现context_switch和pick_next_task函数都在__schedule函数中。
8、分析switch_to中的汇编代码,理解上下文的切换机制,以及与中断上下文切换的关系
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进程就成为当前进程而真正开始执行
汇编代码分析:
asm volatile("pushfl\n\t" /* 保存当前进程的标志位 */
"pushl %%ebp\n\t" /* 保存当前进程的堆栈基址EBP */
"movl %%esp,%[prev_sp]\n\t" /* 保存当前栈顶ESP */
"movl %[next_sp],%%esp\n\t" /* 把下一个进程的栈顶放到esp寄存器中,完成了内核堆栈的切换,从此往下压栈都是在next进程的内核堆栈中。 */
"movl $1f,%[prev_ip]\n\t" /* 保存当前进程的EIP */
"pushl %[next_ip]\n\t" /* 把下一个进程的起点EIP压入堆栈 */
__switch_canary
"jmp __switch_to\n" /* 因为是函数所以是jmp,通过寄存器传递参数,寄存器是prev-a,next-d,当函数执行结束ret时因为没有压栈当前eip,所以需要使用之前压栈的eip,就是pop出next_ip。 */
"1:\t" /* 认为next进程开始执行。 */
"popl %%ebp\n\t" /* restore EBP */
"popfl\n" /* 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)
9、总结
- 内核线程既可以实现主动调度也可以实现被动调度,主动调度是直接调用schedule函数进行调度,被动调度是在中断处理过程中进行调度。
- 而用户态进程无法实现主动调度,只能在中断处理过程中进行调度
- schedule()函数实现进程调度,context_ switch完成进程上下文切换,switch_ to完成寄存器的切换。