本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

固件框架的几种写法:
前后台、时间片、状态机与 RTOS 任务

我写过的固件大致落在这四种框架里:纯前后台、时间片轮询、状态机主导、以及 RTOS 多任务。 它们不是四个等级,而是把复杂度放在了四个不同的地方。 这篇笔记想说的是:框架的价值在于让状态可被外部观察,让错误只可能出现在一处—— 凡是做不到这两点的框架,无论看起来多“高级”,最后都会变成一团要靠记忆维护的代码。

固件框架 状态机 时间片轮询 FreeRTOS 2023 – 2026 已发布

一、框架不是“高级不高级”,而是“复杂度放在哪里”

我刚开始写固件的时候,觉得框架是有档次的:`while(1)` 里堆功能是新手, 用状态机是入门,能上 RTOS 才算“会做项目”。写过几年之后,这个排序在我心里塌掉了。 真正决定一份固件好不好维护的,不是它用了哪种框架,而是两件事:

  1. 状态能不能被外部观察:设备现在在做什么?是在初始化、在等应答、在运行,还是卡在一个故障里? 这个问题能不能在不打断程序、不重新编译的前提下回答出来。
  2. 错误是不是只可能出现在一处:同一件事(比如“超时之后要复位状态”) 是只在一个地方写,还是散落在五个文件的八个分支里。

用这两条去衡量,四种框架的差别就清楚了。前后台把复杂度放在“每个功能都要自己记得让出 CPU”上; 时间片把它放在“任务表与节拍”上;状态机把它放在“状态怎么划分”上; RTOS 把它放在“优先级与共享资源”上。复杂度不会消失,只会换个地方待着。 你要做的是选一个“出问题时你最容易查”的地方放它。

我判断该不该换框架,看的也是四件事:有没有会互相阻塞的慢操作、有没有必须在一个节拍内响应的硬实时要求、 状态数有多少、以及这份代码要交给谁维护。这四条我会在第九节展开成一张决策清单。

先说清楚本文的边界:下面写的是结构与取舍,不给可以直接搬用的实现。 文中的代码块是按个人理解重写的最小骨架,只保留控制流的形状,不含任何具体产品的判定条件与参数。

二、写法一:纯前后台(主循环 + 中断)

结构最简单:中断里只做最短的事(把字节塞进缓冲、置一个标志、给一个计数加一), 所有需要“想一下”的逻辑都放在主循环里按固定顺序跑一遍。 我写过的一块采集单元就是这样,主循环三行:跑业务逻辑、喂狗、延时 1 ms; 另一块扫码转接板更极端,主循环只有两句——处理事件、发出应答—— 所有计时都在一个 1 ms 的定时器中断里完成:每 10 ms 解析一次、每 500 ms 翻转一次指示灯、 两个串口各做 5 ms 的空闲判帧、再加上总线忙与应答超时的计数。

适用边界很清楚:功能数量在一只手数得过来的范围内, 每个功能的单次处理时间可以预估(几十微秒到几毫秒), 并且没有“等外部器件应答”这类阻塞操作。这三条满足,前后台是性价比最高的写法: 没有内核、没有任务栈、没有同步对象,出问题时顺着主循环读一遍就能定位。

它的代价也很清楚。主循环里任何一处加了长延时,整机所有功能一起停: 加一个 500 ms 的等待,按键响应、采样、通信全都被推迟 500 ms。 这类问题的难查之处在于现象和原因不在同一个模块里—— 你看到的是“按键变慢”,根因在半个月前别人加的某个等待里。

把“等待”从主循环里拿掉

前后台要活下去,关键是所有等待都要变成“非阻塞”的:不写“等 500 ms”, 而写“记录一个起点,每轮问一次时间到了没有”。 我在一个 M0+ 工程里同时见到三套这样的非阻塞延时函数,每套用两个静态变量记 “第一次调用”和“历史时间点”,名字不同、用途不同,做的是同一件事。 这种写法看起来笨,但它把“等”变成了“查”,主循环的节奏就不会被某一个功能拖住。

/* 非阻塞延时骨架:把“等一段时间”换成“每轮问一次时间到了没有”
   时间基准由工程自己的毫秒节拍提供(按个人理解重写的最小片段) */
typedef struct {
    uint32_t start;
    uint16_t span;
    uint8_t  running;
} delay_t;

static uint8_t delay_expired(delay_t *d, uint32_t now)
{
    if (!d->running) { d->start = now; d->running = 1; }
    if ((uint32_t)(now - d->start) < d->span) { return 0; }
    d->running = 0;
    return 1;
}

三、写法二:时间片轮询(一个节拍把多路任务排开)

时间片是前后台的自然升级:既然“每个功能自己记得让出 CPU”容易忘, 那就把“谁该跑了”交给一张表。做法是一个硬件节拍(1 ms 或 2 ms)只负责计数, 主循环挨个检查表里的任务,谁的计数到点了就调用一次,然后把计数清零。

我最早接触的是 2 ms 节拍累加到 500 ms 的采集周期;后来见到更省事的做法: 用一个 10 ms 的定时器,靠取模运算做多档定时—— 对 50 取模得到 500 ms,对三万取模得到 300 s,避免为每个功能单独建一个定时器。 这个技巧很实用,但它也埋了一个坑:所有周期都只能是节拍的整数倍, 需要 3 ms 周期的任务在 10 ms 节拍下只能做成 10 ms。

/* 时间片骨架:节拍中断只加计数,主循环按表推进(按个人理解重写的最小片段) */
typedef struct {
    void   (*task)(void);
    uint16_t period;      /* 以节拍为单位 */
    uint16_t elapsed;
} slice_t;

static void slice_run(slice_t *tbl, size_t n)
{
    size_t i;
    for (i = 0; i < n; i++) {
        if (++tbl[i].elapsed >= tbl[i].period) {
            tbl[i].elapsed = 0;
            tbl[i].task();
        }
    }
}

时间片的代价有三条,我是逐条踩过才记住的。第一,节拍是全局的, 改节拍会影响所有任务的周期和精度。第二,最坏响应时间等于前面所有任务耗时之和: 排在第五位的任务,必须等前四个跑完;所以任务表要按周期从短到长排, 而且每个任务的执行时间要能被量出来——我一般的做法是在任务入口翻一个 IO, 用示波器看这一路的高低电平宽度。第三,时间片不表达优先级: 重要的任务和可以等的任务排在同一队里,遇到“必须马上处理”的事件, 还是得靠中断插队。

还有一条纪律:任何“长度不确定”的操作都不允许出现在时间片任务里。 写片内 Flash、等外部模组应答、等总线仲裁,这些都必须拆成“发起 → 轮询 → 完成”的状态推进, 否则它一个人就能把整张表的节奏拖乱。时间片的具体写法我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里展开写过。

四、写法三:状态机主导(把“现在在做什么”显式化)

时间片解决了“什么时候跑”,但没解决“现在在做什么”。一份有六七个工作阶段的固件, 如果阶段只存在于“代码跑到哪一行”里,那么任何人(包括三个月后的我自己) 都无法在不打断程序的情况下回答“设备现在处于哪个阶段”。 状态机做的事情就是把这件事变成一个变量、一个函数、甚至一个任务。

子形态结构好处代价
switch 分发 一个函数里按当前状态分支,每个 case 处理一个状态的逻辑 最直观,状态数在五六个以内时一眼能读完 所有状态的局部变量挤在同一个栈帧里;状态一多,这个函数就没人敢动了
一状态一函数 状态转移表 + 事件表 + 动作表,三个函数分别是初始化、转移、执行动作 状态与事件各自成表,可以当文档看;换 MCU 时这一层几乎不用改 表驱动的写法对新手不友好;表里的一条错项可能很久才被触发一次
一状态一任务 状态切换 = 创建新任务 + 删除旧任务,任务体统一是“进入 → 循环做 → 退出”三段式 任务即状态,切换即重启:局部变量随任务销毁,天然干净;调试器里看一眼任务列表就知道现在在哪个状态 任务创建可能失败、切换期间旧状态必须停手、每个状态一份栈与堆开销

switch 分发:先用它把逻辑跑通

我不反对在一个函数里用 switch 写状态机,尤其是原型阶段: 改起来快、看得清楚。问题出在它很容易“长大”—— 每加一个功能就往某个 case 里塞几行,最后变成一个两百行、局部变量十几个的函数。 我的判断标准是:当某个 case 的代码需要滚动两屏才能读完,或者两个 case 之间开始需要共享局部变量, 就该拆了。

/* 通用状态机骨架:状态与事件都是占位形式,不含任何业务语义(按个人理解重写) */
typedef enum { ST_IDLE, ST_RUN, ST_DONE, ST_FAULT } state_t;

static state_t sm_step(state_t cur, const event_t *ev)
{
    switch (cur) {
    case ST_IDLE:  return (ev->start)  ? ST_RUN   : ST_IDLE;
    case ST_RUN:   return (ev->abort)  ? ST_FAULT :
                          ((ev->finish) ? ST_DONE  : ST_RUN);
    case ST_DONE:  return ST_IDLE;
    case ST_FAULT: return (ev->cleared) ? ST_IDLE : ST_FAULT;
    default:       return ST_IDLE;
    }
}

一状态一函数:可以沉淀成资产的写法

这套写法我见过的最长寿命案例是:同一套“三函数 + 事件表 + 动作表”的状态机框架, 从 2021 年一路用到 2023 年,中间换了三次主控平台、改了四代产品, 而那几个状态文件(初始化、等待、运行、设置、查询、故障)的文件名一直没变。 换平台时改的只有驱动层——协议、状态机、存储格式是可以沉淀的资产,时钟和 GPIO 不是。

但它也把补丁一起继承了下来。那套框架里有三种“防止切换状态时误吞按键”的锁, 分别在不同文件里各写了一遍,本质是同一类竞态的不同表现; 事件处理文件里至今留着“有问题待修改”的注释。 这提醒我一件事:框架的复用会把它的缺陷一起复用, 所以在把一套状态机框架复制到新项目之前,值得先把它已知的补丁读一遍。

一状态一任务:好处与代价都很大

这是四种写法里最“反直觉”的一种:状态切换不改一个变量,而是 创建一个新任务、删掉旧任务。任务体统一是三段式: 进入时准备本状态要用的资源,然后在一个“只要我还是当前状态就一直循环”的循环里干活, 退出时把资源还回去、创建下一个状态的任务、最后删除自己。 我读过一个工程就是这么做的:把系统拆成七个状态, 每个状态一个任务、统一的任务栈大小、统一的优先级,另外还有一个独立的事件管理器任务, 以 30 ms 周期只给“当前状态”派发事件。

它的好处正是这篇笔记的核心论点:

  • 状态可被外部观察:任务列表就是状态列表。不用加打印、不用打断点, 在调试器里看哪个状态任务存在,就知道设备现在在哪一步;上报给上位机也只需一个状态号。
  • 错误只可能出现在一处:每个状态的进入与退出各只有一处, 清理动作写在退出里,不会出现“从 A 走要清这个变量、从 B 走忘了清”的情况。
  • 切换即重启:状态的局部变量放在任务栈上,任务删掉,栈就没了。 不需要在切态时手工重置一堆全局变量——这类“忘了重置”是状态机里最常见的 bug 来源。

代价也必须一起说,否则这套写法看起来像免费的:

  1. 任务创建可能失败。堆不够、任务控制块分配不到, 创建函数会返回失败。如果代码里不检查返回值,表现就是“切过去之后什么都没发生,设备停在那里”。 我见过的工程为此加了一条兜底:状态任务没建起来就重试, 连续失败则回到一个确定安全的状态。
  2. 切换期间旧状态必须停手。旧任务可能正卡在一次外设访问中间, 所以“我还是不是当前状态”这个判断要放在每一个可能阻塞的点之后, 而不是只在循环开头判一次。否则会出现两个状态同时操作同一个外设。
  3. 栈与堆的开销。每个状态一份栈(我见过统一给同一档栈大小的做法), 七个状态就是七份栈的预算,再加上每个任务的调度开销。 这在 8 KB RAM 的芯片上根本不用考虑——这也是为什么这套写法只出现在大容量平台上。
  4. 事件派发需要一个中心。得有一个不随状态销毁的东西负责给当前状态送事件, 并且带一个“状态没变才继续派发”的守卫,否则切态瞬间会把事件送给已经不存在的状态。
/* “一状态一任务”的形状:只保留三段式与自我了断的结构(按个人理解重写) */
static void state_task_body(void *arg)
{
    (void)arg;
    state_entry();                  /* 进入:准备本状态要用的资源 */
    while (state_is_current()) {    /* 没被切走,就一直干本状态的活 */
        state_do();                 /* 具体做什么由各状态自己实现 */
        task_delay_one_tick();
    }
    state_exit();                   /* 退出:把本状态占用的东西还回去 */
    request_next_state();           /* 交接:由它去创建下一个状态的任务 */
    task_delete_self();             /* 任务即状态:状态结束,任务结束 */
}

关于状态划分的粒度、故障怎么分级、以及“设备现在怎么了”怎么对外讲清楚, 我在 《分层状态机与故障优先级:把“设备现在怎么了”讲清楚》 里单独写过。

五、写法四:RTOS 任务(每个功能一个任务)

RTOS 解决的是前面的写法都解决不了的那个问题:慢操作只能阻塞自己。 在前后台和时间片里,“等 4G 模组回一条应答”这件事没有好办法, 只能拆成状态慢慢轮询;有了任务之后,等应答的任务自己阻塞, 按键任务照常响应,采集任务照常按节拍跑。

我手上同一颗 M4F 上的四个工程用的是同一套内核配置骨架,相似到可以互相替换: 1 kHz 的节拍、相同的最小任务栈、栈溢出检测开到第 2 级、 定时器任务栈是最小栈的两倍、静态分配与动态分配同时打开、 系统节拍中断里先跑厂商的节拍处理再转给内核的节拍处理。 差异只在两处:堆大小(10、16、20、30 KB 四档)和优先级档数(16 或 32)。 这四个数字配置决定了这套骨架能承载多少任务,也是我换平台时第一个要重算的东西。

更小的一档我也见过:一颗 M4F 的加注机主板只给了 20 KB 出头的堆、6 档优先级, 最小任务栈压到几十个字,键盘与显示任务放在最高档, 启动任务创建完其它任务就自己退出;事件管理器用静态创建的方式分配, 不占堆。更大的一档是一颗 STM32F407 的集控板:100 KB 堆、32 档优先级, 互斥量、递归互斥量与计数信号量全开,可管理中断的优先级下限在配置里写死。 还有一个工程用的是另一套 RTOS 封装层,20 KB 堆、几十档优先级, 并且打开了运行时间统计,用一路定时器做统计时基。

RTOS 下最舒服的一点:任务本身也是可观察的

这一点常被忽略。任务有名字、有优先级、有栈水位, 所以“设备现在在干什么”可以直接读出来: 我在一个工装工程里见过运行时把任务名、优先级、任务号和栈深度打印出来的实现, 调产线问题的时候非常省事。这是“状态可被外部观察”在任务层面的体现—— 你不必再靠猜。

中断与任务之间:能不用队列就不用队列

一个很实用的细节:按键中断要唤醒按键任务,用“任务通知”比用队列省一次数据拷贝, 中断里给一次通知,任务里阻塞等待,两边配对使用; 唤醒之后要记得按内核要求触发一次上下文切换,否则要等到下一个节拍才轮得到这个任务。 这类“省一次拷贝”的优化在 RAM 紧张的板子上不是可选项,是必须项。

至于什么时候该上 RTOS,我自己的界限在第九节: 有阻塞式慢操作、或者自研代码过一万行、或者需要严格优先级,三者占一个我才上。 8 KB RAM 这一档,我从来没有说服自己上过 RTOS。

六、五维对照:四种写法放在一起

下面这张表是我自己用来做选择的。注意“实时性”一列不是“快慢”, 而是“最坏情况能不能算出来”——能算出来的差,比算不出来的好要好。

写法实时性RAM 开销可读性可测试性出问题时好不好查
纯前后台 中断侧有保证;主循环侧无保证,最坏等于所有功能耗时之和 最低,没有内核对象与额外栈 功能少时最好;功能一多就变成“隐式状态机” 差,基本要跑起来才知道对不对 好查:代码路径是线性的,读一遍就能跟到底
时间片轮询 可预测:节拍精度加任务表最坏耗时,能算出来 低,一张任务表加若干计数器 好,任务表本身就是一份功能清单 中,可以缩短节拍把一天的业务压到几分钟跑完 好查:每个任务有独立计数,在线就能看出谁没跑到
状态机主导 取决于状态内逻辑长度与事件派发周期;事件密集时要单独评估 一状态一函数很低;一状态一任务显著偏高 最好,状态即文档,新人先读状态表 好,事件可以注入;我见过用模拟按键序列跑业务回归的做法 最好查:当前状态可以直接显示或上报,故障现场一眼定位到状态
RTOS 多任务 最好:按优先级抢占,慢操作只阻塞自己 最高:每个任务一份栈,加内核对象与堆 中:任务划分清楚,但同步关系散落在各处 好,单个任务可以单独喂数据测试 最难查:竞态与时序问题常常“现象在 A 任务、根因在 B 任务”

把这张表横着看,会发现一个规律:可读性与可观察性越好的写法,RAM 开销往往越高。 这不是巧合——让状态显式化,本身就要占地方。 所以“该用哪种框架”这个问题,真的要先回答“我这条芯片有多少 RAM”。

四栏并排:纯前后台、时间片轮询、状态机主导、RTOS 任务,每栏画出各自的执行流示意(一个主循环 / 一个节拍源 / 一
图 1 · 四种固件框架的结构对比图

七、同一件事在四种框架里分别怎么写

抽象地比较框架很容易变成空谈,我换成三件最典型的事来看:按键处理、数据采集、通信。 下面只讲结构位置,不给实现。

写法按键处理数据采集通信
纯前后台 节拍中断里扫描、消抖、置键值;主循环里消费键值 主循环里顺序轮询各通道,一轮采一路 串口中断里收字节,主循环里解析与应答
时间片轮询 按键任务 10 ms 一片,节拍中断只加计数 采集任务按通道排片,总周期是节拍的整数倍 解析任务 10 ms 一片,收字节仍在中断里做
状态机主导 按键只是“事件源”:消抖后产生一个事件,送去当前状态 采集是“当前状态下才做的事”,不同状态采不同的量 通信独立于状态机之外,只把解析结果转成事件
RTOS 多任务 独立任务,最高优先级,中断用任务通知唤醒 独立任务,按周期阻塞等待,采完发到队列 独立任务,阻塞在队列或信号量上等数据

这张表更重要的是第三列的差别。同一件“采集”,在前后台里它是主循环中间的几行; 在时间片里它是任务表里的一行;在状态机里它是某个状态内部的逻辑; 在 RTOS 里它是一个有自己栈的实体。 这就是框架迁移比换芯片更难的原因:换芯片改的是驱动, 换框架改的是代码的位置——而“位置”是没有编译器帮你检查的。

顺带说一个观察:这四种框架里,只有状态机和 RTOS 两种天然支持“把状态报出去”。 前后台和时间片也能做,但要额外加一个变量、并且靠人记得在每个分支里更新它—— 一旦忘了更新,你看到的状态就是错的,而错的状态比没有状态更危险。

八、从一种框架迁移到另一种,最容易出事的地方

这一节是这篇笔记里我最想留下的部分。框架迁移出的问题, 几乎全部集中在四个地方,而且它们都有共同的征兆:“以前一直好,换完框架偶尔不好”。

共享状态

裸机里“主循环改、中断读”的一个 32 位变量,在对齐访问的前提下是单条指令完成的, 大多数时候不需要保护;换成 RTOS 之后,同一个变量可能被两个任务同时改, 于是“偶尔丢一次累加”。我的处理办法是三条: 共享状态集中到一处、用位域联合体把它打包成一个可整体读写的字、 需要一致性时用临界区或队列传副本。 我见过的一个多任务工程,连调试打印都用“挂起所有任务再打印”的方式保护, 就是为了避免多任务下打印内容撕裂; 另一个工程的异步存储则做成“专用任务 + 队列串行执行”, 队列里放地址、长度和数据,所有写操作排成一队,从根上避免并发。

临界区

裸机里“关中断”就够了;RTOS 里要用内核提供的临界区宏, 而它只屏蔽到某个优先级为止——比那个界限更紧急的中断照样会进来。 我在内核配置里见过明确写死的“可管理中断优先级下限”, 意思是优先级数值比它更紧急的中断不归内核管,也就不能在内核临界区里被屏蔽、 更不能调用内核的中断安全接口。移植时如果按裸机的习惯在临界区里访问这类中断也会碰的数据, 就会得到一个极难复现的偶发错误。

还有一个具体技巧:把一次传感器访问拆成“触发测量”“等待就绪”“取数据”三步, 临界区只包住中间最短的搬运部分,而不是把整段几百微秒的转换过程都关在临界区里。 临界区越长,被它推迟的中断就越多,实时性越差。

栈

裸机只有主栈和中断栈,RTOS 里每个任务一份栈,于是“栈够不够”从一个问题变成了一堆问题。 两个真实的坑值得记下来。第一个是单位:启动文件里主栈大小从十六进制的 400 改成 1000, 文档里写作“由 400 改为 1000(4K)”——这里的 400 和 1000 是十六进制, 实际是 1 KB 变成 4 KB;同一目录下另外几个启动文件还保留着旧值, 如果没确认工程到底链接了哪一个,很容易得出相反结论。 第二个是检测:栈溢出检测开了级别 2 之后,还要自己实现溢出钩子, 否则只是“检测到了,然后什么都没做”。栈这个问题我建议在项目中期就用高水位法实测一次, 别等到现场复位了再回来算。

优先级反转

两个任务抢同一个共享资源时,持锁的低优先级任务可能被一个中等优先级的任务长期压住, 于是等锁的高优先级任务被无限期推迟。 我的三条做法是:共享总线与共享存储用互斥量而不是二值信号量(互斥量才带优先级继承)、 尽量缩短持锁时间、绝不在持锁期间做延时或打印。 其中“不要在持锁期间打印”是最容易被违反的一条——打印看着无害, 但如果它内部要等一个慢速外设,持锁时间就从微秒变成了毫秒。

迁移方向最容易出的事我的检查手段
前后台 → 时间片 任务里有隐藏的长等待,整张表被拖慢 在任务入口翻 IO 量宽度;把等待改成状态推进
时间片 → 状态机 状态划分照抄功能模块,导致状态之间互相跳转、纠缠不清 先画状态转移图,确认每个状态都有明确的进入与退出条件
状态机 → 一状态一任务 任务创建失败没人管;切态瞬间两个状态同时操作同一外设 检查创建返回值并加重试兜底;把“是否仍是当前状态”的判断放在每个阻塞点之后
裸机 → RTOS 共享变量失去原子性;中断里误用非中断安全的内核接口 列出所有跨上下文访问的变量与所有中断入口,逐个确认保护方式
换 RTOS 或换封装层 旧框架的阻塞接口被漏改,留在某条很少执行的分支里 把“所有会阻塞的接口”列一张表,全局搜一遍再编译

九、我的选择顺序

我现在的做法是“先看约束,再看框架”,顺序固定,不跳步。 约束有五个:有没有阻塞式慢操作、有没有必须在一个节拍内响应的硬实时要求、 状态数有多少、RAM 有多少、代码量有多大。 其中硬实时这一项的极端例子,是我见过的一个变频驱动工程: PWM 载波中断每 111 微秒进一次,这种约束下框架怎么选基本没有余地—— 中断里只能放最必要的整数运算,其余全部让给主循环或任务。

触发换框架的信号我换到哪一档换之前必须先做的一件事
主循环里出现了“为了等一个慢操作,把别的功能停掉”的补丁 时间片,或直接上 RTOS 把那个慢操作拆成“发起 → 轮询 → 完成”三步
注释里开始出现“这段逻辑只有在某个状态下才对” 状态机(先 switch,再考虑拆函数) 把状态和事件写成一张表,哪怕先不落代码
状态数超过八个,还挤在一个 switch 里 一状态一函数 确认每个状态都有明确的进入与退出动作
切态时要手工重置的变量超过三个 一状态一任务(前提是 RAM 够) 算一遍“状态数 × 单份栈”的 RAM 预算
自研代码过一万行,或者加一个功能要改五个地方 多任务 RTOS 先列出所有跨上下文共享的数据,再决定怎么保护
加一个功能只需要动一处,最坏响应时间也能量出来 不换 什么都不用做——能跑、能查、能加,就是好框架

顺序上我坚持一条:前后台 → 时间片 → 状态机 → 一状态一任务 → 多任务 RTOS,不要跳级。 一个连显式状态变量都没有的工程直接上 RTOS,得到的不是清晰的架构, 而是“隐式状态 + 竞态”两件麻烦事叠在一起。

还有一条更重要的纪律:换框架的那一版不要同时换芯片。 两层一起动,一旦出问题,你无法判断是驱动的锅还是框架的锅; 我宁可多花一个版本的时间,也要把这两件事分开做。 如果确实要同时换平台,那就先用最笨的框架把硬件跑通 (这时候 《拿到一份陌生固件工程的前 30 分钟》 里那套“先确认器件、时钟、参数区”的方法很有用),确认底层没问题之后再上框架。

五个轴:实时性、内存开销、可读性、可测试性、问题可诊断性,四种框架各一条线,直观体现取舍
图 2 · 框架选型的五维对照图

十、几条我反复用到的经验

  1. 先问“状态能不能被看到”,再问“用哪种框架”。能被观察的状态,才有资格被调试。
  2. 让同一件事只有一处实现。超时处理、状态清理、故障置位,写两处就一定会漏掉一处。
  3. 不要跳级换框架。没有显式状态的工程直接上 RTOS,只会把问题从“难查”变成“更难查”。
  4. 时间片里不许可变长的等待。任何“长度不确定”的操作都要拆成状态推进。
  5. “一状态一任务”值得用,但要把三笔账算清楚:任务创建失败的兜底、切换期间旧状态的停手条件、以及状态数乘以单份栈的 RAM 预算。
  6. 临界区只包最短的搬运。把慢操作关在临界区里,等于人为制造实时性问题。
  7. 换框架时全局搜一遍旧的阻塞接口名。漏改的那一处,通常藏在最少执行的分支里。
  8. 看门狗要在通信打通之后立刻做,不要留到发布前。我见过的所有工程里,它都是最后一项,也最常没做完。
  9. 把可测试性做进固件。我见过用模拟按键序列跑业务回归、用注入 IO 边沿模拟外部信号的工程, 这类设施写起来不复杂,但能在没有硬件的情况下把整条业务链跑一遍,收益很大。

参考资料与说明

  • FreeRTOS 官方公开的内核文档与移植指南:任务、队列、信号量、互斥量、任务通知、栈溢出检测、临界区与中断优先级配置的语义,均属于公开资料。
  • ARM 公开的 Cortex-M 系列技术参考手册中关于异常优先级与上下文切换的描述,是我理解中断与任务关系的基础。
  • 文中提到的节拍、堆大小、任务栈、优先级档数等数字,均来自我自己写过的工程与读过的工程配置;凡是我没有逐行核对的项,文中已标注“未确认”。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。
  • 文中出现的芯片型号与 RTOS 名称仅用于说明技术方案,与相关厂商无隶属或授权关系;参数以各厂商最新数据手册与官方文档为准。示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 站内相关笔记:我用过的几种 32 位单片机、不用 RTOS 的固件怎么写、分层状态机与故障优先级、定时器与单位换算的坑、拿到一份陌生固件工程的前 30 分钟、学习笔记目录、个人实验。