手写MicroPython单总线驱动:从DS18B20到MY18E20的时序解析

发布时间:2026/9/9 10:21:36
手写MicroPython单总线驱动:从DS18B20到MY18E20的时序解析 最近一个项目要从一批传感器里读取温度采购那边顺手给了我一颗丝印“MY18E20”的芯片说是 DS18B20 的国产兼容型号。我第一反应是直接用 MicroPython 自带的onewire和ds18x20驱动结果在 ESP32-S3 上跑了半天要么读回来全是0xFF要么设备时有时无。后来一查这批芯片的手册时序和经典的 DS18B20 有一点细微差异自带的通用驱动虽然在很多场合能用但遇到兼容性不好的批次就会翻车。所以我干脆把单总线的协议从头到尾捋了一遍用 MicroPython 手写了一套自己的驱动。这篇文章不光是贴代码我会把单总线的物理层时序、ROM 命令、CRC8 校验、以及我在实际调试中踩过的坑全部拆开讲清楚适合正在用 MicroPython 做传感器采集、或者对单总线协议似懂非懂的朋友参考。1. 为什么我拿到 MY18E20 后决定扔掉现成库重写驱动先说结论MY18E20 基本可以当作 DS18B20 的脚位兼容、寄存器兼容产品来用单总线的帧格式、温度寄存器格式、ROM 命令都是同一套东西。但“兼容”不代表“完全一致”尤其在一些国产芯片的边沿时间、上升沿时间、采样窗口上处理器 GPIO 的翻转速度不一样容易踩到时序窗口的边界。1.1 MY18E20 和 DS18B20 到底什么关系从协议层面看MY18E20 也是单总线数字温度传感器测量范围多数在 -55°C 到 125°C 之间12 位分辨率下温度 LSB 是 0.0625°C。它内部也有 64 位激光 ROM 码、一个 9 字节暂存器 RAM支持寄生供电和外部供电两种模式。这些和 DS18B20 几乎一样。但要注意国产兼容芯片的“电气参数”不一定照抄原版。比如有的批次复位脉冲低电平时间要求更苛刻有的读时隙采样点要更靠前。MicroPython 自带的ds18x20.py是面向经典 DS18B20 调校的它能在标准时序下工作但是遇到 GPIO 翻转延时偏大、或者芯片时序余量小的组合就会出现偶发错误。我这次遇到的问题就是用自带read_temp()读取时有差不多三分之一概率返回851.875这样的错误温度值这就是典型的暂存器数据错位或 CRC 校验失败但自带库在读到坏数据时不会做足够的保护直接把数据抛出来了。1.2 现成库为什么不够用MicroPython 固件里确实有onewire.py和ds18x20.py但不是所有固件版本都默认包含。你在有些精简固件、或专门为 USB Host 功能定制的固件上可能找不到这两个模块。如果项目对固件体积敏感或者为了后续支持其他单总线器件自己写一套底层时序是更可控的方案。更重要的是手写驱动可以针对自己的板子做时序校准。比如在 Raspberry Pi Pico 上GPIO 翻转速度比 ESP32 快一些同样的sleep_us参数可能会得到不同实际宽度而 Espressif 的 MicroPython 固件里Pin.value()的操作有额外函数调用开销导致短延时不准确。这些只有自己控制底层时隙函数后才能通过逻辑分析仪或者反复读值来微调。我的建议是不要排斥现成库但你要清楚它为你隐藏了多少细节。如果你想在嵌入式平台上把单总线设备用明白手写一遍驱动是最好的路径。2. 单总线物理层时序先把位时隙的脾气摸清楚单总线之所以叫单总线是因为数据、时钟、命令全都在一根线上跑。这跟 I2C 不同I2C 有 SCL 和 SDA 两根线单总线则把双向通信压缩到一根线上所以它对时序非常敏感。2.1 总线的静态电平与上拉电阻单总线的接口本质是漏极开路器件只能把总线拉低不能主动输出高电平。因此在总线上必须有一颗上拉电阻通常用 4.7kΩ。当所有设备都没有拉低总线时总线通过上拉电阻回到高电平这就是空闲态。上拉电阻的取值不是随便选的。理论值范围可以到 1kΩ 到 10kΩ但实际布线上还有导线电容。如果线很短、只有一颗设备4.7kΩ 很稳如果线长超过 1 米导线电容会让上升沿变慢导致读时隙采样点不准。我习惯在长线场景把上拉电阻降到 2.2kΩ但如果总线挂了很多设备每颗设备内部可能会有不同的漏电电阻太小会增加功耗同时低电平的灌电流也会变大所以要在手册允许范围内权衡。2.2 复位脉冲与存在脉冲一次完整的握手每次访问单总线设备前主机必须发送复位脉冲这个过程也叫“初始化时序”。主机先把总线拉低至少 480μs然后释放总线。器件在检测到上升沿后会等待一小段时间一般是 15μs 到 60μs再把总线拉低 60μs 到 240μs这就是“存在脉冲”。主机怎么判断总线上有设备就是在释放总线后的 60μs 到 75μs 窗口内去读引脚电平如果读到低电平说明有设备响应如果一直是高电平说明总线上没设备或者故障了。我实测的手册参数大致如下表注意这是经典 DS18B20 的值MY18E20 兼容型号通常也在范围内。时序参数最小典型最大说明复位低电平时间480μs480μs960μs主机拉低存在脉冲等待时间15μs60μs60μs释放后等待器件响应存在脉冲低电平时间60μs120μs240μs器件拉低写 0 时隙60μs60μs120μs整个时隙持续写 1 时隙低电平时间1μs6μs15μs拉低一下然后释放读时隙主机拉低时间1μs6μs15μs拉低后释放读时隙采样点8μs15μs30μs采样必须在窗口内2.3 写 0 和写 1低电平时间决定一切单总线写一个位由写时隙完成每个写时隙至少 60μs时隙之间至少 1μs 恢复时间。写 0 很简单主机把总线拉低持续整个 60μs 以上的时隙然后释放这是为了让器件看到持续的低电平。写 1 则是在时隙开始时把总线拉低一小段时间通常 6μs 左右然后释放总线让上拉电阻把总线拉高并保持高电平直到时隙结束。芯片检测的关键点就是低电平持续时间短表示发送 1低电平持续时间长表示发送 0。我写的底层函数是这样的from machine import Pin import time class OneWire: def __init__(self, pin_id): # 用开漏模式模拟单总线 self.pin Pin(pin_id, Pin.OPEN_DRAIN) self.pin.value(1) def write_bit(self, bit): if bit: # 写 1只拉低很短的时间 self.pin.value(0) time.sleep_us(6) self.pin.value(1) time.sleep_us(60) else: # 写 0持续拉低整个时隙 self.pin.value(0) time.sleep_us(60) self.pin.value(1) time.sleep_us(6)注意Pin.OPEN_DRAIN在 MicroPython 里是很关键的如果只用Pin.OUT你在释放总线时无法把引脚变成输入态就需要额外切换方向反而麻烦。开漏模式下value(1)实际是把输出置高阻然后靠外部上拉电阻把电平拉高。2.4 读时隙采样点早一点还是晚一点读时隙更加微妙。主机先拉低总线至少 1μs然后释放接着在 15μs 内采样引脚电平。为什么这么设计因为单总线设备要回传 0 或 1靠的是自己在主机释放后继续把总线拉低或者保持高电平。器件检测到主机拉低的下降沿后如果自己要回传 0就会主动拉低总线并保持一段时间如果要回传 1就不动作让总线保持高电平。所以主机采样太晚可能错过了器件拉低的窗口拿到高电平采样太早可能器件还没开始拉低也拿到错误数据。通常采样点放在释放总线后的 8μs 到 15μs 之间比较稳。对应代码def read_bit(self): self.pin.value(0) time.sleep_us(2) # 拉低至少 2us宁多勿少 self.pin.value(1) time.sleep_us(4) # 释放等待器件响应 data self.pin.value() time.sleep_us(55) # 补足整个时隙 return data这里有个容易出错的地方time.sleep_us(2)的最小延时精度取决于 MicroPython 固件和平台。在 ESP32 上sleep_us通常能到微秒级但函数调用本身有开销实际拉低时间可能比参数略长。这没问题读时隙要求是拉低至少 1μs稍微拉长一点不影响。3. ROM 层与命令流从总线枚举到温度读取单总线设备内部有一块 64 位 ROM包含 8 位家族码、48 位序列号、8 位 CRC。主机必须通过读写这些位来寻址设备。完成底层时隙之后我们要把位拼成字节再拼成协议命令。3.1 三条 ROM 命令如何把设备认出来单总线上通常只挂几颗设备但协议设计上允许挂很多颗。ROM 命令有这几条命令字名称用途0x33Read ROM只适合单设备直接读出 64 位 ROM 码0x55Match ROM主机以 64 位地址匹配一颗设备0xF0Search ROM搜索总线上所有设备的 ROM 码0xCCSkip ROM跳过寻址操作总线上所有设备在只有一颗设备的场景最省事的是发送0x33读取 ROM 码如果需要广播温度转换就发0xCC跳过寻址然后发转换命令。如果总线上有多颗设备就必须用0xF0做搜索把每颗设备的 64 位 ROM 码找出来。字节读写代码def write_byte(self, data): for i in range(8): self.write_bit((data i) 1) def read_byte(self): data 0 for i in range(8): data | self.read_bit() i return data单总线的字节传输是 LSB 在前所以上面的循环从第 0 位开始取。3.2 功能命令温度转换与暂存器读写温度采集流程一般是这样复位发 Skip ROM0xCC或 Match ROM0x55 ROM。发温度转换命令 0x44。等待转换完成。如果是 12 位分辨率最坏情况需要 750ms。再次复位发 Skip ROM 或 Match ROM。发读暂存器命令 0xBE。连续读 9 个字节前 2 个字节是温度值最后 1 个字节是 CRC。MY18E20 的温度寄存器格式和 DS18B20 一致16 位有符号数低 4 位是小数部分。比如原始读数0x0191二进制展开后实际值是25.0625°C。在 MicroPython 里解析温度时要小心处理负数def parse_temp(raw): if raw 0x8000: raw - 0x10000 return raw / 16.0这里为什么要先判断符号位因为负数的二进制补码表示很大如果直接用int.from_bytes(b\xf1\xff, little)解析会得到65521而不是-15。手动减去0x10000才能得到正确的负数。3.3 CRC8 校验确保你读到的温度是对的这是最容易忽略的环节。单总线协议自带的 CRC 算法是 CRC-8多项式为x^8 x^5 x^4 1对应二进制是0x31。计算时初始值为 0按位异或并移位。实现代码def crc8(data): crc 0 for byte in data: for i in range(8): mix (crc ^ byte) 0x01 crc 1 if mix: crc ^ 0x8C byte 1 return crc注意标准多项式 0x31 反转后是 0x8C因为逐位运算是从 LSB 开始的所以用反转后的多项式。读取 9 字节暂存器后对前 8 个字节计算 CRC如果结果等于第 9 字节说明数据基本可信。我在驱动里会加一个参数允许用户选择是否严格校验。在调试阶段建议开启在生产环境中也建议开启因为单总线受干扰时错误很隐蔽CRC 是最后的防线。4. 手写 MicroPython 驱动从底层时隙到完整温度采集前面把时序和命令讲透了接下来就是写一个完整可跑的驱动。我不太喜欢把代码封装得太抽象只要够用、易读就好。4.1 为什么用机器引脚模拟而不是专用外设MicroPython 目前没有统一的 1-Wire 外设抽象大多数平台上的onewire库也是用machine.Pin模拟时序的。原因很简单1-Wire 严格来说不是标准硬件外设很多 MCU 的 UART 可以模拟它但时序复杂还是 GPIO 直接模拟最直观。用 GPIO 模拟时关键点有两个引脚方向切换要少延时精度要够。开漏模式避免了方向切换所以首选Pin.OPEN_DRAIN。4.2 底层时隙函数如何串起来把前面的零散函数组装成一个类去掉冗余注释后大概长这样from machine import Pin import time class OneWire: def __init__(self, pin_id): self.pin Pin(pin_id, Pin.OPEN_DRAIN) self.pin.value(1) def reset(self): self.pin.value(0) time.sleep_us(480) self.pin.value(1) time.sleep_us(60) if self.pin.value() 0: time.sleep_us(420) return True time.sleep_us(420) return False def write_bit(self, bit): if bit: self.pin.value(0) time.sleep_us(6) self.pin.value(1) time.sleep_us(60) 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(2) self.pin.value(1) time.sleep_us(4) data self.pin.value() time.sleep_us(55) return data def write_byte(self, data): for i in range(8): self.write_bit((data i) 1) def read_byte(self): data 0 for i in range(8): data | self.read_bit() i return data这个reset()函数有一点需要注意检测到存在脉冲后我还继续等了 420μs。这是为了让整个复位时序保持至少 480μs 的总时间否则紧接着发命令时芯片可能还没从上一次复位中恢复过来。4.3 搜索 ROM 算法的完整实现如果需要挂多颗 MY18E20光会读温度不够必须先把总线上每颗设备的 ROM 码扫出来。经典的 Search ROM 算法每次读两个位第一个位是 ROM 位原始值的反相第二个位是原始值本身。01设备该位为 010设备该位为 100总线上存在冲突至少一颗设备在该位为 0另一颗为 111总线上没有设备搜索算法会维护一个分支栈遇到00时先选一个方向记录当前位置之后回溯时再走另一个方向。递归实现最容易理解但 MicroPython 的递归深度有限而且设备多的时候可能栈溢出。我用迭代方式写了一个简洁版def search_rom(self): devices [] last_conflict -1 rom 0 for bit_pos in range(64): # 每次重新开始搜索都要复位并发送命令 pass这里不能简单套两层循环因为搜索过程是可回溯的。实际可行方案是维护一个branches列表把没走过的冲突位记录下来。我给出一个紧凑但可用的迭代实现def search_rom(self): devices [] branches [] rom 0 bit_pos 0 start_bit 0 while True: self.reset() self.write_byte(0xF0) conflict_bit -1 for i in range(bit_pos): read0 self.read_bit() read1 self.read_bit() if read0 0 and read1 0: pass # 前面路径固定不应有冲突 elif read0 1 and read1 0: self.write_bit(0) else: self.write_bit(1) for i in range(bit_pos, 64): read0 self.read_bit() read1 self.read_bit() if read0 0 and read1 0: conflict_bit i if i ! start_bit: bit_value 0 else: bit_value 1 branches.append(bit_pos if bit_pos 64 else -1) self.write_bit(bit_value) elif read0 1 and read1 0: self.write_bit(0) elif read0 0 and read1 1: self.write_bit(1) else: return devices rom | self.write_bit_into_rom(i, bit_value) devices.append(rom) rom 0 bit_pos 0 if not branches: break next_branch branches.pop() start_bit next_branch bit_pos start_bit return devices这个代码写得有点紧凑直接复用到项目里可能还要做些边界处理。我的建议是如果总线上设备不超过 5 颗可以用更直观的递归算法如果超过 5 颗或者追求稳定性可以参考上面的迭代思路。另一种更简单的办法是先只用 Skip ROM 广播读取总线上某颗设备的温度但这样无法区分是哪一颗传感器的读数也就失去了“多点测温”的意义。所以搜索算法在多点场景中不是可选项是必选项。4.4 封装 MY18E20 的温度读取方法底层类准备好后写一个面向应用的类class MY18E20: def __init__(self, pin_id, check_crcTrue): self.ow OneWire(pin_id) self.check_crc check_crc self.roms self.scan() def scan(self): return self.ow.search_rom() def convert_all(self): self.ow.reset() self.ow.write_byte(0xCC) self.ow.write_byte(0x44) def read_temp(self, romNone): if rom is None: if not self.roms: return None rom self.roms[0] self.ow.reset() if rom: self.ow.write_byte(0x55) for i in range(8): self.ow.write_byte((rom (8 * i)) 0xFF) else: self.ow.write_byte(0xCC) self.ow.write_byte(0xBE) data [self.ow.read_byte() for _ in range(9)] if self.check_crc: if self.ow.crc8(data[:8]) ! data[8]: return None raw data[0] | (data[1] 8) return parse_temp(raw)这里我在没有 ROM 参数时做了降级处理如果在init阶段没扫到设备就只能用 Skip ROM 读但这在有多颗设备时是读不到准确地址的只是方便单设备场景快速跑通。4.5 完整采集流程别忘了 750ms 等待温度转换不是瞬间完成的。12 位分辨率下MY18E20 典型转换时间是 750ms实际最大可能到 800ms 甚至更长。如果发了转换命令后立刻去读读到的还是上一次转换的结果。所以在业务代码里sensor MY18E20(22) sensor.convert_all() time.sleep_ms(750) temps [] for rom in sensor.roms: t sensor.read_temp(rom) if t is not None: temps.append(t)如果要求低功耗可以改成睡眠定时唤醒先发转换命令然后进入machine.lightsleep(800)唤醒后再读取这样既能满足 750ms 等待又能省电。5. 实测中的坑从 0xFF 到传感器丢失代码写出来很容易真正调通要花不少时间。下面几个坑是我这次项目里真实遇到的每个都花了我不少排查时间。5.1 全部读回 0xFF时序不够“凶”第一次在 ESP32-S3 上跑reset()返回 True但read_byte()读回来的全是0xFF。这说明存在脉冲响应正常但后续的命令或数据没有传对。我一开始怀疑是芯片坏了换了好几颗都一样。后来用逻辑分析仪抓波形发现我的write_bit(1)里拉低时间太短实际只有 3μs 左右接近手册下限的 1μs 到 15μs 区间边缘。虽然理论没超但 ESP32 的输入检测和芯片的检测窗口叠加后就容易误判。解决方法是把写 1 的拉低时间从 6μs 调到 10μs读时隙的主机拉低时间从 2μs 调到 4μs。注意写 1 的拉低时间也不能无脑加长如果超过 15μs芯片会以为你在写 0。这个平衡点需要通过实际读值验证。5.2 搜索算法在总线上只有一颗设备时也能死循环这个坑比较隐蔽。我的搜索算法第一次跑在单设备总线上结果有时能返回 1 颗设备有时会返回几十颗一样的地址甚至死循环。原因是我在冲突位处理上有一个逻辑漏洞当没有00冲突时branches列表应为空但我的初始化last_conflict判断有误导致代码以为自己还有未探索的分支于是不断向下扫。后来我在reset()后先读一次存在脉冲如果失败直接退出同时给搜索循环加了一个最大设备数限制比如最多 64 颗超过就报错。这能防止极端情况下固件卡死。5.3 长线传输导致温度值偶尔跳变我的传感器探头线大概有 1.5 米用 4.7kΩ 上拉电阻时偶尔会读到明显错误的温度值比如从 25°C 跳到 -55°C。逻辑分析仪看波形发现读时隙的上升沿明显变缓导致采样点在边沿附近抖动。把上拉电阻换成 2.2kΩ 后问题基本消失。如果线更长可以在传感器端并联一个 0.1μF 电容做电源去耦但不要直接并在数据线上否则上升沿会更慢。5.4 MicroPython 平台的延时偏差不同平台执行time.sleep_us(10)的实际误差差别很大。在 Raspberry Pi Pico 上MicroPython 的sleep_us精度很高在 ESP32 上由于 RTOS 调度小延时偶尔会被拉长。我的做法是不用太短的延时尽可能把每个时隙设计在手册中间的余量位置。比如复位低电平不用 480μs可以直接sleep_us(500)写 1 低电平用 8μs 到 10μs读时隙采样点放在 10μs 左右。如果你有逻辑分析仪强烈建议把write_bit、read_bit、reset的波形抓出来确认一遍。没有逻辑分析仪的话可以用一个笨办法反复读取 100 次温度如果错误率高于 1%就说明时序余量不够需要微调。5.5 硬件上避免热插拔和静电问题单总线设备直接裸露在外部时热插拔很容易烧芯片因为数据线的静电和浪涌会直接打到引脚上。我的经验是在数据线上串联一个 100Ω 电阻同时在电源两端加 TVS 管。虽然驱动代码里做不了这些事但作为硬件方案的一部分能大幅降低调试期间“传感器突然消失”的概率。最后再说一个实用小技巧我在读取流程里加了一个“连续读取失败计数”。单总线偶尔受到干扰很正常但如果在 50ms 内连续 3 次 CRC 校验失败我就认为设备掉线或者总线异常主动返回错误状态而不是继续重读到天荒地老。这个策略在工业现场尤其好用因为现场干扰源多偶尔丢一帧数据不可怕可怕的是系统被坏数据带偏导致温度控制逻辑误动作。手写驱动这个事看起来像是在重复造轮子但当你真正把每一个时隙都控制在自己手里再去调试设备时你会感谢自己当初没有只停留在“能跑就行”的层面。