LeavaTailの日記

LeavaTailの日記

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

Ubuntu-base-20.04 を QEMU から NFSRootで起動する

要旨

QEMUでエミュレートした armhfプロセッサ に Linuxを起動させ 、Ubuntu-base-20.04イメージをNFS mountする。

変更履歴

  • 2021/10/10: 記事公開

はじめに

Ubuntu baseは、最低限のUbuntuのrootfsである。 そのため、LXCやDockerのコンテナイメージで使われることがある。

一方でrootfsをNFS server上に置き、rootfsをNFS経由でmountする手法は、(主に組込み機器の)開発において有効である。*1

そこで、本記事では、QEMU上でarmhfプロセッサをエミュレートさせて、Ubuntu-base-20.04をrootfsとしてNFS mountする方法を記述する。

QEMUでarmhf用 Linuxカーネルを起動させる方法は、下記の記事を流用する。

leavatail.hatenablog.com

leavatail.hatenablog.com

実行環境

Host PCは下記のような環境で実験する。

項目 概要
Host PC (OS) Ubuntu 20.04.02
Architecture x86_64
QEMU qemu v5.1.0
nfs-kernlel-server 1:1.3.4-2.5ubuntu3.4
buildroot buildroot-2020.11

上記の環境で、QEMUを実行しゲストOSとしてLinux v5.10.1を起動させる。

f:id:LeavaTail:20211010112112p:plain
動作環境の全体像

手順

  1. Host PCに下記パッケージをインストールする。

     $ sudo apt-get install nfs-kernel-server
    
  2. Host PCでNFSサーバの設定する

     $ echo "/srv/rootfs     127.0.0.1(rw,wdelay,insecure,no_root_squash,no_subtree_check,sec=sys,rw,insecure,no_root_squash,no_all_squash)" | sudo tee -a /etc/exports
     $ sudo exportfs -v
    
  3. Host PCにUbuntu-base-20.04 のイメージをダウンロードする

     $ wget -P /tmp http://cdimage.ubuntu.com/ubuntu-base/releases/20.04/release/ubuntu-base-20.04.3-base-armhf.tar.gz
     $ sudo tar xf /tmp/ubuntu-base-20.04.3-base-armhf.tar.gz -C /srv/rootfs/armhf
    
  4. [任意] Host PCに下記パッケージをインストールする。

     $ sudo apt-get install qemu-user-static
    
  5. [任意] Ubuntu-base-20.04 の rootfs に qemu-arm-staticをコピーする

     $ sudo update-binfmts --display | grep arm
     qemu-arm (enabled):
      interpreter = /usr/bin/qemu-arm-static
     qemu-armeb (enabled):
      interpreter = /usr/bin/qemu-armeb-static
    
     $ sudo cp /usr/bin/qemu-arm-static /srv/rootfs/armhf/usr/bin/    
    
  6. rootfs の initスクリプトを追加する

#!/bin/bash

PATH=/sbin:/bin:/usr/sbin:/usr/sbin

set -em
trap rescue ERR

function rescue {
        echo -e "\e[31m NG \e[m"
        exec /bin/sh
}

echo -n "[init] Connect console:"
if (exec 0</dev/console) 2>/dev/null; then
    exec 0</dev/console
    exec 1>/dev/console
    exec 2>/dev/console
fi
echo -e "\e[32m OK \e[m"

echo -n "[init] Mount filesystem:"
# mount -t devtmpfs udev /dev
mount -t proc /proc /proc
mount -t sysfs sysfs /sys
echo -e "\e[32m OK \e[m"

echo -n "[init] Mount filesystem additionaly:"
mount -t tmpfs -o size=32m tmpfs /tmp
if [ -d "/dev/pts" ]; then
        mount -t devpts /dev/pts /dev/pts
fi

if [ -d "/sys/kernel/debug" ]; then
        mount -t debugfs none /sys/kernel/debug
fi
echo -e "\e[32m OK \e[m"

exec setsid /sbin/agetty --long-hostname --autologin root -s ttyAMA0 115200,38400,9600 linux

起動確認

$ sudo qemu-system-arm -M vexpress-a9 \
        -smp 1 \
        -m 1024 \
        -kernel output/images/zImage \
        -dtb output/images/vexpress-v2p-ca9.dtb \
        -append "console=ttyAMA0,115200 rootwait ip=on root=/dev/nfs nfsroot=/srv/rootfs/armhf user_debug=31 rw" \
        -net nic,model=lan9118 \
        -net user \
        -nographic

... 

VFS: Mounted root (nfs filesystem) on device 0:14.
devtmpfs: mounted
Freeing unused kernel memory: 1024K
Run /sbin/init as init process
random: fast init done
[init] Connect console: OK 
[init] Mount filesystem: OK 
[init] Mount filesystem additionaly: OK 

Ubuntu 20.04.3 LTS 10.0.2.15 ttyAMA0

10.0.2.15 login: root (automatic login)

Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.10.1 armv7l)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Sun Oct 10 11:57:52 JST 2021 on ttyAMA0
root@10:~# 

補足

ここでは、手順や起動確認で解説できなかった内容について補足する。

NFSの設定について

QEMUのネットワークは複数の設定ができる。

Documentation/Networking - QEMU

今回は、User Networking を使用しているため、ゲストからホストはNATで扱われる。
そのため、NFSの設定でローカルループバックアドレスに任意のポートから受け付けるようにしている。*2

rootfsの更新について

x86_64アーキテクチャから、armhfのrootfsを直接chrootすることはできない。
そこで、qemu-user-staticを介することでrootfsを操作できるようにしている。

このようにしておくことで、Ubuntu-base 20.04に、systemdがインストールするなどが容易にできる。
その場合は、名前解決できるように適切に設定しておく必要はある。

  # echo  nameserver 8.8.8.8 >> /etc/resolv.conf

おわりに

本記事では、QEMU上でarmhfプロセッサをエミュレートさせて、Ubuntu-base-20.04をrootfsとしてNFS mountする方法を記述した。

Ubuntu baseイメージは最小のrootfsであるため、systemdを追加でインストールしたりなどカスタマイズ性が高い。

参考

QEMUの設定関連

Ubuntu-base関連

initramfs関連

rootfs関連

*1:今回はQEMUで起動させているので、恩恵は小さいが

*2:ポートフォワーディングやブリッジ接続をすれば、ここも@変えることはできる

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

関連記事

変更履歴

  • 2021/10/09: 記事公開

はじめに

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

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

f:id:LeavaTail:20201231223348p:plain
調査対象

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

ファイルシステム: write_end

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

このwrite_begin操作やwrite_end操作はページキャッシュに対する操作となっており、address_space_opearationsのメンバの一つとして定義される。
write_end操作は、write_iter操作(で呼び出されるgeneric_perform_write関数)から呼び出される。

// 3340:
        status = a_ops->write_end(file, mapping, pos, bytes, copied,
                        page, fsdata);

f:id:LeavaTail:20211003183939p:plain
write_end操作開始時の全体イメージ

変数名
file オープンしたファイル
mapping ファイルが持っているページキャッシュのXArray(radix-tree)
pos 書き込み先の位置
bytes 書き込むバイト数
copied ページキャッシュにコピーされたバイト数
page 取得したページを格納する
fsdata ext2ファイルシステムでは使用しない

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

// 896:
static int ext2_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    int ret;

    ret = generic_write_end(file, mapping, pos, len, copied, page, fsdata);
    if (ret < len)
        ext2_write_failed(mapping, pos + len);
    return ret;
}

ext2ファイルシステムでは、汎用APIgeneric_write_end関数を呼び出す。

// 2168:
int generic_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    struct inode *inode = mapping->host;
    loff_t old_size = inode->i_size;
    bool i_size_changed = false;

    copied = block_write_end(file, mapping, pos, len, copied, page, fsdata);

    /*
    * No need to use i_size_read() here, the i_size cannot change under us
    * because we hold i_rwsem.
    *
    * But it's important to update i_size while still holding page lock:
    * page writeout could otherwise come in and zero beyond i_size.
    */
    if (pos + copied > inode->i_size) {
        i_size_write(inode, pos + copied);
        i_size_changed = true;
    }

    unlock_page(page);
    put_page(page);

    if (old_size < pos)
        pagecache_isize_extended(inode, old_size, pos);
    /*
    * Don't mark the inode dirty under page lock. First, it unnecessarily
    * makes the holding time of page lock longer. Second, it forces lock
    * ordering of page lock and transaction start for journaling
    * filesystems.
    */
    if (i_size_changed)
        mark_inode_dirty(inode);
    return copied;
}
EXPORT_SYMBOL(generic_write_end);

generic_write_end関数の処理は下記の通りとなっている。

  • ページキャッシュのフラグを更新する
  • バッファキャッシュのフラグを更新する
  • ファイルサイズを更新する

まず初めに、メインとなるblock_write_end関数から確認していく。

キャッシュのフラグの更新

block_write_end関数は下記のような定義となっている。

// 2132:
int block_write_end(struct file *file, struct address_space *mapping,
            loff_t pos, unsigned len, unsigned copied,
            struct page *page, void *fsdata)
{
    struct inode *inode = mapping->host;
    unsigned start;

    start = pos & (PAGE_SIZE - 1);

    if (unlikely(copied < len)) {
        /*
        * The buffers that were written will now be uptodate, so we
        * don't have to worry about a readpage reading them and
        * overwriting a partial write. However if we have encountered
        * a short write and only partially written into a buffer, it
        * will not be marked uptodate, so a readpage might come in and
        * destroy our partial write.
        *
        * Do the simplest thing, and just treat any short write to a
        * non uptodate page as a zero-length write, and force the
        * caller to redo the whole thing.
        */
        if (!PageUptodate(page))
            copied = 0;

        page_zero_new_buffers(page, start+copied, start+len);
    }
    flush_dcache_page(page);

    /* This could be a short (even 0-length) commit */
    __block_commit_write(inode, page, start, start+copied);

    return copied;
}
EXPORT_SYMBOL(block_write_end);

別スレッドでなどバッファに更新があった場合は、2141行で新規バッファキャッシュの確保など実施する。

今回のケースでは、flush_dcache_page関数でdcacheをフラッシュしたのちに、__block_commit_write関数を呼び出す。

flush_dcache_page関数については下記の記事にて解説済み。

leavatail.hatenablog.com

__block_commit_write関数には、書き込み先のinodeと、書き込み対象のページキャッシュpage, 書き込み対象が記載されているオフセット (ページキャッシュの先頭から)start, 書き込み対象が記載されている末尾 start+copiedを渡す。

__block_commit_write関数の定義は下記の定義となっている。

// 2065:
static int __block_commit_write(struct inode *inode, struct page *page,
        unsigned from, unsigned to)
{
    unsigned block_start, block_end;
    int partial = 0;
    unsigned blocksize;
    struct buffer_head *bh, *head;

    bh = head = page_buffers(page);
    blocksize = bh->b_size;

    block_start = 0;
    do {
        block_end = block_start + blocksize;
        if (block_end <= from || block_start >= to) {
            if (!buffer_uptodate(bh))
                partial = 1;
        } else {
            set_buffer_uptodate(bh);
            mark_buffer_dirty(bh);
        }
        clear_buffer_new(bh);

        block_start = block_end;
        bh = bh->b_this_page;
    } while (bh != head);

    /*
    * If this is a partial write which happened to make all buffers
    * uptodate then we can optimize away a bogus readpage() for
    * the next read(). Here we 'discover' whether the page went
    * uptodate as a result of this (potentially partial) write.
    */
    if (!partial)
        SetPageUptodate(page);
    return 0;
}

__block_commit_write関数では、各バッファキャッシュのフラグを更新していく。
また、ページキャッシュstruct page構造体からバッファキャッシュstruct buffer_headの取得には、page_buffersマクロを使用する。

// 140:
/* If we *know* page->private refers to buffer_heads */
#define page_buffers(page)                 \
   ({                            \
       BUG_ON(!PagePrivate(page));         \
       ((struct buffer_head *)page_private(page));    \
   })

PagePrivateマクロにより、struct page構造体にprivateが設定されているかどうか確認する。
struct buffer_head構造体はprivateに関連付けれらているため、これがFalseとなる場合はバグであるのでカーネルパニックさせる。

// 246:
#define page_private(page)     ((page)->private)

struct page構造体に紐づけられている各struct buffer_head構造体に対して、フラグの更新をする。
更新されたデータにBH_DirtyBH_Uptodateのフラグを付与する。

f:id:LeavaTail:20211005221127p:plain
バッファキャッシュのフラグを更新する

BH_Dirtyのフラグを更新する際には、mark_buffer_dirty関数で実施する。 mark_buffer_dirty関数の定義は下記の通りとなっている。

// 1111:
void mark_buffer_dirty(struct buffer_head *bh)
{
    WARN_ON_ONCE(!buffer_uptodate(bh));

    trace_block_dirty_buffer(bh);

    /*
    * Very *carefully* optimize the it-is-already-dirty case.
    *
    * Don't let the final "is it dirty" escape to before we
    * perhaps modified the buffer.
    */
    if (buffer_dirty(bh)) {
        smp_mb();
        if (buffer_dirty(bh))
            return;
    }

    if (!test_set_buffer_dirty(bh)) {
        struct page *page = bh->b_page;
        struct address_space *mapping = NULL;

        lock_page_memcg(page);
        if (!TestSetPageDirty(page)) {
            mapping = page_mapping(page);
            if (mapping)
                __set_page_dirty(page, mapping, 0);
        }
        unlock_page_memcg(page);
        if (mapping)
            __mark_inode_dirty(mapping->host, I_DIRTY_PAGES);
    }
}
  • buffer_dirtyマクロ: struct buffer_headb_stateBH_Dirtyが設定されていた場合にTrueを返す。
  • test_set_buffer_dirrtyマクロ: struct buffer_headb_stateBH_Dirtyをセットする。
    • マクロ実施前にBH_Dirtyが設定されていない場合はFalseを返す。
    • マクロ実施後にBH_Dirtyが設定されていない場合はTrueを返す。

mark_buffer_dirty関数では、初めに別のスレッドで同一のstruct buffer_headがdirtyになっていた場合は、何もせずに処理を終了させる。

cgroup系の処理(lock_page_memcgunlock_page_memcg)については、省略する。

test_set_buffer_dirtyTestSetPageDirtyによって、バッファキャッシュとページキャッシュにDirtyフラグを付与する。

f:id:LeavaTail:20211006220136p:plain
バッファキャッシュとページキャッシュにDirtyフラグを付与する

__mark_inode_dirty関数は、writeback用のリストに対象のinodeを追加する。
ただし、下記の記事にてリストに追加されているので、ここでは何も実施しない。

leavatail.hatenablog.com

下記のコードで、was_dirtyI_DIRTY_SYNC (0x1)が代入される。

// 2284:
    if ((inode->i_state & flags) != flags) {
        const int was_dirty = inode->i_state & I_DIRTY;

その後の処理は、!was_dirtyで判定されるため、このタイミングでは処理をせず戻る。

// 2313:
        /*
        * 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) {
        ...

ここで、generic_write_end関数の2176行に処理が戻る。

ファイルサイズの更新

ファイルのもともとのサイズinode->i_sizecopiedだけ増えるので、ファイルサイズの更新処理が入る。

// 2178:
    /*
    * No need to use i_size_read() here, the i_size cannot change under us
    * because we hold i_rwsem.
    *
    * But it's important to update i_size while still holding page lock:
    * page writeout could otherwise come in and zero beyond i_size.
    */
    if (pos + copied > inode->i_size) {
        i_size_write(inode, pos + copied);
        i_size_changed = true;
    }

i_size_write関数は、割り込みなどを考慮してinode->i_sizeを更新する。
i_size_read関数と内容が似ているため、ここでは省略する。

おわりに

本記事では、ext2ファイルシステムwrite_end操作(ext2_write_end)を解説した。
write_end操作は、write_iter操作で書き込んだキャッシュにDirtyフラグを立てて、ファイルのサイズの更新するための操作である。

参考

様々なSDカードの読み込み/書き込み性能の実測値を計測する

変更履歴

  • 2021/9/11: 記事公開

概要

SDカードには規格が定義されており、それぞれの製品には準拠した規格が記載されていることが多い。
本記事では、4種類のSDカードと2種類のSDカードリーダを用いて、それぞれ読み込み/書き込み性能を測定した。

その結果、下記のような傾向が見られた。

  • ファイルシステムを介することによる書き込み性能が大きく低下する
  • SDカードによって性能が最も発揮できるブロックサイズが異なる
  • Raspberry Pi 4の場合だと、並列処理によりパフォーマンスは低下する
  • UHS-II 非対応のカードリーダの場合、UHS-II対応のSDカードの性能を発揮できない

はじめに

SDカードには規格が定義されており、それぞれの製品には準拠した規格が記載されていることが多い。
ユースケースに応じて、適切なSDカードを使用することでその製品を最大限に生かすことができる。 しかし、これら規格は数が多く複雑なものが多い。

そこで複数のSDカードを計測することで、実測値と規格の関係性を調査する。

調査には、未使用のSDカードを4種類を使用し、Raspberriy Pi 4上で計測する。 このとき、UHS-II の影響も調査するために2種類のカードリーダ(UHS-II 未対応、UHS-II 対応) を用意し、 UHS-II対応カードリーダとの組み合わせも考慮する。

目的

  1. ファイルシステムを介することによるオーバーヘッドを計測する
  2. SDカードの違いによってパフォーマンスが異なることを確認する
  3. ブロックサイズの違いによってパフォーマンスが異なることを確認する
  4. 並列処理させることでパフォーマンスが異なることを確認する
  5. UHS-II 非対応のカードリーダがボトルネックになることを確認する

実行環境

Raspberry Pi 4 Model B (Raspberry Pi 4) は microSDカード経由でRaspberry Pi OSを起動させることとする。 また、Raspbery Pi 4のUBS3.0ポートにSDカードリーダを接続し、そこから計測対象SDカードを挿入する。

f:id:LeavaTail:20210911165944p:plain
計測環境の概要

ここで使用するRaspberry Pi 4のスペックについて、必要な情報だけ抜粋したものを下記に示す。

項目 Raspberry Pi 4
CPU Cortex-A72 (ARM v8) 1.5GHz
メモリ 4GB LPDDR4-3200
OS Raspberry Pi OS (May 7th 2021)
OS格納先ストレージ microSDHC 16GB Class10 UHS-1
ケース 陽極酸化アルミニウム製ヒートシンクケース

今回は、SDカードリーダによる性能の差を図るために下記の二つを用意した。

  • BSCR27U3BK: UHS-IIに対応していないSDカードリーダ
  • MRW-S1: UHS-IIに対応したSDカードリーダ

上記の環境に対して、下記4種類のSDカードを計測する。(カタログに記載されていないものは、空欄としている)

ELITE SDXC UHS-I SanDisk Extreme Pro(並行輸入品) SF-E64 SF-M64T
SDスピードクラス CLASS10 CLASS10 CLASS10
UHSスピードクラス U3 U3 U3 U3
ビデオスピードクラス V30 V30 V30 V60
インタフェース UHS-I UHS-I UHS-II UHS-II
容量 64GB 64GB 64GB 64GB
最大読み出し速度 90 MB/s 170 MB/s 270 MB/s 277 MB/s
最大書き込み速度 45MB/s 90 MB/s 70 MB/s 150 MB/s

計測方法

計測には、Flexible I/O tester-3.12 (fio) を用いて、読み込みと書き込みの性能を計測する。
fioでは、パラメータを指定することで様々な計測を実施することができる。
本実験では、bsnumjobsfilename の値を変更するような計測を実施する。

  • bs: ユーザプログラムから読み込み・書き込みをするの一回当たりのサイズ。4KB・1MB・256MBに変更して確認する。
  • numjobs: 計測プログラムを並列して複数個のプロセス/スレッドで実行する。1・4・8に変更して確認する。
  • filename: 計測プログラムの読み込み・書き込み先のファイルを指定する。「ファイルシステム以下のファイル」と「デバイスファイル」の二つを確認する。

そこで、下記のようなパラメータファイルを用意し、これらの値を書き換えながら計測することにした。

[global]
size=1G
directory=/mnt
runtime=300
invalidate=1
group_reporting=1
numjobs=1 # FIXME

[write]
name=write
description="WRITE TEST"
unlink=0
rw=randwrite # randread
ioengine=sync
exec_prerun="./prerun.sh"
#exec_postrun="./postrun.sh"
#filename=/dev/sda1
time_based
bs=4K # FIXME
// 1:
#!/bin/sh

echo 3 > /proc/sys/vm/drop_caches

また、SDカードの状態による測定値のブレを最小限に抑えるために、測定前にSDカードをSD Card Formatter 5.0.1の上書きフォーマットを実施しておく。

実験結果

ファイルシステムによるオーバーヘッド

f:id:LeavaTail:20210906000731p:plain
ファイルシステムを介することによる書き込み性能の比較

  • ファイルシステムへの書き込みは、デバイスファイルに直接書き込む場合と比較して、オーバーヘッドが出ている。
    • さらに、並列処理されている場合のオーバーヘッドは顕著に出ている。

f:id:LeavaTail:20210906000803p:plain
ファイルシステムを介することによる読み込み性能の比較

SDカードの違い

f:id:LeavaTail:20210911123332p:plain
SDカードの違いによる書き込み性能の比較

  • 一部例外はあったが、カタログに載っている最大書き込み速度と傾向は同じになっている。
    • SF-M64Tは、ブロックサイズが小さい場合に性能が出ていない傾向にあった。

f:id:LeavaTail:20210911123340p:plain
SDカードの違いによる読み込み性能の比較

  • 一部例外はあったが、カタログに載っている最大読み込み速度と傾向は同じになっている。
    • SF-E64はブロックサイズが小さい場合に性能が出ていない傾向にあった。

ブロックサイズの違い

f:id:LeavaTail:20210911124618p:plain
ブロックサイズの違いによる書き込み性能の比較

  • ブロックサイズが大きくすれば、書き込み性能は向上した (~256MB)

f:id:LeavaTail:20210911124642p:plain
ブロックサイズの違いによる読み込み性能の比較

  • ブロックサイズが大きくすれば、読み込み性能は向上した (~256MB)

UHS-II 非対応によるオーバーヘッド

f:id:LeavaTail:20210910233901p:plain
UHS-II非対応カードリーダによる書き込み性能の比較

f:id:LeavaTail:20210910234228p:plain
UHS-II非対応カードリーダによる読み込み性能の比較

  • 読み込み性能・書き込み性能の双方、UHS-II非対応のカードリーダでは UHS-Iの転送速度104MB/sより下回っている

並列処理によるオーバーヘッド

f:id:LeavaTail:20210911002459p:plain
並列処理による書き込み性能の比較

f:id:LeavaTail:20210911002522p:plain
並列処理による読み込み性能の比較

  • 基本的に並列処理数を増やすことで性能が落ちている。

結論

  • ファイルシステムを介することによる書き込み性能が大きく低下する傾向がある
  • SDカードの違いによってパフォーマンスは異なる
    • また、SDカードによって性能が最も発揮できるブロックサイズが異なる
  • Raspberry Pi 4の場合だと、並列処理によりパフォーマンスは低下する
  • UHS-II 非対応のカードリーダの場合、UHS-II対応のSDカードの性能を発揮できない

おわりに

本記事では、さまざまな環境で読み込み書き込み性能を測定した。
SDカード・ブロックサイズ・並列処理といった要因がパフォーマンスに大きく影響を与えていた。

参考

補足

実験で得られたデータを本章に残す。

SD card Card Reader Block size jobs io File performance
ELITE SDXC UHS-I MRW-S1 4KB 1 randwrite Default 1409KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randwrite Default 483KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randwrite Default 567B/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randwrite Default 23.1MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randwrite Default 9877KB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randwrite Default 148KB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randwrite Default 32.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randwrite Default 19.4MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randwrite Default 12.8MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 1 randwrite Default 31.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 4 randwrite Default 20.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 8 randwrite Default 13.4MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randwrite /dev/sda1 1556KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randwrite /dev/sda1 1523KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randwrite /dev/sda1 1573MB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randwrite /dev/sda1 29.1MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randwrite /dev/sda1 26.9MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randwrite /dev/sda1 25.7MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randwrite /dev/sda1 32.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randwrite /dev/sda1 27.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randwrite /dev/sda1 24.9MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randread Default 4685KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randread Default 4906KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randread Default 4862KB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randread Default 71.4MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randread Default 59.3MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randread Default 61.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randread Default 79.7MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randread Default 66.6MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randread Default 65.5MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 1 randread Default 60.2MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 4 randread Default 58.0MB/s
ELITE SDXC UHS-I BSCR27U3BK 256MB 8 randread Default 58.2MB/s
ELITE SDXC UHS-I MRW-S1 4KB 1 randread /dev/sda1 4644KB/s
ELITE SDXC UHS-I MRW-S1 4KB 4 randread /dev/sda1 4668KB/s
ELITE SDXC UHS-I MRW-S1 4KB 8 randread /dev/sda1 4850MB/s
ELITE SDXC UHS-I MRW-S1 1MB 1 randread /dev/sda1 68.8MB/s
ELITE SDXC UHS-I MRW-S1 1MB 4 randread /dev/sda1 54.8MB/s
ELITE SDXC UHS-I MRW-S1 1MB 8 randread /dev/sda1 54.1MB/s
ELITE SDXC UHS-I MRW-S1 256MB 1 randread /dev/sda1 83.1MB/s
ELITE SDXC UHS-I MRW-S1 256MB 4 randread /dev/sda1 65.0MB/s
ELITE SDXC UHS-I MRW-S1 256MB 8 randread /dev/sda1 62.8MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randwrite Default 1950KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randwrite Default 462KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randwrite Default 369B/s
SanDisk Extreme Pro MRW-S1 1MB 1 randwrite Default 49.7MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randwrite Default 7411KB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randwrite Default 85.4KB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randwrite Default 72.9MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randwrite Default 17.5KB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randwrite Default 9782KB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randwrite /dev/sda1 1907KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randwrite /dev/sda1 1466KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randwrite /dev/sda1 2005KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randwrite /dev/sda1 85.1MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randwrite /dev/sda1 66.0MB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randwrite /dev/sda1 62.1MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randwrite /dev/sda1 73.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randwrite /dev/sda1 35.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randwrite /dev/sda1 31.6MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randread Default 5513KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randread Default 5753KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randread Default 5720KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randread Default 72MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread Default 48.1MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread Default 54.6MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randread Default 83.0MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randread Default 66.1MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randread Default 57.3MB/s
SanDisk Extreme Pro MRW-S1 4KB 1 randread /dev/sda1 5431KB/s
SanDisk Extreme Pro MRW-S1 4KB 4 randread /dev/sda1 5724KB/s
SanDisk Extreme Pro MRW-S1 4KB 8 randread /dev/sda1 5722KB/s
SanDisk Extreme Pro MRW-S1 1MB 1 randread /dev/sda1 64.3MB/s
SanDisk Extreme Pro MRW-S1 1MB 4 randread /dev/sda1 55.0MB/s
SanDisk Extreme Pro MRW-S1 1MB 8 randread /dev/sda1 55.9MB/s
SanDisk Extreme Pro MRW-S1 256MB 1 randread /dev/sda1 80.0MB/s
SanDisk Extreme Pro MRW-S1 256MB 4 randread /dev/sda1 63.2MB/s
SanDisk Extreme Pro MRW-S1 256MB 8 randread /dev/sda1 64.7MB/s
SF-E64 MRW-S1 4KB 1 randwrite Default 1456KB/s
SF-E64 MRW-S1 4KB 4 randwrite Default 1976KB/s
SF-E64 MRW-S1 4KB 8 randwrite Default 5947B/s
SF-E64 MRW-S1 1MB 1 randwrite Default 41.3MB/s
SF-E64 MRW-S1 1MB 4 randwrite Default 14.2MB/s
SF-E64 MRW-S1 1MB 8 randwrite Default 647KB/s
SF-E64 MRW-S1 256MB 1 randwrite Default 60.3MB/s
SF-E64 MRW-S1 256MB 4 randwrite Default 25.3MB/s
SF-E64 MRW-S1 256MB 8 randwrite Default 15.8MB/s
SF-E64 MRW-S1 4KB 1 randread Default 14.5MB/s
SF-E64 MRW-S1 4KB 4 randread Default 8856KB/s
SF-E64 MRW-S1 4KB 8 randread Default 8758KB/s
SF-E64 MRW-S1 1MB 1 randread Default 29.1MB/s
SF-E64 MRW-S1 1MB 4 randread Default 24.9MB/s
SF-E64 MRW-S1 1MB 8 randread Default 23.9MB/s
SF-E64 MRW-S1 256MB 1 randread Default 164MB/s
SF-E64 MRW-S1 256MB 4 randread Default 132MB/s
SF-E64 MRW-S1 256MB 8 randread Default 130MB/s
SF-M64T MRW-S1 4KB 1 randwrite Default 920KB/s
SF-M64T MRW-S1 4KB 4 randwrite Default 67B/s
SF-M64T MRW-S1 4KB 8 randwrite Default 44B/s
SF-M64T MRW-S1 1MB 1 randwrite Default 10.8MB/s
SF-M64T MRW-S1 1MB 4 randwrite Default 19.1KB/s
SF-M64T MRW-S1 1MB 8 randwrite Default 11.0KB/s
SF-M64T MRW-S1 256MB 1 randwrite Default 129MB/s
SF-M64T MRW-S1 256MB 4 randwrite Default 3281KB/s
SF-M64T MRW-S1 256MB 8 randwrite Default 2738 KB/s
SF-M64T BSCR27U3BK 256MB 1 randwrite Default 69.6MB/s
SF-M64T BSCR27U3BK 256MB 4 randwrite Default 2786KB/s
SF-M64T BSCR27U3BK 256MB 8 randwrite Default 2867KB/s
SF-M64T MRW-S1 4KB 1 randread Default 7760KB/s
SF-M64T MRW-S1 4KB 4 randread Default 8418KB/s
SF-M64T MRW-S1 4KB 4 randread Default 8394KB/s
SF-M64T MRW-S1 1MB 1 randread Default 128MB/s
SF-M64T MRW-S1 1MB 4 randread Default 131MB/s
SF-M64T MRW-S1 1MB 8 randread Default 128MB/s
SF-M64T MRW-S1 256MB 1 randread Default 177MB/s
SF-M64T MRW-S1 256MB 4 randread Default 140MB/s
SF-M64T MRW-S1 256MB 8 randread Default 141MB/s
SF-M64T BSCR27U3BK 256MB 1 randread Default 74.9MB/s
SF-M64T BSCR27U3BK 256MB 4 randread Default 69.3MB/s
SF-M64T BSCR27U3BK 256MB 8 randread Default 72.0MB/s

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

関連記事

変更履歴

  • 2021/7/23: 記事公開

はじめに

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

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

f:id:LeavaTail:20201231223348p:plain
調査対象

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

ファイルシステム: ext2ファイルシステム概略

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

f:id:LeavaTail:20210522085717p:plain
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回となっている。

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

f:id:LeavaTail:20210522090319p:plain
データブロックのアドレッシング

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

f:id:LeavaTail:20210723005852p:plain
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;

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

Indirect型について

この関数では、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型のデータ構造は次の通りとなる。

f:id:LeavaTail:20210711161527p:plain
Indirect型のイメージ

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

オフセットの取得

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

// 644:
    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関数のイメージ図は次のとおりである。

f:id:LeavaTail:20210524000315p:plain
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がどのように連結されるのかを表したものが下記となる。

f:id:LeavaTail:20210613223458p:plain
間接ブロックの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関数で登場するデータ構造を表したものが下記となる。

f:id:LeavaTail:20210723021902p:plain
ext2_get_block関数の概要図

参考

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

変更履歴

  • 2021/5/9: 記事公開

はじめに

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のデータ構造は下記の通りとなっている。

f:id:LeavaTail:20210430182914p:plain
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ファイルシステムで計測する。

f:id:LeavaTail:20201231133644p:plain
本記事におけるデバイスとその用途の関係図

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

測定方法

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

f:id:LeavaTail:20210507104309p:plain
測定環境

  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で計測を実施した。

性能計測

f:id:LeavaTail:20210509142718p:plain
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の利用を考えている方は、これらの性能低下を意識してほしい。

f:id:LeavaTail:20210509142331p:plain
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性能の著しい減少が観測された
  • 改ざん検知機能の間でシーケンシャルリード性能の著しい減少が観測された

参考

付録

カーネルコンフィグ

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

関連記事

変更履歴

  • 2021/4/24: 記事公開

はじめに

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

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

f:id:LeavaTail:20201231223348p:plain
調査対象

本記事では、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について再掲する。

f:id:LeavaTail:20210417001359p:plain
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の定義は下記の通り。

// 963:
const struct address_space_operations ext2_aops = {
    .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関数)から呼び出される。

// 3329:
        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関数を実行する。

// 882:
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関数(次回解説)を渡している。

// 2103:
/*
 * block_write_begin takes care of the basic task of block allocation and
 * bringing partial write blocks uptodate first.
 *
 * The filesystem needs to handle block truncation upon failure.
 */
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関数を確認する。

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

// 3266:
/*
 * 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には次のような値が設定されている。(実機で0x100ccaであることを確認済み)

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

pagecache_get_page関数の定義は下記のとおりである。
ただし、今回のケースだと呼び出されない行は独自にコメントアウトしている。

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

repeat:
    page = find_get_entry(mapping, index);
    if (xa_is_value(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;
}

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

// 1691:
struct page *find_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;
}

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

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

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

f:id:LeavaTail:20210306141415p:plain
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

find_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関数で取得したページキャッシュが有効かどうかで処理が変わる。

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

f:id:LeavaTail:20210417001603p:plain
ページキャッシュが有効であるときの状態

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

// 1792:
repeat:
    page = find_get_entry(mapping, index);
    if (xa_is_value(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メンバを使用している
ページキャッシュが存在していない場合

f:id:LeavaTail:20210405234055p:plain
ページキャッシュが存在しない場合

下記のリンクに解説あり。

kernhack.hatenablog.com

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

// 1828:
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関数の定義は下記の通りである。

// 284:
#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を呼び出す。

// 555:
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関数を呼び出す。

// 519:
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);
}

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

// 508:
static inline struct page *
__alloc_pages(gfp_t gfp_mask, unsigned int order, int preferred_nid)
{
    return __alloc_pages_nodemask(gfp_mask, order, preferred_nid, NULL);
}

__alloc_pages関数は、__alloc_pages_nodemask関数を呼び出す。
ただし下記の記事に解説が載っているので、解説は省略する。

kernhack.hatenablog.com

kernhack.hatenablog.com

kernhack.hatenablog.com

grab_cache_page_write_begin関数の結果、指定されたゾーン (今回はZONE_NORMAL) からページを取得できる。

ページ取得した直後のフラグを下記に示す。

フラグ名 説明

f:id:LeavaTail:20210417001746p:plain
__page_cache_alloc関数でページキャッシュを取得した直後の状態

// 1836:
        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;
}

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

f:id:LeavaTail:20210417002049p:plain
ページキャッシュとLRUリスト

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

// 932:
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構造体を関連付ける。(概要は省略する)

f:id:LeavaTail:20210417002128p:plain
address_space構造体とpageの関連付け後

// 462:
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(pvec, page) || PageCompound(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リストに登録する。

f:id:LeavaTail:20210417002321p:plain
LRUにページキャッシュを登録する

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

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

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

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

// 2103:
/*
 * block_write_begin takes care of the basic task of block allocation and
 * bringing partial write blocks uptodate first.
 *
 * The filesystem needs to handle block truncation upon failure.
 */
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関数の定義は下記のとおりである。

// 2058:
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);
}
EXPORT_SYMBOL(__block_write_begin);

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

// 1973:
int __block_write_begin_int(struct page *page, loff_t pos, unsigned len,
        get_block_t *get_block, 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を生成する。

// 1673:
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関数でバッファキャッシュの作成を試みる。

// 1556:
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);
}
EXPORT_SYMBOL(create_empty_buffers);

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

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

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

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

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

f:id:LeavaTail:20210419233548p:plain
バッファキャッシュの確保

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

// 839:
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;

    memcg = get_mem_cgroup_from_page(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);
    mem_cgroup_put(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;
}
EXPORT_SYMBOL_GPL(alloc_page_buffers);

alloc_page_buffers関数では、855行目の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関数は下記の通りとなっている。

// 1443:
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;
}
EXPORT_SYMBOL(set_bh_page);
循環ループを作成する

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

// 1562:
    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をつなげる。

f:id:LeavaTail:20210419234026p:plain
循環リストの更新

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

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

// 1990:
    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;
}

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

フラグ名 説明
PG_locked ページはロック状態
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を発行する。

2045行目を超えると、バッファキャッシュには下記のような値が設定されている。 (今回のケースの限る)

フラグ名 説明
BH_Mapped get_blockハンドラで新しく作成された
BH_New バッファキャッシュはディスクにマッピングされている

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

write_begin処理内で失敗した場合

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

// 882:
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操作でページキャッシュに書き込むための準備をするための操作である。

f:id:LeavaTail:20210417094129p:plain
buffer

参考

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

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

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

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

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

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

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

はじめに

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のオーバーヘッドを測定した。

変更履歴

  • 2021/3/1: 記事公開

fs-verity とは

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

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

f:id:LeavaTail:20201228234304p:plain
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では「署名ファイル検証」をサポートしている。

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

f:id:LeavaTail:20210228113342p:plain
署名付き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: ファイルシステムの機能を利用して、ファイルの改ざんを検知できる機能

f:id:LeavaTail:20201231014348p:plain
dm-verityの概略図

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

gitlab.com

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

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

f:id:LeavaTail:20210228113342p:plain
署名付き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ファイルシステムで計測する。

f:id:LeavaTail:20201231133644p:plain
本記事におけるデバイスとその用途の関係図

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

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の構築手順は下記を参考に、ルートハッシュの署名機能を有効にしてみた。

[http://kernsec.org/pipermail/linux-security-module-archive/2019-July/015347.html:embed:cite]

  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

参考