
简介基于STC32G12单片机的Modbus-RTU主机例程包面向嵌入式开发者和工业自动化方向的学习者旨在解决在八位单片机平台上实现Modbus-RTU主站通信的工程落地问题。STC32G12片内资源丰富例程充分利用其增强型串口与定时器完整演示了从硬件初始化到应用层调度的全过程。整包共一百四十个文件以七十二个C源码和六十二个头文件为主辅以Keil工程文件、HEX固件、汇编与批处理脚本及库文件压缩包约二百二十四KB目录结构清晰便于直接导入工程进行编译与二次开发。例程覆盖串口波特率配置、Modbus报文封装与解析、循环冗余校验计算、主从轮询调度、超时重试与异常处理等关键环节源码注释详细配有实际可运行的工程模板。目前已有502人在CSDN学习下载尤其适合课程设计、毕业设计与工业通信项目参考可大幅缩短开发周期。 做工业数据采集或者小型控制项目久了你会发现一个现象现场的设备五花八门但只要你问一句“支持Modbus吗”大概率都能聊到一块去。STC32G12K128这颗芯片在国产单片机里算很有特点的32位8051内核、主频跑到40M以上价格还便宜拿它做Modbus-RTU主机去轮询几个温湿度传感器、电表或者变频器是性价比很高的方案。这篇东西就是我实际调试这类例程的完整记录从协议底层到状态机轮询再到那些文档里不会写的坑都会覆盖。1. 项目整体思路与选型分析1.1 为什么选STC32G12K128做Modbus主机很多人一提Modbus主站第一反应是STM32或者PLC但 STC32G12K128有它不可替代的场景。这颗片子是STC最新一代32位8051架构指令集兼容传统8051但内核是C251性能比传统51强了不止一个档次。我选它主要是三个原因第一主频最高能到42MHz做Modbus-RTU这种波特率9600/115200的协议绰绰有余第二芯片自带多个串口串口1~4可以一个口做调试打印一个口跑Modbus通信互不干扰这点对调试太重要了第三它支持在线编程和硬件USB下载现场改固件不用拆机箱。但最关键的还是成本。一批设备如果要量产单片机成本能压到几块钱这在很多工控小项目里是决定性的优势。而且STC32G12K128内部有128K Flash和12K SRAM跑完整的Modbus主机协议栈加多个从机轮询表资源完全够用不像普通51那样扣扣搜搜。1.2 Modbus-RTU主机和从机的角色定位差异Modbus总线上一主多从主机是会话的发起者从机只能被动响应。这个“发起者”的角色决定了程序结构完全不一样。从机程序本质是“中断响应型”收到完整一帧请求解析功能码操作寄存器把响应发回去主循环基本是空的。而主机程序是“状态机驱动型”它要主动构建请求帧、发送、等待应答、处理超时、解析响应、继续下一条请求。一个主机例程的复杂度往往比一个从机例程高出一倍以上主要就高在“等待”和“超时处理”上。写主机例程前心里要有这张轮询表的概念什么时候该发哪条指令、哪个从机在上面、指令超时多久算失败、失败要重试几次。这些逻辑在一开始就要设计好否则后面代码会越写越乱。2. 通信底层与硬件接口设计2.1 RS485收发电路自动方向切换的坑与替代方案Modbus-RTU广泛应用在RS485物理层上。RS485是半双工总线发送和接收共用一对差分线所以必须控制收发器的方向脚。大部分RS485芯片比如SP485、MAX485都有DE发送使能和RE接收使能引脚。网上流行一种自动收发电路用一个三极管和三两个电阻从TXD信号反相控制DE/RE省掉一个单片机IO口。我刚接触时图省事用过这种电路结果发现它在9600波特率下勉强能用一旦波特率提到115200就出问题发送结束后DE释放不及时会吞掉最后一个字节或者从机返回的应答头几个字节收不到。如果你要用这个电路至少把波特率控制在19200以内并且发送完最后一字节后加一点延时再切换否则就是给自己挖坑自找麻烦。我实际项目的做法用单片机一个普通IO口直接控DE和RE通常接在一起发送数据前置高发送完成后延时1~2ms再拉低回到接收状态。虽然多占一个IO但可靠性完全在掌握之中调试时也不用猜方向切换的时序问题。2.2 串口配置与波特率误差控制STC32G12K128有4个串口我建议Modbus通信用串口2或串口3把串口1专门留给调试打印。配置串口时波特率用定时器2作为波特率发生器。这里有个关键点波特率误差直接影响通信稳定性。我们算一下串口2在9600波特率、11.0592MHz晶振下定时器重载值和误差都可以忽略。但STC32G时钟可以走内部IRC误差可能到1%左右。Modbus-RTU标准要求波特率误差小于2%所以内部IRC在常温下够用但如果环境温度变化大建议还是外挂晶振或者用STC的时钟校准功能。配置代码基本是这样void UART2_Init(void) { S2CON 0x10; // 模式18位可变波特率 S2CON 0xBF; // 禁止接收中断稍后使能 T2MOD 0x00; T2L 0xE8; // 9600波特率11.0592MHz T2H 0xFF; AUXR | 0x14; // T2作为波特率发生器1T模式 S2CON | 0x01; // 允许接收 // 串口2中断使能 IE2 | 0x01; }注意STC32G的串口2寄存器名和传统51不完全一样查手册时别搞混。2.3 接收缓冲环形队列是必须的Modbus主机收数据是异步的从机什么时候回复你根本不知道。如果你在等待应答时用阻塞式while读串口那系统其它任务全卡死了。工业现场的主机往往还要同时处理按键、显示、报警输出这些都不能因为通信卡住。所以串口接收必须走中断数据丢进环形队列主循环再去解析。环形队列的实现很成熟网上随便能搜到我这里只说一个要点队列长度至少要是最大帧长度的两倍。Modbus-RTU一帧最长大概是256字节我建议环形队列开512字节这样即使处理偶发延迟也不会丢数据。3. Modbus-RTU协议核心帧结构、CRC与功能码3.1 帧间隔RTU协议最容易忽略的时间约束Modbus-RTU有个特殊性它对帧与帧之间的时间间隔有硬性要求一帧内的字节间隔不能超过1.5个字符时间帧与帧之间的空闲间隔必须大于3.5个字符时间。这个时间约束是协议的灵魂因为它决定了解析器怎么判断“一帧结束了”。在9600波特率下一个字符8数据位1起始位1停止位大约是1.04ms1.5个字符时间 1.56ms即一帧中相邻字节间隔超过这个时间必须视为两帧3.5个字符时间 3.64ms即接收空闲超过这个时间说明当前帧接收完毕实际实现中我用一个1ms的软件定时器做帧超时判断。串口每收到一个字节就清掉超时计数器主循环里不停查这个计数器一旦超过4ms就认为帧接收结束把接收缓冲区里的数据交给解析器。这个方案简单可靠不用依赖串口空闲中断。3.2 CRC16-Modbus校验计算过程与查表优化Modbus-RTU的帧尾是两个字节的CRC16多项式是0x8005初值0xFFFF低位在前发送。CRC计算是所有Modbus程序的基础写错了后面全是白搭。按位计算的代码如下适合初学者理解原理uint16_t CRC16_Modbus(uint8_t *data, uint16_t len) { uint16_t crc 0xFFFF; uint16_t i; for (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 1; } } return crc; }按位法在9600波特率下跑完全没问题但如果你要高频轮询十几个从机建议换成查表法。查表法就是把256个CRC中间计算结果预先算好存到数组里每处理一个字节只需要一次查表加三次异或速度提升8倍。表怎么生成网上工具一抓一大把也可以用上面的按位法在PC上先算好生成一个const数组放进工程。代码里注意发送CRC时低字节先发高字节后发。这个顺序错了从机一定会报CRC错误。3.3 主机常用功能码03读寄存器、06写单寄存器、16写多寄存器Modbus-RTU功能码很多但作为主机日常项目里最常用的就是三个功能码含义用途0x03读保持寄存器采集传感器数据、读取设备参数最常用0x06写单个保持寄存器控制单路输出、修改单个参数0x10十进制16写多个保持寄存器批量设置参数、下发多通道控制值以03功能码为例主机请求帧格式从机地址1字节如0x01功能码0x03起始寄存器地址2字节高字节在前寄存器数量2字节CRC162字节比如读从机1的寄存器地址0x0000开始读2个寄存器完整请求帧就是01 03 00 00 00 02 C4 0B其中C4 0B就是前面字节算出来的CRC16。从机响应帧格式从机地址1字节功能码0x03数据字节数1字节 寄存器数 * 2寄存器数据N字节每个寄存器2字节高字节在前CRC16正常响应示例01 03 04 00 00 00 00 FA 33。如果从机报异常它会返回功能码最高位置1即0x83后面跟一个异常码比如02表示非法数据地址、03表示非法数据值。主机收到异常码时不要一直重发请求要看日志分析原因。3.4 多从机轮询与超时重试机制主机要挂多个从机时程序上必须有一个状态机来管理整个“请求-应答”流程。我用的状态定义如下typedef enum { MB_IDLE 0, // 空闲准备发送下一帧 MB_WAIT_RESPONSE, // 已发送等待应答 MB_RECV_DONE, // 应答完整接收 MB_RESPONSE_TIMEOUT, // 应答超时 MB_ERROR_RETRY // 出错准备重试 } MB_STATE;主循环里每个从机的轮询流程是发送一个请求然后等待应答或超时。超时时间建议设在50ms~500ms之间具体要看从机的典型响应时间。一般仪表类设备响应时间在10~100ms变频器可能到200ms。我把默认超时设为200ms如果对端设备特别慢再单独调大。重试机制一条命令最多重试3次3次都失败就标记该从机离线继续轮询下一台设备不能因为一个从机故障把整个总线卡死。这个设计在实际项目里非常重要你想如果某个传感器线断了主机如果一直等它应答其它设备的数据也拿不到了整个系统就瘫了。4. 完整例程框架状态机与核心代码解读4.1 主循环非阻塞式任务调度整个主机程序的框架和裸机前后台系统类似。后台是定时器中断和串口中断前台是主循环里的状态机处理。主循环不能有任何阻塞每一圈都要快速跑完重点做三件事检查帧超时、处理收完的帧、发送下一帧。核心主循环结构void main(void) { sys_init(); // 时钟、串口、GPIO初始化 while (1) { // 1. 检查接收超时判断一帧是否接收完毕 mb_poll_receive_timeout(); // 2. 如果有完整的一帧进入解析流程 if (mb_frame_ready()) { mb_handle_response(); } // 3. Modbus主机状态机管理发送、等待、重试 mb_master_poll(); // 4. 其它任务按键扫描、显示刷新等 user_task(); } }4.2 串口接收与1ms时基中断串口中断接收字节并放入环形队列同时记录最后一次接收时间。1ms定时器中断负责推进软件时钟让主循环判断距离上次收字节的时间是否超过4ms。volatile uint16_t g_mb_last_rx_tick 0; // 最后收包时间戳 void UART2_ISR(void) interrupt 8 { if (S2CON 0x01) { uint8_t byte S2BUF; ringbuf_push(g_rx_buf, byte); g_mb_last_rx_tick g_tick_ms; S2CON 0xFE; // 清除接收中断标志 } } void Timer0_ISR(void) interrupt 1 { g_tick_ms; // 1ms递增 }注意STC32G的串口2中断入口和传统51不一样以你用的开发环境实际头文件为准。我用的Keil C251环境下中断号是8如果你用SDCC或者其它工具链记得查一下数据手册。4.3 主机状态机核心实现状态机是主机程序的灵魂我在mb_master_poll()里做了整个轮询逻辑。简化后的核心逻辑如下void mb_master_poll(void) { switch (mb_state) { case MB_IDLE: // 拉取下一条请求构建请求帧发送 if (mb_get_next_request(req)) { mb_build_request(req, outbuf, outlen); rs485_set_tx(); // DE置高 uart2_send_buf(outbuf, outlen); // 发送 while (uart2_busy()); // 等待发送完成 rs485_set_rx(); // DE拉低 mb_tick_start g_tick_ms; mb_state MB_WAIT_RESPONSE; } break; case MB_WAIT_RESPONSE: // 判断是否超时 if (g_tick_ms - mb_tick_start MB_TIMEOUT_MS) { mb_state MB_RESPONSE_TIMEOUT; } break; case MB_RECV_DONE: // 解析应答帧校验地址、功能码、CRC mb_parse_response(); mb_state MB_IDLE; break; case MB_RESPONSE_TIMEOUT: // 重试次数不足则重发否则标记失败 if (mb_retry_count MB_MAX_RETRY) { mb_retry_count; mb_state MB_IDLE; // 重新发送当前请求 } else { mb_mark_slave_offline(); mb_state MB_IDLE; } break; } }重发的实现有个细节并不是重新走一遍mb_get_next_request那样会跳到下一条命令。正确做法是缓存当前正在请求的帧内容重试时直接把缓存发出去。我工程里有一个mb_cache_request[]数组每次发完就拷进去一份重试时直接发。4.4 响应帧的解析与校验收到一帧完整响应后解析顺序很重要写错了容易把数据搞乱第1字节从机地址必须和当前请求的从机地址一致第2字节功能码检查是否和请求一致或者最高位是否为1异常帧如果是正常帧根据功能码解析数据段。03功能码从第3字节开始是字节数后面跟寄存器数据06功能码则是回显请求帧内容最后两字节校验CRC16校验不过直接丢弃整帧static int mb_parse_response(uint8_t *frame, uint16_t len) { uint16_t crc_calc, crc_recv; if (len 5) return -1; // 最短帧是地址功能码CRC异常帧 if (frame[0] ! g_cur_slave_addr) return -2; // 地址不对 crc_calc CRC16_Modbus(frame, len - 2); crc_recv frame[len - 2] | (frame[len - 1] 8); if (crc_calc ! crc_recv) return -3; // CRC错误 if (frame[1] 0x80) { // 异常响应frame[2]是异常码 return frame[2]; // 上层根据异常码处理 } // 正常响应根据功能码解析数据 if (frame[1] 0x03) { // frame[2]是字节数从frame[3]开始取数据 } else if (frame[1] 0x06) { // 06功能码响应就是回显请求 } return 0; }这里有个经验帧长度校验时不要把长度当成绝对可信的数据。程序要把最坏情况考虑进去——如果从机返回一个异常帧或者中途有干扰长度字段可能完全失真。所以解析前必须先做长度边界检查防止越界读数组。5. 常见问题与排查技巧实录5.1 最常见问题主机收不到任何响应这个问题遇到最多很多人第一反应是程序问题但十有八九是硬件接线或收发方向问题。排查顺序我建议按这个表来现象可能原因排查手段主机发请求从机无反应RS485的A/B接反了交换A/B线试一下主机发请求从机无反应收发器DE/RE没切换示波器看DE引脚波形请求波形正常从机不应答从机地址或功能码不对用串口助手抓包确认帧内容从机有响应主机收不到自动收发电路吞字节改用IO控制DE或降低波特率偶尔通偶尔不通总线没接地共模干扰确保所有设备信号地共接长距离通信出错率高缺少终端电阻反射总线两端各接120欧电阻第一个要怀疑的就是A/B接反。RS485的A脚对应T/RB脚对应T/R-不同厂商设备标注不尽相同我吃过很多亏。用一根杜邦线把A/B对调是最快的测试方法。5.2 CRC校验失败但数据看起来是对的如果你的响应帧内容看着对但程序一直报CRC失败主要检查两个地方。第一CRC的字节序。Modbus是低字节在前也就是说CRC16算出来是0xC4 0B实际发送时先发0xC4再发0x0B。你的接收解析里再拼回CRC时也要反着拼crc_recv frame[len-2] | (frame[len-1] 8)这个顺序反了必定报错。第二帧超时判断太敏感。如果从机响应速度慢在9600波特率下中间可能有超过3.5字符时间的间隔主机可能在请求帧没发完时就误判“帧接收完毕”。我遇到过一次某从机在上电后第一次响应特别慢超过200ms主机那边已经重试了两次。解决方法是针对慢速设备单独调整超时时间不要一刀切。5.3 重试机制导致的总线冲突这是我踩过的一个比较隐蔽的坑。如果主机发请求后超时重试时直接重新发相同请求没问题。但如果主机制作了“超时后先发一段复位指令再重试”的逻辑比如向从机写一个00值再去读这台从机如果还在处理上一条命令就会发生总线冲突——从机在回复上一条旧请求主机听到的不是它当前等待的应答于是继续重试形成恶性循环。我的建议重试就是重发原帧不要在超时后插入额外指令。如果一定要做异常恢复也必须在重试3次全部失败、并且标记从机离线后再发恢复指令不要在上一条请求还没了结时发新命令。5.4 调试工具推荐逻辑分析仪比串口助手好用调试Modbus主机强烈建议用逻辑分析仪不用太贵几十块钱的8通道逻辑分析仪完全够用。串口助手只能看到PC串口的数据看不到RS485总线上的真实波形逻辑分析仪可以同时看TXD、RXD、DE三条线能直接判断方向切换是否正确、字节间隔是否超限、波形畸变位置在哪。我每次联调都会先抓一段波形存档。看到TXD有数据发出、但RXD没返回时先查DE波形是否正常切换看到RXD有波形但解析不了再放大波形看每个字节的时序基本几秒就能定位问题。附例程文件结构建议最后顺便说一下例程的工程组织这属于很多人一开始不在意、后面维护时后悔的事情。我的Modbus主机例程目录结构供参考project/ ├── user/ │ ├── main.c # 主循环和初始化 │ └── user_task.c # 用户业务逻辑 ├── modbus/ │ ├── mb_master.c # 主机状态机 │ ├── mb_master.h │ ├── mb_crc16.c # CRC16计算 │ └── mb_register_table.c # 寄存器映射表 ├── bsp/ │ ├── uart2.c # 串口驱动 │ ├── timer0.c # 1ms定时器 │ ├── rs485_gpio.c # 485方向控制 │ └── ringbuf.c # 环形队列 └── doc/ └── modbus规范摘要.md寄存器映射表单独拎出来这样以后要增加从机或修改寄存器地址只需要改表不用动状态机代码。根据我个人经验写好一个Modbus主机例程核心不是那些协议格式背得多熟而是把“状态机超时重试”这套骨架搭稳了再把CRC和帧间隔两个细节抠对整个工程就成功了八成。调通信时急躁没用照着数据手册和波形一步步查总能找到问题。希望这份记录能帮你在写STC32G的Modbus主机时少走一些弯路。本文还有配套的精品资源点击获取