LeavaTailの日記

LeavaTailの日記

Linuxエンジニアを目指した技術者の備忘録

Linuxカーネルのファイルアクセスの処理を追いかける (6) get_block

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_get_block関数を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

本記事では、ext2ファイルシステムwrite_begin操作から呼び出されるget_block操作を解説する。
また本記事では、get_block操作は、ブロックがすでに確保されている場合のみに限定する。

ext2ファイルシステムのボリュームレイアウト

get_blockの処理に移る前にext2ファイルシステムのレイアウトを解説する。

Ext2パーティションExt2のレイアウト

ext2ファイルシステムは複数のブロックグループ (Block Group)から構成されていて、各ブロックグループは複数のブロックから構成されている。
一つのブロックグループには、次のようなデータが格納されている。

  • スーパーブロック : パーティション全体に関する情報 (1ブロック)
  • ブロックグループディスクリプタ : そのブロックグループに関する情報 (複数ブロック)
  • データブロックビットマップ : そのブロックグループにおけるデータブロックの空き情報 (1ブロック)
  • iノードビットマップ : そのブロックグループにおけるiノードの空き情報(1ブロック)
  • iノードテーブル : iノード (複数ブロック)
  • データブロック : 実際のデータ (複数ブロック)

また、iノードにはi_dataと呼ばれるメンバが合計15個あり、i_dataがデータブロックを参照する。
ただし、i_data[12],i_data[13],i_data[14]に関しては、Indirect Blockと呼ばれるブロック番号のみを保持するブロックを参照する。 それぞれ、Indirect Blockを経由する個数が決まっており、i_data[12]は1回、i_data[13]は2回i_data[14]は3回となっている。

これによって、ファイルサイズの小さいファイルに対しても効率的にデータを保存でき、かつファイルサイズの大きいファイルも扱うことができる。

データブロックのアドレッシング

ext2ファイルシステムの詳細については、解説記事や書籍を参照。

get_blockの概略

前回のwrite_begin操作(ext2_write_begin)は、ファイルシステムのブロックを取得するために、ext2_get_block関数を呼び出す。

このget_block操作はinode 構造体から指定されたブロックを取得する操作である。

関数の定義は次のようになっており、対象ファイルのinode構造体と取得したデータのオフセットiblockを引数としてインプットすることで、対象のデータをbh_resultに返す。
このとき、指定されたオフセットのデータが存在しない場合、かつcreateフラグが立っている場合は、新しくブロックを確保する。

// 779:
int ext2_get_block(struct inode *inode, sector_t iblock,
        struct buffer_head *bh_result, int create)
{
    unsigned max_blocks = bh_result->b_size >> inode->i_blkbits;
    bool new = false, boundary = false;
    u32 bno;
    int ret;

    ret = ext2_get_blocks(inode, iblock, max_blocks, &bno, &new, &boundary,
            create);
    if (ret <= 0)
        return ret;

    map_bh(bh_result, inode->i_sb, bno);
    bh_result->b_size = (ret << inode->i_blkbits);
    if (new)
        set_buffer_new(bh_result);
    if (boundary)
        set_buffer_boundary(bh_result);
    return 0;

}

ext2_get_block関数は、複数ブロックを取得するext2_get_blocks関数から構成される。

ただし、必ずしもブロックが一つのバッファキャッシュに収まるとは限らない。
変数max_blocksは、必要なバッファキャッシュの個数を表している。

ext2_get_blocks関数では、取得するブロックによって処理が異なる。
この関数の大まかなフローチャートを下記に示す。

ext2_get_blocks関数のフローチャート

ext2_get_blocks関数を大きく分けて「取得するブロックが確保済みの場合」と「取得するブロックがまだ確保されていない場合」となる。
本記事では「取得するブロックが確保済みの場合」に注目して処理を確認していく。

まずは、ext2_get_blocks関数から確認していく。

// 620:
static int ext2_get_blocks(struct inode *inode,
               sector_t iblock, unsigned long maxblocks,
               u32 *bno, bool *new, bool *boundary,
               int create)
{
    int err;
    int offsets[4];
    Indirect chain[4];
    Indirect *partial;
    ext2_fsblk_t goal;
    int indirect_blks;
    int blocks_to_boundary = 0;
    int depth;
    struct ext2_inode_info *ei = EXT2_I(inode);
    int count = 0;
    ext2_fsblk_t first_block = 0;

    BUG_ON(maxblocks == 0);

    depth = ext2_block_to_path(inode,iblock,offsets,&blocks_to_boundary);

    if (depth == 0)
        return -EIO;

    partial = ext2_get_branch(inode, depth, offsets, chain, &err);
    /* Simplest case - block found, no allocation needed */
    if (!partial) {
        first_block = le32_to_cpu(chain[depth - 1].key);
        count++;
        /*map more blocks*/
        while (count < maxblocks && count <= blocks_to_boundary) {
            ext2_fsblk_t blk;

            if (!verify_chain(chain, chain + depth - 1)) {
                /*
                * Indirect block might be removed by
                * truncate while we were reading it.
                * Handling of that case: forget what we've
                * got now, go to reread.
                */
                err = -EAGAIN;
                count = 0;
                partial = chain + depth - 1;
                break;
            }
            blk = le32_to_cpu(*(chain[depth-1].p + count));
            if (blk == first_block + count)
                count++;
            else
                break;
        }
        if (err != -EAGAIN)
            goto got_it;
    }

    /* Next simple case - plain lookup or failed read of indirect block */
    if (!create || err == -EIO)
        goto cleanup;

    mutex_lock(&ei->truncate_mutex);
    /*
    * If the indirect block is missing while we are reading
    * the chain(ext2_get_branch() returns -EAGAIN err), or
    * if the chain has been changed after we grab the semaphore,
    * (either because another process truncated this branch, or
    * another get_block allocated this branch) re-grab the chain to see if
    * the request block has been allocated or not.
    *
    * Since we already block the truncate/other get_block
    * at this point, we will have the current copy of the chain when we
    * splice the branch into the tree.
    */
    if (err == -EAGAIN || !verify_chain(chain, partial)) {
        while (partial > chain) {
            brelse(partial->bh);
            partial--;
        }
        partial = ext2_get_branch(inode, depth, offsets, chain, &err);
        if (!partial) {
            count++;
            mutex_unlock(&ei->truncate_mutex);
            goto got_it;
        }

        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }

    /*
    * Okay, we need to do block allocation.  Lazily initialize the block
    * allocation info here if necessary
   */
    if (S_ISREG(inode->i_mode) && (!ei->i_block_alloc_info))
        ext2_init_block_alloc_info(inode);

    goal = ext2_find_goal(inode, iblock, partial);

    /* the number of blocks need to allocate for [d,t]indirect blocks */
    indirect_blks = (chain + depth) - partial - 1;
    /*
    * Next look up the indirect map to count the total number of
    * direct blocks to allocate for this branch.
    */
    count = ext2_blks_to_allocate(partial, indirect_blks,
                    maxblocks, blocks_to_boundary);
    /*
    * XXX ???? Block out ext2_truncate while we alter the tree
    */
    err = ext2_alloc_branch(inode, indirect_blks, &count, goal,
                offsets + (partial - chain), partial);

    if (err) {
        mutex_unlock(&ei->truncate_mutex);
        goto cleanup;
    }

    if (IS_DAX(inode)) {
        /*
        * We must unmap blocks before zeroing so that writeback cannot
        * overwrite zeros with stale data from block device page cache.
        */
        clean_bdev_aliases(inode->i_sb->s_bdev,
                   le32_to_cpu(chain[depth-1].key),
                   count);
        /*
        * block must be initialised before we put it in the tree
        * so that it's not found by another thread before it's
        * initialised
        */
        err = sb_issue_zeroout(inode->i_sb,
                le32_to_cpu(chain[depth-1].key), count,
                GFP_NOFS);
        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }
    *new = true;

    ext2_splice_branch(inode, iblock, partial, indirect_blks, count);
    mutex_unlock(&ei->truncate_mutex);
got_it:
    if (count > blocks_to_boundary)
        *boundary = true;
    err = count;
    /* Clean up and exit */
    partial = chain + depth - 1;   /* the whole chain */
cleanup:
    while (partial > chain) {
        brelse(partial->bh);
        partial--;
    }
    if (err > 0)
        *bno = le32_to_cpu(chain[depth-1].key);
    return err;
}

必要なバッファキャッシュの個数maxblocks0となることは通常ありえないので、その場合はBUG_ONマクロによってカーネルパニックを引き起こす。

この関数では、Indirect型が用いて取得するブロック間の関係性を表現する。
Indirect型は、下記のような定義となっており、ポインタと整数型のキー値とバッファキャッシュのポインタを持つ構造体である。

// 114:
typedef struct {
    __le32  *p;
    __le32  key;
    struct buffer_head *bh;
} Indirect;

Indirect型のメンバはそれぞれ、次のデータが格納されていく。

  • p: Indirect Blockの該当のオフセットへのポインタ
  • key: Indirect Blockの該当オフセットに格納されているブロック番号の値
  • bh: 次のIndirect Blockと関連付けているbuffer_headへのポインタ

例えば、Indirect Block #???とIndirect Block #4111の関係性を表すようなIndirect型のデータ構造は次の通りとなる。

Indirect型のイメージ

上記の内容を踏まえたうえで、ext2_get_blocks関数の処理に戻る。

オフセットの取得

ext2_get_blocks関数で最初に実施することは、「オフセットの取得」である。
この処理に該当するソースコードは下記の部分となっている。

// 639:
    depth = ext2_block_to_path(inode,iblock,offsets,&blocks_to_boundary);

    if (depth == 0)
        return -EIO;

ext2_block_to_path関数は、ファイルのinodeとブロック番号iblockを渡し、それぞれのIndirect Blockにおけるオフセットoffsetを返す。

// 163:
static int ext2_block_to_path(struct inode *inode,
            long i_block, int offsets[4], int *boundary)
{
    int ptrs = EXT2_ADDR_PER_BLOCK(inode->i_sb);
    int ptrs_bits = EXT2_ADDR_PER_BLOCK_BITS(inode->i_sb);
    const long direct_blocks = EXT2_NDIR_BLOCKS,
        indirect_blocks = ptrs,
        double_blocks = (1 << (ptrs_bits * 2));
    int n = 0;
    int final = 0;

    if (i_block < 0) {
        ext2_msg(inode->i_sb, KERN_WARNING,
            "warning: %s: block < 0", __func__);
    } else if (i_block < direct_blocks) {
        offsets[n++] = i_block;
        final = direct_blocks;
    } else if ( (i_block -= direct_blocks) < indirect_blocks) {
        offsets[n++] = EXT2_IND_BLOCK;
        offsets[n++] = i_block;
        final = ptrs;
    } else if ((i_block -= indirect_blocks) < double_blocks) {
        offsets[n++] = EXT2_DIND_BLOCK;
        offsets[n++] = i_block >> ptrs_bits;
        offsets[n++] = i_block & (ptrs - 1);
        final = ptrs;
    } else if (((i_block -= double_blocks) >> (ptrs_bits * 2)) < ptrs) {
        offsets[n++] = EXT2_TIND_BLOCK;
        offsets[n++] = i_block >> (ptrs_bits * 2);
        offsets[n++] = (i_block >> ptrs_bits) & (ptrs - 1);
        offsets[n++] = i_block & (ptrs - 1);
        final = ptrs;
    } else {
        ext2_msg(inode->i_sb, KERN_WARNING,
            "warning: %s: block is too big", __func__);
    }
    if (boundary)
        *boundary = final - 1 - (i_block & (ptrs - 1));

    return n;
}

ext2_block_to_path関数のイメージ図は次のとおりである。

ext2_block_to_path関数によるオフセットの取得

chainの作成

各Indirect Blockのオフセットが取得できたら、これらをIndirect型に代入していく。
ext2_get_branch関数が該当する関数となる。

// 644:
    partial = ext2_get_branch(inode, depth, offsets, chain, &err);

ext2_get_branch関数は、ファイルのinodeとIndirect Blockの数depthと各Indirect Blockのオフセットoffsetを渡し、ブロックが未確保の場合には Indirect型のpを返す。

// 234:
static Indirect *ext2_get_branch(struct inode *inode,
                 int depth,
                 int *offsets,
                 Indirect chain[4],
                 int *err)
{
    struct super_block *sb = inode->i_sb;
    Indirect *p = chain;
    struct buffer_head *bh;

    *err = 0;
    /* i_data is not going away, no lock needed */
    add_chain (chain, NULL, EXT2_I(inode)->i_data + *offsets);
    if (!p->key)
        goto no_block;
    while (--depth) {
        bh = sb_bread(sb, le32_to_cpu(p->key));
        if (!bh)
            goto failure;
        read_lock(&EXT2_I(inode)->i_meta_lock);
        if (!verify_chain(chain, p))
            goto changed;
        add_chain(++p, bh, (__le32*)bh->b_data + *++offsets);
        read_unlock(&EXT2_I(inode)->i_meta_lock);
        if (!p->key)
            goto no_block;
    }
    return NULL;

changed:
    read_unlock(&EXT2_I(inode)->i_meta_lock);
    brelse(bh);
    *err = -EAGAIN;
    goto no_block;
failure:
    *err = -EIO;
no_block:
    return p;
}

ext2_get_branch関数でchainがどのように連結されるのかを表したものが下記となる。

間接ブロックのchain生成

ext2_get_branch関数の処理を大まかにまとめると次の通りとなる。

  1. 0番目のchainを作成する
  2. ブロック番号がまだ格納されていない場合、ブロック未確保となるのでext2_get_blocks関数でブロックを確保する
  3. Indirect Blockが存在する場合、実ブロックまで下記をループする
  4. p->keyから次のブロック番号を取得し、そのブロック番号を用いてsb_bread関数で次のブロックを取得する
  5. i_meta_lockのread_lockを取得する
  6. 取得したブロックを検証する
  7. i_meta_lockのread_lockを開放する

ここでchainの作成には、add_chain関数よって実現される。

// 120:
static inline void add_chain(Indirect *p, struct buffer_head *bh, __le32 *v)
{
    p->key = *(p->p = v);
    p->bh = bh;
}

また、作成したchainが妥当であるか確認するために、verity_chain関数を呼び出す。

// 126:
static inline int verify_chain(Indirect *from, Indirect *to)
{
    while (from <= to && from->key == *from->p)
        from++;
    return (from > to);
}

ext2_get_branch関数から呼び出されるverify_chain関数では、下記を確認している。

  • 作成したchain(p) が引数のchain(chain)と同じ、または新しいこと
  • chainのポインタfrom->pで参照している値と、chainの値from->keyの値が一致していること

ext2_get_branch関数でchainの生成ができた、またはブロックの新規確保が必要と判断ができる。

chainの検証

ブロックがすでに確保済みである場合は、chainにある適切なデータを返す。
処理としては下記のブロックとなる。

// 645:
    /* Simplest case - block found, no allocation needed */
    if (!partial) {
        first_block = le32_to_cpu(chain[depth - 1].key);
        count++;
        /*map more blocks*/
        while (count < maxblocks && count <= blocks_to_boundary) {
            ext2_fsblk_t blk;

            if (!verify_chain(chain, chain + depth - 1)) {
                /*
                * Indirect block might be removed by
                * truncate while we were reading it.
                * Handling of that case: forget what we've
                * got now, go to reread.
                */
                err = -EAGAIN;
                count = 0;
                partial = chain + depth - 1;
                break;
            }
            blk = le32_to_cpu(*(chain[depth-1].p + count));
            if (blk == first_block + count)
                count++;
            else
                break;
        }
        if (err != -EAGAIN)
            goto got_it;
    }

このブロックの処理を簡潔にまとめると、「chainの先頭からverify_chain関数で検証しながら、データブロックを変数blkを取得する」。

このとき、適切であるかどうかをverify_chain関数で確認する。

検証に成功した場合

verify_chain関数で検証に成功した場合、got_itラベルにジャンプする。
got_itラベルは下記の場所に定義されている。

// 763:
got_it:
    if (count > blocks_to_boundary)
        *boundary = true;
    err = count;
    /* Clean up and exit */
    partial = chain + depth - 1;   /* the whole chain */
cleanup:
    while (partial > chain) {
        brelse(partial->bh);
        partial--;
    }
    if (err > 0)
        *bno = le32_to_cpu(chain[depth-1].key);
    return err;

ここで、取得したバッファを開放 (リファレンスカウントをデクリメントする)をした後に、bnoに結果を格納して終了する。

検証に失敗した場合

verify_chain関数で検証に失敗した場合は、セマフォを確保した状態で再度chainの生成をする。(失敗する要因として、他のプロセスやコンテキストスイッチなどの影響で変化してしまった場合などがある)

// 679:
    mutex_lock(&ei->truncate_mutex);
    /*
    * If the indirect block is missing while we are reading
    * the chain(ext2_get_branch() returns -EAGAIN err), or
    * if the chain has been changed after we grab the semaphore,
    * (either because another process truncated this branch, or
    * another get_block allocated this branch) re-grab the chain to see if
    * the request block has been allocated or not.
    *
    * Since we already block the truncate/other get_block
    * at this point, we will have the current copy of the chain when we
    * splice the branch into the tree.
    */
    if (err == -EAGAIN || !verify_chain(chain, partial)) {
        while (partial > chain) {
            brelse(partial->bh);
            partial--;
        }
        partial = ext2_get_branch(inode, depth, offsets, chain, &err);
        if (!partial) {
            count++;
            mutex_unlock(&ei->truncate_mutex);
            goto got_it;
        }

        if (err) {
            mutex_unlock(&ei->truncate_mutex);
            goto cleanup;
        }
    }

再度、ext2_get_branch関数を呼び出し、chainが取得できた場合はgot_itラベル、できない場合はcleanupラベルにジャンプする。

バッファキャッシュにマップする

再掲となるが、ext2_get_blocks関数直後のコードは次の通りとなっている。

// 789:
   if (ret <= 0)
        return ret;

    map_bh(bh_result, inode->i_sb, bno);
    bh_result->b_size = (ret << inode->i_blkbits);
    if (new)
        set_buffer_new(bh_result);
    if (boundary)
        set_buffer_boundary(bh_result);
    return 0;

ext2_get_blocks関数によって指定した関数を取得できた場合は、map_bh関数を実行する。
この関数では、引数のbuffer_headに対応するデータが記憶装置上のデータとマップしているフラグを付与する。

// 341:
static inline void
map_bh(struct buffer_head *bh, struct super_block *sb, sector_t block)
{
    set_buffer_mapped(bh);
    bh->b_bdev = sb->s_bdev;
    bh->b_blocknr = block;
    bh->b_size = sb->s_blocksize;
}

また、バッファキャッシュが新規に作られた場合はnewフラグを立て、Boundary Bufferである場合はboundaryフラグを立てる。

get_block操作では、該当のブロックをbh_resultに更新とフラグの更新をして処理を終了する。

おわりに

ext2_get_block関数で登場するデータ構造を表したものが下記となる。

ext2_get_block関数の概要図

変更履歴

  • 2021/7/23: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

Raspberry Pi 4 Model B (64-bit版 Raspberry Pi OS) で dm-integrity のオーバーヘッドを計測する

概要

Raspberry Pi 4 Model B (64-Bit版 Raspberry Pi OS) で dm-integrityの使用方法について確認した。

  • dm-integrityはシーケンシャルライトが40%、ランダムライトが50%の性能低下していることが確認できた。
  • dm-integrityはシーケンシャルリードが10%ランダムリード20%の性能低下していることが確認できた。

はじめに

Raspberry Pi 4 Model B (Raspberry Pi 4) はarm64のシングルボードコンピュータであり、趣味から仕事まで幅広い用途として利用されている。
Raspberry Pi の 推奨OSの一つに DebianベースOSのRaspberry Pi OSがある。 2021年4月30日現在では、安定性や互換性の観点からRaspberry Pi OSのデフォルトが32-bit版となっている。 しかし、64-bit版のRaspberry Pi OSも用意されているので、設定を変更することで利用できる。

一方で、dm-integrityはLinuxカーネルにおける標準提供の機能であり、ブロックの整合性を保障することができる。 dm-integrity では、 Device mapperと呼ばれるブロックデバイスの仮想レイヤを作成する機能を用いて、ジャーナルログを生成する。
ブロックの整合性を保証できる一方で、性能の観点でもストレージの容量の観点でもオーバーヘッドが発生する。

目的

  • dm-integrityを導入したことで読み書き性能がどれだけ変化するかを調査する
  • 改ざん検知機能 (dm-verity と dm-integrity+dm-crypt) の間で読み込み性能の比較をする

dm-integrity とは

dm-integrityは、Linuxカーネル v4.12以降で利用することができるLinux標準提供の機能である。 dm-integrityのデータ構造は下記の通りとなっている。

dm-integrityのディスクレイアウト

Device-mapperの機構を利用して、データと整合性タグをまとめてジャーナルにコミットしてから、データと整合性タグを適切な位置に書き込むことになっている。
ext4といったジャーナリングファイルシステムはファイルの整合性を保障する一方で、dm-integrityではブロック単位の整合性保証する。
そのため、ビットエラーや書き込み中の電源断による整合性保証のために利用される。

また、dm-integrityはdm-cryptと共に利用されることが多い。
dm-cryptは、Linuxカーネルにおける標準提供の機能であり、ブロック単位にを暗号/複合することができる。 二つの機構を組み合わせHMAC-sha256を使うことで、不正なデータの更新(改ざん)を検出することができる。

なぜ64-bit版 のOSで試すのか

2021年4月30日現在、Raspberry Pi OSはDebian 10 (buster) がベースとなっており、cryptsetup 2.1.0がパッケージされている。 一方で、armhf と cryptsetup 2.1.0は安定していない (ように見える)。

gitlab.com

gitlab.com

そこで下記の観点から、64-bit版のRaspberry Pi OSを使用することにした。

  • カーネルコンフィグを修正するために再ビルドが必要であること
  • arm64の読み書き性能を正しく計測するために、OSも64-bit版が適していること
  • 32-bit版OSの既知のバグを踏まないため

実行環境

計測対象のRaspberry Pi 4 Model Bの詳細は下記のとおりである。

名前 詳細
Hardware Raspberry Pi 4 Model B Rev 1.2 (4GB)
OS Raspberry Pi OS arm64 2021-04-09
Kernel Linux version 5.10.3-v8
Kconfig 付録参照
SDカード HDMCSDH16GCL10UIJP-WOA
USBメモリ SILICON POWER SP032GBUF3B02V1K
IPアドレス (eth0) 172.16.1.3
ユーザ名 pi
ホスト名 raspberry
dosfstools 4.1
cryptsetup v2.1.0
integrity v1.6.0
verity v1.7.0
crypt v1.22.0
fio v3.12

カーネルのビルドや環境のセットアップについては、前回の記事を参照。

leavatail.hatenablog.com

本記事では、SDカードにブートに必要なイメージを格納し、USBメモリに作成したext4ファイルシステムで計測する。

本記事におけるデバイスとその用途の関係図

またUSBメモリ(/dev/sda)はMBRパーティションを作成し、8GB(/dev/sda1)と20GB(/dev/sda2)の二つから構成される。

測定方法

今回は以下の4つのパターンにおける読み書き性能を測定した。 (ただしdm-verityはRead-Onlyであるため、③は読み込み性能のみとする)

測定環境

  1. Device mapperを使用しない場合 (実デバイスドライバファイルシステムを構築する)
  2. dm-integrityのみ使用する場合
  3. dm-verityを使用する場合
  4. dm-integrityとdm-cryprtの両方を使用する場合

各測定パターンの構築方法については付録参照。

また、読み書きの測定計測にはfioを利用し、write, randwrite, read, randreadの4つのエンジンを用いる。

各測定は10回計測し、外れ値を除外したスループットの平均値を性能として評価する。
ここで外れ値は、スループットが平均値から100%以上のずれがあるものや、レイテンシの値が異常値(0や極端に値が振れ幅が大きい)であるものとした。

write

writeとrandwriteについては、4KBのバッファI/Oで計測を実施した。*1

read

writeとrandwriteについては、4KBのバッファI/Oで計測を実施した。

性能計測

write測定結果

上記のグラフは左から、「Device mapperを使用しない場合 」「dm-integrityのみ使用する場合」「dm-verityを使用する場合」「dm-integrityとdm-cryprtの両方を使用する場合」におけるwrite, readwriteのスループットを示している。
dm-verityはRead-Onlyパーティションを対象としているため、write,randwriteのスループットを0と表記している。

normal integrity オーバーヘッド verity crypt+integrity オーバーヘッド
write性能 15.7MB/s 8.9MB/s -43.3% 0 5.9MB/s N/A
randwrite性能 14.5MB/s 6.9MB/s -52.5% 0 4.7MB/s N/A

上記の測定結果より、dm-integrityによる書き込みスループットは50%程度減少することが分かった。
これは、dm-integrity用のDevice Mapper層が追加されたことによる命令数の増加や、ジャーナルの書き込みによりI/O数が増加したことが要因として挙げられる。*2

また、dm-cryptとdm-integrityを組み合わた場合、スループットは65%程度減少することが分かった。
そのため、セキュアブートのためにHMACの利用を考えている方は、これらの性能低下を意識してほしい。

read測定結果

上記のグラフは左から、「Device mapperを使用しない場合 」「dm-integrityのみ使用する場合」「dm-verityを使用する場合」「dm-integrityとdm-cryprtの両方を使用する場合」におけるread, readreadのスループットを示している。

normal integrity オーバーヘッド verity crypt+integrity オーバーヘッド
read性能 88.2MB/s 73.9MB/s -12.2% 39.3MB/s 18.4MB/s -53.2%
randread性能 7.2MB/s 6.0MB/s -17.7% 5.9MB/s 5.0MB/s -15.3%

上記の測定結果より、dm-integrityによる読み込みスループットは15%程度減少することが分かった。
これは、dm-integritによる命令数の増加やI/O数が、LInuxのページング機構によって緩和されたため、著しいスループットの減少が見られなかったと考えられる。

一方で、dm-cryptとdm-integrityを組み合わた場合、dm-verityと比較してスループットが大きく減少する(シーケンシャルリードで50%、ランダムリードで15%)ことが分かった。
特にシーケンシャルリード(read性能)に関しては、著しい減少が見られた。
これは、読み込むべきデータが増加したことにより、ページイン・ページアウトの回数が増加したからではないかと考えられる。*3

おわりに

本記事ではRaspberry Pi 4上でdm-integrityのオーバーヘッドを測定した。

  • dm-integrityを導入したことでwrite性能の著しい減少が観測された
  • 改ざん検知機能の間でシーケンシャルリード性能の著しい減少が観測された

変更履歴

  • 2021/5/9: 記事公開

参考

付録

カーネルコンフィグ

bcm2711_defconfigから更新した箇所は下記のとおりである。

617a618                           
> # CONFIG_CRYPTO_CRCT10DIF_ARM64_CE is not set
735c736,737            
< # CONFIG_BLK_DEV_INTEGRITY is not set
---                             
> CONFIG_BLK_DEV_INTEGRITY=y
> CONFIG_BLK_DEV_INTEGRITY_T10=y
2199c2201,2203
< # CONFIG_DM_VERITY is not set
---
> CONFIG_DM_VERITY=m
> # CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG is not set
> # CONFIG_DM_VERITY_FEC is not set
2202c2206
< # CONFIG_DM_INTEGRITY is not set
---
> CONFIG_DM_INTEGRITY=m
7414c7418
< CONFIG_CRYPTO_GF128MUL=m
---
> CONFIG_CRYPTO_GF128MUL=y
7448,7449c7452,7453
< # CONFIG_CRYPTO_CFB is not set
< CONFIG_CRYPTO_CTR=m
---
> CONFIG_CRYPTO_CFB=y
> CONFIG_CRYPTO_CTR=y
7452,7454c7456,7458
< # CONFIG_CRYPTO_LRW is not set
< # CONFIG_CRYPTO_OFB is not set
< # CONFIG_CRYPTO_PCBC is not set
---
> CONFIG_CRYPTO_LRW=y
> CONFIG_CRYPTO_OFB=y
> CONFIG_CRYPTO_PCBC=y
7476c7480
< # CONFIG_CRYPTO_CRCT10DIF is not set
---
> CONFIG_CRYPTO_CRCT10DIF=y
7482,7485c7486,7489
< # CONFIG_CRYPTO_RMD128 is not set
< # CONFIG_CRYPTO_RMD160 is not set
< # CONFIG_CRYPTO_RMD256 is not set
< # CONFIG_CRYPTO_RMD320 is not set
---
> CONFIG_CRYPTO_RMD128=y
> CONFIG_CRYPTO_RMD160=y
> CONFIG_CRYPTO_RMD256=y
> CONFIG_CRYPTO_RMD320=y
7487c7491
< CONFIG_CRYPTO_SHA256=m
---
> CONFIG_CRYPTO_SHA256=y
7506c7510
< # CONFIG_CRYPTO_CAST6 is not set
---
> CONFIG_CRYPTO_CAST6=m
7610c7614
< # CONFIG_CRC_T10DIF is not set
---
> CONFIG_CRC_T10DIF=y

環境構築

Device mapperを使用しない場合
  1. デバイスドライバにFATファイルシステムを作成する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/sda1 
     mkfs.fat 4.1 (2017-01-24)
    
  2. 作成したファイルシステムを/mntにマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/sda1 /mnt/
    
dm-verityを使用する場合

dm-verityでは、改ざんチェックの対象となるブロックデバイス(データデバイス)とチェックのためのブロックデバイス(ハッシュデバイス)の2つを用意する必要がある。

そこで、測定対象デバイス(USBメモリ) 上でパーティション作成した。
このとき、データデバイス/dev/sda1を16GB、ハッシュデバイス/dev/sda2を残りの領域に割り当てた。

下記の手順にて測定環境を構築した。

  1. USBメモリ上にext4ファイルシステムを作成し、テスト用にデータを埋めておく

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/sda1
     mkfs.fat 4.1 (2017-01-24)
     pi@raspberrypi:~ $ mount /dev/sda1 /mnt
     pi@raspberrypi:~ $ sudo dd if=/dev/urandom of=/mnt/file bs=1M count=4K
    
  2. ハッシュデバイスの構築準備

     pi@raspberrypi:~ $  sudo veritysetup format /dev/sda1 /dev/sda2 
     VERITY header information for /dev/sda2
     UUID:                   68bbac35-32bc-455f-a1f9-ae2a62287847
     Hash type:              1
     Data blocks:            4194304
     Data block size:        4096
     Hash block size:        4096
     Hash algorithm:         sha256
     Salt:                           759b9ed6980255b4469c6732b05519fe09890938b8608ef7f37fa462c9e79a41
     Root hash:              c2ba950245bc4ed2b37de9900f19050467db3c051cd4801a10713ca193a5e1a9
    
  3. device-mapper (/dev/mapper/test)を作成

     pi@raspberrypi:~ $ sudo veritysetup create test /dev/sda1 /dev/sda2 c2ba950245bc4ed2b37de9900f19050467db3c051cd4801a10713ca193a5e1a9
     pi@raspberrypi:~ $ ls -la /dev/mapper/test
     lrwxrwxrwx 1 root root 7 12月 30 04:12 /dev/mapper/test -> ../dm-0
    
  4. 作成したファイルシステムを/mntにマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat -o ro /dev/mapper/dmtest /mnt/
    
dm-integrityのみ使用する場合
  1. integrity target用のMapping tableを作成する

     pi@raspberrypi:~ $ sudo integritysetup format /dev/sda1
    
     Formatted with tag size 4, internal integrity crc32c.
     Wiping device to initialize integrity checksum.
     You can interrupt this by pressing CTRL+c (rest of not wiped device will     contain     invalid checksum).
     Finished, time 18:26.389, 8064 MiB written, speed   7.3 MiB/s   
    
  2. device-mapper (/dev/mapper/test)を作成する

     pi@raspberrypi:~ $ sudo integritysetup open /dev/sda1 test
    
  3. 作成したintegrity targetを確認する

     pi@raspberrypi:~ $ sudo integritysetup status test
     /dev/mapper/test is active.
       type:    INTEGRITY
       tag size: 4
       integrity: crc32c
       device:  /dev/sda1
       sector size:  512 bytes
       interleave sectors: 32768
       size:    16516984 sectors
       mode:    read/write
       failures: 0
       journal size: 67043328 bytes
       journal watermark: 50%
       journal commit time: 10000 ms
    
  4. device mapper tableを表示する (値の意味についてはcryptsetupを参照)

     pi@raspberrypi:~ $ sudo dmsetup table
     test: 0 16516984 integrity 8:1 0 4 J 6 journal_sectors:130944 interleave_sectors:32768 buffer_sectors:128 journal_watermark:50 commit_time:10000 internal_hash:crc32c
    
  5. USBメモリ上にext4ファイルシステムを作成し、テスト用のファイルfileを準備する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/mapper/test
     mkfs.fat 4.1 (2017-01-24)
    
  6. /mnt以下に作成したファイルシステムをマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/mapper/test /mnt/
    
dm-integrityとdm-cryprtの両方を使用する場合
  1. crypt target + integrity target用のMapping tableを作成する

     pi@raspberrypi:~ $ sudo cryptsetup luksFormat --type luks2 /dev/sda1 --cipher aes-xts-plain64 --integrity hmac-sha256
    Enter passphrase for /dev/sda1: 
    Verify passphrase: 
    Wiping device to initialize integrity checksum.
    You can interrupt this by pressing CTRL+c (rest of not wiped device will contain invalid checksum).
    Finished, time 33:38.376, 7634 MiB written, speed   3.8 MiB/s
    
  2. 作成したDevice mapperのインスタンスを有効化する

     pi@raspberrypi:~ $ sudo cryptsetup open /dev/sda test
     Enter passphrase for /dev/sda: 
    
  3. 作成したDevice mapperを確認する

     pi@raspberrypi:~ $ sudo cryptsetup status test
     /dev/mapper/test is active.
       type:    LUKS2
       cipher:  aes-xts-plain64
       keysize: 768 bits
       key location: keyring
       integrity: hmac(sha256)
       integrity keysize: 256 bits
       device:  /dev/sda1
       sector size:  512
       offset:  0 sectors
       size:    15634464 sectors
       mode:    read/write
    
  4. USBメモリ上にFATファイルシステムを作成する

     pi@raspberrypi:~ $ sudo mkfs.fat /dev/mapper/test
     mkfs.fat 4.1 (2017-01-24)
    
  5. /mnt以下に作成したファイルシステムをマウントする

     pi@raspberrypi:~ $ sudo mount -t vfat /dev/mapper/test /mnt/
    

測定スクリプト

#!/bin/bash -eu

MNTPOINT=/mnt/
TARGET=FILE.TXT
LOOP=10

function usage() {
        echo "Usage: $(basename $0) {0 | 1}"
        echo "    0: measure for write, randwrite"
        echo "    1: measure for read,  randread"
}

function do_fio() {
        if [ $# != 2 ]; then
                echo "Internal Error: function needs 2 argument."
                exit 1
        fi

        echo 3 > /proc/sys/vm/drop_caches
        fio --filename=${MNTPOINT}${TARGET} --direct=$2 --rw=$1 --bs=4K --ioengine=libaio --runtime=60 --time_based --name=$2 --size=1G --output-format=json >> result_$1.log
}

##
## MAIN FUNCTION
##
array_0=("write" "randwrite")
array_1=("read" "randread")

if [ $# != 1 ]; then
        usage
        exit 1
fi

if [ $1 = '0' ]; then
        for pattern in ${array_0[@]}
        do
                echo "Start ${pattern}"
                touch result_${pattern}.log
                for i in `seq ${LOOP}`
                do
                        do_fio ${pattern} 0
                        rm ${MNTPOINT}${TARGET}
                done
        done
elif [ $1 = '1' ]; then
        for pattern in ${array_1[@]}
        do
                echo "Start ${pattern}"
                touch result_${pattern}.log
                for i in `seq ${LOOP}`
                do
                        do_fio ${pattern} 1
                done
        done
else
        usage
        exit 1
fi

*1:direct=1で計測したかったが、randwrite性能が極端に低速であったため断念した

*2:より詳細に調査するのであれば、blktraceなどで確認したほうが良い

*3:より詳細に調査するのであれば、topなどでメモリ使用量やページイン・ページアウトを確認したほうが良い

Linuxカーネルのファイルアクセスの処理を追いかける (5) write_begin

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_write_begin関数を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

本記事では、ext2ファイルシステムwrite_iter操作から呼び出されるwrite_begin操作を解説する。

write_beginの概要

前回解説したwrite_iter操作(generic_file_write_iter)は、write_begin操作とwrite_end操作を呼び出すことになっている。

このwrite_begin操作やwrite_end操作はページキャッシュに対する操作となっており、address_space_opearationsのメンバの一つとして定義される。

ここで、address_spaceについて再掲する。

inodeとaddress_spaceの対応関係

address_spaceは、inodeとページキャッシュ(ファイルのデータ)を紐づけるデータ型となっている。
ファイルに紐づいているページキャッシュは、xarray型のi_pagesで管理される。 (バージョン4.20より前はradix treeで管理される)

詳細については、下記の記事に詳しく記載されているのでそちらを参照。

qiita.com

ext2ファイルシステムのaddress_space_operationsは、ext2_aopsext2_nobh_aopsext2_dax_aopsが定義されている。
これらは、マウントオプションnobhdaxが指定された場合に、使用するappress_space_operationsを変更することになっている。

今回は処理が簡単なext2_aopsを用いて考えていく。
ext2_aopsの定義は下記の通り。

// 964:
const struct address_space_operations ext2_aops = {
    .set_page_dirty     = __set_page_dirty_buffers,
    .readpage       = ext2_readpage,
    .readahead      = ext2_readahead,
    .writepage      = ext2_writepage,
    .write_begin        = ext2_write_begin,
    .write_end      = ext2_write_end,
    .bmap           = ext2_bmap,
    .direct_IO      = ext2_direct_IO,
    .writepages     = ext2_writepages,
    .migratepage        = buffer_migrate_page,
    .is_partially_uptodate  = block_is_partially_uptodate,
    .error_remove_page  = generic_error_remove_page,
};

まずは、write_begin操作のext2_write_begin関数から確認する。

write_begin操作は、write_iter操作(で呼び出されるgeneric_perform_write関数)から呼び出される。

// 3770:
        status = a_ops->write_begin(file, mapping, pos, bytes, flags,
                        &page, &fsdata);
変数名
file オープンしたファイル
mapping ファイルが持っているページキャッシュのXArray(radix-tree)
pos 書き込み先の位置
bytes 書き込むバイト数
flags 0
page 取得したページを格納する
fsdata ext2ファイルシステムでは使用しない

ext2ファイルシステムの場合には、write_begin操作でext2_write_begin関数を実行する。

// 884:
static int
ext2_write_begin(struct file *file, struct address_space *mapping,
        loff_t pos, unsigned len, unsigned flags,
        struct page **pagep, void **fsdata)
{
    int ret;

    ret = block_write_begin(mapping, pos, len, flags, pagep,
                ext2_get_block);
    if (ret < 0)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

この関数では、カーネル内で定義されている汎用の関数block_write_begin関数に、ext2ファイルシステムにおけるブロック取得のext2_get_block関数(次回解説)を渡している。

// 2109:
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
        unsigned flags, struct page **pagep, get_block_t *get_block)
{
    pgoff_t index = pos >> PAGE_SHIFT;
    struct page *page;
    int status;

    page = grab_cache_page_write_begin(mapping, index, flags);
    if (!page)
        return -ENOMEM;

    status = __block_write_begin(page, pos, len, get_block);
    if (unlikely(status)) {
        unlock_page(page);
        put_page(page);
        page = NULL;
    }

    *pagep = page;
    return status;
}

block_write_begin関数では、下記を実施する。

  • ページキャッシュを取得する
  • バッファキャッシュ用のデータ構造を作成する

まずは、ページキャッシュを取得するgrab_cache_page_write_begin関数を確認する。

ページキャッシュを取得する

// 3715:
/*
 * Find or create a page at the given pagecache position. Return the locked
 * page. This function is specifically for buffered writes.
 */
struct page *grab_cache_page_write_begin(struct address_space *mapping,
                    pgoff_t index, unsigned flags)
{
    struct page *page;
    int fgp_flags = FGP_LOCK|FGP_WRITE|FGP_CREAT;

    if (flags & AOP_FLAG_NOFS)
        fgp_flags |= FGP_NOFS;

    page = pagecache_get_page(mapping, index, fgp_flags,
            mapping_gfp_mask(mapping));
    if (page)
        wait_for_stable_page(page);

    return page;
}
EXPORT_SYMBOL(grab_cache_page_write_begin);

grab_cache_page_write_begin関数では、下記を実施する。

  • ページキャッシュを取得する
  • ページキャッシュの割り当て中にアクセスされたページをフラッシュする

ただし、後者('wait_for_stable_page`関数)については、本質でない(かつ未調査である)ため説明を省略する。(該当パッチ)

まずは、ページキャッシュを取得するpagecache_get_page関数を確認する。 このとき、引数のfgp_flagsには次のような値が設定されている。

フラグ名 説明
FGP_LOCK 得られたページをロックする
FGP_WRITE ページに書き込みをする
FGP_CREAT ページが見つからない場合はページを作る

また、引数のgfp_maskには次のような値が設定されている。(実機で0x1100ccaであることを確認済み)

パラメータ名 説明
___GFP_HIGHMEM ZONE_HIGHMEMメモリゾーンに属するページフレームを取得する
___GFP_MOVABLE ページ圧縮中にこのページは移動可能であることを示す
___GFP_IO ページフレームを開放するためにI/O転送処置を行ってもよい
___GFP_FS ファイルシステム関連の操作を行っても良い
___GFP_DIRECT_RECLAIM メモリ確保を読んだタスクのコンテキストでメモリ回収する
___GFP_KSWAPD_RECLAIM kswapdカーネルスレッドを起動してメモリ回収できる
___GFP_HARDWALL cpusetのメモリ割り当てポリシーを強制する
___GFP_SKIP_KASAN_POISON KASANがページの割り当て解除時にポイズニングをスキップする

pagecache_get_page関数の定義は下記のとおりである。

// 1888:
struct page *pagecache_get_page(struct address_space *mapping, pgoff_t index,
        int fgp_flags, gfp_t gfp_mask)
{
    struct page *page;

repeat:
    page = mapping_get_entry(mapping, index);
    if (xa_is_value(page)) {
        if (fgp_flags & FGP_ENTRY)
            return page;
        page = NULL;
    }
    if (!page)
        goto no_page;

    if (fgp_flags & FGP_LOCK) {
        if (fgp_flags & FGP_NOWAIT) {
            if (!trylock_page(page)) {
                put_page(page);
                return NULL;
            }
        } else {
            lock_page(page);
        }

        /* Has the page been truncated? */
        if (unlikely(page->mapping != mapping)) {
            unlock_page(page);
            put_page(page);
            goto repeat;
        }
        VM_BUG_ON_PAGE(!thp_contains(page, index), page);
    }

    if (fgp_flags & FGP_ACCESSED)
        mark_page_accessed(page);
    else if (fgp_flags & FGP_WRITE) {
        /* Clear idle flag for buffer write */
        if (page_is_idle(page))
            clear_page_idle(page);
    }
    if (!(fgp_flags & FGP_HEAD))
        page = find_subpage(page, index);

no_page:
    if (!page && (fgp_flags & FGP_CREAT)) {
        int err;
        if ((fgp_flags & FGP_WRITE) && mapping_can_writeback(mapping))
            gfp_mask |= __GFP_WRITE;
        if (fgp_flags & FGP_NOFS)
            gfp_mask &= ~__GFP_FS;

        page = __page_cache_alloc(gfp_mask);
        if (!page)
            return NULL;

        if (WARN_ON_ONCE(!(fgp_flags & (FGP_LOCK | FGP_FOR_MMAP))))
            fgp_flags |= FGP_LOCK;

        /* Init accessed so avoid atomic mark_page_accessed later */
        if (fgp_flags & FGP_ACCESSED)
            __SetPageReferenced(page);

        err = add_to_page_cache_lru(page, mapping, index, gfp_mask);
        if (unlikely(err)) {
            put_page(page);
            page = NULL;
            if (err == -EEXIST)
                goto repeat;
        }

        /*
        * add_to_page_cache_lru locks the page, and for mmap we expect
        * an unlocked page.
        */
        if (page && (fgp_flags & FGP_FOR_MMAP))
            unlock_page(page);
    }

    return page;
}

まずは、mapping_get_entry関数の定義を確認する。

// 1817:
static struct page *mapping_get_entry(struct address_space *mapping,
        pgoff_t index)
{
    XA_STATE(xas, &mapping->i_pages, index);
    struct page *page;

    rcu_read_lock();
repeat:
    xas_reset(&xas);
    page = xas_load(&xas);
    if (xas_retry(&xas, page))
        goto repeat;
    /*
    * A shadow entry of a recently evicted page, or a swap entry from
    * shmem/tmpfs.  Return it without attempting to raise page count.
    */
    if (!page || xa_is_value(page))
        goto out;

    if (!page_cache_get_speculative(page))
        goto repeat;

    /*
    * Has the page moved or been split?
    * This is part of the lockless pagecache protocol. See
    * include/linux/pagemap.h for details.
    */
    if (unlikely(page != xas_reload(&xas))) {
        put_page(page);
        goto repeat;
    }
out:
    rcu_read_unlock();

    return page;
}

mapping_get_entry関数はmappingからページキャッシュを検索する。

ページキャッシュは、Read-copy update (RCU) による排他制御を実現している。
ここで、RCUとはロックを取得しないという特徴があり、ページキャッシュのような書き込みより読み込みのほうが多いデータ構造に適用される。

RCUの仕組みについて下記の図を用いて解説する。

Read-copy-update (RCU) の概略

4コアのシステムで共通の資源(pageが示すAとB)を扱う場合

  • readの場合、CPU1とCPU2はAをreadできる
  • writeの場合、CPU3はBをreadして、CPU4はBの複製B'にwriteする
    • 新しくBを参照する場合、B'に対してread/writeする
    • Bを参照しなくなったら、BへのポイントをB'に更新する

より詳しく知りたい人は下記の記事で解説されているので、そちらを参照。

tkokamo.hateblo.jp

mapping_get_entry関数の先頭と末尾にあるrcu_read_lock関数とrcu_read_unlock関数は、RCUにおけるreadの開始と終わりを表す。
rcu_read_lock関数とrcu_read_unlock関数は、ロックを取るわけではなく、プリエンプトを無効にするだけとなっている。

RCUを宣言したら、XArrayからページキャッシュの取得を試みる。

  1. XA_STATEマクロでXArrayデータ構造の宣言をする
  2. xas_reset関数でXArrayの状態を初期化する
  3. xas_load関数でXA_STATEで宣言したxasを辿りデータを取得することができる。
  4. internal entryを返すことがある(multi-index entryの場合)ため、必要に応じてxas_retry関数で再試行する
  5. xa_is_value関数で取得したデータが値エントリであることを確認する
  6. page_cache_get_speculative関数でページキャッシュのリファレンスカウントを更新する
  7. 取得したページキャッシュが移動されていないかxas_reload関数で確認する

これらを踏まえて、pagecache_get_page関数に戻る。
pagecache_get_page関数は、find_get_entry関数で取得したページキャッシュが有効かどうかで処理が変わる。

ページキャッシュが存在する場合

pagecache_get_page関数は1901行目のgoto文は実行されず、処理を続ける。

// 1893:
repeat:
    page = mapping_get_entry(mapping, index);
    if (xa_is_value(page)) {
        if (fgp_flags & FGP_ENTRY)
            return page;
        page = NULL;
    }
    if (!page)
        goto no_page;

    if (fgp_flags & FGP_LOCK) {
        if (fgp_flags & FGP_NOWAIT) {
            if (!trylock_page(page)) {
                put_page(page);
                return NULL;
            }
        } else {
            lock_page(page);
        }

        /* Has the page been truncated? */
        if (unlikely(page->mapping != mapping)) {
            unlock_page(page);
            put_page(page);
            goto repeat;
        }
        VM_BUG_ON_PAGE(!thp_contains(page, index), page);
    }

    if (fgp_flags & FGP_ACCESSED)
        mark_page_accessed(page);
    else if (fgp_flags & FGP_WRITE) {
        /* Clear idle flag for buffer write */
        if (page_is_idle(page))
            clear_page_idle(page);
    }
    if (!(fgp_flags & FGP_HEAD))
        page = find_subpage(page, index);

ページキャッシュが有効である場合の挙動は以下の通りとなる。

  1. 既にPG_lockedフラグが設定されている場合、ページキャッシュが獲得するまでwaitする。(lock_page関数)
  2. これまでの間にページキャッシュが切り捨てられてしまった場合には、フラグの解除とリファレンスカウントを下げて、再度ページキャッシュの取得を試みる。
  3. ページがidle状態になっていれば、PAGE_EXT_IDLEフラグを落とす。
  4. 取得したページキャッシュを返す*1

状況によって異なるが、参考としてページキャッシュ取得時のフラグを下記に示す。

フラグ名 説明
PG_uptodate ページ読み込みが完了している
PG_lru LRUリスト内にある
PG_private privateメンバを使用している

ページキャッシュが存在していない場合

下記の文献を参照。

kernhack.hatenablog.com

次に、ページキャッシュが有効である場合(xa_is_valuetrue)について考える。
pagecache_get_page関数はno_pageラベルにジャンプする。

// 1932:
no_page:
    if (!page && (fgp_flags & FGP_CREAT)) {
        int err;
        if ((fgp_flags & FGP_WRITE) && mapping_can_writeback(mapping))
            gfp_mask |= __GFP_WRITE;
        if (fgp_flags & FGP_NOFS)
            gfp_mask &= ~__GFP_FS;

        page = __page_cache_alloc(gfp_mask);
        if (!page)
            return NULL;

        if (WARN_ON_ONCE(!(fgp_flags & (FGP_LOCK | FGP_FOR_MMAP))))
            fgp_flags |= FGP_LOCK;

        /* Init accessed so avoid atomic mark_page_accessed later */
        if (fgp_flags & FGP_ACCESSED)
            __SetPageReferenced(page);

        err = add_to_page_cache_lru(page, mapping, index, gfp_mask);
        if (unlikely(err)) {
            put_page(page);
            page = NULL;
            if (err == -EEXIST)
                goto repeat;
        }

        /*
        * add_to_page_cache_lru locks the page, and for mmap we expect
        * an unlocked page.
        */
        if (page && (fgp_flags & FGP_FOR_MMAP))
            unlock_page(page);
    }

    return page;
}

もし、有効なページキャッシュが存在していなかった場合、__page_cache_alloc関数でページを確保する。
__page_cache_alloc関数の定義は下記の通りである。

// 299:
#ifdef CONFIG_NUMA
extern struct page *__page_cache_alloc(gfp_t gfp);
#else
static inline struct page *__page_cache_alloc(gfp_t gfp)
{
    return alloc_pages(gfp, 0);
}
#endif

今回の環境はCONFIG_NUMA=nであるので、__page_cache_alloc関数はalloc_pagesを呼び出す。

// 595:
static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    return alloc_pages_node(numa_node_id(), gfp_mask, order);
}

numa_node_id()はマクロであり、現在使用しているCPU(今回の場合は0)となる。
alloc_pages関数は、alloc_pages_node関数を呼び出す。

// 578:
static inline struct page *alloc_pages_node(int nid, gfp_t gfp_mask,
                        unsigned int order)
{
    if (nid == NUMA_NO_NODE)
        nid = numa_mem_id();

    return __alloc_pages_node(nid, gfp_mask, order);
}
// 564:
static inline struct page *
__alloc_pages_node(int nid, gfp_t gfp_mask, unsigned int order)
{
    VM_BUG_ON(nid < 0 || nid >= MAX_NUMNODES);
    VM_WARN_ON((gfp_mask & __GFP_THISNODE) && !node_online(nid));

    return __alloc_pages(gfp_mask, order, nid, NULL);
}

__alloc_pages_node関数はパラメータのチェックをして、__alloc_pages関数を呼び出す。

__alloc_pages関数については、下記の記事に解説が載っているので、解説は省略する。

kernhack.hatenablog.com

kernhack.hatenablog.com

kernhack.hatenablog.com

__page_cache_alloc関数でページキャッシュを取得した直後の状態

ページキャッシュを取得できたら、add_to_page_cache_lru関数を実行する。
最近参照したデータほど再度参照する可能性が高いということから、LRUリストでも管理している。

add_to_page_cache_lru関数はLRUリストに追加する関数であり、定義は下記のようになっている。

// 977:
int add_to_page_cache_lru(struct page *page, struct address_space *mapping,
                pgoff_t offset, gfp_t gfp_mask)
{
    void *shadow = NULL;
    int ret;

    __SetPageLocked(page);
    ret = __add_to_page_cache_locked(page, mapping, offset,
                     gfp_mask, &shadow);
    if (unlikely(ret))
        __ClearPageLocked(page);
    else {
        /*
        * The page might have been evicted from cache only
        * recently, in which case it should be activated like
        * any other repeatedly accessed page.
        * The exception is pages getting rewritten; evicting other
        * data from the working set, only to cache data that will
        * get overwritten with something else, is a waste of memory.
        */
        WARN_ON_ONCE(PageActive(page));
        if (!(gfp_mask & __GFP_WRITE) && shadow)
            workingset_refault(page, shadow);
        lru_cache_add(page);
    }
    return ret;
}

938行目の__SetPageLockedマクロは引数のpageに対して、PG_lockedフラグを設定する。

フラグ名 説明
PG_locked ページはロック状態

その後、939行目の__add_to_page_cache_locked関数でpageとaddress_space構造体を関連付ける。(概要は省略する)

// 442:
void lru_cache_add(struct page *page)
{
    struct pagevec *pvec;

    VM_BUG_ON_PAGE(PageActive(page) && PageUnevictable(page), page);
    VM_BUG_ON_PAGE(PageLRU(page), page);

    get_page(page);
    local_lock(&lru_pvecs.lock);
    pvec = this_cpu_ptr(&lru_pvecs.lru_add);
    if (pagevec_add_and_need_flush(pvec, page))
        __pagevec_lru_add(pvec);
    local_unlock(&lru_pvecs.lock);
}

lru_cache_add関数では、獲得したページをlruリストにすぐには登録しない。
pagevec_add関数で一旦CPU変数へ登録して、後ほどまとめてlryリストに登録する。

pagevec_add関数が0を返すとき、溜まったページキャッシュを__pagevec_lru_add関数でlruリストに登録する。

バッファキャッシュ用のデータ構造を作成する

Linuxではページキャッシュとは別にバッファキャッシュというデータ構造が存在している。

バッファキャッシュはストレージのデータをメモリにキャッシュするためのものである。
ファイルシステムの観点でいえば、ファイルの実データやinode, ディレクトリなどが対象となっている。 さらに、バッファキャッシュはページキャッシュと紐づけることになっている。

write_beginでは、__block_write_begin関数でバッファキャッシュを準備する。

// 2109:
int block_write_begin(struct address_space *mapping, loff_t pos, unsigned len,
        unsigned flags, struct page **pagep, get_block_t *get_block)
{
    pgoff_t index = pos >> PAGE_SHIFT;
    struct page *page;
    int status;

    page = grab_cache_page_write_begin(mapping, index, flags);
    if (!page)
        return -ENOMEM;

    status = __block_write_begin(page, pos, len, get_block);
    if (unlikely(status)) {
        unlock_page(page);
        put_page(page);
        page = NULL;
    }

    *pagep = page;
    return status;
}

grab_cache_page_write_begin関数によってページキャッシュが取得できたら、__block_write_begin関数でバッファキャッシュ用のデータ構造を作成する。
__block_write_begin関数の定義は下記のとおりである。

// 2057:
int __block_write_begin(struct page *page, loff_t pos, unsigned len,
        get_block_t *get_block)
{
    return __block_write_begin_int(page, pos, len, get_block, NULL);
}

__block_write_begin関数は、仮引数と__block_write_begin_int関数に渡すだけとなっている。
__block_write_begin_int関数の定義は下記の通りである。

// 1972:
int __block_write_begin_int(struct page *page, loff_t pos, unsigned len,
        get_block_t *get_block, const struct iomap *iomap)
{
    unsigned from = pos & (PAGE_SIZE - 1);
    unsigned to = from + len;
    struct inode *inode = page->mapping->host;
    unsigned block_start, block_end;
    sector_t block;
    int err = 0;
    unsigned blocksize, bbits;
    struct buffer_head *bh, *head, *wait[2], **wait_bh=wait;

    BUG_ON(!PageLocked(page));
    BUG_ON(from > PAGE_SIZE);
    BUG_ON(to > PAGE_SIZE);
    BUG_ON(from > to);

    head = create_page_buffers(page, inode, 0);
    blocksize = head->b_size;
    bbits = block_size_bits(blocksize);

    block = (sector_t)page->index << (PAGE_SHIFT - bbits);

    for(bh = head, block_start = 0; bh != head || !block_start;
        block++, block_start=block_end, bh = bh->b_this_page) {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (PageUptodate(page)) {
                if (!buffer_uptodate(bh))
                    set_buffer_uptodate(bh);
            }
            continue;
        }
        if (buffer_new(bh))
            clear_buffer_new(bh);
        if (!buffer_mapped(bh)) {
            WARN_ON(bh->b_size != blocksize);
            if (get_block) {
                err = get_block(inode, block, bh, 1);
                if (err)
                    break;
            } else {
                iomap_to_bh(inode, block, bh, iomap);
            }

            if (buffer_new(bh)) {
                clean_bdev_bh_alias(bh);
                if (PageUptodate(page)) {
                    clear_buffer_new(bh);
                    set_buffer_uptodate(bh);
                    mark_buffer_dirty(bh);
                    continue;
                }
                if (block_end > to || block_start < from)
                    zero_user_segments(page,
                        to, block_end,
                        block_start, from);
                continue;
            }
        }
        if (PageUptodate(page)) {
            if (!buffer_uptodate(bh))
                set_buffer_uptodate(bh);
            continue; 
        }
        if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
            !buffer_unwritten(bh) &&
             (block_start < from || block_end > to)) {
            ll_rw_block(REQ_OP_READ, 0, 1, &bh);
            *wait_bh++=bh;
        }
    }
    /*
    * If we issued read requests - let them complete.
    */
    while(wait_bh > wait) {
        wait_on_buffer(*--wait_bh);
        if (!buffer_uptodate(*wait_bh))
            err = -EIO;
    }
    if (unlikely(err))
        page_zero_new_buffers(page, from, to);
    return err;
}

__block_write_begin_int関数の最初に引数のチェックがある。
下記のどれかを満たしていない場合、BUG_ONマクロでカーネルパニックさせる。

  • ページにロックがかかっていること
  • 開始オフセットがページサイズより小さいこと
  • 終了オフセットがページサイズより小さいこと
  • 終了オフセットが開始オフセットより大きいこと

バッファキャッシュを準備する

引数をチェックし問題がなければ、create_page_buffers関数でbuffer_headを生成する。

// 1672:
static struct buffer_head *create_page_buffers(struct page *page, struct inode *inode, unsigned int b_state)
{
    BUG_ON(!PageLocked(page));

    if (!page_has_buffers(page))
        create_empty_buffers(page, 1 << READ_ONCE(inode->i_blkbits),
                     b_state);
    return page_buffers(page);
}

すでにバッファキャッシュが確保済みである場合は、page_has_buffersマクロがTrueとなりバッファキャッシュを再度確保せずに終了する。
そうでなければ、create_empty_buffers関数でバッファキャッシュの作成を試みる。

// 1555:
void create_empty_buffers(struct page *page,
            unsigned long blocksize, unsigned long b_state)
{
    struct buffer_head *bh, *head, *tail;

    head = alloc_page_buffers(page, blocksize, true);
    bh = head;
    do {
        bh->b_state |= b_state;
        tail = bh;
        bh = bh->b_this_page;
    } while (bh);
    tail->b_this_page = head;

    spin_lock(&page->mapping->private_lock);
    if (PageUptodate(page) || PageDirty(page)) {
        bh = head;
        do {
            if (PageDirty(page))
                set_buffer_dirty(bh);
            if (PageUptodate(page))
                set_buffer_uptodate(bh);
            bh = bh->b_this_page;
        } while (bh != head);
    }
    attach_page_private(page, head);
    spin_unlock(&page->mapping->private_lock);
}

create_empty_buffers関数の処理は大きく分けて下記のとおりである。

  • RAM上にバッファキャッシュを確保する
  • バッファキャッシュ間で循環ループを作成する
  • バッファキャッシュとファイルシステムのデータを同期させる

バッファキャッシュを確保する

バッファキャッシュの確保は、alloc_page_buffers関数で実施する。
ただし、今回は説明を簡略化するためにcgroupの話は省着する。

ブロックサイズが1024, ページサイズが4096の場合、4つのbuffer_headを生成する。

alloc_page_buffers関数によって得られるデータ構造のイメージは下記のとおりである。

バッファキャッシュの確保

alloc_page_buffers関数の定義は下記のとおりである。

// 814:
struct buffer_head *alloc_page_buffers(struct page *page, unsigned long size,
        bool retry)
{
    struct buffer_head *bh, *head;
    gfp_t gfp = GFP_NOFS | __GFP_ACCOUNT;
    long offset;
    struct mem_cgroup *memcg, *old_memcg;

    if (retry)
        gfp |= __GFP_NOFAIL;

    /* The page lock pins the memcg */
    memcg = page_memcg(page);
    old_memcg = set_active_memcg(memcg);

    head = NULL;
    offset = PAGE_SIZE;
    while ((offset -= size) >= 0) {
        bh = alloc_buffer_head(gfp);
        if (!bh)
            goto no_grow;

        bh->b_this_page = head;
        bh->b_blocknr = -1;
        head = bh;

        bh->b_size = size;

        /* Link the buffer to its page */
        set_bh_page(bh, page, offset);
    }
out:
    set_active_memcg(old_memcg);
    return head;
/*
 * In case anything failed, we just free everything we got.
 */
no_grow:
    if (head) {
        do {
            bh = head;
            head = head->b_this_page;
            free_buffer_head(bh);
        } while (head);
    }

    goto out;
}

alloc_page_buffers関数では、831行目のwhile文がメインとなる。

  1. alloc_buffer_head関数でbuffer_headを取得する
  2. 確保したbhをひとつ前のheadに繋げる
  3. set_bh_page関数で、ページキャッシュとバッファを紐づける

alloc_buffer_head関数は、kmem_cache_zalloc関数をデータを確保する。

// 3334:
struct buffer_head *alloc_buffer_head(gfp_t gfp_flags)
{
    struct buffer_head *ret = kmem_cache_zalloc(bh_cachep, gfp_flags);
    if (ret) {
        INIT_LIST_HEAD(&ret->b_assoc_buffers);
        spin_lock_init(&ret->b_uptodate_lock);
        preempt_disable();
        __this_cpu_inc(bh_accounting.nr);
        recalc_bh_state();
        preempt_enable();
    }
    return ret;
}
EXPORT_SYMBOL(alloc_buffer_head);

また、set_bh_page関数は下記の通りとなっている。

// 1442:
void set_bh_page(struct buffer_head *bh,
        struct page *page, unsigned long offset)
{
    bh->b_page = page;
    BUG_ON(offset >= PAGE_SIZE);
    if (PageHighMem(page))
        /*
        * This catches illegal uses and preserves the offset:
        */
        bh->b_data = (char *)(0 + offset);
    else
        bh->b_data = page_address(page) + offset;
}

循環ループを作成する

alloc_page_buffers関数でbuffer_headのリストを生成した後、各データのb_state変数を設定する。

// 1561:
    bh = head;
    do {
        bh->b_state |= b_state;
        tail = bh;
        bh = bh->b_this_page;
    } while (bh);
    tail->b_this_page = head;

その後、末尾のbuffer_headと先頭のbuffer_headをつなげる。

循環リストの更新

バッファキャッシュとファイルシステムを紐付ける

create_page_buffers関数によってバッファキャッシュを作成した後、取得したバッファキャッシュに書き込み対象ファイルのデータを読み込む。

// 1989:
    head = create_page_buffers(page, inode, 0);
    blocksize = head->b_size;
    bbits = block_size_bits(blocksize);

    block = (sector_t)page->index << (PAGE_SHIFT - bbits);

    for(bh = head, block_start = 0; bh != head || !block_start;
        block++, block_start=block_end, bh = bh->b_this_page) {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (PageUptodate(page)) {
                if (!buffer_uptodate(bh))
                    set_buffer_uptodate(bh);
            }
            continue;
        }
        if (buffer_new(bh))
            clear_buffer_new(bh);
        if (!buffer_mapped(bh)) {
            WARN_ON(bh->b_size != blocksize);
            if (get_block) {
                err = get_block(inode, block, bh, 1);
                if (err)
                    break;
            } else {
                iomap_to_bh(inode, block, bh, iomap);
            }

            if (buffer_new(bh)) {
                clean_bdev_bh_alias(bh);
                if (PageUptodate(page)) {
                    clear_buffer_new(bh);
                    set_buffer_uptodate(bh);
                    mark_buffer_dirty(bh);
                    continue;
                }
                if (block_end > to || block_start < from)
                    zero_user_segments(page,
                        to, block_end,
                        block_start, from);
                continue;
            }
        }
        if (PageUptodate(page)) {
            if (!buffer_uptodate(bh))
                set_buffer_uptodate(bh);
            continue; 
        }
        if (!buffer_uptodate(bh) && !buffer_delay(bh) &&
            !buffer_unwritten(bh) &&
             (block_start < from || block_end > to)) {
            ll_rw_block(REQ_OP_READ, 0, 1, &bh);
            *wait_bh++=bh;
        }
    }
    /*
    * If we issued read requests - let them complete.
    */
    while(wait_bh > wait) {
        wait_on_buffer(*--wait_bh);
        if (!buffer_uptodate(*wait_bh))
            err = -EIO;
    }
    if (unlikely(err))
        page_zero_new_buffers(page, from, to);
    return err;
}

1989行の実行直後ではページディスクリプタは下記のようなフラグが立っている。

フラグ名 説明
PG_uptodate ページ読み込みが完了している
PG_lru LRUリスト内にある
PG_private privateメンバを使用している

create_page_buffers関数の以降の処理は下記の通りとなる。

  1. ページキャッシュにPG_uptodate*2が付与されていた場合、バッファキャッシュにもBH_Uptodateを付与し、次のバッファキャッシュを確認する
  2. バッファキャッシュにBH_New*3が付与されていた場合、フラグをクリアする
  3. バッファキャッシュにBH_mapped*4が付与されていない場合、引数のget_blockハンドラを実行してバッファキャッシュにマッピングする
  4. get_blockハンドラでバッファキャッシュを新規にマッピングした場合、ブロックデバイスのバッファをフラッシュする (参考)
  5. get_blockハンドラでバッファキャッシュを新規にマッピングした場合かつページディスクリプタPG_Uptodateである場合、BH_Newフラグを落としBH_Uptodatemark_buffer_dirty関数を実行して次のバッファキャッシュを確認する。
  6. get_blockハンドラでバッファキャッシュを新規にマッピングした場合で、ページキャッシュのゼロ化が必要な場合、ゼロ化し次のバッファキャッシュを確認する
  7. ページディスクリプタPG_Uptodateであるにもかかわらず、バッファキャッシュがBH_Uptodateでない場合、BH_Uptodateフラグを立てて次のバッファキャッシュを確認する。
  8. バッファキャッシュにBH_UptodateBH_Delay*5かつBH_Unwritten*6の何れも立っていない場合、読み込みのためのIOを発行する。

各バッファキャッシュの更新を終えた後、読み込みのためのIOを発行していた場合は、2048行目のwait_on_bufferインライン関数でIOの完了を待つ。

write_begin処理内で失敗した場合

これまでの処理で何かしらも問題が発生した場合、block_write_begin関数の返り値に負の値が返る。

// 883:
static int
ext2_write_begin(struct file *file, struct address_space *mapping,
        loff_t pos, unsigned len, unsigned flags,
        struct page **pagep, void **fsdata)
{
    int ret;

    ret = block_write_begin(mapping, pos, len, flags, pagep,
                ext2_get_block);
    if (ret < 0)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

その場合、ext2_write_failed関数を呼び、ページキャッシュの解放とaddress_spaceのロールバックをする。詳細は割愛。

// 59:
static void ext2_write_failed(struct address_space *mapping, loff_t to)
{
    struct inode *inode = mapping->host;

    if (to > inode->i_size) {
        truncate_pagecache(inode, inode->i_size);
        ext2_truncate_blocks(inode, inode->i_size);
    }
}

おわりに

本記事では、ext2ファイルシステムwrite_begin操作(ext2_write_begin)を解説した。
write_begin操作は、write_iter操作でページキャッシュに書き込むための準備をするための操作である。

変更履歴

  • 2021/4/24: 記事公開
  • 2022/09/19: カーネルバージョンを5.15に変更

参考

*1:find_subpage関数については調査中

*2:バッファのデータが有効であることを示すフラグ

*3:作成後、まだ一度もアクセスされていないことを表すフラグ

*4:バッファキャッシュをディスクにマッピングしていることを示すフラグ

*5:まだ対応付けしていないことを表すフラグ

*6:確保済みだが書き込みされていないことを表すフラグ

Raspberry Pi 4 Model B で fs-verity と dm-verity のオーバーヘッドを計測する

概要

Raspberry Pi 4 Model B で fs-verityとdm-verityの使用方法について確認した。

  • fs-verityはシーケンシャルリードが50%ランダムリード15%の性能低下していることが確認できた。
  • dm-verityはシーケンシャルリードが30%ランダムリード20%の性能低下していることが確認できた。

はじめに

Raspberry Pi 4 Model B (Raspberry Pi 4) は安価に手に入るシングルボードコンピュータとして広く利用されている。

Raspberry Pi は、DebianベースOSのRaspberry Pi OSが搭載されている。
そのため、組込みLinuxの動作確認用のボードとして手軽に使うことができる。

一方で、fs-verityはファイルの改ざんを検出できるLinux Kernel 5.4の新機能である。
残念ながらfs-verityは、Raspberry Pi OSのデフォルトの設定では無効になっている。

そこで、本記事ではRaspberry Piカーネルの自前ビルドしたLinux Kernel 5.10でfs-verityのオーバーヘッドを測定した。

fs-verity とは

fs-verityとは、ファイルシステムと連携して<b<ファイルの実データの改ざんを検知することができるLinux Kernelの機能の一つである。
Linux Kernel 5.10では、ext4ファイルシステムとf2fsファイルシステムで上記がサポートされている。

fs-verityでは、ファイルの実データを一定サイズ (下記の例では4096バイト)毎に区切り、それぞれのハッシュ値を計算する
計算したハッシュ値は、一定サイズになるまで同様にハッシュ値を計算していく。
このように算出されたハッシュツリーをあらかじめファイルの一部として保存し、実データとの比較によりデータの改ざんを検知することができる。

fs-verityによるファイルの改ざんチェック

ただし、fs-verityを有効にしたファイルはRead-Onlyとなるので注意が必要である。

fs-verityの実装は、ファイルシステムreadpages操作をフックするようになっている。 fs-verityを有効にしたreadpages操作では、実データに加えてハッシュツリーもページキャッシュに読み込まれる仕様となっている。 そのため、fs-verityではDirect I/Oがサポートされていない。

fs-verityの使い方については、カーネルドキュメントやfs-verityのユーティリティ(fsverity-utils)のREADMEに記載されている。

git.kernel.org

ただし、上記のやり方のみだと、実データの保証に用いたハッシュツリーの改ざんを防ぐことができない。
そこで、fs-verityでは署名ファイル検証をサポートしている。

署名ファイル検証では、あらかじめ作成した証明書と秘密鍵からシグネチャを生成し、ファイルに付与する。
ファイルの読み込み時にこれらを比較することで、ハッシュツリーが書き換えられていないことを検証することができる

署名付きfs-verity

署名ファイル検証で使用する証明書は、カーネルのkeyring(.fs-verity)を利用して実現する。
fs-verityでは PKCS#7のDER形式のX.509証明書を利用する。

dm-verity とは

fs-verity と似た機能として dm-verity がある。
dm-verityとは、device-mapperと連携してブロックデバイスの改ざんを検知することができるLinux Kernelの機能の一つである。

  • dm-verity: device-mapperの機能を利用して、ブロックデバイスの改ざんを検知できる機能
  • fs-verity: ファイルシステムの機能を利用して、ファイルの改ざんを検知できる機能

dm-verityの概略図

dm-verityの使い方については、カーネルドキュメントやdm-verityのユーティリティ(cryptsetup)のREADMEに記載されている。

gitlab.com

また、dm-verityでも同様にハッシュツリーの署名検証をサポートしている。

ファイルの読み込み時にこれらを比較することで、ハッシュツリーが書き換えられていないことを検証することができる

署名付きfs-verity

署名ファイル検証で使用する証明書は、カーネルのkeyring(.fs-verity)を利用して実現する。
fs-verityでは PKCS#7のDER形式のX.509証明書を利用する。

実行環境

計測対象のRaspberry Pi 4 Model Bの詳細は下記のとおりである。

名前 詳細
Hardware Raspberry Pi 4 Model B Rev 1.2 (4GB)
OS Raspbian GNU/Linux 10 (buster)
Kernel Linux version 5.10.3-v7l+
SDカード HDMCSDH16GCL10UIJP-WOA
USBメモリ SILICON POWER SP032GBUF3B02V1K
IPアドレス (eth0) 172.16.1.3
ユーザ名 pi
ホスト名 raspberry

本記事では、SDカードにブートに必要なイメージを格納し、USBメモリに作成したext4ファイルシステムで計測する。

本記事におけるデバイスとその用途の関係図

カーネルは前回の記事で作成したものを利用する。

leavatail.hatenablog.com

実験

ここまでの作業により、自前ビルドしたカーネルが起動することを確認できる。
そこで、作成したカーネルとルートファイルシステムをSDカードに書き込み、fs-verityを使用してみる。

fsverity-utilsのREADMEに記載されている使用方法に沿ってfs-verityによる改ざんチェックを実験する。

fs-verityの準備

  1. USBメモリ上にext4ファイルシステムを作成し、テスト用のファイルfileを準備する

     pi@raspberrypi:~ $ sudo mkfs.ext4 -O verity /dev/sda1
     pi@raspberrypi:~ $ mount /dev/sda1 /mnt
     pi@raspberrypi:~ $ head -c 1000000 /dev/urandom > /mnt/file
    
  2. OpenSSLを用いて証明書と秘密鍵を生成する

     pi@raspberrypi:~ $ openssl req -newkey rsa:4096 -nodes -keyout key.pem -x509 -out cert.pem
    
  3. 証明書をPEM形式からDER形式に変換する

     pi@raspberrypi:~ $ openssl x509 -in cert.pem -out cert.der -outform der
    
  4. fs-verityのキーリングに証明書を追加する

    pi@raspberrypi:~ $ sudo keyctl padd asymmetric '' %keyring:.fs-verity < cert.der
    
  5. fs-verityのキーリングの修正を禁止する

     pi@raspberrypi:~ $ sudo keyctl restrict_keyring %keyring:.fs-verity
    
  6. fs-verity検証時に署名を要求する

     pi@raspberrypi:~ $ sudo sysctl fs.verity.require_signatures=1
    
  7. ハッシュ値を確認する

     pi@raspberrypi:~ $ sha256sum /mnt/file                                                                                                                                                                                                                                                                                                                                                                                       
     1bfbef29d8891d710007696a02d0ad56297ca46a94eda673c520f4e1fd4daf6d  /mnt/file
    
  8. 対象のファイルに署名する

     pi@raspberrypi:~ $ fsverity sign /mnt/file file.sig --key=key.pem --cert=cert.pem
     Signed file '/mnt/file' (sha256:fe35b9281d3711296bcd49f65a6b0c4c26a33b0e2967c40c365ac94d0a4186af)
    
  9. fs-verityを有効化する

     pi@raspberrypi:~ $ sudo fsverity enable /mnt/file --signature=file.sig
     pi@raspberrypi:~ $ ls -l /mnt/file
     -rw-r--r-- 1 pi pi    1000000 12月 29 13:49 file
    

dm-verityの準備

dm-verityでは、改ざんチェックの対象となるブロックデバイス(データデバイス)とチェックのためのブロックデバイス(ハッシュデバイス)の2つを用意する必要がある。

そこで、測定対象デバイス(USBメモリ) 上でパーティション作成した。
このとき、データデバイス/dev/sda1を10GB、ハッシュデバイス/dev/sda2を残りの領域に割り当てた。

下記の手順にて測定環境を構築した。

  1. USBメモリ上にext4ファイルシステムを作成し、テスト用にデータを埋めておく

     pi@raspberrypi:~ $ sudo mkfs.ext4 /dev/sda1
     pi@raspberrypi:~ $ mount /dev/sda1 /mnt
     pi@raspberrypi:~ $ sudo dd if=/dev/urandom of=/mnt/file bs=1M count=1K
    
  2. ハッシュデバイスの構築準備

     pi@raspberrypi:~ $ sudo veritysetup format /dev/sda1 /dev/sda2 
     VERITY header information for /dev/sda2
     UUID:                   551c1f12-7d4d-4cf6-94a5-c03f6e66271f
     Hash type:              1
     Data blocks:            2621440
     Data block size:        4096
     Hash block size:        4096
     Hash algorithm:         sha256
     Salt:                   010b3c6dbfe22ebf306c1eedaa96de0a05cde16f273a8f5212a3fadd6c57ff43
     Root hash:              06dbc0a80648219b5ae5a9ab229ecea616541c9a9db548bfa9282b076bdf6598
    
  3. device-mapper (/dev/mapper/test)を作成

     pi@raspberrypi:~ $ sudo veritysetup create test /dev/sda1 /dev/sda2 06dbc0a80648219b5ae5a9ab229ecea616541c9a9db548bfa9282b076bdf6598
     Signed file '/mnt/file' (sha256:fe35b9281d3711296bcd49f65a6b0c4c26a33b0e2967c40c365ac94d0a4186af)
     pi@raspberrypi:~ $ ls -la /dev/mapper/test
     lrwxrwxrwx 1 root root 7 12月 30 04:12 /dev/mapper/test -> ../dm-0
    

性能計測

ここでは、fs-verityを有効化していない場合と有効化した場合でread性能にどれだけ差がでるかを確認する。

ここで、測定時の環境を再掲する。

名前 詳細
Hardware Raspberry Pi 4 Model B Rev 1.2 (4GB)
OS Raspbian GNU/Linux 10 (buster)
Kernel Linux version 5.10.3-v7l+
OSインストール先 SDカード
測定対象デバイス USBメモリ

fioで使用したパラメータは下記のとおりである。

  • direct I/Oは使用しない
  • シーケンシャルリードとランダムリードの2つを計測する
  • ブロックサイズは4KB
  • 非同期IO
  • ファイルサイズ1MBと10GBの2つを計測する

毎回の計測時に下記のような作業を1回実施し、その結果を測定値とした。

  1. 測定対象デバイスext4ファイルシステムを作成する
  2. 1MBまたは10GBのファイルを作成する
  3. fioでread性能を計測する
  4. fioでrandread性能を計測する
  5. fs-verityを有効化する
  6. fioでread性能を計測する
  7. fioでrandread性能を計測する

また、fioの計測前に# echo 3 > /proc/sys/vm/drop_cachesを実行してキャッシュを落としておく。

fs-verityの計測

まずは、1MBのファイルに対して計測してみた。

計測に使用したコマンドを下記に示す。

    pi@raspberrypi:~/fsverity $ sudo fio --filename=/mnt/file --direct=0 --rw=read --bs=4K --ioengine=libaio --runtime=300 --time_based --name=read1 --readonly --size=1M 
    pi@raspberrypi:~/fsverity $ sudo fio --filename=/mnt/file --direct=0 --rw=randread --bs=4K --ioengine=libaio --runtime=300 --time_based --name=read1 --readonly --size=1M              

ここでは、fioコマンドの結果によって得られたread/randread性能のみ抽出する。 結果は下記の通りである。

fs-verityなし fs-verityあり 性能変化率
read性能 82.2MiB/s 43.9MiB/s -47%
randread性能 6906KiB/s 5805KiB/s -16%

幣著が実験した環境では、シーケンシャルリードの性能が47%程度、ランダムリードが16%程度落ちていることが分かった。

また、10GBのファイルの読み込み性能も同様に計測してみた。

計測に使用したコマンドを下記に示す。

    pi@raspberrypi:~/fsverity $ sudo fio --filename=/mnt/file --direct=0 --rw=read --bs=4K --ioengine=libaio --runtime=300 --time_based --name=read1 --readonly --size=10G 
    pi@raspberrypi:~/fsverity $ sudo fio --filename=/mnt/file --direct=0 --rw=randread --bs=4K --ioengine=libaio --runtime=300 --time_based --name=read1 --readonly --size=10G              

ここでは、fioコマンドの結果によって得られたread/randread性能のみ抽出する。 その結果が下記の通りとなった。

fs-verityなし fs-verityあり 性能変化率
read性能 70.5MiB/s 27.9MiB/s -60%
randread性能 3792KiB/s 3340KiB/s -12%

幣著が実験した環境では、シーケンシャルリードの性能が60%程度、ランダムリードが12%程度落ちていることが分かった。

dm-verityの計測

これらを利用して、fs-verityと同様の環境で、dm-verityの性能測定をしてみた。

  • direct I/Oは使用しない
  • シーケンシャルリードとランダムリードの2つを計測する
  • ブロックサイズは4KB
  • 非同期IO
  • 測定対象デバイスファイルは/dev/sda1(dm-verityなし)と/dev/mapper/test (dm-verityあり)の2つを計測する
  • 測定対象デバイスファイルに構築されたext4ファイルシステム上の1MBファイルを計測する
dm-verityなし dm-verityあり 性能変化率
read性能 64.9MiB/s 43.8MiB/s -33%
randread性能 6582KiB/s 5420KiB/s -18%

幣著が実験した環境では、シーケンシャルリードの性能が33%程度、ランダムリードが18%程度落ちていることが分かった。

おわりに

本記事ではRaspberry PiカーネルLinux Kernel 5.10を自前でビルドして、fs-verityを有効化した。
またfioを用いて、fs-verityの有無によるread性能を計測することで、fs-verityのオーバーヘッドを測定した。

その結果、fs-verityを有効化したことでシーケンシャルリードが50%、ランダムリードが15%ほど性能低下していることが確認できた。
シーケンシャルリードの性能が大きく変化していることに関しては調査中であるが、readaheadとの相性が悪いのではないかと考えられる。

付録: fs-verity有効化でファイルを更新してみる

  • ファイルシステムから該当ファイルの実データを更新する: 実データの更新不可

     pi@raspberrypi:~ $ pi@raspberrypi:/mnt $ echo "FIX" >> file
     -bash: file: 許可されていない操作です
    
  • ファイルシステムから該当ファイルのメタデータを更新する: メタデータの更新可能

     pi@raspberrypi:~ $ sudo chmod 777 /mnt/file 
     pi@raspberrypi:~ $ ls -l /mnt/file
     -rwxrwxrwx 1 pi pi 1048576 12月 30 02:31 /mnt/file
    
  • ブロックデバイスファイルから該当データを更新する: 読み込み時にエラー

     pi@raspberrypi:~ $ umount /mnt
     # fileの実データが格納されているオフセットを直接書き換え
     pi@raspberrypi:~ $ sudo dd if=/dev/zero of=/dev/sda1 seek=268435456 bs=1 count=2
     pi@raspberrypi:~ $ sudo dd if=/dev/zero of=/dev/sda1 seek=270532608 bs=1 count=2
     pi@raspberrypi:~ $ mount /dev/sda1 /mnt
     pi@raspberrypi:~ $ cat /mnt/file
     cat: /mnt/file: 入力/出力エラーです
    

付録: dm-verityで署名機能を利用してみる

dm-verityの構築手順は下記を参考に、ルートハッシュの署名機能を有効にしてみた。

kernsec.org

  1. 署名用の鍵と証明書を作成する

     leava@server:~/linux $openssl req -x509 -newkey rsa:1024 -keyout ca_key.pem -out ca.pem -nodes -days 365 -set_serial 01 -subj /CN=example.com
    
  2. コンフィグを修正する (CONFIG_SYSTEM_TRUSTED_KEYSのパスを指定)

     leava@server:~/linux $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig  
    
     -*- Cryptographic API  --->
        Certificates for signature checking  --->
          (ca.pem) Additional X.509 keys for default system keyring
    
  3. カーネルのビルド

     leava@server:~/linux $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc) zImage modules dtbs
    
  4. 作成した署名用の鍵をRaspberry Pi 4Bにコピーしておく

     leava@server:~/linux $ scp ca.pem pi@raspberry:~/
     leava@server:~/linux $ scp ca_key.pem pi@raspberry:~/
    
  5. 生成したカーネルイメージをSDカードに焼き、Raspberry Pi 4Bを起動する。

  6. 署名なしでルートハッシュの生成する

     pi@raspberrypi:~ $ veritysetup format /dev/sda1 /dev/sda2
    
  7. メールアーカイブに記載されているスクリプト(下記)を実行する(一部修正済み)

#!/bin/bash

NAME=test
DEV=/dev/sda1
DEV_HASH=/dev/sda2
ROOT_HASH=778fccab393842688c9af89cfd0c5cde69377cbe21ed439109ec856f2aa8a423    ★ここは修正する
SIGN=sign.txt
SIGN_NAME=verity:$NAME

# get unsigned device-mapper table using unpatched veritysetup
veritysetup open $DEV $NAME $DEV_HASH $ROOT_HASH
TABLE=$(dmsetup table $NAME)
veritysetup close $NAME

# sign root hash directly by CA cert
echo -n $ROOT_HASH | openssl smime -sign -nocerts -noattr -binary -inkey ca_key.pem -signer ca.pem -outform der -out $SIGN

# load signature to keyring
keyctl padd user $SIGN_NAME @u <$SIGN

# add device-mapper table, now with sighed root hash optional argument
dmsetup create -r $NAME --table "$TABLE 2 root_hash_sig_key_desc $SIGN_NAME"
dmsetup table $NAME

# cleanup
# dmsetup remove $NAME
# keyctl clear @u

変更履歴

  • 2021/3/1: 記事公開

参考

Linuxカーネルのファイルアクセスの処理を追いかける (4) write_iter

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、ext2_file_write_iter関数 (generic_file_write_iter関数)を確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

本記事では、ext2ファイルシステムwrite操作を解説する。
処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象 (シーケンス図)

ただし、write_begin操作とwrite_end操作の解説は次回にまわす。

write_iterの概要

ファイルシステムで定義しているoperationsの種類によって、ファイルアクセスの挙動が異なる。
本調査では、ext2ファイルシステムを対象としているので、ext2file_operationsを確認する。

// 182:
const struct file_operations ext2_file_operations = {
    .llseek     = generic_file_llseek,
    .read_iter  = ext2_file_read_iter,
    .write_iter = ext2_file_write_iter,
    .unlocked_ioctl = ext2_ioctl,
#ifdef CONFIG_COMPAT
    .compat_ioctl   = ext2_compat_ioctl,
#endif
    .mmap       = ext2_file_mmap,
    .open       = dquot_file_open,
    .release    = ext2_release_file,
    .fsync      = ext2_fsync,
    .get_unmapped_area = thp_get_unmapped_area,
    .splice_read    = generic_file_splice_read,
    .splice_write   = iter_file_splice_write,
};

ext2のfile_operationsは、ext2_file_operations構造体として定義されている。
ext2_file_operations構造体では、write_iterがサポートしている。

VFSレイヤからwrite_iter操作を呼び出し時に渡す引数を下記に再掲する。

再掲: write_iter処理における各データ構造の関係性

ext2のwrite_iter処理でもあるext2_file_write_iter関数は上記のkiocbiov_iterを引数にとる。

// 173:
static ssize_t ext2_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
#ifdef CONFIG_FS_DAX
    if (IS_DAX(iocb->ki_filp->f_mapping->host))
        return ext2_dax_write_iter(iocb, from);
#endif
    return generic_file_write_iter(iocb, from);
}

ext2ファイルシステムでは、Filesystem DAXに対応している。
Filesystem DAXや不揮発メモリ (NVDIMM)については、下記の資料の説明が分かりやすい。

www.slideshare.net

本記事では、Filesystem DAXについては調査対象外とする。
ext2_file_write_iter関数は、generic_file_write_iter関数を呼び出す。

// 3920:
ssize_t generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    ssize_t ret;

    inode_lock(inode);
    ret = generic_write_checks(iocb, from);
    if (ret > 0)
        ret = __generic_file_write_iter(iocb, from);
    inode_unlock(inode);

    if (ret > 0)
        ret = generic_write_sync(iocb, ret);
    return ret;
}

大半のファイルシステムが、ユーザアドレス空間のページキャッシュをカーネル空間にコピーするページキャッシュにDirtyフラグを立てるという決められた操作をする。
generic_file_write_iter関数は、そのようなファイルシステムに対して汎用的に使えるようにLinuxカーネルでそういった処理を定義している。

ここで、generic_file_write_iter関数の流れを確認する。 大まかな流れを下記に記す。

  1. inodeに対応するセマフォ(i_rwsem)を取得する
  2. 書き込み前の正当性を確認する
  3. write_iterのメイン操作を実施する
  4. inodeに対応するセマフォ(i_rwsem)を解放する
  5. O_DSYNCフラグ用の処理を実施する

write_iter操作では、タイムスタンプの更新などによってinode構造体を更新する可能性がある。

このタイミングで別プロセスから読み書きが入るとファイルの整合性が取れなくなってしまう。
そのため、書き込み前の正当性を確認するrite_iter操作を実施するinode_lock関数とinode_unlock関数を挿入している。

// 784:
static inline void inode_lock(struct inode *inode)
{
    down_write(&inode->i_rwsem);
}

static inline void inode_unlock(struct inode *inode)
{
    up_write(&inode->i_rwsem);
}

書き込み前の正当性チェック

generic_file_write_iter関数では、まず書き込み前の正当性を確認しなければならない。 generic_write_checks関数は、書き込み前の正当性を確認する。

// 1631:
ssize_t generic_write_checks(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    struct inode *inode = file->f_mapping->host;
    loff_t count;
    int ret;

    if (IS_SWAPFILE(inode))
        return -ETXTBSY;

    if (!iov_iter_count(from))
        return 0;

    /* FIXME: this is for backwards compatibility with 2.4 */
    if (iocb->ki_flags & IOCB_APPEND)
        iocb->ki_pos = i_size_read(inode);

    if ((iocb->ki_flags & IOCB_NOWAIT) && !(iocb->ki_flags & IOCB_DIRECT))
        return -EINVAL;

    count = iov_iter_count(from);
    ret = generic_write_check_limits(file, iocb->ki_pos, &count);
    if (ret)
        return ret;

    iov_iter_truncate(from, count);
    return iov_iter_count(from);
}

下記は、generic_write_checks関数から変数のチェック部分を抜き出したチャートとなっている。

書き込み前のチェック

  1. 書き込み対象がスワップファイルであるか確認する。スワップファイルである場合、positionという概念がないため、汎用的なwrite_iterを実行することができない。その場合、generic_write_checks関数は-ETXTBSYを返す。
  2. 書き込みサイズ count0であるかどうか確認する。書き込みサイズが0の場合、以降の処理を実行する必要がないため0を返す。
  3. RWF_NOWAIT かつ Direct IOではないか確認する。 ノンブロッキングIOのサポートはDirectIOである場合にのみサポートしているため、どちらかが成立することはない。その場合、generic_write_checks関数は-EINVALを返す。
  4. setrlimitシステムコールにより資源の制限がされている、かつその値がファイルの書き始めを下回るか確認する。Linuxでは、プロセスの資源利用を制限することができる。設定されている場合、開始位置がその値を超えている場合-EFBIGを返す。
  5. ファイルシステムで定義されているファイルサイズの上限値を超えていないか確認する。書き込み開始位置がファイルシステムの上限inode->i_sb->s_maxbytesを超えている場合、書き込みを続けることができる。その場合、generic_write_checks関数は-EFBIGを返す。

これらのチェックにより、書き込み前の正当性が確認できた場合は、書き込みサイズcountを返す。
呼び出し元のgeneric_file_write_iter関数は、この返り値が0より大きい場合にwrite処理を続行する。

また、generic_write_checks関数では、O_APPENDフラグが立っているときに変数ki_posを更新する。 (コメントを読む限り、この処理は version 2.4の後方互換性のためだけの処理らしい)

O_APPENDフラグが立っているときに呼び出すi_size_read関数の定義は以下のようになっている。

// 875:
static inline loff_t i_size_read(const struct inode *inode)
{
#if BITS_PER_LONG==32 && defined(CONFIG_SMP)
    loff_t i_size;
    unsigned int seq;

    do {
        seq = read_seqcount_begin(&inode->i_size_seqcount);
        i_size = inode->i_size;
    } while (read_seqcount_retry(&inode->i_size_seqcount, seq));
    return i_size;
#elif BITS_PER_LONG==32 && defined(CONFIG_PREEMPTION)
    loff_t i_size;

    preempt_disable();
    i_size = inode->i_size;
    preempt_enable();
    return i_size;
#else
    return inode->i_size;
#endif
}

i_size_read関数は、ファイル長を返す関数となっている。
ただし、32bitアーキテクチャの場合には処理が複雑になっている。

これは、読み込み対象のi_sizelong long型であるために、一つの命令で値をすべてコピーできないことが要因となっている。

  • Symmetric Multiprocessing (SMP) の場合: 同時に他CPUにより更新される恐れがある
    • 解決方法: i_size_seqcountによるカウンタを利用する
  • プリエンプション有効 の場合: ファイル長を一部読み込み後にプリエンプションにより別タスクが走る恐れがある
    • 解決方法: その区間のみプリエンプションを無効にする

generic_write_checks関数を実行し、書き込み先のチェックで問題がなかった場合、__generic_file_write_iter関数を呼び出す。 この関数の定義は以下のようになっている。

// 3832:
ssize_t __generic_file_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    struct address_space *mapping = file->f_mapping;
    struct inode   *inode = mapping->host;
    ssize_t        written = 0;
    ssize_t        err;
    ssize_t        status;

    /* We can write back this queue in page reclaim */
    current->backing_dev_info = inode_to_bdi(inode);
    err = file_remove_privs(file);
    if (err)
        goto out;

    err = file_update_time(file);
    if (err)
        goto out;

    if (iocb->ki_flags & IOCB_DIRECT) {
        loff_t pos, endbyte;

        written = generic_file_direct_write(iocb, from);
        /*
        * If the write stopped short of completing, fall back to
        * buffered writes.  Some filesystems do this for writes to
        * holes, for example.  For DAX files, a buffered write will
        * not succeed (even if it did, DAX does not handle dirty
        * page-cache pages correctly).
        */
        if (written < 0 || !iov_iter_count(from) || IS_DAX(inode))
            goto out;

        status = generic_perform_write(file, from, pos = iocb->ki_pos);
        /*
        * If generic_perform_write() returned a synchronous error
        * then we want to return the number of bytes which were
        * direct-written, or the error code if that was zero.  Note
        * that this differs from normal direct-io semantics, which
        * will return -EFOO even if some bytes were written.
        */
        if (unlikely(status < 0)) {
            err = status;
            goto out;
        }
        /*
        * We need to ensure that the page cache pages are written to
        * disk and invalidated to preserve the expected O_DIRECT
        * semantics.
        */
        endbyte = pos + status - 1;
        err = filemap_write_and_wait_range(mapping, pos, endbyte);
        if (err == 0) {
            iocb->ki_pos = endbyte + 1;
            written += status;
            invalidate_mapping_pages(mapping,
                         pos >> PAGE_SHIFT,
                         endbyte >> PAGE_SHIFT);
        } else {
            /*
            * We don't know how much we wrote, so just return
            * the number of bytes which were direct-written
            */
        }
    } else {
        written = generic_perform_write(file, from, iocb->ki_pos);
        if (likely(written > 0))
            iocb->ki_pos += written;
    }
out:
    current->backing_dev_info = NULL;
    return written ? written : err;
}

処理が多くて大変そうに見えるが、この関数にはDirect IOの処理も記載されている。
今回の調査処理 (Buffered IO)の場合は、以下の処理となる。

  1. writebackキューに、書き込み先のブロックデバイスを追加する
  2. 特殊アクセス権 (SUID) を削除する
  3. mtimeのctimeを更新する
  4. ファイルの実データを書きこみ
  5. ファイルのオフセットを更新する

それぞれの処理について解説する。

writebackキューに登録する

writebackキューへの登録は、現在のプロセスを表す変数currentbacking_dev_infoを更新することで達成できる。
ここで、inode_to_bdi関数によって書き込み先のデバイス(今回の場合は/dev/mmcblk0)を取得できる。

これによって、ファイルのデータを/dev/mmcblk0へバックグラウンドで書き出すことができる。

// 136:
static inline struct backing_dev_info *inode_to_bdi(struct inode *inode)
{
    struct super_block *sb;

    if (!inode)
        return &noop_backing_dev_info;

    sb = inode->i_sb;
#ifdef CONFIG_BLOCK
    if (sb_is_blkdev_sb(sb))
        return I_BDEV(inode)->bd_disk->bdi;
#endif
    return sb->s_bdi;
}

本環境においては、I_BDEV(inode)->bd_bdi;を返す。

特殊アクセス権を削除する

特殊アクセス権の削除は、file_remove_privs関数で実現することができる。
ただし、本記事では以降の処理は追いかけない。

// 1936:
int file_remove_privs(struct file *file)
{
    struct dentry *dentry = file_dentry(file);
    struct inode *inode = file_inode(file);
    int kill;
    int error = 0;

    /*
    * Fast path for nothing security related.
    * As well for non-regular files, e.g. blkdev inodes.
    * For example, blkdev_write_iter() might get here
    * trying to remove privs which it is not allowed to.
    */
    if (IS_NOSEC(inode) || !S_ISREG(inode->i_mode))
        return 0;

    kill = dentry_needs_remove_privs(dentry);
    if (kill < 0)
        return kill;
    if (kill)
        error = __remove_privs(file_mnt_user_ns(file), dentry, kill);
    if (!error)
        inode_has_no_xattr(inode);

    return error;
}

ファイルのタイムスタンプを更新する

file_update_time関数によって、ファイルの書き込みによってタイムスタンプ(ctimeとmtime)の更新する。 関数の定義を下記に示す。

// 1977:
int file_update_time(struct file *file)
{
    struct inode *inode = file_inode(file);
    struct timespec64 now;
    int sync_it = 0;
    int ret;

    /* First try to exhaust all avenues to not sync */
    if (IS_NOCMTIME(inode))
        return 0;

    now = current_time(inode);
    if (!timespec64_equal(&inode->i_mtime, &now))
        sync_it = S_MTIME;

    if (!timespec64_equal(&inode->i_ctime, &now))
        sync_it |= S_CTIME;

    if (IS_I_VERSION(inode) && inode_iversion_need_inc(inode))
        sync_it |= S_VERSION;

    if (!sync_it)
        return 0;

    /* Finally allowed to write? Takes lock. */
    if (__mnt_want_write_file(file))
        return 0;

    ret = update_time(inode, &now, sync_it);
    __mnt_drop_write_file(file);

    return ret;
}

  

  1. S_NOCMTIMEフラグを指定した場合、タイムスタンプを更新しない。
  2. ファイルのmtimeが現在時刻より古い場合、update_time関数に渡すパラメータを更新する。
  3. ファイルのctimeが現在時刻より古い場合、update_time関数に渡すパラメータを更新する。
  4. ファイルシステムi_versionをサポートしているかどうか、またファイルのi_versionをインクリメントする必要がある場合、update_time関数に渡すパラメータを更新する。
  5. ファイルシステムが書き込み可能な状態であるかどうかチェックする。書き込み可能な状態である場合、使用カウントを増やす。
  6. update_time関数のメイン処理を実行する。
  7. 書き込み可能な状態である場合、使用カウントを減らす。

今回のwriteの処理では、ctimeとmtimeが現在時刻より古いため、S_CTIMES_MTIMEが付与される。

update_time関数のメイン処理を実行する。
関数の定義を下記に示す。

// 1785:
static int update_time(struct inode *inode, struct timespec64 *time, int flags)
{
    if (inode->i_op->update_time)
        return inode->i_op->update_time(inode, time, flags);
    return generic_update_time(inode, time, flags);
}

update_time関数では、ファイルシステムの独自のupdate_timeがサポートされているかどうかを実行する。

// 199:
const struct inode_operations ext2_file_inode_operations = {
    .listxattr  = ext2_listxattr,
    .getattr    = ext2_getattr,
    .setattr    = ext2_setattr,
    .get_acl    = ext2_get_acl,
    .set_acl    = ext2_set_acl,
    .fiemap     = ext2_fiemap,
    .fileattr_get   = ext2_fileattr_get,
    .fileattr_set   = ext2_fileattr_set,
};

しかし、ext2ファイルシステムでは、独自のupdate_time操作はサポートしていない。
そのため、汎用的なgeneric_update_time関数を実行する。

// 1755:
int generic_update_time(struct inode *inode, struct timespec64 *time, int flags)
{
    int dirty_flags = 0;

    if (flags & (S_ATIME | S_CTIME | S_MTIME)) {
        if (flags & S_ATIME)
            inode->i_atime = *time;
        if (flags & S_CTIME)
            inode->i_ctime = *time;
        if (flags & S_MTIME)
            inode->i_mtime = *time;

        if (inode->i_sb->s_flags & SB_LAZYTIME)
            dirty_flags |= I_DIRTY_TIME;
        else
            dirty_flags |= I_DIRTY_SYNC;
    }

    if ((flags & S_VERSION) && inode_maybe_inc_iversion(inode, false))
        dirty_flags |= I_DIRTY_SYNC;

    __mark_inode_dirty(inode, dirty_flags);
    return 0;
}

generic_update_time関数では、パラメータに応じてinodeオブジェクトを更新する。
ここでは、inode->i_ctimeinode->i_mtimeを更新し、__mark_inode_dirty関数でinodeオブジェクトにDirtyフラグを立てる。

この__mark_inode_dirty関数は、inodeオブジェクトにDirtyフラグ付ける汎用的な関数であり、さまざまな状態でこの関数が呼ばれる。
update_timeからこの関数の呼び出し時には、下記の状態となっている。

  • flagsI_DIRTY_SYNCが設定されている
  • inode->i_state0が設定されている

これらに着目して、__mark_inode_dirty関数のフローに注目する。

// 2381:
void __mark_inode_dirty(struct inode *inode, int flags)
{
    struct super_block *sb = inode->i_sb;
    int dirtytime = 0;

    trace_writeback_mark_inode_dirty(inode, flags);

    if (flags & I_DIRTY_INODE) {
        /*
        * Notify the filesystem about the inode being dirtied, so that
        * (if needed) it can update on-disk fields and journal the
        * inode.  This is only needed when the inode itself is being
        * dirtied now.  I.e. it's only needed for I_DIRTY_INODE, not
        * for just I_DIRTY_PAGES or I_DIRTY_TIME.
        */
        trace_writeback_dirty_inode_start(inode, flags);
        if (sb->s_op->dirty_inode)
            sb->s_op->dirty_inode(inode, flags & I_DIRTY_INODE);
        trace_writeback_dirty_inode(inode, flags);

        /* I_DIRTY_INODE supersedes I_DIRTY_TIME. */
        flags &= ~I_DIRTY_TIME;
    } else {
        /*
        * Else it's either I_DIRTY_PAGES, I_DIRTY_TIME, or nothing.
        * (We don't support setting both I_DIRTY_PAGES and I_DIRTY_TIME
        * in one call to __mark_inode_dirty().)
        */
        dirtytime = flags & I_DIRTY_TIME;
        WARN_ON_ONCE(dirtytime && flags != I_DIRTY_TIME);
    }

    /*
    * Paired with smp_mb() in __writeback_single_inode() for the
    * following lockless i_state test.  See there for details.
    */
    smp_mb();

    if (((inode->i_state & flags) == flags) ||
        (dirtytime && (inode->i_state & I_DIRTY_INODE)))
        return;

    spin_lock(&inode->i_lock);
    if (dirtytime && (inode->i_state & I_DIRTY_INODE))
        goto out_unlock_inode;
    if ((inode->i_state & flags) != flags) {
        const int was_dirty = inode->i_state & I_DIRTY;

        inode_attach_wb(inode, NULL);

        /* I_DIRTY_INODE supersedes I_DIRTY_TIME. */
        if (flags & I_DIRTY_INODE)
            inode->i_state &= ~I_DIRTY_TIME;
        inode->i_state |= flags;

        /*
        * If the inode is queued for writeback by flush worker, just
        * update its dirty state. Once the flush worker is done with
        * the inode it will place it on the appropriate superblock
        * list, based upon its state.
        */
        if (inode->i_state & I_SYNC_QUEUED)
            goto out_unlock_inode;

        /*
        * Only add valid (hashed) inodes to the superblock's
        * dirty list.  Add blockdev inodes as well.
        */
        if (!S_ISBLK(inode->i_mode)) {
            if (inode_unhashed(inode))
                goto out_unlock_inode;
        }
        if (inode->i_state & I_FREEING)
            goto out_unlock_inode;

        /*
        * If the inode was already on b_dirty/b_io/b_more_io, don't
        * reposition it (that would break b_dirty time-ordering).
        */
        if (!was_dirty) {
            struct bdi_writeback *wb;
            struct list_head *dirty_list;
            bool wakeup_bdi = false;

            wb = locked_inode_to_wb_and_lock_list(inode);

            inode->dirtied_when = jiffies;
            if (dirtytime)
                inode->dirtied_time_when = jiffies;

            if (inode->i_state & I_DIRTY)
                dirty_list = &wb->b_dirty;
            else
                dirty_list = &wb->b_dirty_time;

            wakeup_bdi = inode_io_list_move_locked(inode, wb,
                                   dirty_list);

            spin_unlock(&wb->list_lock);
            trace_writeback_dirty_inode_enqueue(inode);

            /*
            * If this is the first dirty inode for this bdi,
            * we have to wake-up the corresponding bdi thread
            * to make sure background write-back happens
            * later.
            */
            if (wakeup_bdi &&
                (wb->bdi->capabilities & BDI_CAP_WRITEBACK))
                wb_wakeup_delayed(wb);
            return;
        }
    }
out_unlock_inode:
    spin_unlock(&inode->i_lock);
}

__mark_inode_dirty関数の処理を簡潔にまとめると下記の処理となる。

  1. inodeオブジェクトのロックを取得する
  2. inodeオブジェクトのフラグを更新する
  3. inodeオブジェクトからbdi_writebackオブジェクトを取得する
  4. inodeオブジェクトのロックを解放する
  5. inodeオブジェクトにDirtyになった時刻を更新する
  6. bdi_writebackオブジェクトのDirtyリストにinodeオブジェクトを追加する
  7. 必要に応じて、bdi_writebackオブジェクトに対応するwritebackスレッドを起床させる

また、この関数呼び出し初期のオブジェクトの関係性を下記に示す。

inode構造体からbdi_writeback構造体への対応関係

bdev_inode構造体は仮引数inodeからポイントされ、後述するcontainer_ofマクロによって取得できる。
また、bdi_writeback構造体のstateは、マウント直後の場合、いくつかのフラグ(WB_has_dirty_io)がセットされている。

ここから、__mark_inode_dirty関数の流れを図を用いつつ解説していく。
初めに、同時にinodeメンバの参照・更新を防ぐためにspin_lockマクロでロックを取る。

inodeオブジェクトのロックを取得する

次に、inodeメンバ自体を更新したことを表すI_DIRTY_SYNCフラグを付与する。
タイムスタンプの更新(update_time)によってこの関数が呼ばれているため、このフラグのみとなる。

inodeオブジェクトのフラグを更新する

次に、inodeオブジェクトからbdi_writebackオブジェクトを取得する。
locked_inode_to_wb_and_lock_list関数は、必要なロックの取得・解放とbdi_writebackオブジェクトを取得する。

// 1173:
static struct bdi_writeback *
locked_inode_to_wb_and_lock_list(struct inode *inode)
    __releases(&inode->i_lock)
    __acquires(&wb->list_lock)
{
    struct bdi_writeback *wb = inode_to_wb(inode);

    spin_unlock(&inode->i_lock);
    spin_lock(&wb->list_lock);
    return wb;
}

この関数では、型属性として__releases__acquiresが付与されている。

  • __releases: 関数に入る時に該当するロックがあらかじめ取得され、関数から出る時に解放されている
  • __acquires 関数に入る時には該当するロックが取得されてなく、関数から出る時に取得される

下記はinode_to_wbインライン関数の実装となっており、backing_dev_info構造体を経由してbdi_writeback構造体を取得する。

// 375:
static inline struct bdi_writeback *inode_to_wb(struct inode *inode)
{
    return &inode_to_bdi(inode)->wb;
}

これによって、inode構造体からbdev_inode構造体を取得し、bdi_writeback構造体を返すことができる。

inodeオブジェクトのロックを解放する

writebackするタイミングを契機を把握するために、inodeオブジェクトにDirtyになった時刻を更新する。
時間変数jiffiesについては、下記のリンクを参照すること。

qiita.com

inodeオブジェクトにDirtyになった時刻を更新する

その後、inode_io_list_move_locked関数でinode構造体にリンクされているDirtyリストをbdi_writeback構造体へ移動させる。
この時、inode構造体の状態によって移動先のリストが異なる。

// 2471:
    if (inode->i_state & I_DIRTY)
        dirty_list = &wb->b_dirty;
    else
        dirty_list = &wb->b_dirty_time;

I_DIRTYフラグは下記の通りとなっている。

// 2445:
#define I_DIRTY_INODE (I_DIRTY_SYNC | I_DIRTY_DATASYNC)
#define I_DIRTY (I_DIRTY_INODE | I_DIRTY_PAGES)
#define I_DIRTY_ALL (I_DIRTY | I_DIRTY_TIME)

(I_DIRTY_SYNC | I_DIRTY_DATASYNC | I_DIRTY_PAGES)の場合はwb->b_dirtyリストへ、そうでなければwb->b_dirty_timeリストに移動させる。
update_time関数から呼び出される場合、inode構造体の状態はI_DIRTY_SYNCとなっているので、wb->b_dirtyとなる。

これらを踏まえて、inode_io_list_move_locked関数の定義を確認する。

// 118:
static bool inode_io_list_move_locked(struct inode *inode,
                      struct bdi_writeback *wb,
                      struct list_head *head)
{
    assert_spin_locked(&wb->list_lock);
 
    list_move(&inode->i_io_list, head);
 
    /* dirty_time doesn't count as dirty_io until expiration */
    if (head != &wb->b_dirty_time)
        return wb_io_lists_populated(wb);

    wb_io_lists_depopulated(wb);
    return false;
}

assert_spin_lockedマクロは、引数のwb->list_lockがlock/unlockの準備ができているかどうか確認することができる。
wb->list_lockの場合、デバイスドライバの登録時に初期化される。

list_moveインラインマクロは、list_head構造体が保持しているデータを移し替える。
ここでは、inode->i_io_listhead(wb->b_dirty)が対象となる。

bdi_writebackオブジェクトのDirtyリストにinodeオブジェクトを追加する

実際には、list_head構造体を通してリストがつながっているが説明の都合上で画像からは省略している。

その後、writeback用のリストをwakeup_delayedさせる必要があるかの確認する。

// 85:
static bool wb_io_lists_populated(struct bdi_writeback *wb)
{
    if (wb_has_dirty_io(wb)) {
        return false;
    } else {
        set_bit(WB_has_dirty_io, &wb->state);
        WARN_ON_ONCE(!wb->avg_write_bandwidth);
        atomic_long_add(wb->avg_write_bandwidth,
                &wb->bdi->tot_write_bandwidth);
        return true;
    }
}

wb_has_dirty_ioインライン関数の結果 (WB_has_dirty_ioのビット値)によって、返す値が異なる。

// 53:
static inline bool wb_has_dirty_io(struct bdi_writeback *wb)
{
    return test_bit(WB_has_dirty_io, &wb->state);
}

もし、b_dirtyにinodeが既に登録されていた場合には、リストに対して初めてノード追加ではないため、そのまま処理を終了する。
一方で、b_dirtyにinodeが登録されていない場合には、リストに対して初めてのノード追加なので、bdk_writebackWB_has_dirty_ioをセットし、writebackカーネルスレッドに処理を依頼する必要がある。

// 2476: 
            wakeup_bdi = inode_io_list_move_locked(inode, wb,
                                   dirty_list);

            spin_unlock(&wb->list_lock);
            trace_writeback_dirty_inode_enqueue(inode);

            /*
            * If this is the first dirty inode for this bdi,
            * we have to wake-up the corresponding bdi thread
            * to make sure background write-back happens
            * later.
            */
            if (wakeup_bdi &&
                (wb->bdi->capabilities & BDI_CAP_WRITEBACK))
                wb_wakeup_delayed(wb);
            return;

wb_wakeup_delayed関数は、writeback用のカーネルスレッドにDirtyなファイルがあることを通知する。 こちらの関数は、後ほど (writebackのセクション) 確認する。

ちなみに、処理としては下記の通りとなっている。

// 263:
void wb_wakeup_delayed(struct bdi_writeback *wb)
{
    unsigned long timeout;

    timeout = msecs_to_jiffies(dirty_writeback_interval * 10);
    spin_lock_bh(&wb->work_lock);
    if (test_bit(WB_registered, &wb->state))
        queue_delayed_work(bdi_wq, &wb->dwork, timeout);
    spin_unlock_bh(&wb->work_lock);
}

ファイルの実データを書きこみ

ファイルのタイムスタンプの更新が完了後、 generic_perform_write関数によってバッファIOを実施する。

// 3733:
ssize_t generic_perform_write(struct file *file,
                struct iov_iter *i, loff_t pos)
{
    struct address_space *mapping = file->f_mapping;
    const struct address_space_operations *a_ops = mapping->a_ops;
    long status = 0;
    ssize_t written = 0;
    unsigned int flags = 0;

    do {
        struct page *page;
        unsigned long offset; /* Offset into pagecache page */
        unsigned long bytes;  /* Bytes to write to page */
        size_t copied;     /* Bytes copied from user */
        void *fsdata;

        offset = (pos & (PAGE_SIZE - 1));
        bytes = min_t(unsigned long, PAGE_SIZE - offset,
                        iov_iter_count(i));

again:
        /*
        * Bring in the user page that we will copy from _first_.
        * Otherwise there's a nasty deadlock on copying from the
        * same page as we're writing to, without it being marked
        * up-to-date.
        */
        if (unlikely(iov_iter_fault_in_readable(i, bytes))) {
            status = -EFAULT;
            break;
        }

        if (fatal_signal_pending(current)) {
            status = -EINTR;
            break;
        }

        status = a_ops->write_begin(file, mapping, pos, bytes, flags,
                        &page, &fsdata);
        if (unlikely(status < 0))
            break;

        if (mapping_writably_mapped(mapping))
            flush_dcache_page(page);

        copied = copy_page_from_iter_atomic(page, offset, bytes, i);
        flush_dcache_page(page);

        status = a_ops->write_end(file, mapping, pos, bytes, copied,
                        page, fsdata);
        if (unlikely(status != copied)) {
            iov_iter_revert(i, copied - max(status, 0L));
            if (unlikely(status < 0))
                break;
        }
        cond_resched();

        if (unlikely(status == 0)) {
            /*
            * A short copy made ->write_end() reject the
            * thing entirely.  Might be memory poisoning
            * halfway through, might be a race with munmap,
            * might be severe memory pressure.
            */
            if (copied)
                bytes = copied;
            goto again;
        }
        pos += status;
        written += status;

        balance_dirty_pages_ratelimited(mapping);
    } while (iov_iter_count(i));

    return written ? written : status;
}
  1. ユーザ空間の書き込み対象のページを事前にページフォールトさせる
  2. シグナルを受信したかどうかチェックする
  3. 必要なオブジェクトを確保したり、書き込み前の準備をする
  4. ユーザ空間の書き込み対象のページをカーネル空間にコピーする
  5. バッファにDirtyフラグを立てる
  6. プリエンプトポイントを明示

それぞれの処理について確認していく。

ユーザ空間の書き込み対象のページを事前にページフォールトさせる」に該当するソースコードは下記の部分となる。

// 3754:
        /*
        * Bring in the user page that we will copy from _first_.
        * Otherwise there's a nasty deadlock on copying from the
        * same page as we're writing to, without it being marked
        * up-to-date.
        *
        * Not only is this an optimisation, but it is also required
        * to check that the address is actually valid, when atomic
        * usercopies are used, below.
        */
        if (unlikely(iov_iter_fault_in_readable(i, bytes))) {
            status = -EFAULT;
            break;
        }

この処理を理解するにあたって、コミットメッセージを一部を訳す。

sys_write() を実行する際には、ユーザ空間に書き込み元のバッファ、書き込み対象のファイル用のページが存在する。

その両方が同じ物理ページである場合、デッドロックの可能性があります。
具体的には下記のような処理となる。

  1. ファイルへの書き込みを開始する
  2. ページキャッシュページを確保して !Uptodate に設定します。
  3. ユーザ空間のバッファをtouchし、ユーザーデータをコピーします。
  4. 書き込み元がまだマップされていないため、ページフォールトが発生する
  5. 書き込み対象のファイル用のページをロックしようとし、ページフォールトコードが発生する、デッドロック発生。

つまり、この段階でページフォールトを発生させてデッドロックを防ぐ。

その後、シグナルを受信したかどうかチェックする

// 3765:
        if (fatal_signal_pending(current)) {
            status = -EINTR;
            break;
        }

この段階で、SIGKILLSIGPENDINGが受信していれば終了させる。
以降は、ファイルのデータが一部だけ書き込まれたり、ファイルに不整合が起きるため、このタイミングで行う。

その後、必要なオブジェクトを確保したり、書き込み前の準備をする

// 3770:
        status = a_ops->write_begin(file, mapping, pos, bytes, flags,
                        &page, &fsdata);
        if (unlikely(status < 0))
            break;

write_begin関数は各ファイルシステムが定義しており、ページキャッシュの取得など実施している。
この処理はだけでも膨大な量となるので、次の記事にてwrite_endと共に追跡する。

そして、write_begin関数で用意したページキャッシュに対して、ユーザ空間の書き込み対象のページをカーネル空間にコピーする

// 3775:
        if (mapping_writably_mapped(mapping))
            flush_dcache_page(page);

        copied = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
        flush_dcache_page(page);

先にiov_iter_copy_from_user_atomic関数を確認する。
iov_iter_copy_from_user_atomic関数の定義は下記のとおりである。

// 909:
size_t copy_page_from_iter_atomic(struct page *page, unsigned offset, size_t bytes,
                  struct iov_iter *i)
{
    char *kaddr = kmap_atomic(page), *p = kaddr + offset;
    if (unlikely(!page_copy_sane(page, offset, bytes))) {
        kunmap_atomic(kaddr);
        return 0;
    }
    if (unlikely(iov_iter_is_pipe(i) || iov_iter_is_discard(i))) {
        kunmap_atomic(kaddr);
        WARN_ON(1);
        return 0;
    }
    iterate_and_advance(i, bytes, base, len, off,
        copyin(p + off, base, len),
        memcpy(p + off, base, len)
    )
    kunmap_atomic(kaddr);
    return bytes;
}

iov_iter_copy_from_user_atomic関数は、ユーザ空間にあるiov_iter構造体のバッファをpage構造体が示す仮想アドレスにコピーする。
今回使用している環境はCONFIG_FLATMEMとなっているので、page構造体は下記のように配置される。

ARM memory Layout

kmap_atomic関数は、page構造体から該当する仮想アドレスを(high memoryに割り当て)仮想アドレスを返す。
似たようなカーネルAPIとしてkmap関数があるが、kmap_atomic関数はsleepされないため割り込みコンテキストで呼ぶことができる。

// 191:
static inline void *kmap_atomic(struct page *page)
{
    if (IS_ENABLED(CONFIG_PREEMPT_RT))
        migrate_disable();
    else
        preempt_disable();
    pagefault_disable();
    return page_address(page);
}

この環境ではCONFIG_HIGHMEM=nであるため、kmap_atomic関数の実装はそのページに割り当てられている仮想アドレスを返す。

またkmap_atomic関数内では、プリエンプトとページフォールトが無効となっている。(これらの関数の詳細はここでは追わないこととする)

// 1630:
#if !defined(HASHED_PAGE_VIRTUAL) && !defined(WANT_PAGE_VIRTUAL)
#define page_address(page) lowmem_page_address(page)
#define set_page_address(page, address)  do { } while(0)
#define page_address_init()  do { } while(0)
#endif

page_addressマクロは条件によって定義が異なるが、この環境ではlowmem_page_addressインライン関数を呼び出す。

// 1603:
static __always_inline void *lowmem_page_address(const struct page *page)
{
    return page_to_virt(page);
}

lowmem_page_addressインライン関数は、page_to_virtマクロを呼び出す。

// 119:
#ifndef page_to_virt
#define page_to_virt(x) __va(PFN_PHYS(page_to_pfn(x)))
#endif

page_to_pfnマクロによって、page構造体からそれに対応するPhysical Frame Number (PFN) を取得する。

// 52:
#define page_to_pfn __page_to_pfn
#define pfn_to_page __pfn_to_page

__page_to_pfnマクロの実装はLinuxのメモリモデルによって異なる。
この環境では、CONFIG_FLATMEMであるのが下記のような定義となる。

// 12:
#if defined(CONFIG_FLATMEM)

#ifndef ARCH_PFN_OFFSET
#define ARCH_PFN_OFFSET     (0UL)
#endif

#define __pfn_to_page(pfn)  (mem_map + ((pfn) - ARCH_PFN_OFFSET))
#define __page_to_pfn(page) ((unsigned long)((page) - mem_map) + \
                ARCH_PFN_OFFSET)

__page_to_pfnマクロは、CONFIG_FLATMEMの場合には、簡単な演算で求めることができる。
(page構造体からmem_mapを引くことによってオフセットを取得して、ARCH_PFN_OFFSET(0x60000)と足す。)

ここで、mem_mep = 0xef7f9000かつpage = 0xeff5e860の場合を考えてみる。

page構造体と物理アドレスマッピング

この時、__page_to_pfnマクロの結果として、(0xeff5e860 - 0xef7f9000)/0x20 + 0x0060000の結果で0x0063B2C3となる。

一方で、PFN_PHYSマクロの定義は次の通りとなっている。

// 18:
#define PFN_ALIGN(x)    (((unsigned long)(x) + (PAGE_SIZE - 1)) & PAGE_MASK)
#define PFN_UP(x)   (((x) + PAGE_SIZE-1) >> PAGE_SHIFT)
#define PFN_DOWN(x) ((x) >> PAGE_SHIFT)
#define PFN_PHYS(x) ((phys_addr_t)(x) << PAGE_SHIFT)
#define PHYS_PFN(x) ((unsigned long)((x) >> PAGE_SHIFT))

Physical Frame Number にPAGE_SHIFT(12)を左ビットシフトすることで物理アドレスを取得できる。

// 323:
/*
 * Drivers should NOT use these either.
 */
#define __pa(x)         __virt_to_phys((unsigned long)(x))
#define __pa_symbol(x)      __phys_addr_symbol(RELOC_HIDE((unsigned long)(x), 0))
#define __va(x)         ((void *)__phys_to_virt((phys_addr_t)(x)))
#define pfn_to_kaddr(pfn)   __va((phys_addr_t)(pfn) << PAGE_SHIFT)

__vaマクロは、引数に渡した物理アドレスから対応する仮想アドレスを返す関数 (__phys_addr_t関数) を返す。

// 260:
static inline unsigned long __phys_to_virt(phys_addr_t x)
{
    unsigned long t;

    /*
    * 'unsigned long' cast discard upper word when
    * phys_addr_t is 64 bit, and makes sure that inline
    * assembler expression receives 32 bit argument
    * in place where 'r' 32 bit operand is expected.
    */
    __pv_stub((unsigned long) x, t, "sub");
    return t;
}

ここまで、kmap_atomic関数によって仮想アドレスを取得できたので、サニティーチェックをした後にコピーを始める。
コピーには、iterate_and_advanceマクロに対してcopyin関数、memcpy関数を渡すことで達成できる。

// 148:
#define iterate_and_advance(i, n, base, len, off, I, K) \
   __iterate_and_advance(i, n, base, len, off, I, ((void)(K),0))
// 111:
#define __iterate_and_advance(i, n, base, len, off, I, K) { \
   if (unlikely(i->count < n))              \
       n = i->count;                    \
   if (likely(n)) {                   \
       if (likely(iter_is_iovec(i))) {         \
           const struct iovec *iov = i->iov;  \
           void __user *base;         \
           size_t len;                \
           iterate_iovec(i, n, base, len, off,  \
                       iov, (I))   \
           i->nr_segs -= iov - i->iov;       \
           i->iov = iov;                \
       } else if (iov_iter_is_bvec(i)) {        \
           const struct bio_vec *bvec = i->bvec;  \
           void *base;                \
           size_t len;                \
           iterate_bvec(i, n, base, len, off,   \
                       bvec, (K))  \
           i->nr_segs -= bvec - i->bvec;     \
           i->bvec = bvec;              \
       } else if (iov_iter_is_kvec(i)) {        \
           const struct kvec *kvec = i->kvec; \
           void *base;                \
           size_t len;                \
           iterate_iovec(i, n, base, len, off,  \
                       kvec, (K))  \
           i->nr_segs -= kvec - i->kvec;     \
           i->kvec = kvec;              \
       } else if (iov_iter_is_xarray(i)) {      \
           void *base;                \
           size_t len;                \
           iterate_xarray(i, n, base, len, off, \
                           (K))    \
       }                       \
       i->count -= n;                   \
   }                           \
}

対象がITER_IOVECなので、iterate_iovecマクロでI(memcpy関数)を実行する。

iterate_iovecマクロはiov_iter構造体で指し示すそれぞれのデータに対して、引数のI処理を実行する。
これにより、ページフレームに書き込み用のデータをコピーすることができたのでkunmap関数を実行して、ページフレームを開放する。

// 3775:
        if (mapping_writably_mapped(mapping))
            flush_dcache_page(page);

        copied = copy_page_from_iter_atomic(page, offset, bytes, i);
        flush_dcache_page(page);

一方で、iov_iter_copy_from_user_atomic関数の前後でflush_dcache_page関数を実行する。
ここから、flush_dcache_page関数について処理を追っていく。

iov_iter_copy_from_user_atomic関数の直前で、mapping_writably_mapped関数でチェックが入る。
mapping_writably_mapped関数は下記のような定義となっている。

// 551:
static inline int mapping_writably_mapped(struct address_space *mapping)
{
    return atomic_read(&mapping->i_mmap_writable) > 0;
}

ここで、該当するページが共有されている場合 (mmapMAP_SHAREDを指定した場合など)は、mapping->i_mmap_writebleが1以上となる。

この後はflush_dcache_page関数を実行するのだが、その前にCPU Cacheについておさらいする。
今回使用しているボードに搭載しているCortex-A9のデータは、Documentation – Arm DeveloperのCortex-Aシリーズのキャッシュ機能についてまとめられている。

Item Description
L2 Cache External
Cache Implementation (Data) PIPT
Cache Implementation (Inst) VIPT
L1 Cache size (Data) 16KB/32KB/64KB
L1 Cache size (Inst) 16KB/32KB/64KB
L1 Cache Structure 4-way set associcative (Inst, Data)
Cache line (words) 8
Cache line (bytes) 32

ここから、データキャッシュはPIPT方式で命令キャッシュはVIPT方式が採用されていることがわかる。

Physically Indexed Physically Tagged (PIPT)方式

Virtually Indexed Physically Tagged (VIPT)方式について

これらを踏まえてflush_dcache_page関数を確認する。

// 296:
/*
 * Ensure cache coherency between kernel mapping and userspace mapping
 * of this page.
 *
 * We have three cases to consider:
 *  - VIPT non-aliasing cache: fully coherent so nothing required.
 *  - VIVT: fully aliasing, so we need to handle every alias in our
 *          current VM view.
 *  - VIPT aliasing: need to handle one alias in our current VM view.
 *
 * If we need to handle aliasing:
 *  If the page only exists in the page cache and there are no user
 *  space mappings, we can be lazy and remember that we may have dirty
 *  kernel cache lines for later.  Otherwise, we assume we have
 *  aliasing mappings.
 *
 * Note that we disable the lazy flush for SMP configurations where
 * the cache maintenance operations are not automatically broadcasted.
 */
void flush_dcache_page(struct page *page)
{
    struct address_space *mapping;

    /*
    * The zero page is never written to, so never has any dirty
    * cache lines, and therefore never needs to be flushed.
    */
    if (page == ZERO_PAGE(0))
        return;

    if (!cache_ops_need_broadcast() && cache_is_vipt_nonaliasing()) {
        if (test_bit(PG_dcache_clean, &page->flags))
            clear_bit(PG_dcache_clean, &page->flags);
        return;
    }

    mapping = page_mapping_file(page);

    if (!cache_ops_need_broadcast() &&
        mapping && !page_mapcount(page))
        clear_bit(PG_dcache_clean, &page->flags);
    else {
        __flush_dcache_page(mapping, page);
        if (mapping && cache_is_vivt())
            __flush_dcache_aliases(mapping, page);
        else if (mapping)
            __flush_icache_all();
        set_bit(PG_dcache_clean, &page->flags);
    }
}
EXPORT_SYMBOL(flush_dcache_page);

flush_dcache_page関数は、CPUキャッシュの方式によって挙動が変わる。
エイリアス(異なる仮想アドレスから同じ物理アドレスを参照する)の問題があるVIPT aliasingやVIVTの場合は、ページフレームを扱う前後でキャッシュをフラッシュすることで回避している。

しかし、今回はnon-aliasingであるためflush_dcache_page関数はpage構造体のフラグ(PG_dcache_clean)を操作するだけである。

その後、バッファにDirtyフラグを立てるためにwrite_end関数を呼び出すが、本記事では解説を省略する。

cond_reschedマクロは、プリエンプトポイントを明示し必要に応じてプリエンプションさせる。

ファイルのオフセットを更新する

write処理が完了した後に、オフセットの更新やdirty pageのbalanceを実施する。

// 3790:
        if (unlikely(status == 0)) {
            /*
            * A short copy made ->write_end() reject the
            * thing entirely.  Might be memory poisoning
            * halfway through, might be a race with munmap,
            * might be severe memory pressure.
            */
            if (copied)
                bytes = copied;
            goto again;
        }
        pos += status;
        written += status;

        balance_dirty_pages_ratelimited(mapping);
    } while (iov_iter_count(i));

    return written ? written : status;

おわりに

本記事では、ext2ファイルシステムwrite_iter操作(generic_file_write_iter)を解説した。
次回の記事で、ファイルシステム固有のwrite_iter操作を解説したいと思う。

変更履歴

  • 2021/2/23: 記事公開
  • 2021/10/08: dirtyリストの繋げる処理する画像の更新
  • 2022/09/04: カーネルバージョンを5.15に変更

参考

Raspberry Pi 4 Model B をネットワークブートで起動させる

関連記事

概要

Raspberry Pi 4 Model BからLinuxカーネル (Raspbian)をネットワークブートするための環境を構築する方法を解説する。

Raspberry PiカーネルLinux Kernel 5.10をビルドし、ルートファイルシステムに必要なパッケージをインストールする。

Raspberry Pi 4 Model Bは、SDカードを挿入せずにネットワーク上にあるネットワークサーバからブートイメージとルートファイルシステムを取得する。

はじめに

Raspberry Pi 4 Model B (Raspberry Pi 4) は安価に手に入るシングルボードコンピュータとして広く利用されている。
Raspberry Pi は、DebianベースOSのRaspberry Pi OSが搭載されている。 そのため、組込みLinuxの動作確認用のボードとして手軽に使うことができる

そして、組込みシステムの開発段階やデバッグ段階では、ネットワーク経由からシステムを起動(ネットワークブート)できることが理想的である。 Raspberry Pi 4では、ネットワーク経由からブートイメージの取得ルートファイルシステムのマウントして起動させることができる。

そこで、本記事ではRaspberry PiカーネルLinux Kernel 5.10を自前でビルドして、ネットワークブートを利用して起動する方法を記載する。

また、作成する環境でdm-verityとfs-verityが使えるようにセットアップする。

実行環境

下記の環境を使用して説明する。

ネットワーク構成図

開発用セグメントに、使用するRaspberry Pi 4 Model Bを接続する。

ネットワークブートサーバの詳細は下記のとおりである。

名前 詳細
OS Ubuntu 20.04.01 LTS
Kernel 5.4.0-58-generic
IPアドレス (eth0) 192.168.1.11
IPアドレス (eth1) 172.16.1.1
ユーザ名 leava
ホスト名 server
Raspberry Pi 4のブートイメージ格納予定 /srv/boot
Raspberry Pi 4のルートファイルシステム格納予定 /srv/rootfs

計測対象のRaspberry Pi 4 Model Bの詳細は下記のとおりである。

名前 詳細
Hardware Raspberry Pi 4 Model B Rev 1.2 (4GB)
OS Raspbian GNU/Linux 10 (buster)
Kernel Linux version 5.10.3-v7l+
SDカード HDMCSDH16GCL10UIJP-WOA
USBメモリ SILICON POWER SP032GBUF3B02V1K
IPアドレス (eth0) 172.16.1.3
ユーザ名 pi
ホスト名 raspberry

構築手順

Raspbian GNU/Linux 10 (buster)は、Linux Kernel 5.4.79ベースのOSとなっている。
この記事では、Linux Kernel 5.10.3を自前でビルドする。

ここでは、次回以降の検証のためにdm-verityとfs-verity関連のコンフィグを有効化しているので、不要の人はスキップしてほしい。

ネットワークブートへの対応

ネットワークブートサーバの構築方法については気になる方は、下記のページを参照してほしい。

leavatail.hatenablog.com

  1. 必要パッケージのインストール

     pi@raspberrypi:~ $ sudo apt update
     pi@raspberrypi:~ $ sudo apt full-upgrade
     pi@raspberrypi:~ $ sudo apt install rpi-eeprom
    
  2. EEPROM のアップデート (著者の環境は既にアップデート済み状態)

     pi@raspberrypi:~ $ sudo rpi-eeprom-update
     BCM2711 detected
     Dedicated VL805 EEPROM detected
     BOOTLOADER: up-to-date
     CURRENT: 2020年 12月 11日 金曜日 11:15:17 UTC (1607685317)
      LATEST: 2020年 12月 11日 金曜日 11:15:17 UTC (1607685317)
      FW DIR: /lib/firmware/raspberrypi/bootloader/stable
     VL805: up-to-date
     CURRENT: 000138a1
      LATEST: 000138a1        
    
  3. 既存のEEPROMの設定を確認する

     pi@raspberrypi:~ $ vcgencmd bootloader_config
     [all]
     BOOT_UART=0
     BOOT_ORDER=0x1
    
  4. EEPROMの設定を更新する

     pi@raspberrypi:~ $ sudo raspi-config
         ->6 Advanced Options     Configure advanced settings
           -> A6 Boot Order              Choose network or USB device boot 
             -> B2 Network Boot Boot from network if SD card boot fails 
    
  5. EEPROMの設定が更新されていることを確認する

     pi@raspberrypi:~ $ vcgencmd bootloader_config
     [all]
     BOOT_UART=0
     BOOT_ORDER=0xf21
    
  6. 起動させるカーネルイメージを用意する。公式イメージの「Raspberry Pi OS Lite」から抽出する。

     leava@server:~ $ wget https://downloads.raspberrypi.org/raspios_lite_armhf/images/raspios_lite_armhf-2020-12-04/2020-12-02-raspios-buster-armhf-lite.zip
     leava@server:~ $ unzip 2020-12-02-raspios-buster-armhf-lite.zip
     leava@server:~ $ sudo kpartx -av 2020-12-02-raspios-buster-armhf-lite.img
     add map loop4p1 (253:0): 0 524288 linear 7:4 8192
     add map loop4p2 (253:1): 0 3096576 linear 7:4 532480
    
     leava@server:~ $ sudo mount /dev/mapper/loop4p1 /mnt/boot
     leava@server:~ $ sudo mount /dev/mapper/loop4p2 /mnt/rootfs
     leava@server:~ $ sudo rsync -av /mnt/boot/ /srv/boot/
     leava@server:~ $ sudo rsync -av /mnt/rootfs/ /srv/rootfs/
     leava@server:~ $ sudo umount /mnt/boot
     leava@server:~ $ sudo umount /mnt/rootfs
    
     leava@server:~ $ sudo kpartx -d 2020-12-02-raspios-buster-armhf-lite.img
    
     leava@server:~ $ sudo mv /srv/tftpboot/cmdline.txt /srv/tftpboot/cmdline.txt.old
     leava@server:~ $ echo "console=serial0,115200 console=tty1 root=/dev/nfs nfsroot=172.16.1.1:/srv/rootfs,vers=3,proto=tcp rw ip=dhcp rootwait elevator=deadline" | sudo tee /srv/tftpboot/cmdline.txt 
    
  7. Raspberry Pi 4 のSDカードを抜去して、電源を再投入する

カーネルをビルド

公式ページのCross-compileを参考に32-bit カーネルをビルドする。

  1. ビルドに必要なパッケージをインストール

     leava@server:~ $ sudo apt install git bc bison flex libssl-dev make libc6-dev libncurses5-dev crossbuild-essential-armhf
    
  2. Raspberry Pi 公式のカーネルソースをダウンロードする

     leava@server:~ $ git clone --depth=1 --branch rpi-5.10.y https://github.com/raspberrypi/linux 
    
  3. デフォルトのコンフィグを生成する

     leava@server:~ $ cd linux
     leava@server:~/linux $ KERNEL=kernel7l
     leava@server:~/linux $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- bcm2711_defconfig
    
  4. コンフィグを修正する (fs-verityとdm-verityの有効化)

     leava@server:~/linux $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- menuconfig  
    
       File systems  --->   
         [*] FS Verity (read-only file-based authenticity protection)       
         [*]   FS Verity builtin signature support    
      Device Drivers  ---> 
         [*] Multiple devices driver support (RAID and LVM)  --->  
           <*>   Device mapper support 
           [*]     Device mapper debugging support    
           <*>     Verity target support
           [*]       Verity data device root hash signature verification support 
    
  5. カーネルのビルド

     leava@server:~/linux $ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- -j$(nproc) zImage modules dtbs
    
  6. カーネルモジュールのインストール

     leava@server:~/linux $ sudo env PATH=$PATH make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- INSTALL_MOD_PATH=/srv/rootfs modules_install
    
  7. カーネルイメージのインストール

     leava@server:~/linux $ sudo cp arch/arm/boot/zImage /srv/boot/$KERNEL.img
     leava@server:~/linux $ sudo cp arch/arm/boot/dts/*.dtb /srv/boot
     leava@server:~/linux $ sudo cp arch/arm/boot/dts/overlays/*.dtb* /srv/boot/overlays/
     leava@server:~/linux $ sudo cp arch/arm/boot/dts/overlays/README /srv/boot/overlays/
    

ルートファイルシステムの更新

QEMUを利用して、ルートファイルシステムに必要なパッケージをインストールする。

  1. 必要なパッケージをインストール

     leava@server:~ $ sudo apt-get install qemu-user-static
    
  2. Raspberry Pi 4B の ルートファイルシステム にユーザモードエミュレータをコピーする

     leava@server:~ $ sudo update-binfmts --display | grep arm
     qemu-arm (enabled):
      interpreter = /usr/bin/qemu-arm-static
     qemu-armeb (enabled):
      interpreter = /usr/bin/qemu-armeb-static
    
     leava@server:~ $ sudo cp /usr/bin/qemu-arm-static /srv/rootfs/usr/bin/
    
  3. chrootでルートファイルシステムを変更するための準備

     leava@server:~ $ sudo mount -t sysfs sysfs arm/raspbian/sys
     leava@server:~ $ sudo mount -t proc proc arm/raspbian/proc
     leava@server:~ $ sudo mount -t devtmpfs udev arm/raspbian/dev
     leava@server:~ $ sudo mount -t devpts devpts arm/raspbian/dev/pts
    
  4. chrootでルートファイルシステムを変更する

     leava@server:~ $ sudo chroot /srv/rootfs/ /usr/bin/bash
     root@server:/# root@mainserver:/# lsb_release -a
     No LSB modules are available.
     Distributor ID: Raspbian
     Description:    Raspbian GNU/Linux 10 (buster)
     Release:        10
     Codename:       buster
    
  5. Raspberry Pi 4用のrootfsにに最低限の設定をする

     root@server:/# systemctl enable ssh.service 
    
  6. Raspberry Pi 4用のrootfsに必要最低限のパッケージをインストールする

     root@server:/# apt-get install libssl-dev fio
     root@server:/# wget https://git.kernel.org/pub/scm/linux/kernel/git/ebiggers/fsverity-utils.git/snapshot/fsverity-utils-1.2.tar.gz
     root@server:/# tar xf fsverity-utils-1.2.tar.gz
     root@server:/# cd fsverity-utils-1.2
     root@server:/fsverity-utils-1.2# make && make install
    

実行結果

  1. Raspberry Pi 4 の電源を再投入する
  2. シリアルコンソール経由でアクセスする。

     Raspbian GNU/Linux 10 raspberrypi ttyS0
    
     raspberrypi login: root
     Password: 
     Last login: Thu Dec  3 16:27:35 GMT 2020 on ttyS0
     Linux raspberrypi 5.10.3-v7l+ #1 SMP Mon Dec 28 05:53:15 UTC 2020 armv7l
    
     The programs included with the Debian GNU/Linux system are free software;
     the exact distribution terms for each program are described in the
     individual files in /usr/share/doc/*/copyright.
    
     Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
     permitted by applicable law.
    
     SSH is enabled and the default password for the 'pi' user has not been changed.
     This is a security risk - please login as the 'pi' user and type 'passwd' to set a new password.
    
    
     Wi-Fi is currently blocked by rfkill.
     Use raspi-config to set the country before use.
    
     root@raspberrypi:~#         
    

おわりに

本記事ではRaspberry PiカーネルLinux Kernel 5.10を自前でビルドして、ネットワークブートを利用して起動する方法を記載する。
次回は、作成した環境でdm-verityとfs-verityの使い方について確認する。

変更履歴

  • 2021/1/1: 記事公開
  • 2021/2/22: dm-verityとfs-verityの記事は別記事で管理する

参考

Linuxカーネルのファイルアクセスの処理を追いかける (3) VFS

関連記事

概要

QEMUの vexpress-a9 (arm) で Linux 5.15を起動させながら、ファイル書き込みのカーネル処理を確認していく。

本章では、vfs_write関数から各ファイルシステムが定義しているwriteまたはwrite_iterを呼ぶところまでを確認した。

はじめに

ユーザプロセスはファイルシステムという機構によって記憶装置上のデータをファイルという形式で書き込み・読み込みすることができる。
本調査では、ユーザプロセスがファイルに書き込み要求を実行したときにLinuxカーネルではどのような処理が実行されるかを読み解いていく。

調査対象や環境などはPart 1: 環境セットアップを参照。

本記事では、VFSレイヤを対象に解説を始める。
処理シーケンス図としては、下記の赤枠部分が該当する。

調査対象(シーケンス図)

VFSレイヤの概要

LinuxVFSレイヤで以下のようなオブジェクトを利用する。

  • file: openシステムコールでユーザプログラムが操作できるようになったファイル
  • path: マウントポイントとdentry
  • dentry: ファイル名とinodeの対応関係
  • inode: ファイルのメタデータ
  • address_space: inodeとページキャッシュの対応関係
  • super_block: ファイルシステム全般に関する情報

VFSオブジェクト関係図

ファイルシステムは上記のoperations関数を固有で定義することで、それぞれのファイルシステムで違う操作を実現している。

これを踏まえて、前回からの続きのvfs_write関数を追いかける。

VFSレイヤはvfs_で始まる関数で定義される。
writeシステムコールの場合はvfs_write関数が該当する。

// 574:
ssize_t vfs_write(struct file *file, const char __user *buf, size_t count, loff_t *pos)
{
    ssize_t ret;

    if (!(file->f_mode & FMODE_WRITE))
        return -EBADF;
    if (!(file->f_mode & FMODE_CAN_WRITE))
        return -EINVAL;
    if (unlikely(!access_ok(buf, count)))
        return -EFAULT;

    ret = rw_verify_area(WRITE, file, pos, count);
    if (ret)
        return ret;
    if (count > MAX_RW_COUNT)
        count =  MAX_RW_COUNT;
    file_start_write(file);
    if (file->f_op->write)
        ret = file->f_op->write(file, buf, count, pos);
    else if (file->f_op->write_iter)
        ret = new_sync_write(file, buf, count, pos);
    else
        ret = -EINVAL;
    if (ret > 0) {
        fsnotify_modify(file);
        add_wchar(current, ret);
    }
    inc_syscw(current);
    file_end_write(file);
    return ret;
}

vfs_write関数では、大きく分けて下記の三つを実施する。

  1. アクセスするデータの領域の正当性確認
  2. ファイルシステムのwrite操作を実行する
  3. 書き込みが発生したことを監視中のユーザプログラムに通知する

それぞれの挙動について、以降の節で解説する。

アクセスするデータ領域の確認

vfs_write関数の下記の部分に該当する。

// 578:
    if (!(file->f_mode & FMODE_WRITE))
        return -EBADF;
    if (!(file->f_mode & FMODE_CAN_WRITE))
        return -EINVAL;
    if (unlikely(!access_ok(buf, count)))
        return -EFAULT;

    ret = rw_verify_area(WRITE, file, pos, count);
    if (ret)
        return ret;
    if (count > MAX_RW_COUNT)
        count =  MAX_RW_COUNT;

ここでは、下記のような処理をする。

  • ファイルがREADモード(openシステムコールO_RDONLYを指定) の場合は-EBADFを返す
  • ファイルシステムがwrite操作をサポートしていない場合は-EINVALを返す
  • 書き込み先のアドレスを確認して、不適切であれば-EFAULTを返す
  • ファイルが強制ロックがかけられている場合はエラー番号を返す
  • 書き込むデータの長さが長い場合には一定サイズに切り詰める

READモードのチェックとwrite操作をサポートしていない場合に関しては、明解であるので説明を省略する。
同様に、サイズを切り詰める処理も省略する。

書き込み先のアドレスを確認する

access_okマクロで、writeシステムコールの引数の書き込み先アドレスを確認する。
このマクロは書き込み先のアドレスと書き込み対象の長さを引数にとる。

ちなみに、access_okアーキテクチャによって処理が異なるので注意が必要である。

// 251:
#define access_ok(addr, size)   (__range_ok(addr, size) == 0)

__range_okマクロはCONFIG_MMUに依存しており、このコンフィグが無効の場合はなにもしない。
コンフィグが有効の場合、下記のマクロが実行される。

// 62:
#define __range_ok(addr, size) ({ \
   unsigned long flag, roksum; \
   __chk_user_ptr(addr);    \
   __asm__(".syntax unified\n" \
       "adds %1, %2, %3; sbcscc %1, %1, %0; movcc %0, #0" \
       : "=&r" (flag), "=&r" (roksum) \
       : "r" (addr), "Ir" (size), "0" (TASK_SIZE) \
       : "cc"); \
   flag; })

まずは、__chk_user_ptrマクロに着目する。

// 7:
#ifdef __CHECKER__
/* address spaces */
# define __kernel  __attribute__((address_space(0)))
# define __user        __attribute__((noderef, address_space(__user)))
# define __iomem   __attribute__((noderef, address_space(__iomem)))
# define __percpu  __attribute__((noderef, address_space(__percpu)))
# define __rcu     __attribute__((noderef, address_space(__rcu)))
static inline void __chk_user_ptr(const volatile void __user *ptr) { }
// 29:
#else /* __CHECKER__ */
/* address spaces */
# define __kernel
# ifdef STRUCTLEAK_PLUGIN
#  define __user   __attribute__((user))
# else
#  define __user
# endif
# define __iomem
# define __percpu
# define __rcu
# define __chk_user_ptr(x)  (void)0
# define __chk_io_ptr(x)    (void)0

__chk_user_ptrマクロは、__CHECKER__が偽の場合に、(void)0を返す。

これはコンパイル時、インラインのaccess_okの引数が__userとなっているかのチェック故かと思います。 https://wiki.bit-hive.com/north/pg/access_ok

次にインラインアセンブラについて着目する。 インラインアセンブラ命令を展開すると下記のようなコードとなり、そのときの入力と出力が下記の表のようになっている。

// 65:
    .syntax unified
    adds %1, %2, %3
    sbcscc %1, %1, %0
    movcc %0, #0
名称 レジスタ 対応するデータ コードとの対応関係
出力オペランド 汎用レジスタ flag %0
汎用レジスタ roksum %1
入力オペランド 汎用レジスタ addr %2
リンクレジスタ size %3
出力オペランド0で割り当てたレジスタ TASK_SIZE %0
破壊レジスタ 条件レジスタ N/A N/A

これらを基にCプログラムのような形に書き起こしてみると、下記のようになる。

unsigned long __range_ok(const char __user *addr, size_t size) {
    unsigned long flag, roksum;

    __chk_user_ptr(addr);
    roksum = addr + size;
    roksum = roksum - TASK_SIZE;
    if (roksum < 0)
        flag = 1;
    return flag;
}

つまり、__range_okインライン関数では、addr + sizeTASK_SIZE以内であるかどうかを確認する処理である。
ちなみに、.syntax unifiedは、「ARM命令 と THUMB命令 を統一した書式で記述する」となっている。

.syntax unifiedは、「ARM命令 と THUMB命令 を統一した書式で記述する https://jhalfmoon.com/dbc/2019/09/27/%E3%81%90%E3%81%A0%E3%81%90%E3%81%A0%E4%BD%8E%E3%83%AC%E3%83%99%E3%83%AB%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%9F%E3%83%B3%E3%82%B07-syntax-%E3%82%B3%E3%83%A1%E3%83%B3%E3%83%88%E3%80%81/

そして、TASK_SIZEは下記の定義となっている。

// 38:
#ifndef CONFIG_KASAN
#define TASK_SIZE       (UL(CONFIG_PAGE_OFFSET) - UL(SZ_16M))
#else
#define TASK_SIZE       (KASAN_SHADOW_START)
#endif

TASK_SIZEは、ユーザプロセスの最大サイズとなっており、The Kernel Address Sanitizer (KASAN) の機能が定義されているかどうかで値が異なる。

CONFIG_KASAN=yの場合は、TASK_SIZEはshadow addressの開始アドレスとなる。

https://lists.cs.columbia.edu/pipermail/kvmarm/2020-January/038743.html

CONFIG_KASAN=nの場合は、CONFIG_PAGE_OFFSET - SZ_16MTASK_SIZEとなる。

__range_ok で確認するメモリアドレス

つまり、__range_okマクロは、引数として渡されたアドレスがUser space mapping領域の範囲内かどうかを確認する。

参照先のアドレスに範囲内であれば、rw_verify_area関数によって書き込み先の検証をする。

rw_verify_area関数の定義は次の通りとなっている。

// 366:
int rw_verify_area(int read_write, struct file *file, const loff_t *ppos, size_t count)
{
    if (unlikely((ssize_t) count < 0))
        return -EINVAL;

    /*
    * ranged mandatory locking does not apply to streams - it makes sense
    * only for files where position has a meaning.
    */
    if (ppos) {
        loff_t pos = *ppos;

        if (unlikely(pos < 0)) {
            if (!unsigned_offsets(file))
                return -EINVAL;
            if (count >= -pos) /* both values are in 0..LLONG_MAX */
                return -EOVERFLOW;
        } else if (unlikely((loff_t) (pos + count) < 0)) {
            if (!unsigned_offsets(file))
                return -EINVAL;
        }
    }

    return security_file_permission(file,
                read_write == READ ? MAY_READ : MAY_WRITE);
}

rw_verify_area関数で検証する内容は次の通りとなっている。

  • countの値が0以上であること
  • FMODE_UNSIGNED_OFFSETフラグが立っていないならば、posの値が0以上であること
    • また、posの絶対値がcountを上回っていないこと
  • FMODE_UNSIGNED_OFFSETフラグが立っていないならば、pos + countの値が0以上であること
  • Linux Security Module (LSM) が設定されているばらば、書き込み権限があること

ファイルシステムのwrite操作を実行する

vfs_write関数の下記の部分に該当する。

// 590:
    file_start_write(file);
    if (file->f_op->write)
        ret = file->f_op->write(file, buf, count, pos);
    else if (file->f_op->write_iter)
        ret = new_sync_write(file, buf, count, pos);
    else
        ret = -EINVAL;
// 602:
    file_end_write(file);

ここでは、下記のような処理をする。

  • super_blockにDirtyリストを更新することを通知する
  • ファイルシステム固有のwrite処理を実行する
  • super_blockにDirtyリストを更新終了したことを通知する

super_blockにファイル更新があることを通知する

アクセスするデータ領域の確認し問題がないと判断できた場合、対応するファイルシステムのwrite処理を実行する。

ただし、write処理の前後にfile_start_write関数とfile_end_write関数を実行しなければならない。
下記はfile_start_write関数を示している。

// 3004:
static inline void file_start_write(struct file *file)
{
    if (!S_ISREG(file_inode(file)->i_mode))
        return;
    sb_start_write(file_inode(file)->i_sb);
}

通常ファイル以外の場合はこの処理は実行されず、通常ファイルの場合はsuper_blockにファイル書き込みを通知する必要がある。

// 1880:
static inline void sb_start_write(struct super_block *sb)
{
    __sb_start_write(sb, SB_FREEZE_WRITE);
}

ファイル書き込みの場合は、SB_FREEZE_WRITEとして通知する。
super_blockに通知する関数には複数のレベルを用意しており、Page faultsの場合などがある。

// 1810:
static inline void __sb_start_write(struct super_block *sb, int level)
{
    percpu_down_read(sb->s_writers.rw_sem + level - 1);
}

この結果、super_blockに存在するセマフォを獲得することになる。 ちなみにこの処理は、fsfreeze(8)やioctl経由でファイルシステムを凍結させることができる処理を大きく関係している。

通常、Linuxではwrite処理はライトバックとなり、一定間隔毎に記憶装置にフラッシュする。
その際に、super_blockはライトバックが必要なファイルの一覧をリストとして管理している。 下記はその様子を表しており、このリストはwriteなどの処理によって更新される。

writebackするinodeリストの関係

一方で、file_end_write関数はこれとは対になる処理で、リストの更新が終わったためセマフォを解放している。

// 3018:
static inline void file_end_write(struct file *file)
{
    if (!S_ISREG(file_inode(file)->i_mode))
        return;
    __sb_end_write(file_inode(file)->i_sb, SB_FREEZE_WRITE);
}
// 1805:
static inline void __sb_end_write(struct super_block *sb, int level)
{
    percpu_up_read(sb->s_writers.rw_sem + level-1);
}

ファイルシステム固有のwrite処理を実行する

それぞれのファイルシステムは、writewrite_iter処理を定義することができる。

  • write: bufferで書き込み先を指定する
  • write_iter: iov_iterで書き込み先を指定する

これを踏まえて、vfs_write関数を確認する。

// 591:
    if (file->f_op->write)
        ret = file->f_op->write(file, buf, count, pos);
    else if (file->f_op->write_iter)
        ret = new_sync_write(file, buf, count, pos);
    else
        ret = -EINVAL;

ユーザプログラムがwrite処理が実行すると、ファイルシステム固有のwrite処理またはwrite_iterを実行する。

  • 両方とも定義されている場合: write処理を実行する
  • 片方のみ定義されている場合: 定義されている方を実行する
  • 両方とも定義されていない場合: -EINVALを返す

まずは、write処理が定義されている場合を考える。
write処理の場合、「ユーザ空間にある書き込み対象のデータのあるアドレス」、「file構造体」、「書き込み対象のデータの長さ」の3つを引数とする。

write処理における各データ構造の関係性

次に、write_iter処理のみ定義されている場合を考える。 write_iter処理の場合、new_sync_write関数を呼び出し、引数に渡すstruct kiocbstruct iov_iterの設定をする。

// 496:
static ssize_t new_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = (void __user *)buf, .iov_len = len };
    struct kiocb kiocb;
    struct iov_iter iter;
    ssize_t ret;

    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = (ppos ? *ppos : 0);
    iov_iter_init(&iter, WRITE, &iov, 1, len);

    ret = call_write_iter(filp, &kiocb, &iter);
    BUG_ON(ret == -EIOCBQUEUED);
    if (ret > 0 && ppos)
        *ppos = kiocb.ki_pos;
    return ret;
}

まずは、それぞれのデータ構造がどのようにつながっているのかを下記に示す。(赤字の値は例である)

write_iter処理における各データ構造の関係性

  • struct iovec (IO vector): 書き込み対象のデータに関する情報を格納する
    • 「ユーザ空間のデータのアドレス」と「書き込み対象のデータの長さ」を持つ
  • struct kiocb (kernel IO control block?): 書き込み時に必要となるメタ情報を格納する
    • file構造体へのポインタ」や「オープンの時に指定したフラグ(の一部)」などを持つ
  • struct iov_iter (IO vector iterator?): 複数の書き込み対象のデータに関する情報を管理する
    • iovec(など)構造体のポインタ」や「iovec構造体をいくつ管理しているか」、「データの合計の長さ」などの情報を持つ

これらをまとめると、今回はこれらの構造体に下記のようなパラメータが設定される。

構造体名 メンバ名
iovec iov_base ユーザ空間のバッファへのポインタ
iov_len 6
kiocb ki_filp 対応するファイルのファイル構造体へのポインタ
ki_pos 0
ki_complete 0
private 0
ki_flags IOCB_APPEND
ki_hint WRITE_LIFE_NOT_SET
ki_ioprio 0
iov_iter type WRITE | ITER_IOVEC
data_source true
iov_offset 0
count 6
iovec iovec構造体へのポインタ
nr_segs 1
head 1
start_head 0
xattar_start 1

init_syc_kiocb関数はkiocb構造体を設定し、 iov_iter_init関数はiov_iter構造体を設定する。
ここでは、これらの関数の詳細については解説しない。下記はそれらの関数である。

// 2325:
static inline void init_sync_kiocb(struct kiocb *kiocb, struct file *filp)
{
    *kiocb = (struct kiocb) {
        .ki_filp = filp,
        .ki_flags = iocb_flags(filp),
        .ki_hint = ki_hint_validate(file_write_hint(filp)),
        .ki_ioprio = get_current_ioprio(),
    };
}
// 463:
void iov_iter_init(struct iov_iter *i, unsigned int direction,
            const struct iovec *iov, unsigned long nr_segs,
            size_t count)
{
    WARN_ON(direction & ~(READ | WRITE));
    *i = (struct iov_iter) {
        .iter_type = ITER_IOVEC,
        .data_source = direction,
        .iov = iov,
        .nr_segs = nr_segs,
        .iov_offset = 0,
        .count = count
    };
}
EXPORT_SYMBOL(iov_iter_init);

これらの構造体の設定した後に、call_write_iter関数によりファイルシステム固有のwrite_iter処理を実行する。

// 2160:
static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
                      struct iov_iter *iter)
{
    return file->f_op->write_iter(kio, iter);
}

write_iter処理を実行した後、オフセットが移動した場合には更新をしてから終了する。

監視中のユーザプログラムにイベントを通知する

vfs_write関数の下記の部分に該当する。

// 597:
    if (ret > 0) {
        fsnotify_modify(file);
        add_wchar(current, ret);
    }
    inc_syscw(current);

Linuxでは、ファイルへの書き込み (≠ ストレージへの書き出し) に関する情報をユーザプロセスに通知することができる。
ここでは、二つの通知の手法について説明する。

  • Taskstats
  • fsnotify

taskstats

taskstats はカーネルからユーザ空間のプロセスにタスクの統計情報やプロセスの統計情報を送信するためのインターフェイスである。

CONFIG_TASK_XACCTが有効の場合、Taskstatsで取得できる項目に「writeシステムコールを実行した回数」と「書き込みした総バイト数」が追加される。

// 11:
#ifdef CONFIG_TASK_XACCT
static inline void add_rchar(struct task_struct *tsk, ssize_t amt)
{
    tsk->ioac.rchar += amt;
}

static inline void add_wchar(struct task_struct *tsk, ssize_t amt)
{
    tsk->ioac.wchar += amt;
}

static inline void inc_syscr(struct task_struct *tsk)
{
    tsk->ioac.syscr++;
}

static inline void inc_syscw(struct task_struct *tsk)
{
    tsk->ioac.syscw++;
}

そのため、vfs_write関数で書き込みが終了したタイミングで、add_wchar関数とinc_syscw関数を呼び出して統計情報の更新をする。

ちなみに、CONFIG_TASK_XACCTが無効の場合は、何も実行されない。

// 31:
#else
static inline void add_rchar(struct task_struct *tsk, ssize_t amt)
{
}

static inline void add_wchar(struct task_struct *tsk, ssize_t amt)
{
}

static inline void inc_syscr(struct task_struct *tsk)
{
}

static inline void inc_syscw(struct task_struct *tsk)
{
}
#endif

taskstasについては、カーネルドキュメントに詳細な説明が記載されているので、そちらを要参照。

fsnotify

fsnotifyは、ファイルシステム上でのイベント通知機構のバックエンドとなる機構である。
fsnotifyは、dnotify, inotify, fanotifyなどの通知機構のための基盤となっている。

  • dnotify: ディレクトリの状態変化を通知する機構。のちにinotifyに取って代わる。
  • inotify: ファイルの状態変化を通知する機構。
  • fanotity: ファイルシステムの状態変化を通知する機能。

これらの機構の特徴をまとめると下記のようになる。

項目 dnotify inotify fanotify
権限 ユーザー権限 ユーザー権限 要root権限
監視範囲 ディレクト ファイル マウントポイント
ツール - https://github.com/inotify-tools/inotify-tools -
制約 リムーバルメディア非対応 アクセス許可の判定は不可能 create、delete、moveに関するイベントがサポートされていない

これらの機構について詳しく知りたいのであれば、下記のサイトを拝見することを推奨する。

https://blog.1mg.org/20190803_01/blog.1mg.org

www.nminoru.jp

これらを踏まえると、vfs_write関数で書き込みが終了したタイミングで、fsnotify_modify関数によりイベントを(必要に応じて)通知させる必要がある。

fsnotifyの仕組みについてソースコードを追いかけるとなると膨大になってしまうので、ここでは説明を省略する。
下記に、一部のみ掲載してあるが、やっていることはファイルに変更があったことを(必要に応じて)通知する処理である。

// 262:
static inline void fsnotify_modify(struct file *file)
{
    fsnotify_file(file, FS_MODIFY);
}
// 92:
static inline int fsnotify_file(struct file *file, __u32 mask)
{
    const struct path *path = &file->f_path;

    if (file->f_mode & FMODE_NONOTIFY)
        return 0;

    return fsnotify_parent(path->dentry, mask, path, FSNOTIFY_EVENT_PATH);
}

おわりに

本記事では、VFS(vfs_write関数)からファイルシステム固有のwrite(またはwrite_iter操作)を呼び出すまでを解説した。次回の記事で、ファイルシステム固有のwrite_iter操作を解説したいと思う。

変更履歴

  • 2020/12/25: 記事公開
  • 2020/12/31: VFSオブジェクトの説明を追加
  • 2022/08/28: カーネルバージョンを5.15に変更

参考