ARTICLE DETAIL

资讯详情

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

PIC单片机实现Modbus RTU通信:主从站C语言代码详解

PIC单片机实现Modbus RTU通信:主从站C语言代码详解 简介这是一份针对PIC微控制器的Modbus RTU主从协议实现资源采用C语言编写主要面向嵌入式开发与工业现场通信调试人员。压缩包共13个文件整体约6.88MB包含3个C源码主站、从站及LCD显示相关、1个DSN原理图、1个PDF说明、1个README.md以及6张工程截图外加GIF演示可完整展示代码结构、硬件连接与运行效果。主站部分基于PIC18F458配置为RTU模式、2400bps波特率通过外部中断RB0接收数据适合与SDM120等Modbus从设备稳定对接从站示例配合原理图与LCD显示可帮助理解典型轮询流程、报文解析与异常处理。资源内附可编辑的DSN原理图方便根据实际硬件调整接线README.md提供使用说明与配置细节能显著缩短二次开发时间。已有330人学习下载适合需要参考完整工程实现的初、中级嵌入式开发者。1. 旧设备上网PIC 做主从哪一头把一颗老掉牙的 PIC16F877A 接进组态软件的串口屏或者让一排 PIC 控制板听 PLC 统一指挥最省事的办法就是把 Modbus RTU 协议烧进去。Modbus RTU 在工业现场之所以还活着是因为它只依赖 RS-485 两线差分信号抗干扰好、接线简单更重要的是它的帧格式足够简单一个地址、一个功能码、若干数据、两个 CRC 校验字节C 语言几百行代码就能收干净。对于 PIC 这种 Flash/RAM 都抠门的 8 位单片机算力紧张但也能轻松跑起来你不需要移植什么操作系统或协议栈组织好串口中断和定时器把接收帧的状态机写稳主站、从站两套逻辑就都立起来了。这篇文章围绕“PIC 主从”来写从帧结构落地到串口切帧、寄存器映射、调试排错给出一套适合 PIC 平台的 C 代码骨架照着改就能出活。2. Modbus RTU 帧格式与 CRC16先算出通信的底子2.1 RTU 帧的字节布局和功能码选择Modbus RTU 规定一帧最多 256 字节实际应用里读保持寄存器0x03、写单个寄存器0x06、写多个寄存器0x10三个功能码占了九成以上场景。帧格式固定为地址码 1 字节 功能码 1 字节 数据区 N 字节 CRC16 低字节在前、高字节在后。字段长度说明从站地址1 字节1~247 有效0 为广播地址功能码1 字节0x03/0x06/0x10 最常用数据区N 字节寄存器地址、数量、字节数、寄存器值CRC162 字节低字节在前高字节在后PIC 端做主站时发送的报文要自己组做从站时解析完数据区更要回帧。很多人第一次上手容易被字节序搞晕Modbus RTU 是大端传输一个 16 位寄存器值在线上是高字节先走而 PIC 的变量在内存里往往是低字节在前所以组帧和解帧需要手动调换高低字节不能用结构体直接填充再发。2.1.1 CRC16 的两种实现路线CRC16 校验在 RTU 里是强制性的不能省。常见做法有两种查表法和位运算法。查表法把 256 个 CRC 值预先算好放进 ROM查表一次处理一个字节速度最快代价是占掉约 512 字节 Flash位运算法用循环逐位计算几乎不占额外空间速度慢但对几十个字节的帧毫无压力。PIC16 系列 Flash 通常有几 K 到几十 K我一般选择查表法因为 Modbus 主站要同时处理多个从站请求对时间敏感的场合查表更稳妥。如果你用的是 PIC10/PIC12 这类小容量型号改用位运算法即可逻辑如下// 位运算法计算 CRC16-Modbus // 算法参数poly0xA001初始值为 0xFFFF结果低字节在前 uint16_t pic_modbus_crc16(uint8_t *pdata, uint16_t len) { uint16_t crc 0xFFFF; while (len--) { crc ^ *pdata; // 异或当前字节 for (uint8_t i 0; i 8; i) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; // 多项式 0xA001 0x8005 反转 } else { crc 1; } } } return crc; }这段代码的输入参数是待校验字节流的指针和长度返回值是 16 位 CRC。组帧时把返回值拆成低字节和高字节依次填入帧尾解析时对整帧含接收到的 CRC 字节重新计算若结果为零则校验通过。注意返回类型要显式使用 uint16_t8 位单片机默认 int 可能只有 16 位unsigned 定义不清晰容易造成右移时符号位介入。2.2 帧间隙与 3.5 字符时间这是 RTU 最容易被忽略的边界RTU 协议对帧与帧之间的间隔有明确要求静止时间大于 3.5 个字符时间视为一帧结束大于 1.5 个字符时间会导致帧认为是粘连错误。这个时间阈值直接决定了解析策略也是最容易埋雷的地方。3.5 字符时间从波特率可以算出来一个字符包含 1 个起始位 8 个数据位 1 个停止位 10 位时间因此波特率1 字符时间3.5 字符时间96001.04 ms3.65 ms192000.52 ms1.82 ms384000.26 ms0.91 msPIC 的常用做法是用一个定时器做超时计时每收到一个字节就把定时器清零重装定时器溢出就认为一帧结束。从站比较省心一帧最多几十字节间隔判断按 4ms 做主站则要根据实际波特率动态设置因为主站可能同时挂不同波特率的从站配置。3. 用 PIC 的串口中断和定时器组合切分 RTU 帧3.1 串口初始化与波特率计算PIC 系列单片机的 USART 模块配置是标准化的以 PIC16F877A 在 8MHz 晶振、9600 波特率下为例SPBRG 寄存器的计算方式为// 配置 PIC16F877A 的 USART 异步发送接收8MHz9600 波特率 void pic_uart_init(void) { // 先关收发使能避免配置过程中触发错误中断 RCSTA 0x00; TXSTA 0x00; // 波特率计算SPBRG Fosc / (16 * Baud) - 1高/低速模型 SPBRG 51; // 8MHz/(16*9600)-1 51.08取整 51 BRGH 1; // 高速波特率模式 SYNC 0; // 异步模式 SPEN 1; // 串口模块使能 TXEN 1; // 发送使能 CREN 1; // 接收使能 RCIE 1; // 接收中断使能 PEIE 1; // 外设中断总使能 GIE 1; // 全局中断使能 }SPBRG 取值直接影响实际波特率偏差51 代入公式得到实际波特率 9615误差 0.16%在可选范围内。如果晶振频率不是标准值用计算器手动算出最接近整数后还要把误差控制在 2% 以内不然一帧几十字节累积下来时序就歪了。串口这里要注意 PIC 的 TXSTA 和 RCSTA 寄存器在不同型号上寄存器名一致但位定义略有差异换芯片先看数据手册再改代码。3.1.1 接收中断和定时器溢出协同组帧单靠串口接收中断只能得到字节流Modbus 从站需要知道一帧从哪里开始、到哪里结束。我一般用 Timer0 做 4ms 超时判断配合一个简单的状态机收帧volatile uint8_t rtu_buf[RTU_BUF_MAX]; // 接收缓冲区 volatile uint8_t rtu_len 0; // 已接收字节数 volatile uint8_t rtu_frame_ready 0; // 完整帧标志 void __interrupt() pic_isr(void) { if (RCIF) { uint8_t ch RCREG; // 读取收到的字节同时清 RCIF // 超时定时器清零重装标记正在接收 TMR0 0x00; rtu_buf[rtu_len] ch; if (rtu_len RTU_BUF_MAX) { rtu_len 0; // 溢出保护丢弃整帧 } } if (T0IF) { // 4ms 没有新字节认为一帧结束 if (rtu_len 0) { rtu_frame_ready 1; } T0IF 0; } }这段代码的关键在于TMR0 每次收到字节都被清零只要字节流间隔小于 4ms定时器就不会溢出帧自然就是完整的。如果设备处于空闲状态TMR0 溢出后如果没有新数据说明历史上曾有帧残留但尚未处理rtu_len 清零的逻辑要放到主循环取帧之后。主循环里轮询 rtu_frame_ready 标志然后把 rtu_buf 和 rtu_len 交给协议层去解析。3.2 主循环里的协议解析不阻塞任何通信组完帧之后解析不能放在中断里做。CRC 校验、功能码分发、寄存器读写都需要时间和可能访问 EEPROM中断里做会让串口接收产生间隙导致接收字节间隔超过 1.5 字符时间而错帧。实际做法是把 rtu_frame_ready 置位后立即退出中断主循环里及时拷贝接收数据并复位缓冲区保持“中断只管收主循环负责处理”的职责边界。// 主循环流程示意 void main(void) { pic_uart_init(); pic_timer0_init(); while (1) { if (rtu_frame_ready) { rtu_frame_ready 0; uint8_t len rtu_len; rtu_len 0; if (pic_modbus_crc16((uint8_t*)rtu_buf, len) ! 0) { continue; // CRC 校验失败丢弃 } // 地址匹配或者接收的是广播地址 uint8_t addr rtu_buf[0]; if (addr ! PIC_MODBUS_ADDR addr ! 0) { continue; } pic_modbus_slave_handle(rtu_buf, len); } } }注意这里在取 len 之前把 rtu_len 清零是为了避免中断在后续解析过程中不断写入旧数据。CRC 校验放在地址匹配之前这是为了防误码如果帧被干扰某个字节错误可能导致从站地址被误判为其它站但 CRC 校验能先拦截掉这类错误帧。4. 主从站的 C 语言代码架构寄存器映射决定可维护性4.1 从站寄存器表设计Modbus 从站的本质是一张寄存器映射表外部主机通过功能码读写这张表具体读写什么资源由你决定。用 C 语言实现时最常见做法是维护一个全局数组模拟保持寄存器再提供注册回调函数让业务代码直接操作这张表协议层只负责搬运不关心数据含义。4.1.1 寄存器读写回调的 C 语言写法typedef uint8_t (*reg_read_cb)(uint16_t addr, uint16_t *value); typedef uint8_t (*reg_write_cb)(uint16_t addr, uint16_t value); typedef struct { uint16_t start_addr; uint16_t reg_count; reg_read_cb read_cb; reg_write_cb write_cb; } modbus_reg_map_t; const modbus_reg_map_t reg_map[] { { 0x0000, 10, reg_read_holding, NULL }, { 0x0100, 2, reg_read_temp, reg_write_fan }, };这个结构体数组把 Modbus 地址段和实际的读取函数绑定主站请求 0x0000 地址时从站查到起始地址是 0x0000、数量 10就调用对应的回调函数逐项读取数据。回调函数返回状态用 0 表示成功非 0 表示寄存器不可读或地址越界可以直接映射成 Modbus 异常码 0x02。寄存器表的好处是明显的协议栈和业务解耦新增一个寄存器只需要在数组里加一行不需要改协议解析函数。对于 PIC 这种没有内存保护的单片机回调函数也要做输入合法性App 层可以不去处理非法地址问题但协议层必须在访问之前检查地址段和数量是否越界否则恶意报文可以直接把栈打穿。4.1.2 从站处理 0x03 与 0x06 的 C 语言分发逻辑void pic_modbus_slave_handle(uint8_t *frame, uint8_t len) { uint8_t func frame[1]; uint16_t start_addr (frame[2] 8) | frame[3]; // 大端转小端 uint16_t reg_count (frame[4] 8) | frame[5]; switch (func) { case 0x03: // 读保持寄存器 if (reg_count 125) { // 单帧最多读 125 个寄存器 pic_modbus_slave_exception(frame[0], func, 0x03); return; } pic_modbus_handle_read(frame[0], start_addr, reg_count); break; case 0x06: // 写单个寄存器 pic_modbus_handle_write_single(frame[0], start_addr, (frame[4] 8) | frame[5]); break; case 0x10: // 写多个寄存器 pic_modbus_handle_write_multi(frame[0], start_addr, reg_count, frame[7]); break; default: pic_modbus_slave_exception(frame[0], func, 0x01); // 非法功能码 break; } }这里的字节拼接(frame[2] 8) | frame[3]是把 Modbus 网络上高字节在前的地址转换成 PIC 内部常用的整型变量千万不能直接用指针强转因为 PIC 是小端强转必然得到错值。0x10 功能码的数据地址要从帧偏移 6 处读取字节计数然后从偏移 7 开始取寄存器值帧结构决定了偏移位置写代码时注释务必写清楚。4.2 主站逻辑的收发状态机做主站比做从站多一件麻烦事发请求之后要等从站回应等待期间如果遇到噪声或从站掉线需要超时重试。主站侧的代码架构我习惯拆成“请求队列 超时状态机”任何时候只有一个请求在飞队列深度 4 到 8 足够用。主站发送 0x03 指令的一个最小实现// 主站构造一个读保持寄存器请求帧 uint8_t pic_modbus_build_read(uint8_t addr, uint16_t start, uint16_t count, uint8_t *frame) { frame[0] addr; frame[1] 0x03; frame[2] (uint8_t)(start 8); frame[3] (uint8_t)(start 0xFF); frame[4] (uint8_t)(count 8); frame[5] (uint8_t)(count 0xFF); uint16_t crc pic_modbus_crc16(frame, 6); frame[6] crc 0xFF; frame[7] crc 8; return 8; // 返回帧长度 }这个组完帧后的发送过程是把 8 字节按顺序写入发送缓冲区通过 TSR 标志位逐字节发送。主站的数据解析逻辑核心是等待响应的超时判断常用周期是 100ms 到 500ms视从站处理速度而定。PIC 片内没有 RTC用定时器轮询计数的场景比较多volatile uint16_t response_timer_ms; // 每 1ms 由定时器中断累加主循环检查超时 uint8_t waiting 1; response_timer_ms 0; while (waiting) { if (rtu_frame_ready) { rtu_frame_ready 0; waiting 0; // 校验 CRC解析响应帧 } if (response_timer_ms 200) { waiting 0; // 超时处理重试或标记故障 } }这个 while 循环不能写在主循环的临界区里因为 PIC 是单线程如果主循环正阻塞在这里其它任务全部暂停。工程上推荐的形态是“主站定时器驱动每次周期尝试推进状态机”但代码复杂度高不少对于不复杂的单主站应用上面的简单循环配合看门狗一样可靠。4.2.1 高低位转换的两种实用写法伺服电机控制、汇川 PLC 这类设备最常用 Modbus RTU 通信它们对寄存器的字节序有约定——通常高字节在前。PIC 内部数据的读写需要手动转换我给出两种典型的写法// 方法一移位组装推荐可读性好 uint16_t value (uint16_t)(buf[0] 8 | buf[1]); // 方法二宏定义适合在多个文件里用到 #define MODBUS_GET_U16(p) (uint16_t)(((uint16_t)(p)[0] 8) | (p)[1]) #define MODBUS_SET_U16(p, v) do { \ (p)[0] (uint8_t)((v) 8); \ (p)[1] (uint8_t)(v); \ } while (0)宏定义的方式省掉了重复函数调用的开销且不占用栈空间但注意宏参数是表达式时不要带副作用否则展开后可能被计算两次。实际项目中如果寄存器数量少直接写函数更清晰因为宏报错定位到行时内容比较晦涩。5. RS-485 方向切换、超时重试与寄存器自检技巧5.1 485 收发方向控制的时序细节PIC 自带的 USART 是 TTL 电平接 RS-485 收发器如 MAX485时需要用 RE/DE 引脚控制方向。从站收到帧应答时要先把 RE/DE 拉到发送模式发送完成后等最后一个字节完全移位出去再拉回接收状态。发送完成的判断不能用 TXIFTXIF 只是表示发送缓冲器空但移位寄存器可能还在逐位输出。正确做法是等待 TRMT 位置 1表示整个 TSR 移位寄存器为空void pic_rs485_send_frame(uint8_t *buf, uint8_t len) { DE 1; // 置为发送模式 for (uint8_t i 0; i len; i) { TXREG buf[i]; // 写入发送缓冲 while (!TRMT); // 等待移位寄存器完全发完 } DE 0; // 拉回接收模式 }注意 DE 拉低前一定要清空发送缓冲和移位寄存器否则最后一个字节的停止位还没发完就被转为接收方向对方会收到残缺帧。速率高时可以在拉低前加一个空循环延时一个字符时间牺牲不到 1ms换来稳定性。5.2 CRC 错帧定位与主机重试策略联调时最常见的现象是主站发指令没回应排查顺序是先确认波特率再确认从站地址然后抓帧头帧尾对比 CRC 值。通用的调试手法是在电脑端用串口调试助手 USB 转 485 直接监听总线抓到的帧拿 CRC 计算器重新算一次如果手算结果和帧尾不符问题在从站组帧如果正确但从站不应答问题在地址或功能码。主站的超时重试逻辑建议遵循“短超时 固定次数”模式。一般超时设 200ms重试 3 次3 次失败后把从站标记为离线但不要一直阻塞总线。主站的轮询周期也要设计好单从站 100ms 轮询一次足够多从站则按地址依次循环不要因某一个从站掉线就不去查询后面的站。5.3 给寄存器表加一个非易失性心跳计数器测试 Modbus 通信是否稳定有个实用技巧分配一个从站寄存器专门做心跳计数主站在每次读数据时检查该寄存器的值是否递增。这个计数器放在 EEPROM 里掉电也不清零每次写一次寄存器就加一用通信帧本身驱动计数变化。// 心跳寄存器回调普通业务内容 uint8_t reg_cb_heartbeat(uint16_t addr, uint16_t *value, uint8_t is_write) { if (is_write) { heartbeat_count; if (heartbeat_count 0xFFFF) heartbeat_count 0; EEPROM_WRITE(0x10, heartbeat_count); // 写入 EEPROM } *value heartbeat_count; return 0; }把 EEPROM 写入放在写寄存器回调里有一个额外好处主站通过 0x06 写这个寄存器就能验证从站 EEPROM 的耐久性同时如果从站第二次上电后心跳值仍然递增说明 EEPROM 读写链路也没问题。这个技巧在正式验收调试中特别有用可以省去反复查数据手册验证 EEPROM 操作代码是否写对的环节。5.4 一帧多寄存器组包时的缓冲区规划主机读多个寄存器时从站回的帧里面数据长度是可变的主站解析响应前必须先从帧头字节 2 拿到字节数再向后读取对应长度的数据。缓冲区大小按最坏情况规划一个从站最大返回 125 个寄存器对应 250 字节数据加上帧头和 CRC 共 255 字节PIC 端数组至少要开 255 字节。这部分 RAM 在 PIC16 上已经不小可以考虑单独定义全局数组而不是放在函数栈里避免栈溢出同时优先使用 bank0 区域降低 BANK 切换开销。5.5 老设备移植时的寄存器地址规约建议如果你在把已有设备接入 Modbus 现场总线寄存器地址分配建议参考行业惯例0x0000 到 0x0050 放运行参数速度、温度、阀位0x0100 到 0x0140 放设定值可读写0x0200 以上放状态字、报警字。这么做的好处是 PLC、组态王、触摸屏做点位表时可以直接套用模板不需要为每个设备单独写通信程序。伺服、变频器厂商也基本遵循这种分段即地址越靠前越偏向只读状态越靠后越偏向可写配置。高位地址统一作为报警区回读时用位掩码解析出具体报警位可读性远好于把所有参数挤在连续地址段里。本文还有配套的精品资源点击获取
返回列表