搞嵌入式或者玩ESP32、RP2040这些开发板的朋友,对温度采集肯定不陌生。市面上便宜好用的数字温度传感器里,DS18B20那个生态最有名,而MY18E20就是国产兼容方案里用得非常多的一颗芯片:封装、命令、时序都和DS18B20高度兼容,三根线甚至两根线就能把温度数据送出来。单总线协议的精髓在于只用一根DQ数据线完成双向通信,省引脚、省IO,一条总线上还能挂多个器件。今天这篇就围绕MY18E20,把单总线协议的时序细节拆开揉碎,再跟着思路用MicroPython写一个可以直接跑起来的驱动,最后聊一聊实际调试中踩过的坑。适合手上有开发板、想低成本做温度采集,或者一直调现成库但不知道底层怎么工作的朋友往下看。
1. 先搞清楚MY18E20是什么型号
1.1 芯片规格与引脚定义
MY18E20是国产单总线数字温度传感器,协议层和Maxim的DS18B20基本一致,在很多方案里直接被当作DS18B20的替代料使用。它的测温范围一般在-55℃到+125℃,在-10℃到+85℃区间精度能做到±0.5℃,12位分辨率下温度转换时间约为750ms,这些参数都和DS18B20看齐。因为引脚定义也基本兼容,常见的TO-92直插封装和防水不锈钢探头封装都有,手头有DS18B20的库或者旧项目,迁移成本很低。
三个引脚里,GND接地,VDD接3.0~5.5V电源,DQ就是数据线,负责发命令、读温度、回数据,所有通信都在这根线上完成。如果做寄生供电,VDD甚至可以悬空,由DQ线上的信号给芯片供电,不过那样在温度转换期间需要主机强制拉高DQ提供电流,实际项目里大多数还是老老实实接三根线,简单可靠。MY18E20的待机电流非常小,既可以用3.3V逻辑的开发板直连,也能在5V单片机上工作,电平兼容性比很多I2C传感器更宽。
除了默认的12位分辨率,MY18E20也支持通过配置寄存器把分辨率降到11位、10位或9位,对应转换时间从375ms一路降到93.75ms。分辨率越高,温度数据LSB代表的物理量越小,12位时是0.0625℃,9位时是0.5℃。如果你只是测环境温度,12位分辨率性价比最高;如果要做快速响应或者电池供电的低功耗场景,可以适当降分辨率换转换速度。这一点在驱动里虽然没有显式暴露,但了解之后遇到“为什么必须等750ms”就不会困惑了。
1.2 为什么选单总线和MicroPython
单总线协议(1-Wire)最大的价值是省引脚。一颗温度传感器只占用一个GPIO,一个GPIO理论上可以并联几十颗器件,靠每颗芯片出厂烧录的64位ROM码区分身份。相比之下,I2C需要两根线加设备地址,SPI需要三根线加片选,在引脚紧张的板子上单总线的优势非常明显。缺点也明显:时序要求精确到微秒级别,通信速率不高,实际有效波特率也就是几十kbps,但拿它传温度这种低频数据绰绰有余。
选MicroPython写驱动,是因为ESP32、RP2040这些主流开发板都有现成的MicroPython固件,下载刷入之后就能用解释型语法控制GPIO。MicroPython里操作IO非常简单,用machine.Pin定义引脚、用time.sleep_us做微秒延时,就能把单总线协议的底层时序完整模拟出来。相比C驱动,MicroPython版本更容易读、容易改,跑在ESP32这类主频较高的芯片上时序余量也够用,特别适合快速验证传感器逻辑。不需要为了测个温度就去啃寄存器手册和编译工具链,看懂时序图就能写驱动,这也是这个方向最吸引人的地方。
不过MicroPython毕竟不是实时系统,Python解释器执行每条语句都有固定开销,后面我们会专门讨论这个坑该怎么补。
2. 单总线协议底层时序拆解
2.1 复位与存在脉冲:握手的两个关键电平
单总线上所有的通信都由主机发起,从机不会主动说话。主机想和MY18E20通信,第一件事就是发复位脉冲。主机先把DQ拉低480~960μs,然后释放总线让上拉电阻把电平抬高。从机在检测到下降沿后,会等待15~60μs,再把DQ拉低60~240μs,这就是存在脉冲。主机在释放总线后大概70μs时读取引脚电平,如果读到低电平,说明总线上有从机应答,通信链路是通的。
这个过程就像两个人碰头先对暗号:主机喊“有人在吗”,从机回一句“我在”,两边对上之后才开始传输具体内容。很多驱动初始化失败,问题就出在这个握手阶段:要么上拉电阻没接,总线释放后一直悬空;要么延时太短,主机采样太早,从机还没来得及拉低;要么总线上有干扰,存在脉冲的宽度不够。遇到“读不到温度”别急着怀疑芯片坏了,先看复位函数返回的布尔值对不对。
2.2 写时序:主机如何往总线上“塞”数据
写时序的本质不是发送高低电平本身,而是用低电平的持续时间来表达0和1。主机要写0时,拉低DQ并保持60~120μs,然后释放;要写1时,拉低1~15μs就马上释放,剩下的时间让上拉电阻把总线拉高。从机在每个时隙开始后的15~60μs窗口内采样,根据总线被拉低的时间长短判断收到的是0还是1。
这个设计很有意思:明明是同一条线、同一个引脚,写0和写1的起始动作完全相同,区别只在于低电平保持多久。数据手册里特别强调,写1的拉低时间不能超过15μs,否则从机会误判成0;写0的低电平时间最好在60~120μs之间,太短了从机采不到,太长了占用时隙影响后续时序。实际操作时我习惯写0保持约65μs,写1保持约5μs后释放,既留足Python解释器的执行余量,又不越界。
2.3 读时序:从机如何把数据交回给主机
读时序同样是主机发起。主机把DQ拉低至少1μs,然后释放,紧接着在释放后约15μs的时间内去采样引脚电平。注意释放瞬间,因为上拉电阻的存在,总线默认是高电平,但从机如果此时想传0,它会在主机释放后继续把自己的输出端拉低,一直拉到整个时隙结束;如果从机传1,它就什么都不做,总线被上拉保持高电平。
所以读时序的关键是采样点要卡得准。采样太早,可能会在从机还没来得及输出有效数据时读到高电平;采样太晚,读0场景下没问题,但读1场景下如果总线上有其他容性负载,电平可能拖着上不来,也会发生误判。MicroPython里每执行一条Python代码都要好几微秒,所以我在驱动里把采样时间设在释放后约8~10μs,然后不等时隙完全结束就继续跑下一位,实测下来比数据手册给出的15μs采样窗口更稳。
2.4 一个容易踩的坑:MicroPython的微秒延时
前面反复提到时隙和延时,这里必须说透一个MicroPython特有的坑。time.sleep_us(10)在C语言里可能真的等10μs,但在MicroPython里,Python解释器解析函数调用、进出函数栈、执行time模块的C接口,都需要额外时间。实测在ESP32上,一段写了sleep_us(5)的代码,实际低电平时间可能是8~15μs。所以驱动参数不能照搬数据手册的极限值,要给解释器开销留余量。
我用的原则是:凡是要短延时的时序(写1的拉低、读时序前的拉低),尽量把目标时间设在安全区间下限附近,而不是卡着下限;凡是要长延时的时序(复位、写0),则在区间内取偏中值。另外,如果项目对时序要求很苛刻,可以在关键收发函数前后暂时关闭中断,比如调用machine.disable_irq(),读完或写完再enable_irq()。我在ESP32上试过,正常跑不关中断也没问题,但如果你同时用着WiFi、Timer这类频繁触发中断的外设,建议把关键收发操作包在关中断里面。
3. 驱动框架设计与命令集
3.1 总线层与设备层的分层思路
写驱动前先想清楚结构。我的做法是把代码拆成两层:下面一层叫OneWire总线驱动,只负责最底层的复位、读写bit、读写byte,不关心温度数据长什么样;上面一层叫MY18E20设备驱动,负责发跳过ROM、温度转换、读暂存器、解析温度值。底层越独立越好,以后换一颗同样走单总线的芯片,底层代码直接复用,只改上层命令和解析逻辑。
MicroPython里面向对象很顺手,所以我把设备层封装成一个类MY18E20,构造函数传入GPIO编号,对外暴露convert_temp()和read_temp()两个方法。使用方只需要new一个对象,先调用convert_temp()触发转换,再调read_temp()读结果,不需要接触任何寄存器细节。这种“库式”设计对初学者友好,对后续集成也方便,这是我在多个项目里迭代之后觉得最舒服的结构。
3.2 MY18E20命令集梳理
MY18E20继承DS18B20的命令集,常用的有这么几条:
| 命令 | 代码 | 作用 |
|---|---|---|
| 读ROM | 0x33 | 读取64位ROM码,单设备时可用 |
| 匹配ROM | 0x55 | 后面跟64位ROM码,只与指定设备通信 |
| 跳过ROM | 0xCC | 不指定设备,直接操作总线上的所有设备 |
| 温度转换 | 0x44 | 启动一次温度转换,结果存入内部暂存器 |
| 读暂存器 | 0xBE | 从第0字节开始读9字节数据 |
| 写暂存器 | 0x4E | 写入报警上下限和分辨率配置 |
| 搜索ROM | 0xF0 | 多设备时遍历查找所有从机的ROM码 |
单设备场景最省事的流程是:复位、发跳过ROM、发温度转换,等待转换完成;再复位、发跳过ROM、发读暂存器,然后连续读9字节。多设备场景必须用0x55匹配ROM或者0xF0搜索ROM,否则所有挂在总线上的传感器会同时响应,数据就乱套了。命令发送对字节顺序有要求,所有字节都是低位在前,这点在驱动里要格外小心,写代码时如果从MSB开始发,读回来的数据会完全对不上。
3.3 暂存器与CRC校验
读暂存器返回的9字节里,前两个字节是温度值本身。温度寄存器是16位有符号数,默认12位分辨率下LSB代表0.0625℃。比如温度寄存器里的数值是0x0191,换算成十进制是401,除以16就得到25.0625℃,这个计算在解析函数里直接套用。负温度用二进制补码表示,符号位为1时,要把16位数值先减65536再除以16,否则会得到一个明显不对的大正数。
第2、3字节是报警阈值TH和TL,第4字节是配置寄存器,用来设置9到12位分辨率。第5到7字节是保留字节,固定为0xFF,第8字节是CRC。CRC校验对工业现场采集特别重要,总线长了以后容易有干扰,数据跳变在温度显示上往往只是零点几度,不太会让你察觉异常。我每次读完9字节都会用CRC-8校验一遍,校验不过就丢弃这次结果,宁可不刷新也不能把坏数据采进统计表。
4. 从零写一个可用的MicroPython驱动
4.1 引脚初始化与底层位操作
先定义引脚和类。我用的是开漏思路:需要拉低总线时把引脚配置为输出并写0,需要释放总线时把引脚切换为输入,由外部上拉电阻负责把电平抬高。这里不建议直接用输出模式写1来“释放”,因为如果从机正在拉低总线,你用强推挽输出写1会造成总线冲突,长期可能会损伤引脚。GPIO模式切换的成本不高,单总线速率也低,所以切换方式是安全的。
在代码实现里,我把引脚编号存在self.pin_num里,每次操作前重新创建Pin对象。这是因为MicroPython里直接改Pin的模式在某些板子上会有缓存问题,重新构造最稳妥。底层函数分别是_reset、_write_bit、_read_bit,函数注释里写清楚了每个延时的设计意图。需要说明的是,所有时序参数都是针对ESP32官方MicroPython固件实测调过的,如果你换其他板子跑,适当把sleep_us里的数字上下调几微秒就能适配。
from machine import Pin import time class MY18E20: CMD_SKIP_ROM = 0xCC CMD_CONVERT = 0x44 CMD_READ_SCRATCHPAD = 0xBE def __init__(self, pin_num): self.pin_num = pin_num self.dq = Pin(pin_num, Pin.OUT) self.dq.value(1) def _reset(self): self.dq = Pin(self.pin_num, Pin.OUT) self.dq.value(0) time.sleep_us(480) self.dq = Pin(self.pin_num, Pin.IN) time.sleep_us(70) present = self.dq.value() == 0 time.sleep_us(410) return present def _write_bit(self, bit): self.dq = Pin(self.pin_num, Pin.OUT) self.dq.value(0) if bit: time.sleep_us(5) self.dq = Pin(self.pin_num, Pin.IN) time.sleep_us(65) else: time.sleep_us(65) self.dq = Pin(self.pin_num, Pin.IN) time.sleep_us(5) def _read_bit(self): self.dq = Pin(self.pin_num, Pin.OUT) self.dq.value(0) time.sleep_us(2) self.dq = Pin(self.pin_num, Pin.IN) time.sleep_us(8) v = 1 if self.dq.value() else 0 time.sleep_us(50) return v_reset函数的480μs拉低是复位脉冲的时长,70μs时读取存在脉冲,刚好落在从机拉低窗口的中段。_write_bit里写1时拉低5μs后马上释放,就算Python解释器拖到10μs,也不会超过15μs上限;写0时拉低65μs,算上开销大约70μs,依然在60~120μs窗口内。_read_bit里拉低2μs是为了制造一个下降沿,释放后等8μs采样,再补足剩余位周期,保证整个时隙长度接近60μs,和协议要求的时序形状一致。
4.2 字节读写与CRC8实现
字节级读写就是循环调用位函数,注意先发低位,这一点很容易被忽略。读取字节时,把每一位的结果累加到一个整数里,bit0是LSB,所以用左移操作把高位放上去。CRC-8算法本身不复杂:初值0,多项式是0x31的反转形式0x8C,对每一位做异或判断,若异或结果为1就继续异或多项式,移位循环8轮。有了CRC校验函数,read_temp里就能判断暂存器的最后一字节是否是前面8字节的校验值。
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): if self._read_bit(): data |= (1 << i) return data @staticmethod def _crc8(data): crc = 0 for byte in data: for _ in range(8): mix = (crc ^ byte) & 0x01 crc >>= 1 if mix: crc ^= 0x8C byte >>= 1 return crc这里顺便给个建议:如果你只是自己玩玩,不做CRC校验也能跑,但一旦传感器线超过30厘米,或者旁边有继电器、电机这类干扰源,CRC错误会时不时出现。做产品或者做长时间数据采集,CRC校验这一行必须写。好多开源例程为了精简把CRC省了,实际项目里害人不浅,温度偶尔跳一下你根本不知道是真实温度变了还是数据错了。
4.3 温度转换和读取的主流程
设备层的主流程分两步。convert_temp方法先复位总线,发跳过ROM命令,再发转换命令,然后等待一个转换周期。12位分辨率下转换时间是750ms,为了简单我固定sleep 750ms;如果你在配置寄存器里改过分辨率,这里要相应调整。read_temp方法先复位,发跳过ROM,发读暂存器,然后连续读9字节,最后做CRC校验和温度值换算。
为了减少外部调用时的出错概率,我在read_temp里加了保护逻辑:CRC校验失败时返回None,而不是返回一个0或者-999之类的占位值。调用方只需要判断一下结果是否为空再决定下一步,代码更干净。温度值用float返回,保留两位小数打印就够了。
def convert_temp(self): self._reset() self._write_byte(self.CMD_SKIP_ROM) self._write_byte(self.CMD_CONVERT) time.sleep_ms(750) def read_temp(self): self._reset() self._write_byte(self.CMD_SKIP_ROM) self._write_byte(self.CMD_READ_SCRATCHPAD) data = [self._read_byte() for _ in range(9)] if self._crc8(data[:8]) != data[8]: return None raw = data[0] | (data[1] << 8) if raw & 0x8000: raw -= 0x10000 return raw / 16.0调用方式很简单,先定义对象,再走“转换-读取”流程:
sensor = MY18E20(4) # 假设DQ接在GPIO4 sensor.convert_temp() t = sensor.read_temp() if t is not None: print("temperature = %.2f C" % t) else: print("CRC error or no sensor")注意上面这个例子只读一次温度。实际项目里通常放在主循环里,每次循环先convert_temp,再read_temp。如果你在主循环里忘了先转换就直接读,读到的永远是上一次转换的旧结果,这也是很多初学者“温度不更新”的常见原因。
4.4 多设备ROM匹配扩展
一总线上挂多个MY18E20是单总线的经典应用,但代码复杂度会上一台阶。常规做法是先用0xF0搜索ROM,把总线上所有设备的64位ROM码列出来,之后每次通信都用0x55匹配ROM并附上目标设备的ROM码。搜索ROM的算法比较绕,核心思路是对每一位做两次读:第一次读所有设备在该位的输出,第二次读反码。根据两次读到的组合判断方向,“11”表示无设备,“00”表示这一位存在冲突,“01/10”表示只有唯一分支,然后按规则逐位回溯,最终得到完整ROM码。
如果不想啃搜索算法,还有一个工程上更简单的方案:一个GPIO只挂一个传感器,代码里创建多个MY18E20实例,每个实例指向不同引脚。这种方案牺牲了GPIO数量,但完全绕开搜索ROM的复杂度,代码稳定性更高。我自己做项目时,不超过四路温度采集基本都用“一对一”接法,只有需要挂七八个传感器时才会去写搜索算法。毕竟一条总线上设备多,一旦某个器件应答时序稍微异常,整条总线都会被拖住,排查起来比多占几个GPIO头疼得多。
5. 常见问题与调试心得
5.1 上拉电阻取值
单总线在空闲时靠上拉电阻维持高电平,没有上拉电阻,总线一释放电平就悬空,时序全是乱的。实践经验是3.3V供电、短线(几十厘米以内)用4.7kΩ上拉到VCC,5V供电可以继续用4.7kΩ;总线上设备较多或线缆超过2米,上拉电阻换成1kΩ到2.2kΩ。上拉电阻太小会让总线上升沿变陡,但会增加从机导通时的电流;上拉电阻太大,总线电容充电慢,读1时采样到的电平可能还没爬升到位。我自己测试时发现,1米屏蔽线加4.7kΩ上拉,读时序偶尔出错;降到2.2kΩ后连续读几千次都没有CRC错误。
如果你用的是现成的MY18E20模块而不是裸芯片,绝大多数模块上已经焊好了4.7kΩ上拉电阻,直接接开发板就行。只有自己用铁壳探头或者裸芯片搭电路时,才需要专门检查上拉电阻。还有一个容易忽略的点:开发板内部的GPIO上拉电阻通常只有几十kΩ,不能替代外部4.7kΩ上拉,尤其长线场景千万不要依赖内部上拉。
5.2 读回0xFF、复位失败怎么办
读温度一直都是0xFF,通常是总线根本没应答。按这个顺序排查:先量DQ引脚的静态电平,确认有没有上拉或者上拉异常;再检查GPIO编号对不对,很多开发板的丝印和实际GPIO号不一致;然后用万用表量传感器VDD和GND,MY18E20供电异常时也会无响应。如果复位函数始终返回False,还有可能是DQ引脚被配置成了推挽输出而不是开漏/输入切换模式,导致从机根本没机会拉低总线。
我把常见现象和排查方向整理成一个速查表,调试时直接按表操作:
| 现象 | 可能原因 | 优先排查项 |
|---|---|---|
| 复位始终失败 | 上拉电阻缺失 / 接线错误 | 用万用表量DQ静态电平,看是否接近VCC |
| 读回固定0xFF | 设备无回应 / 引脚错误 | 单独测复位返回值,确认GPIO映射 |
| 偶尔CRC错误 | 线过长 / 干扰 | 减小上拉电阻,缩短导线,加屏蔽 |
| 温度固定不变 | 没有重新发转换命令 | 确认循环里先调用convert_temp再read_temp |
| 第一路正常第二路乱 | 多个设备未处理ROM | 一对一连线或实现搜索ROM匹配 |
5.3 时序参数补偿与固件差异
不同开发板、不同MicroPython固件之间,解释器执行速度差异很大。比如ESP32的官方固件跑160MHz,一次空Pin操作大概几微秒;RP2040的MicroPython解释器也跑在MHz级别,但C接口实现不一样,延时会略有不同。我的驱动里写死了几个时间参数,换平台后建议逐项验证:复位拉低时间是否在480μs以上,写1的低电平时长是否小于15μs,读时序采样点是否在15μs内。条件允许的话,用逻辑分析仪抓一次DQ线上的波形,和手册时序图对照,这是最直观的调试方法。
如果没有逻辑分析仪,就靠功能验证:调小写1延时,如果读出来的值开始随机变0,说明实际拉低时间逼近15μs上限了;慢慢加大复位等待时间,直到复位稳定返回True,再留一点余量作为最终参数。这种试凑法听起来土,但在没有示波器的场景下很管用。另外,不同MicroPython固件对time.sleep_us的实现精度也不同,有些第三方固件基于ESP-IDF的esp_timer,精度反而比官方固件更高,遇到驱动不稳定可以换固件版本交叉验证。
5.4 实际项目中的稳定运行经验
写到这里,分享几个长期跑数据采集项目攒下来的经验。第一,温度和别的传感器不同,物理量变化慢,不需要高频刷新,两次转换之间至少间隔1秒,既能降低总线占用,也能让传感器自身温度稳定;第二,每次从read_temp拿到None(CRC失败)时,直接丢弃这轮数据,不要用上一次的值去填充,否则异常会被掩盖;第三,如果做的是多点采集,尽量避免在温度转换期间同时操作WiFi或者刷屏幕,单总线时序对中断敏感,这些操作抢走CPU时间后容易出现偶发CRC错误。我把这些都实现到代码里后,连续跑了两个月,两千多个温度点没有一次异常跳变,这对一个用MicroPython写的驱动来说已经很可靠了。
另外一个小技巧:MY18E20这类芯片重新上电后,内部暂存器通常已经准备好上一次的温度结果,但如果刚上电就立刻读取而没触发转换,读出来的值可能不是当前温度。所以我的初始化流程固定是“先转换,再读取,再进入主循环”,避免把残留值当成实时值用。这个坑在各种开源库的讲解帖里很少有人提,实际却非常常见。