x86上のLinuxとFreeBSDにおいて、メモリキャッシュを無効にするカーネルモジュールを書いた話
どうも、前回の記事が思っていたよりも反響があり、Blogを書くモチベーションが高まり、また記事を書くことにします。
↑とか書いたんですが、このへんまで書いて50日くらい記事を書くのを放置していたので、ちゃっちゃと書き上げることにします。
今回はx86なCPUに存在するControl Registerのうち、CR0に存在するCache Disableフラグを操作するカーネルモジュールをLinux、FreeBSDのそれぞれ向けに作ったのでそれについて記述します。
それでは、続きからどうぞ。
はじめに、作成したカーネルモジュールのリポジトリへのリンクを掲載します。
Linux版のカーネルモジュール(procfsとして実装) cr0cd
FreeBSD版のカーネルモジュール(キャラクタデバイスとして実装)cr0cd_fbsd
そもそもこれはなに
一言で言うと、メモリのキャッシュ(L1, L2, L3)をdisableするやつです。
対象とするターゲット(というか、テスト済みの環境)は、Intelの64bit CPUで実行中の64bitなLinux or FreeBSDです。
どうやっているか
IntelのCPU(AMDのCPUはもってないので試してませんがあるはずです)にはControl Registerというものがあり(Wikipediaの記事)、これを操作することでCPUのもーどというか設定を切り替えたり出来ます。今回いじるのはCR0であり、Wikipediaには
CR0 has various control flags that modify the basic operation of the processor.
とかかれていて、たとえば、0bit目(0-indexed)はPE(Protected Mode Enable)に関する設定でこのビットを立てるとprotected modeにCPUを設定できます。 それで、今回は 30bit目のCD(Cache Disable)フラグを立てることで、システム全体のメモリキャッシュをdisableします。
どうやるのか
CR0などのControl Registerは一般の権限では操作できません。そのため、特権命令が扱えるカーネルランドで操作する必要があります。
そこで、カーネルモジュールとしてCR0の30ビット目を操作できるようにすれば実現できます。
Linux版とFreeBSD版もどちらも同じで、デバイスドライバとして作ります。デバイスの名前は、cr0cdとします。
使い方としては、CR0.CDに設定したい値をデバイスに書き込む感じで使えるようにしています。 つまり、
$ echo 0 > /path/to/cr0cd
でCache DisableをDisable(つまり、キャッシュが有効になる)
$ echo 1 > /path/to/cr0cd
でCache DisableをEnable(つまり、キャッシュがOFFになる)というかんじで、CR0のCDを操作できます。
また、現在のCR0の状態を確認できるように、 cr0cd_reader.c
(Linux版)、reader.c
(FreeBSD版)を用意しており、これは/path/to/cr0cd
を33byte readして
それを標準出力に出力することで、現在のCR0の状態を確認できるようにしています。
Linux版の実装と、FreeBSD版の実装について、基本的な概念は同じですが、LinuxとFreeBSDでのカーネルモジュール(Linux版のはprocファイルシステムとして、FreeBSDのはキャラクタデバイスとして実装してあります)の書き方の簡単な例としてどのように実装しているかを示したいと思います。(Linuxのデバドラの書き方はググればたくさん出てきますが、少なくとも日本語の情報という点においてはFreeBSDのキャラクタデバイスの書き方について書かれてる文章に僕は到達できなかったので、そういう情報が必要になる方のためにも書こうと思います)
具体的にLinux版の実装についての解説
上述しましたが、Linux版はprocファイルシステムとして実装しています。
初めに、GitHubで公開しているソースコードにコメントをつけたバージョンのコードを以下に示します。
#include <linux/fs.h> #include <linux/init.h> #include <linux/kernel.h> #include <linux/module.h> #include <linux/proc_fs.h> #include <linux/slab.h> #include <linux/types.h> #include <linux/uaccess.h> MODULE_LICENSE("Dual MIT/GPL"); #define DRIVER_NAME "cr0cd" #define PROC_NAME "cr0cd" // open(2)がこのデバイスに対して行われた時のハンドラ。今回は特にやることがない。 static int cr0cd_proc_open(struct inode *inode, struct file *file) { printk("%s IS OPEND\n", DRIVER_NAME); return 0; } // CR0の状態を保持するためのbit volatile static int CR0; // intの値を2進数文字列に変換する関数(staticにすべきだけど、忘れていた) void int_to_bits(int n, char *bits) { int i; for(i = 0; i < 32; i++) { bits[i] = (n >> (31 - i)) & 1 ? '1' : '0'; } } // read(2)が呼ばれた時のハンドラ。現在のCR0の状態を返す。戻り値は固定で33。 // このため、デバイスをcatしてCR0の状態を確認しようとするとreturnが0になるまで読もうとしてうまく動作しないのでreaderを作ったというわけ static ssize_t cr0cd_proc_read(struct file *file, char __user *buf, size_t count, loff_t *f_pos) { char bits[33]; printk("%s IS READ\n", DRIVER_NAME); // インラインアセンブリでCR0にアクセスできるので、raxにCR0.CDをコピーして // それをbitsという2進数文字列に変換する asm("mov %%cr0, %%rax; mov %%rax, CR0" ::: "rax"); // read int_to_bits(CR0, bits); bits[32] = '\0'; // copy_to_userで33ビット分データをユーザーランドに渡す copy_to_user(buf, bits, 33); return 33; } // write(2)が呼ばれた時のハンドラ。これが、cr0cdに対する書き込みをハンドルし、CR0.CDのフラグを操作する。 static ssize_t cr0cd_proc_write(struct file *file, const char __user *buf, size_t count, loff_t *f_pos) { char bits[33]; char mode[10]; printk("%s IS WRITTEN\n", DRIVER_NAME); // 現在の状態を取得 asm("mov %%cr0, %%rax; mov %%rax, CR0" ::: "rax"); // read bits[32] = '\0'; int_to_bits(CR0, bits); // 変更前のCR0をprintkしておく(デバッグのためにも便利) printk("<BEFORE> CURRENT CR0: %s", bits); // ユーザーランドから書き込まれたデータを取得 copy_from_user(mode, buf, sizeof(mode)); // 1が渡された場合、CaceをDisableする。 if(mode[0] == '1') { // disable printk("[DISABLE] CPU Cache\n"); CR0 |= 1 << 30; // 1を30bit左シフトしてorでフラグをたてる。 } else { // enable // メモリキャッシュを有効にする printk("[ENABLE] CPU Cache\n"); CR0 &= ~(1 << 30); // ビットマスクで30ビット目だけを0にしてandでフラグをおろす。 } // 設定されたCR0の状態をCR0に書き込み反映させる。 asm("mov CR0, %%rax; mov %%rax, %%cr0" ::: "rax"); // write int_to_bits(CR0, bits); // 変更後の状態をprintk printk("<AFTER> CURRENT CR0: %s", bits); return count; } // 上で書いたハンドラを設定する // handler table for proc_fs static struct file_operations cr0cd_proc_fops = { .owner = THIS_MODULE, .open = cr0cd_proc_open, .read = cr0cd_proc_read, .write = cr0cd_proc_write, }; // モジュールがロードされた時のハンドラ static int cr0cd_init(void) { struct proc_dir_entry *entry; printk("INTIALIZE %s\n", DRIVER_NAME); // procデバイスをつくる。 entry = proc_create(PROC_NAME, S_IRUGO | S_IWUGO, NULL, &cr0cd_proc_fops); if(entry == NULL) { printk(KERN_ERR " failed to create device %s\n", DRIVER_NAME); return -ENOMEM; } return 0; } // モジュールがunloadされたときのハンドラ static void cr0cd_exit(void) { printk("EXIT %s\n", DRIVER_NAME); remove_proc_entry(PROC_NAME, NULL); } // initとexitを設定する module_init(cr0cd_init); module_exit(cr0cd_exit);
取り敢えず、こんな感じです。
カーネルモジュールを取り敢えず作ってみたい場合は簡単で、ハンドラとかを考えずに、module_init
に渡す関数とmodule_exit
に渡す関数を定義し、それぞれをマクロで設定すればいいだけです(ライセンスをマクロで設定する必要もあるかも)。
また、MakefileはLinuxのカーネルモジュールのビルドシステムを用いて以下のように書きます。
# DST obj-m+=cr0cd.o # SOURCE CFLAGS_kfree.o := -g -O0 KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) VERBOSE = 0 all: $(MAKE) -C $(KDIR) M=$(PWD) KBUILD_VERBOSE=$(VERBOSE) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) KBUILD_VERBOSE=$(VERBOSE) clean
また、procファイルシステムにする場合は、procファイルシステムとしてのハンドラを定義し、module_init
に渡す関数の中でproc_create
を呼び出しprocデバイスを作る&ハンドラの設定をすればいいです。
今回はopen(2), write(2), read(2)に対するハンドラを定義し、それを設定して渡しました。
それぞれのハンドラの役割はコメントに書きましたが、要約すると以下のようになります。
- open: cr0cd_proc_openという関数。open(2)がデバイスに対して呼ばれたときのハンドラ。特に何もしない
- read: cr0cd_proc_readという関数。read(2)がデバイスに対して呼ばれたときのハンドラ。現在のCR0の状態を返す。
- write: cr0cd_proc_writeという関数。write(2)がデバイスに対して呼ばれたときのハンドラ。与えられたフラグをCR0.CDに設定する。
こんな感じです。
また、しれっと書いていますが、Linuxでは、ユーザーランドとカーネルランドでメモリ空間で別れています。そのため、互いのメモリ空間に直接アクセスすることは出来ません。
つまりこれが何を意味しているかと言うと、直接ポインタを渡したりできないということです。(これはFreeBSD版も同じです)
そのため、以下のようにしてカーネルランドとユーザーランドの間でmemcpyみたいな感じでデータをやり取りする必要があります。
- ユーザーランド→カーネルランド:
copy_from_user(&dst_kernel_ptr, &src_user_ptr, size_of_user_buffer)
- カーネルランド→ユーザーランド:
copy_to_user(&dst_user_ptr, &src_kernel_ptr, size_of_kernel_buffer)
みたいな感じでデータの受け渡しをwrite(2)とread(2)のハンドラでやっているというわけです。
具体的にFreeBSD版の実装についての解説
上述のとおり、FreeBSDにはprocファイルシステムがないので、キャラクタデバイスとして作ります。(Linux版もproc fsとしてじゃなくて、キャラクタデバイスとしてつくれるけど、procっぽいなってことでproc fsにした)
Linux版の解説と同様にコードを示しながら解説を簡単に行います。(Linuxのカーネルモジュールとかなり似た感じです)
#include <sys/types.h> #include <sys/module.h> #include <sys/systm.h> #include <sys/param.h> #include <sys/kernel.h> #include <sys/conf.h> #include <sys/uio.h> #define DEV_NAME "cr0cd" // ハンドラのプロトタイプ宣言 static d_open_t cr0cd_open; static d_close_t cr0cd_close; static d_read_t cr0cd_read; static d_write_t cr0cd_write; /* Character device entry points */ // ハンドラを設定 static struct cdevsw cr0cd_cdevsw = { .d_version = D_VERSION, .d_open = cr0cd_open, .d_close = cr0cd_close, .d_read = cr0cd_read, .d_write = cr0cd_write, .d_name = DEV_NAME, }; // これがデバイス static struct cdev *cr0cd_dev; volatile static int CR0; static void int_to_bits(int n, char *bits) { int i; for (i = 0; i < 32; i++) { bits[i] = (n >> (31 - i)) & 1 ? '1' : '0'; } } // モジュールがload, unloadされた時に呼ばれるハンドラ。 static int cr0cd_loader(struct module *m __unused, int what, void *arg __unused) { int error = 0; switch (what) { case MOD_LOAD: /* kldload */ // ロードされた時。 // 上で設定したハンドラとかを渡す。デバイスのユーザーとグループ、パーミッション、名前を設定する。 error = make_dev_p(MAKEDEV_CHECKNAME | MAKEDEV_WAITOK, &cr0cd_dev, &cr0cd_cdevsw, 0, UID_ROOT, GID_WHEEL, 0666, DEV_NAME); if (error != 0) break; printf("%s loaded.\n", DEV_NAME); break; case MOD_UNLOAD: // デバイスを消す destroy_dev(cr0cd_dev); printf("%s unloaded.\n", DEV_NAME); break; default: error = EOPNOTSUPP; break; } return (error); } // open(2)のハンドラ。特にやることはない。 static int cr0cd_open(struct cdev *dev __unused, int oflags __unused, int devtype __unused, struct thread *td __unused) { int error = 0; uprintf("Opened %s successfully\n", DEV_NAME); return (error); } // close(2)のハンドラ。特にやることはない。 static int cr0cd_close(struct cdev *dev __unused, int fflag __unused, int devtype __unused, struct thread *td __unused) { uprintf("Closing %s \n", DEV_NAME); return (0); } // read(2)のハンドラ。CR0の状態をユーザーランドに返す。 static int cr0cd_read(struct cdev *dev __unused, struct uio *uio, int ioflag __unused) { int error; char bits[33]; printf("%s is READ\n", DEV_NAME); asm("mov %%cr0, %%rax; mov %%rax, CR0" ::: "rax"); int_to_bits(CR0, bits); bits[32] = '\0'; // Linuxのcopy_to_userみたいなもん。 if ((error = uiomove(bits, 33, uio)) != 0) { uprintf("uiomove failed!\n"); } return error; } // write(2)のハンドラ。ユーザーランドから渡された情報を元にCR0.CDを操作する。 static int cr0cd_write(struct cdev *dev __unused, struct uio *uio, int ioflag __unused) { int error; char bits[33]; char mode[10]; printf("%s IS WRITTEN\n", DEV_NAME); asm("mov %%cr0, %%rax; mov %%rax, CR0" ::: "rax"); // read bits[32] = '\0'; int_to_bits(CR0, bits); printf("<BEFORE> CURRENT CR0: %s\n", bits); // Linuxのcopy_from_user的な。多分uioでユーザーランド→カーネルランドかその逆かを指定してるんだと思う error = uiomove(mode, sizeof(mode), uio); if (error != 0) { uprintf("Copy from user failed: bad address!\n"); return error; } if (mode[0] == '1') { // disable printf("[DISABLE] CPU Cache\n"); CR0 |= 1 << 30; } else { printf("[ENABLE] CPU Cache\n"); CR0 &= ~(1 << 30); } asm("mov CR0, %%rax; mov %%rax, %%cr0" ::: "rax"); // write int_to_bits(CR0, bits); printf("<AFTER> CURRENT CR0: %s\n", bits); return error; } // モジュールとして登録 DEV_MODULE(cr0cd, cr0cd_loader, NULL);
やってることはLinux版とほぼ同じなので特別な説明はいらない気がしますので、ユーザーランドとカーネルランドのデータの受け渡しについてだけ書きます。
あまり調べてないのでアレですが、どうやらFreeBSDではuiomove
という関数一つでやり取りを行っているようです。
関数(ハンドラ)の引数で渡されたuio
を引数に渡してuiomove
を呼ぶことで、write(2)
のハンドラならユーザーランド→カーネルランド方向、read(2)
のハンドラならカーネルランド→ユーザーランド方向のデータの受け渡しができるようです。
使い方としては、uiomove(dst_ptr, size_of_buffer, uio)
という感じです。
使い方
以下にLinux版と、FreeBSD版の使い方をそれぞれ示します。
CR0は、コアごとに存在するため、メモリキャッシュをdisableする場合はコア単位になります。
そこで、どのコアのキャッシュをdisableするかを指定するために、Linuxではtasksetを、FreeBSDではcpusetをつかってつかうコアを指定します。
以下の例では、CORE_NUMBER
という環境変数に、どのコアに対して操作を行うかを設定しています。
Linux版の使い方
$ git clone https://github.com/alphaKAI/cr0cd $ cd cr0cd $ make $ gcc -o cr0cd_reader cr0cd_reader.c $ sudo insmod cr0cd.ko $ CORE_NUMBER=2 $ taskset -c $CORE_NUMBER echo 1 > /proc/cr0cd # Disable CPU Cache $ taskset -c $CORE_NUMBER some command # キャッシュがDisableされた世界でコードを実行する $ taskset -c $CORE_NUMBER echo 0 > /proc/cr0cd # Enable CPU Cache $ taskset -c $CORE_NUMBER ./cr0cd_reader # display current CR0 bits
FreeBSD版の使い方
$ git clone https://github.com/alphaKAI/cr0cd_fbsd $ cd cr0cd_fbsd $ make $ gcc -o reader reader.c $ CORE_NUMBER=2 $ sudo kldload -v ./cr0cd.ko $ cpuset -l $CORE_NUMBER echo 1 > /dev/cr0cd # Disable CPU Cache $ cpuset -l $CORE_NUMBER some command # キャッシュがDisableされた世界でコードを実行する $ cpuset -l $CORE_NUMBER echo 0 > /dev/cr0cd # Enable CPU Cache $ cpuset -l $CORE_NUMBER ./reader # display current CR0 bits
実際にキャッシュをdisableするとどうなるか
stress-ngを用いて、メモリのアクセス速度を見てみます。これで、どのくらい性能が落ちたかの目安がわかります。
最初にスクリーンショットをLinux版、FreeBSD版のそれぞれでとったものを示します。
Linux版の実行例
FreeBSD版の実行例
えー、この実行結果を見れば一目瞭然なのですが、致命的に遅くなります。
Linux版では(キャッシュの有効/無効のそれぞれの最速値をもってきたとして) 1943.59 MB/sec → 1.02 MB/sec となり、つまり 1905 倍は遅くなっています。(約2000倍)
また、FreeBSD版ではキャッシュをdisableすると、おそすぎて測定不能となりました。
このことからわかるように、キャッシュをオフにすると致命的に動作が遅くなります。GUIを起動した状態でキャッシュをオフにすると、ほんとうに動作が遅くなるのを体感できます。
また、現代のCPUは複数のコアがあると思いますが、すべてのコアのキャッシュをdisableにした場合、本当の文字通り何もできなくなります(いや、できるんですが、おそすぎて実質何も出来ない)。
私は、実際にGUIを起動した状態でキャッシュをdisableすることにより、キャッシュの偉大さを身をもって実感しました。
何も考えなくてもメモリがキャッシュされている世界に生きていると、キャッシュの偉大さを体感する機会はあんまりないかもしれないので、人生経験としても一度メモリキャッシュを無効にするのはいい体験になると思います。
あ、かき忘れてましたが、このcr0cdも以前書いたファイルシステムの記事(ファイルシステムを自作しています.)と同様に、カーネルハックの自由課題の一つとして作りました。
なお、これを作ろうとした経緯としてはhikaliumさんの以下のTweetをみて、あ、つくったろ と思って作りました。
キャッシュ無効化されるとかいう状況を現代のソフトウエアは考慮していなさそうなので、無理やりhttps://t.co/4rlg1fYOvpを立てたら爆発しそう。(ニコニコ。)
— hikalium (@hikalium) 2019年5月19日
次回予告的な感じですが、次回こそminilispの記事を書きます。(今日、この記事を書いたのは書きかけのままお蔵入りになるのはもったいないと思ったので、先にこの記事をかきあげて公開しようと思ったからです。)
ではでは、このへんで。