1-Wire协议实战:STM32驱动多路DS18B20温度采集 📅 2026/8/26 7:22:29 这两年做嵌入式项目经常被“线太多”折腾得头疼。传感器要供电、要地、要信号三根线起步多点采集时线束更是绕成蜘蛛网。后来在冷链温湿度记录仪的项目里改用1-Wire协议一条总线挂十几个DS18B20温度探头只拉一对双绞线就全解决了。这个协议在工业现场、智能家居、农业大棚里出现频率非常高尤其适合“传感器分散、距离几十米、不想布线太复杂”的场景。这篇就围绕“Putting 1-Wire Protocol into Action”展开从原理到底层时序再到STM32上的完整驱动和排坑经验一次讲透。1-Wire协议由Dallas Semiconductor现为Maxim Integrated推出单根数据线既传数据又能给从机供电通信距离在标准模式下可达几十上百米主机只需要一个GPIO口就能挂接大量从机。对刚接触的人来说最陌生的是它的“严格时序”读写每一位都要精确控制延时和I2C、SPI那种靠时钟线同步的思路完全不同。这篇内容适合正在做单片机采集、温度监控、设备识别或传感器网络的朋友尤其是想啃底层协议、不满足于直接抄库函数的开发者。1. 认识1-Wire一根线怎么既传数据又供电1.1 单总线的通信模型1-Wire总线本质上是一个主从式半双工通信系统所有从机挂在同一条数据线上主机MCU的某个GPIO发起所有通信。总线上没有独立的时钟线从机靠主机发出的时序边沿来同步节奏没有独立的电源线部分器件能从数据线上“偷电”工作这就是它最特殊的点——寄生供电。整个网络的硬件结构非常简单主机GPIO接一只上拉电阻到VCC通常3.3V或5V然后数据线延伸到各个从机的DQ引脚每个从机再有一条独立的GND回路。通信过程由主机控制从机永远不会主动说话主机问一句、从机答一句。因为从机只能通过“拉低总线”来表达自己的状态所以总线必须依赖上拉电阻保证空闲时有确定的高电平。1.2 为什么某些场景非它不可做技术选型时很多人问我有I2C、SPI、UART为什么还要用1-Wire我的回答是它牺牲速度换来了极端的接口简化。I2C虽然也是两根线但地址只有7位一条总线上同类传感器地址相同就容易冲突SPI速度虽快可每加一个从机就要多一根片选线RS485距离远但需要收发器和双线差分。1-Wire最吸引人的地方是每个器件出厂时烧录了唯一的64位ROM序列号一条总线上可以挂几十个同型号芯片不需要额外地址引脚也不存在地址撞车问题。再加上单线供电成本非常低特别适合温度采集这种低速、多点、远距离的场景。1.3 典型器件和应用场景说到1-Wire器件必须提DS18B20几乎成了这个协议的代名词。这颗数字温度传感器精度在-10°C到85°C范围内可以做到±0.5°C分辨率可配置成9到12位直出数字结果不需要校准。除了测温常用的1-Wire器件还有DS24311Kb EEPROM常用于设备身份识别、校准参数存储。DS2405单路可寻址开关做远端IO控制很实用。DS28E05廉价安全认证芯片用于防伪。DS24088通道IO扩展器一条总线扩展出多个IO。应用场景上最典型的是多测点温度监控比如养殖大棚、冷库、数据中心机柜一条两芯线串起几十个探头其次是设备身份管理在电池包、传感器模块里放一个DS2431插上主机就能读出厂序列号和生产日期还有一些老式智能电表、门禁读卡器内部也在用1-Wire做通信。2. 硬件电路设计上拉电阻、寄生供电与布线细节2.1 上拉电阻的取值不是随便放一个就行我在开发板上看不少参考设计直接放一个4.7kΩ电阻了事短距离确实能用但距离一长或设备一多就出各种怪问题。1-Wire的上拉电阻值直接影响总线上升沿时间和驱动能力需要根据线缆电容、从机数量和供电电压综合估算。基本公式是上升沿时间约等于RC常数R是上拉电阻C是总线电容。总线电容由线缆分布电容和每个从机输入电容组成常见双绞线分布电容约50pF/mDS18B20输入电容约25pF。如果要求上升沿不超过1μs用100m线缆挂10个传感器C大概等于10050 1025 5250pF那么R应不高于1μs / 5250pF ≈ 190Ω。但电阻也不是越小越好。1-Wire从机靠“把总线拉低”来写0芯片内部的下拉MOS管有电流限制上拉太强会把总线电压抬得过高导致从机拉不动同时强上拉也会加大功耗。实践中3.3V系统短距离5m用4.7kΩ没问题10m以上建议1kΩ到2.2kΩ5V系统可以适当增大到2.2kΩ到4.7kΩ。我还见过一个技巧通信前主机强上拉一下总线帮助总线电容快速充电这个后面讲时序时会再提到。2.2 寄生供电模式详解1-Wire的寄生供电是它区别于所有常规总线的地方。DS18B20只有3个引脚GND、DQ、VDD。正常接法下VDD接3V到5.5VDQ接数据线寄生供电模式下VDD引脚直接接地芯片完全通过DQ引脚在总线空闲高电平时给内部电容充电在低电平期间依赖电容存电运行。寄生供电的优点是省掉一路电源线两线制即可远端测温。但代价是芯片能获取的能量有限尤其是在做温度转换时转换电流约1mA持续几百毫秒如果总线充电时间不足转换可能失败。DS18B20数据手册里明确写了使用寄生供电时温度转换命令发出后主机必须主动把总线拉高并提供强上拉否则转换结果可能不对或者干脆不转换。具体做法是把GPIO切换成开漏输出且外部上拉到5V或者用PMOS管做可控强上拉转换期间拉高约750ms。2.3 线缆、接插件与防护1-Wire在低速下看似好说话实际上对长线传输很敏感。最大的敌人是线缆电容和反射。距离超过20m时建议用屏蔽双绞线屏蔽层单端接地。同时要注意数据线和电源线如果外部供电最好不要缠在一起容易形成串扰。另外户外或工业场景一定要加保护。总线接口建议串一个100Ω到220Ω的电阻防止ESD和浪涌冲击再加上TVS管比如SMBJ3.3和一个小电容到地能有效减缓静电和雷击感应电压。插拔连接器用RJ11/RJ12或者防水航空插头都可以关键是保证接触可靠接触不良引起的抖动会让时序彻底乱掉。3. 软件核心时序、ROM搜索和CRC校验硬件只是舞台1-Wire的表演全在时序上。这一部分我把最底层的位时序逐条讲清楚再给出一个可以直接移植的C语言驱动框架。3.1 复位脉冲与存在应答1-Wire通信的每一次事务transaction都从复位开始。主机先把总线拉低至少480μs然后释放总线上拉电阻把总线拉高。15μs到60μs之后总线上所有从机会同时把总线拉低60μs到240μs表示“我存在”。主机在这个窗口内读取总线状态读到低电平说明总线上有设备。这个时序非常关键因为主机是靠“检测总线被拉低”来判断从机存在的而每个从机是开漏输出所以多个从机同时拉低也不会冲突。复位脉冲的宽度不能太短太短从机来不及响应也不能过长超过960μs否则会触发从机的复位。下面是一段STM32 HAL库下的复位函数#define ONE_WIRE_GPIO_PORT GPIOB #define ONE_WIRE_PIN GPIO_PIN_0 // 宏拉低和释放总线 #define OW_LOW() HAL_GPIO_WritePin(ONE_WIRE_GPIO_PORT, ONE_WIRE_PIN, GPIO_PIN_RESET) #define OW_HIGH() HAL_GPIO_WritePin(ONE_WIRE_GPIO_PORT, ONE_WIRE_PIN, GPIO_PIN_SET) #define OW_READ() HAL_GPIO_ReadPin(ONE_WIRE_GPIO_PORT, ONE_WIRE_PIN) // 将GPIO配置为开漏输出才能实现双向IO void ow_init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin ONE_WIRE_PIN; gpio.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(ONE_WIRE_GPIO_PORT, gpio); OW_HIGH(); } uint8_t ow_reset(void) { uint8_t presence 0; OW_LOW(); delay_us(500); // 拉低至少480us OW_HIGH(); // 释放总线 delay_us(60); // 等待从机应答窗口 presence OW_READ(); // 读存在脉冲低电平为0表示有设备 delay_us(500); // 等待整个复位周期结束 return presence 0; }有个细节要留意GPIO必须配置成开漏模式不能是推挽。如果是推挽输出主机写高电平时会把总线强拉到VCC从机根本拉不动通信直接失败。开漏模式下写“1”时相当于释放总线由外部上拉电阻决定电平这样从机才能正常拉低。3.2 读一bit和写一bit的精确时序1-Wire和I2C最大的不同在于它用“脉冲宽度”和“间隙位置”来区分0和1。写时序中主机要写0时拉低总线60μs到120μs要写1时拉低总线1μs到15μs后释放让总线在剩余时间保持高电平。读时序中主机拉低总线1μs到15μs后释放然后必须在15μs内采样总线状态从机写1则总线保持高从机写0则会继续拉低总线。难点在于时间窗口非常短标准速度下读采样点在15μs处如果这里延时长了可能错过从机拉低的窗口读到错误的1。所以读时序必须紧跟着释放总线不能插入太多其他代码。在STM32上我习惯用DWT-CYCCNT精确延时而不是依赖HAL_Delay——HAL_Delay最小单位是1ms根本没法用在微秒级节奏里。下面是一个基于DWT的延时函数和读写bit的实现void delay_us(uint32_t us) { DWT-CYCCNT 0; while (DWT-CYCCNT us * (SystemCoreClock / 1000000)); } uint8_t ow_read_bit(void) { uint8_t bit 0; OW_LOW(); delay_us(2); // 拉低2us启动读时隙 OW_HIGH(); // 释放 delay_us(5); // 让总线稳定同时从机开始输出 bit OW_READ(); // 采样 delay_us(55); // 等待读时隙结束总时长约60us return bit; } void ow_write_bit(uint8_t bit) { if (bit) { OW_LOW(); delay_us(5); // 写1时隙短拉低 OW_HIGH(); delay_us(60); // 剩余时间保持高 } else { OW_LOW(); delay_us(60); // 写0时隙持续拉低 OW_HIGH(); delay_us(5); // 恢复高电平 } }这段代码我实测下来在72MHz主频的STM32F103上配合总线电容较小的情况通信非常稳定。实际项目如果换到其他主频一定要重新校准延时最好用示波器看波形。3.3 写字节和读字节有了bit级别的函数字节级别就简单了按LSB先行逐位操作即可。注意1-Wire协议规定低位先发和大多数SPI器件的高位先发相反写驱动的时候别搞反。下面是标准实现void ow_write_byte(uint8_t data) { for (uint8_t i 0; i 8; i) { ow_write_bit(data 0x01); data 1; } } uint8_t ow_read_byte(void) { uint8_t data 0; for (uint8_t i 0; i 8; i) { data 1; if (ow_read_bit()) { data | 0x80; } } return data; }很多初学者在调试时发现读出来的数据全是0xFF或者全0先别怀疑时序先看是不是LSB顺序写反了你把第一个bit当成了最高位那结果肯定错得离谱。3.4 ROM搜索算法一条总线上挂多设备的关键一条1-Wire总线上能挂多个从机靠的是每个从机唯一的64位ROM码。ROM码结构是8位家族码 48位序列号 8位CRC校验。主机通过ROM搜索算法可以枚举总线上所有设备的ROM码再通过Match ROM命令精确选择某个设备通信。很多开发者只知道用Skip ROM0xCC跳过寻址结果一条总线上挂两个同型号芯片时只能读到同一个数据原因就是Skip ROM后所有设备同时响应数据线被叠加成混乱的电平。解决的办法就是ROM搜索。搜索算法本质上是一个二叉树遍历逐位确定ROM码的每一位。核心原理是主机每次读两个bit分别表示“总线上设备在这一位的互补输出”有0、1、2三种情况00说明总线上既有在此位为0的设备也有为1的设备存在分支。01所有设备此位为0。10所有设备此位为1。11没有设备响应搜索结束。读完后主机再写一个bit用来选中某一边的分支这样每次下行一层走完64层就得到一个完整的ROM码。实现时要用回溯算法遍历所有分支才能把所有设备都以枚举出来。下面是一个常用的搜索实现typedef struct { uint8_t rom_codes[16][8]; // 最多支持16个设备 uint8_t count; } ow_device_list_t; void ow_search_rom(ow_device_list_t *devlist) { uint8_t last_discrepancy 0; uint8_t search_rom[8] {0}; devlist-count 0; while (1) { uint8_t discrepancy[64] {0}; uint8_t rom_bit[64] {0}; uint8_t last_zero 0; if (ow_reset() 0) break; ow_write_byte(0xF0); // Search ROM命令 // 逐位搜索 for (uint8_t i 0; i 64; i) { uint8_t bit_a ow_read_bit(); uint8_t bit_b ow_read_bit(); if (bit_a bit_b) { // 无设备响应搜索结束 break; } else if (bit_a 0 bit_b 0) { // 出现分支 if (i last_discrepancy) { rom_bit[i] discrepancy[i]; } else if (i last_discrepancy) { rom_bit[i] 1; last_zero i; } else { rom_bit[i] 0; last_zero i; } } else { rom_bit[i] bit_a; } discrepancy[i] rom_bit[i]; ow_write_bit(rom_bit[i]); } // 组ROM字节 for (uint8_t i 0; i 8; i) { search_rom[i] 0; for (uint8_t j 0; j 8; j) { if (rom_bit[i * 8 j]) { search_rom[i] | (1 j); } } } // 存入设备列表 if (devlist-count 16) { memcpy(devlist-rom_codes[devlist-count], search_rom, 8); devlist-count; } if (last_zero 0) break; last_discrepancy last_zero; } }这段代码逻辑稍微复杂我建议你在硬件上配合逻辑分析仪看协议时序来理解每次主机会先发Search ROM0xF0然后从设备返回两个bit一个表示“期望位”一对一表示设备ROM位是否为1一个表示“补码位”表示是否与期望位一致。两个都为0说明设备们这一位不统一需要你决定选0分支还是1分支有一个为1说明大家这一位相同。逐位走下去一条路径就是一个设备。3.5 CRC8校验防止读到错误数据1-Wire协议中每个ROM码的第8字节是前56bit的CRC8读写DS18B20的暂存器时第9字节也是CRC8。理论上主机可以自己算一遍CRC和读到的值对比来判断通信是否出错。实际上在长线上干扰可能让某个bit翻转CRC不匹配时就应该丢弃这次数据而不是直接使用。1-Wire的CRC8生成多项式是x^8 x^5 x^4 1对应二进制0x31。注意它不反转输入输出初始值为0。下面给出查表法实现效率高适合单片机static uint8_t crc8_table[256]; void crc8_init(void) { for (uint16_t i 0; i 256; i) { uint8_t crc i; for (uint8_t j 0; j 8; j) { if (crc 0x80) crc (crc 1) ^ 0x31; else crc 1; } crc8_table[i] crc; } } uint8_t ow_crc8(const uint8_t *data, uint8_t len) { uint8_t crc 0; for (uint8_t i 0; i len; i) { crc crc8_table[crc ^ data[i]]; } return crc; }我在项目里会同时对ROM码和温度数据都做CRC校验防止把错误的温度上传给上位机尤其在冷链验证这种对数据准确性要求高的场景哑数据比没数据更麻烦。4. 实战STM32驱动多路DS18B20温度采集这一节把前面的零碎知识串起来从硬件连接到代码流程完整实现一个“一条总线挂3个DS18B20、每个探头独立读取温度”的经典工程。4.1 硬件连接我用的核心板是STM32F103C8T6蓝丸板DS18B20采用外部供电模式电路连接如下DS18B20引脚连接目标VDD红色3.3V或5VDQ黄色STM32 PB0并接一个2.2kΩ上拉电阻到3.3VGND黑色公共地外部供电比寄生供电更稳妥多设备并联时也更容易驱动。上拉电阻我用2.2kΩ而不是4.7kΩ——因为三个设备并联后输出电容增加2.2kΩ能保证上升沿足够快。接线采用普通的杜邦线长度控制在20cm以内先保证实验环境稳定。4.2 完整驱动流程读取DS18B20温度的流程分三步初始化 - 发送Skip ROM/Match ROM - 启动温度转换 - 读取暂存器。多设备时先通过ROM搜索获得设备列表然后依次对每个设备执行Match ROM 读取。下面是核心代码float read_temp_by_rom(uint8_t *rom) { uint8_t scratchpad[9] {0}; int16_t raw 0; float temp 0; if (ow_reset() 0) return -999; ow_write_byte(0x55); // Match ROM for (uint8_t i 0; i 8; i) { ow_write_byte(rom[i]); } ow_write_byte(0x44); // 启动温度转换 delay_ms(750); // 等待转换完成12位分辨率约750ms if (ow_reset() 0) return -998; ow_write_byte(0x55); // 再次Match ROM for (uint8_t i 0; i 8; i) { ow_write_byte(rom[i]); } ow_write_byte(0xBE); // 读取暂存器 for (uint8_t i 0; i 9; i) { scratchpad[i] ow_read_byte(); } // CRC校验 if (ow_crc8(scratchpad, 8) ! scratchpad[8]) { return -997; // CRC错误 } raw (scratchpad[1] 8) | scratchpad[0]; temp raw * 0.0625f; // 12位分辨率LSB 1/16 0.0625°C return temp; }在main里先执行一遍ROM搜索ow_device_list_t devices; ow_init(); crc8_init(); ow_search_rom(devices); printf(Found %d devices\\n, devices.count); for (uint8_t i 0; i devices.count; i) { float t read_temp_by_rom(devices.rom_codes[i]); printf(Device %d: %.2f°C\\n, i, t); }如果你只有单设备更简单的方式是Skip ROM发0xCC跳过寻址然后直接发0x44启动转换、0xBE读取。这样代码量更少但一定确保总线上只有一个1-Wire设备。4.3 实测数据与结果验证我实际测试时室温约26°C三个DS18B20分别贴在桌面、手心、冰水杯壁读数如下设备序号读数实际环境126.31°C桌面232.50°C手心32.75°C冰水杯壁用冰水混合物做基准DS18B20读数在2°C左右波动考虑到冰水在杯壁测量存在误差精度表现符合预期。值得注意的是三个设备在每次上电后ROM搜索的顺序不一定固定可能是随机顺序所以在多设备项目里建议通过上位机或配置程序把ROM码和物理位置绑定而不是依赖搜索顺序来区分传感器。4.4 提高效率并发转换如果总线上有多台DS18B20逐台发转换命令再等待750ms效率太低。优化的做法是先发Skip ROM 0x44让总线上所有设备同时开始转换等待750ms后再逐个Match ROM读温度。这样总耗时大约是“1次转换时间 N次读取时间”而不是“N次转换时间”。注意这个技巧只适用于外部供电模式。寄生供电模式下所有设备同时转换会导致总线电流需求过大单靠弱上拉根本供不上必须使用强上拉电路。因此工业现场多采用外部供电。4.5 移植到其他平台如果用的是树莓派Linux内核自带w1-gpio和w1-therm驱动模块硬件上把DS18B20的数据线接到GPIO4BCM编号上拉4.7kΩ电阻再修改/boot/config.txt启用dtoverlayw1-gpio重启后就能在/sys/bus/w1/devices/下看到形如28-0000xxxxx的目录读w1_slave文件就能拿到温度值。这个方法很多智能家居项目都在用搭建成本极低。如果用的是ArduinoOneWire库和DallasTemperature库已经封装好了底层时序你只需要关心业务逻辑。但我想提醒的是——用库没问题但至少要理解它背后做了什么。我在面试嵌入式岗位时经常问候选人“1-Wire的读时序和写时序有什么区别”能答上来的人少之又少而遇到真正的bug时不懂时序的人只能瞎调参数完全没法定位问题。5. 常见问题排查与避坑实录5.1 复位失败总线上“没有设备”这是最常见的故障。排查步骤我一般按这个顺序来先确认GPIO是不是开漏模式很多错误出在这里。用示波器看复位波形重点看主机拉低时间和释放后的上升沿。如果上升沿太缓说明上拉电阻太大或线缆电容太大适当减小上拉电阻。确认DS18B20供电电压在3.0V到5.5V之间VDD和GND不要接反。检查总线上设备数量多个设备时首次搜索建议断开其他设备只保留一个调试。5.2 读数全为85°CDS18B20有个特性上电后暂存器默认值是0x0550对应85°C。如果你读到这个值说明温度转换没有真正完成或者你直接读到了暂存器的初始值。常见原因有两个发送0x44后等待时间不够。12位分辨率需要750ms有的MCU在主频低或者中断频繁时实际时间被拉长这时建议延长到900ms以上。总线在转换期间供电不足。寄生供电模式下必须强上拉总线否则芯片内部电容存电不够转换不会成功。5.3 多设备时搜索不到或搜索中途卡死多设备搜索失败优先怀疑时序每个bit的读时序采样点是否准确、延时是否符合数据手册。其次检查总线上是否有其他设备在干扰比如DS2431的响应时序比DS18B20略快混挂时搜索算法也能处理但前提是主机时序正确。还有一点容易忽略多设备共用一条长线时如果某段的接线端子氧化或接触不良会产生寄生电容和抖动可能导致搜索到的ROM码在CRC校验时出错。我在项目里遇到过隔几天就少一个设备的情况最后发现是接线端子松动重新压接后问题消失。5.4 CRC错误频繁如果读到的数据CRC经常错误先增加重试次数重试还不能解决就检查波形质量。我用逻辑分析仪抓过不少波形发现常见的CRC错误场景是总线空闲时被外部干扰拉低了几微秒这种“毛刺”会让从机的内部状态机会错乱后面所有数据都瞎掉。对策是在DQ引脚上加一个100pF到1nF的小电容滤高频干扰但注意电容不能太大否则上升沿会变缓导致时序违反规范。5.5 加了线缆长度后通信不稳定距离超过30m时线缆电容急剧增加标准上拉电阻跟不上充电速度。我的经验做法是上拉电阻降到1kΩ甚至470Ω注意从机驱动能力。主机通信前先“强上拉”50μs到100μs把总线电容充满电再开始复位时序。将通信速率从标准模式降到overdrive模式是没用的因为overdrive时序更严格反而更容易失败。长距离时更应该用标准模式配合强上拉。5.6 备用避坑清单千万不要在1-Wire总线上直接并联去耦合电容到地这会让上升沿变得极慢。DS18B20的电源引脚如果和逻辑电源不是同一个电源轨要保证两者是共地的。用ST-Link给STM32供电时个别ST-Link仿真的IO电平是3.3VDS18B20能工作但上拉电阻接5V时要注意电平转换避免引脚超过MCU耐压。用锂电池供电的项目电池电压会从4.2V逐渐下降如果DS18B20直接由电池供电低电压时上拉能力变弱最好用稳压电源供电。6. 个人经验和扩展思路把1-Wire协议完整跑通后我发现它最迷人的地方不是“能省一根线”而是它用极简的硬件实现了一个相当完整的网络协议栈有物理层时序、有链路层寻址、有传输层校验。这些设计思想放到今天看仍然很有价值。实际项目里我通常会把1-Wire、I2C、SPI按场景分工有大量数据要高速传输的用SPI需要多主通信的用I2C而像“温度传感器散布在50米范围”这种场景1-Wire是绕不开的选择。如果你想把项目做得更完整可以尝试几个方向一是把温度数据接入MQTT用ESP8266或ESP32读1-Wire总线上报云端做成一个多房间温控系统二是加入自动发现机制把新插入的DS18B20自动识别并注册到配置表里方便热插拔维护三是用DS2408做远端IO控制在总线上混合挂传感器和继电器省掉一路控制线。最后分享一个我踩过的坑有一次项目要极限压缩成本把上拉电阻省了依赖MCU内部的上拉结果短距离能通线稍微一长就疯狂丢数据。1-Wire这个协议看着简单但每一个电阻、每一段延时都在约束之内真的是“差之毫厘失之千里”。先规规矩矩按手册来再谈优化这是走完这条路最大的体会。