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

不用 RTOS 的固件怎么写:
时间片轮询 + 消息队列

不跑 RTOS 的固件并不是“一个 while 里堆一堆函数”。它需要两样东西才站得住: 一个把多路采集排开的节拍(时间片轮询),和一条把中断数据安全交给主循环的通道(消息队列)。 这篇笔记以一块液位采集小板(HC32F030、48 MHz、Cortex-M0+)为例, 记录我的窗口怎么分、状态变量怎么传、临界区为什么只关一边,以及这套框架的边界在哪里。

裸机框架 时间片轮询 消息队列 HC32F030 已发布

这块小板上的活其实只有三类:采集(两路液位、两路温度)、控制(四路继电器输出)、 通信(RS485 上的 Modbus RTU 从站,设备地址 0x41,功能码 0x03 / 0x10)。 主控是 HC32F030(Cortex-M0+),48 MHz PLL(外部晶振),没有跑 RTOS。 这篇要记的是:没有 RTOS 的情况下,这三类活是怎么被排开、又是怎么安全地交换数据的。 协议那一层另有笔记,这里只把它当作一个普通的“生产者”。

一、什么时候不需要 RTOS

先说结论:这块小板不用 RTOS,不是因为我排斥 RTOS,而是因为它的并发需求简单到用不上。 我拿几个问题过了一遍,答案都指向同一个方向:

我给自己提的问题这块板子的答案结论
有几个“任务”? 四路采集 + 继电器控制 + 通信响应,本质上是同一件事的四个阶段 窗口排得下
有没有需要阻塞等待的操作? 没有文件系统、没有网络协议栈;唯一的“等”是 DS18B20 转换,可以用状态推进绕开 不需要阻塞语义
需不需要在运行时动态创建、销毁任务? 不需要,任务集合在编译期就定死了 不需要调度器
RAM 宽裕吗? 很紧张,每一个字节都要算 内核 + 每个任务一份栈,代价不划算
并发模型复杂吗? 一个生产者(串口接收中断)、一个消费者(主循环),仅此而已 一把临界区就够

这些问题里,真正决定性的是第二条和第五条。 RTOS 最不可替代的能力是“阻塞等待 + 优先级抢占”: 一个任务可以安心睡眠或者等信号量,把 CPU 让给别的任务。 如果代码里根本没有这种等待,那么 RTOS 带来的主要是复杂性 —— 栈要算、优先级要排、抢占引起的问题要查,而这些成本换不来对应的收益。

反过来说,我也不是说裸机一定更好。我的判断标准很土: 能不能把最坏情况下的执行时间算出一个上限。 只要每一段代码的最长执行时间都能估出来、加起来还留有余量,时间片轮询就是安全的; 一旦有某一段的时间不确定(比如 Flash 写入、或者等外部器件响应),这个账就算不清了, 那时候就该认真考虑 RTOS。这一条在第六节还会展开。

二、时间片轮询:用一个节拍把多路采集排开

时基是 Timer0:初始化调用 App_Tim0_Init(375), 在 48 MHz 主频下,这个周期值配上一级分频正好是 500 Hz,也就是 2 ms 一拍。 之所以选 2 ms,是因为它同时要干两件事:给采集排窗口,以及给 Modbus RTU 的帧间隔计时 (3.5 个字符时间在常见波特率下是几毫秒的量级,用 2 ms 的节拍去累计够用了)。

一个全局的 u32 sys_tick 在这个节拍里自增,累加到 60 就归零 —— 60 × 2 ms = 120 ms,这就是一个完整的大循环。 我把这 120 ms 均分成 4 个 15 tick(30 ms)的窗口:

sys_tick时间负责的通道说明
0 ~ 150 ~ 30 msAD1(液位 1)ADC 扫描 + 20 点环形缓冲滤波
15 ~ 3030 ~ 60 msAD2(液位 2)同上,第二路
30 ~ 4560 ~ 90 msTP1(温度 1)DS18B20,接在 PB0
45 ~ 6090 ~ 120 msTP2(温度 2)DS18B20,接在 PB1

关键的一点在于:窗口到期的时候,中断里并不直接干活。 它只做一件很轻的事 —— 把“该处理哪个通道”的编号写进 col_ctrl / col_state。 真正的采集与计算在主循环里由 collect_data() 根据 col_state 分发执行。

/* 为按个人理解重写的最小片段:2 ms 节拍只做“派工” */
static volatile u32 sys_tick = 0;
static volatile u8  col_ctrl = 0;

void Tim0_IrqCallback(void)          /* 每 2 ms 进来一次 */
{
    sys_tick++;
    if (sys_tick >= 60u) {           /* 60 × 2 ms = 120 ms 一个大循环 */
        sys_tick = 0u;
    }

    if (sys_tick == 0u)  { col_ctrl = 1u; }   /* 0 ~ 15 :AD1 液位 1 */
    if (sys_tick == 15u) { col_ctrl = 2u; }   /* 15 ~ 30:AD2 液位 2 */
    if (sys_tick == 30u) { col_ctrl = 3u; }   /* 30 ~ 45:TP1 温度 1 */
    if (sys_tick == 45u) { col_ctrl = 4u; }   /* 45 ~ 60:TP2 温度 2 */
}

为什么中断里只改一个变量

定时器中断每 2 ms 必然要执行一次,这段时间是“固定支出”。 如果让它在中断里直接做采集,那么排序滤波、DS18B20 的单总线时序、Flash 写入这些重活, 都会拉长中断的占用时间;结果就是 Modbus 接收被推迟、丢字节、通信偶发失败。

改成“中断只写一个变量”之后,中断的执行时间恒定且极短, 和当前有多少活完全无关。重活全部落在主循环: 主循环被打断一下没关系,因为它没有硬实时的截止时间; 而串口接收被打断就有关系,因为它有硬件层面的截止时间 —— 下一个字节到来之前,接收寄存器的内容必须被取走。 把不确定性从中断里挪到主循环里,这是整套设计的核心。

一条 120 毫秒的时间轴,等分成四个窗口,每个窗口标注处理哪一路(液位1/液位2/温度1/温度2),轴上方标注 2 毫
图 1 · 节拍与时间窗口分配时序图

三、状态变量 col_ctrl / col_state:把“该谁干活”传下去

这两个变量的分工是我这次写得最满意的一处,所以单独拿出来说。 中断只写请求(col_ctrl),主循环取走请求、 把它转成当前正在处理谁(col_state),处理完清 0。 我个人的理解是这样分开之后,不会出现“中断刚写了新请求、主循环却顺手把旧请求清掉”的竞争: 两个变量各由一个执行流写,读的那一侧只读。

/* 为按个人理解重写的最小片段:主循环里的分发 */
void collect_data(void)
{
    switch (col_state) {
    case 0:                                   /* 空闲 */
        break;

    case 1:                                   /* 启动 ADC 扫描 */
        Adc_SQR_Start();
        break;

    case 2:                                   /* 取滤波结果 → 换算 + 补偿 */
        /* 这一轮轮到哪一路液位,就取哪一路的缓冲;
           下面以液位 1(value2)为例,液位 2 用 value3 */
        sys_data.lev_sen1_ac = Ad_Filtering(value2) * 3300u / 4096u * 1.51;
        break;

    case 3:                                   /* 读温度 1(PB0) */
        Get_Tp(1);
        break;

    case 4:                                   /* 读温度 2(PB1) */
        Get_Tp(2);
        break;

    default:
        break;
    }

    col_state = 0u;                           /* 用完就清,避免重复执行 */
}

collect_data() 里的 switch(col_state) 一共五个分支:

col_state含义具体做什么
0空闲什么都不做,直接返回
1启动 ADC 扫描调用 Adc_SQR_Start() 按下启动键
2取滤波结果取滤波后的值,做 AD 码值 → 工程量的换算与补偿
3读温度 1操作接在 PB0 上的 DS18B20
4读温度 2操作接在 PB1 上的 DS18B20

每个分支处理完都会把 col_state 清 0,避免下一轮主循环又执行一遍同一件事。 这个“用完就清”的习惯我建议一定要保持:状态变量忘记清, 表现是某一个通道一直在刷、别的通道像没反应; 而且它属于“多做了一次”而不是“报错”,从日志和现象上都不容易一眼看出来。

说明一下这里的示意程度:两个液位窗口都要走 case 1 和 case 2, 区别只是取哪个缓冲、结果写进哪个变量 —— 这部分“轮到哪一路”的信息, 我在这里是跟着 col_ctrl / col_state 两个变量一起表达的。 不同的写法里这两个变量的分工可能略有差别,但思路是一样的: 中断只负责发号,主循环负责执行。

为什么“启动扫描”和“取结果”要分成两个状态

因为 ADC 是中断驱动、缓冲在后台填的:case 1 只是按下启动键, 数据要靠 ADC 扫描完成中断一点一点写进 20 点环形缓冲; case 2 才是去把这个缓冲滤波之后取走。 写成两步,而不是“启动 + 原地等待 + 取结果”,好处是中间不会阻塞 —— 等待期间主循环可以去响应通信、可以去干别的窗口的活。 在这套框架里,“等”永远不能写成 while 循环, 只能写成“下次轮到我再看一眼”的状态推进。 环形缓冲与滤波的具体做法,见 《采样数据的处理:20 点环形缓冲、中值滤波与迟滞控制》。

为什么每路给 30 ms

窗口长度是按最慢的那一路反推的,而且留了余量。最慢的一路是温度: DS18B20 一次 12 位转换最长需要约 750 ms,比一个 120 ms 的大循环还长。 所以温度这一路不能“发了命令就等”,我把它写成非阻塞的状态推进 —— 窗口里只推进一步,转换没完成就退出,等下一轮轮到这个窗口再看一眼, 结果在后面的某一轮里取回来。一路温度的完整读取会跨过好几个大循环, 这对液位和温度这种慢变量来说完全可以接受。

液位这一路就轻松得多:走 ADC 中断,30 ms 足够完成 20 点环形缓冲的一次刷新判断。 所以 30 ms 这个数字不是“120 除以 4 刚好”拍出来的, 而是先看最慢的通道需要多长、再留出余量,最后发现均分成四份刚好放得下。

还有一个约束要提一句:DS18B20 驱动是用“切换 Ds18b20_Port / Ds18b20_Pin 两个全局变量”的方式复用的,不可重入。 它能安全工作的前提,就是温度 1 和温度 2 各自在自己的窗口里、串行调用。 这一点我在采样那一篇里写得更细。

左右两栏对比,左栏“中断侧”只列轻动作:写缓冲、置标志、计数,右栏“主循环侧”列重动作:排序滤波、物理量换算、Flash
图 2 · 中断与主循环的职责划分图

四、消息队列:中断与主循环之间怎么交接数据

采集这条线是“定时器中断派工、主循环干活”,而通信这条线正好相反: 中断是生产数据的一方,主循环是消费数据的一方。 串口每收到一个字节就进一次中断,而一个 Modbus RTU 帧要收完才能解析, 解析又不能放在中断里做(那是几百微秒到几毫秒的活)。 所以中间必须有个地方把字节存下来,这就是消息队列。

我没有用 RTOS 的队列,而是自己写了一个极简的环形队列(Mqueue.c / Mqueue.h)。 结构体一共五个字段,队列容量 QueueMaxLen = 512 字节:

/* 为按个人理解重写的最小片段:极简环形队列 */
#define QueueMaxLen  512u

typedef struct {
    u16 SendLen;                 /* 写指针 */
    u16 ReceiveLen;              /* 读指针 */
    u16 DataLen;                 /* 当前已存字节数 */
    u8  Data[QueueMaxLen];       /* 数据区,环形使用 */
    u8  Busy;                    /* 占用标记(本版实现里没有真正起作用) */
} Mqueue_t;
字段类型作用
SendLenu16写指针,写入后自增并对 512 取模回绕;只有生产者改
ReceiveLenu16读指针,取走后自增回绕;只有消费者改
DataLenu16当前已存字节数;两边都会改(下一节的重点)
Data[512]u8数据区,按两个指针环形使用
Busyu8本意是占用标记,目前并没有真正起作用(见下一节)

两个操作函数的逻辑都很短,返回值就是“成功 / 失败”:

/* 为按个人理解重写的最小片段:生产端(入队) */
/* 本来就在串口接收中断里,所以没有再关中断 */
u8 mQueueSend(Mqueue_t *queue, u8 byte)
{
    if (queue->DataLen >= QueueMaxLen) {
        return 0u;                       /* 满了:这一次的字节丢掉,由调用者决定怎么办 */
    }
    queue->Data[queue->SendLen] = byte;
    queue->SendLen = (queue->SendLen + 1u) % QueueMaxLen;   /* 回绕 */
    queue->DataLen++;
    return 1u;
}
/* 为按个人理解重写的最小片段:消费端(出队) */
/* 在主循环里,DataLen 的读改写必须保护起来 */
u8 mQueueReceive(Mqueue_t *queue, u8 *byte)
{
    u8 ok = 0u;

    __set_PRIMASK(1);                    /* 进临界区 */
    if ((queue->Busy == 0u) && (queue->DataLen > 0u)) {
        *byte = queue->Data[queue->ReceiveLen];
        queue->ReceiveLen = (queue->ReceiveLen + 1u) % QueueMaxLen;  /* 回绕 */
        queue->DataLen--;
        ok = 1u;
    }
    __set_PRIMASK(0);                    /* 出临界区 */

    return ok;
}

中断里根据返回值决定要不要丢弃这个字节 —— 丢弃意味着这一帧废掉, 但至少不会把中断卡住,也不会覆盖还没被取走的数据。 这个取舍很重要:队列满的时候,正确的做法是丢新的、保住旧的, 因为旧数据是已经开始收的那一帧的一部分,混着用只会让两帧都废。

512 字节这个容量是照着最坏情况留的:Modbus RTU 一帧最长也就 256 字节上下, 留一倍余量,正常通信下队列几乎不会满。 真正会满的场景是主循环被某件重活占住了(比如 Flash 写入), 这时候队列就是那块“缓冲垫”。

五、临界区:为什么消费端关了中断,生产端却没关

这是这套代码里我最有心得的一处,因为它看起来“不对称得不像话”:

  • 生产端 —— 串口接收中断里的 mQueueSend,没有关中断;
  • 消费端 —— 主循环里的 mQueueReceive, 用 __set_PRIMASK(1) / __set_PRIMASK(0) 关了中断。

一开始我以为是哪里写漏了,后来才想明白:这不是漏写, 而是这个队列的并发模型决定的。

先看生产端为什么可以不关

生产端本身就在中断里。中断不会被主循环打断,也不会被同级或更低优先级的中断打断, 所以它那几行“写数据、SendLen 自增回绕、DataLen 自增”在执行过程中是连续的, 没有别人能插进来。既然没有别人能插进来,加临界区就是白加。

再看消费端为什么必须关

主循环是可以被打断的。mQueueReceive 里对 DataLen 做的是 读 — 改 — 写(DataLen--),在 Cortex-M0+ 上这会展开成三条指令: 读出、减一、写回。危险就出在“读出”和“写回”之间:

  1. 主循环读出 DataLen,值是 5;
  2. 这时串口中断来了,mQueueSend 写入一个字节,把 DataLen 改成 6;
  3. 中断返回,主循环接着执行“减一、写回”,把 4 写进了 DataLen。

那一次中断里的自增被整个覆盖掉了 —— 队列里实际还剩 5 个字节,DataLen 却说只有 4 个。 后果是有一个字节永远不会被消费。如果它正好是一帧的最后一个字节(CRC 的高字节), 这一帧就废了。现象是偶发的 CRC 校验失败、偶发的帧不完整, 而且因为要恰好撞上那个极短的窗口,频率很低,非常难复现。

所以我用 __set_PRIMASK(1) / __set_PRIMASK(0) 把整段消费逻辑包起来。 这个临界区里只有几条指令,关中断的时间极短,不会影响 2 ms 节拍, 也不会影响串口接收的实时性。但有一条纪律必须守住: 临界区里绝不调用别的函数 —— 如果把滤波、DS18B20 时序、甚至一个 delay 放进临界区, 关中断的时间就从微秒级变成毫秒级,通信和节拍全会出问题。

为什么“只关一边”就够了

关键在于这个队列是单生产者单消费者的。把每个变量归谁写列出来,就一目了然:

变量谁写谁读要不要临界区
SendLen只有生产者(中断)只有生产者不需要
ReceiveLen只有消费者(主循环)只有消费者不需要
DataLen两边都写两边都读需要,由消费端关中断来保证
Data[]按 SendLen 写按 ReceiveLen 读不需要(各写各的位置)

三个计数字段里有两个是“私有”的,两边共享的只有 DataLen 一个。 而消费端在读改写 DataLen 的时候关掉了中断,生产者就不可能插进来 —— 这个共享变量被保护住,整个队列也就安全了。

这里有个前提必须说清楚:只要变成多生产者,这套写法立刻不安全。 比如再挂一路串口、两个接收中断都往同一个队列里写, 生产者之间就会互相覆盖 SendLen 和 DataLen, 那时候必须给生产端也加上临界区,或者干脆每个生产者一个队列。 我现在只挂了一路 485,所以暂时不需要,但这是个明确的边界,我记在这里。

Busy 字段:一个没真正起作用的字段

自己写队列 vs 用 RTOS 队列

这个取舍我也记一下,免得以后只记得“自己写很爽”:

对比项自己写的这一版RTOS 队列
代码量约 40 行,零依赖属于内核的一部分,得先把内核跑起来
RAM 占用512 字节数据区 + 几个计数,完全可控数据区 + 内核对象的额外开销
阻塞语义没有,满了就返回 0,由调用者决定怎么办可以阻塞等待,也可以设超时
出错时怎么查只能自己看指针和计数是否自洽有现成的调试手段可用
适合的场景单生产者单消费者、字节流缓冲多任务之间传消息、需要等待与超时

调试这个小队列时,我用过一个很土但有效的办法:检查不变式。 任何时刻 DataLen 都应该等于 (SendLen - ReceiveLen) 对 512 取模的结果; 一旦这两个数对不上,就说明有人偷了计数 —— 我那次偶发丢字节就是靠这个发现的。 有一个例外要排除:队列满(DataLen 等于 512)时两个指针会重合, 这时候按公式算出来是 0,检查时要单独判断。

六、时间片轮询的边界在哪(什么时候该上 RTOS)

这套框架我用了不短的时间,也大概摸清了它在什么地方会开始难受。 下面这份清单是我自己的判断依据,不是标准答案:

判断项时间片轮询还够用该考虑 RTOS
任务数量与周期 窗口排得下,每件事的最长执行时间都能估出上限 通道或任务多到窗口排不下,或者某一段执行时间不确定
有没有阻塞等待 没有;等待都能改写成状态推进(像这里的 DS18B20) 有网络收发、文件系统这类天然阻塞的操作
任务是否动态 任务集合在编译期就定死 需要在运行时创建、销毁任务,或者任务数量随配置变化
RAM 预算 紧张(本板的 RAM 就很紧),每个字节都要算 宽裕,内核与每个任务一份栈的开销可以接受
响应的确定性 靠“最坏情况可估算”来保证 需要按优先级保证某件事先做,也就是抢占
调试手段 打点、看变量、示波器就够 熟悉栈溢出、优先级反转、死锁这些问题的排查手段

最后一条我想多写两句。RTOS 不是“更高级的裸机”,它是一套新的出错方式: 栈给少了会溢出、优先级排错了会反转、临界区用错了会死锁。 如果这些问题的排查手段我还不熟,那么上 RTOS 之后, 大概率不是“能力升级”,而是把原来能查出来的问题变成查不出来的问题。 所以我给自己的规矩是:先把裸机的时间片和临界区写明白,再考虑 RTOS。

顺便说一个反过来看的角度:这套时间片轮询的框架其实并不“土”, 它本质上就是一个静态优先级、非抢占的调度器, 只不过任务表写死在 switch 里,切换点固定在节拍上。 理解这一点之后,将来真要上 RTOS,迁移路径也很清楚: 把窗口里那几件事各自变成一个任务,把共享变量换成内核对象, 把“关中断保护”换成内核提供的临界区或互斥量。

至于采集链路上更细的那一层 —— 20 点环形缓冲怎么填、排序后取中间三点平均的滤波、 ADC 码值到工程量的整数换算、继电器阈值为什么必须成对 —— 我记在《采样数据的处理:20 点环形缓冲、中值滤波与迟滞控制》里; Modbus 从站那一层(寄存器表、CRC、异常码、485 方向控制)记在 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》里。 这三篇是同一块板子的三个切面。

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

  1. 中断里只改标志,不干活。把不确定性挪到主循环,是这套框架能稳的根本原因。
  2. “等”永远不写成 while。能改写成状态推进的等待,就不要死等; 这一条决定了框架会不会被某一路慢器件拖住。
  3. 状态变量用完就清 0。忘记清的表现是“某一件事被重复做”, 它不报错,只能靠现象推断,很难查。
  4. 临界区关一边就够,但前提是单生产者单消费者。 这个前提一旦被破坏,代码必须跟着改,不能心存侥幸。
  5. 临界区里只放几条指令。任何函数调用都不该出现在关中断的区间里。
  6. 先分清生产者和消费者,再看每个变量归谁写。 我这次真正需要保护的变量只有一个 DataLen; 把这张表列出来,比凭感觉到处加关中断靠谱得多。
  7. 没起作用的字段要如实标注。Busy 现在是个摆设, 写清楚比装作它在工作要好 —— 否则下一个人会以为这里已经有互斥保护了。

参考资料与说明

  • Modbus 应用协议规范(Modbus Application Protocol Specification)中关于 RTU 帧结构、 帧间隔与最大帧长的定义,属于公开标准;本站在 《Modbus RTU 从站实现笔记》里有更细的记录。
  • DS18B20 数据手册(DS18B20 Programmable Resolution 1-Wire Digital Thermometer): 12 位转换时间与单总线时序的公开依据。
  • ARM Cortex-M0+ 处理器的一般性资料(如 ARM 官方技术参考手册): 关中断原语与中断优先级的公开依据。
  • 本文涉及的 MCU 型号、外设配置与代码,均为个人学习项目中的实际做法; 示例代码是按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。