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.
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
| VMIN | VTIME | Behavior |
|---|---|---|
| 0 | 0 | Non-blocking; returns whatever is in the buffer (possibly 0 bytes) |
| N>0 | 0 | Block until exactly N bytes arrive |
| 0 | T>0 | Block up to T×100ms; return 0 on timeout |
| N>0 | T>0 | Timer 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.
Debugging Serial Links
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 ModemManagerFrequently 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
Bash Arrays and Associative Arrays
Master bash indexed and associative arrays: declaration, element access, looping, mapfile, namerefs, and practical patterns for real scripting work.
Bash Functions and Variable Scoping
Master Bash function scoping with local variables, source-based libraries, correct use of return codes, and array passing techniques including namerefs.
Bash Loops: for, while and until
Learn all three Bash loop types — for, while, and until — with practical, copy-paste examples covering file iteration, counting, polling, and safe line reading.
Bash Scripting for Beginners
Learn Bash scripting from scratch: shebang lines, variables, conditionals, loops, and arguments, plus a real backup script to tie it all together.