做嵌入式这些年,我接触过的通信方式不少,但单总线协议(1-Wire)始终是很特别的一个。它不靠时钟线,也不靠差分信号,只靠一根数据线配合严格的时间窗口,就能完成供电、通信和设备识别。DS18B20 温度传感器、iButton 身份钥匙、电池仓里的存储芯片,背后都是这套逻辑。很多朋友第一次照着例程跑通了,可一旦换线缆、换芯片、或者挂多个设备,就开始各种莫名其妙的问题——十有八九都出在物理层的时序和 ROM 寻址上。下面我就把这根“单行线”从电平标准、上拉电阻、寄生供电,一路拆到 64 位 ROM 码和搜索算法,把核心逻辑掰开揉碎讲清楚。
1. 单总线协议要解决的问题:为什么一根线也能通信
1.1 省线省到极致:从 I2C、UART 到单总线的思路转变
正常的通信方式都离不开“时钟”的概念。I2C 有独立的 SCL 时钟线,主机和从机跟着时钟沿动作;SPI 更夸张,MOSI、MISO、SCLK、CS 四条线少一根都不行;UART 虽然只有 TX、RX 两根,但双方必须事先约定波特率,一旦晶振偏差就出乱码。而单总线的思路完全不一样:它把时钟信息“塞”进一根数据线的时间窗口里,任何一次通信都是从主机主动拉低总线开始的,从机靠检测这些低电平的宽度来判断“现在该听什么、该说什么”。
这种设计带来的最大好处就是省 GPIO 和布线。对一个温控系统来说,如果传感器分布在好几个位置,用 I2C 至少要拉两根信号线再加电源和地,用单总线只需要一根数据线加公共地,传感器本身还能从数据线上取电。对消费类产品来说,减少一根线就意味着降低一个连接器引脚、少一道线束成本,这在量产阶段是非常实在的收益。
单总线还有一个容易被忽视的优点:器件可以做成“触点式”。iButton 就是一个典型的例子,它的外壳就是一个金属触点,人手拿着往读卡器上一碰,通信和供电同时建立。这种形态在巡检打卡、身份识别、设备维护记录里非常好用,普通协议很难做到这么简单的使用体验。
1.2 它能做什么,不能做什么
从实际项目来看,单总线最常见的应用有这么几类:
- 温度采集:DS18B20、DS18S20 这类单总线温度传感器可以并联在同一根线上,主机通过 ROM 寻址区分每颗传感器,适合做多测点测温。
- 身份识别:iButton(DS1990)、DS2401 这类只读序列号器件,本质上就是一个出厂激光刻录的 64 位 ID,适合做防伪、授权、设备身份绑定。
- 存储与校准:DS2431、DS2433 这类 EEPROM 和电池管理芯片,可以存放校准系数、产品信息、电池电量和循环次数等数据。
- 安全认证:带认证引擎的芯片(比如 DS2432)可以在单总线上完成密钥校验,防止耗材、配件被恶意克隆。
不过单总线不是一个万能方案。它的速度非常低,标准模式只有 15.4kbps,超速模式(Overdrive)也就是 111.1kbps,和 CAN、以太网这类完全不在一个量级。它的传输距离也不算远,常规设计建议在几十米以内,超过这个长度需要对上拉电阻、线缆和终端芯片做专门的优化。它还是半双工通信,主机和从机不能同时收发。所以,单总线最适合的场景是“信号量小、节点分散、成本敏感”的控制系统,而不是大数据量的骨干网络。
1.3 和 CAN 物理层放一起看,才算真正看懂“物理层”的含义
很多做总线开发的人会习惯性把“协议”和“物理层”混在一起,但单总线这点体现得非常清楚。物理层只管电平、阻抗、时序这些物理特性,不管数据怎么组织。比如 CAN 的物理层用的是 CANH 和 CANL 两根差分线,靠显性位和隐性位表示 0 和 1,总线两端还要接 120Ω 终端电阻来消除反射;而单总线的物理层就是一根开漏数据线加一个上拉电阻,0 和 1 靠高低电平表示。同样是总线,物理层完全不同,上层即便都叫“寻址”,实现方式也天差地别。
把单总线、I2C、UART、CAN 拉在一起对比,一眼就能看出各自的定位:
| 项目 | 单总线(1-Wire) | I2C | UART | CAN |
|---|---|---|---|---|
| 信号线 | DQ + GND | SDA + SCL + GND | TX + RX + GND | CANH/CANL 差分 |
| 时钟来源 | 无,靠时隙 | SCL 时钟线 | 无,靠波特率 | 无,靠位同步 |
| 电平方式 | 开漏 + 上拉电阻 | 开漏 + 上拉电阻 | 推挽/差分 | 差分 + 终端电阻 |
| 寻址方式 | 64位ROM+搜索 | 7位/10位地址 | 无 | 报文ID仲裁 |
| 常见速率 | 15.4k/111.1kbps | 100k/400k/1M+ | 按波特率 | 达1Mbps,CAN FD更高 |
| 典型距离 | 几米到几十米 | 板级 | 板级/短距 | 几百米 |
| 成本 | 极低 | 低 | 低 | 中 |
顺带回答一个常被问到的问题:物理层能不能做加密?对单总线、CAN 这类普通总线来说,答案是很明确的不行。物理层只负责把 0 和 1 发正确,它不区分数据内容,更谈不上加密;加密这件事必须放在上层协议里做。单总线器件要防克隆,靠的不是隐藏总线时序,而是使用带安全认证引擎的芯片,在应用层完成密钥校验,这一点思路一定要转过来。
2. 物理层拆解:电平、上拉电阻与寄生供电
2.1 开漏结构与“总线空闲必须为高”
在单总线的物理层里,主机和所有从机的数据引脚都是开漏结构。所谓开漏,就是引脚内部没有一个主动输出高电平的驱动级,只能把引脚拉低,或者把引脚“放开”。放开之后,总线靠外部接在 VCC 和 DQ 之间的上拉电阻把电平拉回高。这个设计带来的好处是,任何一方拉低总线,总线就是低;所有设备都放开,总线才是高。多主机、多从机不会出现两个推挽输出互相打架烧管子的情况,这也是单总线能安全并联几十颗器件的基础。
所以“总线空闲必须为高”不是随便说说,而是整个协议的物理前提。如果上拉电阻缺失,总线就会一直飘在低电平或者浮空,任何复位、时隙、数据都无法进行;如果上拉电阻接错地方,或者 VCC 电压不稳定,后续解码全是错的。我见过不少“刚上电能读,一接长线就失败”的案例,最后查下来都是上拉阻值对不上实际线缆电容,导致上升沿太慢,从机采样窗口内还没越过高电平阈值。
2.2 上拉电阻怎么选:一个 RC 上升沿的计算实例
单总线上拉电阻的典型推荐值是 4.7kΩ,这也是 DS18B20 数据手册给的标准值。但在实际工程里,这个值不是万能的,选型核心要看总线的等效电容。数据线本身有寄生电容,每一颗挂载器件也有输入电容,二者叠加决定总线释放之后从低到高的上升速度。上升时间可以近似按 RC 一阶电路估算:从 0V 上升到 90% VCC 大约需要 2.2 × R × C 的时间,这个时间必须远小于从机判断高电平所用的时间窗口,否则从机就会把本应的高电平误判成低电平。
举个例子:假设总线挂 10 颗 DS18B20,每颗输入电容按 50pF 估算,再加上 2 米线缆约 200pF,总电容大约 700pF。用 4.7kΩ 上拉时,时间常数 τ = 4.7k × 700pF ≈ 3.3µs,上升到 90% 大约要 7.2µs。标准模式下读时隙通常在释放后 15µs 内就要采样,这个上升沿是来得及的。但如果换成 100 米长线,线缆电容按 100pF/米算就有 10nF,τ 直接变成 47µs,上升 90% 要 100µs,早就错过了采样窗口。这种情况要么降低上拉电阻到 2.2kΩ 甚至 1kΩ,要么改用带更强驱动力的一线桥接芯片,比如 DS2482/DS2480B。
- 板内短线、少于 5 个器件:10kΩ 可用,但更推荐 4.7kΩ,兼容性最好。
- 5 到 20 个器件、线长几米:4.7kΩ 是稳妥起点,如果波形上升沿偏慢再换 2.2kΩ。
- 长线、大负载、超速模式:2.2kΩ 起步,必要时加 1.5kΩ 超速专用上拉,同时减小线缆电容。
注意:上拉电阻不是越小越好。阻值太低会让低电平时流过引脚的电流过大,既增加功耗,也可能超过器件的灌电流能力。实际调参时一定要用示波器看上升沿,不要凭感觉换电阻。
2.3 寄生供电的秘密:数据线就是电源线
单总线最让人“眼前一亮”的设计是寄生供电。许多单总线器件只有 DQ 和 GND 两个引脚,比如最常见的 TO-92 封装 DS18B20,VDD 引脚可以不接外部电源。原理说起来不复杂:器件内部有一个整流二极管和储能电容,总线为高电平时,电流通过 DQ 给内部电容充电;总线被拉低时,器件靠电容里存的电维持工作。也就是说,一根数据线既要传数据,又要当电源线,这就是“寄生供电”名字的由来。
寄生供电的限制也来自这里。既然器件靠电容储能,它就不能容忍长时间的低电平,否则电容放完电就会复位或者掉电。所以数据手册会明确要求,在温度转换、拷贝暂存器等大电流操作期间,总线必须保持高电平,而且主机要提供“强上拉”——所谓强上拉,就是用一个小功率 MOS 管或三极管直接给 DQ 供 5V,而不是继续用 4.7kΩ 上拉那么点电流。很多人照着外部供电的例程写代码,温度转换期间让总线随便飘着,结果 DS18B20 转换出来的温度永远是 85°C 或者乱码,多半就是寄生供电下没有拉强上拉。
判断一个器件到底是不是寄生供电,单总线上有个命令叫 READ POWER SUPPLY(0xB4)。流程是复位后发这个功能命令,再读一个时隙:如果总线保持高电平,说明器件有外部电源;如果器件把总线拉低,说明它依靠寄生供电。这样程序就能在运行时自动适配,不用在硬件上猜。
3. 时序详解:时隙世界的微观规则
3.1 复位脉冲和存在脉冲:每次通信的“握手”
单总线上所有通信的第一步都是复位。主机先把总线拉低至少 480µs,然后释放。任何挂在总线上的从机,在检测到这个足够长的低电平后,会等待 15µs 到 60µs,然后自己拉低总线 60µs 到 240µs,这个由从机发出的低电平就叫“存在脉冲”。主机在释放总线之后的 60µs 到 240µs 窗口内采样,如果读到低电平,就知道总线上至少有一个从机在线,可以开始后续命令。
为什么复位低电平要 480µs 这么长?因为单总线没有独立的唤醒信号,从机可能处在各种低功耗状态,必须给足时间让它们苏醒并做好应答准备。同时,480µs 这个宽度也远远大于普通数据时隙的低电平宽度,从机可以由此区分“这是复位”而不是“一个写 0 时隙”。我在调试时经常用逻辑分析仪先把复位波形拉出来看,如果存在脉冲缺失或者位置不对,后面所有命令都不可能有结果,排查顺序永远是先复位后数据。
这里还要注意主机采样点的选择。释放总线后如果采样太早,从机的存在脉冲可能还没拉低;采样太晚,存在脉冲可能已经结束。稳妥的做法是在释放后 60µs 到 70µs 处采样,这个位置处于从机应答窗口的中段,容错空间最大。我自己实现的复位函数一般释放后延时 70µs 再读一次引脚,然后继续等待 410µs,让整个复位窗口自然走完。
3.2 写时隙:写 1 和写 0 的本质区别
单总线的数据读写不是靠电平高低,而是靠一个固定长度时间窗里的低电平持续时间来区分 0 和 1,这个时间窗就叫时隙。每个时隙最短 60µs,主机必须保证在时隙起始时拉低总线。
写 1 时隙的要求很苛刻:主机把总线拉低,但只能维持 1µs 到 15µs,然后立刻释放,让上拉电阻把总线拉回高,剩下的时间里总线保持高电平。从机的采样点大概在时隙开始后的 30µs 到 60µs 之间,它看到的是“总线已经恢复高”,于是判为 1。写 0 时隙则相反,主机需要把总线持续拉低最少 60µs,让采样点看到的是低电平,从而判为 0,之后再释放。
这段逻辑看起来简单,实际翻车最多的地方就是“写 1 的低电平时间过长”。如果主机释放不及时,或者上拉电阻太大导致上升沿太慢,从机在采样点看到的还是低电平,一个好好的 1 就被误判成了 0。我踩过最典型的一个坑:同一套代码在不同主频的 MCU 上跑,空循环延时函数没校准,低电平从原来的 6µs 变成了 40µs,所有指令全部错乱。从那以后,我在任何单总线项目里都会明确写出每个延时对应的实际微秒数,并单独用示波器核对。
3.3 读时隙:主机让位,从机说话
读时隙和写时隙在起始阶段是一样的:主机先把总线拉低 1µs 到 15µs,然后释放。区别在于释放之后,总线控制权交给从机。如果从机要发送 0,它会继续把总线拉低,直到时隙结束;如果从机要发送 1,它什么都不做,让上拉电阻把总线拉回高。主机必须在释放总线后的 15µs 内完成采样,过早没等从机接管,过晚则可能读到下一个时隙的状态。
这里有个很容易犯的错:主机读位时为了保险,把采样点放在时隙末尾,结果读回来全是 1。原因是单总线器件在发送完 0 之后,会在时隙结束前释放总线,如果主机采样太晚,总线已经被上拉电阻拉回高,等于白读。正确做法是宁可采样点在 15µs 以内偏早一点,也不要贪图“稳”而拖到后面。稳定的实现通常是拉低 2µs、释放、再延时 8µs 左右采样,这个时间点既给从机留了接管总线的机会,又没超过 15µs 的采样上限。
另外,读写时隙之间建议留 1µs 以上的恢复时间。很多人写字节时一个时隙接一个时隙,中间没有任何空隙,短距离小负载下可能没问题,但一旦总线电容变大,恢复时间不足就会让电平还没稳定就进入下一个时隙,累积起来就变成偶发错误。规范的做法是在每个位操作末尾补足 60µs 的时隙总长,让采样和切换都有稳定的窗口。
4. ROM 寻址:64 位身份码与多设备搜索算法
4.1 64 位 ROM 码:家族码、序列号和 CRC 各管什么
每一颗单总线器件在出厂时都有一个全球唯一的 64 位 ROM 码,这个码是在激光刻录阶段写死的,用户不能修改。它的布局是:最低字节是家族码,中间 6 个字节是唯一的 48 位序列号,最高字节是 CRC 校验码。家族码用来区分器件类型,比如 DS18B20 是 0x28,DS18S20 是 0x10,DS1990 iButton 是 0x01,DS2431 EEPROM 是 0x2D。主机拿到 8 个字节之后,先看家族码就知道接下来该执行哪一套功能命令。
CRC 字节的作用是校验前面 7 个字节有没有被读错。单总线的 CRC-8 多项式是 x^8 + x^5 + x^4 + 1,初始化值为 0,采用 LSB-first 的位处理方式,在代码里常用 0x8C 这个反射多项式来做异或运算。校验通过不代表 ROM 码一定正确,但校验失败几乎可以断定是时序问题或者总线干扰。我的习惯是:所有从总线上读回来的多字节数据(ROM 码、暂存器内容)先跑一遍 CRC,再决定要不要使用,宁可在软件里多花几微秒,也不要让脏数据进入业务逻辑。
下面是单总线 CRC-8 的一个常见实现,这段代码也可以用在校验 DS18B20 暂存器的 9 个字节上:
uint8_t owCrc8(const uint8_t *data, uint8_t len) { uint8_t crc = 0; while (len--) { uint8_t in = *data++; for (uint8_t i = 0; i < 8; i++) { uint8_t mix = (crc ^ in) & 0x01; crc >>= 1; if (mix) crc ^= 0x8C; in >>= 1; } } return crc; }4.2 四类基本 ROM 指令:什么时候该用哪一条
主机在和器件通信之前,必须先发一条 ROM 指令,用来选择“跟谁说话”。单总线最基本的 ROM 指令有四条:
| 指令 | 名称 | 功能 | 使用限制 |
|---|---|---|---|
| 0x33 | READ ROM | 直接读取 64 位 ROM 码 | 总线上只能有 1 个设备 |
| 0x55 | MATCH ROM | 按 64 位 ROM 码精确匹配设备 | 后面必须跟 8 字节 ROM 码 |
| 0xCC | SKIP ROM | 跳过寻址,直接访问所有设备 | 常用于总线单设备,广播功能命令需谨慎 |
| 0xF0 | SEARCH ROM | 搜索总线上所有设备的 ROM 码 | 适合多设备自动发现 |
| 0xEC | ALARM SEARCH | 只搜索满足报警条件的设备 | 适合温控报警巡检 |
用 SKIP ROM 的时候要格外小心。如果总线上挂了多颗 DS18B20,主机发 0xCC 再发 CONVERT T,所有传感器会同时开始转换、同时尝试把结果放到同一根总线上,读回来的数据必然互相打架。正确做法是先用 SEARCH ROM 把所有 ROM 码枚举出来,再用 MATCH ROM 逐个点名访问。如果系统固定只挂一个设备,SKIP ROM 才能安全使用。
4.3 SEARCH ROM 搜索算法:为什么每位要读两次
多设备识别是单总线最精彩的部分。SEARCH ROM 的思路是“按位分叉”:主机把每一位都读两次,第一次读到的是所有设备在该位的实际值,第二次读到的是实际值的取反。如果总线上只有一位设备,两次读回的结果必然互补,要么是 0 和 1,要么是 1 和 0;如果总线上有多位设备且某些设备在该位上是 0、另一些是 1,两次读回都会是 0,这一位就发生了“冲突”,也就是总线同时出现了 0 和 1。
主机在冲突位必须做出选择:写回 0 还是写回 1,这个写回动作会把选择广播给所有设备,只有 ROM 码与写回方向一致的设备继续参与后续位的比较,另一分支的设备进入“旁观”状态。在一遍搜索结束后,主机就拿到了一个完整 ROM 码。要想拿到下一个 ROM 码,主机需要回到上一个冲突位,改写方向再搜一遍。这个过程本质上就是在一棵二叉树上做深度优先遍历,只是一切都发生在逐位读写里。
SEARCH ROM 算法里最核心的状态变量是“上一个冲突位”。第一遍搜索时,遇到冲突一律选 0,并记录最后一个被选 0 的冲突位;下一遍搜索时,在那个记录的位选 1,之前的冲突位沿用上一轮的路径,之后的冲突位再继续选 0。如此往复,直到某遍搜索全程没有遇到冲突位,说明所有设备都枚举完了。
4.4 SEARCH ROM 代码实现:一份可直接改用的参考
下面这份实现改编自广泛使用的 OneWire 公开库搜索逻辑,去掉了硬件平台相关内容,只保留算法骨架。它每调用一次返回一个 ROM 码,配合外部循环不断调用,直到返回 0 表示搜索结束。
static uint8_t searchLastDiscrepancy = 0; uint8_t owSearchRom(uint8_t *romId) { uint8_t byteIdx = 0, mask = 0x01; uint8_t bitIdx = 0; uint8_t idBit, cmpBit, dir; uint8_t lastZero = 0; if (owReset() == 0) return 0; // 总线无设备 owWriteByte(0xF0); // SEARCH ROM do { idBit = owReadBit(); cmpBit = owReadBit(); if (idBit && cmpBit) break; // 读回 1,1:总线异常 if (idBit != cmpBit) { dir = idBit; // 所有设备这一位一致 } else { // 00:冲突位,需要决策 if (bitIdx < searchLastDiscrepancy) { dir = (romId[byteIdx] & mask) ? 1 : 0; // 沿用之前方向 } else { dir = (bitIdx == searchLastDiscrepancy); } if (dir == 0) lastZero = bitIdx; // first search时 searchLastDiscrepancy=0,冲突位默认选1, // 后续根据 lastZero 回溯另一分支 } owWriteBit(dir); if (dir) romId[byteIdx] |= mask; else romId[byteIdx] &= ~mask; bitIdx++; mask <<= 1; if (mask == 0) { mask = 1; byteIdx++; } } while (byteIdx < 8); if (bitIdx < 64) return 0; // 异常中断 searchLastDiscrepancy = lastZero; return 1; // 找到一个设备 }调用方式很简单:先把所有 ROM 码清零,循环调用owSearchRom,每成功一次就保存一份地址,直到返回 0。第一次调用前要把全局searchLastDiscrepancy初始化为 0。这段代码把搜索逻辑压缩到了最短,生产项目可以以此为基础,再补上超时保护、异常恢复和“无更多设备”标志,就能应对绝大多数多设备采集场景。
5. 完整实操:DS18B20 多设备读取的最小工程
5.1 硬件准备与接线
动手之前先把硬件理清楚。一个典型的多点测温系统只需要四样东西:MCU 开发板、一个 4.7kΩ 电阻、若干 DS18B20、以及连接线。MCU 的 GPIO 口接到 DQ 总线,4.7kΩ 电阻一端接 VCC(一般 3.3V 或 5V),另一端接 DQ,所有 DS18B20 的 DQ 引脚并联到同一条线,GND 全部共地。如果传感器离主机比较远,优先用双绞线,并且每米线缆的电容越小越好。
软件上最需要注意的是 GPIO 的方向切换。读总线时要让引脚变成输入模式,让从机可以把总线拉低;写总线时要变成输出模式,主动拉低或释放。很多 MCU 的库函数切换模式有几十微秒的开销,这在单总线位操作里是非常致命的,所以生产代码建议直接操作寄存器,绕过库层抽象。如果开发平台的库函数实在绕不开,也要用开关中断的方式把每次位操作的临界区保护起来,防止切换模式过程中调度器插队。
5.2 核心位操作与时序函数
下面给出一套完整的最小实现,基于宏抽象 GPIO,方便移植到 STM32、GD32、ESP32 等平台。使用时只需要把OW_LOW、OW_HIGH、OW_RELEASE、OW_RD四个宏对接到底层引脚操作上即可。
#define OW_LOW() do { ow_dir_out(); ow_write(0); } while (0) #define OW_HIGH() do { ow_dir_out(); ow_write(1); } while (0) #define OW_RELEASE() do { ow_dir_in(); } while (0) #define OW_RD() ow_read() void delay_us(uint32_t us) { // 必须校准,建议用 SysTick 或 DWT,不要用简单的空循环 } uint8_t owReset(void) { uint8_t presence; OW_LOW(); delay_us(480); // 主机拉低至少 480us OW_RELEASE(); delay_us(70); // 等待存在脉冲 presence = (OW_RD() == 0); // 采样存在脉冲 delay_us(410); // 让复位窗口完整结束 return presence; } void owWriteBit(uint8_t bit) { if (bit) { OW_LOW(); delay_us(6); // 低电平 1~15us OW_RELEASE(); delay_us(64); // 补足时隙 } else { OW_LOW(); delay_us(60); // 低电平至少 60us OW_RELEASE(); delay_us(10); } } uint8_t owReadBit(void) { uint8_t val; OW_LOW(); delay_us(2); // 起始低电平 1~15us OW_RELEASE(); delay_us(8); // 释放后 15us 内采样 val = OW_RD(); delay_us(50); // 补足时隙 return val ? 1 : 0; } void owWriteByte(uint8_t byte) { for (uint8_t i = 0; i < 8; i++) { owWriteBit(byte & 0x01); byte >>= 1; } } uint8_t owReadByte(void) { uint8_t val = 0; for (uint8_t i = 0; i < 8; i++) { if (owReadBit()) val |= (1 << i); } return val; }注意:不同 MCU 的 GPIO 翻转速度、库函数延时精度差异很大,上面代码里的 6µs、8µs、60µs 这些数值不是死参数。第一次移植时务必用示波器确认实际低电平宽度和采样点位置,再按实测微调。
5.3 单设备温度读取:从 ROM 跳过到暂存器解析
单设备场景直接用 SKIP ROM 最省事。复位后发 0xCC 跳过寻址,再发 0x44 启动温度转换,等待转换完成(12 位分辨率大约 750ms),再次复位后发 0xCC 和 0xBE 读取暂存器。暂存器一共 9 个字节,前两个字节是温度值的低字节和高字节,第九字节是 CRC。温度数据是 16 位有符号数,低 4 位是小数部分,实际温度等于raw / 16.0。
float readTempSingle(void) { uint8_t sp[9]; if (owReset() == 0) return -999; owWriteByte(0xCC); // SKIP ROM owWriteByte(0x44); // CONVERT T delay_ms(750); // 外部供电模式直接延时 if (owReset() == 0) return -999; owWriteByte(0xCC); owWriteByte(0xBE); // READ SCRATCHPAD for (uint8_t i = 0; i < 9; i++) sp[i] = owReadByte(); if (owCrc8(sp, 8) != sp[8]) return -999; int16_t raw = (int16_t)((sp[1] << 8) | sp[0]); return raw / 16.0f; }负温度的处理很多人会栽跟头。DS18B20 的负温度用二进制补码表示,比如 -0.5°C 对应的 16 位值是 0xFFF8,直接把两个字节拼成uint16_t再除以 16,会得到 4095.5 这种离谱结果。正确做法是先把两个字节组合成int16_t,利用 C 语言有符号整数的类型转换自动得到带符号的补码值,然后再除以 16.0。
5.4 多设备采集编排:搜索、匹配、逐个点名
多设备流程比单设备多一步“点名”。完整流程分成三个阶段:先复位并发 SEARCH ROM,把所有传感器的 ROM 码收集到一个数组里;然后对每个 ROM 码,依次执行复位、发 MATCH ROM、把 8 字节 ROM 码逐个发送、发 CONVERT T;最后再复位、发 MATCH ROM、读暂存器并解析温度。这样每颗传感器被独立点名,互不干扰。
需要特别提醒的是,温度转换命令发出后,如果使用外部供电,可以通过读取总线电平判断转换是否完成:DS18B20 在转换期间把总线拉低,转换结束释放总线。用while (owReadBit() == 0);等待可以省去固定延时,提升效率。但如果是寄生供电模式,这个等待策略不能直接用,因为转换期间总线必须被强上拉维持电源,此时要切换强上拉电路,等到转换结束后再恢复普通上拉,否则传感器会因为“饿电”而转换失败。
6. 常见问题与排查技巧实录
6.1 故障速查表:现象、原因、对策
单总线开发的问题有很强的共性,我整理了这几年遇到频率最高的几类:
| 现象 | 可能原因 | 排查与对策 |
|---|---|---|
| 复位检测不到存在脉冲 | 上拉电阻缺失或没接 VCC | 检查 DQ 到 VCC 之间是否有 4.7kΩ,测量空闲电平应为高 |
| 读回全是 0xFF | 总线没上拉或线序接反 | 用万用表确认 DQ/GND/VCC 三根线,重点看 DQ 空闲是否接近电源电压 |
| 单设备正常,多设备全乱 | 用 SKIP ROM 执行了广播功能命令 | 改用 SEARCH ROM 枚举地址,再用 MATCH ROM 点名 |
| 偶发数据错误 | 延时函数未校准或中断打扰 | 关闭位操作期间的中断,用 SysTick/DWT 校准延时 |
| 温度固定 85°C 或乱码 | 寄生供电下转换期间缺少强上拉 | 检查外部供电或增加强上拉电路 |
| 换主频后全部失效 | 空循环延时假设错误 | 不要依赖编译器优化行为,实测每个延时点的微秒数 |
| 长线时波形上升沿很缓 | 上拉电阻偏大、线缆电容大 | 换 2.2kΩ,必要时用桥接芯片 |
排查这类问题,我强烈建议先用万用表确认“总线空闲电平”,再看“复位波形”,最后才看数据时隙。很多人一上来就盯数据位,结果查半天才发现是上拉没接,白耽误时间。
6.2 示波器验证三件事:电平、时序、采样点
如果你有一台示波器,单总线的调试会快速很多。拿到一块新板子,我做的第一件事是复位后测量 DQ 的空闲电平,确认它在高电平范围。第二件事是抓一次复位波形,看主机拉低是否够 480µs,从机的存在脉冲是否在释放后 15µs 到 60µs 内出现,宽度是否在 60µs 到 240µs。第三件事是抓写 1 时隙,重点看主机释放之后总线能否快速回升到高,如果在采样点附近还在爬坡,就要考虑减小上拉电阻。
逻辑分析仪也很好用。把采样率调到 1MHz 以上,抓一段复位和几个时隙,用光标量每个低电平宽度。我通常会抓一条完整的读暂存器命令,对照数据手册逐位核对:写地址位、读数据位,每一位的电平宽度对不对。只要这段波形对了,上层数据解析出的温度基本不会错。
6.3 最容易翻车的三个低级错误
第一个低级错误是 GPIO 模式配错。很多 MCU 的引脚默认是模拟输入或推挽输出,推挽输出模式下如果主机一直输出高电平,从机根本没有机会把总线拉低,通信一开始就废了。正确做法是写时序时把引脚切成输出模式,读时序和释放时切成输入模式,而且这个切换必须在每个位操作里严丝合缝地完成。
第二个低级错误是编译优化改变空循环延时。同一段for循环,在 -O0 和 -O2 下的实际延时可能差好几倍。我见过一个项目在调试版本里一切正常,切到 release 版本后单总线完全认不到设备,最后查出来就是优化后空循环被编译器“聪明”地省略或压缩了。解决方法是使用硬件定时器延时、volatile变量计数,或者干脆用汇编级别的 NOP 来保证时序稳定。
第三个低级错误是中断处理造成位操作中途被打断。单总线的一个时隙才几十微秒,如果刚好在写 1 的低电平持续期间来了一个高优先级中断,中断处理几十微秒,总线状态就被破坏了。正确做法是在一个字节或一个完整位操作的临界区内关中断,退出后再开。像温度转换这种毫秒级等待不需要关中断,但位操作期间一定不能被中断插队。
最后再分享一个小技巧:如果系统对温度采集速度有要求,可以把 DS18B20 的分辨率从默认的 12 位降到 10 位甚至 9 位。12 位分辨率转换时间最长 750ms,9 位只要 93.75ms,速度差距接近 8 倍。写入配置寄存器的操作是发 0x4E 写暂存器,把配置字节对应分辨率位改掉,再做一次 0x48 拷贝暂存器让它保存到 EEPROM。代价是分辨率从 0.0625°C 降到 0.5°C,但对大多数恒温、温升监控场景来说完全够用。开发后期我一般会把分辨率参数做成可配置项,量产固件里预留这个开关,调试和交付两不误。