How to build your C project with Make

Why do we need Make?

If you don’t want to manually build your code (as in, running gcc every time), or you have a complex codebase (or, say, tests), Make is a great tool. It also helps ensure that your build is run the same way every time.

One great feature of Make is that it only rebuilds objects when the source has changed for that object, which saves a ton of build time for large codebases. I also personally like using Make for all kinds of non-C/C++ projects, rather than building some specific build/run script.

Getting set up

For this tutorial I’m assuming your dev environment is like the one I set up in my dev environment tutorial, but as long as you’re running Linux and have the C dependencies and Make installed, you should be fine.

To get started, we’re going to build a basic C adding library, with some tests, so we can flex our build script. This library wouldn’t be helpful in any real case, but it’s an easy way to try out Make.

Let’s make a new directory for our project, and a couple subfolders to hold our files:

mkdir c-make-tutorial

mkdir c-make-tutorial/src
mkdir c-make-tutorial/include
mkdir c-make-tutorial/lib
mkdir c-make-tutorial/bin
cd c-make-tutorial

Now let’s create the header file for our new library at include/add.h:

#ifndef ADD_H

#define ADD_H

/**
* Calculates the sum of two integers (a+b).
* @param int a One of the operands to be summed.
* @param int b The other operand to be summed.
* @returns int The sum of the two operands.
*/
int add(int a, int b);

#endif

And create the source file at src/add.c:

#include "add.h"


int add(int a, int b) {
return a + b;
}

Now we have a C library that should compile just fine. Let’s try it out!

~/c-make-tutorial$ gcc -Wall -Wextra -fpic -shared -Iinclude src/add.c -o lib/add.so

If you’re not super familiar with the C compiler that might have been a lot, so we can go over the flags briefly. -Wall and -Wextra enable all the warnings gcc may provide. -fpic and -shared are for building libraries. -I{folder} is for including directories; in this case we want to include the directory where our header file is located. src/add.c is the name of our source file, and -o tells gcc where to put our compiled file when it’s done. Note that this file won’t be executable (successfully, anyways), because it’s a library and doesn’t have a main function.

So why do we need Make, again?

Doing it “manually” that way isn’t great for a few reasons. Firstly, we had to create the lib and bin folders manually at the beginning, but we wouldn’t want to commit those to Git, so anyone trying to build our library later would have to create those folders. Also, it’s just a long command to remember, and if you get it wrong you could break your program down the road.

Good software build processes are all about reproducibility: your program should build the same way every time. As our program becomes more complex than a dead-simple adding library, it becomes unwieldy to build it by hand. Let’s see that in action by building a simple testing framework for our new library.

Create a file called src/testAdd.c:

#include <stdio.h>
<br>#include <assert.h>
<br>#include "add.h"
<br>
<br>int main() {
<br>    printf("Running tests...");
<br>    int a = 1;
<br>    int b = 2;
<br>    assert(add(a,b) == 3);
<br>    printf("Done.\n");
<br>    return 0;
<br>}

This is a super simple program that will test the base functionality of our add() function. If you’re unfamiliar with assert(), it prints to STDERR if the condition you provide it is false, otherwise it does nothing. So if we were to run this program and get no output, all is well! Let’s build it:

gcc -Wall -Wextra -Iinclude src/testAdd.c lib/add.so -o bin/testAdd

You’ll notice that this command depends on the add.so object file that we built with the last gcc command, which makes sense. That also means that if we make changes to add.c, we need to recompile both add.c and testAdd.c, in that order. Let’s run it for good measure:

$ bin/testAdd

Running tests...Done.

That’s the output we expect when our simple test passes, so we’re good! See how this is getting more complicated?

Building our Makefile

Setting up Make is relatively easy. It has two components: your Makefile, which is a file that contains your build instructions, and the make command, which you run to execute those instructions. If you’re at all familiar with Docker, the Dockerfile is a very similar concept.

So create a Makefile file in the root of our project folder. First we’ll set up a few variables at the top of our file:

CC = gcc

CFLAGS += -Wall -Wextra
GENDIRS = bin lib
LIBFLAGS = -fpic -shared
INCLUDES = include
LIBPATH = lib

Some of these are hopefully pretty self-explanatory, but let’s walk through them. CC is our compiler executable, in this case gccCFLAGS are flags we want to use in every gcc command, and these are those warning flags I mentioned earlier. GENDIRS is a list of directories we want to create automatically. LIBFLAGS is a list of flags specifically for building libraries, which we already covered. and then INCLUDES and LIBPATH are directories we’ll be referencing in our gcc commands.

Make calls the commands you run in it “rules”, and the syntax is like this:

targets : prerequisites

recipe

Now let’s add rules to build our directories and library:

allDirs:

mkdir -p $(GENDIRS)

lib: allDirs
$(CC) $(CFLAGS) $(LIBFLAGS) -I$(INCLUDES) src/add.c -o $(LIBPATH)/add.so

Go ahead and delete your lib and bin directories. Now, run:

$ make lib

and you should see the output:

mkdir -p bin lib

gcc -Wall -Wextra -fpic -shared -Iinclude src/add.c -o lib/add.so

If you get an error like No targets specified, you’re not in the correct directory. Your Makefile should be in your project root(c-make-tutorial) and you should be running make from there.

Eagle-eyed readers may have noticed that it ran the lib rule, but it ran the allDirs rule first; that’s because we made allDirs a dependency of lib by putting allDirs on the same line as lib: after the colon. You can do that with multiple dependencies, in order. Now let’s add a rule to build and run our tests:

test: lib

$(CC) $(CFLAGS) -I$(INCLUDES) src/testAdd.c lib/add.so -o bin/testAdd
bin/testAdd

We make lib a dependency of our new test rule, because we need the add library to be built before we can test it. Then we compile testAdd.c against our compiled add.so file, and we run it. Now if we run make test, we expect to see:

mkdir -p bin lib

gcc -Wall -Wextra -fpic -shared -Iinclude src/add.c -o lib/add.so
gcc -Wall -Wextra -Iinclude src/testAdd.c lib/add.so -o bin/testAdd
bin/testAdd
Running tests...Done.

Many projects also choose to create an all rule with all your rules as dependencies, so that make all runs everything. Let’s do that by adding the rule right above allDirs:

all: allDirs lib test

You can also specify a .PHONY rule, which gets executed when you run make without any additional rule parameters. Let’s add that too, to make our finished Makefile look like this:

CC = gcc

CFLAGS += -Wall -Wextra
GENDIRS = bin lib
LIBFLAGS = -fpic -shared
INCLUDES = include
LIBPATH = lib

.PHONY: all

all: allDirs lib test

allDirs:
mkdir -p $(GENDIRS)

lib: allDirs
$(CC) $(CFLAGS) $(LIBFLAGS) -I$(INCLUDES) src/add.c -o $(LIBPATH)/add.so

test: lib
$(CC) $(CFLAGS) -I$(INCLUDES) src/testAdd.c lib/add.so -o bin/testAdd
bin/testAdd

Now, if you run make, it will build and test all our code.

And just like that, we’re done! This was a simple example, but hopefully it showed you the power of Make, and how you can use it in your own projects in the future. You can also find the code for this tutorial on my GitHub profile.

If you have thoughts, or you think I’m wrong and just have to tell me, email me!