ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

STM32裸机定时器模拟任务:手写轻量级任务调度器

STM32裸机定时器模拟任务:手写轻量级任务调度器 在实际的嵌入式项目里任务调度并不是引入 RTOS 之后才需要考虑的问题。很多基于 STM32、51、Arduino 的裸机项目代码一开始是从流水灯开始的等按键、传感器、显示、通信模块都加进来之后main 函数里通常会变成一个巨大的 while(1) 循环各种功能按固定顺序轮流执行。表面上看每个功能都能跑但某个函数内部一旦加了一段延时或等待整个系统的实时性就会明显下降。这时定时器模拟任务就成为一种非常实用的裸机架构升级手段用定时器中断产生统一时基把大循环拆成多个可独立调度的周期任务让程序具备接近时间片轮转的调度效果。这篇文章会从超级大循环的代码缺陷讲起解释定时器模拟任务的基本概念再给出一个可以在 STM32 上直接运行的轻量任务调度器实现。代码不依赖 RTOS却可以复现 FreeRTOS 里最重要的周期性任务调度思路适合想理解嵌入式调度原理、准备嵌入式面试、或者想把裸机项目改造成更规范架构的开发者。1. 定时器模拟任务概念从超级大循环到事件驱动架构1.1 超级大循环能用但会逐渐失控的程序结构裸机项目最常见的代码结构是超级大循环也叫前后台系统中的后台部分。前台是中断后台就是 main 函数里的一个死循环。典型的写法是把按键扫描、传感器读取、屏幕刷新、协议解析全部放在同一个 while(1) 中int main(void) { while (1) { key_scan(); sensor_read(); display_update(); protocol_process(); } }这种结构在功能很少时非常直观代码从上往下执行逻辑清楚。但问题会随着功能增加而暴露出来。假设sensor_read()里使用阻塞延时等待传感器转换完成比如读取一个需要 10ms 才能稳定的 ADC 采样结果那么这 10ms 内key_scan()和display_update()都在等待。用户按下的按键不会立即响应屏幕刷新也会卡顿。更严重的是如果某个通信函数使用超时循环等待应答而这个超时时间写得比较长整个系统的反应速度会被拉得很差。超级大循环的缺点可以归结为三点实时性差。一个任务的阻塞会拖累所有后续任务。扩展性差。新增一个功能就要插入循环某处还要小心会不会影响现有任务的执行周期。时序不可控。每个功能的执行频率完全取决于循环整体运行时间无法保证固定周期。所以当项目里出现“某个任务稍微延迟几毫秒系统表现就异常”的情况时需要的就是一种能把“任务”和“时间”解耦的架构。1.2 定时器如何把大循环改造成多任务结构要解决阻塞问题最直接的办法是引入定时器。定时器产生固定频率的中断比如每 1ms 中断一次。每次中断时一个全局计数变量加一。主循环里不再按固定顺序盲目执行所有函数而是根据当前计数判断该执行哪个任务。这种做法的本质是把“时间”从“任务代码”中抽出来用一个独立时基驱动所有任务。每个任务不再自己用 delay 制造延时而是通过调度器判断“现在是否到了该运行的时刻”。这样即使某个任务内部有短暂阻塞其他任务也只会延迟最多一个任务周期的运行时间而不是被整个阻塞住。从架构角度看这种改动就实现了事件驱动定时器中断产生周期事件也就是 tick。主循环根据 tick 判断事件类型并调用对应处理函数。各任务从主动延时等待变成被动轮询执行。事件驱动架构的最大收益是任务之间的耦合被大幅降低。按键扫描可以作为 10ms 周期任务运行LED 闪烁可以作为 500ms 周期任务运行传感器数据解析可以作为 100ms 周期任务运行它们互不修改对方的代码。1.3 定时器模拟任务与 RTOS 任务调度的关系定时器模拟任务这个概念最容易被误解成“用定时器做延时”。实际上它模拟的是 RTOS 最基本的调度机制按照时间片决定哪个任务运行。FreeRTOS 这类系统也有一个滴答定时器也就是 SysTick用来产生系统时基。每次 tick 中断时内核检查是否有更高优先级的任务进入就绪态然后保存当前任务上下文切换到下一个任务。裸机里的定时器模拟任务少了上下文切换保存恢复这一层任务之间是通过函数调用完成的但仍然包含了三个核心抽象系统时基周期中断维护的 tick 计数。任务控制块记录每个任务的函数指针、周期和状态。调度逻辑根据时间差决定当前该运行哪个任务。下面用一张表对比三种常见方案便于理解定位项目超级大循环定时器模拟任务FreeRTOS时基来源无统一时基依赖延时函数定时器中断滴答定时器任务切换方式顺序执行周期轮询调度高优先级抢占或时间片轮转任务阻塞影响全系统卡住只影响当前周期当前任务阻塞其他任务继续上下文保存无无寄存器现场保存与恢复适用场景功能少、逻辑简单裸机中等复杂度项目多任务、强实时、复杂交互从这张表可以看出定时器模拟任务并不是一个最终方案而是一个理解 RTOS 调度机制的中间台阶。把裸机调度器写明白之后再去看 FreeRTOS 的任务切换流程会容易得多。2. 实验环境与定时器选型先准备好硬件和时基2.1 硬件平台与软件工具这套调度器实现依赖一个周期中断以及一个能够输出 printf 串口信息的调试环境。这里以最常见的 STM32F103C8T6 为例因为它的 SysTick 定时器是 Cortex-M 内核自带的配置简单不占用外部外设。项目推荐选择说明主控芯片STM32F103C8T6基于 Cortex-M3支持 SysTick定时器SysTick内核定时器不必依赖外部 TIM 资源开发环境STM32CubeIDE 或 Keil MDK两者都可以在线调试驱动库HAL 库或标准库不影响调度器核心逻辑辅助工具串口助手、逻辑分析仪用于观察任务执行周期如果手头没有 STM32也可以用其他带 SysTick 的 Cortex-M 芯片或者用 51 单片机的外部定时器。核心思想是一样的只是寄存器操作不同。2.2 选择 SysTick 还是通用定时器SysTick 是 ARM Cortex-M 内核自带的一个 24 位递减计数器它不占用 TIM2、TIM3 这类通用定时器资源。很多项目会把通用定时器留给 PWM 输出、输入捕获、编码器计数等硬件功能因此用 SysTick 做系统时基是更合理的选择。使用 HAL 库时要注意HAL_Init()默认会配置 SysTick 工作为 1ms 时基并提供HAL_Delay()延时函数。如果自己也要用 SysTick 做调度时基存在两种做法直接复用 HAL 的 1ms 时基在SysTick_Handler里同时调用HAL_IncTick()和自己维护的g_tick_ms。完全脱离 HAL 的 SysTick 管理改用 TIM2 或者普通定时器做调度时基。第一种做法代码更少适合快速跑通。第二种做法与 HAL 解耦适合以后迁移到其他芯片。下面示例以第一种做法为主因为它能尽快验证调度器逻辑。2.3 时基频率和重载值的计算方法定时器模拟任务需要先确定一个基准 tick 周期。常见的选择是 1ms因为大多数裸机任务的周期都是 5ms、10ms、100ms、500ms 这类整数倍。如果任务周期要求到微秒级1ms 时基就不够用但那属于更高精度的实时控制范畴调度器设计的思路需要另外调整。SysTick 的配置关键在于重载值计算。SysTick 是一个向下计数的定时器从重载值递减到 0然后产生中断并自动重新加载。重载值公式为重载值 SysTick 时钟频率 / 期望中断频率 - 1以 STM32F103C8T6 在 HCLK 72MHz 为例SysTick 输入时钟如果按 HCLK 计算 重载值 72,000,000 / 1000 - 1 71,999得到 1ms 中断一次的结果。使用 HAL 库时HAL_InitTick(PRIORITY)已经按类似方式配置好可以不再手动配置。但理解这个计算过程仍然重要因为换到不同主频的芯片时错误的重载值会导致 tick 周期变成几毫秒甚至几十毫秒而调度器表面的周期参数并不会告诉你真相只能通过串口或示波器观察。3. 手写一个轻量任务调度器从定义任务表开始3.1 任务控制块描述一个任务需要哪些信息要让定时器模拟多个任务第一步是定义任务控制块。一个任务需要记录四个信息任务函数指针、执行周期、上次运行时间、是否启用。#define TASK_MAX_NUM 8 typedef struct { void (*p_task_func)(void); uint32_t period_ms; uint32_t last_run_ms; uint8_t enabled; } task_ctrl_t; static task_ctrl_t g_task_table[TASK_MAX_NUM]; static volatile uint32_t g_tick_ms 0;这里p_task_func是任务函数入口period_ms是任务的周期last_run_ms记录上一次执行时的 tick 值enabled控制任务是否参与调度。任务表用静态数组保存初始化为全空。g_tick_ms必须加volatile因为它会在中断里被修改主循环里被读取。如果不加volatile编译器可能把变量优化到寄存器中导致判断条件看不到中断里的更新。初始化函数也在这里补上void scheduler_init(void) { for (uint8_t i 0; i TASK_MAX_NUM; i) { g_task_table[i].p_task_func NULL; g_task_table[i].period_ms 0; g_task_table[i].last_run_ms 0; g_task_table[i].enabled 0; } g_tick_ms 0; }初始化时把函数指针清空用p_task_func NULL表示该槽位可以注册新任务。这里不用单独的“空闲”标志是为了简化结构体。3.2 系统时基在定时器中断里维护全局 tickSysTick 中断是调度器的唯一时间来源。中断处理函数里只做一件事累加g_tick_ms。不要在中断里直接调用任务函数这是一个非常重要的边界。void SysTick_Handler(void) { HAL_IncTick(); // HAL 库自身的 tick维持 HAL_Delay 工作 g_tick_ms; }如果工程不使用 HAL 库可以把第一行去掉只保留g_tick_ms。这么做的原则是中断处理函数必须短小精悍尽量只做标志记录、计数累加这类微秒级操作。把任务函数放进中断里会让中断占用时间变长还可能因为共享变量访问造成不可预知的问题。3.3 调度主循环用差值进行周期判定调度器的主循环是整个任务调度的核心。它不断扫描任务表判断每个任务是否到达执行时刻到达则调用任务函数。void scheduler_run(void) { while (1) { for (uint8_t i 0; i TASK_MAX_NUM; i) { if (g_task_table[i].enabled 0) { continue; } if (g_task_table[i].p_task_func NULL) { continue; } if ((uint32_t)(g_tick_ms - g_task_table[i].last_run_ms) g_task_table[i].period_ms) { g_task_table[i].last_run_ms g_tick_ms; g_task_table[i].p_task_func(); } } } }这里的核心是(uint32_t)(g_tick_ms - g_task_table[i].last_run_ms) g_task_table[i].period_ms。使用差值而不是绝对值比较可以避免g_tick_ms从 0xFFFFFFFF 回绕到 0 时判断出错。假设g_tick_ms翻转只要两个值都是uint32_t差值计算仍然能得到正确的时间间隔。主循环看起来还是超级大循环但运行逻辑已经不一样。它不是在顺序执行所有功能而是让每个任务按照自己的周期被调度。任务函数内部的while(1)循环应当拆解成单次执行逻辑跑完就返回等待下一次调度。3.4 任务注册与主程序整合新增任务的接口如下uint8_t scheduler_add_task(void (*p_func)(void), uint32_t period_ms) { for (uint8_t i 0; i TASK_MAX_NUM; i) { if (g_task_table[i].p_task_func NULL) { g_task_table[i].p_task_func p_func; g_task_table[i].period_ms period_ms; g_task_table[i].last_run_ms g_tick_ms; g_task_table[i].enabled 1; return 1; } } return 0; }注册成功后返回 1任务表已满时返回 0。这样在上层可以打印注册失败信息避免函数指针为 NULL 的任务被注册后引发问题。主程序整合示例如下int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); scheduler_init(); scheduler_add_task(task_led_toggle, 500); scheduler_add_task(task_key_scan, 10); scheduler_run(); }task_led_toggle是一个 500ms 周期的 LED 翻转任务task_key_scan是一个 10ms 周期的按键扫描任务。两个任务互不阻塞各自按周期运行。如果之后要增加一个 100ms 的传感器读取任务只需要再写一个任务函数并调用一次scheduler_add_task。4. 深入关键实现细节中断、阻塞、临界区与状态机4.1 中断里只做标记任务交给主循环裸机调度器最容易踩的坑是写代码时觉得“在中断里执行任务更实时”于是在定时器中断里直接调用任务函数。初看好像没问题但系统复杂度一上去问题就暴露出来。一方面中断里执行耗时任务会阻塞其他中断响应尤其是串口、外部中断这类实时性要求较高的中断。另一方面中断环境下的执行时机不可控如果两个中断同时打过来嵌套优先级不能精确控制时程序的执行顺序会变得不确定。推荐做法是中断里修改标志位或计数。主循环里检查标志位并执行任务。紧急任务使用专门的高优先级外部中断而不是把所有事情都放在定时器中断里。这样写出来的程序中断响应时间和任务执行时间可以分开评估。定时器中断占用时间可以保持在几微秒以内任务执行时间则由主循环统筹。4.2 周期判断为什么用差值而不要比较绝对值新手写调度器时最容易写成下面的形式if (g_tick_ms task_table[i].last_run_ms task_table[i].period_ms)这种写法在g_tick_ms很小的时候没有问题但last_run_ms period_ms一旦超过 0xFFFFFFFF就会发生无符号整数溢出结果变成一个很小的数导致条件永远不成立任务停止调度。解决方法是使用差值的写法if ((uint32_t)(g_tick_ms - task_table[i].last_run_ms) task_table[i].period_ms)g_tick_ms - last_run_ms本身就是两个uint32_t的差值符合 C 语言的无符号整数回绕规则。只要两个数之间的真实间隔不超过 2 的 32 次方毫秒即大约 49.7 天这个判断都是正确的。对于大多数嵌入式设备正常运行时间不会超过这个值但写成差值比较更稳妥也更接近 RTOS 内部的时间判断思路。4.3 任务函数必须避免阻塞必要时用状态机改写调度器只解决了“何时启动任务”的问题并没有解决“任务运行多久”的问题。如果任务函数内部使用HAL_Delay(100)或一个超时循环等待那么这次运行会占用 100ms其他所有任务都要等待这么长时间。这就是部分项目把超级大循环改造成定时器调度之后实际效果依然不好的原因任务本身的阻塞没有消除只是从代码结构上看变成了多任务。解决阻塞问题最有效的方法是状态机。一个任务看似复杂其实通常可以拆分成几步每步只需几毫秒甚至几微秒。任务函数每次被调度时推进一个状态而不是从头到尾执行完整流程。以按键防抖为例阻塞写法是void key_scan(void) { if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) 0) { HAL_Delay(20); if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) 0) { // 按键确认 } } }这里HAL_Delay(20)在key_scan内部阻塞 20ms影响所有其他任务。状态机写法如下typedef enum { KEY_IDLE, KEY_CHECK, KEY_CONFIRM } key_fsm_t; static key_fsm_t g_key_fsm KEY_IDLE; static uint32_t g_key_start_ms 0; void task_key_scan_fsm(void) { switch (g_key_fsm) { case KEY_IDLE: if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) 0) { g_key_start_ms g_tick_ms; g_key_fsm KEY_CHECK; } break; case KEY_CHECK: if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) ! 0) { g_key_fsm KEY_IDLE; } else if ((uint32_t)(g_tick_ms - g_key_start_ms) 20) { g_key_fsm KEY_CONFIRM; } break; case KEY_CONFIRM: // 执行按键业务逻辑 g_key_fsm KEY_IDLE; break; default: g_key_fsm KEY_IDLE; break; } }这个任务每个周期只执行一次 switch 判断不阻塞也不等待。防抖时间由g_key_start_ms和当前g_tick_ms的差值来判断。实践里凡是遇到任务内部出现while(条件)、HAL_Delay、超时等待都值得改成状态机或其他非阻塞写法。4.4 共享数据的临界区保护调度器里的任务表通常只在启动阶段注册运行过程中很少修改。但如果业务代码需要在运行期间动态启停任务修改enabled字段时就要小心。g_task_table会被主循环里的scheduler_run()读取如果某个低优先级中断或回调里也修改任务表可能产生竞争条件。简单的保护方式是在修改任务表时关闭中断修改完再打开__disable_irq(); g_task_table[0].enabled 0; __enable_irq();__disable_irq()和__enable_irq()是 CMSIS 提供的全局中断开关函数适用于裸机工程。关闭中断的时间必须极短只保护关键赋值语句不能在保护区内调用耗时函数。更细的临界区保护还可以使用PRIMASK的操作或BASEPRI的优先级屏蔽方式裸机工程里先用全局开关最直观。需要注意的是如果工程同时使用断言、打印函数不要在关中断状态下调用 printf因为 UART 发送等待会拉长关中断时间。5. 运行验证与常见问题排查5.1 用串口打印或 GPIO 波形验证调度是否正确调度器写完不能只看程序有没有报错还要确认每个任务的执行周期真实符合预期。最直接的办法是在任务函数里打印 tick 时间戳。void task_led_toggle(void) { printf(led toggle at %lu ms\r\n, (unsigned long)g_tick_ms); }串口输出如果稳定出现 500ms 的间隔说明调度周期正确。如果间隔一会儿 500ms一会儿 800ms说明任务内部有阻塞或者有高优先级中断长时间打断主循环。另一种办法是让任务函数翻转一个 GPIOvoid task_led_toggle(void) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); }用逻辑分析仪或示波器测量该引脚电平变化周期。如果两个上升沿之间是 1000ms说明 500ms 周期任务正确。这个方法比串口更准确因为它不依赖串口发送耗时也不会被 printf 阻塞影响。5.2 常见工程问题与排查对照表问题现象可能原因检查方式解决方案任务周期明显偏大任务函数内部有阻塞延时在任务前后打印 tick 差值用状态机内部计时替代阻塞某个任务一直不执行enabled未置 1 或函数指针为空检查注册返回值确认scheduler_add_task返回 1加入新任务后老任务乱套任务表槽位冲突或数组越界检查TASK_MAX_NUM和注册函数增大任务表容量检查是否越界写调度器优化后不运行g_tick_ms没加 volatile查看汇编或关闭优化对比给全局共享变量加 volatile使用 HAL_Delay 后程序卡死HAL_Delay 依赖 SysTick而 SysTick 被高优先级中断抢占或关闭检查中断优先级配置和全局中断开关不要在关中断中调用 HAL_Delay串口打印乱码波特率不匹配或时钟配置错误检查 UART 初始化、时钟树重新配置系统时钟和串口参数5.3 一套可复用的排查顺序遇到任务调度不符合预期时按以下顺序排查可以快速缩小范围确认定时器中断有没有触发。在SysTick_Handler里打断点或者翻转一个调试 GPIO。确认g_tick_ms在持续增长。串口打印一次该值隔一秒再看。确认任务注册是否成功。检查scheduler_add_task返回值排除任务表已满。确认任务表的enabled是否为 1函数指针是否为 NULL。确认周期判断条件是否使用了差值写法而不是绝对值相加写法。确认任务函数是否能正常返回。任务函数如果陷入死循环调度主循环自然无法继续。最后检查中断优先级。如果 SysTick 优先级被设得很高而某外设中断持有一个长时间的临界区就可能导致系统时基被延迟。这套顺序适合大部分裸机调度器问题。核心思路是先从“时间有没有走”开始再检查“任务有没有被扫描到”最后再看“任务执行完没有”。6. 从裸机调度器到 FreeRTOS架构演进的下一步6.1 定时器模拟任务与 RTOS 的本质区别定时器模拟任务已经具备任务、时基、周期调度的雏形但它和 FreeRTOS 仍然有本质区别。裸机调度器没有任务上下文切换任务之间通过函数调用完成当前任务运行完成后才能轮到下一个任务。FreeRTOS 则在 tick 中断里保存当前任务的寄存器现场再从就绪任务表里找到最高优先级任务恢复它的现场并跳转执行。FreeRTOS 的任务切换完整流程大致是滴答定时器触发中断进入xPortSysTickHandler保存当前任务栈指针和寄存器然后调用vTaskSwitchContext查找下一个就绪任务最后恢复新任务上下文并退出中断。这套机制使得一个任务即使被阻塞操作系统也能立马切换到其他任务而不是像裸机调度器那样必须等任务函数返回。理解这个区别很重要。定时器模拟任务更接近“协作式调度”每个任务必须主动让出 CPU 控制权。RTOS 是“抢占式调度”高优先级任务可以打断低优先级任务。6.2 何时应该迁移到 RTOS定时器模拟任务适合任务数量稳定、执行时间相对固定、不需要复杂同步机制的裸机项目。当项目出现下面这些迹象时就可以认真考虑引入 FreeRTOS多个任务需要同时等待不同的事件例如等待串口发送完成等待按键释放等待传感器就绪。任务之间需要传递数据用全局变量已经很难维护。某个任务执行时间不均最坏情况下会影响其他任务周期。需要互斥量、信号量、消息队列这类同步原语而不是自己手工实现临界区。迁移 RTOS 并不意味把原来的裸机代码推倒重写。任务函数思想可以保留只是把void task_led_toggle(void)改成带无限循环的任务函数并使用vTaskDelay或任务通知替代周期判断。6.3 迁移时可以保留的设计与需要新增的机制定时器模拟任务里的以下几点迁移到 RTOS 后依然有效任务划分粒度。按键扫描、显示刷新、协议解析拆分成独立任务这个划分思路直接可用。状态机编程方法。状态机在裸机和 RTOS 里都是消除阻塞的通用手段。周期任务思想。RTOS 中可以用软件定时器或vTaskDelayUntil实现类似效果。需要新增的机制主要是同步和通信。裸机共享变量可以继续用但更好的是加入队列或信号量让任务之间通过消息交互。例如按键任务检测到按下后不再直接设置某个全局标志而是向队列发送一个按键事件显示任务接收后更新界面。这样数据流更清晰也方便以后扩展多个任务同时消费事件。6.4 生产环境中的额外保障无论使用裸机调度器还是 RTOS进入生产环境前都要额外关注几点。第一点是看门狗。调度主循环崩溃时定时器中断可能还在跑tick 继续增长程序看起来像活着。建议独立监控一个低频任务看它是否能按周期翻转“喂狗标志”不能则复位系统。第二点是日志开关。调试阶段的printf指令在生产环境要考虑是否保留保留时建议用宏控制#define LOG_ENABLE 1 #if LOG_ENABLE #define LOG_INFO(fmt, ...) printf(fmt, ##__VA_ARGS__) #else #define LOG_INFO(fmt, ...) #endif第三点是系统时基稳定性。SysTick 是内核定时器但如果某段代码长时间关中断SysTick 中断会被延迟时基就不准。任何临界区都要控制执行时间。第四点是任务表越界保护。公开接口内部要做参数检查和数组索引检查避免函数指针数组越界写导致程序跑飞。定时器模拟任务是嵌入式软件设计架构里一个承上启下的概念。它没有 RTOS 那样复杂的调度算法却能让人真正理解“任务”“时基”“周期调度”是怎么一回事。建议先在一个最小开发板上把调度器跑通观察不同任务周期的输出再把阻塞任务改造成状态机最后对比 RTOS 的调度机制。这一套走下来再回头看 FreeRTOS 源码里的任务切换流程会顺畅得多。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表