Linux 系统中断与系统调用 --3rd


先来说一说

  • Intel x86 CPU 能够运行在两种模式下,real mode(实模式)和protected mode(保护模式)#区别#。现代的所有操作系统都使用的是保护模式来使内核和一般进程隔离。从80386(32位CPU)开始,电脑启动的时候是实模式,等操作系统启动后就运行在保护模式.

  • 保护模式提供了4个不同的权限等级(ring0-ring4),用户应用程序在ring3,系统内核运行在ring0。所以系统内核有访问所有CPU寄存器和硬件内存的权力。

中断

分类

中断主要分为同步中断和异步中断

  • 同步中断(在一个指令执行完成后,由CPU控制单元产生的)称为“异常
    分为处理器产生的异常(Faults/错误–指令执行错误, Traps/陷阱,abort/异常终止)和编程安排的异常(即软中断,INT, INTO, INT3, BOUND触发)

  • 异步中断/外部中断(可能会在任意时刻由其他硬件(外围I/O设备)产生的)称为“中断
    分为可屏蔽中断和不可屏蔽中断(Non-Maskable interrupts/NMI)

详细对比请看这里
硬中断属于异步中断(参考维基百科),个人觉得软中断属于同步中断

中断向量

每个中断或异常常用一个0-255的数字识别。Intel称这个数字为中断向量。

  • 0-31:Intel保留的异常或者不可屏蔽中断

  • 32-255:programmable by OS

Linux使用软中断(0x80)作为调用系统内核函数的系统调用接口。

IDT( Interrupt Descriptor Table)

IDT是一个有256个入口的线性表,每个中断向量(入口)关联了一个中断处理过程。每个IDT的入口是个8字节的描述符,所以整个IDT表的大小为256*8=2048 bytes
内核linux-4.10.8中定义如下(arch/x86/kernel/traps.c):

1
2
/* Must be page-aligned because the real IDT is used in a fixmap. */
gate_desc idt_table[NR_VECTORS] __page_aligned_bss;

结构体gate_desc的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 8 byte segment descriptor */
struct desc_struct {
union {
struct {
unsigned int a;
unsigned int b;
};
struct {
u16 limit0;
u16 base0;
unsigned base1: 8, type: 4, s: 1, dpl: 2, p: 1;
unsigned limit: 4, avl: 1, l: 1, d: 1, g: 1, base2: 8;
};
};
} __attribute__((packed));

1
2
3
4
5
6
7
8
9
/* 16byte gate */
struct gate_struct64 {
u16 offset_low;
u16 segment;
unsigned ist : 3, zero0 : 5, type : 5, dpl : 2, p : 1;
u16 offset_middle;
u32 offset_high;
u32 zero1;
} __attribute__((packed));
1
2
#ifdef CONFIG_X86_64
typedef struct gate_struct64 gate_desc;
1
2
#else
typedef struct desc_struct gate_desc;

IDT有三种不同的描述符或者说是入口:

  • Task Gate Descriptor(任务门描述符 )
    Linux 没有使用该类型描述符

  • Interrupt Gate Descriptor(中断门描述符)

  • Trap Gate Descriptor(陷阱门描述符 )

-DPL为0(内核)或者是3(用户)。当前的执行等级CPL(Current Privilege Level)被保存在CS寄存器中。控制单元UC (Unit Of Control) 比较CPL中的值和IDT中断描述符中的DPL字段。如果DPL>=CPL,那么执行中断处理过程。DPL为0的中断在用户态是不能被执行的。

IDTR寄存器

存储当前IDT

IDT的初始化

IDT被BIOS程序首先初始化,当Linux得到控制权后,Linux又重新设置IDT。汇编指令LIDT加载IDTR寄存器(IDT的大小和IDT的地址),然后重新填充256个IDT入口,定义每个入口对应的中断

IDT初始化详细过程

中断处理流程
  • 确定与中断或异常关联的向量i

  • 读取由IDTR寄存器指向的IDT表中的第i项中断描述符

  • 从GDTR寄存器获得GDT的基地址,并在GDT中查找,以获取IDT表中的第i项中断描述符的段选择符

  • 特权级比较,以及一些入栈保护操作

  • 装载cs和eip寄存器,其值分别为IDT表中第i项门的段选择符和偏移量字段。这样就可以转到对应的中断处理程序IRQn_interrupt执行。

more

后续知识还有待学习

系统调用

上面我们提到,现代操作系统都是以保护模式运行的。分为内核态、用户态。内核态的权限等级是ring0,而用户态的权限等级是ring3.内核有访问所有CPU寄存器和硬件内存的权力。写入内核的函数为内核函数,只有当前权限等级为ring0时才能运行,并且只能运行于内核空间。为了安全考虑,很多直接接触硬件的函数操作都是写入内核的。一般的用户程序最初都是运行在用户态的,但运行过程中,一些操作需要内核权限才能运行。这时就需要使用系统调用来实现。
Linux通过128(0x80)号中断(陷阱)来实现。上面我们提到,异常分为Faults,Traps,Abort。Faults会保存出现故障的指令的地址到IP寄存器,Traps会保存下一条指令的地址到IP寄存器,Abort不会保存出现错误的指令的地址到IP寄存器。
当发生系统调用时,会触发0x80号中断(陷阱),这时会对一些用户态的重要数据/寄存器进行压栈处理,然后切换到内核态,并调用中断服务处理程序system_call,并根据传入的系统调用号/参数(保存在寄存器中),运行相对应的内核函数,执行完后,将重要数据/返回值等保存到寄存器。之后,切换回用户态,弹栈,恢复用户态数据,并通过寄存器将返回值返回给用户态。然后继续执行用户态后续操作。

Linux系统调用的初始化

  • 计算机启动过程中,Linux内核在完成初始化后,会执行第一个内核程序(init/main.c中定义的asmlinkage __visible void __init start_kernel(void);)来启动内核。

  • start_kernel()执行过程中,会调用arch/x86/kernel/traps.c中定义的void __init trap_init(void);

  • trap_init()执行过程中会调用set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);完成IDT的0x80入口(系统调用)的初始化(完成0x80中断向量和中断服务程序system_call的匹配)。

init/main.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
asmlinkage __visible void __init start_kernel(void)
{
char *command_line;
char *after_dashes;
set_task_stack_end_magic(&init_task);
smp_setup_processor_id();
debug_objects_early_init();
......
sort_main_extable();
trap_init(); //<--------------------------trap_init()
mm_init();
......
}

arch/x86/kernel/traps.c:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void __init trap_init(void)
{
int i;
#ifdef CONFIG_EISA
void __iomem *p = early_ioremap(0x0FFFD9, 4);
if (readl(p) == 'E' + ('I'<<8) + ('S'<<16) + ('A'<<24))
EISA_bus = 1;
early_iounmap(p, 4);
#endif
......
#ifdef CONFIG_IA32_EMULATION
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_compat);//<--------------------------初始化
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
#ifdef CONFIG_X86_32
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);//<--------------------------初始化
set_bit(IA32_SYSCALL_VECTOR, used_vectors);
#endif
}
......

arch/x86/include/asm/irq_vectors.h:

1
#define IA32_SYSCALL_VECTOR 0x80 //<------------------IA32_SYSCALL_VECTOR宏定义

Linux下系统调用的3种方法

通过glibc库函数调用

glibc库,是GNU对C标准库的开源实现。glibc提供了丰富的API。

glibc源码可以从git下载

1
git clone git://sourceware.org/git/glibc.git

通过对源码编译处理后就变成了Linux中使用的运行时库/lib/libc-2.25.so(2.25版本),并通过/lib/libc.so.6软链接指向。

通过命令/lib/libc.so.6可查看glibc版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GNU C Library (GNU libc) stable release version 2.25, by Roland McGrath et al.
Copyright (C) 2017 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 6.3.1 20170306.
Available extensions:
crypt add-on version 2.1 by Michael Glad and others
GNU Libidn by Simon Josefsson
Native POSIX Threads Library by Ulrich Drepper et al
BIND-8.2.3-T5B
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
<https://bugs.archlinux.org/>.

glibc除了提供一些用户态服务外,还对系统调用进行了封装。

  • 一个系统调用至少对应一个glibc库函数
    e.g. 系统调用sys_open对应了glibc的open()函数

  • 一个API可能调用了多个系统调用
    e.g. glibc的printf()函数调用了sys_open、sys_mmap、sys_write、sys_close等系统调用

  • 一个系统调用可以被多个API调用
    e.g. glibc的malloc()、calloc()、free()函数都调用了sys_brk系统调用

拿Hello World!为例子:

1
2
3
4
5
6
7
8
#include<stdio.h>
int main(void)
{
printf("Hello World!");
return 0;
}

当执行printf()函数的时候,就间接得调用了sys_open、sys_mmap、sys_write、sys_close等系统调用。

通过syscall()函数直接调用

syscall()函数为glibc库函数,函数原型定义在/lib/include/unistd.h中:

1
extern long int syscall (long int __sysno, ...) __THROW;

__sysno是系统调用号,一般使用宏定义SYS_XXX,而不是直接使用数字。所以使用时需要包含头文件<sys/syscall.h>
通过查看<sys/syscall.h>头文件(/lib/include/sys/syscall.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#ifndef _SYSCALL_H
#define _SYSCALL_H 1
/* This file should list the numbers of the system calls the system knows.
But instead of duplicating this we use the information available
from the kernel sources. */
#include <asm/unistd.h>
#ifndef _LIBC
/* The Linux kernel header file defines macros `__NR_<name>', but some
programs expect the traditional form `SYS_<name>'. So in building libc
we scan the kernel's list and produce <bits/syscall.h> with macros for
all the `SYS_' names. */
# include <bits/syscall.h>
#endif
#endif

该头文件又包含了<asm/unistd.h>(系统调用号表)和<bits/syscall.h>(__NR_<name>到SYS_<name>).

举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <signal.h>
int
main(int argc, char *argv[])
{
pid_t tid;
tid = syscall(SYS_gettid);
syscall(SYS_tgkill, getpid(), tid, SIGHUP);
}

通过 int 指令陷入

参考Linux 下系统调用的三种方法

syscalls table/系统调用表

#Linux系统调用表

Linux有几百个系统调用,不可能为每一个系统调用都分配一个中断向量,就如上面我们说的,Linux只使用了一个中断向量(0x80)来实现系统调用。那么Linux是怎么处理的呢?实际上,Linux内核维护着一张系统调用表(记录系统调用号和入口函数(内核函数)的对应表)。在Linux内核源码arch/x86/entry/syscalls/syscall_64.tbl(64位)中可以找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The abi is "common", "64" or "x32" for this file.
#
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
......
325 common mlock2 sys_mlock2
326 common copy_file_range sys_copy_file_range
327 64 preadv2 sys_preadv2
328 64 pwritev2 sys_pwritev2
329 common pkey_mprotect sys_pkey_mprotect
330 common pkey_alloc sys_pkey_alloc
331 common pkey_free sys_pkey_free
#
# x32-specific system call numbers start at 512 to avoid cache impact
# for native 64-bit operation.
#
512 x32 rt_sigaction compat_sys_rt_sigaction
513 x32 rt_sigreturn sys32_x32_rt_sigreturn
514 x32 ioctl compat_sys_ioctl
515 x32 readv compat_sys_readv
516 x32 writev compat_sys_writev
517 x32 recvfrom compat_sys_recvfrom
518 x32 sendmsg compat_sys_sendmsg
519 x32 recvmsg compat_sys_recvmsg
......

我们可以看到,Linux内核定义了几百个系统调用。

同时,Linux中还使用了一个头文件来将各系统调用号宏定义为有意义的符号常量(/usr/include/asm/unistd.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef _ASM_X86_UNISTD_64_H
#define _ASM_X86_UNISTD_64_H 1
#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_mmap 9
#define __NR_mprotect 10
#define __NR_munmap 11
#define __NR_brk 12
#define __NR_rt_sigaction 13
#define __NR_rt_sigprocmask 14
#define __NR_rt_sigreturn 15
......

Linux系统调用过程

我们上面所说的3种系统调用方法其实本质都是一样的。对于前两种方式,不论是封装的库函数(如printf(),open()等),还是syscall(),都由glibc运行时库提供。
通过命令file /lib/libc-2.25.so查看glibc运行时库:

1
/lib/libc-2.25.so: ELF 64-bit LSB shared object, x86-64, version 1 (GNU/Linux), dynamically linked, interpreter /usr/lib/ld-linux-x86-64.so.2, BuildID[sha1]=58c735bc7b19b0aeb395cce70cf63bd62ac75e4a, for GNU/Linux 2.6.32, not stripped, with debug_info

可以看到,它其实是已经经过编译、汇编处理产生的可动态链接的目标文件。也就是说所有C标准库函数(printf(),open(),syscall()等)都已经被编译、汇编处理成目标二进制码了。
这些有系统调用封装的库函数以及syscall()函数的机器码部分都有与汇编码int 0x80相对应的机器码。

所以说三种方式本质都是通过int 0x80陷入来实现的

所以系统调用过程大致如下:

  • 编写需要系统调用的代码

  • 通过gcc预处理、编译、汇编处理上述代码,生成目标文件

  • 通过gcc和需要使用到的库函数的目标文件进行动态链接,生成可执行二进制程序

  • 运行程序,当运行到int 0x80对应机器码时,将重要数据/寄存器压栈,保护现场

  • 将系统调用号,参数保存到寄存器

  • 切换到内核态,转到0x80中断向量的中断服务处理程序system_call,通过寄存器中的系统调用号,查系统调用表,转到对应的入口函数,通过寄存器中的参数,执行内核函数

  • 执行完成后,将返回值保存到寄存器

  • 切换回用户态,通过寄存器将返回值返回给用户态函数

  • 继续执行用户态后续操作

添加自己的系统调用

  • 下载Linux内核源码

  • 添加系统调用函数实现(kernel/sys.c)
    实现细节自行Google

  • 添加系统调用函数的声明(arch/x86/include/asm/syscalls.h)

  • 添加对应关系到系统调用表(arch/x86/entry/syscalls/syscall_64.tbl)
    细节自行Google

  • 重新编译内核

  • /usr/include/asm/unistd_64.h中添加宏定义

  • 测试系统调用

0%