1. 项目概述与核心需求解析
1.1 为什么选CS1237这颗ADC
做电子秤,很多教程默认用HX711。HX711便宜、例程多,但它的最大问题是内部PGA增益和采样率固定,噪声水平一般,而且读出来的原始数据经过各种代码处理后,稳定性完全看运气。CS1237是国产芯海科技的一颗24位ADC,同样是两线接口,价格和HX711差不多,但它内置了可编程增益放大器和可配置采样率,在低速率下的有效无噪声位数明显更好。我之前用同一套模具,把HX711换成CS1237之后,静态重复性从±1.5g左右直接压到±0.3g以内(以5kg量程传感器为例),这个差别在称粉末、配料的时候特别明显。
CS1237另外一个优势是外围极简。它不需要外部晶振,SCLK和DOUT两根线直接接MCU,内部有振荡器和稳压电路,甚至可以直接用MCU的3.3V供电,然后通过软件配置PGA和采样率。对新手来说,这意味着不需要折腾模拟前端,布板难度低很多。
说白了,选CS1237就是花一样的钱,买到更好的噪声底和更大的调节空间。对精度有要求的家用电子秤、咖啡秤、小包装分装秤,甚至小量程工业配料秤,这颗芯片都很合适。
1.2 电子秤系统整体架构
一套完整的电子秤系统并不复杂,核心链路可以拆成四段:电阻应变式称重传感器负责把重量变电信号;CS1237负责把这个微弱的差分信号放大并转成24位数字量;MCU读取数字量以后,用滤波算法进行平滑;最后根据标定系数换算成重量,送到OLED、数码管或TFT显示。
我在这个项目里用的主控是STM32F103C8T6,你完全可以用ESP32、GD32或者任何带GPIO的MCU替代,因为CS1237的通信协议就是普通的GPIO模拟时序,不依赖任何硬件外设。显示部分选了一块0.96寸OLED,用I2C接口,方便调试时直接看重量和滤波前后的原始数据。
整体方案确定以后,接下来要解决三个核心问题:传感器怎么接、CS1237怎么读、数据怎么滤。这三个问题解决掉,整个项目就完成80%了。后面所有的工作,包括去皮、标定、显示,本质都是在一条稳定的数据流上做业务逻辑。
2. 硬件底层原理与电路设计
2.1 称重传感器与惠斯通电桥
市售常见的单点式称重传感器,内部其实是4片电阻应变片贴在弹性体的上下表面,接成惠斯通电桥。当传感器受力变形时,上下两对电阻的阻值一个增大一个减小,电桥失去平衡,输出一个与外力成比例的差分电压。这个电压有多小呢?以5kg量程、灵敏度1mV/V的传感器为例,5V激励下满量程也才只有5mV,而1g对应的电压是1μV级别。这就意味着后端ADC必须具备两方面的能力:一是能放大这么小的信号,二是分辨能力要足够高。
所以好传感器的素质很重要,但ADC同样重要。普通的10位、12位ADC面对满量程5mV的信号根本没法定标,必须用高分辨率加内置放大器的方案。CS1237内部PGA可设置增益1、2、64、128,接电桥传感器时一般选64或128。增益128时,满量程5mV放大为640mV,配合24位ADC,理论上每个码字对应的电压分辨率大概是参考电压除以增益再除以2的23次方这个量级。当然,理论分辨率是一回事,噪声会把有效位数拉低,这一点后面会展开讲。
传感器选型上也有讲究。家用秤推荐用单点式铝制传感器,量程比实际需求大出30%到50%即可。比如你要称最大3kg的东西,就选5kg传感器,这样既保证线性度,又不会让弹性体过早疲劳。体积很小的悬臂梁传感器适合做小量程,大量程则要考虑材质和安装方式。记住一个原则:传感器是机械结构,安装不稳,后面软件再怎么滤波都救不回来。
2.2 CS1237引脚与电路连接
CS1237只有两线接口:SCLK和DOUT。引脚功能很直白:VDD和GND接电源,VREF需要接一个高质量的参考电压,AVDD给模拟部分供电,AIN+和AIN-接传感器差分输出。很多人第一次画CS1237电路时会问VREF和AVDD是不是可以接一起,可以,但要在VREF引脚和AVDD引脚之间加足够的去耦电容。我用的是10μF钽电容加0.1μF陶瓷电容并联,放在芯片旁边,实测对数据稳定有很大帮助。
硬件连接方面我给出自己验证过的接法表:
| 传感器线色 | 功能 | 接CS1237 |
|---|---|---|
| 红 | 激励+ | AVDD |
| 黑 | 激励- | GND |
| 绿 | 信号+ | AIN+ |
| 白 | 信号- | AIN- |
这里有个常见误区:很多模块把红黑接电源、绿白接AIN,但不同厂家传感器的线色并不完全统一,最好先用万用表确认一下。电桥输出阻抗一般几百欧到一千欧,AIN端不需要额外保护,但DOUT引脚建议加一个10kΩ上拉电阻,防止上电时序不确定时读到随机电平。SCLK和DOUT的走线尽量短,同时远离电机、继电器这类干扰源。我第一版飞线实验就因为这个吃了亏,线从开关电源旁边绕了一圈,结果数据跳得没法看。
2.3 24位精度到底意味着什么
“24位ADC”这个词很容易让人以为它能分辨出满量程的1/16777216,但实际不是这么回事。ADC手册里的24位指的是转换结果长度,真正有意义的是“无噪声位数”或者“有效位数”,它和PGA增益、采样率、供电噪声都有关系。CS1237在PGA=128、采样率10Hz时,官方典型有效无噪声位数能做到20位左右;采样率提到640Hz后,有效位数会明显下降,因为带宽变宽,噪声进来了。
打个比方:24位ADC是一把刻了1677万个刻度的标尺,但你的手会抖、空气会流动、桌面会振,你实际能稳定读出的位置可能只有20位对应的刻度。所以DIY电子秤别盲目追求显示到0.01g,要先看传感器量程和ADC的有效分辨率,再决定显示小数位。5kg量程、20位无噪声分辨率对应约0.005g的代码级分辨率,但经过机械结构和负载以后,实际稳定显示到0.1g已经很了不起。搞懂这一点,你就明白为什么数据出来一定要滤波:不是滤掉毛刺,而是要榨干这颗24位ADC在低噪声模式下的剩余价值。
3. 软件驱动与卡尔曼滤波实现
3.1 CS1237读取时序与驱动代码
CS1237和MCU之间是类似SPI的两线时序,但没有片选。DOUT有两个作用:一是数据就绪信号,二是数据输出。上电后如果芯片没有完成转换,DOUT保持高电平;转换完成后DOUT自动拉低,通知MCU“数据已经准备好”。MCU检测到DOUT低电平即可开始读数据。
读数据的过程是对SCLK连续打24个脉冲,在每个脉冲的上升沿把DOUT上的数据位移进来,高位在前,结果是一个24位有符号数。读完之后,如果你想修改寄存器配置,可以继续打8个脉冲,把配置字写进去。如果不想改配置,打完24个脉冲后DOUT会自行恢复高电平,下一次转换完成后又会拉低。
这里有一个容易踩的坑:读数据的时机必须严格等DOUT拉低,不能自己用固定延时去凑。我第一次写驱动时图省事,直接用延时去读,结果数据时不时跳一个很大的值。改成检测DOUT下降沿或者循环等待拉低后,就非常稳定了。下面是我在STM32上的一个极简驱动示例,GPIO和延时函数请根据自己的平台替换:
// cs1237.h #ifndef __CS1237_H #define __CS1237_H #include <stdint.h> void CS1237_Init(void); int32_t CS1237_Read(void); uint8_t CS1237_IsDataReady(void); #endif// cs1237.c #include "cs1237.h" #include "main.h" #define CS1237_SCLK_PIN GPIO_PIN_0 #define CS1237_DOUT_PIN GPIO_PIN_1 #define CS1237_GPIO_PORT GPIOA static void SCLK_High(void) { HAL_GPIO_WritePin(CS1237_GPIO_PORT, CS1237_SCLK_PIN, GPIO_PIN_SET); } static void SCLK_Low(void) { HAL_GPIO_WritePin(CS1237_GPIO_PORT, CS1237_SCLK_PIN, GPIO_PIN_RESET); } static uint8_t DOUT_Read(void) { return HAL_GPIO_ReadPin(CS1237_GPIO_PORT, CS1237_DOUT_PIN); } void CS1237_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin = CS1237_SCLK_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(CS1237_GPIO_PORT, &GPIO_InitStruct); GPIO_InitStruct.Pin = CS1237_DOUT_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(CS1237_GPIO_PORT, &GPIO_InitStruct); SCLK_Low(); } uint8_t CS1237_IsDataReady(void) { return (DOUT_Read() == 0) ? 1 : 0; } // 读取一次ADC转换结果,配置寄存器暂用上电默认值 // 想修改PGA增益、采样率等参数时,请参考数据手册按位写入 int32_t CS1237_Read(void) { uint32_t raw = 0; uint32_t timeout = 0xFFFFF; while (DOUT_Read() == 1 && timeout--); if (timeout == 0) return 0; for (int i = 0; i < 24; i++) { SCLK_High(); raw <<= 1; if (DOUT_Read()) raw |= 1; SCLK_Low(); } // 24位有符号数扩展为32位有符号数 if (raw & 0x800000) raw |= 0xFF000000; return (int32_t)raw; }要提醒的是,CS1237写配置寄存器时DOUT需要切换为输出方向,在SCLK配合下逐位送出配置字,和读数据的方向正好相反。不同MCU实现时,要特别注意DOUT引脚的方向切换。我上面的代码使用上电默认配置,这样最保险。如果你要配成PGA=64、40Hz采样,建议写一个独立的CS1237_WriteConfig(uint8_t cfg)函数,不要把它和读数据混在一起,否则调起来会很痛苦。
3.2 为什么称重数据必须做滤波
从CS1237读回来的原始数据,即使一切正常,也会在静止状态下小幅跳动。原因有几个:传感器本身的机械迟滞、ADC量化噪声、电源纹波、电磁干扰,甚至桌面的轻微震动。跳动幅度通常有几十到几百个原始码值,对应到重量可能是±0.1g到±1g,这在很多场合不可接受。
最简单的滤波是均值滤波,连续采N次取平均,效果也不错,但缺点也很明显。均值滤波把所有采样点当成同等可信,一旦出现一个大幅度的随机尖峰,要把它完全平掉需要很多个点,而点多了动态响应又变差。称重恰恰是“既要短时间稳定、又要在放上重物时快速跟随”的场景,均值滤波很难兼顾。
卡尔曼滤波就不一样。它把系统当成一个有数学模型的状态估计问题,每一帧数据进来时,先根据上一帧的预测值做“经验估计”,再用当前观测值做“修正”,并根据噪声水平给预测和观测分别分配一个随时间变化的权重。放到电子秤上,效果就是:静止时滤波输出非常平滑,几乎看不出跳动;放上砝码后,几百毫秒内就能跟上真实重量,不会像大窗口均值那样拖泥带水。
3.3 卡尔曼滤波的五个核心公式
卡尔曼滤波非常适合“线性系统+高斯噪声”的场景。电子秤在静止或缓变时,可以近似认为重量是一个常量,只是被噪声污染。用数学语言描述就是:
x(k) = x(k-1) + w(k-1) z(k) = x(k) + v(k)
其中x是真实重量,z是ADC读数,w是过程噪声,v是测量噪声。整条滤波流程可以浓缩成五个公式:
预测部分: x_pred = x_est P_pred = P_est + Q
更新部分: K = P_pred / (P_pred + R) x_est = x_pred + K * (z - x_pred) P_est = (1 - K) * P_pred
这里的Q是过程噪声协方差,代表你对状态模型本身的信任程度;R是测量噪声协方差,代表你对单次ADC读数的信任程度。Q越大,滤波器越相信新数据;R越大,滤波器越平滑但反应越慢。
放到程序里,这个滤波器的全部状态就是两个变量x和P,输入一个观测值z,输出一个新的x。计算量非常小,在STM32F103这种72MHz主频上跑,几乎不占用CPU时间。所以不要被“卡尔曼”这个名字吓到,它落到代码里就是一个结构体加两个函数,和均值滤波的工程量差不了太多。
我用的卡尔曼滤波代码是这样的:
// kalman.h #ifndef __KALMAN_H #define __KALMAN_H typedef struct { float x; // 状态估计值 float P; // 估计误差协方差 float Q; // 过程噪声协方差 float R; // 测量噪声协方差 float K; // 卡尔曼增益 } KalmanFilter_t; void Kalman_Init(KalmanFilter_t *kf, float init_x, float init_P, float Q, float R); float Kalman_Update(KalmanFilter_t *kf, float measurement); #endif// kalman.c #include "kalman.h" void Kalman_Init(KalmanFilter_t *kf, float init_x, float init_P, float Q, float R) { kf->x = init_x; kf->P = init_P; kf->Q = Q; kf->R = R; kf->K = 0.0f; } float Kalman_Update(KalmanFilter_t *kf, float z) { // 预测 // 常量模型下状态预测值就是上一刻的最优估计 // P_pred = P + Q // 更新 kf->K = (kf->P + kf->Q) / (kf->P + kf->Q + kf->R); kf->x = kf->x + kf->K * (z - kf->x); kf->P = (1.0f - kf->K) * (kf->P + kf->Q); return kf->x; }这段代码里,我直接把P_pred的计算合并进了K和P的更新里,少定义一个临时变量,工程上完全没问题。你如果刚开始看卡尔曼,建议先按五个公式原样写一遍,确认理解以后再压缩代码。
3.4 滤波参数怎么调
Q和R的取值,我见过很多人头疼,其实有个实用起点。先把CS1237的原始码值换算成克,然后看静止时数据的方差,假设方差是0.01g²,那R可以取0.01。Q可以先取一个很小的值,比如0.001,这时候滤波输出会很滑,响应稍慢。如果你发现放上重物后数值需要一秒钟才到位,就把Q调大一个数量级,到0.01左右。相反,如果静止时输出波动太大,就把R调大。
调参时注意一定要在真实使用场景下测,不能只在桌面上拿传感器单独测。用手触碰传感器、旁边放风扇、底部垫软垫,这些都会改变噪声特性。我自己的经验是:5kg传感器、CS1237设置PGA=64、采样率40Hz,静止方差大约0.005g²,最后取Q=0.002、R=0.02,响应和稳定度都很理想。如果你把采样率降到10Hz,噪声会更小,Q还可以再低一点。
4. 完整源码与工程实践
4.1 工程结构与功能划分
在写整个工程代码之前,先想清楚功能划分。嵌入式程序不需要很复杂,但模块清晰能让你以后改量程、换传感器、加按键都方便。我的工程分四个模块:
| 模块 | 职责 |
|---|---|
| cs1237.c | CS1237驱动:初始化、读取数据、配置寄存器 |
| kalman.c | 卡尔曼滤波器:初始化、更新、参数设置 |
| scale.c | 称重逻辑:均值采样、标定系数、重量换算 |
| main.c | 主循环:定时读取、滤波、显示、按键处理 |
这里有个容易被新手忽略的地方:标定系数不要直接写死在滤波后的数据里,而应该把“零位”和“斜率”分成两个变量。这样你做两点标定的时候,只修改这两个变量,不需要重新编译。我习惯在Flash里专门存一组标定参数,上电时读取,按一下校准键就重新标定并写回。这样批量生产或者后续换传感器时,不需要改程序。
4.2 主循环与ADC采样流程
主循环的逻辑其实很简单:检测到DOUT拉低就读取一次原始码值,把码值通过滤波器,再用标定系数换算成重量,刷新显示。不要在一个循环里强迫自己用等间隔采样,CS1237在有数据时会拉低DOUT,你可以配合定时器,每隔2ms查一次DOUT,也可以直接在循环里轮询。40Hz采样率下,两次有效数据间隔25ms,主循环里轮询完全够用。
核心逻辑大概是这样的:在主循环中先判断数据是否就绪,如果就绪,就读取原始码值;然后做一次滤波,将滤波结果当成“当前重量信号”;再减去零点偏移乘上斜率得到重量值。如果按下去皮键,就把当前滤波后的重量记录为零点偏移。整个过程没有任何阻塞等待,OLED刷新放到一个独立的小节拍里,避免影响采样。
在实际工程中,我会把ADC原始码值到克的换算放在滤波前还是滤波后,取决于噪声分布。我的习惯是先做一次原始码值到克的线性映射,再做滤波,因为这样Q和R参数的单位直接就是克和克²,调参时不至于对着几万几十万的码值发懵。
4.3 两点标定法实操
电子秤测量的基础是把码值映射成重量。这个映射关系在线性区间内就是一条直线,所以理论上只要两个点就能确定斜率和截距。第一个点是空秤零点,第二个点是放上已知重量的砝码。实际操作步骤如下:
- 空秤上电预热30秒以上,等待数据稳定。
- 记录零点平均值,记为zero_raw。
- 放上标准砝码,例如1kg,等待稳定,记录均值,记为span_raw。
- 计算斜率:scale = 1000.0 / (span_raw - zero_raw)。
- 实际重量 = (raw - zero_raw) * scale。
注意,这里用的是滤波前还是滤波后都行,但一定要保持口径一致。比如你滤波后得到的是一个“克”值,那么重新标定时也要用同一个路径。如果用公斤或者市斤做单位,斜率换算要同步。
标定砝码不用追求特别多,一个准确的质量块就够。你要是没有砝码,可以用一瓶标注净含量500g的矿泉水做粗略标定。标定的关键不是“多重”,而是“重量已知且准确”,所以能用砝码就用砝码。我测下来,用1kg标准砝码做两点标定以后,0到3kg范围内的非线性误差基本在±0.5g以内,对DIY项目来说已经很能打了。
5. 常见问题与调试经验
5.1 常见问题速查表
我把DIY过程中最容易遇到的问题列成一张表,基本能覆盖大多数情况:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读回数据一直是0xFFFFFF或满量程 | 传感器接线反、激励没供电 | 检查红黑绿白,用万用表测桥臂输出 |
| 数据跳变剧烈 | DOUT没上拉、供电纹波大、飞线过长 | 加10kΩ上拉,加强去耦,缩短连线 |
| 静止时数据缓慢漂移 | 温漂、传感器蠕变、预热不充分 | 上电预热1分钟,放在避风处测试 |
| 放上重物数值慢慢涨 | 机械结构松、夹具变形 | 检查称重平台的固定螺丝 |
| 标定后量程偏大或偏小 | 砝码不准、采样率过高导致有效位数下降 | 换标准砝码,降低采样率至10Hz或40Hz |
| 显示屏出现负数 | 零位设置不对 | 重新去皮或重新标定零点 |
5.2 调试实录:数据跳变与温漂
我说两个自己真实踩过的坑。
第一个是数据跳变。第一版飞线测试时,我把CS1237模块直接插在面包板上,用杜邦线连到STM32,传感器放在桌子上。结果数据跳得非常明显,滤波都滤不平。排查后发现,问题出在DOUT引脚悬空时电平不稳。CS1237的DOUT在上电到第一次转换完成这段时间是不确定的,MCU读到随机高电平时就会误判“数据就绪”,导致读出乱码。加上10kΩ上拉电阻后问题立刻消失。另外,我当时把SCLK线从开关电源旁边走过,每次附近设备切换时数值都会抖一下,把线挪到远离电源的地方就好了。
第二个是温漂。北方冬天开窗测试,我注意到空秤读数会在几分钟内缓慢变化,大约0.2g。这不是ADC的问题,而是应变片本身的零点温漂。解决办法不是换硬件,而是在软件上做“自动零点跟踪”:当滤波后的重量连续N秒都落在零点附近的一个小窗口内,比如±0.3g,就自动把该值更新为新的零位偏置。这个功能在很多商用电子秤里都有,理解原理后自己写几十行代码就能加上。注意窗口不能设太大,否则小重物放上去也会被当成零点漂移吃掉。
6. 效果测试与优化扩展
6.1 实测数据对比
我拿机械砝码做了一组简单测试,对比了不滤波、10点均值滤波和卡尔曼滤波三种方式的表现。测试条件是5kg单点传感器、CS1237 PGA=64、采样率40Hz、STM32F103,传感器静止放置,每0.5秒记录一次重量。以下是1kg砝码连续读数时的极差和稳定时间:
| 滤波方式 | 静止极差 | 加载稳定时间(±0.1g) |
|---|---|---|
| 不滤波 | ±1.2g | 不适用,晃动太大 |
| 10点均值 | ±0.35g | 约1.2s |
| 卡尔曼滤波 | ±0.1g | 约0.4s |
这个结果基本符合预期。卡尔曼滤波的优势主要在于,它在降噪的同时没有像均值滤波那样积累大量延迟。静止极差从±1.2g降到±0.1g,对一个5kg量程的传感器来说,已经能胜任绝大多数家用和小型商用场景。如果你把采样率降到10Hz,PGA和滤波参数重新调一下,精度还能更好,但动态响应会变慢,适合极低速平台。
6.2 后续可以怎么扩展
这套方案只需要改很少的代码就能做很多变体。比如把单路传感器换成四角支撑的多传感器结构,硬件上需要把四个传感器的信号求和后接进CS1237,软件流程基本不变。也可以加上蓝牙模块,把滤波后的重量数据通过低功耗蓝牙发送到手机App,做一个智能营养秤。这类扩展在工程上难度不大,真正花时间的是数据格式和App端交互。
关于显示,OLED在小秤上很好用,但如果你要做厨房秤或者商用标签秤,建议换成LCD段码屏或者数码管,可视角度和寿命会更好。按键方面,目前只做了一个去皮键,实际上还可以加单位切换键和标定键,配合一个简单的菜单,就能把DIY秤做成一个完整产品原型。
我个人的看法是,CS1237加卡尔曼滤波这套组合,非常适合作为个人进入高精度数据采集领域的第一块敲门砖。它的硬件简单到一天就能搭完,软件难度也集中在十几行滤波代码上,但你能从中理解传感器、ADC、数字滤波、标定这一整套信号链路的逻辑。后面哪怕去做应变式压力计、拉力计、液位传感器,这套方法论都是通用的。
最后再分享一个小技巧:如果你发现静止数据里偶尔有一个尖峰,别急着改卡尔曼参数,先在采样入口加一个最朴素的限幅滤波,比如相邻两次读数差超过50g就丢弃本次数据。这个“前端限幅加卡尔曼平滑”的组合,比单纯调Q和R省事得多,而且不容易出现余差。这个坑我踩过,写在这里帮你避开。