Writing a Linux Debugger Part 2: Breakpoints
In the first part of this series we wrote a small process launcher as a base for our debugger. In this post we’ll learn how breakpoints work in x86 Linux and augment our tool with the ability to set them.
- Registers and memory
- Elves and dwarves
- Source and signals
- Source-level stepping
- Source-level breakpoints
- Stack unwinding
- Handling variables
- Advanced topics
How is breakpoint formed?
There are two main kinds of breakpoints: hardware and software. Hardware breakpoints typically involve setting architecture-specific registers to produce your breaks for you, whereas software breakpoints involve modifying the code which is being executed on the fly. We’ll be focusing solely on software breakpoints for this article, as they are simpler and you can have as many as you want. On x86 you can only have four hardware breakpoints set at a given time, but they give you the power to make them fire on reading from or writing to a given address rather than only executing code there.
I said above that software breakpoints are set by modifying the executing code on the fly, so the questions are:
- How do we modify the code?
- What modifications do we make to set a breakpoint?
- How is the debugger notified?
The answer to the first question is, of course,
ptrace. We’ve previously used it to set up our program for tracing and continuing its execution, but we can also use it to read and write memory.
The modification we make has to cause the processor to halt and signal the program when the breakpoint address is executed. On x86 this is accomplished by overwriting the instruction at that address with the
int 3 instruction. x86 has an interrupt vector table which the operating system can use to register handlers for various events, such as page faults, protection faults, and invalid opcodes. It’s kind of like registering error handling callbacks, but right down at the hardware level. When the processor executes the
int 3 instruction, control is passed to the breakpoint interrupt handler, which – in the case of Linux – signals the process with a
SIGTRAP. You can see this process in the diagram below, where we overwrite the first byte of the
mov instruction with
0xcc, which is the instruction encoding for
The last piece of the puzzle is how the debugger is notified of the break. If you remember back in the previous post, we can use
waitpid to listen for signals which are sent to the debugee. We can do exactly the same thing here: set the breakpoint, continue the program, call
waitpid and wait until the
SIGTRAP occurs. This breakpoint can then be communicated to the user, perhaps by printing the source location which has been reached, or changing the focused line in a GUI debugger.
Implementing software breakpoints
We’ll implement a
breakpoint class to represent a breakpoint on some location which we can enable or disable as we wish.
Most of this is just tracking of state; the real magic happens in the
As we’ve learned above, we need to replace the instruction which is currently at the given address with an
int 3 instruction, which is encoded as
0xcc. We’ll also want to save out what used to be at that address so that we can restore the code later; we don’t want to forget to execute the user’s code!
PTRACE_PEEKDATA request to
ptrace is how to read the memory of the traced process. We give it a process ID and an address, and it gives us back the 64 bits which are currently at that address.
(data & ~0xff) zeroes out the bottom byte of this data, then we bitwise
OR that with our
int 3 instruction to set the breakpoint. Finally, we set the breakpoint by overwriting that part of memory with our new data with
disable is easier, but still has a subtlety to it. Since the
ptrace memory requests operate on whole words rather than bytes we need to first read the word which is at the location to restore, then overwrite the low byte with the original data and write it back to memory.
Adding breakpoints to the debugger
We’ll make three changes to our debugger class to support setting breakpoints through the user interface:
- Add a breakpoint storage data structure to
- Write a
- Add a
breakcommand to our
I’ll store my breakpoints in a
std::unordered_map<std::intptr_t, breakpoint> structure so that it’s easy and fast to check if a given address has a breakpoint on it and, if so, retrieve that breakpoint object.
set_breakpoint_at_address we’ll create a new breakpoint, enable it, add it to the data structure, and print out a message for the user. If you like, you could factor out all message printing so that you can use the debugger as a library as well as a command-line tool, but I’ll mash it all together for simplicity.
Now we’ll augment our command handler to call our new function.
I’ve simply removed the first two characters of the string and called
std::stol on the result, but feel free to make the parsing more robust.
std::stol optionally takes a radix to convert from, which is handy for reading in hexadecimal.
Continuing from the breakpoint
If you try this out, you might notice that if you continue from the breakpoint, nothing happens. That’s because the breakpoint is still set in memory, so it’s hit repeatedly. The simple solution is to disable the breakpoint, single step, re-enable it, then continue. Unfortunately we’d also need to modify the program counter to point back before the breakpoint, so we’ll leave this until the next post where we’ll learn about manipulating registers.
Testing it out
Of course, setting a breakpoint on some address isn’t very useful if you don’t know what address to set it at. In the future we’ll be adding the ability to set breakpoints on function names or source code lines, but for now, we can work it out manually.
A way to test out your debugger is to write a hello world program which writes to
std::cerr (to avoid buffering) and set a breakpoint on the call to the output operator. If you continue the debugee then hopefully the execution will stop without printing anything. You can then restart the debugger and set a breakpoint just after the call, and you should see the message being printed successfully.
One way to find the address is to use
objdump. If you open up a shell and execute
objdump -d <your program>, then you should see the disassembly for your code. You should then be able to find the
main function and locate the
call instruction which you want to set the breakpoint on. For example, I built a hello world example, disassembled it, and got this as the disassembly for
1189: f3 0f 1e fa endbr64
118d: 55 push %rbp
118e: 48 89 e5 mov %rsp,%rbp
1191: 48 8d 35 6d 0e 00 00 lea 0xe6d(%rip),%rsi # 2005 <_ZStL19piecewise_construct+0x1>
1198: 48 8d 3d 81 2e 00 00 lea 0x2e81(%rip),%rdi # 4020 <_ZSt4cerr@@GLIBCXX_3.4>
119f: e8 dc fe ff ff callq 1080 <_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc@plt>
11a4: b8 00 00 00 00 mov $0x0,%eax
11a9: 5d pop %rbp
11aa: c3 retq
As you can see, we would want to set a breakpoint on
0x1198 to see no output, and
0x119f to see the output.
However, these addresses may not be absolute. If the program is compiled as a position independent executable, which is the default for some compilers, then they are offsets from the address which the binary is loaded at. And due to address space layout randomization this load address will change with each run of the program.
You could compile everything with
-no-pie and be done with it, but that’s not a great solution, and won’t work for some projects. Instead we’ll disable address space layout randomization for the programs we launch, and look up the correct load address.
To disable address space randomization, add a call to
personality before we call
execute_debugee in the child process:
To find the load address you can:
- Start debugging the program
- Note what the child process id is
- Read /proc/<pid>/maps to find the load address
Here’s the maps file for my program:
08000000-08001000 r--p 00000000 00:00 56882 /path/to/hello
08001000-08002000 r-xp 00001000 00:00 56882 /path/to/hello
08002000-08003000 r--p 00002000 00:00 56882 /path/to/hello
08003000-08005000 rw-p 00002000 00:00 56882 /path/to/hello
7fffff7b0000-7fffff7b1000 r--p 00000000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7b1000-7fffff7d3000 r-xp 00001000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7d3000-7fffff7d4000 r-xp 00023000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7d4000-7fffff7db000 r--p 00024000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7db000-7fffff7dc000 r--p 0002b000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7dd000-7fffff7df000 rw-p 0002c000 00:00 948077 /usr/lib/x86_64-linux-gnu/ld-2.31.so
7fffff7df000-7fffff7e0000 rw-p 00000000 00:00 0
7fffff7ef000-7ffffffef000 rw-p 00000000 00:00 0 [stack]
7ffffffef000-7fffffff0000 r-xp 00000000 00:00 0 [vdso]
The important address is the first one noted for
/path/to/hello, which is
0x08000000. That’s the load address of the binary. If you add the breakpoint offsets to that base address, you’ll get the real addresses to set breakpoints at.
You should now have a debugger which can launch a program and allow the user to set breakpoints on memory addresses. Next time we’ll add the ability to read from and write to memory and registers. Again, let me know in the comments if you have any issues.
You can find the code for this post here.
Let me know what you think of this article on twitter @TartanLlama or leave a comment below!