
一个很常见的问题嵌入式裸机项目刚启动时代码大多是一套“超级大循环”结构while(1)里按顺序执行 LCD 刷新、按键扫描、传感器读取、串口上报。在原型验证阶段这种写法确实简单直接但随着外设增多、功能逻辑变复杂你会很快发现一个致命问题某个模块里一次delay_ms(50)会让整个主循环卡住其他模块全部跟着掉链子。按键不灵、显示卡顿、通讯超时问题却难以定位。真正让嵌入式代码从“能跑”走向“可靠”的不是硬件多高端而是软件架构如何组织而所有嵌入式软件架构的地基几乎都离不开一样东西——定时器。这篇文章想讲清楚一件事如何用定时器把裸机程序从“超级大循环”改造成“时间片轮询 事件驱动”的任务式架构并且给出可以直接抄的 C 代码实现。读完你会获得三样东西第一一套比delay阻塞式更优雅的裸机任务调度思路第二可以直接移植到 STM32、51、GD32 等常见 MCU 的定时器任务代码第三从裸机软件定时器逐步过渡到 RTOS 的清晰判断。如果你正在写带按键、显示、多传感器的小型嵌入式项目这篇文章应该能省下不少重构时间。1. 这篇文章真正要解决的问题先讲一个典型的开发场景。你接管了一个跑在 STM32F103 上的项目代码长这样while (1) { key_scan(); // 大约耗时 2ms if (key_pressed()) { show_menu(); // 全屏刷新耗时 80ms } sensor_read(); // 阻塞读取温度耗时 30ms lcd_refresh(); // 刷新显示耗时 20ms uart_report(); // 串口上传耗时 10ms delay_ms(10); // 保底延时 }一眼看过去似乎没什么毛病但实际运行中会出现一系列隐藏问题按键扫描的实时性取决于主循环跑一轮的时间。如果show_menu()用了 80ms那么在最坏情况下用户按下按键要等 80ms 才能被扫描到体感就是“按键不灵敏”。某个传感器驱动里如果用了while(等待标志)这样的阻塞等待而传感器恰好 I2C 无响应整个程序就会卡死在那里。想要给某个模块增加独立频率比如 LED 500ms 翻转一次、按键 5ms 扫描一次、串口 1s 上报一次在超级大循环里很难优雅地表达。这就是“实时性不足”和“模块耦合过重”的典型症状。还有一个更隐蔽的问题所有阻塞延时都是在浪费 CPU。delay_ms(10)期间CPU 只是空转什么事情都没做。在低功耗场景下这种方式还会让整个系统无法进入睡眠模式因为主循环总是在跑。所以这篇文章要解决的问题可以概括为一句话在不引入 RTOS 的前提下用定时器作为“心跳”把裸机程序改造成多个可独立设置周期的任务让每个模块该多久执行一次就多久执行一次互不拖累。2. 基础概念前后台、时间片轮询与事件驱动改造架构之前先统一一下基本概念。嵌入式裸机开发的底层模型通常有两个阶段前后台系统和时间片轮询系统再往上就是事件驱动和 RTOS。2.1 前后台系统“前台”是中断“后台”是main函数的while(1)主循环。中断负责处理紧急事情比如接收串口数据、检测外部信号主循环负责处理非紧急事情比如刷新屏幕、更新按键状态。这种模型的核心问题是主循环是“串行”的。一旦循环中某个操作耗时过长后续所有任务都会被延迟。前台中断虽然可以立刻打断主循环但如果中断服务函数里做了太多事情又会导致主循环饥饿。2.2 时间片轮询时间片轮询的思路是把主循环的时间切分成固定的小段比如 1ms 一个 tick。每个任务记录自己上次执行的时间判断“当前时间 - 上次执行时间 自己的周期”时才执行一次。这样LED 任务周期 500ms每 500 个 tick 执行一次。按键任务周期 5ms每 5 个 tick 执行一次。串口上报任务周期 1000ms每 1000 个 tick 执行一次。所有任务在同一个主循环里被调度但每个任务都有自己的“节奏”。谁也不会因为别的任务执行时间长而被阻塞除非某个任务的执行时间超过了它自己的周期。2.3 事件驱动事件驱动是时间片轮询的进一步升级。中断里只负责做两件事收集数据和置标志位不做复杂处理。主循环检测到标志位置位后才去处理对应事件。举例来说按键中断里不直接执行菜单逻辑只是把g_key_event_flag 1;置上主循环发现标志位后才执行菜单切换。这样既保证了中断的短小又让主循环可以有条理地“响应事件”。2.4 定时器在架构中的角色定时器在整个架构里扮演“心跳源”。没有定时器时间片轮询就是空中楼阁。常见做法是用 SysTick 或者其他硬件定时器产生固定周期中断比如 1ms在中断服务函数里维护一个毫秒计数器主循环通过读取这个计数器来获得当前时间。这里要强调一个容易误解的点很多人以为“定时器任务”就是单纯用定时器中断去执行任务。实际上中断里直接执行任务是大忌因为中断服务函数必须短小。更合理的做法是定时器中断只负责“数数”主循环负责“干活”。下面这个表格概括了三种组织方式的区别组织方式任务频率控制阻塞影响中断耗时适用场景超级大循环靠代码顺序弱一个阻塞全部卡住无极简单原型时间片轮询每个任务独立周期单次超时会拖慢整体只更新时间戳多周期任务裸机项目事件驱动事件触发处理要快只置标志/存数据有外部中断交互项目3. 定时器任务架构设计从心跳到任务表要把“定时器模拟任务”落地架构上需要拆成三层考虑时基层、任务管理层、应用任务层。3.1 时基层时基层是整个系统的心跳。它决定“1 个 tick 是多长时间”。经验上1ms 是大多数场景的通用选择按键 5ms 扫描、LED 500ms 翻转、传感器 10ms 读取、串口 100ms 上报都能用 1ms tick 组合出来。如果项目要求更高精度可以改 100us但要注意中断频率提高后对 CPU 的占用也会增加。时基层要对外提供两个接口platform_tick_init()初始化硬件定时器。platform_tick_get()获取当前毫秒计数。3.2 任务管理层任务管理层维护一张任务表每个任务占一项。典型任务控制块包含任务函数指针。任务周期。上次运行的时刻。任务状态就绪、阻塞、挂起。是否被使用。主循环每次调用task_dispatch()就遍历任务表找出“当前时刻 - 上次运行时刻 周期”的任务执行它。这是一种最简单的非抢占式调度器。它不打断任务任务执行完才会轮到下一个任务。3.3 应用任务层应用任务层就是你具体要做的功能LED 翻转、按键扫描、传感器读取、数据上报。每个任务函数内部要遵守一个铁律不要使用长时间的阻塞延时。如果任务本身需要等待外部事件应该使用状态机或者把等待拆成多次执行的判断。3.4 可扩展的软定时器任务表方案适合“周期任务”。但实际项目里还有一种常见需求某个功能要延迟一段时间再执行或者每隔一段时间触发一次回调但不希望为它单独建立一个任务函数。这时候就需要“软定时器”。软定时器的本质是一组被定时器时基驱动的回调注册表。它比任务表更轻量也更灵活。后面代码实现里我会把任务表和软定时器一起写出来。4. 环境准备与前置条件本文代码是纯 C 实现不依赖具体品牌 SDK可以移植到大多数 MCU。为了便于演示我以 STM32 Cortex-M 内核的 SysTick 为例来写时基层但思路完全适用于 51、GD32、ESP32、Linux 下的普通 C 程序。需要准备的东西一块裸机开发板不限型号。一个交叉编译工具链如 arm-none-eabi-gcc Makefile或者 STM32CubeIDE、Keil 等 IDE。一个串口调试助手用于观察验证结果。一个逻辑分析仪或者示波器用于检查定时精度但不是必需。如果你用的是其他 MCU只需要修改platform_tick.c里的时基初始化部分其他模块可以原样保留。版本细节以你当前工程实际为准本文的代码重点演示通用架构思路。5. 完整示例代码实现下面开始写代码。整个工程分为四个文件时基模块、任务调度模块、软定时器模块、应用示例。5.1 时基模块代码文件路径bsp/platform_tick.h#ifndef PLATFORM_TICK_H #define PLATFORM_TICK_H #include stdint.h void platform_tick_init(void); uint32_t platform_tick_get(void); #endif文件路径bsp/platform_tick.c#include platform_tick.h static volatile uint32_t s_tick_ms 0; void SysTick_Handler(void) { s_tick_ms; } void platform_tick_init(void) { // 以 STM32 CMSIS 为例配置 SysTick 中断为 1ms // SystemCoreClock 是 CMSIS 提供的系统主频变量 SysTick_Config(SystemCoreClock / 1000); } uint32_t platform_tick_get(void) { return s_tick_ms; }这段代码的关键点是s_tick_ms被声明为volatile uint32_t因为它在中断里被修改在主循环里被读取如果不加volatile编译器可能为了优化而缓存这个变量导致主循环读取不到最新值。另外SysTick_Config(SystemCoreClock / 1000)表示把 SysTick 的计数频率设置为主频的 1/1000也就是 1ms 触发一次中断。如果你的系统主频是 72MHz那么SysTick_Config(72000)就是 1ms。这里需要特别提醒SysTick_Handler是启动文件里已经定义过的默认中断服务函数名。如果工程里已经有其他地方定义了同名函数会造成重复定义链接错误如果没有定义则需要确认启动文件引用的就是这个断向量名。5.2 任务注册表代码文件路径kernel/app_task.h#ifndef APP_TASK_H #define APP_TASK_H #include stdint.h #define TASK_MAX_NUM 8 #define TASK_STATE_READY 0 #define TASK_STATE_BLOCK 1 typedef void (*task_func_t)(void); typedef struct { uint8_t used; uint8_t state; task_func_t func; uint32_t period_ms; uint32_t last_run_tick; } task_tcb_t; int task_create(task_func_t func, uint32_t period_ms); void task_dispatch(void); #endif文件路径kernel/app_task.c#include app_task.h #include bsp/platform_tick.h static task_tcb_t g_task_table[TASK_MAX_NUM]; int task_create(task_func_t func, uint32_t period_ms) { for (int i 0; i TASK_MAX_NUM; i) { if (g_task_table[i].used 0) { g_task_table[i].used 1; g_task_table[i].state TASK_STATE_READY; g_task_table[i].func func; g_task_table[i].period_ms period_ms; g_task_table[i].last_run_tick platform_tick_get(); return i; } } return -1; } void task_dispatch(void) { uint32_t now platform_tick_get(); for (int i 0; i TASK_MAX_NUM; i) { if (g_task_table[i].used 0) { continue; } if (g_task_table[i].state ! TASK_STATE_READY) { continue; } uint32_t elapsed now - g_task_table[i].last_run_tick; if (elapsed g_task_table[i].period_ms) { g_task_table[i].last_run_tick now; g_task_table[i].func(); } } }task_create负责往任务表里注册一个周期任务。task_dispatch则是主循环的灵魂它做三件事获取当前 tick 值。遍历任务表跳过未使用和阻塞的任务。根据当前时间和上次运行时间的时间差判断任务是否到期。这里有一个新手容易忽略的细节uint32_t elapsed now - last_run_tick;不是简单比较now last_run_tick period_ms而是用无符号减法算差值。这样即使uint32_t计数器发生回绕从0xFFFFFFFF回到0时间差依然能正确计算。这是嵌入式定时器编程里非常经典的回绕处理技巧。5.3 软定时器模块代码任务表适合管理“永久运行”的周期任务。软定时器则适合管理“延迟执行”和“单次回调”的场景。比如按键按下后需要等 50ms 做消抖或者某条消息要在 200ms 后超时都需要软定时器。文件路径kernel/sw_timer.h#ifndef SW_TIMER_H #define SW_TIMER_H #include stdint.h #define SW_TIMER_MAX 16 #define SW_TIMER_MODE_REPEAT 0 #define SW_TIMER_MODE_ONESHOT 1 typedef void (*sw_timer_cb_t)(void *arg); typedef struct { uint8_t used; uint8_t mode; uint8_t active; uint32_t period_ms; uint32_t last_tick; sw_timer_cb_t cb; void *arg; } sw_timer_t; int sw_timer_start(uint32_t period_ms, uint8_t mode, sw_timer_cb_t cb, void *arg); void sw_timer_stop(int id); void sw_timer_poll(void); #endif文件路径kernel/sw_timer.c#include sw_timer.h #include bsp/platform_tick.h static sw_timer_t g_timers[SW_TIMER_MAX]; int sw_timer_start(uint32_t period_ms, uint8_t mode, sw_timer_cb_t cb, void *arg) { for (int i 0; i SW_TIMER_MAX; i) { if (g_timers[i].used 0) { g_timers[i].used 1; g_timers[i].mode mode; g_timers[i].active 1; g_timers[i].period_ms period_ms; g_timers[i].last_tick platform_tick_get(); g_timers[i].cb cb; g_timers[i].arg arg; return i; } } return -1; } void sw_timer_stop(int id) { if (id 0 || id SW_TIMER_MAX) { return; } g_timers[id].active 0; } void sw_timer_poll(void) { uint32_t now platform_tick_get(); for (int i 0; i SW_TIMER_MAX; i) { if (g_timers[i].used 0 || g_timers[i].active 0) { continue; } uint32_t elapsed now - g_timers[i].last_tick; if (elapsed g_timers[i].period_ms) { g_timers[i].last_tick now; if (g_timers[i].cb) { g_timers[i].cb(g_timers[i].arg); } if (g_timers[i].mode SW_TIMER_MODE_ONESHOT) { g_timers[i].active 0; } } } }和任务表不同软定时器把回调函数作为参数传入不占用任务表数量。sw_timer_poll需要在主循环里周期调用或者在SysTick中断里调用。如果在中断里调用回调函数会运行在中断上下文必须保证回调非常短小。更稳妥的做法是在主循环里调用这样回调函数运行在主循环上下文可以稍微放松限制。5.4 综合应用示例把上面的模块组合起来写一个演示程序LED 每 500ms 翻转一次按键每 5ms 扫描一次串口每 1000ms 上报一次状态并且使用一个软定时器在系统启动后 3s 打印一条提示。文件路径app/app_demo.c#include stdio.h #include kernel/app_task.h #include kernel/sw_timer.h #include bsp/platform_tick.h static uint8_t s_led_state 0; static uint32_t s_run_count 0; // 模拟 LED 读取实际项目中换成 GPIO 寄存器操作 static uint8_t read_led(void) { return s_led_state; } static void write_led(uint8_t state) { s_led_state state; } // 模拟按键读取实际项目中换成 GPIO 电平读取 static uint8_t read_key(void) { static uint8_t level 1; level (level 1) ? 0 : 1; // 模拟按键按下一次 return level; } static void led_task(void) { write_led(read_led() ^ 1); } static void key_scan_task(void) { static uint8_t last_level 1; uint8_t level read_key(); if (last_level 1 level 0) { printf(key pressed at tick%lu\n, platform_tick_get()); } last_level level; } static void report_task(void) { s_run_count; printf([report] tick%lu, run_count%lu, led%d\n, platform_tick_get(), s_run_count, read_led()); } static void boot_tip_cb(void *arg) { printf(boot tip: sw timer expired, arg0x%p\n, arg); } void app_demo_init(void) { // 启动一个 3000ms 的单次软定时器 sw_timer_start(3000, SW_TIMER_MODE_ONESHOT, boot_tip_cb, (void *)0x1); // 注册三个周期任务各自独立频率 task_create(led_task, 500); task_create(key_scan_task, 5); task_create(report_task, 1000); }文件路径main.c#include bsp/platform_tick.h #include kernel/app_task.h #include kernel/sw_timer.h void app_demo_init(void); int main(void) { platform_tick_init(); app_demo_init(); while (1) { task_dispatch(); sw_timer_poll(); } }这个例子里有几个值得注意的设计led_task周期 500ms所以 LED 每 500ms 翻转一次实际闪烁周期是 1000ms亮 500ms 灭 500ms。key_scan_task周期只有 5ms按键的响应延迟被控制在 5ms 以内比超级大循环里的 80ms 好得多。report_task周期 1000ms用来输出系统运行状态。软定时器boot_tip_cb只注册一次在 3000ms 后触发触发完自动停止非常适合“延时执行”场景。6. 运行结果与效果验证编译烧录后打开串口调试助手预期看到类似下面的输出boot tip: sw timer expired, arg0x1 [report] tick1000, run_count1, led1 [report] tick2000, run_count2, led0 [report] tick3000, run_count3, led1 [report] tick4000, run_count4, led0 key pressed at tick...如何判断系统运行正确串口输出时间差应该稳定在 1000ms 左右说明report_task的周期是准确的。LED 状态取反的频率稳定说明led_task正常调度。软定时器触发时间点在 3000ms 附近说明sw_timer_poll正确工作。如果运行失败第一步先检查platform_tick_init()是否真正配置了中断。SysTick 没有启动platform_tick_get()永远是 0所有任务都会“同时到期”也就是主循环会不停执行所有任务现象就是任务执行频率远高于预期。其次检查启动文件里是否已经存在SysTick_Handler防止因编译链接重复定义导致程序无法启动。如果要用示波器验证 LED 任务就在 LED 引脚上接一根线观察高电平持续时间是否稳定在 500ms。更专业的做法是在某个任务函数里翻转一个调试 GPIO通过逻辑分析仪观察时间片调度的抖动情况。注意任务自身的执行时间也会占用调度周期如果任务执行时间过长可能会出现“本该 5ms 执行一次的任务实际变成了 6ms 执行一次”这是裸机时间片轮询的固有特征。7. 常见问题与排查思路下面这张表格整理了我实际排错时最常遇到的几个问题。问题现象可能原因排查方式解决方案任务执行频率明显偏快时基中断未启动tick 一直为 0在platform_tick_get()处打断点观察返回值是否递增初始化 SysTick 并确认中断开启任务执行规律不固定时快时慢主循环里存在阻塞延时或长耗时操作定位while、for空转和阻塞读取代码移除阻塞延时把耗时操作拆成多步或改用状态机串口 printf 无输出串口重定向未实现检查是否实现fputc确认串口时钟和波特率实现标准库重定向或者使用自定义uart_send_str使用软定时器的 ID 无效软定时器数量超过SW_TIMER_MAX打开数组越界检测或打印 ID增大SW_TIMER_MAX或定期回收不用的定时器程序运行一段时间后突然卡死回调函数里执行了阻塞等待检查中断优先级和回调代码回调里只置标志、发消息不在回调内等待按键扫描偶尔丢失主循环某次执行时间过长超过按键周期用逻辑分析仪测量主循环最坏执行时间缩短主循环最大耗时或把按键扫描放到更高优先级外部中断里第一个问题是最容易踩的坑。很多人写完代码发现 LED 闪得飞快第一反应是“任务表逻辑写错了”其实往往只是 SysTick 没有工作。排查时看platform_tick_get()的值是否随时间增长是最快的方法。第二个问题在实际项目里非常普遍。即使有了任务调度器也不代表主循环不会卡。如果某个任务函数内部调用了一个阻塞 100ms 的传感器读取 API那么在这一帧里所有其他任务都会被推迟。解决思路是为传感器读取也建立一个状态机发起读取、等待完成标志、读取结果分三步执行每步都不阻塞。第四个问题需要多说一句。软定时器表是静态数组ID 一旦被sw_timer_start返回就会一直占用槽位。如果你反复创建单次定时器而不回收数组最终会被占满。工程中可以在回调结束后调用sw_timer_stop(id)或者把used也置为 0允许槽位复用。这是一个典型的资源管理问题项目越大越明显。8. 工程最佳实践与架构演进建议代码能跑只是第一步想在大一点的项目里用稳还要遵守几条工程纪律。8.1 时基选择不要盲目追求小1ms 是多数裸机项目的最优解。10ms 适合对实时性要求不高的显示刷新和按键扫描但做不了精细的超时统计。100us 虽然精度高但中断频繁CPU 开销变大功耗也会上升。选择时基之前先列一下项目中所有任务的周期需求取最小公共周期的三分之一到一半比较稳妥。8.2 任务函数内部不要阻塞这是整个架构能否成立的生死线。任务调度器本身不抢占它只能在任务函数返回后才能调度下一个任务。一旦某个任务函数内部delay_ms(200)整个系统就会停滞 200ms。正确处理阻塞等待的方法是状态机化把“等待完成”拆成多个轮询步骤每次任务执行时检查条件是否满足。8.3 中断只置标志不做事如果外部中断和定时器中断里都要做事情原则是越短越好。比如串口收到一帧数据中断里只把数据放进环形缓冲区、置一个g_rx_flag 1主循环检测到标志后再做解析。这样既能保证中断响应快也能避免中断和主循环共享资源的复杂同步问题。8.4 为任务函数命名加上周期后缀在小团队协作或者自己维护项目时建议在任务函数里体现周期信息比如led_task_500ms、key_scan_5ms这样阅读代码时不用反复翻注册部分。这个习惯虽然简单但在任务数量超过十个之后能省很多时间。8.5 从时间片轮询到 RTOS 的分水岭当任务数量越来越多任务间依赖关系越来越复杂时裸机时间片轮询会逐渐力不从心。比如任务 A 必须等任务 B 的结果任务 C 又依赖任务 A这种先后关系在裸机里很难表达得清楚。这时就该考虑引入 RTOS用信号量、消息队列、互斥锁这些内核对象来管理任务同步。但不建议一上来就上 RTOS。如果项目只有三五个周期任务裸机时间片轮询的确定性更强、代码更直观、调试成本更低。RTOS 的核心价值在于“多任务并发 同步机制”而不是单纯“让代码看起来高级”。从裸机时间片轮询到 FreeRTOS本质上是从“手写调度”到“使用现成内核”的演进理解本文这套定时器任务机制你会更容易理解 RTOS 里的 tick、任务控制块和调度器设计。8.6 系统 tick 统一管理无论有多少个硬件定时器建议整个系统只保留一个“时间之源”其他所有延时、超时、周期判断都从platform_tick_get()派生。这样可以避免多个定时器各自维护计数导致的资源浪费也让系统的行为更容易预测。软件定时器和任务表共享同一个时基天然就保证了时间一致性。9. 总结与后续实践建议从“超级大循环”到“定时器任务调度”表面上只是多了一个任务表和几个if判断本质上是软件架构从线性执行向时间分片的一次跃迁。它不依赖 RTOS不需要复杂的中断嵌套却能让项目的实时性和可维护性上一个台阶。这篇文章的主要脉络是先用一个典型主循环案例说明阻塞式架构的痛点然后梳理前后台系统、时间片轮询和事件驱动的关系最后用一套完整的 C 代码展示了时基层、任务层、软定时器层三层架构的实现方式。代码可以整体复用也可以拆开按需移植。下一步建议你动手做三件事第一把当前项目里最耗时的几个delay_ms全部替换成软定时器观察运行行为的变化第二给任务表增加“挂起”和“恢复”接口让任务可以按需启停第三当你的任务数超过十个并且开始需要任务间通信时去学习 FreeRTOS 的消息队列和信号量。那时候你会发现本篇文章里的任务控制块和调度循环就是 RTOS 内核最原始的雏形。