JOS之中断和系统调用

Enochii published at 2021-04-05

lab 3 断断续续写了小久,终于糊完了。虽然没能完全独立地解决所有实现细节,但对 JOS 系统调用的实现有了点理解,在此做下记录。

Interrupt & Exception

后文当提到中断,它可能包括 Interrupt 和 Exception ,也可能单指 Interrupt ,视上下文而定。

JOS 的系统调用是利用类似中断的机制实现的,即:

关于中断部分的介绍也可以跳过

lab 3 让我们阅读 Intel 中断部分的手册,相关术语在各种架构之间并没有很多的统一,下面会基于 Intel 资料简单介绍下中断。

一般说中断,我们会包括 Interrupt 和 Exception ,Interrupt 可分为:

Exception 则一般是在程序执行过程中被触发,比如当执行指令时访问了一个地址发生缺页异常。还有一类称为软中断(software interrupt),即通过指令显示地触发,它包括了上面 Interrupt 的第二种分类。这两种分类标准分别出现于下面两个 6.828 提供的文档,有点自相矛盾但无伤大雅。

参考:

  • [1]
  • [2] 5.3 Sources of Interrupts

综上,一般异常是同步的,一般是程序(不自觉或自觉地)主动触发中断机制;而由硬件触发的中断是异步的,比如一个时间中断突然把一个程序的执行流打断。

中断向量表

上面提到异常和中断的处理是用的同一套机制,这其中有一个很重要的概念叫中断向量表(Interrupt Descriptor Table 简称 IDT),其实是一个数组。不同中断、异常都需要对应的处理程序(handler),IDT 的一项就对应着一个处理程序,由一个小整数标识。

IDT 有 256 项,0-31 被 Intel 保留,比如 0 是 divide by zero ;32-255 开放给用户自定义,通常用于处理外部的硬件中断。

[2] 5.2 EXCEPTION AND INTERRUPT VECTORS

Vectors in the range 32 to 255 are designated as user-defined interrupts and are not reserved by the Intel 64 and IA-32 architecture. These interrupts are generally assigned to external I/O devices to enable those devices to send interrupts to the processor through one of the external hardware interrupt mechanisms (see Section 5.3, “Sources of Interrupts”).

0-31 除去一些保留的以及 NMI ,大部分都是异常,可查看 [2] Table 5-1. Protected-Mode Exceptions and Interrupts 。

JOS 使用了 48(0x30) 作为系统调用的中断向量,使得我们可以像处理异常、中断一样处理系统调用。

系统调用流程

这一节我们从一个系统调用入手,来观察整个过程是如何发生的。

用户与内核的边界

这个系统调用是位于 lib/syscall.csys_cputs ,其定义为:

int
sys_cgetc(void)
{
	return syscall(SYS_cgetc, 0, 0, 0, 0, 0, 0);
}

这里调的 syscall 是同文件下的一个 static function (只在当前文件下可见,所以用户无法直接调用,一种保护、隔离内核的手段):

static inline int32_t
syscall(int num, int check, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	int32_t ret;

	// Generic system call: pass system call number in AX,
	// up to five parameters in DX, CX, BX, DI, SI.
	// Interrupt kernel with T_SYSCALL.
	//
	// The "volatile" tells the assembler not to optimize
	// this instruction away just because we don't use the
	// return value.
	//
	// The last clause tells the assembler that this can
	// potentially change the condition codes and arbitrary
	// memory locations.

	asm volatile("int %1\n"
		     : "=a" (ret)
		     : "i" (T_SYSCALL),
		       "a" (num),
		       "d" (a1),
		       "c" (a2),
		       "b" (a3),
		       "D" (a4),
		       "S" (a5)
		     : "cc", "memory");

	if(check && ret > 0)
		panic("syscall %d returned %d (> 0)", num, ret);

	return ret;
}

这里使用了内联汇编,我们把 num 系统调用号放入了 %eax ,参数 1-5 放入了 %eds 等寄存器(见注释)。然后执行指令 int $T_SYSCALL ,接下来发生的事情就是中断机制那一套了,这里便是用户和内核之间的边界。最终我们会把系统调用的返回值放在变量 ret 中。

中断向量表初始化

这一节不感兴趣可以先行跳过

int $T_SYSCALL 之后,我们需要去 IDT 中寻找第 T_SYSCALL 即 48 个表项作为中断处理程序。在此前,我们可以稍微说下 JOS 的中断向量表是如何初始化的。

kern/trap.c trap_init.c 中,通过 SETGATE 宏为每个中断向量逐个设置处理程序。

SETGATE(idt[T_DIVIDE], 0, GD_KT, trap_handler_divide, 0);
SETGATE(idt[T_DEBUG], 0, GD_KT, trap_handler_debug, 0);
SETGATE(idt[T_NMI], 0, GD_KT, trap_handler_nmi, 0);

这里的 trap_hanlder_divide 就是一个处理程序,你也不会找到它的函数体,它是利用 TRAPHANDLER_NOEC(trap_handler_divide, 0) 宏定义的:

/* Use TRAPHANDLER_NOEC for traps where the CPU doesn't push an error code.
 * It pushes a 0 in place of the error code, so the trap frame has the same
 * format in either case.
 */
#define TRAPHANDLER_NOEC(name, num)					\
	.globl name;							\
	.type name, @function;						\
	.align 2;							\
	name:								\
	pushl $0;							\
	pushl $(num);							\
	jmp _alltraps

这里的 num 是 0 ,它是除零错误的中断向量。 push $0 是压了一个伪 error code ,因为对于一些异常 CPU 不会为我们压入 error code ,为了保持一致我们压了一个 0 。TRAPHANDLER_NOEC 的变体是 TRAPHANDLER (会压入 error code)。所有的中断处理程序都是用这两个宏(TRAPHANDLER_NOECTRAPHANDLER)定义的,它们都会 jump 到 _alltraps 这个统一入口点。这个入口点会建立 trap frame ,保存进程的上下文。

trap frame

定义

在处理包括系统在内的中断之前,我们需要保存当前进程的上下文,即进程的“状态”,主要是各种寄存器,这样在中断处理结束后我们可以让进程继续从当前上下文继续执行。

在 JOS 中,进程状态的定义是 struct Trapframe,可以注意到 trap frame 是由 hardware 和 kernel 共同构建的:

struct Trapframe {
	struct PushRegs tf_regs; // all registers that pushed by `pusha` instruction
	uint16_t tf_es;
	uint16_t tf_padding1;
	uint16_t tf_ds;
	uint16_t tf_padding2;
	uint32_t tf_trapno;
	/* below here defined by x86 hardware */
	uint32_t tf_err;
	uintptr_t tf_eip;
	uint16_t tf_cs;
	uint16_t tf_padding3;
	uint32_t tf_eflags;
	/* below here only when crossing rings, such as from user to kernel */
	uintptr_t tf_esp;
	uint16_t tf_ss;
	uint16_t tf_padding4;
} __attribute__((packed));
构建

在中断向量表初始化时我们提到 _alltraps 这个中断的统一入口点:

.global _alltraps
_alltraps:
	# build trap frame
	pushl %ds
	pushl %es
	pushal

	# set up ds es
	movw $GD_KD, %ax
	movw %ax, %ds
	movw %ax, %es
	# call trap(tf)
	pushl %esp
	call trap

头三条指令建立 trap frame ,后面切换了段寄存器。pushl %esp 这个有点小 trick ,当前 %esp 指向栈顶,栈顶包含了各种寄存器。结合 C 的 calling convention ,其实是把 %esp 作为参数作为 trap(tf) 的参数进行了一个调用。

trap(tf) 调用了 trap_dispatch(tf) ,进行中断的分发:

static void
trap_dispatch(struct Trapframe *tf)
{
	// Handle processor exceptions.
	switch (tf->tf_trapno)
	{
	case T_PGFLT:
		/* code */
		page_fault_handler(tf);
		return;
	case T_BRKPT:
	case T_DEBUG:
		monitor(tf);
		return;
	case T_SYSCALL:
		syscall_handler(tf);
		return;
	
	default:
		break;
	}

	...
}

根据 trap frame 的 tf_trapno ,我们找到对应的处理程序。对于系统调用,就是我们的 syscall_handler

system call handler

注意到在用户与内核的边界这一节的内联汇编中,我们在执行 int $T_SYSCALL 之前还设置了寄存器,包括系统调用号和系统调用的参数,在用户态陷入内核前,我们将这些信息保存在进程的寄存器。于是在 _alltrapspushal ,这些信息借由寄存器就被放在 trap frame 中了。

所以在系统调用的处理程序中,我们通过 tf 获取这些参数,然后执行一个 syscall(),并把返回值放回 trap frame 的 %eax 寄存器中。在进程恢复执行后,返回值就位于 %eax 中,相当于执行了一次函数调用。

void syscall_handler(struct Trapframe *tf)
{
	int num = tf->tf_regs.reg_eax;
	int a1  = tf->tf_regs.reg_edx;
	int a2  = tf->tf_regs.reg_ecx;
	int a3  = tf->tf_regs.reg_ebx;
	int a4  = tf->tf_regs.reg_edi;
	int a5  = tf->tf_regs.reg_esi;
	int ret = syscall(num, a1, a2, a3, a4, a5);

	// when resume user process, it can get return
	// value directly by accessing %eax
	tf->tf_regs.reg_eax = ret;
}

这里的 syscall 要和前面的区分开,它位于 kern/syscall.c 中:

// Dispatches to the correct kernel function, passing the arguments.
int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	// Call the function corresponding to the 'syscallno' parameter.
	// Return any appropriate return value.
	switch (syscallno) {
		case SYS_cputs:
			sys_cputs((char*)a1, a2);
			return 0;
		case SYS_cgetc:
			return sys_cgetc();
		case SYS_getenvid:
			return sys_getenvid();
		case SYS_env_destroy:
			return sys_env_destroy(a1);
	default:
		return -E_INVAL;
	}
}

咦,这里的 sys_cputs() 不就是我们一开始说的那个系统调用吗?不是,这是 kern/syscall.c 下的一个 static function:

static int
sys_cgetc(void)
{
	return cons_getc();
}

总结

最终,我们可以给出这么一个系统调用的流程图:

系统调用流程

Reference

[1] https://pdos.csail.mit.edu/6.828/2018/readings/i386/c09.htm

[2] https://pdos.csail.mit.edu/6.828/2018/readings/ia32/IA32-3A.pdf