開發(fā)一個(gè)Linux調(diào)試器(一):準(zhǔn)備環(huán)境
任何寫過比 hello world 復(fù)雜一些的程序的人都應(yīng)該使用過調(diào)試器(如果你還沒有,那就停下手頭的工作先學(xué)習(xí)一下吧)。但是,盡管這些工具已經(jīng)得到了廣泛的使用,卻并沒有太多的資源告訴你它們的工作原理以及如何開發(fā),尤其是和其它那些比如編譯器等工具鏈技術(shù)相比而言。
此處有一些其它的資源可以參考:
- http://eli.thegreenplace.net/2011/01/23/how-debuggers-work-part-1
- https://t-a-w.blogspot.co.uk/2007/03/how-to-code-debuggers.html
- https://www.codeproject.com/Articles/43682/Writing-a-basic-Windows-debugger
- http://system.joekain.com/debugger/
我們將會(huì)支持以下功能:
- 啟動(dòng)、暫停、繼續(xù)執(zhí)行
- 在不同地方設(shè)置斷點(diǎn)
- 內(nèi)存地址
- 源代碼行
- 函數(shù)入口
- 讀寫寄存器和內(nèi)存
- 單步執(zhí)行
- 指令
- 進(jìn)入函數(shù)
- 跳出函數(shù)
- 跳過函數(shù)
- 打印當(dāng)前代碼地址
- 打印函數(shù)調(diào)用棧
- 打印簡(jiǎn)單變量的值
在***一部分,我還會(huì)大概介紹如何給你的調(diào)試器添加下面的功能:
- 遠(yuǎn)程調(diào)試
- 共享庫和動(dòng)態(tài)庫支持
- 表達(dá)式計(jì)算
- 多線程調(diào)試支持
在本項(xiàng)目中我會(huì)將重點(diǎn)放在 C 和 C++,但對(duì)于那些將源碼編譯為機(jī)器碼并輸出標(biāo)準(zhǔn) DWARE 調(diào)試信息的語言也應(yīng)該能起作用(如果你還不知道這些東西是什么,別擔(dān)心,馬上就會(huì)介紹到啦)。另外,我只關(guān)注如何將程序運(yùn)行起來并在大部分情況下能正常工作,為了簡(jiǎn)便,會(huì)避開類似健壯錯(cuò)誤處理方面的東西。
系列文章索引
隨著后面文章的發(fā)布,這些鏈接會(huì)逐漸生效。
- 準(zhǔn)備環(huán)境
- 斷點(diǎn)
- 寄存器和內(nèi)存
- Elves 和 dwarves
- 源碼和信號(hào)
- 源碼層逐步執(zhí)行
- 源碼層斷點(diǎn)
- 調(diào)用棧
- 讀取變量
- 之后步驟
LCTT 譯注:ELF —— 可執(zhí)行文件格式(Executable and Linkable Format);DWARF(一種廣泛使用的調(diào)試數(shù)據(jù)格式,參考 WIKI)。
準(zhǔn)備環(huán)境
在我們正式開始之前,我們首先要設(shè)置環(huán)境。在這篇文章中我會(huì)依賴兩個(gè)工具:Linenoise 用于處理命令行輸入,libelfin 用于解析調(diào)試信息。你也可以使用更傳統(tǒng)的 libdwarf 而不是 libelfin,但是界面沒有那么友好,另外 libelfin 還提供了基本完整的 DWARF 表達(dá)式求值器,當(dāng)你想讀取變量的值時(shí)這能幫你節(jié)省很多時(shí)間。確認(rèn)你使用的是 libelfin 我的 fbreg 分支,因?yàn)樗峁?x86 上讀取變量的額外支持。
一旦你在系統(tǒng)上安裝或者使用你喜歡的編譯系統(tǒng)編譯好了這些依賴工具,就可以開始啦。我在 CMake 文件中把它們?cè)O(shè)置為和我其余的代碼一起編譯。
啟動(dòng)可執(zhí)行程序
在真正調(diào)試任何程序之前,我們需要啟動(dòng)被調(diào)試的程序。我們會(huì)使用經(jīng)典的 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
- }
我們調(diào)用 fork 把我們的程序分成兩個(gè)進(jìn)程。如果我們是在子進(jìn)程,fork 返回 0,如果我們是在父進(jìn)程,它會(huì)返回子進(jìn)程的進(jìn)程 ID。
如果我們是在子進(jìn)程,我們要用希望調(diào)試的程序替換正在執(zhí)行的程序。
- ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);
- execl(prog.c_str(), prog.c_str(), nullptr);
這里我們***次遇到了 ptrace,它會(huì)在我們編寫調(diào)試器的時(shí)候經(jīng)常遇到。ptrace 通過讀取寄存器、內(nèi)存、逐步調(diào)試等讓我們觀察和控制另一個(gè)進(jìn)程的執(zhí)行。其 API 非常簡(jiǎn)單;你需要給這個(gè)簡(jiǎn)單函數(shù)提供一個(gè)枚舉值指定你想要進(jìn)行的操作,然后是一些取決于你所提供的值可能會(huì)被使用也可能會(huì)被忽略的參數(shù)。函數(shù)原型看起來類似:
- long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
request 是我們想對(duì)被跟蹤進(jìn)程進(jìn)行的操作;pid 是被跟蹤進(jìn)程的進(jìn)程 ID;addr 是一個(gè)內(nèi)存地址,用于在一些調(diào)用中指定被跟蹤程序的地址;data 是 request 相應(yīng)的資源。返回值通常是一些錯(cuò)誤信息,因此在你實(shí)際的代碼中你也許應(yīng)該檢查返回值;為了簡(jiǎn)潔我這里就省略了。你可以查看 man 手冊(cè)獲取更多(關(guān)于 ptrace)的信息。
上面代碼中我們發(fā)送的請(qǐng)求 PTRACE_TRACEME 表示這個(gè)進(jìn)程應(yīng)該允許父進(jìn)程跟蹤它。所有其它參數(shù)都會(huì)被忽略,因?yàn)?API 設(shè)計(jì)并不是很重要,哈哈。
下一步,我們會(huì)調(diào)用 execl,這是很多諸多的 exec 函數(shù)格式之一。我們執(zhí)行指定的程序,通過命令行參數(shù)傳遞它的名稱,然后用一個(gè) nullptr 終止列表。如果你愿意,你還可以傳遞其它執(zhí)行你的程序所需的參數(shù)。
在完成這些后,我們就會(huì)和子進(jìn)程一起結(jié)束;在我們結(jié)束它之前它會(huì)一直執(zhí)行。
添加調(diào)試循環(huán)
現(xiàn)在我們已經(jīng)啟動(dòng)了子進(jìn)程,我們想要能夠和它進(jìn)行交互。為此,我們會(huì)創(chuàng)建一個(gè) debugger 類,循環(huán)監(jiān)聽用戶輸入,然后在我們父進(jìn)程的 main 函數(shù)中啟動(dòng)它。
- 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 函數(shù)中,我們需要等待,直到子進(jìn)程完成啟動(dòng),然后一直從 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);
- }
- }
當(dāng)被跟蹤的進(jìn)程啟動(dòng)時(shí),會(huì)發(fā)送一個(gè) SIGTRAP 信號(hào)給它,這是一個(gè)跟蹤或者斷點(diǎn)中斷。我們可以使用 waitpid 函數(shù)等待這個(gè)信號(hào)發(fā)送。
當(dāng)我們知道進(jìn)程可以被調(diào)試之后,我們監(jiān)聽用戶輸入。linenoise 函數(shù)它自己會(huì)用一個(gè)窗口顯示和處理用戶輸入。這意味著我們不需要做太多的工作就會(huì)有一個(gè)支持歷史記錄和導(dǎo)航命令的命令行。當(dāng)我們獲取到輸入時(shí),我們把命令發(fā)給我們寫的小程序 handle_command,然后我們把這個(gè)命令添加到 linenoise 歷史并釋放資源。
處理輸入
我們的命令類似 gdb 以及 lldb 的格式。要繼續(xù)執(zhí)行程序,用戶需要輸入 continue 或 cont 甚至只需 c。如果他們想在一個(gè)地址中設(shè)置斷點(diǎn),他們會(huì)輸入 break 0xDEADBEEF,其中 0xDEADBEEF 就是所需地址的 16 進(jìn)制格式。讓我們來增加對(duì)這些命令的支持吧。
- 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 command\n";
- }
- }
split 和 is_prefix 是一對(duì)有用的小程序:
- 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());
- }
我們會(huì)把 continue_execution 函數(shù)添加到 debuger 類。
- void debugger::continue_execution() {
- ptrace(PTRACE_CONT, m_pid, nullptr, nullptr);
- int wait_status;
- auto options = 0;
- waitpid(m_pid, &wait_status, options);
- }
現(xiàn)在我們的 continue_execution 函數(shù)會(huì)用 ptrace 告訴進(jìn)程繼續(xù)執(zhí)行,然后用 waitpid 等待直到收到信號(hào)。
總結(jié)
現(xiàn)在你應(yīng)該編譯一些 C 或者 C++ 程序,然后用你的調(diào)試器運(yùn)行它們,看它是否能在函數(shù)入口暫停、從調(diào)試器中繼續(xù)執(zhí)行。在下一篇文章中,我們會(huì)學(xué)習(xí)如何讓我們的調(diào)試器設(shè)置斷點(diǎn)。如果你遇到了任何問題,在下面的評(píng)論框中告訴我吧!
你可以在這里找到該項(xiàng)目的代碼。