LeavaTailの日記

LeavaTailの日記

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

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」に則り実施していない。

Linuxカーネルのファイルアクセスの処理を追いかける (1) 環境セットアップ

関連記事

概要

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

本章では、コードリーディング用にデバッグ情報を付与したLinuxカーネルのビルドBuildRootによる実行環境を構築した。

はじめに

一般的なOSはファイルという形式を通して、ハードディスクやフラッシュメモリといった記憶装置にデータを保存している。
この処理を担うのがファイルシステムと呼ばれる機構である。
一般的な利用者はこのことを意識せずに利用することができるが、ソフトウェアエンジニアは処理を理解していないとディスクIOパフォーマンスが悪化し、システム全体のパフォーマンスに大きく影響を及ぼす恐れがある。
そこで、アプリケーションがファイルを書き込んだ際にLinuxカーネルがどのような処理で記憶装置に読み書きされるかを順を追って説明する。

調査範囲

本記事では、SYSCALL_DEFINE(write)からデバイスドライバまでの処理を対象とする。

背景

一般的なOSでは、さまざまなコンポーネントから成り立っている。ファイルシステムもその一つである。 ファイルの書き込み処理一つとっても、多数のコンポーネントとの関係を持つ。

下記の図は、他サイトで掲載されているLinuxカーネルv4.10の構成図である。(2022年8月現在、Linuxカーネルv5.19.1がリリースされている)

https://www.thomas-krenn.com/de/wikiDE/images/e/e0/Linux-storage-stack-diagram_v4.10.png

"https://www.thomas-krenn.com/en/wiki/Linux_Storage_Stack_Diagram">Linux Storage Stack Diagram

このように、Linuxカーネルv4.10の時点でもたくさんのフローからファイルアクセスが成り立っている。(大まかな処理は最新カーネルでも変わらないのでこの図を基に説明を続ける)
ここでは、read(2)とwrite(2)について説明する。
read(2)

  1. VFSは、ファイルに対応するファイルシステムのread処理を呼び出す。
  2. ファイルシステムは、ファイルがキャッシュに載っているか確認する。(あればそれをApplicationに渡して終了する)
  3. ファイルシステムは、Block LayerにBIOを挿入する。
  4. Block Layerは、スケジューラによりBIOを並び替る。
  5. Block Layerは、Device DriverにRequestを発行する。
  6. Device Driverは、Physical devicesにIOを要求する。
  7. Physical devicesは、デバイスファームウェアに則りデータの読み込みをする。
  8. Physical devicesは、カーネルにIO完了通知をする。
  9. (Direct_IOでなければ)カーネルは、読み込みしたデータをPage cacheとしてキャッシュする。
  10. カーネルは、Applicationにデータを渡して終了する。

write(2)

  1. VFSは、ファイルに対応するファイルシステムのwrite処理を呼び出す。
  2. (Direct_IOでなければ)ファイルシステムは、ファイルをキャッシュにしてApplicationに完了を通知する。
  3. ファイルシステムは、Block LayerにBIOを挿入する。
  4. Block Layerは、スケジューラによりBIOを並び替る。
  5. Block Layerは、Device DriverにRequestを発行する。
  6. Device Driverは、Physical devicesにIOを要求する。
  7. Physical devicesは、デバイスファームウェアに則りデータの書き込みをする。
  8. Physical devicesは、カーネルにIO完了通知をする。

一般的なストレージに対する書き込み処理は、下記のようなライトバック方式で行われる。

カーネルの処理シーケンス

ファイルの書き込みをしたアプリケーションはページキャッシュをDirtyにするだけで処理を終了する。その後、カーネルスレッドが定期的にDirtyとなっているキャッシュを書き込む。

環境構成

本稿では、QEMUを用いて観測対象のLinuxカーネルを起動させる。 QEMUを利用することで、下記のような利点が得られる。

  • 実行環境による違いを緩和することができる
  • ホスト側から任意のタイミングでGDBでアタッチすることができる

本稿では、下記の環境で処理を確認していった。

Host側

概要 説明
Architecture x86_64
Board custom board
Linux 5.15.0-46-generic
kernel config unknown
Userland Ubuntu Desktop 22.04.1
Buildroot buildroot 2022.08.1
QEMU QEMU emulator version 7.0.0

Guest側

概要 説明
Architecture armhf
Board vexpress-a9
Linux linux-5.15
kernel config vexpress_defconfig
Userland Buildroot
Storage SD card
File-Syste ext2
Disk Scheduler MQ-DEADLINE

デバッグ機能について

vexpress_defconfigでもカーネルを起動させることができるが、デバッグ容易性のために Kconfigの変更デバッグ用のカーネルパッチを適用をする。

追加したデバッグ機能については次のRepositoryで管理している。

https://github.com/LeavaTail/buildroot-2022.08.1-qemu_arm_vexpressgithub.com

これを、buildrootディレクトリの配下にある board/qemu/arm-vexpress 以下に展開しておく。

行基板について

QEMUでは、Versatile Express motherboardとCoreTile Express A9x4 daughterboardの組み合わせをvexpress-a9というボードでサポートしている。 それぞれの機器のデータシートはArm Developerに記載されている。

下記は、Arm Developerで記載されている機器のレイアウト図を引用している。
こちらは、Versatile Express motherboardのレイアウト図である。

https://documentation-service.arm.com/static/5e9074b78259fe2368e2acd9?token= https://developer.arm.com/documentation/dui0448/i/hardware-description/overview-of-the-coretile-express-a9-4-daughterboard

こちらは、CoreTile Express A9x4 daughterboardのレイアウト図である。

https://documentation-service.arm.com/static/5e9db8569931941038de23df?token= https://developer.arm.com/documentation/dui0448/i/hardware-description/overview-of-the-coretile-express-a9-4-daughterboard

これらの情報とQEMUの公式サイトに書かれている情報を基に、vexpress-a9の概略図を示す。

vexpress-a9のレイアウト イメージ図

また、Linuxカーネル v5.15におけるメモリーマップを記す。

ARM memory Layout

作成手順

実行環境の準備

Linuxカーネルのファイルアクセスをトレースするための実行環境をBuildRootにより作成する。

  1. Buildrootを入手する。

     leava@kbuild:/work$ git clone https://github.com/buildroot/buildroot.git
     leava@kbuild:/work$ cd buildroot
     leava@kbuild:/work/buildroot$ git checkout 2022.08.1
    
  2. Buildrootのデフォルトの設定を使用する。

     leava@kbuild:/work/buildroot$ make qemu_arm_vexpress_defconfig
    
  3. Buildrootの設定を適宜修正する。

     toolchain  --->
       (glibc) C library
       [*] Enable C++ support
       [*] Build cross gdb for the host
         [*]   TUI support
    
     System configuration  --->
       /bin/sh (bash)  --->
       (root) Root password
    
     Kernel   --->
       (5.15) Kernel version
       (board/qemu/arm-vexpress/patches) Custom kernel patches
       Kernel configuration (Using a custom (def)config file)  --->
       (board/qemu/arm-vexpress/.config) Configuration file path
    
     Target packages
       [*]   Show packages that are also provided by busybox
       Debugging, profiling and benchmark  --->
         [*] blktrace
       Development tools
         [*] binutils
       Filesystem and flash utilities
         [*] mmc-utils
       Networking applications
         [*] dropbear
    
     Host utilities  ---> 
       [*] host qemu 
          *** Emulators selection ***
         [*]   Enable system emulation
         [*]   Enable Linux user-land emulation
    
  4. Buildrootの設定からユーザランドを構築する。

     leava@kbuild:/work/buildroot$ make
    
  5. Buildrootで作成した環境を実行するためのスクリプトを用意する。

// 1:
#!/bin/bash -x
(
BUILDROOT_DIR="/usr/local/src/buildroot"
BINARIES_DIR="${BUILDROOT_DIR}/output/images/"
NFSROOT="/srv/nfsroot/armhf/buildroot"
FSTYPE="ext2"
SDCARD="/tmp/${FSTYPE}.img"
EXTRA_ARGS="-nographic -s"
TARGET_ROOTFS="/dev/nfs"
EXTRA_CMDLINE="nfsroot=${NFSROOT},vers=3,tcp ip=on"
CMDLINE="console=ttyAMA0,115200 rootwait root=${TARGET_ROOTFS} rw ${EXTRA_CMDLINE}"

function gen_testimage () {
        DISTDEV="/mnt"
        mkfs.${FSTYPE} ${SDCARD}
        mount -t ${FSTYPE} -o loop ${SDCARD} ${DISTDEV}
        echo -n A > ${DISTDEV}/FILE
        umount ${DISTDEV}
}

if [ ! -e ${SDCARD} ]; then
        dd if=/dev/zero of=${SDCARD} bs=1K count=1M
        gen_testimage
elif [ -z `blkid -o value -s TYPE ${SDCARD}` ]; then
        gen_testimage
fi

cd ${BINARIES_DIR}

export PATH="/usr/local/src/buildroot/output/host/bin:${PATH}"
exec qemu-system-arm -M vexpress-a9 -smp 1 -m 1024 \
        -kernel zImage -dtb vexpress-v2p-ca9.dtb \
        -drive file=${SDCARD},if=sd,format=raw \
        -append "${CMDLINE}" \
        -net nic,model=lan9118 -net user \
        ${EXTRA_ARGS}
)

ルートファイルシステムのカスタマイズ

Buildrootで生成したルートファイルシステムNFS経由でmountできるようにカスタマイズする。

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

     leava@leava-host:/srv/nfsroot$ sudo apt-get install nfs-kernel-server
    
  2. Host PCでNFSサーバの設定する

     leava@leava-host:/srv/nfsroot$ echo "/srv/nfsroot       127.0.0.1(rw,no_root_squash,no_subtree_check,insecure)" | sudo tee -a /etc/exports
     leava@leava-host:/srv/nfsroot$ sudo exportfs -v
    
  3. Host PCにBuildrootで生成したルートファイルシステムを展開する

     leava@leava-host:/srv/nfsroot$ sudo tar -xf output/images/rootfs.tar -C /srv/nfsroot/armhf/buildroot
    

テストスクリプトの作成

// 1:
#!/bin/bash

DEVFILE="/dev/mmcblk0"
DIRECTORY="/mnt"
TARGETFILE="FILE"

if [ ! -e ${DEVFILE} ]; then
        echo "Target device is not exist" 1>&2
        exit 1
fi

mountpoint -q ${DIRECTORY} || mount -t ext2 ${DEVFILE} ${DIRECTORY}

echo "Write: Test start"
mount | grep ${DIRECTORY}

sync
echo 3 > /proc/sys/vm/drop_caches

echo -n "HELLO" >> ${DIRECTORY}/${TARGETFILE}

sync
echo 3 > /proc/sys/vm/drop_caches

umount ${DIRECTORY}

調査方法

  1. QEMU上でLinuxカーネルを起動する。

     leava@leava-host:~/work$ start-qemu.sh
     ...
     [    2.193490][    T1] Run /sbin/init as init process
     Starting syslogd: OK
     Starting klogd: OK
     Running sysctl: OK
     Initializing random number generator: OK
     Saving random seed: [   34.958887][   T96] random: dd: uninitialized urandom read (512 bytes read)
     OK
     Starting rpcbind: OK
     Starting network: ip: RTNETLINK answers: File exists
     Skipping eth0, used for NFS from 10.0.2.2
     FAIL
     Starting dropbear sshd: OK
    
     Welcome to Buildroot
     buildroot login: root
     Password:
     #
    
  2. ホスト側からGDBでattachする。

     leava@leava-host:~/work$  cd /usr/local/src/buildroot/output/build/linux-5.15; ../../host/bin/arm-buildroot-linux-gnueabihf-gdb vmlinux
     GNU gdb (GDB) 10.2
     Copyright (C) 2021 Free Software Foundation, Inc.
     License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
     This is free software: you are free to change and redistribute it.
     There is NO WARRANTY, to the extent permitted by law.
     Type "show copying" and "show warranty" for details.
     This GDB was configured as "--host=x86_64-pc-linux-gnu --target=arm-buildroot-linux-gnueabihf".
     Type "show configuration" for configuration details.
     For bug reporting instructions, please see:
     <https://www.gnu.org/software/gdb/bugs/>.
     Find the GDB manual and other documentation resources online at:
         <http://www.gnu.org/software/gdb/documentation/>.
    
     For help, type "help".
     Type "apropos word" to search for commands related to "word"...
     Reading symbols from vmlinux...
     (gdb) target remote :1234
     Remote debugging using :1234
     cpu_v7_do_idle () at arch/arm/mm/proc-v7.S:78
     78              ret     lr
    
  3. 任意の関数 (ここでは、sys_writeに対して)ブレークポイントを設置する。

     (gdb) b sys_write
    
  4. プログラムの実行を再開する。

     (gdb) c
    
  5. 上記の環境で下記のコマンドを実行した場合のファイルアクセスの処理を調査する。

     # write-exec.sh
    

おわりに

本記事では、これからLinuxカーネルのファイルアクセスの処理を追いかけるための環境構築をした。
次回の記事では、作成した環境を用いて「writeシステムコールの実態からVFSレイヤまで」の処理を追いかける。

変更履歴

  • 2020/09/25: 記事公開
  • 2020/11/22: 調査対象 (Syscall Interface ~ デバイスドライバ) を追加
  • 2020/12/14: GDB接続手順の追記
  • 2020/12/17: アーキテクチャx86_64からARMに変更
  • 2020/12/18: 調査するカーネルのバージョンを5.7.19から5.10に変更
  • 2021/11/23: 環境構築をinitramfsからNFSを用いる方法に変更
  • 2022/08/21: 調査するカーネルのバージョンを5.10から5.15に変更
  • 2022/10/09: Buildroot製のルートファイルシステムに変更

参考