UnixV6分析(4) 进程--相关汇编程序
26 May 2013 by LelouchHe
关于本篇
从下一篇开始,我们就准备跳入C语言的世界了,在跳进去之前,我们需要把汇编的东东搞定.所以借着研究进程的讲解,我们把剩下的大部分相关汇编介绍一遍.
分析汇编代码
代码脱离开使用情景,非常的让人迷惑,所以曾经我试图想在讲解main的时候讲解代码,但是总觉得没有搞清每个函数的作用,断然调入各种调用之间,很是迷惑
所以,我们这里先介绍以后使用到的各种函数,当然,我们首先简单介绍每个函数的使用场景,具体的参数/返回值和函数的作用,希望在分析的时候大家注意这点
mfpi/mtpi
这两个不是函数,而是两条特殊汇编指令,用以提供模式之间的数据交互(还记得否,模式在PS的高4位,分为前模式和现模式,有两种值,kernel或者user),可以根据PS的值来进行对应的设置,甚至可以是同一个模式
- mfpi: 使用的格式为
mfpi d
,作用是将前模式的d(任意一个地址值或寄存器)中的内容压入当前栈 - mtpi: 使用的格式为
mtpi d
,作用是将从当前栈出栈,并把值赋值到前模式的d(任意一个地址值或寄存器)里
这两个指令不仅提供了不同模式之间数据的交互,同样可以以栈为中介,完成同一模式下的数据交互,我们可以在后面的具体使用中看到实例
clearseg
clearseg的原型类似于void clearseg(char *block_addr);
,参数是内存中某block的起始地址,作用是将本block的内容清零.一个block是32字,即64B大小
具体代码见676行:
.globl _clearseg
_clearseg:
mov PS, -(sp) / 保存原有的PS
mov UISA0, -(sp) / 保存原有的UISA0
mov $30340, PS / cur = kernel, prev = user, pri = 7
mov 6(sp), UISA0 / UISA0 = block_addr
mov UISD0, -(sp) / 保存UISD0
mov $6, UISD0 / acf = rw
clr r0
mov $32., r1 / num = 32
1:
clr -(sp) / 0入栈
mtpi (r0)+ / 0出栈,并赋值到r0
sob r1, 1b
mov (sp)+, UISD0 / 恢复原有值
mov (sp)+, UISA0
mov (sp)+, PS
rts pc
clearseg其实就是把kernel栈的内容清零,然后把它出栈赋值到user空间的r0位置.此处,我们借用了user模式的S1作为kernel和user交互的窗口,一个字一个字(2B)的清零
copyseg
copyseg的原型类似于void copyseg(char *src, char *dst);
,参数是内存中两个block地址,作用是将src block的内容复制到dst block中.
具体见695行:
.globl _copyseg
_copyseg:
mov PS, -(sp) / 保存原有值
mov UISA0, -(sp)
mov UISA1, -(sp)
mov $30340, PS / cur = kernel, prev = user, pri = 7
mov 10(sp), UISA0 / UISA0 = src
mov 12(sp), UISA1 / UISA1 = dst
mov UISD0, -(sp)
mov UISD1, -(sp)
mov $6, UISD0 / acf = rw
mov $6, UISD1 / acf = rw
mov r2, -(sp)
clr r0 / r0从0开始,对应S0
mov $8192., r1 / r1从8K开始,对应S1
mov $32., r2
1:
mfpi (r0)+ / 从S0取,即从src取
mtpi (r1)+ / 复制到S1,即复制到dst
sob r2, 1b
mov (sp)+, r2 / 恢复
mov (sp)+, UISD1
mov (sp)+, UISD0
mov (sp)+, UISA1
mov (sp)+, UISA0
mov (sp)+, PS
rts pc
这就是利用kernel栈作为中介,将同一模式(user模式)下数据进行操作的例子,和clearseg,我们借用了user的S0/S1作为数据的来源和目的.恩,本质来说,还是比较简单的
savu
savu的原型类似于void savu(int reg[2]);
,参数是一个寄存器的数组,作用是将当前的sp和r5保存到这个数组中去(一般就是上一篇看到的user.u_rsav).当然,这是它的显式作用,至于为什么要这样设计,这个我们后叙
具体代码见725行:
_savu:
bis $340, PS / pri = 7
mov (sp)+, r1 / 出栈返回地址,保存到r1
mov (sp), r0 / 出栈参数(即reg地址),保存到r0
mov sp, (r0)+ / 一系列赋值
mov r5, (r0)+
bic $340, PS / pri = 0,此处并没有保存,因为没有必要
jmp (r1) / 直接跳转
一般我们使用rts或者rtt(中断的时候)来从函数返回,但此处并没有,而是选择了手动的获取返回地址并直接jmp.我们把栈的情况用图说明下:
从上图可以看到,保存的reg[0]为指向参数reg的sp值,我们从一个函数中返回后,sp同样指向类似的位置(即首个参数的位置),然后有调用方进行清栈,所以保存这样的sp是正确的
最后我们使用jmp
,就是为了避免再次出栈的情况(因为我们已经手动出栈了)
但是否我们只有这么一个方法来做呢?我觉得不见得,其实我们计算出真正的sp值之后赋值给reg即可,没有必要手动处理返回地址出栈的过程.我想,这样做的目的,应该是和下面两个紧跟的函数相对称(这个想法可能是错的..额..)
我们就来看配合的其他两个函数
aretu, retu
这两个参数的原型类似,都是如void aretu(int reg[2]); void retu(int user_addr);
,参数是保存了sp和r5的数组(一般在我们的user.u_rsav或者user.u_ssav等字段,两个参数是一样的,知道为什么user结构的前两个是U_rsav了么?),作用是修改现在进程的sp和r5值,唯一的区别在于retu直接修改了kernel的S6,从而造成了进程的切换(还记得S6是指向当前运行进程么?)
具体代码见734行:
_aretu:
bis $340, PS
mov (sp)+, r1
mov (sp), r0 / 和savu类似,使用r1保存地址,r0保存reg
br 1f
_retu:
bis $340, PS
mov (sp)+, r1
mov (sp), KISA6 / 把reg的值赋值给KISA6,这也是为什么user结构最开始必须是u_rsav的原因
mov $_u, r0 / r0的值为_u的地址,_u指向的同样是KISA6
1:
mov (r0)+, sp
mov (r0)+, r5
bic $340, PS
jmp (r1)
看了代码,我们就清楚了,retu直接把reg的地址复制给了S6的PAR,这样S6指向的进程就发生了切换.不过此时的pc还没有改变,所以我们执行的还是原进程的代码.
也许你说,怎么会呢?进程间不是隔离的么?自然,我这里说的,只是指进程的kernel部分,这一部分是进程间共享的(进程切换属于调度范畴,是kernel的功能之一,也就是kernel做的额外事),所以我们切换了S6,但是仍然在原进程的kernel部分继续执行代码.
让我们用图来梳理下:
当我们从retu/aretu返回时,调用方首先清理参数栈,看到了吧,由于savu和retu/aretu的参数是一致的,所以清栈是成功的(参数个数一致,清栈操作一致),这样从retu/aretu返回完全不影响原进程的kernel部分的执行,直到该调用方返回.
此时调用方返回,清理临时变量栈,取得返回地址,更新r5,然后返回.但此时sp和r5变成了原先savu时的sp和r5,此时的清栈变成了类似savu调用方的清栈,然后获取savu调用方的返回地址,这样,我们成功的返回,就相当于从最近一次savu调用方返回一致(此时连pc都变了)
为什么是savu的调用方?因为我们保存的不是savu的环境,而是手动的退回到了savu调用方的环境,此时再次返回的话,就是从savu的调用方返回.(再看一遍,保证你懂了)
记住一点,参数是调用方环境的一部分,retu的调用方返回时,我们的sp已经指向了savu的调用方,因此此时的返回必然是从savu的调用方返回.
这就是我们要同时保存sp和r5的原因,清理临时变量时需要r5给出清理的终点(一般直接赋值mov r5, sp
即可),能这样做,是因为这同样是系统的约定,在编译器层面和os的约定,只有这样我们才能完成如此trick的返回.
好吧,最复杂的已经过去了,savu/retu组合用来完成进程的切换.savu可以类比于setjmp,保存当下环境,retu类比于longjmp,跳转回原先保存的地方.当然,这只是类比,savu/retu处理的返回都是和调用方相关的.savu用于保存savu调用方的环境,retu的返回不会异常,但是retu调用方的返回,就是从savu的调用方的返回.等会儿分析到进程切换我们还会详叙.
retu和aretu的区别
上面介绍了二者的相似之处,其实这两个有一个很大的区别.
retu用于进程的切换,这里的进程指的是不同的kernel进程,即真正的进程.我们可以看到,retu使用的地方就在swtch(不在里头,就在附近,都是一个作用)
而aretu则不是,它虽然也是切换,但它是在kernel进程与信号处理进程直接切换.当然,本质而言,此时没有切换,信号处理仍然是在当前进程处理的(虽然有可能信号不是发给本进程的),从代码里也能看到,aretu唯一的目的就是trap的时候(之前和之后)提醒进程处理信号问题.注意,信号也是中断的一种,不要把它想的复杂了.
copyin, copyout
这两个函数类似于memcpy,不同的是,copyin/copyout区分了不同的数据来源.其原型为void copyin(void *src, void *dst, int size); void copyout(void *dst, void *src, int size)
,src为源数据区,dst为目标数据区,size为复制的字节数
这两个函数比较简单,我们直接看代码即可(见1244行)
_copyin:
jsr pc, copsu / 设置环境
1:
mfpi (r0)+
mov (sp)+, (r1)+
sob r2, 1b
br 2f
_copyout:
jsr pc, copsu / 设置环境
1:
mov (r0)+, -(sp)
mtpi (r1)+
sob r2, 1b
2:
mov (sp)+, nofault
mov (sp)+, r2
clr r0 / 成功,返回0
rts pc
copsu:
mov (sp)+, r0
mov r2, -(sp)
mov nofault, -(sp)
mov r0, -(sp)
mov 10(sp), r0
mov 12(sp), r1
mov 14(sp), r2
asr r2 / 数据传输以字(2B)为单位
mov $1f, nofault / mfpi/mtpi可能会导致bus error
rts pc
1:
mov (sp)+, nofault
mov (sp)+, r2
mov $-1, r0 / bus error时返回-1
rts pc
本身代码非常简单,就是利用mtpi/mfpi来完成数据的传输.需要注意的是,有可能造成的bus error,如果传输失败,就是被中断(还记得以前中,我们介绍的中断么?),在trap中调用我们这里设置好的nofault处理函数,然后返回-1
idle
此函数原型为void idle();
, 只是在空等而已,具体见1285
_idle:
mov PS, -(sp)
bic $340, PS / 优先级为0,最低
wait / 等
mov (sp)+, PS
rts pc
splx(x=0~7)
这一类函数的原型为void splx();
,就是修改PS来设置当前的优先级而已,代码见1929行.
从代码来看,v6用到的优先级只有0,1,5,6,7五个优先级
csv, cret
这两个函数在以前已经介绍过了,这个是v6的C编译器为每个C函数调用添加的
总结
还有一些汇编函数我们没有提到,有些是比较简单,有些同其他部分纠缠的过紧,我们后面才有足够的知识来了解,这些我们都在后面叙述
其实可以看到,大部分v6的汇编只是做了C语言无法做到的事情,比如操作各种寄存器,设置合适的硬件中断的.可以看到,汇编更多的是提供一种机制,但机制之上完成的各种策略仍然是需要C语言来完成的.
机制和策略的分离,使得v6容易的进行移植,因为我们只要完成底层机制的移植,上层的所有复杂策略就可以原封不动的使用.同样,这也是机制简单而牢固的原因