深入理解系统调用
十月 25, 2023 [系统调用] #syscalls深入理解系统调用
系统调用相关知识
系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进⼊内核态。 系统调用具有以下功能和特性: 把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,⽤户态进程不用直接与硬件设备打交道。 极⼤地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产⽣安全隐患,可能引起系统崩溃。 使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接⼝(api)代替了,不会有紧密的关系,便于在不同系统间移植。

inux上的系统调用实现原理
要想实现系统调用,主要实现以下几个方面:
- 通知内核调用一个哪个系统调用
- 用户程序把系统调用的参数传递给内核
- 用户程序获取内核返回的系统调用返回值
通知内核调用一个哪个系统调用
每个系统调用都有一个系统调用号,系统调用发生时,内核就是根据传入的系统调用号来知道是哪个系统调用的。 在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中。

实验环境准备
sudo apt install build-essential \
qemu qemu-system-x86 \
libncurses5-dev bison flex libssl-dev libelf-dev \
axel
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
cd linux-5.4.34
make defconfig #默认配置基于'x86_64_defconfig'
make menuconfig
#打开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让加载到内核时是随机一个地址,如果加载内核一直同一个地址,容易被黑客攻击,这里为了实验方便,把它关闭。
make -j$(nproc) #编译内核,需要几分钟的时间
#测试一下,不能正常加载运行
qemu-system-x86_64 -kernel arch/x86/boot/bzImage //在没有xwindow的情况下运行qemu 需要使用 -display curses
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
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/
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
chmod +x init
find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../rootfs.cpio.gz
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
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 运行到断点处)
通过汇编指令触发该系统调用
- 首先查看系统调用表,选择setdomainname
cat ~/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl
第一列的数字是系统调用号。 第三列是系统调用的名字。 第四列是系统调用在内核的实现函数。 如上图,171是写调用: 系统调用 setdomainname,函数入口为 __x64_sys_setdomainname。
#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;
}
#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
qemu-system-x86_64 -kernel ~/linux-5.4.34/arch/x86/boot/bzImage -initrd rootfs.cpio.gz -S -s -nographic -append "console=ttyS0"
分析系统调用在内核态的工作机制
应用程序调用一个库函数xyz()
库函数内封装了一个系统调用SYSCALL,它将参数(系统调用号)传递给内核并触发中断
触发中断后,内核进行中断处理,执行系统调用处理函数 (5.0内核中是entry_INT80_32,不再是system_call了)
系统调用处理函数会根据系统调用号,选择相应的系统调用服务例程(在这里是sys_xyz),真正开始处理该系统调用
其中 setdomainname函数由glibc实现 源码位于glibc下的./sysdeps/unix/sysv/linux/x86_64/sysdep.h 比如 syscall0的定义
#undef internal_syscall0
#define internal_syscall0(number, dummy...) \
({ \
unsigned long int resultvar; \
asm volatile ( \
"syscall\n\t" \
: "=a" (resultvar) \
: "0" (number) \
: "memory", REGISTERS_CLOBBERED_BY_SYSCALL); \
(long int) resultvar; \
})
其中的 "syscall\n\t" 为调用特殊模块寄存器(Model Specific Registers,简称 MSR)。这种寄存器是 CPU 为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。 其值由人为定义.相当于手动配置的常量内存,由系统初始化时指定.
在系统初始化的时候,trap_init 除了初始化上面的中断模式,这里面还会调用 cpu_init->syscall_init。*arch/x86/kernel/cpu/common.c 的 syscall_init 中初始化代码:
void syscall_init(void)
{
extern char _entry_trampoline[];
extern char entry_SYSCALL_64_trampoline[];
int cpu = smp_processor_id();
unsigned long SYSCALL64_entry_trampoline =
(unsigned long)get_cpu_entry_area(cpu)->entry_trampoline +
(entry_SYSCALL_64_trampoline - _entry_trampoline);
wrmsr(MSR_STAR, 0, (__USER32_CS << 16) | __KERNEL_CS);
if (static_cpu_has(X86_FEATURE_PTI))
wrmsrl(MSR_LSTAR, SYSCALL64_entry_trampoline);
else
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 和压栈动作保存现场。
然后跳转到了arch/x86/entry/common.c:290 目录下的 do_syscall_64 函数,在ax寄存器中获取到系统调用号,然后去执行系统调用内容: 接下来,在编译的过程中,需要根据 syscall_64.tbl 生成 unistd_64.h。生成方式在 arch/x86/entry/syscalls/Makefile 中。 这里面会使用两个脚本,其中第一个脚本 arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成 __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 通过引入<asm/syscalls_64.h> 来找到系统调用对应的入口
然后程序跳转到kernel/sys.c下的__x64_sys_setdomainname 函数,开始执行: 函数执行完后回到步骤(3)中的 syscall_return_slowpath(regs); 准备进行恢复现场: 接着程序再次回到arch/x86/entry/entry_64.S,执行恢复现场,最后两句完成了堆栈的切换。
