BUILDING / COOL / THINGS
Bachelor Thesis

aarch64 debugger

ItemValue
TitleA Low-Latency Debugger with Conditional Breakpoints and Watchpoints on macOS aarch64
Year2026
SupervisorRNDr. Jozef Šiška, PhD.
SchoolComenius University Bratislava
DepartmentFMFI.KAI - Department of Applied Informatics

Aim

Explore the feasibility and constraints of implementing conditional breakpoints and watchpoints via code injection on macOS aarch64. Design and implement a minimally usable debugger leveraging this approach for real-world use.

The story

Most debuggers are centered around breakpoints. Pretty much everything is based around them. From the user’s perspective, breakpoints always stop the program and show the current state. Conditional breakpoints1 are an exception to this. The user doesn’t expect the debugger to stop the program and show the current state when the condition is not met.

This perspective is quite misleading. It may not be obvious, but the debugger uses more breakpoints than the user sees. Breakpoints are used even for the most common operations, such as stepping over a line of code, or stepping into a function. The only difference is that the debugger doesn’t tell us about them.

Conditional breakpoints are not so special either. They are exactly the same as regular breakpoints. When we set a conditional breakpoint, the debugger sets a regular breakpoint and writes down the condition alongside it. When the breakpoint is hit, the debugger checks the condition and skips it transparently if it evaluates to false. The flow of a conditional breakpoint may look something like this:

FIG_001: COND_BREAKPOINT_LOOP
process:
fn main() {
    // ...some setup code...

    for idx in 1..=5 {
        let mut entity = game.get_entity(idx);
        entity.update();
    }
}
debugger:
Waiting for breakpointBreakpoint hitEvaluating expressionFalseTrueShow to userContinueexecution

We can see that on each breakpoint hit, the process gets suspended, and control is given to the debugger. Then when the debugger has done its evaluation, control is given back to the target process. Each of these operations requires a context switch, and resuming requires waiting for the scheduler to schedule the resumed process.

Unfortunately, this is not everything. As the debugger is a separate process, it has its own address space2. This means that it needs to perform a syscall3 for each read/write operation on the child process.

If we want to improve performance we have two options:

  1. Make breakpoints faster
  2. Avoid breakpoints altogether

I chose the second option.

The basic idea behind this thesis

If breakpoints are too slow, let’s just avoid breakpoints. Could we teach the target process to check the condition for us and activate the breakpoint only when the condition is met?

To teach the target process anything we need to inject code into it. This can be whatever code we want. This code will be able to check the condition for us. So instead of a breakpoint, we insert a hook that will jump to our injected code, and then jump back to the original code. This way we can avoid the overhead of breakpoints altogether.

The injected code can check the condition automatically and activate a breakpoint only if the condition is met. This way we avoid all the overhead of breakpoints. The only cost we pay is the execution of our injected code, which is orders of magnitude faster than a breakpoint. It’s comparable to just manually writing the if statement in the program and putting the breakpoint in it.

Let’s see how this works in practice:

FIG_002: INSTRUMENTATION
process:
fn main() {
    // some setup code...

    for idx in 1..=5 {
        let mut entity = game.get_entity(idx);
        entity.update();
    }
}

// pseudo injected assembly
asm! {
    ldr x0, [idx]
    cmp x0, 3
    b.ne continue
    brk
    continue:
    b original_code
}
debugger:
Waiting for breakpointBreakpoint hitShow to userContinueexecution

We can see that in this approach, the debugger is woken up only once the condition is met. At that point we want the debugger to wake up, because we need to tell the user that the breakpoint was hit.

Performing the check in the target process is orders of magnitude faster than calling the debugger. The check can directly access the memory of the target, because it is a part of it. All caches are preserved, no context switches are needed. The only cost we pay is the execution of the small assembly snippet.

The technique is called “dynamic instrumentation”. One of the most popular tools for this is Frida. Frida is focused on tracing the target process, analyzing its execution and hooking functions and specific instructions.

Most of these tools are focused on security research and are primarily designed to hook functions, therefore their hooking techniques have some limitations. Common use cases for Frida are bypassing security measures present in malware or applications during research.

In this thesis I explore the implications of taking these ideas and applying them in a debugger. What new features does this enable? What are the limitations? What are the trade-offs?

Footnotes

  1. Conditional breakpoints allow you to specify an expression (like var_a == 13) which must evaluate to true at that point for the breakpoint to hit

  2. Modern operating systems use virtual memory to isolate processes. Therefore address 0x1000 in one process does not refer to the same memory as the same address in another process.

  3. A syscall is a request made by the program to the operating system kernel. It requires a context switch to the kernel, and another context switch back to the program.