一.对Linux系统的理解及学习Linux内核的心得
在近两个月的学习过程中,跟着孟宁老师的学习,渐渐地对linux产生了一个大致的了解,(不仅是内核知识点本身,而且包括学习的方法,学习的方向,都有了一些初步的想法)。
内容上:自己学到的东西如下(自己将知识点串在一起的思路)
(1)为什么需要内核 --> 内核的主要工作 --> 搭建linux系统,在系统上进行内核的下载、配置编译等相关操作 --> Linux中GCC ,GDB,Bash Shell,Vim等的使用 -->
(2)进程的概念 --> 进程描述符(进程运行时内核需要了解的相关信息) --> 进程的创建(手工创建和通过系统调用) --> 什么是系统调用,系统调用的三层皮 -->
(3)与系统调用类似的中断和中断处理 --> 进程运行状态(五种状态,状态切换的方法) --> 进程之间的切换(包括进程调度相关知识点,如schedule()函数等) -->
(4)可执行程序的转载和启动(关键的exec() 系统调用) -->通过在控制台上输入ls命令的例子再将相关知识串在一起理解。
二.博客作业列表
1.linux内核分析第一周-通过分析汇编代码理解计算机是如何工作的http://www.cnblogs.com/baka/p/5224797.html
2.linux内核分析第二周-完成一个简单的时间片轮转多道程序内核代码http://www.cnblogs.com/baka/p/5247344.html
3.linux内核分析第三周-跟踪linux内核的启动过程http://www.cnblogs.com/baka/p/5268075.html
4.linux内核分析第四周-使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用http://www.cnblogs.com/baka/p/5294327.html
5.linux内核分析第五周-分析system_call中断处理过程http://www.cnblogs.com/baka/p/5322850.html
6.linux内核分析第六周-分析linux内核创建一个新进程的过程http://www.cnblogs.com/baka/p/5341518.html
7.linux内核分析第七周-linux内核如何装载和启动一个可执行程序http://www.cnblogs.com/baka/p/5372272.html
8.linux内核分析第八周-理解进程调度时机跟跟踪分析进程调度与进程切换的过程http://www.cnblogs.com/baka/p/5386941.html
三.收获与遗憾
(一)老师在上课的时候说过,linux的具体实现代码很多,细节也很多,如果直接看代码,很容易陷入细节中而忽略主干,应该在看代码之前,思考并找出代码想要实现的功能的基本框架,心里带着框架再来研究代码,在具体代码的研究中修正自己的认识偏差和误差。这样也容易抓住主干。
学习的同时要动手,老师每次都布置了作业,在这个完成作业的过程中,又有许多任务必须要在linux平台完成,比如GDB调试,下载内核并编译内核等等,将所学用上去,自己也能对上课的知识有更深的理解。
自己学完之后,对进程地址空间、内存管理等知识没有较好的理解(应该是这方面的知识吧),所以在理解程序的加载、进程上下文切换,中断上下文切换等涉及地址,内存知识点的东西,理解起来很费劲,要是老师能在这些方面有所点拨就好了。或许是因为自己本身不是计算机专业的,没有学操作系统和计算机组成原理相关知识,不过接下来还是会认真学习一下相关的知识。
(二)知识点
第一周.通过分析汇编代码理解计算机是如何工作的
1.通过分析这段C语言代码的汇编代码,可以得到计算机程序执行的几个特点:
- 总是通过EIP取得下一段要执行的代码,然后执行该段代码,即总是取指执行
- 当进行函数调用时,堆栈会保存调用函数之前的程序状态,同时堆栈指针bp和sp会在一个
伪初始位置
- 每次函数调用结束,堆栈指针bp和sp回复到调用之前的状态
第二周.完成一个简单的时间片轮转多道程序内核代码
1. mypcb.h
首先来看mypcb.h。其中定义了两个结构和一个函数。
struct Thread {
unsigned long ip;
unsigned long sp;
};
第一个是结构Thread,里面有两个变量,ip和sp用于保存现场。
typedef struct PCB{
int pid;
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
char stack[KERNEL_STACK_SIZE];
/* CPU-specific state of this task */
struct Thread thread;
unsigned long task_entry;
struct PCB *next;
}tPCB;
第二个是结构PCB,PCB结构定义了进程管理块,包括6各变量:(1)pid进程标识符;(2)state状态,-1表示不可运行,0表示可运行,>0表示停止;(3)定义了一个栈空间;(4)一个Thread变量;(5)任务入口点;(6)下一个PCB的指针。
#define MAX_TASK_NUM 4
#define KERNEL_STACK_SIZE 1024*8
void my_schedule(void);
还定义了一个my_schedule函数,以及两个宏定义。
2. mymain.c
tPCB task[MAX_TASK_NUM];
tPCB * my_current_task = NULL;
volatile int my_need_sched = 0;
首先定义了3个全局变量,两个PCB结构,一个是所有的进程集合,一个是当前的进程。
void my_process(void);
void __init my_start_kernel(void){};
然后是两个函数,my_process和my_start_kernel。
(1)my_start_kernel函数
这个函数可以分为三部分来解析。
int pid = 0;
int i;
/* Initialize process 0*/
task[pid].pid = pid;
task[pid].state = 0;/* -1 unrunnable, 0 runnable, >0 stopped */
task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process;
task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1];
task[pid].next = &task[pid];
第一部分,是初始化进程0。pid代表了进程号,0是第一个。state代表运行状态,初始化为可运行。Thread的ip就是进程入口点,其实就是进程运行的起点。sp实际上是定义了一段进程的栈空间。最后定义了下一个PCB的链接先指向自己。
/*fork more process */
for(i=1;i<MAX_TASK_NUM;i++)
{
memcpy(&task[i],&task[0],sizeof(tPCB));
task[i].pid = i;
task[i].state = -1;
task[i].thread.sp = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
task[i].next = task[i-1].next;
task[i-1].next = &task[i];
}
第二部分,是根据第一个进程0初始化余下的进程。因为我们设置最大进程数为4,所以这里实际上是设置了进程1-3的数据结构的值。
/* start process 0 by task[0] */
pid = 0;
my_current_task = &task[pid];
asm volatile(
"movl %1,%%esp\n\t" /* set task[pid].thread.sp to esp */
"pushl %1\n\t" /* push ebp */
"pushl %0\n\t" /* push task[pid].thread.ip */
"ret\n\t" /* pop task[pid].thread.ip to eip */
"popl %%ebp\n\t"
:
: "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
);
最后一个部分,是从进程0号开始运行。这里使用了内联汇编编程,实际上就是将进程0的thread.sp的值赋给esp,将当前运行的地址保存到栈中,这样如果切换的话就可以保证下一个进程结束时回到原来的位置执行。
总而言之,my_start_kernel函数实现了定义进程数组,并运行第一个进程。
(2)my_process函数
int i = 0;
while(1)
{
i++;
if(i%10000000 == 0)
{
printk(KERN_NOTICE "this is process %d -\n",my_current_task->pid);
if(my_need_sched == 1)
{
my_need_sched = 0;
my_schedule();
}
printk(KERN_NOTICE "this is process %d +\n",my_current_task->pid);
}
}
my_process函数很简单,就是建立一个循环不断运行进程,并输出表明进程正在运行的语句。这里注意有一个my_schedule()函数,实际上这个函数是在myinterrupt.c中实现的,主要作用是切换进程。
3. myinterrupt.c
extern tPCB task[MAX_TASK_NUM];
extern tPCB * my_current_task;
extern volatile int my_need_sched;
volatile int time_count = 0;
首先定义了一些全局变量。然后主要实现了两个函数:my_time_handler和my_schedule,其中my_time_handler实现了中断,而my_schedule实现了中断之后进程的切换。
(1)my_time_handler函数
void my_timer_handler(void)
{
#if 1
if(time_count%1000 == 0 && my_need_sched != 1)
{
printk(KERN_NOTICE ">>>my_timer_handler here<<<\n");
my_need_sched = 1;
}
time_count ++ ;
#endif
return;
}
这个函数也很简单,就是每1000毫秒的时候产生一个中断,产生中断之后把my_need_sched设置为1,这样mymain.c中的my_process函数就会调用my_schedule函数来进行进程切换。
(2)my_schedule函数
这个函数才是重点,实现了时间片轮转的中断处理过程。
tPCB * next;
tPCB * prev;
if(my_current_task == NULL
|| my_current_task->next == NULL)
{
return;
}
printk(KERN_NOTICE ">>>my_schedule<<<\n");
/* schedule */
next = my_current_task->next;
prev = my_current_task;
首先是初始化next和prev两个PCB结构。
if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */
{
/* switch to next process */
asm volatile(
"pushl %%ebp\n\t" /* save ebp */
"movl %%esp,%0\n\t" /* save esp */
"movl %2,%%esp\n\t" /* restore esp */
"movl $1f,%1\n\t" /* save eip */
"pushl %3\n\t"
"ret\n\t" /* restore eip */
"1:\t" /* next process start here */
"popl %%ebp\n\t"
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
my_current_task = next;
printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
}
这一段是循环运行代码,就是当下一个进程的state状态是可运行时,说明这个进程之前已经在运行了,此时可以继续执行,就切换到下一个进程,这中间有一段内联汇编,实现了保存栈地址和栈指针,这样进程切换回来的时候就可以正常运行。然后根据之前保存的栈地址恢复执行。
else
{
next->state = 0;
my_current_task = next;
printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
/* switch to new process */
asm volatile(
"pushl %%ebp\n\t" /* save ebp */
"movl %%esp,%0\n\t" /* save esp */
"movl %2,%%esp\n\t" /* restore esp */
"movl %2,%%ebp\n\t" /* restore ebp */
"movl $1f,%1\n\t" /* save eip */
"pushl %3\n\t"
"ret\n\t" /* restore eip */
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
}
当下一个进程的state不为0时,那么也就是说下一个进程还从来都没有执行过,所以这一段内联汇编的作用是开始执行一个新进程。
第三周.跟踪分析linux内核的启动过程
1.打开环境
执行命令:cd LinuxKernel/
执行命令:qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img
MenuOS便可以成功启动。可以测试三个命令“help,version,quit”的工作情况
2、使用gdb跟踪调试内核
执行命令:qemu -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -s -S
冻结启动窗口,重新打开一个终端使用gdb命令调试
3.系统启动后首先执行一系列的初始化工作,直到start_kernel处,它是代码的入口点,相当于main.c函数。然后启动系统的第一个进程init,init是所有进程的父进程,由init再启动子进程,从而使得系统成功运行起来。
第四周.使用库函数API和C代码中嵌入汇编两种方式使用同一个系统调用
1.getpid的函数很简单,就是获取当前进程的进程号
.系统调用号放在eax中。
.系统调用的参数,按照顺序分别放在ebx、ecx、edx、esi、edi和ebp中
.返回值使用eax传递
2.fork函数同样不需要参数,只有输出,
3.fork这个函数有个特点,就是调用一次返回两次,原因在于它复制出了一个子进程,执行同样地代码段。
区分子进程和父进程的手段就是检查返回值。
4.read函数需要三个参数。参数保存在ebx、ecx等寄存器中,这里的三个参数就是放在这三个寄存器中。最后一行的
:"b"(fd), "c"(buf), "d"(count) 就是声明,fd使用的是ebx,buf使用ecx传递,count使用edx传递。
第五周.分析system_call中断处理过程
1.system_call()函数
首先把系统调用号和这个异常处理程序可以用到的所有CPU寄存器保存到相应的栈中,不包括由控制单元已自动保存的eflags、cs、eip、ss和esp寄存器。
pushl %eax
SAVE_ALL
movl $0xffffe000, %ebx /* or 0xfffff000 for 4-KB stacks */
andl %esp, %ebx
接下来检查thread_info结构flag字段的TIF_SYSCALL_TRACE和TIF_SYSCALL_AUDIT标志之一是否被置为1,即检查是否有某一调试程序正在跟踪执行程序对系统调用的调用。
如果系统调用号无效则把-ENOSYS值存放在栈中曾保存eax寄存器的单元中,当进程恢复在用户态的执行时会在eax中得到一个负的返回码。
cmpl $NR_syscalls, %eax
jb nobadsys
movl $(-ENOSYS), 24(%esp)
jmp resume_userspaces
最后调用与eax中所包含的系统调用号对应的特定服务例程。
call *sys_call_table(0, %eax, 4)
2.从系统调用退出
当系统调用服务例程结束时,system_call()函数从eax获得返回值,并保存在曾经保存用户态eax寄存器值的栈单元位置上,用户态进程将在eax中找到系统调用的返回码。
movl %eax, 24(%esp)
system_call()函数关闭本地中断并检查当前进程的thread_info结构中的标志,如果所有的标志都没有被设置函数就会跳转到restore_all标记处,恢复保存在内核栈中的寄存器的值,并执行iret汇编语言指令以重新开始执行用户态进程。
第六周.分析一个linux内核创建一个新进程的过程
1.Linux中一般进程都是由现有的一个进程创建的,也就是我们所说的父进程
2.具体的创建是通过fork()实现的
3.fork()的大体工作过程:
1)在内存中申请一页内存存放进程控制块task_struct,并返回进程号nr,并在task数组的nr处存放task_struct的指针,还要将task的当前指针current指到nr处;
2)将父进程的task_struct的内容复制到新进程的task_struct中作为模版
3)对task_struct中的信息进行修改,主要进行一下工作:设置父进程、清除信号位图、时间片、运行时间、根据当前环境设置tss(内核态指针esp0指向task_struct所在页的顶端)、设置LDT的选择子等(根据nr指向GDT中相应的ldt描述符)。
4)设置新进程的代码段、数据段的基地址和段长:更新task_struct中的代码开始地址:进程号(nr)×64M,更新task_struct中局部描述符表中的代码段和数据段描述符。 5)复制父进程的页表目录项和页表:在页目录表中,复制父进程的页表目录项,目的地址由新进程的线性地址计算出来;对每个对应的页表目录项申请一个空闲页,并用页表地址更新页表目录项,最后将父进程页表中各项复制到新进程对应的页表中,也就是说,这个时候,子进程与父进程共享物理内存。
6)更新task_struct中的文件信息:文件打开次数加1,父进程的当前目录引用数加1。
7)设置TSS和LDT描述符项:在全局描述符表(GDT)中设置新任务的TSS描述符项和LDT段的描述符项,使TSS描述符项和LDT描述符项分别指向task_struct的TSS结构和LDT结构。
8)将任务设置为就绪状态,向当前进程(父进程)返回新进程号。
4.fork()中,内核并不立刻为新进程分配代码和数据物理内存页,新进程与父进程共同使用父进程已有的代码和数据物理内存页面。只有当以后执行过程中由一个进程一写方式访问内存时候被访问的内存页面才会在写操作之前被复制到新申请的内存页面中。
5.另外在fork的最后是将任务设置成了就绪状态,由于fork()是一个系统调用,在系统调用部分system_call.s,可以看到在系统函数返回后,会调用调度函数schedule(),在schedule()中,就会检测到新进程的就绪状态,并用switch_to()切换到新进程进行执行。
第七周.linux内核如何装载和启动一个内核程序
1.可执行文件的创建就是三步:预处理、编译和链接。
cd Code
vi hello.c #写入最简单的helloworld的c程序
gcc -E -o hello.cpp hello.c -m32 #-E参数就是生成预处理后的文件,看到-o后面的是生成的文件hello.cpp,注意它并不是cplusplus,而是随意起的后缀名
vi hello.cpp #查看该文件,发现预处理做了把include的文件包含进来以及宏替换等工作。
gcc -x cpp-output -S -o hello.s hello.cpp -m32 #-x language filename作用是设定文件使用的语言,使后缀名无效。此处就是让刚才的cpp不要让编译器误会为cplusplus,而是当做cpp-output这种文件格式。-s是指生成汇编.s文件
vi hello.s
gcc -x assembler -c hello.s -o hello.o -m32 #-c指将.s转为.o文件
vi hello.o
gcc -o hello hello.o -m32 #-o指将.o文件链接为可执行的文件
vi hello
gcc -o hello.static hello.o -m32 -static #静态链接
ls -l #注意看结果中的各文件的大小,其中静态链接的很大,因为它把所需要的库一次包到进程(可执行文件)中
2.可执行文件属于目标文件之一。目标文件的格式为ELF。ELF的格式以段来组织的二进制代码
3.以ELF为格式的主要有三种文件:①可重定位文件:保持着代码和适当的数据,用来和其他的object文件一起来创建一个可执行文件或者一个共享文件。例如.o文件。
②可执行文件:可以运行的文件。该文件指出了exec(BA_OS)如何来创建进程映象。再来联想下程序和进程的区别。到底这种可执行文件是进程还是程序?我们发现它的段中只含.text和.data一类的段,而不含有堆栈段。所以可以确定它只是程序。当它被操作系统调入内存开始执行时才会真正的成为进程。例如.out文件。
③共享object文件:保存着代码和数据,被两个链接器链接。一个是连接编辑器,可以和其他可重定位和共享object文件来创建其他的object。第二个是动态链接器,联合一个可执行文件和其他共享object文件来创建一个进程映像。
4.ELF文件的头部:使用命令查看hello文件的头:
shiyanlou:Code/ $ readelf -h hello
第八周.理解进程调度时机跟踪分析进程调度与进程切换的关系
最一般的情况:正在运行的用户态进程X切换到运行用户态进程Y的过程
1. 正在运行的用户态进程X
2. 发生中断——save cs:eip/esp/eflags(current) to kernel stack, then load cs:eip(entry of a specific ISR) and ss:esp(point to kernel stack).
3. SAVE_ALL //保存现场,这里是已经进入内核中断处里过程
4. 中断处理过程中或中断返回前调用了schedule(),其中的switch_to做了关键的进程上下文切换
5. 标号1之后开始运行用户态进程Y(这里Y曾经通过以上步骤被切换出去过因此可以从标号1继续执行)
6. restore_all //恢复现场
7. iret - pop cs:eip/ss:esp/eflags from kernel stack
8. 继续运行用户态进程Y
几种特殊情况
- 通过中断处理过程中的调度时机,用户态进程与内核线程之间互相切换和内核线程之间互相切换,与最一般的情况非常类似,只是内核线程运行过程中发生中断没有进程用户态和内核态的转换;
- 内核线程主动调用schedule(),只有进程上下文的切换,没有发生中断上下文的切换,与最一般的情况略简略;
- 创建子进程的系统调用在子进程中的执行起点及返回用户态,如fork;
- 加载一个新的可执行程序后返回到用户态的情况,如execve;
进程的调度时机与进程的切换
操作系统原理中介绍了大量进程调度算法,这些算法从实现的角度看仅仅是从运行队列中选择一个新进程,选择的过程中运用了不同的策略而已。
对于理解操作系统的工作机制,反而是进程的调度时机与进程的切换机制更为关键。
进程调度的时机
- 中断处理过程(包括时钟中断、I/O中断、系统调用和异常)中,直接调用schedule(),或者返回用户态时根据need_resched标记调用schedule();比如sleep,就可能直接调用了schedule
- 内核线程可以直接调用schedule()进行进程切换,也可以在中断处理过程中进行调度,也就是说内核线程作为一类的特殊的进程可以主动调度,也可以被动调度;
- 用户态进程无法实现主动调度,仅能通过陷入内核态后的某个时机点进行调度,即在中断处理过程中进行调度。用户态进程只能被动调度。
进程的切换
- 为了控制进程的执行,内核必须有能力挂起正在CPU上执行的进程,并恢复以前挂起的某个进程的执行,这叫做进程切换、任务切换、上下文切换;即进程上下文切换!
- 挂起正在CPU上执行的进程,与中断时保存现场是不同的,中断前后是在同一个进程上下文中,只是由用户态转向内核态执行;
- 进程上下文包含了进程执行需要的所有信息
- 用户地址空间:包括程序代码,数据,用户堆栈等
- 控制信息:进程描述符,内核堆栈等
- 硬件上下文(注意中断也要保存硬件上下文只是保存的方法不同)
- schedule()函数选择一个新的进程来运行,并调用context_switch进行上下文的切换,这个宏调用switch_to来进行关键上下文切换
- next = pick_next_task(rq, prev);//进程调度算法都封装这个函数内部
- context_switch(rq, prev, next);//进程上下文切换
- switch_to利用了prev和next两个参数:prev指向当前进程,next指向被调度的进程