
本記事は Emacs - Qiita Advent Calendar 2025 - Qiita の19日目の記事です。
成果物
セットアップや実行方法は README をご覧ください。
経緯
私が本プロダクトを作るきっかけは、遡ること1年前、builderscon 2024 で @bokuweb17 さんのトークセッションを見たことでした。
様々な言語で 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 アーキテクチャ自体の説明は参考資料に全て丸投げします。
- 参考実装関連
- RustでRISC-Vエミュレータを書いてNOMMU Linuxをブラウザで動かした | MaybeUnInit
- 発表スライドの(多分)基になった記事
- 既にバレていると思いますが、本記事のタイトルのパクり元です
- RustでRISC-Vエミュレータを書いてNOMMU Linuxをブラウザで動かした | MaybeUnInit
- RISC-V 関連
- RISC-V Reference Library :: RISC-V Ratified Specifications Library
- ISA (Instruction Set Manual) の仕様書
- amane-uehara/riscv-tests-description
- riscv-tests (後述) の内部構造追い掛けメモその1
- 自作CPU & 自作OSをやっていく (3) - riscv/riscv-tests の挙動を追う | 俺とお前とlaysakura
- riscv-tests (後述) の内部構造追い掛けメモその2
- RISC-V Reference Library :: RISC-V Ratified Specifications Library
- ELF (Executable and Linkable Format) 関連
- 第 7 章 オブジェクトファイル形式 (リンカーとライブラリ)
- ELF (Executable and Linkable Format) ファイルを解析する必要があったので (後述)
- 第 7 章 オブジェクトファイル形式 (リンカーとライブラリ)
動作するまでの話
時系列でふんわりと
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))) ;; => "opcode = 100, a = aa"
Lisp でもパターンマッチングっぽいコードが書けることを知りました。結構好き。
ちなみに cl-destructuring-bind は defmacro で定義されているため、インタプリタ実行時はマクロ展開のオーバーヘッドがありますが、byte-compile するとあまり気にならなくなりました。
2. 各命令のユニットテスト
ある程度実装が終わったところで「動作確認をするぞ!」となり、ユニットテスト用テストイメージとして riscv-tests を使うことにしました。
This repository hosts unit tests for RISC-V processors.
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) ;; ;; (snip) tohost の値が変わるまで(テスト終了するまで)ループ ;; )) (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) ;; gp == 1 なら成功 (kill-emacs 0) (kill-emacs 1)) gp)))
テスト結果はこう!(一部のテストが落ちるのは想定どおりです)
$ 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ファイルを解析してセクションヘッダーを参照すればいいじゃん」となり、やりました。
これにより、テストファイル毎に「正しい終了条件」を扱えるようになりました。
3. 文字の出力
命令の実装も終わり、いよいよ Linux イメージを読み込ませていくぞ!というところでまず必要になるのは画面出力ですね。
基本的には UART (Universal Asynchronous Receiver-Transmitter) を Emacs Lisp でエミュレートすることになります。
- Linux が UART のアドレス(
0x10000000)に文字を書き込んだら、Emacs バッファに insert する - Emacs バッファの入力内容を保持しておき、Linux が UART から読み込もうとしたらその文字を返す
- ここは「4. 文字の入力」で解説します
そこらへんを意識しつつなんやかんやあって、ついに表示されました!

実装メモ:ANSI カラー対応
Linux では実行結果に色が付いていることがあり、これも Emacs バッファ上で対応させておきたいです。
Emacs は標準パッケージで ansi-color.el を持っているので、これで対応できます。ansi-color-apply 関数に文字列を渡すと、ANSIエスケープシーケンスを解釈して色付きテキストに変換してくれます。
(ansi-color-apply "He\x1b[0;32mll\x1b[mo")

ここで気になっていたのが「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") ;; => "H" (ansi-color-apply "e") ;; => "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") ;; ここで "\x1b[0;32m" が完成、緑色の状態を保存 (ansi-color-apply "l") ;; => #("l" 0 1 (font-lock-face (:foreground "green3"))) (ansi-color-apply "l") ;; => #("l" 0 1 (font-lock-face (:foreground "green3"))) (ansi-color-apply "\x1b") (ansi-color-apply "[") (ansi-color-apply "m") ;; ここで "\x1b[m" が完成、色をリセット (ansi-color-apply "o") ;; => "o"
正直なところ ansi-color-context の持つ値の詳細まで理解できていませんが、本プロダクトのように「1文字ずつエスケープシーケンスが飛んでくる環境」においても ansi-color-apply を呼び出しておけばよしなに表示してくれる、ということが分かりました。
4. 文字の入力
いよいよログインしていきましょう。ログインするためにはユーザー名を入力しなければなりません。
Emacs バッファで RET を押すと、入力行の内容を UART の入力バッファに送信する riscv-console--send-input が呼ばれます:
(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 出力がそれと一致する間はスキップする仕組みを実装しました。
動作の流れ:
- ユーザーが "root" と入力して RET を押す
- "root\n" を UART 入力バッファに送信
- 同時に "root\n" を記録 (echo-back 検出用)
- Linux が echo-back として "root\n" を返す
- 1文字ずつチェックして、記録した文字列と一致する間はスキップ
- その後の実際の出力だけが表示される
この仕組みにより、ユーザーが「ls」と入力したとき、画面には「ls」が一度だけ表示され、その後に ls コマンドの実行結果が表示されるようになります。
快適なLinux体験を目指して
ここまでで「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 を行ないました。
次に「UART出力」や「ユーザーによる入力(RET)」が発生するたびにバッファローカル変数である riscv-console--input-marker を (point-max) にします。
この maker より前の領域、すなわち「システムが出力した文字列」および「ユーザー送信後の文字列」に対する変更を before-change-functions で検知したら、after-change-functions で 変更を無かったことにします 。

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

ノンブロッキングUI
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 の全体図
つまり、こういうことでした。

おまけ (Claude Code + NotebookLM)
RISC-V の仕様書を読む時は PDF を NotebookLM に食わせたあと、 Claude Code の NotebookLM Skill 経由で実装確認を行なっていました。

NotebookLM は偉大。
まとめ
開発を始めたのは2024年末からですが、なんやかんや難しすぎて滞っていました。併走してくれてありがとう Claude Code 。
それでは、今年もお疲れ様でした。良い御年を
