Linux内核学习总结
一.对Linux系统的理解
刚开始不太了解linux系统,对代码来执行操作比较反感,觉得太麻烦。可是当我熟悉了linux的命令的时候就觉得比较有成就感,而且他的开放性还有安全性深深地吸引了我,特别是它的可移植性,不管是掌上电脑还是普通电脑都能装上linux,说明它的应用极其广泛。我认为Linux的基本思想有两点:第一,一切都是文件;第二,每个软件都有确定的用途。其中第一条详细来讲就是系统中的所有都归结为一个文件,包括命令、硬件和软件设备、操作系统、进程等等对于操作系统内核而言,都被视为拥有各自特性或类型的文件。至于说Linux是基于Unix的,很大程度上也是因为这两者的基本思想十分相近。
二.学习Linux内核的心得
第一周.通过分析汇编代码理解计算机是如何工作的
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
第八周.理解进程调度时机跟踪分析进程调度与进程切换的关系
1.调度时机
2.进程调度的时机
3.linux进程调度与进程切换
内容:
三.博客作业目录列表
第一周:通过汇编一个简单的C程序,分析汇编代码理解计算机是如何工作的:http://www.cnblogs.com/kryst4l/p/5225254.html
第二周:完成一个简单的时间片轮转多道程序内核代码:http://www.cnblogs.com/kryst4l/p/5247136.html
第三周:跟踪分析Linux内核的启动过程:http://www.cnblogs.com/kryst4l/p/5269527.html
第四周:使用库函数API和C代码中嵌入汇编代码两种方式使用同一个系统调用:http://www.cnblogs.com/kryst4l/p/5297908.html
第五周:system_call中断处理过程:http://www.cnblogs.com/kryst4l/p/5325568.html
第六周:分析Linux内核创建一个新进程的过程:http://www.cnblogs.com/kryst4l/p/5341791.html
第七周:Linux内核如何装载和启动一个可执行程序:http://www.cnblogs.com/kryst4l/p/5372200.html
第八周:理解进程调度时机跟踪分析进程调度与进程切换的过程:http://www.cnblogs.com/kryst4l/p/5387004.html
四.学习linux课程的最大收获
学习整个课程,让我受益匪浅,不仅仅是学到了通过汇编语言来了解计算机运作的方法,还熟悉了之前不太熟悉的linux命令,现在使用linux比前以前更加得心应手。更主要学到了一种学习方法,那就是通过读懂代码来了解整个操作系统,这不仅仅是学习linux,在学习其他的操作系统或者机器语言上面都能用到。最后特别是在写博客这个习惯的养成方面,让我知道了知识不单单是需要学会,更重要的是复习与总结。温故而知新,这个习惯能让我们对之前的知识更加深入的了解,而不是学一点忘一点,到最后能真正学到知识。
五.学习linux最大的遗憾
学习linux最大的遗憾就是没有把网上课程和自己买的教材联系起来,好像是在上两堂课,对于知识不能融会贯通,只能停留在理解的阶段,对于知识没有能应用。