系統呼叫三層機制

sgqmax發表於2024-12-05

使用者態和核心態

intel x86 CPU有4種不同的執行級別,分別為0,1,2,3
按照intel的設想,核心執行在Ring0級別,驅動執行在Ring1和Ring2級別,應用執行在Ring3級別
linux系統中,只使用了0和3兩個級別,分別對應核心態和使用者態,使用暫存器CS:EIP的指向範圍區分

  • 使用者態下,只能訪問0x00000000~0xBFFFFFFF的地址空間
  • 核心態下,可以指向任意地址,0xC0000000只能在核心態下訪問

在32位x86機器上有4GB程序地址空間,MMU負責邏輯地址和實體地址的轉換

| |核心空間1GB
| |<- 0xc0000000
| |
| |使用者空間3GB
| |<- 0x00000000

中斷

int指令觸發中斷機制(包括系統呼叫)
中斷觸發時,會在堆疊上儲存暫存器的值,儲存使用者態棧頂地址、當時的狀態字和CS:EIP
同時將核心態的棧頂地址、核心態的狀態字載入CPU暫存器中,並將ES:EIP暫存器指向中斷處理程式入口(對系統呼叫來說是system_call

int指令觸發後,進入中斷處理程式,開始執行核心程式碼SAVE_ALL儲存現場
中斷處理程式結束後,執行恢復現場操作,在3.18.6的x86-32核心中,restore_allINTERRUPT_RETURNiret)負責將中斷時儲存的使用者態暫存器值恢復到當前CPU

系統呼叫

作業系統管理硬體,提高系統安全性,使得使用者程式具有可移植性

  1. Linux下系統呼叫透過觸發int 0x80中斷完成
    中斷儲存了使用者態CS:EIP的值,及當前堆疊段暫存器的棧頂,將EFLAGS暫存器的值儲存到核心堆疊中
    同時將當前的中斷訊號或系統呼叫和終端服務例程的入口載入在CS:EIP中,將當前的堆疊段SS:ESP也載入到CPU中

  2. 觸發系統呼叫及引數傳遞方式
    當Linux透過執行int 0x80觸發系統呼叫時(Intel Pentium II還引入sysenter指令,Linux2.6後支援),進入核心,開始執行中斷向量128對應的中斷服務例程system_call
    使用者態程序需要指明呼叫哪個系統呼叫,透過EAX暫存器傳遞系統呼叫號引數來區分

除了系統呼叫號外,系統呼叫可能需要傳遞其他引數,由於系統呼叫從使用者態切換到核心態,使用不同的堆疊空間,無法透過壓棧的方式傳遞引數,而是透過暫存器傳遞引數,引數個數若超過暫存器數量,可將某個暫存器作為指標指向記憶體,此時可以透過記憶體傳遞更多引數

使用庫函式libc和C嵌入彙編程式碼觸發同一個系統呼叫

使用庫函式libc

#include<stdio.c>
#include<time.c>
int main(){
    time_t tt; // int
    struct tm* t;
    tt= time(NULL);
    t= localtime(&tt);
    printf("time:%d:%d:%d:%d:%d:%d\n",
            t->tm_year+1900, t->tm_mon, t->tm_mda,
            t->tm_hour, t->tm_min, t->tm_sec);
    return 0;
}

編譯gcc -m32 time.c -o time

使用C嵌入彙編程式碼

#include<stdio.h>
#include<time.h>
int main(){
    time_t tt;
    struct tm* t;
    // 使用匯編代替 time(NULL)
    asm volatile(
        "mov $0, %%ebx\n\t"
        "mov $0xd, %%eax\n\t" // 傳遞系統呼叫號 13
        "int $0x80\n\t"       // 觸發系統呼叫
        "mov %%eax, %0\n\t"
        : "=m" (tt)
    );
    t= localtime(&tt);
    printf("time:%d:%d:%d:%d:%d:%d\n",
            t->tm_year+1900, t->tm_mon, t->tm_mda,
            t->tm_hour, t->tm_min, t->tm_sec);

    return 0;
}

編譯gcc time-asm.c -o time-asm -m32

含有兩個引數的系統呼叫示例
重新命名函式rename(),在核心中對應的系統呼叫核心處理函式sys_rename(),系統呼叫號為38
asmlinkage long sys_rename(const char __user* oldname, const char __user *newname);

使用庫函式libc

#include<stdio.h>
int main(){
    int ret;
    char* oldname= "hello.c";
    char* newname= "newhello.c";
    ret= rename(oldname, newname);
    if(ret==0){
        printf("renamed successfully\n");
    }else{
        printf("unable to rename the file\n");
    }

    return 0;
}

使用C嵌入彙編程式碼

#include<stdio.h>
int main(){
    int ret;
    char* oldname= "hello.c";
    char* newname= "newhello.c";

    asm volatile(
        "movl %2, %%ecx\n\t"
        "movl %1, %%ebx\n\t"
        "movl $0x26, %%eax\n\t"
        "int $0x80"
        : "=a" (ret)
        : "b" (oldname), "c" (newname)
    );

    if(ret==0){
        printf("renamed successfully\n");
    }else{
        printf("unable to rename the file\n");
    }

    return 0;
}

EAX用來傳遞系統呼叫號,其他引數按順序賦給EBX,ECX,EDX,ESI,EDI,EBP
將系統呼叫號38存入EAX暫存器,將oldname存入EBX暫存器,將newname存入ECX暫存器,由於引數是字串,實際傳遞的是指標變數
透過執行int $0x80指令來執行系統呼叫,進入核心態,system_call()根據系統呼叫號在系統呼叫列表中查詢對應的系統呼叫核心函式sys_rename(),執行完將結果存入EAX暫存器,再將EAX暫存器的值傳給ret

使用gdb跟蹤MenuOS系統呼叫過程

timetime-asm命令整合到MenuOS中

rm -rf menu
git clone https://github.com/mengning/menu.git
make rootfs

透過在test.c:main()中增加兩行程式碼,來給MenuOS增加兩個命令

MenuConfig("time");
MenuConfig("time-asm");

使用gdb跟蹤系統呼叫核心函式

除錯time命令所用到的系統呼叫核心處理函式

cd ..
qemu-system-i386 -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -S -s

啟動核心後,先啟動gdb,再載入核心

(gdb) file vmlinux # cd linux-3.18.6
(gdb) target remote:1234

time()系統呼叫是系統呼叫號13對應的核心處理函式,即sys_time

(gdb) b sys_time
(gdb) c

此時,在已經啟動的 MenuOS 中執行time-asm命令,程式會停在sys_time

sys_time位於kernel/time/time.c,使用宏實現,所以無法直接看到sys_time

單步執行,會進入get_seconds(),位於kernel/time/timekeeping.c

使用gdb的finish命令將該函式執行完,再單步執行,直到return i,即獲取到系統時間

若繼續單步除錯,會出現cannot find bounds of current function
這裡的程式碼比較特殊,不好除錯,因為這時會返回到system_call位置的彙編程式碼,完成恢復現場並返回到使用者態

當執行int 0x80時,實際上會跳轉到system_call(),可以直接將斷點設在該處
該函式是彙編程式碼,位於arch/x86/kernel/entry_32.S#490
當執行time-asm命令時,並不能在system_call()處停下,該函式不是一個正常的C函式,gdb不支援跟蹤彙編程式碼

ENTRY(system_call)
system_call還有一個函式原型宣告,是一段彙編程式碼的起點,內部沒有遵守函式呼叫堆疊機制,所有gdb不能跟蹤
該段程式碼是理解Linux運作過程的關鍵,系統呼叫作為一種特殊的中斷,其執行過程可以類推到其他中斷訊號觸發的中斷服務處理過程,下面分析:系統呼叫time在核心程式碼中的處理過程
time -> system_call -> sys_time
system_call還涉及一個程序排程時機

中斷向量0x80和system_call中斷服務程式入口
start_kernel()呼叫了trap_init(),該函式呼叫了set_system_trap_gate()

#ifdef CONFIG_X86_32
    set_system_trap_gate(SYSCALL_VECTOR, &system_call);
    set_bit(SYSCALL_VECTOR, used_vectors);
#endif

這是trap_init()中的一段程式碼,位於arch/x86/kernel/traps.c#838
其中,system_call被宣告為一個函式,是彙編程式碼的入口
透過set_system_trap_gate()繫結中斷向量0x80和system_call中斷服務程式入口,一旦執行int 0x80則自動跳轉到system_call
SYSCALL_VECTOR時系統呼叫中斷向量0x80,位於arch/x86/include/asm/irq_vectors.h#49

#define IA32_SYSCALL_VECTOR 0x80
#ifdef CONFIG_X86_32
#define SYSCALL_VECTOR      0x80
#endif

後面再分析int指令執行或中斷訊號發生時,CPU的具體行為

system_call彙編程式碼和系統呼叫核心處理函式
system_call和其他中斷一樣,也有儲存現場SAVE_CALL和恢復現場restore_all
程式碼中的sys_call_table是一個系統呼叫表,EAX暫存器傳遞系統呼叫號,在呼叫時會根據該值呼叫對應的系統呼叫核心處理函式,在退出時會進入syscall_exit_work,此時有一個程序排程時機
system_call

ENTRY(system_call)
    RING0_INT_FRAME
    ASM_CLAC
    pushl_cfi %eax        # 儲存系統呼叫號
    SAVE_ALL              # 儲存現場,將暫存器值壓棧
    GET_THREAD_INFO(%ebp) # ebp用於存放當前程序thread_info結構的地址
    testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
    jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax # 檢查系統呼叫號,系統呼叫號應小於NR_syscalls
    jae syscall_badsys    # 不合法,跳轉到異常處理
syscall_call:
    call *sys_call_table(, %eax, 4) # 透過系統呼叫號在表中找到對應的系統呼叫核心處理函式
    movl %eax, PT_EAX(%esp)         # 儲存返回值到棧
syscall_exit:
    testl $_TIF_ALLWORK_MASK,%ecx # 檢查是否有任務需要處理
    jne syscall_exit_work         # 若需要,進入syscall_exit_work,這裡是最常見的程序排程時機
restore_all:
    TRACE_ITQS_IRET  # 恢復現場
irq_return:
    INTERRUPT_RETURN # iret

在syscall_call中判斷當前任務是否需要處理,若需要,進入syscall_exit_work,這裡是最常見的程序排程時機
sys_call_table(,%eax,4) 透過系統呼叫號在表中找到對應的系統呼叫核心處理函式
系統呼叫表中每個表項佔4位元組,所以先將系統呼叫號(EAX暫存器)乘4,再加上表起始地址,即得到對應的系統呼叫核心處理函式指標
sys_call_table分派表由一段指令碼根據arch/x86/syscalls/syscall_32.tbl自動生成

整體上理解系統呼叫核心處理過程

ENTRY(system_call) SAVE_ALL cmpl $(nr_syscalls),%eax 否-> syscall_badsys 是 call*sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) syscall_exit 否-> restore_all -> iret 是 syscall_exit_work work_pending work_resched restore_all iret

流程圖中涉及system_call_exit內部處理,大致過程是需要跳轉到work_pending,裡面有work_notifysig處理訊號,還有work_resched需要重新排程,這裡是程序排程時機點call_schedule,排程結束會跳轉到restore_all恢復現場返回系統呼叫到使用者態,位於arch/x86/kernel/entry_32.S#593

ENTRY(system_call)
	RING0_INT_FRAME			# can't unwind into user space anyway
	ASM_CLAC
	pushl_cfi %eax			# save orig_eax
	SAVE_ALL
	GET_THREAD_INFO(%ebp)
					# system call tracing in operation / emulation
	testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
	jnz syscall_trace_entry
	cmpl $(NR_syscalls), %eax
	jae syscall_badsys
syscall_call:
	call *sys_call_table(,%eax,4)
syscall_after_call:
	movl %eax,PT_EAX(%esp)		# store the return value
syscall_exit:
	LOCKDEP_SYS_EXIT
	DISABLE_INTERRUPTS(CLBR_ANY)	# make sure we don't miss an interrupt
					# setting need_resched or sigpending
					# between sampling and the iret
	TRACE_IRQS_OFF
	movl TI_flags(%ebp), %ecx
	testl $_TIF_ALLWORK_MASK, %ecx	# current->work
	jne syscall_exit_work

restore_all:
	TRACE_IRQS_IRET
restore_all_notrace:
#ifdef CONFIG_X86_ESPFIX32
	movl PT_EFLAGS(%esp), %eax	# mix EFLAGS, SS and CS
	# Warning: PT_OLDSS(%esp) contains the wrong/random values if we
	# are returning to the kernel.
	# See comments in process.c:copy_thread() for details.
	movb PT_OLDSS(%esp), %ah
	movb PT_CS(%esp), %al
	andl $(X86_EFLAGS_VM | (SEGMENT_TI_MASK << 8) | SEGMENT_RPL_MASK), %eax
	cmpl $((SEGMENT_LDT << 8) | USER_RPL), %eax
	CFI_REMEMBER_STATE
	je ldt_ss			# returning to user-space with LDT SS
#endif
restore_nocheck:
	RESTORE_REGS 4			# skip orig_eax/error_code
irq_return:
	INTERRUPT_RETURN

從系統呼叫處理過程的入口開始,SAVE_ALL儲存現場,然後找到syscall_badsys和sys_call_table
call*sys_call_table(,%eax,4)就是呼叫了系統呼叫的核心處理函式,之後restore_all和INTERRUPT_RETURN(iret)用於恢復現場並返回系統呼叫到使用者態,這個過程中可能會執行syscall_exit_work,裡面有work_pending,其中的work_notifysig用來處理訊號,work_pending還有可能呼叫schedule

相關文章