開發一個 Linux 調試器(五):源碼和信號
在上一部分我們學習了關於 DWARF 的信息,以及它如何被用於讀取變數和將被執行的機器碼與我們的高級語言的源碼聯繫起來。在這一部分,我們將進入實踐,實現一些我們調試器後面會使用的 DWARF 原語。我們也會利用這個機會,使我們的調試器可以在命中一個斷點時列印出當前的源碼上下文。
系列文章索引
隨著後面文章的發布,這些鏈接會逐漸生效。
設置我們的 DWARF 解析器
正如我在這系列文章開始時備註的,我們會使用 libelfin 來處理我們的 DWARF 信息。希望你已經在第一部分設置好了這些,如果沒有的話,現在做吧,確保你使用我倉庫的 fbreg
分支。
一旦你構建好了 libelfin
,就可以把它添加到我們的調試器。第一步是解析我們的 ELF 可執行程序並從中提取 DWARF 信息。使用 libelfin
可以輕易實現,只需要對調試器
作以下更改:
class debugger {
public:
debugger (std::string prog_name, pid_t pid)
: m_prog_name{std::move(prog_name)}, m_pid{pid} {
auto fd = open(m_prog_name.c_str(), O_RDONLY);
m_elf = elf::elf{elf::create_mmap_loader(fd)};
m_dwarf = dwarf::dwarf{dwarf::elf::create_loader(m_elf)};
}
//...
private:
//...
dwarf::dwarf m_dwarf;
elf::elf m_elf;
};
我們使用了 open
而不是 std::ifstream
,因為 elf 載入器需要傳遞一個 UNIX 文件描述符給 mmap
,從而可以將文件映射到內存而不是每次讀取一部分。
調試信息原語
下一步我們可以實現從程序計數器的值中提取行條目(line entry)以及函數 DWARF 信息條目(function DIE)的函數。我們從 get_function_from_pc
開始:
dwarf::die debugger::get_function_from_pc(uint64_t pc) {
for (auto &cu : m_dwarf.compilation_units()) {
if (die_pc_range(cu.root()).contains(pc)) {
for (const auto& die : cu.root()) {
if (die.tag == dwarf::DW_TAG::subprogram) {
if (die_pc_range(die).contains(pc)) {
return die;
}
}
}
}
}
throw std::out_of_range{"Cannot find function"};
}
這裡我採用了樸素的方法,迭代遍歷編譯單元直到找到一個包含程序計數器的,然後迭代遍歷它的子節點直到我們找到相關函數(DW_TAG_subprogram
)。正如我在上一篇中提到的,如果你想要的話你可以處理類似的成員函數或者內聯等情況。
接下來是 get_line_entry_from_pc
:
dwarf::line_table::iterator debugger::get_line_entry_from_pc(uint64_t pc) {
for (auto &cu : m_dwarf.compilation_units()) {
if (die_pc_range(cu.root()).contains(pc)) {
auto < = cu.get_line_table();
auto it = lt.find_address(pc);
if (it == lt.end()) {
throw std::out_of_range{"Cannot find line entry"};
}
else {
return it;
}
}
}
throw std::out_of_range{"Cannot find line entry"};
}
同樣,我們可以簡單地找到正確的編譯單元,然後查詢行表獲取相關的條目。
列印源碼
當我們命中一個斷點或者逐步執行我們的代碼時,我們會想知道處於源碼中的什麼位置。
void debugger::print_source(const std::string& file_name, unsigned line, unsigned n_lines_context) {
std::ifstream file {file_name};
//獲得一個所需行附近的窗口
auto start_line = line <= n_lines_context ? 1 : line - n_lines_context;
auto end_line = line + n_lines_context + (line < n_lines_context ? n_lines_context - line : 0) + 1;
char c{};
auto current_line = 1u;
//跳過 start_line 之前的行
while (current_line != start_line && file.get(c)) {
if (c == 'n') {
++current_line;
}
}
//如果我們在當前行則輸出遊標
std::cout << (current_line==line ? "> " : " ");
//輸出行直到 end_line
while (current_line <= end_line && file.get(c)) {
std::cout << c;
if (c == 'n') {
++current_line;
//如果我們在當前行則輸出遊標
std::cout << (current_line==line ? "> " : " ");
}
}
//輸出換行確保恰當地清空了流
std::cout << std::endl;
}
現在我們可以列印出源碼了,我們需要將這些通過鉤子添加到我們的調試器。實現這個的一個好地方是當調試器從一個斷點或者(最終)逐步執行得到一個信號時。到了這裡,我們可能想要給我們的調試器添加一些更好的信號處理。
更好的信號處理
我們希望能夠得知什麼信號被發送給了進程,同樣我們也想知道它是如何產生的。例如,我們希望能夠得知是否由於命中了一個斷點從而獲得一個 SIGTRAP
,還是由於逐步執行完成、或者是產生了一個新線程等等導致的。幸運的是,我們可以再一次使用 ptrace
。可以給 ptrace
的一個命令是 PTRACE_GETSIGINFO
,它會給你被發送給進程的最後一個信號的信息。我們類似這樣使用它:
siginfo_t debugger::get_signal_info() {
siginfo_t info;
ptrace(PTRACE_GETSIGINFO, m_pid, nullptr, &info);
return info;
}
這會給我們一個 siginfo_t
對象,它能提供以下信息:
siginfo_t {
int si_signo; /* 信號編號 */
int si_errno; /* errno 值 */
int si_code; /* 信號代碼 */
int si_trapno; /* 導致生成硬體信號的陷阱編號
(大部分架構中都沒有使用) */
pid_t si_pid; /* 發送信號的進程 ID */
uid_t si_uid; /* 發送信號進程的用戶 ID */
int si_status; /* 退出值或信號 */
clock_t si_utime; /* 消耗的用戶時間 */
clock_t si_stime; /* 消耗的系統時間 */
sigval_t si_value; /* 信號值 */
int si_int; /* POSIX.1b 信號 */
void *si_ptr; /* POSIX.1b 信號 */
int si_overrun; /* 計時器 overrun 計數;
POSIX.1b 計時器 */
int si_timerid; /* 計時器 ID; POSIX.1b 計時器 */
void *si_addr; /* 導致錯誤的內存地址 */
long si_band; /* Band event (在 glibc 2.3.2 和之前版本中是 int 類型) */
int si_fd; /* 文件描述符 */
short si_addr_lsb; /* 地址的最不重要位
(自 Linux 2.6.32) */
void *si_lower; /* 出現地址違規的下限 (自 Linux 3.19) */
void *si_upper; /* 出現地址違規的上限 (自 Linux 3.19) */
int si_pkey; /* PTE 上導致錯誤的保護鍵 (自 Linux 4.6) */
void *si_call_addr; /* 系統調用指令的地址
(自 Linux 3.5) */
int si_syscall; /* 系統調用嘗試次數
(自 Linux 3.5) */
unsigned int si_arch; /* 嘗試系統調用的架構
(自 Linux 3.5) */
}
我只需要使用 si_signo
就可以找到被發送的信號,使用 si_code
來獲取更多關於信號的信息。放置這些代碼的最好位置是我們的 wait_for_signal
函數:
void debugger::wait_for_signal() {
int wait_status;
auto options = 0;
waitpid(m_pid, &wait_status, options);
auto siginfo = get_signal_info();
switch (siginfo.si_signo) {
case SIGTRAP:
handle_sigtrap(siginfo);
break;
case SIGSEGV:
std::cout << "Yay, segfault. Reason: " << siginfo.si_code << std::endl;
break;
default:
std::cout << "Got signal " << strsignal(siginfo.si_signo) << std::endl;
}
}
現在再來處理 SIGTRAP
。知道當命中一個斷點時會發送 SI_KERNEL
或 TRAP_BRKPT
,而逐步執行結束時會發送 TRAP_TRACE
就足夠了:
void debugger::handle_sigtrap(siginfo_t info) {
switch (info.si_code) {
//如果命中了一個斷點其中的一個會被設置
case SI_KERNEL:
case TRAP_BRKPT:
{
set_pc(get_pc()-1); //將程序計數器的值設置為它應該指向的地方
std::cout << "Hit breakpoint at address 0x" << std::hex << get_pc() << std::endl;
auto line_entry = get_line_entry_from_pc(get_pc());
print_source(line_entry->file->path, line_entry->line);
return;
}
//如果信號是由逐步執行發送的,這會被設置
case TRAP_TRACE:
return;
default:
std::cout << "Unknown SIGTRAP code " << info.si_code << std::endl;
return;
}
}
這裡有一大堆不同風格的信號你可以處理。查看 man sigaction
獲取更多信息。
由於當我們收到 SIGTRAP
信號時我們已經修正了程序計數器的值,我們可以從 step_over_breakpoint
中移除這些代碼,現在它看起來類似:
void debugger::step_over_breakpoint() {
if (m_breakpoints.count(get_pc())) {
auto& bp = m_breakpoints[get_pc()];
if (bp.is_enabled()) {
bp.disable();
ptrace(PTRACE_SINGLESTEP, m_pid, nullptr, nullptr);
wait_for_signal();
bp.enable();
}
}
}
測試
現在你應該可以在某個地址設置斷點,運行程序然後看到列印出了源碼,而且正在被執行的行被游標標記了出來。
後面我們會添加設置源碼級別斷點的功能。同時,你可以從這裡獲取該博文的代碼。
via: https://blog.tartanllama.xyz/c++/2017/04/24/writing-a-linux-debugger-source-signal/
作者:TartanLlama 譯者:ictlyh 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive