ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32手写Modbus RTU从机库:协议解析、CRC校验与寄存器映射实战

STM32手写Modbus RTU从机库:协议解析、CRC校验与寄存器映射实战 做嵌入式这几年我有个特别深的感受在工业现场跑过的设备甭管是PLC、变频器、电表还是各类传感器十有七八都会跟Modbus RTU沾点边。原因不复杂Modbus RTU帧结构轻、实现简单、对MCU资源要求极低一块STM32F103这种级别的芯片就能轻松当从机。这篇文章我就从一个完整可跑的工程出发手把手讲清楚怎么用C语言给STM32写一个自用的Modbus RTU从机库。不是拿现成的FreeModbus改改完事而是从协议帧、CRC校验、寄存器映射到串口接收状态机每一步都自己写出来最后附上完整源码和使用说明。适合正在做设备联网、上位机通信或者毕业设计的同学参考照着抄一遍比自己盲调两天效率高得多。1. 动手之前先弄清Modbus RTU从机到底在做什么1.1 一主多从的问答模式Modbus RTU是典型的一主多从半双工通信总线上只有一个主机通常是PLC、上位机、触摸屏从机最多247个地址1-247所有通信由主机发起从机只能被动应答。主机发一帧请求从机解析出地址、功能码、数据和CRC校验地址匹配就执行命令并回复响应帧地址不匹配就静默装死。这个机制有点像老师点名提问点到谁谁回答没点到就闭嘴谁要是乱抢话整个课堂秩序就乱了。实际项目里很多人第一次调Modbus是从“把传感器接到串口上读数”开始的。你发一帧01 03 00 00 00 02 C4 0B设备回一串十六进制你拿着协议文档一个字节一个字节对照感觉好像懂了。但真要自己写一个从机库面对的就是完全相反的过程主机发过来一帧指令你怎么把里面的寄存器地址取出来、怎么往响应帧里填数据、怎么把CRC算对再发回去。这些细节文档里写得都很简略真上手写代码时才会卡住。所以这篇文章我不打算堆一堆协议理论而是直接按照“从机库”这个角度来拆需求。你只需要记住一句话从机库就是一个“串口命令翻译器”主机问什么你就答什么问你寄存器就读寄存器让你写寄存器就写寄存器出错了也要按规矩回一个异常帧。1.2 从机库该做什么、不该做什么写库之前最重要的一步是划清职责边界。我见过不少初学者把Modbus从机库和业务逻辑揉在一起结果换一个项目整个代码全部推翻重来。正确的做法是把库做成“纯协议层”只负责解析、执行、回复不关心寄存器背后的数据是温度、湿度还是电机转速。从机库内部要做的事串口字节接收与帧完整性判断CRC16校验与生成从机地址匹配支持广播地址处理功能码解析分发给对应处理函数寄存器读写操作带地址越界检查生成正常响应帧和异常响应帧从机库不应该做的事不负责RS485收发器的方向切换逻辑这个属于硬件抽象层可以放进来但要独立成函数不负责具体业务计算比如温湿度传感器怎么读取、PID输出怎么算不负责主机端的协议栈那是上位机或者触摸屏的事情这种边界清晰的设计好处在项目复用的时候特别明显。我今天用STM32F103做的这个库明天换到STM32F407只要把串口发送、接收、定时器这三个底层函数替换掉上层协议代码一个字都不用改。1.3 报文帧格式必须烂熟于心Modbus RTU帧格式其实非常简单总共就四段从机地址1字节、功能码1字节、数据区N字节、CRC16校验2字节低字节在前。普通模式一帧报文最长256字节实际常用的也就十几字节。主机请求帧示例读从机1的保持寄存器起始地址0x0000数量2字节偏移内容值说明0从机地址0x01目标从机地址1功能码0x03读保持寄存器2-3起始地址0x00 0x00高字节在前4-5寄存器数量0x00 0x02读2个寄存器6-7CRC160xC4 0x0B低字节在前从机正常响应帧字节偏移内容值说明0从机地址0x01本机地址1功能码0x03与请求一致2字节数0x042个寄存器4字节3-6寄存器数据高字节在前依次输出7-8CRC16计算得到低字节在前常用功能码就那么几个我整理成了一张速查表功能码名称操作对象方向0x01读线圈线圈可读可写0x02读离散输入离散输入只读0x03读保持寄存器保持寄存器可读可写0x04读输入寄存器输入寄存器只读0x05写单个线圈线圈可读可写0x06写单个寄存器保持寄存器可读可写0x0F写多个线圈线圈可读可写0x10写多个寄存器保持寄存器可读可写从机库的核心工作就是把这8个功能码全部实现再加上异常响应。做完这些市面上绝大多数Modbus主站工具都能直接连上你的设备。2. 软件架构设计寄存器模型与帧接收状态机2.1 四种数据对象的寄存器模型Modbus协议把数据分成四类线圈Coil、离散输入Discrete Input、保持寄存器Holding Register、输入寄存器Input Register。前两种是位bit操作后两种是16位寄存器操作。从机库里必须给这四类数据分别建立存储空间和访问接口。最简单的实现方式就是数组映射。比如定义uint8_t coil_array[64]和uint16_t holding_reg_array[128]直接把Modbus地址空间映射到数组下标。这种方式简单直接适合大多数中小型项目。如果寄存器数量很大比如上千个就要考虑用回调函数动态读写寄存器而不是用静态数组硬扛。我推荐一个折中方案库内部维护一组寄存器指针和数量描述实际存储空间由用户工程提供。这样库本身不关心数据从哪里来用户可以把保持寄存器数组直接指向一个结构体变量实现“协议层与业务层解耦”。还记得前面说的“不负责业务逻辑”吗这正是它的具体落地方式。寄存器模型定义可以这样设计typedef struct { uint8_t slave_addr; // 从机地址范围1-247 uint16_t coil_start; // 线圈起始地址 uint16_t coil_num; // 线圈数量 uint8_t *coil_buffer; // 线圈存储指针 uint16_t discrete_start; // 离散输入起始地址 uint16_t discrete_num; // 离散输入数量 uint8_t *discrete_buffer; // 离散输入存储指针 uint16_t holding_start; // 保持寄存器起始地址 uint16_t holding_num; // 保持寄存器数量 uint16_t *holding_buffer; // 保持寄存器存储指针 uint16_t input_start; // 输入寄存器起始地址 uint16_t input_num; // 输入寄存器数量 uint16_t *input_buffer; // 输入寄存器存储指针 } modbus_slave_t;为什么要保存起始地址和数量而不是直接写死从0开始因为Modbus地址空间是1-based偏移量实际项目中可能只占用其中一段。比如你的设备保持寄存器占地址0x0000到0x000F输入寄存器占0x0000到0x0007各自独立编址。有了起始地址和数量库就能精确判断“主机请求的地址是否越界”。2.2 帧接收状态机与3.5字符间隔Modbus RTU是异步串口通信没有专门的帧起始和帧结束标志协议靠“空闲间隔”来区分帧。规范要求帧内两个字节之间的间隔不能超过1.5个字符时间帧与帧之间的间隔至少3.5个字符时间。实际工程中只要接收超时大于3.5个字符时间就可以认为一帧结束了。这个时间跟波特率直接相关。3.5个字符时间 3.5 × 11位1起始位8数据位1停止位无校验 38.5位时间。以9600波特率为例一位约104.17微秒38.5位约4.01毫秒。我列几个常用波特率的3.5字符时间供参考波特率1位时间3.5字符时间建议超时9600104.2us4.01ms5ms1920052.1us2.01ms3ms3840026.0us1.00ms2ms1152008.7us0.33ms1ms实现方式有两种一种是纯定时器轮询串口每收到一个字节就记录当前时间戳主循环里周期性检查“距离最后一个字节是否超过3.5字符时间”超了就处理这一帧。另一种是用STM32串口的IDLE空闲中断收到一帧数据后总线空闲会自动触发中断直接进处理流程。我自己的习惯是用第一种定时器1ms中断配合时间戳计数逻辑直观移植到任何MCU都只需要一个1ms时基。实现大概长这样#define MODBUS_FRAME_TIMEOUT_MS 5 // 根据波特率调整 static uint32_t last_rx_time; static uint8_t rx_buffer[MODBUS_RTU_BUF_SIZE]; static uint16_t rx_len; void Modbus_UART_RxByte(uint8_t byte) { if (rx_len MODBUS_RTU_BUF_SIZE) { rx_buffer[rx_len] byte; } last_rx_time modbus_tick; } void Modbus_1ms_Timer(void) { modbus_tick; } uint8_t Modbus_FrameReady(void) { if (rx_len 0 (modbus_tick - last_rx_time) MODBUS_FRAME_TIMEOUT_MS) { return 1; } return 0; }这里有几个坑必须提前说清楚超时时间不能设得太短否则主机发送过程中稍微有一点点延迟从机就会把半截帧当成完整帧处理解析CRC肯定失败。超时时间也不能太长否则通信响应会变慢尤其是波特率低的情况下连续多个从机轮询时主机容易超时。如果从机同时还要响应广播帧广播帧不需要回复但依然要执行操作这个状态机设计时要留好分支。2.3 CRC16校验的正确姿势Modbus RTU的CRC16校验是这个协议里最容易写错的地方。多项式是0x8005的反射形式0xA001初始值0xFFFF结果低字节在前发送。很多新手在这里栽跟头要么计算出来的CRC总是差一位要么发送高低字节顺序反了导致主机一直报超时。CRC计算有两种常见实现按位计算和查表计算。按位计算代码简洁占用ROM小缺点是每字节要循环8次速度慢一点。查表法速度飞快但需要一张256字节的CRC高字节表加一张256字节的CRC低字节表总共512字节ROM。对STM32来说ROM根本不叫事所以项目里用查表法就行。查表法的核心代码static const uint8_t crc_hi_table[256] { 0x00, 0xC1, 0x81, 0x40, 0x01, 0xC0, 0x80, 0x41, // 完整表可以从附录获取网上资料很多 }; static const uint8_t crc_lo_table[256] { 0x00, 0xC0, 0xC1, 0x01, 0xC3, 0x03, 0x02, 0xC2, // 完整表可以从附录获取 }; uint16_t Modbus_CRC16(const uint8_t *data, uint16_t len) { uint8_t crc_hi 0xFF; uint8_t crc_lo 0xFF; while (len--) { uint8_t idx crc_hi ^ (*data); crc_hi crc_lo ^ crc_hi_table[idx]; crc_lo crc_lo_table[idx]; } return (uint16_t)((uint16_t)crc_hi 8) | crc_lo; }调用的时候注意CRC返回值的高字节是CRC_H低字节是CRC_L。发送帧时先发CRC_L再发CRC_H这个顺序千万别搞反。提示如果你不想手工维护表可以先用按位算法验证逻辑确认无误后再用查表法替换。两种方法算出来的结果必须完全一致。3. 手写核心代码从功能码分发到寄存器读写3.1 对外接口如何设计一个合格的从机库对外接口应该尽量精简让使用者不用关心内部细节。我推荐的接口只有四个void Modbus_Init(modbus_slave_t *ctx); void Modbus_Process(modbus_slave_t *ctx); // 主循环轮询处理完整帧 void Modbus_UART_RxByte(modbus_slave_t *ctx, uint8_t byte); // 串口中断调用 void Modbus_Timer1ms(modbus_slave_t *ctx); // 1ms定时器中断调用使用者只需要在串口中断里调用Modbus_UART_RxByte在定时器中断里调用Modbus_Timer1ms然后在主循环里死磕Modbus_Process。至于什么时候该处理帧、什么时候该回超时、CRC怎么校验全部封装在库内部。3.2 主处理函数的调用流程主处理函数是整个库的心脏我把它拆成五步。第一步检查有没有完整帧。用前面的Modbus_FrameReady()判断没有就返回。第二步长度合法性检查。接收长度至少需要4个字节地址功能码CRC低CRC高超过256字节直接丢弃。第三步CRC校验。把收到的帧去掉最后两个CRC字节重新计算CRC跟帧尾的CRC值比对。不一致就丢弃不回复任何内容。第四步地址匹配。收到帧的地址如果是本机地址正常处理如果是0x00广播地址执行命令但不回复如果都不是直接丢弃。第五步功能码分发。进入switch-case匹配到支持的功能码就调用对应处理函数匹配不到就回异常码0x01非法功能码。核心伪代码如下void Modbus_Process(modbus_slave_t *ctx) { if (!Modbus_FrameReady()) return; uint16_t len rx_len; rx_len 0; // 清空接收缓冲 if (len 4 || len MODBUS_RTU_BUF_SIZE) return; if (Modbus_CRC16(rx_buffer, len - 2) ! (rx_buffer[len-1] 8 | rx_buffer[len-2])) { return; // CRC错误静默丢弃 } uint8_t addr rx_buffer[0]; if (addr ! ctx-slave_addr addr ! 0x00) return; switch (rx_buffer[1]) { case 0x01: Modbus_ReadCoils(ctx, addr); break; case 0x02: Modbus_ReadDiscreteInputs(ctx, addr); break; case 0x03: Modbus_ReadHoldingRegs(ctx, addr); break; case 0x04: Modbus_ReadInputRegs(ctx, addr); break; case 0x05: Modbus_WriteSingleCoil(ctx, addr); break; case 0x06: Modbus_WriteSingleReg(ctx, addr); break; case 0x0F: Modbus_WriteMultiCoils(ctx, addr); break; case 0x10: Modbus_WriteMultiRegs(ctx, addr); break; default: Modbus_SendException(ctx, addr, 0x01); break; } }3.3 读保持寄存器的完整实现以最常用的0x03功能码为例看看一个完整handler怎么写。请求帧数据部分是起始地址2字节和寄存器数量2字节响应帧数据部分由“字节数寄存器数据”组成。void Modbus_ReadHoldingRegs(modbus_slave_t *ctx, uint8_t addr) { uint16_t start_addr (rx_buffer[2] 8) | rx_buffer[3]; uint16_t reg_count (rx_buffer[4] 8) | rx_buffer[5]; // 数量检查0x01到0x7D125个 if (reg_count 1 || reg_count 0x7D) { Modbus_SendException(ctx, addr, 0x03); // 非法数据值 return; } // 地址越界检查 uint16_t map_index start_addr - ctx-holding_start; if (start_addr ctx-holding_start || (map_index reg_count) ctx-holding_num) { Modbus_SendException(ctx, addr, 0x02); // 非法数据地址 return; } uint8_t resp_len 3 reg_count * 2; // 地址功能码字节数数据 tx_buffer[0] addr; tx_buffer[1] 0x03; tx_buffer[2] reg_count * 2; for (uint16_t i 0; i reg_count; i) { tx_buffer[3 i*2] (ctx-holding_buffer[map_index i] 8) 0xFF; tx_buffer[4 i*2] ctx-holding_buffer[map_index i] 0xFF; } uint16_t crc Modbus_CRC16(tx_buffer, resp_len - 2); tx_buffer[resp_len - 2] crc 0xFF; tx_buffer[resp_len - 1] (crc 8) 0xFF; Modbus_UART_Send(tx_buffer, resp_len); }这里有几个细节容易翻车协议规定一次最多读125个保持寄存器也就是250字节数据超过这个值必须回异常码0x03。如果你不做限制主机发个0xFFFF数量过来你的响应帧长度直接溢出缓冲区轻则数据错乱重则跑飞。寄存器数据必须是高字节在前big-endian。STM32本身是小端模式如果直接memcpy16位数组进发送缓冲区字节顺序一定是反的必须手动拆分。起始地址减掉holding_start才能得到数组下标。我见过有人直接拿Modbus地址当下标用寄存器起始地址不是0的时候就全乱套了。3.4 写多寄存器与广播地址的处理写单个寄存器0x06比较简单请求帧里直接带寄存器地址和数值从机执行完把请求帧原样返回。写多个寄存器0x10稍微复杂一点请求帧数据区包含起始地址2字节、寄存器数量2字节、字节数1字节、数据区N字节。执行成功后响应帧只需要返回从机地址、功能码、起始地址、寄存器数量、CRC不需要回数据。广播地址0x00的处理尤其容易忽略。按照Modbus规范地址0表示广播所有从机都要执行命令但是不能回复任何帧。实际项目里广播常用于“同步校时”“统一启停”这类场景。所以在写Modbus_Process的时候我把addr0的分支直接跳过发送只做写操作。注意广播只适用于写命令读命令收到广播地址应当直接忽略。还有一个就是写线圈时的数据编码陷阱。Modbus规定写单个线圈0x05时数据字段0xFF00表示ON0x0000表示OFF其他值都是非法数据值必须回异常码0x03。写多个线圈0x0F时每个线圈占1位1表示ON0表示OFF字节内低位在前。这些规范细节写代码时特别容易忽视建议把协议文档里关于数据编码的表格打印出来贴在工位上。4. STM32工程集成串口、定时器与485方向控制4.1 基于HAL库的串口接收中断在实际项目中我用的是STM32CubeMX生成基础工程然后手动把Modbus库代码加进去。串口配置成115200、8数据位、无校验、1停止位使能接收中断。串口中断服务函数里只需要做一件事把收到的字节交给Modbus库void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { Modbus_UART_RxByte(modbus_ctx, (uint8_t)(huart-Instance-RDR 0xFF)); HAL_UART_Receive_IT(huart, rx_tmp, 1); } }注意HAL库里收到一个字节后要重新调用HAL_UART_Receive_IT才能继续接收下一字节。如果你忘了重新使能串口就只收到第一个字节然后整个通信就卡死了。这是HAL库开发最常见的新手坑。4.2 485方向切换怎么处理RS485是半双工总线发送的时候要拉高DE/RE引脚比如RE拉低、DE拉高发送完必须立刻恢复接收状态。这个切换时序很关键尤其在高波特率下切换晚了会造成帧尾部被截断或者把回显数据发到总线上干扰其他设备。我的做法是发送函数里直接控制GPIO不用额外加延时void Modbus_UART_Send(uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_SET); // 拉高进入发送模式 HAL_UART_Transmit(huart1, data, len, 100); while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET); // 等待发送完成 HAL_GPIO_WritePin(RS485_DE_GPIO_Port, RS485_DE_Pin, GPIO_PIN_RESET); // 拉低恢复接收 }有人习惯用HAL_UART_Transmit的返回值和超时参数来判断发送完成实际上HAL_UART_Transmit在阻塞模式下数据写入DR寄存器就算完成最后一个字节可能还没从移位寄存器发完这时候切回接收模式最后一个字节就会被砍掉一半。所以必须等待TCTransmission Complete标志位置位才表示整个帧已经从引脚上发完了。4.3 定时器时基与超时判定我用TIM6做1ms时基在中断里调用Modbus_Timer1ms(modbus_ctx)给库内部提供一个递增的tick计数。前面提到的Modbus_FrameReady()就是靠这个tick来算“距离最后收到字节的时间”。void TIM6_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim6, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); Modbus_Timer1ms(modbus_ctx); } }定时器中断里不要做耗时操作比如CRC计算、寄存器读写这些全部丢到主循环的Modbus_Process里。原因很简单中断服务函数越短越好否则会阻塞其他中断尤其是串口接收中断一旦错过一个字节整帧就废了。4.4 主循环结构与代码组织主循环代码非常简洁读Modbus帧、处理、剩下的时间全给业务逻辑int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM6_Init(); Modbus_Init(modbus_ctx); HAL_UART_Receive_IT(huart1, rx_tmp, 1); HAL_TIM_Base_Start_IT(htim6); while (1) { Modbus_Process(modbus_ctx); // 你的业务代码比如读传感器、刷LCD、跑控制算法 Handle_User_App(); } }这种结构的好处是Modbus处理完全非阻塞即使主机一直发命令也不会把CPU占满。实测在72MHz主频下处理一帧最短的读保持寄存器请求大概只需要几十微秒对主循环几乎无感。5. 实测验证与常见问题排查5.1 用Modbus Poll进行联调测试写完代码我习惯先用现成的Modbus调试工具做主机来验证从机库推荐Modbus Poll或者串口助手配合手动发帧。Modbus Poll功能全能自动计算CRC还能连续轮询非常适合长时间稳定性测试。把STM32的USART1通过USB转485接到电脑上打开Modbus Poll配置好串口号和波特率从站地址填1功能码选03起始地址填0数量填2点击连接。如果一切正常能看到寄存器数值实时刷新。然后再测试写操作切换功能码到06和10写入一个值再读回来验证数据是否完整。我实际测试时让Modbus Poll以100ms周期连续轮询一小时稳得很。再把波特率降到9600用示波器抓波形看帧间隔发现比在115200下更容易出现“一帧分成两段”的现象原因就是3.5字符时间变长了我的超时设置需要同步调整。所以强烈建议你根据实际波特率把超时时间设置正确而不是随便填个1ms。5.2 常见问题速查表下面这个表格是我调试Modbus从机库时遇到的典型问题每条都踩过坑整理出来给你参考现象可能原因排查方向主机发请求后从机无任何响应CRC校验错误用串口助手抓帧确认主机发送的CRC是否正确主机发请求后从机无任何响应从机地址不匹配检查slave_addr配置确认广播帧是否被错误忽略主机发请求后从机无任何响应485方向引脚没切换示波器看DE引脚电平发送期间是否拉高从机响应乱码波特率不匹配核对串口配置确认两端波特率完全一致响应帧少一个字节或多一个字节发送完成标志位没等待确认是否等待TC标志位再切485方向偶发帧被拆成两段处理超时时间设置太短按3.5字符时间计算超时低波特率要适当放宽读寄存器返回异常码0x02寄存器地址越界检查holding_start和holding_num配置写寄存器后主机显示超时响应帧数据区带了多余字节0x10响应帧不需要回数据区只能返回地址功能码起始地址数量广播写命令被执行了但数据不对广播帧没走写处理分支确认地址0x00也执行写操作且发送前判断不回复5.3 两个排查利器串口助手和逻辑分析仪排查Modbus问题工具用好能省一半时间。我最常用的组合是USB转485串口助手抓总线数据 逻辑分析仪看时序波形。串口助手的用法很简单手动输入十六进制帧比如01 03 00 00 00 02 C4 0B发送后观察从机回复。如果从机什么都没回先用串口助手确认自己发出去的帧对不对再用示波器或者逻辑分析仪看串口TXD、RXD引脚上有没有波形。很多PCB布线问题、485芯片故障在波形上看得一清二楚。逻辑分析仪主要看帧间隔和RS485方向切换时序。Modbus RTU的帧间隔是全协议最核心的物理特征抓一次波形量一下帧内字节间隔和帧间间隔对比协议规范立刻就知道问题出在哪里。测485方向切换时序时把DE引脚和A/B差分信号一起抓能直观看到拉高DE之后多长时间数据才发出去发送结束到拉低DE之间有没有留够余量。6. 这个库后续还能怎么扩展写完一个基础从机库你会发现很多功能都可以在这个骨架上继续加。我自己在项目里陆续扩展过三个方向分享出来给你参考。第一个是自定义功能码。Modbus协议为厂家保留了65到72号功能码0x41到0x48可以自定义业务命令。比如我做过一个设备用0x41功能码做“读取设备固件版本”用0x42功能码做“远程复位”。实现方式就是在switch-case里增加分支完全复用现有的帧校验和发送框架非常方便。第二个是Modbus RTU转Modbus TCP网关。这个本质上就是把从机库前面套一层TCP解析收到网络数据包后转换成同样的寄存器操作。基础库的寄存器模型可以复用只把“串口收发”换成“TCP收发”再做一下RTU和TCP的帧格式转换一个网关设备就出来了。第三个是多从机透传或者路由功能。有的项目需要用一个STM32读取多个RS485总线上的从机设备这时候就要在从机库旁边写一个主机库或者集成一个FreeModbus主站库。两个库共用串口解析的能力但方向相反一个往外发命令一个收命令并应答。数据经过设备中转需要处理一下寄存器映射和地址转换。我现在用下来的感受是这套代码最大的价值不在于功能多强而在于它足够简单每一行逻辑都能看懂出了问题能直接定位。FreeModbus功能齐全但代码量太大入门阶段容易看晕。自己手写一个最小实现调试通过后再去研究FreeModbus的精巧设计进步会快得多。如果你在新项目里需要快速支持Modbus RTU从机功能欢迎直接参考这个实现基本上改一改寄存器数量和串口配置就能跑起来。
返回列表