開發一個 Linux 調試器(一):準備環境
任何寫過比 hello world 複雜一些的程序的人都應該使用過調試器(如果你還沒有,那就停下手頭的工作先學習一下吧)。但是,儘管這些工具已經得到了廣泛的使用,卻並沒有太多的資源告訴你它們的工作原理以及如何開發,尤其是和其它那些比如編譯器等工具鏈技術相比而言。
此處有一些其它的資源可以參考:
我們將會支持以下功能:
- 啟動、暫停、繼續執行
- 在不同地方設置斷點
- 內存地址
- 源代碼行
- 函數入口
- 讀寫寄存器和內存
- 單步執行
- 指令
- 進入函數
- 跳出函數
- 跳過函數
- 列印當前代碼地址
- 列印函數調用棧
- 列印簡單變數的值
在最後一部分,我還會大概介紹如何給你的調試器添加下面的功能:
- 遠程調試
- 共享庫和動態庫支持
- 表達式計算
- 多線程調試支持
在本項目中我會將重點放在 C 和 C++,但對於那些將源碼編譯為機器碼並輸出標準 DWARE 調試信息的語言也應該能起作用(如果你還不知道這些東西是什麼,別擔心,馬上就會介紹到啦)。另外,我只關注如何將程序運行起來並在大部分情況下能正常工作,為了簡便,會避開類似健壯錯誤處理方面的東西。
系列文章索引
隨著後面文章的發布,這些鏈接會逐漸生效。
- 準備環境
- 斷點
- 寄存器和內存
- Elves 和 dwarves
- 源碼和信號
- 源碼層逐步執行
- 源碼層斷點
- 調用棧
- 讀取變數
- 之後步驟
LCTT 譯註:ELF —— 可執行文件格式 ;DWARF(一種廣泛使用的調試數據格式,參考 [WIKI](https://en.wikipedia.org/wiki/DWARF "DWARF WIKI"))。
準備環境
在我們正式開始之前,我們首先要設置環境。在這篇文章中我會依賴兩個工具:Linenoise 用於處理命令行輸入,libelfin 用於解析調試信息。你也可以使用更傳統的 libdwarf 而不是 libelfin,但是界面沒有那麼友好,另外 libelfin 還提供了基本完整的 DWARF 表達式求值器,當你想讀取變數的值時這能幫你節省很多時間。確認你使用的是 libelfin 我的 fbreg 分支,因為它提供 x86 上讀取變數的額外支持。
一旦你在系統上安裝或者使用你喜歡的編譯系統編譯好了這些依賴工具,就可以開始啦。我在 CMake 文件中把它們設置為和我其餘的代碼一起編譯。
啟動可執行程序
在真正調試任何程序之前,我們需要啟動被調試的程序。我們會使用經典的 fork
/exec
模式。
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "Program name not specified";
return -1;
}
auto prog = argv[1];
auto pid = fork();
if (pid == 0) {
//we're in the child process
//execute debugee
}
else if (pid >= 1) {
//we're in the parent process
//execute debugger
}
我們調用 fork
把我們的程序分成兩個進程。如果我們是在子進程,fork
返回 0,如果我們是在父進程,它會返回子進程的進程 ID。
如果我們是在子進程,我們要用希望調試的程序替換正在執行的程序。
ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
execl(prog.c_str(), prog.c_str(), nullptr);
這裡我們第一次遇到了 ptrace
,它會在我們編寫調試器的時候經常遇到。ptrace
通過讀取寄存器、內存、逐步調試等讓我們觀察和控制另一個進程的執行。其 API 非常簡單;你需要給這個簡單函數提供一個枚舉值指定你想要進行的操作,然後是一些取決於你所提供的值可能會被使用也可能會被忽略的參數。函數原型看起來類似:
long ptrace(enum __ptrace_request request, pid_t pid,
void *addr, void *data);
request
是我們想對被跟蹤進程進行的操作;pid
是被跟蹤進程的進程 ID;addr
是一個內存地址,用於在一些調用中指定被跟蹤程序的地址;data
是 request
相應的資源。返回值通常是一些錯誤信息,因此在你實際的代碼中你也許應該檢查返回值;為了簡潔我這裡就省略了。你可以查看 man 手冊獲取更多(關於 ptrace)的信息。
上面代碼中我們發送的請求 PTRACE_TRACEME
表示這個進程應該允許父進程跟蹤它。所有其它參數都會被忽略,因為 API 設計並不是很重要,哈哈。
下一步,我們會調用 execl
,這是很多諸多的 exec
函數格式之一。我們執行指定的程序,通過命令行參數傳遞它的名稱,然後用一個 nullptr
終止列表。如果你願意,你還可以傳遞其它執行你的程序所需的參數。
在完成這些後,我們就會和子進程一起結束;在我們結束它之前它會一直執行。
添加調試循環
現在我們已經啟動了子進程,我們想要能夠和它進行交互。為此,我們會創建一個 debugger
類,循環監聽用戶輸入,然後在我們父進程的 main
函數中啟動它。
else if (pid >= 1) {
//parent
debugger dbg{prog, pid};
dbg.run();
}
class debugger {
public:
debugger (std::string prog_name, pid_t pid)
: m_prog_name{std::move(prog_name)}, m_pid{pid} {}
void run();
private:
std::string m_prog_name;
pid_t m_pid;
};
在 run
函數中,我們需要等待,直到子進程完成啟動,然後一直從 linenoise
獲取輸入直到收到 EOF
(CTRL+D
)。
void debugger::run() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
char* line = nullptr;
while((line = linenoise("minidbg> ")) != nullptr) {
handle_command(line);
linenoiseHistoryAdd(line);
linenoiseFree(line);
}
}
當被跟蹤的進程啟動時,會發送一個 SIGTRAP
信號給它,這是一個跟蹤或者斷點中斷。我們可以使用 waitpid
函數等待這個信號發送。
當我們知道進程可以被調試之後,我們監聽用戶輸入。linenoise
函數它自己會用一個窗口顯示和處理用戶輸入。這意味著我們不需要做太多的工作就會有一個支持歷史記錄和導航命令的命令行。當我們獲取到輸入時,我們把命令發給我們寫的小程序 handle_command
,然後我們把這個命令添加到 linenoise
歷史並釋放資源。
處理輸入
我們的命令類似 gdb 以及 lldb 的格式。要繼續執行程序,用戶需要輸入 continue
或 cont
甚至只需 c
。如果他們想在一個地址中設置斷點,他們會輸入 break 0xDEADBEEF
,其中 0xDEADBEEF
就是所需地址的 16 進位格式。讓我們來增加對這些命令的支持吧。
void debugger::handle_command(const std::string& line) {
auto args = split(line,' ');
auto command = args[0];
if (is_prefix(command, "continue")) {
continue_execution();
}
else {
std::cerr << "Unknown commandn";
}
}
split
和 is_prefix
是一對有用的小程序:
std::vector<std::string> split(const std::string &s, char delimiter) {
std::vector<std::string> out{};
std::stringstream ss {s};
std::string item;
while (std::getline(ss,item,delimiter)) {
out.push_back(item);
}
return out;
}
bool is_prefix(const std::string& s, const std::string& of) {
if (s.size() > of.size()) return false;
return std::equal(s.begin(), s.end(), of.begin());
}
我們會把 continue_execution
函數添加到 debuger
類。
void debugger::continue_execution() {
ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
}
現在我們的 continue_execution
函數會用 ptrace
告訴進程繼續執行,然後用 waitpid
等待直到收到信號。
總結
現在你應該編譯一些 C 或者 C++ 程序,然後用你的調試器運行它們,看它是否能在函數入口暫停、從調試器中繼續執行。在下一篇文章中,我們會學習如何讓我們的調試器設置斷點。如果你遇到了任何問題,在下面的評論框中告訴我吧!
你可以在這裡找到該項目的代碼。
via: http://blog.tartanllama.xyz/c++/2017/03/21/writing-a-linux-debugger-setup/
作者:Simon Brand 譯者:ictlyh 校對:jasminepeng
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive