ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

基于STM32F103的MODBUS RTU六路继电器模块开发实战

基于STM32F103的MODBUS RTU六路继电器模块开发实战 简介基于STM32F103的串行Modbus RTU六通道继电器切换方案涵盖完整单片机程序与硬件设计文件面向希望快速搭建继电器控制模块或了解工业总线通信的嵌入式开发者。资源包共二百四十六个文件大小七点二四兆字节主要包含C语言源码与头文件、Keil工程配置、HEX烧录文件、Cadence原理图和PCB设计从程序逻辑到硬件布局均有覆盖并保留编译中间文件便于重新构建。方案已在实际项目中稳定运行采用STM32F103作为主控通过USB转TTL接口连接上位机使用Modbus RTU协议控制六路继电器通断原理图与PCB使用Cadence设计单片机工程基于Keil平台整体可直接烧录使用。目前已有八百七十五人学习或下载适合需要参照完整实战项目进行二次开发或直接量产的技术人员。1. 硬件选型与系统构成这就是个带协议的总线开关先聊清楚项目本质。STM32F103 串口 MODBUS RTU 6路继电器说白了就是做一个上位机可以通过两根线RS485 A/B远程控制的六路开关组。工控现场管这东西叫IO扩展模块或者继电器输出模块最常见的应用场景就是PLC远程控制水泵、风机、灯光或者和触摸屏、组态软件做联动。选STM32F103做这个项目有几个非常实际的理由。首先是成本——F103C8T6的板子几块钱到十几块钱一片继电器模块六路的也就二十上下整套物料成本可以压在五十元以内搞工程报价有竞争力。其次是生态成熟——标准外设库StdPeriph_Lib V3.5和HAL库的资料铺天盖地中文参考手册随便下遇到问题搜一搜基本都有答案。最后是资源够用——USART1做MODBUS通信一个TIM做超时计时几个GPIO控继电器Flash里存参数这活儿用F103的C8T6都绰绰有余根本不需要上F407甚至F429。硬件连接上给大家一个我验证过的接线参考PA9USART1_TX、PA10USART1_RX接MAX3485或SP3485芯片转RS485电平A/B线接出去PB0~PB5接六路继电器模块的IN1~IN6继电器的VCC和GND从板外单独供电电源别跟MCU混在一起PA1接一个LED作为通信指示灯收到有效帧就闪一下调试时候特别有用BOOT0接GND正常启动SWD口预留出来方便下程序这里提醒一句继电器线圈反峰电压是硬件设计里最容易被忽略的坑。我用的是带光耦隔离的继电器模块模块上一般都自带了续流二极管和ULN2003/2803驱动问题不大。但如果你自己画板子继电器线圈两端一定要并续流二极管1N4007就行不然断电瞬间的高压反峰轻则干扰串口重则打坏MCU引脚。另外MCU的电源和继电器电源务必要分开共地就行。还有一点很多人会忽略RS485的A/B线终端电阻。如果通信距离超过几十米或者线上挂着多个设备终端电阻不匹配会直接导致波形反射、数据错乱。短距离测试可以不加120Ω终端电阻但现场使用建议在总线两端各并联一个120Ω电阻。2. MODBUS RTU帧结构拆解主从问答和CRC16是躲不开的两座山MODBUS RTU的实质是一问一答的主从协议。只有主机PLC、触摸屏、PC调试软件能主动发起请求从机我们的STM32板子只能被动响应。每帧数据的结构非常固定地址码1字节这条命令是发给谁的取值范围1~247地址0是广播地址功能码1字节要让从机干什么比如03读寄存器、06写单个寄存器、0F写多个线圈数据区N字节具体要读/写的寄存器地址、数量、值CRC16校验2字节低字节在前高字节在后帧与帧之间需要至少有3.5个字符时间的静默间隔接收方靠这个间隔来判断一帧数据结束了。还是以写单个线圈功能码0x05为例让6路继电器全部吸合的真实报文长这样发送01 05 00 00 FF 00 8C 3A含义拆开来01从机地址是105功能码是05写单个线圈00 00线圈地址是0对应第1路继电器FF 00值是ON如果是00 00就是OFF8C 3A前面几个字节算出来的CRC16校验码从机收到正确的报文之后会原封不动地把这条帧回给主机表示我收到了并且已经执行。如果发生了错误从机返回的帧则是地址码 0x85功能码最高位置1 异常码 CRC异常码能告诉主机具体哪里出了问题01功能码不支持02寄存器地址越界03数据值不合法04从机故障CRC16是MODBUS RTU的第二个坎。它能发现传输过程中的绝大部分错误实现方式一般有两种——查表法和实时计算法。查表法速度快但是要占用256个字节的Flash和RAM实时计算法省空间但速度稍慢。对于115200波特率来说一个字节大约87微秒而F103跑72MHz时纯软件实时算CRC16用不了几微秒完全无恙所以我这里用实时计算的方式省点Flashuint16_t MODBUS_CRC16(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; for (uint16_t i 0; i len; i) { crc ^ data[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc crc 1; } } return crc; }注意MODBUS是低字节先发所以发送CRC时要先发送低8位再发送高8位这个顺序搞反了通信会直接失败。我调试的时候在这个坑里栽过一次排查了半天最后发现是字节序问题。发送报文举例实际第一路的ON帧01 05 00 00 FF 00 8C 3A 发送CRC低字节在前... 3A 8C3. 串口接收状态机用空闲中断判断一帧数据结束MCU怎么判断一帧MODBUS报文结束了两种主流方案一是接收空闲中断IDLE二是超时定时器判断。我推荐用空闲中断这是STM32的一个硬件机制——当串口接收线上出现空闲没有新数据达到一个字节时间时硬件自动产生IDLE中断。它的好处是不用额外开定时器代码简单而且判断准确。3.1 USART1初始化与IDLE中断配置用标准外设库写接收部分需要开两个中断接收数据寄存器非空中断RXNE和空闲中断IDLE。配置的关键点在于串口时钟和GPIO复用别的都没什么特别的void USART1_Config(uint32_t baudrate) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // PA9 --- TX GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PA10 --- RX GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); USART_InitStructure.USART_BaudRate baudrate; USART_InitStructure.USART_WordLength USART_WordLength_8b; USART_InitStructure.USART_StopBits USART_StopBits_1; USART_InitStructure.USART_Parity USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, USART_InitStructure); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_ITConfig(USART1, USART_IT_IDLE, ENABLE); // 关键空闲中断 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); USART_Cmd(USART1, ENABLE); }在这个项目里串口参数用9600 8 N 1是工控现场最保守稳妥的配置触摸屏和PLC默认基本也是这个。如果调试时想提速115200也可以但F103的USART1最高能跑4.5Mbps做MODBUS RTU完全够用。3.2 中断服务函数里的完整状态机中断里要处理的事情看起来多实际上只有两步RXNE来了就往缓冲区塞数据并刷新计数IDLE来了就说明一帧结束把缓冲区数据交给MODBUS协议解析函数。这里的关键细节是IDLE中断标志的清除方式——必须先读SR再读DR才能清除直接写个清除指令是没用的很多人就是卡在这里#define MODBUS_RX_BUF_SIZE 128 volatile uint8_t modbus_rx_buf[MODBUS_RX_BUF_SIZE]; volatile uint16_t modbus_rx_len 0; volatile uint8_t modbus_rx_complete 0; void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { if (modbus_rx_len MODBUS_RX_BUF_SIZE) { modbus_rx_buf[modbus_rx_len] (uint8_t)USART_ReceiveData(USART1); } else { // 缓冲区满了清空防止溢出导致死循环 modbus_rx_len 0; USART_ReceiveData(USART1); } } if (USART_GetITStatus(USART1, USART_IT_IDLE) ! RESET) { // 必须先读SR再读DR清除IDLE标志 USART_ReceiveData(USART1); if (modbus_rx_len 0) { modbus_rx_complete 1; // 置位主循环去处理 } modbus_rx_len 0; } }可能会有读者问为什么不在中断里直接解析MODBUS帧我的经验是中断里只做数据收集解析放在主循环做。原因有三个第一解析逻辑里涉及到CRC计算和多个功能的判断放在中断里会无故拉长中断响应时间影响其他实时性要求高的任务第二主循环轮询一个标志位的开销极小不会丢失数据第三代码层次清晰方便后续扩展功能码。主循环里的处理逻辑是这样的int main(void) { // 初始化LED、继电器GPIO // 初始化USART1 // 初始化控制参数继电器默认状态、从机地址、波特率 while (1) { if (modbus_rx_complete) { modbus_rx_complete 0; // 处理一帧数据如果有回复要发送函数返回1 MODBUS_Poll((uint8_t *)modbus_rx_buf, modbus_rx_len); } } }这套结构非常简单清晰modbus_rx_complete标志位是关键——它像是一个“有活儿了”的信号主循环一旦看到它就赶紧处理处理完又重新进入低功耗的等待状态。4. 从机参数配置与掉电保存断电后继电器状态要能记住写MODBUS从机程序有个很现实的问题从机地址、波特率、继电器开关状态这些参数断电之后能不能记住如果从机地址固定写在代码里每次烧录才知道是几号从机那现场工程如果要改地址就得重新下载程序非常不灵活。我的做法是把这些参数存在F103的片上Flash里。STM32F103C8T6的Flash是64KB地址从0x08000000开始每页1KB。程序占用的空间一般不到20KB所以我把最后一页0x0800F800~0x0800FBFF划出来作为参数存储区完全不影响程序区。4.1 参数结构体定义与Flash读写参数设计成一个结构体写入Flash之前先计算一个校验和读取的时候如果校验失败就恢复默认值。这样就算Flash里是乱数据比如第一次烧录程序也能正常跑#define PARAM_FLASH_ADDR 0x0800F800 typedef struct { uint8_t slave_addr; // 从机地址 1~247 uint32_t baudrate; // 波特率 uint8_t relay_state[6]; // 6路继电器状态 uint16_t crc16; // 参数校验和 } MODBUS_Param_Typedef; MODBUS_Param_Typedef g_param; void MODBUS_Param_Save(void) { uint16_t crc 0; uint8_t *p (uint8_t *)g_param; // 计算结构体前面部分的CRC不含crc16本身 crc MODBUS_CRC16(p, sizeof(MODBUS_Param_Typedef) - 2); g_param.crc16 crc; // 解锁Flash FLASH_Unlock(); FLASH_ErasePage(PARAM_FLASH_ADDR); for (uint16_t i 0; i sizeof(MODBUS_Param_Typedef); i 2) { uint16_t word p[i] | (p[i 1] 8); FLASH_ProgramHalfWord(PARAM_FLASH_ADDR i, word); } FLASH_Lock(); } void MODBUS_Param_Load(void) { uint8_t *p (uint8_t *)PARAM_FLASH_ADDR; memcpy(g_param, (void *)PARAM_FLASH_ADDR, sizeof(MODBUS_Param_Typedef)); uint16_t crc MODBUS_CRC16(p, sizeof(MODBUS_Param_Typedef) - 2); if (crc ! g_param.crc16) { // 校验失败恢复默认参数 g_param.slave_addr 1; g_param.baudrate 9600; for (uint8_t i 0; i 6; i) g_param.relay_state[i] 0; MODBUS_Param_Save(); } }这块有两点我要单独拿出来强调。Flash擦写次数约一万次如果主机疯狂地通过MODBUS写继电器每次都触发Flash保存很快会把这个参数区写坏。我的方案是只在收到指令且状态发生变化时才调用保存函数不是每次响应都Save。或者更稳妥一点——加一个保存参数功能码比如0x46由主控主动发指令才保存运行过程中频繁切换继电器不碰Flash。还有一个坑就是Flash在写的时候程序是暂停执行的。F103写半字16位大概要几十微秒到百微秒擦除一页更慢典型20~40ms。虽然不会让系统崩溃但如果这时恰好有新的MODBUS帧进来就会丢帧。所以保存参数时最好关闭串口中断保存完成再打开。标准做法是加一段临界区保护__disable_irq()和__enable_irq()操作前后包住。虽然粗暴但保证不丢数据。4.2 继电器GPIO初始化与上下电保护继电器控制引脚初始化不复杂但有一个实用细节我要特别说明所有引脚初始化完成后最好先保持低电平继电器不吸合等参数加载完成后再按保存的状态输出。void RELAY_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_2MHz; GPIO_Init(GPIOB, GPIO_InitStructure); // 先全部关闭防止上电瞬间误动作 GPIO_ResetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5); }为什么GPIO速度设2MHz就够了因为继电器切换频率极低不像SPI要高频翻转2MHz可以降低EMI减少对串口通信的干扰。这都是实际跑项目积攒的经验。5. 功能码实现与协议解析03、06、05、0F、10全覆盖一个能用在真实项目里的MODBUS从机不能只实现一两个功能码。最常见的需求是要能读状态、写单个、写多个。我实现的从机功能码列表如下0x03 读保持寄存器用来读取6路继电器的当前状态也可以用来读取从机地址、波特率等参数。这是上位机最常用的“查询”指令。0x06 写单个寄存器可以用来修改从机地址、波特率参数。0x05 写单个线圈控制一路继电器的开关。这是最基础、也最常用到的指令。0x0F 写多个线圈一次性设置多路继电器的开关状态适合“一键全部吸合/断开”的场景。0x10 写多个寄存器批量写参数比如同时修改从机地址和波特率。主站的协议解析函数写法有很多如果上FreeMODBUS库代码量会省很多但自行实现的好处是代码透明可控、占用的资源极小也不需要引入额外的库依赖。对于一个六路继电器控制这种轻量级应用自己写解析函数完全够用。5.1 地址映射线圈和寄存器的编号规则我定义了一套映射规则主机就知道该往哪写功能码地址范围含义0x05写线圈0x0000 ~ 0x0005第1路~第6路继电器0x0F写多线圈0x0000 ~ 0x0005同上可一次写多路0x03读保持寄存器0x0000继电器状态bit0~bit5对应六路0x03读保持寄存器0x0001从机地址0x03读保持寄存器0x0002波特率编码09600, 119200, 238400, 31152000x06写单个寄存器0x0001修改从机地址0x06写单个寄存器0x0002修改波特率有了这个表上位机工程师不需要知道你内部怎么实现的按这个地址表发指令就行。协议文档一定要写清楚这个映射表否则后面联调就是一场灾难。5.2 0x05写线圈的典型处理流程0x05是所有功能码里基础中的基础流程非常经典校验地址 → 校验CRC → 判断地址是否匹配 → 判断功能码 → 解析数据 → 执行动作 → 原样回复。完整实现如下void MODBUS_Poll(uint8_t *buf, uint16_t len) { uint16_t crc_rx 0; uint16_t crc_calc 0; if (len 8) // 最短帧地址 功能码 数据 2字节CRC 8 return; // 计算CRC crc_calc MODBUS_CRC16(buf, len - 2); // 接收到的CRC低字节在前 crc_rx buf[len - 1]; crc_rx (crc_rx 8) | buf[len - 2]; if (crc_calc ! crc_rx) return; // 校验失败静默丢弃 // 地址匹配判断支持广播地址0 if (buf[0] ! g_param.slave_addr buf[0] ! 0) return; // 根据功能码分发 switch (buf[1]) { case 0x05: // 写单个线圈 MODBUS_Handle_0x05(buf, len); break; case 0x03: // 读保持寄存器 MODBUS_Handle_0x03(buf, len); break; case 0x0F: MODBUS_Handle_0x0F(buf, len); break; case 0x06: MODBUS_Handle_0x06(buf, len); break; case 0x10: MODBUS_Handle_0x10(buf, len); break; default: // 不支持的功能码返回异常码0x01 MODBUS_Send_Exception(buf[0], buf[1], 0x01); break; } }0x05的具体处理逻辑如下。这个指令的关键是数据区必须只能是0xFF00ON或0x0000OFF其他值都是非法数据要返回异常码0x03void MODBUS_Handle_0x05(uint8_t *buf, uint16_t len) { uint16_t coil_addr (buf[2] 8) | buf[3]; uint16_t coil_value (buf[4] 8) | buf[5]; // 地址越界 if (coil_addr 6) { MODBUS_Send_Exception(buf[0], 0x05, 0x02); // 非法地址 return; } // 数值不合法 if (coil_value ! 0xFF00 coil_value ! 0x0000) { MODBUS_Send_Exception(buf[0], 0x05, 0x03); // 非法数据 return; } // 执行继电器动作 if (coil_value 0xFF00) RELAY_Set( coil_addr, 1 ); else RELAY_Set( coil_addr, 0 ); // 原样回复 MODBUS_UART_Send(buf, len); }这里说个细节很多初学着照着例程写0x05用位操作直接改一个变量然后把继电器GPIO也顺便改了。看起来没什么问题但如果后续要支持0x0F写多个线圈就会遇到一个麻烦——0x0F处理的是“一批线圈”的状态如果每路继电器只有一个bit0x05改了bit0、0x0F改了bit1和bit2最后要同时把bit0~bit5输出到GPIO就需要有一个统一的继电器状态变量volatile uint8_t relay_status 0; // bit0~bit5对应六路继电器 void RELAY_Set(uint8_t ch, uint8_t on_off) { if (on_off) relay_status | (1 ch); else relay_status ~(1 ch); // 统一输出到GPIO GPIOB-ODR (GPIOB-ODR 0xFFC0) | (relay_status 0x3F); }用这个统一变量0x05和0x0F都只需要改relay_status的对应位最终输出时一次性刷GPIO不会出现某一路被覆盖的状态问题。GPIOB的ODR低6位bit0~bit5正好对应PB0~PB5一句位操作就搞定全部输出效率极高。5.3 0x0F写多线圈的报文解析0x0F的报文比0x05复杂一点因为多了字节数和线圈值字段。主机发来一帧01 0F 00 00 00 06 01 3F CRC解析规则00 00起始线圈地址这里从第1路开始00 06要操作的线圈数量这里是6路全部01后面线圈值占用的字节数计算方法是(数量 7) / 86路就是1字节3Fbit0~bit5对应6路继电器的状态0x3F 0b00111111表示6路全吸合解析代码里要注意一个细节如果请求的线圈数量不是8的倍数最后一个字节里多余的位要忽略且必须为0否则返回非法数据异常。比如只操作3路起始0数量3最后一个字节只要bit0~bit2有效bit3~bit7如果主机填了1从机应该返回异常码0x03。void MODBUS_Handle_0x0F(uint8_t *buf, uint16_t len) { uint16_t start (buf[2] 8) | buf[3]; uint16_t num (buf[4] 8) | buf[5]; uint8_t byte_count buf[6]; // 地址数量越界检查 if (start num 6) { MODBUS_Send_Exception(buf[0], 0x0F, 0x02); return; } // 字节数必须和数量匹配 if (byte_count ! (num 7) / 8) { MODBUS_Send_Exception(buf[0], 0x0F, 0x03); return; } // 提取各线圈状态并设置继电器 for (uint16_t i 0; i num; i) { uint8_t byte_idx i / 8; uint8_t bit_idx i % 8; uint8_t state (buf[7 byte_idx] bit_idx) 0x01; RELAY_Set(start i, state); } // 0x0F成功响应只回复地址、功能码、起始地址和数量不需要回线圈值 uint8_t resp[8] { buf[0], 0x0F, buf[2], buf[3], buf[4], buf[5] }; uint16_t crc MODBUS_CRC16(resp, 6); resp[6] crc 0xFF; resp[7] crc 8; MODBUS_UART_Send(resp, 8); }注意0x0F的回复和0x05不一样。0x05是原样回显0x0F只需要回复地址、功能码、起始地址和线圈数量这是MODBUS协议规定的不能多也不能少。5.4 0x06写单个寄存器改从机地址和波特率0x06的报文结构是地址 06 寄存器地址 寄存器值 CRC。它跟0x05很像但是更灵活可以改参数void MODBUS_Handle_0x06(uint8_t *buf, uint16_t len) { uint16_t reg_addr (buf[2] 8) | buf[3]; uint16_t reg_val (buf[4] 8) | buf[5]; switch (reg_addr) { case 0x0001: // 修改从机地址 if (reg_val 1 || reg_val 247) { MODBUS_Send_Exception(buf[0], 0x06, 0x03); return; } g_param.slave_addr reg_val; MODBUS_Param_Save(); break; case 0x0002: // 修改波特率 switch (reg_val) { case 0: g_param.baudrate 9600; break; case 1: g_param.baudrate 19200; break; case 2: g_param.baudrate 38400; break; case 3: g_param.baudrate 115200; break; default: MODBUS_Send_Exception(buf[0], 0x06, 0x03); return; } MODBUS_Param_Save(); break; default: MODBUS_Send_Exception(buf[0], 0x06, 0x02); return; } // 原样回复 MODBUS_UART_Send(buf, len); // 如果改了波特率需要重置串口参数 if (reg_addr 0x0002) { // 加一个延时让主机先收到回复 delay_ms(10); USART1_Config(g_param.baudrate); } }修改波特率这里有一个贴心的小细节先原样回复再延时10ms然后才重新初始化串口。如果先改串口再回复主机那边根本收不到确认帧它以为自己写失败了实际从机已经改了波特率两边就对不上了。这样做虽然简单却能避免联调时一个很经典的“改了波特率后设备找不到了”的尴尬场景。5.5 异常响应帧的构造MODBUS的设备不只靠不回话来表示错误更规范的做法是返回异常码。异常响应帧结构从机地址 功能码|0x80 异常码 CRC16。void MODBUS_Send_Exception(uint8_t addr, uint8_t func, uint8_t err_code) { uint8_t frame[5]; frame[0] addr; frame[1] func | 0x80; // 功能码最高位置1 frame[2] err_code; uint16_t crc MODBUS_CRC16(frame, 3); frame[3] crc 0xFF; frame[4] crc 8; MODBUS_UART_Send(frame, 5); }异常响应不是可有可无的东西。有它在上位机工程师可以通过错误码快速定位问题是功能码不支持、是地址没映射好、还是数据填错了。调试阶段省下来的时间都是联调时省下来的血压。6. 串口收发与串口调试助手实践没有上位机也能验证正确性程序写了好几天最激动的环节就是第一次把设备接上电脑看数据能不能通。调试工具的选型非常重要我用的是串口调试助手 USB转RS485模块硬件层面最需要注意的是接线别反A对A、B对B交叉接反而连不上。6.1 串口驱动的坑CH340 vs FTDI市场上最常见的USB转串口芯片是CH340和FTDI FT232。CH340在Win10/Win11下会自动装驱动但Win7老系统经常要手动装而且某些精简版系统会把CH340识别成USB-SERIAL CH340 (COM3)如果识别不到第一件事就是去装官方驱动别折腾系统。FTDI的驱动相对更自动但翻新芯片多装上驱动后设备管理器里可能会冒感叹号这问题可以通过芯片丝印来排查。此外还有一个很玄学但真实存在的坑有些USB转RS485模块是靠串口的DTR/RTS引脚来控制收发方向的如果软件里把DTR/RTS置位或者硬件上跳线帽没插对会出现只收不发、只发不收或收发乱套的情况。我的习惯是调试时直接选零延迟收发自动切换的模块省掉这些硬件层面的烦恼。6.2 用串口调试助手做主机测从机以常见的串口调试助手xcom、sscom、友善串口助手、ModbusPoll等都行为例按下面步骤走选对COM口波特率9600数据位8停止位1无校验打开串口注意很多调试助手的串口被占用时你的程序会收不到任何数据这是电脑上另一个程序占着串口了在发送区输入01 05 00 00 FF 00 8C 3A注意要选HEX模式十六进制发送不能发ASCII字符串点击发送观察接收区——如果一切正常会收到一模一样的帧只要收到原样回显就说明从机的CRC校验、地址匹配、0x05处理、串口发送这四关全通了后面只需要依次测0x03、0x0F、0x06、0x10。没有现成的MODBUS调试软件时纯粹靠串口助手的HEX收发也完全能测完所有功能。ModbusPoll只是把报文自动组装好了省去手算CRT的麻烦但有一点——ModbusPoll发的是标准MODBUS帧如果你的从机有特殊映射地址表不对应它会直接提示异常。6.3 逻辑分析仪与示波器排查物理层问题软件层面测完如果联调不稳定就该上逻辑分析仪了。几十块钱的8通道逻辑分析仪配上PulseView软件在工控调试中非常顶用。把探针勾在RS485芯片的RO接收端和DI发送端上能直接看到波形快速判断是硬件没发出去、还是发了但被人为干扰、还是时序不对。常见的物理层问题有几种A/B线接反表现为完全无响应终端电阻不匹配表现为近距离正常拉远线就错乱波特率偏差RS485芯片没问题但MCU串口配置错了表现为数据乱码共模电压问题两个设备跨距离较远供电不是同一电源最好用隔离电源模块这些坑很多都是排了半天软件才发现是硬件问题所以我还是建议调试开始时先裸测一个回环——把TXD和RXD短接MCU自发自收如果在串口助手上能看到发出的数据原样回来说明串口收发通路正常问题大概率出在上位机或RS485芯片方向上。7. 上位机侧MODBUS调试与常见故障排查程序实现完之后我在电脑上用模拟主站软件ModbusPoll做了完整的回环验证。从第一步到最终全部通过花了小半天其中遇到的故障排错过程我觉得比功能本身更值得记录下来。7.1 故障现象一发送指令从机完全无响应排查思路先看串口有没有打开发送成功——有些调试助手发送失败了会无声无息地报错你看到的只是从机没回话看USB转RS485模块的指示灯——正常发送时TX灯闪如果完全不闪大概率是端口选错或串口被占用确认A/B线接对了没有示波器/逻辑分析仪勾RS485芯片的DI引脚看MCU到底有没有把数据发出来查程序——USART1的GPIO配置是不是对、TX是否真的接了RS485芯片的DI当时查到后面发现是RS485芯片的DE/RE使能脚没有拉高导致芯片一直处于接收模式数据根本发不出去。这个问题在软件上就是一句话——把DE/RE对应的GPIO拉高但如果没有这一步程序写得再对也白搭。7.2 故障现象二能收到响应但响应内容全是乱码排查思路波特率不匹配的概率最大——上位机9600从机115200那收到的肯定是乱码检查位数和校验位——8N1是最稳妥的如果上位机配了9位或偶校验也是乱码检查从机发送函数的字节间隔——如果发送时每字节之间被定时器中断打断间隔太长上位机可能把一帧拆成多帧处理看波形——如果波形上升沿/下降沿太缓或者有振铃那就是硬件抗阻匹配问题那个发送函数被中断打断的问题特别隐蔽。我用的是逐字节发送的循环如果发送过程中来了定时器中断延时稍长主站尤其是对时序敏感的ModbusPoll可能就会报超时。解决方法是发送前关闭总中断或者用DMA发送。7.3 故障现象三继电器动作了但上位机报异常这个是比较典型的协议对了一半的问题。当时我还在测试0x0F写多个线圈用ModbusPoll发了一帧01 0F 00 00 00 06 01 3F继电器确实全吸合了但上位机报了异常响应。查了一圈发现是0x0F处理函数里回复帧多带了一个字节。协议规定0x0F的成功响应只有8个字节地址、功能码、起始地址高8位、低8位、数量高8位、低8位、CRC低、CRC高我返的却带了线圈值。上位机按照帧格式去解析自然认为这是一条故障帧。这告诉我们每个功能码的响应帧格式必须严格遵守MODBUS协议不能想当然地把数据带回去。7.4 故障现象四偶尔能通偶尔不通这种最常见的原因是从机对帧间隔的判断不准确。如果用的是空闲中断那基本不会误判但如果用的是定时器超时判断定时器定时的时长必须大于3.5个字符时间。波特率9600时一个字符约1.04ms3.5个字符约3.6ms我一般定时器设5ms比较稳妥太短容易把一帧拆成两帧太长又会让从机响应变慢。还有一个更隐蔽的坑——主机软件发送间隔本身就不稳定。有些串口调试助手在HEX模式下每个字节之间会插入额外的延时导致从机把完整的一帧当成多帧来处理表现为时通时不通。遇到这种现象建议先换个主流调试软件试试确认不是软件的问题再说。8. 扩展与维护建议从能用到好用还需要做的几件事六路继电器MODBUS RTU从机这个项目做到能响应功能码、能保存参数、能稳定通信基础版本就算完成了。但要真正拿到工程项目里用还需要考虑几个扩展方向。8.1 加RS485自动收发控制如果用的是MAX3485/SP3485这类半双工RS485芯片发送数据之前要把DE引脚发送使能拉高发送完成后拉低。我之前图省事直接写了一个发送函数把DE拉高后逐字节发送发送完拉低。如果你用DMA发送要注意在DMA发送完成中断里拉低DE否则下一帧收数据时DE还高着自己的发送数据会回环到自己的接收脚造成混乱。void MODBUS_UART_Send(uint8_t *buf, uint16_t len) { // 拉高DE进入发送模式 RS485_DE_HIGH(); // 逐字节发送或者用DMA中断更优雅 for (uint16_t i 0; i len; i) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); USART_SendData(USART1, buf[i]); } // 等待最后一个字节移位完成 while (USART_GetFlagStatus(USART1, USART_FLAG_TC) RESET); // 拉低DE回到接收模式 RS485_DE_LOW(); }8.2 预留固件升级入口现场设备已经装好如果发现程序有bug需要改总不能把设备拆回来刷程序。我一般会在代码里留一个Bootloader升级入口上电时检测某个引脚比如PA0的电平如果为低则进入串口ISP模式否则正常跑应用。这样现场维护只需要一台笔记本加一个USB转串口就能在线升级固件。这个话题展开又是一篇文章但设计之初留好这个入口后面能省很多事。8.3 增加看门狗和通信超时保护用在工业控制里最怕的是主站挂了从机还傻傻地保持最后的状态。比如控制电机的继电器如果主站突然掉线电机还继续转那就是安全事故。我的做法是加一个通信超时看门狗从机每收到一帧合法数据就清零一个软件计数器主循环里计数器累加如果连续N秒没有收到任何合法帧就自动把所有继电器断开或者按预设安全状态执行。这个功能虽然不在MODBUS协议里但作为一个从机设备的自我保护机制模拟量输出、数字量输出模块基本都有是提高设备可靠性的重要一环。8.4 预留其他传感器扩展很多用户做这个项目不只是想控制继电器还想顺带读一些现场数据温度、湿度、液位等。这些采集量可以映射到MODBUS的**输入寄存器功能码0x04或者保持寄存器功能码0x03**里。例如可以用PA1~PA3接三个ADC通道把温度、电压、电流的原始值实时映射到保持寄存器0x0010~0x0012这样上位机就能随时读取。这个扩展只需要增加一个ADC初始化和一个定时采集逻辑不影响已有的继电器控制功能。9. 写在最后的价值总结做了这么多个MODBUS设备最大的感受是MODBUS RTU真正难的地方不在协议本身而在稳定二字。CRC校验写对了不算什么能在各种干扰环境下不丢帧、不错乱才是真的合格。这需要软硬件协同去优化比如RS485的隔离、终端电阻、电源滤波、串口DMA、帧间隔判断每个细节都可能成为现场不稳定的一根刺。从学习角度讲用F103做六路继电器MODBUS RTU从机是一个非常合适的练手项目。它麻雀虽小但五脏俱全——串口中断、定时器、Flash参数存储、状态机、CRC算法、协议解析每一项都是嵌入式开发里高频使用的技能。学完这个项目再去看Modbus TCP、CANopen、Profinet这些协议底层的思维方式是完全相通的。最后分享一个小技巧。如果你手头有逻辑分析仪但又是第一次调MODBUS可以把分析仪挂在RS485芯片的RO脚接收输出上用PulseView解码UART。这样主机发过来的每一个字节你都能看得清清楚楚哪一步解析出错、什么时候帧被拆开一目了然。整个调试验证过程十分钟就能完成绝对比盲调靠谱得多。本文还有配套的精品资源点击获取
返回列表