深入理解 Linux 核心---系統呼叫
Unix 系統通過向核心發出系統呼叫實現了使用者態程序和硬體裝置之間的大部分介面。
POSIX API 和系統呼叫
應用程式設計介面:只是一個函式定義,說明如何獲得一個給定的服務。
系統呼叫:通過軟中斷向核心態發出一個明確的請求。
一個 API 沒必要對應一個特定的系統呼叫,比如抽象的數學函式。
一個 API 可能呼叫幾個系統呼叫。
系統呼叫屬於核心,而使用者態的庫函式不屬於核心。
系統呼叫處理程式及服務例程
當用戶態的程序呼叫一個系統呼叫時,CPU 切換到核心態並開始執行一個核心函式。
80x86 中,有兩種呼叫 Linux 系統呼叫的方式,最終結果都是跳轉到所謂系統呼叫處理程式的組合語言函式。
程序通過引數系統呼叫號來識別所需的系統呼叫,eax 暫存器用作此目的。
系統呼叫返回一個整數值:
- >= 0 表示系統呼叫成功結束
- < 0 表示一個出錯條件,存放在 errno 變數中,該值是由封裝例程從系統呼叫返回後設置。
系統呼叫處理程式與其他異常處理程式結構類似,執行下列操作:
- 核心態堆疊儲存大多數暫存器的內容。
- 呼叫名為“系統呼叫服務例程”的相應的 C 函式處理系統呼叫。
- 退出系統呼叫處理程式:用儲存在核心棧中的值載入暫存器,CPU 從核心態切回用戶態。
xyz() 系統呼叫對應的服務例程的名字通常是 sys_xyz()。
圖 10-1 中,箭頭是執行流,SYSCALL 和 SYSEXIT 是組合語言指令,分別將 CPU 從使用者態切換到核心態和從核心態切換到使用者態。
總結:
- 系統呼叫處理程式是彙編指令,系統呼叫服務例程是 C 語言函式。
- 系統呼叫處理程式呼叫系統呼叫服務例程。
核心利用系統呼叫分派表將系統呼叫號與相應的服務例程關聯。
該表存放在 sys_call_table 陣列中,有 NR_syscalls 個表項:
第 n 個表項包含系統呼叫號為 n 的服務例程的地址。
NR_syscalls 巨集只是對可實現的系統呼叫最大個數的靜態限制,並不表示實際已實現的系統呼叫個數。
分派表中的任意一個表項可包含 sys_ni_syscall() 的地址,該函式是”未實現“系統呼叫的服務例程,僅僅返回出錯碼 -ENOSYS。
進入和退出系統呼叫
本地應用可通過兩種不同的方式呼叫系統呼叫:
- 執行 int $0x80 彙編指令。Linux 舊版本中。
- 執行 sysenter 彙編指令。Linux 新版本引入。
同樣,核心可通過兩種方式從系統呼叫退出,從而使 CPU 切換回使用者態:
- 執行 iret 彙編指令。Linux 舊版本中。
- 執行 sysexit 彙編指令。Linux 新版本引入。
支援進入核心的兩種方式需要解決相容性問題。
通過 int $0x80 發出系統呼叫
向量 128(0x80)對應於核心入口點。
在核心初始化期間呼叫 trap_init(),用如下方式建立對應於向量 128 的中斷描述符表表項:
set_system_gate(0x80, &system_call);
該呼叫將下列值存入該門描述符的相應欄位:
- Segment Selector,核心程式碼段 __KERNEL_CS 的段選擇符。
- Offset,指向 system_call() 系統呼叫處理程式的指標。
- Type,置為 15,表示這個異常是一個陷阱,相應的處理程式不禁止可遮蔽中斷。
- DPL,描述符特權級,置為 3,允許使用者態程序呼叫該異常處理程式。
因此,當用戶態程序發出 int $0x80 指令時,CPU 切換到核心態並從地址 system_call 處開始執行指令。
system_call()
首先將系統呼叫號和該異常處理程式可以用到的所有 CPU 暫存器儲存到相應的棧中,
不包括由控制單元已經自動儲存的 eflags、cs、eip、ss 和 esp 暫存器。
在 ds 和 es 中裝入核心資料段的段選擇符。
system_call:
pushl %eax
SAVE_ALL
movl $0xffffe000, %eax
andl %esp, %ebx
隨後,在 ebx 中存放當前程序的 thread_info 資料結構的地址,
這是通過獲得核心棧指標的值並把它取整到 4KB 或 8KB 的倍數而完成的。
接下來,檢查 thread_info 結構 flag 欄位的 TIF_SYSCALL_TRACE 和 TIF_SYSCALL_AUDIT 標誌之一是否被設定為 1,
即檢查是否有某一呼叫程式正在跟蹤執行程式對系統呼叫的呼叫。
如果是,兩次呼叫 do_syscall_trace():
一次正好在該系統呼叫服務例程執行前,一次在其之後。
do_syscall_trace() 停止 current,並因此允許除錯程序收集關於 current 的資訊。
然後,對使用者態程序傳遞來的系統呼叫號進行有效性檢查。
如果大於或等於系統呼叫分派表中的表項數,則系統呼叫程式終止:
cmpl $NR_syscalls, %eax
jb nobadsys
; 如果系統呼叫號無效,將 -ENOSYS 值存放在棧中曾儲存 eax 暫存器的單元中
; 然後跳到 resume_userspace
; 這樣,當程序恢復它在使用者態的執行時,會在 eax 中發現一個負的返回碼
movl $(-ENOSYS), 24(%esp)
jmp resume_userspace
nobadsys:
最後,呼叫與 eax 中所包含的系統呼叫號對應的特定服務例程:
; 因為分派表中的每個表項佔 4 個位元組
; 因此首先把系統呼叫號乘以 4
; 再加上 sys_call_table 分配表的起始地址,
; 然後從該地址單元獲取執行服務例程的指標
; 核心就找到了要呼叫的服務例程
call *sys_call_table(0, %eax, 4)
從系統呼叫退出
當系統呼叫服務例程結束時,system_call() 從 eax 獲得它的返回值,
並將該值存放到曾儲存使用者態 eax 暫存器值的那個棧單元的位置上:
movl %eax, 24(%esp)
因此,使用者態程序將在 eax 中找到系統呼叫的返回碼。
然後 system_call() 關閉本地中斷並檢查當前程序的 thread_info 結構的標誌:
cli ; 關閉本地中斷
movl 8(%ebp), %ecx ; flags 欄位在 thread_info 資料結構的偏移量為 8
testw $0xffff, %cx ; 通過掩碼 0xffff 選擇與所有標誌(不包括 TIF_POLLING_NRFLAG)對應的位
; 如果所有的標誌都沒有被設定,函式就跳轉到 restore_all 標記處
; restore_all 標記處的程式碼恢復儲存在核心棧中的暫存器的值
; 並執行 iret 彙編指令以重新開始執行使用者態程序
je restore_call
只要有任意標誌被設定,就要在返回使用者態之前完成一些工作。
如果 TIF_SYSCALL_TARACE 標誌被設定,system_call() 就第二次呼叫 do_syscall_trace() ,然後跳轉到 resume_userspace 標記處。
否則,函式就跳轉到 work_pending 標記處。
通過 sysenter 指令發出系統呼叫
彙編指令 int 由於要執行幾個一致性和安全性檢查,所以速度較慢。
sysenter 指令提供了一種從使用者態到核心態的快速切換方法。
sysenter 指令
彙編指令 sysenter 使用三種特殊的暫存器,必須裝入以下值:
- SYSENTER_CS_MSR,核心程式碼段的段選擇符
- SYSENTER_EIP_MSR,核心入口點線性地址
- SYSENTER_ESP_MSR,核心堆疊指標
執行 sysenter 指令時,CPU 控制單元:
- 把 SYSENTER_CS_MSR 的內容拷貝到 cs。
- 把 SYSENTER_EIP_MSR 的內容拷貝到 eip。
- 把 SYSENTER_ESP_MSR 的內容拷貝到 esp。
- 把 SYSENTER_CS_MSR 加 8 的值裝入 ss。
因此,CPU 切換到核心態並開始執行核心入口點的第一條指令。
核心初始化期間,一旦系統中的每個 CPU 執行 enable_esp_cpu() ,三個特定於模型的暫存器就被初始化了。
enable_esp_cpu() 執行以下步驟:
- 把核心程式碼(__KERNEL_CS)的段選擇符寫入 SYSENTER_CS_MSR 暫存器。
- 把 sysenter_entry() 的線性地址寫入 SYSENTER_CS_EIP 暫存器。
- 計算本地 TSS 末端的線性地址,並將該值寫入 SYENTER_CS_ESP 暫存器。
對 SYSENTER_CS_ESP 暫存器的設定的說明:
系統呼叫開始的時候,核心棧是空的,因此 esp 暫存器應該執行 4KB 或 8KB 記憶體區域的末端,該記憶體區域包括核心堆疊和當前程序的描述符。
因為使用者態的封裝例程不知道該記憶體區域的地址,因此不能正確設定該暫存器。
但必須在切換到核心態之前設定該暫存器的值,因此,核心初始化該暫存器,以便為本地 CPU 的任務狀態段編址。
每次程序切換時,核心把當前程序的核心棧指標儲存到本地 TSS 的 esp0 欄位。
這樣,系統呼叫處理程式讀 esp 暫存器,計算本地 TSS 的 esp0 欄位的地址,然後把正確的核心堆疊指標裝入 esp 暫存器。
vsyscall 頁
只要 CPU 和 Linux 核心都支援 sysenter 指令,標準庫 libc 中的封裝函式就可以使用它。
該相容性問題需要非常複雜的解決方案。
本質上,在初始化階段,sysenter_setup() 建立一個稱為 vsyscall 頁的頁框,它包括一個小的 EFL 共享物件(一個很小的 EFL 動態連結庫)。
當程序發出 execve() 系統呼叫而開始執行一個 EFL 程式時,vsyscall 頁中的程式碼就會自動連結到程序的地址空間。
vsyscall 頁中的程式碼使用最有用的指令發出系統呼叫。
sysenter_setup() 為 vsyscall 頁分配一個新頁框,並將它的實體地址與 FIX_VSYSCALL 固定對映的線性地址相關聯。
然後把預先定義好的多個 EFL 共享物件拷貝到該頁中:
- 如果 CPU 不支援 sysenter,sysenter_setup() 建立一個包括下列程式碼的 vsyscall 頁:
__kernel_vsyscall:
int $0x80
ret
- 否則,如果 CPU 的確支援 sysenter,sysenter_setup() 建立一個包括下列程式碼的 vsyscall 頁:
__kernel_vsyscall:
pushl %ecx
push %edx
push %ebp
movl %esp, %ebp
sysenter
當標準庫中的封裝例程必須呼叫系統呼叫時,都呼叫 __kernel_vsyscall()。
進入系統呼叫
當用 sysenter 指令發出系統呼叫時,依次執行下述步驟:
- 標準庫中的封裝例程把系統呼叫號裝入 eax 暫存器,並呼叫 __kernel_vsyscall()。
- __kernel_vsyscall() 把 ebp、edx 和 ecx 的內容儲存到使用者態堆疊中,把使用者棧指標拷貝到 ebp 中,然後執行 sysenter 指令。
- CPU 從使用者態切換到核心態,核心態開始執行 sysenter_entry()(由 SYSENTER_EIP_MSR 暫存器指向)。
- sysenter_entry() 彙編指令執行下述步驟:
a. 建立核心堆疊指標:
movl -508(%esp), %esp
- 開始時,esp 暫存器指向本地 TSS 的第一個位置,本地 TSS 的大小為 512 位元組。
因此,sysenter 指令把本地 TSS 中的偏移量為 4 處的欄位的內容(即 esp0 欄位的內容)裝入 esp。
esp0 欄位總是存放當前程序的核心堆疊指標。 - b. 開啟本地中斷:
sti
- c. 把使用者資料段的段選擇符、當前使用者棧指標、eflags 暫存器、使用者程式碼段的段選擇符及從系統呼叫退出時要指向的指令的地址儲存到核心堆疊:
pushl $(__USER_DS)
pushl %ebp
pushfl
pushl $(__USER_CS)
pushl $SYSENTER_RETURN
- d. 把由封裝例程傳遞的暫存器的值恢復到 ebp 中:
movl (%ebp), %ebp
-
該指令完成恢復的工作,因為__kernel_vsyscall() 把 ebp 的原始值存入使用者態堆疊中,並隨後把使用者堆疊指標的當前值裝入 ebp 中。
-
e. 通過執行一系列指令呼叫系統呼叫處理程式,這些指令與 system_call 標記處開始的指令是一樣的。
退出系統呼叫
系統呼叫服務例程結束時,sysenter_entry() 本質上執行與 system_call() 相同的操作。
首先,從 eax 獲得系統呼叫服務例程的返回碼,並存入核心棧中儲存使用者態 eax 暫存器值的位置。
然後,函式禁止本地中斷,並檢查 current 的 thread_info 結構中的標誌。
如果有任何標誌被設定,則在返回使用者態之前還需要完成一些工作。
為避免程式碼複製,跳轉到 resume_userspace 或 work_pending 標記處。
最後,彙編指令 iret 從核心堆疊中取 5 個引數(在 sysenter_entry() 第 4c 步儲存到核心堆疊中),這樣,CPU 切換到使用者態並開始執行 SYSENTER_RETURN 標記處的程式碼。
否則,如果 sysenter_entry() 確定標誌都被清 0,就快速返回使用者態:
; 將由 sysenter_entry() 在第 4c 步儲存的堆疊值載入到 edx 和 ecx 暫存器中
movl 40(%esp), %edx ; edx 獲得 SYSENTER_RETURN 標記處的地址
movl 52(%esp), %ecx ; ecx 獲得當前使用者資料棧的指標
xorl %ebp, %ebp
sti
sysexit
sysexit 指令
sysexit 是與 sysenter 配對的彙編指令:它允許從核心態快速切換到使用者態。
CPU 控制單元執行下述步驟:
- 把 SYSENTER_CS_MSR 暫存器中的值加 16 所得到的結果載入到 cs 暫存器。
- 把 edx 暫存器的內容拷貝到 eip 暫存器。
- 把 SYSENTER_CS_MSR 暫存器中的值加 24 所得到的結果載入到 ss 暫存器。
- 把 ecx 暫存器的內容拷貝到 esp 暫存器。
因為 SYSENTER_CS_MSR 暫存器載入的是核心程式碼的段選擇符。
所以,cs 暫存器載入的是使用者程式碼的段選擇符,ss 暫存器載入的是使用者資料段的段選擇符。
結果,CPU 從核心態切換到使用者態,並開始執行其地址存放在 edx 中的指令。
SYSENTER_RETURN 的程式碼
SYSENTER_RETURN 標記處的程式碼存放在 vsyscall 頁中,通過 sysenter 進入的系統呼叫被 iret 或 sysexit 指令終止時,該頁框中的程式碼被執行。
該程式碼恢復儲存在使用者態堆疊中的 ebp、edx 和 ecx 暫存器的原始內容,並把控制權返回給標準庫中的封裝例程:
SYSENTER_RETURN:
popl %ebp
popl %edx
popl %ecx
ret
引數傳遞
系統呼叫的輸入/輸出引數可能是:
- 實際的值
- 使用者態程序地址空間的變數
- 指向使用者態函式的指標的資料結構地址
因為 system_call() 和 sysenter_entry() 是 Linux 中所有系統呼叫的公共入口點,因此每個系統呼叫至少有一個引數 ,即通過 eax 暫存器傳遞進來的系統呼叫號。
普通 C 函式的引數傳遞時通過把引數值寫入活動的程式棧(使用者態棧或核心態棧)實現的。
而系統呼叫是一種橫跨使用者和核心的特殊函式,所以既不能使用使用者態棧也不能使用核心態棧。
在發出系統呼叫前,系統呼叫的引數被寫入 CPU 暫存器,然後再呼叫系統呼叫服務例程前,核心再把存放在 CPU 中的引數拷貝到核心態堆疊中,因為系統呼叫服務例程是普通的 C 函式。
為什麼核心不直接把引數從使用者態的棧拷貝到核心態的棧?
- 同時操作兩個棧比較複雜。
- 暫存器的使用使得系統呼叫服務處理程式的結構與其他異常處理程式結構類似。
使用暫存器傳遞引數時,必須滿足兩個條件:
- 每個引數的長度不能超過暫存器的長度,即 32 位。
- 引數的個數不能超過 6 個(除 eax 中傳遞的系統呼叫號),因為暫存器數量有限。
當確實存在多於 6 個引數的系統呼叫時,用一個單獨的暫存器指向程序地址空間中這些引數值所在的一個記憶體區。
用於存放系統呼叫號和系統呼叫引數的暫存器是:
- eax(系統呼叫號)、ebx、ecx、edx、esi、edi 及 ebp。
system_call() 和 sysenter_entry() 使用 SAVE_ALL 巨集將這些暫存器的值儲存在核心態堆疊中。
因此,當系統呼叫服務例程轉到核心態堆疊時,就會找到 system_call() 或 sysenter_entry() 的返回地址,緊接著時存放在 ebx 中的引數(系統呼叫的第一個引數),存放在 ecx 中的引數等。
這種棧結構與普通函式呼叫的棧結構完全相同,因此,系統呼叫服務例程很容易通過使用 C 語言結構引用它的引數。
有時候,服務例程需要知道在發出系統呼叫前 CPU 暫存器的內容。
型別為 pt_regs 的引數允許服務例程訪問由 SAVE_ALL 巨集儲存在核心態堆疊中的值:
int sys_fork(struct pt_regs regs)
服務例程的返回值必須寫入 eax 暫存器。
這在執行 return n 指令時由 C 編譯程式自動完成。
驗證引數
有一種檢查對所有的系統呼叫都是通用的。
只要有一個引數指定的是地址,那麼核心必須檢查它是否在這個程序的地址空間內。
檢查方式:僅僅驗證該線性地址是否小於 PAGE_OFFSET(即沒有落在留給核心的線性地址區間內)。
這是一種非常錯略的檢查,真正的檢查推遲到分頁單元將線性地址轉換為實體地址時。
後面的“動態地址檢查:修正程式碼”會討論缺頁異常處理程式如何成功地檢測到由使用者態程序以引數傳遞的這些地址在核心態是無效的。
該粗略的檢查確保了程序地址空間和核心地址空間都不被非法訪問。
對系統呼叫所傳遞地址的檢測是通過 access_ok() 巨集實現的,它有兩個分別為 addr 和 size 的引數。
該巨集檢查 addr 到 addr+size-1 之間的地址區間:
int access_ok(const void *addr, unsigned long size)
{
unsigned long a = (unsigned long)addr;
// 驗證 addr + size 是否大於 2^32-1
// 因為 gcc 編譯器用 32 位數表示無符號長整數和型別
// 所以等價於對溢位條件進行檢查
// addr_limit.seg:
// 普通程序,通常存放 PAGE_OFFSET
// 核心執行緒,為 0xffffffff
// 可通過 get_fs 和 set_fs 巨集動態修改 addr_limit.seg
if(a + size < a || a + size > current_thread_info()->addr_limit.seg)
return 0;
return 1;
}
訪問程序地址空間
get_user() 和 put_user() 巨集可方便系統呼叫服務例程讀寫程序地址空間的資料。
get_user() 巨集從一個地址讀取 1、2 或 4 個連續位元組。
put_user() 巨集把 1、2 或 4 個連續位元組的內容寫入一個地址。
引數:
- 要傳送的值 x
- 一個變數 ptr,決定還有多少位元組要傳送
get_user(x, ptr) 可展開為 __get_user_1()、__get_user_2() 或 __get_user_4() 組合語言函式。
__get_user_2:
; 前 6 個指令所執行的檢查與 access_ok() 巨集相同
; 即確保要讀取的兩個位元組的地址小於 4GB 並小於 current 程序的 addr_limit.seg 欄位
addl $1, %eax ; eax 包含要讀取的第一個位元組的地址 ptr
jc bad_get_user
movl $0xffffe000, %edx
andl %esp, %edx
cmpl 24(%edx), %eax ; addr_limit.seg 位於 current 的 thread_info 中偏移量為 24 處
jae bad_get_user
; 如果地址有效,執行 movzwl 指令
; 把要讀的資料存到 edx 暫存器的兩個低位元組
; 兩個高位元組置 0
2: movzwl -1(%eax), %edx
xorl %eax, %eax // 在 eax 中設定返回碼 0
ret
; 如果地址無效
bad_get_user:
xorl %edx, %edx ; 清 edx
movl $-EFAULT, %eax ; 將 eax 置為 -EFAULT
ret
put_user(x, ptr) 巨集類似於 get_user,但把值 x 寫入以地址 ptr 為起始地址的程序地址空間。
根據 x 的大小,使用 __put_user_asm() 巨集,或 __put_user_u64() 巨集。
成功寫入則在 eax 暫存器中返回 0,否則返回 -EFAULT。
動態地址檢查:修正程式碼
access_ok() 巨集僅對以引數傳入的線性地址空間進行粗略檢查,保證使用者態程序不會試圖侵擾核心地址空間。
但線性地址仍然可能不屬於程序地址空間,這時,核心使用該地址時,會發生缺頁異常。
缺頁異常處理程式區分在核心態引起缺頁異常的四種情況,並進行相應處理:
- 核心試圖訪問屬於程序地址空間的頁,但是,相應的頁框可能不存在,或核心試圖寫一個只讀頁。
此時,處理程式必須分配和初始化一個新的頁框(請求調頁、寫時複製)。 - 核心試圖訪問屬於核心地址空間的頁,但是,相應的頁表項還沒有初始化(處理非連續記憶體區訪問)。
此時,核心必須在當前程序頁表中適當建立一些表項。 - 某一個核心函式包含程式設計錯誤,導致函式執行時引起異常;或者,可能由於瞬時的硬體錯誤引起異常。
此時,處理程式必須執行一個核心漏洞(處理地址空間以外的錯誤地址)。 - 系統呼叫服務例程試圖讀寫一個記憶體區,該記憶體區的地址以系統呼叫引數傳入,但不屬於程序的地址空間。
下面解釋缺頁處理程式如何區分第 3、4 種情況。
異常表
只有少數的函式和巨集訪問程序的地址空間;
因此,如果異常是由一個無效的引數引起的,那麼引起異常的指令一定包含在其中一個函式或展開的巨集中。
對使用者空間定址的指令非常少。
因此,可把訪問程序地址空間的每條核心指令的地址放到一個叫異常表的結構中。
當核心態發生缺頁異常時,do_page_fault() 處理程式檢查異常表:
如果表中包含產生異常的指令地址,則該錯誤就是由非法的系統呼叫引數引起的,
否則,就是由某一嚴重的 bug 引起的。
Linux 定義了幾個異常表。
主要的異常表在建立核心程式映像時,由 C 編譯器自動生成。
它存放在核心程式碼段的 __ex_table 節,起始地址和終止地址由 C 編譯器產生的兩個符號 __start__ex_table 和 __stop__ex_table 標識。
此外,每個動態裝載的核心模組都包含自己的區域性異常表。
該表在建立模組映像時,由 C 編譯器自動產生,在把模組插入到執行中的核心時,該表被裝入記憶體。
每個異常表的表項是一個 exception_table_entry 結構,有兩個欄位:
- insn,訪問程序地址空間的指令的線性地址。
- fixup,修正程式碼的地址。
修正程式碼由幾條彙編指令組成,用以解決由缺頁異常所引起的問題。
修正通常由插入的一個指令序列組成,該指令序列強制服務例程向用戶態程序返回一個出錯碼。
這些指令通常在訪問程序地址空間的同一函式或巨集中定義;
由 C 編譯器把它們放置在核心程式碼段的一個叫 .fixup 的獨立部分。
search_exception_tables() 在所有異常表中查詢一個指定地址:
若該地址在某一個表中,則返回指向相應 exception_table_entry 結構的指標;否則,返回 NULL。
因此,缺頁處理程式 do_page_fault() 執行:
// regs->eip 欄位包含異常發生時儲存到核心態棧 eip 暫存器中的值
// 如果 eip 暫存器中的該值在某個異常表中
if((fixup = search_exception_tables(regs->eip))
{
// 把 regs->eip 儲存的值替換為 search_exception_tables() 的返回值
regs->eip = fixup->fixup;
// 缺頁處理程式終止,被中斷的程式恢復執行
return 1;
}
生成異常表和修正程式碼
GNU 彙編程式偽指令 .section 允許程式設計師指定可執行檔案的哪部分包含緊接著要執行的程式碼。
可執行檔案包含一個程式碼段,該程式碼段可能被劃分為節。
在異常表中加入一個表項:
; "a" 屬性指定必須把這一節與核心映像的剩餘部分一塊載入到記憶體中
.section __ex_table, "a"
.long faulty_instruction_address, fixup_code_address
.previous
.previous 偽指令強制彙編程式把緊接著的程式碼插入到遇到上一個 .section 偽指令時啟用的節。
__get_user_1:
[...]
1: movzbl (%eax), %edx
[...]
__get_user_2:
[...]
2: movzwl -1(%eax), %edx
[...]
__get_user_4:
[...]
3: movl -3(%eax), %edx
[...]
; 修正程式碼對這三個函式是公用的,被標記為 bad_get_user
; 如果缺頁異常是由標號 1、2 或 3 處的指令查時,則修正程式碼就執行
; bad_get_user 修正程式碼給發出系統呼叫的程序只簡單地返回一個出錯碼 -EFAULT
bad_get_user:
xorl %edx, %edx
movl $-EFAULT, %eax
ret
; 每個異常表項由兩個標號組成
; 第一個是標號,字首 "b" 表示"向後的"
.section __ex_table, "a"
.long 1b, bad_get_user
.long 2b, bad_get_user
.long 3b, bad_get_user
.previous
其他作用於使用者態地址空間的核心函式也使用修正程式碼技術。
比如 strlen_user(string) 巨集,返回系統呼叫中 string 引數的長度,string 以 null 結尾;出錯時返回 0。
strlen_user(string):
mvol $0, %eax
; ecx 和 ebx 暫存器的初始值設定為 0x7fffffff
; 表示使用者態地址空間字串的最大長度
movl $0x7fffffff, %ecx
movl %ecx, %ebx
movl string, %edi
; repne; scabsb 迴圈掃描由 edi 指向的字串
; 在 eax 中查詢值為 0 的字元(字串的結尾標誌 \0)
0: repne; scabsb
subl %ecx, %ebx ; 每一次迴圈 scasb 都將 ecx 減 1
movl %ebx, %eax ; 所以 eax 中最後存放字串長度
; 修正程式碼被插入到節 .fixup
; "ax" 屬性指定該節必須載入到記憶體且包含可執行程式碼
; 如果缺頁異常是由標號為 0 的指令引起
; 就執行修正程式碼,它只簡單地把 eax 置為 0
; 因此強制該巨集返回一個出錯碼 0 而不是字串長度
; 然後跳轉到標號 1,即巨集之後的相應指令。
1:
.section .fixup, "ax"
2: xorl %eax, %eax
jmp 1b
.previous
; 在 __ex_table 中增加一個表項
; 內容包括 repne; scasb 指令的地址和相應的修正程式碼的地址
.section __ex_table, "a"
.long 0b, 2b
.previous
核心封裝例程
系統呼叫也可以被核心執行緒呼叫,核心執行緒不能使用庫函式。
為了簡化相應的封裝例程的宣告,Linux 定義了 7 個從 _syscall0 到 _syscall6 的一組巨集。
每個巨集名字中的數字 0~6 對應著系統呼叫所用的引數個數(系統呼叫號除外)。
也可以用這些巨集來宣告沒有包含在 libc 標準庫中的封裝例程。
然而,不能用這些巨集來為超過 6 個引數(系統呼叫號除外)的系統呼叫或返回非標準值的系統呼叫封裝例程。
每個巨集嚴格地需要 2+2*n 個引數,n 是系統呼叫的引數個數。
前兩個引數指明返回值型別和名字;
後面的每一對附加引數指明引數的型別和名字。
以 fork() 系統呼叫為例,其封裝例程可以通過如下語句產生:
_syscall0(int, fork)
而 write() 系統呼叫的封裝例程可通過如下語句產生:
_syscall3(int, write, int, fd, const char *, buf, unsigned int, count)
展開如下:
int write(int fd, const char *buf, usninged int count)
{
long __res;
adm("int $0x80",
: "0" (__NR_write), "b" ((long)fd),
"c" ((long)buf), "d" ((long)count));
if((unsigned long)__res >= (unsigned long)-129)
{
errno = -__res;
__res = -1;
}
return (int)__res;
}
__NR_write 巨集來自 _syscall3 的第二個引數;
它可展開成 write() 的系統呼叫號,當編譯前面的函式時,產生如下彙編程式碼:
write:
pushl ebx ; 將 ebx 入棧
movl 8(%esp), %ebx ; 第一個引數放入 ebx
movl 12(%esp), %ecx ; 第二個引數放入 ecx
mvol 16(%esp), %edx ; 第三個引數放入 edx
movll $4, %eax ; __NR_write 放入 eax
int $0x80 ; 呼叫系統呼叫
cmpl $-125, %eax ; 檢測返回碼
jbe .L1 ; 如果無錯則跳轉
negl %eax ; 求 eax 的補碼
movl %eax, errno ; 結果放入 errno
movl -1, %eax ; eax 置為 -1
.L1: popl %ebx ; 從堆疊彈出 ebx
ret ; 返回呼叫程式
如果 eax 中的返回值在 -1~-129 之間,則被解釋為出錯碼,在 errno 中存放 -eax 的值並返回 -1;
否則,返回 eax 中的值。