Easy Makefiles

over 1 year

2023 05

How to create an ideal Makefile for your 42 project? Understand and become a master of make!

Have you ever wondered how Makefiles work?
What is that .PHONY  thing and how can you compile your sources to another folder? What is re-link and when should your Makefile re-link?

Keep reading to learn all about make and how you can create a very efficient Makefile for your projects.
This tutorial is completely based from the official GNU make documentation.

[Interesting Fact] Did you know that the variable names on the Makefile are NOT arbitrary?
For example, CC will set the default compiler for C programs, you can see the full list of Implicit Rules on chapter 10 from the gnu man documentation.

The whole purpose of the make project was to develop a tool which compiles a large program and it can determine which portions of the program need to be recompiled. This way you don't need to wait for the whole program to compile again every time you make a change.

If you find yourself always having to do "make re"  or your program is always compiling all your sources, then something is wrong and you should fix your Makefile.

As a rule, each source file which changed should be recompiled, and when an include file changes, all files which include that header should also be recompiled.

Makefiles are a set of rules with the following format

target … : prerequisitesrecipe

The target can be an action to be performed for example, clean or show, and usually targets are executable files which were generated by a program. 🞋
A prerequisite is a file or the name of a rule which is used as input in order to create the target. Prerequisites need not to be present. ⁉️
Therecipe will contain all the actions/commands to be carried out. (You must put a tab before each recipe line)
In most cases, the recipe will generate the target file. 🍲

⚠️ [About re-link] The target will be recompiled if the prerequisites change. Or if it's not present.
💡You can print the target name using $@

When only executing "make" the rule nearest to the top will be executed.

Let's make a very simple example:

main.c
int main(void) {return 0;}

Makefile

SRC     = main.c
OBJ     = $(SRC:.c=.o)
NAME    = simple_example

all: $(OBJ)

$(OBJ): $(NAME)
        echo $@

This example should make sense, but just in case I will explain each line.
The first line declares the source file name.
The second line declares a variable OBJ through a substitution reference. It will take $(SRC) which is a string containing a list of files, separated by spaces and will substitute the .c with .o you can read about substitution references on the manual.
On the third line we declare a NAME variable and we leave an empty line between arguments and rules, and each rule should also by norm leave an empty line after it finishes.

Now we create a rule all which will have as prerequisite OBJ which was our list generated  by substitution. Our rule all will execute the prerequisite, which has a matching rule, the rule $(OBJ).

The rule $(OBJ) will be executed, the prerequisite of the rule is $(NAME) and since we don't have any rule named $(NAME) or a file that matches, it will super fail! 🚩

Bellow in the image you can see how it failed.

💡when make fails, run make --debug to see all the steps up until it failed.
We got an error:

make: *** No rule to make target 'simple_example', needed by 'main.o'.  Stop.

This makes sense, and we should tell the Makefile that simple_example == $(NAME) is a file not a target we don't have any rule for simple_example.

Add the following line to the end of the Makefile, this way the Makefile will not fail when the file $(NAME) is not found. (Although later we will generate a file $(NAME), for this example only I add it to the .PHONY)

.PHONY: $(NAME)

💡[.PHONY] It is a special rule which contains keywords to tell Make that something is NOT a file, but rather a target only.

Now you might want to improve your Makefile:

Makefile

SRC	= main.c
OBJ	= $(SRC:.c=.o)
NAME	= simple_example

all: $(OBJ)
	@echo "From all: " $@

$(OBJ): $(NAME)
	@echo 'From $$(OBJ): ' $@

.PHONY: $(NAME)

💡@ when you append an the character "@" on the beggining of a recipe the recipe won't be printed to the output.
💡$$ Use a $ before any variable expansion to avoid expanding and take the term literally.

After calling make the output resulting should be:

From $(OBJ):  main.o
From all:  all

As you can see both recipes were executed because the Makefile had to remake the targets!
If you run with --debug you can see the process that the Makefile went through:
If you run with the flag -d you will see even more information!

Now let's create a file main.o and see what happens

gcc -c main.c
Now the debug doesn't show that the file main.o does not exist. So we have some progress! 👍
Now let's try to create the executable  simple_example 😁

gcc -o simple_example main.o

If you run make --debug again, you will notice that the message is the same!
File 'simple_example' does not exist.
This makes no sense! 😠

Well that happened because we told Makefile that $(NAME) or  simple_example is NOT a file.
We must remove that from the .PHONY

After removing $(NAME) from the .PHONY we can see that the message changed!
It says:
Prerequisite 'simple_example' is newer than target 'main.o'.

Let's make sure that 'simple_example' is not newer than 'main.o' (This makes no sense but I am trying just to explain the point because the executable SHOULD be indeed newer than the binary, that means that it is up to date)

So let's redo the binary

gcc -c main.c

Now finally it works!

Probably you already noticed it but if not let me tell you. The makefile will recompile the target when the prerequisite is newer than the target! (If you think about it, this means that the prerequisite changed!)
In that case we need the target to be our executable and the prerequisite will be the binaries (main.o). In that case we know that we should redo the target when a binary changed.
The same goes to our binaries, we should redo our binaries when the source file (main.c) changes!

Real life example

SRC	= srcs/main.c
BIN	= bin
DEBUGBIN = dbin
INCS	= includes/
TRIGGER_HEADERS = $(INCS)/test.h
#LIBFT	= Libft	# Libft folder
#LIBFT_INCS = ${LIBFT}/includes	# Libft includes/ folder
#LFLAGS	= -L${LIBFT} -lft
CFLAGS	= -Wall -Werror -Wextra -g
DEBUG	=	-D DEBUG
IFLAGS	= -I${INCS} #-I${LIBFT_INCS}
UNAME	:= ${shell uname}
NAME	= test_executable
DNAME = debug_test_executable
RM	= rm -rf
OBJS	= ${SRC:srcs/%c=${BIN}/%o}
DOBJS = ${SRC:srcs/%c=${DEBUGBIN}/%o}
VALGRIN_DFLAGS = --leak-check=full --show-leak-kinds=all --track-origins=yes --verbose
VALGRIND_OUTFILE = valgrind-out.txt

ifeq ($(UNAME), Darwin)
	CC = gcc
else ifeq ($(UNAME), FreeBSD)
	CC = clang
else
	CC	= gcc
	CFLAGS += -D LINUX
endif

all: ${NAME}

${NAME}: ${BIN} ${OBJS} ${TRIGGER_HEADERS} #| ${LIBFT}
	${CC} -o ${NAME} ${OBJS} # ${LFLAGS}

debug: ${DNAME}

${DNAME}: ${DEBUGBIN} ${DOBJS} ${TRIGGER_HEADERS} #| ${LIBFT}
	${CC} -o ${DNAME} ${DOBJS} ${DEBUG} # ${LFLAGS}

${BIN}/%o: srcs/%c
	${CC} -c $< ${CFLAGS} ${IFLAGS} -o $@

${DEBUGBIN}/%o: srcs/%c
	${CC} -c $< ${CFLAGS} ${IFLAGS} ${DEBUG} -o $@

${BIN}:
	@mkdir -p ${BIN}

${DEBUGBIN}:
	@mkdir -p ${DEBUGBIN}

clean:
	${RM} ${BIN} ${DEBUGBIN}

fclean: clean
	${RM} ${NAME} ${DNAME}

#${LIBFT}:
#	@make all -C ${LIBFT} --no-print-directory

re: fclean all

test: debug
	@echo "[MAKEFILE] You can setup test arguments by setting up the env ARGS"
	@echo "[MAKEFILE] Ex: export FT_LS_ARGS=\"-a --recursive ..\""
	valgrind ${VALGRIND_FLAGS} --log-file=$(VALGRIND_OUTFILE) ./${DNAME} ${ARGS}

show:
	@printf "UNAME		: ${UNAME}\n"
	@printf "NAME		: ${NAME}\n"
	@printf "CC		: ${CC}\n"
	@printf "CFLAGS		: ${CFLAGS}\n"
	@printf "LFLAGS		: ${LFLAGS}\n"
	@printf "SRC		: ${SRC}\n"
	@printf "OBJS		: ${OBJS}\n"

.PHONY: re all clean fclean debug test #${LIBFT}
  • SRC: list of your sources separated by a space
  • BIN: bin file for binary files, this helps keep everything organized and you don't cluster *.o files with *.c files
  • DEBUGBIN: debug bin for debug binary files (debug files will be run with the MACRO -D DEBUG=1)
  • INCS: path to folder where your headers are
  • TRIGGER_HEADERS: List of headers separated by space if any header on the list changes all files including that header will recompile
  • LIBFT: Libft folder, this could be any library you want to use
  • LIBFT_INCS: path to the library headers
  • LFLAGS: Linker Flags to link Libraries
  • CFLAGS: C Flags for the binary compilation
  • DEBUG: C Macro for Debug binary compilation
  • IFLAGS: C Flags to add directories to the list of searched paths for includes
  • UNAME: Multiplatform variable definition to point the OS name
  • NAME: The name of the executable
  • DNAME: The name of the debug executable
  • RM: Remove commands
  • OBJS: Substitution References for binary objects in the BIN folder
  • DOBJS: Substitution References for debug binary objects in the DEBUGBIN folder
  • VALGRIN_DFLAGS: valgrind flags for testing
  • VALGRIND_OUTFILE: valgrind output file