$30
This lab was originally written by a former TA from several years ago for my class. It has been modified to meet our needs.
Lab Objective
· Learn how to use the Make utility to automate your builds and scripts
· Learn some basic shell scripting
Introduction
In the past, as you’ve worked on labs and or assignments invariably you’ve typed a compile command incorrectly or misspelled a filename. As your projects grow, you want to move away from manually typing in the gcc/g++ compile commands and begin using automated build tools.
For Linux-like environments, Make is a very common build tool which allows you to compile and link C/C++ programs in a more structured way. Today, we’ll begin with simple Makefiles and build to a general-purpose script which will be applicable to many future C/C++ projects.
For those of you that had me for 1020 or have already had this lab, please read the entire lab because it is not exactly like the previous lab.
For this lab you will be required to create a document consisting of screenshots documenting your work. Your screenshots MUST include the terminal you are using along with your username. I will do my best to mark the places in the lab that require screenshots. However, this is not guaranteed so be sure to read and follow all directions.
Resources
For a step-by-step tutorial on Makefiles and some information on bash scripting checkout the following links:
https://www.tutorialspoint.com/makefile/
https://www.tutorialspoint.com/unix/unix-what-is-shell.htm
Assignment
Create a folder called Lab2. This is the folder you will use for this part of the lab. Inside Lab2 create another folder called src and move your functions.c and functions.h in this folder.
SCREENSHOT:
Create a document called Task.doc. Throughout this lab you will be instructed to take screenshots; adding them to Task.doc.
What you should have now is:
A folder called Lab2 – In this folder you should have another folder called src
/Lab2/src
Lab2 should have your driver.c, and a folder src which should have funtions.c, functions.h and task.doc
Because of the above actions you are going to need to change your driver.c file. Figure out what that change is and make it.
In your task.doc file describe what the change was and why you needed it.
As projects get larger and include more source files, it often makes sense to logically separate them into subdirectories to make them easier to manage. Even if you don’t split them up, compiling multiple source files into an executable becomes more involved and error prone process. Especially when multiple files have compiler errors.
This is where the make command is very useful. make allows us to define a script called the makefile which contains instructions on compiling our programs. In the root directory (Lab2) of the code listed above, create a file named makefile by doing the command below:
touch makefile
Touch is a Linux/Unix command that is used to create an empty file with no content.
SCREENSHOT
Make a screenshot of this and copy/paste it in task.doc.
Note that the file MUST be named Makefile or makefile for the make command to identify it.
Makefile Targets and Dependencies
Makefiles are a form of bash script, which is like a programming language for controlling your Linux terminal. It allows us to compile source files and run additional programs like echo, grep, or tar. Consider the following:
MESSAGE=HELLO WORLD!
all: build
@echo "All Done"
build:
@echo "${MESSAGE}"
The first thing to notice is that we defined a bash variable at the top of the script. Bash variables are simply string substitutions. Using the $() or ${} operator, we can replace the variable with the defined string. Use ${} with curly braces if you are in between double quotes. This is a pretty straightforward concept, but it’s very powerful. In this instance, we use a variable to define a message we print to the screen.
****************************************************************************
HUGELY IMPORTANT NOTE:
Any code that belongs to a given target must be indented at least one tab character. There is a tab before both of the @echo commands above.
****************************************************************************
SCREENSHOT:
Using the file you created using touch, type the above code in the makefile. Create a screenshot and paste it in your document
Now we move on to our first target, the default target all:
A target is simply a named chunk of code our Makefile will try and execute. By default, Make will execute the all: target if it exists. Otherwise, it executes the first target it finds. You can also specify a target from the command line, which means that the following commands are equivalent:
make
make all
Next to the target name there is a whitespace separated list of dependencies. When make encounters a target to execute, it will read the dependency list and perform the following actions:
1. If the dependency is another target, attempt to execute that target
2. If the dependency is a file that matches a rule, execute that rule
This means we can chain multiple targets together.
Run the make command from the terminal and see what is printed to the screen:
make
HELLO WORLD!
All Done!
Notice that the “HELLO WORLD!” is printed first… that’s because the all: target is dependent on the build: target and so it is run first.
Now run the following
make build
SCREENSHOT:
Take a screenshot showing that you ran the make command and make build. Paste the screenshot in your task.doc.
Add a target called tar: that will tar zip everything in the folder, your tar file should be test.tar.gz.
Now add a target called list: that will list all files in the folder. In other words, if I type make list it should list all files in the folder. If I type make tar it will tar all files in the folder creating a file named test.tar.gz. Test your make tar and make list.
SCREENSHOT:
Create a screen shot and copy/paste it in your document.
Now, change the all target to have dependencies of build, tar, and list.
SCREENSHOT:
Test it, create a screenshot, and place it in your document.
For those wondering, echo is a command that just prints its arguments to the terminal. Including the @ symbol in front of the command prevents make from printing the command. Play around with the above file to get a feel for what we are doing. See what happens if you remove the @ symbol.
Making C Programs
The power of the makefile is in this dependency list/rule execution loop. What we want is for our build target to run our gcc compile command. This target should be dependent on our C source files. A powerful feature of make dependencies is that a target will only be executed if its dependencies have changed since the last time you called make. Again:
A target will only be executed if its dependencies have changed since the last time you called make!
Let’s just skip to an example!
Change the name of the previous makefile you created. Use touch and create another makefile. Type the following code in the makefile filling in the C_SRCS and HDRS information.
# Config, just variables
CC=gcc
CFLAGS=-Wall -g
LFLAGS=-lm
TARGET=out
# Generate source and object lists, also just string variables
C_SRCS := FILLIN THE C FILES HERE
HDRS := FILL IN THE HEADER FILES
OBJS := driver.o src/functions.o
# default target
all: build
@echo “All Done!”
# Link all built objects
build: $(OBJS)
$(CC) $(OBJS) -o $(TARGET) $(LFLAGS)
# special build rule
%.o: %.c $(HDRS)
$(CC) $(CFLAGS) -c $< -o $@
SCREENSHOT:
Run your make file, your output should look something like the following. Take a screenshot and put it in your document.
make
gcc -Wall -g -c driver.c -o driver.o
gcc -Wall -g -c src/functions.c -o src/functions.o
gcc driver.o src/functions.o -o out -lm
All Done
Take a deep breath… this is a lot so let’s go over it! First, we define some new variables using an alternative assignment operator “:=”. Nothing special here, just an assignment and string substitution. The C_SRCS and HDRS variables should make sense, but this might be the first time you’ve seen the .o files defined in the OBJS variable.
A “.o” file is called an object file, and is basically one step away from machine runnable code. Open one in a text editor and look at it yourself. We use the -c gcc flag to generate these files, and they are useful as an intermediate step before producing our executable. Using this in between step, it is possible to compile source files individually to isolate compiler errors before linking.
Note that our build target depends on these object files. This means that make will look for these files in our directory, and try to create them if they are missing.
To create these object files, we define a rule or recipe:
%.o: %.cpp $(HDRS)
$(CC) $(CFLAGS) -c $< -o $@
This rule applies to any file that ends in .o. So, when build tries to resolve the $(OBJS) dependencies, it will run the rule once for every file in our list. The % sign is a wildcard in this case, replaced by the path to the file we are processing.
Note that this rule to make an object file also has dependencies. In this case, the object file depends on the source file of the same name and all the headers in the program. This means that if the C source or any header is changed between makes, we will then recompile the object. The compile command is straightforward except for the $< and $@ variables:
• $< evaluates to the first dependency (so, %.c)
• $@ evaluate to the name of the rule (so, %.o)
This allows us to write one rule that covers all object files in our OBJS list. For every object file we need, we will run an individual compile command. Note that this rule will run if the .o file does not exist or if the dependencies have changed since last make. Basically, Make checks the “last altered” time of the C source file and the object file to see if they are different.
It might not be easy to see, but all this means is that as we work on larger projects and need to recompile, only object files which need to be updated will be recompiled. This saves us time and reduces the number of unneeded recompiles.
Once all the object files are up to date and created, we go back to the build target and link them all together to produce our executable TARGET.
It’s not a big deal if this doesn’t make complete sense right now, but play around with the makefile above and it should come together. At the end of the day, it just has to work!
Advanced Makefile Commands and Rules
All this is great, but really, we just moved typing out file lists from the terminal to a file. What would be useful is if the Makefile could find our source files by itself. Linux has many useful functions to help manipulate files in our project, and we can access these tools from our Makefile. You can include conditionals as well as filters to really customize your build process. For our purposes, let’s just use the simple wildcard function:
TASK
Type the following.
C_SRCS := \
$(wildcard *.c) \
$(wildcard src/*.c) \
$(wildcard src/**/*.c)
HDRS := \
$(wildcard *.h) \
$(wildcard src/*.h) \
$(wildcard src/**/*.h)
Here we are executing the wildcard command to match all files with the .c and .h endings. This includes all files in the “src” folder and all its subdirectories. The \ is used to break a command over more than 1 line.
Let’s add a new target called “which” to see what these wildcard commands do:
which:
@echo "FOUND SOURCES: ${C_SRCS}"
@echo "FOUND HEADERS: ${HDRS}"
Run “make which”.
SCREENSHOT:
Make a screenshot of the output and paste it in your documents.
Read through the explanation of the remaining task. At the end the completed make file is given.
Now that we’ve successfully collected our headers and source, we need to take the list of sources and generate an appropriate list of object files. Things are about to get a little tricky:
OBJS := $(patsubst %.c, bin/%.o, $(wildcard *.c))
OBJS += $(filter %.o, $(patsubst src/%.c, bin/%.o, $(C_SRCS)))
Don’t panic. The first line uses the patsubst command to generate a list of strings where we replace the .c ending with a .o ending. patsubst stands for pattern substring and replaces a pattern found in source files (such as the extension!). In this case, we use the wildcard function again so that we only process files in the root directory. Note another tweak: we’ve also tacked on a “bin/” in the front of all these object files when we did the substitution. More on that later.
Next, we get the list of object files needed from the src directory and its children. Here we are using the filter function to exclude any results which don’t end in “.o”. This is necessary if we have source files in both the root directory and source directory because root directory files get processed twice.
Don’t worry too much if this step is confusing, just know that it allows us to collect all our source, header, and object files with only 4 lines of code!
The last step is to tweak our rules used in compiling object files. Remember that “bin/” we added to the beginning of all our objects? We did this so that the compiled objects are saved to their own bin/ directory instead of cluttering up our src folder.
# Catch root directory src files
bin/%.o: %.c $(HDRS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
# Catch all nested directory files
bin/%.o: src/%.c $(HDRS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
We simply add a bin/ to the front of the rule to match our new object file names. We also create a duplicate rule which looks for src/%.c files instead of simply %.c files. This is needed because we are no longer saving our .o files next to our source files, and the paths don’t match by default.
There’s also a call to mkdir in these rules, which will create the bin/ directory and its subfolders if necessary. Here we use the dir command to get the directory prefix of our object file (remember the file name is the same as the rule name $@).
Now if you run “make” you will be able to compile all your source files into objects and store those object in the bin/ directory which will be created by make. Since our original build target depends on the OBJS list, it will automagically know to use this bin/ directory to link your program together.
The only thing we have left to do is to add a clean target and a run target to allow for fresh builds and quick execution respectively. Add these to the bottom of your Makefile:
clean:
rm -f $(TARGET)
rm -rf bin
run: build
./$(TARGET) <command line arguments needed go here>
Again, run these targets using “make clean” or “make run”. Notice that run depends on our build target, so the program will be compiled if needed before we try and run the executable.
Test out the makefile and run the included code if you wish. This Makefile is generic and will work on most C projects. For very large projects with modules and outside libraries, it is possible to generate dependency lists, include other makefiles, have conditional rules, and all manner of other witchcraft.
Make is powerful, if you code in C or C++ it is one of the most important tools to learn. Other compiled languages like Java or TypeScript have similar dependency tools like Maven, Gradle, and yarn. Out in the industry they are must haves for efficient development, so keep a lookout and be ready to learn new tools to make your programming life easier!
Tips and Tricks
Here is the completed Makefile described above. You are to carefully type this makefile and add detailed comments. These comments should be long enough to convince your TA that you know and understand what each section of this makefile is doing. Most of the explanation is described in this document. You are not allowed to simply copy and past. Paraphrase if you wish, but make sure the grader is convinced you know what this make file is doing.
# add comments
CC=gcc
CFLAGS=-Wall -g
LFLAGS=-lm
TARGET=out
# add comments
C_SRCS := \
$(wildcard *.c) \
$(wildcard src/*.c) \
$(wildcard src/**/*.c)
# add comments
HDRS := \
$(wildcard *.h) \
$(wildcard src/*.h) \
$(wildcard src/**/*.h)
# add comments
OBJS := $(patsubst %.c, bin/%.o, $(wildcard *.c))
OBJS += $(filter %.o, $(patsubst src/%.c, bin/%.o, $(C_SRCS)))
# add comments
all: build
@echo "All Done"
# add comments
build: $(OBJS)
$(CC) $(OBJS) -o $(TARGET) $(LFLAGS)
# add comments
bin/%.o: %.c $(HDRS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
# add comments
bin/%.o: src/%.c $(HDRS)
@mkdir -p $(dir $@)
$(CC) $(CFLAGS) -c $< -o $@
#add comments
clean:
rm -f $(TARGET)
rm -rf bin
#add comments
run: build
./$(TARGET) < put your command line arguments here>
#add comments
which:
@echo "FOUND SOURCES: ${C_SRCS}"
@echo "FOUND HEADERS: ${HDRS}"
@echo "TARGET OBJS: ${OBJS}"
Compile and Execute
Use Make to compile your code as follows:
make
Execute the program
make run
You should now know enough about makefiles to create your own simple makefile.
The information in this lab only scratches the surface of what the make utility can actually do. I encourage you to explore the make utility.