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)测试:
-
读离散输入(功能码02):读取地址0~2,验证按键状态是否正确。
-
写单个线圈(功能码05):分别写地址3~7,观察继电器和LED是否按预期动作。
-
写多个线圈(功能码15):同时控制多个输出。
8.9 本章核心知识点总结
-
从设备程序设计三要素:初始化、数据映射、请求处理循环。
-
数据映射(modbus_mapping_t) :将Modbus地址空间映射到内存数组,通过
tab_bits和tab_input_bits访问。 -
输入更新时机:在每次收到请求后立即更新,确保数据实时性。
-
输出更新时机:在回复请求后立即根据映射值控制硬件,实现主站写入立即生效。
-
电平极性处理:根据硬件电路将物理电平转换为逻辑值(1/0),并在输出时反向转换。
-
错误处理:区分CRC错误和其他错误,CRC错误可忽略继续接收。
-
FreeRTOS集成 :使用
pvPortMalloc分配内存,任务循环中不能阻塞太久(但Modbus接收可能阻塞,需合理设置超时)。
8.10 扩展思考
-
如何增加保持寄存器?比如设置设备地址、波特率等参数。
-
如何支持广播地址?从设备对广播请求不回复,但需执行写操作。
-
如何处理多个从站共存?每个从站有唯一地址,程序只需关心自身地址。
本章结束
通过本章实战,你应该能够独立完成一个基于libmodbus的开关量传感器从设备程序设计,理解Modbus地址映射、输入输出控制和请求处理的全流程。