Embedded real-time operating system¶
Learning goals: Basic principles of embedded programming.
We will start familiarizing ourselves with embedded systems programming by first reviewing the structure of a program (in Raspberry Pi Pico). After that we will add functionality by learning the usage of different peripheral device libraries.
Real-time operating system¶
In this course, we are using Raspberry Pi Pico C/C++ SDK and ARM toolchain to program our device. In addition we’ll be using the FreeRTOS real-time operating system (RTOS) to aid us in our programming tasks. This RTOS is widely supported in the industry and is easy enough to understand main programming concepts. Both will be used for practice and eventually for our project work. Throughout the material, you’ll find programming examples for the device, but the best way to learn is by experimenting and hands-on practice. Let’s dive in!
An operating system is software that runs applications on a computer. It has two main tasks: providing an execution environment for applications and managing the computer’s hardware resources, peripherals, and access rights. The operating system offers services to applications through interfaces and libraries, allowing them to utilize these resources—for example, memory management, file systems, peripheral device access, and data transfer from the system to external sources. Additionally, the operating system controls application execution by allocating processor time and memory among different applications. More detailed discussions on these topics are covered in later Operating Systems-course, so we’ll skip an exhaustive presentation here.
One important note: on a computer (PC or server), the operating system (e.g. Windows, Linux, macOS) runs as a separate layer that manages hardware and provides services to applications. Programs are installed and executed on top of this operating system using its system calls and APIs. On the other hand, in embedded systems, the situation is different. The embedded operating system is usually compiled together with the application into a single binary image that runs directly on the hardware. Instead of being a large, independent platform, it provides only the minimal set of services (such as drivers, scheduling, or libraries) that the program needs to interact with the device.
Real-time operating system provides an execution environment for applications in embedded systems. Real-time responsiveness in this context means that the operating system guarantees specific response times for functionalities. For instance, it ensures that responses to input or events occur within one millisecond and that the maximum response time is ten milliseconds. For this reason, the concept of preemption is very important: the operating system can interrupt a running process to allocate resources to another process based on different scheduling policies: mainly time-sharing and priority. The piece of software in charge of assigning time slots to each process is called scheduler. The scheduler uses such policies to determine when it needs to stop the current process execution, and which process should gain access.
An RTOS can be lightweight, merely sharing processor time among applications. This lightweight design supports applications with strict real-time requirements because the operating system's own routines consume minimal time. The implementation of RTOS services significantly impacts the design, implementation, and maintenance of embedded programs.
As mentioned earlier, it is possible to program embedded systems without an operating system or device firmware. In such cases, the programmer is responsible for implementing the device’s corresponding functionalities, connecting peripheral components to the microcontroller programmatically, and implementing the necessary interfaces/services for the application.
Implementation of an embedded program¶
As mentioned in the previous C language material, modular programming makes a lot of sense and also sense when implementing computer programs. In the embedded world, it’s also sensible to divide the overall program into different tasks in a modular way, where each task’s functionality can be logically encapsulated or in other words, separated. Now, in the program, each task is defined with its own inputs to which it responds and its own outputs.
For example, in the Pico device used in the course, tasks could involve reading sensor data, updating the display, or performing serial or wireless communication. Well, these are not just examples; they are precisely the tasks we’ll implement in the project work.
Now, it makes sense to let the RTOS handle the parallel execution of tasks to ensure efficient responsiveness. The programmer’s responsibility is to implement the actual task and define its execution parameters, considering efficiency requirements. These execution parameters include timing, priority, memory allocation, initial settings, and more. In this context, learning embedded programming involves understanding how to use these ready-made RTOS features through libraries, interfaces, and software structures.
An alternative would be to program the RTOS or device firmware manually, following an Arduino-style superloop architecture (a large loop within the main function that contains all program functionality). As mentioned, the superloop is an acceptable solution for small embedded systems with modest resource and performance requirements. However, it sacrifices many benefits of modularity, and the programmer might inadvertently hinder program execution. Administratively, such an approach can become a so called nightmare because even minor code changes may disrupt finely tuned timings. It’s unlikely that anyone would manually implement task prioritization within a superloop; the solution would inevitably be cumbersome. In the professional world, embedded programming is its own art form compared to general workstation programming, especially when dealing with demanding tasks, peripheral drivers, or even custom operating systems.
In addition to implementing tasks and defining execution parameters, synchronizing task behavior requires solutions. For instance, a single input (such as a button press on the device) might trigger one or multiple tasks or cause a sequence of consecutive actions across different tasks. How can such a design be achieved?
Actually, now we’re getting into the territory of the Operating Systems course, so let’s leave those matters there. However, there’s a familiar and straightforward solution from digital technology that we’ll be using in the course: state machines. The course doesn’t aim to delve deeper into the theory of state machines (which is quite extensive), but we’ll use them to implement synchronization between tasks in our program.
In short, the idea behind a state machine is that the logical execution of a program progresses from one state to another based on inputs, tasks, and responses. To achieve this, the program defines (at least one) state variable whose value changes according to events and corresponding tasks. Software components (read: functions) that react to tasks and events receive information about state changes and perform actions accordingly. Afterward, the state transitions to the next state, and the software components react to it again. For example, state transitions in an embedded program could look like this:
waiting state -> button press -> read sensor data -> display result/message -> waiting state
In this example, the program could have three or four different tasks: an interrupt handler that responds to button presses, a task for communicating with the sensor, a task for updating the display, and possibly a concurrent task for wireless data transfer. Perhaps even a task for preprocessing data, depending on factors like the amount of data. Now, you could certainly implement this logical functionality using a superloop, but then you’d need to consider the state of other program functionalities in each subtask—for instance, whether you’re currently communicating with peripheral devices. In the example above, we couldn’t receive messages simultaneously while reading sensor data.
Here’s an example: A state machine-based implementation of an embedded program for Arduino. Here also there is an interesting example. Even just looking at the overview on that pages is worthwhile, especially because we’ll be using a similar approach in the course. Let's present a simple example with Arduino:
enum State {
WAITING,
READ_SENSOR,
PROCESS_DATA,
DISPLAY_RESULT,
TRANSMIT_DATA
};
State currentState = WAITING;
void loop() {
switch (currentState) {
case WAITING:
if (buttonPressed()) {
currentState = READ_SENSOR;
}
break;
case READ_SENSOR:
readSensor();
currentState = PROCESS_DATA;
break;
case PROCESS_DATA:
processData();
currentState = DISPLAY_RESULT;
break;
case DISPLAY_RESULT:
displayResult();
currentState = TRANSMIT_DATA;
break;
case TRANSMIT_DATA:
transmitData();
currentState = WAITING;
break;
}
}
Well, more on this shortly.
Introduction of FreeRTOS and Pico SDK¶
In this course we are using Raspberry Pi Pico. The Raspberry Pi Pico microcontroller is the RP2040 (Version 1) and the RP2350 (for the version 2). In addition the Pico provides external Flash Memory, regulator and a crystal oscillator. The RP2040 architecture is shown in the following picture. It has a dual core ARM M0+ chip, 264KB of RAM and different peripherals to connect external devices.
FreeRTOS, the real-time operating system used in the course, does not in itself provide anything else besides the basic tools needed to implement programs requiring real-time operations. Luckily, we have Pico C/C++ SDK (later only Pico SDK) maintained by Raspberry Pi. This SDK allows us to use for example the pins and peripheral devices connected to the Pico. This functionality is hidden below API (application programming interface) in various different libraries. By using these libraries, we can enable these functionalities in our program.
We will mention some of these libraries now, but the usage of them in programs will be discussed in detail in later sections of the lecture material.
(For those who are interested: Pico SDK and FreeRTOS are open-source software. This means that their source code is publicly available, so anyone can view it here and here).
The following figure presents the different components from a system perspective:
The RP2040 / RP2350 interact with their peripherals through specific drivers provided in the SDK. On top of this, the HAL layer simplifies interaction by avoiding direct register access. The API further abstracts the device, ensuring that all Raspberry Pi Pico family chips (currently the RP2040 and RP2350) can use the same SDK. FreeRTOS adds scheduling and task control capabilities. Finally, your user application is built on top of this infrastructure. This approach makes development significantly easier compared to bare-metal programming, where you would have to work directly with the hardware registers.
Services and libraries¶
We will use the following libraries to implement programs. Don't worry, the terms used below will be discussed in lecture material later.
- hardware_gpio A library to control the I/O pins of the microcontroller. The buttons and LEDs of the Pico are controlled using this library.
- hardware_i2c A library to use the i2c data transfer bus. Many of the devices connected to Pico can be accessed by using this library.
- hardware_uart Serial communications protocol to communicate with peripheral devices. For example, data could be sent to PC, or commands could be received by using this library.
- hardware_pwm A library for producing pulse width modulation (PWM) with the I/O pins of the Pico. By using PWM, we can feed analog current with a specific frequency from a digital pin to for example control the brightness of an LED
In the SDK most of the most impartant libraries are aggregated in the pico_stdlib library which includes among others: hardware_gpio, pico_stdio, and hardware_uart.
(For those who are interested: More information about the libraries can be found from the documentation of Pico SDK)
You can find lots of examples on programming using the Pico-SDK in the official Pico examples repository. It includes examples of FreeRTOS also.
Functionality of a program¶
Let’s take a closer look at the main function in a Pico C program. When working with FreeRTOS and the Pico SDK, the C language might initially appear cryptic due to the extensive use of predefined elements. However, understanding the code and considering what actually happens can be helpful.
In the context of Pico programming, there is indeed a main function from which the program execution begins. Although FreeRTOS performs some background tasks before executing the main function, we won’t delve into those details in this course.
Below is an example of the
main-function of a FreeRTOS-program://Including the libraries of FreeRTOS and Pico
#include <FreeRTOS.h>
#include <task.h>
#include <stdio.h>
#include <pico/stdlib.h>
#include <inttypes.h>
//Includes libraries for the HAT. Not part of the SDK or FreeRTOS.
#include <TKJTAG/sdk.h>
void buttonFxn(uint gpio, uint32_t eventMask);
void taskFxn(void * pvParams);
#define STACKSIZE 1024
int main(void) {
//(en) Optional support for stdio. Start Pico input/output
stdio_init_all();
//(en) Optional, intialize the Pico HAT. This is not specific for SDK, but for the hat you are using.
init_hat();
TaskHandle_t myTaskHandle = NULL;
// (en) I/O-definition that is used to enable a push button
gpio_init(BUTTON1);
gpio_set_dir(BUTTON1, 0);
// (en) We set and interrupt handler for the button, in this case the function buttonFxn
gpio_set_irq_enabled_with_callback(BUTTON1, GPIO_IRQ_EDGE_FALL, true, buttonFxn);
// (en) We initialize one task to the application, implementation is in taskFn-function
BaseType_t result = xTaskCreate(
taskFxn,
"BUTTON_TASK",
STACKSIZE,
NULL,
1,
&myTaskHandle
);
if(result != pdPASS) {
printf("Task create failed!\n");
return 0;
}
// (en) Tell that the initialization of the device succeeded
printf("Initialization OK!\n");
// (en) Start the FreeRTOS scheduler
vTaskStartScheduler();
return 0;
}
In all C programs, libraries are brought into the program using preprocessor directives at the beginning of the program. With FreeRTOS, there are quite a few of these libraries, and we haven’t covered them all here (not even close). FreeRTOS includes also a configuration file: FreeRTOSConfig.h. We include also the libraries to use the different sensors and actuators of our TKJ HAT (more on this in future chapters). For instance the BUTTON0 is not part of the Raspberry Pi Pico hardware, but of the extension HAT that we will use in the course. In our case the librar is in TKJTag/sdk.h.
In
main we include the different intialization: I/0, HAT and peripherals (defining required I/O settings). In the example, we enable one of the push buttons and define the event handler (interrupt) for button presses, which is the function buttonFxn. Each FreeRTOS task is essentially a C function, and the rules for writing and using them are (with some reservations) the same as for regular C functions.After that, there’s room in the main function to perform other initializations to control the program’s behavior. For example, if we use timers to control task execution, we can initialize them here.
Finally, it’s a good idea to send greetings to the development environment’s console window using the debugger. This way, we can verify that the device initialization was successful. The
puts or printf function can be used to write to the console window. printf works (with minor exceptions) similarly to the standard C library’s printf function. Remember that embedded implementations of standard libraries can vary in terms of implemented features.Note that the code includes many post-initialization checks to ensure that the initializations were successful. These checks are necessary, because when programming embedded systems, our view of the device’s internal operation is limited. You can also used led blinking to show that the process have succeed, specially when you do not have access to console.
A common question is how the program keeps running forever if there is no infinite loop in main() like in Arduino. The reason is that once we call vTaskStartScheduler();, the FreeRTOS kernel takes control of execution. The scheduler starts the different tasks and manages how they run. Each task is typically written with its own internal infinite loop, ensuring that it continues executing as long as the scheduler is active. The scheduler itself will always run as long as there are active tasks in the system, switching between them as needed to keep the application alive.
Conclusion¶
We’ve now covered the basics of executing programs in FreeRTOS. We’ll delve deeper into using FreeRTOS libraries in the course’s lab exercises.
The example code above may seem cryptic after the clear C language we’ve seen before. However, upon closer examination, you’ll notice that FreeRTOS code relies heavily on existing libraries, their function calls (with their own naming conventions), predefined data structures, pointers, and constants. In essence, it’s still the same C language.