C++函式呼叫棧從何而來

rainInSunny發表於2024-08-25
C++函式呼叫棧從何而來

竹杖芒鞋輕勝馬,誰怕?一蓑煙雨任平生~
個人主頁:rainInSunny | 個人專欄:C++那些事兒Qt那些事兒

目錄
  • 寫在前面
  • 原理綜述
  • x86架構函式呼叫棧分析
  • 如何獲取rbp暫存器的值
  • 總結

寫在前面

  程式設計師對函式呼叫棧是再熟悉不過了,無論是使用IDE除錯還是GDB等工具進行除錯,都離不開函式呼叫棧的分析。當我們遇到卡頓問題的時候,經常苦於沒有卡頓現場,也就是函式呼叫棧進行分析解決。除了利用上述工具獲取函式呼叫棧,能不能想辦法在程式碼中記錄函式呼叫棧,特別是卡頓的時候,還好是有辦法的~

原理綜述

  工具能夠獲取呼叫棧一定也是某個地方記錄著這樣的資訊。實際上函式呼叫棧和函式呼叫過程是分不開的。函式呼叫過程在彙編角度分析,是由一幀一幀函式幀棧過程實現。這個過程大致包含呼叫現場保護、棧拉伸、引數傳遞、函式執行、返回值傳遞、棧平衡、呼叫現場恢復等過程。整個過程比較複雜,後續會寫文章說明函式呼叫過程,這裡只用關注與函式呼叫棧相關的呼叫現場保護過程,簡單分析這個過程就能得出獲取呼叫棧的基本原理。

C++函式呼叫棧從何而來

  呼叫現場保護是隻在函式巢狀呼叫的過程中需要一塊記憶體空間來記錄呼叫返回後仍然需要用到的資料,可以把這些需要儲存的資料理解為一種呼叫現場,當函式呼叫返回時把這些資料讀到對應的暫存器,函式就能愉快的執行下去了。上圖是函式呼叫的棧幀示意圖,想要獲取函式呼叫棧,要關注FP(儲存棧底地址)和LR(儲存函式返回地址)暫存器,不同平臺暫存器名稱不一樣,但都會有這樣功能類似的暫存器。FP暫存器有很重要的三個作用:

  • 在FP儲存棧底地址基礎上增加值偏移,可以訪問到父函式的棧記憶體資料(如這裡的LR暫存器中的值)。
  • 在FP儲存棧底地址基礎上減少值偏移,可以訪問到子函式的棧記憶體資料(如區域性變數)。
  • FP儲存棧底地址指向的內容是父函式FP的棧頂地址,用於子函式執行完畢後回到父函式時的FP暫存器還原。

  函式巢狀呼叫過程中,每次開闢新的函式幀棧時之前最後做的就是將PC暫存器儲存下一條指令的地址壓棧儲存,此時LR暫存器也會儲存該棧地址。進入巢狀子函式呼叫後第一件事就是將父函式的棧頂地址壓棧儲存,也就是這兩者在棧空間地址是連續的,此時獲取FP暫存器中地址向上偏移一個單位,再讀取偏移後地址指向內容就能獲取下一條指令地址。(個人理解,希望講明白了>-<)如果用程式碼描述,就像這樣:

while (fp) 
{
	  pc = *(fp + 1); //pc代表儲存的下一條指令地址
	  fp = *fp; //fp指向的是父函式棧頂地址
}

x86架構函式呼叫棧分析

  如果看完原理還有點暈>-<,別急接下來一步步分析下x86上函式呼叫和呼叫棧相關的部分。下面是一個最簡單的程式,在分析之前,回顧下上面的程式碼,while迴圈中有pc暫存器和fp暫存器,需要明確的是x86上pc暫存器名稱是rip,fp暫存器的名稱是rbp,指向棧底,其次這裡會提到rsp暫存器,它一般指向棧頂)

int sub(int a, int b, int c, int d, int e, int f, int g, int h, int i, int j, int k, int l, int m, int n, int o) {
  int t = a + b;
  printf("The sub value is:%d\n", t);
  return t;
}
int main(void) {
  int p = sub(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15);
  printf("the return value is:%d\n", p);
  return 0;
}
main:
  0x100003ee0 <+0>:   pushq  %rbp
  0x100003ee1 <+1>:   movq   %rsp, %rbp
  0x100003ee4 <+4>:   subq   $0x50, %rsp                # 預留 80 位元組大小的棧記憶體空間
  0x100003ee8 <+8>:   movl   $0x0, -0x4(%rbp)		# 0 值寫入,預設預留的大小空間,無特別場景,不會使用

  0x100003eef <+15>:  movl   $0x1, %edi			# 引數入 暫存器
  0x100003ef4 <+20>:  movl   $0x2, %esi			# 引數入 暫存器
  0x100003ef9 <+25>:  movl   $0x3, %edx			# 引數入 暫存器
  0x100003efe <+30>:  movl   $0x4, %ecx			# 引數入 暫存器
  0x100003f03 <+35>:  movl   $0x5, %r8d			# 引數入 暫存器
  0x100003f09 <+41>:  movl   $0x6, %r9d			# 引數入 暫存器

  0x100003f0f <+47>:  movl   $0x7, (%rsp)               # 引數入 棧記憶體
  0x100003f16 <+54>:  movl   $0x8, 0x8(%rsp)		# 引數入 棧記憶體
  0x100003f1e <+62>:  movl   $0x9, 0x10(%rsp)		# 引數入 棧記憶體
  0x100003f26 <+70>:  movl   $0xa, 0x18(%rsp)		# 引數入 棧記憶體
  0x100003f2e <+78>:  movl   $0xb, 0x20(%rsp)		# 引數入 棧記憶體
  0x100003f36 <+86>:  movl   $0xc, 0x28(%rsp)		# 引數入 棧記憶體
  0x100003f3e <+94>:  movl   $0xd, 0x30(%rsp)		# 引數入 棧記憶體
  0x100003f46 <+102>: movl   $0xe, 0x38(%rsp)		# 引數入 棧記憶體
  0x100003f4e <+110>: movl   $0xf, 0x40(%rsp)		# 引數入 棧記憶體

  0x100003f56 <+118>: callq  0x100003e90               ; sub at main.c:10

  0x100003f5b <+123>: movl   %eax, -0x8(%rbp)
  0x100003f5e <+126>: movl   -0x8(%rbp), %esi
  0x100003f61 <+129>: leaq   0x32(%rip), %rdi          ; "the return value is:%d\n"
  0x100003f68 <+136>: movb   $0x0, %al
  0x100003f6a <+138>: callq  0x100003f78               ; symbol stub for: printf
  0x100003f6f <+143>: xorl   %eax, %eax
  0x100003f71 <+145>: addq   $0x50, %rsp
  0x100003f75 <+149>: popq   %rbp
  0x100003f76 <+150>: retq  

  上圖中的彙編是main函式呼叫過程,看著挺多,還好我們只用關注0x100003f56 <+118>: callq 0x100003e90 ; sub at main.c:10callq指令完成函式幀的切換,實現在main函式中呼叫sub子函式,這個過程完成了兩件事。首先將當前rip暫存器的值(下一條指令地址)儲存到棧空間中,也就是下圖中0x100003F5B儲存在了棧最下方,然後將子函式sub的地址0x100003e90賦值給了rip暫存器,這樣cpu下一條指令就會跳轉到sub函式。

pushq %rip
movl <子函式記憶體地址> %rip
C++函式呼叫棧從何而來

  下面分析sub函式,我們只用關注彙編過程的前面兩行,第一行0x100003e80 <+0>: pushq %rbp pushq相當於兩個過程,一個是subq $0x8, %rsp,這是一個棧拉伸的過程,相當於騰出8個位元組的空間來,接下來是movl. %rbp, %rsp,這條指令把rbp暫存器裡的值(也就是main函式的棧底地址)儲存到剛剛騰出來的位置上。第二行0x100003e81 <+1>: movq %rsp, %rbp ,將rsp暫存器的值賦值給rbp暫存器,其實就是讓rbp暫存器指向了sub函式的棧底。

  sub:
    0x100003e80 <+0>:  pushq  %rbp 
    # 以上,將父函式的 rbp 值存入棧底
    0x100003e81 <+1>:  movq   %rsp, %rbp 
    # 以上,將當前函式的 rsp 值賦予 rbp,此時 rbp 是子函式的棧底
    0x100003e84 <+4>:  subq   $0x20, %rsp 
    # 以上,將 rsp 值減少 32 位元組偏移,開闢棧預留記憶體空間
    0x100003e88 <+8>:  movl   0x50(%rbp), %eax
    0x100003e8b <+11>: movl   0x48(%rbp), %eax
    0x100003e8e <+14>: movl   0x40(%rbp), %eax
    0x100003e91 <+17>: movl   0x38(%rbp), %eax
    0x100003e94 <+20>: movl   0x30(%rbp), %eax
    0x100003e97 <+23>: movl   0x28(%rbp), %eax
    0x100003e9a <+26>: movl   0x20(%rbp), %eax
    0x100003e9d <+29>: movl   0x18(%rbp), %eax
    0x100003ea0 <+32>: movl   0x10(%rbp), %eax 
    # 以上,根據 棧底 rbp 做增加值偏移,獲取父函式的棧記憶體資料,即入參
    0x100003ea3 <+35>: movl   %edi, -0x4(%rbp)
    0x100003ea6 <+38>: movl   %esi, -0x8(%rbp)
    0x100003ea9 <+41>: movl   %edx, -0xc(%rbp)
    0x100003eac <+44>: movl   %ecx, -0x10(%rbp)
    0x100003eaf <+47>: movl   %r8d, -0x14(%rbp)
    0x100003eb3 <+51>: movl   %r9d, -0x18(%rbp) 
    # 以上,將入參暫存器的值存入當前棧記憶體空間,做減小值偏移
    0x100003eb7 <+55>: movl   -0x4(%rbp), %eax
    0x100003eba <+58>: addl   -0x8(%rbp), %eax 
    # 以上,完成 a + b 操作
    0x100003ebd <+61>: movl   %eax, -0x1c(%rbp) 
    # 以上,將 a + b 的結果,存入棧記憶體空間
    0x100003ec0 <+64>: movl   -0x1c(%rbp), %esi
    0x100003ec3 <+67>: leaq   0xd4(%rip), %rdi          ; "The return value is:%d\n"
    0x100003eca <+74>: movb   $0x0, %al
    0x100003ecc <+76>: callq  0x100003f7e               ; symbol stub for: printf 
    # 以上,呼叫 printf 函式開始列印 a + b 的值
    0x100003ed1 <+81>: movl   -0x1c(%rbp), %eax
    0x100003ed4 <+84>: addq   $0x20, %rsp
    0x100003ed8 <+88>: popq   %rbp
    0x100003ed9 <+89>: retq 

  注意這是一個連續的過程,所以棧上也是連續的,下圖可以看出main函式最後callq指令儲存指令地址0x100003F5B的棧位置就在當前rbp暫存器指向位置的上方,所以只要得到rbp暫存器指向的位置,加上1就能得到我們想要的指令地址了。還有重要的一點是rbp暫存器指向的內容是父函式(這裡是main函式)的棧底地址,透過這個地址加1又能得到上一層指令地址了,如此迴圈往復,得到呼叫棧指日可待~

C++函式呼叫棧從何而來

如何獲取rbp暫存器的值

  不同作業系統有不同的系統呼叫來獲取執行緒暫存器的狀態,這裡提供一個基於架構的通用思路,使用內聯彙編的方式來獲取。

int get_rbp_value() {
    int value;
    __asm__("movl %%rbp, %0" : "=r" (value));
    return value;
}

  這段程式碼使用GCC的內聯彙編語法,透過movl指令將rbp暫存器的值移動到一個區域性變數value中。"=r"是一個輸出約束,表示將結果儲存在提供的變數中。在這個例子中,我們使用%0來引用輸出變數value。當這段程式碼被執行時,rbp暫存器的值就會被讀取並儲存在value中,然後返回給呼叫者。這裡因為是內聯彙編,為了區分暫存器和變數,所以暫存器前有兩個%。
  當獲取到指令地址後,就可以透過類似backtrace_symbolsaddress2line等方式獲取對於的函式呼叫字串形式,這塊還沒實踐過,後續有時間研究研究>-<。

總結

  這個過程中需要核心關注的有以下幾點:

  • FP暫存器指向每個函式幀棧的棧底,而當前函式棧底儲存的內容就是父函式的棧底地址,這樣透過FP暫存器就能迴圈得到每個函式的棧底地址。
  • 每次函式呼叫發生幀棧切換前,下一條指令的地址會被儲存在呼叫者棧頂,這個地址和被呼叫者的棧底相鄰,因此能夠透過棧底地址偏移一個單位來獲取指令地址,最後達到獲取呼叫棧目的。

創作不易,感謝點贊、關注和收藏~

相關文章