Understanding Instruction Flow on AArch64 with LLDB
Past lessons in this series:
- Reversing 101 - introduction
- Preparing to Reverse
- Introduction to registers
- More on registers: the ABI
Introduction
In this post we finalise the discussion on the
BLinstruction, see how and why an assembly program flows, and introduce a powerful tool: the debugger. No, we won’t do the usual “hello world” rubbish. Not yet - and hopefully, never!
Note: We work on the macOS dialect of the AARCH64 ARM assembly, but I may happen to jump on Linux (and why not? some BSDs!). I’m not digressing. I think that seeing how different OS solve a given problem gives you a better understanding of the overarching structures. Sorry, no Windows here - I am not that good on that system anymore.
Hello, debugger!
I made a deliberate choice: avoid the “hello world” program. This is more to defy the laws of tradition rather than for any pedagogical reason. However, this choice forces me to give you a tool to see what’s going on in the machine: having no printouts is a severe limitation after all. But reversers need to sharpen their teeth at the beginning of their career on the tool they’ll use forever: the debugger. I learnt reversing this way, I teach it this way.
Another note: I use nano in these initial programs. On more complex projects, working with nano is not easy and you may want to choose an editor that fits your needs better. When it comes to assembly, VS Code is great — and so is Xcode. If you have better options, off you go, select the one you want.
Now open a terminal and create a new file - main.s:
nano main.s
Fill this new file with:
.global _main
.extern _exit
_main:
mov X11, #0x1723
mov X0, #0x0000 // exit code
bl _exit // invokes exit
This is not so different from the ASM_o_DETH we saw in https://bytearchitect.io/macos-security/security-reversing/Introduction-to-registers/ and if you followed the lesson https://bytearchitect.io/macos-security/security-reversing/More-on-registers-the-ABI/ you already know what this program does: it moves an immediate value (a constant, expressed in hexadecimal format) into a register (X11), then it exits with the value 0.
Then compile it:
gabrielebiondo@RevEng3 02-mov % as -mmacosx-version-min=14.0 main.s
gabrielebiondo@RevEng3 02-mov % file main.o
main.o: Mach-O 64-bit object arm64
and link it:
gabrielebiondo@RevEng3 02-mov % SDK=$(xcrun --sdk macosx --show-sdk-path)
gabrielebiondo@RevEng3 02-mov % ld -arch arm64 -platform_version macos 14.0 14.0 -syslibroot "$SDK" -lSystem -o hellodebugger main.o
gabrielebiondo@RevEng3 02-mov % ls -al
total 88
drwxr-xr-x@ 5 gabrielebiondo staff 160 12 Dec 07:14 .
drwxr-xr-x@ 8 gabrielebiondo staff 256 12 Dec 06:55 ..
-rwxr-xr-x@ 1 gabrielebiondo staff 33440 12 Dec 07:14 hellodebugger
-rw-r--r--@ 1 gabrielebiondo staff 408 12 Dec 07:11 main.o
-rw-r--r--@ 1 gabrielebiondo staff 133 12 Dec 07:01 main.s
gabrielebiondo@RevEng3 02-mov % file hellodebugger
hellodebugger: Mach-O 64-bit executable arm64
If you read the previous lessons, the syntax should be quite easy to understand. Here we just forced the minimum macOS version compatibility, but you could also do the same with the default values:
gabrielebiondo@RevEng3 02-mov % mv main.o main14.o
gabrielebiondo@RevEng3 02-mov % as main.s
gabrielebiondo@RevEng3 02-mov % ls
hellodebugger main.o main.s main14.o
gabrielebiondo@RevEng3 02-mov % file main.o
main.o: Mach-O 64-bit object arm64
gabrielebiondo@RevEng3 02-mov % ld -arch arm64 -syslibroot $SDK -o hellodebugger26 -lSystem
ld: Missing -platform_version option
gabrielebiondo@RevEng3 02-mov % ld \
-arch arm64 \
-platform_version macos 26.0 26.0 \
-syslibroot "$SDK" \
-lSystem \
-o hellodebugger26 \
main.o
From now on, I assume you have no issues compiling and linking. Running this program gives you nothing (try if you want).
Interestingly, the fact that the code has been compiled for different macOS versions, in this case, does not change its size:
gabrielebiondo@RevEng3 02-mov % ls -al hellodebugger*
-rwxr-xr-x@ 1 gabrielebiondo staff 33440 12 Dec 07:14 hellodebugger
-rwxr-xr-x@ 1 gabrielebiondo staff 33440 12 Dec 07:17 hellodebugger26
Well, so far nothing new. In the same terminal now run:
lldb hellodebugger
or lldb <name-of-the-executable-you-just-obtained>. You should obtain:
gabrielebiondo@RevEng3 02-mov % lldb hellodebugger
(lldb) target create "hellodebugger"
Current executable set to '/Users/gabrielebiondo/MiB/51 - Reversing 101/reversing-101/lessons/02-mov/hellodebugger' (arm64).
(lldb)
lldb is the main debugger we will use for the rest of this course. When working with Linux, we may end up using gdb and peda. We’ll revert to gdb also when working with other BSD flavours.
We have attached (technical term. This one is as horrible as immediate for constants!) lldb to a process. Now we can use it to debug the process.
When working with REPLs, the first thing you want to do is to invoke the help. In this case, you receive a wall of text. If I were to explain all the commands, concepts, and options, we’d be here for another dozen of lessons, so let’s start the hard way: I will explain commands when we use them, and for the minimum extent required for our goals.
If you are not fond with debuggers, you may think that the first thing you have to do is running the program:
(lldb) run
Process 55042 launched: '/Users/gabrielebiondo/reversing-101/lessons/02-mov/hellodebugger' (arm64)
Process 55042 exited with status = 0 (0x00000000)
and with contempt you realise that you cannot interact with the program. Actually this is how the debugger is meant to work: it stops at breakpoints. So you need to create a breakpoint. Do you remember that in past lessons I emphasised the _main symbol? Well, that symbol exists within the present scope, what you need is a breakpoint. It only has a different name - main, no trailing underscore.
If you do:
(lldb) breakpoint
Commands for operating on breakpoints (see 'help b' for shorthand.)
Syntax: breakpoint <subcommand> [<command-options>]
The following subcommands are supported:
clear -- Delete or disable breakpoints matching the specified source file and line.
command -- Commands for adding, removing and listing LLDB commands executed when a breakpoint is hit.
delete -- Delete the specified breakpoint(s). If no breakpoints are specified, delete them all.
disable -- Disable the specified breakpoint(s) without deleting them. If none are specified, disable all
breakpoints.
enable -- Enable the specified disabled breakpoint(s). If no breakpoints are specified, enable all of them.
list -- List some or all breakpoints at configurable levels of detail.
modify -- Modify the options on a breakpoint or set of breakpoints in the executable. If no breakpoint is
specified, acts on the last created breakpoint. With the exception of -e, -d and -i, passing an
empty argument clears the modification.
name -- Commands to manage breakpoint names
read -- Read and set the breakpoints previously saved to a file with "breakpoint write".
set -- Sets a breakpoint or set of breakpoints in the executable.
write -- Write the breakpoints listed to a file that can be read in with "breakpoint read". If given no
arguments, writes all breakpoints
you see all the options and switch that the command breakpoint accepts. Reading this contextual help is always a good thing to do, within REPLs. Now, let’s try with
(lldb) breakpoint list
No breakpoints currently set.
Now we create our first breakpoint with:
(lldb) breakpoint set --name main
Breakpoint 1: where = hellodebugger`main, address = 0x00000001000003c0
(lldb) breakpoint list
Current breakpoints:
1: name = 'main', locations = 1
1.1: where = hellodebugger`main, address = hellodebugger[0x00000001000003c0], unresolved, hit count = 0
A few notes:
- the REPL supports auto-completion
- in general,
lldbcommands have a short version. In fact we could do:
(lldb) breakpoint delete 1
1 breakpoints deleted; 0 breakpoint locations disabled.
(lldb) breakpoint list
No breakpoints currently set.
(lldb) b main
Breakpoint 2: where = hellodebugger`main, address = 0x00000001000003c0
(lldb) breakpoint list
Current breakpoints:
2: name = 'main', locations = 1
2.1: where = hellodebugger`main, address = hellodebugger[0x00000001000003c0], unresolved, hit count = 0
just as planned.
The next step is executing the program. We expect:
- the program stops as soon as it hits a breakpoint
- there must be a way to navigate through instructions and see what is going on.
let’s try now launching the run command:
(lldb) run
Process 55119 launched: '/Users/gabrielebiondo/reversing-101/lessons/02-mov/hellodebugger' (arm64)
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 2.1
frame #0: 0x00000001000003c0 hellodebugger`main
hellodebugger`main:
-> 0x1000003c0 <+0>: mov x11, #0x1723 ; =5923
0x1000003c4 <+4>: mov x0, #0x0 ; =0
0x1000003c8 <+8>: bl 0x1000003cc ; symbol stub for: exit
hellodebugger`exit:
0x1000003cc <+0>: adrp x16, 4
Target 0: (hellodebugger) stopped.
Great - the output is very dense, but tells you everything you need to know:
- a process (
PID 55119) has been launched. Its URL follows. - it is now stopped, the reason being
breakpoint 2.1 - the listing of the program follows
- first column is the memory address,
- the value between angle brackets (damn idiots that cannot say “lesser” and “greater than” have invented these other brackets. Again… terrible wording) denotes the offset in bytes from the beginning of the program,
- then it’s easy to recognise the program.
- the comments you see after the
;symbol are not part of the binary. They are added by LLDB, based on metadata produced by the linker.- pretty prints
- stubs
- etc.
I hope you have invoked the help before. If you didn’t, now it’s time to do so. You will see a command called register:
(lldb) register
Commands to access registers for the current thread and stack frame.
Syntax: register [read|write|info] ...
The following subcommands are supported:
info -- View information about a register.
read -- Dump the contents of one or more register values from the current frame. If no register is
specified, dumps them all.
write -- Modify a single register value.
It’s a good place to start - after all, by now you only have the concept of registers in quite an abstract fashion, and have a generic knowledge of what bl and mov do.
Let’s read the registers, then. All of them, hang the cost!
(lldb) register read
General Purpose Registers:
x0 = 0x0000000000000001
x1 = 0x000000016fdff0e0
x2 = 0x000000016fdff0f0
x3 = 0x000000016fdff260
x4 = 0x0000000000000001
x5 = 0x0000000000000010
x6 = 0x0000000000000041
x7 = 0x0000000000000000
x8 = 0x00000001000003c0 hellodebugger`main
x9 = 0x0000000201920df0 dyld`lsl::sPoolBytes + 3408
x10 = 0x000000016fdfe568
x11 = 0x00000002019400a0
x12 = 0x0000000000020000
x13 = 0x0000000194e59000
x14 = 0x0000000000000001
x15 = 0x0000000000000055
x16 = 0x0000000195227884 libsystem_platform.dylib`os_unfair_lock_unlock
x17 = 0x0000000203204278
x18 = 0x0000000000000000
x19 = 0x0000000201920060 lsl::sAllocatorBuffer
x20 = 0x0000000201bca9c8 lsl::sMemoryManagerBuffer
x21 = 0x0000000201920df0 dyld`lsl::sPoolBytes + 3408
x22 = 0xfffffffffffffff0
x23 = 0x0000000201bce0e0 dyld`vm_page_mask
x24 = 0x0000000000000001
x25 = 0x000000016fdfec60
x26 = 0x0000000201bce0f0 dyld`mach_task_self_
x27 = 0x0000000000000000
x28 = 0x0000000000000000
fp = 0x000000016fdff0c0
lr = 0x0000000194e61d54 dyld`start + 7184
sp = 0x000000016fdfea70
pc = 0x00000001000003c0 hellodebugger`main
cpsr = 0x600018000
plenty of stuff here. Not dense, anyway - there are a lot of registers, so a lot of lines - but lines are not that full of information. We will be more specific from now on - I think there is no need to explain the syntax here:
(lldb) register read x0
x0 = 0x0000000000000001
(lldb) register read x0 x11
x0 = 0x0000000000000001
x11 = 0x00000002019400a0
Great. We also observe that some of the registers mention the current breakpoint, hellodebugger'main (yes, it must be a backtick. I use markdown and my current editor stinks when it comes to special characters: the backtick is a reserved markdown symbol. Sorry). Let’s keep them under control in our probes:
(lldb) register read x0 x8 x11 pc
x0 = 0x0000000000000001
x8 = 0x00000001000003c0 hellodebugger`main
x11 = 0x00000002019400a0
pc = 0x00000001000003c0 hellodebugger`main
Ok, now we need to move away from the current instruction - which means:
- having the current instruction
0x1000003c0 <+0>: mov x11, #0x1723executed - jump magically (by now!) to the next instruction to be executed, which should be
0x1000003c4 <+4>: mov x0, #0x0
Now, if you’re here and you haven’t read the help yet, you won the prize “I just installed Kali Linux what now”. Otherwise you have seen the step and stepi commands. They do exactly what the help tells you: execute the current instruction and jump magically to the next:
(lldb) step
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003c4 hellodebugger`main + 4
hellodebugger`main:
-> 0x1000003c4 <+4>: mov x0, #0x0 ; =0
0x1000003c8 <+8>: bl 0x1000003cc ; symbol stub for: exit
hellodebugger`exit:
0x1000003cc <+0>: adrp x16, 4
0x1000003d0 <+4>: ldr x16, [x16]
Let’s compare the previous state of the registers with the current one. The previous state is above, I write it again here for your convenience:
Before step
(lldb) register read x0 x8 x11 pc
x0 = 0x0000000000000001
x8 = 0x00000001000003c0 hellodebugger`main
x11 = 0x00000002019400a0
pc = 0x00000001000003c0 hellodebugger`main
After step
(lldb) register read x0 x8 x11 pc
x0 = 0x0000000000000001
x8 = 0x00000001000003c0 hellodebugger`main
x11 = 0x0000000000001723
pc = 0x00000001000003c4 hellodebugger`main + 4
Observations:
- the value of
X11actually changed to0x1723. Great! - The value of this strange
pcchanged to0x00000001000003c4 hellodebugger'main + 4. Hmmm… interesting.- we conjecture that if we do another
step(or simplys):- the value of
X0becomes 0, and even more interestingly: - the value of
pcis incremented by 4 more bytes.
- the value of
- we conjecture that if we do another
Let us validate our hypotheses:
(lldb) s
Process 55119 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = instruction step into
frame #0: 0x00000001000003c8 hellodebugger`main + 8
hellodebugger`main:
-> 0x1000003c8 <+8>: bl 0x1000003cc ; symbol stub for: exit
hellodebugger`exit:
0x1000003cc <+0>: adrp x16, 4
0x1000003d0 <+4>: ldr x16, [x16]
0x1000003d4 <+8>: br x16
Target 0: (hellodebugger) stopped.
(lldb) register read x0 x8 x11 pc
x0 = 0x0000000000000000
x8 = 0x00000001000003c0 hellodebugger`main
x11 = 0x0000000000001723
pc = 0x00000001000003c8 hellodebugger`main + 8
Just as planned. Now, observe the instruction to be executed, namely:
-> 0x1000003c8 <+8>: bl 0x1000003cc ; symbol stub for: exit
and look at what we have at the address 0x1000003c8:
hellodebugger`exit:
0x1000003cc <+0>: adrp x16, 4
0x1000003d0 <+4>: ldr x16, [x16]
0x1000003d4 <+8>: br x16
Technically speaking hellodebugger'exit: is a label for another piece of code. What that code does is outside the scope of the current post but - again: remember what we said with regards to bl: it branches to a label. Et voila!
Dense lesson, but there is more to that
Technical Analysis
We have seen that every time an instruction is executed, the register named pc is incremented by 4. Why?
The program counter
The register pc is called program counter. It is a 64-bit register (8 bytes) that always contains the address of the instruction currently executed.
We did not mention it yet, but if you have looked at the mov page in the ARM site (https://developer.arm.com/documentation/ddi0597/2025-09/Base-Instructions/MOV–MOVS–immediate—Move–immediate–) you should have noticed that there is a row of 32 values. This is because every AArch64 instruction is 32 bits (4 bytes) in size.
Whenever the execution of an instruction terminates, the register is updated. This actually implements the sequential execution flow of the code.
Conclusion
At this point, we have seen enough to draw a few solid conclusions.
First, instruction execution on AArch64 is strictly sequential by default. Each instruction is 32 bits wide, and once an instruction completes, the pc is advanced by 4 bytes. This simple mechanism is what implements the linear flow of execution we intuitively expect when reading assembly code.
Second, stepping through instructions with a debugger makes this mechanism explicit. Instructions modify registers, and only the registers explicitly targeted by an instruction are affected. Nothing “magical” happens: the machine does exactly what the instruction encoding prescribes, no more and no less.
Finally, the debugger is not an optional accessory. It is the tool that bridges static code and runtime behaviour. Reading disassembly tells you what could happen; stepping through execution tells you what actually happens. From this point onward, we will rely on the debugger to validate assumptions, test hypotheses, and reason about control flow with precision.
One last note on bl: for the purpose of this post, we have seen enough. We observed that bl alters the control flow by branching to a different piece of code, and we verified this behavior directly with the debugger. That is all we needed here. The mechanics behind what happens around the branch — return addresses, link registers, and calling conventions — will be revisited later, when we have the proper context to reason about them rigorously.
This is the foundation. Everything else builds on top of it.
References
- https://developer.arm.com/documentation/ddi0597/2025-09/Base-Instructions/MOV–MOVS–immediate—Move–immediate–
- Advanced Apple Debugging & Reverse Engineering
Next Lesson
The next lesson will focus even more on the debugger, and we will see some other instructions. It takes time to understand registers, and in the next lesson we will spipple again with PC. Fasten your seatbelt, the next post will be here shortly.
Want the deep dive?
If you’re a security researcher, incident responder, or part of a defensive team and you need the full technical details (labs, YARA sketches, telemetry tricks), email me at info@bytearchitect.io or DM me on X (@reveng3_org). I review legit requests personally and will share private analysis and artefacts to verified contacts only.
Prefer privacy-first contact? Tell me in the first message and I’ll share a PGP key.
Subscribe to The Byte Architect mailing list for release alerts and exclusive follow-ups.
Gabriel(e) Biondo
ByteArchitect · RevEng3 · Rusted Pieces · Sabbath Stones