两路液位 + 两路温度采集小板(HC32F030 + Modbus RTU)
做这块板子的出发点是:一个液体加注与加热的小场景,只需要“看两个罐子的液位、看两处温度、 按阈值开关几路负载”这几件事,用成品控制器不划算,也学不到东西。 于是我用 HC32F030 画了一块小板,不跑 RTOS,用 2 ms 定时节拍把四个采集通道排成四个时间片, 采集值经过环形缓冲中值滤波、工程量换算和人工补偿后,与迟滞阈值比较, 驱动四路继电器,同时把全部数据挂到一张 Modbus RTU 寄存器表上,由上位机轮询或下发参数。 结论是:基本功能都跑通了,滤波与迟滞这两件事对稳定性的改善在实测中能直接看出来; 但 DS18B20 驱动复用、参数单区存储、补偿值编码这几处,我知道它们迟早要返工。
一、这块小板要解决什么问题
需求本身很朴素:一个储液与加热的小装置,需要同时盯着两个罐子的液位和 两处温度,并按各自的上下限去开关泵和加热片。这类需求如果买成品控制器, 功能上够用,但参数改不动、数据拿不出来;如果上 PLC,成本和我能学到的东西完全不成比例。
所以我给自己定了一个很克制的目标:这块板子只做两件事——
- 把四个通道的量采准:两路液位(模拟量)、两路温度(数字量,DS18B20);
- 把四路继电器按阈值动作:液位 1、液位 2、温度 1(加热)、温度 2 各一路。
其余全部交给上位机:阈值由上位机下发,采集值和继电器状态由上位机读取, 板子自己不做任何“高级”判断。这样切分之后,固件里没有状态机、没有业务流程, 只有“采集 → 换算 → 比较 → 输出”这一条直线。
我一开始想把逻辑判断也放到板子上,后来发现:凡是能被上位机改的规则,就不要写进固件。 规则放进固件,意味着每次调整阈值都要重新烧写;放进寄存器表,上位机点一下就行。 这条经验是我在这块板子上最大的收获。
硬件上选择 RS485 + Modbus RTU,是因为现场可能不止这一块板子,总线式挂接最省线; 而 Modbus 的寄存器模型又刚好能把“采集值、阈值、状态”这三类东西一次装进去。
二、硬件方案与信号链
主控是 HC32F030,Cortex-M0+ 内核,外部晶振经 PLL 倍频到 48 MHz 运行, 不跑 RTOS。不跑系统的理由很直接:这块板子只有四个采集通道和一个通信口, 任务之间没有复杂的先后依赖,用一个硬定时节拍排队处理就够了,多一层调度器反而是负担。
从传感器到 ADC 的这条链
模拟侧一共用了三个 ADC 通道,按扫描方式依次转换:
PA4:输入电压监测。它测的是板子自身的供电电压,用来判断供电是否异常;因为信号是经过分压网络才进来的,换算时要按前端的那个分压比还原。PA5、PA6:两路液位传感器输入。液位探头出来的信号先经过分压/调理网络再进 ADC,换算时同样要按前端的分压比与调理比例还原。
温度走的是另一条路:两路 DS18B20 单总线数字温度传感器,分别接在
PB0 和 PB1 上。用数字传感器而不是热敏电阻 + ADC,理由是省 ADC 通道、
不用自己做非线性拟合,代价是转换慢——按 DS18B20 数据手册,12 位分辨率下单次转换最慢约 750 ms,
所以温度通道根本不需要像液位那样密集采样。
ADC 的具体配置
我把 ADC 的关键配置项列在下面这张表里。这些参数不是抄来的推荐值,而是我在“要精度” 和“不要被噪声带着跑”之间试出来的结果:
| 配置项 | 取值 | 我的理解 |
|---|---|---|
| 工作模式 | 扫描模式 | 一次触发按顺序转换多个通道,省去逐通道启动的开销 |
| ADC 时钟 | AdcClkSysTDiv8 | 系统时钟 8 分频后给 ADC,宁可慢一点也要稳 |
| 采样时间 | AdcSampTime12Clk | 给前端调理电路留足够的建立时间 |
| 参考电压 | AVDD | 直接以模拟电源为参考,换算时用 3300 mV 代入 |
| 输入缓冲 | 关闭 | 按我的信号源阻抗,关掉缓冲后读数更稳定 |
| 中断优先级 | IrqLevel3 | 采集节拍要能压过其他中断,不能被通信拖慢 |
参考电压选 AVDD 有个隐含前提:AVDD 必须准。所以我把 PA4 上的输入电压监测 一直留着——它既是供电监测,也是我判断“这次读数整体偏高是不是电源飘了”的依据。
三、采集通道与时间片安排
这块板子最核心的调度只有一个定时器:Timer0 提供一个固定的硬节拍,
节拍长度由定时器的重装值决定(属于工程里的整定值,本文不列出)。
所有采集节奏都建立在这个节拍之上。
具体做法是:中断里维护一个全局计数器 sys_tick,每次加一,
累加到“一轮的总节拍数”就归零;再把这一轮均分成四个窗口,
每个窗口负责一个采集通道。下面这张表说明的是“谁在什么时候干活”:
| 窗口 | 处理哪个通道 | 为什么把它排在这里 |
|---|---|---|
| 第 1 个窗口 | AD1:液位 1(PA5) | 液位是慢变量,采样间隔一致比采样快更重要 |
| 第 2 个窗口 | AD2:液位 2(PA6) | 与液位 1 相邻,两个罐子的数据在相邻两拍内更新完 |
| 第 3 个窗口 | TP1:温度 1(DS18B20 @ PB0) | 温度转换本身很慢,排在液位之后,不占用液位的时隙 |
| 第 4 个窗口 | TP2:温度 2(DS18B20 @ PB1) | 两路温度串行处理,一轮走完刚好回到起点 |
中断里只改一个编号
这里有个我特意坚持的写法:定时器中断里不做任何采集和计算,
它只负责把“这轮该处理哪个通道”的编号改掉(col_ctrl / col_state),
真正的采样与换算全部留给主循环去分派。
/* 定时器节拍中断:只做“数节拍 + 标记轮到谁”,示意片段 */
void timer_tick_IRQHandler(void)
{
if (timer_get_int_flag()) {
timer_clear_int_flag();
if (++sys_tick >= TICKS_PER_ROUND) { /* 一轮的总节拍数 */
sys_tick = 0;
}
if (sys_tick == WIN1_EDGE) col_ctrl = 1; /* 第 1 个窗口到期 */
else if (sys_tick == WIN2_EDGE) col_ctrl = 2; /* 第 2 个窗口到期 */
else if (sys_tick == WIN3_EDGE) col_ctrl = 3; /* 第 3 个窗口到期 */
else if (sys_tick == 0) col_ctrl = 4; /* 一轮回到起点 */
col_state = col_ctrl; /* 中断里只改编号,绝不在中断里做采集 */
}
}主循环那一侧只做一件事:拿到编号、选对应的采集通道、把原始值存进自己的缓冲。 分派本身可以是查表也可以是分支,与业务无关,这里不展开代码。
把采集放到主循环,好处是中断永远很短,不会被某个耗时的转换卡住; 而且 DS18B20 这种需要“拉低总线 → 释放 → 等转换 → 读回”的时序,本来就不适合在中断里做。 关于这种“硬节拍 + 软处理”的调度写法,我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里展开写了自己的理解,这里不重复。
一个窗口给一个液位通道够不够?对液位这种慢变量来说完全够——液面的变化速度远低于这个尺度, 我更在意的是每个通道拿到的采样间隔一致,而不是采样有多快。 窗口长度是按“一轮的周期”反过来定的:一轮要能把四个通道都走一遍, 同时整轮耗时又不能长到把通信那边的应答拖住。
四、滤波与工程量换算
环形缓冲 + 中值滤波
三个模拟通道各有一个 u16 采样数组,构成一个环形缓冲,
长度按需要的滤波深度设定(本文不给出具体点数)。
中断里只往缓冲区里存数,不做任何运算;
主循环处理到该通道时,把缓冲里的值拷贝到一个临时数组,排序,
然后取中间的一段求平均,作为这一轮的滤波结果。
#define AD_CACHE /* 每个模拟通道的环形缓冲长度:按需要的滤波深度设定 */
/* 拷贝 -> 排序 -> 取中间一段求平均,示意片段(中值滤波的通用写法) */
static u16 filter_mid(const u16 *buf, u8 len)
{
u16 tmp[AD_CACHE], t;
u8 i, j;
u32 sum = 0;
for (i = 0; i < len; i++) tmp[i] = buf[i]; /* 先整体拷贝,避免边写边读 */
for (i = 0; i < len - 1; i++) { /* 冒泡排序,点数不多时开销可接受 */
for (j = 0; j < len - 1 - i; j++) {
if (tmp[j] > tmp[j + 1]) {
t = tmp[j]; tmp[j] = tmp[j + 1]; tmp[j + 1] = t;
}
}
}
for (i = MID_LO; i <= MID_HI; i++) sum += tmp[i]; /* 只取中间一段,掐掉两端毛刺 */
return (u16)(sum / (MID_HI - MID_LO + 1));
}为什么是“取中间一段”而不是“只取正中间那一个”?因为缓冲长度是偶数时并没有严格的中位数, 取相邻几点平均既保留了中值抗脉冲干扰的特性,又不会让输出出现台阶状跳变。 环形缓冲的读写指针怎么维护、为什么必须“拷贝出来再排序”, 我在滤波那一篇的环形缓冲一节里写得更完整。
换算:ADC 码值到工程量
ADC 是 12 位,满量程按 4096 计,参考电压按 3300 mV 代入—— 这两个是通用的换算常数,与具体产品无关。在此基础上再按前端的分压比还原: 每个通道的分压比由硬件设计决定,属于设计参数,本文不列出具体数值。
- 输入电压监测:先按
3300 / 4096得到 mV,再乘上本通道的分压比; - 液位通道:同样先换算成 mV,再乘上本通道的分压比与调理比例。
#define ADC_FULL 4096UL /* 12 位 ADC 满量程 */
#define ADC_VREF 3300UL /* 参考电压 mV */
/* 先乘后除:把系数全部乘上去,最后一次性做除法,减少整数截断 */
u32 vin = (u32)vin_filt * ADC_VREF * VIN_DIV_NUM / (ADC_FULL * VIN_DIV_DEN);
u32 lvl1 = (u32)ad1_filt * ADC_VREF * LVL_DIV_NUM / (ADC_FULL * LVL_DIV_DEN);
/* VIN_DIV_* 与 LVL_DIV_* 就是各通道的分压比,按硬件设计取值 */值 / 4096 * 3300 的顺序写的,逻辑上没错,但整数除法会先把小数部分丢掉。
小信号时滤波值可能只有几十,除以 4096 直接变成 0,后面乘什么都没用——表现为“低液位时读数一直是 0”。
改成先乘后除之后就正常了。整数运算里,除法的位置就是精度的位置。
补偿值与它的编码方式
每个通道都有一个 32 位的人工补偿量(代码里按通道命名为 *_er),
它采用的是一种符号-数值表示法:最高位为 1 表示“减”,
为 0 表示“加”,剩下的低位是数值本身。换算成最终值时就是:
/* 符号-数值表示法:最高位是符号位,而不是补码 */
if (er & SIGN_BIT) {
ac = raw - (er & VALUE_MASK); /* 最高位为 1:减 */
} else {
ac = raw + er; /* 最高位为 0:加 */
}五、控制输出:迟滞比较与继电器组
四路输出的判断逻辑是同一条:每个通道有一对阈值,open_*_val(打开阈值)
和 stop_*_val(关闭阈值),拿最终值 ac 去比较。
把输入组合穷举出来,就是下面这张真值表:
| 开阈值 / 关阈值 | 当前输出 | 这一次要做什么 |
|---|---|---|
| 两阈值相等 | 开或关(不看) | 强制关闭。这是安全兜底:阈值被上位机写成相等时,宁可不动,也不要在临界点上反复吸合 |
两阈值不等,ac 已越过开阈值 |
关 | 置位输出(打开)。这是唯一会打开输出的组合 |
两阈值不等,ac 已越过关阈值 |
开 | 清零输出(关闭) |
两阈值不等,ac 落在两阈值之间 |
开 | 保持开。落在迟滞区间里不动作,这一段区间本身就是在防抖动 |
两阈值不等,ac 落在两阈值之间 |
关 | 保持关。同上,两个方向都不动,输出因此是稳定的 |
一句话总结这张表:只有“越过开阈值且当前是关”才置位,只有“越过关阈值且当前是开”才清零, 两阈值相等时一律强制关闭;其余组合都是保持。四路输出共用这一套判定, 区别只在用哪一对阈值、操作输出字节里的哪一位;判定之后的动作是直接改输出镜像的那一位。
四路输出打包成同一个输出字节(代码里叫 relay_group),
每一位对应一路、顺序固定。我把这张表也写进了寄存器说明里,
这样上位机改单路输出时不用关心其他位:
| 位序(从低到高) | 对应通道 | 动作含义 |
|---|---|---|
| 第 1 位 | 液位 1 | 加液 / 排液泵 |
| 第 2 位 | 液位 2 | 加液 / 排液泵 |
| 第 3 位 | 温度 1 | 加热输出 |
| 第 4 位 | 温度 2 | 加热输出 |
为什么要花力气做迟滞,而不是简单地“低于阈值开、高于阈值关”?因为模拟量永远在抖。 如果开关阈值是同一个点,读数在阈值附近来回摆动时,继电器就会跟着反复吸合—— 我实测遇到过这种情况,声音很明显,触点寿命更是问题。 加了一个区间宽度之后,这个现象就消失了。迟滞区间要大于滤波后的峰峰波动, 否则残留的抖动还是能把输出顶过去;具体留多少,按工艺允许的波动范围定—— 工艺允许多大起伏,区间就取在那之内。 迟滞和滤波是什么关系,我在滤波与迟滞那一篇的迟滞比较一节里写得更细。
六、对外接口:Modbus 寄存器表
通信部分是一路 RS485 上的 Modbus RTU 从站:从站地址在工程里是一个固定值; 只实现两个功能码——0x03(读保持寄存器)和 0x10(写多个寄存器), 接收缓冲按最大报文长度留。异常返回分三种:CRC 错误回 0x11、 功能码不支持回 0x01、寄存器地址无效回 0x02—— 这三个是协议标准里定义的,与具体产品无关。
这里有一个必须说明的扩展:标准 Modbus 的寄存器是 16 位的,
而这块板子上的每个参数都是 32 位,所以我一律用 4 个字节(大端)来传输一个寄存器,
发送前用 ArryH_To_ArryL() 做一次字节序反转。这意味着:
用通用 Modbus 调试工具按 16 位去解析这块板子的数据,看到的一定是错的,
必须按 32 位合并两个寄存器来看。
| 类别 | 含义 | 读写 | 排布 |
|---|---|---|---|
| 版本与状态 | 版本号(用编译日期当版本号)、设备状态 / 故障码 | R | 从地址 0x?? 起最前面两个 |
| 液位 1 组 | 采集值 / 补偿值 / 精度值 | R | 连续三个,紧跟其后 |
| 液位 2 组 | 采集值 / 补偿值 / 精度值 | R | 连续三个 |
| 温度 1 / 2 组 | 采集值 / 补偿值 / 精度值 | R | 每组连续三个,两组相邻 |
| 阈值区 | 温度 1 / 2 与液位 1 / 2 的开、关阈值 | R/W | 每个通道一对,四对连续排布 |
| 输出状态 | 继电器组状态(按位对应四路输出) | R/W | 排在最后,占一个寄存器 |
这张表的读法是:顺序就是契约——上位机只关心“第几个寄存器是什么”,
具体从哪个地址起算由固件里的寄存器基址决定,本文统一用 0x?? 表示。
只要这个顺序不变,中间插入新寄存器时把地址整体后移,上位机的解析代码也不用改。
至于输出状态那一个寄存器,位从低到高依次对应液位 1、液位 2、温度 1、温度 2 这四路。
这张表是精简版。关于功能码怎么处理、CRC16 用哪种实现、帧结束怎么判断、 RS485 半双工方向控制的时间账,以及异常码为什么要分成“值得重试”和“不值得重试”两类, 我在《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》 里单独写了一篇,这里就不再展开了。
七、参数掉电保存
Flash 访问被封装在一个很薄的驱动里,对外只有读和写两个函数, 按字节、半字、字三种粒度访问。参数区在 Flash 里单独划了一段, 起始地址由分区方案决定,与代码区分开,不会被程序本身占用; 具体地址数值属于工程里的分区设定,本文不列出。
保存的实现非常直接:参数全部放在一个结构体 sys_data 里(类型是
__data_property_def),整个结构体按字节写进 Flash。
代码里的写法是先取结构体地址 u32 *pdata = (u32*)&sys_data;,
再按字节视图遍历写入:
/* 参数整体落盘:把结构体当成一串字节,逐个写进参数区(示意片段) */
void param_save(void)
{
u32 *pdata = (u32 *)&sys_data; /* 结构体首地址 */
u8 *pbyte = (u8 *)pdata; /* 再转成字节视图遍历 */
u32 i;
for (i = 0; i < sizeof(sys_data); i++) {
flash_write(PARAM_BASE + i, pbyte[i]); /* PARAM_BASE 由分区方案决定 */
}
}十几行就写完了,简单到几乎没有出错的空间——但它有一个很硬的前提: 结构体的内存布局必须稳定。一旦编译器版本变了、对齐方式变了, 或者我在结构体中间增删了一个成员,老参数在新固件里的偏移就全变了, 读出来的数据会被“错位解读”。表现出来就是用户最常说的那句话:“固件升级以后参数全乱了”。
八、我做过的验证与观察到的现象
我没有为这块小板建一套完整的测试台,做的主要是“改一处、看一处”的对比观察。 下面几条是我确实看到的现象,都是定性的,我没有做量化统计:
- 滤波前后对比:加上环形缓冲 + 中值滤波之后, 液位读数在静止液面下的波动明显小于未滤波时。这一点用串口把原始值打出来看很直观。
- 迟滞的效果:把开关阈值设成同一个点时,液面轻微波动会让继电器反复吸合; 给出一段迟滞区间之后,这种反复动作消失了。
- 通信密集读取不影响采集节拍:我特意把 Modbus 读寄存器的频率提上去、 连续不停地读,四个通道的采集节奏没有变化。我理解原因是采集走的是“定时器中断打标记 + 主循环处理”,而通信接收也是中断驱动、解析在主循环——两边是分开的, 所以密集通信只会让响应慢一点,不会把采集节拍挤掉。
我也很清楚这些还只是“看起来对”。真正要证明稳定性,需要长时间老化、需要覆盖电源波动、 需要把液位探头拔掉看它读到什么——这几项我都没做,所以下面一节我把它们如实列出来。
九、局限与还没做完的部分
这块板子实现了基本功能,但离“我觉得可以放心用”还有距离。下面每一条都是我知道问题在哪、 只是还没动手改的:
| 问题 | 现状 | 我打算怎么改 |
|---|---|---|
| DS18B20 驱动复用 | 两路温度共用同一套驱动,靠切换引脚实现复用,不可重入,只能串行调用 | 如果将来加第三路,就把驱动改成结构体化:把引脚、时序状态、转换结果都收进一个实例里 |
| 参数存储 | 只有一份、只有一个起始地址,没有双区备份,也没有版本迁移 | 按参数存储那篇笔记的思路加版本号与双区 |
| 补偿值编码 | 符号-数值表示法,正负号和取值都容易写错 | 改成补码,用一个普通的有符号 32 位整数表示 |
| 断线检测 | 没有做采集通道的断线检测;传感器断线时读数会是什么,我还没有系统验证过 | 先做实验把各通道断线/短路的读数区间测出来,再决定是报警还是回退到安全值 |
把“没做完”写出来并不舒服,但我觉得这比含糊地说一句“运行稳定”要诚实得多。 下一轮我想先解决断线检测——因为它关系到设备在异常情况下会怎么动作, 而这比参数存储的优雅程度重要得多。
参考资料与说明
- HC32F030 系列用户手册(GPIO / ADC / Timer0 / Flash 控制器章节),用于确认外设配置项与参数区地址。
- DS18B20 数据手册,用于确认单总线时序与不同分辨率下的转换时间。
- Modbus 应用协议规范(Modbus Application Protocol Specification),用于确认功能码与异常码定义。
- TIA/EIA-485 串行通信标准相关公开资料,用于确认半双工总线的方向控制时序要求。
- 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中寄存器表只保留类别与顺序,完整实现细节不在本文范围内。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。