Implementing Breakpoints with GDB and Ptrace on Linux
This article explains how to create a simple breakpoint mechanism by using GDB for illustration and then reproducing the same behavior with the Linux ptrace system call, covering required knowledge about the RIP register, ELF symbols, and step‑by‑step implementation details.
Introduction
When debugging C/C++ programs we often rely on GDB to set breakpoints, but it is useful to understand the underlying mechanism. This article demonstrates a minimal breakpoint implementation using the Linux ptrace system call, reproducing the effect of GDB.
Simple GDB Demonstration
// test.cpp
#include
#include
void test1() {
std::cout << "test" << std::endl;
}
int main() {
while (true) {
std::cout << "main: " << getpid() << std::endl;
test1();
sleep(1);
}
return 0;
}Compile and run:
g++ -std=c++11 test.cpp && ./a.out
// output
main: 22346
test
main: 22346
test
...Start GDB and set a breakpoint at test1 :
sudo gdb a.out -p 22346
(gdb) break test1
Breakpoint 1 at 0x40091a
(gdb) c
Continuing.
Breakpoint 1, 0x000000000040091a in test1 ()
(gdb) i r rip
rip 0x40091a 0x40091a <test1+4>After the breakpoint the process stops in state T (TASK_STOPPED or TASK_TRACED), indicating that the debugger has gained control.
Prerequisite Knowledge
RIP Register
The RIP register holds the address of the next instruction to be executed. It is automatically incremented after each instruction.
Ptrace
ptrace provides two roles:
tracee – the process being traced.
tracer – the debugger that controls the tracee.
Typical ptrace calls used are PTRACE_SEIZE , PTRACE_INTERRUPT , PTRACE_PEEKDATA , PTRACE_POKEDATA , PTRACE_GETREGS , PTRACE_SETREGS , and PTRACE_CONT .
Implementation Idea
The implementation follows five simple steps:
1. Determine the address to break
Instead of line numbers, we use the function name. The ELF symbol table (e.g., via readelf -s a.out ) provides the address of test1 , which is 0x400916 .
2. Obtain control of the tracee
// Establish tracing relationship
ptrace(PTRACE_SEIZE, pid, NULL, data);
// Interrupt the tracee to gain control
ptrace(PTRACE_INTERRUPT, pid, NULL, data);
// Wait for the tracee to stop
waitpid(pid, &status, options);3. Save the original instruction at the target address and replace it with a trap
// Read original instruction
long old_code = ptrace(PTRACE_PEEKDATA, pid, addr, NULL);
// Insert INT 3 (0xcc) as the trap instruction
unsigned char *p = (unsigned char *)&old_code;
p[0] = 0xcc;
ptrace(PTRACE_POKEDATA, pid, addr, old_code);4. Continue execution and wait for the trap to fire
ptrace(PTRACE_CONT, pid, NULL, NULL);
// Wait until SIGTRAP is received
dowait(pid);5. Restore the original instruction and registers, then detach
// Restore registers
ptrace(PTRACE_SETREGS, pid, NULL, &old_regs);
// Restore original code
ptrace(PTRACE_POKEDATA, pid, addr, old_code);
ptrace(PTRACE_CONT, pid, 0, 0);Full Tracer Code
#include
#include
#include
#include
#include
#include
void dowait(pid_t pid) {
int status, signum;
while (true) {
waitpid(pid, &status, 0);
if (WIFSTOPPED(status)) {
signum = WSTOPSIG(status);
if (signum == SIGTRAP) {
break;
} else {
std::cout << "Other signum, skipping..." << std::endl;
ptrace(PTRACE_CONT, pid, 0, 0);
}
}
}
}
void break_once(pid_t pid, long addr) {
// Save original instruction and registers
long old_code = ptrace(PTRACE_PEEKDATA, pid, addr, NULL);
user_regs_struct old_regs;
ptrace(PTRACE_GETREGS, pid, NULL, &old_regs);
long trap_code = old_code;
unsigned char *p = (unsigned char *)&trap_code;
p[0] = 0xcc; // INT 3
if (ptrace(PTRACE_POKEDATA, pid, addr, trap_code)) {
std::cout << "Break failed" << std::endl;
return;
}
ptrace(PTRACE_CONT, pid, NULL, NULL);
dowait(pid);
std::cout << "Next ? " << std::endl;
std::string instruction;
std::cin >> instruction;
// Restore registers and original code
ptrace(PTRACE_SETREGS, pid, NULL, &old_regs);
ptrace(PTRACE_POKEDATA, pid, addr, old_code);
ptrace(PTRACE_CONT, pid, 0, 0);
}
void quit(pid_t pid) {
ptrace(PTRACE_DETACH, pid, NULL, NULL);
std::cout << "quit!" << std::endl;
exit(0);
}
int main(int argc, char* argv[]) {
pid_t pid = std::stoi(argv[1]);
if (ptrace(PTRACE_SEIZE, pid, NULL, NULL)) {
perror("ptrace_seize failed");
return -1;
}
if (ptrace(PTRACE_INTERRUPT, pid, 0, 0)) {
perror("interrupt failed");
quit(pid);
}
dowait(pid);
long break_addr = 0x400916; // address of test1
break_once(pid, break_addr);
quit(pid);
return 1;
}Compile and run the tracer:
g++ trace_test.cpp -std=c++11 -o trace_test
./trace_test 22346 # 22346 is the PID of the target processConclusion
The article shows a concrete example of how a breakpoint works at the kernel level, using ptrace to pause a process, replace an instruction with a trap, and then restore the original state. The approach can be extended to resolve function addresses from ELF symbols, manage multiple breakpoints, and handle error cases more robustly.
NetEase Game Operations Platform
The NetEase Game Automated Operations Platform delivers stable services for thousands of NetEase titles, focusing on efficient ops workflows, intelligent monitoring, and virtualization.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.