Search code examples
cgccmakefilegnu-make

How to refactor repetition inside a Makefile?


Here is a Makefile I made, it is quite explicit but also maybe too verbose and I wonder if it would be possible to change things (whether it be the logic or the directory structure itself) in order to avoid repeating myself too much.

# General settings
NAME = foobar
SRCS = main.c
HDRS = main.h
OBJS = $(SRCS:.c=.o)

# Per-target settings
release_CFLAGS = -Wall -Wextra -Werror
debug_CFLAGS   = -Wall -Wextra -ggdb3
tsan_CFLAGS    = -Wall -Wextra -ggdb3 -fsanitize=thread,undefined

release_OBJS   = $(addprefix build/release/,$(OBJS))
debug_OBJS     = $(addprefix build/debug/,$(OBJS))
tsan_OBJS      = $(addprefix build/tsan/,$(OBJS))

.PHONY: all release debug tsan clean fclean re

# Default target (pick one or more)
all: debug tsan

release: $(NAME)

debug: $(NAME)-debug

tsan: $(NAME)-tsan

# Linker rules
$(NAME): $(release_OBJS)
    $(CC) $(release_CFLAGS) -o $@ $^

$(NAME)-debug: $(debug_OBJS)
    $(CC) $(debug_CFLAGS) -o $@ $^

$(NAME)-tsan: $(tsan_OBJS)
    $(CC) $(tsan_CFLAGS) -o $@ $^

# Pattern rules
build/release/%.o: %.c
    @mkdir -p $(@D)
    $(CC) $(release_CFLAGS) -MMD -MP -c -o $@ $<

build/debug/%.o: %.c
    @mkdir -p $(@D)
    $(CC) $(debug_CFLAGS) -MMD -MP -c -o $@ $<

build/tsan/%.o: %.c
    @mkdir -p $(@D)
    $(CC) $(tsan_CFLAGS) -MMD -MP -c -o $@ $<

# Include dependencies
-include $(release_OBJS:.o=.d) $(debug_OBJS:.o=.d) $(tsan_OBJS:.o=.d)

# Housekeeping rules
clean:
    rm -rf build

fclean: clean
    rm -f $(NAME) $(NAME)-debug $(NAME)-tsan

re:
    $(MAKE) fclean
    $(MAKE) all

I can explain the thought process behind some of the decisions here.

  • I decided to use GNU make with all of its features in order to not limit myself, even tho I lose on portability, it is a tradeoff I am willing to make. For multiple reasons I would like to stick with using Makefiles for as my build system.
  • I wanted to have an easy way to switch between compiler flags during the development phase. Because some compiler flags are mutually exclusive, like thread sanitizer and address sanitizer for example, it made sense for me to separate these targets, even tho it clutters the Makefile, I found that it was the most sensible solution for this problem.
  • At any point I have the possibility to build the executable I desire by calling the right phony target, eg make release make debug or even make debug release. This is quite flexible, I like it and on top of that I can edit my default target (which is all) in order to avoid typing more than just make at each iteration of the development cycle.
  • I use the -MMD -MP flags in order to generate the full dependency tree, nothing more interesting to say here.
  • The naming choices are a mix of arbitrary choices and imposed rules. The variable $(NAME) was imposed on me, it is the name of the final release executable. This executable has to be built in the current working directory, that is a constraint I built my Makefile around, otherwise I would have done things differently like maybe using a bin/$(TARGET) directory for output. On the other hand, the rest of the folder structure was chosen arbitrarily, I just happened to find it convenient but I don't know if separating object files and dependencies into multiple subdirectories is the best way to go about things.

Just to illustrate, here is what the current folder structure looks like:

├── build
│   ├── debug
│   │   ├── ph_main.d
│   │   └── ph_main.o
│   └── release
│       ├── ph_main.d
│       └── ph_main.o
├── Makefile
├── philo
├── philo-debug
├── ph_main.c
├── ph_main.c~
├── ph_main.h
├── ph_main.h~
├── README.md
└── README.md~

So there it is, I tried to expose my problem as clearly as possible.

To summarize, the issue is that I it feels like I am repeating the same rules three times when there is almost no changes between the targets except the CFLAGS. At the moment it is not a big issue because this is just a small pet project but I want to know if there are better ways to do things and how to improve my project structure in case I need to scale up.


Solution

  • There is not a LOT you can do here, without adding a lot of complexity. I have a few small comments:

    First, it seems odd to add -Werror only to the release build. To me this seems at least exactly backwards. You should fixing warnings at the development stage, not the release stage. I'm aware that with optimization disabled you don't get as many warnings. Certainly if you were to ever release this as open source you would not want to have -Werror on by default for downstream users.

    You can eliminate the smallest amount of redundancy by combining flags, like:

    CFLAGS = -Wall -Westra
    release_CFLAGS = -Werror
    debug_CFLAGS   = -ggdb3
    tsan_CFLAGS    = -ggdb3 -fsanitize=thread,undefined
    

    then using both in your recipe:

        $(CC) $(CFLAGS) $(release_CFLAGS) -o $@ $^
    

    If you didn't have to write separate rules for different directories, you could take advantage of target-specific variables like this:

    $(NAME):       CFLAGS += $(release_CFLAGS)
    $(NAME)-debug: CFLAGS += $(debug_CFLAGS)
    $(NAME)-tsan:  CFLAGS += $(tsan_CFLAGS)
    

    then you wouldn't need different recipes for the different pattern rules; however, since you are writing the pattern rules anyway for different directories this doesn't buy you much.

    There are two more radical ideas you could pursue if it was really bothering you:

    First, you could define the base of the makefile to simply build the target in one mode, using variables for the target details. Like this:

    OBJS := $(addprefix $(OBJDIR)/,$(OBJS))
    
    target: $(NAME)
    
    # Linker rules
    $(NAME): $(OBJS)
            $(CC) $(CFLAGS) -o $@ $^
    
    $(OBJDIR)/%.o: %.c
            @mkdir -p $(@D)
            $(CC) $(CFLAGS) -MMD -MP -c -o $@ $<
    

    Then use recursion to invoke a sub-make with the right settings, like this:

    release:
            $(MAKE) target CFLAGS=$(release_CFLAGS) OBJDIR=build/release
    debug tsan:
            $(MAKE) target NAME=$(NAME)-$@ CFLAGS=$($@_CFLAGS) OBJDIR=build/$@
    

    I know there are many texts out there about how makefile recursion is considered harmful, and there is truth to them, but just like most everything in computer science there are places where it's appropriate and the best choice.

    Alternatively you could generate rules using a combination of eval and define, etc. See this discussion for info.

    Personally I would not go to this trouble for only three variants. If you really do end up adding a lot more variants, maybe the extra complexity could be worthwhile.