Some times we have functionalities implemented in C and we need to use them in our Swift project. There are two scenarios:

  1. We have access to the C source code.
  2. We only have access to a static or dynamic library.

In this post, I explain how to handle both situations.

Let's consider the case when we have a couple of C files that contain math functions to operate on vectors:

// addvec.c file
void addvec(int *a, int *b, int *c, int n) {
    for (int i = 0; i < n; ++i) {
        c[i] = a[i] + b[i];
    }
}

// multvec.c file
void multvec(int *a, int *b, int *c, int n) {
    for (int i = 0; i < n; ++i) {
        c[i] = a[i] * b[i];
    }
}

We could either copy all the sources files directly into the Swift project, or we could create a library with the C code and include it in our project. Taking the second option will show us what we need when we only have access to a precompiled library, so let's try that one.

Creating a C static library

In this section, we'll go with a static library, but the steps needed for creating a dynamic library are almost the same.

Creating a static library is a very simple process consisting of two steps:

  1. Obtaining the object files from the source files.
  2. Merging the object files to create a static library.

These two steps are performed by entering the following commands in the terminal:

# create the object files for each input files (addvec.o and multvec.o)
gcc -c addvec.c multvec.c

# create a static library (libmathvec.a) from the object files
libtool -static addvec.o multvec.o -o libmathvec.a

# To create a dynamic library we should use:
# libtool -dynamic addvec.o multvec.o -o libmathvec.dylib

Including the library in an Xcode project

To include the library into the project we need to execute the following steps:

  1. Drag the library to the project in Xcode. Make sure you check the option Copy items if needed and also select the target in which the library should be included.
  2. Click on File > New > File… and choose a C file from the list. Name it mathvec.c and check the option to include its corresponding header file mathvec.h.
  3. At this point, Xcode will ask you whether you want to configure an Objective-C bridging file for the project. Choose the option to create one.
  4. Delete the mathvec.c file you created in step 2. We are interested only in the header.
  5. We'll use the file mathvec.h as the header of the library, so we need to add the following function definitions to it:
// mathvec.h file
void addvec(int *a, int *b, int *c, int n);

void multvec(int *a, int *b, int *c, int n);
  1. Import the library header file in the project bridging header:
// MyApp-Bridging-Header.h file
#import "mathvec.h"

That is it!

To use our C functions from the app, add the next lines of code to the app delegate didFinishLaunchingWithOptions method or any other that will be executed once the app starts:

var a: [Int32] = [1, 2]
var b: [Int32] = [4, 5]
var c: [Int32] = [0, 0]

addvec(&a, &b, &c, 2)
print("\(a) + \(b) = \(c)")

multvec(&a, &b, &c, 2)
print("\(a) * \(b) = \(c)")

After running the project, in the iOS Simulator you should see the output:

[1, 2] + [4, 5] = [5, 7]
[1, 2] * [4, 5] = [4, 10]

Using the correct architecture for the iOS simulator

When you build the project you will note the following warnings in the Xcode Issue navigator:

ld: warning: building for iOS Simulator, but linking in object file 
(~/MyApp/MyApp/libmathvec.a(addvec.o)) built for macOS

ld: warning: building for iOS Simulator, but linking in object file 
(~MyApp/MyApp/libmathvec.a(multvec.o)) built for macOS

You might be tempted to ignore that warning, but if you try to run the project in a real device, it will turn into an error:

ld: warning: ignoring file ~/MyApp/MyApp/libmathvec.a, 
building for iOS-arm64 but attempting to link with file built for macOS-x86_64

Undefined symbol: _addvec
Undefined symbol: _multvec

The problem is that we built the library targeting the default architecture (macOS-x86_64), which isn't the one used in iPhones (arm64-apple-ios) nor in the simulator (x86_64-apple-ios-simulator). Actually, the simulator uses the same architecture as the mac, since it runs on a mac, that's why it only shows a warning.

Let's fix the simulator warning first by building the library targeting the x86_64-apple-ios-simulator architecture. To do this we have to perform the following steps:

  1. Configure the sysdir gcc should use for building the object files. A sysdir is basically a copy of part of the file system of the target architecture that will include the libraries and header files available in that system. In our case we should use:
 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk

Which includes both /usr/include and /usr/lib/. We have two options for specifying the sysdir directory to the compiler: exporting the path to the sysdir export SDKROOT path-to-sysdir, or using the gcc flag -isysdir path-to-sysdir.

  1. Generate the object files again, this time specifying the correct architecture using the compiler flag -target.
  2. Build the static library again.
# Point sysdir to iOS SDK
export SDKROOT=/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk

# Create object files again by specifying output architecture
gcc -c addvec.c multvec.c -target x86_64-apple-ios-simulator

# Create static library again
libtool -static addvec.o multvec.o -o libmathvec.a

If you replace the old library file (libmathvec.a) in Xcode with the newer one, you will see that the warning will disappear when running the app on the simulator. However, if you try to run the app on a real device, you will still get an error.

If we repeat the process above specifying the arm64-apple-ios architecture we might be able to run the app on a real device, however, we won't be able to run it on the simulator.

Should we have two versions of the library, one for each architecture, and replace them every time we want to run on a different platform? Well, yes, and not. The bad news is that that's precisely what we need to do, the good one is that Xcode does the switching libraries work for us.

Using universal binaries

In macOS, there are two categories of binary files:

  • MACH-O: binary files that contain code for a single architecture.
  • Universal (or Fat): non-executable binary files that contain code for multiple architectures.

The purpose of universal binaries is to include in only one file the code of one module compiled for different architectures. Once you need to include the code from a fat binary in single-architecture executable, Apple has a tool called lipo (which stands for liposuction) which extracts the code for a particular architecture from a fat file. lipo also allows you to merge the code from several architecture-specific files into one fat binary.

When we run the app in Xcode to a device or simulator, Xcode compiles our app for that architecture and uses lipo to extract the code for that particular architecture from all the universal binaries we have as dependencies. Similarly, when we archive an app for uploading it to the App Store, we choose Generic iOS Device as the target, which basically instructs Xcode to compile the app for all the architectures we support and bundle them in one universal package using (again) lipo. Once we upload this universal package to the Apple servers it's processed there and is built for each particular platform as part of the app thinning process.

Inspecting the binaries we have so far with the file utility, we can see that they contain code for only one architecture:

file addvec.o 
# addvec.o: Mach-O 64-bit object x86_64
file libmathvec.a
# libmathvec.a: current ar archive random library

Let's change that by creating a universal static library. Doing that requires:

  1. Generating multiple object files for each source file, one for each architecture.
  2. Merging the content from the object files corresponding to the same source file into a universal object file.
  3. Creating a static library using the universal object files as input.
# create object files for x86_64 architecture for use in the simulator
gcc -c addvec.c -o addvec-x86_64.o -target x86_64-apple-ios-simulator
gcc -c multvec.c -o multvec-x86_64.o -target x86_64-apple-ios-simulator

# create object files for arm64 architecture for use in real devices
gcc -c addvec.c -o addvec-arm64.o -target arm64-apple-ios
gcc -c multvec.c -o multvec-arm64.o -target arm64-apple-ios

# create universal object files
lipo -create addvec-x86_64.o addvec-arm64.o -output addvec.o
lipo -create multvec-x86_64.o multvec-arm64.o -output multvec.o

# create universal static library
libtool -static addvec.o multvec.o -o libmathvec.a

If we inspect the type of the new files, we can see that they are multi-architecture:

file addvec.o 
# addvec.o: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit object x86_64] [arm64:Mach-O 64-bit object arm64]
# addvec.o (for architecture x86_64):	Mach-O 64-bit object x86_64
# addvec.o (for architecture arm64):	Mach-O 64-bit object arm64

file libmathvec.a
# libmathvec.a: Mach-O universal binary with 2 architectures: [x86_64:current ar archive random library] [arm64:current ar archive random library]
# libmathvec.a (for architecture x86_64):	current ar archive random library
# libmathvec.a (for architecture arm64):	current ar archive random library

At this point, running the app on the simulator works as expected, but if you run it on a real device, you will get an error:

ld: '~/MyApp/libmathvec.a(addvec.o)' does not contain bitcode. 
You must rebuild it with bitcode enabled (Xcode setting ENABLE_BITCODE), 
obtain an updated library from the vendor, 
or disable bitcode for this target. for architecture arm64

The error message is very descriptive about what the problem is and what the options are to fix it. In this case, we are the vendors, so let's try rebuilding the library using bitcode.

Building the library with bitcode enabled

Apple builds iOS apps using the LLVM compiler infrastructure. In this setup, we have what is called front-end compilers, which receive as input files source code written in a high-level programming language (like C, C++, Swift) and output machine code for an imaginary architecture. That machine code is called bitcode. At this point, LLVM performs optimizations on top of the bitcode and pass it to what is know as back-end compilers, which generate machine code for specific architectures (like x86_64 and arm64).

When we enable bitcode in an iOS project, the package we submit to the App Store is compiled only until the bitcode stage. This allows Apple to compile the app in their servers, opening the doors for applying future optimizations to our code. Enabling bitcode in an iOS app requires all its dependencies to have bitcode support too, so we need to support it in our static library. We can do this by using the gcc flag -fembed-bitcode.

# re-create object files *ONLY* for arm64 with bitcode enabled
gcc -c addvec.c -o addvec-arm64.o -target arm64-apple-ios -fembed-bitcode
gcc -c multvec.c -o multvec-arm64.o -target arm64-apple-ios -fembed-bitcode

# create universal object files
lipo -create addvec-x86_64.o addvec-arm64.o -output addvec.o
lipo -create multvec-x86_64.o multvec-arm64.o -output multvec.o

# create universal static library
libtool -static addvec.o multvec.o -o libmathvec.a

After replacing the library again, you should be able to run the app on the simulator and on real devices as well. Our library supports both x86_64 and arm64, but if you are targeting old iOS devices, you will also need to support armv7 using the same process described in the previous section.

Automatizing the build process using Make

So far we have been repeating the same commands for each source file, which isn't practical in real-world scenarios. We can automatize these steps using a make file. Create a file called makefile alongside our source files with the following content:

.PHONY = clean

CC = gcc
CFLAGS = -fembed-bitcode -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk
SOURCES = $(wildcard *.c)
UNIVERSAL_OBJECTS = $(SOURCES:%.c=%.o)

libmathvec.a: $(UNIVERSAL_OBJECTS)
	# creating static library
	libtool -static $^ -o libmathvec.a

%.o: %-x86_64.o %-arm64.o
	# creating universal object file for $@
	lipo -create $^ -output $@

%-x86_64.o: %.c 
	# creating object file for $< using x86_64 architecture
	$(CC) -c $< -o $@ -target x86_64-apple-ios-simulator $(CFLAGS)

%-arm64.o: %.c 
	# creating object file for $< using arm64 architecture
	$(CC) -c $< -o $@ -target arm64-apple-ios $(CFLAGS)

clean:
	# removing intermediate object files
	rm *.o

Now you can trigger the build process using the command make -r. Running make clean will remove the intermediate object files you don't need anymore.

What if you already have the prebuilt C library?

If you have followed the sections before, you should know that for including a library in an app that will run on both real devices and the simulator, you will need a library binary for each of the target architectures. You can combine them in only one file by using the lipo tool again:

lipo -create libmathvec-x86_64.a libmathvec-arm64.a -output libmathvec.a

If the library binary for arm64 doesn't support bitcode, you won't be able to enable it because you don't have access to the source files, so you should disable it for your Xcode project. This can be done by setting the flag Build Settings -> Enable Bitcode to No.