ARM嵌入式学习(四),这一篇的内容跟常见的ARM学习路线有点不一样,我要把主控从ARM暂时换回51单片机,去驱动一只非常经典的DS18B20温度传感器。别觉得这是在走回头路,恰恰相反,很多人在ARM上摸爬滚打半天、GPIO翻转都玩出花来了,一到DS18B20这类有时序要求的传感器就集体翻车。我用51来啃这颗传感器,就是因为它的单总线时序足够"裸",能让你把"I/O引脚到底是怎么控制外部器件的"这件事彻底想明白。
这篇文章会从DS18B20的硬件特性讲起,把单总线复位、读、写的微秒级时序拆开揉碎,再给出完整的51驱动代码,最后聊一聊我实测中遇到的85℃怪象、0xFF读回、负温度计算等一堆坑,以及把51驱动移植到ARM平台的工程化思路。适合正在入门嵌入式、被1-Wire/I2C/SPI等协议卡住的朋友,也适合准备从51转向ARM、想建立"驱动分层"思维的开发者。
1. 在ARM学习路线里插入51和DS18B20,到底图什么
1.1 为什么不是直接从ARM开始啃单总线
我先说一个观察:现在很多人学嵌入式是直接上手ARM的,开发环境、HAL库、例程都给你准备好了。点个灯、发个串口,甚至I2C读个传感器,都有现成库函数,一句HAL_I2C_Mem_Read()就能把数据读出来。这样当然能跑,但一旦遇到问题时,很多人就卡住了:为什么读回来全是0xFF?为什么设备就是没应答?因为库把时序细节全都封装掉了,你根本不知道总线上一瞬间发生了什么。
而51单片机这个"老古董"反而适合做这件事。51的I/O操作极其直接——写1就拉高、写0就拉低,没有复用功能、没有模式配置。更重要的是,51在11.0592MHz晶振下,一个指令周期大约是1微秒,软件延时的时间账非常好算。这就意味着,你在写DS18B20驱动的时候,可以一个_nop_()一个_nop_()地推演时序窗口,这种"手搓时序"的过程,恰恰是嵌入式最值钱的底层能力。
1.2 为什么偏偏选DS18B20当教学道具
DS18B20这颗温度传感器,在嵌入式传感器里属于"小而美"的典型。它只占一根数据线,却要完成复位、命令发送、数据读取整套双向通信,这叫单总线(1-Wire)协议。跟I2C、SPI相比,1-Wire没有时钟线,所有时序全靠时间窗口来界定,对延时的精度和稳定性要求非常高。
这颗传感器还便宜、皮实、随处可见,测温范围-55℃到+125℃,-10℃到+85℃范围内精度是±0.5℃,正常人生活的环境温度它都能覆盖。做机房温控、温室大棚、冷链运输这种实战项目时,它也是常客。我后面接ARM项目时,凡是遇到"外设没反应"的怪问题,基本都是靠它练出来的排查思路去解决的。所以,花一两天在51上把DS18B20玩透,绝对值。
2. 先摸清DS18B20的脾气:引脚、供电、暂存器和温度格式
2.1 三条腿的传感器,供电却有讲究
DS18B20常见封装就三个引脚:VDD接电源(3.0V到5.5V都行,经典5V和3.3V都能跑),GND接地,DQ是数据脚。我第一次用的时候觉得这东西简单得不像话,但供电模式这里就藏着一个坑。
它支持两种供电方式。第一种是外部供电,VDD接3.3V或5V,DQ外接一个4.7kΩ上拉电阻到电源,这是最推荐的做法,信号最稳定,温度转换期间电流也足。第二种是寄生供电,也就是VDD和GND直接连起来接地,靠DQ线高电平时给内部电容充电来维持工作。寄生供电能省一根线,但温度转换期间需要的电流很大,主机必须在这段时间把DQ强制拉高提供"强上拉",否则转换不可靠。我建议入门阶段老老实实用外部供电,三根线的排线比两根线乱不了多少,但能排除掉一堆稀奇古怪的故障。
还要记住一个重要特性:DQ线是开漏结构的,DS18B20只能把总线拉低,不能主动拉高。总线恢复高电平靠的是外部上拉电阻。所以,上拉电阻不是可选项,是必需品。有些开发板已经把4.7kΩ上拉画好了,你只要把DQ插到对应引脚就行;如果是面包板飞线,没加上拉电阻,后面读回来的数据各种跳,你还不一定想得到是这个原因。
2.2 64位ROM、暂存器结构与12位温度数据
每颗DS18B20出厂时都烧录了一个64位的ROM编码,由8位家族码(固定为0x28)、48位唯一序列号和8位CRC校验组成。这个编码的意义在于,一条总线上可以挂很多颗DS18B20,主机通过ROM命令来区分它们。入门阶段我们通常只接一颗,直接用SKIP ROM命令跳过寻址,最省事。
控制DS18B20的核心是它内部的9字节暂存器,我来把这张"内存表"列一下:
| 字节编号 | 内容 | 说明 |
|---|---|---|
| 0 | 温度值低字节 | 读取温度的主要数据 |
| 1 | 温度值高字节 | 高字节含符号位 |
| 2 | TH报警上限 | 可写入,超温时置报警 |
| 3 | TL报警下限 | 可写入 |
| 4 | 配置寄存器 | 设置9到12位分辨率 |
| 5-7 | 保留 | 通常是0xFF |
| 8 | CRC校验字节 | 可用来校验前面8字节 |
温度数据是16位的,传输时先读低字节再读高字节。12位分辨率下,每个LSB等于0.0625℃,也就是1/16℃,最低4位表示小数部分。举个例子:读到0x0191,十进制401,401×0.0625=25.0625℃,这就是25.0625℃的含义。下面是几个典型值对照:
| 原始16位数据(无符号视角) | 有符号十进制 | 温度值 |
|---|---|---|
| 0x0000 | 0 | 0℃ |
| 0x0001 | 1 | 0.0625℃ |
| 0x0191 | 401 | 25.0625℃ |
| 0x07D0 | 2000 | 125℃ |
| 0x0550 | 1360 | 85℃(上电默认值) |
| 0xFF92 | -110 | -6.875℃ |
| 0xFC90 | -880 | -55℃ |
这里最有意思的就是那个0x0550。DS18B20上电复位之后,温度寄存器默认值就是0x0550,换算过来恰好是85℃。所以很多人一接上传感器、还没发任何转换命令就去读温度,读回85℃,就会误以为是环境温度85℃或者传感器坏了。它其实只是一个出厂默认值,不是真实温度。这个坑我后面还会专门展开说。
3. 单总线时序拆解:复位、写时隙、读时隙的时间账
3.1 复位与存在脉冲
既然没有时钟线,DS18B20和主机之间怎么"对齐"?靠的就是严格的时序窗口。每一次通信,主机都要先发一个复位脉冲,才能让总线上所有从机回到空闲状态、等待命令。可以把这个过程理解成开会前的点名:主持人重重敲一下桌子(拉低总线),然后环顾四周(释放总线,等回应),在座的人举手示意(从机拉低总线发出存在脉冲)。
完整的过程是这样的:
- 主机把DQ拉低,至少保持480微秒,这是复位脉冲。
- 主机释放总线,也就是把DQ置1,让上拉电阻把总线拉回高电平。
- DS18B20检测到这个释放沿之后,会等待15到60微秒,然后主动把总线拉低60到240微秒,这就是存在脉冲。
- 主机在释放总线后的大约60到70微秒去读一次DQ引脚,读到低电平就说明总线上有设备应答,读到高电平则说明没有任何设备。
我见过很多人写的复位函数,释放总线后延时很短就去读,结果误判没有设备。这里我实测比较稳的采样点是延时60到70微秒再读,正好落在存在脉冲持续期内。
3.2 写1和写0:时间窗口决定一切
复位之后,主机就可以发送命令了。命令的每一位都有独立的"时隙",每个时隙至少60微秒,两个时隙之间至少要留1微秒的恢复时间。
写1时隙和写0时隙的区别,不在于电平高低,而在于低电平持续的时长:
- 写0:主机拉低总线,持续60到120微秒,然后释放。
- 写1:主机拉低总线,只保持1到15微秒,然后立刻释放,剩余时间让上拉电阻把总线拉高。
DS18B20会在时隙开始后的15到60微秒之间采样总线电平,所以关键是"这个窗口里总线处在什么状态"。写0时总线一直低,采样到0;写1时总线早早恢复高,采样到1。这也是为什么1-Wire时序不能适当放水:如果你写1时低电平时间太长,超过15微秒甚至到几十微秒,从机就可能把它误判成写0。
我早期写代码时图省事,用同一个delay50us()函数糊弄所有时隙,结果命令经常发不进去,设备要么不理我,要么返回乱七八糟的数据。后来我老老实实把写1和写0分成两个独立时序,每个时隙都用示波器对着看,才彻底通畅。
3.3 读时隙:总线释放后15微秒内必须采样
读数据的时隙跟写时隙类似,也是由主机发起。主机先拉低总线,保持至少1微秒,然后释放总线。接下来关键就来了:DS18B20如果要发0,它会自己把总线拉低;如果要发1,它就什么都不做,让总线被上拉电阻拉高。所以总线被释放后的状态,就是从机要告诉主机的数据。
但是,这个数据保持的时间很有限。DS18B20只在读时隙的早期阶段维持总线状态,大约到了时隙的后半段它就不再控制了,总线会因为上拉电阻而恢复高电平。所以主机必须在释放总线之后的大约15微秒内完成采样,读晚了,不管从机本来要发的是0还是1,你采到的都可能变成1。
这也是为什么很多初学者的读函数返回结果几乎全是0xFF。代码里用了delay10us()再读,看着没多长,但加上函数调用、循环判断的开销,采样点早就超出了15微秒窗口。正确的做法是:拉低→延时约2微秒→释放→立刻读引脚。必要的时候可以用逻辑分析仪或者示波器去看实际采样点。
4. 在51上从零敲驱动:用C代码把时序变成现实
4.1 硬件接线与延时基础
我用的实验环境很朴素:随便一块51开发板,主控是常见的STC89C52,晶振11.0592MHz,DS18B20的DQ接在P3.7口,VDD接5V,GND共地,开发板上已经有4.7kΩ上拉电阻。如果你的板上没有上拉,就在DQ和5V之间自己接一个4.7kΩ电阻。
在写时序之前,第一件麻烦事就是微秒级延时。51上最土的办法就是用软件循环,但C语言循环的实际耗时跟编译器优化级别、晶振频率都有关系。我用的延时函数长这样:
sbit DQ = P3^7; void delay_loop(unsigned int n) { // 实测条件:Keil C51,11.0592MHz,0级优化 // 这个循环大约每2次迭代耗时1微秒,具体要以示波器校准为准 while (n--) { _nop_(); } }我自己在11.0592MHz下做了一次实测校准:把逻辑分析仪夹在DQ引脚上,调整循环次数,看波形宽度。你没必要精算每条指令的机器周期,用示波器或逻辑分析仪看实际波形,是最省力气、也最可信的做法。
4.2 初始化、读写字节的实现细节
下面是我实测能稳定工作的核心函数。先看复位和存在检测:
unsigned char ds18b20_reset(void) { unsigned char presence; DQ = 0; delay_loop(250); // 拉低约500us,符合>=480us要求 DQ = 1; delay_loop(35); // 释放后等约70us,落在存在脉冲区间 presence = DQ; // 读到0表示有设备,读到1表示异常 delay_loop(60); // 等待整个存在脉冲结束 return presence; // 返回1表示无设备,返回0表示正常 }接着是写一个位的函数:
void ds18b20_write_bit(unsigned char bit) { if (bit) { DQ = 0; // 拉低,开启写1时隙 _nop_(); _nop_(); // 低电平保持约2us,在1~15us窗口内 DQ = 1; // 释放,让上拉拉高 delay_loop(30); // 补足剩余时隙,总时长约60us } else { DQ = 0; // 拉低,开启写0时隙 delay_loop(30); // 持续拉低约60~70us DQ = 1; // 释放 delay_loop(2); // 预留恢复时间 } }读一个位的函数:
unsigned char ds18b20_read_bit(void) { unsigned char dat; DQ = 0; // 发起读时隙 _nop_(); _nop_(); // 拉低约2us DQ = 1; // 释放总线,关键转折点 _nop_(); _nop_(); // 给从机一点点响应时间,但必须在15us内 dat = DQ; // 立即采样 delay_loop(30); // 补足时隙长度 return dat; }字节级的读写就简单了,注意DS18B20的字节传输是低位在前:
void ds18b20_write_byte(unsigned char dat) { unsigned char i; for (i = 0; i < 8; i++) { ds18b20_write_bit(dat & 0x01); dat >>= 1; } } unsigned char ds18b20_read_byte(void) { unsigned char i, dat = 0; for (i = 0; i < 8; i++) { dat >>= 1; if (ds18b20_read_bit()) { dat |= 0x80; } } return dat; }代码逻辑没什么玄乎的,唯一要注意的就是读写bit函数里延时的配合。我在写的时候每改一次延时参数,都会用逻辑分析仪拉一遍波形确认,而不是拍脑袋觉得"差不多就行"。单总线这东西,差不多就是差很多。
4.3 完整的温度读取主流程与完整代码
驱动DS18B20读取一次温度的标准流程是这样的:复位→发SKIP ROM命令(0xCC)→发启动转换命令(0x44)→等待转换完成→复位→再发SKIP ROM→发读暂存器命令(0xBE)→读取低字节和高字节→拼接成温度值。完整的温度读取函数如下:
float ds18b20_read_temp(void) { unsigned char temp_l, temp_h; int raw; float temp; EA = 0; // 关键时序期间关中断 ds18b20_reset(); ds18b20_write_byte(0xCC); // SKIP ROM,单设备直接跳过寻址 ds18b20_write_byte(0x44); // 启动温度转换 delay_ms(800); // 12位分辨率转换最坏需要750ms ds18b20_reset(); ds18b20_write_byte(0xCC); ds18b20_write_byte(0xBE); // 读取暂存器 temp_l = ds18b20_read_byte(); // 温度低字节 temp_h = ds18b20_read_byte(); // 温度高字节 EA = 1; raw = (temp_h << 8) | temp_l; // 拼接成16位,int默认为有符号 temp = (float)raw * 0.0625f; // 直接乘,负数由补码自动处理 return temp; }主程序里循环调用就行:
void main(void) { float temperature; while (1) { temperature = ds18b20_read_temp(); // 这里接上串口/LCD/数码管把 temperature 显示出来 delay_ms(1000); } }关于raw直接乘0.0625这一点,很多人不太理解负数为什么能自动算对。因为C语言里int默认是有符号的,当你把高字节拼进去以后,如果符号位是1,raw本身就是一个负整数。而负整数的存储形式是补码,比如-55℃对应的原始数据0xFC90,在有符号int视角就是-880,再乘0.0625就得到-55。这一套补码机制是硬件和编译器自动完成的,你不需要手动取反加一。如果你用的是51的Keil C51,int默认就是有符号,直接用就行。
5. 实测踩坑记录:85℃、0xFF和负温度的前因后果
5.1 读回0xFF和85℃的完整排查链路
我在调试这颗传感器的过程中,翻过不少次车,但最后总结下来主要就两类现象。
第一类,读回0xFF。这说明你从DQ引脚上读到的一直是高电平,总线从头到尾没有被拉低过。遇到这种情况,排查顺序我建议是这样:
- 查硬件接线:GND有没有接,DQ是不是真的插对了引脚,上拉电阻有没有焊上去。
- 用示波器看复位波形:复位脉冲有没有拉到约500微秒的低电平?释放后能不能回到高电平?
- 逐位检查写命令时序:写1的低电平时间是不是超过15微秒了?如果是,DS18B20会把很多写1误判成写0。
- 检查读时隙的采样点:是不是释放总线后拖了太久才采样,导致从机拉低的电平已经被上拉电阻恢复成高了。
我遇到过最隐蔽的问题是:代码在读写时隙里调用了普通的delay_ms()函数,而这个函数的实现依赖定时器中断。结果就是,时序操作刚进行到一半,定时器中断一来,中断服务程序在里面跑了几十微秒,正在进行的读时隙采样窗口早就过去了。从那以后我再也不用中断驱动的方式来跑这类微秒级时序。
第二类,读回稳定的85℃。这个真的太经典了,我第一次看到时还以为是实验室温度真的这么高,差点去摸传感器。后来查了手册才明白:DS18B20上电后温度寄存器的默认值就是0x0550,换算等于85℃。它只是在告诉你"我还没做过温度转换"。
以下几种情况最容易让你看到这个85℃:
- 你发了启动转换命令,但没有等待足够的时间就去读暂存器。12位分辨率转换需要最多750毫秒,如果你只延时了几十毫秒甚至几毫秒,读到的基本还是旧的默认值。
- 复位不够彻底,或者SKIP ROM命令没发成功,DS18B20根本不理会接下来的读操作。
- 你读的地址不对,比如发的命令是0xBE之前的某一个数,寄存器内容没被正确索引。
最靠谱的排查办法就是:用逻辑分析仪把整个通信过程抓下来,一眼就能看出主机到底发了几条命令、从机有没有出现存在脉冲。这个工具在嵌入式调试里真的值得早点学会。
5.2 负温度和小数处理
温度低于0℃时,DS18B20返回的16位数据是一个补码形式的负数。比如-6.875℃对应的原始值是0xFF92。如果你把temp_l和temp_h拼成一个无符号的int,那0xFF92会被看成65426,再乘0.0625,得到4087.875℃,显然不对。所以拼成有符号int、然后直接乘0.0625,是省心又正确的方式。
如果要自己手动处理,也可以这样理解:先看最高位(bit15)是不是1。是1,说明是负数,就把这个16位数按位取反加一,换算成绝对值,再加上负号。我早期为了彻底搞懂补码原理,确实手动写过一遍,但项目里最终还是用有符号int一步到位。
还有小数部分。12位分辨率下,数据低4位就是小数部分,每个LSB=0.0625℃。测温结果不能直接当整数用。我之前遇到过一个需求,要显示到0.1℃,输出一位小数就行。我这里推荐一个定点计算的思路:raw * 625之后再除以10000,得到的就是带一位小数的值,避免了51单片机浮点运算的开销。代码大致是这样:
long temp_scaled = (long)raw * 625; // 相当于乘以0.0625再乘以10000 long temp_output = temp_scaled / 10000; // 结果:25表示2.5℃,-55表示-5.5℃如果你压根不需要小数,只想看个大概温度,可以只用raw >> 4,因为右移4位就相当于除以16,直接拿到整数部分。但要注意,负数的右移跟正数不同,最终显示时还是建议用有符号数直接计算。
5.3 容易被忽视的转换时间与中断干扰
DS18B20的分辨率可以通过配置寄存器设置成9位、10位、11位、12位,上电默认是12位。分辨率越高,转换时间越长,具体看这个表:
| 分辨率 | 转换时间(最大值) | 温度步进 |
|---|---|---|
| 9位 | 93.75ms | 0.5℃ |
| 10位 | 187.5ms | 0.25℃ |
| 11位 | 375ms | 0.125℃ |
| 12位 | 750ms | 0.0625℃ |
我一开始只延时了100毫秒就去读,结果读到的温度数值一直停留在某个固定值上,怎么都不更新。后来才意识到,12位转换要750毫秒,必须等它转换完。这里有三个解决办法:一是简单粗暴,延时800毫秒以上再读;二是轮询DQ引脚状态,DS18B20在转换期间会把总线拉低,转换完成后再释放,主机可以等它释放再继续;三是用定时器安排一个"转换完成后再读取"的状态机,不阻塞主循环。入门用第一种就够,但做实际产品时建议用第三种思路。
关于中断干扰,我再强调一次。1-Wire协议的时序窗口都是微秒级的,你无法预知中断会在哪一条指令处插进来。我曾经开着串口中断去读DS18B20,结果在采样指令前一刻被串口接收中断打断了几十微秒,读回来的数据就变成了0xFF。解决办法就是在每次完整的DS18B20通信过程中关掉中断,读完立刻开回来。如果你的项目中还有其他紧急任务,就用互斥锁、临界区这些机制来保护这段时序代码。
6. 从51迁到ARM:驱动移植的第一步和后续扩展
6.1 移植要改的三处核心
51上跑通的驱动,按理说可以直接复制到ARM吗?不能,但也不用从零重写。实际上你只需要改三处:引脚操作、微秒延时、底层IO配置。
先把协议层和平台层拆开。协议层就包含初始化时序、读bit、写bit、读字节、写字节、读温度这些函数,它们只关心"把引脚拉低""把引脚拉高""读引脚电平""延时若干微秒",不关心引脚具体是P3^7还是GPIOA PIN5。平台层则负责提供这四个基础操作。将来换芯片,只需要重写平台层,协议层完全可以原封不动搬过去。
以STM32为例,改动最大的是引脚操作。51的P3口是准双向口,直接赋值就能拉高拉低,读电平也直接读引脚。而ARM的GPIO要先把引脚配置成开漏输出模式,利用外部上拉电阻实现电平释放。读温度时切换到输入模式,或者直接读取输入数据寄存器。这里我列一下典型的平台层接口:
void ow_dq_low(void) { // 拉低DQ HAL_GPIO_WritePin(OW_GPIO_Port, OW_Pin, GPIO_PIN_RESET); } void ow_dq_high(void) { // 拉高DQ HAL_GPIO_WritePin(OW_GPIO_Port, OW_Pin, GPIO_PIN_SET); } unsigned char ow_dq_read(void) { // 读取当前DQ电平 return HAL_GPIO_ReadPin(OW_GPIO_Port, OW_Pin); }延时也要换。51靠软件循环,ARM上最好用DWT(Core Debug)或者硬件定时器实现微秒级延时,不要用HAL_Delay,因为它只有毫秒级精度,满足不了1-Wire的15微秒采样窗口。
还有一层容易忽略的是编译器和类型。51上int默认有符号、16位,在ARM上int虽然也是32位的、依然有符号,但如果你用无符号短整型去拼温度数据,负温度就会出现巨大偏差。移植时建议显式写成signed int或者int16_t,避免平台差异暗算你。
6.2 基于DS18B20的温控系统还能往哪走
DS18B20单颗用起来只是入门,真正让它发光的是"多点组网"和"温控联动"。一条1-Wire总线上能挂几十颗DS18B20,每颗都有唯一64位ROM,通过搜索ROM算法可以逐个读出所有设备编号,然后针对性地向某颗发MATCH ROM命令、读取它的温度。做好事软件上就是一层协议处理,硬件上只需要多接几个传感器,在同一根DQ线上并联就行。
更实际一点的应用是温控系统。比如我做过一个模拟机房温度监控的Demo:DS18B20采集温度,主控判断当前温度,超过35℃就开风扇,低于10℃就报警。控制逻辑本身不复杂,难点反而在于"采到的数据可信"——所以我在产品里做了一次滤波处理:连续读5次,去掉最高最低再取平均。这种小细节,比急着接继电器、接加热棒更能决定系统稳不稳。
如果你要继续扩展,还可以让DS18B20把报警阈值TH、TL写进EEPROM,通过ALARM SEARCH命令自动找出所有超温设备,主控只跟超温设备通信,效率高很多。再往上就是无线化,把单片机读到的温度通过串口、蓝牙、Wi-Fi模块上报,这就是一个标准的物联网温湿度节点了。
我个人的体会是,DS18B20这个传感器虽然简单,但它把嵌入式里最核心的几个东西串起来了:读芯片手册、理解时序、写底层驱动、排查硬件问题、跨平台移植。你在51上把它跑通,再去ARM开发板上走一遍,你会发现之前觉得很高深的外设驱动,其实就是一层窗户纸。真要说有什么技巧值得记住,那就是:驱动代码一定要按平台层和协议层分开写,别把引脚操作揉在协议逻辑里。这样以后无论换什么芯片,你都能在一个小时内把DS18B20重新点起来。