深入理解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\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 <unistd.h> #include <stdio.h> #include <fcntl.h> #include <stdlib.h> #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 <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 \n",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\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指令,恢復了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 打印內部變量值

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM