LeavaTailの日記

LeavaTailの日記

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

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

はじめに

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

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

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

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

変更履歴

  • 2021/4/24: 記事公開

ステータス

変数 補足
sb->s_writers.rw_sem SB_FREEZE_WRITE - 1 指定したCPU個別
inode->i_rwsem 1

ファイルシステム: 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

参考

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

はじめに

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

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

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

本記事では、ext2ファイルシステムwrite操作を解説する。
ただし、write_begin操作とwrite_end操作の解説は次回にまわす。

変更履歴

  • 2021/2/23: 記事公開

ステータス

変数 補足
sb->s_writers.rw_sem SB_FREEZE_WRITE - 1 指定したCPU個別

ファイルシステム: write_iter

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

// 183:
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操作を呼び出し時に渡す引数を下記に再掲する。

f:id:LeavaTail:20201221232242p:plain
再掲: write_iter処理における各データ構造の関係性

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

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

// 3481:
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構造体を更新する可能性がある。
このタイミングで別プロセスから読み書きが入るとファイルの整合性が取れなくなってしまう。
そのため、「書き込み前の正当性を確認する」と「write_iter操作を実施する」にinode_lock関数とinode_unlock関数を挿入している。

// 772:
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関数は、書き込み前の正当性を確認する。

// 1635:
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関数から変数のチェック部分を抜き出したチャートとなっている。

f:id:LeavaTail:20210101230817p:plain
書き込み前のチェック

  1. 書き込み対象がスワップファイルであるか確認する。スワップファイルである場合、positionという概念がないため、汎用的なwrite_iterを実行することができない。その場合、generic_write_checks関数は-ETXTBSYを返す。
  2. 書き込みサイズ count が 0であるかどうか確認する。書き込みサイズが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関数の定義は以下のようになっている。

// 830:
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 = &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によるカウンタを利用する
  • プリエンプション有効 の場合: ファイル長を一部読み込み後にプリエンプションにより別タスクが走る恐れがある
    • 解決方法: その区間のみプリエンプションを無効にする

write_iterのメイン操作

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

// 3393:
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_bdi;
#endif
    return sb->s_bdi;
}

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

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

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

// 1923:
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(dentry, kill);
    if (!error)
        inode_has_no_xattr(inode);

    return error;
}
ファイルのタイムスタンプを更新する

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

// 1964:
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関数のメイン処理を実行する。
関数の定義を下記に示す。

// 1773:
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がサポートされているかどうかを実行する。

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

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

// 1745:
int generic_update_time(struct inode *inode, struct timespec64 *time, int flags)
{
    int iflags = I_DIRTY_TIME;
    bool dirty = false;

    if (flags & S_ATIME)
        inode->i_atime = *time;
    if (flags & S_VERSION)
        dirty = inode_maybe_inc_iversion(inode, false);
    if (flags & S_CTIME)
        inode->i_ctime = *time;
    if (flags & S_MTIME)
        inode->i_mtime = *time;
    if ((flags & (S_ATIME | S_CTIME | S_MTIME)) &&
        !(inode->i_sb->s_flags & SB_LAZYTIME))
        dirty = true;

    if (dirty)
        iflags |= I_DIRTY_SYNC;
    __mark_inode_dirty(inode, iflags);
    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_TIME | I_DIRTY_SYNCが設定されている
  • inode->i_state0が設定されている

これらに着目して、下記にwrite_iter操作でタイムスタンプを更新する場合におけるフローのみ抜粋する。

// 2245:
void __mark_inode_dirty(struct inode *inode, int flags)
{
    /*
    * Paired with smp_mb() in __writeback_single_inode() for the
    * following lockless i_state test.  See there for details.
    */
    smp_mb();
    spin_lock(&inode->i_lock);

    inode->i_state |= flags;

    /*
    * If the inode was already on b_dirty/b_io/b_more_io, don't
    * reposition it (that would break b_dirty time-ordering).
    */
    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;
    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);

    /*
    * 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;
}

__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スレッドを起床させる

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

f:id:LeavaTail:20210118001834p:plain
inode構造体からbdi_writeback構造体への対応関係

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

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

f:id:LeavaTail:20210118001923p:plain
inodeオブジェクトのロックを取得する

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

f:id:LeavaTail:20210118002005p:plain
inodeオブジェクトのフラグを更新する

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

// 1028:
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インライン関数は、container_ofマクロでbdi_writeback構造体を取得する。
下記はinode_to_wbインライン関数の一部の処理のBDEV_Iインライン関数の実装となっている。

// 46:
static inline struct bdev_inode *BDEV_I(struct inode *inode)
{
    return container_of(inode, struct bdev_inode, vfs_inode);
}

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

container_ofマクロについて詳しく知りたい場合は、下記のリンクや書籍を参照してほしい。

kernelhack.hatenablog.com

www.sbcr.jp

f:id:LeavaTail:20210118002052p:plain
inodeオブジェクトのロックを解放する

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

qiita.com

f:id:LeavaTail:20210118002246p:plain
inodeオブジェクトにDirtyになった時刻を更新する

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

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

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

// 2176:
#define I_DIRTY_INODE (I_DIRTY_SYNC | I_DIRTY_DATASYNC)
#define I_DIRTY (I_DIRTY_INODE | I_DIRTY_PAGES)

(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)が対象となる。

f:id:LeavaTail:20210118002542p:plain
bdi_writebackオブジェクトのDirtyリストにinodeオブジェクトを追加する

ただし、今回の場合では、inode->i_io_listは空になっているためリストの付け替えは発生しない。

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

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

今回の環境では、デバイスドライバの登録時にはWB_has_dirty_ioがセットされているため、falseとなる。
つまり、writeback用のプロセスを起床させる必要はないということなので、wb->list_lockを解放して、関数を出る。

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

    spin_unlock(&wb->list_lock);

    if (wakeup_bdi &&
        (wb->bdi->capabilities & BDI_CAP_WRITEBACK))
        wb_wakeup_delayed(wb);
    return;

ちなみに、起床させる必要があった場合、wb_wakeup_delayed関数を呼ぶことになるが詳細は省略する。

// 43:
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を実施する。

// 3288:
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.
        *
        * 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;
        }

        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 = iov_iter_copy_from_user_atomic(page, i, offset, bytes);
        flush_dcache_page(page);

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

        cond_resched();

        iov_iter_advance(i, copied);
        if (unlikely(copied == 0)) {
            /*
            * If we were unable to copy any data at all, we must
            * fall back to a single segment length write.
            *
            * If we didn't fallback here, we could livelock
            * because not all segments in the iov can be copied at
            * once without a pagefault.
            */
            bytes = min_t(unsigned long, PAGE_SIZE - offset,
                        iov_iter_single_seg_count(i));
            goto again;
        }
        pos += copied;
        written += copied;

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

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

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

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

// 3309:
        /*
        * 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. 書き込み対象のファイル用のページをロックしようとし、ページフォールトコードが発生する、デッドロック発生。

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

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

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

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

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

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

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

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

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

// 992:
size_t iov_iter_copy_from_user_atomic(struct page *page,
        struct iov_iter *i, unsigned long offset, size_t bytes)
{
    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_all_kinds(i, bytes, v,
        copyin((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len),
        memcpy_from_page((p += v.bv_len) - v.bv_len, v.bv_page,
                 v.bv_offset, v.bv_len),
        memcpy((p += v.iov_len) - v.iov_len, v.iov_base, v.iov_len)
    )
    kunmap_atomic(kaddr);
    return bytes;
}
EXPORT_SYMBOL(iov_iter_copy_from_user_atomic);

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

f:id:LeavaTail:20210221150605p:plain
ARM memory Layout

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

// 154:
static inline void *kmap_atomic(struct page *page)
{
    preempt_disable();
    pagefault_disable();
    return page_address(page);
}

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

またkmap_atomic関数内では、プリエンプトとページフォールトが無効となっている。

// 1542:
#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インライン関数を呼び出す。

// 1514:
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を取得する。

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

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

// 31:
#if defined(CONFIG_FLATMEM)

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

__page_to_pfnマクロは、CONFIG_FLATMEMの場合には簡単な演算で求めることができる。
ここで、メモリレイアウトと共にイメージしてみる。

f:id:LeavaTail:20210221174411p:plain
page構造体と物理アドレスマッピング

__page_to_pfnマクロは、page構造体からmem_mapを引くことによってオフセットを取得して、ARCH_PFN_OFFSET(0x60000)と足す。

これによって得られた結果を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)を左ビットシフトすることで物理アドレスを取得できる。

// 291:
/*
 * 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マクロは、引数に渡した物理アドレスから対応する仮想アドレスを返す。

f:id:LeavaTail:20210221183950p:plain
page構造体から仮想アドレスの取得

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

// 81:
#define iterate_all_kinds(i, n, v, I, B, K) {          \
   if (likely(n)) {                  \
       size_t skip = i->iov_offset;            \
       if (unlikely(i->type & ITER_BVEC)) {       \
           struct bio_vec v;          \
           struct bvec_iter __bi;         \
           iterate_bvec(i, n, v, __bi, skip, (B))  \
       } else if (unlikely(i->type & ITER_KVEC)) {  \
           const struct kvec *kvec;      \
           struct kvec v;             \
           iterate_kvec(i, n, v, kvec, skip, (K))  \
       } else if (unlikely(i->type & ITER_DISCARD)) {   \
       } else {                  \
           const struct iovec *iov;      \
           struct iovec v;                \
           iterate_iovec(i, n, v, iov, skip, (I))  \
       }                       \
   }                           \
}

対象がITER_IOVECなので、条件式はelse以降を実行する。 else文では、iterate_iovecマクロでI(memcpy関数)を実行する。

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

// 3334:
        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関数の前後でflush_dcache_page関数を実行する。
ここから、flush_dcache_page関数について処理を追っていく。

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

// 534:
/*
 * Might pages of this file have been modified in userspace?
 * Note that i_mmap_writable counts all VM_SHARED vmas: do_mmap
 * marks vma as VM_SHARED if it is shared, and the file was opened for
 * writing i.e. vma may be mprotected writable even if now readonly.
 *
 * If i_mmap_writable is negative, no new writable mappings are allowed. You
 * can only deny writable mappings, if none exists right now.
 */
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方式が採用されていることがわかる。

f:id:LeavaTail:20210221213201p:plain
Physically Indexed Physically Tagged (PIPT)方式

f:id:LeavaTail:20210204230421p:plain
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処理が完了した後に、writebackキューへの登録を解除する。

// 3459:
        if (likely(written > 0))
            iocb->ki_pos += written;
    }
out:
    current->backing_dev_info = NULL;
    return written ? written : err;

まとめ

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

参考

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

はじめに

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が使えるようにセットアップする。

変更履歴

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

実行環境

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

f:id:LeavaTail:20201227050714p:plain
ネットワーク構成図

開発用セグメントに、使用する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の使い方について確認する。

参考

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

はじめに

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

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

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

本記事では、VFSレイヤを対象に解説を始める。

変更履歴

  • 2020/12/25: 記事公開
  • 2020/12/31: VFSオブジェクトの説明を追加

VFS

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

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

f:id:LeavaTail:20201231221748p:plain
VFSオブジェクト関係図

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

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

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

// 585:
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関数の下記の部分に該当する。

// 589:
    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システムコールの引数の書き込み先アドレスを確認する。
このマクロは書き込み先のアドレスと書き込み対象の長さを引数にとる。
ちなみに、このマクロはアーキテクチャによって処理が異なるので注意が必要である。

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

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

// 86:
#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" (current_thread_info()->addr_limit) \
       : "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となっているかのチェック故かと思います。」とのこと。

wiki.bit-hive.com

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

// 90:
    .syntax unified
    adds %1, %2, %3
    sbcscc %1, %1, %0
    movcc %0, #0
名称 レジスタ 対応するデータ コードとの対応関係
出力オペランド 汎用レジスタ flag %0
汎用レジスタ roksum %1
入力オペランド 汎用レジスタ addr %2
リンクレジスタ size %3
出力オペランド0で割り当てたレジスタ current_thread_info()->addr_limit %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 - current_thread_info()->addr_limit;
    if (roksum < 0)
        flag = 1;
    return flag;
}

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

jhalfmoon.com

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

// 83:
static inline struct thread_info *current_thread_info(void)
{
    return (struct thread_info *)
        (current_stack_pointer & ~(THREAD_SIZE - 1));
}
// 8:
register unsigned long current_stack_pointer asm ("sp");

つまり、current_thread_infoインライン関数では、current_stack_pointer変数で指し示すスタックポインタを取得して、下位11ビットをマスクしたアドレスを返している。

これはLinuxカーネルは前提として、プロセス毎にカーネルスタックを持ち、下記のような関係が成り立っているからである。

f:id:LeavaTail:20201219144058p:plain
カーネルスタックとメモリ配置

そして、thread_info構造体のaddr_limitは、ユーザ空間のアドレス上限を示している。

これまでの情報をまとめると、access_okマクロでは参照先のアドレスがユーザ空間に収まっているかを確認する。

f:id:LeavaTail:20201219143246p:plain
access OKとaccess NGのパターン

ファイルのロックを確認する

ソースコードの確認に移る前に、勧告ロック(advisory lock)と強制ロック(mandatory lock)の違いについて説明する。

UNIX系システムでは、複数のプロセスから同じファイルにアクセスしたことによって生じる不整合を防ぐためにファイルロックという機構を提供している。

このファイルロックの手法はいくつか存在し、Linuxでは勧告ロックが標準となっている。
勧告ロックでは、ユーザプログラム側で明示的にロック/アンロックをとる手法となっている。

一方で、Linuxでは強制ロックもサポートしている。
強制ロックでは、カーネル側でロック/アンロックをとる手法となっている。
ただし、Linuxで強制ロックを使用するためには、以下の条件を満たす必要がある。(詳細はman fcntlを参照)

  1. ファイルシステムについて、強制ロックを有効にしている (マウントオプションに-o mandを指定する)
  2. ファイルの権限について、「グループ実行許可が無効 (g-x)」かつ「Set Group IDが有効(g+s)」

それぞれの手法について、簡潔にまとめると下記の通りとなる。

f:id:LeavaTail:20201206213859p:plain
勧告ロックと強制ロック

  • 勧告ロックの場合、readやwriteなどでアクセスしてもロックは自動的に取られない。
  • 強制ロックの場合、readやwriteなどでアクセスするとロックが自動的に取られる

以上のことを踏まえて、rw_verify_area関数を確認する。

// 366:
int rw_verify_area(int read_write, struct file *file, const loff_t *ppos, size_t count)
{
    struct inode *inode;
    int retval = -EINVAL;

    inode = file_inode(file);
    if (unlikely((ssize_t) count < 0))
        return retval;

    /*
    * 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 retval;
            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 retval;
        }

        if (unlikely(inode->i_flctx && mandatory_lock(inode))) {
            retval = locks_mandatory_area(inode, file, pos, pos + count - 1,
                    read_write == READ ? F_RDLCK : F_WRLCK);
            if (retval < 0)
                return retval;
        }
    }

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

rw_verify_area関数の解説に入る前に、file構造体からinode構造体までの関係性についておさらいする。

f:id:LeavaTail:20201219152620p:plain
file構造体からinode構造体までの関係性

file構造体からinode構造体を取得する方法として大きく二つ存在する。

一つ目は、「そのファイルのdエントリオブジェクトからinodeを取得する」方法である。
Linuxでは、ファイルそのもの(inode)とファイル名(dエントリ)を別々に管理している。
file構造体から、path構造体を経由することで、dエントリオブジェクトを取得することができる。

二つ目は、「そのファイルがキャッシュしているinodeを取得する」方法である。
file構造体からinode構造体を頻繁に参照することが多いため、file構造体にinode構造体のキャッシュを残している。
file構造体の生成(ファイルのオープンなど)時に、inode構造体へのポインタを設定しているはずなので、基本的にこちらの手法を用いるのが良い。
ちなみに、file_inode関数もこちらの方法でfile構造体からinode構造体を取得している。

以上のことを踏まえて、rw_verify_area関数のワークフローを確認する。

f:id:LeavaTail:20201219180104p:plain
rw_verify_area関数のワークフロー

条件分岐が多く、複雑そうに見えるがやっていることは大きく以下の3点である。

  1. 入力値が適切であるかどうか
  2. 強制ロックをとられているかどうか
  3. LSMが設定されていれば、それを呼び出す

一つ目の入力値チェックでは、「countがオーバーフローしていないか」や「posと加算した結果がオーバーフローしていないか」を確認する。

二つ目の強制ロックのチェックについて、まず強制ロックの条件を確認する。

// 2456:
static inline int mandatory_lock(struct inode *ino)
{
    return IS_MANDLOCK(ino) && __mandatory_lock(ino);
}

IS_MANDLOCKマクロは、マウントオプションに-o mandを指定されている場合に成立する。

// 2016:
#define IS_MANDLOCK(inode) __IS_FLG(inode, SB_MANDLOCK)

__mandatory_lock関数は、ファイルの実行権限が「グループ実行許可が無効 (g-x)」かつ「Set Group IDが有効(g+s)」である場合に成立する。

// 2346:
static inline int __mandatory_lock(struct inode *ino)
{
    return (ino->i_mode & (S_ISGID | S_IXGRP)) == S_ISGID;
}

上記のチェック後、locks_mandatory_area関数でロックエリアの確認をするが、本稿では説明を省略する。

また、条件分岐の三つ目についても、LSMの話になり本筋から大きく離れてしまうため、本稿では説明を省略する。

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

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

// 601:
    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;
...
    file_end_write(file);

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

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

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

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

// 2771:
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にファイル書き込みを通知する必要がある。

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

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

// 1592:
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などの処理によって更新される。

f:id:LeavaTail:20201221215123p:plain
writebackするinodeリストの関係

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

// 2785:
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);
}
// 1587:
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関数を確認する。

// 602:
    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つを引数とする。

f:id:LeavaTail:20201221232215p:plain
write処理における各データ構造の関係性

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

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

まずは、それぞれのデータ構造がどのようにつながっているのかを下記に示す。(赤字の値は例である)

f:id:LeavaTail:20201221232242p:plain
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
iov_offset 0
count 6
iovec iovec構造体へのポインタ
nr_segs 1

init_syc_kiocb関数はkiocb構造体を設定し、 iov_iter_init関数はiov_iter構造体を設定する。
ここでは、これらの関数の詳細については解説しない。下記はそれらの関数である。

// 2064:
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(),
    };
}
// 448:
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));
    direction &= READ | WRITE;

    /* It will get better.  Eventually... */
    if (uaccess_kernel()) {
        i->type = ITER_KVEC | direction;
        i->kvec = (struct kvec *)iov;
    } else {
        i->type = ITER_IOVEC | direction;
        i->iov = iov;
    }
    i->nr_segs = nr_segs;
    i->iov_offset = 0;
    i->count = count;
}

これらの構造体の設定した後に、call_write_iter関数によりファイルシステム固有のwrite_iter処理を実行する。

// 1900:
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関数の下記の部分に該当する。

// 608:
    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に関するイベントがサポートされていない

これらの機構について詳しく知りたいのであれば、下記のサイトを拝見することを推奨する。

blog.1mg.org

www.nminoru.jp

これらを踏まえると、vfs_write関数で書き込みが終了したタイミングで、fsnotify_modify関数によりイベントを(必要に応じて)通知させる必要がある。

fsnotifyの仕組みについてソースコードを追いかけるとなると膨大になってしまうので、ここでは説明を省略する。
下記に、一部のみ掲載してあるが、やっていることはファイルに変更があったことを(必要に応じて)通知する処理である。

// 253:
static inline void fsnotify_modify(struct file *file)
{
    fsnotify_file(file, FS_MODIFY);
}
// 83:
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操作を解説したいと思う。

参考

QEMUでARM用Linuxカーネルを起動する

はじめに

ARMアーキテクチャは、CPUアーキテクチャの一つである。
私たちの身近なPCはx86_64アーキテクチャであることが多いが、組み込み機器はARMアーキテクチャであることが多い。

また最近でも、AppleARMアーキテクチャを採用したMacを発表したことによって、ARMアーキテクチャはより一層注目されている。

一方で、アーキテクチャ毎に命令セットが異なるため、ARM用にビルドされたバイナリを別のアーキテクチャで実行することはできない。

そこで、プロセッサエミュレータでもあるQEMUを用いてARM環境を構築し、ARM用にビルドされたLinuxカーネルを動かす方法を解説する。
また本記事では、以下の動作をする環境を目指す。

f:id:LeavaTail:20201212144827p:plain
カーネル起動ワークフローとメモリマップイメージ図

変更履歴

  • 2020/12/12: 記事公開
  • 2021/2/25: initramfsの作成手順を修正

initramfs とは

initramfsでは、kernel起動直後にRAM上に展開されるファイルシステムとなっている。
カーネルドキュメントに詳細な説明が記載されているので、興味のある方は一読することを推奨する。

https://www.kernel.org/doc/Documentation/filesystems/ramfs-rootfs-initramfs.txt

initramfsを利用する意義として、ルートファイルシステムをマウントする前に必要なドライバのインストールや処理 (ファイルシステムの復号や改ざんチェックなど)があげられる。

同じような仕掛けとしてinitrdがある。それらの大きな違いはフォーマット形式である。 initramfsはアーカイブ形式(cpio + gzip)、initrdはファイルシステム形式(gzip)となっている。

そして、initramfsはinitスクリプト内で下記のようなルートファイルシステムを変更する処理が必要となる。 一般的には、switch_rootコマンドで実現している。

f:id:LeavaTail:20201212030218p:plain
initramfsからルートファイルシステムへの切り替え

環境構成

本記事は、下記の環境とソフトウェアバージョンに基づいて説明する。

環境 パラメータ
ホスト環境 x86_64
ホストOS Ubuntu 20.04 (python3arm-linux-gnueabi-をインストール済み)
Buildroot buildroot-2020.02.8
QEMU QEMU emulator version 5.0.0
ターゲットボード vexpress-a9
linux 5.4.58
U-Boot 2020.07
Busybox 1_32_stable

ARM用のLinuxを構築する

Buildrootを用いてLinux(kernel, rootfs, qemu, device tree)を構築する。

  1. Buildrootをインターネットからダウンロード、ファイルを解凍する。

     leava@ubuntu-bionic:~$ wget https://buildroot.org/downloads/buildroot-2020.02.8.tar.gz
     leava@ubuntu-bionic:~$ tar zxvf  buildroot-2020.02.8.tar.gz && cd buildroot-2020.02.8
    
  2. arm専用のデフォルトコンフィグqemu_arm_vexpress_defconfig (ちなみに、vexpressVersatile Expressの略で汎用的なarmの評価ボードを意味する) を利用する。

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ make qemu_arm_vexpress_defconfig
    
  3. Buildrootのビルド対象にU-Bootを追加する。

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ make menuconfig
    
     Bootloaders  --->
         [*] U-Boot
         (vexpress_ca9x4) Board defconfig 
    
  4. Buildrootのビルド (rootユーザでビルドしてしまうと失敗してしまうので注意)

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ make
    
  5. カーネルが実行できるかどうかQEMUで確認してみる。

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ ./output/images/start-qemu.sh serial-only
     ...
     buildroot login:                                                  # rootでログイン可能
     # 
    

ARM用のinitramfsを構築する

BusyBoxを用いて簡易initramfsを構築する。

  1. Buildrootをインターネットからダウンロード、stable versionにチェックアウトする。(投稿時は1_32_stableが最新版)

     leava@ubuntu-bionic:~$ git clone git://busybox.net/busybox.git
     leava@ubuntu-bionic:~$ cd busybox
     leava@ubuntu-bionic:~/busybox$ git checkout remotes/origin/1_32_stable
    
  2. arm専用のデフォルトコンフィグを利用する。

     leava@ubuntu-bionic:~/busybox$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- defconfig
    
  3. initramfs用に最小限の設定を変更する。(initramfsの場合、スタティックリンクであるほうが好ましい)

     leava@ubuntu-bionic:~/busybox$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- menuconfig
    
     Settings  --->
         [*] Build static binary (no shared libs)
    
  4. BusyBoxのビルド (make installを実行してもホスト環境に何かしらパッケージを変更するわけではなく、_installディレクトリにディレクトリツリーが構築される)

     leava@ubuntu-bionic:~/busybox$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi-
     leava@ubuntu-bionic:~/busybox$ make ARCH=arm CROSS_COMPILE=arm-linux-gnueabi- install
    
  5. initramfs用のディレクトリに移動する

     leava@ubuntu-bionic:~/busybox$ cd _install
    
  6. initramfsとして必要なディレクトリを作成する

     leava@ubuntu-bionic:~/busybox/_install$ mkdir dev
     leava@ubuntu-bionic:~/busybox/_install$ mkdir proc
     leava@ubuntu-bionic:~/busybox/_install$ mkdir sys
     leava@ubuntu-bionic:~/busybox/_install$ mkdir -p mnt/newroot
    
  7. initramfsからルートファイルシステムをマウントするようにinitスクリプトを修正する。

     leava@ubuntu-bionic:~/busybox/_install$ cat <<EOF > init
     #!/bin/busybox sh
     echo "Mounting Proc and Sysfs"
     # Mount the /proc and /sys filesystems. 
     mount -t devtmpfs devtempfs /dev
     mount -t proc none /proc
     mount -t sysfs none /sys
     # Mount the root filesystem
     mount -t ext4 /dev/mmcblk0 /mnt/newroot
     # Switch mount point        
     mount -n -o move /sys /mnt/newroot/sys
     mount -n -o move /proc /mnt/newroot/proc
     mount -n -o move /dev /mnt/newroot/dev
     # Execute new mount rootfilesystem
     exec switch_root -c /dev/console /mnt/newroot /sbin/init
     EOF
    
  8. 作成したディレクトリツリーを用いて、initramfsを構築する

     leava@ubuntu-bionic:~/busybox/_install$ find . | cpio -o --format=newc > ../rootfs.img;
    
  9. initramfsを使用するようにQEMUの起動スクリプトを修正する

     leava@ubuntu-bionic:~/busybox/_install$ cd ~/buildroot-2020.02.8
     leava@ubuntu-bionic:~/buildroot-2020.02.8$ sed -e '$d' ./output/images/start-qemu.sh > ./output/images/start-qemu2.sh
     leava@ubuntu-bionic:~/buildroot-2020.02.8$ echo "exec   qemu-system-arm -M vexpress-a9 -smp 1 -m 256 -kernel \${IMAGE_DIR}/zImage -dtb \${IMAGE_DIR}/vexpress-v2p-ca9.dtb -initrd ../rootfs.img -drive file=\${IMAGE_DIR}/rootfs.ext2,if=sd,format=raw -append \"console=ttyAMA0,115200 rootwait\"  -net nic,model=lan9118 -net user  \${EXTRA_ARGS}" >> output/images/start-qemu2.sh
    
  10. initramfsからルートファイルシステムを正常にマウントできるかどうか、QEMUで確認してみる。

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ ./output/images/start-qemu2.sh serial-only
     ...
     Mounting Proc and Sysfs                      # initramfsのinitスクリプトが実行されている
     ...
     buildroot login:                                                  # rootでログイン可能
     # 
    

U-Bootからカーネルをロードする

  1. initramfsを使用するようにQEMUの起動スクリプトを修正する

     leava@ubuntu-bionic:~/buildroot-2020.02.8$ sed -e '$d' ./output/images/start-qemu.sh > ./output/images/start-qemu3.sh
     leava@ubuntu-bionic:~/buildroot-2020.02.8$ echo "exec   qemu-system-arm -M vexpress-a9 -smp 1 -m 256 -kernel \${IMAGE_DIR}/../build/uboot-2020.07/u-boot -device loader,file=\${IMAGE_DIR}/zImage,addr=0x62000000 -device loader,file=\${IMAGE_DIR}/vexpress-v2p-ca9.dtb,addr=0x63000000 -device loader,file=urootfs.img,addr=0x63008000 -drive file=\${IMAGE_DIR}/rootfs.ext2,if=sd,format=raw   -net nic,model=lan9118 -net user  \${EXTRA_ARGS}" >> output/images/start-qemu3.sh
    
  2. U-Bootを起動させる。
    このとき、カーネル0x62000000、device treeを0x63000000、initramfsを0x630080000にロードする。 (これは参考文献より、今回のボードvexpress-a9は0x6000000からSDRAMが実装されているためである)

      leava@ubuntu-bionic:~/buildroot-2020.02.8$ ./output/images/start-qemu3.sh serial-only
    
      U-Boot 2020.07 (Dec 12 2020 - 04:16:22 +0000)
    
      DRAM:  256 MiB
      WARNING: Caches not enabled
      Flash: 128 MiB
      MMC:   MMC: 0
      *** Warning - bad CRC, using default environment
    
      In:    serial
      Out:   serial
      Err:   serial
      Net:   smc911x-0
      Hit any key to stop autoboot:  0 
      => 
    
  3. device treeが展開されているアドレスなどを指定してカーネル起動させる。

     => bootz 0x62000000 0x63008000 0x63000000
     Kernel image @ 0x62000000 [ 0x000000 - 0x46a478 ]                                                        
     ## Loading init Ramdisk from Legacy Image at 63008000 ...                                                
        Image Name:                                                                                           
        Image Type:   ARM Linux RAMDisk Image (gzip compressed)                                               
        Data Size:    1123310 Bytes = 1.1 MiB                                                                 
        Load Address: 00000000                                                                                
        Entry Point:  00000000                                                                                
        Verifying Checksum ... OK                                                                             
     ## Flattened Device Tree blob at 63000000                                                                
        Booting using the fdt blob at 0x63000000                                                              
        Loading Ramdisk to 6fd65000, end 6fe773ee ... OK                                                      
        Loading Device Tree to 6fd5e000, end 6fd6473e ... OK                                                  
    
     Starting kernel ...
     ...
     buildroot login:                                                  # rootでログイン可能
     # 
    

作成ファイル置き場

#!/bin/busybox sh
 echo "Mounting Proc and Sysfs"
 # Mount the /proc and /sys filesystems. 
 mount -t devtmpfs devtempfs /dev
 mount -t proc none /proc
 mount -t sysfs none /sys
 # Mount the root filesystem
 mount -t ext4 /dev/mmcblk0 /mnt/newroot
 # Switch mount point        
 mount -n -o move /sys /mnt/newroot/sys
 mount -n -o move /proc /mnt/newroot/proc
 mount -n -o move /dev /mnt/newroot/dev
 # Execute new mount rootfilesystem
 exec switch_root -c /dev/console /mnt/newroot /sbin/init
#!/bin/sh
IMAGE_DIR="${0%/*}/"

if [ "${1}" = "serial-only" ]; then
    EXTRA_ARGS='-nographic'
else
    EXTRA_ARGS='-serial stdio'
fi

export PATH="/srv/src/buildroot-2020.08/output/host/bin:${PATH}"
exec   qemu-system-arm -M vexpress-a9 -smp 1 -m 256 -kernel ${IMAGE_DIR}/zImage -dtb ${IMAGE_DIR}/vexpress-v2p-ca9.dtb -initrd ../rootfs.img -drive file=${IMAGE_DIR}/rootfs.ext2,if=sd,format=raw -append "console=ttyAMA0,115200 rootwait"  -net nic,model=lan9118 -net user  ${EXTRA_ARGS}
#!/bin/sh
IMAGE_DIR="${0%/*}/"

if [ "${1}" = "serial-only" ]; then
    EXTRA_ARGS='-nographic'
else
    EXTRA_ARGS='-serial stdio'
fi

export PATH="/srv/src/buildroot-2020.08/output/host/bin:${PATH}"
exec   qemu-system-arm -M vexpress-a9 -smp 1 -m 256 -kernel ${IMAGE_DIR}/../build/uboot-2020.07/u-boot -device loader,file=${IMAGE_DIR}/zImage,addr=0x62000000 -device loader,file=${IMAGE_DIR}/vexpress-v2p-ca9.dtb,addr=0x63000000 -device loader,file=urootfs.img,addr=0x63008000 -drive file=${IMAGE_DIR}/rootfs.ext2,if=sd,format=raw   -net nic,model=lan9118 -net user  ${EXTRA_ARGS}

おわりに

本記事では、QEMUでARM用Linuxカーネルを起動させる手順を説明した。
Buildrootで構築した場合、自動でセットアップしてくれるため非常に使いやすく便利である。

また、今回はその恩恵にあやかっていないが、initramfsを使用した起動方法を紹介した。 initramfsを利用しないと実現できない要件もあるので、役割や作り方を一度おさらいしておくとよい。

FAQ

  • initramfs がうまく起動しない。
    • 横着して find _install | cpio -o --format=newc > ../rootfs.img;などとしていませんか?initramfs用のディレクトリ直下で実行しましょう。
  • BusyBoxswitch_rootコマンドで、ルートファイルシステムの変更に失敗する。
    • /sbin/switch_rootでコマンドを実行するとPIDが変わってしまい起動に失敗します。execコマンドを使用しましょう。
  • u-bootで、bootzコマンドを実行後にStarting kernel ...で止まってしまう。
    • メモリマップを確認して、「ロードしたイメージと衝突していないか」と「ロードしたイメージ同士が衝突していないか」を確認しましょう。

参考