使用 Tcl 和 Tk 从您的 C 程序

作者:Matt Welsh

Tcl 最初被设计为一种“扩展语言”——也就是说,一种嵌入在另一个程序(例如,用 C 编写的程序)中的解释型脚本语言,用于处理用户自定义的日常任务,或者,与 Tk 一起,处理更复杂的任务,例如为程序提供 X Window 系统界面。 Tcl 解释器本身只是一个函数库,您可以从程序中调用它; Tk 是一系列与 Tcl 解释器结合使用的例程。虽然您可以完全以脚本形式编写 Tcl/Tk 程序,并通过 wish 执行,但这只是故事的一个方面。要真正发挥这个系统的优势,您需要从其他程序中使用 Tcl 和 Tk。

Ousterhout 的著作《Tcl 和 Tk 工具包》包含了关于将 Tcl 解释器与您的 C 程序链接的详尽材料。这通常需要您的程序从某个来源生成或读取 Tcl 命令,并将命令作为字符串传递给 Tcl 解释器函数,这些函数返回评估和执行 Tcl 表达式的结果。

虽然这种机制当然很有用,但也有一些缺点。首先,它要求程序员学习将其 C 代码与 Tcl 解释器连接的细节。虽然这通常并不困难,但这意味着程序员不仅必须部分使用 C,部分使用 Tcl(起初可能是一种不熟悉的语言),而且还要学习使用 Tcl 库例程的细节。在大多数情况下,这需要对程序进行一定程度的重组——例如,程序的 main 函数被 Tcl “事件循环”取代。

另一个缺点是 Tcl 和 Tk 库非常庞大——链接它们会生成超过 1 兆字节的可执行文件。虽然现在有可用的 Tcl 和 Tk 共享库,但这对于某些人来说是一个设计上的考虑因素。

这种方法提出的基本范例是,人们用 C 实现新的 Tcl 函数,而这些 Tcl 函数可以从一个脚本中调用,该脚本将您的程序用作 Tcl/Tk 解释器——替代特定应用程序的 wish

我的解决方案可能功能较弱,但从程序员的角度来看也更加直接。 其想法是 fork 一个 wish 实例作为您的 C 程序的子进程,并通过两个管道与 wish 通信。 wish 作为一个单独的进程,不直接链接到您的 C 程序。 它被用作 Tcl 和 Tk 命令的“服务器”——您将 Tcl/Tk 命令通过管道发送到 wishwish 执行它们(例如,通过创建按钮、绘制图形等)。 您可以让 wish 响应事件(例如,当用户单击 wish 窗口中的按钮时)将其标准输出打印为字符串——您的 C 程序可以从读取管道接收这些字符串并对其进行操作。

这种机制更符合 Unix 使用小型工具处理特定任务的理念。 您的 C 程序专注于特定于应用程序的处理,并简单地将 Tcl/Tk 命令写入管道。 wish 专注于执行这些命令。

此解决方案还避免了为使用 Tcl 和 Tk 编写的每个应用程序都单独替换 wish 的问题。 这样,所有应用程序都可以执行相同的 wish 副本,并以不同的方式与其通信。

Using Tcl and Tk from Your C Programs

图 1

本月,我将演示一个使用这些概念的“真实世界”应用程序。 我在康奈尔大学进行的机器视觉研究需要我可视化三维点集。 (对于好奇的人,问题涉及特征分类:对于图像中的每个区域,量化了五个特征,例如平均强度、Canny 边缘密度等等。问题是通过将每个区域视为五维特征空间中的一个点,并使用 k-最近邻聚类算法将区域分组在一起来对相似区域进行分类。 我需要获取这个 5D 空间的三维切片,为每个点分配一个类型,并通过旋转、缩放等方式实时查看它。 这将使我能够验证我的特征是否聚类良好。)本质上,这是一个用于手头任务的简单科学可视化程序; 使用 Tcl 和 Tk 编写这个程序比使用当时可用的大型可视化软件包要容易得多。 此外,我可以根据自己的喜好自定义它。

该程序读入一个数据文件,其中包含 3D 坐标,每个点一个坐标。 每个点还被分配了一个“类型”,这是一个从 0 到 6 的整数。 每个点都经过一个简单的 3D 到 2D 的转换,并根据类型用不同的颜色绘制。 使用 wish 画布小部件进行绘图; wish 提供滚动条,允许您旋转和缩放数据集。 图 1(上图)显示了程序在约 70 个点的样本数据集上的外观。

请注意,此程序的原始版本包含其他功能,例如显示轴的选项。 为了适合这里,我已经大幅精简了代码。

我们首先需要的是某种方法来启动子进程并通过两个管道与其通信。 (此实现中使用了两个管道:一个用于写入子进程,另一个用于从中读取。 最后,我发现这比同步使用单个管道更简单。)

这是代码,我称之为 child.c,用于执行此操作

/* child.c */
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/time.h>
#include "child.h"
/* Exec the named cmd as a child process, returning
 * two pipes to communicate with the process, and
 * the child's process ID */
int start_child(char *cmd, FILE **readpipe, FILE
        **writepipe) {
   int childpid, pipe1[2], pipe2[2];
   if ((pipe(pipe1) < 0) || (pipe(pipe2) < 0)) {
        perror("pipe"); exit(-1);
}
   if ((childpid = vfork()) < 0) {
     perror("fork"); exit(-1);
   } else if (childpid > 0) {  /* Parent. */
     close(pipe1[0]); close(pipe2[1]);
     /* Write to child is pipe1[1], read from
      * child is pipe2[0].  */
     *readpipe = fdopen(pipe2[0],"r");
     *writepipe=fdopen(pipe1[1],"w");
     setlinebuf(*writepipe);
     return childpid;
   } else {  /* Child. */
     close(pipe1[1]); close(pipe2[0]);
     /* Read from parent is pipe1[0], write to
      * parent is pipe2[1].  */
     dup2(pipe1[0],0);
     dup2(pipe2[1],1);
     close(pipe1[0]); close(pipe2[1]);
     if (execlp(cmd,cmd,NULL) < 0)
        perror("execlp");
     /* Never returns */
} }

如果您熟悉 Unix 系统编程,这是一个 cookbook 函数。 我们使用 vfork(fork 也可以)启动一个子进程,并在子进程中 execlp 传递给函数的命令。 传递给 start_child 的命令在使用此函数时必须在您的路径中; 此外,您不能将命令行参数传递给该命令。 添加代码来执行此操作很容易,但为了简洁起见,我们在此处不显示。

我们使用 dup2 将子进程的标准输入连接到写入管道,并将子进程的标准输出连接到读取管道。 这样,子进程打印到 stdout 的任何内容都将显示在 readpipe 上,而父进程写入 writepipe 的任何内容都将显示在子进程的 stdin 上。 在父进程中,我们使用 fdopen 将管道视为 stdio FILE 指针,并设置 linebuf 以强制在每次发送换行符时刷新写入管道。 这省去了每次向管道写入字符串时都使用 fflush 的麻烦。

头文件 child.h 仅包含 start_child 的原型。 它应包含在任何使用上述函数的代码中。

#ifndef _mdw_CHILD_H
#define _mdw_CHILD_H
#include stdio.h
#include sys/types.h
#include sys/time.h
extern int start_child(char *cmd,
        FILE **readpipe, FILE **writepipe);
#endif

现在,我们可以编写一个 C 程序来调用 start_child 以执行 wish 作为子进程。 我们将 Tcl/Tk 命令写入 writepipe,并从 wish 在 readpipe 上读取响应。 例如,我们可以让 wish 在按钮被按下或滚动条移动时将字符串打印到 stdout; 我们的 C 程序将看到这个字符串并对其进行操作。

这是代码 splot.c,它实现了 3D 数据集查看器。

/* splot.c */
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <assert.h>
#include "child.h"
#define Z_DIST 400.0
#define SCALE_FACTOR 100.0
/* Factor for degrees to radians */
#define DEG2RAD 0.0174532
typedef struct _point_list {
   float x, y, z;
   int xd, yd;
   int type; /* Color */
   struct _point_list *next;
}  point_list;
static char *colornames[] = { "red",
        "blue", "slateblue", "lightblue",
        "yellow", "orange",
        "gray90"
};
inline void matrix(float *a, float *b,
                float sinr, float cosr) {
        float tma;
        tma = *a;
        *a = (tma * cosr) - (*b * sinr);
        *b = (tma * sinr) + (*b * cosr);
}
void plot_points(FILE *read_from, FILE *write_to,
        point_list *list, char *canvas_name,
        float xr, float yr, float zr,
        float s, int half) {
point_list *node;
float cx, sx, cy, sy, cz, sz, mz;
float x,y,z;
xr *= DEG2RAD; yr *= DEG2RAD; zr *= DEG2RAD;
s /= SCALE_FACTOR;
cx = cos(xr); sx = sin(xr);
cy = cos(yr); sy = sin(yr);
cz = cos(zr); sz = sin(zr);
for (node = list; node != NULL;
        node = node->next) {
   /* Simple 3D transform with perspective */
   x = (node->x * s); y = (node->y * s);
   z = (node->z * s);
   matrix(&x,&y,sz,cz); matrix(&x,&z,sy,cy);
   matrix(&y,&z,sx,cx);
   mz = Z_DIST - z; if (mz < 3.4e-3) mz = 3.4e-3;
   x /= (mz * (1.0/Z_DIST));
   y /= (mz * (1.0/Z_DIST));
   node->xd = x+half; node->yd = y+half;
}
/* Erase points */
fprintf(write_to,"%s delete dots\n",canvas_name);
for (node = list; node != NULL;
        node = node->next) {
   /* Send canvas command to wish... create
    * an oval on the canvas for each point. */
   fprintf(write_to,
        "%s create oval %d %d %d %d " \
        "-fill %s -outline %s -tags dots\n",
        canvas_name,(node->xd)-3,(node->yd)-3,
        (node->xd)+3,(node->yd)+3,
        colornames[node->type],
        colornames[node->type]);
}
}
/* Create dataset list given filename to read */
point_list *load_points(char *fname) {
   FILE *fp;
   point_list *thelist = NULL, *node;
   assert (fp = fopen(fname,"r"));
   while (!feof(fp)) {
        assert (node =
            (point_list *)malloc(sizeof(point_list)));
        if (fscanf(fp,"%f %f %f %d",
                &(node->x),&(node->y),&(node->z),
                &(node->type)) == 4) {
            node->next = thelist;
            thelist = node;
        }
}
   fclose(fp);
   return thelist;
}
void main(int argc,char **argv) {
   FILE *read_from, *write_to;
   char result[80], canvas_name[5];
   float xr,yr,zr,s;
   int childpid, half;
   point_list *thelist;
   assert(argc == 2);
   thelist = load_points(argv[1]);
   childpid = start_child("wish",
                &read_from,&write_to);
   /* Tell wish to read the init script */
   fprintf(write_to,"source splot.tcl\n");
   while(1) {
        /* Blocks on read from wish */
        if (fgets(result,80,read_from) <= 0) exit(0);
                /* Exit if wish dies */
        /* Scan the string from wish */
        if ((sscanf(result,"p %s %f %f %f %f %d",
                canvas_name,&xr,&yr,&zr,
                &s,&half)) == 6)
           plot_points(read_from,write_to,thelist,
   else
        fprintf(stderr,"Bad command: %s\n",result);
   }
}

要构建上面的程序(称之为 splot),您可以使用命令

gcc -O2 -o splot splot.c child.c -lm

您应该会发现 splot 非常简单明了。

我们首先做的是读取命令行上命名的数据文件,使用 load_points 函数。 此函数读取看起来像这样的文件

-50 -50 -50 0
 50 -50 -50 1
-50  50 -50 2
-50 -50  50 3
-50  50  50 4
 50 -50  50 5
 50  50 -50 1
 50  50  50 2

(此特定数据集定义了一个立方体。 第四列指示每个点的类型或颜色。)load_points 读取每一行并将值作为 point_list 类型的链表返回。 接下来,我们使用 start_child 启动 wish。 写入 write_to 的任何内容都将被 wish 读取为 Tcl/Tk 命令。 首先,我们发送命令 source splot.tcl,这将导致 wish 读取脚本 splot.tcl,如下所示。

# splot.tcl
option add *Width 10
# Called whenever we replot the points
proc replot val {
        puts stdout "p .c [.sf.rxscroll get] \
                           [.sf.ryscroll get] \
                           [.sf.rzscroll get] \
                           [.sf.sscroll get] 250"
        flush stdout
}
# Create canvas widget
canvas .c -width 500 -height 500 -bg black
pack .c -side top
# Frame to hold scrollbars
frame   .sf
pack .sf -expand 1 -fill x
# Scrollbars for rotating view. Call replot whenever
# we move them.
scale  .sf.rxscroll -label "X Rotate" -length 500 \
 -from 0 -to 360 -command "replot" -orient horiz
scale   .sf.ryscroll -label "Y Rotate" -length 500 \
 -from 0 -to 360 -command "replot" -orient horiz
scale   .sf.rzscroll -label "Z Rotate" -length 500 \
 -from 0 -to 360 -command "replot" -orient horiz
# Scrollbar for scaling view.
 scale .sf.sscroll -label "Scale" -length 500 \
  -from 1 -to 1000 -command "replot" -orient horiz \
  -showvalue 0
 .sf.sscroll set 500
# Pack them into the frame
pack  .sf.rxscroll .sf.ryscroll .sf.rzscroll \
      .sf.sscroll -side top
# Frame for holding buttons
frame .bf
pack    .bf -expand 1 -fill x
# Exit button
button .bf.exit -text "Exit" -command {exit}
# Reset button
button .bf.sreset -text "Reset" -command \
   {.sf.sscroll set 500; .sf.rxscroll set 0;
    .sf.ryscroll set 0; .sf.rzscroll set 0; replot 0}
# Dump postscript
button .bf.psout -text "Dump postscript" -command \
   {.c postscript -colormode gray -file "ps.out"}
# Pack buttons into frame
pack  .bf.exit .bf.sreset .bf.psout -side left \
   -expand 1 -fill x
# Call replot
replot 0

此脚本中的几乎所有内容都在 12 月号中介绍过; 如果您不理解它,请查看 scrollbar、button 等的 Tcl/Tk 手册页(或订购过刊)。

在告诉 wish 读取 splot.tcl 之后,程序进入一个读取循环,使用 fgets 从读取管道读取行。 这会导致 splot 进入休眠状态,直到管道上有数据可读取。 如果您希望您的程序在等待 wish 的输出时继续运行,则有几种替代方案。 您可以调用 select 来轮询管道上是否有待处理数据,或者您可以将管道设置为使用非阻塞 I/O(请参阅 fcntl 的手册页)。 任何关于 Unix 系统编程的书籍都可以提供帮助。

每当滚动条移动时,它们都会调用 splot.tcl 中的 replot 函数。 这会打印一个以字母“p”开头的行,后跟要绘制的画布小部件的名称、旋转和缩放滚动条的值以及画布小部件的半高。 后者用于在绘制图像时将图像居中在画布中。

请注意,我们必须在向 stdout 写入命令后刷新 stdout。 否则,命令将被缓冲,并且不会立即发送到 splot

一旦 splot 收到此行,它将使用 sscanf 解析值并调用 plot_points。 此函数实现了一个非常简单但相对快速的 3D 透视变换,并将其应用于每个点。 对于每个点,我们向 wish 发送一个 canvas 命令,以根据其在变换后的 2D 位置创建一个椭圆对象。 变量 half 用于将点集居中在画布上。 colornames 数组使用每个点结构的类型字段进行索引以设置颜色。

您已经得到了! 一个完整的可视化程序,只有几千字节的 C 和 Tcl 代码。 试用一下:输入上面的代码,编译它,然后将程序作为 splot cube.dat 运行,其中 cube.dat 包含上面给出的 3D 立方体的数据集。 您应该能够在 wish 窗口中翻滚和缩放立方体。 在我的系统上,这非常快——我可以查看包含数百个点的数据集,而几乎没有明显的延迟。

但是,这里的想法是在 C 代码中编写程序的所有速度关键部分,并允许 wish 仅处理用户界面。 请记住,Tcl 和 Tk 将所有内容都作为脚本传递,因此您的 Tcl 代码越紧凑越好。 例如,请注意我们如何在 C 代码中进行角度到弧度的转换和点缩放。 使用 Tcl expr 命令执行相同的操作将需要更高的开销。

此程序有许多可能的扩展。 例如,您可以向 splot.tcl 添加按钮或其他滚动条,这些按钮或滚动条将导致其他类型的命令打印到 wish 的 stdout。 例如,splot 中的读取循环可以根据从 wish 收到的行的第一个字符执行 switch 操作,并根据该字符执行不同的操作。 只要您的 C 代码和 Tcl 脚本在使用的命令格式上达成一致,您就“如鱼得水”了。

如果您对此代码有疑问或在使其为您工作时遇到问题,请随时与我联系。 我建议购买一本 John Ousterhout 的著作《Tcl 和 Tk 工具包》(Addison-Wesley 出版),以及一本关于 Unix 系统编程的书籍,其中将涵盖使用管道进行进程间通信的详细信息。

下次见,祝您编程愉快。

Matt Welsh (mdw@sunsite.unc.edu) Matt Welsh 是一位系统黑客和作家,与 Linux 文档项目合作。

加载 Disqus 评论