裸机程序用定时器模拟任务:前后台系统调度器设计与实践
2026/9/2 23:11:00 网站建设 项目流程

嵌入式软件开发里,评估一个项目代码质量往往不需要看注释,只需要看一个简单场景:往while(1)主循环里加一个新功能时,你是否要反复权衡——这个功能放哪里?需要等多久?会不会把按键扫描拖慢?会不会影响通信数据的接收?如果这些问题已经在你的工程里出现,说明代码已经从“功能能跑”阶段走进了“结构需要重构”阶段。

很多人的第一反应是:是不是该上 RTOS 了?FreeRTOS、RT-Thread 确实成熟,但引入操作系统带来的不只是内核运行开销,还有任务划分、同步机制、内存管理以及团队上手成本。其实在裸机程序和 RTOS 之间,存在一条经常被忽略但价值很高的中间路线:用一颗硬件定时器提供系统心跳,把不同功能封装成周期任务,再写一个极简调度器统一管理。这套方案在嵌入式软件架构里属于典型的“前后台系统”,也是学习 RTOS 之前最好的过渡实践。

这篇文章就用一份完整可运行的代码,把“定时器模拟任务”从设计思想、代码实现、运行验证到常见坑位全部讲清楚。读完你可以直接把这个框架移植到自己的工程里,用来整理按键扫描、LED 闪烁、数据显示、协议处理这类周期任务,也会更清楚它和 RTOS 的边界到底在哪里。

1. 定时器模拟任务到底解决了什么问题

1.1 超级大循环为什么扛不住复杂功能

早期单片机程序功能少,确实可以顺序写:初始化之后,先读传感器,再处理数据,最后刷新显示。但真实工程里,外部事件是异步的——按键不知道什么时候按下,串口数据随时可能到达,传感器不同时间段需要不同的采样频率。这时候纯顺序结构就开始失控。

举个例子。很多入门项目会把按键扫描放在循环末尾,而前面的传感器读取用了HAL_Delay等待转换完成,按键响应就会变得非常迟钝。为了让按键马上响应,有人会用定时器中断直接扫描按键,又有人会在延时期间定时查询各种标志位。功能少的时候勉强能跑,功能一多,代码里到处是全局标志位,状态互相纠缠,改一处崩三处。这已经不只是“代码风格”问题,而是软件架构无法承载复杂度。

1.2 核心思路:把“什么时候做”和“做什么事”分离

定时器模拟任务的核心判断是:与其在应用逻辑里手动管理“什么时候做什么事”,不如把“什么时候”交给硬件定时器中断,把“做什么事”拆成独立任务函数,由调度器统一决定执行时机。

具体来说,系统只保留一个后台主循环,里面调用调度器;另外开一个周期稳定的定时器中断,作为系统“心跳”。每次心跳到来时,中断里只做一件事:把所有任务的时间计数减一。主循环则不断检查哪些任务的时间计数已经归零,归零就调用对应的任务函数。这样,时间基准来自硬件定时器,业务逻辑从“时间管理”里解放出来,每个功能只有一个入口函数和一个执行周期。

1.3 这套方案的真正价值是“结构”

需要客观一点:定时器模拟任务并不会让 CPU 跑得更快,它真正的价值是让代码结构更清晰。每个任务都是独立函数,有明确的周期和入口;新功能加入时,只需要写一个函数,然后注册到调度表里,不需要再纠结它应该放在主循环的哪个位置。从开发效率、代码可读性、前后端分离的角度看,收益非常明显。当然它也有明显边界:任务之间不是抢占式的,某个任务内部如果写了阻塞延时,整个系统节奏依然会被打乱。这个边界在后面的章节会详细展开。

2. 核心概念与架构演进

2.1 几个必须理解的基础概念

在展开代码之前,先把这套体系里的核心术语讲清楚。这里的“定时器”指硬件定时器外设,比如 STM32 的 TIM3、TIM6,也可以用 SysTick 滴答定时器来驱动。重点是它能产生固定周期的中断,为系统提供统一的时间基准。这里的“任务”不是操作系统线程,而是一个可被定时调用的 C 函数,通常代表一个业务模块,比如按键扫描、显示刷新、协议发送。

“tick”是系统最小时间单位。假设定时器每 1ms 中断一次,那一个 tick 就是 1ms,所有任务的执行周期都以 tick 为整数倍计算。调度器维护一个“任务注册表”,里面存着每个任务的函数指针、周期和剩余时间。主循环里的调度函数负责遍历注册表,找出时间已到的任务并执行。这套模型里,任务的“注册”这个概念很重要——它把任务信息集中起来,让调度器不再关心业务细节。

2.2 从超级大循环到 RTOS 的架构分水岭

理解这套架构的最好方式,是把它放进嵌入式软件架构的演进链条里看。最早期是超级大循环,所有逻辑顺序堆在主循环里;接着是前后台系统,中断做前台事件处理,主循环做后台业务处理;再往上就是带实时性的 RTOS。

维度超级大循环定时器模拟任务(前后台系统)RTOS
任务组织方式顺序调用任务注册表 + 调度器内核对象 + 调度器
时间基准无统一基准定时器中断产生 tick系统时钟 tick
并发模型无并发协作式调度抢占式或协作式调度
任务阻塞影响整个循环卡死整个循环卡死可切换到其他任务
优先级支持基本无,可简单模拟完整优先级抢占
资源保护机制需要自己处理互斥量、信号量
上手成本

从表格可以看到,定时器模拟任务位置很特殊:它能承担一部分 RTOS 的调度职责,却不需要引入内核。很多人直接学 FreeRTOS 时觉得“任务切换”很抽象,其实先自己写一遍定时器调度器,再回头看 FreeRTOS 的任务控制块和 tick 处理逻辑,理解难度会立刻降一个等级。

3. 最小调度框架的设计思路

3.1 三个组成部分

这套最小调度框架由三部分组成:任务注册表、tick 驱动、调度循环。任务注册表是一个固定大小的结构体数组,限定最大任务数,避免动态内存分配。每个结构体保存任务函数指针、任务周期、当前剩余时间、使能标志和运行标志。tick 驱动在定时器中断里完成,唯一职责是把所有任务剩余时间减一。调度循环在主循环里执行,遍历任务池,发现剩余时间归零且未在运行的任务,就调用它的函数,然后把剩余时间重置为周期。

为什么用“剩余时间递减”而不是“累计 tick 数然后取模”?因为取模运算在某些 8 位单片机上开销不小,而且当任务周期不是 tick 数整数倍时,取模逻辑会变得别扭。递减法只需要在中断里做一次减一,在主循环里做一次比较,逻辑简单,也方便后续扩展紧急任务。

3.2 为什么说它是“协作式调度”

这套调度器没有抢占能力,属于协作式调度。所谓协作式,就是所有任务都主动“配合”:调用任务函数后,必须等函数自己返回,调度器才能去执行下一个任务。如果某个任务函数内部写了HAL_Delay(100),整个系统会卡住 100ms,其他任务哪怕时间已经归零也没机会运行。这对写任务函数的人提出了一个基础要求:函数必须尽量短,必须尽快返回。

这个约束看起来是缺点,但换一个角度看,它也是优点。协作式调度不需要上下文切换,不需要保存和恢复寄存器现场,内存开销极小,逻辑简单到可以逐行看懂。对于轻量级嵌入式应用,这种调度方式足够稳定可靠。

3.3 数据结构设计

任务结构体设计如下,后面代码会基于这个结构展开:

成员作用
task_func任务函数指针
period_ms任务周期,单位 ms
remain_ms剩余时间,tick 中断中自减
enable使能标志,为 0 时跳过
running运行标志,防止任务重入

4. 完整代码实现

下面以 STM32F1 系列、HAL 库为例,完整展示这套调度框架。核心的task_scheduler.c与具体芯片无关,可以很方便地移植到其他平台。

4.1 调度器头文件 task_scheduler.h

/* * 文件路径:Core/Inc/task_scheduler.h * 一个极简的定时器任务调度器 */ #ifndef __TASK_SCHEDULER_H__ #define __TASK_SCHEDULER_H__ #include <stdint.h> #define TASK_MAX_NUM 8 #define INVALID_TASK_ID (-1) typedef struct { void (*task_func)(void); /* 任务函数指针 */ uint32_t period_ms; /* 任务周期,单位 ms */ volatile uint32_t remain_ms; /* 剩余时间,tick 中断中递减 */ uint8_t enable; /* 使能标志 */ uint8_t running; /* 运行标志,防止重入 */ } task_t; int task_register(void (*func)(void), uint32_t period_ms); void task_tick_update(void); void task_scheduler_run(void); #endif

这里把remain_ms声明为volatile,是因为它会被定时器中断修改,而主循环也会读取。在编译器做优化时,volatile能避免变量被缓存在寄存器里导致读到旧值。在单核 MCU 上,这个写法能覆盖大部分场景。

4.2 调度器实现 task_scheduler.c

/* * 文件路径:Core/Src/task_scheduler.c */ #include "task_scheduler.h" static task_t task_pool[TASK_MAX_NUM]; static uint8_t task_cnt = 0; int task_register(void (*func)(void), uint32_t period_ms) { if (func == 0 || period_ms == 0 || task_cnt >= TASK_MAX_NUM) { return INVALID_TASK_ID; } task_pool[task_cnt].task_func = func; task_pool[task_cnt].period_ms = period_ms; task_pool[task_cnt].remain_ms = period_ms; task_pool[task_cnt].enable = 1; task_pool[task_cnt].running = 0; task_cnt++; return (int)(task_cnt - 1); } void task_tick_update(void) { uint8_t i; for (i = 0; i < task_cnt; i++) { if (task_pool[i].enable && task_pool[i].remain_ms > 0) { task_pool[i].remain_ms--; } } } void task_scheduler_run(void) { uint8_t i; for (i = 0; i < task_cnt; i++) { if (!task_pool[i].enable) { continue; } if (task_pool[i].running) { continue; } if (task_pool[i].remain_ms == 0) { task_pool[i].running = 1; task_pool[i].task_func(); task_pool[i].running = 0; task_pool[i].remain_ms = task_pool[i].period_ms; } } }

task_register把任务加入任务池,返回任务 ID。task_tick_update供定时器中断调用,每次把剩余时间减一。task_scheduler_run是主循环的后台调度器,发现剩余时间归零且未在运行的任务,就执行它。这里用running标志防止任务重入:如果某个任务执行时间超过了它自己的周期,下一轮调度不会再次进入,避免嵌套执行导致状态混乱。

4.3 定时器中断回调与 tick 更新

以 STM32 的 TIM3 为例,在定时器中断回调里调用task_tick_update()。HAL 库的统一回调函数在stm32f1xx_it.c或单独文件中实现,需要保证工程中只存在一个同名定义。

/* 文件路径:Core/Src/stm32f1xx_it.c 或用户自定义文件 */ extern TIM_HandleTypeDef htim3; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { task_tick_update(); } }

中断里只调用一个函数,逻辑尽量短。不要在中断里直接执行业务代码,更不要在中断里调用printf。中断只负责更新 tick 计数,这是前后台系统的重要原则。

4.4 定时器初始化参数

TIM3 配置为 1ms 产生一次中断。以常见的 APB1 定时器时钟 72MHz 为例,预分频器 PSC 设为 71,自动重载值 ARR 设为 999。

/* 文件路径:Core/Src/timer.c */ void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 71; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 999; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; if (HAL_TIM_Base_Init(&htim3) != HAL_OK) { Error_Handler(); } sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; if (HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig) != HAL_OK) { Error_Handler(); } sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; if (HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig) != HAL_OK) { Error_Handler(); } }

中断频率计算公式为:

定时器时钟 / (PSC + 1) / (ARR + 1) = 中断频率 72MHz / 72 / 1000 = 1kHz

也就是 1ms 一次中断。注意,不同型号的 STM32,APB1 定时器时钟未必都是 72MHz。比如部分 F1 系列 APB1 最大 36MHz,但定时器时钟经过倍频后仍可能是 72MHz。实际项目中,请先确认自己板卡的定时器时钟频率,再代入公式计算。

4.5 主函数与示例任务

主函数里注册三个任务:LED 闪烁、按键扫描、状态上报,周期分别为 200ms、10ms、1000ms。

/* 文件路径:Core/Src/main.c */ #include "main.h" #include "task_scheduler.h" static void led_task(void); static void key_scan_task(void); static void report_task(void); TIM_HandleTypeDef htim3; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART2_UART_Init(); MX_TIM3_Init(); task_register(led_task, 200); task_register(key_scan_task, 10); task_register(report_task, 1000); HAL_TIM_Base_Start_IT(&htim3); while (1) { task_scheduler_run(); } } static void led_task(void) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } static void key_scan_task(void) { /* 按键读取、消抖、事件投递都在这里实现 */ } static void report_task(void) { printf("[report] system running, task num: 3\r\n"); }

HAL_TIM_Base_Start_IT(&htim3)启动定时器并开启中断。主循环变成一行:不断调用task_scheduler_run()。从这一刻起,应用层不再关心“按键该什么时候扫”“LED 该什么时候翻转”,只负责“每个任务该做什么”。

5. 运行结果与效果验证

5.1 预期现象

烧录并复位开发板后,可以观察到:

  • LED 每 200ms 翻转一次,频率稳定。
  • 串口终端每 1000ms 打印一行状态信息。
  • 按键扫描任务每 10ms 执行一次,按下去响应迅速。

使用示波器或逻辑分析仪测量 LED 引脚波形,能更直观地看到周期是否精确。如果 LED 引脚波形周期稳定在 400ms(高 200ms 低 200ms),说明 tick 计算和任务调度正常。

5.2 怎么判断调度器真的在工作

除了看 LED,可以做一个更有说服力的验证:在report_task中打印当前系统运行时间和任务执行次数。

static uint32_t run_counter = 0; static void report_task(void) { run_counter++; printf("[report] run times: %lu\r\n", (unsigned long)run_counter); }

预期输出类似:

[report] run times: 1 [report] run times: 2 [report] run times: 3

如果打印频率稳定在 1 秒一次,说明report_task被正确注册,定时器 tick 也在正常工作。

5.3 失败时从哪开始查

如果 LED 不闪,先检查两个地方:定时器中断是否真的启动了,HAL_TIM_Base_Start_IT是否被调用;HAL_TIM_PeriodElapsedCallback是否真的响应了 TIM3 的中断。可以在回调里临时翻转另一个 GPIO 作为调试手段。如果串口没有打印,优先确认串口初始化是否成功、printf重定向是否完成,因为调度器本身不负责串口底层。

6. 这套模型的边界:理解它才能理解 RTOS

6.1 任务内部不能阻塞

协作式调度最直接的限制就是:任务里不能有长时间阻塞。比如在report_task里写一个HAL_Delay(5000),那么其他任务在 5 秒内都不会被执行,LED 会立刻卡住。这个现象在初学 RTOS 时其实也有对应的场景,只不过 RTOS 会把阻塞任务挂起,切换到其他就绪任务。而在这里,任务不主动返回,调度器就无能为力。

所以写任务函数时,要把它当成“中断服务程序”来看待:查状态、做必须的事、立刻退出。如果任务需要分多步执行,就用状态机把一个长任务拆成多个短状态,每个周期只处理一步。这是裸机任务调度里最重要的工程习惯。

6.2 优先级只能部分模拟

这套调度器没有真正的优先级抢占。可以做一个简单优化:把优先级高的任务放在任务池的前面,这样每轮遍历时它先被检查、先执行。但这种“优先级”只能决定同一轮内的调用顺序,无法实现真正的抢占。如果系统里同时存在一个 1ms 级的紧急任务和一个 100ms 级的普通任务,而紧急任务又必须在精确时间点执行,这种协作式框架就满足不了需求。

6.3 共享资源需要自己保护

多个任务访问同一个全局变量时,存在中断与主循环竞争的问题。典型场景是:一个任务往缓冲区写数据,另一个任务读取缓冲区分析数据,同时中断服务程序也在刷新缓冲区。在裸机调度器里,最简单的保护手段是进入临界区前关中断,处理完再打开。代码可以做成两个宏ENTER_CRITICAL()EXIT_CRITICAL(),内部使用__disable_irq()__enable_irq()。到了 RTOS 里,这个需求会被信号量、互斥量、消息队列等机制承接,但思想是一致的。

6.4 什么时候应该换 RTOS

当出现以下信号时,说明这套定时器模拟任务已经不够用了:任务数量接近或超过 10 个;任务之间有复杂的同步关系,需要事件通知;系统要求多个任务并行阻塞等待资源;需要成熟的网络协议栈或文件系统组件;有严格的硬实时任务需要优先级抢占。这时候切换到 FreeRTOS 或 RT-Thread 是更稳妥的方案。换个角度说,先把这套模拟任务框架写好,再迁移到 RTOS,你会发现自己已经在“用内核的思维”写裸机代码了。

7. 常见问题与排查方法

问题现象可能原因排查方式解决方案
定时器不触发,任务全部不执行定时器中断未开启检查HAL_TIM_Base_Start_IT是否调用在主循环前调用HAL_TIM_Base_Start_IT(&htim3)
任务执行频率不对定时器分频或重载值计算错误用示波器测中断脚或 LED 波形重新按公式计算 PSC 和 ARR
新加任务后,其他任务明显变慢新任务内部有阻塞延时检查任务函数是否长时间占用 CPU使用状态机拆分长任务,禁止HAL_Delay
某个任务一直执行,其他任务无响应任务函数里写了while(1)或死循环注释掉该任务逐一排查去掉死循环,任务必须能返回
变量在中断和主循环中被同时修改未加临界区保护查看是否有共享变量volatile修饰,必要时关中断保护
任务卡在running状态无法重置任务函数异常未返回在调度器里打印running状态检查任务函数是否提前 return 或异常退出

8. 最佳实践与工程建议

8.1 任务函数保持短小

一个任务函数的理想执行时间应该远小于它的周期。比如周期 200ms 的 LED 任务,函数实际执行时间应该只有几微秒。如果任务里有耗时的东西,比如 Flash 擦写、大数组拷贝、协议解析,尽量拆成状态机,每个周期只处理一部分。另一个判断标准是:任务里尽量不要有循环等待,尤其不要有不确定次数的循环。

8.2 任务周期错开相位

如果多个任务周期相同,比如都是 100ms,它们会在同一轮调度中依次执行,所有任务集中在同一时刻触发。任务多的时候,这会形成明显的执行峰,瞬时 CPU 占用率很高。解决方法是注册任务时给剩余时间设置不同的初始偏移:比如任务 A 初始剩余 100ms,任务 B 初始剩余 50ms,让它们在时间上错开。一个更工程化的做法是,在任务结构体里增加phase_ms字段,注册时指定相位。

8.3 明确 tick 基准单位

这套调度器的 tick 单位是 1ms,所有任务的周期都以 ms 为单位。如果系统里还需要更高精度的计时,比如几十微秒级别的超时控制,不应该放在这套调度器里,而是使用硬件定时器的捕获比较功能。不要在任务里依赖HAL_GetTick()做高精度时间判断,因为它是另一个时基,受到中断和主循环调度的影响。

8.4 中断里只做最少的事情

前后台系统的前台是中断,后台是任务调度循环。定时器中断里只做 tick 计数,不要做业务。串口接收中断里只把数据放入环形缓冲区,解析逻辑放到任务里做。按键中断里只置标志位,消抖和事件分发放到按键扫描任务里做。这个原则能保证中断响应时间短,也能避免中断嵌套带来的复杂竞态。

8.5 为调度器预留扩展点

工程上可以给任务结构体增加几个实用字段:state(任务状态)、priority(软优先级)、last_exec_time(上次执行时间戳)。有了这些字段,后续可以扩展出任务暂停、恢复、单次执行、执行时间统计等功能。但不要一开始就设计得过于复杂,保持最小可运行,再按需增加。调度器本身是整个系统的公共基础设施,修改时必须回归验证所有任务。

8.6 保留调试出口

强烈建议在设计初期就预留一个调试任务。比如每 1000ms 打印一次任务注册数量、系统运行时间、关键任务状态。不要等系统出现问题再临时加打印,那时候往往已经难以定位时序问题。调试任务本身也是验证调度器是否正常工作的最好抓手。

9. 总结与后续学习方向

定时器模拟任务并不是一个“高大上”的技巧,但它是嵌入式软件架构中非常实用的一环。它帮你把裸机大循环里混乱的时间管理抽离出来,用一颗定时器加几十行代码,构建出一个清晰、可扩展的周期任务系统。它更重要的价值是认知上的:理解了任务注册、tick 驱动、协作式调度这三个概念,再去读 FreeRTOS 的任务控制块、tick 处理和任务切换逻辑,你会发现很多片段似曾相识,学习曲线会平缓很多。

下一步建议先在现有工程里跑通这个框架,把几个周期任务迁移进去,体会“主循环只剩一行调度函数”的感受。然后尝试给调度器增加一个“任务暂停/恢复”功能,再实现一个简单的软定时器。最后可以打开 FreeRTOS 源码,阅读vTaskSwitchContextxTaskIncrementTick,对比内核是如何解决协作式调度解决不了的问题的。真正掌握一个知识,不是记住 API,而是能从一个最小实现里看透它背后的设计逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询