001 Notes

Linux I/O Path: From write() to NVMe Doorbell

0. Mental Model

Linux storage I/O is easiest to understand as a chain of ownership transfers.

A userspace buffer does not jump straight to an NVMe controller. It is described, wrapped, copied or pinned, converted into block I/O, translated into a driver request, mapped for DMA, placed into an NVMe submission queue, and later completed through the reverse path.

The high-level path is:

userspace buffer
-> iovec / iov_iter
-> kiocb + file
-> filesystem path
-> page cache folio or direct-I/O user pages
-> bio
-> request
-> nvme_request + nvme_iod + nvme_command
-> NVMe submission queue entry
-> MMIO doorbell
-> device DMA and completion queue entry
-> request completion
-> bio completion
-> kiocb completion or syscall return

This article walks the common Linux I/O paths in the order the important structs appear:

  • buffered write;
  • direct write;
  • buffered read;
  • direct read;
  • minimal struct shapes;
  • how kiocb maps back to the filesystem;
  • how to observe the path with tracepoints;
  • how the NVMe/block path differs from PMEM/DAX.

The function names and files below match the shape used by upstream 5.x and 6.x kernels. Exact line numbers move, but the conceptual handoff has been stable for years.

1. Four Path Comparison

The four common file I/O paths differ mainly in whether data goes through the page cache and whether the bio points at cache pages or userspace pages.

PathPage cacheUser pages pinnedbio payloadDevice DMA target/sourceReturn condition
Buffered writeyesnolater writeback foliosdevice reads cache pagessyscall may return after copy into cache
O_DIRECT writenoyesuser pagesdevice reads user pagesreturns after direct I/O completion
Buffered read hityesnono device I/Ononecopies from cache to userspace
Buffered read missyesnocache foliosdevice writes cache pagescopies from uptodate cache folios
O_DIRECT readnoyesuser pagesdevice writes user pagesreturns after direct I/O completion

The important distinction:

buffered I/O:
userspace <-> page cache <-> block layer <-> NVMe

direct I/O:
userspace pages <-> block layer <-> NVMe

2. Buffered Write

Buffered write is a copy-then-writeback path.

The syscall copies user data into page-cache memory, marks folios dirty, and returns once the write has been accepted into the kernel cache. The actual device write can happen later during writeback.

2.1 Userspace Entry

write(fd, const void *buf, size_t count);

At the syscall boundary, the user pointer is still only a user virtual address. The kernel must describe it safely before a filesystem can consume it.

2.2 Syscall And VFS Glue

Representative path:

__x64_sys_write()
-> ksys_write()
-> vfs_write()
-> new_sync_write()
-> call_write_iter()

Key structs created or used here:

/* uapi/linux/uio.h */
struct iovec {
    void __user *iov_base;
    size_t       iov_len;
};

/* include/linux/uio.h */
struct iov_iter {
    unsigned int type;
    size_t       iov_offset;
    size_t       count;
    const struct iovec *iov;
};

/* include/linux/fs.h */
struct file {
    const struct file_operations *f_op;
    struct inode         *f_inode;
    struct address_space *f_mapping;
    loff_t                f_pos;
    unsigned int          f_flags;
};

struct kiocb {
    struct file *ki_filp;
    loff_t       ki_pos;
    int          ki_flags;
};

Field handoff:

FieldWhy the next layer cares
iov_iter.countbytes remaining in this operation
iov_iter.iovuser buffer segments
kiocb.ki_filpfile object that owns the operation
kiocb.ki_posfile offset
file.f_opfilesystem entry points
file.f_mappingpage-cache anchor

2.3 Filesystem And Page Cache

Common buffered write path:

generic_file_write_iter()
-> iomap_file_buffered_write()
-> copy_page_from_iter_atomic()
-> dirty folios

Important structs:

/* include/linux/fs.h */
struct address_space {
    struct inode *host;
    struct xarray i_pages;
    const struct address_space_operations *a_ops;
};

/* include/linux/mm_types.h */
struct folio {
    unsigned long flags;
    /* wraps one or more physical pages */
};

At this point, the write has not necessarily reached the NVMe device. The page cache owns the new data:

user buffer
-> iov_iter
-> copy into folio memory
-> mark folio dirty

Later, writeback gathers dirty folios and builds block I/O.

2.4 Writeback Builds bio

During writeback, filesystem and iomap code build bio objects:

iomap_writepages()
-> bio_alloc_*()
-> bio_add_page()
-> submit_bio()

Trimmed shape:

/* include/linux/bio.h */
struct bio_vec {
    struct page *bv_page;
    unsigned int bv_len;
    unsigned int bv_offset;
};

struct bvec_iter {
    sector_t     bi_sector;
    unsigned int bi_size;
};

struct bio {
    struct block_device *bi_bdev;
    struct bio_vec      *bi_io_vec;
    unsigned short       bi_vcnt;
    struct bvec_iter     bi_iter;
    unsigned int         bi_opf;
    bio_end_io_t        *bi_end_io;
    void                *bi_private;
};

Fields that matter:

FieldMeaning
bio.bi_bdevtarget block device
bio.bi_iter.bi_sectorstarting sector in 512-byte units
bio.bi_iter.bi_sizebytes left in the bio
bio.bi_io_vecpages and offsets carrying payload
bio.bi_opfoperation and flags, such as REQ_OP_WRITE, REQ_SYNC, REQ_FUA

2.5 Block Layer Builds request

Representative path:

submit_bio()
-> __submit_bio()
-> blk_mq_submit_bio()
-> blk_mq_make_request()
-> blk_mq_alloc_request()
-> driver->queue_rq()

Block layer shape:

/* include/linux/blkdev.h */
struct request_queue {
    const struct blk_mq_ops *mq_ops;
    unsigned int queue_flags;
    /* limits, scheduler state, hardware contexts */
};

struct request {
    struct request_queue *q;
    struct bio           *bio;
    struct bio           *biotail;
    blk_opf_t             cmd_flags;
    void                 *special;
};

struct bio is filesystem/block payload. struct request is the hardware-dispatch object.

The handoff point into the driver is the blk-mq operation:

mq_ops.queue_rq(hctx, &bd)

For NVMe, this eventually reaches the NVMe queueing path.

2.6 NVMe Host Driver

The NVMe driver maps the block request into an NVMe command and DMA description.

Important structs:

/* drivers/nvme/host/nvme.h */
struct nvme_ns {
    struct nvme_ctrl *ctrl;
    u32               ns_id;
    u8                lba_shift;
    struct gendisk   *disk;
};

struct nvme_iod {
    struct scatterlist *sg;
    int                 nents;
    dma_addr_t          first_dma;
    __le64             *dma_prps;
    int                 npages;
    size_t              length;
};

struct nvme_request {
    struct nvme_command cmd;
    struct nvme_iod    *iod;
    int                 status;
    u8                  retries;
};

NVMe command shape:

/* include/linux/nvme.h */
struct nvme_common_command {
    __u8   opcode;
    __u8   flags;
    __u16  command_id;
    __le32 nsid;
    __le64 mptr;
    __le64 prp1;
    __le64 prp2;
    __le32 cdw10;
    __le32 cdw11;
    __le32 cdw12;
    __le32 cdw13;
    __le32 cdw14;
    __le32 cdw15;
};

struct nvme_rw_command {
    __u8   opcode;
    __u8   flags;
    __u16  command_id;
    __le32 nsid;
    __le64 rsvd;
    __le64 slba;
    __le16 length;
    __le16 control;
    __le32 dsmgmt;
    __le32 reftag;
    __le16 apptag;
    __le16 appmask;
};

struct nvme_command {
    union {
        struct nvme_common_command common;
        struct nvme_rw_command     rw;
        /* other command formats */
    };
};

Key command fields:

FieldMeaning
rw.opcodenvme_cmd_write for write, nvme_cmd_read for read
rw.nsidnamespace id
rw.slbastarting logical block address
rw.lengthnumber of LBAs minus one
rw.controlcommand flags such as FUA
common.prp1 / common.prp2DMA pointer or PRP list

The driver derives the NVMe LBA from the block sector:

nvme_lba = bio_sector >> (ns->lba_shift - 9)

Why subtract 9:

block layer sector = 512 bytes = 2^9
NVMe namespace LBA = 2^ns->lba_shift bytes

2.7 NVMe Queue And Doorbell

The PCIe driver maps pages for DMA and posts the command:

nvme_setup_cmd()
-> nvme_setup_rw()
-> dma_map_sg()
-> nvme_pci_setup_prps()
-> copy command into submission queue
-> update SQ tail
-> writel(new_tail, nvmeq->q_db)

NVMe queue shape:

struct nvme_queue {
    volatile struct nvme_command    *sq_cmds;
    volatile struct nvme_completion *cqes;
    u16 qid;
    u16 sq_tail;
    u16 cq_head;
    void __iomem *q_db;
};

The doorbell write is the handoff where the controller is told that new SQ entries exist.

host memory SQ entry is ready
-> MMIO doorbell write
-> controller DMA-reads command and PRP/SGL descriptors

2.8 Device And Completion

The controller executes the command and posts a completion queue entry.

struct nvme_completion {
    __le32 result;
    __u32  rsvd;
    __le16 sq_head;
    __le16 sq_id;
    __u16  command_id;
    __le16 status;
};

Completion path:

NVMe device writes CQE
-> interrupt or polling
-> nvme_irq() / nvme_poll()
-> nvme_process_cq()
-> blk_mq_complete_request(req)
-> bio_endio()
-> filesystem completion
-> kiocb completion or syscall return

Buffered writes usually complete the syscall earlier, after copying into the page cache. Writeback completion is still essential for durability and error reporting, but it is decoupled from the original write() return unless synchronous flags force waiting.

3. Direct Write

O_DIRECT write avoids the page cache for file data.

The beginning is the same:

write()
-> iovec / iov_iter
-> file
-> kiocb
-> file->f_op->write_iter()

Then the filesystem chooses the direct-I/O path.

3.1 iomap Direct I/O

Representative path:

iomap_dio_rw()
-> bio_iov_iter_get_pages()
-> submit_bio()

Direct I/O state:

/* fs/iomap/direct-io.c */
struct iomap_dio {
    struct kiocb    *iocb;
    struct iov_iter *iter;
    struct bio      *head;
    struct bio      *tail;
    loff_t           size;
    loff_t           submitted;
    loff_t           done;
    bool             wait_for_completion;
};

The important change:

buffered write:
bio points at page-cache folios

direct write:
bio points at pinned user pages

After the bio is built, the path converges with buffered writeback:

bio
-> submit_bio()
-> blk-mq request
-> nvme_request
-> nvme_command
-> PRP/SGL from user pages
-> SQ doorbell
-> CQE
-> direct-I/O completion

3.2 Durability Flags

Direct I/O is not automatically the same thing as durable I/O.

With flags such as O_SYNC, RWF_SYNC, or sync write modes, the request may carry sync/FUA semantics:

kiocb.ki_flags
-> request.cmd_flags
-> REQ_SYNC / REQ_FUA
-> NVMe RW command control field

Depending on the filesystem, device, and requested semantics, durability may use:

  • FUA on the NVMe read/write command;
  • a separate flush command;
  • filesystem-level ordering and journal rules;
  • a combination of these.

Useful distinction:

O_DIRECT:
avoid page cache for file data

O_SYNC / RWF_SYNC / FUA:
control completion and durability semantics

They solve different problems.

4. Buffered Read

Buffered read is a page-cache lookup first, device I/O second.

4.1 Syscall And VFS

Representative path:

read()
-> __x64_sys_read()
-> ksys_read()
-> vfs_read()
-> new_sync_read()
-> call_read_iter()

The same front-end structs appear:

iovec
-> iov_iter
-> file
-> kiocb

4.2 Cache Hit

If the page cache already has uptodate folios:

filemap_read()
-> find folios in mapping->i_pages
-> copy_to_iter()
-> return to userspace

No bio, no request, no NVMe command.

4.3 Cache Miss

On a miss, the kernel allocates or locates folios and submits read I/O.

Representative path:

filemap_read()
-> page cache miss
-> readahead_control
-> iomap_readpages()
-> bio_alloc_*()
-> bio_add_page()
-> submit_bio()

Readahead control shape:

/* include/linux/pagemap.h */
struct readahead_control {
    struct address_space *mapping;
    pgoff_t _index;
    unsigned int _nr_pages;
};

The block and NVMe path is the same as write, but the command is read:

bio with cache folios
-> request
-> nvme_cmd_read
-> PRPs for device-to-host DMA
-> controller writes into folios
-> CQE
-> bio_endio()
-> folios marked Uptodate
-> copy_to_iter()
-> return to userspace

The key difference from direct read is the DMA target:

buffered read miss:
device DMA writes page-cache folios

direct read:
device DMA writes pinned userspace pages

5. Direct Read

O_DIRECT read avoids filling the page cache.

Path:

read()
-> iovec / iov_iter
-> file
-> kiocb
-> iomap_dio_rw()
-> pin destination user pages
-> build bio pointing at those pages
-> submit_bio()
-> blk-mq request
-> nvme_cmd_read
-> PRP/SGL to user pages
-> SQ doorbell
-> device DMA into user pages
-> CQE
-> ki_complete()
-> userspace return

There is no page-cache fill in the normal direct-read path.

6. Field Handoff Table

This table is the shortest way to remember what each layer contributes.

LayerStructFields that matter
Userspace descriptionioveciov_base, iov_len
Iteratoriov_itertype, count, iov_offset, iov
Per-I/O operationkiocbki_filp, ki_pos, ki_flags, completion state
Open filefilef_op, f_inode, f_mapping, f_flags, f_pos
Inode mappingaddress_spacei_pages, a_ops, host
Page cache unitfoliobacking pages and flags such as dirty/uptodate
Block payloadbiobi_bdev, bi_iter, bi_io_vec, bi_vcnt, bi_opf
Hardware requestrequestq, bio, biotail, cmd_flags, special
NVMe namespacenvme_nsctrl, ns_id, lba_shift
NVMe private requestnvme_requestcmd, iod, status, retries
DMA mappingnvme_iodsg, nents, first_dma, dma_prps, length
NVMe commandnvme_commandopcode, nsid, slba, length, prp1, prp2
Queuenvme_queuesq_cmds, cqes, sq_tail, cq_head, q_db
Completionnvme_completioncommand_id, status, sq_head, sq_id, result

7. Minimal Struct Shapes

This section repeats the core structs in one place, trimmed to fields useful for following the I/O path.

7.1 Userspace To VFS

struct iovec {
    void __user *iov_base;
    size_t       iov_len;
};

struct iov_iter {
    unsigned int type;
    size_t       count;
    size_t       iov_offset;
    const struct iovec *iov;
};

struct kiocb {
    struct file *ki_filp;
    loff_t       ki_pos;
    int          ki_flags;
};

7.2 Core VFS Objects

struct file {
    const struct file_operations *f_op;
    struct path         f_path;
    struct inode       *f_inode;
    struct address_space *f_mapping;
    loff_t              f_pos;
    unsigned int        f_flags;
};

struct file_operations {
    ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter)(struct kiocb *, struct iov_iter *);
    int     (*mmap)      (struct file *, struct vm_area_struct *);
    int     (*fsync)     (struct file *, loff_t, loff_t, int datasync);
    long    (*unlocked_ioctl)(struct file *, unsigned int, unsigned long);
};

struct inode {
    umode_t                       i_mode;
    struct super_block           *i_sb;
    const struct inode_operations *i_op;
    struct address_space         *i_mapping;
    loff_t                        i_size;
};

struct super_block {
    const struct super_operations *s_op;
    struct block_device           *s_bdev;
    struct dentry                 *s_root;
};

7.3 Page Cache And Mapping

struct address_space {
    struct inode *host;
    struct xarray i_pages;
    const struct address_space_operations *a_ops;
};

struct address_space_operations {
    int (*writepages)(struct address_space *, struct writeback_control *);
    int (*readpage)(struct file *, struct page *);
    /* modern filesystems often route reads/writeback through iomap helpers */
};

7.4 Block Layer

struct bio_vec {
    struct page *bv_page;
    unsigned int bv_len;
    unsigned int bv_offset;
};

struct bvec_iter {
    sector_t     bi_sector;
    unsigned int bi_size;
};

struct bio {
    struct block_device *bi_bdev;
    unsigned int         bi_opf;
    struct bio_vec      *bi_io_vec;
    unsigned short       bi_vcnt;
    struct bvec_iter     bi_iter;
    bio_end_io_t        *bi_end_io;
    void                *bi_private;
};

struct request_queue {
    const struct blk_mq_ops *mq_ops;
    unsigned int queue_flags;
};

struct request {
    struct request_queue *q;
    struct bio           *bio;
    struct bio           *biotail;
    blk_opf_t             cmd_flags;
    void                 *special;
};

7.5 NVMe Host Driver

struct nvme_ns {
    struct nvme_ctrl *ctrl;
    u32               ns_id;
    u8                lba_shift;
    struct gendisk   *disk;
};

struct nvme_iod {
    struct scatterlist *sg;
    int                 nents;
    dma_addr_t          first_dma;
    __le64             *dma_prps;
    int                 npages;
    size_t              length;
};

struct nvme_request {
    struct nvme_command cmd;
    struct nvme_iod    *iod;
    int                 status;
    u8                  retries;
};

struct nvme_queue {
    volatile struct nvme_command    *sq_cmds;
    volatile struct nvme_completion *cqes;
    u16 qid;
    u16 sq_tail;
    u16 cq_head;
    void __iomem *q_db;
};

7.6 iomap Direct I/O

struct iomap_dio {
    struct kiocb    *iocb;
    struct iov_iter *iter;
    struct bio      *head;
    struct bio      *tail;
    loff_t           size;
    loff_t           submitted;
    loff_t           done;
    bool             wait_for_completion;
};

8. How The Objects Point To Each Other

The core relationship graph:

userspace buffer
  |
  v
iovec -> iov_iter -> kiocb
                       |
                       v
                    struct file
                       |
                       +-> f_op: file_operations
                       |      +-> read_iter / write_iter
                       |
                       +-> f_inode: inode
                       |      +-> i_sb: super_block
                       |      |      +-> s_type: file_system_type
                       |      |
                       |      +-> i_mapping: address_space
                       |
                       +-> f_mapping: address_space

address_space
  |
  +-> i_pages: folio cache
  +-> a_ops: address_space_operations

bio
  |
  +-> bi_io_vec: pages
  +-> bi_bdev: block device
  +-> bi_iter: sector and size

request_queue
  |
  +-> mq_ops.queue_rq()
          |
          v
       NVMe driver

The key fact:

kiocb does not directly know the filesystem.
kiocb points to file.
file points to f_op and inode.
f_op points to filesystem methods.
inode points to super_block and filesystem instance state.

9. Minimal Filesystem Wiring

An iomap-style filesystem wires data I/O through file_operations, address_space_operations, and inode setup.

Pseudocode:

static const struct file_operations myfs_file_ops = {
    .read_iter  = generic_file_read_iter,
    .write_iter = generic_file_write_iter,
    .mmap       = generic_file_mmap,
    .fsync      = generic_file_fsync,
};

static const struct address_space_operations myfs_aops = {
    .writepages = iomap_writepages,
    /* dirty_folio, write_end, readahead hooks depend on filesystem design */
};

static const struct inode_operations myfs_inode_ops = {
    .getattr = myfs_getattr,
};

static const struct super_operations myfs_super_ops = {
    .statfs = myfs_statfs,
};

/* during inode setup */
inode->i_fop = &myfs_file_ops;
inode->i_mapping->a_ops = &myfs_aops;
inode->i_op = &myfs_inode_ops;

With that wiring:

buffered write()
-> generic_file_write_iter()
-> iomap_file_buffered_write()
-> dirty folios
-> writeback
-> bio
-> submit_bio()
-> blk-mq
-> NVMe

O_DIRECT write()
-> generic_file_write_iter()
-> filesystem direct-I/O decision
-> iomap_dio_rw()
-> bio with pinned user pages
-> submit_bio()
-> blk-mq
-> NVMe

10. How kiocb Maps To The Filesystem

struct kiocb is the bridge between one I/O operation and the persistent file object.

It represents one in-flight operation:

same struct file
-> many concurrent kiocbs
-> each with its own offset, flags, completion state

10.1 Where kiocb Comes From

Representative write setup:

ssize_t vfs_write(struct file *file, const char __user *buf,
                  size_t count, loff_t *pos)
{
    struct kiocb kiocb;
    struct iov_iter iter;

    init_sync_kiocb(&kiocb, file);
    kiocb.ki_pos = *pos;
    iov_iter_init(&iter, WRITE, &iov, 1, count);

    return call_write_iter(file, &kiocb, &iter);
}

The binding happens here:

kiocb.ki_filp = file

That file already knows its filesystem entry points and inode mapping.

10.2 Filesystem Indirection Chain

Pointer chain:

kiocb
  |
  v
ki_filp: struct file
  |
  +-> f_op: struct file_operations
  |
  +-> f_inode: struct inode
  |     |
  |     +-> i_sb: struct super_block
  |     |     |
  |     |     +-> s_type: struct file_system_type
  |     |           |
  |     |           +-> name = "ext4", "xfs", "btrfs", ...
  |     |
  |     +-> i_mapping: page cache and a_ops
  |
  +-> f_mapping: usually inode->i_mapping

10.3 call_write_iter()

ssize_t call_write_iter(struct file *file,
                        struct kiocb *kiocb,
                        struct iov_iter *iter)
{
    const struct file_operations *fop = file->f_op;
    return fop->write_iter(kiocb, iter);
}

The write_iter function pointer was selected by the filesystem when the file was opened.

Example shape:

const struct file_operations ext4_file_operations = {
    .read_iter  = generic_file_read_iter,
    .write_iter = ext4_file_write_iter,
    /* other methods */
};

So the call becomes:

file->f_op->write_iter(&kiocb, &iter)
-> ext4_file_write_iter(&kiocb, &iter)

At that point, the operation is inside filesystem code.

10.4 How Filesystems Use kiocb

Representative filesystem shape:

ssize_t ext4_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    loff_t pos = iocb->ki_pos;

    if (iocb->ki_flags & IOCB_DIRECT)
        return iomap_dio_rw(iocb, from, &ext4_iomap_ops, ...);

    return generic_file_write_iter(iocb, from);
}

What the filesystem learns from kiocb:

FieldMeaning
ki_filpwhich open file this operation targets
ki_posoffset for this operation
ki_flagsdirect I/O, nowait, sync, append, async properties
completion fieldshow async completions are reported

Why file alone is not enough:

struct file:
persistent per-open state

struct kiocb:
transient per-operation state

Many threads or io_uring submissions can operate on the same struct file. Each operation needs its own kiocb.

11. Exact Handoff Cheat Sheet

11.1 Buffered Write

userspace buf
-> iovec + iov_iter
-> file + kiocb
-> file->f_op->write_iter()
-> generic_file_write_iter() / filesystem write_iter()
-> iomap_file_buffered_write()
-> folio in mapping->i_pages
-> copy_page_from_iter_atomic()
-> mark folio dirty
-> later writeback
-> bio with page-cache folios
-> submit_bio()
-> blk_mq_make_request()
-> request + request_queue
-> nvme_queue_rq()
-> nvme_request + nvme_iod + nvme_command
-> dma_map_sg()
-> PRP/SGL setup
-> submission queue entry
-> MMIO doorbell
-> device DMA reads host pages
-> completion queue entry
-> nvme_process_cq()
-> blk_mq_complete_request()
-> bio_endio()

11.2 Direct Write

userspace buf
-> iovec + iov_iter
-> file + kiocb
-> file->f_op->write_iter()
-> iomap_dio_rw()
-> pin user pages
-> bio with bvecs pointing at user pages
-> submit_bio()
-> blk-mq request
-> nvme_request + nvme_iod + nvme_command
-> PRP/SGL from user pages
-> SQ doorbell
-> device DMA reads user pages
-> CQE
-> direct-I/O completion
-> ki_complete()

11.3 Buffered Read

userspace buf
-> iovec + iov_iter
-> file + kiocb
-> file->f_op->read_iter()
-> filemap_read()
-> lookup folios in mapping->i_pages

cache hit:
  -> copy_to_iter()
  -> return

cache miss:
  -> readahead_control
  -> allocate / lock folios
  -> bio with cache folios
  -> submit_bio()
  -> blk-mq request
  -> nvme_cmd_read
  -> PRP/SGL to cache folios
  -> SQ doorbell
  -> device DMA writes folios
  -> CQE
  -> bio_endio()
  -> folios become Uptodate
  -> copy_to_iter()
  -> return

11.4 Direct Read

userspace buf
-> iovec + iov_iter
-> file + kiocb
-> file->f_op->read_iter()
-> iomap_dio_rw()
-> pin destination user pages
-> bio with bvecs pointing at user pages
-> submit_bio()
-> blk-mq request
-> nvme_cmd_read
-> PRP/SGL to user pages
-> SQ doorbell
-> device DMA writes user pages
-> CQE
-> ki_complete()
-> return

12. Observing The Path With Tracepoints

Tracepoints let you watch the block layer and NVMe layer without modifying the kernel.

Block I/O lifecycle:

sudo bpftrace -e '
tracepoint:block:block_bio_queue {
  printf("bio %p queue dev=%d:%d sector=%u bytes=%u rwbs=%s\n",
    args->bio, args->dev >> 20, args->dev & 0xfffff,
    args->sector, args->nr_sector * 512, args->rwbs);
}

tracepoint:block:block_rq_issue {
  printf("rq %p issue dev=%d:%d bytes=%u rwbs=%s\n",
    args->rq, args->dev >> 20, args->dev & 0xfffff,
    args->nr_bytes, args->rwbs);
}

tracepoint:block:block_rq_complete {
  printf("rq %p complete bytes=%u\n", args->rq, args->nr_bytes);
}'

NVMe submit and complete, when enabled by the kernel:

sudo bpftrace -e '
tracepoint:nvme:nvme_sq {
  printf("NVMe SQ qid=%d cid=%d opcode=%d nsid=%d\n",
    args->qid, args->cid, args->opcode, args->nsid);
}

tracepoint:nvme:nvme_cq {
  printf("NVMe CQ qid=%d cid=%d status=0x%x\n",
    args->qid, args->cid, args->status);
}'

What to correlate:

TracepointObject layer
block:block_bio_queuebio enters block layer
block:block_rq_issuerequest issued to driver
nvme:nvme_sqNVMe command submitted
nvme:nvme_cqNVMe completion observed
block:block_rq_completeblock request completed

Tracepoint argument fields vary across kernel versions, so treat the scripts as a starting point and inspect available fields with:

sudo bpftrace -lv 'tracepoint:block:*'
sudo bpftrace -lv 'tracepoint:nvme:*'

13. NVMe Block Path Versus PMEM/DAX

The NVMe path above is a block-device DMA path.

Persistent memory with DAX has a different shape.

13.1 NVMe Block Device Path

NVMe block I/O uses:

filesystem
-> page cache or direct user pages
-> bio
-> request
-> NVMe command
-> PRP/SGL DMA mapping
-> submission queue
-> MMIO doorbell
-> controller DMA
-> completion queue

The device is active. It receives commands, performs DMA, and posts completions.

13.2 PMEM Without DAX

PMEM can still be exposed through a block device path.

In that mode, the upper layers may still look familiar:

filesystem
-> page cache or direct I/O
-> bio
-> request-like block path
-> pmem driver

The lower device behavior differs from NVMe because the storage is byte-addressable persistent memory, not a queue-based NVMe controller.

13.3 DAX Path

DAX changes the model more aggressively.

The point of DAX is to bypass the page cache for file data and allow direct mappings of persistent memory into a process address space.

Conceptual path:

filesystem
-> iomap / dax_iomap
-> persistent-memory PFNs
-> CPU load/store path

Important differences from NVMe:

QuestionNVMe block pathPMEM/DAX path
Is there an NVMe submission queue?yesno
Is there a controller doorbell?yesno
Does data transfer require device DMA?yesno, CPU load/store can access mapped persistent memory
Is the page cache used for file data?buffered path yesDAX bypasses page cache for file data
Are persistence rules still needed?yesyes, but they involve CPU cache flush and ordering rules

DAX does not mean “ordinary RAM with no rules.” CPU stores may sit in caches before reaching the persistence domain. Correct persistence still depends on flush and ordering mechanisms appropriate for the platform.

13.4 Why This Contrast Matters

NVMe and PMEM can both sit under filesystems, but they do not have the same final handoff.

NVMe final handoff:

host prepares DMA-visible command and buffers
-> rings MMIO doorbell
-> controller owns progress

PMEM/DAX final handoff:

filesystem maps persistent memory
-> CPU executes loads/stores
-> persistence depends on cache flush and ordering

That is the main mental split.

14. Glossary

TermMeaning
iovecuserspace buffer segment
iov_iterkernel iterator over one or more buffer segments
kiocbper-I/O operation context
filepersistent per-open file object
inodefilesystem object representing the file
address_spacepage-cache anchor for an inode
foliomodern page-cache unit, possibly larger than one page
bioblock I/O payload made of page vectors
requestblk-mq hardware-dispatch object
blk-mqmultiqueue block layer
nvme_requestNVMe driver’s private state for a block request
nvme_iodNVMe I/O descriptor, including scatterlist and PRP state
PRPphysical region page pointer used by NVMe DMA
SGLscatter-gather list
SQNVMe submission queue
CQNVMe completion queue
doorbellMMIO register write that tells the controller new queue entries exist
FUAforce unit access, a durability-related write flag
DAXdirect access path for persistent memory mappings

15. Final Shape

The whole NVMe/block path compresses to:

userspace buffer
-> iovec / iov_iter
-> file / kiocb
-> filesystem method through file_operations
-> buffered: folio in page cache
-> direct: pinned user pages
-> bio
-> request_queue / request
-> nvme_request / nvme_iod / nvme_command
-> PRP or SGL DMA mapping
-> NVMe submission queue entry
-> MMIO doorbell
-> controller DMA
-> NVMe completion queue entry
-> blk-mq completion
-> bio_endio()
-> kiocb completion or syscall return

The shortest useful rule:

kiocb tells the filesystem which file operation is happening.
bio tells the block layer which pages and sectors are involved.
request tells the driver what hardware operation to issue.
nvme_command tells the controller what DMA to perform.

Comments