Laser Measurement System with FreeRTOS

Build a simulated laser interferometer using FreeRTOS tasks and a Jenkins CI/CD pipeline.

Introduction

30 Second Summary

Every semiconductor chip in your phone or laptop was manufactured with precision measured in nanometers. Laser interferometers are the instruments that make this precision possible, and they run on embedded firmware written in C.

In this project, you will build a simulated laser measurement system using FreeRTOS tasks and queues, then automate building and testing it with a Jenkins CI/CD pipeline.

What You'll Build

A multi-task C application running on the FreeRTOS Windows simulator that simulates a laser interferometer measurement pipeline, complete with a CMake build system, Google Test unit tests, and a Jenkins CI/CD pipeline.

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

  • A multi-task FreeRTOS application with inter-task communication via queues that simulates a laser interferometer sensor pipeline.
  • A signal processing library that filters measurement noise and converts raw fringe counts to nanometer displacement.
  • A CMake build system with automated unit tests using Google Test, and a local Jenkins CI/CD pipeline that builds, tests, and analyzes your code on every commit.
  • Secret Mission: Add mutex-protected runtime calibration that simulates how real measurement tools handle calibration updates without stopping.

Are there any prerequisites?

You need a basic understanding of programming concepts like loops, functions, and variables. Experience with LabVIEW or similar embedded tools is helpful but not required.

This project runs on Windows and assumes you have VS Code and Git installed. Step 1 walks you through installing everything else (MSYS2, CMake, Java, and Jenkins).

Install the Development Toolchain

Before you write any C code, you need a compiler that turns your code into executables your computer can run.

In this step, you'll install the GCC compiler toolchain through MSYS2, a package manager for Windows that provides Linux-style development tools. You'll also install Java 21 (needed for Jenkins later) and configure VS Code for C/C++ development.

In this step, get ready to:
  • Install MSYS2 and the MinGW-w64 GCC toolchain.
  • Install Java 21 for running Jenkins.
  • Verify all tools are accessible from the command line.
Install MSYS2 and MinGW-w64 GCC toolchain

MSYS2 is a software distribution for Windows that gives you access to GCC (the same compiler used in professional embedded development) through a package manager called pacman.

  • Open your browser and go to https://www.msys2.org/.
  • Click the download button for the installer (the .exe file).
  • Run the downloaded installer and accept all the default settings.
  • On the final screen, check Run MSYS2 now and click Finish.

An MSYS2 terminal window opens. This is where you'll use pacman to install the compiler toolchain.

Which MSYS2 terminal?

MSYS2 has several terminal environments. You need the UCRT64 terminal for this project.

If the terminal that opened says "MSYS" in the title bar instead of "UCRT64", close it. Open the UCRT64 terminal from your Start menu by searching for MSYS2 UCRT64.

  • Install the MinGW-w64 toolchain (GCC compiler, G++ compiler, GDB debugger, and make) by running this command:
pacman -S --needed base-devel mingw-w64-ucrt-x86_64-toolchain

What does this command do?

pacman is the package manager for MSYS2 (similar to how apt works on Ubuntu).

The mingw-w64-ucrt-x86_64-toolchain group includes gcc, g++, gdb, and mingw32-make.

  • When prompted to select which packages to install, press Enter to accept the default (all packages).
  • When asked to proceed with the installation, type Y and press Enter.

The download and installation takes a few minutes.

  • Once that finishes, install CMake by running this command:
pacman -S mingw-w64-ucrt-x86_64-cmake
  • Type Y and press Enter when prompted to proceed.

Now you need to add the MSYS2 tools to your Windows PATH so you can use them from any terminal, not just the MSYS2 terminal.

  • Press the Windows key to open search.
  • Type environment variables and click Edit the system environment variables.
  • Click Environment Variables at the bottom of the window.
  • Under User variables, select Path and click Edit.
  • Click New and paste this path:
C:\msys64\ucrt64\bin
  • Click OK on all three open windows to save.

Why add to PATH?

The PATH tells Windows where to find executables when you type a command. Without this step, you'd only be able to use gcc from inside the MSYS2 terminal.

Adding C:\msys64\ucrt64\bin makes all the tools accessible from Command Prompt, PowerShell, and VS Code's integrated terminal.

Installed MSYS2 to a different location?

If you changed the install directory during setup, adjust the PATH to match. The default is C:\msys64.

help me find my MSYS2 install path

Install Java 21 (Eclipse Temurin)

Jenkins (the CI/CD tool you'll set up later) requires Java 21 to run. Eclipse Temurin is the recommended free distribution of the Java Development Kit.

  • Open your browser and go to https://adoptium.net/temurin/releases.
  • Make sure the filters are set to version 21, Windows, and x64.
  • Download the .msi installer.
  • Run the installer.
  • On the Custom Setup screen, make sure Add to PATH and Set JAVA_HOME are both enabled.
  • Click Install and wait for it to complete.

Don't see a .msi download?

Make sure you selected JDK (not JRE) in the package type filter. The MSI option appears under the Windows x64 section.

help me download Eclipse Temurin JDK 21

Verify the toolchain

You need to confirm that all the tools you just installed are accessible from a regular Command Prompt. Opening a new terminal is important because PATH changes only take effect in new windows.

  • Press the Windows key to open search.
  • Type cmd and press Enter to open a new Command Prompt.
  • Check that GCC is accessible by running:
gcc --version
  • Check the C++ compiler:
g++ --version
  • Check CMake:
cmake --version
  • Check make:
mingw32-make --version
  • Check Java:
java -version
  • Check Git:
git --version

Each command should print a version number. If any command says "is not recognized", the PATH entry for that tool is missing.

✔️ I see version numbers for all commands

Your toolchain is ready. Time to set up VS Code for C/C++ development.

ⓧ One or more commands say 'not recognized'

This means the PATH entry is missing or the tool didn't install correctly.

  • If gcc, g++, cmake, or mingw32-make failed: verify that C:\msys64\ucrt64\bin is in your PATH and that you opened a new Command Prompt after editing PATH.
  • If java failed: rerun the Temurin installer and make sure Add to PATH is checked.
  • Close your Command Prompt and open a fresh one before retrying.

help me fix my PATH for MSYS2 tools

Now configure VS Code with the C/C++ extension so it can provide IntelliSense (code completion and error highlighting) for your project.

  • Press Windows key to open search.
  • Type VS Code and press Enter to open it.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • Search for C/C++ and install the extension by Microsoft (it should be the top result).

You should see a blue Installed badge on the extension after it finishes.

VS Code not finding GCC?

If VS Code shows a warning about not finding a compiler, close VS Code completely and reopen it. VS Code reads the PATH when it starts, so it needs a fresh launch to pick up your new PATH entries.

help me configure VS Code to find my GCC compiler

Your development environment is ready. Next up, you'll learn the C language fundamentals you need to build the measurement system.

Learn C Language Fundamentals for Embedded Development

Your development toolchain is ready to go. Now it is time to learn the C language patterns that every embedded system is built on.

Embedded firmware is written in C because it gives you direct control over memory and hardware. In this step, you will learn three essential patterns: structs (custom data types), header files (code organization), and pointer-based array processing. These are the exact same patterns you will use when building the measurement system in later steps.

In this step, get ready to:
  • Write a C program with a struct and functions, then compile and run it.
  • Split your code into header and source files for professional project organization.
  • Write a pointer-based function that processes an array of sensor values.
Write your first C program with structs and functions

In C, a struct groups related data together into a single custom type. This is how embedded systems package sensor readings, configuration parameters, and measurement results.

  • Open a Command Prompt by pressing the Windows key, typing cmd, and pressing Enter.
  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Create a practice folder and move into it by running these commands:
mkdir practice
cd practice
  • Confirm the folder exists by running:
cd

You should see a path ending in Desktop\practice.

Now you will create a single C file that defines a struct, writes a function to operate on it, and prints the result. This introduces the compile-link-run cycle that every C program follows.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the practice folder on your Desktop, then click Select Folder.
  • Create a new file by clicking the new file icon in the Explorer sidebar, then name it main.c.
  • Paste the following code into main.c:
#include <stdio.h>

/* A struct groups related data into one type */
typedef struct {
    double value;
    unsigned int timestamp;
} SensorReading;

/* This function takes a pointer to a SensorReading */
void print_reading(SensorReading *r) {
    printf("Value: %.4f, Timestamp: %u\n", r->value, r->timestamp);
}

int main(void) {
    /* Create a SensorReading with initial values */
    SensorReading r = {3.14, 1000};
    /* Pass the address of r using the & operator */
    print_reading(&r);
    return 0;
}

What does this code do?

typedef struct defines a new data type called SensorReading that bundles a double value and an unsigned int timestamp together.

The print_reading function takes a pointer to a SensorReading (that is what the * means). A pointer is a variable that holds a memory address instead of the actual data. Using r->value accesses the struct's fields through the pointer.

In main(), the & operator passes the address of r to the function. This is how embedded C avoids copying large structs around in memory.

  • Save main.c by pressing Ctrl+S.
  • Back in your Command Prompt (still in the practice folder), compile and run the program by running these commands:
gcc main.c -o main
main.exe

You should see this output:

Value: 3.1400, Timestamp: 1000

What is the compile-link-run cycle?

gcc main.c -o main compiles your C source code into an executable called main.exe. The -o flag names the output file.

Every time you change your C code, you must recompile before running. This is different from interpreted languages like Python where you just run the file directly.

Seeing an error when compiling?

If you see 'gcc' is not recognized, your PATH is not set correctly. Close and reopen Command Prompt, then verify with gcc --version.

If you see a syntax error, double-check that your code has matching curly braces and a semicolon at the end of each statement.

Help me fix my GCC compilation error

Understand header files and multi-file projects

Real embedded projects never put everything in one file. They separate declarations (what exists) from definitions (how it works). Declarations go in header files (.h) and definitions go in source files (.c).

You will now split your single-file program into three files. This is how every embedded C project you will encounter is organized.

  • In VS Code, create a new file in the practice folder called sensor_reading.h.
  • Paste the following code into sensor_reading.h:
#ifndef SENSOR_READING_H
#define SENSOR_READING_H

typedef struct {
    double value;
    unsigned int timestamp;
} SensorReading;

void print_reading(SensorReading *r);

#endif

What are include guards?

The #ifndef SENSOR_READING_H and #define SENSOR_READING_H lines are called include guards. They prevent the header from being processed more than once if multiple files include it.

Without include guards, the compiler would see duplicate struct definitions and throw an error. Every header file in embedded C uses this pattern.

  • Save sensor_reading.h.
  • Create another new file in the practice folder called sensor_reading.c.
  • Paste the following code into sensor_reading.c:
#include <stdio.h>
#include "sensor_reading.h"

void print_reading(SensorReading *r) {
    printf("Value: %.4f, Timestamp: %u\n", r->value, r->timestamp);
}

What is the difference between < > and quotes in includes?

#include <stdio.h> uses angle brackets to include system headers (part of the C standard library).

#include "sensor_reading.h" uses quotes to include your own project headers. This tells the compiler to look in your project folder first.

  • Save sensor_reading.c.
  • Now replace the entire contents of main.c with this updated version that uses the header:
#include <stdio.h>
#include "sensor_reading.h"

int main(void) {
    SensorReading r = {3.14, 1000};
    print_reading(&r);
    return 0;
}

Why split into multiple files?

main.c no longer contains the struct definition or function body. It only knows about SensorReading and print_reading because the header declares them.

This separation means you can change how a function works (in the .c file) without touching the files that use it. The measurement system you build later will use this pattern for all its modules.

  • Save main.c.
  • In your Command Prompt, compile both source files together by running:
gcc main.c sensor_reading.c -o main
main.exe

You should see the same output as before: Value: 3.1400, Timestamp: 1000.

What does compiling multiple files do?

gcc main.c sensor_reading.c -o main compiles each .c file separately, then links them together into one executable. The linker connects main.c's call to print_reading() with the actual implementation in sensor_reading.c.

Seeing undefined reference errors?

If you see undefined reference to 'print_reading', make sure you included sensor_reading.c in the compile command. Both .c files must be listed.

If you see No such file or directory for the header, verify that sensor_reading.h is in the same folder as your .c files.

Help me fix my multi-file compilation error

Work with pointers and arrays

Signal processing in embedded systems works on buffers of data. In C, arrays are passed to functions as pointers. You will now write a function that takes an array of sensor readings and computes their average. This is the exact pattern used in the measurement system's moving average filter.

  • In sensor_reading.h, find this line:
void print_reading(SensorReading *r);
  • Add this new function declaration directly below it (before the #endif):
double compute_average(const double *arr, int size);

What does this function signature mean?

const double *arr means the function receives a pointer to an array of doubles. The const keyword promises that the function will not modify the array contents.

int size tells the function how many elements the array contains. In C, arrays do not carry their own length, so you must always pass the size separately.

  • Save sensor_reading.h.
  • In sensor_reading.c, add the implementation of compute_average at the bottom of the file, below the print_reading function:
double compute_average(const double *arr, int size) {
    double sum = 0.0;
    for (int i = 0; i < size; i++) sum += arr[i];
    return sum / (double)size;
}

How does array access work with pointers?

arr[i] accesses the element at position i in the array. Even though arr is declared as a pointer (double *), you can use square bracket notation to index into it. This is because arrays and pointers are closely related in C.

The cast (double)size converts the integer to a double before division. Without it, C would perform integer division and discard the decimal portion.

  • Save sensor_reading.c.
  • Now update main.c to call this new function. Replace the entire contents of main.c with:
#include <stdio.h>
#include "sensor_reading.h"

int main(void) {
    SensorReading r = {3.14, 1000};
    print_reading(&r);
    double data[] = {1.0, 2.0, 3.0, 4.0, 5.0};
    printf("Average: %.2f\n", compute_average(data, 5));
    return 0;
}

What does this code do?

double data[] declares an array of five doubles. When you pass data to a function, C automatically converts it to a pointer to the first element.

This is the same pattern the measurement system uses to pass a buffer of sensor readings to the moving average filter. You pass the array and its size, and the function processes all the values.

  • Save main.c.
  • Compile and run the updated program by running:
gcc main.c sensor_reading.c -o main
main.exe

You should see both lines of output:

Value: 3.1400, Timestamp: 1000
Average: 3.00

The average of 1, 2, 3, 4, 5 is 3.00, confirming your function works correctly.

Output not showing the average?

If you see the sensor reading line but not the average, check that compute_average is declared in sensor_reading.h and implemented in sensor_reading.c.

If the average shows 0.00, make sure you are dividing by (double)size and not just size. Integer division would truncate the result.

Help me debug my compute_average output

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here are all three files in their final state. Compare these with your files to spot any differences.

#ifndef SENSOR_READING_H
#define SENSOR_READING_H

typedef struct {
    double value;
    unsigned int timestamp;
} SensorReading;

void print_reading(SensorReading *r);
double compute_average(const double *arr, int size);

#endif
#include <stdio.h>
#include "sensor_reading.h"

void print_reading(SensorReading *r) {
    printf("Value: %.4f, Timestamp: %u\n", r->value, r->timestamp);
}

double compute_average(const double *arr, int size) {
    double sum = 0.0;
    for (int i = 0; i < size; i++) sum += arr[i];
    return sum / (double)size;
}
#include <stdio.h>
#include "sensor_reading.h"

int main(void) {
    SensorReading r = {3.14, 1000};
    print_reading(&r);
    double data[] = {1.0, 2.0, 3.0, 4.0, 5.0};
    printf("Average: %.2f\n", compute_average(data, 5));
    return 0;
}

You now have the three core C patterns under your belt: structs for bundling data, header files for code organization, and pointers for processing arrays. Next up, you will set up the real project structure with CMake and FreeRTOS.

Set Up the Project Structure and CMake Build

In the last step, you learned the core C patterns that power every embedded project: structs, header files, pointers, and multi-file compilation.

Now it is time to build the real project structure. Every professional embedded project needs a build system that can compile dozens of source files, pull in external libraries, and run automated tests.

In this step, you will create the project folder structure, clone the FreeRTOS kernel, and write a CMake build configuration that ties everything together.

In this step, get ready to:
  • Create the project directory structure and clone the FreeRTOS kernel.
  • Write a CMakeLists.txt that configures the build system, FreeRTOS sources, and Google Test.
  • Create a FreeRTOSConfig.h and verify the project compiles.
Create the project directory structure

Embedded projects follow a standard folder layout: source files in src/, header files in include/, and test files in tests/. This separation keeps the project organized as it grows.

  • Move to your Desktop by running this command:
cd %USERPROFILE%\Desktop
  • Create the project folder and its subdirectories by running these commands:
mkdir laser_measurement_system
cd laser_measurement_system
mkdir src include tests
  • Confirm the folders exist by running:
dir

You should see include, src, and tests listed as directories in the output.

Next, you need the FreeRTOS kernel source code. FreeRTOS provides a Windows simulator port that runs RTOS tasks as Windows threads, letting you develop and test task logic without any hardware.

  • Clone the FreeRTOS kernel repository into your project by running:
git clone https://github.com/FreeRTOS/FreeRTOS-Kernel.git

What is this downloading?

The FreeRTOS kernel is the core scheduler and API that manages tasks, queues, and semaphores. It is about 5-10 KB of compiled code and uses roughly 236 bytes of RAM for the scheduler itself.

You are cloning just the kernel (not the full FreeRTOS repository with all its demos). The Windows port you will use is in the portable/MSVC-MingW/ directory inside the cloned repo.

  • Verify the clone succeeded by running:
dir FreeRTOS-Kernel

You should see folders like include, portable, and files like tasks.c and queue.c in the output.

Clone failed or timed out?

Make sure you have an internet connection and that git is accessible from your Command Prompt (run git --version to check). If you see a certificate error, try running git config --global http.sslBackend schannel and retrying.

Help me troubleshoot the git clone failure

  • Open the laser_measurement_system folder in VS Code. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the laser_measurement_system folder, then click Select Folder.

Your VS Code Explorer sidebar should now show the src, include, tests, and FreeRTOS-Kernel folders.

Write the root CMakeLists.txt

CMake is the modern standard build system for C/C++ projects. Instead of manually typing compiler commands, you describe your project in a CMakeLists.txt file and CMake generates the build scripts for you.

Why CMake instead of raw Makefiles?

CMake integrates cleanly with Google Test (automatic download), supports multiple compilers, and is widely used in embedded CI/CD pipelines. Jenkins, the CI tool you will set up later, works directly with CMake.

  • In VS Code, create a new file in the root of your project by pressing Ctrl+N.
  • Save it immediately with Ctrl+S and name it CMakeLists.txt in the root of your laser_measurement_system folder.
  • Add the project settings and FreeRTOS source list by pasting this code:
cmake_minimum_required(VERSION 3.14)
project(laser_measurement_system C CXX)

set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# FreeRTOS Kernel sources
set(FREERTOS_KERNEL_PATH "${CMAKE_SOURCE_DIR}/FreeRTOS-Kernel")

set(FREERTOS_SOURCES
    ${FREERTOS_KERNEL_PATH}/tasks.c
    ${FREERTOS_KERNEL_PATH}/queue.c
    ${FREERTOS_KERNEL_PATH}/list.c
    ${FREERTOS_KERNEL_PATH}/timers.c
    ${FREERTOS_KERNEL_PATH}/portable/MemMang/heap_4.c
    ${FREERTOS_KERNEL_PATH}/portable/MSVC-MingW/port.c
)

What does this code do?

The first three lines set the minimum CMake version, name the project, and enable both C and C++ languages (C for the firmware, C++ for Google Test).

The FREERTOS_KERNEL_PATH variable points to the kernel you just cloned. The FREERTOS_SOURCES list names the specific kernel files needed: the task scheduler, queue system, timer service, memory allocator (heap_4.c), and the Windows simulator port (port.c).

  • Below the code you just added, paste the main application target:
# Main application
add_executable(laser_measurement
    src/main.c
    src/sensor_task.c
    src/processing_task.c
    src/output_task.c
    src/signal_processing.c
    ${FREERTOS_SOURCES}
)

target_include_directories(laser_measurement PRIVATE
    ${CMAKE_SOURCE_DIR}/include
    ${FREERTOS_KERNEL_PATH}/include
    ${FREERTOS_KERNEL_PATH}/portable/MSVC-MingW
)

target_link_libraries(laser_measurement PRIVATE winmm)

What does this code do?

The add_executable command defines your program. It lists every C source file that will be compiled and linked together, including the FreeRTOS kernel sources.

The target_include_directories tells the compiler where to find header files. The winmm library provides the multimedia timer functions that the FreeRTOS Windows port needs to simulate tick interrupts.

  • Below the main application section, paste the Google Test configuration:
# Google Test for unit tests
include(FetchContent)
FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.15.2.zip
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(measurement_tests
    tests/test_signal_processing.cpp
    tests/test_sensor_simulation.cpp
    src/signal_processing.c
)

target_include_directories(measurement_tests PRIVATE
    ${CMAKE_SOURCE_DIR}/include
)

target_link_libraries(measurement_tests PRIVATE GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(measurement_tests)

What does this code do?

FetchContent automatically downloads Google Test v1.15.2 during the build. No manual download required.

The gtest_force_shared_crt setting prevents a common Windows linker conflict. The measurement_tests target compiles the test files alongside the signal processing source so you can test your math functions independently of FreeRTOS.

  • Save CMakeLists.txt with Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Here is the complete CMakeLists.txt for comparison:

cmake_minimum_required(VERSION 3.14)
project(laser_measurement_system C CXX)

set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

# FreeRTOS Kernel sources
set(FREERTOS_KERNEL_PATH "${CMAKE_SOURCE_DIR}/FreeRTOS-Kernel")

set(FREERTOS_SOURCES
    ${FREERTOS_KERNEL_PATH}/tasks.c
    ${FREERTOS_KERNEL_PATH}/queue.c
    ${FREERTOS_KERNEL_PATH}/list.c
    ${FREERTOS_KERNEL_PATH}/timers.c
    ${FREERTOS_KERNEL_PATH}/portable/MemMang/heap_4.c
    ${FREERTOS_KERNEL_PATH}/portable/MSVC-MingW/port.c
)

# Main application
add_executable(laser_measurement
    src/main.c
    src/sensor_task.c
    src/processing_task.c
    src/output_task.c
    src/signal_processing.c
    ${FREERTOS_SOURCES}
)

target_include_directories(laser_measurement PRIVATE
    ${CMAKE_SOURCE_DIR}/include
    ${FREERTOS_KERNEL_PATH}/include
    ${FREERTOS_KERNEL_PATH}/portable/MSVC-MingW
)

target_link_libraries(laser_measurement PRIVATE winmm)

# Google Test for unit tests
include(FetchContent)
FetchContent_Declare(
    googletest
    URL https://github.com/google/googletest/archive/refs/tags/v1.15.2.zip
)
set(gtest_force_shared_crt ON CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(googletest)

enable_testing()

add_executable(measurement_tests
    tests/test_signal_processing.cpp
    tests/test_sensor_simulation.cpp
    src/signal_processing.c
)

target_include_directories(measurement_tests PRIVATE
    ${CMAKE_SOURCE_DIR}/include
)

target_link_libraries(measurement_tests PRIVATE GTest::gtest_main)

include(GoogleTest)
gtest_discover_tests(measurement_tests)
Create FreeRTOSConfig.h and verify the build compiles

FreeRTOS requires a configuration header that tells it how to behave: how much memory to allocate, how many priority levels to support, and which optional features to enable. Every FreeRTOS project must have this file.

  • In the VS Code Explorer sidebar, right-click the include folder and select New File.
  • Name the file FreeRTOSConfig.h.
  • Paste the following configuration into the file:
#ifndef FREERTOS_CONFIG_H
#define FREERTOS_CONFIG_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 ) 128 )
#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 configUSE_RECURSIVE_MUTEXES            0
#define configQUEUE_REGISTRY_SIZE              0
#define configUSE_COUNTING_SEMAPHORES          0
#define configUSE_ALTERNATIVE_API              0
#define configCHECK_FOR_STACK_OVERFLOW         0
#define configUSE_APPLICATION_TASK_TAG         0
#define configGENERATE_RUN_TIME_STATS          0

#define configMAX_PRIORITIES                    ( 7 )
#define configMAX_CO_ROUTINE_PRIORITIES         ( 2 )
#define configUSE_CO_ROUTINES                  0
#define configUSE_TIMERS                       0

#define configUSE_MALLOC_FAILED_HOOK           0

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

#endif /* FREERTOS_CONFIG_H */

What do these settings control?

The key settings for your project are: configTICK_RATE_HZ (1000 means 1 ms per tick), configTOTAL_HEAP_SIZE (65 KB for task stacks and queues), and configMAX_PRIORITIES (7 priority levels for your tasks).

The INCLUDE_* defines enable specific API functions. Setting them to 1 means your code can use those functions. The ones enabled here (vTaskDelay, vTaskDelayUntil, etc.) are the ones your tasks will need.

  • Save FreeRTOSConfig.h.

Now you need a placeholder main.c so CMake has something to compile. You will write the real main function in a later step.

  • Right-click the src folder in the Explorer sidebar and select New File.
  • Name it main.c.
  • Paste this placeholder code:
// Placeholder main.c
int main(void) { return 0; }
  • Save main.c.

Time to test the build. The CMakeLists.txt references source files that do not exist yet (sensor_task.c, processing_task.c, etc.), so you need to create empty placeholder files for them too.

  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
  • Create the placeholder source files by running these commands:
echo // placeholder > src\sensor_task.c
echo // placeholder > src\processing_task.c
echo // placeholder > src\output_task.c
echo // placeholder > src\signal_processing.c
  • Create placeholder test files by running:
echo // placeholder > tests\test_signal_processing.cpp
echo // placeholder > tests\test_sensor_simulation.cpp

Now run the CMake configuration step. This reads your CMakeLists.txt and generates the actual build scripts.

  • Configure the build by running:
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++

What does this command do?

  • -S . tells CMake the source is in the current directory.
  • -B build puts all generated files into a build/ folder (keeps your source clean).
  • -G "MinGW Makefiles" generates build scripts for your MinGW toolchain.
  • The compiler flags explicitly select GCC, preventing CMake from accidentally picking up a different compiler if one is installed.

First run downloads Google Test

The first time you run this command, CMake will download Google Test from GitHub. This may take a minute depending on your internet speed. Subsequent builds skip this download.

  • Once configuration succeeds, compile the project by running:
cmake --build build

You should see compiler output ending with a successful link. The build creates laser_measurement.exe inside the build/ folder.

  • Verify the executable was created by running:
dir build\laser_measurement.exe

You should see the file listed with its size. The placeholder returns immediately if you run it, which is expected.

Build failed with errors?

If you see CMAKE_C_COMPILER not set or similar, make sure your MSYS2 ucrt64 bin directory (C:\msys64\ucrt64\bin) is on your system PATH. Close and reopen your terminal after editing PATH.

If you see No such file or directory errors referencing FreeRTOS headers, confirm the FreeRTOS-Kernel folder exists in your project root and contains an include subfolder.

Help me debug my CMake build errors

Your build system is locked in. You have a CMake configuration that compiles FreeRTOS kernel sources, links the Windows simulator port, and is ready for Google Test. Next up, you will write the actual signal processing functions that power the measurement system.

Write the Signal Processing Library

Your project structure is set up and the CMake build system compiles successfully.

Now you need the core logic that powers the measurement system. In a real laser interferometer, raw optical fringe counts need to be filtered and converted into meaningful displacement values. In this step, you will write that signal processing logic as pure C functions that are completely independent of FreeRTOS. This separation is a professional embedded development pattern that makes your code testable on the host machine without needing hardware or an RTOS scheduler.

In this step, get ready to:
  • Define the measurement data structures and function prototypes in a header file.
  • Implement three signal processing functions: averaging, fringe conversion, and sensor simulation.
  • Verify the functions produce correct output by compiling and running a temporary test.
Define the data structures and function prototypes in a header

Every embedded C project starts with a header file that defines the "contract" for a module. It declares the data types, constants, and function signatures that other files can use without knowing the implementation details.

  • Create a new file called measurement_system.h inside your include/ folder.
  • Copy the following code into it:
#ifndef MEASUREMENT_SYSTEM_H
#define MEASUREMENT_SYSTEM_H

#include <stdint.h>

/* Configuration constants */
#define SENSOR_SAMPLE_RATE_MS      50
#define FILTER_WINDOW_SIZE         5
#define LASER_WAVELENGTH_NM        632.8
#define NOISE_AMPLITUDE            0.05
#define STAGE_SPEED                0.1

/* Data structures */
typedef struct {
    double raw_fringe_count;
    uint32_t timestamp_ms;
} SensorReading;

typedef struct {
    double displacement_nm;
    double filtered_value;
    uint32_t timestamp_ms;
} ProcessedMeasurement;

/* Signal processing functions (testable without RTOS) */
double apply_moving_average(const double *buffer, int size);
double fringe_to_nanometers(double fringe_count, double wavelength_nm);
double generate_simulated_sensor(uint32_t sample_number, double noise_amplitude);

/* Task function declarations */
void vSensorAcquisitionTask(void *pvParameters);
void vSignalProcessingTask(void *pvParameters);
void vDataOutputTask(void *pvParameters);

#endif /* MEASUREMENT_SYSTEM_H */

What does this header define?

This header defines the entire interface for your measurement system. It has four sections:

  • Configuration constants control system behavior. SENSOR_SAMPLE_RATE_MS sets sampling at 50ms (20 Hz). LASER_WAVELENGTH_NM is 632.8nm, the wavelength of a HeNe laser used in real interferometers.
  • SensorReading holds a raw fringe count and timestamp from the simulated sensor.
  • ProcessedMeasurement holds the filtered value converted to nanometer displacement.
  • Function prototypes declare the signal processing functions and FreeRTOS task functions. Other files can call these functions by including this header.

Why separate signal processing from RTOS code?

The three signal processing functions have no dependency on FreeRTOS. They take inputs and return outputs as pure C functions. This means you can test them on your computer directly without running the RTOS scheduler.

This is the standard embedded testing pattern: test logic on the host machine, test integration on the target. You will use this pattern later when writing Google Test unit tests.

  • Save measurement_system.h.
Implement the signal processing functions

Now you will write the actual implementation of the three signal processing functions. These functions use only basic arithmetic. No trigonometry or complex math libraries are required.

  • Create a new file called signal_processing.c inside your src/ folder.
  • Add the includes and the moving average function by copying this code:
#include "measurement_system.h"
#include <stdlib.h>

/* Compute the average of values in a buffer */
double apply_moving_average(const double *buffer, int size)
{
    if (buffer == NULL || size <= 0) {
        return 0.0;
    }

    double sum = 0.0;
    for (int i = 0; i < size; i++) {
        sum += buffer[i];
    }
    return sum / (double)size;
}

What does apply_moving_average do?

This function takes a pointer to an array of doubles and the array size, then returns the arithmetic mean. The NULL check and zero-size guard prevent crashes if bad data is passed in.

In a real measurement system, a moving average smooths out random sensor noise. Each new reading replaces the oldest value in a fixed-size buffer, and the average of all buffer values becomes the filtered output.

  • Add the fringe conversion and sensor simulation functions below apply_moving_average:
/* Convert fringe count to nanometer displacement */
double fringe_to_nanometers(double fringe_count, double wavelength_nm)
{
    /* displacement = count * (wavelength / 2) */
    return fringe_count * (wavelength_nm / 2.0);
}

/* Simulate a stage moving at constant speed with random noise */
double generate_simulated_sensor(uint32_t sample_number, double noise_amplitude)
{
    double base_signal = (double)sample_number * STAGE_SPEED;
    double random_value = (double)(rand() % 1000) / 500.0 - 1.0;
    double noise = noise_amplitude * random_value;
    return base_signal + noise;
}

What do these two functions do?

  • fringe_to_nanometers() applies the fundamental interferometry formula. Each optical fringe represents half a laser wavelength of stage motion. With a 632.8nm HeNe laser, one fringe equals 316.4nm of displacement.
  • generate_simulated_sensor() produces a steadily increasing base signal (simulating a stage moving at constant speed) with random noise added on top. The noise is a random value between -1.0 and +1.0, scaled by noise_amplitude.
  • Save signal_processing.c.

Seeing red underlines in VS Code?

If VS Code shows errors on the #include "measurement_system.h" line, your editor may not know where to find the include folder. This does not affect compilation with gcc or CMake, which specify include paths explicitly.

help me fix include path errors in VS Code for my C project

Test the functions manually

Before connecting these functions to FreeRTOS, you should verify the math is correct. You will temporarily replace your placeholder main.c with test code, compile directly with gcc, and check the output.

  • Open src/main.c in VS Code.
  • Replace its entire contents with this temporary test code:
#include "measurement_system.h"
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    /* Test apply_moving_average */
    double buffer[] = {2.0, 4.0, 6.0, 8.0, 10.0};
    double avg = apply_moving_average(buffer, 5);
    printf("Moving average of [2,4,6,8,10]: %.2f (expected 6.00)\n", avg);

    /* Test fringe_to_nanometers */
    double nm = fringe_to_nanometers(1.0, 632.8);
    printf("1 fringe at 632.8nm: %.1f nm (expected 316.4)\n", nm);

    /* Test generate_simulated_sensor */
    srand(42);
    printf("Sensor readings (first 5 samples):\n");
    for (int i = 0; i < 5; i++) {
        double val = generate_simulated_sensor(i, 0.05);
        printf("  Sample %d: %.4f\n", i, val);
    }

    return 0;
}

What does this test code do?

  • Calls apply_moving_average with known values [2, 4, 6, 8, 10] and checks the result is 6.0.
  • Calls fringe_to_nanometers with 1 fringe at 632.8nm and checks the result is 316.4nm.
  • Generates 5 simulated sensor readings to confirm the values increase over time with small noise added.
  • Save src/main.c.
  • Compile the test directly with gcc by running this command from your laser_measurement_system folder:
gcc -I include src/main.c src/signal_processing.c -o test_functions.exe

Why gcc instead of CMake here?

Your CMakeLists.txt references source files that do not exist yet (sensor_task.c, processing_task.c, output_task.c). Compiling directly with gcc lets you test just the two files you have. The -I include flag tells gcc where to find your header file.

  • Run the compiled test by running:
./test_functions.exe

You should see output similar to this:

Moving average of [2,4,6,8,10]: 6.00 (expected 6.00)
1 fringe at 632.8nm: 316.4 nm (expected 316.4)
Sensor readings (first 5 samples):
  Sample 0: 0.0344
  Sample 1: 0.1482
  Sample 2: 0.2017
  Sample 3: 0.2689
  Sample 4: 0.4231

What should I check in the output?

  • The moving average should be exactly 6.00.
  • The fringe conversion should be exactly 316.4.
  • Sensor readings should increase roughly by 0.1 per sample (that is the STAGE_SPEED constant) with small random variations. Your exact numbers will differ because of the random noise, but the upward trend should be clear.

Seeing a compilation error?

  • If you see measurement_system.h: No such file or directory, make sure you are running the gcc command from the laser_measurement_system root folder (not from inside src/).
  • If you see undefined reference to apply_moving_average, make sure both src/main.c and src/signal_processing.c are listed in the gcc command.

help me fix my gcc compilation error for signal_processing.c

  • Now that you have verified the functions work, revert src/main.c back to its placeholder state. Replace the entire file contents with:
// Placeholder main.c
int main(void) { return 0; }
  • Save src/main.c.
  • Delete the temporary test binary by running:
del test_functions.exe

You will write the real main.c with FreeRTOS task creation in a later step.

✔️ Awesome, I've got everything!

Great. Double check you have saved both include/measurement_system.h and src/signal_processing.c.

ⓧ I'd like to double check the full code

Here is the complete content for each file you created in this step.

#ifndef MEASUREMENT_SYSTEM_H
#define MEASUREMENT_SYSTEM_H

#include <stdint.h>

/* Configuration constants */
#define SENSOR_SAMPLE_RATE_MS      50
#define FILTER_WINDOW_SIZE         5
#define LASER_WAVELENGTH_NM        632.8
#define NOISE_AMPLITUDE            0.05
#define STAGE_SPEED                0.1

/* Data structures */
typedef struct {
    double raw_fringe_count;
    uint32_t timestamp_ms;
} SensorReading;

typedef struct {
    double displacement_nm;
    double filtered_value;
    uint32_t timestamp_ms;
} ProcessedMeasurement;

/* Signal processing functions (testable without RTOS) */
double apply_moving_average(const double *buffer, int size);
double fringe_to_nanometers(double fringe_count, double wavelength_nm);
double generate_simulated_sensor(uint32_t sample_number, double noise_amplitude);

/* Task function declarations */
void vSensorAcquisitionTask(void *pvParameters);
void vSignalProcessingTask(void *pvParameters);
void vDataOutputTask(void *pvParameters);

#endif /* MEASUREMENT_SYSTEM_H */
#include "measurement_system.h"
#include <stdlib.h>

double apply_moving_average(const double *buffer, int size)
{
    if (buffer == NULL || size <= 0) {
        return 0.0;
    }

    double sum = 0.0;
    for (int i = 0; i < size; i++) {
        sum += buffer[i];
    }
    return sum / (double)size;
}

double fringe_to_nanometers(double fringe_count, double wavelength_nm)
{
    return fringe_count * (wavelength_nm / 2.0);
}

double generate_simulated_sensor(uint32_t sample_number, double noise_amplitude)
{
    double base_signal = (double)sample_number * STAGE_SPEED;
    double random_value = (double)(rand() % 1000) / 500.0 - 1.0;
    double noise = noise_amplitude * random_value;
    return base_signal + noise;
}
// Placeholder main.c
int main(void) { return 0; }

Your signal processing library is verified and ready. Next up, you will connect these functions to FreeRTOS tasks that simulate a real-time measurement pipeline.

Create the FreeRTOS Multi-Task Architecture

You have a working signal processing library with three pure C functions. Now it is time to bring the system to life by connecting those functions into a real-time pipeline.

In a real laser measurement system, separate hardware modules handle sensing, processing, and output simultaneously. FreeRTOS tasks model this architecture in software. Each task runs in its own loop, and queues pass data between them without shared memory conflicts.

In this step, get ready to:
  • Write a sensor acquisition task that generates readings at a fixed interval.
  • Write a signal processing task that filters and converts sensor data.
  • Write a data output task and main function that ties the system together.
Write the sensor acquisition task

The sensor task is the heartbeat of the measurement system. It generates a new simulated reading every 50 milliseconds and pushes it into a queue for downstream processing.

  • Create a new file called sensor_task.c inside your src/ folder.
  • Paste the following code into src/sensor_task.c:
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"

extern QueueHandle_t xSensorQueue;

void vSensorAcquisitionTask(void *pvParameters)
{
    SensorReading reading;
    TickType_t xLastWakeTime = xTaskGetTickCount();
    uint32_t sample_number = 0;

    /* Cast unused parameter to void to avoid compiler warning */
    (void)pvParameters;

    for (;;) {
        /* Generate a simulated sensor reading */
        reading.raw_fringe_count = generate_simulated_sensor(
            sample_number, NOISE_AMPLITUDE);
        reading.timestamp_ms = (uint32_t)xTaskGetTickCount();

        /* Send reading to the processing task via queue */
        xQueueSend(xSensorQueue, &reading, portMAX_DELAY);

        sample_number++;

        /* Wait until next sample period (50ms = 20 Hz) */
        vTaskDelayUntil(&xLastWakeTime,
            pdMS_TO_TICKS(SENSOR_SAMPLE_RATE_MS));
    }
}

What does this code do?

This task runs in an infinite loop, just like a real embedded sensor driver.

  • extern QueueHandle_t xSensorQueue declares a queue that is defined in main.c. This is how tasks share the communication channel without global coupling.
  • vTaskDelayUntil() blocks the task until the next 50ms boundary. Unlike vTaskDelay(), it compensates for execution time so the sample rate stays precise.
  • xQueueSend() copies the reading struct into the queue. The processing task can receive it whenever it is ready.
  • Save src/sensor_task.c.

You will not see any visible output yet. The sensor task feeds data into the processing task, which you will write next.

Write the signal processing task

The processing task sits in the middle of the pipeline. It receives raw sensor readings, smooths them with the moving average filter you already wrote, converts fringe counts to nanometers, and passes the result to the output task.

  • Create a new file called processing_task.c inside your src/ folder.
  • Paste the following code into src/processing_task.c:
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"

extern QueueHandle_t xSensorQueue;
extern QueueHandle_t xOutputQueue;

void vSignalProcessingTask(void *pvParameters)
{
    SensorReading reading;
    ProcessedMeasurement result;
    double filter_buffer[FILTER_WINDOW_SIZE] = {0};
    int buffer_index = 0;

    (void)pvParameters;

    for (;;) {
        /* Wait for a reading from the sensor task */
        if (xQueueReceive(xSensorQueue, &reading, portMAX_DELAY) == pdPASS) {

            /* Store new reading in circular buffer */
            filter_buffer[buffer_index] = reading.raw_fringe_count;
            buffer_index = (buffer_index + 1) % FILTER_WINDOW_SIZE;

            /* Apply moving average to smooth out noise */
            double filtered = apply_moving_average(
                filter_buffer, FILTER_WINDOW_SIZE);

            /* Convert filtered fringe count to nanometers */
            result.displacement_nm = fringe_to_nanometers(
                filtered, LASER_WAVELENGTH_NM);
            result.filtered_value = filtered;
            result.timestamp_ms = reading.timestamp_ms;

            /* Send processed result to output task */
            xQueueSend(xOutputQueue, &result, portMAX_DELAY);
        }
    }
}

What does this code do?

This task demonstrates a common embedded pattern: a circular buffer feeding a filter.

  • filter_buffer[FILTER_WINDOW_SIZE] holds the last 5 readings. Each new reading overwrites the oldest slot via buffer_index wrapping with the modulo operator.
  • xQueueReceive() blocks this task until a new reading arrives from the sensor queue. The task uses zero CPU while waiting.
  • The task calls your apply_moving_average() and fringe_to_nanometers() functions. Separating the math from the RTOS plumbing is what makes those functions unit-testable.
  • Save src/processing_task.c.

Still no visible output. The final piece is the output task and the main function that wires everything together.

Write the data output task and main

The output task is the end of the pipeline. It receives processed measurements and prints them as a formatted table. The main function creates the queues and tasks, then hands control to the FreeRTOS scheduler.

  • Create a new file called output_task.c inside your src/ folder.
  • Paste the following code into src/output_task.c:
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"
#include <stdio.h>

extern QueueHandle_t xOutputQueue;

void vDataOutputTask(void *pvParameters)
{
    ProcessedMeasurement measurement;
    uint32_t sample_count = 0;

    (void)pvParameters;

    printf("\n=== Laser Interferometer Measurement System ===\n");
    printf("%-10s %-15s %-15s %-12s\n",
        "Sample", "Displacement", "Filtered", "Time(ms)");
    printf("------------------------------------------------------\n");

    for (;;) {
        if (xQueueReceive(xOutputQueue, &measurement, portMAX_DELAY) == pdPASS) {
            sample_count++;

            printf("%-10u %-12.3f nm %-15.4f %-12u\n",
                (unsigned int)sample_count,
                measurement.displacement_nm,
                measurement.filtered_value,
                (unsigned int)measurement.timestamp_ms);

            /* Print summary every 20 samples */
            if (sample_count % 20 == 0) {
                printf("--- %u samples processed ---\n",
                    (unsigned int)sample_count);
            }
        }
    }
}

What does this code do?

  • The task prints a table header once when it starts, then loops forever waiting for processed measurements.
  • Each measurement prints as a row showing the sample number, displacement in nanometers, filtered fringe value, and timestamp.
  • Every 20 samples, a summary line prints to confirm the system is still running. In a real system, this is where you would log to a file or send data over a network.
  • Save src/output_task.c.

Now replace the placeholder main.c with the real entry point that creates queues, spawns all three tasks, and starts the scheduler.

  • Open src/main.c in VS Code.
  • Delete all existing content (the placeholder) and replace it with the following:
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

/* Queue handles - shared between tasks via extern */
QueueHandle_t xSensorQueue = NULL;
QueueHandle_t xOutputQueue = NULL;

/* Task priorities - higher number = higher priority */
#define SENSOR_TASK_PRIORITY     ( tskIDLE_PRIORITY + 3 )
#define PROCESSING_TASK_PRIORITY ( tskIDLE_PRIORITY + 2 )
#define OUTPUT_TASK_PRIORITY     ( tskIDLE_PRIORITY + 1 )

/* Stack sizes (in words, not bytes) */
#define TASK_STACK_SIZE          ( configMINIMAL_STACK_SIZE * 4 )

int main(void)
{
    /* Seed random number generator for noise simulation */
    srand((unsigned int)time(NULL));

    printf("Initializing Laser Measurement System...\n");

    /* Create queues - each holds up to 10 messages */
    xSensorQueue = xQueueCreate(10, sizeof(SensorReading));
    xOutputQueue = xQueueCreate(10, sizeof(ProcessedMeasurement));

    if (xSensorQueue == NULL || xOutputQueue == NULL) {
        printf("ERROR: Failed to create queues.\n");
        return 1;
    }

    /* Create tasks */
    xTaskCreate(vSensorAcquisitionTask, "Sensor",
        TASK_STACK_SIZE, NULL, SENSOR_TASK_PRIORITY, NULL);

    xTaskCreate(vSignalProcessingTask, "Process",
        TASK_STACK_SIZE, NULL, PROCESSING_TASK_PRIORITY, NULL);

    xTaskCreate(vDataOutputTask, "Output",
        TASK_STACK_SIZE, NULL, OUTPUT_TASK_PRIORITY, NULL);

    printf("Starting FreeRTOS scheduler...\n");

    /* Start the scheduler - this never returns */
    vTaskStartScheduler();

    /* Should never reach here */
    printf("ERROR: Scheduler exited unexpectedly.\n");
    return 1;
}

What does this code do?

This is the entry point that orchestrates the entire system.

  • xQueueCreate(10, sizeof(SensorReading)) allocates a queue that can hold 10 sensor readings. If the processing task falls behind, up to 10 readings buffer before the sensor task blocks.
  • Task priorities determine which task runs when multiple are ready. The sensor task has the highest priority (3) so it never misses a sample deadline.
  • vTaskStartScheduler() hands control to FreeRTOS. From this point on, the scheduler decides which task runs based on priority and blocking state. The code after this line only executes if the scheduler fails.

Why does the sensor task get the highest priority?

In real measurement systems, missing a sensor sample means losing data permanently. A higher priority ensures the sensor task always preempts lower-priority work to meet its timing deadline.

The processing and output tasks have lower priorities because they can tolerate brief delays. The queue buffers absorb any timing jitter between tasks.

  • Save src/main.c.

Your project now has all four source files that make up the FreeRTOS application. You will build and run it in the next step.

Seeing red squiggles in VS Code?

The C/C++ extension may show errors for FreeRTOS headers because it does not know the include paths yet. These resolve once you run CMake in the next step. The important thing is that your file contents match the code above.

If you see errors in your own measurement_system.h include, make sure the file exists in the include/ folder and the filename matches exactly.

Help me fix include path errors in my FreeRTOS project.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: src/sensor_task.c, src/processing_task.c, src/output_task.c, and src/main.c.

ⓧ I'd like to double check the full code

Here is the full content of each file. Compare yours against these.

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

extern QueueHandle_t xSensorQueue;

void vSensorAcquisitionTask(void *pvParameters)
{
    SensorReading reading;
    TickType_t xLastWakeTime = xTaskGetTickCount();
    uint32_t sample_number = 0;

    /* Cast unused parameter to void to avoid compiler warning */
    (void)pvParameters;

    for (;;) {
        /* Generate a simulated sensor reading */
        reading.raw_fringe_count = generate_simulated_sensor(
            sample_number, NOISE_AMPLITUDE);
        reading.timestamp_ms = (uint32_t)xTaskGetTickCount();

        /* Send reading to the processing task via queue */
        xQueueSend(xSensorQueue, &reading, portMAX_DELAY);

        sample_number++;

        /* Wait until next sample period (50ms = 20 Hz) */
        vTaskDelayUntil(&xLastWakeTime,
            pdMS_TO_TICKS(SENSOR_SAMPLE_RATE_MS));
    }
}
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"

extern QueueHandle_t xSensorQueue;
extern QueueHandle_t xOutputQueue;

void vSignalProcessingTask(void *pvParameters)
{
    SensorReading reading;
    ProcessedMeasurement result;
    double filter_buffer[FILTER_WINDOW_SIZE] = {0};
    int buffer_index = 0;

    (void)pvParameters;

    for (;;) {
        /* Wait for a reading from the sensor task */
        if (xQueueReceive(xSensorQueue, &reading, portMAX_DELAY) == pdPASS) {

            /* Store new reading in circular buffer */
            filter_buffer[buffer_index] = reading.raw_fringe_count;
            buffer_index = (buffer_index + 1) % FILTER_WINDOW_SIZE;

            /* Apply moving average to smooth out noise */
            double filtered = apply_moving_average(
                filter_buffer, FILTER_WINDOW_SIZE);

            /* Convert filtered fringe count to nanometers */
            result.displacement_nm = fringe_to_nanometers(
                filtered, LASER_WAVELENGTH_NM);
            result.filtered_value = filtered;
            result.timestamp_ms = reading.timestamp_ms;

            /* Send processed result to output task */
            xQueueSend(xOutputQueue, &result, portMAX_DELAY);
        }
    }
}
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"
#include <stdio.h>

extern QueueHandle_t xOutputQueue;

void vDataOutputTask(void *pvParameters)
{
    ProcessedMeasurement measurement;
    uint32_t sample_count = 0;

    (void)pvParameters;

    printf("\n=== Laser Interferometer Measurement System ===\n");
    printf("%-10s %-15s %-15s %-12s\n",
        "Sample", "Displacement", "Filtered", "Time(ms)");
    printf("------------------------------------------------------\n");

    for (;;) {
        if (xQueueReceive(xOutputQueue, &measurement, portMAX_DELAY) == pdPASS) {
            sample_count++;

            printf("%-10u %-12.3f nm %-15.4f %-12u\n",
                (unsigned int)sample_count,
                measurement.displacement_nm,
                measurement.filtered_value,
                (unsigned int)measurement.timestamp_ms);

            /* Print summary every 20 samples */
            if (sample_count % 20 == 0) {
                printf("--- %u samples processed ---\n",
                    (unsigned int)sample_count);
            }
        }
    }
}
#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "measurement_system.h"
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

/* Queue handles - shared between tasks via extern */
QueueHandle_t xSensorQueue = NULL;
QueueHandle_t xOutputQueue = NULL;

/* Task priorities - higher number = higher priority */
#define SENSOR_TASK_PRIORITY     ( tskIDLE_PRIORITY + 3 )
#define PROCESSING_TASK_PRIORITY ( tskIDLE_PRIORITY + 2 )
#define OUTPUT_TASK_PRIORITY     ( tskIDLE_PRIORITY + 1 )

/* Stack sizes (in words, not bytes) */
#define TASK_STACK_SIZE          ( configMINIMAL_STACK_SIZE * 4 )

int main(void)
{
    /* Seed random number generator for noise simulation */
    srand((unsigned int)time(NULL));

    printf("Initializing Laser Measurement System...\n");

    /* Create queues - each holds up to 10 messages */
    xSensorQueue = xQueueCreate(10, sizeof(SensorReading));
    xOutputQueue = xQueueCreate(10, sizeof(ProcessedMeasurement));

    if (xSensorQueue == NULL || xOutputQueue == NULL) {
        printf("ERROR: Failed to create queues.\n");
        return 1;
    }

    /* Create tasks */
    xTaskCreate(vSensorAcquisitionTask, "Sensor",
        TASK_STACK_SIZE, NULL, SENSOR_TASK_PRIORITY, NULL);

    xTaskCreate(vSignalProcessingTask, "Process",
        TASK_STACK_SIZE, NULL, PROCESSING_TASK_PRIORITY, NULL);

    xTaskCreate(vDataOutputTask, "Output",
        TASK_STACK_SIZE, NULL, OUTPUT_TASK_PRIORITY, NULL);

    printf("Starting FreeRTOS scheduler...\n");

    /* Start the scheduler - this never returns */
    vTaskStartScheduler();

    /* Should never reach here */
    printf("ERROR: Scheduler exited unexpectedly.\n");
    return 1;
}

Your multi-task architecture is complete. Three tasks are wired together through queues, ready to simulate a real laser measurement pipeline. Next up, you will build the project with CMake and watch the system run.

Build and Run the Measurement System

You now have a complete multi-task FreeRTOS application spread across multiple source files. The sensor task generates data, the processing task filters it, and the output task displays results.

The code is written, but none of it has been compiled together yet. CMake needs to pull in the FreeRTOS kernel sources, your application files, and the Windows port into a single executable. In this step, you will build, run, and tune your measurement system.

In this step, get ready to:
  • Build the full project with CMake and MinGW.
  • Run the simulator and verify data flows through all three tasks.
  • Experiment with measurement parameters to observe their effect on precision.
Build the project with CMake

CMake uses a two-stage process. First it configures the build (generates Makefiles), then it compiles everything. You need to explicitly tell CMake to use the MinGW generator and GCC compiler to avoid conflicts with any other compilers on your system.

  • Open a Command Prompt and navigate to your project root folder by running:
cd %USERPROFILE%\Desktop\laser_measurement_system
  • Configure the CMake build by running this command:
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++

What does this command do?

This command tells CMake where to look and what to build with:

  • -S . uses the current directory as the source.
  • -B build puts all generated files into a build/ subdirectory.
  • -G "MinGW Makefiles" tells CMake to generate Makefiles for MinGW's make tool.
  • The -DCMAKE_C_COMPILER=gcc and -DCMAKE_CXX_COMPILER=g++ flags ensure CMake uses GCC instead of any other compiler it might detect.
  • You should see output ending with Configuring done and Generating done. CMake has created the Makefiles in the build/ directory.
  • Now compile the project by running:
cmake --build build
  • Wait for the build to complete. You should see a series of compilation lines followed by a successful linking step.
  • Confirm the executable was created by running:
dir build\laser_measurement.exe
  • You should see the file listed with its size. This is your compiled measurement system.

Seeing compilation errors?

If CMake cannot find GCC, make sure C:\msys64\ucrt64\bin is in your Windows PATH. Open a new Command Prompt after adding it.

If you see errors about missing FreeRTOS headers, verify the FreeRTOS-Kernel folder exists in your project root and contains an include directory with FreeRTOS.h.

If you get undefined reference errors during linking, double-check that all .c files listed in CMakeLists.txt match the filenames in your src/ folder exactly.

Help me debug my CMake build errors for this FreeRTOS project

Run the simulator and verify output

The executable launches the FreeRTOS scheduler, which starts all three tasks. The sensor task generates data at 20 Hz (every 50ms), the processing task filters and converts each reading, and the output task prints the results.

  • Run the measurement system by executing:
.\build\laser_measurement.exe
  • You should see the initialization messages followed by a table of measurement data scrolling in your terminal.

What should I see?

The output starts with an initialization message, then prints a table header with columns for Sample, Displacement, Filtered, and Time(ms). Each row represents one processed measurement.

The Displacement column shows nanometer values that increase steadily over time. This makes sense because the simulated stage is moving at a constant speed. The Filtered column shows the smoothed fringe count before conversion.

Every 20 samples, a summary line appears. New rows print roughly every 50ms (the sensor sample rate).

  • Let it run for at least 40-60 samples so you can see the displacement values climbing.
  • Press Ctrl+C to stop the program.

Why do the displacement values keep increasing?

The sensor simulation models a stage moving at constant speed in one direction. Each sample represents a slightly higher fringe count, which converts to a larger displacement. In a real semiconductor tool, this is exactly what you would see during a wafer stage move.

Program exits immediately or shows no output?

If you see "ERROR: Failed to create queues" the heap size may be too small. Check that configTOTAL_HEAP_SIZE is set to ( ( size_t ) ( 65 * 1024 ) ) in FreeRTOSConfig.h.

If the program prints the header but no data rows, one of the tasks may have failed to start. Verify that all three task source files are listed in CMakeLists.txt and that each file compiles without warnings.

Help me debug why my FreeRTOS measurement system is not producing output

Experiment with parameters

Real measurement systems are tuned by adjusting parameters like sample rate, filter window size, and acceptable noise levels. Your system uses constants defined in measurement_system.h that control these behaviors.

Changing these values and rebuilding lets you observe how each parameter affects measurement quality. This is the same tuning process engineers perform on real interferometer systems.

  • In VS Code, open include/measurement_system.h.
  • Find this line near the top of the file:
#define NOISE_AMPLITUDE            0.05
  • Change 0.05 to 0.5 to increase the measurement noise by 10x:
#define NOISE_AMPLITUDE            0.5
  • Save measurement_system.h.
  • Rebuild and run by executing these commands:
cmake --build build
.\build\laser_measurement.exe
  • Observe how the displacement values now jump around more between samples. The moving average filter smooths some of this, but with only 5 samples in the window, large noise still shows through.
  • Press Ctrl+C to stop.

Now try increasing the filter window to compensate for the noise.

  • In measurement_system.h, find this line:
#define FILTER_WINDOW_SIZE         5
  • Change 5 to 15 to average over more samples:
#define FILTER_WINDOW_SIZE         15
  • Save, rebuild, and run again.
  • Notice how the displacement values are smoother now, even with the higher noise. The tradeoff is that the filter introduces a slight lag in tracking actual stage position.
  • Press Ctrl+C to stop.

Why does a larger filter window smooth the data?

The moving average averages the last N readings. With N=5, one noisy sample contributes 20% of the output. With N=15, that same noisy sample only contributes ~6.7%. More samples means random noise cancels out more effectively.

In real interferometer systems, this is a fundamental tradeoff. A wider filter gives better precision but slower response to real position changes. Engineers choose the filter size based on how fast the stage moves and how precise the measurement needs to be.

  • Try one more experiment. Change SENSOR_SAMPLE_RATE_MS from 50 to 100 to slow the measurement rate to 10 Hz:
#define SENSOR_SAMPLE_RATE_MS      100
  • Save, rebuild, and run. Notice the measurements print half as frequently.
  • Press Ctrl+C to stop when you have seen enough.
  • Revert all your changes back to the original values before moving on. Set NOISE_AMPLITUDE back to 0.05, FILTER_WINDOW_SIZE back to 5, and SENSOR_SAMPLE_RATE_MS back to 50.
  • Save measurement_system.h.

✔️ Awesome, I've got everything!

Make sure you have saved measurement_system.h with the original values restored.

ⓧ I'd like to double check the full code

Here is the original measurement_system.h with all values restored:

#ifndef MEASUREMENT_SYSTEM_H
#define MEASUREMENT_SYSTEM_H

#include <stdint.h>

/* Configuration constants */
#define SENSOR_SAMPLE_RATE_MS      50
#define FILTER_WINDOW_SIZE         5
#define LASER_WAVELENGTH_NM        632.8
#define NOISE_AMPLITUDE            0.05
#define STAGE_SPEED                0.1

/* Data structures */
typedef struct {
    double raw_fringe_count;
    uint32_t timestamp_ms;
} SensorReading;

typedef struct {
    double displacement_nm;
    double filtered_value;
    uint32_t timestamp_ms;
} ProcessedMeasurement;

/* Signal processing functions (testable without RTOS) */
double apply_moving_average(const double *buffer, int size);
double fringe_to_nanometers(double fringe_count, double wavelength_nm);
double generate_simulated_sensor(uint32_t sample_number, double noise_amplitude);

/* Task function declarations */
void vSensorAcquisitionTask(void *pvParameters);
void vSignalProcessingTask(void *pvParameters);
void vDataOutputTask(void *pvParameters);

#endif /* MEASUREMENT_SYSTEM_H */

Build fails after changing parameters?

If you get a compilation error after changing FILTER_WINDOW_SIZE, make sure the value is a plain integer with no trailing characters or spaces. The macro is used as an array size in processing_task.c, so it must be a valid constant expression.

Help me fix build errors after changing measurement parameters

Your measurement system is running and you understand how its parameters affect precision. Next up, you will write automated unit tests to verify your signal processing logic works correctly without running the full RTOS application.

Add Unit Tests with Google Test

Your measurement system runs and produces output, but how do you know the math is correct? In professional embedded development, you never trust signal processing code without automated tests that verify every function against known inputs and expected outputs.

This is where the architecture pays off. Because you wrote your signal processing functions as pure C functions with no FreeRTOS dependency, you can test them independently on the host machine using Google Test. This is the standard embedded testing pattern: test logic on the host, test integration on the target.

In this step, get ready to:
  • Write unit tests for the moving average filter function.
  • Write unit tests for fringe conversion and sensor simulation.
  • Build and run all tests with CTest to verify they pass.
Write unit tests for the moving average function

The moving average filter is the core of your signal processing pipeline. If it returns wrong values, every measurement downstream will be incorrect. You need tests that cover normal operation, edge cases, and invalid inputs.

Why test C code with a C++ framework?

Google Test is a C++ framework, but your signal processing code is plain C. The extern "C" block in the test file tells the C++ compiler to use C-style linking when calling your functions. This is the standard approach for testing embedded C code on a host machine.

  • Create a new file called test_signal_processing.cpp inside the tests/ folder of your laser_measurement_system project.
  • Add the following test code to the file:
#include <gtest/gtest.h>

extern "C" {
#include "measurement_system.h"
}

/* Tests for apply_moving_average */

TEST(MovingAverageTest, ReturnsAverageOfValues) {
    double buffer[] = {2.0, 4.0, 6.0, 8.0, 10.0};
    double result = apply_moving_average(buffer, 5);
    EXPECT_DOUBLE_EQ(result, 6.0);
}

TEST(MovingAverageTest, SingleValue) {
    double buffer[] = {7.5};
    double result = apply_moving_average(buffer, 1);
    EXPECT_DOUBLE_EQ(result, 7.5);
}

TEST(MovingAverageTest, HandlesNullBuffer) {
    double result = apply_moving_average(NULL, 5);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(MovingAverageTest, HandlesZeroSize) {
    double buffer[] = {1.0, 2.0};
    double result = apply_moving_average(buffer, 0);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(MovingAverageTest, AllSameValues) {
    double buffer[] = {3.0, 3.0, 3.0, 3.0, 3.0};
    double result = apply_moving_average(buffer, 5);
    EXPECT_DOUBLE_EQ(result, 3.0);
}

/* Tests for fringe_to_nanometers */

TEST(FringeConversionTest, ZeroFringes) {
    double result = fringe_to_nanometers(0.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(FringeConversionTest, OneFringe) {
    /* 1 fringe * (632.8 / 2) = 316.4 nm */
    double result = fringe_to_nanometers(1.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 316.4);
}

TEST(FringeConversionTest, TenFringes) {
    /* 10 * (632.8 / 2) = 3164.0 nm */
    double result = fringe_to_nanometers(10.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 3164.0);
}

TEST(FringeConversionTest, FractionalFringes) {
    /* 0.5 * (632.8 / 2) = 158.2 nm */
    double result = fringe_to_nanometers(0.5, 632.8);
    EXPECT_DOUBLE_EQ(result, 158.2);
}

What does this code do?

Each TEST() macro defines an independent test case. The first argument is the test suite name (grouping related tests), and the second is the specific test name.

EXPECT_DOUBLE_EQ checks that two doubles are exactly equal. The MovingAverageTest suite covers normal inputs, a single value, NULL buffer, zero size, and identical values. The FringeConversionTest suite verifies the displacement formula (fringe_count * wavelength/2) against hand-calculated results.

  • Save tests/test_signal_processing.cpp.
Write unit tests for the sensor simulation

The sensor simulation uses random noise, which makes testing trickier. You need to verify deterministic behavior (zero noise gives exact values) and statistical bounds (noise stays within the amplitude you specified).

  • Create a new file called test_sensor_simulation.cpp inside the tests/ folder.
  • Add the following test code:
#include <gtest/gtest.h>
#include <cstdlib>
#include <cmath>

extern "C" {
#include "measurement_system.h"
}

TEST(SensorSimulationTest, FirstSampleNearZero) {
    /* At sample 0, base signal = 0 * 0.1 = 0.0
       With small noise, result should be near zero */
    srand(42);
    double value = generate_simulated_sensor(0, 0.05);
    EXPECT_NEAR(value, 0.0, 0.1);
}

TEST(SensorSimulationTest, ValueIncreasesOverTime) {
    /* Base signal increases linearly, so later samples
       should have higher values */
    srand(42);
    double early = generate_simulated_sensor(10, 0.0);
    double later = generate_simulated_sensor(100, 0.0);
    EXPECT_GT(later, early);
}

TEST(SensorSimulationTest, ZeroNoiseGivesExactValue) {
    /* With zero noise, output = sample_number * STAGE_SPEED */
    double value = generate_simulated_sensor(20, 0.0);
    double expected = 20.0 * STAGE_SPEED;
    EXPECT_DOUBLE_EQ(value, expected);
}

TEST(SensorSimulationTest, NoiseStaysWithinBounds) {
    /* Noise should be within +/- noise_amplitude of base signal */
    srand(123);
    double noise_amp = 0.05;
    for (uint32_t i = 0; i < 100; i++) {
        double value = generate_simulated_sensor(i, noise_amp);
        double base = (double)i * STAGE_SPEED;
        EXPECT_GE(value, base - noise_amp);
        EXPECT_LE(value, base + noise_amp);
    }
}

What does this code do?

These tests use different strategies for testing code that involves randomness:

  • srand(42) seeds the random number generator with a known value so results are deterministic across runs.
  • EXPECT_NEAR allows a small tolerance (0.1) because noise means the exact value is unpredictable.
  • ZeroNoiseGivesExactValue eliminates randomness entirely to verify the base formula works.
  • NoiseStaysWithinBounds runs 100 iterations to verify noise never exceeds the amplitude you set.
  • Save tests/test_sensor_simulation.cpp.

Why is this testing pattern important for embedded work?

In production embedded systems, you cannot easily attach a debugger to a running device. Automated tests that run on your development machine catch math errors, boundary violations, and logic bugs before code ever reaches hardware. This is called host-based testing.

Build and run all tests with CTest

Your CMakeLists.txt already has the Google Test configuration and the test executable defined. CMake will download Google Test automatically the first time you build with the test files present.

  • Reconfigure and rebuild the project by running these commands in your terminal from the laser_measurement_system folder:
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
cmake --build build

What does this command do?

The first command reconfigures CMake, which now detects the new test files and downloads Google Test v1.15.2 via FetchContent. The second command builds both the main application and the measurement_tests executable. The first build with Google Test takes longer because it downloads and compiles the framework.

  • Once the build succeeds, run the tests using CTest:
cd build && ctest --output-on-failure

What does this command do?

CTest is CMake's built-in test runner. The --output-on-failure flag only prints detailed output if a test fails, keeping the output clean when everything passes.

You should see output showing all 9 tests passing. The output will list each test suite and confirm a 100% pass rate.

Tests failing or build errors?

  • If you see "undefined reference" errors, make sure your test files include the extern "C" block around the #include "measurement_system.h" line. Without it, the C++ linker cannot find C functions.
  • If CMake fails to download Google Test, check your internet connection. FetchContent needs to reach GitHub to download the framework.
  • If a specific test fails, read the failure message carefully. It shows the expected value vs. the actual value, which helps you identify typos in your test code.

Help me debug my Google Test build errors.

✔️ Awesome, I've got everything!

Great. Double check you have saved both test files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for both test files:

#include <gtest/gtest.h>

extern "C" {
#include "measurement_system.h"
}

/* Tests for apply_moving_average */

TEST(MovingAverageTest, ReturnsAverageOfValues) {
    double buffer[] = {2.0, 4.0, 6.0, 8.0, 10.0};
    double result = apply_moving_average(buffer, 5);
    EXPECT_DOUBLE_EQ(result, 6.0);
}

TEST(MovingAverageTest, SingleValue) {
    double buffer[] = {7.5};
    double result = apply_moving_average(buffer, 1);
    EXPECT_DOUBLE_EQ(result, 7.5);
}

TEST(MovingAverageTest, HandlesNullBuffer) {
    double result = apply_moving_average(NULL, 5);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(MovingAverageTest, HandlesZeroSize) {
    double buffer[] = {1.0, 2.0};
    double result = apply_moving_average(buffer, 0);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(MovingAverageTest, AllSameValues) {
    double buffer[] = {3.0, 3.0, 3.0, 3.0, 3.0};
    double result = apply_moving_average(buffer, 5);
    EXPECT_DOUBLE_EQ(result, 3.0);
}

/* Tests for fringe_to_nanometers */

TEST(FringeConversionTest, ZeroFringes) {
    double result = fringe_to_nanometers(0.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 0.0);
}

TEST(FringeConversionTest, OneFringe) {
    /* 1 fringe * (632.8 / 2) = 316.4 nm */
    double result = fringe_to_nanometers(1.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 316.4);
}

TEST(FringeConversionTest, TenFringes) {
    /* 10 * (632.8 / 2) = 3164.0 nm */
    double result = fringe_to_nanometers(10.0, 632.8);
    EXPECT_DOUBLE_EQ(result, 3164.0);
}

TEST(FringeConversionTest, FractionalFringes) {
    /* 0.5 * (632.8 / 2) = 158.2 nm */
    double result = fringe_to_nanometers(0.5, 632.8);
    EXPECT_DOUBLE_EQ(result, 158.2);
}
#include <gtest/gtest.h>
#include <cstdlib>
#include <cmath>

extern "C" {
#include "measurement_system.h"
}

TEST(SensorSimulationTest, FirstSampleNearZero) {
    /* At sample 0, base signal = 0 * 0.1 = 0.0
       With small noise, result should be near zero */
    srand(42);
    double value = generate_simulated_sensor(0, 0.05);
    EXPECT_NEAR(value, 0.0, 0.1);
}

TEST(SensorSimulationTest, ValueIncreasesOverTime) {
    /* Base signal increases linearly, so later samples
       should have higher values */
    srand(42);
    double early = generate_simulated_sensor(10, 0.0);
    double later = generate_simulated_sensor(100, 0.0);
    EXPECT_GT(later, early);
}

TEST(SensorSimulationTest, ZeroNoiseGivesExactValue) {
    /* With zero noise, output = sample_number * STAGE_SPEED */
    double value = generate_simulated_sensor(20, 0.0);
    double expected = 20.0 * STAGE_SPEED;
    EXPECT_DOUBLE_EQ(value, expected);
}

TEST(SensorSimulationTest, NoiseStaysWithinBounds) {
    /* Noise should be within +/- noise_amplitude of base signal */
    srand(123);
    double noise_amp = 0.05;
    for (uint32_t i = 0; i < 100; i++) {
        double value = generate_simulated_sensor(i, noise_amp);
        double base = (double)i * STAGE_SPEED;
        EXPECT_GE(value, base - noise_amp);
        EXPECT_LE(value, base + noise_amp);
    }
}

You now have automated tests proving your signal processing math is correct. Next up, you will set up a Jenkins CI/CD pipeline that runs these tests automatically every time you commit code.

Set Up Jenkins CI/CD Pipeline

Your measurement system builds, runs, and passes all tests. In a professional embedded environment, you would never rely on a developer remembering to run tests manually before committing code.

A CI/CD pipeline automates the build-test-analyze cycle so that every commit is validated automatically. In this step, you will set up Jenkins to run your CMake build, execute your Google Test suite, and perform static analysis on every push.

In this step, get ready to:
  • Download and launch Jenkins locally.
  • Create a Jenkinsfile that defines your build, test, and static analysis pipeline.
  • Run the pipeline and verify all stages pass in the Jenkins dashboard.
Download and launch Jenkins locally

Jenkins is a CI/CD server that watches your Git repository and runs your pipeline automatically. You will run Jenkins locally using a single WAR file, which requires no installer or background service.

The Static Analysis stage in your pipeline will use cppcheck to scan your C source for common issues. You need to install it first.

  • Open the MSYS2 UCRT64 terminal.
  • Install cppcheck by running this command:
pacman -S mingw-w64-ucrt-x86_64-cppcheck
  • When prompted to proceed with installation, type Y and press Enter.
  • Verify cppcheck is accessible by running:
cppcheck --version

You should see a version number printed in the terminal.

cppcheck not found?

Make sure you ran the install command in the MSYS2 UCRT64 terminal (the one with the orange icon), not the regular MSYS2 terminal. The cppcheck binary installs to C:\msys64\ucrt64\bin, which should already be on your Windows PATH from Step 1.

Help me troubleshoot cppcheck installation in MSYS2

Now download and start Jenkins. Jenkins runs as a Java application from a single WAR file.

  • Open a regular Command Prompt (not MSYS2).
  • Navigate to your project folder by running:
cd %USERPROFILE%\Desktop\laser_measurement_system
  • Download the Jenkins WAR file by opening this URL in your browser:

https://get.jenkins.io/war-stable/latest/jenkins.war

  • Save the jenkins.war file to your laser_measurement_system folder (about 92 MB download).
  • Start Jenkins by running this command in your Command Prompt:
java -jar jenkins.war

What does this command do?

This starts Jenkins as a local web server on port 8080. Jenkins will print startup logs to your terminal. Keep this terminal window open for the rest of the step.

Wait for the startup to complete. You will see a line containing a long alphanumeric password. This is your initial admin password.

  • Copy the password displayed in the terminal output.
  • Open your browser and navigate to http://localhost:8080.
  • Paste the admin password into the Administrator password field and click Continue.
  • Click Install suggested plugins on the Customize Jenkins page.
  • Wait for all plugins to finish installing. This may take a few minutes.
  • On the Create First Admin User page, fill in your details and click Save and Continue.
  • On the Instance Configuration page, leave the default URL (http://localhost:8080/) and click Save and Finish.
  • Click Start using Jenkins.

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

Port 8080 already in use?

If another service is using port 8080, start Jenkins on a different port by running:

java -jar jenkins.war --httpPort=9090

Then navigate to http://localhost:9090 instead.

Help me troubleshoot Jenkins startup issues

You also need to install the Warnings Next Generation plugin for static analysis reporting.

  • In the Jenkins dashboard, click Manage Jenkins in the left sidebar.
  • Click Plugins.
  • Click the Available plugins tab.
  • Search for Warnings Next Generation in the search box.
  • Check the box next to Warnings Next Generation and click Install.
  • Wait for the plugin to install, then click Go back to the top page.
Create a Jenkinsfile

A Jenkinsfile defines your pipeline as code. It lives in your project repository and tells Jenkins exactly which stages to run.

  • In VS Code, create a new file called Jenkinsfile in the root of your laser_measurement_system folder (no file extension).
  • Add the following pipeline definition:
pipeline {
    agent any

    stages {
        stage('Build') {
            steps {
                // Configure CMake with MinGW Makefiles generator
                bat 'cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++'
                // Compile the full project
                bat 'cmake --build build'
            }
        }

        stage('Test') {
            steps {
                // Run CTest and output results in JUnit XML format
                bat 'cd build && ctest --output-on-failure --output-junit test-results.xml'
            }
            post {
                always {
                    // Publish test results to Jenkins dashboard
                    junit 'build/test-results.xml'
                }
            }
        }

        stage('Static Analysis') {
            steps {
                // Run cppcheck and output results as XML
                bat 'cppcheck --enable=all --xml --xml-version=2 src/ 2> build/cppcheck-report.xml'
            }
            post {
                always {
                    // Publish static analysis results
                    recordIssues tools: [cppCheck(pattern: 'build/cppcheck-report.xml')]
                }
            }
        }
    }

    post {
        success {
            echo 'Build and tests passed successfully!'
        }
        failure {
            echo 'Build or tests failed. Check the logs for details.'
        }
    }
}

What does this pipeline do?

  • Build stage: Configures CMake with the MinGW Makefiles generator and compiles the entire project (application + tests).
  • Test stage: Runs CTest, which executes your Google Test suite. Results are exported as JUnit XML so Jenkins can display them in its dashboard.
  • Static Analysis stage: Runs cppcheck to detect bugs, undefined behavior, and style issues in your C source files without executing the code.
  • The bat commands run Windows batch commands. The post blocks publish results regardless of whether the stage passed or failed.
  • Save Jenkinsfile.

Now create a README that documents your system architecture. This is how professional embedded projects communicate their design to other engineers.

  • Create a new file called README.md in the root of your laser_measurement_system folder.
  • Add the following documentation:
# Laser Interferometer Measurement System (Simulated)

A multi-task FreeRTOS application simulating a precision laser measurement system
for semiconductor manufacturing. Built as a demonstration of embedded C firmware
architecture, RTOS patterns, and CI/CD automation.

## Architecture

The system uses three FreeRTOS tasks communicating via message queues:

```
[Sensor Task] --Queue--> [Processing Task] --Queue--> [Output Task]
   (50ms)                   (filter + convert)          (display)
```

- **Sensor Acquisition Task** (Priority 3): Simulates a moving stage generating
  fringe count data at 20 Hz with measurement noise.
- **Signal Processing Task** (Priority 2): Applies a moving average filter and
  converts fringe counts to nanometer displacement.
- **Data Output Task** (Priority 1): Formats and prints calibrated measurements.

## Key Formula

Displacement = fringe_count * (laser_wavelength / 2)

Using a HeNe laser at 632.8 nm: each fringe = 316.4 nm of motion.

## Building

Requires: MinGW-w64 (GCC), CMake 3.14+

```bash
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
cmake --build build
```

## Running

```bash
./build/laser_measurement.exe
```

## Testing

```bash
cd build
ctest --output-on-failure
```

## CI/CD

The project includes a Jenkinsfile for automated build, test, and static analysis.

## Key Concepts Demonstrated

- C language fundamentals (structs, pointers, header files)
- FreeRTOS task creation and scheduling
- Inter-task communication with queues
- Signal filtering (moving average)
- Interferometric displacement measurement (lambda/2 per fringe)
- CMake build system with external dependencies
- Google Test unit testing for embedded logic
- Jenkins CI/CD pipeline automation

Why write a README?

In embedded development teams, documentation is how you communicate system architecture to other engineers. A well-structured README demonstrates that you think about maintainability and collaboration, not just code that compiles.

  • Save README.md.

Jenkins needs a Git repository to pull your code from. You will initialize a local Git repo and commit everything.

  • Open a new Command Prompt (keep the Jenkins terminal running in the other window).
  • Navigate to your project folder and initialize a Git repository by running these commands:
cd %USERPROFILE%\Desktop\laser_measurement_system
git init
git add .
git commit -m "Initial commit: laser measurement system with FreeRTOS"

What about the FreeRTOS-Kernel and build folders?

The FreeRTOS-Kernel directory and the build directory will be included in the commit. For a production project you would add them to a .gitignore file, but for this local Jenkins setup it is simpler to include everything so the pipeline can find all sources.

You should see a message confirming the commit with a list of files added.

Git commit failed?

If Git asks you to configure your identity, run these two commands first (replacing the placeholder values with your name and email):

git config user.name "Your Name"
git config user.email "your@email.com"

Then retry the git commit command.

Help me fix my git commit error

Create and run the Jenkins pipeline job

With your code committed and Jenkins running, you can create a pipeline job that points at your local repository. Jenkins will read the Jenkinsfile and execute each stage.

  • In your browser, go to the Jenkins dashboard at http://localhost:8080.
  • Click New Item in the left sidebar.
  • Enter laser-measurement-pipeline as the item name.
  • Select Pipeline from the list of project types.
  • Click OK.

You are now on the pipeline configuration page. Scroll down to the Pipeline section at the bottom.

  • In the Definition dropdown, select Pipeline script from SCM.
  • In the SCM dropdown, select Git.
  • In the Repository URL field, enter the full path to your local Git repository:
[[PROJECT_PATH="C:\Users\YourUsername\Desktop\laser_measurement_system"]]
  • In the Branch Specifier field, change */master to */main if your default branch is named main. You can check by running git branch in your terminal.
  • Leave the Script Path as Jenkinsfile (the default).
  • Click Save at the bottom of the page.

Now trigger the pipeline to run.

  • Click Build Now in the left sidebar.
  • Watch the Build History section in the bottom left. A new build (#1) will appear with a progress bar.
  • Wait for the build to complete. The first run may take several minutes because CMake downloads Google Test via FetchContent.
  • Once the build finishes, click on build #1 in the Build History.

You should see a pipeline visualization showing three green boxes: Build, Test, and Static Analysis. All three stages passed.

  • Click Test Result in the left sidebar to view your Google Test results displayed in the Jenkins dashboard.

You should see all 13 tests (9 from signal processing + 4 from sensor simulation) listed as passed.

Build stage failed?

Click on the failed build number, then click Console Output to see the full error log. Common causes:

  • "cmake is not recognized": The MinGW bin directory (C:\msys64\ucrt64\bin) is not in the system PATH. Jenkins inherits the system PATH, not your user PATH. Add it to the system environment variables and restart Jenkins.
  • "gcc is not recognized": Same PATH issue as above.
  • "Repository not found": Double-check the repository URL matches the exact path to your project folder on disk.

Help me debug my Jenkins pipeline failure

Why CI/CD for embedded development?

In semiconductor manufacturing, firmware bugs can be extremely expensive. A CI pipeline catches regressions immediately instead of letting them reach hardware. Static analysis with cppcheck finds undefined behavior, memory leaks, and style issues that a compiler won't catch.

This same pipeline pattern scales to real hardware. Production embedded CI systems add cross-compilation stages and hardware-in-the-loop test stages, but the build-test-analyze structure stays the same.

✔️ Awesome, I've got everything!

Great. Double check you have saved both Jenkinsfile and README.md and that your Jenkins pipeline shows all green stages.

ⓧ I'd like to double check the full code

Here is the complete Jenkinsfile:

pipeline {
    agent any

    stages {
        stage('Build') {
            steps {
                bat 'cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++'
                bat 'cmake --build build'
            }
        }

        stage('Test') {
            steps {
                bat 'cd build && ctest --output-on-failure --output-junit test-results.xml'
            }
            post {
                always {
                    junit 'build/test-results.xml'
                }
            }
        }

        stage('Static Analysis') {
            steps {
                bat 'cppcheck --enable=all --xml --xml-version=2 src/ 2> build/cppcheck-report.xml'
            }
            post {
                always {
                    recordIssues tools: [cppCheck(pattern: 'build/cppcheck-report.xml')]
                }
            }
        }
    }

    post {
        success {
            echo 'Build and tests passed successfully!'
        }
        failure {
            echo 'Build or tests failed. Check the logs for details.'
        }
    }
}

Here is the complete README.md:

# Laser Interferometer Measurement System (Simulated)

A multi-task FreeRTOS application simulating a precision laser measurement system
for semiconductor manufacturing. Built as a demonstration of embedded C firmware
architecture, RTOS patterns, and CI/CD automation.

## Architecture

The system uses three FreeRTOS tasks communicating via message queues:

```
[Sensor Task] --Queue--> [Processing Task] --Queue--> [Output Task]
   (50ms)                   (filter + convert)          (display)
```

- **Sensor Acquisition Task** (Priority 3): Simulates a moving stage generating
  fringe count data at 20 Hz with measurement noise.
- **Signal Processing Task** (Priority 2): Applies a moving average filter and
  converts fringe counts to nanometer displacement.
- **Data Output Task** (Priority 1): Formats and prints calibrated measurements.

## Key Formula

Displacement = fringe_count * (laser_wavelength / 2)

Using a HeNe laser at 632.8 nm: each fringe = 316.4 nm of motion.

## Building

Requires: MinGW-w64 (GCC), CMake 3.14+

```bash
cmake -S . -B build -G "MinGW Makefiles" -DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
cmake --build build
```

## Running

```bash
./build/laser_measurement.exe
```

## Testing

```bash
cd build
ctest --output-on-failure
```

## CI/CD

The project includes a Jenkinsfile for automated build, test, and static analysis.

## Key Concepts Demonstrated

- C language fundamentals (structs, pointers, header files)
- FreeRTOS task creation and scheduling
- Inter-task communication with queues
- Signal filtering (moving average)
- Interferometric displacement measurement (lambda/2 per fringe)
- CMake build system with external dependencies
- Google Test unit testing for embedded logic
- Jenkins CI/CD pipeline automation

You now have a fully automated CI/CD pipeline that builds, tests, and analyzes your embedded measurement system on every commit. This is the same automation pattern used in professional semiconductor firmware teams.

Secret mission

Add Runtime Calibration with Mutex Protection

Real measurement systems need to update calibration parameters while running. Temperature changes, pressure shifts, and humidity variations all affect laser wavelength. Can you add a mutex-protected calibration system that updates measurement parameters without stopping the tool?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources installed, pause Jenkins for later, or delete everything entirely.

Resources you used:

  • MSYS2 with MinGW-w64 toolchain (GCC, G++, CMake, cppcheck).
  • Eclipse Temurin JDK 21.
  • Jenkins WAR file.
  • FreeRTOS-Kernel source code (cloned from GitHub).
  • Project files (src, include, tests, CMakeLists.txt, Jenkinsfile, README.md).
  • VS Code with C/C++ extension.

Keep everything running

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

  • All tools remain installed and available from your PATH.
  • Jenkins only runs when you explicitly launch it with java -jar jenkins.war. It does not run as a background service.
  • MSYS2, Java, and your project files consume disk space but no CPU or memory when idle.

Pause - I'll come back to this later

Free up disk space from build artifacts while keeping all tools installed for future use.

  • Delete the build folder inside your project directory. This removes compiled binaries and downloaded Google Test files.
  • Keep MSYS2, Java, and Jenkins installed so you can rebuild quickly when you return.
  • To rebuild later, run cmake -S . -B build -G "MinGW Makefiles" and cmake --build build from your project folder.

Delete - I don't want to use this again

Remove all project resources and tools to start fresh.

  • Delete the entire project folder (including the FreeRTOS-Kernel subdirectory).
  • Delete the Jenkins home directory at %USERPROFILE%\.jenkins. This removes all Jenkins configuration, jobs, and build history.
  • Delete the Jenkins WAR file from wherever you downloaded it.
  • Uninstall Eclipse Temurin JDK 21 from Settings then Apps & features.
  • Uninstall MSYS2 by deleting the C:\msys64 folder and removing C:\msys64\ucrt64\bin from your system PATH.
  • Optionally uninstall the C/C++ extension from VS Code if you no longer need it.

Nice Work!

Nice Work!

You just built a professional-grade embedded measurement system from scratch. This project demonstrates the same firmware architecture, RTOS patterns, and CI/CD practices used in real semiconductor manufacturing tools.

Here's what you learned:

  • Built a multi-task FreeRTOS application with inter-task communication via queues, simulating a laser interferometer sensor pipeline with signal filtering.
  • Created a CMake build system with automated unit testing via Google Test, proving your signal processing logic works correctly.
  • Set up a Jenkins CI/CD pipeline with build, test, and static analysis stages that automatically validates every commit.
  • Secret Mission: Added mutex-protected runtime calibration demonstrating safe shared-state concurrency, the same pattern real measurement tools use for environmental compensation.

Ready to quiz yourself?