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

11. Assembly Register Calling Convention
Written by Walter Tyree

Now that you’ve gained a basic understanding of how to maneuver around the debugger, it’s time to take a step down the executable Jenga tower and explore the 1s and 0s that make up your source code. This section will focus on the low-level aspects of debugging.

In this chapter, you’ll look at registers the CPU uses and explore and modify parameters passed into function calls. You’ll also learn about common Apple computer architectures and how their registers are used within a function. This is known as an architecture’s calling convention.

Knowing how assembly works and how a specific architecture’s calling convention works is an extremely important skill to have. It lets you observe function parameters you don’t have the source code for and lets you modify the parameters passed into a function. In addition, it’s sometimes even better to go to the assembly level because your source code could have different or unknown names for variables you’re not aware of.

For example, let’s say you always wanted to know the second parameter of a function call, regardless of what the parameter’s name is. Knowledge of assembly gives you a great base layer to manipulate and observe parameters in functions.

Assembly 101

Wait, so what’s assembly again?

Have you ever stopped in a function you didn’t have source code for, and saw an onslaught of memory addresses followed by cryptic, short commands? Did you huddle in a ball and quietly whisper to yourself you’ll never look at this dense stuff again? Well… that stuff is known as assembly!

Here’s a picture of a backtrace in Xcode, which showcases the assembly of a function within the Simulator.

Looking at the image above, the assembly can be broken into several parts. Each line in an assembly instruction contains an opcode, which can be thought of as an extremely simple instruction for the computer.

So what does an opcode look like? An opcode is an instruction that performs a simple task on the computer. For example, consider the following snippet of assembly:

stp    x0, x1, [sp]
sub    x0, x8, #0x3
mov    x8, x20

In this nonsense block of assembly, you see three opcodes, stp, sub, and mov. Think of the opcode items as the action to perform. The things following the opcode are the source and destination labels. That is, these are the items the opcode acts upon.

In the above example, there are several registers, shown as x0, x8, x1, and sp. Most registers begin with x or r but there are some special use registers like sp, fp and xzr.

In addition, you can also find a numeric constant in hexadecimal shown as 0x3. The # before this constant tells you it’s an absolute number. A hexadecimal number by itself is almost always a memory location.

There’s no need to know what this code is doing at the moment, since you’ll first need to learn about the registers and calling convention of functions. Then you’ll learn more about the opcodes and write your own assembly in a future chapter. Remember, though, the focus in this book is to be able to read and follow assembly to help in debugging, not to write a bunch of assembly.

x86_64 vs ARM64

As a developer for Apple platforms, there are two primary architectures you’ll deal with when learning assembly: x86_64 architecture and arm64 architecture. x86_64 was the architecture used on macOS computers with “Intel” CPUs. ARM64 is the architecture used on iOS devices and macOS computers with “Apple Silicon”.

arm64 is a 64-bit architecture, which means every address can hold up to 64 1s or 0s. Alternatively, older Macs and older iOS devices use a 32-bit architecture, but Apple stopped making 32-bit Macs at the end of the 2010’s. As of the writing of this book, Apple has also almost completed its transition away from x86_64 to Apple Silicon. To assist owners of Apple Silicon computers with running older x86_64 software, Apple created the Rosetta2 technology.

If you have any doubt of what hardware architecture you’re working with, you can get your computer’s hardware architecture by running the following command in Terminal:

uname -m

ARM emphasizes power conservation, so it has a reduced set of opcodes compared to x86 that help facilitate energy consumption over complex assembly instructions. This is good news for you, because there are fewer instructions for you to learn on the ARM architecture.

Apple originally shipped 32-bit ARM processors in many of their devices, but have since moved to 64-bit ARM processors. 32-bit devices are basically obsolete as Apple has phased them out through various iOS versions. For example, iOS 16 does not support any 32-bit devices.

Since it’s best to focus on what you’ll need for the future, this book focuses primarily on arm64 assembly.

arm64 Register Calling Convention

Your CPU uses a set of registers in order to manipulate data in your running program. These are storage holders, just like the RAM in your computer. However they’re located on the CPU itself very close to the parts of the CPU that need them. So these parts of the CPU can access these registers incredibly quickly. Also, there are a finite number of registers.

Most instructions involve one or more registers and perform operations such as writing the contents of a register to memory, reading the contents of memory to a register or performing arithmetic operations (add, subtract, etc.) on two registers.

In arm64, there are 31 general purpose registers used by the machine to manipulate data.

These registers are x0 through x30. You may also see registers referred to starting with w. Registers starting with w are referencing 32-bit numbers. If you’re working with Metal or some floating point math, then registers might start with s, d, q, h or b depending on their size. For now, just think about registers that start with x.

When you call a function in arm64, the manner and use of the registers follows a very specific convention. This dictates where the parameters to the function should go and where the return value from the function will be when the function finishes. This is important so code compiled with one compiler can be used with code compiled with another compiler.

Take a look at this simple Swift code:

let fName = "Zoltan"

When viewing code through assembly, the computer doesn’t care about names for variables; it only cares about locations in memory. Here is the assembly code to create the String object and initialize it.

0x1044757a8 <+124>: adrp   x0, 8
0x1044757ac <+128>: add    x0, x0, #0x770            ; "Zoltan"
0x1044757b0 <+132>: mov    w8, #0x6
0x1044757b4 <+136>: mov    x1, x8
0x1044757b8 <+140>: mov    w8, #0x1
0x1044757bc <+144>: str    w8, [sp, #0x4]
0x1044757c0 <+148>: and    w2, w8, #0x1
0x1044757c4 <+152>: bl     0x10447b3f0               ; symbol stub for: Swift.String.init(_builtinStringLiteral: Builtin.RawPointer, utf8CodeUnitCount: Builtin.Word, isASCII: Builtin.Int1) -> Swift.String

A short string like "Zoltan" in Swift is contained in a struct with the string data at the beginning and the length of the string at the end. The code above, makes some space for the "Zoltan" string in x0 and then adds the length value of #0x6 and stores the value in x1. The bl line calls the String.init function. That function expects the given registers will contain the appropriate values as shown above. When the String.init function returns, x0 will contain the memory location of the new String.

However, as soon as the function prologue (the beginning section of a function that prepares the stack and registers) finishes executing, the values in these registers will likely change. The generated assembly will likely overwrite the values stored in these registers, or just simply discard these references when the code has no more need of them.

This means as soon as you leave the start of a function (through stepping over, stepping in, or stepping out), you can no longer assume these registers will hold the expected values you want to observe, unless you actually look at the assembly code to see what it’s doing.

This calling convention heavily influences your debugging (and breakpoint) strategy. If you were to automate any type of breaking and exploring, you would have to stop at the start of a function call in order to inspect or modify the parameters without having to actually dive into the assembly.

Objective-C and Registers

As you learned in the previous section, registers use a specific calling convention. You can take that same knowledge and apply it to other languages as well.

When Objective-C executes a method, a special C function, objc_msgSend, is executed. There’s actually several different types of these functions, but objc_msgSend is the most widely used, as this is the heart of message dispatch. As the first parameter, objc_msgSend takes the reference of the object upon which the message is being sent. This is followed by a selector, which is simply just a char * specifying the name of the method being called on the object. Finally, objc_msgSend takes a variable amount of arguments within the function if the Selector specifies there should be parameters.

Let’s look at a concrete example of this in an iOS context:

[UIApplication sharedApplication];

The compiler will take this code and create the following pseudocode:

id UIApplicationClass = [UIApplication class];
objc_msgSend(UIApplicationClass, "sharedApplication");

The first parameter is a reference to the UIApplication class, followed by the sharedApplication selector. An easy way to tell if there are any parameters is to simply check for colons in the Objective-C Selector. Each colon will represent a parameter in a Selector.

Here’s another Objective-C example:

NSString *helloWorldString = [@"Can't Sleep; " stringByAppendingString:@"Coffee for dessert was unwise."];

The compiler will create the following (shown below in pseudocode):

NSString *helloWorldString;
helloWorldString = objc_msgSend(@"Can't Sleep; ", "stringByAppendingString:", @"Coffee for dessert was unwise.");

The first argument is an instance of an NSString (@"Can't Sleep; "), followed by the Selector, followed by a parameter which is also an NSString instance.

Using this knowledge of objc_msgSend, you can use the registers to help explore content, which you’ll do very shortly.

Putting Theory to Practice

For this section, you’ll be using a project supplied in this chapter’s resource bundle called Registers.

Open this project up through Xcode and give it a run.

This is a rather simple application which merely displays the contents of some registers. It’s important to note that this application can’t display the values of registers at any given moment; it can only display the values of registers during a specific function call. This means that you won’t see too many changes to the values of these registers since they’ll likely have the same (or similar) value when the function to grab the register values is called.

Now that you’ve got an understanding of the functionality behind the Registers macOS application, create a symbolic breakpoint for NSViewController’s viewDidLoad method. Remember to use “NS” instead of “UI”, since you’re working on a Cocoa application.

Build and rerun the application. Once the debugger has stopped, type the following into the LLDB console:

(lldb) register read

This will list all of the main registers at the paused state of execution. However, this is too much information. You should selectively print out registers and treat them as Objective-C objects instead.

If you recall, -[NSViewController viewDidLoad] will be translated into the following assembly pseudocode:

x0 = UIViewControllerInstance
x1 = "viewDidLoad"
objc_msgSend(x0, x1)

With the arm64 calling convention in mind, and knowing how objc_msgSend works, you can find the specific NSViewController that is being loaded.

Type the following into the LLDB console:

(lldb) po $x0

You’ll get output similar to the following:

<Registers.ViewController: 0x6080000c13b0>

This will dump out the NSViewController reference held in the x0 register, which as you now know, is the location of the first argument to the method. Remember that you can use $arg1 to refer to the register where the first argument is held.

In LLDB, it’s important to prefix registers with the $ character, so LLDB knows you want the value of a register and not a variable related to your scope in the source code. Yes, that’s different than the assembly you see in the disassembly view! Annoying, eh?

Note: The observant among you might notice whenever you stop on an Objective-C method, you’ll never see the objc_msgSend in the LLDB backtrace. This is because the objc_msgSend family of functions performs a b, or jump opcode command in assembly. This means that objc_msgSend acts as a trampoline function, and once the Objective-C code starts executing, all stack trace history of objc_msgSend will be gone. This is an optimization known as tail call optimization.

Try printing out the RSI register, which will hopefully contain the Selector that was called. Type the following into the LLDB console:

(lldb) po $x1

Unfortunately, you’ll get garbage output that looks something like this:

8211036373

Why is this?

An Objective-C Selector is basically just a char *. This means, like all C types, LLDB does not know how to format this data. As a result, you must explicitly cast this reference to the data type you want.

Try casting it to the correct type:

(lldb) po (char *)$x1

You’ll now get the expected:

"viewDidLoad"

Of course, you can also cast it to the Selector type to produce the same result:

(lldb) po (SEL)$x1

Now, it’s time to explore an Objective-C method with arguments. Since you’ve stopped on viewDidLoad, you can safely assume the NSView instance has loaded. A method of interest is the mouseUp: Selector implemented by NSView’s parent class, NSResponder.

In LLDB, create a breakpoint on NSResponder’s mouseUp: Selector and resume execution. If you can’t remember how to do that, here are the commands you need:

(lldb) b -[NSResponder mouseUp:]
(lldb) continue

Now, click on the application’s window. Make sure to click on the outside of the NSScrollView as it will gobble up your click and the -[NSResponder mouseUp:] breakpoint will not get hit.

As soon as you let go of the mouse or the trackpad, LLDB will stop on the mouseUp: breakpoint. Print out the reference of the NSResponder by typing the following into the LLDB console:

(lldb) po $x0

You’ll get something similar to the following:

<NSView: 0x11d62e010>

However, there’s something interesting with the Selector. There’s a colon in it, meaning there’s an argument to explore! Type the following into the LLDB console:

(lldb) po $x2

You’ll get the description of the NSEvent:

NSEvent: type=LMouseUp loc=(351.672,137.914) time=175929.4 flags=0 win=0x6100001e0400 winNum=8622 ctxt=0x0 evNum=10956 click=1 buttonNumber=0 pressure=0 deviceID:0x300000014400000 subtype=NSEventSubtypeTouch

How can you tell it’s an NSEvent? Well, you can either look online for documentation on -[NSResponder mouseUp:] or, you can simply use Objective-C to get the type:

(lldb) po [$x2 class]

Pretty cool, eh?

Sometimes it’s useful to use registers and breakpoints in order to get a reference to an object you know is alive in memory.

For example, what if you wanted to change the front NSWindow to red, but you had no reference to this view in your code, and you didn’t want to recompile with any code changes? You can simply create a breakpoint you can easily trip, get the reference from the register and manipulate the instance of the object as you please. You’ll try changing the main window to red now.

Note: Even though NSResponder implements mouseDown:, NSWindow overrides this method since it’s a subclass of NSResponder. You can dump all classes that implement mouseDown: and figure out which of those classes inherit from NSResponder to determine if the method is overridden without having access to the source code. An example of dumping all the Objective-C classes that implement mouseDown: is image lookup -rn '\ mouseDown:'

First remove any previous breakpoints using the LLDB console:

(lldb) breakpoint delete
About to delete all breakpoints, do you want to do that?: [Y/n]

Then type the following into the LLDB console:

(lldb) b "-[NSWindow mouseDown:]"
(lldb) continue

This sets a breakpoint for mouseDown

Tap on the application. Immediately after tapping, the breakpoint should trip. Then type the following into the LLDB console:

(lldb) po [$x0 setBackgroundColor:[NSColor redColor]]
(lldb) continue

Upon resuming, the NSWindow will change to red!

Swift and Registers

When exploring registers in Swift you’ll hit three hurdles that make assembly debugging harder than it is in Objective-C.

  1. First, registers are not available in the Swift debugging context. This means you have to get whatever data you want and then use the Objective-C debugging context to print out the registers passed into the Swift function. Remember that you can use the expression -l objc -O -- command, or alternatively use the cpo custom command you made in Chapter 9, “Persisting & Customizing Commands”. Fortunately, the register read command is available in the Swift context.

  2. Second, Swift is not as dynamic as Objective-C. In fact, it’s sometimes best to assume that Swift is like C, except with a very, very cranky and bossy compiler. If you have a memory address, you need to explicitly cast it to the object you expect it to be; otherwise, the Swift debugging context has no clue how to interpret a memory address.

  3. When Swift calls a function, it has no need to use objc_msgSend, unless you mark up a method to use @objc. In addition, Swift will oftentimes opt to remove the self register (x0) as the first parameter and instead place it on the stack.

This means that the x0 register, which originally held the instance to self, and the x1 register, which originally held the Selector in Objective-C, are freed up to handle parameters for a function. This is done in the name of “optimization”, but the compiler’s inconsistency results in incompatible code and tools which struggle to analyze Swift generated assembly.

It has also resulted in version updates for this book to be a major PITA, since the Swift authors seem to come up with a new calling convention each year for Swift.

In the Registers project, navigate to ViewController.swift and add the following function below viewDidLoad:

func executeLotsOfArguments(one: Int, two: Int, three: Int,
                            four: Int, five: Int, six: Int,
                            seven: Int, eight: Int, nine: Int,
                            ten: Int) {
    print("arguments are: \(one), \(two), \(three),
          \(four), \(five), \(six), \(seven),
          \(eight), \(nine), \(ten)")
}

Note: The print command should be one line, if it’s on multiple lines above, it’s just beacause of page geometry, enter it as one line.

Next, add the following to the end of viewDidLoad to call this new function with the appropriate arguments:

self.executeLotsOfArguments(
  one: 31, two: 32, three: 33, four: 34,
  five: 35, six: 36, seven: 37,
  eight: 38, nine: 39, ten: 40)

Put a breakpoint on the very same line as of the declaration of executeLotsOfArguments so the debugger will stop at the very beginning of the function. This is important, or else the registers might get clobbered if the function is actually executing.

Finally, remove the symbolic breakpoint you set on -[NSViewController viewDidLoad].

Build and run the app, then wait for the executeLotsOfArguments breakpoint to stop execution.

Again, a good way to start investigating is to dump the list registers. In LLDB, type the following:

(lldb) register read -f d

This will dump the registers and display the format in decimal by using the -f d option.

The output will look similar to the following:

General Purpose Registers:
        x0 = 1
        x1 = 8419065840  libswiftCore.dylib`type metadata for Any + 8
        x2 = 33
        x3 = 34
        x4 = 35
        x5 = 36
        x6 = 37
        x7 = 38
        x8 = 1
        x9 = 40
       x10 = 39
       x11 = 32
       x12 = 8419006000  libswiftCore.dylib`protocol witness table for Swift.Int : Swift.CustomStringConvertible in Swift
       x13 = 105553126854192
       x14 = 2161727825501079277 (0x000000010411c6ed) (void *)0x01f4c6b578000000
       x15 = 8401620944  (void *)0x00000001f4c71400: NSResponder
       x16 = 8401620944  (void *)0x00000001f4c71400: NSResponder
       x17 = -424042045551510852 (0x0000000199127abc) libobjc.A.dylib`-[NSObject release]
       x18 = 0
       x19 = 105553139451872
       x20 = 6103686920
       x21 = 105553160866960
       x22 = 0
       x23 = 4294967300
       x24 = 1
       x25 = 8434082064  @"Found circular dependency when loading dependencies for %@ and %@"
       x26 = 5192684800
       x27 = 21474836484
       x28 = 8359120896  AppKit`_OBJC_PROTOCOL_REFERENCE_$_NSSecureCoding
        fp = 6103687040
        lr = 4363202980  Registers`Registers.ViewController.viewDidLoad() -> () + 192 at ViewController.swift:61:3
        sp = 6103686528
        pc = 4363203312  Registers`Registers.ViewController.executeLotsOfArguments(one: Swift.Int, two: Swift.Int, three: Swift.Int, four: Swift.Int, five: Swift.Int, six: Swift.Int, seven: Swift.Int, eight: Swift.Int, nine: Swift.Int, ten: Swift.Int) -> () + 228 at ViewController.swift:67:13
      cpsr = 1610616832

As you can see things aren’t in quite the order you’d expect. In the console, now type:

(lldb) disassemble

Scroll back up to the top of the function and look for a number of stur commands. This is the function placing all of the numbers onto the stack. Notice that things are already kind of crazy. Register x0 gets placed on the stack first, then x11 contains the second value. Then things look ok, but x10 seems to be in the place of x8. This is all because of how swift uses the stack more for storage and how registers like x8 in addition to x0 and x1 have special uses, so you can’t just store regular values in there.

You may (or may not depending on the Swift version) also notice other parameters are stored in some of the other registers. While this is true, it’s simply a leftover from the code that sets up the stack for the remaining parameters. Remember, parameters after the sixth one go on the stack.

The Return Register

But wait — there’s more! So far, you’ve seen how registers are called in a function, but what about return values?

Fortunately, there is only one designated register for return values from functions: x0. Go back to executeLotsOfArguments and modify the function to return an Int:

func executeLotsOfArguments(one: Int, two: Int, three: Int,
                            four: Int, five: Int, six: Int,
                            seven: Int, eight: Int, nine: Int,
                            ten: Int) -> Int {
    print("arguments are: \(one), \(two), \(three), \(four),
          \(five), \(six), \(seven), \(eight), \(nine), \(ten)")
    return 100
}

In viewDidLoad, modify the function call to receive and ignore the String value.

override func viewDidLoad() {
    super.viewDidLoad()
    _ = self.executeLotsOfArguments(one: 1, two: 2,
          three: 3, four: 4, five: 5, six: 6, seven: 7,
          eight: 8, nine: 9, ten: 10)
}

Create a breakpoint somewhere in executeLotsOfArguments. Build and run again, and wait for execution to stop in the function. Next, type the following into the LLDB console:

(lldb) finish

This will finish executing the current function and pause the debugger again. At this point, the return value from the function should be in x0. Type the following into LLDB:

(lldb) re re x0 -fd

You’ll get something similar to the following:

     x0 = 100

Boom! Your return value!

Knowledge of the return value in x0 is extremely important as it will form the foundation of debugging scripts you’ll write in later sections.

Changing Around Values in Registers

In order to solidify your understanding of registers, you’ll modify registers in an already-compiled application.

Close Xcode and the Registers project. Open a Terminal window and launch the iPhone X Simulator. Do this by typing the following:

xcrun simctl list

You’ll see a long list of devices. Search for the latest iOS version for which you have a simulator installed. Underneath that section, find your favorite device. The one that you’ve been using to run the examples in this book will work best.

It will look something like this:

iPhone 14 (DE1F3042-4033-4A69-B0BF-FD71713CFBF6) (Shutdown)

The UUID is what you’re after. Use that to open the iOS Simulator by typing the following, replacing your UUID as appropriate:

open /Applications/Xcode.app/Contents/Developer/Applications/Simulator.app --args -CurrentDeviceUDID DE1F3042-4033-4A69-B0BF-FD71713CFBF6

Make sure the simulator is launched and is sitting on the home screen. You can get to the home screen by pressing Command + Shift + H. Once your simulator is set up, head over to the Terminal window and attach LLDB to the SpringBoard application:

lldb -n SpringBoard

This attaches LLDB to the SpringBoard instance running on the iOS Simulator! SpringBoard is the program that controls the home screen on iOS.

Note: Attaching to the SpringBoard requires that SIP, System Integrity Protection, is disabled. The procedure for disabling this was outline in Chapter 1. If you can’t or don’t want to disable SIP, launch a program you own in the simulator. Then in terminal use the pgrep command to find the process id for your app. Next, use lldb -p <the process id you found> and continue with the chapter. Choose an app with some Objective-C in it, like the Signals app.

Once attached, type the following into LLDB:

(lldb) p/x @"Yay! Debugging"

You should get some output similar to the following:

(__NSCFString *) $3 = 0x0000618000644080 @"Yay! Debugging!"

Take a note of the memory reference of this newly created NSString instance as you’ll use it soon. Now, create a breakpoint on UILabel’s setText: method in LLDB:

(lldb) br set -n  "-[UILabel setText:]" -C "po $x2 = 0x0000618000644080" -G1

The above breakpoint will stop on the -[UILabel setText:] Objective-C method. When that happens, it will assign the RDX register the value 0x0000618000644080, thanks to the -C or --command option. In addition, you’ve told LLDB to resume execution immediately after executing this command via the -G or --auto-continue option, which expects a boolean to determine if it should auto continue.

Take a step back and review what you’ve just done. Whenever UILabel’s setText: method gets hit, you’re replacing what’s in RDX — the third parameter — with a different NSString instance that says Yay! Debugging!.

Resume the debugger by using the continue command:

(lldb) continue

Explore the SpringBoard Simulator app and see what content has changed. Swipe up and down and observe the changes:

Try exploring other areas where modal presentations can occur, as this will likely result in a new UIViewController (and all of its subviews) being lazily loaded, causing the breakpoint action to be hit.

Although this might seem like a cool gimmicky programming trick, it provides an insightful look into how a limited knowledge of registers and assembly can produce big changes in applications you don’t have the source for.

This is also useful from a debugging standpoint, as you can quickly visually verify where the -[UILabel setText:] is executed within the SpringBoard application and run breakpoint conditions to find the exact line of code that sets a particular UILabel’s text.

To continue this thought, any UILabel instances whose text did not change also tells you something. For example, the UIButtons whose text didn’t change to Yay! Debugging! speaks for itself. Perhaps the UILabel’s setText: was called at an earlier time? Or maybe the developers of the SpringBoard application chose to use setAttributedText: instead? Or maybe they’re using SwiftUI or a private method that is not publicly available to third-party developers?

As you can see, using and manipulating registers can give you a lot of insight into how an application functions.

Key Points

  • Architectures define a calling convention which dictates where parameters to a function and its return value are stored.
  • In Objective-C, the x0 register is the reference of the calling NSObject, x1 is the Selector, x2 is the first parameter and so on.
  • In Swift, there’s still not a consistent register calling convention. For right now, the reference to “self” in a class is passed on the stack allowing the parameters to start with the x2 register. But who knows how long this will last and what crazy changes will take place as Swift evolves.
  • The x0 register is used for return values in functions regardless of whether you’re working with Objective-C or Swift.
  • Make sure you use the Objective-C context when printing registers with $.

Where to Go From Here?

Whew! That was a long one, wasn’t it? Sit back and take a break with your favorite form of liquid; you’ve earned it.

There’s a lot you can do with registers. Try exploring apps you don’t have the source code for; it’s a lot of fun and will build a good foundation for tackling tough debugging problems.

Try attaching to an application on the iOS Simulator and map out the UIViewControllers as they appear using assembly, a smart breakpoint, and a breakpoint command.

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.