UnixV6分析(2) 中断/陷入/调用
17 Mar 2013 by LelouchHe
题外话
真的很是惭愧,自从去年11月开始弄这个之后,已经过去五个月了,而这居然才仅仅是v6分析的第二篇文章.当然,工作生活比较忙确实是一个原因,但更为重要的是,做事情没有计划,东一下西一下,什么都搞,什么都落下.所以在指定了新的计划之后,我会每周更新一次源码分析的文章,先把v6搞定再说
好的,闲话休讲.
为什么讲这篇
硬件是OS内部比较复杂的一块,我们看到,很多讲内核开发的书,谈论的主要内容就是驱动,和硬件打交道的模块.除去硬件和同硬件交互的模块,剩下的就是我们的应用层面的事情了(当然,这是针对内核而言,不是我们普通的应用),不论是文件系统,调度还是输入输出等等,都可以看作是软件工作,它们固然很复杂,但是就已经脱离开硬件的层面了.
所以这就是这篇的目的,上一篇当中我们介绍了v6运行的环境–pdp11/40,这一篇我们讲和硬件的交互,中断/陷入
名词解释
- 中断: cpu外设产生的信号,有一定的优先级,等待cpu进行处理,可以被屏蔽
- 陷入: cpu内部产生的信号,无优先级,cpu一般立即处理,无法屏蔽(也可以认为优先级最高)
- 调用: 软件生成的信号,一般来说是陷入的一种,通常用来进行系统调用
之所以将这三个统一起来介绍,最主要的原因在于这三种本质上都是同cpu打交道(cpu是最重要的硬件,所以也算是驱动交互模块吧),通知cpu某些事情发生,等待cpu进行处理,而且这三个都是异步的,即不是事先安排好的调用顺序,是可能随时发生,随时处理的事件(其实想想,很类似我们GUI编程中的事件循环,cpu不停的idle中,直到有事件来,处理完后又回到idle).而且,这三个的处理在v6基本上是一个过程(其他os也类似),所以我就直接来总的说明了.
下文我就不每次都把三个名称都列出来了,大家意会.
补充说明
unixv6的汇编代码中大部分的数字都是用8进制表示的,10进制表示的符号会在数字末尾添加’.’符号以示区别,而本篇中默认的显示为10进制,8进制的数字我会按照常规,前面添加’0’以示区别.这点比较闹心,还望大家记清(v6汇编中使用8进制的原因见前一篇)
相关指令一览
pdp-11/40有些指令同这三种交互有关,现在给予一个简单的介绍,通过后文对代码的描述,应该会有更彻底的了解
需要注意的是,我们这里讨论的是v6使用的汇编器的指令,可能和pdp-11/40手册有所不同
br 1f
: 无条件相对跳转,有一定范围限制(-128~+127之间),一般我们只用标号来代替实际的相对位置(这个复杂工作交由汇编器处理),如本例,1f表示向下的第一个标号1位置,反之,1b表示向上的第一个标号1位置jmp dst
: 无条件绝对跳转(pc=dst)jsr reg, dst
: 这是我们使用最多的跳转,用来完成调用的,操作过程为1.reg入栈;2.reg=next(即reg保存了这条指令的下一条指令地址,也就是调用返回地址);3.jmp dstrts reg
: 配合jsr完成调用的返回,操作过程为1.pc=reg(即reg保存了返回地址);2.reg=出栈内容.对比jsr,可以看到对称的一面rtt
: 配合中断(中断都是将ps,pc依次入栈),操作为pc,ps依次出栈回复原值
trap之谜
在v6中,有三个trap,是我们需要注意的(对,万恶的三个trap居然是一模一样的名字,谁起的啊)
- 汇编指令trap: 1518行的代码即是汇编指令trap的一例,trap的icode从0104400到0104777,前八位固定,后八位是调用号,013表示第11号系统调用,即exec(参看2923行).很多时候,我们可以称之为’sys’,因为trap指令唯一的用途就是进行系统调用
- 汇编代码trap: 755行的代码就是trap的汇编程序,这个trap是作为一个入口来使用的,同下面(776行)的call一样,是为了给中断调用搭建环境的,因为毕竟我们不想在汇编完成复杂的处理逻辑,但中断调用时,cpu不会自动的建立c函数需要的堆栈环境,所以需要做个中间跳板
- c函数trap: 2693行的代码就是c函数trap,这里完成了大多数调用逻辑,2中的汇编代码就是为了给这个函数提供环境,所以记住,这个trap函数并不普通,有一些神奇的特性(后文会看)
中断/陷入流程
中断/陷入的过程都是类似的,区别仅在于来源和优先级不一样(一个外,一个内;一个有,一个无),所以二者只在最开始有所不同
中断的前奏
外部硬件产生一个信号,发送到与之相连的总线,并接通到cpu的中断引脚上,此时,cpu会首先比较该中断优先级和当前优先级(ps的5-7位),如果当前优先级高,则忽略该中断,否则就进行处理
陷入的前奏
cpu内部产生一个信号(某些故障或者软件进行了系统调用),cpu马上进行无延迟的处理
注意,此处无延迟的处理并不是说我们写的某个系统调用一下子就被调用了,要知道,我们能控制的只是在进程内部使用了trap指令,但是什么时候cpu运行到进程的这一步,是有cpu进行调度的(这是后面的大问题),所以不要把调度和陷入混淆.(但是只要运行这个指令,就是无延迟的处理)
故事的后来
- cpu将当前的ps,pc分别入当前栈.此处需要注意的是,pdp-11/40分两种模式,kernel和user,需要看清楚什么时候入kernel栈,什么时候入user栈.一般而言,内核自己处理的一般都是kernel栈,比如内核正在调度时,中断发生了,那么此时ps,pc就是入kernel栈;再比如用户进行系统调用的时候,就是入user栈
- 从指定的中断向量中,依次得到pc,ps的新值.中断向量(trap vector)是事先约定好的,比如总线错误(bus error)就是1号,系统调用就是7号,中断向量的地址是中断向量号*4(每个中断向量为4B,2B的pc值,2B的ps值),比如总线错误的向量位置为4,系统调用的位置为28.大家可以从low.s(500-577有一个感性的认识)
- pc有了新值,自然就执行新的命令,一般而言,执行的都是一个跳转指令,跳到入口的trap(755行)或者call(776行),给即将执行的c函数搭建环境.当然其中还有一些额外的检测处理,不过大致都是构建堆栈,填补参数,此处的参数不是我们真正的处理参数,而是交给trap或者对应硬件驱动处理函数的参数(我们仅介绍trap)
- 从调用中返回,准备进行返回,进行调度检测(这个以后说),然后清理堆栈,恢复原来的pc和ps(使用rtt指令),执行完毕
总结
可以看到,流程还是比较简单的,代码除了有些绕之外,也是比较好理解的.
问题的关键在于我们如何设计一个通用的处理流程,而且还能针对不同的处理进行区分,这一点,我想,是这些trap设计的难点,也是下面要一一展开的.
代码分析
入口
中断/陷入的进入是不受软件控制的,当cpu外部或者内部产生对应的信号,cpu会自动的将ps/pc入栈,然后按照事先约定好的中断号,取得对应的新的pc/ps(注意顺序,正好的对称的),而这里,则是我们可以控制的最接近入口的地方
v6的中断向量如下(507开始)
br4 = 200
br5 = 240
br6 = 300
br7 = 340
. = 0 ^ .
br 1f
4
/ trap vectors
trap; br7 + 0. / trap是新的pc,br7+0是新的ps,需要注意的是二者都是2B(16bit),不足补零.
trap; br7 + 1. / 可以看到,前面定义的br正好是在ps的优先级位置,表明当前的优先级
trap; br7 + 2. / 而前模式和现模式都是00,表示为kernel模式
trap; br7 + 3. / 后面的0-6并不是普通的ps状态,而是后面处理用来区别不同来源的标识
trap; br7 + 4.
trap; br7 + 5.
trap; br7 + 6.
后面还有一些特殊的其他硬件中断向量,我们在分析到具体的硬件时,再讨论
trap
从中断向量过来,我们的新pc变成了trap的地址(就是我们即将运行的地方),新ps变成了br7+中断来源标识(特殊的仅供trap使用的),接下来我们进入trap来看下(755开始)
trap:
mov PS, -4(sp) / 保存新的ps
开始的时候,我们先把新的ps入栈,前面可以看到,新的ps最主要的功能是表明当前的优先级和中断来源标识,现在,一种可能的栈内容如下(如果中断发生在kernel模式时):
tst nofault
bne 1f / nofault不为0,跳转到1f执行代码
mov SSR0, ssr
mov SSR2, ssr + 4
mov $1, SSR0 / 不论SSR的设置,保存后开启mm
jsr r0, call1; _trap / 当前的r0入栈,新的r0值为_trap(C程序的trap),跳转到call1
1:
mov $1, SSR0 / 开启mm
mov nofault, (sp) / 将原本在栈中的pc值替换为nofault,
rtt / 所以这个rtt就能直接跳转到nofault了
系统的中断处理并不是一成不变的,如果我们事先设置了nofault的值(一般是某个执行地址),我们就会跳转到nofault而不是进行常规的中断处理(851行的gword就设置了nofault的值为880行的err,当中断出现的时候,会执行err).nofault变量是保存在内存固定位置的(可见1466行)
进入call1之前的栈内容(注意,此时r0保存了C程序中trap的地址):
call1:
tst -(sp) / 我们把新ps保存在-4(sp),跳转的时候,又入栈了当前r0,所以现在tst的是保存的新ps
bic $340, PS / 将PS中的优先级清0,只剩下的中断来源标识
br 1f
call:
mov PS, -(sp)
1:
mov r1, -(sp) / 将r1入栈
mfpi sp / 将前模式的sp入栈
mov 4(sp), -(sp) / 将新ps入栈
bic $!37, (sp) / 清空此处新ps的优先级,保留中断来源标识
bit $30000, PS / 测试前模式,
beq 1f / 如果是user模式,则跳转
jsr pc, *(r0)+ / pc入栈(入栈前pc的值为2的地址,作为返回地址),pc为下面2的地址,跳转到r0(pc又重新为c程序中的trap地址)
如果我们不进行跳转(前模式为kernel,从上面的中断向量来看其实都是,只是有时有特殊情况),跳转前的栈内容如下:
这个栈就是我们进入C语言的trap(2693行)的栈内容.可以看到,除了栈顶的返回地址外,栈内容大部分都是作为trap的参数处理,按照C语言的调用规范,参数从右往左入栈.
当然,C语言的函数入口会添加特殊的内容(1420的csv)用以保存额外的寄存器值,C语言的特殊处理和trap的操作我们后叙.
我们先看从trap回来之后.需要记住一点,C语言调用规范要求调用者来清理调用栈,所以当从trap返回的时候,除了返回地址外,其余的栈内容是不变的(当然,值能可能变)
2:
bis $340, PS / 设置优先级为7,类似于加锁
tstb _runrun / 测试_runrun,这个全局变量用于指示目前可以进行调度,如果_runrun>0,则会调用swtch,这个后叙
beq 2f
bic $340, PS / 清空优先级,因为swtch调度程序是可以被中断的
jsr pc, _swtch / 进入调度
br 2b / 跳转回上面的2,进行重复判断,直到不用调度可以直接返回
虽然我们这里不说,但是这里可以看到一个进行进程调度的时机–从中断返回的时候.要不然是中断了,要不然就是从陷入返回了,此时都是进行程序调度的时机.后面介绍进程的时候会叙述.
如果不需要调度了,此时的栈内容为:
2:
tst (sp)+ / 测试中断来源标识
mtpi sp / 将栈顶值出栈,赋值到前模式的sp中
br 2f
下面的1离上面已经很远了,当我们判断前模式为kernel时,我们直接跳转到trap;为user时,我们先设置现模式为user(因为统一中断向量入口进来的,现模式都是kernel),然后再跳转,之后恢复堆栈
1: / 如果前模式的user,则跳转到这里
bis $30000, PS / 恢复PS的模式值(11)
jsr pc, *(r0)+ /
cmp (sp)+, (sp)+
此时,二者堆栈内容相同了,我们恢复保存的寄存器
2:
mov (sp)+, r1 / 恢复r1
tst (sp)+
mov (sp)+, r0 / 恢复r0
rtt / 返回
C函数
刚才我们说到,从汇编的trap进入C语言的trap,还有一个额外的步骤,即C编译器对所有C函数的入口和出口进行了处理,用以保存和恢复可能影响的寄存器值.这一点是pdp-11上特有的,在其他系统也许不太一样,我们来简单的看下编译器究竟添加了什么
入口(1420的csv):
csv:
mov r5, r0 / 保存环境指针
mov sp, r5 / 设置新的环境指针
mov r4, -(sp) / 保存r4
mov r3, -(sp) / 保存r3
mov r2, -(sp) / 保存r2
jsr pc, (r0) / 跳转,返回地址变成了紧跟着csv的返回函数,见下
r5被称为”环境指针”,从这段代码可以看到,r5保存的是跳转前的sp值,类似于x86的bp,用于保存调用顺序和方便寄存器和参数的偏移计算.还记得进入函数时sp的指向么?对,它指向的是返回地址
出口(1430的cret)紧跟着csv
,这个是必须的,因为上面的jsr
跳转时,返回地址就是csv
,这样才能保证csv
会在每个函数的出口时调用
cret:
mov r5, r1 / 取得刚才保存的bp位置
mov -(r1), r4 / 恢复r4
mov -(r1), r3 / 恢复r3
mov -(r1), r2 / 恢复r2
mov r5, sp / 恢复sp值
mov (sp)+, r5 / 恢复r5
rts pc / pc=返回地址,返回了
栈内容我就不画出了,比较容易.
大家也许会疑问,添加了这么多的寄存器值,我们从C函数获取参数的时候怎么办呢?偏移不就乱了么?
这个不用担心,刚才我们看到的r5就是来做这个的,r5是函数参数和额外保存寄存器的分割,使用r5就能以常量偏移计算出所需要的各种参数,这是是pdp-11编译器自动保证的.
顺便提一下C函数的调用约定:调用者清理调用栈,函数参数从右至左依次入栈,r0和r1作为可能的返回值(这也是他们不保存的原因).这一点我们从trap的调用已经看得很清楚了.
真正的trap处理函数
好吧,我食盐了.还是要画一个完整的栈图,才能说清楚2605行的那些乱七八糟的值是什么
现在我可以解释了,2605-2613其实都是对应寄存器相对于R0的偏移,之后我们只需要知道R0的值,就能找到所有寄存器的值.当然,这个值是保存在栈上的值,而不是真正寄存器的值,但是当函数返回之后,这些栈上的值会复制回原有的寄存器(从cret和trap可以看到),所以对他们的更改,等于变相更改了寄存器.这个是只能发生在trap里的trick,别的函数也可以,不过别的函数无法修改除r2-r5的值,因为其他的不在栈上保存
trap的代码结构比较简单,比较复杂的是各种中断的定义和信号的处理,这些内容我们在后文叙述,现在只说下系统调用是如何处理的.
系统调用的中断标识为6(见518行),但是所有的系统调用的标识都是6,我们通过其在调用时的编号来对各种系统调用进行区分.如下(2751行):
case 6 + USER: // 系统调用都是在user模式下调用的,kernel直接使用对应的操作即可
u.u_error = 0;
ps =& ~EBIT;
callp = &sysent[fuiword(pc - 2) & 077]; // pc - 2正好是系统调用指令,低6位是调用号
if (callp == sysent) { // 0为间接调用,参数的起始位置是pc处的值
a = fuiword(pc);
pc =+ 2;
i = fuword(a);
if ((i & ~077) != SYS)
i = 077;
callp = &sysent[i & 077];
for (i = 0; i < callp->count; i++)
u.u_arg[i] = fuword(a =+ 2);
} else { // 余为直接调用,参数的起始位置是pc
for (i = 0; i < callp->count; i++) {
u.u_arg[i] = fuiword(pc);
pc =+ 2;
}
}
// 剩下的就是调用和返回值处理,需要注意中断和信号处理
// 这个问题我们后叙
// ...
fuiword和fuword是从user模式下取得一个字的数据,其区别可以忽略.主要说下直接调用和间接调用的区别
- 间接调用: 参数放在数据区,但是其起始位置在
sys xx
之后,即pc的位置处.所以我们先需要读取pc的值,然后从那个值代表的地址处开始,读取count个值 - 直接调用: 参数放在
sys xx
之后,其起始位置就是pc(因为pc指向调用时下一条指令位置),所以我们需要从pc开始读取数据,直到count个
注意我们对pc的修改,这个修改最后会赋值到真正的pc中去(从trap的返回可以看到),我们的修改恰好可以跳过系统调用的参数(直接或间接),正好在返回时指向下一个指令的地址(根据调用约定,参数后的是下一条指令)
其实可以注意到,在分析trap里到处都是各种约定,这些约定在一个系统里是实现约定统一的,这样我们做的任何假定才能正确建立,我们需要了解这些约定,并充分的利用之.
总结
对中断/陷入/调用的讲述基本OK了,我们在上一篇讲解硬件的基础上,讲解了部分可能的软硬件接口操作,这些操作在以后的驱动之类的地方我们还会再次碰到,到时候我们会从特定应用的角度来讲这个的实际应用
这一篇讲的重点还是和硬件的交互,下一篇会重点介绍OS的一个重要概念–进程,重点还是放在和硬件打交道上,包括进程的切换和各种状态值间的设置.希望借下一篇把大部分汇编代码讲完