Home

UnixV6分析(4) 进程--相关汇编程序

26 May 2013 by LelouchHe

关于本篇

从下一篇开始,我们就准备跳入C语言的世界了,在跳进去之前,我们需要把汇编的东东搞定.所以借着研究进程的讲解,我们把剩下的大部分相关汇编介绍一遍.

分析汇编代码

代码脱离开使用情景,非常的让人迷惑,所以曾经我试图想在讲解main的时候讲解代码,但是总觉得没有搞清每个函数的作用,断然调入各种调用之间,很是迷惑

所以,我们这里先介绍以后使用到的各种函数,当然,我们首先简单介绍每个函数的使用场景,具体的参数/返回值和函数的作用,希望在分析的时候大家注意这点

mfpi/mtpi

这两个不是函数,而是两条特殊汇编指令,用以提供模式之间的数据交互(还记得否,模式在PS的高4位,分为前模式和现模式,有两种值,kernel或者user),可以根据PS的值来进行对应的设置,甚至可以是同一个模式

这两个指令不仅提供了不同模式之间数据的交互,同样可以以栈为中介,完成同一模式下的数据交互,我们可以在后面的具体使用中看到实例

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.我们把栈的情况用图说明下:

savu栈说明

从上图可以看到,保存的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栈说明

当我们从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容易的进行移植,因为我们只要完成底层机制的移植,上层的所有复杂策略就可以原封不动的使用.同样,这也是机制简单而牢固的原因