Kprobe 筆記
工作上會接觸研究 Linux kernel, 特別是 block device 相關的部分(公司是做 NAS (Network-attached storage) 的), 所以對於 kernel debugging 也很重視.
有一次就遇到一個效能問題: 某款硬碟在某個測項的效能明顯落後別款硬碟, 為了追蹤 bio 的 merge 情形, 使用了 kprobe 插入在 merge scheduler 前面撈出相關資訊.
接下來的筆記都是我參考: https://www.kernel.org/doc/html/latest/trace/kprobes.html 所得到的心得, 如果有疑慮的話, 還是依官方文件跟實驗結果為準, 並且希望能夠指正我的錯誤.
甚麼是 Kprobe?
根據文件:
Kprobes enables you to dynamically break into any kernel routine and collect debugging and performance information non-disruptively.
也就是說這是一種 kernel runtime 的 debugging 技巧, 可以讓你蒐集 debugging 資訊.
There are currently two types of probes: kprobes, and kretprobes (also called return probes). A kprobe can be inserted on virtually any instruction in the kernel. A return probe fires when a specified function returns.
這邊是說, 其實還是分兩種: kprobes 跟 kretprobes (又稱為 return probes).
我覺得最大的差別在於粒度:
基本上 kprobes 可以插在 kernel space 的任意位置 (對應到 struct 裡面的 offset 之後會提到) (當然還是有一些限制, 例如插入之前會做檢查, 之後會提到) 並且基本是以一個 kernel module 的形式插入.
而 kretprobes 則是在指定的 function 回傳時才會觸發.
Kprobe 原理
根據文件:
When a kprobe is registered, Kprobes makes a copy of the probed instruction and replaces the first byte(s) of the probed instruction with a breakpoint instruction (e.g., int3 on i386 and x86_64).When a CPU hits the breakpoint instruction, a trap occurs, the CPU’s registers are saved, and control passes to Kprobes via the notifier_call_chain mechanism. Kprobes executes the “pre_handler” associated with the kprobe, passing the handler the addresses of the kprobe struct and the saved registers.Next, Kprobes single-steps its copy of the probed instruction. (It would be simpler to single-step the actual instruction in place, but then Kprobes would have to temporarily remove the breakpoint instruction. This would open a small time window when another CPU could sail right past the probepoint.)After the instruction is single-stepped, Kprobes executes the “post_handler,” if any, that is associated with the kprobe. Execution then continues with the instruction following the probepoint.
簡單來說就是一種 trampoline (跳跳床) 的概念 (相信玩過逆向工程, 模糊化的大大們應該不陌生).
kprobes 會把你有興趣的那段 instructions 複製起來到他那邊去, 保存狀態 (eg. registers), 並且把第一個指令替換成 breakpoints 指令(這邊之所以寫 bytes 是因為不同指令集的 breakpoint 指令長度不一, 例如 INT3 就是一個 one-byte x86 指令).
等到 breakpoint 被觸發到便會透過 nofifier_call_chain 機制通知並且轉跳到 kprobe 的 pre_handler. 執行完之後 single-steps 那些被 copy 的指令, 之後執行 post_handler 然後轉跳回去.
Jump Optimization
在追蹤效能問題最麻煩的就是怕 probe 影響效能, 這樣會倒是我們的 probe 使結果失真.
如果你的 kernel 有開對應的 config 跟 parameters, 那 kprobe 機制會試圖去做一些優化來減少 kprobe 所帶來的 overhead. (CONFIG_OPTPROBES=y, debug.kprobes_optimization)
Safety Check:
在做 optimization 之前會先進行一些安全檢測
- Kprobes 會確保要被 jump 指令抽換的區域 (稱為 optimized region) 是在一個 function 裡面, 因為 jump 指令可能是多個 bytes 的, 可能會跟其他指令重疊
- Kprobes 會確保 optimized region 裡面沒有 jump 指令, 不然想想看, 如果有的話不就有可能跳到其他不預期的地方, 那也太尷尬了
- 確保 optimized region 裡面的指令們可以 by line 的執行, 這樣才能 single-step
Optimization:
Kprobes 不會立刻用 jump 指令抽換, 而是先呼叫 synchronize_rcu()
這個呼叫可以確保在之前 active 的指令都做完了. 想想看如果 PC在這個 optimized region 時被 interrupted 怎麼辦?
確認之後, 呼叫 stop_machine() 把 optimized region 抽換到 detour buffer.
實際案例:
接下來就是我實際上用到的一個案例: 我想要追蹤 block/elevator.c 裡面的 elv_merge function 裡面的 bio 的 merge 情形.
程式碼: https://github.com/tony2037/spotmerge/blob/master/kprobe_merge.c
kernel 版本: TODO
第一步: 查看 symbol
我們可以從 /proc/kallsyms 看到 kernel export 出來的 symbols
ffffffff8b013d20 409 t pt_buffer_setup_aux
ffffffff8b014130 11f T intel_pt_interrupt
ffffffff8b014250 2d T cpu_emergency_stop_pt
ffffffff8b014280 13a t rapl_pmu_event_init [intel_rapl_perf]
ffffffff8b0143c0 bb t rapl_event_update [intel_rapl_perf]
ffffffff8b014480 10 t rapl_pmu_event_read [intel_rapl_perf]
ffffffff8b014490 a3 t rapl_cpu_offline [intel_rapl_perf]
ffffffff8b014540 24 t __rapl_event_show [intel_rapl_perf]
ffffffff8b014570 f2 t rapl_pmu_event_stop [intel_rapl_perf]
沒意外的話你可以在裡面找到我們的目標 elv_merge (cat /proc/kallsyms | grep elv_merge)
第二步: 相關的 structures
Kprobe 結構
struct kprobe {
struct hlist_node hlist;/* list of kprobes for multi-handler support */
struct list_head list;/*count the number of times this probe was temporarily disarmed */
unsigned long nmissed;/* location of the probe point */
kprobe_opcode_t *addr;/* Allow user to indicate symbol name of the probe point */
const char *symbol_name;/* Offset into the symbol */
unsigned int offset;/* Called before addr is executed. */
kprobe_pre_handler_t pre_handler;/* Called after addr is executed, unless… */
kprobe_post_handler_t post_handler;/*
* … called if executing addr causes a fault (eg. page fault).
* Return 1 if it handled fault, otherwise kernel will see it.
*/
kprobe_fault_handler_t fault_handler;/*
* … called if breakpoint trap occurs in probe handler.
* Return 1 if it handled break, otherwise kernel will see it.
*/
kprobe_break_handler_t break_handler;/* Saved opcode (which has been replaced with breakpoint) */
kprobe_opcode_t opcode;/* copy of the original instruction */
struct arch_specific_insn ainsn;/*
* Indicates various status flags.
* Protected by kprobe_mutex after this kprobe is registered.
*/
u32 flags;
};
這邊我們講幾個常用的:
- symbol_name: 這個例子裏面就是 elv_merge
- offset: 比如說我們想插在 elv_merge 後面 offset 0x32 的位置, 就填上 0x32. 如果想知道要填在哪裡可以編譯出這個檔案的 .o 檔之後使用 objdump 查看
- pre_handler: 執行原本的指令之前要做甚麼
- post_handler: 執行完原本的邏輯之後要做甚麼
- fault_hander: 要記住, 因為我們是 runtime 插入 code, 可能會導致不預期的結果, e.g. segmentation fault, 這邊就是當出錯時會觸發的 callback function.
pre_handler 的結構
typedef int (*kprobe_pre_handler_t) (struct kprobe *, struct pt_regs *);
這邊只要注意 pt_regs 就好了, 要拿到 bio (也就是 function 的 argument 得靠他).
這邊以 x86 為例子看到 https://elixir.bootlin.com/linux/v4.3/source/arch/x86/include/asm/ptrace.h#L11
我們可以看到這就是很直觀的 C ABI 定義的 registers.
以我們的例子來說看到 elv_merge 的 prototype:
int elv_merge(struct request_queue *q, struct request **req, struct bio *bio)
bio 是第二個參數, 根據 ABI 我們可以知道是透過 dx 傳遞. 有興趣的朋友們可以看到 https://docs.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-160#parameter-passing.
Integer valued arguments in the leftmost four positions are passed in left-to-right order in RCX, RDX, R8, and R9, respectively.
第三部: 寫一個 kernel module
綁定 symbol
#define MAX_SYMBOL_LEN 64
static char symbol[MAX_SYMBOL_LEN] = “elv_merge”;
module_param_string(symbol, symbol, sizeof(symbol), 0644);
static long long times = 0;/* For each probe you need to allocate a kprobe structure */
static struct kprobe kp = {
.symbol_name = symbol,
};
定義 pre_handler
static int __kprobes handler_pre(struct kprobe *p, struct pt_regs *regs)
{
struct bio *block_io = NULL;
times++;
if (times >= LLONG_MAX — 1) {
times = 0;
}
pr_info(“<ztex><%s> pre_handler: p->addr = 0x%p, ip = %lx, flags = 0x%lx, times=%lld, nmissed=%lu\n”,
p->symbol_name, p->addr, regs->ip, regs->flags, times, p->nmissed);
dump_stack();
block_io = regs->dx;
if (NULL != block_io) {
pr_info(“<ztex>disk name: %s\n”, block_io->bi_disk->disk_name);
}
/* A dump_stack() here will give a stack backtrace */
return 0;
}
註冊 kernel module
static int __init kprobe_init(void)
{
int ret;
kp.pre_handler = handler_pre;
kp.post_handler = handler_post;
kp.fault_handler = handler_fault;ret = register_kprobe(&kp);
if (ret < 0) {
pr_err(“register_kprobe failed, returned %d\n”, ret);
return ret;
}
pr_info(“Planted kprobe at %p\n”, kp.addr);
return 0;
}static void __exit kprobe_exit(void)
{
unregister_kprobe(&kp);
pr_info(“kprobe at %p unregistered\n”, kp.addr);
}module_init(kprobe_init)
module_exit(kprobe_exit)
MODULE_LICENSE(“GPL”);
編譯之後會得到一個 .ko, 之後就透過 insmod 插入 kernel module
可以知道當前處理的 bio 指向哪個硬碟.