Introduction to Cross-Platform Serial Communication in C

Serial communication remains one of the most reliable and widely used methods for exchanging data between computers and peripherals, embedded systems, sensors, and industrial equipment. Developing a cross-platform serial communication library in C allows developers to write code that compiles and runs consistently across Windows, Linux, and macOS without requiring per-platform rewrites. This expanded guide walks through the architectural decisions, platform-specific APIs, error handling strategies, and best practices required to build a production-grade serial communication library in C.

By the end of this article you will understand how to abstract operating system differences, implement thread-safe read/write operations, configure baud rates and parity settings, and integrate asynchronous communication patterns. We will also cover testing methodologies and provide references to established libraries and documentation for further study.

Understanding Serial Communication Fundamentals

Serial communication transmits data one bit at a time sequentially over a physical channel, typically using RS-232, RS-485, or USB-to-serial adapters. The protocol defines parameters such as baud rate, data bits, parity, stop bits, and flow control. These parameters must match between the sender and receiver for successful communication.

Key Serial Port Parameters

  • Baud Rate – the symbol rate in bits per second (e.g., 9600, 115200).
  • Data Bits – usually 7 or 8 bits per character.
  • Parity – none, even, odd, mark, or space for error detection.
  • Stop Bits – typically 1, 1.5, or 2 bits.
  • Flow Control – hardware (RTS/CTS) or software (XON/XOFF).

Each operating system exposes these parameters through different APIs. On Linux and macOS, the termios interface is standard; on Windows, the Win32 COMM API is used. A cross-platform library must map a unified configuration to these underlying APIs.

Architectural Considerations for a Cross-Platform Library

Building a portable serial library requires careful abstraction of platform dependencies. The common approach is to define a public API in a header file that is identical across platforms, then implement platform-specific code in separate source files selected at compile time through conditional compilation or build system differentiation.

Core API Design

The library should expose a minimal but complete set of functions. A typical design includes:

  • serial_open(const char *port_name) – Opens a serial port and returns a handle.
  • serial_configure(serial_handle_t handle, const serial_config_t *config) – Configures baud rate, parity, data bits, stop bits, and flow control.
  • serial_read(serial_handle_t handle, void *buffer, size_t size, int timeout_ms) – Reads data with optional timeout.
  • serial_write(serial_handle_t handle, const void *data, size_t size) – Writes data.
  • serial_close(serial_handle_t handle) – Closes the port and releases resources.
  • serial_error_message(int error_code) – Converts internal error codes to human-readable strings.

Using an opaque handle type (e.g., a void pointer or integer) hides platform-specific data structures from the user. Error codes should be consistent across platforms.

Project Structure

A recommended file layout:

  • serial.h – public API header (cross-platform)
  • serial_common.c – shared error handling, utility functions
  • serial_windows.c – Windows-specific implementation using CreateFile, SetCommState, overlapped I/O
  • serial_unix.c – POSIX implementation using open, tcsetattr, poll/select
  • serial_types.h – internal structures and platform-forward declarations

Conditional compilation with #ifdef _WIN32 or build system files (CMake, Makefile) selects the correct source file.

Implementing Platform-Specific Serial Port Access

Each major operating system has its own set of functions for opening, configuring, and controlling serial ports. Below we examine each in detail.

Windows Implementation with Win32 API

Windows uses device names like COM1, COM2, etc. The port is opened with CreateFile using the \\\\.\\COM1 path syntax. Configuration is done via DCB (Device Control Block) structures and the SetCommState function. Timeouts are managed with COMMTIMEOUTS. For asynchronous (non-blocking) reads, overlapped I/O is preferred.

Key functions:

  • CreateFile – opens the port
  • GetCommState / SetCommState – get/set DCB
  • SetCommTimeouts – set read/write timeouts
  • ReadFile / WriteFile – synchronous or overlapped I/O
  • ClearCommError – check for errors and pending data
  • CloseHandle – closes the port

Example: Opening a Port on Windows

HANDLE serial_open(const char *port_name) {
    char full_path[32];
    snprintf(full_path, sizeof(full_path), "\\\\.\\%s", port_name);
    HANDLE h = CreateFileA(full_path, GENERIC_READ | GENERIC_WRITE,
                           0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL);
    if (h == INVALID_HANDLE_VALUE) return NULL;

    DCB dcb = { .DCBlength = sizeof(DCB) };
    GetCommState(h, &dcb);
    dcb.BaudRate = CBR_115200;
    dcb.ByteSize = 8;
    dcb.Parity = NOPARITY;
    dcb.StopBits = ONESTOPBIT;
    SetCommState(h, &dcb);

    COMMTIMEOUTS timeouts = { .ReadIntervalTimeout = 10,
                              .ReadTotalTimeoutConstant = 100,
                              .ReadTotalTimeoutMultiplier = 0 };
    SetCommTimeouts(h, &timeouts);
    return h;
}

Error handling should check each call and return a code indicating failure type (port not found, access denied, invalid parameter).

Linux and macOS Implementation with termios

On POSIX systems, serial ports appear as device files like /dev/ttyS0, /dev/ttyUSB0 (Linux) or /dev/cu.usbserial (macOS). The open() function is used with flags such as O_RDWR | O_NOCTTY | O_NDELAY. Configuration is performed using the termios structure and functions tcgetattr, tcsetattr, cfsetospeed, cfsetispeed.

Important termios fields:

  • c_cflag – baud rate, data bits, parity, stop bits, flow control
  • c_iflag – input processing (usually disable all)
  • c_oflag – output processing (disable)
  • c_lflag – local modes (disable canonical, echo)
  • c_cc[VMIN] and c_cc[VTIME] – read behavior (blocking vs timeout)

To set a custom baud rate not in the termios speed tables, use cfsetospeed with a B constant or, on Linux, the c_ispeed / c_ospeed fields and TCSETS2 ioctl (advanced).

Example: Opening and Configuring a Port on Linux

int serial_open(const char *port_name) {
    int fd = open(port_name, O_RDWR | O_NOCTTY | O_NDELAY);
    if (fd < 0) return -1;

    struct termios tty;
    if (tcgetattr(fd, &tty) != 0) { close(fd); return -1; }

    cfsetospeed(&tty, B115200);
    cfsetispeed(&tty, B115200);

    tty.c_cflag = (tty.c_cflag & ~CSIZE) | CS8; // 8 data bits
    tty.c_cflag |= CLOCAL | CREAD;              // enable receiver
    tty.c_cflag &= ~PARENB;                     // no parity
    tty.c_cflag &= ~CSTOPB;                     // 1 stop bit
    tty.c_cflag &= ~CRTSCTS;                    // no hardware flow control

    tty.c_iflag &= ~(IXON | IXOFF | IXANY);     // no software flow control
    tty.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL);
    tty.c_oflag &= ~OPOST;                      // raw output
    tty.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN); // raw input

    tty.c_cc[VMIN] = 0;   // non-blocking read
    tty.c_cc[VTIME] = 10; // 1 second timeout (deci-seconds)

    if (tcsetattr(fd, TCSANOW, &tty) != 0) { close(fd); return -1; }
    return fd;
}

Handling Platform Differences in Build Systems

To compile the library on multiple platforms, use a build system that can conditionally include files. With CMake, you can check WIN32 or UNIX variables:

if(WIN32)
    target_sources(serial PRIVATE serial_windows.c)
else()
    target_sources(serial PRIVATE serial_unix.c)
endif()

Alternatively, use #ifdef _WIN32 within a single source file, but maintaining separate files often improves readability and maintainability.

Thread Safety and Asynchronous Communication

A robust library must support multiple threads reading/writing the same port. Strategies include:

  • Using a mutex to protect read and write operations.
  • Providing a separate serial_read_async that returns immediately and later signals completion.
  • Implementing a background I/O thread for buffered reads.

On Windows, overlapped I/O is the native asynchronous mechanism. On POSIX, use select(), poll(), or epoll() to check for data without blocking. The library should expose both blocking and non-blocking interfaces.

Example: Non-blocking Read with poll()

ssize_t serial_read_nonblock(serial_handle_t handle, void *buf, size_t size, int timeout_ms) {
    struct pollfd pfd = { .fd = handle, .events = POLLIN };
    int ret = poll(&pfd, 1, timeout_ms);
    if (ret < 0) return -1;   // error
    if (ret == 0) return 0;   // timeout
    return read(handle, buf, size);
}

Error Handling and Logging

Each platform-specific call can fail for different reasons. Define a consistent error enumeration in the header, e.g.:

  • SERIAL_OK = 0
  • SERIAL_ERR_OPEN = -1
  • SERIAL_ERR_CONFIG = -2
  • SERIAL_ERR_READ = -3
  • SERIAL_ERR_WRITE = -4
  • SERIAL_ERR_TIMEOUT = -5
  • SERIAL_ERR_NOT_FOUND = -6

Include a function to translate these to strings. Internally, preserve the system error code (errno or GetLastError) for debugging. Provide a logging callback mechanism so the library user can capture diagnostic output.

Testing the Library

Testing serial communication often requires loopback adapters or virtual serial port emulators. Approaches:

  • Physical loopback: connect TX to RX on the same port.
  • Virtual serial port pairs (e.g., socat on Linux, com0com on Windows).
  • Unit tests that mock the low-level API using function pointers.

Create a test suite that exercises every configuration option and verifies data integrity under various baud rates. Use a continuous integration system to run tests on all target platforms.

Performance Optimization

For high-throughput applications, consider these optimizations:

  • Adjust read buffer sizes to match the operating system's internal buffer (usually 4096 bytes).
  • Use large write buffers to reduce system call overhead.
  • On Windows, use overlapping I/O to avoid blocking while waiting for hardware.
  • On Linux, set low latency mode with termios c_cc[VTIME] = 0 and VMIN = 1 for blocking reads with minimal delay.
  • Avoid copying data; pass user buffers directly to read/write when possible.

External Resources and References

To deepen your understanding and build upon this foundation, consult the following authoritative sources:

Conclusion

Developing a cross-platform serial communication library in C requires meticulous attention to platform-specific APIs, consistent error handling, thread safety, and a well-defined public interface. By abstracting the underlying system calls behind a unified API and using conditional compilation or separate source files, you can create a library that compiles and performs reliably on Windows, Linux, and macOS.

Start with a minimal implementation that handles the most common baud rates and configurations, then iteratively add support for advanced features like hardware flow control, parity, custom baud rates, and asynchronous I/O. Rigorous testing with loopback adapters and virtual ports will help ensure robustness across environments. The resulting library will serve as a portable foundation for countless embedded, industrial, and research applications.