STM32单总线协议实战:如何用一根线搞定多个DS18B20温度采集(附ROM搜索代码)
在工业监控、冷链物流或是智能家居这类需要多点温度监测的场景里,布线成本和系统复杂度往往是工程师们头疼的问题。想象一下,一个大型冷库或者一辆冷链运输车,需要在十几个甚至几十个关键位置实时监控温度,如果每个点都独立布线,那将是一场线缆的噩梦。而单总线协议,恰恰是解决这个痛点的优雅方案。
DS18B20这款经典的数字温度传感器,凭借其独特的单总线接口,允许我们将多个传感器并联在同一根数据线上,仅需一个微控制器引脚就能管理整个温度传感网络。这不仅仅是节省了几根线那么简单,它意味着更低的硬件成本、更简洁的PCB布局、更高的系统可靠性,以及后期维护和扩展的极大便利。对于STM32开发者而言,掌握单总线协议下多设备的管理,尤其是那个听起来有点神秘的“ROM搜索算法”,是从入门到精通的关键一步。很多初学者在尝试连接多个DS18B20时,常常卡在设备地址识别和分时读取上,感觉像是面对一个黑盒。今天,我们就来彻底拆解这个黑盒,从原理到代码,手把手带你实现一根线上搞定所有温度采集。
1. 单总线协议与DS18B20多设备管理核心原理
要驾驭多个挂在同一根线上的DS18B20,首先得理解单总线协议是如何在共享介质上实现设备寻址的。这不像I2C有固定的设备地址,也不像SPI有独立的片选线。单总线协议更像是一个“广播+点名”的课堂:老师(主机)先向全班(总线)发问,然后通过一套精妙的算法,让每个学生(从设备)依次报出自己的唯一学号(64位ROM ID)。
单总线协议的精髓在于时间隙(Time Slot)和线“与”逻辑。所有通信都始于一个由主机发出的复位脉冲(>480µs的低电平),随后DS18B20会拉低总线60-240µs作为应答,表明设备在线。之后的所有数据交换,无论是读是写,都以特定的时间窗口为单位进行。当多个设备同时响应时,它们会共同作用于总线电平,遵循“线与”规则——任何一个设备拉低总线,总线就是低电平;只有所有设备都输出高电平(或释放总线由上拉电阻拉高),总线才是高电平。正是这个特性,为后续的ROM搜索算法奠定了基础。
每个DS18B20在出厂时都被激光刻录了一个全球唯一的64位ROM代码,其结构如下表所示:
| 位范围 | 长度(位) | 内容 | 说明 |
|---|---|---|---|
| 0-7 | 8 | 家族代码 | DS18B20固定为0x28 |
| 8-55 | 48 | 序列号 | 制造商分配的唯一标识 |
| 56-63 | 8 | CRC校验码 | 用于验证前56位数据的正确性 |
这个64位的ROM ID就是每个传感器的“身份证”。当总线上有多个传感器时,主机必须通过这个ID来区分它们,进行精准的“一对一”对话。这里就引出了几个核心的ROM操作命令:
0x33(Read ROM):直接读取ROM。此命令仅在总线上有且只有一个DS18B20时有效,多设备时会因同时响应导致数据冲突。0x55(Match ROM):匹配ROM。主机发送此命令后,紧接着发送一个64位的ROM ID,只有ID完全匹配的那个传感器才会响应后续的命令。这是实现分时读取的关键。0xCC(Skip ROM):跳过ROM。此命令广播给总线上所有设备,让它们忽略ROM匹配,直接响应后续命令。常用于同时启动所有传感器进行温度转换(0x44),但不能用于读取数据,否则会因多个设备同时发送温度值而导致数据冲突。0xF0(Search ROM):搜索ROM。这是多设备系统的灵魂命令。主机通过执行此命令,并配合一套搜索算法,可以逐一找出总线上所有DS18B20的完整64位ROM ID。
那么,如何获得这些未知的ROM ID呢?这就是接下来要深入探讨的“ROM搜索算法”。
2. ROM搜索算法深度解析:二叉树的遍历艺术
ROM搜索算法本质上是一个在冲突中寻找唯一路径的过程,其思想非常巧妙,可以类比为遍历一棵深度为64的二叉树。每一次搜索,主机都在尝试确定ROM ID中某一位的值(0或1)。由于“线与”逻辑,当主机读取该位时,可能会遇到三种情况:
- 所有设备在该位都返回0-> 主机读到0。
- 所有设备在该位都返回1-> 主机读到1。
- 有些设备返回0,有些返回1-> 发生冲突,主机读到0(因为只要有一个设备拉低,总线就是低电平)。
前两种情况很简单,直接确定了该位的值。棘手的是第三种情况——“冲突位”。这时,主机需要做出选择:是沿着“0”分支走,还是沿着“1”分支走?算法规定,在发生冲突的位上,主机先强制写入0,将所有在该位为1的设备暂时“屏蔽”,只与在该位为0的设备通信,从而找出一条路径,读出一个完整的ROM ID。记录下这条路径以及发生冲突的位置。下次搜索时,在最近的一个冲突位,主机改为强制写入1,从而探索另一条分支,找到另一个设备的ROM ID。如此反复,直到遍历完所有可能的分支。
提示:理解搜索算法的关键在于“路径选择”和“冲突记录”。算法需要维护一个“最后分歧位”的索引,确保每次搜索都能探索到新的、未记录过的设备。
下面,我们通过一个简化的例子(假设ROM只有4位)来可视化这个过程:
假设总线上有3个设备,ROM ID分别为:0101,0110,1100。
- 第一次搜索,从最高位开始。主机读位,发生冲突(有0有1),主机写0,记录冲突位(第3位)。假设路径走向
0xxx,最终可能找到设备0101。 - 第二次搜索,算法回溯到最后一个冲突位(第3位),这次选择写1。于是路径变为
1xxx,找到了设备1100。 - 第三次搜索,算法需要处理更早的冲突(可能在更低位),最终找到设备
0110。
这个过程用代码实现,核心是一个循环64次(对应64位ROM)的搜索过程。在每一位上,主机执行两次读操作(读原码和读补码)来判断冲突,然后根据搜索状态决定写入0还是1,并逐步构建出当前设备的ROM ID字节数组。
3. 工程实战:STM32上的ROM搜索与温度采集代码实现
理论清晰后,我们进入实战环节。这里将提供基于STM32 HAL库的、带详细注释的核心代码。我们假设DS18B20的数据线连接在GPIOA的PIN_1上,并已配置好一个精准的微秒级延时函数Delay_us()。
首先,是单总线底层的时序操作函数,这是所有高级功能的基础:
/* 设置DQ引脚为输出模式 */ void DS18B20_SetOutput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } /* 设置DQ引脚为输入模式(带上拉) */ void DS18B20_SetInput(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; GPIO_InitStruct.Pin = GPIO_PIN_1; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); } /* 复位DS18B20,返回1表示有设备应答 */ uint8_t DS18B20_Reset(void) { uint8_t presence = 0; DS18B20_SetOutput(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); Delay_us(480); // 保持低电平480us以上 DS18B20_SetInput(); Delay_us(60); // 释放总线后等待15-60us if (!HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1)) { presence = 1; // 检测到低电平应答 } Delay_us(420); // 等待剩余的应答时间完成 return presence; } /* 向DS18B20写入一个字节(低位先行) */ void DS18B20_WriteByte(uint8_t dat) { DS18B20_SetOutput(); for (int i = 0; i < 8; i++) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); Delay_us(1); // 拉低至少1us开始写时隙 if (dat & 0x01) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); // 写1 } // 写0则保持低电平 Delay_us(60); // 保持写时隙 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); // 释放总线 Delay_us(1); // 恢复时间 dat >>= 1; } } /* 从DS18B20读取一个字节(低位先行) */ uint8_t DS18B20_ReadByte(void) { uint8_t value = 0; for (int i = 0; i < 8; i++) { value >>= 1; DS18B20_SetOutput(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); Delay_us(1); // 拉低至少1us开始读时隙 DS18B20_SetInput(); Delay_us(10); // 等待约15us后采样 if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1)) { value |= 0x80; // 读到1 } Delay_us(50); // 完成读时隙 } return value; }接下来,是本文的核心——ROM搜索算法的实现。这个函数会搜索总线上所有的DS18B20,并将找到的ROM ID存入提供的数组中。
/** * @brief 搜索单总线上所有的DS18B20 ROM ID * @param rom_ids: 用于存储找到的ROM ID的二维数组,每行8个字节 * @param max_devices: 数组能存储的最大设备数,防止溢出 * @retval 实际找到的设备数量 */ uint8_t DS18B20_SearchRom(uint8_t rom_ids[][8], uint8_t max_devices) { uint8_t id_bit_number; uint8_t last_zero, rom_byte_number, search_result; uint8_t id_bit, cmp_id_bit; uint8_t rom_byte_mask, search_direction; uint8_t last_discrepancy = 0; // 记录最后一次发生冲突的位置 uint8_t last_device_flag = 0; // 搜索完成标志 uint8_t found_devices = 0; // 已找到的设备计数 // 1. 确保总线上有设备 if (!DS18B20_Reset() || max_devices == 0) { return 0; } // 2. 发起搜索ROM命令 DS18B20_WriteByte(0xF0); // 3. 开始搜索循环,直到找不到新设备 while (found_devices < max_devices && !last_device_flag) { id_bit_number = 1; last_zero = 0; rom_byte_number = 0; rom_byte_mask = 1; search_result = 0; // 初始化当前搜索的ROM ID为全0 for (int i = 0; i < 8; i++) { rom_ids[found_devices][i] = 0; } // 4. 循环处理64个ROM位 do { // 4.1 读取当前位的原码和补码 id_bit = DS18B20_ReadBit(); cmp_id_bit = DS18B20_ReadBit(); // 4.2 判断读取结果 if (id_bit && cmp_id_bit) { // 11: 总线错误,没有设备响应(理论上不应在搜索命令后发生) break; } else { if (id_bit != cmp_id_bit) { // 01 或 10: 没有冲突,所有设备该位值一致 search_direction = id_bit; // 值就是id_bit } else { // 00: 发生冲突,有设备为0,有设备为1 if (id_bit_number < last_discrepancy) { // 冲突位在“最后分歧位”之前,沿用上一次的选择 search_direction = ((rom_ids[found_devices][rom_byte_number] & rom_byte_mask) > 0); } else { // 冲突位在“最后分歧位”或之后,本次强制选择0 search_direction = 0; } // 如果强制选择了0,但实际有设备为1,则记录这个冲突位置 if (search_direction == 0) { last_zero = id_bit_number; } } // 4.3 根据确定的方向,写入一位并更新本地ROM ID DS18B20_WriteBit(search_direction); if (search_direction) { rom_ids[found_devices][rom_byte_number] |= rom_byte_mask; } else { rom_ids[found_devices][rom_byte_number] &= ~rom_byte_mask; } } // 4.4 移动到下一位 id_bit_number++; rom_byte_mask <<= 1; if (rom_byte_mask == 0) { rom_byte_number++; rom_byte_mask = 1; } } while (rom_byte_number < 8); // 循环64次 // 5. 一次完整的64位搜索完成 if (id_bit_number >= 65) { // 成功搜索到一个完整ID last_discrepancy = last_zero; // 更新最后分歧位 if (last_discrepancy == 0) { last_device_flag = 1; // 没有更多分歧,搜索完成 } found_devices++; // 设备计数加一 } } // 6. 返回找到的设备总数 return found_devices; } /* 辅助函数:读取一位 */ uint8_t DS18B20_ReadBit(void) { uint8_t bit_value; DS18B20_SetOutput(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); Delay_us(1); DS18B20_SetInput(); Delay_us(10); // 关键:在约15us内采样 bit_value = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1); Delay_us(50); // 完成读时隙 return bit_value; } /* 辅助函数:写入一位 */ void DS18B20_WriteBit(uint8_t bit_value) { DS18B20_SetOutput(); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET); Delay_us(1); if (bit_value) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); } Delay_us(60); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET); Delay_us(1); }有了ROM ID,多路温度采集就水到渠成了。流程通常分两步:广播启动所有转换,然后依次匹配ROM读取温度。
/** * @brief 读取指定ROM ID的DS18B20温度值 * @param rom_id: 指向目标设备8字节ROM ID数组的指针 * @retval 温度值(浮点数,单位摄氏度) */ float DS18B20_ReadTempByRom(uint8_t *rom_id) { uint8_t temp_l, temp_h; int16_t temp_raw; float temperature; // 1. 启动所有设备进行温度转换(广播命令) DS18B20_Reset(); DS18B20_WriteByte(0xCC); // Skip ROM DS18B20_WriteByte(0x44); // Convert T // 等待转换完成,12位分辨率约需750ms,此处可采用延时或查询总线状态 Delay_ms(750); // 2. 针对特定设备读取温度 DS18B20_Reset(); DS18B20_WriteByte(0x55); // Match ROM for (int i = 0; i < 8; i++) { DS18B20_WriteByte(rom_id[i]); // 发送64位ROM ID } DS18B20_WriteByte(0xBE); // Read Scratchpad temp_l = DS18B20_ReadByte(); // 温度低字节 temp_h = DS18B20_ReadByte(); // 温度高字节 // 3. 合成原始温度数据并转换 temp_raw = (temp_h << 8) | temp_l; // 判断符号位(高5位) if (temp_h & 0xF8) { // 高5位不全为0,表示负数 // 对于负数,数据以补码形式存储,需先取反加1再计算 temp_raw = ~temp_raw + 1; temperature = -((float)temp_raw * 0.0625); } else { temperature = (float)temp_raw * 0.0625; // 默认12位分辨率 } return temperature; }在主函数中,你可以这样组织逻辑:
int main(void) { // 系统初始化(时钟、GPIO、延时、串口等)... uint8_t rom_list[10][8]; // 假设最多支持10个设备 uint8_t device_count; float temp; // 1. 搜索总线上所有DS18B20 device_count = DS18B20_SearchRom(rom_list, 10); printf("Found %d DS18B20 device(s).\r\n", device_count); for (int i = 0; i < device_count; i++) { printf("Device %d ROM: ", i); for (int j = 0; j < 8; j++) { printf("%02X ", rom_list[i][j]); } printf("\r\n"); } // 2. 循环读取每个设备的温度 while (1) { for (int i = 0; i < device_count; i++) { temp = DS18B20_ReadTempByRom(rom_list[i]); printf("Device %d Temperature: %.3f C\r\n", i, temp); } printf("-------------------\r\n"); HAL_Delay(2000); // 每2秒采集一轮 } }4. 工业级应用优化与避坑指南
将上述代码直接用于工业环境可能还不够稳健。在实际项目中,尤其是像冷链物流车这种存在振动、电源波动和电磁干扰的场景,我们需要考虑更多的细节。
首先,CRC校验是数据可靠性的第一道防线。DS18B20在ROM ID(8字节)和暂存器数据(9字节)的最后一个字节都提供了CRC-8校验码。忽略CRC校验就像开车不看仪表盘报警。建议在搜索到ROM ID和读取温度数据后,都进行CRC校验。这里提供一个常用的CRC-8 Dallas/Maxim计算函数:
/** * @brief CRC-8 Dallas/Maxim 计算函数 * @param data: 待校验数据指针 * @param len: 数据长度 * @retval 计算得到的CRC8值 */ uint8_t DS18B20_CRC8(const uint8_t *data, uint8_t len) { uint8_t crc = 0; for (uint8_t i = 0; i < len; i++) { uint8_t inbyte = data[i]; for (uint8_t j = 0; j < 8; j++) { uint8_t mix = (crc ^ inbyte) & 0x01; crc >>= 1; if (mix) { crc ^= 0x8C; } inbyte >>= 1; } } return crc; } // 在搜索ROM后校验 if (DS18B20_CRC8(rom_list[i], 7) != rom_list[i][7]) { printf("Warning: CRC mismatch for device %d ROM!\r\n", i); } // 在读取温度后,可以读取完整的9字节暂存器进行校验其次,总线驱动与抗干扰设计至关重要。单总线对线路电容敏感,长距离或多设备并联会降低信号质量。
- 上拉电阻:4.7kΩ是典型值,但如果总线较长或设备很多(>10个),可能需要减小电阻值(如2.2kΩ)以提供更强的上拉电流,确保上升沿速度。
- 布线规范:尽量使用双绞线或屏蔽线,远离电机、继电器等强干扰源。总线长度不宜过长,一般建议在几十米以内。
- 电源去耦:在每个DS18B20的VCC和GND之间就近放置一个0.1µF的陶瓷电容,可以极大抑制电源噪声。
- 寄生电源模式:如果使用寄生供电(仅接DQ和GND),在温度转换(
0x44)和拷贝EEPROM(0x48)命令期间,必须通过强上拉(如用MOS管将总线直接拉到VCC)提供足够电流,否则转换可能失败。
最后,软件层面的鲁棒性增强。工业应用要求系统不能轻易挂死。
- 超时机制:在所有等待DS18B20应答或转换完成的地方(如
DS18B20_Reset,DS18B20_ReadBit中的等待),加入超时判断,避免因设备损坏或接触不良导致程序死等。 - 错误重试:一次读取失败后,不要立即判定设备故障。可以实现一个简单的重试机制(如重试3次),很多偶发的干扰问题可以通过重试解决。
- 热插拔处理:如果系统支持热插拔,需要定期(如每分钟)或触发式地重新执行ROM搜索,以更新在线设备列表,但要注意搜索期间会短暂中断温度采集。
注意:时序精度是单总线通信的生命线。确保你的
Delay_us()函数在系统时钟变化时(如配置了低功耗模式)依然准确。使用硬件定时器(如SysTick或通用定时器)来实现微秒延时,远比软件空循环可靠。
在调试阶段,如果遇到问题,可以借助逻辑分析仪或示波器观察DQ线上的波形,对照DS18B20的时序图,检查复位脉冲宽度、应答信号、读写时隙的位置和电平是否合规,这是定位硬件连接或软件时序问题最直接有效的方法。