第十二章:上位机访问多个传感器(事件驱动的网关设计)

12.1 本章目标与背景

在前一章(第十一章)中,我们设计了H5主控作为Modbus网关,通过多个任务周期性地与传感器交互,并将数据汇总到全局映射,响应PC请求。但那种设计有一个缺点:输出(DO)的更新是周期性的,即每个client任务每隔固定时间(如500ms)才将映射中的DO值写入传感器。如果PC频繁修改DO,从修改到实际生效可能会有几百毫秒的延迟。

本章引入一种事件驱动的改进 :当PC通过Server任务修改了映射中的DO值时,Server任务立即通知对应的client任务,client任务马上将新值写入传感器,实现近乎实时的输出更新。同时,输入数据(DI、AI)仍周期性读取。

12.2 硬件连接与任务划分

硬件连接与第十一章相同:

  • H5通过USB串口与PC连接(PC作为Modbus主站,H5地址为1)。

  • CH1(UART2)挂载两个传感器:开关量传感器(地址1)和环境监测传感器(地址2)。

  • CH2(UART4)挂载温湿度传感器(地址3)。

任务划分如下(新增信号量和锁):

cs 复制代码
text

任务1:Server任务(LibmodbusServerTask)
    处理PC的Modbus请求,直接读写全局映射g_mb_mapping。
    检测映射中DO区域的变化,释放对应的二进制信号量通知client任务。

任务2:CH1开关量Client任务(LibmodbusCH1SwitchClientTask)
    使用共享的CH1上下文g_ch1_ctx(需加锁)与传感器1通信。
    周期性读取按键(DI)并更新映射。
    等待信号量g_xBinarySemaphoreSwitch,一旦获得立即将映射中对应DO写入传感器。

任务3:CH1环境监测Client任务(LibmodbusCH1ENVClientTask)
    同样使用共享的CH1上下文,与传感器2通信。
    周期性读取ADC值(AI)并更新映射。
    等待信号量g_xBinarySemaphoreENV,获得后写入对应DO。

任务4:CH2温湿度Client任务(LibmodbusCH2TempHumiClientTask)
    使用独立的CH2上下文(因为只有它使用UART4),与传感器3通信。
    周期性读取温湿度(AI)并更新映射。
    等待信号量g_xBinarySemaphoreTempHumi,获得后写入对应DO。

关键点 :CH1上的两个传感器共享同一个串口,因此它们的client任务必须互斥访问g_ch1_ctx,使用互斥信号量g_ch1_lock保护。

12.3 新增的全局变量与初始化

app_freertos.c中,新增了以下全局变量:

cs 复制代码
c

static SemaphoreHandle_t g_xBinarySemaphoreSwitch;   // 通知开关量传感器写DO
static SemaphoreHandle_t g_xBinarySemaphoreENV;      // 通知环境传感器写DO
static SemaphoreHandle_t g_xBinarySemaphoreTempHumi; // 通知温湿度传感器写DO
static SemaphoreHandle_t g_ch1_lock;                  // 保护CH1共享上下文的互斥锁

modbus_mapping_t *g_mb_mapping;       // 全局映射,供所有任务访问
static modbus_t *g_ch1_ctx;            // CH1共享的modbus上下文(UART2)
在MX_FREERTOS_Init中创建这些同步对象:
cs 复制代码
c

g_xBinarySemaphoreSwitch = xSemaphoreCreateBinary();
g_xBinarySemaphoreENV    = xSemaphoreCreateBinary();
g_xBinarySemaphoreTempHumi = xSemaphoreCreateBinary();
g_ch1_lock = xSemaphoreCreateMutex();

// 初始化CH1共享上下文(只创建一次)
g_ch1_ctx = modbus_new_st_rtu("uart2", 115200, 'N', 8, 1);
modbus_set_slave(g_ch1_ctx, 1);  // 默认从站地址为1
if (modbus_connect(g_ch1_ctx) == -1) {
    // 错误处理
    modbus_free(g_ch1_ctx);
    return;
}

12.4 Server任务详解(LibmodbusServerTask)

这是与PC通信的任务,负责接收PC的Modbus请求并回复。代码中有几个新增的关键点。

12.4.1 创建映射
cs 复制代码
c

mb_mapping = modbus_mapping_new_start_address(0,
                                              16,  /* DO数量:H5自身1个 + 开关量5 + 环境5 + 温湿度5 */
                                              0,
                                              3,   /* DI数量:开关量3个 */
                                              0,
                                              0,   /* AO数量:0 */
                                              0,
                                              4);  /* AI数量:环境2 + 温湿度2 */
g_mb_mapping = mb_mapping;  // 赋值给全局指针

映射布局与第十一章一致,但这里显式注明了数量。

12.4.2 备份DO数组
cs 复制代码
c

uint8_t do_registers_backup[16];
memset(do_registers_backup, 0, sizeof(do_registers_backup));

这个数组用于保存上一次的DO值,以便检测变化。

12.4.3 接收PC请求并回复
cs 复制代码
c

for (;;) {
    do {
        rc = modbus_receive(ctx, query);
    } while (rc == 0);   // 过滤广播等

    if (rc == -1 && errno != EMBBADCRC) {
        continue;        // 非CRC错误则跳过
    }

    rc = modbus_reply(ctx, query, rc, mb_mapping);
    if (rc == -1) {
        // 错误处理
    }

这部分是标准的server循环。

12.4.4 检测DO变化并释放信号量
cs 复制代码
c

// 比较开关量传感器对应的DO区域(H5地址1~5)
if (memcmp(&do_registers_backup[1], &mb_mapping->tab_bits[1], 5) != 0)
{
    xSemaphoreGive(g_xBinarySemaphoreSwitch);  // 唤醒任务2
}
// 比较环境传感器对应的DO区域(H5地址6~10)
if (memcmp(&do_registers_backup[6], &mb_mapping->tab_bits[6], 5) != 0)
{
    xSemaphoreGive(g_xBinarySemaphoreENV);     // 唤醒任务3
}
// 比较温湿度传感器对应的DO区域(H5地址11~15)
if (memcmp(&do_registers_backup[11], &mb_mapping->tab_bits[11], 5) != 0)
{
    xSemaphoreGive(g_xBinarySemaphoreTempHumi); // 唤醒任务4
}

// 更新备份
memcpy(do_registers_backup, mb_mapping->tab_bits, 16);

解释

  • 每次PC通过modbus_reply修改了映射中的线圈(写操作)后,mb_mapping->tab_bits已经被更新。

  • 我们比较备份数组与当前tab_bits中对应传感器的5个线圈,如果有任何不同,说明PC修改了该传感器的输出,于是释放对应的二进制信号量。

  • 注意:do_registers_backup[0]对应H5自身的LED,不通知任何client任务,而是直接在后面处理:

    cs 复制代码
    c
    
    if (mb_mapping->tab_bits[0])
        HAL_GPIO_WritePin(GPIOC, GPIO_PIN_12, GPIO_PIN_RESET); // 低电平亮(假设)
    else
        HAL_GPIO_WritePin(GPIOC, GPIO_PIN_12, GPIO_PIN_SET);

这样,每当PC写某个传感器的DO,对应的client任务就会立即被唤醒去执行写操作,而不是等待下一个周期。

12.5 CH1共享上下文与互斥锁

因为CH1上挂载了两个传感器,且它们使用同一个串口(UART2),所以必须串行化访问。我们创建了一个全局的g_ch1_ctx,并在访问前获取互斥锁g_ch1_lock

12.5.1 初始化时的首次读取

在任务2和任务3启动时,它们首先尝试读取传感器的当前DO状态,以同步映射。注意它们都使用同一个锁:

cs 复制代码
c

// 在LibmodbusCH1SwitchClientTask开头:
xSemaphoreTake(g_ch1_lock, portMAX_DELAY);
modbus_set_slave(ctx, 1);  // 传感器1
rc = modbus_read_bits(ctx, 0, 5, bits);
if (rc == 5)
{
    memcpy(&g_mb_mapping->tab_bits[1], bits, 5);
}
xSemaphoreGive(g_ch1_lock);

类似地,环境传感器任务也会读取其DO状态并更新映射的相应位置(地址6~10)。

为什么要在初始化时读取DO?

为了确保全局映射中的DO值与传感器实际输出一致。因为传感器可能在H5重启前已有状态,或者之前被其他任务修改过,读取当前值可以同步。

12.5.2 周期性读取输入(DI/AI)

每个client任务会周期性地(通过循环中的无条件执行部分)读取传感器的输入数据,并更新映射。这部分也需要加锁,因为使用了共享的g_ch1_ctx

例如,开关量任务读取按键:

cs 复制代码
c

xSemaphoreTake(g_ch1_lock, portMAX_DELAY);
modbus_set_slave(ctx, 1);
rc = modbus_read_input_bits(ctx, 0, 3, bits);
if (rc == 3)
{
    sprintf(buf, "SWITCH keys: %d %d %d", bits[0], bits[1], bits[2]);
    Draw_String(0, 0, buf, 0xff0000, 0);
    memcpy(g_mb_mapping->tab_input_bits, bits, 3);  // 更新DI
}
xSemaphoreGive(g_ch1_lock);

注意,读取完成后立即释放锁,避免长时间占用。

12.5.3 事件驱动的写DO

任务的循环中,除了周期性读取输入外,还等待对应的二进制信号量:

cs 复制代码
c

if (xSemaphoreTake(g_xBinarySemaphoreSwitch, 500) == pdTRUE)
{
    xSemaphoreTake(g_ch1_lock, portMAX_DELAY);
    modbus_set_slave(ctx, 1);
    rc = modbus_write_bits(ctx, 0, 5, &g_mb_mapping->tab_bits[1]);
    xSemaphoreGive(g_ch1_lock);
}

这里使用了超时500ms,如果500ms内没有信号量,任务会继续执行周期性读取部分(因为循环会继续,读取部分是无条件执行的)。注意,周期性读取并不依赖信号量,它每次循环都会执行(上面读取按键的代码在信号量检查之前还是之后?看代码顺序:它先执行读取按键,然后等待信号量。所以每次循环都会读取按键,然后可能被信号量阻塞最多500ms。这样设计保证了即使没有写事件,输入数据也能定期更新。

为什么要有超时?

如果没有超时,任务就会一直阻塞在信号量上,无法周期性读取输入。设置超时500ms,保证最多500ms就会执行一次读取,同时如果有信号量,则立即处理写操作。

12.6 各client任务的具体分析

12.6.1 LibmodbusCH1SwitchClientTask
  • 初始化时读取传感器1的DO状态,更新映射的tab_bits[1~5]

  • 循环中:

    1. 加锁读取传感器1的离散输入(按键),更新映射的tab_input_bits[0~2],并显示在LCD上。

    2. 等待开关量信号量(超时500ms)。如果获得,则加锁将映射中对应DO(地址1~5)写入传感器1。

  • 注意:这里没有周期性读取传感器1的DO状态(因为DO是由本任务写入的,理论上不需要读回,除非担心其他主站修改)。如果传感器支持读回,也可以添加。

12.6.2 LibmodbusCH1ENVClientTask

类似,但读取的是输入寄存器(ADC值),更新映射的tab_input_registers[0~1],并等待环境信号量后写入DO(地址6~10)。

12.6.3 LibmodbusCH2TempHumiClientTask
  • 使用独立的上下文(UART4),无需锁。

  • 初始化时读取传感器3的DO状态,更新映射的tab_bits[11~15]

  • 循环中:

    1. 读取温湿度输入寄存器,更新映射的tab_input_registers[2~3],并显示。

    2. 等待温湿度信号量(超时500ms),获得后直接写入DO(地址11~15)。

12.7 二进制信号量的作用与注意事项

  • 二进制信号量在xSemaphoreGive之前,如果已经有信号量积累(例如多次PC写),但二进制信号量不支持计数,只会保持"已给出"状态。因此如果连续多次写,第一次写后信号量被消耗,第二次写会再次给出,不会丢失事件。但如果写事件发生时任务正在处理上次事件,信号量会保持有效,任务下次等待时会立即获得。这种机制足够应对常见情况。

  • 注意:xSemaphoreGive可以在中断或任务中调用,这里是在Server任务中调用,是允许的。

  • 初始化时信号量应为"空"状态,xSemaphoreCreateBinary创建的信号量初始为0。

12.8 数据流与同步示意图

12.9 与第十一章的对比

cs 复制代码
text

PC (Modbus主站)
    │ 读写请求
    ▼
任务1 (Server)
    ├─ 读取/更新 g_mb_mapping
    ├─ 检测DO变化 → 释放信号量
    └─ 回复PC

g_mb_mapping (全局)
    ├─ DI[0~2] (开关量按键)
    ├─ AI[0~1] (环境ADC)
    ├─ AI[2~3] (温湿度)
    ├─ DO[1~5] (开关量输出)
    ├─ DO[6~10] (环境输出)
    └─ DO[11~15] (温湿度输出)

任务2 (开关量Client)   <-- 信号量Switch
    ├─ 加锁 → 读DI → 更新映射 → 显示 → 解锁
    └─ 等待信号量 → 加锁 → 写DO → 解锁

任务3 (环境Client)     <-- 信号量ENV
    ├─ 加锁 → 读AI → 更新映射 → 显示 → 解锁
    └─ 等待信号量 → 加锁 → 写DO → 解锁

任务4 (温湿度Client)   <-- 信号量TempHumi
    ├─ 读AI → 更新映射 → 显示
    └─ 等待信号量 → 写DO
特性 第十一章(周期性) 第十二章(事件驱动)
输出更新 每个client任务周期(如500ms)写入传感器 PC修改后立即通过信号量触发写入,实时性好
输入读取 周期读取 周期读取(与输出分离)
共享串口 每个client任务独立创建上下文(可能冲突) 使用同一上下文,加锁保护,避免冲突
同步机制 二进制信号量+互斥锁
适用场景 输出变化不频繁 输出变化频繁,要求实时响应

12.10 常见问题与注意事项

  1. 为什么需要互斥锁?

    因为CH1上的两个client任务可能同时操作同一个串口(例如任务2正在读取时,任务3也尝试读取),导致数据错乱。互斥锁保证同一时间只有一个任务使用该串口

  2. 二进制信号量是否足够?

    对于写事件,二进制信号量是合适的,因为即使多个写事件连续发生,只要任务被唤醒后读取最新的DO值并写入,就能覆盖所有变化。如果要求每个写事件都精确触发一次写,可以考虑计数信号量,但Modbus写通常不需要如此精确。

  3. 初始化时读取DO的作用?

    保证映射中的DO初始值与传感器实际状态一致,避免PC未写之前映射中的默认值与传感器实际输出不符,导致后续信号量误判(备份比较时会认为无变化)。

  4. 为什么Server任务要备份DO数组?

    因为PC可能只修改了部分DO,我们需要精确知道哪些传感器的DO区域发生了变化,从而只唤醒对应的client任务,避免不必要的唤醒。

  5. LCD显示的互斥?

    代码中Draw_String函数内部是否使用了互斥?从之前的章节可知,Draw_String应使用互斥信号量保护,否则多个任务同时写LCD会导致花屏。但本章代码中未显式包含LCD互斥。

故事场景:业主远程控制三个住户

角色

  • 业主(PC):通过手机App(Modbus主站)发送指令。

  • 物业中心(H5主控):有一块大屏幕(LCD),四个工作人员(任务)。

  • 住户1(开关量传感器):家里有3个开关(按键)和5盏灯(继电器+LED)。

  • 住户2(环境监测传感器):家里有光敏电阻和可调电阻(ADC),还有5个蜂鸣器/LED。

  • 住户3(温湿度传感器):家里有温湿度计,还有5个蜂鸣器/LED。


第一步:初始化------物业中心准备就绪

物业中心刚上班,做了几件事:

  1. 挂起一块大黑板(全局映射 g_mb_mapping),上面画了三个区域:

    • 左边记住户1的3个开关状态(DI 0~2)。

    • 中间记住户2的两个ADC值(AI 0~1)和住户3的温湿度(AI 2~3)。

    • 右边记16个灯的开关计划(DO 0~15),其中:

      • 第1个灯是物业自己的灯(DO0)。

      • 第2~6个灯是住户1的(DO1~5)。

      • 第7~11个灯是住户2的(DO6~10)。

      • 第12~16个灯是住户3的(DO11~15)。

  2. 给四个工作人员分配任务:

    • 工作人员A(Server任务):专门接听业主电话(处理PC请求)。

    • 工作人员B(开关量Client任务):负责住户1,但只能通过"对讲机1"(串口UART2)联系,且和工作人员C共用这个对讲机,所以约定谁用谁锁门(互斥锁g_ch1_lock)。

    • 工作人员C(环境Client任务):负责住户2,也用对讲机1。

    • 工作人员D(温湿度Client任务):负责住户3,有自己的专用电话(UART4),不用锁。

  3. 准备三个"传呼机"(二进制信号量):B、C、D各一个,当业主改了他们的灯,A就会按传呼机叫他们马上去办。


第二步:业主查询状态------读取数据

场景:业主想知道住户1的按键是否按下。

  1. 业主打电话到物业中心(PC发送读请求)。

  2. 工作人员A接起电话(modbus_receive收到请求),看看黑板上的左边(DI区域),把按键状态告诉业主(modbus_reply回复数据)。

  3. 业主满意,挂电话。

为什么黑板上已经有按键状态?

因为工作人员B一直在默默工作:他每隔一会儿就用对讲机1问住户1:"你家按键现在啥情况?"(modbus_read_input_bits),然后记到黑板上。即使业主不问,他也定期更新。


第三步:业主开灯------事件驱动写输出

场景:业主要求打开住户1的第2盏灯(对应DO地址2)。

  1. 业主打电话:"把住户1的第2盏灯打开!"(PC写单个线圈)。

  2. 工作人员A接电话,在黑板右侧的DO区域,找到住户1的灯位置(DO2),用粉笔写上"1"(mb_mapping->tab_bits[2] = 1)。

  3. A写完一看,咦,住户1的灯区域(DO1~5)和之前备份的相比变了!于是A立刻按响工作人员B的传呼机(xSemaphoreGive(g_xBinarySemaphoreSwitch))。

  4. A然后回复业主:"已记录。"(modbus_reply

此时工作人员B在干嘛?

B正在自己的工位上,他有两种任务:

  • 定期(每500ms)主动问住户1按键状态,并记录。

  • 同时竖着耳朵听传呼机。

现在传呼机响了!B放下手头的事(如果有),拿起对讲机1,锁上门(获取互斥锁),拨通住户1的电话(modbus_set_slave(ctx, 1)),说:"请把你家的第1~5盏灯按这个新方案打开!"(modbus_write_bits,参数是&g_mb_mapping->tab_bits[1])。

住户1收到指令,第2盏灯亮了。

B挂电话,开门(释放互斥锁),继续监听传呼机,并等待下次定期问按键的时间。

关键:从业主打电话到灯亮,几乎是实时的,因为A一写完就通知B,B立即去执行,而不是等到B的下一个500ms周期。


第四步:业主同时控制多个住户

场景:业主想同时打开住户2的蜂鸣器和住户3的LED。

  1. 业主发送一个"写多个线圈"的请求,一次性修改多个DO(比如DO6和DO12)。

  2. 工作人员A修改黑板上对应区域,发现住户2的DO区域(DO6~10)变了,就按响C的传呼机;发现住户3的DO区域(DO11~15)变了,就按响D的传呼机。

  3. 工作人员C和D听到传呼机后,各自去执行写操作。

    • C需要和对讲机1的B抢锁,但B可能在忙,C就等一会儿(xSemaphoreTake会阻塞),直到B用完才进去。

    • D有自己的专用电话,直接打过去。


第五步:互斥锁的作用------避免对讲机混乱

假设住户1的按键刚好在变化,B正用对讲机1询问按键(modbus_read_input_bits),此时C也想用对讲机1去写蜂鸣器。

  • C试图拿锁,发现锁被B拿着,于是C在门外等待(阻塞)。

  • B读完按键,释放锁,C才进去打电话。

  • 这样保证了同一时间只有一个工作人员使用对讲机1,不会出现两个人同时说话导致信息混乱。


第六步:二进制信号量的超时机制

工作人员B的日常工作循环大致是:

cs 复制代码
c

while (1) {
    // 1. 定期问按键(即使没传呼机)
    拿锁 → 问按键 → 更新黑板 → 放锁;
    
    // 2. 等待传呼机,但最多等500ms
    if (收到传呼机) {
        拿锁 → 写灯 → 放锁;
    }
}
  • 如果业主经常改灯,B会频繁被传呼机叫醒,每次都会执行写灯。

  • 如果业主很久不改灯,B每次循环的第二步会等到500ms超时,然后进入下一次循环,继续问按键。这样保证了按键状态仍能定期更新。


总结:事件驱动的精髓

  • 传统周期方式:不管有没有变化,固定每隔500ms才去写灯。业主可能等500ms才看到灯亮。

  • 事件驱动方式:业主一改,立即通过信号量触发写操作,响应时间大大缩短,同时按键等输入仍周期读取,保证了新鲜度。

这样,物业中心既及时响应业主指令,又不会漏掉住户状态的变化,而且通过锁保护了共享资源(对讲机1),一切井井有条。

相关推荐
m***49582 小时前
LangChain-08 Query SQL DB 通过GPT自动查询SQL
数据库·sql·langchain
山岚的运维笔记2 小时前
SQL Server笔记 -- 第52章 拆分字符串函数
数据库·笔记·sql·mysql·microsoft·sqlserver
数据知道2 小时前
PostgreSQL:详解 pgAudit 插件的使用(数据脱敏与审计)
数据库·postgresql
_千思_2 小时前
【小白说】数据库系统概念 2
数据库·oracle
Re.不晚2 小时前
Redis——分布式锁
数据库·redis·分布式
桂花很香,旭很美2 小时前
[7天实战入门Go语言后端] Day 3:项目结构与配置——目录组织、环境变量与 viper
开发语言·数据库·golang
倔强的石头1062 小时前
国产化时序替换落地指南:用金仓数据库管好海量时序数据
数据库·kingbase
生命因何探索3 小时前
Redis—主从复制+哨兵
数据库·redis·php
undefinedType3 小时前
rails知识扫盲
数据库·后端·敏捷开发