4. 裸机任务调度方法
说实话,很多初学者刚接触单片机时,最习惯的写法就是 delay_ms(1000)。我也一样。但很快你就会发现,这种“死等”的方式,让CPU在延时期间啥也干不了。你想想看,一个主频几十兆的芯片,就为了等那几百毫秒,白白空转,是不是有点浪费?
这一章,我们就来解决这个问题。核心思路很简单:用SysTick定时器来“记账”,让CPU不再傻等。这就是软定时器的本质——时间片轮转的雏形。
4.1 为什么需要非阻塞延时?
先看一个典型场景。你要控制一个LED以500ms的周期闪烁,同时还要扫描按键。如果用阻塞延时:
while(1) {
LED_Toggle();
delay_ms(500); // CPU在这里空转500ms
if(Key_Scan() == KEY_PRESS) {
// 按键处理
}
}
问题很明显:按键扫描的实时性完全被破坏了。你在延时期间按了键,CPU根本不理你。我在一个智能家居项目中就吃过这个亏——用户按了开关,灯要等半秒才反应,体验极差。
非阻塞延时的核心思想是:不等待,只查询。你告诉定时器“我要等500ms”,然后继续干别的事。每隔一段时间,你去问定时器“时间到了没?”到了就执行,没到就继续忙别的。
4.2 SysTick定时器:Cortex-M内核的“心跳”
SysTick是Cortex-M内核内置的一个24位递减计数器。说白了,它就是一个硬件计时器,专门用来给操作系统或裸机程序提供时间基准。我个人习惯把它配置成1ms中断一次,这样就有了一个“毫秒级的心跳”。
- 24位递减计数器,最大值16,777,215
- 时钟源可选:内核时钟或外部参考时钟
- 计数到0时,可产生中断
- 自动重装载,无需手动重置
配置代码其实很简单。以STM32为例,我通常这样初始化:
// SysTick配置:1ms中断一次
// 假设系统时钟为72MHz
void SysTick_Init(void) {
// 设置重装载值:72000000 / 1000 = 72000
SysTick->LOAD = 72000 - 1;
// 清零当前值
SysTick->VAL = 0;
// 使能SysTick,使能中断,使用内核时钟
SysTick->CTRL = 0x07;
}
嗯,这里要注意:LOAD寄存器的值要减1,因为计数器是从LOAD值减到0,总共LOAD+1个时钟周期。我曾经在这里踩过坑,少减了1,导致时间偏差了1个时钟周期。虽然影响不大,但做精确计时时还是要严谨。
4.3 软定时器的实现:从“死等”到“查询”
有了SysTick这个1ms的“心跳”,我们就可以实现软定时器了。核心数据结构就是一个计数器变量:
// 全局变量:系统滴答计数
volatile uint32_t sysTickCount = 0;
// SysTick中断服务函数
void SysTick_Handler(void) {
sysTickCount++;
}
// 获取当前滴答值
uint32_t GetTick(void) {
return sysTickCount;
}
// 非阻塞延时函数
// 返回1表示时间到,返回0表示时间未到
uint8_t CheckDelay(uint32_t startTick, uint32_t delayMs) {
return (GetTick() - startTick) >= delayMs;
}
使用方式就变成了这样:
uint32_t ledTimer = 0;
uint8_t ledState = 0;
void LED_Blink_NonBlocking(void) {
// 启动定时器
if(ledTimer == 0) {
ledTimer = GetTick();
}
// 检查500ms是否到达
if(CheckDelay(ledTimer, 500)) {
ledTimer = GetTick(); // 重新计时
ledState = !ledState;
GPIO_WriteBit(LED_PORT, LED_PIN, ledState);
}
}
// 主循环中,LED闪烁和按键扫描互不干扰
while(1) {
LED_Blink_NonBlocking();
Key_Scan_NonBlocking(); // 按键也是非阻塞的
// 还可以做其他任务...
}
GetTick() - startTick 而不是 GetTick() > startTick + delayMs,可以避免定时器溢出带来的问题。因为uint32_t的减法在溢出时是自动回绕的,只要延时时间不超过49.7天(2^32毫秒),结果就是正确的。
4.4 多任务时间片轮转:让每个任务都有机会
单个软定时器只能控制一个任务。但实际项目中,我们往往需要同时管理多个周期性任务。比如:LED闪烁(500ms)、按键扫描(20ms)、温度采集(1000ms)、串口发送(100ms)。
这时候,就需要一个任务调度表来统一管理。我习惯用结构体数组来实现:
// 任务控制块
typedef struct {
void (*taskFunc)(void); // 任务函数指针
uint32_t interval; // 执行间隔(ms)
uint32_t lastTick; // 上次执行时间
uint8_t enable; // 使能标志
} Task_t;
// 任务列表
Task_t taskList[] = {
{LED_Blink, 500, 0, 1}, // LED闪烁,500ms
{Key_Scan, 20, 0, 1}, // 按键扫描,20ms
{Temp_Read, 1000, 0, 1}, // 温度采集,1000ms
{UART_Send, 100, 0, 1}, // 串口发送,100ms
};
// 任务调度器
void TaskScheduler(void) {
uint32_t currentTick = GetTick();
uint8_t i;
for(i = 0; i < TASK_NUM; i++) {
if(taskList[i].enable) {
// 检查是否到达执行时间
if(currentTick - taskList[i].lastTick >= taskList[i].interval) {
taskList[i].lastTick = currentTick; // 更新时间戳
taskList[i].taskFunc(); // 执行任务
}
}
}
}
// 主循环
while(1) {
TaskScheduler(); // 所有任务都在这里被调度
}
4.5 时间片轮转的核心逻辑
上面这个调度器,其实就是时间片轮转的雏形。每个任务都分到了一个“时间片”,但这个时间片不是固定的CPU执行时间,而是固定的执行间隔。每个任务在自己的时间片到来时执行一次,然后让出CPU给其他任务。
我画了一张图,帮你理解这个流程:
4.6 软定时器的进阶用法:单次定时与周期定时
上面的例子都是周期定时——任务每隔固定时间执行一次。但实际中,我们也需要单次定时:比如按下按键后,延迟100ms做消抖,然后只执行一次。
我通常这样扩展软定时器:
typedef enum {
TIMER_ONESHOT, // 单次定时
TIMER_PERIODIC // 周期定时
} TimerMode_t;
typedef struct {
uint32_t timeout; // 定时时长(ms)
uint32_t startTick; // 启动时的Tick值
uint8_t active; // 是否激活
TimerMode_t mode; // 模式
void (*callback)(void);// 回调函数
} SoftTimer_t;
// 启动定时器
void Timer_Start(SoftTimer_t *timer, uint32_t ms, TimerMode_t mode, void (*cb)(void)) {
timer->timeout = ms;
timer->startTick = GetTick();
timer->active = 1;
timer->mode = mode;
timer->callback = cb;
}
// 定时器处理(在主循环中调用)
void Timer_Process(SoftTimer_t *timer) {
if(!timer->active) return;
if(GetTick() - timer->startTick >= timer->timeout) {
if(timer->callback) {
timer->callback();
}
if(timer->mode == TIMER_ONESHOT) {
timer->active = 0; // 单次定时,自动关闭
} else {
timer->startTick = GetTick(); // 周期定时,重新计时
}
}
}
SoftTimer_t debounceTimer(按键消抖)、SoftTimer_t ledTimer(LED闪烁)、SoftTimer_t commTimeout(通信超时检测)。这样代码结构清晰,也方便调试。
4.7 避坑指南:软定时器的常见问题
我在多个项目中用过这种软定时器方案,遇到过不少坑。这里列几个最常见的:
| 问题 | 现象 | 原因 | 解决方案 |
|---|---|---|---|
| 定时不准 | 实际延时比设定值长 | 任务执行时间过长,导致调度延迟 | 拆分耗时任务,或提高SysTick中断优先级 |
| 定时器溢出 | 长时间运行后定时异常 | GetTick()回绕导致比较逻辑错误 | 使用差值比较(current - start),而非直接比较大小 |
| 中断冲突 | SysTick中断与其它中断互相干扰 | 中断优先级配置不当 | 合理设置NVIC优先级分组,SysTick优先级设为较低值 |
| 任务饿死 | 某个任务长时间得不到执行 | 高优先级中断或任务占用CPU太久 | 确保每个任务执行时间远小于调度间隔 |
我曾经在一个工业控制器项目中,因为SysTick中断里做了太多事情(打印日志、计算复杂算法),导致主循环的调度严重滞后。后来我把所有非必要操作都移出了中断,只在中断里做sysTickCount++这一件事,问题就解决了。记住:中断服务函数要短小精悍。
4.8 小结:从软定时器到RTOS的过渡
你现在实现的这个任务调度器,其实已经具备了RTOS的雏形。你看:
- 任务控制块(TCB):我们定义的
Task_t结构体,就是最简单的TCB - 任务调度器:
TaskScheduler()函数,就是最朴素的调度算法 - 时间片:每个任务的执行间隔,就是它的时间片
- 上下文切换:虽然我们没有保存寄存器,但函数调用和返回本质上就是一种上下文切换
当然,真正的RTOS比这复杂得多。它要处理任务优先级、抢占式调度、信号量、消息队列、内存管理等等。但核心思想是一样的:让CPU在多个任务之间高效切换,让每个任务都感觉自己独占CPU。