4. VxWorks任务间通信与同步
在VxWorks中,任务是最基本的调度单位,但一个工业控制设备往往需要多个任务协同工作:采集任务读取ADC或现场总线数据,控制任务执行PID运算,执行任务驱动继电器或伺服,通信任务负责上传数据。这些任务之间必须交换数据并协调时序,否则会出现数据覆盖、控制滞后或执行抖动。VxWorks提供了五类核心机制:信号量、消息队列、管道、事件寄存器和共享内存。理解它们的语义差异,比记住API更重要。
五类机制的定位与选择
信号量用于互斥和同步,不传递数据。二值信号量保护共享资源,计数信号量用于任务间的事件通知。工业控制中,采集任务完成一次采样后释放一个计数信号量,控制任务等待该信号量后再读取数据,这是最典型的“生产者-消费者”同步。
消息队列传递离散消息,每条消息有固定长度,支持优先级排队。适合传递命令、报警、状态帧等结构化数据。例如执行任务向控制任务发送“阀门开度设定值”消息,消息体包含阀门编号和开度百分比。
管道是字节流通信,类似文件读写,适合不定长数据。在VxWorks中管道由pipeDevCreate()创建,任务通过read()/write()操作。它比消息队列更灵活,但需要自行处理消息边界。
事件寄存器用于任务等待多个事件中的任意一个或全部。每个任务有32位事件寄存器,通过eventReceive()等待事件组合。工业控制中,一个任务可能需要同时等待“采集完成”和“通信就绪”两个事件,事件寄存器比多个信号量更高效。
共享内存是最高效的数据交换方式,多个任务直接读写同一块内存。但必须配合信号量或事件寄存器实现互斥和同步,否则会出现读写竞争。共享内存适合传递大块数据,如波形缓冲区、图像帧或批量采集数据。
工业控制同步案例:采集与执行任务的闭环
假设一个温度控制设备,采集任务tAcq每10ms读取一次热电偶ADC值,控制任务tCtrl执行PID运算,执行任务tExec驱动加热继电器。三者优先级分别为120、110、130(数值越小优先级越高)。
采集任务完成一次采样后,将数据写入共享内存g_adcBuf的双缓冲区,然后释放计数信号量semData。控制任务阻塞在semTake(semData, WAIT_FOREVER),被唤醒后读取共享内存,执行PID计算,得到控制量u(k),再通过消息队列cmdQ发送给执行任务。执行任务阻塞在msgQReceive(cmdQ, ...),收到消息后驱动继电器。
这里有一个关键细节:共享内存的读写必须互斥。如果采集任务正在写入缓冲区A,控制任务同时读取缓冲区A,就会读到半新半旧的数据。解决方案是使用二值信号量semBuf保护共享内存,或者采用双缓冲区加“写指针切换”的方式,让采集任务写A区时控制任务读B区,下一次交换。双缓冲区配合信号量,既保证了数据一致性,又减少了任务阻塞时间。
/* 共享内存与同步对象定义 */
#define BUF_SIZE 32
float g_adcBuf[2][BUF_SIZE];
int g_wrIdx = 0; /* 当前写入缓冲区索引 */
SEM_ID semData; /* 计数信号量:采集完成通知 */
SEM_ID semBuf; /* 二值信号量:共享内存互斥 */
MSG_Q_ID cmdQ; /* 消息队列:控制量下发 */
/* 采集任务 */
void tAcq(void)
{
int idx;
while (1) {
idx = g_wrIdx;
semTake(semBuf, WAIT_FOREVER);
for (int i = 0; i < BUF_SIZE; i++) {
g_adcBuf[idx][i] = read_adc();
}
semGive(semBuf);
g_wrIdx = 1 - idx; /* 切换缓冲区 */
semGive(semData); /* 通知控制任务 */
taskDelay(1); /* 10ms周期 */
}
}
/* 控制任务 */
void tCtrl(void)
{
float setpoint = 50.0f, u;
while (1) {
semTake(semData, WAIT_FOREVER);
semTake(semBuf, WAIT_FOREVER);
float pv = g_adcBuf[1 - g_wrIdx][BUF_SIZE - 1];
semGive(semBuf);
u = pid_calc(setpoint, pv); /* PID运算 */
msgQSend(cmdQ, (char *)&u, sizeof(u), WAIT_FOREVER, MSG_PRI_NORMAL);
}
}
/* 执行任务 */
void tExec(void)
{
float u;
while (1) {
if (msgQReceive(cmdQ, (char *)&u, sizeof(u), WAIT_FOREVER) != ERROR) {
set_heater(u); /* 驱动继电器 */
}
}
}
上述代码中,semData的初始计数为0,采集任务每次释放后计数加1,控制任务取走后减1。如果控制任务处理较慢,信号量计数会累积,但不会丢失通知。实际工程中通常将semData初始计数设为0,并在系统启动时先启动采集任务,再启动控制任务,避免控制任务首次等待超时。
事件寄存器与多条件等待
如果执行任务需要同时等待“控制量就绪”和“使能信号有效”两个条件,使用两个信号量会导致任务需要两次semTake,代码复杂且容易死锁。事件寄存器可以一次性等待多个事件:
#define EVT_CMD_READY 0x01
#define EVT_ENABLE 0x02
void tExec(void)
{
UINT32 events;
while (1) {
events = eventReceive(EVT_CMD_READY | EVT_ENABLE,
EVT_ANY, WAIT_FOREVER, NULL);
if (events & EVT_CMD_READY) {
/* 取命令并执行 */
}
if (events & EVT_ENABLE) {
/* 使能输出 */
}
}
}
eventReceive的第二个参数EVT_ANY表示任意一个事件到达即返回,EVT_ALL表示必须全部到达。工业控制中,安全联锁通常使用EVT_ALL,确保所有条件满足才动作;而报警处理使用EVT_ANY,任一报警触发即响应。
管道与共享内存的适用边界
管道适合传输不定长、流式的数据,例如将调试日志从多个任务汇聚到一个日志任务。管道是字节流,没有消息边界,因此需要在数据中自行加入长度字段或分隔符。共享内存适合大块、高频的数据交换,例如每1ms采集256点波形数据,用消息队列复制数据开销太大,共享内存加信号量是唯一合理选择。
semGive、msgQSend等非阻塞API,不能使用semTake或msgQReceive。共享内存的互斥信号量必须在所有访问路径上成对出现,遗漏一次就会导致数据竞争。事件寄存器的32位中,高8位被系统保留,用户任务只能使用低24位。
综合来看,VxWorks的五种通信机制各有明确边界。工业控制中,采集与执行任务之间的同步通常采用“共享内存+计数信号量+消息队列”的组合:共享内存传递批量数据,计数信号量实现周期同步,消息队列传递控制命令。理解每种机制的语义和开销,才能设计出确定性强、抖动小的实时控制软件。