Blog

Following a File Through the Linux 2.4 VFS

I was reading a small filesystem and kept confusing dentries, inodes, and open files. They all seemed to point at roughly the same object until a rename and two simultaneous opens proved otherwise. I finally followed one pathname through the Linux 2.4 VFS and wrote down what each structure represents.

The VFS is the layer that gives user programs the usual operations such as open, read, and stat while allowing ext2, NFS, procfs, and other filesystems to implement different storage rules. It does this with common objects and tables of function pointers. The useful mental model is not one structure per file. It is several structures per role.

A filesystem implementation first registers a file_system_type:

static DECLARE_FSTYPE(sample_type, "samplefs", sample_read_super,
                      FS_REQUIRES_DEV);

static int __init sample_init(void)
{
    return register_filesystem(&sample_type);
}

In 2.4, mounting leads to the filesystem’s read_super function. It validates or reads the on-disk superblock, fills the VFS super_block, installs superblock operations, and supplies a root dentry. The superblock represents one mounted filesystem instance, not the filesystem format in general. Two mounted ext2 partitions therefore have separate superblocks while sharing ext2’s implementation code.

The inode represents a filesystem object and its metadata: type, mode, owner, times, size, and operation tables. A filesystem usually embeds or associates its private inode information with the VFS inode. The inode number identifies it within that filesystem. Hard links make the distinction important: several directory names can refer to the same inode.

The dentry represents a name in a directory and connects pathname lookup to an inode. It belongs to the dentry cache, remembers parent and child relationships, and may even be negative, meaning that a lookup established that a name does not currently exist. A dentry is therefore about a pathname component and cached lookup state, not merely another spelling of inode.

Suppose a process opens /mnt/notes/todo. Pathname lookup starts from a root or current directory and walks components. For each component, the VFS uses cached dentries when possible and asks the parent inode’s lookup operation when necessary. By the end, it has a dentry for todo and, if the file exists, an associated inode.

Opening then creates a file object for that particular open instance. It records the current offset, access mode, flags, and file operations. Two processes opening the same inode receive distinct file objects and can have different offsets. After fork, file descriptors may instead refer to the same open file object, which explains why their offsets can move together.

The operation tables divide responsibility. Directory inode operations include tasks such as lookup, create, link, and rename. File operations include read, write, llseek, open, and release. Superblock operations deal with the mounted instance, including writing metadata and releasing it. The VFS calls through these tables without needing ext2 rules in generic pathname code.

A tiny read method illustrates another boundary:

static ssize_t sample_read(struct file *file, char *buf,
                           size_t count, loff_t *ppos)
{
    const char text[] = "hello\n";
    size_t left;

    if (*ppos >= sizeof(text) - 1)
        return 0;

    left = sizeof(text) - 1 - *ppos;
    if (count > left)
        count = left;
    if (copy_to_user(buf, text + *ppos, count))
        return -EFAULT;

    *ppos += count;
    return count;
}

The method receives the open file, not just the inode, because position and open flags belong to the open instance. It uses copy_to_user() because a user pointer cannot be treated as an ordinary kernel pointer. Returning zero at end of file is part of the read contract, not an error.

Caching makes lifetime rules less obvious. Closing the last file descriptor does not imply that the inode or dentry disappears immediately; the caches may retain them. Conversely, unlinking a name removes a directory entry but an already open file can remain usable until its final reference is released. Reference counts and VFS helper functions, rather than guesses about user-visible names, govern these lifetimes.

Locking also follows roles. Operations changing a directory need the directory inode’s semaphore according to the VFS calling rules, while filesystem-private data may need its own protection. I do not add locks at random around every pointer. I first determine which VFS locks are already held for that operation and what shared state remains uncovered. Too little locking corrupts data; too much can deadlock, which is corruption with better manners.

My preferred debugging method is to trace one operation and print object addresses and names carefully: mount, lookup, open, read, release, and unmount. A toy in-memory filesystem is especially educational because there is no disk format to distract from the object relationships.

The concise version is: a superblock is a mount, an inode is an object, a dentry is a cached name, and a file is an open instance. It is not the whole VFS, but it is enough to stop asking an inode for an open offset. That alone made the source considerably less mysterious.

Waking a Process in Linux 2.4

I had a character driver polling a flag in a loop. It worked, if one defines working as consuming a processor while waiting for a byte that might arrive next Tuesday. A wait queue is the proper mechanism.

The process prepares to sleep on a queue, checks the condition, and asks the scheduler to run something else. The producer changes the condition and wakes sleepers. In 2.4 code, the shape can be written with the wait-event helpers:

static DECLARE_WAIT_QUEUE_HEAD(read_wait);
static int data_ready;

if (wait_event_interruptible(read_wait, data_ready))
    return -ERESTARTSYS;

When data becomes available:

data_ready = 1;
wake_up_interruptible(&read_wait);

The condition is the important part. Wakeups are hints to run and check state, not ownership of an event. A process may wake because of a signal, another reader may consume the data first, or the condition may change again before this process runs. The helper loops around the test, which avoids treating a wakeup as a guarantee.

For interruptible sleep, a pending signal returns control to the caller. Driver code should propagate the interruption rather than quietly restarting a complicated operation itself. User space can then decide what to do.

The producer and consumer still need proper locking around shared state. A wait queue arranges sleeping and waking; it does not make data_ready and the associated buffer safe on a multiprocessor machine. I protect the state with the lock appropriate to all its callers, taking care not to sleep while holding a spinlock.

Underneath, the scheduler tracks each process in a task_struct, including its state. A sleeping task is not selected to run until it is made runnable again. This is why sleeping is so much cheaper than repeatedly checking a flag, and why calling a sleeping function from interrupt context is forbidden: there is no process context there to put to sleep.

Nonblocking reads need a parallel path. If no data is ready and the file was opened with O_NONBLOCK, I return -EAGAIN instead of joining the wait queue. The same readiness condition should drive poll so programs using select or poll observe exactly what a blocking read would observe. Three slightly different definitions of “ready” produce wonderfully intermittent programs.

I prefer wait queues over homemade polling even when the first version seems simpler. The caveat is to make the condition explicit and recheck it under the same synchronization used by the producer. Otherwise the code gains a pleasant nap and an occasional missed wakeup, usually during demonstrations.

A Linux 2.4 Module with a Clean Exit

I unloaded a test module and discovered that its timer was still quite enthusiastic. The machine survived, but only because the callback lost its race with removal. That is not a synchronization strategy I want to defend.

A small 2.4 module has an initialization function and an exit function:

#include <linux/init.h>
#include <linux/module.h>

static int __init sample_init(void)
{
    printk(KERN_INFO "sample: loaded\n");
    return 0;
}

static void __exit sample_exit(void)
{
    printk(KERN_INFO "sample: unloaded\n");
}

module_init(sample_init);
module_exit(sample_exit);
MODULE_LICENSE("GPL");

Initialization should acquire resources in a known order. If the third registration fails, it must release the first two before returning an error. The exit path then releases every successfully acquired resource in the reverse order. This includes interrupt handlers, timers, task queues, proc entries, device numbers, and allocated memory.

The subtle part is activity that may still enter the module. Before freeing data, I stop new work and wait or synchronize with work already in progress. For a timer, del_timer_sync() is preferable when the callback might be running on another processor. For an interrupt, I disable the device’s source as appropriate and call free_irq(), which synchronizes with handlers before returning.

Module use counts provide another guard in 2.4 code. An open file operation commonly increments the count and release decrements it, preventing removal while file operations can still call module text. The exact helper depends on the code and kernel version, but the principle is stable: every path that lends out an active reference must take and return it symmetrically.

Registration order can reduce cleanup work. I initialize private locks and memory before exposing a device entry that another process can open. The externally visible registration comes last. During removal I reverse that idea: withdraw the entry first so no new caller arrives, then drain existing work and release private state. An object should not become public while it is only half constructed.

I keep a small state table while reviewing the code: which resources exist after each possible failure, and which callbacks can still run. If two exits require different cleanup, explicit labels in reverse order are clearer than one flag for every allocation.

I test the unhappy path too. Repeated insmod and rmmod, an intentional allocation failure, and unloading while a user process has the device open find more defects than one successful load at boot.

My preference is an exit routine so boring that each line is the mirror of initialization. The caveat is that reversal alone does not solve concurrency; first make callbacks impossible, then free what they used. Memory is patient. A callback into unloaded text is less so.

Adding a Source File to KDE Autotools

I added history.cpp to an application, ran make, and spent several minutes wondering why none of my mistakes appeared in the compiler output. The build was not unusually forgiving. I had neglected to tell it that the file existed.

In a typical KDE source tree, the immediate place to look is Makefile.am. The target’s source list must contain both implementation and relevant headers:

bin_PROGRAMS = notebook

notebook_SOURCES = main.cpp notebook.cpp history.cpp \
                   notebook.h history.h

notebook_LDADD = $(LIB_KDEUI)

Automake uses this description to generate the lower-level make rules. KDE’s admin machinery and configure input then arrange compiler flags, library checks, translations, installation paths, and moc handling. Editing the generated Makefile is temporary theatre; the next regeneration removes the change with admirable confidence.

After changing the build description, I regenerate from the source root:

make -f Makefile.cvs
./configure --prefix="$KDEDIR" --enable-debug
make

For an out-of-tree build I run configure from the build directory after regenerating the source-side files. If I have added a new subdirectory, it also needs to appear in the parent’s SUBDIRS, and its makefile must be included by the configure machinery. A source file alone is simpler and normally needs only the target list.

Headers with Q_OBJECT are worth checking after the first build. If moc output is absent, I confirm the header is listed and that the macro is visible in the class declaration. Stale dependencies can confuse matters, so I prefer a fresh build directory before rewriting build rules that may already be correct.

Installation lists are a separate concern from compilation lists. A desktop file belongs in the appropriate data variable with its destination, translations belong in their directory, and public headers need an installation rule only if they truly form an interface. Putting every file into EXTRA_DIST may make an archive complete, but it does not make installation correct.

I check both operations before sending a change:

make dist
make install DESTDIR=/tmp/notebook-install

The staged installation makes missing icons and resources visible without scattering test files through the real prefix. I inspect the archive too, because a local build can use a file that never entered the distribution. Generated files and source files have different rules, and guessing which will be recreated on another machine is avoidable.

I keep custom shell commands out of Makefile.am unless the normal primary and source variables cannot express the job. Automake syntax can feel indirect, but the standard forms understand clean builds, distribution archives, and dependency tracking. A handcrafted shortcut often understands only the exact machine on which it was written, which is a rather narrow audience.

KDEPrint and CUPS: Where the Options Go

I was chasing a printing bug because the application appeared to ignore duplex selection. The drawing code was innocent. The useful clue was that printing to a PostScript file worked while the CUPS queue did not apply the requested option.

KDEPrint sits between the application and the print system. The application paints pages through Qt’s printer interface, KDEPrint presents the common and driver-specific choices, and the selected backend submits the resulting job. With CUPS, queue capabilities and options come from CUPS rather than being invented by every application.

For an application, the core remains ordinary painting:

KPrinter printer;
if (printer.setup(this)) {
    QPainter painter(&printer);
    painter.drawText(72, 72, "Printer test");
    painter.end();
}

The setup dialog gathers destination, page range, copies, and available properties. KPrinter then carries those choices into the KDEPrint path. The application’s responsibility is to paginate correctly and draw each requested page. It should not run lpr itself and then wonder why the desktop’s settings vanished.

When options misbehave, I now separate the stages. First I print to a file and inspect whether the PostScript pages are sane. Next I check the queue and its advertised defaults:

lpstat -p -d
lpoptions -p office -l

Then I submit a small known document directly with lp to see whether CUPS and the printer agree without the application involved. This distinguishes a rendering defect from a queue, PPD, filter, or device problem.

Driver-specific settings deserve some humility. Duplex names, available resolutions, and media trays are described by the printer’s PPD and may differ across queues. Hard-coding one printer’s option into application code is therefore both fragile and impolite. KDEPrint already has the unenviable job of asking the printer what it thinks it can do.

Page ranges require coordination with the application. If the print dialog says pages 3 through 5, my painting loop must honor the values reported by the printer object and call newPage() between pages. I test one page, a middle range, reverse order when offered, and several copies. These cases expose off-by-one errors that a ten-page full print politely hides under ten sheets of paper.

I also end the painter explicitly before inspecting the job. That completes PostScript generation and lets the backend submit a finished stream. Error reporting after setup deserves attention; cancellation from the dialog is not the same event as a backend rejecting a job.

I prefer letting KDEPrint expose those details and keeping application settings limited to document matters. The caveat is that a successful job submission only means the print system accepted responsibility. It does not mean paper will emerge. Printers retain a small but determined independence from software engineering.