LeavaTailの日記

LeavaTailの日記

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

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

概要

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

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

はじめに

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

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

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

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

fs-verity とは

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

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

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

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

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

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

git.kernel.org

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

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

署名付きfs-verity

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

dm-verity とは

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

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

dm-verityの概略図

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

gitlab.com

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

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

署名付きfs-verity

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

実行環境

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

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

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

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

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

leavatail.hatenablog.com

実験

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

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

fs-verityの準備

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

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

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

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

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

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

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

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

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

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

dm-verityの準備

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

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

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

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

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

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

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

性能計測

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

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

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

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

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

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

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

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

fs-verityの計測

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

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

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

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

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

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

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

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

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

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

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

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

dm-verityの計測

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

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

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

おわりに

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

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

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

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

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

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

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

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

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

kernsec.org

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

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

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

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

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

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

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

#!/bin/bash

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

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

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

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

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

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

変更履歴

  • 2021/3/1: 記事公開

参考

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

関連記事

概要

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

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

はじめに

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

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

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

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

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

write_iterの概要

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

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

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

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

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

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

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

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

www.slideshare.net

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    if (IS_SWAPFILE(inode))
        return -ETXTBSY;

    if (!iov_iter_count(from))
        return 0;

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

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

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

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

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

書き込み前のチェック

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

writebackキューに登録する

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

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

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

    if (!inode)
        return &noop_backing_dev_info;

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

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

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

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

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

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

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

    return error;
}

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

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

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

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

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

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

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

    if (!sync_it)
        return 0;

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

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

    return ret;
}

  

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

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

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

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

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

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

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

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

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

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

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

    __mark_inode_dirty(inode, dirty_flags);
    return 0;
}

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

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

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

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

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

    trace_writeback_mark_inode_dirty(inode, flags);

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

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

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

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

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

        inode_attach_wb(inode, NULL);

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

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

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

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

            wb = locked_inode_to_wb_and_lock_list(inode);

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

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

            wakeup_bdi = inode_io_list_move_locked(inode, wb,
                                   dirty_list);

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

qiita.com

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

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

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

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

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

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

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

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

    wb_io_lists_depopulated(wb);
    return false;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

ARM memory Layout

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

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

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

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

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

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

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

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

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

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

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

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

// 12:
#if defined(CONFIG_FLATMEM)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Physically Indexed Physically Tagged (PIPT)方式

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

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

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

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

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

    mapping = page_mapping_file(page);

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

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

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

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

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

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

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

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

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

    return written ? written : status;

おわりに

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

変更履歴

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

参考

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

関連記事

概要

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

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

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

はじめに

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

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

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

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

実行環境

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

ネットワーク構成図

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

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

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

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

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

構築手順

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

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

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

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

leavatail.hatenablog.com

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

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

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

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

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

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

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

カーネルをビルド

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

実行結果

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

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

おわりに

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

変更履歴

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

参考

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

関連記事

概要

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

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

はじめに

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

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

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

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

VFSレイヤの概要

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

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

VFSオブジェクト関係図

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

writebackするinodeリストの関係

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

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

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

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

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

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

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

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

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

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

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

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

// 496:
static ssize_t new_sync_write(struct file *filp, const char __user *buf, size_t len, loff_t *ppos)
{
    struct iovec iov = { .iov_base = (void __user *)buf, .iov_len = len };
    struct kiocb kiocb;
    struct iov_iter iter;
    ssize_t ret;

    init_sync_kiocb(&kiocb, filp);
    kiocb.ki_pos = (ppos ? *ppos : 0);
    iov_iter_init(&iter, WRITE, &iov, 1, len);

    ret = call_write_iter(filp, &kiocb, &iter);
    BUG_ON(ret == -EIOCBQUEUED);
    if (ret > 0 && ppos)
        *ppos = kiocb.ki_pos;
    return ret;
}

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

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

  • struct iovec (IO vector): 書き込み対象のデータに関する情報を格納する
    • 「ユーザ空間のデータのアドレス」と「書き込み対象のデータの長さ」を持つ
  • struct kiocb (kernel IO control block?): 書き込み時に必要となるメタ情報を格納する
    • file構造体へのポインタ」や「オープンの時に指定したフラグ(の一部)」などを持つ
  • struct iov_iter (IO vector iterator?): 複数の書き込み対象のデータに関する情報を管理する
    • iovec(など)構造体のポインタ」や「iovec構造体をいくつ管理しているか」、「データの合計の長さ」などの情報を持つ

これらをまとめると、今回はこれらの構造体に下記のようなパラメータが設定される。

構造体名 メンバ名
iovec iov_base ユーザ空間のバッファへのポインタ
iov_len 6
kiocb ki_filp 対応するファイルのファイル構造体へのポインタ
ki_pos 0
ki_complete 0
private 0
ki_flags IOCB_APPEND
ki_hint WRITE_LIFE_NOT_SET
ki_ioprio 0
iov_iter type WRITE | ITER_IOVEC
data_source true
iov_offset 0
count 6
iovec iovec構造体へのポインタ
nr_segs 1
head 1
start_head 0
xattar_start 1

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

// 2325:
static inline void init_sync_kiocb(struct kiocb *kiocb, struct file *filp)
{
    *kiocb = (struct kiocb) {
        .ki_filp = filp,
        .ki_flags = iocb_flags(filp),
        .ki_hint = ki_hint_validate(file_write_hint(filp)),
        .ki_ioprio = get_current_ioprio(),
    };
}
// 463:
void iov_iter_init(struct iov_iter *i, unsigned int direction,
            const struct iovec *iov, unsigned long nr_segs,
            size_t count)
{
    WARN_ON(direction & ~(READ | WRITE));
    *i = (struct iov_iter) {
        .iter_type = ITER_IOVEC,
        .data_source = direction,
        .iov = iov,
        .nr_segs = nr_segs,
        .iov_offset = 0,
        .count = count
    };
}
EXPORT_SYMBOL(iov_iter_init);

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

// 2160:
static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
                      struct iov_iter *iter)
{
    return file->f_op->write_iter(kio, iter);
}

write_iter処理を実行した後、オフセットが移動した場合には更新をしてから終了する。

監視中のユーザプログラムにイベントを通知する

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

// 597:
    if (ret > 0) {
        fsnotify_modify(file);
        add_wchar(current, ret);
    }
    inc_syscw(current);

Linuxでは、ファイルへの書き込み (≠ ストレージへの書き出し) に関する情報をユーザプロセスに通知することができる。
ここでは、二つの通知の手法について説明する。

  • Taskstats
  • fsnotify

taskstats

taskstats はカーネルからユーザ空間のプロセスにタスクの統計情報やプロセスの統計情報を送信するためのインターフェイスである。

CONFIG_TASK_XACCTが有効の場合、Taskstatsで取得できる項目に「writeシステムコールを実行した回数」と「書き込みした総バイト数」が追加される。

// 11:
#ifdef CONFIG_TASK_XACCT
static inline void add_rchar(struct task_struct *tsk, ssize_t amt)
{
    tsk->ioac.rchar += amt;
}

static inline void add_wchar(struct task_struct *tsk, ssize_t amt)
{
    tsk->ioac.wchar += amt;
}

static inline void inc_syscr(struct task_struct *tsk)
{
    tsk->ioac.syscr++;
}

static inline void inc_syscw(struct task_struct *tsk)
{
    tsk->ioac.syscw++;
}

そのため、vfs_write関数で書き込みが終了したタイミングで、add_wchar関数とinc_syscw関数を呼び出して統計情報の更新をする。

ちなみに、CONFIG_TASK_XACCTが無効の場合は、何も実行されない。

// 31:
#else
static inline void add_rchar(struct task_struct *tsk, ssize_t amt)
{
}

static inline void add_wchar(struct task_struct *tsk, ssize_t amt)
{
}

static inline void inc_syscr(struct task_struct *tsk)
{
}

static inline void inc_syscw(struct task_struct *tsk)
{
}
#endif

taskstasについては、カーネルドキュメントに詳細な説明が記載されているので、そちらを要参照。

fsnotify

fsnotifyは、ファイルシステム上でのイベント通知機構のバックエンドとなる機構である。
fsnotifyは、dnotify, inotify, fanotifyなどの通知機構のための基盤となっている。

  • dnotify: ディレクトリの状態変化を通知する機構。のちにinotifyに取って代わる。
  • inotify: ファイルの状態変化を通知する機構。
  • fanotity: ファイルシステムの状態変化を通知する機能。

これらの機構の特徴をまとめると下記のようになる。

項目 dnotify inotify fanotify
権限 ユーザー権限 ユーザー権限 要root権限
監視範囲 ディレクト ファイル マウントポイント
ツール - https://github.com/inotify-tools/inotify-tools -
制約 リムーバルメディア非対応 アクセス許可の判定は不可能 create、delete、moveに関するイベントがサポートされていない

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

https://blog.1mg.org/20190803_01/blog.1mg.org

www.nminoru.jp

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

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

// 262:
static inline void fsnotify_modify(struct file *file)
{
    fsnotify_file(file, FS_MODIFY);
}
// 92:
static inline int fsnotify_file(struct file *file, __u32 mask)
{
    const struct path *path = &file->f_path;

    if (file->f_mode & FMODE_NONOTIFY)
        return 0;

    return fsnotify_parent(path->dentry, mask, path, FSNOTIFY_EVENT_PATH);
}

おわりに

本記事では、VFS(vfs_write関数)からファイルシステム固有のwrite(またはwrite_iter操作)を呼び出すまでを解説した。次回の記事で、ファイルシステム固有のwrite_iter操作を解説したいと思う。

変更履歴

  • 2020/12/25: 記事公開
  • 2020/12/31: VFSオブジェクトの説明を追加
  • 2022/08/28: カーネルバージョンを5.15に変更

参考

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

関連記事

概要

x86_64の開発PCで、BuildRootによりARM用にLinuxカーネルとU-Bootをビルド、busybnoxで簡易initramfsを用意する。
また、QEMU (vexpress-a9ボード)でこれらのバイナリを動かし、BuildRootで作成したルートファイルシステムがマウントされることを確認した。

はじめに

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

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

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

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

カーネル起動ワークフローとメモリマップイメージ図

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コマンドで実現している。

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 ...で止まってしまう。
    • メモリマップを確認して、「ロードしたイメージと衝突していないか」と「ロードしたイメージ同士が衝突していないか」を確認しましょう。

変更履歴

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

参考

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

関連記事

概要

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

本章では、ksys_write関数からvfs_write関数を呼ぶところまでを確認した。

はじめに

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

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

ファイルアクセスの一連の処理をシーケンスとしてあらわしたときに、赤枠の部分が該当する。

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

本記事では、Syscall interfaceの途中 (SYSCALL_DEFINE)からVFSの関数を呼び出すところまでとする。
ここより上のレイヤー(Application部やsystem call interfaceの上位部)の解説は今回は省略する。

ユーザプログラム

下記の記事では、システムコールについて説明されている。 システムコールを一から学習したい人におすすめ。

www.kimullaa.com

システムコールハンドラ

writeシステムコールは、SYSCALL_DEFINEマクロによって定義される。

// 656:
SYSCALL_DEFINE3(write, unsigned int, fd, const char __user *, buf,
        size_t, count)
{
    return ksys_write(fd, buf, count);
}

writeシステムコールの実態は、ksys_write()となっている。
ksys_write()の定義は下記のようになっている。

// 636:
ssize_t ksys_write(unsigned int fd, const char __user *buf, size_t count)
{
    struct fd f = fdget_pos(fd);
    ssize_t ret = -EBADF;

    if (f.file) {
        loff_t pos, *ppos = file_ppos(f.file);
        if (ppos) {
            pos = *ppos;
            ppos = &pos;
        }
        ret = vfs_write(f.file, buf, count, ppos);
        if (ret >= 0 && ppos)
            f.file->f_pos = pos;
        fdput_pos(f);
    }

    return ret;
}

ksys_write()には、大きく分けて3つ処理がある。

  1. 現在のプロセスが保有しているファイルディスクリプタテーブルからfd構造体を取得する
  2. ファイルの現在のオフセットを取得する
  3. VFSのwrite処理を呼び出す

ファイルディスクリプタからfd構造体を取得する

ファイル構造体を取得するまでの流れは下記のようなものとなっており、マルチスレッドであった場合に限りファイルディスクリプタテーブルにロックをかけるといった処理がされる。
ちなみに、FMODE_PATHO_PATHフラグを指定してファイルをopenしたときに付与されるフラグであり、その場合は書き込みを失敗させる。

ファイル構造体を取得するまでのフロー

file構造体の取り扱い方については、カーネルドキュメントが用意されているので気になる人はそちらを読むことを推奨する。

ksys_write関数で実行されるfdget_pos関数について定義を確認する。
fdget_pos関数は下記のような定義である。

// 73:
static inline struct fd fdget_pos(int fd)
{
    return __to_fd(__fdget_pos(fd));
}

fdget_pos関数の処理は単純で下記の2点のみである。

  • file構造体にflagsを付与して、fd構造体を呼び出し元に返す
  • __fdget_pos関数でファイルディスクリプタからfile構造体を取得する

まずは、__to_fd関数の定義から確認する。 __to_fd関数は下記のような定義である。

// 58:
static inline struct fd __to_fd(unsigned long v)
{
    return (struct fd){(struct file *)(v & ~3),v & 3};
}

引数として与えられたvから、下位2bitに格納されたフラグを抽出する。(アドレス空間は4bytes or 8bytesの管理されるので、file構造体のアドレスに影響はない)
上記のフラグと下位2bitをマスクしたfile構造体へのポインタをfd構造体に代入する。

ちなみにfd構造体の定義は下記の通りである。

// 36:
struct fd {
    struct file *file;
    unsigned int flags;
};

ここまでの__to_fd関数の流れのイメージを下記に示す。

file構造体のアドレスとfd構造体の対応

  1. __fdget_pos関数(後述)によって、ファイルディスクリプタからファイル構造体のアドレスを取得する
  2. __fdget_pos関数は、取得したアドレスをunsigned long型の変数vに代入し、下位2bitにフラグを代入する
  3. __to_fd関数は、変数vからfile構造体とflagsを取得して、fd構造体を返す

ファイルディスクリプタからfile構造体を取得する

fdget_pos関数は、 ファイルディスクリプタからfile構造体を取得するために__fdget_pos関数を呼び出す。

// 982:
unsigned long __fdget_pos(unsigned int fd)
{
    unsigned long v = __fdget(fd);
    struct file *file = (struct file *)(v & ~3);

    if (file && (file->f_mode & FMODE_ATOMIC_POS)) {
        if (file_count(file) > 1) {
            v |= FDPUT_POS_UNLOCK;
            mutex_lock(&file->f_pos_lock);
        }
    }
    return v;
}

__fdget_pos関数の処理は下記の2点のみである。

  • __fdget関数でファイルディスクリプタからfile構造体のアドレスを取得する (unsigned long型bに代入していたり、~3とAND演算しているのは前述の理由)
  • 同じファイルが複数openされている場合、オフセットのためのmutex_lockを取得する (取得したロックは、ksys_write関数の最後に実行しているfdput_pos関数で解放する)

まず、__fdget関数とその呼び出し先の__fget_light関数の定義を確認する。

// 971:
unsigned long __fdget(unsigned int fd)
{
    return __fget_light(fd, FMODE_PATH);
}
// 954:
static unsigned long __fget_light(unsigned int fd, fmode_t mask)
{
    struct files_struct *files = current->files;
    struct file *file;

    if (atomic_read(&files->count) == 1) {
        file = files_lookup_fd_raw(files, fd);
        if (!file || unlikely(file->f_mode & mask))
            return 0;
        return (unsigned long)file;
    } else {
        file = __fget(fd, mask, 1);
        if (!file)
            return 0;
        return FDPUT_FPUT | (unsigned long)file;
    }
}

__fget_light関数では、現在のプロセスのファイルディスクリプタテーブルを取得して、状況に応じてロックを取得する。

ここで、task_struct構造体からfile構造体までの関係性についておさらいする。

  • current変数は、現在のプロセスのtask_struct構造体を指す
  • task_struct構造体は、現在openしているファイルを管理するためにfiles_struct型のポインタを変数filesで管理する
  • files_struct構造体は、fdtable型の変数fdtでファイルディスクリプタのテーブルを管理する
  • fdtable構造体は、file構造体の配列を管理する。

これらの関係を図示したものが以下である。

ファイルディスクリプタからfile構造体を取得するまでの各構造体の関係性

__fget_light関数でも上記の規則と同様に、current変数からfiles_struct構造体を取得している。
しかし、シングルスレッドマルチスレッドの場合で、files_struct以降の取り扱いが変わる。

シングルスレッドの場合

シングルスレッドの場合 (つまり、atomic_read(&files->count) == 1) は、対象のファイルディスクリプタテーブルを保有しているは自分のみである。 そのため、それぞれの変数に対して、排他制御を意識する必要はない

files_lookup_fd_raw関数の定義は以下のようになっている。

// 83:
static inline struct file *files_lookup_fd_raw(struct files_struct *files, unsigned int fd)
{
    struct fdtable *fdt = rcu_dereference_raw(files->fdt);

    if (fd < fdt->max_fds) {
        fd = array_index_nospec(fd, fdt->max_fds);
        return rcu_dereference_raw(fdt->fd[fd]);
    }
    return NULL;
}

files_lookup_fd_raw関数は、Linuxカーネル v5.10まではfcheck_files関数という名前で定義されていた。
fcheck_files関数についての注意点は、カーネルドキュメントにも記載されている。

4 To look up the file structure given an fd, a reader must use either fcheck() or fcheck_files() APIs.
These take care of barrier requirements due to lock-free lookup. https://www.kernel.org/doc/Documentation/filesystems/files.txt

file構造体を取得した後に、そのファイルのopenフラグを確認する。 FMODE_PATHフラグが立っていた場合、ファイル操作は無効となっているので0を返す必要がある。

マルチスレッドの場合

マルチスレッドの場合 (つまり、atomic_read(&files->count) > 1) は、排他制御を意識する必要がある

__fget関数と呼び出し先の__fget_files関数の定義は以下のようになっている。

// 867:
static inline struct file *__fget(unsigned int fd, fmode_t mask,
                  unsigned int refs)
{
    return __fget_files(current->files, fd, mask, refs);
}
// 844:
static struct file *__fget_files(struct files_struct *files, unsigned int fd,
                 fmode_t mask, unsigned int refs)
{
    struct file *file;

    rcu_read_lock();
loop:
    file = files_lookup_fd_rcu(files, fd);
    if (file) {
        /* File object ref couldn't be taken.
        * dup2() atomicity guarantee is the reason
        * we loop to catch the new file (or NULL pointer)
        */
        if (file->f_mode & mask)
            file = NULL;
        else if (!get_file_rcu_many(file, refs))
            goto loop;
    }
    rcu_read_unlock();

    return file;
}

files_lookup_fd_rcu関数は、files_lookup_fd_raw関数(シングルスレッドの場合を参照)のラッパー関数である。

シングルスレッドの場合と大きく異なるのは、rcu_read_lock/rcu_read_unlock命令を発行している点である。

  • rcu_read_lock()プリエンプションを無効にする命令
  • rcu_read_unlock()プリエンプションを有効にする命令

コメントにも書いてあるが、dup2()が要因でファイルカウントがおかしくなることがあるため、file構造体の取得をループさせることがある。
file構造体の取得後にプリエンプションが発生して、別にfile構造体を操作されると整合性がとれなくなってしまう。

__fget_files関数ではこの関数内でFMODE_PATHフラグの確認もしている。 シングルスレッドの場合と同様に、上記のフラグが立っていたら0を返す。

また、マルチスレッドの場合にはFDPUT_FPUTフラグを立てる。

ファイルの現在のオフセットを取得する

file構造体には、現在のオフセットをpos変数によって管理している。

file_ppos関数は、file構造体のpos変数を返す関数である。 このとき、stream-likeのファイルの場合は、オフセットを持たないのでNULLを返す必要がある。

// 618:
static inline loff_t *file_ppos(struct file *file)
{
    return file->f_mode & FMODE_STREAM ? NULL : &file->f_pos;
}

VFSのwrite処理を呼び出す

ここまでで、file構造体とオフセットが取得できたのでvfs_write関数でVFSのレイヤに移る(次回説明)。
vfs_write関数による書き込みをした後は、オフセットの更新と後処理(参照カウンタやロックの解放)が必要になる。

// 78:
static inline void fdput_pos(struct fd f)
{
    if (f.flags & FDPUT_POS_UNLOCK)
        __f_unlock_pos(f.file);
    fdput(f);
}

FDPUT_POS_UNLOCKは、__fdget_pos関数内でfile_count(file) > 1)の場合に立てられるフラグである。
このとき、__f_unlock_pos関数でファイルオフセット用のmutexを解放する。

// 1009:
void __f_unlock_pos(struct file *f)
{
    mutex_unlock(&f->f_pos_lock);
}

その後、fdput関数でファイルの参照カウンタを一つ減らす。

// 43:
static inline void fdput(struct fd fd)
{
    if (fd.flags & FDPUT_FPUT)
        fput(fd.file);
}

実際には、fput関数で参照カウンタを減らす操作をしているが、ここでは割愛する。

おわりに

本記事では、writeシステムコールの実態からvfs_write関数を呼び出すまでを解説した。 次回の記事では、vfs_write関数から各ファイルシステムのwrite操作を呼び出すまでの処理を追いかける。

変更履歴

  • 2020/11/29: 記事公開
  • 2022/08/18: カーネルバージョンを5.15に変更

参考

GitHub ActionsでChangeLogからReleasesを自動化する

概要

本記事では、GitHub ActionsでReleasesを生成する手順を確認した。

RepositoryにあるChangeLogからReleaseの説明文を自動生成する。

はじめに

ソフトウェア開発者 (ユーザ) は、ソフトウェアをリリースする際に幾つかの作業を実施する必要がある。 しかし、それらの作業は、ある程度決められた作業を実施する必要がある。 ヒューマンエラーやユーザの手間を考慮すると、これらの作業を自動化できるとうれしい。

本記事では、GitHubReleases機能を使用しているプロジェクトを対象とする。
Releases機能は、GitHub上にあるリポジトリと紐づけられていて、リポジトリのタグに対して任意のファイルやメッセージを残すことができる。

この機能を使うユースケースとして、下記の作業が考えられる。

  • ユーザは、ChangeLog(または、NEWSなど)に更新点を書き記す。
  • ユーザは、任意のコミットにタグを作成する。
  • ユーザは、GitHubにあるリモートリポジトリにタグをプッシュする。
  • ユーザは、該当リポジトリのパッケージを作成する。
  • ユーザは、Releases 機能からメッセージ(リリースノート)の追加や任意のファイル(パッケージ)を添付する。

本記事では、「パッケージを作成する」作業と「Releases 機能」における作業をGitHub Actionsを利用して自動化を目指す。*1
下記は、本記事を適用したときのソフトウェアのリリースするまでの手順を示したものである。

Releasesするまで手順

  1. ユーザは、ChangeLogに更新点を書き記す。
  2. ユーザは、任意のコミットにタグを作成する。
  3. ユーザは、GitHubにあるリモートリポジトリにタグをプッシュする。
  4. GitHub Actionsは、Change Logからメッセージ(リリースノート)の追加とパッケージを生成し、添付する。

本作業は下記のリポジトリのworkflowに導入済みである。本記事では、これを基に説明する。 github.com

リポジトリでは、automakeを使用しているため、下記のmakeターゲットがデフォルトで用意されている。

  • make: ソフトウェアをビルドする
  • make install: 成果物をユーザの環境にインストールする
  • make dist: 配布用パッケージを生成する (パッケージ名-バージョン名.tar.gz)

Git-flowは下記のような形式を採用しており、適当なタイミングでtag(図中の灰色吹き出し)を作成している。
このtagが作成されたタイミングで、そのバージョンにおけるパッケージとChangeLogを残すようにしている。

本プロジェクトのGit-flow

また、図中の白吹き出しはブランチを表している。

  • mainブランチ: 製品として常に安定した状態を保つ。
  • bugfixブランチ: リリース後に、不具合を修正する。
  • developブランチ: 次のリリースに向けた作業をする。
  • featureブランチ: 各機能における開発をする。

自動化のための作業

Releases手順の自動化に向けて、あらかじめ以下の作業を完了させておく必要がある。

  • ChangeLogから変更内容を取得するスクリプトの作成
  • Releases 機能を自動的に設定するworkflowの作成

ChangeLogから変更内容を取得するスクリプトの作成

Releases のリリースノートに記述するための、該当バージョンの更新点をChangeLogから抽出する必要がある。

今回は、下記のようなフォーマットのChangeLogをユーザが作成している場合を考える。

# Changelog
## [1.1.0] - 2020-10-01
### Added
- 新機能C
- 新機能D

### Changed
- 既存機能Zの修正

### Removed
- 既存機能Yの削除

## [1.0.1] - 2020-09-11
### Fixed
- 既存機能Xの修正

## [1.0.0] - 2020-09-01
### Added
- 新機能A
- 新機能B

この形式は、第2レベルの見出しに[バージョン] - 日付が記述されていて、第3レベルの見出しに変更の種類(AddedやChanged)が記述されている。
リリースノートとして必要となるのは、該当バージョンにおける第2レベルの見出し以下の内容となる。

そのため、該当バージョンの第2レベルの見出しの検索とその内容を取得する必要がある。 第2レベルの見出しの検索は^##grepすることで取得できる。

    $ grep -n "^## " ChangeLog.md
    2## [1.1.0] - 2020-10-01
    13:## [1.0.1] - 2020-09-11
    17:## [1.0.0] - 2020-09-01

ここから、awkコマンドでパターンマッチを行い、該当バージョン(VERSION)内容の先頭と末尾の行番号を取得する。

    $ grep -n "^## " ChangeLog.md |\
        awk -F: -v version=${VERSION} '/'"${VERSION}"'/ \
            { start = $1 + 1; getline; end = $1 - 1 } \
            END { print start, end }' )
   sed -n ${sline},${eline}p ${FILE}

例えばv1.1.0の場合、先頭は3行目、末尾12行目が得られる。
ここから、ChangeLogの3行目から12行目を表示することで該当バージョン(v1.1.0)の変更内容を取得できる。

上記の内容を踏まえて、下記のようなChangeLogから変更内容を取得するスクリプトget_changelog.shを用意する。

#!/bin/bash
FILE=CHANGELOG.md
VER=`echo $1 | tr -d "refs/tags/"`    # i.e. v1.0, v20.15.10
VERSION=`echo ${VER} | tr -d v`       # i.e. 1.0, 20.15.10

read sline eline <<< \
    $( grep -n "^## " ${FILE} | \
   awk -F: -v version=${VERSION} '/'"${VERSION}"'/ \
      { start = $1 + 1; getline; end = $1 - 1 } \
      END { print start, end }' )
sed -n ${sline},${eline}p ${FILE}

Releases 機能を自動的に設定するworkflowの作成

タグのプッシュを契機に、Releases 機能を自動的に設定するworkflowの作成する必要がある。

GitHubでは、Releasesを生成するActions と ReleasesにファイルをアップロードするActionsが用意されている。

github.com github.com

create-releaseを利用して、Markdownファイルで記述されたリリースノート(body.md)からReleasesの作成する場合には下記のように記述する。 (Example workflowを参照)

- name: Create Release
  id: create_release
  uses: actions/create-release@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 
  with:
    tag_name: ${{ github.ref }}                      
    release_name: Release ${{ github.ref }}      # Releasesのタイトル (i.e. Release v1.0.0)
    body_path: "body.md"                         # Releasesのリリースノート
    draft: false
    prerelease: false

upload-release-assetを利用して、ファイルをアップロードする場合も同様にExample workflowが用意されているのそれに従って記述する。

ただし、Automakeで生成される配布物パッケージ名はパッケージ名-バージョン名.tar.gzとなっており、可変のファイル名である。
upload-release-assetはアップロードするファイルの名前に正規表現をサポートしていないため、Example workflowに一工夫しなければいけない。

コミュニティで提案されているのは、前のステップにて該当ファイルを環境変数に代入する方法である。

- name: Get Name of Artifact
  run: |
    ARTIFACT_PATHNAME=$(ls debugfatfs-*.tar.gz | head -n 1)
    ARTIFACT_NAME=$(basename $ARTIFACT_PATHNAME)
    echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV
    echo "ARTIFACT_PATHNAME=${ARTIFACT_PATHNAME}" >> $GITHUB_ENV
- name: Upload Release Asset
  id: upload-release-asset
  uses: actions/upload-release-asset@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    upload_url: ${{ steps.create_release.outputs.upload_url }}
    asset_path: ${{ env.ARTIFACT_PATHNAME }}
    asset_name: ${{ env.ARTIFACT_NAME }}
    asset_content_type: application/gzip

上記のActionsを用いて、Releases 機能を自動的に設定するworkflowを用意する。

name: Create Releases

on:
  push:
    tags:
      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: setup environment           # パッケージ生成のための環境構築
      run: |
        sudo apt-get update
        sudo apt-get install autoconf automake libtool help2man make
    - run:  script/bootstrap.sh
    - run: ./configure
    - run: make
    - run: make dist          # 配布物パッケージの生成 i.e. debugfatfs-0.1.0.tar.gz
      env:
        CI: true
    - run: |                  # 更新内容を一時的にbody.mdとして保存しておく
        ./get_changelog.sh ${{ github.ref }} > body.md
    - name: Create Release
      id: create_release
      uses: actions/create-release@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 
      with:
        tag_name: ${{ github.ref }}                      
        release_name: Release ${{ github.ref }}      # Releasesのタイトル (i.e. Release v1.0.0)
        body_path: "body.md"                         # Releasesのリリースノート
        draft: false
        prerelease: false
    - name: Get Name of Artifact
      run: |
        ARTIFACT_PATHNAME=$(ls debugfatfs-*.tar.gz | head -n 1)      # 正規表現で成果物パッケージのファイル名を取得する
        ARTIFACT_NAME=$(basename $ARTIFACT_PATHNAME)
        echo "ARTIFACT_NAME=${ARTIFACT_NAME}" >> $GITHUB_ENV         # 成果物パッケージのファイル名の環境変数に設定する
        echo "ARTIFACT_PATHNAME=${ARTIFACT_PATHNAME}" >> $GITHUB_ENV
    - name: Upload Release Asset
      id: upload-release-asset
      uses: actions/upload-release-asset@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      with:
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: ${{ env.ARTIFACT_PATHNAME }}
        asset_name: ${{ env.ARTIFACT_NAME }}
        asset_content_type: application/gzip

実行例

# Changelog

## [0.1.0] - 2020-10-24

### Added

- Print main Boot Sector field
- Print any cluster
- Print Directory list
- Backup or restore FAT volume
- Convert into update latter
- Create file
- Remove file
- Change any FAT entry
- Change any allocation bitmap
- Trim deleted directory entry

## Initial Version
  • tag (v0.1.0)を作成し、リモートリポジトリにプッシュする。

     $ git tag v0.1.0
     $ git push origin --tags
    

タグ (v0.1.0)をプッシュした後のリポジトリ

Releases v0.1.0の概要

おわりに

本記事では、ユーザがタグを作成したタイミングでChangeLogからReleasesを自動的に生成するworkflowを作成した。

この手順では、ChangeLogの生成がユーザの手作業で書き記す必要があるが、制度の良いcommitを生成しているのであればこの作業も自動化しても良いだろう。 今回は、正規表現を利用してアップロードファイルを指定しているのでひと手間かかったが、GitHub Actionsは様々なworkflowが用意されているので、任意の作業の自動化が容易である。

このように作業の自動化が容易であるため、定型的な作業がGitHub Actions (などのサービス)を利用して可能な限り自動化していくとよい。

変更履歴

  • 2020/10/25: 記事公開

参考

*1:ChangeLogの生成を自動化に関しては「keep a changelog」に則り実施していない。