操作系统:系统调用的实现
内核态与用户态、内核段与用户段
内核态与用户态是保护模式下的概念
内核态:具有较高特权,可以访问所有寄存器和存储区,执行所有指令;OS一般运行在内核态
用户态:较低权限的执行状态,仅能执行规定的指令(如不能随意 jmp ),访问指定的寄存器,应用程序一般只能在用户态运行
计算机中用两个 bit 来表示四种特权状态,硬件将 0 作为内核态,3 作为用户态,Windows 与 Linux 均只使用两个状态
如何保证应用程序不能进入到内核态?
特权保护:
用户态不能直接转向内核态从而执行特权操作,是由硬件保证的
操作系统将内存分段看待,内核态执行在受保护的内核段,用户态对应用户段,
用户态的程序不能由用户段直接跳到内核段,而段是由段寄存器来表示
CPL(Current Privilege Level)当前特权级别,CS 寄存器的最低两位
DPL(Destination/Descriptor Privilege Level)目标特权级别,段描述符的权限位
RPL (Request Privilege Level)请求特权级别,访问的数据段 DS 的最低两位,段选择子的最低两位
简单地说,DPL描述目标内存段的特权级别,CPL表示当前特权级别,当 DPL>=CPL 时,才允许访问
稍微详细一点说,RPL 可以与 CPL 不同,比如说 CPL=0,以 RPL = 3 请求访问 DPL = 3 的段,当然是允许的
即 DPL >= RPL 且 DPL >= CPL 时允许访问,这里涉及到较为复杂的与 GDT 相关的实现,暂且不表
进一步说明:段选择子与段描述符
进一步说明:数据段与代码段权限检查
即每次访问时 通过硬件检查 DPL 与 CPL 是否满足条件,进行特权保护
系统调用
应用程序不能随意访问内核区域,但是它又需要特权级别完成一些任务,
中断 是硬件提供的进入内核的唯一方法, int 指令使 CS 中的 CPL 改成 0,可以"进入内核"
系统调用就是一段包含 int 指令的代码,表现为一系列的内核函数,
由应用程序来调用,在内核中执行,将结果返回给应用程序
即系统调用是操作系统提供给上层程序访问内核的接口
系统调用:
- 应用程序以系统调用的方式访问内核,防止程序随意更改、访问数据与指令
- 屏蔽底层细节,使应用程序有更好的移植性
系统调用的过程:
① 用户程序通过系统调用触发相应的中断
② 操作系统进行中断处理,获取系统调用号(入口地址)
③ 操作系统根据系统调用号执行相应的程序代码
系统调用的实现
下面是 Linux 0.11 中,write
系统调用的实现
可以看到,系统调用通过内联汇编传递参数、调用中断
进一步说明:GCC内联汇编简介
//<unistd.h>
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \
: "=a" (__res) \
: "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}
//write.c
_syscall3(int,write,int,fd,const char *,buf,off_t,count)
即 write
实现为
int write(int fd, const char * buf, off_t count){
long __res;
__asm__ volatile(
"int $0x80"
: "=a"(__res)
: "0" (__NR_write),"b" ((long)(fd)),"c" ((long)(buf)),"d" ((long)(count))
);
if(__res>=0)
return (int) __res;
errno =- __res;
return -1;
}
int 0x80 又是如何利用中断执行相应的程序的呢?
系统在 main 中进行初始化时执行的 sched_init 函数中设置了 0x80 的中断处理
void sched_init(){
set_system_gate(0x80, &system_call);//设置0x80的中断处理
}
//linux/include/asm/system.h
#define set_system_gate(n, addr) _set_gate(&idt[n], 15, 3, addr);
//idt中断向量表基址, n中断处理号, addr 中断服务程序
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ (
"movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \ eax的值放到idt[n]的前四个字节
"o" (*(4+(char *) (gate_addr))), \ edx的值放到idt[n]的后四个字节
"d" ((char *) (addr)), \
"a" (0x00080000)
)
相当于
eax = 0x00080000 edx = addr
eax = 0x00080000 | (addr << 16)
edx = addr & 0x0000 | (0x8000+(dpl<<13)+(type<<8)
eax -> idt[n]前四个字节 edx -> idt[n]后四个字节
这段代码将 system_call 的入口地址与相应的段选择符、DPL 及其他信息
填充为 IDT 的一个表项
即设置成为
斯巴拉西
注意这里 DPL 被置为 3,是用户态可访问的
那么取到表项中的段选择子(Selector)与偏移地址(Offset)合成新的 PC(CS : IP)
CS = 0x0008,CPL = 0,IP = addr = system_call
system_call 就是中断 int 0x80 的处理程序,也就是说
系统调用是通过中断机制实现的
继续分析,system_call 如何执行用户程序调用的系统调用
简单地来看,现实检查了系统调用号小于 nr_system_call - 1
然后压栈保存 ds、es、fs,edx、ecx、ebx 的值
再将 ds、es 设置为内核数据段,cs 已经被设置为内核代码段,fs 指向用户数据段
通过 _sys_call_table + eax * 4 得到系统调用处理函数的入口地址
eax 存放系统调用号,4 为 x86 系统地址字节数,_sys_call_table 为系统调用表基址
pushl eax 压栈保存系统调用返回值,在 ret_from_sys_call 中使用
最后恢复寄存器值,使用 iret 指令从中断返回
再看 _sys_call_table 是如何组织的
在 include/linux/sys.h 中
所有的系统调用处理函数的指针,组织成这样的表
且 fn_ptr 定义为返回值为 int 的函数指针,这里参数列表为空
在 include/linux/sched.h 中
就这样 call _sys_call_table + eax * 4 执行真正的系统调用功能,这里 eax 为 _NR_write = 4,
执行 sys_write 函数
在系统调用返回时 iret 指令会将 CPL 置回3
再简单说一下 system_call 还做了哪些事
在系统调用功能函数结束后,检查当前任务的运行状态,如果不在就绪状态就去执行调度程序
如果在就绪态,但是时间片用完,也会执行调度程序
即 系统调用返回时会检查进程调度
若继续执行,则返回用户程序系统调用
总结一下
因为安全问题,用户程序不能直接访问内核,由硬件检查 DPL >= CPL 保证
而有些任务又必须在内核态下完成,系统调用是用户程序访问内核的接口,由中断机制实现
因此,系统调用实际上是 int 0x80 对应的中断处理程序,再根据系统调用号执行不同的程序,完成相应的功能
2019/12/15