Skip to main content What char devices and file_operations Expose to User Space | IoT Worker

What char devices and file_operations Expose to User Space

In Linux driver debugging, user space often sees a /dev/xxx node.

An application can open() it, read() from it, write() to it, or control it with ioctl(). It looks as if the device is a file.

But a char device is not “hardware handed directly to the application.” /dev/xxx is only a user-space entry. What actually decides what each call means is the char device object registered by the driver and its file_operations.

The safest first model is this: a char device exposes a kernel driver as a file-descriptor interface; file_operations defines which operations this fd supports and the blocking, data, state, and error semantics of each operation.

driver init / probe
-> char device or misc device is registered
-> devtmpfs / udev creates /dev node
-> application opens it
-> file_operations
-> driver buffers / hardware / interrupts / DMA

So a /dev node only means user space has an entry. It does not prove that the hardware is working.

What a char device Solves

Linux user space cannot freely access kernel addresses or hardware registers.

A driver needs a controlled entry so applications can request device operations through system calls. A char device is one common entry.

It is suitable for interfaces such as:

  • UART-like channels, debug channels, simple control devices
  • sensor samples
  • custom MCU communication channels
  • small control commands and status
  • private devices that do not fit an existing subsystem

The point is not that the device is like a text file. The point is that the application gets an fd.

After that, read/write/ioctl/poll/mmap are dispatched by the kernel to operations defined by the driver.

Where the /dev Node Comes From

Many drivers register an upper interface in probe.

For char devices, common paths include:

alloc_chrdev_region / register_chrdev_region
-> cdev_init
-> cdev_add
-> class_create / device_create
-> devtmpfs or udev creates /dev node

A simpler option is a misc device:

misc_register
-> kernel allocates minor
-> /dev/xxx is created

When a /dev node appears, it means the char-device interface was registered. It does not mean hardware initialization definitely succeeded or every operation will succeed.

If the driver creates the /dev node before hardware initialization finishes, or if hardware later loses power, resets, or enters runtime suspend, the application may still open the node while reads and writes fail.

/dev is an entry, not a health check.

file_operations Is the User-Space Contract

The key object for a char device is struct file_operations.

Simplified, it is a function table:

static const struct file_operations sensor_fops = {
    .owner = THIS_MODULE,
    .open = sensor_open,
    .release = sensor_release,
    .read = sensor_read,
    .write = sensor_write,
    .unlocked_ioctl = sensor_ioctl,
    .poll = sensor_poll,
    .mmap = sensor_mmap,
};

When an application makes a system call, the kernel finds the file behind the fd and dispatches to these functions.

Example:

application read(fd)
-> system call enters kernel
-> VFS finds struct file
-> calls fops->read
-> driver returns data or an error

So file_operations is not just a few callbacks. It defines the long-term contract between user space and the driver.

That contract includes:

  • which operations are supported
  • whether calls can block
  • what the data boundary is
  • what return values mean
  • how error codes should be interpreted
  • how concurrent access is handled
  • what happens when the device is removed or suspended

If this interface is unclear, applications and drivers will keep guessing each other’s behavior.

open Is Not Just Opening a File

When an application calls open("/dev/xxx"), the driver’s .open can do several things:

  • check whether the device exists and is usable
  • initialize private state for this open instance
  • increment reference counts
  • enforce exclusive access
  • start hardware or resume runtime PM
  • clear or bind buffers

But .open should not carry all hardware initialization. Basic hardware initialization usually belongs in probe. open is better for state related to “this user-space handle is now using the device.”

If the device allows only one process, open should clearly return -EBUSY.
If the device is not ready, open can return an error instead of making a later read block forever.

release is the cleanup path after the fd is closed. It often decrements references, stops streams, releases per-open state, or allows runtime suspend.

read/write Must Define Data Semantics

read and write look simple, but they are easy to make ambiguous.

For a regular file, read returns bytes from the file offset. For a device, read may mean:

  • read one sensor frame
  • pop events from a ring buffer
  • read a hardware FIFO
  • wait for one conversion
  • copy data from a DMA buffer

write may mean:

  • send data to the device
  • write a command
  • update configuration
  • trigger an operation

The driver must define the boundary clearly.

For read(fd, buf, 100):

  • if no data exists, should it block or return -EAGAIN
  • is returning 20 bytes success, or must it wait for all 100 bytes
  • does one read mean one frame, or is it a byte stream
  • how is data distributed across multiple readers
  • which error code represents device failure

Without clear semantics, the application cannot implement event loops, timeouts, and retries correctly.

copy_to_user and copy_from_user Are Boundaries

The buf passed from user space is a user virtual address.

The driver cannot treat it like an ordinary kernel pointer. It may be invalid, out of range, lack permission, or fault on access.

Char devices usually use:

  • copy_to_user() to copy kernel data to user space
  • copy_from_user() to copy user data into the kernel

This is the safety boundary between user space and kernel space.

If the driver needs to use user data asynchronously, it usually should not keep the user pointer. It should copy data into a kernel buffer, or use an explicit pin/mmap/DMA mechanism.

User pointers, kernel pointers, physical addresses, and DMA addresses are different layers. Mixing them in a char-device interface easily causes stability and security problems.

ioctl Is for Control, Not Everything

ioctl is flexible. That is exactly why it easily becomes a junk drawer.

It is suitable for control operations that do not fit normal read/write:

  • set mode
  • query status
  • configure sample period
  • trigger a command
  • pass structured control parameters

If every feature is pushed into ioctl, the interface quickly becomes hard to maintain:

  • command numbers are hard to manage
  • parameter structs become incompatible
  • 32/64-bit compatibility gets tricky
  • permission checks are scattered
  • blocking semantics are unclear
  • application and driver become tightly coupled

If an existing kernel subsystem can model the device, it is usually better than inventing a private ioctl interface. Sensors, input devices, network devices, tty, GPIO, IIO, LED, and hwmon already have ecosystems and user-space tools.

Private char devices are useful for clear private interfaces, not as replacements for every standard subsystem.

poll Decides Whether Event Loops Work

Applications do not always want to block in one read().

If an application waits on multiple fds, it uses select/poll/epoll. A char device needs .poll to support this style.

The core of .poll is telling the kernel:

  • whether the device is currently readable
  • whether it is currently writable
  • whether an error or hangup exists
  • which wait queue to use if it is not ready

Typical path:

application poll/epoll_wait
-> driver poll registers wait queue
-> no data, application sleeps
-> interrupt or worker puts data into buffer
-> wake_up
-> poll returns readable
-> application read

If .poll is wrong, the application may never receive events, or it may spin continuously.

poll is not just returning a flag. It must match wait queues, buffer state, interrupt wakeups, and nonblocking read semantics.

mmap Is Not a Better read

Some devices move a lot of data, and repeated read copies are expensive. That is where mmap may appear.

mmap can map a kernel buffer, device memory, or DMA buffer into user space so the application can access it directly.

It fits video frames, acquisition buffers, and shared ring buffers.

But mmap also makes the boundary more complex:

  • buffer lifetime must be clear
  • user space and kernel may access the same memory
  • DMA and CPU cache coherency must be handled
  • mapping range and permissions must be controlled
  • process exit, fork, and close need cleanup
  • mappings can still exist when the device is removed

So mmap is not simply “faster read.” It speeds up the data path while exposing synchronization, cache, and lifetime issues to the interface design.

What to Check First for char-device Problems

When /dev exists but application I/O behaves incorrectly, split the path:

First, check whether the char device was really registered. Look at major/minor, cdev or misc device, and class/device creation.

Second, check whether the /dev node points to the right device. Look at devtmpfs/udev, permissions, device numbers, and symlinks.

Third, check whether open successfully established usage state. Is the device ready, already opened exclusively, and runtime-resumed?

Fourth, check whether read/write semantics match the application. Blocking, nonblocking, short reads/writes, frame boundaries, and error codes must be clear.

Fifth, check user/kernel copying. Lengths, struct versions, alignment, and 32/64-bit compatibility can all matter.

Sixth, check whether poll and wait queues agree. Does data arrival call wakeup? Does the readable flag match buffer state?

Seventh, check whether hardware is actually working. Interrupts, DMA, FIFO, power, reset, and runtime PM can fail while the fd still exists.

Eighth, check remove, close, and error paths. release, remove, recovery, and concurrent access must not corrupt shared state.

What to Remember in Practice

A char device does not expose raw hardware to applications.

It wraps a driver as a file-descriptor interface. file_operations decides what open/read/write/ioctl/poll/mmap mean for this device.

The /dev node is only the entry. The real semantics live in driver state machines, buffers, wait queues, error codes, hardware access, and subsystem boundaries.

Once this layer is clear, application-driver debugging is less likely to mix “node exists,” “open succeeds,” “read blocks,” “poll has no event,” and “hardware interrupt never arrived” into the same vague problem.