ioctl() & Universal I/O
Intermediate
5 of 7
When the Four System Calls Are Not Enough
We have learned the four core system calls: open(), read(), write(), and close(). These cover almost all I/O needs. But every now and then, you need to do something device-specific that does not fit neatly into “reading” or “writing” data. That is where ioctl() comes in.
This final post in the series also brings everything together — explaining the Universal I/O model with concrete examples, and providing a comprehensive summary of all five concepts covered.
The Universality of I/O — Explained with Examples
The Universal I/O model means that the same program — using the same read() and write() calls — can work with completely different types of resources, without changing a single line of code in the I/O section.
Consider a simple file copy program. Using the exact same binary, you can run it with different arguments and have it do very different things:
/* All of these use the exact same copy program code: */
/* Copy a regular disk file */
./copy document.txt backup.txt
/* Copy from a disk file to the terminal screen */
./copy notes.txt /dev/tty
/* Read from keyboard and write to a file (until Ctrl+D) */
./copy /dev/tty typed_input.txt
/* Copy between two different terminal windows */
./copy /dev/pts/1 /dev/pts/2
The program does not know or care whether it is dealing with a file, a terminal, or a device. The kernel handles all the differences internally. This is the power of the universal model.
Every file system and every device driver in the Linux kernel implements the same set of internal functions — open, read, write, close, seek. When your program calls read(fd, buf, n), the kernel looks up what type of resource fd refers to, and calls the appropriate driver’s read function.
The key layer that makes this work is called the Virtual File System (VFS). It is a layer of abstraction in the kernel that provides a uniform interface. Every driver registers its own implementation of the standard file operations. The VFS routes calls to the right implementation based on the file type.
The ioctl() System Call — The Escape Hatch
The universal model works beautifully for transferring data. But some operations on devices or files do not fit into “read data” or “write data”. Examples:
- Query the current window size of a terminal
- Set the baud rate (data speed) of a serial port
- Eject a CD/DVD drive tray
- Get the filesystem-specific attributes of a file
- Control a network interface (set IP address, bring up/down)
- Query how many bytes are waiting to be read from a pipe
None of these are “read” or “write” — they are control operations on the device. The ioctl() (“I/O control”) system call is a general-purpose door for these. Rather than adding a new system call for every possible device operation (which would be thousands), Linux provides one flexible call.
#include <sys/ioctl.h>
int ioctl(int fd, int request, ... /* argp */);
/* Returns:
value depends on the specific request (often 0 on success)
-1 on error (errno is set) */
Parameters:
- fd — file descriptor of the open device or file
- request — a constant (defined in device-specific header files) that specifies what operation to perform. Different devices have different sets of valid requests.
- argp — an optional third argument. Its type depends entirely on the request. It could be an integer, a pointer to a struct, or nothing at all.
One of the most common uses of ioctl() is getting the size of the terminal window (number of rows and columns). Programs like text editors (vim, nano) use this to know how much screen space they have.
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
struct winsize ws;
/* TIOCGWINSZ = "Terminal IOCtl Get WINdow SiZe" */
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1) {
perror("ioctl");
return 1;
}
printf("Terminal size: %d columns x %d rows\n",
ws.ws_col, ws.ws_row);
return 0;
}
/* Output: Terminal size: 220 columns x 50 rows */
#include <sys/ioctl.h>
#include <fcntl.h>
int bytes_available(int fd) {
int nbytes = 0;
/* FIONREAD = "File I/O N bytes READable" */
if (ioctl(fd, FIONREAD, &nbytes) == -1)
return -1;
return nbytes;
}
/* Use: */
int fd = open("/dev/ttyS0", O_RDONLY); /* serial port */
int n = bytes_available(fd);
printf("%d bytes waiting to be read\n", n);
An important limitation of ioctl() is that the request constants (like TIOCGWINSZ, FIONREAD) are device-specific and often Linux-specific. Code that uses ioctl() is generally not portable to other operating systems.
The POSIX standard only specifies ioctl() for STREAMS devices (a System V feature not used in mainstream Linux). Everything else is a Linux extension. This means:
- Use ioctl() only when there is no portable alternative
- Document clearly which ioctl() requests your code uses and why
- If portability matters, wrap ioctl() calls in platform-specific ifdef blocks
Complete Series Summary — All Five Concepts at a Glance
| Call | Signature | Returns | Key Points |
|---|---|---|---|
open() |
open(path, flags, mode) |
fd or -1 | One of O_RDONLY/O_WRONLY/O_RDWR required. mode needed only with O_CREAT. |
read() |
read(fd, buf, count) |
bytes read, 0=EOF, -1=err | May return less than count. Does not add null byte. Use ssize_t for return. |
write() |
write(fd, buf, count) |
bytes written or -1 | Data goes to kernel buffer first. Use fsync() for disk guarantee. |
close() |
close(fd) |
0 or -1 | Always check return value. Never double-close in threaded code. |
lseek() |
lseek(fd, offset, whence) |
new offset or -1 | SEEK_SET/CUR/END. Does not access disk. Fails on pipes/sockets (ESPIPE). |
ioctl() |
ioctl(fd, request, argp) |
request-specific or -1 | Device-specific control. Not portable. Use only when open/read/write/close cannot do the job. |
Common Patterns to Remember
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
void safe_read_example(const char *filename) {
int fd = open(filename, O_RDONLY);
if (fd == -1) {
perror("open");
exit(1);
}
char buf[4096 + 1]; /* +1 for null terminator */
ssize_t n;
while ((n = read(fd, buf, 4096)) > 0) {
buf[n] = '\0';
/* process buf here */
printf("%s", buf);
}
if (n == -1) {
perror("read");
}
if (close(fd) == -1) {
perror("close");
}
}
void safe_write_example(const char *filename, const char *data, size_t len) {
int fd = open(filename,
O_WRONLY | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH); /* 0644 */
if (fd == -1) {
perror("open");
exit(1);
}
/* Write all bytes (handle partial writes) */
size_t written = 0;
while (written < len) {
ssize_t n = write(fd, data + written, len - written);
if (n == -1) {
perror("write");
close(fd);
exit(1);
}
written += n;
}
if (close(fd) == -1) {
perror("close");
}
}
off_t get_file_size(const char *filename) {
int fd = open(filename, O_RDONLY);
if (fd == -1) return -1;
/* Seek to end — the offset IS the file size */
off_t size = lseek(fd, 0, SEEK_END);
close(fd);
return size; /* -1 if lseek failed */
}
/* Usage: */
off_t sz = get_file_size("data.bin");
if (sz == -1) perror("get_file_size");
else printf("File is %ld bytes\n", (long)sz);
Practice Exercises
The tee command reads from standard input and writes to both standard output AND a named file simultaneously. Try implementing it yourself using only read() and write().
Hint: stdin is already fd 0. Open the output file. In a loop, read from fd 0, write to fd 1 (stdout) AND write to your output file.
Bonus: Add a -a flag to append to the file instead of overwriting it (use O_APPEND instead of O_TRUNC).
Write a file copy program that, when copying a sparse file, creates corresponding holes in the destination instead of writing out all the zero bytes. This makes the copy more efficient for large sparse files.
Hint: When you read a buffer that is entirely zero bytes, instead of writing those zeros to the output file, use lseek() to skip ahead — the kernel will treat the skipped region as a hole.
/* Skeleton for hole-preserving copy: */
while ((n = read(src_fd, buf, BUF_SIZE)) > 0) {
if (buffer_is_all_zeros(buf, n)) {
/* Skip — creates a hole in dest */
lseek(dest_fd, n, SEEK_CUR);
} else {
write(dest_fd, buf, n);
}
}
/* After the loop: make sure file size matches.
The final lseek + write of 1 byte ensures the file
is the right size even if it ends in a hole. */
The Full Mental Model — How It All Connects
Key Takeaways — Complete Series Summary
- File Descriptors are small integers that represent open resources. fd 0/1/2 are stdin/stdout/stderr by default.
- open() opens or creates a file and returns a fd. Use flags like O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_TRUNC, O_APPEND.
- read() copies bytes from a file into your buffer. Returns count read, 0 for EOF, -1 for error. May do partial reads. Does not null-terminate.
- write() copies bytes from your buffer into the kernel page cache. Returns count written. Actual disk write is deferred. Use fsync() for guarantee.
- close() releases the file descriptor. Always check the return value. Never double-close. File descriptors are a limited resource — don’t leak them.
- lseek() moves the file offset to any position. SEEK_SET/CUR/END. Does not access disk. Enables random access. Seeking past EOF creates holes.
- File holes are zero-filled gaps in sparse files that consume no disk space on most Linux filesystems.
- ioctl() is the escape hatch for device-specific control operations. Not portable. Use only when the four core calls are insufficient.
- The Universal I/O model means the same four calls work on all resource types — files, terminals, pipes, sockets, devices.
Now that you have mastered the fundamentals of File I/O, here are natural next steps:
- I/O Buffering — How stdio buffering layers on top of these system calls, and how to control it
- File Metadata and stat() — Getting file size, permissions, timestamps, and type information
- Directory Operations — Reading and creating directories with opendir(), readdir()
- File Locking — Coordinating file access between multiple processes with flock() and fcntl()
- Multiplexed I/O — Handling multiple file descriptors at once with select(), poll(), and epoll()
- Memory-Mapped Files — A faster alternative to read/write for large files using mmap()
Congratulations — You Now Understand Linux File I/O!
You have gone from not knowing what a file descriptor is, to understanding the complete lifecycle of file I/O in Linux — open, read, write, seek, close, and beyond. This is foundational knowledge for all systems programming.
Start the Series Over → Next Topic: I/O Buffering →
