Introduction
I am not an expert on GNU Make, or building C programs, but I want to share some information that I wish I had found more easily when learning how to write effective Makefiles.
Makefiles are most commonly used for C/C++, but I think they provide a good and consistent interface for building and installing software of any tech stack. For this article, I will focus on using Make for C.
What is it?
Make is a DSL for managing your projects, it usually has variable declarations at the top, and then a series of "targets" that will be exposed via the CLI.
Here is a basic example to build Hello World:
CC=gcc
CFLAGS=-g -std=gnu11 -Wall
PROGNAME=hello-world
build:
$(CC) $(CFLAGS) -o $(PROGNAME) hello.c
Keep in mind Make is whitespace sensitive, you need to have a \t (tab) in the lines following a target name.
Now, assuming you have a hello.c in the same directory, you can build and run the program like this:
make build
./hello-world
You can also just run `make` instead of `make build` and it will find the first target that does not begin with a `.`, so this works too:
make
./hello-world
Try changing the variables in this Makefile and see what happens!
C project structure
So far we haven't seen anything very useful, it's not very hard to just type the CC command into your shell for hello world, and most projects have many source files.
Lets expand our Makefile a bit, copy this code and I will explain how it works:
CC=gcc
CFLAGS=-g -std=gnu11 -Wall
PROGNAME=hello-world
# define subdirectories
BINDIR=bin
SRCDIR=src
OBJDIR=obj
# binary path
BIN=$(BINDIR)/$(PROGNAME)
# lists of src and obj files
SRCS=$(wildcard $(SRCDIR)/**/*.c) $(wildcard $(SRCDIR)/*.c)
OBJS=$(patsubst $(SRCDIR)/%.c, $(OBJDIR)/%.o, $(SRCS))
# create directory structure
prepare:
-mkdir $(BINDIR)
-mkdir $(SRCDIR)
-mkdir $(OBJDIR)
# build objects from source
$(OBJDIR)/%.o: $(SRCS)
$(CC) $(CFLAGS) -c $< -o $@
# build binary from objects
$(BIN): $(OBJS)
$(CC) $(CFLAGS) $(OBJS) -o $(BIN)
build: $(BIN)
Now, run `make prepare` to set up the project structure. As you can probably guess from the code, this creates three directories. The dash prefix tells Make to ignore errors if these directories already exist. Your project directory should now look like this:
$ ls
bin Makefile obj src
Put a .c file with a hello world main function in src/ and then type `make build`, now your project structure should look like this:
$ tree
.
├── bin
│ └── hello-world
├── Makefile
├── obj
│ └── hello.o
└── src
└── hello.c
./bin/hello-world
hello world
Okay, sorry if you are confused, let me explain what all is happening here…
SRCS and OBJS
These two variable definitions use special Make functions to collect the .c and .o files from the proper directories, if you want to see what exactly these values hold, you can create a debug target that just echos out variables:
debug:
echo $(OBJS)
echo $(SRCS)
$ make debug
echo obj/hello.o
obj/hello.o
echo src/hello.c
src/hello.c
This is pretty simple now since there is only one source and object file, but with this setup, we don't have to do any extra work to add as many files to the project as we want.
Special targets
The next thing that looks confusing is probably the `$(OBJDIR)/%.o` target, this target compiles source files into object files. The `$(SRCS)` after the colon shows the files this step depends on, if any of the source files change, this part runs again. The $< and $@ symbols are kind of confusing, but they basically take a .c file and make a .o file of the same name.
After the object files are built, we can run the binary target, which depends on the objects, and creates our final executable. We can't run this target directly from the CLI, so we will make a `build` target that depends on the binary and does nothing else.
Exercises
- Add some more source and header files to your project and call them from your main function.
Helper targets
Usually Makefiles have a few blocks that help with managing the project, like `install`, `uninstall`, `clean`, `run`, et cetera.
# remove objects and binaries
clean:
-rm -r $(OBJDIR)/*
-rm -r $(BINDIR)/*
Just like the prepare target, this uses dashes so that if there are no object files, but there are binaries (for some reason), the first line won't cause Make to error and quit before removing binaries.
Exercises
- Make an `install` target that moves the built binary into your $PATH. You will probably want to make a variable that specifies the installation path so other users can easily adapt it.
- Make an `uninstall` target to remove the binary from $PATH
Managing dependencies
In C, we do not have de-facto package manager for a project, instead we use our system package manager and use CC flags to link to the necessary dependencies.
Lets say you wanted to use glib in our project, which is a popular C library including common things like linked lists, hash-tables, and heap-allocated arrays. In the most basic example, you would need to supply a whole bunch of flags to your CC command.
gcc -I/usr/include/glib-2.0 -I/usr/lib/glib-2.0/include -I/usr/include/sysprof-6 -pthread -lglib-2.0 main.c
It would be pretty painful if we had to look up the headers to include and objects to link to every time we wanted to add another dependency. Thankfully, most systems provide a tool called `pkg-config`, install it with your package manager if you don't already have it.
Run this command and inspect the output:
pkg-config --cflags --libs glib-2.0
You will see that this gives you all the flags you need to compile a program with glib.
Now, to make everything a lot easier, you can define a variable in your Makefile that holds the output of a pkg-config command:
LDLIBS=`pkg-config --cflags --libs glib-2.0`
Now, in your Makefile substitute `$(CC) $(CFLAGS)` with `$(CC) $(CFLAGS) $(LDLIBS)` and all your dependencies should magically work.
Keep in mind, this only allows you to build and run the program, if you are using clang LSP in your editor, it still won't be able to find the right header files. To fix this, use bear. Execute the following commands:
make clean
bear -- make build
This will generate a file called `compile-commands.json` in your project root, and now when you restart the LSP, everything should work perfectly. You might want to put this in your `prepare` target.
Exercises
- Run pkg-config with other libraries and inspect the output, try libcurl or libuv for example.
Resources
The book 21st Century C has a lot of good info about building and managing C projects and goes a lot more in-depth than this blog post. If you learn well from videos, I recommend Jacob Sorber's videos about Make