Contents:

  • Introduction
  • Project Setup
  • Makefile
  • Conclusion

Introduction

I've been doing a lot of side projects in the C language lately.
Which has lead me to write Makefiles for all the scenarios I encounter with each new project.

The more I modified the Makefiles I was using to satisfy each new project structure
has lead to a Makefile I barely need to change now.

!!
I know this Makefile won't solve all usecases but it tends to cover most of my needs.
!!

Project Setup

To start, I'll show what a typical project setup looks like for me.

├── LICENSE
├── Makefile
├── README.md
├── compile_flags.txt
├── install_deps.sh
├── run.sh
├── resources
│   └── 
├── bin
│   ├── lib.(so|a)
│   └── exe
├── obj
│   └── 
├── deps
│   └── 
└── src
    ├── lib
    └── main.c

I'll quickly give a highlight of everything, but the folders are the important part.

  • LICENSE the license.
  • Makefile The Makefile.
  • README.md The readme.
  • compile_flags.txt Clangd file for LSP configuration.
  • install_deps.sh Shell file to install dependencies.
  • run.sh Shell script to run the generated executable.
  • resources The resources folder.
  • bin The folder for the target executable or library.
  • obj The object files when compiling.
  • deps The third party dependencies.
  • src The source code.
  • src/lib The common code (like for a library).

Makefile

Now that you know what folder structure to expect we can look at the Makefile.
First I'll present the Makefile and then we will break it down.

# define our compiler
CC=gcc
# define our generic compiler flags
CFLAGS=-Wall -Wextra -std=c11
# define the paths to our third party libraries
LIBS=-L./deps/example_lib/build -lexample_lib.so -lm
# define the paths to our include directories
INCLUDES=-I./src -I./deps/example_lib/include

# define variables for our source files
# we use find to grab them
SOURCES=$(shell find ./src -name '*.c')

# define variable for our dependencies' Makefiles.
# we use find to grab only the top level Makefiles and also some convenient ignores.
DEPS=$(shell find ./deps -maxdepth 2 -name Makefile -printf '%h\n' | grep -v 'unittest' | grep -v '^.$$')

# define folder paths and names
OBJ=obj
BIN=bin
TARGET=main

# setup up conditional build flags
# if debug is set to 1, add debug specific flags
ifeq ($(DEBUG), 1)
    CFLAGS += -DDEBUG=1 -ggdb
endif
# Release specific flags
ifeq ($(RELEASE), 1)
    CFLAGS += -O2
endif
# if SHARED flag is set, we prepare variables for building a shared/static library.
# we change the SOURCES variable to point to only the common source files.
# we also rename the TARGET to our library name.
ifeq ($(SHARED), 1)
    SOURCES=$(shell find ./src/lib -name '*.c')
    TARGET=my_lib
endif

# This variable is for our object files.
# We take the files in SOURCES and rename them to end in .o
# Then we add our OBJ folder prefix to all files.
OBJECTS=$(addprefix $(OBJ)/,$(SOURCES:%.c=%.o))

# We setup our default job
# it will build dependencies first then our source files.
.PHONY: all
all: deps src

# Build the source files.
# Conditional change to building for an executable or libraries.
# We also create the output BIN directory if it doesn't exist.
# This job depends on the OBJECT files.
.PHONY: src
src: $(OBJECTS)
    @mkdir -p $(BIN)
ifeq ($(SHARED), 1)
    $(CC) -shared -fPIC -o $(BIN)/$(TARGET).so $^ $(LIBS)
    ar -rcs $(BIN)/$(TARGET).a $^
else
    $(CC) $(CFLAGS) $(LIBS) $^ -o $(BIN)/$(TARGET)
endif

# Compile all source files to object files
# This job executes because the `src` job depends on all the files in OBJECTS
# which has the `$(OBJ)/%.o` file signature.
$(OBJ)/%.o: %.c
    @mkdir -p $(dir $@)
    $(CC) -c -o $@ $< $(CFLAGS) $(INCLUDES)

# Job to clean out all object files and exe/libs.
.PHONY: clean
clean:
    @rm -rf $(OBJ)/* 2> /dev/null
    @rm -f $(BIN)/* 2> /dev/null

# Job to run `make clean` on all dependencies.
.PHONY: clean_deps
clean_deps:
    $(foreach dir, $(DEPS), $(shell cd $(dir) && $(MAKE) clean))

# Job to clean dependencies and our files.
.PHONY: clean_all
clean_all: clean clean_deps

# Job to run `make` on all of our dependencies.
# This only works if the dependencies' Makefile is at the top and implements a
# default job.
.PHONY: deps
deps:
    $(foreach dir, $(DEPS), $(shell cd $(dir) && $(MAKE)))

The Common Variables

At the top we have our common variables to define our compiler, compiler flags,
library paths, and includes paths.

Add or remove anything from these variables to fit your project's requirements.

# define our compiler
CC=gcc
# define our generic compiler flags
CFLAGS=-Wall -Wextra -std=c11
# define the paths to our third party libraries
LIBS=-L./deps/example_lib/build -lexample_lib.so -lm
# define the paths to our include directories
INCLUDES=-I./src -I./deps/example_lib/include

The Source Files

We expect all of our source files to be under the src folder. So we use
find to grab all of them. By default we assume we are compiling for an executable,
we can change this later with some conditional logic.

# define variables for our source files
# we use find to grab them
SOURCES=$(shell find ./src -name '*.c')

The Dependencies

We want to automate compiling our third party dependencies as well so we grab their
Makefiles at the top of their directories. We exclude any unittest directories.

This might need to change as well depending on what third party dependencies you use.

# define variable for our dependencies' Makefiles.
# we use find to grab only the top level Makefiles and also some convenient ignores.
DEPS=$(shell find ./deps -maxdepth 2 -name Makefile -printf '%h\n' | grep -v 'unittest')

Folders & Target

We always need a bin and obj folder. We default the target to an executable
name (this can change with conditional logic).

# define folder paths and names
OBJ=obj
BIN=bin
TARGET=main

Conditional Flags

We setup some conditional flags we can pass to make. We define DEBUG,
RELEASE, and SHARED already. The DEBUG and RELEASE modify the CFLAGS
to better align with their respective states.

The SHARED flag is independant from the other conditional flags because it changes
what source files we use and the TARGET name.

# setup up conditional build flags
# if debug is set to 1, add debug specific flags
ifeq ($(DEBUG), 1)
    CFLAGS += -DDEBUG=1 -ggdb
endif
# Release specific flags
ifeq ($(RELEASE), 1)
    CFLAGS += -O2
endif
# if SHARED flag is set, we prepare variables for building a shared/static library.
# we change the SOURCES variable to point to only the common source files.
# we also rename the TARGET to our library name.
ifeq ($(SHARED), 1)
    SOURCES=$(shell find ./src/lib -name '*.c')
    TARGET=my_lib
endif

Object Files

We setup our Object files next so we can construct them based on what the SOURCES
variable is conditionally set to.

# This variable is for our object files.
# We take the files in SOURCES and rename them to end in .o
# Then we add our OBJ folder prefix to all files.
OBJECTS=$(addprefix $(OBJ)/,$(SOURCES:%.c=%.o))

The Jobs

The rest of the Makefile are the jobs.

Default Job

The first one is the default job. It's an empty job but we use it to set
up the dependency tree for our Makefile.

We first depend on deps then src.

# We setup our default job
# it will build dependencies first then our source files.
.PHONY: all
all: deps src

Source Job

We setup our src job to have a dependency on the Object files we defined.

Inside the job we create the bin directory if it doesn't exist then we conditionally
execute code to build either libraries or an executable.

All the places you see $^ is Makefile magic to reference everything in the dependency
list (which are all the files in OBJECTS).

# Build the source files.
# Conditional change to building for an executable or libraries.
# We also create the output BIN directory if it doesn't exist.
# This job depends on the OBJECT files.
.PHONY: src
src: $(OBJECTS)
    @mkdir -p $(BIN)
ifeq ($(SHARED), 1)
    $(CC) -shared -fPIC -o $(BIN)/$(TARGET).so $^ $(LIBS)
    ar -rcs $(BIN)/$(TARGET).a $^
else
    $(CC) $(CFLAGS) $(LIBS) $^ -o $(BIN)/$(TARGET)
endif

Object Files Job

The object files job looks a little weird at first but the job name is a wildcard.
It matches anything with the signature $(OBJ)/%.o which is what all of our files
listed in the OBJECTS variable match.

The %.c dependency is some Makefile magic to find the source file with the same path
except the $(OBJ)/ part.

There are 2 other Makefile magic symbols that need some explaining in here.
First is the $@ symbol to reference the left side of the job signature
(the $(OBJ)/%.o side).
Second is the $< symbol to reference the first item in the dependency side of the
job signature (the %.c side).

# Compile all source files to object files
# This job executes because the `src` job depends on all the files in OBJECTS
# which has the `$(OBJ)/%.o` file signature.
$(OBJ)/%.o: %.c
    @mkdir -p $(dir $@)
    $(CC) -c -o $@ $< $(CFLAGS) $(INCLUDES)

Clean Jobs

The next section are all the jobs related to clean up.

The first job is to clean our project. You'll notice we pipe the errors to
/dev/null this is because we get errors if the folders are empty when cleaning.

The second job is to run make clean on all of the dependencies. We need to call
make as $(MAKE) for it to execute properly.

The last job is to do both of the cleanup jobs together.

# Job to clean out all object files and exe/libs.
.PHONY: clean
clean:
    @rm -rf $(OBJ)/* 2> /dev/null
    @rm -f $(BIN)/* 2> /dev/null

# Job to run `make clean` on all dependencies.
.PHONY: clean_deps
clean_deps:
    $(foreach dir, $(DEPS), $(shell cd $(dir) && $(MAKE) clean))

# Job to clean dependencies and our files.
.PHONY: clean_all
clean_all: clean clean_deps

Dependency Job

The last job in the Makefile is to compile our third party dependencies.
This command iterates over the Makefiles we captured in the DEPS variable
and runs make. This assumes the third party dependencies have a default job setup
and it's the one you want to run.

This section may need to change depending on your projects needs.

# Job to run `make` on all of our dependencies.
# This only works if the dependencies' Makefile is at the top and implements a
# default job.
.PHONY: deps
deps:
    $(foreach dir, $(DEPS), $(shell cd $(dir) && $(MAKE)))

Conclusion

Hopefully this Makefile helps you, if not fully, maybe it at least gets you started
in having something that can cover the uses you need.