Debug a process by hand from a c program on an Apple Silicon CPU

Hello,

My purpose is to understand how macOS works.

Here is what i've done: I have wrote a c program on a M1 CPU with this lines:

printf("Before breakpoint\n");
asm volatile("brk #0"); 
printf("After breakpoint\n");

When i run this program with lldb, a breakpoint is hit on the second line. So i suppose lldb is writing a "brk #0" instruction when we put a breakpoint manually.

I can't continue to next line with lldb "c" command. PC stays on the brk instruction. I need to manually set PC to next instruction in lldb.

Now, what i want to do is to create my own debugger. (I want to understand how lldb works).

I have managed to ptrace the target program and i was able to catch an event with waitpid when "brk #0" is hit. But i don't know how i can increase PC value and continue execution because i can't do this on Silicon CPU:

ptrace(PTRACE_GETREGS, child_pid, NULL, &regs);
ptrace(PTRACE_SETREGS, child_pid, NULL, &regs);
kill(child_pid, SIGCONT);

So my question is: How does lldb managed to change ARM64 registers of a remote process ?

Thanks

Your asm is between the two printfs. But I don't think the debugger can do that - it would need to move all the following code along by one to make space to insert it, which would break everything. So I presume that, if the debugger works in this way at all, then it must replace an existing instruction with the "brk". When it continues it must temporarily put the original instruction back. In this case, you don't need to increase the pc value.

I know that doesn't answer your question, and I have no idea how the debugger actually does this.

Source for lldb should be available.

Lemme preface this by saying that writing a debugger is really hard. It requires you to understand a lot of stuff:

  • CPU architecture — The techniques you use for Apple silicon will be quite different from the techniques you use for, say, Intel.

  • Runtime architecture — Even within the same CPU architecture, different platforms have different calling conventions, different ways to invoke system functionality, and so on.

  • Binary format — Technically this is part of the runtime architecture. Apple platforms use the Mach-O binary format. You’ll be able to find base information about this online, but Apple’s recent changes are not well documented. I have some notes about this in An Apple Library Primer.

  • Platform debugging infrastructure — Each platform has its own unique features here. That’s true even for closely related platforms. Notably, the macOS APIs for this are very different than the APIs on other Unix-y platforms. More on that below.

  • Compiler — To implement a source-level debugging you have to understand how the compiler maps source code to machine code. A compiler usually outputs this mapping as it builds the code. For Apple compilers that means understanding DWARF, which is ridiculously complicated in and of itself. If you want to support some other compiler, consult that compiler’s documentation.

I’m happy to help you with this stuff but you’ll end up doing all the heavy lifting. Given the time I have to spend here on DevForums, the best I’ll be able to do is offer pointers to other resources.


Let’s start with some breakpoint basics. The standard approach here is to replace the target instruction with a trap instruction [1]. The question, as you’ve noticed, is how to continue after the trap. There are two common techniques:

  • Emulate the instruction and then resume the thread.

  • Restore the original instruction, single step the thread, re-insert the breakpoint, and then resume. Note that you have to single step the thread and leave all the other threads suspended. Otherwise there’s a small chance that some other thread could miss your breakpoint.

Both of these require you to manipulate the thread’s state. On macOS you do this with Mach APIs [2]. Specifically:

  • Install yourself as a task exception handler. If you’re launching the inferior [3] that means posix_spawnattr_setspecialport_np.

  • That means writing a Mach exception handler, which means working with Mach messaging directly. This is one of the things that I specifically advise folks not to do O-:

  • Suspend and resume the task using task_suspend and task_resume.

  • Enumerate threads using task_threads.

  • Suspend and resume threads with thread_suspend and thread_resume.

  • Set registers with thread_get_state and thread_set_state.

None of these APIs are documented in the usual place. See Availability of Low-Level APIs for links to the documentation we do have. However, for Mach specifically you should search the ’net for conceptual docs. I also recommend:

  • Programming Under Mach by Boykin et al

  • Mac OS X Internals: A Systems Approach by Amit Singh

Both are wildly out of date, but they cover background that you’ll need to know.

Good luck!

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

[1] This is and of itself is tricky in a world of read-only code, which prevents you from modifying code, and code signing, where the system gets grumpy if a page’s hash doesn’t match the on-disk value.

[2] On other Unix-y systems you typically do this using ptrace. macOS does support ptrace but that’s mostly as a compatibility measure.

[3] If you’re trying to attach to an existing process things get even more complex.

Debug a process by hand from a c program on an Apple Silicon CPU
 
 
Q