This blog post introduces basics of Make in Linux.

In the article, Road to C Programmer #14 - Modularization, we discussed how we can use object files and header files to modularize the code, which requires us to compile files in multiple steps. To keep compilation or builds easy and simple, we might want to automate these steps with a build system.
Bash Scripting
One possible way to manage the compilation is to use bash scripting. We can create a shell script containing all the commands necessary for compiling the code files, as shown below.
#! /bin/bash
gcc -c -o LinkedList.c LinkedList.o
gcc -c -o main.c main.o
gcc -o bin LinkedList.o main.o
By running the shell script with ./build.sh
, you can execute all the commands at once and build the executable bin
.
However, this build system is not ideal, as all the commands are hardcoded, making it difficult to change the code.
For example, changing the compiler would require editing each command. To improve this, we can use variables and logic,
as follows.
#! /bin/bash
CC=gcc
BINARY=bin
declare -a CFILES=(
[0]=LinkedList.c
[1]=main.c
)
declare -a OBJECTS=(
[0]=LinkedList.o
[1]=main.o
)
for i in ${!CFILES[@]}
do
$CC -c -o ${CFILES[$i]} ${OBJECTS[$i]}
done
$CC -o $BINARY ${OBJECTS[@]}
By using variables and for loops, we can easily modify the code. However, this is still not an ideal build system, as it has no way to recompile only the files that were changed and the files that have dependency on them. To achieve this, we would need to implement much more complicated logic, which becomes infeasible as the codebase grows.
Make
Make is a build system tool for automating the build process, which allows us to manage dependencies more easily. It is widely used in Unix-like operating systems, including Linux, and works with a Makefile defined by the developer, where rules regarding dependencies are written. The following is an example Makefile for the same project.
CC=gcc
CFILES=main.c LinkedList.c
OBJECTS=main.o LinkedList.o
BINARY=bin
all:$(BINARY)
$(BINARY):$(OBJECTS)
$(CC) -o $@ $^
%.o:%.c
$(CC) -c -o $@ $^
clean:
rm -rf $(BINARY) *.o
In Makefiles, you can define variables and refer to them with $()
. When running make
, it starts by looking at the rule for all
by default,
using the dependencies on the right and the command underneath. Since no command is specified, Make moves on and looks for a rule to create the dependency BINARY
,
which requires all the OBJECTS
to be created and passed to the command just below the BINARY
rule. $@
and $^
represent the variable on the left and right of the :
above,
respectively. Hence, the command will translate to gcc -o bin main.o LinkedList.o
.
However, these OBJECTS
are not created yet, so Make moves to the next rule regarding the creation of objects.
The next rule specifies that to create any object file %.o
, you need to use %.c
and run the command gcc -c -o %.o %.c
.
As we already have %.c
files in the directory, Make runs this command to create all the necessary objects
and passes them to the rule above to create an executable. The final rule, clean
, removes the binary and all the object files.
This rule can be executed by running make clean
. Make not only allows us to write rules regarding dependencies
in a maintainable manner but also enables us to set rules for operations related to the build and
to recompile only the files that need to be recompiled based on these rules.
Header Files
Make can monitor changes in %.c
files listed in the dependency rules and decide which files need recompilation.
However, it cannot detect changes in header files %.h
. To reflect changes made to a header file,
we need to create something called a dependency file with a .d
extension. The following Makefile incorporates
the creation of dependency files with GCC and their use in dependency checks by Make,
along with extra syntax and a more manageable file structure.
BINARY=bin
CODEDIRS=. lib # Where `.c` files live.
INCDIRS=. ./include/ # Where `.h` files live
CC=gcc
DEPFLAGS=-MP -MD # Generate `.d` files
CFLAGS=$(foreach D, $(INCDIRS), -I$(D)) $(DEPFLAGS)
# `-I<directory_name> flag specify which directory contain header files
# from which we want to create `.d` files
CFILES=$(foreach D, $(CODEDIRS), $(wildcard $(D)/*.c)) # Using regular expression to find `.c` files
OBJECTS=$(patsubst %.c, %.o, $(CFILES))
DEPFILES=$(patsubst %.c, %.d, $(CFILES))
all:$(BINARY)
$(BINARY):$(OBJECTS)
$(CC) -o $@ $^
%.o:%.c
$(CC) -c -o $@ $^
clean:
rm -rf $(BINARY) *.o
-include $(DEPFILES) # Include dependencies
$(foreach D, DIRS, OPERATION(D))
iterates over DIRS
and performs OPERATION
on D
.
$(wildcard REGEX)
finds all the paths matching the regular expression REGEX
.
$(patsubst %.c, %.o, FILES)
substitutes the files in FILES
that match the pattern on the left with the one on the right.
Using these operations, along with GCC flags -I
, -MP -MD
, and -include
, we include all the dependency information in .d
files
created based on the header files in the .
or root directory and ./include/
subdirectory. Their definitions reside in the .
and lib
subdirectory.
Hence, we can place the main source code in the root directory, all the header files in the include
subdirectory,
and modules in the lib
subdirectory, using the above Makefile to take advantage of an automated build process
that reflects any changes made in the project.
Conclusion
In this article, we covered the basics of Make and Makefiles in the context of C,
but you can use them for building projects in any programming language and
in conjunction with other DevOps tools like Git and Docker.
You can also add a help
rule to help other developers understand the rules,
which you can learn by reading the article by Steenssone, S. (2023) cited below.
Resources
- Parmer, G. 2020. Makefiles: 95% of what you need to know. YouTube.
- Steenssone, S. 2023. Make your Makefile user-friendly: Create a custom ‘help’ target. Medium.