学号404
原创作品转载请注明出处 https
文章目录
一. 实验要求
二. 实验内容
1. 阅读理解task_struct数据结构
为了方便管理进程,操作系统需要清楚地描述每一个进程。所以,操作系统定义了一个数据结构来描述不同的进程,这个数据结构就是进程控制块(PCB),也就是task_struct,这里面包含了系统执行过程中需要了解的进程的信息。
以下列举了task_stuct中定义的重要参数及作用。
volatile long state; //表示进程当前的状态是就绪,阻塞还是运行三个状态 /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack; // 进程的内核堆栈
unsigned int flags; // 进程的标志 /* per process flags, defined below */
pid_t pid; // 进程的pid
struct list_head tasks; // 进程的链表
struct task_struct __rcu; //描述进程的父子进程关系。
unsigned int rt_priority; //描述调度优先级
unsigned int policy; //描述调度策略
2.分析fork函数对应的内核处理过程do_fork
do_fork函数主要处理的是进程的创建,在linux系统中主要以下有三个系统调用(fork, vfork, clone)可以创建一个新进程,它们的共同点是都是通过调用do_fork函数实现进程创建的,它们的不同点如下:
- fork:创建子进程。
- vfork,创建子进程,并且父子进程共享地址空间,子进程要先于父进程运行。
- clone,主要用于创建线程。
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;
/*
* Determine whether and which event to report to ptracer. When
* called from kernel_thread or CLONE_UNTRACED is explicitly
* requested, no event is reported; otherwise, report if the event
* for the type of forking is enabled.
*/
// 检查标志位,选择通过哪一个系统调用实现进程创建
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if ((clone_flags & CSIGNAL) != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
if (likely(!ptrace_event_enabled(current, trace)))
trace = 0;
}
// 复制一份进程描述符
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);
// 获取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);
}
// 调用wake_up_new_task,将新进程加入调度队列
wake_up_new_task(p);
// 上面检查是vfork,保证子进程先于父进程执行的解决方案:将父进程加入等待队列,直到子进程处理完
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}
put_pid(pid);s
} else {
nr = PTR_ERR(p);
}
return nr;
}
do_fork主要完成了:
- 调用copy_process,复制一份进程标识符作为子进程,同时进行错误检查。
- 检查是否是vfork调用,保证子进程先于父进程执行的解决方案:将父进程加入等待队列,直到子进程处理完。
3.使用gdb跟踪分析一个fork系统调用内核处理函数do_fork
使用gdb跟踪分析一个fork系统调用内核处理函数do_fork ,验证对Linux系统创建一个新进程的理解,特别关注以下问题:
- 新进程是从哪里开始执行的?
- 为什么从那里能顺利执行下去?即执行起点与内核堆栈如何保证一致。
(1)启动MenuOS 并打开gdb调试
这部分内容在实验二中已经介绍。不同的是,本次实验是在实验楼中完成的。
启动MenuOS代码如下:
rm menu -rf
git clone https://github.com/mengning/menu.git
cd menu
mv test_fork.c test.c
qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S # 关于-s和-S选项的说明:
-S freeze CPU at startup (use ’c’ to start execution)
-s shorthand for -gdb tcp::1234 若不想使用1234端口,则可以使用-gdb tcp:xxxx来取代-s选项
(2)设置断点,调试
进入gdb调试模式:
gdb
file linux-3.18.6/vmlinux
target remote:1234
设置以下断点:
b sys_clone
b do_fork
b dup_task_struct
b copy_process
b copy_thread
b ret_from_fork
4.理解编译链接的过程和ELF可执行文件格式
(1) 编译链接
从源文件编译链接成可执行文件需要经历以下步骤:
源文件 -> 预处理 -> 编译 -> 链接 -> 可执行文件
编译: 把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生成汇编代码。
链接: 就是把多个目标文件和库文件拼合成一个最终的可执行文件的过程。
(2) ELF可执行文件格式
ELF全称Executable and Linkable Format,可执行连接格式,ELF格式的文件用于存储Linux程序。ELF文件(目标文件)格式主要三种:
- 可重定向文件:文件保存着代码和适当的数据,用来和其他的目标文件一起来创建一个可执行文件或者是一个共享目标文件。(目标文件或者静态库文件,即linux通常后缀为.a和.o的文件)
- 可执行文件:文件保存着一个用来执行的程序。(例如bash,gcc等)
- 共享目标文件:共享库。文件保存着代码和合适的数据,用来被下连接编辑器和动态链接器链接。(linux下后缀为.so的文件。)目标文件既要参与程序链接又要参与程序执行:
一般的 ELF 文件包括三个索引表:ELF header,Program header table,Section header table。
- ELF header:在文件的开始,保存了路线图,描述了该文件的组织情况。
- Program header table:告诉系统如何创建进程映像。用来构造进程映像的目标文件必须具有程序头部表,可重定位文件不需要这个表。
- Section header table:包含了描述文件节区的信息,每个节区在表中都有一项,每一项给出诸如节区名称、节区大小这类信息。用于链接的目标文件必须包含节区头部表,其他目标文件可以有,也可以没有这个表。
5.编程使用exec*库函数加载一个可执行文件,动态链接分为可执行程序装载时动态链接和运行时动态链接
先编写一个hello.c
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("Hello World!\n");
return 0;
}
接下来采取两种方式,静态链接和动态链接分别执行hello.c
(1) 动态编译
gcc -E -o hello.cpp hello.c -m32 //
gcc -x cpp-ouput -S -o hello.s hello.cpp -m32
gcc -x assembler -c hello.s -o hello.o -m32
gcc -o hello hello.o -m32
./hello
执行结果如下:
(2)静态编译
gcc -o hello.static hell.o -m32 -static
./hello.static
执行结果:
可以发现hello.static 733254 比hello 7292要 大很多,所以静态编译要更加占空间,因为它需要在程序运行之前就完成所有的拼合,生成一个可执行的目标文件,而且每一个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,同一个目标文件都在内存存在多个副本;而动态编译时程序是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,多个程序在执行时共享同一份副本。
6.理解Linux系统中进程调度的时机
Linux进程调度时机主要有:
- 进程状态转换的时刻:进程终止、进程睡眠;
- 当前进程的时间片用完时(current->counter=0);
- 设备驱动程序
- 进程从中断、异常及系统调用返回到用户态时;
schedule():进程调度函数,由它来完成进程的选择(调度)。
schedule()调用的地方:
- 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule()
- 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。
7.使用gdb跟踪分析一个schedule()函数 ,验证对Linux系统进程调度与进程切换过程的理解
添加断点:
执行结果:
- __schedule完成了真正的调度工作
- pick_next_task选择抢占的进程;pick_next_task函数会从按照优先级遍历所有调度器类的pick_next_task函数, 去查找最优的那个进程,。
- context_switch完成了进程上下文切换
8. 特别关注并仔细分析switch_to中的汇编代码,理解进程上下文的切换机制,以及与中断上下文切换的关系
switch_to中的汇编代码如下:
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)
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()