这块液位采集小板的功能很朴素:两路液位采集、两路温度采集、四路继电器输出,
对外通过 RS485 跑 Modbus RTU(设备地址 0x41,功能码 0x03 / 0x10)。
协议那一层我另外整理过一篇笔记,这里不展开,只把它当作背景。
这篇要记的是协议下面那一层:ADC 采回来的数,到底要怎么处理,才敢拿去做控制。
一、为什么“取一次 ADC 值就用”一定会出问题
我最早写采集的版本非常直接:ADC 扫描完成中断里把结果换算成工程量,赋值给一个全局变量, 主循环拿这个变量去和阈值比较。逻辑上完全说得通,但一上电就发现继电器在阈值附近“咔咔”响个不停, 读回来的数值末位也一直在跳。
后来我把原因拆成三条,都很朴素:
- ADC 本身的量化误差与末位跳动。 12 位 ADC、参考电压 AVDD = 3300 mV、满量程 4096,1 个码值约 0.806 mV。 也就是说,即使输入电压一动不动,最后一位也可能在相邻码值之间来回跳。
- 被测量本身在动。 液面不是静止的:进出液、搅拌、气泡都会让它有波动,这种波动是真实的,不该被当成噪声。
- 干扰是脉冲式的。 继电器吸合的瞬间、485 总线上的收发切换,都可能在一两次采样上叠出一个明显的野值。
把这三点放在一起看,结论就清楚了:单次采样代表的是“此时此刻的一个瞬时值”, 而我真正需要的是“现在大概是多少”。这两件事不是一回事 —— 前者包含噪声,后者才是信号。 想从前者得到后者,中间至少要做三件事: 先攒一串样本(环形缓冲)、把一串样本变成一个可信值(滤波)、 再让这个值在阈值附近不抖(迟滞比较)。
还有一个约束贯穿全文:这块小板不跑 RTOS,主循环是时间片轮询。 所以“在哪里做计算”本身就是一个设计问题。这件事我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里写得更细,这里先强调一句:中断里能做的事越少越好。
二、环形缓冲:让中断只负责存,不负责算
ADC 的配置是扫描模式(AdcSCanMode),时钟分频 AdcClkSysTDiv8,
采样时间 AdcSampTime12Clk,参考电压选择 AVDD(RefVolSelAVDD),输入缓冲关闭。
一次扫描依次走三个通道:
| 扫描序号 | 通道配置 | 引脚 | 用途 |
|---|---|---|---|
| 0(CH0MUX) | AdcExInputCH4 | PA4 | 输入电压监测 |
| 1(CH1MUX) | AdcExInputCH5 | PA5 | 液位 1 采集 |
| 2(CH2MUX) | AdcExInputCH6 | PA6 | 液位 2 采集 |
中断优先级设成 IrqLevel3,扫描完成回调 AdcContIrqCallback 里依次调用
Adc_GetSqrResult(&value1[vadd], 0/1/2),把三个通道的结果取出来写进环形缓冲。
缓冲深度 AD_cache = 20,三个通道各一个 u16 数组,写指针 vadd 累加到 20 归零。
/* 为按个人理解重写的最小片段:ADC 扫描完成中断只做“存” */
#define AD_cache 20
static u16 value1[AD_cache]; /* CH0:PA4 输入电压监测 */
static u16 value2[AD_cache]; /* CH1:PA5 液位 1 */
static u16 value3[AD_cache]; /* CH2:PA6 液位 2 */
static volatile u8 vadd = 0; /* 写指针,累加到 20 归零 */
static void AdcContIrqCallback(void)
{
(void)Adc_GetSqrResult(&value1[vadd], 0);
(void)Adc_GetSqrResult(&value2[vadd], 1);
(void)Adc_GetSqrResult(&value3[vadd], 2);
vadd++;
if (vadd >= AD_cache) {
vadd = 0;
}
}这个中断函数里没有排序、没有除法、没有浮点,只有三次取值、一次自增、一次比较、一次赋值。 执行时间是恒定的,而且很短。这是我有意为之的:
- 排序 20 个元素、再做工程量换算,在 Cortex-M0+ 上要成百上千个周期。 这类活一旦放进中断,中断占用时间就变长,其他中断(尤其是串口接收)会被推迟。
- 串口接收被推迟到下一个字节到来之后,接收寄存器就会被覆盖,表现为丢字节; 丢一个字节,一整帧 Modbus 报文就废了 —— 而且现象是“偶发通信失败”,最难查。
- 中断里做的事情越少,时序就越容易估算。这一条在手上只有示波器和串口助手的时候特别重要。
代价是 RAM:三个通道各 20 个 u16,一共 120 字节;
滤波时还要一个 temp[20] 的临时数组,再 40 字节。
在这块板子上,这个开销是可以接受的。
temp[] 的时候,我并没有关中断。
我个人的分析是:拷贝只需要很短的时间,而缓冲刷新一轮的间隔远大于拷贝时间,
所以拷贝过程中最多混进一个新样本;对一个“排序后取中间值”的滤波器来说,
边界上混进一个新样本不会改变结论。
如果以后把采样率提上去、或者把缓冲深度加大,这一处也应该补上临界区 ——
现在这样是“算过账”的简化,不是随手省掉的。
三、滤波算法:我为什么最后选了“排序后取中间三点平均”
实现很短:先把环形缓冲里的 20 个值拷到临时数组 temp[20],
用冒泡排序 U16_BubbleSort 升序排好,
然后取 temp[9] + temp[10] + temp[11] 三个值并求平均。
也就是中位数附近的 3 点滑动平均。
/* 为按个人理解重写的最小片段:排序后取中间三点平均 */
static void U16_BubbleSort(u16 *buf, u16 len)
{
u16 i, j, t;
for (i = 0; i < len - 1; i++) {
for (j = 0; j < len - 1 - i; j++) {
if (buf[j] > buf[j + 1]) {
t = buf[j];
buf[j] = buf[j + 1];
buf[j + 1] = t;
}
}
}
}
/* 返回滤波后的 ADC 码值(还没有换算成工程量) */
static u16 Ad_Filtering(const u16 *src)
{
u16 temp[AD_cache];
u16 i;
for (i = 0; i < AD_cache; i++) {
temp[i] = src[i];
}
U16_BubbleSort(temp, AD_cache);
return (u16)((temp[9] + temp[10] + temp[11]) / 3u);
}三种做法的取舍,我整理成了下面这张表:
| 方法 | 对脉冲干扰 | 输出连续性 | 计算量 | 我的结论 |
|---|---|---|---|---|
| 算术平均 | 几乎没有抵抗力:一个野值就能把结果拉偏 | 好,输出平滑 | 最小,一次累加 | 不用 |
纯中值(取 temp[10]) |
最好,单个野值排序后直接被排到两端 | 差,输出呈阶梯状,分辨率只有 1 个码值 | 要先排序 | 不用 |
| 中间三点平均 | 好,野值排到两端后进不了 temp[9] ~ temp[11] |
比纯中值好,判决点附近更稳 | 排序 + 两次加法 | 最后选它 |
先说为什么不用算术平均。它是最容易想到的,也是最不抗干扰的: 20 个样本里只要有一个野值,它就会被平均进去,而且无法被“投票”排除。 液位这种慢变量,野值出现的频率不高,但一旦出现,正好可能落在判决点附近,让继电器误动作。
再说为什么不用纯中值。中值对单个脉冲干扰的抑制是这几种里最好的 —— 野值排序后一定会跑到两端,取中间那个值天然不受影响。 但它的问题出在输出:取的终究是“某一个样本”,所以输出只能是整数码值,呈明显的阶梯状。 做小信号比较时(比如液位已经贴近阈值),输出会在相邻两个码值之间来回跳,看上去就是“抖”。 取中间三点平均,等于“先剔除极端值、再轻微平滑”: 野值进不了中间三点,抗脉冲能力基本保留;而三个样本一起参与运算, 真实值缓慢越过码值边界时,三个中间样本不会同时翻过去,判决点附近就稳得多。
这里有一个我自己记下来的细节:代码里写的是 (temp[9] + temp[10] + temp[11]) / 3,
整数除法是向下取整的,所以输出仍然是整数码值,
并没有真的拿到 1/3 码值的分辨率,只是把判决点附近的抖动压下去了。
如果想彻底用上 1/3 码值的分辨率,办法是“不除 3、保留三个数之和”,
同时把参与比较的阈值也放大三倍 —— 比较结果等价,但中间多保留了一位有效信息。
我目前没有这么做,因为要改的地方(阈值寄存器、补偿值、上位机显示)会一起变,收益不明显;
但这是一个明确的、可选的改进方向。
代价:190 次比较,以及缓冲深度怎么定
冒泡排序 20 个元素,最坏情况下比较次数是 20 × 19 ÷ 2 = 190 次。 在 Cortex-M0+ 上这不是一个小数字,但它是按需执行的 —— 不是每个中断周期都算,而是主循环轮到这一路通道时才排一次序。 在我自己的板子上、按当前的节拍实测,这个开销可以接受。 如果以后采样率更高、或者通道数更多,我会按这个顺序改:
- 把冒泡换成插入排序:环形缓冲前后两次的 20 个值高度重合, 近乎有序的数组上插入排序接近线性,而不是稳稳的 190 次比较。
- 改用硬件过采样,让 ADC 自己把多个样本累加平均,软件只读结果。
- 再不行才考虑降低排序频率,比如两次采集只排一次序。
缓冲深度 AD_cache = 20 也是权衡出来的:太浅(比如 5)抗干扰差,
一个野值就能占到 1/5 的权重;太深(比如 64)虽然更稳,但响应变慢,
而且每个通道的更新周期会被拉长。
对一个要驱动继电器的系统来说,“稳”和“跟得上”必须同时成立,20 是我在这两者之间找到的位置。
四、从 ADC 码值到工程量:一次完整的换算链
滤波之后拿到的还是 ADC 码值(0 ~ 4095),要变成能和人对话的工程量,中间还有一步换算。 基准很清楚:12 位 ADC、参考电压 AVDD = 3300 mV、满量程 4096,所以 1 个码值约 0.806 mV。 真正要小心的是每个通道前端还有自己的分压或放大系数,必须乘回去。
/* 为按个人理解重写的最小片段:ADC 码值 → 工程量 */
#define ADC_VREF_MV 3300u /* 参考电压 AVDD = 3300 mV */
#define ADC_FULLscale 4096u /* 12 位 ADC 满量程 */
/* 输入电压监测通道:前端是 1/12 分压,所以乘 12 还原 */
AD_Data.val1 = (u32)Ad_Filtering(value1) * ADC_VREF_MV / ADC_FULLscale * 12u;
/* 两路液位:这两路前端的分压/放大还原系数是 1.51 */
sys_data.lev_sen1_ac = Ad_Filtering(value2) * ADC_VREF_MV / ADC_FULLscale * 1.51;
sys_data.lev_sen2_ac = Ad_Filtering(value3) * ADC_VREF_MV / ADC_FULLscale * 1.51;整数运算的顺序:这一步我踩过坑
上面这几行,乘和除的顺序是刻意的。
如果按“先化成电压、再乘系数”的直觉写成
Ad_Filtering(value2) / 4096 * 3300,结果会全部落在 0 附近:
value2 最大也就 4095,先做整数除法 / 4096 得到的是 0,
后面乘什么都没用了 —— 整数截断把小信号整个吃掉了。
正确的顺序是先把 * 3300 做掉,再除以 4096。
中间结果最大约 4095 × 3300 ≈ 1.35 × 107,
这个量级 32 位无符号数(上限约 4.29 × 109)装得下,还留有很大余量。
有两条相关的注意事项:
-
中间结果必须用
u32。 4095 × 3300 早就超过了u16的 65535; 如果参与运算的变量是u16,这里会静默溢出, 得到一个“看起来像那么回事、但完全不对”的数。 - 各通道的写法要统一。 所有通道都用同一个顺序,截断误差的方向和量级才一致,通道之间、以及与阈值比较时才有可比性。 最怕的是这一路先除后乘、那一路先乘后除,最后数值对不上却查不出是谁的错。
还有一个小细节:液位那两行里的 1.51 是浮点字面量,在 C 里会按 double 参与运算。
Cortex-M0+ 没有硬件浮点单元,这类运算会走软件浮点库,开销不小。
所以我把它留在主循环里,绝不放进 ADC 中断 ——
这和第二节里“中断只存不算”是同一条原则的两面。
温度:一套驱动、两个引脚,但不能重入
温度用的是 DS18B20 单总线器件,两路分别接在 PB0 和 PB1。
驱动只有一套:函数 Get_Tp(ch) 在调用前先切换 Ds18b20_Port / Ds18b20_Pin
两个全局变量,用同一份时序代码去操作不同的引脚。
/* 为按个人理解重写的最小片段:一套驱动、两个引脚 */
static GpioPort_t Ds18b20_Port;
static GpioPin_t Ds18b20_Pin;
/* ch = 1 → PB0;ch = 2 → PB1 */
void Get_Tp(u8 ch)
{
if (ch == 1u) {
Ds18b20_Port = GpioPortB;
Ds18b20_Pin = GpioPin0;
} else {
Ds18b20_Port = GpioPortB;
Ds18b20_Pin = GpioPin1;
}
/* 下面是单总线时序:复位、跳过 ROM、启动转换、读暂存器……
这些函数内部只认 Ds18b20_Port / Ds18b20_Pin 两个全局变量 */
}
这是省代码的常见做法:单总线时序(复位脉冲、写位、读位、CRC)只写一份,换引脚只改变量。
但它有一个必须说清楚的代价:这套驱动是不可重入的。
两个全局变量是共享状态,如果两路温度“同时”在跑(比如中断里也去调 Get_Tp),
后一次调用会把前一次的引脚改掉,前一次剩下的时序就打到了错误的引脚上。
现象是温度值随机错乱,而且很难复现。
我的做法是:把它约束在同一个任务里串行调用。 在时间片轮询的框架下,温度 1 和温度 2 各自有自己的窗口,永远不会同时执行; 中断里也绝对不会去碰单总线。这是能用“一套驱动、两个引脚”的前提。
采集顺序上,我把它排成“先液位、后温度”。原因很直接:温度转换要等, DS18B20 一次 12 位转换最长约 750 ms,是整条采集链里最慢的一环; 液位走 ADC 中断,20 点缓冲攒起来快得多。 把慢的放到时间片靠后的位置,整个大循环的节奏才不会被它拖住 —— 具体的窗口划分见 《不用 RTOS 的固件怎么写》那一篇。
五、迟滞比较:为什么阈值必须有一对而不是一个
到这一步,我手上终于有一个比较可信的工程量了。接下来是把它变成继电器动作。
每个通道有一对阈值:开阈值 open_*_val 和关阈值 stop_*_val。
以液位 1 为例,逻辑是这样的:
-
若
open != stop:- 当
lev_sen1_ac <= open_lev1_val且继电器当前是关 → 打开(置位relay_group1_val的 bit0); - 当
lev_sen1_ac >= stop_lev1_val且继电器当前是开 → 关闭(清 bit0)。
- 当
- 若
open == stop:强制关闭。
写成代码就是下面这样(四路完全同构,只有变量名和位不同):
/* 为按个人理解重写的最小片段:一路液位的迟滞比较 */
#define RELAY_LEV1 0x01u /* bit0 = 液位 1 */
#define RELAY_LEV2 0x02u /* bit1 = 液位 2 */
#define RELAY_TP1 0x04u /* bit2 = 温度 1(加热) */
#define RELAY_TP2 0x08u /* bit3 = 温度 2 */
if (open_lev1_val != stop_lev1_val) {
if ((sys_data.lev_sen1_ac <= open_lev1_val) &&
((relay_group1_val & RELAY_LEV1) == 0u)) {
relay_group1_val |= RELAY_LEV1; /* 低到开阈值,且当前是关 → 打开 */
} else if ((sys_data.lev_sen1_ac >= stop_lev1_val) &&
((relay_group1_val & RELAY_LEV1) != 0u)) {
relay_group1_val &= (u8)(~RELAY_LEV1); /* 高到关阈值,且当前是开 → 关闭 */
}
} else {
relay_group1_val &= (u8)(~RELAY_LEV1); /* 阈值被设成相等:强制关闭 */
}| 位 | 对应输出 | 宏值 | 动作方向 |
|---|---|---|---|
| bit0 | 液位 1 | 0x01 | 低到开阈值 → 打开;高到关阈值 → 关闭 |
| bit1 | 液位 2 | 0x02 | 同上,变量换成第二路 |
| bit2 | 温度 1(加热) | 0x04 | 低到开阈值 → 打开;高到关阈值 → 关闭 |
| bit3 | 温度 2 | 0x08 | 同上,变量换成第二路 |
为什么必须有一对阈值
假设只有一个阈值 T:x >= T 就关、x < T 就开。听起来没毛病,
但测量值不可能刚好停在 T 上不动:ADC 末位会跳,液面本身也在晃。
于是测量值在 T 附近来回穿越,继电器就以采样周期为频率反复吸合,
现象是“咔咔咔”响个不停,触点寿命急剧下降。
更麻烦的是,这种故障在实验室未必复现 —— 要等液位刚好落在阈值附近才出现。
加入 stop > open 的区间之后,系统就有了“记忆”:
打开之后要一路涨到 stop 才会关闭,关掉之后要一路跌到 open 才会打开。
阈值附近的抖动被这个区间整个吞掉了,继电器每两次动作之间必然走完一段完整的行程。
阈值区间该留多大
我给不出一个“万能数字”,但方法是可以讲清楚的。区间宽度至少要同时满足两个条件:
- 大于滤波后测量值的峰峰波动。 做法是让设备在稳定工况下跑一段时间,把滤波后的值记录下来,看最大值与最小值差多少。 本文这套滤波之后,测量值的波动只剩几个码值;阈值区间应该明显大于这个峰峰值, 而不是刚好相等 —— 刚好相等等于没有迟滞,第一节里的“咔咔”声马上就会回来。
- 落在工艺允许的波动范围内。 区间也不能无限大:迟滞区间越大,控制越“钝”, 实际液位(或温度)围绕目标值的偏差就越大。 区间宽度本质上是“抗抖动”和“控制精度”之间的一次交换。
还有一个容易被忽略的点:判断用的是滤波后的值,所以滤波强度会影响迟滞区间的下限。 如果哪天把滤波改强了(缓冲加深、或者换成更强的平滑),波动变小,迟滞区间就可以收紧一点, 控制精度也能跟着提高。这两处要一起调,不能只动一个。
open == stop 时强制关闭:一条安全兜底
这是我在写完迟滞之后补的一条分支。阈值是上位机可以写的, 万一有人把开阈值和关阈值写成同一个数,迟滞逻辑的两个条件就可能同时成立、或者都不成立, 输出会卡在某个状态上再也翻不回来 —— 对一个控制加液或加热输出的设备来说,这是危险的。 所以我的处理是:一旦发现两个阈值相等,直接把这一路关掉。 宁可不动,也不要卡在一个无法恢复的状态里。
顺带一个设计上的收益:relay_group1_val 是一个位域变量,四路输出各占一位。
上位机读一个寄存器就能拿到四路输出的完整状态,
比按线圈一路一路读省一次往返(协议细节见
《Modbus RTU 从站实现笔记》)。
写的时候也方便:置位、清位互不影响,不需要读改写整个变量。
六、补偿值的表示方式:一个我踩过的坑
每个通道都有一个 32 位的补偿值 *_er,用来让现场的人手动把读数拉正。
参与运算时的处理是:
- 若
(er & 0x80000000) == 0x80000000→ac -= (er & 0x7fffffff) - 否则 →
ac += er
也就是说,用最高位当符号位、低 31 位当数值(符号-数值表示法,sign-magnitude), 而不是补码。
/* 为按个人理解重写的最小片段:符号-数值表示的补偿值 */
#define ER_SIGN_BIT 0x80000000u
#define ER_VALUE_MASK 0x7FFFFFFFu
/* ac:采集值(工程量);er:上位机下发的 32 位补偿值 */
static u32 apply_er(u32 ac, u32 er)
{
if ((er & ER_SIGN_BIT) == ER_SIGN_BIT) {
return ac - (er & ER_VALUE_MASK); /* 最高位为 1:减 */
}
return ac + er; /* 最高位为 0:加 */
}好处是直观:打开寄存器表,看到一个最高位为 1 的补偿值,一眼就知道是“减”; 而且正数区间的量程还多出了一个 bit。对一张要人工填写的参数表来说,这份直观是有价值的。
坏处有三个,我按自己踩到的顺序写:
-
和 C 语言的整数语义不一致。这是最要命的一条。
如果谁把它当成
int32_t直接加减,负补偿会变成一个巨大的正数。 举个具体的例子:-50按符号-数值编码是0x80000032; 把它当补码int32_t看,它是-2147483598。 现象就是“补偿值一填负数,读数直接跳到天上”。 -
参与运算前必须先剥离符号位。
多一步判断,就多一处可能写错的地方;漏掉那一步
& 0x7fffffff, 减数里就带上了符号位,结果同样是飞到天上。 - 上位机显示时也要按同样的规则解析。 设备端按符号-数值处理、上位机按补码显示,两边就会各说各话,而且各自的逻辑都是自洽的, 这种问题最难查。协议文档必须把这一条写清楚。
我的结论是:能用补码就用补码。
补码和 C 的整数语义天然一致,int32_t 直接加减就是对的,
不需要额外约定,也不容易写错。
如果因为“人工填写方便”而选了符号-数值,那就要在协议文档里用最大字号写清楚,
并且在上位机做同样的解析 —— 设备、文档、上位机三处必须严格一致,
任何一处偷懒,最后都会变成一次远程排查。
七、几条我反复用到的经验
- 中断里只做“存”。ADC 中断里只有取值、自增、比较;排序、除法、浮点全部留给主循环。
- 先攒样本,再谈算法。没有缓冲,任何滤波算法都无从下手; 缓冲深度本身就是参数,20 是我在抗干扰和响应速度之间找到的位置。
- 滤波算法的取舍要写下来。为什么不用算术平均、为什么不用纯中值, 如果当时不写,半年后自己都说不清,很容易被“优化”回原来的坑里。
-
整数运算先乘后除,中间量用
u32。 先除后乘会把小信号截断成 0;用u16装中间结果会静默溢出。 - 阈值成对出现。单阈值控制的抖动不是“可能发生”,而是“一定会发生”, 只是要等工况刚好落在阈值附近才暴露。
-
编码格式要在文档里写死。
符号-数值和补码的差别,在设备端只是一个
&,在排查现场就是半天。 - “一套驱动、多个引脚”的写法要标注重入限制。 省的是代码量,付出的是“只能在同一个任务里串行调用”这条约束 —— 约束不写清楚,下一个改代码的人一定会踩。
参考资料与说明
- DS18B20 数据手册(DS18B20 Programmable Resolution 1-Wire Digital Thermometer): 12 位转换时间、单总线时序与 CRC 的公开依据。
- Modbus 应用协议规范(Modbus Application Protocol Specification): 本文只作为背景提及寄存器与功能码,实现细节见 《Modbus RTU 从站实现笔记:寄存器表、CRC 与异常码》。
- 本文涉及的 MCU 型号、外设配置与代码,均为个人学习项目中的实际做法; 示例代码是按个人理解重写的最小片段,不代表任何产品或交付代码。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。