深入理解Linux系统调用:write/writev

实验要求:

  • 找一个系统调用,系统调用号学号最后2位相同的系统调用
  • 通过汇编指令触发该系统调用
  • 通过gdb跟踪该系统调用的内核处理过程
  • 重点阅读分析系统调用入口的:保存现场、恢复现场和系统调用返回,以及重点关注系统调用过程中内核堆栈状态的变化

 

一、系统调用相关知识

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

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

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

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

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

 

二、环境准备

1. 安装开发工具:

sudo apt install build-essential
sudo apt install qemu
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 <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char buffer[50] = "hello==>qingyang2199
";   //buffer里面写上String类型的内容
    int count;
    int fd = open ("abc.txt",O_RDWR);
    if (fd == -1)
    {
        fprintf(stderr,"can't open file:[%s]
","abc.txt");  //打不开文件
        exit(EXIT_FAILURE);
    }
    count = write(fd,buffer,strlen(buffer));  //在这里【write函数】将buffer里的内容,写入文件abc.txt
    if (count == -1)
    {
        fprintf(stderr,"write error
");  //写的时候出错
        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 <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char buffer[50] = "hello==>qingyang2199
";   //buffer里面写上String类型的内容
    int count;
    int fd = open ("abc.txt",O_RDWR);
    if (fd == -1)
    {
        fprintf(stderr,"can't open file:[%s]
","abc.txt");  //打不开文件
        exit(EXIT_FAILURE);
    }

    //count = write(fd,buffer,strlen(buffer));  //这行被下面的asm替换

//------------------asm汇编代码-------------------// asm volatile( "movq %3, %%rdx " // 参数3 "movq %2, %%rsi " // 参数2 "movq %1, %%rdi " // 参数1 "movl $0x1,%%eax " // 传递系统调用号(入口地址0x1,从Write.S中得知,如下图:) "syscall " // 系统调用 "movq %%rax,%0 " // 结果存到%0 就是count中 :"=m"(count) //输出到count :"a"(fd),"b"(buffer),"c"(strlen(buffer)) //对应输入的三个参数 ); //------------------asm汇编代码-------------------//
if (count == -1) { fprintf(stderr,"write error "); //写的时候出错 exit(EXIT_FAILURE); } exit(EXIT_SUCCESS); }

编译新写的汇编程序:

gcc -o Write-asm Write-asm.c -static

 

然后运行汇编程序:

./Write-asm

 

write汇编有问题,感谢热心同学指导,我替换为了writev,用来实现write相同的功能:

write.c:

#include <stdio.h>
#include <sys/uio.h>
 
/*
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 
",str_len);
    return 0;
}

从write.S汇编代码中得知,入口地址0x14:

 

write-asm.c:

#include <stdio.h>
#include <sys/uio.h>
 
/*
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
	"  // 参数3
        "movq %1, %%rsi
	"   //  参数2
        "movq $0x1, %%rdi
	"  //  参数1 
        "movl $0x14,%%eax
	" //  传递系统调用号
        "syscall
	"          //  系统调用
        "movq %%rax,%0
	"    //  结果存到%0 就是str_len中
        :"=m"(str_len) // 输出
        :"g"(vec) // 输入
    );
        
    puts("");
    printf("Write bytes: %d 
",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指令,恢复了rdirsp寄存器。系统调用完成:

实验操作部分到此结束,下面是工作机制的理论分析

 

五、分析系统调用的工作机制

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):数据的低字节保存在内存的低地址(从右到左保存)(8086、X86是小端)

 

gcc-gdb使用方法学习:

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

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

gdb调试基础知识:

  • : run 运行程序
  • b : break 设置断点
  • c : continue
  • bt : 查看堆栈状况
  • n : next 执行下一条语句(不进入函数调用)
  • s : step 执行下一条语句(若是函数调用,则进入)
  • q : quit 结束调试
  • l : list 显示多行源代码
  • print 打印内部变量值
原文地址:https://www.cnblogs.com/qyf2199/p/12890688.html