Architecture
Our firmware is separated in 2 layers: application and platform.
The application (app) layer describes the what the project does at a high level. This includes:
- Interacting with generic peripheral objects.
- Task scheduling and program flow.
- Receiving and sending messages.
The platform layer configures hardware to run the app code. In this layer, you will find:
- Concrete peripheral implementations.
- Peripheral configurations.
- Initialization functions.
The interface between the app and platform layers is a contract called bindings. This contract declares a handle for each peripheral and function required by the application. The platform binds a configured peripheral or function implementation to each handle.
Why separate these layers?
There are two major motivations for strictly separating the application and platform code.
Motivation 1: Platform Abstraction
Since the application code is not tied to any specific platform, it can run on any platform, provided that platform can fulfill the bindings contract.
I cannot overstate how significant this is. Being able to arbitrarily swap platforms enables:
- Running vehicle firmware on your local machine through its command line interface.
- Testing application code with a HIL or SIL setup.
- Writing portable code, should we ever change our microcontroller.
- Simultaneously having multiple hardware configurations for the same platform.
Platform abstraction means that each device executes the exact same application code. This greatly simplifies debugging:
- If there is a bug when running the vehicle but the test platform works fine, then you immediately know that the vehicle platform layer was configured incorrectly.
- If all platforms have the same bug, then you know that all hardware peripherals are responding and the bug exists at the app layer.
Debugging Example
Imagine you are creating a simple project to turn on an LED whenever a button is pressed (this is the purpose of the Demo/BasicIO
project). There are four primary elements of this code:
- Read the button state (platform layer).
- Store the button state in a boolean variable (app).
- Set the LED with the boolean (platform).
- Loop to repeat this process indefinitely (app).
Without platform abstraction, if the code "didn't work", then you would be wondering:
- Does my button circuit work?
- Is the variable being stored properly?
- Did I choose the right GPIO pins for the button and LED?
- Is the LED burnt out?
- Did my program crash and fail to loop?
Addressing all of these potential errors would lead you down a long road of circuit testing and code modifications, trying to find other ways to see if the LED is working of if there was a crash.
Now suppose that you have used platform abstraction. When your project doesn't work, you can quickly configure a CLI platform to emulate the physical button and LED. The button state is "read" by prompting for a true
/false
input and the LED state is "set" by printing a message to the screen.
When you run the program in the CLI, you may find everything works as expected. When you type true
, the program prints "LED On" and false
causes "LED Off", and the program loops as expected. This indicates the the physical hardware was configured incorrectly.
Or, maybe the CLI always prints "LED Off" regardless of your input. Now you know that there is an error in the app code. You inspect the app code because the error is independent of the platform configuration.
Motivation 2: Modular Logic
Application behaviour can be completely defined without knowledge of the hardware setup, and vice-versa. This means we can develop, test, debug, and optimize a new project before we have the electrical system ready!
Alternatively, two developers can work in parallel: one concerned with configuring the hardware and the other with defining app level behaviour. The strict interface between the two layers means they can work independently without breaking each others' code.
Implementation
Note
Unless otherwise mentioned, all paths are relative to the racecar/firmware/
directory.
Shared Peripheral Interfaces
Each peripheral has a platform-independent interface defined as an Abstract Class. This class declares the high-level methods that the application can call on the peripheral. The methods are declared pure virtual and thus have no definition.
These peripheral definitions are located in shared/periph
.
Platform Peripheral Implementations
Each platform defines a class that inherits from this abstract peripheral class. It must provide a definition for each virtual function defined in the parent class. This child class can use platform-specific logic to accomplish the high-level goal.
The peripheral implementations are in the MCAL (Microcontroller Abstraction Layer) folder mcal/<platform-name>/periph/
.
Interface Example
Let us create a simple DigitalInput
abstract class and implement it for the stm32f767
and CLI
platforms.
At the app level, DigitalInput
has just a single method Read()
. This method takes no parameters and should return a boolean indicating the state of the input.
#pragma once
class DigitalInput {
public:
virtual bool Read() = 0;
}
The virtual
specifier allows the Read
method to be overridden while the = 0
syntax requires the child class to override it by making it a "pure" virtual method.
To implement this peripheral on the platforms, we inherit from the shared class and override Read
.
Digital inputs are read using the stm HAL_GPIO_ReadPin
function. This function accepts a port and pin number and returns true
(false
) if the port pin input is HIGH (LOW).
We do not specify a port or pin in this class. They are provided when an object is constructed in the project's platform layer, allowing this class to be reused for any stm32f767 digital input.
#pragma once
#include <cstdint>
#include "shared/periph/gpio.h"
#include "stm32f7xx_hal.h"
namespace mcal::stm32f767::periph {
class DigitalInput : public shared::periph::DigitalInput {
public:
DigitalInput(GPIO_TypeDef* gpio_port, uint16_t pin)
: port_(gpio_port), pin_(pin) {}
bool Read() override {
return HAL_GPIO_ReadPin(port_, pin_);
}
private:
GPIO_TypeDef* port_;
uint16_t pin_;
};
} // namespace mcal::stm32f767::periph
A command line interface does not have physical pins to read but the digital input behaviour can be implemented by prompting the user for a boolean input.
#pragma once
#include <iostream>
#include <string>
#include "shared/periph/gpio.h"
namespace mcal::cli::periph {
class DigitalInput : public shared::periph::DigitalInput {
public:
DigitalInput(std::string name) : name_(name) {}
bool Read() override {
int value;
std::cout << "Reading DigitalInput " << name_ << std::endl;
std::cout << " | Enter 0 for False, 1 for True: ";
std::cin >> value;
std::cout << " | Value was " << (value ? "true" : "false") << std::endl;
return value;
}
private:
std::string name_;
};
} // namespace mcal::cli::periph
We allow the developer to give a name to each digital input during construction. This name is included in the input prompt.
Suppose a DigitalInput
object is constructed with the name "UserButton"
. When Read()
is called for this CLI
platform, the user is prompted to enter 0 or 1.
Reading DigitalInput UserButton
| Enter 0 for False, 1 for True: 1
| Value was true
With this structure, the developer can write platform-agnostic app level code using the shared DigitalInput
interface, knowing that both platforms have a matching implementation.
Both the peripheral interface and implementations can be used by multiple projects. To see how they are used in project-specific code, continue to Project Structure.
Example: TMS Layers
The TMS functionality is separated into these 2 layers (+ bindings) as follows.
This is an example only and does not represent how the actual TMS is configured.
Application Layer
- Read the battery temperature with analog sensor
temp_sensor
. - Calculate a fan speed to cool the battery.
- Control a fan via
fan_control
. - Generate a summary CAN message.
- Transmit the message over the
vehicle_can
bus.
Bindings Contract
The application needs:
- ADC input
temp_sensor
- PWM output
fan_control
- CAN Bus
vehicle_can
Platform Layer
We can run and debug the TMS on any platform satisfying this contract.
temp_sensor
= Channel 10 of ADC1fan_control
= Channel 1 of hardware timer 4vehicle_can
= CAN2
Functions are executed through the stm32 HAL.
temp_sensor
= gRPC analog input signal namedTempSensor
fan_control
= gRPC analog output signal namedFanSpeed
vehicle_can
= SocketCAN interface calledvcan0
The project runs on a Raspberry Pi.
temp_sensor
= Numeric input from the userfan_control
= Print speed to the screenvehicle_can
= Print CAN message contents
The project runs in the developer's terminal.