My C Project Setup

Part of the Blog series

For the last couple of months most of my projects have revolved around low-level C programming, with the most prominent one being Lander, my URL shortener. During this time I’ve developed a method for structuring my repositories in a way that works for me and my development style. In this post, I’ll be detailing my approach!

If you prefer looking at the structure directly, the basic structure’s available as a template on my Gitea.

Basic structure

The basic structure for my repositories looks like this:

.
├── example
├── include
│   └── project_name
├── src
│   ├── _include
│   │   └── project_name
│   └── project_name
└── test

Let’s break it down.

Naturally, src contains the actual source files, both those native to the project and those included from thirdparty libraries. src/project_name contains all source files native to the project, while thirdparty files are stored in their own subdirectories separated by library, or directly in src.

For header files, we have two relevant directories. include/project_name contains all header files that are part of the public API for the library. src/_include on the other hand contains header files that are only used internally by the project. Here we once again have the same split where src/_include/project_name contains internal header files native to the project, while thirdparty header files can be placed either directly in src/_include or in their own subdirectories.

Finally we have test and example. test contains unit tests, while example contains source files that illustrate how to use the library in a practical context.

This setup seems to be fairly standard, and it works perfectly for me. To power a C project, we of course need some form of build system, so let’s talk about the Makefile.

The Makefile

During my years of creating personal projects I started leaning more towards a lightweight development style. For a while I was a big fan of CMake, but for my projects it’s way too complex. As a replacement, I opted for a hand-written Makefile. While I’m not going to go into detail on the specifics of the Makefile, I will mention its most predominant features.

First and foremost it supports compiling all required files and linking them into either a static library or a native binary, depending on the project. It allows all source files to include any header file from both include and src/_include. Unit tests and example binaries are compiled separately and linked with the static library. Unit tests are allowed to include any internal header file for more precise testing where needed, whereas example binaries only get access to the public API.

The Makefile properly utilizes the CC, CFLAGS and LDFLAGS variables, allowing me to build release binaries and libraries simply by running make CFLAGS='-O3' LDFLAGS='-flto'. Make also allows running compilation in parallel using the -j flag, greatly speeding up compilation. A properly written Makefile really does make life a lot easier.

It also solves a common issue with C compilation: header files. The usual bog-standard Makefile only defines the C source file as a dependency for its respective object file. Because to this, object files do not get recompiled whenever a header file included by its source file is changed. This can result in unexpected errors when linking. The Makefile solves this by setting the -MMD -MP compiler flags. -MMD tells the compiler to generate a Makefile in the build directory next to each source file’s object file. These Makefiles define all included header files as a dependency for its respective object file. By importing these Makefiles into our main Makefile, our object files are automatically recompiled whenever a relevant header file is changed.

The Makefile also contains some quality-of-life phony targets for stuff I use regularly:

Testing

My setup currently only supports unit tests, as I haven’t really had the need for anything more complex. For this, I use acutest, a simple and easy to use header-only testing framework that’s perfect for my projects. It’s fully contained within a single header file that gets imported by all test files under the test directory. By having the testing framework fully contained in the project it also becomes very easy to run tests in a CI. If the CI environment can compile the library it can also run the tests without any additional dependencies required.

Combining projects

My projects, specifically libraries, often start as part of a different project (e.g. lnm used to be part of Lander). As the parent project grows, some sections start to grow into their own, self-contained unit. At this point, I take the time to properly decouple the codebases, moving the new library into its own subdirectory. This subdirectory then gets the same structure as described above, allowing the parent project to include it as a static library.

This approach gives me a lot of flexibility when it comes to testing, as well as giving me the freedom to separate subprojects into their own repositories as desired. Each project functions exactly the same if it’s a local subdirectory or a Git submodule, allowing me to easily use my libraries in multiple projects simply by including them as submodules.

Outro

That was my C project setup in a nutshell. Maybe this post could be of use to someone, giving them ideas on how to improve their existing setups.

As is standard with this blog, this post was rather technical. If you got to this point, thank you very much for reading.

Jef