learn-tech/专栏/操作系统实战45讲/42瞧一瞧Linux:如何实现系统API?.md
2024-10-16 06:37:41 +08:00

20 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        42 瞧一瞧Linux如何实现系统API
                        你好我是LMOS。

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

不知道你是否好奇过业内成熟的Linux内核又是怎样为应用程序提供服务的呢

这节课我们就来看看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指令。

好了我们已经了解了这个open函数的调用流程如果用一幅图来展示Linux内核API的架构就会呈现后面这个样子。

有了前面代码流程分析和结构示意图我想你会对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 <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系统调用表也清楚了Linux系统调用的个数和定义一个Linux系统调用的方式。

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

为Linux增加一个系统调用其实有很多步骤不过也别慌下面我将一步一步为你讲解。

下载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内核

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

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

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

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

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

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系统调用。

至此在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我们下节课见。