资讯详情

资讯详情

ESP32-C3 Super-Mini实现ModbusRTU主站与6路ADC采集方案

这板子我用了好几个月越用越觉得适合做工业数据采集这种活。ESP32-C3-Super-Mini这个板子很多人买来都是做智能家居、WiFi小玩具其实它的串口资源、ADC通道数量和价格都适合搞ModbusRTU主机。这次要分享的是我用它实现一个ModbusRTU主站同时挂6路模拟量采集的完整方案代码可以直接编译下载硬件只需要再加一块RS485转TTL模块。整个过程不算复杂但中间涉及RS485收发方向切换、ADC通道选择和参考电压校准这几个地方最容易翻车。我先把这套东西说清楚用一块十几块钱的ESP32-C3-Super-Mini通过RS485总线去轮询Modbus从站设备比如温控表、变频器、电表同时板载的6路ADC引脚接模拟量传感器比如0-3.3V输出的压力变送器、光敏电阻分压电路采集到的电压值可以本地打印也可以转成Modbus寄存器写到从站设备里。适合谁手头有工控设备要做数据采集、需要一个低成本的协议转换网关、或者刚接触ModbusRTU想找完整代码参照的开发者。1. 准备工作硬件选型与接线1.1 ESP32-C3-Super-Mini到底够不够用很多人一提到ModbusRTU第一反应是STM32、PLC实际上ESP32-C3做这个任务绰绰有余。它有一颗单核RISC-V处理器主频160MHz跑Modbus轮询和ADC采集绰绰有余。关键是片上集成了足够多的UART和ADC资源不用外挂扩展芯片。这个板子比较特殊的一点是它把USB转串口和主控串口复用默认的Serial0用来下载日志所以RS485通讯需要启用Serial1UART1把TX和RX引脚用代码重新映射到任意GPIO。这个设计反而是优势普通STM32如果要重新映射串口引脚还得看引脚复用表ESP32-C3每个外设都可以灵活路由省了不少事。6路ADC是精确计算过的。ESP32-C3的ADC1一共有6个通道对应GPIO0到GPIO5刚好可以满足6路模拟量输入。需要注意的是ADC2虽然也存在但在WiFi射频开启时会被占用结果极不稳定所以这里我干脆只用ADC1正好把所有通道都用上。整体项目成本可以控制在30元以内开发板十几块MAX3485模块两三块传感器另算。这个性价比在工业数据采集场景里非常有吸引力。1.2 需要用到的器件清单核心物料其实就这么几样ESP32-C3-Super-Mini开发板带排针方便接杜邦线RS485转TTL模块必须选3.3V供电的MAX3485方案别选5V的MAX485一个5V/3.3V稳压电源或电脑USB口供电若干模拟量输出的传感器输出范围最好在0-3.3V以内一个或多个ModbusRTU从站设备如果没有实体设备可以用电脑上的Modbus模拟软件配合USB转RS485调试MAX3485模块是最关键的一环。ModbusRTU物理层是RS485差分信号标准电压高达-7V到12VESP32的GPIO绝对承受不了必须经过收发器转换。选择MAX3485而不是MAX485的核心原因是电平匹配MAX485是5V器件逻辑高电平输出接近5V直接接到ESP32的RX引脚会超出3.3V承受范围长期使用有烧毁风险。MAX3485供电和逻辑电平都是3.3V匹配ESP32直接接线不用额外电平转换。1.3 接线表与供电注意事项具体接线不搞虚的直接给表。ESP32-C3-Super-Mini引脚定义我用实测确认过的ESP32-C3-Super-Mini引脚外接模块/传感器说明5VRS485模块VCC不接MAX3485用部分模块带隔离电源才需要3V3RS485模块VCCMAX3485供电GNDRS485模块GND、所有传感器GND必须共地GPIO21RS485模块DI发送数据连接UART1 TXGPIO20RS485模块RO接收数据连接UART1 RXGPIO8RS485模块DE/RE收发方向控制GPIO0模拟量通道0ADC1_CH0GPIO1模拟量通道1ADC1_CH1GPIO2模拟量通道2ADC1_CH2GPIO3模拟量通道3ADC1_CH3GPIO4模拟量通道4ADC1_CH4GPIO5模拟量通道5ADC1_CH5供电这块要单独提醒一下。如果传感器数量多不要用板载3V3引脚给传感器供电3V3是通过板载LDO从5V转出来的输出电流有限带6个传感器很可能电压跌落直接影响ADC采集精度。我测试时发现一个现象接4个传感器之后3V3从标称3.3V掉到3.12VADC读出来的数值整体偏高。靠谱做法是传感器统一用外部5V供电输出为0-3.3V的信号接ESP32引脚或者用一个独立的3.3V稳压模块专门给传感器供电。ADC参考电压就是3V3引脚的实际电压值这一点在后面校准部分还会细说。2. 设计思路这台设备要干三件事2.1 ModbusRTU主机是怎么个玩法ModbusRTU协议本身是一主多从结构总线上只能有一个主机从机地址从1到247主机通过地址区分不同从机。通讯过程是问答式主机发请求帧从机响应主机收到响应后发下一帧不允许从机主动上报数据。ESP32-C3做主机意味着要自己维护轮询调度逻辑。请求帧结构很固定从站地址1字节 功能码1字节 数据N字节 CRC16低字节 CRC16高字节比如要读1号从站的保持寄存器起始地址0x0000读8个寄存器功能码0x03请求帧就是01 03 00 00 00 08 44 0C。最后两个字节是CRC16校验这个必须自己算做协议解析逃不掉。2.2 ADC六通道采集的踩坑点ESP32系列的ADC一直被人吐槽精度一般这不完全是芯片差而是摸不准它的脾气。先说12位分辨率这件事理论上0-3.3V对应0-4095每1个LSB大约0.8mV看起来挺美但ESP32的ADC实际线性度只在0.1V到3.0V左右比较好接近满量程时误差明显。另外ADC1的参考电压实际上是内部带隙基准约1.1V经过增益放大得到的所以最终满量程电压取决于衰减配置。Arduino环境下通常默认配置了11dB衰减量程可以到3.3V左右但不同内核版本默认行为可能不同。最稳妥的做法是直接在代码里用analogReadMilliVolts()这个函数它会把衰减和校准数据都算进去返回以毫伏为单位的结果比自己用analogRead再乘比例系数准得多。六通道扫描还有一个容易被忽视的采样稳定时间问题。模拟输入切换通道后ADC内部的采样保持电容需要时间充电如果通道切换立刻读取读数会偏小。解决办法是每个通道切换后稍作延时或者连续读取多次取平均值我用的是后者。2.3 任务调度时间片轮询还是状态机硬件方案定了之后最需要考虑的是代码结构。常见的做法是loop里不断轮询这个项目如果直接在循环里既做ADC采集又做Modbus请求会出现一个问题发送RS485请求后要等待从站响应这个等待期间如果还去执行ADC读取到超时时间没到就从等待状态跳出来了逻辑容易混乱。我采用的方案是用一个简单的时间片轮询框架。Modbus轮询间隔设定为1秒ADC采集间隔设定为500毫秒。每次循环先检查当前时间是否到ADC采集时机如果是就执行采集和打印再检查是否到Modbus轮询时机如果是就发起一次读请求。两个任务的时间片不重叠代码结构清晰不会出现一个任务卡住另一个任务的情况。这里有个进阶技巧工业现场如果需要更快响应可以考虑把Modbus状态机拆成发送态、等待态、接收完成态用非阻塞方式处理。但对于大多数采集需求1秒间隔已经足够反而代码越简单越好维护。3. 完整代码从零到能跑3.1 开发环境配置我用的Arduino IDE 2.3以上版本在开发板管理器里搜索esp32安装Espressif官方支持的ESP32 Arduino Core。要用到的板子型号选择“ESP32C3 Dev Module”不是“ESP32-C3-Super-Mini”这种第三方名称——Super-Mini只是硬件形态芯片就是ESP32-C3开发板选择标准型号即可。USB CDC功能开启与否无所谓不影响本项目。如果下载失败按住板载BOOT按键再插USB线进入下载模式基本都能解决。3.2 完整源码完整工程可以直接复制到Arduino IDE编译下载。代码里我已经把关键路径都注释掉了下面分段贴出来方便对照讲解。先看头文件、宏定义和全局变量#include HardwareSerial.h // RS485方向控制引脚 // DE/RE接GPIO8高电平为发送低电平为接收 #define RS485_DIR_PIN 8 // UART1引脚定义 // GPIO21 - MAX3485 DIGPIO20 - MAX3485 RO #define RS485_TX_PIN 21 #define RS485_RX_PIN 20 #define RS485_BAUD 9600 // Modbus从站参数 #define MB_SLAVE_ADDR 0x01 #define MB_REG_START 0x0000 #define MB_REG_QTY 8 // ADC通道引脚 const uint8_t adcPins[6] {0, 1, 2, 3, 4, 5}; #define ADC_SAMPLE_COUNT 32 HardwareSerial rs485(1); // 使用UART1然后是CRC16计算函数。ModbusRTU用的是标准CRC16-Modbus多项式是0xA001。这个函数网上很多版本我贴的是自己整理过、测试没有问题的uint16_t crc16(const uint8_t* data, size_t len) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }接着是RS485收发方向切换函数。方向切换为什么需要延时MAX3485从接收状态切换到发送状态大约需要几百纳秒到几微秒如果切换完立刻发数据第一个字节可能被吃掉。我这里留了10微秒实测9600波特率下非常稳定void rs485SetTransmit() { digitalWrite(RS485_DIR_PIN, HIGH); delayMicroseconds(10); } void rs485SetReceive() { delayMicroseconds(10); digitalWrite(RS485_DIR_PIN, LOW); }Modbus读保持寄存器函数带完整的状态码判断和CRC校验。接收数据时用超时控制防止总线故障导致死循环bool modbusReadRegisters(uint8_t slave, uint16_t addr, uint16_t qty, uint16_t* values) { uint8_t frame[8]; frame[0] slave; frame[1] 0x03; // 功能码读保持寄存器 frame[2] (addr 8) 0xFF; frame[3] addr 0xFF; frame[4] (qty 8) 0xFF; frame[5] qty 0xFF; uint16_t crc crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; // 清空接收缓存避免读到上一帧的残留数据 while (rs485.available()) { rs485.read(); } rs485SetTransmit(); rs485.write(frame, 8); rs485.flush(); rs485SetReceive(); // 等待并接收响应 // 从站正常响应长度为从站地址 功能码 字节数 寄存器数据 CRC共 9 qty*2 字节 uint8_t buf[64]; size_t idx 0; size_t expectedLen 3 qty * 2 2; unsigned long start millis(); while (millis() - start 200) { while (rs485.available()) { buf[idx] rs485.read(); if (idx expectedLen) break; } if (idx expectedLen) break; } if (idx expectedLen) return false; // 校验从站地址和功能码 if (buf[0] ! slave || buf[1] ! 0x03) return false; // 校验CRC uint16_t recvCrc buf[idx - 2] | (buf[idx - 1] 8); if (crc16(buf, idx - 2) ! recvCrc) return false; // 提取寄存器值 for (uint16_t i 0; i qty; i) { values[i] (buf[3 i * 2] 8) | buf[4 i * 2]; } return true; }写单个保持寄存器函数也一起放出来后面如果要把ADC数据上报给从站设备就能直接用。功能码0x06响应帧和请求帧完全相同bool modbusWriteSingleRegister(uint8_t slave, uint16_t addr, uint16_t value) { uint8_t frame[8]; frame[0] slave; frame[1] 0x06; // 功能码写单个保持寄存器 frame[2] (addr 8) 0xFF; frame[3] addr 0xFF; frame[4] (value 8) 0xFF; frame[5] value 0xFF; uint16_t crc crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; while (rs485.available()) { rs485.read(); } rs485SetTransmit(); rs485.write(frame, 8); rs485.flush(); rs485SetReceive(); uint8_t buf[8]; size_t idx 0; unsigned long start millis(); while (millis() - start 200) { while (rs485.available()) { buf[idx] rs485.read(); if (idx 8) break; } if (idx 8) break; } if (idx 8) return false; if (buf[0] ! slave || buf[1] ! 0x06) return false; uint16_t recvCrc buf[6] | (buf[7] 8); if (crc16(buf, 6) ! recvCrc) return false; return true; }ADC采集部分我封装了两个函数。读取函数做了多次采样取平均这样能有效滤掉随机噪声uint32_t readADCMillivolts(uint8_t pin) { uint32_t sum 0; for (uint8_t i 0; i ADC_SAMPLE_COUNT; i) { sum analogReadMilliVolts(pin); // 采样间隔极短让内部采样保持电容有充分时间稳定 } return sum / ADC_SAMPLE_COUNT; }setup函数里初始化串口和引脚。GPIO8在ESP32-C3上属于strapping pin上电瞬间电平状态会影响启动方式但程序运行后作为普通输出没有影响。AnlogRead的衰减在Arduino Core里默认已经配置好无需额外设置void setup() { Serial.begin(115200); delay(500); // 等待串口监视器就绪 pinMode(RS485_DIR_PIN, OUTPUT); digitalWrite(RS485_DIR_PIN, LOW); // 默认为接收状态 rs485.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); Serial.println(ESP32-C3 ModbusRTU Master 6ch ADC Start); }主循环采用时间片轮询。ADC每500毫秒刷新一次Modbus轮询每1秒执行一次void loop() { static unsigned long lastAdcTime 0; static unsigned long lastModbusTime 0; unsigned long now millis(); // ADC采集任务每500ms执行一次 if (now - lastAdcTime 500) { lastAdcTime now; uint32_t mv[6]; for (uint8_t i 0; i 6; i) { mv[i] readADCMillivolts(adcPins[i]); } Serial.printf(ADC: %lu %lu %lu %lu %lu %lu mV\r\n, mv[0], mv[1], mv[2], mv[3], mv[4], mv[5]); // 可选把前6路ADC值写入从站保持寄存器 // 这里写入偏移地址0x0010开始的位置 for (uint8_t i 0; i 6; i) { modbusWriteSingleRegister(MB_SLAVE_ADDR, 0x0010 i, mv[i]); } } // Modbus轮询任务每1000ms执行一次 if (now - lastModbusTime 1000) { lastModbusTime now; uint16_t regs[MB_REG_QTY]; bool ok modbusReadRegisters(MB_SLAVE_ADDR, MB_REG_START, MB_REG_QTY, regs); if (ok) { Serial.print(MODBUS: ); for (uint8_t i 0; i MB_REG_QTY; i) { Serial.printf(%04X , regs[i]); } Serial.println(); } else { Serial.println(MODBUS: timeout or error); } } }3.3 代码执行逻辑与关键函数拆解整个程序的主线不复杂先用CRC16函数生成请求帧然后切换RS485到发送状态把请求帧发到总线上切换回接收状态等待从站响应收齐数据后校验从站地址、功能码、CRC最后提取寄存器值。重点说几个容易被忽略的细节第一发送前为什么要清空接收缓存RS485总线上可能存在干扰脉冲或者上一次通讯残留的数据如果不先清空接收循环会把这些旧数据当成响应帧的一部分处理。我的做法是在发送请求前用while循环把串口接收缓冲区全部读出来丢掉。第二接收超时时间怎么定9600波特率下一个字节大约1.04ms8个字节的响应帧需要约8.3ms。200ms的超时时间已经非常充裕同时也能在从站无响应时快速报错。如果你把波特率提高到115200超时时间可以缩短到50ms。第三flush()函数的作用。rs485.write()只是把数据放进UART发送缓冲区硬件还没发完就切换方向的话帧后半部分会丢。flush()会阻塞直到发送完成再切换DE/RE引脚这样就不会出错。第四关于ADC和Modbus写寄存器的顺序。我在代码里是先读ADC然后直接写到从站寄存器再从从站读回几个寄存器用于验证。实际项目中这个顺序可以根据需求调整但要注意不要让Modbus请求过于频繁否则总线上全是写操作主机别的任务都干不了。4. 调试实录与常见问题4.1 第一次上电发生了什么第一次接好硬件上电串口监视器输出ADC数据正常但Modbus请求一直报超时。排查过程花了大约半小时最后发现问题出在MAX3485模块的DE/RE没有接对。我买的模块DE和RE是独立引脚RE是低电平有效使能接收DE是高电平使能发送两个引脚可以单独控制。如果只把DE接GPIO8而RE悬空RE内部可能被拉低或者拉高导致模块始终处于某种状态。解决办法是把DE和RE短接在一起再接到GPIO8。这样GPIO8输出高电平时模块进入发送模式输出低电平时进入接收模式逻辑正好匹配代码里的方向切换逻辑。4.2 常见问题排查速查表调试过程中我整理了一个排查表从项目验证到实际运行都很实用问题现象可能原因解决办法Modbus一直超时RS485 A/B接反对调A、B两根信号线Modbus能收到响应但CRC错误从站地址或寄存器地址不对使用Modbus Poll等工具先读一次确认地址单个从站正常多个从站后偶尔超时缺少终端电阻或总线过长在总线两端各加一个120欧终端电阻发送完请求后立刻切回接收数据串帧DE/RE方向切换太快发送后增加1-2ms延时再切接收ADC读数明显偏高3V3电源被传感器拖垮外部独立供电或测量实际3V3电压校准系数某一路ADC读数始终为0引脚悬空或衰减配置不对确认传感器有输出电压信号检查接线下载代码失败板子处于异常状态按住BOOT键再插USB进入下载模式RS485模块发热严重供电电压接反或接到5V确认模块是3.3V供电检查接线4.3 关于RS485方向和隔离的几点经验项目中我踩过最深的一个坑是RS485收发方向切换。很多教程写的是发送数据时把DE拉高发完立刻拉低看起来没问题但实际跑起来经常出现接收不到响应或者响应乱码。原因在于MAX3485从发送模式切回接收模式时总线驱动器输出要从高阻态切换到差分接收状态这个切换过程大约持续几微秒。如果刚切完立即去读串口可能把总线上的残余电压当成数据读进来。我在rs485SetReceive()函数里故意加了10微秒延时实测可以明显减少帧错误。另一个值得说的是隔离问题。我这套方案用的是非隔离MAX3485板子和外部设备共地。如果现场有变频器、电机之类的大功率设备共地容易引入共模干扰严重时可能烧毁MCU。预算允许的情况下建议换成带隔离的RS485模块或者在总线上加TVS管和共模电感做保护。对于纯粹的学习和低速采集场景非隔离方案已经够用但做工业产品必须考虑隔离。ADC采集同样有硬件层面的坑。ESP32-C3的模拟输入引脚没有过压保护输入超过3.3V哪怕一瞬间都可能烧毁内部ADC。如果传感器是0-5V甚至0-10V输出必须加电阻分压。我用的分压方案是10k和6.8k电阻分压把0-5V映射到0-3.25V留了一点余量再在输入引脚并一个100nF电容做低通滤波。整个工程还有一个天然优势ESP32-C3自带WiFi和BLE目前跑Modbus和ADC通信只用了极少的外设资源。如果后续需要上云只需要加几行代码把ADC数据和Modbus寄存器值通过网络发送到MQTT服务器上位机或手机小程序就能远程查看。我目前就在从站端接了一个温湿度传感器把温度和湿度换算成ADC通道值再用Modbus寄存器上报到数据中控台跑了几个星期没有出现过数据丢失或者卡死的情况。这个方案的扩展方向很广后面打算把波特率提到115200再挂几个不同地址的仪表做多从站压力测试。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →