ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

MicroPython下MY18E20单总线温度传感器驱动详解

MicroPython下MY18E20单总线温度传感器驱动详解 1. 为什么是“单总线”MY18E20 的底层通信逻辑做嵌入式这些年我接触过的温湿度传感器没有二十种也有十几种了。从最普通的 NTC 热敏电阻到 I2C 接口的 SHT30、AHT20再到 SPI 接口的 MAX31865每种接口都有自己的脾气。但要说最“抠门”的接口设计单总线1-Wire绝对排得上号——两根线甚至一根线就能完成供电和数据传输代价就是时序要求极其苛刻稍不留神就读回 0xFFFF 或者干脆超时。MY18E20 这颗传感器本质上就是 DS18B20 的国产兼容方案。引脚定义、寄存器映射、ROM 指令、单总线时序基本完全对齐。所以网上很多 DS18B20 的驱动代码稍微改改引脚定义就能跑到 MY18E20 上。但“基本兼容”和“完全兼容”之间往往藏着坑尤其是时序余量这块国产芯片在制造工艺上的差异会导致某些边缘时序参数不如原厂那么“抗造”。这也是为什么我坚持自己写驱动而不是无脑复制网上的 DS18B20 驱动。单总线为什么叫“单总线”因为它只用一根数据线完成双向通信。数据线和地线两根线就能组网如果是寄生供电模式连 VCC 都能省掉接两根线就行。这在需要远程部署传感器的场景下非常实用——线材成本直接砍半布线难度也降低不少。但省下的线材成本全都要用严格的时序规范来偿还。MY18E20 的数据手册里所有关键时序参数都是以微秒μs为单位的。比如复位脉冲的低电平时间要求 480μs 以上存在脉冲的采样窗口在复位脉冲结束后的 60μs 到 240μs 之间读时隙的采样点必须在拉低总线后的 15μs 内完成。这些数字看起来简单但在 MicroPython 这种解释型语言环境下每一行代码的执行时间都不是固定的一个 print 语句可能就要吃掉几百微秒。所以用 MicroPython 写单总线驱动最大的挑战不是“不懂协议”而是“控制不住时序”。回头说说 MY18E20 的硬件特性。测温范围 -55℃ 到 125℃精度在 -10℃ 到 85℃ 范围内是 ±0.5℃分辨率和 DS18B20 一样可以通过配置寄存器在 9 位到 12 位之间调整默认是 12 位对应 0.0625℃ 的温度分辨率。内部有 64 位激光 ROM 编码每个芯片都不同这是多设备组网时用来寻址的基础。供电方式支持外部供电3.0V 到 5.5V和寄生供电两种MicroPython 驱动里我们默认用外部供电这样逻辑最简单也最容易排查问题。2. 时序级解析从 MicroPython 到 GPIO 的每一个周期2.1 初始化时序复位与存在脉冲单总线通信的第一步永远是复位。主机把数据线拉低至少 480μs然后释放让上拉电阻把总线拉回高电平。MY18E20 检测到这个下降沿后等待 15μs 到 60μs然后拉低总线 60μs 到 240μs这个低电平信号就是“存在脉冲”相当于芯片在说“我在呢”。在 MicroPython 里最直接的办法就是操作 machine.Pin 的 value 和 time.sleep_usfrom machine import Pin import time def reset(pin): pin.init(Pin.OUT) pin.value(0) time.sleep_us(500) # 拉低至少 480us pin.init(Pin.IN) # 释放总线切换到输入模式 time.sleep_us(60) # 等待从设备拉低 presence pin.value() # 采样 time.sleep_us(240) # 等待整个时隙结束 return presence 0 # 如果读到低电平说明设备存在这里有个 MicroPython 特有的坑pin.init(Pin.IN)之后引脚是浮空输入模式必须依赖外部上拉电阻才能保证高电平。很多开发板的 GPIO 默认有内部上拉但强度不够在长线缆或者多设备场景下还是建议在数据线上外接一个 4.7kΩ 的上拉电阻到 VCC。我实测下来内部上拉在 20cm 以内的杜邦线上勉强能用一旦线缆超过 1 米误码率明显上升。2.2 写时隙瘦脉冲与胖脉冲写时隙用来给传感器写入数据每个时隙写 1 个 bit。写 0 和写 1 的区别在于总线拉低的时间长度写 0 需要拉低整个时隙60μs 到 120μs而写 1 只需要拉低 1μs 到 15μs然后释放总线保持高电平到时隙结束。我习惯把写时隙分成“瘦脉冲”写 1和“胖脉冲”写 0def write_bit(pin, bit, delay_us70): pin.init(Pin.OUT) pin.value(0) if bit: time.sleep_us(6) # 瘦脉冲拉低 6us 后释放 pin.init(Pin.IN) time.sleep_us(delay_us - 6) else: time.sleep_us(delay_us) # 胖脉冲拉低整个时隙 pin.init(Pin.IN)关键点在于两个写时隙之间必须留出至少 1μs 的恢复时间。实际操作中write_bit函数结束到下一次调用之间Python 函数调用的开销天然提供了这个恢复间隔所以只要不刻意做极端优化问题不大。写一个完整字节时最低有效位LSB先发。比如要写入 0xCC跳过 ROM 指令二进制是 11001100发送顺序是 0、0、1、1、0、0、1、1。2.3 读时隙15μs 窗口里抢数据读时隙是单总线协议里最容易翻车的环节。主机拉低总线 1μs 到 15μs然后释放MY18E20 会在检测到这个下降沿后决定是否拉低总线——如果它要发送 0就继续拉低如果要发送 1就不拉低。主机必须在拉低释放后的 15μs 内完成采样。在 MicroPython 里这个 15μs 窗口非常紧张。pin.value()函数本身有开销加上 Python 解释器的执行不确定性直接在读时隙中间裸调pin.value()有时会读到不确定值。我的做法是“提前采样”——在释放总线后立刻调用time.sleep_us(8)然后马上读引脚因为 MicroPython 的 sleep_us 在实际执行时通常会多睡几个微秒这个“超时”在容差范围内反而帮了忙。def read_bit(pin, delay_us70): pin.init(Pin.OUT) pin.value(0) time.sleep_us(2) # 拉低 2us pin.init(Pin.IN) # 释放 time.sleep_us(8) # 等待约 8us实际可能 10-12us val pin.value() # 采样 time.sleep_us(delay_us - 12) # 补足剩下的时隙 return val这个函数每次读一个 bit读完整字节需要按位组装LSB 优先def read_byte(pin): val 0 for i in range(8): bit read_bit(pin) val | (bit i) return val2.4 为什么 MicroPython 能跑对 OC 与 ODC 的掌控讲到这里可能有人会问MicroPython 这么“慢”官方为什么还是支持 onewire 库其实单总线设备的工作频率并不高标准模式下每个 bit 时隙大约 70μs一个字节也就 560μs 左右。相比 I2C 的 400kHz、SPI 的 10MHz 以上单总线的数据速率低得感人恰好给解释型语言留了一条活路。但低速率不意味着低难度。单总线的通信质量高度依赖总线上的电平竞争——主机释放总线后从设备才有机会拉低总线。如果主机的 GPIO 在输入模式下浮空加上没有合适的上下拉总线电平就处在中间态采样结果就是薛定谔的电平。这也是为什么单总线设计规范里反复强调“开漏输出OC/OD 外部上拉”的原因。MicroPython 的 machine.Pin 在大多数 ESP32、RP2040 开发板上不支持真正的开漏输出只能靠切换输入/输出模式模拟。虽然 ESP32 的Pin.OPEN_DRAIN引脚模式在底层也做了开漏处理但我在实际测试中发现模拟切换的速度往往比真正的开漏更快尤其是在快速连续复位、写读切换的场景下模拟模式反而更稳定。所以我的驱动里全部采用Pin.OUT/Pin.IN手动切换的方式不依赖引脚的开漏模式。3. 驱动代码落地从底层寄存器到上层 API 封装3.1 寄存器映射与 ROM 指令先弄清 MY18E20 的寄存器布局。芯片内部有一个 9 字节的暂存器Scratchpad地址内容0温度 LSB1温度 MSB2用户寄存器 1高温触发值 TH3用户寄存器 2低温触发值 TL4配置寄存器分辨率设置5保留6保留7保留8CRC 校验所有数据传输都是先发一个 ROM 命令再发功能命令。单总线上只有一个设备时可以直接发跳过 ROM 的 0xCC 命令省去 64 位 ROM 码的传输时间。如果总线上挂了多个设备就必须用 0x55 匹配 ROM 命令先发送 64 位 ROM 码芯片会比对自身 ROM匹配上的才响应后续命令。常用 ROM 命令表命令字名称说明0x33Read ROM读取 64 位 ROM 编码0x55Match ROM匹配指定 ROM 地址0xCCSkip ROM跳过 ROM直接访问0xF0Search ROM搜索总线上所有设备0xECAlarm Search搜索告警设备功能命令表中最关键的是两个0x44 启动温度转换、0xBE 读取暂存器。0x44 命令发起后芯片开始进行 A/D 转换默认 12 位分辨率下需要 750ms 才能完成。转换期间芯片会拉低总线电平主机可以轮询总线状态来判断转换是否结束——这就是经典的“读 0 等待”技巧。3.2 温度读取的核心流程一次完整的温度读取流程是复位等待存在脉冲发 0xCC跳过 ROM单设备场景发 0x44启动温度转换等待转换完成750ms 或轮询总线复位发 0xCC发 0xBE读取暂存器连续读 9 个字节其中第 1、2 字节是温度值class MY18E20: def __init__(self, pin_id): self.pin Pin(pin_id, Pin.OUT) self.pin.value(1) self.rom None def read_temp_raw(self): reset(self.pin) write_byte(self.pin, 0xCC) write_byte(self.pin, 0x44) # 等待转换完成12 位分辨率需要 750ms time.sleep_ms(750) reset(self.pin) write_byte(self.pin, 0xCC) write_byte(self.pin, 0xBE) data [read_byte(self.pin) for _ in range(9)] reset(self.pin) return data def read_temp(self): data self.read_temp_raw() # 合并温度字节注意符号位 raw (data[1] 8) | data[0] if data[1] 0x80: # 负温度补码转换 raw -((~raw 1) 0xFFFF) return raw * 0.06253.3 把命令组织成可复用的驱动类写驱动最忌讳的就是把所有逻辑堆在几个孤立函数里。我的习惯是封装一个驱动类把时序操作、指令发送、数据解析、错误处理全部收进去对外只暴露最简洁的接口。除了上面看到的read_temp()我还会加一个scan()静态方法用于扫描总线上所有 MY18E20 设备并返回地址列表staticmethod def scan(pin): devices [] reset(pin) write_byte(pin, 0xF0) # Search ROM for _ in range(64): # 最多 64 位 ROM bit1 read_bit(pin) bit2 read_bit(pin) if bit1 0 and bit2 0: # 出现分支这里简化为选择第一个方向 write_bit(pin, 0) elif bit1 1 and bit2 0: # 当前位为 0 pass elif bit1 0 and bit2 1: # 当前位为 1 pass else: break # 无设备 reset(pin) return devicesscan 的逻辑在 MicroPython 下性能消耗较大因为每个 bit 要经过两轮读时隙加一轮写时隙。但多设备组网时非常实用尤其是你要在总线上动态发现设备的场景。4. 让驱动在真实世界里可靠多设备、精度与复位策略4.1 多设备组网从 Skip ROM 到 Match ROM单总线最大的优势就是用一根线挂多个传感器。但驱动从“单设备”升级到“多设备”不只是把 0xCC 换成 0x55 那么简单。你需要先扫描出每个设备的 ROM 码然后在每次访问指定设备时准确发送匹配命令。常见做法是先扫描生成设备列表然后对每个设备单独建一个驱动实例def find_devices(pin): roms [] # 这里用完整 search rom 算法收集所有 64 位地址 return roms roms find_devices(pin) sensors [MY18E20(pin, rom) for rom in roms] for s in sensors: temp s.read_temp() print(设备 %s: %.2f C % (s.rom_hex(), temp))多设备场景有个容易被忽略的坑芯片的 ROM 码在出厂时已经固化但搜索算法在某个 bit 遇到两个设备值不同时会出现分支。很多简化版驱动只选了一个方向导致总线上某些设备永远扫不到。如果你确实要扫描多设备建议用 MicroPython 官方 onewire 库里的_search_rom逻辑别看它代码不多分支处理已经过了很多人的验证。4.2 分辨率配置与温度计算精度MY18E20 的默认分辨率是 12 位对应转换时间 750ms。如果需要更快的数据刷新率可以把配置寄存器改为 9 位分辨率转换时间缩短到 93.75ms但温度精度降到 0.5℃。怎么改写配置寄存器需要用到 0x4E 命令def set_resolution(self, bits): # bits: 9, 10, 11, 12 config_map {9: 0x00, 10: 0x20, 11: 0x40, 12: 0x7F} reset(self.pin) write_byte(self.pin, 0xCC) write_byte(self.pin, 0x4E) # 写暂存器 write_byte(self.pin, 0x00) # TH write_byte(self.pin, 0x00) # TL write_byte(self.pin, config_map[bits]) reset(self.pin) write_byte(self.pin, 0xCC) write_byte(self.pin, 0x48) # 复制暂存器到 EEPROM time.sleep_ms(10) reset(self.pin)这里有个顺序问题0x4E 命令后面必须紧跟着写 TH、TL、配置寄存器三个字节顺序不能乱。写完后再发 0x48 把暂存器内容复制到 EEPROM这样断电重启后配置仍然保留。温度计算的细节同样值得注意。12 位分辨率下温度寄存器的 LSB 代表 0.0625℃。正温度直接乘 0.0625 就行但负温度需要做补码转换。上面代码里用-(~raw 1) 0xFFFF的方式处理补码是为了兼容 Python 的无限精度整数特性——直接raw - 65536在 raw 大于 32767 时也能得到正确结果但可读性差一些。4.3 转换等待750ms 的死等与轮询优化默认 12 位分辨率下0x44 命令后的转换时间最长 750ms。如果在read_temp()里直接time.sleep_ms(750)每次读取都会被卡住 750ms批量采集时效率非常低。更聪明的做法是利用总线的“忙状态”信号转换期间芯片会把总线拉低。你可以在发出 0x44 后把引脚设为输入模式并持续采样直到读到高电平说明转换完成。这通常比固定等 750ms 要快一些因为不同芯片、不同温度下的实际转换耗时并不一样。def convert_and_wait(self): reset(self.pin) write_byte(self.pin, 0xCC) write_byte(self.pin, 0x44) self.pin.init(Pin.IN) while self.pin.value() 0: time.sleep_ms(10) self.pin.init(Pin.OUT)不过要注意寄生供电模式下转换期间主机必须持续拉高总线为芯片供电这时候就不能用轮询方式了老老实实 sleep 750ms。这也是我建议优先选外部供电的原因之一——省电和省心不可兼得。5. 实测踩坑MicroPython 驱动最容易翻车的几个环节5.1 复位后立刻读存在脉冲的时间窗口最开始写 reset 函数时我犯了所有初学者都会犯的错误释放总线后立刻采样。看起来逻辑没问题但实际运行时经常读到高电平误判为“设备不存在”。后来抓了波形才发现芯片的存在脉冲在复位脉冲结束后的 15μs 到 60μs 之间才开始拉低。如果主机在 15μs 内就采样总线还没被拉低自然读到高电平。解决办法是在释放总线后先等 60μs 再采样确保落在存在脉冲的有效窗口内。我的驱动里time.sleep_us(60)就是这么来的实测还是有点紧改成 70μs 更稳。不过这个时间不能拖太长一旦超过 240μs 从设备就释放总线了采样窗口关闭。60μs 到 70μs 是个比较折中的选择即使考虑到 MicroPython sleep_us 的误差也不至于错过窗口。5.2 sleep_us 的误差与“先释放再延时”的时序补偿MicroPython 在 ESP32 上执行time.sleep_us(10)实际休眠时间可能是 15μs 甚至 20μs。这是因为底层定时器的最小精度和 Python 解释器的调用开销叠加导致的。单独几次这种误差对整体时序影响不大但在读时隙这种窗口只有 15μs 的环节误差可能让采样点滑出窗口。我的补偿策略很简单把读时隙里的采样延时从“理论值”调成“实际值余量”。比如理论上应该在释放后 5μs 到 15μs 内采样我写成time.sleep_us(8)靠系统开销自然把它拖到 10μs 到 12μs 左右仍然在窗口内。写时序也一样不需要纠结 6μs 还是 10μs只要保证写 0 时拉低时间远大于 60μs、写 1 时拉低时间小于 15μs就没问题。5.3 GPIO 模式切换的开销为什么 Pin.IN→Pin.OUT 不要频繁切换在 3.2 节的示例里每次写 bit 都会执行pin.init(Pin.OUT)每次读 bit 都会执行pin.init(Pin.IN)。对于单次读取这种切换开销无伤大雅但如果你要连续采集 100 个设备每个设备读 9 字节那总线切换次数会达到数万次每次切换都有几十微秒的开销累积起来非常可观。我在批量采集代码里做了一次优化读数据阶段把引脚保持为输入模式只在写命令阶段才切换为输出模式。这样读 9 字节期间不再有 init 切换整体耗时下降了将近 20%。代价是代码结构稍微复杂了一点需要保证读数据阶段不会有写操作穿插进来。5.4 线缆长度与上拉电阻从 4.7kΩ 到 1kΩ 的调整单总线的通信距离受线缆电容和上拉电阻共同影响。短距离20cm 以内4.7kΩ 上拉没问题但线缆一旦超过 2 米上升沿变缓时序后面部分的高电平可能达不到阈值导致读到的 bit 不稳定。我的做法是在长线缆场景把上拉电阻降到 1kΩ 到 2.2kΩ牺牲一点功耗换取更陡的上升沿。另外数据线和电源线最好用双绞线减少共模干扰。实测在 5 米双绞线 1kΩ 上拉条件下连续读取 500 次温度只出现 1 次 CRC 错误可靠性可接受。5.5 内存管理MicroPython 里别用 list 存大块数据有段时间我在 ESP32 上用 MicroPython 读取 10 个 MY18E20每次扫描都返回一个 ROM 地址列表然后在主循环里反复调用read_temp()结果跑了几分钟后系统内存不足直接抛出 MemoryError。罪魁祸首是 read_temp 里创建的那个 9 元素列表还有 scan 过程中不断拼接的列表。解决办法是把列表改成元组或者直接在函数内处理数据不返回列表def read_temp(self): self._send_cmd(0xBE) # 不创建列表直接按序读取 data0 read_byte(self.pin) data1 read_byte(self.pin) for _ in range(7): read_byte(self.pin) # 丢弃剩余字节 ...如果确实需要缓存多个设备的数据用array.array(h)而不是 Python list在内存占用上能省 70% 以上。6. 拓展玩法把驱动接到 Home Assistant 与数据采集体系里驱动写出来不只是为了在终端打印温度数字。我的一个实际应用是把 MY18E20 接到 ESP32 上通过 MQTT 上报温度到 Home Assistant实现室内温湿度监控和自动供暖控制。整个链路是这样的ESP32 上运行 MicroPython 驱动采集温度每 30 秒读取一次12 位分辨率转换等待用轮询方式实际每个设备大约耗时 800ms 左右然后通过 umqtt.simple 库发布到 MQTT Broker。Home Assistant 订阅对应主题把数据写入长期统计数据库再通过自动化规则联动加热器或空调。from umqtt.simple import MQTTClient import network # 省略 AP 连接代码... client MQTTClient(esp32_temp, 192.168.1.100, usermqtt, passwordsecret) client.connect() sensor MY18E20(4) while True: temp sensor.read_temp() client.publish(home/bedroom/temperature, %.2f % temp) time.sleep(30)要注意的是read_temp()里的time.sleep_ms(750)会阻塞整个线程导致 WiFi 保活心跳超时。在长时间运行场景下一定要把等待时间缩到最短或者改用uselect异步方案。我的经验是如果只是周期上报同步阻塞问题不大但一定要在读取前调用network.WLAN.isconnected()做检查断开时及时重连。另外一个容易忽略的是上电后的“初始化延时”。MY18E20 在上电后需要至少 10ms 的稳定时间才能响应复位命令所以驱动类的__init__里最好加上time.sleep_ms(10)。如果跳过这一步第一次读取大概率失败。类似的芯片从 EEPROM 复制数据到暂存器也需要时间每次烧写配置后等 10ms 再继续操作。7. 最后聊聊驱动设计里那些值得反复斟酌的取舍写这个驱动对我个人来说最大的收获不是“会读温度了”而是把“时序敏感设备在高级语言环境下的驱动写法”彻底想透了。有几个取舍回头看看很有意思第一到底该不该用 MicroPython 官方的 onewire 库如果你只是做简单 demo直接import onewire就行。但如果你想深入理解单总线协议想在驱动里加入多设备扫描、分辨率动态配置、CRC 校验、异常恢复这些进阶功能自己写一版是值得的。官方库为了通用性牺牲了很多灵活性比如你无法直接控制读时隙的采样时间点。第二驱动代码要尽量保持“薄”。不要把应用层逻辑塞进驱动类里比如温度报警、数据平滑滤波、断线重连这些单独放在上层处理。驱动类只做三件事时序控制、指令封装、数据解析。这样后续换用其他传感器或协议上层代码不用动。第三异常处理要“主动复习”而不是“被动兜底”。比如read_temp()返回 85℃ 这个问题——很多 DS18B20 兼容芯片在供电不稳定时会返回一个标志性的 85℃。这个值其实来自芯片内部默认的上电复位值一旦读到 85℃ 且芯片温度明显不合理基本可以断定是供电或接触问题。我在驱动里专门加了一个校验如果 raw 值等于 0x0550即 85℃就返回一个自定义的读取异常而不是直接把它当作有效温度输出。这个细节在长时间无人值守的监控系统里特别重要能帮你尽早发现问题而不是记一堆垃圾数据。还有 CRC 校验。MY18E20 暂存器的第 9 字节是前 8 个字节的 CRC 校验值算法是 CRC-8多项式 x^8 x^5 x^4 1。驱动里实现这个校验并不复杂大概 20 行代码但能过滤掉绝大部分因为时序毛刺导致的读错。尤其是线缆质量不好或电磁干扰较强的场景CRC 校验是最后一道防线。我自己写的驱动里每次读暂存器都会校验校验失败就重新读取。实测下来在 3 米线缆环境中校验失败率大约 0.3%看起来不高但在连续运行一个月后累积起来就不少了。如果你还想在驱动基础上继续扩展我建议下一步研究两个方向一个是给驱动加上看门狗机制检测到连续多次 CRC 失败后自动重启传感器模块另一个是研究寄生供电模式下的驱动差异因为在某些只需要单根线缆的场景里寄生供电能省掉一整套布线成本。这两个方向都有不少坑但踩过去之后你对单总线的理解会再上一个台阶。
返回列表