嵌入式调试笔记
一、MAX13487芯片产生数据回环问题
二、Keil在线调试过程中遇到的问题
三、使用VScdoe开发STM32单片机
四、VANTPEK可调电源接负载电压被拉低
五、用CD74HC4067扩展16路ADC采集及两个调试坑
六、4档旋转开关省IO采集,与Modbus中float的拆分还原
七、MODBUS协议详解与调试实战
文章目录
- 嵌入式调试笔记
- 前言
- 一、协议基础
- 1.主从架构:
- 2.三种传输模式:
- 3.RTU帧结构与静默间隔:
- 4.从机地址与广播规则:
- 二、字节序(大小端)
- 1.大端与小端:
- 2.怎么区分:
- 3.MODBUS 的字节序约定:
- 三、功能码详解
- 1.常用功能码总表:
- 2.寄存器地址编号与报文偏移:
- 3.功能码 01:读线圈
- 4.功能码 02:读离散输入
- 5.功能码 03:读保持寄存器
- 6.功能码 04:读输入寄存器
- 7.功能码 05:写单个线圈
- 8.功能码 06:写单个寄存器
- 9.功能码 0F:写多个线圈
- 10.功能码 10:写多个寄存器
- 11.异常响应:
- 四、CRC-16/MODBUS 校验
- 1.参数:
- 2.计算步骤(逐位法):
- 3.查表法代码(可直接用):
- 4.验证数据:
- 五、调试工具:Modbus Poll / Modbus Slave
- 1.Modbus Poll(主机):
- 2.Modbus Slave(从机):
- 3.对测流程与常见错误排查:
- 六、经验小结
- 总结
前言
MODBUS 是工业现场用得最多的串行通信协议,做嵌入式离不开它。本文把协议要点和调试工具用法整理成一份速查手册,开发时直接翻这一篇。
一、协议基础
1.主从架构:
MODBUS 是一主多从的总线式协议:主机(Master)发起所有请求,从机(Slave)被动应答;从机之间不直接通信,也没有从机主动上报。
- 主机:PC 上位机、PLC、或者 MCU;
- 从机:传感器、仪表、驱动器等设备,每台一个地址;
- 轮询机制:主机按地址逐个发请求,从机收到与自己地址匹配的帧才响应。
注意:广播帧(地址 0)所有从机都要执行,但从机不应答,所以广播只能用于写操作,读操作不能用广播。
2.三种传输模式:
| 模式 | 载体 | 数据格式 | 校验 | 特点 |
|---|---|---|---|---|
| RTU | RS-232/485 | 二进制 | CRC16 | 帧紧凑、效率高,最常用,本文重点 |
| ASCII | RS-232/485 | 十六进制字符 | LRC | 帧长翻倍、可肉眼读,老设备才用 |
| TCP | 以太网 | 二进制 | 无(TCP 自带) | 走 502 端口,带 MBAP 头,无 CRC |
本文默认 RTU 模式。RTU 模式下波特率、校验位(常用 8N1)两边必须完全一致,否则必现帧错。
3.RTU帧结构与静默间隔:
RTU 帧结构(固定 4 段):
| 字段 | 字节数 | 说明 |
|---|---|---|
| 从机地址 | 1 | 目标从机地址 1~247,0 为广播 |
| 功能码 | 1 | 要执行的操作(见第三章) |
| 数据域 | N | 随功能码而定,长度可变 |
| CRC16 | 2 | 校验值,低字节在前发送 |
静默间隔(帧定界靠时间,不是靠帧头帧尾):
- 帧与帧之间:静默 ≥3.5 个字符时间(如 9600bps、8N1 约 4ms);
- 帧内相邻字符间隔:≤1.5 个字符时间,超了从机就认为帧结束(且不完整,丢弃)。
这就是为什么 STM32 上做 RTU 从机必须用定时器量帧间隔——收到 3.5 字符静默才说明一帧结束,靠判断字节空闲是处理不了的。
4.从机地址与广播规则:
| 地址 | 用途 |
|---|---|
| 1~247 | 有效从机地址,同一条总线上不能重复 |
| 0 | 广播:所有从机执行、不应答,只用于写 |
| 248~255 | 保留,不要使用 |
RS-485 多从机共挂一条总线时,地址重复是最隐蔽的坑:两个从机都应答,主机收到的帧就乱了。
二、字节序(大小端)
1.大端与小端:
- 大端(Big-Endian):高字节在前。先发/先存的是高位字节;
- 小端(Little-Endian):低字节在前。先发/先存的是低位字节。
2.怎么区分:
拿 16 位数0x1234举例:大端发12 34,小端发34 12。串口/网络协议里看到 16 位数据"高位在前",就是大端。
3.MODBUS 的字节序约定:
| 对象 | 字节序 | 说明 |
|---|---|---|
| 寄存器内 16 位数据 | 大端 | 如0x1234在报文里是12 34 |
| 32 位数据(float/int32) | 两个字 | 字序按 ABCD/CDAB 约定(见第 6 篇),字节再按大端 |
| CRC16 | 低字节在前 | CRC 计算值 0xD7B5,帧里发B5 D7 |
联调最容易栽在 32 位数据:float 拆两个寄存器,字序是 ABCD 还是 CDAB 两边不先说好,数值就是天文数字。用 12.5f(=0x41480000)当测试探针能快速定位(详见第 6 篇)。
三、功能码详解
1.常用功能码总表:
| 功能码 | 名称 | 读/写 | 操作对象 | 最大数量 |
|---|---|---|---|---|
| 01 (0x01) | 读线圈 | 读 | 线圈(输出) | 2000 位 |
| 02 (0x02) | 读离散输入 | 读 | 离散输入 | 2000 位 |
| 03 (0x03) | 读保持寄存器 | 读 | 保持寄存器(可读可写) | 125 个 |
| 04 (0x04) | 读输入寄存器 | 读 | 输入寄存器(只读) | 125 个 |
| 05 (0x05) | 写单个线圈 | 写 | 线圈 | 1 位 |
| 06 (0x06) | 写单个寄存器 | 写 | 保持寄存器 | 1 个 |
| 0F (15) | 写多个线圈 | 写 | 线圈 | 1968 位 |
| 10 (16) | 写多个寄存器 | 写 | 保持寄存器 | 123 个 |
2.寄存器地址编号与报文偏移:
这是新手最容易懵的地方。报文里的偏移地址从 0x0000 起,而很多上位机/PLC 手册用"数据区 + 1 基编号":
| 数据区 | 用户编号(1基) | 报文偏移 | 说明 |
|---|---|---|---|
| 线圈 | 00001 | 0x0000 | 01/05/0F 操作 |
| 离散输入 | 10001 | 0x0000 | 02 操作 |
| 输入寄存器 | 30001 | 0x0000 | 04 操作 |
| 保持寄存器 | 40001 | 0x0000 | 03/06/10 操作 |
对应关系一句话:报文偏移 = 用户编号 - 1。例如保持寄存器40002→ 报文偏移0x0001。部分上位机工具直接填 0 基偏移,就无需换算;以设备手册为准,别想当然。
3.功能码 01:读线圈
请求帧(主机→从机):
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 目标从机 | 0x01 |
| 功能码 | 1 | 0x01 | 0x01 |
| 起始线圈地址 | 2 | 偏移,高字节在前 | 0x0013 |
| 线圈数量 | 2 | 1~2000 | 0x0025 |
| CRC16 | 2 | 低字节在前 | 0x0C 0x14 |
响应帧(从机→主机):
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x01 | |
| 字节数 | 1 | 数量/8 向上取整 | 0x05 |
| 线圈数据 | N | 每 8 个线圈打包 1 字节,位 0 对应起始线圈 | 0xCD 0x6B 0xB2 0x0E 0x1B |
| CRC16 | 2 | 0x44 0xEA |
完整报文:
请求:01 01 00 13 00 25 0C 14 响应:01 01 05 CD 6B B2 0E 1B 44 EA线圈按"位"打包:第一个数据字节的最低位(bit0)是起始线圈,不是最高位。解析时别倒序。
应用场景:回读继电器、接触器、固态继电器等输出点(DO)的真实状态——比如控制柜里各继电器是否真的吸合、阀门开到位/关到位反馈,上位机做"下发后回读确认"。
4.功能码 02:读离散输入
格式与 01 完全一致,只把功能码换成 0x02、操作对象换成只读的离散输入:
请求:01 02 00 C4 00 16 B8 39 响应:01 02 03 AC DB 35 22 88应用场景:读取光电传感器、行程开关、按钮、门磁、限位开关这类开关量输入(DI)的状态,一个位代表一路输入。批量读 16 路传感器只要一条请求,按位解析即可。
5.功能码 03:读保持寄存器
请求帧:
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x03 | |
| 起始地址 | 2 | 寄存器偏移,高字节在前 | 0x006B |
| 寄存器数量 | 2 | 1~125 | 0x0002 |
| CRC16 | 2 | 0xB5 0xD7 | |
| 响应帧: | |||
| 字段 | 字节数 | 说明 | 示例值 |
| ----- | — | ------------ | ------------- |
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x03 | |
| 字节数 | 1 | 数量×2 | 0x04 |
| 寄存器数据 | N×2 | 每寄存器 2 字节、大端 | 0x000A 0x000B |
| CRC16 | 2 | 0x9B 0xF6 | |
| 完整报文: |
请求:01 03 00 6B 00 02 B5 D7 响应:01 03 04 00 0A 00 0B 9B F6含义:读从机 1 偏移 0x006B(=40001+107=40108)起的 2 个保持寄存器,得到 10 和 11。
应用场景:读取设备的参数与设定值(可读可写区)——温控器目标温度、变频器给定频率、报警阈值;不少设备也把运行累计值(总运行时间、总电量)放在这里。
6.功能码 04:读输入寄存器
格式与 03 相同,功能码换 0x04、对象换成只读输入寄存器:
请求:01 04 00 08 00 01 B0 08 响应:01 04 02 00 1E 39 38应用场景:读取实时测量值(只读区)——温度、压力、流量、电压电流等连续量,仪表每秒更新的实测数据都在这里,和可读可写的保持寄存器分开,防止上位机误写测量值。
7.功能码 05:写单个线圈
请求帧:
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x05 | |
| 线圈地址 | 2 | 偏移 | 0x00AC |
| 写入值 | 2 | 0xFF00=ON,0x0000=OFF,其它值非法 | 0xFF00 |
| CRC16 | 2 | 0x4C 0x1B |
响应帧原样回显请求帧(从机收到什么回什么),这是 05/06 与其它功能码最大的区别:
请求:01 05 00 AC FF 00 4C 1B // 写线圈 ON 响应:01 05 00 AC FF 00 4C 1B // 原样回显写线圈的值固定
FF 00表示 ON,写01 00会被从机判为非法数据值(异常码 03)。这是常见低级错误。
应用场景:单点启停控制——继电器开/关、电磁阀开/关、设备启停、故障复位(先写 OFF 再写 ON)。注意:写线圈不像读线圈能确认实际状态,关键回路建议再读回确认。
8.功能码 06:写单个寄存器
请求帧:
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x06 | |
| 寄存器地址 | 2 | 偏移 | 0x0001 |
| 寄存器值 | 2 | 大端 | 0x0003 |
| CRC16 | 2 | 0x98 0x0B |
响应同样原样回显:
请求:01 06 00 01 00 03 98 0B 响应:01 06 00 01 00 03 98 0B应用场景:修改单个参数——设定目标温度、给定电机转速、写入校零/标定系数。一次只改一个;要改多个参数用 10 功能码更高效。
9.功能码 0F:写多个线圈
请求帧:
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x0F | |
| 起始线圈地址 | 2 | 偏移 | 0x0013 |
| 线圈数量 | 2 | 1~1968 | 0x000A |
| 字节数 | 1 | 数量/8 向上取整 | 0x02 |
| 线圈数据 | N | 位打包,bit0=起始线圈 | 0xCD 0x01 |
| CRC16 | 2 | 0x72 0xCB |
响应帧(不含数据,只回起始地址和数量):
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x0F | |
| 起始线圈地址 | 2 | 0x0013 | |
| 线圈数量 | 2 | 0x000A | |
| CRC16 | 2 | 0x24 0x09 |
完整报文:
请求:01 0F 00 13 00 0A 02 CD 01 72 CB 响应:01 0F 00 13 00 0A 24 09应用场景:批量控制输出——多路继电器组/阀组同时动作、多路报警输出一次性置位、LED/指示灯组整体刷新,一条帧搞定,比逐路发 05 快得多。
10.功能码 10:写多个寄存器
请求帧:
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 0x10 | |
| 起始地址 | 2 | 偏移 | 0x0001 |
| 寄存器数量 | 2 | 1~123 | 0x0002 |
| 字节数 | 1 | 数量×2 | 0x04 |
| 寄存器数据 | N×2 | 大端 | 0x000A 0x0102 |
| CRC16 | 2 | 0x92 0x30 |
响应帧(同 0F,只回地址和数量):
请求:01 10 00 01 00 02 04 00 0A 01 02 92 30 响应:01 10 00 01 00 02 10 08写多个寄存器时,字节数字段必须等于 数量×2,填错从机直接回异常码 03。
应用场景:批量下发参数——整组设定值一次性下载(如 PID 三个参数一条帧写完)、曲线/配方数据、标定表;比 06 逐条写帧数少,参数间不会出现"改了一半"的中间态。
11.异常响应:
从机收到请求但无法执行时,回异常帧,功能码最高位置 1(原功能码 + 0x80):
| 字段 | 字节数 | 说明 | 示例值 |
|---|---|---|---|
| 从机地址 | 1 | 0x01 | |
| 功能码 | 1 | 原功能码 | 0x80 → 0x83 |
| 异常码 | 1 | 见下表 | 0x02 |
| CRC16 | 2 | 0xC0 0xF1 |
请求:01 03 00 6B 00 02 B5 D7 // 读保持寄存器 响应:01 83 02 C0 F1 // 异常:功能码0x83=03|0x80,异常码02=非法数据地址常见异常码:
| 异常码 | 含义 | 常见触发场景 |
|---|---|---|
| 01 | 非法功能码 | 从机不支持该功能码 |
| 02 | 非法数据地址 | 寄存器地址或数量超出从机范围 |
| 03 | 非法数据值 | 数据内容不合法(如 05 写值不是 FF00/0000) |
| 04 | 从机设备故障 | 从机内部错误,无法处理 |
| 06 | 从机忙 | 从机处理上一帧中,稍后重试 |
收到异常响应先看异常码,比瞎猜快得多:超范围多半是 02,参数错多半是 03。
四、CRC-16/MODBUS 校验
1.参数:
CRC-16/MODBUS 是参数固定的 CRC,与通用 CRC-16/CCITT 不是一回事,别混用:
| 参数 | 值 |
|---|---|
| 宽度 | 16 位 |
| 多项式(Poly) | 0x8005(计算用反射值 0xA001) |
| 初值 | 0xFFFF |
| 输入反转 / 输出反转 | 都是(refin/refout = true) |
| 输出异或值 | 0x0000 |
2.计算步骤(逐位法):
1> CRC 寄存器初始化为 0xFFFF;
2> 取一字节数据与 CRC 低 8 位异或;
3> 右移 1 位,若移出的位为 1,则与 0xA001 异或;
4> 重复 8 次(一个字节处理完);
5> 处理完所有字节后,低字节在前追加到帧尾。
3.查表法代码(可直接用):
// 上电初始化一次:生成 256 项查表uint16_tcrc16_table[256];voidCRC_Table_Init(void){for(uint16_ti=0;i<256;i++){uint16_tcrc=i;for(intb=0;b<8;b++)crc=(crc&1)?(crc>>1)^0xA001:(crc>>1);crc16_table[i]=crc;}}// 查表计算,快,适合从机每帧都要算的场景uint16_tModbus_CRC16(constuint8_t*data,uint16_tlen){uint16_tcrc=0xFFFF;for(uint16_ti=0;i<len;i++)crc=(crc>>8)^crc16_table[(crc^data[i])&0xFF];returncrc;}// 使用:帧尾先发低字节,再发高字节uint16_tcrc=Modbus_CRC16(frame,len);tx[len++]=crc&0xFF;// 低字节在前tx[len++]=crc>>8;// 高字节在后4.验证数据:
- 标准验证串
"123456789"(ASCII)计算得0x4B37,这是 CRC-16/MODBUS 公认测试值,代码对不对用它一验便知; - 完整帧验证:
01 03 00 6B 00 02的 CRC = 0xD7B5,帧尾发B5 D7,与第三章示例一致。
收到帧先算 CRC 再处理:CRC 不对直接丢弃整帧,这是从机防误动作的第一道防线。
五、调试工具:Modbus Poll / Modbus Slave
调试利器是 Witte Software 的两件套:Modbus Poll(当主机)+Modbus Slave(当从机),Windows 免费版够用。
1.Modbus Poll(主机):
连接配置:菜单Connection → Connect,选串口和参数,必须与从机一致:
| 参数 | 常见值 | 说明 |
|---|---|---|
| COM Port | 实际串口号 | 设备管理器里查 |
| Baud Rate | 9600 / 115200 | 与从机一致 |
| Data Bits | 8 | 常用 8N1 |
| Parity / Stop | None / 1 | 两边一致,从机带校验则改 |
参数填写:菜单Setup → Read/Write Definition,填从机要请求的内容:
| 参数 | 说明 | 示例 |
|---|---|---|
| Slave ID | 从机地址 | 1 |
| Function | 功能码 | 03 Read Holding Registers |
| Address | 寄存器偏移(0 基) | 0 |
| Quantity | 数量 | 10 |
| Polling interval | 轮询周期 ms | 1000 |
收发报文监视:菜单Display → Communication,勾选显示后能看到 HEX 原文,逐字节和第三章的报文对,很快能定位问题。
2.Modbus Slave(从机):
从机定义:菜单Setup → Slave Definition,设 Slave ID、功能码(如 03)、地址、数量,界面里出现一个寄存器表,双击单元格可以手动改寄存器值,主机再读就能看到新值——这就是最方便的联调闭环。
连接:同样Connection → Connect选串口。Modbus Poll 和 Modbus Slave 分别占两个 COM(USB 转 485 模块各一个,A/B 对接)。
没有真实从机时,用 Modbus Slave 模拟一个,先把主机流程跑通再换真机,能省大量现场排查时间。
3.对测流程与常见错误排查:
标准对测步骤:
1> Modbus Slave 设 Slave ID=1、Function=03、Address=0、Quantity=10;
2> Modbus Poll 设 Slave ID=1、Function=03、Address=0、Quantity=10;
3> 两个工具都 Connect,串口参数一致;
4> Slave 里改一个寄存器的值,Poll 里应能看到同步变化。
常见错误排查表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| Connection timed out(超时) | 地址错 / 接线错 / 从机没上电 / 波特率不一致 | 查地址、A/B 接线、电源、串口参数 |
| 异常码 02 非法数据地址 | 寄存器地址或数量超出从机范围 | 核对 Address + Quantity |
| 异常码 03 非法数据值 | 写入值不合法 | 05 写线圈用 FF00/0000,字节数别填错 |
| 一直帧错 / CRC 错 | 校验位或停止位不一致 | 两端统一 8N1 |
| Poll 表格整列红色 ERR | 见 Communication 窗口的具体报文 | 看报文内容,别只看状态 |
六、经验小结
1> MODBUS 就是"地址 + 功能码 + 数据 + CRC"的框架,功能码决定数据域怎么拼怎么拆,一张表全搞定;
2>帧定界靠时间(3.5 字符静默)不靠帧头,STM32 从机必须用定时器量帧间隔——下一篇就是从 UART 空闲中断 + 定时器讲起的;
3> 字节序三件事:寄存器大端、32 位字序 ABCD/CDAB 先说好、CRC 低字节在前,联调前对齐能少吵半天;
4> 从机收到帧先过三道关:CRC 校验 → 地址匹配(含广播)→ 功能码分发,不合规就回异常码,这是从机代码的标准骨架;
5> 先 Modbus Poll + Slave 对测跑通协议,再接真机,排查效率翻倍。
总结
MODBUS 协议本身不复杂:主从架构 + 时间定界帧 + 大端数据 + 8 个常用功能码 + CRC16。难的是细节——偏移地址对应关系、位打包顺序、CRC 低字节在前、32 位字序,任何一个记错都会让你在联调现场怀疑人生。
本文把报文格式、CRC、工具用法都整理成表,开发时直接照抄即可。
如有表述错误、介绍模糊的地方,欢迎交流指正。