第八章:开关量传感器从设备程序设计(实战案例)

8.1 项目概述

本章以一块STM32开发板为例,设计一个开关量传感器从设备。该设备具有:

  • 3个按键输入(作为离散输入 DI)

  • 2个继电器输出(作为线圈 DO,控制外部设备)

  • 3个LED输出(作为线圈 DO)

设备通过RS485接口以Modbus RTU协议与主站通信。

8.2 硬件引脚定义

根据提供的表格,硬件连接如下:

功能 引脚 电平逻辑 Modbus类型
KEY1 PA3 低电平表示被按下 DI
KEY2 PA4 低电平表示被按下 DI
KEY3 PA5 低电平表示被按下 DI
K1_CTRL(继电器1) PB5 高电平使能继电器 DO
K2_CTRL(继电器2) PB4 高电平使能继电器 DO
LED1 PB11 低电平发光 DO
LED2 PB12 低电平发光 DO
LED3 PB13 低电平发光 DO

注意:电平逻辑不一致,编程时需要根据实际情况进行转换。

8.3 Modbus寄存器地址分配

根据需求,设备地址设为01H,寄存器分配如下:

设备地址 寄存器地址 寄存器类别 用途 描述(位值)
01H 0000H DI 读取按键KEY1 1-被按下
0001H DI 读取按键KEY2 1-被按下
0002H DI 读取按键KEY3 1-被按下
0003H DO 控制继电器1(K1) 1-吸合
0004H DO 控制继电器2(K2) 1-吸合
0005H DO 控制LED1 1-亮
0006H DO 控制LED2 1-亮
0007H DO 控制LED3 1-亮

注意:这里地址是Modbus协议中的地址(从0开始),对应PLC组态地址:DI为10001~10003,DO为00001~00005(但实际PLC地址会偏移1,这里暂不展开)。

8.4 代码整体结构分析

代码基于STM32 HAL库和FreeRTOS,使用libmodbus库实现Modbus RTU从站。

8.4.1 头文件与宏定义
cs 复制代码
#include "FreeRTOS.h"
#include "task.h"
#include "main.h"
#include "cmsis_os.h"
#include "uart_device.h"
#include "modbus.h"
#include "errno.h"

#define USE_SWITCH_SENSOR 1
#ifdef USE_SWITCH_SENSOR
#define SLAVE_ADDR 1
#define NB_BITS             5   // 线圈数量(继电器2 + LED3 = 5)
#define NB_INPUT_BITS       3   // 离散输入数量(按键3)
#define NB_REGISTERS        0   // 保持寄存器数量(本示例未使用)
#define NB_INPUT_REGISTERS  0   // 输入寄存器数量(本示例未使用)
#endif

解释

  • NB_BITS = 5:对应地址0003H~0007H,共5个线圈输出。

  • NB_INPUT_BITS = 3:对应地址0000H~0002H,共3个离散输入。

  • 寄存器类数据未使用,设置为0。

8.4.2 FreeRTOS任务创建
cs 复制代码
osThreadId_t defaultTaskHandle;
const osThreadAttr_t defaultTask_attributes = {
  .name = "defaultTask",
  .stack_size = 128 * 4,
  .priority = (osPriority_t) osPriorityNormal,
};

void MX_FREERTOS_Init(void) {
    defaultTaskHandle = osThreadNew(StartDefaultTask, NULL, &defaultTask_attributes);
}

创建一个名为defaultTask的任务,堆栈512字节(128*4),优先级正常。

8.4.3 任务函数 StartDefaultTask

这是核心的Modbus从设备处理循环。我们分段解析。

8.4.3.1 初始化Modbus上下文
cs 复制代码
GPIO_PinState val;
uint8_t *query;
modbus_t *ctx;
int rc;
modbus_mapping_t *mb_mapping;

ctx = modbus_new_st_rtu("uart1", 115200, 'N', 8, 1);
modbus_set_slave(ctx, SLAVE_ADDR);
query = pvPortMalloc(MODBUS_RTU_MAX_ADU_LENGTH);
  • modbus_new_st_rtu:自定义函数,标准libmodbus为modbus_new_rtu。作用:创建RTU上下文,指定串口设备名("uart1")、波特率115200、无校验、8数据位、1停止位。

  • modbus_set_slave(ctx, SLAVE_ADDR):设置从站地址为1。

  • pvPortMalloc:FreeRTOS的内存分配函数,为接收缓冲区分配内存,大小MODBUS_RTU_MAX_ADU_LENGTH(通常256字节)。

8.4.3.2 创建数据映射
cs 复制代码
mb_mapping = modbus_mapping_new_start_address(0, NB_BITS,        // 线圈起始0,数量5
                                               0, NB_INPUT_BITS,  // 离散输入起始0,数量3
                                               0, NB_REGISTERS,   // 输入寄存器起始0,数量0
                                               0, NB_INPUT_REGISTERS); // 保持寄存器起始0,数量0
  • modbus_mapping_new_start_address 是libmodbus提供的函数,用于创建映射结构体并分配内存。

  • 线圈:从地址0开始,共5个。

  • 离散输入:从地址0开始,共3个。

  • 其他寄存器未使用,数量0。

8.4.3.3 连接串口
cs 复制代码
rc = modbus_connect(ctx);
if (rc == -1) {
    modbus_free(ctx);
    vTaskDelete(NULL);
}
  • 打开串口设备,配置波特率等。失败则释放资源并删除任务。
8.4.3.4 主循环:接收请求 → 更新输入 → 回复 → 更新输出
cs 复制代码
for (;;) {
    do {
        rc = modbus_receive(ctx, query);
    } while (rc == 0);  // 过滤掉查询(可能是广播或过滤后的请求)

    if (rc == -1 && errno != EMBBADCRC) {
        continue;  // 非CRC错误则跳过本次循环(实际应该处理重连等)
    }

    // 更新输入寄存器(读取按键)
#ifdef USE_SWITCH_SENSOR
    /* key1 */
    val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_3);
    mb_mapping->tab_input_bits[0] = (val == GPIO_PIN_RESET) ? 1 : 0;

    /* key2 */
    val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_4);
    mb_mapping->tab_input_bits[1] = (val == GPIO_PIN_RESET) ? 1 : 0;

    /* key3 */
    val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_5);
    mb_mapping->tab_input_bits[2] = (val == GPIO_PIN_RESET) ? 1 : 0;
#endif

    // 处理请求并发送响应
    rc = modbus_reply(ctx, query, rc, mb_mapping);
    if (rc == -1) {
        // 错误处理(可忽略)
    }

    // 根据线圈状态更新物理输出
#ifdef USE_SWITCH_SENSOR
    /* 继电器1 */
    if (mb_mapping->tab_bits[0])  // 地址0003H对应tab_bits[0]
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_SET);   // 高电平吸合
    else
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_5, GPIO_PIN_RESET);

    /* 继电器2 */
    if (mb_mapping->tab_bits[1])  // 地址0004H对应tab_bits[1]
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_SET);
    else
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_RESET);

    /* LED1 */
    if (mb_mapping->tab_bits[2])  // 地址0005H对应tab_bits[2]
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_RESET); // 低电平亮
    else
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_11, GPIO_PIN_SET);

    /* LED2 */
    if (mb_mapping->tab_bits[3])  // 地址0006H对应tab_bits[3]
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET);
    else
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET);

    /* LED3 */
    if (mb_mapping->tab_bits[4])  // 地址0007H对应tab_bits[4]
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_RESET);
    else
        HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_SET);
#endif
}
8.4.3.5 清理资源
cs 复制代码
modbus_mapping_free(mb_mapping);
vPortFree(query);
modbus_close(ctx);
modbus_free(ctx);
vTaskDelete(NULL);

8.5 关键细节深入解析

8.5.1 输入读取与电平转换

按键电路通常采用上拉电阻,按键按下时引脚为低电平。因此代码中:

cs 复制代码
val = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_3);
mb_mapping->tab_input_bits[0] = (val == GPIO_PIN_RESET) ? 1 : 0;
  • GPIO_PIN_RESET 表示低电平。按下时,读到的值为GPIO_PIN_RESET,我们将其映射为1(表示按下)。这符合寄存器说明中"1-被按下"的定义。
8.5.2 输出控制与电平转换
  • 继电器控制:K1_CTRL接PB5,高电平使能继电器。所以当tab_bits[0]为1时,我们输出高电平;为0时输出低电平。

  • LED控制:LED共阳或共阴设计,这里采用低电平点亮。所以当tab_bits[2]为1(要求LED亮)时,输出低电平GPIO_PIN_RESET;否则输出高电平GPIO_PIN_SET

注意:代码中if (mb_mapping->tab_bits[2])条件成立(为1)时,执行HAL_GPIO_WritePin(..., GPIO_PIN_RESET),符合预期。

8.5.3 线圈与离散输入的地址对应
  • tab_input_bits数组索引0对应Modbus地址0x0000(DI KEY1),索引1对应0x0001(DI KEY2),索引2对应0x0002(DI KEY3)。

  • tab_bits数组索引0对应Modbus地址0x0003(DO 继电器1),索引1对应0x0004(DO 继电器2),索引2对应0x0005(DO LED1),索引3对应0x0006(DO LED2),索引4对应0x0007(DO LED3)。

这种对应关系由modbus_mapping_new_start_address(0, NB_BITS, 0, NB_INPUT_BITS, ...)决定:线圈从地址0开始分配,离散输入也从地址0开始分配,但它们是两个独立的地址空间,在Modbus协议中通过功能码区分。例如:

  • 读线圈(功能码0x01)使用线圈地址空间,地址0x0003对应tab_bits[0]

  • 读离散输入(功能码0x02)使用离散输入地址空间,地址0x0000对应tab_input_bits[0]

8.5.4 接收过滤循环
cs 复制代码
do {
    rc = modbus_receive(ctx, query);
} while (rc == 0);

modbus_receive返回0表示接收到的请求被过滤(例如广播地址,从设备不回应)。这里循环直到收到一个有效请求(rc>0)或错误(rc==-1)。

8.5.5 错误处理
cs 复制代码
if (rc == -1 && errno != EMBBADCRC) {
    continue;
}
  • 如果错误是CRC错误(EMBBADCRC),则不退出,继续接收下一个请求(因为可能是线路干扰)。其他错误(如连接断开)则跳过本次循环,但实际应处理重连逻辑,这里简化了。
8.5.6 更新输入和输出时机
  • 输入读取:在每次收到有效请求后立即更新,确保主站读到最新按键状态。

  • 输出更新:在回复请求后更新,这样主站写入线圈后,硬件立即响应。注意,即使没有收到写请求,输出也可能因之前的状态保持不变。

8.6 完整点表回顾

根据代码和硬件,最终的点表可以总结为:

类型 Modbus地址 PLC地址(常见) 名称 访问 说明
离散输入 0x0000 10001 KEY1 只读 1=按下
离散输入 0x0001 10002 KEY2 只读 1=按下
离散输入 0x0002 10003 KEY3 只读 1=按下
线圈 0x0003 00004 继电器1 读写 1=吸合
线圈 0x0004 00005 继电器2 读写 1=吸合
线圈 0x0005 00006 LED1 读写 1=亮
线圈 0x0006 00007 LED2 读写 1=亮
线圈 0x0007 00008 LED3 读写 1=亮

注意:PLC地址通常从1开始编号,且用前缀表示类型。例如线圈00001对应Modbus地址0x0000,这里为了对齐,我们将线圈地址0x0003映射到PLC地址00004,是因为PLC地址1对应Modbus地址0,所以偏移1。读者在实际应用中需注意组态软件的地址偏移习惯。

8.7 程序执行流程图

cs 复制代码
任务启动
  ↓
创建Modbus上下文
  ↓
设置从站地址
  ↓
创建数据映射
  ↓
连接串口
  ↓
主循环
  ├→ 接收请求
  ├→ 读取按键状态 → 更新tab_input_bits
  ├→ 回复响应(modbus_reply)
  ├→ 根据tab_bits更新继电器/LED
  └→ 继续循环

8.8 调试与验证

使用Modbus主站软件(如Modbus Poll)测试:

  1. 读离散输入(功能码02):读取地址0~2,验证按键状态是否正确。

  2. 写单个线圈(功能码05):分别写地址3~7,观察继电器和LED是否按预期动作。

  3. 写多个线圈(功能码15):同时控制多个输出。

8.9 本章核心知识点总结

  1. 从设备程序设计三要素:初始化、数据映射、请求处理循环。

  2. 数据映射(modbus_mapping_t) :将Modbus地址空间映射到内存数组,通过tab_bitstab_input_bits访问。

  3. 输入更新时机:在每次收到请求后立即更新,确保数据实时性。

  4. 输出更新时机:在回复请求后立即根据映射值控制硬件,实现主站写入立即生效。

  5. 电平极性处理:根据硬件电路将物理电平转换为逻辑值(1/0),并在输出时反向转换。

  6. 错误处理:区分CRC错误和其他错误,CRC错误可忽略继续接收。

  7. FreeRTOS集成 :使用pvPortMalloc分配内存,任务循环中不能阻塞太久(但Modbus接收可能阻塞,需合理设置超时)。

8.10 扩展思考

  • 如何增加保持寄存器?比如设置设备地址、波特率等参数。

  • 如何支持广播地址?从设备对广播请求不回复,但需执行写操作。

  • 如何处理多个从站共存?每个从站有唯一地址,程序只需关心自身地址。


本章结束
通过本章实战,你应该能够独立完成一个基于libmodbus的开关量传感器从设备程序设计,理解Modbus地址映射、输入输出控制和请求处理的全流程。

相关推荐
Hello_Embed8 天前
libmodbus 移植 STM32(USB 串口后端篇)
笔记·stm32·单片机·嵌入式·freertos·libmodbus
嵌入式×边缘AI:打怪升级日志12 天前
[特殊字符] libmodbus RTU 源码情景分析 - 发送请求
数据库·libmodbus
Hello_Embed16 天前
libmodbus 源码分析(发送请求篇)
笔记·单片机·嵌入式·freertos·libmodbus
sakabu1 年前
libmodbus编程应用(超详细源码讲解+移植到stm32)
笔记·学习·开源协议·modbus协议·libmodbus
初级代码游戏1 年前
libmodbus:写一个modbusTCP服务
modbus·服务·libmodbus