0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Using Xcode to debug external C projects

Last updated at Posted at 2025-01-27

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:

  1. Write your code in an editor of your choice.
  2. Build your project however you like as long as it has debug symbols.
  3. 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 you error: 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!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?