$linuxjunkies
>

C Serial Port Programming on Linux

Learn to open /dev/ttyS* and /dev/ttyUSB* in C, configure raw mode with termios, handle blocking reads, and debug serial link problems on Linux.

AdvancedUbuntuDebianFedoraArch12 min readUpdated June 7, 2026

Before you start

  • GCC or Clang installed and a basic understanding of C
  • A serial device or USB-to-serial adapter connected to the machine
  • User account added to the dialout (or uucp on Arch) group
  • Root or sudo access for installing debug tools and modifying group membership

Serial ports remain essential for embedded systems, industrial hardware, microcontrollers, and legacy equipment. On Linux, serial devices appear as /dev/ttyS* (physical UARTs), /dev/ttyUSB* (USB-to-serial adapters), or /dev/ttyACM* (CDC ACM devices). This guide walks through writing a functional C program that opens a serial port, configures it with the POSIX termios API, performs reads and writes, and helps you diagnose problems when hardware misbehaves.

Identifying Your Serial Device

Before writing a line of code, confirm which device node corresponds to your hardware.

ls -l /dev/ttyS* /dev/ttyUSB* /dev/ttyACM* 2>/dev/null

For USB adapters, check kernel messages immediately after plugging in:

journalctl -k --since "1 minute ago" | grep -i tty

Your user must belong to the dialout group (Debian/Ubuntu/Fedora) or uucp group (Arch) to access serial devices without root:

# Debian / Ubuntu / Fedora
sudo usermod -aG dialout $USER

# Arch Linux
sudo usermod -aG uucp $USER

Log out and back in for the group change to take effect.

Opening the Port

Open the device with O_RDWR, O_NOCTTY (so the terminal doesn't become a controlling TTY for your process), and O_NDELAY or O_NONBLOCK depending on whether you want blocking reads. For most embedded-device work, blocking reads with a timeout are the right choice—set those via termios after opening.

# Compile and run examples with:
gcc -Wall -o serial_demo serial_demo.c
cat serial_demo.c

Here is the complete example program, broken down by section below:

// serial_demo.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <termios.h>

#define DEVICE   "/dev/ttyUSB0"
#define BAUDRATE B115200

int open_serial(const char *device) {
    int fd = open(device, O_RDWR | O_NOCTTY | O_NDELAY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    // Switch back to blocking mode after open
    fcntl(fd, F_SETFL, 0);
    return fd;
}

O_NOCTTY is critical: without it, sending a SIGHUP from the remote end could kill your process. After opening, fcntl(fd, F_SETFL, 0) clears the non-blocking flag so subsequent read() calls block until data or a timeout fires.

Configuring termios

The termios structure controls every aspect of serial I/O. Always read the current settings first, modify only what you need, then write them back.

Key termios Fields

  • c_cflag — baud rate, data bits, stop bits, parity, hardware flow control
  • c_iflag — input processing (parity checking, XON/XOFF software flow control)
  • c_oflag — output processing (disable for raw mode)
  • c_lflag — line discipline (disable canonical mode, echo, signals for raw mode)
  • c_cc[VMIN] / c_cc[VTIME] — read blocking behavior
void configure_serial(int fd) {
    struct termios tty;

    if (tcgetattr(fd, &tty) != 0) {
        perror("tcgetattr");
        exit(EXIT_FAILURE);
    }

    // Baud rate
    cfsetispeed(&tty, BAUDRATE);
    cfsetospeed(&tty, BAUDRATE);

    // 8N1: 8 data bits, no parity, 1 stop bit
    tty.c_cflag &= ~PARENB;          // No parity
    tty.c_cflag &= ~CSTOPB;          // 1 stop bit
    tty.c_cflag &= ~CSIZE;
    tty.c_cflag |=  CS8;             // 8 data bits
    tty.c_cflag &= ~CRTSCTS;         // No hardware flow control
    tty.c_cflag |=  CREAD | CLOCAL;  // Enable receiver, ignore modem lines

    // Raw input (no canonical mode, no echo, no signals)
    tty.c_lflag &= ~(ICANON | ECHO | ECHOE | ISIG);

    // Raw output
    tty.c_oflag &= ~OPOST;

    // Disable software flow control
    tty.c_iflag &= ~(IXON | IXOFF | IXANY);

    // Disable special handling of received bytes
    tty.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL);

    // Blocking read: wait up to 1 second, return when at least 1 byte arrives
    tty.c_cc[VMIN]  = 0;   // Return immediately if no bytes
    tty.c_cc[VTIME] = 10;  // 10 * 100ms = 1 second timeout

    if (tcsetattr(fd, TCSANOW, &tty) != 0) {
        perror("tcsetattr");
        exit(EXIT_FAILURE);
    }
}

VMIN and VTIME Combinations

VMINVTIMEBehavior
00Non-blocking; returns whatever is in the buffer (possibly 0 bytes)
N>00Block until exactly N bytes arrive
0T>0Block up to T×100ms; return 0 on timeout
N>0T>0Timer starts after first byte; return when N bytes or T×100ms elapses

Reading and Writing

Always handle partial reads and writes. The kernel may return fewer bytes than requested, especially at high baud rates or with USB adapters that batch data.

ssize_t write_serial(int fd, const char *data, size_t len) {
    ssize_t total = 0;
    while ((size_t)total < len) {
        ssize_t n = write(fd, data + total, len - total);
        if (n < 0) {
            if (errno == EINTR) continue;
            perror("write");
            return -1;
        }
        total += n;
    }
    tcdrain(fd);  // Wait until all output has been transmitted
    return total;
}

ssize_t read_serial(int fd, char *buf, size_t maxlen) {
    ssize_t n = read(fd, buf, maxlen);
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) return 0;
        perror("read");
        return -1;
    }
    return n;
}

A Minimal main()

int main(void) {
    int fd = open_serial(DEVICE);
    configure_serial(fd);

    const char *msg = "Hello, device!\r\n";
    write_serial(fd, msg, strlen(msg));

    char buf[256];
    ssize_t n = read_serial(fd, buf, sizeof(buf) - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("Received %zd bytes: %s\n", n, buf);
    } else {
        printf("No response within timeout.\n");
    }

    close(fd);
    return 0;
}

Verifying the Connection

Before blaming your code, verify the physical link works using existing tools.

Loop-back Test

Connect TX to RX on your adapter (a simple jumper wire), then send and read in two terminals:

# Terminal 1 — listen
cat /dev/ttyUSB0

# Terminal 2 — send
echo "loopback test" > /dev/ttyUSB0

Use minicom or picocom

# Debian / Ubuntu
sudo apt install minicom
minicom -D /dev/ttyUSB0 -b 115200

# Fedora / RHEL
sudo dnf install minicom
minicom -D /dev/ttyUSB0 -b 115200

# Arch
sudo pacman -S picocom
picocom -b 115200 /dev/ttyUSB0

Inspect Port Settings at Runtime

stty -F /dev/ttyUSB0 -a

Output will vary but should show your configured baud rate, cs8, -parenb, and -cstopb for an 8N1 setup.

Check for Framing and Parity Errors

Mismatched baud rate or wire noise causes framing errors. Watch kernel messages while traffic flows:

journalctl -kf | grep -i "ttyUSB\|uart\|serial"

Capture Raw Bytes with od

Pipe the device output through od to see exact byte values, which helps identify framing or encoding problems:

od -tx1 -c /dev/ttyUSB0 | head -20

Use strace to Trace System Calls

strace -e trace=open,read,write,ioctl ./serial_demo 2>&1 | head -40

Look for ioctl calls with TCSETS/TCGETS to confirm your tcsetattr is actually reaching the kernel. A mismatch between intended and applied settings often surfaces here.

Hardware Flow Control Pitfall

If write() blocks indefinitely, the remote end may be asserting CTS=low. Either enable hardware flow control in c_cflag with CRTSCTS, or wire RTS to CTS locally if the device doesn't use flow control:

# Assert RTS manually with setserial (install if missing)
setserial /dev/ttyUSB0 rts_on_send

Custom Baud Rates

Standard POSIX baud constants top out at B4000000. For non-standard rates (e.g., 250000 for DMX512), use BOTHER and the struct termios2 interface via ioctl(fd, TCSETS2, &tio2). This is Linux-specific and available since kernel 2.6.32.

Permission Denied After Correct Group Membership

# Verify group is active in current session
id | grep dialout

# If missing, apply without logout using
newgrp dialout

ModemManager Interference

On desktop systems, ModemManager probes new serial devices and can interrupt your program's open or first write. Disable it if you don't use mobile broadband:

sudo systemctl disable --now ModemManager
tested on:Ubuntu 24.04Fedora 40Arch 2024.05Debian 12

Frequently asked questions

Why does read() return immediately with 0 bytes even though I set VTIME=10?
This usually means the file descriptor was opened or later set to non-blocking (O_NONBLOCK). Verify with fcntl(fd, F_GETFL) and clear the flag with fcntl(fd, F_SETFL, 0). Also check that you called tcsetattr() successfully before the first read.
My write() blocks forever. What is wrong?
The most common cause is hardware flow control: the remote device is holding CTS low. Either wire RTS to CTS on the adapter, enable CRTSCTS in c_cflag if your device supports it, or disable hardware flow control on both ends.
How do I use a non-standard baud rate like 250000?
Use the Linux-specific termios2 struct and ioctl(fd, TCSETS2, &tio2) with the BOTHER flag and your numeric rate in c_ispeed/c_ospeed. Include <asm/termios.h> instead of <termios.h> for this path, and note it is not portable to other POSIX systems.
What is the difference between /dev/ttyS0 and /dev/ttyUSB0?
ttyS* are hardware UART ports built into the motherboard or a PCI card, driven by the 8250/16550 kernel driver. ttyUSB* are USB-to-serial adapters using drivers like cp210x or ftdi_sio. ttyACM* are USB CDC ACM devices (common with Arduino). All use the same termios API.
Should I disable ModemManager for embedded development?
Yes, on desktop Linux systems ModemManager probes newly connected serial devices by sending AT commands, which can interfere with your device's startup state. Disable it with systemctl disable --now ModemManager if you have no mobile broadband hardware.

Related guides