<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`

<!--  -->
- 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追蹤

----
## Extra
### 使用GDB追蹤

----
## Extra
### 使用GDB追蹤

----
## Extra
### 使用GDB追蹤

----
## Extra
### 使用GDB追蹤

----
## Extra
### 使用GDB追蹤

----
## Extra
### 使用GDB追蹤

---
## 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}]"}