在嵌入式这条路上摸爬滚打好几年,我自己的体会是:很多看起来高级的东西,底子反而是最简单的。ARM嵌入式学习系列写到现在,前面几篇把开发环境、启动流程、GPIO和时钟这些地基捋了一遍,这一篇我打算换个思路,不急着上复杂的ARM外设,而是先回到51单片机上,把DS18B20这个温度传感器彻底玩明白。DS18B20是数字温度传感器里最经典的一颗,单总线协议、时序驱动、数据解析,每一个环节都足够硬核。它就是最适合用来训练"裸机驱动思维"的教材,而且等你把它吃透了,回头再写ARM上的同类传感器,基本就是换汤不换药,寄存器操作换成库函数或者HAL层的API而已。
这一篇会覆盖:DS18B20的硬件连接、单总线通信原理、初始化/读写三种时序的逐位拆解、完整的51驱动代码、以及我实际调试中踩过的一堆坑。适合刚入门单片机、准备往ARM方向走的同学,也适合那些已经在用STM32但一直没搞懂底层时序的朋友。看完你至少能独立写出一个稳定可靠的DS18B20驱动,并且知道出了问题该往哪个方向排查。
1. 这一篇要解决的问题:为什么先从51学DS18B20
1.1 温度测量的场景与DS18B20的定位
温度采集几乎出现在所有嵌入式项目里:环境监控、设备过热保护、冷链运输、工业现场、智能家居。而DS18B20能在这么多方案里长期占有一席之地,靠的是三个特点:单总线接口、数字输出、可编程精度。
所谓单总线(1-Wire),就是数据线和时钟线合二为一,只有一根线完成双向通信。这在硬件设计上非常省事,MCU只要一个GPIO就能挂载一颗甚至多颗传感器。数字输出意味着不需要ADC,没有模拟采样的漂移问题,直接读出12位温度数据。可编程精度则让用户能在9bit到12bit之间取舍,精度越高转换时间越长。
和模拟温度传感器(比如热敏电阻、LM35)相比,DS18B20的优势是省掉了运放、分压电阻和校准环节。和I2C/SPI的数字传感器相比,它的优势是占用IO少、布线简单。代价则是时序要求严格,通信需要靠精确延时来保证,这对初学者来说是挑战,但对想搞懂底层机制的人来说反而是好事。
1.2 从ARM嵌入式视角看51入门的意义
可能有人会问:我都准备学ARM了,为什么还要回头折腾51?我第一次接触DS18B20也是用51单片机,当时觉得这玩意儿时序太麻烦,后来写了STM32版本才发现,底层逻辑完全一样。
本质上,51单片机的GPIO操作是最原始的:直接控制寄存器某个位。这种"裸"的方式逼着你理解每个电平变化的时刻。而ARM(比如STM32)提供了丰富的库函数,你可以一行代码翻转引脚,但如果你不懂时序,库封装得再好也救不了你——因为DS18B20不吃"电平翻转"这一套,它吃的是"这个电平维持了多少微秒"。
所以我的建议很明确:学协议、学时序、学传感器驱动,先在51上搞一遍。51的主频低(一般12MHz),指令周期清晰,时序计算直观,调试难度低。等你在51上把单总线时序吃透了,再迁移到ARM,只需要改延时函数的实现方式,其他逻辑原封不动。
1.3 这篇内容的整体脉络
我开始写这篇之前,把DS18B20相关的资料又过了一遍,发现大多数教程的问题在于:要么代码直接贴出来,没有讲为什么这么写;要么只讲时序,不给完整的可运行工程。所以我打算按这样的顺序来讲:
- 先讲硬件连接和单总线的通信模型,搞清楚物理层是怎么回事;
- 再拆解初始化和读写时序,这是驱动的心脏;
- 然后给一套完整的51工程代码,逐段注释;
- 最后把我在调试中遇到的典型问题列成排查表。
2. DS18B20硬核基础:单总线协议与内部结构
2.1 引脚与硬件连接
DS18B20有三个引脚:VDD、DQ、GND。标准封装是TO-92,长得和三极管差不多。有的人第一次拿到会搞错引脚顺序,我建议以芯片型号面朝自己、引脚朝下为准,从左到右依次是GND、DQ、VDD。这个顺序一定要对着数据手册确认,不同封装的引脚定义不完全一样,别凭记忆乱接。
DQ是数据线,也是单总线的核心。它的工作方式很特别:属于开漏输出,必须外接一个上拉电阻。推荐阻值是4.7kΩ。为什么需要上拉电阻?因为单总线的空闲状态是高电平,总线上的设备都是靠拉低电平来发起通信的。没有上拉电阻,总线拉低后无法恢复到高电平,通信直接就废了。
供电方式有两种:外部供电和寄生供电。外部供电就是VDD接3.3V或5V,GND接地;寄生供电则是不接VDD,直接从DQ线上取电,利用内部电容在总线空闲时充电。寄生供电省一根线,但时序要求更严格,而且驱动能力有限,初学者千万别用这个模式,老老实实外部供电。
注意:DS18B20工作电压范围是3.0V~5.5V。用3.3V的单片机(部分ARM)可以直接连接;用5V的51单片机也完全没问题。唯一要注意的是总线上的上拉电阻到底接哪个电源,必须接MCU同一侧的电源,保证电平匹配。
2.2 单总线通信模型
单总线的通信模型可以理解为"半双工、主机主导"。总线上只有一个主机(MCU),可以挂多个从机(多个DS18B20)。所有从机共用一条DQ线,所以必须有机制区分"现在跟谁说话",这就是ROM地址的作用。
每颗DS18B20出厂时都有唯一的64位ROM编码,其中8位是家族码(DS18B20固定为0x28),48位是序列号,8位是CRC校验。主机可以通过发送ROM指令来寻址某个特定设备。
协议层的指令分两类:ROM指令和设备功能指令。ROM指令有Read ROM(0x33)、Match ROM(0x55)、Skip ROM(0xCC)、Search ROM(0xF0)等。设备功能指令有Convert T(0x44,启动温度转换)、Read Scratchpad(0xBE,读暂存器)、Write Scratchpad(0x4E)等。
我只用一颗传感器的时候,ROM指令直接用Skip ROM跳过,不用管64位地址。但如果是多颗传感器挂同一根总线,就必须处理ROM匹配,这个后面再展开。
2.3 温度数据格式与暂存器布局
DS18B20内部有一个9字节的暂存器(Scratchpad)。我们最关心的是前两个字节,也就是温度数据的低字节和高字节。
温度数据是16位有符号数,格式分两部分:符号位和数值位。高字节的高5位是符号扩展位:温度为正时,这5位全是0;温度为负时,这5位全是1。剩下的11位是温度数值,其中最低4位是小数部分,高7位是整数部分。
举个例子:如果读到低字节为0xD0,高字节为0x07,合起来是0x07D0。二进制是0000 0111 1101 0000。去掉高5位符号扩展,有效数据是0xFD0?不是,应该先看整体:0x07D0的最高位(第15位)是0,表示正温度。数值部分是低11位,即111 1101 0000,等于0x7D0,即2000。由于最低4位是小数部分,实际温度就是2000 × 0.0625 = 125.0℃。对,这个例子里的0x07D0对应的就是125℃。
再比如负温度:读到0xFC90。最高位是1,表示负数。为了得到真正的数值,需要对整个16位取反加一:0xFC90取反是0x036F,加1后是0x0370,即880,乘以0.0625得到55.0℃,所以实际温度是-55.0℃。
分辨率是可配置的:9bit对应的温度增量为0.5℃,10bit为0.25℃,11bit为0.125℃,12bit为0.0625℃。12bit转换时间最长,约750ms,9bit只需要93.75ms。这个参数在配置寄存器(暂存器第4字节)里设置,默认是12bit。
3. 三种关键时序的实现细节
3.1 初始化时序
DS18B20的所有通信都是从主机发起复位脉冲开始的。整个过程分两步:主机拉低总线至少480μs,然后释放总线,等待从机的存在脉冲。
具体来说:主机先把DQ拉低,保持480μs到960μs之间。然后释放总线,上拉电阻把总线拉回高电平。从机收到复位脉冲后,会在60μs到240μs之间拉低总线,持续60μs到240μs,产生存在脉冲。主机在释放总线后,应该在60μs到240μs的时间窗口内采样总线,如果读到低电平,说明总线上有DS18B20应答了。
我用51来实现时,最头疼的就是这个"采样窗口"。不能太早也不能太晚。经验做法是释放总线后延时70μs左右再读引脚,这个时间点比较安全。
// 初始化:返回1表示存在,0表示不存在 bit DS18B20_Init(void) { bit presence = 0; DQ = 1; delay_us(10); // 让总线先稳定 DQ = 0; // 拉低总线 delay_us(600); // 保持至少480us DQ = 1; // 释放总线 delay_us(70); // 等待从机响应 presence = DQ; // 读取存在脉冲,0=有设备 delay_us(230); // 补足整个时序周期 return (presence == 0); }delay_us函数是这里的关键。51单片机常用的方式是软件延时,用_NOP()空指令或者循环计数。如果用12MHz晶振,一个机器周期是1μs,定时器或者纯循环都能比较准确地控制延时。但要注意,不同编译器(Keil C51的优化等级)对循环的优化不同,我遇到过优化等级一改,延时时间就变了的情况。所以软件延时最好用volatile变量,并且实测验证。
3.2 写时序
写时序分为写"1"和写"0"。单总线的写时序最小周期是60μs,两次写之间至少要间隔1μs。
写0的流程是:主机拉低总线,然后一直保持低电平,持续60μs以上。写1的流程是:主机拉低总线,但只维持很短的延时(1μs到15μs),然后释放总线,让上拉电阻把总线拉高,持续60μs以上。
所以核心区别在于:拉低之后释放的时间点。写0不释放,写1要快速释放。DS18B20会在主机拉低总线后的15μs到60μs之间采样总线电平,所以只要你在15μs内释放总线并拉高,它读到的就是1;如果你一直保持低电平,它读到的就是0。
// 写1字节,data为要写入的数据 void DS18B20_WriteByte(unsigned char data) { unsigned char i; for (i = 0; i < 8; i++) { DQ = 0; // 起始:拉低总线 delay_us(2); // 保持低电平2us,满足写时序起始要求 if (data & 0x01) // 先写低位 { DQ = 1; // 写1:释放总线 } delay_us(60); // 保持整个写时隙 >60us DQ = 1; // 恢复总线空闲高电平 data >>= 1; } }很多人在写"1"的时候搞了一个小误区:以为写1就是把DQ置1就完了。实际上因为开漏结构,DQ直接置1也就是释放总线,真正的电平靠上拉电阻实现。所以在51代码里,写1的操作就是"先拉低、再释放",而不是直接置位。
3.3 读时序
读时序比写时序稍微麻烦一点,因为读的时候总线方向要反转。主机发起一个读时隙,拉低总线至少1μs,然后释放,在释放后的很短时间内(通常是15μs以内)去采样总线电平。DS18B20看到主机拉低后会迅速决定是否拉低总线:如果它要传0,就继续保持低电平;如果要传1,就不动,让上拉电阻维持高电平。主机必须在这个时间窗口内把电平读走。
需要注意:读时序的采样点一般在拉低总线后的15μs以内。太晚了,传输0和1的区别就不明显了。
unsigned char DS18B20_ReadByte(void) { unsigned char i, data = 0; for (i = 0; i < 8; i++) { data >>= 1; DQ = 0; // 发起读时隙 _nop_(); // 拉低后保持极小延时 _nop_(); DQ = 1; // 释放总线 delay_us(8); // 等待从机驱动总线 if (DQ) { data |= 0x80; // 读到高电平,该位为1 } delay_us(60); // 保证整个读时隙 >60us } return data; }读时序的"采样点"其实是个经验值。有些人的代码里用的是delay_us(10)或者delay_us(15),差别不大,但绝对不能小于1μs(太早了总线还没稳定),也不能大于15μs(可能会漏掉从机拉低的信号)。实际调试时可以用逻辑分析仪抓波形,看采样点是否落在安全的区域内。
3.4 时序参数整理与常见延时误区
把DS18B20数据手册里的时序参数整理成一张表,方便对照:
| 时序项目 | 最短 | 最长 | 说明 |
|---|---|---|---|
| 复位脉冲低电平 | 480μs | 960μs | 主机拉低 |
| 存在脉冲等待时间 | 15μs | 60μs | 释放后开始等待 |
| 存在脉冲低电平 | 60μs | 240μs | 从机应答 |
| 写时隙起始低电平 | 1μs | 15μs | 主机拉低后采样窗口 |
| 写1时释放总线时间 | 1μs | 15μs | 释放后维持高电平 |
| 写时隙总时长 | 60μs | 120μs | 含起始和恢复 |
| 读时隙起始低电平 | 1μs | 15μs | 主机拉低 |
| 读时隙采样窗口 | 15μs | 15μs | 释放后采样 |
| 读时隙总时长 | 60μs | 120μs | 含起始和恢复 |
最常见的延时误区有两个:
第一,软件延时不准确。尤其在使用Keil C51时,不同优化等级会改变循环体生成的汇编指令数,导致延时有几十微秒甚至上百微秒的偏差。这个问题很难通过"看代码"发现,只能通过示波器/逻辑分析仪实测。所以我建议在关键时序部分使用_nop_()函数或者定时器延时,别依赖复杂的循环嵌套。
第二,中断干扰时序。如果在温度转换或者读取过程中打开了全局中断,而中断服务函数耗时过长,就会破坏DS18B20严格的时序窗口。尤其是写时序和读时序,一个微秒级的窗口被打断就直接废了。经验做法是:在整个通信过程中关闭中断,或者确保中断服务函数足够短。
4. 完整驱动代码与实操过程
4.1 底层GPIO操作设计
用51驱动DS18B20,最重要的底层就是DQ引脚的定义。用Keil C51的话,sbit是最自然的写法:
sbit DQ = P1^0; // DS18B20数据线接在P1.0这里我建议把DQ引脚定义放在一个单独的头文件里,这样换引脚只需要改一处。如果你用的是STC或者其他增强型51,某些芯片支持更强的推挽模式,但对单总线来说,开漏加上拉是最标准的,别轻易改。
延时方面,我前面说了尽量别用复杂循环。这里提供一个基于空指令的通用微秒延时:
void delay_us(unsigned int us) { while (us--) { _nop_(); _nop_(); _nop_(); _nop_(); _nop_(); } }但这种方式受主频影响很大,12MHz和11.0592MHz的周期不一样,nop()的数量也需要调整。我的做法是在调试时用逻辑分析仪测实际延时,然后微调循环体里的空指令数,不要盲目照搬网上的延时函数。
4.2 驱动函数实现
完整的驱动我按功能拆成几个独立函数:初始化、写字节、读字节、读温度。读取温度的完整流程如下:
- 复位,等待设备应答。
- 发送Skip ROM(0xCC),跳过ROM匹配。
- 发送Convert T(0x44),启动温度转换。
- 等待转换完成(可以延时750ms,也可以读总线电平判断忙状态)。
- 再次复位。
- 发送Skip ROM(0xCC)。
- 发送Read Scratchpad(0xBE),读暂存器。
- 读取低字节、高字节,合并成16位温度数据。
- 按精度系数转换成实际温度值。
float DS18B20_GetTemperature(void) { unsigned char tempL, tempH; int temp; float temperature; DS18B20_Init(); // 复位 DS18B20_WriteByte(0xCC); // 跳过ROM DS18B20_WriteByte(0x44); // 启动温度转换 delay_ms(750); // 等待转换完成,12bit分辨率 DS18B20_Init(); // 再次复位 DS18B20_WriteByte(0xCC); // 跳过ROM DS18B20_WriteByte(0xBE); // 读暂存器 tempL = DS18B20_ReadByte(); // 低字节 tempH = DS18B20_ReadByte(); // 高字节 temp = (tempH << 8) | tempL; // 合成16位 // 判断正负 if (temp & 0x8000) { temp = ~temp + 1; // 取反加一得到绝对值 temperature = temp * 0.0625; temperature = -temperature; } else { temperature = temp * 0.0625; } return temperature; }多说一句:delay_ms(750)是12bit分辨率下的保守等待时间。如果你在追求低功耗或者快速循环,可以用9bit分辨率,转换时间只有93.75ms,延时100ms就够了。但如果后面不处理CRC,也不校验,等待时间不足很可能读到旧数据。
4.3 主流程与温度值显示
主函数里怎么用这个驱动,核心就是循环读取、处理、显示。51环境里通常用数码管、LCD1602或者串口输出。我用串口输出做调试最方便,把温度值格式化后发到PC端观察。
void main(void) { float temp; unsigned char buf[32]; UART_Init(); // 初始化串口,9600bps while (1) { temp = DS18B20_GetTemperature(); sprintf(buf, "Temp: %.2f C\r\n", temp); UART_SendString(buf); delay_ms(1000); // 每秒采样一次 } }这一小段代码看起来简单,但有几个地方要注意。sprintf在C51环境下比较耗资源,如果你用的51芯片ROM不大,可以考虑用整数运算自己拼字符串,避免引入printf系列函数。我是用STC8系列测试的,资源充裕无所谓,但老C51还是悠着点。
浮点运算在51上也是老大难。51没有硬件浮点单元,所有的float运算都是软件模拟,速度很慢。如果你对速度敏感,可以不转成float,直接保留整数。例如温度值用"整数部分 + 小数部分"分别表示,或者用温度 × 16 的整型值来处理,最后显示时再决定小数点位置。
4.4 移植性设计:为什么为ARM做准备
前面说过,51版的驱动逻辑可以直接搬到ARM上,但底层部分一定要做接口隔离。我的习惯是写两个文件:ds18b20.c(协议层)和hw_gpio.c/hw_gpio.h(硬件抽象层)。协议层只调用HAL提供的几个原语,比如gpio_set_low()、gpio_set_high()、gpio_read()、delay_us()。
这样当你从51换到STM32时,只需要重写hw_gpio相关函数。比如ST的HAL库:
void gpio_set_low(void) { HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_RESET); } void gpio_set_high(void) { HAL_GPIO_WritePin(DS18B20_PORT, DS18B20_PIN, GPIO_PIN_SET); } uint8_t gpio_read(void) { return HAL_GPIO_ReadPin(DS18B20_PORT, DS18B20_PIN); }延时函数从软件延时换成滴答定时器或者定时器通道。协议层代码基本不用动。这就是我在51上花时间写"看起来没必要"的驱动的原因:养成分层隔离的习惯,后面会省大量时间。
5. 常见问题、排查方法与避坑实录
5.1 读不到存在脉冲怎么办
这个问题我遇到太多次了。现象是初始化函数一直返回0,或者返回值不稳定。排查顺序:
- 先查接线:VDD和GND别接反,DQ是不是真的接在P1.0上。这个最基础但最容易错。尤其是面包板上一堆杜邦线,颜色都一样,很容易插错。
- 再查上拉电阻:DQ到VCC之间有没有4.7kΩ的上拉?有些开发板没有上拉,需要外接。如果忘了接,总线永远是低电平,设备不可能应答。
- 最后查供电:最好外部供电,VDD接3.3V或5V。寄生供电模式虽然也能用,但对时序更敏感,不建议排障时用。
一个亲身经历:我第一次用51写DS18B20驱动时,读了无数遍数据手册,代码写得很标准,但就是读不到设备。最后拿万用表一量,才发现面包板那一排的电源轨接触不良,VDD根本没通。硬件上的低级错误,反而是排查起来最耗时的。
5.2 温度一直是85℃的疑惑
如果你发现程序能跑,温度显示固定是85℃(0x07D0),这其实是数据手册里的一个"坑":DS18B20上电后的默认温度寄存器值就是85℃。如果你没有成功启动转换就直接读暂存器,读到的就是这个默认值。所以这不是传感器坏了,而是你的转换流程没走通。
排查思路很简单:检查Convert T指令有没有正确发出,检查延时等待是否足够。还有一种情况:如果你读取温度时,中途总线时序被干扰,有可能读到的数据依然是0x07D0。用逻辑分析仪抓一下波形,看看复位、写指令、读暂存器三个阶段的时序波形是否完整。
5.3 时序延时与中断冲突
51上最麻烦的就是中断。我调试的时候开着定时器中断做LED闪烁,结果DS18B20读出来的温度时对时错。原因很简单:中断打断了读时隙的采样窗口,读到的位全乱了。
解决方案有两个方向。方向一:在DS18B20通信期间关中断,EA=0,读完再开。方向二:如果必须保持中断,那就减少中断处理时间,最好只置标志位,不处理耗时逻辑。我强烈推荐第一种,简单粗暴,而且通信过程本身也就几十毫秒,关中断对系统影响很小。
提示:DS18B20启动温度转换后,在等待转换的几百毫秒里是可以开中断的。真正需要关中断的是复位/读写字节这几个毫秒级时段,把握好这个粒度,系统的实时性不会被破坏。
5.4 多设备挂载与ROM匹配
单总线上挂多颗DS18B20时,Skip ROM就不行了,必须做ROM匹配。流程是:先用Search ROM(0xF0)枚举总线上所有设备的64位ROM地址,保存下来;然后每次想读取某颗传感器时,先发Match ROM(0x55),再发送64位ROM地址,然后操作。
这个枚举过程对新手来说不是一般的绕。我第一次做的时候,用单总线库简化,但理解上还是费了不少劲。建议先用两颗传感器验证,把ROM编码打印出来,确认枚举结果正确,再扩大规模。另外,如果多颗传感器供电不足,也可能导致响应不稳定,最好每一颗都外部供电。
5.5 排查工具与经验:逻辑分析仪必买
如果认真做嵌入式,尤其是驱动开发,逻辑分析仪应该人手一台。上百块钱的8通道逻辑分析仪配合开源软件,就能把DS18B20的时序波形看得清清楚楚。比对着示波器猜延时不知道高效多少倍。
具体怎么看:把逻辑分析仪的通道接在DQ线上,设置采样率至少1MHz,触发条件设为下降沿。然后运行程序,就能抓到完整的复位脉冲、存在脉冲、读写时隙。对照数据手册上的时序参数,一眼就能看出哪个环节延时不够,或者哪个时隙的电平翻转不正常。
我的习惯是:先抓复位和存在脉冲,确认物理层通信正常;再抓写时序,确认指令字节正确送到设备;最后抓读时序,确认返回数据符合预期。三步层层排查,基本没有解决不了的问题。
6. 从51到ARM的迁移思路与个人体会
驱动DS18B20这套流程,我后来在ARM平台上也写过多次。每次写的时候都庆幸当初在51上把时序啃了下来。51的主频低,寄存器和GPIO操作直接,调试器也简单,非常适合把单总线协议这种"微妙"的东西彻底搞透。而ARM的优势在于主频高、外设丰富,但掩盖不了协议本身的复杂度。
我个人的建议是,大家在学ARM嵌入式时,别急着只盯着ARM芯片的特殊外设,抽个时间把DS18B20、I2C从机、SPI Flash这类经典外设在51上写一遍。等你能不看参考代码,独立写出可用的驱动,再来写ARM版本,你会发现自己对底层的理解完全不同。
还有一个很实用的扩展思路:把DS18B20的驱动改造成一个简单的"传感器总线框架"。底层单总线驱动只负责字节读写,上层用状态机管理温度采集任务,再配合一个环形缓冲区存储历史温度数据。这个框架可以直接迁移到ARM上,配合RTOS时每个传感器一个任务,几乎不用改协议层。我实际做过一个温控项目,就是在51上先验证这个框架,然后把整个驱动层无缝挪到ARM上,整个调试时间缩短了至少一个下午。
最后再分享一个细节技巧:DS18B20在温度转换期间,总线上的电平其实会表现出"忙状态",也就是DQ一直被从机拉低。你可以在启动Convert T之后,不断读取DQ引脚,看它什么时候变高,以此判定转换完成,这样可以省掉固定的等待时间,采样周期也能更紧凑。这个技巧在51上实现很简单,ARM上同样适用,实测下来能把采样周期从750ms缩短到200ms左右,相当实用。ARM学习路上,这类"小技巧"积累多了,你就会发现,难的不是代码本身,而是每一步背后那一层没被说透的逻辑。DS18B20就是个最好的起点。