使用 Tcl 和 Tk 从您的 C 程序
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 命令通过管道发送到 wish,wish 执行它们(例如,通过创建按钮、绘制图形等)。 您可以让 wish 响应事件(例如,当用户单击 wish 窗口中的按钮时)将其标准输出打印为字符串——您的 C 程序可以从读取管道接收这些字符串并对其进行操作。
这种机制更符合 Unix 使用小型工具处理特定任务的理念。 您的 C 程序专注于特定于应用程序的处理,并简单地将 Tcl/Tk 命令写入管道。 wish 专注于执行这些命令。
此解决方案还避免了为使用 Tcl 和 Tk 编写的每个应用程序都单独替换 wish 的问题。 这样,所有应用程序都可以执行相同的 wish 副本,并以不同的方式与其通信。
本月,我将演示一个使用这些概念的“真实世界”应用程序。 我在康奈尔大学进行的机器视觉研究需要我可视化三维点集。 (对于好奇的人,问题涉及特征分类:对于图像中的每个区域,量化了五个特征,例如平均强度、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 文档项目合作。