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

两路液位 + 两路温度采集小板

这是我为了练手、也为了替换掉一个“用 PLC 太浪费”的小场景而做的一块采集板: HC32F030 主控,两路液位、两路温度、四路继电器输出,通过 RS485 上的 Modbus RTU 对外通信。 这篇记录写的是它怎么工作、我为什么这么写,以及哪些地方我至今还没做完。

HC32F030 裸机 时间片轮询 Modbus RTU RS485 已发布
已发布 · 个人学习项目

两路液位 + 两路温度采集小板(HC32F030 + Modbus RTU)

做这块板子的出发点是:一个液体加注与加热的小场景,只需要“看两个罐子的液位、看两处温度、 按阈值开关几路负载”这几件事,用成品控制器不划算,也学不到东西。 于是我用 HC32F030 画了一块小板,不跑 RTOS,用 2 ms 定时节拍把四个采集通道排成四个时间片, 采集值经过环形缓冲中值滤波、工程量换算和人工补偿后,与迟滞阈值比较, 驱动四路继电器,同时把全部数据挂到一张 Modbus RTU 寄存器表上,由上位机轮询或下发参数。 结论是:基本功能都跑通了,滤波与迟滞这两件事对稳定性的改善在实测中能直接看出来; 但 DS18B20 驱动复用、参数单区存储、补偿值编码这几处,我知道它们迟早要返工。

PLATFORMHC32F030 / Cortex-M0+
STACK裸机 + 时间片轮询 + Modbus RTU
TOOLSKeil MDK + 示波器
STATUS个人学习项目 · 已实现基本功能

一、这块小板要解决什么问题

需求本身很朴素:一个储液与加热的小装置,需要同时盯着两个罐子的液位和 两处温度,并按各自的上下限去开关泵和加热片。这类需求如果买成品控制器, 功能上够用,但参数改不动、数据拿不出来;如果上 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 上的输入电压监测 一直留着——它既是供电监测,也是我判断“这次读数整体偏高是不是电源飘了”的依据。

主控居中,四周连接:两路液位传感器(模拟输入)、两路温度传感器(单总线)、四路继电器输出、RS485 总线接口、调试串口
图 1 · 系统框图

三、采集通道与时间片安排

这块板子最核心的调度只有一个定时器: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 的固件怎么写:时间片轮询 + 消息队列》 里展开写了自己的理解,这里不重复。

一个窗口给一个液位通道够不够?对液位这种慢变量来说完全够——液面的变化速度远低于这个尺度, 我更在意的是每个通道拿到的采样间隔一致,而不是采样有多快。 窗口长度是按“一轮的周期”反过来定的:一轮要能把四个通道都走一遍, 同时整轮耗时又不能长到把通信那边的应答拖住。

上方 2 毫秒节拍,中间四个处理窗口,下方是“滤波 → 换算 → 迟滞比较 → 输出”的数据流
图 2 · 采集流程与时序图

四、滤波与工程量换算

环形缓冲 + 中值滤波

三个模拟通道各有一个 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_* 就是各通道的分压比,按硬件设计取值 */

补偿值与它的编码方式

每个通道都有一个 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 由分区方案决定 */
    }
}

十几行就写完了,简单到几乎没有出错的空间——但它有一个很硬的前提: 结构体的内存布局必须稳定。一旦编译器版本变了、对齐方式变了, 或者我在结构体中间增删了一个成员,老参数在新固件里的偏移就全变了, 读出来的数据会被“错位解读”。表现出来就是用户最常说的那句话:“固件升级以后参数全乱了”。

八、我做过的验证与观察到的现象

我没有为这块小板建一套完整的测试台,做的主要是“改一处、看一处”的对比观察。 下面几条是我确实看到的现象,都是定性的,我没有做量化统计:

  1. 滤波前后对比:加上环形缓冲 + 中值滤波之后, 液位读数在静止液面下的波动明显小于未滤波时。这一点用串口把原始值打出来看很直观。
  2. 迟滞的效果:把开关阈值设成同一个点时,液面轻微波动会让继电器反复吸合; 给出一段迟滞区间之后,这种反复动作消失了。
  3. 通信密集读取不影响采集节拍:我特意把 Modbus 读寄存器的频率提上去、 连续不停地读,四个通道的采集节奏没有变化。我理解原因是采集走的是“定时器中断打标记 + 主循环处理”,而通信接收也是中断驱动、解析在主循环——两边是分开的, 所以密集通信只会让响应慢一点,不会把采集节拍挤掉。

我也很清楚这些还只是“看起来对”。真正要证明稳定性,需要长时间老化、需要覆盖电源波动、 需要把液位探头拔掉看它读到什么——这几项我都没做,所以下面一节我把它们如实列出来。

九、局限与还没做完的部分

这块板子实现了基本功能,但离“我觉得可以放心用”还有距离。下面每一条都是我知道问题在哪、 只是还没动手改的:

问题现状我打算怎么改
DS18B20 驱动复用 两路温度共用同一套驱动,靠切换引脚实现复用,不可重入,只能串行调用 如果将来加第三路,就把驱动改成结构体化:把引脚、时序状态、转换结果都收进一个实例里
参数存储 只有一份、只有一个起始地址,没有双区备份,也没有版本迁移 按参数存储那篇笔记的思路加版本号与双区
补偿值编码 符号-数值表示法,正负号和取值都容易写错 改成补码,用一个普通的有符号 32 位整数表示
断线检测 没有做采集通道的断线检测;传感器断线时读数会是什么,我还没有系统验证过 先做实验把各通道断线/短路的读数区间测出来,再决定是报警还是回退到安全值

把“没做完”写出来并不舒服,但我觉得这比含糊地说一句“运行稳定”要诚实得多。 下一轮我想先解决断线检测——因为它关系到设备在异常情况下会怎么动作, 而这比参数存储的优雅程度重要得多。

参考资料与说明

  • HC32F030 系列用户手册(GPIO / ADC / Timer0 / Flash 控制器章节),用于确认外设配置项与参数区地址。
  • DS18B20 数据手册,用于确认单总线时序与不同分辨率下的转换时间。
  • Modbus 应用协议规范(Modbus Application Protocol Specification),用于确认功能码与异常码定义。
  • TIA/EIA-485 串行通信标准相关公开资料,用于确认半双工总线的方向控制时序要求。
  • 文中涉及的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中寄存器表只保留类别与顺序,完整实现细节不在本文范围内。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。