Firmware 102 - Intro to FreeRTOS
Introduction
Welcome to firmware 102! By now you should have your dev environment setup.
Here’s a quick recap of what we’ve covered so far:
We run code on STM32 micro controllers
We use a universal controller board with many different electrical boards
We have conventions like using an infinite loop, using “settings” structs, and using libraries
We have our build system set up such that we can run our programs on x86
We use hardware libraries to allow us to access different capabilities of the microcontroller, like GPIOs, Interrupts and software timers
In this lesson we’ll be covering:
An intro to FreeRTOS
Tasks
Queues
NOTE: Message @Aryan Kashem or @Adel Chinoy with your github username to get added to the repository
NOTE: All of todays activities should be performed on your own branch off master. To set this up do the following:git checkout main
git pull
git checkout -b fwxv_999_[your name]_103_hw
You are now on your own branch, which is up to date with master
Intro to FreeRTOS
A full document about the main FreeRTOS functionality and how we are planning to use it, is available here: https://uwmidsun.atlassian.net/l/c/1Mz8eU7r
It’s definitely a bit long and dense (but trust me, nothing compared to the main version), so I’ve listed the most important sections to cover below:
Tasks and Multitasking
Scheduling
Context Switching
This is the primer, and you should make sure you’ve got a good grasp on the major ideas before you continue. Leads are happy to answer any conceptual questions! Assuming the above is all complete we can go a bit deeper into the details of how to use FreeRTOS in our programs.
Part 1: Tasks
We have our own API for tasks, which has been written to make task declaration easier to use and understand. It works through text-replacement macros (don’t worry about this too much) to create a nice interface and hide some of the more complex details.
Task Declaration
A basic task-based program structure is outlined below:
#include "tasks.h"
// Task Declaration. Allocates necessary static memory to use tasks
TASK(my_task, TASK_STACK_512) {
// Initialization of any variables before task operation begins
int my_int = 0;
struct MyType var = { 0 };
// Main operation of the task occurs in the while true loop
// The task always stays in this loop
// The scheduler will pause/resume this while loop for each task depending on priority
while(true) {
do_task_functionality(); // This function executes in the context of the calling task
}
}
// The main function is still the entry point of the program
int main(void) {
// Initialize any libraries
log_init();
tasks_init();
// Create task(s)
tasks_init_task(my_task, TASK_PRIORITY(2), NULL);
// Start scheduler. The regular execution of the program is now controlled
// by the scheduler. tasks_start() should never return.
tasks_start();
}
Our API is defined in tasks.h, with credits and respects to @Ryan Dancy. As you can see, we use the TASK macro to define our task functionality. (*Note: if you are referring to a task across multiple files, you will need to declare it in a header file using the DECLARE_TASK macro) Basically, this is just an improved function prototype and we can treat it as such. It takes 2 parameters; name
and stack_size
. The name is basically just a label which we can use to refer to the task, and the stack size is the amount of memory to allocate to the task for it to use for all the local variables it creates.
Task implementations are effectively just functions, with one very important caveat. Task functions should never return. All task functions must have a while true loop in which they execute all their functionality. The scheduler is responsible for how they execute.
Tasks are created (initialized and added to the scheduler) with the tasks_init_task
function. This takes the name of the task, the priority which we are giving the task (see Task Priorities), and an optional void pointer to pass to the task function (usually NULL).
Task 1 - Task implementation:
Alright, now it’s time to try it for yourself. The first task (does this count as a pun?) you will be completing is to implement two tasks task1, and task2 which should:
Print the name of the task and the counter value using LOG_DEBUG
Increment the counter
Call the prv_delay function for 1 second
To do this, take the following steps:
If you haven’t already, checkout to a new branch by running
git checkout -b fwxv_999_[your name]_103_hw
Make a new project
scons new --project=task1
Download the tasks1.c file, move to inside your new project’s src folder, and rename it to main.c and
Implement the task program
Fill out the two TASK function bodies where indicated.
Create the tasks using the
tasks_init_task()
function, initializing them at the same priorityStart the scheduler in the correct place
Build and run the project:
scons sim --project=<your_project> --platform=x86
Task Priorities and Scheduling
As was discussed in the introduction, time in FreeRTOS is split into multiples of the tick time. This is a periodic interrupt which occurs at a configurable time, and allows the kernel to change the state of the program. Since we only have one CPU core on our MCUs (and as such can’t execute more than one task in a given instant) each time slice is effectively a unit of computation where one task is put on the CPU to execute. The scheduler in the FreeRTOS kernel is in charge of determining which task should be given CPU time at each time slice.
Each task has a priority, and these priorities determine when it gets to execute. This is one of the main features that makes FreeRTOS safe. If you have critical behaviour (like a battery fault indicator), you can delegate it to a high priority task, so that it will always execute before less critical behaviour.
The highest priority task that is ready to run, will run at when the tick interrupt occurs. If multiple tasks of the same highest priority are available, they will alternate (this is called Time Slicing). The reason our tasks in the last exercise were able to both run was because they were configured at the same priority, and so were alternated between.
For an optional exercise, try changing the priority of one of the tasks to be higher than the other. You can see that only the high priority task runs.
Task States
A task can be in 4 different different states:
Running: The scheduler is running the task
Ready: The scheduler will run it when it is the highest priority available
Blocked: The task is waiting on some event to occur, and does not need to run
Suspended: rarely used, allows you to start/stop a task
A task blocks when a blocking API function is called. This basically means that the task will not be put back into the ready state until a certain condition has been satisfied. For example (as we’ll see later) we can block on data arriving on a queue, temporarily stopping the task until that data gets there. When it does become unblocked, however, it will overrule any lower priority tasks, a process called pre-emption.
If we take a look at the prv_delay()
function used in the last task, effectively what it’s doing is performing cpu operations until a given time has passed. This means that the task is always in the ready/running state, and will never relinquish the processor to tasks of a lower priority. FreeRTOS provides blocking delay functions, which when called in a task will cause the task to stop using the processor until the requested amount of time has passed. Our delay functions in delay.h
are essentially a wrapper for the blocking delay, and can be used directly.
Task 2 - Blocking APIs:
We are going to make changes to our previous program to use blocking API calls. The instructions have purposely been left a bit ambiguous, but see if you can figure it out. Here’s the set of steps:
Include delay.h
Change the priority of one task to be higher than the other
Alter your program so that both tasks are able to run. You should be able to see the output messages from both printing to the console!
NOTE: This file will be one of your deliverables. Once you have it working and complete, you should commit it to your branch.
git add <path_to_file>
→ this stages the changes you’ve made so that they can be committed
git commit -m "Complete task 1"
→ this adds a commit with the associated (required) commit message
git push
→ this command pushes to the remote version of the repo on your branch. It will most likely need you to set an upstream, which it will give you a command to do. Copy and run that, then run git push again
Part 2: Queues
Queues
Queues, as implemented by FreeRTOS are very similar to the queue data structure used in normal programming. They implement a first in first out (FIFO) buffer system for storing and accessing data where data is written sent to the back (tail) with a push operation and received from the front (head) with a pop operation.
Some things to note:
Queues are created with a fixed length. This governs the number of items they can hold
Multiple tasks can access a queue (via its queue handle). As such, they can be used as transmit information between tasks
FreeRTOS uses queue by copy: this means that the data parameter is copied byte-by-byte into the queue. If you use a pointer as the parameter, then you can queue by reference
It is possible for tasks to read from multiple queues, but it is recommended that this is only done if strictly necessary.
Sending and Receiving
Send and receive operations can provide an optional value, ‘block time', to indicate a length of time to wait.
Receive - If there is nothing in the queue, the task will block until ‘block time’ for data to become available in the queue. It might be put there by an interrupt or another task
Send - If the queue is full, the task will block until ‘block time’ waiting for data to be removed. This could occur from another interrupt or task
Queues can have multiple readers/writers, so it is possible for a single queue to have more than one task blocked on it. When this is the case, only one task will be unblocked when data becomes available. The task that is unblocked will always be the highest priority task that is waiting for data. If the blocked tasks have equal priority, then the task that has been waiting for data the longest will be unblocked.
Using the Queue API
Our queue api, defined in queues.h is defined as follows:
// Queue storage and access struct. Must be declared statically
typedef struct {
uint32_t num_items; // Number of items the queue can hold
uint32_t item_size; // Size of each item
uint8_t *storage_buf; // Must be declared statically, and have size num_items*item_size
StaticQueue_t queue; // Internal Queue storage
QueueHandle_t handle; // Handle used for all queue operations
} Queue;
// Create a queue with the parameters specified in settings. Returns STATUS_CODE_OK if successful,
// STATUS_CODE_INVALID_ARGS otherwise.
StatusCode queue_init(Queue *queue);
// Attempt to place an item into the queue, delaying for delay_ms in ms before timing out.
StatusCode queue_send(Queue *queue, const void *item, uint32_t delay_ms);
// Attempt to receive an item from the queue, delaying for delay_ms in ms before timing out.
StatusCode queue_receive(Queue *queue, void *buf, uint32_t delay_ms);
There is also a queue_peek()
which receives an item from the queue without removing it and queue_num_items()
which returns the number of items the queue can hold, but we don’t need to worry about that for now.
Queue
→ A struct used to store all the initialization information and memory that the queue needs to operate. Instances should be declared statically in the top region of your program, as they must persist for the entirety of the programnum_items
-> the number of items the queue can holditem_size
-> the size of each item in bytes (ie sizeof(uint32_t) for a queue of 32-bit unsigned values)storage_buf
-> a pointer to a static buffer ofnum_size*num_items
size. This is where all the queue data is storedqueue
-> memory needed by the kernel for this data structure. Don't worry about thishandle
-> set when queue_init is called. Used to refer to the queue by our API, but should not be accessed directly
queue_init()
- Takes a pointer to a queue struct, and initializes it based on its num_items and item_sizequeue_send()
- Sends an item to the back of a queue. The item must be the same size as num_size in the queue. If the queue is full, the method will block fordelay_ms
time or until space becomes available. Must be called from a taskqueue_receive()
- Receives an item from the front of a queue. You must provide it withbuf
, a pointer to a region of memory big enough to store the received value. If there is no data available, the method will block the calling task fordelay_ms
or until data arrived
Code that uses the queue API might look something like this:
#include "log.h"
#include "queues.h"
#include "tasks.h"
#define NUM_ITEMS 5
#define ITEM_SIZE sizeof(uint32_t) // This is the same as 4 bytes
// Allocate a data buffer (array of bytes) big enough to hold all queue items
static uint8_t s_queue_buf[NUM_ITEMS*ITEM_SIZE];
// Initialize queue settings
static Queue s_my_queue = {
.num_items = NUM_ITEMS,
.item_size = ITEM_SIZE,
.storage_buf = s_queue_buf,
};
// Sends an incremented uint32_t to the queue every second
TASK(t1, TASK_STACK_512) {
uint32_t to_send = 0; // Queue item
while(true) {
// Copy current value of to_send by to the back of the queue
queue_send(&s_my_queue, &to_send, 1000);
delay_ms(1000);
to_send++;
}
}
// Receives a uint32_t from the queue every half-second
// Because this is faster than the send method
// it will block until data becomes available
TASK(t1, TASK_STACK_512) {
uint32_t received = 0; // Value to receive to the queue from
while(true) {
// Copies data from front of queue into receive
queue_receive(&s_my_queue, &receive, 1000);
delay_ms(500);
LOG_DEBUG("Received: %u\n", recieved);;
}
}
int main(void) {
log_init();
queue_init(&s_my_queue)
//init tasks and start scheduler
...
}
Task 2: Queues:
We are now going to implement a FreeRTOS program using queues. We want to create a queue, and write to/receive from it, using the API provided in queues.h. It will be implemented in two tasks, task1 and task2 which should do the following:
Task1:
Loop through the list of strings, pushing them to the queue with queue_send (ticks_to_wait = 0)
Delay for 100 ms between queue pushes
Get the status from the above method, and LOG_DEBUG ”write to queue failed”) it if it is != STATUS_CODE_OK
Task 2:
Receive the strings from the queue to the provided buffer
If the dequeue was successful then LOG_DEBUG the string, otherwise print (“read from queue failed”)
Follow the steps below to get this program working:
Download the file below, and copy it into your project/src folder
Implement the queue struct with the needed parameters. Have a look at the example code above if you get stuck
Implement task 1 to the above spec
Implement task 2 to the above spec
Call queue_init to initialize your queue in main()
Build and run the project (scons sim --project=queues --platform=x86)
Deliverables
For this module, you will be submitting 2 files for review, both of the commits you created by following the steps above. Both of these files should be able to compile and run. You will be opening a pull request for these files, which we will go over below. This is the process that we have for submitting code when we write it as part of the team.
Pushing to the remote repository
On your computer you have a local version of your branch. When you make commits these are stored on this local version. To make them available to other repository members (ie the leads for getting your stuff marked), you need to push them to the remote. You can do this with a
git push
You may need to also run git push --set-upstream origin <your branch name> if it is your first time pushing.
Your changes are now viewable on Github, and you can now go and open a pull request. A pull request is when you submit your completed code for review, and then to be merged into the master branch upon approval. Your code should be fully functional and tested when you do this.
To open a pull request, once you push your changes you can go to GitHub - uw-midsun/fwxv: All the firmware for MSXV!. If you pushed your changes correctly you should see something like this:
You can click the compare and pull request button, add a title and description, and open your pull request for review.