How Do I Do Full-Duplex Serial Port Communication in Windows?
If you’re an embedded systems hacker like me, or one that isn’t at all like me, you’ve probably used a serial port before. Also known as a Universal Asynchronous Receiver/Transmitter (UART), this venerable method of communication has become ubiquitous in embedded systems due to its simplicity.
And yes, even your Arduino Uno works this way - it has a built-in USB <-> Serial converter chip that appears as a “virtual” serial port to your system and allows it to communicate with the microcontroller as if it were a good ol’ UART.
Recently, I wanted to create a serial <-> TCP relay in order to sidestep a limitation of WSL2 (Windows is the best Linux distribution, fight me). The key requirements here are that it can send and receive data simultaneously (full duplex) and do so efficiently. In theory this is simple enough, but once we get the Win32 API and a hipster programming language known as Rust involved, it gets more complicated.
Blocked
The basic premise of the app is simple: We need to open a communications channel with the Serial port and a TCP stream with a client, and then relay data between them. Where this gets a bit tricky is that by default, the Win32 ReadFile
function is synchronous. This means that it will lock our app up until it receives some data, which sucks because that will also prevent us from writing data from the TCP socket. We need to do this asynchronously so that the serial port can be written to while it is waiting for data to arrive.
We have a few options:
- Open the port in non-blocking mode and poll it periodically;
- Use two threads: One for Serial->TCP, and the other for TCP->Serial;
- Try to use Win32’s asynchronous faculties to perform both reads and writes simultaneously;
Although option 1 is the simplest, it’s also the most wasteful of system resources since it has to continuously check for new data via repeated system calls instead of simply waiting for it to arrive. My general take on using threads is “don’t”, so option 3 seemed like the best approach.
Meet my Ex
The Win32 API provides special versions of the ReadFile
and WriteFile
functions that provide a callback argument for asynchronous operations: ReadFileEx and WriteFileEx. My plan was to execute the “relay” code in the provided LPOVERLAPPED_COMPLETION_ROUTINE
method, which would also restart the next read. The “main” application loop could just call the SleepEx
method to keep the application in an “alertable wait state” for the callbacks.
I wrote a simple C++ program using the above async serial port API (and Winsock2 for TCP), and it worked very well…in one direction. As soon as I tried to send data from TCP -> Serial port, nothing would be sent until the read from that same serial port completed…isn’t this supposed to be async?
Well yes, but also, no
As usual, extensive reading saves the day. I dug around Microsoft’s documentation and came across this little gem. Here’s the interesting part:
Be careful when you code for asynchronous I/O because the system reserves the right to make an operation synchronous if it needs to. So it is best if you write the program to correctly handle an I/O operation that may be completed either synchronously or asynchronously. The sample code demonstrates this consideration.
Rats, I should have read the fine print! Oh well, on to option 2?
Sweet Threads
Since we’ve broken up with our Ex
, we can go back to synchronous operations but with threads. I always welcome an opportunity to learn something new, so I decided to try to write this version in Rust. Rust has been generating a lot of buzz lately being the first new programming language in like 20 years that low-level developers are actually considering using, so let’s give it a try.
Unlike C++, Rust actually has a decent package manager that organizes packages into “crates”. For the serial port, there is serialport-rs.
Also unlike C++, Rust has inbuilt network support: std::net
, cool!
Also also unlike C++, Rust imposes a strict notion of “ownership” of data among threads, so you can’t shoot yourself in the foot as easily with race conditions and deadlocks. Off to a good start!
Well, not so fast. When I tried sharing access to a serial port handle between two threads using a reference count (Arc
), I got the following complaint:
*mut winapi::ctypes::c_void cannot be shared between threads safely
Aw, nuts. I checked online if any Rustaceans knew a way around this, and Stack Overflow proposed this solution. In short: Use a mutex to share access to the handle. Unfortunately, this solution effectively prevents concurrent access to the port, and brings us right on back to square one. Guess we need to use our gray matter instead of Google here.
After a bit more experimenting, I discovered that the secret is actually to use the try_clone()
method in the SerialPort
trait implementation (or try_clone_native()
for COMPort
). This actually wraps a Win32 API function DuplicateHandle
, and allows each thread to get its own copy of a handle that references the same COMPort
, effectively bypassing the whole mess of reference counts and mutexes.
Excitedly, I fired up my shiny new hella-safe Rust program only to be greeted by exactly the same problem I had before: Waiting on a read blocks writes (and vice-versa). I feel like we’re close…
Looking at the serialport-rs
source code, we see that the Win32 serial port access is implemented synchronously. Now that shouldn’t be a problem, you say, because we have two threads, right? Well unfortunately, in synchronous mode, ReadFile
explicitly blocks WriteFile
and vice-versa. BUT we have some good news: Remember our first attempt? That made use of overlapped I/O, which is designed to permit concurrent access to different parts of a file. So in theory, we don’t care if the “asynchronous” read function blocks now since it runs in a separate thread - it just has to permit concurrent access to the port from a different thread.
I checked out the serialport-rs
repository, edited the Cargo.toml
file to point to my local version, and then modified it to use overlapped I/O. After a bit of head-scratching over how to initialize the OVERLAPPED
structure in Rust (std::mem::zeroed()
seems to do the trick), I got it to compile and gave it a test run. Success! My little Rust app was happily relaying data between a test COM port and a virtual WSL2 serial port created using socat
! While we’re digging around in the source code, I noticed serialport-rs
also set the ReadIntervalTimeout
to 0 (i.e: Not used). This timeout defines “how long should I wait for the next character after receiving one” and is quite useful to improve responsiveness, so I set it to 20ms instead.
Some Extra Text
For testing, I tried using kgdb to debug an embedded Linux kernel and console access via minicom
- it works! CPU usage in Windows seems to sit at 0% which would indicate that our relay is efficient (in a very hand-wavy sort of way). The blank console output is kind of boring, perhaps some periodic stats reporting or auto-reconnection could be the next step.
I found it a bit bizarre that a serial port library at version 4+ doesn’t support something as fundamental as full-duplex communication. There appears to be a merge request for it in deliberation for almost a year, so maybe it’s coming soon? I think the fact that most protocols implemented over the UART are challenge/response means that half-duplex communication is sufficient for most applications. Either that or most of the users are running Linux. Wild speculation is fun!
Anyways, the code lives here!