做嵌入式这些年,我写过不少传感器驱动,I2C、SPI、UART都有现成库,唯独单总线协议总是得自己操刀。MY18E20这颗芯片我是在一个温控项目里用上的,它基本沿用了DS18B20的电气特性和寄存器设计,但细节上又有自己的脾气。当时手头没有现成的MicroPython驱动,网上的DS18B20代码改过来也能跑,但总感觉没吃透协议,出了问题只能瞎猜。后来花了一个周末把单总线协议从头到尾撸了一遍,重新用MicroPython写了一套驱动,项目才算真正稳下来。
这篇东西就是那次实践的完整复盘。我会从单总线的物理时序讲起,把初始化、读写时隙、ROM寻址、暂存器操作、CRC校验这些底层机制全部摊开,然后给出可以直接抄作业的MicroPython驱动代码,最后附上实测中踩过的坑。适合三种人看:第一次碰单总线传感器、想搞懂协议原理的初学者;在MicroPython上写时序敏感驱动但总觉得不踏实的嵌入式开发者;以及要在项目里批量用MY18E20或兼容芯片、需要稳定驱动做底座的工程师。如果你是DS18B20的老用户,这篇也能帮你补上不少手册里没写透的细节。
1. 先说清楚:MY18E20到底是个什么东西
1.1 DS18B20兼容生态与MY18E20的定位
MY18E20从命名就能猜到它和Maxim的DS18B20血缘很近。这类国产单总线温度传感器在国内市场已经形成了完整的兼容生态,引脚定义、封装尺寸、单总线通信协议甚至寄存器地址都刻意向原厂看齐,目的就是让老项目能无缝切换。实际用下来,MY18E20在12位分辨率下的温度精度标称是±0.5℃,典型测量范围-55℃到+125℃,和DS18B20的经典参数基本对得上。
但"兼容"不等于"完全一致"。国产芯片在具体时序参数上往往留有更宽的容差,有的对复位脉冲的响应时间、读时隙的数据建立时间做了调整。这类差异在低速、短距离的测试环境里根本看不出来,一旦线长超过1米、上拉电阻取值不合理或者总线上挂了多个设备,问题就会成倍放大。所以我的建议是:项目选型阶段就把MY18E20当"另一个传感器"来调试,而不是默认它跟DS18B20一模一样。
1.2 为什么单总线协议值得你亲手写一次驱动
很多开发者第一次接触单总线传感器,第一反应是去GitHub搜现成驱动。这没有错,但如果你准备在产品里批量使用,我强烈建议自己写一遍。原因有三:第一,单总线对时序极其敏感,现成驱动如果是为特定平台调过的,换个芯片型号或者板卡就可能出现偶发读错数据的情况;第二,单总线的调试几乎离不开逻辑分析仪,自己写过驱动、亲手看过波形之后,遇到问题才知道从哪个环节入手;第三,单总线协议本身非常精巧,用一根线同时完成供电、通信和设备寻址,理解它对嵌入式底层能力的提升比调十个I2C传感器都大。
MicroPython因为运行在解释层,很多人一听就觉得没法写时序敏感的驱动。实际情况没那么绝对,只要不是对微秒级的每个比特都要求极致精确,MicroPython配合machine模块的Pin操作完全能覆盖单总线通信的需求。关键是要知道哪些语句开销大、哪些平台存在中断延迟风险,这些我在第四章会详细说。
1.3 它和I2C/SPI的本质差异
单总线协议和I2C/SPI最大的区别,从字面就能看出来:一根线。I2C至少需要SDA和SCL两根线,且总有主从之分;SPI则至少三根线(MOSI、MISO、SCK)。单总线把时钟和数据合并到一根DQ线上,通信双方靠精确的时间窗口来区分"这是命令""这是数据"以及"这一位是0还是1"。
这种设计的直接代价就是时序协议异常复杂。I2C的时钟由SCL显式提供,从机只要在边沿采样就行;单总线没有独立的时钟线,数据有效性完全依赖主机产生的时隙长度。主机拉低多久、释放多久、在哪个时间点采样,都有严格规定。也正因如此,单总线的通信速率并不高,常规模式下一位大约60到120微秒,一条完整命令加上数据响应往往要几毫秒。对温度这种低频采样对象完全够用,但别指望它能当高速总线用。
2. 单总线协议根层拆解:从物理时序到ROM指令树
2.1 总线结构与空闲、复位、存在脉冲
单总线系统的物理结构并不复杂:主机和所有从机共享一根DQ线,正常工作时外部需要接一个上拉电阻到VCC,阻值通常在4.7kΩ左右。总线的空闲状态是高电平,所有从机都处于高阻状态,不主动驱动总线。通信由主机主导,每次通信必须从初始化开始。
初始化的过程俗称为"复位脉冲+存在脉冲"。主机先把总线拉低至少480微秒(数据手册的标准范围是480到960微秒),然后释放总线,让上拉电阻把电平拉回高。从机检测到这个下降沿后会等待一段时间,然后主动把总线拉低60到240微秒,形成一个存在脉冲。主机在释放总线之后的60到240微秒窗口内采样电平,如果读到低电平,说明总线上至少有一个从机在线。
这个环节有个容易被忽略的细节:主机拉低的时间如果太短,从机可能根本没检测到复位;如果采样窗口太靠前,可能还没等从机拉低就已经采样完成。我在实测中习惯把主机复位拉低时间定在500微秒左右,释放之后延时70微秒再开始采样,基本能覆盖市面常见兼容芯片的响应范围。
2.2 写时隙与读时隙的时序参数
初始化完成后,所有通信都通过"时隙"完成。每个时隙传输一位数据,写时隙和读时隙的时序规则不同。
写1的流程是:主机把总线拉低1到15微秒,然后释放总线,让上拉电阻把电平拉回高,总时隙长度至少60微秒。从机在主机拉低后的15到60微秒窗口内采样,如果此时总线是高电平,就认为这一位是1。写0的流程则简单粗暴:主机把总线拉低,并且保持拉低状态至少60微秒,整个时隙内总线都是低电平,从机一采样就知道收到的是0。
读时隙的时序是:主机先把总线拉低1到15微秒,然后释放总线并尽快切换到输入模式准备采样。从机在检测到这个下降沿后,如果要发送0,会主动把总线拉低并保持到整个时隙结束;如果要发送1,则什么都不做,总线在上拉电阻作用下回到高电平。主机必须在拉低后的15到60微秒窗口内完成采样,采样点越靠后,总线上的信号越稳定,但要小心别超过从机释放总线的时间。
这里可以用一个生活化的类比来记:写1就像"拍一下就走",写0就像"按住不放";读数据则是主机喊一声"该你说了",然后竖起耳朵听从机是沉默还是按住。MicroPython里实现这些时序时,最大的考验就是保证每个拉低、释放、延时的Duration都落在允许窗口内,具体做法第四章会讲。
2.3 ROM命令与寄存器地图
单总线协议在初始化之后、读写暂存器之前,必须先发送ROM命令。ROM命令的作用是选择与哪一个从机通信,因为总线上可能挂多个设备。常用的ROM命令有五条:
- 0x33 Read ROM:只有总线上仅有一个从机时才能用,直接读出该设备的64位唯一序列号。
- 0x55 Match ROM:后跟64位序列号,只对序列号匹配的从机生效。
- 0xCC Skip ROM:跳过ROM匹配,广播给所有设备。总线上只有一个设备时最常用。
- 0xF0 Search ROM:用二叉树搜索算法逐位扫描,找出总线上所有设备的序列号。
- 0xEC Alarm Search:类似于Search ROM,但只查找温度越限的设备。
对于MY18E20这类兼容芯片,功能命令和DS18B20也是一致的。最核心的是0x44发起温度转换、0xBE读取暂存器、0x4E写入暂存器、0x48把暂存器复制到EEPROM、0xB8把EEPROM内容召回暂存器、0xB4查询供电模式。实际开发中90%的操作就是两个动作:发0xCC跳过寻址、发0x44开始转换,等转换完成再发0xCC、0xBE读回9字节暂存器数据。只有总线挂多设备时才需要动用Match ROM或Search ROM。
2.4 Search ROM:在一根线上摸清所有设备
Search ROM是整个单总线协议里最烧脑、但也是最有意思的部分。它的本质是让主机在一根线上快速枚举所有从机的64位序列号。算法核心是一个逐位决策的二叉树遍历:
- 主机发出Search ROM命令0xF0。
- 对序列号的每一位,主机发出两个读时隙:第一个读时隙读取该位的实际值,第二个读时隙读取该位的反码。
- 如果第一个时隙读到0、第二个时隙读到1,说明该位为0;如果第一个读到1、第二个读到0,说明该位为1;如果两位都读到0,说明总线上既有该位为0的设备也有为1的设备,发生了冲突,需要主机自己决定先走哪条分支。
- 主机根据冲突位决策写入一个方向位,然后继续下一位。
程序上维护一个路径栈,记录每一步选择了哪个冲突分支,就能在一轮遍历后找到第一个设备;回退到上一个冲突点走另一条分支,就能找到下一个设备,直到所有分支走完。MicroPython实现时要注意每次迭代都要完整重发0xF0命令并重新初始化,因为设备在搜索过程中不会一直保持响应状态。
3. 温度转换背后的计算逻辑与CRC校验
3.1 Scratchpad暂存器的九个字节如何用
MY18E20的暂存器一共9个字节,分别是:
| 字节 | 名称 | 说明 |
|---|---|---|
| 0 | 温度值低字节 | 温度数据的低8位 |
| 1 | 温度值高字节 | 温度数据的高8位,含符号位 |
| 2 | TH用户字节 | 高温报警阈值 |
| 3 | TL用户字节 | 低温报警阈值 |
| 4 | 配置寄存器 | 控制转换分辨率 |
| 5 | 保留 | 固定为0xFF |
| 6 | 保留 | 固定为0x1F(部分芯片) |
| 7 | 保留 | 固定为0xFF |
| 8 | CRC校验值 | 前8个字节的CRC-8 |
实际开发中,温度转换完成后主机发0xBE,从机从第0字节开始连续输出这9个字节。即使你只需要温度值,也建议把9个字节全部读完,至少把最后一个CRC字节读回来做完整性校验。很多偶发的-55℃、+125℃这种异常读数,根源就是数据链路瞬间干扰导致某个位跳变,如果有CRC校验,这类坏数据能直接过滤掉。
3.2 12位分辨率温度值的符号扩展
MY18E20默认工作在12位分辨率,温度值以16位有符号数形式存放在暂存器的第0和第1字节。低字节在前,高字节在后,转换公式是:
实际温度 = 16位有符号整数值 ÷ 16
举个例子:读到的低字节是0x50、高字节是0x07,组合起来是0x0750,十进制为1872,除以16得到117.0℃。如果读到的值是0xFC90,高字节最高位是1,说明是负数,先转成有符号数再除以16,结果是-55.0℃。
MicroPython里处理符号扩展有个简写法:把两个字节拼成16位值之后,如果大于等于0x8000就减去0x10000,就得到了有符号整型。这个逻辑在第四章代码里会直接体现。需要留意的是,配置寄存器的低两位决定了分辨率,00是9位、01是10位、10是11位、11是12位。不同分辨率下LSB对应的温度步长不一样:9位分辨率下LSB是0.5℃,10位是0.25℃,11位是0.125℃,12位是0.0625℃。如果你的应用要求快速转换,把分辨率降到9位或者10位能让一次转换时间从750毫秒左右缩短到100毫秒以内。
3.3 CRC-8校验电路与查表实现
单总线的CRC-8用于校验读取的暂存器数据是否完整正确。生成多项式是x^8 + x^5 + x^4 + 1,对应二进制0x31,初始值通常为0。别被"多项式"这个名词吓到,用移位寄存器的思路完全可以手算:初始CRC为0,对每个输入字节,把CRC与输入字节异或,然后循环8次,每次根据最高位决定是否与0x31再异或。
在MicroPython这种解释型语言里,逐位计算CRC虽然正确,但每次读9字节数据都要跑72次循环,效率偏低。更推荐的做法是查表法:预生成一个256项的CRC表,每个字节进来只需要查一次表做三次异或运算。代码起来也不复杂:
def _build_crc_table(): table = [] for i in range(256): crc = i for _ in range(8): if crc & 0x80: crc = ((crc << 1) ^ 0x31) & 0xFF else: crc = (crc << 1) & 0xFF table.append(crc) return table _CRC_TABLE = _build_crc_table() def crc8(data): crc = 0 for b in data: crc = _CRC_TABLE[(crc ^ b) & 0xFF] return crc我建议把CRC校验作为驱动的必备能力,而不是可选项。虽然单总线在短距离下误码率不高,但一旦遇到电机启动、继电器吸合这类电磁干扰场景,CRC就是你的最后一道防线。
4. 用MicroPython逐行手写MY18E20驱动
4.1 MicroPython写时序敏感驱动的可行性判断
写驱动之前,先解决一个灵魂问题:MicroPython这种解释型语言,能不能稳得住单总线时序?
我的结论是:能,但前提是你对平台的时序开销心里有数。MicroPython每条Python语句都要经过词法解析、编译、解释执行,单条语句的耗时可能达到几微秒甚至几十微秒。这在普通业务逻辑里无所谓,但在单总线的读时隙场景里,15到60微秒的采样窗口会被挤掉大半。
解决办法有两个方向。一是尽可能用机器码级别的操作替代Python循环:比如在ESP32上使用@micropython.viper装饰器把关键函数编译成机器码,读时隙的耗时能明显下降。二是接受Python层的延迟,把采样时间点往窗口中间修正:既然每条语句都有固定开销,就在代码里做减法,把无意义的延时压到最小。实测中,同样的驱动在ESP32和RP2040上的表现会有差异,原因是两个平台的MicroPython解释器执行速度不同。最稳妥的做法并不是盲目追求极速,而是先拿逻辑分析仪观察实际波形,再反向调代码。
4.2 OneWire总线底层:位时序和字节时序
驱动底层只需要做一个总线类,核心是四个方法:复位、写一位、读一位、写一个字节。下面是适配ESP32和RP2040的基础实现:
from machine import Pin import time class OneWireBus: def __init__(self, pin_num): self.pin = Pin(pin_num, Pin.OPEN_DRAIN, Pin.PULL_UP) self.reset() def reset(self): self.pin.value(0) time.sleep_us(500) self.pin.value(1) time.sleep_us(70) present = (self.pin.value() == 0) time.sleep_us(410) return present def write_bit(self, value): if value: self.pin.value(0) time.sleep_us(6) self.pin.value(1) time.sleep_us(64) else: self.pin.value(0) time.sleep_us(60) self.pin.value(1) time.sleep_us(6) def read_bit(self): self.pin.value(0) time.sleep_us(6) self.pin.value(1) time.sleep_us(9) bit = self.pin.value() time.sleep_us(55) return bit def write_byte(self, value): for i in range(8): self.write_bit((value >> i) & 1) def read_byte(self): value = 0 for i in range(8): if self.read_bit(): value |= (1 << i) return value这个实现里的延时参数是经过实测修正的。MicroPython的time.sleep_us精度有限,实际延时往往会比参数值偏大一些,所以我在写1时隙之后留出了64微秒的等待,确保整个时隙落在60到120微秒的合法范围内。读时隙里先拉低6微秒再释放,再等9微秒后采样,采样点大约在第15微秒,正好落在从机数据有效的起始位置附近,给后面的Python语句开销留出余量。
需要特别说明的一点:这里把Pin配置成了开漏模式并启用内部上拉。开漏模式意味着拉低是主动驱动,释放后由电阻恢复高电平,这正好符合单总线的电气要求。内部上拉一般只有几十千欧,对于很长的线缆可能偏弱,最好在硬件上再并联一个4.7kΩ外部上拉电阻。
4.3 MY18E20传感器类:初始化、转换、读取与配置
有了总线底层,传感器类就很清晰了。这个类的职责是:跳过ROM、发起转换、等待完成、读取暂存器、解析温度、校验CRC。下面是我实测过的完整实现:
class MY18E20: def __init__(self, bus, resolution=12): self.bus = bus self._set_resolution(resolution) def _set_resolution(self, resolution): if resolution == 9: config = 0x00 elif resolution == 10: config = 0x20 elif resolution == 11: config = 0x40 else: config = 0x7F self.bus.reset() self.bus.write_byte(0xCC) self.bus.write_byte(0x4E) self.bus.write_byte(0xFF) self.bus.write_byte(0xFF) self.bus.write_byte(config) def start_conversion(self): self.bus.reset() self.bus.write_byte(0xCC) self.bus.write_byte(0x44) def read_temperature(self): data = self._read_scratchpad() if data is None: return None raw = (data[1] << 8) | data[0] if raw & 0x8000: raw -= 0x10000 return raw / 16.0 def _read_scratchpad(self): self.bus.reset() self.bus.write_byte(0xCC) self.bus.write_byte(0xBE) data = bytearray(9) for i in range(9): data[i] = self.bus.read_byte() if crc8(data[:8]) != data[8]: return None return data def wait_conversion(self, timeout_ms=1000): elapsed = 0 while True: self.bus.reset() self.bus.write_byte(0xCC) self.bus.write_byte(0xB4) if self.bus.read_bit(): break time.sleep_ms(10) elapsed += 10 if elapsed > timeout_ms: return False return True有几处设计细节值得解释。第一,_set_resolution里往TH和TL写了0xFF,这样保险起见不会触发任何报警阈值比较,避免某些芯片因为EEPROM里残留了错误的报警值而产生报警搜索匹配。第二,read_temperature先经过CRC校验,返回None表示数据异常,调用方可以根据业务逻辑选择重试还是丢弃。第三,wait_conversion并没有简单固定延时750毫秒,而是通过轮询0xB4读供电模式命令检测设备是否完成转换,这样代码既适配12位分辨率也适配快速模式,而且不会在低分辨率下白等。
4.4 Search ROM在MicroPython里的落地实现
如果总线上挂了多个MY18E20,Skip ROM就失效了,需要先枚举所有设备的序列号,再逐个用Match ROM访问。Search ROM的实现比前面所有代码都复杂一些,但整个算法只有几十行:
def scan_rom(bus): devices = [] last_fork = -1 path = 0 while True: bus.reset() bus.write_byte(0xF0) bit_pos = 0 current = 0 fork_pos = -1 while bit_pos < 64: bit_master = _read_search_bit(bus) bit_inverse = _read_search_bit(bus) if bit_master and bit_inverse: return devices if bit_master or bit_inverse: choice = 1 if bit_master else 0 else: mask = 1 << (bit_pos & 7) if bit_pos < last_fork: choice = 0 if (path & mask) else 1 elif bit_pos == last_fork: choice = 1 if (path & mask) else 0 else: choice = 0 fork_pos = bit_pos if choice: path |= mask if choice: current |= (1 << (bit_pos & 7)) bus.write_bit(choice) bit_pos += 1 devices.append(current) if fork_pos == -1: return devices last_fork = fork_pos这个代码的核心思想不复杂:每次搜索都从根节点开始遍历二叉树,遇到冲突位就做一次分支决策;找不到新的分叉点,说明所有设备都已经枚举完成。实际使用中,建议把search得到的结果缓存起来,因为总线上的设备一般不会频繁变动,没有必要每次读温度都重新扫描。
5. 实测验证与避坑清单
5.1 逻辑分析仪看到的现象
驱动写完后,第一步不是看温度读数,而是用逻辑分析仪抓波形。我把DQ线接到逻辑分析仪的通道上,在16MHz采样率下抓取一次完整的"复位 + 0xCC + 0x44"操作。正常情况下会看到三段特征明显的波形:最前面是约500微秒的低电平复位脉冲和后续的存在脉冲;接着是8个写时隙,每个写时隙都是由一个短脉冲开头、后面跟着高电平,符合写1的特征;最后是8个写1时隙和8个写0时隙交替的0x44字节。
我遇到过一种典型的翻车波形:写0时隙的拉低时间只有40多微秒,明显不够60微秒的底线。原因是代码里写0时用了两次value操作之间夹sleep_us(60),但Python解释器执行赋值语句本身就有开销,实际拉低时间被压缩了。修正办法是手动把sleep_us参数加到70或者80,以实际波形为准。这类问题光看代码很难发现,没有逻辑分析仪基本只能靠猜。
5.2 容易翻车的三个坑:上拉电阻、采样窗口、总线冲突
先说上拉电阻。很多人直接用开发板自带的内部上拉,省了外部电阻。这个方法在实验板上能跑,但线一长就出幺蛾子。内部上拉阻值通常几十千欧,对线缆寄生电容的充电速度太慢,读时隙里总线可能还没回到高电平就被采样了,导致本来该读1的位读成0。我的经验是外面并联一个4.7kΩ电阻,超过50厘米的线缆再降一点到2.2kΩ,实测波形明显更利落。
再说采样窗口。读时隙里从机拉低总线的时间窗口有限,主机采样太早可能读到的还是上一位的电平残留,采样太晚可能从机已经释放总线。MicroPython因为解释开销不稳定,采样点往往偏晚。所以我习惯在read_bit里把采样延时刻意压缩到9微秒左右,宁可让采样点稍微靠前,也要避免读到总线释放后的高电平噪声。实际取舍以波形为准。
最后是总线冲突。多个设备共用一根线时,如果程序里忘了Match ROM而直接发Skip ROM广播,所有设备会同时响应0x44转换命令,这没问题;但如果你用0xBE读暂存器,多个设备会同时往总线上输出数据,波形直接乱掉。所以总线挂多设备时,匹配ROM这一步绝对不能省略。
5.3 原厂芯片和兼容芯片混用的处理方式
实际项目里完全用同一批芯片的情况不多,更常见的是库存里既有原厂DS18B20又有MY18E20,甚至有几个其他国产兼容型号。混用的风险在于不同厂家的时序参数标称值略有差异,但实测下来只要主机时序落在标准范围内,它们基本都能正常响应。
真正要留意的是转换时间。原厂DS18B20在12位分辨率下典型转换时间是750毫秒,一些国产兼容芯片会略微超一点。如果代码里用的是固定延时,建议把等待时间放到850到900毫秒,或者干脆用轮询0xB4的方式,后者最稳妥。此外不同芯片的EEPROM写寿命也有差异,不要在生产测试里频繁调分辨率或者写报警阈值,工业现场写入几千次之后就可能出现EEPROM写入失败。
6. 驱动的进阶扩展思路
6.1 多设备轮询与异常重试机制
驱动跑通后,我一般会把单次读取封装成带重试的轮询函数。逻辑很简单:连续读取三次,如果三次都是None或者有三秒以上读不到数据,就上报设备离线。单总线传感器在工业环境里偶尔被干扰实在太正常了,靠重试比靠硬件滤波要省事得多。轮询节奏上,正常业务1秒采样一次足够,不用每次都等满转换时间,可以提前一帧发起转换,下一帧再读结果,这样采样率能稳定在1Hz以上。
6.2 低功耗唤醒与总线释放
电池供电的设备要注意一个细节:温度转换期间传感器自己会消耗大约1mA的电流,如果用的是电池供电且总线上挂了好几个设备,这个电流积少成多。更关键的是,不读取的时候总线要保证处于高电平空闲状态,不要在GPIO配置上把引脚钳位成低电平,否则传感器会一直处于异常状态。低功耗场景里可以先把温度转换发起,然后进入深睡眠,用定时器唤醒后再回来读温度。因为转换是设备自己完成的,主机睡觉不影响它,这个特性非常实用。
6.3 温度校准与批量生产经验
最后聊一点生产相关的东西。MY18E20这类传感器出厂前都会做标定,但不同批次之间仍然会存在±0.5℃甚至更大的偏差。如果你的产品对温度精度有硬性要求,建议在产线上做两点校准:用恒温槽或者精确的温度源,在比如0℃和50℃两个点记录每颗传感器的实测偏差,然后把这组偏移量写进设备的Flash里,在软件层面对读数做修正。单点校准能消除固定偏移,两点校准还能补偿斜率偏差,效果会好很多。
我在实际项目里还踩过一个小坑:MY18E20在首次上电时读到的温度数据偶尔会是0.0625的整数倍之外的奇怪值,后来发现是暂存器还没准备好,重新初始化一遍就好了。如果你也遇到类似现象,不要急着改算法,先看复位和初始化流程是否完整。驱动这种东西,稳定比花哨重要,能把异常数据挡住、把每个读数的来源都解释清楚,比炫技有价值得多。