Blog IndexPosts by TagHome

Writing Drivers for 2.11BSD

Posted <2021-10-14 Thu 21:35> by Aaron S. Jackson.

I am not a good programmer. I spent the past 6 years of my life writing lousy Lua, messy MATLAB and shameful shell scripts in an academic setting. There will be things in this post which are completely wrong. Feel free to send me an email and let me know if this is the case.

I recently purchased an IBV11-A controller off eBay for the bargain price of £20. This is a GPIB (General Purpose Interface Bus, or more officially, an IEEE-488) controller card, allowing a PDP-11 to interface with equipment such as older digital oscilloscopes, laboratory power supplies, printers and even floppy drives. The bus has some nice features, such as having multiple devices hanging off the same controller, either "daisy chained" or in some kind of a star topology.

My PDP-11 runs 2.11BSD. This release of the second BSD incorporates some features from 4.3BSD into 2.10BSD, notably shadow password files, the TMSCP tape driver, and longer filename support. For a while now, I've been wanting to get to grips with how this early kernel is structured. The IBV11 controller seemed like a good candidate for this learning, since there is no support for it within the kernel at present. Also I'm not entirely sure why, but I really want to control my oscilloscope and power supply from my PDP-11. I have plans for some other drivers in future, such as the Dowty SBD video frame buffer card and a random 64 bit digital I/O board, but the IBV11 seemed line an ideal first kernel project.

Lacking experience in kernel development, I found it quite difficult to pull together all the necessary knowledge for writing a driver. The kernel source isn't particularly well commented (bytes were expensive back then) and the man pages are generally quite short and lacking in detail. I spent most of my time on this project reading other drivers and jumping between random files in the kernel. Hopefully this post will help me retain some of this information, but also help others get started quicker if they decide they want to add support for a card in 2.11BSD.

The General Purpose Interface Bus

GPIB bus a parallel communications standard consisting of 8 bidirectional data lines, along with a number of bidirectional signal lines. Each device on the bus has an address and any which is sent at the beginning of each data frame. It is commonly used on test equipment, such as power supplies and oscilloscopes, but also more typical computing peripherals, such as floppy drives, scanners and printers. Since test equipment often has a very long shelf life, GPIB is still commonly used in laboratory settings, even though modern devices are no longer sold with GPIB support. One of the novelties of GPIB is the double sided plug has both male and female connectors. This allows devices to be conveniently daisy chained or arranged in a star configuration.

The address of a GPIB device is usually specified using physical switches on the rear of the device, although this can also be handled in software just as easily. Each message sent on the bus is prefixed with a address of the device it is intended for.

The IBV11 Controller

How we talk to the IBV11 controller is described in detail in the user guide. Fundamentally however, the controller provides two addressable registers. These are both one word (2 bytes) long. The first is the Instrument Bus Status (IBS) register, and the second is the Instrument Bus Data (IBD) register. Both are shown below, expanded to see name of each bit. The first row for each register denotes whether the bit is read-only or read-write. The exception to this is the IBS SRQ bit, which is only read-write if the IBV11 is the only GPIB controller (which requires a switch on the board to be enabled).

15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 0

Most of these bits follow the standard GPIB signal names, except for DAV and DAC which are the inverted versions of NDAV and NDAC. IBS is located (by default) at 1601508 and IBD is in available at the next word in memory. The card also has four vector addresses, the first of which defaults to 4208. On the PDP-11 (probably different platforms too), each vector is two words long. The first the interrupt handler address, and the second is the processor status word, which includes an interrupt priority. These interrupt vectors are as follows,

The 2.11BSD kernel is quite picky about interrupt priorities. The IBV11 user guide isn't particularly kind when it comes to telling you what priority level these interrupts are raised. I will write more about interrupts and priorities later in this post, but for now, we can figure out the interrupt priorities from the assembly source listed on page 51. The top reads,

000430  001024   ; Command/Talker Interrupt address
000432  000200   ; PSW
000434  001056   ; Listener Interrupt address
000436  000200   ; PSW

As mentioned earlier, interrupt vectors consist of two words, the first being the address of the interrupt handler, and the second being the Processor Status Word (PSW). We can break up the value of this PSW to figure out the interrupt priority. If we split up the least significant digits into binary, we get:

010 000 000
 ^^ ^

Bits 5 to 7 (zero indexed) are the interrupt priority level, telling us that in this case, the priorities are level 410. The page of assembler, in the user guide, also gives us a good idea about how we should interact with the card. There are a few things we should take note of:

Now that we have learnt about how GPIB commands are formed and studied the controller's example assembler, we have a decent idea of how we can use the controller to talk to an interface. Next we need to understand a bit about how the 2.11BSD kernel is structured so we can put all three pieces together.

2.11BSD Structure

The kernel source is stored in /usr/src/sys/, but the directory structure of 2.11BSD does not exactly match up with the original intentions. MASSBUS drivers should be placed in the pdpmba directory, while UNIBUS drivers should be placed in pdpuba. Unfortunately, the pdpmba folder is empty and all the MASSBUS drivers are in the pdpuba directory. This also doesn't separate drivers for UNIBUS and QBUS systems, but this might be because so much of a given driver can be shared regardless the bus being used for communication - The same is not true for MASSBUS drivers though. Network drivers are located in pdpif. Machine specific, such as memory mapping, goes in pdp. Some drivers, mainly for disk controllers, go in both pdpuba and pdpstand, although in most cases the drivers in pdpstand seem to be smaller, supporting on a subset of the functionality.

The autoconfig directory contains the source for a programme called by init, just before entering the single user mode shell during boot. It checks that drivers provide both a probe and attach routine, followed by checking if the device is actually present. Some cards support dynamic interrupt vector addresses, such as MSCP disk controllers. In these cases, autoconfig is responsible for setting those vector addresses to a free memory location. Information about which devices should be present is defined in /etc/dtab. We can actually get a driver working without having to touch anything in autoconfig or dtab, but it isn't really the right way to build a driver.

In order to hook the driver into the kernel, we also need to modify pdp/conf.c. This file checks whether a driver is enabled at compile time, and provides the function prototypes for calls such as open, close, read, and write. If a driver isn't present, these are mapped to a nodev routine instead. conf.c also contains a table of all these calls, for all drivers.

Writing the Driver

Character and Block based devices

Devices under Unix tend to fall in one of two categories: block devices or character devices. The fundamental difference between the two is that block devices have buffering. This means sending a single byte to the block device will not result in a single write. A character device does not buffer bytes, and instead handles each byte as they arrive. Character device read and write functions are synchronous - they do not return until the byte has been written or read. Block devices do not even have read and write functions, instead they implement what is called (at least in the BSD world) a strategy function. The strategy function handles both reading and writing, but from buffers, and is called periodically, usually when the buffers are full.

Which is better for a GPIB interface? In some ways, GPIB is a lot like a serial port - when talking to, for example, an oscilloscope, you type a command, press return, and the remote device responds. Under Unix, serial devices are implemented as character devices, so this might lead us to believe that our driver should also implement a character device. However, GPIB is a G eneral P urpose bus, and many GPIB devices existed which would typically be considered block devices, such as floppy drives.

Realistically though, neither block or character devices are generic enough to support all the possibly functionality of GPIB. I'm not sure we could even do it if we started using ioctl. This leaves us with two options. We either make some assumptions about what kind of devices we support, or we provide a library within the kernel, allowing 'higher level' drivers to interact with devices across GPIB. Unfortunately, given that this is my first kernel project, I chose the former option. I will be working under the assumption that all the devices I talk to are like oscilloscopes, where we can just send text based commands, terminated with line breaks.

Device Nodes

Earlier I mentioned that in order to provide a hook into the kernel, we must modify pdp/conf.c to define what a device driver supports (e.g. read, write, etc.). However, we also have to define a major number to our device. The major number ties a file system node (e.g. /dev/ttyS0) to a specific driver. It is a unique numeric identifier for that driver. For our IBV11 driver, we will use 27, because it's the next available number (26 is used by the file descriptor device). Some controllers may have multiple devices attached to them, and for those we also have a minor number. Creating a device node is quite simple - we just use the mknod program which takes a path, the type of device (character or block), the major number and the minor number.

$ mknod /dev/ibv0 c 27 1

For our IBV11 driver, we can use the minor number to indicate the address of the device we are talking to. This means we could, for example, create a device called /dev/ibv/scope which points to address 3 on the GPIB bus.

A Dummy Driver

To figure out how to pass bytes from user space to kernel space, and back, I decided I would write a dummy driver. The purpose of this driver is to spit out a string when we read from it (I chose "Hello"), and to write any character written to the device to syslogd.

Let's start with pdpuba/dummy.c. This first chunk pulls in the syslog and uio headers, defines a buffer and creates the open function. The open function should return 0 if successful, and a non-zero value if there was an error. The man page for open describes some of the common error codes you might want to return if this fails.

#include "../h/syslog.h"
#include "../h/uio.h"

#define DUMMY_BUFF_LEN 128

dummyopen(dev, flag)
     dev_t dev;
     short flag;
  log(LOG_INFO, "dummy open\n");
  return 0;

The close method is very similar - it should also return 0 if successful. For our dummy driver, we are not actually opening a device, so we don't need to worry about this. Still, it is a good opportunity to add a log call because it will help us understand when these functions are called.

dummyclose(dev, flag)
     dev_t dev;
     short flag;
  log(LOG_INFO, "dummy close\n");
  return 0;

Next, the read method. This makes use of the ureadc function, which conveniently passes a single character into the uio object.

dummyread(dev, uio, flag)
     dev_t dev;
     struct uio *uio;
     int flag;
  log(LOG_INFO, "dummy read\n");
  ureadc('H', uio);
  ureadc('e', uio);
  ureadc('l', uio);
  ureadc('l', uio);
  ureadc('o', uio);

  return 0;

Finally, the write function. The majority of this was copied from the lp driver. For our IBV11 driver, this will handle the actual pushing of bytes out to GPIB.

dummywrite(dev, uio, flag)
     dev_t dev;
     struct uio *uio;
     int flag;
  register int n;
  register char *cp;
  char inbuf[DUMMY_BUFF_LEN];
  int error;

  while (n = MIN(DUMMY_BUFF_LEN, uio->uio_resid)) {
    cp = inbuf;
    error = uiomove(cp, (int)n, uio);
    if (error)
      return error;
      log(LOG_NOTICE, "dummy write: c=%c\n", *cp++);
    while (--n);
  return 0;

While there are a few other files which need to be modified (e.g. scb.s and conf.c), this is pretty much all that is required for a simple character device driver. I built this into the kernel as device number 27, so below is a quick example of how we can use it.

$ mknod /dev/dummy c 27 0
$ cat /dev/dummy
$ echo Hello > /dev/dummy

Taking a look at /usr/adm/messages will reveal a dummy open, several dummy reads, and a dummy close. The echo line (i.e. writing to the device) will also produce a dummy open and close, but also a bunch of dummy writes, one for each character.


One of the things I always appreciated about C (which is also a thing many people hate about C due to security concerns etc), is the ability to take address in memory and just cast it to an object. We learnt earlier than our IBV card has two registers, the status register and the data register. These registers are contiguous in memory, occupying a single word of memory each (16 bits, because it's a PDP-11). So, what do you think if we define the following structure and cast a pointer to it?

struct ibvdevice {
  short ibs; /* instrument bus status */
  short ibd; /* instrument bus data */

struct ibvdevice *ibv = (struct ibvdevice *)0160150;

Well, we can easily address the data register at ibv->ibd and the status register at ibv->ibs. However, we learnt earlier that we can only actually write to the lower half of the data register. We are also going to need to query specifically that byte of memory quite frequently. Of course, we could just and it with something (lame), or we could make our structure above more useful.

struct ibvdevice {
  union {
    short ibs; /* Control status register */
    struct {
      char ibsl; /* low byte for r/w */
      char ibsh; /* high byte for ro (except for b15) */
    } s;
  } un1;
#define ibvcsr un1.ibs
#define ibvcsrl un1.s.ibsl
#define ibvcsrh un1.s.ibsh
  union {
    short ibd; /* Data register */
    struct {
      char ibd_io; /* i/o data (low byte) */
      char ibd_status; /* data bus status (high byte) */
    } s;
  } un2;
#define ibvd un2.ibd
#define ibvio un2.s.ibd_io
#define ibvds un2.s.ibd_status

This is what my struct actually ended up looking like. I know, sort of disgusting, but allows us to very easily access every part of the status and data registers with ease.

Interrupt Handling and Priorities

PDP-11 interrupts are defined as vector addresses which consist of 2 words. The first word contains the address of interrupt handler, and the second word contains the processor status word (PSW). Going into details of all the bits within the PSW isn't really relevant to the our goal here. However, what we do need to know is that three bits of the PSW define the priority of an interrupt handler. This allows more important interrupts to be processed immediately, deferring less important interrupts until the processor isn't thinking so hard. The actual hardware which drives these interrupts is based around three interrupt lines on the physical backplane. In order for the CPU to handle the interrupt, the interrupt priorities on the bus and the PSW must match.

Interrupt handlers are defined in two places scb.s. First, towards the top of the file, where we use DEVTRAP macros to specify the vector address, handler and priority level. For our IBV11 driver, this looks like:

#if NIBV > 0                /* IBV11 */
    DEVTRAP(420,    ibvinterr, br4) /* Error */
    DEVTRAP(424,    ibvintsr,  br4) /* Service Request */
    DEVTRAP(430,    ibvintcmd, br4) /* Command and talker */
    DEVTRAP(434,    ibvintlis, br4) /* Listener

If we try to build this as is, the compiler will complain, telling us that these functions aren't defined. We have to define them towards the bottom of this

#if NIBV > 0                /* IBV11 */

It seems simple when I write this here, but it took me quite a while to figure this bit out 😅.


Once all the pieces have been put in the right place, we can modify our GENERIC configuration file to include a flag which will enable or disable the IBV11 driver at compile time.

NIBV        1 # IBV-11

We also need to modify the config script to produce a header file for the IBV11. There are lots of examples within this script, so not worth explaining in any further detail.

The general procedure for compiling a custom 2.11BSD kernel is roughly as follows:

  1. Copy GENERIC kernel configuration file to a name of your choosing. For this I usually use hostname, unless I'm testing some weird stuff.
  2. Modify your kernel configuration to enable the devices specific to your machine.
  3. Run the config script with your configuration file as an argument. This will produce a build directory one which you can cd into.
  4. Run make - this will almost certainly fail with an error about overlays being too big.
  5. Rearrange the kernel objects so they fit together. These overlays correspond to memory ranges which can be accessed at a given time. The memory management unit manages this for us, but we need to be sensible about the order of our objects. The most used objects need to be first, and related objects should be close together to prevent excessive page switching. The base can be no more than 56KB, and each overlay can be no more than 8KB. You can use the size command to find your chonkiest kernel libraries.

Once you have finally got it to compile, you should take a backup of your current kernel and then install your new one before rebooting.

$ cp /unix /unixold
$ make install

With any luck, things should boot up fine, but if they don't you might need to specify unixold at boot time to figure out what went wrong.


Originally I constructed a fairly ugly looking cable to go from the 20 pin IDC header, to the 24 pin GPIB Centronix plug. However, while searching for "digital dec" (as you do) on eBay one night, I accidentally stumbled across the correct cable for ~£28, so snapped it up.

I'm still working on some bugs with this driver, so I'll include some more stuff here once it's working fully. Just wanted to get this blog post out of draft mode for now. :-)

Wanting to leave a comment?

Comments and feedback are welcome by email (

Related posts:

Tags: oldcomputers pdp11 computing programming

Blog IndexPosts by TagHome

Copyright 2007-2022 Aaron S. Jackson (compiled: Sun 2 Jan 00:24:11 GMT 2022)