EidosOS 实现(1)-任务切换
从零实现实时操作系统EidosOS系列!
1、摘要
在一个抢占式实时操作系统中,任务调度机制决定了系统的整体运行效率与响应能力,而任务控制块(Task Control Block,TCB)则是这一机制的核心载体。它不仅描述了任务的基本属性,还承担了任务切换、状态管理以及阻塞控制等关键职责。在实际实现过程中,TCB 并非一开始就具备完整结构,而是随着调度需求的不断增加逐步演化而来。本文将围绕 EdiosOS 的实现过程,从最简任务模型出发,结合时间片调度与延时阻塞机制,逐步构建出一个面向调度系统的 TCB 结构,并分析各阶段设计背后的动机与取舍。
2、TCB
在完成基础调度机制的实现后,EdiosOS 中用于任务管理的任务控制块(TCB)结构如下:
typedef struct TCB
{
uint32_t *sp; // 堆栈指针(必须位于首位)
int priority; // 任务优先级
uint32_t tick_count; // 时间片计数
task_state_t state; // 任务状态
ListItem_t stateListItem; // 用于挂入调度相关链表(ready/delay)
ListItem_t eventListItem; // 用于挂入事件等待链表(如信号量)
} TCB_t;
该结构围绕“任务调度”这一核心目标设计,既包含任务的基本属性,也为后续扩展阻塞与通信机制预留了必要的数据结构。下面介绍一下各字段的设计意图:
uint32_t *sp:该字段用于保存当前任务的堆栈指针,是任务上下文切换的核心数据。在进行任务切换时,处理器寄存器的现场信息会被压入任务栈中,而sp则指向当前栈顶位置。int priority:该字段用于实现不同任务的优先级排序,为优先级调度算法提高基础,目前实现中优先级越高的任务优先获取CPU使用权uint32_t tick_count:时间片计数,用于分配每个任务占用CPU的时间片数量。时间片用完后必须进行调度选择下一个任务task_state_t state:任务状态,描述当前任务所处的状态,不同的状态可以参与的CPU抢占规则不同,现在只有两种状态:READY:就绪态,任务准备就绪,随时可以分配CPU使用权进行工作BLOCKING:阻塞态,任务由于其他原因阻塞(延时、同步、互斥等),不能参与调度。
- ` ListItem_t stateListItem`:调度链表项,用于挂在各种调度相关的链表中,比如在就绪链表下可以等待调度,在延时链表下可以在时钟中断判断是否到时间,还有信号量链表或互斥链表等等
ListItem_t eventListItem:事件链表项,与调度链表项类似,只不过用于事件相关的链表中
3、TCB最小实现
3.1sp从何而来
在开始之前,我们先思考一个问题:在两个任务来回切换的过程中,如何保证切换回来时还能从中断的位置继续执行?
类比我们生活中的其他经历,比如最简单的看书,如果看书过程中离开一段时间回来后再看可能会忘记之前读到了哪一页,这时书签或者折页就起到了作用——它帮我们“记录”了中断的位置,从而能够快速“恢复”。
在程序中也是类似的。以函数调用为例,函数返回之后之所以能够继续执行调用点的下一行代码,是因为在调用过程中,已经提前将返回地址(PC)以及必要的上下文信息保存了下来,通常是保存在栈中。等函数执行完毕,再从栈中恢复这些信息,于是程序得以无缝衔接地继续运行。
这些例子比比皆是,本质在做同一件事,都是用一个东西来保存我中断了的事情,在做完其他事情之后,有助于我迅速恢复到被中断的事情当中。即在中断发生时保存现场,在恢复时还原现场。
那么,回到任务切换的问题:一个任务的“现场”究竟包含哪些内容?任务需要保存和恢复的是什么呢?
从本质上讲,就是CPU在执行该任务时所依赖的所有关键寄存器状态,例如PC、xPSR、LR以及通用寄存器R0–R12等。在Cortex-M架构中,其中一部分寄存器(如PC、xPSR、LR、R0–R3、R12)会在异常发生时由硬件自动压栈,而剩余的寄存器(如R4–R11)则通常由RTOS在任务切换时手动保存。
接下来问题是:这些被保存的“现场”应该存放在哪里?
肯定不能放在同一个内存区域中,否则会发生覆盖或者冲突,因为真实的系统中不止两个任务,所以每个任务都需要一块区域来保存。而在Cortex-M架构中,CPU的异常处理机制本身就是基于栈来完成上下文保存与恢复的,压栈和出栈操作都由硬件直接参与。因此,为每个任务分配一段独立的栈空间,就成为一种自然且高效的选择。
进一步来看,任务切换的本质其实可以归结为一句话: 切换任务,本质上就是切换栈指针(SP)
因为一个任务的全部执行现场,都已经保存在它自己的栈中,而栈的位置正是由SP所指示的。当我们切换SP时,CPU随后的出栈操作就会自动恢复出另一个任务的上下文,从而实现任务的无缝切换。
正因为如此,在任务控制块(TCB)中,最关键的信息之一就是该任务当前的栈指针(sp)。RTOS只需要在任务切换时保存和恢复这个sp,就能够间接完成整个任务上下文的切换。也正是这个原因,sp通常被放在TCB的第一个字段中,以便在调度和切换时能够以最高效的方式访问和更新。
3.2最小任务切换实现
那我们来看看TCB中只有任务的栈指针时,如何实现的两个任务的切换。
typedef struct TCB
{
uint32_t *sp; // 堆栈指针(必须位于首位)
} TCB_t;
只有一个sp字段
前面提到了任务的现场是一些寄存器,而又因为在Cortex-M架构中,其中一部分寄存器(如PC、xPSR、LR、R0–R3、R12)会在异常发生时由硬件自动压栈和出栈,所以在切换时我们只需要软件实现其他寄存器的压栈和出栈即可。下面是一段伪代码写的上下文切换函数:
void xPortPendSVHandler( void )
{
取当前任务栈SP1
拿到当前TCB1
保存寄存器R4-R11
保存SP到TCB1
调度器选择下一TCB2
取新的任务栈SP2
恢复寄存器R4-R11
更新PSP为SP2
调用R14即LR异常返回
}
用汇编语言写出来大概是下面这样:(各RTOS的实现可能有些不同)
void xPortPendSVHandler( void )
{
/* This is a naked function. */
__asm volatile
(
" mrs r0, psp \n"
" isb \n"
" \n"
" ldr r3, pxCurrentTCBConst \n" /* Get the location of the current TCB. */
" ldr r2, [r3] \n"
" \n"
" stmdb r0!, {r4-r11, r14} \n" /* Save the core registers. */
" str r0, [r2] \n" /* Save the new top of stack into the first member of the TCB. */
" \n"
" stmdb sp!, {r0, r3} \n"
" mov r0, %0 \n"
" cpsid i \n" /* Errata workaround. */
" msr basepri, r0 \n"
" dsb \n"
" isb \n"
" cpsie i \n" /* Errata workaround. */
" bl vTaskSwitchContext \n"
" mov r0, #0 \n"
" msr basepri, r0 \n"
" ldmia sp!, {r0, r3} \n"
" \n"
" ldr r1, [r3] \n" /* The first item in pxCurrentTCB is the task top of stack. */
" ldr r0, [r1] \n"
" \n"
" ldmia r0!, {r4-r11, r14} \n" /* Pop the core registers. */
" \n"
" msr psp, r0 \n"
" isb \n"
" \n"
" push { r14 } \n"
" pop { pc } \n"
" \n"
" bx r14 \n"
);
}
至此,任务切换中最核心的一部分上下文切换已经完成了,主要就是前一个任务的状态保存与下一个任务的状态恢复。但这段汇编中有一个vTaskSwitchContext还没有实现,它是任务切换的另一个核心——调度器,即面对这么多的任务,CPU应该选择哪一个运行呢?
3.3最小调度器实现
一个操作系统之所以可以同时运行多个任务,是因为操作系统用调度器不停地从一个任务切换到另一个任务而且每个任务都能分配到时间执行,而调度器的核心功能就是从任务队列中选择一个合适的任务。调度器有很多算法,不过无论是FIFO、时间片轮转、优先级队列或者多级优先级队列等等,调度器的核心就是选择,选择一个该算法下的最优解。由于算法有很多,所以实现的方式也有很多,这里不进行详细的算法介绍,只写一个最简单的两个任务之间切换的调度器。
void* vTaskSwitchContext()
{
if (&tasks[0] == currentTask)
{
currentTask = &tasks[1];
}
else
{
currentTask = &tasks[0];
}
}
3.4创建任务
当我们了解了上下文切换以及调度器后,就可以着手其他的辅助实现了,比如最开始任务是怎么创建的,如何对任务进行初始化呢?任务的栈指针以及那些寄存器怎么设置呢?
任务切换时最重要的是现场状态的保存和恢复,而任务创建时最重要的就是伪造一个假的现场,即把栈顶的那些元素写成假的寄存器状态(赋0值),然后等待调度时进行处理。
uint32_t* myInitialiseStack(uint32_t *topOfStack, void (*taskFunc)(void *), void *param) {
// 1. 模拟硬件自动压栈的 8 个寄存器
topOfStack--; // 向下增长
*topOfStack = 0x01000000; // xPSR: 必须设置 Thumb 位
topOfStack--;
*topOfStack = (uint32_t)taskFunc; // PC: 任务的入口地址!
topOfStack--;
*topOfStack = (uint32_t)prvError; // LR: 任务万一退出了去哪
// 中间跳过 R12, R3, R2, R1 (4个)
topOfStack -= 4;
topOfStack--;
*topOfStack = (uint32_t)param; // R0: 任务函数的参数
// 2. 模拟软件手动压栈的部分
topOfStack--;
*topOfStack = 0xFFFFFFFD; // EXC_RETURN: 切换模式的魔法值
// 剩下的 R4-R11 (8个)
topOfStack -= 8;
return topOfStack; // 返回当前的栈顶指针,存进任务控制块(TCB)
}
里面有几个重要的寄存器设置:
- xPSR:设置0x01000000为Thumb位,必须设置如此
- PC:程序计数器,任务的入口地址
- LR:任务退出错误处理函数(一般是while循环)
- R14:EXC_RETURN: 切换模式的魔法值
注意这个初始化和任务切换之间有一个R14寄存器是有关联的,如果任务切换的时候保存和恢复任务状态时对R14进行了压栈和出栈操作,如
stmdb r0!, {r4-r11, r14},那么初始化要赋值;若没有,只是stmdb r0!, {r4-r11}那就不需要赋值,可以用其他方法实现R14寄存器的功能
创建任务时直接根据栈地址加栈大小获取栈顶指针,然后调用初始化函数即可。
int task_init(void (*func)(void *), TCB_t *task, void *const pvParameters, uint32_t stackSize)
{
uint32_t stack_addr = (uint32_t)stack + stackSize;
uint32_t *stack_top = (uint32_t *)stack_addr;
// 初始化任务栈
task->sp = myInitialiseStack(stack_top, func, pvParameters);
}
return 0;
}
到此为止,任务创建已经完成,接下来就是如何运行任务了。
3.5开启第一个任务
创建好了任务后,怎么开始呢?我们从裸机的main函数是如何开启多任务的呢?这里用到了svc中断,通过与PendSV类似的保存和恢复现场的操作调用第一个任务执行。下面两个函数用于第一个任务的开启
void vPortSVCHandler( void )
{
__asm volatile (
" ldr r3, pxCurrentTCBConst2 \n" /* Restore the context. */
" ldr r1, [r3] \n" /* Use pxCurrentTCBConst to get the pxCurrentTCB address. */
" ldr r0, [r1] \n" /* The first item in pxCurrentTCB is the task top of stack. */
" ldmia r0!, {r4-r11, r14} \n" /* Pop the registers that are not automatically saved on exception entry and the critical nesting count. */
" msr psp, r0 \n" /* Restore the task stack pointer. */
" isb \n"
" mov r0, #0 \n"
" msr basepri, r0 \n"
" bx r14 \n"
" \n"
" .align 4 \n"
"pxCurrentTCBConst2: .word pxCurrentTCB \n"
);
}
/*-----------------------------------------------------------*/
static void prvPortStartFirstTask( void )
{
/* Start the first task. This also clears the bit that indicates the FPU is
in use in case the FPU was used before the scheduler was started - which
would otherwise result in the unnecessary leaving of space in the SVC stack
for lazy saving of FPU registers. */
__asm volatile(
" ldr r0, =0xE000ED08 \n" /* Use the NVIC offset register to locate the stack. */
" ldr r0, [r0] \n"
" ldr r0, [r0] \n"
" msr msp, r0 \n" /* Set the msp back to the start of the stack. */
" mov r0, #0 \n" /* Clear the bit that indicates the FPU is in use, see comment above. */
" msr control, r0 \n"
" cpsie i \n" /* Globally enable interrupts. */
" cpsie f \n"
" dsb \n"
" isb \n"
" svc 0 \n" /* System call to start first task. */
" nop \n"
);
}
首先,单片机正常运行在MSP栈中,prvPortStartFirstTask的目的是恢复MSP栈,开启SVC中断。然后在vPortSVCHandler中断处理函数中,找到当前的任务TCB,恢复它的寄存器状态(之前初始化的现场状态),然后开始执行任务。
3.6最后一步
所有的准备工作都已经完成了,在main函数上写几个任务的初始化task_init,然后调用开启第一个任务prvPortStartFirstTask就可以跑起来了,但是你会发现任务一直在跑其中第一个,根本不会切换。因为我们只实现了怎样切换,还没有实现为什么切换,是任务主动出让CPU,还是时间片?这些我们都还没有实现,所有任务不会进行切换。现在我们只实现一个最小的切换原因,就是主动出让CPU的函数yield。
来思考一下下面的实现是对的吗?主动出让CPU的函数直接调用xPortPendSVHandler即可:
void yield()
{
xPortPendSVHandler();
}
你会发现在这段代码添加之后,在任务中调用会直接进入HardFault中断。原因是正常情况下如果是中断触发xPortPendSVHandler进行调用,首先硬件会主动压栈一部分寄存器,然后函数再压栈一部分。而如果直接调用函数,不经过中断,那么硬件不会压栈,所以只有函数压栈了一部分寄存器,导致寄存器值恢复异常,进入HardFault中断。所以正确的做法如下:
#define SCB_ICSR_PENDSVSET_Msk (1UL << 28U)
void yield()
{
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 触发 PendSV
}
这样就可以触发硬件的自动压栈和恢复,可以与初始化时栈的数量对应上,不会报错了
4、时间片轮转
最小实现中采用任务主动出让的方式进行任务切换,但这种方法不是RTOS中最常用的,下面实现一种最常用的任务切换的方式,时间片轮转。为每个任务分配相同的时间片,到时间后进入PendSV中断,然后进行任务切换。目前实现的方式是全局变量system_tick,所有任务都是1000个时间片,到时间后进行任务切换。
void SysTick_Handler(void)
{
system_tick++;
if(system_tick == 1000)
{
system_tick = 0;
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
HAL_IncTick();
}
5、实现TaskDelay
最简单的实现中任务是一直可以参与到任务调度的,而RTOS中会给予任务一种状态,只有在就绪态的任务才可以参与 到任务调度中,所以接下来我们为任务TCB添加状态字段,并实现一个简单的TaskDelay功能,它可以主动让任务阻塞一段时间,以达到不参与调度的目的。
首先在TCB中添加任务状态与任务延迟时间:
typedef enum { READY, RUNNING, BLOCKED } task_state_t;
typedef struct TCB
{
uint32_t *sp; // 堆栈指针,必须在第一个位置,以便在上下文切换时正确保存和恢复
uint32_t delay; // 任务延迟时间
task_state_t state;//任务状态
}TCB_t;
然后实现TaskDelay函数:
void task_delay(uint32_t ticks)
{
if (currentTCB != NULL)
{
currentTCB->delay = ticks;
currentTCB->state = BLOCKED; // 设置为阻塞状态
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 触发 PendSV 进行任务切换
}
}
延迟函数中设置当前TCB的延迟时间,状态设置为阻塞,然后触发PendSV进行任务切换。然后在SysTick中断处理中,每次都要判断这些阻塞的任务是否到时间可以变成就绪态。
void SysTick_Handler(void)
{
/* 新增内容 */
for (int i = 0; i < MAX_TASKS; i++)
{
if (tasks[i].state == BLOCKED)
{
if (tasks[i].delay > 0)
{
tasks[i].delay--;
if (tasks[i].delay == 0)
{
tasks[i].state = READY;
}
}
}
}
//...
}
由于添加了任务状态,所以调度器中不能遍历所有的任务,只能遍历就绪态的任务,改动如下:
void vTaskSwitchContext()
{
for(int i = 0; i < MAX_TASKS; i++)
{
if(currentTCB == &tasks[i])
{
continue; // 跳过当前任务
}
if (tasks[i].state == READY)
{
currentTCB = &tasks[i];
break;
}
}
return;
}
实现了TaskDelay之后,任务中可以加入延迟函数让其阻塞,但是需要注意需要额外加入一个task_idle的空闲任务,否则如果都阻塞,调度器可以选不到任务而出现异常
6、任务初始化优化
在最小实现中维护一个最大任务数的TCB数组,每次新增任务时,就向里面增加一个,这种方式弊端很大,实现不够灵活,所以进行优化,采用malloc的方式分配一段内存空间给TCB。
int task_init(void (*func)(void *), int priority, void *const pvParameters, uint32_t stackSize)
{
static int currentTaskNum;
if (currentTaskNum >= MAX_TASKS)
{
return -1;
}
TCB_t *newTask = malloc(sizeof(TCB_t));
if (newTask == NULL)
{
return -1;
}
uint32_t *stack = malloc(stackSize);
if (stack == NULL)
{
free(newTask);
return -1;
}
else
{
uint32_t stack_addr = (uint32_t)stack + stackSize;
uint32_t *stack_top = (uint32_t *)stack_addr;
newTask->sp = myInitialiseStack(stack_top, func, pvParameters);
newTask->state = READY;
// newTask->priority = priority;
tasks[currentTaskNum] = newTask;
}
currentTaskNum++;
return 0;
}
虽然用malloc的方式分配了内存给TCB,但是还需要一个全局TCB指针来管理多任务。
在修改用malloc实现初始化的时候遇到了严重问题,无法正常运行。是因为Systick中断处理函数中一直在用currentTCB进行判断,以及数组里的TCB进行判断。之前全局变量的形式不会出现问题,因为程序一开始就已经初始化成功了,而用malloc的方式初始化时,在未分配时,指针为NULL,这时Systick中断处理函数使用指针出现了异常。
7、优先级调度算法
实现高优先级优先调度与同优先级流转调度
下一步实现更高级的调度算法,任务根据优先级大小进行调度,任务优先级越高,越优先获取CPU使用权,相同优先级实现时间片的流转调度。主要修改在函数vTaskSwitchContext中。
typedef struct TCB
{
uint32_t *sp; // 堆栈指针,必须在第一个位置,以便在上下文切换时正确保存和恢复
int priority;
uint32_t delay; // 任务延迟时间
task_state_t state;
}TCB_t;
void vTaskSwitchContext()
{
int maxPriority = -1;
static uint16_t next = 0; // 用于记录下一个任务的索引
// 寻找就绪任务中最大优先级
for (int i = 0; i < MAX_TASKS; i++)
{
if (tasks[i] && tasks[i]->state == READY)
{
if (maxPriority < tasks[i]->priority)
{
maxPriority = tasks[i]->priority;
}
}
}
// 同优先级流转
for (int i = 0; i < MAX_TASKS; i++)
{
next = (next + 1) % MAX_TASKS;
if (tasks[next] && tasks[next]->state == READY && maxPriority == tasks[next]->priority)
{
currentTCB = tasks[next];
break; // 找到优先级最高且未完成的任务并跳出循环,以继续执行下一个任务
}
}
return;
}
调度算法中首先选择就绪态的最大优先级,然后根据优先级同级时间片流转调度
8、抢占!
抢占的含义是任何高优先级的任务可以打断低优先级的任务,而这种情况下一般发生在高优先级任务从阻塞恢复就绪态时,正在执行的任务为低优先级任务。目前我们的实现中任务状态的切换只在任务延时以及延时结束后,只有在Systick中断服务函数中从阻塞恢复就绪态,所以只需要改一下这里即可。
void SysTick_Handler(void)
{
for (int i = 0; i < MAX_TASKS; i++)
{
if (NULL != tasks[i] && tasks[i]->state == BLOCKED)
{
if (tasks[i]->delay > 0)
{
tasks[i]->delay--;
if (tasks[i]->delay == 0)
{
tasks[i]->state = READY;
// 如果新就绪的任务优先级高于当前正在运行的任务,则触发 PendSV 进行任务切换
if(tasks[i]->priority > currentTCB->priority)
{
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk; // 触发 PendSV 进行任务切换
}
}
}
}
}
//...
}
9、结语
到此为止,最小的TCB与任务调度已经初步完成,所有的实现都没有考虑性能问题,只是功能上能实现而已。除了SVC_Handler、PendSV_Handler、prvPortStartFirstTask等函数以后基本不变之外,其他函数随着功能的复杂以及性能的优化都需要很大的变动。我们可以看到每次Systick以及任务调度都需要O(n)的算法,这些可能都是后续要优化的地方,不过目前我们只需关注功能上的实现即可。