我接手一个传感器模组的项目时,第一次认真研究 DS2431。以前我写过不少 EEPROM,AT24C02、AT24C256、W25Q64 都用过,潜意识里觉得 EEPROM 无非就是地址加数据,换个型号改改地址位宽就完事了。可 DS2431 这个容量只有 1Kb 的“小东西”,硬是让我推翻了这个想法。它不走 I2C 也不走 SPI,只用一根数据线通信,靠的是 OneWire 协议;存储区也不是一个从 0x00 到 0x3F 的连续数组那么简单,写数据之前还要先经过一个叫暂存器(Scratchpad)的缓冲区,甚至状态寄存器里还有一组只能从 1 改成 0、改完就回不来的写保护位。
这枚芯片在STM32项目里其实很常见,尤其是需要给设备写身份 ID、校准参数、序列号、电池信息这类“一锤子买卖”场景。如果你正在用 STM32 做类似需求,或者只是想把 OneWire 这个协议彻底吃透,这篇文章应该能帮你把从时序到驱动编写的路一次走通。我会从芯片硬件模型讲起,拆完协议时序后直接给你一套可以拿去改的 STM32 分层驱动代码,最后再聊聊实测中容易踩的坑。
1. DS2431 这颗 EEPROM 为什么值得单独写一篇
1.1 单总线与 I2C/SPI 的根本差异
大多数工程师接触 EEPROM 的第一站是 I2C 接口的 AT24C02。I2C 有 SCL 和 SDA 两根线,通信靠时钟沿控制,数据线有明确的主从关系,写一个字节要发设备地址、寄存器地址、数据,一整套流程虽然繁琐但很规整。SPI 更直接,MOSI、MISO、SCK、CS 四根线各司其职,速度也快。
DS2431 不一样,它只有一根数据线 DQ,外加 VCC 和 GND,总共三根引脚。数据线上既传时钟信息又传数据,靠的是严格的时序时隙(Time Slot)来区分 0 和 1。这种设计最大的优势是省 IO、省线缆,传感器探头、电池组、仪表模块里经常是只有一根多余信号线的情况,DS2431 正好填上这个坑。代价是协议必须由主机用软件模拟,而且对时序的准确性要求比 I2C 更苛刻。
还有一个容易忽略的点:每一颗 DS2431 出厂时都带一个唯一的 64 位 ROM ID。这意味着同一条总线上可以挂多颗 DS2431,主机通过 ROM 命令选择操作哪一颗。I2C 也能挂多颗,但需要不同的地址引脚组合,而 OneWire 是纯靠 ID 区分,布线更简单,代价是搜索算法比 I2C 寻址麻烦不少。
1.2 引脚、上拉与容量分布:数据手册里容易被绕晕的两个点
DS2431 的引脚很少,TO-92、SOT-23、倒装片封装都有。最常见的是 TO-92,三根脚分别是 GND、DQ、VCC。注意 DQ 不是推挽输出,它是开漏结构,所以总线上必须接一个上拉电阻,典型值 4.7kΩ。如果你的线缆比较长,或者总线上挂的设备多,就要降到 2.2kΩ 左右。上拉电阻选得太大,信号上升沿会变缓,时序窗口本来就短,很容易读错。
第二个容易绕晕的是容量。DS2431 标称 1Kb,很多人以为能存 128 字节,结果看数据手册发现用户可用空间只有 64 字节。原因在于这 1Kb 被拆成了好几块:
- 主 EEPROM:64 字节,地址 0x00 到 0x3F,分成 4 页,每页 16 字节。
- EPROM 仿真保护页:32 字节,地址 0x80 到 0x9F,每页对应一个 8 字节的影子区。
- 状态寄存器:8 字节,地址 0xA0 到 0xA7,里面放着写保护位、EPROM 仿真模式位和版本标识。
其中 0xA0 是保留字节,0xA1 和 0xA2 是写保护寄存器,0xA3 到 0xA6 是 EPROM 仿真寄存器,0xA7 是版本寄存器。DS2431 的原厂版本寄存器读出值是 0xA5,用 0xAA 表示兼容替代芯片。这个细节在选型时有用,因为市场上某些兼容片在高温或长时间写入性能上可能与原厂有细微差别。
| 地址范围 | 区域 | 大小 | 用途 |
|---|---|---|---|
| 0x00 - 0x3F | 用户 EEPROM | 64 字节 | 正常读写数据 |
| 0x80 - 0x9F | EPROM 仿真保护页 | 32 字节 | 与 EPROM 仿真模式配合的影子区 |
| 0xA0 | 保留 | 1 字节 | 读出 0xFF |
| 0xA1 - 0xA2 | 写保护寄存器 | 2 字节 | 控制四个页面的写保护状态 |
| 0xA3 - 0xA6 | EPROM 仿真寄存器 | 4 字节 | 设置单个字节为 EPROM 模式 |
| 0xA7 | 版本寄存器 | 1 字节 | 芯片版本标识 |
很多应用只使用前 64 字节,根本不碰状态寄存器,这没问题。但如果你的需求里有“出厂后锁定校准参数”这种要求,那状态寄存器才是真正的主角。
2. OneWire 协议时序的底层拆解:把波形翻译成代码
OneWire 的时序全部围绕一根线展开,理解它的关键是四个动作:复位、写 0、写 1、读。每个动作都在一个固定长度的时间窗口内完成,DS2431 的数据手册里把窗口定成了 60us 的时隙。
2.1 复位与存在脉冲:一次请求一次应答
主机想和 DS2431 通信,第一件事永远是把总线拉低 480us 以上,然后释放。释放后,总线依靠上拉电阻恢复高电平,DS2431 检测到这个上升沿后,会在 15us 到 60us 之间主动把总线拉低 60us 到 240us,作为存在脉冲(Presence Pulse)。
主机什么时候检测这个低电平很关键。我的做法是:拉低 480us 后释放,等 60us,然后读一次总线电平。这时候如果读到 0,说明从机在线且完成了应答;如果读到 1,说明总线上没有设备,或者设备没来得及响应。
主机 : 拉低 480us -> 释放 DS2431 : 等待 15~60us -> 拉低 60~240us 主机采样点 : 释放后约 60us 处这个采样点不能太晚。存在脉冲最短只有 60us,如果你用 HAL_Delay 这种毫秒级延时,等再想去读的时候低电平早就结束了,复位判断会莫名其妙地失败。
2.2 写 0、写 1、读时隙:15 微秒的采样窗口
写时序的规则比复位更精细:
- 写 1:主机拉低总线 1us 到 15us,然后释放,剩余时间保持高电平。从设备在时隙开始后的 15us 到 60us 区间读总线,读到高电平记为 1。
- 写 0:主机拉低总线并保持至少 60us,整个时隙内总线都是低电平,从设备读到低电平记为 0。
读时序稍微麻烦一点。主机拉低总线 1us 到 15us,然后释放,紧接着从设备决定是否把总线继续拉低。如果 DS2431 要返回 0,它会在主机释放后主动拉低总线;如果要返回 1,它就不动作,总线被上拉电阻拉高。所以主机必须在时隙开始后的 15us 之内完成采样,太晚的话读 0 的时隙可能已经结束,读 1 的时隙也可能受线路电容影响还没稳定。
我把时序参数整理成了下面这张表,方便你在调代码时对照:
| 动作 | 低电平时间 | 释放后行为 | 主机采样点 |
|---|---|---|---|
| 复位 | 480us | 从机 15~60us 后拉低 60~240us | 释放后约 60us |
| 写 1 | 1~15us | 保持高电平 | 从机在 15~60us 内采样 |
| 写 0 | 60~120us | 保持低电平 | 从机在 15~60us 内采样 |
| 读 | 1~15us | 从机控制总线电平 | 主机在 15us 内采样 |
2.3 ROM 层命令:一条总线上挂多个 DS2431 的识别机制
OneWire 总线的每个设备出厂时都有一个 64 位 ROM ID,前 8 位是家族码,DS2431 的家族码是 0x2D。主机可以通过几个 ROM 级命令来操作设备:
- READ ROM(0x33):读取总线上唯一设备的 64 位 ID。如果总线上挂了多颗,数据会冲突,所以这个命令只适合单设备场景。
- SKIP ROM(0xCC):跳过 ROM 寻址,直接对当前设备发存储命令。单设备场景最常用,可以少传 8 字节 ID。
- MATCH ROM(0x55):后面跟 64 位 ROM ID,只操作指定 ID 的设备。多设备场景下用这个。
- SEARCH ROM(0xF0):搜索总线上所有设备的 ROM ID。需要逐位读两个电平来判断分支,算法稍复杂。
如果你的系统里只有一颗 DS2431,直接用 SKIP ROM 就够了。如果是多颗,我的建议是产品初始化和维护阶段用 SEARCH ROM 把所有 ID 读出来存到 Flash 里,运行阶段用 MATCH ROM 精确访问。
3. DS2431 的命令体系:暂存器、复制与状态寄存器
3.1 为什么 DS2431 写 EEPROM 要分成两步
DS2431 没有直接写 EEPROM 的指令,写入必须走“写暂存器”和“复制暂存器”两步。这跟 AT24C02 很不一样,刚切换过来的人容易懵。
第一步是 WRITE SCRATCHPAD(0x0F)。主机发送命令后跟目标地址 TA1、TA2,然后发送 1 到 8 字节数据,数据先存到芯片内部的暂存器里。这个暂存器就是一个 8 字节的缓冲区,还带 CRC 校验。
第二步是 COPY SCRATCHPAD(0x55)。主机重新发送地址 TA1、TA2,再跟一个 0xFF 字节(这个字节叫 ES,用来指示复制范围,常规整 8 字节复制时发 0xFF),芯片会把暂存器里的数据真正写进 EEPROM,然后进入编程时间 tprog,典型值 10ms,最大 20ms。
为什么这么设计?因为 EEPROM 写入需要内部电荷泵,时间较长,如果直接把数据写进去,万一总线上出现干扰,数据写到一半就废了。先写暂存器、读回校验、再复制,能最大程度避免坏数据落进 EEPROM。
3.2 READ MEMORY 与页边界:读取时的地址滚动
读数据用 READ MEMORY(0xF0),命令后跟 16 位目标地址,读的时候地址会自动递增。读主存储区时从 0x00 开始,连续读 64 字节就能拿到全部用户数据。
有一个细节:READ MEMORY 读到地址 0xFFFF 后会继续输出 0xFF 通知主机结束。所以如果你循环读取时没有限制读取次数,会一直读到一堆 0xFF。程序里一般按需求长度固定读,或者读到连续数据末尾再判断结束。
另外,写数据时要注意页边界。主存储区每页 16 字节,暂存器只有 8 字节,意味着每次 COPY SCRATCHPAD 最多写入 8 字节。如果你想更新地址 0x0A 处的数据,直接写一个字节其实是可以的,但剩下的暂存器空间会残留旧值,复制时可能把垃圾数据一起写进去。稳妥的做法是:先把整 8 字节块读回 RAM,在 RAM 里修改目标字节,再整块写回。
3.3 状态寄存器与写保护:一次性烧写的物理限制
状态寄存器是最容易被忽略、也最危险的部分。写保护寄存器 0xA1、0xA2 里,每个存储页对应三个保护位,默认全为 1。保护位是物理 EPROM 性质的位,只能从 1 变成 0,一旦写成 0,就永久生效。它们控制的分别是对应页面的暂存器写入、EEPROM 复制等操作权限,三个位全部清零后,该页就彻底锁死。
EPROM 仿真寄存器 0xA3 到 0xA6 也是一样的道理,每个字节用两个位控制用户区的一个字节,默认 00 表示普通 EEPROM 模式。如果你把某个字节对应的两位改成 10,那么这个字节就进入了“EPROM 仿真模式”:只能把 1 写成 0,不能再从 0 恢复成 1,适合做一次性熔丝位、版本号降级记录这类需求。
这里我强烈建议:除非你的产品确实需要产线锁定,否则不要随便写状态寄存器。我见过有人想测试写保护功能,把 0xA1 写了几个 0,结果整页数据彻底锁死,只能换芯片。写保护是物理性的,不是软件标志位,没有后悔药。
4. STM32 端免驱模拟 OneWire:驱动分层与代码落地
4.1 为什么不直接用 USART 外设模拟单总线
网上有教程用 STM32 的 USART 外设模拟 OneWire 时序,思路是在 USART 发送 8 位数据时,把波特率调成对应时隙,利用起始位和停止位组合出 0 和 1 的波形。这么做确实能省 CPU,但我在实际项目里不推荐,原因有三个:
一是 USART 模拟对波特率精度要求高,一旦系统时钟倍频配置改过,时序就漂了。二是接收方向不好处理,读时序需要精确采样,USART 做不到。三是最关键的问题:OneWire 读写过程中要反复切换方向和拉高拉低,USART 的灵活性不够。所以老老实实 GPIO 模拟,才是最适合大多数 STM32 项目的方案。
4.2 微秒级延时的实现:DWT 比空循环靠谱
HAL_Delay 是毫秒级,OneWire 的时隙是微秒级,根本不能用。常见替代方案是空循环,但空循环受编译器优化影响大,换个优化等级时序就变了,非常不稳。
我推荐用 DWT 的 CYCCNT 计数器做微秒延时。它是内核自带的调试计数器,不占用定时器资源,精度高,代码也简单:
static void delay_us_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 / 1000000U); while ((DWT->CYCCNT - start) < ticks); }注意,SystemCoreClock 必须在系统时钟初始化完成后才准确。如果换了时钟树配置,这个值会变,延时也会跟着变。
4.3 onewire_bit 底层:复位、读写 bit、读写 byte
我习惯把驱动分成三层:底层是一组 onewire_bit 操作,中间是 DS2431 命令层,顶层是应用层。底层代码这一部分,核心就是四件事。
GPIO 初始化用开漏模式加外部上拉。开漏模式下,写 0 就是拉低,写 1 就是释放总线,读输入电平直接读 IDR,不需要切换模式,非常方便。如果你选的引脚是 PA15、PB3、PB4 这些复用脚,记得先禁用 JTAG,否则电平会被调试器干扰。
void ow_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = GPIO_PIN_1; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); }然后是复位函数。注意读存在脉冲的采样点要卡在释放后 60us 附近:
uint8_t ow_reset(void) { uint8_t presence; HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_RESET); delay_us(480); HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET); delay_us(60); presence = HAL_GPIO_ReadPin(ONEWIRE_PORT, ONEWIRE_PIN); delay_us(420); return presence == GPIO_PIN_RESET; }写 bit 和读 bit 是实现时序的核心:
void ow_write_bit(uint8_t bit) { if (bit) { HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_RESET); delay_us(6); HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET); delay_us(64); } else { HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_RESET); delay_us(60); HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET); delay_us(10); } } uint8_t ow_read_bit(void) { uint8_t bit; HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_RESET); delay_us(6); HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET); delay_us(5); bit = HAL_GPIO_ReadPin(ONEWIRE_PORT, ONEWIRE_PIN); delay_us(50); return bit; }读写字节都是低位在前,这个不要搞反。OneWire 的 LSB first 特性跟 I2C 的 MSB first 正好相反,很多人在这里栽过跟头。
void ow_write_byte(uint8_t data) { for (int i = 0; i < 8; i++) { ow_write_bit(data & 0x01); data >>= 1; } } uint8_t ow_read_byte(void) { uint8_t data = 0; for (int i = 0; i < 8; i++) { data >>= 1; if (ow_read_bit()) { data |= 0x80; } } return data; }4.4 ds2431 命令层与应用层:读 64 字节与写一页的完整流程
有了底层,DS2431 命令层就很好写了。先把命令和地址定义好:
#define DS2431_SKIP_ROM 0xCC #define DS2431_WRITE_SCRATCHPAD 0x0F #define DS2431_READ_SCRATCHPAD 0xAA #define DS2431_COPY_SCRATCHPAD 0x55 #define DS2431_READ_MEMORY 0xF0读 64 字节用户数据:
int ds2431_read_all(uint8_t *buf, uint8_t len) { if (!ow_reset()) return -1; ow_write_byte(DS2431_SKIP_ROM); ow_write_byte(DS2431_READ_MEMORY); ow_write_byte(0x00); ow_write_byte(0x00); for (int i = 0; i < len; i++) { buf[i] = ow_read_byte(); } return 0; }写入一个 8 字节块到指定页。这里的关键是“读回校验后再复制”:
int ds2431_write_block(uint8_t addr, const uint8_t *data) { uint8_t check; ow_reset(); ow_write_byte(DS2431_SKIP_ROM); ow_write_byte(DS2431_WRITE_SCRATCHPAD); ow_write_byte(addr); ow_write_byte(0x00); for (int i = 0; i < 8; i++) { ow_write_byte(data[i]); } // 读回暂存器,校验地址和数据 ow_reset(); ow_write_byte(DS2431_SKIP_ROM); ow_write_byte(DS2431_READ_SCRATCHPAD); (void)ow_read_byte(); // TA1 (void)ow_read_byte(); // TA2 for (int i = 0; i < 8; i++) { check = ow_read_byte(); if (check != data[i]) return -2; } // 执行复制 ow_reset(); ow_write_byte(DS2431_SKIP_ROM); ow_write_byte(DS2431_COPY_SCRATCHPAD); ow_write_byte(addr); ow_write_byte(0x00); ow_write_byte(0xFF); delay_us(20000); // 等待 tprog return 0; }有一个细节值得说明:READ SCRATCHPAD 读回数据后,在暂存器末尾还会收到 CRC 字节。上面的代码没做 CRC 校验,只做了数据比对。对大多数产品来说够用了,但如果你做的是高可靠性设备,建议把 CRC 也算上。
应用层就简单多了。比如在设备启动时读取序列号:
uint8_t user_data[64]; if (ds2431_read_all(user_data, 64) == 0) { // 数据有效,检查版本、解析校准参数 }5. 实测中的坑:时序、中断与恢复策略
5.1 读回全是 0xFF:先怀疑复位没成功
最常见的故障现象是读回的数据全是 0xFF,看起来像总线上不存在设备。我排查时的第一反应是看复位函数有没有返回 1。
原因通常是上拉电阻问题。如果你的开发板上没有外部上拉,只靠 STM32 的内部上拉,那总线上升沿会非常慢,DS2431 可能来不及检测到有效的上升沿,自然也就不回存在脉冲。解决方法是加一颗 4.7kΩ 外部上拉,或者把 GPIO 配置为开漏输出并开启内部上拉临时验证。
还有一种情况是你在初始化 GPIO 时把引脚配置成了推挽输出。推挽模式下写 1 会强制拉高,这时 DS2431 拉低总线会产生冲突,时序完全乱掉。检查一下 GPIO 配置是不是 GPIO_MODE_OUTPUT_OD。
5.2 时灵时不灵:线长、上拉和中断的叠加效应
如果某块板子测试正常,换了一个线缆更长的工装就时灵时不灵,大概率是上升沿变缓加上采样点太靠近窗口边缘导致的。OneWire 本身是为短距离设计的,虽然也能走几米,但线缆电容会把波形边沿磨圆。
我的处理方式是:上拉电阻从 4.7kΩ 换成 2.2kΩ,同时把读时序里的采样点从 5us 微调到 10us 左右。这样能显著提高长线场景下的稳定性。
中断是另一个大坑。如果读写过程中来了一个高优先级中断,打断了 OneWire 时序,哪怕只有 10us,也可能导致整个时隙错位。最有效的解决办法是在单字节读写时进入临界区,禁止中断响应:
__disable_irq(); ow_write_byte(cmd); __enable_irq();如果你用的是 FreeRTOS,更好的方案是把这个操作放在低优先级任务里,同时用临界区保护每一笔完整的事务,而不是每次只保护一个 bit。因为一个 bit 才 60us,频繁开关中断反而增加抖动。
5.3 写保护位一旦改错不可恢复的教训
前面说过写保护位是 EPROM 性质的位,只能由 1 改 0,不能恢复。我在测试阶段踩过这个坑:当时想验证写保护寄存器能不能正常写入,直接对 0xA1 写了 0x00,想当然地以为重启后能重新写回 0xFF。结果就是四个用户页全部锁定,芯片报废。
正确做法是:先读当前状态寄存器的值,做好记录;如果有可写保护的小批量芯片,先用它们做验证;正式产品里,写保护位的置位操作必须放在产线最后一步,而且要有确认弹窗之类的二次保护机制。
如果你确实需要保护部分数据,我建议优先用 EPROM 仿真模式而不是整页写保护。比如只把 0x00 到 0x07 这 8 个字节作为熔丝位,通过 0xA3 寄存器把对应字节设成 EPROM 模式,这样只影响一小块区域,不至于整页锁死。
5.4 与 I2C EEPROM 移植习惯的差异
从 AT24C02 移植过来的人,最容易犯的错是把 I2C 的“写完后读回 ACK”思维带进来。OneWire 的 ACK 机制不是在同一事务里完成的,它靠的是复位和存在脉冲,每次命令结束都要重新复位。另一个是地址方向:I2C 地址字节是 MSB first,OneWire 的字节传输是 LSB first。代码里如果沿用 I2C 的移位逻辑,数据会全部颠倒。
还有一点,DS2431 的 COPY SCRATCHPAD 命令后面必须跟 ES 字节,如果直接移植 I2C 写内存的三字节地址模式,漏掉 ES,芯片不会进入编程状态,看起来就是“写不进去”。
6. 一些后续扩展和我的使用建议
如果你以后要在 FPGA 上用 Verilog 实现 OneWire 主机,我的建议是不要照搬本文的延时函数思路。FPGA 更合适的方式是用计数器状态机来精确划分时隙,把 60us、15us、6us 这类参数换算成对应时钟周期的计数值。相比 STM32 的软件延时,FPGA 的时序更稳定,但灵活性不如单片机,改时序参数要重新综合。
如果要做多设备管理,建议把 SEARCH ROM 算法的搜索表放在上位机或产品初始化阶段做,不要在每次读写时都跑一遍。搜索过程需要逐位读两次电平来判断分支,效率低,而且对时序更敏感。
还有一点:DS2431 的工作电压是 2.8V 到 5.25V,但 OneWire 总线的高速模式对电压有额外要求。如果系统是 3.3V 供电,上拉电阻接到 3.3V 即可;如果是 5V 系统,要注意 STM32 引脚是否容忍 5V,避免烧引脚。
文章开头说过,我为传感器模组选 DS2431,用它存身份 ID 和校准参数。产品跑了几个月后,我最满意的不是它的 EEPROM 容量,而是 ONE Wire 总线把主板和探头之间的连线简化到了极致。校准数据写保护后,产线再也没出现过参数被误改的返修。如果你手头的项目正好受限于 IO 数量,或者需要一个带唯一 ID 的存储芯片,DS2431 是个值得花半天时间把协议吃透的选择。