LeavaTailの日記

LeavaTailの日記

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

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

関連記事

変更履歴

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

はじめに

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

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

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

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

VFS

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

__chk_user_ptrマクロは、__CHECKER__が偽の場合に、(void)0を返す。
別記事の情報によると、「これはコンパイル時、インラインのaccess_okの引数が__userとなっているかのチェック故かと思います。」とのこと。

wiki.bit-hive.com

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

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

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

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

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

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

jhalfmoon.com

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// 2785:
static inline void file_end_write(struct file *file)
{
    if (!S_ISREG(file_inode(file)->i_mode))
        return;
    __sb_end_write(file_inode(file)->i_sb, SB_FREEZE_WRITE);
}
// 1587:
static inline void __sb_end_write(struct super_block *sb, int level)
{
    percpu_up_read(sb->s_writers.rw_sem + level-1);
}
ファイルシステム固有のwrite処理を実行する

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  • Taskstats
  • fsnotify
taskstats

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

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

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

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

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

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

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

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

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

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

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

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

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

fsnotify

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

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

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

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

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

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

www.nminoru.jp

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

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

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

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

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

おわりに

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

参考