Posted at
LinuxDay 17

initramfsについて

More than 1 year has passed since last update.

久々のQiita投稿がAdvent Calendarになった@akachochinです。異分野のソフトエンジニアから質問されたこと、個人的に不明瞭な箇所を調べてみたかったことがあり、今回はinitramfs周りの実装の基本的な部分をまとめました。

このあたり、地味ですが、知っていると評価用ボードでとりあえずのLinux環境を構築できたり、意外と有用です。

なお、ソースコードを引用したLinuxのバージョンは4.12.7, アーキテクチャは断りのない限り、amd64もしくはi386です。


initramfsとは


initramfsの説明

Documentation/filesystems/ramfs-rootfs-initramfs.txt に概要が書かれています。

日本語訳もあります。このドキュメントはinitramfsを知るためには必読だと思います。

面倒くさい方のために(笑)、かいつまんで書くと・・・

- かつてはinitrdという「ファイルシステムイメージ」と「RAM上に作られた擬似ブロックデバイス」を使っていました。

- これだと、結局initrdを解釈するためのファイルシステムを組み込む必要があったりするなどデメリットが多いです。

- そこで、今のinitramfsの仕組みができました。メモリ上にファイルシステムを作っておき、そこにinitramfsのイメージを展開します。

- initramfsのイメージはcpio形式のアーカイブファイルをgzip圧縮したものです。(圧縮形式はgzip形式以外にも複数サポートしているようです)

ここで、cpio形式について簡単に触れておきます。


cpioの簡単な解説と図

ざっくり書くと、こんな感じです。(厳密には誤っている箇所もあると思いますが、この文書を読むためには、これで事足りると思います。)

また、ヘッダフォーマットについては、FreeBSDのman和訳IBMの記事が参考になります。


どこからinitramfsは渡されるのか

ブートローダ経由で渡されます。今回見るブートローダはgrubです。

例えば、自分が使っているLinux Mintの場合、/boot/grub/grub.cfgに以下のようなエントリがあります。

以下エントリのinitrd行で指定したファイルがinitramfsイメージです。このイメージは先に書いたcpioフォーマット書庫をgzip圧縮したものです。(なお、以下エントリのUUIDは意図的に変更しています。)


/boot/grub/grub.cfg

menuentry 'Linux Mint 18.2 MATE 64-bit' --class ubuntu --class gnu-linux --class gnu --class os $menuentry_id_option 'gnulinux-simple-aaaaaaaa-bbbb-cccc-dddd-86d6d6df4f7a' {

recordfail
load_video
gfxmode $linux_gfx_mode
insmod gzio
# 略
linux /boot/vmlinuz-4.8.0-53-generic root=UUID=XXXX-YYYY-ZZZZ-HHHH-AAAAAAAA ro quiet splash $vt_handoff
initrd /boot/initrd.img-4.8.0-53-generic
}


まず、grubを見る

以下grubのgrub_cmd_initrd()を見ます。これは、grub.cfgのinitrd行で指定したファイルをRAMに展開し、展開した領域の物理アドレスとサイズをLinuxに渡すための関数です。なお、日本語コメントは私が追加しました。

なお、以下grub_cmd_initrd()はアーキテクチャごとに存在します。ここではi386/pcの下にある関数を抜粋しました。


grub-core/loader/i386/pc/linux.c

static grub_err_t

grub_cmd_initrd (grub_command_t cmd __attribute__ ((unused)),
int argc, char *argv[])
{
// 略
// ここでinitrdで指定されたファイル名を取得し、open()し、ファイルサイズを取得します。
if (grub_initrd_init (argc, argv, &initrd_ctx))
goto fail;

size = grub_get_initrd_size (&initrd_ctx);
// 略
{
// initramfsの中身を格納するための領域を確保します。
grub_relocator_chunk_t ch;
err = grub_relocator_alloc_chunk_align (relocator, &ch,
addr_min, addr_max - size,
size, 0x1000,
GRUB_RELOCATOR_PREFERENCE_HIGH, 0);
if (err)
return err;
initrd_chunk = get_virtual_current_address (ch);
initrd_addr = get_physical_target_address (ch);
}

// 先ほど確保した領域にinitrdで指定されたファイルの中身をコピーします。
if (grub_initrd_load (&initrd_ctx, argv, initrd_chunk))
goto fail;

// Linuxカーネルヘッダを通して、以下2つのパラメータをLinuxカーネルに引き渡します。
lh->ramdisk_image = initrd_addr;
lh->ramdisk_size = size;

fail:
grub_initrd_close (&initrd_ctx);

return grub_errno;
}


なお、上記コメントで「Linuxカーネルヘッダ」と書きましたが、これは「ヘッダファイル」でなく、grubがメモリ経由でLinuxに引き渡す各種パラメータ群のことです。また、カーネルに渡しているアドレスは物理アドレスであることも注意してください。


舞台はLinuxカーネルへ

initramfsに関して、Linuxカーネルでは、主に以下3つのことを実施します。


  • ブートローダから受け取ったinitramfsの物理アドレスに対応した仮想アドレスをマップする

  • ブートローダがRAM上に配置したinitramfsを展開し、rootfsを作成する

  • rootfs上のinitを実行する


ブートローダから受け取ったinitramfsの物理アドレスに対応した仮想アドレスを獲得する

Linuxがブートする際、多くの初期化をstart_kernel()で実施します。

さらに、start_kernel()は、アーキテクチャに依存した初期化をするために、setup_arch()を呼び出します。

setup_arch()では、以下の2つの関数を呼び出します。


arch/x86/kernel/setup.c

void __init setup_arch(char **cmdline_p)

{
// 略
early_reserve_initrd();
// 略
reserve_initrd();

early_reserve_initrd()は、initramfsが置かれた領域を他の用途に使われないように予約する関数です。

get_ramdisk_image()はブートローダから渡されたinitramfs領域の先頭物理アドレスを、get_ramdisk_size()はinitramfs領域のサイズを返します。

これら2つのパラメータを使い、memblock_reserve()を呼び出すことで指定された物理メモリを予約します。


arch/x86/kernel/setup.c

static void __init early_reserve_initrd(void) 

{
/* Assume only end is not page aligned */
u64 ramdisk_image = get_ramdisk_image();
u64 ramdisk_size = get_ramdisk_size();
u64 ramdisk_end = PAGE_ALIGN(ramdisk_image + ramdisk_size);

// 略

memblock_reserve(ramdisk_image, ramdisk_end - ramdisk_image);
}


reserve_initrd()は、initramfsが置かれた物理メモリ領域に対応した仮想アドレスをマップする関数です。


arch/x86/kernel/setup.c

static void __init reserve_initrd(void)

{
/* Assume only end is not page aligned */
u64 ramdisk_image = get_ramdisk_image();
u64 ramdisk_size = get_ramdisk_size();
u64 ramdisk_end = PAGE_ALIGN(ramdisk_image + ramdisk_size);
u64 mapped_size;

// 略

// これが一番簡単なケース。詳細を追うことは述べたいことの本質から外れるため、以下if文は真である
// ことを前提に話を進めます。
if (pfn_range_is_mapped(PFN_DOWN(ramdisk_image),
PFN_DOWN(ramdisk_end))) {
/* All are mapped, easy case */
// 勘違いしがちですが、PAGE_OFFSETはカーネル空間の開始仮想アドレスで、ストレートマップの先頭です。
// よって、これは物理アドレス -> 仮想アドレスへの変換です。
initrd_start = ramdisk_image + PAGE_OFFSET;
initrd_end = initrd_start + ramdisk_size;
return;
}

relocate_initrd();

memblock_free(ramdisk_image, ramdisk_end - ramdisk_image);
}


該当物理メモリは、ストレートマップされている低位の領域です。よって、PAGE_OFFSETを足すことでその仮想アドレス空間を得られるのです。

PAGE_OFFSETやストレートマップがよくわからない方は、こちら「Linuxカーネルメモ」を参照されると良いでしょう。

なお、領域先頭の仮想アドレスを格納するinitrd_startと、末尾の仮想アドレスを格納するinitrd_endは、init/do_mounts_initrd.cで宣言されたグローバル変数です。

よって、初期値は0で、これら2変数に値がセットされたということは、ブートローダ経由でinitramfsイメージが渡されたことを意味します。これは後述するコードで大切になります。

これで、initramfsが置かれた物理メモリ空間に対応した仮想アドレスを得ることができました。


ブートローダがRAM上に配置したinitramfsを展開し、rootfsを作成する

initramfsを展開する処理はpopulate_rootfs()です。populate_rootfs()はいつ呼ばれるのでしょうか。

全体感がわかりにくいと思われるので、initramfsに関連した処理の呼び出し関係図を書きました。以下図のとおり、populate_rootfs()はカーネルのブート処理の最後に近い箇所で呼ばれます。rootfs_initcall(populate_rootfs)があるため、do_initcalls()経由で呼ばれることがわかるのです。

詳しいことは、Linux Advent Calendar 12日目「Linuxのドライバの初期化が呼ばれる流れ」に書かれています。良記事です。

※先の記事によると「使ってるところが数えるほどしかない」rootfs_initcall()を使っている場所の一つです(笑)。

あらためて、populate_rootfs()を見ます。


init/initramfs.c

static int __init populate_rootfs(void)

{
/* Load the built in initramfs */
char *err = unpack_to_rootfs(__initramfs_start, __initramfs_size);
if (err)
panic("%s", err); /* Failed to decompress INTERNAL initramfs */
/* If available load the bootloader supplied initrd */
if (initrd_start && !IS_ENABLED(CONFIG_INITRAMFS_FORCE)) {
#ifdef CONFIG_BLK_DEV_RAM
// この箇所はやや実装が複雑なため、略。
// 結局はunpack_to_rootfs()を呼ぶという本質的な箇所には変わりがない。
#else
printk(KERN_INFO "Unpacking initramfs...\n");
err = unpack_to_rootfs((char *)initrd_start,
initrd_end - initrd_start);
if (err)
printk(KERN_EMERG "Initramfs unpacking failed: %s\n", err);
free_initrd();
#endif
}
flush_delayed_fput();
/*
* Try loading default modules from initramfs. This gives
* us a chance to load before device_initcalls.
*/

load_default_modules();

return 0;
}
rootfs_initcall(populate_rootfs);


populate_rootfs()は、unpack_to_rootfs()を呼び出してinitramfsをrootfsに展開します。

しかし、よく見ると、unpack_to_rootfs()を先頭で呼び出して、引用コード内のif文が成立した場合にもう一度unpack_to_rootfs()を呼び出しています。これは何なのでしょうか。

実は、Linuxカーネルをビルドする際、デフォルトのinitramfsがカーネルに埋め込まれます。1回目に呼び出すunpack_to_rootfs()ではこれを展開します。

また、if文が意味するのは「ブートローダからinitramfsを受け取っており、それを使う場合」です。

この判定が真の場合、ブートローダから受け取ったinitramfsをrootfsに展開します。これが2回目のunpack_to_rootfs()です。

今回は「ブートローダから渡されたinitramfsイメージをそのまま使う」ユースケースを考えます。しかし、デフォルトのinitramfsやカーネルコンフィグについても興味深いため、補足の章を別途設けて、そちらで書くことにします。


unpack_to_rootfs

unpack_to_rootfs()でinitramfsを展開します。


init/initramfs.c

static char * __init unpack_to_rootfs(char *buf, unsigned long len)

{
// 略
state = Start;
this_header = 0;
message = NULL;
while (!message && len) {
// 略
// initramfsの圧縮形式に対応した展開関数の関数ポインタを取得する。
decompress = decompress_method(buf, len, &compress_name);
// 略
// initramfsの圧縮形式に対応した展開関数を呼び出す。
if (decompress) {
int res = decompress(buf, len, NULL, flush_buffer, NULL,
&my_inptr, error);
if (res)
error("decompressor failed");
// 略
}
// 略
}

実は、decompress以降の展開処理は一見するとわかりにくいです。図示すると以下のとおりです。

なお、図中に書きましたが、initramfsはgzip圧縮されているものとします。

また、上記引用のdecompress_method()で取得できる関数の実体は lib/decompress_inflate.c にあります。詳しく知りたい方はぜひコードを読んでください。

上記図のwrite_buffer()は以下のとおりです。現在のcpio解析の状態を示すstateの値に応じた関数が呼ばれることがわかります。


init/initramfs.c

static __initdata int (*actions[])(void) = {

[Start] = do_start,
[Collect] = do_collect,
[GotHeader] = do_header,
[SkipIt] = do_skip,
[GotName] = do_name,
[CopyFile] = do_copy,
[GotSymlink] = do_symlink,
[Reset] = do_reset,
};

static long __init write_buffer(char *buf, unsigned long len)
{
byte_count = len;
victim = buf;

while (!actions[state]())
;
return len - byte_count;
}


ここでは、「ファイルやディレクトリの名前」を見つけたときに呼ぶdo_name()を見ましょう。

例えば、見つけた名前が「普通のファイル」を指している場合、O_CREAT付きでopen()してファイルを新規作成します。また、見つけた名前がディレクトリならsys_mkdir()してディレクトリを作成します。


init/initramfs.c

static int __init do_name(void)

{
// 略
clean_path(collected, mode);
if (S_ISREG(mode)) {
int ml = maybe_link();
if (ml >= 0) {
int openflags = O_WRONLY|O_CREAT;
// 略
wfd = sys_open(collected, openflags, mode);
// 略
}
} else if (S_ISDIR(mode)) {
sys_mkdir(collected, mode);
sys_chown(collected, uid, gid);
sys_chmod(collected, mode);
dir_add(collected, mtime);
} else if (S_ISBLK(mode) || S_ISCHR(mode) ||
S_ISFIFO(mode) || S_ISSOCK(mode)) {
if (maybe_link() == 0) {
sys_mknod(collected, mode, rdev);
sys_chown(collected, uid, gid);
sys_chmod(collected, mode);
do_utime(collected, mtime);
}
}
return 0;
}

しかし、ちょっと考えると奇妙です。cpioイメージの内容に応じて、当然のようにsys_open()呼んだりsys_mkdir()呼んだりしています。これらの関数はファイルシステムの存在を前提としています。

一体どこにある何のファイルシステムを操作しているのでしょうか。実は、rootfsです。次でrootfsの初期化について確認します。


rootfsって?それはどこで準備されているの?

先の関数呼び出し図を見てください。この中にmnt_init()という関数があります。

この中で、rootfsの登録、初期化、マウントを行っています。mnt_init()は以下のとおりです。


fs/namespace.c

void __init mnt_init(void)

{
// 略
init_rootfs();
init_mount_tree();
}

rootfsの登録は以下のとおり、init_rootfs()で行います。


init/do_mounts.c

static struct file_system_type rootfs_fs_type = {

.name = "rootfs",
.mount = rootfs_mount,
.kill_sb = kill_litter_super,
};

int __init init_rootfs(void)
{
int err = register_filesystem(&rootfs_fs_type);
// 略
err = init_ramfs_fs();
// 略
}


init_rootfs()では、rootfsの前提となるramfsも登録しておきます。

後は、rootfsのマウントを行い、rootfsが見えるようにします。

(この関数を読んで、vfsmount構造体の使い方にちょっと感心しました。ぜひ、普通のmount()との対比で読んでみてください。)


fs/namespace.c

static void __init init_mount_tree(void)

{
struct vfsmount *mnt;
struct mnt_namespace *ns;
struct path root;
struct file_system_type *type;

type = get_fs_type("rootfs");
if (!type)
panic("Can't find rootfs type");
mnt = vfs_kern_mount(type, 0, "rootfs", NULL);
// 略

root.mnt = mnt;
root.dentry = mnt->mnt_root;
mnt->mnt_flags |= MNT_LOCKED;

set_fs_pwd(current->fs, &root);
set_fs_root(current->fs, &root);
}


なお、rootfsのマウント処理など詳細は割愛します。

何はともあれ、ここまで書いてきたコードで、rootfsの準備ができました。これでinitramfsの展開が可能になります。

また、前の章では、「initramfsをブートローダから受け取る」ことと、「cpioの解析を行いつつ、rootfsにその中身を展開する」ことまで確認ができています。

よって、後はinitを実行するだけです。


rootfs上のinitを実行する

先の関数呼び出し図にもあるとおり、kernel_init()から以下のようにtry_to_run_init_process()を呼び出すことで最終的にdo_execve()を呼び出し、自身をinitとして実行します。


init/main.c

static int __ref kernel_init(void *unused)

{
// 略
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;

panic("No working init found. Try passing init= option to kernel. "
"See Linux Documentation/admin-guide/init.rst for guidance.");
}


大半のシステムでは、initramfsを展開したrootfsの中で、HDD, SSDの中に含まれるファイルシステムをrootfsとしてリマウントします。

このリマウント処理については、この文書では記載しません。


補足


カーネルに組み込まれているinitramfsについて

先に書いたとおり、populate_rootfs()の最初のunpack_to_rootfs()を用いて、カーネルに埋め込まれたinitramfsを展開します。


init/initramfs.c

extern char __initramfs_start[];

extern unsigned long __initramfs_size;
/* 略 */

static int __init populate_rootfs(void)
{
/* Load the built in initramfs */
char *err = unpack_to_rootfs(__initramfs_start, __initramfs_size);


領域の先頭仮想アドレス__initramfs_startは、以下のようなシンボルです。


include/asm-generic/vmlinux.lds.h

#define INIT_RAM_FS                         \

. = ALIGN(4); \
VMLINUX_SYMBOL(__initramfs_start) = .; \
KEEP(*(.init.ramfs)) \
. = ALIGN(8); \
KEEP(*(.init.ramfs.info))

このINIT_RAM_FSを使っているのが以下リンカスクリプトとなります。


arch/arc/kernel/vmlinux.lds.S

SECTIONS

{
// 略
__init_begin = .;

.init.ramfs : { INIT_RAM_FS }

. = ALIGN(PAGE_SIZE);
_stext = .;
// 略


さらに、.init.ramfsの具体的な内容は以下で実装されています。これを見ると、その中身はINITRAMFS_IMAGEで指定されることがわかります。


usr/initramfs_data.S

# .init.ramfsセクションに__irf_startから__irf_endまでを含めます。

# 属性は"a" == allocatable です。
.section .init.ramfs,"a"
__irf_start:
# .incbinディレクティブにより、指定したファイルの内容を含めます。
# つまり、このセクションの中身をINITRAMFS_IMAGEで指定されたファイルの中身で埋めます。
.incbin __stringify(INITRAMFS_IMAGE)
__irf_end:
.section .init.ramfs.info,"a"
.globl VMLINUX_SYMBOL(__initramfs_size)
VMLINUX_SYMBOL(__initramfs_size):

INITRAMFS_IMAGEは以下のとおり、usr/Makefileで定義されています。


usr/Makefile

# 略

datafile_y = initramfs_data.cpio$(suffix_y)
AFLAGS_initramfs_data.o += -DINITRAMFS_IMAGE="usr/$(datafile_y)"
# 略
$(obj)/initramfs_data.o: $(obj)/$(datafile_y) FORCE
# 略
$(obj)/$(datafile_y): $(obj)/gen_init_cpio $(deps_initramfs) klibcdirs
$(Q)$(initramfs) -l $(ramfs-input) > $(obj)/.initramfs_data.cpio.d
$(call if_changed,initfs)

このイメージファイルは、カーネルビルドの際に生成されます。だいたい以下のような感じです。

- usr/gen_init_cpio.c をビルドして、gen_init_cpioを生成します。これは、イメージファイルを生成するツールです。

- その後、「どのようなファイル構成をするかを記述したテキストファイル」を作り、それをgen_init_cpioに渡します。このファイルの指定がない場合(-d)、デフォルトのファイル構成でイメージファイルが作られます。

Documentation/filesystems/ramfs-rootfs-initramfs.txt の「Populating initramfs:」に詳しいことが書かれていますので、さらなる詳細はそちらに譲ります。

※再度、日本語訳のリンクを付けておきます。


initramfs関連のカーネルコンフィグ

自前でinitramfsイメージを作りたい場合、有用と思われるカーネルコンフィグを2点挙げます。


INITRAMFS_FORCE

INITRAMFS_FORCEをyにすると、ブートローダから渡されたinitramfsを使いません。

Symbol: INITRAMFS_FORCE [=n]

Type : boolean
Prompt: Ignore the initramfs passed by the bootloader
Location:
-> General setup
(1) -> Initial RAM filesystem and RAM disk (initramfs/initrd) support (BLK_DEV_INITRD [=y])
Defined at usr/Kconfig:24
Depends on: BLK_DEV_INITRD [=y] && (CMDLINE_EXTEND || CMDLINE_FORCE)


INITRAMFS_SOURCE

INITRAMFS_SOURCEは、先に書いたgen_init_cpioに渡すテキストファイルのパスを設定します。

すると、デフォルトのファイル構成で生成されたイメージでなく、指定した構成で生成されたイメージがカーネルに埋め込まれます。

Symbol: INITRAMFS_SOURCE [=]

Type : string
Prompt: Initramfs source file(s)
Location:
-> General setup
(2) -> Initial RAM filesystem and RAM disk (initramfs/initrd) support (BLK_DEV_INITRD [=y])
Defined at usr/Kconfig:5
Depends on: BLK_DEV_INITRD [=y]


おわりに

今年も残り少ないですが、今年も来年もHappy Hacking!