深入理解系统调用

Posted hesetone

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入理解系统调用相关的知识,希望对你有一定的参考价值。

一、熟悉对应的系统调用

  本人学号尾号86,对应的系统调用号是link,在./arch/x86/entry/syscall_64.tbl得到如下信息:

#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The __x64_sys_*() stubs are created on-the-fly for sys_*() system calls
#
# The abi is "common", "64" or "x32" for this file.
#
...
86    common    link            __x64_sys_link
...

  因此,可以看到,86号系统调用,对应link指令,在shell下,简写为ln,ln是linux中一个非常重要命令,它的功能是为某一个文件在另外一个位置建立一个同不的链接,这个命令常用的可选项参数是-s,具体用法是:ln –s 源文件 目标文件。当我们需要在不同的目录,用到相同的文件时,我们不需要在每一个需要的目录下都放一个必须相同的文件,我们只要在某个固定的目录,放上该文件,然后在其它的目录下用ln命令链接(link)它就可以,不必重复的占用磁盘空间。ln的链接又软链接和硬链接两种,软链接只会在你选定的位置上生成一个文件的镜像,不会占用磁盘空间,硬链接没有参数-s,它会在你选定的位置上生成一个和源文件大小相同的文件,无论是软链接还是硬链接,文件都保持同步变化,

二、反汇编代码讨论

  操作系统内核提供用户空间程序与内核空间进行交互的一套标准接口,这些接口让用户态程序能以受限的权利访问硬件设备,比如申请系统资源,操作设备读写,创建新进程等。用户空间发生请求,内核空间负责执行,这些接口便是用户空间和内核空间共同识别的桥梁,这里提到两个字“受限”,是为了保证内核稳定性,避免让用户空间程序有足够的权限随意更改系统,必须是内核对外开放的且满足权限的程序才能调用相应接口。以64位系统为例,一个系统调用的完整执行过程有如下几个步骤:
1、通过特定指令syscall发出系统调用
2、CPU从用户态切换到内核态,进行一些寄存器和环境设置
调用system_call内核函数,通过系统调用号获取对应的服务例程
4、调用系统调用处理例程
5、使用特定指令sysret从系统调用返回用户态
  link()函数在unistd.h文件中定义如下:
int link(const char *, const char *);
  其次,我们知道,用户态的方法link()对应于系统调用层的sys_link,但是sys_link是无法直接查获的,
SYSCALL_DEFINE2(link, const char __user *, oldname, const char __user *, newname)
{
    return do_linkat(AT_FDCWD, oldname, AT_FDCWD, newname, 0);
}

  可以发现,它调用了一个do_linkat函数,并且把两个参数都传递给了它,继续追溯do_linkat函数的定义,在./fs/namei.c中找到其对应的函数实现如下:

int do_linkat(int olddfd, const char __user *oldname, int newdfd,
          const char __user *newname, int flags)
{
    struct dentry *new_dentry;
    struct path old_path, new_path;
    struct inode *delegated_inode = NULL;
    int how = 0;
    int error;

    if ((flags & ~(AT_SYMLINK_FOLLOW | AT_EMPTY_PATH)) != 0)
        return -EINVAL;
    /*
     * To use null names we require CAP_DAC_READ_SEARCH
     * This ensures that not everyone will be able to create
     * handlink using the passed filedescriptor.
     */
    if (flags & AT_EMPTY_PATH) {
        if (!capable(CAP_DAC_READ_SEARCH))
            return -ENOENT;
        how = LOOKUP_EMPTY;
    }

    if (flags & AT_SYMLINK_FOLLOW)
        how |= LOOKUP_FOLLOW;
retry:
    error = user_path_at(olddfd, oldname, how, &old_path);
    if (error)
        return error;

    new_dentry = user_path_create(newdfd, newname, &new_path,
                    (how & LOOKUP_REVAL));
    error = PTR_ERR(new_dentry);
    if (IS_ERR(new_dentry))
        goto out;

    error = -EXDEV;
    if (old_path.mnt != new_path.mnt)
        goto out_dput;
    error = may_linkat(&old_path);
    if (unlikely(error))
        goto out_dput;
    error = security_path_link(old_path.dentry, &new_path, new_dentry);
    if (error)
        goto out_dput;
    error = vfs_link(old_path.dentry, new_path.dentry->d_inode, new_dentry, &delegated_inode);
out_dput:
    done_path_create(&new_path, new_dentry);
    if (delegated_inode) {
        error = break_deleg_wait(&delegated_inode);
        if (!error) {
            path_put(&old_path);
            goto retry;
        }
    }
    if (retry_estale(error, how)) {
        path_put(&old_path);
        how |= LOOKUP_REVAL;
        goto retry;
    }
out:
    path_put(&old_path);

    return error;
}

  因为SYSCALL_DEFINE2(link,...)等同于asmlinkage long sys_link(const char __user *oldname,const char __user *newname);,因此调用sys_link()等价于调用前者。接下来,触发syscall指令调用的时候,会从MSR寄存器里面拿出该函数的首地址来调用,也就是调用 entry_SYSCALL_64,这个入口在/arch/x86/entry/entry.S里面:

ENTRY(entry_SYSCALL_64)
    UNWIND_HINT_EMPTY
    /*
     * Interrupts are off on entry.
     * We do not frame this tiny irq-off block with TRACE_IRQS_OFF/ON,
     * it is too small to ever cause noticeable irq latency.
     */

    swapgs
    /* tss.sp2 is scratch space. */
    movq    %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)
    SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
    movq    PER_CPU_VAR(cpu_current_top_of_stack), %rsp

    /* Construct struct pt_regs on stack */
    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 */
GLOBAL(entry_SYSCALL_64_after_hwframe)
    pushq    %rax                    /* pt_regs->orig_ax */

    PUSH_AND_CLEAR_REGS rax=$-ENOSYS

    TRACE_IRQS_OFF

    /* IRQs are off. */
    movq    %rax, %rdi
    movq    %rsp, %rsi
    call    do_syscall_64        /* returns with IRQs disabled */

    TRACE_IRQS_IRETQ        /* we‘re about to change IF */

    /*
     * Try to use SYSRET instead of IRET if we‘re returning to
     * a completely clean 64-bit userspace context.  If we‘re not,
     * go to the slow exit path.
     */
    movq    RCX(%rsp), %rcx
    movq    RIP(%rsp), %r11

    cmpq    %rcx, %r11    /* SYSRET requires RCX == RIP */
    jne    swapgs_restore_regs_and_return_to_usermode
END(entry_SYSCALL_64)

  显然,可以看到,此处是保存了相当多的结构体pt_regs的相关内容,其中就包含栈段、代码段、指令指针、栈指针、标志寄存器和ax的值,最后的ax寄存器实际上存储了要执行的系统调用号码,接下来执行call do_syscall_64:

__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs)
{
    struct thread_info *ti;

    enter_from_user_mode();
    local_irq_enable();
    ti = current_thread_info();
    if (READ_ONCE(ti->flags) & _TIF_WORK_SYSCALL_ENTRY)
        nr = syscall_trace_enter(regs);

    if (likely(nr < NR_syscalls)) {
        nr = array_index_nospec(nr, NR_syscalls);
        regs->ax = sys_call_table[nr](regs);
#ifdef CONFIG_X86_X32_ABI
    } else if (likely((nr & __X32_SYSCALL_BIT) &&
              (nr & ~__X32_SYSCALL_BIT) < X32_NR_syscalls)) {
        nr = array_index_nospec(nr & ~__X32_SYSCALL_BIT,
                    X32_NR_syscalls);
        regs->ax = x32_sys_call_table[nr](regs);
#endif
    }

    syscall_return_slowpath(regs);
}

 

二、编程触发系统调用(含汇编)

  首先,编写出测试程序execl_ln.c:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
    link("./execl_ln.c", "./a.c");
    return 0;
}

  分别按照软/硬连接处理得到a.c文件,上面的是软连接,只占有一极小部分内存,下面是硬连接,和源文件占有同样多的内存:

技术图片

技术图片

  接下来我们汇编实现它,因为它是86号系统调用,因此我们需要预先存储0x56给eax寄存器:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    const char *oldpath = "execl_ln.c";
    const char *newpath = "a.c";
    short int err_flag;
    asm volatile(
        "movl $0x56,%%eax
	"
        "movl %1, %%ebx
	"
        "movl %2, %%ecx
	"
        "int 0x80
	"
        "movl %%eax,%0
	"
        : "=m"(err_flag)
        : "b"(oldpath), "c"(newpath));

    if (!err_flag)
        printf("link successful.");
    return 0;
}

  使用命令编译为32位汇编源码,gcc -S -o a32.s execl_ln.c -static -m32,首先,运行execl_ln.c编译链接完毕的可执行文件,可以查看到可以在当前目录查看到产生了一个a.c文件,以硬链接的方式链接到execl_ln.c,在a32.s中可以查看到相关的代码组织结构,很明显,在main函数中调用link函数的时候,事实上是调用了__link()函数,这个函数的位置在0x0806dad0,我们继续追踪,可以查看到从地址0x0806dad0开始执行系统调用例程,且执行结果存储在eax中,如果eax中的返回值小于0xfffff001,那么就会引起触发__syscall_error,引起报错,展示如下:

技术图片

技术图片

技术图片

  显然,可以看到,触发系统调用的操作,成功使程序进入到__link()函数中,查看对应地址的内存,发现0x080eaf90地址的数据已经超出了当前文件m32.S的存储范围,这是因为我们编译源文件的时候,采用了静态链接,所以0x0806dadf地址会存储一条等同于call *_dl_sysinfo的指令,继续查看0xf7ffcc80地址的内存,可以发现,其实最终调用的是VDSO(linux-gate.so.1)中的__kernel_vsyscall函数,显然其包含sysenter指令,syscall()函数也是类似的,根据静态/动态链接的不同分别采用的不同的指令,最终调用__kernel_vsyscall函数。  

三、系统调用栈空间分析

  在具有函数调用的处理例程当中,各个函数的栈空间关系如下图所示:

 

技术图片

  在所有的寄存器中,%rax 通常用于存储函数调用的返回结果,%rsp 是堆栈指针寄存器,它会一直指向栈顶位置,堆栈的pop和push操作就是通过改变%rsp 的值即移动堆栈指针的位置来实现出栈和压栈操作的。%rbp 是栈帧指针,用于标识当前栈帧的起始位置,剩余的%rdi, %rsi, %rdx, %rcx,%r8, %r9 六个寄存器用于存储函数调用时的6个参数,一般不带有6个以上的参数。在整个系统调用过程中,调用方主要做的操作有:

1、先把参数保存在寄存器edi和esi中(通过寄存器传参数)

2、调用callq,其中callq保存下一条指令的地址,用于函数返回继续执行,之后再跳转到子函数地址。

3、处理返回值,函数返回值通常存放在%eax。

被调用方的操作通常有:

  1、上一个函数的帧指针%ebp入栈

  2、栈指针%esp保存到帧指针

  3、从寄存器(%edi和%esi)取出参数到栈中

  4、参数参与运算

  5、把计算结果保存在%eax

  6、弹出帧指针(还原前一个函数的%rbp)

  7、函数返回,取从%eip下一跳指令继续执行

  从main函数调用link函数的时候,可以查看到如下信息:

技术图片

  指令push %ebp和mov %esp,%ebp,保存了上一个函数(main)的栈帧指针,同时,让%ebp重新指向新的栈帧的起始地址。之所以保存返回地址和保存上一栈帧的%ebp,都是为了函数返回时,恢复上一个函数的栈帧结构。事实上,保存返回地址和跳转到子函数处执行由call一条指令完成,在call 指令执行完成时,已经进入了子程序中,因而将上一栈帧%ebp 压栈的操作,需要由子程序来完成,%ebp的压栈的内容也是保存在被调用函数的栈空间之中。

  在调用函数例程结束返回时,我们只要知道函数的运行结果存储在%eax中,之后就需要将栈的结构恢复到启动函数调用之前的状态,并跳转到上一个函数的返回地址处继续执行下一条指令。由于函数调用时已经保存了返回地址和父函数栈帧的起始地址,为了恢复子函数调用之前的上一个栈帧状态,我们只需要执行mov %ebp,%esp和pop %ebp指令就行,前一条使得%esp和%ebp都指向子函数栈的起始处,然后出栈%ebp的值,赋值给%ebp,此时,%esp也会自动上移一个位置,指向上一个函数的栈结尾处。此外,在上图中,可以看到,并没有这两条指令出现,这是因为x86_64 架构中提供了leave指令来实现上述两条命令的功能,而且可以看出,leave指令用于恢复父函数的栈帧,ret用于跳转到返回地址处,也就是上一个函数栈帧的结尾处,leave和ret配合共同完成了子函数的返回。当执行完成 ret 后,%esp 指向的是上一个函数栈帧的结尾处。


 

以上是关于深入理解系统调用的主要内容,如果未能解决你的问题,请参考以下文章

深入理解系统调用

深入理解系统调用

深入理解计算机操作系统(笔记)

深入理解系统调用

深入理解系统调用

深入理解系统调用