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

8. Watchpoints
Written by Walter Tyree

You’ve learned how to create breakpoints on executable code; that is, memory that has read and execute permissions. But using only breakpoints leaves out an important component to debugging — you can monitor when the instruction pointer executes an address, but with breakpoints you can’t monitor when memory is being read or written to. You can’t monitor value changes to instantiated Swift objects on the heap, nor can you monitor reads to a particular address (say, a hardcoded string) in memory. This is where a watchpoint comes into play.

A watchpoint is a special type of breakpoint that can monitor reads or writes to a particular value in memory and is not limited to executable code as are breakpoints. However, there are limitations to using watchpoints: there are a finite amount of watchpoints permitted per architecture (typically 4) and the “watched” size of memory usually caps out at 8 bytes.

Watchpoint Best Practices

Like all debugging techniques, a watchpoint is a another tool in the debugging toolbox. You’ll likely not use this tool very often, but it can be extremely useful in certain situations. Watchpoints are great for:

  • Tracking an allocated Swift/Objective-C object when you don’t know how a property is getting set, i.e. via direct ivar access, Swift inout parameter initialization, Objective-C property setter method, Swift property setter method, hardcoded offset access, or other methods.
  • Monitoring when a hardcoded string is being utilized, such as in a print/printf/NSLog/cout function call.
  • Monitoring the instruction pointer for a particular type of assembly instruction.

You should use a watchpoint when a value is getting set. You’ll learn about a different tool in an upcoming chapter, the MallocStackLogging environment variable, to monitor when an object is being allocated.

Breakpoints Limitations

Watchpoints are great for discovering when a particular piece of memory is being read or written to. A practical example of this is when an instance variable is written to a previously allocated instance created from the heap, such as in an Objective-C/Swift class instance.

Fortunately, in Swift, you don’t have direct access to the instance variable. Access is gated via the setter methods that are generated by the compiler! In addition, Swift also gives you the didGet and didSet methods which provide an attractive alternative to using watchpoints on Swift properties provided you have the source code to augment.

The same features do not get carried over to the C/ObjC/ObjC++ family, where a value in memory could be modified directly through the instance variable, through the property setter, or even through direct memory access.

Unfortunately, even with all the safety that Swift brings, there are some edge cases in Swift where a setter breakpoint will not get used, like inout functions modifying the underlying ivar. Take the following code as an example:

class SomeClass {
  var heresAVariable: Int = 0
}

func modifyVal(_ i : inout Int) { i = 4 }

var c = SomeClass()
modifyVal(&c.heresAVariable)

Setting a (lldb) rb setter breakpoint will not stop here! The generated assembly will calculate the offset of heresAVariable and write directly to that memory. This means you can’t always rely on Objective-C’s set{PropertyName}:’s breakpoint syntax, nor Swift’s {Module}.{Class}.{Property}.setter methods to catch when write-able memory is being set!

Finding a Property’s Offset

You’ll explore how to use watchpoints in lieu of a breakpoint to catch a particular write in memory on a singleton.

Open up the Signals application in the starter directory for this chapter and build and run the program.

Once running, pause the application and head over to the lldb console. Type the following:

(lldb) language objc class-table dump UnixSignalHandler -v

This will dump the Objective-C class layout of UnixSignalHandler. The output will look similar to the following:

isa = 0x100acc5d8 name = UnixSignalHandler instance size = 56 num ivars = 4 superclass = NSObject
  ivar name = source type = id size = 8 offset = 24
  ivar name = _shouldEnableSignalHandling type = bool size = 1 offset = 32
  ivar name = _signals type = id size = 8 offset = 40
  ivar name = _sharedUserDefaults type = id size = 8 offset = 48
  instance method name = initPrivate type = @16@0:8
  instance method name = appendSignal:sig: type = v28@0:8^{__siginfo=iiiiIi^v(sigval=i^v)q[7Q]}16i24
  instance method name = setShouldEnableSignalHandling: type = v20@0:8B16
 ...

Check out the ivar named _shouldEnableSignalHandling, a bool type, whose offset is 32 bytes and whose size is 1 byte (yes, a byte, NOT a bit).

This means that if you know where an instance of the UnixSignalHandler class is located on the heap, you can add 32 bytes to that address to get the location where _shouldEnableSignalHandling is stored for that instance of UnixSignalHandler.

Note: The lldb command “language objc class-table dump” isn’t as fleshed out as it could be on Swift classes… even though on Apple platforms a Swift class inherits from an Objective-C class. You can alternatively try the dsdump tool, which is an up to date class-dump tool for finding offsets in Swift code.

Now that you know the offset to find the _shouldEnableSignalHandling ivar on an instance, it’s time to find the instance of the UnixSignalHandler singleton. In Xcode:

  1. Click on the Debug Memory Graph button located on the top of the debug console.
  2. Select the Signals project, then select the Commons framework (the framework responsible for implementing the UnixSignalHandler).

Drill down and you’ll see the instance of the UnixSignalHandler both visually in Xcode and the memory address. Notice in the image below, that Xcode gives you multiple ways to see the memory address for the object. It’s in the Debug navigator on the left, in the breadcrumb indicator at the top and in the Memory Inspector in the pane on the right.

Once you have the instance, copy the memory address of the UnixSignalHandler into your clipboard or write it down somewhere.

On my particular instance of the Signals program, I can see that the singleton instance of UnixSignalHandler has a heap address value starting at 0x600002ce8940, but note that yours will be different.

Note: Without dwelling too long on the fact that the debugging scripts we provide may be a better debugging experience than what Xcode can currently deliver, if you’re not a fan of all the GUI clicking you just performed, check out the search command from Appendix C: “Helpful Python Scripts”. This command can enumerate the heap for Swift/Objective-C classes and has a few extra features rich that Xcode’s GUI equivalent lacks.

Through lldb, Add your instance value to 32 to find the location of the _shouldEnableSignalHandling ivar. Format the output in hexadecimal using lldb’s p/x (print hexadecimal) command.

(lldb) p/x 0x600002ce8940 + 32

lldb’s with (long) $0 = 0x0000600002ce8960 which is the location of interest. Time to put a watchpoint on it!

Now you can use watchpoint set to place the watchpoint. If you used lldb to make the memory calculation the correct address is stored in the variable that lldb just created for you. In the example above it is $0. If you did the memory calculation by hand, use your calculated offset value of UnixSignalHandler.

(lldb) watchpoint set expression -s 1 -w write -- $0

This creates a new watchpoint that monitors a 1 byte range (thanks to the -s 1 argument) starting at address 0x0000600002ce8960 and only stops if the value gets set (-w write).

The -w argument can be set to the following values:

  • read: Fires when the value is read from.
  • write: Fires when the value is written to.
  • read_write: Fires when the value is read from or written to.

The plumbing is now set up in lldb to monitor this change. You’ll now trigger the write to occur through the GUI of the Signals app. Resume the app if it’s still paused and tap on the playbook UISwitch button.

The Signals app will be suspended. Take a gander over to the left hand side of Xcode to view the stack trace and see how the program got stopped.

The Xcode GUI Watchpoint Equivalent

Xcode provides a GUI for setting watchpoints. You could perform the equivalent of the above methods by setting a breakpoint on the creation method of the UnixSignalHandler singleton, then set a watchpoint via the GUI. First though, you need to delete the watchpoint you just made.

In lldb, delete the watchpoint, then resume execution:

(lldb) watchpoint delete
About to delete all watchpoints, do you want to do that?: [Y/n] Y
All watchpoints removed. (1 watchpoints)
(lldb) c

In the Signals program, make sure the Playbook UISwitch is flicked back to on. Once on, navigate to UnixSignalHandler.m and set a GUI breakpoint at the end of the sharedHandler constructor that returns the singleton instance. The line of code reads return sharedSignalHandler;.

Control should suspend since you’ve added a breakpoint to a callback function that monitors breakpoints and references that code. If not, make sure your Playbook UISwitch is active.

Once control is suspended, make sure your Variables View is visible. The toggle for that view is found in the lower-right corner of Xcode.

In the Variables view, drill down into the sharedSignalHandler instance, then right click on the _shouldEnableSignalHandling variable. Select Watch _shouldEnableSignalHandling.

Now, remove the GUI breakpoint, you were just using it to pause the app in the right stack frame. Resume control of the program through Xcode or LLDB. Test out the newly created watchpoint by tapping the Playbook UISwitch yet again in the app. Each time you toggle the switch, the app will pause on the line where the value of _shouldEnableSignalHandling gets set.

Other Watchpoint Tidbits

Fortunately, the syntax for watchpoints is very similar to the syntax for breakpoints. You can delete, disable, enable, list, command, or modify them just as you would using lldb’s breakpoint syntax.

The more interesting ones of the group are the command and modify actions. Use modify to add a condition to trigger the watchpoint only if it’s true. The command action lets you perform a unique command whenever the watchpoint gets triggered.

For example, if you wanted the previous watchpoint to stop only when the new value is set to 0.

First, find the watchpoint ID to modify:

(lldb) watchpoint list  -b

This says to list all the watchpoints in a “brief” (-b) format. lldb responds with information about all of the current watchpoints:

Number of supported hardware watchpoints: 4
Current watchpoints:
Watchpoint 2: addr = 0x60000161be60 size = 1 state = enabled type = w

You can see the Watchpoint ID is 2. From there modify, Watchpoint ID 2:

(lldb) watchpoint modify 2 -c '*(BOOL*)0x60000161be60 == 0'

This will modify Watchpoint ID 2 to only stop if the new value of _shouldEnableSignalHandling is set to false.

If you omit the Watchpoint ID in the above example (the 2), your modification will be applied to every valid watchpoint in the process.

One more example before you wrap this chapter up! Instead of conditionally stopping when _shouldEnableSignalHandling is set to 0, you can simply have lldb print the stack trace every time it’s set.

Remove all watchpoint conditions by using the modify subcommand without supplying any new conditions:

(lldb) watchpoint modify 2

This removes the condition you previously created. Now add a command to print the backtrace, then continue.

(lldb) watchpoint command add 2
Enter your debugger command(s).  Type 'DONE' to end.
> bt 5
> continue
> DONE

Instead of conditionally stopping, the watchpoint will now print the first five stack frames in the lldb console, then continue.

Once you get bored of seeing all that output, you can remove this command by typing:

(lldb) watchpoint command delete 2

And there you have it! Watchpoints in a nutshell.

Key Points

  • Watchpoints can monitor memory addresses for read, write, or read_write actions.
  • The watchpoint command has similar subcommands to breakpoints such as: list, delete, and disable.
  • Use watchpoint modify to add conditions to how often a watchpoint should fire.
  • Use watchpoint command when you want triggering of the watchpoint to do more than just pause execution of the code.
  • You are limited to four watchpoints at a time.
  • Watchpoints shine when you are tracking a variable that seems to be changing outside of its official accessor methods.
  • Find the memory addresses of variables in the instance of an object using class-table dump.

Where to Go From Here?

Watchpoints tend to play very nicely with those who understand how an executable is laid out in memory. This layout, known as Mach-O, will be discussed in detail in the “Low Level” section. Combining this knowledge with watchpoints, you can watch when strings are referenced, or when static pointers are initialized, without having to tediously track the locations at runtime.

But for now, just remember that you have a great tool to use when you need to hunt for how something is changing and your breakpoints don’t produce any results.

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.