
RTOS内核开发实战(3):任务创建、时间片轮转与状态提交,让调度器真正运转
有了 tcb_t 和 ready_queue_t,调度器就已经“能存东西”了,但还不等于“能工作”。
真正让内核运转起来的,是三个关键动作:
- 创建任务时把它合法地放进 runnable 集合。
- 调度器只负责决定“下一个该跑谁”。
- port 层在合适的时机真正提交这次切换。
这版实现里,这三个动作被刻意拆开了。
先看这一层到底在解决什么问题
一、任务创建不是简单 memset,而是一条完整的准入链路
task_create() 的职责不是“把结构体填满”这么简单,它要保证一个新任务在进入调度器之前已经满足所有前置条件:
os_status_t task_create(tcb_t *task, const task_init_config_t *config)
{
os_status_t status = OS_STATUS_OK;
uint8_t scheduler_running = 0U;
if (task == NULL)
{
return OS_STATUS_INVALID_PARAM;
}
if (g_task_system_initialized == 0U)
{
(void)task_system_init();
}
if (task_is_known_to_scheduler(task) != 0U)
{
return OS_STATUS_ALREADY_INITIALIZED;
}
status = task_init(task, config);
if (status != OS_STATUS_OK)
{
return status;
}
ready_queue_insert_tail(&g_task_ready_queue, task);
if (task->sched_node.owner != &g_task_ready_queue.ready_lists[task->priority])
{
task->state = TASK_DELETED;
return OS_STATUS_INSERT_FAILED;
}
scheduler_running = (uint8_t)(g_current_task != NULL);
status = task_schedule();
if (status == OS_STATUS_NO_CHANGE)
{
return OS_STATUS_OK;
}
if ((status == OS_STATUS_SWITCH_REQUIRED) && (scheduler_running == 0U))
{
return OS_STATUS_OK;
}
return status;
}
这里有两个设计非常关键。
第一,task_create() 会在首次创建任务时顺手初始化调度器全局状态。这意味着系统可以不依赖一个额外的“必须先手工 init”的固定启动顺序,使用体验会更接近一个自洽的内核接口。
第二,创建接口会区分“首任务尚未启动”和“系统已经在跑”两种场景。对于首任务阶段,task_schedule() 返回 OS_STATUS_SWITCH_REQUIRED 是正常现象,但不能误报成“运行期抢占”。所以代码用 scheduler_running 做了一次语义消歧。
二、调度器只做选择,不直接切换
当前实现把“选出 next task”和“把 next task 真正切成 current task”分成了两步。task_schedule() 只负责前者:
os_status_t task_schedule(void)
{
os_status_t status = OS_STATUS_OK;
status = task_select_next();
if (status != OS_STATUS_OK)
{
return status;
}
if (g_current_task == NULL)
{
return OS_STATUS_SWITCH_REQUIRED;
}
if (g_next_task != g_current_task)
{
return OS_STATUS_SWITCH_REQUIRED;
}
return OS_STATUS_NO_CHANGE;
}
为什么要这么拆?
- 调度器运行在 C 层,负责根据 ready queue 给出一个确定结果。
- 真正的上下文切换运行在 port 层,通常要配合 PendSV、栈切换和汇编恢复现场。
- 只要把
g_next_task维护好,调度器和硬件相关代码就能通过一个清晰边界协作,而不是互相掺在一起。
这种拆分还带来一个副产品:调试非常直接。
你可以先验证“调度决策对不对”,再验证“切换动作对不对”,两个问题不会缠在一起。
三、状态提交集中在 task_set_current()
既然 task_schedule() 不直接切任务,那么谁来真正修改 g_current_task 和任务状态?答案是 task_set_current():
os_status_t task_set_current(tcb_t *task)
{
if (task == NULL)
{
return OS_STATUS_INVALID_PARAM;
}
if (task_is_valid(task) == 0U)
{
return OS_STATUS_INVALID_STATE;
}
if (task != g_next_task)
{
return OS_STATUS_INVALID_STATE;
}
if (task_is_in_runnable_queue(task) == 0U)
{
return OS_STATUS_INVALID_STATE;
}
if ((task != g_current_task) && (task->state != TASK_READY))
{
return OS_STATUS_INVALID_STATE;
}
if ((g_current_task != NULL) && (g_current_task != task) && (g_current_task->state == TASK_RUNNING))
{
g_current_task->state = TASK_READY;
}
g_current_task = task;
g_current_task->state = TASK_RUNNING;
return OS_STATUS_OK;
}
这段代码把“状态迁移”的约束卡得很紧。
它不允许调用方绕过调度器,直接把任意一个 READY 任务强行设成 current;它只接受“刚刚被选中的 g_next_task”。
这就是一个很典型的内核设计原则:所有状态提交都尽量经过单一入口,避免多个路径同时拥有写状态的权力。
四、时间片轮转的前提不是 tick,而是 runnable 集合语义要先统一
很多人写 round-robin 时一上来就看 SysTick,但当前实现先把“同优先级任务如何在 ready list 中轮转”定义清楚了。
主动让出 CPU 的逻辑很直接:
os_status_t task_yield(void)
{
os_status_t status = OS_STATUS_OK;
list_t *ready_list = NULL;
status = task_validate_running_task(g_current_task);
if (status != OS_STATUS_OK)
{
return status;
}
ready_list = task_get_ready_list_by_priority(g_current_task->priority);
if (ready_list == NULL)
{
return OS_STATUS_INVALID_STATE;
}
g_current_task->time_slice = g_current_task->time_slice_reload;
if ((ready_list->item_count > 1U) && (ready_list->head == &g_current_task->sched_node))
{
ready_queue_rotate(&g_task_ready_queue, g_current_task->priority);
}
return task_schedule();
}
这里的关键点不在于“恢复时间片”,而在于只在当前任务确实位于本优先级队头,且同优先级还有其他任务时,才执行头尾轮转。
也就是说,轮转动作本身就是对 ready queue 语义的消费。
五、为什么 tick 里先调度,再看时间片
时间片 tick 逻辑也采用相同原则:
os_status_t task_time_slice_tick(void)
{
os_status_t status = OS_STATUS_OK;
list_t *ready_list = NULL;
tcb_t *current_task = g_current_task;
if (g_task_system_initialized == 0U)
{
return OS_STATUS_NOT_INITIALIZED;
}
if (current_task == NULL)
{
return OS_STATUS_NO_CHANGE;
}
status = task_validate_running_task(current_task);
if (status != OS_STATUS_OK)
{
return status;
}
ready_list = task_get_ready_list_by_priority(current_task->priority);
if (ready_list == NULL)
{
return OS_STATUS_INVALID_STATE;
}
status = task_schedule();
if (status == OS_STATUS_SWITCH_REQUIRED)
{
return status;
}
if (status != OS_STATUS_NO_CHANGE)
{
return status;
}
if (ready_list->item_count <= 1U)
{
current_task->time_slice = current_task->time_slice_reload;
return OS_STATUS_NO_CHANGE;
}
if (current_task->time_slice > 0U)
{
current_task->time_slice--;
}
if (current_task->time_slice > 0U)
{
return OS_STATUS_NO_CHANGE;
}
current_task->time_slice = current_task->time_slice_reload;
if (ready_list->head == ¤t_task->sched_node)
{
ready_queue_rotate(&g_task_ready_queue, current_task->priority);
}
return task_schedule();
}
为什么 tick 开头先调用一次 task_schedule()?因为这能优先发现“有更高优先级任务已经就绪”的情况,避免时间片逻辑只盯着同优先级任务,反而漏掉真正应该立即抢占的对象。
所以当前实现的思路不是“tick 触发了调度”,而是“tick 只是可能触发重新决策的一个事件源”。
这个角度非常重要,因为以后加上阻塞唤醒、中断投递消息、超时到期,本质上都会变成类似的调度触发点。
小结
从当前代码看,调度器已经把以下几件事理顺了:
- 任务创建与调度器准入分开处理。
- 调度决策与状态提交分开处理。
- 时间片轮转建立在 ready list 头尾操作之上,而不是写一堆额外标志位。
下一篇就进入硬件相关部分:既然 g_next_task 已经能稳定选出来,Cortex-M3 端口层是怎样通过初始栈帧和 PendSV 把这个“决策”变成一次真实上下文切换的。