嵌入式与实时应用中的 POSIX 线程及消息队列
1. 条件变量概述
条件变量是多线程编程中用于线程间同步的重要工具。以下是条件变量的基本操作函数:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int pthread_cond_init (pthread_cond_t *cond, const pthread_condattr_t *cond_attr);
int pthread_cond_destroy (pthread_cond_t *cond);
int pthread_cond_wait (pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_timedwait (pthread_cond_t *cond, pthread_mutex_t *mutex,
const struct timespec *abstime);
int pthread_cond_signal (pthread_cond_t *cond);
int pthread_cond_broadcast (pthread_cond_t *cond);
条件变量的基本操作是信号(signal)和等待(wait)。信号操作会唤醒一个等待在该条件变量上的线程,线程唤醒的顺序取决于调度策略。线程也可以执行定时等待操作,如果在指定的时间间隔内条件变量未收到信号,等待操作将返回错误。此外,线程还可以广播一个条件,这会唤醒所有等待在该条件变量上的线程。
1.1 条件变量属性
Pthreads 并未为条件变量定义任何必需的属性,但至少有一个可选属性。RTAI Pthreads 提供了初始化和销毁条件变量属性对象的函数,但未实现该可选属性。
2. 用户空间中的 Pthreads
2.1 数据采集应用示例
为了练习在用户空间中使用 Pthreads,我们重新实现了一个四通道模拟数据采集应用。每个通道生成一个指定幅度的简单锯齿波,采集到的数据将显示在屏幕上,而不是写入文件。与之前的实现不同的是,现在每个通道都有自己的线程,而不是由一个任务管理所有四个通道。这样做的动机是,只管理一个通道的线程可能比管理“n”个通道的线程更简单。
系统架构如下:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(Command):::process -->|命令| B(Ch 0):::process
A -->|命令| C(Ch 1):::process
A -->|命令| D(Ch 2):::process
A -->|命令| E(Ch 3):::process
B -->|数据| F(data_point_t):::process
C -->|数据| F
D -->|数据| F
E -->|数据| F
F -->|数据| G(Display):::process
2.2 代码分析
2.2.1 数据结构
在
data_acq.c
文件中,靠近顶部的第 21 行有一个
channel_t
数据结构数组。查看
data_acq.h
文件,会发现
channel_t
中的许多字段已被移除,因为它们现在可以作为
data_acq()
线程内的局部变量。同时,为了支持 Pthreads,添加了其他字段。注意,
data_point_t
类型定义中包含一个
#ifdef
来指定时间戳的替代定义,这是因为在内核空间中处理时间的方式略有不同。
2.2.2
main()
函数
main()
函数从第 166 行开始,它首先使用
channel[]
数组初始化四个数据采集通道。对于每个通道,创建一个互斥锁、一个条件变量和一个线程,并将通道的
channel_t
结构作为参数传递给线程。
接下来,初始化显示,使用 Curses 库。然后创建
Command()
线程,用于监控键盘的操作员输入。最后,
main()
调用
Display()
函数,该函数成为主线程,等待通道线程的数据并显示它。
2.2.3
Display()
函数
Display()
函数从第 146 行开始,这是我们使用互斥锁和条件变量的第一个示例。显示线程锁定与显示数据结构关联的互斥锁(
m_display
),并等待相应的条件变量(
c_display
)。
pthread_cond_wait()
函数不会返回,直到其他线程(即通道线程之一)发出信号,表示已将数据放入显示结构中。唤醒后,显示线程将数据复制到局部变量,然后解锁互斥锁。
2.2.4
data_acq()
函数
data_acq()
函数从第 26 行开始,首先从作为参数传递的
channel_t
结构中复制一些字段,这主要是为了提高代码的可读性。我们保持相同的约定,即采样周期为零表示通道被禁用。
data_acq()
线程有两种唤醒情况:
- 正在采样且采样周期已过期。
- 收到
Command()
线程的消息,要求更改其采样参数之一。
data_acq()
通过
channel_t
结构中的缓冲区接收
Command()
线程的消息。由于该缓冲区由两个线程访问,因此必须使用互斥锁进行保护。在访问消息缓冲区之前,我们锁定
channel_t
结构中关联的互斥锁。但在
Command()
线程发送消息之前,尝试读取消息缓冲区是没有意义的,因此我们等待
channel_t
中的条件变量,让
Command()
线程在有变化时发出信号。
如果通道未采样(
sample_period == 0
),则可以直接调用
pthread_cond_wait()
,直到操作员决定打开该通道。如果通道正在采样,我们可以调用
pthread_cond_timedwait()
,将采样周期作为超时值。当它返回时,状态值将指示调用是否超时(
status == ETIMEOUT
)或是否有人发出信号(
status == 0
)。
从
pthread_cond_timedwait()
唤醒后,我们需要计算下一次唤醒时间,因为定时等待操作使用绝对时间作为超时参数。因此,我们将采样周期转换为纳秒,并添加到超时参数中。
如果状态指示超时,我们对消息缓冲区不感兴趣,因此可以立即解锁互斥锁。然后,我们需要独占访问显示数据结构,因此锁定其互斥锁,将相关信息复制到显示结构中,发出其条件变量的信号,然后解锁互斥锁。
如果状态为零,
Command()
线程已将消息放入消息缓冲区。在这种情况下,我们在处理消息时保持互斥锁锁定。这里的主要复杂性在于,如果消息更改了采样周期,我们必须计算新的唤醒时间。
2.2.5
Command()
线程
Command()
线程从第 108 行开始,
getstr()
函数会阻塞线程,直到操作员按下
<Enter>
键。返回后,我们解析命令字符串。命令语法类似于之前在恒温器中添加可编程参数的操作,有四个有效命令:
-
'c'
– 通道。
Command()
线程内部处理此命令,后续命令将指向此通道,直到通道值更改。
-
'q'
– 退出。停止所有操作。调用
exit()
函数会取消所有线程,这可能不是停止系统的最干净方式。更干净的解决方案是让
Command()
线程通知其他线程是时候退出,并让它们自行终止。
-
'r'
– 范围。此通道生成数据的最大值。
-
's'
– 采样周期。以毫秒为单位设置当前通道的采样周期。
为了方便起见,
Command()
线程不进行错误或合理性检查。除了
'c'
和
'q'
(注意区分大小写),所有感知到的命令令牌都将传递给当前通道,并且不检查通道值是否在范围内。键盘输入不会回显,因为这需要在两个线程之间共享显示,以便
Command()
线程可以将光标移动到命令行字段,并在输入每个字符时恢复它,这反过来又要求显示由互斥锁保护。
2.3 编译和运行
查看
Makefile
,忽略注释部分。
data_acq
的编译命令中使用
-l
选项添加了两个库:
pthread
库包含 Pthreads 函数,
curses
是 Curses 库。此外,还有一个编译时符号
_REENTRANT
,其作用是提醒编译器使用可重入版本的函数,以确保线程安全。
操作步骤如下:
1. 执行
make data_acq
编译程序。
2. 运行程序。
3. 输入命令
c 0 s 500
启动通道 0 的采样。
4. 输入类似的命令启动其他通道的采样。
2.4 多线程程序调试
在用户空间中运行 Pthreads 的好处是可以使用 DDD/GDB 调试程序。多线程会给调试过程带来一些复杂性,但幸运的是,GDB 提供了处理这些问题的功能。
调试步骤如下:
1. 在 DDD 下运行
data_acq
,并在
main()
函数开头附近的
channel[i].number = i;
行设置初始断点。
2. 在运行程序之前,执行
View->Execution Window
打开一个单独的窗口,用于程序输出,因为 Curses 输出在 GDB 控制台窗口中效果不佳。
3. 运行程序,当程序在断点处停止时,在 GDB 控制台窗口中会看到以下消息:
[New Thread 1024 (runnable)]
[Switching to Thread 1024 (runnable)]
这表明 GDB 已经识别到正在运行多线程环境。
4. 继续运行程序,当再次在断点处停止时,会看到另外两条不同编号的“New Thread”消息。其中一条是由
for
循环创建的通道线程,另一条是 Pthreads 为自身使用创建的线程。
5. 删除断点,让程序继续运行到显示循环顶部的
while (1)
语句。此时,所有线程都已创建。执行
Status->Threads
,会看到所有线程及其当前执行点的列表,当前执行的线程会高亮显示。注意,四个通道线程都处于相同的执行点。
6. 执行
Status->Backtrace
显示当前线程的调用栈,这表明我们在
main()
函数中,并且
main()
是从
__libc_start_main()
调用的。选择一个位于
sigsuspend.c:48
的线程,Backtrace 显示会立即更改,显示该线程的调用栈。因此,可以将任何线程设置为“活动线程”并查看其状态。
7. 如果在
data_acq()
函数中设置断点,当执行
data_acq()
的四个线程中的任何一个到达该点时,程序将停止,此时可以检查该线程的局部变量。但请注意,只有当前执行线程的局部变量是可见的。
3. 迁移到内核空间
3.1 代码修改
现在需要重新构建
data_acq.c
以使其在 RTAI Pthreads 下运行,具体操作如下:
1. 将
data_acq.c
复制到
data_acq_rt.c
。
2.
main()
函数转变为
init_module()
函数。除了创建
Command()
线程外,现在还必须显式创建
Display()
线程。将
printf()
替换为
printk()
,并创建一个空的
cleanup_module()
函数。
3. 由于在内核空间中无法进行屏幕 I/O 操作,且无法访问 Curses 库,因此需要将
Display()
和
Command()
的实际功能在内核空间和用户空间之间进行拆分。可以使用 RTAI FIFOs 在用户空间线程和在内核空间运行的“伙伴”线程之间进行通信。从
main()/init_module()
中移除 Curses 初始化调用,并用两个
rtf_create()
调用来代替。
data_acq.h
文件中包含了两个 FIFOs 的
#define
定义。
3.2 内核空间函数修改
-
Display()函数 :内核空间的Display()函数只需将接收到的数据点复制到一个 FIFO 中。 -
Command()函数 :将Command()函数中的getstr()调用替换为rtf_get()调用。 -
data_acq()函数 :由于ftime()函数在内核空间中不可用,需要使用替代方法来读取时间。RTAI Pthreads 定义了以下函数:
void clock_gettime (int clock_id, struct timespec *current_time);
该函数似乎不是 Posix 标准的一部分,在可访问的文档中也未提及。
struct timespec
在
time.h
中定义,有两个
long int
字段:
-
tv_sec
:自 1970 年 1 月 1 日午夜以来的秒数。
-
tv_nsec
:自
tv_sec
以来的纳秒数。
这与
data_acq.c
使用的
timeb
结构几乎相同。
clock_id
实际上是一个枚举,其唯一有效的值是
CLOCK_REALTIME
。需要将
data_acq()
函数中的两个
ftime()
调用替换为
clock_gettime()
调用。
3.3 头文件处理
RTAI Pthreads 有自己的
pthread.h
版本,名为
rtai_pthread.h
,但它不在
/usr/src/rtai/include
目录中,而是在
/usr/src/rtai/posix/include
目录中。有以下几种处理方式:
-
指定完整路径
:在
#include
指令中指定完整路径,但通常不建议这样做。
-
添加到包含路径
:将
/usr/src/rtai/posix/include
添加到编译器标志的包含路径中。
-
复制文件
:将
rtai_pthread.h
复制到
/usr/src/rtai/include
目录中。
3.4 内核模块构建与加载
logger.c
文件实现了
Command()
和
Display()
在用户空间的“伙伴”线程,它展示了需要从
data_acq_rt.c
中移出的功能。
打开
Makefile
,删除用
#
注释的部分,该部分用于构建内核模块
data_acq_rt.o
,然后再次运行
make
。
data_acq_rt.o
需要以下模块:
| 模块名称 | 说明 |
| ---- | ---- |
| rtai | 基础 RTAI 模块 |
| rtai_sched | 调度相关模块 |
| rtai_utils | 为 pthread 提供实用函数 |
| rtai_pthread | RTAI 的 Pthreads 模块 |
| rtai_fifos | RTAI 的 FIFO 模块 |
加载这些模块的最简单方法是执行以下命令:
modprobe rtai_pthread
insmod rtai_fifos
insmod ./data_acq_rt.o
rtai_pthread
和
rtai_fifos
相互独立,因此可以在使用
modprobe
加载一个模块后,使用
insmod
加载另一个模块,当然第二个模块也可以使用
modprobe
加载。
然后运行
./logger
,如果一切正常,应该能够输入
c 0 s 500
并看到通道 0 开始“采集数据”。如果出现问题,可以在
data_acq_rt.c
的关键位置添加
rt_printk()
语句来查看具体情况。
4. 消息队列
4.1 概述
消息队列是 POSIX 标准中可选实时扩展的一部分,标准 Linux(至少在 2.4 内核版本中)不包含实时扩展,但 RTAI 实现了 Posix 消息队列。相应的头文件是
rtai_pqueue.h
,与
rtai_pthread.h
一样,它位于
/usr/src/rtai/posix/include
目录中,而不是
/usr/src/rtai/include
目录中。
消息队列与 RTAI FIFOs 非常相似,但它们是全双工的,并且处理离散消息而不是连续的字节流。不幸的是,它们仅适用于内核空间中的任务。
4.2 消息队列 API
消息队列的 API 相对简单直接,以下是相关函数:
mqd_t mq_open (char *mq_name, int oflags, mode_t permissions, struct mq_attr *mq_attr);
int mq_close (mqd_t mq);
int mq_unlink (char *mq_name);
size_t mq_receive (mqd_t mq, char *msg_buffer, size_t buflen, unsigned int *msgprio);
int mq_send (mqd_t mq, const char *msg, size_t msglen, unsigned int msgprio);
int mq_notify (mqd_t mq, const struct sigevent *notification);
int mq_getattr (mqd_t mq, struct mq_attr *attrbuf);
int mq_setattr (mqd_t mq, const struct mq_attr *new_attrs, struct mq_attr *old_attrs);
4.3 消息队列操作流程
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(打开消息队列 mq_open):::process --> B(发送或接收消息 mq_send/mq_receive):::process
B --> C{是否需要关闭}:::process
C -->|是| D(关闭消息队列 mq_close):::process
C -->|否| B
D --> E{是否需要销毁}:::process
E -->|是| F(删除消息队列 mq_unlink):::process
E -->|否| G(结束):::process
4.4 详细操作说明
-
打开消息队列
:使用
mq_open()函数打开消息队列,需要指定队列名称、访问模式和权限,还可以包含一个属性结构。打开消息队列类似于打开文件。 -
关闭消息队列
:
mq_close()函数关闭消息队列,但不会销毁它,只是断开与队列的链接,队列及其包含的任何消息仍然可供其他链接和其他线程使用。 -
删除消息队列
:
mq_unlink()函数销毁消息队列并释放其资源。如果在调用mq_unlink()时队列还有打开的链接,队列将被标记为在最后一个链接关闭时销毁。一旦队列被标记为销毁,就不能再打开新的链接。 -
接收和发送消息
:
mq_receive()和mq_send()函数用于在队列上进行接收和发送操作。除非在打开链接时设置了O_NONBLOCK标志,否则这两个操作都是阻塞的。消息按照优先级顺序放入队列,即高优先级消息将插入到已有的低优先级消息之前,相同优先级的消息按照 FIFO 顺序排队。 -
异步通知
:
mq_notify()函数允许线程指定一个异步回调函数,当有消息到达队列时调用。struct sigevent结构包含要调用的函数指针等信息。RTAI 实现了mq_notify()函数以保证完整性,但实际上不进行异步通知。
综上所述,通过对用户空间和内核空间的 Pthreads 应用以及消息队列的介绍,我们可以看到在嵌入式与实时应用中如何利用这些技术实现多线程编程和线程间通信,从而构建高效、稳定的系统。
超级会员免费看
4

被折叠的 条评论
为什么被折叠?



