深入 ARM-Linux 的系统调用世界

发布于:2025-07-01 ⋅ 阅读:(19) ⋅ 点赞:(0)

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 COMPAT
	syscall_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_startsyscall_table_endNATIVE 这三个宏,接下来就一个个解析。

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> 为例):

  1. /usr/include/asm/unistd.h(如果目标架构提供了自定义)
  2. 否则 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)

网站公告

今日签到

点亮在社区的每一天
去签到