一、一个脉冲到底代表多少量
计量通道的输入是两路方波,行业里通常叫 A 相和 B 相。要把它们变成“加了多少升”“发了多少千克”, 中间隔着两个量:脉冲当量(一个完整脉冲周期代表多少量)和 每计数边沿数(一个周期里我认几个计数)。 最终的分辨率就是这两者的商:
最小可分辨量 = 脉冲当量 ÷ 每计数边沿数
这两个量决定了三件事,我一开始只想到第一件:
- 分辨率:能分辨的最小量。倍频就是在这里起作用——把每计数边沿数从 1 提到 4, 分辨率就细了四倍,而且不需要换传感器。
- 累积误差的量级:如果每个周期都丢掉一点零头(比如边沿数不够一个计数就被清掉), 误差不是随机的,而是单向累积的,跑得越久偏得越多。
- 能否判向:只有一路输出的传感器(单脉冲型)根本区分不了正反转, 只能统计“反向误差”;两路正交的(双脉冲型)才能靠相位关系判向。
因为同一块板子要配不同厂家的传感器,我把“传感器类型”做成了参数表里的一项: 单脉冲型走一条边沿累加的路,双脉冲型走“判向 + 倍频”的路。 这两种路径的计数方式不同,但最终都归到同一个累计量上。
还有一个细节值得单独说:累计量不要用浮点。 脉冲当量往往不是整数(甚至是带小数的换算结果),如果每次累加都用浮点, 一是慢,二是不同编译器、不同优化等级下的舍入行为不一致, 排查误差时你会怀疑人生。我的做法是把最小计量单位定成一个整数单位, 全程整数运算,只在显示和上报的最后一刻才做单位换算。 关于单位和换算的坑,我在《定时器与单位:五个反复出现的坑》里单独整理过; 脉冲当量、传感器类型这些项怎么进参数表、怎么掉电保存,见 《Flash 参数区的双备份与原子切换》。
二、正交判向:用两路相位差判断正反转
正交信号的关键性质是:A、B 两路频率相同,相位差四分之一个周期。 正转时 A 领先 B,反转时 B 领先 A。 于是判向的方法就出来了:任何一个边沿到来时,去读另一路的电平, 电平的高低就对应方向。
把它写成真值表,一共四行,非常好记:
| 本次跳变 | 另一路电平 | 判定的方向 |
|---|---|---|
| A 相出现边沿 | B 相为高 | 正转 |
| A 相出现边沿 | B 相为低 | 反转 |
| B 相出现边沿 | A 相为高 | 反转 |
| B 相出现边沿 | A 相为低 | 正转 |
写成代码形状,就是一个没有常量的骨架:
/* 伪代码骨架:常量已去掉,只保留控制流形状。
真实实现里两路的引脚、极性都要按硬件接线核对。 */
void on_edge(通道 ch)
{
if (ch == 通道A) {
方向 = 读电平(通道B) ? 正转 : 反转;
} else {
方向 = 读电平(通道A) ? 反转 : 正转;
}
if (方向 == 正转) { 边沿累加器 += 1; }
else { 反向边沿累加器 += 1; }
}真值表的方向列,不同接线、不同传感器极性下可能整表取反。 这不是问题,只要在装配时用一次已知方向的动作核对一遍就行; 真正麻烦的是另一种情况:如果我把两路的对应关系弄错了(比如 A 的边沿去读了 A 自己的电平), 判出来的方向会变成“看起来有时对有时错”,因为它在某些边沿上碰巧是对的。
还有一种情况必须显式处理:状态跳级。 正常时 A、B 组成的两位状态每次只变一位(00 → 01 → 11 → 10 → 00), 如果某次直接从 00 跳到 11,说明中间丢了一个边沿,或者线上来了一个窄干扰。 这时候无论按哪个方向算都不对:算一个计数会偏,算两个计数会偏得更多。 我的处理是把它计入一个单独的“可疑跳变”计数,只做诊断,不参与计量—— 宁可少计一点,也不要凭空多出一个方向不明的计数。
三、倍频计数:为什么一个周期算四个边沿
把正交信号的一个完整周期画出来,A 相有一次上升、一次下降,B 相也有一次上升、一次下降, 合起来正好四个边沿。这四个边沿在时间上是均匀分布的, 每个边沿之间的间隔是四分之一周期。
这就意味着:如果每个边沿都计一个计数,一个周期就能得到四个计数。 相比“只在一个通道的上升沿计数”(一个周期一个计数),分辨率提高了四倍。 换算关系可以写成:
1 个计数 = 4 个边沿 → 累计量 = 计数 × 脉冲当量 ÷ 4
常见的三种计数方式对比:
| 计数方式 | 一个周期得到几个计数 | 分辨率 | 能否判向 | 中断负荷 |
|---|---|---|---|---|
| 单通道、单沿 | 1 | 1 个脉冲当量 | 不能 | 最低 |
| 单通道、双沿 | 2 | 1/2 个脉冲当量 | 不能 | 中等 |
| 双通道、双沿(四倍频) | 4 | 1/4 个脉冲当量 | 能 | 最高 |
倍频不是没有代价。边沿变密,意味着同样一段噪声会制造出更多的假边沿; 中断次数也翻了几倍,在低主频的核上要考虑中断服务程序的执行时间。 所以倍频和消抖必须一起做——只提倍频不做消抖,分辨率是细了, 但细出来的那部分全都是噪声。
这里有一个我踩过的坑,值得单独写出来:余数不能丢。 既然 4 个边沿才算 1 个计数,那就一定存在“边沿累加器里还有 1 个、2 个、3 个”的时刻。 如果每次处理完就把累加器清零,那么每个周期都会被丢掉一点零头, 误差只朝一个方向累积,而且和流速成正比。 正确的做法是:只有在边沿累加器够一个计数时才进位,进位后把余数留在里面, 让下一次的边沿接着累。
/* 伪代码骨架:每计数边沿数来自配置项,这里写成符号,不给具体取值 */
边沿累加器 += 本次边沿数;
while (边沿累加器 >= 每计数边沿数) {
边沿累加器 -= 每计数边沿数; /* 只减掉整份,余数留在累加器里 */
计数 += 1;
}
/* 错误做法:这里写 边沿累加器 = 0 —— 每个周期都丢零头,误差单向累积 */
上面这段骨架里的 while(而不是 if)也是有意的:
如果一次进中断时已经攒下了不止一份边沿(中断被更高优先级打断过、或者两次处理之间的间隔偏长),
用 if 只会前进一份,剩下的要等下一次,长期下来同样是丢计数。
四、消抖:秒级窗口比延时更稳
脉冲通道的抖动来源很杂:机械触点和干簧管的弹跳、光电开关在临界位置的反复翻转、 电缆上耦合进来的窄脉冲、变频器与继电器动作时的干扰。 它们在示波器上表现不同,但在 MCU 看来都一样——一段密集的、不该被计量的边沿。
最常见的做法是延时消抖:每个边沿记一下时刻, 如果距上一个边沿的时间小于某个固定延时,就把它当成抖动丢掉。 简单、直观、几乎不占资源。但它在计量场景里有一个致命性质: 它给“两个有效边沿之间的最小间隔”设了下限,也就等于给“能测的最大脉冲频率”设了上限。
高速加注时,脉冲间隔可能比这个延时还短。于是真实脉冲被当成抖动吃掉了: 表现为“流量越大,少计越多”,而且是系统性的、单调的少计。 我一开始以为是大阀开度导致流量计非线性,查了很久才发现是消抖参数把高速段切掉了。
/* 错误示范:固定延时消抖 —— 它会连真实的高速脉冲一起吃掉。
这个写法本身没有价值,放在这里只为了说明问题出在哪。 */
void on_edge(void)
{
if (距上次边沿的时间 < 消抖延时) {
return; /* 高速脉冲全部落在窗口里 → 系统性漏计 */
}
上次边沿 = 当前时刻;
计数 += 1;
}我后来换成了窗口消抖:不再逐个边沿判断间隔, 而是开一个以秒为量级的观察窗口, 在窗口内单纯累计边沿数;窗口到期时做一次判断—— 这一批边沿的总数达到阈值,就整批认可;达不到,就整批当抖动丢弃, 然后重新开窗。窗口长度和阈值都来自参数表,这里只讲方法,不给出取值。
/* 伪代码骨架:窗口长度与阈值都来自配置项,本文不给具体数值 */
窗口内边沿数 += 本次边沿数; /* 中断里只做累加,不做判断 */
if (窗口到期) {
if (窗口内边沿数 >= 消抖阈值) {
采纳(窗口内边沿数); /* 真实脉冲流:整批认可 */
} else {
丢弃(窗口内边沿数); /* 零星尖峰:整批不认,且不计入误差 */
}
窗口内边沿数 = 0;
重新开窗();
}两种做法的差别,本质上是判据从“单次间隔”换成了“一批的密度”:
- 真实的脉冲流,密度很高,随便怎么抖,一个窗口内的边沿总数都会远超阈值, 所以再快的流量也不会被吃掉;
- 孤立的干扰尖峰,哪怕幅度很大,在窗口内也只是零星几个边沿,永远够不到阈值, 所以再大的干扰也进不了计量。
对比着看更清楚:
| 对比项 | 固定延时消抖 | 秒级窗口消抖 |
|---|---|---|
| 判据 | 相邻两个边沿的间隔 | 一个窗口内边沿的总数 |
| 对高速脉冲 | 会被当成抖动吃掉,流量越大漏计越多 | 密度更高,必然通过阈值,不漏计 |
| 对孤立尖峰 | 只要间隔够开就能漏过去,形成假计数 | 数量太少,够不到阈值,被整批丢弃 |
| 代价 | 几乎为零 | 认可最多延迟一个窗口;需要一个节拍来开窗 |
| 参数敏感度 | 延时稍大就切掉高速段,很难两全 | 阈值只要低于“最小可信流量”对应的密度即可,容错区间宽 |
窗口消抖有两个取舍要提前想明白:
- 阈值必须按“最小可信流量”来定。如果阈值定得比小流量时的脉冲密度还高, 小流量就会被当成抖动丢掉——这比漏计大流量更糟,因为它发生在每一次小流量动作上。
- 丢计与误计是不对称的。丢掉几个抖动边沿,最坏是少一点量; 把抖动当成流量计进去,就是凭空多出量,直接变成账目问题。 所以阈值宁可略低(偏向“少丢真脉冲”),也不要为了滤得干净而调高。
顺带说一个副产品:窗口计数天然给出了脉冲频率这个量。 有了它,“阀门开着、单位时间内的边沿数却不在合理区间”就可以被判成传感器异常, 这是保护逻辑的一部分,我在《从能跑到能出厂》里 把它归到“可观测性”那一类。
五、误差口径:累计误差与单次误差不是一回事
这一节来自一次真实的修改记录:我原来统计的是“历史累计脉冲误差”, 后来改成了“单次脉冲误差”。 改动只有几个字,但看出来的结论完全变了。
先说这两个口径分别是什么:
- 累计误差:从设备投运到现在,脉冲换算出来的总量与参考总量之间的偏差。
- 单次误差:只针对这一次动作,脉冲换算量与该次的参考量之间的偏差。
我最初选累计口径,是因为它“看起来更有说服力”——数字大,显得全面。 实际用下来,它有三个绕不开的问题:
- 它把历史上所有阶段混在一起。中间换过传感器、改过脉冲当量、调过消抖参数, 这些阶段的偏差会被加总成一个数,你无法判断现在的配置到底对不对。
- 分母会越来越大,把系统偏差稀释掉。同样一个固定比例的偏差, 在累计量小的时候很显眼,累计量涨上去之后相对误差就“变好看”了, 于是一个一直存在的配置错误,反而被指标掩盖了。
- 一次历史遗留的固定偏差,会永久污染这个指标。反过来也一样: 早期某一次异常动作留下的一个固定差值,会让之后所有读数都带着它, 让你以为当前还有问题。
单次口径就干净得多:它只反映“这一次”的配置与工况,而且可以直接行动:
| 单次误差的表现 | 我的判断 | 下一步查什么 |
|---|---|---|
| 偏差符号固定、比例稳定 | 脉冲当量或换算系数不对 | 核对参数表里的脉冲当量与传感器类型 |
| 偏差正负跳动、无明显规律 | 分辨率或抖动导致的量化误差 | 看窗口阈值是否偏高、是否丢了边沿余数 |
| 小流量偏差大、大流量偏差小 | 消抖把低速段或高速段切掉了一部分 | 核对消抖阈值与最小可信流量是否匹配 |
| 同一台设备忽好忽坏 | 干扰或接线问题,不是算法问题 | 看可疑跳变计数、屏蔽与接地 |
两个口径的关系,我的结论是:单次口径用来判断“现在对不对”, 累计口径只用来做“账目对不对得上”的宏观核对。 拿累计口径去调参数,等于用一个被平均过的、迟钝的指标去指挥一个需要精细调整的对象。
六、硬件与软件的职责划分
这条通道上有一半的问题不是代码写错,而是“该硬件做的事推给了软件”, 或者反过来。我现在按下面这条线分:
| 环节 | 硬件负责 | 软件负责 |
|---|---|---|
| 边沿质量 | 比较器/施密特整形、必要的 RC 低通、上拉与限流 | 不靠软件“修”边沿,只在配置里选择触发极性 |
| 抗干扰 | 双绞与屏蔽、光耦隔离、走线与地平面 | 窗口消抖、可疑跳变诊断 |
| 方向 | 保证两路相位关系正确、接线不反 | 真值表判向、跳级检测 |
| 分辨率 | 传感器本身的脉冲数 | 倍频计数与余数保留 |
| 异常判断 | — | 按窗口频率判断“阀门开着却没有脉冲” |
边界不清的后果是双向的。硬件没整形、边沿很缓,软件就只能用更长的延时去躲, 一躲就切掉高速段;反过来,硬件上堆了太重的 RC,真实脉冲被滤得变了形, 软件再用什么算法也补不回来。 能用一个比较器解决的事,不要留到软件里用参数去补。
软件这一侧,中断里要做的事情尽量少。我把它压缩到“记边沿、判方向”两件事, 所有涉及阈值的判断都留到窗口到期时在任务里做:
/* 驱动访问骨架:按个人理解重写,只体现“中断里少做事”的形状 */
void pulse_isr(void)
{
if (外部中断标志已置位(通道A) || 外部中断标志已置位(通道B)) {
清中断标志();
记一个边沿(); /* 只累加、只判向,不做阈值判断 */
}
}
/* 触发方式在初始化里配成双边沿:
exti_init(通道A, 双边沿触发);
exti_init(通道B, 双边沿触发);
两路的优先级要一致,否则判向时读到的是错位的电平。 */中断里“少做事”还有一个不那么直观的好处:它让判向变得可靠。 如果中断里顺手做了别的事情(比如刷显示、写存储), 处理时间一长,A、B 两个边沿的先后关系就可能被拉平, 于是真值表读到的“另一路电平”变成了上一个状态的残留值,方向就判反了。
七、几条我反复用到的经验
- 先算分辨率,再选倍频。最小可分辨量 = 脉冲当量 ÷ 每计数边沿数, 这个式子决定了你能做到多细,也决定了参数该配成什么样。
- 余数一定要留住。边沿累加器进位后只减掉整份,余数接着累; 每周期清零就是单向漂移。
- 判向读的是“另一路”。读错通道的表现是“方向时对时错”,最难查。
- 状态跳级要单独记,不要硬算。方向不明的边沿宁可计入诊断计数,也不进计量。
- 消抖看密度,不看间隔。间隔判据会给最大可测频率设上限,密度判据不会。
- 阈值按“最小可信流量”定,不是按“想把干扰滤多干净”定。
- 丢计与误计不对称。少计一点是精度问题,凭空多计量是账目问题,后者严重得多。
- 看指标先看分母。判断当前配置对不对,用单次口径;累计口径只用来对总账。
- 中断里只做两件事:记边沿、判方向。阈值判断放到窗口到期后做, 这也是《嵌入式存储的三层分工》里 “控制流程不要同步落盘”的同一条原则。
- 脉冲量最终要落成一笔记录,而落盘本身需要时间。 这笔时间预算是怎么来的,我在《掉电检测与重启抑制》里算过。
参考资料与说明
- 增量式正交编码器的通用原理资料:A/B 两路相位差四分之一周期、四倍频解码与方向判定的真值表,属于公开技术内容。
- MCU 参考手册中外部中断/EXTI 与 GPIO 章节:双边沿触发配置、中断标志清除方式与中断优先级,属于公开文档。
- 流量计与编码器数据手册中关于输出脉冲数、脉冲当量与输出形式的说明(不同厂家表述不同),属于公开文档。
- 关于机械触点与光电器件抖动特性的通用应用笔记,以及“计数型信号消抖”的通用做法,属于公开技术资料。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码, 示例代码为按个人理解重写的通用片段。文中的消抖阈值、窗口长度、脉冲当量、传感器系数等产品特定参数已全部省略, 消抖一节里的第一段代码是错误示范,仅用于说明问题。
- 文中出现的芯片型号仅用于说明技术方案,与相关厂商无隶属或授权关系。