Previously in Part 5, we initialized the power management system, giving our STM32F429ZI the ability to sleep and wake up efficiently. Now we’re ready to initialize the task scheduler – the system that will orchestrate multiple tasks running at different rates.
Where We Are in the Code
PowerMgmt_Configure() has just returned to main(), and execution continues to the next line:
/* Initialize the task scheduler */
TaskScheduler_Init();
This single function call sets up the infrastructure for managing multiple concurrent tasks without using a full Real-Time Operating System (RTOS).
What is a Task Scheduler?
Before diving into the code, let’s understand what we’re building:
A task scheduler is a system that manages the execution of multiple functions (tasks) at specified intervals. Think of it as a conductor orchestrating musicians – each plays their part at the right time.
Our scheduler features:
- Up to 8 concurrent tasks
- Each task has its own execution period (e.g., every 500ms)
- Priority-based execution (HIGH, NORMAL, LOW, IDLE)
- Performance monitoring (execution time, jitter)
- Dynamic task management (start, stop, suspend, resume)
Why not use an RTOS? An RTOS adds complexity and memory overhead. For many embedded applications, a simple cooperative scheduler is sufficient and more predictable.
The Journey Begins: Jumping to TaskScheduler_Init
When the processor encounters TaskScheduler_Init(), it jumps from main.c to the TaskScheduler_Init function in task_scheduler.c:
void TaskScheduler_Init(void) {
/* Clear all task entries */
memset(tasks, 0, sizeof(tasks));
We’re now inside the task scheduler initialization function.
Understanding the Task Data Structure
At the top of task_scheduler.c, there’s a global array:
/* Task array - supports MAX_TASKS concurrent tasks */
static Task_t tasks[MAX_TASKS];
Where MAX_TASKS is defined as 8 in task_scheduler.h. Each task is represented by this structure:
typedef struct {
void (*taskFunc)(void); /* Task function pointer */
uint32_t period_ms; /* Task period in milliseconds */
uint32_t lastExecuted; /* Last execution time */
uint32_t maxExecutionTime; /* Max execution time measured */
uint32_t maxJitter; /* Maximum scheduling jitter */
TaskState_t state; /* Current task state */
uint8_t priority; /* Task priority (0 = highest) */
uint8_t taskId; /* Unique task identifier */
char name[16]; /* Task name for debugging */
} Task_t;
Let’s understand each field:
- taskFunc: A pointer to the function that will be called periodically
- period_ms: How often to run this task (e.g., 500 = every 500ms)
- lastExecuted: Timestamp of last execution (uses systick_counter)
- maxExecutionTime: Longest time this task has taken to execute
- maxJitter: Maximum deviation from scheduled execution time
- state: STOPPED, READY, RUNNING, or SUSPENDED
- priority: 0-3, where 0 is highest priority
- taskId: Unique identifier assigned when registered
- name: Human-readable name for debug messages
Step 1: Clearing the Task Array
/* Clear all task entries */
memset(tasks, 0, sizeof(tasks));
This line clears the entire tasks array. Let’s calculate the size:
- Each Task_t is approximately 40 bytes
- MAX_TASKS = 8
- Total: 8 × 40 = 320 bytes cleared
Memory visualization:
tasks array in RAM:
┌─────────────┐
│ Task 0 │ ← All fields set to 0
├─────────────┤
│ Task 1 │ ← All fields set to 0
├─────────────┤
│ Task 2 │ ← All fields set to 0
├─────────────┤
│ ... │
├─────────────┤
│ Task 7 │ ← All fields set to 0
└─────────────┘
After memset():
- All function pointers are NULL
- All periods are 0
- All states are TASK_STATE_STOPPED (which equals 0)
- All names are empty strings
Step 2: Reset Global Counters
/* Reset counters */
nextTaskId = 0;
registeredTaskCount = 0;
These are static variables at the top of task_scheduler.c:
/* Task counter for unique IDs */
static uint8_t nextTaskId = 0;
/* Registered task count */
static uint8_t registeredTaskCount = 0;
What these do:
- nextTaskId: Increments each time a task is registered, ensuring unique IDs
- registeredTaskCount: Tracks how many tasks are currently registered
Step 3: Debug Message
/* Log initialization */
UART_SendString("\r\n[SCHEDULER] Task scheduler initialized\r\n");
This sends a confirmation message via UART. The message takes about 3.5ms to transmit at 115200 baud.
Step 4: Return to main()
}
TaskScheduler_Init() completes and returns to main(). The scheduler is now ready to accept task registrations.
What Just Happened
While TaskScheduler_Init() seems simple, it has prepared critical infrastructure:
1. Memory Allocation
Unlike dynamic schedulers that use malloc(), our scheduler uses static allocation:
- Predictable: Always uses exactly 320 bytes
- Safe: No memory fragmentation or allocation failures
- Fast: No heap management overhead
2. Clean State
By clearing everything, we ensure:
- No garbage data in task structures
- All tasks start in STOPPED state
- Function pointers are NULL (safe to check)
3. Ready for Registration
The scheduler can now accept up to 8 task registrations. Each task will get:
- A unique ID (starting from 0)
- A slot in the tasks array
- Performance monitoring
Back in main() – Registering Tasks
Right after TaskScheduler_Init() returns, we’re back in main() with an empty scheduler ready to accept tasks. Now main() will register several tasks. Let’s understand this process step by step.
What Does “Registering a Task” Mean?
Think of the scheduler as a manager with 8 empty desks. Registering a task means:
- Assigning a function to one of these desks
- Telling the manager how often to call this function
- Setting the priority (who goes first if multiple are ready)
- Giving it a name for debugging
The First Task Registration
Let’s look at the very first task registration in main():
/* Register demo tasks with different priorities and periods */
uint8_t taskId;
First, main() declares a variable taskId to store the ID that will be assigned to each task.
Now the actual registration:
/* High priority LED task - Green LED */
if (TaskScheduler_RegisterTask(Task_LED1_Blink, 500, TASK_PRIORITY_HIGH, "LED1_Green", &taskId) == TASK_OK) {
TaskScheduler_StartTask(taskId);
}
Let’s break down the parameters:
Task_LED1_Blink– The function to call (blinks green LED)500– Call it every 500 millisecondsTASK_PRIORITY_HIGH– This is priority 0 (highest)"LED1_Green"– A name for debug messages&taskId– Where to store the assigned ID
Step Inside TaskScheduler_RegisterTask
When this function is called, execution jumps to task_scheduler.c. Let’s follow what happens step by step.
Step 1: Check If There’s Room
TaskError_t TaskScheduler_RegisterTask(void (*taskFunc)(void),
uint32_t period_ms,
uint8_t priority,
const char* name,
uint8_t* taskId) {
/* Check if we have space for more tasks */
if (registeredTaskCount >= MAX_TASKS) {
return TASK_ERROR_FULL;
}
First check: Do we have space? We can only handle 8 tasks total.
registeredTaskCountis currently 0 (no tasks yet)MAX_TASKSis 8- So we have space!
Step 2: Validate the Function Pointer
/* Validate function pointer */
if (taskFunc == NULL) {
return TASK_ERROR_INVALID_PARAM;
}
Second check: Is the function pointer valid?
- We’re passing
Task_LED1_Blink, which is a real function - It’s not NULL, so we continue
Step 3: Find an Empty Slot
/* Find empty slot */
uint8_t slot = MAX_TASKS;
for (uint8_t i = 0; i < MAX_TASKS; i++) {
if (tasks[i].state == TASK_STATE_STOPPED && tasks[i].taskFunc == NULL) {
slot = i;
break;
}
}
Now we search for an empty desk in our array of 8 tasks:
- Start at task 0
- Is it empty? (state == STOPPED && taskFunc == NULL)
- Yes! So slot = 0
- Break out of the loop
Step 4: Fill in the Task Information
Now we have slot 0 available. Let’s fill it with our LED task:
/* Initialize task */
tasks[slot].taskFunc = taskFunc; // tasks[0].taskFunc = Task_LED1_Blink
This stores the function pointer. Now tasks[0] knows which function to call.
tasks[slot].period_ms = period_ms; // tasks[0].period_ms = 500
This sets how often to call it – every 500ms.
tasks[slot].lastExecuted = systick_counter; // tasks[0].lastExecuted = current time
This records when the task was registered. If systick_counter is currently 50, then lastExecuted = 50.
tasks[slot].state = TASK_STATE_READY; // tasks[0].state = READY
The task is ready to run (but not running yet).
tasks[slot].priority = priority; // tasks[0].priority = 0 (HIGH)
Priority 0 is the highest priority.
tasks[slot].taskId = nextTaskId++; // tasks[0].taskId = 0, then nextTaskId becomes 1
Assign ID 0 to this task, then increment nextTaskId for the next task.
Step 5: Copy the Name
/* Set name if provided */
if (name != NULL) {
strncpy(tasks[slot].name, name, sizeof(tasks[slot].name) - 1);
tasks[slot].name[sizeof(tasks[slot].name) - 1] = '\0';
}
This code safely copies the task name. Let me explain why it’s written this way:
The Problem with Regular String Copy
In C, strings are just arrays of characters ending with ‘\0’ (null terminator). If we used regular strcpy():
strcpy(tasks[slot].name, name); // DANGEROUS!
What if someone passed a 50-character name but our buffer is only 16 characters? It would write past the end of our array, corrupting memory!
Understanding the Task Name Buffer
In our Task_t structure:
char name[16]; // Can hold 15 characters + 1 null terminator
Breaking Down the Safe Copy
Let’s trace through with our example name “LED1_Green”:
- Check if name was provided:
if (name != NULL) {We’re passing “LED1_Green”, which is not NULL, so we continue. - Copy with size limit:
strncpy(tasks[slot].name, name, sizeof(tasks[slot].name) - 1);tasks[slot].name= destination (where to copy TO)name= source (what we’re copying FROM) = “LED1_Green”sizeof(tasks[slot].name)= 16 (the size of our array)sizeof(tasks[slot].name) - 1= 15 (leave room for ‘\0’)
- Ensure null termination:
tasks[slot].name[sizeof(tasks[slot].name) - 1] = '\0';sizeof(tasks[slot].name) - 1= 15 (the last position in our array)- This line forces position 15 to be ‘\0’
Visual Example
Let’s see what happens in memory:
Case 1: Normal name “LED1_Green” (10 characters)
Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Content: 'L''E''D''1''_''G''r''e''e''n''\0' ? ? ? ? '\0'
↑ ↑
| |
Original null terminator |
We force this to '\0'
Case 2: Long name “SuperLongTaskNameThatWontFit” (28 characters)
Without protection - BUFFER OVERFLOW:
Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18...
Content: 'S''u''p''e''r''L''o''n''g''T''a''s''k''N''a''m''e''T''h'...
↑
|
Writing past our array!
With our protection:
Position: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Content: 'S''u''p''e''r''L''o''n''g''T''a''s''k''N''a''\0'
↑
|
Forced null terminator
The name gets truncated to “SuperLongTaskNa” but our program doesn’t crash!
Why Two Steps?
You might wonder why we need both strncpy AND manually setting ‘\0’:
- strncpy copies up to 15 characters
- If the source string is shorter than 15, strncpy adds ‘\0’ automatically
- If the source string is 15 or longer, strncpy does NOT add ‘\0’
- So we always force position 15 to be ‘\0’ just to be safe
In Our Example
For “LED1_Green”:
- strncpy copies all 10 characters plus ‘\0’
- We force position 15 to ‘\0’ (redundant but safe)
- Result: tasks[0].name contains “LED1_Green\0”
This defensive programming prevents buffer overflows that could crash our system or cause unpredictable behavior.
Step 6: Return the Task ID
/* Return task ID if requested */
if (taskId != NULL) {
*taskId = tasks[slot].taskId; // Store 0 in the taskId variable in main()
}
This stores the assigned ID (0) back in main()’s taskId variable.
Step 7: Update Counter and Log
registeredTaskCount++; // Now equals 1
/* Log registration */
char debug_msg[100];
snprintf(debug_msg, sizeof(debug_msg),
"[SCHEDULER] Registered task '%s' (ID: %d, Period: %lums)\r\n",
tasks[slot].name, tasks[slot].taskId, tasks[slot].period_ms);
UART_SendString(debug_msg);
return TASK_OK;
}
The debug message will show:
[SCHEDULER] Registered task 'LED1_Green' (ID: 0, Period: 500ms)
Back in main() – Starting the Task
After registration succeeds, main() immediately starts the task:
if (TaskScheduler_RegisterTask(...) == TASK_OK) {
TaskScheduler_StartTask(taskId); // taskId is 0
}
Wait, didn’t we already set state to READY? Yes, but StartTask does additional things:
- Resets the lastExecuted time to “now”
- Sends a debug message
- Could do other initialization in the future
What’s Our Task Array Like Now?
After registering the first task:
Task Array:
[0] LED1_Green - Function: Task_LED1_Blink, Period: 500ms, Priority: HIGH, State: READY
[1] Empty
[2] Empty
[3] Empty
[4] Empty
[5] Empty
[6] Empty
[7] Empty
Can We Add Tasks Later?
YES! Tasks can be registered at any time:
- During initialization (what we’re doing now)
- During runtime based on conditions
- In response to user commands
- From within other tasks
The only limit is the total number (8).
The Complete Registration Flow
Let’s see the complete flow for all 6 tasks in main():
// Task 1: Green LED every 500ms (HIGH priority)
TaskScheduler_RegisterTask(Task_LED1_Blink, 500, TASK_PRIORITY_HIGH, "LED1_Green", &taskId);
TaskScheduler_StartTask(taskId); // ID = 0
// Task 2: Blue LED every 1000ms (NORMAL priority)
TaskScheduler_RegisterTask(Task_LED2_Blink, 1000, TASK_PRIORITY_NORMAL, "LED2_Blue", &taskId);
TaskScheduler_StartTask(taskId); // ID = 1
// Task 3: Red LED every 2000ms (NORMAL priority)
TaskScheduler_RegisterTask(Task_LED3_Blink, 2000, TASK_PRIORITY_NORMAL, "LED3_Red", &taskId);
TaskScheduler_StartTask(taskId); // ID = 2
// Task 4: UART status every 3000ms (LOW priority)
TaskScheduler_RegisterTask(Task_UARTStatus, 3000, TASK_PRIORITY_LOW, "UART_Status", &taskId);
TaskScheduler_StartTask(taskId); // ID = 3
// Task 5: System monitor every 5000ms (IDLE priority)
TaskScheduler_RegisterTask(Task_SystemMonitor, 5000, TASK_PRIORITY_IDLE, "System_Monitor", &taskId);
TaskScheduler_StartTask(taskId); // ID = 4
// Task 6: Power saving every 5000ms (IDLE priority)
TaskScheduler_RegisterTask(Task_PowerSaving, 5000, TASK_PRIORITY_IDLE, "Power_Saving", &taskId);
TaskScheduler_StartTask(taskId); // ID = 5
Each registration:
- Finds the next empty slot
- Fills in all the task information
- Assigns the next ID (0, 1, 2, 3, 4, 5)
- Starts the task
- Sends a debug message
Visual: Task Array After All Registrations
Slot | ID | Name | Function | Period | Priority | State
-----|----|-------------- |-------------------|--------|----------|-------
0 | 0 | LED1_Green | Task_LED1_Blink | 500ms | HIGH | READY
1 | 1 | LED2_Blue | Task_LED2_Blink | 1000ms | NORMAL | READY
2 | 2 | LED3_Red | Task_LED3_Blink | 2000ms | NORMAL | READY
3 | 3 | UART_Status | Task_UARTStatus | 3000ms | LOW | READY
4 | 4 | System_Monitor | Task_SystemMonitor| 5000ms | IDLE | READY
5 | 5 | Power_Saving | Task_PowerSaving | 5000ms | IDLE | READY
6 | - | [empty] | NULL | - | - | STOPPED
7 | - | [empty] | NULL | - | - | STOPPED
Other Functions in task_scheduler.c – When Are They Used?
Let’s map out ALL the functions in task_scheduler.c and understand when each one is used in our program flow.
Functions Used During Initialization (Where We Are Now)
Already used:
TaskScheduler_Init()– Called once to set up empty schedulerTaskScheduler_RegisterTask()– Called 6 times to register each taskTaskScheduler_StartTask()– Called 6 times to mark tasks as ready
Functions That Will Be Used in the Main Loop (Coming Next)
Not yet called, but will be soon:
TaskScheduler_RunDispatcher()– The heart of the scheduler!// In main loop:while(1) { TaskScheduler_RunDispatcher(); // This runs continuously // ... handle commands ...}This function:- Checks which tasks are due to run
- Selects highest priority ready task
- Executes it
- Updates performance metrics
Utility Functions Used by the Dispatcher
Called internally by RunDispatcher:
DetermineWakeupSource()– Static helper function- Used after waking from low-power mode
- Checks what caused the wake-up (RTC or pin)
- Performance tracking (inline code in RunDispatcher):
- Measures execution time
- Calculates scheduling jitter
- Updates max values
Management Functions Used During Runtime
Called based on user commands or system events:
TaskScheduler_StopTask(uint8_t taskId)// When user presses 's' in command menu: TaskScheduler_StopTask(taskId); // Stops a running taskTaskScheduler_SuspendTask(uint8_t taskId)// Temporarily pause a task (can be resumed) TaskScheduler_SuspendTask(2); // Suspend the red LED taskTaskScheduler_ResumeTask(uint8_t taskId)// Resume a suspended task TaskScheduler_ResumeTask(2); // Resume the red LED taskTaskScheduler_GetTaskInfo(uint8_t taskId, TaskInfo_t* info)// Get information about a task TaskInfo_t info; TaskScheduler_GetTaskInfo(0, &info); printf("Task %s has run %lu times\n", info.name, info.executionCount);TaskScheduler_GetStatus(TaskSchedulerStatus_t* status)// When user presses 'd' for scheduler status: TaskSchedulerStatus_t status; TaskScheduler_GetStatus(&status); // Prints all task information
Functions That May Never Be Called
Depending on program flow:
TaskScheduler_ResetStats(uint8_t taskId)- Resets performance statistics for a task
- Only called if user chooses to reset stats
TaskScheduler_UnregisterTask(uint8_t taskId)- Removes a task completely from scheduler
- Our demo doesn’t unregister tasks, but it’s available
Visual: Function Call Timeline
Program Start
|
├─> TaskScheduler_Init() ──────────────── Called once
|
├─> TaskScheduler_RegisterTask() ───┐
├─> TaskScheduler_StartTask() ├──── Called 6 times
| (repeated 6 times) ┘ during init
|
├─> Main Loop Starts
| |
| ├─> TaskScheduler_RunDispatcher() ─── Called continuously
| | ├─> Checks all tasks
| | ├─> Executes ready tasks
| | └─> Updates metrics
| |
| ├─> User presses 'd'
| | └─> TaskScheduler_GetStatus() ─── Shows all tasks
| |
| ├─> User presses 's'
| | └─> TaskScheduler_StopTask() ──── Stops a task
| |
| └─> (continues forever)
|
└─> Program never ends (infinite loop)
Why Have Functions We Don’t Use?
Having these additional functions makes the scheduler:
- Flexible – Can adapt to different use cases
- Debuggable – Can inspect task states during development
- Maintainable – Can add/remove tasks dynamically
- Reusable – Same scheduler can work in different projects
Summary: Current Execution State
Right now, we’ve:
- Initialized the scheduler (empty)
- Registered 6 tasks
- Started all 6 tasks (marked as READY)
But we haven’t:
- Actually executed any tasks yet
- Used any management functions
- Entered the main loop
Next: The main loop will start, and TaskScheduler_RunDispatcher() will begin orchestrating task execution based on timing and priorities.
The Complete Task Registration Sequence
Main() registers six tasks in total:
1. Task_LED1_Blink - 500ms period, HIGH priority (Green LED)
2. Task_LED2_Blink - 1000ms period, NORMAL priority (Blue LED)
3. Task_LED3_Blink - 2000ms period, NORMAL priority (Red LED)
4. Task_UARTStatus - 3000ms period, LOW priority (Status messages)
5. Task_SystemMonitor - 5000ms period, IDLE priority (System info)
6. Task_PowerSaving - 5000ms period, IDLE priority (Power management)
Task array after all registrations:
┌─────────────────────┐
│ ID:0 LED1_Green │ HIGH priority, 500ms
├─────────────────────┤
│ ID:1 LED2_Blue │ NORMAL priority, 1000ms
├─────────────────────┤
│ ID:2 LED3_Red │ NORMAL priority, 2000ms
├─────────────────────┤
│ ID:3 UART_Status │ LOW priority, 3000ms
├─────────────────────┤
│ ID:4 System_Monitor │ IDLE priority, 5000ms
├─────────────────────┤
│ ID:5 Power_Saving │ IDLE priority, 5000ms
├─────────────────────┤
│ [empty] │
├─────────────────────┤
│ [empty] │
└─────────────────────┘
How the Scheduler Will Work
After initialization and registration, the scheduler is ready to run. In the main loop, TaskScheduler_RunDispatcher() will be called repeatedly:
while(1) {
/* Run the task dispatcher */
TaskScheduler_RunDispatcher();
/* Handle user input */
if (UART_IsDataAvailable()) {
// Process commands
}
/* Small delay to prevent CPU overload */
SysTick_Delay(1);
}
The Dispatcher Algorithm
TaskScheduler_RunDispatcher() implements a priority-based cooperative scheduler:
- Check all tasks to see which are due to run
- Select highest priority task among those ready
- Execute the selected task
- Update performance metrics
- Return to main loop
Timing Example
Let’s see how tasks execute over time:
Time(ms) | Task Executed | Next Due Times
---------|-------------------|------------------
0 | - | LED1:500, LED2:1000, LED3:2000
500 | LED1 (HIGH) | LED1:1000, LED2:1000, LED3:2000
1000 | LED1 (HIGH) | LED1:1500, LED2:2000, LED3:2000
1000 | LED2 (NORMAL) | LED1:1500, LED2:2000, LED3:2000
1500 | LED1 (HIGH) | LED1:2000, LED2:2000, LED3:2000
2000 | LED1 (HIGH) | LED1:2500, LED2:3000, LED3:4000
2000 | LED2 (NORMAL) | LED1:2500, LED2:3000, LED3:4000
2000 | LED3 (NORMAL) | LED1:2500, LED2:3000, LED3:4000
Notice how when multiple tasks are ready at the same time, higher priority tasks execute first.
Performance Monitoring
The scheduler tracks two key metrics for each task:
1. Execution Time
How long each task takes to complete:
uint32_t startTime = systick_counter;
task->taskFunc(); // Execute task
uint32_t executionTime = systick_counter - startTime;
if (executionTime > task->maxExecutionTime) {
task->maxExecutionTime = executionTime;
}
2. Scheduling Jitter
Deviation from the ideal execution time:
uint32_t scheduledTime = task->lastExecuted + task->period_ms;
uint32_t actualTime = systick_counter;
uint32_t jitter = (actualTime > scheduledTime) ?
(actualTime - scheduledTime) :
(scheduledTime - actualTime);
if (jitter > task->maxJitter) {
task->maxJitter = jitter;
}
These metrics help identify:
- Tasks taking too long
- Scheduling accuracy
- System load issues
Integration with Other Systems
The task scheduler integrates with all previously initialized systems:
SysTick Integration
- All timing based on systick_counter
- 1ms resolution for task periods
- Accurate performance measurements
UART Integration
- Tasks can output debug messages
- Scheduler status can be printed
- Command interface for task control
Power Management Integration
- Power saving task runs periodically
- System can sleep between task executions
- Wake-up resumes normal scheduling
What’s Next in main()
After all tasks are registered and started, main() enters its infinite loop where TaskScheduler_RunDispatcher() orchestrates everything. The system is now fully initialized and operational.
Next: Part 7 – The Main Loop and Task Execution →
This is Part 6 of the STM32 IoT Framework series.
Pingback: Part 5: Finally! Unlock MCU Sleep: Easy Power Secrets - Learn By Building
Pingback: Part 7: Your Simple Guide to the STM32 Main Loop - Learn By Building