Ftrace function graph 簡介
引言
由於 android 開發的需要與 systrace 的普及,現在大家在進行性能與功耗分析時候,經常會用到 systrace 跟 pefetto. 而 systrace 就是基於內核的 event tracing 來實現的。以如下的一段 pefetto 爲例。可以看到 tid=1845 的線程,在被喚醒到 CPU5 上之後,在 runnable 狀態上維持了 503us 纔開始運行,一共運行了 498us.
通過查找 systrace 裏面的原始 events 信息,具體如下
我們可以從 systrace 找到相應的 trace events 的信息
可以看到上面 systrace 顯示的 runnable 狀態,其實是通過解析 sched_waking 及 sched_switch 事件來獲取到的(237.160859 - 237.160356 = 0.000503),而 running 時間是通過 2 次 sched_switch 事件解析出來的(237.161357 - 237.160859 = 0.000498)
一、ftrace function graph 是什麼
除了上面提到的 trace events 之外,tracer 提供了很多其餘的功能(如下的 config 宏開關),本文主要介紹 function graph 的實現。
CONFIG_FUNCTION_TRACER=y
CONFIG_FUNCTION_GRAPH_TRACER=y
CONFIG_CONTEXT_SWITCH_TRACER=y
CONFIG_NOP_TRACER=y
#CONFIG_SHADOW_CALL_STACK is not set
通過打開上面的一些宏定義,並且關閉 CONFIG_SHADOW_CALL_STACK(具體爲什麼要關閉這個宏,後面再講)。我們可以看到如下的一些 tracer。
/sys/kernel/tracing # cat available_tracers
blk function_graph preemptirqsoff preemptoff irqsoff function nop
通過 echo xxxx > current_tracer 可以動態切換 tracer
/sys/kernel/tracing # cat current_tracer
nop
/sys/kernel/tracing # echo function_graph > current_tracer
/sys/kernel/tracing # cat current_tracer
function_graph
最終看到的 trace 信息如下圖。我們可看到進程的內核函數的調用關係,並且可以看到每一個函數的執行時間 (又一個性能調試神器)。
二、打開 function graph 時做了什麼
那麼具體內核是如何實現這個功能的呢?
linux 在打開 ftrace 的相關編譯宏之後,在編譯的時候加入 gcc 的 - pg 編譯選項。在函數中加入_mcount 函數。以 cpu_up 函數爲例,通過反彙編內核的 kernel/cpu.c 文件,可以看到如下彙編代碼。
可以看到在做完一系列壓棧準備之後,直接跳轉到了_mcount 函數。這個函數定義在 arch/arm64/kernel/entry-ftrace.S 文件裏面。最終函數調用到了 ftrace_caller 函數。
在詳細進入這段彙編代碼的解釋之前,我們先看一下在設置 current_tracer 的時候具體發生了什麼。通過寫 current_tracer 節點來切換 tracer 的話,調到了內核的 tracing_set_trace_write 函數,如果是使用 function_graph 的話,最終調用了函數 ftrace_enable_ftrace_graph_caller
這個函數比較重要:
-
獲取 ftrace_graph_call 這個函數的地址,放到 pc 這個變量裏面
-
通過 aarch64_insn_gen_branch_imm 函數,產生一條到 ftrace_graph_caller 的跳轉指令。
-
最終通過 ftrace_modify_code 來修改 ftrace_graph_call 原來所在位置的代碼(步驟 2 中產生的跳轉指令,這樣可以直接跳轉到 ftrace_graph_caller 這個函數)
所以我們可以看到,在使能 ftrace function graph 的時候,通過動態修改一條指令來跳轉到我們想執行的函數上。在關閉的時候,通過將這條跳轉指令恢復爲 nop 指令。
三、function graph 的功能實現
下面我們看一下_mcount 函數, 第一個是 mcount_enter 宏。
這裏面要不得不提到 ARM64 平臺的 ABI(Application Binary Interface)
簡而言之,X0~X7 寄存器用來進行函數的傳參,X29 作爲 FP(frame pointer,幀指針,用來指向一段函數的棧頂,注意不是整個程序的棧頂),X30 作爲 LR(link register)。
所以其實 mcount_enter 函數就是將 FP 及 LR 寄存器的值保持在棧裏面。同時將當前的棧指針 SP 作爲新函數的 FP(frame pointer)。
ENTRY(ftrace_caller)
mcount_enter
mcount_get_pc0 x0 // function's pc
mcount_get_lr x1 // function's lr
.global ftrace_call
ftrace_call: // tracer(pc, lr);
nop // This will be replaced with "bl xxx"
// where xxx can be any kind of tracer.
#ifdef CONFIG_FUNCTION_GRAPH_TRACER
.global ftrace_graph_call
ftrace_graph_call: // ftrace_graph_caller();
nop // If enabled, this will be replaced
// "b ftrace_graph_caller"
#endif
mcount_exit
ENDPROC(ftrace_caller)
由於我們在使能 function graph 的時候在 ftrace_enable_ftrace_graph_caller 裏面把 ftrace_graph_call 地址所在的 nop 指令改成了 b ftrace_graph_caller(注意這裏面是無返回的跳轉,沒有保存 lr)
ENTRY(ftrace_graph_caller)
mcount_get_lr_addr x0 // pointer to function's saved lr
mcount_get_pc x1 // function's pc
mcount_get_parent_fp x2 // parent's fp
bl prepare_ftrace_return // prepare_ftrace_return(&lr, pc, fp)
mcount_exit
ENDPROC(ftrace_graph_caller)
我們前面說到了 X0~X7 是默認用來進行參數傳遞的。在跳轉到 prepare_ftrace_return 之前,先準備一下傳入參數。這裏面的 prepare_ftrace_return 函數是 C 語言的,我們看一下這個函數的 3 個輸入參數。
void prepare_ftrace_return(unsigned long *parent, unsigned long self_addr,unsigned long frame_pointer)
prepare_ftrace_return 函數里面,除了 function_graph_enter
之外,最重要的就是 * parent = return_hooker.
這個代碼非常重要!parent 指針具體指向哪裏?
我們再次回到 ftrace_graph_caller 函數里面準備 parent 參數的地方。
mcount_get_lr_addr x0 // pointer to function's saved lr
看起來有點難懂,我們再次回到 mcount_enter 函數。
將 X29(FP)與 X30(LR)寄存器的內容壓棧,然後當前的棧地址設置爲當前函數的 FP。
由於棧是遞減的,所以這張圖的上面是棧的高地址,下面是低地址部分。隨着函數調用,棧從下往上遞減。再回到代碼
X29 即 FP,爲當前函數的棧頂。由於棧地址是遞減的,所以 [X29] 裏面保持的內容,就是下圖中的箭頭指向的 FP,即函數的棧頂。
而 [X29] + 8 , 就是綠框所在的地址(注意是地址,是一個指針)。
*parent = return_hooker 就是將函數在棧裏面保存的 LR 值給改成了 return_hooker。
會產生什麼結果呢?
依然以上面的 cpu_up 的彙編代碼爲例,首先通過壓棧將 LR、FP 寄存器的內容保存在棧裏。在函數結束時,通過 ldr x29, x30, [SP], #32 將棧裏面的 LR 及 FP 的內容恢復到寄存器裏面。然後最終直接 ret 指令。這樣在函數調用中就實現了 “從哪兒來,回哪兒去”。
但是執行了 * parent = return_hooker 這條代碼之後,棧內的 LR 的內容就被改變了。
函數會返回執行 return_to_handler 函數。
這一段依然是彙編代碼。
其中 save_return_regs 將 X0~X7 的值保持在棧裏面,restore_return_regs
用於將內容重新 restore 到寄存器裏面。
爲什麼要這麼做呢?因爲這時候函數主體已經執行完了,應該返回父函數繼續往下跑。但是因爲開啓了 function graph,這時候並沒有直接返回父函數繼續執行,而是在執行 return_to_handler 函數。這時候 X0~X7 裏面保持了一些返回值(函數主體的執行結果,需要返回給調用的地方進行返回值的判斷),而且 X0~X7(見 Aarch64 ABI)本身又是用來進行參數傳遞的,會用來給 return_to_handler 的一些子函數 ftrace_return_to_handler 進行傳參。所以爲了防止這些返回值被破壞,就臨時保持在棧裏面。
在 prepare_ftrace_return 裏面,除了替換了函數的 LR 之外,還將原來的 LR 的值進行了保存。調用 ftrace_push_return_trace 函數將 old 的 LR 值(即原始的 LR 返回地址)保存在 current->ret_stack[index].ret = ret; 裏面(可以看到 function graph 之後,task_struct 結構體裏面增加了不少字段)。最終通過調用 ftrace_pop_return_trace 將 LR 的值恢復。這樣回到了正常的父函數里面繼續往下執行了。
四、小結
本文介紹了 ftrace 的 function graph tracer,通過在函數的調用開始及調用結束分別調用了 prepare_ftr****ace_return 及 ftrace_return_to_handle****r 來進行 LR 的修改與恢復。這樣可以統計到每一個函數的調用關係與具體執行時間(在開始與結束時分別記錄了時間)。該功能可以幫助讀者在性能調試的時候識別到性能瓶頸,以便於後期的進一步性能優化調優。
由於在執行過程中要動態的修改棧內容,所以需要關閉 CONFIG_SHADOW_CALL_STACK; 在比較舊的內核版本上是需要關閉 CONFIG_STRICT_MEMORY_RWX 和 KERNEL_TEXT_RDONLY, 因爲代碼段是隻讀的,不允許動態修改。在 linux 內核的熱補丁中也用到類似的技術。
當然有些函數用 notrace 進行修飾,如 u64notracetrace_clock**(****void)**。具體原因留給讀者思考。通過 ftrace function graph 的整個代碼的學習,我們可以再次梳理一下在 arm64 架構上函數之間的調用是如何實現的、aarch64 上一些 ABI 的規範要求的參數傳遞方式與結果返回方式。
本文基於 kernel-4.19 的代碼進行解讀分析。
參考
-
https://www.kernel.org/ kernel-4.19 內核代碼
-
https://blog.csdn.net/u010681589/article/details/122400638 ARM V8A 體系結構 - 第九章 ARM64 平臺的 ABI
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Nr_UY-_T9usHltug1v00xw