How does a debugger work?

Let's show the internals of a debugger...

In this post, the main goal is to understand how a debugger (like gdb) works. In that purpose, the exemples will be based on my personnal toy debugger Edb (Easy DeBugger). Because of few bugs in the x86_64 version, the article is based on the i386 (32 bits) version.

How to handle i386 and x86_64

I wanted my debugger to be able to debug 32 and 64 bits x86 programs. Thus, I needed to make a configure file which initializes some Makefile variables and also make some defines about the target architecture.



This project was mainly based on a syscall called ptrace. This one allows the tracer (edb) to follow a tracee (program to debug). Thus, the tracer can change the tracee’s memory, change its registers, trace their syscalls or put some breakpoints.

Here is the basic architecture of a program using ptrace:

int main () {
  pid_t child;
  child = folk ();
  if (child == 0) {
    execv ("my_prog", NULL);
  } else {
    wait (NULL);
    printf ("I am the tracer");
    ptrace (PTRACE_CONT, child, NULL,NULL);
  return 0;

Control Flow


To put a breakpoint in your program, you have to put the opcode 0xcc where you need to stop and that’s it. But the problem is, if you want to continue, you will be blocked by the breakpoint. So, you also need to keep a table, called the breakpoint table.

In this table, there will be three fields for each breakpoint:

  • The breakpoint’s address
  • An ELF symbol (optional, can be NULL).
  • The content of the memory before the breakpoint opcode (0xcc) was placed.


To kill the process traced, it is very easy:

ptrace (PTRACE_KILL, pid, NULL, NULL);


It is more complicated because, if the program is on a breakpoint, then to continue, it needs to pass over the breakpoint. So, let’s check if there is a breakpoint:

  • Extract current IP
  • Check if this one is in the breakpoint table, if it is, that’s a breakpoint.

Now, there are two cases:

  • If it is a breakpoint, let’s pass over:

    • decrement current IP
    • put the opcode saved in the breakpoint table.
    • do a single step (see below)
    • continue
  • Otherwise:

    • just continue


This feature is already implemented in ptrace.


Execution Monitoring


This Ptrace call gives you all the registers in a register structure. This structure must be already declared because Ptrace needs a pointer.

struct user_regs_struct regs;
ptrace (PTRACE_GETREGS, pid, NULL, &regs);


There are three commands:

  • ‘x’ for hexadecimal memory dump
  • ’d’ for signed decimal
  • ‘u’ for unsigned decimal

The first argument is the number of bytes to dump and the second is the address which can be in decimal or hexadecimal.

Let’s see how it works, p is the address and count is the number of bytes to dump:

for (int i = 0; i < count_; i++) {
  val = ptrace (PTRACE_PEEKTEXT, pid_, p, NULL);
  cout << val << endl;


A backtrace is a monitoring of the functions called before the current function thanks to the callstack. So, to get a backtrace, you have to walk through the stack until ptrace returns -1. The current frame begins at the value pointed by EBP (Base pointer), which points to the old EBP.

backtrace (pid_t pid_)
    unsigned long pc, fp, nextfp;
    int ret;
    struct user_regs_struct regs;

    ret = ptrace (PTRACE_GETREGS, pid_, NULL, &regs);

    pc = PROGRAM_COUNTER(regs);
    fp = FRAME_POINTER(regs);

    cout << pc << endl;

    while (fp) 
	nextfp = ptrace (PTRACE_PEEKDATA, pid_, 

	if (nextfp == (unsigned long) -1) break;
	if (!nextfp) break;

	pc = ptrace (PTRACE_PEEKDATA, pid_, 

	if (pc == (unsigned long) -1) break;

	fp = nextfp;
	cout << pc << endl;
    return 0;