14. Project: Create a Task Scheduler

1. Introduction

Goal

The goal of this project is to create a task scheduler that can blink 4 LEDs, each at a different rate, as if they're running in parallel. We'll use the SysTick timer to trigger the scheduler every 1 ms. The scheduler will then switch between the 4 tasks in a round-robin fashion.

Steps

  • First, create a scheduler that handles multiple tasks one at a time in a cycle. This is known as round-robin scheduling.
  • Use the SysTick handler to switch between these tasks at first.
  • Later, we'll switch to using the PendSV handler for this job.

What's a Task?

  • Think of a task as a specific C function that runs when it's the CPU's turn to execute it.
  • Each task has its own memory area, called a stack, to keep track of its data.
  • When a task is not using the CPU, the scheduler saves its current state. This way, the task can pick up where it left off.

2. Create User Tasks

Remember, task are nothing but functions. Let's create four tasks in main.c:

#include <stdio.h>
#include "imt_debug.h"

void task1_handler(void);
void task2_handler(void);
void task3_handler(void);
void task4_handler(void);

int main(void)
{

}

void task1_handler(void)
{

}

void task2_handler(void)
{

}

void task3_handler(void)
{

}

void task4_handler(void)
{

}

The imt_debug.h is used to overwrite the __io_putchar() function so that we can use printf() to print to the console.


3. Stack Pointer Selection

Allocating a private stack for each task and the scheduler is standard practice in real-time and embedded systems. This method streamlines modularity, debugging, and reliability. Each task functions like an independent program with its own resources, simplifying context switching, task preemption, and task isolation. This is especially important in safety-critical applications. Therefore, real-time operating systems (RTOS) and custom schedulers widely adopt this approach.

We'll designate 1 KB of memory for each task's private stack and another 1 KB for the scheduler's stack. The ARM Cortex-M stack is descending, so we'll allocate the top 1 KB of SRAM for task T1, the next 1 KB for task T2, and so on. The fifth 1 KB will go to the scheduler's stack. To keep the code readable, we'll define macros for the starting addresses of these private stacks. For example, consider the memory model in the ARM Cortex-M4 Generic User Guide:

memory_model

SRAM starts at 0x20000000, and the STM32F446 has only 128 KB of memory. We can define macros in the main.h file as follows:

#ifndef MAIN_H_
#define MAIN_H_

#define MAX_TASKS  5

/* Stack memory calculations */
#define SIZE_TASK_STACK  1024U
#define SIZE_SCHED_STACK 1024U

#define SRAM_START       0x20000000U
#define SIZE_SRAM        ((128) * (1024))
#define SRAM_END         (SRAM_START + SIZE_SRAM)

#define T1_STACK_START   SRAM_END
#define T2_STACK_START   (SRAM_END - (1 * SIZE_TASK_STACK))
#define T3_STACK_START   (SRAM_END - (2 * SIZE_TASK_STACK))
#define T4_STACK_START   (SRAM_END - (3 * SIZE_TASK_STACK))
#define IDLE_STACK_START (SRAM_END - (4 * SIZE_TASK_STACK))
#define SCHED_STACK_START (SRAM_END - (5 * SIZE_TASK_STACK))

#endif

Notice that we have an extra stack for the idle task. We'll discuss this later.


4. Tasks and Scheduling

Instructions

  • We will employ round-robin pre-emptive scheduling, which rotates tasks in and out of the CPU based on a fixed time slice.
  • We won't consider task priorities for the sake of simplicity.
  • A SysTick timer will generate an exception every 1 ms to trigger the scheduler.

What is Scheduling?

  • Scheduling is an algorithm that decides when to preempt a running task from the CPU and which task should run next.
  • This decision can be based on various factors like system load, task priority, resource sharing, or a simple round-robin approach as we're using here.

What is Pre-emption?

  • Pre-emption refers to the process of interrupting a currently running task to allow another task to use the CPU.

What is Context Switching?

  • Context switching involves saving the execution context or state of the current task before moving it out of the CPU.
  • It also involves restoring the previous execution context of the next task to run.
  • The state of a task includes:
    • General-purpose registers that hold the task's data
    • Special registers like PSP (Process Stack Pointer), SP (Stack Pointer), and LR (Link Register)
    • Status registers that contain various CPU flags

5. Case Study of Context Switching

Case 1: T1 Switches to T2

  • T1 is currently running on the CPU.

    • T1 uses its own private stack and Processor Stack Pointer (PSP).
  • SysTick timer generates an exception.

    • When the SysTick timer reaches zero, an exception is triggered, causing the CPU to stop executing T1 and start executing the SysTick handler.
  • Context Saving:

    • Automatic Context Saving: When an exception occurs, the Cortex-M hardware automatically saves certain state information onto the current stack (in the case of threads, this is the PSP). This includes xPSR, return address (PC), R12, R3, R2, R1, and R0.
    • Manual Context Saving: Some registers, like R4-R11, are not automatically saved. These need to be manually saved onto T1's stack to fully capture its execution context.
    • Save PSP of T1: Store the current value of the PSP (as a global variable), which points to where T1's context has been saved, so we can come back to it later.
  • Context Retrieval:

    • Load PSP for T2: Update the PSP to point to T2's stack.
    • Automatic Context Retrieval: As the exception returns, the Cortex-M hardware automatically pops T2's saved context (the subset that was automatically saved) from the stack.
    • Manual Context Retrieval: Manually restore the remaining registers for T2 from its stack.
  • T2 resumes execution.

    • Execution picks up where T2 left off the last time it was running.

6. Configuring the SysTick Timer

Calculating the Reload Value

  • Processor Clock: 16 MHz
  • SysTick Timer Count Clock: 16 MHz
  • Target Frequency: 1 ms (or 1 KHz)
  • To match the SysTick timer frequency to our 1 ms (1 KHz) target, we use a reload value of 16,000 (16 MHz / 1 KHz).

SysTick Registers

To configure the SysTick timer, we primarily interact with two registers:

  • SysTick Control and Status Register (SYST_CSR):

    • Bit 0: Enable bit - Enables the counter.
    • Bit 1: Tick interrupt - Enables SysTick exception request.
    • Bit 2: Clock Source - Sets the clock source as the processor clock. SYST_CSR SYST_CSR_continued
  • SysTick Reload Value Register (SYST_RVR):

  • This register holds the reload value, which in our case is 16,000. SYST_RVR

Code Configuration

First, define macros for the clock settings in your main.h:

#define TICK_HZ             1000U
#define HSI_CLOCK           16000000U
#define SYSTICK_TIM_CLK     HSI_CLOCK

Then, add the function to initialize the SysTick timer in main.c:

void init_systick_timer(uint32_t tick_hz)
{
    uint32_t *pSRVR = (uint32_t*)0xE000E014;
    uint32_t *pSCSR = (uint32_t*)0xE000E010;

    /* Calculate reload value */
    uint32_t count_value = (SYSTICK_TIM_CLK / tick_hz) - 1;

    /* Clear existing reload value */
    *pSRVR &= ~(0x00FFFFFFFF);

    /* Set new reload value */
    *pSRVR |= count_value;

    /* Configure SYST_CSR settings */
    *pSCSR |= (1 << 1);  // Enable SysTick exception request
    *pSCSR |= (1 << 2);  // Set clock source as processor clock
    *pSCSR |= (1 << 0);  // Enable the SysTick counter
}

7. Stack Initialization

To ensure smooth context switching between tasks, we must initialize each private stack with appropriate dummy register values. The task stacks (T1 - T4) and the scheduler stack will be initialized differently.

Scheduler Stack Initialization

Our Main Stack Pointer (MSP) should initially point to the top of the scheduler stack. While we could directly insert this line __asm volatile("MSR MSP,%0": : "r" (sched_top_of_stack) : ); in main(), it's better to maintain modularity. For this, we'll create a function named init_scheduler_stack().

__attribute__((naked)) void init_scheduler_stack(uint32_t sched_top_of_stack)
{
    __asm volatile("MSR MSP,%0": :  "r" (sched_top_of_stack)  :   );
    __asm volatile("BX LR");
}

The function is defined as 'naked', meaning it won't automatically push or pop the stack. The second line, __asm volatile("BX LR");, manually returns from the function. Here's how our main() function looks so far:

int main(void)
{
    init_scheduler_stack(SCHED_STACK_START);
    init_systick_timer(TICK_HZ);

    for(;;);
}

Task Stack Initialization

We need to populate each private stack with dummy register values in the following order (from top of stack to bottom):

  • Stack Frame 1 (SF1)
  • xPSR
  • Return Address (PC)
  • LR
  • R12
  • R3-R0
  • Stack Frame 2 (SF2)
  • R11-R4 (PSP points here)

To initialize these, we'll use:

  • xPSR: 0x01000000 (dummy value)
  • PC: Task handler function pointer
  • LR: 0xFFFFFFFD (appropriate value for EXC_RETURN, more on this later)
  • R0-R12: 0

Additionally, we need to track the PSP value for each task and the task handler function pointer. We can use a global structure called TCB_t (Task Control Block) to store this info.

typedef struct
{
    uint32_t psp_value;
    void (*task_handler)(void);
} TCB_t;

TCB_t user_tasks[MAX_TASKS];

Now, define a macro for XPSR:

#define DUMMY_XPSR  0x01000000U

We can now create a function called init_tasks_stack().

void init_tasks_stack(void)
{
    uint32_t *pPSP;

    for (int i = 0; i < MAX_TASKS; i++)
    {
        pPSP = (uint32_t *)user_tasks[i].psp_value;

        pPSP--;
        *pPSP = DUMMY_XPSR;

        pPSP--; // PC
        *pPSP = (uint32_t)user_tasks[i].task_handler;

        pPSP--; // LR
        *pPSP = 0xFFFFFFFD;

        for (int j = 0; j < 13; j++)
        {
            pPSP--;
            *pPSP = 0;
        }

        user_tasks[i].psp_value = (uint32_t)pPSP;
    }
}

Remember, the EXC_RETURN value gets loaded to LR upon exception entry. This value influences behavior upon exiting the exception. We set it to 0xFFFFFFFD to continue using the Process Stack Pointer (PSP).

EXC_RETURN

Now, you can call init_tasks_stack() in main():

int main(void) {
    init_scheduler_stack(SCHED_STACK_START);
    init_tasks_stack();
    init_systick_timer(TICK_HZ);
    while(1);
}

8. Enable Fault Exceptions

In case there is a fault in the code, we need to enable the fault exceptions. We can do this by setting the appropriate bits in the System Handler Control and State Register (SHCSR). We'll use the enable_processor_faults() function, something we mentioned in Section 12.

void enable_processor_faults(void)
{
    uint32_t *pSHCSR = (uint32_t *)0xE000ED24;

    *pSHCSR |= (1 << 16); // mem manage
    *pSHCSR |= (1 << 17); // bus fault
    *pSHCSR |= (1 << 18); // usage fault
}

Put it in the begginning of main():

int main(void) {
    enable_processor_faults();
    init_scheduler_stack(SCHED_STACK_START);
    init_tasks_stack();
    init_systick_timer(TICK_HZ);
    while(1);
}

The fault handlers are simple. We just print a message and go into an infinite loop:

void HardFault_Handler(void)
{
    printf("Exception : Hardfault\n");
    while (1);
}

void MemManage_Handler(void)
{
    printf("Exception : MemManage\n");
    while (1);
}

void BusFault_Handler(void)
{
    printf("Exception : BusFault\n");
    while (1);
}

9. Switching the Stack Pointer

Define a global variable called current_task to keep track of the current task. We'll start with task 1, so set it to 1.

uint8_t current_task = 1;

Create a function called get_psp_value() to return the PSP value of the current task.

uint32_t get_psp_value(void)
{
    return user_tasks[current_task].psp_value;
}

Now, we need to switch the stack pointer from MSP to PSP. We'll use the switch_sp_to_psp() function.

__attribute__((naked)) void switch_sp_to_psp(void)
{
    // 1. initialize the PSP with TASK1 stack start address

    // get the value of psp of current_task
    __asm volatile("PUSH {LR}"); // preserve LR which connects back to main()
    __asm volatile("BL get_psp_value");
    __asm volatile("MSR PSP,R0"); // initialize psp
    __asm volatile("POP {LR}");   // pops back LR value

    // 2. change SP to PSP using CONTROL register
    __asm volatile("MOV R0,#0X02");
    __asm volatile("MSR CONTROL,R0");
    __asm volatile("BX LR");
}

We call get_psp_value() to get the PSP value of the current task. However, remember that switch_sp_to_psp() is a naked function, so we need to preserve the LR value by manually pushing and popping it.

Here's how our main() function looks so far:

int main(void) {
    enable_processor_faults();
    init_scheduler_stack(SCHED_STACK_START);
    init_tasks_stack();
    init_systick_timer(TICK_HZ);
    switch_sp_to_psp();
    while(1);
}

10. Implementing the SysTick Handler

Two Functions

Let's implement two functions that we will call in our inline assembly code later:

void save_psp_value(uint32_t current_psp_value)
{
    user_tasks[current_task].psp_value = current_psp_value;
}

void update_next_task(void)
{
    current_task++;
    current_task %= MAX_TASKS;
}
  • save_psp_value(uint32_t current_psp_value): This function saves the current Process Stack Pointer (PSP) value for the running task in its Task Control Block (TCB).
  • update_next_task(): This function updates the current task to the next task in the list. If the current task is the last task, it wraps around to the first task (round-robin scheduling).

SysTick Handler

The SysTick handler's job is to manage time-sensitive operations without being too time-consuming itself. It offloads most of its work to the PendSV_Handler.

void SysTick_Handler(void)
{
    uint32_t *pICSR = (uint32_t *)0xE000ED04;
    *pICSR |= (1 << 28);
}
  • *pICSR |= (1 << 28);: Sets the PendSV exception as pending. This triggers the PendSV handler to switch the context to another task.

PendSV Handler

__attribute__((naked)) void PendSV_Handler(void)
{
    /*Save the context of current task */

    // 1. Get current running task's PSP value
    __asm volatile("MRS R0,PSP");
    // 2. Using that PSP value store SF2 (R4 to R11)
    __asm volatile("STMDB R0!,{R4-R11}");
    __asm volatile("PUSH {LR}");

    // 3. Save the current value of PSP
    __asm volatile("BL save_psp_value");

    /*Retrieve the context of next task */

    // 1. Decide next task to run
    __asm volatile("BL update_next_task");

    // 2. get its past PSP value
    __asm volatile("BL get_psp_value");

    // 3. Using that PSP value retrieve SF2 (R4 to R11)
    __asm volatile("LDMIA R0!,{R4-R11}");

    // 4. update PSP and exit
    __asm volatile("MSR PSP,R0");
    __asm volatile("POP {LR}");
    __asm volatile("BX LR");
}

The PendSV_Handler is responsible for saving and restoring the context when task switching occurs.

  1. Saving the context of the current task: The handler first fetches the PSP value of the currently running task (MRS R0,PSP) and then stores the necessary register values (STMDB R0!,{R4-R11}). The save_psp_value function is called to save the PSP value.

  2. Retrieving the context of the next task: The handler decides the next task to run (BL update_next_task), retrieves its PSP value (BL get_psp_value), and then loads the register values back (LDMIA R0!,{R4-R11}).

  3. Updating PSP and exiting: Finally, the PSP is updated (MSR PSP,R0) and the function returns (BX LR).

Special Instructions:

  • STMDB and LDMIA: These ARM instructions are used for storing and loading multiple data registers. STMDB decrements before storing (Decrement Before), and LDMIA increments memory after accessing (Increment After).

  • R0!: The exclamation mark indicates that the register (R0 in this case) will be updated after the operation. So, after storing/loading, R0 will point to the next memory location.


11. Toggling LEDs

LED Header File

The course comes with files led.h and led.c that we can use to toggle the LEDs. Here is led.h:

#ifndef LED_H_
#define LED_H_

#define LED_GREEN 12
#define LED_ORANGE 13
#define LED_RED 14
#define LED_BLUE 15

#define DELAY_COUNT_1MS 1250U
#define DELAY_COUNT_1S (1000U * DELAY_COUNT_1MS)
#define DELAY_COUNT_500MS (500U * DELAY_COUNT_1MS)
#define DELAY_COUNT_250MS (250U * DELAY_COUNT_1MS)
#define DELAY_COUNT_125MS (125U * DELAY_COUNT_1MS)

void led_init_all(void);
void led_on(uint8_t led_no);
void led_off(uint8_t led_no);
void delay(uint32_t count);

#endif /* LED_H_ */

The delay here is implemented using a for-loop, which is not the best practice. We will later replace this with the idle task. This course does not cover GPIO, so we will not go into the details of led.c.

Task Handler Implementation

We can use the functions provided in led.h to implement the task handlers:

void task1_handler(void)
{
    while (1)
    {
        led_on(LED_GREEN);
        delay(DELAY_COUNT_1S);
        led_off(LED_GREEN);
        delay(DELAY_COUNT_1S);
    }
}

void task2_handler(void)
{
    while (1)
    {
        led_on(LED_ORANGE);
        delay(DELAY_COUNT_500MS);
        led_off(LED_ORANGE);
        delay(DELAY_COUNT_500MS);
    }
}

void task3_handler(void)
{
    while (1)
    {
        led_on(LED_BLUE);
        delay(DELAY_COUNT_250MS);
        led_off(LED_BLUE);
        delay(DELAY_COUNT_250MS);
    }
}

void task4_handler(void)

{
    while (1)
    {
        led_on(LED_RED);
        delay(DELAY_COUNT_125MS);
        led_off(LED_RED);
        delay(DELAY_COUNT_125MS);
    }
}

Main

Now we can initialize the LEDs in main() and call the first task handler to start the round-robin scheduling:

int main(void) {
    enable_processor_faults();
    init_scheduler_stack(SCHED_STACK_START);
    init_tasks_stack();
    led_init_all();
    init_systick_timer(TICK_HZ);
    switch_sp_to_psp();
    task1_handler();
    while(1);
}

Blocking States of Tasks

Introduction

Our goal is to make each LED blink at its own rate, without affecting how the other LEDs blink. To do this, we'll use the idea of "blocking" tasks.

Here's how this applies to our application:

  1. Incorporating a Delay Function: When an LED doesn't need to be on, its controlling task can call a delay function. This puts the task in a "blocked" state, freeing up CPU resources. Essentially, the task signals the scheduler: "I'm good for now, let the other LEDs have their moment."

  2. Understanding Task States: With the "blocked" state introduced, each LED task can be in either a "Running" or "Blocked" state. When a task is "Running," its LED is active, either turning on or off. When "Blocked," the LED maintains its current state and waits for its next cue.

  3. Synchronizing the Lights: Being in a "blocked" state is not permanent. The task will come out of this state when it's time for the LED to change. The scheduler keeps track of these "blocked" tasks and moves them back to the "Running" state at the appropriate times.

By implementing these features, our scheduler becomes more efficient. It can manage CPU resources more effectively, ensuring that the LEDs blink independently but in a coordinated manner—as if they're not queued, but operating in parallel.

States of a Task

Modifying the TCB_t Structure

typedef struct {
    uint32_t psp_value;
    uint32_t block_count;
    uint8_t  current_state;
    void (*task_handler)(void);
} TCB_t;

The Task Control Block (TCB_t) is like the "ID card" for tasks. Previously, it didn't have information about whether a task was blocked or how long it was supposed to be blocked. To add this feature, two new fields are introduced:

  • block_count: This will store the time (in ticks) when the task should be unblocked.
  • current_state: This stores the current status of the task (either blocked or ready).

Defining State Macros

#define TASK_READY_STATE 0x00
#define TASK_BLOCKED_STATE 0xFF

To make the code more readable and maintainable, it's often a good idea to use named constants instead of "magic numbers." Here, TASK_READY_STATE and TASK_BLOCKED_STATE act as labels for the states that current_state can hold, making it clearer what each state represents.

Initializing Task States

void init_tasks_stack(void)
{
    user_tasks[0].current_state = TASK_READY_STATE;
    // ... same for other tasks
}

When the system boots up, or when you're initializing tasks, you want them to be in a known state. In this case, the current_state for each task is set to TASK_READY_STATE, indicating that they're ready to run.

Deciding the Next Task to Run

void update_next_task(void)
{
    int state = TASK_BLOCKED_STATE;

    for (int i = 0; i < (MAX_TASKS); i++)
    {
        current_task++;
        current_task %= MAX_TASKS;
        state = user_tasks[current_task].current_state;
        if ((state == TASK_READY_STATE) && (current_task != 0))
            break;
    }

    if (state != TASK_READY_STATE)
        current_task = 0;
}

The function update_next_task is essentially the "decision-maker" for which task gets to run next. The system goes through the list of tasks and checks their states. If a task is ready to run (TASK_READY_STATE) and it's not the idle task (current_task != 0), then it gets selected as the current_task. If no tasks are ready, the idle task (usually task 0) will run.

The current_task variable cycles through all tasks with the help of the modulus operator, ensuring it loops back to the start when it reaches the end.

Blocking a Task

Global Tick Counter

uint32_t g_tick_count = 0;

We need to keep track of elapsed time. The g_tick_count variable acts as our global tick counter, updated every time the SysTick interrupt occurs. This variable is vital for determining when a blocked task should be moved back to the running state.

Updating the Global Tick Counter

void update_global_tick_count(void)
{
    g_tick_count++;
}

This function increments the global tick counter. It's usually called within the SysTick interrupt handler, essentially acting as our timekeeping method.

Scheduling Function

void schedule(void)
{
    uint32_t *pICSR = (uint32_t *)0xE000ED04;
    *pICSR |= (1 << 28);
}

The function triggers a PendSV exception, which will cause the scheduler to take control and switch tasks. This is done when we know a task's state has changed and it should no longer continue executing.

Disabling and Enabling Interrupts

#define INTERRUPT_DISABLE() do{__asm volatile ("MOV R0,#0x1"); asm volatile("MSR PRIMASK,R0"); } while(0)
#define INTERRUPT_ENABLE() do{__asm volatile ("MOV R0,#0x0"); asm volatile("MSR PRIMASK,R0"); } while(0)

To avoid race conditions when we're modifying shared variables like g_tick_count or task states, interrupts are temporarily disabled. This ensures that these operations are atomic.

Implementing task_delay()

void task_delay(uint32_t tick_count)
{
    INTERRUPT_DISABLE();
    if (current_task)
    {
        user_tasks[current_task].block_count = g_tick_count + tick_count;
        user_tasks[current_task].current_state = TASK_BLOCKED_STATE;
        schedule();
    }
    INTERRUPT_ENABLE();
}

The function task_delay sets a task's state to blocked and specifies how long it will be blocked in terms of ticks. It uses g_tick_count to set the time when the task should be unblocked.

Unblocking Tasks

void unblock_tasks(void)
{
    for (int i = 1; i < MAX_TASKS; i++)
    {
        if (user_tasks[i].current_state != TASK_READY_STATE)
        {
            if (user_tasks[i].block_count == g_tick_count)
            {
                user_tasks[i].current_state = TASK_READY_STATE;
            }
        }
    }
}

The unblock_tasks function scans through all tasks to see if any blocked task's block_count matches the current g_tick_count. If it does, that task is moved back to the ready state.

SysTick Handler

void SysTick_Handler(void)
{
    uint32_t *pICSR = (uint32_t *)0xE000ED04;
    update_global_tick_count();
    unblock_tasks();
    *pICSR |= (1 << 28);
}

The SysTick handler is the function that's called whenever the SysTick interrupt occurs. It updates the global tick count and then checks if any tasks need to be unblocked. Finally, it pends a PendSV exception to potentially switch tasks.

The Collective Dance

  1. SysTick Fires: Increment g_tick_count and see if any tasks need to be unblocked.
  2. Task Runs: When it has nothing to do, it calls task_delay() to block itself.
  3. Schedule: Switch to the next task that is in the TASK_READY_STATE.
  4. Repeat: Continue this process indefinitely.

13. Collective Dance

In our code, we have four tasks, each responsible for turning on and off a specific LED with different frequencies. Here’s how each task works:

  • task1_handler: Turns the green LED on and off every 1000 ticks.
  • task2_handler: Turns the orange LED on and off every 500 ticks.
  • task3_handler: Turns the blue LED on and off every 250 ticks.
  • task4_handler: Turns the red LED on and off every 125 ticks.

All tasks use the task_delay function to block themselves for a specific period, allowing other tasks to execute. When a task calls task_delay(tick_count), it sets its state to "blocked" and updates the block_count for that task. The task will remain blocked until the global tick counter g_tick_count matches the task's block_count. Once this happens, the task will transition back to the "ready" state and will be available for scheduling again.

For example, now we implement the task1_handler function:

void task1_handler(void)
{
    while (1)
    {
        led_on(LED_GREEN);
        task_delay(1000);
        led_off(LED_GREEN);
        task_delay(1000);
    }
}

How the task_delay function works

The task_delay function accepts a tick_count and performs the following steps: 1. Disables interrupts to ensure atomicity. 2. Checks if the current task is not the idle task (task 0). 3. Updates the block_count and sets the task's state to "blocked." 4. Calls schedule() to trigger a context switch. 5. Enables interrupts back.

Overall, the task_delay function allows you to simulate different LED blinking frequencies by blocking tasks for different periods. This demonstrates a very rudimentary form of cooperative multitasking, where each task gives up control voluntarily to allow other tasks to run.


14. Final Code

main.h:

#ifndef MAIN_H_
#define MAIN_H_

#define MAX_TASKS 5
#define SIZE_TASK_STACK 1024U
#define SIZE_SCHED_STACK 1024U

#define SRAM_START 0x20000000U
#define SIZE_SRAM (128 * 1024)
#define SRAM_END (SRAM_START + SIZE_SRAM)

#define T1_STACK_START SRAM_END
#define T2_STACK_START (SRAM_END - (1 * SIZE_TASK_STACK))
#define T3_STACK_START (SRAM_END - (2 * SIZE_TASK_STACK))
#define T4_STACK_START (SRAM_END - (3 * SIZE_TASK_STACK))
#define IDLE_STACK_START (SRAM_END - (4 * SIZE_TASK_STACK))
#define SCHED_STACK_START (SRAM_END - (5 * SIZE_TASK_STACK))

#define TICK_HZ 1000U
#define HSI_CLOCK 16000000U
#define SYSTICK_TIM_CLK HSI_CLOCK

#define DUMMY_XPSR 0x01000000U

#define TASK_READY_STATE 0x00
#define TASK_BLOCKED_STATE 0xFF

#define INTERRUPT_DISABLE() do{__asm volatile ("MOV R0,#0x1"); asm volatile("MSR PRIMASK,R0"); } while(0)
#define INTERRUPT_ENABLE() do{__asm volatile ("MOV R0,#0x0"); asm volatile("MSR PRIMASK,R0"); } while(0)

#endif /* MAIN_H_ */

main.c:

#include <stdio.h>
#include "main.h"
#include "led.h"
#include "imt_debug.h"

// Function prototypes for tasks
void task1_handler(void);
void task2_handler(void);
void task3_handler(void);
void task4_handler(void);

void init_systick_timer(uint32_t tick_hz);
void init_scheduler_stack(uint32_t sched_top_of_stack) __attribute__((naked));
void init_tasks_stack(void);
void enable_processor_faults(void);
void switch_sp_to_psp(void) __attribute__((naked));
uint32_t get_psp_value(void);
void task_delay(uint32_t tick_count);

// Current task running
uint8_t current_task = 1;
uint32_t g_tick_count = 0;

typedef struct {
    uint32_t psp_value;
    uint32_t block_count;
    uint8_t  current_state;
    void (*task_handler)(void);
} TCB_t;

TCB_t user_tasks[MAX_TASKS];

int main(void) {
    enable_processor_faults();
    init_scheduler_stack(SCHED_STACK_START);
    init_tasks_stack();
    led_init_all();
    init_systick_timer(TICK_HZ);
    switch_sp_to_psp();
    task1_handler();
    while(1);
}

void idle_task(void)
{
    while (1);
}

void task1_handler(void)
{
    while (1)
    {
        led_on(LED_GREEN);
        task_delay(1000);
        led_off(LED_GREEN);
        task_delay(1000);
    }
}

void task2_handler(void)
{
    while (1)
    {
        led_on(LED_ORANGE);
        task_delay(500);
        led_off(LED_ORANGE);
        task_delay(500);
    }
}

void task3_handler(void)
{
    while (1)
    {
        led_on(LED_BLUE);
        task_delay(250);
        led_off(LED_BLUE);
        task_delay(250);
    }
}

void task4_handler(void)

{
    while (1)
    {
        led_on(LED_RED);
        task_delay(125);
        led_off(LED_RED);
        task_delay(125);
    }
}

void init_systick_timer(uint32_t tick_hz)
{
    uint32_t *pSRVR = (uint32_t *)0xE000E014;
    uint32_t *pSCSR = (uint32_t *)0xE000E010;

    /* calculation of reload value */
    uint32_t count_value = (SYSTICK_TIM_CLK / tick_hz) - 1;

    // Clear the value of SVR
    *pSRVR &= ~(0x00FFFFFFFF);

    // load the value in to SVR
    *pSRVR |= count_value;

    // do some settings
    *pSCSR |= (1 << 1); // Enables SysTick exception request:
    *pSCSR |= (1 << 2); // Indicates the clock source, processor clock source

    // enable the systick
    *pSCSR |= (1 << 0); // enables the counter
}

__attribute__((naked)) void init_scheduler_stack(uint32_t sched_top_of_stack)
{
    __asm volatile("MSR MSP,%0"
                   :
                   : "r"(sched_top_of_stack)
                   :);
    __asm volatile("BX LR");
}

/* this function stores dummy stack contents for each task */
void init_tasks_stack(void)
{

    user_tasks[0].current_state = TASK_READY_STATE;
    user_tasks[1].current_state = TASK_READY_STATE;
    user_tasks[2].current_state = TASK_READY_STATE;
    user_tasks[3].current_state = TASK_READY_STATE;
    user_tasks[4].current_state = TASK_READY_STATE;

    user_tasks[0].psp_value = IDLE_STACK_START;
    user_tasks[1].psp_value = T1_STACK_START;
    user_tasks[2].psp_value = T2_STACK_START;
    user_tasks[3].psp_value = T3_STACK_START;
    user_tasks[4].psp_value = T4_STACK_START;

    user_tasks[0].task_handler = idle_task;
    user_tasks[1].task_handler = task1_handler;
    user_tasks[2].task_handler = task2_handler;
    user_tasks[3].task_handler = task3_handler;
    user_tasks[4].task_handler = task4_handler;

    uint32_t *pPSP;

    for (int i = 0; i < MAX_TASKS; i++)
    {
        pPSP = (uint32_t *)user_tasks[i].psp_value;

        pPSP--;
        *pPSP = DUMMY_XPSR; // 0x01000000

        pPSP--; // PC
        *pPSP = (uint32_t)user_tasks[i].task_handler;

        pPSP--; // LR
        *pPSP = 0xFFFFFFFD;

        for (int j = 0; j < 13; j++)
        {
            pPSP--;
            *pPSP = 0;
        }

        user_tasks[i].psp_value = (uint32_t)pPSP;
    }
}

void enable_processor_faults(void)
{
    uint32_t *pSHCSR = (uint32_t *)0xE000ED24;

    *pSHCSR |= (1 << 16); // mem manage
    *pSHCSR |= (1 << 17); // bus fault
    *pSHCSR |= (1 << 18); // usage fault
}

uint32_t get_psp_value(void)
{
    return user_tasks[current_task].psp_value;
}

void save_psp_value(uint32_t current_psp_value)
{
    user_tasks[current_task].psp_value = current_psp_value;
}

void update_next_task(void)
{
    int state = TASK_BLOCKED_STATE;

    for (int i = 0; i < (MAX_TASKS); i++)
    {
        current_task++;
        current_task %= MAX_TASKS;
        state = user_tasks[current_task].current_state;
        if ((state == TASK_READY_STATE) && (current_task != 0))
            break;
    }

    if (state != TASK_READY_STATE)
        current_task = 0;
}

__attribute__((naked)) void switch_sp_to_psp(void)
{
    // 1. initialize the PSP with TASK1 stack start address

    // get the value of psp of current_task
    __asm volatile("PUSH {LR}"); // preserve LR which connects back to main()
    __asm volatile("BL get_psp_value");
    __asm volatile("MSR PSP,R0"); // initialize psp
    __asm volatile("POP {LR}");   // pops back LR value

    // 2. change SP to PSP using CONTROL register
    __asm volatile("MOV R0,#0X02");
    __asm volatile("MSR CONTROL,R0");
    __asm volatile("BX LR");
}

void schedule(void)
{
    // pend the pendsv exception
    uint32_t *pICSR = (uint32_t *)0xE000ED04;
    *pICSR |= (1 << 28);
}

void task_delay(uint32_t tick_count)
{
    // disable interrupt
    INTERRUPT_DISABLE();

    if (current_task)
    {
        user_tasks[current_task].block_count = g_tick_count + tick_count;
        user_tasks[current_task].current_state = TASK_BLOCKED_STATE;
        schedule();
    }

    // enable interrupt
    INTERRUPT_ENABLE();
}

__attribute__((naked)) void PendSV_Handler(void)
{

    /*Save the context of current task */

    // 1. Get current running task's PSP value
    __asm volatile("MRS R0,PSP");
    // 2. Using that PSP value store SF2( R4 to R11)
    __asm volatile("STMDB R0!,{R4-R11}");
    __asm volatile("PUSH {LR}");

    // 3. Save the current value of PSP
    __asm volatile("BL save_psp_value");

    /*Retrieve the context of next task */

    // 1. Decide next task to run
    __asm volatile("BL update_next_task");

    // 2. get its past PSP value
    __asm volatile("BL get_psp_value");

    // 3. Using that PSP value retrieve SF2(R4 to R11)
    __asm volatile("LDMIA R0!,{R4-R11}");

    // 4. update PSP and exit
    __asm volatile("MSR PSP,R0");
    __asm volatile("POP {LR}");
    __asm volatile("BX LR");
}

void update_global_tick_count(void)
{
    g_tick_count++;
}

void unblock_tasks(void)
{
    for (int i = 1; i < MAX_TASKS; i++)
    {
        if (user_tasks[i].current_state != TASK_READY_STATE)
        {
            if (user_tasks[i].block_count == g_tick_count)
            {
                user_tasks[i].current_state = TASK_READY_STATE;
            }
        }
    }
}

void SysTick_Handler(void)
{
    uint32_t *pICSR = (uint32_t *)0xE000ED04;
    update_global_tick_count();
    unblock_tasks();

    // pend the pendsv exception
    *pICSR |= (1 << 28);
}

// 2. implement the fault handlers
void HardFault_Handler(void)
{
    printf("Exception : Hardfault\n");
    while (1);
}

void MemManage_Handler(void)
{
    printf("Exception : MemManage\n");
    while (1);
}

void BusFault_Handler(void)
{
    printf("Exception : BusFault\n");
    while (1);
}