歡迎光臨
每天分享高質量文章

gdb 如何呼叫函式? | Linux 中國

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


致謝
編譯自 | https://jvns.ca/blog/2018/01/04/how-does-gdb-call-functions/ 
 作者 | Julia Evans
 譯者 | Lv Feng (ucasFL) ? ? ? ? ? 共計翻譯:64 篇 貢獻時間:537 天

(之前的 gdb 系列文章:gdb 如何工作(2016)[1] 和三步上手 gdb(2014)[2]

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

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

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

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

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

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

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

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

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

  1. fn elf_symbol_value(file_name: &str, symbol_name: &str) -> Result<u64, Box<std::error::Error>> {

  2.    // 開啟 ELF 檔案

  3.    let file = elf::File::open_path(file_name).ok().ok_or("parse error")?;

  4.    // 在所有的段 & 符號中迴圈,直到找到正確的那個

  5.    let sections = &file.sections;

  6.    for s in sections {

  7.        for sym in file.get_symbols(&s).ok().ok_or("parse error")? {

  8.            if sym.name == symbol_name {

  9.                return Ok(sym.value);

  10.            }

  11.        }

  12.    }

  13.    None.ok_or("No symbol found")?

  14. }

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

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

你不能僅僅進行跳轉

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

如何從 gdb 中呼叫 C 函式

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

  1. #include <unistd.h>

  2. int foo() {

  3.    return 3;

  4. }

  5. int main() {

  6.    sleep(1000);

  7. }

接下來,編譯並執行它:

  1. $ gcc -o test  test.c

  2. $ ./test

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

  1. $ sudo gdb -p $(pgrep -f test)

  2. (gdb) p foo()

  3. $1 = 3

  4. (gdb) quit

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

這有什麼用?

下麵是一些可能的用途:

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

它是如何工作的

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

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

步驟列舉如下:

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

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

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

建立一個棧框

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

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

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

以及

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

我透過 gdb 做了一個試驗:

  1. (gdb) p $rsp

  2. $7 = (void *) 0x7ffea3d0bca8

  3. (gdb) break foo

  4. Breakpoint 1 at 0x40052a

  5. (gdb) p foo()

  6. Breakpoint 1, 0x000000000040052a in foo ()

  7. (gdb) p $rsp

  8. $8 = (void *) 0x7ffea3d0bc00

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

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

改變指令指標

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

  1. (gdb) p $rip

  2. $1 = (void (*)()) 0x7fae7d29a2f0 <__nanosleep_nocancel+7>

  3. (gdb) b foo

  4. Breakpoint 1 at 0x40052a

  5. (gdb) p foo()

  6. Breakpoint 1, 0x000000000040052a in foo ()

  7. (gdb) p $rip

  8. $3 = (void (*)()) 0x40052a <foo+4>

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

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

如何設定斷點

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

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

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

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

  1. // 改變 0x400528 處的指令

  2. 25622 ptrace(PTRACE_PEEKTEXT, 25618, 0x400528, [0x5d00000003b8e589]) = 0

  3. 25622 ptrace(PTRACE_POKEDATA, 25618, 0x400528, 0x5d00000003cce589) = 0

  4. // 開始執行程式

  5. 25622 ptrace(PTRACE_CONT, 25618, 0x1, SIG_0) = 0

  6. // 當到達斷點時獲取一個訊號

  7. 25622 ptrace(PTRACE_GETSIGINFO, 25618, NULL, {si_signo=SIGTRAP, si_code=SI_KERNEL, si_value={int=-1447215360, ptr=0x7ffda9bd3f00}}) = 0

  8. // 將 0x400528 處的指令更改為之前的樣子

  9. 25622 ptrace(PTRACE_PEEKTEXT, 25618, 0x400528, [0x5d00000003cce589]) = 0

  10. 25622 ptrace(PTRACE_POKEDATA, 25618, 0x400528, 0x5d00000003b8e589) = 0

在某處放置一條陷阱指令

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

  1. 5908  ptrace(PTRACE_PEEKTEXT, 5810, 0x7f6fa7c0b260, [0x48f389fd89485355]) = 0

  2. 5908  ptrace(PTRACE_PEEKTEXT, 5810, 0x7f6fa7c0b260, [0x48f389fd89485355]) = 0

  3. 5908 ptrace(PTRACE_POKEDATA, 5810, 0x7f6fa7c0b260, 0x48f389fd894853cc) = 0

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

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

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

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

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

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

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


via: https://jvns.ca/blog/2018/01/04/how-does-gdb-call-functions/

作者:Julia Evans[13] 譯者:ucasFL 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

贊(0)

分享創造快樂

© 2024 知識星球   網站地圖