Navigating and discovering an iOS codebase using lldb
Sometimes you find yourself in a situation where you have to fix a bug or add a new features to a codebase you know little about. Wether you moved to a new job, downloaded an open source project, or came back to one of your old projects, you may not know where things are.
In these situations, in order to start editing code, you need to understand the codebase and find which parts of the code are responsible for which screens/features.
Imagine you are trying to find the method that is executed when a table cell is selected on a given screen.
You could use a brute force approach, by searching for the didSelectRow and narrow the result down to the file that seems closer to the screen at hand, and then probably add breakpoints and run the action to make sure you got the correct location. This approach, that I am guilty of using, is error prone and on the long run, it teaches nothing.
A more sane approach would be to use the debugger, lldb, and Xcode view debugging to easily and more precisely discovering the code.
Using lldb as a discovery mechanism has some advantages:
- It tests and improve your knowledge of cocoa touch framework and objective-c runtime.
- It improves your general debugging skills.
- It is a more scientific approach, since you can quickly make assumptions and accept or reject them.
- It is much faster to add and remove breakpoints, than is to search text and read code.
For this article, I am going to discover and fix a couple of issues in an unknown codebase, by using lldb and Xcode view debugging and never going to the source code, except when breakpoints are hit.
We will use my own fork of hniosreader by Marcin Kmiec, a Hacker news reader app. In this fork I introduced some bugs that we are going to fix.
Lets start by cloning the project and installing the pods
git clone https://github.com/oarrabi/hniosreader
cd hniosreader
pod install
We will use Xcode view debugging, that means we need Xcode 6 and iOS 8+, and we are going to use a 32-bit iPhone simulator device, since we will stick with x86 ABI calling conventions for lldb.
Lets start by opening the project and looking at the first bug.
First Bug:
Switch to bug1 branch of the git repo.
The bug description is:
Selecting any table row always displays the first row content.
Since it happens when we select a row, we start by adding a break point on didSelectRow. To do that, Run the app, then pause the execution using Debug -> Pause menu item, and write the following in the console.
breakpoint set -r “didSelectRow”
The above adds a regular expression breakpoint for any method that contains the string didSelectRow.
Next, continue the app execution by writing continue (and hit enter) in lldb or Debug -> Continue menu item. Tap on any cell to reproduce the issue, Xcode now should be pointing at EntryListController line 242.
self.selectedRow = indexPath;
[self showWebViewAtIndexPath:nil];
Now since this issue was introduced by me, I know that I should pass indexPath instead of nil, lets fix the bug by passing indexPath.
[self showWebViewAtIndexPath:indexPath];
Second Bug:
Switch to bug2 branch to reproduce this bug.
The bug description is:
Tapping on the user name button displays the wrong user name
For this bug, we need to figure out what action is called when the user name is tapped, in order to add a breakpoint, we need to know the cocoa touch class for the user name view.
Lets start by hitting on debug view in Xcode.
After Xcode shows the view debugging screen, we select select any user name view and notice that it is a UIButton object. That means we need to add a breakpoint on the action that is called when a UIButton is tapped.
When a button is tapped, UIApplication sends a sendAction:to:forEvent: message to the UIControl. Lets proceed to add a breakpoint on that message.
breakpoint set -r “UIControl sendAction:to:forEvent:”
If we continue the app execution, and tap on any user name view, Xcode will stop us on an assembly section instead of actual source code; This happens because we don’t have “UIControl sendAction:to:forEvent” source code.
In order to proceed, we need to know the action associated with this UIControl. In the sendAction:to:forEvent: method call, the UIControl action is the first parameter passed to the method.
In order to access the parameters when stopped on an assembly section, we have to understand the ABI calling convention. These conventions explain how are parameters packaged and passed to method calls, for a breakpoint stopped on a method prologue on a 32-bit simulator, accessing the method parameters is summarised in this table:
Returning to our problem, we need to print the first parameter from sendAction:to:forEvent: ; In any objective c method call, the first and second parameters passed are always self and cmd. So executing the following in the lldb console prints:
po *(int*)($esp + 4) -> prints self
po (SEL)*(int*)($esp + 8) -> prints __cmd “sendAction:to:forEvent”
po (SEL)*(int*)($esp + 12) -> prints the first parameter
po *(int*)($esp + 16) -> prints the second
… so on
Notice that we had to cast to (SEL) since lldb will not know how to represent a SEL without explicit casting.
When we execute po (SEL)*(int*)($esp + 12) we get “userNameButtonPressed:” which is the name of the action that will be called when a user name button is tapped.
Lets add a breakpoint on that.
breakpoint set -r “userNameButtonPressed”
Notice the flag -o which means a one shot breakpoint: A breakpoint that deleted after the first stop.
Continue the execution of the app, and you should be stopped on TableViewController line 420
If you scan this method, you will find the culprit snippet that caused this issue on line 425
NSNumber *itemNumber = self.top100StoriesIds[item + 2];
To fix it just remove the + 2 which I introduced for this example.
Third Bug:
Switch to bug3 branch to reproduce this bug.
Sometimes we get a “— points” string instead of the number of points
First lets use Xcode view debugging to get the class of the view that is displaying the — points string.
We notice that the view is a UILabel. That means we need to set a breakpoint on setText. However, since we have lots of labels on screen, setText will be called in too many places, this looks like a job for a conditional breakpoint.
We need to check the value of the first parameter passed to setText and execute the breakpoint when it isEqual to @”— points” string.
We learned from the previous bug that the parameters passed to a method can be accessed through the esp register. In order to catch the text desired, we need to add the following almost self-explanatory breakpoint.
breakpoint set -r “setText:” -c ‘(BOOL)[*(int*)($esp + 12) isEqual:@”— points”]’
The above will call isEqual on the first parameter (third parameter if counting self and __cmd) passed to setText.
If we continue executing the project, and scroll the table, Xcode should stop on an assembly again. However this time the call stack will show a location in our code.
Make sure you are displaying the Debug Navigator, by pressing command + 6, or selecting View -> Navigators -> Show Debug Navigator menu item.
If you select the next call stack you should find yourself in TableViewController line 315.
The culprit line is:
cell.pointsLabel.text = [NSString stringWithFormat:@”%@ points”, points < 100? @(points) : @”—”];
Since this is a bogus bug that I introduced, fixing it is as simple as changing the above line to the following.
cell.pointsLabel.text = [NSString stringWithFormat:@”%@ points”, points];
Wrapping Up
lldb is a very flexible and versatile debugger, this article didn’t even scratch the surface of what lldb can do. As always, in order to learn more about lldb, refer to lldb documentation. Another great resource is apple iOS debugging magic article that can be found here. Also, objc.io last article Dancing in the Debugger — A Waltz with LLDB has great information on how to use lldb.
Happy debugging!