深入理解系统调用

通过汇编指令触发该系统调用

通过gdb跟踪该系统调用的内核处理过程

重点阅读分析系统调用入口的:保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化

一、系统调用相关知识

系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进?内核态。

系统调用具有以下功能和特性:

把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,?户态进程不用直接与硬件设备打交道。

极?地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产?安全隐患,可能引起系统崩溃。

使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接?(api)代替了,不会有紧密的关系,便于在不同系统间移植。

深入理解系统调用

1. Linux上的系统调用实现原理

要想实现系统调用,主要实现以下几个方面:

  1. 通知内核调用一个哪个系统调用

  2. 用户程序把系统调用的参数传递给内核

  3. 用户程序获取内核返回的系统调用返回值

下面看看Linux是如何实现上面3个功能的。

  • 通知内核调用一个哪个系统调用

每个系统调用都有一个系统调用号,系统调用发生时,内核就是根据传入的系统调用号来知道是哪个系统调用的。

在x86架构中,用户空间将系统调用号是放在eax中的,系统调用处理程序通过eax取得系统调用号。

系统调用号定义在内核代码:arch/x86/include/asm/unistd.h 中,可以看出linux的系统调用不是很多。

  • 用户程序把系统调用的参数传递给内核

系统调用的参数也是通过寄存器传给内核的,在x86系统上,系统调用的前5个参数放在ebx,ecx,edx,esi和edi中,如果参数多的话,还需要用个单独的寄存器存放指向所有参数在用户空间地址的指针。

一般的系统调用都是通过C库(最常用的是glibc库)来访问的,Linux内核提供一个从用户程序直接访问系统调用的方法。

参见内核代码:sysdeps/unix/sysv/linux/x86_64/sysdep.h

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

里面定义了6个宏,分别可以调用参数个数为0~6的系统调用

syscall0(type,name)

_syscall1(type,name,type1,arg1)

_syscall2(type,name,type1,arg1,type2,arg2)

_syscall3(type,name,type1,arg1,type2,arg2,type3,arg3)

_syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4)

_syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5)

_syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5,type6,arg6)

超过6个参数的系统调用很罕见,所以这里只定义了6个。

  • 用户程序获取内核返回的系统调用返回值

获取系统调用的返回值也是通过寄存器,在x86系统上,返回值放在eax中。

2. 变量传递方式

用户态数据栈-->寄存器-->内核态数据栈

深入理解系统调用

二、环境准备

1. 安装开发工具:

sudo apt install build-essential qemu qemu-system-x86 libncurses5-dev bison flex libssl-dev libelf-dev axel 

2. 下载内核源码:

axel -n 20 https://mirrors.edge.kernel.org/pub/linux/kernel/v5.x/linux-5.4.34.tar.xz
xz -d linux-5.4.34.tar.xz
tar -xvf linux-5.4.34.tar 

3. 编译menuOS调试工具

cd linux-5.4.34
make defconfig  #默认配置基于‘x86_64_defconfig‘
make menuconfig

深入理解系统调用

4. 配置内核选项

#打开debug相关选项
Kernel hacking --->
    Compile-time checks and compiler options --->
        [*] Compile the kernel with debug info
        [*] Provide GDB scripts for kernel debugging  [*] Kernel debugging

深入理解系统调用

#关闭KASLR,否则会导致打断点失败
Processor type and features ---->
    [] Randomize the address of the kernel image (KASLR)

深入理解系统调用

KASLR让加载到内核时是随机一个地址,如果加载内核一直同一个地址,容易被黑客攻击,这里为了实验方便,把它关闭。

到这里,内核还不能够正常加载运?,因为没有?件系统,最终会kernel panic。

5 编译内核

make -j$(nproc)  #编译内核,需要几分钟的时间
#测试一下,不能正常加载运行
qemu-system-x86_64 -kernel arch/x86/boot/bzImage  //在没有xwindow的情况下运行qemu 需要使用 -display curses

6. 制作根文件系统

电脑加电启动?先由bootloader加载内核,内核紧接着需要挂载内存根?件系统,其中包含必要的设备驱动和?具,bootloader加载根?件系统到内存中,内核会将其挂载到根?录/下,然后运?根?件系统中init脚本执??些启动任务,最后才挂载真正的磁盘根?件系统。

我们这?为了简化实验环境,仅制作内存根?件系统。这?借助BusyBox 构建极简内存根?件系统,提供基本的?户态可执?程序

下载 busybox源代码解压:

axel -n 20 https://busybox.net/downloads/busybox-1.31.1.tar.bz2
tar -jxvf busybox-1.31.1.tar.bz2
cd busybox-1.31.1

配置编译 并安装:

make menuconfig
/*
记得要编译成静态链接,不用动态链接库。
Settings --->
    [*] Build static binary (no shared libs)    

深入理解系统调用

然后编译安装,默认会安装到源码目录下的 _install 目录中。
*/
make -j$(nproc) && make install

7. 制作内存根文件系统镜像

mkdir rootfs
cd rootfs
cp ../busybox-1.31.1/_install/* ./ -rf
mkdir dev proc sys home
sudo cp -a /dev/{null,console,tty,tty1,tty2,tty3,tty4} dev/

8. init脚本

准备init脚本文件放在根文件系统目录下(rootfs/init),添加如下内容到init文件:

#!/bin/sh
mount -t proc none /proc 
mount -t sysfs none /sys
echo "Welcome to test-OS!" 
echo "--------------------"
cd home
/bin/sh

给init脚本添加可执行权限:

chmod +x init

打包成内存根文件系统镜像:

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz

镜像文件在上一级目录:

测试挂载根文件系统,看内核启动完成后是否执行init脚本:

cd ../   #一定要返回到上一级,因为rootfs.cpio.gz在上一级
qemu-system-x86_64 -kernel ~/linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz //在没有xwindow的情况下运行qemu 需要使用 -display curses 
qemu-system-x86_64的参数比较多,这里简单说下:   
-kernel是指定一个大内核文件,当仁不让的是bzImage。   
-initrd是指定一个 initrd.img文件,这个文件就是我们使用busybox生成的initramfs.img。   
-smp可以从名字猜想,它是给qemu指定几个处理器,或者是几个线程<嗯,大概意思就thread吧>。
-gdb则是启动qemu的内嵌gdbserver,监听的是本地tcp端口1234—如果这样写: -gdb tcp:192.168.1.100:1234 ,似乎也是没问题的。 
-S 就是挂起gdbserver,让gdb remote connect it。   
-s 默认使用1234端口进行远程调试,和-gdb tcp::1234类似。   
-m 2048指定内存大小为2048M

此画面表示启动成功
深入理解系统调用

如果不成功参考:

https://blog.csdn.net/baidu_31504167/article/details/93853921

9. QEMU+GDB调试内核

qemu-system-x86_64 -kernel /usr/src/linux-4.6.2/arch/x86/boot/bzImage -initrd …/initramfs.img -smp 2 -S -s  -display curses 
gdb /usr/src/linux-4.6.2/vmlinux (修改成自己的vmlinux路径)
target remote:1234 (默认端口是1234,进行远程连接)
b start_kernel (设置断点)
c (continue 运行到断点处)

深入理解系统调用

三、通过汇编指令触发该系统调用

1. 首先查看系统调用表,选择setdomainname

cat ~/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl

第一列的数字是系统调用号。

第三列是系统调用的名字。

第四列是系统调用在内核的实现函数。

如上图,171是写调用: 系统调用 setdomainname,函数入口为 __x64_sys_setdomainname。

2. 自己写一个简单C语言程序Write.c,通过这个程序触发系统调用setdomainname:

系统调用

write.c:

#include <stdio.h>
#include <unistd.h>
int main (int argc, char *argv[])
{
	int a,b;
	char buf[50]="test123";
	int size=sizeof("test123");
	a=setdomainname(buf,sizeof("test123"));
	printf("a=%d\n",a);
	b=getdomainname(buf,sizeof(buf));
	printf("new domainname is %s\n",buf);
	return 0;
}

内核调用方式

write-asm.c:

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

int main (int argc,char *argv[])
{
        int a,b;
        char buf[50] = "abc444";
        int size = sizeof("abc444");
        asm volatile(
                "movq %1,%%rdi\n\t"            //EDI寄存器用于传递参数
                "movq %2,%%rsi\n\t"            //ESI寄存器用于传递参数
                "movq $0xab,%%rax\n\t"         //使用EAX传递系统调用号
                "syscall\n\t"                  //64位触发系统调用 ,32位使用int $x80 回去内存特定地址执行指定函数
                "movq %%rax,%0\n\t"      	   //保存返回值
                : "=m"(a)
                : "g" (buf),"g" (size)    /* input g mean Choose any one Regtster*/
                );

        printf("a=%d\n",a);
        b=getdomainname(buf,sizeof(buf));
        printf("new domainname is %s\n",buf);
        return 0;
}

运行一下汇编程序:

gcc -o write-asm write-asm.c -static
//主要要静态编译
./write

需要提前将write文件放入/home/uos/rootfs.cpio.gz中

四、通过gdb跟踪该系统调用的内核处理过程

gdb调试基础知识:

r : run 运行程序

q : quit

b : break 设置断点

c : continue

l : list 显示多行源代码

step 执行下一条语句(若是函数调用,则进入)

next 执行下一条语句(不进入函数调用)

print 打印内部变量值

1.重新制作根文件系统:

把编译好的 write-asm文件放在rootfs/syscall目录下:

重新生成根文件系统(rootfs目录下):

find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz

2. 纯命令行下启动虚拟机:

使用gdb跟踪调试内核,加两个参数,一个是-s,在TCP 1234端口上创建了一个gdbserver。可以另外打开一个窗口,用gdb把带有符号表的内核镜像vmlinux加载进来,然后连接gdb server,设置断点跟踪内核。若不想使用1234端口,可以使用-gdb tcp:xxxx来替代-s选项),另一个是-S代表启动时暂停虚拟机,等待 gdb 执行 continue指令(可以简写为c):

qemu-system-x86_64 -kernel ~/linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"

然后发现这个窗口暂停等待(作为gdbserver,端口号TCP1234):

3. 另外打开一个窗口,用gdb把带有符号表的内核镜像vmlinux加载进来:

然后连接gdb server:

打断点

b 函数名

其中函数名查找arch/x86/entry/syscalls

4. 设置断点跟踪内核:

在虚拟机中执行 write-asm,会卡住:

在gdb界面查看断点分析:

5. gdb界面bt查看堆栈:

(gdb) bt
#0  __x64_sys_setdomainname (regs=0xffffc900001b7f58) at kernel/sys.c:1358
#1  0xffffffff810025a3 in do_syscall_64 (nr=<optimized out>, regs=0xffffc900001b7f58) at arch/x86/entry/common.c:290
#2  0xffffffff81c0007c in entry_SYSCALL_64 () at arch/x86/entry/entry_64.S:175
#3  0x0000000000000000 in ?? ()

查看此时堆栈情况,有4层:

  • 第一层/ 顶层 __x64_sys_setdomainname 系统调用函数,开放给用户态使用的系统调用函数接口
  • 第二层 do_syscall_64 获取系统调用号, 前往系统调用函数
  • 第三层 entry_syscall_64 中断入口,做保存线程工作,调用 do_syscall_64
  • 第四层 操作系统

五、分析系统调用在内核态的工作机制

深入理解系统调用

  1. 应用程序调用一个库函数xyz()
  2. 库函数内封装了一个系统调用SYSCALL,它将参数(系统调用号)传递给内核并触发中断
  3. 触发中断后,内核进行中断处理,执行系统调用处理函数 (5.0内核中是entry_INT80_32,不再是system_call了)
  4. 系统调用处理函数会根据系统调用号,选择相应的系统调用服务例程(在这里是sys_xyz),真正开始处理该系统调用

其中 setdomainname函数由glibc实现

源码位于glibc下的./sysdeps/unix/sysv/linux/x86_64/sysdep.h

比如 syscall0的定义

241 #undef internal_syscall0
242 #define internal_syscall0(number, dummy...)             243 ({                                  244     unsigned long int resultvar;                    245     asm volatile (                          246     "syscall\n\t"                           247     : "=a" (resultvar)                          248     : "0" (number)                          249     : "memory", REGISTERS_CLOBBERED_BY_SYSCALL);            250     (long int) resultvar;                       251 })

其中的

246     "syscall\n\t"

为调用特殊模块寄存器(Model Specific Registers,简称 MSR)。这种寄存器是 CPU 为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。 其值由人为定义.相当于手动配置的常量内存,由系统初始化时指定.

在系统初始化的时候,trap_init 除了初始化上面的中断模式,这里面还会调用 cpu_init->syscall_init。*arch/x86/kernel/cpu/common.c 的 syscall_init 中初始化代码:

1639 void syscall_init(void)
1640 {
1641     extern char _entry_trampoline[];
1642     extern char entry_SYSCALL_64_trampoline[];
1643 
1644     int cpu = smp_processor_id();
1645     unsigned long SYSCALL64_entry_trampoline =
1646         (unsigned long)get_cpu_entry_area(cpu)->entry_trampoline +
1647         (entry_SYSCALL_64_trampoline - _entry_trampoline);
1648 
1649     wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
1650     if (static_cpu_has(X86_FEATURE_PTI))
1651         wrmsrl(MSR_LSTAR, SYSCALL64_entry_trampoline);     
1652     else
1653         wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

MSR_LSTAR 就是这样一个特殊的寄存器,当 syscall 指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用 entry_SYSCALL_64。

这一步也就是堆栈信息中的 ??() 部分的动作,至此系统完成了以下动作:

  • 将调用号由用户态数据栈写入eax寄存器,将系统调用的参数依次写入寄存器rdi,rsi,rdx,r10,r8,r9(如果有)
  • 中断系统
  • 找到内存中系统调用内核态函数entry_SYSCALL_64的入口,进入内核态

第三层

汇编指令 syscall 触发系统调用,通过MSR寄存器找到了中断函数入口,此时,代码执行到arch/x86/entry/entry_64.S 目录下的ENTRY(entry_SYSCALL_64)入口,然后开始通过swapgs 和压栈动作保存现场。

 ENTRY(entry_SYSCALL_64_trampoline)
     UNWIND_HINT_EMPTY
     swapgs
 
     /* Stash the user RSP. */
     movq    %rsp, RSP_SCRATCH
 
     /* Note: using %rsp as a scratch reg. */
     SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp
 
     /* Load the top of the task stack into RSP */
     movq    CPU_ENTRY_AREA_tss + TSS_sp1 + CPU_ENTRY_AREA, %rsp
 
     /* Start building the simulated IRET frame. */
     pushq   $__USER_DS          /* pt_regs->ss 保存数据段其实位置*/ 
     pushq   RSP_SCRATCH         /* pt_regs->sp 保存函数栈栈顶*/ 
     pushq   %r11                /* pt_regs->flags 保存cpu标识*/
     pushq   $__USER_CS          /* pt_regs->cs 保存代码段起始位置*/
     pushq   %rcx                /* pt_regs->ip 保存指针指令寄存器*/
     pushq   %rdi
     movq    $entry_SYSCALL_64_stage2, %rdi                                                                                                                                          
     JMP_NOSPEC %rdi
 END(entry_SYSCALL_64_trampoline)

     
 ENTRY(entry_SYSCALL_64_stage2)
     UNWIND_HINT_EMPTY
     popq    %rdi
     jmp entry_SYSCALL_64_after_hwframe
 END(entry_SYSCALL_64_stage2)
     
     
 ENTRY(entry_SYSCALL_64)
	UNWIND_HINT_EMPTY
     ...
	/* Construct struct pt_regs on stack */
	pushq	$__USER_DS			/* pt_regs->ss */
	pushq	PER_CPU_VAR(rsp_scratch)	/* 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 
     //恢复进程信息
     ...
     popq	%rdi
	 popq	%rsp
	 USERGS_SYSRET64  //返回
     ...
END(entry_SYSCALL_64)

第二层

(2)然后跳转到了arch/x86/entry/common.c:290 目录下的 do_syscall_64 函数,在ax寄存器中获取到系统调用号,然后去执行系统调用内容:

__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); //nr系统调用号 syscall_trace_enter检查用于trace服务 
    if (x32_enabled) {           
        nr &= ~__X32_SYSCALL_BIT;
        if (unlikely(nr >= NR_syscalls))
            goto bad;
        nr = array_index_nospec(nr, NR_syscalls);
        goto good;
    } else {
        nr &= ~0U;
        if (unlikely(nr >= NR_non_x32_syscalls))
            goto bad;
        nr = array_index_nospec(nr, NR_non_x32_syscalls); //NR_non_x32_syscalls 最大系统调用号
good:
        regs->ax = sys_call_table[nr](regs); 
    }
bad:
    syscall_return_slowpath(regs);
}

接下来,在编译的过程中,需要根据 syscall_64.tbl 生成 unistd_64.h。生成方式在 arch/x86/entry/syscalls/Makefile 中。

这里面会使用两个脚本,其中第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成 #define __NR_open;

第二个脚本 arch/x86/entry/syscalls/syscalltbl.sh,会在文件中生成 __SYSCALL(__NR_open, sys_open)。

这样,unistd_64.h 是对应的系统调用号和系统调用实现函数之间的对应关系。

在文件 arch/x86/entry/syscall_64.c,定义了这样一个表,里面 include 了这个头文件,这样所有的 sys_ 系统调用就都在这个表里面了

其中sys_call_table()定义在arch/x86/entry/syscall_64.c :21

asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
    /*
     * Smells like a compiler bug -- it doesn‘t work
     * when the & below is removed.
     */
    [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
}; 

通过引入<asm/syscalls_64.h> 来找到系统调用对应的入口

 851 #ifdef CONFIG_X86
 852 __SYSCALL_64(171, __x64_sys_setdomainname, ) 
 853 #else /* CONFIG_UML */
 854 __SYSCALL_64(171, sys_setdomainname, )
 855 #endif

第一层

(3)然后程序跳转到kernel/sys.c下的__x64_sys_setdomainname 函数,开始执行:

SYSCALL_DEFINE2(setdomainname, char __user *, name, int, len)      
{
    int errno;
    char tmp[__NEW_UTS_LEN];

    if (!ns_capable(current->nsproxy->uts_ns->user_ns, CAP_SYS_ADMIN)) //检查cap_sys_admin权限
        return -EPERM; //权限不足
    if (len < 0 || len > __NEW_UTS_LEN)
        return -EINVAL; // 参数不对

    errno = -EFAULT; //地址错
    if (!copy_from_user(tmp, name, len)) { //copy_from_user将name指向的字符串从用户空间拷贝到内核空间,失败返回没有被拷贝的字节数,成功返回0  来完成必须的检查以及内核空间与用户空间之间数据的来回拷贝
        struct new_utsname *u;
        down_write(&uts_sem);
        u = utsname();
        memcpy(u->domainname, tmp, len);
        memset(u->domainname + len, 0, sizeof(u->domainname) - len);
        errno = 0;
        uts_proc_notify(UTS_PROC_DOMAINNAME); //处理任务
        up_write(&uts_sem);
    }
	return errno;
}

(4)函数执行完后回到步骤(3)中的 syscall_return_slowpath(regs); 准备进行恢复现场:

(5)接着程序再次回到arch/x86/entry/entry_64.S,执行恢复现场,最后两句完成了堆栈的切换。

深入理解系统调用

附:相关知识-学习笔记

汇编指令学习:

x86架构

Intel:Windows派系 -> vc编译器

AT&T:Linux/iOS派系 -> gcc编译器

寄存器(16位):

ax bx cx dx 通用数据

sp 堆栈指针 bp 基址指针

ip 指令指针(下一条)

cs ds ss es 段 si di 变址 flag 标志

16位:- - push %ax

32位:l e pushl %eax

64位:q r pushq %rax

8086常用指令(16位为例):

mov ax,1122H //将1122H存入寄存器ax

jmp ax //如果ax是1000H,那么IP将被改为1000H

add ax,1111H //将寄存器ax中的值加上1111H再赋值给ax //sub类似

ret //栈顶值出栈,给IP

lea dx,1111H //把偏移地址存到dx

cmp 比较

inc 加一 dec减一

mul 无符号乘法 div 无符号除法

shl shr 逻辑左移/右移

call 过程调用 ret 过程返回

proc 定义过程 endp过程结束

segment 定义段 ends段结束

end程序结束

大小端:

大端模式(Big Endian):数据的低字节保存在内存的高地址。

小端模式(Little Endian):数据的低字节保存在内存的低地址。(从右到左保存)(8086、X86是小端)

gcc-gdb使用方法学习:

源文件123.c编译:gcc 123.c -o 123 得到123可执行文件

然后 gdb 123 进行调试:b/c/s/...

gdb调试基础知识:

r : run 运行程序

b : break 设置断点

c : continue

bt : 查看堆栈状况

n : next 执行下一条语句(不进入函数调用)

s : step 执行下一条语句(若是函数调用,则进入)

q : quit 结束调试

l : list 显示多行源代码

print 打印内部变量值

附2 自定义syscall

https://blog.csdn.net/qq_44222849/article/details/105754952

深入理解系统调用

上一篇:二分查找,开闭区间


下一篇:LeetCode 剑指 Offer 20. 表示数值的字符串