尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

ESP32-C3-Super-Mini实战:Modbus RTU主站与6通道ADC同步采集方案

发布时间:2026/9/28 1:30:06

资讯中心
01
ARTICLE

ESP32-C3-Super-Mini实战:Modbus RTU主站与6通道ADC同步采集方案

ESP32-C3-Super-Mini实战:Modbus RTU主站与6通道ADC同步采集方案
做现场设备改造的朋友十有八九都遇到过这种组合需求一边要接几路4-20mA变送器、电位计、光敏电阻这类模拟量另一边还要通过RS485总线去轮询一台智能电表、温湿度传感器或者PLC的寄存器。以前遇到这种活要么搬PLC要么一块单片机采集、另一块单片机跑协议再接个串口互传麻烦不说成本也压不下来。现在我手头这块七八块钱的ESP32-C3-Super-Mini用一个UART外挂RS485收发器去当Modbus RTU主机轮询从机同时把GPIO0到GPIO5全部留给ADC正好凑满6通道模拟采集两个需求在一颗RISC-V芯片上同时完成。这篇文章就把整个方案的引脚分配、Modbus RTU帧结构、ESP32-C3的ADC特性、完整可编译代码和实测排错过程一次讲透适合刚从Arduino转过来、或者第一次接触Modbus主站协议的朋友直接照着抄作业。1. 为什么是ESP32-C3-Super-Mini一个小板子同时干两件活1.1 这个组合的真实使用场景先想清楚项目的本质。Modbus RTU主机负责主动发出请求从机收到请求之后回数据而ADC采集则是纯粹的本机行为不依赖总线。两者原本互不相干但你的现场设备经常把这两种信号混在一起压力变送器输出4-20mA测完电流还要去隔壁RS485总线上读一台温湿度传感器的寄存器最后把两组数据一起上报给上位机。这种场景如果用两个MCU做通信同步、数据拼接、双串口日志都得额外处理。ESP32-C3-Super-Mini的好处在于它本身具有硬件UART能跑Modbus剩下的普通GPIO又刚好能复用成模拟输入通道单核160MHz处理这点轮询和滤波绰绰有余。板子尺寸又小我甚至直接在改造成品仪表外壳时把它塞进去过。1.2 和其他常见方案的对比选型纠结的时候把同类方案放在一起比一下结论会直白很多方案优势劣势结论ESP32-C3-Super-Mini便宜、6路ADC、硬件UART、有WiFi、Arduino兼容ADC精度一般、单核首选STM32F103外设可靠、开发资料多配置繁琐、网络能力弱、价格波动大工业量产再考虑ESP8266WiFi成熟、生态大只有1个可用ADC、电压范围窄不适合多通道采集树莓派资源丰富、可跑Python贵、功耗高、上电启动慢现场嵌入式大材小用我最终选C3-Super-Mini还有一个很实际的原因它的GPIO0到GPIO5是独立连续的6个ADC1通道不用像部分板子那样跟下载引脚、LED引脚挤在一起布线时脑子很清爽。2. 硬件接线6路ADC引脚分配与RS485收发器2.1 引脚分配ADC采样恰好占满GPIO0到GPIO5ESP32-C3的数据手册写得很明确芯片只有ADC1没有ADC2ADC1一共6个通道分别对应GPIO0到GPIO5。这也是很多人第一次用C3时踩的坑——以为是ESP32那种有ADC2的架构结果把SPI引脚当模拟输入读数永远是0或者乱跳。除了ADC引脚RS485部分也要规划好不能跟ADC抢GPIO。我采用的分配如下功能引脚说明ADC1_CH0GPIO0模拟输入ADC1_CH1GPIO1模拟输入ADC1_CH2GPIO2模拟输入ADC1_CH3GPIO3模拟输入ADC1_CH4GPIO4模拟输入ADC1_CH5GPIO5模拟输入RS485 TXGPIO6接MAX3485的DIRS485 RXGPIO7接MAX3485的RORS485 DE/REGPIO10方向控制DE和RE并联每个开发板批次的LED和按键引脚有差异我建议你在Super-Mini上先确认一下自己这块板子的GPIO8和GPIO9分别接了什么东西避免后面调试时误判。转接板丝印看不清的时候直接用万用表量LED两端引脚基本就能判断。2.2 MAX3485的接线和DE/RE方向控制RS485是半双工总线所以收发器一定有一个方向控制脚。MAX3485的DE是发送使能RE是接收使能两者高电平时发送、低电平时接收直接把这两个脚并在一起接到GPIO10最省事。接线口诀很简单MAX3485的RO接ESP32的RXGPIO7DI接ESP32的TXGPIO6DE和RE并联接GPIO10VCC接3.3VGND共地A和B就是总线输出。注意别用MAX485它是5V逻辑电平的器件虽然部分模块标的兼容3.3V但实测在边缘电压下容易误码既然C3是3.3V系统直接用MAX3485最稳。2.3 总线稳定通信的三个隐藏前提RS485虽然抗干扰强但并不是两根线一接就能跑。做过几个项目之后我把最容易忽略的三个前提列给你A/B接反A接B、B接A结果就是完全收不到响应而且这种错误用万用表很难一眼看出来要靠交换线序排查。终端电阻和偏置距离超过几十米或者总线上的从机节点数较多时最好在总线两端各放一个120Ω终端电阻如果空闲状态下从机返回乱码还要在A上拉、B下拉阻值取4.7k到10k让空闲电平稳定在确定状态。共地问题短距离、同一供电系统下可以直接共用GND不同供电、长距离布线时建议拉一条GND线或者用带隔离的RS485模块避免共模电压超出接收器允许范围。3. Modbus RTU帧结构拆解主机必须自己会算CRC3.1 请求帧、响应帧和CRC16Modbus RTU的报文格式并不复杂难的是很多教程只教怎么调库不教帧结构导致一旦通信失败就完全摸不着头脑。主机读保持寄存器的请求帧固定8个字节字节内容例子0从机地址0x011功能码0x032-3起始寄存器地址大端0x00 0x004-5寄存器数量大端0x00 0x026-7CRC16低字节在前见计算从机正常响应是从机地址、功能码、数据字节数、N个寄存器数据、CRC。异常响应则是功能码最高位置1后面带一个异常码。这里最容易错的是CRC字节序——Modbus规定CRC低字节在前高字节在后跟大多数人的直觉相反。CRC16 Modbus的标准算法是初值0xFFFF多项式0xA001逐字节异或后右移8次uint16_t crc16Modbus(const uint8_t* data, size_t len) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { crc ^ data[i]; for (int b 0; b 8; b) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }这个函数我用在多个项目里从没出过问题。你完全可以拿它去校验调试助手的抓包数据。3.2 功能码怎么选03、04、06、16的区别很多从机设备手册会写寄存器地址40001这就是功能码03和地址的映射关系。40001对应保持寄存器的0号地址用03功能码读如果设备手册写的是30001开头的地址就要用04功能码读输入寄存器。功能码含义典型场景0x03读保持寄存器大多数仪表、变送器0x04读输入寄存器只读采集量0x06写单个寄存器设报警值、地址0x10写多个寄存器设PID参数、批量配置判断用哪个功能码最靠谱的办法是看设备手册的Modbus寄存器表或者用485转USB调试助手手动发一帧确认。我习惯先试03因为国内大多数工业仪表的出厂寄存器都映射到保持寄存器。3.3 超时和异常码别让主机傻等主站程序最容易写成的样子是发出请求后死等数据这在真实总线上非常危险。实际总会发生从机掉线、接线松动、CRC错误这些情况所以主机必须有超时机制。超时时间建议按照波特率计算9600波特率下1个字符大约1ms一帧响应几十个字节200ms的超时已经非常宽裕。另外要识别Modbus异常码常见的有01非法功能码、02非法数据地址、03非法数据值、04从站设备故障。调试时收到异常码重点查寄存器地址和功能码是否匹配手册而不是去怀疑CRC代码。4. ESP32-C3的ADC没那么省心6通道采集前必须知道的限制4.1 只有ADC1、12位分辨率有效位数大概10位C3的ADC是12位逐次逼近型理论分辨率是4096但实际有效位数受噪声和电源纹波影响大概在10到11位之间。指望它读出稳定的0.1mV精度不现实但用于4-20mA变送器、电位器、光敏电阻这类信号绰绰有余。关键限制是C3只有ADC1也就是GPIO0到GPIO5这6个通道不要试图用GPIO6、GPIO7等其他引脚去变通行不通。4.2 衰减档位与测量范围别一上来就设11dBArduino环境下用analogSetAttenuation配置衰减常见档位如下衰减档位推荐测量范围说明ADC_0db0 ~ 1.1V适合低电压信号ADC_2_5db0 ~ 1.5V少用ADC_6db0 ~ 2.2V常用ADC_11db0 ~ 2.5V线性区最大3.1V采集0-3.3V信号我建议统一用11dB衰减但要注意两点一是在约150mV以下以及接近3.1V时非线性明显所以系统设计时尽量把被测信号映射到0.2V到2.5V之间二是不要超过3.1V否则可能损坏ADC引脚。如果你的信号源是0-10V需要加分压电阻建议用10k和3.3k分压让10V对应约2.48V。4.3 4-20mA变送器接ADC的省事方案4-20mA电流环是工控现场最常见的模拟量信号。接C3 ADC最省钱的办法是让电流流过一只精密电阻用ADC测电阻两端电压。注意20mA时电压不能超过2.5V的线性区所以选120Ω电阻4mA对应0.48V20mA对应2.4V刚好卡在11dB衰减的线性范围内。如果要算实际电流值电流(mA) 电压(mV) / 120 * 4这个换算系数写进代码里传感器标定会方便很多。4.4 滤波是必须的不是可选项ESP32-C3的ADC裸读数跳动比较明显尤其开启WiFi后射频工作瞬间会引入毛刺。我采用中位值指数滑动平均组合每次采集对同一引脚连续读5次取中位数干掉尖峰脉冲然后对多次中位结果做EMA平滑压制随机噪声。void sortUint32(uint32_t* a, int n) { for (int i 0; i n - 1; i) for (int j 0; j n - 1 - i; j) if (a[j] a[j 1]) { uint32_t t a[j]; a[j] a[j 1]; a[j 1] t; } }这个滤波逻辑带来的延迟很小100ms采样周期下完全察觉不到但ADC输出稳定度提升非常明显。4.5 GPIO0到GPIO5在启动阶段的特殊行为ESP32-C3的GPIO2、GPIO8、GPIO9和部分引脚在芯片上电复位时有特殊作用比如GPIO2是下载模式相关的strap引脚。所以我建议给ADC输入外加一只100nF到1uF的去耦电容既滤高频干扰又能避免传感器输出端在上电瞬间把这些strap引脚拉到一个不确定的电平。如果复位后程序偶尔起不来优先检查接在GPIO2和GPIO8上的外部电路在复位期间的电平。5. 完整代码实现Modbus主机轮询 6通道ADC同步采集5.1 代码整体架构下面的代码可以直接在Arduino IDE中编译环境需要安装arduino-esp32内核2.0.x或3.x均可。整体流程是setup初始化串口和ADC参数loop中每100ms采样一遍6路ADC每1000ms通过Modbus读取一次从机寄存器结果以JSON格式输出到USB串口方便后续接Node-RED、Grafana或者自己写解析脚本。#include Arduino.h /* * ESP32-C3-Super-Mini: Modbus RTU Master 6CH ADC * * 接线: * GPIO0~GPIO5 - 6 路模拟输入 (ADC1_CH0~CH5) * GPIO6 - MAX3485 DI * GPIO7 - MAX3485 RO * GPIO10 - MAX3485 DE RE (并联) * * 使用前根据你的从机修改: MB_SLAVE / MB_BAUD / * MB_REG_ADDR / MB_REG_COUNT * */ // --- RS485 引脚 --- #define PIN_RS485_TX 6 #define PIN_RS485_RX 7 #define PIN_RS485_DE_RE 10 // --- Modbus 参数 --- #define MB_SLAVE 1 // 从机地址按实际修改 #define MB_BAUD 9600 // 与从机保持一致 #define MB_TIMEOUT 200 // 响应超时 ms #define MB_POLL_INTERVAL 1000 // 轮询间隔 ms #define MB_REG_ADDR 0x0000 // 起始寄存器地址 #define MB_REG_COUNT 2 // 连续读取寄存器个数 // --- ADC 参数 --- const int adcPins[6] {0, 1, 2, 3, 4, 5}; #define ADC_MEDIAN_N 5 #define ADC_EMA_ALPHA 0.3f HardwareSerial mbSerial(1); uint8_t rxBuf[256]; uint16_t mbRegs[MB_REG_COUNT]; float adcEmaMv[6] {0, 0, 0, 0, 0, 0}; unsigned long lastModbusMs 0; unsigned long lastAdcMs 0; /* ---------- Modbus CRC16 (poly 0xA001) ---------- */ uint16_t crc16Modbus(const uint8_t* data, size_t len) { uint16_t crc 0xFFFF; for (size_t i 0; i len; i) { crc ^ data[i]; for (int b 0; b 8; b) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; } /* ---------- 读保持寄存器 ---------- * 返回: 0 成功 / -1 超时 / -2 CRC错误 / -3 从机异常 */ int modbusReadHoldingRegs(uint8_t slave, uint16_t regAddr, uint16_t count, uint16_t* out) { uint8_t frame[8]; frame[0] slave; frame[1] 0x03; // 读保持寄存器 frame[2] (uint8_t)(regAddr 8); frame[3] (uint8_t)(regAddr 0xFF); frame[4] (uint8_t)(count 8); frame[5] (uint8_t)(count 0xFF); uint16_t crc crc16Modbus(frame, 6); frame[6] crc 0xFF; // CRC 低字节在前 frame[7] crc 8; digitalWrite(PIN_RS485_DE_RE, HIGH); // 切换为发送 mbSerial.write(frame, 8); mbSerial.flush(); // 等发送完成 digitalWrite(PIN_RS485_DE_RE, LOW); // 切回接收 size_t idx 0; size_t need 0; unsigned long t0 millis(); while (millis() - t0 MB_TIMEOUT) { while (mbSerial.available()) { uint8_t b mbSerial.read(); if (idx 0 b ! slave) continue; // 过滤别的从机响应 rxBuf[idx] b; if (idx 2 (rxBuf[1] 0x80)) { need 5; // 异常帧: 从机功能码异常码CRC } else if (idx 3) { need (size_t)rxBuf[2] 5; // 正常帧长度 if (need sizeof(rxBuf)) { idx 0; need 0; } } if (need 0 idx need) { uint16_t rx_crc rxBuf[need - 2] | (rxBuf[need - 1] 8); if (rx_crc ! crc16Modbus(rxBuf, need - 2)) { return -2; } if (rxBuf[1] 0x80) { return -3; // 异常码在 rxBuf[2] } for (uint16_t i 0; i count; i) { out[i] (rxBuf[3 i * 2] 8) | rxBuf[4 i * 2]; } return 0; } } } return -1; } /* ---------- 6 通道 ADC: 中位值 指数滑动平均 ---------- */ void sortUint32(uint32_t* a, int n) { for (int i 0; i n - 1; i) for (int j 0; j n - 1 - i; j) if (a[j] a[j 1]) { uint32_t t a[j]; a[j] a[j 1]; a[j 1] t; } } void sampleAdcAll() { for (int ch 0; ch 6; ch) { uint32_t mv[ADC_MEDIAN_N]; for (int i 0; i ADC_MEDIAN_N; i) { mv[i] analogReadMilliVolts(adcPins[ch]); delayMicroseconds(200); } sortUint32(mv, ADC_MEDIAN_N); float v (float)mv[ADC_MEDIAN_N / 2]; adcEmaMv[ch] (adcEmaMv[ch] 0) ? v : (adcEmaMv[ch] * (1.0f - ADC_EMA_ALPHA) v * ADC_EMA_ALPHA); } } /* ---------- 输出 JSON ---------- */ void printJson(int mbResult) { Serial.print(F({\ms\:)); Serial.print(millis()); Serial.print(F(,\modbus\:)); if (mbResult 0) { Serial.print(F([)); for (int i 0; i MB_REG_COUNT; i) { if (i) Serial.print(F(,)); Serial.print(mbRegs[i]); } Serial.print(F(])); } else { Serial.print(F(null)); } Serial.print(F(,\adcMv\:[)); for (int ch 0; ch 6; ch) { if (ch) Serial.print(F(,)); Serial.print((int)adcEmaMv[ch]); } Serial.println(F(]})); } void setup() { Serial.begin(115200); pinMode(PIN_RS485_DE_RE, OUTPUT); digitalWrite(PIN_RS485_DE_RE, LOW); // 默认接收 mbSerial.begin(MB_BAUD, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX); analogReadResolution(12); analogSetAttenuation(ADC_11db); // 所有 ADC 通道 11dB 衰减 delay(100); } void loop() { unsigned long now millis(); if (now - lastAdcMs 100) { lastAdcMs now; sampleAdcAll(); } if (now - lastModbusMs MB_POLL_INTERVAL) { lastModbusMs now; int r modbusReadHoldingRegs(MB_SLAVE, MB_REG_ADDR, MB_REG_COUNT, mbRegs); printJson(r); } }把这段代码下载进板子打开串口监视器就能看到类似这样的输出{ms:1000,modbus:[25,63],adcMv:[480,1200,2450,0,3,5]}其中modbus数组是对应从机两个寄存器的原始值adcMv数组是6个通道的毫伏值。对没有挂从机的情况modbus会输出null方便你在上位机判断从机是否掉线。5.2 如果你更想用现成库如果不想自己维护帧结构ModbusMaster库也支持这种用法配置示例如下#include ModbusMaster.h ModbusMaster node; void preTransmission() { digitalWrite(PIN_RS485_DE_RE, HIGH); } void postTransmission() { digitalWrite(PIN_RS485_DE_RE, LOW); } void setup() { Serial1.begin(9600, SERIAL_8N1, PIN_RS485_RX, PIN_RS485_TX); node.begin(1, Serial1); node.preTransmission(preTransmission); node.postTransmission(postTransmission); } void loop() { uint8_t result node.readHoldingRegisters(0, 2); if (result node.ku8MBSuccess) { // node.getResponseBuffer(0), node.getResponseBuffer(1) } }用库的好处是省事坏处是出问题之后库内部的时序细节像个黑盒。我建议先用库把项目跑通再抽时间把我上面的裸实现替换进去这样对协议的理解会扎实很多。5.3 6路ADC以外的扩展把采集量映射成Modbus从机这个方案还有一种很实用的反向玩法让ESP32-C3既当Modbus主机采集其他从机又通过另一个串口或TCP把自己的6路ADC映射成一个寄存器表供PLC或上位机读取。只要在代码里用mbSerial.write配合上面同一套CRC函数构造响应帧就能把板卡变成一台协议转换网关。这属于进阶玩法但如果你做的是整套采集系统强烈建议预留这个能力。6. 实测数据与排查链路遇到问题这样一步步定位6.1 开WiFi与关WiFi的ADC实测对比我在三种条件下分别采集了同一路稳定电压一节18650经分压得到的约1.65V每组采样100次看波动范围测试条件平均电压最大波动备注关WiFi裸读1651 mV±18 mV噪声明显关WiFi中位值EMA1648 mV±5 mV推荐常态开WiFi滤波后1647 mV±9 mV可接受结论是滤波相当有用而且如果项目最终要开WiFi上传数据ADC的波动会比关了WiFi高一点建议把ADC采样放在WiFi休眠间隙或者在软件上保留滤波逻辑不要因为测试时看着稳定就删掉。6.2 Modbus完全无响应时的完整排查链路这是新手最容易卡住的地方我把排查顺序整理成一条链照着走基本半小时内能定位先确认总线上是否有电平活动用万用表或示波器量MAX3485的RO脚主机发请求时应该能看到一串脉冲。如果没有任何脉冲查TX引脚和DI接线再查DE控制脚是否在发送前被拉高。检查AB是否接反把两根线对调再试这是最隐蔽也最常见的问题。用485转USB调试助手直接连从机手动发一帧01 03 00 00 00 02 C4 0B看从机是否有响应。如果调试助手能通而ESP32不能通问题就在你的板子侧如果调试助手也不通那就要去查从机地址、波特率和寄存器地址了。核对波特率很多传感器出厂默认是9600但也有不少默认为4800或19200一定以手册为准。检查CRC计算把CRC代码单独提出来跟调试助手抓到的正确帧比对注意高低字节顺序。如果程序运行中偶尔返回CRC错误优先怀疑总线上有别的节点干扰或者线路过长导致信号边沿失真这时候加终端电阻和偏置电阻能解决大部分问题。6.3 常见问题速查表现象优先检查项处理方式完全无响应AB接反、DI/RO接反交换线序测试偶发超时波特率、终端电阻对表检查、加120Ω终端CRC频繁错误总线干扰、偏置缺失加偏置电阻、缩短线缆返回异常码02/03寄存器地址与功能码核对设备手册寄存器表ADC一直为0引脚选错、未开衰减确认GPIO0-5、设11dBADC读数跳得厉害滤波、WiFi干扰开启中位值EMA滤波6.4 多从机轮询的扩展思路如果一个项目里不止一台从机可以在代码里维护一个从机地址数组按顺序轮询每个从机分配独立的超时时间。注意Modbus规定同一时刻总线只能有一个主机发起请求所以轮询间隔要大于上一次请求的响应超时时间。实测9600波特率下轮询4台从机、每台读2个寄存器1秒钟的轮询周期完全够用。如果你打算把采集数据存到SD卡或者上报MQTT记得把WiFi初始化放到主循环之前并且把ADC采样和WiFi发送调度到不同的时间片里避免射频工作瞬间拉高ADC噪声。类似的技术点很多但核心骨架就是我上面这份代码后续所有功能都在这两条主线上叠加。我在实际做这一套方案时最大的体会是ESP32-C3的ADC精度并不差差的是很多人直接裸读不做滤波。加上RS485那一侧只要把DE/RE方向控制和超时机制写对整个系统其实非常皮实。最后再分享一个小技巧调试阶段把输出改成CSV格式方便在Excel里拉曲线等系统稳定后再切回JSON对接上位机你会省掉很多反复烧录的时间。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。