Laser Interferometer Driver with FreeRTOS

Build a simulated laser interferometer driver with FreeRTOS and Jenkins CI/CD.

Introduction

30 Second Summary

Semiconductor chips are built with features smaller than a wavelength of light. The machines that manufacture them rely on laser interferometers to measure position with nanometer precision, and firmware drivers to interpret those measurements in real time.

In this project, you will build a simulated laser interferometer measurement driver in C, run it on a real-time operating system, validate it with unit tests, and automate everything with a CI/CD pipeline.

What You'll Build

A C firmware driver that simulates laser interferometer sensor readings, processes them through FreeRTOS tasks and queues, and is automatically built and tested by a Jenkins pipeline running in Docker.

By the end of this project, you'll have:

  • A C driver module implementing sensor reading, signal averaging, and bounds checking with proper error handling.
  • A FreeRTOS application with two RTOS tasks communicating via a queue to simulate real-time data acquisition and processing.
  • A unit test suite with 15 test cases using the Unity embedded test framework, plus a Jenkins CI/CD pipeline that builds and runs tests automatically inside a Docker container.
  • Secret Mission: Add a moving average filter with configurable window size and integrate static analysis into the Jenkins pipeline.

Are there any prerequisites?

You need basic programming concepts like variables, functions, and loops. No C or embedded experience is required.

Install Your Development Tools

Before you can write any firmware, you need the right tools on your machine. Embedded C development requires a compiler, a build system, version control, and a container runtime for your CI/CD pipeline.

In this step, you will install four tools: Visual Studio 2022 (for the MSVC compiler and CMake), Docker Desktop (for running Jenkins later), and Git (for version control).

In this step, get ready to:
  • Install Visual Studio 2022 with the C++ workload and CMake tools.
  • Install Docker Desktop with the WSL 2 backend.
  • Install Git for Windows and verify all tools from the command line.
Install Visual Studio 2022 Community Edition

Visual Studio 2022 Community Edition gives you the MSVC compiler for building C code on Windows. It also bundles CMake, which you will use to configure your build system.

  • Open your browser and go to https://visualstudio.microsoft.com/downloads/.
  • Under Community 2022, click Free download.
  • Run the downloaded installer.
  • When the Visual Studio Installer opens, check the Desktop development with C++ workload.
  • Click the Individual Components tab at the top of the installer.
  • Search for C++ CMake tools for Windows and make sure it is checked.
  • Click Install and wait for the installation to complete. This may take 10-20 minutes depending on your internet speed.

Why do I need both a workload and an individual component?

The Desktop development with C++ workload installs the MSVC compiler, linker, and standard library. The C++ CMake tools for Windows individual component adds CMake integration so you can configure builds from the command line.

Together, these give you everything needed to compile C code and manage multi-target build configurations without needing to install CMake separately.

Installer not launching?

Make sure you are running the installer as an administrator. Right-click the downloaded file and select Run as administrator.

If you already have an older version of Visual Studio installed, the installer may prompt you to update first.

Help me troubleshoot my Visual Studio 2022 installation

Install Docker Desktop

Docker Desktop lets you run containers on your Windows machine. You will use it later to run Jenkins in an isolated environment for your CI/CD pipeline.

  • Open your browser and go to https://www.docker.com/products/docker-desktop.
  • Click Download for Windows.
  • Run the downloaded installer.
  • When prompted, select Use WSL 2 instead of Hyper-V.
  • Complete the installation and restart your machine when asked.
  • After your machine restarts, launch Docker Desktop.
  • Wait for the status in the bottom-left corner to show Docker Desktop is running. This may take a minute on first launch.

Is Docker Desktop free?

Docker Desktop is free for individual developers, education, and small businesses under 250 employees. You do not need a paid subscription for this project.

Docker Desktop won't start?

Docker Desktop requires Windows 10 or 11 (64-bit, version 22H2 or newer), at least 8 GB of RAM, and hardware virtualization enabled in your BIOS. If Docker fails to start, check your BIOS settings for Intel VT-x or AMD-V.

If you see a WSL 2 error, open PowerShell as administrator and run wsl --install to install the WSL 2 kernel update.

Help me get Docker Desktop running on my Windows machine

Install Git and verify all tools

Git tracks changes to your source code and is required for cloning the FreeRTOS and Unity libraries in the next step.

  • Open your browser and go to https://git-scm.com.
  • Click Download for Windows and run the installer.
  • Accept the default options on every screen and click Next until the installation completes.

Now verify that all three tools are working. You will use the Developer Command Prompt for VS 2022, which has the MSVC compiler and CMake available on its PATH.

  • Press the Windows key to open your search bar.
  • Type Developer Command Prompt for VS 2022 and press Enter to open it.
  • Verify Git is installed by running:
git --version
  • Verify CMake is available by running:
cmake --version
  • Verify the MSVC compiler is available by running:
cl
  • Verify Docker is running by running:
docker --version

Each command should print a version number. If any command fails, check the troubleshooting guidance below.

✔️ I see version numbers for all four commands

Your development environment is ready. You have the MSVC compiler, CMake, Git, and Docker all working from the command line.

ⓧ One or more commands failed

  • If git --version fails, close and reopen the Developer Command Prompt. Git adds itself to the PATH but existing terminal sessions need a restart to pick it up.
  • If cmake --version fails, reopen the Visual Studio Installer, click Modify, go to Individual Components, and ensure C++ CMake tools for Windows is checked.
  • If cl fails, make sure you are using Developer Command Prompt for VS 2022 and not a regular Command Prompt or PowerShell window. The Developer Command Prompt automatically sets the PATH variables the compiler needs.
  • If docker --version fails, make sure Docker Desktop is fully started (green whale icon in the system tray).

Help me troubleshoot my development tool installations

Your development environment is ready. Next up, you will create the project structure and pull in the FreeRTOS and Unity libraries your firmware depends on.

Create the Project Structure and Clone Dependencies

Your development tools are installed and verified. Now it's time to build the project skeleton that will hold your interferometer driver, tests, and CI/CD configuration.

Professional firmware projects separate source code, configuration, tests, and dependencies into distinct directories from day one. You'll create that structure, pull in two critical libraries, and write the build configuration that ties everything together.

In this step, get ready to:
  • Create the project directory with organized subdirectories and initialize Git.
  • Clone the FreeRTOS Kernel and Unity test framework as project dependencies.
  • Create the CMake build configuration and Docker files for Jenkins CI.
Create the project directory and initialize Git

A clean directory structure keeps your source code, tests, and dependencies separated. You'll create the project folder on your Desktop so it's easy to find.

  • Press the Windows key to open your search bar.
  • Type Developer Command Prompt for VS 2022 and press Enter to open it.
  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Create the project folder, move into it, and initialize a Git repository by running these commands:
mkdir laser-interferometer-driver
cd laser-interferometer-driver
git init
  • Create the subdirectories for your project by running this command:
mkdir src config test lib jenkins

What do these directories hold?

  • src/ holds your driver source code and FreeRTOS application.
  • config/ holds the FreeRTOS kernel configuration header.
  • test/ holds your unit test files.
  • lib/ holds external dependencies (FreeRTOS Kernel, Unity framework).
  • jenkins/ holds the Docker configuration for your CI server.
  • Confirm the directories were created by running:
dir

You should see config, jenkins, lib, src, and test folders listed as <DIR> entries.

Don't see all five directories?

Make sure you're inside the laser-interferometer-driver folder. Run cd to check your current path. If the prompt shows your Desktop, you missed the cd laser-interferometer-driver command.

Help me troubleshoot my project directory structure.

Clone the FreeRTOS Kernel and Unity test framework

Your driver needs two external libraries. FreeRTOS provides the real-time operating system kernel that schedules tasks and manages queues. Unity is a lightweight C testing framework built specifically for embedded systems.

  • Clone the FreeRTOS Kernel into the lib directory by running this command:
git clone https://github.com/FreeRTOS/FreeRTOS-Kernel.git lib/FreeRTOS-Kernel
  • Clone the Unity test framework by running this command:
git clone https://github.com/ThrowTheSwitch/Unity.git lib/Unity

Why these two libraries?

FreeRTOS is the most widely used RTOS in embedded systems. Its Windows simulator port maps RTOS tasks to Windows threads, letting you develop firmware logic without physical hardware.

Unity is the industry standard for embedded C testing. It consists of just one .c file and two headers, making it trivial to integrate into any build system.

  • Verify both libraries were cloned by running:
dir lib

You should see FreeRTOS-Kernel and Unity directories listed.

Clone failed or timed out?

Check your internet connection. Both repositories are public and don't require authentication.

If you see a fatal: destination path already exists error, the clone partially completed on a previous attempt. Delete the folder with rmdir /s lib\FreeRTOS-Kernel and try again.

Help me fix my git clone issue.

Create the build configuration files

Your project needs three configuration files to define how code gets built locally and how Jenkins builds it in CI. CMake handles the build system, and Docker containers run Jenkins with the right tools installed.

  • Create a new file called CMakeLists.txt in your laser-interferometer-driver folder. Open it in your text editor and paste the following build configuration:
cmake_minimum_required(VERSION 3.20)
project(interferometer_driver C)

set(CMAKE_C_STANDARD 11)

# --- Unit Tests (cross-platform, always built) ---
set(UNITY_DIR ${CMAKE_SOURCE_DIR}/lib/Unity/src)

add_executable(interferometer_tests
    test/test_interferometer.c
    src/interferometer.c
    ${UNITY_DIR}/unity.c
)

target_include_directories(interferometer_tests PRIVATE
    src
    ${UNITY_DIR}
)

enable_testing()
add_test(NAME unit_tests COMMAND interferometer_tests)

# --- FreeRTOS Application (Windows only) ---
if(WIN32)
    set(FREERTOS_DIR ${CMAKE_SOURCE_DIR}/lib/FreeRTOS-Kernel)
    set(FREERTOS_PORT_DIR ${FREERTOS_DIR}/portable/MSVC-MingW)

    add_executable(interferometer_app
        src/main.c
        src/interferometer.c
        ${FREERTOS_DIR}/tasks.c
        ${FREERTOS_DIR}/queue.c
        ${FREERTOS_DIR}/list.c
        ${FREERTOS_DIR}/timers.c
        ${FREERTOS_DIR}/portable/MemMang/heap_4.c
        ${FREERTOS_PORT_DIR}/port.c
    )

    target_include_directories(interferometer_app PRIVATE
        src
        config
        ${FREERTOS_DIR}/include
        ${FREERTOS_PORT_DIR}
    )

    target_link_libraries(interferometer_app PRIVATE winmm)
endif()

What does this CMakeLists.txt do?

This file defines two separate build targets for your project.

  • interferometer_tests builds your unit tests with the Unity framework. This target is cross-platform, meaning it compiles on both Windows (locally) and Linux (in Jenkins CI).
  • interferometer_app builds the FreeRTOS application. It only compiles on Windows because FreeRTOS uses the MSVC-MingW port which maps RTOS tasks to Windows threads. It links against winmm for multimedia timer functions that the Windows port requires.
  • Save CMakeLists.txt.
  • Create a new file called docker-compose.yml in your laser-interferometer-driver folder. Open it in your text editor and paste the following:
services:
  jenkins:
    build: ./jenkins
    container_name: jenkins-interferometer
    ports:
      - "8080:8080"
    volumes:
      - jenkins_home:/var/jenkins_home
      - ./:/workspace:ro

volumes:
  jenkins_home:

What does docker-compose.yml configure?

  • build: ./jenkins tells Docker to build the Jenkins image from your custom Dockerfile in the jenkins/ folder.
  • ports: "8080:8080" maps port 8080 on your PC to Jenkins inside the container, so you access it at http://localhost:8080.
  • ./:/workspace:ro mounts your entire project folder inside the container as read-only, so Jenkins can build your code without copying files.
  • Save docker-compose.yml.
  • Create a new file called Dockerfile inside the jenkins subdirectory. Open it in your text editor and paste the following:
FROM jenkins/jenkins:lts-jdk21
USER root
RUN apt-get update && apt-get install -y build-essential cmake
USER jenkins

What does this Dockerfile do?

The base Jenkins image does not include a C compiler or CMake. This Dockerfile starts from the official Jenkins LTS image and installs build-essential (which provides GCC and Make) and cmake so Jenkins can compile and test your C code inside the container.

  • Save jenkins/Dockerfile.
  • Confirm all configuration files exist by running:
dir CMakeLists.txt docker-compose.yml jenkins\Dockerfile

You should see all three files listed without any "File Not Found" errors.

Seeing "File Not Found" for one of the files?

Double check that CMakeLists.txt is in the project root (not inside a subdirectory). The file name is case-sensitive on some systems.

Make sure Dockerfile is inside the jenkins\ folder, not in the project root. Some editors save to the wrong location by default.

Help me figure out why my files are not being found.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are all three configuration files in full. Compare yours against these:

cmake_minimum_required(VERSION 3.20)
project(interferometer_driver C)

set(CMAKE_C_STANDARD 11)

# --- Unit Tests (cross-platform, always built) ---
set(UNITY_DIR ${CMAKE_SOURCE_DIR}/lib/Unity/src)

add_executable(interferometer_tests
    test/test_interferometer.c
    src/interferometer.c
    ${UNITY_DIR}/unity.c
)

target_include_directories(interferometer_tests PRIVATE
    src
    ${UNITY_DIR}
)

enable_testing()
add_test(NAME unit_tests COMMAND interferometer_tests)

# --- FreeRTOS Application (Windows only) ---
if(WIN32)
    set(FREERTOS_DIR ${CMAKE_SOURCE_DIR}/lib/FreeRTOS-Kernel)
    set(FREERTOS_PORT_DIR ${FREERTOS_DIR}/portable/MSVC-MingW)

    add_executable(interferometer_app
        src/main.c
        src/interferometer.c
        ${FREERTOS_DIR}/tasks.c
        ${FREERTOS_DIR}/queue.c
        ${FREERTOS_DIR}/list.c
        ${FREERTOS_DIR}/timers.c
        ${FREERTOS_DIR}/portable/MemMang/heap_4.c
        ${FREERTOS_PORT_DIR}/port.c
    )

    target_include_directories(interferometer_app PRIVATE
        src
        config
        ${FREERTOS_DIR}/include
        ${FREERTOS_PORT_DIR}
    )

    target_link_libraries(interferometer_app PRIVATE winmm)
endif()
services:
  jenkins:
    build: ./jenkins
    container_name: jenkins-interferometer
    ports:
      - "8080:8080"
    volumes:
      - jenkins_home:/var/jenkins_home
      - ./:/workspace:ro

volumes:
  jenkins_home:
FROM jenkins/jenkins:lts-jdk21
USER root
RUN apt-get update && apt-get install -y build-essential cmake
USER jenkins

Your project skeleton is in place with all dependencies cloned and build configuration ready. Next up, you'll write the interferometer driver code that CMake will compile.

Write the Interferometer Driver

Your project structure and dependencies are ready. Now it's time to write the actual firmware that simulates a laser interferometer sensor.

A laser interferometer measures position by counting wavelengths of light. In semiconductor manufacturing, these sensors achieve nanometer-level accuracy. Your driver will simulate this by generating position data, averaging multiple readings for stability, and validating that measurements stay within acceptable bounds.

In this step, get ready to:
  • Define the driver interface in a C header file with status codes, data structures, and function prototypes.
  • Implement the driver functions for sensor reading, signal averaging, and bounds checking.
  • Compile the driver and verify there are no errors.
Create the driver header file

In C firmware, a header file defines the public interface of a module. It declares what functions exist and what data types they use, without revealing how they work internally.

  • Create a new file called interferometer.h inside your src folder.
  • Copy the following code into it:
#ifndef INTERFEROMETER_H
#define INTERFEROMETER_H

#include <stdint.h>

#define INTERFEROMETER_MAX_SAMPLES 10
#define INTERFEROMETER_WAVELENGTH_NM 632

/* Status codes returned by all driver functions */
typedef enum {
    INTERFEROMETER_OK = 0,
    INTERFEROMETER_ERROR_NULL_PTR,
    INTERFEROMETER_ERROR_INVALID_COUNT,
    INTERFEROMETER_ERROR_OUT_OF_RANGE
} interferometer_status_t;

/* A single measurement reading from the interferometer */
typedef struct {
    int32_t position_nm;
    uint32_t timestamp_ms;
    uint8_t quality;
} interferometer_reading_t;

/* Driver function prototypes */
interferometer_status_t interferometer_init(void);
interferometer_status_t interferometer_read_raw(int32_t *raw_value);
interferometer_status_t interferometer_average_readings(
    const int32_t *readings,
    uint8_t count,
    int32_t *result
);
interferometer_status_t interferometer_check_bounds(
    int32_t position_nm,
    int32_t lower_bound,
    int32_t upper_bound
);

#endif

What does this code do?

This header defines the complete public interface for your interferometer driver.

  • The #ifndef guard prevents the file from being included twice, which would cause compile errors.
  • The interferometer_status_t enum gives every function a consistent way to report success or specific error types.
  • The interferometer_reading_t struct bundles a position measurement with its timestamp and quality score.
  • The four function prototypes declare what operations the driver supports, without revealing how they work.
  • Save interferometer.h.
  • You won't see any output yet. The build step at the end confirms everything compiles correctly.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

#ifndef INTERFEROMETER_H
#define INTERFEROMETER_H

#include <stdint.h>

#define INTERFEROMETER_MAX_SAMPLES 10
#define INTERFEROMETER_WAVELENGTH_NM 632

typedef enum {
    INTERFEROMETER_OK = 0,
    INTERFEROMETER_ERROR_NULL_PTR,
    INTERFEROMETER_ERROR_INVALID_COUNT,
    INTERFEROMETER_ERROR_OUT_OF_RANGE
} interferometer_status_t;

typedef struct {
    int32_t position_nm;
    uint32_t timestamp_ms;
    uint8_t quality;
} interferometer_reading_t;

interferometer_status_t interferometer_init(void);
interferometer_status_t interferometer_read_raw(int32_t *raw_value);
interferometer_status_t interferometer_average_readings(
    const int32_t *readings,
    uint8_t count,
    int32_t *result
);
interferometer_status_t interferometer_check_bounds(
    int32_t position_nm,
    int32_t lower_bound,
    int32_t upper_bound
);

#endif
Implement the driver functions

The implementation file contains the actual logic behind each function prototype you declared. Your driver simulates a real sensor by generating nanometer-scale position data with random noise, then provides functions to process and validate that data.

  • Create a new file called interferometer.c inside your src folder.
  • Copy the following code into it:
#include "interferometer.h"
#include <stdlib.h>

/* Module-level state for the simulated sensor */
static uint8_t initialized = 0;
static int32_t simulated_position = 0;

interferometer_status_t interferometer_init(void) {
    initialized = 1;
    simulated_position = 0;
    srand(42); /* Fixed seed for reproducible simulation */
    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_read_raw(int32_t *raw_value) {
    /* Null pointer check before writing */
    if (raw_value == NULL) {
        return INTERFEROMETER_ERROR_NULL_PTR;
    }

    /* Simulate position drift: random walk +/- 100 nm per read */
    simulated_position += (rand() % 201) - 100;
    /* Add measurement noise of +/- 5 nm */
    *raw_value = simulated_position + (rand() % 11) - 5;

    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_average_readings(
    const int32_t *readings,
    uint8_t count,
    int32_t *result
) {
    /* Validate all pointer parameters */
    if (readings == NULL || result == NULL) {
        return INTERFEROMETER_ERROR_NULL_PTR;
    }
    /* Validate count is within acceptable range */
    if (count == 0 || count > INTERFEROMETER_MAX_SAMPLES) {
        return INTERFEROMETER_ERROR_INVALID_COUNT;
    }

    /* Use int64_t to avoid overflow when summing */
    int64_t sum = 0;
    for (uint8_t i = 0; i < count; i++) {
        sum += readings[i];
    }
    *result = (int32_t)(sum / count);

    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_check_bounds(
    int32_t position_nm,
    int32_t lower_bound,
    int32_t upper_bound
) {
    /* Return error if position falls outside the acceptable range */
    if (position_nm < lower_bound || position_nm > upper_bound) {
        return INTERFEROMETER_ERROR_OUT_OF_RANGE;
    }
    return INTERFEROMETER_OK;
}

What does this code do?

Each function implements one responsibility of the interferometer driver.

  • interferometer_init resets the simulated sensor state and seeds the random number generator with a fixed value for reproducibility.
  • interferometer_read_raw simulates a real sensor by applying a random walk (drift) and measurement noise to produce nanometer-scale position data.
  • interferometer_average_readings computes the mean of multiple samples. Averaging reduces noise, which is essential for precision measurement.
  • interferometer_check_bounds validates that a measurement falls within an acceptable range. Out-of-range readings indicate hardware drift or faults.

Every function returns a status code instead of crashing on bad input. This defensive pattern is standard in embedded firmware where a crash could damage expensive hardware.

  • Save interferometer.c.
  • You won't see output yet. The next substep compiles this code and confirms there are no errors.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

#include "interferometer.h"
#include <stdlib.h>

static uint8_t initialized = 0;
static int32_t simulated_position = 0;

interferometer_status_t interferometer_init(void) {
    initialized = 1;
    simulated_position = 0;
    srand(42);
    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_read_raw(int32_t *raw_value) {
    if (raw_value == NULL) {
        return INTERFEROMETER_ERROR_NULL_PTR;
    }

    simulated_position += (rand() % 201) - 100;
    *raw_value = simulated_position + (rand() % 11) - 5;

    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_average_readings(
    const int32_t *readings,
    uint8_t count,
    int32_t *result
) {
    if (readings == NULL || result == NULL) {
        return INTERFEROMETER_ERROR_NULL_PTR;
    }
    if (count == 0 || count > INTERFEROMETER_MAX_SAMPLES) {
        return INTERFEROMETER_ERROR_INVALID_COUNT;
    }

    int64_t sum = 0;
    for (uint8_t i = 0; i < count; i++) {
        sum += readings[i];
    }
    *result = (int32_t)(sum / count);

    return INTERFEROMETER_OK;
}

interferometer_status_t interferometer_check_bounds(
    int32_t position_nm,
    int32_t lower_bound,
    int32_t upper_bound
) {
    if (position_nm < lower_bound || position_nm > upper_bound) {
        return INTERFEROMETER_ERROR_OUT_OF_RANGE;
    }
    return INTERFEROMETER_OK;
}
Build and verify the driver compiles

You need to confirm that your driver code has no syntax errors or type mismatches before moving on. The MSVC compiler will catch these issues immediately.

  • In your Developer Command Prompt for VS 2022, make sure you are in the laser-interferometer-driver folder.
  • Compile the driver to an object file by running this command:
cl /c /I src src\interferometer.c

What does this command do?

  • cl is the MSVC compiler that comes with Visual Studio.
  • /c tells the compiler to compile only (produce an object file) without linking into an executable.
  • /I src adds the src folder to the include search path so the compiler can find interferometer.h.

You should see output similar to this, with no error messages:

Microsoft (R) C/C++ Optimizing Compiler Version 19.xx.xxxxx
interferometer.c
  • Confirm that the file interferometer.obj was created by running:
dir interferometer.obj

You should see the file listed with its size and timestamp. This confirms your driver code compiles cleanly with no errors.

Seeing compiler errors?

  • If you see fatal error C1083: Cannot open include file: 'interferometer.h', make sure you included the /I src flag and that interferometer.h is saved inside the src folder.
  • If you see 'cl' is not recognized, you are not in the Developer Command Prompt for VS 2022. Close your current terminal and open the Developer Command Prompt instead.
  • If you see syntax errors referencing a specific line number, compare that line in your file against the double-check tab above. A missing semicolon or mismatched brace is the most common cause.

Help me fix my interferometer driver compile errors.

Why compile without CMake?

Your CMakeLists.txt references source files that you have not created yet (test/test_interferometer.c and src/main.c). Those come in the next steps.

Using cl /c directly lets you verify your driver code right now without waiting for all project files to exist. You will run the full CMake build once all source files are in place.

Your interferometer driver compiles cleanly. Next up, you will write a full suite of unit tests to prove every function handles both valid inputs and error conditions correctly.

Write Unit Tests with the Unity Framework

Your driver compiles and the functions look correct. But how do you prove they work in every edge case? In embedded firmware, a subtle bug in bounds checking or null pointer handling can crash an entire manufacturing line.

Unit tests give you that proof. You will write 15 test cases using the Unity embedded test framework, the industry standard for testing C firmware. Each test targets one specific behavior of your driver and asserts the exact expected outcome.

In this step, get ready to:
  • Write 15 unit tests covering initialization, averaging, error handling, and bounds checking.
  • Run the test suite and see all 15 tests pass.
  • Intentionally break a test to learn how Unity reports failures.
Create the test file with setup and basic tests

Unity tests follow a simple structure: a setUp function runs before each test to reset state, and each test function calls assertion macros to verify expected outcomes.

  • Create the file test/test_interferometer.c and add the includes, setup functions, and first three tests:
#include "unity.h"
#include "interferometer.h"

// Called before each test to reset driver state
void setUp(void) {
    interferometer_init();
}

// Called after each test (nothing to clean up)
void tearDown(void) {
}

// --- Initialization and raw reading tests ---

void test_init_returns_ok(void) {
    interferometer_status_t status = interferometer_init();
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_read_raw_returns_value(void) {
    int32_t value;
    interferometer_status_t status = interferometer_read_raw(&value);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_read_raw_null_pointer_returns_error(void) {
    interferometer_status_t status = interferometer_read_raw(NULL);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

What does this code do?

  • setUp() calls interferometer_init() before every single test. This guarantees each test starts from a clean state, so one test cannot affect another.
  • TEST_ASSERT_EQUAL(expected, actual) compares two values and fails the test immediately if they do not match.
  • The null pointer test verifies that passing NULL returns the correct error code instead of crashing.
Add the averaging tests

The averaging function is the most complex part of your driver. You need to verify it handles both normal inputs and every error condition.

  • Add these seven test functions below the raw reading tests in test/test_interferometer.c:
// --- Averaging tests: happy path ---

void test_average_single_reading(void) {
    int32_t readings[] = {1000};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 1, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(1000, result);
}

void test_average_multiple_readings(void) {
    int32_t readings[] = {100, 200, 300, 400, 500};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 5, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(300, result);
}

void test_average_negative_values(void) {
    int32_t readings[] = {-100, -200, -300};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 3, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(-200, result);
}

// --- Averaging tests: error handling ---

void test_average_null_readings_returns_error(void) {
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(NULL, 5, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

void test_average_null_result_returns_error(void) {
    int32_t readings[] = {100, 200, 300};
    interferometer_status_t status = interferometer_average_readings(readings, 3, NULL);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

void test_average_zero_count_returns_error(void) {
    int32_t readings[] = {100};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 0, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_INVALID_COUNT, status);
}

void test_average_exceeds_max_samples_returns_error(void) {
    int32_t readings[11] = {0};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 11, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_INVALID_COUNT, status);
}

What does this code do?

  • The happy path tests verify correct math: averaging {100, 200, 300, 400, 500} should produce 300, and averaging negative values should work identically.
  • TEST_ASSERT_EQUAL_INT32 is a type-specific assertion that produces clearer error messages for 32-bit integer comparisons.
  • The error tests verify every defensive check in your driver: null pointers, zero count, and exceeding INTERFEROMETER_MAX_SAMPLES (10). Each must return the correct error code without crashing.
Add bounds checking tests and the test runner

Bounds checking is critical in precision measurement. You need to test the exact boundary values, not just values clearly inside or outside the range.

  • Add the five bounds tests and the main function below the averaging tests in test/test_interferometer.c:
// --- Bounds checking tests ---

void test_bounds_check_within_range(void) {
    interferometer_status_t status = interferometer_check_bounds(500, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_at_lower_boundary(void) {
    interferometer_status_t status = interferometer_check_bounds(-1000, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_at_upper_boundary(void) {
    interferometer_status_t status = interferometer_check_bounds(1000, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_below_range(void) {
    interferometer_status_t status = interferometer_check_bounds(-1001, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_OUT_OF_RANGE, status);
}

void test_bounds_check_above_range(void) {
    interferometer_status_t status = interferometer_check_bounds(1001, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_OUT_OF_RANGE, status);
}

// --- Test runner: registers and executes all tests ---

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_init_returns_ok);
    RUN_TEST(test_read_raw_returns_value);
    RUN_TEST(test_read_raw_null_pointer_returns_error);
    RUN_TEST(test_average_single_reading);
    RUN_TEST(test_average_multiple_readings);
    RUN_TEST(test_average_negative_values);
    RUN_TEST(test_average_null_readings_returns_error);
    RUN_TEST(test_average_null_result_returns_error);
    RUN_TEST(test_average_zero_count_returns_error);
    RUN_TEST(test_average_exceeds_max_samples_returns_error);
    RUN_TEST(test_bounds_check_within_range);
    RUN_TEST(test_bounds_check_at_lower_boundary);
    RUN_TEST(test_bounds_check_at_upper_boundary);
    RUN_TEST(test_bounds_check_below_range);
    RUN_TEST(test_bounds_check_above_range);
    return UNITY_END();
}

What does this code do?

  • The boundary tests check the exact edges: -1000 and 1000 should pass (they are within [-1000, 1000]), while -1001 and 1001 should fail. This catches off-by-one errors.
  • UNITY_BEGIN() starts the test session and UNITY_END() prints the summary and returns the number of failures as the exit code.
  • Every test function must be registered with RUN_TEST() in main. If you forget one, it simply will not run.
  • Save test/test_interferometer.c.

Seeing red squiggles or missing header errors?

  • Make sure your file is saved in the test/ subdirectory, not the project root.
  • Confirm you cloned Unity into lib/Unity in Step 2. The CMakeLists.txt already points to that path.
  • help me fix header include errors in my test file

✔️ Awesome, I've got everything!

Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

#include "unity.h"
#include "interferometer.h"

void setUp(void) {
    interferometer_init();
}

void tearDown(void) {
}

void test_init_returns_ok(void) {
    interferometer_status_t status = interferometer_init();
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_read_raw_returns_value(void) {
    int32_t value;
    interferometer_status_t status = interferometer_read_raw(&value);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_read_raw_null_pointer_returns_error(void) {
    interferometer_status_t status = interferometer_read_raw(NULL);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

void test_average_single_reading(void) {
    int32_t readings[] = {1000};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 1, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(1000, result);
}

void test_average_multiple_readings(void) {
    int32_t readings[] = {100, 200, 300, 400, 500};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 5, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(300, result);
}

void test_average_negative_values(void) {
    int32_t readings[] = {-100, -200, -300};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 3, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
    TEST_ASSERT_EQUAL_INT32(-200, result);
}

void test_average_null_readings_returns_error(void) {
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(NULL, 5, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

void test_average_null_result_returns_error(void) {
    int32_t readings[] = {100, 200, 300};
    interferometer_status_t status = interferometer_average_readings(readings, 3, NULL);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_NULL_PTR, status);
}

void test_average_zero_count_returns_error(void) {
    int32_t readings[] = {100};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 0, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_INVALID_COUNT, status);
}

void test_average_exceeds_max_samples_returns_error(void) {
    int32_t readings[11] = {0};
    int32_t result;
    interferometer_status_t status = interferometer_average_readings(readings, 11, &result);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_INVALID_COUNT, status);
}

void test_bounds_check_within_range(void) {
    interferometer_status_t status = interferometer_check_bounds(500, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_at_lower_boundary(void) {
    interferometer_status_t status = interferometer_check_bounds(-1000, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_at_upper_boundary(void) {
    interferometer_status_t status = interferometer_check_bounds(1000, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_OK, status);
}

void test_bounds_check_below_range(void) {
    interferometer_status_t status = interferometer_check_bounds(-1001, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_OUT_OF_RANGE, status);
}

void test_bounds_check_above_range(void) {
    interferometer_status_t status = interferometer_check_bounds(1001, -1000, 1000);
    TEST_ASSERT_EQUAL(INTERFEROMETER_ERROR_OUT_OF_RANGE, status);
}

int main(void) {
    UNITY_BEGIN();
    RUN_TEST(test_init_returns_ok);
    RUN_TEST(test_read_raw_returns_value);
    RUN_TEST(test_read_raw_null_pointer_returns_error);
    RUN_TEST(test_average_single_reading);
    RUN_TEST(test_average_multiple_readings);
    RUN_TEST(test_average_negative_values);
    RUN_TEST(test_average_null_readings_returns_error);
    RUN_TEST(test_average_null_result_returns_error);
    RUN_TEST(test_average_zero_count_returns_error);
    RUN_TEST(test_average_exceeds_max_samples_returns_error);
    RUN_TEST(test_bounds_check_within_range);
    RUN_TEST(test_bounds_check_at_lower_boundary);
    RUN_TEST(test_bounds_check_at_upper_boundary);
    RUN_TEST(test_bounds_check_below_range);
    RUN_TEST(test_bounds_check_above_range);
    return UNITY_END();
}
Build and run the test suite

Your CMakeLists.txt already defines the interferometer_tests target and links it to your driver source and the Unity library. You just need to rebuild.

  • Rebuild the project by running this command from your project root:
cmake --build build
  • Run the test executable:
build\interferometer_tests.exe
  • You should see Unity output listing each test with a PASS status, ending with a summary line showing 15 Tests, 0 Failures, 0 Ignored.

Seeing compiler errors instead of test results?

  • Check that every test function name in main() matches the actual function name exactly. A single typo causes an unresolved symbol error.
  • Make sure you have the closing brace } for every test function. A missing brace causes cascading errors in all functions below it.
  • If you see "cannot open source file unity.h", delete the build folder and regenerate with cmake -S . -B build -G "NMake Makefiles" to pick up the new test file.
  • help me fix compiler errors in my test file
Break a test to see how Unity reports failures

Knowing what a passing test looks like is only half the picture. You also need to recognize failure output so you can quickly diagnose real bugs when they appear.

  • In test/test_interferometer.c, find the test_average_multiple_readings function.
  • Find this line:
    TEST_ASSERT_EQUAL_INT32(300, result);
  • Change 300 to 999 so the line reads:
    TEST_ASSERT_EQUAL_INT32(999, result);
  • Save the file.
  • Rebuild and run the tests:
cmake --build build && build\interferometer_tests.exe

You should see Unity report a FAIL for test_average_multiple_readings, showing the file name, line number, expected value (999), and actual value (300).

Why is this useful?

Unity's failure output gives you three things: the exact file, the exact line number, and the exact values that did not match. In real firmware debugging, this pinpoints the problem instantly instead of requiring you to step through code with a debugger.

  • Change the expected value back to 300 so the assertion reads TEST_ASSERT_EQUAL_INT32(300, result) again.
  • Save the file.
  • Rebuild and run one final time to confirm all 15 tests pass again:
cmake --build build && build\interferometer_tests.exe
  • Confirm the summary line shows 15 Tests, 0 Failures, 0 Ignored.

Still seeing a failure after reverting?

  • Double check that the expected value is exactly 300, not 999 or any other number.
  • Make sure you saved the file before rebuilding. Unsaved changes are not picked up by the compiler.
  • help me fix my failing test after reverting the change

You now have a rigorous test suite proving your driver works correctly. Next up, you will build the FreeRTOS application that uses this driver in a real-time multitasking environment.

Build the FreeRTOS Application

Your driver is tested and proven. Now you need to put it to work in a real-time environment where sensor readings arrive on a strict schedule and data flows through a processing pipeline.

In this step, you will build a FreeRTOS application with two tasks that communicate through a queue. One task acquires sensor data on a timer. The other task receives that data, averages it, and checks for out-of-bounds positions.

In this step, get ready to:
  • Configure the FreeRTOS kernel for the Windows simulator port.
  • Write two RTOS tasks that communicate via a message queue.
  • Build and run the complete real-time application.
Create the FreeRTOS kernel configuration

FreeRTOS needs a configuration header that tells the kernel how to behave. This file controls tick rate, memory allocation, which features are enabled, and how tasks are scheduled.

  • Create a new file called FreeRTOSConfig.h inside your config folder and paste the following code:
#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H

#include <stdint.h>

#define configUSE_PREEMPTION                    1
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 0
#define configUSE_IDLE_HOOK                     0
#define configUSE_TICK_HOOK                     0
#define configTICK_RATE_HZ                      (1000)
#define configMINIMAL_STACK_SIZE                ((unsigned short) 256)
#define configTOTAL_HEAP_SIZE                   ((size_t)(65 * 1024))
#define configMAX_TASK_NAME_LEN                 (12)
#define configUSE_TRACE_FACILITY                0
#define configUSE_16_BIT_TICKS                  0
#define configIDLE_SHOULD_YIELD                 1
#define configUSE_MUTEXES                       1
#define configCHECK_FOR_STACK_OVERFLOW          0
#define configUSE_RECURSIVE_MUTEXES             1
#define configQUEUE_REGISTRY_SIZE               0
#define configUSE_COUNTING_SEMAPHORES           1
#define configMAX_PRIORITIES                    (7)
#define configSUPPORT_STATIC_ALLOCATION         0
#define configSUPPORT_DYNAMIC_ALLOCATION        1

#define configUSE_TIMERS                        1
#define configTIMER_TASK_PRIORITY               (configMAX_PRIORITIES - 1)
#define configTIMER_QUEUE_LENGTH                5
#define configTIMER_TASK_STACK_DEPTH            (configMINIMAL_STACK_SIZE * 2)

#define INCLUDE_vTaskPrioritySet                1
#define INCLUDE_uxTaskPriorityGet               1
#define INCLUDE_vTaskDelete                     1
#define INCLUDE_vTaskSuspend                    1
#define INCLUDE_vTaskDelayUntil                 1
#define INCLUDE_vTaskDelay                      1

extern void vAssertCalled(const char *pcFile, uint32_t ulLine);
#define configASSERT(x) if ((x) == 0) vAssertCalled(__FILE__, __LINE__)

#endif

What do these settings control?

The FreeRTOS kernel is highly configurable. Here are the most important settings for your application:

  • configTICK_RATE_HZ = 1000 means the scheduler runs 1000 times per second (1 ms resolution).
  • configTOTAL_HEAP_SIZE = 65 KB gives FreeRTOS 65 KB of RAM for creating tasks and queues.
  • configUSE_PREEMPTION = 1 allows higher-priority tasks to interrupt lower-priority ones.
  • INCLUDE_vTaskDelayUntil = 1 enables the precise periodic timing function your sensor task needs.
  • configASSERT calls your custom handler when something goes wrong inside the kernel.
  • Save config/FreeRTOSConfig.h.
  • Confirm the file exists by running:
dir config

You should see FreeRTOSConfig.h listed in the output.

Don't see the file?

Make sure you created the file inside the config folder (not the project root or src). The CMakeLists.txt expects FreeRTOSConfig.h at exactly config/FreeRTOSConfig.h.

Help me find where my FreeRTOSConfig.h should go.

Write the application with two RTOS tasks

Your application uses two tasks that run concurrently. A Sensor Task reads the interferometer at a fixed interval and pushes readings into a queue. A Processing Task pulls readings from that queue, averages them, and checks bounds.

Why tasks and queues instead of a simple loop?

In real embedded systems, sensor acquisition must happen on a strict timer regardless of how long processing takes. RTOS tasks let you separate these concerns. The sensor task never misses a reading, and the processing task works through data at its own pace.

The queue acts as a buffer between them. If the processing task is busy, readings queue up instead of being lost.

  • Create a new file called main.c inside your src folder and paste the following code for the file header and assertion handler:
#include <stdio.h>
#include <stdlib.h>

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "interferometer.h"

#define SENSOR_TASK_PRIORITY    (tskIDLE_PRIORITY + 2)
#define PROCESS_TASK_PRIORITY   (tskIDLE_PRIORITY + 1)
#define QUEUE_LENGTH            5
#define SENSOR_READ_PERIOD_MS   200
#define NUM_SAMPLES_TO_AVERAGE  5

static QueueHandle_t xMeasurementQueue;

void vAssertCalled(const char *pcFile, uint32_t ulLine) {
    printf("ASSERT FAILED: %s line %lu\r\n", pcFile, (unsigned long)ulLine);
    for (;;) {}
}

What does this code do?

  • The FreeRTOS headers (FreeRTOS.h, task.h, queue.h) give you access to the RTOS task scheduler and inter-task communication.
  • SENSOR_TASK_PRIORITY is higher than PROCESS_TASK_PRIORITY so sensor readings always take precedence over processing.
  • xMeasurementQueue is the shared queue that connects the two tasks.
  • vAssertCalled is the handler FreeRTOS calls when a kernel assertion fails. It prints the file and line, then halts.

You will not see any output yet. The next code block adds the sensor task that produces data.

  • Below the code you just pasted in src/main.c, add the sensor acquisition task:
static void vSensorTask(void *pvParameters) {
    int32_t raw_reading;
    interferometer_reading_t reading;
    TickType_t xLastWakeTime = xTaskGetTickCount();

    (void)pvParameters;
    printf("Sensor task started.\r\n");

    for (;;) {
        vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(SENSOR_READ_PERIOD_MS));

        if (interferometer_read_raw(&raw_reading) == INTERFEROMETER_OK) {
            reading.position_nm = raw_reading;
            reading.timestamp_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
            reading.quality = 100;

            if (xQueueSend(xMeasurementQueue, &reading, pdMS_TO_TICKS(100)) != pdPASS) {
                printf("WARNING: Queue full, measurement dropped.\r\n");
            }
        }
    }
}

What does the sensor task do?

  • vTaskDelayUntil wakes the task every 200 ms on a precise schedule. This is how real firmware achieves fixed-rate sampling.
  • Each reading gets packaged into an interferometer_reading_t struct with position, timestamp, and quality fields.
  • xQueueSend pushes the reading into the shared queue. If the queue is full, it prints a warning instead of blocking forever.

You will not see any output yet. The next code block adds the processing task and the main entry point.

  • Below vSensorTask in src/main.c, add the processing task and main function:
static void vProcessingTask(void *pvParameters) {
    interferometer_reading_t reading;
    int32_t buffer[NUM_SAMPLES_TO_AVERAGE];
    uint8_t buffer_index = 0;
    int32_t averaged_position;

    (void)pvParameters;
    printf("Processing task started.\r\n");

    for (;;) {
        if (xQueueReceive(xMeasurementQueue, &reading, portMAX_DELAY) == pdPASS) {
            buffer[buffer_index] = reading.position_nm;
            buffer_index++;

            if (buffer_index >= NUM_SAMPLES_TO_AVERAGE) {
                if (interferometer_average_readings(buffer, NUM_SAMPLES_TO_AVERAGE, &averaged_position) == INTERFEROMETER_OK) {
                    printf("[%lu ms] Averaged position: %ld nm",
                           (unsigned long)reading.timestamp_ms,
                           (long)averaged_position);

                    if (interferometer_check_bounds(averaged_position, -10000, 10000) != INTERFEROMETER_OK) {
                        printf(" *** OUT OF BOUNDS ***");
                    }
                    printf("\r\n");
                }
                buffer_index = 0;
            }
        }
    }
}

int main(void) {
    printf("=== Laser Interferometer Driver - FreeRTOS Simulation ===\r\n\r\n");

    interferometer_init();

    xMeasurementQueue = xQueueCreate(QUEUE_LENGTH, sizeof(interferometer_reading_t));
    if (xMeasurementQueue == NULL) {
        printf("ERROR: Failed to create queue.\r\n");
        return 1;
    }

    xTaskCreate(vSensorTask, "Sensor", configMINIMAL_STACK_SIZE, NULL, SENSOR_TASK_PRIORITY, NULL);
    xTaskCreate(vProcessingTask, "Process", configMINIMAL_STACK_SIZE, NULL, PROCESS_TASK_PRIORITY, NULL);

    vTaskStartScheduler();

    printf("ERROR: Scheduler exited unexpectedly.\r\n");
    return 1;
}

What does this code do?

The processing task and main function complete your application:

  • vProcessingTask blocks on xQueueReceive until a reading arrives. It buffers 5 readings, then averages them and checks if the position is within +/- 10,000 nm.
  • main() initializes the driver, creates the queue, creates both tasks, and starts the FreeRTOS scheduler. Once vTaskStartScheduler() runs, the RTOS takes over and your tasks run concurrently.
  • If the scheduler ever exits (it should not), the final printf and return 1 signal an error.
  • Save src/main.c.

✔️ Awesome, I've got everything!

Great. Double check you have saved both config/FreeRTOSConfig.h and src/main.c.

ⓧ I'd like to double check the full code

Here is the complete src/main.c for comparison:

#include <stdio.h>
#include <stdlib.h>

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "interferometer.h"

#define SENSOR_TASK_PRIORITY    (tskIDLE_PRIORITY + 2)
#define PROCESS_TASK_PRIORITY   (tskIDLE_PRIORITY + 1)
#define QUEUE_LENGTH            5
#define SENSOR_READ_PERIOD_MS   200
#define NUM_SAMPLES_TO_AVERAGE  5

static QueueHandle_t xMeasurementQueue;

void vAssertCalled(const char *pcFile, uint32_t ulLine) {
    printf("ASSERT FAILED: %s line %lu\r\n", pcFile, (unsigned long)ulLine);
    for (;;) {}
}

static void vSensorTask(void *pvParameters) {
    int32_t raw_reading;
    interferometer_reading_t reading;
    TickType_t xLastWakeTime = xTaskGetTickCount();

    (void)pvParameters;
    printf("Sensor task started.\r\n");

    for (;;) {
        vTaskDelayUntil(&xLastWakeTime, pdMS_TO_TICKS(SENSOR_READ_PERIOD_MS));

        if (interferometer_read_raw(&raw_reading) == INTERFEROMETER_OK) {
            reading.position_nm = raw_reading;
            reading.timestamp_ms = (uint32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS);
            reading.quality = 100;

            if (xQueueSend(xMeasurementQueue, &reading, pdMS_TO_TICKS(100)) != pdPASS) {
                printf("WARNING: Queue full, measurement dropped.\r\n");
            }
        }
    }
}

static void vProcessingTask(void *pvParameters) {
    interferometer_reading_t reading;
    int32_t buffer[NUM_SAMPLES_TO_AVERAGE];
    uint8_t buffer_index = 0;
    int32_t averaged_position;

    (void)pvParameters;
    printf("Processing task started.\r\n");

    for (;;) {
        if (xQueueReceive(xMeasurementQueue, &reading, portMAX_DELAY) == pdPASS) {
            buffer[buffer_index] = reading.position_nm;
            buffer_index++;

            if (buffer_index >= NUM_SAMPLES_TO_AVERAGE) {
                if (interferometer_average_readings(buffer, NUM_SAMPLES_TO_AVERAGE, &averaged_position) == INTERFEROMETER_OK) {
                    printf("[%lu ms] Averaged position: %ld nm",
                           (unsigned long)reading.timestamp_ms,
                           (long)averaged_position);

                    if (interferometer_check_bounds(averaged_position, -10000, 10000) != INTERFEROMETER_OK) {
                        printf(" *** OUT OF BOUNDS ***");
                    }
                    printf("\r\n");
                }
                buffer_index = 0;
            }
        }
    }
}

int main(void) {
    printf("=== Laser Interferometer Driver - FreeRTOS Simulation ===\r\n\r\n");

    interferometer_init();

    xMeasurementQueue = xQueueCreate(QUEUE_LENGTH, sizeof(interferometer_reading_t));
    if (xMeasurementQueue == NULL) {
        printf("ERROR: Failed to create queue.\r\n");
        return 1;
    }

    xTaskCreate(vSensorTask, "Sensor", configMINIMAL_STACK_SIZE, NULL, SENSOR_TASK_PRIORITY, NULL);
    xTaskCreate(vProcessingTask, "Process", configMINIMAL_STACK_SIZE, NULL, PROCESS_TASK_PRIORITY, NULL);

    vTaskStartScheduler();

    printf("ERROR: Scheduler exited unexpectedly.\r\n");
    return 1;
}

And here is the complete config/FreeRTOSConfig.h:

#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_H

#include <stdint.h>

#define configUSE_PREEMPTION                    1
#define configUSE_PORT_OPTIMISED_TASK_SELECTION 0
#define configUSE_IDLE_HOOK                     0
#define configUSE_TICK_HOOK                     0
#define configTICK_RATE_HZ                      (1000)
#define configMINIMAL_STACK_SIZE                ((unsigned short) 256)
#define configTOTAL_HEAP_SIZE                   ((size_t)(65 * 1024))
#define configMAX_TASK_NAME_LEN                 (12)
#define configUSE_TRACE_FACILITY                0
#define configUSE_16_BIT_TICKS                  0
#define configIDLE_SHOULD_YIELD                 1
#define configUSE_MUTEXES                       1
#define configCHECK_FOR_STACK_OVERFLOW          0
#define configUSE_RECURSIVE_MUTEXES             1
#define configQUEUE_REGISTRY_SIZE               0
#define configUSE_COUNTING_SEMAPHORES           1
#define configMAX_PRIORITIES                    (7)
#define configSUPPORT_STATIC_ALLOCATION         0
#define configSUPPORT_DYNAMIC_ALLOCATION        1

#define configUSE_TIMERS                        1
#define configTIMER_TASK_PRIORITY               (configMAX_PRIORITIES - 1)
#define configTIMER_QUEUE_LENGTH                5
#define configTIMER_TASK_STACK_DEPTH            (configMINIMAL_STACK_SIZE * 2)

#define INCLUDE_vTaskPrioritySet                1
#define INCLUDE_uxTaskPriorityGet               1
#define INCLUDE_vTaskDelete                     1
#define INCLUDE_vTaskSuspend                    1
#define INCLUDE_vTaskDelayUntil                 1
#define INCLUDE_vTaskDelay                      1

extern void vAssertCalled(const char *pcFile, uint32_t ulLine);
#define configASSERT(x) if ((x) == 0) vAssertCalled(__FILE__, __LINE__)

#endif
Build and run the FreeRTOS application

Your CMakeLists.txt already defines the interferometer_app target. It compiles your driver, the FreeRTOS kernel source files, and the Windows port together into one executable.

  • Regenerate the build system to pick up the new files by running:
cmake -S . -B build -G "NMake Makefiles"
  • Build the project by running:
cmake --build build
  • Run the FreeRTOS application by running:
build\interferometer_app.exe

You should see periodic output showing averaged position measurements in nanometers. The output looks something like this:

=== Laser Interferometer Driver - FreeRTOS Simulation ===

Sensor task started.
Processing task started.
[1000 ms] Averaged position: -47 nm
[2000 ms] Averaged position: 283 nm
[3000 ms] Averaged position: -891 nm *** OUT OF BOUNDS ***

What should I see?

Both tasks print a startup message, then averaged positions appear every second (5 samples at 200 ms each). The exact numbers will vary because the simulated sensor has randomized drift.

Occasional "OUT OF BOUNDS" warnings are expected. They appear when the simulated position drifts beyond +/- 10,000 nm.

  • Press Ctrl+C to stop the application.

Seeing a build error?

  • If you see "Cannot open include file: FreeRTOS.h", make sure you ran the cmake -S . -B build command again after creating the new files. CMake needs to regenerate to find the new source.
  • If you see "unresolved external symbol vAssertCalled", check that your vAssertCalled function in main.c matches the extern declaration in FreeRTOSConfig.h exactly.
  • If you see "cannot open winmm.lib", confirm you are running this in the Developer Command Prompt for VS 2022 (not a regular terminal).

Help me debug my FreeRTOS build error.

Your firmware is running with real-time task scheduling, inter-task communication, and signal processing. Next up, you will automate the entire build-and-test workflow with a Jenkins CI/CD pipeline running in Docker.

Set Up Jenkins in Docker and Create the CI Pipeline

Your driver is written, tested, and running under FreeRTOS. The final piece of a professional embedded workflow is automation. You need a system that builds and tests your code every time, without relying on you to remember.

In this step, you will launch a Jenkins server inside a Docker container, write a pipeline script, and trigger your first automated build. By the end, pushing code means tests run automatically.

In this step, get ready to:
  • Launch Jenkins in a Docker container with build tools pre-installed.
  • Write a Jenkinsfile that defines your CI pipeline stages.
  • Trigger your first automated build and see 15 tests pass.
Build and launch the Jenkins container

You already have a docker-compose.yml and jenkins/Dockerfile in your project from Step 2. These configure a Jenkins server with GCC and CMake pre-installed so it can compile your C code.

Why run tests in Linux when your app runs on Windows?

Professional embedded CI uses a two-tier approach. The fast feedback layer runs unit tests and static analysis on a host machine (no hardware needed). The second tier runs firmware on actual target hardware.

Your unit tests are pure C logic with no Windows dependencies. They build perfectly with GCC on Linux inside Docker. The FreeRTOS application uses the Windows port and must be built locally with MSVC. This separation is how semiconductor teams actually structure their CI/CD.

  • Make sure Docker Desktop is running. You should see the Docker whale icon in your system tray.
  • In your Developer Command Prompt (still in the project root), build and start the Jenkins container by running this command:
docker compose up -d --build

What does this command do?

  • docker compose up starts the services defined in your docker-compose.yml.
  • -d runs the container in detached mode (in the background).
  • --build forces Docker to rebuild the image from your jenkins/Dockerfile, installing GCC and CMake.

The first build takes 1-2 minutes as Docker downloads the Jenkins image and installs build tools. You will see progress messages in the terminal.

  • Wait about 30 seconds after the command completes for Jenkins to fully start.
  • Open your web browser and go to http://localhost:8080.

You should see the Jenkins unlock screen asking for an initial admin password.

  • Retrieve the initial admin password by running this command:
docker exec jenkins-interferometer cat /var/jenkins_home/secrets/initialAdminPassword
  • Copy the long string of characters that appears in your terminal.
  • Paste it into the Administrator password field in your browser and click Continue.
  • Click Install suggested plugins. Wait for all plugins to install (this takes 1-2 minutes).
  • When prompted to create the first admin user, enter a username and password you will remember.
  • Click Save and Continue.
  • On the Instance Configuration page, leave the Jenkins URL as http://localhost:8080/ and click Save and Finish.
  • Click Start using Jenkins.

You should now see the Jenkins dashboard with a welcome message.

Can't reach localhost:8080?

  • Run docker ps to check if the container is running. You should see jenkins-interferometer in the list.
  • If the container is not listed, run docker compose up -d --build again and check for error messages.
  • Make sure no other service is already using port 8080 on your machine.

Help me troubleshoot my Jenkins Docker container

Create the Jenkinsfile

A Jenkinsfile defines your CI/CD pipeline as code. It tells Jenkins exactly what steps to run, in what order, and what to do with the results.

  • Create a new file called Jenkinsfile in your project root (the laser-interferometer-driver folder). Paste the following pipeline definition:
pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'cmake -S /workspace -B build'
                sh 'cmake --build build'
            }
        }
        stage('Test') {
            steps {
                sh 'cd build && ctest --output-on-failure --output-junit test-results.xml'
            }
        }
    }
    post {
        always {
            junit 'build/test-results.xml'
        }
    }
}

What does this pipeline do?

  • The Build stage runs CMake with GCC (inside the Linux container) to compile your test executable. The /workspace path is where docker-compose.yml mounts your project.
  • The Test stage runs CTest, which executes your 15 unit tests. The --output-junit flag produces a JUnit XML report that Jenkins can parse.
  • The post/always block publishes the JUnit test results to the Jenkins UI regardless of whether the build passed or failed.
  • Save Jenkinsfile.
  • Confirm it exists by running:
dir Jenkinsfile

You should see Jenkinsfile listed with its file size.

File not found?

Make sure you created the file in the project root (same level as CMakeLists.txt), not inside a subdirectory. The filename must be exactly Jenkinsfile with a capital J and no file extension.

Help me find where my Jenkinsfile went

Create the pipeline job and run the first build

Jenkins is running and your pipeline script is ready. Now you will connect the two by creating a Pipeline job that uses your Jenkinsfile content.

  • In your browser, go back to the Jenkins dashboard at http://localhost:8080.
  • Click New Item in the left sidebar.
  • Enter interferometer-driver in the item name field.
  • Select Pipeline from the list of job types.
  • Click OK.

You are now on the pipeline configuration page.

  • Scroll down to the Pipeline section at the bottom of the page.
  • In the Definition dropdown, select Pipeline script.
  • Delete any placeholder text in the script editor box.
  • Copy the entire contents of your Jenkinsfile and paste it into the script editor.
  • Click Save at the bottom of the page.

You are now on the job page for your pipeline. Time to trigger it.

  • Click Build Now in the left sidebar.
  • Watch the Build History panel in the bottom-left. A new build (#1) will appear with a progress bar.
  • Wait for the build to finish. This takes about 15-30 seconds.

When the build completes, you should see a blue circle next to #1 indicating success.

  • Click on #1 in the Build History to open the build details.
  • Click Test Result in the left sidebar.

You should see Jenkins reporting 15 tests passing with a green status. Every unit test you wrote is now validated automatically.

Build failed with a red circle?

  • Click on the build number, then click Console Output to see the full log.
  • If you see cmake: command not found, your Docker image did not build correctly. Run docker compose down then docker compose up -d --build to rebuild it.
  • If you see a compilation error, check that your Jenkinsfile uses /workspace (not a relative path) in the cmake -S command.
  • If CTest reports 0 tests, make sure your CMakeLists.txt has the enable_testing() and add_test() lines.

Help me debug my Jenkins pipeline failure

✔️ Awesome, I've got everything!

Great. Double check you have saved your Jenkinsfile and that your Jenkins build shows green.

ⓧ I'd like to double check the full code

Here is the complete Jenkinsfile for reference:

pipeline {
    agent any
    stages {
        stage('Build') {
            steps {
                sh 'cmake -S /workspace -B build'
                sh 'cmake --build build'
            }
        }
        stage('Test') {
            steps {
                sh 'cd build && ctest --output-on-failure --output-junit test-results.xml'
            }
        }
    }
    post {
        always {
            junit 'build/test-results.xml'
        }
    }
}

You now have a complete embedded firmware development workflow. Your driver code, unit tests, and CI pipeline all work together. Any change to your C code can be validated automatically by clicking Build Now in Jenkins.

Secret mission

Add a Moving Average Filter and Static Analysis to the Pipeline

Your interferometer driver works, but real signal processing needs more sophisticated filtering. Can you implement a moving average filter with a configurable window size and add static analysis to catch code quality issues automatically in your CI pipeline?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no cloud costs. However, the Jenkins container and Docker volume do use disk space and memory while running.

Resources you used:

  • Jenkins Docker container (jenkins-interferometer).
  • Docker volume (jenkins_home) storing Jenkins configuration and job history.
  • Project directory (laser-interferometer-driver) with source code, tests, and build artifacts.
  • Cloned Git repositories in lib/ (FreeRTOS-Kernel and Unity).
  • Visual Studio 2022 with the C++ workload.
  • Docker Desktop application.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Jenkins container continues running and is accessible at http://localhost:8080.
  • Docker Desktop stays running in your system tray.
  • Your project files remain on your Desktop ready to edit.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU, but keep all files and configuration intact so you can resume later.

  • Stop the Jenkins container by running this in your terminal:
docker compose down
  • This stops and removes the container but preserves the jenkins_home volume with all your Jenkins jobs and configuration.
  • Quit Docker Desktop from the system tray icon to free up memory.
  • To resume later, relaunch Docker Desktop, navigate to your project folder, and run docker compose up -d --build.

Delete - I don't want to use this again

Remove all project resources and start fresh. This cannot be undone.

  • Stop and remove the Jenkins container and its volume by running:
docker compose down -v
  • The -v flag removes the named volume, deleting all Jenkins data.
  • Delete the project directory from your Desktop.
  • If you no longer need Docker Desktop, uninstall it from Settings then Apps & features.
  • If you no longer need Visual Studio 2022, uninstall it from the same location.

Nice Work!

Nice Work!

You just built a complete embedded firmware development workflow from the ground up. You now have a working RTOS-based measurement driver, a rigorous test suite, and an automated CI/CD pipeline proving you can write, test, and ship firmware the way semiconductor manufacturing teams do.

You've learned how to:

  • Write a precision measurement C driver with status codes, null-pointer guards, and bounds checking.
  • Build a real-time application using FreeRTOS tasks and queues to simulate sensor data acquisition and processing.
  • Automate your build and test cycle with a Jenkins CI/CD pipeline running inside Docker.
  • Secret Mission: Add a moving average filter with a configurable window size and integrate static analysis into your pipeline for production-quality code.

Ready to quiz yourself?