This repo contains a template for projects I use. Template includes:
- Makefile for building the project (RTX5 supported)
- RTX5 files
- Readme file
- License file
- Git ignore file
- Project structure
- Project folder structure
- Set of coding rules I follow in embedded software development
The diagram shows the project structure.
The base for every project. It runs the project.
Drivers are the gate for other layers of the application to interact with the hardware. They are written with minimal logic inside. Every driver is written without another driver(s). The only way for the driver to interact with hardware(eg., I2C sensor, SPI flash, GPIO, etc.) is through an external handler(user provided). Because of that, drivers are not fixed to certain MCU or framework.
Drivers are not written with application logic. They are like little legos, You can use them everywhere. Drivers can interact with libraries.
Every driver is written as a C++ class within its own namespace.
Libraries are pieces of software with basic logic and do not require interactions with the hardware. Every layer can use libraries. Libraries are not written with application logic.
Every library is written within its own namespace.
Eg., a library with string manipulation functions.
Application layer is made of application modules. Application modules combine driver(s) and libraries to produce basic logic for the application. Module alone is worthless(one plank is not a bench, but many planks combined create the bench).
Every module has its own namespace and can be written as one or more C++ classes.
Tasks combine application modules and their logic to do something useful. In the case of the bare metal project, there is no task layer.
Application entry point(main
). In case of bare metal application, it is only "task".
- đź“‚ {Project_name}: Root folder.
- đź“‚ .builds: Folder with per hardware build folders(used by Make and ARM-GCC).
- đź“‚ {Build_name}
- đź“‚ .git: Git folder.
- đź“‚ .jlink: Folder with J-Link scripts for flash and erase.
- đź“‚ .releases: Folder with stable releases.
- đź“‚ RC: Folder with release candidate releases.
- đź“‚ .vscode: Folder with VS Code config files.
- đź“‚ Application: Folder with application layer source files.
- đź“‚ Inc: Folder with application layer header files.
- đź“‚ Tasks: Folder with task source files.
- đź“‚ Inc: Folder with task header files.
- đź“‚ CMSIS: Folder with CMSIS-related files.
- đź“‚ Core: Folder with CMSIS-related core files.
- đź“‚ RTX: Folder with CMSIS RTX source files. - đź“‚ Inc: Folder with CMSIS RTX header files. - đź“‚ IRQ: Folder with RTX IRQ files.
- đź“‚ Config: Folder with hardware-related configuration files.
- đź“‚ Documentation: Folder with project documentation generated with Doxygen and files used for documentation.
- đź“‚ Drivers: Folder with driver source files.
- đź“‚ {Driver_Name}
- đź“‚ Inc: Folder with driver header files.
- đź“‚ {Driver_Name}
- đź“‚ Libraries: Folder with library source files.
- đź“‚ Inc: Folder with library header files.
- đź“‚ Linker: Folder with linker scripts.
- đź“‚ Make: Folder with per hardware Make files.
- đź“‚ MCU: Folder with MCU-related source files.
- đź“‚ Inc: Folder with MCU-related header files.
- .gitignore: List of items for Git to ignore.
- AppConfig.hpp: Header file with configuration for application layer.
- Doxyfile: Doxygen project file.
- LICENSE: Project license.
- Main.cpp: Main source file with application entry point.
- main.h: Legacy main header file.
- Main.hpp: Main header file.
- README.md: Project readme file.
- đź“‚ .builds: Folder with per hardware build folders(used by Make and ARM-GCC).
Note:
- MCU-related drivers are grouped with folder in
Drivers
folder(both source and header files). - Folder
Make
contains one Make file for every hardware version and/or application type.
- đź“‚ {Project_name}: Root folder.
- đź“‚ .git: Git folder.
- đź“‚ .vscode: Folder with VS Code config files.
- đź“‚ Documentation: Folder with project documentation generated with Doxygen and files used for documentation.
- đź“‚ Example: Folder with project example files.
- .gitignore: List of items for Git to ignore.
- Doxyfile: Doxygen project file.
- LICENSE: Project license.
- README.md: Project readme file.
Note:
Driver/library files are placed in root folder or in Src
or Inc
folder if project has multiple source or header files.
-
Library/Driver: vX.Y(rcA)
- Y: Minor version number. Starts from zero. Cannot go over 99. With leading zero(if
Y
is not zero). Increased by some amount by bug fixes and new features. Resets to zero whenX
increases. - X: Mayor version number. Can start from zero. Cannot go over 99. Without leading zero. Increased when
Y
overflows. - rc: Stands for release candidate which means test release.
- A: Release candidate number. Starts from one. Cannot go over 99. Without leading zero. Increased by one with every new release candidate.
X
= 0 means the software does not contain all features for the first full release - beta phase (not the same as the release candidate).
Examples:v0.01rc5
Release candidate #5 for version 0.01.v1.13
Stable release, version 1.13.
- Y: Minor version number. Starts from zero. Cannot go over 99. With leading zero(if
-
Application: vX.Y.Z(rcA)
- Z: Build number. Starts from zero. Cannot go over 99. With leading zero(if
Z
is not zero). Increased by some amount by bug fixes. Resets to zero whenY
increases. - Y: Minor version number. Starts from zero. Cannot go over 99. With leading zero(if
Y
is not zero). Increased by one whenZ
overflows or new features are introduced. Resets to zero whenX
increases. - X: Mayor version number. Can start from zero. Cannot go over 99. Without leading zero. Increased by one when
Y
overflows or when big changes are introduced(on the application code side). - rc: Stands for release candidate which means test release.
- A: Release candidate number. Starts from one. Cannot go over 99. Without leading zero. Increased by one with every new release candidate.
X
= 0 means the software does not contain all features for the first full release - beta phase (not the same as the release candidate).
Examples:v0.13.18rc8
Release candidate #8 for version 0.13.18v13.12.0
Stable release, version 13.12.0
- Z: Build number. Starts from zero. Cannot go over 99. With leading zero(if
v1.10.32
-> v1.11.0rc1
-> v1.11.0rc2
-> v1.11.0rc3
-> v1.11.0
Naming rule is: {fw_name}_{fw_version}(_{HW})
This rule applies to naming application executables files(.bin and .hex).
Application name contains project name and application type tag, eg., 3DCLK-FW
is the name of firmware for 3D Clock. 3DCLK-BL
is the name of the bootloader for 3D Clock. The firmware name is max 16 chars long.
The firmware version is copied from the software versioning rule.
_HW
is inserted in the case when release is for specific hardware, eg., hardware 22-0091rev1
.
Examples:
3DCLK-FW_v0.13.18rc3
is release name for 3D Clock firmware, version 0.13.18, release candidate 3.3DCLK-FW_v1.0.50rc1_22-0091rev1
is name of executables for hardware version22-0091rev1
, release3DCLK-FW_v1.0.50rc1
for 3D Clock, firmware version v1.0.50, release candidate 1.
Every file is named with first uppercase letter(Main.cpp, not main.cpp).
Files made for C++ have a .hpp header file, while C files have a .h header file.
List of the tools I use (Windows 10 Pro x64):
- VS Code
- C/C++ IntelliSense by Microsoft
- Cortex-Debug by marus25
- debug-tracker-vscode by mcu-debug
- Doxygen Documentation Generator by Christoph Schlosser
- Hex Editor by Microsoft
- Markdown All in One by Yu Zhang
- Markdown Preview by Yiyi Wang
- MemoryView by mcu-debug
- Peripheral Viewer by mcu-debug
- RTOS Views by mcu-debug
- Serial Monitor by Microsoft
- Solarized by Ryan Olson
- Git
- ARM-GCC v10.3.1 20210824 (GNU Arm Embedded Toolchain 10.3-2021.10)
- GNU Make v3.81
- Doxygen v1.9.7
- SEGGER J-Link(SWD) v7.88e
- STM32CubeMX
- nRF PPK2
- Salea Logic
- CMSIS Configuration Wizard v0.0.7
- Draw.io
- Fusion 360 (Electronics)
- Saturn PCB Toolkit
I prefer to use tabs for indents, size 4.
I prefer to use C++ over C, but only parts of C++ that do not induce overhead and bloat(except templates).
Classes, namespaces, and enum classes!
This is only a basic layout for source and header files. Layout depends on case-to-case and it is prone to changes.
-
SourceFile.cpp:
Defines, macro functions, enums, typedefs, structs, and classes in the translation unit means they are intended only and only for that translation unit.
/** * @file SourceFile.cpp * @author silvio3105 (www.github.com/silvio3105) * @brief This is template source file. * * @copyright Copyright (c) 2023, silvio3105 * */ // ----- INCLUDE FILES // ----- DEFINES // ----- MACRO FUNCTIONS // ----- TYPEDEFS // ----- ENUMS // ----- STRUCTS // ----- CLASSES // ----- VARIABLES // ----- STATIC FUNCTION DECLARATIONS // ----- FUNCTION DEFINITIONS // ----- STATIC FUNCTION DEFINITIONS // END WITH NEW LINE
-
SourceFile.hpp
/** * @file SourceFile.hpp * @author silvio3105 (www.github.com/silvio3105) * @brief This is template header file. * * @copyright Copyright (c) 2023, silvio3105 * */ #ifndef _SOURCEFILE_H_ #define _SOURCEFILE_H_ // ----- INCLUDE FILES // ----- DEFINES // ----- MACRO FUNCTIONS // ----- TYPEDEFS // ----- ENUMS // ----- STRUCTS // ----- CLASSES // ----- EXTERNS // ----- NAMESPACES // ----- FUNCTION DECLARATIONS #endif // _SOURCEFILE_H_ // END WITH NEW LINE
Pointers and references are written like this:
uint8_t* ptr8 = nullptr;
void foo(uint16_t& argRef);
not like this:
uint8_t * ptr8 = nullptr;
uint8_t *ptr8 = nullptr;
void foo(uint16_t & argRef);
void foo(uint16_t &argRef);
After comma should be space, so uint8_t foo(void* ptr, uint16_t len);
not uint8_t foo(void* ptr,uint16_t len);
. Same applies to arrays.
Here's complete code example:
/*
This is a comment block.
The comment block is a multiline comment.
*/
// This is an inline comment
/*
Defines are written in uppercase and space is replaced with underscore.
Since defines does not have "namespace", every define should start with a module abbreviation, eg., "#define FWCFG_GSM_UART USART1".
If a macro contains multiple elements(eg., another macro), its value is placed between ().
*/
#define THIS_IS_MACRO value
#define THIS_IS_SECOND_MACRO (THIS_IS_MACRO - 5)
/*
The macro function is written in uppercase, it starts with two underscores, and spaces are replaced with underscores.
Argument names start with underscore and the first letter is in lowercase.
The function body is written in a new line.
*/
#define __THIS_IS_MACRO_FUNCTION(_argOne, _argTwo) \
(_argOne - _argTwo)
/*
C-style enum type is written in lowercase, spaces are replaced with underscores and the type name ends with "_t".
Enum values are written like defines.
Enum definition also contains data size(uint8_t, uint16_t, etc..).
Every value starts with an abbreviation(eg., "GSM_ERROR") if not placed inside the namespace.
*/
enum enum_type_t : uint8_t
{
THIS_IS_ENUM1 = 0,
THIS_IS_ENUM2
};
/*
Same as classes:
Enum value names in the enum class are named with uppercase letters for every word.
Value names do not start with an abbreviation(eg., "ERROR", not "GSM_ERROR").
*/
enum class EnumClass_t : uint16_t
{
EnumOne = 0,
EnumTwo
};
// Type alias is written using the same rules as enum types.
typedef uint16_t idx_t;
// Same as typedef above but it ends with "_f".
typedef void (*ext_handler_f)(void);
/*
Struct type uses rules from typedefs.
Struct members are named using rules for global variables.
Each member should have a default value.
Structs are used only for data storage(no functions).
*/
struct this_is_struct_t
{
uint8_t someVar = 1;
uint32_t* somePtr = nullptr;
};
/*
Classes hold data(as structs) and methods to manipulate the data.
The class name is written with the uppercase first letter of every word in the name.
Only the private part of the class contains variables. To get or set variables from outside, getter and setter methods are used.
Variables and methods in class use naming rules from global variables and functions
*/
/**
* @brief Class brief.
*
* Class description.
*/
class ThisIsClass
{
public:
ThisIsClass(void);
~ThisIsClass(void);
uint8_t somePublicFunction(void);
private:
const char someArray[] = "Array"; /**< @brief This is inline doxygen comment. */
inline void somePrivateFunction(void);
};
// Classic extern
extern volatile uint8_t someVaraible;
/*
Global variable name starts with module abbreviation if not in namespace.
Module abbreviation is written in lowercase while every other word starts in uppercase.
The variable should have a default value.
*/
uint32_t thisIsVariable = 0;
/*
Global function name starts with module abbreviation if not in namespace.
Module abbreviation is written in lowercase while every other word starts in uppercase.
*/
/**
* @brief Function brief.
*
* Function description.
*
* @param argOne Some argument.
* @param argsList Some argument.
* @param varRef Some argument.
*
* @return No return value.
*/
void someFunction(const uint8_t argOne, uint16_t* argsList, uint32_t& varRef);
// Namespace uses naming rules from classes. Content in the namespace uses the same global type rule.
namespace SomeNameSpace
{
// VARIABLES
uint64_t x;
// FUNCTIONS
void setFoo(char someChar);
};
For some reason, I like to add a bunch of "workflow comments". Workflow comments describe what the lines below (comment) do. I tend to "group" lines of code into little sections.
void foo(void)
{
float tmp = 0;
uint16_t x = 1;
char str[32] = { '\0' };
uint32_t* ptr = nullptr;
// Execute something with value x
exe(x);
// Calculate result and convert it
foo2(ptr, x);
tmp = 2.00f * (*ptr);
// Do something with value
if (tmp > 10.00f)
{
tmp = 10.00f;
}
else
{
tmp -= 0.55f;
}
// Check does string exist
if (str[0])
{
// Do something with string
}
else // String does not exist, abort
{
return;
}
// Rest of the function...
}
I prefer less nested code. If I can check requirements before the function does its job, I do it.
Short examples:
void foo(void)
{
// Check if device is online
if (isOnline())
{
// Check if data is ready
if (isReady())
{
// Send data
bar();
bar2();
}
else
{
print("Not ready\n");
}
}
else
{
print("Not online\n");
}
}
void foo(void)
{
// Check if device is online
if (!isOnline())
{
print("Not online\n");
// Abort
return;
}
// Check if data is ready
if (!isReady())
{
print("Not ready\n");
// Abort
return:
}
// Send data
bar();
bar2();
}
For the sake of easy debugging I prefer multi line if statments.
if (statment)
{
foo();
}
not
if (statment) foo();
Every curly bracket is in new line.
function()
{
someCodeHere;
foo();
return 1;
}
Not like this(or any variation where curly bracket is not in new line)
function() {
someCodeHere;
foo();
return 1;
}
Except arrays, sometimes.
uint8_t arr[] = { 1, 2, 3, 4, 5 };
Declarations are placed in header files(.hpp/.h).
Definitions and private (static) stuff are placed in translation units(.cpp/.c).
Inline and template stuff are defined in header files.
To enable debug build DEBUG
flag should be defined during project build. Flag DEBUG_HANDLER
defines name of the function for debug printing(over UART or RTT). Eg., with flag DEBUG_HANDLER=log
log
function will be used for printing debug text.
Debug toggle switch is DEBUG
flag. Main debug configuration flag is DEBUG_HANDLER
. Those two flags are needed to create debug build.
DEBUG_x(_y)
is flag format to enable per module debug.
x
is driver/library/module name or abbreviation.y
is driver/library/module part name or abbreviation.
Eg., DEBUG_SML_RB
will enable debug print for ring buffer in SML library, DEBUG_SHT35
will enable debug print for SHT35 driver and DEBUG_GSM
will enable debug print for GSM module of the project.
Debug related code have to be removed in non-debug build.