赵瀚青原创作品转载请注明出处《Linux内核分析》MOOC课程http://mooc.study.163.com/course/USTC-1000029000
一、计算机的三个法宝
- 存储程序计算机
- 函数调用堆栈
- 中断机制
二、堆栈
1、堆栈的空间分配
- 栈:由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆: 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收,分配方式倒是类似于链表。
2、堆栈缓存方式
栈使用的是一级缓存, 他们通常都是被调用时处于存储空间中,调用完毕立即释放。
堆则是存放在二级缓存中,生命周期由虚拟机的垃圾回收算法来决定(并不是一旦成为孤儿对象就能被回收)。所以调用这些对象的速度要相对来得低一些。
3、堆栈相关寄存器
- esp:堆栈指针(stack pointer),指向系统栈最上面一个栈帧的栈顶
- ebp: 基址指针(base pointer),指向系统栈最上面一个栈帧的底部
- cs:eip:指令寄存器(extended instruction pointer),指向下一条等待执行的指令地址
三、堆栈的一些基本指令
- push:以字节为单位将数据(对于32位系统可以是4个字节)压入栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4的地址单元。
- pop: 过程与PUSH相反。
- call: 用来调用一个函数或过程,此时,下一条指令地址会被压入堆栈,以备返回时能恢复执行下条指令。
- leave:当调用函数调用时,一般都有这两条指令pushl %ebp和movl %esp,%ebp,leave是这两条指令的反操作。
- ret: 从一个函数或过程返回,之前call保存的下条指令地址会从栈内弹出到EIP寄存器中,程序转到CALL之前下条指令处执行。
四、C函数调用与堆栈变化
一个典型的栈帧
ESP==>| : |
| . |
+-------------------------+
| 被调用者保存的寄存器现场 |
| EBX,ESI和EDI(根据需要) |
+-------------------------+
| 临时空间 |
+-------------------------+
| 局部变量#2 | [EBP - 8]
+-------------------------+
| 局部变量#1 | [EBP - 4]
+-------------------------+
EBP==>| 调用者的EBP |
+-------------------------+
| 返回地址 |
+-------------------------+
| 实际参数#1 | [EBP + 8]
+-------------------------+
| 实际参数#2 | [EBP + 12]
+-------------------------+
| 实际参数#3 | [EBP + 16]
+-------------------------+
| 调用者保存的寄存器现场 |
| EAX,ECX和EDX(根据需要)|
+-------------------------+
| : |
| . |
图 1
图1是一个典型的栈帧,图中,栈顶在上,地址空间往下增长。
这是如下一个函数调用时的栈的内容:
int foo(int arg1, int arg2, int arg3);
并且,foo有两个局部的int变量(4个字节)。在这个简化的场景中,main调用foo,而程序的控制仍在foo中。这里,main是调用者(caller),foo是被调用者(callee)。
ESP被foo使用来指示栈顶。EBP相当于一个“基准指针”。从main传递到foo的参数以及foo本身的局部变量都可以通过这个基准指针为参考,加上偏移量找到。
由于被调用者允许使用EAX,ECX和EDX寄存器,所以如果调用者希望保存这些寄存器的值,就必须在调用子函数之前显式地把他们保存在栈中。另一方面, 如果除了上面提到的几个寄存器,被调用者还想使用别的寄存器,比如EBX,ESI和EDI,那么,被调用者就必须在栈中保存这些被额外使用的寄存器,并在 调用返回前回复他们。也就是说,如果被调用者只使用约定的EAX,ECX和EDX寄存器,他们由调用者负责保存并回复,但如果被调用这还额外使用了别的寄 存器,则必须有他们自己保存并回复这些寄存器的值。
传递给foo的参数被压到栈中,最后一个参数先进栈,所以第一个参数是位于栈顶的。foo中声明的局部变量以及函数执行过程中需要用到的一些临时变量也都存在栈中。
小于等于4个字节的返回值会被保存到EAX中,如果大于4字节,小于8字节,那么EDX也会被用来保存返回值。如果返回值占用的空间还要大,那么调用者会向被调用者传递一个额外的参数,这个额外的参数指向将要保存返回值的地址。用C语言来说,就是函数调用:
x = foo(a, b, c);
被转化为:
foo(&x, a, b, c);
注意,这仅仅在返回值占用大于8个字节时才发生。有的编译器不用EDX保存返回值,所以当返回值大于4个字节时,就用这种转换。
当然,并不是所有函数调用都直接赋值给一个变量,还可能是直接参与到某个表达式的计算中,如:
m = foo(a, b, c) + foo(d, e, f);
有或者作为另外的函数的参数, 如:
fooo(foo(a, b, c), 3);
这些情况下,foo的返回值会被保存在一个临时变量中参加后续的运算,所以,foo(a, b, c)还是可以被转化成foo(&tmp, a, b, c)。
让我们一步步地看一下在c函数调用过程中,一个栈帧是如何建立及消除的。
函数调用前调用者的动作
在我们的例子中,调用者是main,它准备调用函数foo。在函数调用前,main正在用ESP和EBP寄存器指示它自己的栈帧。
首先,main把EAX,ECX和EDX压栈。这是一个可选的步骤,只在这三个寄存器内容需要保留的时候执行此步骤。
接着,main把传递给foo的参数一一进栈,最后的参数最先进栈。例如,我们的函数调用是:
a = foo(12, 15, 18);
相应的汇编语言指令是:
push dword 18
push dword 15
push dword 12
最后,main用call指令调用子函数:
call foo
当call指令执行的时候,EIP指令指针寄存器的内容被压入栈中。因为EIP寄存器是指向main中的下一条指令,所以现在返回地址就在栈顶了。在call指令执行完之后,下一个执行周期将从名为foo的标记处开始。
图2展示了call指令完成后栈的内容。图2及后续图中的粗线指示了函数调用前栈顶的位置。我们将会看到,当整个函数调用过程结束后,栈顶又回到了这个位置。
| |
+-------------------------+
ESP>| 返回地址 |
+-------------------------+
| 实际参数#1 = 12 |
+-------------------------+
| 实际参数#2 = 15 |
+-------------------------+
| 实际参数#3 = 18 |
+-------------------------+
| 调用者保存的寄存器现场 |
|EAX,ECX和EDX(根据需要) |
+=====================+
| : |
EBP>| . |
图 2
被调用者在函数调用后的动作
当函数foo,也就是被调用者取得程序的控制权,它必须做3件事:建立它自己的栈帧,为局部变量分配空间,最后,如果需要,保存寄存器EBX,ESI和EDI的值。
首先foo必须建立它自己的栈帧。EBP寄存器现在正指向main的栈帧中的某个位置,这个值必须被保留,因此,EBP进栈。然后ESP的内容赋值给了 EBP。这使得函数的参数可以通过对EBP附加一个偏移量得到,而栈寄存器ESP便可以空出来做其他事情。如此一来,几乎所有的c函数都由如下两个指令开 始:
push ebp
mov ebp, esp
此时的栈入图3所示。在这个场景中,第一个参数的地址是EBP加8,因为main的EBP和返回地址各在栈中占了4个字节。
| |
+-------------------------+
ESP=EBP>| main的EBP |
+-------------------------+
| 返回地址 |
+-------------------------+
| 实际参数#1 = 12 | [EBP + 8]
+-------------------------+
| 实际参数#2 = 15 | [EBP + 12]
+-------------------------+
| 实际参数#3 = 18 | [EBP + 16]
+-------------------------+
| 调用者保存的寄存器现场 |
|EAX,ECX和EDX(根据需要) |
+=======================+
| : |
图 3
下一步,foo必须为它的局部变量分配空间,同时,也必须为它可能用到的一些临时变量分配 空间。比如,foo中的一些C语句可能包括复杂的表达式,其子表达式的中间值就必须得有地方存放。这些存放中间值的地方同城被称为临时的,因为他们可以为 下一个复杂表达式所复用。为说明方便,我们假设我们的foo中有两个int类型(每个4字节)的局部变量,需要额外的12字节的临时存储空间。简单地把栈 指针减去20便为这20个字节分配了空间:
sub esp, 20
现在,局部变量和临时存储都可以通过基准指针EBP加偏移量找到了。
最后,如果foo用到EBX,ESI和EDI寄存器,则它f必须在栈里保存它们。结果,现在的栈如图4所示。
| : |
+-------------------------+
ESP==>|被调用者保存的寄存器现场 |
|EBX,ESI和EDI(根据需要) |
+-------------------------+
| 临时空间 | [EBP - 20]
| |
+-------------------------+
| 局部变量#2 | [EBP - 8]
+-------------------------+
| 局部变量#1 | [EBP - 4]
+-------------------------+
EBP==>| main的EBP |
+-------------------------+
| 返回地址 |
+-------------------------+
| 实际参数#1 | [EBP + 8]
+-------------------------+
| 实际参数#2 | [EBP + 12]
+-------------------------+
| 实际参数#3 | [EBP + 16]
+-------------------------+
| 调用者保存的寄存器现场 |
|EAX,ECX和EDX(根据需要) |
+=========================+
| : |
图 4
foo的函数体现在可以执行了。这其中也许有进栈、出栈的动作,栈指针ESP也会上下移动,但EBP是保持不变的。这意味着我们可以一直用[EBP+8]找到第一个参数,而不管在函数中有多少进出栈的动作。
函数foo的执行也许还会调用别的函数,甚至递归地调用foo本身。然而,只要EBP寄存器在这些子调用返回时被恢复,就可以继续用EBP加上偏移量的方式访问实际参数,局部变量和临时存储。
被调用者返回前的动作
在把程序控制权返还给调用者前,被调用者foo必须先把返回值保存在EAX寄存器中。我们 前面已经讨论过,当返回值占用多于4个或8个字节时,接收返回值的变量地址会作为一个额外的指针参数被传到函数中,而函数本身就不需要返回值了。这种情况 下,被调用者直接通过内存拷贝把返回值直接拷贝到接收地址,从而省去了一次通过栈的中转拷贝。
其次,foo必须恢复EBX,ESI和EDI寄存器的值。如果这些寄存器被修改,正如我们前面所说,我们会在foo执行开始时把它们的原始值压入栈中。如 果ESP寄存器指向如图4所示的正确位置,寄存器的原始值就可以出栈并恢复。可见,在foo函数的执行过程中正确地跟踪ESP是多么的重要————也就是 说,进栈和出栈操作的次数必须保持平衡。
这两步之后,我们不再需要foo的局部变量和临时存储了,我们可以通过下面的指令消除栈帧:
mov esp, ebp
pop ebp
其结果就是现在栈里的内容跟图2中所示的栈完全一样。现在可以执行返回指令了。从栈里弹出返回地址,赋值给EIP寄存器。栈如图5所示:
| |
+-------------------------+
ESP==>| 实际参数#1 |
+-------------------------+
| 实际参数#2 |
+-------------------------+
| 实际参数#3 |
+-------------------------+
| 调用者保存的寄存器现场 |
|EAX,ECX和EDX(根据需要) |
+=========================+
| : |
EBP==>| . |
图 5
i386指令集有一条“leave”指令,它与上面提到的mov和pop指令所作的动作完全相同。所以,C函数通常以这样的指令结束:
leave
ret
实验
1、实验内容
运行并分析一个精简的操作系统内核,理解操作系统是如何工作的
使用实验楼的虚拟机打开shell
cd LinuxKernel/linux-3.9.4
qemu -kernel arch/x86/boot/bzImage
然后cd mykernel 您可以看到qemu窗口输出的内容的代码mymain.c和myinterrupt.c
使用自己的Linux系统环境搭建过程参见mykernel,其中也可以找到一个简单的时间片轮转多道程序内核代码
2、实验步骤
1、代码分析(代码为git库中下载)
一、进程的启动
/* start process 0 by task[0] */
pid = 0;
my_current_task = &task[pid];
asm volatile(
"movl %1,%%esp
" /* 将进程的sp赋给esp寄存器 */
"pushl %1
" /* ebp入栈:因为在这里栈为空,esp=ebp,所以push的%1就是esp就是ebp。*/
"pushl %0
" /* 进程入口ip入栈 */
"ret
" /* 把进程入口ip赋给eip,即从这之后0号进程启动。*/
"popl %%ebp
"
:
: "c" (task[pid].thread.ip),"d" (task[pid].thread.sp) /* input c or d mean %ecx/%edx*/
);
```
二、进程的切换
进程的切换有两种:
1.下一个进程next->state == 0 即正在执行时
/*出自myinterrupt.c*/
//两个正在运行的进程之间做进程上下文切换
if(next->state == 0)
/* state值的含义:-1表示没有执行,0表示正在执行,>0表示停止,这里为0,即进程正在执行 */
{
/* 以下是进程切换关键代码 */
asm volatile(
"pushl %%ebp
" /* 把当前进程的ebp保存*/
"movl %%esp,%0
" /* 把当前进程的esp赋值到sp中保存下来*/
"movl %2,%%esp
" /* 把下一个进程的sp放到esp中*/
"movl $1f,%1
" /* 把eip保存起来,$1f指接下来的标号1:的位置*/
"pushl %3
" /*把下一个进程的eip保存起来*/
"ret
" /* 还原eip */
"1: " /* 标号1,下一进程从此开始 */
"popl %%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<<<
",prev->pid,next->pid);
}
2.进程是一个新进程,还从未执行过
/*出自myinterrupt.c*/
/*这段代码是当进程从未执行过时,所执行的动作,即启动一个进程
```javascript
next->state = 0; /* 首先要把进程置为运行时状态,作为当前正在执行的进程 */
my_current_task = next;
printk(KERN_NOTICE ">>>switch %d to %d<<<
",prev->pid,next->pid);
/* 进程切换时的提示,从当前进程切换至下一进程*/
asm volatile( //混合编程
"pushl %%ebp
" /* 保存ebp */
"movl %%esp,%0
" /* 保存esp */
"movl %2,%%esp
" /* 将下一进程的sp存入esp */
"movl %2,%%ebp
" /* 将下一进程的bp存入ebp,因为栈空,所以esp和ebp指向同一位置 */
"movl $1f,%1
" /* 保存eip */
"pushl %3
" /*保存当前进程入口 */
"ret
" /* 还原eip */
: "=m" (prev->thread.sp),"=m" (prev->thread.ip)
: "m" (next->thread.sp),"m" (next->thread.ip)
);
二、实验过程
如下图截图所示
三、实验结论
在my_schedule函数中,完成进程的切换。进程的切换分两种。
1.下一个进程没有被调度过。
2.下一个进程被调度过,可以通过下一个进程的state知道其状态。
进程切换通过内联汇编代码实现,需要保存之前的进程的eip和堆栈,然后将新进程的eip和堆栈的值存入相对应的寄存器中。
心得与体会
本周内容比起上周难度有明显的提高,讲解操作系统时将操作系统的很多细节都描述的非常到位,并且让我们模拟了一个操作系统的运行过程,让我们用代码的形式理解操作系统的运行机制,也让我对操作系统有了更深刻的认识,希望在日后的学习中能够更加顺利的完成学习任务