实验要求:
- 找一个系统调用,系统调用号为学号最后2位相同的系统调用
- 通过汇编指令触发该系统调用
- 通过gdb跟踪该系统调用的内核处理过程
- 重点阅读分析系统调用入口的:保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化
一、系统调用相关知识
系统调用(system call)利用陷阱(trap),是异常(Exception)的一种,从用户态进⼊内核态。
系统调用具有以下功能和特性:
把用户从底层的硬件编程中解放出来。操作系统为我们管理硬件,⽤户态进程不用直接与硬件设备打交道。
极⼤地提高系统的安全性。如果用户态进程直接与硬件设备打交道,会产⽣安全隐患,可能引起系统崩溃。
使用户程序具有可移植性。用户程序与具体的硬件已经解耦合并用接⼝(api)代替了,不会有紧密的关系,便于在不同系统间移植。
二、环境准备
1. 安装开发工具:
sudo apt install build-essential sudo apt install qemu qyf
sudo apt install libncurses5-dev bison flex libssl-dev libelf-dev sudo apt install 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)
5. 编译内核
make -j$(nproc) #编译内核,需要几分钟的时间
#测试一下,不能正常加载运行
qemu-system-x86_64 -kernel arch/x86/boot/bzImage
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 qingyang-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
QEMU界面如下,第一步系统配置完成:
三、通过汇编指令触发该系统调用
1. 首先查看系统调用表,我的学号末尾两位为01
cat ~/linux-5.4.34/arch/x86/entry/syscalls/syscall_64.tbl
如上图,01是写调用: 系统调用 write
,函数入口为 __x64_sys_write
。
2. 自己写一个简单C语言程序Write.c,通过这个程序触发系统调用write:
#include
#include
#include
#include
#include <string.h>
int main(void)
{
char buffer[50] = "hello==>qingyang2199\n"; //buffer里面写上String类型的内容
int count;
int fd = open ("abc.txt",O_RDWR);
if (fd == -1)
{
fprintf(stderr,"can't open file:[%s]\n","abc.txt"); //打不开文件
exit(EXIT_FAILURE);
}
count = write(fd,buffer,strlen(buffer)); //在这里【write函数】将buffer里的内容,写入文件abc.txt
if (count == -1)
{
fprintf(stderr,"write error\n"); //写的时候出错
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
gcc编译(这里采用静态编译)后运行,输出结果:
gcc -o Write Write.c -static
生成可执行文件后,还需要一个abc.txt:
然后执行可执行文件Write:
可见,write的作用是将buffer里的内容写入文件。
3.编写汇编程序Write-asm.c,触发write系统调用:
写Write-asm.c之前,还需要从反汇编Write来获取一些信息:
objdump -S Write >Write.S #反汇编
从Write.S汇编代码中得知,入口地址0x1:
Write.c里面的write函数的那一行:
count = write(fd,buffer,strlen(buffer)); //在这里【write函数】将buffer里的内容,写入文件abc.txt
编写汇编程序Write-asm.c,只要把上面的Write.c里面的write函数的那一行,改写成汇编代码就可以了:
#include
#include
#include
#include
#include <string.h>
int main(void)
{
char buffer[50] = "hello==>qingyang2199\n"; //buffer里面写上String类型的内容
int count;
int fd = open ("abc.txt",O_RDWR);
if (fd == -1)
{
fprintf(stderr,"can't open file:[%s]\n","abc.txt"); //打不开文件
exit(EXIT_FAILURE);
}
//count = write(fd,buffer,strlen(buffer)); //这行被下面的asm替换
//------------------asm汇编代码-------------------//
asm volatile(
"movq %3, %%rdx\n\t" // 参数3
"movq %2, %%rsi\n\t" // 参数2
"movq %1, %%rdi\n\t" // 参数1
"movl $0x1,%%eax\n\t" // 传递系统调用号(入口地址0x1,从Write.S中得知,如下图:)
"syscall\n\t" // 系统调用
"movq %%rax,%0\n\t" // 结果存到%0 就是count中
:"=m"(count) //输出到count
:"a"(fd),"b"(buffer),"c"(strlen(buffer)) //对应输入的三个参数
);
//------------------asm汇编代码-------------------//
if (count == -1)
{
fprintf(stderr,"write error\n"); //写的时候出错
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
编译新写的汇编程序:
gcc -o Write-asm Write-asm.c -static
然后运行汇编程序:
./Write-asm
write汇编有问题,感谢热心同学指导,我替换为了writev,用来实现write相同的功能:
write.c:
#include
#include
/*
struct iovec
{
void *iov_base; //指向一个char数组
size_t iov_len; //大小
};
*/
int main(int argc,char *argv[])
{
struct iovec vec[2];
char buf[]="qingyang2199";
int str_len;
vec[0].iov_base=buf;
vec[0].iov_len=8;
// 1 标准输出
// vec 缓冲区
// 1 缓冲区长度
str_len=writev(1,vec,1); //调用writev()函数
puts("");
printf("Write bytes: %d \n",str_len);
return 0;
}
从write.S汇编代码中得知,入口地址0x14:
write-asm.c:
#include
#include
/*
struct iovec
{
void *iov_base; //指向一个char数组
size_t iov_len; //大小
};
*/
int main(int argc,char *argv[])
{
struct iovec vec[2];
char buf[]="qingyang2199";
int str_len;
vec[0].iov_base=buf;
vec[0].iov_len=8;
// 1 标准输出
// vec 缓冲区
// 1 缓冲区长度
//str_len=writev(1,vec,1); //调用writev()函数
asm volatile(
"movq $0x1, %%rdx\n\t" // 参数3
"movq %1, %%rsi\n\t" // 参数2
"movq $0x1, %%rdi\n\t" // 参数1
"movl $0x14,%%eax\n\t" // 传递系统调用号
"syscall\n\t" // 系统调用
"movq %%rax,%0\n\t" // 结果存到%0 就是str_len中
:"=m"(str_len) // 输出
:"g"(vec) // 输入
);
puts("");
printf("Write bytes: %d \n",str_len);
return 0;
}
运行一下汇编程序:
./write
四、通过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:
4. 设置断点跟踪内核:
在虚拟机中执行 write-asm
,会卡住:
在gdb界面查看断点分析:
5. gdb界面bt查看堆栈:
查看此时堆栈情况,有4层:
- 第一层/ 顶层
__x64_sys_writev
系统调用函数 - 第二层
do_syscall_64
获取系统调用号, 前往系统调用函数 - 第三层
entry_syscall_64
中断入口,做保存线程工作,调用do_syscall_64
- 第四层 内部不可见
6. 继续深入查看系统调用:
断点定位为到 /home/qyf001/linux-5.4.34/fs/read_write.c
1128行:
writev()函数内调用 do_writev():
进入do_writev函数查看:
可知,这里是完成程序内容的地方,前期的保存现场工作已经完成。
执行完这个函数,发现回到了函数堆栈上一层的do_sys_call_64
中 ,接下来要执行的 syscall_return_slowpath
函数要为恢复现场做准备。
继续执行(next),回到了函数堆栈的上一层,entry_SYSCALL_64:
接下来执行的是用于恢复现场的汇编指令:
最后伴随着两个pop
指令,恢复了rdi
和rsp
寄存器。系统调用完成:
实验操作部分到此结束,下面是工作机制的理论分析
五、分析系统调用的工作机制
writev函数从用户空间到内核空间的过程:
- 第一层/ 顶层 __x64_sys_writev 系统调用函数
- 第二层 do_syscall_64 获取系统调用号, 前往系统调用函数
- 第三层 entry_syscall_64 中断入口,做保存线程工作,调用 do_syscall_64
- 第四层 内部不可见
系统调用全部步骤:
(1)汇编指令 syscall 触发系统调用,通过MSR寄存器找到了中断函数入口,此时,代码执行到/home/qyf001/linux-5.4.34/arch/x86/entry/entry_64.S 目录下的ENTRY(entry_SYSCALL_64)入口,然后开始通过swapgs 和压栈动作保存现场。
(2)然后跳转到了/linux-5.4.34/arch/x86/entry/common.c 目录下的 do_syscall_64 函数,在ax寄存器中获取到系统调用号,然后去执行系统调用内容:
(3)然后程序跳转到/linux-5.4.34/fs/read_write.c 下的do_writev 函数,开始执行:
(4)函数执行完后回到步骤(3)中的 syscall_return_slowpath(regs); 准备进行恢复现场:
(5)接着程序再次回到arch/x86/entry/entry_64.S,执行恢复现场,最后两句完成了堆栈的切换。
过程分步骤截图:
(1)汇编指令 syscall 触发系统调用,通过MSR寄存器找到了中断函数入口,此时,代码执行到/home/qyf001/linux-5.4.34/arch/x86/entry/entry_64.S 目录下的ENTRY(entry_SYSCALL_64)入口,然后开始通过swapgs 和压栈动作保存现场:
(2)然后跳转到了/linux-5.4.34/arch/x86/entry/common.c 目录下的 do_syscall_64 函数,在ax寄存器中获取到系统调用号,然后去执行系统调用内容:
(3)然后程序跳转到/linux-5.4.34/fs/read_write.c 下的do_writev 函数,开始执行功能函数【这是本次系统调用最深的一层】:
(4)函数执行完后回到步骤(2)中的 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):数据的低字节保存在内存的低地址。(从右到左保存)(X86是小端)
堆栈状态学习:
SS 作为栈段的段地址,
SS:SP 指向栈顶元素。
现在假设SS=1000H,SP=0004H,AX寄存器中存放着2266H,并且现在栈的内存空间都是存放00H。
SS:SP=1000H*10H+0004H=10004H
压栈:SP=SP-2
pop: pop操作后,内存中的值是不会清0的,它们还保持着原来的值。假如下次再进行将3399H入栈是,那么33H就会覆盖22H,99H就是覆盖66H。
stackoverflow 栈越界
汇编语言中栈是不会自动判断栈是否越界的,那么就可能出现如下图push和pop越界问题:
无论是push还是pop越界都是非常危险的,因为栈外部的内存中可能存放其它任意数据,可能是代码、重要数据等,将它们覆盖或者拿出来使用都可能发生不可预知的严重错误。
gcc-gdb使用方法学习:
源文件123.c编译:gcc 123.c -o 123 得到123可执行文件
然后 gdb 123 进行调试:b/c/s/...
gdb调试基础知识
- r : run 运行程序
- q : quit
- b : break 设置断点
- c : continue
- l : list 显示多行源代码
- step 执行下一条语句(若是函数调用,则进入)
- next 执行下一条语句(不进入函数调用)
- print 打印内部变量值