☰
ESP32-S3四通道ADC采集与Modbus RTU从站实现及Modbus Poll调试指南
2026/10/22 16:19:48 网站建设 项目流程

简介:本资源面向物联网开发初学者与嵌入式工程师,提供基于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 寄存器地址和实际报文里的地址偏移是 1,Modbus 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.04ms,3.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 留一点余量。如果波特率改成 19200,3.5 字符约 1.82ms,超时要改成 2ms 左右,否则帧会被截断。

4. Modbus Poll 联调:从超时到数据正确的排查路径

4.1 Modbus Poll 连接参数怎么填

Modbus Poll 新建连接时,几个参数必须和从站一致:Mode 选 RTU,Slave ID 填从站地址(比如 1),Function 选 03 Read Holding Registers,Address 填 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_DISABLE,Modbus 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}, // 通道 0:0-1.0MPa {0.0f, 100.0f, 0, 3300}, // 通道 1:0-100% 液位 {-50.0f, 150.0f, 0, 3300}, // 通道 2:-50 到 150 度 {0.0f, 10.0f, 0, 3300}, // 通道 3:0-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 返回 8,Modbus Poll 里读到 8 就知道实际是 0.8MPa。这样 PLC 不用做浮点运算,直接比较整数就能做越限判断。如果量程是负值,比如 -50 到 150 度,放大 10 倍后范围是 -500 到 1500,用int16_t存,Modbus 寄存器按有符号解析。

越限报警可以在从站里做,也可以在上位机做。从站做的好处是不依赖轮询周期,响应快。我一般会在从站里加一个报警寄存器,每个通道对应两个位:上限报警和下限报警,用 0x03 功能码一起读出去。Modbus Poll 里可以设一个表达式,当报警位置 1 时高亮显示,比盯着数值看省事。

调试这个方案时,我最大的教训是不要相信“一次就能读对”。ADC 的噪声、Modbus 的地址偏移、RS485 的方向切换,每个环节都可能让数据看起来“有值但不对”。我习惯在从站代码里留一个调试模式,把原始值、校准值、工程值都映射到不同寄存器,用 Modbus Poll 一次性读出来对照,确认每一层都对得上,再关掉调试寄存器。这个习惯帮我省了很多来回改代码的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询