操作系统:服务接口

服务接口

Cosmos 系统已经有内存管理,进程、文件、I/O 了,这些重要的组件已经建立了,也就是说它们可以向应用程序提供服务了。

服务接口的结构

先来设计一下服务接口的整体结构,即 Cosmos 的 API 结构。因为 Cosmos 的 API 数量很多,所以我们先来分个类,它们分别是进程类、内存类、文件类和时间类的 API。这些 API 还会被上层 C 库封装,方便应用程序调用。

可参考下图,来理解他们之间的关系:


结合上图可以看到,我们的应用程序库分为时间库、进程库、内存库、文件库这几种类型。

通常情况下,应用程序中调用的是一些库函数。库函数是对系统服务的封装,有的库函数是直接调用相应的系统服务;而有的库函数为了完成特定的功能,则调用了几个相应的系统服务;还有一些库函数完成的功能不需要调用相应的系统调用,这时前台接待人员也就是“库函数”,可以自行处理。如何进入内核

如何进入内核

由上图我们还可以看出,应用程序和库函数都在用户空间中,而系统服务却在内核空间中,想要让代码控制流从用户空间进入到内核空间中,如何穿过 CPU 保护模式的“铜墙铁壁”才是关键。下面我们就一起来探索这个问题。

软中断指令

设备向 CPU 发送一个中断信号,CPU 接受到这个电子信号后,在允许响应中断的情况下,就会中断当前正在运行的程序,自动切换到相应的 CPU R0 特权级,并跳转到中断门描述符中相应的地址上运行中断处理代码。

当然,这里的中断处理代码就是操作系统内核的代码,这样 CPU 的控制权就转到操作系统内核的手中了。

其实,应用软件也可以给 CPU 发送中断。现代 CPU 设计时都会设计这样一条指令,一旦执行该指令,CPU 就要中断当前正在运行的程序,自动跳转到相应的固定地址上运行代码。当然这里的代码也就是操作系统内核的代码,就这样 CPU 的控制权同样会回到操作系统内核的手中。

因为这条指令模拟了中断的电子信号,所以称为软中断指令。在 x86 CPU 上这条指令是 int 指令。例如 int255。int 指令后面需要跟一个常数,这个常数表示 CPU 从中断表描述符表中取得第几个中断描述符进入内核。

传递参数

虽然 int 指令提供了应用程序进入操作系统内核函数的底层机制,但是我们还需要解决参数传递的问题。

因为需要告诉操作系统你要干什么,系统才能做出相应的反馈。比如你要分配内存,分配多大的内存,这些信息必须要以参数的形式传递给操作系统内核。

因为应用程序运行在用户空间时,用的是用户栈,当它切换到内核空间时,用的是内核栈。所以参数的传递,就需要硬性地规定一下,要么所有的参数都用寄存器传递,要么所有的参数都保存在用户栈中。

显然,第一种用寄存器传递所有参数的方法要简单得多,事实上有很多操作系统就是用寄存器传递参数的。

我们使用 RBX、RCX、RDX、RDI、RSI 这 5 个寄存器来传递参数,事实上一个系统服务接口函数不会超过 5 个参数,所以这是足够的。而 RAX 寄存器中保存着一个整数,称为系统服务号。在系统服务分发器中,会根据这个系统服务号调用相应的函数。

因为 C 编译器不能处理这种参数传递形式,另外 C 编译器也不支持 int 指令,所以要用汇编代码来处理这种问题。

建立一个 cosmos/include/libinc/lapinrentry.h 文件,在这里写上后面的代码。

//传递一个参数所用的宏
#define API_ENTRY_PARE1(intnr,rets,pval1) \
__asm__ __volatile__(\
         "movq %[inr],%%rax\n\t"\//系统服务号
         "movq %[prv1],%%rbx\n\t"\//第一个参数
         "int $255 \n\t"\//触发中断
         "movq %%rax,%[retval] \n\t"\//处理返回结果
         :[retval] "=r" (rets)\
         :[inr] "r" (intnr),[prv1]"r" (pval1)\
         :"rax","rbx","cc","memory"\
    )
//传递四个参数所用的宏    
#define API_ENTRY_PARE4(intnr,rets,pval1,pval2,pval3,pval4) \
__asm__ __volatile__(\
         "movq %[inr],%%rax \n\t"\//系统服务号
         "movq %[prv1],%%rbx \n\t"\//第一个参数
         "movq %[prv2],%%rcx \n\t"\//第二个参数
         "movq %[prv3],%%rdx \n\t"\//第三个参数
         "movq %[prv4],%%rsi \n\t"\//第四个参数
         "int $255 \n\t"\//触发中断
         "movq %%rax,%[retval] \n\t"\//处理返回结果
         :[retval] "=r" (rets)\
         :[inr] "r" (intnr),[prv1]"g" (pval1),\
         [prv2] "g" (pval2),[prv3]"g" (pval3),\
         [prv4] "g" (pval4)\
         :"rax","rbx","rcx","rdx","rsi","cc","memory"\
    )

上述代码中只展示了两个宏。其实是有四个,在代码文件中我已经帮你写好了,主要功能是用来解决传递参数和触发中断问题,并且还需要处理系统返回的结果。这些都是用 C 语言中嵌入汇编代码的方式来实现的。

面我们用它来写一个系统服务接口,代码如下所示。

//请求分配内存服务
void* api_mallocblk(size_t blksz)
{
    void* retadr;
    //把系统服务号,返回变量和请求分配的内存大小
    API_ENTRY_PARE1(INR_MM_ALLOC,retadr,blksz);
    return retadr;
}

上述代码可以被库函数调用,也可以由应用程序直接调用,它用 API_ENTRY_PARE1 宏传递参数和触发中断进入 Cosmos 内核,最终将由内存管理模块相应分配内存服务的请求。

到这里,我们已经解决了如何进入内核和传递参数的问题了,下面我们看看进入内核之后要做些什么。

系统服务分发器

由于执行了 int 指令后,CPU 会停止当前代码执行,转而执行对应的中断处理代码。再加上随着系统功能的增加,系统服务也会增加,但是中断的数量却是有限的,所以我们不能每个系统服务都占用一个中断描述符。

那这个问题怎么解决呢?其实我们可以只使用一个中断描述符,然后通过系统服务号来区分是哪个服务。这其实就是系统服务器分发器完成的工作。

实现系统服务分发器

其实系统服务分发器就是一个函数,它由中断处理代码调用,在它的内部根据系统服务号来调用相应的服务。下面我们一起在 cosmos/kernel/krlservice.c 文件中写好这个函数,代码如下所示。

sysstus_t krlservice(uint_t inr, void* sframe)
{
    if(INR_MAX <= inr)//判断服务号是否大于最大服务号
    {
        return SYSSTUSERR;
    }
    if(NULL == osservicetab[inr])//判断是否有服务接口函数
    {
        return SYSSTUSERR;
    }
    return osservicetab[inr](inr, (stkparame_t*)sframe);//调用对应的服务接口函数
}

上面的系统服务分发器函数现在就写好了。其实逻辑非常简单,就是先对服务号进行判断,如果大于系统中最大的服务号,就返回一个错误状态表示服务失败。然后判断是否有服务接口函数。最后这两个检查通过之后,就可以调用相应的服务接口了。

那么 krlservice 函数是谁调用的呢?答案是中断处理的框架函数,如下所示。

sysstus_t hal_syscl_allocator(uint_t inr,void* krnlsframp)
{
    return krlservice(inr,krnlsframp);
}

hal_syscl_allocator 函数则是由我们系统中断处理的第一层汇编代码调用的,这个汇编代码主要是将进程的用户态 CPU 寄存器保存在内核栈中,代码如下所示。

//cosmos/include/halinc/kernel.inc
%macro  EXI_SCALL  0
  push rbx//保存通用寄存器到内核栈
  push rcx
  push rdx
  push rbp
  push rsi
  push rdi
    //删除了一些代码
  mov  rdi, rax //处理hal_syscl_allocator函数第一个参数inr
  mov rsi, rsp //处理hal_syscl_allocator函数第二个参数krnlsframp
  call hal_syscl_allocator //调用hal_syscl_allocator函数
  //删除了一些代码
  pop rdi
  pop rsi
  pop rbp
  pop rdx
  pop rcx
  pop rbx//从内核栈中恢复通用寄存器
  iretq //中断返回
%endmacro
//cosmos/hal/x86/kernel.asm
exi_sys_call:
  EXI_SCALL

上述代码中的 exi_sys_call 标号的地址保存在第 255 个中断门描述符中。这样执行了 int $255 之后,CPU 就会自动跳转到 exi_sys_call 标号处运行,从而进入内核开始运行,最终调用 krlservice 函数,开始执行系统服务。

系统服务表

从上面的代码可以看出,我们不可能每个系统服务都占用一个中断描述符,所以要设计一个叫做系统服务表的东西,用来存放各种系统服务的入口函数,它能在 krlservice 函数中根据服务号,调用相应系统服务表中相应的服务入口函数。怎么实现系统服务表呢?如果你想到函数指针数组,这说明你和我想到一块了。

下面我们一起来定义这个函数指针数组,它是全局的,我们放在 cosmos/kernel/krlglobal.c 中,代码如下所示。

typedef struct s_STKPARAME
{
    u64_t gs;
    u64_t fs;
    u64_t es;
    u64_t ds;
    u64_t r15;
    u64_t r14;
    u64_t r13;
    u64_t r12;
    u64_t r11;
    u64_t r10;
    u64_t r9;
    u64_t r8;
    u64_t parmv5;//rdi;
    u64_t parmv4;//rsi;
    u64_t rbp;
    u64_t parmv3;//rdx;
    u64_t parmv2;//rcx;
    u64_t parmv1;//rbx;
    u64_t rvsrip;    
    u64_t rvscs;
    u64_t rvsrflags;
    u64_t rvsrsp;
    u64_t rvsss;
}stkparame_t;
//服务函数类型
typedef sysstus_t (*syscall_t)(uint_t inr,stkparame_t* stkparm);
//cosmos/kernel/krlglobal.c
KRL_DEFGLOB_VARIABLE(syscall_t,osservicetab)[INR_MAX]={};

执行 int 指令后会 CPU 会进入中断处理流程。中断处理流程的第一步就是把 CPU 的一寄存器压入内核栈中,前面系统传递参数正是通过寄存器传递的,而寄存器就保存在内核栈中。

所以需要定义一个 stkparame_t 结构,用来提取内核栈中的参数。

接着是第二步,我们可以查看一下 hal_syscl_allocator 函数的第二个参数,正是传递的 RSP 寄存器的值,只要把这个值转换成 stkparame_t 结构的地址,就能提取内核栈中的参数了。

但是目前 osservicetab 数组中为空,什么也没有,这是因为我们还没有实现相应服务接口函数。下面我们就来实现它。

系统服务实例

我们下面就来实现系统时间系统服务,应用程序也是经常要获取时间数据的。

时间库

根据前面所讲,应用程序开发者往往不是直接调用系统 API(应用程序编程接口,我们称为服务接口),而是经常调用某个库来达到目的。

所以,我们要先来实现一个时间的库函数。首先,我们需要建立一个 cosmos/lib/libtime.c 文件,在里面写上后面这段代码。

//时间库函数
sysstus_t time(times_t *ttime)
{
    sysstus_t rets = api_time(ttime);//调用时间API
    return rets;
}

time 库函数非常简单,就是对系统 API 的封装、应用程序需要传递一个 times_t 结构的地址,这是这个系统 API 的要求, 这个结构也是由系统定义的,如下所示。

typedef struct s_TIME
{
    uint_t      year;
    uint_t      mon;
    uint_t      day;
    uint_t      date;
    uint_t      hour;
    uint_t      min;
    uint_t      sec;
}times_t;

可以看到,上述结构中定义了年、月、日、时、分、秒。系统内核会将时间信息填入这个结构中,然后返回,这样一来,时间数据就可以返回给应用程序了。

时间 API 接口

时间库函数已经写好了,在库中需要调用时间 API 接口,因为库和 API 接口函数不同层次的,有时应用程序也会直接调用 API 接口函数,所以我们要分为不同模块。

下面我们建立一个 cosmos/lib/lapitime.c 文件,并在里面实现 api_time 函数,如下所示。

sysstus_t api_time(buf_t ttime)
{
    sysstus_t rets;
    API_ENTRY_PARE1(INR_TIME,rets,ttime);//处理参数,执行int指令 
    return rets;
}

INR_TIME 是系统服务号,它经过 API_ENTRY_PARE1 宏处理,把 INR_TIME 和 ttime、rets 关联到相应的寄存器,如果不明白可以参考前面的参数传递中使用寄存器的情况。最后就是执行 int 指令进入内核,开始运行时间服务代码。

内核态时间服务接口

当执行 int 指令后,就进入了内核模式下开始执行内核代码了。系统服务分发器会根据服务号从系统服务表中取出相应的函数并调用。因为我们这里要响应的是时间服务,所以取用的自然就是时间服务的接口函数。

下面我们来建立一个 cosmos/kernel/krltime.c 文件,写出这个时间服务的接口函数,代码如下所示。

sysstus_t krlsvetabl_time(uint_t inr, stkparame_t *stkparv)
{
    if (inr != INR_TIME)//判断是否时间服务号
    {
        return SYSSTUSERR;
    }
    //调用真正时间服务函数 
    return krlsve_time((time_t *)stkparv->parmv1);
}

每个服务接口函数的参数形式都是固定的,我们在前面已经讲过了,但是这个 krlsvetabl_time 函数一定要放在系统服务表中才可以,系统服务表其实是个函数指针数组。虽然前面已经提过了,但是那时 osservicetab 数组是空的,现在我们要把 krlsvetabl_time 函数放进去,如下所示。

KRL_DEFGLOB_VARIABLE(syscall_t, osservicetab)[INR_MAX] = {
    NULL, krlsvetabl_mallocblk,//内存分配服务接口
    krlsvetabl_mfreeblk, //内存释放服务接口
    krlsvetabl_exel_thread,//进程服务接口
    krlsvetabl_exit_thread,//进程退出服务接口
    krlsvetabl_retn_threadhand,//获取进程id服务接口
    krlsvetabl_retn_threadstats,//获取进程状态服务接口
    krlsvetabl_set_threadstats,//设置进程状态服务接口
    krlsvetabl_open, krlsvetabl_close,//文件打开、关闭服务接口
    krlsvetabl_read, krlsvetabl_write,//文件读、写服务接口
    krlsvetabl_ioctrl, krlsvetabl_lseek,//文件随机读写和控制服务接口
    krlsvetabl_time};//获取时间服务接口

我们的获取时间服务接口占最后一个,第 0 个要保留,其它的服务接口函数我已经帮你实现好了,可以自己查看代码。这样就能调用到 krlsvetabl_time 函数完成服务功能了。

实现时间服务

上面我们只实现了时间服务的接口函数,这个函数还需要调用真正完成功能的函数,下面我们来实现它。想在该函数中完成获取时间数据的功能,我们依然要在 cosmos/kernel/krltime.c 文件中来实现,如下所示。

sysstus_t krlsve_time(time_t *time)
{
    if (time == NULL)//对参数进行判断
    {
        return SYSSTUSERR;
    }
    ktime_t *initp = &osktime;//操作系统保存时间的结构
    cpuflg_t cpufg;
    krlspinlock_cli(&initp->kt_lock, &cpufg);//加锁
    time->year = initp->kt_year;
    time->mon = initp->kt_mon;
    time->day = initp->kt_day;
    time->date = initp->kt_date;
    time->hour = initp->kt_hour;
    time->min = initp->kt_min;
    time->sec = initp->kt_sec;//把时间数据写入到参数指向的内存
    krlspinunlock_sti(&initp->kt_lock, &cpufg);//解锁
    return SYSSTUSOK;//返回正确的状态
}

krlsve_time 函数,只是把系统的时间数据读取出来,写入用户应用程序传入缓冲区中,由于 osktime 这个结构实例会由其它代码自动更新,所以要加锁访问。好了,这样一个简单的系统服务函数就实现了。

系统服务函数的执行过程

我们已经实现了一个获取时间的系统服务函数,我想你应该能自己实现其它更多的系统服务函数了。下面我来帮你梳理一下,从库函数到进入中断再到系统服务分发器,最后到系统服务函数的全过程,我给你准备了一幅图,如下所示。
系统服务流程示意图:

上图中应用程序在用户空间中运行,调用库函数,库函数调用 API 函数执行 INT 指令,进入中断门,从而运行内核代码。最后内核代码一步步执行了相关服务功能,返回到用户空间继续运行应用程序。这就是应用程序调用一个系统服务的全部过程。

小结

  1. 首先,我们从全局了解了 Cosmos 服务接口的结构,它是分层封装的,由库、API 接口、系统服务分发器、系统服务接口、系统服务组成的。

  2. 接着,我们学习了如何使用 int 指令触发中断,使应用程序通过中断进入内核开始执行相关的服务,同时解决了如何给内核传递参数的问题。

  3. 然后,我们一起实现了系统分发器和系统服务表,这是实现系统服务的重要机制。

  4. 最后,我们从库函数开始一步步实现了获取时间的系统服务,了解了实现一个系统的全部过程和细节。

Linux如何实现系统API

上节课,我们通过实现一个获取时间的系统服务,学习了 Cosmos 里如何建立一个系统服务接口。Cosmos 为应用程序提供服务的过程大致是这样的:应用程序先设置服务参数,然后通过 int 指令进入内核,由 Cosmos 内核运行相应的服务函数,最后为应用程序提供所需服务。

这节课我们就来看看 Linux 内核是如何实现这一过程的,我们首先了解一下 Linux 内核有多少 API 接口,然后了解一下 Linux 内核 API 接口的架构,最后,我们动手为 Linux 内核增加一个全新的 API,并实现相应的功能。

Linux 内核 API 接口的架构

在上节课中,我们已经熟悉了我们自己的 Cosmos 内核服务接口的架构,由应用程序调用库函数,再由库函数调用 API 入口函数,进入内核函数执行系统服务。

其实对于 Linux 内核也是一样,应用程序会调用库函数,在库函数中调用 API 入口函数,触发中断进入 Linux 内核执行系统调用,完成相应的功能服务。

在 Linux 内核之上,使用最广泛的 C 库是 glibc,其中包括 C 标准库的实现,也包括所有和系统 API 对应的库接口函数。几乎所有 C 程序都要调用 glibc 的库函数,所以 glibc 是 Linux 内核上 C 程序运行的基础。

下面我们以 open 库函数为例分析一下,看看 open 是如何进入 Linux 内核调用相关的系统调用的。glibc 虽然开源了,但是并没有在 Linux 内核代码之中,你需要从这里下载并解压,open 函数代码如下所示。

//glibc/intl/loadmsgcat.c
#ifdef _LIBC
# define open(name, flags)  __open_nocancel (name, flags)
# define close(fd)      __close_nocancel_nostatus (fd)
#endif
//glibc/sysdeps/unix/sysv/linux/open_nocancel.c
int __open_nocancel (const char *file, int oflag, ...)
{
  int mode = 0;
  if (__OPEN_NEEDS_MODE (oflag))
    {
      va_list arg;
      va_start (arg, oflag);//解决可变参数
      mode = va_arg (arg, int);
      va_end (arg);
    }
  return INLINE_SYSCALL_CALL (openat, AT_FDCWD, file, oflag, mode);
}
//glibc/sysdeps/unix/sysdep.h
//这是为了解决不同参数数量的问题
#define __INLINE_SYSCALL0(name) \
  INLINE_SYSCALL (name, 0)
#define __INLINE_SYSCALL1(name, a1) \
  INLINE_SYSCALL (name, 1, a1)
#define __INLINE_SYSCALL2(name, a1, a2) \
  INLINE_SYSCALL (name, 2, a1, a2)
#define __INLINE_SYSCALL3(name, a1, a2, a3) \
  INLINE_SYSCALL (name, 3, a1, a2, a3)
#define __INLINE_SYSCALL_NARGS_X(a,b,c,d,e,f,g,h,n,...) n
#define __INLINE_SYSCALL_NARGS(...) \
  __INLINE_SYSCALL_NARGS_X (__VA_ARGS__,7,6,5,4,3,2,1,0,)
#define __INLINE_SYSCALL_DISP(b,...) \
  __SYSCALL_CONCAT (b,__INLINE_SYSCALL_NARGS(__VA_ARGS__))(__VA_ARGS__)
#define INLINE_SYSCALL_CALL(...) \
  __INLINE_SYSCALL_DISP (__INLINE_SYSCALL, __VA_ARGS__)
//glibc/sysdeps/unix/sysv/linux/sysdep.h
//关键是这个宏
#define INLINE_SYSCALL(name, nr, args...)       \
  ({                  \
    long int sc_ret = INTERNAL_SYSCALL (name, nr, args);    \
    __glibc_unlikely (INTERNAL_SYSCALL_ERROR_P (sc_ret))    \
    ? SYSCALL_ERROR_LABEL (INTERNAL_SYSCALL_ERRNO (sc_ret))   \
    : sc_ret;               \
  })
#define INTERNAL_SYSCALL(name, nr, args...)       \
  internal_syscall##nr (SYS_ify (name), args)
#define INTERNAL_SYSCALL_NCS(number, nr, args...)     \
  internal_syscall##nr (number, args)
//这是需要6个参数的宏
#define internal_syscall6(number, arg1, arg2, arg3, arg4, arg5, arg6) \
({                  \
    unsigned long int resultvar;          \
    TYPEFY (arg6, __arg6) = ARGIFY (arg6);        \
    TYPEFY (arg5, __arg5) = ARGIFY (arg5);        \
    TYPEFY (arg4, __arg4) = ARGIFY (arg4);        \
    TYPEFY (arg3, __arg3) = ARGIFY (arg3);        \
    TYPEFY (arg2, __arg2) = ARGIFY (arg2);        \
    TYPEFY (arg1, __arg1) = ARGIFY (arg1);        \
    register TYPEFY (arg6, _a6) asm ("r9") = __arg6;      \
    register TYPEFY (arg5, _a5) asm ("r8") = __arg5;      \
    register TYPEFY (arg4, _a4) asm ("r10") = __arg4;     \
    register TYPEFY (arg3, _a3) asm ("rdx") = __arg3;     \
    register TYPEFY (arg2, _a2) asm ("rsi") = __arg2;     \
    register TYPEFY (arg1, _a1) asm ("rdi") = __arg1;     \
    asm volatile (              \
    "syscall\n\t"             \
    : "=a" (resultvar)              \
    : "0" (number), "r" (_a1), "r" (_a2), "r" (_a3), "r" (_a4),   \
      "r" (_a5), "r" (_a6)            \
    : "memory", REGISTERS_CLOBBERED_BY_SYSCALL);      \
    (long int) resultvar;           \
})

上述代码中,可以清楚地看到,open 只是宏,实际工作的是 __open_nocancel 函数,其中会用 INLINE_SYSCALL_CALL 宏经过一系列替换,最终根据参数的个数替换成相应的 internal_syscall##nr 宏。

比如有 6 个参数,就会替换成 internal_syscall6。其中 number 是系统调用号,参数通过寄存器传递的。但是这里我们没有发现 int 指令,这是因为这里用到的指令是最新处理器为其设计的系统调用指令 syscall。这个指令和 int 指令一样,都可以让 CPU 跳转到特定的地址上,只不过不经过中断门,系统调用返回时要用 sysexit 指令

LinuxAPI框架:

那么 Linux 系统有多少个 API 呢?我们一起去看看吧。

Linux 内核有多少 API 接口

Linux 作为比较成熟的操作系统,功能完善,它以众多 API 接口的方式向应用程序提供文件、网络、进程、时间等待服务,并且完美执行了国际 posix 标准。

Linux 从最初几十个 API 接口,现在已经发展到了几百个 API 接口,从这里你可以预见到 Linux 内核功能增加的速度与数量。那么现在的 Linux 内核究竟有多少个 API 接口呢?我们还是要来看看最新发布的 Linux 内核版本,才能准确知道。

具体我们需要对 Linux 代码进行编译,在编译的过程中,根据 syscall_32.tbl 和 syscall_64.tbl 生成自己的 syscalls_32.h 和 syscalls_64.h 文件。

生成方式在 arch/x86/entry/syscalls/Makefile 文件中。这里面会使用两个脚本,即 syscallhdr.sh、syscalltbl.sh,它们最终生成的 syscalls_32.h 和 syscalls_64.h 两个文件中就保存了系统调用号和系统调用实现函数之间的对应关系,在里面可以看到 Linux 内核的系统调用号,即 API 号,代码如下所示。

//linux/arch/x86/include/generated/asm/syscalls_64.h
__SYSCALL_COMMON(0, sys_read)
__SYSCALL_COMMON(1, sys_write)
__SYSCALL_COMMON(2, sys_open)
__SYSCALL_COMMON(3, sys_close)
__SYSCALL_COMMON(4, sys_newstat)
__SYSCALL_COMMON(5, sys_newfstat)
__SYSCALL_COMMON(6, sys_newlstat)
__SYSCALL_COMMON(7, sys_poll)
__SYSCALL_COMMON(8, sys_lseek)
//……
__SYSCALL_COMMON(435, sys_clone3)
__SYSCALL_COMMON(436, sys_close_range)
__SYSCALL_COMMON(437, sys_openat2)
__SYSCALL_COMMON(438, sys_pidfd_getfd)
__SYSCALL_COMMON(439, sys_faccessat2)
__SYSCALL_COMMON(440, sys_process_madvise)
//linux/arch/x86/include/generated/uapi/asm/unistd_64.h
#define __NR_read 0
#define __NR_write 1
#define __NR_open 2
#define __NR_close 3
#define __NR_stat 4
#define __NR_fstat 5
#define __NR_lstat 6
#define __NR_poll 7
#define __NR_lseek 8
//……
#define __NR_clone3 435
#define __NR_close_range 436
#define __NR_openat2 437
#define __NR_pidfd_getfd 438
#define __NR_faccessat2 439
#define __NR_process_madvise 440
#ifdef __KERNEL__
#define __NR_syscall_max 440
#endif

上述代码中,已经定义了 __NR_syscall_max 为 440,这说明 Linux 内核一共有 441 个系统调用,而系统调用号从 0 开始到 440 结束,所以最后一个系统调用是 sys_process_madvise。

其实,__SYSCALL_COMMON 除了表示系统调用号和系统调用函数之间的关系,还会在 Linux 内核的系统调用表中进行相应的展开,究竟展开成什么样子呢

Linux 系统调用表

Linux 内核有 400 多个系统调用,它使用了一个函数指针数组,存放所有的系统调用函数的地址,通过数组下标就能索引到相应的系统调用。这个数组叫 sys_call_table,即 Linux 系统调用表。

sys_call_table 到底长什么样?我们来看一看代码才知道,同时也解答一下前面留下的疑问,这里还是要说明一下,__SYSCALL_COMMON 首先会替换成 __SYSCALL_64,因为我们编译的 Linux 内核是 x86_64 架构的,如下所示。

#define __SYSCALL_COMMON(nr, sym) __SYSCALL_64(nr, sym)
//第一次定义__SYSCALL_64
#define __SYSCALL_64(nr, sym) extern asmlinkage long sym(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long) ;
#include <asm/syscalls_64.h>//第一次包含syscalls_64.h文件,其中的宏会被展开一次,例如__SYSCALL_COMMON(2, sys_open)会被展开成:
extern asmlinkage long sys_open(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long) ;
这表示申明
//取消__SYSCALL_64定义
#undef __SYSCALL_64
//第二次重新定义__SYSCALL_64
#define __SYSCALL_64(nr, sym) [ nr ] = sym,

extern asmlinkage long sys_ni_syscall(unsigned long, unsigned long, unsigned long, unsigned long, unsigned long, unsigned long);
const sys_call_ptr_t sys_call_table[] ____cacheline_aligned = {
    [0 ... __NR_syscall_max] = &sys_ni_syscall,//默认系统调用函数,什么都不干
#include <asm/syscalls_64.h>//包含前面生成文件
//第二次包含syscalls_64.h文件,其中的宏会被再展开一次,例如__SYSCALL_COMMON(2, sys_open)会被展开成:
[2] = sys_open, 用于初始化这个数组,即表示数组的第二个元素填入sys_open
};
int syscall_table_size = sizeof(sys_call_table);//系统调用表的大小

上述代码中,通过两次包含 syscalls_64.h 文件,并在其中分别定义不同的 __SYSCALL_64 宏,完成了系统调用函数的申明和系统调用表的初始化,不得不说这是一个非常巧妙的方式。

sys_call_table 数组,第一次全部初始化为默认系统调用函数 sys_ni_syscall,这个函数什么都不干,这是为了防止数组有些元素中没有函数地址,从而导致调用失败。这在内核中是非常危险的。我单独提示你这点,其实也是希望你留意这种编程技巧,这在内核编码中并不罕见,考虑到内核编程代码的安全性,加一道防线可以有备无患。

Linux系统调用实现

为了让你更好地理解 Linux 系统是如何工作的,我们为现有的 Linux 写一个系统调用。这个系统调用的功能并不复杂,就是返回你机器的 CPU 数量,即你的机器是多少核心的处理器。

下载 Linux 源码

想为 Linux 系统增加一个系统调用,首先你得有 Linux 内核源代码,如果你机器上没有 Linux 内核源代码,你就要去内核官网下载,或者你也可以到 GitHub 上 git clone 一份内核代码。
如果你使用了 git clone 的方式,可以用如下方式操作。

git clone git://git.kernel.org/pub/scm/linux/kernel/git/stable/linux-stable.git/

        克隆代码的时间比较长......

        如果你想尽量保持与我的 Linux 内核版本相同,降低出现各种未知问题的概率,那么请你使用 5.10.13 版本的内核。另外别忘了,如果你下载的 Linux 内核是压缩包,请记得先解压到一个可以访问的目录下。

申明系统调用

根据前面的知识点,可以得知 Linux 内核的系统调用的申明文件和信息,具体实现是这样的:由一个 makefile 在编译 Linux 系统内核时调用了一个脚本,这个脚本文件会读取另一个叫 syscall_64.tbl 文件,根据其中信息生成相应的文件 syscall_64.h。

请注意,我这里是以 x86_64 架构为例进行说明的,这里我们并不关注 syscall_64.h 的生成原理,只关注 syscall_64.tbl 文件中的内容。下面我们还是结合代码看一下吧。

//linux-5.10.13/arch/x86/entry/syscalls/syscall_64.tbl
0  common  read      sys_read
1  common  write      sys_write
2  common  open      sys_open
3  common  close      sys_close
4  common  stat      sys_newstat
5  common  fstat      sys_newfstat
6  common  lstat      sys_newlstat
7  common  poll      sys_poll
8  common  lseek      sys_lseek
9  common  mmap      sys_mmap
10  common  mprotect    sys_mprotect
11  common  munmap      sys_munmap
12  common  brk          sys_brk
//……
435  common  clone3      sys_clone3
436  common  close_range    sys_close_range
437  common  openat2      sys_openat2
438  common  pidfd_getfd    sys_pidfd_getfd
439  common  faccessat2    sys_faccessat2
440  common  process_madvise    sys_process_madvise

上面这些代码可以分成四列,分别是系统调用号、架构、服务名,以及其相对应的服务入口函数。例如系统调用 open 的结构,如下表所示。

那我们要如何申明自己的系统调用呢?第一步就需要在 syscall_64.tbl 文件中增加一项,如下所示。

441  common  get_cpus    sys_get_cpus

我们自己的系统调用的系统调用号是 441,架构是 common ,服务名称是 get_cpus,服务入口函数则是 sys_get_cpus。请注意系统调用号要唯一,不能和其它系统调用号冲突

写好这个,我们还需要把 sys_get_cpus 函数在 syscalls.h 文件中申明一下,供其它内核模块引用。具体代码如下所示。

//linux-5.10.13/include/linux/syscalls.h
asmlinkage long sys_get_cpus(void);

这一步做好之后,我们就完成了一个 Linux 系统调用的所有申明工作。下面我们就去定义这个系统调用的服务入口函数。

定义系统调用

我们现在来定义自己的第一个 Linux 系统调用,为了降低工程复杂度,我们不打算新建一个 C 模块文件,而是直接在 Linux 内核代码目录下挑一个已经存在的 C 模块文件,并在其中定义我们自己的系统调用函数。

定义一个系统调用函数,需要使用专门的宏。根据参数不同选用不同的宏,这个宏的细节我们无须关注。对于我们这个无参数的系统调用函数,应该使用 SYSCALL_DEFINE0 宏来定义,代码如下所示。

//linux-5.10.13/include/linux/syscalls.h
#ifndef SYSCALL_DEFINE0
#define SYSCALL_DEFINE0(sname)                  \
    SYSCALL_METADATA(_##sname, 0);              \
    asmlinkage long sys_##sname(void);          \
    ALLOW_ERROR_INJECTION(sys_##sname, ERRNO);      \
    asmlinkage long sys_##sname(void)
#endif /* SYSCALL_DEFINE0 */
//linux-5.10.13/kernel/sys.c
SYSCALL_DEFINE0(get_cpus)
{
    return num_present_cpus();//获取系统中有多少CPU
}

上述代码中 SYSCALL_DEFINE0 会将 get_cpus 转换成 sys_get_cpus 函数。这个函数中,调用了一个 Linux 内核中另一个函数 num_present_cpus,从名字就能推断出作用了,它负责返回系统 CPU 的数量。 这正是我们要达到的结果。这个结果最终会返回给调用这个系统调用的应用程序。

编译 Linux 内核(开发机器版本20.04 编译版本内核版本)

现在我们的 Linux 系统调用的代码,已经写好了,不过这跟编写内核模块还是不一样的。编写内核模块,我们只需要把内核模块动态加载到内核中,就可以直接使用了。系统调用发生在内核中,与内核是一体的,它无法独立成为可以加载的内核模块。所以我们需要重新编译内核,然后使用我们新编译的内核。

要编译内核首先是要配置内核,内核的配置操作非常简单,我们只需要源代码目录下执行“make menuconfig”指令,就会出现如下所示的界面。

安装:

sudo apt-get install ncurses-dev
sudo apt-get install flex
sudo apt-get install bison


配置Linux:

sudo make menuconfig

 

 

 

 

图中这些菜单都可以进入子菜单或者手动选择。

但是手动选择配置项非常麻烦且危险,如果不是资深的内核玩家,不建议手动配置!但是我们可以选择加载一个已经存在的配置文件,这个配置文件可以加载你机器上 boot 目录下的 config 开头的文件,加载之后选择 Save,就能保存配置并退出以上界面。

然后输入如下指令,就可以喝点茶、听听音乐,等待机器自行完成编译,编译的时间取决于机器的性能,快则十几分钟,慢则几个小时。

sudo apt-get install libelf-dev

sudo apt-get install libssl-dev

  在源码路径下执行:

sudo gedit .config

删掉双引号中的内容(保留双引号)

否则会报错:

编译内核错误——*** 没有规则可制作目标“debian/canonical-certs.pem”,由“certs/x509_certificate_list” 需求。 停止。

安装:

sudo apt install dwarves
sudo apt-get install zstd

执行:

make -j8 bzImage && make -j8 modules

上述代码指令干了哪些事儿呢?我来说一说,首先要编译内核,然后再编译内核模块,j8 表示开启 8 线程并行编译,这个你可以根据自己的机器 CPU 核心数量进行调整。编译过程结束之后就可以开始安装新内核了,你只需要在源代码目录下,执行如下指令。       

sudo make modules_install && sudo make install

 

上述代码指令先安装好内核模块,然后再安装内核,最后会调用 update-grub,自动生成启动选项,重启计算机就可以选择启动我们自己修改的 Linux 内核了。

编写应用测试

相信经过上述过程,你应该已经成功启动了修改过的新内核。不过我们还不确定我们增加的系统调用是不是正常的,所以我们还要写个应用程序测试一下,其实就是去调用一下我们增加的系统调用,看看结果是不是预期的。

应用程序代码如下所示。

#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
int main(int argc, char const *argv[])
{
    //syscall就是根据系统调用号调用相应的系统调用
    long cpus = syscall(441);
    printf("cpu num is:%d\n", cpus);//输出结果
    return 0;
}

对上述代码我们使用 gcc main.c -o cpus 指令进行编译,运行之后就可以看到结果了,但是我们没有写库代码,而是直接使用 syscall 函数。这个函数可以根据系统调用号触发系统调用,根据上面定义,441 正是对应咱们的 sys_get_cpus 系统调用。

小结

  1. 从 Linux 系统的 API 架构开始,我们了解了 glibc 库,这个库是大部分应用程序的基础,我们以其中的 open 函数为例,分析了库函数如何通过寄存器传递参数,最后执行 syscall 指令进入 Linux 内核,执行系统调用,最后还归纳出一幅 Linux 系统 API 框架图。

  2. 然后, 我们了解 Linux 系统中有多少个 API,它们都放在系统调用表中,同时也知道了 Linux 系统调用表的生成方式。

  3. 最后,为了验证我们了解的知识是否正确,我们从申明系统调用、定义系统调用到编译内核、编写应用测试,在现有的 Linux 代码中增加了一个属于我们自己的系统调用。

        好了,我们通过这节课搞清楚了 Linux 内核系统调用的实现原理。你是否感觉这和我们的 Cosmos 的系统服务有些相似,又有些不同?

相似的是我们都使用寄存器来传递参数,不同的是 Cosmos 使用了中断门进入内核,而 Linux 内核使用了更新的 syscall 指令。

        请说说 syscall 指令和 int 指令的区别,是什么?

        int指令和syscall指令都会发生特权级切换吧,但是syscall能直接调定位到具体系统调用函数,int则需要经过中断门描述符表和分发器才行。int的步骤要多一些,那就是取指令次数要多一些?还有个问题使用int来执行系统调用是不是也会遇到中断优先级的问题?

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值