前言:在程式出現bug的時候,最好的解決辦法就是通過 GDB 調試程式,然後找到程式出現問題的地方。比如程式出現 段錯誤(記憶體位址不合法)時,就可以通過 GDB 找到程式哪裡通路了不合法的記憶體位址而導緻的。
本文不是介紹 GDB 的使用方式,而是大概介紹 GDB 的實作原理,當然 GDB 是一個龐大而複雜的項目,不可能隻通過一篇文章就能解釋清楚,是以本文主要是介紹 GDB 使用的核心的技術 - ptrace。
一、ptrace系統調用
ptrace() 系統調用是Linux提供的一個調試程序的工具,ptrace() 系統調用非常強大,它提供非常多的調試方式讓我們去調試某一個程序,下面是 ptrace() 系統調用的定義:
long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data);
下面解釋一下 ptrace() 各個參數的作用:
- request:指定調試的指令,指令的類型很多,如:PTRACE_TRACEME、PTRACE_PEEKUSER、PTRACE_CONT、PTRACE_GETREGS等等,下面會介紹不同指令的作用。
- pid:程序的ID(這個不用解釋了)。
- addr:程序的某個位址空間,可以通過這個參數對程序的某個位址進行讀或寫操作。
- data:根據不同的指令,有不同的用途,下面會介紹。
要自己動手寫 strace 的第一步就是了解 ptrace() 系統調用的使用,我們來看看 ptrace() 系統調用的定義:
int ptrace(long request, long pid, long addr, long data);
ptrace() 系統調用用于跟蹤程序的運作情況,下面介紹一下其各個參數的含義:
- request:指定跟蹤的動作。也就是說,通過傳入不同的 request 參數可以對程序進行不同的跟蹤操作。其可選值有:
- PTRACE_TRACEME
- PTRACE_PEEKTEXT
- PTRACE_POKETEXT
- PTRACE_CONT
- PTRACE_SINGLESTEP
- pid:指定要跟蹤的程序PID。
- addr:指定要讀取或者修改的記憶體位址。
- data:對于不同的 request 操作,data 有不同的作用,下面會介紹。
前面介紹過,使用 strace 跟蹤程序有兩種方式,一種是通過 strace 指令啟動程序,另外一種是通過 -p 指定要跟蹤的程序。
ptrace() 系統調用也提供了兩種 request 來實作上面兩種方式:
- 第一種通過 PTRACE_TRACEME 來實作
- 第二種通過 PTRACE_ATTACH 來實作
本文我們主要介紹使用第一種方式。由于第一種方式使用跟蹤程式來啟動被跟蹤的程式,是以需要啟動兩個程序。通常要建立新程序可以使用 fork() 系統調用,是以自然而然地我們也使用 fork() 系統調用。
我們建立一個檔案 strace.c,輸入代碼如下:
int main(int argc, char *argv[])
{
pid_t child;
child = fork();
if (child == 0) {
// 子程序...
} else {
// 父程序...
}
return 0;
}
上面的代碼通過調用 fork() 來建立一個子程序,但是沒有做任何事情。之後,我們就會在 子程序 中運作被跟蹤的程式,而在 父程序 中運作跟蹤程序的代碼。
運作被跟蹤程式
前面說過,被跟蹤的程式需要在子程序中運作,而要運作一個程式,可以通過調用 execl() 系統調用。是以可以通過下面的代碼,在子程序中運作 ls 指令:
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
pid_t child;
child = fork();
if (child == 0) {
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
// 父程序...
}
return 0;
}
execl() 用于執行指定的程式,如果執行成功就不會傳回,是以 execl(...) 的下一行代碼 exit(0) 不會被執行到。
由于我們需要跟蹤 ls 指令,是以在執行 ls 指令前,必須調用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 來告訴系統需要跟蹤這個程序,代碼如下:
#include <sys/ptrace.h>
#include <unistd.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
pid_t child;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
// 父程序...
}
return 0;
}
這樣,被跟蹤程序部分的代碼就完成了,接下來開始編寫跟蹤程序部分代碼。
編寫跟蹤程序代碼
如果編譯運作上面的代碼,會發現什麼效果也沒有。這是因為當在子程序調用 ptrace(PTRACE_TRACEME, 0, NULL, NULL) 後,并且調用 execl() 系統調用,那麼子程序會發送一個 SIGCHLD 信号給父程序(跟蹤程序)并且自己停止運作,直到父程序發送調試指令,才會繼續運作。
由于上面的代碼中,父程序(跟蹤程序)并沒有發送任何調試指令就退出運作,是以子程序(被跟蹤程序)在沒有運作的情況下就跟着父程序一起退出了,那麼就不會看到任何效果。
現在我們開始編寫跟蹤程序的代碼。
由于被跟蹤程序會發送一個 SIGCHLD 資訊給跟蹤程序,是以我們先要在跟蹤程序的代碼中接收 SIGCHLD 信号,接收信号通過使用 wait() 系統調用完成,代碼如下:
#include <sys/ptrace.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
pid_t child;
int status;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
}
return 0;
}
上面的代碼通過調用 wait() 系統調用來接收被跟蹤程序發送過來的 SIGCHLD 信号,接下來需要開始向被跟蹤程序發送調試指令,來對被跟蹤程序進行調試。
由于本文介紹怎麼跟蹤程序調用了哪些 系統調用,是以我們需要使用 ptrace() 的 PTRACE_SYSCALL 指令,代碼如下:
#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
pid_t child;
int status;
struct user_regs_struct regs;
int orig_rax;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
// 1. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用前,可以擷取系統調用的參數)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
// 2. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用後,可以擷取系統調用的傳回值)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
}
return 0;
}
從上面的代碼可以發現,我們調用了兩次 ptrace(PTRACE_SYSCALL, child, NULL, NULL),這是因為跟蹤系統調用時,需要跟蹤系統調用前的環境(比如擷取系統調用的參數)和系統調用後的環境(比如擷取系統調用的傳回值),是以就需要調用兩次 ptrace(PTRACE_SYSCALL, child, NULL, NULL)
背景私信【核心】免費領取
擷取程序寄存器的值
Linux系統調用是通過 CPU寄存器 來傳遞參數的,是以要想擷取調用了哪個系統調用,必須擷取程序寄存器的值。擷取程序寄存器的值,可以通過 ptrace() 系統調用的 PTRACE_GETREGS 指令來實作,代碼如下:
#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
pid_t child;
int status;
struct user_regs_struct regs;
int orig_rax;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
// 1. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用前,可以擷取系統調用的參數)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
ptrace(PTRACE_GETREGS, child, 0, ®s); // 擷取被跟蹤程序寄存器的值
orig_rax = regs.orig_rax; // 擷取rax寄存器的值
printf("orig_rax: %d\n", orig_rax); // 列印rax寄存器的值
// 2. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用後,可以擷取系統調用的傳回值)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
}
return 0;
}
上面的代碼通過調用 ptrace(PTRACE_GETREGS, child, 0, ®s) 來擷取程序寄存器的值,PTRACE_GETREGS 指令需要在 data 參數傳入類型為 user_regs_struct 結構的指針,user_regs_struct 結構定義如下(在檔案 sys/user.h 中):
struct user_regs_struct {
unsigned long r15,r14,r13,r12,rbp,rbx,r11,r10;
unsigned long r9,r8,rax,rcx,rdx,rsi,rdi,orig_rax;
unsigned long rip,cs,eflags;
unsigned long rsp,ss;
unsigned long fs_base, gs_base;
unsigned long ds,es,fs,gs;
};
其中 user_regs_struct 結構的 orig_rax 儲存了系統調用号,是以我們可以通過 orig_rax 的值來知道調用了哪個系統調用。
編譯運作上面的代碼,會輸出結果:orig_rax: 12,就是說目前調用的是編号為 12 的系統調用。那麼編号為 12 的系統調用是哪個系統調用呢?
通過查閱系統調用表,可以知道編号 12 的系統調用為 brk(),如下:
系統調用号 函數名 入口點 源碼
...
12 brk sys_brk mm/mmap.c
...
上面的程式隻跟蹤了一個系統調用,那麼怎麼跟蹤所有的系統調用呢?很簡單,隻需要把跟蹤的代碼放到一個無限循環中即可。代碼如下:
#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
int main(int argc, char *argv[])
{
pid_t child;
int status;
struct user_regs_struct regs;
int orig_rax;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
while (1) {
// 1. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用前,可以擷取系統調用的參數)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if (WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
ptrace(PTRACE_GETREGS, child, 0, ®s); // 擷取被跟蹤程序寄存器的值
orig_rax = regs.orig_rax; // 擷取rax寄存器的值
printf("orig_rax: %d\n", orig_rax); // 列印rax寄存器的值
// 2. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用後,可以擷取系統調用的傳回值)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if (WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
}
}
return 0;
}
從執行結果來看,隻是列印系統調用号不太直覺,那麼我們怎麼優化呢?
我們可以定義一個系統調用号與系統調用名的對應表來實作更清晰的輸出結果,如下:
#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
struct syscall {
int code;
char *name;
} syscall_table[] = {
{0, "read"},
{1, "write"},
{2, "open"},
{3, "close"},
{4, "stat"},
{5, "fstat"},
{6, "lstat"},
{7, "poll"},
{8, "lseek"},
...
{-1, NULL},
}
char *find_syscall_symbol(int code) {
struct syscall *sc;
for (sc = syscall_table; sc->code >= 0; sc++) {
if (sc->code == code) {
return sc->name;
}
}
return NULL;
}
int main(int argc, char *argv[])
{
pid_t child;
int status;
struct user_regs_struct regs;
int orig_rax;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
while (1) {
// 1. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用前,可以擷取系統調用的參數)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if(WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
ptrace(PTRACE_GETREGS, child, 0, ®s); // 擷取被跟蹤程序寄存器的值
orig_rax = regs.orig_rax; // 擷取rax寄存器的值
printf("syscall: %s()\n", find_syscall_symbol(orig_rax)); // 列印系統調用
// 2. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用後,可以擷取系統調用的傳回值)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if(WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
}
}
return 0;
}
上面例子添加了一個函數 find_syscall_symbol() 來擷取系統調用号對應的系統調用名,實作也比較簡單。編譯運作後輸出結果如下:
[root@localhost liexusong]$ ./strace
syscall: brk()
syscall: mmap()
syscall: access()
syscall: open()
syscall: fstat()
syscall: mmap()
syscall: close()
syscall: open()
syscall: read()
syscall: fstat()
syscall: mmap()
syscall: mprotect()
syscall: mmap()
syscall: mmap()
syscall: close()
...
從執行結果來看,現在可以列印系統調用的名字了,但我們知道 strace 指令還會列印系統調用參數的值,我們可以通過 ptrace() 系統調用的 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 來擷取參數的值,是以有興趣的就自己實作這個效果了。
#include <sys/ptrace.h>
#include <sys/user.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
struct syscall {
int code;
char *name;
} syscall_table[] = {
{0, "read"},
{1, "write"},
{2, "open"},
{3, "close"},
{4, "stat"},
{5, "fstat"},
{6, "lstat"},
{7, "poll"},
{8, "lseek"},
{9, "mmap"},
{10, "mprotect"},
{11, "munmap"},
{12, "brk"},
{13, "rt_sigaction"},
{14, "rt_sigprocmask"},
{15, "rt_sigreturn"},
{16, "ioctl"},
{17, "pread64"},
{18, "pwrite64"},
{19, "readv"},
{20, "writev"},
{21, "access"},
{22, "pipe"},
{23, "select"},
{24, "sched_yield"},
{25, "mremap"},
{26, "msync"},
{27, "mincore"},
{28, "madvise"},
{29, "shmget"},
{30, "shmat"},
{31, "shmctl"},
{32, "dup"},
{33, "dup2"},
{34, "pause"},
{35, "nanosleep"},
{36, "getitimer"},
{37, "alarm"},
{38, "setitimer"},
{39, "getpid"},
{40, "sendfile"},
{41, "socket"},
{42, "connect"},
{43, "accept"},
{44, "sendto"},
{45, "recvfrom"},
{46, "sendmsg"},
{47, "recvmsg"},
{48, "shutdown"},
{49, "bind"},
{50, "listen"},
{51, "getsockname"},
{52, "getpeername"},
{53, "socketpair"},
{54, "setsockopt"},
{55, "getsockopt"},
{56, "clone"},
{57, "fork"},
{58, "vfork"},
{59, "execve"},
{60, "exit"},
{61, "wait4"},
{62, "kill"},
{63, "uname"},
{64, "semget"},
{65, "semop"},
{66, "semctl"},
{67, "shmdt"},
{68, "msgget"},
{69, "msgsnd"},
{70, "msgrcv"},
{71, "msgctl"},
{72, "fcntl"},
{73, "flock"},
{74, "fsync"},
{75, "fdatasync"},
{76, "truncate"},
{77, "ftruncate"},
{78, "getdents"},
{79, "getcwd"},
{80, "chdir"},
{81, "fchdir"},
{82, "rename"},
{83, "mkdir"},
{84, "rmdir"},
{85, "creat"},
{86, "link"},
{87, "unlink"},
{88, "symlink"},
{89, "readlink"},
{90, "chmod"},
{91, "fchmod"},
{92, "chown"},
{93, "fchown"},
{94, "lchown"},
{95, "umask"},
{96, "gettimeofday"},
{97, "getrlimit"},
{98, "getrusage"},
{99, "sysinfo"},
{100, "times"},
{101, "ptrace"},
{102, "getuid"},
{103, "syslog"},
{104, "getgid"},
{105, "setuid"},
{106, "setgid"},
{107, "geteuid"},
{108, "getegid"},
{109, "setpgid"},
{110, "getppid"},
{111, "getpgrp"},
{112, "setsid"},
{113, "setreuid"},
{114, "setregid"},
{115, "getgroups"},
{116, "setgroups"},
{117, "setresuid"},
{118, "getresuid"},
{119, "setresgid"},
{120, "getresgid"},
{121, "getpgid"},
{122, "setfsuid"},
{123, "setfsgid"},
{124, "getsid"},
{125, "capget"},
{126, "capset"},
{127, "rt_sigpending"},
{128, "rt_sigtimedwait"},
{129, "rt_sigqueueinfo"},
{130, "rt_sigsuspend"},
{131, "sigaltstack"},
{132, "utime"},
{133, "mknod"},
{134, "uselib"},
{135, "personality"},
{136, "ustat"},
{137, "statfs"},
{138, "fstatfs"},
{139, "sysfs"},
{140, "getpriority"},
{141, "setpriority"},
{142, "sched_setparam"},
{143, "sched_getparam"},
{144, "sched_setscheduler"},
{145, "sched_getscheduler"},
{146, "sched_get_priority_max"},
{147, "sched_get_priority_min"},
{148, "sched_rr_get_interval"},
{149, "mlock"},
{150, "munlock"},
{151, "mlockall"},
{152, "munlockall"},
{153, "vhangup"},
{154, "modify_ldt"},
{155, "pivot_root"},
{156, "_sysctl"},
{157, "prctl"},
{158, "arch_prctl"},
{159, "adjtimex"},
{160, "setrlimit"},
{161, "chroot"},
{162, "sync"},
{163, "acct"},
{164, "settimeofday"},
{165, "mount"},
{166, "umount2"},
{167, "swapon"},
{168, "swapoff"},
{169, "reboot"},
{170, "sethostname"},
{171, "setdomainname"},
{172, "iopl"},
{173, "ioperm"},
{174, "create_module"},
{175, "init_module"},
{176, "delete_module"},
{177, "get_kernel_syms"},
{178, "query_module"},
{179, "quotactl"},
{180, "nfsservctl"},
{181, "getpmsg"},
{182, "putpmsg"},
{183, "afs_syscall"},
{184, "tuxcall"},
{185, "security"},
{186, "gettid"},
{187, "readahead"},
{188, "setxattr"},
{189, "lsetxattr"},
{190, "fsetxattr"},
{191, "getxattr"},
{192, "lgetxattr"},
{193, "fgetxattr"},
{194, "listxattr"},
{195, "llistxattr"},
{196, "flistxattr"},
{197, "removexattr"},
{198, "lremovexattr"},
{199, "fremovexattr"},
{200, "tkill"},
{201, "time"},
{202, "futex"},
{203, "sched_setaffinity"},
{204, "sched_getaffinity"},
{205, "set_thread_area"},
{206, "io_setup"},
{207, "io_destroy"},
{208, "io_getevents"},
{209, "io_submit"},
{210, "io_cancel"},
{211, "get_thread_area"},
{212, "lookup_dcookie"},
{213, "epoll_create"},
{214, "epoll_ctl_old"},
{215, "epoll_wait_old"},
{216, "remap_file_pages"},
{217, "getdents64"},
{218, "set_tid_address"},
{219, "restart_syscall"},
{220, "semtimedop"},
{221, "fadvise64"},
{222, "timer_create"},
{223, "timer_settime"},
{224, "timer_gettime"},
{225, "timer_getoverrun"},
{226, "timer_delete"},
{227, "clock_settime"},
{228, "clock_gettime"},
{229, "clock_getres"},
{230, "clock_nanosleep"},
{231, "exit_group"},
{232, "epoll_wait"},
{233, "epoll_ctl"},
{234, "tgkill"},
{235, "utimes"},
{236, "vserver"},
{237, "mbind"},
{238, "set_mempolicy"},
{239, "get_mempolicy"},
{240, "mq_open"},
{241, "mq_unlink"},
{242, "mq_timedsend"},
{243, "mq_timedreceive"},
{244, "mq_notify"},
{245, "mq_getsetattr"},
{246, "kexec_load"},
{247, "waitid"},
{248, "add_key"},
{249, "request_key"},
{250, "keyctl"},
{251, "ioprio_set"},
{252, "ioprio_get"},
{253, "inotify_init"},
{254, "inotify_add_watch"},
{255, "inotify_rm_watch"},
{256, "migrate_pages"},
{257, "openat"},
{258, "mkdirat"},
{259, "mknodat"},
{260, "fchownat"},
{261, "futimesat"},
{262, "newfstatat"},
{263, "unlinkat"},
{264, "renameat"},
{265, "linkat"},
{266, "symlinkat"},
{267, "readlinkat"},
{268, "fchmodat"},
{269, "faccessat"},
{270, "pselect6"},
{271, "ppoll"},
{272, "unshare"},
{273, "set_robust_list"},
{274, "get_robust_list"},
{275, "splice"},
{276, "tee"},
{277, "sync_file_range"},
{278, "vmsplice"},
{279, "move_pages"},
{280, "utimensat"},
{281, "epoll_pwait"},
{282, "signalfd"},
{283, "timerfd_create"},
{284, "eventfd"},
{285, "fallocate"},
{286, "timerfd_settime"},
{287, "timerfd_gettime"},
{288, "accept4"},
{289, "signalfd4"},
{290, "eventfd2"},
{291, "epoll_create1"},
{292, "dup3"},
{293, "pipe2"},
{294, "inotify_init1"},
{295, "preadv"},
{296, "pwritev"},
{297, "rt_tgsigqueueinfo"},
{298, "perf_event_open"},
{299, "recvmmsg"},
{300, "fanotify_init"},
{301, "fanotify_mark"},
{302, "prlimit64"},
{303, "name_to_handle_at"},
{304, "open_by_handle_at"},
{305, "clock_adjtime"},
{306, "syncfs"},
{307, "sendmmsg"},
{308, "setns"},
{309, "getcpu"},
{310, "process_vm_readv"},
{311, "process_vm_writev"},
{312, "kcmp"},
{313, "finit_module"},
{314, "sched_setattr"},
{315, "sched_getattr"},
{316, "renameat2"},
{317, "seccomp"},
{318, "getrandom"},
{319, "memfd_create"},
{320, "kexec_file_load"},
{321, "bpf"},
{322, "execveat"},
{323, "userfaultfd"},
{324, "membarrier"},
{325, "mlock2"},
{326, "copy_file_range"},
{327, "preadv2"},
{328, "pwritev2"},
{329, "pkey_mprotect"},
{330, "pkey_alloc"},
{331, "pkey_free"},
{332, "statx"},
{333, "io_pgetevents"},
{334, "rseq"},
{424, "pidfd_send_signal"},
{425, "io_uring_setup"},
{426, "io_uring_enter"},
{427, "io_uring_register"},
{428, "open_tree"},
{429, "move_mount"},
{430, "fsopen"},
{431, "fsconfig"},
{432, "fsmount"},
{433, "fspick"},
{434, "pidfd_open"},
{435, "clone3"},
{436, "close_range"},
{437, "openat2"},
{438, "pidfd_getfd"},
{439, "faccessat2"},
{440, "process_madvise"},
{512, "rt_sigaction"},
{513, "rt_sigreturn"},
{514, "ioctl"},
{515, "readv"},
{516, "writev"},
{517, "recvfrom"},
{518, "sendmsg"},
{519, "recvmsg"},
{520, "execve"},
{521, "ptrace"},
{522, "rt_sigpending"},
{523, "rt_sigtimedwait"},
{524, "rt_sigqueueinfo"},
{525, "sigaltstack"},
{526, "timer_create"},
{527, "mq_notify"},
{528, "kexec_load"},
{529, "waitid"},
{530, "set_robust_list"},
{531, "get_robust_list"},
{532, "vmsplice"},
{533, "move_pages"},
{534, "preadv"},
{535, "pwritev"},
{536, "rt_tgsigqueueinfo"},
{537, "recvmmsg"},
{538, "sendmmsg"},
{539, "process_vm_readv"},
{540, "process_vm_writev"},
{541, "setsockopt"},
{542, "getsockopt"},
{543, "io_setup"},
{544, "io_submit"},
{545, "execveat"},
{546, "preadv2"},
{547, "pwritev2"},
{-1, NULL},
};
char *find_syscall_symbol(int code) {
struct syscall *sc;
for (sc = syscall_table; sc->code >= 0; sc++) {
if (sc->code == code) {
return sc->name;
}
}
return NULL;
}
int main(int argc, char *argv[])
{
pid_t child;
int status;
struct user_regs_struct regs;
int orig_rax;
child = fork();
if (child == 0) {
ptrace(PTRACE_TRACEME, 0, NULL, NULL);
execl("/bin/ls", "/bin/ls", NULL);
exit(0);
} else {
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
while (1) {
// 1. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用前,可以擷取系統調用的參數)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if(WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
ptrace(PTRACE_GETREGS, child, 0, ®s); // 擷取被跟蹤程序寄存器的值
orig_rax = regs.orig_rax; // 擷取rax寄存器的值
printf("syscall: %s()\n", find_syscall_symbol(orig_rax)); // 列印rax寄存器的值
// 2. 發送 PTRACE_SYSCALL 指令給被跟蹤程序 (調用系統調用後,可以擷取系統調用的傳回值)
ptrace(PTRACE_SYSCALL, child, NULL, NULL);
wait(&status); // 接收被子程序發送過來的 SIGCHLD 信号
if(WIFEXITED(status)) { // 如果子程序退出了, 那麼終止跟蹤
break;
}
}
}
return 0;
}
二、ptrace使用示例
下面通過一個簡單例子來說明 ptrace() 系統調用的使用,這個例子主要介紹怎麼使用 ptrace() 系統調用擷取目前被調試(追蹤)程序的各個寄存器的值,代碼如下(ptrace.c):
#include <sys/ptrace.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <sys/user.h>
#include <stdio.h>
int main()
{ pid_t child;
struct user_regs_struct regs;
child = fork(); // 建立一個子程序
if(child == 0) { // 子程序
ptrace(PTRACE_TRACEME, 0, NULL, NULL); // 表示目前程序進入被追蹤狀态
execl("/bin/ls", "ls", NULL); // 執行 `/bin/ls` 程式
}
else { // 父程序
wait(NULL); // 等待子程序發送一個 SIGCHLD 信号
ptrace(PTRACE_GETREGS, child, NULL, ®s); // 擷取子程序的各個寄存器的值
printf("Register: rdi[%ld], rsi[%ld], rdx[%ld], rax[%ld], orig_rax[%ld]\n",
regs.rdi, regs.rsi, regs.rdx,regs.rax, regs.orig_rax); // 列印寄存器的值
ptrace(PTRACE_CONT, child, NULL, NULL); // 繼續運作子程序
sleep(1);
}
return 0;
}
通過指令 gcc ptrace.c -o ptrace 編譯并運作上面的程式會輸出如下結果:
Register: rdi[0], rsi[0], rdx[0], rax[0], orig_rax[59]
ptrace ptrace.c
上面結果的第一行是由父程序輸出的,主要是列印了子程序執行 /bin/ls 程式後各個寄存器的值。而第二行是由子程序輸出的,主要是列印了執行 /bin/ls 程式後輸出的結果。
下面解釋一下上面程式的執行流程:
- 主程序調用 fork() 系統調用建立一個子程序。
- 子程序調用 ptrace(PTRACE_TRACEME,...) 把自己設定為被追蹤狀态,并且調用 execl() 執行 /bin/ls 程式。
- 被設定為追蹤(TRACE)狀态的子程序執行 execl() 的程式後,會向父程序發送 SIGCHLD 信号,并且暫停自身的執行。
- 父程序通過調用 wait() 接收子程序發送過來的信号,并且開始追蹤子程序。
- 父程序通過調用 ptrace(PTRACE_GETREGS, child, ...) 來擷取到子程序各個寄存器的值,并且列印寄存器的值。
- 父程序通過調用 ptrace(PTRACE_CONT, child, ...) 讓子程序繼續執行下去。
從上面的例子可以知道,通過向 ptrace() 函數的 request 參數傳入不同的值時,就有不同的效果。比如傳入 PTRACE_TRACEME 就可以讓程序進入被追蹤狀态,而傳入 PTRACE_GETREGS 時,就可以擷取被追蹤的子程序各個寄存器的值等。
三、調試工具
3.1基礎知識
我将介紹 Linux 上調試器實作的主要建構塊 -ptrace系統調用。本文中的所有代碼都是在32位Ubuntu機器上開發的。請注意,該代碼在很大程度上是特定于平台的,盡管将其移植到其他平台應該不會太困難。
動機
要了解我們要做什麼,請嘗試想象調試器需要什麼才能完成其工作。調試器可以啟動某個程序并對其進行調試,或者将其自身附加到現有程序。它可以單步執行代碼、設定斷點并運作它們、檢查變量值和堆棧跟蹤。許多調試器具有進階功能,例如在調試程序的位址空間中執行表達式和調用函數,甚至動态更改程序的代碼并觀察效果。
盡管現代調試器是複雜的野獸,但令人驚訝的是它們的建構基礎卻如此簡單。調試器一開始隻提供作業系統和編譯器/連結器提供的一些基本服務,其餘的隻是簡單的程式設計問題。
Linux調試——ptrace
Linux 調試器的瑞士軍刀是ptrace系統調用。它是一種多功能且相當複雜的工具,允許一個程序控制另一個程序的執行并窺探其内部結構。ptrace需要一本中等大小的書才能完整解釋,這就是為什麼我隻在示例中重點介紹它的一些實際用途。
單步執行流程的代碼
我現在将開發一個在“跟蹤”模式下運作程序的示例,其中我們将單步執行其代碼 - 由 CPU 執行的機器代碼(彙編指令)。我将分部分展示示例代碼,逐一進行解釋,在文章末尾,您将找到一個下載下傳完整 C 檔案的連結,您可以編譯、執行和使用該檔案。進階計劃是編寫代碼,将其分為一個執行使用者提供的指令的子程序和一個跟蹤子程序的父程序。
主要功能:
int main ( int argc, char ** argv)
{
pid_t 子程序pid;
if (argc < 2 ) {
fprintf(stderr, "需要一個程式名稱作為參數\n" );
傳回- 1;
}
child_pid = fork();
如果(child_pid == 0)
run_target(argv[ 1 ]);
否則 如果(child_pid > 0 )
run_debugger(child_pid);
否則{
perror( “分叉” );
傳回- 1;
}
傳回 0;
}
非常簡單:我們使用fork 啟動一個新的子程序。後續條件的if分支運作子程序(此處稱為“目标”),else if分支運作父程序(此處稱為“調試器”)。
這是目标程序:
void run_target ( const char * 程式名)
{
procmsg( "目标已啟動。将運作 '%s'\n" , 程式名);
/* 允許跟蹤該程序 */
if (ptrace(PTRACE_TRACEME, 0 , 0 , 0 ) < 0 ) {
perror( “ptrace” );
傳回;
}
/* 用給定的程式替換該程序的映像 */
execl(programname, programname, 0 );
}
這裡最有趣的一行是ptrace調用。ptrace是這樣聲明的(在sys/ptrace.h中):
long ptrace( enum __ptrace_request 請求, pid_t pid,
void *addr, void *data);
第一個參數是request ,它可能是許多預定義的PTRACE_*常量之一。第二個參數指定某些請求的程序 ID。第三個和第四個參數是位址和資料指針,用于記憶體操作。上面代碼片段中的 ptrace 調用發出PTRACE_TRACEME請求,這意味着該子程序請求作業系統核心讓其父程序跟蹤它。手冊頁中的請求描述非常清楚:
表示該程序将被其父程序跟蹤。傳遞給該程序的任何信号(SIGKILL 除外)都會導緻該程序停止,并通過 wait() 通知其父程序。此外,此程序對 exec() 的所有後續調用都會導緻向其發送 SIGTRAP,進而使父程序有機會在新程式開始執行之前獲得控制權。如果程序的父程序不希望跟蹤它,則它可能不應該發出此請求。 (pid、addr 和 data 被忽略。)
我在這個例子中強調了我們感興趣的部分。請注意, run_target在ptrace之後執行的下一件事是使用execl調用作為參數提供給它的程式。正如突出顯示的部分所解釋的,這會導緻作業系統核心在開始執行execl中的程式并向父程序發送信号之前停止該程序。
是以,時機成熟了,看看父母會做什麼:
無效 run_debugger(pid_t child_pid)
{
int wait_status;
無符号icounter = 0;
procmsg( “調試器已啟動\n” );
/* 等待子程序停止執行第一個指令 */
等待(&等待狀态);
while (WIFSTOPPED(wait_status)) {
icounter++;
/* 讓子程序執行另一條指令 */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) {
perror( “ptrace” );
傳回;
}
/* 等待子程序停止執行下一條指令 */
等待(&等待狀态);
}
procmsg( "子程序執行了 %u 條指令\n" , icounter);
}
想一下上面的内容,一旦子程序開始執行exec調用,它将停止并發送SIGTRAP信号。這裡的父級在第一個等待調用中等待這種情況發生。一旦發生有趣的事情, wait将傳回,并且父程序檢查是否是因為子程序被停止(如果子程序通過傳遞信号而停止,則WIFSTOPPED傳回 true)。
父母接下來要做的事情是本文最有趣的部分。它通過PTRACE_SINGLESTEP請求調用ptrace,并為其提供子程序 ID。它的作用是告訴作業系統 -請重新啟動子程序,但在執行下一條指令後停止它。同樣,父程序等待子程序停止并繼續循環。當wait調用發出的信号不是關于子程序停止時,循環将終止。在跟蹤器正常運作期間,這将是告訴父程序子程序已退出的信号(WIFEXITED将傳回 true)。
請注意,icounter計算子程序執行的指令數量。是以,我們的簡單示例實際上做了一些有用的事情 - 在指令行上給定程式名稱,它會執行該程式并報告從開始運作到結束所需的 CPU 指令量。讓我們看看它的實際效果。
試運作
我編譯了以下簡單程式并在跟蹤器下運作它:
#include <stdio.h>
int 主函數()
{
printf( "你好,世界!\n" );
傳回 0;
}
令我驚訝的是,跟蹤器運作了很長時間,并報告執行了超過 100,000 條指令。對于一個簡單的printf調用?是什麼賦予了?答案很有趣。預設情況下, Linux 上的gcc動态地将程式連結到 C 運作時庫。這意味着,執行任何程式時首先運作的事情之一就是查找所需共享庫的動态庫加載器。這是相當多的代碼 - 請記住,我們的基本跟蹤器會檢視每條指令,不僅僅是主函數,而是整個過程。
是以,當我使用-static标志連結測試程式時(并驗證可執行檔案的重量增加了約 500KB,這對于 C 運作時的靜态連結來說是合乎邏輯的),跟蹤僅報告了 7,000 條左右的指令。這仍然很多,但如果您還記得libc初始化仍然必須在main之前運作,并且清理必須在main之後運作,那就完全有意義了。此外,printf是一個複雜的函數。
仍然不滿意,我想看到一些可測試的東西- 即我可以解釋執行的每條指令的整個運作。當然,這可以通過彙編代碼來完成。是以我拍了這個版本的《你好,世界!》并組裝它:
節.文本
;必須為連結器聲明 _start 符号 (ld)
全局_start
_開始:
;為 sys_write 系統調用準備參數:
; - eax:系統調用号(sys_write)
; - ebx:檔案描述符(标準輸出)
; - ecx:指向字元串的指針
; - edx:字元串長度
mov edx,僅
mov ecx, 消息
移動 ebx, 1
移動 eax, 4
;執行sys_write系統調用
整數0x80
;執行sys_exit
移動eax, 1
整數0x80
.data 節
msg db '你好,世界!',0xa
len equ $ - 味精
果然。現在跟蹤器報告執行了 7 條指令,這是我可以輕松驗證的。
深入指令流
通過彙編編寫的程式,我可以向您介紹ptrace的另一個強大用途- 仔細檢查所跟蹤程序的狀态。這是run_debugger函數的另一個版本:
無效 run_debugger(pid_t child_pid)
{
int wait_status;
無符号icounter = 0;
procmsg( “調試器已啟動\n” );
/* 等待子程序停止執行第一個指令 */
等待(&等待狀态);
while (WIFSTOPPED(wait_status)) {
icounter++;
struct user_regs_struct regs;
ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
無符号指令 = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0 );
procmsg( "icounter = %u.EIP = 0x%08x.instr = 0x%08x\n" ,
icounter、regs.eip、instr);
/* 讓子程序執行另一條指令 */
if (ptrace(PTRACE_SINGLESTEP, child_pid, 0 , 0 ) < 0 ) {
perror( “ptrace” );
傳回;
}
/* 等待子程序停止執行下一條指令 */
等待(&等待狀态);
}
procmsg( "子程序執行了 %u 條指令\n" , icounter);
}
唯一的差別在于while循環的前幾行。有兩個新的ptrace調用。第一個将程序寄存器的值讀入結構中。user_regs_struct在sys/user.h中定義。現在這是有趣的部分 - 如果你檢視這個頭檔案,靠近頂部的評論說:
/* 這個檔案的全部目的是為了 GDB 和 GDB 而已。
不要過度解讀它。除非知道自己在做什麼,否則不要将其用于
GDB 以外的任何用途
。 */
現在,我不了解你的情況,但這讓我覺得我們走在正确的軌道上:-)無論如何,回到這個例子。一旦我們在regs中擁有了所有寄存器,我們就可以通過使用PTRACE_PEEKTEXT調用ptrace來檢視程序的目前指令,并将regs.eip(x86 上的擴充指令指針)作為位址傳遞給它。我們傳回的是指令。讓我們看看這個新的跟蹤器在我們的彙編代碼片段上運作:
$ simple_tracer 追蹤_helloworld
[5700]調試器已啟動
[5701]目标開始了。将運作“traced_helloworld”
[5700] icounter = 1。EIP = 0x08048080。指令 = 0x00000eba
[5700] icounter = 2。EIP = 0x08048085。指令 = 0x0490a0b9
[5700] icounter = 3。EIP = 0x0804808a。指令 = 0x000001bb
[5700] icounter = 4。EIP = 0x0804808f。指令 = 0x000004b8
[5700] icounter = 5。EIP = 0x08048094。指令 = 0x01b880cd
你好世界!
[5700] icounter = 6。EIP = 0x08048096。指令 = 0x000001b8
[5700] icounter = 7。EIP = 0x0804809b。指令 = 0x000080cd
【5700】孩子執行了7條指令
好的,現在除了icounter之外,我們還可以看到指令指針以及它在每一步指向的指令。如何驗證這是否正确?通過在可執行檔案上使用objdump-d :
$ objdump -d traced_helloworld
traced_helloworld:檔案格式 elf32-i386
.text 節的反彙編:
08048080 <.文本>:
8048080: ba 0e 00 00 00 mov $0xe,%edx
8048085:b9 a0 90 04 08 mov $0x80490a0,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094:cd 80 int $0x80
8048096:b8 01 00 00 00 mov $0x1,%eax
804809b:cd 80 int $0x80
這和我們的跟蹤輸出之間的對應關系很容易觀察到。
附加到正在運作的程序
如您所知,調試器還可以附加到已經運作的程序。現在您不會驚訝地發現這也是通過ptrace完成的,它可以擷取PTRACE_ATTACH請求。我不會在這裡展示代碼示例,因為考慮到我們已經完成的代碼,它應該很容易實作。出于教育目的,這裡采用的方法更友善(因為我們可以在子程序開始時停止它)。
代碼
本文中介紹的簡單跟蹤器的完整 C 源代碼(更進階的指令列印版本)可在此處擷取。它可以在gcc 4.4 版本上使用-Wall -pedantic --std=c99進行幹淨地編譯。這部分内容并沒有涉及太多内容——我們距離擁有真正的調試器還很遠。然而,我希望它已經讓調試過程至少不再那麼神秘了。ptrace确實是一個多功能的系統調用,具有多種功能,到目前為止我們隻采樣了其中的一些。
單步執行代碼很有用,但僅限于一定程度。采取 C “你好,世界!”我上面示範的示例。要進入main,可能需要執行數千條 C 運作時初始化代碼指令。這不太友善。理想情況下,我們希望能夠在main 的入口處放置一個斷點,然後從那裡開始執行。很公平,在本系列的下一部分中,我打算展示斷點是如何實作的。
3.2斷點
斷點是調試的兩個主要支柱之一,另一個支柱能夠檢查被調試程序記憶體中的值。我們已經在該系列的第 1 部分中看到了另一個支柱的預覽,但斷點仍然很神秘。到本文結束時,他們将不再是這樣。
軟體中斷
為了在 x86 架構上實作斷點,需要使用軟體中斷(也稱為“陷阱”)。在深入讨論細節之前,我想先解釋一下中斷和陷阱的一般概念。
CPU 具有單個執行流,一條一條地執行指令[1]。為了處理 IO 和硬體定時器等異步事件,CPU 使用中斷。硬體中斷通常是附有特殊“響應電路”的專用電信号。該電路注意到中斷的激活,并使CPU停止目前的執行,儲存其狀态,并跳轉到中斷處理程式例程所在的預定義位址。當處理程式完成其工作時,CPU 從停止處恢複執行。
軟體中斷在原理上類似,但在實踐中有些不同。 CPU 支援允許軟體模拟中斷的特殊指令。當執行這樣的指令時,CPU 将其視為中斷 - 停止其正常執行流程,儲存其狀态并跳轉到處理程式例程。這些“陷阱”使得現代作業系統的許多奇迹(任務排程、虛拟記憶體、記憶體保護、調試)得以有效實作。
一些程式設計錯誤(例如除以 0)也會被 CPU 視為陷阱,并且通常稱為“異常”。這裡硬體和軟體之間的界限變得模糊,因為很難說這種異常是真正的硬體中斷還是軟體中斷。但我已經離主題太遠了,是以是時候回到斷點了。
理論上int 3
寫完上一節後,我現在可以簡單地說,斷點是通過一個名為int 3的特殊陷阱在 CPU 上實作的。int是 x86 術語,意為“陷阱指令”——調用預定義的中斷處理程式。 x86支援int指令,其8位操作數指定發生的中斷編号,是以理論上支援256個陷阱。前 32 個由 CPU 為其自身保留,而第 3 個是我們在這裡感興趣的 - 它稱為“調試器陷阱”。
話不多說,我将引用聖經本身:
INT 3 指令生成一個特殊的單位元組操作碼 (CC),用于調用調試異常處理程式。 (這個單位元組形式很有價值,因為它可以用來用斷點替換任何指令的第一個位元組,包括其他單位元組指令,而無需覆寫其他代碼)。
括号中的部分很重要,但現在解釋還為時過早。我們将在本文後面讨論這個問題。
實踐中的 int 3
是的,了解事物背後的理論固然很好,但這到底意味着什麼呢?我們如何使用int 3來實作斷點呢?或者解釋一下常見的程式設計問答術語 -請告訴我代碼!
實際上,這确實非常簡單。一旦您的程序執行int 3指令,作業系統就會停止它。在 Linux 上(這是我們在本文中關注的内容),它會向程序發送一個信号 - SIGTRAP。
這就是全部内容——誠實!現在回想一下本系列的第一部分,跟蹤(調試器)程序會收到其子程序(或其附加的用于調試的程序)獲得的所有信号的通知,并且您可以開始了解我們要去的地方。
就這樣,不再有計算機體系結構 101 jabber。現在是示例和代碼的時候了。
手動設定斷點
我現在将展示在程式中設定斷點的代碼。我将用于此示範的目标程式如下:
節.文本
;必須為連結器聲明 _start 符号 (ld)
全局_start
_開始:
;為 sys_write 系統調用準備參數:
; - eax:系統調用号(sys_write)
; - ebx:檔案描述符(标準輸出)
; - ecx:指向字元串的指針
; - edx:字元串長度
mov edx,隻有 1
mov ecx, 消息1
移動 ebx, 1
移動 eax, 4
;執行sys_write系統調用
整數0x80
;現在列印另一條消息
移動edx,len2
mov ecx, 消息2
移動 ebx, 1
移動 eax, 4
整數0x80
;執行sys_exit
移動eax, 1
整數0x80
.data 節
msg1 db '你好,',0xa
len1 equ $ - msg1
msg2 db '世界!', 0xa
len2 equ $ - msg2
我現在使用彙編語言,是為了避免我們進入 C 代碼時出現的編譯問題和符号。上面列出的程式所做的隻是在一行上列印“Hello”,然後列印“world!”在下一行。它與上一篇文章中示範的程式非常相似。
我想在第一個列印輸出之後、第二個列印輸出之前設定一個斷點。假設就在mov edx, len2指令上的第一個int 0x80 [4]之後。首先,我們需要知道該指令映射到什麼位址。運作objdump -d:
Traced_printer2:檔案格式 elf32-i386
部分:
Algn 中的 Idx 名稱大小 VMA LMA 檔案
0.文本00000033 08048080 08048080 00000080 2**4
内容、配置設定、加載、隻讀、代碼
1.資料0000000e 080490b4 080490b4 000000b4 2**2
内容、配置設定、加載、資料
.text 節的反彙編:
08048080 <.文本>:
8048080: ba 07 00 00 00 mov $0x7,%edx
8048085:b9 b4 90 04 08 mov $0x80490b4,%ecx
804808a: bb 01 00 00 00 mov $0x1,%ebx
804808f: b8 04 00 00 00 mov $0x4,%eax
8048094:cd 80 int $0x80
8048096: ba 07 00 00 00 mov $0x7,%edx
804809b: b9 bb 90 04 08 mov $0x80490bb,%ecx
80480a0: bb 01 00 00 00 移動 $0x1,%ebx
80480a5: b8 04 00 00 00 mov $0x4,%eax
80480aa:cd 80 int $0x80
80480ac: b8 01 00 00 00 mov $0x1,%eax
80480b1:cd 80 int $0x80
是以,我們要設定斷點的位址是0x8048096。等等,這不是真正的調試器的工作方式,對嗎?真正的調試器在代碼行和函數上設定斷點,而不是在某些裸記憶體位址上設定斷點?非常正确。但我們距離目标還很遠 - 要像真正的調試器一樣設定斷點,我們仍然必須首先介紹符号和調試資訊,并且需要本系列中的另一部分或兩部分來讨論這些主題。現在,我們必須處理裸記憶體位址。
說到這裡我真的很想再跑題了,是以你有兩個選擇。如果您确實有興趣了解為什麼位址是 0x8048096 以及它的含義,請閱讀下一節。如果沒有,并且您隻想繼續處理斷點,則可以安全地跳過它。
使用 int 3 在調試器中設定斷點
要在跟蹤程序中的某個目标位址處設定斷點,調試器将執行以下操作:
- 記住目标位址存儲的資料
- 将目标位址的第一個位元組替換為 int 3 指令
然後,當調試器要求作業系統運作該程序(如我們在上一篇文章中看到的PTRACE_CONT)時,該程序将運作并最終遇到 int 3 ,在那裡它将停止,作業系統将向其發送一個信号。這是調試器再次介入的地方,接收到其子程序(或跟蹤程序)已停止的信号。然後它可以:
- 将目标位址處的int 3指令替換為原指令
- 将跟蹤程序的指令指針復原 1。這是必需的,因為指令指針現在指向int 3之後,并且已經執行了它。
- 允許使用者以某種方式與程序互動,因為程序仍然在所需的目标位址處停止。這是調試器允許您檢視變量值、調用堆棧等的部分。
- 當使用者想要繼續運作時,調試器将負責将斷點放回目标位址(因為它在步驟 1 中被删除),除非使用者要求取消斷點。
讓我們看看其中一些步驟如何轉換為實際代碼。我們将使用第 1 部分中介紹的調試器“模闆”(分叉子程序并跟蹤它)。無論如何,本文末尾有一個指向此示例的完整源代碼的連結。
/* 擷取并顯示子程序的指令指針 */
ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
procmsg( "子程序已啟動。EIP = 0x%08x\n" , regs.eip);
/* 檢視我們感興趣的位址處的字 */
unsigned addr = 0x8048096 ;
無符号資料 = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 );
procmsg( "原始資料位于 0x%08x: 0x%08x\n" , addr, data);
此處,調試器從跟蹤的程序中擷取指令指針,并檢查目前位于 0x8048096 處的字。當運作跟蹤本文開頭列出的彙程式設計式時,将列印:
[13028] 孩子開始了。電子IP = 0x08048080
[13028] 0x08048096處的原始資料:0x000007ba
到目前為止,一切都很好。下一個:
/* 将陷阱指令 'int 3' 寫入位址 */
unsigned data_with_trap = (data & 0xFFFFFF00 ) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data_with_trap);
/* 再看看那裡有什麼... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, ( void *)addr, 0 );
procmsg( "陷阱後,資料位于 0x%08x: 0x%08x\n" , addr, readback_data);
注意int 3是如何插入到目标位址的。這列印:
[13028] 陷阱後,資料位于 0x08048096:0x000007cc
再次,正如預期的那樣 - 0xba被替換為0xcc。調試器現在運作子程序并等待它在斷點處停止:
/* 讓子程序運作到斷點并等待它
** 到達它
*/
ptrace(PTRACE_CONT, child_pid, 0 , 0 );
等待(&等待狀态);
如果(WIFSTOPPED(等待狀态)){
procmsg( "孩子收到一個信号:%s\n" , strsignal(WSTOPSIG(wait_status)));
}
否則{
perror( “等待” );
傳回;
}
/* 檢視子程序現在在哪裡 */
ptrace(PTRACE_GETREGS, child_pid, 0 , ®s);
procmsg( "子程序停在 EIP = 0x%08x\n" , regs.eip);
這列印:
你好,
[13028] 孩子收到信号:跟蹤/斷點陷阱
[13028] 子程序停止在 EIP = 0x08048097
請注意在斷點之前列印的“Hello”——與我們計劃的完全一樣。另請注意子程序停止的位置 - 就在單位元組陷阱指令之後。
最後,正如前面所解釋的,為了讓孩子繼續奔跑,我們必須做一些工作。我們用原始指令替換陷阱,并讓程序繼續從它運作。
/* 通過恢複目标位址處之前的資料來移除斷點
,并将 EIP 回退 1,以
** 讓 CPU 執行那裡的原始指令
。
*/
ptrace(PTRACE_POKETEXT, child_pid, ( void *)addr, ( void *)data);
regs.eip -= 1 ;
ptrace(PTRACE_SETREGS, child_pid, 0 , ®s);
/* 子程序現在可以繼續運作 */
ptrace(PTRACE_CONT, child_pid, 0 , 0 );
這使得子列印出“世界!”并按計劃退出。
請注意,我們在這裡不恢複斷點。這可以通過以單步模式執行原始指令,然後放回陷阱,然後才執行PTRACE_CONT來完成。本文後面示範的調試庫實作了這一點。
有關 int 3 的更多資訊
現在是回來檢查int 3和英特爾手冊中那個奇怪的注釋的好時機。又是這樣:
這種單位元組形式很有價值,因為它可以用來用斷點替換任何指令的第一個位元組,包括其他單位元組指令,而無需覆寫其他代碼
x86 上的int指令占用兩個位元組 - 0xcd後跟中斷号[6]。 int 3可以被編碼為cd 03,但是有一個為其保留的特殊單位元組指令 - 0xcc。
為什麼這樣?因為這允許我們插入斷點而無需覆寫多個指令。這很重要。考慮這個示例代碼:
..一些代碼..
富傑
十進制
富:
呼叫欄
..一些代碼..
假設我們想在dec eax上放置一個斷點。這恰好是一條單位元組指令(操作碼為0x48)。如果替換斷點指令的長度超過 1 個位元組,我們将被迫覆寫下一條指令 ( call ) 的一部分,這會使其出現亂碼,并可能産生完全無效的結果。但是jz foo 的分支是什麼?然後, CPU不會在dec eax處停止,而是直接執行其後的無效指令。
對int 3使用特殊的 1 位元組編碼可以解決這個問題。由于 1 位元組是 x86 上一條指令可以得到的最短指令,是以我們保證隻有我們想要中斷的指令才會改變。
封裝一些血淋淋的細節
上一節的代碼示例中顯示的許多低級細節可以輕松封裝在友善的 API 後面。我已經将一些封裝到一個名為debuglib的小型實用程式庫中- 它的代碼可以在文章末尾下載下傳。在這裡,我隻想示範一個其用法的示例,但有所不同。我們将跟蹤用 C 編寫的程式。
跟蹤 C 程式
到目前為止,為了簡單起見,我主要關注彙編語言目标。現在是時候更上一層樓,看看我們如何跟蹤用 C 編寫的程式了。
事實證明,情況并沒有太大不同 - 隻是找到放置斷點的位置有點困難。考慮這個簡單的程式:
#include <stdio.h>
無效 do_stuff ()
{
printf( “你好,” );
}
int 主函數()
{
for ( int i = 0 ; i < 4 ; ++i)
做東西();
printf( "世界!\n" );
傳回 0;
}
假設我想在do_stuff的入口處放置一個斷點。我将使用老朋友objdump來反彙編可執行檔案,但其中有很多内容。特别是,檢視文本部分有點無用,因為它包含很多我目前不感興趣的 C 運作時初始化代碼。是以,讓我們在轉儲中查找do_stuff :
080483e4 <do_stuff>:
80483e4: 55 推 %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 83 ec 18 子 $0x18,%esp
80483ea: c7 04 24 f0 84 04 08 movl $0x80484f0,(%esp)
80483f1: e8 22 ff ff ff 呼叫 8048318 <puts@plt>
80483f6:c9離開
80483f7:c3 ret
好吧,我們将斷點放置在 0x080483e4 處,這是do_stuff的第一條指令。此外,由于該函數是在循環中調用的,是以我們希望一直在斷點處停止,直到循環結束。我們将使用debuglib庫來簡化此操作。這是完整的調試器功能:
無效 run_debugger(pid_t child_pid)
{
procmsg( “調試器已啟動\n” );
/* 等待子程序在執行第一條指令時停止 */
wait( 0 );
procmsg( "子程序現在的 EIP = 0x%08x\n" , get_child_eip(child_pid));
/* 建立斷點并運作到它*/
debug_breakpoint* bp = create_breakpoint(child_pid, ( void *) 0x080483e4 );
procmsg( "已建立斷點\n" );
ptrace(PTRACE_CONT, child_pid, 0 , 0 );
等待(0);
/* 隻要子程序沒有退出就循環 */
while ( 1 ) {
/* 子程序在斷點處停止。恢複其
** 執行,直到退出或
再次遇到 ** 斷點。
*/
procmsg( "子程序在斷點處停止。EIP = 0x%08X\n" , get_child_eip(child_pid));
procmsg( “正在恢複\n” );
int rc =resume_from_breakpoint(child_pid, bp);
如果(rc== 0){
procmsg( "子程序退出\n" );
打破;
}
否則 if (rc == 1 ) {
繼續;
}
否則{
procmsg( “意外:%d\n”,rc);
打破;
}
}
cleanup_breakpoint(bp);
}
我們不必親自修改 EIP 和目标程序的記憶體空間,而隻需使用create_breakpoint、resume_from_breakpoint和cleanup_breakpoint。讓我們看看跟蹤上面顯示的簡單 C 代碼時會列印什麼:
$ bp_use_lib traced_c_loop
[13363] 調試器已啟動
[13364] 目标開始。将運作“traced_c_loop”
[13363] 孩子現在在 EIP = 0x00a37850
[13363] 斷點已建立
[13363] 孩子停在斷點處。電子IP = 0x080483E5
[13363] 恢複
你好,
[13363] 孩子停在斷點處。電子IP = 0x080483E5
[13363] 恢複
你好,
[13363] 孩子停在斷點處。電子IP = 0x080483E5
[13363] 恢複
你好,
[13363] 孩子停在斷點處。電子IP = 0x080483E5
[13363] 恢複
你好,
世界!
[13363] 孩子退出了
如預期的那樣!
代碼
這是這部分的完整源代碼檔案。在檔案中您會發現:
- debuglib.h 和 debuglib.c - 用于封裝調試器的一些内部工作的簡單庫
- bp_manual.c - 本文首先介紹的設定斷點的“手動”方式。将debuglib庫用于某些樣闆代碼。
- bp_use_lib.c - 将debuglib用于其大部分代碼,如用于跟蹤 C 程式中的循環的第二個代碼示例中所示。
我們已經介紹了如何在調試器中實作斷點。雖然不同作業系統的實作細節有所不同,但當您使用 x86 時,它基本上都是同一主題的變體 - 将int 3替換為我們希望程序停止的指令。
也就是說,我确信有些讀者,就像我一樣,對于指定要中斷的原始記憶體位址不會感到興奮。我們想說“在do_stuff上中斷”,甚至“在do_stuff中的這一行上中斷”并讓調試器執行此操作。
3.3調試資訊
現代編譯器可以很好地将進階代碼(具有良好的縮進和嵌套控制結構以及任意類型的變量)轉換為一大堆稱為機器代碼的位,其唯一目的是在計算機上盡可能快地運作目标CPU。大多數 C 代碼行都會被轉換成多個機器代碼指令。變量被塞到各處——堆棧中、寄存器中,或者完全優化掉。結構和對象甚至不存在于生成的代碼中——它們隻是一個抽象,被轉換為寫死的偏移量到記憶體緩沖區中。
那麼,當您要求調試器在某個函數的入口處中斷時,調試器如何知道在哪裡停止呢?當您向它詢問變量的值時,它如何設法找到要顯示的内容?答案是——調試資訊。
調試資訊由編譯器與機器代碼一起生成。它是可執行程式與原始源代碼之間關系的表示。該資訊被編碼為預定義的格式并與機器代碼一起存儲。多年來,針對不同平台和可執行檔案發明了許多此類格式。由于本文的目的不是調查這些格式的曆史,而是展示它們的工作原理,是以我們必須做出一些決定。這個東西就是 DWARF,它現在幾乎普遍用作 Linux 和其他 Unix-y 平台上的 ELF 可執行檔案的調試資訊格式。
ELF中的矮人
根據其維基百科頁面,DWARF 是與 ELF 一起設計的,盡管理論上它也可以嵌入其他目标檔案格式中[1]。
DWARF 是一種複雜的格式,建立在針對各種體系結構和作業系統的先前格式的多年經驗之上。它必須很複雜,因為它解決了一個非常棘手的問題 - 将任何進階語言的調試資訊呈現給調試器,提供對任意平台和 ABI 的支援。要充分解釋它,需要的不僅僅是這篇不起眼的文章,而且說實話,我對它所有的陰暗角落都沒有足夠的了解,無論如何也無法參與這樣的努力。在本文中,我将采取更實際的方法,展示足夠多的 DWARF 來解釋調試資訊在實際中是如何工作的。
ELF 檔案中的調試部分
首先讓我們看一下 DWARF 資訊在 ELF 檔案中的位置。 ELF 定義了每個目标檔案中可能存在的任意部分。節頭表定義存在哪些節及其名稱。不同的工具以特殊的方式處理不同的部分 - 例如連結器正在尋找某些部分,調試器則尋找其他部分。
我們将使用從此 C 源代碼建構的可執行檔案進行本文中的實驗,并将其編譯為tracedprog2:
#include <stdio.h>
無效 do_stuff ( int my_arg)
{
int my_local = my_arg + 2 ;
整數我;
for (i = 0 ; i < my_local; ++i)
printf( "i = %d\n" , i);
}
int 主函數()
{
do_stuff( 2 );
傳回 0;
}
使用objdump-h從 ELF 可執行檔案中轉儲節頭,我們會注意到幾個名稱以.debug_開頭的節- 這些是 DWARF 調試節:
26.debug_aranges 00000020 00000000 00000000 00001037
内容,隻讀,調試
27.debug_pubnames 00000028 00000000 00000000 00001057
内容,隻讀,調試
28.debug_info 000000cc 00000000 00000000 0000107f
内容,隻讀,調試
29.debug_abbrev 0000008a 00000000 00000000 0000114b
内容,隻讀,調試
30.debug_line 0000006b 00000000 00000000 000011d5
内容,隻讀,調試
31.debug_frame 00000044 00000000 00000000 00001240
内容,隻讀,調試
32.debug_str 000000ae 00000000 00000000 00001284
内容,隻讀,調試
33.debug_loc 00000058 00000000 00000000 00001332
内容,隻讀,調試
這裡每個部分看到的第一個數字是它的大小,最後一個數字是它在 ELF 檔案中開始的偏移量。調試器使用此資訊從可執行檔案中讀取該部分。現在讓我們看一些在 DWARF 中查找有用調試資訊的實際示例。
尋找函數
調試時我們要做的最基本的事情之一就是在某個函數處放置斷點,期望調試器在其入口處中斷。為了能夠執行此功能,調試器必須在進階代碼中的函數名稱與機器代碼中該函數的指令開始的位址之間具有某種映射。
可以通過檢視.debug_info部分從 DWARF 擷取此資訊。在我們進一步讨論之前,先介紹一些背景知識。 DWARF 中的基本描述實體稱為調試資訊條目 (DIE)。每個 DIE 都有一個标簽 - 它的類型和一組屬性。 DIE 通過兄弟連結和子連結互相連結,并且屬性值可以指向其他 DIE。
讓我們運作:
objdump --dwarf=info tracedprog2
輸出相當長,對于這個例子,我們隻關注這些行:
<1><71>:縮寫編号:5(DW_TAG_子程式)
<72> DW_AT_外部:1
<73> DW_AT_name : (...): do_stuff
<77> DW_AT_decl_file:1
<78> DW_AT_decl_line:4
<79> DW_AT_原型:1
<7a> DW_AT_low_pc:0x8048604
<7e> DW_AT_high_pc:0x804863e
<82> DW_AT_frame_base : 0x0(位置清單)
<86> DW_AT_sibling:<0xb3>
<1><b3>:縮寫編号:9(DW_TAG_子程式)
<b4> DW_AT_external:1
<b5> DW_AT_name : (...): 主要
<b9> DW_AT_decl_file:1
<ba> DW_AT_decl_line : 14
<bb> DW_AT_type : <0x4b>
<bf> DW_AT_low_pc:0x804863e
<c3> DW_AT_high_pc:0x804865a
<c7> DW_AT_frame_base : 0x2c(位置清單)
有兩個條目(DIE)标記為DW_TAG_subprogram,這是 DWARF 行話中的一個函數。請注意,有一個do_stuff條目和一個main條目。有幾個有趣的屬性,但我們感興趣的是DW_AT_low_pc。這是函數開始處的程式計數器( x86 中的EIP)值。請注意,do_stuff的值為0x8048604。現在讓我們通過運作objdump-d來看看該位址在可執行檔案的反彙編中是什麼:
08048604 <do_stuff>:
8048604:55推ebp
8048605: 89 e5 mov ebp,esp
8048607: 83 ec 28 子 esp,0x28
804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
804860d: 83 c0 02 添加 eax,0x2
8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
8048613: c7 45 (...) mov DWORD PTR [ebp-0x10],0x0
804861a: eb 18 jmp 8048634 <do_stuff+0x30>
804861c: b8 20 (...) mov eax,0x8048720
8048621:8b 55 f0 mov edx,DWORD PTR [ebp-0x10]
8048624: 89 54 24 04 mov DWORD PTR [esp+0x4],edx
8048628: 89 04 24 mov DWORD PTR [esp],eax
804862b: e8 04 (...) 調用 8048534 <printf@plt>
8048630: 83 45 f0 01 添加 DWORD PTR [ebp-0x10],0x1
8048634:8b 45 f0 mov eax,DWORD PTR [ebp-0x10]
8048637:3b 45 f4 cmp eax,DWORD PTR [ebp-0xc]
804863a: 7c e0 jl 804861c <do_stuff+0x18>
804863c:c9 離開
804863d:c3 右
事實上,0x8048604是do_stuff的開頭,是以調試器可以在函數及其在可執行檔案中的位置之間建立映射。
尋找變量
假設我們确實停在do_stuff内的斷點處。我們想讓調試器向我們顯示my_local變量的值。它怎麼知道在哪裡可以找到它?事實證明,這比查找函數要棘手得多。變量可以位于全局存儲中、堆棧中,甚至寄存器中。此外,具有相同名稱的變量在不同的詞法作用域中可以具有不同的值。調試資訊必須能夠反映所有這些變化,DWARF 确實做到了。
我不會涵蓋所有可能性,但作為示例,我将示範調試器如何在do_stuff中找到my_local。讓我們從.debug_info開始,再次檢視do_stuff的條目,這次還檢視它的幾個子條目:
<1><71>:縮寫編号:5(DW_TAG_子程式)
<72> DW_AT_外部:1
<73> DW_AT_name : (...): do_stuff
<77> DW_AT_decl_file:1
<78> DW_AT_decl_line:4
<79> DW_AT_原型:1
<7a> DW_AT_low_pc:0x8048604
<7e> DW_AT_high_pc:0x804863e
<82> DW_AT_frame_base : 0x0(位置清單)
<86> DW_AT_sibling:<0xb3>
<2><8a>:縮寫編号:6(DW_TAG_formal_parameter)
<8b> DW_AT_name : (...): my_arg
<8f> DW_AT_decl_file:1
<90> DW_AT_decl_line:4
<91> DW_AT_類型:<0x4b>
<95> DW_AT_位置:(...)(DW_OP_fbreg:0)
<2><98>:縮寫編号:7 (DW_TAG_variable)
<99> DW_AT_name : (...): my_local
<9d> DW_AT_decl_file:1
<9e> DW_AT_decl_line : 6
<9f> DW_AT_type:<0x4b>
<a3> DW_AT_location : (...) (DW_OP_fbreg: -20)
<2><a6>:縮寫編号:8 (DW_TAG_variable)
<a7> DW_AT_名稱:i
<a9> DW_AT_decl_file:1
<aa> DW_AT_decl_line : 7
<ab> DW_AT_type : <0x4b>
<af> DW_AT_location : (...) (DW_OP_fbreg: -24)
請注意每個條目中尖括号内的第一個數字。這是嵌套級别 - 在此示例中,帶有<2> 的條目是帶有<1>的條目的子項。是以我們知道變量my_local(由DW_TAG_variable标簽标記)是do_stuff函數的子函數。調試器還對變量的類型感興趣,以便能夠正确顯示它。在my_local的情況下,類型指向另一個 DIE - <0x4b>。如果我們在objdump的輸出中查找它,我們會看到它是一個帶符号的 4 位元組整數。
為了在執行程序的記憶體映像中實際定位變量,調試器将檢視DW_AT_location屬性。對于my_local,它顯示DW_OP_fbreg: -20。這意味着該變量存儲在距其包含函數的DW_AT_frame_base屬性的偏移量 -20 處 - 這是該函數的架構的基礎。
do_stuff的DW_AT_frame_base屬性的值為0x0 (位置清單),這意味着該值實際上必須在位置清單部分中查找。我們來看一下:
$ objdump --dwarf=loc Tracedprog2
tracedprog2:檔案格式 elf32-i386
.debug_loc 部分的内容:
偏移開始結束表達式
00000000 08048604 08048605 (DW_OP_breg4: 4 )
00000000 08048605 08048607 (DW_OP_breg4: 8 )
00000000 08048607 0804863e (DW_OP_breg5:8)
00000000 <清單結束>
0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
0000002c 0804863f 08048641(DW_OP_breg4:8)
0000002c 08048641 0804865a(DW_OP_breg5:8)
0000002c <清單結束>
我們感興趣的位置資訊是第一個[4]。對于調試器所在的每個位址,它指定目前幀基址,從該基址計算變量的偏移量作為寄存器的偏移量。對于 x86,bpreg4指esp,bpreg5指ebp。
再次檢視do_stuff的前幾條指令是有教育意義的:
08048604 <do_stuff>:
8048604:55推ebp
8048605: 89 e5 mov ebp,esp
8048607: 83 ec 28 子 esp,0x28
804860a: 8b 45 08 mov eax,DWORD PTR [ebp+0x8]
804860d: 83 c0 02 添加 eax,0x2
8048610: 89 45 f4 mov DWORD PTR [ebp-0xc],eax
請注意,ebp僅在執行第二條指令後才變得相關,實際上,對于前兩個位址,基址是根據上面列出的位置資訊中的esp計算的。一旦ebp有效,就可以友善地計算相對于它的偏移量,因為它保持不變,而esp随着資料從堆棧中壓入和彈出而不斷移動。
那麼my_local給我們帶來了什麼呢?我們隻對0x8048610指令之後的值感興趣(在eax中計算後,它的值被放置在記憶體中),是以調試器将使用DW_OP_breg5: 8幀基數來查找它。現在是時候回顧一下my_local的DW_AT_location屬性 為DW_OP_fbreg: -20。讓我們計算一下:距架構基數 -20,即ebp + 8。我們得到ebp - 12。現在再次檢視反彙編并注意資料從eax移至何處 - 事實上,ebp - 12是my_local的存儲位置。
查找行号
當我們談到在調試資訊中查找函數時,我有點作弊。當我們調試 C 源代碼并在函數中放置斷點時,我們通常對第一條機器代碼指令不感興趣[5]。我們真正感興趣的是該函數的第一行C 代碼行。
這就是為什麼 DWARF 對 C 源代碼中的行和可執行檔案中的機器代碼位址之間的完整映射進行編碼。此資訊包含在.debug_line部分中,可以以可讀形式提取,如下所示:
$ objdump --dwarf=decodedline tracedprog2
tracedprog2:檔案格式 elf32-i386
.debug_line 部分調試内容的解碼轉儲:
CU:/home/eliben/eli/eliben-code/debugger/tracedprog2.c:
檔案名 行号 起始位址
跟蹤prog2.c 5 0x8048604
跟蹤prog2.c 6 0x804860a
跟蹤prog2.c 9 0x8048613
跟蹤prog2.c 10 0x804861c
跟蹤prog2.c 9 0x8048630
追蹤prog2.c 11 0x804863c
跟蹤prog2.c 15 0x804863e
追蹤prog2.c 16 0x8048647
追蹤prog2.c 17 0x8048653
追蹤prog2.c 18 0x8048658
不難看出這些資訊、C 源代碼和反彙編轉儲之間的對應關系。第 5 行指向do_stuff - 0x8040604的入口點。下一行 6 是調試器在被要求中斷do_stuff時真正應該停止的地方,它指向0x804860a,即函數序言後面的位置。此線路資訊可以輕松實作線路和位址之間的雙向映射:
- 當要求在某一行放置斷點時,調試器将使用它來查找應該将陷阱放置在哪個位址(還記得上一篇文章中我們的朋友int 3嗎?)
- 當指令導緻分段錯誤時,調試器将使用它來查找發生該錯誤的源代碼行。
libdwarf - 以程式設計方式使用 DWARF
使用指令行工具通路 DWARF 資訊雖然有用,但并不完全令人滿意。作為程式員,我們想知道如何編寫可以讀取格式并從中提取我們需要的内容的實際代碼。
當然,一種方法是擷取 DWARF 規範并開始破解。現在,還記得每個人都說你永遠不應該手動解析 HTML 而應該使用庫嗎?嗯,對于 DWARF 來說情況更糟。 DWARF比 HTML 複雜得多。我在這裡展示的隻是冰山一角,讓事情變得更加困難的是,大部分資訊都以非常緊湊和壓縮的方式編碼在實際的目标檔案中[6]。
是以,我們将采取另一條路并使用庫來與 DWARF 一起工作。我知道有兩個主要的庫(加上一些不太完整的庫):
- BFD ( libbfd ) 由GNU binutils使用,包括在本文中發揮重要作用的objdump 、 ld(GNU 連結器)和as(GNU 彙編器)。
- libdwarf - 與其老大哥libelf一起用于 Solaris 和 FreeBSD 作業系統上的工具。
我選擇libdwarf而不是 BFD,因為它對我來說似乎不那麼神秘,而且它的許可證更自由(LGPL與GPL)。
由于libdwarf本身相當複雜,是以需要大量代碼來操作。我不會在這裡展示所有這些代碼,但您可以自己下載下傳并運作它。要編譯此檔案,您需要安裝libelf和libdwarf,并将-lelf和-ldwarf标志傳遞給連結器。
示範的程式采用可執行檔案并列印其中的函數名稱及其入口點。以下是它為我們在本文中使用的 C 程式生成的結果:
$ dwarf_get_func_addr 追蹤prog2
DW_TAG_子程式:'do_stuff'
低電腦:0x08048604
高電腦:0x0804863e
DW_TAG_子程式:'主'
低電腦:0x0804863e
高電腦:0x0804865a
libdwarf的文檔(連結在本文的參考資料部分)非常好,并且通過一些努力,您應該可以毫無問題地使用它從 DWARF 部分中提取本文中示範的任何其他資訊。
調試資訊原則上是一個簡單的概念。實作細節可能很複雜,但最終重要的是我們現在知道調試器如何找到它所需的有關編譯其跟蹤的可執行檔案的原始源代碼的資訊。有了這些資訊,調試器就在使用者的世界(根據代碼行和資料結構進行思考)和可執行檔案的世界(隻是寄存器和記憶體中的一堆機器代碼指令和資料)之間建立了橋梁。
四、ptrace實作原理
本文使用的 Linux 2.4.16 版本的核心,看懂本文需要的基礎:程序排程,記憶體管理和信号處理相關知識。
調用 ptrace() 系統函數時會觸發調用核心的 sys_ptrace() 函數,由于不同的 CPU 架構有着不同的調試方式,是以 Linux 為每種不同的 CPU 架構實作了不同的 sys_ptrace() 函數,而本文主要介紹的是 X86 CPU 的調試方式,是以 sys_ptrace() 函數所在檔案是 linux-2.4.16/arch/i386/kernel/ptrace.c。
sys_ptrace() 函數的主體是一個 switch 語句,會傳入的 request 參數不同進行不同的操作,如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
struct task_struct *child;
struct user *dummy = NULL;
int i, ret;
...
read_lock(&tasklist_lock);
child = find_task_by_pid(pid); // 擷取 pid 對應的程序 task_struct 對象
if (child)
get_task_struct(child);
read_unlock(&tasklist_lock);
if (!child)
goto out;
if (request == PTRACE_ATTACH) {
ret = ptrace_attach(child);
goto out_tsk;
}
...
switch (request) {
case PTRACE_PEEKTEXT:
case PTRACE_PEEKDATA:
...
case PTRACE_PEEKUSR:
...
case PTRACE_POKETEXT:
case PTRACE_POKEDATA:
...
case PTRACE_POKEUSR:
...
case PTRACE_SYSCALL:
case PTRACE_CONT:
...
case PTRACE_KILL:
...
case PTRACE_SINGLESTEP:
...
case PTRACE_DETACH:
...
}
out_tsk:
free_task_struct(child);
out:
unlock_kernel();
return ret;
}
從上面的代碼可以看出,sys_ptrace() 函數首先根據程序的 pid 擷取到程序的 task_struct 對象。然後根據傳入不同的 request 參數在 switch 語句中進行不同的操作。
ptrace() 支援的所有 request 操作定義在 linux-2.4.16/include/linux/ptrace.h 檔案中,如下:
#define PTRACE_TRACEME 0
#define PTRACE_PEEKTEXT 1
#define PTRACE_PEEKDATA 2
#define PTRACE_PEEKUSR 3
#define PTRACE_POKETEXT 4
#define PTRACE_POKEDATA 5
#define PTRACE_POKEUSR 6
#define PTRACE_CONT 7
#define PTRACE_KILL 8
#define PTRACE_SINGLESTEP 9
#define PTRACE_ATTACH 0x10
#define PTRACE_DETACH 0x11
#define PTRACE_SYSCALL 24
#define PTRACE_GETREGS 12
#define PTRACE_SETREGS 13
#define PTRACE_GETFPREGS 14
#define PTRACE_SETFPREGS 15
#define PTRACE_GETFPXREGS 18
#define PTRACE_SETFPXREGS 19
#define PTRACE_SETOPTIONS 21
由于 ptrace() 提供的操作比較多,是以本文隻會挑選一些比較有代表性的操作進行解說,比如 PTRACE_TRACEME、PTRACE_SINGLESTEP、PTRACE_PEEKTEXT、PTRACE_PEEKDATA 和 PTRACE_CONT 等,而其他的操作,有興趣的朋友可以自己去分析其實作原理。
進入被追蹤模式(PTRACE_TRACEME操作)
當要調試一個程序時,需要使程序進入被追蹤模式,怎麼使程序進入被追蹤模式呢?有兩個方法:
- 被調試的程序調用 ptrace(PTRACE_TRACEME, ...) 來使自己進入被追蹤模式。
- 調試程序(如GDB)調用 ptrace(PTRACE_ATTACH, pid, ...) 來使指定的程序進入被追蹤模式。
第一種方式是程序自己主動進入被追蹤模式,而第二種是程序被動進入被追蹤模式。
被調試的程序必須進入被追蹤模式才能進行調試,因為 Linux 會對被追蹤的程序進行一些特殊的處理。下面我們主要介紹第一種進入被追蹤模式的實作,就是 PTRACE_TRACEME 的操作過程,代碼如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
...
if (request == PTRACE_TRACEME) {
if (current->ptrace & PT_PTRACED)
goto out;
current->ptrace |= PT_PTRACED; // 标志 PTRACE 狀态
ret = 0;
goto out;
}
...
}
從上面的代碼可以發現,ptrace() 對 PTRACE_TRACEME 的處理就是把目前程序标志為 PTRACE 狀态。
當然事情不會這麼簡單,因為當一個程序被标記為 PTRACE 狀态後,當調用 exec() 函數去執行一個外部程式時,将會暫停目前程序的運作,并且發送一個 SIGCHLD 給父程序。父程序接收到 SIGCHLD 信号後就可以對被調試的程序進行調試。
我們來看看 exec() 函數是怎樣實作上述功能的,exec() 函數的執行過程為 sys_execve() -> do_execve() -> load_elf_binary():
static int load_elf_binary(struct linux_binprm * bprm, struct pt_regs * regs)
{
...
if (current->ptrace & PT_PTRACED)
send_sig(SIGTRAP, current, 0);
...
}
從上面代碼可以看出,當程序被标記為 PTRACE 狀态時,執行 exec() 函數後便會發送一個 SIGTRAP 的信号給目前程序。
我們再來看看,程序是怎麼處理 SIGTRAP 信号的。信号是通過 do_signal() 函數進行處理的,而對 SIGTRAP 信号的處理邏輯如下:
nt do_signal(struct pt_regs *regs, sigset_t *oldset)
{
for (;;) {
unsigned long signr;
spin_lock_irq(¤t->sigmask_lock);
signr = dequeue_signal(¤t->blocked, &info);
spin_unlock_irq(¤t->sigmask_lock);
// 如果程序被标記為 PTRACE 狀态
if ((current->ptrace & PT_PTRACED) && signr != SIGKILL) {
/* 讓調試器運作 */
current->exit_code = signr;
current->state = TASK_STOPPED; // 讓自己進入停止運作狀态
notify_parent(current, SIGCHLD); // 發送 SIGCHLD 信号給父程序
schedule(); // 讓出CPU的執行權限
...
}
}
}
裡面的代碼主要做了3件事:
- 如果目前程序被标記為 PTRACE 狀态,那麼就使自己進入停止運作狀态。
- 發送 SIGCHLD 信号給父程序。
- 讓出 CPU 的執行權限,使 CPU 執行其他程序。
執行以上過程後,被追蹤程序便進入了調試模式,過程如下圖:
父程序(調試程序)接收到 SIGCHLD 信号後,表示被調試程序已經标記為被追蹤狀态并且停止運作,那麼調試程序就可以開始進行調試了。
擷取被調試程序的記憶體資料(PTRACE_PEEKTEXT / PTRACE_PEEKDATA)
調試程序(如GDB)可以通過調用 ptrace(PTRACE_PEEKDATA, pid, addr, data) 來擷取被調試程序 addr 處虛拟記憶體位址的資料,但每次隻能讀取一個大小為 4位元組的資料。
我們來看看 ptrace() 對 PTRACE_PEEKDATA 操作的處理過程,代碼如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
...
switch (request) {
case PTRACE_PEEKTEXT:
case PTRACE_PEEKDATA: {
unsigned long tmp;
int copied;
copied = access_process_vm(child, addr, &tmp, sizeof(tmp), 0);
ret = -EIO;
if (copied != sizeof(tmp))
break;
ret = put_user(tmp, (unsigned long *)data);
break;
}
...
}
從上面代碼可以看出,對 PTRACE_PEEKTEXT 和 PTRACE_PEEKDATA 的處理是相同的,主要是通過調用 access_process_vm() 函數來讀取被調試程序 addr 處的虛拟記憶體位址的資料。
access_process_vm() 函數的實作主要涉及到 記憶體管理 相關的知識,可以參考我以前對記憶體管理分析的文章,這裡主要大概說明一下 access_process_vm() 的原理。
我們知道每個程序都有個 mm_struct 的記憶體管理對象,而 mm_struct 對象有個表示虛拟記憶體與實體記憶體映射關系的頁目錄的指針 pgd。如下
struct mm_struct {
...
pgd_t *pgd; /* 頁目錄指針 */
...
}
而 access_process_vm() 函數就是通過程序的頁目錄來找到 addr 虛拟記憶體位址映射的實體記憶體位址,然後把此實體記憶體位址處的資料複制到 data 變量中。如下圖所示:
access_process_vm() 函數的實作這裡就不分析了,有興趣的讀者可以參考我之前對記憶體管理分析的文章自行進行分析。
單步調試模式(PTRACE_SINGLESTEP)
單步調試是一個比較有趣的功能,當把被調試程序設定為單步調試模式後,被調試程序沒執行一條CPU指令都會停止執行,并且向父程序(調試程序)發送一個 SIGCHLD 信号。
我們來看看 ptrace() 函數對 PTRACE_SINGLESTEP 操作的處理過程,代碼如下:
asmlinkage int sys_ptrace(long request, long pid, long addr, long data)
{
...
switch (request) {
case PTRACE_SINGLESTEP: { /* set the trap flag. */
long tmp;
...
tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
put_stack_long(child, EFL_OFFSET, tmp);
child->exit_code = data;
/* give it a chance to run. */
wake_up_process(child);
ret = 0;
break;
}
...
}
要把被調試的程序設定為單步調試模式,英特爾的 X86 CPU 提供了一個硬體的機制,就是通過把 eflags 寄存器的 Trap Flag 設定為1即可。
當把 eflags 寄存器的 Trap Flag 設定為1後,CPU 每執行一條指令便會産生一個異常,然後會觸發 Linux 的異常處理,Linux 便會發送一個 SIGTRAP 信号給被調試的程序。eflags 寄存器的各個标志如下圖:
從上圖可知,eflags 寄存器的第8位就是單步調試模式的标志。
是以 ptrace() 函數的以下2行代碼就是設定 eflags 程序的單步調試标志:
tmp = get_stack_long(child, EFL_OFFSET) | TRAP_FLAG;
put_stack_long(child, EFL_OFFSET, tmp);
而 get_stack_long(proccess, offset) 函數用于擷取程序棧 offset 處的值,而 EFL_OFFSET 偏移量就是 eflags 寄存器的值。是以上面兩行代碼的意思就是:
- 擷取程序的 eflags 寄存器的值,并且設定 Trap Flag 标志。
- 把新的值設定到程序的 eflags 寄存器中。
設定完 eflags 寄存器的值後,就調用 wake_up_process() 函數把被調試的程序喚醒,讓其進入運作狀态。單步調試過程如下圖:
處于單步調試模式時,被調試程序每執行一條指令都會觸發一次 SIGTRAP 信号,而被調試程序處理 SIGTRAP 信号時會發送一個 SIGCHLD 信号給父程序(調試程序),并且讓自己停止執行。
而父程序(調試程序)接收到 SIGCHLD 後,就可以對被調試的程序進行各種操作,比如讀取被調試程序記憶體的資料和寄存器的資料,或者通過調用 ptrace(PTRACE_CONT, child,...) 來讓被調試程序進行運作等。
由于 ptrace() 的功能十分強大,是以本文隻能抛磚引玉,沒能對其所有功能進行分析。另外斷點功能并不是通過 ptrace() 函數實作的,而是通過 int3 指令來實作的,在 Eli Bendersky 大神的文章有介紹。而對于 ptrace() 的所有功能,隻能讀者自己慢慢看代碼來體會了。