实时数据绘图程序
本文介绍了 rtp(实时绘图器)的实现,这是一个基于 Qt 窗口库的实时 x,y 数据绘图实用程序。 rtp 结合了实时更新、放大、自动缩放和自动跟踪模式。它旨在用于 gnuplot 受限的场景,例如实时数据管道的终止。然而,rtp 体积小,并不试图涵盖 gnuplot 用于生成可发布数据图表的庞大功能集。
rtp 源代码在 GPL 许可下发布,可在 metalab.unc.edu/pub/linux/science/visualization/rtp-1.0.0.tar.gz 获取。我使用 Red Hat 6.0 和 Qt 1.44 开发并测试了它。软件包中包含一个 README 文件,以帮助您构建和使用 rtp。图 1 显示了 rtp 的屏幕截图。
rtp 提供实时更新和基本的鼠标驱动分辨率选择。然而,它缺乏 gnuplot 将格式化、带标题的图表发送到打印机的能力。 rtp 仍然是一个简单的软件(1200 行代码),需要添加许多功能。通过在此处描述其原理,我希望能提供一个基于 Qt 库和 X 窗口系统的实用且温和的应用程序示例。我也希望激励一些感兴趣的人们在基于 Linux 的实时交互式数据可视化系统上做更多工作。这可以通过扩展 rtp 或作为一个全新的项目来完成。
由于 rtp 的所有数据都来自 STDIN(标准输入),因此通过 X 窗口系统与用户的交互仅限于设置查看模式。它允许用户即使在新数据点正在处理时也更改查看模式。查看模式如下:
自动缩放:在必要时调整缩放比例以包含所有接收到的数据点。这是默认模式,可以通过按下工具栏上的按钮来选择。
自动跟踪:保持固定的缩放比例,但改变视口偏移量以跟踪最新的点。此模式通过按下工具栏上的按钮来选择。缩放比例将固定为按下工具栏按钮之前的状态。
用户定义的固定:保持固定的视口(包括缩放比例和偏移量),由用户定义。当用户使用鼠标在绘图窗口中拖出一个视口时,将选择此模式。
我基于 Qt 库开发了 rtp,因为 Linux 社区中的许多其他人都在使用它(例如,KDE),并且因为它具有高质量的文档。一个 HTML 树(保证与 Qt 源代码同步,因为它是由源代码和注释自动生成的)描述了 Qt 的所有类和函数。Dalheimer 还写了一本关于 Qt 编程的书,这是一本非常有用的入门书(参见“资源”部分)。
Qt 库提供了一个相当完整的 GUI 编程环境。在 Qt 环境中编程时,无需引用底层的 XLib 库。Qt 的功能超越了 GUI 领域,还包括实现多个标准数据结构的容器类。
Qt 的每个功能组件都打包为一个 C++ 类,这为 C++ 高手提供了许多思考和修改的空间,也为我们这些喜欢编写操作代码的人提供了一套很好的工具集。就我个人而言,我只有大约一年的生产 C 代码编写经验和一门大学 C++ 课程,学习 Qt C++ 框架相当容易。
Qt 库通过其 C++ 扩展:“信号”和“槽”,使独立开发的类更容易集成。信号是一个类成员函数,在编译时未定义。槽是一个专门指定的成员函数,用于在运行时连接到信号。例如,GUI 按钮类可以有一个 Push 信号。在运行时,绘图窗口的槽 Render 可以连接到按钮的 Push。从那时起,调用按钮的 Push 方法的代码实际上会调用绘图窗口的 Render 方法。
基于信号和槽机制的代码比处理运行时函数指针表的代码更易于阅读和维护。(我敢打赌实现中使用了一个或两个函数指针。)Qt 还处理了诸如将未连接的信号存根到空函数之类的烦恼,因此您不会因空指针而导致段错误。
信号和槽的缺点是它们是非标准的 C++ 扩展,使用了新的语法,因此带有信号和槽的 Qt 代码必须通过 Qt 库提供的预处理器才能编译。Dalheimer 的书充分详细地解释了信号和槽,足以让您开始使用它们。
为了提供可接受的用户界面,rtp 必须始终快速响应 GUI 事件(即,鼠标事件等)。如果所有程序活动都由 GUI 事件驱动,则可以轻松满足此要求。例如,交互式绘图程序完全由 GUI 驱动,因此其唯一的责任是执行相对较短的代码序列以响应 GUI 事件。
rtp 的架构由于两个额外的要求而变得复杂,除了快速的 GUI 响应之外。它必须在 STDIN 上有新数据点可用时快速更新。此功能使 rtp 与其他绘图程序(如 gnuplot)区分开来。它还必须处理以下事实:渲染数据集通常比 GUI 延迟可接受的时间更长。这排除了使用简单的函数调用来渲染整个绘图。
从根本上说,rtp 必须多路复用三个“任务”,从最高优先级到最低优先级列出如下:
快速响应 GUI 事件。这些事件作为来自 X 服务器的数据在套接字上到达。
从 STDIN 读取可用的数据点。
在需要更新时将数据集渲染到绘图窗口中。
Qt 库提供了支持此处理结构的机制。第一个机制是 QSocketNotifier 类。当我们创建一个 QSocketNotifier 对象时,我们将感兴趣的文件描述符传递给它。(花哨的名字 QSocketNotifier 让我认为该类完全与网络套接字相关联,但实际上它可以与大多数文件描述符一起工作。)对于 rtp,这是 STDIN 文件描述符 (STDIN_FILENO)。然后,我们将 QSocketNotifier 的 activated 信号连接到处理新数据的特定槽。
第二个机制是 QTimer 类。提供此类是为了支持定期计划的后台处理以及单次定时事件。Qt 文档告诉我们,通过设置超时为零的 QTimer 对象,我们可以使函数在没有事件要处理时被调用。同样,将 QTimer 连接到执行后台处理的实际函数的机制是信号和槽。
图 2 说明了 rtp 的控制流和数据处理方案。Qt 事件循环是应用程序的控制中心。当事件发生时,它会调用 rtp 应用程序中的函数。图中的每个箭头都表示调用函数或库。请注意,只有名称以 PlotWindow 或 RtpRender 开头的函数才是实际的 rtp 代码。rtp 函数包括 X 事件回调(例如 PlotWindow::paintEvent 及其友元)、QSocketNotifier 回调 (PlotWindow::slotStdinAwake) 和 QTimer 回调 (RtpRender::slotWorkAwhile)。
XLib 是作为字节流套接字上 X 服务器接口提供的最低级别 C 库。它管理套接字的输入端(提供事件)和输出端(向服务器发送请求)。(请注意,图 2 仅显示输入端。)XLib 还提供某些性能优化,例如过滤冗余事件和延迟内部队列中的请求,以便将请求分组到大型数据块中以实现高效的套接字使用。有关详细信息,请参阅 Adrian Nye 的经典 XLib 书籍(“资源”部分)。
POSIX select 系统调用通常用于在单个线程中为多个文件描述符(套接字)提供服务的应用程序。select 由必须响应多个文件描述符上的数据并且不希望通过轮询浪费 CPU 时间的应用程序(例如 rtp)使用。此外,Qt 使用 select 的超时函数来启动 QTimer 计划的函数。
Qt 库中的 select 调用是 rtp 进程可能阻塞的唯一位置(据我所知)。对于系统编程的新手,我应该解释一下“阻塞”的含义。像 Linux 这样的多任务操作系统必须能够在一个较少数量的处理器上多路复用大量程序的执行。通过捕获中断,Linux 以某种顺序在运行程序之间切换处理器。这使得单个程序无法通过进入无限循环来锁定系统。
由于 Linux 具有抢占式多任务处理,rtp 可以进入无限循环等待 X 事件或 STDIN 上的数据点,而不会锁定系统。但是,在此循环中花费的 CPU 时间将不必要地降低其余正在执行程序的性能。因此,大多数内核 I/O 调用都会“阻塞”正在执行的程序,直到 I/O 完成。该程序将从 CPU 运行的程序集中删除。一旦 I/O 完成,该程序将被标记为“可运行”,并将重新进入内核的运行队列,以便与其他可运行程序一起在 CPU 中切换进出。
select 是 Qt 表示“在 X 事件可用、QSocketNotifier 对象之一发生 I/O 事件或 QTimer 对象之一的超时到期之前,我无事可做”的方式。从 Qt 的角度来看,select 等待这些事件之一发生,然后返回。
Qt 文档清楚地描述了如何使用 QSocketNotifier 和 QTimer 连接到 select。但是,它没有完全描述 X 事件与其他套接字事件与定时器事件的优先级级别。在编写像 rtp 这样的程序时,理解这些细节非常重要,因为程序的性能在很大程度上取决于它们。
为了理解 X 套接字、其他套接字和定时器的优先级如何,我们必须查看 Qt 源代码。Troll 免费提供 Qt 源代码(URL 请参见“资源”部分)。我们需要的代码位于发行版树下的 /src/kernel/qapplication_x11.cpp 中。请注意,虽然 Qt 源代码可以自由再分发,但 Troll 的许可证禁止修改,这与 GPL 不同。
函数 QApplication::processNextEvent 由主事件循环调用,用于服务 X 套接字、QSocketNotifier 套接字和 QTimer 定时器。QApplication::processNextEvent 首先检查是否有要处理的 X 事件。如果没有找到,它会进入 select 系统调用。
在 select 返回后,QApplication::processNextEvent 会将事件分派给所有文件描述符已准备就绪的 QSocketNotifier 对象。然后,它将事件分派给所有超时已到的 QTimer。Qt 1.44 的事件循环可以总结如下(fd 代表文件描述符):
while (1) { if (X event pending) { dispatch X event; continue; } timeout = minimum of all QTimer times to next event; select (X fd, all QSocketNotifier fd's, timeout); dispatch events to all QSocketNotifier's with active sockets; dispatch events to all QTimer's with expired times; }
请注意,X 事件具有最高优先级,因为只要有更多的 X 事件,循环将忽略 QSocketNotifier 和 QTimer 的事件。但是,当 X 事件不可用时,Qt 可能会在返回到 X 事件之前执行 每个 QSocketNotifier 和 QTimer 事件。这意味着我们必须将注册的 QSocketNotifier 和 QTimer 事件处理时间的总和视为最坏情况下的用户界面延迟。
从现在开始,我将详细介绍 rtp 代码。您可能需要从前面给出的 URL 下载代码并打印出来,并附带行号。
rtp 的所有非自动数据结构都嵌入在两个主要的 C++ 类中。PlotWindow 派生自 Qt 的 QWidget,并提供所有用户界面回调以及 STDIN 回调。这些类在 rtp.h 中布局。PlotWindow 的重要数据成员是:
deque<DoubPt> _points:从 stdin 接收的整个原始 (x,y) 数据点集。deque 是一个 C++ 标准模板库类,它(除其他外)为动态大小的块链接数据结构提供了连续内存布局的错觉(数组索引、您可以递增以遍历 deque 的伪指针)。点按照接收顺序保存。
QPixmap *_buffer:每当窗口被绘制时复制到绘图窗口中的像素图。
RtpMapping _map:保存当前有效的视口边界、比例因子和偏移量,用于将接收到的数据点映射到绘图窗口中。
QRect *_rubberBox:如果为非 NULL,则定义用户正在用鼠标框选的“橡皮筋”框,以定义新的视口。一旦用户释放鼠标,该框将从屏幕上删除,并且视口将更改。
另一个重要的 rtp 类是 RtpRender。其重要的数据成员是:
QTimer _timer:激活以在渲染进行中时调用 RtpRender::slotWorkAwhile。渲染未进行时处于非活动状态。
unsigned int _pti:标记点在 RtpRender::slotWorkAwhile 的连续调用中遍历数据的位置。
QPixMap *_privateBuff:*_buf. _privateBuff 将为私有渲染创建(如下所述)。_buf 是 RtpRender::slotWorkAwhile 实际绘制到的像素图。对于私有渲染,它将等于 _privateBuff,对于在线渲染,它将等于主重绘像素图。
要绘制的数据点从 STDIN 输入。作为其初始设置的一部分,rtp 将 STDIN 文件描述符模式设置为非阻塞,以便任何对 STDIN 的读取都不会阻塞程序。这使我们能够以相对较大的块读取 stdin,从而提高效率。rtp 然后为 STDIN 创建一个 QSocketNotifier,通过信号/槽机制注册 PlotWindow::slotStdinAwake 作为回调。以下是代码,来自 rtp.c 的第 454 行:
fcntl(STDIN_FILENO, F_SETFL, O_NONBLOCK); QSocketNotifier sn(STDIN_FILENO, QSocketNotifier::Read); QObject::connect(&sn, SIGNAL(activated(int)), plotter, SLOT(slotStdinAwake()));
当 slotStdinAwake(rtp.c,第 100 行)被调用时,我们知道 STDIN 上至少有一个字符的数据(因为 select 返回时 STDIN 被标记为就绪,并且所有 POSIX I/O 都是基于字符的)。然而,仅仅读取一个字符然后返回是非常低效的。为了获得最佳效率,我们希望尽可能多地读取和处理字符。
但是,在 slotStdinAwake 中花费的时间会增加用户界面延迟,因为在 slotStdinAwake 退出之前,无法处理任何 X 事件。如果我们处理尽可能多的 STDIN 字符,并且 STDIN 接收点的速率快于它们可以被处理的速率,我们可能会冒着完全锁定 UI(用户界面)的风险。因此,我们在效率和延迟之间进行了经典的权衡。当前版本的 rtp 尝试在每次 slotStdinAwake 调用中读取和处理 1024 个字符的数据。但是,由于 read 调用不会阻塞,我们实际上可能不会处理这么多字符。
slotStdinAwake 因其自身进行缓冲并且不使用 STDIO 库而变得丑陋。我无法从 GNU libc 信息中判断在描述符上设置 O_NONBLOCK 后 STDIO 是否会工作。与其找出答案,我不如采取懒惰的方法并编写了自己的缓冲代码。
当 rtp 解析一个新的 x,y 数据点时,如果它在当前视口的范围内,它将把它映射到当前的像素图中。如果该点超出范围并且绘图模式是自动缩放或自动跟踪,则必须以新的缩放比例和偏移量重新绘制整个绘图。列表 1 中的代码(rtp.c,第 255 行)处理这些情况。
类 RtpRender(在 rtpRender.c 中定义)处理将数据集绘制到像素图中的细节。由于渲染整个集合可能需要相当长的时间,RtpRender 设置了一个超时为零的 QTimer 对象,以便在保持快速 UI 响应的同时,将所有可用的 CPU 时间都用于渲染。RtpRender::slotWorkAwhile,即 QTimer 回调,在固定间隔(目前为 100 毫秒)内处理点,然后返回。列表 2 中的代码是 RtpRender::slotWorkAwhile 的核心部分(rtpRender.c,第 274 行)。
有两种类型的渲染操作。抢占式或在线渲染将点直接绘制到用于重绘事件的像素图中。通过调用 RtpRender::newOnlineRender 启动新的在线渲染。当调用此函数时,任何可能正在进行的渲染都会被取消,新的渲染从头开始,绘制所有接收到的点。代码在列表 3 中(rtpRender.c,第 77 行)。
当调用 RtpRender::newOnlineRender 时,用于重绘的像素图的指针作为 buf 参数传入。_map 是一个数据结构,其中包含新渲染的比例和偏移因子,并返回给调用代码,以便即使在渲染进行中,也可以直接绘制来自 STDIN 的新点。
非抢占式或私有渲染首先创建一个私有像素图,然后将点绘制到其中。通过调用 RtpRender::quePrivateRender 请求私有渲染。如果当前正在进行渲染操作,则私有渲染不会取消当前渲染操作。它会等待当前渲染完成后再开始。代码在列表 4 中(rtpRender.c,第 97 行)。
由于排队机制只是一个布尔标志,因此私有渲染队列的深度仅为 1。当调用 RtpRender::quePrivateRender 时,它将销毁任何挂起的私有渲染操作,但不会干扰已经进行的渲染操作。请注意,_timer 是 QTimer 类型的对象。如果定时器已激活,则表示渲染已在进行中。
rtp 使用私有渲染来更新绘图,当新的数据点在自动缩放或自动跟踪模式下强制进行视口更改时。在线渲染用于响应用户发起的视口更改来更新绘图,例如放大鼠标选定的区域。理论是新点进入的速度足够快,我们不想每次获得一个新点都重新开始。但是,当用户更改视口时,他对除了最新和最好的绘图之外的任何东西都不感兴趣。
David Watt 的电子邮件地址是 wattd@elcsci.com。