一、为什么“读到 ADC 值就等于压力”是错的
我最早接触数字压力芯体时的想法很朴素:I²C 读三个字节出来,拼成一个数,乘一个系数,就是压力。 这个想法在实验室里能跑通——因为实验室只有一个室温、一个接近零压的状态。 等到换温度点、换量程点,数据就散了。后来我把这件事拆开看,才发现“读出来的数”上面叠了四层约定, 任何一层弄错,结果都不是“差一点”,而是“完全不是这个量”。
第一层是字节序。芯体吐出来的 3 个字节并不一定是“高位在前”,我在一个工程里看到的做法是 读回 3 字节后先做一次字节反转,再拼成 24 位整数。这一步不做,读数会变得毫无规律, 但因为变化方向看起来“也在跟着压力动”,很容易被误认为“传感器噪声大”。
第二层是符号约定。24 位有符号数的满码是 8388608(223), 代码里的判据是“原始码大于 8388608 就认为它是负值,减掉 16777216”。 如果按无符号处理,一个负压读数会变成接近满量程的大正数, 在差压测量里这意味着“吸”被读成了“吹”。
第三层是LSB 对应的物理量。有一个差压芯体的换算关系是 8192 个码 = 1 Pa。这句话反过来读更有意思:满码 8388608 除以 8192, 它的满量程大约只有 ±1024 Pa。也就是说,末位几个码的跳动根本不该被当成压力变化, 而我当初却拿它去做阈值判断。
第四层是芯体自己用的输出区间。另一个 24 位芯体并不是把整个 ±1 都用满,
而是只用归一化输出的 10%~100% 这一段,再由量程寄存器 DPH(满量程)和 DPL(零点量程)
线性映射到实际压力。这两个值可以从芯体寄存器(0xD1 / 0xD4)读回,
也可以硬编码;源码里的注释写着“根据芯体官方要求,不建议修改此参数”,
但运行时实际用的是读回来的值。
把这几层摆开之后,就很清楚了:从芯体到“一个能用的压力值”,最少要经过 原始码 → 归一化 → 量程映射 → 零点 → 两点校准 →(可选)温度补偿 → 输出 这几段。我在 《个人实验:工业压力变送器与 RS485 / CAN Bootloader》 里记的是这条链路的工程实现,这篇笔记专门讲标定与补偿本身。
| 层级 | 典型取值范围 | 含义 | 弄错的后果 |
|---|---|---|---|
| 24 位原始码 | 0 ~ 16777215 | 芯体吐出的三个字节拼成的整数 | 不做符号判断,负压变成满量程正数 |
| 归一化值 | -1.0 ~ +1.0 | 原始码 ÷ 8388608(223) | 后续量程映射全部错位 |
| 差压工程量 | 由公开标度换算出的 Pa 范围 | 原始码 ÷ 公开标度(多少码对应 1 Pa 由数据手册给出),再加一个正向偏置 | 忘记偏置就会整段平移一个固定量 |
| 表压/绝压工程量 | DPL ~ DPH(芯体的物理量程) | 用芯体量程寄存器线性映射 | 量程读错,满量程点整段偏 |
| 输出量 | 定时器比较值(区间由实测标定得到) | 对应 4 ~ 20 mA 的定时器比较值 | 输出恒定或反向,现场直接判为坏表 |
二、第一层:零点与满量程(两点线性插值)
标定的第一层永远是线性的,形式也很固定:
压力 =(原始值 − 零点)× 增益 + 偏移。
我在压力变送器的代码里看到的实现就是这两步:
先 sgm = pressure_disp - set_zero_data,再 sgm = sgm * check_gain + check_offset。
三个参数各管一件事,分清楚它们分别修什么误差,比记住公式重要。
set_zero_data(零点):由现场“置零”命令写入,把当前压力当作 0。它修的是零点误差。check_gain(增益):它修的是量程/灵敏度误差,也就是“加了标准压力却只读到偏小的值”这类问题。check_offset(偏移):它是一个额外的平移量,用来处理置零之后仍然存在的固定偏差。
为什么至少要两个点?因为只置零修不了斜率。零压时置零,把曲线平移到了正确的位置, 但斜率还是芯体自己的斜率;到满量程附近,误差就随压力线性放大。 正确做法是:零压点读一次、加一个已知标准压力再读一次, 增益 =(标准压力 − 零点压力)÷(两次读数之差)。两点之间的所有点都靠线性插值, 这也是“两点标定”这个名字的来历。
零点这一层还有一个更隐蔽的来源:量程寄存器的缺省值不一致。 我在源码里看到两处相关定义——文件里硬编码的初值给的是一个量程, 而从芯体读失败时的兜底缺省值却是另一个量程。也就是说, “读不到芯体量程”这件事会悄悄把量程换成另一个数,输出整段被压扁对应的比例。 这两组缺省值本身属于产品参数,本文不列具体数字; 这类不一致不会报错,只会让现场数据看起来“偏小”,我认为它是这类代码里最值得先清理的地方。
芯体量程的读取本身也要有耐心:状态寄存器里有一个“数据就绪”位, 代码里的就绪判据是“状态字节的某一位为 0 或者整个字节为 0xFF 就继续等”, 最多重试 2000 次。这个 2000 不是随便写的——它对应一个上界, 而不是“死等”,超时后走缺省值,避免 I²C 掉线时整个固件卡在初始化里。
| 参数 | 修什么误差 | 谁来写 | 存在哪 | 丢了会怎样 |
|---|---|---|---|---|
零点 set_zero_data | 固定零点偏移 | 现场置零命令 | 参数区整块结构体 | 全量程平移,低压端最明显 |
增益 check_gain | 灵敏度/量程误差 | 校验命令写入 | 参数区整块结构体 | 误差随压力线性放大 |
偏移 check_offset | 残余固定偏差 | 校验命令写入 | 参数区整块结构体 | 全量程同向偏移 |
量程 DPH / DPL | 物理量程映射 | 从芯体寄存器读回 | 不落盘,每次上电读 | 退回缺省值,量程整段错 |
/* 24 位码值 → 压力的方法骨架(按个人理解重写的最小片段)。
用符号化名字代替真实变量:标度系数、零点偏置等具体数值本文不公开。 */
float code_to_pressure(uint32_t 原始码值)
{
int32_t 有符号码 = (原始码值 > 满量程的一半)
? (int32_t)原始码值 - 满量程 /* 最高位为 1 → 负压 */
: (int32_t)原始码值;
float 工程量 = (float)有符号码 / 标度系数; /* 标度:由公开资料给出 */
return 工程量 + 零点偏置; /* 偏置:实测标定得到 */
}关于这个正向偏置,我想多说一句:这个芯体的量程本来就窄, 加上偏置之后结果基本落在 0 以上,看上去“消掉了负数”。 但上层的判定代码里还留着一道“读数必须先超过某个门限才减去偏置”的门槛, 说明这个偏置只是把负压挪到了不常见的位置,并没有真正消除负压。 偏置值与那道门限都属于实测整定出来的产品参数,本文不列具体数字。 用偏置代替符号处理,迟早要还的。
三、第二层:温度补偿为什么躲不掉
温度补偿能不能做,取决于“能不能知道温度”。这一点上数字芯体有天然优势:
那个 24 位芯体除了压力寄存器(0x06)之外,还有一个温度寄存器(0x09),
读回的 3 字节同样是 24 位有符号,换算关系是
温度 = 读回值 ÷ 65536 + 25。也就是说读回 0 表示 25 ℃,
温度是跟着压力一起免费拿到的,不需要额外的测温器件。
为什么必须补?我个人的理解是:这类压阻式敏感元件在温度变化时, 零点和灵敏度都会变。零点漂移在低压端表现为一个固定偏移, 灵敏度漂移在满量程端表现为斜率变化。而在室温下做的置零只修好了“室温的零点”, 温度一变,这两项误差一起回来——低压端先偏,高压端偏得更多。 这也解释了为什么单点标定的表在实验室很好、到现场就“低端不准、高端更不准”。
我在两个工程里看到两种应对方式,恰好代表两个层次的工程成熟度。 一个是便携式差压监测设备:它用电位器系数补偿做人工修正—— 以一个基准 ADC 码为中心,每偏离若干码就补偿一点压力; 而且只有偏离超过一个门限,才认定“系数确实被调节过”——这是一个迟滞判断, 防止抖动被反复补偿。基准码、补偿比例与门限都是实测整定值,本文不公开。另一个就是压力变送器:它先写了一版线性温补函数, 后来被 21 个系数的二维多项式取代,线性版被明确标注为已弃用。
还有一个容易被忽略的取舍:那个变送器里原本有一个“去极值平均”的滤波函数 (多次读取后去掉最大最小再平均),后来被标注为已禁用, 滤波策略整体从 ADC 域平均转到了校准系数域修正。 我的理解是:温漂是确定性的、可以用模型补;随机噪声才需要平均。 先把确定性误差用系数修掉,剩下的随机部分再谈滤波,顺序反了就会用平均值去掩盖一个本来能算出来的偏差。
四、从线性补偿到二维多项式:多出来的系数怎么来的
先看被取代的那一版。它是标准的二元一次形式
p = p00 + p10·x + p01·y,其中 x 是温度、y 是压力原始值。
代码里给了三个系数,都是按实测数据拟合出来的,具体值不在本文公开;
但把它们的量级关系读一遍,就能看出这一版的定位:
压力项的系数接近 1,说明压力基本是直通的;温度项的系数很小,
意味着每升高 1 ℃ 只补偿一点点偏移。
它能修的是“零点随温度线性漂移”,修不了“斜率随温度变化”。
而真实的器件里,斜率本身也是温度的函数。线性模型只能对整段温度取一个折中的斜率,
结果必然是“中间对、两头偏”。要表达“斜率随温度变”,就必须引入交叉项,
把增益写成一个关于温度的式子:
gain(T) = p01 + p11·T + p21·T² + …。
这就是多项式里多出来的那些系数的用处——它们不是精度装饰,而是模型自由度的增加。
具体到这个工程,它是一个两个变量的多项式,系数按 p_ij 编号,
i 是压力的次数、j 是温度的次数,
满足 i + j ≤ 5,一共 21 个系数,
其中包含 p50(压力的五次项)和 p05(温度的五次项)。
项数是可以算出来的:两个变量、总次数不超过 n 的项数是 (n+1)(n+2)/2,
依次是 1 阶 3 项、2 阶 6 项、3 阶 10 项、4 阶 15 项、5 阶 21 项。
21 这个数字恰好对应“总次数不超过 5”,这也是为什么同一份材料里会出现“四阶”和“五阶”两种说法——
按交叉项的层数数是四阶,按最高次项数是五阶。命名可以含糊,项数是确定的。
还有一个细节决定了这套系数怎么用:源码注释明确写着“此函数的参数需要通过 MATLAB 仿真获得”, 并且代码里保留了一组被注释掉的备用系数。 这两件事说明:系数不是推导出来的,是离线拟合出来的; 而且换一个芯体批次,系数就要换一套。 如果换批次不换系数,误差有可能比不做补偿还大。
| 做法 | 参数个数 | 能表达什么 | 标定工作量 | 主要风险 |
|---|---|---|---|---|
| 只做零点 | 1 | 固定零点偏移 | 1 个压力点 | 斜率误差完全没修 |
| 两点线性标定 | 2(增益 + 偏移) | 固定斜率 + 固定偏移 | 2 个压力点(同温度) | 换温度后误差回来 |
| 线性温补 | 3(p00/p10/p01) | 偏移随温度线性变化 | 2 个温度 × 2 个压力点 | 无法表达斜率随温度变 |
| 二维多项式温补 | 21(i + j ≤ 5) | 偏移与斜率都随温度变,含交叉项 | 多个温度 × 多个压力点拟合 | 外推区不可信、系数与批次绑定 |
五、系数怎么标定、怎么存、怎么迭代
标定流程本身不难,难的是每一步都要留下可复现的记录。我给自己定了一套固定顺序:
- 先确认量程:上电读芯体的量程寄存器(满量程
0xD1、零点量程0xD4),和实物铭牌对一遍。这一步不做,后面所有数据都白标。 - 零压置零:在确定的温度和零压下发出置零命令,把当前值写进零点参数。
- 加一个已知标准压力:读回设备输出,算出增益与偏移,用校验命令写入并持久化。
- 再做温度点:在不同温度下重复“零压 + 若干标准压力点”,把每次的(温度、原始值、标准压力)记下来。
- 离线拟合:把记录带回去拟合出多项式系数,写回设备后再跑一次全程复核,而不是只抽查两个点。
存放这件事,我在这个工程里看到的是最简做法:整个参数结构体
(零点、增益、偏移、各类上下限、串口配置、输出配置、设备地址、SN、OTA 标志等)
整块放在 Flash 的 0x08040000 一个扇区里,
写就是“擦这个扇区 + 整块写回”,读就是整块读出。
简单、直接,但有两个明确的隐患:
- 没有校验:结构体里没有魔数、没有 CRC、没有版本号,读回来直接用。
- 两个工程共用一块区域、结构体定义却不一样:Bootloader 与 App 各有一份结构体定义, App 侧多出两个字段,但两者用的是同一个地址。也就是说,同一块 Flash 会被两种布局解读, 一边写了另一边读,字段就整体错位。
更极端的反面教材出现在另一个工程里:参数写入函数每次调用都做一次整扇区擦除,
没有读-改-写、没有磨损均衡、也没有 CRC;
更麻烦的是函数参数同时被当作“要写的地址”和“要擦的扇区地址”。
后果是写 4 个字节要擦掉一整页,而且擦除之后如果掉电,参数区全变成 0xFF,
浮点数会变成 NaN 量级的异常值。代码里对应的兜底只有“增益等于 0 或小于一个大负数”这类零值判断,
以及初始化时给的一组默认值——这不是校验,只是碰巧挡住了最离谱的情况。
我认为这类参数区最少要补四件事:魔数、版本号、CRC、默认值回退, 再加一层“每个系数都落在合理区间”的范围检查。 关于双备份与递增序号的完整做法,我在 《Flash 参数存储:结构体直存的问题与双备份做法》 里单独写过,这里只留一段最小实现:
/* 参数区读取的骨架(按个人理解重写的最小片段,不是任何交付代码):
参数区只有一个裸结构体时,至少要有魔数、版本、CRC 三道检查;
任何一道不过就整块回默认值,而不是带着空片值继续跑。 */
int param_load(struct storge_param *out)
{
read_head(&head); /* 先只读记录头 */
if (head.magic != PARAM_MAGIC) /* 不是本布局写的 → 回退 */
return param_load_default(out);
if (head.version != PARAM_VERSION) /* 结构变过 → 不能按新布局解读旧数据 */
return param_load_default(out);
if (head.crc != crc32_of_body()) /* 擦写中掉电会留下半新半旧的数据 */
return param_load_default(out);
read_body(out);
return param_range_check(out); /* 每个系数还要落在合理区间 */
}迭代这件事还有一个值得记下来的现象:这个工程里同时存在“旧路径被禁用”和“新路径默认关闭”两种状态。 去极值平均滤波被标注禁用;线性温补函数被多项式取代;板载单点总线温度器件的初始化被注释掉; 4–20 mA 输出那条链路的校准系数还没有标定(保持零值),导致初始化函数根本不会被执行。 这些不是垃圾代码,而是“同一套源码支持多个型号”的裁剪痕迹。 但它带来一个实际后果:读代码时必须先确认哪条链路真的在跑, 不然很容易对着一段永远不执行的分支分析半天。我把这些整理在下面这张表里。
| 被替换/禁用的部分 | 原来的作用 | 现在的状态 | 我的理解 |
|---|---|---|---|
| 去极值平均滤波 | ADC 域多点平均去尖峰 | 标注已禁用 | 确定性误差改由系数修正,滤波不再是主力 |
| 线性温补函数 | 偏移随温度线性补偿 | 被多项式取代,标注已弃用 | 模型自由度不够,两端拟合不上 |
| 板载单点总线温度器件 | 提供板级温度 | 初始化被注释禁用 | 温度改由压力芯体自带寄存器提供 |
| 4–20 mA 输出链路 | 模拟电流输出 | 校准系数尚未标定(零值),初始化不执行 | 硬件方案迭代中裁剪,代码保留 |
六、输出环节:把压力变成 4–20 mA
工业现场偏爱 4–20 mA 电流环是有道理的:长线传输时电压会随线阻衰减,电流不会; 而 4 mA 作为“活零点”,让“断线(0 mA)”和“真实的零(4 mA)”变成两件可以区分的事。 这是它比 0–20 mA 或 0–10 V 更适合现场的原因,也是这类变送器的通用做法。
这个工程里的实现是定时器 PWM:TIMER7 的通道 1 输出到 PC7, 用预分频、周期与重复计数器配出一路固定载波频率的 PWM,并使能主输出。 也就是说,MCU 侧交给后级的是“一个占空比”,占空比对应目标电流。 代码里给了三个标定点:零点锚点、中段锚点、满量程锚点, 每个锚点记录的是“某个压力 → 某个定时器比较值”。这三个数不是算出来的, 是在后级电流环上实测出来的,本文不公开具体数值。
我按这三个点反推了一下,发现一件很干净的事:“压力 → 电流”这一段是严格线性的, 中段锚点恰好落在量程的同一个比例上,用线性公式算出来的电流和实测电流对得上。 之所以还要分两段、给三个锚点,是因为“比较值 → 实际电流”这一段不是线性的 (后级电流环的特性),所以要在中段再实测一个锚点,把曲线掰回来。 这正是“分段线性 + 每段两点标定”这个做法的来历: 每一段用两个实测锚点定一条直线,段与段之间共用一个锚点接上。
第二段的末尾还挂着一个很小的经验修正量,原样留在代码里。 这几个字很能说明模拟输出的现实:理论公式负责把量级算对, 收尾的小偏差通常靠一次实测标定补上。修正量本身也是实测整定值,本文不列。
/* 4–20 mA 比较值:分段线性 + 每段两点标定(按个人理解重写的最小片段)。
每段用两个实测锚点定一条直线,中段锚点由后级电流环实测标定得到;
锚点值与末尾的经验修正量都不在本文公开。 */
uint32_t pressure_to_compare(float p)
{
if (p <= ANCHOR_MID_P) /* 第一段:零点锚点 → 中段锚点 */
return lerp(p, ANCHOR_ZERO, ANCHOR_MID);
/* 第二段:中段锚点 → 满量程锚点,末尾再加一个实测的经验修正量 */
return lerp(p, ANCHOR_MID, ANCHOR_FULL) + TRIM_MEASURED;
}| 锚点 | 怎么得到 | 记录什么 | 说明 |
|---|---|---|---|
| 零点锚点 | 量程零点处实测 | 压力值 + 定时器比较值 | 对应 4 mA 活零点,用来区分断线 |
| 中段锚点 | 量程中段实测 | 压力值 + 定时器比较值 | 后级电流环非线性,需要一个中间点把曲线掰回来 |
| 满量程锚点 | 量程上限处实测 | 压力值 + 定时器比较值 | 对应 20 mA;第二段末尾另加一个实测的经验修正量 |
必须说清楚的是:链路虽然写完了,但当前版本里它是不执行的—— 校准系数还没有标定(保持零值),初始化函数被条件判断挡在外面。 而这也带来一个提醒:这些标定常数和硬件是绑死的 (PWM 频率、后级电流环器件、供电范围任何一项变了都要重标), 换一代板子沿用旧常数,输出一定偏。
七、一个必须承认的事实:补偿有边界
把标定和温补做完之后,很容易产生一种“误差已经被消除了”的错觉。我的经验恰恰相反: 补偿只是把误差压小,并且把误差的适用范围写清楚了。下面这几条边界我认为必须承认。
- 拟合区间之外不可信。多项式是用有限个温度点拟合出来的, 出了这个区间就是外推,而且高次项在区间外增长极快,五阶多项式尤其明显。 所以标定的温度范围应该写进设备说明,超范围使用要明确拒绝,而不是让它“自己算”。
- 系数与芯体批次绑定。代码里留着一组备用系数这件事本身就是证据: 换批次就要换系数。如果只换芯体不换系数,误差可能比不补还大。
- 补的是芯体自己感受到的温度。温度值来自芯体内部的温度寄存器, 补偿只能修“这个温度”引起的漂移。安装方式、引压管的导热、 介质温度与壳体温度不一致带来的偏差,都不在这个模型里。 这一条我是按机理推的,没有做过实测对照,如实说明。
- 零点会随时间与安装应力漂移。现场置零是一次性的, 长期使用需要复标——“置零”这个命令本身的存在就说明零点会变。
- 参数坏了会伪装成传感器坏了。参数区没有校验、没有备份、没有版本, 擦写过程中掉电、或者换了芯体忘了改系数,表现出来都是“这块表不准”, 这是最难查的一类问题。
- 补偿是可以被关掉的。总开关关闭的时候, “有温补代码”不等于“有温补效果”。这一点我在第四节踩过。
所以我现在写这类代码,除了标定参数之外,一定会额外留一个“标定信息”: 标定日期、标定温度范围、用的是哪套系数。一条不知道适用范围的补偿曲线,比不补更危险, 因为它会让人以为数据是对的。
八、几条我反复用到的经验
- 先确认单位与 LSB 标度,再谈算法。“8192 个码 = 1 Pa”这种关系要先写在纸上, 再决定滤波窗口和阈值。标度弄错,后面全是白算。
- 零点和增益分开标,至少两个点。只置零修不了斜率,这是最常被跳过的一步。
- 量程信息优先从芯体读,不要写死。能读
0xD1/0xD4就别硬编码; 如果一定要有硬编码缺省值,就让它和“读失败时的缺省值”保持一致—— 我见过两处缺省量程互相不一致的例子,足够让一批表全部偏掉。 - 用宏开关的功能,一定要在版本号和说明里体现。 开关打开与关闭的固件,在现场是两个产品。
- 参数区必须有魔数、版本、CRC 和默认值回退。 否则“参数坏了”会一直伪装成“传感器坏了”。
- 系数与批次绑定,把系数放进参数区而不是源码里。 留在源码里的代价是:换一次批次就要重新编译、重新走一遍烧录流程。
- 输出链路的标定常数与硬件绑死,换板必须重标。 公式可以复用,锚点值不能。
- 注释里的定时参数要回算一遍。我在同一个工程里见过注释里写的采样周期 与按预分频、周期实算出来的差好几倍的例子——采样周期差几倍,会直接影响温补的响应速度评价。
参考资料与说明
- 数字压力芯体的公开数据手册(I²C 接口、压力/温度寄存器、量程寄存器 DPH/DPL 的定义), 用于确认寄存器地址与 24 位数据格式。
- 24 位 ΔΣ 型 ADC 的公开数据手册,用于理解有效位、噪声与“末位码值不等于物理量”的关系。
- I²C 总线规范(NXP UM10204),用于确认时序与从地址的读写位约定。
- Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与 CRC16 的定义,本文的置零与校验命令是自定义命令集,不是标准功能码。
- 4–20 mA 电流环的通用工业实践资料,用于说明“活零点”与断线检测的关系。
- 本文涉及的芯片与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 文中的标定链路、参数与常量来自个人学习项目中的实际实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
- 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。