Serial ports used to be easy to program on a PC. Then they got more complex, then unreachable. Now they can be made to look simple again.
Anyone porting 16-bit serial communication code to 32-bit Windows NT or Windows 95 faces a common problem: the familiar methods of implementing communication are at the very least different and at the worst, no longer present. Some of the Win32 API function for setting up the communications port have not changed with respect to their Win16 counterparts. However, the functions used to open, close, read, and write to the port do not exist, nor do the messages generated by the driver when an I/O event occurs. If, like me, you move from 16-bit DOS right into 32-bit Windows, the change is even more pronounced, as you can no longer use an interrupt routine to perform serial communications and you must learn new methods of performing the required tasks of serial I/O.
Having said that, the Win32 API does offer improved support for communication devices. Win32 eliminates the need to deal with communication devices in a non-standard way; it also eliminates the need to deal with the hardware directly. Instead, you perform serial communication with the standard Win32 file I/O functions. For those moving from 16-bit Windows, Table 1 lists the Win32 API equivalents for the 16-bit API functions.
16-bit API | Win-32 API |
---|---|
OpenComm | CreateFile |
CloseComm | CloseHandle |
FlushComm | PurgeComm |
GetComError | ClearCommError |
ReadComm | ReadFile |
WriteComm | WriteFile |
SetCommEventMask | SetCommMask |
GetCommEventMask | GetCommMask |
EnableCommEventNotification | WaitComEvent* |
UngetCommChar | -None- |
*WaitCommEvent will not post WM_COMMNOTIFY messages |
Table 1: 16-bit Communication Functions and their Win32 Equivalents
While the Win32 API does make it simple to open a port and start sending and receiving data, I soon found that there is more to serial I/O than that. For example, as always, you must configure the port with the right set of options and timeout values for these operations to work as expected. This article presents a class that encapsulates the Win32 API functions used for serial communication and simplifies their use. This class also provides some member functions that make it easy to start and stop a separate thread for sending and receiving data. The source code and some sample programs that demonstrate how the class can be used can be downloaded from my web site. I developed and tested the code using Borland C++ 5.01 and Visual C++ 4.2.
Listing 1 (SerialPort.h) shows the class declaration for
CSerialPort
and its supporting definitions. The class consists of some protected data members that
track the state of the object, a set of configuration functions, a set of I/O functions, and wrappers for the
Win32 API functions relating to serial communication. The class also provides built-in support for overlapped I/O
and for starting and stopping a separate thread to send and/or receive data via the port. More often than not,
the basic class can be used as is unless there is a need for specialized read/write operations. In those cases,
it's fairly easy to derive a class from CSerialPort
and override one or more of its virtual
functions. This will enable you to set up customized, multi-threaded, serial communications. To date, I have
not needed the class's overlapped read/write features beyond the polled I/O support provided by the
WaitCommEvent
and CheckForCommEvent
functions. Most of my applications have made good
use of the StartCommThread
function, though.
To create a communication object, simply pass its constructor the name of the port you want to open. By
default CSerialPort
initializes the port for 19,200 bps, no parity, eight data bits, one stop bit,
hardware flow control, no read timeouts, one-second write timeouts, and enables monitoring of EV_RXCHAR
events. Once the object is created, you can alter these settings with the configuration member functions shown
in Table 2. The Win32 wrapper functions such as SetCommState
, SetupComm
,
and SetCommTimeouts
can be used if necessary, but the functions in Table 2 take
care of many of the low-level details associated with initializing the required structures. Each of the
functions in Table 2 also combines several steps into a single function call. Once the port
is opened and configured, use the ReadCommBlock
and WriteCommBlock
member functions to
send and receive data.
Function | Purpose |
---|---|
SetBaudRate | Sets the baud rate (bps) |
SetParityDataStop | Change parity, data bits, stop bit settings |
SetBufferSizes | Change input/output buffer sizes used by Windows |
SetReadTimeouts | Change the read timeouts |
SetWriteTimeouts | Change the write timeouts |
SetCommMask | Specify which set of comm events to monitor |
Table 2: CSerialPort Configuration Functions
Listing 2 (Terminal.cpp) shows the ubiquitous dumb terminal
program, reworked to take advantage of the 32-bit environment and utilize the basic CSerialPort
class. The DumbTerminal
function calls the StartCommThread
member function to start a
separate thread to handle incoming serial data (signaled by the EV_RXCHAR
communication events)
while the main thread waits for keyboard input and writes it out to the port. Note that by allowing a separate
thread to handle incoming serial port data you can eliminate the need to continuously poll for both forms of
input in the main thread. Thus the application consumes less CPU time without the programmer expending any
special effort.
TermPoll.cpp, included on the CUJ FTP site, is a less efficient implementation of Terminal.cpp that
illustrates this point. In the TermPoll.cpp version, the Sleep
function must be called to introduce
a slight delay in the main loop. This prevents the CPU from reaching 100% continuous utilization. Using
separate threads instead to send and receive data especially makes sense in a GUI application; the main thread
remains responsive to user-interface events.
A CSerialPort
object can receive notification of certain communication events. To select which
notifications your object will receive, use the SetCommMask
member function. The events are
specified by ORing together constants such as EV_RXCHAR
, EV_ERR
, etc. defined in
WINBASE.H. When constructed, the class enables EV_RXCHAR
automatically, so if that's the only
notification you need, you don't need to call SetCommMask
in your application.
Selecting which notifications the object is to receive is different than enabling the object to actually
receive notification. After selecting the events of interest with SetCommMask
, you must enable the
object to receive notification by calling WaitCommEvent
. This situation is analogous to setting an
interrupt mask and then later enabling interrupts by executing a special instruction. I did not implement
WaitCommEvent
quite like its API equivalent in Win32. My version splits the API call into two
separate functions. Member function WaitCommEvent
should be used to enable notification; use
CheckForCommEvent
to see if any have occurred.
I implemented these functions this way to enable a program to either block while waiting for an event to
occur (by calling CheckForCommEvent(TRUE)
) or poll for events as needed (CheckForCommEvent(FALSE)
).
The return value is a bit mask of the events that have occurred; it is zero if none are available or an error
occurred. As with the Win32 API, WaitCommEvent
must be called again to re-enable event notification
after CheckForCommEvent
returns a value other than zero. Refer to the CommReader
thread function in Listing 2 for an example of their use.
The GUITerm example demonstrates the use of CSerialPort
in an MFC application. It provides a
dumb terminal much like the console mode example and it can also perform a basic XMODEM file transfer. This
example also demonstrates stopping and restarting a thread function for the port object and a way to use timeouts
on read operations. The application uses the document/view model. In this case, the document manages the serial
port object and the view simply displays received data and passes key presses on to the document for
transmission. When the Connect option is chosen, the document object opens the serial port and starts a thread
to handle incoming data. This approach is similar to that of the Terminal.cpp example presented above. The
difference here is that when data arrives, the receiver thread sends a WM_COMMDATA
message (defined
by the application as WM_USER + 500
) to the view object, which causes it to insert the received data
into the edit control used for display purposes. This application behaves somewhat like 16-bit Windows, in which
the communications driver generates a WM_COMMNOTIFY
event when data arrives.
The XMODEM protocol used for the file transfer requires specific timeout values for its read and write operations. The application temporarily alters the port's timeout settings for the duration of the transfer and resets them afterwards. Under Windows, all communications resources have an associated set of timeout parameters that affect the behavior of read and write operations. Timeouts can cause a read or write operation to finish even though the specified number of characters have not been read or written. When this occurs, it is not treated as an error. The read or write function's return value indicates success but the count of bytes actually read or written will be less than what was requested.
There are two types of timeouts: interval timeouts and total timeouts. Read operations can utilize either or both forms of timeout. Write operations only use total timeouts. An interval timeout occurs when the time between the receipt of any two characters exceeds a specified number of milliseconds. Timing starts when the first character is received and is restarted when each new character arrives. A total timeout occurs when the total amount of time consumed by a read or write operation exceeds a calculated number of milliseconds. Timing starts immediately when the I/O operation begins. The number of milliseconds is calculated as follows:
Total_Timeout = (Multiplier * Number_Of_Bytes) + Constant
The use of a multiplier value allows for longer timeouts based on the number of bytes being read or written. If you do not need both a multiplier and a constant, you can set the unwanted parameter to zero. If both parameters are zero, total timeouts are disabled for the given operation and the read or write will not return until all bytes have been read or written.
Table 3 summarizes the various values and combinations of valid read timeouts. Because read operations can utilize either or both forms, you must take extra care to ensure that they are set correctly for your application. Setting the read timeouts too low can result in a read operation stopping early and possibly giving the impression that data loss occurred. Setting timeouts too high usually is not a problem, especially when a separate thread is handling the receive operation. However, it may become a problem if the receiver thread is also responsible for other operations besides checking the port for incoming data. With a little experimentation, you can determine whether or not the class's default behavior of disabling read timeouts and setting the write timeout to one second is sufficient for your needs.
I = Interval ms T = Total ms (Multiplier * Bytes_Requested) + Constant Interval Total Behavior -------------------------------------------------------------------------------- MAXDWORD 0 No read timeouts. Return immediately with any available data. MAXDWORD * Special case. If the interval and multiplier values are both set to MAXDWORD, and the constant is set to any non-zero value less than MAXDWORD, one of the following occurs: If there are any characters in the input buffer, return immediately with those characters. If there are no characters in the input buffer, wait until a character arrives and then return immediately. If no character arrives within the time specified by the constant value, a timeout occurs. 0 0 Return only when the buffer is completely filled. Timeouts are not used. 0 T Returns when the buffer is completely filled or when T milliseconds have elapsed since the beginning of the operation. I 0 Returns when the buffer is completely filled or when I milliseconds have elapsed between the receipt of any two characters. Timing does not begin until the first character is received. I T Returns when the buffer is completely filled or when either type of timeout occurs.
Table 3: Behavior of Read Timeout Value Combinations
This article and the example code cover the most common uses for the CSerialPort
class.
Instead of covering the remaining member functions in detail, I refer you to the appropriate Win32 online
documentation provided with the compilers. The wrapper functions are identical in name and form except for the
omitted handle parameter that the class manages internally. One final point worth mentioning is that the wrapper
functions will keep track of any error code resulting from the call. The inline member function
CSerialPort::GetLastError
will return the proper error value even if your application has called
other Win32 functions that alter what the API-level ::GetLastError
returns.
To date, I have used CSerialPort
to communicate with other PCs and modems as well as with
hand-held data collection devices and cash registers. It is a versatile class in its own right and provides a
solid foundation from which to build specialized serial communication classes. By letting CSerialPort
handle the underlying details it also makes the transition from the 16-bit to the 32-bit platform a much easier
task.