最近完成了一个基于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
Project/
├── Core/
(核心驱动层)
│   ├── Inc/(头文件):main.h、gpio.h、usart.h、tim.h
│   └── Src/(源文件):main.c、gpio.c、usart.c、tim.c
├── FreeModbus/(协议栈层)
│   ├── 核心文件:mb.h、mb.c、mbrtu.c等
│   └── port/(移植文件):portserial.c、porttimer.c等
├── Drivers/(外设驱动层)
│   ├── atk_aht20/、ina226/、OLED/(各外设驱动)
└── README.md(项目说明)

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存储与在线修改功能,核心流程如下:

  1. 上电初始化时,从EEPROM读取存储的从机地址;
  1. 判断地址是否有效(1-247),有效则使用该地址,无效则使用默认地址1,并保存到EEPROM;
  1. 上位机通过功能码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开发的小伙伴提供一些参考,也欢迎大家留言交流开发过程中遇到的问题和经验,一起进步、一起成长!

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐