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

這個函數比較重要:

  1. 獲取 ftrace_graph_call 這個函數的地址,放到 pc 這個變量裏面

  2. 通過 aarch64_insn_gen_branch_imm 函數,產生一條到 ftrace_graph_caller 的跳轉指令。

  3. 最終通過 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_returnftrace_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 的代碼進行解讀分析。

參考

  1. https://www.kernel.org/  kernel-4.19 內核代碼

  2. https://blog.csdn.net/u010681589/article/details/122400638 ARM V8A 體系結構 - 第九章 ARM64 平臺的 ABI

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Nr_UY-_T9usHltug1v00xw