Chapters

Hide chapters

Advanced Apple Debugging & Reverse Engineering

Fourth Edition · iOS 16, macOS 13.3 · Swift 5.8, Python 3 · Xcode 14

Section I: Beginning LLDB Commands

Section 1: 10 chapters
Show chapters Hide chapters

Section IV: Custom LLDB Commands

Section 4: 8 chapters
Show chapters Hide chapters

12. Assembly & Memory
Written by Walter Tyree

You’ve begun the journey and learned the dark arts of the calling convention in the previous chapter. When a function is called, you now know how parameters are passed to functions, and how function return values come back. What you haven’t learned yet is how code is executed when it’s loaded into memory.

In this chapter, you’ll explore how a program executes. You’ll look at a special register used to tell the processor where it should read the next instruction from, as well as how different sizes and groupings of memory can produce very different results.

Reviewing Reading Assembly

As you saw in the previous chapter, assembly instructions contain an opcode, a source and a destination. During the course of history, there have been two formats for the assembly code, called Intel and AT&T. They changed around the order of source and destination, and used different leading characters to denote registers, constants, etc. The default format for LLDB is Intel. It places the destination as the first argument after the opcode.

opcode  destination source

If you ever encounter a disassembly where those things are reversed, or where the registers are all prefixed with % symbols, you are reading AT&T format. Depending on what system you’re using at the time, there should be a setting to swap formats.

Before you move forward, another change to your LLDB setup will make some things a little easier. Before your code can be executed, functions need to make space in memory and get all of the values into the right registers or into the right order on the stack. This is called the function prologue. After completing its work, a function needs to put everything back and clean up. This is the function epilogue.

Because these two parts aren’t particularly relevant to the logic of a function, LLDBs default is to skip over them when you’ve set a breakpoint. However, as you’re learning, seeing how the prologue moves things around is important. So, you’ll change this setting.

Add the following line to the bottom of your ~/.lldbinit file:

settings set target.skip-prologue false

This line tells LLDB to not skip the function prologue. You came across this earlier in this book, and from now on it’s prudent to not skip the prologue since you’ll be inspecting assembly right from the first instruction in a function.

Note: When editing your ~/.lldbinit file, make sure you don’t use a program like TextEdit for this, as it will add unnecessary characters into the file that could result in LLDB not correctly parsing the file. An easy (although dangerous) way to add this is through a Terminal command like so: echo "settings set target.skip-prologue false" >> ~/.lldbinit.

Make sure you have two ‘>>’ in there or else you’ll overwrite all your previous content in your ~/.lldbinit file. If you’re not comfortable with the Terminal, editors like nano (which you’ve used earlier) are your best bet.

Creating the cpx Command

First of all, you’re going to create your own LLDB command to help later on.

Open ~/.lldbinit again in your favorite text editor (vim, right?). Then add the following to the bottom of the file:

command alias -H "Print value in ObjC context in hexadecimal" -h "Print in hex" -- cpx expression -f x -l objc --

This command, cpx, is a convenience command you can use to print out something in hexadecimal format, using the Objective-C context. This will be useful when printing out register contents.

Remember, registers aren’t available in the Swift context, so you need to use the Objective-C context instead.

Now you have the tools needed to explore memory in this chapter through an assembly point of view!

Bits, Bytes and Other Terminology

Before you begin exploring memory, you need to be aware of some vocabulary about how memory is grouped. A value that can contain either a 1 or a 0 is known as a bit. You can say there are 64 bits per address in a 64-bit architecture. Simple enough.

When there are 8 bits grouped together, they’re known as a byte. How many unique values can a byte hold? You can determine that by calculating 2^8 which will be 256 values, starting from 0 and going to 255.

Lots of information is expressed in bytes. For example, the C sizeof() function returns the size of the object in bytes.

If you are familiar with ASCII character encoding, you’ll recall all ASCII characters can be held in a single byte.

It’s time to take a look at this terminology in action and learn some tricks along the way.

Open up the Registers macOS application, which you’ll find in the resources folder for this chapter. Next, build and run the app. Once it’s running, pause the program and bring up the LLDB console. As mentioned previously, this will result in the non-Swift debugging context being used.

(lldb) p sizeof('A')

This will print out the number of bytes required to make up the A character:

(unsigned long) $0 = 1

Next, type the following:

(lldb) p/t 'A'

You’ll get the following output:

(char) $1 = 0b01000001

This is the binary representation for the character A in ASCII.

Another more common way to display a byte of information is using hexadecimal values. Two hexadecimal digits are required to represent a byte of information in hexadecimal.

Print out the hexadecimal representation of A using your new command, or just use p/x if you decided not to add it:

(lldb) cpx 'A'

You’ll get the following output:

(char) $2 = 0x41

Hexadecimal is great for viewing memory because a single hexadecimal digit represents exactly 4 bits. So if you have 2 hexadecimal digits, you have 1 byte. If you have 8 hexadecimal digits, you have 4 bytes. And so on.

Here are a few more terms for you that you’ll find useful in the chapters to come:

  • Nybble: 4 bits, a single value in hexadecimal
  • Half word: 16 bits, or 2 bytes
  • Word: 32 bits, or 4 bytes
  • Double word or Giant word: 64 bits or 8 bytes.

With this terminology, you’re all set to explore the different memory chunks.

The Program Counter Register

When a program executes, code to be executed is loaded into memory. The location of which code to execute next in the program is determined by one magically important register: the pc , program counter or instruction pointer register.

You’ll now see this register in action. Open the Registers application again and navigate to AppDelegate.swift. Modify the file so it contains the following code:

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

  func applicationWillBecomeActive(
    _ notification: Notification) {
      print("\(#function)")
      self.aBadMethod()
  }

  func aBadMethod() {
    print("\(#function)")
  }

  func aGoodMethod() {
    print("\(#function)")
  }
}

Build and run the application. Unsurprisingly, the method name, applicationWillBecomeActive(_:), appears in the debug console, followed by the aBadMethod. There will be no execution of aGoodMethod.

Create a breakpoint at the very beginning of the aBadMethod using the Xcode GUI:

Build and run again. Once the breakpoint hits at the beginning of the aBadMethod, use the Debug menu in Xcode to Debug ▸ Debug Workflow ▸ Always Show Disassembly. You’ll now see the actual assembly of the program!

Next, type the following into the LLDB console:

(lldb) cpx $pc

This prints out the instruction pointer register using the cpx command you created earlier.

You’ll notice the output LLDB spits out will match the address highlighted by the green line in Xcode:

(unsigned long) $1 = 0x0000000100dfda78

It’s worth noting your address could be different than the above output, but the address of the green line and the pc console output will match. If they don’t match then you likely didn’t adjust the prologue setting from the beginning of this chapter and your green line is just before the bl opcode. Now, enter the following command in LLDB:

(lldb) image lookup -vrn ^Registers.*aGoodMethod

This is the tried-and-true image lookup command with the typical regular expression arguments plus an added argument, -v, which dumps the verbose output.

You’ll get a fair bit of content. Search for the content immediately following range = [; pressing Command-F may prove useful here. It’s the first value in the range brackets that you’re looking for.

This address is known as the load address. This is the actual physical address of this function in memory.

This differs from the usual output you’ve seen in the image lookup command, in that it only displays the offset of the function relative to the executable, also known as the implementation offset. When hunting for a function’s address, it’s important to differentiate the load address from the implementation offset in an executable, as it will differ.

Copy this new address at the beginning of the range brackets. For this particular example, the load address of aGoodMethod is located at 0x0000000100dfdc48. Now, write this address which points the beginning of the aGoodMethod method to the pc register.

(lldb) register write $pc 0x0000000100dfdc48

Click continue using the Xcode debug button. It’s important you do this instead of typing continue in LLDB, as there is a bug that will trip you up when modifying the pc register and continuing in the console.

After pressing the Xcode continue button, you’ll see that aBadMethod() is not executed and aGoodMethod() is executed instead. Verify this by viewing the output in the console log.

Note: Modifying the pc register is actually a bit dangerous. According to the ARM documentation, the pc register is read-only on 64-bit systems. You need to make sure the registers holding data for a previous value in the pc register do not get applied to a new function which would make an incorrect assumption with the registers. Since aGoodMethod and aBadMethod are very similar in functionality, you’ve stopped at the beginning, and as no optimizations were applied to the Registers application, this is not a worry.

Registers and Breaking Up the Bits

As mentioned in the previous chapter, arm64 has 31 general purpose registers: x0 - x30. In order to maintain compatibility with previous architectures, such as a 32-bit architecture, registers can be broken up into their 32, 16, or 8-bit values.

For registers that have had a history across different architectures, the frontmost character in the name given to the register determines the size of the register. For example, the x0 register starts with x, which signifies 64 bits. If you wanted the 32 bit equivalent of the x0 register, you’d swap out the x character with an w, to get the w0 register.

Additionally, ARM64 has a set of vector or floating point registers. These registers are 128-bits each. The floating point registers begin with v. They can be broken into 64-bits by prefixing with a d or 32-bits by prefixing with an s. For now, just think about the integer registers, and x or w.

Why is this useful? When working with registers, sometimes the value passed into a register does not need to use all 64 bits. For example, consider the Boolean data type.

All you really need is a 1 or a 0 to indicate true or false, right? Based upon the languages features and constraints, the compiler knows this and will sometimes only write information to certain parts of a register.

Let’s see this in action.

Remove all breakpoints in the Registers project. Build and run the project. Now, pause the program out of the blue.

Once stopped, type the following:

(lldb) register write x0 0x0123456789ABCDEF

This writes a value to the x0 register.

Let’s halt for a minute. A word of warning: You should be aware that writing to registers could cause your program to tank, especially if the register you write to is expected to have a certain type of data. But you’re doing this in the name of science, so don’t worry if your program does crash!

Confirm that this value has been successfully written to the x0 register:

(lldb) cpx $x0

Since this is a 64-bit program, you’ll get a double word, i.e. 64 bits, or 8 bytes, or 16 hexadecimal digits.

Now, try printing out the w0 register:

(lldb) cpx $w0

The w0 register is the least-significant half of the x0 register. So you’ll only see the least-significant half of the double word, i.e., a word. You should see the following:

0x89abcdef

Keep an eye out for registers with different sizes when exploring assembly. The size of the registers can give clues about the values contained within.

Breaking Down the Memory

Now that you’ve taken a look at the program counter, it’s time to explore further the memory behind it.

The counter is actually a pointer. It’s not executing the instructions stored in the pc register — it’s executing the instructions pointed to in the pc register.

Seeing this in LLDB will perhaps describe it better. Back in the Registers application, open AppDelegate.swift and once again set a breakpoint on aBadMethod. Build and run the app.

Once the breakpoint is hit and the program is stopped, navigate back to the assembly view. If you forgot, and haven’t created a keyboard shortcut for it, it’s found under Debug ▸ Debug Workflow ▸ Always Show Disassembly.

You’ll be greeted by the onslaught of opcodes and registers. Take a look at the location of the pc register, which should be pointing to the very beginning of the function.

For this particular build, the beginning address of aBadMethod begins as 0x100685a78. As usual, your address will likely be different.

In the LLDB console, type the following:

(lldb) cpx $pc

As you know by now, this prints out the contents of the program counter register.

As expected, you’ll get the address of the start of aBadMethod. But again, the pc register points to a value in memory. What is it pointing to?

Well… you could dust off your mad C coding skillz (you remember those, right?) and dereference the pointer, but there’s a much more elegant way to go about it using LLDB.

Type the following, replacing the address with the address of your aBadMethod function:

(lldb) memory read -fi -c1 0x100685a78

Wow, what the heck does that command do?!

memory read takes a value and reads the contents pointed at by the memory address you supply. The -f command is a formatting argument; in this case, it’s the assembly instruction format. Finally you’re saying you only want one assembly instruction to be printed out with the count, or -c argument.

You’ll get output that looks similar to this:

->  0x100685a78: 0xd10383ff   sub    sp, sp, #0xe0

This here is some gooooooooood output. It’s telling you the assembly instruction, as well as the opcode, provided in hexadecimal (0xd10383ff) that is responsible for the sub operation.

Look at that “d10383ff” there in the output some more. This is an encoding of the entire instruction, i.e. the whole pushq rbp. Don’t believe me? You can verify it. Type the following into LLDB:

(lldb) expression -f i -l objc -- 0xd10383ff

The i format asks LLDB to decode 0xd10383ff into an opcode format. You’ll get the following output:

(unsigned int) $1 = 0xd10383ff   sub    sp, sp, #0xe0

That command is a little long, but it’s because you need the required switch to Objective-C context if you are in the Swift debugging context. However, if you move to the Objective-C debugging context, you can use a convenience expression that is a lot shorter.

Try clicking on a different frame in the left panel of Xcode to get into an Objective-C context which doesn’t contain Swift or Objective-C/Swift bridging code.

Click on any frame which is in an Objective-C function.

Next, type the following into the LLDB console:

(lldb) p/i 0xd10383ff

Much better, right?

Now, back to the application in hand. Type the following into LLDB, replacing the address once again with your aBadMethod function address:

(lldb) memory read -fi -c4 0x1005eda78

You’ll get 10x the output! That’s something worthy to put on that LinkedIn résumé…

By the way, there’s a shorthand convenience way to execute the above command. You can simply type the following to achieve the same result.

(lldb) x/4i 0x1005eda78

With either command you choose, you’ll get something similar to following output:

0xd10383ff   sub    sp, sp, #0xe0
0xa90c4ff4   stp    x20, x19, [sp, #0xc0]
0xa90d7bfd   stp    x29, x30, [sp, #0xd0]
0x910343fd   add    x29, sp, #0xd0

There’s something interesting to note here: arm64 instructions can have variable lengths when decoded, but are always encoded to 4 bytes. Also, based on the way you’ve been working, you might think that the byte stored at memory address 0x1005eda78 is d1, the first part of the first instruction encoding.

Narrator: it’s not.

Perhaps now would be a good time to talk about endianness.

Endianness… This Stuff Is Reversed?

The ARM family architecture devices all use little-endian, which means that data is stored in memory with the least significant byte first. If you were to store the number 0xabcd in memory, the 0xcd byte would be stored first, followed by the 0xab byte.

Back to the instruction example, this means that the instruction 0xd10383ff will be stored in memory as 0xff, followed by 0x83, followed by 0x03 and finally 0xd1.

Thinking about opcode encoding, if you’d been reading the memory and tried to decode the opcode without remembering endianness, you might type:

(lldb) p/i 0xff8303d1

You’d now get a most unhelpful opcode:

0xff8303d1   .long  0xff8303d1 ; unknown opcode

Let’s see some more examples of little-endian in action. Type the following into LLDB:

(lldb) memory read -s1 -c20 -fx 0x1005eda78

This command reads the memory at address 0x1005eda78. It reads in size chunks of 1 byte thanks to the -s1 option, and a count of 20 thanks to the -c20 option.

You’ll see something like this:

0x1005eda78: 0xff 0x83 0x03 0xd1 0xf4 0x4f 0x0c 0xa9
0x1005eda80: 0xfd 0x7b 0x0d 0xa9 0xfd 0x43 0x03 0x91
0x1005eda88: 0xe8 0x03 0x14 0xaa

Now, double the size and half the count like so:

(lldb) memory read -s2 -c10 -fx 0x1005eda78

You will see something like this:

0x1005eda78: 0x83ff 0xd103 0x4ff4 0xa90c 0x7bfd 0xa90d 0x43fd 0x9103
0x1005eda88: 0x03e8 0xaa14

Notice how when the memory values are grouped together, they are reversed thanks to being in little-endian.

Now double the size and half the count again:

(lldb) memory read -s4 -c5 -fx 0x1005eda78

And now you’ll get something like this:

0x1005eda78: 0xd10383ff 0xa90c4ff4 0xa90d7bfd 0x910343fd
0x1005eda88: 0xaa1403e8

Once again the values are reversed compared to the previous output.

This is very important to remember and also a source of confusion when exploring memory. Not only will the size of memory give you a potentially incorrect answer, but also the order. Remember this when you start yelling at your computer when you’re trying to figure out how something should work!

Key Points

  • The default format for assembly in LLDB is opcode destination source which is referred to as “Intel” format.
  • LLDB skips the function prologue when a breakpoint drops into assembly. You can change this using the target.skip-prologue setting.
  • A bit is a single 0 or 1 value. Bits are grouped into larger chunks called nibbles (4 bits), bytes (8 bits0), words (32 bits) and double words (64 bits).
  • Use register read and register write to manipulate the values in the registers during an LLDb session.
  • The pc register is technically read-only, but you can write to it at the risk of crashing everything.
  • ARM64 uses a w prefix to refer to the lower 32-bits of any x register.
  • Assembly opcodes and parameters are encoded into 4-byte groups regardless of how long they are.
  • ARM64 uses little-endian encoding where the least significant byte is stored first.

Where to Go From Here?

Good job getting through this one. Memory layout can be a confusing topic. Try exploring memory on other devices to make sure you have a solid understanding of the little-endian architecture and how assembly is grouped together.

In the next chapter, you’ll explore the stack frame and how a function gets called.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.