深入 ARM-Linux 的系统调用世界
1、引言
本篇文章以 ARM 架构为例,进行讲解。需要读者有一定的 ARM 架构基础
在操作系统的世界中,系统调用(System Call)是用户空间与内核空间沟通的桥梁。用户态程序如 ls、cp 或你的 C 程序,无权直接操作硬件、访问文件系统或调度进程,它们必须通过系统调用,让内核代为完成这些敏感任务。
a. 为什么需要系统调用?
计算机系统通常被分为两个运行级别:
- 内核态(Kernel Mode):有最高权限,可以直接操作硬件资源
- 用户态(User Mode):权限受限,只能通过内核提供的接口间接访问资源
如果允许用户程序直接访问硬件,系统将变得混乱、极不安全。因此,操作系统提供了一套安全的、受控的接口:系统调用。这既保证了安全性,也为跨平台移植性打下基础。
b. 系统调用的作用与设计目的
系统调用的设计是为了解决以下核心问题:
- 权限隔离:防止用户程序破坏系统稳定性
- 抽象封装:统一对硬件的访问接口(如网络、磁盘、内存)
- 标准化接口:方便编译器、语言运行时与硬件解耦
例如,当你在 C 程序中调用 write(),它本质上会通过系统调用将数据写入文件描述符对应的设备或文件。这背后,Linux 内核通过系统调用号查找对应的内核函数,在内核态完成实际的写入工作。
c. 一个简单示例:write()
#include <unistd.h>int main() {write(1, "Hello, ARM-Linux!\n", 18);return 0;
}
这个程序将字符串输出到标准输出(文件描述符 1,对应终端)。虽然我们只写了一行代码,实际发生的事情包括:
- 编译器将 write() 转换为一次系统调用指令(如 ARM 的 svc #0)
- 系统调用号被放入特定寄存器(如 r7),参数通过寄存器传递
- CPU 从用户态切换到内核态,执行系统调用号对应的 sys_write 函数
- 执行完成后返回结果,再切回用户态
2、系统调用的初始化
2.1 sys_call_table 系统调用符号表
了解了系统调用的基本概念之后,我们同样需要知道,系统调用有哪些?
系统调用由内核中定义的一个静态数组描述的,这个数组名为 sys_call_table
,系统调用的数量由 NR_syscalls
这个宏描述,默认为 400,也可以手动地修改,这些系统调用的定义在 entry-common.S
中:
Linux/arch/arm/kernel/entry-common.S 文件中有这样一段代码:
/** This is the syscall table declaration for native ABI syscalls.* With EABI a couple syscalls are obsolete and defined as sys_ni_syscall.*/syscall_table_start sys_call_table
#define COMPAT(nr, native, compat) syscall nr, native
#ifdef CONFIG_AEABI
#include <calls-eabi.S>
#else
#include <calls-oabi.S>
#endif
#undef COMPATsyscall_table_end sys_call_table
calls-eabi.S
是在构建过程中由 Linux 内核构建系统根据 syscall.tbl
自动生成的系统调用表定义文件,并不会在源码中直接出现。它为 ARM 架构的 EABI 系统提供系统调用分发表,通过 sys_call_table
引用,用于系统调用调度。
以 ARM 架构为例:
输入文件:
arch/arm/tools/syscall.tbl
格式是这样的:
nr abi name entry-point [compat]
0 common restart_syscall sys_restart_syscall
1 common exit sys_exit
2 common fork sys_fork
3 common read sys_read
4 common write sys_write
5 common open sys_open
6 common close sys_close
...
脚本生成:
使用构建脚本如:
scripts/syscall-generate.sh
生成结果放在:
arch/arm/kernel/calls-eabi.S
生成结果如下:
NATIVE(0, sys_restart_syscall)
NATIVE(1, sys_exit)
NATIVE(2, sys_fork)
NATIVE(3, sys_read)
NATIVE(4, sys_write)
NATIVE(5, sys_open)
NATIVE(6, sys_close)
......
2.2 sys_call_table 的创建
Linux/arch/arm/kernel/entry-common.S 文件中,有三个部分需要关注,syscall_table_start
、syscall_table_end
和 NATIVE
这三个宏,接下来就一个个解析。
syscall_table_start:
.macro syscall_table_start, sym.equ __sys_nr, 0.type \sym, #object
ENTRY(\sym)
.endm
含义:
- 宏定义名:syscall_table_start,带一个参数 sym(表名)
- __sys_nr 是当前的系统调用编号,从 0 开始
- .type \sym, #object 告诉调试器这是一个对象变量,例如数组、变量等(符号表使用)
- ENTRY(\sym) 展开为 .global \sym; \sym:,即导出符号
📌 示例:
如果写 syscall_table_start sys_call_table,就等价于:
.global sys_call_table
.type sys_call_table, #object
sys_call_table:
__sys_nr = 0
简要概括就是:
- 创建一个 sys_call_table 的符号并使用 .globl 导出到全局符号
- sys_call_table 是一个对象变量,可以理解为一个数组的首地址
- 定义一个内部符号 __sys_nr,初始化为 0,这个变量主要用于后续系统调用好的计算和判断
NATIVE:
.macro syscall, nr, func.ifgt __sys_nr - \nr.error "Duplicated/unorded system call entry".endif.rept \nr - __sys_nr.long sys_ni_syscall.endr.long \func.equ __sys_nr, \nr + 1
.endm#define NATIVE(nr, func) syscall nr, func
含义:
- 插入一个系统调用函数(func)到编号为 nr 的位置
- 做检查:如果当前编号比传入编号大,说明 syscall 编号是无序或重复的,编译报错
- 如果 nr > __sys_nr,则插入空洞(sys_ni_syscall),表示之前的系统调用未定义
- 然后插入 .long \func,即 syscall 的函数地址
- 最后更新 __sys_nr
📌 示例:
NATIVE(5, sys_open)
如果当前编号是 0,则展开为:
.long sys_restart_syscall; index 0
.long sys_exit; index 1
......
.long sys_open; index 5
.long sys_open 表示: “生成一个占用 4 字节的值,这个值是 sys_open 的地址”。
接下来就是系统调用的收尾部分:syscall_table_end sys_call_table,传入的参数为 sys_call_table,它也是通过宏实现的:
.macro syscall_table_end, sym.ifgt __sys_nr - __NR_syscalls.error "System call table too big".endif.rept __NR_syscalls - __sys_nr.long sys_ni_syscall.endr.size \sym, . - \sym
.endm
含义:
- 检查当前 syscall 编号是否超过 __NR_syscalls(系统调用总数)
- 如果没有填满表,则用 sys_ni_syscall 补齐
- 最后通过 .size 设置
sys_call_table
对象变量的大小(也就是数组的大小)
总结:
上面的这套宏系统的作用,在汇编文件 entry-common.S
中定义了一个 sys_call_table
表。表中是各样的系统调用号以及其对应的内核接口地址。
3、系统调用的产生
系统调用尽管是由用户空间产生的,但是在日常的编程工作中我们并不会直接使用系统调用,只知道在使用诸如 read、write 函数时,对应的系统调用就会产生,实际上,发起系统调用的真正工作封装在 C 库中,要查看系统调用的产生细节,一方面可以查看 C 库,另一方面也可以查看编译时的汇编代码。
如果想通过查看编译时的汇编代码,找到系统调用的细节,编译时必须使用静态链接 libc.a 库
3.1 glibc
既然系统调用基本都是封装在 glibc 中,最直接的方法就是看看它们的源代码实现,因为只是探究系统调用的流程,找一个相对简单的函数作为示例即可,这里以 close 为例,下载的源代码版本为 glibc-2.30。
glibc 作为 GNU 的标准 C 程序库,在使用 gcc 编译目标文件时,默认是会去链接 libc.so/libc.a 这种 glibc 库的
close 的定义在 close.c 中:
int __close (int fd)
{return SYSCALL_CANCEL (close, fd);
}
SYSCALL_CANCEL
是一个宏,被定义在 sysdeps/unix/sysdep.h
中,由于该宏的嵌套有些复杂,全部贴上来进行解析并没有太多必要,就只贴上它的调用路径:
SYSCALL_CANCEL->INLINE_SYSCALL_CALL->__INLINE_SYSCALL_DISP->__INLINE_SYSCALLn(n=1~7)->INLINE_SYSCALL
对于不同的架构,INLINE_SYSCALL
有不同的实现,毕竟系统调用指令完全是硬件相关指令,可以想到其最后的定义肯定是架构相关的,而 arm 平台的 INLINE_SYSCALL
实现在 sysdeps/unix/sysv/linux/arm/sysdep.h:
INLINE_SYSCALL->INTERNAL_SYSCALL->INTERNAL_SYSCALL_RAW
整个实现流程几乎全部由宏实现,在最后的 INTERNAL_SYSCALL_RAW
中,执行了以下的指令:
...
# define INTERNAL_SYSCALL_RAW(name, err, nr, args...) \({ \register int _a1 asm ("r0"), _nr asm ("r7"); \LOAD_ARGS_##nr (args) \_nr = name; \asm volatile ("swi 0x0 @ syscall " #name \: "=r" (_a1) \: "r" (_nr) ASM_ARGS_##nr \: "memory"); \_a1; })
...
其中的 swi 指令正是执行系统调用的软中断指令,在新版的 arm 架构中,使用 svc 指令代替 swi,这两者是别名的关系,没有什么区别。
这里需要区分系统调用和普通函数调用,对于普通函数调用而言,前四个参数被保存在 r0~r3 中,其它的参数被保存在栈上进行传递。
但是在系统调用中,swi(svc) 指令将会引起处理器模式的切换,user->svc,而 svc 模式下的 sp 和 user 模式下的 sp 并不是同一个,因此无法使用栈直接进行传递,从而需要将所有的参数保存在寄存器中进行传递,在内核文件 include/linux/syscall.h 中定义了系统调用相关的函数和宏,其中 SYSCALL_DEFINE_MAXARGS
表示系统调用支持的最多参数值,在 arm 下为 6,也就是 arm 中系统调用最多支持 6 个参数,分别保存在 r0~r5 中。
glibc 库是如何知道每个函数对应的系统调用号的呢?在内核构建过程中,有一个脚本叫 scripts/syscallhdr.sh 会将 syscall.tbl 中的内容转成 unistd.h 中的一行行:
#define __NR_close 6
#define __NR_open 5
#define __NR_openat 56
…
glibc 通过包含 Linux 内核提供的头文件(unistd.h),在编译时就知道每个系统调用对应的 syscall number
3.2 Linux 内核中系统调用的处理
svc 指令实际上是一条软件中断指令,也是从用户空间主动到内核空间的唯一通路(被动可以通过中断、其它异常) 相对应的处理器模式为从 user 模式到 svc 模式,svc 指令执行系统调用的大致流程为:
- 执行 svc 指令,产生软中断,跳转到系统中断向量表的 svc 向量处执行指令
- 保存用户模式下的程序断点信息,以便系统调用返回时可以恢复用户进程的执行
- 根据传入的系统调用号(r7)确定内核中需要执行的系统调用,比如 read 对应 sys_read(从第二章节我们创建的 sys_call_table 符号表中寻找函数入口)
- 执行完系统调用之后返回到用户进程,继续执行用户程序
上述只是一个简化的流程,省去了很多的实现细节以及在真实操作系统中的多进程环境,不过通过这些可以建立一个对于系统调用大致的概念。
4、拓展
4.1 ARM64 架构下 sys_call_table 的创建
和 ARM 不同,ARM64 采取头文件声明(数组)的方式,看起来更简单、更清晰。对于 ARM64 架构,sys_call_table
的构建如下:
arch/arm64/kernel/sys.c:
/** Wrappers to pass the pt_regs argument.*/
#define __arm64_sys_personality __arm64_sys_arm64_personality#undef __SYSCALL
#define __SYSCALL(nr, sym) asmlinkage long __arm64_##sym(const struct pt_regs *);
#include <asm/unistd.h>#undef __SYSCALL
#define __SYSCALL(nr, sym) [nr] = __arm64_##sym,const syscall_fn_t sys_call_table[__NR_syscalls] = {[0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall,
#include <asm/unistd.h>
};
arch/arm64/include/asm/unistd.h:
#ifndef __COMPAT_SYSCALL_NR
#include <uapi/asm/unistd.h>
#endif
arch/arm64/include/uapi/asm/unistd.h:
#define __ARCH_WANT_RENAMEAT
#define __ARCH_WANT_NEW_STAT
#define __ARCH_WANT_SET_GET_RLIMIT
#define __ARCH_WANT_TIME32_SYSCALLS
#define __ARCH_WANT_SYS_CLONE3#include <asm-generic/unistd.h>
include/uapi/asm-generic/unistd.h:(这里是 Linux 默认的 unistd.h 文件,ARM 架构没有使用这个)
......
#define __NR_io_setup 0
__SC_COMP(__NR_io_setup, sys_io_setup, compat_sys_io_setup)
#define __NR_io_destroy 1
__SYSCALL(__NR_io_destroy, sys_io_destroy)
#define __NR_io_submit 2
__SC_COMP(__NR_io_submit, sys_io_submit, compat_sys_io_submit)
......
/* fs/read_write.c */
#define __NR3264_lseek 62
__SC_3264(__NR3264_lseek, sys_llseek, sys_lseek)
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
#define __NR_write 64
__SYSCALL(__NR_write, sys_write)
......
上面的头文件相互包含,关系有点乱,需要仔细理一下。这里直接给出结论,上面文件展开如下:
const syscall_fn_t sys_call_table[__NR_syscalls] = {[0] = __arm64_compat_sys_io_setup,[1] = __arm64_sys_io_destroy,...[63] = __arm64_sys_read,...[__NR_syscalls - 1] = xxx,};
至此我们就得到了完整的 sys_call_table
。
关于表中为什么没有 sys_open,Linux 内核从 2.6.16 起,就逐步推荐使用 openat() 代替 open()。所以只有 sys_openat。在用户空间仍可用 open 函数,由 libc 实现为对 openat() 的封装
4.2 关于 unistd.h 文件
关于上面众多的 unistd.h
,这里简要说明一下
- unistd = UNIX + standard
- 所以 unistd.h = “UNIX 标准头文件”
它是 POSIX 标准中定义的头文件之一,用于提供 UNIX 系统调用接口的声明。
unistd.h 里面都包含什么?
它主要包含:
- 系统调用声明(如 read, write, fork, exec, pipe, chdir, getpid, 等等)
- 标准常量(如 _POSIX_VERSION)
- 宏定义(如 STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO)
- 系统调用号(某些平台上,如 Linux 下的 _NR* 宏)
它可以看作是与操作系统交互的通用入口。在不同的 Unix-like 系统中,unistd.h 提供一致的编程接口。
4.3 Linux 系统下查看系统调用号
usr/include/asm-generic/
是供用户空间程序使用的 头文件集合。其中的 unistd.h
定义系统调用号。
Linux 为了支持多种 CPU 架构(x86、ARM、MIPS、RISCV 等),采用以下策略:
头文件查找顺序(以 #include <asm/unistd.h> 为例):
- /usr/include/asm/unistd.h(如果目标架构提供了自定义)
- 否则 fallback 到:/usr/include/asm-generic/unistd.h
例如,以 rockchip rk3568 为例:
root@firefly:/usr/include/asm-generic# cat unistd.h
/* SPDX-License-Identifier: GPL-2.0 WITH Linux-syscall-note */
#include <asm/bitsperlong.h>/** This file contains the system call numbers, based on the* layout of the x86-64 architecture, which embeds the* pointer to the syscall in the table.** As a basic principle, no duplication of functionality* should be added, e.g. we don't use lseek when llseek* is present. New architectures should use this file* and implement the less feature-full calls in user space.*/#ifndef __SYSCALL
#define __SYSCALL(x, y)
#endif#if __BITS_PER_LONG == 32 || defined(__SYSCALL_COMPAT)
#define __SC_3264(_nr, _32, _64) __SYSCALL(_nr, _32)
#else
#define __SC_3264(_nr, _32, _64) __SYSCALL(_nr, _64)
#endif#ifdef __SYSCALL_COMPAT
#define __SC_COMP(_nr, _sys, _comp) __SYSCALL(_nr, _comp)
#define __SC_COMP_3264(_nr, _32, _64, _comp) __SYSCALL(_nr, _comp)
#else
#define __SC_COMP(_nr, _sys, _comp) __SYSCALL(_nr, _sys)
#define __SC_COMP_3264(_nr, _32, _64, _comp) __SC_3264(_nr, _32, _64)
#endif#define __NR_io_setup 0
__SC_COMP(__NR_io_setup, sys_io_setup, compat_sys_io_setup)
#define __NR_io_destroy 1
__SYSCALL(__NR_io_destroy, sys_io_destroy)
#define __NR_io_submit 2
__SC_COMP(__NR_io_submit, sys_io_submit, compat_sys_io_submit)
#define __NR_io_cancel 3
__SYSCALL(__NR_io_cancel, sys_io_cancel)
#if defined(__ARCH_WANT_TIME32_SYSCALLS) || __BITS_PER_LONG != 32
#define __NR_io_getevents 4
__SC_3264(__NR_io_getevents, sys_io_getevents_time32, sys_io_getevents)
#endif/* fs/xattr.c */
#define __NR_setxattr 5
__SYSCALL(__NR_setxattr, sys_setxattr)
#define __NR_lsetxattr 6
__SYSCALL(__NR_lsetxattr, sys_lsetxattr)
#define __NR_fsetxattr 7
__SYSCALL(__NR_fsetxattr, sys_fsetxattr)
#define __NR_getxattr 8
__SYSCALL(__NR_getxattr, sys_getxattr)
#define __NR_lgetxattr 9
__SYSCALL(__NR_lgetxattr, sys_lgetxattr)
#define __NR_fgetxattr 10
__SYSCALL(__NR_fgetxattr, sys_fgetxattr)
#define __NR_listxattr 11
__SYSCALL(__NR_listxattr, sys_listxattr)
#define __NR_llistxattr 12
__SYSCALL(__NR_llistxattr, sys_llistxattr)
#define __NR_flistxattr 13
__SYSCALL(__NR_flistxattr, sys_flistxattr)
#define __NR_removexattr 14
__SYSCALL(__NR_removexattr, sys_removexattr)
#define __NR_lremovexattr 15
__SYSCALL(__NR_lremovexattr, sys_lremovexattr)
#define __NR_fremovexattr 16
__SYSCALL(__NR_fremovexattr, sys_fremovexattr)