<style> .part { text-align: left; } code,pre.code-wrapper{ width: 110%; } </style> # 使用eBPF trace `read` System Call ## Linux v6.16.7 x86_64 --- # 目錄 - eBPF介紹 - System Call 呼叫流程 - 使用bpftrace追蹤System Call - VFS介紹 --- ## eBPF介紹 - 前身為BPF(Berkeley Packet Filter),最初用於封包過濾 - 可在不修改Kernel本身、不載入Kernel module的前提進行安全的追蹤或擴充kernel - 可透過libbpf, bpftrace, bcc來撰寫eBPF程式 Note: eBPF前身為BPF(Berkeley Packet Filter),最初是專門用來處理封包過濾,擴充為 eBPF 後,就變成 Linux Kernel 內建的內部行為分析工具,某種程度上也可以當成是在Kernel內部的虛擬機 eBPF能讓使用者在不修改Kernel本身或不載入kernel module的前提下進行安全的kernel追蹤或功能擴充 使用者透過libbpf, bpftrace或bcc撰寫的eBPF程式會被編譯成eBPF bytecode,然後透過`bpf` system call將bytecode載入Kernel中 在執行任何 eBPF 程式之前,Kernel 內部的 Verifier 會對 bytecode 進行靜態分析,確保它不會對Kernel造成危害。 Verifier會檢查 1. 載入 eBPF 程式的程序擁有所需的能力(特權)。除非啟用非特權 eBPF,否則只有特權程序才能載入 eBPF 程式。 2. 該程式不會崩潰或以其他方式損害系統。 3. 程式一定會運行至結束(即程式不會永遠處於循環中,從而阻止進一步的處理)。 --- ## System Call呼叫流程 1. 函式庫呼叫System Call wrapper(`read`, `write`, `syscall`, ...) ```clike read(fd, buf, count); ``` 2. System Call wrapper內部執行`syscall` ASM Instruction,並**同時**執行以下兩個動作 - 跳轉到System Call entry (entry_SYSCALL_64) - 切換至kernel mode > 以ASM角度算是一前一後,軟體層面會視為同時 > 中斷不會在syscall中間處理 Note: 接著來說一下System Call的呼叫流程,首先使用者透過函式庫呼叫System Call wrapper(像是read, write, syscall等),然後這些wrapper內部會呼叫`syscall`這個Assembly Instruction,讓CPU跳轉到system call entry,並切換至Kernel mode system call entry就是syscall_init中寫入到LSTAR這個MSR(Model Specific Register)的位置,以x86_64來說是entry_SYSCALL_64 ---- ## System Call呼叫流程 3. `entry_SYSCALL_64`儲存User Space暫存器程式狀態後呼叫`do_syscall_64` 4. 在`do_syscall_64`中根據`sys_call_table`尋找相對應system call尋找 service routine * sys_call_table在x86_64中參考arch/x86/entry/syscalls/syscall_64.tbl在編譯期間生成 ``` # <number> <abi> <name> <entry point> [<compat entry point> [noreturn]] # 0 common read sys_read ``` Note: System Call entry會將User Space的暫存器與Stack先儲存起來,然後呼叫`do_syscall_64`來根據sys_call_table尋找System Call 編號對應到的service routine並呼叫,在x86_64中會基於這個table檔案(arch/x86/entry/syscalls/syscall_64.tbl)在編譯期間生成真正的sys_call_table到syscalls_64.h 以read來說,它的system call編號為0,對應到的service routine 是`sys_read`,這個service routine會被Macro擴展成`__x64_sys_*`的形式 // 執行scripts/syscallhdr.sh以生成 ---- ## System Call呼叫流程 5. 呼叫Kernel Function(如ksys_read) 6. 恢復暫存器與CPU狀態後透過`sysret` ASM Instruction離開system call - 跳轉回原本程式的下一個指令 - 回到User mode #### fs/read_write.c ```clike=723 [|5] SYSCALL_DEFINE3(read, unsigned int, fd, char __user *, buf, size_t, count) // sys_read -> __x64_sys_read { return ksys_read(fd, buf, count); } ``` Note: 而進入這個service routine後就會開始執行真正的Kernel Function 當 kernel function 執行完畢後會先將暫存器與CPU狀態回復到system call之前,並透過`sysret`這個 Assembly instruction 回到User Mode並繼續執行原本的程式 --- ## 使用bpftrace追蹤System Call 透過kstack獲取ksys_read的kernel call stack ```clike= [|4] kprobe:ksys_read { printf("Process %s(PID: %d) called `read()`\n"); printf("Kernel call stack: ", comm, pid); print(kstack); printf("\n\n") } ``` Note: 我們在前面知道read會呼叫ksys_read這個Function,因此試著透過bpftrace在ksys_read設定Hook point來抓取ksys_read的kernel call stack ---- ## 使用bpftrace追蹤System Call ### Output ``` Process ls(PID: 657485) called `read()` Kernel call stack: ksys_read+5 do_syscall_64+129 entry_SYSCALL_64_after_hwframe+118 ``` Note: 然後得到結果,發現抓到的Kernel call stack是從entry_SYSCALL_64_after_hwframe進入到do_syscall_64然後再到ksys_read,跟上面提到的順序差不多,sys_read應該是被inline所以沒有顯示 ---- ## 使用bpftrace追蹤System Call ### deep dive into `ksys_read` - 使用`stress-ng`與`perf`取得 `ksys_read` 函式內部 call stack與flamegraph ```bash perf record -F 99 -a --call-graph=dwarf -- \ stress-ng --hdd 1 --hdd-bytes 20G \ --timeout 1m --hdd-opts rd-rnd ``` Note: 不過上面的方式只能抓到ksys_read被呼叫當下的Call Stack,所以我改用perf來紀錄壓力測試期間的event並過濾來取得ksys_read內部的call stack ---- ## 使用bpftrace追蹤System Call ### deep dive into `ksys_read` ```bash perf script | stackcollapse-perf.pl | grep 'ksys_read' | flamegraph.pl --width 1920 --color mem > read_stress-rnd_flamegraph.svg ``` ---- ## 使用bpftrace追蹤System Call ### deep dive into `ksys_read` ![image](https://hackmd.io/_uploads/rJARPOy3gx.png) <!-- ![image](https://hackmd.io/_uploads/BkWRx2Rixl.png=250x) --> - Call Stack ```tree ksys_read └─ vfs_read └─ filemap_read ├─ filemap_get_pages // 嘗試從 Page Cache 中找到對應資料 │ └─ filemap_get_read_batch └─ copy_page_to_iter // 複製資料到User Space └─ _copy_to_iter └─ rep_movs_alternative ``` Note: 然後發現從`filemap_read`開始會先呼叫`filemap_get_pages`來從 Page Cache 中找到對應的資料,在Cache Hit 或Cache Miss然後生成新Page之後,它再呼叫 `copy_page_to_iter` 將這些 Kernel Space 的資料複製到User Space。(具體位置在函數庫接收到的buffer地址那個參數) 然後可以注意到,copy_page_to_iter花費的時間比filemap_get_pages的時間長很多,我猜是因為除了搬運資料本身就需要比較多時間以外,filemap_get_pages只要Cache Hit 就能較早離開Function --- ## VFS介紹 - Kernel中對於檔案操作的抽象介面 - 只需實作出VFS的介面即可擴充新的File System - `vfs_read`會根據`file->f_op-> <read/read_iter>`呼叫File System讀取文件的function > ### 以Btrfs為例: `btrfs_file_read_iter` Note: 在剛才 perf 的分析中,我們看到 ksys_read 內部會呼叫到 vfs_read。VFS 是 Kernel 中對於檔案操作的抽象介面,全名是 Virtual File System。可以讓檔案操作相關的Function透過一致的行為來存取而不用管現在是使用哪一個File System 任何新的檔案系統,只要依循 VFS 界面來開發實作,就可在執行時期掛載到Kernel。 以我們剛才看到的`vfs_read`來說,它會根據各檔案系統指定的file_operation來執行read或read_iter。以BTRFS來說,vfs_read會透過btrfs_file_read_iter處理檔案讀取 (不過我還在試著了解read與read_iter的差異) ---- ## VFS介紹 - fs/btrfs/file.c <!-- TODO: 搞懂file->f_op->read與file->f_op->read_iter的差異 --> ```clike=3791 [1-2|6-7|14] static ssize_t btrfs_file_read_iter(struct kiocb *iocb, struct iov_iter *to) { ssize_t ret = 0; if (iocb->ki_flags & IOCB_DIRECT) { ret = btrfs_direct_read(iocb, to); if (ret < 0 || !iov_iter_count(to) || iocb->ki_pos >= i_size_read( file_inode(iocb->ki_filp))) return ret; } return filemap_read(iocb, to, ret); } ``` Note: 來看一下btrfs處理read_iter的程式碼,可以注意到這個程式分為兩個路線,如果是透過直接讀取,也就是設定了IOCB_DIRECT這個flag的話就會直接從硬碟讀取資料,反之則是透過mm的filemap_read來試著從Page Cache中找對應資料 ---- ## VFS介紹 ### filemap_read - 透過`filemap_get_pages`嘗試從Page Cache尋找對應資料 - 若Cache hit則直接透過`copy_page_to_iter`將資料傳到User Space Note: 當進入filemap_read時會透過filemap_get_pages來嘗試從Page Cache尋找對應資料,如果成功找到對應資料(也就是Cache Hit)就會直接透過`copy_page_to_iter`將資料傳到User Space ---- ## VFS介紹 ### filemap_read #### Cache Miss - filemap_get_pages總共會搜尋Page Cache 2次 1. 第一次失敗後會嘗試啟用readahead prefetching機制(`page_cache_sync_ra`),並再次嘗試搜尋Cache 2. 第二次失敗代表readahead的資料還沒來得及載入(Cache Miss) - 透過`filemap_create_folio`建立Page Cache並透過`copy_page_to_iter`將這個Page傳到User Space Note: 但如果第一次沒有找到資料的話,會先嘗試啟用readahead prefetching機制,也就是先將檔案後面的資料非同步的從磁碟讀取。然後再次搜尋Cache (雖然說是非同步讀取,但我在Source Code中發現readahead是呼叫同步版本的Function) 如果這次還是沒找到的話,代表prefetch資料還來不及載入,因此需要透過`filemap_create_folio`建立一個新的Page,然後觸發一個 I/O 請求,真正地從磁碟把資料讀到這個Page中,並透過`copy_page_to_iter`將資料傳到user space指定的buffer --- # END --- # Extra --- ## Extra ### glibc syscall - sysdeps/unix/sysv/linux/x86_64/syscall.S ```asm=29 [1|9] ENTRY (syscall) movq %rdi, %rax /* Syscall number -> rax. */ movq %rsi, %rdi /* shift arg1 - arg5. */ movq %rdx, %rsi movq %rcx, %rdx movq %r8, %r10 movq %r9, %r8 movq 8(%rsp),%r9 /* arg6 is on the stack. */ syscall /* Do the system call. */ cmpq $-4095, %rax /* Check %rax for error. */ jae SYSCALL_ERROR_LABEL /* Jump to error handler if error. */ ret /* Return to caller. */ PSEUDO_END (syscall) ``` --- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/SkxDi0ghlg.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/SkJnj0xhle.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/ryJ0sRxnlx.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/BylbaCxnlx.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/HyuQj0gngg.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/S1NS60ghxl.png) ---- ## Extra ### 使用GDB追蹤 ![image](https://hackmd.io/_uploads/HJL5aAx3ee.png) --- ## Extra ### System Call呼叫流程 #### arch/x86/entry/entry_64.S; entry_SYSCALL_64 * 切換 GS 暫存器以存取Kernel Space資料 * 保存 User Space Stack Pointer 並切換至 Kernel Space Stack。 ---- ## Extra ### System Call呼叫流程 #### arch/x86/entry/entry_64.S; entry_SYSCALL_64_safe_stack * 建立 pt_regs 結構來儲存User Space程式的狀態 * ss:sp (User space stack pointer) * rflags (處理器狀態) * cs:ip (User space return address) ---- ## Extra ### System Call呼叫流程 #### arch/x86/entry/entry_64.S; entry_SYSCALL_64_after_hwframe * 處理do_syscall_64所需參數 * (%rsp(pt_regs) -> %rdi, %eax(nr) -> %rsi) ---- ## Extra ### System Call呼叫流程 - arch/x86/entry/entry_64.S ```asm=87 [1|2-9|11|12-19|20|21|26|28|35] SYM_CODE_START(entry_SYSCALL_64) UNWIND_HINT_ENTRY ENDBR swapgs /* tss.sp2 is scratch space. */ movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2) SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp SYM_INNER_LABEL(entry_SYSCALL_64_safe_stack, SYM_L_GLOBAL) ANNOTATE_NOENDBR /* Construct struct pt_regs on stack */ pushq $__USER_DS /* pt_regs->ss */ pushq PER_CPU_VAR(cpu_tss_rw + TSS_sp2) /* pt_regs->sp */ pushq %r11 /* pt_regs->flags */ pushq $__USER_CS /* pt_regs->cs */ pushq %rcx /* pt_regs->ip */ SYM_INNER_LABEL(entry_SYSCALL_64_after_hwframe, SYM_L_GLOBAL) pushq %rax /* pt_regs->orig_ax */ PUSH_AND_CLEAR_REGS rax=$-ENOSYS /* IRQs are off. */ movq %rsp, %rdi /* Sign extend the lower 32bit as syscall numbers are treated as int */ movslq %eax, %rsi /* clobbers %rax, make sure it is after saving the syscall nr */ IBRS_ENTER UNTRAIN_RET CLEAR_BRANCH_HISTORY call do_syscall_64 /* returns with IRQs disabled */ /* * Try to use SYSRET instead of IRET if we're returning to * a completely clean 64-bit userspace context. If we're not, * go to the slow exit path. * In the Xen PV case we must use iret anyway. */ ALTERNATIVE "testb %al, %al; jz swapgs_restore_regs_and_return_to_usermode", \ "jmp swapgs_restore_regs_and_return_to_usermode", X86_FEATURE_XENPV ``` ---- ## Extra ### System Call呼叫流程 - fs/read_write.c ```clike=87 [8] __visible noinstr bool do_syscall_64(struct pt_regs *regs, int nr) { add_random_kstack_offset(); nr = syscall_enter_from_user_mode(regs, nr); instrumentation_begin(); if (!do_syscall_x64(regs, nr) && !do_syscall_x32(regs, nr) && nr != -1) { /* Invalid system call, but still a system call. */ regs->ax = __x64_sys_ni_syscall(regs); } instrumentation_end(); syscall_exit_to_user_mode(regs); ``` ---- ## Extra ### System Call呼叫流程 - arch/x86/entry/syscalls/syscall_64.tbl ```asm= [4] # The format is: # <number> <abi> <name> <entry point> [<compat entry point> [noreturn]] # 0 common read sys_read # ... ``` ---- ## Extra ### System Call 呼叫流程 - arch/x86/include/generated/asm/syscalls_64.h ```asm= __SYSCALL(0, sys_read) __SYSCALL(1, sys_write) __SYSCALL(2, sys_open) __SYSCALL(3, sys_close) __SYSCALL(4, sys_newstat) __SYSCALL(5, sys_newfstat) __SYSCALL(6, sys_newlstat) __SYSCALL(7, sys_poll) __SYSCALL(8, sys_lseek) __SYSCALL(9, sys_mmap) __SYSCALL(10, sys_mprotect) __SYSCALL(11, sys_munmap) __SYSCALL(12, sys_brk) __SYSCALL(13, sys_rt_sigaction) __SYSCALL(14, sys_rt_sigprocmask) __SYSCALL(15, sys_rt_sigreturn) __SYSCALL(16, sys_ioctl) __SYSCALL(17, sys_pread64) ``` --- ## Extra ### VFS介紹 ```clike=704 [1|12] ssize_t ksys_read(unsigned int fd, char __user *buf, size_t count) { CLASS(fd_pos, f)(fd); ssize_t ret = -EBADF; if (!fd_empty(f)) { loff_t pos, *ppos = file_ppos(fd_file(f)); if (ppos) { pos = *ppos; ppos = &pos; } ret = vfs_read(fd_file(f), buf, count, ppos); if (ret >= 0 && ppos) fd_file(f)->f_pos = pos; } return ret; } ``` Note: 笑死準備那麼久的講稿結果完全沒用到 ----
{"title":"使用eBPF trace `read` System Call","description":"使用bpftrace追蹤","slideOptions":"{\"transition\":\"slide\"}","contributors":"[{\"id\":\"58a26e03-02dc-4c2b-a00b-5736c732ac4d\",\"add\":50943,\"del\":77248,\"latestUpdatedAt\":1759215167222}]"}
    411 views