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

压力传感器的标定与温度补偿:
从线性拟合到二维多项式

拿到一个数字输出的压力芯体,最容易犯的错是“读出来的数乘个系数就是压力”。 这篇笔记记录我在这件事上走过的三层:先把码值换算成压力(字节序、符号、LSB 标度、量程寄存器), 再做零点与满量程的两点线性标定,最后才是 21 个系数的二维多项式温度补偿。 每一层我都写清楚它修的是什么误差、参数从哪里来、以及它修不了什么。

压力传感器 标定 温度补偿 24 位 ADC 4–20 mA 已发布

一、为什么“读到 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 的定时器比较值输出恒定或反向,现场直接判为坏表
自上而下五层:原始码值归一化 → 芯体量程映射 → 零点校准 → 两点增益偏移校准 → 温度补偿 → 输出,每层右侧标注
图 1 · 标定链路分层框图

二、第一层:零点与满量程(两点线性插值)

标定的第一层永远是线性的,形式也很固定: 压力 =(原始值 − 零点)× 增益 + 偏移。 我在压力变送器的代码里看到的实现就是这两步: 先 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 域平均转到了校准系数域修正。 我的理解是:温漂是确定性的、可以用模型补;随机噪声才需要平均。 先把确定性误差用系数修掉,剩下的随机部分再谈滤波,顺序反了就会用平均值去掩盖一个本来能算出来的偏差。

立体坐标系:横轴温度、纵轴原始压力、高度为修正量,用一个弯曲的曲面表示“同一压力在不同温度下需要不同修正”
图 2 · 温度补偿的二维关系示意

四、从线性补偿到二维多项式:多出来的系数怎么来的

先看被取代的那一版。它是标准的二元一次形式 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)偏移与斜率都随温度变,含交叉项多个温度 × 多个压力点拟合外推区不可信、系数与批次绑定

五、系数怎么标定、怎么存、怎么迭代

标定流程本身不难,难的是每一步都要留下可复现的记录。我给自己定了一套固定顺序:

  1. 先确认量程:上电读芯体的量程寄存器(满量程 0xD1、零点量程 0xD4),和实物铭牌对一遍。这一步不做,后面所有数据都白标。
  2. 零压置零:在确定的温度和零压下发出置零命令,把当前值写进零点参数。
  3. 加一个已知标准压力:读回设备输出,算出增益与偏移,用校验命令写入并持久化。
  4. 再做温度点:在不同温度下重复“零压 + 若干标准压力点”,把每次的(温度、原始值、标准压力)记下来。
  5. 离线拟合:把记录带回去拟合出多项式系数,写回设备后再跑一次全程复核,而不是只抽查两个点。

存放这件事,我在这个工程里看到的是最简做法:整个参数结构体 (零点、增益、偏移、各类上下限、串口配置、输出配置、设备地址、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 频率、后级电流环器件、供电范围任何一项变了都要重标), 换一代板子沿用旧常数,输出一定偏。

七、一个必须承认的事实:补偿有边界

把标定和温补做完之后,很容易产生一种“误差已经被消除了”的错觉。我的经验恰恰相反: 补偿只是把误差压小,并且把误差的适用范围写清楚了。下面这几条边界我认为必须承认。

  • 拟合区间之外不可信。多项式是用有限个温度点拟合出来的, 出了这个区间就是外推,而且高次项在区间外增长极快,五阶多项式尤其明显。 所以标定的温度范围应该写进设备说明,超范围使用要明确拒绝,而不是让它“自己算”。
  • 系数与芯体批次绑定。代码里留着一组备用系数这件事本身就是证据: 换批次就要换系数。如果只换芯体不换系数,误差可能比不补还大。
  • 补的是芯体自己感受到的温度。温度值来自芯体内部的温度寄存器, 补偿只能修“这个温度”引起的漂移。安装方式、引压管的导热、 介质温度与壳体温度不一致带来的偏差,都不在这个模型里。 这一条我是按机理推的,没有做过实测对照,如实说明。
  • 零点会随时间与安装应力漂移。现场置零是一次性的, 长期使用需要复标——“置零”这个命令本身的存在就说明零点会变。
  • 参数坏了会伪装成传感器坏了。参数区没有校验、没有备份、没有版本, 擦写过程中掉电、或者换了芯体忘了改系数,表现出来都是“这块表不准”, 这是最难查的一类问题。
  • 补偿是可以被关掉的。总开关关闭的时候, “有温补代码”不等于“有温补效果”。这一点我在第四节踩过。

所以我现在写这类代码,除了标定参数之外,一定会额外留一个“标定信息”: 标定日期、标定温度范围、用的是哪套系数。一条不知道适用范围的补偿曲线,比不补更危险, 因为它会让人以为数据是对的。

八、几条我反复用到的经验

  1. 先确认单位与 LSB 标度,再谈算法。“8192 个码 = 1 Pa”这种关系要先写在纸上, 再决定滤波窗口和阈值。标度弄错,后面全是白算。
  2. 零点和增益分开标,至少两个点。只置零修不了斜率,这是最常被跳过的一步。
  3. 量程信息优先从芯体读,不要写死。能读 0xD1 / 0xD4 就别硬编码; 如果一定要有硬编码缺省值,就让它和“读失败时的缺省值”保持一致—— 我见过两处缺省量程互相不一致的例子,足够让一批表全部偏掉。
  4. 用宏开关的功能,一定要在版本号和说明里体现。 开关打开与关闭的固件,在现场是两个产品。
  5. 参数区必须有魔数、版本、CRC 和默认值回退。 否则“参数坏了”会一直伪装成“传感器坏了”。
  6. 系数与批次绑定,把系数放进参数区而不是源码里。 留在源码里的代价是:换一次批次就要重新编译、重新走一遍烧录流程。
  7. 输出链路的标定常数与硬件绑死,换板必须重标。 公式可以复用,锚点值不能。
  8. 注释里的定时参数要回算一遍。我在同一个工程里见过注释里写的采样周期 与按预分频、周期实算出来的差好几倍的例子——采样周期差几倍,会直接影响温补的响应速度评价。

参考资料与说明

  • 数字压力芯体的公开数据手册(I²C 接口、压力/温度寄存器、量程寄存器 DPH/DPL 的定义), 用于确认寄存器地址与 24 位数据格式。
  • 24 位 ΔΣ 型 ADC 的公开数据手册,用于理解有效位、噪声与“末位码值不等于物理量”的关系。
  • I²C 总线规范(NXP UM10204),用于确认时序与从地址的读写位约定。
  • Modbus 应用协议规范(Modbus Application Protocol Specification)中关于功能码与 CRC16 的定义,本文的置零与校验命令是自定义命令集,不是标准功能码。
  • 4–20 mA 电流环的通用工业实践资料,用于说明“活零点”与断线检测的关系。
  • 本文涉及的芯片与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 文中的标定链路、参数与常量来自个人学习项目中的实际实现, 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码。
  • 出于对个人项目实现细节的保护,本文只公开方法与思路;涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。