前言
在裸机单片机开发中,我们经常需要同时处理多个任务:按键扫描、LED闪烁、传感器采集、通信协议解析……如果所有逻辑都塞在 while(1) 里顺序执行,一旦某个任务耗时过长,其他任务就会被延误,系统实时性荡然无存。引入RTOS固然可以优雅地解决并发问题,但它会额外消耗Flash和RAM,在资源极度受限的MCU上并不划算。
有没有一种方法,能在不跑操作系统的前提下实现“伪多任务”呢?时间片轮转调度(Round-Robin Scheduling) 正是为此而生。它利用定时器将CPU时间划分成固定长度的时间片,轮流分配给各个任务,从而在宏观上实现多任务并发。
本文将以 STM32F103C8T6 和 标准库函数 为基础,利用 SysTick 定时器 实现一个轻量级的协作式时间片轮转调度器,并演示如何与低功耗模式配合。所有代码均可直接复制到你的工程中运行。

一、时间片轮转调度原理
1.1 基本思想
时间片轮转是多任务操作系统中最简单的调度算法之一:
- 将CPU时间划分成连续的、固定长度的时间片(如10ms);
- 维护一个任务就绪队列;
- 每个任务按顺序获得一个时间片的CPU使用权;
- 时间片用完后,无论任务是否执行完毕,都必须让出CPU,调度器将切换到下一个就绪任务。
1.2 裸机下的协作式实现
真正的操作系统可以使用硬件上下文切换实现抢占,但裸机环境不具备这个条件。因此我们采用 协作式时间片轮转:
- 每个任务函数被设计成非阻塞的状态机,每次被调度时只执行一小段逻辑,然后立即返回;
- 调度器在主循环中运行,当 SysTick 中断标记“时间片耗尽”后,主循环负责把执行权交给下一个任务;
- SysTick 中断仅负责计时和置标志,不进行实际的上下文切换。
这样既避免了复杂的堆栈操作,又保证了 CPU 时间被公平地分配,非常适合资源受限的单片机项目。
二、时基源:SysTick 定时器
STM32 的 Cortex-M3 内核内置了一个 24 位向下计数的 SysTick 定时器,专用于产生系统节拍。用它做调度器时钟源具有先天优势:
- 配置简单,调用
SysTick_Config()即可; - 中断优先级可调整;
- 与系统时钟同步,精确稳定。
我们将 SysTick 配置为每 1ms 中断一次,作为调度器的时基。
三、调度器设计
3.1 任务控制块(TCB)
#define MAX_TASKS 5 /* 最大任务数 */
#define TASK_NAME_LEN 8 /* 任务名长度 */
/* 任务控制块 */
typedef struct {
char name[TASK_NAME_LEN]; /* 任务名称(调试用) */
void (*func)(void); /* 任务函数指针 */
uint32_t timeSlice; /* 分配的时间片(ms) */
int32_t remainTicks; /* 当前时间片剩余 ticks */
uint8_t state; /* 0=挂起, 1=就绪 */
} TaskTCB;
3.2 调度器全局变量
static TaskTCB taskList[MAX_TASKS];
static uint8_t taskCount = 0; /* 已注册任务数 */
static uint8_t currentTask = 0; /* 当前执行的任务索引 */
static uint8_t taskSwitchFlag = 0; /* 任务切换请求标志 */
3.3 任务注册函数
/**
* @brief 注册一个任务
* @param name 任务名(调试用)
* @param func 任务函数(不能阻塞,必须快速返回)
* @param timeSlice 分配的时间片(单位ms)
*/
void Task_Register(const char *name, void (*func)(void), uint32_t timeSlice) {
if (taskCount >= MAX_TASKS) return;
TaskTCB *t = &taskList[taskCount];
strncpy(t->name, name, TASK_NAME_LEN - 1);
t->func = func;
t->timeSlice = timeSlice;
t->remainTicks = timeSlice;
t->state = 1; /* 就绪 */
taskCount++;
}
3.4 SysTick 初始化与中断服务
/* 系统运行毫秒计数(供任务使用) */
volatile uint32_t sysTickUptime = 0;
/**
* @brief 初始化 SysTick,产生 1ms 节拍
*/
void SysTick_Init(void) {
if (SysTick_Config(SystemCoreClock / 1000)) {
/* 重装载值超出范围时会返回 1,此处正常情况下不会发生 */
while (1);
}
/* 设置 SysTick 中断优先级为最低 */
NVIC_SetPriority(SysTick_IRQn, 0x0F);
}
/**
* @brief SysTick 中断服务函数
*/
void SysTick_Handler(void) {
sysTickUptime++; /* 递增系统时间 */
if (taskCount > 0) {
TaskTCB *t = &taskList[currentTask];
if (t->remainTicks > 0) {
t->remainTicks--;
if (t->remainTicks == 0) {
taskSwitchFlag = 1; /* 时间片耗尽,请求切换 */
}
}
}
}
3.5 调度器主循环
/**
* @brief 启动调度器(应在 main 中调用,永不返回)
*/
void Scheduler_Run(void) {
while (1) {
/* 如果有任务切换请求,且存在任务 */
if (taskSwitchFlag && taskCount > 0) {
taskSwitchFlag = 0;
/* 寻找下一个就绪任务 */
uint8_t next = currentTask;
do {
next = (next + 1) % taskCount;
} while (taskList[next].state == 0 && next != currentTask);
/* 切换到该任务并重置时间片 */
currentTask = next;
taskList[currentTask].remainTicks = taskList[currentTask].timeSlice;
}
/* 执行当前任务(如果就绪) */
if (taskCount > 0 && taskList[currentTask].state == 1) {
taskList[currentTask].func();
}
/* 低功耗:若无紧急事件可进入休眠 */
if (taskSwitchFlag == 0) {
__WFI(); /* 等待中断,SysTick 会自动唤醒 */
}
}
}
四、完整多任务示例代码
本示例实现 4 个任务:LED1 500ms 闪烁、LED2 200ms 闪烁、按键扫描(去抖)、按键动作处理。使用 STM32F103C8T6,LED 接 PB0 和 PB1,按键接 PA0(上拉,按下低电平)。
4.1 头文件与宏定义
#include "stm32f10x.h"
#include <string.h>
#define MAX_TASKS 5
#define TASK_NAME_LEN 8
#define LED1_GPIO GPIOB
#define LED1_PIN GPIO_Pin_0
#define LED2_GPIO GPIOB
#define LED2_PIN GPIO_Pin_1
#define KEY_GPIO GPIOA
#define KEY_PIN GPIO_Pin_0
4.2 全局变量与 TCB
volatile uint32_t sysTickUptime = 0;
typedef struct {
char name[TASK_NAME_LEN];
void (*func)(void);
uint32_t timeSlice;
int32_t remainTicks;
uint8_t state;
} TaskTCB;
static TaskTCB taskList[MAX_TASKS];
static uint8_t taskCount = 0;
static uint8_t currentTask = 0;
static uint8_t taskSwitchFlag = 0;
4.3 硬件初始化
void GPIO_Init_All(void) {
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE);
/* LED1(PB0), LED2(PB1) 推挽输出 */
GPIO_InitStructure.GPIO_Pin = LED1_PIN | LED2_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(LED1_GPIO, &GPIO_InitStructure);
/* 按键 PA0 上拉输入 */
GPIO_InitStructure.GPIO_Pin = KEY_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(KEY_GPIO, &GPIO_InitStructure);
}
void SysTick_Init(void) {
if (SysTick_Config(SystemCoreClock / 1000)) {
while (1);
}
NVIC_SetPriority(SysTick_IRQn, 0x0F);
}
4.4 任务注册与调度器
(此处直接使用第 3 节中的 Task_Register、SysTick_Handler 和 Scheduler_Run,不再重复。复制时请将三个函数完整放在工程中。)
4.5 任务函数实现
/* 全局按键状态,任务间共享 */
volatile uint8_t keyPressed = 0;
/**
* @brief 任务1:LED1 500ms 翻转(状态机)
*/
void Task_LED1_Blink(void) {
static uint32_t prevTime = 0;
if (sysTickUptime - prevTime >= 500) {
prevTime = sysTickUptime;
/* 翻转 LED1 */
GPIO_WriteBit(LED1_GPIO, LED1_PIN,
(BitAction)(1 - GPIO_ReadOutputDataBit(LED1_GPIO, LED1_PIN)));
}
}
/**
* @brief 任务2:LED2 200ms 翻转
*/
void Task_LED2_Blink(void) {
static uint32_t prevTime = 0;
if (sysTickUptime - prevTime >= 200) {
prevTime = sysTickUptime;
GPIO_WriteBit(LED2_GPIO, LED2_PIN,
(BitAction)(1 - GPIO_ReadOutputDataBit(LED2_GPIO, LED2_PIN)));
}
}
/**
* @brief 任务3:按键扫描(每20ms去抖一次)
*/
void Task_KeyScan(void) {
static uint32_t prevTime = 0;
static uint8_t lastState = 1;
if (sysTickUptime - prevTime < 20) return;
prevTime = sysTickUptime;
uint8_t curState = GPIO_ReadInputDataBit(KEY_GPIO, KEY_PIN);
if (lastState == 1 && curState == 0) { /* 下降沿 */
keyPressed = 1;
}
lastState = curState;
}
/**
* @brief 任务4:按键动作处理
*/
void Task_KeyAction(void) {
if (keyPressed) {
keyPressed = 0;
/* 这里可插入自定义动作,如点亮LED1 */
GPIO_SetBits(LED1_GPIO, LED1_PIN);
}
}
4.6 主函数
int main(void) {
GPIO_Init_All();
SysTick_Init();
/* 注册 4 个任务并分配时间片 */
Task_Register("LED1", Task_LED1_Blink, 10); /* 10ms 时间片 */
Task_Register("LED2", Task_LED2_Blink, 10);
Task_Register("Key", Task_KeyScan, 5);
Task_Register("Act", Task_KeyAction, 5);
Scheduler_Run(); /* 启动调度器,永不返回 */
}
4.7 代码运行说明
- 时间片分配:LED任务各分配10ms,按键任务各分配5ms。由于SysTick节拍为1ms,时间片数字即代表该任务最多连续执行多少个节拍。
- 非阻塞设计:所有任务都使用
static变量保存历史状态,通过比较sysTickUptime差值决定是否动作,单次调用后立即返回,绝不死等。 - 任务切换时机:SysTick中断中递减当前任务的时间片,减至0后置
taskSwitchFlag;主循环检测到标志后立即切换到下一个就绪任务,并重置其时间片。
五、低功耗集成思路
在 Scheduler_Run 的循环末尾已加入 __WFI() 指令。当 taskSwitchFlag == 0 且所有任务都暂时无需动作时,MCU会进入休眠模式,SysTick 定时中断将自动唤醒CPU继续运行,从而显著节省功耗。
如果某些任务需要长时间挂起,可将对应 TCB 的 state 字段置为 0,调度器会跳过它们,直至外部事件(如按键中断)将其重新激活。
六、调试与常见问题
6.1 验证时间片效果
- 使用示波器或逻辑分析仪同时监测 PB0 和 PB1,若两个LED分别以500ms和200ms周期稳定闪烁,说明两个任务在并发执行。
- 可通过串口打印当前任务名和
sysTickUptime,观察调度时序。
6.2 常见故障排查
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 只有第一个任务在运行 | SysTick中断未使能;taskSwitchFlag 未被置位 | 检查 SysTick_Config 调用;确认中断优先级未被屏蔽 |
| 某个任务“饿死” | 其他任务函数内有死循环或长时间阻塞 | 将长流程拆分成状态机,保证每次调用在1ms内返回 |
| 低功耗唤醒失败 | 未开启SysTick中断或其他唤醒源 | 确保 SysTick 中断正常,或增加外部中断作为辅助唤醒源 |
sysTickUptime 溢出导致逻辑错误 | 32位无符号数约49天归零 | 使用差值比较(now - last >= period)可安全处理溢出 |
| 按键响应迟钝 | 扫描任务时间片太小或去抖周期过长 | 适当增加按键任务的时间片或减小去抖间隔 |
6.3 扩展建议
- 动态时间片:可根据任务负载动态调整时间片长度。
- 优先级支持:可加入优先级字段,在切换时优先选择高优先级任务。
- 任务挂起/恢复:通过修改
state字段可灵活启停任务。
七、总结
本文基于 STM32 标准库和 SysTick 定时器,从零搭建了一个极简的协作式时间片轮转调度器。通过将顺序执行的逻辑拆分成非阻塞的状态机,再配合时间片轮转机制,成功在裸机上实现了多个任务的“伪并发”。整套代码仅百余行,资源占用极小,非常适合成本敏感的嵌入式项目。
配合之前讲解的 I2C、SPI、PWM 等外设驱动,你可以轻松构建功能丰富的裸机多任务系统。如果你对抢占式调度或更复杂的调度算法感兴趣,欢迎在评论区继续交流!
377

被折叠的 条评论
为什么被折叠?



