LoginSignup
3

More than 1 year has passed since last update.

posted at

updated at

FUSEで「ブロ子の歌」を返すファイルシステムを作ってみる

Linux Advent Calendar 2020 第4日目の記事です。

LinuxにはFUSE(Filesystem in Userspace)という、ユーザランド上に仮想的なファイルシステムを構築するための機能があります。
今日の記事では、このFUSEを利用してちょっとしたサンプルを作成してみる話をしようと思います。

背景

今期のアニメにおちこぼれフルーツタルトがあります。この劇中歌に「ブロ子の歌」という同じフレーズを延々と繰り返す曲があり、なかなかの中毒性があります。
(ちなみに歌詞の繰り返しをグラフ化するアプリもご用意してございます)

brocco_graph2.png

「これはファイルを開くたびに歌詞を表示し続けるような振る舞いをさせると面白いのでは」と思い、FUSEを使用してサンプルアプリを作成してみようと考えたのが今日のお話の背景になります。

FUSEを使用する

FUSEカーネルモジュールをビルドする

実験環境としてCentOS-8上にLinux-5.9.3のカーネルをインストールして試してみました。
Linuxの make defconfig ではFUSEがビルドに含まれないようです。そのため今回はカーネルモジュールとしてFUSEをビルドしてみました。

既にカーネルがビルド済みの場合は、FUSEをカーネルモジュールとしてビルドする設定で対応できます。
具体的には、カーネルコンフィグの画面で"FUSE (Filesystem in Userspace) support"の設定値を「<M>」にすればOKです。

img01.png

上記の設定を入れたカーネルソースツリーで、 make fs/fuse/fuse.ko を実行するとFUSEのカーネルモジュール( fuse.ko )をビルドできます。

# cd /usr/src
# make fs/fuse/fuse.ko
  CALL    scripts/checksyscalls.sh
  CALL    scripts/atomic/check-atomics.sh
  DESCEND  objtool
  MODPOST Module.symvers
  CC [M]  fs/fuse/fuse.mod.o
  LD [M]  fs/fuse/fuse.ko

あとは fuse.ko を読み込めばLinuxカーネル側での準備は完了です。

# lsmod
Module                  Size  Used by
# insmod /usr/src/fs/fuse/fuse.ko
# lsmod
Module                  Size  Used by
fuse                  118784  1

libfuseのサンプルを参照してみる

サンプルのコンパイル

ユーザランド側でFUSE(のライブラリ)機能は、libfuse/libfuseを使用します。CentOS-8ではパッケージで提供されており、以下の手順でインストールできます。

$ sudo dnf install -y fuse3 fuse3-libs fuse3-devel

libfuse/libfuseのリポジトリにはサンプルコードも含まれているため、パッケージでFUSEの開発ライブラリをインストールして以下のようにサンプルをコンパイルして試してみるのが理解が進みやすそうです。

サンプルとして提供されている hello.c をコンパイルして動作させることで、FUSEの使い方を把握してみます。

以下の手順でサンプルのコンパイルが行えます。

$ git clone https://github.com/libfuse/libfuse.git
$ cd libfuse/example
$ gcc -g -o hello hello.c `pkg-config fuse3 --cflags --libs`

どうやら実行時引数にマウント先のディレクトリを指定するようです。ここでは mnt ディレクトリを作成し、そこにマウントしてみましょう。

$ mkdir mnt
$ ./hello ./mnt

プログラムを実行すると、すぐにプロンプトが返ってきます。 ps コマンドで確認するとプロセスが存在しており、端末から切り離されて実行されるような挙動になっていることが分かります。

また、 mnt ディレクトリの中を見ると、 hello というファイルが生えて(?)きていることも確認できます。

$ ps ax | grep hello
  10585 ?        Ssl    0:00 ./hello ./mnt
  10589 pts/0    S+     0:00 grep --color=auto hello
$ ls -l ./mnt/hello
-r--r--r-- 1 root root 13  1月  1  1970 ./mnt/hello

ファイル hello の中身を見ると、"Hello World!"というテキストが入っていました。

$ cat ./mnt/hello
Hello World!

ソースコードと照らし合わせて調べてみる

libfuse/libfuse/example/hello.cを見ると、マウントした時に生えてくるファイルを options.filename に指定し、ファイルの中身を options.contents に設定するようです。また、いずれも strdup() で得られたアドレスを渡していることから、おそらくはデータはヒープ領域に配置しておく、という制約があるのかもしれません。

example/hello.c
    150 int main(int argc, char *argv[])
    151 {
    ...
    155         /* Set defaults -- we have to use strdup so that
    156            fuse_opt_parse can free the defaults if other
    157            values are specified */
    158         options.filename = strdup("hello");
    159         options.contents = strdup("Hello World!\n");

そして、 struct fuse_operations のメンバ変数に関数ポインタを設定しています。名前から推測するに、 open(2)read(2) で呼び出される関数を指定しているのでしょう。

example/hello.c
    131 static const struct fuse_operations hello_oper = {
    132         .init           = hello_init,
    133         .getattr        = hello_getattr,
    134         .readdir        = hello_readdir,
    135         .open           = hello_open,
    136         .read           = hello_read,
    137 };

read(2) に対応しているであろう関数、 hello_read() の実装を見てみます。 offsetlen の計算を行っていたりしますが、肝心なのは124行目の memcpy(buf, options.contents + offset, size); で、単に options.contentsmemcpy() することにより、 read(2) 等で読みだされたデータとして返しているようです。

example/hello.c
    112 static int hello_read(const char *path, char *buf, size_t size, off_t offset,
    113                       struct fuse_file_info *fi)
    114 {
    115         size_t len;
    116         (void) fi;
    117         if(strcmp(path+1, options.filename) != 0)
    118                 return -ENOENT;
    119
    120         len = strlen(options.contents);
    121         if (offset < len) {
    122                 if (offset + size > len)
    123                         size = len - offset;
    124                 memcpy(buf, options.contents + offset, size);
    125         } else
    126                 size = 0;
    127
    128         return size;
    129 }

ということは、適宜 options.contents が指すデータを変えてゆくことでファイルを読みだすたびに中身が変化するという挙動を実現できそうです。

サンプル「broccofs」を作成してみる

さっそくFUSEのサンプルを作成してみましょう。「ブロ子の歌」の歌詞を延々と返し続けるファイルとしたいので、"broccofs"という名称にしてみます。サンプルコードは以下のgistに置いてあります。

libfuseのサンプルコード( hello.c )をベースに作成しているので、broccofsでの変更箇所のみを解説します。
データ構造としては単純で、歌詞データを文字列の配列として保持しておきます。

int main(int argc, char *argv[])
{
    ...

    // FUSEマウント時に表示させるファイル名を設定。
    options.filename = strdup("brocco.txt");

    // 「ブロ子の歌」データ。
    char *messages[] = {
        "ブロっ子\n",
        "ブロっ子\n",
        "ブロッコリー♪\n",
        "(ブロっ子)\n",
        NULL
    };

    // 歌詞データの行数をカウントする。
    for (options.max = 0; messages[options.max] != NULL; options.max++)
        ;

    // 歌詞データをstrdup()したうえで設定する。
    options.messages = malloc(sizeof(char *) * options.count);
    if (options.messages) {
        for (int i = 0; i < options.max; i++) {
            options.messages[i] = strdup(messages[i]);
        }
    }

    options.count = 0;
    options.contents = options.messages[0];

そして hello_read() が呼ばれる度に次に表示する歌詞データを差し替えてゆくという挙動になります。

static int hello_read(const char *path, char *buf, size_t size, off_t offset,
              struct fuse_file_info *fi)
{
    ...
    len = strlen(options.contents);
    memcpy(buf, options.contents, len);

    if (++(options.count) >= options.max) {
        options.count = 0;
    }

    // 参照するデータを切り替える。
    options.contents = options.messages[options.count];

    return size;
}

一点注意する個所として、 struct fuse_config->kernel_cache の値を 0 に設定しておくというものがあります。具体的な実装までは追えていませんが、構造体のメンバ変数名から推測するに、カーネル内でのデータキャッシュを行わない・行うという設定のようです。
(この値が 1 (0以外?)になっていると、 options.contents のデータを差し替えても変更が反映されないという挙動になります)

static void *hello_init(struct fuse_conn_info *conn,
            struct fuse_config *cfg)
{
    (void) conn;
    cfg->kernel_cache = 0;
    return NULL;
}

上記の実装・注意点を踏まえたうえでサンプルファイルを作成しました。コンパイルは以下の手順で行えます。

$ gcc -g -o brocco brocco.c `pkg-config fuse3 --cflags --libs`

さっそく実行してみましょう。 hello.c のサンプルと同様に、ファイル( brocco.txt )が生えてきています。

$ mkdir mnt01
$ ./brocco ./mnt01
$ ls -l ./mnt01/
合計 0
-r--r--r-- 1 root root 13  1月  1  1970 brocco.txt

そして想定通り、ファイルの中身を表示するたびに「ブロ子の歌」歌詞が表示されています。

 $ for i in `seq 1 13` ; do echo "`cat ./mnt01/brocco.txt` `echo '(^_^)/'`" ; done
ブロっ子 (^_^)/
ブロっ子 (^_^)/
ブロッコリー♪ (^_^)/
(ブロっ子) (^_^)/
ブロっ子 (^_^)/
ブロっ子 (^_^)/
ブロッコリー♪ (^_^)/
(ブロっ子) (^_^)/
ブロっ子 (^_^)/
ブロっ子 (^_^)/
ブロッコリー♪ (^_^)/
(ブロっ子) (^_^)/
ブロっ子 (^_^)/

Sixel Graphicsと組み合わせてみる

Sixelという、端末にビットマップグラフィックスを表示させるための拡張機能(というか端末シーケンス)があります。broccofsの発展例としておちこぼれフルーツタルトのアイコン画像をFUSE経由で参照するサンプルを作成してみましょう。

アイコン画像の取得とsixelファイル化

Sixelのフォーマットは、ビットマップを端末のエスケープシーケンスの形で表現したものであるため、PNG等の画像をSixelのエスケープシーケンスに変換しておく必要があります。

変換に必要なツールはsaitoha/libsixelリポジトリで提供されており、以下の手順でビルドします。

$ git clone https://github.com/saitoha/libsixel.git
$ cd libsixel
$ ./configure
$ make
$ file converters/img2sixel
converters/img2sixel: POSIX shell script, ASCII text executable, with very long lines

また、アイコン画像を縮小しておきたいので、ImageMagickも併せてインストールしておきます。

$ sudo dnf install epel-release
$ sudo dnf install ImageMagick

以下のスクリプトでアイコンのダウンロードとサイズ縮小、Sixelフォーマットへの変換を行っておきます。準備はこれで完了です。

#!/bin/sh

BASE_URL='http://ochifuru-anime.com/images/special/003/icon/'
L="
OF_icon_1.png
OF_icon_2.png
OF_icon_3.png
OF_icon_4.png
OF_icon_5.png
OF_icon_8.png
OF_icon_7.png
OF_icon_6.png
"
export LD_LIBRARY_PATH=../libsixel/src/.libs
for i in $L
do
    if [ ! -f $i ]; then
        echo "===> download $i"
        curl -sLO $BASE_URL/$i
        convert $i -resize 40% $i.resized
        ../libsixel/converters/img2sixel $i.resized > $i.resized.sixel
    fi
done

ソースコードは以下のgistに置いてあります。

基本的な構造は先述のサンプルと同じで表示する文字列をSixelデータに置き換えた形になります。

int main(int argc, char *argv[])
{
    ...
    options.filename = strdup("icon.txt");
    char *icons[] = {
        "OF_icon_1.png.resized.sixel",
        "OF_icon_2.png.resized.sixel",
        "OF_icon_3.png.resized.sixel",
        "OF_icon_4.png.resized.sixel",
        "OF_icon_5.png.resized.sixel",
        "OF_icon_6.png.resized.sixel",
        "OF_icon_7.png.resized.sixel",
        "OF_icon_8.png.resized.sixel",
        NULL
    };
    for (options.max = 0; icons[options.max] != NULL; options.max++)
        ;
    options.icons = malloc(sizeof(char *) * options.count);
    if (options.icons) {
        FILE *fp;
        for (int i = i ; i < options.max; i++) {
            size_t sz = sizeof(char) * (BUFSIZ * 20);
            FILE *fp = fopen(icons[i], "r");
            options.icons[i] = malloc(sz);
            if (options.icons[i] != NULL && fp != NULL) {
                fread(options.icons[i], sz, 1, fp);
                //fclose(fp); // double-freeが発生して原因が特定できないので(ホントはダメだけど)コメントアウト。
            }
        }
    }
    options.count = 0;
    options.contents = options.icons[0];

以下の手順でコンパイル・実行します。

$ gcc -g -o of_icon of_icon.c `pkg-config fuse3 --cflags --libs`
$ mkdir mnt02
$ ./of_icon ./mnt02

実行してみると端末にアイコン画像が表示されます!
ls -l の結果をみると、ファイルを開くたびにサイズが変わる(=内部的に次のデータに切り替えられている)ことが確認できます。

sample.gif

その他:FUSEの実行でエラーになった場合の対応方法

FUSEでアプリを作成している際にプログラムが落ちたりすると、以下のようなエラーが出ることがあります。このエラーがでると、別のディレクトリを指定しない限りFUSEでマウントできない状況に陥ってしまいます。

fuse: bad mount point `./mnt': Transport endpoint is not connected

このような場合は、 fusermount コマンドでディレクトリをアンマウントすることで問題が解消します。

$ fusermount -u ./mnt

まとめ

FUSEを利用するためのカーネル側・ユーザランド側の設定手順と、FUSEライブラリを用いた簡単なサンプルアプリの紹介をしました。
FUSE側で返したいデータを read(2) 等のタイミングで操作できるのと、比較的シンプルな手順で仮想的なファイルシステムを構築できるので、より面白い応用例を考えたいと思います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
3