简介这是一套基于STM32F030的多点温度采集系统完整源码适合物联网与嵌入式开发者参考用于实现远程环境温度监测与云平台数据上报。系统采用DHT20传感器采集温度通过移远BC260Y-CN模块以NB-IoT技术连接OneNet平台并加入OLED本地显示功能。压缩包共24个文件包含10个C源码文件、10个头文件、2个Markdown文档及其他配置文件整体仅40KB结构紧凑易读。已有39人下载学习。源码涵盖了硬件初始化、外设驱动、通信协议及系统调度等模块可直接移植或二次开发对理解NB-IoT组网、MQTT参数配置和嵌入式软件分层设计均有帮助。1. STM32F030 做多点温度采集为什么这个组合能打坑也最多一台冷链箱要盯 8 个测温点某个货架要在 3 米线缆上挂 6 路温度传感器成本压到几十块还得能在量产时复制——这种需求一出现STM32F030 加单总线温度传感器就是大多数工程师的第一反应。F030 是入门级 M0 内核芯片主频 48MHz价格便宜到可以批量铺单总线协议只需要一根 GPIO 就能串起几十个节点每个节点还有唯一 64 位 ID天然适合多点采集。但真把这套组合跑起来你会发现坑全在代码细节里微秒级时序容差、ROM 搜索算法、CRC 校验、上拉电阻选型任何一个没处理好都会让数据变成 85℃ 或直接搜不到设备。这篇笔记把从零到跑的完整路径按可复现的代码讲清楚适合预算有限、又不想踩一遍时序坑的开发者。2. 先选型单总线多点还是 ADC 多通道F030 的资源卡点在哪2.1 DS18B20 单总线一条线串起 8 路把成本压到最低多点温度采集最常见的两条路一条是单总线数字传感器另一条是 NTC 热敏电阻走 ADC 多通道。先聊单总线因为它最贴合标题里的「多点」两个字。单总线的物理拓扑很简单主机一个 GPIO一根数据线一个 4.7k 左右的上拉电阻到 3.3V所有传感器并联在这根线上。每个传感器内部有唯一 64 位 ROM 编码主机通过搜索命令把线上的设备一个个枚举出来之后用匹配命令点名读取。这套机制决定了它天然适合多点不需要每个点单独占用一个 ADC 通道IO 资源只花一个引脚。单总线温度传感器常见的型号精度标称在 ±0.5℃-10℃ 到 85℃ 范围内分辨率可以通过配置寄存器在 9 到 12 位之间切换默认 12 位也就是 0.0625℃ 每 LSB。12 位分辨率下完成一次转换的典型时间是 750ms这个速度对温度巡检完全够用但如果你要跟踪快速温变它就不合适了。成本是另一个优势。不算 PCB传感器本身单价压到几块钱以内8 路也就一杯咖啡钱。对比 NTC 加运放加精密电阻的方案省下来的不只是物料还有每个通道的标定工时。单总线方案还有个隐性好处传感器坏了不连累主机替换后新器件的 ROM 编码变了搜索逻辑重新枚举即可。2.2 NTC 多通道F030 的 ADC 没有硬件校准VREFINT 才是准头另一条路是 NTC 热敏电阻配 F030 内置 ADC。F030 的 ADC 是 12 位多通道采集时逐通道扫描理论上一路一个 IO 就能测一路温度采样速度比单总线快几个数量级。但这里有个绕不开的卡点F030 这类 M0 芯片的 ADC 没有硬件校准电路供电电压直接决定采样结果的绝对精度而板子上 3.3V 往往不是精确的 3.3V。F030 出厂时在信息块里存了一个内部参考电压的 ADC 采样值地址在 0x1FFFF7BA。这个值是芯片在 3.3V、30℃ 条件下校准得到的12 位 ADC 对它采样的结果会落在某个固定值附近。实际使用时读出当前 VREFINT 的 ADC 值用「3.3 × 校准值 ÷ 当前采样值」就能反推出真实供电电压再用这个真实电压去换算 NTC 分压结果。很多开发者不知道这一步直接拿 3.3V 去算 NTC 阻值测出来的温度偏出好几度还找不到原因。NTC 本身的换算也不省心。常见做法是 NTC 与固定电阻串联分压ADC 采到分压点电压后先算出 NTC 当前阻值再套 B 值公式换算温度1/T 1/T0 (1/B) * ln(R/R0)T0 是 25℃R0 是 NTC 在 25℃ 下的标称阻值B 值由器件手册给出。公式不复杂但 B 值本身有离散性NTC 的一致性也一般想做到 ±0.5℃ 以内就得逐点标定量产时这是不小的工作量。2.3 选型对比几路、多远、多快三个问题定方案两条路各有边界选型时先问三个问题几路、多远、多快。维度单总线方案NTC ADC 方案路数扩展一根线可挂数十个不受 ADC 通道限制受 ADC 通道数限制通道多了要加模拟开关线缆长度3 米以内稳妥超过要降速率或加强上拉线缆阻值会影响分压比长线要四线制或差分采样速度12 位分辨率单次转换约 750msADC 采样毫秒级可做同步快速采集精度一致性器件出厂校准互换性好依赖 B 值精度和逐点标定单路成本每路一个数字传感器价格略高NTC 加电阻很便宜但标定工时贵我一般会这样判断8 路以内、线不长、对采样速度没要求、想快速交付选单总线需要同步采集多个点的温升曲线或者要 10ms 级采样周期老老实实上 NTC 加 ADC把 VREFINT 校准和标定流程做扎实。这篇笔记后面的代码和踩坑全部围绕单总线方案展开因为它的坑更隐蔽也更容易在代码层面讲透。3. 单总线驱动代码从微秒延时到 ROM 搜索参数全解3.1 48MHz 主频下的微秒延时先校准再谈时序单总线协议对时序的要求是微秒级的所有读写操作都建立在精确延时上。F030 主频 48MHz一个空循环周期大约 20 多纳秒但 M0 内核没有 M3/M4 的 DWT 周期计数器SysTick 又往往被系统计时占用最省事的做法就是裸循环延时。// 简易微秒延时系数依赖主频和编译优化等级必须实测校准 static void delay_us(uint32_t us) { // 48MHz O2 优化下的经验系数换成 O0 或换编译器要重新标定 uint32_t cnt us * 12; while (cnt--) { __NOP(); } }这段代码里唯一的魔法数字就是 12。不同优化等级下O0 生成的代码比 O2 慢不少同样的延时循环实际耗时差出 30% 以上。我见过有人把 F103 的延时函数直接搬过来用F103 主频 72MHzF030 主频 48MHz一样的循环次数实际时间差了 1.5 倍单总线时序全乱读回来的数据全是 0xFF。标定方法很简单让 GPIO 翻转用逻辑分析仪或示波器量一下实际高电平时间对比期望值调系数。比如写一个延时 100us 的循环示波器量出来只有 80us系数就乘 1.25。这个步骤省不得属于「玄学参数」里最影响成败的一个。3.2 复位、写时序与读时序窗口参数和它们的容差单总线的时序分三块复位、写位、读位。先看复位主机拉低 480us 以上释放传感器在释放后 60 到 240us 内拉低总线作为存在脉冲。// 单总线复位返回 1 表示检测到设备0 表示无设备或总线异常 uint8_t ow_reset(void) { uint8_t presence; GPIOB-BRR (1 0); // 拉低数据线PB0 delay_us(480); // 复位脉冲必须大于 480us GPIOB-BSRR (1 0); // 释放总线 delay_us(70); // 等待存在脉冲窗口 presence (GPIOB-IDR 0) 1; delay_us(410); // 补足复位时序剩余时间 return presence 0; // 低电平表示设备存在 }复位脉冲 480us 只要求下限短了不行长了没事。存在脉冲的采样点放在释放后 70us 左右处在 60 到 240us 窗口的前段给慢速设备留足余量。补足 410us 这个动作容易被忽略但它保证两次操作之间总线处于正常的空闲高电平状态。写时序和读时序的窗口更窄。写 1 和写 0 的区别在于拉低持续时间写 0 要拉低 60 到 120us写 1 只需拉低 1 到 15us 后释放。// 写一位bit1 时短拉低bit0 时长拉低 void ow_write_bit(uint8_t bit) { if (bit) { GPIOB-BRR (1 0); delay_us(6); // 写 1拉低 6us远小于 15us 上限 GPIOB-BSRR (1 0); delay_us(64); // 释放余量 } else { GPIOB-BRR (1 0); delay_us(60); // 写 0拉低 60us进入有效窗口 GPIOB-BSRR (1 0); delay_us(10); // 释放余量 } } // 读一位主机拉低 2us 后释放在 15us 内采样总线电平 uint8_t ow_read_bit(void) { uint8_t bit; GPIOB-BRR (1 0); delay_us(2); // 启动读时隙 GPIOB-BSRR (1 0); delay_us(8); // 释放后等待电平稳定 bit (GPIOB-IDR 0) 1; // 采样点约 10us 处 delay_us(50); // 补足读时隙总时长 return bit; }写时序里最关键的是写 1 的拉低时间不能超过 15us否则设备会当成写 0。我习惯把写 1 的拉低压到 6us 再释放留出充足余量。读时序的采样点放在释放后 8us也就是总时长约 10us 处这在 15us 采样窗口的中前段。读时序还有个隐形坑如果主机拉低时间太长释放后总线恢复高电平需要时间采到的永远是低电平。提示执行这些微秒级时序时必须关中断。SysTick、UART 中断随时可能打断时序一旦在写 0 的 60us 窗口中间被插一脚整个字节就废了。时序操作前后用__disable_irq()和__enable_irq()包起来。3.3 ROM 搜索算法一条线上找出所有传感器的唯一 ID多点采集的第一步是把线上所有设备的 64 位 ROM 编码找出来。单总线协议里有一条 SEARCH ROM 命令0xF0主机逐位发送读时隙设备会返回两位一位是自己的 ROM 位值另一位是该位的反码。两位合起来有三种情况01 表示该位为 010 表示该位为 100 表示线上存在多个设备且该位发生冲突需要主机决定选哪条分支。// 搜索总线上所有设备 ROM存入 rom_list返回找到的数量 uint8_t ds18b20_search_rom(uint8_t (*rom_list)[8], uint8_t max_devices) { uint8_t rom[8] {0}; uint8_t last_discrepancy 0; // 上次搜索中最后一位冲突位置 uint8_t last_device 0; // 是否已搜索完最后一台设备 uint8_t found 0; while (!last_device found max_devices) { uint8_t discrepancy 0; // 本次搜索最后一位冲突位置 if (!ow_reset()) break; ow_write_byte(0xF0); // SEARCH ROM 命令 for (uint8_t byte_pos 0; byte_pos 8; byte_pos) { uint8_t byte 0; for (uint8_t bit_pos 0; bit_pos 8; bit_pos) { uint8_t bit_index byte_pos * 8 bit_pos; uint8_t b1 ow_read_bit(); // 设备 ROM 位值 uint8_t b2 ow_read_bit(); // 该位反码 if (b1 1 b2 1) { return found; // 总线异常无设备 } uint8_t dir; if (b1 0 b2 0) { // 冲突位按搜索规则选择方向 if (bit_index last_discrepancy) { dir (rom[byte_pos] bit_pos) 1; } else if (bit_index last_discrepancy) { dir 1; // 上次分歧位置这次走 1 } else { dir 0; discrepancy bit_index; // 记录新的分歧位置 } } else { dir b1; // 该位唯一确定 } byte | (dir bit_pos); ow_write_bit(dir); // 把选择写回总线 } rom[byte_pos] byte; } if (discrepancy 0) { last_device 1; // 没有新冲突搜索结束 } else { last_discrepancy discrepancy; // 为下一轮搜索更新位置 } // ROM 最后一位是 CRC 校验码验证通过才算有效 if (crc8(rom, 7) rom[7]) { for (uint8_t i 0; i 8; i) { rom_list[found][i] rom[i]; } found; } } return found; }搜索算法的核心逻辑在冲突位处理上。第一次搜索时 last_discrepancy 为 0所有冲突位都走 0 分支算法会找到 ROM 编码最小的设备。下一轮搜索时在上一轮最后一个未走 1 的冲突位置改走 1就能找到编码次小的设备。如此反复直到某次搜索全程没有出现新的冲突位说明所有设备都枚举完了。这里有个容易翻车的地方冲突位的方向选择必须严格按照「小于上次分歧位沿用旧值等于上次分歧位走 1大于上次分歧位走 0」的规则。某开发者把小于和等于的情况合并处理导致搜索到第 3 个设备时陷入死循环每次枚举出来的 ROM 都是同一个。另外CRC 校验不能省总线上如果有干扰错误的 ROM 会被存进列表后续匹配命令永远找不到对应设备。4. 读温度与上报CRC 校验、负温度、UART 输出4.1 启动转换与读暂存器命令序列别搞反ROM 搜索只解决「线上有谁」的问题真正读温度还要走两条命令0x44 启动温度转换0xBE 读取暂存器。命令顺序不能反先转换再读取转换命令发出后必须等转换完成。// 读取指定 ROM 设备温度rom 为 NULL 时跳过匹配命令单设备快速读 float ds18b20_read_temp(uint8_t *rom) { uint8_t data[9] {0}; int16_t raw; if (!ow_reset()) return -100.0f; if (rom) { ow_write_byte(0x55); // MATCH ROM指定设备 for (uint8_t i 0; i 8; i) ow_write_byte(rom[i]); } else { ow_write_byte(0xCC); // SKIP ROM广播给所有设备 } ow_write_byte(0x44); // 启动温度转换 // 等待转换完成12 位分辨率典型 750ms delay_ms(750); if (!ow_reset()) return -100.0f; if (rom) { ow_write_byte(0x55); for (uint8_t i 0; i 8; i) ow_write_byte(rom[i]); } else { ow_write_byte(0xCC); } ow_write_byte(0xBE); // 读取暂存器 for (uint8_t i 0; i 9; i) { data[i] ow_read_byte(); // 读 9 字节温度 告警 CRC } if (crc8(data, 8) ! data[8]) return -200.0f; // CRC 失败哨兵值 raw (data[1] 8) | data[0]; // 温度寄存器低 2 字节 return raw * 0.0625f; // 12 位分辨率每 LSB 0.0625℃ }这段代码里有两个返回值约定-100.0f 表示总线复位失败-200.0f 表示 CRC 校验失败。DS18B20 的测温范围是 -55℃ 到 125℃-100 和 -200 都在真实量程之外不会和正常数据混淆。用这种「哨兵值」做错误标记比返回结构体或错误码更直观上层轮询代码直接判断执行范围即可。转换等待时间有个优化空间12 位分辨率下器件手册给出的典型转换时间是 750ms但这是上限实际往往更快。对单设备读温度固定延时最简单多设备巡检时固定延时会让每路都等满 750ms8 路就是 6 秒后面第 6 章会讲怎么用广播转换把这个时间压下来。4.2 CRC-8 检查判错不重读等于白做单总线协议里每个设备的 ROM 编码和暂存器数据都带 CRC 校验。CRC 校验不是可选功能长线环境下一个干扰脉冲就可能让暂存器里的温度数据跳变几位没有校验你根本不知道数据是错的。// CRC-8 按位计算多项式 x^8 x^5 x^4 1即 0x31 uint8_t crc8(uint8_t *data, uint8_t len) { uint8_t crc 0; for (uint8_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x80) { crc (crc 1) ^ 0x31; } else { crc 1; } } } return crc; }按位计算的 CRC 在单总线上足够快8 字节数据算一遍也就几十微秒。多项式固定是 0x31初值 0这个参数不能随意改否则和器件内部的 CRC 生成器对不上。如果追求速度也可以改成查表法256 字节的表在 F030 的 64KB Flash 里毫无压力。我一般会在 CRC 校验失败后连续重读 3 次3 次都失败才判定通道异常。单总线在电机启停、继电器吸合这类干扰场景下偶发单次失败很正常重读能过滤掉大部分毛刺。但注意重读之间要有至少 10ms 间隔让总线充分恢复连续快速重读往往连续失败。4.3 负温度与格式化输出int16_t 补码直接乘分辨率就对了温度寄存器是 16 位有符号补码格式12 位分辨率下低 4 位是小数部分。处理负温度时最容易出问题的是用了无符号类型承接导致 -10℃ 变成 65526 然后乘出荒谬结果。// 格式化输出一路温度经 UART1 发送 void report_temp(uint8_t channel, uint8_t *rom) { float temp ds18b20_read_temp(rom); char buf[48]; if (temp 125.0f || temp -55.0f) { // 哨兵值范围之外说明是 CRC 失败或总线错误 sprintf(buf, CH%02d ERR\r\n, channel); } else { sprintf(buf, CH%02d %6.2f C\r\n, channel, temp); } // 逐字符发送避免占用大缓冲 for (char *p buf; *p; p) { while (!(USART1-ISR (1 7))); // 等待 TXE 空 USART1-TDR *p; } }温度转换的核心就一行先拼成int16_t再乘 0.0625f。负温度在补码下乘完直接就是负数不需要额外判断符号位。sprintf输出的浮点位宽用%6.2f固定两位小数串口工具解析也方便。换行用\r\n而不是\n很多串口助手只认前者。格式化成字符串后逐字符发送而不是一次性printf是为了避开 F030 上printf重定向到 UART 时可能出现的缓冲问题。USART1 在 115200 波特率下发送几十个字符只需几毫秒不会拖慢巡检周期。5. 多点采集避坑记录上拉电阻、寄生供电与 85℃ 假数据5.1 第二路以后全是 85℃上拉电阻和驱动能力的问题现象单设备测试正常挂上 4 个传感器后有些通道读回 85℃有些通道 CRC 校验失败随机分布但集中在离主机远的设备。原因DS18B20 上电后温度寄存器默认值就是 85℃读回这个值说明设备压根没响应主机读到了空总线上的 0xFFCRC 也自然对不上。总线上一堆设备并联后等效电容变大上拉电阻驱动力不足远端的设备在读写时隙里电平没有充分翻转。解决把上拉电阻从 10k 换成 4.7k3 米以上线缆再降到 2.2k。F030 的 GPIO 输出驱动能力有限必要时把速率等级配置成最高。上拉电阻换来换去是这门手艺里最常见的「玄学」操作实际就是 RC 常数问题找到示波器上边沿不再缓变的值就对了。5.2 线一长就 CRC 报错对线缆电容的敏感度远超预期现象1 米内一切正常线延长到 5 米后读温度开始间歇性失败时间集中在温度转换完成后的读取阶段。原因长线的分布电容让读写时隙的波形边沿变缓尤其是读时序主机在 15us 窗口内采样时总线电平可能还没落到有效的逻辑低。另一个因素是分支线缆的反射拓扑从星型变成多分支后更严重。解决把拓扑改成手拉手的菊花链避免长分支给主机侧的数据线串联一个 100Ω 电阻抑制反射读时序的采样点从 8us 往后挪 2us避开最陡峭的振铃区。线缆超过 8 米还想要可靠就得降低分辨率到 9 位或加总线驱动器不能靠调软参硬扛。5.3 搜索 ROM 时算法卡死总线被从机拉死的恢复技巧现象上电后第一次搜索 ROM 正常执行几十次复位后某次搜索返回 0再次复位也检测不到设备必须断电重启。原因某台设备在复位脉冲来临时没有正确释放总线或者设备内部状态机被异常时序打乱一直占用总线。常见诱因是搜索过程中有中断插入把读时隙拉长到 70us 以上设备误判成复位信号。解决每次复位失败后不要立即重试把复位脉冲拉低时间从 480us 加到 640us给设备更长的恢复窗口连续失败 3 次后延时 50ms 再整体重来。搜索函数里对 b1、b2 同 1 的情况直接返回而不是继续死循环这个保护必须有否则总线故障时系统卡在搜索里出不来。5.4 上电就报 85℃传感器默认值不能当故障现象工装刚上电尚未执行任何转换命令第一路温度就上报 85℃。原因DS18B20 上电后暂存器复位温度寄存器默认加载 0x0550换算出来就是 85℃。这是正常现象不是传感器坏了也不是接线问题。很多开发者第一次遇到会误判成硬件故障折腾半天。解决上电后延时 500ms 再发起第一次转换确保器件内部电源稳定如果读到的温度恰好是 85℃ 且连续两次一致主动触发一次转换并等 750ms 重新读取。把 85℃ 当作非法值过滤掉也行但要注意冬天室温如果恰好到 85℃ 那是传感器真的坏了判断逻辑要结合连续读数和 CRC。5.5 中断把时序拉断M0 没有 DWT也不能靠 HAL_Delay现象系统里加了 UART 打印后单总线读取开始偶尔失败打印越频繁失败越明显裸机跑单总线反而一切正常。原因串口中断在读写时隙中间抢占执行把 60us 的写 0 时序拉成 90us设备直接识别成复位信号。M0 内核没有 M3/M4 的 DWT 计数器很多人想用周期计数做精确延时也做不了只能靠关中断。解决所有单总线位级操作放在__disable_irq()和__enable_irq()之间整个字节读写期间关中断。关中断会导致串口接收丢字节但温度采集场景对丢一两个串口字符不敏感丢一个温度字节才是大问题。如果系统里还有对实时性要求高的中断就把单总线访问改成原子操作只保护每一位的读写而不是整个字节。6. 巡检提速与验证手法广播转换、等待完成与逻辑分析仪多路巡检最直观但最蠢的做法是挨个设备发转换命令、每个都等 750ms。8 路就是 6 秒一轮系统响应慢到没法用。正确的做法是广播一条转换命令让线上所有传感器并行转换等一轮转换时间后再逐个匹配 ROM 读取数据。// 广播转换所有传感器并行工作一轮读取只等一次 750ms void read_all_temps(uint8_t (*rom_list)[8], uint8_t count) { // 广播启动转换所有设备同时开始 ow_reset(); ow_write_byte(0xCC); // SKIP ROM ow_write_byte(0x44); // CONVERT T // 转换期间总线被拉低轮询等待所有设备完成 while (1) { // 读一位设备转换完成后释放总线主机读到 1 if (ow_read_bit()) break; } // 逐个读取 for (uint8_t i 0; i count; i) { ow_reset(); ow_write_byte(0x55); // MATCH ROM for (uint8_t j 0; j 8; j) { ow_write_byte(rom_list[i][j]); } ow_write_byte(0xBE); // READ SCRATCHPAD // 后续 9 字节读取与 CRC 校验同前 float temp read_temp_from_scratchpad(); report_temp(i 1, rom_list[i], temp); } }广播转换的精髓在第二条命令转换期间 DS18B20 会把总线拉低转换完成后释放总线。主机持续发读时隙读到高电平就说明所有设备都干完活了不用傻等 750ms。这个技巧把多路读取时间从 N 乘以 750ms 压到 750ms 加逐路读取的几百微秒8 路巡检一轮不到 1 秒。验证工作比写代码更值得花时间。新板回来我先用逻辑分析仪抓三段波形复位后存在脉冲的位置和宽度、写 1 时隙释放后总线恢复高电平的斜率、读时隙采样点落在窗口的哪个位置。这三段波形没问题上层的搜索和读取基本不会出幺蛾子。逻辑分析仪采样率至少 20MHz便宜的逻辑分析仪够用。每次改板子回来我都会先看一遍这三段波形再跑程序这个习惯救过我很多次。波形对了代码不动波形变了优先检查上拉电阻和线缆。时序这类问题逻辑分析仪上一个周期就看明白了比盲调参数快得多。希望帮到你。本文还有配套的精品资源点击获取