一、背景
在之前的博客?內核邏輯里抓取用戶棧的幾種方法-CSDN博客?里,介紹了使用內核邏輯進行用戶棧的函數地址的抓取邏輯,但是并沒有涉及如何解析出函數符號的邏輯。
就如perf工具一樣,它也是分為兩個步驟,一個步驟是內核態抓取函數地址的PC調用鏈,第二個步驟則是在用戶態邏輯里根據抓取的PC轉換成人可閱讀的函數符號名字。
當前如果不去考慮解析的性能的話,那么只要拿到elf的路徑及offset(具體參考之前的博客 內核邏輯里抓取用戶棧的幾種方法-CSDN博客),然后通過addr2line就可以解析出用戶棧的調用鏈符號,但是這樣解析的話,重復的進行進程創建及退出,性能是不夠好的。這篇博客里,我們就講如何高效地進行用戶棧的調用鏈符號的解析。
二、高性能解析的核心思想
2.1 復用elf的解析結果,預解析常用的so
該高性能解析的核心思想就是啟動一個用來解析elf和進程的maps表的應用服務,該應用服務可以以守護進程方式長期運行,被解析請求觸發來執行。
由于該應用服務是常駐的,所以就避免了反復啟動進程重復解析一樣的elf文件導致性能損失。該應用服務會把已經解析elf文件得到的信息進行記錄,下一次需要解析相同的elf文件時復用之前解析的結果,同樣的,下一次需要解析相同的進程時,也可以復用之前的/proc/<pid>/maps的解析結果。
對于elf文件,一般來說,它是不變的。但是要注意,對于/proc/<pid>/maps里的內容,它是可能變化的,如程序使用dlopen/dlclose這種動態加載和解加載so庫的函數,就會觸發maps的變更,在實現時需要考慮這樣的情況。
另外,為了進一步提升性能,我們可以在該進程啟動后,先把一些常用的so庫先解析出來,這樣在真正接到要解析的請求時由于之前已經解析過了,就可以減少第一次解析某個so的elf文件的耗時。
2.2 使用map容器的upper_bound函數快速找到對應的符號及偏移
假設我們已經解析了某個elf的文件,拿到了函數表,我們如何能快速找到對應的符號呢?
我們可以使用map容器的upper_bound接口來實現這樣的快速查找,并且map容器的下標得用地址區間的end,而不能用地址區間的start。
這算是一個小算法,但是也有一定的細節,不能用lower_bound,也不能用start作為key,否則會出現解析不符合預期及解析錯誤的情況。
這里面主要考慮的就是地址區間通常來說是指[start, end)這么一個區間,也就是start是大于等于,而end是小于。
三、完整的解析步驟
這里說的完整的解析步驟是假設了已經拿到了用戶棧調用鏈的PC的情況下的。至于如何抓取用戶棧PC,在之前的博客里?內核邏輯里抓取用戶棧的幾種方法-CSDN博客 給出了通過內核態邏輯抓取的方法。
我們分析,如果根據進程的pid及進程的PC地址的va,得到對應的函數符號和offset。
3.1 先根據進程的pid獲取到進程的elf和maps信息的管理對象
每個進程都對應有一個管理對象,來管理進程的相關與解析函數符號邏輯有關的信息,最主要就是/proc/<pid>/maps,其他信息則是用于輔助輸出的內容,比如cmdline內容,這些輔助內容的輸出可以幫助定位是具體哪個進程,因為有時候進程名是一樣的(/proc/<pid>/comm),但是cmdline是不一樣的,可以看出一些細節信息。
關于/proc/<pid>/maps的解析,參考如下邏輯:
if (unlikely(!READ_PROC_MAPS(i_processid, [&](char *i_obuf, int i_obufsize) -> bool {unsigned long start;unsigned long end;char permissions[5]; // 讀、寫、執行、共享unsigned long offset;char pathname[HARDLINK_MAXBYTE];int ret = sscanf(i_obuf, "%lx-%lx %4s %lx %*x:%*x %*u %s",&start, &end, permissions, &offset,pathname);if (ret == 5 && permissions[2] == 'x') {...
#if (DEBUG_LOG == 1)printf("range: %lx-%lx, permission: %s, offset: %lx, hlink: %s\n",start, end, permissions, offset, pathname);
#endifreturn true;}else {return false;}}))) {...break;}
上面的邏輯里使用了lambda表達式,可以簡化邏輯。
3.2 根據elf路徑進行增量解析
所謂“增量”解析,也就是指已經解析過的elf文件不再重復解析,因為我們已經保存下來之前解析出來的結果了。
我們可以用一個map來保存已經解析過的內容,key表示elf絕對路徑。
如果之前沒有解析過相關的elf,則使用objdump -t來進行解析,要注意,務必使用objdump -t來解析,因為objdump -t可以解析出弱符號和static的局部符號,而objdump -T則解析不出這些符號。
objdump的命令如下:
"objdump -t %s | grep -E '\\.text'"
上面的%s替換成elf的絕對路徑。
3.3 解析vdso及vsyscall的符號
不管哪個平臺,一般都有vdso的符號,但是vsyscall則不同的平臺不一樣,x86上是有的。
有關vdso和vsyscall的基礎介紹和相關內核邏輯和glibc邏輯的相關細節見之前的博客?vdso概念及原理,vdso_fault缺頁異常,vdso符號的獲取-CSDN博客?和 vdso內核與glibc配合的相關邏輯分析-CSDN博客。
3.3.1 vdso符號表的獲取
vdso的符號表的獲取,我們是通過dd命令從系統上一般都存在的systemd進程里撈取取出相關的so文件內容,并通過objdump進行解析。
通過dd命令撈取vdso.so文件的命令如下:
if (unlikely(!PROC1MAPS_GREP_VDSO([&](char *i_obuf, int i_obufsize) -> bool {unsigned long vdso_va_begin;unsigned long vdso_va_end;if (sscanf(i_obuf, "%lx%*c%lx", &vdso_va_begin, &vdso_va_end) == 2) {...
#if (DEBUG_LOG == 1)printf("vdso_va_begin:0x%lx, vdso_va_end:0x%lx, size:0x%lx\n", vdso_va_begin, vdso_va_end, vdso_va_end - vdso_va_begin);
#endifchar systemcmd[256];snprintf(systemcmd, 256, "dd if=/proc/1/mem of=" TEMP_VDSO_SO_FILE " skip=%lu ibs=1 count=%lu\n", vdso_va_begin, vdso_va_end - vdso_va_begin);system(systemcmd);return true;}return false;}))) {// 出錯了return psyminfo;}
上面PROC1MAPS_GREP_VDSO則是執行如下的命令:
"cat /proc/1/maps | grep -E '\\[vdso\\]'"
然后通過sscanf解析出vdso.so的va的begin和end,然后通過dd命令去dump,dump到一個臨時文件中。
然后再通過如下的objdump命令進行解析:
"objdump -T %s | grep -E '\\.text'"
上面的%s則是vdso.so的臨時文件的路徑。
3.3.2 vsyscall符號表的獲取
vsyscall的符號表則是直接根據對應平臺的內核里的相關符號的內容情況,手動進行組裝。有關vsyscall的符號信息如何查看,參考之前的博客?vdso概念及原理,vdso_fault缺頁異常,vdso符號的獲取-CSDN博客 里的 4.2 一節。
下面的是大致的拼湊邏輯:
..* ...() {..* psyminfo;...// vsyscall目前只用考慮x86場景,x86的vsyscall的情況是固定的,就三個符號...unsigned long start = 0;unsigned long span = 1024;psysrange = ...psysrange->start = start;psysrange->span = span;strscpy(psysrange->sym, "gettimeofday", SYM_MAXBYTE);
#if (DEBUG_LOG == 1)printf("start:0x%llx, span:0x%llx, typestr:%s, symname:%s \n",psysrange->start, psysrange->span, HLINK_VSYSCALL, psysrange->sym);
#endifstart += 1024;...psysrange->start = start;psysrange->span = span;strscpy(psysrange->sym, "time", SYM_MAXBYTE);
#if (DEBUG_LOG == 1)printf("start:0x%llx, span:0x%llx, typestr:%s, symname:%s \n",psysrange->start, psysrange->span, HLINK_VSYSCALL, psysrange->sym);
#endifstart += 1024;...psysrange->start = start;psysrange->span = span;strscpy(psysrange->sym, "getcpu", SYM_MAXBYTE);
#if (DEBUG_LOG == 1)printf("start:0x%llx, span:0x%llx, typestr:%s, symname:%s \n",psysrange->start, psysrange->span, HLINK_VSYSCALL, psysrange->sym);
#endifreturn psyminfo;}
3.4 通過upper_bound來查找對應的符號及offset
有關為什么用upper_bound在上面 2.2 里做了簡要說明。
大致的代碼如下:
..* ...(u64 i_addr) {auto it = xx.upper_bound(i_addr);//printf("count[%d]\n", xx.size());if (it != xx.end()) {if (it->second->start <= i_addr && i_addr < it->second->start + it->second->span) {return it->second;}}return NULL;}
3.5 成果展示
我們使用perf來抓取進程pid是1的systemd這個進程的用戶態符號,然后,再用該程序進行解析,看解析出的內容是否一致。
用perf record -g -p 1之后,用perf script得到的如下圖的一處采樣:
我們使用解析程序,輸入pid和va,進行解析,可以看到解析出一樣的函數符號名和offset。