I first started my current project, otter, with an interesting premise: can you have a C project that only requires the compiler to build itself? This means having a project that can bootstrap its own build system and then build the rest of itself without needing something like CMake, Meson, or Makefiles. Why do this at all, you might ask? Why reinvent the wheel? Because it’s interesting to try it out! Get out of here with that practical mindset of yours. You’re no fun 😒.

I’ll admit that this is not an original idea. I was inspired to try it myself after watching some of tsoding‘s videos using his header-only build system, nob. I don’t particularly like writing header-only libraries (I get why they are convenient from a consumer’s POV… that’s just not my goal), and I wanted to try my hand at my own design.

Let’s start with what I wanted out of the basics of a build system and go from there. I want the ability to:

  • Define targets (i.e., the things to be run that generate an artifact)
    • Right now I’m assuming that each target will generate one artifact
  • Spawn a process/command per target
  • Associate targets with other targets to be able to express a dependency graph
  • Ability to do partial rebuilds based on the dependency graph of targets

There are a couple of things I don’t want to do right off the bat. That doesn’t mean I won’t eventually do any of these things, but I want to keep it simple enough to be achievable as a proof of concept first. For one, I don’t want to worry about being able to use this build system on anything other than a unix-like system that has POSIX APIs. I don’t know how abstract this build system will get, but if it’s relatively bare bones, I don’t want to have to wrap concepts in opaque structs or any other kind of shenanigans to use POSIX in one implementation and Win32 in another (as an example). I also only want to support GCC and Clang initially. They both support the same style of command line flag syntax and I don’t want to deal with the headache of having another code path for MSVC or any other compilers right now.

So here’s the basic API that I came up with as a first draft. You are able to create a named target with otter_target_create that describes a list of files it intends to use. You can then associate a command to run for that target using otter_target_add_command. That command could be executed by using otter_target_execute.

typedef struct otter_target otter_target;
struct otter_target {
  otter_allocator *allocator;
  otter_filesystem *filesystem;
  otter_logger *logger;
  char *name;

  OTTER_ARRAY_DECLARE(char *, files);

  char *command;
  OTTER_ARRAY_DECLARE(char *, argv);
  OTTER_ARRAY_DECLARE(otter_target *, dependencies);
  unsigned char *hash;
  unsigned int hash_size;
  bool executed;
};

int otter_target_execute(otter_target *target);
void otter_target_free(otter_target *target);
OTTER_DEFINE_TRIVIAL_CLEANUP_FUNC(otter_target *, otter_target_free);
otter_target *otter_target_create(const char *name, otter_allocator *allocator,
                                  otter_filesystem *filesystem,
                                  otter_logger *logger, ...);
void otter_target_add_command(otter_target *target, const char *command);
void otter_target_add_dependency(otter_target *target, otter_target *dep);

Here is the usage of the targets API to generate the build system for otter. The output of this code creates and executable, otter_make, that can then be used to build the rest of the project. I’ve only included the bits that generate the make program here, but you can find the whole build source code here. Once all the artifacts of the build exist, otter_make, will only rebuild the targets that have changed since the previous build. This rebuild will propagate to the targets that depend on a target that has changed. This is figured out through hashing the file contents that each target depends on. I’ll get into the implementation details of this a lot more in a subsequent blog post.

#include "otter_allocator.h"
#include "otter_filesystem.h"
#include "otter_inc.h"
#include "otter_logger.h"
#include "otter_target.h"

#include <stddef.h>

#define CC_FLAGS_COMMON "-std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion "

#define CC_FLAGS_DEBUG CC_FLAGS_COMMON "-g -fsanitize=address,undefined "
#define CC_FLAGS_COVERAGE CC_FLAGS_COMMON "-fprofile-arcs -ftest-coverage"

#define CC_FLAGS CC_FLAGS_DEBUG

int main() {
  OTTER_CLEANUP(otter_allocator_free_p)
  otter_allocator *allocator = otter_allocator_create();

  OTTER_CLEANUP(otter_logger_free_p)
  otter_logger *logger = otter_logger_create(allocator, OTTER_LOG_LEVEL_INFO);
  otter_logger_add_sink(logger, otter_logger_console_sink);

  OTTER_CLEANUP(otter_filesystem_free_p)
  otter_filesystem *filesystem = otter_filesystem_create(allocator);

  /* Build the build system */
  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_allocator_obj = otter_target_create(
      "otter_allocator.o", allocator, filesystem, logger, "otter_allocator.c",
      "otter_allocator.h", "otter_inc.h", NULL);
  otter_target_add_command(
      otter_allocator_obj,
      "cc -fPIC -c otter_allocator.c -o otter_allocator.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_logger_obj = otter_target_create(
      "otter_logger.o", allocator, filesystem, logger, "otter_logger.c",
      "otter_logger.h", "otter_allocator.h", "otter_inc.h", "otter_array.h",
      NULL);
  otter_target_add_command(
      otter_logger_obj,
      "cc -c -fPIC otter_logger.c -o otter_logger.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_file_obj =
      otter_target_create("otter_file.o", allocator, filesystem, logger,
                          "otter_file.c", "otter_file.h", "otter_inc.h", NULL);
  otter_target_add_command(otter_file_obj,
                           "cc -c otter_file.c -o otter_file.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_filesystem_obj = otter_target_create(
      "otter_filesystem.o", allocator, filesystem, logger, "otter_filesystem.c",
      "otter_filesystem.h", "otter_file.h", "otter_allocator.h", "otter_inc.h",
      NULL);
  otter_target_add_command(
      otter_filesystem_obj,
      "cc -c otter_filesystem.c -o otter_filesystem.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_cstring_obj = otter_target_create(
      "otter_cstring.o", allocator, filesystem, logger, "otter_cstring.c",
      "otter_cstring.h", "otter_allocator.h", NULL);
  otter_target_add_command(
      otter_cstring_obj,
      "cc -c -fPIC otter_cstring.c -o otter_cstring.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_target_obj = otter_target_create(
      "otter_target.o", allocator, filesystem, logger, "otter_target.c",
      "otter_target.h", "otter_allocator.h", "otter_filesystem.h",
      "otter_logger.h", "otter_cstring.h", "otter_inc.h", NULL);
  otter_target_add_command(otter_target_obj,
                           "cc -c otter_target.c -o otter_target.o " CC_FLAGS);

  OTTER_CLEANUP(otter_target_free_p)
  otter_target *otter_make_exe = otter_target_create(
      "otter_make", allocator, filesystem, logger, "otter_make.c",
      "otter_allocator.h", "otter_filesystem.h", "otter_logger.h",
      "otter_target.h", "otter_inc.h", NULL);
  otter_target_add_command(
      otter_make_exe,
      "cc otter_make.c otter_target.o otter_allocator.o otter_file.o "
      "otter_filesystem.o "
      "otter_cstring.o otter_logger.o -o otter_make -lgnutls " CC_FLAGS);
  otter_target_add_dependency(otter_make_exe, otter_target_obj);
  otter_target_add_dependency(otter_make_exe, otter_allocator_obj);
  otter_target_add_dependency(otter_make_exe, otter_cstring_obj);
  otter_target_add_dependency(otter_make_exe, otter_logger_obj);
  otter_target_add_dependency(otter_make_exe, otter_filesystem_obj);
  otter_target_add_dependency(otter_make_exe, otter_file_obj);
  otter_target_execute(otter_make_exe);
}

I did fib a bit when I said that otter is an 100% C codebase. I ended up writing a very basic Makefile to improve on the usability of the project. It’s not required to actually build anything though and I treat it more as a convenience set of scripts that are entirely optional.

bootstrap:
	cc -g -fsanitize=address -o otter_bootstrap otter_make.c otter_target.c otter_allocator.c otter_logger.c otter_cstring.c otter_filesystem.c otter_file.c -lgnutls
	./otter_bootstrap
	rm ./otter_bootstrap

.PHONY: otter
otter:
	./otter_make

cstring_tests: otter
	./otter_test ./otter_cstring_tests.so

array_tests: otter
	./otter_test ./otter_array_tests.so

lexer_tests: otter
	./otter_test ./otter_lexer_tests.so

parser_tests: otter
	./otter_test ./otter_parser_tests.so
	./otter_test ./otter_parser_integration_tests.so

If you just run make, the bootstrap target will be run which generates an otter_bootstrap executable. It then runs the bootstrap executable which builds our otter_make program that I described above. Subsequent runs with the Makefile can specify make otter which will just forward to our build executable. I’ll also call out that I use the Makefile to easily run unit tests. I’ll make another post someday about the testing system in otter and its design/methodology.

For posterity and for those that are interested in seeing it all work: here’s the output from a clean build:

nathan@nathans-laptop:~/Dev/otter$ ./otter_make
[2026-01-03 20:27:37 UTC] - INFO - Executing target 'otter_target.o'
Command: 'cc -c otter_target.c -o otter_target.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_allocator.o'
Command: 'cc -fPIC -c otter_allocator.c -o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_cstring.o'
Command: 'cc -c -fPIC otter_cstring.c -o otter_cstring.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_logger.o'
Command: 'cc -c -fPIC otter_logger.c -o otter_logger.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_filesystem.o'
Command: 'cc -c otter_filesystem.c -o otter_filesystem.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_file.o'
Command: 'cc -c otter_file.c -o otter_file.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_make'
Command: 'cc otter_make.c otter_target.o otter_allocator.o otter_file.o otter_filesystem.o otter_cstring.o otter_logger.o -o otter_make -lgnutls -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_vm.o'
Command: 'cc -fPIC -c otter_vm.c -o otter_vm.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:38 UTC] - INFO - Executing target 'otter_bytecode.o'
Command: 'cc -fPIC -c otter_bytecode.c -o otter_bytecode.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter'
Command: 'cc -o otter otter.c otter_vm.o otter_logger.o otter_cstring.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter_test.o'
Command: 'cc -c -fPIC otter_test.c -o otter_test.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter_test'
Command: 'cc -o otter_test otter_test_driver.c otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter_cstring_tests.so'
Command: 'cc -fPIC -shared -o otter_cstring_tests.so otter_cstring_tests.c otter_test.o otter_cstring.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter_array_tests.so'
Command: 'cc -fPIC -shared -o otter_array_tests.so otter_array_tests.c otter_test.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:39 UTC] - INFO - Executing target 'otter_lexer.o'
Command: 'cc -fPIC -c otter_lexer.c -o otter_lexer.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:40 UTC] - INFO - Executing target 'otter_token.o'
Command: 'cc -fPIC -c otter_token.c -o otter_token.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:40 UTC] - INFO - Executing target 'otter_lexer_tests.so'
Command: 'cc -fPIC -shared -o otter_lexer_tests.so otter_lexer_tests.c otter_test.o otter_cstring.o otter_token.o otter_lexer.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:41 UTC] - INFO - Executing target 'otter_node.o'
Command: 'cc -fPIC -c otter_node.c -o otter_node.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:41 UTC] - INFO - Executing target 'otter_parser.o'
Command: 'cc -fPIC -c otter_parser.c -o otter_parser.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:41 UTC] - INFO - Executing target 'otter_parser_tests.so'
Command: 'cc -fPIC -shared -o otter_parser_tests.so otter_parser_tests.c otter_test.o otter_parser.o otter_allocator.o otter_token.o otter_node.o otter_cstring.o otter_logger.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:27:42 UTC] - INFO - Executing target 'otter_parser_integration_tests.so'
Command: 'cc -fPIC -shared -o otter_parser_integration_tests.so otter_parser_integration_tests.c otter_test.o otter_parser.o otter_allocator.o otter_token.o otter_node.o otter_cstring.o otter_logger.o otter_lexer.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '

Let’s say that now I’ve changed something in the implementation of otter_allocator.c. If I run otter_make again, this is now the output. Notice that it’s now a decent bit smaller than the original output. That’s because we really only need to rebuild otter_allocator.o and re-link the shared objects and executables that are dependent on it.

nathan@nathans-laptop:~/Dev/otter$ ./otter_make
[2026-01-03 20:29:21 UTC] - INFO - Executing target 'otter_allocator.o'
Command: 'cc -fPIC -c otter_allocator.c -o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:21 UTC] - INFO - Executing target 'otter_make'
Command: 'cc otter_make.c otter_target.o otter_allocator.o otter_file.o otter_filesystem.o otter_cstring.o otter_logger.o -o otter_make -lgnutls -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:21 UTC] - INFO - Target 'otter' up-to-date
[2026-01-03 20:29:21 UTC] - INFO - Executing target 'otter_test'
Command: 'cc -o otter_test otter_test_driver.c otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:21 UTC] - INFO - Executing target 'otter_cstring_tests.so'
Command: 'cc -fPIC -shared -o otter_cstring_tests.so otter_cstring_tests.c otter_test.o otter_cstring.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:21 UTC] - INFO - Executing target 'otter_array_tests.so'
Command: 'cc -fPIC -shared -o otter_array_tests.so otter_array_tests.c otter_test.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:22 UTC] - INFO - Executing target 'otter_lexer_tests.so'
Command: 'cc -fPIC -shared -o otter_lexer_tests.so otter_lexer_tests.c otter_test.o otter_cstring.o otter_token.o otter_lexer.o otter_allocator.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:22 UTC] - INFO - Executing target 'otter_parser_tests.so'
Command: 'cc -fPIC -shared -o otter_parser_tests.so otter_parser_tests.c otter_test.o otter_parser.o otter_allocator.o otter_token.o otter_node.o otter_cstring.o otter_logger.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '
[2026-01-03 20:29:24 UTC] - INFO - Executing target 'otter_parser_integration_tests.so'
Command: 'cc -fPIC -shared -o otter_parser_integration_tests.so otter_parser_integration_tests.c otter_test.o otter_parser.o otter_allocator.o otter_token.o otter_node.o otter_cstring.o otter_logger.o otter_lexer.o -std=c23 -Wall -Wextra -Werror -Wshadow -Wconversion -g -fsanitize=address,undefined '

2 responses to “Simple build system in C”

  1. Neat! I saw your activity on GitHub and was curious what this was. Any reason why you named it otter?
    Cool project. Does it handle the dependency tree? I don’t write much C anymore so I don’t really remember what CMake handles.

    1. I just like otters 🙂

      It does handle the dependency tree. Each target can have an array of dependencies. Each item in the array is a pointer to another target. You can imagine something like


      a
      / \
      b c
      /
      d

      Executing targets is then a recursive operation that in turn has to execute any of the targets it depends on.

      CMake is a meta build generator. It will generate a build system for you depending on the platform you are targeting (e.g., Visual Studio solutions for Windows, Makefiles for other systems, etc.) So really, this C program I’ve written is more emulating build systems like Makefiles instead of CMake itself. It was probably a bad comparison on my part 🙂

Leave a Reply

Discover more from Nathaniel Wright

Subscribe now to keep reading and get access to the full archive.

Continue reading