系统调用是用户态和内核态通信的一种方式,用户程序可以直接调用系统调用的接口陷入内核中执行相关任务,完成后返回用户态继续运行。
应用程序使用系统调用很简单,直接调用C库提供的系统调用接口即可。在C库中,对用户传入的参数进行分析和保存,然后通过syscall指令引发系统调用异常,之后便陷入内核。
内核处理根据系统调用号执行相应的处理函数,并将结果返回到用户态。
图1 系统调用大体流程
当发生异常时,协处理器0的Cause寄存器会记录发生了什么种类的异常。Cause寄存器的每个域如图2所示。其中bit6-2(ExcCode)位中保存了具体发生了什么异常,系统可以根据异常种类决定调用哪一个异常处理例程。
图2 Cause寄存器
所有的异常入口都位于mips内存映射中不需要地址转换的区域——非缓存的kseg1段和缓存的kseg0段。如图3所示,RAM中的异常入口点的起始地址为BASE+0x000,BASE表示EBase寄存器编程的异常基地址。一些特殊的异常的处理例程有单独的地址存放其异常处理例程,如缓存异常和TLB重填等,其他异常处理例程都放在BASE+0x180地址处。
图3 异常处理入口
BASE+0x180共存放了32种异常的入口函数地址,图4中显示了部分异常类型对应的ExcCode值,可以看到其中系统调用对应的ExcCode等于8。当发生系统调用时,内核就可以根据Cause寄存器查看异常类型,然后跳转到BASE+0x180地址处执行,执行的结果就是找到对应的处理函数并跳转到处理函数的地址去执行。
图4 异常类型
这些异常处理函数的注册在trap_init()函数中完成,该函数将上面所说的32个异常的处理函数地址放到一个全局数组exception_handlers中,这个全局变量定义为:
unsigned long exception_handlers[32];
这个全局变量是unsigned long型,每个元素的值就是一种异常向量处理函数的入口地址。
那BASE+0x180地址处的代码如何找到异常对应的处理函数呢。trap_init()函数中将except_vec3_generic拷贝到了BASE+0x180,这是一个函数,其实现如下:
NESTED(except_vec3_generic, 0, sp)
.set push
.set noat
mfc0 k1, CP0_CAUSE #读取协处理器0的cause寄存器保存到k1中。
andi k1, k1, 0x7c #取得k1的2-6位,即excCode
#取得exception_handlers[excCode]的值
PTR_L k0, exception_handlers(k1)
jr k0 #跳转到excCode对应的处理函数去执行
.set pop
END(except_vec3_generic)
由except_vec3_generic的实现可知,它负责读取Cause寄存器并跳转到异常处理函数。
产生异常时,MIPS CPU所要做的主要工作为:
异常处理的流程主要包括以下步骤:
以系统调用为例,用户程序执行系统调用后,C库通过执行syscall指令产生一个软件异常,进行上面的一系列工作后会定位到系统调用的处理函数handle_sys。
内核支持的系统调用都放在一张全局的系统调用表sys_call_table中,所有的系统调用按照系统调用号从大到小的顺序存放。这个表中每个条目的大小为8字节,定义如下:
.macro sys function, nargs
PTR \function
LONG (\nargs << 2) - (5 << 2)
.endm
可以看出,系统调用表中每个条目由两部分组成,前4个字节是处理函数function的地址,后4个字节为(\nargs << 2) -(5 << 2),nargs是系统调用的参数个数,这个表达式的结果用来判断参数个数是否超过4个。
下面通过分析handle_sys函数的实现介绍系统调用在内核态所做的工作。
将异常发生时的当前进程的通用寄存器的值保存起来,并确定异常返回地址epc的值,使其可以正常返回。
在C库执行syscall指令之前,会先把系统调用号存放到寄存器v0中,并将需要传递的参数放到a0-a4中,如果参数个数大于四个,就需要保存在栈里面。
handle_sys的开头先通过SAVE_SOME宏将当前进程的通用寄存器的值备份到进程栈中:
NESTED(handle_sys, PT_SIZE, sp)
.set noat
SAVE_SOME # 见下面对该函数的分析
TRACE_IRQS_ON_RELOAD # not implemented
STI #进入内核模式,使能全局中断
.set at
lw t1, PT_EPC(sp) # 取出epc的值。这时应该指向syscall指令
/* v0中存放着系统调用号,由于系统调用号是从4000开始的,所以将v0修改为实际的序号: v0 = v0 – 4000 */
subu v0, v0, __NR_O32_Linux
/* 判断系统调用号的合法性 */
sltiu t0,v0, __NR_O32_Linux_syscalls + 1
addiu t1, 4 #skip to next instruction
sw t1, PT_EPC(sp) # 跳过syscall指令,这样返回时可以继续执行
beqz t0, illegal_syscall # if(t0== 0) illegal syscall.
SAVE_SOME宏的定义如下:
.macro SAVE_SOME
.set push
.set noat
.set reorder
mfc0 k0, CP0_STATUS
sll k0, 3 /* k0 = k0 << 3,即CU0成了最高位 */
.set noreorder
/* 最高位是1就是负数,小于0。CU0=1则得到用户特权级别 */
bltz k0, 8f /*if k0 < 0, goto 8: */
move k1, sp /* 延迟槽,如果是内核态进来的,直接获取sp的值放到k1中 */
.set reorder
/* 如果是从用户态进来的,则需要使用kernel中保存的sp */
get_saved_sp /* 读取全局kernelsp中的sp的值到k1中。 */
8: move k0, sp /* 把原来的sp的值放到k0中保存。 */
/* sp = k1 - sizeof(struct pt_regs),由于kernelsp存放的是sp + _THREAD_SIZE - 32,所以这里得到的sp就是进程地址空间的栈顶。 */
PTR_SUBU sp, k1, PT_SIZE
/* 将k0的值(即刚保存的sp)保存到进程的pt_regs.regs[29] */
LONG_S k0, PT_R29(sp)
LONG_S $3, PT_R3(sp) /* 保存v1的值 */
/*
* You might think that you don't need to save$0,
* but the FPU emulator and gdb remote debugstub
* need it to operate correctly
*/
LONG_S $0, PT_R0(sp) /* 保存$0的值 */
mfc0 v1, CP0_STATUS
LONG_S $2, PT_R2(sp) /* 保存v0的值 */
LONG_S v1, PT_STATUS(sp) /* 保存cp0_status的值 */
LONG_S $4, PT_R4(sp) /* 保存a0的值 */
mfc0 v1, CP0_CAUSE
LONG_S $5, PT_R5(sp) /* 保存a1的值 */
LONG_S v1, PT_CAUSE(sp) /* 保存cp0_cause的值 */
LONG_S $6, PT_R6(sp) /* 保存a2的值 */
MFC0 v1, CP0_EPC
LONG_S $7, PT_R7(sp) /* 保存a3的值 */
LONG_S v1, PT_EPC(sp) /* 保存cp0_epc的值 */
LONG_S $25, PT_R25(sp) /* 保存t9的值 */
LONG_S $28, PT_R28(sp) /* 保存gp的值 */
LONG_S $31, PT_R31(sp) /* 保存ra的值 */
ori $28, sp, _THREAD_MASK /* gp = sp | 0x1FFF */
/* gp = gp ^ 0x1FFF,即sp的末13位清0赋值给gp,内核栈的大小就是8K,所以,这里的结果就是gp指向栈顶。 */
xori $28, _THREAD_MASK
.set pop
.endm
这里需要说明一下内核线程的内核栈空间,内核栈是从高地址向下延伸的,大小为两个页,即8K。为了方便的定位到进程的task_struct结构,进程的thread_info结构被放在栈底(低地址),这样,在进程地址空间内的任何地址,只需将末13位清零就是thread_info的位置,再通过thread_info结构体的task指针可以很快找到进程的task_struct结构。
在创建进程时,在栈顶(高地址)会预留32字节的空间,这32字节目前没有被使用,可能是为了防止溢出而导致覆盖了进程的重要信息。在32字节下面是一个struct pt_regs结构体,它的目的是为了在发生系统调用或其他异常时,保存进程的重要寄存器的值,如通用寄存器和CP0的寄存器。在距离栈顶32Bytes +sizeof(struct pt_regs)的位置才是sp的初始位置。
根据系统调用号在sys_call_table中找到该系统调用需要几个参数。
# v0左移3位。因为sys_call_table中每个条目占用8字节。
sll t0, v0, 3
la t1, sys_call_table # t1中存放sys_call_table的地址
addu t1, t0 # t1 = t1 + t0。得到要找的系统调用的地址。
lw t2, (t1) # 把处理函数地址放到t2中
lw t3, 4(t1) # t3中存放是否参数个数大于4
beqz t2, illegal_syscall # 如果找不到处理函数,非法
sw a3, PT_R26(sp) # save a3for syscall restarting
bgez t3, stackargs # 如果t3>=0,则参数大于4个,需要栈
在上面的代码中,t2中保存了系统调用处理函数的地址。而t3的值就有两层意思:
1. 如果t3小于0,说明系统调用的参数少于或等于4个。
2. 如果t3大于等于0,那t3的取值可能是0,4,8,16,分别对应5,6,7,8个参数的情况。这里t3赋值成4的倍数是为了两个相邻值之间相差一个指令的长度,在下面获取参数时利用了这一点。
如果参数个数小于等于4个,就不需要使用栈保存参数,那处理很简单:如果需要跟踪系统调用,在执行系统调用之前,需要通知父进程。一般情况下,我们不需要跟踪系统调用,所以直接跳转到系统调用的处理函数。
stack_done:
lw t0, TI_FLAGS($28) # 得到进程的thread_info.flags
li t1, _TIF_SYSCALL_TRACE | _TIF_SYSCALL_AUDIT
and t0, t1 # thread_info.flags是否设置了上面两个标志
bnez t0, syscall_trace_entry # 如果设置了,跳到处理函数
jalr t2 # 进入系统调用处理函数
如果参数个数大于4个,我们需要在栈用获取多余的参数,然后再跳转到上面的stack_done调用处理函数。
stackargs:
lw t0, PT_R29(sp) # get olduser stack pointer
/*
* We intentionally keep the kernel stack alittle below the * top of userspace so we don't have to do a slower byteaccurate check here.
*/
lw t5, TI_ADDR_LIMIT($28) # 获得thread_info.addr_limit
addu t4, t0, 32 # sp + 32就是栈的高地址
and t5, t4
/*
* addr_limit有两种:
* 0-0x7FFFFFFF for user-thead
* 0-0xFFFFFFFF for kernel-thread
*/
bltz t5, bad_stack # t5 < 0即位于了内核态,不合法
/* Ok, copy the args fromthe luser stack to the kernel stack.
* t3 is the precomputed number of instructionbytes needed to
* load or store arguments 6-8.
*/
la t1, 5f # load up to 3arguments
# 通过上面赋值,t3可能取0, 4, 8, 16对应5,6, 7, 8个参数。
subu t1, t3
1: lw t5, 16(t0) # argument #5from usp 取出#5
.set push
.set noreorder
.set nomacro
jr t1 # 根据参数个数跳转
addiu t1,6f - 5f # 延迟槽,跳转同时把t1加上6f -5f.
2: lw t8, 28(t0) # argument #8from usp
3: lw t7, 24(t0) # argument #7from usp
4: lw t6, 20(t0) # argument #6from usp
5: jr t1
sw t5,16(sp) # argument #5 to ksp 延迟槽,跳转同时存入#5
sw t8, 28(sp) # argument #8 toksp
sw t7, 24(sp) # argument #7 toksp
sw t6, 20(sp) # argument #6 toksp
6: j stack_done # 跳回和小于等于4个参数相同的处理流程
nop
.set pop
系统调用的处理程序执行完成后,就要准备返回用户空间了。
#
# 准备系统调用的返回值。
#
li t0, -EMAXERRNO - 1 # error?
sltu t0, t0, v0 # if t0< v0, t0 =1, else t0 = 0.
sw t0, PT_R7(sp) #把t0的值存到a3里去。
beqz t0, 1f # if t0 == 0,goto 1:
negu v0 # error, v0 = -v0
sw v0, PT_R0(sp) # set flagfor syscall
# restarting
1: sw v0, PT_R2(sp) # result, v0存到pt_regs[2]中
o32_syscall_exit:
local_irq_disable # make sure need_resched and
# signalsdont change between
# samplingand return
# 下面的内容还是和trace syscall相关的,在系统调用完成后,通知父进程。
lw a2, TI_FLAGS($28) #current->work
li t0, _TIF_ALLWORK_MASK
and t0, a2
bnez t0, o32_syscall_exit_work
j restore_partial /* 恢复寄存器,并返回 */
可以看到,系统调用将a3和v0返回给用户态,经过上面的代码处理,这两个寄存器中的值的含义如下:
返回到C库后,会根据a3判断是成功还是失败,如果成功就给用户程序返回v0。如果失败,就将v0写到errno中,然后根据该系统调用的规定,给用户程序返回失败时的返回值。
代码的最后跳转到restore_partial中去,它的定义很简单:
FEXPORT(restore_partial) #restore partial frame
RESTORE_SOME
RESTORE_SP_AND_RET
其中RESTORE_SOME对应最开头的SAVE_SOME。而RESTORE_SP_AND_RET做了两件事情:
1. 将进程栈中保存的sp的值恢复,赋值给sp寄存器。
2. 将进程栈中保存的epc的值恢复,并跳转到epc指向的地址。而开头讲到过,这时epc指向syscall指令的下一条指令,即继续执行C库中调用syscall指令之后的代码。
.macro RESTORE_SP_AND_RET
.set push
.set noreorder
LONG_L k0, PT_EPC(sp)
LONG_L sp, PT_R29(sp)
jr k0
rfe #在异常返回前恢复CPU状态
.set pop
.endm