Lab: Writing Makefiles

Today’s lab will help you explore some of the practical uses of make by creating a Makefile for the multi-file project you wrote in the previous lab. If you haven’t finished that lab yet you should go back and complete it before starting today’s lab.

Once you’ve finished that lab, run these commands to make a copy that you’ll use for today’s lab and open it in VSCode:

$ cd csc161/labs
$ cp -R multifile makefiles
$ cd makefiles
$ rm -f farm
$ code .

A. Basic Makefiles

At the most basic level, a Makefile is meant to hold the commands you must run to turn a project’s source code into a runnable program. We’ll start off by writing the simplest possible Makefile to compile the code from our previous lab, and then go from there.

Recall the final compilation command you used for the last lab:

$ clang -o farm farm.c chicken.c cow.c util.c

Exercises

  1. Create a file named Makefile and write one rule to produce farm from its prerequisites. Test your Makefile by running make in the terminal. You should end up with a runnable farm program.

  2. What will happen if you run make again without editing any code? Make a prediction, then run make to check.

  3. Now try editing farm.c in some simple way; one possible edit would be to change the number of animals of some type. What do you think will happen when you run make this time? Make a guess, then run it to find out.

  4. Finally, edit chicken.h to add something you’re confident will cause an error. What do you think will happen if you run make this time? Make a guess, then run it to find out.

  5. Did you get a compiler error when you ran make in step 4? If so, nice work—you wrote your Makefile correctly. You can undo your erroneous edit to chicken.h and move on.

    If you didn’t see a compiler error, that’s a problem. You introduced an error by editing code, but make didn’t try to update the build in response to your change. That happened because your Makefile is missing one or more dependencies. Fix your Makefile before you move on.

B. Adding Useful Targets

While we usually use make to invoke a compiler, there are other common tasks we typically encode in a Makefile as well. We can do this by adding phony targets to the Makefile.

A phony target is a target that doesn’t produce a file; instead, it’s shorthand for a rule we want to run. The exercises below will walk you through writing two standard phony targets you’ll find in most Makefiles.

Exercises

  1. Most Makefiles include a clean target that will remove all the “products” from the compiler. The idea is that you may want to undo the work make did, either to recover from an error or reset the project’s state. The clean target for this project has no prerequisites, and uses the following recipe:
    $ rm -f farm
    

    The rm command removes a file, and the -f flag tells it not to fail if the file you are removing doesn’t exist. That means you can run make clean even if you haven’t compiled the project yet and it won’t throw an error.

    Add a clean target to your Makefile and test it. It should remove the farm program. *Note: You may need to run make farm to compile the program again if you put clean above your farm target in the Makefile.

  2. The other standard target you’ll find in most Makefiles is one called all, which compiles everything. The all target typically comes first in the Makefile, which makes it the default. That way you can run make without a target name and it will build everything. Add an all target to your project with no recipe, and farm as its only prerequisite.

  3. What do you think will happen if you create a file named clean in the current directory? Make a guess, then create an empty file named clean with this shell command:
    $ touch clean
    

    Test your Makefile again and see what happens. You can remove the clean file you created once you’re done testing.

  4. Any time you create a phony target you should also add it as a prerequisite to a special target called .PHONY. This tells make that the target isn’t supposed to produce a file, so it will continue to work even if a file with the phony target’s name exists. Add a .PHONY target to the end of your Makefile with both of your phony targets as prerequisites.

C. Variables in Makefiles

Sometimes Makefiles include configuration options in variables near the top of the file. There are a few standard variables we often see:

CC
The C compiler command to use. Common options are cc, clang, or gcc.
CFLAGS
The options passed to the C compiler. There are many options available, but you’ll frequently see -g here because that tells the compiler to include debugging information in the program.
LDFLAGS
More options passed the C compiler, specifically related to libraries and linking. The only flag we’ve seen in this category so far is -lm, which tells the compiler to include the math library required when you #include the math.h file.

Exercises

  1. Create a CC variable at the top of your Makefile and set it to clang. You can refer back to today’s reading if you don’t remember how to create variables in Makefiles.

  2. Update the farm target to use $(CC) instead of clang in the recipe. Try changing CC to gcc, then run make clean all to rebuild everything with gcc instead of clang. You can change the compiler back to clang once this works.

  3. Create a CFLAGS variable with the value -g just below your definition of CC. Add $(CFLAGS) to your recipe for farm between $(CC) and -o. Run make clean all to build everything again. Make sure make includes the -g flag when you build farm.

D. Separate Compilation

Large software projects can take quite a while to compile in their entirety. Luckily for us, C projects don’t need to be recompiled in their entirety for every change. A C compiler converts a .c (source) file into a .o (object) file. Compiling a multifile project produces many .o files, then combines them with the linker.

To compile a single .c file into .o file we run a command like this:

$ clang -c -o farm.o farm.c

Note that farm.o is not a complete program, so you can’t run it as-is. It only contains the implementations of functions defined in farm.c. We can finish building our farm program with these commands:

$ clang -c -o chicken.o chicken.c
$ clang -c -o cow.o cow.c
$ clang -c -o sheep.o sheep.c
$ clang -c -o util.o util.c
$ clang -o farm farm.o chicken.o cow.o sheep.o util.o

We call this process separate compilation. While this might seem like much more work than a single clang command, clang is actually running all of these commands when you ask it to compile multiple .c files into a single program. The following exercises walk you through changes to your Makefile to take advantage of separate compilation for efficient builds.

Exercises

  1. Which files will your existing Makefile recompile if you edit just sheep.c? Which files must be recompiled if you edit sheep.c? Write down your answer before moving on.

  2. Add targets to your Makefile to produce .o files from each of your .c files. Don’t forget to include the prerequisites for each of those steps. An object file target should depend on the source file you are compiling as well as all the local include files it references. You should still use the $(CC) and $(CFLAGS) variables, but you’ll have to add -c to the recipe (as in the examples above) to ask for separate compilation.

  3. Now change your farm target so it links the .o files together. The farm target’s prerequisites should be all of the .o files your new targets produce. Its recipe should be the final command from the example above, but it should use $(CC) and $(CFLAGS).

  4. Now that you’ve updated your Makefile, check your answer to exercise 1. Run make, edit sheep.c, and then run make again. If your Makefile is correct you should see two commands run.

  5. Finally, you’ll need to update your clean target so it removes all the .o files your Makefile now produces. You can pass as many files as you want to a single rm -f command.

E. Advanced Makefile Techniques

The Makefile you produced in part D is quite long, which leaves a lot of opportunities for errors. There are a few advanced techniques you can use to keep your Makefile compact. The exercises below will guide you through a few of them.

Exercises

  1. Many of the recipes in your Makefile are similar, so there’s a good chance you used copy–paste to create them. But if you forget to edit a line after copying it you might have broken your Makefile in a way that’s quite difficult to catch. There are a few special variables that make gives us in every rule that can help us avoid these errors:

    $@
    The name of the target this rule produces
    $^
    All of the prerequisites for this rule
    $<
    The leftmost prerequisite for this rule

    Go through all of your Makefile rules and replace the name of the target in your recipe with $@. Test your Makefile to be sure it still works before moving on.

  2. For all of the separate compilation rules, move your .c file to the leftmost prerequisite if it isn’t there already. Now replace that source file name with $< in your recipe for each rule. Test your Makefile again before moving on.

  3. The linking step (which produces farm) can use the $^ variable in place of the list of .o files. Update your Makefile with this simplification and test it before moving on.

  4. You may have noticed all of your recipes for .o targets are now the same. We can combine these into a single Makefile rule to simplify the Makefile. Here’s an example of two equivalent Makefiles:
    a.o: a.c
      $(CC) -c $(CFLAGS) -o $@ $<
    b.o: b.c
      $(CC) -c $(CFLAGS) -o $@ $<
    

    The Makefile above is equivalent to:

    a.o b.o: %.o: %.c
      $(CC) -c $(CFLAGS) -o $@ $<
    

    The single rule in the second Makefile works for targets a.o and b.o. This is a basic example of a static pattern rule. The leftmost part of the first line is the list of targets this rule applies to. The middle part of the first line is the target pattern, where the % character in %.o will match part of the target name (either a or b). The rightmost portion of the first line is the list of prerequisites, where we can use % to substitute in the same text that % matched in the middle pattern portion of the rule. With this type of rule we get two targets in one rule: a.o has prerequisite a.c and b.o has prerequisite b.c.

    Use this example to write a single rule that works for all of the .o targets in your Makefile. You will need to ignore .h files for now, since not all .c files depend on the same set of .h files; we’ll fix those in the next exercise.

  5. You should now have a Makefile with just two rules (excluding the phony targets): farm, and the new static pattern rule that works for all .o files. This is shorter than your earlier Makefile with separate compilation, but it doesn’t track dependencies on .h files anymore. That’s a problem since a Makefile with incorrect dependencies won’t always work.

    You can add header file dependencies back in with extra rules that do not include recipes. For example, adding this line to your Makefile will specify that util.o depends on util.h:

    util.o: util.h
    

    Add these dependency rules for all of your .o files. If a source file includes a .h file, its .o target should depend on that header file.

    Once you’ve updated your Makefile, test it. If you run make, edit a .h file (perhaps by adding a comment), and then run make again it should recompile every .c file that depends on it and relink.

  6. We can further simplify our Makefile by asking the C compiler to produce dependency information for us automatically. If you run clang -c with the flags -MMD -MP it will generate a .o file as usual, along with a .d file. This .d file contains dependency information just like the rules you wrote in the previous exercise, except the compiler generates it automatically by looking at what .h files each .c file actually includes. Add the flags -MMD -MP to the recipe for your .o files. You have some flexibility in where you place these flags, but it’s nice to put them after -c for readability.

    Automating dependency generation is a good idea because prerequisites must be correct for a Makefile to work correctly. Prerequisites must include all dependencies, including transitive dependencies; as an example, if a.c includes a.h and a.h includes b.h, then a.o depends on b.h. Those can be hard to keep track of, and it’s easy for dependency information to drift out of date as you edit your code.

    The generated .d files are in Makefile syntax, so we can use the Makefile’s include directive to pull them into the Makefile. These files won’t exist on the first build, so we use -include to tell make that it’s not an error if the included file doesn’t exist. Add this line to the end of your Makefile to pull in all the generated .d files:

    -include *.d
    

    Finally, update your clean target to remove .d files. You can just add *.d to the rm command instead of listing them all. It’s a good idea to change the list of .o files in the clean target to *.o as well.

    Test your updated Makefile, including a test where you edit an include file. Try adding an include to a source file (e.g. add #include "chicken.h" to cow.c) and see what happens to the generated .d file.

  7. The last simplifying change we’ll make to your Makefile is to use the wildcard directive to discover source files automatically. Create a variable called SRC just below CFLAGS and give it the value $(wildcard *.c) We can transform this into a list of object files with this line:
    OBJ = $(SRC:.c=.o)
    

    This is a substitution reference in Makefile syntax. The result is a variable that replaces the .c suffix on each filename with .o.

    Now anywhere you listed .o files you can write $(OBJ) instead. It’s a good idea to leave *.o and *.d in the clean target’s recipe in case you add or remove source files; we still want clean to remove a .o file if we delete the .c file it was derived from.

Wrapping Up

You’ve now created a Makefile that works for virtually any C project that produces a single executable from all source files in the current directory. This Makefile automatically discovers source files, compiles them each into .o files while also generating dependency information, and links all the .o files into a final executable.

Our reading and the course textbook reference built-in rules in Makefiles that can do similar things for us. You may wonder why we didn’t just use those. This is partly to help you see the inner workings of make, but it’s also not great practice to rely on built-in make rules. That’s because make isn’t one program; we read about GNU make for class, but there are other implementations dating back to the early days of C. Not all make implementations use the same built-in rules, so relying on them is asking for trouble if you ever try to compile your code on a different machine.