Thanks Driven Life

日々是感謝

Emacs Lisp で RISC-V エミュレータを書いて NOMMU Linux を Emacs 上で動かしました

本記事は 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 アーキテクチャ自体の説明は参考資料に全て丸投げします。

動作するまでの話

時系列でふんわりと

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.

https://github.com/riscv-software-src/riscv-tests

riscv-tests は RISC-V 命令セットの動作を検証するための公式テストスイートです。今回必要としている各命令についてもテストケースが網羅されていたので、使わせてもらいました。

  • rv32ui-p-* (User-level I instruction) - IZifencei 向け
  • 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)))

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 から読み込もうとしたらその文字を返す
    • ここは「4. 文字の入力」で解説します

そこらへんを意識しつつなんやかんやあって、ついに表示されました!

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") ;; => "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 が呼ばれます:

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

動作の流れ:

  1. ユーザーが "root" と入力して RET を押す
  2. "root\n" を UART 入力バッファに送信
  3. 同時に "root\n" を記録 (echo-back 検出用)
  4. Linux が echo-back として "root\n" を返す
  5. 1文字ずつチェックして、記録した文字列と一致する間はスキップ
  6. その後の実際の出力だけが表示される

この仕組みにより、ユーザーが「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 を行ないました。

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 はあくまでも 既存テキストに対する保護 であり、下図のように「保護テキストと保護テキストの間に挿入」されることは防げないからです。

ノンブロッキング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 の全体図

つまり、こういうことでした。

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 。

それでは、今年もお疲れ様でした。良い御年を

*1:実はこの発表を聞くまでは RISC-V のこともあまりわかっていなかった

*2:2025年12月現在、RISC-V の Lisp 実装はいくつかありましたが、Emacs Lisp だとまだ無かった

*3:CLINT (Core Local Interruptor) はタイマー割り込み、MMIO (Memory-Mapped I/O) はメモリマップドI/O、partial su は Supervisor モードの部分的サポートを指します

*4:どうやってもログインプロンプト表示まで約3分かかる。打開策を見出したい

*5:ここは自分の理解が足りない可能性はあります