在Linux这片广袤而深邃的技术海洋里,系统调用与 API 犹如两座灯塔,为无数开发者照亮前行的道路,指引着我们探索系统底层的奥秘并构建强大的应用程序。系统调用,作为用户空间与内核空间的沟通使者,赋予了应用程序直接向内核请求服务的能力。它宛如一把神奇的钥匙,能够开启内核深处诸如硬件控制、进程管理、内存调配等珍贵宝藏的大门。每一个系统调用都是对底层系统资源与功能的精准掌控,是构建稳定高效软件基石的关键材料。
而 API,则是在系统调用之上精心构建的编程利器。它犹如一位贴心的向导,以更加友好、便捷且抽象的方式,将系统调用的复杂性巧妙地隐藏起来,为开发者们提供了一片广阔的创作天地。通过 API,我们可以轻松地进行文件操作、网络通信、图形界面构建以及多线程编程等一系列丰富多彩的任务,极大地提高了开发效率,降低了开发难度。
一、概述
Linux 系统调用是用户空间程序与内核交互的桥梁,而 API 则是为开发者提供的更高级别的编程接口。两者在 Linux 系统中都起着至关重要的作用,共同为开发者提供了丰富的功能和便捷的开发环境。
首先,什么是系统调用呢?所谓系统调用是操作系统提供给用户程序调用的一组 “特殊” 接口,用户程序可以通过这组 “特殊” 接口来获得操作系统内核提供的服务。例如,用户可以通过进程控制相关的系统调用来创建进程、实现进程调度、进程管理等。同时,Linux 系统的保护机制规定了内核模式和用户模式,以及内核空间和用户空间。
为了对系统提供保护,内核模式可以执行一些特权指令和进入用户模式,而用户模式则不能。同样,Linux 将程序的运行空间也分为内核空间和用户空间,它们分别运行在不同的级别上,在逻辑上是相互隔离的。系统调用规定用户进程进入内核空间的具体位置。进行系统调用时,程序运行空间需要从用户空间进入内核空间,处理完毕后再返回到用户空间。
而 API,即应用程序编程接口,是操作系统向应用程序提供的编程接口。它的作用是屏蔽系统调用的各种细节,增加通用性和跨平台性。有了 API,开发者就不用考虑系统调用了,无论在任何平台、任何操作系统,只要它们的 API 是相同的,开发者的源码就是兼容的、跨平台的。
系统调用是偏底层、偏实现的,API 是偏上层、偏接口的。系统调用是实现在内核里的,它的修改只要符合内核的规范、只要内核的主要管理者同意就可以。API 首先是行业标准或者业内标准,是不能随意改变的,一般都有相应的标准委员会来制定和发展 API。
API 的实现是在用户空间库里面,一般都是在 libc 中实现。API 的底层实现一般使用的是系统调用,很多 API 和系统调用是一对一关系。但也有特殊情况,比如有的 API 并不使用系统调用,有的系统调用没有对应的 API,有的 API 可能调用了多个系统调用,有的系统调用可能被多个 API 使用。
1.1系统调用的来源与作用
我们先来看一下进程的虚拟内存空间布局,我们以32位为例,64位的逻辑也是一样的。
可以看到一个进程的内存空间分为用户空间和内核空间两部分。每个进程都有自己独立的用户空间,但是所有进程都共享同一个内核空间,所以所有进程都可以请求内核的服务。不过内核空间运行在特权级,用户空间运行在非特权级,所以用户空间是不能直接访问内核空间的。为此,内核向用户空间提供了有限制的访问,系统调用。用户空间可以通过系统调用来调用内核里一些特定的函数。这样的话,进程就可以通过系统调用来请求内核的服务了。
1.2 API的来源与作用
既然有了系统调用,进程可以通过系统调用来请求内核的服务,那么为什么还会有API呢?因为系统调用是偏底层的,有很多细节要处理,而且不同的平台其系统调用并不相同;就算是同一个平台,其提供的系统调用功能以及系统调用的实现方法都有可能会发生变化。
因此为了屏蔽系统调用的各种细节,增加通用性和跨平台性,操作系统又向用户进程提供了API。API,Application Programming Interface,应用程序编程接口,它的意思就是它的字面意思,就是指操作系统向应用程序提供的编程接口。现实中有很多人把API当做I(Interface)接口的意思来用,本文所说的API都是指它的本意。有了API你就不用考虑系统调用了,无论在任何平台、任何OS,你只管使用API,只要它们的API是相同的,你的源码就是兼容的、跨平台的。
1.3 API与系统调用的关系
API和系统调用具体是什么关系呢?系统调用是偏底层、偏实现的,API是偏上层、偏接口的。系统调用是实现在内核里的,它的修改只要符合内核的规范、只要内核的主要管理者同意就可以。API它首先是行业标准或者业内标准,是不能随意改变的,一般都有相应的标准委员会来制定和发展API。API的实现是在用户空间库里面,一般都是在libc中实现。API的底层实现一般使用的是系统调用,很多API和系统调用是一对一关系。
但也有特殊情况,比如有的API并不使用系统调用,有的系统调用没有对应的API,有的API可能调用了多个系统调用,有的系统调用可能被多个API使用。也就是说大部分情况下API和系统调用是1:1的关系,但有些情况下是1:0、0:1、1:n、n:1、m:n的关系。当API和系统调用的关系是1:1,而且它们的名字也相同时,我们不能把它们看做是同一个事物,而应当把它们看做不同的事物,只不过是名字相同而已,是同名的API使用了同名的系统调用。
就好比有两种情况,第一种情况是,有两个人都叫张伟,一个是副市长,一个是公安局局长,张伟副市长安排张伟局长去做某件事情。第二种情况是,有一个人叫张伟,他是副市长兼任公安局局长,张伟副市长兼局长去做某件事情。这两种情况是不一样的,同名的API与系统调用的关系类似于前者。
下面我们举例来说明一下API与系统调用的关系。我们来写一个最简单的hello world程序,代码如下。
#include <unistd.h>
#include <string.h>
int main(int argc, char *argv[])
{
char str[] = "hello, world\n";
write(1, str, strlen(str));
}
编译:gcc -o hello hello.c
运行:./hello
会在屏幕上输出 hello, world。
这个程序非常简单,我们调用了两个API(strlen 和 write),在屏幕上输出了一行文字。同样是API,strlen没有使用系统调用,自己直接在用户空间就把功能实现了,而write API则使用了write系统调用。有些API的功能比较简单,自己在用户空间就能实现,没必要麻烦内核。但是有些API的功能在用户空间是不可能实现或者很难实现的,必须要求助于内核。我们把write API与write系统调用画成图,如下所示:
API函数通过系统调用机制调用系统调用函数。那么系统调用机制要做的事情有哪些呢?有两件事,一是实现CPU特权级的转变,把CPU设置为特权模式之后才能执行内核的代码。二是传递系统调用的编号和函数参数,系统调用函数有很多,怎么知道你想调用的是哪个系统调用函数呢,通过编号来区分。系统调用函数大部分都是有参数的,所以还需要传递参数,参数怎么传递是和具体硬件相关的,由相应的ABI来规定。
1.4 系统调用机制的基本原理
那么系统调用机制该怎么实现呢?答案是要靠CPU提供的特殊指令(系统调用指令)来实现,虽然不同架构的CPU实现不尽相同,但是大概模式都是一样的,都是往某个寄存器写入系统调用编号,在约定的寄存器或者栈上写入参数,然后调用特殊指令(系统调用指令),此时CPU就会切换到特权模式并进入内核执行一段预先设定的代码(系统调用入口函数),这段代码会根据系统调用编号调用相应的系统调用函数。画成图如下所示:
二、Linux API的介绍
红帽企业提供了许多 API 接口,帮助开发者更好地利用 Linux 操作系统的功能和特性。这些 API 涵盖了文件系统、进程管理、网络通信、设备驱动等各个方面,为开发者提供了丰富的功能和特性。
红帽企业(Red Hat)是一家以 Linux 操作系统为基础的软件服务公司,被广大开发者和企业用户所熟知。红帽企业在推广和维护 Linux 操作系统的过程中,提供了许多 API 接口来帮助开发者更好地利用 Linux 操作系统的功能和特性。
在 Linux 内核中,API 的设计是非常严谨和灵活的,开发者可以通过 API 访问各种系统资源,如文件系统、网络、内存管理等,实现各种功能。红帽作为一家提供商用 Linux 发行版的公司,也为开发者提供了一些特有的 API,以满足企业级用户的需求。
一般来说,Linux 内核 API 可以分为几个层次,包括系统调用、函数库、设备驱动等。红帽为了简化开发过程,提供了一些高层次的 API,如 GLib、DBus 等。这些 API 可以帮助开发者更快速地开发应用程序,并与 Linux 系统进行交互。
除了提供 API 外,红帽还提供了一些工具和框架,帮助开发者更好地理解和使用 Linux 内核 API。例如,红帽的 Developer Toolset 提供了一些最新的开发工具,如 GCC、GDB 等,方便开发者进行调试和优化。此外,红帽还提供了一些示例代码和文档,帮助开发者快速上手。
在开发基于 Linux 的应用程序时,了解和熟悉 Linux 内核 API 是非常重要的。通过使用红帽提供的 API 和工具,开发者可以更高效地开发出高质量的应用程序,满足用户的需求。
红帽 Linux 的 API 手册包含了丰富的内容,帮助开发者轻松地进行系统编程和应用开发工作。在红帽 Linux 的 API 手册中,开发者可以找到各种系统调用的使用方法和参数说明,了解库函数的功能和用法,掌握命令的选项和用法等。同时,API 手册还包含了大量的实例代码,帮助开发者更好地理解和应用系统的各种功能。
通过学习和使用红帽 Linux 的 API 手册,开发者可以更加高效地进行系统编程和应用开发工作。他们可以快速地查找到所需的信息,避免重复劳动,提高开发效率和质量。
除此之外,红帽 Linux 的 API 手册还提供了丰富的文档和教程,帮助开发者深入理解系统的工作原理和机制,掌握最佳的开发实践。这些文档和教程包括了系统架构、内核功能、进程管理、文件系统、网络编程等方面的内容,帮助开发者全面了解系统并快速上手开发工作。
三、系统调用的实现
系统调用机制的实现原理都是相同的,但是不同操作系统、不同硬件平台上的实现细节又不尽相同。下面我们分别来讲一下Linux在x86平台和arm平台上实现细节。
3.1 x86平台的实现
X86平台的系统调用的实现方法经历了三代的变迁,每次改变都提高了系统调用的执行效率。第一代系统调用指令,借用了中断机制的指令,int 0x80、iret。第二代系统调用指令sysenter、sysexit。第三代系统调用指令syscall、sysret。三代指令在内核中的使用情况如下图所示:
⑴指令基本原理
第一代系统调用指令使用的是中断指令,基本原理如下。中断发生时,CPU会切换到特权模式并跳到内核执行预先指定的一段程序。执行哪段程序呢,要根据中断源来决定,不同的中断源执行不同的程序,每个中断源都对应一个整数来标识自己,这个整数就叫做中断向量。中断源有三类,外设中断、CPU异常、指令中断,前两种都有自己的方法来指定中断向量,指令中断是在指令的操作数里面指定中断向量号的。
我们的系统调用就是利用指令中断,用向量号0x80,也就是十进制的128当做自己的中断向量,来执行系统调用的。我们在用户空间,先把系统调用编号赋值给寄存器EAX,然后执行int 0x80,CPU就会跳转到内核执行内核预先设定的中断处理程序(也就是系统调用入口函数)。系统调用入口函数根据EAX的值调用对应的系统调用函数。系统调用函数执行完成之后返回系统调用入口函数,入口函数再执行iret返回到用户空间,一个系统调用就完成了。
第二代系统调用指令sysenter/sysexit,由于通过中断流程进行系统调用开销太大了,很多操作对系统调用来说又是没有意义的,因此Intel专门开发了只用于系统调用的指令。由于sysenter是专用指令,它可以把很多中断相关的操作都省略掉,具体来说有以下几点,1.不再自动把寄存器信息保存到内核栈上,2.不再自动从内核栈上加载esp的值,3.不再走中断处理流程。
使用sysenter指令需要提前设置一些MSR寄存器,具体来说要做以下一些设置。把内核代码段的选择符写入MSR IA32_SYSENTER_CS,把系统调用入口函数写入MSR IA32_SYSENTER_EIP,内核栈段的选择符要放在紧挨着内核栈段的后面,把内核栈的地址写入MSR IA32_SYSENTER_ESP,这样sysenter执行时CPU就会切换到特权模式,然后执行系统调用入口函数。在执行sysexit之前把要返回到的用户空间指令的地址写入EDX,用户空间栈的值写入ECX。
sysenter/sysexit指令也可以用于64位模式,但是Linux选择在64位上只使用syscall/sysret。
第三代系统调用指令syscall/sysret,是AMD开发的,它只能用于64位模式,比sysenter/sysexit还要快一些,因为1.它不再保存和恢复用户空间RSP,2.它只能用于平坦内存,因此省略了分段单元的开销。
使用syscall/sysret前要提前设置一些MSR。要在MSR IA32_STAR中设置内核空间和用户空间的代码段,其中内核空间CS、SS在47:32位,用户空间CS、SS在63:48位。系统调用入口函数的地址要写人MSR IA32_LSTR。syscall执行的时候会把MSR IA32_STAR的47:32位加载到CS和SS,把MSR IA32_LSTR的值加载到RIP。在执行sysret之前把要返回到的用户空间指令的地址写入RCX,sysret执行时会把MSR IA32_STAR的63:48位加载到CS和SS,把RCX加载到RIP。
⑵系统调用编号
我们先来解决第一个问题,系统调用编号是怎么确定的。不同架构不同位数的系统,系统调用编号是不一样的。如果用户空间传递的系统调用编号和内核里的系统调用编号对不上,那问题就严重了。Linux内核在编译时会生成一个文件,arch/x86/include/generated/uapi/asm/unistd_64.h,这个文件是生成的,不是本来就有的,这个文件里面有所有系统调用的编号。
在安装操作系统时或者单独安装内核和内核头文件时,这个文件会被安装在/usr/include/asm/unistd_64.h,libc会使用这个文件,这样用户空间传递的编号和内核里面的系统调用编号就是一致的了。
⑶系统调用入口函数
下面我们来说说系统调用入口函数是怎么设置的。X86_64对于64位的进程来说只有一个系统调用指令,就是syscall,它的入口函数在linux-src/arch/x86/entry/entry_64.S, 函数名叫entry_SYSCALL_64。对于32位的进程来说有三个系统调用指令 int 0x80、sysenter、syscall,它们的入口函数都在 linux-src/arch/x86/entry/entry_64_compat.S,函数名分别叫做entry_INT80_compat、entry_SYSENTER_compat、entry_SYSCALL_compat。设置它们的代码在两个地方,syscall(64)、syscall(32)、sysenter 这三个设置在一个地方,在文件linux-src/arch/x86/kernel/cpu/common.c中的函数 syscall_init
#ifdef CONFIG_X86_64
void syscall_init(void)
{
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);
#ifdef CONFIG_IA32_EMULATION
wrmsrl(MSR_CSTAR, (unsigned long)entry_SYSCALL_compat);
/*
* This only works on Intel CPUs.
* On AMD CPUs these MSRs are 32-bit, CPU truncates MSR_IA32_SYSENTER_EIP.
* This does not cause SYSENTER to jump to the wrong location, because
* AMD doesn't allow SYSENTER in long mode (either 32- or 64-bit).
*/
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)__KERNEL_CS);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP,
(unsigned long)(cpu_entry_stack(smp_processor_id()) + 1));
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, (u64)entry_SYSENTER_compat);
#else
wrmsrl(MSR_CSTAR, (unsigned long)ignore_sysret);
wrmsrl_safe(MSR_IA32_SYSENTER_CS, (u64)GDT_ENTRY_INVALID_SEG);
wrmsrl_safe(MSR_IA32_SYSENTER_ESP, 0ULL);
wrmsrl_safe(MSR_IA32_SYSENTER_EIP, 0ULL);
#endif
/*
* Flags to clear on syscall; clear as much as possible
* to minimize user space-kernel interference.
*/
wrmsrl(MSR_SYSCALL_MASK,
X86_EFLAGS_CF|X86_EFLAGS_PF|X86_EFLAGS_AF|
X86_EFLAGS_ZF|X86_EFLAGS_SF|X86_EFLAGS_TF|
X86_EFLAGS_IF|X86_EFLAGS_DF|X86_EFLAGS_OF|
X86_EFLAGS_IOPL|X86_EFLAGS_NT|X86_EFLAGS_RF|
X86_EFLAGS_AC|X86_EFLAGS_ID);
}
#else /* CONFIG_X86_64 */
......
#endif /* CONFIG_X86_64 */
从代码中可以看出只有在64位的情况下才会设置syscall指令的入口函数,只有在系统兼容32位进程(CONFIG_IA32_EMULATION)的情况下才会设置syscall(32)、sysenter的兼容入口函数。大部分linux发行版都支持32位进程兼容。
兼容int 0x80的代码设置在另外一个地方,因为int 0x80是中断指令,所以它是在设置中断的地方设置的,具体位置是linux-src/arch/x86/kernel/idt.c中的函数idt_setup_traps。
tatic const __initconst struct idt_data def_idts[] = {
INTG(X86_TRAP_DE, asm_exc_divide_error),
ISTG(X86_TRAP_NMI, asm_exc_nmi, IST_INDEX_NMI),
INTG(X86_TRAP_BR, asm_exc_bounds),
INTG(X86_TRAP_UD, asm_exc_invalid_op),
INTG(X86_TRAP_NM, asm_exc_device_not_available),
INTG(X86_TRAP_OLD_MF, asm_exc_coproc_segment_overrun),
INTG(X86_TRAP_TS, asm_exc_invalid_tss),
INTG(X86_TRAP_NP, asm_exc_segment_not_present),
INTG(X86_TRAP_SS, asm_exc_stack_segment),
INTG(X86_TRAP_GP, asm_exc_general_protection),
INTG(X86_TRAP_SPURIOUS, asm_exc_spurious_interrupt_bug),
INTG(X86_TRAP_MF, asm_exc_coprocessor_error),
INTG(X86_TRAP_AC, asm_exc_alignment_check),
INTG(X86_TRAP_XF, asm_exc_simd_coprocessor_error),
#ifdef CONFIG_X86_32
TSKG(X86_TRAP_DF, GDT_ENTRY_DOUBLEFAULT_TSS),
#else
ISTG(X86_TRAP_DF, asm_exc_double_fault, IST_INDEX_DF),
#endif
ISTG(X86_TRAP_DB, asm_exc_debug, IST_INDEX_DB),
#ifdef CONFIG_X86_MCE
ISTG(X86_TRAP_MC, asm_exc_machine_check, IST_INDEX_MCE),
#endif
#ifdef CONFIG_AMD_MEM_ENCRYPT
ISTG(X86_TRAP_VC, asm_exc_vmm_communication, IST_INDEX_VC),
#endif
SYSG(X86_TRAP_OF, asm_exc_overflow),
#if defined(CONFIG_IA32_EMULATION)
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_compat),
#elif defined(CONFIG_X86_32)
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_32),
#endif
};
void __init idt_setup_traps(void)
{
idt_setup_from_table(idt_table, def_idts, ARRAY_SIZE(def_idts), true);
}
从代码中可以看出,只有系统支持32位进程兼容(CONFIG_IA32_EMULATION)才会去设置entry_INT80_compat。
我们设置好了这些系统调用指令的入口函数之后,当用户空间调用这些指令的时候就会调用这些函数。那么这些函数又是怎样去调用具体对应的系统调用函数呢?我们以64位进程的syscall指令为例来看一看。先看它的入口函数,linux-src/arch/x86/entry/entry_64.S:entry_SYSCALL_64
SYM_CODE_START(entry_SYSCALL_64)
pushq $__USER_DS /* pt_regs->ss */
pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */
pushq %r11 /* pt_regs->flags */
pushq $__USER_CS /* pt_regs->cs */
pushq %rcx /* pt_regs->ip */
pushq %rax /* pt_regs->orig_ax */
call do_syscall_64 /* returns with IRQs disabled */
sysretq
SYM_CODE_END(entry_SYSCALL_64)
我们对代码做了精简只留下最关键的。可以看到函数先把__USER_DS和__USER_CS都push到了栈上,这是为了执行最后面的那条sysretq时可以返回用户空间把特权级也转为用户级。函数的主体就是调用函数do_syscall_64,我们再来看一个这个函数,linux-src/arch/x86/entry/common.c
static __always_inline bool do_syscall_x64(struct pt_regs *regs, int nr)
{
/*
* Convert negative numbers to very high and thus out of range
* numbers for comparisons.
*/
unsigned int unr = nr;
if (likely(unr < NR_syscalls)) {
unr = array_index_nospec(unr, NR_syscalls);
regs->ax = sys_call_table[unr](regs);
return true;
}
return false;
}
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
add_random_kstack_offset();
nr = syscall_enter_from_user_mode(regs, nr);
instrumentation_begin();
if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) {
/* Invalid system call, but still a system call. */
regs->ax = __x64_sys_ni_syscall(regs);
}
instrumentation_end();
syscall_exit_to_user_mode(regs);
}
可以看到do_syscall_64就是调用do_syscall_x64,do_syscall_x64就是根据用户空间传来的系统调用编号在sys_call_table数组中调用相应的函数。那么这个sys_call_table数组是怎么来的呢?它是在文件linux-5.15.28/arch/x86/entry/syscall_64.c中定义的,如下:
#include <linux/linkage.h>
#include <linux/sys.h>
#include <linux/cache.h>
#include <linux/syscalls.h>
#include <asm/syscall.h>
#define __SYSCALL(nr, sym) extern long __x64_##sym(const struct pt_regs *);
#include <asm/syscalls_64.h>
#undef __SYSCALL
#define __SYSCALL(nr, sym) __x64_##sym,
asmlinkage const sys_call_ptr_t sys_call_table[] = {
#include <asm/syscalls_64.h>
};
那么syscalls_64.h的内容是什么,它是怎么来的呢?这个文件并不是手写的,而是在编译时由脚本生成的,它是根据文件linux-src/arch/x86/entry/syscalls/syscall_64.tbl 生成的。我们截取一段syscalls_64.h的内容如下:
__SYSCALL(0, sys_read)
__SYSCALL(1, sys_write)
__SYSCALL(2, sys_open)
__SYSCALL(3, sys_close)
__SYSCALL(4, sys_newstat)
__SYSCALL(5, sys_newfstat)
__SYSCALL(6, sys_newlstat)
__SYSCALL(7, sys_poll)
__SYSCALL(8, sys_lseek)
__SYSCALL(9, sys_mmap)
......
__SYSCALL(442, sys_mount_setattr)
__SYSCALL(443, sys_quotactl_fd)
__SYSCALL(444, sys_landlock_create_ruleset)
__SYSCALL(445, sys_landlock_add_rule)
__SYSCALL(446, sys_landlock_restrict_self)
__SYSCALL(447, sys_memfd_secret)
__SYSCALL(448, sys_process_mrelease)
对syscall_64.c进行预编译之后我们可以发现sys_call_table数组的内容如下:
const sys_call_ptr_t sys_call_table[] = {
__x64_sys_read,
__x64_sys_write,
__x64_sys_open,
__x64_sys_close,
__x64_sys_newstat,
__x64_sys_newfstat,
__x64_sys_newlstat,
__x64_sys_poll,
__x64_sys_lseek,
__x64_sys_mmap,
__x64_sys_mprotect,
__x64_sys_munmap,
__x64_sys_brk,
......
__x64_sys_openat2,
__x64_sys_pidfd_getfd,
__x64_sys_faccessat2,
__x64_sys_process_madvise,
__x64_sys_epoll_pwait2,
__x64_sys_mount_setattr,
__x64_sys_quotactl_fd,
__x64_sys_landlock_create_ruleset,
__x64_sys_landlock_add_rule,
__x64_sys_landlock_restrict_self,
__x64_sys_memfd_secret,
__x64_sys_process_mrelease,
};
也就是说这是由一堆函数名构成的函数指针数组,那么这些函数名是怎么生成的呢?它是由一系列的SYSCALL_DEFINEx宏生成的,x代表函数的参数个数。我们以open系统调用来讲解一下,open系统调用的实现是在文件linux-src/fs/open.c
long do_sys_open(int dfd, const char __user *filename, int flags, umode_t mode)
{
struct open_how how = build_open_how(flags, mode);
return do_sys_openat2(dfd, filename, &how);
}
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
我们把宏SYSCALL_DEFINE3展开之后大致可以得到如下的代码:
long __x64_sys_open(const struct pt_regs *regs)
{ return __se_sys_open(regs->di, regs->si, regs->dx); }
long __ia32_sys_open(const struct pt_regs *regs)
{ return __se_sys_open((unsigned int)regs->bx, (unsigned int)regs->cx, (unsigned int)regs->dx); }
static long __se_sys_open(__typeof(filename), __typeof(flags), __typeof(mode) )
{
long ret = __do_sys_open(( const char *) filename, ( int) flags, ( umode_t) mode);
return ret;
}
long __do_sys_open(const char * filename, int flags, umode_t mode)
{
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
可以看出这个宏会生成函数__x64_sys_open,这个函数正好是sys_call_table数组里面的函数名。__x64_sys_open接受的参数是一个寄存器集的指针,然后提取寄存器中的值再调用函数__se_sys_open,函数__se_sys_open对参数进行强转再调用__do_sys_open,这个函数是最终的函数。
我们可以看到这里面还生成了函数__ia32_sys_open,这个函数是32位进程兼容的系统调用所使用的数组ia32_sys_call_table的成员。
⑷汇编程序演示
下面我们用汇编语言来试一试执行系统调用,一般情况下我们都不会直接使用系统调用指令,下面的例子仅仅是为了演示,标准编程中请使用API。
.data
msg:
.ascii "Hello from syscall !\n"
len = . - msg
.text
.global _start
_start:
movq $1, %rax
movq $1, %rdi
movq $msg, %rsi
movq $len, %rdx
syscall
movq $60, %rax
xorq %rdi, %rdi
syscall
执行如下命令,先汇编后链接
gcc -c -o hello-syscall64.o hello-syscall64.S
ld -entry _start hello-syscall64.o -o hello-syscall64
然后运行程序
./hello-syscall64
可以看到运行成功,命令行输出了 Hello from syscall !
下面我们再来演示一下32位进程兼容模式的系统调用,汇编代码如下:
.data
msg1:
.ascii "Hello from int 0x80 !\n"
len1 = . - msg1
msg2:
.ascii "Hello from sysenter !\n"
len2 = . - msg2
.text
.globl _start
_start:
movl $4, %eax
movl $1, %ebx
movl $msg1, %ecx
movl $len1, %edx
int $0x80
movl $4, %eax
movl $1, %ebx
movl $msg2, %ecx
movl $len2, %edx
call sys
movl $1, %eax
movl $0, %ebx
int $0x80
sys:
pushl %ecx
pushl %edx
pushl %ebp
movl %esp, %ebp
sysenter
popl %ebp
popl %edx
popl %ecx
ret
执行如下命令,先汇编后链接
gcc -m32 -c -o hello-syscall32.o hello-syscall32.S
ld -melf_i386 -entry _start hello-syscall32.o -o hello-syscall32
然后运行程序
./hello-syscall32
可以看到运行成功,命令行输出了
Hello from int 0x80 !
Hello from sysenter !
从上面的汇编代码示例中我们看到了用户空间是如何调用系统调用的,这也正是libc中的做法。我们前面有个内容没有讲,那就是执行了系统调用指令,CPU是如何切换到特权模式的。其实前面的系统调用入口函数设置里面也在相应的寄存器里面设置了__KERNEL_CS,这个会导致CPU切到特权模式来执行。
⑸vsyscall与vdso
最刚开始的时候只有一种系统调用方式int 0x80,这时候libc都是直接使用这个指令。后来有个sysenter系统调用指令,libc就要考虑系统有没有sysenter指令,有的话就用sysenter,没有的话就用int 0x80。
但是这对libc来说太难了,因此内核想了一个办法,把内核的一个page设置为用户空间可访问的,叫做vsyscall,libc通过这个vsyscall来进行系统调用,就不用有那么复杂的考虑了。对于内核来说,如果CPU支持sysenter并且内核自己也支持sysenter,就把vsyscall设置为sysenter,否则就设置为int 0x80。
这对内核来说是一件非常简单的事。后来人们发现可以把一些系统调用的函数放到vsyscall里面,如果获取系统时间,这是一个只读的操作,而且对系统没有啥影响,放到vsyscall之后,libc就可以直接调用了,没有额外的开销。
后来人们又觉得vsyscall的地址在内核空间,而且vsyscall没有一定的格式,这不太好。于是又开发了vdso,它是so的格式,在进程创建的时候映射到进程的地址空间,这样进程就可以像使用so一样使用vdso。再后来,64位的进程下只有一个系统调用指令,vsyscall的最初的作用就没有了意义,所以64位进程下的vsyscall和vdso就没有了系统调用指令兼容层的功能,就只剩下了可以直接调用一些系统调用函数的功能。
3.2 ARM平台的实现
⑴ 指令基本原理
ARM 平台系统调用的实现依赖于特定的指令基本原理。在 ARM 系统上,通过软中断指令(swi)进行系统调用。
⑵系统调用编号
系统调用有很多,通过系统调用编号来区分。在进行系统调用之前,须将编号写入特定的寄存器。例如,在 ARM 版本的 Linux 中,系统调用号的获取方式有多种情况。如果是 CONFIG_OABI_COMPAT 配置,对于 thumb 模式下,会判断 r8 寄存器的值来确定是否为 thumb OABI 仿真,若不是,则从 [lr, #-4] 获取 SWI 指令中的立即数,即系统调用号;对于非 thumb 模式,直接从 [lr, #-4] 获取系统调用号,并通过与 0x0f000000 进行与操作和比较来检查是否为 SWI。如果是 CONFIG_AEABI 配置,则直接从 [lr, #-4] 获取 SWI 指令,然后通过与 0x0f000000 进行与操作和比较来检查是否为 SWI,若不是 SWI 则认为是通过将系统调用号写入 r7 寄存器(纯 EABI 用户空间总是将系统调用号放入 scno,即 r7 寄存器)的方式进行系统调用。
如果是 CONFIG_ARM_THUMB 且为遗留 ABI 模式,会判断 r8 寄存器的值来确定是否为 thumb 模式,若是 thumb 模式,则将 r7 寄存器的值加上__NR_SYSCALL_BASE 作为系统调用号,若不是 thumb 模式,则从 [lr, #-4] 获取系统调用号。如果不是 CONFIG_ARM_THUMB 且为遗留 ABI 模式,则直接从 [lr, #-4] 获取系统调用号,并通过与 0x0f000000 进行与操作和比较来检查是否为 SWI,若不是则报错。
⑶系统调用入口函数
在 ARM 系统上,系统调用入口函数通常是通过特定的异常处理程序实现的。ARM 版本的 Linux 的系统调用的实现是通过发一个 SWI 异常中断的方式实现的,通过 SWI 异常可以实现用户空间陷入到内核的操作。
通常 SWI 异常中断处理程序可以分为两级:第一级 SWI 异常中断处理程序为汇编语言,用于确定 SWI 中的 24 位立即数;第二级 SWI 异常中断处理程序实现 SWI 的各个功能。在进入 SWI 异常时,LR 寄存器保存的是 SWI 指令的下一条,所以,SWI 指令应该是:LDR R0,[LR, # -4] 取 SWI 指令的 24 位立即数,BIC R0,R0,#0XFF000000。在 linux 系统调用时,采用该两行代码取得系统调用号。具体的实现代码如下:
AREA TopLevelSWI, CODE, READONLY
EXPORT SWI_Handler
SWI_Handler
STMFD sp!, [r0-r12, lr]
LDR r0, [lr, # -4]
BIC r0, r0, #0xff000000
BLC C_SWI_Handler //使用 R0 寄存器中的值,调用相应的 SWI 异常中断的第二级处理程序。
LDMFD sp!, [r0-r12, pc]^
END
Void C_SWI_Handler(unsigned number)
{
Switch(number)
Case 0://执行分支 0;
Break;
Case 1://执行分支 1;
Break;
Default:break
}
此外,linux SWI 函数 vector_swi 也是系统调用入口函数的重要部分,其代码如下:
.align 5
ENTRY(vector_swi)
sub sp, sp, #S_FRAME_SIZE
stmia sp, {r0 - r12} @ Calling r0 - r12
add r8, sp, #S_PC
stmdb r8, {sp, lr}^ @ Calling sp, lr
mrs r8, spsr @ called from non-FIQ mode, so ok.
.str lr, [sp, #S_PC] @ Save calling PC
.str r8, [sp, #S_PSR] @ Save CPSR
.str r0, [sp, #S_OLD_R0] @ Save OLD_R0
zero_fp
// 保存寄存器值到栈中
/** Get the system call number.*/
#if defined(CONFIG_OABI_COMPAT)
/** If we have CONFIG_OABI_COMPAT then we need to look at the swi
* value to determine if it is an EABI or an old ABI call.*/
#ifdef CONFIG_ARM_THUMB
tst r8, #PSR_T_BIT
movne r10, #0 @ no thumb OABI emulation
ldreq r10, [lr, #-4] @ get SWI instruction,取 swi 指令中的立即数,即系统调用号
#else
ldr r10, [lr, #-4] @ get SWI instruction // here get syscall num swiA710( and ip, r10, #0x0f000000 @ check for SWI )
A710( teq ip, #0x0f000000 )
A710( bne.Larm710bug )
#endif
#elif defined(CONFIG_AEABI)
/** Pure EABI user space always put syscall number into scno (r7).*/
A710( ldr ip, [lr, #-4] @ get SWI instruction )
A710( and ip, ip, #0x0f000000 @ check for SWI )
A710( teq ip, #0x0f000000 )
A710( bne.Larm710bug )
#elif defined(CONFIG_ARM_THUMB)
/* Legacy ABI only, possibly thumb mode. */
tst r8, #PSR_T_BIT @ this is SPSR from save_user_regs
addne scno, r7, #__NR_SYSCALL_BASE @ put OS number in /*__NR_SYSCALL_BASE*/
ldreq scno, [lr, #-4] // scno 是寄存器 r7 的别名
#else
/* Legacy ABI only. */
ldr scno, [lr, #-4] @ get SWI instruction
A710( and ip, scno, #0x0f000000 @ check for SWI )
A710( teq ip, #0x0f000000 )
A710( bne.Larm710bug )
#endif
#ifdef CONFIG_ALIGNMENT_TRAP
ldr ip, __cr_alignment
ldr ip, [ip]
mcr p15,0, ip, c1, c0 @ update control register
#endif
enable_irq
get_thread_info tsk
ldr ip, [tsk, #TI_FLAGS] @ check for syscall tracing
/***/
#endif
stmdb sp!
四、Linux API的应用场景
4.1文件操作
在 Linux 系统中,文件操作是非常常见的应用场景。通过使用诸如open、close、read、write、fstat等函数,可以方便地进行文件的打开、关闭、读写以及获取文件属性等操作。例如,使用open函数可以打开一个文件,指定文件的访问模式和权限;使用read和write函数可以对打开的文件进行读写操作;而fstat函数则可以获取文件的状态信息,如文件大小、创建时间等。这些函数为开发者提供了强大的文件操作能力,使得在 Linux 系统中进行文件管理变得更加高效和便捷。
4.2网络编程
Linux 的套接字 API 为网络编程提供了强大的支持。通过使用socket、bind、listen等函数,可以创建套接字并进行网络通信。
例如,使用socket函数创建一个套接字,指定通信协议和地址族;使用bind函数将套接字绑定到特定的地址和端口;使用listen函数开始监听连接请求。此外,还可以使用网络文件系统 API 访问远程计算机上的文件资源,实现分布式文件系统的功能。
4.3图形界面与多线程编程
Linux 支持 X Window 系统 API 和 POSIX 线程 API,为图形界面和多线程编程提供了便利。在图形界面方面,X Window 系统 API 允许开发者创建和管理图形窗口、处理用户输入等。在多线程编程方面,POSIX 线程 API 提供了一系列函数,如pthread_create、pthread_join、pthread_exit等,用于创建、等待和终止线程。
例如,使用pthread_create函数可以创建一个新的线程,指定线程的启动函数和参数;使用pthread_join函数可以等待一个线程的结束,并获取其返回值;使用pthread_exit函数可以终止当前线程的执行。通过这些函数,开发者可以实现多线程应用程序,提高程序的并发性和响应性。
4.3DRM API 的应用
Linux 的 DRM(Direct Rendering Manager)API 在图形加速、嵌入式开发、高性能计算等领域有着广泛的应用。例如,在图形加速方面,DRM API 可以直接访问图形硬件,实现高效的图形渲染;在嵌入式开发中,DRM API 可以用于管理显示设备,实现低功耗和高性能的图形显示;在高性能计算领域,DRM API 可以与其他技术结合,实现大规模并行计算的图形可视化。
此外,开源项目如 “drm_doc How to write a Linux DRM application” 为开发者提供了学习和实践 DRM API 的宝贵资源,通过一系列详尽的文章和完整的代码实例,引领开发者深入了解 DRM API 的奥秘,掌握其在不同应用场景中的使用方法。
五、Linux系统调用与API的区别
5.1系统调用的来源与作用
系统调用是应用程序和内核间的桥梁,是应用程序访问内核的入口点。用户空间可以通过系统调用来调用内核里的特定函数,实现对硬件的控制、设置系统状态或读取内核数据等功能。在 Linux 中,系统调用是由内核提供的一系列强大的函数,例如 open、read、write 等用于文件操作的系统调用,fork、exec 等用于进程管理的系统调用。这些系统调用使得用户程序能够与内核进行交互,完成各种底层操作。
系统调用的实现需要硬件的特殊支持,通常是通过软中断或特定的指令来实现从用户态到内核态的切换。当用户程序需要执行诸如文件操作、网络通信、进程管理等不能直接由用户空间代码执行的操作时,它们会通过系统调用来请求内核代为完成这些操作。
5.2API 的来源与作用
API 是操作系统向应用程序提供的编程接口,为了屏蔽系统调用的各种细节,增加通用性和跨平台性。API 的实现是在用户空间库里面,一般都是在 libc 中实现。
有了 API,开发者就不用考虑系统调用了,无论在任何平台、任何操作系统,只要它们的 API 是相同的,开发者的源码就是兼容的、跨平台的。例如,在不同的操作系统上,可能系统调用的实现方式不同,但如果 API 保持一致,开发者可以使用相同的代码实现相同的功能,而无需关心底层系统调用的差异。
5.3API 与系统调用的关系
大部分情况下 API 和系统调用是一对一关系,但也有特殊情况,如有的 API 并不使用系统调用,有的系统调用没有对应的 API,有的 API 可能调用了多个系统调用,有的系统调用可能被多个 API 使用。
例如,在一个最简单的 “hello world” 程序中,我们调用了两个 API(strlen 和 write),其中 strlen 没有使用系统调用,自己直接在用户空间就把功能实现了,而 write API 则使用了 write 系统调用。有些 API 的功能比较简单,自己在用户空间就能实现,没必要麻烦内核;但是有些 API 的功能在用户空间是不可能实现或者很难实现的,必须要求助于内核。
系统调用是偏底层、偏实现的,API 是偏上层、偏接口的。系统调用是实现在内核里的,它的修改只要符合内核的规范、只要内核的主要管理者同意就可以。而 API 首先是行业标准或者业内标准,是不能随意改变的,一般都有相应的标准委员会来制定和发展 API。
Linux 的系统 API 一般是一个或者多个系统调用的组合实现,需要 include <unistd.h>。某些 C 库函数不依赖任何系统调用,比如 atoi、strcpy 等,无需向内核请求任何资源;某些 C 库函数强依赖系统调用,比如 malloc、socket 等。某些 linux API 可以 C 库的形式调用,比如 fopen --> sys_open、sys_mmap、sys_write、sys_close。
六、深入理解Linux系统调用的方法
6.1系统调用号查询
在 Linux 系统中,可以通过查阅特定的头文件来获取系统调用号。例如,在 /usr/include/asm/unistd.h 或 /usr/include/asm-generic/unistd.h 中可以找到系统调用号的常量定义。同时,也可以查阅在线文档,如 http://asm.sourceforge.net/syscall.html 和 https://syscalls.w3challs.com/(分为 32 位和 64 位,还包括其他架构的系统调用号查询)以及 https://syscall.sh/(可查询 arm64 的系统调用号),来获取系统调用号的信息。
6.2以 lseek 函数为例
lseek 函数是一个用于改变读写一个文件时读写指针位置的系统调用。
⑴作用
调整文件读写位置:可以将文件读写指针移动到文件开头、当前位置或末尾,实现随机读写文件的功能。
计算文件大小:通过将文件指针移动到文件末尾,返回的偏移量就是文件开头到目前读写位置的长度,即文件大小。
作为文件拓展:可以通过设置偏移量和适当的参数,实现文件的拓展。
⑵使用方法
①C 语言调用:包含头文件#include <sys/types.h>和#include <unistd.h>。
函数原型为off_t lseek(int fd, off_t offset, int whence);,其中fd是文件描述符,offset是偏移量,whence是起始位置,可以是SEEK_SET(文件开始处)、SEEK_CUR(当前读写位置)或者SEEK_END(文件结束处)。
例如,将读写位置移到文件开头可以使用lseek(int fildes,0, SEEK_SET);;移到文件尾可以使用lseek(int fildes,0, SEEK_END);;取得目前文件位置可以使用lseek(int fildes,0, SEEK_CUR);。
②汇编语言调用:通过将系统调用号sys_lseek(在某些架构中,如 x86,系统调用号可以在特定的表中查询到,例如在 /linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl 文件中,8 号系统调用为 lseek)放入寄存器中,并将文件描述符、偏移量和参考位置等参数放入相应的寄存器,然后触发系统调用。
以open函数为例,在 C 语言中使用open函数打开文件时,可以指定文件的访问模式和权限。一般以阻塞打开,如果要变成非阻塞,可以添加open第二个参数的第三个可选项O_NONBLOCK,或者使用fcntl函数设置文件描述符为非阻塞模式。
6.3gdb 调试和分析
通过制作根文件系统,可以使用 gdb 调试工具对系统调用进行分析,了解系统调用的执行过程和内核堆栈状态的变化。
⑴制作根文件系统
下载并编译内核,如从 https://www.kernel.org/pub/linux/kernel/ 下载内核源码,进行编译配置,选中内核调试相关选项,如在make menuconfig中选中kernel hacking -> Kernel debugging、kernel hacking -> Compile-time checks and compiler options -> compile the kernel with debug info和compile the kernel with frame pointers等。
制作 busybox 所需要的 rootfs,执行一系列命令在指定目录下创建 rootfs,安装 busybox,并将其安装到 rootfs 下。
启动 qemu 进行调试,使用 qemu-system-x86_64 命令启动虚拟机,并通过gdbserver tcp::1234和ddd vmlinux等命令在另一个终端进行调试连接。
⑵使用 gdb 调试系统调用
在 gdb 中,可以设置断点、查看源代码、打印表达式、监视表达式等。例如,可以使用break命令设置断点,如break func在函数入口处设置断点;使用print命令打印变量的值,如print a显示变量a的值;使用watch命令设置监视点,一旦被监视的表达式的值改变,gdb 将强行终止正在被调试的程序。
以 lseek 为例,可以通过汇编指令触发该系统调用,然后在 gdb 中设置断点,如b __x64_sys_lseek,并通过c命令使内核进行执行,此时程序会停在断点处,可以通过bt命令查看堆栈信息,连续输入n可以查看 lseek 的执行过程。
七、系统调用的总结
Linux 系统调用是用户空间程序与内核进行交互的重要接口,它允许应用程序请求内核服务,如文件操作、进程管理、内存管理等。以下是一些常见的 Linux 系统调用的详细解释:
⑴进程管理相关系统调用
fork():用于创建一个新的进程,新进程几乎是父进程的副本。它返回两次值,在父进程中返回子进程的 PID,在子进程中返回 0。通过这种方式,父子进程可以根据返回值执行不同的代码路径。例如,在一个简单的多进程程序中,父进程可以创建子进程后继续执行其他任务,而子进程可以执行特定的功能,如处理网络连接或计算任务。
execve():在当前进程的上下文中执行一个新的程序。它会替换当前进程的地址空间和执行代码,使进程开始执行新的可执行文件。这在需要启动其他程序的场景中非常有用,比如在一个命令行解释器中,当用户输入一个命令时,解释器会使用execve来执行对应的可执行文件。
wait () 和 waitpid ():用于等待子进程结束,并获取子进程的退出状态。父进程可以使用这些系统调用来同步子进程的执行,确保子进程完成任务后再进行后续操作。例如,在一个服务器程序中,父进程创建子进程处理客户端请求,然后使用waitpid等待子进程完成处理并回收子进程的资源,防止僵尸进程的产生。
⑵文件管理相关系统调用
open():用于打开或创建一个文件。它接受文件名和打开模式等参数,返回一个文件描述符,后续的文件读写操作都将基于这个文件描述符进行。例如,open("test.txt", O_RDONLY)会以只读模式打开名为test.txt的文件,如果文件不存在则返回错误。
read () 和 write ():分别用于从文件描述符指向的文件中读取数据和向文件中写入数据。read函数从文件中读取指定字节数的数据到缓冲区中,write函数则将缓冲区中的数据写入文件。例如,read(fd, buffer, sizeof(buffer))会尝试从文件描述符fd对应的文件中读取sizeof(buffer)字节的数据到buffer中。
close():关闭一个已打开的文件,释放相关的文件描述符和内核资源。当文件操作完成后,必须使用close系统调用来确保资源的正确回收,避免资源泄漏。例如,close(fd)会关闭文件描述符fd对应的文件。
⑶内存管理相关系统调用
brk () 和 sbrk ():用于调整进程数据段的大小,实现堆内存的动态分配。brk系统调用设置进程数据段的结束地址,sbrk则通过相对偏移量来调整数据段大小。例如,sbrk(1024)会将进程的堆内存增加 1024 字节,以便程序可以动态分配更多的内存来存储数据。
mmap () 和 munmap ():mmap用于将文件或设备映射到进程的虚拟地址空间,这样可以直接通过内存操作来访问文件或设备,提高读写效率。munmap则用于解除这种映射关系。例如,在一些数据库系统中,可以使用mmap将数据库文件映射到内存中,以便快速访问数据,在不需要时再使用munmap解除映射。
⑷信号处理相关系统调用
signal () 和 sigaction ():用于设置信号处理函数,当进程接收到特定信号时,会执行相应的处理函数。signal是一个较旧且简单的信号处理函数设置接口,sigaction则提供了更强大和灵活的信号处理机制,包括可以设置信号的掩码、获取信号的详细信息等。例如,signal(SIGINT, my_handler)会将SIGINT信号(通常由用户按下 Ctrl+C 产生)的处理函数设置为my_handler。
⑸进程间通信相关系统调用
pipe():创建一个管道,用于在具有亲缘关系的进程(如父子进程)之间进行单向数据通信。管道有两个文件描述符,一个用于读数据,一个用于写数据。例如,父进程可以创建管道后,通过fork创建子进程,然后父子进程分别使用管道的读写端进行数据传递,如父进程向管道写入数据,子进程从管道读取数据。
shmget ()、shmat ()、shmdt () 和 shmctl ():这组系统调用用于创建、使用和管理共享内存区域。多个进程可以通过共享内存实现高效的数据共享,shmget用于创建或获取共享内存段的标识符,shmat将共享内存段映射到进程的地址空间,shmdt解除映射,shmctl对共享内存段进行控制操作,如获取共享内存段的状态或删除共享内存段。例如,在一个多进程的图像处理程序中,多个进程可以通过共享内存共享图像数据,提高处理效率。