Using Tcl and Tk from Your C Programs
Tcl was originally designed to be an “extension language”--that is, an interpreted script language to be embedded in another program (say, one written in C) and used to either handle the mundane tasks of user customizations, or, with Tk, more complex tasks such as providing an X Window System interface for the program. The Tcl interpreter itself is simply a library of functions which you can invoke from your program; Tk is a series of routines used in conjunction with the Tcl interpreter. Although you can write Tcl/Tk programs entirely as scripts, to be executed via wish, this is only one side of the story. To really make this system shine you need to utilize Tcl and Tk from other programs.
Ousterhout's book, Tcl and the Tk Toolkit, contains exhaustive material on linking the Tcl interpreter with your C programs. What this generally entails is having your program produce or read Tcl commands from some source and pass the commands, as strings, to the Tcl interpreter functions, which return the result of evaluating and executing the Tcl expressions.
While this mechanism is certainly useful, there are several drawbacks. First of all, it requires the programmer to learn the details of interfacing their C code with the Tcl interpreter. While this is not usually difficult, it means that the programmer must not only work partly in C and partly in Tcl (which may be an unfamiliar language at first), but also learn the details of using the Tcl library routines. In most cases this requires the program to be reorganized to some extent—for example, the program's main function is replaced with a Tcl “event loop”.
The other drawback is that the Tcl and Tk libraries are literally huge—linking against them produces executables over a megabyte in size. Although there are now Tcl and Tk shared libraries available, this is a design concern for some.
The basic paradigm presented by this approach is that one implements new Tcl functions in C, and those Tcl functions can be called from a script which uses your program as a Tcl/Tk interpreter—a replacement for wish for your particular application.
My solution to this problem is perhaps less powerful, but also much more straightforward from the point-of-view of the programmer. The idea is to fork an instance of wish as a child process of your C program and talk to wish via two pipes. wish, being a separate process, isn't linked directly to your C program. It is used as a “server” for Tcl and Tk commands—you send Tcl/Tk commands down the pipe to wish, which executes them (say, by creating buttons, drawing graphics, whatever). You can have wish print strings to its standard output in response to events (say, when the user clicks a button in the wish window)--your C program can receive these strings from the read pipe and act upon them.
This mechanism is more in line with the Unix philosophy of using small tools to handle particular tasks. Your C program concerns itself with application-specific processing, and simply writing Tcl/Tk commands to a pipe. wish concerns itself with executing these commands.
This solution also gets around the problem of having a separate wish replacement for each application that you write using Tcl and Tk. In this way, all applications can execute the same copy of wish and communicate with it in different ways.
This month, I'm going to demonstrate a “real world” application which uses these concepts. My machine vision research at Cornell required me to visualize three-dimensional point sets. (For the curious, the problem dealt with feature classification: for each region in an image, five features were quantified, such as average intensity, Canny edge density, and so forth. The problem is to classify like regions by treating each region as a point in a five-dimensional feature space, and group regions together using the k-nearest neighbor clustering algorithm. I needed to take a 3D slice of this 5D space, assign a type to each point, and view it in realtime by rotating, scaling and so forth. This would allow me to verify that my features were clustering well.) Essentially, it's a simple scientific visualization program for the task at hand; this was much easier to write, using Tcl and Tk, than working with the large visualization packages that were available. Additionally, I could customize it to taste.
This program reads in a datafile consisting of 3D coordinates, one per point. Each point is also assigned a “type”, which is an integer from 0 to 6. Each point is given a simple 3D-to-2D transformation and plotted with a different color, based on the type. A wish canvas widget is used to do the plotting; wish provides scrollbars to allow you to rotate and scale the dataset. Figure 1 (above) shows what the program looks like on a sample dataset of about 70 points.
Note that the original version of this program contained other features, such as the option to display axes. I have trimmed down the code considerably in order for it to fit here.
The first thing that we need is some way to start up a child process and talk to it via two pipes. (Two pipes are used in this implementation: one for writing to the child, and one for reading from it. In the end I found this simpler than synchronizing the use of a single pipe.)
Here is the code, which I call child.c, to do this:
/* 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 */ } }
If you're familiar with Unix systems programming, this is a cookbook function. We use vfork (fork would do as well) to start a child process, and in the child execlp the command passed to the function. The command passed to start_child must be on your path when using this function; also, you can't pass command-line arguments to the command. It's easy to add the code to do this, but we don't show this here for sake of brevity.
We use dup2 to connect the child's standard input to the write pipe, and the child's standard output to the read pipe. In this way anything that the child prints to stdout will show up on readpipe, and anything the parent writes to writepipe will show up on the child's stdin. In the parent, we use fdopen to treat the pipes as stdio FILE pointers, and setlinebuf to force the write pipe to be flushed whenever we send a newline. This saves us the trouble of using fflush each time we write strings to the pipe.
The header file, child.h, simply contains a prototype for start_child. It should be included in any code which uses the above function.
#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
Now, we can write a C program to call start_child to execute wish as a child process. We write Tcl/Tk commands to writepipe, and read responses back from wish on readpipe. For example, we can have wish print a string to stdout whenever a button is pressed or a scrollbar moved; our C program will see this string and act upon it.
Here is the code, splot.c, which implements the 3D dataset viewer.
/* 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); } }
To build the above program (call it splot) you can use the command:
gcc -O2 -o splot splot.c child.c -lm
You should find splot to be fairly straightforward.
The first thing we do is read the data file named on the command line, using the load_points function. This function reads a file which looks like the following:
-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
(This particular dataset defines a cube. The fourth column indicates the type, or color, of each point.) load_points reads each line and returns the values as a linked list of type point_list. Next, we use start_child to fire up wish. Anything written to write_to will be read by wish as a Tcl/Tk command. First we send the command source splot.tcl, which causes wish to read the script splot.tcl, shown below.
# 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
Nearly everything in this script was introduced in the December issue; if you can't follow it, check the Tcl/Tk manpages for scrollbar, button, and so forth (or order back issues).
After telling wish to read splot.tcl, the program goes into a read loop, using fgets to read lines from the read pipe. This causes splot to sleep until there is data on the pipe to be read. If you wanted your program to continue running while waiting for output from wish, there are several alternatives. You could call select to poll for pending data on the pipe, or you could set the pipe to use non-blocking I/O (see the man page for fcntl). Any book on Unix systems programming can help.
Whenever the scrollbars are moved, they call the replot function within splot.tcl. This prints a line beginning with the letter “p”, followed by the name of the canvas widget to draw to, the values of the rotation and scale scrollbars, and the half-height of the canvas widget. This latter is used to center the image in the canvas when it is drawn.
Note that we must flush stdout after writing a command to it. Otherwise the commands would be buffered and not sent immediately to splot.
Once splot receives this line, it uses sscanf to parse the values and calls plot_points. This function implements a very simple, but relatively fast, 3D perspective transform, and applies it to each point. For each point, we send wish a canvas command to create an oval object based upon its 2D location after the transform. The variable half is used to center the point set on the canvas. The colornames array is indexed with the type field of each point structure to set the color.
There you have it! A complete visualization program in a few kilobytes of C and Tcl code. Try it out: Enter the above code, compile it, and run the program as splot cube.dat where cube.dat contains the dataset for the 3D cube given above. You should be able to tumble and scale the cube in the wish window. On my systems, this is remarkably fast—I can view datasets of several hundred points with very little noticeable lag.
However, the idea here is to code all of the speed-critical parts of the program in C, and allow wish to handle just the user interface. Remember that Tcl and Tk passes everything around as scripts, so the tighter your Tcl code, the better. For example, note how we do the degree-to-radian conversion and point scaling in the C code. Using a Tcl expr command to do the same would require greater overhead.
There are many possible extensions to this program. For example, you could add buttons or additional scrollbars to splot.tcl which would cause other kinds of commands to be printed to wishs stdout. The read loop in splot, for example, could do a switch based on the first character of the line received from wish and perform different actions based on that. As long as your C code and Tcl script agree on the command format used, you're “cooking with gas”.
Please feel free to get in touch with me if you have questions about this code or problems getting it to work for you. I suggest picking up a copy of John Ousterhout's book Tcl and the Tk Toolkit, from Addison-Wesley, as well as a book on Unix systems programming, which will cover the details of using pipes for interprocess communication.
Until next time, happy hacking.
Matt Welsh (mdw@sunsite.unc.edu) Matt Welsh is a systems hacker and writer, working with the Linux Documentation Project.