通过汇编指令触发该系统调用
通过gdb跟踪该系统调用的内核处理过程
重点阅读分析系统调用入口的:保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化
一、系统调用相关知识
系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进?内核态。
系统调用具有以下功能和特性:
把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,?户态进程不用直接与硬件设备打交道。
极?地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产?安全隐患,可能引起系统崩溃。
使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接?(api)代替了,不会有紧密的关系,便于在不同系统间移植。
1. Linux上的系统调用实现原理
要想实现系统调用,主要实现以下几个方面:
-
通知内核调用一个哪个系统调用
-
用户程序把系统调用的参数传递给内核
-
用户程序获取内核返回的系统调用返回值
下面看看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
- 第四层 操作系统
五、分析系统调用在内核态的工作机制
- 应用程序调用一个库函数xyz()
- 库函数内封装了一个系统调用SYSCALL,它将参数(系统调用号)传递给内核并触发中断
- 触发中断后,内核进行中断处理,执行系统调用处理函数 (5.0内核中是entry_INT80_32,不再是system_call了)
- 系统调用处理函数会根据系统调用号,选择相应的系统调用服务例程(在这里是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 打印内部变量值