
本記事は Emacs - Qiita Advent Calendar 2025 - Qiita の19日目の記事です。
成果物
github.com
Emacs 上で NOMMU Linux が動いている様子
セットアップや実行方法は README をご覧ください。
経緯
私が本プロダクトを作るきっかけは、遡ること1年前、builderscon 2024 で @bokuweb17 さんのトークセッションを見たことでした。
speakerdeck.com
様々な言語で RISC-V のエミュレータを実装する、というネタは昔からあるらしく*1、
また僕の趣味の1つに「Emacs Lisp で様々なエミュレータを書いてみる (例: JVM, NES)」というものがあったので、前例が無いこと*2も後押しとなって「やってみようかな!」という気持ちになりました。
余談ですが、7年前に Emacs で動く NES エミュレータを作ったきっかけ (Hatena Blog) も、実は builderscon tokyo 2018 の bokuweb さんの発表 (Speaker Deck) でした。bokuwebさんには「いつもありがとう……面白いネタを教えてくれて……」という気持ちでいっぱいです。
参考資料
本記事では riscv.el 特有の話題(Emacs Lisp 実装に関する制限とか工夫とか)に焦点を当てるため、RISC-V アーキテクチャ自体の説明は参考資料に全て丸投げします。
- 参考実装関連
- RISC-V 関連
- ELF (Executable and Linkable Format) 関連
動作するまでの話
時系列でふんわりと
1. CPU の実装
エミュレータを作るにはとにかく CPU が無いと始まらない!ということで、必要な命令を Emacs Lisp に落とし込んでいく作業を始めました。
今回ターゲットとしている mini-rv32ima は Implements a RISC-V rv32ima/Zifencei+Zicsr (and partial su), with CLINT and MMIO. を謳っているため、必要な命令は以下のとおりです。*3
- I (RV32I: 基本整数命令セット) - LOAD/STORE, 分岐, 演算など
- M (乗算・除算拡張) - MUL, DIVなど
- A (アトミック命令拡張) - LR.W, SC.W, AMO系など
- Zifencei (Zifencei拡張) - FENCE.I
- Zicsr (CSR命令拡張) - CSR (Control and Status Register) 操作命令
命令コードのパースや各種演算処理(主にビット演算)については emacs-nes の経験を活かしてガリガリ書いていきました。
実装メモ:cl-destructuring-bind との出会い
定められたフォーマット(S-TypeとかI-Type)でパースした結果を参照するところで、初めて cl-destructuring-bind を使いました。
(let ((ir '(:opcode 100 :a "aa" :b "gongo")))
(cl-destructuring-bind (&key opcode a &allow-other-keys) ir
(message "opcode = %d, a = %s" opcode a)))
Lisp でもパターンマッチングっぽいコードが書けることを知りました。結構好き。
ちなみに cl-destructuring-bind は defmacro で定義されているため、インタプリタ実行時はマクロ展開のオーバーヘッドがありますが、byte-compile するとあまり気にならなくなりました。
ある程度実装が終わったところで「動作確認をするぞ!」となり、ユニットテスト用テストイメージとして riscv-tests を使うことにしました。
This repository hosts unit tests for RISC-V processors.
https://github.com/riscv-software-src/riscv-tests
riscv-tests は RISC-V 命令セットの動作を検証するための公式テストスイートです。今回必要としている各命令についてもテストケースが網羅されていたので、使わせてもらいました。
rv32ui-p-* (User-level I instruction) - I と Zifencei 向け
rv32um-p-* (User-level M instruction) - M 向け
rv32ua-p-* (User-level A instruction) - A 向け
rv32si-p-* (Supervisor-level instruction) - 特権命令 (partial su: Supervisor モードの部分的サポート) と Zicsr
riscv-tests の解説も先人の記事にお任せするとして、めちゃくちゃざっくり書くと
- テスト開始(メインループ開始)
tohost アドレスが変わったらテスト終了を意味する
gp レジスタが「1 なら PASS」「1 以外なら FAIL」を意味する
こういう仕組みになっています。そういうわけでテストランナーはこういう感じで書きました。
(defun riscv--cpu-run (cpu tohost-addr)
(while (= (riscv--cpu-read32 cpu tohost-addr) 0)
))
(defun riscv-tests-run (&optional filepath)
(let* ((filepath (if noninteractive
(car command-line-args-left)
filepath))
(parsed (riscv--riscv-tests-load-file filepath))
(tohost-addr (car parsed))
(bus (make-riscv--bus :ram (cadr parsed)))
(cpu (make-riscv--cpu :bus bus))
gp)
(riscv--cpu-set-next-pc cpu :absolute riscv-ram-base-addr)
(riscv--cpu-run cpu tohost-addr)
(setq gp (aref (riscv--cpu-x cpu) 3))
(if noninteractive
(if (eq gp 1)
(kill-emacs 0)
(kill-emacs 1))
gp)))
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-tests-runner.el#L128-L153
テスト結果はこう!(一部のテストが落ちるのは想定どおりです)
$ make test
[PASSED] ./fixtures/riscv-tests-isa/rv32si-p-csr
[FAILED] ./fixtures/riscv-tests-isa/rv32si-p-dirty (expected - see note below)
[FAILED] ./fixtures/riscv-tests-isa/rv32si-p-ma_fetch (expected - see note below)
[PASSED] ./fixtures/riscv-tests-isa/rv32si-p-sbreak
[PASSED] ./fixtures/riscv-tests-isa/rv32si-p-scall
(snip)
[PASSED] ./fixtures/riscv-tests-isa/rv32um-p-mulhu
[PASSED] ./fixtures/riscv-tests-isa/rv32um-p-rem
[PASSED] ./fixtures/riscv-tests-isa/rv32um-p-remu
--- Note ---
The following tests are expected to fail:
- rv32si-p-dirty: Requires virtual memory (paging) support
- rv32si-p-ma_fetch: Requires misaligned instruction fetch exception
$
実装メモ:ELFファイルの解析
前項で「tohost アドレスが変わったらテスト終了を意味する」と書きましたが、それではこの tohost アドレスはどこなのかというと、実は テストファイルによって変わる のです。
$ /path/to/riscv64-unknown-elf-readelf -S rv32ui-p-add | grep tohost
[ 2] .tohost PROGBITS 80001000 002000 000048 00 WA 0 0 64
$ /path/to/riscv64-unknown-elf-readelf -S rv32ui-p-ld_st | grep tohost
[ 2] .tohost PROGBITS 80002000 003000 000048 00 WA 0 0 64
このようにELFセクションヘッダーの .tohost で定義されています。
大多数は .tohost = 0x80001000 であり、それ以外になっているのは本当に例外的(対象とするテストファイルのうち1個だけ)なので、そのテストだけは無視しても良かったのです。
ですがせっかくなのでこいつ (rv32ui-p-ld_st) も救ってあげたいよなぁとなりました。
とはいえ「このファイルは tohost = 0x80002000 で、それ以外は 0x80001000 にして」とテストランナー内で決め打ちするのも面白みに欠けるため、「ならばELFファイルを解析してセクションヘッダーを参照すればいいじゃん」となり、やりました。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-tests-runner.el#L56-L79
これにより、テストファイル毎に「正しい終了条件」を扱えるようになりました。
3. 文字の出力
命令の実装も終わり、いよいよ Linux イメージを読み込ませていくぞ!というところでまず必要になるのは画面出力ですね。
基本的には UART (Universal Asynchronous Receiver-Transmitter) を Emacs Lisp でエミュレートすることになります。
- Linux が UART のアドレス(
0x10000000)に文字を書き込んだら、Emacs バッファに insert する
- Emacs バッファの入力内容を保持しておき、Linux が UART から読み込もうとしたらその文字を返す
そこらへんを意識しつつなんやかんやあって、ついに表示されました!
Linuxのブートログが止まってログインプロンプトが見えている様子。一部文字がおかしいことになっているが、本家(mini-rv32ima)でも再現するので、そういうものなのだと思うことにする
実装メモ:ANSI カラー対応
Linux では実行結果に色が付いていることがあり、これも Emacs バッファ上で対応させておきたいです。
Emacs は標準パッケージで ansi-color.el を持っているので、これで対応できます。ansi-color-apply 関数に文字列を渡すと、ANSIエスケープシーケンスを解釈して色付きテキストに変換してくれます。
(ansi-color-apply "He\x1b[0;32mll\x1b[mo")
"He" + 緑色の "ll" + "o"
ここで気になっていたのが「UART からは1文字ずつ出力される」という挙動です。上記の例に合わせると
(ansi-color-apply "H")
(ansi-color-apply "e")
(ansi-color-apply "\x1b")
(ansi-color-apply "[")
(ansi-color-apply "0")
(ansi-color-apply ";")
(ansi-color-apply "3")
(ansi-color-apply "2")
(ansi-color-apply "m")
(ansi-color-apply "l")
(ansi-color-apply "l")
(ansi-color-apply "\x1b")
(ansi-color-apply "[")
(ansi-color-apply "m")
(ansi-color-apply "o")
これに近しい挙動になるはずです。エスケープシーケンスが途中で分断されても大丈夫なのか……? と不安になりましたが、結論 問題ありません でした。
ansi-color.el は ansi-color-context というバッファローカル変数を用意しており、不完全なエスケープシーケンスを次の呼び出しまで保存してくれます。そのため実際の挙動はこのようになります。
(ansi-color-apply "H")
(ansi-color-apply "e")
(ansi-color-apply "\x1b")
(ansi-color-apply "[")
(ansi-color-apply "0")
(ansi-color-apply ";")
(ansi-color-apply "3")
(ansi-color-apply "2")
(ansi-color-apply "m")
(ansi-color-apply "l")
(ansi-color-apply "l")
(ansi-color-apply "\x1b")
(ansi-color-apply "[")
(ansi-color-apply "m")
(ansi-color-apply "o")
正直なところ ansi-color-context の持つ値の詳細まで理解できていませんが、本プロダクトのように「1文字ずつエスケープシーケンスが飛んでくる環境」においても ansi-color-apply を呼び出しておけばよしなに表示してくれる、ということが分かりました。
4. 文字の入力
いよいよログインしていきましょう。ログインするためにはユーザー名を入力しなければなりません。
Emacs バッファで RET を押すと、入力行の内容を UART の入力バッファに送信する riscv-console--send-input が呼ばれます:
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L309-L324
(define-key map (kbd "RET") 'riscv-console--send-input)
この関数は入力テキストを取得し、UART 入力バッファに追加します。Linux が UART read を実行すると、このバッファから1文字ずつ取り出して返す仕組みです。
実装メモ:echo-back の二重表示を防ぐ
多くの Linux アプリケーション (シェルなど) は、入力された文字をそのまま出力 (echo-back) します。これをそのまま表示すると、同じ文字が二重に表示されてしまいます。
入力したものと同じ文字列が直後に挿入される様子
echo-back の扱いは環境によって異なりますが、今回の話題に関連するものだとこのようになっています:
- mini-rv32ima: ホストターミナル (iTerm など) の termios で ECHO フラグをオフ (ソースコード)
- ユーザー入力は画面に表示されない
- Linux からの echo-back だけが表示される
- riscv-console.el: Emacs バッファには termios 相当の機能がない
- そのままではユーザー入力も Linux からの echo-back も両方表示される
そこで emacs-riscv では 最後に送信した入力文字列 を記録しておき、UART 出力がそれと一致する間はスキップする仕組みを実装しました。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L288-L290
動作の流れ:
- ユーザーが "root" と入力して RET を押す
- "root\n" を UART 入力バッファに送信
- 同時に "root\n" を記録 (echo-back 検出用)
- Linux が echo-back として "root\n" を返す
- 1文字ずつチェックして、記録した文字列と一致する間はスキップ
- その後の実際の出力だけが表示される
この仕組みにより、ユーザーが「ls」と入力したとき、画面には「ls」が一度だけ表示され、その後に ls コマンドの実行結果が表示されるようになります。
ここまでで「Linuxブートの表示」「ログインユーザー名の入力」まで可能となりましたが、 快適にLinuxを操作できているか というと足りないものがたくさんあります。
パフォーマンス問題はいったん横に置いておき*4、「バッファの保護」と「ノンブロッキングUI」の対策を進めました。
ちなみにここらへんからは Claude Code のお世話になりまくりました。すべてを採用したわけではありませんが、土台としてはかなり助かりました。ありがとな Claude Code ...
システム出力テキストおよびユーザー入力テキストの保護
Emacs バッファに文字を書き出すのは簡単ですが、編集も簡単です。
Linux が出力した文字までユーザーの手で編集されてしまうのは困るので防ぎたいところです。
バッファ全体をリードオンリーにする (setq buffer-read-only t) という手もありますが、そうするとログインプロンプト (例: login:) やシェルプロンプト (例: ~ #) 以降の入力まで弾いてしまいます。
- Linuxが出力する文字列は保護したい
- ユーザーの入力を阻害しないが、ユーザーが送信した文字列 は保護したい
これらを実現するために、まずは Linuxが出力した文字に対して read-only プロパティを設定することにしました。
emacs-riscv では「UARTが出力した文字列」=「Linux が出力した文字列」と見なすことができるので、 riscv-console--uart-output で出力した文字列に対して read-only t を行ないました。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L301
次に「UART出力」や「ユーザーによる入力(RET)」が発生するたびにバッファローカル変数である riscv-console--input-marker を (point-max) にします。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L302-L303
この maker より前の領域、すなわち「システムが出力した文字列」および「ユーザー送信後の文字列」に対する変更を before-change-functions で検知したら、after-change-functions で 変更を無かったことにします 。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L85-L125

ここまでを行なうことで
- Linuxが出力する文字列は保護したい
- ユーザーの入力を阻害しないが、ユーザーが送信した文字列は保護したい
が実現できました。
Q. なぜ read-only だけでなく領域チェックまで?
read-only はあくまでも 既存テキストに対する保護 であり、下図のように「保護テキストと保護テキストの間に挿入」されることは防げないからです。

CPUのエミュレート、つまり main routine の無限ループをそのまま実装してしまうと、UIがフリーズしてしまい、入力を受け付けなくなります。
- ユーザーの入力を受け付ける余地を残しつつ
- 裏では CPU step をループさせる
これらを実現するために、Emacs の タイマー機能 を使って、CPU step を一定間隔ごと実行する方式にしました。
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L175-L195
https://github.com/gongo/emacs-riscv/blob/0696b923fbbb15d7f81e9f3ce6a5114e1435b64b/riscv-console.el#L231-L234
(run-with-timer) で一定間隔 riscv-console--tick-interval ごとにタイマー関数を呼び出し、呼ばれるごとに riscv-console--steps-per-tick 回 step を進めています。初期値だと 5msecごとに 2000 step する 設定です。
Q. なぜスレッドやマルチプロセスを採用しなかったの?
スレッド不採用理由は以下のとおりです:
- too much 。複雑になりすぎる気配(バッファの変更検知とかミューテックスでなんやかんやとか例外の扱いとか)
- Emacs のスレッド は thread-yield だけでなく I/O 待ちのタイミングでスレッドが切り換わるらしく、ユーザー入力やLinux出力のタイミングでブロックされるので、そもそも要件を満たせなかった*5
もう1つのマルチプロセス不採用理由です:
- 当初は batch-mode + make-process を攻めてみたが、batch-mode は stdinを取れない
- ならば stdin 以外ならどうかと考えてみたが、複雑になりすぎる気配 (ファイルを経由したりソケット通信!?)
パフォーマンスを突き詰めるのであれば上記2案のどれかに戻る可能性もありますが、現在の設計でも(遅くはあるものの)入出力に違和感は無いため、これでいくぞいとなりました。
emacs-riscv の全体図
つまり、こういうことでした。
excalidraw.com で描いた emacs-riscv のアーキテクチャ図。タイマーによる CPU ステップ実行、UART 入出力、echo-back 検出の流れを示す
おまけ (Claude Code + NotebookLM)
RISC-V の仕様書を読む時は PDF を NotebookLM に食わせたあと、 Claude Code の NotebookLM Skill 経由で実装確認を行なっていました。
github.com
実装済みの命令について仕様書と突合を依頼したところ、間違いを指摘してくれている様子
NotebookLM は偉大。
まとめ
開発を始めたのは2024年末からですが、なんやかんや難しすぎて滞っていました。併走してくれてありがとう Claude Code 。
それでは、今年もお疲れ様でした。良い御年を