☰
基于STM32F103与CS1237的高精度应变数据采集实战指南
2026/9/27 1:19:13 网站建设 项目流程

做称重和应变采集的朋友对HX711肯定不陌生,但如果你试过CS1237这颗国产24位ADC,会发现它在不少场景下比HX711更顺手——SOP-8小封装、两线接口、内置可编程增益放大器和温度传感器,配合STM32F103这类入门级MCU,几十块成本就能搭出一套精度不错的数据采集系统。这篇内容不打算讲太多理论,就从我实际做的一个基于STM32F103和CS1237的应变测量小模块说起,把硬件连接、时序理解、代码实现到调试排错的整个流程完整走一遍,适合正在做称重、张力、气压或者通用高精度模拟量采集的朋友参考。

1. 为什么是CS1237而不是STM32内部ADC或HX711

1.1 被内部12位ADC逼出来的需求

STM32F103内部自带的ADC是12位分辨率,满量程4096个码,在3.3V参考电压下每个LSB大约对应0.8mV。检测电池电压、电位器分压、光敏电阻这类大幅度缓变信号,它绰绰有余。但如果接的是应变式称重传感器,情况就完全不同了:传感器满量程输出通常只有1mV/V到2mV/V,5V激励下满量程也不过5到10mV,分配到12位ADC上,整个量程只有几十到几百个码,稍微有点噪声读数就跳得没法看。我当时做的是一个小量程拉力计,要求能分辨到1克,传感器满量程5kg,也就是满量程内总共5000个目标分度,用内部ADC无论如何都达不到这个精度。所以只能外挂高分辨率ADC。

1.2 CS1237与HX711的差异对比

挑选过程中绕不开的是HX711,两者都是24位Σ-Δ ADC,也都采用两线接口,网上资料一抓一大把。我把关键参数整理成表格对比了一下:

项目CS1237HX711
分辨率24位24位
接口SCLK + DOUT 两线SCLK + DOUT 两线
PGA增益1/2/4/8/16/32/64/128 可配置32/64/128 固定
内部寄存器有,可配置通道和增益无,靠SCLK脉冲数选择
内置温度传感器支持不支持
输出速率10Hz / 40Hz / 640Hz / 1.28kHz(视批次)10Hz / 80Hz
封装SOP-8SOP-16
差分输入通道1路2路(A/B)

HX711的优势是有两个差分输入通道,适合同时接两个传感器。但它的增益只能选固定的32、64、128,扩展性和灵活性不如CS1237。CS1237的增益从1倍到128倍连续可编程,意味着它不只是给称重传感器用的,还可以直连一些输出幅度稍大的传感器,比如压力变送器、电流采样电阻,甚至直接采集热电偶放大后的信号。另外CS1237内置温度传感器,这个功能在做温漂补偿时特别有用,省掉了一颗外置温度芯片。

1.3 我最终选它的三个理由

第一,增益范围宽,适配的传感器类型多;第二,SOP-8封装非常小,在寸土寸金的PCB上能省不少面积;第三,内置温度传感器,后期做温漂补偿有数据可依。还有一个现实原因:同等采购量下CS1237单价通常比HX711便宜一两毛钱,对于批量产品来说,BOM成本就是这样一点一点抠出来的。如果你只需要做一个最简单的电子秤,HX711完全够用;但如果你想做的是一个精度要求更高、可能需要做温度补偿、未来还可能换不同量程传感器的通用采集模块,CS1237明显更合适。

2. 硬件连接:五根线背后的电源与信号完整性问题

2.1 供电、参考电压和滤波电容的处理

CS1237的供电范围是2.7V到5.5V,AVDD和DVDD在芯片内部是分开的引脚。我先说的是电源,因为这个环节处理不好,后面所有精度都是空谈。如果AVDD用5V供电,传感器的激励电压也最好从5V取,这样传感器的共模输出范围更宽;如果用3.3V供电,那么传感器满量程输出会成比例降低,对ADC的噪声要求更高。为了保证精度,我习惯在AVDD和GND之间放一颗10uF的钽电容和一颗100nF的陶瓷电容并联,DVDD同样来一份,两者在地端汇合,尽量用星形接地。参考电压方面,CS1237的REFP和REFN引脚可以直接接VCC和GND,低成本场景这样最省事,但代价是电源纹波会直接注入参考。如果项目对精度要求比较高,建议在REFP前面加一颗如REF192之类的精密基准源,实测下来稳定性提升非常明显。

2.2 与STM32F103的引脚连接

CS1237对外只需要两根信号线:SCLK和DOUT。SCLK由MCU产生,作为时钟输入;DOUT是双向引脚,读数据阶段输出转换结果,写配置阶段接收配置字。我用引脚分配如下:

CS1237引脚功能STM32F103引脚
VCC电源3.3V / 5V
GND地GND
SCLK时钟输入PA5
DOUT双向数据PA6

PA5和PA6在F103最小系统板上都是普通GPIO,没有复用冲突。需要注意尽量别用PA13、PA14、PA15、PB3、PB4这些引脚,因为它们默认是SWD调试口或JTAG口,如果用它们做SCLK和DOUT,会导致下载器连不上芯片,或者程序跑到一半被调试复位干扰。我之前在STM32F103C8T6最小系统板上就是先用PA15和PB3测试,结果Keil能下载但程序一运行就卡死,最后发现是JTAG引脚冲突,换到PA5和PA6才解决。

2.3 上电快速自检流程

硬件焊好后,不要急着写代码,先做三个简单的检查能省下大量调试时间。

先用万用表测量VCC和GND之间的电压值,确认芯片供电正常且极性正确。接着断电,用万用表二极管档检查一下VCC和GND之间有没有短路,防止焊油或毛刺引起的问题。最后给系统上电,不运行任何通信代码时,用示波器或万用表观察SCLK引脚应该是稳定的低电平,DOUT引脚应该是高电平。这里有一个非常重要的判断依据:CS1237在转换完成后会把DOUT拉低一段时间,但上电后尚未通信时,芯片默认处于配置模式,DOUT应该保持高。如果上电后DOUT一直是低,大概率是芯片没有正确复位,或者SCLK引脚有持续的干扰脉冲。这个自检只要三十秒,却能提前排除六成以上的硬件问题。

3. 读懂CS1237的时序图:25个SCLK和一个8位配置字

3.1 数据读取帧的完整过程

CS1237的通信看起来像SPI,但又不完全是标准SPI。它的数据读取帧由25个SCLK脉冲组成:前24个脉冲用于移出24位ADC转换结果,第25个脉冲移出的是一个状态位。每次转换完成后,DOUT会从高电平变为低电平,这个下降沿就是给MCU的"数据准备好了"信号。MCU检测到DOUT变低之后,开始产生SCLK。在每个SCLK的上升沿之后,DOUT上依次输出当前数据的最高位到最低位,也就是MSB先行。第25个SCLK所移出的状态位含义是:0表示数据已准备好且有效,1表示数据尚未就绪。实际读取时我一般会把第25位也读出来,如果读到1就丢弃当前数据,重新进入等待状态,这样可以在一定程度上避免读到新旧数据交界处的无效值。

3.2 配置寄存器怎么写入DOUT

读数据的过程理解之后,更关键的是写配置。CS1237上电后芯片会要求MCU先写入一个8位配置字,寄存器的字段包括增益选择、输出速率、工作通道等。具体来说,写入的流程是在完成25个SCLK的读取帧之后,SCLK继续产生时钟脉冲,同时DOUT由芯片的输出模式切换为输入模式,MCU在每一个SCLK周期内把配置字的最高位先放到DOUT上,芯片在内部锁存,依次移入8位数据。注意这里DOUT的方向发生了切换:读数据阶段MCU是输入,写配置阶段MCU是输出。如果不做方向切换,配置字根本写不进去,这是新手最容易卡住的地方。

我用的CS1237默认配置是内部增益128倍,10Hz输出速率,单端输入通道1。对应的配置字在不同批次芯片上略有差异,但常见的手册示例值是0x0C。如果你手头的配置字和我不一样,务必以你买到的芯片手册为准。写配置时不要只写一次,我推荐在上电后连续写入两次,第一次作为唤醒,第二次作为正式配置,有效减少上电瞬间时序不稳定导致的首帧数据异常。

3.3 用逻辑分析仪验证时序

没有逻辑分析仪的调试都是盲人摸象,我说的不是玩笑。CS1237的工作频率不高,SCLK脉冲甚至可以放到1MHz左右,常见的24MHz采样率的逻辑分析仪完全够用。接线很简单:逻辑分析仪的CH1接STM32F103的PA5,CH2接PA6,GND接板子的GND。抓一段时序之后,应该能在软件上看到这样几个特征:DOUT先由高变低,随后出现一组25个方波脉冲,再接着是8个方波脉冲。如果在25个SCLK之前看到DOUT根本没有拉低,说明芯片没有完成转换,通常要查SCLK和DOUT是否接反,或者供电是否正常。我曾经遇到过一种情况:逻辑分析仪上看着波形没问题,但代码就是读不到正确数据,排查了很久才发现是GPIO初始化的速度等级配错了,SCLK上升沿过缓导致芯片采样出错,把GPIO速度从2MHz改到50MHz就恢复正常。

4. 标准库驱动:从零手写读写函数

4.1 引脚初始化与微秒延时准备

标准库版本的代码逻辑最直观,适合完全理解底层的朋友。先做引脚初始化,SCLK配成通用推挽输出,DOUT先配成浮空输入,等写配置时再切换方向。初始化代码如下:

#include "stm32f10x.h" #include "delay.h" // 需自行提供微秒级延时函数 #define CS1237_SCLK_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_5) #define CS1237_SCLK_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_5) #define CS1237_DOUT_READ() GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6) #define CS1237_DOUT_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_6) #define CS1237_DOUT_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_6) void CS1237_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 先初始化 SCLK 为推挽输出 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // DOUT 初始化为浮空输入,等待芯片输出 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_Init(GPIOA, &GPIO_InitStructure); }

延时函数在CS1237通信里非常重要,不能省。SCLK的高电平和低电平至少要维持几百纳秒,我一般直接延时1us左右,这在72MHz主频下用简单的空循环就能实现。注意MDK的优化等级如果开得太高,空循环可能被编译器优化掉,建议延时函数用volatile变量实现,或者直接用SysTick延时。

4.2 读取24位原始数据与超时保护

接下来是读取函数。核心逻辑是等待DOUT变低,然后依次产生25个SCLK,边沿读取数据。很多人写的代码里都有一个隐患:while(CS1237_DOUT_READ());,这个循环如果DOUT一直不拉低,程序会永远卡死在这里。在实际调试中,芯片虚焊、引脚配置错误、供电异常都会导致DOUT拉不低。我强烈建议加上超时机制,例如:

uint32_t CS1237_ReadRaw(void) { uint32_t raw = 0; uint32_t timeout = 0xFFFFF; uint8_t i; // 等待DOUT拉低,表示转换完成,带超时保护 while (CS1237_DOUT_READ()) { if (--timeout == 0) { return 0xFFFFFFFF; // 超时返回值 } } for (i = 0; i < 25; i++) { CS1237_SCLK_HIGH(); delay_us(1); if (i < 24) { raw <<= 1; if (CS1237_DOUT_READ()) { raw |= 1; } } CS1237_SCLK_LOW(); delay_us(1); } return raw; }

读取顺序是从第0位到第23位,每读一位前把上一轮的结果左移一位,这样最终收集到的是MSB在前、LSB在后的24位二进制补码数据。每次读完第25个SCLK之后,DOUT会恢复为高电平,等待下一次转换完成。

4.3 配置寄存器:切换通道、增益和采样率

配置字写入需要把DOUT切换为推挽输出模式,然后逐位移入8位数据。这里的细节是位顺序:芯片要求从最高位开始,也就是MSB first。代码实现如下:

void CS1237_SetConfig(uint8_t config) { GPIO_InitTypeDef GPIO_InitStructure; uint8_t i; // DOUT 切换为推挽输出 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 按位发送配置字,MSB first for (i = 0; i < 8; i++) { if (config & 0x80) { CS1237_DOUT_HIGH(); } else { CS1237_DOUT_LOW(); } config <<= 1; CS1237_SCLK_HIGH(); delay_us(1); CS1237_SCLK_LOW(); delay_us(1); } // DOUT 恢复为浮空输入,准备读取数据 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_Init(GPIOA, &GPIO_InitStructure); }

上电后的初始化顺序建议是:先延时100ms以上,等待芯片电源稳定,然后写两次配置字,再延时一段时间,最后才开始循环读数据。这个顺序看似简单,但跳过任何一步都会导致第一帧数据异常。配置字不同位段控制不同的功能,比如增益和采样率,具体每一位的含义同样要查芯片手册确认,不同批次之间可能有位序差异。

4.4 原始数据的补码转换

读到的24位数据是二进制补码格式。正满量程时接近0x7FFFFF,负满量程时接近0x800000。如果要换算成有符号数,最简洁的做法是判断最高位:如果data & 0x800000为真,说明是负数,此时data减去0x1000000即可得到真正的有符号值。也可以直接用32位有符号变量接收24位数据,然后左移8位再右移8位完成符号扩展,效果一样。我在实际代码里用的是第一种方法,逻辑简单,别人接手也容易看懂:

int32_t CS1237_GetSignedValue(uint32_t raw) { if (raw == 0xFFFFFFFF) { return 0; // 超时或者无效数据 } if (raw & 0x800000) { return (int32_t)(raw - 0x1000000); } else { return (int32_t)raw; } }

转换后的有符号值,配合PGA增益和参考电压,就能换算成实际电压:电压 = 原始码 / 8388608 * Vref / 增益。这个公式留到后面的标定环节再展开。注意读取的原始值不是真实物理电压,中间还隔着参考电压和增益这两个变量,很多初学者直接把原始值当电压用,结果在更换增益后莫名其妙发现读数差了好几倍。

5. HAL库版本:CubeMX工程化的配置路径

5.1 CubeMX中的引脚和时钟配置

工程化的项目现在基本都用CubeMX生成初始化代码,HAL库和标准库的差异主要在于GPIO配置和读写函数。在CubeMX里配置PA5为GPIO_Output,PA6为GPIO_Input,同时把PA6的上拉方式选为Pull-up。为什么要在CubeMX里给PA6配置上拉?因为DOUT在部分模块上是开漏或者悬空状态,如果MCU引脚没有上拉,空闲时DOUT电平不确定,可能直接导致等待DOUT拉低的循环提前退出或一直进不去。PA5的输出速度建议选High,也就是50MHz那一档,保证SCLK边沿足够陡峭。

时钟树部分用默认的HSE 8MHz外部晶振,PLL倍频到72MHz即可,ADC和定时器都用不到,所以不用额外处理。CubeMX生成的main函数里,调用我们自己的CS1237_Init()之前,确保SystemClock_Config已经执行。这里有个容易忽视的问题:CubeMX生成代码时会默认把所有未使用的GPIO配置为模拟输入,如果PA5和PA6在CubeMX里没有被显式配置,生成的代码会把它们设成Analog模式,通信时序自然就完全不正常了。碰到"代码看着没问题硬件也正常但就是读不到数据"的情况,先检查一下CubeMX的Pinout页面。

5.2 HAL库驱动代码的适配

HAL库版本里的寄存器宏定义要换成HAL函数,核心逻辑不变。我的习惯是把CS1237相关函数封装成一个独立模块cs1237.c和cs1237.h,对外只暴露三个接口:初始化、读原始值、写配置。这样上层应用不需要关心引脚细节,日后换芯片平台时只需要改这个模块。

#include "cs1237.h" void CS1237_SCLK_HIGH(void) { HAL_GPIO_WritePin(CS1237_SCLK_GPIO_Port, CS1237_SCLK_Pin, GPIO_PIN_SET); } void CS1237_SCLK_LOW(void) { HAL_GPIO_WritePin(CS1237_SCLK_GPIO_Port, CS1237_SCLK_Pin, GPIO_PIN_RESET); } uint8_t CS1237_DOUT_READ(void) { return HAL_GPIO_ReadPin(CS1237_DOUT_GPIO_Port, CS1237_DOUT_Pin) == GPIO_PIN_SET ? 1 : 0; }

在CubeMX生成的工程里,把以上的宏定义换成HAL函数调用后,读写函数主体可以直接复用标准库版本。如果对时序比较敏感,也可以用寄存器方式加快IO速度,比如直接操作GPIOA->ODR和GPIOA->IDR,只要注意不要破坏CubeMX配置的其他引脚即可。我看过不少网友用HAL库版本说CS1237读取不稳定,实测下来多数不是HAL函数本身的问题,而是CubeMX引脚配置或延时精度的问题。

5.3 硬件SPI+DMA读取CS1237到底行不行

还是那句话,遇到"CubeMX + SPI + DMA + ADC"这些热词组合时,很多帖子把CS1237也拉了进来,但我的实际结论是:CS1237不适合用硬件SPI+DMA读取。原因有三点:首先,CS1237的DOUT是双向引脚,读数据阶段和写配置阶段方向不同,SPI的MISO和MOSI是分开的,用硬件SPI需要把MOSI外接DOUT,配置时必须手动切换引脚模式;其次,CS1237的数据帧是25位加8位配置字,不是8位或16位的整数倍,帧长度对不上。DMA是按帧长度搬运数据的,25位这种非标长度会让DMA配置变得极其别扭;最后,SCLK和DOUT之间的相位关系与标准SPI模式差一点,即使强行配置成Mode 0或Mode 3,都可能存在采样沿不匹配的问题。我在一次试验中确实用硬件SPI读出了数据,但代码里横七竖八都是特殊处理,稳定性远不如GPIO模拟。CS1237的时钟频率本身不高,用GPIO模拟并不会消耗多少CPU资源,所以我的建议很简单:别在这个芯片上折腾SPI+DMA。

6. 调试图:数据跳变、读取卡死、零点漂移

6.1 数据跳变:先怀疑电源和地线

我做拉力计调试的第一天,CS1237读出的数据在几百个码之间疯狂跳动,看起来就像随机数。当时第一个怀疑对象是代码,反复检查时序也没有问题。后来用示波器看CS1237的VCC引脚,才发现纹波高达接近100mV,电源来自电脑USB的5V经过AMS1117降压到3.3V,在带动传感器和模块时已经力不从心。解决措施有三步:把DC-DC电源换成了线性电源,在CS1237的VCC引脚组并了一个100uF电解电容和一个100nF陶瓷电容,同时把传感器和ADC之间的杜邦线全部换成了双绞屏蔽线,一端接地。这三步做完,跳动幅度从几百个码降到了十几个码。如果你也遇到类似情况,先别急着优化代码,把示波器探头戳到芯片电源脚上看一看,往往比调试半天软件省力得多。

6.2 读取卡死的两种常见原因

程序卡死在等待DOUT变低的循环里,是我收到提问最多的问题。第一个原因前面提过,DOUT被初始化成了模拟输入或者引脚方向不对,导致读不到电平变化。我见过有人在CubeMX里为了省电把所有GPIO初始化成Analog模式,DOUT引脚也在其中,后果就是读上来的电平永远不是期望值。第二个原因是芯片进入了一种半睡半醒的状态,比如上电后没有正确配置就立刻读数据,芯片不产生转换完成信号。这种情况的修复办法是上电后延时100ms,然后完整写两次配置字,每次写完都需要把DOUT方向切回输入。第三个比较少见但很坑,DOUT引脚碰巧被其他外设占用,例如I2C的SCL或SPI的NSS,外设在后台产生了电平变化干扰CS1237的时序。排查卡死问题时,我建议在等待循环里加一个串口打印或者一个翻转电平输出,如果走到循环说明DOUT电平正常,如果卡住就知道了,然后用逻辑分析仪看DOUT波形,比盲猜效率高得多。

6.3 零点漂移与PGA组合的关系

零点漂移表现为刚上电时读数缓慢变化,或者温度变化后零点位置移动。CS1237在高增益档位下,比如PGA=128,任何微小的共模电压变化都会被放大到很夸张的程度。我做了个简单实验:同样一颗芯片,增益从1倍切到128倍,短时间漂移量从不到10个码变成了三四百个码。而且芯片上电后存在一个短时间的自热过程,内部温度上升会让偏移发生可复现的缓慢变化。解决思路有两条:一是对精度不是极高要求的场合,上电后预热五分钟再开始采集,或者用软件做零点扣除;二是利用CS1237内置的温度传感器,实时读取片内温度,拟合一条零点温漂曲线来做软件补偿。后者的效果我实测下来能把满载漂移抑制掉七成以上,代价是标定工作量大一些。

7. 滤波和标定:把原始值变成可靠的物理量

7.1 三种滤波算法怎么选

原始读取到的数据不可能直接用,软件滤波是必要的最后一道工序。我试过三类滤波方式,简单说说各自适用场景。算术平均滤波适合静态称重,读取N次取平均,N越大噪声抑制越强,实时性越差,一般取16到64次。滑动平均适合需要连续输出的动态场景,新数据进来顶替最老的数据,每读一次输出一个平均结果,响应快且平滑。中值滤波适合采集信号里有偶发尖峰干扰的场合,比如电机启停瞬间的电磁干扰,取N次排序后取中间值,能有效去掉离群点。如果要求更高,可以在这三种基础之上再叠加一阶低通滤波,公式是new = new + alpha * (sample - new),alpha取0.1到0.3之间,相当于给数据流加惯性。

7.2 两点标定法的完整过程

滤波之后就要做标定,把ADC码值转成物理量。两点标定是最常用也是最可靠的方法,本质上就是解一条直线方程。先不施加任何负载,记录稳定后的原始码值作为零点的y0;然后放上一个已知重量的标准砝码,记录这时的原始码值作为满量程点的y1。假设砝码重量是w1,那么斜率k = (y1 - y0) / w1,截距b = y0,之后任意时刻的重量weight = (raw - b) / k。这里有一个容易被忽略的点:y0和y1不是直接读一次,而是各取滤波后的稳定值,而且要等示数稳定了再记录,否则标定误差会被线性放大。不同增益档位下斜率不同,所以更换PGA后必须重新标定。

7.3 我实测下来的一组参考数据

最后给一组我自己的实测数据供参考。我用的传感器是5kg量程的铝制悬臂梁式称重传感器,灵敏度2mV/V,5V激励,CS1237配置为PGA=128,输出速率10Hz。不加负载时,原始码稳定在约+1200的位置,零点峰峰值波动大约正负15个码。加上1kg标准砝码后,原始码跳到约+320000,换算下来每克重量大约对应320个码。也就是说,1个码对应约3mg的等效重量,配合滑动平均滤波后,最终显示的重量波动能控制在正负1g以内。如果你手头的原始码范围和我差很多,先检查激励电压、PGA增益和传感器灵敏度这三个参数,它们每一处都会成倍影响码值范围。做产品时,这套标定流程我会要求在产线上自动执行一遍,把每台设备的斜率存到EEPROM里,因为传感器和ADC都存在个体差异,换一台设备就预标定一次,长期跑下来稳定性才有保障。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询