
Mastering multithreading with ChibiOS: a beginner’s guide
Introduction
In this article, we will delve into the world of ChibiOS, a powerful and feature-rich collection of products designed for embedded systems. We will focus on the combination of ChibiOS/RT, a Real-Time Operating System (RTOS) and ChibiOS/HAL, a Hardware Abstraction Layer that supports a wide range of MCU peripherals. Together these two products, ChibiOS/RT and ChibiOS/HAL, provide a highly modular and versatile solution for embedded systems development.
We will take a closer look at the anatomy of a ChibiOS project, including its different components and how they interact with each other. Specifically, we will delve into the details of tweaking each subsystem via the configuration headers and understanding the essential concepts of multithreading to effectively use RT in embedded systems development.
By the end of this article, you will have a better understanding of how to set up, modify and build a ChibiOS project and how multithreading works in ChibiOS. This will give you the knowledge and skills needed to start building your own ChibiOS projects with confidence.
Whether you are a beginner or an experienced developer, this guide will provide you with the essential information needed to unlock the full potential of ChibiOS and build efficient, high-performance embedded systems.
Project anatomy
A ChibiOS project typically consists of several key components that are used to build and run the application. The main entry point of the application is typically a file called main.c
, which contains the code that runs when the application is first started. The project also includes a makefile
, which is used to specify the compiler instructions for building the project.
In addition to these core components, ChibiOS projects also include several configuration files that are used to customize the behavior of the system. These files are typically contained in a subfolder called cfg
and are:
halconf.h
, which contains configuration options for HALchconf.h
, which contains configuration options for the RTOSmcuconf.h
, which contains MCU-specific configuration options
The ChibiOS repository is typically linked to the project via makefile, and in ChibiStudio, there is often a link to the main folder of the ChibiOS repository often called os
. However, it is important to note that this folder is a link, and its contents are shared between all projects. As a result, modifying this folder can have a significant impact on all projects and is generally discouraged.
Another linked folder that may be found in ChibiStudio is board
, which contains the Board Support Package (BSP). The BSP includes a series of configurations that are specific to the development board in use. This includes the speed of the crystal mounted on the PCB, the MCU voltage, and the default configuration of each GPIO depending on the board schematic.
Finally, a ChibiOS project may also include one or more folders of additional source files, typically user-defined, that are included as a user library via makefile. These files can include custom code and functionality that is specific to the project.
main.c
In a ChibiOS project, main.c
contains the main()
function, the entry point of the program. To be fair, some early initialization such as clock tree setup and memory initializations are done before the main nevertheless, we can say that this function represents the entry point for the user application.
The main is always responsible for initializing the ChibiOS kernel and the hardware abstraction layer. It also creates and starts the custom threads that perform the specific actions of the program, such as reading sensor data or controlling actuators.
The following code is an example of the structure of a simple main file of a two-thread application (main
and Thread1
)
#include "ch.h" #include "hal.h" static THD_WORKING_AREA(waThread1, 128); static THD_FUNCTION(Thread1, arg) { /* Onetime actions of Thread 1. */ while (true) { /* Periodic actions of Thread 1. */ chThdSleepMilliseconds(50); } } /* * Application entry point. */ int main(void) { /* HAL and RT initialization. */ halInit(); chSysInit(); /* Creatation of the Thread 1. */ chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO + 1, Thread1, NULL); /* Onetime actions of Thread main. */ while (true) { /* Periodic actions of Thread main. */ chThdSleepMilliseconds(50); } }
The code starts by including the ChibiOS headers ch.h
and hal.h
, which provide access to the ChibiOS kernel and hardware abstraction layer APIs, respectively then those are initialized with the calls halInit()
and chSysInit()
. The second thread, Thread1
, is created when calling chThdCreateStatic()
.
This code demonstrates the use of multithreading in ChibiOS, where multiple threads can run concurrently. It is worth nothing that for this specific example, the two threads are not performing any specific actions, but comments have been placed in both threads to indicate where one-time and periodic actions would be placed in a real-world application.
makefile
The makefile is a vital component of a ChibiOS project as it contains instructions for the compiler. It is typically used to manage the build process of the project, and it is the go-to file when it comes to including custom libraries or changing compiler options such as optimization level.
It tells the compiler which files need to be compiled and linked together to create the final executable. It also contains information about the target architecture, the location of header files and libraries, and various other options that control the behavior of the compiler.
The makefile also provides a convenient way to automate repetitive build tasks, such as cleaning the build directory, and it can be used to build and deploy the project on different platforms or with different configurations.
The structure of the makefile is different from that of a regular C file, it is more similar to a command-line script. The make manual is a helpful resource for understanding the intricacies of this syntax. However, it is not necessary to have a deep understanding of it as the makefile is typically modified only in a few instances such as debugging the application or including custom libraries.
Configuration files
chconf.h
The ChibiOS kernel comes with default settings that are suitable for most basic applications. However, in some cases, you may want to fine-tune the behavior of the RTOS to better match the requirements of your project. The chconf
is where you can find all the configuration options related to the kernel.
This file allows you to customize the scheduling algorithm, memory management and other options of the system. It also contains preprocessor switches that enable various features such as mutex, semaphores, pipes, and memory pools. These features are enabled by default but in some cases, such as when you need to reduce the memory footprint, you may want to disable them.
Additionally, this configuration file also contains debugging options that are disabled by default but can be enabled to make the developer’s life simpler during the debugging phase. These options provide more detailed information about the state of the system at runtime. It is worth mentioning that these options are discussed in more detail in the ChibiOS debugging guide.
halconf.h
The Hardware Abstraction Layer of ChibiOS includes a wide range of drivers and a large codebase, which is not always necessary to include in the final firmware. Depending on the specific application, a user may need to enable or disable certain drivers or change their settings. The halconf
is where all the configuration options for the HAL drivers are located.
It contains preprocessor switches for every single driver at the beginning of the file, organized in alphabetical order. Following that, there is a section for each driver that contains compile-time settings. In general, this file needs to be modified every time a user wants to enable or disable a peripheral.
It’s important to note that the HAL of ChibiOS is designed to be modular, so a user can only include the drivers that are needed for a specific application and exclude the ones that are not needed. This allows for the reduction of the memory footprint of the final firmware and makes the development process more efficient.
Additionally, this file also contains options to configure the behavior of the driver, for example, the size of internal buffers, the default baudrate of the serial driver, etc. The halconf
file is designed to be user-friendly, making it easy for developers to quickly find the settings they need to change.
mcuconf.h
Many configurations related to the HAL are highly dependent on the specific microcontroller being used. For example, when enabling a driver in the halconf
, the number of peripheral instances depends on the microcontroller in use. Similarly, the clock configurations or interrupt priorities are also extremely hardware dependent. All of these configurations are contained in the mcuconf.h
file, which can differ from microcontroller to microcontroller, even if they belong to the same family.
The mcuconf
is specific to the microcontroller that is used in the project and it contains all the low-level configurations that are required to properly configure the microcontroller. It contains definitions that configure clock trees, interrupt priorities, pin assignments, and other microcontroller-specific parameters.
For example, the mcuconf
for the STM32F401 microcontroller is different from that for the STM32F469, and both of them are different from the one for the RP2040. This is because each microcontroller has its own set of peripherals, clock tree, and memory map, and therefore, requires different configurations. It’s worth noting that this file is an essential part of a ChibiOS project and must be properly configured for the specific microcontroller that is used in the project. This ensures that the ChibiOS HAL drivers are correctly configured and can properly interface with the microcontroller.
Finally, to understand which microcontroller a configuration file is specific to, you may want to check the beginning of the file. Often, the file will contain a header that includes information about the microcontroller it is intended for. For example, the following is the header of a configuration file for the STM32F469/79 microcontroller.
/* ChibiOS - Copyright (C) 2006..2020 Giovanni Di Sirio Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ #ifndef MCUCONF_H #define MCUCONF_H /* * STM32F4xx drivers configuration. * The following settings override the default settings present in * the various device driver implementation headers. * Note that the settings for each driver only have effect if the whole * driver is enabled in halconf.h. * * IRQ priorities: * 15...0 Lowest...Highest. * * DMA priorities: * 0...3 Lowest...Highest. */ #define STM32F4xx_MCUCONF #define STM32F469_MCUCONF #define STM32F479_MCUCONF
Final remarks
The configuration files and the makefile of a ChibiOS demo are specific to the version of the kernel and HAL as well as the target hardware in use. Manually combining the right files can be risky and may lead to a project that doesn’t work as expected. This is why the best way to create a new project is to duplicate a default demo for your evaluation board, starting from the distribution of ChibiOS in use. If the evaluation board is not available, you can start with a project for your microcontroller and build the custom board files.
It’s important to understand that the compatibility between different versions of the ChibiOS kernel and HAL, as well as the microcontroller, can change. So, it’s always advisable to check the documentation and release notes of the ChibiOS version you are using to ensure that the configuration files and demos are compatible with your setup.
A good further reading on this topic would be articles about how to port your application from one platform to another, as it can provide more detailed information on how to properly set up and configure a ChibiOS project for a specific hardware and version.
ChibiOS multithreading 101
The main feature of an RTOS is definitely multithreading, which allows multiple threads to run in parallel, even on a single-core CPU. A thread is an independent lightweight unit of execution, that can run in parallel with other threads. The scheduler is responsible for allocating CPU time to each thread and managing their execution order based on the scheduling strategy.
ChibiOS/RT uses a priority-based, pre-emptive scheduling algorithm. In simpler words, the scheduler ensures that the highest-priority ready thread is executed (priority-based) and if a higher-priority thread becomes ready it will overtake the CPU at expense of any lower-priority thread currently running (pre-emptive). In addition to this ChibiOS/RT offers also preemptive and cooperative round-robin for threads at the same priority level (more about the topic in this article).
But what that means for the users that want to approach RT for the first time and want to use it without digging into the details?
What is a thread?
In practice, a thread is typically implemented as a function that contains an infinite loop. This loop is responsible for executing the tasks that the thread is assigned to do. Each thread in ChibiOS requires a dedicated working area to store its stack and other data structures. The size of the working area depends on the specific requirements of the thread and the target platform.
In ChibiOS it is possible to create a thread assigning it a working area dynamically allocated (dynamic thread) or a preallocated one (static thread). Using static threads is preferable as dynamic allocation can cause memory fragmentation.
Static thread
To create a static thread it is possible to use the following API
thread_t *chThdCreateStatic(void *wsp, size_t size, tprio_t prio, tfunc_t pf, void *arg)
This function creates a thread and assigns it a pre-allocated working area. The parameters are:
wsp
pointer to the working area.size
the size of the working area in bytes.prio
the thread’s priority level.pf
the thread’s code function.arg
parameter to be passed to the thread function.
It returns a pointer to the created thread. ChibiOS offers macros to declare the thread function and working area, such as THD_FUNCTION
and THD_WORKING_AREA
. These macros allow for a consistent declaration of threads across different platforms, as they hide the architecture-specific instructions that may be required. This makes it easy to port code between different platforms and ensures that the code is more maintainable. The following code is an example of how to use the chThdCreateStatic
, THD_FUNCTION
and THD_WORKING_AREA
.
static THD_WORKING_AREA(waThread1, 128); static THD_FUNCTION(Thread1, arg) { /* Onetime actions of Thread 1. */ while (true) { /* Periodic actions of Thread 1. */ chThdSleepMilliseconds(50); } } int main(void) { ... chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO + 1, Thread1, NULL); ... }
The macro THD_WORKING_AREA
is used to declare a thread’s working area. The first parameter, waThread1
, represents the name and pointer to this area, and the second, 128
, represents the size in bytes that the thread can use for its stack. It is important to note that the actual amount of memory allocated for the working area will be larger than 128 bytes, as additional data structures are required by the system. However, the guarantee is that the thread will have an effective stack size of 128 bytes.
The macro THD_FUNCTION
is used to declare a thread function. The first parameter, Thread1
, represents the name and pointer to this function, the second, arg
, represents the arguments that this function receives. The type of arg is void*
in order to make the thread function accept any type of argument without specifying it. This approach allows for flexibility and abstraction, as the argument passed to the thread function may depend on the specific application and may not be known beforehand. More info about how to use a pointer to void can be found in this article.
Finally the chThdCreateStatic
uses the pointer to the thread working area and the thread function to create the thread. It is worth noticing that Thread1
is not running until this function is called. The function also needs the real size of the working area, a thread priority to operate the scheduling and an argument for the thread function (in this case NULL
).
Dynamic thread
Dynamic thread creation allows allocating their working area from the heap or a memory pool at runtime. To use dynamic threads, either CH_CFG_USE_HEAP or CH_CFG_USE_MEMPOOLS must be enabled in the chconf
configuration file. While dynamic threads are generally not recommended for use in embedded systems, they may be necessary for certain situations and ChibiOS provides the functionality to do so. The API for dynamic thread creation includes functions such as chThdCreateFromHeap
and chThdCreateFromMemoryPool
which allows for allocating the working area from a memory heap or memory pool respectively. Navigating dynamic thread creation and management is beyond the scope of this article, however, for those interested in exploring this topic further, we recommend consulting the documentation provided by the official ChibiOS website.
Optimizing Thread Priority in ChibiOS
Great! Now that we know how to create threads, how can we ensure that the system runs and executes all of them? One way to achieve this is by using “blocking functions” in each thread. A blocking function in ChibiOS is a function that will cause the thread executing it to be blocked until a certain condition is met or an event occurs. These functions are used to wait for resources, synchronize with other threads or external events, or enter low-power states. It’s important to note that even though a thread is blocked, it doesn’t mean the CPU is kept busy from that thread. Instead, the thread enters a wait state and the CPU is free to be allocated to another thread ready for execution.
The most common example of a blocking function is the chThdSleepMilliseconds
() which puts the thread to sleep for an amount of time specified in milliseconds. All the HAL API that uses the DMA to do a conversion or a data exchange are also blocking functions.
It’s important to note that the main()
function becomes a thread after calling chSysInit()
and it runs with a priority equal to NORMALPRIO
. The specific value of this constant is not important for scheduling purposes, only the order of priority numbers matters. For example, the following two lines will have the same effect in terms of scheduling:
chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO + 1, Thread1, NULL); chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO + 32, Thread1, NULL);
Both lines will create a thread with a priority higher than the main thread. Different would be if there is another thread that has a priority in between the two here used.
In reality when chSysInit()
is called, an idle
thread is created. This is a special thread that runs when there are no other ready threads available to be executed by the scheduler. The thread runs with the lowest possible priority, usually referred to as IDLEPRIO
, and can be preempted by any other ready thread. The main purpose of the idle thread is to wait for an Interrupt. However, it can also be customized or modified through callbacks to perform specific actions, such as putting the MCU in deep sleep mode to achieve ultra-low power consumption, upon entering or leaving the thread.
Back to the priority topic, in general, it is best practice to assign higher priority to threads performing critical tasks, as they will be given priority over other threads by the scheduler. However, it is important to keep in mind that these high-priority threads can pre-empt other threads and cause them to be blocked. To avoid this, it is important to include blocking functions within the critical thread’s loop.
To summarize: choose thread priority wisely and always include blocking functions within the thread’s loop to avoid monopolizing the CPU.
A deeper look at the default demo
Now that we have introduced some concepts of multithreading let us look into a default demo of ChibiOS once again. The following code is the main of the demo RT-STM32F469I-EVAL-SDP-CK1Z of ChibiOS 21.11.x
/* ChibiOS - Copyright (C) 2006..2018 Giovanni Di Sirio Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ #include "ch.h" #include "hal.h" #include "rt_test_root.h" #include "oslib_test_root.h" /* * This is a periodic thread that does absolutely nothing except flashing * a LED. */ static THD_WORKING_AREA(waThread1, 128); static THD_FUNCTION(Thread1, arg) { (void)arg; chRegSetThreadName("blinker"); while (true) { palSetLine(LINE_LED_GREEN); chThdSleepMilliseconds(50); palSetLine(LINE_LED_ORANGE); chThdSleepMilliseconds(50); palSetLine(LINE_LED_RED); chThdSleepMilliseconds(200); palClearLine(LINE_LED_GREEN); chThdSleepMilliseconds(50); palClearLine(LINE_LED_ORANGE); chThdSleepMilliseconds(50); palClearLine(LINE_LED_RED); chThdSleepMilliseconds(200); } } /* * Application entry point. */ int main(void) { /* * System initializations. * - HAL initialization, this also initializes the configured device drivers * and performs the board-specific initializations. * - Kernel initialization, the main() function becomes a thread and the * RTOS is active. */ halInit(); chSysInit(); /* * Activates the serial driver 5 using the driver default configuration. */ sdStart(&SD5, NULL); /* * Creates the example thread. */ chThdCreateStatic(waThread1, sizeof(waThread1), NORMALPRIO + 1, Thread1, NULL); /* * Normal main() thread activity, in this demo it does nothing except * sleeping in a loop and check the button state. */ while (true) { if (!palReadLine(LINE_ARD_A0)) { test_execute((BaseSequentialStream *)&SD5, &rt_test_suite); test_execute((BaseSequentialStream *)&SD5, &oslib_test_suite); } chThdSleepMilliseconds(500); } }
We already discussed here that this demo executes two tasks:
- blink the board LEDs following a specific sequence
- launch the test suite if the pin A0 of the SKD-K1 is grounded and output the results on the CMSIS-DAP serial
But how this is accomplished? When main
is executed both HAL and RT are initialized. After chSysInit()
:
main
becomes a thread that runs atNORMALPRIO
idle
is created and immediately suspended as it runs atIDLEPRIO
(the lowest priority possible)
The main then starts the Serial Driver 5 with the default configuration (8-bit, no parity, 38400 bps): note that this function only initializes the serial but no data is exchanged on this stream (this driver will later be used to print out the results of the test suite in the main thread loop). Right afterward, the Thread1
is launched, operating on a working area of 128 bytes with a priority higher than the main thread. As a consequence, the main thread is preempted and Thread1
is executed until it reaches its first sleep (the one in the loop after the palSetLine(LINE_LED_GREEN)
). At this point, Thread1
is suspended, and the main thread is ready to continue its execution.
Entering its loop, if line A0 is shorted to ground, the test suite is executed, printing the results on the Serial Driver 5. Otherwise, the main thread goes to sleep for 500 milliseconds. With all probability at this point, Thread1
is still sleeping and idle
runs until Thread1
gets ready again and can continue with palSetLine(LINE_LED_ORANGE)
before going to sleep once more.
To conclude some important observations:
- The line A0 is checked every 500ms, which means that if we don’t keep A0 grounded long enough we may never enter the if executing the test suite.
- Thread 1 is having higher priority and will always pre-empt the test suite. Nevertheless, the test suite makes large use of blocking functions that would allow a thread with lower priority to run.
Next steps
Now that we grasp the basics of multithreading a good idea is to experiment by playing around with the default demo and creating new applications with more than two threads. An interesting further reading is The simplest project ever with ChibiOS which will guide you in the simplification of the default demo to obtain a simple and optimized project template.
While they are not available yet, some more examples and exercises will be added in the near future and this small paragraph will be revised to include links to them and to the next article about GPIO.
Be the first to reply at Mastering multithreading with ChibiOS: a beginner’s guide