# 42 | 瞧一瞧Linux:如何实现系统API? 你好,我是LMOS。 上节课,我们通过实现一个获取时间的系统服务,学习了Cosmos里如何建立一个系统服务接口。Cosmos为应用程序提供服务的过程大致是这样的:应用程序先设置服务参数,然后通过int指令进入内核,由Cosmos内核运行相应的服务函数,最后为应用程序提供所需服务。 不知道你是否好奇过业内成熟的Linux内核,又是怎样为应用程序提供服务的呢? 这节课我们就来看看Linux内核是如何实现这一过程的,我们首先了解一下Linux内核有多少API接口,然后了解一下Linux内核API接口的架构,最后,我们动手为Linux内核增加一个全新的API,并实现相应的功能。 下面让我们开始吧!这节课的配套代码你可以从[这里](https://gitee.com/lmos/cosmos/tree/master/lesson42)下载。 ## Linux内核API接口的架构 在上节课中,我们已经熟悉了我们自己的Cosmos内核服务接口的架构,由应用程序调用库函数,再由库函数调用API入口函数,进入内核函数执行系统服务。 其实对于Linux内核也是一样,应用程序会调用库函数,在库函数中调用API入口函数,触发中断进入Linux内核执行系统调用,完成相应的功能服务。 在Linux内核之上,使用最广泛的C库是glibc,其中包括C标准库的实现,也包括所有和系统API对应的库接口函数。几乎所有C程序都要调用glibc的库函数,所以**glibc是Linux内核上C程序运行的基础。** 下面我们以open库函数为例分析一下,看看open是如何进入Linux内核调用相关的系统调用的。glibc虽然开源了,但是并没有在Linux内核代码之中,你需要从[这里](https://www.gnu.org/software/libc/sources.html)下载并解压,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指令。 好了,我们已经了解了这个open函数的调用流程,如果用一幅图来展示Linux内核API的架构,就会呈现后面这个样子。 ![](https://static001.geekbang.org/resource/image/03/8f/03yy161484ed15837f58a4283b960c8f.jpg?wh=3363x2822 "LinuxAPI框架") 有了前面代码流程分析和结构示意图,我想你会对Linux内核API的框架结构加深了解。上图中的系统调用表和许多sys\_xxxx函数你可能不太明白,别担心,我们后面就会讲到。 那么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系统调用表 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 //第一次包含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 //包含前面生成文件 //第二次包含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系统调用表,也清楚了Linux系统调用的个数和定义一个Linux系统调用的方式。 为了让你更好地理解Linux系统是如何工作的,我们为现有的Linux写一个系统调用。这个系统调用的功能并不复杂,就是返回你机器的CPU数量,即你的机器是多少核心的处理器。 为Linux增加一个系统调用,其实有很多步骤,不过也别慌,下面我将一步一步为你讲解。 ### 下载Linux源码 想为Linux系统增加一个系统调用,首先你得有Linux内核源代码,如果你机器上没有Linux内核源代码,你就要去[内核官网](https://www.kernel.org/)下载,或者你也可以到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的结构,如下表所示。 ![](https://static001.geekbang.org/resource/image/77/f4/777e8e56b151b5812c48e06d861408f4.jpg?wh=1483x541) 那我们要如何申明自己的系统调用呢?第一步就需要在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内核 现在我们的Linux系统调用的代码,已经写好了,不过这跟编写内核模块还是不一样的。编写内核模块,我们只需要把内核模块动态加载到内核中,就可以直接使用了。系统调用发生在内核中,与内核是一体的,它无法独立成为可以加载的内核模块。所以我们需要重新编译内核,然后使用我们新编译的内核。 要编译内核首先是要配置内核,内核的配置操作非常简单,我们只需要源代码目录下执行“make menuconfig”指令,就会出现如下所示的界面。 ![](https://static001.geekbang.org/resource/image/66/96/66597887b59b30d73ff300249e47e296.jpg?wh=836x679 " 配置Linux") 图中这些菜单都可以进入子菜单或者手动选择。 但是手动选择配置项非常麻烦且危险,**如果不是资深的内核玩家,不建议手动配置!**但是我们可以选择加载一个已经存在的配置文件,这个配置文件可以加载你机器上boot目录下的config开头的文件,加载之后选择Save,就能保存配置并退出以上界面。 然后输入如下指令,就可以喝点茶、听听音乐,等待机器自行完成编译,编译的时间取决于机器的性能,快则十几分钟,慢则几个小时。 ``` make -j8 bzImage && make -j8 modules ``` 上述代码指令干了哪些事儿呢?我来说一说,首先要编译内核,然后再编译内核模块,j8表示开启8线程并行编译,这个你可以根据自己的机器CPU核心数量进行调整。 编译过程结束之后就可以开始安装新内核了,你只需要在源代码目录下,执行如下指令。 ``` sudo make modules_install && sudo make install ``` 上述代码指令先安装好内核模块,然后再安装内核,最后会调用update-grub,自动生成启动选项,重启计算机就可以选择启动我们自己修改的Linux内核了。 ### 编写应用测试 相信经过上述过程,你应该已经成功启动了修改过的新内核。不过我们还不确定我们增加的系统调用是不是正常的,所以我们还要写个应用程序测试一下,其实就是去调用一下我们增加的系统调用,看看结果是不是预期的。 我们应用程序代码如下所示。 ``` #include #include #include 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系统调用。 至此,在Linux系统上增加自己的系统调用这个实验,我们就完成了。 ## 重点回顾 今天我们从了解Linux系统的API架构开始,最后在Linux系统上实现了一个自己的系统调用,虽然增加一个系统调用步骤不少,但你只要紧跟着我的思路一定可以拿下。 下面我来为你梳理一下课程的重点。 1.从Linux系统的API架构开始,我们了解了glibc库,这个库是大部分应用程序的基础,我们以其中的open函数为例,分析了库函数如何通过寄存器传递参数,最后执行syscall指令进入Linux内核,执行系统调用,最后还归纳出一幅Linux系统API框架图。 2.然后,我们了解Linux系统中有多少个API,它们都放在系统调用表中,同时也知道了Linux系统调用表的生成方式。 3.最后,为了验证我们了解的知识是否正确,我们从申明系统调用、定义系统调用到编译内核、编写应用测试,在现有的Linux代码中增加了一个属于我们自己的系统调用。 好了,我们通过这节课搞清楚了Linux内核系统调用的实现原理。你是否感觉这和我们的Cosmos的系统服务有些相似,又有些不同? 相似的是我们都使用寄存器来传递参数,不同的是Cosmos使用了中断门进入内核,而Linux内核使用了更新的syscall指令。有了这些知识储备,我也非常期待你能动手拓展,挑战一下在Cosmos上实现使用syscall触发系统调用。 ## 思考题 请说说syscall指令和int指令的区别,是什么? 欢迎你在留言区跟我交流互动,也推荐你把这节课分享给有需要的朋友,一起实现操作系统里的各种功能。 我是LMOS,我们下节课见。