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任务,而是直接在后面处理:csc 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的离散输入(按键),更新映射的
tab_input_bits[0~2],并显示在LCD上。 -
等待开关量信号量(超时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]。 -
循环中:
-
读取温湿度输入寄存器,更新映射的
tab_input_registers[2~3],并显示。 -
等待温湿度信号量(超时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 常见问题与注意事项
-
为什么需要互斥锁?
因为CH1上的两个client任务可能同时操作同一个串口(例如任务2正在读取时,任务3也尝试读取),导致数据错乱。互斥锁保证同一时间只有一个任务使用该串口。
-
二进制信号量是否足够?
对于写事件,二进制信号量是合适的,因为即使多个写事件连续发生,只要任务被唤醒后读取最新的DO值并写入,就能覆盖所有变化。如果要求每个写事件都精确触发一次写,可以考虑计数信号量,但Modbus写通常不需要如此精确。
-
初始化时读取DO的作用?
保证映射中的DO初始值与传感器实际状态一致,避免PC未写之前映射中的默认值与传感器实际输出不符,导致后续信号量误判(备份比较时会认为无变化)。
-
为什么Server任务要备份DO数组?
因为PC可能只修改了部分DO,我们需要精确知道哪些传感器的DO区域发生了变化,从而只唤醒对应的client任务,避免不必要的唤醒。
-
LCD显示的互斥?
代码中
Draw_String函数内部是否使用了互斥?从之前的章节可知,Draw_String应使用互斥信号量保护,否则多个任务同时写LCD会导致花屏。但本章代码中未显式包含LCD互斥。
故事场景:业主远程控制三个住户
角色:
-
业主(PC):通过手机App(Modbus主站)发送指令。
-
物业中心(H5主控):有一块大屏幕(LCD),四个工作人员(任务)。
-
住户1(开关量传感器):家里有3个开关(按键)和5盏灯(继电器+LED)。
-
住户2(环境监测传感器):家里有光敏电阻和可调电阻(ADC),还有5个蜂鸣器/LED。
-
住户3(温湿度传感器):家里有温湿度计,还有5个蜂鸣器/LED。
第一步:初始化------物业中心准备就绪
物业中心刚上班,做了几件事:
-
挂起一块大黑板(全局映射
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)。
-
-
-
给四个工作人员分配任务:
-
工作人员A(Server任务):专门接听业主电话(处理PC请求)。
-
工作人员B(开关量Client任务):负责住户1,但只能通过"对讲机1"(串口UART2)联系,且和工作人员C共用这个对讲机,所以约定谁用谁锁门(互斥锁
g_ch1_lock)。 -
工作人员C(环境Client任务):负责住户2,也用对讲机1。
-
工作人员D(温湿度Client任务):负责住户3,有自己的专用电话(UART4),不用锁。
-
-
准备三个"传呼机"(二进制信号量):B、C、D各一个,当业主改了他们的灯,A就会按传呼机叫他们马上去办。
第二步:业主查询状态------读取数据
场景:业主想知道住户1的按键是否按下。
-
业主打电话到物业中心(PC发送读请求)。
-
工作人员A接起电话(
modbus_receive收到请求),看看黑板上的左边(DI区域),把按键状态告诉业主(modbus_reply回复数据)。 -
业主满意,挂电话。
为什么黑板上已经有按键状态?
因为工作人员B一直在默默工作:他每隔一会儿就用对讲机1问住户1:"你家按键现在啥情况?"(modbus_read_input_bits),然后记到黑板上。即使业主不问,他也定期更新。
第三步:业主开灯------事件驱动写输出
场景:业主要求打开住户1的第2盏灯(对应DO地址2)。
-
业主打电话:"把住户1的第2盏灯打开!"(PC写单个线圈)。
-
工作人员A接电话,在黑板右侧的DO区域,找到住户1的灯位置(DO2),用粉笔写上"1"(
mb_mapping->tab_bits[2] = 1)。 -
A写完一看,咦,住户1的灯区域(DO1~5)和之前备份的相比变了!于是A立刻按响工作人员B的传呼机(
xSemaphoreGive(g_xBinarySemaphoreSwitch))。 -
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。
-
业主发送一个"写多个线圈"的请求,一次性修改多个DO(比如DO6和DO12)。
-
工作人员A修改黑板上对应区域,发现住户2的DO区域(DO6~10)变了,就按响C的传呼机;发现住户3的DO区域(DO11~15)变了,就按响D的传呼机。
-
工作人员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),一切井井有条。