简介:面向嵌入式开发者的CS1237驱动源码包,基于C/C++编制,内置滤波处理,并特意规避Delay函数依赖,适合对时序实时性要求较高的单片机采集场景,常见于称重、压力检测等精密测量应用。压缩包体积仅3KB,共包含2个文件,分别为1个.c源文件与1个.h头文件,代码注释简洁、结构清晰,便于快速移植至现有工程。目前已有1379人学习下载,被多位开发者用于CS1237 ADC的快速接入与信号预处理。通过这份驱动,用户可直接获得芯片寄存器配置、数据读取和滤波算法的完整实现,省去翻查数据手册逐条编写底层时序的重复工作;无Delay函数的设计也大大增强了可移植性,在RTOS、定时器轮询等非阻塞环境中都能稳定运行,是嵌入式项目中值得参考的驱动模板。
1. CS1237 驱动到底难在哪
第一次用 CS1237 的人,最容易把它当成普通 SPI 芯片来写:SCLK 给时钟,DOUT 读数据,读完之后再给个片选复位。结果读回来的数据不是跳 0xFFFFFF,就是在零位附近来回抖;换成中断方式抓 DOUT 下降沿,又把 DRDY 当成数据位直接采,导致每一帧都错一位。CS1237 的接口比 SPI 简单,但简单的地方反而容易踩坑:只有 SCLK 和 DOUT 两根线,DOUT 既输出转换结果,又承担配置命令的输入,时序的方向切换和 25 个时钟的边界必须由驱动自己管。这篇按“时序 → C 驱动 → 参数 → C++ 封装 → 排错”的顺序,给出一套能直接落地的 CS1237 驱动方案。
2. 先把 CS1237 的时序和配置字吃透,再写驱动
CS1237 没有标准的 CS 片选引脚,也不会像普通 SPI 从机那样主动回应寄存器地址。它用 DOUT 的双向特性,把“就绪通知、数据输出、命令输入”三件事压在一条线上。驱动要做的不是调 SPI 外设,而是用 GPIO 严格模拟它的状态机。只要把下面这个状态机数清楚,后面的 C 代码几乎不会出问题。
2.1 CS1237 的两根线到底谁控制谁
从主机视角看,CS1237 的两根线分工如下:
| 引脚 | 主机侧方向 | 在驱动里的作用 | 推荐接法 |
|---|---|---|---|
| SCLK | 输出 | 串行时钟,空闲为低 | GPIO 推挽输出,速度配置成 HIGH |
| DOUT/DRDY | 双向 | 转换完成拉低;24 位数据输出;配置命令输入 | GPIO 开漏或推挽,外部加 4.7kΩ 上拉到 VDD |
| VREF | 输入 | 外部参考电压输入 | 用内部参考时接 100nF 去耦电容;用外部参考时接基准源 |
DOUT 的上拉电阻不是可选项。CS1237 处于“等待主机读取”状态时,DOUT 是被芯片内部释放的,主机要靠外部上拉才能看到稳定的高电平。如果你用推挽输出并且直接在读数据之后把 DOUT 切输出发命令,至少要在方向切换后加一个 SCLK 周期的延时,让总线电平稳定,否则第一个命令位很容易被拉错。
2.2 从 DRDY 低电平到 24 位数据的 25 个时钟
一次完整转换的时序可以拆成三步:
- CS1237 完成一次转换后,把 DOUT 从高拉低,表示数据准备好。
- 主机发送 24 个 SCLK,在每个 SCLK 高电平期间采样 DOUT,得到 24 位原始数据。
- 主机补第 25 个 SCLK,作为“帧结束”信号,DOUT 随后被释放,可以切换到配置输入阶段。
下面这段是原理验证用的最小片段,先不看配置写入,只验证数据链路:
#define CS1237_DOUT_READ() gpio_read(CS1237_DOUT_PIN) #define CS1237_SCLK_HIGH() gpio_write(CS1237_SCLK_PIN, 1) #define CS1237_SCLK_LOW() gpio_write(CS1237_SCLK_PIN, 0) static uint32_t cs1237_wait_ready(uint32_t timeout_us) { while (CS1237_DOUT_READ()) { if (--timeout_us == 0) return 1; /* 超时 */ } return 0; } static uint32_t cs1237_shift_in_24(void) { uint32_t v = 0; for (int i = 0; i < 24; i++) { CS1237_SCLK_HIGH(); v = (v << 1) | (CS1237_DOUT_READ() ? 1u : 0u); CS1237_SCLK_LOW(); } /* 第 25 个时钟:结束当前数据帧,释放 DOUT */ CS1237_SCLK_HIGH(); CS1237_SCLK_LOW(); return v; }这里的采样点放在 SCLK 拉高之后、拉低之前,对应数据手册里的“数据在 SCLK 下降沿更新、高电平期间保持”的约定。如果你手上的芯片批次是下降沿采样,把v =那一行移到CS1237_SCLK_LOW()之后即可,驱动框架不用改。
要注意的是:不要用外部中断抓 DOUT 下降沿后立即读位。DOUT 变低表示转换完成,但 SCLK 脉冲序列还没建立,数据线上的第一位在此时并不稳定。更常见的做法是轮询CS1237_DOUT_READ(),读到低后再统一发 24 个时钟。
2.3 配置字节和 0x1C 这个默认值
CS1237 的配置字是 8 bit,没有独立地址,在数据帧结束后的配置窗口逐位移入。不同厂牌、不同手册版本对位域的定义不完全一致,所以驱动里最好把它当一个不透明字节透传,不要在业务代码里直接cmd |= (1 << 3)这种写法。
| 配置项 | 可选范围 | 我常用的值 | 什么时候需要改 |
|---|---|---|---|
| 输出速率 | 10Hz / 30Hz / 60Hz / 240Hz 等 | 10Hz | 称重、温度漂移慢的场景优先低速 |
| PGA 增益 | 1 / 2 / 4 / 8 / 16 / 32 / 64 / 128 | 128 | 输入信号超过满量程时降增益 |
| 工作通道 | 差分输入 / 温度传感器 | 差分输入 | 做温补时切温度通道 |
| CRC 校验 | 使能 / 禁止 | 禁止 | 交付前打开做在线诊断 |
| 典型配置字 | 8 bit | 0x1C | 对应 10Hz + PGA128 + 正常通道 |
0x1C是称重方案里最常见的默认值:低速率、高增益、差分输入,直接对应应变式称重传感器的输出范围。拿到新批次芯片时,第一件事是读自己那版数据手册上的配置寄存器位图,确认0x1C解出来的速率和增益符不符合预期,再往下写驱动。
3. 用纯 C 写一版能上秤的 CS1237 驱动
时序验证通过后,驱动就可以拆成两个文件:cs1237.h放配置宏和函数声明,cs1237.c放等待、读取、写配置、滤波四部分。这个结构在 STM32、ESP32、Linux 用户态 GPIO 上都能复用,只需要替换几个宏。
3.1 文件划分和硬件抽象
cs1237.h里不写任何单片机相关头文件,只定义一只占位用的uint32_t数据帧类型:
#ifndef CS1237_H #define CS1237_H #include <stdint.h> #define CS1237_CMD_DEFAULT 0x1Cu #define CS1237_ERR_TIMEOUT -1 int cs1237_read_raw(uint32_t *raw); int cs1237_write_cmd(uint8_t cmd); int32_t cs1237_to_signed(uint32_t raw); float cs1237_to_voltage(int32_t adc, float vref, float pga); #endif宏CS1237_CMD_DEFAULT是唯一需要根据手册确认的配置字。驱动函数只把它当一个字节使用,不猜位含义;如果某个项目要切到 30Hz,直接在宏定义处改,不用动 TDM。
3.2 带超时和错误码的原始数据读取
读函数不能死等,否则芯片损坏或 DOUT 被拉死时,整个系统会卡在 while 循环里。实际项目里必须加超时,并把错误码传给应用层:
#include "cs1237.h" #define CS1237_SCLK_HIGH() gpio_write(CS1237_SCLK_PIN, 1) #define CS1237_SCLK_LOW() gpio_write(CS1237_SCLK_PIN, 0) #define CS1237_DOUT_READ() gpio_read(CS1237_DOUT_PIN) int cs1237_read_raw(uint32_t *raw) { uint32_t v = 0; uint32_t timeout = 100000; /* 等待 DOUT 从高变低,表示一次转换完成 */ while (CS1237_DOUT_READ()) { if (--timeout == 0) return CS1237_ERR_TIMEOUT; } for (int i = 0; i < 24; i++) { CS1237_SCLK_HIGH(); v = (v << 1) | (CS1237_DOUT_READ() ? 1u : 0u); CS1237_SCLK_LOW(); } /* 第 25 个时钟,告诉 CS1237 数据帧结束 */ CS1237_SCLK_HIGH(); CS1237_SCLK_LOW(); *raw = v; return 0; }参数说明:raw是输出参数,直接返回 24 位无符号值,符号转换留给cs1237_to_signed()做。超时时间 100000 是相对计数,不是微秒,在实际代码里要换算成你自己的延时函数。SCLK 高电平之后立即读 DOUT,如果 GPIO 翻转速度不够快,需要在宏里补一条__NOP()或短暂延时,尤其在高主频 MCU 上明显。
3.3 写配置命令的正确姿势
CS1237 的 DOUT 在写配置阶段变成输入,主机要在 SCLK 上升沿之前把对应位放到 DOUT 上。这里最容易出的问题是方向切换:读数据时 DOUT 是输入,写配置时必须先切输出,否则命令位根本没送进去。
#define CS1237_DOUT_OUT() gpio_set_mode(CS1237_DOUT_PIN, GPIO_MODE_OUTPUT) #define CS1237_DOUT_IN() gpio_set_mode(CS1237_DOUT_PIN, GPIO_MODE_INPUT_PULLUP) #define CS1237_DOUT_WRITE(b) gpio_write(CS1237_DOUT_PIN, (b) ? 1 : 0) int cs1237_write_cmd(uint8_t cmd) { CS1237_DOUT_OUT(); for (int i = 7; i >= 0; i--) { CS1237_DOUT_WRITE((cmd >> i) & 1u); /* 先发最高位 */ CS1237_SCLK_HIGH(); CS1237_SCLK_LOW(); } CS1237_DOUT_IN(); return 0; }注意CS1237_DOUT_OUT()建议用开漏模式,配合外部上拉,避免和 CS1237 内部驱动打架。如果只能用推挽,在方向切换后加一个微小延时再发第一位。写配置不需要在每一次读之后都执行;初始化时发一次即可,除非你开启了 CRC 或想在运行中切速率。
3.4 符号扩展、电压换算和滤波
CS1237 输出的是 24 位二进制补码,直接塞进int32_t不会得到正确负数。最高位为 1 时要把高 8 位全部补 1:
int32_t cs1237_to_signed(uint32_t raw) { if (raw & 0x800000u) raw |= 0xFF000000u; return (int32_t)raw; } float cs1237_to_voltage(int32_t adc, float vref, float pga) { return (float)adc * vref / (pga * 8388608.0f); }8388608是 2 的 23 次方,代表 24 位 ADC 的双极性满量程半幅。公式里vref / pga就是满量程电压,再乘以归一化后的读数。电压换算公式在单片机浮点性能弱的时候会拖慢循环,称重项目通常只需要原始 ADC 码,用定点校准系数代替浮点。
滤波的选择取决于响应速度:
| 应用场景 | 推荐滤波方式 | 参数建议 | 原因 |
|---|---|---|---|
| 静置称重 | 滑动平均 | 8~16 点 | 抑制噪声,允许一定滞后 |
| 动态拉力/压力 | 中值滤波 | 3~5 点 | 保留快速变化,去掉尖峰 |
| 多通道扫描 | 不做软件滤波 | 不滤波 | 靠内置数字滤波,减少通道切换时间 |
滑动平均代码很短,但要注意累加和用 64 位整数:
#define CS1237_FILTER_N 8 static int32_t buf[CS1237_FILTER_N]; static uint8_t idx; static int64_t sum; int32_t cs1237_filter(int32_t raw) { sum -= buf[idx]; buf[idx] = raw; sum += raw; idx = (uint8_t)((idx + 1) % CS1237_FILTER_N); return (int32_t)(sum / CS1237_FILTER_N); }sum是 int64_t,避免 8 个 24 位有符号数相加后溢出。滤波窗口越大,零位越稳,但阶跃响应越慢;跑 10Hz 采样率时,16 点平均意味着 1.6 秒才稳定到最终值,动态称重项目不要开这么大。
4. 参考电压、PGA 和 C++ 封装:使用层的参数与模块化
驱动把原始码读出来之后,真正决定精度的是 PGA、参考电压和配置字三者的匹配。这一层最容易出现“读数很稳但不准”的问题:芯片没坏,驱动没 bug,错在用内部参考电压当精密基准。
4.1 满量程与分辨率:PGA 和参考电压先算清楚
CS1237 的满量程输入电压等于参考电压除以 PGA。以内部参考 2.5V 为例,不同 PGA 对应的输入范围和 1 LSB 电压如下:
| PGA | 满量程(VREF=2.5V) | 1 LSB 对应电压 | 典型应用 |
|---|---|---|---|
| 1 | ±2.5V | 298nV | 大信号、电池电压采集 |
| 32 | ±78.125mV | 9.31nV | 1mV/V 传感器直连 |
| 64 | ±39.06mV | 4.66nV | 压力桥、应变片 |
| 128 | ±19.53mV | 2.33nV | 称重传感器最常见 |
挑选 PGA 的规则不是“越大越好”,而是让最大差分输入不超过满量程的 80%。比如一个传感器灵敏度是 1mV/V,激励 5V,满量程输出 5mV;用 PGA128 时满量程 19.53mV,还有约 3.9 倍余量,这是合理的。反过来,如果只把 5mV 信号配 PGA8,满量程 312.5mV,有效分辨率会白白丢掉 4 位。
4.2 CS1237 参考电压输出准确吗?什么时候换外部 VREF
很多人在选型阶段会搜“CS1237 参考电压输出准确吗”,答案是不建议把它当基准源用。芯片内置参考的绝对精度和温漂都有限,适合做比率式测量,不适合直接输出给别的电路。所谓的“准确”只在一定温度范围内、同一批芯片内部自洽时成立。
如果你的应用是称重,传感器激励电压和 ADC 参考电压用同一条电源轨,参考误差会被比值抵消,这时候内部参考完全可以接受。但如果你要的是绝对电压测量,比如电池电压、电流采样电阻两端电压,误差 0.5% 都嫌大,就必须在 VREF 引脚接外部基准,比如 2.5V 的低漂移基准芯片,并把cs1237_to_voltage()里的vref改成实测值而不是手册典型值。
4.3 用 C++ 封装 CS1237,兼容 MCU 与 Linux
C 驱动跑通后,C++ 封装主要解决两件事:一是把 GPIO 操作从业务逻辑中隔离出去,二是给上层一个“读一次采样”的干净接口。我用std::function注入回调,这样同一份类可以在 STM32、Linux 用户态、模拟器上复用:
#include <functional> #include <cstdint> class CS1237 { public: using DoutRead = std::function<int()>; using DoutWrite = std::function<void(bool)>; using SclkWrite = std::function<void(bool)>; CS1237(DoutRead dout, DoutWrite dout_write, SclkWrite sclk) : _dout(std::move(dout)) , _dout_write(std::move(dout_write)) , _sclk(std::move(sclk)) { } int32_t readRaw() { while (_dout() != 0) { /* 生产环境换成带超时的 waitReady() */ } uint32_t v = 0; for (int i = 0; i < 24; ++i) { _sclk(true); v = (v << 1) | (_dout() ? 1u : 0u); _sclk(false); } _sclk(true); _sclk(false); return (v & 0x800000u) ? (int32_t)(v | 0xFF000000u) : (int32_t)v; } void writeConfig(uint8_t cmd) { for (int i = 7; i >= 0; --i) { _dout_write((cmd >> i) & 1u); _sclk(true); _sclk(false); } } virtual ~CS1237() = default; private: DoutRead _dout; DoutWrite _dout_write; SclkWrite _sclk; };C++ 封装时有个容易出现的问题:子类重写writeConfig时漏写override,或者新增一个readRaw(int)重载,导致基类的无参readRaw被隐藏。前者编译器能检查,后者只在调用处表现为“找不到函数”,排查起来很隐蔽。习惯上把基类读写函数都声明成virtual,子类重载时统一加override。
4.4 Linux 用户态复用同一套逻辑
在嵌入式 Linux 上跑同一个驱动,只需要把四个宏从 STM32 HAL 换成 libgpiod。以树莓派和大多数国产 Linux 板子为例:
#define CS1237_SCLK_HIGH() gpiod_line_set_value(line_sclk, 1) #define CS1237_SCLK_LOW() gpiod_line_set_value(line_sclk, 0) #define CS1237_DOUT_READ() gpiod_line_get_value(line_dout)这里不推荐用/sys/class/gpio的方式,因为每次 read/write 都要经过文件系统,SCLK 周期会被拉长到几百微秒,虽然 CS1237 也能工作,但 25 个时钟的边沿抖动会比内核 GPIO 控制器直接操作大很多。libgpiod 行句柄在初始化时一次性获得,循环里只有gpiod_line_get_value,时序稳定得多。
5. 收尾技巧:CRC、软启动和逻辑分析仪验证
驱动能稳定读数之后,建议在交付前做三件小事:打开 CRC 验证链路、周期重发配置、用逻辑分析仪确认 25 个时钟的真实波形。这三件事看起来不紧急,却能避开绝大多数“偶发跳数、上电乱码、跑几天后漂移”的问题。
5.1 使能 CRC 后先别急着写校验逻辑
CS1237 在配置字里打开 CRC 校验后,输出帧不再是单纯的 24 位数据,还会在数据位后附加校验字节。这时候第一件要做的不是去实现某种多项式算法,而是先用逻辑分析仪完整抓一次帧,确认当前芯片批次到底是 24+8 个字节还是 25 个时钟内结束。不同手册版本的 CRC 位定义和发送顺序有差异,驱动侧先把它定义成“附加 8 位原始宽度”,把校验字节原样放进数据帧结构体里,由上层协议去判断,比在底层写死多项式更省事。
5.2 周期重发配置与软启动
CS1237 的配置寄存器在强电磁干扰下可能被改写,尤其是 DOUT 和 SCLK 走线较长时。我会每隔 64 次采样重发一次配置字,成本几乎可以忽略:
#define CS1237_RECONFIG_PERIOD 64 static uint8_t frame_count; int cs1237_service(void) { uint32_t raw; if (cs1237_read_raw(&raw) != 0) return -1; if (++frame_count >= CS1237_RECONFIG_PERIOD) { cs1237_write_cmd(CS1237_CMD_DEFAULT); frame_count = 0; } return (int)raw; }上电软启动也要注意:不要在主控复位后的第一条指令就去读 ADC,先给 CS1237 至少 10ms 稳定时间,等 VREF 和内部 LDO 稳定。这个延时放大了看是小事,但很多开发板刚上电时读数乱跳,就是没做这一步。
5.3 用逻辑分析仪验证 25 个时钟的边界
直接把 SCLK 和 DOUT 两根线接到逻辑分析仪,采样率至少设成 4MHz。重点看三个位置:DOUT 下降沿之后到第一个 SCLK 上升沿的间隔;第 24 个 SCLK 下降沿时 DOUT 的电平;第 25 个 SCLK 之后 DOUT 是否回到空闲高电平。如果数出来只有 24 个 SCLK,把读取循环的i < 24改成i < 25,并单独处理最后一个时钟不采样数据。
如果只记一条排错经验,那就是:读数出现周期性 0xFFFFFF,别急着调滤波,先确认你的 SCLK 数到了 25 个而不是 24 个;这一位之差,是最常见也最费电的坑。
本文还有配套的精品资源,点击获取