操作系统的接口
接口即一些函数,操作系统为上层应用提供一种进入到内核中的手段
接口表现为函数调用又由系统提供,所以称为系统调用
操作系统向上连接的并不是用户而是应用程序
接口是计算机系统中两个独立的部件进行信息交换的共享边界,应用程序与操作系统之间存在接口,操作系统和计算机硬件之间也存在接口。通过接口可以实现应用程序与操作系统之间的通信和操作系统与计算机硬件之间的通信。由此可见与操作系统相连接的并不是用户,而是应用程序。
操作系统既然并不面向用户那么用户是如何使用操作系统的
- 命令行 (命令程序)
- 图形界面 (消息框架程序 + 消息处理程序)
- 应用程序
命令行
以Linux操作系统为例,当我们使用命令行在去执行hello world程序的时候,C语言代码中的printf函数会在Linux操作系统中生成gcc -o output output.c文件,然后通过shell命令去执行output.c文件,此时该文件会调用操作系统里的write函数,将printf里的字符串写到cmd命令行界面上。这里的write函数就是操作系统接口也叫系统调用。
图形界面
我们以图形化界面中的一个按钮举例。当鼠标点击按钮以后,硬件输入首先会进入一个系统消息队列,然后进入应用消息队列,此时应用消息程序会从消息队列中获得硬件输入的指令,应用消息程序中有WinMain消息循环函数,WinMain函数中有一个GetMassage函数,GetMassage函数从操作系统中获取系统调用,硬件输入的系统调用是OnOK()。在OnOK()中有fopen() 函数,printf() 函数,fclose() 函数,主要功能是获取硬件输入指令相应的信息,然后打印该信息最后关闭该信息。打印的信息作为OnOK()函数的返回值返回到getMassage函数,最后消息循环函数在处理这一硬件输入的循环过程结束,硬件输入的执行结果进入到消息队列中,最后返回给用户。
一些常见的操作系统接口
POSIX: Portable Operating System Interface of UNIX (IEEE 制定的一个标准族)
POSIX标准定义了操作系统应该为应用程序提供的接口标准,目的是为了增强程序的可移植性。
| 分类 | POSIX 定义 | 描述 |
|---|---|---|
| fork | 创建一个进程 | |
| 任务管理 | execl | 运行一个可执行程序 |
| pthread_create | 创建一个线程 | |
| open | 打开一个文件或目录 | |
| 文件系统 | EACCES | 返回值:表示没有权限 |
| mode_tst_mode | 文件头结构:文件属性 |
系统调用的实现
实现一个 whoami 系统调用,一个放在操作系统内核中的字符串 ”I am cxy“ (系统引导时被载入的)被取出来并打印。
1 | //假设该段为用户程序 |
要求
- 不能jmp,不应该jmp 唉
- 凭什么不让我jmp 害
- 不jmp该怎么实现 呢
为什么不可以jmp
操作系统在内存中,应用程序也在内存中,想访问操作系统提供的功能为什么不直接跳进去?直接 mov 不行嘛?
no no no !!!
直接mov jmp 就成为了简单的函数调用。操作系统中存储着重要的信息,如root 的用户名和密码,如果可以轻松进入随便访问,那么root的用户名和密码就很可能被应用程序给捞上来,太不安全。所以绝对不可以jmp,mov 进入操作系统内部。
为什么jmp不进去
特权环:将内核程序与用户程序隔离-> 区分为 内核态 和用户态 (一种处理器的硬件设计刚性的)
计算机对内存的使用是一段一段的,内核态可以访问任何数据,用户态不可以 访问内核态数据 -> 由段寄存器实现
两个特殊的段寄存器
DPL :目标内存段的特权级
CPL :当前内存段的特权级
只有当 CPL <= DPL 即 当前内存段的特权级小于等于目标内存段的特权级才能进入到目标内存段
注解
在操作系统初始化时,head.s执行时会针对内核态的代码和数据建立GDT表,对应的内核态的DPL为0 是初始化好的。当用户态执行的时候,启动一个用户程序,CS中的CPL = 3 (当操作系统初始化后推动用户态将CS的CPL置为3,之后就一直维持为3)。每次跳转或者mov 都要访问GDT表,当发现 CPL = 3 > DPL = 0 时就直接挡住,根本不让你jmp mov ,所以也就无法通过 jmp、mov 进入内核。
不jmp如何实现系统调用—中断
中断 -> 硬件提供的“主动进入内核的方法”
中断是用户程序发起的调用内核代码的唯一方式
中断指令 int 0x80 -> 操作系统初始化好的代码,只能从 int 0x80 这扇大门进入到操作系统内部
系统调用的核心
- 用户程序中包含一段含有int指令的代码
- 操作系统写中断处理,获取想调程序的编号
- 操作系统根据编号执行相应的代码
write()系统调用解析
首先应用程序调用printf,在库函数中变成了printf
我们使用printf的写法是printf(“%d”,i); 但事实上printf()内部是调用了系统函数 write();
write的函数头部
1 | // fd:要进行写操作的文件描述;buf:需要输出的缓冲区;count:最大输出字节计数 |
我们可以发现,printf()函数的形参和write()的形参是不一样的。所以要想通过printf()调用write()的话,首先就需要库函数格式化输出,采用c语言的处理方式使其转化成符合write()函数的格式。
在printf函数里面调用write
1 |
|
_syscall3 宏
1 |
|
_syscall3 宏注解
- 可以看出在printf函数里面调用write其实是利用了_syscall3 这个宏, _syscall3 宏调用之后就是展开成上面的一段汇编代码。主要就是将宏展开代码中的type,name,atype,a,btype,b,ctype,c 替换成了int, write, int, fd, const char* buf, off_t, count。 因此type name(atype a, btype b, ctype c) 就变成了int write(int fd,const char * buf, off_t count);
- 展开的汇编代码一样会跟着变化,此时出现了中断代码 int 0x80, (该段代码为_syscall3 的核心代码)操作系统内核的大门即将打开。之前我们了解到在head.s里面会重新建立idt表,之后中断就会根据中断号查那个表,然会获得中断服务函数的入口地址。
- ”=a”(res):””(NR_##name) -> :左边为输出右边为输入。
”“(NR##name) name为之前传入的参数 write 所以该条指令就是将NR_write = 4 赋值给eax这个寄存器,NR_write称为系统调用号,就是根据它来区分不同的系统调用函数同时获得中断函数的入口地址。如在linux/inlcude/unistd.h中 # define _NR_write 4
”=a”( res) 为输出,将来要把eax赋给res - ”b”((long)(a)),”c”((long)(b)),“d”((long)(c))就是把形参的a、b、c依次赋值给ebx、ecx、edx三个寄存器;输入完成之后就通过int 0x80这个中断号进入操作系统。
- int 0x80这条指令执行完之后,eax中就会存放int 0x80的返回值,然后将这个返回值赋值给res,res就是int write()这个系统调用的返回值,至此write这个系统调用也就结束了。
int 0x80
int 0x80 的实现 -> 通过set_system_gate 中断处理门 取出中断处理函数然后跳到那里去执行
1 | void sched_init(void) |
int 0x80对应的中断处理函数就是system_call,init 代表初始化,0x80就是要用后面这个 system_call来处理。
1 | 在linux/include/asm/system.h中 |
set_system_gate这个宏又调用了_set_gate这个宏
1 | 在linux/include/asm/system.h中 |
_set_gate这个宏建立了类似于下图的表格

_set_gate的目的就是要改变特权级穿过特权环的隔离进入到内核
用户态的程序如果要进入内核,必须使用0x80号中断,那么就必须先要进入idt表。用户态的CPL=3,且idt表的DPL故意设置成3,因此能够跳到idt表,跳到idt表中之后就能找到之后程序跳转的地方,也就是中断服务函数的起始地址,CS就是段选择符(8),ip就是”处理函数入口点偏移“。CS=8,IP =&system_call就是跳到内核的system_call这个函数去执行;
int 0x80 完整流程
- 初始化的时候0x80号中断的DPL设成3,让用户态的代码能跳进内核
- 进入内核后,设置CS = 8(CS 的最后两位为 00 所以 CPL 也就自然变成了0),IP = &system_call 来重置 PC指针
- 根据PC指针继续跳到 system_call 去执行
- 最后 int 0x80返回之后,CS最后两位再次变成3,变成用户态
system_call
1 | 在linux/kernel/system_call.s中 |
system_call注解
- system_call 的核心代码为 call _sys_call_table(,%eax,4) ->通过call 跳到另外一个地址去执行
- _sys_call_table(,%eax,4) 为一种寻址方式 -> _sys_call_table + 4 * %eax 等于相应的系统调用处理函数真正的入口地址
- _sys_call_table->函数表 起始地址; 4 ->每个系统调用占4个字节; eax -> 系统调用号__NR_write 4
_sys_call_table
1 | 在include/linux/sys.h中 |
_sys_call_table注解
sys_call_table是一个fn_ptr类型的全局函数表,fn_ptr是一个函数指针,4个字节,这就是_sys_call_table+4*%eax 这里为什么要乘4的原因。查表然后就可以根据eax来知道要调用的真正中断服务函数的入口地址。此处 eax = __NR_write = 4, 找到下标4对应的函数即为 sys_write。所以 call _sys_call_table(,%eax,4) 就对应了 call sys_write 接着就真正开始执行系统函数 sys_write 了。
write系统调用总结
库函数printf -> syscall3 -> 库函数write -> int 0x80 -> system_call -> sys_call_table -> sys_write
库函数printf 通过 _syscall3 这个宏来调用 write 函数,在 write 函数中调用 system_call 来处理 int 0x80, 在system_call 中调用 system_call_table 这个表知道要调用的真正中断服务函数的入口地址,再根据 eax 中存储的系统调用号就可以真正跳到sys_write这个系统函数去执行了。执行完成后输出返回值并跳回用户态至此write的系统调用结束。
whoami 系统调用设计
1 | //用户态代码 |
前面我们了解到,想要设计一个whoami() 系统调用,因为很不安全所以不允许jmp,此时用户态的CPL = 3,内核态代码DPL = 0,也根本跳不进来。我们要想设计一个系统调用就要必须通过操作系统硬件设计好的 int 0x80中断指令进入内核,所以我们自己设置系统调用号eax = 72 再通过int 0x80 进入到操作系统内部。那么此时我们用户态CPL = 3, int 0x80 的DPL 也做成 3 ,就可以穿过接口了,一旦穿过去之后,CPL就会被置为0,然后就开始执行 system_call, 在 _sys_call_table中查表就调用了 sys_whoami(); 最终就真的进入到内核去执行sys_whoami(),这时候再用printk()将内存地址100 处的字符串 “I am cxy” 打印出来,故事就到这里结束了。
参考资料