Ben's blog

rambling through cyberspace

Introduction

Today I'd like to talk about Makefiles, since these days they seem to have fallen out of favor and are basically forgotten by anyone without a beard. I'll start by giving a simple C example application where we'll build the Makefile up step by step. If you're impatient you can just clone the template on GitHub or copy out the parts you're interested in.

The Project Structure

The project itself is a boring example consisting of 2 source files, mainly to test out the various bits of the Makefile.

// main.c
#include <stdio.h>
#include "util.h"

int main(int argc, char *argv[]){
    char *name = NULL;
    while(!name){
        printf("What is you name? ");
        name = getInput();
    }
    printf("Hello, %s", name);
}
// util.c
#include <stdio.h>
#include <string.h>

char *getInput() {
  static char buffer[256];
  if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
      buffer[strcspn(buffer, "\n")] = 0;
      return buffer;
  }
  return NULL;
}
// util.h
char *getInput();

Nothing particularly interesting as you can see, just a sort of bloated hello world program.

Creating an Efficient Makefile

This is pretty much the essence of the GNU Makefile (this does not work with BSD Make, one can probably port it though).

SRC          := $(shell find ./ -type f -name '*.c')
OBJ          := $(SRC:.c=.o)
DEPENDENCIES := $(SRC:.c=.d)
BIN          := hello-name-test

ifneq ($(MAKECMDGOALS),clean)
-include $(DEPENDENCIES)
endif

$(BIN): $(OBJ)
	$(CC) -o $@ $(OBJ) $(CFLAGS) $(LIBS)

%.o: %.c
	$(CC) -o $@ -c $< $(CFLAGS) -MMD > ${<:.c=.d}

clean:
	rm -f $(OBJ) $(BIN) $(DEPENDENCIES)

Automatic Dependency Tracking

The key benefit of this Makefile is automatic dependency tracking. Here's how it works:

The overall idea is to automatically find all the source files using find and then generating a list of object files and dependency files out of it. These dependency files are automatically generated by the compiler during compilation using the -MMD flag which generates a dependency list in a make compatible syntax.

This way we don't have to manually specify any dependencies or source files. Things mostly just work as we extend the project and add/move source files around.

Setting Up Cross-Platform CI with GitHub Actions

While cleaning up the Makefile and building an example project I've also started working on a GitHub Actions CI template. This was mainly for Windows support since it can be a pain to get this going which does result in me sometimes ignoring Windows. Here's the full GitHub Actions CI File:

name: Simple C CI

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

jobs:
  build-and-test-nix:
    name: ${{ matrix.icon }} - ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { icon: '🐧', os: 'ubuntu-latest' }
          - { icon: 'ðŸĶĒ', os: 'ubuntu-22.04-arm' }
          - { icon: '🍏', os: 'macos-13' }
          - { icon: '🍎', os: 'macos-latest' }
    
    steps:
      - uses: actions/checkout@v4
        name: 🔄 Checkout

      - name: ðŸ”Ļ Build
        run: make
      
      - name: 🔎 Test
        run: make test

  build-and-test-win:
    name: ${{ matrix.icon }} - ${{ matrix.sys }} - ${{ matrix.os }} 
    runs-on: ${{ matrix.os }}
    defaults:
      run:
        shell: msys2 {0}
    strategy:
      fail-fast: false
      matrix:
        include:
          - { icon: 'ðŸ§ļ', sys: mingw32, os: 'windows-latest' }
          - { icon: '🊟', sys: mingw64, os: 'windows-latest' }
          - { icon: '🌐', sys: ucrt64, os: 'windows-latest' }
          - { icon: '🐉', sys: clang64, os: 'windows-latest' }
          - { icon: '💊', sys: clangarm64, os: 'windows-11-arm' }
    
    steps:
      - uses: msys2/setup-msys2@v2
        name: 🔄 Setup MSys2
        with:
          msystem: ${{matrix.sys}}
          install: git make
          cache: true
          pacboy: toolchain:p
    
      - uses: actions/checkout@v4
        name: 🔄 Checkout
      
      - name: ðŸ”Ļ Build
        run: make
      
      - name: 🔎 Test
        run: make test  

Platform Support and Testing

If you're familiar with GitHub Actions all of this should look quite familiar, the *nix jobs are quite simple as you can see and for Windows/msys2 it was mainly figuring out the various fields one needs to set to get things working correctly.

This one is really testing all the platforms, including Windows on ARM, 32-Bit Windows or Ubuntu ARM64. So this should be great if you want to build portable software, as an aside: you can also use Sourcehut if you want to automatically test on the BSD's or more interesting Linux Distros like Guix/Nix/Alpine.

Conclusion

Was quite fun to clean up the various bits and pieces I had flying around in various projects of mine. Also finally got around to setting up a nice GitHub Actions CI for C projects.

These Makefiles have served me well across multiple projects, providing a simple yet powerful build system without the overhead of more complex alternatives. The CI setup ensures your code works consistently across a wide range of platforms, which is especially valuable for C projects that should run everywhere.