Dr. Dobb's Journal February 1998
Connecting external hardware to DOS-based PCs usually requires no more than a simple Basic program. In comparison, connecting Linux-based computers to external hardware for data logging and/or control can be a headache because the operating system requires special device drivers to connect any device to the computer. In this article, we'll describe a general-purpose data-acquisition system for Linux that can be connected to the PC's parallel port to record eight channels of analog voltage. The input range for each channel is 0 to 4.095 volts. The rate at which the data can be recorded is limited only by the speed of operation of the PC; for a 66/486 DX2, this is about 160 msec per sample, as compared to 220
sec on a 20/386.
We built the hardware half of the data-acquisition system around Maxim's Max186 eight-channel, 12-bit analog-to-digital converter (ADC). The Max186 is a low-power, multichannel ADC with integrated S/H amplifier, internal clock source, and four-wire serial interface. It can be programmed to work in a variety of modes and has a peak-supply current consumption of only 2.5 mA at 133 KSPS. In the power-down mode, the current consumption drops to 2
A.
The eight ADC analog input pins can be programmed as eight-channel single-ended inputs, for four-channel differential inputs. Figure 1 is the circuit schematic. The circuit is powered by an external +5 V supply, though the current consumption is small enough to be powered by the serial port or onboard battery.
The ADC is interfaced to the PC via the parallel-printer adapter port. (For more information on the parallel port, see "The Enhanced Parallel Printer Port," by D.V. Gadre and Larry Stein, DDJ, October 1997. Available electronically; see "Resource Center," page 3.) To acquire a sample of the analog voltage at one of the input channels of the ADC, a conversion is started on the Max186 by clocking a control byte into the Din pin of the ADC. To clock a control byte into the ADC, the CS* pin of the ADC must be held low. Each rising edge on the SCLK pin clocks a bit into the Din pin. Table 1 lists the format of the control byte. At the end of this clocking sequence, the converted data is clocked out on the Dout pin of the ADC. The ADC generates two output data bytes with one leading zero and padded with three trailing zeros as the result of the conversion process. Each rising edge on the CLK pin outputs one data bit on the Dout pin from the ADC. In our design, the SCLK bit is connected to the D0 bit of the printer adapter data port. The Din pin is driven by the D7 bit of the data port. Since Dout is an input bit for the PC, it is read on the S7 bit of the status port of the parallel adapter. When the ADC is not being read, it is disabled by pulling the CS* high to reduce power consumption. For example, the control byte for accessing channel 0 in unipolar, single-ended mode with an external clock is 10001111 (that is, 0x8F). Table 2 presents the control byte for the eight channels with single-ended unipolar input and external clock source. Table 3 is the control byte for configuring the ADC to operate with differential, unipolar inputs. The entries in the Channel \# column refer to the two inputs whose difference is encoded by the ADC; that is, the third entry CH4-CH5 means that the voltage on CH5 is subtracted from that on CH4 and this difference is encoded.
Testing external hardware connected to Linux-based x86 systems is possible if you run the machine with root privileges. As a superuser, all hardware -- without any supervisory control -- is accessible.
For our project, we needed to access the three ports of the parallel-printer adapter. Listing One (testport.c) shows how we did this. The parallel adapter data-port address was determined by putting the machine in DOS mode. With the function ioperm(), the program is granted permission to access requested ports by the operating system. A similar function iopl() can also be used, but iopl() gives unlimited access to the program and should be avoided. Another feature that is important to note in the program is the syntax of the macro outb(). DOS programmers are used to a similar function outportb(port_address, port_value), but with the GCC compiler, the structure is outb(port_value, port_address). Listing One sends a byte to the data port and reads it back. It also reads the contents of the status and control registers. The value sent to the data port and the value read back should be the same, unless the port bits are being pulled externally.
It was important for us to have a device driver for the ADC under Linux, so that this device could be practically used in user mode in a multitasking environment for data acquisition. Since it is not possible to detail the construction of a character device driver under Linux here, we'll instead focus on the most relevant features. To build the driver, we wrote:
We then wrote test code as superuser to access the parallel port directly using the Linux ioperm or iopl call, which allows the program to access a window of specified width into the I/O space. The code tests for the presence of a parallel port, identifies which port is to be used, tests for the presence of the device (the ADC, for example), and finally reads data from the device directly using the inb and outb calls of Linux.
The code has to be compiled with the gcc -O option so that the inline calls inb and outb can be expanded properly, otherwise the linker will complain.
Since this testing can cause the system to hang (with a reboot as the only option), you must be careful when testing on a fully working multiuser system. Also, it is better to use the ioperm call rather than iopl since ioperm allows only restricted access to I/O ports.
Once we were satisfied that the ADC was working as expected, we moved on to the process of constructing the device driver and integrating it into the kernel.
The device driver code is structured as follows:
For Linux kernels 1.2 and greater, there exists a directory /usr/src/linux/drivers/char where the character device-driver codes are placed. The driver has a drivername_init function that, for the newer kernels, returns an int and takes a void. The void serves as the point of linking to the kernel. In the source code for mem.c (in /usr/src/linux/drivers/char), there is a function, chr_dev_init, in which one simply adds a devicename_init call, and recompiles the kernel. Listing Two shows the relevant portions of the mem.c file with the necessary modifications. ADC.c (available electronically) is the complete device-driver code which contains the adc_init code. The header file ADC.h is also available electronically.
The steps in the recompilation of the kernel are:
ifdef CONFIG_SANSON_ADC L_OBJS += ADC.o endif
mknod /dev/adc0 c 31 0 mknod /dev/adc1 c 31 1 mknod /dev/adc2 c 31 2 mknod /dev/adc3 c 31 3 mknod /dev/adc4 c 31 4 mknod /dev/adc5 c 31 5 mknod /dev/adc6 c 31 6 mknod /dev/adc7 c 31 7
After the compilation and linking, the resulting kernel is installed in the system. The ADC device is connected to the parallel port and powered on. While booting, the kernel calls the adc_init function, which:
Another way of including the device driver in the kernel is to dynamically insert it into a running kernel. This requires that the device driver contain two functions: init_module and cleanup_module. Dynamic installation into a running kernel is carried out via the insmod ADC.o command. It can be removed by the command rmmod ADC. This also requires that the string kernel_version be defined in the driver as static char * kernel_version= UTS_RELEASE;.
If the kernel complains of mismatched version, the module can be installed using insmod -f ADC.o. The init_module does the job of testing and registering the ADC driver with the kernel.
Once the registration is done, it is simple to use the ADC. If you want to use the functions we have written (in ADC.c), simply make a call like adc(CHANNEL_NUMBER, DEVICE_OPEN), followed by any number of calls to adc(CHANNEL_NUMBER, DEVICE_READ), closed with an adc(CHANNEL_NUMBER, DEVICE_CLOSE), then repeat the process with any channel (0-7). As currently implemented, the code allows only one user process to access any one channel of the ADC at a given time. The various channels on the ADC are configured as single-ended, unipolar inputs.
The other way to use the ADC is to open (with the system call open) the device file corresponding to the required channel number in /dev directory. The files are named /dev/adc0-/dev/adc7 and are made using the mknod command (mknod /dev/adc0 c MAJOR MINOR) as a superuser. Eight such files are made with the same major number (in this case, 31) and different minor numbers (0-7) for the eight channels of the ADC. If the open returns True, then you can read two _unsigned chars from the buffer, obtained using a read call on the file descriptor returned by open. These can be reassembled to form the actual output, after typecasting to an int. The device file must be closed with a close() call.
There are two ways of accessing the ADC. USE_ADC.c (also available electronically) shows how to access the device using the user function call. SYS_ADC.c (available electronically) shows how to access the device using the system call open.
One application for the SanSon DAS is as a temperature recorder. We used this data-acquisition system to record the ambient temperature of the surroundings using the circuit schematic in Figure 2 and the user code in log_temp.c. The code accesses the ADC channel 3 using the function adc() in adc.c. Figure 3 is a sample temperature record with this setup.
Ulrich Raich at CERN, Geneva, Switzerland was the first to show us the tricks of writing device drivers. His lecture notes on real-time control at the ICTP workshop in 1994 at Trieste, Italy, have helped us a lot in our work. The facilities at IUCAA, Pune, India, have been a great help in finishing this work rapidly. We also acknowledge the excellent work of the world-wide Linux hacker community.
Balluder, Karsten et al. "Selecting an Operating System, Part IV: Linux," Computers in Physics, January/February 1996.
LinuxLab Project web site: http://www.fu-berlin.de/~clausi.
Gadre, Dhananjay V. "Multichannel 12-bit ADC connects to PC," Design Ideas, EDN, April 25, 1996.
-- -- -- . "The Parallel Adapter as a Host Interface Port," DDJ, April 1996.
MAX186 Data Sheets, MAXIM Integrated Products.
Raich, Ulrich. "Linux Device Drivers," Lecture Notes, IIIrd ICTP college on Real-Time Control, ICTP, Trieste, 1994.
Templon, Jeffery A. "Evaluation of PC/ LINUX Systems for use as a Scientific Workstations," Computers in Physics, January/February 1996.
DDJ
/* testport.c *//* compile as: gcc -O testport.c */
/* Execution is possible only as a superuser*/
#include <asm/io.h>
#include <asm/segment.h>
#include <asm/system.h>
#include <unistd.h>
int ioperm();
#define port_add 0x378
void main()
{
unsigned char test_value;
int ret_value;
ret_value=ioperm(data_port, 3, 1);
if ( ret_value == -1 )
{
printf("Cannot get I/O permission\n");
exit(-1);
}
outb(0x55,port_add);
test_value=inb(port_add);
printf("\nValue at Data port= %x\n", test_value);
test_value=inb(port_add+1);
printf("\nValue at the Status Port= %x\n", test_value);
test_value=inb(port_add+2);
printf("\nValue at Control Port= %x\n", test_value);
}
/* linux/drivers/char/mem.c * Copyright (C) 1991, 1992 Linus Torvalds
*/
...
int chr_dev_init(void)
{
if (register_chrdev(MEM_MAJOR,"mem",&memory_fops))
printk("unable to get major %d for memory devs\n", MEM_MAJOR);
rand_initialize();
tty_init();
#ifdef CONFIG_PRINTER
lp_init();
#endif
#if defined (CONFIG_BUSMOUSE) || defined (CONFIG_82C710_MOUSE) || \
defined (CONFIG_PSMOUSE) || defined (CONFIG_MS_BUSMOUSE) || \
defined (CONFIG_ATIXL_BUSMOUSE)
mouse_init();
#endif
#ifdef CONFIG_SOUND
soundcard_init();
#endif
#if CONFIG_QIC02_TAPE
qic02_tape_init();
#endif
#ifdef CONFIG_SANSON_ADC
adc_init(); /* Here is where our device driver is initalized */
#endif
return 0;
}
...