ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

裸机编程的中间路线:定时器模拟任务架构详解

裸机编程的中间路线:定时器模拟任务架构详解 很多做嵌入式开发的朋友都会在裸机编程里遇到同一个坎想让多个任务“同时”工作但代码写到最后总是变成一团乱麻。按键扫描不能卡住 LED 闪烁串口打印又不能影响电机控制一个地方用了延时整个系统的节奏全被打乱。这个时候有人会告诉你“上 RTOS 吧”但对于很多资源受限、项目周期紧、或者场景本来就不复杂的项目来说引入 RTOS 往往是大炮打蚊子反而增加了调试和排错的复杂度。那有没有一条中间路线当然有这就是本文要讲的定时器模拟任务。简单说就是用定时器中断作为“心脏”把原本在超级大循环里干等延时的任务拆成一个个小的状态机在固定的时间片里轮转执行。它算不上真正的多任务但能用最小的成本让裸机程序具备多任务并存的效果。这是一个非常经典的嵌入式软件设计架构思路也是许多老工程师在裸机项目里的首选方案。这篇文章会把概念、原理、代码、坑点一次性讲透。你可以把这套思路直接用在 STM32、51、ESP32 等主流单片机上。读完你会明白什么时候该用大循环什么时候该用模拟任务以及怎么用代码把它落到真实项目里。1. 先回答一个关键问题为什么需要模拟任务架构先看一个很典型的场景。假设你正在做一个智能小车项目主控是 STM32F103。需求看起来并不复杂LED 每隔 200ms 闪烁一次按键按下后切换电机转速OLED 屏幕每 100ms 刷新一次数据串口每 500ms 打印一次运行状态。用最直觉的写法是下面这样的while (1) { LED_Blink(); // 里面用了 delay(200) Key_Scan(); // 里面有延时消抖 OLED_Refresh(); // 刷新屏幕耗时可能 10ms UART_SendStatus(); // 打包发送耗时不确定 }问题在哪里呢只要Key_Scan()里有个 10ms 的延时LED 的 200ms 闪烁周期马上就会被拉长变成 210ms 多。OLED 刷新如果耗时太长串口发送就会滞后。更严重的是如果你在一段代码里用了delay(1000)整个系统在这一秒里基本等于“死机”。所有任务都在一个循环里排队谁耗时多谁就拖慢所有人。这就是最典型的超级大循环Super Loop架构。超级大循环的痛点总结起来就是三个痛点具体表现后果阻塞耗时任意一个任务的延时或等待都会卡住整个循环系统实时性极差耦合严重所有任务代码都挤在 main 函数的 while 里代码阅读、维护困难扩展性差新增一个功能就得往循环里再塞一段循环越来越长越来越乱那为什么不用 RTOS 呢因为很多项目根本不需要。任务只有三五个内存只有几十 KB产品生命周期里也不会出现大型的业务逻辑。为了这么点需求引入 FreeRTOS意味着你要学信号量、队列、任务优先级还可能要处理优先级反转、临界区保护这些复杂问题。开发成本和 bug 出现的概率反而更高。所以定时器模拟任务架构就是那个恰到好处的中间方案。它不引入实时操作系统却能让每个任务拥有相对独立的“执行节奏”。它在架构层解决的核心问题不是“并发”而是**“定时”和“分片”**。2. 定时器的本质嵌入式系统的“心跳”要理解模拟任务必须先理解定时器的角色。定时器在嵌入式系统里的地位相当于人的心跳。它负责给整个系统提供一个稳定的时间基准所有任务都靠这个基准来安排自己什么时候干活。2.1 硬件定时器与软件定时器的区别先做一个概念区分很多初学者会把这两者搞混。硬件定时器是芯片内部的一个外设本质是一个计数器。它依赖时钟源自己数数数到设定值之后触发中断或者翻转引脚。它不消耗 CPU 资源是“硬件层面”的时间计量工具。STM32 里的 TIM2、TIM351 单片机里的 Timer0、Timer1都是硬件定时器。软件定时器则完全不同。它是基于硬件定时器中断在软件层做出来的“虚拟定时器”。典型做法是硬件定时器每隔 1ms 产生一次中断中断服务函数里对几个全局变量做操作。每个变量代表一个定时器谁计数到目标值就置位一个标志位主循环检查到这个标志位后执行对应的任务。模拟任务架构真正依托的是软件定时器。硬件定时器只是它的底座。2.2 定时器中断的执行机制当硬件定时器计数到设定值CPU 会暂停当前执行的代码跳转到中断服务函数ISR里执行中断逻辑。执行完再返回暂停的位置。这带来一个非常重要的特性**中断可以打断主循环。**哪怕主循环里正在跑一个很耗时的算法定时器中断依然能够准时触发。这就是模拟任务架构能够脱离超级大循环的根本原因。不过在中断里执行代码有两条铁律必须遵守中断服务函数必须尽量短能不做的事绝不在中断里做。中断里不能调用带延时、阻塞、或者非重入的函数比如HAL_Delay()、printf()这类函数绝对不能出现在中断里。很多新手第一次写模拟任务架构就是栽在这里他直接在定时器中断里调用HAL_Delay(5)结果中断没退出主循环进不去看起来比超级大循环还卡。正确做法是中断里只做标记主循环里做任务。3. 模拟任务架构的核心设计思路现在进入正题。定时器模拟任务本质上是一种协作式时间片轮转调度。它把 CPU 时间分成一小片一小片每一片给一个任务用。任务执行完必须主动让出 CPU主循环才能继续调度下一个任务。3.1 时间片复用思想时间片复用听起来高级其实非常好理解。我们用一个生活场景来类比。假设你是一家奶茶店的老板手上只有一个员工。这个员工一小时内要做三件事煮珍珠、打奶盖、招呼客人。如果他在煮珍珠时一定要全程盯着锅看 20 分钟那他这 20 分钟里什么也干不了。但如果他改用“先烧水再去招呼客人5 分钟后再回来放珍珠”的方式就能把时间复用起来三件事都能推进。嵌入式里的定时器模拟任务就是同一个思路。LED 闪烁任务不需要 CPU 一直盯着它只需要在到达 200ms 时间点的时候翻转一次引脚电平。所以 CPU 完全可以在这 200ms 里去做按键扫描、串口发送这些事情。这就是“分时复用”。3.2 从“轮询”到“事件驱动”的转变超级大循环是典型的轮询架构每个循环都去问“按键按下了吗时间到了吗数据来了吗”。不管有没有事件发生每个任务都被完整执行一遍。模拟任务架构则迈向了事件驱动的雏形。它的运行逻辑变成定时器中断产生时间事件。主循环检查各个时间标志。只有标志被置位的任务才执行。这个转变非常关键。它意味着一个任务没有到执行时间时主循环根本不会进入它的函数体CPU 不会为它浪费任何指令周期。这也是模拟任务架构比大循环更省资源的原因。从行业趋势来看很多嵌入式工程师都在从“超级大循环”走向“事件驱动”这确实是嵌入式架构升级的一道分水岭。但这里要说清楚模拟任务只是事件驱动的一种简化实现它不涉及操作系统的任务切换也不涉及优先级抢占它依靠的是“每个任务主动检查自己的执行条件”。3.3 模拟任务的三要素一个完整的模拟任务由三个要素组成第一时间标志。每个任务都有一个关联的运行周期。比如 LED 任务是 200ms按键扫描是 10ms。在定时器中断里用计数器对周期做累加到达周期后置位标志位。这是任务的“闹钟”。第二状态机。每个任务内部必须拆成几个状态一次只处理一个状态。这是任务的“执行逻辑”。第三非阻塞执行。执行任务时不能有阻塞等待。如果任务执行到一半需要等待外部条件应该记录当前状态先退出函数等下一次调度时再继续。这是任务的“纪律”。可以把这三要素理解成闹钟时间标志 剧本状态机 演员守则非阻塞。三者齐全一个任务才能真正融入模拟任务架构。4. 环境准备与前置条件纸上谈兵没有意义下面用一个可运行的工程来演示。为了让示例尽量通用这里以 STM32F103 系列芯片为硬件平台开发环境选择 STM32CubeIDE 或者 Keil MDK 都可以核心代码并不依赖某个特定 IDE。建议前置条件如下一块 STM32F103 最小系统板C8T6 就够用。一个 LED 连接到 PC13很多开发板板载 LED 就是这个引脚。一个按键连接到 PA0。STM32CubeMX 生成基础工程开启 SysTick 或 TIM2 作为时基。需要说明的是本示例不依赖 HAL 库的HAL_Delay()只使用 HAL 库的定时器中断回调机制。如果你想用手工配置寄存器的方式原理完全一致只是代码写法不同。本文重点演示的是通用的架构思路。如果使用 STM32CubeMX需要配置的内容如下时钟源内部 8MHz 或外部晶振均可毕竟我们只用定时器计时对主频不敏感。定时器使能 TIM2预分频和自动重载值根据你的时钟频率计算目标是让更新中断频率为 1kHz也就是 1ms 进一次中断。中断使能 TIM2 的全局中断。GPIOPC13 设为输出PA0 设为输入可配下拉电阻。5. 完整代码实现一个三任务并行的模拟调度器下面直接给出一套完整代码。这个示例包含三个模拟任务LED 闪烁周期 200ms、按键扫描周期 10ms、串口状态打印周期 500ms。5.1 任务定义与全局变量先定义任务控制结构。这里不需要像 RTOS 那么复杂每个任务只需要三个字段运行周期、计数器、时间标志。// 文件路径user/task.h #ifndef TASK_H #define TASK_H #include main.h #define TASK_NUM 3 typedef struct { uint16_t period; // 任务运行周期单位 ms uint16_t counter; // 倒计时计数器 uint8_t flag; // 时间到标志置 1 表示该执行任务了 } SoftTimer_TypeDef; extern SoftTimer_TypeDef softTimer[TASK_NUM]; #define TASK_LED 0 #define TASK_KEY 1 #define TASK_UART 2 void SoftTimer_Init(void); void SoftTimer_Proc(void); void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim); #endif// 文件路径user/task.c #include task.h #include gpio.h #include usart.h SoftTimer_TypeDef softTimer[TASK_NUM]; // 初始化三个软件定时器 void SoftTimer_Init(void) { softTimer[TASK_LED].period 200; softTimer[TASK_LED].counter 0; softTimer[TASK_LED].flag 0; softTimer[TASK_KEY].period 10; softTimer[TASK_KEY].counter 0; softTimer[TASK_KEY].flag 0; softTimer[TASK_UART].period 500; softTimer[TASK_UART].counter 0; softTimer[TASK_UART].flag 0; }这里用一个数组来管理多个任务后续要新增任务只需要扩展数组长度再加上任务各自的函数实现即可。这种设计的好处是调度逻辑是通用的不需要为每个新增任务重复写一套。5.2 定时器中断服务函数定时器中断服务函数是整个架构的“心跳源”。它的作用很简单每隔 1ms 对每个任务的计数器做一次减一操作减到 0 就重新装载周期值并置位时间标志。// 放在 stm32f1xx_it.c 或由 HAL 库回调到本文件 void SysTick_Handler(void) { HAL_IncTick(); } // 这里由 HAL 库统一回调TIM2 更新事件会进入本函数 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { uint8_t i; for (i 0; i TASK_NUM; i) { if (softTimer[i].counter 0) { softTimer[i].counter--; } if (softTimer[i].counter 0) { softTimer[i].flag 1; softTimer[i].counter softTimer[i].period; } } } }这段代码有什么讲究呢注意中断里只对计数器和标志位做操作不执行任何业务逻辑。LED 翻转、按键读取、串口发送这些全部被放到主循环里的SoftTimer_Proc()中。延迟 1ms 进一次中断中断里是几行简单的比较和赋值语句整个中断耗时可以控制在几微秒以内完全不会影响主流程。5.3 主循环调度器// 文件路径user/task.c (续) void SoftTimer_Proc(void) { if (softTimer[TASK_LED].flag 1) { softTimer[TASK_LED].flag 0; HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } if (softTimer[TASK_KEY].flag 1) { softTimer[TASK_KEY].flag 0; Key_ScanTask(); } if (softTimer[TASK_UART].flag 1) { softTimer[TASK_UART].flag 0; UART_ReportTask(); } }这个调度函数非常朴素检查标志位执行任务清标志位。但它已经具备了一个微型调度器的雏形。主循环只要不断调用SoftTimer_Proc()就能保证每个任务按自己的节奏运行。5.4 任务函数按键扫描状态机按键扫描是模拟任务里最典型的一个例子。如果不做状态机按键消抖就要用delay(10)而这个延时一旦进入任务函数其他任务也会跟着卡住。所以这里使用一个简单的两状态状态机。// 文件路径user/task.c (续) static void Key_ScanTask(void) { static uint8_t keyState 0; switch (keyState) { case 0: // 空闲状态 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { keyState 1; // 发现按键可能按下进入确认状态 } break; case 1: // 消抖确认状态 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) GPIO_PIN_RESET) { // 确实按下了执行一次业务处理 keyState 0; // 这里可以设置电机转速等全局变量 } else { keyState 0; // 抖动取消本次按键 } break; default: keyState 0; break; } }为什么需要状态机因为这个函数每次只被调用一次一次只执行一个状态。按下、消抖、确认这个流程被拆到了多次调用中完成。在第 1 次调用时发现按键按下不做延时直接跳到确认状态第 10ms 后第 2 次调用再来看按键是否仍然按下。这样就把原本需要“延时 10ms”的阻塞逻辑转换成了“分次检查”的非阻塞逻辑。5.5 任务函数串口状态上报串口打印任务如果直接用printf在某些单片机配置下会阻塞运行尤其当串口外设忙时CPU 会一直在等待。更稳妥的做法是准备一个环形缓冲区把要发送的数据放进去由串口中断或者 DMA 真正去传输。// 文件路径user/task.c (续) static void UART_ReportTask(void) { static uint8_t tick 0; char buf[32]; tick; sprintf(buf, tick %d\r\n, tick); // 这里使用非阻塞方式发送具体函数视你的串口驱动而定 UART_SendString_DMA((uint8_t *)buf, strlen(buf)); }关键点是sprintf在任务函数里执行没问题但UART_SendString_DMA必须是非阻塞的。也就是说它把缓冲区地址和长度交给 DMA 控制器后立即返回实际发送在后台进行。如果怕地址失效可以把缓冲区定义为静态数组。5.6 主函数// 文件路径Core/Src/main.c (关键部分) int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); MX_USART1_UART_Init(); // 初始化软件定时器 SoftTimer_Init(); // 启动 TIM2开始产生 1ms 中断 HAL_TIM_Base_Start_IT(htim2); while (1) { // 主循环只做一件事让调度器去检查任务 SoftTimer_Proc(); } }整个 main 函数非常干净这是模拟任务架构对比超级大循环最有说服力的地方。主循环不需要知道具体任务怎么实现它只需要不断地调用SoftTimer_Proc()让调度器去决定“现在执行哪个任务”。6. 运行结果与效果验证把上面的代码烧录到开发板你会看到以下现象板载 LED 以约 200ms 周期翻转也就是 5Hz 左右在闪烁。这个闪烁频率稳定不受按键按下次数影响。按下 PA0 按键任务会执行一次按键业务逻辑但不会导致 LED 闪烁中断或变慢。串口每隔 500ms 会输出一条tick x的消息格式稳定与按键动作无关。如果一切正常说明三个任务已经实现了“逻辑并行”。为了进一步确认调度效果你还可以故意在UART_ReportTask()里加一段HAL_Delay(50)然后观察 LED。你会发现 LED 的闪烁开始出现不稳定的抖动。这恰好说明了一个问题模拟任务架构虽然能把大循环里的“干等延时”拆掉但如果任务函数内部再次引入阻塞操作整个调度照样会被卡住。所以架构只管“定时调度”真正要控制的是每个任务内部的执行纪律。验证时建议再加一个辅助手段在SoftTimer_Proc()的循环里加一个翻转引脚 GPOID 的操作用示波器测量这个引脚的波形。如果调度顺畅你会看到主循环的执行频率是稳定的如果出现卡顿波形会立刻显示出毛刺。如果发现某个定时器不工作按下面的顺序排查检查 TIM2 是否真正启动了HAL_TIM_Base_Start_IT(htim2)。漏掉这一句所有软件定时器都不会跳动。检查HAL_TIM_PeriodElapsedCallback是否被正确触发可以在回调里临时翻转一个调试引脚确认。检查主循环是否调用了SoftTimer_Proc()。如果主循环里自己写了一个HAL_Delay(1000)全局阻塞定时器中断可能照常触发但主循环里SoftTimer_Proc()没有机会执行任务标志位全被置位却无人消费。7. 常见问题与排查思路模拟任务架构本身不复杂但在实际工程里新手遇到的问题往往集中在几个固定的地方。这里整理成一个表格方便你按图索骥。问题现象可能原因排查方式解决方案任务完全不动定时器更新中断没有开启检查HAL_TIM_Base_Start_IT是否调用调用HAL_TIM_Base_Start_IT(htim)启动中断任务执行时间明显不准任务函数内使用了阻塞延时查看任务代码是否含HAL_Delay把阻塞延时改为状态机或删掉两个任务同时抢占一个变量任务里访问全局变量时未考虑中断检查中断与主循环是否读写同一变量中断只置标志位变量在主循环里读写进中断后系统卡死中断服务函数中调用了不可重入函数检查回调代码是否干净保证 ISR 里只做标志位操作按键扫描偶尔失灵消抖逻辑未正确处理抖动打印按键状态观察状态切换节奏增加状态机至少两次确认串口数据乱码缓冲区地址失效或发送未完成检查 DMA 回调确认发送完成标志使用静态全局缓冲区用环形队列保证数据完整性这里要特别强调第一类问题。很多人在 CubeMX 里配置好了 TIM2以为它就会自动工作。实际上TIM2 初始化后默认是关闭状态的必须调用HAL_TIM_Base_Start_IT(htim2)更新中断才会真正使能。这一步是新手最容易漏掉的。8. 最佳实践与工程建议定时器模拟任务是一个很灵活的架构但正因为它灵活如果设计时没有规则代码很快就会变得难以控制。下面是我在实际项目中总结的几条建议每一条都踩过坑值得你认真对待。8.1 中断里只做一件事更新计数器和标志位这条规则再怎么强调也不过分。定时器中断是这个架构的心脏它的执行时间直接决定系统的最小响应延迟。如果你在中断里做了浮点数计算、调用了长时间的函数系统响应就会变慢甚至出现中断嵌套错乱。建议的严格标准是中断服务函数里的代码控制在几十行以内只做整型变量的自增、比较、赋值。如果要做复杂操作应该把操作转化成一个“待执行请求”也就是置位一个更高层次的标志由主循环消费。8.2 每个任务尽量做到“一次调用马上返回”模拟任务不是多线程它没有真正的抢断能力。一个任务执行多久取决于它自己什么时候退出函数。所以任务函数内部的设计原则是不做任何while循环等待。不调用会阻塞的外部库函数。一个任务如果需要处理很多数据把它拆成多个状态一次处理一部分。比如串口解析任务如果缓冲区里有 100 帧数据不要在一个调用里全部解析完每一帧的处理都可能导致其他任务被延迟。正确做法是每次只处理一帧剩余的在下一轮调度时继续。8.3 任务周期之间保持合理的整数倍数关系如果 Task A 的周期是 20msTask B 的周期是 30ms两个任务的时间点会偶尔重叠导致某一瞬间响应变慢。建议把任务周期设置为一个基础时基的整数倍比如 10ms、20ms、50ms、100ms、500ms。这样调度节奏稳定、可预测。8.4 共享变量的访问注意原子性模拟任务架构里任务之间的通信通常通过全局变量完成。如果某个全局变量被中断以外的任务修改同时又被另一个任务读取而这个变量的类型是 8 位或 16 位的通常没问题。但如果变量是 32 位的或者结构体、数组建议在访问时关闭中断保护。__disable_irq(); // 访问共享结构体 __enable_irq();代码很短但能避免很多难以复现的偶发 bug。8.5 做好任务执行时间预算虽然模拟任务已经把“延时”拆掉了但每个任务还是有执行时间的。在实际项目里建议用示波器测量一下每个任务的执行时间估算出最坏情况下整个SoftTimer_Proc()的耗时。这决定了系统能否满足最差情况下的实时性要求。比如 LED 任务耗时 1us按键任务耗时 5us串口任务耗时 100us主要是 sprintf那么一轮调度在最坏情况下可能在 200us 左右。这个量级对于绝大多数裸机场景完全够用。8.6 从超级大循环迁移时的步骤建议如果你是在改造一个已经跑得通的超级大循环项目不要一下子全重写。建议按下面的步骤推进列出现有 while 循环里的所有任务估算每个任务的周期。对涉及延时的任务先用“标志位等待”替代延时把延时拆掉。建立软件定时器框架把拆好的任务挂到对应的周期槽里。每挂一个任务就实测一下原有功能是否正常再挂下一个。全部挂完后再统一整理全局变量精简共享数据。这套迁移路径风险很小每一步都能验证非常适合正在维护老项目的工程师。9. 继续向事件驱动架构迈进的路线定时器模拟任务只是解决了一个基础问题如何让多个任务按各自周期运行。但真正的嵌入式系统还常常需要处理外部事件比如按键来了、串口收到数据了、传感器数值超限了。模拟任务架构对这类“异步事件”处理得还比较原始。下一步你可以在现有架构上引入一个“事件标志组”的概念。每个模块把产生的事件写到一个全局变量中主循环在调度时检查并处理。这其实就是事件驱动架构的雏形。很多轻量级框架比如很多人用的 ProtoThreads、或者基于函数指针的简单状态机都是在这个阶段再加一层。从更长远的角度看如果你发现任务数量超过 10 个、任务之间有频繁的数据交换和复杂依赖、某些任务要求微秒级的实时响应那模拟任务架构就不太够用了。这才是真正应该切换到 FreeRTOS 或 RT-Thread 的时刻。判断的标准很简单当代码里开始出现大量“标志位互相等待”“状态机嵌套状态机”时就该考虑上 RTOS 了。在那之前模拟任务架构能帮你用最小的复杂度解决 80% 的问题。10. 总结定时器模拟任务架构是嵌入式裸机开发里性价比非常高的一种软件设计方式。它不需要引入额外系统却能通过“定时器中断 软件定时器 状态机”的组合把超级大循环里的痛点逐个拆解掉。核心思想可以概括为用定时器做时间基准用标志位做任务调度用状态机做非阻塞任务逻辑。如果你现在还被疯狂的延时和嵌套循环折磨强烈建议在下一个项目里尝试这个架构。先把基础的 1ms 心跳跑通再看手头任务各自需要的周期最后把业务逻辑逐步移植成状态机。你会明显感受到代码变得清爽了调试也轻松了很多。后续如果再遇到“同时处理按键、LED、屏幕、串口”这类需求就不会再被主循环堵得头疼了。
返回列表