92[SPRESENSE 2026]Cインタプリタ(VM)を作ったにょ[LiveC編]
テキストエディタを核にした、USBキーボード/マウス対応の組み込みC実行環境「liveC」を作った
概要
Arduino環境で動く自作の組み込みOS「liveC」を作りました。特徴はこの3つです。
- テキストエディタがOSの核になっていて、その場でCコードを書いて実行できる
- USB接続の一般的なキーボード/マウスがそのまま使える(専用の変換基板 CH9350L 経由)
- 積んでいるCサブセットインタプリタ(livec)がかなり本格的で、構造体・switch・配列・マクロまで動く
対象ボードは以下の2枚です。
- Sony Spresense
- Vicharak Shrike-Lite (RP2040)
きっかけ
「マイコン上で動く、その場でコードを書いて動かせる環境が欲しい」というのが出発点でした。PCで書いてビルドして書き込んで…のサイクルではなく、電源を入れたらすぐエディタが立ち上がり、その場でCっぽいコードを書いて:run一発で動く。ゲーム機やレトロPCのBASIC環境に近い体験を、令和のマイコンとCで再現するイメージです。
テキストエディタをOSの核にする
liveCは起動すると、DOS風のシェルか、nanoスタイルのテキストエディタが立ち上がります。
nanoeditor.hpp… エディタのデータモデル(行バッファ、カーソル、スクロール等)editorloop.hpp… キー入力ループ、マウス対応(クリックでカーソル移動、ホイールでスクロール)shell.hpp… DOS風のコマンドシェル(edit、run、lsなどのコマンド)
エディタ上でCのソースを書いて Ctrl+R (またはシェルの run コマンド) を叩くと、その場でコンパイル→実行されます。ファイルはSD/LittleFSに保存でき、書いたスクリプトを蓄積していけます。
画面は LovyanGFX 経由で ILI9341 (320×240) / ILI9488 (480×320) の2種類のパネルに対応しており、ビルド時のマクロ1つで切り替えられるようにしてあります。
USBキーボード/マウス対応
マイコンにUSBホスト機能で汎用のキーボード・マウスを繋ぐのは地味に面倒なポイントですが、CH9350L という「USB→UART変換」チップを使うことで解決しました。
- CH9350LにUSBキーボード・マウスを挿すと、UART経由でHIDレポートが飛んでくる
ch9350lib.hppでこのUARTフレーム(可変長: マウスは4バイトのステータスフレーム、キーボードは長さバイト付きのデータフレーム)をパースし、キー入力とマウスの相対/絶対座標に変換- JIS106キー配列に対応(US配列だと記号がズレるので要注意)
- マウスカーソルは背景退避/復元方式のビットマップ描画(
cursor.hpp)
さらに input.hpp で「USBシリアル(Arduino IDEのシリアルモニタ)」と「CH9350経由のUSBキーボード」を1本の入力ストリームに統合しています。エディタやシェル、Cインタプリタの getch()/kbhit() はどちらの入力源からのキーも区別なく受け取れます。
ハードウェア的には、CH9350の2つ目のUSB-Aポート(スタック型コネクタ)を使うことで、キーボードとマウスを同時に挿して両方使える、いわゆる「下位機モード」で動作させています。
Cインタプリタ「livec」
このOS向けに自作したCサブセットのインタプリタで、c4(Robert Swierczek氏の有名な自己ホスト型ミニCコンパイラ)を土台にしたスタックマシン方式です。テキストをその場でバイトコードにコンパイルし、シンプルなVMで実行します。
対応している機能は以下の通りです。
基本
int/char/ ポインタ、四則演算・比較・論理演算・ビット演算(| ^ & << >>)、三項演算子?:if/else/while/for(初期化子付き宣言for (int i = 0; ...)にも対応)switch/case/default… 本物のC同様のフォールスルー、breakで抜けられる(breakはwhile/forでも使用可)- 関数定義・呼び出し・再帰
++/--(前置・後置とも、ポインタ演算のスケーリングも正しく処理)- 変数宣言時の初期化子 (
int a = 1;)、グローバル変数の定数初期化
全体の流れ
c4系の設計の特徴は、構文木(AST)を作らないことです。字句解析したトークン列を読みながら、その場でバイトコードを直接書き出していきます。ソースを1回なめるだけでコンパイルが終わるので、マイコン上でも十分な速度が出ます。
ソース(.c テキスト)
│
▼
┌───────────┐ 1文字ずつ読んで
│ レキサー │ トークン列に変換
└───────────┘ (識別子・数値・記号...)
│
▼
┌───────────┐ トークンを読みながら
│ コンパイラ │ その場でバイトコードを emit
│(構文木なし) │ ※ if/while/for の分岐先アドレスは
└───────────┘ 「あとで埋める」プレースホルダ方式
│
▼
┌───────────┐
│ バイトコード │ text_[] 配列に積まれた命令列
└───────────┘
│
▼
┌───────────┐ 単純なスタックマシンが
│ VM │ 1命令ずつ実行
└───────────┘
VMの内部
レジスタはたったの4つだけです。
| レジスタ | 役割 |
|---|---|
a |
アキュムレータ(演算結果はここに入る) |
pc |
次に実行する命令のアドレス |
sp |
スタックポインタ(値を積む場所。下に伸びる) |
bp |
ベースポインタ(今の関数のローカル変数の基準点) |
メモリ領域は3つに分かれています。
text_[] ... コンパイル済みバイトコード(命令列)
data_[] ... グローバル変数、文字列リテラル
stack_[] ... 関数呼び出しのスタック(ローカル変数もここに乗る)
命令セットはLEA(アドレス計算)IMM(即値ロード)LI/LC(int/charロード)SI/SC(int/charストア)PUSH``JMP``BZ(ゼロなら分岐)ENT/LEV(関数の出入り)ADD/SUB/...(演算)など、30個弱です。
例えば a = a + 1; は、こんなバイトコード列にコンパイルされます。
LEA -1 // a: この変数はbpから見て何番目のスロットか(アドレス計算)
PUSH // アドレスをスタックに退避
LEA -1
LI // a の現在値をロード
PUSH
IMM 1
ADD // 現在値 + 1
SI // 退避しておいたアドレスへストア
一見遠回りですが、「アドレスを先に計算してスタックに積んでおき、右辺を評価してから最後にストアする」という型は、代入・複合代入・構造体のフィールド代入・配列要素代入まで全部同じパターンで扱えるので、実装がとてもシンプルになります。
ローカル変数のアドレス配置(構造体・配列とバグの話)
関数に入ると ENT 命令が「ローカル変数用に何ワード確保するか」をspから差し引きます。ローカル変数は bp からの相対オフセット(負の数)でアクセスします。
アドレス大 ↑
┌─────────────┐
│ 呼び出し元の情報 │
├─────────────┤ ← bp (ここが基準点)
│ ローカル変数 #1 │ bp-1
├─────────────┤
│ ローカル変数 #2 │ bp-2
├─────────────┤
│ : │
└─────────────┘
アドレス小 ↓ (spはここまで伸びる)
bpに近いほどアドレスが大きく、離れるほど小さくなります。構造体変数や配列を1つのローカル変数として複数ワード確保するとき、「1つ目のフィールド/要素がどっち向きに並ぶか」を最初逆に実装してしまい、2番目以降のフィールドが変数の外側のメモリを書き換えるバグを作り込みました(後述)。
構造体
struct Point { int x; int y; };
struct Point origin; // グローバルでもローカルでもOK
int dist2(struct Point *a, struct Point *b) {
int dx = a->x - b->x;
int dy = a->y - b->y;
return dx*dx + dy*dy;
}
構造体は「型ごとにフィールド名→オフセットの対応表を持つ」という素朴な実装です。./->アクセスは、どちらも最終的に「ベースアドレス + フィールドのオフセット」を計算するだけの、ほぼ同じコードに落ちます。
s.x (構造体を値として持っている場合)
│
▼
s のアドレスは、実は s を参照した時点で
「値をロードせず、アドレスのまま」保持している
(配列名がポインタに落ちるのと同じ考え方)
│
▼
アドレス + (x のオフセット) → その番地をロード
p->x (構造体へのポインタの場合)
│
▼
p の"値"(=ポインタの指す先のアドレス)をロード
│
▼
アドレス + (x のオフセット) → その番地をロード
.と->で最終的にやっていることはほぼ同じ(「アドレス + オフセット」を計算してロードするだけ)というのが、実装してみて分かった面白いポイントでした。
- 自己参照ポインタ(
struct Node *next;)で連結リストも組める - 構造体変数を関数に「裸で」渡すと自動的にアドレス渡しになる(Cの配列がポインタに落ちるのと同じ考え方)
- 値渡し・値返し、構造体のネスト、初期化子は非対応(ポインタ渡しに寄せた割り切り設計)
固定長配列
int a[5];
char buf[64]; // char配列はバイト単位でパック(int等と同じくワード単位で確保すると8倍メモリを食うため)
配列も構造体と同じ「連続したワードを確保して、名前は先頭アドレスに対応させる」方式です。ただしchar配列だけは特別扱いをしています。intは元々1個1ワード(8バイト)を使う設計なので、配列にしてもそのままですが、charをそのままの規則で確保すると 1文字あたり8バイト消費してしまい、char buf[64];が512バイトになってしまいます。文字列バッファは実用上よく使うサイズなので、char配列だけはバイト単位でパック(切り上げてワード数を計算)するようにしました。
int a[5]; (1要素 = 1ワード) char buf[9]; (バイト単位でパック)
┌────┬────┬────┬────┬────┐ ┌──────────────────┐
│a[0]│a[1]│a[2]│a[3]│a[4]│ │b[0..7]│b[8] │
└────┴────┴────┴────┴────┘ └──────────────────┘
8B 8B 8B 8B 8B = 40B 8B 8B = 16B (64Bなら512→64Bまで削減)
- ローカル・グローバルどちらも対応、配列名は関数に渡すと自動でポインタに変換される
switchのコンパイル方式
switchは一見複雑ですが、実は「値を一時変数に退避 → 各caseの定数と順番に比較 → 一致したらそのcaseのコード位置へジャンプ」という単純な比較チェーンにコンパイルしています。
switch (x) {
case 1: A; break;
case 2: B; break;
default: C;
}
は、概念的にはこう展開されます。
┌─ 本体のコードは先に書いてしまい、あとから
│ 「x と 1 を比較 → 一致すれば A の位置へジャンプ」
│ という比較チェーンを本体の"後ろ"に生やす
▼
[ジャンプ命令: 比較チェーンへ] ─┐
A: ...; break→switch終端 │
B: ...; break→switch終端 │
C: ... │
← 本体はここまでフォールスルーで実行される │
────────────────────────────────┘
比較チェーン:
x==1 ? → Aへジャンプ : 次へ
x==2 ? → Bへジャンプ : 次へ
どれにも一致しない → defaultがあればCへ、なければ終端へ
caseラベル自体は何の命令も生成せず、「今のバイトコード上の位置」を記録するだけの目印です。本体を先にコンパイルしながら位置を記録しておき、本体の後ろに比較チェーンを生やして、最初にそこへジャンプする、という2段構えになっています。これは次のforのpost式と同じ「前方参照」の問題を、同じ考え方で解いています。
forのpost式 ― 実行順とソース上の順番のズレ
for (init; cond; post) { body } の post 部分は、ソース上では本体より前に書かれているのに、実際に実行される順番は「本体の後」です。1回ソースをなめるだけのコンパイラだと、これは地味に厄介な問題でした。
ソース上の見た目: 実際の実行順:
┌──────────┐ ┌──────────┐
│ init │ │ init │
│ cond ──┐ │ │ cond ──┐ │
│ post │ │ ソース順 │ body │ │ 実行順
│ { body }◄─┘ ≠ │ post ─┘ │
└──────────┘ └──────────┘
解決策は「post部分を一旦トークンだけ読み飛ばして位置を覚えておき、本体をコンパイルし終わってから、その位置までレキサーを巻き戻してもう一度読み直し、今度は実際にコードを生成する」という二度読み方式です。バイトコードの並び順は「本体→post→条件判定に戻るジャンプ」という、実行順どおりの並びになります。
プリプロセッサ
#include "file.c"(SDから展開)#define NAME 値/#undef(関数形式マクロは非対応、文字列・コメント内は展開されない)
ハードウェア連携(syscall)
Cコードから叩ける組み込み関数:
| カテゴリ | 関数 |
|---|---|
| 画面 | print printi printc cls gotoxy rect frect flush |
| 時間 | delayms nowms |
| GPIO | aread dread dwrite pmode |
| 入力 | getch kbhit (Serial + CH9350キーボード両対応) |
| メモリ | malloc free peek poke |
実装で苦労した点
- Ctrl+CによるプログラムAbort: 無限ループしているCコードを外から止めるため、
kbhit/getchを呼んでいなくても定期的にCtrl+Cをポーリングする仕組みを、実行速度を落とさない形で入れる必要がありました - 構造体・配列のアドレス計算方向(詳細は上記): ローカル変数のフィールド/要素オフセットの向きを最初逆にしてしまい、2番目以降が変数の外側のメモリを壊すバグを作り込みました(テスト中に発見・修正)
forのpost式・switchの分岐(詳細は上記): どちらも「実行順とソース上の記述順がズレる」問題を、字句解析位置の巻き戻し/コード位置の記録という同じ考え方で解決しました- Spresenseのメモリ: ILI9488のような大きい画面を使うと、Arduino IDEの「MainCore RAM」設定を1024KBにしないとCインタプリタが無言でハングする現象がありました(768KBだとメモリ不足)
ファイル構成
config.hpp ビルド時の切り替えマクロを集約
display.hpp LovyanGFX / ILI9341・ILI9488両対応、描画プリミティブ
nanoeditor.hpp エディタのデータモデル
ch9350lib.hpp CH9350のUARTフレーム解析(マウス+キーボード)
input.hpp USBシリアル + CH9350キーボードの統合入力
cursor.hpp マウスカーソル描画
storage.hpp SD/LittleFSアクセス
livec.hpp Cサブセットインタプリタ本体 + ハードウェアsyscall
lineinput.hpp 共通のプロンプト/コマンドライン入力
editorloop.hpp エディタのキー入力ループ
shell.hpp 起動時のDOS風シェル
main.hpp 全体の配線
関連コンテンツ
・liveC 本体編
・liveC エディタ編
・liveC ch9350編
このプロジェクトはArduinoフレームワーク上で開発しています。ボードはVicharak Shrike-Lite (RP2040) / Sony Spresenseの2機種に対応。
投稿者の人気記事
![[SPRESENSE 2026]Cインタプリタ(VM)を作ったにょ[LiveC編]](https://res.cloudinary.com/elchika/image/upload/t_elchika_article_cover/v1/user/878d4a9c-c79b-489e-8802-a7be8ac2f070/article/b4e1e0f3-5044-46c0-acd8-899735e12ec4/zl9zpkvh0l39wafnz8li.jpg)




-
chrmlinux03
さんが
前の水曜日の11:44
に
編集
をしました。
(メッセージ: 初版)
-
chrmlinux03
さんが
前の水曜日の11:45
に
編集
をしました。
-
chrmlinux03
さんが
前の水曜日の13:11
に
編集
をしました。
-
chrmlinux03
さんが
前の水曜日の13:34
に
編集
をしました。
ログインしてコメントを投稿する