Linux操作系統系統編程:x86-64架構下的系統調用
在Linux操作系統里,系統編程如同精密儀器的核心部件,掌控著系統運行的關鍵。而 x86-64 架構下的系統調用,更是連接用戶空間程序與內核的關鍵橋梁。你可以把用戶空間的程序想象成一個個 “工匠”,它們有著各式各樣的需求,比如讀取文件數據、展示圖像、與其他程序交流信息等。但用戶空間就像被一道無形的屏障圍住,“工匠們” 無法直接觸碰內核掌管的磁盤、內存、網絡接口等底層資源。這時,系統調用就如同 “工匠們” 手中的神奇工具,當他們發出特定指令,就能突破屏障,讓內核這位 “大管家” 提供相應服務。
從計算機發展歷程看,系統調用一直在不斷革新。早期操作系統資源有限,系統調用種類和功能少,程序與內核交互簡單。隨著硬件性能提升、軟件場景變復雜,x86-64 架構持續演進,系統調用機制也在優化,指令集、參數傳遞方式不斷改進,與內核功能深度融合,推動著 Linux 系統編程不斷進步。當下,不管是數據中心的高性能應用,還是手持設備里的便捷 APP,高效的系統調用機制都是背后的有力支撐。理解 x86-64 架構下的系統調用,不僅是掌握 Linux 系統編程的關鍵,更是開啟現代計算機高效運行奧秘的鑰匙。現在,就讓我們一起深入探索 x86-64 架構下系統調用的精妙之處 。
一、x86-64系統調用初相識
在計算機的世界里,系統調用可謂是連接用戶程序與操作系統內核的橋梁,有著不可或缺的地位。它是操作系統提供給用戶程序的一組 “特殊接口”,用戶程序能夠借助這些接口,請求內核提供各種服務,像文件操作、進程管理、內存分配等等。可以說,系統調用是操作系統內核向外提供服務的主要途徑,也是用戶程序與操作系統交互的關鍵方式。
系統調用與常規函數調用不同,因為被調用的代碼位于內核中。需要特殊指令來使處理器執行從用戶態切換到特權態(ring 0)。此外,調用的內核代碼通過系統調用號來標識,而不是函數地址。
當用戶空間程序需要執行一個系統調用時,它會使用特定的指令(例如x86架構中的syscall指令)觸發從用戶態到內核態的切換。在進行切換時,處理器會將當前的上下文保存起來,包括寄存器狀態和程序計數器等。然后,處理器會跳轉到預定義的系統調用入口點,該入口點由系統調用號標識。
在內核中,系統調用表(system call table)維護了系統調用號與相應內核函數的映射關系。當處理器進入內核態并跳轉到系統調用入口點時,內核會根據系統調用號找到對應的內核函數來執行相應的操作。內核函數完成后,處理器將恢復之前保存的上下文,并返回到用戶空間程序繼續執行。
通過使用系統調用號而不是函數地址,內核能夠提供一種標準化的、跨平臺的系統調用接口。不同的系統調用由唯一的系統調用號進行標識,這樣用戶空間程序可以使用相同的系統調用號在不同的操作系統上進行系統調用,而無需關心具體的內核實現;Linux 應用程序要與內核通信,需要通過系統調用。系統調用,相當于用戶空間和內核空間之間添加了一個中間層。
圖片
因此,系統調用的機制涉及從用戶態到內核態的切換、系統調用號的標識和匹配,以及內核中相應的處理邏輯,以實現用戶空間程序與內核的交互,系統調用作用:
- 內核將復雜困難的邏輯封裝起來,用戶程序通過系統來操作硬件,極大簡化了用戶程序開發。
- 降低用戶程序非法操作的風險,保證操作系統能安全,穩定地工作。
- 系統有效地分離了用戶程序和內核開發。
- 通過接口訪問黑盒操作,使得程序有更好的移植性。
而 x86-64 系統調用,指的是在 x86-64 架構的計算機系統中,用戶空間程序與內核進行交互的主要機制。x86-64 是一種廣泛應用的計算機硬件架構,包括我們日常使用的桌面電腦、服務器等,很多都是基于這個架構。在這個架構下的系統調用,有著特定的實現方式和規則。
或許你會好奇,x86-64 系統調用與我們平常熟悉的函數調用有啥不一樣呢?從本質上來說,普通函數調用是在用戶空間內進行的,執行過程相對簡單。當我們在程序里調用一個普通函數時,程序直接跳轉到函數的代碼處執行,執行完畢后再返回調用點繼續執行后續代碼,整個過程都在用戶空間,不會涉及到系統內核。比如說,在 C 語言中調用一個自定義的函數add(int a, int b),計算兩個整數的和,這就是一個普通函數調用:
#include <stdio.h>
int add(int a, int b) {
return a + b;
}
int main() {
int result = add(3, 5);
printf("結果是: %d\n", result);
return 0;
}
在這個例子里,add函數在用戶空間執行,調用和返回都很直接。
但 x86-64 系統調用可就復雜多了。由于它涉及到用戶空間程序請求內核服務,所以需要進行特權級別的切換,從用戶態切換到內核態。簡單來講,用戶態下程序的操作權限有限,而內核態下程序擁有更高的權限,可以訪問系統的關鍵資源和執行特權指令。當進行系統調用時,程序需要通過特定的指令(比如syscall指令)來觸發從用戶態到內核態的切換,然后內核根據系統調用號找到對應的內核函數進行執行,執行完畢后再切換回用戶態,并返回結果給用戶程序。這就好比你要進入一個高級機密區域(內核態)獲取某些重要資源(執行內核服務),必須先經過嚴格的身份驗證(特權級切換),才能進入并獲取所需。
二、x86-64 系統調用原理
2.1系統調用流程
為了更直觀地理解 x86-64 系統調用的工作過程,我們通過一個詳細的流程圖表(如下)和具體的程序實例來深入剖析。就以一個簡單的文件讀取程序為例,看看它是如何進行系統調用的。
假設我們有一個用 C 語言編寫的簡單文件讀取程序:
#include <stdio.h>
int main() {
FILE *file = fopen("test.txt", "r");
if (file == NULL) {
perror("無法打開文件");
return 1;
}
char buffer[100];
size_t bytes_read = fread(buffer, 1, sizeof(buffer), file);
if (bytes_read > 0) {
printf("讀取的內容: %s\n", buffer);
}
fclose(file);
return 0;
}
在這個程序中,當執行fopen函數時,實際上它會調用底層的系統調用open來打開文件。具體過程如下:
- 用戶空間程序發起系統調用請求:程序執行到fopen函數時,它會向操作系統發起打開文件的請求,這就觸發了系統調用。
- 設置系統調用號和參數到寄存器:根據 x86-64 的調用約定,會將系統調用號(比如open系統調用在 x86-64 系統中的調用號是 2)存入%rax寄存器,將文件名(這里是test.txt)的地址存入%rdi寄存器,將打開文件的模式(這里是只讀模式"r"對應的標志)存入%rsi寄存器。
- 執行 syscall 指令:當所有參數設置好后,程序執行syscall指令,這個指令是觸發系統調用的關鍵,它會引發處理器從用戶態切換到內核態。
- 處理器切換到內核態:syscall指令執行后,處理器的特權級別提升,從用戶態進入內核態,此時程序可以訪問內核的資源和執行特權指令。
- 內核根據系統調用號查找對應的內核函數:內核接收到系統調用請求后,會從%rax寄存器中讀取系統調用號,然后在內核的系統調用表中查找對應的內核函數。比如對于open系統調用號 2,內核會找到對應的sys_open函數。
- 執行內核函數:內核調用sys_open函數,該函數會進行一系列的操作,如檢查文件權限、查找文件的 inode 等,最終完成文件的打開操作,并返回一個文件描述符。
- 內核函數執行完畢,返回結果到寄存器:sys_open函數執行完成后,會將結果(文件描述符或者錯誤碼)存入%rax寄存器。
- 處理器切換回用戶態:內核處理完系統調用后,通過特定的機制(如sysret指令)將處理器的特權級別從內核態降回用戶態。
- 用戶空間程序從寄存器獲取結果:用戶空間程序繼續執行,從%rax寄存器中獲取系統調用的結果。如果%rax的值是一個有效的文件描述符,那么fopen函數就可以繼續進行后續的文件讀取操作;如果%rax的值是一個錯誤碼,那么fopen函數會根據錯誤碼進行相應的錯誤處理,比如在程序中通過perror函數輸出錯誤信息。
2.2調用約定深度剖析
參數傳遞規則:依據 x86-64 ABI(應用二進制接口)文檔,在進行系統調用時,參數的傳遞有著明確的規則。參數 1 對應%rdi寄存器,參數 2 對應%rsi寄存器,參數 3 對應%rdx寄存器,參數 4 對應%r10寄存器,參數 5 對應%r8寄存器,參數 6 對應%r9寄存器 。例如,在前面提到的open系統調用中,文件名作為參數 1,就會被傳遞到%rdi寄存器;打開文件的模式作為參數 2,會被傳遞到%rsi寄存器。
并且,系統調用的參數數量限制為 6 個,如果需要傳遞更多參數,可能需要將多個參數打包成一個結構體,通過內存傳遞。同時,參數類型限制為INTEGER和MEMORY。INTEGER類型指的是可以存放在通用寄存器中的整型數據,比如int、long等;MEMORY類型則是指通過內存(堆棧)來傳遞和返回的數據類型,像結構體、數組等。
系統調用號作用:系統調用號在 x86-64 系統調用中起著至關重要的作用。它通過%rax寄存器傳遞,是內核識別系統調用的唯一標識。每一個系統調用在內核中都有一個對應的系統調用號,就如同函數指針一樣,引導程序找到對應的內核函數執行。比如,在 Linux 系統中,write系統調用的系統調用號是 1,exit系統調用的系統調用號是 60。
當用戶空間程序發起系統調用時,將相應的系統調用號存入%rax寄存器,內核接收到系統調用請求后,首先從%rax寄存器讀取系統調用號,然后根據這個調用號在內核的系統調用表中查找對應的內核函數。系統調用表是一個存儲著系統調用號和對應內核函數指針的數組,通過系統調用號作為索引,內核可以快速定位到要執行的內核函數,從而實現對用戶請求的處理。
系統調用指令解析:syscall指令是 x86-64 系統調用的核心指令,它的執行過程相當復雜。當程序執行syscall指令時,首先會保存返回地址到%rcx寄存器,這個返回地址就是syscall指令的下一條指令的地址,以便系統調用完成后能夠返回正確的位置繼續執行用戶程序。接著,syscall指令會替換指令指針寄存器%rip,將其值替換為 IA32_LSTAR MSR(模型特定寄存器)中存儲的地址,這個地址指向內核中系統調用處理程序的入口。
同時,syscall指令還會保存標志寄存器%rflags到%r11寄存器,并使用 IA32_FMASK MSR 對%rflags進行掩碼操作 ,以確保在特權級切換過程中標志位的正確處理。之后,syscall指令會加載新的CS(代碼段寄存器)和SS(堆棧段寄存器)選擇子,其值來源于 IA32_STAR MSR 的特定比特位。通過這一系列操作,syscall指令實現了從用戶態到內核態的快速切換,使得程序能夠進入內核執行系統調用對應的內核函數。
2.3返回值與錯誤碼
當系統調用執行完畢,從內核返回用戶空間時,%rax寄存器保存著系統調用的結果。如果系統調用成功執行,%rax中存儲的就是正常的返回值,比如對于open系統調用,如果文件成功打開,%rax中會返回一個有效的文件描述符;對于read系統調用,如果讀取文件成功,%rax中會返回實際讀取的字節數。然而,如果系統調用過程中發生了錯誤,%rax的值就會在 -4095 至 -1 之間,這個值表示錯誤碼,并且是實際錯誤碼的相反數(即-errno) 。例如,如果%rax的值為 -1,表示發生了EPERM錯誤,即操作不被允許;如果%rax的值為 -2,表示發生了ENOENT錯誤,即文件或目錄不存在。
在 C 語言中,我們可以通過errno全局變量來獲取具體的錯誤碼,然后通過查閱相關的錯誤碼定義(通常在<errno.h>頭文件中),定位具體的錯誤類型,以便進行相應的錯誤處理。比如在前面的文件讀取程序中,如果fopen函數返回NULL,我們可以通過perror函數輸出錯誤信息,perror函數會根據errno的值查找對應的錯誤描述并輸出,幫助我們快速定位和解決問題。
三、用戶空間
我們以一個 Hello world 程序開始,逐步進入系統調用的學習。下面是用匯編代碼寫的一個簡單的程序:
.section .data
msg:
.ascii "Hello World!\n"
len = . - msg
.section .text
.globl main
main:
# ssize_t write(int fd, const void *buf, size_t count)
mov $1, %rdi # fd
mov $msg, %rsi # buffer
mov $len, %rdx # count
mov $1, %rax # write(2)系統調用號,64位系統為1
syscall
# exit(status)
mov $0, %rdi # status
mov $60, %rax # exit(2)系統調用號,64位系統為60
syscall
編譯并運行:
$ gcc -o helloworld helloworld.s
$ ./helloworld
Hello world!
$ echo $?
0
上面這段代碼,是直接從我的一篇文章 使用 GNU 匯編語法編寫 Hello World 程序的三種方法拷貝過來的。那篇文章里還提到了使用int 0x80軟中斷和printf函數實現輸出的方法,有興趣的可以去看下。
四、內核空間
用戶空間通過 syscall 指令,從用戶空間進入內核空間。
4.1內核調試
設置斷點。在內核 write
函數名下斷點,調試跟蹤函數的調用堆棧。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
static ssize_t my_write(struct file *file, const char __user *buf,
size_t len, loff_t *offset)
{
/* 在這里設置斷點 */
/* 打印調用堆棧 */
dump_stack();
/* 寫入操作的具體實現 */
// ...
return len;
}
static struct file_operations fops = {
.write = my_write,
};
static int __init my_init(void)
{
/* 注冊字符設備驅動程序 */
// ...
return 0;
}
static void __exit my_exit(void)
{
/* 注銷字符設備驅動程序 */
// ...
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
調試觸發斷點。查看函數調用堆棧,可以發現 syscall 指令觸發 entry_SYSCALL_64
處理函數。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
pid_t pid = getpid();
// 觸發系統調用
syscall(39, pid, NULL, NULL);
return 0;
}
以上代碼是一個簡單的C程序,在執行期間會通過syscall函數觸發系統調用。你可以將代碼保存為test.c,然后使用gcc進行編譯:gcc -o test test.c。
接下來,你可以使用GDB連接到生成的可執行文件并設置斷點以及跟蹤函數調用堆棧。在終端中輸入gdb ./test啟動GDB調試器。然后按照以下步驟進行操作:
- 在GDB提示符下輸入命令:break main,設置一個斷點在程序的main函數處。
- 輸入命令: run ,運行程序。
- 當程序運行到syscall指令時,會進入內核并跳轉到相應的系統調用處理函數(例如entry_SYSCALL_64)。
- 在entry_SYSCALL_64處理函數處會自動停下,此時你可以使用命令: bt(backtrace) 或者 where 來查看函數調用堆棧信息。
4.2系統調用入口
entry_SYSCALL_64 是 64 位 syscall 指令 入口函數,這個函數通常是由操作系統提供并負責處理所有來自用戶空間發起的系統調用請求。具體實現可能因不同的操作系統而有所差異,但其作用都是為了協調用戶空間和內核空間之間的交互。在不同的架構或操作系統上,對于syscall指令和相應處理函數名稱可能會有所不同。例如,在32位x86架構上使用entry_INT80_32來處理syscall指令。因此,請根據目標平臺和操作系統環境選擇正確的符號名稱和相關文檔來進行調試和理解
初始化系統調用。當 linux 內核啟動時,MSR
特殊模塊寄存器會存儲 syscall 指令的入口函數地址;當 syscall 指令執行后,系統從特殊模塊寄存器中取出入口函數地址進行調用。
#include <linux/kernel.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
// 聲明一個簡單的系統調用函數
asmlinkage long my_syscall(void)
{
printk(KERN_INFO "Hello from custom syscall!\n");
return 0;
}
// 初始化系統調用表
static void init_syscall_table(void)
{
// 獲取syscall table地址
unsigned long *syscall_table = (unsigned long *)kallsyms_lookup_name("sys_call_table");
// 替換對應系統調用函數指針
write_cr0(read_cr0() & (~0x10000)); // 關閉寫保護
syscall_table[__NR_my_syscall] = (unsigned long)my_syscall; // 將自定義系統調用函數指針存儲在syscall table中
write_cr0(read_cr0() | 0x10000); // 開啟寫保護
}
static int __init my_module_init(void)
{
init_syscall_table();
printk(KERN_INFO "Custom syscall module loaded\n");
return 0;
}
static void __exit my_module_exit(void)
{
printk(KERN_INFO "Custom syscall module unloaded\n");
}
module_init(my_module_init);
module_exit(my_module_exit);
入口函數工作流程:
- 程序從用戶空間進入內核空間,保存用戶態現場,載入內核態的信息,程序工作狀態從用戶態轉變為內核態。
- 根據系統調用號,從系統跳轉表中,調用對應的系統調用函數。
- 系統調用函數完成邏輯后,需要從內核空間回到用戶空間,程序內核態轉變為用戶態,需要把之前保存的用戶態現場進行恢復。
ENTRY(entry_SYSCALL_64)
TRACE_IRQS_OFF
subq $FRAME_SIZE, %rsp /* Reserve space for pt_regs */
MOV_LDX(regs, %rsp) /* Save user stack pointer */
cmpl $(nr_syscalls),%eax /* syscall number valid? */
jae badsys
/*
* Load the syscall table pointer into r10 from a global variable.
* We stash it in memory at boot time to workaround boot loader
* address randomization.
*
* movl sys_call_table(,%rax,8),%r10
*
* can be replaced with this:
*
* leal sys_call_table(%rip),%r10
* movq (%r10,%rax,8),%r10
*/
.section ".data", "a"
sys_call_table:
.quad __x64_sys_call_table- sys_call_table
.section ".text", "ax"
leaq sys_call_table(%rip),%r10 /* Get the syscall table address into r10 */
movq (%r10,%rax,8), %r10 /* Load the corresponding system call handler */
在這段代碼中,我們可以看到以下幾個關鍵步驟:
- 首先,通過
subq
指令為 pt_regs 結構體在用戶棧上分配空間,用于保存系統調用的參數和返回值。 - 然后,將用戶棧指針
%rsp
的值保存到regs
寄存器中,以便在系統調用處理函數中可以訪問到用戶棧上的參數。 - 接下來,通過
cmpl
指令檢查系統調用號是否有效。如果系統調用號大于等于nr_syscalls
(即 sys_call_table 數組的長度),則跳轉到badsys
標簽處進行錯誤處理。 - 緊接著,使用
leaq
和movq
指令加載 syscall table 的地址,并從表中獲取對應的系統調用處理函數地址,存儲在寄存器%r10
中。這里有兩種不同的實現方式,一種是直接使用全局變量 sys_call_table 獲取 syscall table 的地址;另一種是先通過 RIP 相對尋址獲取 sys_call_table 地址,并再從表中獲取對應的系統調用處理函數地址。
然后,在代碼中還有其他一些邏輯和錯誤處理部分,在此就不一一列舉了。
gdb 反匯編查看 entry_SYSCALL_64 函數功能
(1)編譯內核并啟動調試模式:
make menuconfig # 配置內核選項(可根據需要進行配置)
make -j$(nproc) # 編譯內核
sudo gdb vmlinux # 啟動 gdb,并加載編譯好的內核文件
(2)在gdb中設置斷點:
break entry_SYSCALL_64 # 在 entry_SYSCALL_64 函數處設置斷點
(3)啟動內核調試:
target remote :1234 # 連接到 QEMU 調試服務器(如果使用 QEMU 進行內核調試)
continue # 繼續執行,使程序運行到設置的斷點處
(4)反匯編查看代碼:
disassemble /m entry_SYSCALL_64 # 使用 disassemble 命令反匯編 entry_SYSCALL_64 函數
struct pt_regs。程序在系統調用后,從用戶空間進入內核空間,保存用戶態現場,保存用戶態傳入參數。
/* arch/x86/include/asm/ptrace.h */
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10; /* 程序傳遞到內核的第 4 個參數。 */
unsigned long r9; /* 程序傳遞到內核的第 6 個參數。 */
unsigned long r8; /* 程序傳遞到內核的第 5 個參數。 */
unsigned long ax; /* 程序傳遞到內核的系統調用號。 */
unsigned long cx; /* 程序傳遞到內核的 syscall 的下一條指令地址。 */
unsigned long dx; /* 程序傳遞到內核的第 3 個參數。 */
unsigned long si; /* 程序傳遞到內核的第 2 個參數。 */
unsigned long di; /* 程序傳遞到內核的第 1 個參數。 */
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax; /* 系統調用號。 */
/* Return frame for iretq
* 內核態返回用戶態需要恢復現場的數據。*/
unsigned long ip; /* 保存程序調用 syscall 的下一條指令地址。 */
unsigned long cs; /* 用戶態代碼起始段地址。 */
unsigned long flags; /* 用戶態的 CPU 標志。 */
unsigned long sp; /* 用戶態的棧頂地址(棧內存是向下增長的)。 */
unsigned long ss; /* 用戶態的數據段地址。 */
/* top of stack page */
};
4.3do_syscall_64
do_syscall_64 函數是 Linux 內核中的關鍵函數之一,它的主要功能是處理 64 位系統調用。當用戶程序通過軟件中斷(syscall)發起系統調用請求時,內核會將控制轉移到 do_syscall_64 函數來執行相應的操作。
具體而言,do_syscall_64 函數完成以下主要功能:
- 獲取系統調用號:從當前進程的 CPU 寄存器或棧中獲取系統調用號,以確定用戶程序請求執行哪個特定的系統調用。
- 參數傳遞:根據系統調用約定,從當前進程的寄存器或堆棧中提取相應數量和類型的參數,并將這些參數傳遞給相應的系統調用處理函數。
- 權限檢查:驗證當前進程是否有足夠權限執行所請求的系統調用。這可能涉及訪問權限、資源配額、權限級別等方面的檢查。
- 系統調用執行:將控制權轉移給與所請求系統調用對應的內核函數,以便在內核模式下執行特定操作。
- 結果返回:如果需要,將系統調用執行結果返回給用戶空間,并更新相應寄存器或內存位置以供用戶程序讀取結果。
ENTRY(entry_SYSCALL_64)
...
call do_syscall_64 /* returns with IRQs disabled */
...
END(entry_SYSCALL_64)
/* arch/x86/entry/common.c */
#ifdef CONFIG_X86_64
__visible void do_syscall_64(unsigned long nr, struct pt_regs *regs) {
struct thread_info *ti;
...
/*
* NB: Native and x32 syscalls are dispatched from the same
* table. The only functional difference is the x32 bit in
* regs->orig_ax, which changes the behavior of some syscalls.
*/
nr &= __SYSCALL_MASK;
if (likely(nr < NR_syscalls)) {
nr = array_index_nospec(nr, NR_syscalls);
/* 通過系統調用跳轉表,調用系統調用號對應的函數。
* 函數返回值保存在 regs->ax 里,最后將這個值,保存到 rax 寄存器傳遞到用戶空間。 */
regs->ax = sys_call_table[nr](regs);
}
syscall_return_slowpath(regs);
}
#endif
4.4系統調用表
系統調用表 syscall_64.tbl
,建立了系統調用號與系統調用函數名的映射關系。腳本會根據這個表,自動生成相關的映射源碼。
#include <stdio.h>
#include <unistd.h>
#include <sys/syscall.h>
// 定義系統調用號與函數名的映射數組
static const char *syscall_names[] = {
[0] = "sys_read",
[1] = "sys_write",
[2] = "sys_open",
// ...
};
int main() {
int i;
// 遍歷系統調用號并打印對應的函數名
for (i = 0; i < sizeof(syscall_names) / sizeof(syscall_names[0]); i++) {
printf("Syscall number %d: %s\n", i, syscall_names[i]);
}
return 0;
}
4.5系統跳轉表(sys_call_table)
運行流程。系統調用的執行流程如下,但是系統調用號、系統跳轉表,系統調用函數,這三者是如何關聯起來的呢?
系統調用的執行流程如下:
- 用戶程序通過編寫系統調用號(或者使用對應的庫函數)來請求操作系統提供某項服務。
- 當用戶程序發起系統調用時,會觸發處理器從用戶態切換到內核態,進入特權模式。
- 處理器將控制權交給操作系統內核,并傳遞系統調用號以及其他必要的參數。
- 操作系統內核根據系統調用號在系統調用表中查找相應的處理函數地址。
- 內核跳轉到對應的系統調用處理函數,開始執行具體的操作。
- 執行完畢后,將結果返回給用戶程序,并再次切換回用戶態。
關于系統調用號、系統跳轉表和系統調用函數之間的關聯:
- 系統調用號:每個系統調用都被賦予一個唯一的編號。例如,在 Linux 中使用 x86_64 架構時,可以在 syscall_64.tbl 文件中找到這些編號定義。它們為每個操作分配了一個特定的數字標識符。
- 系統跳轉表:在內核中,有一個稱為“system_call”或類似名稱的特殊位置存儲著一個指向所有系統調用處理函數地址數組(也稱為“sys_call_table”)的指針。該數組包含了所有可能存在的系統調用處理函數地址。
- 系統調用函數:每個具體的功能對應一個系統調用函數,它們是內核中的實現代碼。這些函數通過在系統跳轉表中查找與其對應的位置來進行調用。
當用戶程序觸發系統調用時,操作系統根據系統調用號從系統跳轉表中獲取對應的處理函數地址,并執行該函數來完成請求的操作。因此,通過系統調用號和系統跳轉表,操作系統能夠將用戶程序的請求路由到正確的系統調用函數上。
syscall's number -> syscall -> entry_SYSCALL_64 -> do_syscall_64 -> sys_call_table -> __x64_sys_write
sys_call_table 的定義。#include <asm/syscalls_64.h> 這行源碼對應的文件是在內核編譯的時候,通過腳本創建的。
/* include/generated/asm-offsets.h */
#define __NR_syscall_max 547 /* sizeof(syscalls_64) - 1 */
/* arch/x86/entry/syscall_64.c */
#define __SYSCALL_64(nr, sym, qual) [nr] = sym,
/* arch/x86/entry/syscall_64.c */
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>
};
Makefile。通過執行 syscalltbl.sh 腳本,解析系統調用文件 syscall_64.tbl 數據,自動生成 syscalls_64.h。
# arch/x86/entry/syscalls/Makefile
syscall64 := $(srctree)/$(src)/syscall_64.tbl
systbl := $(srctree)/$(src)/syscalltbl.sh
quiet_cmd_systbl = SYSTBL $@
cmd_systbl = $(CONFIG_SHELL) '$(systbl)' $< $@
syscalltbl.sh
# arch/x86/entry/syscalls/syscalltbl.sh
...
syscall_macro() {
abi="$1"
nr="$2"
entry="$3"
# Entry can be either just a function name or "function/qualifier"
real_entry="${entry%%/*}"
if [ "$entry" = "$real_entry" ]; then
qualifier=
else
qualifier=${entry#*/}
fi
echo "__SYSCALL_${abi}($nr, $real_entry, $qualifier)"
}
...
syscalls_64.h 文件內容
/* arch/x86/include/generated/asm/syscalls_64.h */
...
#ifdef CONFIG_X86
__SYSCALL_64(0, __x64_sys_read, )
#else /* CONFIG_UML */
__SYSCALL_64(0, sys_read, )
#endif
#ifdef CONFIG_X86
__SYSCALL_64(1, __x64_sys_write, )
#else /* CONFIG_UML */
__SYSCALL_64(1, sys_write, )
#endif
...
三者關系。通過上述操作,sys_call_table 的定義與 syscalls_64.h 文件內容結合起來就是一個完整的數組初始化,將系統調用號,系統調用函數,系統跳轉表三者結合起來了。
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,
[0] = __x64_sys_read,
[1] = __x64_sys_write,
...
系統調用函數。現在雖然搞清楚了系統調用的關系,但是還沒有發現 __x64_sys_write
這個函數是在哪里定義的。答案就在這個宏 SYSCALL_DEFINE3
,將這個宏展開,回頭再看上面 gdb 調試斷點截斷處的那些函數,整個思路就清晰了。
__do_sys_write() (/root/linux-5.0.1/fs/read_write.c:610)
__se_sys_write() (/root/linux-5.0.1/fs/read_write.c:607)
__x64_sys_write(const struct pt_regs * regs) (/root/linux-5.0.1/fs/read_write.c:607)
...
/* fs/read_write.c */
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
size_t, count) {
return ksys_write(fd, buf, count);
}
/* include/linux/syscalls.h */
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
/* arch/x86/include/asm/syscall_wrapper.h */
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long __x64_sys##name(const struct pt_regs *regs); \
ALLOW_ERROR_INJECTION(__x64_sys##name, ERRNO); \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long __x64_sys##name(const struct pt_regs *regs) \
{ \
return __se_sys##name(SC_X86_64_REGS_TO_ARGS(x,__VA_ARGS__)); \
} \
__IA32_SYS_STUBx(x, name, __VA_ARGS__) \
static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))
五、系統調用的定義
read()系統調用是一個很好的初始示例,可以用來探索內核的系統調用機制。它在fs/read_write.c中作為一個簡短的函數實現,大部分工作由vfs_read()函數處理。從調用的角度來看,這段代碼最有趣的地方是函數是如何使用SYSCALL_DEFINE3()宏來定義的。實際上,從代碼中,甚至并不立即清楚該函數被稱為什么。
// linux-3.10/fs/read_write.c
SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget(fd);
ssize_t ret = -EBADF;
if (f.file) {
loff_t pos = file_pos_read(f.file);
ret = vfs_read(f.file, buf, count, &pos);
file_pos_write(f.file, pos);
fdput(f);
}
return ret;
}
這些SYSCALL_DEFINEn()宏是內核代碼定義系統調用的標準方式,其中n后綴表示參數計數。這些宏的定義(在include/linux/syscalls.h中)為每個系統調用提供了兩個不同的輸出。
// include/linux/syscalls.h
#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)
// include/linux/syscalls.h
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
SYSCALL_METADATA(_read, 3, unsigned int, fd, char __user *, buf, size_t, count)
__SYSCALL_DEFINEx(3, _read, unsigned int, fd, char __user *, buf, size_t, count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
/* ... */
5.1SYSCALL_METADATA
其中之一是SYSCALL_METADATA()宏,用于構建關于系統調用的元數據,以便進行跟蹤。只有在內核構建時定義了CONFIG_FTRACE_SYSCALLS時才會展開該宏,展開后它會生成描述系統調用及其參數的數據的樣板定義。(單獨的頁面詳細描述了這些定義。)
SYSCALL_METADATA()宏主要用于在內核中進行系統調用的跟蹤和分析。當啟用了CONFIG_FTRACE_SYSCALLS配置選項進行內核構建時,宏會展開,并生成一系列用于描述系統調用及其參數的元數據定義。這些元數據包括系統調用號、參數個數、參數類型等信息,用于記錄和分析系統調用的執行情況。
通過使用SYSCALL_METADATA()宏,內核能夠在編譯時生成系統調用的元數據,以支持跟蹤工具對系統調用的監控和分析。這些元數據的定義是一種樣板代碼,提供了系統調用的相關信息,幫助開發人員和調試工具在系統調用層面進行問題排查和性能優化。
5.2__SYSCALL_DEFINEx
__SYSCALL_DEFINEx()部分更加有趣,因為它包含了系統調用的實現。一旦各種宏和GCC類型擴展層層展開,生成的代碼包含一些有趣的特性:
#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
SYSCALL_ALIAS(sys##name, SyS##name); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__))
asmlinkage long sys_read(unsigned int fd, char __user * buf, size_t count)
__attribute__((alias(__stringify(SyS_read))));
static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count);
asmlinkage long SyS_read(long int fd, long int buf, long int count);
asmlinkage long SyS_read(long int fd, long int buf, long int count)
{
long ret = SYSC_read((unsigned int) fd, (char __user *) buf, (size_t) count);
asmlinkage_protect(3, ret, fd, buf, count);
return ret;
}
static inline long SYSC_read(unsigned int fd, char __user * buf, size_t count)
{
struct fd f = fdget_pos(fd);
ssize_t ret = -EBADF;
/* ... */
[root@localhost ~]# uname -r
3.10.0-693.el7.x86_64
[root@localhost ~]# cat /proc/kallsyms | grep '\<sys_read\>'
ffffffff812019e0 T sys_read
[root@localhost ~]# cat /proc/kallsyms | grep '\<SYSC_read\>'
[root@localhost ~]# cat /proc/kallsyms | grep '\<SyS_read\>'
ffffffff812019e0 T SyS_read
5.3SYSCALL_ALIAS
SYSCALL_ALIAS宏定義如下:
// file: include/linux/linkage.h
#ifndef SYSCALL_ALIAS
#define SYSCALL_ALIAS(alias, name) asm( \
".globl " VMLINUX_SYMBOL_STR(alias) "\n\t" \
".set " VMLINUX_SYMBOL_STR(alias) "," \
VMLINUX_SYMBOL_STR(name))
#endif
宏VMLINUX_SYMBOL_STR定義如下:
// file: include/linux/export.h
/*
* Export symbols from the kernel to modules. Forked from module.h
* to reduce the amount of pointless cruft we feed to gcc when only
* exporting a simple symbol or two.
*
* Try not to add #includes here. It slows compilation and makes kernel
* hackers place grumpy comments in header files.
*/
/* Indirect, so macros are expanded before pasting. */
#define VMLINUX_SYMBOL(x) __VMLINUX_SYMBOL(x)
#define VMLINUX_SYMBOL_STR(x) __VMLINUX_SYMBOL_STR(x)
#define __VMLINUX_SYMBOL(x) x
#define __VMLINUX_SYMBOL_STR(x) #x
實際效果是給name設置了個別名alias,本例中是給SyS_write設置了別名sys_write。
5.4Syscall table entries
尋找調用sys_read()的函數還有助于了解用戶空間如何調用該函數。對于沒有提供自己覆蓋的"通用"架構,include/uapi/asm-generic/unistd.h文件中包含了一個引用sys_read的條目:
// include/uapi/asm-generic/unistd.h
#define __NR_read 63
__SYSCALL(__NR_read, sys_read)
這個定義為read()定義了通用的系統調用號__NR_read(63),并使用__SYSCALL()宏以特定于體系結構的方式將該號碼與sys_read()關聯起來。例如,arm64使用asm-generic/unistd.h頭文件填充一個表格,將系統調用號映射到實現函數指針。
然而,我們將集中討論x86_64架構,它不使用這個通用表格。相反,x86_64架構在arch/x86/syscalls/syscall_64.tbl中定義了自己的映射,其中包含sys_read()的條目:
// arch/x86/syscalls/syscall_64.tbl
#
# 64-bit system call numbers and entry vectors
#
# The format is:
# <number> <abi> <name> <entry point>
#
# The abi is "common", "64" or "x32" for this file.
#
0 common read sys_read
1 common write sys_write
2 common open sys_open
3 common close sys_close
4 common stat sys_newstat
......
這表明在x86_64架構上,read()的系統調用號為0(不是63),并且對于x86_64的兩種ABI(應用二進制接口),即sys_read(),有一個共同的實現。(關于不同的ABI將在本系列的第二部分中討論。)syscalltbl.sh腳本從syscall_64.tbl表生成arch/x86/include/generated/asm/syscalls_64.h文件,具體為sys_read()生成對__SYSCALL_COMMON()宏的調用。然后,該頭文件用于填充syscall表sys_call_table,這是一個關鍵的數據結構,將系統調用號映射到sys_name()函數。
// arch/x86/syscalls/syscalltbl.sh
#!/bin/sh
in="$1"
out="$2"
grep '^[0-9]' "$in" | sort -n | (
while read nr abi name entry compat; do
abi=`echo "$abi" | tr '[a-z]' '[A-Z]'`
if [ -n "$compat" ]; then
echo "__SYSCALL_${abi}($nr, $entry, $compat)"
elif [ -n "$entry" ]; then
echo "__SYSCALL_${abi}($nr, $entry, $entry)"
fi
done
) > "$out"
在x86_64架構中,syscalltbl.sh腳本使用syscall_64.tbl表格生成了arch/x86/include/generated/asm/syscalls_64.h文件。其中,對于sys_read()的定義會包含類似以下的代碼:
__SYSCALL_COMMON(0, sys_read)
這個宏的調用將系統調用號0和sys_read()函數關聯起來。然后,arch/x86/include/generated/asm/syscalls_64.h文件會被其他代碼引用,用于填充sys_call_table數據結構。
即由一個 Makefile文件中在編譯 Linux 系統內核時調用了一個腳本,這個腳本文件會讀取 syscall_64.tbl 文件,根據其中信息生成相應的文件 syscall_64.h。
// arch/x86/syscalls/Makefile
syscall64 := $(srctree)/$(src)/syscall_64.tbl
systbl := $(srctree)/$(src)/syscalltbl.sh
$(out)/syscalls_64.h: $(syscall64) $(systbl)
$(call if_changed,systbl)
sys_call_table是一個數組,其中每個元素對應一個系統調用號,它將系統調用號映射到相應的sys_name()函數。在這種情況下,sys_read()函數將與系統調用號0關聯起來,以便當用戶空間發起sys_read()的系統調用請求時,內核可以根據系統調用號從sys_call_table中找到sys_read()函數并執行。這樣,內核就能正確處理用戶空間對read()的系統調用請求。
六、x86-64系統調用實戰演練
6.1匯編代碼實操
為了更直觀地感受 x86-64 系統調用的實際應用,我們通過具體的匯編代碼示例來深入學習。這里以文件讀寫和進程創建這兩個常見的系統調用為例,詳細剖析每一行代碼的功能和作用。
(1)文件讀取匯編代碼示例
section .data
filename db 'test.txt', 0 ; 要讀取的文件名,以0結尾表示字符串結束
buffer times 128 db 0 ; 用于存儲讀取內容的緩沖區,大小為128字節
section .bss
fd resq 1 ; 用于保存文件描述符,resq表示預留8字節空間(64位系統)
bytes_read resq 1 ; 用于保存實際讀取的字節數
section .text
global _start
_start:
; 打開文件,使用O_RDONLY標志表示只讀模式
mov rax, 2 ; 將系統調用號2(open系統調用號)存入%rax寄存器
mov rdi, filename ; 將文件名的地址存入%rdi寄存器,作為open系統調用的第一個參數
mov rsi, 0 ; 將打開文件的標志O_RDONLY(值為0)存入%rsi寄存器,作為第二個參數
syscall ; 執行系統調用,觸發從用戶態到內核態的切換,執行open系統調用
mov [fd], rax ; 將open系統調用返回的文件描述符保存到fd變量中
; 讀取文件內容到緩沖區
mov rax, 0 ; 將系統調用號0(read系統調用號)存入%rax寄存器
mov rdi, [fd] ; 將文件描述符從fd變量中取出,存入%rdi寄存器,作為read系統調用的第一個參數
mov rsi, buffer ; 將緩沖區的地址存入%rsi寄存器,作為read系統調用的第二個參數
mov rdx, 128 ; 將讀取的最大字節數128存入%rdx寄存器,作為read系統調用的第三個參數
syscall ; 執行系統調用,觸發read系統調用,從文件中讀取內容到緩沖區
mov [bytes_read], rax ; 將read系統調用返回的實際讀取的字節數保存到bytes_read變量中
; 關閉文件
mov rax, 3 ; 將系統調用號3(close系統調用號)存入%rax寄存器
mov rdi, [fd] ; 將文件描述符從fd變量中取出,存入%rdi寄存器,作為close系統調用的第一個參數
syscall ; 執行系統調用,觸發close系統調用,關閉文件
; 退出程序
mov rax, 60 ; 將系統調用號60(exit系統調用號)存入%rax寄存器
xor rdi, rdi ; 將退出狀態碼0存入%rdi寄存器,作為exit系統調用的第一個參數
syscall ; 執行系統調用,觸發exit系統調用,程序結束
在這段代碼中,首先定義了要讀取的文件名test.txt和用于存儲讀取內容的緩沖區buffer。然后通過open系統調用打開文件,獲取文件描述符并保存。接著使用read系統調用從文件中讀取內容到緩沖區,保存實際讀取的字節數。最后通過close系統調用關閉文件,并使用exit系統調用退出程序。每一個系統調用都嚴格按照 x86-64 的調用約定,將系統調用號存入%rax寄存器,將參數依次存入%rdi、%rsi、%rdx等寄存器,通過syscall指令觸發系統調用。
(2)進程創建匯編代碼示例
section .text
global _start
_start: ; 創建子進程
mov rax, 57 ; 將系統調用號57(clone系統調用號,用于創建進程,在Linux中clone可用于創建進程、線程等,這里用于創建進程)存入%rax寄存器
xor rdi, rdi ; 將%rdi寄存器清零,作為clone系統調用的第一個參數(這里參數為0,表示使用默認的克隆標志)
xor rsi, rsi ; 將%rsi寄存器清零,作為clone系統調用的第二個參數(通常用于傳遞棧指針,這里為0表示使用默認棧)
xor rdx, rdx ; 將%rdx寄存器清零,作為clone系統調用的第三個參數(通常用于傳遞父進程的標志,這里為0表示默認)
xor r10, r10 ; 將%r10寄存器清零,作為clone系統調用的第四個參數(通常用于傳遞子進程的標志,這里為0表示默認)
xor r8, r8 ; 將%r8寄存器清零,作為clone系統調用的第五個參數(通常用于傳遞新的線程組ID,這里為0表示默認)
xor r9, r9 ; 將%r9寄存器清零,作為clone系統調用的第六個參數(通常用于傳遞新的父進程ID,這里為0表示默認)
syscall ; 執行系統調用,觸發clone系統調用,創建子進程
cmp rax, 0 ; 比較clone系統調用的返回值(%rax寄存器)與0
jz child ; 如果返回值為0,說明是子進程,跳轉到child標簽處執行
; 父進程執行的代碼
mov rax, 60 ; 將系統調用號60(exit系統調用號)存入%rax寄存器
xor rdi, rdi ; 將退出狀態碼0存入%rdi寄存器,作為exit系統調用的第一個參數
syscall ; 執行系統調用,觸發exit系統調用,父進程結束
child:
; 子進程執行的代碼
mov rax, 1 ; 將系統調用號1(write系統調用號)存入%rax寄存器
mov rdi, 1 ; 將文件描述符1(標準輸出)存入%rdi寄存器,作為write系統調用的第一個參數
mov rsi, msg ; 將要輸出的消息的地址存入%rsi寄存器,作為write系統調用的第二個參數
mov rdx, msg_len ; 將消息的長度存入%rdx寄存器,作為write系統調用的第三個參數
syscall ; 執行系統調用,觸發write系統調用,子進程向標準輸出打印消息
mov rax, 60 ; 將系統調用號60(exit系統調用號)存入%rax寄存器
xor rdi, rdi ; 將退出狀態碼0存入%rdi寄存器,作為exit系統調用的第一個參數
syscall ; 執行系統調用,觸發exit系統調用,子進程結束
section .data
msg db 'This is a child process!', 0xa, 0 ; 子進程要輸出的消息,0xa表示換行符,0表示字符串結束
msg_len equ $ - msg ; 計算消息的長度
在這段進程創建的匯編代碼中,通過clone系統調用創建一個新的子進程。clone系統調用的參數較多,這里使用默認值,通過將各個參數寄存器清零來實現。clone系統調用返回后,根據返回值判斷是父進程還是子進程。如果返回值為 0,則是子進程,子進程會向標準輸出打印一條消息,然后退出;如果返回值不為 0,則是父進程,父進程直接退出。同樣,每個系統調用都遵循 x86-64 的調用約定,準確設置系統調用號和參數寄存器,通過syscall指令實現系統調用的執行。
6.2C 語言調用示范
在 C 語言中,我們通常不會直接使用系統調用的原始方式(如匯編代碼中的方式),而是通過調用 glibc 庫函數來間接使用系統調用。glibc(GNU C Library)是 GNU 項目中提供的 C 標準庫,它對系統調用進行了封裝,提供了更方便、更高級的接口,使得程序員可以更便捷地使用系統調用。下面以open、read、write等函數為例,分析 C 語言中如何調用這些庫函數,以及它們內部是如何封裝系統調用的。
(1)C語言文件操作示例
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define BUFFER_SIZE 128
int main() {
int fd;
char buffer[BUFFER_SIZE];
ssize_t bytes_read;
// 打開文件,使用O_RDONLY標志表示只讀模式
fd = open("test.txt", O_RDONLY);
if (fd == -1) {
perror("無法打開文件");
return 1;
}
// 讀取文件內容到緩沖區
bytes_read = read(fd, buffer, BUFFER_SIZE);
if (bytes_read == -1) {
perror("讀取文件失敗");
close(fd);
return 1;
}
// 輸出讀取到的內容
write(1, buffer, bytes_read);
// 關閉文件
close(fd);
return 0;
}
在這個 C 語言示例中,首先使用open函數打開文件test.txt,open函數的原型定義在<fcntl.h>頭文件中,其函數聲明為int open(const char *pathname, int flags, mode_t mode);。第一個參數pathname是要打開的文件名,第二個參數flags用于指定打開文件的模式,這里使用O_RDONLY表示只讀模式。如果open函數調用失敗,會返回 -1,并設置errno全局變量來表示具體的錯誤類型,通過perror函數可以輸出錯誤信息。
接著使用read函數從文件中讀取內容到緩沖區,read函數的原型定義在<unistd.h>頭文件中,聲明為ssize_t read(int fd, void *buf, size_t count);。第一個參數fd是文件描述符,即open函數返回的值;第二個參數buf是用于存儲讀取內容的緩沖區;第三個參數count是要讀取的最大字節數。如果read函數調用失敗,同樣會返回 -1,并設置errno變量。
然后使用write函數將讀取到的內容輸出到標準輸出,write函數的原型為ssize_t write(int fd, const void *buf, size_t count);。第一個參數fd為標準輸出的文件描述符(值為 1),第二個參數buf是要輸出的內容緩沖區,第三個參數count是要輸出的字節數。
最后使用close函數關閉文件,close函數的原型為int close(int fd);,參數fd為要關閉的文件描述符。
從內部實現來看,這些 glibc 庫函數實際上是對系統調用的封裝。以open函數為例,當我們在 C 語言中調用open函數時,glibc 會將函數調用轉換為對應的系統調用。在 x86-64 架構下,它會按照系統調用的調用約定,設置好系統調用號和參數寄存器,然后執行syscall指令,觸發系統調用。
例如,對于open系統調用,glibc 會將系統調用號 2 存入%rax寄存器,將文件名的地址存入%rdi寄存器,將打開文件的標志存入%rsi寄存器,然后執行syscall指令。系統調用完成后,glibc 會根據系統調用的返回值進行處理,如果返回錯誤碼,會設置errno全局變量,并返回 -1 給用戶程序。同樣,read、write、close等函數也都是類似的封裝方式,通過這種方式,glibc 為程序員提供了更簡潔、更易用的接口,隱藏了系統調用的底層細節 。
七、x86-64系統調用常見問題與優化策略
7.1常見問題診斷
在使用 x86-64 系統調用時,可能會遭遇各種棘手的問題,這些問題倘若不能及時解決,就會對程序的正常運行和性能產生嚴重影響。
參數傳遞錯誤是較為常見的問題之一。比如,在進行文件讀取系統調用時,如果錯誤地將文件名傳遞到了本該存放文件描述符的寄存器,就會導致系統調用失敗。假設在一個文件讀取的匯編代碼中,原本應該將文件描述符存入%rdi寄存器,卻錯誤地存入了文件名:
; 錯誤示例
mov rax, 0 ; read系統調用號
mov rdi, filename ; 錯誤地將文件名存入%rdi寄存器,應該存入文件描述符
mov rsi, buffer
mov rdx, 128
syscall
解決這類問題,需要仔細檢查系統調用的參數傳遞,嚴格按照 x86-64 的調用約定,將參數準確無誤地傳遞到對應的寄存器中。在編寫代碼時,可以參考相關的系統調用文檔,明確每個參數所對應的寄存器。同時,使用調試工具(如 GDB),在程序運行過程中查看寄存器的值,以確保參數傳遞正確。比如,在 GDB 中,可以使用info registers命令查看寄存器的值,定位參數傳遞錯誤的位置。
系統調用號錯誤也是一個容易出現的問題。如果傳遞了錯誤的系統調用號,內核將無法找到對應的內核函數,從而引發未知行為。例如,將open系統調用號誤寫成了其他值:
; 錯誤示例
mov rax, 5 ; 錯誤的系統調用號,open系統調用號應為2
mov rdi, filename
mov rsi, 0
syscall
為了避免這類錯誤,在編寫代碼時,要確保使用正確的系統調用號。可以查閱相關的操作系統文檔或頭文件,獲取準確的系統調用號。在 Linux 系統中,系統調用號的定義通常可以在/usr/include/asm/unistd_64.h頭文件中找到。并且,在程序中使用宏定義來表示系統調用號,這樣不僅可以提高代碼的可讀性,還能減少因手寫系統調用號而導致的錯誤。例如:
; 正確示例,使用宏定義表示系統調用號
%define SYS_OPEN 2
mov rax, SYS_OPEN
mov rdi, filename
mov rsi, 0
syscall
7.2性能優化策略
系統調用涉及用戶態和內核態的切換,這個過程會帶來一定的開銷,包括保存和恢復寄存器狀態、切換頁表等。因此,優化系統調用的性能對于提高程序的整體效率至關重要。
減少系統調用次數是一個有效的優化策略。以文件讀寫操作為例,如果需要讀取大量的數據,頻繁地進行小數據量的系統調用會導致較高的開銷。假設我們要讀取一個大文件的內容,如果每次只讀取 10 個字節,然后進行一次系統調用,那么對于一個 1MB 大小的文件,就需要進行 10 萬次系統調用,這會產生大量的上下文切換開銷。
// 低效的文件讀取方式,頻繁進行系統調用
#include <stdio.h>
int main() {
FILE *file = fopen("large_file.txt", "r");
if (file == NULL) {
perror("無法打開文件");
return 1;
}
char buffer[10];
while (fread(buffer, 1, 10, file) > 0) {
// 處理讀取到的數據
}
fclose(file);
return 0;
}
為了優化性能,可以采用批量操作數據的方式,一次性讀取較大的數據塊,減少系統調用的次數。比如將緩沖區大小設置為 1024 字節,這樣讀取 1MB 大小的文件只需要進行約 1000 次系統調用,大大降低了上下文切換的開銷。
// 優化后的文件讀取方式,批量讀取數據
#include <stdio.h>
int main() {
FILE *file = fopen("large_file.txt", "r");
if (file == NULL) {
perror("無法打開文件");
return 1;
}
char buffer[1024];
while (fread(buffer, 1, 1024, file) > 0) {
// 處理讀取到的數據
}
fclose(file);
return 0;
}
合理選擇系統調用函數也能提高效率。不同的系統調用函數在功能和性能上可能存在差異,應根據具體需求選擇最合適的系統調用。例如,在創建進程時,如果只是簡單地創建一個子進程并等待其結束,可以使用fork和wait系統調用;但如果需要創建一個新的進程,并在新進程中執行一個新的程序,那么就應該使用execve系統調用。
如果在需要執行新程序的情況下錯誤地使用了fork,就無法達到預期的效果,還可能導致性能問題。同時,了解系統調用函數的底層實現和性能特點,可以幫助我們在編寫程序時做出更優的選擇。比如,一些系統調用函數可能會涉及到復雜的內核操作,而另一些則相對簡單,我們可以根據實際需求選擇更高效的函數。