There is no "X" in the Xcode app icon :)
Introduction
Ideally, you should be able to use whichever code editor you desire while still being able to access the best debugging tools available. This is usually the case, but often requires some additional tweaking of project configurations.
Much like it is possible to setup Visual Studio to debug external projects (although there are more enjoyable tools out there), it is also possible to setup Xcode with the same purpose in mind. What's more, there aren't many other great debuggers for macOS out there.
Before we start, here is what whe expect to get from using Xcode only for debugging:
- The ability to place and remove breakpoints at will.
- The ability to visualize variables in-scope.
- The ability to view (dump) certain memory pages.
- The ability to visualize the state of ARM registers.
- The ability to watch custom expressions.
- The ability to view a program's Disassembly.
- The ability to set breakpoints programmatically.
I am sure you can get more features out of Xcode if you want to, particularly if you are developing Metal or Cocoa applications. However, the above will suffice for general-purpose C development.
The Setup
I am assuming you have an existing C project successfully compiling with the appropriate symbols for macOS. I am also assuming the presence of an M-series device, that is, a modern ARM processor. Lastly, all of the following will be written based on Xcode 16, but besides future UI changes, all the information should remain relevant.
The workflow is comprised of three easy steps:
- Write your code in an editor of your choice.
- Build your project however you like as long as it has debug symbols.
- Run the compiled program within Xcode to debug it.
However, some prior setup will be needed. Fear not, I will guide you through each step with pretty screenshots.
Step 1: Creating a New Project
In order to get Xcode to launch our program, we will need to have a proper Xcode project associated with it. Such project can be identified with the .xcodeproj
extension. Although it is actually a directory, it behaves like a file when clicked (opening Xcode). Since it can be traversed as a directory from the terminal, I will refer to it as both a file and a directory interchangeably.
Easily enough, open Xcode and click on the "Create New Project" button.
Then, we will proceed to set up a stub build system. We don't want a build system because we are building our project outside of Xcode. At one point you will be prompted to Choose a template for your new project
, and that is when you must pick the Other
option and then External Build System
.
The next step is to Choose options for your new project
. You will be asked for the following:
- A product name.
- A team.
- An organization identifier.
- A build tool.
とても迷惑です!
To make it simple, let the product name be the name of your project's directory. Write whatever you want as your organization identifier (I just use "test") and set the build tool to the stub /usr/bin/true
, this is important because we do not want to build the project in Xcode.
Lastly, a filesystem window will pop open and you will be (implicitly) asked to choose the location of your new project. But we already have a project! Instead, you should create a temporary directory within your project directory, call it whatever you want, as we are going to delete it later, and set that to be the directory in which the .xcodeproj
file (directory) will be created.
Therefore, your project structure should look something like
/my_project
/tmp
my_project.xcodeproj
/src
main.c
build.sh
...
The important thing is to double-check that the .xcodeproj
now exists within the temporary directory as we wanted. At this point you should close Xcode, because we are about to change the location of said file and it won't like it!
If you, for any reason, want the project file to live in another directory other than the project root, then you should try that by setting a better name than "tmp" within the project creation wizard.
If you happened to choose the project directory as the location for the project file, then you should end up with /my_project/my_project/my_project.xcodeproj
, which is fine, but I thought that it would lead to a more confusing explanation. If you did this by mistake, worry not, you can simply proceed by treating the second my_project
directory as the temporary directory.
You can now run
mv ./tmp/my_project.xcodeproj ./my_project.xcodeproj
to move the project file to the project root, and then
rm -r tmp
to remove the temporary directory.
Otherwise, you can copy-and-paste the .xcodeproj
file to the desired location and delete the entire tmp folder directly from the Finder.
Finally, you can re-open Xcode, click on Open Existing Project...
(see the first picture) and select your .xcodeproj
file (directory). I guess this should also work if you open it from the Finder.
If you forgot to close Xcode when I mentioned it earlier, worry not, you will be prompted to do so when Xcode detects that the project file changed unexpectedly.
Step 2: Configuring the Project Scheme
An Xcode scheme defines a collection of targets to build, a configuration to use when building, and a collection of tests to execute. We do not want any of that, so we are going to only instruct Xcode to run our pre-built executable.
You will need to go to Product > Scheme > Edit Scheme...
or use the shortcut that appears in the screenshot below.
There, within the Run
configuration, in the Info
tab you will be able to choose which executable to launch upon clicking the, well, "Run" button.
Here, you must select your pre-compiled executable, which contains debug symbols.
That is all we care about in terms of Schemes.
Step 3: Adding Files
Surely you will want to navigate through your various project files in order to place some breakpoints, or simply to check some code while debugging. This is the last step of the project setup.
Right click on your project as it appears on the sidebar, and then click on Add Files to "my_project"...
. Apple sure loves suffixing menu options with ellipsis...
After picking the appropriate files you will want to Reference files in place
and set the target to your project. Then, click Finish
.
Actually Doing Some Debugging
That was a lot of talk and quite a few screenshots to do something that takes five minutes or less. At least you didn't have to figure it out yourself!
Now, it is time to actually debug our program. To recap, we are looking to achieve some objectives in particular:
- Placing and removing breakpoints at will, visualizing variables in-scope.
- Viewing (dump) certain memory pages.
- Visualizing the state of ARM registers.
- Watching custom expressions.
- Viewing Disassembly.
- Setting breakpoints programmatically.
I will also explain how to use basic lldb commands, right within Xcode.
The Program
If you don't have a project already but you want to try out debugging, let me share with you the source-code of the project I will be using. You will need two files.
One, the main.c
file:
// main.c
#include <stdio.h>
int main(void)
{
printf("Hello world!\n");
int three = 1 + 2;
printf("Foo bar baz! %d\n", three);
return 0;
}
The complexity is staggering, I know.
Two, the build.sh
script:
export SDKROOT="/Users/Daniel/Code/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk"
clear
clear
./clang@19.0.0/bin/clang \
-fuse-ld=lld \
-std=c2y \
-g \
main.c \
-o my_program
Here -fuse-ld=lld
indicates that we want to link using lld
; -std=c2y
indicates that we want to use the latest C23 features; -g
indicates that want to include debug symbols; main.c
is our entry point and -o my_program
is our output executable.
I keep my own Xcode installations in various places, sometimes even vendored within specific projects. Regardless, change the SDKROOT path to wherever your Xcode installation is located. You should be able to find the appropriate path by running
xcodebuild -sdk -version
if you do not know it already.
I also like to either symlink or fully vendor my Clang installations. In this case I have it symlinked within the project itself. Note that Xcode ships with its own version of clang, which you can find by running
xcodebuild -find clang
but, usually, it is already available in your PATH so you should just be able to run clang -v
to verify that it is properly installed and use that in your build script instead.
This is all you need to follow along. Try running ./build.sh
to obtain a debuggable executable.
Placing and Removing Breakpoints
Breakpoints are pretty simple. You can click on a line number to place one, and right click on it to remove them. Clicking an active breakpoint deactivates it. I do not like to do that a lot, because I end up with many lingering breakpoints.
If you double-click on a breakpoint, a popup modal will open where you will be able to constrain the execution of that breakpoint. This is nothing out of the ordinary, I am just showing you where to find what you would take for granted in other debuggers.
Let us assume you are like me, and despite your honest efforts, you indeed end up with unused breakpoints all over the place. What then? Well, you can select the Breakpoints tab in your sidebar and right click at some point in the hierarchy to Delete Breakpoints
, or even better, Delete Disabled Breakpoints
.
Placing Breakpoints Programmatically
Usually when I am programming, I think to myself "I would like to see how well the program runs up to this point". Mind you, I am not thinking "I would like to debug up to this point". I am in a programming mindset. You know what is better than remembering to clear disabled breakpoints? To set breakpoints right in your code, right when you are coding! This is a practice that I inherited from my JavaScript days, so much so that I use the same "keyword".
#include <stdio.h>
#include <signal.h>
#define debugger raise(SIGTRAP)
int main(void)
{
printf("Hello world!\n");
int three = 1 + 2;
debugger;
printf("Foo bar baz! %d", three);
return 0;
}
...and if you Step Out, then you return at the expected location in your code.
Easy, simple and useful. What more could I ask for? Well, stepping through Assembly instructions would be nice!
Viewing Disassembly
Reading assembly is useful when verifying the application of some optimizations, or when double-checking that certain code has been properly inlined. It is also very necessary to implement certain systems, such as context switching for coroutines. It is a necessary part of kernel programming and also a basic learning step when attempting to understand how certain algorithms work "under the hood".
Many debuggers offer a Disassembly pane as a first class citizen. That is not the case with Xcode. Instead, you must pull up the view from within a menu, and you must disable it whenever you wish to go back to reading C code. Let me know if you know how to do both at the same time!
To enable this feature, you must go to `Debug > Debug Workflow > Always Show Disassembly".
One thing normal humans would like to do is to step through instructions one at a time. You see, Xcode is not for normal humans. Instead, you must hold Control to switch the stepping mode. You can observe that the icons in the lower bar change and the Step Over arrow has a dot below it as opposed to the traditional hollow bar. Note that Xcode will automatically put you in this mode when there is no source available for display, for example, when trapping after a programmatic debugger;
call.
Recently I discovered another way to open the Disassembly for a given file, which is useful because you would otherwise have to wait for Xcode to "jump" to the appropriate file in order to place a breakpoint. On the top-left of the screen, above the code view, you will find an icon with four little squares. Clicking on that icon will display a large menu, from which you can select "Disassembly". I'm using Xcode 16 and the new tab will not automatically focus, instead it will flash for a few milliseconds. However, if you try to do it multiple times, the tab will proceed to focus properly.
If you are wondering, clicking on "Assembly" or "Preprocess" will fail, because our project is not setup to do that. You should use the corresponding clang compiler switches instead.
Displaying Variables and Registers
By default, Xcode will attempt to hide any useful information from you, perhaps to ease-in novice developers and to reduce cognitive load. We don't care about that. We are hard-core heavy-metal programmers. We like our Disassembly and we very much need our registers!
So, here's the one button that enables that. First, while debugging, focus your attention below the action bar that contains the stepping buttons. There you will find local variables and their current values. Below that you will find a label that reads "Auto".
From there you can choose "All variables, Registers, Globals and Statics", and in return, you will get the glorious list we were looking for.
One more thing: if you right-click the empty space in the variable/register list, you will be able to select the Add Expression...
option, where you can add C expressions to the watch list. You can use this feature to write an arithmetic expression with a variable operand and watch it change accordingly as the program execution progresses.
Viewing Memory
First, let us modify our test program a little bit to include a dynamic memory allocation.
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#define debugger raise(SIGTRAP)
int main(void)
{
printf("Hello world!\n");
int* three = calloc(1, sizeof(int));
debugger;
*three = 1 + 2;
printf("Foo bar baz! %d", *three);
return 0;
}
We are interested in viewing the memory corresponding to the address of three
. Such memory will be initialized to zero, but should change to 3 once we step through the next assignment. To do this, right click the variable in watch window and then select View Memory of "three"
.
Xcode will not highlight the desired memory for you, I selected it myself for the sake of clarity. Interestingly, while trying to take a screenshot with a shrunk window size, the memory view will hide some lines in an unscrollable area. What a mess!
We could have achieved the same result by watching the corresponding dereferenced variable, or watching an expression cast to an array of bytes. This is a simple case, you can imagine how it could be useful when going through larger buffers.
You are free to update the address field as you desire. You can also access the "Memory Document" at any time from the top menu or by performing a Naruto-style shortcut jutsu that I will never remember.
Some Basic lldb Commands
Perhaps you are craving some of that sweet, sweet REPL experience. Or perhaps you are just a masochist who yearns for the good ol' gdb experience. Regardless of the reason, Xcode will allocate a nice portion of your screen to the lldb console, so you better learn how to use it!
Step 1: There's no step 1! It's already open! Just focus your attention the lower right side of the Xcode window. The console is comprised of two principal components. First, the output feed, where we can also observe our program's stdout; second, the command input field, which allows us to interact with lldb as if we were running it from a terminal.
You can type h
to display the help for various commands, or alternatively you can gain a quick understanding by reading the rest of this section!
Displaying the value of a variable
Writing p
followed by the name of a variable will print its value. After the latest change to our test program, three got declared as a pointer.
(lldb) p three
(int *) 0x000060000200c140
If three
had still been an int
we would have seen its value as an integer. Instead, we can include a dereference operator to achieve the same result.
(lldb) p *three
(int) 3
Where the result would have been zero prior to the assignment. As you can imagine, p
will adapt to the type of the variable.
Displaying the value of a register
Similarly, it is possible to print the value of any of the registers listed in the watch window prefixed with $
. For example, we could print the frame pointer register:
(lldb) p $fp
(unsigned long) 6171915696
or equivalently
(lldb) p $x29
(unsigned long) 6171915696
Pay no attention to the actual integer values, as those are specific to my debugging session.
Formatting a pointer as Hexadecimal
Notice in the previous example that registers are printed in a non-address form. We can ask lldb to format the values for us:
(lldb) p/x $x29
(unsigned long) 0x00000016fdff5b0
This, of course, will also work with variables
(lldb) p/x *three
(int) 0x00000003
A similar approach can print octals by using the p/o
command.
Displaying C Strings
Assume that we had a variable
char* foo = "hello";
Then, we could simply print the value of foo
with the following command:
(lldb) p foo
(char *) 0x0000000100000502 "hello"
We could also do
(lldb) p/s foo
(char *) "hello" "hello"
But that is a bit silly!
Displaying memory adjacent to a pointer
Suppose that we wanted to see if there is any other meaningful memory adjacent to our three
pointer. The answer is of course "no", but it would have been useful, say, if we were implementing a dynamic array. Here's how to do it:
(lldb) x three
0x600000dec1c0: 03 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
0x600000dec1d0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................
As you can see, we get a similar (but limited) experience to opening the Memory Window.
Silly Things about Xcode
Here is a list of "fun" bugs and DX issues I have found so far, in no particular order.
- If you step through instructions too fast, the debugger will stop working.
- There is no way to manually refresh the project file list.
- The debugger always takes an irrational amount of time to run the program for the first time (this may be lldb's fault).
- Often times, when you try to "Print Description of X" after right-clicking a given pointer variable X (or clicking on the information icon), the lldb output will show
error: could not evaluate print object function: expression interrupted
. If you try to do this with a struct, it will tell youerror: not a pointer type
. Probably lldb's fault but I still haven't figured out how to use this feature. - Xcode does not have a readonly browsing mode.
- It is not possible to change the font size of watched variables and registers.
Conclusion
What a ride. I wrote this whole article, for better or worse, in one afternoon. It was fun. I guess I had quite a bit of weight on my mind regarding this topic. I hadn't realized that I had learned so many little Xcode quirks.
Well... we both made it to the end! I will take this opportunity to let you know that I did not write this as a "content creator". I wrote it both to document the workflow and with the hopes to interact with like-minded people. If you found the information presented here to be useful, then let me know!
I can only imagine the amount of mistakes I forgot to correct, so I leave you with a promise: I will come back to re-read and correct this article multiple times in the future. I will also add any relevant information that I either forgot at the moment of writing, or that I happened to learn after the fact.
Thanks for reading!