专栏名称: 嵌入式微处理器
关注这个时代最火的嵌入式微处理器,你想知道的都在这里。
目录
相关文章推荐
湖北经视  ·  痛心消息传来!母女双双遇难 ·  昨天  
湖北经视  ·  关晓彤、鹿晗,冲上热搜! ·  2 天前  
湖北经视  ·  70 岁以上老人猥亵不行拘,高龄成 ... ·  2 天前  
湖北经视  ·  噩耗传来!3人牺牲,有1人原准备5月结婚 ·  3 天前  
湖北经视  ·  确认!她官宣复出 ·  4 天前  
51好读  ›  专栏  ›  嵌入式微处理器

什么是系统调用机制?结合Linux0.12源码图解

嵌入式微处理器  · 公众号  ·  · 2024-08-01 12:00

正文

本文目录:

  • 内核态与用户态

  • 什么是系统调用

  • 系统调用是怎么实现的

    • 库函数write

    • 库函数扩展汇编宏

    • int 0x80中断 调用对应的中断处理函数

    • 检索系统调用函数表

    • 最终执行sys_write

  • 内核态与用户态数据交互


哈喽,大家好,我是呼噜噜,今天我们来聊一聊内核中一个非常重要的机制- 系统调用 ,本文结合linux0.12源码来揭开早期操作系统中系统调用机制的神秘面纱

系列文章目录

  1. 聊聊x86计算机启动发生的事?
  2. Linux0.12内核源码解读(2)-Bootsect.S
  3. Linux0.12内核源码解读(3)-Setup.S
  4. 图解CPU的实模式与保护模式
  5. Linux0.12内核源码解读(5)-head.s
  6. Linux0.12内核源码解读(6)-main.c
  7. Linux0.12内核源码解读(7)-陷阱门初始化
  8. 图解计算机中断
  9. Linux0.12内核源码解读(9)-blk_dev_init和chr_dev_init

内核态与用户态

早期工程师们在操作系统上编写程序的时候,自己写个程序可以访问别人的程序地址,甚至是操作系统占用的地址,这样就很容易一不小心就直接把操作系统给干挂了,所以那个时候的程序员编写程序都得小心翼翼的

计算机核心的资源,一般有:内存,I/O端口,特殊机器指令等,这些资源必须得保护起来,规定哪些程序可以去访问,哪些程序不能去访问

所以引入了 特权级别 的概念,由硬件设备商直接来提供硬件级别的支持,最常见的就是给 CPU指令集的权限分级 来控制CPU的访问权限

比如 Intel CPU指令集 操作的权限由高到低划为4级: Ring0、Ring1、Ring2、Ring3 ,其中Ring0权限最高,可以使用所有CPU指令集,Ring3权限最低,仅能使用部分CPU指令,比如不能使用操作硬件资源的CPU指令:I/O操作、内存分配等操作;另外CPU处于Ring3状态不能访问Ring0的地址空间,包括 代码和数据

CPU指令集,就是CPU中用来计算和控制计算机系统的一套指令的集合,实现软件指挥硬件执行的媒介,常见的CPU指令集有X86、ARM、MIPS、Alpha、RISC等

那么CPU是如何记录这些特权级信息的?

我们这里以 80386CPU 为例, 前文 提到过CPU里面有许多段寄存器(CS、DS、SS、ES、FS、GS等)。这些段寄存器里面存放 段选择符 (也叫段选择子)

段选择符中包含请求特权级RPL(CPL)字段,通过段选择符可以去查找全局描述符表GDT、局部描述符表LDT中对应的项,需要先进行 特权级检查 ;这些项中都包含 DPL 字段(规定访问该段的权限级别),只有 DPL >= max {CPL, RPL} ,才允许访问

CPL很特殊,跟踪当前CPU正在执行的代码所在段的描述符中DPL的值,总是等于CPU的当前特权级

内核态与用户态 都是操作系统的层面的概念,和CPU硬件没有必然的联系;由于硬件已经提供了一套特权级使用的相关机制,Linux操作系统没有必要重新"造轮子",直接使用了硬件的 Ring0和Ring3 这两个级别的权限,也就是 使用Ring3作为用户态,Ring0作为内核态

那么有人会问为什么Linux系统仅使用了 Ring0和Ring3 这两个级别?

因为CPU给的权限管理细度不够,比如 Intel CPU Ring2和Ring3 在操作系统里安全情况没有区别, Ring1 下的系统权限又需要经常调用 Ring0 特权指令,频繁切换特权级成本过高,操作系统不如将 Ring2 合并到 Ring3 ,将 Ring1 划入 Ring0 特权级

另一方面不是每种处理器都像 x86 一样支持4个权限级别,有些处理器可能只支持2个级别,更少的特权级别,便于移植其他处理器架构上

我们再来看下linux的体系架构图:

我们可以发现Linux系统从整体上看,被划分为 用户态和内核态

  1. 内核态

内核态是处于操作系统的最核心处, Ring0 特权级,拥有操作系统的最高权限,能够控制所有的硬件资源,掌控各种核心数据,并且能够访问内存中的任意地址;由内核态统一管理这些核心资源,减少有限资源的访问和使用冲突;在内核里发生的任何程序异常都是灾难性的,会导致整个操作系统的奔溃

  1. 用户态

用户态,就是我们通常编写程序的地方,处于 Ring3 特权级,权限较低;这一层次的程序 没有对硬件的直接控制权限,也不能直接访问地址的内存 。在这种模式下,即使程序发生崩溃也不会影响其他程序,可恢复

什么是系统调用

当计算机启动的时候,CPU处于Ring0状态,这个时候所有的指令都可以执行,通过主引导程序将磁盘扇区中的操作系统程序加载到内存中,从而启动操作系统( 需要注意一下,本文的操作系统 以Linux0.12为例子 )

也就是说当 Linux0.12启动 的时候,是在权限最高级别的 内核态 运行的;同时对内存进行划分,划出一部分(内核区)专门给内核使用,这部分内存只能被内核使用;主内存区域给其他应用软件使用。对这部分感兴趣地,可以看看笔者之前的文章 Linux0.12内核源码解读(6)-main.c

当操作系统启动完成后,CPU就切换到 Ring3 级别上,操作系统同时进入用户态,之后的 应用程序代码 都运行在权限最低级别的 用户态 上,通常我们能编写的程序都运行在用户态上

需要格外注意一下, CPU特权级其实并不会对操作系统的用户造成什么影响 !有人会和Linux的用户权限搞混淆,无论是根用户(root),管理员,访客还是一般用户,它们都属于用户; 而所有的用户代码都在用户态Ring3上执行,所有的内核代码都在内核态Ring0上执行,和Linux用户的身份权限并没有关系

因为我们编写的程序都运行在用户态上,是无法对内存和I/O端口的访问,可以说基本上无法与外部世界交互,但是我们平时工作的时候访问磁盘、写文件,这些都是必要的需求,怎么办?

那就需要通过执行 系统调用system call ,操作系统会切换到内核态,由内核去统一执行相关操作(大哥帮小弟去执行);当执行完操作系统再切换回用户态。这样方便集中管理,减少有限资源的访问和使用冲突

系统调用 是操作系统专门为用户态运行的进程与硬件设备之间进行交互提供了一组接口,是用户态 主动 要求切换到内核态的一种方式

系统调用是怎么实现的

接下来我们就结合 Linux0.12 的源码一起来看看系统调用是怎么实现的?

库函数write

本文以一个常见的库函数 write 函数为例来,来更方便大家理解,开始发车:

//  lib/write.c

#define __LIBRARY__
#include  //头文件

_syscall3(int,write,int,fd,const char *,buf,off_t,count) //定义write的实现,:fd - 文件描述符;buf - 写缓冲区指针;count - 写字节数

write.c 这个文件主要是定义write的实现, _syscall3(*,write,*) 函数的主要功能是,向文件描述符fd指定的文件写入count个字节的数据到缓冲区buf中

需要注意一下 #define __LIBRARY__ 这个宏定义,这里定义 直接原因 是为了包括在 unistd.h 中的内嵌汇编代码

库函数扩展汇编宏

因为 _syscall3 这个函数定义在 /include/unistd.h 中,来看下源码:

//  /include/unistd.h


#ifdef __LIBRARY__ # 若提前定义__LIBRARY__,则以后内容被包含

...

#define __NR_write 4 //系统调用号,用作系统调用函数表中索引值

...

//定义有3个參数的, 定义系统调用嵌入式汇编宏函数
//%0 - eax(__res),%1 - eax(__NR_name),%2 - ebx(a),%3 - ecx(b),%4 - edx(c)。
#define _syscall3(type,name,atype,a,btype,b,ctype,c) \
type name(atype a,btype b,ctype c) \
{ \
long __res; \
__asm__ volatile ("int $0x80" \                                             // 调用系统中断 0x80
 : "=a" (__res) \                                                          // 返回值eax(__res)
 : "0" (__NR_##name),"b" ((long)(a)),"c" ((long)(b)),"d" ((long)(c))); \   //输入为:系统中断调用号__NR_name,还有另外3个参数
if (__res>=0) \                                                             // 如果返回值>=0,则直接返回该值
 return (type) __res; \
errno=-__res; \                                                             // 否则置出错号,并返回-1
return -1; \                                                                
}

#endif /* __LIBRARY__ */

...

int write(int fildes, const char * buf, off_t count); //write系统调用的函数原型定义

...

只有在 lib/write.c 中先定义了 #define __LIBRARY__ ,那么才能在 /include/unistd.h 中,找到系统调用号和内嵌汇编 _syscall3() ;不然就代表它不需要进行系统调用,这样就可以忽略 unistd.h 中和系统调用相关的宏定义,非常的优雅

其实我们可以把write.c中的write函数再重新整合一下:

int write(int fd,const char* buf,off_t count) \
{ \
long __res; \
__asm__ volatile ( "int $0x80" \
"=a" (__res) \
"" (__NR_write), "b" ((long)(fd)), "c" ((long)(buf)), "d" ((long)(count))); \
if (__res>=0) \
return (type) __res; \
errno=-__res; \
return -1; \
}

这样大家就能更容易明白 #define __LIBRARY__ 的作用

上面 int $0x80" 表示调用 系统中断0x80 ** ,其实 系统调用的本质还是通过中断(0x80)去实现的**!操作系统中真的是处处离不开中断。中断相关知识不了解的,可以看看笔者之前写过的一篇文章 图解计算机中断

另外由于程序处于用户态无法直接操作硬件资源,所以需要进行 系统调用 ,切换到内核态;也就是说用户程序如果使用 库函数write ,会进行系统调用

而系统调用,其实就是去调用 int 0x80中断 ,然后把三个参数 fd、buf、count 依次存入 ebx、ecx、edx寄存器

还有 #define __NR_write 4 ,定义了 系统调用号 _NR_write 会被存入 eax寄存器 ;当调用返回后,从eax取出返回值,存入 __res ,建立了用户栈和内核栈的联系。至于 __NR_write 的作用下文再讲解

int 0x80中断 调用对应的中断处理函数

我们来看下中断是调用对应的中断处理函数的流程图:

当发生中断的时候,CPU获取到中断向量号后,通过 IDTR ,去查找 IDT中断描述符表 ,得到相应的中断描述符;然后根据描述符中的对应中断处理程序的入口地址,去执行中断处理程序

早在linux0.12启动时,会进行调度程序初始化 main.c/sched_init() ,其源码:

//     /kernel/sched.c

...

void sched_init(void)
{
 ...
 set_system_gate(0x80,&system_call);//设置系统调用中断门
}

...

set_system_gate 在之前的文章 Linux0.12内核源码解读(7)-陷阱门初始化 讲解过,不再赘述

需要注意的是:在用户态和内核态运行的进程使用的栈是不同的,分别叫做 用户栈和内核栈 , 两者各自负责相应特权级别状态下的函数调用;所以当执行系统调用中断 int 0x80 从用户态进入内核态时,会 从用户栈切换到内核栈 ,系统调用返回时,还要切换回用户栈,继续完成用户态下的函数调用(这也叫做被中断进程上下文的保存与恢复)

其中其关键作用的是,CPU会可以自动通过 TR寄存器 找到当前进程的 TSS ,然后根据里面 ss0和esp0 的值找到内核栈的位置,完成用户栈到内核栈的切换。先了解一下,这块等进程那块我们会再详细聊聊

set_system_gate(0x80,&system_call) 这句整体作用是, 设置系统调用中断门 ,将 0x80中断 和函数 system_call 绑定在一起,换句话说 system_call 就是 0x80 的中断处理函数

检索系统调用函数表

我们接着去看 system_call 函数的源码:

//    /kernel/sys_call.s

...

// int 0x80
_system_call:
 push %ds      # 压栈, 保存原段寄存器值
 push %es
 push %fs   
 pushl %eax  # 保存eax原值
 pushl %edx  
 pushl %ecx  # push %ebx,%ecx,%edx as parameters
 pushl %ebx  # to the system call,  ebx,ecx,edx 中放着系统调用对应的C语言函数的参数
 movl $0x10,%edx  # ds,es 指向内核数据段
 mov %dx,%ds
 mov %dx,%es
 movl $0x17,%edx  # fs 指向当前局部数据段(局部描述符表中数据段描述符)
 mov %dx,%fs
 cmpl _NR_syscalls,%eax  # 判断eax是否超过了最大的系统调用号,调用号如果超出范围的话就跳转!
 jae bad_sys_call
 call _sys_call_table(,%eax,4)   # 间接调用指定功能C函数!
 pushl %eax                      #  把系统调用的返回值入栈!

...

ret_from_sys_call:  #当系统调用执行完毕之后,会执行此处的汇编代码,从而返回用户态
 movl _current,%eax  # 取当前任务(进程)数据结构指针->eax
 cmpl _task,%eax   # task[0] cannot have signals
 ...

其中 _sys_call_table(,%eax,4) ,这里的eax寄存器存放的就是 _NR_write 系统调用号, _sys_call_table 是sys.h中的一个 int (*)() 类型的数组,里面存的是所有的系统调用函数地址,也叫做 系统调用函数表 ,所以 __NR_write 也表示系统调用函数表中的索引值

那为什么 %eax * 4 乘上4呢?这是因为 sys_call_table[] 指针每项4 个字节,这样 被调用处理函数的地址=[_sys_call_table + %eax * 4]

我们再来看下 sys_call_table 的定义:

//    /include/linux/sys.h

...
extern int sys_write();
...

fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid,sys_setregid, sys_sigsuspend, sys_sigpending, sys_sethostname,
sys_setrlimit, sys_getrlimit, sys_getrusage, sys_gettimeofday, 
sys_settimeofday, sys_getgroups, sys_setgroups, sys_select, sys_symlink,
sys_lstat, sys_readlink, sys_uselib };

//系统调用总数目,注意一下:这里相较于linux0.11做了改进,新增系统调用不再需要手动调整该数目!
int NR_syscalls = sizeof(sys_call_table)/sizeof(fn_ptr);

可以知晓这里的 call _sys_call_table(,%eax,4) 就是调用系统调用号所对应的内核系统调用函数 sys_write

最终执行sys_write

sys_write 在fs下的 read_write.c

//   /fs/read_write.c

// 写文件系统调用
int sys_write(unsigned int fd,char * buf,int count)
{
 struct file * file;
 struct m_inode * inode;

  //判断函数参数的有效性
 if (fd>=NR_OPEN || count <0 || !(file=current->filp[fd]))
  return -EINVAL;
 if (!count)
  return 0;
  // 取文件相应的i节点
 inode=file->f_inode;
  // 若是管道文件,并且是写管道文件模式,则进行写管道操作
 if (inode->i_pipe)
  return (file->f_mode&2)?write_pipe(inode,buf,count):-EIO;
  //如果是字符设备文件,则进行写字符设备操作
 if (S_ISCHR(inode->i_mode))
  return rw_char(WRITE,inode->i_zone[0],buf,count,&file->f_pos);
  // 如果是块设备文件,则进行块设备写操作
 if (S_ISBLK(inode->i_mode))
  return block_write(inode->i_zone[0],&file->f_pos,buf,count);
  // 若是常规文件,则执行文件写操作
 if (S_ISREG(inode->i_mode))
  return file_write(inode,file,buf,count);
 printk("(Write)inode->i_mode=%06o\n\r",inode->i_mode);
 return -EINVAL;
}

至此库函数write,进行系统调用,最终调用了 sys_write 这个函数

我们再通过下图回顾一下,整个系统调用的过程:

内核态与用户态数据交互

到这里我们已经了解了系统调用的过程,还遗留一个问题需要去解决一下,就是内核态与用户态如何进行数据交互?

回顾系统调用过程中,我们可以发现 寄存器 在其中起到了不可或缺的作用,linus在 linux0.12 中也是采用类似的方法来进行数据交互







请到「今天看啥」查看全文