Initial DOS VM tooling setup

This commit is contained in:
2026-08-16 06:12:59 +09:00
commit cbfad06e6f
14 changed files with 1615 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# QEMU runtime state, downloaded media, and generated captures
.qemu/*
!.qemu/*.ps1
!.qemu/*.mjs
!.qemu/*.txt
!.qemu/share/
!.qemu/share/*.c
.qemu/*.qcow2
.qemu/*.img
.qemu/*.iso
.qemu/*.zip
.qemu/*.png
.qemu/*.ppm
.qemu/*.tmp
# Local logs and editor/OS metadata
*.log
*.tmp
*.swp
*~
.DS_Store
Thumbs.db
# Node/tool caches
node_modules/
.npm/
.cache/
# Temporary DOS build sources
.qemu/share/dosag*.c
+20
View File
@@ -0,0 +1,20 @@
# DOS VM tools
This project-local Pi extension exposes `dos_read`, `dos_write`, `dos_edit`,
`dos_list`, and `dos_exec`. It communicates with `DOSAGENT.EXE` over COM1
through the serial relay controller port at `127.0.0.1:5555`.
The agent is not started automatically. From the FreeDOS VGA console, run
`DOSAGENT` (installed in `C:\\FREEDOS\\BIN`, which is on `PATH`) when Pi tool
access is needed. While it runs, press Ctrl+C in the VGA console to stop it and
return to the DOS prompt; Pi tools then cannot connect until it is started
again. QEMU monitor control remains available at `127.0.0.1:4444`.
Connect PuTTY in Raw mode to `127.0.0.1:5557` for a read-only decoded mirror of
Pi/DOS serial traffic. Port 5556 is reserved for the internal QEMU-to-relay
connection.
`D:` is QEMU's FAT-directory view of `.qemu\\share`. Treat it as an exchange
volume, not a live-sync mount: host-side changes can be stale or partially
visible to a running VM. Restart QEMU before compiling host-edited files, or
use `dos_write` to place the source on `C:` before compiling.
+222
View File
@@ -0,0 +1,222 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import net from "node:net";
const HOST = "127.0.0.1";
const PORT = 5555;
const MONITOR_PORT = 4444;
const CHUNK_SIZE = 4096;
const MAX_FILE_SIZE = 4 * 1024 * 1024;
function hexEncode(value: string | Buffer): string {
return Buffer.from(value).toString("hex").toUpperCase();
}
function hexDecode(value: string): Buffer {
return Buffer.from(value, "hex");
}
function normalizePath(path: string): string {
const result = path.startsWith("@") ? path.slice(1) : path;
if (!/^[A-Za-z]:[\\/]/.test(result)) {
throw new Error(`DOS path must be absolute (for example C:\\SRC\\FILE.C): ${result}`);
}
return result.replaceAll("/", "\\");
}
async function request(command: string, signal?: AbortSignal, timeoutMs = 30_000): Promise<string> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host: HOST, port: PORT });
let buffered = "";
let settled = false;
const finish = (error?: Error, value?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
signal?.removeEventListener("abort", abort);
socket.destroy();
if (error) reject(error); else resolve(value ?? "");
};
const abort = () => finish(new Error("DOS tool call aborted"));
const timer = setTimeout(() => finish(new Error(`DOS agent timed out after ${timeoutMs}ms`)), timeoutMs);
signal?.addEventListener("abort", abort, { once: true });
socket.setEncoding("ascii");
socket.on("connect", () => socket.write(`${command}\r\n`, "ascii"));
socket.on("data", (chunk) => {
buffered += chunk;
const lines = buffered.split(/\r?\n/);
buffered = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("OK ")) return finish(undefined, line.slice(3));
if (line === "OK") return finish(undefined, "");
if (line.startsWith("ERR ")) return finish(new Error(hexDecode(line.slice(4)).toString("utf8")));
}
});
socket.on("error", (error) => finish(new Error(`Cannot connect to DOS agent at ${HOST}:${PORT}: ${error.message}`)));
socket.on("close", () => {
if (!settled) finish(new Error("DOS serial connection closed before a response"));
});
});
}
async function interruptForegroundCommand(): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = net.createConnection({ host: HOST, port: MONITOR_PORT });
const timer = setTimeout(() => { socket.destroy(); reject(new Error("QEMU monitor did not accept Ctrl+C")); }, 5_000);
socket.on("connect", () => socket.write("sendkey ctrl-c\r\n", "ascii"));
socket.on("data", () => { clearTimeout(timer); socket.destroy(); resolve(); });
socket.on("error", (error) => { clearTimeout(timer); reject(new Error(`Cannot contact QEMU monitor: ${error.message}`)); });
});
}
async function waitForAgentRecovery(timeoutMs = 10_000): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (await request("PING", undefined, 1_000) === "504F4E47") return true;
} catch { /* Command is still unwinding. */ }
await new Promise((resolve) => setTimeout(resolve, 250));
}
return false;
}
async function readFile(path: string, signal?: AbortSignal): Promise<Buffer> {
const dosPath = normalizePath(path);
const chunks: Buffer[] = [];
let size = 0;
let offset = 0;
for (;;) {
const response = await request(`READ ${hexEncode(dosPath)} ${offset}`, signal);
const match = /^([01])(?:\s(.*))?$/.exec(response);
if (!match) throw new Error(`Malformed READ response: ${response}`);
const chunk = hexDecode(match[2] ?? "");
chunks.push(chunk);
size += chunk.length;
if (size > MAX_FILE_SIZE) throw new Error(`DOS file exceeds ${MAX_FILE_SIZE} byte tool limit`);
offset += chunk.length;
if (match[1] === "1") return Buffer.concat(chunks, size);
if (chunk.length === 0) throw new Error("DOS agent returned an empty non-final chunk");
}
}
async function writeFile(path: string, content: Buffer, signal?: AbortSignal): Promise<void> {
const dosPath = normalizePath(path);
if (content.length > MAX_FILE_SIZE) throw new Error(`Content exceeds ${MAX_FILE_SIZE} byte tool limit`);
if (content.length === 0) {
await request(`WRITE ${hexEncode(dosPath)} T `, signal);
return;
}
for (let offset = 0; offset < content.length; offset += CHUNK_SIZE) {
const chunk = content.subarray(offset, offset + CHUNK_SIZE);
await request(`WRITE ${hexEncode(dosPath)} ${offset === 0 ? "T" : "A"} ${hexEncode(chunk)}`, signal);
}
}
function textResult(text: string) {
return { content: [{ type: "text" as const, text }], details: {} };
}
export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "dos_read",
label: "DOS Read",
description: "Read a text file from the running FreeDOS VM over COM1.",
promptSnippet: "Read files inside the running FreeDOS VM",
parameters: Type.Object({ path: Type.String({ description: "Absolute DOS path such as C:\\SRC\\MAIN.C" }) }),
async execute(_id, params, signal) {
const content = await readFile(params.path, signal);
return textResult(content.toString("utf8"));
},
});
pi.registerTool({
name: "dos_write",
label: "DOS Write",
description: "Create or replace a text file in the running FreeDOS VM over COM1.",
promptSnippet: "Write files inside the running FreeDOS VM",
parameters: Type.Object({
path: Type.String({ description: "Absolute DOS path" }),
content: Type.String({ description: "Complete UTF-8 text content" }),
}),
async execute(_id, params, signal) {
const content = Buffer.from(params.content, "utf8");
await writeFile(params.path, content, signal);
return textResult(`Wrote ${content.length} bytes to ${normalizePath(params.path)}`);
},
});
pi.registerTool({
name: "dos_edit",
label: "DOS Edit",
description: "Replace one uniquely matching text fragment in a file in the running FreeDOS VM.",
promptSnippet: "Make exact text replacements inside FreeDOS files",
parameters: Type.Object({
path: Type.String({ description: "Absolute DOS path" }),
old_text: Type.String({ description: "Text that must occur exactly once" }),
new_text: Type.String({ description: "Replacement text" }),
}),
async execute(_id, params, signal) {
const original = (await readFile(params.path, signal)).toString("utf8");
const occurrences = original.split(params.old_text).length - 1;
if (occurrences !== 1) throw new Error(`Expected old_text exactly once, found ${occurrences} occurrences`);
const updated = original.replace(params.old_text, params.new_text);
await writeFile(params.path, Buffer.from(updated, "utf8"), signal);
return textResult(`Edited ${normalizePath(params.path)}`);
},
});
pi.registerTool({
name: "dos_list",
label: "DOS List",
description: "List a directory in the running FreeDOS VM over COM1.",
promptSnippet: "List directories inside the running FreeDOS VM",
parameters: Type.Object({ path: Type.String({ description: "Absolute DOS directory path" }) }),
async execute(_id, params, signal) {
const response = await request(`LIST ${hexEncode(normalizePath(params.path))}`, signal);
return textResult(hexDecode(response).toString("utf8"));
},
});
pi.registerTool({
name: "dos_exec",
label: "DOS Exec",
description: "Execute a command in the running FreeDOS VM and return its output.",
promptSnippet: "Run commands inside the running FreeDOS VM",
parameters: Type.Object({
command: Type.String({ description: "FreeDOS command line" }),
timeout_seconds: Type.Optional(Type.Integer({ minimum: 1, maximum: 120, description: "Abort after this many seconds (default: 30)" })),
}),
async execute(_id, params, signal) {
if (params.command.length > 600) throw new Error("DOS command exceeds 600 characters");
const timeoutMs = (params.timeout_seconds ?? 30) * 1_000;
let response: string;
try {
response = await request(`EXEC ${hexEncode(params.command)}`, signal, timeoutMs);
} catch (error) {
if (signal?.aborted) throw error;
await interruptForegroundCommand();
const recovered = await waitForAgentRecovery();
throw new Error(`DOS command timed out after ${timeoutMs / 1_000}s and was interrupted with Ctrl+C${recovered ? "; agent recovered" : "; agent did not recover"}`);
}
const match = /^(-?\d+)(?:\s(.*))?$/.exec(response);
if (!match) throw new Error(`Malformed EXEC response: ${response}`);
const output = hexDecode(match[2] ?? "").toString("utf8");
return textResult(`${output}${output.endsWith("\n") || !output ? "" : "\n"}[exit ${match[1]}]`);
},
});
pi.registerTool({
name: "dos_abort",
label: "DOS Abort",
description: "Send Ctrl+C to interrupt the foreground DOS command without rebooting the VM.",
promptSnippet: "Interrupt a hung DOS command without rebooting the VM",
parameters: Type.Object({}),
async execute() {
await interruptForegroundCommand();
const recovered = await waitForAgentRecovery();
return textResult(recovered ? "Sent Ctrl+C; DOS agent recovered." : "Sent Ctrl+C; DOS agent has not responded yet.");
},
});
}
+28
View File
@@ -0,0 +1,28 @@
param()
$ErrorActionPreference = 'Stop'
$qemu = Get-Command qemu-system-i386.exe -ErrorAction Stop
$disk = Join-Path $PSScriptRoot 'freedos.qcow2'
$iso = Join-Path $PSScriptRoot 'FD14LIVE.iso'
if (-not (Test-Path -LiteralPath $disk)) {
throw "Missing $disk. Create it first with: qemu-img create -f qcow2 .qemu\\freedos.qcow2 2G"
}
if (-not (Test-Path -LiteralPath $iso)) {
throw "Missing $iso. Run .qemu\\setup.ps1 first."
}
if (Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue) {
throw 'QEMU monitor port 4444 is already in use. Stop the existing VM first.'
}
& $qemu.Source `
-machine pc,usb=on `
-cpu pentium3 `
-m 64 `
-drive "file=$disk,format=qcow2,if=ide,index=0,media=disk" `
-drive "file=$iso,media=cdrom,readonly=on" `
-nic user,model=ne2k_isa `
-monitor "tcp:127.0.0.1:4444,server=on,wait=off" `
-serial null `
-boot order=d `
-display default
+36
View File
@@ -0,0 +1,36 @@
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$Command
)
$ErrorActionPreference = 'Stop'
$client = [System.Net.Sockets.TcpClient]::new()
try {
$client.Connect('127.0.0.1', 4444)
$stream = $client.GetStream()
$stream.ReadTimeout = 1000
$writer = [System.IO.StreamWriter]::new($stream, [System.Text.Encoding]::ASCII, 1024, $true)
$writer.NewLine = "`n"
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 100
while ($stream.DataAvailable) {
$buffer = New-Object byte[] 4096
[void]$stream.Read($buffer, 0, $buffer.Length)
}
$writer.WriteLine($Command)
Start-Sleep -Milliseconds 200
$result = New-Object System.Text.StringBuilder
while ($stream.DataAvailable) {
$buffer = New-Object byte[] 4096
$count = $stream.Read($buffer, 0, $buffer.Length)
[void]$result.Append([System.Text.Encoding]::ASCII.GetString($buffer, 0, $count))
}
$result.ToString().Trim()
}
finally {
$client.Dispose()
}
+144
View File
@@ -0,0 +1,144 @@
###############################################################################
FreeDOS 1.4 ("FreeDOS 1.4")
###############################################################################
-------------------------------------------------------------------------------
General system requirements:
-------------------------------------------------------------------------------
* DOS-compatible system (Intel + BIOS, or UEFI with Legacy support)
* At least 20MB free disk space:
20MB Plain DOS system
30MB Plain DOS system, with sources
275MB Full installation including applications and games
450MB Full installation with sources
-------------------------------------------------------------------------------
What's in all those zip files?
-------------------------------------------------------------------------------
FD14-LiveCD.zip
* FD14BOOT.IMG - Basic FreeDOS installation boot floppy image.
If your computer has a CD-ROM drive, but you cannot boot from the Live CD
or Legacy CD. Use this diskette image to boot the system. Then insert the
install CD. The FreeDOS installer should do the rest. This diskette
image is for installation purposes only and does not provide a Live
Environment.
* FD14LIVE.ISO - The FreeDOS 1.4 installer. Most users should
use this image to install FreeDOS.
Depending on your computer system and hardware configuration, you
can also use the LiveCD to boot and run FreeDOS directly from the
CD-ROM without installation to your hard drive.
FD14-LegacyCD.zip
* FD14BOOT.IMG - This zip archive also contains a copy of the basic
CD-ROM installation boot floppy.
* FD14LGCY.ISO - A bootable CD image designed for older hardware. If
you cannot boot the LiveCD to install FreeDOS, try this disc image.
This disc image uses the older El Torito boot CD format. Some newer
computers and virtual machines cannot use this older format. Unless
you have a computer that requires this type of bootable CD, we
recommend using the LiveCD instead.
FD14-BonusCD.zip
* FD14BNS.ISO - A non-bootable CD image that contains some FreeDOS
packages that are not installed as part of either the LiveCD or
the Legacy CD.
FD14-LiteUSB.zip
* FD14LITE.IMG - A minimal FreeDOS installer, as a USB fob drive
image. This does not contain all of the packages from either the
LiveCD or the LegacyCD, and instead only contains a basic set of
FreeDOS packages.
* FD14LITE.VMDK - A virtual machine disk file, compatible with a
variety of virtual machine software including VirtualBox, VMware,
and other systems.
Using a VMDK file can simplify installing FreeDOS. Just attach the
VMDK image to your virtual machine software as a hard drive, and
boot it. (Please note that you will still need to create a virtual
hard disk to install FreeDOS)
FD14-FullUSB.zip
* FD14FULL.IMG - Plain DOS system and Full install USB stick image.
* FD14FULL.VMDK - A virtual machine disk file, compatible with a
variety of virtual machine software. Just attach the VMDK image to
your virtual machine as a hard drive, and boot it.
VERIFY.TXT
* Contains MD5, SHA256 and SHA512 hashes for all of the different
release files. You can verify your copy of FreeDOS with these.
README.TXT
* The "before you choose and install" document. (All of the zip
files listed above also have a copy of the README file.)
-------------------------------------------------------------------------------
FreeDOS Floppy-Only Edition (FD14-x86)
-------------------------------------------------------------------------------
FreeDOS 1.4 includes a Floppy-Only Edition! This edition should run on
any hardware that can run FreeDOS and has EGA or better graphics:
* Are you running a '286 or another classic system without a CD-ROM
drive? Install from these floppies to install FreeDOS.
* Do you have just one hard drive and no CD or floppy drive? Just
copy the contents of the floppies to a temporary directory and run
the installer from there.
* Want to perform a "headless" install to a different DOS directory?
It's easy with the command line options.
The Floppy-Only Edition uses a completely different installer than
the CD-ROM or USB installers. The Floppy-Only Edition does not use
any of those other media to install.
The Floppy-Only Edition contains a limited set of FreeDOS programs
that are more useful on classic PC hardware.
The FreeDOS Floppy-Only Edition is distributed as single zip archive that
contains several pre-made floppy diskette images:
These zip archives contain image files for several common floppy
diskette media under separate directories:
* 720k - 3.5" 720k diskette images
* 144m - 3.5" 1.44mb diskette images
* 120m - 5.25" 1.2mb diskette images
Each of those sets contain a number of pre-made disk images:
* x86BOOT.img - A floppy boot disk image with the x86 installer.
* x86DSK??.img - Several floppy diskette images that contain the
core FreeDOS operating system files. The number of floppy images
and amount of files on each varies depending on the diskette
capacity.
To conserve space, the FreeDOS Floppy-Only Edition does not contain
the source code for the FreeDOS packages. You can find the source code
via the FreeDOS website (https://www.freedos.org/download/) or from
the other release media, like the USB or CD-ROM installer.
+44
View File
@@ -0,0 +1,44 @@
param()
$ErrorActionPreference = 'Stop'
$qemu = Get-Command qemu-system-i386.exe -ErrorAction Stop
$node = Get-Command node.exe -ErrorAction Stop
$disk = Join-Path $PSScriptRoot 'freedos.qcow2'
$share = Join-Path $PSScriptRoot 'share'
$relay = Join-Path $PSScriptRoot 'serial-relay.mjs'
# QEMU's FAT-directory drive is a convenient exchange volume, but it is not a
# reliable live-sync mechanism. Restart QEMU after host-side source changes.
New-Item -ItemType Directory -Force -Path $share | Out-Null
if (-not (Test-Path -LiteralPath $disk)) {
throw "Missing $disk. Run .qemu\\setup.ps1, then .qemu\\install.ps1."
}
$relayListening = Get-NetTCPConnection -State Listen -LocalPort 5556 -ErrorAction SilentlyContinue
if (-not $relayListening) {
Start-Process -FilePath $node.Source -ArgumentList @($relay) -WorkingDirectory $PSScriptRoot -WindowStyle Hidden
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
Start-Sleep -Milliseconds 100
$relayListening = Get-NetTCPConnection -State Listen -LocalPort 5556 -ErrorAction SilentlyContinue
} while (-not $relayListening -and [DateTime]::UtcNow -lt $deadline)
if (-not $relayListening) { throw 'Serial relay did not start.' }
}
$monitorListening = Get-NetTCPConnection -State Listen -LocalPort 4444 -ErrorAction SilentlyContinue
if ($monitorListening) {
throw 'QEMU monitor port 4444 is already in use. Stop the existing VM before starting another one.'
}
& $qemu.Source `
-machine pc,accel=whpx,kernel-irqchip=off,usb=on `
-smp 1 `
-m 64 `
-drive "file=$disk,format=qcow2,if=ide,index=0,media=disk" `
-hdb "fat:rw:$share" `
-nic user,model=ne2k_isa `
-monitor "tcp:127.0.0.1:4444,server=on,wait=off" `
-serial "tcp:127.0.0.1:5556" `
-boot order=c `
-display default
+26
View File
@@ -0,0 +1,26 @@
param(
[string]$Name = 'qemu-screen',
[switch]$KeepPpm
)
$ErrorActionPreference = 'Stop'
$ffmpeg = Get-Command ffmpeg.exe -ErrorAction Stop
$target = if ([IO.Path]::IsPathRooted($Name)) { $Name } else { Join-Path $PSScriptRoot $Name }
$extension = [IO.Path]::GetExtension($target)
if ($extension -in @('.png', '.ppm')) {
$target = [IO.Path]::Combine([IO.Path]::GetDirectoryName($target), [IO.Path]::GetFileNameWithoutExtension($target))
}
$directory = [IO.Path]::GetDirectoryName($target)
if ($directory) { New-Item -ItemType Directory -Force -Path $directory | Out-Null }
$ppm = "$target.ppm"
$png = "$target.png"
$qemuPath = $ppm.Replace('\', '/')
& (Join-Path $PSScriptRoot 'monitor.ps1') "screendump $qemuPath" | Out-Null
if (-not (Test-Path -LiteralPath $ppm)) { throw 'QEMU did not create the requested screenshot.' }
& $ffmpeg.Source -y -loglevel error -i $ppm $png
if ($LASTEXITCODE -ne 0) { throw "ffmpeg conversion failed with exit code $LASTEXITCODE." }
if (-not $KeepPpm) { Remove-Item -LiteralPath $ppm -Force }
Get-Item -LiteralPath $png
+60
View File
@@ -0,0 +1,60 @@
param(
[Parameter(Mandatory)]
[string] $Text,
[ValidateRange(0, 1000)]
[int] $DelayMilliseconds = 25,
[switch] $NoEnter
)
$ErrorActionPreference = 'Stop'
$map = @{
' ' = 'spc'; ':' = 'shift-semicolon'; ';' = 'semicolon'
'\' = 'backslash'; '|' = 'shift-backslash'; '/' = 'slash'; '?' = 'shift-slash'
'.' = 'dot'; '>' = 'shift-dot'; ',' = 'comma'; '<' = 'shift-comma'
'-' = 'minus'; '_' = 'shift-minus'; '=' = 'equal'; '+' = 'shift-equal'
'[' = 'bracket_left'; '{' = 'shift-bracket_left'
']' = 'bracket_right'; '}' = 'shift-bracket_right'
"'" = 'apostrophe'; '"' = 'shift-apostrophe'; '`' = 'grave_accent'; '~' = 'shift-grave_accent'
'!' = 'shift-1'; '@' = 'shift-2'; '#' = 'shift-3'; '$' = 'shift-4'; '%' = 'shift-5'
'^' = 'shift-6'; '&' = 'shift-7'; '*' = 'shift-8'; '(' = 'shift-9'; ')' = 'shift-0'
}
function ConvertTo-QemuKey([char] $Character) {
$text = [string] $Character
if ($map.ContainsKey($text)) { return $map[$text] }
if ([char]::IsLetter($Character)) {
$letter = [char]::ToLowerInvariant($Character)
if ([char]::IsUpper($Character)) { return "shift-$letter" }
return [string] $letter
}
if ([char]::IsDigit($Character)) { return $text }
throw "Unsupported QEMU key character: '$Character'"
}
$client = [System.Net.Sockets.TcpClient]::new([System.Net.Sockets.AddressFamily]::InterNetwork)
try {
$client.Connect([System.Net.IPAddress]::Parse('127.0.0.1'), 4444)
$stream = $client.GetStream()
$writer = [System.IO.StreamWriter]::new($stream, [System.Text.Encoding]::ASCII, 1024, $true)
try {
$writer.NewLine = "`r`n"
$writer.AutoFlush = $true
Start-Sleep -Milliseconds 100
foreach ($char in $Text.ToCharArray()) {
$writer.WriteLine("sendkey $(ConvertTo-QemuKey $char)")
Start-Sleep -Milliseconds $DelayMilliseconds
}
if (-not $NoEnter) { $writer.WriteLine('sendkey ret') }
Start-Sleep -Milliseconds $DelayMilliseconds
}
finally {
$writer.Dispose()
}
}
finally {
$client.Dispose()
}
+110
View File
@@ -0,0 +1,110 @@
import net from "node:net";
const HOST = "127.0.0.1";
const QEMU_PORT = 5556;
const TOOL_PORT = 5555;
const OBSERVER_PORT = 5557;
let qemu = null;
let controller = null;
const observers = new Set();
function safeWrite(socket, data) {
if (socket && !socket.destroyed && socket.writable) socket.write(data);
}
function decodeHex(hex) {
if (!hex || hex.length % 2 || !/^[0-9a-f]+$/i.test(hex)) return null;
return Buffer.from(hex, "hex").toString("utf8");
}
function visible(text) {
const shortened = text.length > 400 ? `${text.slice(0, 400)}` : text;
return shortened
.replace(/\r\n?/g, "\n")
.replace(/[^\x20-\x7E\n]/g, (char) => `[0x${char.charCodeAt(0).toString(16).padStart(2, "0")}]`)
.replace(/\n/g, "\r\n ");
}
function formatLine(source, line) {
if (!line) return null;
if (line === "PING" || line === "QUIT" || line === "DOSAGENT READY") return `${source} ${line}`;
const [command, ...args] = line.split(" ");
if (["READ", "LIST"].includes(command) && args[0]) {
return `${source} ${command} ${visible(decodeHex(args[0]) ?? args[0])}${args[1] ? ` @${args[1]}` : ""}`;
}
if (command === "WRITE" && args.length >= 3) {
const path = decodeHex(args[0]) ?? args[0];
const body = decodeHex(args.slice(2).join(" ")) ?? args.slice(2).join(" ");
return `${source} WRITE ${visible(path)} (${args[1] === "T" ? "replace" : "append"}) ${visible(body)}`;
}
if (command === "EXEC" && args[0]) return `${source} EXEC ${visible(decodeHex(args.join(" ")) ?? args.join(" "))}`;
if (command === "ERR" && args[0]) return `${source} ERROR ${visible(decodeHex(args.join(" ")) ?? args.join(" "))}`;
if (command === "OK") {
const singlePayload = args.length === 1 ? decodeHex(args[0]) : null;
const payload = singlePayload ?? (args.length > 1 ? decodeHex(args.slice(1).join(" ")) : null);
const status = singlePayload !== null ? "" : (args[0] ? ` (${args[0]})` : "");
return `${source} OK${status}${payload !== null ? ` ${visible(payload)}` : ""}`;
}
return `${source} ${line}`;
}
const traceBuffers = new Map();
function trace(source, data) {
const pending = (traceBuffers.get(source) ?? "") + data.toString("ascii");
const parts = pending.split(/\r?\n/);
traceBuffers.set(source, parts.pop());
for (const line of parts) {
const formatted = formatLine(source, line);
if (formatted) for (const observer of observers) safeWrite(observer, `${formatted}\r\n`);
}
}
const qemuServer = net.createServer((socket) => {
if (qemu && !qemu.destroyed) {
socket.end();
return;
}
qemu = socket;
socket.setNoDelay(true);
socket.on("data", (data) => {
safeWrite(controller, data);
trace("DOS -> Pi", data);
});
socket.on("close", () => {
if (qemu === socket) qemu = null;
if (controller && !controller.destroyed) controller.destroy();
});
socket.on("error", () => {});
});
const toolServer = net.createServer((socket) => {
if (controller && !controller.destroyed) {
socket.end("ERR 53455249414C20434F4E54524F4C4C45522042555359\r\n");
return;
}
controller = socket;
socket.setNoDelay(true);
socket.on("data", (data) => {
safeWrite(qemu, data);
trace("Pi -> DOS", data);
});
socket.on("close", () => {
if (controller === socket) controller = null;
});
socket.on("error", () => {});
});
const observerServer = net.createServer((socket) => {
observers.add(socket);
socket.setNoDelay(true);
socket.write("DOS serial observer - decoded protocol (read-only)\r\n");
socket.on("data", () => {});
socket.on("close", () => observers.delete(socket));
socket.on("error", () => observers.delete(socket));
});
qemuServer.listen(QEMU_PORT, HOST);
toolServer.listen(TOOL_PORT, HOST);
observerServer.listen(OBSERVER_PORT, HOST);
+26
View File
@@ -0,0 +1,26 @@
param()
$ErrorActionPreference = 'Stop'
$qemuImg = Get-Command qemu-img.exe -ErrorAction Stop
$disk = Join-Path $PSScriptRoot 'freedos.qcow2'
$archive = Join-Path $PSScriptRoot 'FD14-LiveCD.zip'
$iso = Join-Path $PSScriptRoot 'FD14LIVE.iso'
$url = 'https://www.ibiblio.org/pub/micro/pc-stuff/freedos/files/distributions/1.4/FD14-LiveCD.zip'
if (-not (Test-Path -LiteralPath $disk)) {
& $qemuImg.Source create -f qcow2 $disk 2G
if ($LASTEXITCODE -ne 0) { throw "qemu-img failed with exit code $LASTEXITCODE." }
}
if (-not (Test-Path -LiteralPath $iso)) {
if (-not (Test-Path -LiteralPath $archive)) {
Invoke-WebRequest -Uri $url -OutFile $archive
}
Expand-Archive -LiteralPath $archive -DestinationPath $PSScriptRoot -Force
}
if (-not (Test-Path -LiteralPath $iso)) {
throw 'FreeDOS ISO extraction failed.'
}
Get-Item -LiteralPath $disk,$iso | Select-Object Name,Length,LastWriteTime
+240
View File
@@ -0,0 +1,240 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dos.h>
#include <conio.h>
#include <bios.h>
#define LINE_SIZE 9000
#define CHUNK_SIZE 4096
static char linebuf[LINE_SIZE];
static unsigned char data[CHUNK_SIZE + 1];
#define COM1_BASE 0x3F8
static void serial_init(void) {
outp(COM1_BASE + 1, 0x00);
outp(COM1_BASE + 3, 0x80);
outp(COM1_BASE + 0, 12);
outp(COM1_BASE + 1, 0);
outp(COM1_BASE + 3, 0x03);
outp(COM1_BASE + 2, 0xC7);
outp(COM1_BASE + 4, 0x0B);
}
static int console_break_requested(void) {
unsigned short key = _bios_keybrd(_KEYBRD_READY);
if ((key & 0xFF) != 3) return 0;
_bios_keybrd(_KEYBRD_READ);
return 1;
}
static int serial_getc(void) {
while ((inp(COM1_BASE + 5) & 0x01) == 0) {
if (console_break_requested()) return -1;
}
return inp(COM1_BASE);
}
static void serial_putc(int c) {
while ((inp(COM1_BASE + 5) & 0x20) == 0) { }
outp(COM1_BASE, c);
}
static void serial_write(const char *text) {
while (*text) serial_putc((unsigned char)*text++);
}
static int serial_readline(char *buffer, int cap) {
int used = 0;
int c;
for (;;) {
c = serial_getc();
if (c < 0) return -1;
if (c == '\r' || c == '\n') {
if (used == 0) continue;
buffer[used] = '\0';
return used;
}
if (used < cap - 1) buffer[used++] = (char)c;
}
}
static int hexval(int c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
static int decode_hex(const char *src, unsigned char *dst, int cap) {
int n = 0;
int hi, lo;
while (*src && src[1]) {
if (n >= cap) return -1;
hi = hexval((unsigned char)src[0]);
lo = hexval((unsigned char)src[1]);
if (hi < 0 || lo < 0) return -1;
dst[n++] = (unsigned char)((hi << 4) | lo);
src += 2;
}
return *src ? -1 : n;
}
static void print_hex(const unsigned char *src, unsigned count) {
static const char digits[] = "0123456789ABCDEF";
unsigned i;
for (i = 0; i < count; ++i) {
serial_putc(digits[src[i] >> 4]);
serial_putc(digits[src[i] & 15]);
}
}
static void ok_data(const unsigned char *src, unsigned count) {
serial_write("OK ");
print_hex(src, count);
serial_write("\r\n");
}
static void error_text(const char *message) {
serial_write("ERR ");
print_hex((const unsigned char *)message, strlen(message));
serial_write("\r\n");
}
static int decode_path(const char *hex, char *path, int cap) {
int n = decode_hex(hex, (unsigned char *)path, cap - 1);
if (n < 0) return 0;
path[n] = '\0';
return 1;
}
static void command_read(char *args) {
char path[260];
char *offset_text = strchr(args, ' ');
FILE *file;
long offset;
size_t count;
int eof;
if (!offset_text) { error_text("READ requires path and offset"); return; }
*offset_text++ = '\0';
if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; }
offset = atol(offset_text);
file = fopen(path, "rb");
if (!file) { error_text("Cannot open file"); return; }
if (fseek(file, offset, SEEK_SET) != 0) { fclose(file); error_text("Cannot seek file"); return; }
count = fread(data, 1, CHUNK_SIZE, file);
eof = feof(file) ? 1 : 0;
if (count < CHUNK_SIZE) eof = 1;
fclose(file);
sprintf(linebuf, "OK %d ", eof);
serial_write(linebuf);
print_hex(data, count);
serial_write("\r\n");
}
static void command_write(char *args) {
char path[260];
char *mode_text = strchr(args, ' ');
char *payload;
FILE *file;
int count;
if (!mode_text) { error_text("WRITE requires path, mode and data"); return; }
*mode_text++ = '\0';
payload = strchr(mode_text, ' ');
if (!payload) { error_text("WRITE requires data"); return; }
*payload++ = '\0';
if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; }
count = decode_hex(payload, data, CHUNK_SIZE);
if (count < 0) { error_text("Invalid data encoding"); return; }
file = fopen(path, mode_text[0] == 'A' ? "ab" : "wb");
if (!file) { error_text("Cannot write file"); return; }
if (count && fwrite(data, 1, count, file) != (size_t)count) {
fclose(file); error_text("Short write"); return;
}
fclose(file);
ok_data((const unsigned char *)"", 0);
}
static void command_list(char *args) {
char path[260];
char pattern[300];
char output[CHUNK_SIZE];
struct find_t found;
unsigned used = 0;
int rc;
if (!decode_path(args, path, sizeof(path))) { error_text("Invalid path encoding"); return; }
strcpy(pattern, path);
if (pattern[0] && pattern[strlen(pattern) - 1] != '\\' && pattern[strlen(pattern) - 1] != '/') strcat(pattern, "\\");
strcat(pattern, "*.*");
rc = _dos_findfirst(pattern, _A_NORMAL | _A_RDONLY | _A_HIDDEN | _A_SYSTEM | _A_SUBDIR | _A_ARCH, &found);
while (rc == 0) {
char entry[100];
int len;
if (strcmp(found.name, ".") && strcmp(found.name, "..")) {
sprintf(entry, "%s\t%lu\t%s\n", found.name, found.size, (found.attrib & _A_SUBDIR) ? "DIR" : "FILE");
len = strlen(entry);
if (used + len >= sizeof(output)) break;
memcpy(output + used, entry, len);
used += len;
}
rc = _dos_findnext(&found);
}
ok_data((const unsigned char *)output, used);
}
static void command_exec(char *args) {
char command[700];
char shell_command[800];
const char *temp_path = "C:\\PIEXEC.TMP";
FILE *file;
int n, exit_code;
size_t count;
n = decode_hex(args, (unsigned char *)command, sizeof(command) - 1);
if (n < 0) { error_text("Invalid command encoding"); return; }
command[n] = '\0';
sprintf(shell_command, "COMMAND.COM /C %s > %s", command, temp_path);
exit_code = system(shell_command);
file = fopen(temp_path, "rb");
count = file ? fread(data, 1, CHUNK_SIZE, file) : 0;
if (file) fclose(file);
remove(temp_path);
sprintf(linebuf, "OK %d ", exit_code);
serial_write(linebuf);
print_hex(data, count);
serial_write("\r\n");
}
int main(void) {
char *command;
char *args;
serial_init();
cprintf("\r\n[DOSAGENT] Ready on COM1. Press Ctrl+C here to stop.\r\n");
serial_write("DOSAGENT READY\r\n");
for (;;) {
if (serial_readline(linebuf, sizeof(linebuf)) < 0) {
cprintf("\r\n[DOSAGENT] Stopped from VGA console.\r\n");
break;
}
command = linebuf;
args = strchr(command, ' ');
if (args) *args++ = '\0'; else args = command + strlen(command);
cprintf("[DOSAGENT] %s\r\n", command);
if (!strcmp(command, "PING")) ok_data((const unsigned char *)"PONG", 4);
else if (!strcmp(command, "READ")) command_read(args);
else if (!strcmp(command, "WRITE")) command_write(args);
else if (!strcmp(command, "LIST")) command_list(args);
else if (!strcmp(command, "EXEC")) command_exec(args);
else if (!strcmp(command, "QUIT")) { ok_data((const unsigned char *)"BYE", 3); break; }
else error_text("Unknown command");
}
return 0;
}
+623
View File
@@ -0,0 +1,623 @@
# Ferro 언어 명세 v0.1
DOS용 시스템 프로그래밍 언어. C만큼 빠르고, 메모리 안전성을 함수 단위 지역 검사만으로 보장한다.
파일 확장자 `.fe`, 컴파일러 이름 `fec`, 심볼 파일 `.fei`.
이 문서는 언어 명세 + 컴파일러 구현 지시서를 겸한다. 구현 중 애매한 부분은 §1 철학과 §5 소유권 규칙을 기준으로 결정한다.
---
## 1. 설계 철학
1. **안전은 기본, 위험은 명시.** 기본 코드는 메모리 안전(널 역참조, 버퍼 오버런, use-after-free, 이중 해제 불가). 위험한 연산은 `unsafe {}` 블록 안에서만.
2. **전역 분석 금지.** 모든 검사(타입, 소유권, 참조)는 함수 하나만 보고 완결되어야 한다. 이 제약이 라이프타임 표기를 없애고, 640KB 머신에서 셀프호스팅을 가능하게 한다.
3. **숨은 비용 없음.** 힙 할당, 복사, 소멸자 호출, 형변환이 전부 소스에 보인다. GC 없음, 예외 없음, 암묵 변환 없음.
4. **읽히는 문법.** `이름: 타입` 순서, 좌→우 파싱, LL(1) 재귀하강으로 처리 가능.
5. **작게 시작.** 기능을 넣기 전에 뺄 이유를 먼저 찾는다. 뺀 것과 그 대체 수단은 §13에 기록한다.
6. **기존 도구체인 재사용.** 링커, `.OBJ`/`.LIB`/`.EXE` 포맷, DPMI 익스텐더를 새로 만들지 않는다.
---
## 2. 타깃
| | `bits16` | `bits32` |
|---|---|---|
| CPU/모드 | 8086 리얼모드 | 386 보호모드 플랫 (DPMI) |
| `usize`/`isize` | 16비트 | 32비트 |
| 포인터 크기 | near 2B / far 4B | 4B (far 없음) |
| 메모리 모델 | small, large | flat |
| 시스템 호출 | INT 21h 직접 | DPMI 서비스 + 실모드 콜백 |
- CLI: `fec main.fe --target=bits16|bits32 [--model=small|large]`
- 소스 분기: `comptime if @bits == 16 { ... } else { ... }`
- `bits32`에서 `far` 키워드를 쓰면 컴파일 에러.
- 표준 라이브러리는 코어 공용, `sys` 유닛만 타깃별 구현.
---
## 3. 어휘 구조
- 식별자: `[A-Za-z_][A-Za-z0-9_]*`. 대소문자 구분.
- 주석: `//` 줄 끝까지, `/* */` **중첩 허용**.
- 정수 리터럴: `123`, `0xFF`, `0b1010`, `0o17`, 자릿수 구분 `1_000_000`.
- 문자 리터럴: `'a'`, `'\n'`, `'\x41'` → 타입 `char`.
- 문자열 리터럴: `"abc"` → 타입 `str`. NUL 종료 아님. 이스케이프는 문자 리터럴과 동일. 인접 리터럴 자동 연결 없음.
- 불린: `true`, `false`. 옵셔널 널: `null`.
- 세미콜론 필수. 블록 중괄호 필수(단문 `if``{}` 필요).
**예약어:**
```
unit import pub fn struct enum error const static var let mut
if else while for in match return break continue defer
unsafe comptime asm try catch as extern interrupt far
true false null self Self type
```
---
## 4. 타입 시스템
### 4.1 기본 타입
- 정수: `i8 i16 i32 u8 u16 u32 usize isize`
- `bool` (1바이트, 정수와 상호 변환 없음)
- `char` (u8과 크기 같지만 별개 타입)
- `void` (반환 타입으로만)
- `type` (comptime 파라미터에서만, §9)
**정수 규칙:**
- 서로 다른 정수 타입 간 암묵 변환 없음. `as`로 명시.
- `as`는 절단/부호확장을 수행하며 값 손실을 검사하지 않는다.
- `+ - * / %`는 검사 빌드에서 오버플로 시 트랩. `+% -% *%`는 랩어라운드(항상 무검사).
- `/`, `%`의 0 나눗셈은 항상 트랩(검사 빌드 여부 무관, CPU가 트랩함).
- 시프트 `<< >>`: 우변은 `u8`. 시프트 양이 비트폭 이상이면 검사 빌드에서 트랩.
- 비트 연산 `& | ^ ~`는 같은 타입끼리만.
### 4.2 복합 타입
| 문법 | 의미 | 표현 |
|---|---|---|
| `[N]T` | 배열, 값 타입, N은 컴파일타임 상수 | `N * sizeof(T)` |
| `[]T` | 슬라이스 (참조성, §5 R4 적용) | `(ptr, len)` |
| `str` | `[]u8` 불변 별칭 | `(ptr, len)` |
| `^T` | 소유 포인터 (힙, 단일 소유자) | 포인터 |
| `&T` | 공유 참조 | 포인터 |
| `&mut T` | 배타 참조 | 포인터 |
| `*T` | raw 포인터 (`unsafe`에서만 역참조) | 포인터 |
| `far ^T`, `far *T`, `far &T` | far 포인터 (`bits16` 전용) | 4바이트 |
| `?T` | 옵셔널 | 널 표현 가능 타입은 크기 동일, 아니면 `(bool, T)` |
| `E!T` / `!T` | 에러 유니온 (`!T`는 기본 에러 집합) | `(u16 err, T val)` |
| `fn(A, B) -> R` | 함수 포인터 | 포인터 |
- **배열은 포인터로 붕괴하지 않는다.** 함수에 넘기려면 `arr[..]`로 슬라이스를 만들거나 `&arr` / `^[N]T`를 쓴다.
- 슬라이싱: `arr[..]`, `arr[a..b]`(반개구간, 경계 검사), `arr[a..]`, `slice[a..b]`.
- 소유 슬라이스가 필요하면 `^[]T`(길이 있는 힙 버퍼)를 쓴다. `mem.alloc_slice(T, n)`가 반환.
### 4.3 구조체
```fe
pub struct Point {
x: i32,
y: i32,
pub fn new(x: i32, y: i32) -> Point { return Point{ x: x, y: y }; }
pub fn len2(self: &Self) -> i32 { return self.x*self.x + self.y*self.y; }
pub fn shift(self: &mut Self, dx: i32) { self.x += dx; }
}
```
- 리터럴: `Point{ x: 1, y: 2 }`. 모든 필드 명시 필수(기본값 없음).
- 메서드는 struct 블록 안에 정의. 첫 파라미터가 `self: Self | &Self | &mut Self`면 메서드.
- `x.f(y)``Point.f(x, y)`의 설탕. 자동 참조 취함(`x.shift(1)``Point.shift(&mut x, 1)`).
- `Self`는 자기 타입의 별칭.
- 필드 레이아웃은 선언 순서. 정렬은 타깃 규칙(`bits16`은 1바이트 정렬, `bits32`는 자연 정렬). `packed struct`로 정렬 강제 해제.
- 소멸자: `fn drop(self: &mut Self)`를 정의하면 스코프 종료 시 자동 호출(§5 R3).
### 4.4 열거형 (태그드 유니온)
```fe
pub enum Shape {
Empty,
Circle(i32),
Rect{ w: i32, h: i32 },
}
```
- 표현: `struct { u8 tag; union {...} payload; }`. 배리언트 256개 초과 시 `u16` 태그.
- 페이로드 없는 배리언트만 있는 열거형은 정수처럼 취급되며 `as u8` 가능.
- 생성: `Shape.Circle(5)`, `Shape.Rect{ w: 3, h: 4 }`, `Shape.Empty`.
- 해체는 `match` 또는 `if let`으로만. 직접 필드 접근 불가.
### 4.5 옵셔널
```fe
var p: ?^Node = null;
if let Some(node) = p { node.value = 1; } // node: &mut Node (p가 mut일 때)
let v = p.?; // null이면 트랩
let v = p orelse default_node; // null이면 우변
```
- `?T`에서 T가 `^T`, `&T`, `*T`, `fn`이면 널 포인터를 널 표현으로 사용(크기 증가 없음).
- 검사 없이 역참조 불가. `p.^`는 컴파일 에러, `p.?.^`가 필요.
### 4.6 에러
```fe
pub error IoError {
NotFound = 1,
Denied = 2,
Eof = 3,
}
fn read_all(path: str) -> IoError!^[]u8 {
let f = try io.open(path); // 에러면 즉시 반환
defer f.close();
let n = f.size() catch |e| { return e; };
...
}
```
- `error` 선언은 `u16` 코드 집합. 코드 0은 "성공" 예약, 사용 불가.
- `E!T` 함수만 `try`/`catch` 사용 가능.
- `try e`: 에러면 현재 함수에서 즉시 반환(현재 함수도 에러 유니온을 반환해야 함).
- `e catch |x| { ... }`: 블록은 값을 만들거나 `return`/`break`로 탈출.
- `e catch default_value`: 짧은 형태.
- 서로 다른 error 타입 간 자동 변환 없음. `!T`(기본 에러 집합 `core.Error`)로 통일하거나 명시 매핑.
- 에러는 값이다. 언와인딩, 스택 추적, 소멸자 이외의 자동 정리 없음.
### 4.7 타입 동등성
이름 기반(nominal). 필드가 같아도 다른 이름이면 다른 타입. 별칭은 `const Alias = Type;`으로 만들며 완전 동일 취급.
---
## 5. 소유권과 참조 — 핵심 규칙
이 절이 언어의 핵심이다. 모든 규칙은 **함수 하나만 보고** 검사된다.
**R1 (단일 소유자).** 모든 값의 소유자는 정확히 하나. 변수 대입, 함수 인자 전달, 반환은 **이동(move)**이다. 이동된 변수는 이후 사용 시 컴파일 에러.
**R2 (Copy 타입).** 다음은 이동 대신 복사된다: 정수, `bool`, `char`, raw 포인터 `*T`, 참조 `&T`, 함수 포인터, 그리고 모든 필드가 Copy이면서 `drop`이 없는 struct/enum/배열. `^T``&mut T`는 Copy가 아니다.
**R3 (소멸자, RAII).** `^T`는 소유자 스코프 종료 또는 재대입 시 `drop` 호출 후 해제. struct에 `fn drop(self: &mut Self)`가 있으면 그 값의 스코프 종료 시 자동 호출되며, 이어서 필드들의 drop이 선언 역순으로 호출된다. `drop`을 직접 호출하는 것은 컴파일 에러(`mem.destroy(x)` 사용).
**R4 (참조는 2급 값).** `&T`, `&mut T`, `[]T`는 다음 위치에만 존재할 수 있다:
- 함수 파라미터
- 지역 변수 (`let`/`var`)
- 표현식 안의 임시값
다음은 **컴파일 에러**다:
- struct/enum 필드의 타입
- 배열/슬라이스의 원소 타입
- 함수 반환 타입 (예외: R8)
- `^T`, `*T`의 대상 타입
- 전역 변수의 타입
이 한 줄이 라이프타임 표기 전체를 불필요하게 만든다.
**R5 (참조 수명).** 지역 참조 변수는 대상보다 오래 살 수 없다. R4 덕분에 대상은 항상 같은 함수의 지역 변수, 파라미터, 또는 전역이므로 스코프 중첩 확인만으로 검사된다.
**R6 (배타성).** `&mut x`가 살아있는 동안 `x`에 대한 다른 참조 생성, 직접 읽기/쓰기, 이동이 금지된다. `&x`(공유)는 여러 개 동시 가능하지만 그동안 `x`에 쓰기/이동 금지. 참조의 생존 구간은 **참조 변수의 스코프 끝까지**(NLL 아님). 임시 참조(`f(&x)`)는 그 문장 끝까지.
**R7 (참조 무효화).** 참조 대상이 이동되거나 재대입되면 그 참조는 이후 사용 시 에러.
**R8 (반환값 예외).** 메서드는 `-> &T` / `-> &mut T`를 반환할 수 있다. 단, 첫 파라미터가 `&Self`/`&mut Self`이고 반환 참조는 그 self에서 파생된 것이어야 하며(컴파일러가 확인), **호출 결과는 그 문장 안에서만 사용 가능**하고 지역 변수에 바인딩할 수 없다.
```fe
list.at(0).x = 5; // OK
let r = list.at(0); // 에러: 반환 참조를 바인딩 불가
```
장수하는 접근이 필요하면 인덱스(`usize`)나 핸들을 쓴다.
**R9 (unsafe).** `unsafe {}` 안에서만 허용: raw 포인터 역참조, `*T``^T`/`&T` 변환, `@ptr_cast`, `@seg_ptr`, `@volatile_*`, `@port_*`, `asm`, `*_unchecked` 함수, 전역 `var` 접근. R1~R8은 `unsafe` 안에서도 유지되며, raw 포인터를 경유해야만 우회된다.
**R10 (전역).** `static`은 불변이며 컴파일타임 상수 초기화만 가능. `var` 전역은 허용되나 접근이 `unsafe`. 전역에 `^T``drop` 있는 타입 금지.
**R11 (그래프 구조).** 참조 순환이 필요한 자료구조는 아레나 + 인덱스 핸들로 표현한다. 표준 라이브러리 `mem.Arena``u16`/`u32` 인덱스를 쓴다.
---
## 6. 문법
### 6.1 EBNF
```
unit := 'unit' ident ';' import* decl*
import := 'import' ident ';'
decl := ['pub'] (fn_decl | struct_decl | enum_decl | error_decl
| const_decl | global_decl)
fn_decl := ['extern' string] ['interrupt'] 'fn' ident
'(' [param (',' param)*] ')' ['->' type] (block | ';')
param := ['comptime'] ident ':' type
struct_decl := ['packed'] 'struct' ident '{' field* fn_decl* '}'
field := ident ':' type ','
enum_decl := 'enum' ident '{' variant (',' variant)* [','] '}'
variant := ident | ident '(' type ')' | ident '{' field* '}'
error_decl := 'error' ident '{' (ident '=' int ',')* '}'
const_decl := 'const' ident [':' type] '=' expr ';'
global_decl := ('static' | 'var') ident ':' type '=' expr ';'
block := '{' stmt* '}'
stmt := 'let' ['mut'] ident [':' type] '=' expr ';'
| 'var' ident ':' type ['=' expr] ';'
| 'const' ident [':' type] '=' expr ';'
| lvalue ('=' | '+=' | '-=' | '*=' | '/=' | '%='
| '&=' | '|=' | '^=' | '<<=' | '>>=') expr ';'
| if_stmt | while_stmt | for_stmt | match_stmt
| 'return' [expr] ';' | 'break' ';' | 'continue' ';'
| 'defer' block
| 'unsafe' block
| 'comptime' 'if' expr block ['else' (block | 'if' ...)]
| 'asm' '{' asm_body '}'
| expr ';'
if_stmt := 'if' (expr | 'let' pattern '=' expr) block
['else' (block | if_stmt)]
while_stmt := 'while' expr block
for_stmt := 'for' ident [',' ident] 'in' expr block
match_stmt := 'match' expr '{' arm+ '}'
arm := pattern '=>' (expr ';' | block)
pattern := ident // 배리언트, 페이로드 없음
| ident '(' ident ')' // 튜플형 배리언트 바인딩
| ident '{' ident (',' ident)* '}' // 필드형 배리언트 바인딩
| 'Some' '(' ident ')' | 'None'
| int_literal | '_'
type := ident ['.' ident]
| '?' type | '!' type | ident '!' type
| '^' type | '&' ['mut'] type | '*' type
| 'far' ('^' | '*' | '&' ['mut']) type
| '[' expr ']' type | '[' ']' type
| 'fn' '(' [type (',' type)*] ')' ['->' type]
| ident '(' type (',' type)* ')' // 제네릭 인스턴스
```
### 6.2 표현식 우선순위 (낮음 → 높음)
```
1 orelse, catch
2 ||
3 &&
4 == != < <= > >=
5 | ^
6 &
7 << >>
8 + - +% -%
9 * / % *%
10 단항: - ! ~ & &mut ^(주소아님) try
11 후위: .field .? .^ [i] [a..b] (args) as T
12 기본: literal, ident, '(' expr ')', struct_literal, @builtin(...)
```
- `&&`, `||`는 단축 평가.
- `as`는 후위 우선순위(단항보다 강함): `-x as i32``-(x as i32)`.
- 비교 연산 체이닝 금지(`a < b < c`는 에러).
### 6.3 빌트인
```
@size_of(T) -> usize @align_of(T) -> usize
@bits -> comptime int @target -> comptime str
@ptr_cast(T, p) -> *T (unsafe)
@seg_ptr(seg: u16, off: u16) -> far *T (unsafe, bits16)
@port_in8(p) @port_in16(p) @port_out8(p,v) @port_out16(p,v) (unsafe)
@volatile_load(p) @volatile_store(p, v) (unsafe)
@trap() -> never @unreachable() -> never (unsafe)
@line() @file() // 진단용
```
### 6.4 예제
```fe
unit vga;
import sys;
const WIDTH: u16 = 320;
const HEIGHT: u16 = 200;
pub fn set_mode13() {
unsafe { asm { mov ax, 0x0013; int 0x10; } }
}
pub fn put_pixel(x: u16, y: u16, c: u8) {
if x >= WIDTH or y >= HEIGHT { return; }
unsafe {
let vram: far *u8 = @seg_ptr(0xA000, 0);
@volatile_store(vram + (y * WIDTH + x) as usize, c);
}
}
```
```fe
unit main;
import io;
import list;
import fmt;
fn count_lines(path: str) -> !usize {
let f = try io.open(path, io.Read);
defer f.close();
var buf: [256]u8 = undefined;
var n: usize = 0;
while true {
let got = try f.read(buf[..]);
if got == 0 { break; }
for c in buf[0..got] {
if c.^ == '\n' { n += 1; }
}
}
return n;
}
pub fn main() -> !void {
let n = count_lines("data.txt") catch |e| {
fmt.print_str("failed: ");
fmt.print_int(e as u16);
return e;
};
fmt.print_int(n);
}
```
---
## 7. 의미론 세부
### 7.1 변수와 초기화
- `let`은 불변, `let mut`은 가변, `var`는 타입 명시 필수인 가변 선언.
- 모든 변수는 사용 전 초기화 필수(정적 검사). 명시적 미초기화는 `= undefined`(unsafe 아님, 단 읽기 전 쓰기 필수는 여전히 검사).
- 섀도잉 허용(같은 스코프에서 `let` 재선언).
### 7.2 제어 흐름
- `for x in slice`: `x``&T`(가변 슬라이스면 `&mut T`). 값 접근은 `x.^`.
- `for i, x in slice`: `i: usize`.
- `for i in a..b`: 정수 범위.
- 이 루프 형태들은 경계 검사를 생략한다(컴파일러가 안전을 보장).
- `while``bool` 조건만.
- `match`**완전성 검사**. 모든 배리언트를 다루거나 `_` 필요.
- `break`/`continue`는 가장 안쪽 루프에만 적용(레이블 없음).
- `defer block`은 스코프 종료 시 역순 실행. 소멸자와 함께 선언 역순으로 병합 실행. `return`/`break`/에러 전파 경로에서도 실행.
### 7.3 함수 호출 규약
- 기본: `bits32`는 cdecl, `bits16`은 타깃 C 컴파일러 기본.
- `extern "c" fn name(...) -> T;` — 본문 없이 선언, C 심볼과 링크. 이름 맹글링 없음. 인자/반환에 `^T`, 슬라이스, 에러 유니온 사용 불가(`*T`, `usize`만).
- `interrupt fn name()` — 모든 레지스터 보존 + `iret`. 파라미터/반환 없음. 주소는 `@as_far_fn(name)`으로 획득.
- 큰 struct(> 4바이트)는 숨은 포인터로 반환(C ABI 따름).
### 7.4 검사와 트랩
트랩 발생 조건: 배열/슬라이스 경계 초과, 정수 오버플로, 0 나눗셈, `?T``.?` 실패, `@trap()`.
동작: `core.panic(msg: str, file: str, line: u32)` 호출 → 메시지 출력 → `sys.exit(3)`. 사용자가 `core.set_panic_handler`로 교체 가능.
`--no-checks` 빌드에서 제거되는 것: 경계 검사, 오버플로 검사, `.?` 검사.
**절대 제거되지 않는 것:** 소유권/참조 검사, 옵셔널 타입 검사, `match` 완전성 — 전부 컴파일타임이므로.
### 7.5 comptime
- `const` 선언의 초기값은 컴파일타임 평가(정수 연산, `@size_of`, `@bits`, 다른 const).
- `comptime if`는 평가되지 않는 분기를 **파싱은 하되 타입 검사/코드 생성하지 않는다**(타깃별 분기용).
- 함수의 `comptime` 파라미터는 §9 제네릭.
- 재귀 평가 깊이 제한 256, 초과 시 에러.
---
## 8. 유닛과 빌드
- 파일 하나 = 유닛 하나. 첫 줄은 `unit <이름>;`이며 파일명과 일치해야 함.
- `import bar;` → 같은 검색 경로의 `bar.fe`. 접근은 `bar.name`.
- `pub` 붙은 선언만 외부 노출. 구조체 필드도 개별 `pub` 필요.
- 순환 import 금지(에러).
- 유닛 컴파일 시 `.fei` 생성: pub 선언 시그니처, 타입 레이아웃, 제네릭 본문 토큰. 소스 해시가 같으면 재컴파일 생략.
- 검색 경로: `-I <dir>`, 기본은 현재 디렉터리 + `<fec>/std`.
```
fec main.fe --target=bits32 -o game.exe
fec main.fe --target=bits16 --model=large --no-checks -o game.exe
fec main.fe --emit-c -o out/ # 트랜스파일 결과만
fec --dump-ast main.fe
```
---
## 9. 제네릭
`comptime` 파라미터 기반 모노모피제이션.
```fe
pub struct List(T) {
items: ^[]T,
len: usize,
pub fn new() -> List(T) { ... }
pub fn push(self: &mut Self, v: T) -> !void { ... }
pub fn at(self: &Self, i: usize) -> &T { ... } // R8 적용
pub fn drop(self: &mut Self) { ... }
}
fn max(comptime T: type, a: T, b: T) -> T {
if a > b { return a; }
return b;
}
let m = max(i32, 3, 7);
var xs: List(u8) = List(u8).new();
```
- 인스턴스화 시 타입 인자를 대입해 본문을 재검사하고 코드를 생성한다. 인스턴스 캐시 키는 `(선언, 타입 인자 목록)`.
- 제약(trait bound) 없음. 본문에서 쓰는 연산이 그 타입에 없으면 **인스턴스화 시점에** 에러(에러 메시지에 인스턴스화 위치를 표시할 것).
- `.fei`에 제네릭 본문을 토큰 스트림으로 저장, 사용처에서 재파싱.
- 재귀적 인스턴스화 깊이 제한 32.
---
## 10. 표준 라이브러리 (최소 집합)
- **core**: `panic`, `set_panic_handler`, `Error`(기본 에러 집합), `assert`.
- **mem**: `create(T) -> !^T`, `destroy(p)`, `alloc_slice(T, n) -> !^[]T`, `copy(dst, src)`, `set(dst, v)`, `Arena{ init, alloc, reset, drop }`.
- **str**: `eq`, `find`, `starts_with`, `split_at`, `parse_int`, `trim`, `to_cstr(buf, s)`, `from_cstr(p)`.
- **list**: `List(T)`.
- **map**: `Map(K, V)` (오픈 어드레싱, `K`는 정수/str).
- **fmt**: `print_str`, `print_int`, `print_hex`, `format(buf, ...)`. 제네릭 도입 후 `print(fmt, args)` 추가.
- **io**: `File{ open, create, read, write, seek, size, close(=drop) }`, `stdin`, `stdout`, `stderr`.
- **sys**: `exit`, `args`, `env`, `ticks`, `int21(regs)`, `dpmi_*`(bits32), `port_in/out`, `far_copy`(bits16).
`io.File``drop`에서 핸들을 닫는다. 이중 닫기는 소유권 규칙이 막는다.
---
## 11. 컴파일러 구현
### 11.1 부트스트랩 전략
1. **컴파일러 A** — C89로 작성. Ferro → C 트랜스파일러. 호스트는 현대 PC 또는 DOS. 출력 C는 DJGPP(gcc, bits32) / Open Watcom(bits16, bits32) / Borland C(bits16)로 컴파일.
2. **컴파일러 B** — Ferro로 A와 동일 구조를 재작성. A로 빌드.
3. **셀프호스팅** — B로 B를 빌드. 그 결과로 다시 B를 빌드해 출력이 바이트 동일(fixpoint)하면 완료. A 폐기.
4. **네이티브 백엔드** — B에 386 코드 생성기 추가, 이후 8086 코드 생성기.
A는 버릴 코드다. 최적화하지 말고 B를 컴파일할 수 있는 최소 언어 부분집합만 지원한다.
### 11.2 파이프라인
```
소스 → lexer → parser(AST) → resolve(이름/import) → check(타입)
→ own(소유권·참조) → lower(소멸자/defer/try/for 전개 → LIR)
→ emit_c(C 소스) [또는 emit_x86]
```
각 단계는 실패해도 가능한 한 진행해 에러를 모아 보고한다(문장 단위 복구).
### 11.3 디렉터리
```
fec/
src/
lexer.c/h 토큰화. 위치(파일, 줄, 열) 보존.
ast.c/h 노드 정의, 아레나 할당자.
parser.c/h LL(1) 재귀하강. 에러 복구는 다음 ';' 또는 '}'까지 스킵.
types.c/h 타입 인터닝(포인터 비교로 동등성), 레이아웃 계산(타깃별).
resolve.c/h 스코프 체인, 심볼 테이블, import, .fei 읽기/쓰기.
check.c/h 타입 검사, 리터럴 타입 결정, match 완전성, R4 위치 검사.
own.c/h §11.5 알고리즘.
lower.c/h AST → LIR. 소멸자/defer 삽입, try/catch/for/메서드 호출 전개.
emit_c.c/h LIR → C. §11.4 규칙.
generic.c/h 인스턴스 캐시, 토큰 재파싱.
driver.c CLI, 유닛 의존 순서, .fei 캐시, 외부 C 컴파일러 호출.
rt/ 런타임 (C): trap, 힙, 슬라이스 헬퍼, DPMI/INT21 shim
std/ 표준 라이브러리 (.fe)
tests/ §12
```
### 11.4 C 방출 규칙
| Ferro | C |
|---|---|
| `i16`, `u32` 등 | `int16_t`, `uint32_t` (`<stdint.h>` 없으면 자체 typedef) |
| `usize` | `uint16_t`(bits16) / `uint32_t`(bits32) |
| `bool` | `unsigned char` |
| `^T`, `*T` | `T*` |
| `&T` | `const T*` |
| `&mut T` | `T*` |
| `far X` | `__far X` (Watcom/Borland), bits32는 무시 |
| `[N]T` | `struct { T a[N]; }` (값 의미론 유지, 붕괴 방지) |
| `[]T` | `typedef struct { T* p; fe_usize n; } fe_slice_T;` |
| `?T` (포인터류) | 원래 포인터, null 사용 |
| `?T` (그 외) | `struct { unsigned char has; T v; }` |
| `E!T` | `struct { uint16_t e; T v; }`, `!void``uint16_t` |
| struct | `struct fe_<unit>_<Name>` |
| enum | `struct { uint8_t tag; union { ... } u; }` |
| 함수 | `fe_<unit>_<name>`, 메서드는 `fe_<unit>_<Type>_<name>` |
| 제네릭 인스턴스 | `fe_<unit>_<Name>__<타입인자맹글>` |
세부:
- **오버플로 검사**: `fe_add_i16(a, b, LINE)` 인라인 함수. `--no-checks`면 매크로가 `((a)+(b))`로 축약.
- **경계 검사**: `fe_idx_T(s, i, LINE)``(i < s.n ? s.p[i] : (fe_trap_bounds(LINE), s.p[0]))`. `for` 루프는 직접 인덱스.
- **`try`**: `{ Ttmp t = expr; if (t.e) return (RetT){ t.e }; }``t.v` 사용. defer/소멸자가 있으면 return 전에 정리 코드 삽입.
- **`catch`**: `t.e`가 참일 때 블록 실행, 바인딩 변수는 `t.e`.
- **`defer`/소멸자**: lower 단계에서 스코프 종료 지점(정상 흐름, `return`, `break`, `continue`, `try` 전파)마다 역순 호출을 명시적으로 삽입. C의 goto 라벨을 써도 되고 복제해도 된다(A는 복제, B는 goto 권장).
- **조건부 이동**: 이동 여부가 분기에 따라 다르면 `unsigned char fe_live_<var> = 1;` 플래그 삽입, drop 전에 검사.
- **`match`**: `switch (x.tag)`. 페이로드 바인딩은 지역 변수로 복사 또는 포인터.
- **`asm`**: Intel 문법으로 고정 저장. Watcom/Borland는 그대로, gcc는 `__asm__(".intel_syntax noprefix\n" ...)`로 감싼다.
- **방출 순서**: typedef 전방선언 → struct 정의(의존 위상 정렬) → 전역 → 함수 프로토타입 → 함수 본문.
- 유닛 하나당 `.c` 하나, `.fei`에서 필요한 부분은 `.h`로 생성.
### 11.5 own.c 알고리즘
함수 단위. 각 지역 변수/파라미터에 상태:
```
Uninit | Owned | Moved | MaybeMoved | Shared(n) | Exclusive
```
1. AST를 문장 순서로 순회하며 상태 전이.
2. 표현식 평가 시 lvalue 사용을 분류: 읽기 / 이동 / `&` 대여 / `&mut` 대여 / 쓰기.
3. 이동: `Owned → Moved`. `Moved`/`MaybeMoved` 사용 시 에러(최초 이동 위치를 에러에 표시).
4. `&x`: `Owned → Shared(n+1)`. `&mut x`: `Owned → Exclusive`. 해제는 참조 변수의 스코프 끝(임시 참조는 문장 끝).
5. `Shared`/`Exclusive` 상태에서 쓰기/이동/재대여 시 에러(R6).
6. **분기 합류**: `if`/`match`의 각 브랜치를 독립 상태로 계산 후 병합. `Owned` + `Moved``MaybeMoved`(사용 에러, drop은 런타임 플래그).
7. **루프**: 본문을 2회 순회. 1회차 종료 상태를 진입 상태와 병합해 2회차 실행, 상태가 수렴하지 않으면(예: 첫 반복에서 이동) 에러.
8. R4 위반(참조를 필드/반환/힙에 저장)은 own이 아니라 check 단계에서 **타입만 보고** 거부한다.
9. R8(메서드 참조 반환)은 호출 결과를 바인딩하려는 시도를 check에서 거부.
에러 메시지 형식: `file:line:col: error: <설명>` + 관련 위치 `file:line:col: note: <최초 이동/대여 위치>`.
### 11.6 마일스톤
| # | 내용 | 완료 기준 |
|---|---|---|
| M1 | lexer, parser, AST 덤프 | `--dump-ast`가 std 소스 전체를 파싱 |
| M2 | 타입 검사 + C 방출: 정수, 함수, if, while | bits32 hello world 실행 |
| M3 | struct, enum, match, 배열, 슬라이스, 경계 검사, str | 문자열 처리 예제 통과 |
| M4 | `^T`, drop, defer, 이동 검사 | 누수/이중해제 테스트 통과 |
| M5 | `&`, `&mut`, 배타성 검사 (own.c 전체) | R1~R8 실패 테스트 통과 |
| M6 | `?T`, `E!T`, try/catch | io 유닛 동작 |
| M7 | 유닛/import/.fei, 분리 컴파일, std 초안 | 다중 유닛 프로그램 빌드 |
| M8 | **제네릭** (모노모피제이션) | `List(T)`, `Map(K,V)`를 Ferro로 재작성 |
| M9 | bits16 타깃: far, `@seg_ptr`, 메모리 모델, asm, interrupt fn | DOSBox에서 VGA 데모 실행 |
| M10 | 컴파일러 B를 Ferro로 작성, A로 빌드 | B가 M1~M9 테스트 통과 |
| M11 | 셀프호스팅 fixpoint | B(B(B)) == B(B) 바이트 동일, A 폐기 |
| M12 | 386 네이티브 백엔드 | gcc 없이 빌드, 컴파일 속도 10배 |
| M13 | 8086 네이티브 백엔드 | Watcom 없이 bits16 빌드 |
M8은 M9보다 앞이다(제네릭 없이 표준 라이브러리를 쓰는 기간을 최소화).
---
## 12. 테스트
```
tests/
pass/*.fe + *.expected 컴파일→실행→stdout 비교
fail/*.fe 첫 줄 "// ERROR:<line>:<메시지 일부>"
run16/*.fe bits16 빌드 후 DOSBox 실행, 출력 파일 비교
boot/ A/B 출력 비교, fixpoint 검증
```
- `fail/`은 규칙별 최소 3개: R1(이동 후 사용), R3(직접 drop 호출), R4(필드에 참조), R5(스코프 초과), R6(배타성 위반), R7(무효화), R8(참조 반환 바인딩), R9(unsafe 밖 raw 역참조), match 완전성, 암묵 변환, 타입 불일치.
- 각 마일스톤은 해당 기능의 pass/fail 테스트와 함께 완료한다.
- 회귀 실행: `make test` — 전 타깃 전 테스트.
---
## 13. 의도적으로 제외한 기능
| 기능 | 제외 이유 | 대체 수단 |
|---|---|---|
| 라이프타임 표기 (`'a`) | 전역 분석 필요 | R4 (2급 참조) |
| 트레잇/인터페이스 | 복잡도 대비 이득 낮음 | 함수 포인터 struct: `struct Writer { ctx: *void, write: fn(*void, []u8) -> !usize }` |
| 클로저 | 캡처 = 참조 저장 = R4 위반 | 콜백에 `ctx: *void` 전달 |
| 매크로 / 전처리기 | 도구 지원과 컴파일 속도 파괴 | `const`, `comptime if`, 제네릭 |
| 예외 | 언와인딩 기반 시설 없음, 비용 큼 | 에러 유니온 |
| GC | 결정적 비용 원칙 위반 | 소유권 + RAII + 아레나 |
| 연산자 오버로딩 | 숨은 비용 | 메서드 |
| 가변 인자 | ABI 복잡, 타입 안전 불가 | 제네릭 `comptime` 파라미터 |
| 튜플 / 다중 반환 | 이름 없는 필드는 가독성 손해 | struct |
| 암묵 형변환 | 버그 원인 1위 | `as` |
| 스레드 | DOS에 없음 | — |
| 상속 | — | 합성 |
+5
View File
@@ -0,0 +1,5 @@
# TODO
- [x] Add a non-reboot abort path for a hung DOS command: inject `Ctrl+C` through QEMU's monitor and wait for the serial agent to recover.
- [x] Apply a configurable timeout to `dos_exec` and invoke the non-reboot abort path on timeout.
- [x] Expose `dos_abort` for an immediate user-requested command interruption.