资讯详情

ESP32-S3四通道ADC采集与Modbus RTU从站实现及Modbus Poll调试指南

📅 2026/10/22 16:20:00 | 华诺云谱 👁 阅读
ESP32-S3四通道ADC采集与Modbus RTU从站实现及Modbus Poll调试指南
简介本资源面向物联网开发初学者与嵌入式工程师提供基于ESP32-S3实现四通道ADC数据采集并通过Modbus RTU协议上传至PC端的完整源码与配套Modbus Poll软件。内容涵盖ADC通道初始化、传感器数据读取、Modbus数据包封装与串行通信配置帮助读者快速搭建从硬件采集到上位机可视化的实验链路。压缩包共221个文件约14.55MB包含cpp与ino源码、h头文件、json与ini工程配置、a与o编译产物、bin固件及exe工具等兼顾源码学习与直接烧录验证。目前已有398人学习下载适合需要掌握ESP32-S3与Modbus RTU结合应用、进行数据采集与远程监控实践的开发者参考。1. 四通道 ADC 采集与 Modbus 从站为什么 ESP32-S3 是低成本数据采集的甜点区如果你手头有一台带 RS485 接口的 PLC 或工控屏想让它读到几路 0-10V 或 4-20mA 的模拟量传统做法是买一块专用的模拟量采集模块价格不低通道数还固定。而 ESP32-S3 自带 12 位 SAR ADC配合一片 RS485 收发器就能做成一个四通道的 Modbus RTU 从站成本压到几十块以内通道数、量程、上报格式全由自己定。这个方案适合做设备改造、实验室数据记录、小型工控节点的人——你不需要精通 Modbus 协议栈但需要知道 ADC 怎么配、寄存器怎么映射、上位机怎么轮询。标题里的“Modbus Poll 教程”和“源码”指向很明确一边是嵌入式端的采集与协议实现另一边是 PC 端用 Modbus Poll 这个工具做调试和验证。很多人卡住的地方不是 ADC 读不出来而是读出来的值对不上、Modbus Poll 一直报超时、或者四通道数据在寄存器里排布错位。这篇笔记就按“硬件选型 → ADC 配置 → Modbus 从站实现 → 上位机联调 → 避坑”的顺序把每个环节的参数和踩坑点讲清楚。2. 硬件链路与 ADC 配置从引脚到原始值的完整路径2.1 为什么选 ESP32-S3 而不是外挂 ADC 芯片ESP32-S3 有两个 SAR ADC 控制器ADC1 和 ADC2分别对应不同的 GPIO。ADC1 支持 8 个通道ADC2 支持 10 个通道但 ADC2 在 Wi-Fi 开启时会被占用所以做四通道采集时优先把通道分配到 ADC1 上。常见做法是选 GPIO1 到 GPIO4 作为四路模拟输入对应 ADC1 的通道 0 到 3。这样即使后续要开 Wi-Fi 做数据转发也不会和 ADC 采集冲突。ESP32-S3 的 ADC 是 12 位分辨率理论输出 0 到 4095但实际有效位数受参考电压和噪声影响。默认参考电压约 1.1V通过衰减器可以扩展到约 3.3V 量程。四通道采集时如果信号源是 0-10V 或 4-20mA需要外加分压电阻或精密采样电阻把电压压到 ADC 可接受范围内。这一步是硬件设计但软件上必须知道满量程对应的原始值是多少否则后面换算全是错的。2.2 ADC 初始化的关键参数与代码下面这段代码用 ESP-IDF 的 ADC 单次转换模式配置 ADC1 的四个通道并做多次采样取平均。单次模式比连续模式更可控适合 Modbus 这种按需读取的场景。#include esp_adc/adc_oneshot.h #include esp_adc/adc_cali.h #include esp_adc/adc_cali_scheme.h #define ADC_CHANNEL_NUM 4 static adc_oneshot_unit_handle_t adc1_handle; static adc_cali_handle_t cali_handle[ADC_CHANNEL_NUM]; static adc_channel_t channel_list[ADC_CHANNEL_NUM] { ADC_CHANNEL_0, // GPIO1 ADC_CHANNEL_1, // GPIO2 ADC_CHANNEL_2, // GPIO3 ADC_CHANNEL_3, // GPIO4 }; void adc_init(void) { // 初始化 ADC1 单元 adc_oneshot_unit_init_cfg_t init_config { .unit_id ADC_UNIT_1, .ulp_mode ADC_ULP_MODE_DISABLE, }; ESP_ERROR_CHECK(adc_oneshot_new_unit(init_config, adc1_handle)); // 逐通道配置衰减和位宽 adc_oneshot_chan_cfg_t chan_config { .atten ADC_ATTEN_DB_12, // 约 3.3V 满量程 .bitwidth ADC_BITWIDTH_12, // 12 位 }; for (int i 0; i ADC_CHANNEL_NUM; i) { ESP_ERROR_CHECK(adc_oneshot_config_channel(adc1_handle, channel_list[i], chan_config)); } // 校准句柄用曲线拟合方案 for (int i 0; i ADC_CHANNEL_NUM; i) { adc_cali_curve_fitting_config_t cali_config { .unit_id ADC_UNIT_1, .chan channel_list[i], .atten ADC_ATTEN_DB_12, .bitwidth ADC_BITWIDTH_12, }; ESP_ERROR_CHECK(adc_cali_create_scheme_curve_fitting(cali_config, cali_handle[i])); } }这段代码里三个参数最容易被忽略。ADC_ATTEN_DB_12对应约 3.3V 满量程如果你选ADC_ATTEN_DB_0满量程只有 1.1V输入超过 1.1V 会直接饱和到 4095数据看起来“有值”但完全不对。ADC_BITWIDTH_12是默认值但如果你改成 9 位原始值范围变成 0-511后面换算系数要跟着改。校准句柄用曲线拟合方案比查表方案精度略好但每个通道都要单独创建不能共用一个句柄。2.3 多次采样与滤波读一次不够ADC 原始值跳动是常态尤其在没有屏蔽的工控环境里。我一般会在单次读取里做 16 次采样去掉最大最小各 2 个剩下的取平均。这样既不会太慢又能压掉大部分尖峰噪声。#define SAMPLE_TIMES 16 #define DISCARD_NUM 2 int adc_read_filtered(adc_channel_t ch, adc_cali_handle_t cali) { int raw_buf[SAMPLE_TIMES]; for (int i 0; i SAMPLE_TIMES; i) { ESP_ERROR_CHECK(adc_oneshot_read(adc1_handle, ch, raw_buf[i])); } // 简单冒泡排序数据量小够用 for (int i 0; i SAMPLE_TIMES - 1; i) { for (int j 0; j SAMPLE_TIMES - 1 - i; j) { if (raw_buf[j] raw_buf[j 1]) { int tmp raw_buf[j]; raw_buf[j] raw_buf[j 1]; raw_buf[j 1] tmp; } } } int sum 0; for (int i DISCARD_NUM; i SAMPLE_TIMES - DISCARD_NUM; i) { sum raw_buf[i]; } int raw_avg sum / (SAMPLE_TIMES - 2 * DISCARD_NUM); int voltage_mv 0; ESP_ERROR_CHECK(adc_cali_raw_to_voltage(cali, raw_avg, voltage_mv)); return voltage_mv; // 返回毫伏值 }SAMPLE_TIMES设 16 是权衡结果再多单通道读取时间超过 10ms四通道轮询会拖慢 Modbus 响应再少滤波效果不明显。DISCARD_NUM设 2 是去掉两端各 2 个极值如果环境噪声特别大可以加到 3 或 4但有效样本数会减少平均值的随机误差反而可能变大。返回毫伏值而不是原始值是为了后面 Modbus 寄存器映射时直接用工程单位减少上位机换算。3. Modbus RTU 从站实现寄存器映射与四通道数据排布3.1 四通道数据在保持寄存器里怎么放Modbus 保持寄存器是 16 位而 ADC 毫伏值范围 0-3300一个寄存器就能放下。四通道占四个连续寄存器比如从地址 0x0000 到 0x0003分别对应通道 0 到通道 3。但这里有个坑Modbus 寄存器地址和实际报文里的地址偏移是 1Modbus Poll 里填的地址如果是 40001对应报文里的地址是 0x0000。很多人第一次调的时候Modbus Poll 读 40001 读到的是通道 1 的数据就是因为偏移没搞对。我一般会在代码里显式定义寄存器映射表把通道号和寄存器地址绑死避免后期改通道顺序时到处找。// 保持寄存器映射地址 0x0000 - 0x0003 对应四通道毫伏值 #define REG_ADC_CH0 0x0000 #define REG_ADC_CH1 0x0001 #define REG_ADC_CH2 0x0002 #define REG_ADC_CH3 0x0003 #define REG_HOLDING_NUM 4 static uint16_t holding_regs[REG_HOLDING_NUM]; void update_holding_regs(void) { holding_regs[REG_ADC_CH0] (uint16_t)adc_read_filtered(channel_list[0], cali_handle[0]); holding_regs[REG_ADC_CH1] (uint16_t)adc_read_filtered(channel_list[1], cali_handle[1]); holding_regs[REG_ADC_CH2] (uint16_t)adc_read_filtered(channel_list[2], cali_handle[2]); holding_regs[REG_ADC_CH3] (uint16_t)adc_read_filtered(channel_list[3], cali_handle[3]); }这段代码把四个通道的毫伏值直接写进holding_regs数组Modbus 协议栈在收到读保持寄存器请求时直接从数组里取数据返回。注意adc_read_filtered返回的是int强转uint16_t时如果电压超过 65535mV 会溢出但 3.3V 量程下最大 3300mV不会溢出。如果你改了衰减配置导致满量程超过 65535mV这里要改成两个寄存器拼一个 32 位值。3.2 用 FreeModbus 还是自己写协议栈ESP32-S3 上跑 Modbus RTU 从站常见做法有两种移植 FreeModbus或者自己写一个精简的 RTU 解析器。FreeModbus 功能全支持线圈、离散输入、保持寄存器、输入寄存器但代码量大移植时要对接串口和定时器。自己写的话只实现 0x03 读保持寄存器和 0x06 写单个寄存器代码量不到 200 行可控性更强。我一般会自己写因为四通道采集场景只需要读保持寄存器不需要线圈和离散输入。下面是一个精简的 RTU 帧解析核心逻辑基于 UART 中断和 3.5 字符间隔判断帧结束。#define MODBUS_SLAVE_ADDR 0x01 #define MODBUS_FUNC_READ_HOLDING 0x03 #define MODBUS_FUNC_WRITE_SINGLE 0x06 // CRC16 查表法省略表用计算法 uint16_t modbus_crc16(uint8_t *buf, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; } // 处理读保持寄存器请求请求帧格式addr(1) func(1) start_hi(1) start_lo(1) num_hi(1) num_lo(1) crc(2) void modbus_handle_read_holding(uint8_t *req, uint8_t *resp, uint16_t *resp_len) { uint16_t start_addr (req[2] 8) | req[3]; uint16_t reg_num (req[4] 8) | req[5]; if (reg_num 0 || reg_num 125) { // 非法数据值返回异常码 0x03 resp[0] MODBUS_SLAVE_ADDR; resp[1] MODBUS_FUNC_READ_HOLDING | 0x80; resp[2] 0x03; *resp_len 3; return; } resp[0] MODBUS_SLAVE_ADDR; resp[1] MODBUS_FUNC_READ_HOLDING; resp[2] reg_num * 2; // 字节数 for (uint16_t i 0; i reg_num; i) { uint16_t val 0; if (start_addr i REG_HOLDING_NUM) { val holding_regs[start_addr i]; } resp[3 i * 2] (val 8) 0xFF; resp[4 i * 2] val 0xFF; } uint16_t crc modbus_crc16(resp, 3 reg_num * 2); resp[3 reg_num * 2] crc 0xFF; resp[4 reg_num * 2] (crc 8) 0xFF; *resp_len 5 reg_num * 2; }这段代码里reg_num 125的判断是 Modbus 协议规定的一次最多读 125 个寄存器超过要返回异常码 0x03。CRC 计算用计算法而不是查表法省 Flash 空间代价是每次多几个微秒对 9600 波特率来说完全无感。响应帧里字节数reg_num * 2是必须的Modbus Poll 靠这个字段判断后面跟了多少数据。3.3 串口参数与 3.5 字符间隔的实现Modbus RTU 对帧间隔有硬性要求帧内字符间隔不能超过 1.5 个字符时间帧间间隔至少 3.5 个字符时间。9600 波特率下1 个字符是 10 位1 起始 8 数据 1 停止约 1.04ms3.5 个字符约 3.65ms。ESP32-S3 的 UART 可以设接收超时用uart_set_rx_timeout设成 4ms 左右配合环形缓冲区就能在超时中断里判断一帧结束。#define MODBUS_UART_NUM UART_NUM_1 #define MODBUS_BAUD_RATE 9600 #define MODBUS_TX_PIN 17 #define MODBUS_RX_PIN 18 #define MODBUS_RTS_PIN 16 // RS485 方向控制 void modbus_uart_init(void) { uart_config_t uart_cfg { .baud_rate MODBUS_BAUD_RATE, .data_bits UART_DATA_8_BITS, .parity UART_PARITY_DISABLE, .stop_bits UART_STOP_BITS_1, .flow_ctrl UART_HW_FLOWCTRL_DISABLE, .source_clk UART_SCLK_DEFAULT, }; ESP_ERROR_CHECK(uart_driver_install(MODBUS_UART_NUM, 256, 256, 0, NULL, 0)); ESP_ERROR_CHECK(uart_param_config(MODBUS_UART_NUM, uart_cfg)); ESP_ERROR_CHECK(uart_set_pin(MODBUS_UART_NUM, MODBUS_TX_PIN, MODBUS_RX_PIN, MODBUS_RTS_PIN, UART_PIN_NO_CHANGE)); // RS485 半双工用 RTS 控制方向 ESP_ERROR_CHECK(uart_set_mode(MODBUS_UART_NUM, UART_MODE_RS485_HALF_DUPLEX)); // 接收超时约 4ms对应 3.5 字符间隔 ESP_ERROR_CHECK(uart_set_rx_timeout(MODBUS_UART_NUM, 4)); }UART_MODE_RS485_HALF_DUPLEX会自动控制 RTS 引脚发送时拉高接收时拉低不需要手动翻转 GPIO。uart_set_rx_timeout设 4ms 是经验值9600 波特率下 3.5 字符约 3.65ms取 4ms 留一点余量。如果波特率改成 192003.5 字符约 1.82ms超时要改成 2ms 左右否则帧会被截断。4. Modbus Poll 联调从超时到数据正确的排查路径4.1 Modbus Poll 连接参数怎么填Modbus Poll 新建连接时几个参数必须和从站一致Mode 选 RTUSlave ID 填从站地址比如 1Function 选 03 Read Holding RegistersAddress 填 0 或 1 取决于软件版本Quantity 填 4。串口参数 9600-8-N-1和代码里一致。如果用的是 USB 转 RS485 模块还要注意 A/B 线有没有接反接反了现象是超时不是数据错误。一个容易翻车的点是 Modbus Poll 的 Address 基准。有些版本默认 1 基准填 1 对应报文地址 0x0000有些版本可以切到 0 基准。我一般先在从站代码里把寄存器地址打印出来确认从站认为的 0x0000 是哪个通道再在 Modbus Poll 里试填 0 和 1看哪个能读到正确数据。4.2 超时问题的三层排查Modbus Poll 报超时按物理层、链路层、应用层三层查。物理层看 A/B 线电压空闲时 A 比 B 高 200mV 左右如果电压差接近 0说明收发器没供电或线接错。链路层看波特率和校验位从站代码里parity设UART_PARITY_DISABLEModbus Poll 里也要选 None如果从站设了偶校验而上位机选无校验帧会被当成错误帧丢弃。应用层看从站地址和功能码从站地址不匹配时从站不会回复Modbus Poll 一直等到超时。我遇到过一种情况从站代码里 UART 接收超时设了 10ms而 Modbus Poll 发完请求后 5ms 就开始等响应结果从站还没判断帧结束上位机已经超时了。把超时改到 4ms 后正常。所以超时时间不是越长越好要匹配波特率和帧长。4.3 数据对不上时的寄存器映射检查如果 Modbus Poll 能读到数据但值不对先确认三件事从站返回的字节序、寄存器地址偏移、ADC 换算系数。Modbus 保持寄存器是大端高字节在前代码里resp[3 i * 2] (val 8) 0xFF就是高字节。如果上位机按小端解析读出来的值会差 256 倍。另一个常见问题是四通道数据在寄存器里顺序错位。比如代码里holding_regs[0]对应 GPIO1但 Modbus Poll 读 40001 读到的是 GPIO2 的值说明地址偏移搞反了。我一般会在从站代码里加一个调试寄存器把通道号和原始值一起返回用 Modbus Poll 读出来对照确认映射关系。5. 避坑与常见问题四通道采集调试中的血泪经验5.1 ADC 读数跳变超过 100mV现象Modbus Poll 里读到的毫伏值在几十到一百多 mV 之间跳用手碰一下 GPIO 线跳得更厉害。原因ADC 输入引脚悬空或走线太长引入了工频干扰和空间噪声。解决在 GPIO 和 GND 之间并一个 0.1uF 电容走线尽量短信号源和从站共地。如果信号源是 4-20mA采样电阻要选低温漂的普通碳膜电阻温漂能到几百 ppm夏天和冬天读数差几十 mV。5.2 Modbus Poll 读到的值一直是 4095现象四个通道都读到 4095换通道也一样。原因ADC 衰减配置成了ADC_ATTEN_DB_0满量程只有 1.1V输入电压超过 1.1V 后 ADC 输出饱和到最大值。解决把衰减改成ADC_ATTEN_DB_12满量程约 3.3V。如果输入信号确实超过 3.3V要在前面加分压电阻不能靠改衰减硬扛。5.3 四通道轮询时 Modbus 响应变慢现象单通道读取时 Modbus Poll 响应很快四通道一起读时偶尔超时。原因adc_read_filtered里每个通道做 16 次采样四通道就是 64 次 ADC 转换加上排序和校准总时间超过 Modbus 主站的超时阈值。解决把采样次数降到 8 次或者用 ADC 连续模式配合 DMA在后台采集Modbus 请求到来时直接取最新值。我一般会把采样次数做成可配置调试时用 16 次量产时用 8 次。5.4 RS485 方向控制导致第一帧丢失现象Modbus Poll 第一次发请求没响应再发一次就正常。原因RS485 收发器的方向切换需要时间UART_MODE_RS485_HALF_DUPLEX虽然自动控制 RTS但发送完成后 RTS 拉低到接收使能之间有微小延迟如果主站发完请求立刻等响应从站可能还没切到接收模式。解决在从站代码里发送响应后加 1ms 延时再切接收或者用带自动方向控制的收发器芯片比如 MAX13487省掉 RTS 引脚。5.5 校准句柄创建失败导致读数为 0现象Modbus Poll 读到的值全是 0但 ADC 原始值打印出来正常。原因adc_cali_create_scheme_curve_fitting返回错误但代码里没检查cali_handle是空指针adc_cali_raw_to_voltage调用失败后voltage_mv保持初始值 0。解决每个ESP_ERROR_CHECK都要保留如果校准方案不支持可以退回adc_cali_create_scheme_line_fitting精度略差但兼容性更好。6. 进阶技巧把四通道数据映射成工程单位并做越限报警四通道 ADC 采集跑通之后下一步通常是把毫伏值换算成实际的物理量比如温度、压力、液位。换算公式取决于传感器但 Modbus 寄存器里放什么值有个实用技巧用两个寄存器拼一个 32 位浮点数或者用一个寄存器放放大 10 倍后的整数值。前者精度高但上位机要按浮点解析后者简单直观适合 PLC 直接读。我一般会在从站代码里加一个换算层把毫伏值根据通道配置映射成工程值再存进保持寄存器。下面是一个示例通道 0 接 0-10V 压力变送器量程 0-1.0MPa分压后满量程对应 3300mV。// 通道配置量程下限、上限、对应毫伏下限、上限 typedef struct { float eng_min; float eng_max; int mv_min; int mv_max; } channel_scale_t; static channel_scale_t ch_scale[4] { {0.0f, 1.0f, 0, 3300}, // 通道 00-1.0MPa {0.0f, 100.0f, 0, 3300}, // 通道 10-100% 液位 {-50.0f, 150.0f, 0, 3300}, // 通道 2-50 到 150 度 {0.0f, 10.0f, 0, 3300}, // 通道 30-10V 电压 }; uint16_t mv_to_eng_x10(int ch, int mv) { float ratio (float)(mv - ch_scale[ch].mv_min) / (float)(ch_scale[ch].mv_max - ch_scale[ch].mv_min); float eng ch_scale[ch].eng_min ratio * (ch_scale[ch].eng_max - ch_scale[ch].eng_min); return (uint16_t)(eng * 10.0f); // 放大 10 倍存整数 }mv_to_eng_x10返回放大 10 倍后的整数比如 0.85MPa 返回 8Modbus Poll 里读到 8 就知道实际是 0.8MPa。这样 PLC 不用做浮点运算直接比较整数就能做越限判断。如果量程是负值比如 -50 到 150 度放大 10 倍后范围是 -500 到 1500用int16_t存Modbus 寄存器按有符号解析。越限报警可以在从站里做也可以在上位机做。从站做的好处是不依赖轮询周期响应快。我一般会在从站里加一个报警寄存器每个通道对应两个位上限报警和下限报警用 0x03 功能码一起读出去。Modbus Poll 里可以设一个表达式当报警位置 1 时高亮显示比盯着数值看省事。调试这个方案时我最大的教训是不要相信“一次就能读对”。ADC 的噪声、Modbus 的地址偏移、RS485 的方向切换每个环节都可能让数据看起来“有值但不对”。我习惯在从站代码里留一个调试模式把原始值、校准值、工程值都映射到不同寄存器用 Modbus Poll 一次性读出来对照确认每一层都对得上再关掉调试寄存器。这个习惯帮我省了很多来回改代码的时间。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑