OS内核/用户隔离机制并非与生俱来:一个例子说明MIT JOS实现下,内核态中断处理函数直接调用用户函数失败的过程

中断和系统调用是硬件以及应用程序访问内核资源的方式,不同系统组件利用这两种方式互相访问,可以做到相当程度的系统隔离性,将用户与硬件分隔,可以增加系统的稳定性和复用性。

其中,用户态与内核态在陷入和恢复的过程中通过 trapframe 记录之前的的运行信息,如用户程序申请了一个 write 系统调用,当内核处理写操作时,通过 trapframe 获得各种用户信息以及参数,其中还包含返回中断位置的下一条 EIP。

那么,在内核态没有返回用户态的时候直接执行一个用户态的函数,会发生什么?此时并不是通过系统定义的方式执行用户函数,没有进行内核态/用户态切换,这种行为是未预先定义的。

实例

在实现操作系统的中断机制时,有这样的需求:用户完成了一个中断处理函数,并提前通过系统调用将其注册进入内核的对应中断号处理流程,来完成用户自定义的用户态中断响应。在内核的处理部分,有如下代码:

	// 如果时钟中断发生了用户规定的次数,就触发用户定义的中断处理函数
    if(myproc()->alarmticks != 0 && ticks % myproc()->alarmticks == 0) {
        myproc()->alarmhandler();
    }

运行后,系统没有输出中断处理函数的预期结果。回顾相关内容,特别是用户态与内核态隔离部分的内容,我发现这里直接调用一个用户态函数并不符合操作系统的设计支持,我应该等待处理器返回用户态执行后,再执行这个用户定义的处理函数,而不是在内核态直接调用这个处理函数。进行修改:

    if(myproc()->alarmticks != 0 && ticks % myproc()->alarmticks == 0) {
    	// 将中断在用户程序发生位置下一条指令的EIP入栈
        tf->esp -= 4;
        *((uint *)(tf->esp)) = tf->eip;
        // 当内核态执行 IRET 返回用户态时,从tf中得到的返回地址为中断处理函数
        tf->eip =(uint)myproc()->alarmhandler;
        // 当这个中断处理函数完成后,RET 指令会将栈顶元素赋给指令寄存器,为之前入栈的EIP
        // 原先执行流: 时钟中断 -> 用户态处理函数 (内核用户转换)-> 返回用户程序发生中断的位置
        // 现在执行流: 时钟中断 -> (内核用户转换) 用户态处理函数 -> 返回用户程序发生中断的位置
    }

系统给出了正确输出:

// 正确
alarmtest
alarmtest starting
...alarm!
....alarm!
....alarm!
....alarm!
....alarm!
....alarm!
....alarm!
.....alarm!
....alarm!
....alarm!
....alarm!
....alarm!
..$

// 错误 没有用户定义的中断处理函数的输出
alarmtest
alarmtest starting
..................................$

虽然给出了正确的输出,但是一开始的错误情况,即直接调用中断处理函数,往往是比较直觉的代码设计思路。只有非常明确OS内核设计的理念,才能及时反应过来将用户函数的调用放在OS定义的用户态模式下。

接下来,探究一下到底发生了什么导致了中断处理函数的错误输出,而不是只用“不能在内核态调用用户态函数”这样的方式说明情况。将代码还原为之前错误的情况,即直接在内核态中调用用户程序alarmhandler

EFLAG寄存器中的IF=0导致write系统调用被处理器屏蔽?

在 EFLAGS 寄存器中,IF 标志位是中断使能(Interrupt Flag)标志,位于第 9 位。该标志用于控制处理器是否响应可屏蔽的硬件中断。

当 IF 标志被设置(即 IF=1)时,CPU 能够响应外部的、可屏蔽的中断。
当 IF 标志被清除(即 IF=0)时,这些中断会被忽略,CPU 不会对它们做出响应。

使用GDB调试的过程中,我发现EFLAG寄存器的IF在时钟中断中为0,由于调用 alarmhandler 时系统一定处于内核态的时钟中断中,那么是否是用户定义的中断处理函数在调用 write 系统调用时被屏蔽,所以没有输出?

在这里插入图片描述
在IDT中,系统调用也被注册为一个中断表项,由 INT 指令触发,可能算作可屏蔽中断。继续进行调试,奇怪的是,系统连续进入了两次 trap 函数,一次是时钟中断,一次是中断处理过程中 INT 触发的软件中断系统调用,看起来系统调用并没有屏蔽。(通过查阅汇编中间文件,中间问号表示的函数,是putcharprintf等函数)

查询资料如下:

The Interrupt flag (IF) is a flag bit in the CPU’s FLAGS register, which determines whether or not the (CPU) will respond immediately to maskable hardware interrupts.[1] If the flag is set to 1 maskable interrupts are enabled. If reset (set to 0) such interrupts will be disabled until interrupts are enabled. The Interrupt flag does not affect the handling of non-maskable interrupts (NMIs) or software interrupts generated by the INT instruction.

也就是说,INT并不会受 IF flag值的影响,看来问题不是出在系统调用被处理器屏蔽而不能打印出正确结果。

系统调用参数地址合法性保护

跟随GDB,运行到如下代码:

int
sys_write(void)
{
  struct file *f;
  int n;
  char *p;
  
  // 合法性检查
  if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argptr(1, &p, n) < 0)
  	// 由于argfd返回-1,返回-1
    return -1;
  return filewrite(f, p, n);
}

...

static int
argfd(int n, int *pfd, struct file **pf)
{
  int fd;
  struct file *f;

  if(argint(n, &fd) < 0)
    return -1;
  
  if(fd < 0 || fd >= NOFILE || (f=myproc()->ofile[fd]) == 0)
  	// 合法性检查,该位置引起返回-1
  	// #define NOFILE       16  // open files per process
  	// GDB中,fd=0x57e58959,无效值,看起来像访问越界
    return -1;
  if(pfd)
    *pfd = fd;
  if(pf)
    *pf = f;
  return 0;
}

代码在if分支跳转进入了return -1,由此退出了系统调用过程。这三个arg前缀,充当条件的函数调用用来获得系统调用的参数。在用户开启一个系统调用时,会根据头文件当中的声明,向栈中压入系统调用的参数。回顾中断处理的机制,可知这些参数的相对位置并不会像通常的调用一样,即被调用者栈帧紧跟调用者栈帧,而是经过了多个中断处理的内部逻辑后,才来到了真正的系统调用实现中。通过访问trapframe来拿到这些参数,以argint为例:

// Fetch the nth 32-bit system call argument.
int
argint(int n, int *ip)
{
  // 通过tf保存的esp,访问用户栈在准备系统调用时所存放的参数
  // 通过栈帧结构和c函数调用约定,可知在call指令后,esp指向eip即下一条指令位置
  // 紧邻着eip向高地址方向,从低往高依次是第1,2,3...个参数(n=0,1,2...)
  return fetchint((myproc()->tf->esp) + 4 + 4*n, ip);
}

// Fetch the int at addr from the current process.
int
fetchint(uint addr, int *ip)
{
  struct proc *curproc = myproc();
  // 判断地址合法性,由于tf来自于用户,不可信
  // 保证其访问的地址位于用户程序内部,若不合法,返回-1
  if(addr >= curproc->sz || addr+4 > curproc->sz)
    return -1;
  *ip = *(int*)(addr);
  return 0;
}

通过GDB,看看发生了什么导致了获得的参数值无效。分析的重点是观察内核态调用用户程序前与后trapframe以及内核栈上的信息。

程序运行在时钟中断处理函数时,相关信息

在这里插入图片描述
到这里,系统还没有发生未定义操作

程序在中断处理函数中嵌套系统调用,相关信息

在这里插入图片描述
esp保存的地址为0x328,查看汇编代码:
在这里插入图片描述
也就是说,第二次系统调用拿到的esp的值居然指向代码段!仔细观察,trapframe的成员esp指向的是系统调用write的下一条指令,也就是call之后入栈保存的eip=0x328。而为什么ss值为0x01呢?由结构体存储知识可知,这个成员的存储地址位于esp成员地址+4。仔细看,地址0x320的指令将%eax的值放在了eip存储栈位置+4也就是esp成员地址+4,由c调用约定可知此为函数调用的第一个参数fd,这恰好是putc函数要写的文件的文件描述符stdout,其描述符值默认为1

这两个值明明是调用者栈帧的内容,怎么会被被调用者获得的trapframe直接包访问且含义明显错误?查阅相关资料,内核态->内核态trapframe如下图:

If the processor is already in kernel mode and takes a nested exception, since it does not need to switch stacks, it does not save the old SS or ESP registers. For exception types that do not push an error code, the kernel stack therefore looks like the following on entry to the exception handler:

                 +--------------------+ <---- old ESP
                 |     old EFLAGS     |     " - 4
                 | 0x00000 | old CS   |     " - 8
                 |      old EIP       |     " - 12
                 +--------------------+             

原来如此,由于在内核态使用INT指令,并不会切换栈,而是会在旧内核栈指针的基础上继续向下扩充,类似普通的内核函数调用,只是附加了很多额外信息。通常情况下,系统调用用户态->内核态trapframe由于涉及到了栈切换,于是要保存原有的栈信息,这种情况如下图:

                  +--------------------+ KSTACKTOP             
                 | 0x00000 | old SS   |     " - 4
                 |      old ESP       |     " - 8
                 |     old EFLAGS     |     " - 12
                 | 0x00000 | old CS   |     " - 16
                 |      old EIP       |     " - 20 <---- ESP 
                 +--------------------+             

现在可以解释为什么上面的trapframe会访问到eip,1st argument。由于系统调用在设计是就完全以用户态->内核态的思路设计,其trapframe一定含有ssesp两个成员;但是在内核态->内核态的情况下,这两个成员并没有入栈,导致对trapframe的这两个成员访问会越界,向栈上更高的位置即调用者栈帧,访问其中内容。

现在也很好解释为什么第二次系统调用会没有效果了:

由于内核中执行的系统调用构造的trapframeesp没有入栈,而系统调用的参数获取依赖于esp访问用户压入的参数,导致访问了一个无效的栈指针值,获得了无效的参数,在参数获取函数内部的合法性检测上,就将其视为错误,返回-1,不继续进行系统调用过程;如果没有合法性检测,那么会直接操作无效的文件描述符与地址,造成kernel crash

这种很直觉的内核设计陷阱,浮现了三个疑问:

  • 为什么内核代码可以直接跳转到用户指令?
    – 明明设计了返回用户态所需的必要中间步骤来保证隔离性
  • 为什么用户指令可以修改内核堆栈?
    – 整个用户定义的中断处理函数CPL=0,使用内核栈,非常危险
  • 为什么系统调用(INT)可以从内核中执行?
    – 使得trapframe的结构与结构体描述不一致,甚至可能导致内核崩溃

这表示了,即便这些都不是操作系统的预期特性,但x86硬件不直接提供隔离性,x86 有许多单独的特性(页表、INT 等),但隔离不是处理器默认的,我们需要手动组装这些特点,达到我们期望的隔离性的目的。

为了更好的开发系统,我们要遵守一开始设计系统时所约定的原则,避免产生严重的错误,因为不存在这样的机制来保护一个我们正在开发的新feature如user-kernel隔离。

  • 41
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值