做称重和压力采集的项目做多了,你会发现一个很有意思的现象:便宜的电子秤方案清一色HX711,稍微讲究一点的工业仪表,最近两年越来越多人在用CS1237。一开始我也没把这颗国产24位ADC当回事,直到有一次项目要求5kg量程、分辨率做到0.1g以内,原来的HX711方案在温漂和噪声上死活压不下去,这才认真把CS1237的原理图设计、STM32 HAL库驱动和调试流程完整走了一遍。这套流程走完,我可以直接说:CS1237在10Hz输出速率下配合PGA=128,做到称重分辨率0.05g甚至更好,是完全可以实现的,而且成本比进口方案低一大截。
这篇文章就把我从画原理图到HAL库调试的全过程拆开来讲,包括为什么选它、原理图哪些地方容易埋雷、通信时序到底怎么回事、驱动代码怎么组织,以及我实测过程中踩过的各种坑。打算用CS1237做电子秤、拉力计、压力传感器采集,或者做毕业设计需要高精度ADC的同学,这篇应该能帮你少走不少弯路。
1. 为什么选CS1237:这颗国产ADC到底强在哪
1.1 从HX711到CS1237:一次称重项目的选型复盘
先说说项目背景。当时要做一款小量程高精度的数字秤,量程5kg,要求出厂校准后全量程误差不超过±1g,显示分辨率0.1g。HX711虽然便宜、例程满天飞,但实测下来有几个痛点:一是内部时钟温漂大,零点随温度变化明显;二是输出速率固定,想提速就得牺牲稳定性;三是它的模拟电源抑制比一般,开关电源供电时数据毛刺比较多。
CS1237进入视野是因为它的datasheet里写了一个让我很在意的特性——内置温度传感器和高精度振荡器,加上PGA可编程增益1/2/64/128,输出速率10Hz/40Hz/640Hz/1024Hz可配置。这意味着我可以把PGA拉到128倍,把5kg传感器满量程5mV的信号放大到接近ADC的满量程范围,再用10Hz低速采样换取高有效位数。这一套组合拳打下来,信噪比和温漂表现都比HX711好不少。
1.2 不看广告看疗效:CS1237的核心参数拆解
CS1237是一颗24位Δ-Σ型ADC,内置PGA、内部振荡器、内部温度传感器,还带一个简单的SPI兼容接口。供电范围2.35V到5.5V,这个电压范围设计得很友好,3.3V和5V系统都能直接用。
几个关键参数我列一下,方便你快速判断这颗芯片适不适合你的项目:
- 分辨率:24位,输出数据格式为二进制补码
- PGA增益:1、2、64、128四档,通过命令字配置
- 输出速率:10Hz、40Hz、640Hz、1024Hz四档
- 差分输入:AIN+和AIN-,适合桥式传感器
- 内置温度传感器:可以做温漂补偿
- 内置RC振荡器:不需要外部晶振,省两个电容
- 参考电压:支持外部VREF,也支持直接用AVDD做参考
实际使用中最重要的是理解一点:24位ADC不代表你每次读数都有24位有效精度。有效位数取决于输出速率、PGA增益和电路噪声。10Hz速率下噪声最低,有效位数能做到20位以上;1024Hz速率下有效位数会掉到16位左右。所以如果你的应用不是特别要求高速,尽量用低速档采集,再用软件滤波,这是最划算的提升精度手段。
1.3 项目需求确认:先定性能指标再画板子
画原理图之前,先把指标算清楚。以我的5kg称重项目为例:
传感器灵敏度1mV/V,激励电压5V,满量程输出就是5mV。PGA设为128后,ADC输入端看到的最大差分信号是5mV×128=640mV。CS1237的满量程输入范围是±VREF/PGA,VREF取AVDD=5V时,满量程约为±39.06mV(5V除以128)。这样算下来,传感器满量程5mV只占了ADC满量程的约12.8%,实际可用码值约为±107万,折算到5kg量程,理论分辨率接近0.005g。
注意这完全是理论值。实际噪声会让最低几位不停跳动,所以最终显示分辨率做到0.1g是合理的,再高就要靠滤波和屏蔽了。先把这个计算过程写在设计文档里,后面调软件的时候心里有数,不会为了一个根本不可能的0.001g分辨率折腾半天。
2. 原理图设计:高精度采集不是把引脚连上就行
2.1 引脚功能梳理与供电设计
CS1237的引脚不算多,典型封装下主要有AVDD、DVDD、VREF、AIN+、AIN-、SCLK、DIN、DOUT/DRDY、GND。画原理图时最容易犯的错就是模拟电源和数字电源不做区分,一根5V直接拉过去,结果数据噪声大得没法看。
我的做法是:AVDD和DVDD分别接一个0.1μF陶瓷电容加10μF电解电容退耦,电容尽量靠近芯片引脚。如果系统里模拟和数字部分共用一组电源,在AVDD入口处加一个磁珠或10Ω电阻隔离,避免数字开关噪声窜进模拟电源。VREF脚如果是用外部基准,退耦电容要选低ESR的X7R或者钽电容;如果用AVDD做参考,VREF脚可以直接和AVDD连在一起,但走线要短粗。
还有一个细节:CS1237的SCLK、DIN和DOUT/DRDY,建议各加一个10kΩ上拉电阻到DVDD。SCLK和DIN上拉是为了防止芯片上电瞬间引脚浮空导致误触发,DOUT上拉是为了让转换完成信号有确定的空闲电平。这三个电阻不贵,但能省掉很多上电时序的怪问题。
2.2 桥式传感器接口与比率测量
如果做称重,传感器基本都是惠斯通电桥。四根线分别是E+(激励正)、E-(激励负)、S+(信号正)、S-(信号负)。S+接AIN+,S-接AIN-,这个顺序别接反,接反了读出来是负值,校准的时候容易蒙圈。
更关键的是参考电压接法。最简单也最推荐的做法是比率测量:把传感器的激励电压E+经过电阻分压后接到VREF,或者直接把E+接到VREF。这样当激励电压因为电源波动而变化时,ADC的参考电压和传感器输出会同步变化,比值保持不变,测量结果不受激励源波动影响。这也是为什么很多电子秤方案里ADC的VREF直接引传感器的E+。
有一点要提醒:VREF的输入范围需要看手册,一般不要超过AVDD。如果传感器激励是10V而你ADC供电是5V,就必须分压,千万别直接把10V接到VREF引脚上。
2.3 PCB布局与BOM的避坑细节
PCB布局上,模拟信号走线要短,差分对要平行等长,尽量远离SCLK这类数字跳变信号。如果板子面积允许,在AIN+和AIN-之间并联一个100pF到1nF的小电容,可以有效滤除高频干扰,代价是响应速度变慢一点点,对静态称重完全够用。
地线处理上,模拟地和数字地单点连接,不要让数字电流穿过模拟地平面。如果是两层板,至少保证芯片底下有一块完整的地铜皮,不要被走线切碎。
BOM方面几个容易被忽视的点:
- 退耦电容选X7R或C0G材质,不要用Y5V,温漂太大
- 上拉电阻精度无所谓,10kΩ就行,但电源去耦电容的ESR要低
- 如果用了外部基准,基准芯片的噪声指标直接影响ADC性能,选ADR420这类低噪声基准,别用三端稳压器凑合
3. 通信协议解析:SPI之外的几种讲究
3.1 DOUT/DRDY引脚的工作机制
CS1237的通信接口长得像SPI,但实际用起来和标准SPI有区别。它有三根线:SCLK、DIN、DOUT/DRDY。其中DOUT/DRDY是一根双功能引脚,平时作为转换完成信号输出,读取数据时作为数据输出口。
上电后芯片会自动做一次内部校准,校准期间DOUT保持高电平,校准完成后DOUT拉低,表示可以读取数据了。之后每次转换完成,DOUT也会拉低一次,主机看到这个下降沿就知道数据准备好了。这个机制和HX711很相似,用起来比较顺手。
实际操作中我习惯把CS1237的CS引脚接到MCU的GPIO,每次通信前手动拉低CS,通信完拉高。CS低电平期间,SCLK的每个下降沿,芯片会把当前转换结果从最高位开始逐位移到DOUT上;发送命令字时,DIN在SCLK的上升沿被采样。理解这个“下降沿输出数据、上升沿采样命令”的方向,后面写驱动就不会乱。
3.2 命令字结构:PGA、速率和通道怎么配
命令字是8位,主机通过DIN引脚逐位发送。我项目里验证过的常用配置有这么几个:
- 0x14:PGA=128,输出速率10Hz,用于高精度称重
- 0x18:PGA=128,输出速率40Hz,用于动态稍快的场合
- 0x1C:PGA=128,输出速率640Hz,用于峰值捕捉或粗采
网上很多CS1237例程初始化直接写CS1237_WriteCmd(0x14),这个值基本就是圈子里的默认配置。如果你想自己配,需要对着数据手册的寄存器位定义填,重点看三块:增益位段、速率位段、通道/温度传感器选择位段。特别注意命令字最高位是读写标志位,写命令时必须清零,否则芯片会把后续数据当寄存器读取,时序就乱了。
我踩过的一个坑是:刚上电就发命令,结果芯片还在内部校准阶段,命令没接收进去。后来我在初始化流程里加了固定延时,上电后等100ms再发第一条命令,这个问题就消失了。实际上可以更聪明一点,初始化时先轮询DOUT,等它拉低再发命令,这样不管芯片上电校准要多久都能正确同步。
3.3 为什么我坚持用GPIO模拟而不是硬件SPI
CS1237虽然长得像SPI,但用标准SPI外设驱动起来很别扭。原因有三个:第一,24位数据不是字节对齐,标准SPI按8位一传,要拼三次,处理起来麻烦;第二,读数据阶段和写命令阶段是三线方向切换的,硬件SPI在半双工模式下控制NSS和方向比较折腾;第三,CS1237的时序在某些边沿定义上跟标准SPI模式不严格一致,硬件SPI的极性和相位配置容易踩坑。
所以我最终的方案是用GPIO模拟时序,SCLK、DIN、DOUT分别接三个普通GPIO,CS单独控制。模拟SPI的频率我控制在几百kHz左右,完全满足40Hz以内的数据速率需求,而且代码移植性好,换一颗MCU只需要改引脚宏。对于这种低速高精度ADC,GPIO模拟是性价比最高的方案。
4. STM32 HAL库驱动实现:从零写一个能复用的驱动
4.1 CubeMX工程配置与引脚分配
驱动代码我基于STM32 HAL库写的,工程用CubeMX初始化。引脚分配上,我选用PA0做CS、PA1做SCLK、PA2做DIN、PA3做DOUT,这样四个引脚挨在一起,检查逻辑分析仪波形的时候方便。
CubeMX里配置如下:
- CS:GPIO输出,推挽,初始电平高
- SCLK:GPIO输出,推挽,初始电平低
- DIN:GPIO输出,推挽,初始电平低
- DOUT:GPIO输入,浮空或上拉(板子上已有上拉电阻的话选浮空即可)
时钟树方面,让APB2外设时钟默认就行,因为我们是GPIO模拟,不涉及SPI外设时钟。但要注意一点:如果CubeMX生成的工程把JTAG引脚占了,下载调试会受影响,PA15/PB3/PB4这类引脚用的时候先确认调试口配置。
4.2 驱动架构:把时序和业务逻辑分开
我写驱动有个习惯:底层引脚操作全部用宏封装,上层代码只调接口函数。这样板子换引脚了,只改头文件里的宏,逻辑代码一行不动。
驱动文件分成cs1237.h和cs1237.c两个文件。头文件里放引脚宏定义和接口函数声明,源文件里放时序实现。整个驱动对外只暴露三个接口:
void CS1237_Init(void); int32_t CS1237_ReadRaw(void); uint32_t CS1237_ReadWeight(float *weight_kg);其中ReadWeight内部完成零点校准、增益换算和滤波,返回的是处理后重量值。这样业务代码(显示、上传、报警逻辑)完全不用关心ADC时序,耦合度低,后面想换ADC芯片也方便。
引脚宏定义大致是这样的:
#define CS1237_CS_PORT GPIOA #define CS1237_CS_PIN GPIO_PIN_0 #define CS1237_SCLK_PORT GPIOA #define CS1237_SCLK_PIN GPIO_PIN_1 #define CS1237_DIN_PORT GPIOA #define CS1237_DIN_PIN GPIO_PIN_2 #define CS1237_DOUT_PORT GPIOA #define CS1237_DOUT_PIN GPIO_PIN_3 #define CS1237_CS_LOW() HAL_GPIO_WritePin(CS1237_CS_PORT, CS1237_CS_PIN, GPIO_PIN_RESET) #define CS1237_CS_HIGH() HAL_GPIO_WritePin(CS1237_CS_PORT, CS1237_CS_PIN, GPIO_PIN_SET) #define CS1237_SCLK_LOW() HAL_GPIO_WritePin(CS1237_SCLK_PORT, CS1237_SCLK_PIN, GPIO_PIN_RESET) #define CS1237_SCLK_HIGH() HAL_GPIO_WritePin(CS1237_SCLK_PORT, CS1237_SCLK_PIN, GPIO_PIN_SET) #define CS1237_DIN_LOW() HAL_GPIO_WritePin(CS1237_DIN_PORT, CS1237_DIN_PIN, GPIO_PIN_RESET) #define CS1237_DIN_HIGH() HAL_GPIO_WritePin(CS1237_DIN_PORT, CS1237_DIN_PIN, GPIO_PIN_SET) #define CS1237_DOUT_READ() HAL_GPIO_ReadPin(CS1237_DOUT_PORT, CS1237_DOUT_PIN)4.3 读取、滤波与工程单位换算
写命令的时序实现,核心就是8个SCLK脉冲,MSB先行,DIN在SCLK上升沿前准备好:
void CS1237_WriteCmd(uint8_t cmd) { CS1237_CS_LOW(); Delay_us(2); for (int i = 0; i < 8; i++) { CS1237_SCLK_LOW(); if (cmd & (0x80 >> i)) { CS1237_DIN_HIGH(); } else { CS1237_DIN_LOW(); } Delay_us(1); CS1237_SCLK_HIGH(); Delay_us(1); } CS1237_CS_HIGH(); Delay_us(2); }读数据的实现,关键是等待DOUT拉低,然后24个SCLK脉冲把数据一位位移出来,最后做符号扩展:
int32_t CS1237_ReadRaw(void) { int32_t value = 0; uint32_t timeout = 0; CS1237_CS_LOW(); while (CS1237_DOUT_READ() == GPIO_PIN_SET) { if (++timeout > 500000) { CS1237_CS_HIGH(); return INT32_MIN; // 超时,返回异常值 } } for (int i = 0; i < 24; i++) { CS1237_SCLK_HIGH(); Delay_us(1); value = value << 1; if (CS1237_DOUT_READ() == GPIO_PIN_SET) { value |= 0x01; } CS1237_SCLK_LOW(); Delay_us(1); } CS1237_CS_HIGH(); if (value & 0x800000) { value |= ~0xFFFFFF; // 24位二进制补码符号扩展 } return value; }注意读数据时我在SCLK拉高后读取DOUT。如果换了别的例程发现数据全乱,很可能是采样沿不对,可以改成SCLK下降沿之前读一次试试,具体以手册时序图为准。
初始化函数也很简单:
void CS1237_Init(void) { CS1237_CS_HIGH(); CS1237_SCLK_LOW(); Delay_ms(100); // 等待芯片上电内部校准完成 CS1237_WriteCmd(0x14); // PGA=128,输出速率10Hz Delay_ms(100); }4.4 数据滤波:C语言实现的几种实用方案
CS1237在10Hz速率下读数已经很稳了,但任何ADC都有噪声,尤其是工业现场。我做过的项目里最实用的滤波算法有三种:
第一种是滑动平均滤波,适合静态称重:
#define FILTER_LEN 8 int32_t filter_buf[FILTER_LEN]; uint8_t filter_index = 0; int64_t filter_sum = 0; int32_t CS1237_SlideAverage(int32_t new_value) { filter_sum -= filter_buf[filter_index]; filter_buf[filter_index] = new_value; filter_sum += new_value; filter_index = (filter_index + 1) % FILTER_LEN; return (int32_t)(filter_sum / FILTER_LEN); }第二种是中值滤波,能剔除明显的毛刺,适合有振动干扰的场合:
int32_t CS1237_MiddleValue(int32_t *buf, uint8_t len) { // 简单排序后取中间值,len一般取5~9 }第三种是限幅平均滤波,先判断当前值与上一次值偏差是否超过阈值,超过就丢弃,然后做平均。这种方式对突发干扰的抑制效果最好,但需要根据实际信号变化率调阈值。
我个人最常用的是滑动平均,原因很简单:程序简单、执行开销小、对10Hz速率的称重信号来说完全没有延迟感,还能有效压低峰峰值跳动。如果你发现滤波后重量值变“粘”,响应慢,那就把滤波窗口从8降到4,或者改用加权滑动平均,权重偏向新数据。
关于工程单位换算,在我的项目中是这样处理的。先找一段空秤状态读100次取平均作为零点,然后把标准砝码放上去,读到的原始值减去零点就是增益斜率,重量就等于(当前原始值-零点)/增益斜率。这个标定过程我会在下一节详细讲。
5. 实测调试:读数异常与精度问题的排查手册
5.1 现象一:读回来全是0或者0xFFFFFF
这个现象我在调第一版板子的时候遇到过,排查了半个下午。先说结论,这种问题九成出在时序或者接线。
首先用万用表确认CS1237供电正常,AVDD和DVDD都有电压,GND连通。然后检查CS、SCLK、DIN、DOUT四根线的GPIO配置,特别是DOUT,如果CubeMX里配置成了推挽输出,那读取引脚电平永远读到的是自己输出的电平,数据全是0或者全是1。
排除了硬件问题再看程序。读取超时函数返回的INT32_MIN在打印时可能显示成异常大数,检查是不是等待DOUT拉低超时了,超时常见原因是SCLK频率太高,芯片跟不上。CS1237不是高速器件,我模拟SPI的SCLK半周期都留了至少1μs,跑得很稳。如果你用硬件SPI拉到几MHz,大概率读不到数据,降速就好了。
至于0xFFFFFF,那是典型的24位二进制补码中的-1。出现-1基本是芯片在CS拉低后没有正确输出采样值,或者SCLK边沿采反了。可以试着把读取数据时的采样点从SCLK高电平改为SCLK低电平,两个方向都试一下,能读回来就说明时序对齐了。
5.2 现象二:静止状态下数据乱跳
静止放置时数据跳动能到几十甚至上百个码,这种问题先别急着怀疑ADC芯片,大概率是前端电路和PCB的事。
第一个检查项是电源。用示波器看AVDD引脚的纹波,如果纹波超过10mV,说明退耦没做好,或者模拟电源和数字电源没分开。我遇到过一块板子,把DIN引脚旁边走了一根PWM风扇控制线,结果风扇一转ADC读数就跳,后来在原理图上把PWM线绕开,问题立刻消失。
第二个检查项是参考电压。VREF不稳,读数必然跳。用外部基准的话查基准芯片输出有没有自激;用AVDD做参考的话,AVDD纹波会直接折算到输出码值里,所以AVDD的干净程度直接决定系统精度。
第三个检查项是PGA设置。增益越高,输入端的噪声被放得越大。如果传感器引线比较长,又没有接滤波电容,高频干扰会直接进来。我在AIN+和AIN-之间并联了一个100pF电容,100Hz以上的噪声衰减明显,静止跳动从十几个码降到了两三个码。
最后一个可能性是软件问题,比如滤波函数写错了,滤波窗口没对齐,或者滤波数组有符号数右移导致符号位扩展错误。排查时先把滤波关掉,直接打印原始码值,如果原始码值稳定,那就是滤波算法的问题;如果原始码值本身就在跳,才往前走硬件排查。
5.3 现象三:测量值整体偏大或偏小
校准问题。很多人一上来就按网上例程的固定系数去算重量,不标定就用,结果当然不准。正确的做法是二点标定:
- 空秤状态下读取100次原始值取平均,得到零点offset
- 放一个已知重量的标准砝码(比如1kg),再次读取100次取平均,得到满点值
- 增益系数slope = (满点值 - 零点)/ 砝码重量
- 实际重量 = (当前值 - 零点)/ slope
这里有个关键细节:校准时的PGA设置和运行时的PGA设置必须完全一致,中途改了增益,重新标定,否则线性关系就变了。另外如果做了通道切换,比如用了温度传感器再切回普通通道,也需要重新校准一次。
我还在项目里加了校准参数存储功能,用Flash模拟EEPROM保存offset和slope,出厂标定一次,后面断电重启直接读取,不用每次开机都标定。这个功能虽然简单,但客户拿到手就能用,体验完全不一样。
5.4 从信噪比角度理解采样周期与有效位数
调试过程中我认真研究过输出速率对精度的影响,这里多说一句。CS1237的24位输出是包含噪声的,数据手册里有个有效分辨率(ENOB)的概念,指的就是信号与噪声之比折算出的有效位数。输出速率越低,内部数字滤波器对带外噪声抑制越强,ENOB越高。
10Hz速率下,CS1237的有效分辨率通常能做到20位以上,对应最小分辨信号在微伏级别。40Hz时候掉到19位,640Hz时候大概16位多点,1024Hz基本就15位了。所以如果你的应用不要求快速响应,老老实实用10Hz,这是性价比最高的方式。
如果非要高速采样又想要高精度,就得在软件上花功夫。比如过采样技术:用1024Hz采集,每16次平均一下等效降到64Hz,加上随机噪声平均掉一部分,实际效果能接近40Hz档的噪声水平。这个方法在数据量足够的时候很有效,但注意它只能改善白噪声,对确定性干扰无能为力。
6. 关于HAL库调试环境的一些补充
6.1 HAL库文件结构对驱动开发的影响
用STM32 HAL库开发,工程里会看到Core/Src、Core/Inc、Drivers等一系列目录。CS1237驱动代码我建议单独建一个App/CS1237目录,或者直接放在Core/Src里,不要和main.c混在一起。我在调项目的时候吃过这个亏:所有代码都堆在main.c里,后期想改滤波参数,得在几百行代码里翻半天。
正确做法是把驱动封装成独立模块,main.c里只保留系统初始化和业务逻辑调用,ADC相关细节全部在cs1237.c里实现。这样即使CubeMX重新生成代码,只要不勾选覆盖用户代码,驱动文件完全不受影响。
6.2 微秒延时的坑与DWT方案
HAL库自带的HAL_Delay只支持毫秒级延时,而模拟SPI的时序需要微秒级,所以一定要自己实现微秒延时。最简单的方案是空循环,但空循环受编译器优化影响很大,换一个优化等级延时时间就变了,不推荐。
推荐用DWT实现微秒延时,DWT是Cortex-M内核自带的调试监视单元,不用额外占用定时器。初始化代码如下:
static void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } static void Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }这个方案的好处是精度高、不依赖定时器、移植方便。我在好几个项目里都是这么干的。
6.3 常见开发环境问题
调CS1237驱动的时候还可能遇到一些环境层面的问题,虽然不是ADC本身的锅,但很影响心态。比如开发板插到电脑上ST-Link无法识别,大概率是驱动没装好或者USB线只有供电没有数据线,换根线试试,再不行就重装驱动。比如Keil里编译报错找不到芯片包,确认Pack Installer里STM32F1系列的芯片支持包装好了没有。
还有一个小经验:用逻辑分析仪抓CS1237的SCLK、DIN、DOUT波形,对调试极有帮助。有一次我写完驱动怎么都读不对,抓波形发现写命令时DIN的电平和命令字完全对不上,一查是GPIO配置成了开漏模式又没有外部上拉,电平根本拉不上去。这种问题没有示波器或逻辑分析仪,纯靠猜能猜一天。
我个人在调试中最常犯的错是改完代码忘了重新生成工程,结果编译的还是旧代码。后来养成习惯,每次CubeMX改动后都重新生成一次,编译前对比一下修改时间,这个问题基本就杜绝了。CS1237这颗芯片整体调试难度不算高,最难的部分其实是把手册里那几张时序图吃透。一旦把上电校准、命令配置、数据读取这三个流程跑通,后面做温度传感器通道、做多量程切换基本都是水到渠成的事。最后再分享一个小技巧:如果你手头有现成的电子秤模块,先别急着画板子,把CS1237模块用杜邦线接到STM32开发板上,用同样一套HAL库驱动代码先把时序跑通、读数稳定,再回头画PCB。先把软件和硬件风险分开验证,成功率会高很多。