Linux Basics #9 - Makefiles

Last Edited: 1/17/2025

This blog post introduces basics of Make in Linux.

DevOps

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.

build.sh
#! /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.

build.sh
#! /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.

Makefile
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.

Makefile
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