陈民禾—— 原创作品转载请注明出处 ——《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000 ,我的博客中有一部分是出自MOOC课程中视频,再加上一些我自己的理解。
一.计算机是如何工作的,首先要明白三个法宝
法宝一:存储程序计算机工作模型,计算机系统最最基础性的逻辑结构;
法宝二:函数调用堆栈,高级语言得以运行的基础,只有机器语言和汇编语言的时候堆栈机制对于计算机来说并不那么重要,但有了高级语言及函数,堆栈成为了计算机的基础功能;堆栈是C语言运行程序时必须的一个记录调用路径和参数的空间。1.函数调用框架 2.传递参数 3.保存返回地址 4. 提供局部变量空间等等需要注意的是C语言编译器对于堆栈的使用有一套规则,例如以下语句可分别改为:enter:1. pushl %ebp 2.movl %esp,%ebp leave:1.movl %ebp,%esp 2.popl %ebp
法宝三:函数参数传递机制和局部变量存储中断,多道程序操作系统的基点,没有中断机制程序只能从头一直运行结束才有可能开始运行其他程序。
二.计算机三大法宝可以详细一点来说明来说明
法宝一:堆栈:1.堆栈相关的寄存器:esp,堆栈指针 (stack pointer)、ebp,基址指针(base pointer )2.堆栈操作:push 栈顶指针减少四个字节(32位)、pop 在栈顶地址增加四个字节(32位)、ebp在c语言中用作记录当前函数调用基址 3.其他关键寄存器:cs:eip:总是指向下一条的指令地址;顺序执行:总是指向地址连续的下一条指令;跳转/分支:执行这样的指令的时候,cs:eip的值会根据程序需要被修改;call :将当前的cs:eip的值压入栈顶,cs:eip指向被调用函数的入口地址;ret:从栈顶弹出原来保存在这里的cs:eip的值,放入cs:eip中
法宝三:存储程序计算机:也就是冯诺依曼体系,在第一篇作业博客中已经说明,这里不再赘述
三.分析一个简单的堆栈,怎样将汇编嵌入c语言,简单的举例
我们在分析内核的过程中可能会看到C代码中嵌入汇编代码,在嵌入的过程中我们可以看到嵌入的过程大致像下面一样:_asm_( 1.汇编语句模块: 2.输出部分:函数调用时候的参数 3.输入部分:函数调用时候的参数 4.破坏描述部分): 即格式为asm("statements":output_regs:input_regs:clobbered_regs);可以看成是一个函数,有时候可以加一个_volatile_来选择让编译器优化或者不让编译器优化。
下面我们可以看一个例子,如下所示:
#include<stdio.h> int main() { /*val1+val2=val3*/ unsigned int val1=1; unsigned int val2=2; unsigned int val3=3; printf("val1:%d,val2:%d;val3:%d\n",val1,val2.val3); asm volatile( "movl $0,%%eax\n\t" /*clear %eax to 0*/ //一个%是转义符号,也就是将eax清0 "addl %1,%%eax\n\t" /*%eax+=val1*/ //%1也是特殊的指的是我们下面要输入和输出的部分其中%1就是指的是value 1,也就是val1+0放到eax里面 "addl %2,%%eax\n\t" /*%eax+=val2*/ //然后将val1+val2放到eax里面 "movl %%eax,%0\n\t" /*val2=%eax*/ //把val1+val2存储的值放到%0,因为%0就是val3 :"=m"(val3) /*input c or d mean %ecx/%edx*/ //用=m表示,就是写到内存变量里面去,m就是内存,不是用寄存器,是直接将eax的值放到内存变量val3里面去 :"c"(val1),"d"(val2) /*input c or d mean %ecx/%edx*/ //我们用ecx这个寄存器存储val1的值,这样编译的时候就将val1的值放到了ecx的寄存器,val2放在edx这个寄存器里面 ); printf("val1:%d+val2:%d=val3:%d\n",val1,val2,val3); return 0; }
后面有一些修饰符我们需要大概的了解一下,也就是嵌入式汇编的时候我们每一个输出或者是输入的部分都可以前面加一个限定符,限定符a、b、c、d、分别表示将输入变量放入eax,ebx,ecx,edx。s表示将变量放入esi,D表示将输入变量放入edi,q表示将输入变量放入eax、ebx、ecx、edx中的一个,m表示内存变量。=表示操作数在指令中是只写的(输出操作数),+操作数在指令中是 读写类型的(输入输出操作数)这是一些内嵌汇编常用的限定符。
常用限制字符
- “a” 将输入变量放入eax
- “b” 将输入变量放入ebx
- “c” 将输入变量放入ecx
- “d” 将输入变量放入edx
- “s” 将输入变量放入esi
- “d” 将输入变量放入edi
- “q” 将输入变量放入eax,ebx,ecx,edx中的一个
- “r” 将输入变量放入通用寄存器,也就是eax,ebx,ecx,edx,esi,edi中的一个
- “A” 把eax和edx合成一个64 位的寄存器(use long longs)
- 内存 “m” 内存变量
- “o” 操作数为内存变量,但是其寻址方式是偏移量类型,也即是基址寻址,或者是基址加变址寻址
- “V” 操作数为内存变量,但寻址方式不是偏移量类型
- “ ” 操作数为内存变量,但寻址方式为自动增量
- “p” 操作数是一个合法的内存地址(指针)
- “g” 将输入变量放入eax,ebx,ecx,edx中的一个或者作为内存变量
- “X” 操作数可以是任何类型
- “I” 0-31之间的立即数(用于32位移位指令)
- “J” 0-63之间的立即数(用于64位移位指令)
- “N” 0-255之间的立即数(用于out指令)
- “n” 立即数
- “p” 立即数,有些系统不支持除字以外的立即数,这些系统应该使用“n”而不是“i”
- 匹配 & 该输出操作数不能使用过和输入操作数相同的寄存器
- “=” 操作数在指令中是只写的(输出操作数)
- “+” 操作数在指令中是读写类型的(输入输出操作数)
- “f” 浮点寄存器
- “t” 第一个浮点寄存器
- “u” 第二个浮点寄存器
- “G” 标准的80387浮点常数
- % 该操作数可以和下一个操作数交换位置
{ int input,output,temp; input=1; _asm ___volatile_( "movl $0,%%eax;\n\t" //把eax的值置为0 "movl %%eax,%1;\n\t" //把0赋值给temp "movl %2,%%eax;\n\t" //input是1,且赋值给eax "movl %%eax,%0;\n\t" //output是1 :"=m"(output),"=m"(temp) //只写的 :"r"(input) //计算机选择任意一个来存储r可以是eax、ebx、ecx、edx :"eax"); //eax就是破坏描述部分 printf("%d %d\n",temp,output); return 0; } 这段代码的输出结果是什么?输出的结果是0,1
四.一个简单的内核程序分析(其中带有中断的分析)
mypcb.h是我们自己写的也就是我们定义一下进程控制块 下面是一些代码的内容 #define MAX_TASK_NUM #define KERNEL_STACK_SIZE /*CPU-specific state of this task*/ struct Thread{ unsigned long ip; unsigned long sp; //用于ip和esp }; typedef struct PCB{ int pid; //也就是进程的id,进程的状态 volatile long state; char stack[KERNEL_STACK_SIZE]; //内核堆栈,当前进程的堆栈 /*CPU-specific state of this task*/ struct Thread thread; //进程 unsigned long task_entry; //它的入口 struct PCB *next; }tPCB; void my_scheduke(void);调度器 {..... 再看一下mymain.c这个文件 内核初始化和0号进程启动 #include<mypcb.h> tPCB task[MAX_TASK_NUM]; //声明了PCB的一个数组TASK tPCB *my_current_task=NULL; //声明了TASK的一个指针 volatile int my_need_sched=0; //需不需要调度有一个标志 void my_process(void); //声明了my_process的函数 void _init my_start_kernel(void) { void pid=0; int i; /*Initialize process 0*/ //初始化0号进程的数据结构 task[pid].pid=pid; task[pid].state=0; //第一个作为0号进程,状态是正在运行 task[pid].task_entry=task[pid].thread.ip=(unsigned long)my_process; //实际上是my_start_kenel的初始化 task[pid].thread.sp=(unsigned long)&task[KERNEL_STACK_SIZE-1]; //堆栈的栈顶 task[pid].next=&task[pid]; //它的next指向它自己 /*fork more process*/ //fork很多其他的进程,初始化更多的进程 for(i=1;i<MAX_TASK_NUM:i++) { memcpy(&task[i],&task[0],sizeof(tPCB)); //我们把0号进程的很多的进程状态都copy过来 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]; //我们还创建了1、2等号进程 } /*start process 0 by task[0*/ //创建好了之后发现这跟当前的系统里面的许多进程除了0号进程之外 pid=0; my_current_task=&task[pid]; //启动0号进程 asm volatile( “movl %1,%%esp\n\t" //我们用%1表示下面的参数,也就是thread.sp "pushl %1\n\t" //push的是ebp,因为当前的栈是空的,esp=ebp,直接push当前1号参数 "pushl %0\n\t" //把当前的ip给push,压栈 "ret\n\t" //return实际上就是把eip,实际上就是上一题指令中的内容,是my_process的头部 /*return之后0号进程就启动了 "popl %%ebp\n\t" : :"c"(task[pid].thread.ip),"d"(task[pid].thread.sp) ); }//内核的初始化工作就完成了,并且把0号进程给启动起来了 void my_process(void) { 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); } } } 敲打:e myinterrupt.c 进入另外一个程序myinterrupt.c #include<linux/types.h> #include<linux/string.h> #include<linux/ctype.h> #include<linux/tty.h> #include<linux/ymalloc.h> #include<mypcb.h> extern tPCB task[MAX_TASK_NUM]; //把一些全局的东西给extern过来 extern tPCB *my_current_task; extern volatile int my_need_sched; volatile int time_count=0; void my_timer_handler(void) { #if 1 if(time_count%1000=0&&my_need_sched!=1)//设置时间片的大小。时间片用完时设置一下 并且my_need_shed!=1的时候 { printk(KERN_NOTICE">>>my_timer_handler her<<<\n" my_need_sched=1; //我们就把my_need_sched设为1,这样就会调度一下,去执行my_sched } time_count++; #endif return; } void my_schedule(void) { tPCB *next; tPCB *prev; //当前进程是prev if(my_current_task==NULL||my_current_task->next=NULL) //如果是NULL说明有问题要进行处理 { return; } printk(KERN_NOTICE">>>my_schedule<<<\n"); /*schedule*/ next=my_current_task->next; //把当前进程的下一个进程赋值给next prev=my_current_task; //当前进程就是prev if(next->state==0) /*-1 unrunnable,0 runnable*/ //如果当前进程的状态是0的话是正在执行的话,就执行下面的代码,用这种方法来切换进程,也就是上下文进程的切换,switch to next process 两个正在运行的进程之间做进程上下文切换 { /*switch to next process*/ asm volatile( "pushl %%ebp\n\t" //保存当前进程的ebp “movl %%esp,%0\n\t" //把当前进程的esp赋值到这个0,0就是prev-thread.sp "movl %2,%%esp\n\t" //把2也就是这个下一个进程的eip,2就是next->thread.sp "movl $1f,%1\n\t" // $1f是指接下来的标号1:的位置,把eip给保存起来 "pushl %3\n\t" //把下一个进程的eip给push进来,也就是next->thread.ip "ret\n\t" //return之后下一个进程就开始执行了 "1:\t" //下一个进程从这里开始 "popl %%ebp\n\t" //pop出来ebp :"=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); } /*上次讲到的计算机硬件的三个法宝,讲到程序存储计算机,函数调用中堆栈和中断,实际上操作系统也有两个非常关键的东西,一个是保存现场和恢复现场和中断对应的中断处理程序,另一个是进程上下文的切换,这段代码就是比较关键的代码 ,这个地方有两种处理,一个是下一个进程为0的时候是正在执行的时候,还有一种情况是这个进程是一个新的进程,还从来没有执行过。那么这个进程就 处理起来稍微特殊一点。 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" //把ebp保存起来 "movl %%esp,%0\n\t" //把esp保存起来 "movl %2,%%esp\n\t" //restore esp,因为从来没有执行过这个栈是空的,esp和ebp都指向同一个位置 "movl %2,%%ebp\n\t" //restore ebp "movl $1f,%1\n\t" //把eip保存起来 "pushl %3\n\t" //把当前进程的入口给保存起来 "ret\n\t" :"=m"(prev->thread.sp),"=m"(prev->thread.ip) :"m"(next->thread.sp),"m"(next->thread.ip) ); } return; }
我们可以利用实验楼提供的虚拟机上完成这次的实验,在虚拟机上的操作如下:使用实验楼的虚拟机打开shell 然后敲如下命令: cd LinuxKernel/linux-3.9.4 qemu -kernel arch/x86/boot/bzImage,加载内核
然后cd mykernel您可以看到qemu窗口输出的内容的代码 mymain.c,系统里面的进程和myinterrupt.c
使用自己的Linux系统环境搭建过程参见mykernel,详见我们的课程指导当前有一个CPU执行C代码的上下文环境,同时具有中断处理程序的上下文环境,我们就初始化好了系统环境
如果要自己写个很简单的操作系统内核也就是基于时间片轮转的多进程调度可以在mymain.c基础上基础写进程描述PCB和进程链表管理等代码,在myinterrupt.c的基础上完成进程切换代码,一个可运行的小OS kernel就完成了。可以看一下mymain.c使用命令vi mymain.c 很多内核头文件
my_start_kernel里面有很多一开始的硬件初始化的东西,里面设置一个简单的循环每十万次循环打印一个my_start_kernel 在下面输入代码,查看myinterrupt.c:e myinterrupt.c 每次当函数被调用的时候就时钟中断,就输入一个KERN_NOTICE 模拟硬件平台的工作就完成了,包括一些初始化的动作就完成了
五.完成学习任务后对操作系统工作的感想
这次的学习任务比原来多了很多,在这次实验完成过后就是对计算机中操作系统是如何工作的有了更加深刻地了解,我理解中这三大法宝是缺一不可,共同配合之后完成的工作。尤其是中断,上一次课的时候没有讲到中断,其实中断也很重要。有了中断以后在系统里面可以同时跑好几个程序,每个程序有自己的执行流,在这个执行流的过程中,可以切换过去完成另外一项任务,这时候cpu就会做一件事情,由cpu和内核代码共同实现了保存现场和恢复现场。cpu把当前的esp、ebp都压到一个叫做内核堆栈的另外一个堆栈里面去,压到这个堆栈里面去之后呢,然后把eip指向一个中断处理程序的入口,保存现场,执行中断处理程序,我们这个中断处理程序模拟了这个时钟中断,这是是周期性的,每过一段时间发生一个时钟中断,这个时钟中断也会调用一个程序,我们的实验可以很明显看到当前系统里只有一个进程,一直在那里执行,执行过程中每隔一段时间,时钟中断发生一次,这样就模拟了一个最基本的系统环境,在这个系统环境中我们就可以写一个最简单的时间片轮转的一个非常小的,代码少的一个操作系统内核。这就是我理解的操作系统的工作过程。