gdb 如何呼叫函式?

發表於2018-04-30

在這周,我發現我可以從 gdb 上呼叫 C 函式。這看起來很酷,因為在過去我認為 gdb 最多隻是一個只讀除錯工具。

我對 gdb 能夠呼叫函式感到很吃驚。正如往常所做的那樣,我在 Twitter 上詢問這是如何工作的。我得到了大量的有用答案。我最喜歡的答案是 Evan Klitzke 的示例 C 程式碼,它展示了 gdb 如何呼叫函式。程式碼能夠執行,這很令人激動!

我(通過一些跟蹤和實驗)認為那個示例 C 程式碼和 gdb 實際上如何呼叫函式不同。因此,在這篇文章中,我將會闡述 gdb 是如何呼叫函式的,以及我是如何知道的。

關於 gdb 如何呼叫函式,還有許多我不知道的事情,並且,在這兒我寫的內容有可能是錯誤的。

從 gdb 中呼叫 C 函式意味著什麼?

在開始講解這是如何工作之前,我先快速的談論一下我是如何發現這件令人驚訝的事情的。

假如,你已經在執行一個 C 程式(目標程式)。你可以執行程式中的一個函式,只需要像下面這樣做:

  • 暫停程式(因為它已經在執行中)
  • 找到你想呼叫的函式的地址(使用符號表)
  • 使程式(目標程式)跳轉到那個地址
  • 當函式返回時,恢復之前的指令指標和暫存器

通過符號表來找到想要呼叫的函式的地址非常容易。下面是一段非常簡單但能夠工作的程式碼,我在 Linux 上使用這段程式碼作為例子來講解如何找到地址。這段程式碼使用 elf crate。如果我想找到 PID 為 2345 的程式中的 foo 函式的地址,那麼我可以執行 elf_symbol_value("/proc/2345/exe", "foo")

這並不能夠真的發揮作用,你還需要找到檔案的記憶體對映,並將符號偏移量加到檔案對映的起始位置。找到記憶體對映並不困難,它位於 /proc/PID/maps 中。

總之,找到想要呼叫的函式地址對我來說很直接,但是其餘部分(改變指令指標,恢復暫存器等)看起來就不這麼明顯了。

你不能僅僅進行跳轉

我已經說過,你不能夠僅僅找到你想要執行的那個函式地址,然後跳轉到那兒。我在 gdb 中嘗試過那樣做(jump foo),然後程式出現了段錯誤。毫無意義。

如何從 gdb 中呼叫 C 函式

首先,這是可能的。我寫了一個非常簡潔的 C 程式,它所做的事只有 sleep 1000 秒,把這個檔案命名為 test.c

接下來,編譯並執行它:

最後,我們使用 gdb 來跟蹤 test 這一程式:

我執行 p foo() 然後它執行了這個函式!這非常有趣。

這有什麼用?

下面是一些可能的用途:

  • 它使得你可以把 gdb 當成一個 C 應答式程式(REPL),這很有趣,我想對開發也會有用
  • 在 gdb 中進行除錯的時候展示/瀏覽複雜資料結構的功能函式(感謝 @invalidop
  • 在程式執行時設定一個任意的名字空間(我的同事 nelhage 對此非常驚訝)
  • 可能還有許多我所不知道的用途

它是如何工作的

當我在 Twitter 上詢問從 gdb 中呼叫函式是如何工作的時,我得到了大量有用的回答。許多答案是“你從符號表中得到了函式的地址”,但這並不是完整的答案。

有個人告訴了我兩篇關於 gdb 如何工作的系列文章:原生除錯:第一部分原生除錯:第二部分。第一部分講述了 gdb 是如何呼叫函式的(指出了 gdb 實際上完成這件事並不簡單,但是我將會盡力)。

步驟列舉如下:

  1. 停止程式
  2. 建立一個新的棧框(遠離真實棧)
  3. 儲存所有暫存器
  4. 設定你想要呼叫的函式的暫存器引數
  5. 設定棧指標指向新的棧框stack frame
  6. 在記憶體中某個位置放置一條陷阱指令
  7. 為陷阱指令設定返回地址
  8. 設定指令暫存器的值為你想要呼叫的函式地址
  9. 再次執行程式!

(LCTT 譯註:如果將這個呼叫的函式看成一個單獨的執行緒,gdb 實際上所做的事情就是一個簡單的執行緒上下文切換)

我不知道 gdb 是如何完成這些所有事情的,但是今天晚上,我學到了這些所有事情中的其中幾件。

建立一個棧框

如果你想要執行一個 C 函式,那麼你需要一個棧來儲存變數。你肯定不想繼續使用當前的棧。準確來說,在 gdb 呼叫函式之前(通過設定函式指標並跳轉),它需要設定棧指標到某個地方。

這兒是 Twitter 上一些關於它如何工作的猜測:

我認為它在當前棧的棧頂上構造了一個新的棧框來進行呼叫!

以及

你確定是這樣嗎?它應該是分配一個偽棧,然後臨時將 sp (棧指標暫存器)的值改為那個棧的地址。你可以試一試,你可以在那兒設定一個斷點,然後看一看棧指標暫存器的值,它是否和當前程式暫存器的值相近?

我通過 gdb 做了一個試驗:

這看起來符合“gdb 在當前棧的棧頂構造了一個新的棧框”這一理論。因為棧指標($rsp)從 0x7ffea3d0bca8 變成了 0x7ffea3d0bc00 —— 棧指標從高地址往低地址長。所以 0x7ffea3d0bca80x7ffea3d0bc00 的後面。真是有趣!

所以,看起來 gdb 只是在當前棧所在位置建立了一個新的棧框。這令我很驚訝!

改變指令指標

讓我們來看一看 gdb 是如何改變指令指標的!

的確是!指令指標從 0x7fae7d29a2f0 變為了 0x40052afoo 函式的地址)。

我盯著輸出看了很久,但仍然不理解它是如何改變指令指標的,但這並不影響什麼。

如何設定斷點

上面我寫到 break foo 。我跟蹤 gdb 執行程式的過程,但是沒有任何發現。

下面是 gdb 用來設定斷點的一些系統呼叫。它們非常簡單。它把一條指令用 cc 代替了(這告訴我們 int3 意味著 send SIGTRAP https://defuse.ca/online-x86-assembler.html),並且一旦程式被打斷了,它就把指令恢復為原先的樣子。

我在函式 foo 那兒設定了一個斷點,地址為 0x400528

PTRACE_POKEDATA 展示了 gdb 如何改變正在執行的程式。

在某處放置一條陷阱指令

當 gdb 執行一個函式的時候,它也會在某個地方放置一條陷阱指令。這是其中一條。它基本上是用 cc 來替換一條指令(int3)。

0x7f6fa7c0b260 是什麼?我檢視了程式的記憶體對映,發現它位於 /lib/x86_64-linux-gnu/libc-2.23.so 中的某個位置。這很奇怪,為什麼 gdb 將陷阱指令放在 libc 中?

讓我們看一看裡面的函式是什麼,它是 __libc_siglongjmp 。其他 gdb 放置陷阱指令的地方的函式是 __longjmp___longjmp_chkdl_main_dl_close_worker

為什麼?我不知道!也許出於某種原因,當函式 foo() 返回時,它呼叫 longjmp ,從而 gdb 能夠進行返回控制。我不確定。

gdb 如何呼叫函式是很複雜的!

我將要在這兒停止了(現在已經凌晨 1 點),但是我知道的多一些了!

看起來“gdb 如何呼叫函式”這一問題的答案並不簡單。我發現這很有趣並且努力找出其中一些答案,希望你也能夠找到。

我依舊有很多未回答的問題,關於 gdb 是如何完成這些所有事的,但是可以了。我不需要真的知道關於 gdb 是如何工作的所有細節,但是我很開心,我有了一些進一步的理解。

相關文章