这篇文章结合一个具体的系统调用来讲解如何实现用户态切换到内核态,再从内核态切换到用户态的详细流程。

获取进程 ID 的系统调用

sys_getenvid()是一个系统调用,它的作用是获取当前进程的进程 ID。在操作系统中每个进程都有一个唯一的进程 ID,这个 ID 是由内核分配的。通过sys_getenvid()系统调用,进程可以知道自己的进程 ID。

在代码中,sys_getenvid()被用于获取当前进程的进程 ID,然后将其作为参数传递给cprintf函数,用于打印消息。这样,你可以知道是哪个进程发送了消息。下面是用户态程序调用 sys_getenvid() 的具体例子。

cprintf("i am %08x; thisenv is %p\n", sys_getenvid(), thisenv);

获取进程 ID 需要通过系统调用来实现,主要是因为进程 ID 是由操作系统内核管理和分配的。在操作系统中,每个进程都有一个唯一的 ID,这个 ID 是由内核在创建进程时分配的。进程自身无法直接获取或修改这个 ID,因为这会破坏操作系统的安全性和稳定性。

系统调用是用户空间进程与内核空间进行交互的一种机制。通过系统调用,用户空间的进程可以请求内核提供服务,比如创建进程、打开文件、获取进程 ID 等。当进程执行系统调用时,会发生上下文切换,从用户模式切换到内核模式。在内核模式下,操作系统可以访问受保护的内核数据结构,并执行可能影响整个系统的操作。

获取进程 id 的实现细节

通过sys_getenvid() 系统调用可以实现获取进程 ID,这样可以保证操作系统的安全性和稳定性,防止用户进程直接访问和修改内核数据结构。

这个系统调用在lib/syscall.c文件中实现,具体的实现代码如下:

static inline envid_t
sys_getenvid(void)
{
	return syscall(SYS_getenvid, 0, 0, 0, 0, 0, 0);
}

其中,syscall函数是一个通用的系统调用函数,它将系统调用号和参数传递给内核。SYS_getenvidsys_getenvid系统调用的系统调用号。

syscall 函数是一个系统调用的通用实现。它接受一个系统调用号和最多五个参数,然后通过中断指令int来触发一个系统调用。

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;
	asm volatile("int %1\n"							//执行int T_SYSCALL指令
		     : "=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:系统调用号,用于指定要执行的系统调用。
  • check:一个标志,如果为真并且系统调用返回值大于 0,则会触发一个内核恐慌。
  • a1a5:这是系统调用的参数,最多可以有五个。

函数的主体是一个内联汇编语句,它执行int指令来触发一个系统调用。在这个汇编语句中,系统调用号被放在AX寄存器中,参数被放在DXCXBXDISI寄存器中。然后,int指令会触发一个中断,中断号是T_SYSCALL,这是系统调用的中断号。

这个汇编语句的输出是一个名为ret的变量,它包含了系统调用的返回值。如果check参数为真并且ret大于 0,那么函数会触发一个内核 panic 。否则,函数会返回ret

这个函数的主要作用是提供一个通用的方式来执行系统调用。在代码中,所有的系统调用都是通过这个函数来执行的。例如,sys_cputssys_cgetcsys_getenvid等函数都是通过调用syscall函数来实现的。

从 syscall 跳转到 trap 的细节

lib/syscall.c中的syscall函数和kern/trap.c中的trap函数之间的跳转主要是通过硬件中断和操作系统的中断处理机制实现的。

当用户态程序需要请求内核提供服务时,它会执行一个特殊的指令(在 x86 架构中,这个指令是int 0x30),这个指令会触发一个系统调用中断。这个中断的中断号是T_SYSCALL,在kern/trap.h中定义。

当这个中断发生时,CPU 会自动保存当前的执行环境(包括各个寄存器的值、程序计数器等),然后跳转到 IDT 中对应的中断处理函数去执行。在这个过程中,CPU 会从用户态切换到内核态。

kern/trap.c中的trap_init函数中,我们可以看到系统调用中断的处理函数被设置为th_syscall。这个函数在kern/trapentry.S中定义,它的主要作用是保存中断前的环境,然后调用trap函数。

trap函数首先会检查中断的类型,如果是系统调用中断,它会调用trap_dispatch函数。在trap_dispatch函数中,会根据tf->tf_trapno的值来判断中断的类型,如果是T_SYSCALL,则会调用syscall函数。

syscall函数在lib/syscall.c中定义,它会根据系统调用号(存储在eax寄存器中)来调用相应的系统调用处理函数。

总的来说,从syscall函数跳转到trap函数的过程主要是通过硬件中断和操作系统的中断处理机制实现的。

如何在 IDT 中设置对应的中断处理函数?

trap_init函数是操作系统内核中的一个重要函数,它的主要作用是初始化中断描述符表(Interrupt Descriptor Table,简称 IDT)。IDT 是用于处理中断和异常的关键数据结构,每当 CPU 接收到中断或异常时,就会根据 IDT 中的条目来调用相应的处理函数。

trap_init 函数设置了系统调用的处理函数 th_syscall 。然后,使用SETGATE宏为每种中断或异常设置相应的处理函数。例如,SETGATE(idt[T_SYSCALL], 0, GD_KT, th_syscall, 3);这行代码就是设置系统调用 T_SYSCALL 的处理函数为th_syscall

th_syscall是一个中断处理函数的名称,它被定义在 trapentry.STRAPHANDLER_NOEC宏中。这个宏用于生成没有错误代码的中断处理函数。

TRAPHANDLER_NOEC宏接受两个参数:namenumname是生成的中断处理函数的名称,num是对应的中断号。

在这个宏中,首先使用.globl指令声明了一个全局符号name,然后使用.type指令将这个符号的类型设置为函数。接着,使用.align指令将函数定义对齐到 2 字节边界。

然后,定义了函数name,这个函数的作用是将中断号num压入栈中,然后跳转到_alltraps函数去执行。

在这个例子中,th_syscall是系统调用的中断处理函数,它的中断号是T_SYSCALL。当发生系统调用中断时,CPU 会跳转到这个函数去处理。

总的来说,th_syscall的定义就是使用TRAPHANDLER_NOEC宏生成一个名为th_syscall,中断号为T_SYSCALL的中断处理函数。

寄存器切换

所有的中断最终都会调用_alltraps函数。

_alltraps:
	pushl %ds
	pushl %es
	pushal
	pushl $GD_KD
	popl %ds
	pushl $GD_KD
	popl %es
	pushl %esp
	call trap

当发生中断时,CPU 可能正在用户模式下运行,此时%ds%es寄存器中的选择子指向的是用户数据段。但是,中断处理代码通常需要运行在内核模式下,因此需要切换到内核数据段。

$GD_KD是内核数据段的选择子,将其压入栈中,然后弹出到%ds%es寄存器,就实现了将数据段切换到内核数据段的目的。

这样做的好处是,中断处理代码可以访问内核数据段中的数据,而不必担心访问到用户数据段中的数据。这对于保护内核数据的安全性和隔离用户空间和内核空间是非常重要的。

然后,将栈指针%esp压入栈中,这样就保存了所有的寄存器和中断前的栈指针的状态。

最后,调用trap函数进行中断处理。这个函数会根据保存在栈中的中断号来调用相应的中断处理函数。

总的来说,_alltraps函数的目的就是在中断发生时保存 CPU 的状态,然后调用trap函数进行中断处理。

中断处理

当 CPU 执行到 trap 函数后,会进入 trap_dispatch 函数进一步的分发,根据 tf->tf_trapno 字段来判断 T_SYSCALL 类型。这个函数的参数 tf 是一个指向 Trapframe 结构的指针,这个结构包含了发生中断或异常时 CPU 的状态。

void
trap(struct Trapframe *tf)
{
    // ...

	trap_dispatch(tf);

    // ...
}
static void
trap_dispatch(struct Trapframe *tf)
{
	switch (tf->tf_trapno) {
		case T_PGFLT:
			page_fault_handler(tf);
			return;
		case T_BRKPT:
			monitor(tf);
			return;
		case T_SYSCALL:
			tf->tf_regs.reg_eax = syscall(tf->tf_regs.reg_eax, tf->tf_regs.reg_edx, tf->tf_regs.reg_ecx,
				tf->tf_regs.reg_ebx, tf->tf_regs.reg_edi, tf->tf_regs.reg_esi);
			return;
		default:
			break;
	}
    // ...
}

根据参数做进一步的区分后会调用 sys_getenvid()

int32_t
syscall(uint32_t syscallno, uint32_t a1, uint32_t a2, uint32_t a3, uint32_t a4, uint32_t a5)
{
	int32_t ret;
	switch (syscallno) {
        // ...
		case SYS_getenvid:
			ret = sys_getenvid();
			break;
		// ...
        default:
			return -E_INVAL;
	}

	return ret;
}

static envid_t
sys_getenvid(void)
{
	return curenv->env_id;
}

中断处理完后

中断处理完后继续执行,若调用 env_run(curenv); 切换到用户进程继续执行,否则选取一个新的进程重新执行。

void
trap(struct Trapframe *tf)
{
    // ...

	trap_dispatch(tf);

    // ...

	if (curenv && curenv->env_status == ENV_RUNNING)
		env_run(curenv);
	else
		sched_yield();
}

进程切换后,如何从内核态切换到用户态呢?

通过env_run函数实现的。env_run函数首先会调用lcr3函数切换到新进程的页表,然后调用env_pop_tf函数恢复新进程的寄存器状态,并使用iret指令从内核态切换到用户态,开始执行新进程的代码。

具体的代码如下:

void
env_run(struct Env *e)
{
	// ...
	// 5. Use lcr3() to switch to its address space
	lcr3(PADDR(curenv->env_pgdir));

	unlock_kernel();
	// Step 2: Use env_pop_tf() to restore the environment's registers and drop into user mode in the environment
	env_pop_tf(&curenv->env_tf);
}

env_pop_tf函数中,使用iret指令从内核态切换到用户态:

void
env_pop_tf(struct Trapframe *tf)
{
	// ...
	asm volatile(
		"\tmovl %0,%%esp\n"
		"\tpopal\n"
		"\tpopl %%es\n"
		"\tpopl %%ds\n"
		"\taddl $0x8,%%esp\n" /* skip tf_trapno and tf_errcode */
		"\tiret\n"
		: : "g" (tf) : "memory");
	panic("iret failed");  /* mostly to placate the compiler */
}

这里的iret指令会从栈中弹出eipcseflags寄存器的值,然后跳转到eip指定的地址开始执行代码,这个地址就是新进程的入口点。同时,iret指令还会将 CPU 从内核态切换到用户态。

这里弹出的寄存器主要有以下几个:

  1. %%esp:堆栈指针寄存器,用于指向当前的栈顶。在这里,它被设置为tf,也就是指向了保存的寄存器状态。

  2. popal:这是一个汇编指令,用于从堆栈中弹出所有的通用寄存器的值。这些寄存器包括eaxecxedxebxespebpesiedi

  3. %%es%%ds:这两个都是段寄存器,用于在内存分段模式下存储段选择子。在这里,它们被弹出并恢复到了保存的状态。

  4. addl $0x8,%%esp:这条指令用于跳过tf_trapnotf_errcode这两个字段。因为在保存寄存器状态时,这两个字段是最后压入堆栈的,所以在恢复状态时需要先跳过它们。

  5. iret:这是一个汇编指令,用于从堆栈中弹出eipcseflags寄存器的值,并从内核态切换到用户态。eip寄存器存储的是下一条要执行的指令的地址,cs寄存器是代码段寄存器,eflags寄存器存储的是一些状态标志。

这些寄存器的恢复是为了让环境能够在被中断的地方继续执行。