Drivers for Serial-Attached Devices
March 15, 2003
Walter Oney

Copyright 2003 by Walter Oney. All rights reserved

I'll explain in this article how to write a driver for a device that attaches to a serial port in a PC. With the advent of the Universal Serial Bus, the standard RS-232 serial port has become much less important as an attachment point for PC hardware. One still finds devices that attach this way, though, including SmartCard readers, bar code scanners, and one-off  laboratory instruments.

The first section of the article describes the overall architecture of a driver for a serial-attached device and explains some of the factors you will need to consider in designing your hardware and in deciding which OS platforms to support. The second section contains sample code for inclusion in a WDM driver for a device that provides a Plug and Play identification string. The third section discusses how you would write an equivalent VxD driver for the Windows 9x/Me platforms. The fourth and final section of this article describes the alterations you would want to make to your driver package for a seriously stupid device that doesn't provide a PnP id.

Overview:

Let's suppose you need to write the driver for a widget that connects to a PC through a standard serial port, as shown in Figure One:

Figure One
A Serial-Attached Widget

Your driver fits into the system into the system between the serial port driver and other drivers, as suggested by Figure Two:

Figure Two
Driver Architecture for Serial-Attached Device

Figure Two doesn't, unfortunately, accurately describe the stack of drivers that you'll end up with in a real situation. Think of it as an architectural picture that shows the logical flow information. To see an accurate picture, we have to know which OS platform we're talking about and whether our widget has a PnP id.

Widgets with PnP identifiers, Windows 2000 and later systems:

If your device follows the standard described in the Plug and Play External COM Device Specification to provide a PnP id, the system SERENUM.SYS driver will find your widget whenever it enumerates the serial port to which the end user has attached it. In this situation, SERENUM will act as a bus driver and create a Physical Device Object (PDO) to represent the widget. Your driver will be a standard WDM function driver that the PnP Manager loads automatically. Using a tool such as the DevView utility that accompanies Programming the Microsoft Windows Driver Model Second Edition (Microsoft Press 2003), you could inspect the tree of device objects to generate a picture like Figure Three:

Figure Three
Driver Stack for Widget with PnP ID

In this figure, SERENUM appears twice: once in its role as an Upper Filter for a serial port, and once in its role as the bus driver that enumerates your widget.

In this architecture, SERENUM reads the PnP id from your widget. The id will be a pseudo-EISA identifer of the form XYZ1234, where "XYZ" is a 3-letter manufacturer prefix and "1234" is a 4-digit hex number you choose to differentiate your products, one from the other. TODO describe how to obtain the 3-letter prefix. The INF file in your driver package will have a model statement like this one:

"Acme Widget Model 101 (PNP)"=DriverInstall,SERENUM\XYZ1234

The PnP Manager matches the device id returned by SERENUM to this model statement in order to install your driver according to the directions in the DriverInstall section. It then loads your driver, calling DriverEntry and AddDevice in the normal way for WDM drivers. In your StartDevice function, you would normally send an IRP_MJ_CREATE to the lower driver. When SERENUM receives this IRP, it forwards it in the other driver stack down to SERIAL, which opens the port for your use. Thereafter, you send IRP_MJ_READ, IRP_MJ_WRITE, and IRP_MJ_DEVICE_CONTROL requests down the stack to SERENUM, which forwards them to SERIAL. You use these requests to control the RS-232 signal lines and and to send and receive data from your widget.

If possible, design your device so that removing the device changes one of the RS-232 signal lines CTS or DSR. Your driver can detect the change and initiate a surprise removal sequence that mimics the way other hot-pluggable devices behave.

Widgets without PnP identifiers, Windows 2000 and later systems:

If your device does not provide a PnP id, you'll install your driver as legacy driver. You need to give the end user a way to specify the COM port to which the device is attached, since scanning ports in a driver is not a good thing to do. The best, though not the easiest, way to do this is by means of a co-installer DLL that stores the port name in the Device Parameters registry subkey for your device. You'll then instruct the end user to use the Add Hardware wizard to install an instance of your device, and your instructions will include advice about filling in the COM port name in a dialog presented by your co-installer. To remove your device, the end user should click the Remove choice in the Device Manager.

Your INF file will use legacy syntax in the model statement, as in this example:

"Acme Widget Model 102 (Legacy)"=DriverInstall,*XYZ1234

Here, XYZ1234 is a pseudo-EISA identifier as in the previous example, but it appears with no bus driver qualification.

Figure Four illustrates the driver stack topology for his legacy situation:

Figure Four
Driver Stack for Legacy Widget

The root enumerator acts as the bus driver for your device. The PnP Manager loads your driver and calls DriverEntry and AddDevice in the normal way for a WDM driver. In your StartDevice function, you would normally read the registry to get the name of the attachment port, and then you'd call IoGetDeviceObjectPointer to open a handle to SERIAL.SYS and to obtain a DEVICE_OBJECT pointer to which you can submit IRPs. Thereafter, you send IRP_MJ_READ, IRP_MJ_WRITE, and IRP_MJ_DEVICE_CONTROL requests to SERIAL. You use these requests to control the RS-232 signal lines and and to send and receive data from your widget.

The major programming difference between a legacy device and a PnP device is this: with the legacy device, someone has to tell you which port to use, and you use IoGetDeviceObjectPointer to connect to the driver for that port. With the PnP device, the identity of the port is implicit in the driver topology established by the PnP Manager, and you simply send an IRP_MJ_CREATE to your bus driver to connect to the driver for the port.

Widgets with PnP identifiers, Windows 9x and Me systems:

In the 9x/Me world, you need to provide a VxD driver for your widget. Your driver calls services in the system VCOMM driver, which then calls functions in a port driver like SERIAL.VXD. A third driver named SERENUM.VXD comes into play when your widget provides a PnP id. SERENUM is the "enumerator" VxD for a standard serial port. When it reenumerates the port, it reads your PnP id and constructs a child DEVNODE below the serial port's DEVNODE. The Configuration Manager will eventually load your driver because of statements like these in your INF file:

"Acme Widget Model 101 (PNP)"=DriverInstall,SERENUM\XYZ1234 
. . .
[DriverInstall]
AddReg=DriverAddReg

[DriverAddReg]
HKR,,DevLoader,,widget.vxd

That is, your driver (widget.vxd) is technically the "Device Driver Loader" for your device's DEVNODE. As such, it will receive a PNP_NEW_DEVNODE control message from the Configuration Manager, whereupon it will register a configuration function. Thereafter, the Configuration Manager will call the configuration function to get the device configured and running. Refer to chapters 11 and 12 in Systems Programming for Windows 95 (Microsoft Press 1996), if you have it, for all the gory details about configuration functions.

The driver topology you end up with in this situation looks like Figure Five:

Figure Five
DEVNODE and Driver topology for PnP Widget

In Configuration Manager terms, the Widget device node (DEVNODE) is a child of the port DEVNODE. SERENUM is the enumerator for the port DEVNODE, and VCOMM is both the DevLoader and the device driver. (In reality, VCOMM relies on a separate port driver VxD to actually talk to the hardware.) Your Widget driver is both the DevLoader and the device driver for the Widget DEVNODE.

In your driver's PNP_NEW_DEVNODE handler, you would interrogate the hardware registry key belonging to the parent DEVNODE to determine the port name. You would then call VCOMM_OpenComm to open the port. Thereafter, you call other VCOMM services to actually access your device.

Widgets without PnP identifiers, Windows 9x and Me systems:

In the 9x/Me world, you would handle a serial-attached device that doesn't have a PnP id in nearly the same way as one that does have a PnP id. The architectural picture is only slightly different than for the PnP case, as shown in Figure Six:

Figure Six
DEVNODE and Driver toplogy for Legacy Widget

Your INF file would contains statements like these:

"Acme Widget Model 102 (Legacy)"=DriverInstall,*XYZ1234
. . .
[DriverInstall]
AddReg=DriverAddReg

[DriverAddReg]
HKR,,DevLoader,,widget.vxd

The only difference between Figures Five and Six is that the legacy Widget doesn't rely on SERENUM. You will have some independent way (such as a control panel applet that writes to the registry) to determine the name of the port to which your device is attached. While handling PNP_NEW_DEVNODE, you would call VCOMM_OpenComm to open this port. Thereafter, you call other VCOMM services to actually access your device.

WDM Drivers for PnP-type Devices:

Now that I've explained the architectural overview of the four different types of driver you might need for a serial-attached widget, it's time to focus in on the most common situation you're likely to encounter: a device that provides a PnP id, for which you need a driver that works in Windows 2000 and later systems. For this kind of device, you'll write a WDM driver that the PnP Manager loads automatically after SERENUM detects your device. See Figure Three. To keep the discussion bounded, I'll suppose that you're building your driver according to the pattern in my books, perhaps by using the WDMWIZ that comes with the sample drivers. Following this pattern, you'll define these subroutines in your driver:

Most of the work you do in these routines has nothing to do with the fact that your device is connected to a serial port, and I'm not going to discuss it further in this article. Refer to my books and to the DDK samples for all of those details. I'm going to concentrate here on the parts that will be different because your device is connected through a serial port managed by another driver.

AddDevice processing:

In a WDM driver's AddDevice function, you normally create and initialize a DEVICE_OBJECT, establish a name for your device, initialize a device extension structure,and call IoAttachDeviceToDeviceStack to attach your new device object to the PnP stack. In my drivers, I save the return value from this function in a field within my device extension:

PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
. . .
pdx->LowerDeviceObject = IoAttachDeviceToDeviceStack(fdo, pdo);

With a PnP serial-attached device, the bus driver underneath you is SERENUM.SYS, and the LowerDeviceObject belongs to it or, possibly, to some lower filter driver. No matter who owns the LowerDeviceObject, it's the target for all the IRPs you're going to generate in order to talk to your device.

StartDevice processing:

In a regular WDM driver built according to the patterns in my book, StartDevice is where you do the device-dependent work associated with handling the IRP_MN_START_DEVICE request. Instead of handling standard I/O resources like ports or interrupts, you need to open a handle to the serial port through which your device is connected. You open the port by sending a synchronous IRP_MJ_CREATE down the stack to SERENUM, which will forward it to the actual port driver:

typedef struct _DEVICE_EXTENSION {
  PDEVICE_OBJECT LowerDeviceObject;
  BOOLEAN portopen;
  . . .
  } DEVICE_EXTENSION, *PDEVICE_EXTENSION;

NTSTATUS StartDevice(PDEVICE_OBJECT fdo, PCM_PARTIAL_RESOURCE_LIST junk1,
  PCM_PARTIAL_RESOURCE junk2)
  {
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  KEVENT event;
  IO_STATUS_BLOCK iosb;
  KeInitializeEvent(&event, NotificationEvent, FALSE);
  PIRP Irp = IoBuildSynchronousFsdRequest(IRP_MJ_FLUSH_BUFFERS, pdx->LowerDeviceObject,
    NULL, 0, NULL, &event, &iosb);
  if (!Irp)
    return STATUS_INSUFFICIENT_RESOURCES;
  PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
  stack->MajorFunction = IRP_MJ_CREATE;
  NTSTATUS status = IoCallDriver(pdx->LowerDeviceObject, Irp);
  if (status == STATUS_PENDING)
    {
    KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
    status = iosb.Status;
    }
  if (!NT_SUCCESS(status))
    return status;
  pdx->portopen = TRUE;
  . . .
  return STATUS_SUCCESS;
  }

StopDevice processing:

In my driver scheme, StopDevice is where you do the device-dependent work associated with releasing your I/O resources in response to IRP_MN_STOP_DEVICE and other PnP requests. This function essentially undoes whatever work your StartDevice function did. For a serial-attached device, you need to close the handle you previously opened to the port by sending the port driver a synchronous IRP_MJ_CLOSE:

NTSTATUS StartDevice(PDEVICE_OBJECT fdo, BOOLEAN oktouch)
  {
  PDEVICE_EXTENSION pdx = (PDEVICE_EXTENSION) fdo->DeviceExtension;
  if (pdx->portopen)
    {
    pdx->portopen = FALSE;
    KEVENT event;
    IO_STATUS_BLOCK iosb;
    KeInitializeEvent(&event, NotificationEvent, FALSE);
    PIRP Irp = IoBuildSynchronousFsdRequest(IRP_MJ_FLUSH_BUFFERS, pdx->LowerDeviceObject,
      NULL, 0, NULL, &event, &iosb);
    if (Irp)
      {
      PIO_STACK_LOCATION stack = IoGetNextIrpStackLocation(Irp);
      stack->MajorFunction = IRP_MJ_CLOSE;
      NTSTATUS status = IoCallDriver(pdx->LowerDeviceObject, Irp);
      if (status == STATUS_PENDING)
        KeWaitForSingleObject(&event, Executive, KernelMode, FALSE, NULL);
      }
    }
  . . .
  }

Talking to your device:

You talk to your device indirectly, by sending IRPs down the PnP stack to SERENUM, which forwards them to the serial port driver. In turn, the serial port driver controls the RS-232 signal lines DTR and RTS; monitors the RS-232 signal lines DCD, CTS, and DSR; and reads and writes data over the RS-232 Tx and Rx signal lines.

Your job in the function driver is to create the right IRPs to control your device. A standard serial port supports about 40 different IOCTL requests, many of which you're going to need to use, as well as IRP_MJ_READ and IRP_MJ_WRITE. Within a driver routine that happens to be running at PASSIVE_LEVEL, you can use IoBuildDeviceIoControlRequest and IoBuildSynchronousFsdRequest to build synchronous IRPs to send to the port driver. The port driver is able to accept IRPs at DISPATCH_LEVEL too, however. To create an IRP at DISPATCH_LEVEL, you must use IoBuildAsynchronousFsdRequest or IoAllocateIrp, and you must have a completion routine that will (at least) call IoFreeIrp. Furthermore, you can't block at DISPATCH_LEVEL in order to wait for an asynchronous operation to complete. I'm not going to describe the mechanics of using these routines here. Consult the DDK documentation and pp. 299-303 of my book for some examples.

Table One lists some of the IOCTL codes you're likely to use in a driver for a serial-attached device. The DDK documentation describes these requests in detail.

Table One
Common IOCTL Codes for Serial Ports

Control Code Purpose
IOCTL_SERIAL_CLR_DTR Clears the DTR signal line
IOCTL_SERIAL_CLR_RTS Clears the RTS signal line
IOCTL_SERIAL_GET_MODEMSTATUS Gets the current contents of the modem status register
IOCTL_SERIAL_IMMEDIATE_CHAR Transmits a priority character
IOCTL_SERIAL_SET_BAUD_RATE Sets the baud rate
IOCTL_SERIAL_SET_BREAK_ON
IOCTL_SERIAL_SET_BREAK_OFF
Used in pairs to transmit a break signal
IOCTL_SERIAL_SET_DTR Sets the DTR signal line
IOCTL_SERIAL_SET_HANDFLOW Sets flow control options (usually, you'd want to turn off all flow control when talking to a serial-attached device, I think).
IOCTL_SERIAL_SET_LINE_CONTROL Sets line control parameters, including parity, word-size, and number of stop bits.
IOCTL_SERIAL_SET_RTS Sets the RTS signal line
IOCTL_SERIAL_SET_TIMEOUTS Sets timeout values. Consult the Platform SDK documentation for the COMMTIMEOUTS structure for an explanation of the complex way these timeouts affect processing.
IOCTL_SERIAL_SET_WAIT_MASK Set the event mask for a later WAIT_ON_MASK.
IOCTL_SERIAL_WAIT_ON_MASK Wait for an unmasked event to occur.

The WAIT_ON_MASK request is especially important in most drivers. You will normally have one of these outstanding as an asynchronous request at all times in order to detect communications events of interest to your driver. For example:

While you're at the prototyping stage, by the way, you can test some aspects of your device's operation by writing a user-mode application that uses the standard Win32 API functions for serial ports to talk to the device. If you compare these functions with the documented IOCTL codes, it should be pretty obvious how to translate your test application's API calls into IRPs to be used by your driver. Just as one example, if you need to raise the DTR signal line from user mode, you'd call EscapeCommFunction with the SETDTR code. In kernel mode, you'd send an IOCTL_SERIAL_SET_DTR. (In fact, that's what EscapeCommFunction does!)

VxD Drivers for PnP-type Devices:

In the world of Windows 9x/Me, VCOMM.VXD (a system component) is in overall charge of the serial ports. Figure Seven shows where your driver (WIDGET.VXD) fits into the picture:

Figure Seven
Overall Driver Architecture in Windows 9x/Me

SERENUM.VXD will read the PnP id from your device, and the Configuration Manager will then load your driver and send you a PNP_NEW_DEVNODE control message with a dwLoadType argument of  DLVXD_LOAD_DEVLOADER. You'll ignore the literal meaning of the dwLoadType argument (Yo! Load the driver for this device!) and perform the following steps:

  1. Determine the name of the COM port to which your device is attached.
  2. Open your COM port.
  3. Register a configuration function.
  4. Configure the port and your device.

Now I'll explain these steps in detail.

Determine the name of your COM port:

The name of the COM port is in the hardware registry key for the port, whose DEVNODE is your own DEVNODE's parent. See Figure Five for an illustration of the relationship between DEVNODEs and drivers. You would use code like this to get the name of the port:

extern "C" CONFIGRET OnPnpNewDevnode(DEVNODE devnode, DWORD dwLoadType)
  {
  . . .
  DEVNODE parent;
  CM_Get_parent(&parent, devnode, 0);
  char portname[64];
  DWORD size = sizeof(portname);
  CM_Read_Registry_Value(parent, NULL, "PortName", REG_SZ, portname, &size, CM_REGISTRY_HARDWARE);
  . . .
  return CR_SUCCESS;
  }

Open your COM port:

You open the COM port by calling a VCOMM service:

HPORT hport = VCOMM_OpenComm(portname, 0xFFFFFFFF);

If your driver is initializing during the startup of Windows, a complication can occur at this point. SERENUM keeps its handle open through much of the DEVICE_INIT phase of startup, which is the time at which the Configuration Manager is trying to configure your device. Your call to VCOMM_OpenComm will return the error code IE_OPEN in this situation. To cope with this problem, you need to do something rather complex:

Incidentally, the reason why we want to open the COM port before registering our configuration function may now be clear: we need to return the special value CR_DEVLOADER_NOT_READY if the port is busy, but that return code would be ineffective if we were to register the configuration function first.

Register a configuration function:

Once the port is open, register your configuration function in the normal way:

DWORD flags = CM_REGISTER_DEVICE_DRIVER_REMOVABLE | CM_REGISTER_DEVICE_DRIVER_DISABLEABLE | CM_REGISTER_DEVICE_DRIVER_ACPI_APM;
CM_Register_Device_Driver(devnode, (CMCONFIGHANDLER) ConfigurationFunction, (ULONG) context, flags);

Before this DDI call returns, the Configuration Manager will send your configuration function a CONFIG_START message, which you'll handle as described in the next section.

(Note: the context parameter in the preceding code fragment is an arbitrary value that the Configuration Manager will pass on as an argument to every configuration function call. I usually create a data structure, analogous to the DEVICE_EXTENSION in a WDM driver, and use its address here.)

Configure the port:

When the Configuration Manager sends your configuration function a CONFIG_START message, you'll want to finish configuring the port. You may need to take several steps, but one of them will almost certainly be to call VCOMM_SetCommState to initialize the communication parameters for the device. The exact values you pick depend on your device:

_DCB dcb;
memset(&dcb, 0, sizeof(dcb));
dcb.DCBLength = sizeof(dcb);
dcb.BaudRate = CBR_9600;
dcb.BitMask = fBinary | fRtsDisable | fDtrEnable;
dcb.ByteSize = 8;
dcb.Parity = 0;    // no parity
dcb.StopBits = 0;  // 1 stop bit
. . .
VCOMM_SetCommState(hport, &dcb, 0xFFFFFFFF);

Additionally, you may need to perform these additional initialization steps:

Talking to your device:

You talk to your device indirectly, by calling VCOMM services. VCOMM implements service calls by making further calls to a port drivers, such as SERIAL.VXD. In turn, the serial port driver controls the RS-232 signal lines DTR and RTS; monitors the RS-232 signal lines DCD, CTS, and DSR; and reads and writes data over the RS-232 Tx and Rx signal lines.

You can find documentation for VCOMM and other VxD services in the Windows 95 DDK file docs\ddpr.hlp. Systems Programming for Windows 95  (Microsoft Press 1996) contains useful information about VCOMM and serial port drivers but is, unfortunately, out of print. Here is a summary of the VCOMM services that you're likely to use in a driver for a serial-attached device:

Table Two
Common VCOMM Service Calls

Service Call Purpose
_VCOMM_ClearComm Retrieves and clears error flags
_VCOMM_CloseComm Closes a port
_VCOMM_EnableCommNotification Enables a callback when unmasked events occur
_VCOMM_EscapeCommFunction Performs an "escape" operation, such as CLRDTR, CLRRTS, SETDTR, SETRTS, etc.
_VCOMM_GetCommEventMask Retrieves and clears unmasked events
_VCOMM_GetCommState Gets the current communications parameters (baud rate, parity, etc.)
_VCOMM_GetLastError Gets the error code associated with the most recent VCOMM service call
_VCOMM_GetModemStatus Retrieves the current modem status register
_VCOMM_GetSetCommTimeouts Gets or sets the timeouts for a port
_VCOMM_OpenPort Opens a port
_VCOMM_PurgeComm Purges the input or output buffer
_VCOMM_ReadComm Reads Rx data
_VCOMM_SetCommEventMask Unmasks specified events
_VCOMM_SetState Sets communication parameters (baud rate, parity, etc.)
_VCOMM_WriteComm Writes Tx data

Drivers for Legacy-type Devices:

In the preceding sections of this article, I considered a device that provides a PnP id when interrogated by SERENUM. If your device doesn't provide a PnP id, you need to make some slight changes to your driver. First of all, you'll want to provide some way for the end user to indicate which serial port (s)he has connected your widget to. In Windows 2000 and later systems, I recommend providing a coinstaller DLL to obtain this information when the user installs your device. Since Windows 9x and Millennium don't support coinstallers, you would need to provide some other kind of applet that you can launch from a RunOnce registry entry populated by your INF file.

An overview of the installation and removal process for a legacy-type device would, then, be as follows:

  1. User launches Add Hardware wizard and points the setup program to your INF file.
  2. INF file either designates a device-specific coinstaller (2K/XP) or uses the RunOnce registry key to designate an applet (9x/Me). The 2K/XP coinstaller presents an additional wizard page to obtain a port name. The 9x/Me applet presents a dialog for the same purpose. Both components write the port name to a PortName value in the hardware registry key for the device.
  3. Driver's AddDevice function or PNP_NEW_DEVNODE handler reads the registry to determine the attachment point for the widget. From this point on, the driver will work about the same way as does the driver for a PnP device.
  4. When user wishes to unplug the device, (s)he first opens the Device Manager and performs a Remove step.

Summary:

In this article, I discussed the basics of writing a WDM or VxD driver for a device that attaches to a Windows system via a serial port. The following table summarizes the key architectural points you'll need to consider.

Table Three
Key Architectural Points about Serial-Attached Device Drivers

  Windows 2000, XP, and later Windows 9x and Millennium
Has Plug and Play ID WDM driver, opens LowerDeviceObject, sends IRPs to LowerDeviceObject VxD driver, opens PortName from parent devnode's hardware key, calls VCOMM services for resulting port handle
Doesn't have Plug and Play ID WDM driver with coinstaller, calls IoGetDeviceObjectPointer for  PortName from hardware key, sends IRPs to resulting DEVICE_OBJECT VxD driver with RunOnce applet, opens PortName from hardware key, calls VCOMM services for resulting port handle

Another key point about the kind of driver considered here is that it talks only indirectly to the hardware, either by issuing IRPs that the serial port driver interprets (2K and later) or by calling VCOMM services (9x/Me).

About the author:

Walter Oney is a freelance driver programmer, seminar leader, and author based in Boston, Massachusetts. You can reach him by e-mail at waltoney@oneysoft.com. Information about the Walter Oney Software seminar series, and other services, is available online at http://www.oneysoft.com