Linux内核:基于int指令的经典系统调用过程分析

众所周知,代码运行可以存在多种不同的特权级别,而针对Linux系统,即为:用户模式(user mode)和内核模式(kernel mode)。在用户模式下,CPU的功能空间受到极大的限制,是没有权利访问多少系统资源的,诸多关键资源的使用无法直接调配,如:硬件设备读取、磁盘文件读取等;诸多情况无法直接处理,如开关中断、页中断、断电等。这些资源的调配和硬件中断的响应处理都是内核态下由系统内核代码进行应对。之所以在用户态程序和底层的系统资源之间添加一层内核态,主要目的还是起到封装和保护的作用,系统资源毕竟有限,若是直接暴露给多个不同的应用程序,则很可能出现冲突。再一次验证了计算机行业的万能规则,“没有增加一层抽象封装层解决不了的计算机问题,如果有,那就再加一层”。

系统调用是运行在内核态的,而上层的应用程序则运行在用户态,用户态的程序需要使用系统资源,显然必须使用系统调用。
Q:那么用户态的程序是如何运行内核态的内核代码的?
A:通过中断机制,来触发操作系统从用户态切换到内核态。根据中断的类型,主要又可以分为软中断和硬中断,软中断主要是指软件代码运行过程中人为预设触发的系统调用,如Linux系统下,是通过显式的int 0x80指令触发系统调用响应;硬中断则包括的更多是外设响应、硬件失效、断电等情况,核心特征是硬件主动激活发送电信号。两者的核心区别总结如下:

  • 1.软中断是由程序工作流安排好的,而硬中断的发生是有硬件触发的,具有突发性;
  • 2.硬中断的处理优先级更高,需要CPU立刻停下当前的工作,转向处理;软中断则并不打断CPU,依旧附属在相应的进程,等待CPU时间片轮转轮训处理。

中断机制是另一个话题,系统调用则是属于软中断中的一个特例,int指令(软中断还有其他指令,如yield)是程序用来显式声明软中断的,故而所谓的“基于int指令的系统调用”便是来源于此。软中断int指令发出后,显然需要同时传递一个中断类型的ID,用来告知内核启动相应的中断处理程序(缺页、硬件驱动、系统调用等),在内核中,系统调用即申请内核提供系统资源调配服务system_call对应的便是0x80中断号,内核中通过中断向量表(IVT, interrupt vector table),存放中断号和中断处理程序入口地址对应关系。0x80中断号告知了内核当前进程需要内核提供系统资源调配服务system_call,但是依旧没有告诉内核自己想要具体干什么(读文件、写文件还是创建子进程),这意味着还需再提供一个参数,用来告知内核具体使用的系统调用函数,这个系统调用服务号存放在eax寄存器中。下图便是对这一过程的详细展示。

Fig.1 基于int指令的系统调用过程示意图

系统调用的用户态部分

1. 无参数的系统调用宏函数的代码填充

//以fork()为例介绍Linux系统下基于int的经典系统调用实现
/*fork()函数是对一个对系统调用fork的封装,但fork()调用时并没有传递参数
显然是需要利用一个参数对fork()函数进行参数封装。*/

_syscall0(pid_t, fork); 
/*_syscall0是一个宏函数,用于定义一个没有参数的系统调用的封装,pid_t代表当前进程的id,是Linux自定义类型;第二个参数是系统调用的名称*/

/*_syscall0宏函数的定义如下*/

#define _syscall0(type, name)
        type name(void){                   \
        long __res;                        \
        __asm__ volatile ("int $0x80"      \
            :"=a" (__res)                  \
            :"0"  (__NR_##name));          \
        __syscall_return (type, __res);    \
    }

//故而对于_syscall0(pid_t, fork)宏展开如下:
pid_t  fork(void){
    long __res;
    __asm__ volatile ("int $0x80" 
        :"=a"(__res)
        :"0" (__NR_fork));
    __syscall_return(pid_t, __res);
    }

/*
“__asm__”C语言内嵌汇编,volatile关键字告诉GCC编译器该段代码不进行任何优化
"int $0x80"代表调用0x80号中断
"=a" (__res)表示用eax寄存器来存储系统调用的返回值,并输出返回数据并存储在__res里
"0" (__NR_##name)表示__NR_##name为输入,"0"指示有编译器选择和输出相同的寄存器(即eax)来传递参数
所以直观的看上述fork函数的可读伪代码如下
*/

pid_t  fork(void) {
    long __res;
    $eax = __NR_fork; //__NR_fork宏代表fork系统调用的二层系统调用号
    int $0x80;
    __res = $eax;
    __syscall_return(pid_t, __res);
}

对于x86体系而言,系统调用号都是通过宏来封装,这些宏具体的定义可在/usr/include/asm-generic/unist.h找到,以下为了示意考虑,便人为地设置前5个宏的定义,实际意义需要在unist.h查找,如exit实际对应的是#define __NR_exit 93

#define __NR_restart_syscall  0
#define __NR_exit         1
#define __NR_fork         2
#define __NR_read         3
#define __NR_write        4
...

2. 系统调用的返回值处理
__syscall_return是另外一个宏,定义如下

#define  __syscall_return (type, res)                                
    do {                        
        if ((unsigned long) (res) >= (unsigned long)(-125)){ 
            errno = -(res);       
            res = -1;                    
        }                            
        return (type) (res);                     
    }while (0)

这个宏是用于检查系统调用的返回值,并把它相应地转换为C语言的errno错误码。在Linux里系统调用时通过使用返回值传递错误码,如果返回值为负数,那么表明调用失败,返回值的绝对值就是错误码,而在C语言里则不然,C语言里的大多数函数都以返回-1表示调用失败,而将出错信息存储在一个名为errno的全局变量(在多线程库中,errno存储于TLS中)。所以__syscall_return就负责将系统调用的返回信息存储在errno中

这样fork函数在汇编后就形成类似如下汇编代码

fork:
mov  eax,2
int 0x80
cmp eax, 0xFFFF FF83
jmp  syscall_noerror
neg eax
mov errno, eax
mov eax,0xFFFF FFFF
syscall_noerror:
ret

3. 带有参数的系统调用宏函数
以上是针对无参数输入的系统调用,如果系统调用本身有参数,则需要用到另一个带有1个参数的系统调用

#define _syscall12(type, name, type1, arg1)
    type name (type1 arg1)
{
    long __res;
    __asm__ volatile ("int $0x80"  \
        :"=a" (__res)       \
        :"0"  (__NR__##name), "b" ((long)(arg1)) );\
    --syscall_return (type, __res); \
}

上述代码中的”b” ((long) (arg1))代表先将arg1强制转换为long,然后用EBX寄存器存储编译器会在使用EBX寄存器时进行现场保护,使得返回后原来的EBX的值不被破坏。


x86体系下,Linux系统支持的系统调用参数至多有6个,分别可用6个寄存器来传递,按照顺序分别是:
EBX、ECX、EDX、ESI、EDI和EBP


4. 系统调用之前的现场保护和栈切换
上图中中现场保护和系统调用参数准备的过程对应的汇编代码如下

push ebx      //反汇编常看到push ebx; push esi; push edi现场保护
eax = __NR_##name
ebx = arg1
int 0x80
__res = eax  //eax存储这系统调用返回值
pop ebx      //弹出原先的ebx值

当用户调用摸个系统调用的时候,实际上便是执行这些汇编代码。在CPU执行到int 0x80系统服务请求之时,便会先进行现场保护(正常的子函数调用时也会有现场保护),接着将代码运行的特权状态从用户态切换到内核态,依次开始查看中断向量表(Interrupt Vector Table)、系统调用服务表。

所谓的“当前栈”,可以通过查看ESP的值所在的栈空间来判断,如果ESP的值位于用户栈的范围内(0 ~ 0xC000 0000),那么程序当前栈便是用户栈,反之亦然。此外寄存器SS的值还应该指向当前栈所在的页。用户栈切换到内核栈的实际过程是:
1.保存当前的ESP、SS的值; //将用户栈的信息保存在内核栈上,显然不能保存用户栈上,不然怎么返回
2.将ESP、SS的值设置为内核栈的相应值;
3.内核程序运行完后,从内核栈上弹出原用户栈ESP、SS的值。

当0x80号中断发生的时候,CPU除了切入内核态之外,还会自动完成下列几件事:
1.找到当前进程的内核栈(0xC000 0000 ~ 0xFFFF FFFF)
2.在内核栈中依次压入用户态的寄存器SS、ESP、EFLAGS、CS、EIP
当内核从系统调用中返回的时候,需要调用“iret”指令来返回用户态,显然iret代表的是内核栈中一系列的寄存器SS、ESP、EFLAGS、CS、EIP弹出操作。

系统调用的内核部分

1. 中断向量表查找
上面的代码还是局限在int指令执行之前,即仍停留在用户态,下面正式进入int指令在内核态的完成过程中断向量表示int指令要完成的系统调用任务的第一站。Linux/arch/i386/kernel/traps.c存在一个函数trap_init用来初始化中断向量表,为每个中断号绑定相应的中断处理程序的函数指针。

void __init trap_init(void)
{
    ...
    set_trap_gate(0, &divide_error);
    set_intr_gate(1, &debug);
    set_intr_gate(2, &nmi);
    set_system_intr_gate(3, &int3);
    set_system_gate(4, &overflow);
    set_system_gate(5, &bounds);
    set_trap_gate(6, &invalid_op);
    set_trap_gate(7, &device_not_available);
    set_task_gate(8, GDT_ENTRY_DOUBLEDEFAULT_TSS);
    set_trap_gate(9, &coprocessor_segment_overrun);
    set_trap_gate(10, &invalid_TSS);
    set_trap_gate(11, &segment_not_present);
    set_trap_gate(12, &stack_segment);
    set_trap_gate(13, &general_protection);
    set_trap_gate(14, &page_fault);
    set_trap_gate(15, &spurious_interrupt_bug);
    set_trap_gate(16, &coprocess_error);
    set_trap_gate(17, &alignment_check);

#ifdef COFIG_X86_MCE
    set_trap_gate(18, &machine_check);
#endif

    set_trap_gate(19, &simd_coprocessor_error);

    set_system_gate(SYSCALL_VECTOR, &system_call); //在Linux/include/asm-i386/mach-default/irq_vectors.h中可以找到#define  SYSCALL_VECTOR  0x80,这便意味着int 0x80触发执行的中断处理函数是system_call
    ...
}

到此可以看到程序的工作流已经可以梳理到:main -> fork ->int 0x80触发中断响应 -> 系统调用system_call

2. 系统调用表查找
既然工作流已经到了system_call,那么参考《程序员的自我修养》中的内容补足system_call函数对应的工作内容。

ENTRY(system_call)
    ...
    SAVE_ALL //宏,将各种寄存器压入栈中,以免它们被后续执行的代码修改
    ...
    cmpl $(nr_syscall), %eax //比较eax系统调用号和"当前系统最大有效系统调用号+1"的大小,如果是则执行下面这句syscall_badsys应对“无效的系统调用”这一情况
    jar  syscall_badsys //无效的系统调用号
    jar  syscall_call
    ...

/*SAVE_ALL主要的工作是为了后续的系统调用函数提供参数*/
#define SAVE_ALL \
    ...
    push  %eax
    push  %ebp
    push  %edi 
    push  %esi
    push  %edx
    push  %ecx
    push  %ebx //寄存器入栈顺序是各寄存器存储系统调用参数的优先装配顺序的倒序
    mov $(KERNEL_DS), %edx
    mov %edx, %ds
    mov %edx, %es
    ...

/*
入栈的操作意味着后面将被启用的sys_XXX()只能从内核栈上取参数,不像gcc编译器还可以通过__fastcall修饰来使函数可以通过寄存器来取参数加速。这种情况下,显然必须通过强制措施告诉系统调用函数必须从栈上按顺序取参数,这就引出了asmlinkage宏标识,
*/
#define asmlinkage __attribute__((regparm(0))) //扩展关键字的意思是让这个函数只能从栈上取参数

可以看到这一步中通过cmpl $(nr_syscall), %eax判断接下来的工作方向,如果系统调用号有效,则正常进入syscall_call块,去往系统调用表中查找具体的系统调用函数

    syscall_call:
        call *sys_call_table(0, %eax, 4)//系统调用号有效,查找
        ...
        RESTORE_REGS //宏,恢复之前由SAVE_ALL压栈的寄存器
        ...
        iret//从终端处理程序system_call中返回

Linux的i386调用表中,记录着syscall_call_table的详细情况,每个元素对应的都是相应的系统调用函数的入口地址。可以看到call *sys_call_table(0, %eax, 4)中的是标准的数组寻址(index = 0+ eax * 4),如果eax为2,则显然sys_fork()函数将被调用。

ENTRY (sys_call_table)
    .long  sys_restart_syscall
    .long  sys_exit
    .long  sys_fork
    .long  sys_read
    .long  sys_write

Fig.2 fork()系统调用流程

至此,可以看到基于int指令的经典系统调用的全部过程。但是基于int指令的系统调用效率较低,Linux在2.5之后便采用了一种新型的系统调用机制,虽然如此,但是基于int指令的系统调用依旧可以很好的展示出系统调用的过程和流程。

“`

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值