ioctl and Device Interaction

When read, write, and lseek aren't enough, there's ioctl. It's the Swiss Army knife of device control -- a single syscall that can do anything the driver author wanted. This chapter explains how ioctl works, how request numbers are encoded, and how to use it to talk to devices from user space.

What ioctl Is

int ioctl(int fd, unsigned long request, ...);

ioctl sends a command to a device driver through an open file descriptor. The request number encodes what to do, and the optional third argument carries data (usually a pointer to a struct).

User space                     Kernel space
+----------+                   +------------------+
| program  |  ioctl(fd, cmd)   | driver           |
|          | ----------------> | .unlocked_ioctl  |
|          | <---------------- | return result     |
+----------+                   +------------------+

ioctl exists because devices have operations that don't fit the read/write model: setting baud rates, querying screen dimensions, ejecting disks, configuring network interfaces.

ioctl Request Number Encoding

On Linux, ioctl numbers are 32-bit values with structure:

Bits:  31..30   29..16    15..8      7..0
       +------+---------+----------+--------+
       | dir  |  size   |   type   | number |
       +------+---------+----------+--------+

dir:    _IOC_NONE (0), _IOC_READ (2), _IOC_WRITE (1), _IOC_READ|_IOC_WRITE (3)
size:   Size of the data argument (14 bits)
type:   Magic number identifying the driver (8 bits)
number: Command number within the driver (8 bits)

The kernel provides macros to build these:

#include <linux/ioctl.h>  /* or <sys/ioctl.h> */

_IO(type, number)              /* No data transfer */
_IOR(type, number, datatype)   /* Read from driver to user */
_IOW(type, number, datatype)   /* Write from user to driver */
_IOWR(type, number, datatype)  /* Both directions */

Example: _IOR('T', 1, struct winsize) means "read from driver, magic type 'T', command 1, data is a struct winsize."

Getting Terminal Size: TIOCGWINSZ

The most common ioctl in everyday programming.

/* term_size.c */
#include <stdio.h>
#include <sys/ioctl.h>
#include <unistd.h>

int main(void) {
    struct winsize ws;

    if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) < 0) {
        perror("ioctl TIOCGWINSZ");
        return 1;
    }

    printf("Terminal size:\n");
    printf("  Rows:    %d\n", ws.ws_row);
    printf("  Columns: %d\n", ws.ws_col);
    printf("  X pixels: %d\n", ws.ws_xpixel);
    printf("  Y pixels: %d\n", ws.ws_ypixel);

    return 0;
}
$ gcc -o term_size term_size.c && ./term_size
Terminal size:
  Rows:    40
  Columns: 120
  X pixels: 0
  Y pixels: 0

The name decodes as: Terminal I/O Control, Get WINdow SiZe.

Setting Terminal Attributes

/* term_raw.c — put terminal in raw mode, then restore */
#include <stdio.h>
#include <unistd.h>
#include <termios.h>

int main(void) {
    struct termios orig, raw;

    /* Save original settings */
    if (tcgetattr(STDIN_FILENO, &orig) < 0) {
        perror("tcgetattr");
        return 1;
    }

    raw = orig;

    /* Disable canonical mode and echo */
    raw.c_lflag &= ~(ICANON | ECHO);
    raw.c_cc[VMIN]  = 1;  /* read at least 1 byte */
    raw.c_cc[VTIME] = 0;  /* no timeout */

    if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) < 0) {
        perror("tcsetattr");
        return 1;
    }

    printf("Raw mode. Press 'q' to quit. Typed characters show as hex.\r\n");

    char c;
    while (read(STDIN_FILENO, &c, 1) == 1) {
        if (c == 'q') break;
        printf("0x%02x\r\n", (unsigned char)c);
    }

    /* Restore original settings */
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig);
    printf("\nRestored normal mode.\n");

    return 0;
}
$ gcc -o term_raw term_raw.c && ./term_raw
Raw mode. Press 'q' to quit. Typed characters show as hex.

Under the hood, tcgetattr and tcsetattr call ioctl with TCGETS and TCSETS requests.

Try It: Run term_raw and press arrow keys. You'll see escape sequences (0x1b 0x5b 0x41 for Up). This is how terminal applications detect special keys.

Block Device ioctls

/* blk_size.c — get block device size */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/fs.h>
#include <stdint.h>

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <block_device>\n", argv[0]);
        return 1;
    }

    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    /* Get size in bytes */
    uint64_t size_bytes;
    if (ioctl(fd, BLKGETSIZE64, &size_bytes) < 0) {
        perror("ioctl BLKGETSIZE64");
        close(fd);
        return 1;
    }

    /* Get sector size */
    int sector_size;
    if (ioctl(fd, BLKSSZGET, &sector_size) < 0) {
        perror("ioctl BLKSSZGET");
        close(fd);
        return 1;
    }

    /* Get read-only status */
    int readonly;
    if (ioctl(fd, BLKROGET, &readonly) < 0) {
        perror("ioctl BLKROGET");
        close(fd);
        return 1;
    }

    printf("Device:      %s\n", argv[1]);
    printf("Size:        %lu bytes (%.2f GB)\n",
           size_bytes, size_bytes / 1e9);
    printf("Sector size: %d bytes\n", sector_size);
    printf("Read-only:   %s\n", readonly ? "yes" : "no");

    close(fd);
    return 0;
}
$ gcc -o blk_size blk_size.c
$ sudo ./blk_size /dev/sda
Device:      /dev/sda
Size:        500107862016 bytes (500.11 GB)
Sector size: 512 bytes
Read-only:   no

Defining Custom ioctl Numbers

When writing a user-space program that talks to a custom driver, you define matching ioctl numbers.

/* custom_ioctl.h — shared between driver and user-space */
#ifndef CUSTOM_IOCTL_H
#define CUSTOM_IOCTL_H

#include <linux/ioctl.h>

#define MYDEV_MAGIC 'M'

struct mydev_config {
    int  speed;
    int  mode;
    char name[32];
};

/* Commands */
#define MYDEV_GET_CONFIG  _IOR(MYDEV_MAGIC, 0, struct mydev_config)
#define MYDEV_SET_CONFIG  _IOW(MYDEV_MAGIC, 1, struct mydev_config)
#define MYDEV_RESET       _IO(MYDEV_MAGIC, 2)
#define MYDEV_TRANSFER    _IOWR(MYDEV_MAGIC, 3, struct mydev_config)

#endif /* CUSTOM_IOCTL_H */

User-space program using the custom ioctls:

/* user_ioctl.c — user-space side of custom device control */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <string.h>

/* In real code, include custom_ioctl.h */
#include <linux/ioctl.h>

#define MYDEV_MAGIC 'M'

struct mydev_config {
    int  speed;
    int  mode;
    char name[32];
};

#define MYDEV_GET_CONFIG  _IOR(MYDEV_MAGIC, 0, struct mydev_config)
#define MYDEV_SET_CONFIG  _IOW(MYDEV_MAGIC, 1, struct mydev_config)
#define MYDEV_RESET       _IO(MYDEV_MAGIC, 2)

int main(void) {
    int fd = open("/dev/mydevice", O_RDWR);
    if (fd < 0) {
        perror("open /dev/mydevice");
        printf("(This demo needs a matching kernel module loaded.)\n");
        return 1;
    }

    /* Read current config */
    struct mydev_config cfg;
    if (ioctl(fd, MYDEV_GET_CONFIG, &cfg) < 0) {
        perror("ioctl GET_CONFIG");
        close(fd);
        return 1;
    }
    printf("Current: speed=%d mode=%d name=%s\n",
           cfg.speed, cfg.mode, cfg.name);

    /* Modify and write back */
    cfg.speed = 115200;
    cfg.mode  = 3;
    strncpy(cfg.name, "fast_mode", sizeof(cfg.name));

    if (ioctl(fd, MYDEV_SET_CONFIG, &cfg) < 0) {
        perror("ioctl SET_CONFIG");
        close(fd);
        return 1;
    }
    printf("Config updated.\n");

    close(fd);
    return 0;
}

Driver Prep: On the kernel side, the driver's file_operations.unlocked_ioctl function receives these commands. It uses copy_from_user() and copy_to_user() to safely transfer the struct between user and kernel space. This is exactly how real hardware drivers are controlled.

Decoding ioctl Numbers

You can decode any ioctl number:

/* decode_ioctl.c */
#include <stdio.h>
#include <sys/ioctl.h>

int main(void) {
    unsigned long cmd = TIOCGWINSZ;

    printf("TIOCGWINSZ = 0x%lx\n", cmd);
    printf("  Direction: %lu\n", (cmd >> 30) & 3);
    printf("  Size:      %lu bytes\n", (cmd >> 16) & 0x3FFF);
    printf("  Type:      '%c' (0x%02lx)\n",
           (char)((cmd >> 8) & 0xFF), (cmd >> 8) & 0xFF);
    printf("  Number:    %lu\n", cmd & 0xFF);

    return 0;
}
$ gcc -o decode_ioctl decode_ioctl.c && ./decode_ioctl
TIOCGWINSZ = 0x5413
  Direction: 0
  Size:      0 bytes
  Type:      'T' (0x54)
  Number:    19

Note: TIOCGWINSZ is an older ioctl that predates the modern encoding scheme, so the direction and size fields may be zero.

The ioctl vs sysfs Debate

+------------------+----------------------------+---------------------------+
| Aspect           | ioctl                      | sysfs                     |
+------------------+----------------------------+---------------------------+
| Interface        | Binary struct              | Text string               |
| Discovery        | Need header file           | ls /sys/...               |
| Scripting        | Requires C/compiled code   | cat/echo from shell       |
| Performance      | One syscall                | open+read/write+close     |
| Complex data     | Handles structs natively   | Must serialize to text    |
| Debugging        | Opaque without docs        | Self-documenting filenames|
+------------------+----------------------------+---------------------------+

Modern practice: use sysfs for simple attributes (enable/disable, speed, status), use ioctl for complex operations (DMA transfers, firmware upload, bulk configuration).

Rust: ioctl with the nix Crate

The nix crate provides type-safe ioctl wrappers.

// Cargo.toml: nix = { version = "0.27", features = ["ioctl", "term"] }
use nix::libc;
use nix::sys::termios;
use std::os::unix::io::AsRawFd;
use std::io;

// Terminal size ioctl
nix::ioctl_read_bad!(tiocgwinsz, libc::TIOCGWINSZ, libc::winsize);

fn main() -> io::Result<()> {
    let stdout = io::stdout();
    let fd = stdout.as_raw_fd();

    // Get terminal size
    let mut ws = libc::winsize {
        ws_row: 0,
        ws_col: 0,
        ws_xpixel: 0,
        ws_ypixel: 0,
    };

    unsafe {
        tiocgwinsz(fd, &mut ws).expect("TIOCGWINSZ failed");
    }

    println!("Terminal: {} rows x {} cols", ws.ws_row, ws.ws_col);

    // Get terminal attributes using nix's safe wrapper
    let attrs = termios::tcgetattr(fd).expect("tcgetattr failed");
    println!("Input flags:  {:?}", attrs.input_flags);
    println!("Output flags: {:?}", attrs.output_flags);
    println!("Local flags:  {:?}", attrs.local_flags);

    Ok(())
}

Defining Custom ioctls in Rust

use nix::libc;

// Define the same custom ioctls from the C example
const MYDEV_MAGIC: u8 = b'M';

#[repr(C)]
struct MydevConfig {
    speed: i32,
    mode: i32,
    name: [u8; 32],
}

nix::ioctl_read!(mydev_get_config, MYDEV_MAGIC, 0, MydevConfig);
nix::ioctl_write_ptr!(mydev_set_config, MYDEV_MAGIC, 1, MydevConfig);
nix::ioctl_none!(mydev_reset, MYDEV_MAGIC, 2);

fn main() {
    // These would be called as:
    // unsafe { mydev_get_config(fd, &mut cfg) }
    // unsafe { mydev_set_config(fd, &cfg) }
    // unsafe { mydev_reset(fd) }
    println!("Custom ioctl macros defined successfully.");
    println!("(Need /dev/mydevice to actually use them.)");
}

Rust Note: The nix crate's ioctl macros generate unsafe functions because ioctls inherently bypass Rust's type system -- you're passing raw memory to a kernel driver. The unsafe block explicitly marks this trust boundary.

Practical Example: Watchdog Timer

The Linux watchdog (/dev/watchdog) is controlled entirely via ioctls.

/* watchdog_info.c */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/watchdog.h>

int main(void) {
    int fd = open("/dev/watchdog", O_RDWR);
    if (fd < 0) {
        perror("open /dev/watchdog (need root or watchdog group)");
        return 1;
    }

    /* Get watchdog info */
    struct watchdog_info info;
    if (ioctl(fd, WDIOC_GETSUPPORT, &info) == 0) {
        printf("Watchdog: %s\n", info.identity);
        printf("Firmware: %u\n", info.firmware_version);
        printf("Options:  0x%08x\n", info.options);
    }

    /* Get timeout */
    int timeout;
    if (ioctl(fd, WDIOC_GETTIMEOUT, &timeout) == 0)
        printf("Timeout:  %d seconds\n", timeout);

    /* Magic close: write 'V' before closing to prevent reboot */
    write(fd, "V", 1);
    close(fd);

    return 0;
}

Caution: Opening /dev/watchdog starts the watchdog timer. If you don't periodically write to it (or close with the magic 'V' character), the system will reboot. Do not run this on a production system without understanding the consequences.

Try It: Use strace to trace the ioctls of a familiar command: strace -e ioctl ls -l /dev/tty. How many different ioctl requests do you see?

Quick Knowledge Check

  1. What do the four fields in an ioctl number encode?
  2. What is the difference between _IOR and _IOW?
  3. Why does the nix crate mark ioctl functions as unsafe?

Common Pitfalls

  • Wrong ioctl number. A mismatched magic type or command number returns ENOTTY ("inappropriate ioctl for device"). Despite the confusing name, this is the standard error.
  • Wrong data size. If the struct in _IOR doesn't match the kernel's expected size, the ioctl fails or corrupts memory.
  • Missing O_RDWR. Some ioctls require the fd to be opened read-write, even if the ioctl only reads data.
  • Forgetting copy_from_user on the kernel side. Accessing user pointers directly from kernel code is a security vulnerability (and crashes on SMAP- enabled CPUs).
  • Platform differences. ioctl numbers can differ between architectures (32-bit vs 64-bit). Always use the macros, never hardcode numbers.
  • ioctl on the wrong fd. TIOCGWINSZ works on a terminal fd, not a regular file fd. Check what your fd actually points to.