SDPT Lab 3
Lab 3: Build Systems - From GNU Make to Modern CMake
Introduction
In Week 2, we solved the human collaboration problem using Git. Today, we solve the compilation scaling problem.
As embedded projects grow from a single file to hundreds of files with external dependencies (like cryptography or networking libraries), clicking a "Build" button in an IDE or typing g++ main.cpp in the terminal is no longer viable. By the end of this 2-hour lab, you will have:
- Written a scalable GNU Makefile using pattern rules and automatic variables.
- Solved the "Header Dependency Problem" using GCC's
-MMDflags. - Migrated the project to Modern CMake.
- Enforced clean "Out-of-Source" builds.
- Built a static hardware driver library and linked it to an executable.
- Automatically downloaded and linked a 3rd-party library directly from GitHub using CMake's
FetchContent.
Requirement: You may work individually or with your partner from last week.
---
Part 1: Project Setup and The GNU Make Baseline (30 Minutes)
First, we need some source code to compile. We will simulate an IoT sensor node.
1. Create the Project Workspace
Open your terminal and create a new directory:
mkdir sdpt-lab3-builds cd sdpt-lab3-builds mkdir src include
2. Create the Source Files
Create a header file include/sensor.h:
#ifndef SENSOR_H
#define SENSOR_H
struct SensorData {
int temperature;
int humidity;
};
void init_sensor();
SensorData read_sensor();
#endif
Create the implementation file src/sensor.cpp:
#include <iostream>
#include "../include/sensor.h"
void init_sensor() {
std::cout << "[Hardware] I2C Sensor Initialized." << std::endl;
}
SensorData read_sensor() {
SensorData data;
data.temperature = 24;
data.humidity = 60;
return data;
}
Create the main application src/main.cpp:
#include <iostream>
#include "../include/sensor.h"
int main() {
std::cout << "Starting IoT Node..." << std::endl;
init_sensor();
SensorData current_data = read_sensor();
std::cout << "Temp: " << current_data.temperature << "C, Hum: " << current_data.humidity << "%" << std::endl;
return 0;
}
3. Write the Scalable Makefile
In the root directory (sdpt-lab3-builds/), create a file named exactly Makefile.
Warning: You MUST use an actual TAB character for the indented lines, not spaces!
CXX = g++ CXXFLAGS = -Wall -Wextra -O2 -MMD -MP TARGET = firmware.bin # Dynamically find all .cpp files in the src/ directory SRCS = $(wildcard src/*.cpp) # String substitution: convert .cpp list to .o list OBJS = $(SRCS:.cpp=.o) # Create a list of .d (dependency) files DEPS = $(OBJS:.o=.d) # Default rule all: $(TARGET) # Linker rule using automatic variables ($^ = all dependencies, $@ = target) $(TARGET): $(OBJS) $(CXX) $^ -o $@ # Pattern rule to compile any .cpp into a .o file ($< = first dependency) %.o: %.cpp $(CXX) $(CXXFLAGS) -c $< -o $@ # Clean rule to remove binaries clean: rm -f $(OBJS) $(DEPS) $(TARGET) # Include the auto-generated GCC dependencies -include $(DEPS)
4. Test and Inspect the Build
Run the build:
make ./firmware.bin
Now, look inside your src/ folder. Run ls src/.
Notice the .d files? Open src/main.d in a text editor. You will see that GCC automatically wrote a Makefile rule proving that main.o depends on sensor.h. This is how Make knows to recompile main.cpp if you only change the header file!
---
Part 2: Migrating to Modern CMake (30 Minutes)
Makefiles are great, but they leave .o and .d files scattered all over our source code, and they are not cross-platform. Let's upgrade.
1. Clean up the Make artifacts
Run the clean rule to delete all the generated binaries from Part 1.
make clean
2. Write the CMakeLists.txt
In the root directory, create a file named CMakeLists.txt:
cmake_minimum_required(VERSION 3.10) project(IoTNode CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) # Define the executable and its source files add_executable(firmware src/main.cpp src/sensor.cpp) # Tell CMake where to look for header files target_include_directories(firmware PRIVATE include/)
3. The Out-of-Source Build
We will never compile in the root directory again.
mkdir build cd build # Tell CMake to read the parent directory and generate the build system cmake .. # Execute the build make
Run your program: ./firmware. Notice that your src/ directory is completely clean. All binaries are safely isolated inside the build/ folder.
---
Part 3: Modular Architecture (Static Libraries) (30 Minutes)
In professional embedded development, we don't dump all source files into the executable. We build independent, reusable Static Libraries (.a files) for hardware drivers.
1. Refactor CMakeLists.txt
Go back to your root directory and open CMakeLists.txt. Rewrite it to decouple the sensor driver from the main application:
cmake_minimum_required(VERSION 3.10) project(IoTNode CXX) set(CMAKE_CXX_STANDARD 14) # 1. Build the sensor driver as a STATIC library add_library(sensor_lib STATIC src/sensor.cpp) # 2. Attach the include directory to the library (PUBLIC means anyone who links this library gets the headers too) target_include_directories(sensor_lib PUBLIC include/) # 3. Build the main executable (Notice we removed sensor.cpp!) add_executable(firmware src/main.cpp) # 4. Link the library to the executable target_link_libraries(firmware PRIVATE sensor_lib)
2. Rebuild and Verify
Go into your build/ directory and just run make (CMake automatically detects the changes to CMakeLists.txt and regenerates everything).
cd build make
Look at the terminal output. You should see it building libsensor_lib.a first, and then linking it to firmware. Run ls -l to verify the .a archive exists.
---
Part 4: Remote 3rd-Party Dependencies (30 Minutes)
Our IoT node generates data, but we need to format it as JSON to send it over the network. Writing a JSON parser in C++ from scratch is a terrible idea. We will use a popular 3rd-party library (Nlohmann JSON) and import it directly from GitHub using CMake's FetchContent.
1. Fetching the Library
Open your CMakeLists.txt and add the FetchContent block before your executable definition:
cmake_minimum_required(VERSION 3.14) # Note: Upgraded to 3.14 for FetchContent project(IoTNode CXX) set(CMAKE_CXX_STANDARD 14) # --- Define local library --- add_library(sensor_lib STATIC src/sensor.cpp) target_include_directories(sensor_lib PUBLIC include/) # --- Fetch 3rd Party Library from GitHub --- include(FetchContent) FetchContent_Declare( json GIT_REPOSITORY https://github.com/nlohmann/json.git GIT_TAG v3.11.2 ) FetchContent_MakeAvailable(json) # --- Define Executable --- add_executable(firmware src/main.cpp) # Link BOTH our local hardware library AND the remote JSON library # (The remote library exposes a target named 'nlohmann_json::nlohmann_json') target_link_libraries(firmware PRIVATE sensor_lib nlohmann_json::nlohmann_json)
2. Use the JSON Library in Code
Open src/main.cpp and update it to serialize our sensor data:
#include <iostream>
#include "../include/sensor.h"
#include <nlohmann/json.hpp> // Included from the fetched library!
using json = nlohmann::json;
int main() {
std::cout << "Starting IoT Node..." << std::endl;
init_sensor();
SensorData current_data = read_sensor();
// Create a JSON object and pack our data into it
json payload;
payload["device_id"] = "RPI_NODE_01";
payload["status"] = "active";
payload["data"]["temperature"] = current_data.temperature;
payload["data"]["humidity"] = current_data.humidity;
// Serialize and print the JSON string
std::string network_message = payload.dump(4); // 4 spaces of indentation
std::cout << "\n--- Transmission Payload ---\n" << network_message << std::endl;
return 0;
}
3. The Final Build
Go to your build/ directory. Run make.
Note: This build will take slightly longer because CMake is reaching out to GitHub, downloading the library, and configuring it on the fly.
Run ./firmware. You should see a perfectly formatted JSON payload printed to your terminal!
---
Lab Deliverable
To receive full credit for this week's lab, demonstrate the following to your professor or TA:
- Run
./firmwareto show the successful JSON serialization. - Show your
CMakeLists.txtfile demonstrating theFetchContentblock andtarget_link_libraries. - Navigate to
build/_deps/json-srcin your terminal to prove that CMake successfully downloaded the external repository into the out-of-source build tree. - Explain the difference between the
PUBLICandPRIVATEkeywords in your CMake link step.