- Published on
How to Structure C Projects: These Best Practices Worked for Me
- Name
- Luca Cavallin
I recently worked on two different C projects, and I wanted to structure them in a way that would make them easy to maintain and understand. I also wanted to make sure that the projects were easy to build and test. In this post, I will share my experience and the best practices I found for structuring C projects.
You can find the source code for the projects I worked on in the following repositories:
- lucavallin/barco: Linux containers from scratch in C.
- lucavallin/gnaro: A proto-database inspired by SQLite for educational purposes.
Because of my limited experience with C - I hadn't written any in 10 years - I had to do a lot of research to find out what the current consensus is when it comes to directory layout. I found a lot of useful information on GitHub, Reddit, and Stack Overflow. I also looked at the source code of some popular open-source C projects to see how they were structured. I found that most of the projects I looked at followed a similar layout, and I decided to use that as a starting point.
What The Internet People Say
If you Google for "c project structure best practices" you'll get about 583.000.000 results - no need to worry about doing your own research - I read all of those pages, twice. While opinions vary, there are some common themes that come up again and again. Two approaches are particularly popular:
- The "modular" approach: This is the most common approach for large projects. The idea is to split the project into multiple directories, each containing a different module of the project. Each module has its own header files, source files, and tests. This approach makes it easy to find the code you're looking for and makes it easy to test individual modules in isolation. This is the way the Linux kernel is structured, for example.
- The "flat" approach: This approach is more common for small projects and it focuses on keeping the project as simple as possible and yet well-organized.
Since the projects I worked on were relatively small, I decided to use the "flat" approach, which I am going to describe next.
My Approach to Structuring C Projects
After going through all the 583.000.000 results twice, I settled on the following directory layout for my projects:
├── .devcontainer configuration for GitHub Codespaces
├── .github configuration GitHub Actions and other GitHub features
├── .vscode configuration for Visual Studio Code
├── bin the executable (created by make)
├── build intermediate build files e.g. *.o (created by make)
├── docs documentation
├── include header files
├── lib third-party libraries
├── scripts scripts for setup and other tasks
├── src C source files
│ ├── main.c (main) Entry point for the CLI
│ └── *.c
├── tests contains tests
├── .clang-format configuration for the formatter
├── .clang-tidy configuration for the linter
├── .gitignore
├── compile_commands.json compilation database for clang tools
├── LICENSE
├── Makefile
└── README.md
Let's dive deeper into this layout. We can ignore .devcontainer
, .github
, .vscode
and scripts
for now, as they are specific to my development environment and not relevant to the project structure. The files .clang-format
and .clang-tidy
are configuration files for the Clang formatter and linter, respectively. The compile_commands.json
file is a compilation database for Clang tools. These files are not strictly necessary, but they can be useful if you want to use Clang tools in your project. LICENSE
and README.md
are self-explanatory, and Makefile
needs no introduction either, although you can read more about how I wrote mine in Crafting a Clean, Maintainable, and Understandable Makefile for a C Project.
Before we get into the more important details, let's get a few more directories out-of-the-way:
- The
bin
directory contains the executable that is created when you runmake
. - The
build
directory contains the intermediate build files, such as.o
files. - The
docs
directory contains the documentation for the project.
Let's spend some time on the src
, include
, lib
, and tests
directories.
src
Directory
The The src
directory contains the C source files for the project, and you will spend most of your time here. I decided to keep it simple using a flat layout. Besides the main.c
file which works as the entry file of the program, I split the rest of the code based on "concerns" and data-structures. For example, in the gnaro
project:
btree.c
: contains the implementation of a B-tree data structure.cursor.c
: contains the implementation of a cursor for reading and writing to the database.database.c
: contains the implementation of the database.pager.c
: contains the implementation of the pager.row.c
: contains the implementation of a row in the database.input.c
,meta.c
andstatement.c
: contain logic needed to parse and prepare user input.
I found this simple layout to be easy to understand and navigate. It also makes it easy to find the code you're looking for, as long as you make an active effort to keep the files small and focused. The downside of this approach is that you will need to keep the Makefile
updated with the new files you add to the project so that they are compiled and linked correctly. Given the small size of the projects I worked on, I didn't find this to be a problem, but it could be a problem for others.
include
Directory
The The include
directory contains the header files for the project. Most if not all of the .c
files in the src
directory will have a corresponding .h
file in the include
directory. The header files should contain the public API for the module, and the source files should contain the implementation. This makes it easy to see what the module does without having to look at the implementation. It also makes it easy to test the module in isolation, as you can just include the header file in your test file.
Once again, using the gnaro
project as an example:
btree.h
: included insrc/btree.c
, defines the public API for the B-tree data structure.cursor.h
: included insrc/cursor.c
, defines the public API for the cursor used to read and write from and to the database.database.h
: included insrc/database.c
, defines the public API for the database implementation.pager.h
: included insrc/pager.c
, defines the public API for the paging logic.row.h
: included insrc/row.c
, defines the public API for the database's row structure.input.h
,meta.h
andstatement.h
: included insrc/input.c
and insrc/meta.c
and insrc/statement.c
, defines the public API for handling user input.
Just like the src
directory, the downside of this layout is that header files should be referenced in the Makefile
so that they are included in the compilation process.
lib
Directory
The The lib
directory contains third-party libraries that the project depends on. For example, lucavallin/gnaro makes use of the argtable and log.c libraries for parsing CLI arguments and logging, respectively.
There is not much to say about this directory. It's just a place to put your dependencies. Again, don't forget to include these in the Makefile
as well, by the way.
tests
Directory
The The tests
directory contains the tests for the project. I used the CUnit library for testing, and I found it to be a good fit for my needs. The tests
directory contains a test file for each module in the src
directory. For example, in the gnaro
project, the tests
directory cointains gnaro_test.c
file which is meant to test whatever logic defined in src/gnaro.c
.
At this time, in pratice, the file only contains the code needed to setup the tests as recommended by the CUnit documentation. While the tests run, I actually never followed-up on writing useful checks for gnaro
and barco
since they're just side-, hobby-projects.
Conclusion
Thanks for reading this far! I hope you found this article useful. I know that the project layout I've described is not the only way to organize a C project, but it's the way that I've found to be the most effective for me. The bin
, build
, docs
, script
, and ".something" directories are helpful for development purposes, but it is in the src
, include
, lib
, and tests
directories where the real work happens.
src
: contains the C source files for the project.include
: contains the header files for the project, included by.c
files insrc
.lib
: contains third-party libraries that the project depends on.tests
: contains the tests for the project.
The Makefile
is the glue that holds everything together and it must be updated to include new files and dependencies. While more modern build systems like CMake and Meson are available, I found that a simple Makefile
was sufficient for my needs.
I hope that you can take some of the ideas I've presented here and apply them to your own projects. If you have any questions or comments, feel free to reach out!