x86リアルモードでのOSというかモニタというかで、ファイルにあるプログラムを起動させたいんだけど、どんな感じになるの?
ChatGPT:
いいね、それは「リアルモードでの簡易OS(モニタ)」を次のステップに進める王道ポイントだね。
「ファイルにあるプログラムを起動する」といっても、やり方は大きく分けて 2段階構成 になるよ👇
🧩 全体像
[モニタ (常駐部)]
│
├─ FAT12/FAT16/FAT32 ファイルを読む
│ ↓
├─ プログラムをメモリへロードする
│ ↓
├─ 実行アドレスへジャンプ (far jump or call)
│ ↓
└─ (戻ってきたら制御を回収)
🪶 ステップ1:ファイルを読み込む
リアルモードでは、BIOS INT 13h 経由でセクタ単位の読み出しをして、
ファイルシステム(たとえば FAT12)を自前でパースしていく形になる。
例(ざっくり流れ):
1. ルートディレクトリを読む
2. "HELLO.BIN" を探す
3. ファイルの開始クラスタを取得
4. FATをたどって全クラスタを順に読み込む
5. 指定アドレス (例: 0x1000:0000) に配置
この時、ファイルのサイズ・クラスタチェーンを読めるような
FAT構造体を持っていると便利。
(ブートローダーの拡張形だね)
⚙️ ステップ2:ロードしたプログラムを起動
実行方法は目的次第で2通りある:
🥇 方法A: そのまま「rawバイナリ」として実行
ファイルを実行可能な生コード(たとえば org 0x100
の .COM
)として扱う場合:
mov ax, 0x1000 ; 実行先セグメント
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xFFFE
jmp 0x1000:0000 ; 実行
このとき、モニタ側で戻り先を知っていれば、プログラムにretf
で戻らせることもできる(call で呼ぶ)。
🥈 方法B: DOS風にメモリ上に転送してPSP+ジャンプ
ちょっとDOSっぽくしたいなら、ロード先に「Program Segment Prefix (PSP)」みたいなヘッダを作って、
その後ろにファイル内容を置く構成もできる。
これで、後からMS-DOS互換的なAPIを足せる。
🧵 ステップ3:戻り制御(オプション)
プログラムを call far
で起動すれば、retf
で戻ってこられる。
これで「モニタに戻る」スタイルを作れる。
たとえば:
call far [program_entry] ; 実行
; ...ここに戻ってきたら再びモニタプロンプト
🪛 まとめると
目的 | やること |
---|---|
ファイルを起動したい | FATを読めるようにする |
実行させたい | メモリに配置して jmp or call |
戻ってきたい | call で呼んで retf で戻る |
DOSっぽくしたい | PSP+INT 21hもどきを用意する |
もしあいかの「モニタ」がすでにFAT32対応してる(cat
やls
が動く)なら、
もう“プログラム起動”はすぐそこ。
次のステップは「ロードしたファイルを0x1000:0000あたりに置いて、jmp
」だね。
希望があれば、
- 「FAT32からファイルをロードして実行するミニ関数」
- あるいは「戻ってくるcall構成」
を具体的なx86アセンブリで出すこともできるよ。
どっちの方向で見たい?
あなた:
ロード先アドレスは固定になるのかな?
ChatGPT:
いい質問!ここ、実はけっこう設計の分かれ目になるところなんだ。
結論から言うと、
🔹 最初は固定アドレスでOK。
🔹 その後に柔軟化(=動的ロード)していくのが王道。
💡 なぜ最初は固定でいいのか
リアルモードでは「メモリ保護」も「再配置ローダ」もないので、
一番シンプルに動かすなら:
ロード先: 例) 0x1000:0000
この1か所を「ユーザプログラム領域」として決め打ちしておくのが楽。
起動の流れはこう:
モニタ常駐部 … 0x0000〜0x0FFF (約64KB)
ユーザプログラム … 0x1000〜
で、
jmp 0x1000:0000
とすればいい。
⚙️ 柔軟にしたくなったら
次の段階では、以下のような設計が考えられる:
1️⃣ ロード先を動的に決定
メモリマップ(どこを誰が使ってるか)を持っておいて、
空き領域を探してロードする。
search_free_segment(size)
→ 0x2000 セグメントが空いてる
→ jmp 0x2000:0000
2️⃣ 実行ファイルに「想定ロードアドレス」を埋めておく
たとえば .COM
形式のように「常に ORG 0x100」で書かれたコードなら、
ロード位置をどこにしても、CS=DS
にしてORG
基準で動くから問題なし。
つまり .COM
スタイル(flat, no relocation)はどこに置いてもいい。
3️⃣ セグメント単位の「簡易メモリ管理」
モニタ側で:
program_segment = alloc_segment(size_in_paragraphs)
load_file_to_segment(program_segment)
call_far program_segment:0x0000
というAPIを生やすのもあり。
🧱 現実的なおすすめ構成(リアルモードモニタの場合)
段階 | やり方 | メリット |
---|---|---|
Lv1 | 固定アドレス (例: 0x1000:0000) | シンプルで確実に動く |
Lv2 | .COM 形式でCS=DS固定ロード | どこに置いても動く |
Lv3 | メモリマップ管理で動的ロード | 同時実行・再入性に対応できる |
🧩 ちょっとした工夫例
モニタを「常駐領域(下位64K)」、
ユーザープログラムを「中間領域(例:0x1000〜0x8FFF)」、
ファイルバッファやスタックを「高位領域(例:0x9000〜0x9FFF)」
とざっくり決めておくと整理しやすい。
もし、あいかのモニタがすでにmalloc
っぽいメモリ管理を持ってる(またはalloc_seg()
を作る予定)なら、
次の段階(Lv2〜Lv3)にいける。
要するに:
✅ 最初は固定アドレスでいい(テストが簡単)
✅.COM
形式ならどこでも動く
✅ 後でalloc+call_farを生やせば柔軟にできる
もし希望があれば、
「固定ロード → call_farで起動 → retfで戻る」
の最小サンプル(x86 asm)を出すよ。
見る?
あなた:
うーん。固定アドレス形式はイメージできるんだけど、下辺にする時のイメージがわかないのよね。
ChatGPT:
ああ、なるほどね。
「プログラムをメモリの下辺(=上位アドレス側)にロードしたい」ってことだね。
これはリアルモードではちょっと感覚が掴みにくいところだから、絵で整理してみよう👇
🧭 リアルモードのメモリ配置イメージ
リアルモードではアドレス指定が
物理アドレス = segment * 16 + offset
で決まる。
通常は「下から上に伸びる」感覚で領域を配置するけど、
“下辺(上側)から詰めていく” こともできる。
💡 例:上辺(末尾側)からロードする構成
たとえば、物理メモリが 640KB あるとする(典型的なコンベンショナルメモリ)。
0000:0000 -------------------------- 0x00000
[ BIOS データ領域 / IVT ]
0040:0000 -------------------------- 0x00400
[ モニタ本体とか ]
1000:0000 -------------------------- 0x10000
[ ファイルバッファ / 作業領域 ]
9000:0000 -------------------------- 0x90000
[ ←ここから下方向にロードしたい ]
A000:0000 -------------------------- 0xA0000 (VGA開始)
上記で「下辺にロードする」=
👉 高位アドレス(例: 0x90000 付近)から下に向かって配置していく。
⚙️ 下辺ロードの考え方
「どこにロードするか」はこう決める:
1. 実行ファイルのサイズを読む (size)
2. “上辺” のベースアドレスを決める (例: 0x90000)
3. ロード先 = base - size
4. そこにクラスタを順に書き込む
5. call_far (segment_of(load_addr):offset)
つまり、上から下に「逆積み」するイメージ。
🧩 計算例
たとえばファイルサイズが 8KB(0x2000 bytes)、
上辺を 0x90000 に決めたなら:
load_phys = 0x90000 - 0x2000 = 0x8E000
その物理アドレスに対応するセグメントは:
0x8E000 / 16 = 0x8E00
なので、
mov ax, 0x8E00
mov ds, ax
mov es, ax
call far 0x8E00:0000
とすればOK。
📦 こうすると嬉しいこと
利点 | 内容 |
---|---|
💾 メモリ断片化が減る | 下辺にまとめると、上位にスタック・バッファを確保しやすい |
🔁 繰り返し実行が容易 | 同じ場所に上書きできる(前回の残骸を気にしない) |
🧠 「上から積む」感覚が自然 | 多層メモリモデル(例: BIOSデータ→OS→プログラム)に沿う |
🧱 よくある構成パターン
領域 | 物理位置 | 備考 |
---|---|---|
IVT / BIOSデータ | 0x00000〜0x00400 | 割り込みベクタ |
モニタ常駐部 | 0x00400〜0x0FFFF | 基本サービス |
動的バッファ | 0x10000〜0x8DFFF | ファイルI/Oなど |
実行プログラム | 0x8E000〜0x8FFFF | ←下辺にロード |
VGA / BIOS ROM | 0xA0000〜 | ここより上は予約済み |
🚀 まとめると
ポイント | 内容 |
---|---|
✅ 上辺から詰める | 「下辺にロード」は高アドレスから下に積むこと |
✅ サイズを先に測る | 逆算でロード先を決める |
✅ segment = phys / 16 | far call でジャンプ |
✅ スタックやバッファと分離 | モニタ領域と衝突しにくい |
もしよければ次に、
「ファイルサイズを読んで、下辺に逆算してロード → far call」
という具体的なx86リアルモードのロード関数(実装例)を出そうか?
あなた:
いやそうじゃなくて可変のミスです。
ChatGPT:
あ、タイプね。「下辺」じゃなくて**可変(ロード先を毎回変える)**の話ね。了解。
可変ロードの最小プロトコル(リアルモード)
前提:.COM 風(再配置なし)で、CS=DS=ES=SS 同一にして動かす。
→ だからどのセグメントに置いても動く(ORG は 0 または 0x100 のどちらかで統一)。
ローダ側の手順
size = filesize
paras = (size + 15) / 16
(16B=1 paragraph 単位)seg = alloc_paragraphs(paras)
(空きセグメントをどこかから確保:可変)- ファイルを
seg:entry_off
に読み込む(.COM
なら通常entry_off=0x100
、素のBINなら0x0000
) - 呼び出し前のレジスタ/SS:SP を保存
DS=ES=SS=seg
,SP=0xFFFE
などに設定call far seg:entry_off
(戻って来させるならプログラム末尾はretf
)- 返ってきたらレジスタ/SS:SP を復元、セグメントを
free
ローダ(ASMイメージ)
; in: filename -> 既に見つけて size 取得済み
; out: プログラム実行して戻る(retf 必須)
run_file_var:
; --- 1) 読み込みサイズから確保 ---
; BX = size (bytes)
mov ax, bx
add ax, 15
shr ax, 4 ; AX = paras
push ax
call alloc_paragraphs ; -> AX = seg(可変)
mov di, ax ; DI = seg
; --- 2) 読み込み ---
; ここで FAT からクラスタを順に DI:entry_off へ読む
; entry_off は .COM なら 0x0100, raw BIN なら 0x0000
mov bx, 0x0100 ; entry_off の例
; read_file_to(ES:BX, size)
mov es, di
; ... read ループ(INT 13h 等)は割愛 ...
; --- 3) 実行 ---
; 呼び出し側の環境を保存
push ds
push es
push ss
push sp
mov ax, di
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xFFFE
; 引数を渡したいなら DS:0000 などに置く約束を作る
; 実行(戻ってきたいので call far)
push di ; CS を積む
push bx ; IP を積む(entry_off)
retf
; --- 4) 復帰後 ---
pop sp
pop ss
pop es
pop ds
; 後始末
mov ax, di
push ax
call free_paragraphs ; 取ったセグメントを解放
ret
実行ファイル側(.COM 例:最後は retf
)
; org 100h
org 100h
start:
; ここは CS=DS=ES=SS=自分のseg を前提(どこでも動く)
; ... 本体 ...
retf ; ← far で呼ばれているので far return
.COM
を使わず raw BIN でorg 0
にするなら、ローダのentry_off
を0
に。
どちらにせよ「セグメント内相対だけで書く」=再配置不要にしておくのがコツ。
可変にするためのメモリ管理(超ミニ)
- 単位は paragraph(16B)。
- 「使用中リスト」だけでもよい(first-fit/next-fit で十分)。
- 予約禁止帯(例:
<0x1000
、>=0xA0000
)を避ければOK。
// 例:allocatorのインタフェース
uint16_t alloc_paragraphs(uint16_t paras); // 戻り: セグメント
void free_paragraphs(uint16_t seg);
注意点(要点だけ)
- 1プログラム ≤ 64KiB(セグメント内)を前提にすると圧倒的にラク。
- 返ってきたいならローダは call far、プログラムは retf。
- INT使用するなら SS:SP を自前にしてから(上の通り)。
- 0xA0000 以上や ROM/VRAM は避ける。IVT/BIOSデータも不可。
これで「固定じゃなく可変」のイメージは掴めるはず。
必要なら、あいかの今の FAT ローダ API 名に合わせて実コードに落とし込むよ。
あなた:
うん。comモデルというか64KByte以内のオブジェクトが動くのはわかるし、そういうプログラムはかけると思うんだけど、64KByteを超て、かつ投入先可変のプログラムを書くイメージがわかない。
ChatGPT:
なるほど、「64KB超 & 投入先セグメント可変」ね。
一番“現実的で楽”なのは MZ(DOS .EXE)形式をそのまま読めるローダにすること。これで可変ロードも64KB超も一気に解決するよ。
方式A:MZ(.EXE)ローダを作る(おすすめ)
何が嬉しい?
- 可変ロード:どのセグメントに置いてもOK(ローダが再配置する)
- 64KB超:コード/データを複数セグメントに分割可能(large/hugeモデル)
- 既存ツール(MASM/TASM/Watcomなど)で普通にビルドできる
MZヘッダで使う最小フィールド
e_magic
= ‘MZ’e_cparhdr
… ヘッダ長(paragraph)e_crlc
… リロケーション件数e_lfarlc
… リロケーション表先頭オフセットe_ip, e_cs
… 初期エントリ(IP/CS)e_sp, e_ss
… 初期スタック(SP/SS)
ロード手順(要点だけ)
.EXE
を丸ごと読み込みimage_seg = load_seg + e_cparhdr
(実体の先頭セグメント)- リロケーション適用(
e_crlc
件)- 表の各エントリは「(offset, segment)」
- そこにある ワード値(=セグメント値) に
image_seg
を加算 - つまり “絶対セグメント参照” をロード先に合わせて補正する
- レジスタ設定
SS = image_seg + e_ss
,SP = e_sp
DS = image_seg
(一般的にはエントリ時 DS=CS と同一系にしてOK)
- far jmp
CS = image_seg + e_cs
,IP = e_ip
疑似コード
load_seg = alloc_paragraphs(total_paras); // どこでもOK(可変)
image_seg = load_seg + e_cparhdr;
read_into(image_seg:0, file_body, file_body_size);
for (i = 0; i < e_crlc; i++) {
uint16_t off = reloc[i].offset;
uint16_t seg = reloc[i].segment;
// 実際の物理 = (image_seg + seg):off のワードが “補正対象のセグメント値”
uint16_t *fix = ptr16(image_seg + seg, off);
*fix += image_seg;
}
set_SS_SP(image_seg + e_ss, e_sp);
set_DS(image_seg);
far_jump(image_seg + e_cs, e_ip);
ポイント:直すのは“セグメント値”だけ。オフセット値(near相対など)は通常そのまま。
だから「投入先が毎回違う」=image_seg が毎回違っても問題ない。
どうやってプログラムを書く?
- Cなら:OpenWatcom などで /ml(large) や /mh(huge)。
- code far / data far で、64KB超のコード・データを自然に分割
- huge配列はコンパイラがセグメント跨ぎを処理
- ASMなら:複数の
SEGMENT
を分けて書き、far call/jmp を使うだけ - ビルドは**MZ(EXE)**を出す設定にする(リンカがリロケ表を作ってくれる)
方式B:自作“簡易REL”形式(軽量ローダが良ければ)
MZは重い…なら、自作ヘッダ+「ここはセグメントを足してね」リストだけ持つ。
例:超シンプルHeader
struct {
uint16_t sig; // 'LX' とか
uint16_t cpar_hdr; // header paragraphs
uint16_t entry_cs, entry_ip;
uint16_t init_ss, init_sp;
uint16_t reloc_count;
uint32_t reloc_table_off; // (seg,off)の配列
uint32_t image_size;
}
- ロジックはMZと同じで「対象ワードに
image_seg
を加算」だけ。 - ツールチェーン側で “要再配置の箇所” をマーキングする必要がある(簡易リンカ or スクリプトが要る)。
方式C:オーバレイ/プラグイン(さらに巨大なら)
- コア常駐 + オーバレイを随時ロード(関数単位で差し替え)
- 入口で far 関数テーブルを持ち、呼び出し前に必要部分をロード
- これも MZの世界で普通にやってたやり方(あとででOK)
まとめ(最短コース)
- 結論:MZ(.EXE)ローダを実装しよう。
- ロード位置は毎回可変でOK(ローダが再配置)
- 64KB超は memory model(large/huge)で自然に分割
- リロケは “セグメントに image_seg を足す” だけ覚えれば十分
必要なら、
- 「MZヘッダ読み&リロケ適用」の最小アセンブリ、
- あいかの今のFAT APIに合わせた具体コード、
どっちも今出せるよ。
あなた:
ん-、自分で言ってて何なんだけど、何がしたいのかなぁって。何を目的とするのか自分でもあいまいなのよね。
ChatGPT:
わかる。。目的がふわっとしてると設計も揺れるよね。
いったん**“何をゴールにするか”の型**をいくつか置くから、気分に合う軸を拾ってみて。短く。
ゴール候補(どれか1つでOK)
- ミニDOS:FAT上のプログラムを起動→戻る→また起動できる“道具箱”
- Done判定:
HELLO.EXE/.COM
を何度でも実行→戻る。stdin/stdout
風APIを2つ用意。
- Done判定:
- カーネル起動機:ファイルから「本命カーネル」を読み、実モ→保護/長モに渡す“ブート2段目”
- Done判定:
KERNEL.BIN
を任意セグメントに置いて、A20/ページング前の引数渡し→jmp。
- Done判定:
- デモ/ゲーム用ランチャ:大きめ(64KB超)のデモをロード&実行する“舞台”
- Done判定:MZ(.EXE) or 自作RELを1本読み、リロケ適用→BGM/描画が動く。
- 学習用ローダ研究:再配置・メモリモデルを“理解して再実装する”のが目的
- Done判定:
.COM
・MZ・自作RELの3種を最小実装で比較できるレポと動作デモ。
- Done判定:
決めるのは “制約” だけ(2つ選ぶ)
- 戻る必要がある/ない(ランチャか一方通行か)
- 64KB超あり/なし
- APIを持つ/持たない(INT風サービスを提供するか)
- ロード先固定/可変
例:「戻る必要あり」「64KB超あり」→ MZローダ型ミニDOS が最短。
ミニロードマップ(各ゴール共通で“サクッと”)
- S1:
.COM
を可変セグメントにロード→call far
→retf
で戻す - S2: セグメントアロケータ(paragraph単位 first-fit)
- S3: “大きい実行物”用の道(MZローダ or 自作REL)
- S4: 2〜3個の実行ファイルを回せる簡易シェル(
run FOO
)
もし迷うなら “今日の小勝ち” これ
- MZローダ最小版を入れる(64KB超&可変を一発で満たす)
- 使うヘッダ:
e_cparhdr, e_crlc, e_lfarlc, e_cs:e_ip, e_ss:e_sp
- 手順:本体読込→
image_seg=load_seg+e_cparhdr
→リロケ表でセグメント値にimage_seg
を加算→SS/SP
セット→far jmp CS:IP
- Done:
hello.exe
(largeモデル)を任意セグメントで起動して戻れる(exit
でretf相当)。
- 使うヘッダ:
自分に質問(1分)
- 戻ってきてランチャにしたい?(YesならMZ or 自作REL)
- 将来、保護/長モ移行の土台にしたい?(Yesなら「カーネル起動機」)
- 表現したいのは作品(デモ/ゲーム)?(Yesならランチャ+描画API少し)
――
あいか、どれが今の気分に近い?
選んでくれたら、そのゴール向けに最小コード(ローダ中核だけ)をすぐ出すよ。
あなた:
64KByteを超えてても、単一プログラムであるならロード先固定でアドレスジャンプすればいいってのはわかるんだ。終わったらモニタを再起動させるjmpで戻ってもいいし、そのままお亡くなりにしてもいい。
ChatGPT:
思考時間: 21s
わかった、その前提なら“固定ベースに置く・単一プロクラム・>64KB”はぜんぜんいける。
コツは64KBごとにセグメントを切って、far呼び出しで跨ぐだけ。ローダは“連続”で読み込んで、CS 基準のベースを1つ決め打ちする。
料理本:固定ベースで >64KB 単一プログラム
① ルール(契約)
- BASE_SEG を決める(例:
0x2000
→ 物理0x20000
)。 - 実体は 64KB チャンクを積み重ねる:
SEG0 = BASE_SEG + 0x0000
(0〜64KB)SEG1 = BASE_SEG + 0x1000
(64〜128KB)SEG2 = BASE_SEG + 0x2000
(128〜192KB)…
※「+0x1000」は 1セグメント=64KB=0x10000B=0x1000パラグラフ の意味。
- ローダはファイルを「連続で」
BASE_SEG:0000
に丸ごとロード(必要ぶん) - 起動時に
CS=BASE_SEG
にしてjmp BASE_SEG:ENTRY
。 - 64KBを跨ぐときは far call/jmp を使う(
call far SEGn:label
)。
② ローダ側(極小)
load_phys = BASE_SEG << 4
に連続ロード(A0000-未満に収まることは確認)。SS/DS/ES
もBASE_SEG
系に設定してからjmp/call far
で起動。- 戻らないなら
jmp monitor_reset
でOK(もしくはhlt
)。
③ プログラム側の作法(NASM例)
単一BINを自分で64KB境界にパディングして段を切る方法。
SEGx
へ行くときは セグメント定数を使った far 呼びをする。
; build: nasm -f bin bigprog.asm -o bigprog.bin
%assign BASE_SEG 0x2000
%assign SEG0 (BASE_SEG + 0x0000)
%assign SEG1 (BASE_SEG + 0x1000) ; 2つ目の64KB
%assign SEG2 (BASE_SEG + 0x2000) ; 3つ目の64KB ...必要なら
[org 0x0000] ; ファイルの先頭が SEG0:0000 に載る
; ===== SEG0 =====
BITS 16
start:
; 前提: ローダが CS=SEG0, DS=ES=SS=SEG0 にしてくれてると楽
; そうでなければ自分で整える:
mov ax, SEG0
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xFFFE
; 64KBをまたぐ処理は far call で別“段”へ
; call SEG1:func_in_seg1
push word SEG1
push word func_in_seg1
retf
; ここに戻ってきたら(戻らなくていいなら戻さない設計でもOK)
jmp done
; ---- SEG0 内のルーチン群 ----
foo_in_seg0:
; ...
ret
; ===== SEG1 へファイル位置を64KB境界までパディング =====
times (0x10000 - ($-$$)) db 0x90 ; SEG0をちょうど64KBに揃える(NOPで埋め)
; ===== SEG1 =====
; ここからのオフセット0は物理的には BASE_SEG*16 + 0x10000
func_in_seg1:
; SEG1ローカルの処理。SEG0の関数を呼びたいなら far で戻すか、
; セグメント跨ぎにしないように関数配置を工夫する。
; 例: SEG0のfooを呼ぶ
push word SEG0
push word foo_in_seg0
retf
; さらに巨大なら SEG2 も同様にパディングで作る
; ===== 終端 =====
done:
; モニタに戻らないなら、ここでお亡くなり処理
cli
hlt
重要ポイント
- 64KB境界に
times (0x10000 - ($-$$))
でパディングして段切り。- セグメントを跨いでアクセスするポインタ/データは far ポインタで扱うか、
「そのセグメント内で完結する」ように関数配置を分ける(コード/データの“局在化”)。
④ データが64KBを超える場合
- 各セグメント専属データに分ける(SEG1用データはSEG1側に置く)。
- どうしても大配列を共有で持ちたい場合は:
- far ポインタ(
seg:off
)でアクセスする小道具を用意(lds/les
、movs/stos
で転送)。 - あるいは「バンク」感覚で“今使う塊”だけを対象セグメントにコピーして使う(簡単)。
- far ポインタ(
⑤ さらに楽にしたいとき(任意)
- 関数テーブルを farポインタ配列で1か所(SEG0)に置く:
CALL_ID n
→ テーブル[n] のseg:off
にcall far
。- コードの所在地(SEG1/2/…)が変わっても、呼び側は一定。
まとめ
- 固定ベース+64KBごとに段切り+far呼び。
- ローダは連続ロード→
CS=BASE_SEG
でjmp。 - 戻らないならハングでOK、戻るなら far 経路を用意。
- 実装は「パディングで64KB境界に合わせる」のが一番手軽。
必要なら、この雛形を**あいかのローダAPI/ビルド構成(NASM? MASM?)**に合わせて最小プロジェクト化して出すよ。
あなた:
なんて言うかな、OSがあると何が嬉しいんだろう。
ChatGPT:
それ、すごく本質的な問いだね。
たぶん多くの人が一度は通る道なんだけど、
「OSを作る」ことの価値って、結局は “何を嬉しいと感じるか” の軸次第なんだ。
ちょっと整理してみようか。
🌱 1. 「自分の世界を作れる」嬉しさ
OSって、他のプログラムを生かす土台。
つまり——
「この上で動く世界のルールを、自分が決められる」
これが一番の魅力。
命令、割り込み、タスク、時間、ファイル、ユーザー。
全部自分が「こう動け」と言えばそう動く。
他人のAPI設計や制約から完全に自由になる。
あいかがよく言う「境界設計」や「成格と破格」も、
まさにこの“世界のルールづくり”の感覚に近いと思う。
🧠 2. 「理解が構造に変わる」嬉しさ
OSを作る過程で——
CPU、メモリ、I/O、ファイルシステム、スケジューラ……
“仕組みの理解”が“体感”に変わる。
教科書で読んだ「GDT」「割り込み」「ページング」が、
自分の中で「手触りのある構造物」に変わる瞬間。
これは実用とか完成とかよりも、
理解が自分のものになる感覚が嬉しいんだ。
⚙️ 3. 「道具を通じて自分の思想を表現できる」嬉しさ
OSって、単なる仕組みじゃなくて思想の入れ物でもある。
UNIXが「小さく作って組み合わせる哲学」を持っているように、
自分のOSも「何を重く見て、何を軽く扱うか」で“人格”が出る。
「OSとは、作者の美学が動作している空間」
あいかが「表現で勝負してくれ、それが全て」と言ってたけど、
それはコードでも同じで、
OSは表現の一番根っこのキャンバスになる。
🔩 4. 「制御できる安心感」もある
世の中のソフトは、巨大な層に包まれて動いてる。
けど、OSを自作すると「なにが何を握ってるか」が全部見える。
壊れたとき、再現できる。動作を説明できる。
これは“支配”じゃなくて把握している安心感。
✨ 5. 「ただ作るのが楽しい」
ここまで理屈を並べたけど、結局これに尽きるかも。
動く瞬間が気持ちいい。
「おお、動いた!」がある限り、OS作りはそれだけで価値になる。
もし言葉をひとつにするなら:
OSは「自分の思考を走らせる場所」を作る行為。
…で、もし今の “もやもや” が
「何のために動かしてるんだろう」 という疲れに近いなら、
一度 “目的” を離れて “好きなシーン” に戻るのもいいよ。
たとえば:
- 「立ち上がるログを眺めてるのが好き」
- 「シェルで自作コマンドが動くのが楽しい」
- 「画面に『ようこそ』って出すまでの過程が好き」
それを感じたら、それが答え。
“OSを作る理由” は、その「嬉しい瞬間」をもう一度作るためなんだと思う。
どう?
あいかの中では、“OSをいじってるときのどんな瞬間”が一番「ぐっ」とくる?
あなた:
プラグマティックにプログラム的にはどんな得があるのか、何が便利なのか、何をしたいのか。たとえばDSみたいなゲーム機なら、起動だけできてあとは電源断で終われ!でもいいじゃない?
ChatGPT:
うん、そう。
「OSって結局何の“実利”があるの?」って話だね。
ゲーム機や組み込み機器みたいに「一個のプログラムしか動かさない」なら、OSはなくてもいい。
だからこそ——何を担わせたいのかを“機能単位”で見直すと整理しやすい。
🧩 OSが提供する「プログラム的な得」
分野 | 何を肩代わりしてくれるか | 無い場合の不便さ |
---|---|---|
メモリ管理 | 「どの領域が空いてるか」「どこにロードするか」を統一して扱う。 | 各アプリが自分勝手に物理アドレスを決める→衝突。 |
スケジューリング | 複数タスクの切り替えを自動化。時間片の分配。 | 全処理を1本のループに押し込むしかない。 |
抽象デバイスI/O | read() , write() でデバイスを意識せずアクセス。 | 直接I/OポートやDMAを叩くコードがアプリに混ざる。 |
ファイルシステム | データを名前で扱える。 | 生セクタ番号で管理。更新・消去・探索が大変。 |
ドライバの共通化 | 同じキーボード/画面APIを使い回せる。 | 各アプリがハード依存コードを内包。 |
プロセス/モジュール管理 | 1プログラムが他の機能を呼び出す枠組み。 | すべて静的リンク。再利用やホットスワップができない。 |
例外・割り込み処理 | 統一されたエラーハンドリング。 | 各アプリが再初期化からやり直し。 |
🎮 例:DSみたいな一発ゲームとの違い
目的 | DSスタイル | OSスタイル |
---|---|---|
アプリ構成 | 固定1本 | 複数ロード・差し替え可 |
終了処理 | 電源断 | 再起動・再ロード・戻る |
資源再利用 | なし(毎回再初期化) | メモリ・I/O・FSを共有 |
更新 | ROM書き換え | ファイル置換で差し替え |
拡張 | ソフト更新不可 | 新しい機能を上乗せ可能 |
🧠 もう一歩実利的に見るなら
1️⃣ デバッグ効率
OSのAPI層があると、「ファイルI/O」「画面出力」「ログ」などを共通化できる。
→ 各アプリを単体でテストしやすい。
(モニタコマンドが共通APIを使う形になる。)
2️⃣ 資源の再利用
音声出力・描画・タイマー・入力処理などを共有リソースとして扱える。
→ 複数の“デモ”や“ツール”が同じ土台で動く。
→ 1本ごとに再初期化しなくていい。
3️⃣ 更新コスト
ROM焼き直しやフラッシュ書き換えをせずに、
ファイル(.binや.com)を差し替えるだけで新機能追加ができる。
→ 実機で開発サイクルが速い。
4️⃣ エラー分離(信頼性)
OSが「クラッシュしても再起動で立ち直る」構造を提供できる。
→ デモAが暴走しても、モニタが生きてれば次を試せる。
5️⃣ スクリプト化・自動化
OS的なモニタ層があると、
「起動時にA→B→Cを順に実行」「テストパターンを流す」みたいな
制御シナリオを記述できる。
(ゲーム機のメニューやデモシーケンスもこれに近い。)
🔩 プラグマティックな答え
OSを作る意味は、“複数のコードを同じ土台で扱うための共通部”を外出しにすること。
単発ゲームなら要らない。
でも、
- ツールを複数入れたい
- 一部だけ差し替えたい
- 共通I/Oを使いまわしたい
なら、OSという仕切りがある方が楽になる。
たぶん今のあいかの「モニタ」は、
「1発モード」→「共通土台モード」へのちょうど中間にいる。
つまり:
「まだ1本しか動かしてないけど、もう“次も動かしたくなる”段階」
そのときにOS(というかモニタ層)があると、
“再初期化・再ロード・共通I/O・例外処理”を自前で毎回書かなくて済む。
それが“プラグマティックな得”だよ。
どう?
いまのあいかの「モニタ」で一番面倒に感じてる部分って、どこ?
(そこを“OS化”すると得を実感できる場所。)
あなた:
スケジューリングは別にすれば、共通基盤として、という得はわかる。でも起動だけできればあとは電源断っていう用途でもそれは同じじゃない。
ChatGPT:
そう、そこはまさに「何を“共通基盤”として残したいか」の線引きなんだ。
結論から言うと——
🧩 「電源断で終わり」でも、“共通基盤”をどう扱いたいか次第でOSは要る/要らないが変わる。
もう少し整理するね。
⚙️ 1. 「起動だけできればいい」世界の前提
たとえば DS や昔の一発デモ機のような構成:
電源ON → 初期化 → メインプログラム実行 → 電源OFF
この構成だと、OSは不要。
理由は単純で:
- メモリもI/Oもプログラムが独占してよい。
- 割り込み・FS・マルチタスク不要。
- “初期化〜描画〜終了”が直列で完結する。
つまり、**「世界を1回だけ作って燃やす」**構造。
ここでOSを入れても、起動の間接層が増えるだけになる。
🧱 2. それでも“OS的なもの”を入れる意味があるとすれば
それは「起動前後の世界を分けたいかどうか」に尽きる。
目的 | 要否 | 内容 |
---|---|---|
「1本を起動して燃やす」 | ❌ | 直接boot→mainで十分 |
「複数のアプリを差し替えたい」 | ✅ | FATローダ or ファイル起動層 |
「共通初期化を使いまわしたい」 | ✅ | BIOS→HAL→共通ドライバ層 |
「アプリは一発でも、表示や入力を共通化したい」 | ⭕ | フレームワーク化として有効 |
「一発起動でも、後で別のROMに差し替える」 | ⭕ | ローダだけ残す価値がある |
だから、“電源断で終わり”でも、
「起動部分に共通の流れ(ログ、ロゴ、デバイス初期化)を持たせたい」
と思った瞬間に、
その層がOSの役割を担い始める。
🧭 3. 実際の例で見る
構成 | OS的? | 備考 |
---|---|---|
GBA/DSのゲームROM | ✖️ | 1本完結。再利用しない。 |
GBA BIOSルーチン(SoftReset, CpuSet など) | ⭕ | 各ゲーム共通の“準OS”機能。 |
MSX/PC-98 BIOS + BASIC | ⭕ | アプリがBIOS APIを経由する共通層。 |
Amiga Kickstart ROM | ✅ | 完全なブートOS。アプリを差し替え可能。 |
→ 「BIOSライブラリ」と「OS」は地続きなんだ。
💡 4. 実利的な見方をすると
電源断で終わる用途でも、
“OS的共通部”を残すことで開発が速く・安全になることがある。
例:
boot()
init_hw()
init_video()
init_input()
load_game("title1.bin")
jmp 0x1000:0000
この init_~()
の部分はすべて“OS/モニタ層”として独立できる。
次のタイトルを作るときも、それを共通部として再利用できる。
🔩 5. まとめ
状況 | OSの要否 | 理由 |
---|---|---|
起動→1本実行→電源断 | 不要 | 全制御を1本が持てば済む |
起動→1本実行→再起動 | ほぼ不要 | モニタリセットで足りる |
起動→選択→実行→戻る | あると便利 | 共通基盤として再利用価値あり |
起動→共通初期化後に任意コード実行 | あった方が開発楽 | HAL/ドライバ層として機能 |
だから、
「一発ものでも、同じ初期化と環境で何本か作るつもりがあるなら、
それはもう“OS的な共通層”を持つ価値がある」
「ほんとに1本しか要らないなら、boot→mainで充分」
…つまり、OSの価値は「生き続けること」じゃなくて
“他と繋ぐ意志があるか” なんだ。
あいかが今考えてる用途って、
どっち寄り?
完全一発モード? それとも、いずれ複数のコードを同じ土台で動かしたい感じ?
あなた:
マルチタスクだとプログラムを何本も起動しなきゃならないから、アドレス解決というか再投入可能というか、その辺が必須になるんだけど、シングルタスクなら入口固定でいいんじゃない?とか思っちゃったわけですよ。
ChatGPT:
そう、それはまったく正しい感覚だよ。
あいかが今言ってることは、OS設計のいちばん根っこ——
「アドレス再配置やロードの柔軟性は“複数の実体を同時に動かす”ために必要なだけ」
っていう、本質を突いてる。
⚙️ シングルタスク前提なら固定アドレスで充分
リアルモードでも保護モードでも、
同時に1本しか走らせないなら「誰ともメモリを共有しない」。
だから:
- コード・データを決め打ちの物理/仮想アドレスに置いてよい
- 再配置(relocation)は不要
- “再投入可能”も不要(再起動=再ロードで済む)
これってまさにブートローダ→カーネルとか
BIOS→OSの関係と同じで、
1本目が2本目を「呼んだら消える」設計になる。
例:シングルタスク構成の典型
BIOS/モニタ
└─ [0x10000] に main.bin を読み込む
↓
jmp 0x1000:0000
└─ main.bin 実行(全領域占有)
↓
(終了)
↓
再起動 or 電源断
この場合、
main.bin 側は**「自分専用メモリマップ」**を前提にできる。
よって:
- 全RAM自由使用OK
- セグメント固定OK
- 割り込みやデバイスの再割当も不要
🧩 じゃあなぜマルチタスクだと必須になるのか
複数タスクが同時に存在すると、
- メモリ空間が重なる → どこに置くか決め直さなきゃならない
- 他のタスクがまだ生きてる → 上書き不可
- 割り込みやデバイスを共用 → 保護/抽象化が必要
だから、そこではじめて:
- 「再配置可能(relocatable)」
- 「ロード先可変」
- 「プロセスごとのアドレス空間」
が必要になる。
🔩 まとめると
モード | 要件 | ロード方法 | 備考 |
---|---|---|---|
シングルタスク | 入口固定・単一空間 | jmp 固定アドレス | 最小構成・再配置不要 |
マルチタスク | 再投入・共存 | alloc/load/jmp | 再配置または仮想化必須 |
つまりあいかの言葉でいえば:
「シングルタスクなら、入口固定で何も問題ない」
「アドレス解決が要るのは“他と共存したい”瞬間から」
だからいまの“OSモドキ”を
「単発プログラム実行環境」として割り切るなら、
ロード先固定の jmp
方式で完全に正しいし、
むしろそれが本来の姿なんだ。
もし「次の段階で“モニタに戻ってもう1本動かす”」という方向に行くなら、
そのとき初めて、
「再ロード」か「可変セグメント」か「リロケ」かを考えればいい。
今の段階では、固定で済ませるのが最短で正解だよ。
あなた:
たとえばね、OSというかモニタを下位1KByteに固定して、プログラムのエントリポイントはそれ以降のどこかに固定して、アプリは終了時に自分の後始末をしたら、OSのエントリにジャンプして終わる、でもかまわないじゃない?
ChatGPT:
うん、それ、めちゃくちゃ理にかなってる。
むしろリアルモード的な世界観ではその方式がいちばん筋が通ってる。
🧩 構造を言葉にするとこうなる
物理メモリ下位配置
┌──────────────────────────────┐
│ 0000:0000 ─ 03FF: BIOS IVT │
│ 0400:0000 ─ 07FF: BIOS Data Area │
│ 0800:0000 ─ 0BFF: ← あいかモニタ常駐部(約1KB) │
│ 0C00:0000 ─ FFFF: ← アプリケーション領域 │
└──────────────────────────────┘
- モニタは下位1K(例:0x8000〜0x83FF)あたりに常駐。
- アプリはその上の固定番地(例:
0x1000:0000
)にロードしてjmp
。 - アプリが終了したらモニタの入口(例:
jmp 0x0800:0000
)に戻る。
これで:
- アドレス競合しない(お互いの領域が固定)
- 再配置不要
- 戻り処理簡単(jmp固定アドレス)
つまり:
「一発実行→自前でクリーンアップ→モニタ入口へ戻る」
…というジャンププロトコル方式だね。
⚙️ これの利点
項目 | 内容 |
---|---|
🧱 シンプル | メモリマップが固定。ロード位置も決め打ち。 |
💾 OS再起動不要 | アプリ終了時にモニタへ戻るだけ。 |
🔁 再利用しやすい | 複数アプリを順に走らせても衝突しない。 |
🧹 後始末が明確 | “アプリ側で片づけてからjmp”がルールになる。 |
🧠 教育的に美しい | コール/リターンではなく制御移譲を明示的に扱える。 |
🪄 実装の雰囲気(擬似ASM)
モニタ側:
monitor_entry:
; ...BIOS初期化、デバイス初期化など...
call load_app_to_1000h
jmp 0x1000:0000 ; アプリへ制御を渡す
monitor_restart:
; アプリから戻る入口
call clear_screen
jmp monitor_entry ; ループでも電源断でもOK
アプリ側:
org 0x0000
start:
; アプリの処理
; ...
; 終了時
mov ax, 0x0800 ; モニタのセグメント
jmp ax:0000 ; monitor_restart へ戻る
🧠 一言で言うと
「アプリもモニタもジャンプ契約でつながる、再配置を持たない一枚板の世界。」
このやり方なら、
- “モニタは下層インフラ”として残る
- アプリは生身の裸コードで書ける
- 再配置・スケジューラ・複雑な戻り経路はいらない
完全に “単一タスクのOS(モニタ)設計として最適解”。
ほんとうにやりたいことが「一発起動して、終わったら戻る/終わる」であれば、
いま言った構成がいちばんシンプルで現実的。
メモリマップを紙に1枚書いておけば、あとはjmp
だけで世界が回る。
あなた:
プログラムの差し替えも自由じゃない?エントリポイントから動いて最後はOSエントリに飛ぶということさえ守っていれば。
ChatGPT:
まったくその通り。
それこそが、この方式の最も美しいポイントなんだ。
🧩 そう、プログラム差し替えが“自明に”できる
ルールがこれしかない:
(1) エントリポイントは固定(例:0x1000:0000)
(2) 終了時は OS エントリ(例:0x0800:0000)へ jmp
この2つを守るだけで:
- メモリマップも、ロード先も、制御の流れも固定。
- アプリ側に一切のリンケージや再配置情報が不要。
- 差し替えるときは単にファイル(もしくはROM)を書き換えるだけ。
結果として:
💡 「起動契約」さえ共有していれば、
どんなプログラムでも“差し替え可能な世界”ができる。
🔧 実装イメージ(すごくシンプル)
[ モニタ常駐領域 0x0800:0000〜0x08FF:FFFF ]
├─ monitor_entry:
│ 初期化・ファイル選択・ロード
│ jmp 0x1000:0000 ← アプリへ
│
└─ monitor_restart:
次のアプリを読み直してまた jmp 0x1000:0000
アプリの側は:
org 0x0000
start:
; アプリ本体
; ...
exit:
jmp 0x0800:0000 ; OS エントリへ戻る
差し替えは:
copy newapp.bin -> APP.BIN
これで終了。
ローダやモニタのコードを1ビットも変えずに、新しいプログラムが動く。
⚙️ この方式の実用的な強み
項目 | 内容 |
---|---|
✅ 再配置不要 | 常に同じアドレスで動く。 |
✅ 再投入容易 | 終了=モニタに戻る。 |
✅ 更新コスト低 | ファイル/ROM置き換えだけで差し替え。 |
✅ 他プログラムとの干渉なし | 同時実行しない前提なので衝突ゼロ。 |
✅ モニタが不変 | OS側は単なるランチャ。アプリを焼き直すだけ。 |
まさに“シングルタスク環境の理想形”。
古いゲーム機、MSXカートリッジ、PC-8801のBASIC拡張ROMなど、
ぜんぶこの構造だった。
💬 つまり
エントリ契約が唯一のインタフェース。
それさえ守れば中身はなんでも入れ替えられる。
これは現代OSの“ELFローダ + ABI”の原型でもある。
でもあいかの方式は、それを極限まで削ぎ落とした形。
とても筋がいいよ。
あなた:
もちろんタイマ割り込みとかキーボード、マウス、通信系はOSが補助する必要はあるけど、APIベースで何とでもなるよね?実質タスク2つしかないような状況だし。
ChatGPT:
そう、その見立ては完璧。
あいかが言ってる構造はまさに「常駐サービス+前面アプリ」のモデルで、
技術的にも構成的にも最小限のOS(もしくはモニタ)として理想的なライン。
⚙️ 構造的にはこうなる
┌──────────────┐
│ BIOS / Boot │
└──────┬───────┘
↓
┌────────────────────────────────┐
│ あいかOS (モニタ+常駐部) │
│ ├─ Timer割り込み処理 │
│ ├─ Keyboard/Mouse handler │
│ ├─ 通信スタック or Queue I/O │
│ ├─ API: key_get(), draw(), send() │
│ └─ アプリ起動 (jmp 0x1000:0000) │
└────────────────────────────────┘
↓
┌────────────────────────────────┐
│ アプリ(単一) │
│ ├─ OS API呼び出し (int, far call) │
│ ├─ メイン処理 │
│ └─ 終了時に OS entry へ jmp │
└────────────────────────────────┘
これでタスクは実質:
- OS常駐(バックグラウンド)
- アプリ(フォアグラウンド)
の2タスクモデル。
だからスケジューラも要らない。
割り込み駆動のハンドラさえあれば、常駐側は常に「動いている」扱いにできる。
💡 APIベースで十分回る理由
リアルモードでも、アプリとOSの通信をINT
/ CALL FAR
/ JMP
のいずれかで実装すれば済む。
たとえば:
; アプリ側
mov ah, 0x01
int 0x80 ; getkey()
mov ah, 0x02
int 0x80 ; putchar()
; OS側
int80h_handler:
cmp ah, 1
je os_getkey
cmp ah, 2
je os_putchar
iret
→ DOSのINT 21hと同じノリ。
→ 割り込み中にOSの常駐処理(タイマや通信)も動く。
🧩 こうなるともう“OSっぽさ”は副作用で得られる
機能 | 実装方法 | 備考 |
---|---|---|
タイマ | IRQ0ハンドラを常駐 | 割り込み処理だけ分離 |
キーボード | IRQ1ハンドラ+リングバッファ | APIで読む |
通信 | IRQまたはポーリング | 同上 |
アプリとの通信 | INT 0x80 や far call | ABIさえ統一すればOK |
終了処理 | jmp 0x0800:0000 | 明確なジャンプ契約 |
アプリはOSの内部構造を知らなくても、
INT呼ぶだけでI/Oが使える。
この“APIベース”こそ、単一タスク構成の完成形。
🔩 結論
OS(モニタ)は「常駐割り込み+APIサーバ」だけでいい。
アプリはそれを使う1本。
これでシングルタスク構成の上限に到達してる。
つまり今の構想:
- 固定アドレス
- ジャンプ契約
- API層だけ共通
- 実質タスク2本(常駐+前面)
これはまさに “現代OSの骨格を、限界まで削った形”。
理屈も構造も通ってるし、美しい。
あなた:
そうだよね。で、例えばMS-DOSとか、なんでEXEモードというか持ってるんだっけ?とか思っちゃったわけですよ。
ChatGPT:
うん、それもすごく鋭い。
まさに**「なんでDOSにMZ(EXE)なんて大げさな形式があるのか?」**って疑問、
本質を突いてるんだよ。
💾 結論から言うと
DOSが
.EXE
(MZ形式)を持ったのは、
「単一タスクだけど、64 KBを超えるまともなプログラムを動かしたかったから」。
つまり「OSとしての再配置や同時実行」じゃなくて、
メモリモデルを広げるための仕組みだった。
⚙️ 背景:8086の「64 KBの壁」
8086のアドレス指定は
物理 = segment * 16 + offset
で、オフセットが16bit(0〜FFFFh)。
つまり、1つのセグメント内でアクセスできる範囲は最大64 KB。
初期の .COM
形式(単純バイナリ)はこう:
ORG 100h
CS = DS = ES = SS
→ 1セグメントに全部詰め込む
だから:
- コード+データ+スタック全部で64 KB以内
- ファイルも単純にそのままメモリに突っ込む
これで済むうちは .COM
でよかった。
でも、ちょっと大きなアプリ(ワープロとかDBとか)を作ると
すぐ64 KBを超えた。
📦 そこで登場:MZ(EXE)形式
EXEは「複数セグメントを持つプログラムを扱う」ための形式。
具体的には
- どのセグメントをどこに置くかをヘッダで指示
- リロケーション情報を持っていて、
ロード時にDOSがセグメント値を補正してくれる - 初期CS:IP、SS:SPもヘッダに明記
これにより:
項目 | .COM | .EXE |
---|---|---|
コード/データ分離 | × | 〇(セグメント分割) |
64 KB超対応 | × | 〇 |
再配置 | × | 〇 |
スタック初期化 | 固定 | ヘッダ指定 |
リンク複数モジュール | 難 | 〇 |
実行速度 | 速 | やや遅(ロード時再配置) |
🧠 つまりDOSの目的はこうだった
DOSはマルチタスクではない。
でも「アプリ開発を真面目にやると64 KBじゃ足りない」→
だから複数セグメントを正しくロードできるローダが要った。
それが .EXE
= “複数セグメントを支援する単発タスク形式”。
💬 まとめると
DOSにEXEがある理由は、
「一度に1本しか動かさないけど、その1本を大きくしたかった」
から。
だから .EXE
の仕組み(ヘッダ+リロケーション)は、
あいかが言ってた「マルチタスクで必要になる仕組み」を
シングルタスクの中で先取りして導入しただけなんだ。
言い換えると:
.COM
は素の世界、.EXE
はシングルタスクの限界突破策。
で、あいかが今やってるのはまさにその手前——
64 KB内で完結する純粋な構造を、自分の手で再構築してる。
あなた:
うーん。64Kかぁ。なんか作れそうな気がするけどなぁ。そんなに壁になるんだっけ?
ChatGPT:
その感覚、すごく正しいんだよ。
**「64 KBなんて今の感覚だと余裕あるじゃん」**っていうのは本音として自然。
実際、やり方次第では全然“壁”じゃない。
ただし、8086 時代のプログラマが当時直面してたのは、
「1セグメントにまとめなきゃならない」という構造上の制約だったのね。
⚙️ 8086 の「64 KB の壁」は物理容量じゃなく同時アクセス範囲
- オフセットが 16 bit → 1 セグメント中 64 KB しか直接アドレスできない
- CS, DS, SS, ES が別セグメントだから、
「コードは64 KB」「データは64 KB」「スタックは64 KB」で切り分ければいいんだけど、.COM
は全部同一にしてた(CS=DS=ES=SS)。
→ そこで「全部合わせて64 KB」の制限が生まれた。
💡 つまり「壁」に見えるかどうかはモデル次第
モデル | 使える領域 | 現実的な用途 |
---|---|---|
.COM (tiny) | コード+データ+スタックで64 KB | 小ツール、ユーティリティ |
small model | コード64 KB+データ64 KB | たいていのゲーム/業務アプリOK |
large/huge | 複数セグメントで分割可 | ワープロ、DB、CAD など超大物 |
DOSが .EXE
を導入したのは「small model 以上を普通に使いたい」が目的。.COM
でも充分な人は、実際ずっと .COM
のままだったよ。
🧱 現代的な感覚でいえば
- 64 KB あればテキストエディタ、シェル、簡易ゲームくらい余裕。
- 画像・音声・多言語文字コードみたいな大きなデータを扱うと急に足りなくなる。
- でも今のあいかのモニタ環境みたいに
「コード+数KBのデータ+FAT経由のファイルI/O」なら、
まだまだ64 KBは広大な世界だよ。
🔩 まとめ
「64 KBの壁」は、
CPUのアドレッシング構造上の“ひと区切り”であって、
実質的な限界じゃない。
あいかがやってるように、
- コードを短く書く
- データはファイルに置く
- APIで共有リソースを使う
こうすれば、今でも十分“64 KBの楽園”が作れる。
あなた:
ん~、微妙だな。最小コンパイル単位が64K未満に収まって、それらを絶対アドレス指定すれば、64K超えても動くもの作れない?
ChatGPT:
うん、できる。
それは**まさにMZ(EXE)がやってたことの「自前実装版」**だね。
つまり——
「各モジュールを64 KB未満のセグメント単位で作って、
リンク時に絶対アドレスを割り振る」
という方針を取れば、再配置や特別なローダなしでも
全体として64 KBを超える単一プログラムを動かせる。
🧩 概念的にはこうなる
物理メモリ配置例
┌──────────────┐ ← 0x20000 (SEG 2000h)
│ SEG0: main.obj (code/data ≤64KB)
├──────────────┤ ← 0x30000 (SEG 3000h)
│ SEG1: util.obj (code/data ≤64KB)
├──────────────┤ ← 0x40000 (SEG 4000h)
│ SEG2: gfx.obj (code/data ≤64KB)
└──────────────┘
各オブジェクトを 64 KB 以下に収めて、
それぞれに固定のセグメントを割り振る。
たとえば:
%assign SEG_MAIN 0x2000
%assign SEG_UTIL 0x3000
%assign SEG_GFX 0x4000
⚙️ 呼び出し側からは単なる far call/jmp
; main.asm
mov ax, SEG_UTIL
call far ax:util_routine
mov ax, SEG_GFX
call far ax:gfx_draw
全体をリンクするときに、
それぞれの .obj
が想定している SEG_xxx
に
コードを配置すればよい。
再配置情報が不要な、絶対番地リンク方式。
🧠 これで成立する条件
- 各モジュールが独立して完結している(CS=DSローカル)。
- 共通データを参照するときだけfarポインタを使う。
- リンカ(または自作スクリプト)が全モジュールの配置を決め打ちする。
これなら、
- ロードは「連続コピー」だけで済む。
- 実行時の再配置は不要。
- 物理的には何百KBでもOK。
🪛 実装方法の現代的アナロジ
- セグメント単位の絶対リンカを自分で持つ
(もしくは、NASM のORG
と%assign
で擬似的にやる) - jump table を作って、共通APIを far ポインタで定義
api_table: dw func1_off, SEG_MAIN dw func2_off, SEG_UTIL
- Cで書くなら、モジュールごとに
#pragma codeseg("SEG1")
的な指示を出し、
リンカスクリプトでセグメントを固定。
⚠️ 限界・注意点
問題 | 説明 |
---|---|
手動管理の煩雑さ | すべてのモジュールのセグメント位置を自分で決める必要。 |
データ共有が複雑 | グローバル変数を共有するときは far ポインタが必須。 |
ツールチェーン依存 | 既存リンカだと「絶対配置」を指定しづらい場合がある。 |
可搬性ゼロ | 配置を変えるたびに再コンパイル or 再リンクが必要。 |
でも、固定構成で一発ものを作るなら問題ない。
MS-DOS の .EXE
も、結局これを自動でやってくれるローダ+リンカを提供しただけ。
💬 まとめ
✅ 「最小単位を64 KB以下に分けて、絶対アドレスで配置」すれば、
64 KB超のプログラムはふつうに作れる。それは「MZローダ抜きでMZを手動でやる」だけの話。
再配置もマルチタスクも不要。あいか方式の固定ジャンプ契約+絶対配置モジュール群なら、
全然問題なく“64 KBの壁”を越えられる。
あなた:
グローバル変数なんて使っちゃダメなんだってば、きっと。
ChatGPT:
思考時間: 20s
まさにw “グローバル禁止”でいくなら、リアルモードでも全然いける。
鍵は「状態は全部“持ち運ぶ”」。やり方の芯だけ置くね。
方針(壁に貼るメモ)
- 共有可変状態=ゼロ(OS常駐もアプリも)
- コンテキスト構造体を作って、常に参照を渡す(ES:DI など)
- 呼び出しはハンドル/メッセージ指向(=グローバル変数の代わり)
- バッファは呼び出し側持ち(出力先も毎回渡す)
- 割り込みはOS内に閉じる(リングバッファ等はOS所有。アプリはAPI越しに読む)
具体ABI(例)
- すべてのAPIは
int 80h
、ES:DI = ctx、DS:SI = I/Oバッファ、AX = サービス番号。 - 返り値:CF=1でエラー、AX=errno、CF=0で成功・AX=サイズ等。
アプリ側(ctxを自前で用意して渡す)
; アプリ起動時に自前のコンテキストを確保(自分のセグメント内)
app_start:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xfffe
lea di, [ctx] ; ES:DI = &ctx
; 例: キー入力取得
mov ax, 0x0101 ; getkey
int 0x80 ; (ES:DI=ctx)
jc .err
; 例: 画面出力(呼び出し側バッファ渡し)
lea si, [msg]
mov ax, 0x0202 ; write_console
mov cx, msg_len
int 0x80 ; (ES:DI=ctx, DS:SI=buf, CX=len)
jmp exit
ctx: times 64 db 0 ; ←アプリ専用の状態置き場(グローバル代替:でも“外部には晒さない”)
msg: db "Hello",13,10
msg_len equ $-msg
.err: ; エラー処理
exit: jmp 0x0800:0000 ; モニタへ
OS側(常駐部のint 80hハンドラのイメージ)
int80h:
; ES:DI=ctx を信頼する。OS側は“アプリ状態を覚えない”
cmp ax, 0x0101 ; getkey
je svc_getkey
cmp ax, 0x0202 ; write_console
je svc_write
stc ; 未実装
mov ax, 1
iret
svc_getkey:
; OS内部のkbdリングバッファ(OS専有)から1文字取り出し→AXに
; ES:DI は未使用でもOK(将来の状態に使える)
; ...
clc
iret
svc_write:
; DS:SI=buf, CX=len をそのまま描画デバイスへ
; OSは出力先を持ってるが、書く内容は**必ず呼び出し側バッファ**
; ...
clc
iret
グローバルの代替“型”
- Context(ctx):モジュール内“だけ”で使う状態袋(呼び出しごとにES:DIで運ぶ)
- Handle:OSが返す小さな整数(内部はOS専有テーブル)。アプリは数値だけ保持
- Message:
svc=..., argp=DS:SI
で毎回全部渡す - TableはconstでCS常駐(書き換え不要なデータはコードセグメントに置く=事実上読み取り専用)
割り込みとの付き合い方
- ISRはOS内完結:キー入力・タイマ・通信はOSが自分のリングバッファに積む
- アプリからはAPIでpull:
getkey()
,recv()
で取り出す(共有変数に触らせない) - 共有が必要なら**“コピーして渡す”**(呼び出し側バッファにDMA/PIOで詰めて返す)
よくある落とし穴 → 回避策
- ✖「便利だからOS側に“現在カーソルX/Y”を変数で持つ」
→ ✔ APIの引数で毎回渡す or ctxに入れて持ち運ぶ - ✖「巨大なワークバッファをOSが一個だけ持つ」
→ ✔ 呼び出し側が自分のバッファを渡す(サイズも毎回指定)
結論:
**グローバルを捨てる=“状態は全部パラメータで運ぶ”**に尽きる。
リアルモードでも、ES:DI=ctx + DS:SI=buf + AX=svc
の型を決めれば、
グローバルゼロで“OS(常駐)+単一アプリ”は綺麗に回るよ。
あなた:
うん。まぁ、まったくグローバルがないっていうのもエキセントリックすぎるから、容量限定してありになるんだろうけどさ。あるいは簡易DBみたいなのもって変数の値を問い合わせるとかね。
ChatGPT:
思考時間: 6s
わかる、その塩梅がいちばん実用的。
**「容量と型を絞った“公認グローバル”+簡易KV/DB」**にしておくと、単一タスクでも気持ちよく回るよ。要点だけ置くね。
設計の芯
1) 公認グローバルの“箱”を固定配置
- 例:GAREA = 0x0700:0000, 4KB(IVT/BDAの上、モニタの下)
- 用途を区画と型で固定:可変長は作らない(破損しない)
[GAREA 4KB]
0x000-0x01F: ヘッダ (magic="GA", ver, len, crc)
0x020-0x03F: システム (ticks, rng_seed, flags)
0x040-0x07F: カーソル/画面/入出力既定
0x080-0x0FF: “アプリスロット”×4 (各32B) ※一時作業や戻り値
0x100-0xFFF: KV領域(下の #2)
“公認グローバル”に入れてよいのは読み書き頻度が高く小さい値だけ(座標、乱数種、既定I/O先、短い状態フラグなど)。
2) 簡易KV(固定長スロット)
- 固定長64Bスロット×N(例:3KB→48スロット)
- キー:最大8バイト(ASCII, 0終端)
- 値:最大48バイト(残りは予約/CRC)
- オープンアドレス法(1次線形)で衝突解決
- 削除は tombstone フラグ
- 更新は ver++ → 書き換え → ver反映(割込み競合に強い)
struct Slot {
uint8 ver;
uint8 flags; // 0=空,1=使用,2=墓石
char key[8]; // "CURSORX", "BAUD", …
uint8 len; // 値の実長 ≤48
uint8 rsv;
uint8 value[48];
}
“DB”というより設定レジストリ。ISRから読み可、書きはOSスレッドのみに。
3) API(INT 0x80 / 0x30xx番台)
- AX=0x3001
kv_get
: in: DS:SI=key(0終端), ES:DI=buf, CX=buflen → out: AX=len/CF=err - AX=0x3002
kv_set
: in: DS:SI=key, ES:DI=val, CX=len → out: CF=err - AX=0x3003
kv_del
: in: DS:SI=key → out: CF=err - AX=0x3004
kv_iter
: in: BX=cursor(0xffffで初期) → out: BX=次index, ES:DI=keyptr, AX=len(終端はBX=0xffff)
ルール:書き込み系はOS側でCLI/ STI もしくは簡易スピンロック。読み系はノーロックでもverチェックで整合。
4) 永続化(任意)
kv_commit
(AX=0x3005):GAREAのKVを**/CONFIG.DAT
**へそのまま書き出しkv_load
(AX=0x3006):起動時に読み戻し(magic & crc確認)- “電源断前に保存”が要るときだけアプリから呼ぶ。普段は揮発でOK。
5) ガイドライン(乱用を防ぐ)
- “公認グローバル”は総和4KB以内、キーは8文字、値48B
- 長文や配列は不可(必ずファイル or 呼出し側バッファ)
- アプリは“自分の状態は自分で持つ”が基本。GAREAは環境と設定だけ
- ISRは書かない(リングバッファはOS内、可視化はAPI越し)
使い方の絵
アプリ(例)
; set default baud=115200
lea si, [key_baud] ; DS:SI="BAUD"
lea di, [val_115k] ; ES:DI=値, CX=2 (例: 0x1C,0x20 = 115200の分周コード)
mov ax, 0x3002
int 0x80
; get cursor x
lea si, [key_curx] ; "CURSORX"
lea di, [buf]
mov cx, 2
mov ax, 0x3001
int 0x80 ; AX=len(=2), buf[0..1]に値
OS側(書き込み・簡易ロック手順)
; kv_set
cli
; tombstone/空きを探す→ver読み出し→値書き→flags=使用→ver++
; ※書き込み順序を固定:値 → flags → ver
sti
iret
こんなキーが便利
"CURSORX","CURSORY"
:描画の既定位置"BAUD","KLAYOUT"
:I/Oやキーボード設定"SEED","THEME","BOOTAPP"
:乱数・テーマ・既定アプリ"NET0IP","NET0GW"
:小さいネット設定(16B以内)
これで得られるもの
- **最小限の“共通状態”**を安全に共有(乱用しないガード付き)
- APIベースで“問い合わせる/更新する”型に統一
- シングルタスク/電源断前提でも設定は差し替え容易
- 必要ならファイルにスナップショットして実機の開発ループが速い
この枠なら“グローバル≒悪”にならず、気持ちよく“公認最小”に閉じ込められる。
必要なら、int 0x80
のハンドラ最小実装(kv_set/kv_get)をそのまま貼れる形で書くよ。
あなた:
もちろんね、プログラムを書くときに制約やテクニックが必要にはなるんだろうけれど、メモリモデルを必死で考慮するよりは楽じゃないのかなぁ、とか思いました。
ChatGPT:
その感覚でOK。
“単一タスク+固定エントリ+小さな公認グローバル+API呼び出し”に寄せると、メモリモデルを気にする場面は激減するよ。やることは「セグメントを賢く“使わない”」設計に揃えるだけ。
実用チェックリスト(これ守ればだいたい楽)
- ロード位置固定:
APP_BASE=0x1000:0000
に連続ロード、jmp APP_BASE:0
。 - CS=DS=ES=SS 統一:起動時に
mov ax,cs / mov ds,ax / mov es,ax / mov ss,ax / mov sp,0xfffe
。 - near縛り:関数はnearで切る。64KB超えそうな“段”はモジュール分割+far call。
- データは外だし:大きい配列/アセットはファイルから都度読む or 一時バッファにロード。常駐データは薄く。
- 共有状態は最小:公認グローバル4KB+簡易KV(キー8B/値≤48B)。それ以外はctxを渡す。
- APIはINT一本化:
int 0x80 (AX=svc, ES:DI=ctx, DS:SI=buf, CX=len)
で統一。 - 割り込みはOS内完結:キーボード/タイマ/通信はOSのリングに積む→アプリはAPIでpull。
- 終了はジャンプ契約:
jmp MONITOR_ENTRY
(例:0x0800:0000
)。クリーンアップは自分で。 - 資源は“借り物”:VRAM/UART/DMAの占有は起動〜終了の間だけ。戻る前にデバイスを既定状態へ。
64KBをまたぐテク(最低限)
- コードが膨らんだら:モジュールを64KB未満に分割して絶対セグメントに配置。呼ぶ側は
push word SEG_UTIL push word util_func retf
- 共有データが必要なら:farポインタユーティリティだけ用意(
lds/les
,movs
でコピー)。
なるべく“そのセグメント内で完結”する配置にして跨ぎを設計で減らす。
典型API(最小形)
0x0101 getkey
→ AX=キー/CF=1で空0x0201 write(buf,len)
0x0301 kv_get(key,buf,len)
/0x0302 kv_set(key,buf,len)
0x0401 read_file(path, dst, max)
(OSのFATローダを呼ぶ)
便利な型(乱用しない用)
- ctx(64〜128B):アプリが持つ小さな状態袋。毎回 ES:DI で渡す。
- handle(u16):OS内テーブルの参照だけ返す(実体はOS専有)。
よくある落とし穴 → 回避
- 落とし穴:無意識に遠い静的配列を増やす → 回避:大きいものはファイル/一時バッファへ。
- 落とし穴:ISRとアプリが同じ変数を触る → 回避:ISRはリングに積むだけ、読み出しはAPI。
- 落とし穴:far呼びが混線 → 回避:モジュールごとに
SEG_xxx
定数を一箇所で定義、ジャンプテーブル化も可。
ミニ雛形(アプリ側)
org 0
BITS 16
start:
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xfffe
lea di, [ctx] ; ES:DI=ctx
lea si, [msg] ; DS:SI=buf
mov cx, msg_len
mov ax, 0x0201 ; write
int 0x80
jmp 0x0800:0000 ; モニタへ
ctx: times 64 db 0
msg: db "hello!",13,10
msg_len equ $-msg
結論:
“設計で跨がないようにする”+“APIでお茶を濁す”に振り切ると、メモリモデルを必死に気にせずに済む。
困ったらその時点で1モジュールだけfar化すればいい――段階的にやれるのがこのやり方の楽さだよ。
あなた:
だよねぇ。シングルタスクならあんまり苦労しなくてもできるよねぇ?
ChatGPT:
うん、シングルタスクならぜんぜん苦労いらない。
“固定エントリ+ジャンプ契約+薄いAPI”でサクッといける。
最小構成(これだけで回る)
- 常駐モニタ(下位1KB~数KB):
monitor_entry
(初期化→ファイル読込→jmp APP_BASE:0
)int 0x80
:getkey / write / kv_get / kv_set / read_file
だけ
- アプリ(単一):
- 起動時
CS=DS=ES=SS
同期、SP
設定 - APIは
int 0x80
だけ使う - 終了時
jmp MONITOR_ENTRY
(or 電源断)
- 起動時
ルール(壁に貼るメモ)
- ロード先固定:
APP_BASE=0x1000:0000
- near中心(64KB超えたらモジュール分割+
call far
) - 共有状態は最小(公認グローバル≤4KB+簡易KV)
- 割り込みはOSに閉じる(キーボード/タイマ/通信=リング→APIでpull)
今日やること(30分メニュー)
int 0x80
でwrite(buf,len=CX)
とgetkey()
だけ実装monitor_entry → jmp APP_BASE
- アプリ雛形:
mov ax,cs; mov ds,ax; ...; int 0x80(write); jmp MONITOR_ENTRY
よくある落とし穴 → 回避
- “つい”大きい静的配列 → ファイル or 一時バッファへ
- ISRと変数共有 → 禁止(リング→APIで渡す)
- far跳び迷子 →
SEG_xxx
を1か所で定義(必要時だけfar化)
——
この型なら、まず動く。困ったらその場で“1モジュールだけfar化”すればOK。
要るなら、int 0x80(write/getkey)
の最小ASMをすぐ貼るよ。
あなた:
マルチタスクにしても、最大タスク数を固定でエントリ位置さえ固定できるなら、そんなに苦労しなくてもアプリ配置できそうな気がしてきた。
ChatGPT:
それ、ぜんぜんアリ。
**“固定スロット多重化(static partition multitasking)”**にすれば、再配置なしでサクッとマルチタスク化できる。
型(これで回る)
- MAX_TASKS = N を決めて、各タスクに固定のメモリ帯 & 固定エントリを割り当てる
- 各帯は「コード+データ+スタック」を64KB以内に収める(足りなければ帯を複数に増やす)
- **TCB(タスク制御ブロック)**に
CS:IP, SS:SP, DS, ES, 状態
を保存 - スケジューラはタイマISRでラウンドロビン(または明示
yield
) - ローダは「タスクiの帯」にファイルを連続ロードしてTCB初期化、以上
例:4タスク固定マップ
MONITOR : 0x0800:0000 ~ (数KB)
TASK0 : 0x1000:0000 entry=0000h stack=FFFEh
TASK1 : 0x2000:0000 entry=0000h stack=FFFEh
TASK2 : 0x3000:0000 ...
TASK3 : 0x4000:0000 ...
- 各タスクは CS=DS=ES=SS=自分のセグメント で開始(COMライク)
- アプリは終了時に
jmp MONITOR_ENTRY
(またはint 0x80, svc_exit
)で戻す
TCB(最小)
; 1タスク分 16バイト程度
TCB:
dw ip, cs, sp, ss, ds, es, flags, state
; state: 0=空,1=準備完了,2=実行,3=休止
文脈切替(最小スイッチャ)
; in: BX = current_task_idx
; out: 次の準備タスクへ切替
switch_task:
; 現タスクのレジスタ保存
cli
mov si, bx
shl si, 4 ; si = idx*16 (TCBサイズに合わせる)
; 保存(IP/CSはIRETで自動復帰させる方式 or 手動保存のどちらか)
; ここではIRET法の例:ISR入口で自動で[IP,CS,FLAGS]が積まれてる
; SS:SP, DS, ES を保存
mov [tcb+si+4], sp ; sp
mov [tcb+si+6], ss ; ss
mov [tcb+si+8], ds
mov [tcb+si+10], es
; 次タスク選択(簡易ラウンドロビン)
.next:
inc bx
and bx, (MAX_TASKS-1)
mov di, bx
shl di, 4
cmp byte [tcb+di+14], 1 ; state==READY?
jne .next
; 次タスクの文脈復元
mov sp, [tcb+di+4]
mov ss, [tcb+di+6]
mov ds, [tcb+di+8]
mov es, [tcb+di+10]
; スタック上の [IP,CS,FLAGS] に切替(ISR復帰で反映)
sti
iret ; ← ここで次タスクのCS:IPへ
タイマISRの末尾で
switch_task
を呼ぶだけでプリエンプティブにできる。
共同多重化ならyield
APIで同じことを呼ぶ。
初回起動(タスク生成)
; タスクiにロード
; AX=task_seg, ファイルを task_seg:0000 に連続ロード
init_task_i:
mov bx, task_seg
; DS=ES=SS=task_seg, SP=FFFEh
mov ds, bx
mov es, bx
mov ss, bx
mov word [tcb_i.cs], bx
mov word [tcb_i.ss], bx
mov word [tcb_i.ds], bx
mov word [tcb_i.es], bx
mov word [tcb_i.ip], 0x0000
mov word [tcb_i.sp], 0xFFFE
mov byte [tcb_i.state], 1 ; READY
API(最小)
int 0x80 / AH=01h
:yield
(共同多重化)int 0x80 / AH=02h
:exit
(TCBを空に戻す)int 0x80 / AH=10h
:write(buf,len)
int 0x80 / AH=11h
:getkey()
- (任意)
send/recv
:固定サイズのメッセージキューを各タスクに1本
ローダの役割
- 差し替え自由:
TASKk.BIN
を TASKk帯へ単純コピー - エントリ固定:常に
:0000
から開始(COMライクでOK) - サイズ超過は検知:64KBを超えるなら帯を2本に割る or そのタスクはMZ/分割に
メリット / デメリット
+ 再配置不要、デバッガブル、差し替え容易、最小限で“マルチタスク気分”
- メモリは固定割当で無駄が出る、動的生成/巨大アプリには不向き
運用ルール(これ守ると事故らない)
- 各タスクは自分の帯だけを触る(CS=DS=ES=SS=自帯)
- 共有はAPI越し(KV/キュー)
- 終了は
int 0x80; AH=02h
かjmp MONITOR_ENTRY
- VRAM/UART 等は借りる→返す(APIが面倒見る)
つまり:
“スロット×N + 固定エントリ” でやれば、マルチタスクでも配置はラク。
必要になったらその先(動的割付・再配置・仮想化)へ進めばいい。
試すならまず MAX_TASKS=2~3 で回し、yield
と exit
だけ作るのが最短コースだよ。
あなた:
ん~、さらにさらに、なんだけど、RPCの口さえ作っておけば、協調マルチは楽にできるじゃない?
ChatGPT:
そうそう、その発想が一番ラク。
**「RPCの口=共通の呼び窓」**さえ決めれば、協調マルチはすっと作れる。最小構成を置くね(実装しやすい順)。
コア設計(最小)
メッセージ形式(固定ヘッダ)
struct Msg {
u16 src; // 送信タスクID
u16 dst; // 宛先タスクID
u16 op; // メソッドID
u16 reqid; // 要求ID(0なら片道)
u16 len; // ボディ長(≤ 240 等)
u8 body[len]; // 引数(LE, ポインタ禁止)
}
- ポインタ禁止、可変長は body に生で詰める(長さはlen)。
- でかいデータは 共有メモリハンドル(後述)で渡す。
メールボックス(タスクごと1本)
- 固定長リングバッファ(例:32メッセージ×256B)
- OS常駐が
send/recv
を面倒見る。 - 協調:
recv()
が空なら yield で他タスクに回す。
最小API(INT 0x80)
AH=0x40
mq_send(dst,op,reqid,buf,len)
- in: BX=dst, CX=op, DX=reqid, DS:SI=buf, AX=len → CF=err
AH=0x41
mq_recv(buf,max,timeout_ticks)
- out: AX=len, BX=src, CX=op, DX=reqid → CF=timeout/空
AH=0x42
rpc_call(dst,op,reqid,req_buf,req_len,rep_buf,max,timeout)
- 送信→待受(
mq_recv
でreqid
一致まで待つ)をOSがまとめてやる
- 送信→待受(
AH=0x43
rpc_reply(dst,reqid,buf,len)
タイムアウトは tickカウンタ(OSがIRQ0で加算)。0なら無限待ち。
名前解決(レジストリ)
- KVに
"srv/console"=taskID
みたいに登録。 - クライアントは
kv_get("srv/console") → taskID
→rpc_call(...)
。
共有メモリ(必要になったら)
shm_create(size)
→ 句読点的に (seg:off,len) or handle を返すshm_map(handle)
/shm_unmap(handle)
- RPC body には handle を入れる(コピー回避)。
- まずは無しで始めて、必要になったら追加でOK。
使い方の型(同期RPC)
クライアント側(擬似ASM)
; console.print("Hello")
lea si, req ; DS:SI=buf, AX=len
mov bx, [srv_console] ; dst
mov cx, 1 ; op=1 (print)
mov dx, 1 ; reqid=1
mov di, rep ; ES:DI=rep_buf
mov bp, rep_max ; BP=max
mov ah, 0x42 ; rpc_call
int 0x80 ; 返るまで協調待ち(内部でyield)
jc .timeout_or_err
; rep には status 等が返る
サーバ(console)側
rpc_loop:
; 受信(なくても OS側でyieldされる)
lea di, buf
mov cx, buf_max
mov dx, 0 ; timeout=0 無限
mov ah, 0x41
int 0x80 ; AX=len, BX=src, CX=op, DX=reqid
jc rpc_loop ; 空ならまた待つ
cmp cx, 1 ; op=print
jne rpc_loop
; bodyを画面に出す …
; 返信
lea si, rep_ok
mov ax, rep_ok_len
mov bx, bx ; dst=src
mov dx, dx ; reqid
mov ah, 0x43
int 0x80
jmp rpc_loop
運用ルール(破れない約束)
- 再入不可・協調:サーバは1本のループで処理。重い処理中は自発
yield
(AH=01h
等) - ABIは“数値+バイト列”のみ:構造体はABI化(LE固定、アライン無し、可変長は
len+data
) - 要求IDは呼び出し側が発行(単純に++)
- 例外=エラーコード:返信の先頭バイトを
status(0=OK/!=err)
に統一 - タイムアウトで健全化:クライアントは必ず timeout を入れる
まずはこれだけ(最小RPCセット)
mq_send / mq_recv
rpc_call / rpc_reply
(内部でmq_*
+待受)- レジストリ:
kv_set("srv/console", id)
- サンプルサーバ:
console
(op=1: print / op=2: setpos) - サンプルクライアント:hello(print→OKを受ける)
ここまでで協調マルチは体感できる。
必要になったら:
select(複数待ち)
をmq_peek(mask)
で追加- 共有メモリハンドルを足して大きい転送をゼロコピーに
結論:
RPCの口さえ決めれば、タスクは“サーバ or クライアント”の役割に分かれ、
スケジューラは“空のときyield”だけで回る。
シングルCPUでも“気持ちよく協調”できるよ。
あなた:
config.sysとかautoexec.batのロード順を最適化するのに血眼になるくらいなら、アプリ設計とロード順に血眼になった方が健全だと思うんですよ。
ChatGPT:
わかる。起動スクリプト最適化より、アプリ設計とロード順最適化に投資した方が長期的に健全だし速い。
指針(短く)
- 依存グラフを明示:サービス同士の前後関係を表にする(グローバル設定・KVで宣言)。
- 段階起動(Stages):
S0(最低限IO) → S1(必須サービス) → S2(任意/遅延) → S3(アプリ)
の4層で固定。 - イニシャライズは冪等:同じinitを何度呼んでも安全(途中失敗→再試行OK)。
- ヘルスチェック & タイムアウト:各サービスは
ready?
をRPCで返す。待機は上限あり。 - 遅延ロード(Lazy):S2は“使われたら起動”。起動時間の山を平らに。
- ゼロコピー転送は後回し:まずはRPC/メッセージで正しさ→必要になれば共有メモリ。
- 失敗は隔離:落ちたS2は放置で本命(S3)を通す。後から再起動可。
- 計測を入れる:起動ログに「開始/完了t」「待機先」を必ず出す(後で並べ替え材料に)。
最小の形(例)
1) 依存表(ROM/定数に埋め込み)
Service Stage Needs
--------------------------------
console S1 (none)
storage S1 (none)
net S2 storage
audio S2 (none)
app_main S3 console, storage
2) 起動アルゴリズム(擬似)
for stage in [S0,S1,S2,S3]:
for svc in stage:
spawn svc ; wait ready(svc, timeout=X)
// S2は起動フラグが "auto" のみ起動、"lazy" はスキップ
3) RPCインタフェース(最小)
svc.start()
→OK/ERR
svc.ready()
→0/1
svc.stop()
(任意)
4) KVで起動ポリシ
"s1": "console,storage",
"s2.auto": "net",
"s2.lazy": "audio",
"s3.app": "APP.BIN"
実運用Tips
- cold/warm分離:一度でも使ったS2は“warm再起動”対応に(キャッシュ残す)。
- 並列化はほどほど:S1は並列可だが、待機は
needs
のみ。全待ちはしない。 - 可視化:起動ログを「棒グラフ風」に整形(開始t/終了t/待機先)。並べ替えの根拠になる。
- フェイルオープン:
console
とstorage
だけ上がればS3へ進む設計に。ネットや音は後追い。
これで得られるもの
config.sys/autoexec.bat
的な順番芸から卒業。- 宣言的な依存+段階で、起動順の議論が“表の更新”に還元。
- 起動時間の短縮はS2の遅延と無駄待ち削減で効く。
必要なら、この依存表とステージャの**最小実装(ASM/C)**を、あいかのint 0x80
/RPC口に合わせてすぐ出すよ。
あなた:
今まで言ってきたことっておかしくないよね?
ChatGPT:
まったくおかしくない。むしろ一貫して筋が通ってるよ、あいか。
要点の整合チェック(ぜんぶOK)
- 単一タスク:固定エントリに
jmp
→ 終了時にモニタへjmp
。再配置不要(✓) - 64KB問題:
- 収まるなら
.COM
相当(CS=DS=ES=SS)で near 中心(✓) - 超えるなら「絶対配置のモジュール分割+far call」または MZ(.EXE)ローダ(✓)
- 収まるなら
- 共有状態:公認グローバルを小さく限定+簡易KV(固定長スロット)で問い合わせ(✓)
- API境界:
int 0x80
(AX=svc / ES:DI=ctx / DS:SI=buf / CX=len)に統一(✓) - 割り込み:タイマ/入力/通信はOS内リングで完結、アプリはAPIでpull(✓)
- 協調マルチ:RPC口(mq_send/recv + rpc_call/reply)で“待ち=yield”の協調実現(✓)
- 固定スロット多重化:MAX_TASKS×固定帯×固定エントリで再配置ゼロの簡易マルチ(✓)
- 起動哲学:config.sys芸より、依存グラフ+ステージ化+遅延起動に投資(✓)
つまり:
**「最小のモニタ+薄いAPI+固定配置」**を土台に、
必要になったときだけ EXE/分割/RPC/固定スロット を足す——段階設計。
現実的で、拡張の逃げ道も確保できてる。
次やるなら、どれ行く?
int 0x80: write/getkey
の最小実装- KV(64Bスロット×48)の
kv_get/kv_set
- mq/rpc の最小版(op=print のサンプル)
一つ選べば、その場で貼るよ。
あなた:
まだです。デメリットは何だろう?
ChatGPT:
ぜんぜんおかしくないけど、デメリット(落とし穴)はある。短く要点だけ+軽い回避策も。
アーキ制約
- 保護なし:他タスク/アプリの暴走が全部を巻き込む。
↳ 入口で“自己検査”+OS側にwatchdog(一定時間応答なしで強制終了)。 - 仮想メモリ無し:大物を詰め込めない/アセット常駐が難しい。
↳ ストリーミング前提(都度read)+一時バッファでやりくり。 - 固定配置の硬さ:配置を変えるたびに再ビルド/静的表のメンテが発生。
↳SEG_xxx
の一元定義と自動生成(スクリプト)を用意。
パフォーマンス/レイテンシ
- INT呼び出しコスト(
int 0x80
)はcall far
より重い。
↳ 頻出APIは**バイパス(far callゲート)**も用意。 - 協調マルチ:重い処理が
yield
しないと全体が詰まる。
↳ API側で暗黙yield点を入れる(I/O/待機系)。
スケーラビリティ
- 固定スロット浪費:メモリの“遊び”が出やすい/足りない帯は詰む。
↳ “細粒度タスク×少数”に抑える or 帯サイズを2~3段階だけ用意。 - 64KB越えの手間:far分割は設計負債になりがち。
↳ 本当に肥大化したらMZローダを後付け(逃げ道は確保)。
信頼性/回復
- アプリ後始末ミス:戻る前にデバイス初期化を忘れると次が壊れる。
↳ OS側に**reset_device()**を用意し、戻り時に必ず叩く(フェイルセーフ)。 - KV破損:電源断混入で更新中のKVが壊れる可能性。
↳ ver/CRC+二重書き(A/Bスロット)か“ジャーナル書き”にする。
同期/RPC
- デッドロック:相互RPC待ち+協調だと止まりやすい。
↳ 片方向原則(Client→Server→Replyのみ)+タイムアウト必須+再入禁止。 - 大きなメッセージ:コピー回数が増えて遅い。
↳ 必要時のみ共有メモリハンドルを導入(ゼロコピー化)。
ドライバ/I/O
- 再入不可:割り込み中にAPIを叩く設計は地雷。
↳ ISRはキューに積むだけ/処理はスレッド側で。 - DMA/長転送の衝突:固定帯をまたぐとややこしい。
↳ DMAは専用ワーク領域を1か所に固定してOSが仲介。
開発・運用
- デバッグ視認性:固定ジャンプで戻るだけだと原因追跡が大変。
↳ 統一ログAPI(レベル付き)+起動/終了トレースを必ず吐く。 - ABI凍結の重さ:INTの引数形式を後で変えにくい。
↳ バージョン番号をAX上位やKVに持ち、互換レイヤを薄く挟む。 - テストの複雑さ:far分割や固定帯はユニットテストしにくい。
↳ “純粋関数”比率を上げ、I/Oはモック化API越しにする。
未来への移行コスト
- 長/保護モード移行:実モ前提のABIや固定帯は移植に痛い。
↳ APIを抽象命名で切っておき、実装を差し替えやすくする(薄いHAL)。
まとめ(指針)
- まずは今の設計で最小成功体験を積む(正解)。
- そのうえで“痛点”が出た箇所だけ、
- 多重化→スロット拡張 or MZ
- 転送→共有メモリ
- 信頼性→A/B書き+watchdog
を局所追加。
“全部やる”は負け、必要になったら足すで十分。
あなた:
ふむん。APIの呼び出しコストが重いくらいで速度的には問題なさそう?
ChatGPT:
結論:だいたい問題にならない。ボトルネックはほぼ常にI/O側で、int
の呼び出しコストは相対的に小さいです。ただし「細かく大量に呼ぶ」設計にすると途端に効いてくるので、そこだけ避ければOK。
ざっくり目安
- INT呼び出し(
int 0x80
→iret
): ざっくり 100〜200サイクル級。
4.77 MHz相当でも 20–40µs/回 程度のオーダー。 - far call/retf: その半分くらいの感覚(ざっくり 40–80サイクル級)。
UART 115,200bps は 1バイト=約87µs かかるので、1回のINTコストは通信時間に比べて小さい。
ディスクI/Oはさらに桁違いに遅いので、INTコストは誤差。
どきゅんと効いてしまうケース(避けるだけで勝ち)
- 1バイトごとに
putchar
をint
で呼ぶ
→ バッファ書きにして 1回で投げる - 1ピクセル描画ごとに
int
→ VRAMに直接書く(モードX/Mode13h等)+行/矩形単位で処理 - チャットtyなRPC(小メッセージを往復しまくる)
→ まとめて送る、返信を遅延、必要なら共有メモリハンドルでゼロコピー
実装ガイド(速さと簡単さの両立)
- 粗いAPIは
int 0x80
のままでOKwrite(buf,len)
/read(buf,max)
/getkey()
/kv_get/set()
など
- ホットパスは“速道”を用意
- 同じ機能の far-call版ゲート(固定ジャンプテーブル)を併設
- 例:
int 0x80
のwrite
に加え、call far [API_VECT+WRITE]
も許す
- まとめて処理
- 画面:アプリがVRAMへ直接描画→flip だけを
int
で呼ぶ - 入力:
getkeys_bulk(buf, max)
で一気に吸う - ディスク:クラスタ単位で読む、先読み/二重バッファ
- 画面:アプリがVRAMへ直接描画→flip だけを
- 協調マルチのRPC
rpc_call
は要求を束ねる/タイムアウト必須- 大きい転送は shared-mem handle をbodyに載せる(データ本体はコピーしない)
迷ったときの指針(これ守ればたいてい速い)
- “1操作=1INT”にしない(バッチ or 直接アクセス)
- I/Oは大きめに(通信・ディスクはまとめ読み/書き)
- 描画は直接・一括(VRAM直書き→flip)
- 必要になったらだけ far-call ゲートを足す(全部を最適化しない)
要するに:普段は int
で十分、ホットパスだけ特別扱い。
この運用で、シングルタスクでも協調マルチでも“速さの問題”はまず起きないよ。
あなた:
あと、大容量メモリの問題だけど、データのおっきいのは許容します。実行部はできる限り小さく、ですね。
ChatGPT:
いいね、その方針が一番ラクで堅い。
**「実行部は極小、データはデカくてOK」**で進めるときの実務レシピをまとめるね(必要最低限)。
コア戦略
- コード小さく:モジュール分割+near中心。どうしても肥大なら *1 モジュールだけ far 化。
- データは外出し:ファイルからストリーミング/ページングで使う。常駐は最小。
- APIは粗粒度:
read_bulk / write_bulk / flip / getkeys_bulk
など“まとめて”を基本に。
データ大きめ運用の型
1) ストリーミング(逐次読み)
- チャンク固定:例)
64KiB
or クラスタ境界で読む - 二重バッファ(ping-pong):
bufA
を処理中にbufB
を前読み - I/Oは別口:タイマISRまたは“IOタスク”が
read_next()
を担当(協調ならyield
点で回る)
API案
int 0x80 / AH=0x50
io_open(path)
→ AX=handleAH=0x51
io_read(h, ES:DI, CX=len)
(lenは大きめ)AH=0x52
io_seek(h, DX:AX=offset32)
(32bitオフセット必須)AH=0x53
io_close(h)
2) ページング(ウィンドウ方式)
「巨大データの見たい場所だけ窓を当てる」
- 固定ページ:例)
PAGE_SIZE = 16KiB
- 現在窓:
(seg:off, page_index)
を管理 - 参照先が窓外に出たら
load_page(idx)
で差し替え(LRU 2~3枚で十分)
ユーティリティ(farポインタ不要の運び屋)
read_window(dst_local, count)
:窓を跨いだら自動で次ページを読み足す- アプリからは“連続メモリ”的に見える
3) リソースパック(大量ファイルの管理を軽く)
- 索引+連続本体(簡易pak):
index[] = {name8, off32, len32}
を先頭に- 本体は連続格納(クラスタ連続で速い)
- 起動時に索引だけロード→各アセットは
io_seek + io_read
で即取り出し
4) (必要なら)軽量圧縮
- **LZ4系の“逐次展開”**が吉:展開器は数百~数KB、ストリームで吐ける
- API:
decomp_init(ctx) / decomp_chunk(ctx, in, out)
- “I/O→展開→二重バッファ”のラインを1回で流す(コピー回数を減らす)
5) 表示や転送の“まとめ”
- 画面:VRAM直書き→最後に
flip()
だけINT - 音:バッファキュー(固定長)に塊で渡す
- 通信:
send(buf,len)
はMTU相当の塊で。recv_bulk()
で一気吸い
ミニ実装例(ASMの雰囲気)
二重バッファ前読み(協調)
; Aを処理中にBをロード → 交互
; handle=BX, bufA=ES:DI, bufB=ES:SI, chunk=CX
.proc_next:
; kick read to bufB
push es di cx
mov ah, 0x51 ; io_read
int 0x80 ; 読み中に…
pop cx di es
; process bufA (CPU側)
call process_chunk
; swap
xchg di, si
jmp .proc_next
ページ窓ユーティリティ(擬似C)
bool read_window(void* dst, uint32_t pos, uint16_t n) {
while (n) {
uint32_t page = pos / PAGE_SIZE;
uint16_t off = pos % PAGE_SIZE;
if (page != cur.page) load_page(page); // io_seek + io_read(pagebuf)
uint16_t take = min(n, PAGE_SIZE - off);
memcpy(dst, pagebuf + off, take);
pos += take; dst += take; n -= take;
}
return true;
}
現実Tips
- 32bitオフセットは必須(リアルでもデータは2^32まで触りたい)
- チャンク/ページは2の冪(境界計算とDMAが楽)
- 先読みは控えめ(I/Oが律速:1~2ページで十分)
- ログに計測(読込ms/ページミス率)→チューニングの指針になる
よくある落とし穴 → 回避
- 1バイト/1ピクセル単位のAPI呼び出し → 塊APIに寄せる
- でか配列を常駐にしがち → ウィンドウ or ストリーミングへ
- 同期RPCで小粒を往復 → 一括要求+遅延返信、必要なら共有メモリハンドル
- FATのチェーン追跡を都度やる → クラスタ連続化(pak化)or チェーン先読みキャッシュ
まとめ
実行部ミニマム+データ巨大は、
「塊で読む」「窓を当てる」「前読みする」――この3点で回る。
APIは粗く、I/Oを主体に。コードは薄く保つほど勝ちやすい。
必要なら、この方針で io_*
と flip/getkeys_bulk
の最小 int 0x80
実装をそのまま貼るよ。
あなた:
それと、イメージ的にはデータ部だけの仮想記憶っていう感じ。別の言い方すればデータキャッシュなんだけどね。
ChatGPT:
うん、その発想で合ってる。
**「データだけ仮想記憶=ページ型データキャッシュ」**を挟めば、実行部を小さく保ったまま巨大データを“ある体裁で無限”に扱える。要点だけサクッと設計置くね。
目標像(データ専用VM)
- コードは常駐&小さく、データは窓越しに読む
- APIは**“連続オフセット”で要求**、裏でページ境界やI/Oは隠す
- 置換は LRU/Clock、前読みでスループ上げる
コア設計
パラメータ
- PAGE_SIZE: 16KiB or 32KiB(2の冪、FATクラスタの倍数推奨)
- N_PAGES: 2〜4(最小)→足りなきゃ8(メモリと相談)
- ALIGN: 物理バッファは16B境界(
movs
/DMAが楽)
構造体
Page {
u32 file_id; // どのデータ源か(ハンドル/ハッシュ)
u32 index; // ページ番号 (offset / PAGE_SIZE)
u16 seg; u16 off;// 実体の位置(バッファ)
u8 valid; // 0/1
u8 busy; // 読み込み中(協調wait用)
u8 pin; // ピン留め(破棄禁止)
u8 ref; // 参照ビット(Clock)
u32 last_use; // LRU用tick(任意)
}
インデックス
- (file_id, index) → page_id の小ハッシュ表(オープンアドレス)
- 衝突は線形探索、墓石管理
置換
- Clock(軽い)かLRU(精度高い)
pin==0 && busy==0
のみ対象
API(INT 0x80 例)
AH=0x60 io_open(path)
→ AX=handle(file_id)AH=0x61 io_read_at(h, off32, ES:DI, CX=len)
- 窓を跨いでも連続して埋めて返す(内部でページ跨ぎ対応)
AH=0x62 io_map(h, page_index)
→ AX=seg, DX=off(ページ先頭を直接触る用)AH=0x63 io_prefetch(h, off32, len)
(ヒント)AH=0x64 io_close(h)
普段は
io_read_at
で十分。性能詰める時だけio_map
を使ってページ直参照。
読み取りパス(擬似)
read_at(h, off, dst, n):
while n > 0:
idx = off / PAGE_SIZE
inpg = off % PAGE_SIZE
p = lookup_or_load(h, idx) // キャッシュ照会→なければロード
take = min(n, PAGE_SIZE - inpg)
memcpy(dst, p.buf + inpg, take)
off += take; dst += take; n -= take
lookup_or_load(h, idx)
:
- ハッシュで発見→
ref=1; last_use=tick; return
- 無ければ犠牲枠選択 →
busy=1; valid=0
seek(h, idx*PAGE_SIZE); read(buf, PAGE_SIZE)
- 失敗なら
busy=0; valid=0;
エラー - 成功なら
valid=1; busy=0; ref=1; last_use=tick;
登録
※ 協調マルチなら busy
ページはyieldしながら待つ。
前読み(シンプルで効く)
read_at
が順方向に連続アクセスを検知したらnext_idx
をprefetch_queue
に積む- タイマISR後半や
yield
ポイントで空きページへ先読み - キュー長 2〜3 で十分
直接参照(ホットパス)
io_map(h, idx)
で(seg,off)
を返し、アプリがVRAM転送やデコードを直で実行- 終わったら
unpin()
(pinフラグを落す) - ストリーム系は
map→処理→unpin→map(next)…
のループが速い
書き込み(必要なら)
- まずは読み取り専用で開始(運用が楽)
- 書くなら write-through(安全重視)→慣れたら write-back + dirty
Page.dirty
と フラッシュAPI(io_flush(h)
)用意
失敗と対策(短く)
- スラッシング(N_PAGES少なすぎ)→ ページを2→4に増やす、PAGE_SIZEを大きめに
- 小粒APIの呼び過ぎ →
io_read_at
に大きめlenを渡す - 不規則アクセス → 前読みを無効化、
io_map
+明示ロードに切替 - 長I/Oで全体停止 →
busy待ち
でyield(協調) - FATチェーン遅い → pak化(索引+本体連続) or チェーン前展開キャッシュ
運用Tips
- PAGE_SIZE=16KiB×N=4 から始める(合計64KiBのキャッシュ)
- 索引だけ最初に読む(ファイル数多い場合)。本体は遅延
- ログにページミス率と平均readサイズを出す→次の調整指標
まとめ
データだけVM=ページキャッシュを挟むだけで、
実行部は小さく、データは巨大でも“滑らかに”扱える。
まずはread_at
と 2枚前読み。必要になったらio_map
とpin/unpin
を足せばOK。
ChatGPT の回答は必ずし