Modbus协议+FreeRTOS——协议移植&系统设计
项目的核心架构分为“上位机-从机”两层,上位机(PC/PLC/触摸屏)通过RS485总线与STM32从机通信,从机负责数据采集、外设控制和协议解析,整体框架清晰,模块划分明确,这也是嵌入式开发中“模块化设计”的核心思路——将复杂系统拆分为独立模块,降低开发和调试难度。硬件框图清晰划分了各模块的连接关系:STM32作为核心,通过USART2连接MAX485实现Modbus通信,通过I2C总线连接AH
最近完成了一个基于STM32、Modbus-RTU协议与FreeRTOS的从机数据采集与控制系统开发,整个过程从协议栈移植、硬件选型到系统调试,踩过不少坑,也收获了很多嵌入式开发的实战经验。今天就结合项目设计报告,做一次全面的学习总结,梳理开发思路、核心要点和实战感悟,希望能给同样在学习嵌入式Modbus开发的小伙伴提供一些参考。
一、项目要求
做这个项目的初衷,是想熟练掌握Modbus协议在嵌入式系统中的应用,同时结合FreeRTOS实现任务的精细化管理,解决单线程开发中“阻塞式任务”导致的响应不及时问题。项目核心需求很明确:开发一款基于STM32F103ZE的Modbus-RTU从机设备,实现环境参数采集、远程外设控制,并通过Modbus协议与上位机完成通信,同时保证系统的稳定性和实时性。
结合需求,我们设定了具体的开发目标,也是整个项目的核心验收标准:
- 完成FreeModbus V1.6协议栈的移植与配置,支持RTU模式
- 实现Modbus核心功能码:03(读保持寄存器)、06(写单个寄存器)、16(写多个寄存器)
- 实现温湿度(AHT20)、电压电流功率(INA226)的实时采集,每500ms更新一次寄存器
- 支持通过Modbus命令远程控制LED、蜂鸣器、继电器等外设
- 实现从机地址的EEPROM存储与在线修改,适配多设备组网场景
- 添加OLED实时显示功能,直观查看采集到的各项数据
明确的目标能避免开发过程中“跑偏”,尤其是嵌入式开发,每一个功能点都需要提前规划,否则后期修改成本会很高。
项目的核心架构分为“上位机-从机”两层,上位机(PC/PLC/触摸屏)通过RS485总线与STM32从机通信,从机负责数据采集、外设控制和协议解析,整体框架清晰,模块划分明确,这也是嵌入式开发中“模块化设计”的核心思路——将复杂系统拆分为独立模块,降低开发和调试难度。
2.1 硬件选型与框图设计
硬件选型的核心是“适配需求、性价比优先”,结合项目目标,我们选择了以下核心器件:
- 主控芯片:STM32F103ZE(72MHz主频,足够支撑协议解析和多任务运行,资源充足且性价比高)
- 通信模块:MAX485(实现RS485总线通信,适配Modbus-RTU的远距离传输需求)
- 传感器:AHT20(温湿度采集,精度高、接口简单,I2C通信适配STM32)、INA226(电压电流功率监测,满足工业级精度要求)
- 显示模块:0.96寸OLED(12864分辨率,功耗低,适合嵌入式设备的实时显示)
- 外设:LED、蜂鸣器、继电器(用于远程控制演示,贴合工业控制场景)
硬件框图清晰划分了各模块的连接关系:STM32作为核心,通过USART2连接MAX485实现Modbus通信,通过I2C总线连接AHT20和INA226采集数据,通过GPIO口控制外设,同时驱动OLED显示,整个硬件连接简洁,便于焊接和调试。
2.2 软件架构与FreeRTOS任务规划
软件部分的核心是“FreeRTOS任务管理+Modbus协议栈移植”,我们将整个系统拆分为多个独立任务,按优先级分配资源,避免单线程阻塞,提升系统实时性。软件文件结构采用分层设计,便于后期维护和扩展:
|
text |
FreeRTOS任务优先级分配是关键,我们遵循“紧急任务优先级高、耗时任务优先级低”的原则,具体分配如下:
|
任务 |
优先级 |
执行频率 |
核心作用 |
|
TIM6中断(Modbus T3.5超时) |
最高 |
按需 |
保证Modbus帧解析的准确性,避免帧粘连 |
|
TIM1中断(传感器采样) |
高 |
500ms |
定时采集传感器数据,更新寄存器 |
|
USART2中断(Modbus通信) |
高 |
按需 |
接收上位机命令,发送响应数据 |
|
Modbus协议轮询(eMBPoll()) |
中 |
主循环 |
解析Modbus命令,处理寄存器读写 |
|
OLED显示任务 |
低 |
500ms |
实时显示采集到的温湿度、电压电流数据 |
这种任务分配方式,既保证了核心功能(通信、采样)的实时响应,又避免了低优先级任务抢占资源,确保系统稳定运行。
整个项目的开发重点的是Modbus协议栈移植、寄存器映射设计和FreeRTOS任务调度,这也是嵌入式Modbus开发中最容易踩坑的地方,下面结合实战经验,详细梳理每个模块的开发要点和解决方法。
3.1 FreeModbus协议栈移植
我们选择了FreeModbus V1.6开源协议栈,它的优势是代码精简、适配嵌入式系统、支持RTU/ASCII模式,且开源免费可商用,非常适合新手学习和工业项目使用。移植的核心是“适配硬件底层”,主要修改3个关键文件:
- portserial.c(串口硬件层):实现RS485收发控制和TC中断处理,重点是RS485的收发切换——通过GPIO口控制MAX485的DE和RE引脚,避免发送和接收冲突,这是很多新手容易忽略的点,一旦切换不及时,就会出现丢包问题。
- porttimer.c(定时器层):实现Modbus-RTU的T3.5字符超时定时器,用于判断一帧数据的结束,定时器的配置要精准,否则会导致帧解析错误(比如把两帧数据当成一帧处理)。我们采用TIM6定时器,配置为定时中断,确保超时判断的准确性。
- mbproto.h(协议定义):根据项目需求修改广播地址,默认广播地址是0,我们修改为255,更符合工业现场的使用习惯,便于多设备同时接收广播命令。
移植踩坑总结:刚开始移植后,上位机发送命令,从机无响应,排查后发现是RS485收发切换时机不对,没有在发送数据前置高DE/RE引脚,发送完成后及时置低,修改后问题解决;另外,定时器中断优先级设置过低,导致T3.5超时判断不及时,调整优先级后,帧解析准确率大幅提升。
3.2 寄存器映射设计
Modbus通信的本质是“寄存器读写”,因此寄存器映射设计直接决定了数据交互的合理性和便捷性。我们设计了256个保持寄存器,重点映射了控制类、采集类和配置类数据,核心映射关系如下(精简版,便于使用):
|
寄存器地址 |
名称 |
类型 |
核心说明 |
|
0x0000 |
REG_CTRL |
读写 |
控制寄存器,Bit0-3分别控制LED1、LED2、蜂鸣器、继电器 |
|
0x0001-0x0005 |
采集类寄存器 |
只读 |
依次存储温度(×100)、湿度(×100)、电压(mV)、电流(mA)、功率(mW) |
|
0x00FF |
REG_SLAVE_ADDR |
读写 |
从机地址,1-247可配置,存储在EEPROM中 |
设计技巧:采集类数据采用“放大100倍”存储(比如25.5℃存储为2550),避免浮点数运算,提升系统运行效率;控制寄存器采用位操作,一个寄存器实现多个外设的控制,节省寄存器资源;从机地址存储在EEPROM中,实现掉电保存,避免每次上电都需要重新配置。
3.3 传感器采集与数据处理
传感器采集模块的核心是“精准读取、稳定传输”,我们采用I2C总线连接AHT20和INA226,每500ms通过定时器中断触发一次采样,采样流程如下:
void TIM1_IRQHandler(void)
{
if(__HAL_TIM_GET_FLAG(&htim1, TIM_FLAG_UPDATE))
{
__HAL_TIM_CLEAR_FLAG(&htim1, TIM_FLAG_UPDATE);
// 1. 检查控制寄存器,更新外设输出
CheckAndControlOutputs();
// 2. 读取传感器数据(AHT20+INA226)
app_sensors_sample_once();
// 3. 处理数据,更新保持寄存器
read_value();
}
}
踩坑总结:AHT20传感器需要先初始化校准,否则采集到的数据会异常;INA226的校准寄存器需要根据实际电阻值配置,否则电压电流测量精度会偏差很大;另外,I2C通信容易出现总线阻塞,我们在代码中添加了超时判断,避免程序卡死。
3.4 从机地址管理
多设备组网时,每个从机需要有唯一的地址,因此我们实现了从机地址的EEPROM存储与在线修改功能,核心流程如下:
- 上电初始化时,从EEPROM读取存储的从机地址;
- 判断地址是否有效(1-247),有效则使用该地址,无效则使用默认地址1,并保存到EEPROM;
- 上位机通过功能码06(写单个寄存器)修改REG_SLAVE_ADDR,从机验证地址有效性后,保存到EEPROM,提示重启生效。
这个功能的关键是“EEPROM的读写操作”,需要注意EEPROM的读写时序,避免写操作失败导致地址丢失,同时添加地址验证逻辑,防止非法地址配置。
3.5 EEPROM地址加载/保存
static uint8_t app_eeprom_load_slave_addr(uint8_t *out_addr)
{
uint8_t buf[2] = {0};
// 从EEPROM地址0x00读取2字节:标记位(0xA5) + 地址值
if (!at24c02_read(0x00, buf, 2)) return 0;
// 验证有效性:标记位为0xA5且地址在1-247之间
if (buf[0] == 0xA5 && app_slave_addr_valid(buf[1]))
{
*out_addr = buf[1];
return 1;
}
return 0;
}
static uint8_t app_eeprom_save_slave_addr(uint8_t addr)
{
uint8_t buf[2] = {0xA5, addr}; // 写入标记+地址
return at24c02_write(0x00, buf, 2);
}
保持寄存器回调
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress,
USHORT usNRegs, eMBRegisterMode eMode)
{
eMBErrorCode eStatus = MB_ENOERR;
USHORT iRegIndex;
/* 地址处理:
FreeModbus 在回调中传入的 usAddress 通常为 1 起始(即 1 对应 Modbus 地址 0)。
这里将其转换为 0 起始的数组索引,避免主站读写 0000 时返回异常码 0x02。 */
if( usAddress >= 1 )
{
usAddress--;
}
else
{
return MB_ENOREG;
}
/* 映射范围:保持寄存器 0x0000 - 0x00FF -> usRegHoldingBuf[0..255] */
if((usAddress >= 0x0000) && (usAddress + usNRegs <= 0x0100))
{
if(eMode == MB_REG_WRITE) // 写操作
{
for(iRegIndex = 0; iRegIndex < usNRegs; iRegIndex++)
{
/* Modbus 寄存器为大端:高字节在前、低字节在后 */
USHORT usRegValue = (pucRegBuffer[iRegIndex * 2] << 8) | pucRegBuffer[iRegIndex * 2 + 1];
usRegHoldingBuf[usAddress + iRegIndex] = usRegValue;
if((usAddress + iRegIndex) == REG_CTRL)
{
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_5,
(usRegValue & 0x01) ? GPIO_PIN_RESET : GPIO_PIN_SET);
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5,
(usRegValue & 0x02) ? GPIO_PIN_RESET : GPIO_PIN_SET);
}
else if((usAddress + iRegIndex) == REG_LED)
{
HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8,
(usRegValue & 0x02) ? GPIO_PIN_RESET : GPIO_PIN_SET);
}
else if((usAddress + iRegIndex) == REG_SLAVE_ADDR)
{
/* 写 0x00FF:更新运行地址并持久化到 EEPROM */
uint8_t new_addr = (uint8_t)(usRegValue & 0xFF);
if(app_slave_addr_valid(new_addr))
{
g_addr = new_addr;
usRegHoldingBuf[REG_SLAVE_ADDR] = (USHORT)new_addr;
app_eeprom_save_slave_addr(new_addr);
g_mb_addr_reinit_value = new_addr;
g_mb_addr_reinit_pending = 1;
/* 预留 200ms,避免立即重配导致串口回包被截断 */
g_mb_addr_reinit_due_ms = HAL_GetTick() + 200U;
}
else
{
/* 非法地址写入无效,寄存器回退为当前有效地址 */
usRegHoldingBuf[REG_SLAVE_ADDR] = (USHORT)(uint8_t)g_addr;
}
}
}
}
else // 读操作
{
for(iRegIndex = 0; iRegIndex < usNRegs; iRegIndex++)
{
USHORT usRegValue = usRegHoldingBuf[usAddress + iRegIndex];//usRegValue为数据位数据
pucRegBuffer[iRegIndex * 2] = (UCHAR)(usRegValue >> 8);
pucRegBuffer[iRegIndex * 2 + 1] = (UCHAR)(usRegValue & 0xFF);
}
}
}
else
{
eStatus = MB_ENOREG; // 寄存器地址错误
}
return eStatus;
}
线圈回调
eMBErrorCode eMBRegCoilsCB(UCHAR *pucRegBuffer, USHORT usAddress,
USHORT usNCoils, eMBRegisterMode eMode)
{
eMBErrorCode eStatus = MB_ENOERR;
/* 地址按 1 起始转换为 0 起始 */
if( usAddress >= 1 )
{
usAddress--;
}
else
{
return MB_ENOREG;
}
/* 线圈映射示例:仅映射线圈 0 到 PE5 LED(低电平点亮) */
if( usAddress == 0x0000 && usNCoils >= 1 )
{
if( eMode == MB_REG_WRITE ) // 写线圈
{
// 线圈 0 在 buffer 的 bit0
UCHAR coilOn = pucRegBuffer[0] & 0x01;
// 假设 LED 为低电平点亮
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_5,
coilOn ? GPIO_PIN_RESET : GPIO_PIN_SET);
}
else // 读线圈
{
GPIO_PinState pin = HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_5);
// 低电平视为 ON
if( pin == GPIO_PIN_RESET )
{
pucRegBuffer[0] |= 0x01;
}
else
{
pucRegBuffer[0] &= (UCHAR)~0x01;
}
}
}
else
{
eStatus = MB_ENOREG;
}
return eStatus;
}
输入寄存器回调
eMBErrorCode eMBRegInputCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs)
{
eMBErrorCode eStatus = MB_ENOERR;
/* 地址同样按 1 起始转换为 0 起始 */
if( usAddress >= 1 )
{
usAddress--;
}
else
{
return MB_ENOREG;
}
/* 输入寄存器示例:0x0000 - 0x000F,这里简单返回 0 */
if((usAddress >= 0x0000) && (usAddress + usNRegs <= 0x0010))
{
for(USHORT i = 0; i < usNRegs; i++)
{
pucRegBuffer[i * 2] = 0;
pucRegBuffer[i * 2 + 1] = 0;
}
}
else
{
eStatus = MB_ENOREG;
}
return eStatus;
}
离散输入回调
eMBErrorCode eMBRegDiscreteCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNDiscrete)
{
eMBErrorCode eStatus = MB_ENOERR;
/* 地址按 1 起始转换为 0 起始 */
if( usAddress >= 1 )
{
usAddress--;
}
else
{
return MB_ENOREG;
}
/* 离散输入示例:0x0000 - 0x000F,这里简单返回 0 */
if((usAddress >= 0x0000) && (usAddress + usNDiscrete <= 0x0010))
{
for(USHORT i = 0; i < (usNDiscrete + 7) / 8; i++)
{
pucRegBuffer[i] = 0;
}
}
else
{
eStatus = MB_ENOREG;
}
return eStatus;
}
寄存器地址数据更新
void read_value(void)
{
int32_t t = g_temp_x100;
int32_t h = g_hum_x100;
int32_t v = g_bus_mv;
int32_t i = g_cur_ma;
int32_t p = g_pwr_mw;
int32_t d = g_addr;
if (h < 0) h = 0;
if (h > 10000) h = 10000;
if (v < 0) v = 0;
if (t < -32768) t = -32768;
if (t > 32767) t = 32767;
if (i < -32768) i = -32768;
if (i > 32767) i = 32767;
if (p < 0) p = 0;
if (v > 65535) v = 65535;
if (p > 65535) p = 65535;
if (d < 1) d = 1;
if (d > 247) d = 247;
usRegHoldingBuf[REG_TEMPERATURE] = (USHORT)(int16_t)t;
usRegHoldingBuf[REG_HUMIDITY] = (USHORT)h;
usRegHoldingBuf[REG_VOLTAGE] = (USHORT)v;
usRegHoldingBuf[REG_CURRENT] = (USHORT)(int16_t)i;
usRegHoldingBuf[REG_POWER] = (USHORT)p;
usRegHoldingBuf[REG_SLAVE_ADDR] = (USHORT)d;
}
项目完成后,我们对系统的资源占用、实时性和通信性能进行了分析,进一步优化系统性能:
4.1 资源占用分析
STM32F103ZE的Flash为512KB,RAM为64KB,系统资源占用情况如下:
- Flash占用:10476字节,约占2%,资源充足,可预留空间用于后续功能扩展;
- RAM占用:228字节(已初始化)+2044字节(未初始化),约占3.5%,无内存溢出风险;
- 栈使用:约512字节,合理分配,避免栈溢出。
5.2 实时性与通信性能
- 实时性:中断响应时间<1μs,传感器采集最大耗时~5ms,Modbus帧处理<2ms,完全满足实时性需求;
- 通信性能:支持32台从机同时组网,最大通信距离1200米(RS485标准),单帧最大可读取125个寄存器,响应时间<10ms,适配工业现场的远距离、多设备通信需求。
5.1 开发感悟
通过这个项目,我对嵌入式开发、Modbus协议和FreeRTOS的理解更加深刻,也总结了一些实战经验:
- 模块化设计是嵌入式开发的核心,合理划分模块,不仅能降低开发难度,还能便于后期维护和扩展;
- 协议栈移植的关键是“适配硬件底层”,一定要熟悉串口、定时器的配置,理解协议的核心逻辑,遇到问题多排查硬件和底层代码;
- FreeRTOS任务调度的核心是“优先级分配”,要根据任务的紧急程度合理分配优先级,避免任务阻塞和资源抢占;
- 测试是验证项目的关键,一定要全面、细致,不仅要测试正常场景,还要测试异常场景(比如非法地址、高频率轮询),确保系统稳定可靠。
这次Modbus+FreeRTOS项目开发,是一次从理论到实践的完整落地,不仅掌握了Modbus协议栈移植、FreeRTOS任务调度、传感器采集等核心技能,还学会了如何排查问题、优化系统性能。嵌入式开发是一个“不断踩坑、不断进步”的过程,每一个问题的解决,都是一次能力的提升。
希望这篇学习总结能给正在学习嵌入式Modbus开发的小伙伴提供一些参考,也欢迎大家留言交流开发过程中遇到的问题和经验,一起进步、一起成长!
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐


所有评论(0)