6.4 KiB
title | date |
---|---|
My C Project Setup | 2024-03-28 |
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:
make lint
andmake fmt
useclang-format
to lint and format the source filesmake check
runscppcheck
(and possibly other tools in the future) on the source code, notifying me of obvious memory leaks or mistakesmake test
compiles all test binaries and runs the unit testsmake run
compiles and runs the main binarymake build-example
builds all examplesmake bear
generates acompile_commands.json
file using Bear (theclangd
LSP server requires this to work properly)make clean
removes all build artifacts
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