8.
Watchpoints
Written by Derek Selander
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 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 type of 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, 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/coutfunction call. - Monitor the instruction pointer for a particular type of assembly instruction.
Finding a property’s offset
Watchpoints are great for discovering how a particular piece of memory is being written to. A pratical example of this is when a value is written to a previously allocated instance created from the heap, such as in an Objective-C/Swift class.
Fortunately, Swift automatically wraps all writes to offsets in a property’s setter method; in Swift, you don’t have direct access to the ivar! This means that watchpoints for Swift might not always be necessary since your best bet could be a breakpoint with the Swift setter syntax found in Chapter 4, “Stopping in Code”. In fact, Swift also gives you the didGet and didSet methods which provide an attractive alternative to using watchpoints in Swift. The same does not get carried over to the C/ObjC/ObjC++ family, where a value in memory could be modified directly through the ivar, through the property setter, or through a hardcoded offset. This means you can’t always rely on Objective-C’s set{PropertyName}:’s breakpoint syntax to catch when a value is being set.
For this example, you’ll see watchpoints in action by using one in lieu of a breakpoint to catch a particular write to memory.
Open up the Signals application in the starter directory for this chapter and run the program using the iOS 12 iPhone X Simulator.
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 = 0x10e843d90 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 = setShouldEnableSignalHandling: type = v20@0:8B16
...
Check out _shouldEnableSignalHandling, 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 in an instance of UnixSignalHandler.
Note: The LLDB command “
language objc class-table dump” is a little buggy and won’t work on Swift classes… even though a Swift class, on Apple platforms, inherits from an Objective-C class. If you aren’t a fan of this broken command, you can check out thedclasscommand found here: https://github.com/DerekSelander/LLDB/blob/master/lldb_commands/dclass.py. Thedclasscommand works on both Objective-C and Swift classes, and produces much cleaner output.
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, tap on the Debug Memory Graph button located on the top of the debug console.
Once tapped, 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. Make sure to also open the inspectors on the right side and select the Show the Memory Inspector option.
Once you have the instance, copy the memory address of the UnixSignalHandler into your clipboard.
On my particular instance of the Signals program, I can see that the singleton instance of UnixSignalHandler has a heap address value starting at 0x6000024d0f40, but note that yours will most likely be different.
Note: Without dwelling too long on the fact that the author’s debugging scripts may provide a better debugging experience than what Xcode can currently deliver, if you’re not a fan of all the GUI clicking you just performed, please check out the search command here https://github.com/DerekSelander/LLDB/blob/master/lldb_commands/search.py. This command can enumerate the heap for specific Objective-C classes and is frankly, a more powerful and feature rich than Xcode’s GUI equivalent.
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 0x6000024d0f40 + 32
(long) $0 = 0x00006000024d0f60
0x00006000024d0f60 is the location of interest. Time to put a watchpoint on it!
In LLDB, type the following. Remember to replace your own calculated offset value of UnixSignalHandler:
(lldb) watchpoint set expression -s 1 -w write -- 0x00006000024d0f60
This creates a new watchpoint that monitors address 0x00006000024d0f60, whose size monitors a 1 byte range (thanks to the -s 1 argument) and only stops if the value gets set (-w write). The -w argument can monitor read, read_write or write occurrences in memory.
Now that we have the appropriate plumbing in LLDB to monitor this change, it’s time to trigger the event throug the Simulator. In the Signals project, tap on the playbook UISwitch button.
The Signals project 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.
What caused the watchpoint
What exactly caused the watchpoint to be triggered? Caffeinate up, you’ll be looking at a bit of assembly now. To find out, use LLDB to disassemble the current method.
(lldb) disassemble -F intel -m
This will print the current frame’s disassembly in Intel format (more on this and assembly in Section II). In addition, you specified the -m option to show the assembly and source code as mixed. This will give you a better indication of how the assembly relates to the sourcecode.
Scan the output where the program counter is currently stopped at by the ->. You’ll see a -> for both the assembly and the source outputs, but in reality, these are both the same.
It’s the assembly instruction immediately above the program counter -> line which is over interest to us.
In my case, I got the following instruction:
0x100c04be7 <+39>: mov byte ptr [rsi + rdi], al
Note: A new version of Clang could change the assembly output. If that’s the case, use this example as a guide to figure out your unique assembly instruction.
You don’t need to know the specifics to x86_64 assembly yet (that’s in Section II), but the expression is equivalent to the following:
*(BOOL *)(rsi + rdi) = al
You can prove that this will be the UnixSignalHandler instance + 32 offset by typing the following into LLDB:
(lldb) p/x $rsi + $rdi
This will produce the address of the watchpoint you created earlier. In fact, you can get that address of the previously created watchpoint by typing:
(lldb) watchpoint list
Still need convincing this is the instance of the UnixSignalHandler? Type the following to retrieve the original instance:
(lldb) po $rsi + $rdi - 32
<UnixSignalHandler: 0x6000024d0f40>
As you can see, the (0x6000024d0f20 + 32) memory address was modified by the AL register, which caused the watchpoint to trigger. This assembly instruction was the result of the following line in the sourcecode:
self->_shouldEnableSignalHandling = shouldEnableSignalHandling;
There was an overriden Objective-C property setter, which performed direct ivar access to the value. Although an Objective-C property setter breakpoint would have caught this in this particular example, you might not always be so lucky.
As you can see, the setup takes longer, but watchpoints can be much more powerful. This is why watchpoints are a great tool to use when your initial breakpoint strategies fail.
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 previous watchpoint.
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
Process 68247 resuming
In the Signals program, make sure the Playbook UISwitch is flicked back to on. Once on, navigate to UnixSignalHandler.m and set a GUI breapoint at the end of the function that returns the singleton instance.
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. It’s 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.
Resume control of the program through Xcode or LLDB. Test out the newly created watchpoint by tapping the Playbook UISwitch yet again in the Simulator.
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. The modify command can 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, let’s say you wanted to the previous watchpoint to only stop when the new value is set to 0.
First, find the watchpoint ID to modify:
(lldb) watchpoint list -b
Number of supported hardware watchpoints: 4
Current watchpoints:
Watchpoint 2: addr = 0x60000274ee20 size = 1 state = enabled type = w
This says to list all the watchpoints in a “brief” (-b) format. You can see the Watchpoint ID is 2. From there modify, Watchpoint ID 2:
(lldb) watchpoint modify 2 -c '*(BOOL*)0x60000274ee20 == 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), it 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 everytime it’s set.
Remove all watchpoint conditions like so:
(lldb) watchpoint modify 2
This will remove 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 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.
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 Chapter 18, “Hello, Mach-O”. Combining this knowledge with watchpoints, you can watch when strings are referenced, or when static pointers are intialized, 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 created and your breakpoints don’t produce any results.