Using Variables in GNU Make (Make series pt.2)

Using Variables in GNU Make (Make series pt.2)

making your build process more flexible

ยท

6 min read

This is the second article in our Makefile Basics series. Check out our first article if you haven't already, as it covers all the groundwork needed so far. All set? Let's go!

So far we have set up a very basic Makefile which we are using to compile our toy project. As a reminder, this is the project structure we are using:

Which we are compiling with the following makefile:

all: main
    echo "If a rule is present in all, this will always run ๐Ÿ˜ฑ"

main: src/a.o src/b.o
    gcc src/a.o src/b.o -Iinclude -o main

src/a.o: src/a.c
    gcc -c src/a.c -o src/a.o -Iinclude

src/b.o: src/b.c
    gcc -c src/b.c -o src/b.o -Iinclude

Here are some issues with our current makefile:

  1. Too specific: if we want to add new files to our project, such as x.c, we have to manually create rules for them.

  2. No build directory: currently, we are compiling all our object files next to our source files. This makes our project bloated and hard to navigate after each compilation.

  3. No c dependency tracking: ok, this one is a bit more tricky, but Make doesn't know how c works. If we modify our header files a.h and b.h, Make will not automatically recompile the associated a.c and b.c files. This is because Make works on a per-file basis and cannot automatically detect dependencies across multiple files. Instead, we have to manually tell it how to handle dependency tracking in c.

In this post, we will be focusing on the first issue and making our Makefile more generalized.


Variables in GNU Make

If you want to make your code more generalized in a language such as c, you are probably thinking about using variables. The same is true in Make.

warning: in Make, all variables are strings. Handling integers and floats is a more complex topic which we will be dealing with in later articles. Don't worry though, except for very advanced Makefiles you won't be needing this functionality.

SOME_VAR = value
SOME_LST = value1 value2 value3

all:
    echo $(SOME_VAR)
    echo $(SOME_LST)

Let's break this down!

SOME_VAR = value
SOME_LST = value1 value2 value3

In Make, any variable is comprised of a series of strings. To declare a variable, write down its name, followed by an '=', and a series of space-separated values.

By convention, all Make variables should be UPPER CASE and SNAKE_CASE.

all:
    echo $(SOME_VAR)
    echo $(SOME_LST)

To access a variable in Make, use the $() syntax, with your variable name between the (...).

  • Single-value variables will be displayed normally

  • Lists will have their values separated by a ' '

Let's try and use this new-found knowledge to update our Makefile!

Remember to keep your Makefile as general as possible with variables. Keep in mind that any part of your build process might change, including directory names, binary names, compilation options, and source files.

# header directory
DIR_INC = ./include

# c compiler and compilation options
C_COMPILER = gcc
C_ARGS = -Wall -Wextra -Werror -O3 -I$(DIR_INC)

# the name of the binary to genarate
BINARY = ./main

# this is the same thing as 'all: ./main'
all: $(BINARY)

# this is the same thing as './main: src/a.o src/b.o'
$(BINARY): src/a.o src/b.o
    $(C_COMPILER) $(C_ARGS) src/a.o src/b.o -o $(BINARY)

src/a.o: src/a.c
    $(C_COMPILER) $(C_ARGS) -c src/a.c -o src/a.o

src/b.o: src/b.c
    $(C_COMPILER) $(C_ARGS) -c src/b.c -o src/b.o

Wohooo! We now have much more control over how we compile our source files, and we can change these options at any time without having to modify a hundred different lines of code!

But how can we achieve similar flexibility regarding which files are compiled?

FILES_SRC = ./src/a.c ./src/b.c
FILES_OBJ = ./src/a.o ./src/b.o

Even if we define our files as a list, how can Make compile them automatically regardless of their name? Is there some way to automate this?


Wildcards

For information on the wildcard function in GNU Make, see our upcoming article on Make functions.

You might remember wildcards from using the terminal in Linux. If you don't, what are you waiting for, start using Linux! In Make, wildcards are represented by the '%' symbol.

wildcards represent a pattern in a string. './src/%.c' represents any file that starts with ./src/ and ends with .c.

We can use wildcards in GNU Make to construct more general rules that target any files with a specific pattern in their name.

# This is a rule which will target any object file in ./src.
# For that file to be compiled, a file with the same pattern but
# ending in '.c' must exist in ./src.
./src/%.o: ./src/%.c

# './src/a.o' will use this rule. 
# For it to be compiled, './src/a.c' must exist

Ok, that's all well and good, but if we have rules that apply to multiple files, how can we retrieve the name of each file to compile them?

# how can we access the name of the object file being built
# and the source file it depends on?
./src/%.o: ./src/%.c
    gcc -c ??? -o ???

Rule Variables

Not everything in Make is intuitive. In fact, a lot of features in Make are very unintuitive*.* Rule variables are such a feature.

Rule variables allow us to reference the name and the dependencies in a rule without knowing them in advance.

  • $@: the task in a rule

  • $^: every prerequisite in a rule

  • $<: the first prerequisite in a rule

task: rule1 rule2 rule3
    echo $@ # prints 'task'
    echo $^ # prints 'rule1 rule2 rule3'
    echo $< # prints 'rule1'

I know this makes no sense but try and keep this in your head. A lot of Make is just like this: strange syntax you just have to memorize.

Coming back to wildcards, we can use rule variables to access the name of the files we want to compile

./src/%.o: ./src/%.c
    gcc -c $^ -o $@ # same as 'gcc -c a.c -o a.o'

Here is our updated Makefile

# ================================================================== #
#                              VARIABLES                             #
# ================================================================== #

# source and header directory
DIR_SRC = ./src
DIR_INC = ./include

# source and object files
FILES_SRC = $(DIR_SRC)/a.c $(DIR_SRC)/b.c
FILES_OBJ = $(DIR_SRC)/a.o $(DIR_SRC)/b.o

# c compiler and compilation options
C_COMPILER = gcc
C_ARGS = -Wall -Wextra -Werror -O3 -I$(DIR_INC)

# the name of the binary to genarate
BINARY = ./main

# ================================================================== #
#                                RULES                               #
# ================================================================== #

# this is the same thing as 'all: ./main'
all: $(BINARY)

# this is the same thing as './main: src/a.o src/b.o'
#
# Note that we are now using $(FILES_SRC) instead of './src/a.c' and
# './src/b.c' to compile our project, so any new files we add will
# automatically be included in the compilation 
$(BINARY): $(FILES_OBJ)
    $(C_COMPILER) $(C_ARGS) $(FILES_SRC) -o $(BINARY)

# this rule will be applied to './src/a.c' AND './src/b.c'
$(DIR_SRC)/%.o: $(DIR_SCR)/%.c
    $(C_COMPILES) $(C_ARGS) -c $^ -o $@

This is quite a leap in complexity already, so take your time to re-read the code.

We are trying to generalize our Makefile by encapsulating any information we might want to modify later into variables. This is especially useful for source files, as we will be adding new files as our project progresses.

To make this process easier, we have created a general rule using wildcards and rule variables that compiles all our source files into object files. Now, all we need to do is modify FILES_SRC and FILES_OBJ each time we add new files to our project!


Conclusion

I hope you are beginning to see how Make can be a powerful tool when compiling large projects by automatically generating commands for you as well as tracking changes.

Currently though, we still have to specify our source and object files. In the next article, we will see how we can use Make Functions to automate that process.

ย