【刘蔚然 原创作品转载请注明出处 《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000】
WEEK TWO(2.29——3.6)操作系统是如何工作的?
SECTION 1 函数调用堆栈
1.总结上一讲 计算机是如何工作的?
- 存储程序计算机工作模型(冯诺依曼结构),是各种类型计算机的一个框架;
- 堆栈在早期是没有的,它是高级语言的起点(函数调用堆栈)
- 中断机制(多道系统的基础,也是计算机效率提升的关键——否则的画计算机能逐个执行进程)
因而,本讲将介绍第二个重点:函数调用堆栈
2.堆栈
堆栈是C语言程序运行时必须的一个记录调用路径和参数的空间,包括:
(也就是说CPU内已经集成好了一些功能)
- 函数调用框架
- 传递参数(在32位的机器中,64位的略有不同)
- 保存返回地址(%eax中可以保存返回地址或者返回值)
- 提供局部变量空间
3.堆栈相关的寄存器
(上一讲中已经有所涉及,也就是ebp,esp这两个寄存器;还有eip这个寄存器)
【疑惑:关于pushl和popl两条命令对esp的影响?】
- 在一二讲的文档中,都告诉我们地址是向低地址端增长的。那么,该讲中的图示为什么相反呢?
- 因为不同的机器有大端法和小端法的存储区别。图示中也是这样向低地址端增长的
【发生中断的时候,cs:eip会怎么变化呢?(之前的顺序、跳转以及调用都是一条流畅的指令流,并没有发生中断)】见最后一讲的笔记
【函数调用的堆栈框架?】
4.参数传递过程
以如下的代码反汇编程序为例:
#include <stdio.h>
void p1(char c)
{
printf("%c",c);
}
int p2(int x,int y)
{
return x+y;
}
int main(void)
{
char c ='a';
int x,y;
x =1;
y =2;
p1(c);
z = p2(x,y);
printf("%d = %d+%d",z,x,y);
}
- 关于z = p2(x,y)一句
关于0xfffffff4、0xfffffff8的意思? 我在网上查了一下,其实这里面的0x虽然是16进制的意思,不过f并不是表示负数,而是一种内存表示方法(也有说0xfffffff4这个地址有一些其他的限制)
-
前两条语句什么意思?
- 就是把y的值和x的值存放到堆栈中;
- 为什么不是pushl y而是采用变址寻址的方式呢?因为这两个值是局部变量,已经被保存在堆栈中了,所以就可以用变址寻址的方式找到;
- 前两句中的pushl对应着第四句的add $0x8,%esp即:有压栈必有出栈
- 如何将p2的值返回给调用函数?
- 看mov %eax,0xfffffffc(%ebp)一句;
- 0xfffffffc(%ebp)的位置上放置的是z,也就是说,这句指令将把返回值赋给z
- push $0x8048510;就是把内存地址的内容压入堆栈
5.观察局部变量的存储机制
1.sub $0x18,%esp 的作用?
- 是预留出来了一段堆栈空间(0x18个空间),用来存储局部变量
- 早期的编译器比较“笨”,需要在函数头部定义出所有变量,然后一次性留出这么多空间;而现在的编译器可以扫描整段代码中一共有多少变量然后进行空间分配,所以不强制在函数头部声明变量
6.练习一
- 进入实验楼,对代码进行反汇编;
- 下载整理反汇编代码;
(6,7为编译及反汇编)
(8——11为代码)
SECTION 2 借助Linux内核部分源代码模拟存储程序计算机工作模型及时钟中断
1.利用mykernel实验模拟计算机硬件平台
- 搭建一个虚拟平台(虚拟的X86CPU),使用Linux源代码把这个系统配置好,通过程序执行入口执行程序;
- 这样的一个简易的“系统”有上述三个“法宝”;
- 当一个中断信号发生的时候,CPU把当前的esp,ebp,eip压入内核堆栈中,然后让eip指向中断处理程序入口去处理中断;
- 在该系统中,使用的是时钟中断,即每隔一段时间发生一次中断
2. 实验步骤
- 进入实验楼,打开shell之后按照说明输入;
- 运行QEMU,见截图
- (图12)
- 查看源代码
- 查看mymain.c
- 见截图
- (13)
- 之前的#include...都是硬件初始化用到的语句,而截图部分是是“操作系统”开始执行的入口
- 从代码可见,每循环10 000次,打印一句话(从之前的屏幕滚动速度可见,现在的CPU执行的速度还是很快的)
- 查看myinterrupt.c
- 见截图
- (14)
- 每执行一次,都会执行一次时钟中断
- 查看mymain.c
SECTION 3 在mykernel基础上构造一个简单地操作系统内核
1.C代码中嵌入汇编代码的写法
格式:(图15)
-
例子:
- C代码:
- (图16)
- 汇编代码:
- (图17)
- 解释
- movl $0,%%eax\n\t:第一个%表示转义字符
- addl %1,%%eax\n\t:%1也是特殊符号,表示output与input(即下方:之后的部分)变量依次编号后,按照0,1,2,...的号码取变量
- :"c"(val1),"d"(val2):也就是将val1的值放到ecx这个寄存器中,系统就会在编译之前自动完成这些工作。这样addl %1,%%eax\n\t 就会把ecx的值放到eax中
- addl %%eax,%0\n\t:把eax的值放到m(内存变量)中
- C代码:
- 练习
- (图18)
- 难点解析:
- 最后的:eax是破坏描述部分,也就是说这段代码可能会破坏eax;
- 最后输出的是两个内存变量temp和output
- 结果:
- 0,1
2.一个简单的操作系统内核源代码【分析见注释】
1.mypcb.h//头文件,在其他的.c文件中引用;主要是数据结构的定义
1 /*
2 * linux/mykernel/mypcb.h
3 *
4 * Kernel internal PCB types
5 *
6 * Copyright (C) 2013 Mengning
7 *
8 */
9
10 #define MAX_TASK_NUM 4
11 #define KERNEL_STACK_SIZE 1024*8
12
13 /* CPU-specific state of this task */
14 struct Thread {
15 unsigned long ip; //即eip和esp
16 unsigned long sp;
17 };
18
19 typedef struct PCB{ //定义进程相关的数据结构
20 int pid;
21 volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
22 char stack[KERNEL_STACK_SIZE]; //内核堆栈
23 /* CPU-specific state of this task */
24 struct Thread thread;
25 unsigned long task_entry; //进程入口
26 struct PCB *next; //进程链表
27 }tPCB;
28
29 void my_schedule(void); //调度器
2.mymain.c 内核初始化和0号进程启动
1 /*
2 * linux/mykernel/mymain.c
3 *
4 * Kernel internal my_start_kernel
5 *
6 * Copyright (C) 2013 Mengning
7 *
8 */
9 #include <linux/types.h>
10 #include <linux/string.h>
11 #include <linux/ctype.h>
12 #include <linux/tty.h>
13 #include <linux/vmalloc.h>
14
15
16 #include "mypcb.h"
17
18 tPCB task[MAX_TASK_NUM]; //声明一个task数组,tPCB结构体类型在mypcb.h中有定义
19 tPCB * my_current_task = NULL;
20 volatile int my_need_sched = 0;
21
22 void my_process(void);
23
24
25 void __init my_start_kernel(void)
26 {
27 int pid = 0; //这样的话修改进程序号就很方便
28 int i;
29 /* Initialize process 0*/ //以下是0号进程数据结构的初始化
30 task[pid].pid = pid;
31 task[pid].state = 0;/* -1 不可运行, 0 可运行, >0 停止*/
32 task[pid].task_entry = task[pid].thread.ip = (unsigned long)my_process; //见第58行开始的my_process函数;起点是my_process
33 task[pid].thread.sp = (unsigned long)&task[pid].stack[KERNEL_STACK_SIZE-1]; //堆栈的栈顶位置,这个在mypcb.h中也有定义
34 task[pid].next = &task[pid]; //下一个进程还是指向自己(这时候没有其他进程)
35 /*fork more process */
36 for(i=1;i<MAX_TASK_NUM;i++)
37 {
38 memcpy(&task[i],&task[0],sizeof(tPCB)); //copy一下0号进程的状态,然后需要修改的地方再修改(好机智的做法)
39 task[i].pid = i;
40 task[i].state = -1; //不能让它们现在就开始运行
41 task[i].thread.sp = (unsigned long)&task[i].stack[KERNEL_STACK_SIZE-1];
42 task[i].next = task[i-1].next; //42,43两句是很常见的链表加入新项的方法
43 task[i-1].next = &task[i];
44 }
45 /* start process 0 by task[0] */ //为什么这里会是两句pushl和两句popl(ret相当于popl eip)?我的理解是:这样使得新的堆栈中的ebp和eip都与task[i]相匹配(esp的位置已经由第49句决定)
46 pid = 0; //作用同第27行
47 my_current_task = &task[pid];
48 asm volatile(
49 "movl %1,%%esp\n\t" /* set task[pid].thread.sp to esp */ //确定esp的位置
50 "pushl %1\n\t" /* push ebp */ //当前的栈是空的,ebp就等于esp
51 "pushl %0\n\t" /* push task[pid].thread.ip */ //IP压栈
52 "ret\n\t" /* pop task[pid].thread.ip to eip */ //弹出来eip,这之后0号进程正式启动
53 "popl %%ebp\n\t" //弹出来ebp,内核初始化工作完成
54 :
55 : "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
56 );
57 }
58 void my_process(void) //所有的进程都以这个作为起点
59 {
60 int i = 0;
61 while(1)
62 {
63 i++;
64 if(i%10000000 == 0)
65 {
66 printk(KERN_NOTICE "this is process %d -\n",my_current_task->pid);
67 if(my_need_sched == 1) //执行10 000 000次才判断一次是否需要调度
68 {
69 my_need_sched = 0; //在第20行初始化为0
70 my_schedule();
71 }
72 printk(KERN_NOTICE "this is process %d +\n",my_current_task->pid);
73 }
74 }
75 }
3myinterrupt.c 调度机制
1 /*
2 * linux/mykernel/myinterrupt.c
3 *
4 * Kernel internal my_timer_handler
5 *
6 * Copyright (C) 2013 Mengning
7 *
8 */
9 #include <linux/types.h>
10 #include <linux/string.h>
11 #include <linux/ctype.h>
12 #include <linux/tty.h>
13 #include <linux/vmalloc.h>
14
15 #include "mypcb.h"
16
17 extern tPCB task[MAX_TASK_NUM];
18 extern tPCB * my_current_task;
19 extern volatile int my_need_sched;
20 volatile int time_count = 0;
21
22 /*
23 * Called by timer interrupt.
24 * it runs in the name of current running process,
25 * so it use kernel stack of current running process
26 */
27 void my_timer_handler(void)
28 {
29 #if 1
30 if(time_count%1000 == 0 && my_need_sched != 1) //设置时间片的大小,时间片用完时设置一下调度标志。只要满足上述两个条件,就将my_need_sched设为1,这样mymain.c循环到这个进程的时候就可以对这个进程进行调度
31 {
32 printk(KERN_NOTICE ">>>my_timer_handler here<<<\n");
33 my_need_sched = 1;
34 }
35 time_count ++ ;
36 #endif
37 return;
38 }
39
40 void my_schedule(void)
41 {
42 tPCB * next;
43 tPCB * prev;
44
45 if(my_current_task == NULL
46 || my_current_task->next == NULL)
47 {
48 return;
49 }
50 printk(KERN_NOTICE ">>>my_schedule<<<\n");
51 /* schedule */
52 next = my_current_task->next;
53 prev = my_current_task;
54 if(next->state == 0)/* -1 unrunnable, 0 runnable, >0 stopped */ //在两个正在执行的进程之间做上下文切换
55 {
56 /* switch to next process */
57 asm volatile(
58 "pushl %%ebp\n\t" /* save ebp */
59 "movl %%esp,%0\n\t" /* save esp */
60 "movl %2,%%esp\n\t" /* restore esp */
61 "movl $1f,%1\n\t" /* save eip */ //$1f就是指标号1:的代码在内存中存储的地址
62 "pushl %3\n\t"
63 "ret\n\t" /* restore eip */ //这两句使得下一个进程的ip作为eip,这样下一个进程就会接下来执行
64 "1:\t" /* next process start here */
65 "popl %%ebp\n\t"
66 : "=m" (prev->thread.sp),"=m" (prev->thread.ip)
67 : "m" (next->thread.sp),"m" (next->thread.ip)
68 );
69 my_current_task = next;
70 printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid
71 }
72 else //进程是一个新的进程(从来没有执行过)
73 {
74 next->state = 0;
75 my_current_task = next;
76 printk(KERN_NOTICE ">>>switch %d to %d<<<\n",prev->pid,next->pid);
77 /* switch to new process */
78 asm volatile(
79 "pushl %%ebp\n\t" /* save ebp */
80 "movl %%esp,%0\n\t" /* save esp */
81 "movl %2,%%esp\n\t" /* restore esp */
82 "movl %2,%%ebp\n\t" /* restore ebp */ //新进程的esp和ebp都是相同位置
83 "movl $1f,%1\n\t" /* save eip */
84 "pushl %3\n\t" //保存当前进程的入口
85 "ret\n\t" /* restore eip */
86 : "=m" (prev->thread.sp),"=m" (prev->thread.ip)
87 : "m" (next->thread.sp),"m" (next->thread.ip)
88 );
89 }
90 return;
91 }
这样,就完成了一个简单地操作系统内核程序并能够在虚拟机中启动。实现的效果就是0,1,2,3号进程依次循环切换。
总结
如何理解“操作系统是如何工作的?”
- 操作系统是建立在硬件基础上的,基于CPU对整个机器的资源进行管理和调度;
- 操作系统本质上是一种软件,它需要有N多个函数代码来实现“智能”地管理(即根据不同的情境进行不同操作);
- 就像课程中提到的,操作系统的“两把剑”即中断上下文和进程切换。因为这是操作系统能够有条不紊地处理多个计算机的进程的关键(也是操作系统的意义所在);
- 操作系统内核会通过程序(就如同上面的几个小程序)判断何时进行进程切换、什么条件下进行切换,以及如何保存进程上下文,再到进程如何中断和开始等等;
- 操作系统不纠缠于硬件的细节,只关注动作和调度的可实现性。