这块小板上的活其实只有三类:采集(两路液位、两路温度)、控制(四路继电器输出)、
通信(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 ~ 15 | 0 ~ 30 ms | AD1(液位 1) | ADC 扫描 + 20 点环形缓冲滤波 |
| 15 ~ 30 | 30 ~ 60 ms | AD2(液位 2) | 同上,第二路 |
| 30 ~ 45 | 60 ~ 90 ms | TP1(温度 1) | DS18B20,接在 PB0 |
| 45 ~ 60 | 90 ~ 120 ms | TP2(温度 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 接收被推迟、丢字节、通信偶发失败。
改成“中断只写一个变量”之后,中断的执行时间恒定且极短, 和当前有多少活完全无关。重活全部落在主循环: 主循环被打断一下没关系,因为它没有硬实时的截止时间; 而串口接收被打断就有关系,因为它有硬件层面的截止时间 —— 下一个字节到来之前,接收寄存器的内容必须被取走。 把不确定性从中断里挪到主循环里,这是整套设计的核心。
三、状态变量 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 各自在自己的窗口里、串行调用。
这一点我在采样那一篇里写得更细。
四、消息队列:中断与主循环之间怎么交接数据
采集这条线是“定时器中断派工、主循环干活”,而通信这条线正好相反: 中断是生产数据的一方,主循环是消费数据的一方。 串口每收到一个字节就进一次中断,而一个 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;| 字段 | 类型 | 作用 |
|---|---|---|
SendLen | u16 | 写指针,写入后自增并对 512 取模回绕;只有生产者改 |
ReceiveLen | u16 | 读指针,取走后自增回绕;只有消费者改 |
DataLen | u16 | 当前已存字节数;两边都会改(下一节的重点) |
Data[512] | u8 | 数据区,按两个指针环形使用 |
Busy | u8 | 本意是占用标记,目前并没有真正起作用(见下一节) |
两个操作函数的逻辑都很短,返回值就是“成功 / 失败”:
/* 为按个人理解重写的最小片段:生产端(入队) */
/* 本来就在串口接收中断里,所以没有再关中断 */
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+ 上这会展开成三条指令:
读出、减一、写回。危险就出在“读出”和“写回”之间:
- 主循环读出
DataLen,值是 5; - 这时串口中断来了,
mQueueSend写入一个字节,把DataLen改成 6; - 中断返回,主循环接着执行“减一、写回”,把 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 字段:一个没真正起作用的字段
Busy 字段,我本意是做一个简单的占用标记。
但在这版实现里,生产端把它置 1 之后立刻又置 0,实际上它几乎永远是 0,
并没有起到互斥作用,消费端那句 Busy == 0 的判断也就形同虚设。
真正让队列安全的是上面说的“单生产者单消费者 + 消费端关中断”,不是这个字段。
我暂时没有删它,是想留个位置:如果以后真的要支持多生产者,
就会把它改成“进入写操作前先置 1、写完再清 0”的真实占用标记。
但现在的状态必须如实说明:它目前只是个摆设。
自己写队列 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 与异常码》里。 这三篇是同一块板子的三个切面。
七、几条我反复用到的经验
- 中断里只改标志,不干活。把不确定性挪到主循环,是这套框架能稳的根本原因。
- “等”永远不写成 while。能改写成状态推进的等待,就不要死等; 这一条决定了框架会不会被某一路慢器件拖住。
- 状态变量用完就清 0。忘记清的表现是“某一件事被重复做”, 它不报错,只能靠现象推断,很难查。
- 临界区关一边就够,但前提是单生产者单消费者。 这个前提一旦被破坏,代码必须跟着改,不能心存侥幸。
- 临界区里只放几条指令。任何函数调用都不该出现在关中断的区间里。
-
先分清生产者和消费者,再看每个变量归谁写。
我这次真正需要保护的变量只有一个
DataLen; 把这张表列出来,比凭感觉到处加关中断靠谱得多。 -
没起作用的字段要如实标注。
Busy现在是个摆设, 写清楚比装作它在工作要好 —— 否则下一个人会以为这里已经有互斥保护了。
参考资料与说明
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于 RTU 帧结构、 帧间隔与最大帧长的定义,属于公开标准;本站在 《Modbus RTU 从站实现笔记》里有更细的记录。
- DS18B20 数据手册(DS18B20 Programmable Resolution 1-Wire Digital Thermometer): 12 位转换时间与单总线时序的公开依据。
- ARM Cortex-M0+ 处理器的一般性资料(如 ARM 官方技术参考手册): 关中断原语与中断优先级的公开依据。
- 本文涉及的 MCU 型号、外设配置与代码,均为个人学习项目中的实际做法; 示例代码是按个人理解重写的最小片段,不代表任何产品或交付代码。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。