Skip to content

Project Structure

A project is a compilable program that will run on a single device. It should have one main() function. Each ECU in our vehicle has its own project.

This article will explain our project structure through a tutorial in which you will create a project from scratch.

Warning

Knowledge of our firmware architecture is assumed. Read the Architecture article before continuing.

Note

Unless otherwise mentioned, all file paths are relative to racecar/firmware.


The simplest project is Blink (see projects/Demo/Blink). This project toggles a digital output indefinitely, toggling the state every 1 second.

In this tutorial, we will recreate the Blink project from scratch for multiple platforms. You will learn how projects are structured, how to configure an CubeMX for the stm32f767 platform, and how to build and run a project.

Prepare the project folder

All projects are stored under the projects/ directory, with each project organized in its own folder. Projects can also be grouped into subfolders for better organization. For example, the FrontController project is stored at projects/FrontController/, while demo projects can be grouped under projects/Demo/, such as the Blink project stored at projects/Demo/Blink/.

  1. Create a new folder to hold the project at projects/MyBlink.

  2. Add a README.md file explaining the purpose of this project.

    projects/MyBlink/README.md
    # MyBlink
    
    Recreate the `Demo/Blink` project by following the tutorial at
    <https://macformula.github.io/racecar/firmware/project-structure>.
    
  3. Add a platforms/ folder to the project. We will populate the platform implementations later.

You should now have the following directory structure.

projects/
└── MyBlink/
    ├── platforms/
    └── README.md

Bindings contract

A key feature of our firmware architecture is the complete abstraction of the app-level code from any specific platform implementation (See the Architecture article). The two layers are interfaced by the "bindings" contract which we will write first.

Create a new file bindings.h in the project directory. This is a header file since it only declares the interface objects and methods rather than using or implementing them.

projects/MyBlink/bindings.h
#pragma once

namespace bindings {

}  // namespace bindings

Info

#pragma once Ensures the header file is only ever included once.

We use the bindings namespace to indicate that the contained functions and objects form the interface between the app and platform layers.

We now add 3 elements to our contract.

  1. A digital output which we will call indicator. Include the shared/periph/gpio.h to gain access to the app-level DigitalOutput class.

    projects/MyBlink/bindings.h
    #pragma once
    
    #include "shared/periph/gpio.h"
    
    namespace bindings {
    
    extern shared::periph::DigitalOutput& indicator;
    
    }  // namespace bindings
    

    Notice the ampersand & in the type specifier. This makes indicator a reference to a DigitalOutput object. This is necessary since DigitalOutput is a virtual class so its size-in-memory is not defined.

  2. A function to wait between toggling. Time delay mechanisms are platform specific and thus must be included in the bindings contract. We will declare a DelayMS function which receives an unsigned int representing the number of milliseconds to delay for.

    projects/MyBlink/bindings.h
    #pragma once
    
    #include "shared/periph/gpio.h"
    
    namespace bindings {
    
    extern shared::periph::DigitalOutput& indicator;
    
    extern void DelayMS(unsigned int ms);
    
    }  // namespace bindings
    
  3. An initialization function. Some platforms must execute initialization code before they are ready to run. For example, stm32f767 has several HAL_*_Init functions which configure the peripherals. We include an Initialize function in bindings so that each platform can define its own behaviour.

    projects/MyBlink/bindings.h
    #pragma once
    
    #include "shared/periph/gpio.h"
    
    namespace bindings {
    
    extern shared::periph::DigitalOutput& indicator;
    
    extern void DelayMS(unsigned int ms);
    extern void Initialize();
    
    }  // namespace bindings
    

This completes the bindings contract. We can now write the application code, knowing that the platforms will all satisfy this contract.

Application code

Our main method is very simple in this project:

  1. Initialize the platform.
  2. Turn indicator on.
  3. Wait 1 second.
  4. Turn indicator off.
  5. Wait 1 second.
  6. Return to step 2.

Create a main.cc file in the project folder.

projects/MyBlink/main.cc
#include "bindings.h"

int main() {
    bindings::Initialize();

    while (true) {
        bindings::indicator.SetHigh();
        bindings::DelayMS(1000);
        bindings::indicator.SetLow();
        bindings::DelayMS(1000);
    }

    return 0;
}

In this simple project, all functions and peripherals are inside the bindings namespace. A more complex project could have app-level functions defined in main.cc.

The SetHigh and SetLow methods of indicator are declared in the virtual class under shared/periph/gpio.h.

CMake Sources

Our build system needs help determining which source files to compile. This is configured using a CMakeLists file that adds our main.cc file as a source to the global main target.

projects/MyBlink/CMakeLists.txt
target_sources(main PUBLIC main.cc)

This concludes the app-level code. Your project directory should look like

projects/
└── MyBlink/
    ├── platforms/
    ├── bindings.h
    ├── CMakeLists.txt
    ├── main.cc
    └── README.md

Platform code

We will create a platform implementation for the command line interface and the stm32f767. Both of these platforms have an MCAL library under racecar/firmware/mcal/.

Command Line Interface

Create a cli subfolder of MyBlink/platforms. We will satisfy the bindings contract in a bindings.cc file.

  1. Configure the indicator output. The concrete peripheral implementation is provided by mcal/cli/periph/gpio.h. Include this header and create a peripheral.

    projects/MyBlink/platforms/cli/bindings.cc
    #include "mcal/cli/periph/gpio.h"
    
    namespace mcal {
    
    cli::periph::DigitalOutput indicator{"Indicator"};
    
    }  // namespace mcal
    

    Now "bind" it to the app-level handle in the bindings namespace.

    projects/MyBlink/platforms/cli/bindings.cc
    #include "../../bindings.h"
    #include "mcal/cli/periph/gpio.h"
    #include "shared/periph/gpio.h"
    
    namespace mcal {
    
    cli::periph::DigitalOutput indicator{"Indicator"};
    
    }  // namespace mcal
    
    namespace bindings {
    
    shared::periph::DigitalOutput& indicator = mcal::indicator;
    
    }  // namespace bindings
    

    Again, note the ampersand & on the type specifier.

  2. Implement DelayMS. Our CLI will use the POSIX usleep function from unistd.h.

    The usleep delay is measured in microseconds. Multiply our milliseconds parameter by 1000 to convert it to microseconds.

    projects/MyBlink/platforms/cli/bindings.cc
    #include <unistd.h>
    
    #include "../../bindings.h"
    #include "mcal/cli/periph/gpio.h"
    #include "shared/periph/gpio.h"
    
    namespace mcal {
    
    cli::periph::DigitalOutput indicator{"Indicator"};
    
    }  // namespace mcal
    
    namespace bindings {
    
    shared::periph::DigitalOutput& indicator = mcal::indicator;
    
    void DelayMS(unsigned int ms) {
        usleep(ms * 1000);
    }
    
    }  // namespace bindings
    
  3. Implement the Initialize method. Our CLI does not need any initialization, but we can print a message to demonstrate that it is being executed.

    projects/MyBlink/platforms/cli/bindings.cc
    #include <unistd.h>
    
    #include <iostream>
    
    #include "../../bindings.h"
    #include "mcal/cli/periph/gpio.h"
    #include "shared/periph/gpio.h"
    
    namespace mcal {
    
    cli::periph::DigitalOutput indicator{"Indicator"};
    
    }  // namespace mcal
    
    namespace bindings {
    
    shared::periph::DigitalOutput& indicator = mcal::indicator;
    
    void DelayMS(unsigned int ms) {
        usleep(ms * 1000);
    }
    
    void Initialize() {
        std::cout << "Initializing the CLI..." << std::endl;
    }
    
    }  // namespace bindings
    

This completes the bindings implementation for the CLI.

CMake Configuration

We must add 2 files to help the build system find the correct sources.

projects/MyBlink/platforms/cli/CMakeLists.txt
target_sources(bindings PRIVATE bindings.cc)

target_link_libraries(bindings PUBLIC mcal-cli)

The first line adds the bindings.cc file to the globally defined bindings target. The second links the required MCAL library to the target.

The second file tells CMake which MCAL library to include.

projects/MyBlink/platforms/cli/mcal_conf.cmake
set(MCAL cli)
Historical Note

In 2023, the mcal was selected based on the platform folder name rather than by the mcal_conf.cmake file. However, this prevented us from defining two platform implementations using the same mcal, as the two folder names had to be unique but then couldn't reference the same mcal. mcal_conf.cmake was added to allow for this configuration.

This conf file duplicates logic in the second line of CMakeLists.txt. Make a PR if you have a better solution.

Your projects/MyBlink/platforms directory should look like

projects/MyBlink/platforms/
└── cli/
    ├── bindings.cc
    ├── CMakeLists.txt
    └── mcal_conf.cmake

Test the CLI

Tip

Read the Compiling a Project article for a full explanation of the compilation procedure.

To compile the MyBlink project with the CLI platform, open a terminal and navigate to the racecar/firmware directory. Run this command

make PROJECT=MyBlink PLATFORM=cli

This will compile the project to an executable which is stored in the firmware/build/MyBlink/cli directory.

Run the executable and see the indicator toggling every 1 second.

$ ./build/MyBlink/cli/main.exe
Initializing the CLI...
Setting DigitalOutput Channel Indicator to true
Setting DigitalOutput Channel Indicator to false
Setting DigitalOutput Channel Indicator to true
Setting DigitalOutput Channel Indicator to false
# repeats forever

stm32f767 Platform