Nanz — типизированный системный язык для Z80 и ретро-платформ. Компилируется через HIR → MIR2 → Z80 ассемблер — чистый, современный конвейер, общий с фронтендом PL/M-80.
Версия: MinZ compiler v0.19.5 (конвейер MIR2)
Дата: 2026-03-10
Статус: Активная разработка — основные функции стабильны, с отдельными шероховатостями
Источник истины: pkg/nanz/parse.go, pkg/hir/, pkg/mir2/, pkg/pipeline/
- Что такое Nanz?
- Справочник по синтаксису
- Система типов
- Конвейер компиляции
- Промежуточное представление MIR2
- Проходы оптимизации
- Генерация кода для Z80
- Цепочки итераторов и слияние
- Абстракции с нулевой стоимостью
- Оракул корректности QBE
- PL/M-80: Формирование корпуса и идиоматический перевод
- Галерея скомпилированного вывода
- Связь с MinZ и PL/M
- Приложение A: Полная грамматика синтаксиса
- Приложение B: Классы регистров и таблица стоимостей
- Приложение C: Справочник по CLI
- Приложение D: Установка внешних инструментов
- Приложение E: Известные ошибки и ограничения
Nanz (расширение .nanz) — активный фронтенд-язык компилятора MinZ. Он статически типизирован, императивен и предназначен для двух аудиторий:
- Разработчики, пишущие программы для Z80 / ретро-платформ, которым нужен современный синтаксис с абстракциями нулевой стоимости.
- Команда компилятора MinZ, разрабатывающая и тестирующая бэкенд MIR2.
source.nanz
│ nanz.Parse()
▼
*hir.Module ← High-level IR (структурный поток управления, именованные переменные)
│ hir.LowerModule()
▼
*mir2.Module ← Mid-level IR (SSA-подобный, виртуальные регистры, типизированные операции)
│ проходы оптимизации (constant fold, DSE, BranchEquiv, CondRetSink, LUTGen)
▼
*mir2.Module ← оптимизированный
│ OptimizeContracts() → PBQPAllocate()
▼
*mir2.Module ← с распределёнными регистрами (виртуальные → физические)
│ Z80Codegen()
▼
output.a80 ← Z80 ассемблер, совместимый с MZA
│ mza (ассемблер)
▼
output.bin / .tap ← бинарный файл / образ ленты ZX Spectrum
Вызов:
mz source.nanz -o output.a80MinZ (.minz) — оригинальный фронтенд со своим парсером и генератором кода, нацеленным на старый IR MIR1. Этот конвейер заморожен — он работает, но не развивается.
Nanz нацелен на MIR2, который обеспечивает:
- SSA-подобный поток данных с параметрами блоков (стиль Cranelift/MLIR, не phi-узлы)
- Распределитель регистров PBQP со взвешенными векторами стоимости
- Генерация LUT: вычисление во время компиляции для чистых функций с ограниченной областью определения
- Межпроцедурная оптимизация соглашений о вызовах
- Эмулятор Z80, используемый как вычислитель констант и средство доказательства эквивалентности ветвлений
Правило: Новые ретро-программы пишите на Nanz. Существующие .minz-программы оставляйте как есть.
PL/M-80 (.plm) — язык Intel 1970-х для CP/M. Компилятор MinZ включает парсер PL/M-80, который компилирует через тот же конвейер HIR → MIR2 → Z80, что и Nanz:
mz legacy.plm -o legacy.a80 # тот же бэкенд, что и у Nanz
mz legacy.plm --emit=nanz # трансляция PL/M в исходный код NanzNanz намеренно минималистичен. Грамматика умещается на одном экране. Без сборщика мусора, без рантайма, без динамической диспетчеризации. Каждая абстракция — лямбды, итераторы, методы структур, интерфейсы — компилируется в прямые инструкции Z80 без накладных расходов сверх того, что вы написали бы вручную.
Парсер — рукописный рекурсивный нисходящий парсер с выражениями по методу Пратта (pkg/nanz/parse.go). Исходный код: ~2000 строк на Go.
Исходный файл Nanz — это модуль: последовательность объявлений верхнего уровня в произвольном порядке.
// line comment
/* block comment */
struct Point { x: u8, y: u8 }
interface Drawable { draw }
global counter: u8
fun add(a: u8, b: u8) -> u8 { return a + b }
Импортов нет; компоновка выполняется на уровне ассемблера.
| Синтаксис | Ширина | Отображение на Z80 | Примечания |
|---|---|---|---|
u8 |
8 бит | A/B/C/D/E/H/L | беззнаковый байт |
u16 |
16 бит | HL/DE/BC | беззнаковое слово |
i8 |
8 бит | те же регистры, другая арифметика | знаковый байт |
i16 |
16 бит | то же | знаковое слово |
bool |
8 бит | false=0, true=1 | |
void |
— | только тип возврата | |
ptr |
16 бит | HL/DE/BC | нетипизированный указатель |
^T |
16 бит | HL/DE/BC | типизированный указатель на T |
[T; N] |
N×width(T) | — | массив фиксированного размера |
u8<lo..hi> |
8 бит | — | тип с диапазоном (кандидат для LUT) |
u16<lo..hi> |
16 бит | — | тип с диапазоном |
StructName |
сумма полей | передаётся по указателю | тип структуры |
InterfaceName |
— | разрешается во время компиляции | интерфейс как тип параметра |
Типы указателей: ^T запоминает тип элемента для разрешения полей. Когда T — структура, ^Struct обеспечивает типизированный доступ к полям через указатель (например, self.val на приёмнике ^Acc автоматически разыменовывает и разрешает смещения полей). Это НЕ просто синтаксический сахар — парсер использует varPtrElem[paramName] для отслеживания структуры, на которую указывает указатель, для разрешения полей и диспетчеризации UFCS.
Типы с диапазоном: u8<0..255> объявляет вход с диапазоном. Диапазон включительный в исходном коде (0..255), хранится исключительно внутри ([0, 256)). Функции с одним параметром-диапазоном и чистым телом являются кандидатами для генерации LUT (см. §6.4).
fun name(param1: Type1, param2: Type2) -> ReturnType {
// body
}
fn принимается как псевдоним для fun. Функции без возвращаемого значения опускают -> ReturnType (неявно void).
fn add(a: u8, b: u8) -> u8 { return a + b }
fun clear(buf: ^u8, n: u8) {
var i: u8 = 0
while i < n { buf[i] = 0; i = i + 1 }
}
Функции, реализованные за пределами модуля Nanz, объявляются с помощью @extern:
@extern fun process(x: u8) -> void
@extern fun rom_print(s: ptr) -> void
Тело опускается. Компилятор назначает классы регистров параметрам в соответствии со стандартным соглашением о вызовах.
Статус: Аннотированная форма @extern("sym", params=[z80_a], returns=[z80_a]), описанная в некоторой документации, ещё не реализована в парсере. В настоящее время функции @extern получают только автоматическое назначение регистров.
var — явный тип, необязательный инициализатор:
var i: u8 = 0
var buf: ^u8
var port: u8 at(0xFE) // memory-mapped at absolute address
let — тип выводится из инициализатора:
let x = 42 // x: u8
let ptr = @ptr(u8, 0xFE) // ptr: ptr
let y: u16 = 1000 // explicit type override
Конструкция at(addr) привязывает переменную к фиксированному адресу памяти. Чтение и запись становятся LD (addr), r / LD r, (addr).
global counter: u8
global vram: u8 at(0x4000) // memory-mapped
global table: [u8; 4] = [1, 2, 4, 8] // initialized array
global palette: Color // struct global
Глобальные переменные размещаются в секции данных .a80-вывода. Глобальные массивы с инициализаторами получают директивы DB.
42 // decimal — u8 if ≤255, u16 otherwise
0xFF // hexadecimal
0 // zero (u8)
true // bool
false // bool
"hello" // string → ptr to NUL-terminated bytes (interned)
Арифметические (левоассоциативные):
+ - * / %
Побитовые:
& | ^ (XOR) ~ (NOT, унарный) << >>
Сравнения (результат — bool):
== != < <= > >=
Логические:
! (NOT, унарный)
Приоритет (от высшего к низшему):
| Уровень | Операторы |
|---|---|
| 8 | * / % |
| 7 | + - |
| 6 | << >> |
| 5 | < <= > >= |
| 4 | == != |
| 3 | & |
| 2 | ^ (XOR) |
| 1 | | |
Скобки переопределяют приоритет: (a + b) * c.
let val = ptr^ // dereference (load byte at ptr)
^ptr = 42 // store through pointer
let b = buf[3] // index load (buf + 3)
buf[i] = 0 // index store
let p = &counter // address-of global
let kb = @ptr(u8, 0xFE) // typed constant pointer to absolute address
@ptr(T, addr) — идиоматический способ обращения к аппаратным регистрам и процедурам ROM.
u8(expr) // truncate to u8
u16(expr) // zero-extend to u16
i8(expr) // reinterpret as signed 8-bit
i16(expr) // reinterpret as signed 16-bit
Приведения типов явные — неявного расширения нет. На Z80 u8→u16 стоит LD H, 0 / LD L, A; компилятор не будет это скрывать.
// if / else
if condition { /* then */ } else { /* else */ }
// while
while condition { /* body */ }
// for range (exclusive end)
for i in 0..n { /* i = 0, 1, ..., n-1 */ }
// for each (sequential memory scan)
for x: u8 in buf[0..n] { /* x loaded from buf[0]..buf[n-1] */ }
// break and continue
while true { if done { break } if skip { continue } }
// return
return // void
return expr // value
// switch
switch value {
case 0: return 10
case 1: return 20
default: return 0
}
for x: u8 in ptr[start..end] компилируется в цикл DJNZ с HL как указателем и B как счётчиком — самый компактный цикл Z80.
Ветви switch не проваливаются (no fall-through). Тело каждого case завершается на следующем case, default или }.
struct Point { x: u8, y: u8 }
struct Vec3d { x: u16, y: u16, z: u8 }
Поля размещаются последовательно, без выравнивания. Смещения вычисляются во время парсинга:
Point.x→ смещение 0,Point.y→ смещение 1Vec3d.y→ смещение 2,Vec3d.z→ смещение 4
Значения структур всегда передаются по указателю (HL на Z80). Доступ к полям: Load(ptr + offset).
Методы объявляются с синтаксисом TypeName.methodName:
struct Acc { val: u8 }
fun Acc.add(self: ^Acc, amount: u8) -> u8 {
self.val = self.val + amount
return self.val
}
Компилятор сохраняет это как Acc_add. На месте вызова UFCS перезаписывает:
acc_g.add(5) // → Acc_add(&acc_g, 5) — direct CALL, no vtable
Как работает разрешение UFCS (во время парсинга):
- Парсер видит
base.method(args). - Проверяет
exprTy(base)— если это структура, ищет вmethodTable[structName][method]. - Если base — указатель
^Struct, проверяетvarPtrElem[name]для определения типа структуры. - Если base — переменная с типом интерфейса, вызывает
findImplementors(). - Перезаписывает в
CallExpr{Fn: "StructName_method", Args: [base, args...]}.
Приёмники-указатели (self: ^Acc):
self.valавтоматически разыменовывает — разрешает смещение поля черезvarPtrElem.self^.valтоже работает (явное разыменование, тот же результат).- Указатель передаётся в HL (ClassPointer). Чтение полей:
LD reg, (HL)при смещении 0,INC HL; LD reg, (HL)при смещении 1, и т.д.
Приёмники по значению (self: Acc):
- Структура передаётся по указателю — на уровне ABI то же, что
^Acc. - Парсер записывает в
methodTableс полной информацией о типе возврата.
fun +(a: Vec2, b: Vec2) -> Vec2 { return a }
fun -(a: Vec2, b: Vec2) -> Vec2 { return a }
fun compute(a: Vec2, b: Vec2) -> Vec2 {
return a + b // dispatched to op_add(a, b) because a is Vec2
}
Перегружаемые операторы: + - * / % == != < <= > >= & | ^.
Декорированные имена: op_add, op_sub, op_mul, op_div, op_rem, op_eq, op_ne, op_lt, op_le, op_gt, op_ge, op_and, op_or, op_xor.
Важно: Для примитивных типов (u8 + u8) всегда используется встроенный BinExpr, независимо от перегрузок в области видимости. Перегрузки срабатывают только когда левый операнд — тип структуры.
interface Animal {
speak
move
}
Интерфейсы — это контракты времени компиляции. Без vtable, без жирных указателей, без таблиц диспетчеризации методов.
Как тип параметра:
fun feed(a: Animal) -> u8 {
return a.speak() // monomorphized at compile time
}
Правила разрешения:
- Один реализатор → мономорфизация:
a.speak()→Dog_speak(a). - Несколько реализаторов → ошибка компиляции:
"ambiguous dispatch: ... multiple types implement Animal: [Dog Cat]; use concrete type". - Ноль реализаторов → ошибка компиляции.
Интерфейс как тип глобальной переменной:
global g_thing: Drawable
Работает так же: диспетчеризация UFCS разрешается к единственному реализатору.
let double = |x: u8| x * 2 // expression body
let add = |a: u8, b: u8| a + b // multi-param
let greet = |x: u8| { return x + 1 } // block body
let inc = |x| x + 1 // type defaults to u8
Каждая лямбда становится функцией верхнего уровня lambda_N (последовательный счётчик). Ссылка на месте вызова — это VarRefExpr{Name: "lambda_N"}.
Правила захвата:
- Лямбды слитых итераторов (forEach/map/filter): могут захватывать и изменять внешние локальные переменные. Компилятор обнаруживает свободные переменные через
hasFreeVars(), пропускает автономное понижение и передаёт захваченные переменные как параметры блоков через цикл DJNZ. Ноль кучи, ноль сбросов. - Неслитые лямбды (указатели на функции): не могут захватывать внешние локальные переменные — нет фрейма стека. Доступны только глобальные.
buf.forEach(|x: u8| { process(x) }, n) // execute for each element
buf.map(|x: u8| (x * 2)) // transform elements
buf.filter(|x: u8| (x > 5)) // keep matching elements
buf.mapInPlace(|x: u8| (x + 2), n) // in-place transform
Они распознаются парсером как вызовы UFCS-методов на выражениях-указателях. Понижатель HIR tryLowerIterChain сливает цепочки в один цикл DJNZ. См. главу 8.
IR MIR2 (pkg/mir2/types.go) поддерживает:
| Тип | Ширина | Представление в Go |
|---|---|---|
TyVoid |
0 | &IntTy{Bits: 0} |
TyBool |
8 | &IntTy{Bits: 8} |
TyU8 |
8 | &IntTy{Bits: 8, Signed: false} |
TyI8 |
8 | &IntTy{Bits: 8, Signed: true} |
TyU16 |
16 | &IntTy{Bits: 16, Signed: false} |
TyI16 |
16 | &IntTy{Bits: 16, Signed: true} |
TyU24 |
24 | &IntTy{Bits: 24} (eZ80, будущее) |
TyPtr |
16 | &PtrTy{} |
StructTy |
сумма полей | &StructTy{Name, Fields} |
ArrayTy |
N×элемент | &ArrayTy{Len, Elem, Layout} |
TupleTy |
сумма элементов | &TupleTy{Elems} (множественный возврат) |
Кроме того, типы с диапазоном оборачивают базовый тип границами [Lo, Hi):
type RangedTy struct {
Base Ty
Lo, Hi int // [Lo, Hi) — exclusive upper bound
}Параметрам назначаются классы регистров на основе позиции и типа (classForParam в hir/lower.go):
| Позиция | Тип | Класс | Физический регистр Z80 |
|---|---|---|---|
| 1-й | u8, bool |
ClassAcc |
A |
| 1-й | u16, ptr, struct |
ClassPointer |
HL |
| 2-й | u8 |
ClassGeneral |
C |
| 2-й | u16 |
ClassIndex |
DE |
| 3-й | u8 |
ClassCounter |
B |
| 3-й+ | u16 |
ClassPair |
BC/DE |
Возвращаемые значения: u8 → ClassAcc (A), u16/ptr → ClassPointer (HL).
Поля упакованы последовательно, без выравнивания:
struct Color { r: u8, g: u8, b: u8 } // total: 3 bytes
// r at offset 0, g at offset 1, b at offset 2
Парсер вычисляет смещения во время парсинга, суммируя Ty.Width() / 8 для каждого предшествующего поля. Поля глобальных структур получают метки EQU в ассемблере: palette__r EQU palette + 0.
Полный конвейер оркестрируется модулем pkg/pipeline/pipeline.go.
func CompileHIRSteps(hm *hir.Module) (Steps, error)Стадия 1 — Понижение HIR → MIR2 (hir.LowerModule):
- Именованные переменные → виртуальные регистры (новый регистр на каждое присваивание)
- Структурный поток управления → базовые блоки с параметрами блоков
- Мутации в циклах → параметры блоков в заголовках циклов (обнаруживаются через
scanMutations) - ForEachStmt → цикл указатель+счётчик, дружественный к DJNZ
Стадия 2 — Оптимизации для каждой функции (по порядку):
EliminateDeadBlocks— удаление недостижимых блоковReorderBlocks— улучшение проваливания (fall-through)- Конвейер констант (итерируется до фиксированной точки):
PropagateConstants— отслеживание констант через перемещенияFoldConstants— вычисление чистых операций во время компиляцииSimplifyIdentities—PtrAdd(x, 0)→Move(x)и т.д.ConstantCallElim— свёртка вызовов с константными аргументами через ВМ
DeadStoreElim— удаление чистых инструкций с неиспользуемыми результатами (итеративно)BranchEquiv— устранение ветвлений на основе ВМ (доказывает избыточность защит)CondRetSink— подъём тривиальных else-блоков в условные возвраты
Стадия 3 — На уровне модуля: LUTGen:
- Чистые функции с параметрами-диапазонами → таблицы поиска времени компиляции
Стадия 4 — Верификация (Verify):
- Уникальные метки блоков, корректные терминаторы, согласованность типов
Стадия 5 — Межпроцедурная оптимизация:
OptimizeContracts— жадное ДП на графе вызовов для назначения классов регистровApplyContracts— перезапись сигнатур функций
Стадия 6 — Распределение регистров:
ComputeLiveness— обратный анализ потока данных до фиксированной точкиPBQPAllocate— взвешенное назначение виртуальных регистров физическим регистрам Z80
Стадия 7 — Генерация кода:
Z80Codegen→ текст ассемблера
mz source.nanz --emit=hir # HIR dump
mz source.nanz --emit=mir2-raw # MIR2 before optimization
mz source.nanz --emit=mir2 # MIR2 after optimization
mz source.plm --emit=nanz # PL/M → Nanz source translation
mz source.nanz -o output.a80 # Z80 assembly (default)MIR2 — основной IR. Он использует аргументы блоков (стиль Cranelift/MLIR) вместо phi-узлов.
Module
├── Funcs[] — каждая с Blocks[]
├── Globals[] — данные уровня модуля
├── Strings[] — интернированный пул строк
└── Structs[] — определения типов структур
Каждая инструкция: %dst = Op %src0, %src1 : Ty [Class]
Арифметические: OpAdd, OpSub, OpMul, OpDiv, OpSDiv, OpMod
Побитовые: OpAnd, OpOr, OpXor, OpShl, OpShr, OpSar
Унарные: OpNeg, OpNot
Преобразования: OpExt (расширение нулём), OpSext (расширение знаком), OpTrunc
Сравнения: OpCmp с условиями: CmpEq, CmpNe, CmpLt, CmpLe, CmpGt, CmpGe, CmpUlt, CmpUle, CmpUgt, CmpUge, CmpSubCarry
Перемещение данных: OpConst (непосредственное значение), OpMove (копирование регистра)
Память: OpLoad, OpStore, OpAddrOf, OpAlloca, OpField (ptr+смещение), OpPtrAdd (ptr+смещение времени выполнения), OpPtrBump (ptr+шаг времени компиляции)
Вызовы: OpCall (прямой), OpCallIndirect (через указатель)
SMC: OpPatchSlot, OpLoadPatched, OpPatch (примитивы самомодифицирующегося кода)
block @loop_head(%ptr: u16 [pointer], %cnt: u8 [counter]):
%cond = cmp.gt %cnt, %limit : bool [flag]
br_if %cond, @exit(), @body(%ptr, %cnt)
Аргументы блоков определяют регистры на входе. Аргументы передаются по каждому ребру (переход/ветвление). Это заменяет phi-узлы и делает параллельные копии явными для каждого ребра.
| Терминатор | Семантика | Вывод Z80 |
|---|---|---|
TermJmp(target, args) |
Безусловный переход | JP label |
TermBrIf(cond, then, else) |
Условное ветвление | JP Z/NZ/C/NC label |
TermDJNZ(counter, body, exit) |
Декремент B, переход если не ноль | DJNZ rel8 |
TermCondRet(cond, vals, then) |
Условный возврат | RET CC |
TermRet(vals) |
Возврат | RET |
TermCondRet создаётся проходом оптимизации CondRetSink — он задействует однокомандный условный возврат Z80.
Четыре прохода итерируются до стабилизации:
- PropagateConstants — если
%r = const 42, все использования%rзаменяются на 42. - FoldConstants —
const(3) + const(5)→const(8). - SimplifyIdentities —
PtrAdd(x, Const(0))→Move(x). Устраняет избыточную арифметику указателей с нулевым смещением (критично для приёмников^Struct). - ConstantCallElim — чистая функция со всеми константными аргументами → вычисление через ВМ MIR2.
Удаляет чистые инструкции, результаты которых нигде не используются. Итерируется до фиксированной точки, поскольку удаление одной мёртвой инструкции может сделать мёртвыми её источники.
Никогда не удаляются: OpStore, OpCall, OpCallIndirect, OpAsm, OpPatch (побочные эффекты).
Доказывает избыточность условных ветвлений путём исчерпывающего тестирования на ВМ.
Пример: abs_diff с защитой if a == b { return 0 }. BranchEquiv прогоняет все 256 входов (v, v) через исходную и модифицированную функции. Обе возвращают 0 → ветвление доказуемо избыточно → BrIf(eq, @zero, @diff) заменяется на Jmp(@diff).
Корректность для u8: исчерпывающий тест на 256 значениях. Для более широких типов: эвристическая выборка (безопасно для расширения в будущем).
Находит BrIf(cond, @then, @else), где @else тривиален (единственный предшественник, чистые инструкции, TermRet). Поднимает инструкции @else в текущий блок и заменяет BrIf на TermCondRet.
Слитые оптимизации, срабатывающие сразу после подъёма:
- SubSwapNeg: Если поднятое
sub(a, b)имеет обратноеsub(b, a)в then-блоке → замена наneg(result). ЭкономитLD A,r; SUB r2→ однаNEG. - HoistReorderSubBeforeCmp + CmpSubCarry: Переупорядочивание
subпередcmp_ltна тех же операндах → флаг переноса отSUBи ЕСТЬ результат сравнения. Полностью устраняет инструкциюCP.
Чистые функции с одним параметром-диапазоном → таблица поиска.
Критерии: 1 параметр типа u8<lo..hi> или u16<lo..hi>, диапазон ≤ 256, одно возвращаемое значение, без вызовов extern, без записи в глобальные.
Процесс:
- ВМ-вычисление функции для каждого входа в диапазоне
- Генерация выровненной по странице таблицы
DBкак глобальной - Замена тела функции на поиск по таблице:
LD H, lut^H ; 7T — page base (high byte only) LD L, C ; 4T — index LD A, (HL) ; 7T — lookup RET
Результат: 18 тактов независимо от сложности исходной функции. Цикл popcount на 8 итераций становится 3 инструкциями.
Заменяет старый жадный распределитель. PBQP (Partitioned Boolean Quadratic Program) минимизирует:
Σ nodeCost[r][loc(r)] + Σ edgeCost[interfering pairs]
Стоимость узла: useCount[r] × costTable.Cost(r.Class, location). Горячие регистры больше платят за дорогие расположения.
Правила редукции:
- R0: степень 0 (изолированный) → назначить самое дешёвое расположение немедленно.
- R1: степень 1 (лист) → свернуть стоимость ребра в соседа, отложить.
- RN: степень ≥ 2 → жадный по дельте (
2-й_лучший − лучший). Регистры с большой дельтой (высокий штраф при вытеснении) распределяются первыми.
Результат для 4 одновременных регистров ClassPointer:
p0 → HL (cost 0)
p1 → DE (cost 4)
p2 → BC (cost 6)
p3 → IX (cost 8) — no $F0xx memory spill
После того как PBQP назначает физические расположения, coalesceAllocResult устраняет избыточные копии на границах блоков:
- Сбор рёбер аффинности из
OpMoveи пар параметр блока↔аргумент. - Однопроходная перекраска: если ни один сосед в графе интерференции не использует расположение цели, перекрасить для совпадения. Блокировка
recoloredпредотвращает циклы вращения в phi-сетях циклов.
OptimizeContracts выполняет жадное ДП на графе вызовов:
- Топологическая сортировка (сначала листья).
- Для каждой функции: перебор кандидатных векторов классов регистров.
- Стоимость = внутренняя стоимость адаптера + стоимость рёбер по всем вызывающим.
- Выбор назначения с минимальной стоимостью.
Это выбирает соглашения о вызовах для каждой функции глобально, уменьшая перемещения регистров на месте вызовов.
pkg/mir2/z80codegen.go — конвертирует распределённый MIR2 в текст ассемблера.
| Операция MIR2 | Вывод Z80 |
|---|---|
OpConst u8 |
LD r, imm8 |
OpConst u16 |
LD rr, imm16 |
OpAdd u8 |
ADD A, r |
OpAdd u16 |
ADD HL, rr |
OpSub u8 |
SUB r |
OpSub u16 |
AND A; SBC HL, rr |
OpNeg |
NEG (только 8 бит, регистр A) |
OpCmp |
CP r (результат во флагах, ClassFlag) |
OpLoad |
LD A, (HL) / LD A, (rr) |
OpStore |
LD (HL), r |
OpCall |
CALL label |
Когда PBQP назначает указатель на IX, генератор кода использует адресацию со смещением:
LD A, (IX+0) ; 8-bit load, offset 0
LD (IX+1), A ; 8-bit store, offset 1
LD L, (IX+0) ; 16-bit load (lo)
LD H, (IX+1) ; 16-bit load (hi)Копирование 16-битного регистра↔IX: DE/BC→IX использует недокументированное побайтовое копирование:
LD IXH, D ; DD 62 — 8T (D not substituted by DD prefix)
LD IXL, E ; DD 6B — 8T (total 16T)Побайтовое копирование HL→IX НЕДОПУСТИМО: Префикс DD подменяет H→IXH и L→IXL как в позиции источника, так и назначения, поэтому LD IXH, H декодируется как LD IXH, IXH (NOP). Используйте PUSH HL; POP IX (21T) вместо этого.
Подавление мёртвых констант: OpConst, единственное использование которой — OpCmp → генерировать CP imm8 напрямую, пропустить LD r, imm.
Распространение копий: Отслеживание holdsPhys[A] = D — если A уже содержит значение D, пропустить LD A, D.
Отслеживание последних флагов: Если флаги уже установлены предыдущей инструкцией с теми же операндами, подавить избыточный CP.
Постраничный LUT: Предварительное сканирование блоков на паттерны доступа к LUT → LD H, sym^H; LD L, idx; LD A, (HL) (18T).
Прямая адресация полей глобальных структур: Предварительное сканирование на AddrOf(global) + Field(offset) + Load/Store → LD A, (sym__field) напрямую (13T).
Обнаружение HL-цепочек: Несколько последовательных записей полей в одну и ту же структуру → одна LD HL, sym + цепочка LD (HL), r; INC HL (экономия на перезагрузке HL).
| Метод | Сигнатура | Семантика |
|---|---|---|
forEach(lambda, n) |
Выполнить лямбду для n элементов | Терминальный |
map(lambda) |
Преобразовать каждый элемент | Промежуточный |
filter(lambda) |
Оставить элементы, где лямбда возвращает true | Промежуточный |
mapInPlace(lambda, n) |
Преобразовать и записать обратно | Терминальный |
Они распознаются как UFCS на выражениях-указателях. Понижатель HIR tryLowerIterChain сливает цепочки.
buf.map(|x| x * 2).filter(|x| x > 5).forEach(|x| { process(x) }, n)
Сливается в один ForEachStmt:
for x in buf[0..n]:
let mapped = x * 2 // map body inlined
if mapped > 5 { // filter: skip if false
process(mapped) // forEach body inlined
}
Это становится одним циклом DJNZ:
.loop:
LD A, (HL) ; load element
INC HL
ADD A, A ; map: x * 2
CP 6 ; filter: x > 5 → x >= 6
JR C, .skip
CALL process ; forEach body
.skip:
DJNZ .loopБез промежуточного массива. Без CALL лямбды. Три стадии, один цикл.
fun sum(buf: ^u8, n: u8) -> u8 {
var s: u8 = 0
buf.forEach(|x: u8| { s = s + x }, n)
return s
}
s — свободная переменная в лямбде. Компилятор:
- Обнаруживает свободную ссылку через
hasFreeVars(). - Пропускает автономное понижение
lambda_N. - Передаёт
sкак параметр блока через цикл DJNZ.
Результат: s живёт в регистре (например, C) на протяжении всего цикла — ноль сбросов, ноль кучи.
LD D, (HL) ; 7T — load element
LD A, C ; 4T
ADD A, D ; 4T — s + x
LD C, A ; 4T
INC HL ; 6T
DJNZ .loop ; 13T (taken)
; Total: 38T per elementДля сравнения: отдельный вызов функции на каждый элемент добавляет CALL(17T) + RET(10T) = 27T накладных расходов до начала какой-либо работы.
buf.mapInPlace(|x: u8| (x + 2), n)
Флаг MutateInPlace запускает обратную запись после тела лямбды:
LD A, (HL) ; load
ADD A, 2 ; transform
LD (HL), A ; write back
INC HL
DJNZ .loop- enumerate: Конфликт регистра B — B одновременно счётчик DJNZ и индекс перечисления.
- reduce: Регистр A перезаписывается между двумя SMC-параметрами.
Каждая абстракция в Nanz компилируется в прямые инструкции Z80. Вот доказательство.
obj.method(args) разворачивается в method(obj, args) во время парсинга. Таблица методов — это map[string]map[string]methodInfo, к которой обращаются только во время парсинга.
Стоимость: ноль. CALL Acc_add идентичен рукописному вызову.
interface Animal { speak }
struct Dog {}
fun Dog.speak(self: Dog) -> u8 { return 1 }
Без vtable, без жирного указателя. При g_dog.speak() компилятор разрешает Dog во время парсинга и генерирует CALL Dog_speak.
Стоимость: ноль. 17T на прямой CALL против ~55T для диспетчеризации интерфейсов в стиле Go.
Ограничение: Конкретный тип должен быть известен статически. Настоящая динамическая диспетчеризация не реализована (и нарушила бы принцип нулевых накладных расходов).
Каждое |x| expr становится lambda_N — обычной функцией. При использовании в цепочках итераторов тело встраивается. При использовании как указателя на функцию — стандартный CALL.
Стоимость: ноль аллокаций, ноль структур замыканий. Накладные расходы CALL только при отсутствии встраивания.
fun Vec2.add(self: Vec2, ...) хранится как Vec2_add. Декорирование имён — единственные «накладные расходы» (во время компиляции, не во время выполнения). Сгенерированный ассемблер неотличим от свободной функции.
a + b на структурных типах диспетчеризуется в op_add(a, b) — обычный вызов функции. Без проверки типов во время выполнения.
QBE (https://c9x.me/compile/) — небольшой, быстрый компиляторный бэкенд, преобразующий QBE IL (простой SSA-формат) в нативный ассемблер x86-64 или arm64. Это НЕ целевая платформа Nanz — это инструмент тестирования.
source.nanz
├─→ HIR → MIR2 → Z80Codegen → MZE emulator → Result A
└─→ HIR → MIR2 → QBE IL → qbe → cc → native binary → Result B
If A ≠ B: Bug is in Z80 codegen (MIR2 semantics proven correct)
If A = B: Both pipelines agree — correctness confirmed
Конвейер QBE останавливается ДО Z80-специфичных шагов (оптимизация контрактов, распределение регистров). QBE выполняет собственное распределение регистров.
pkg/mir2qbe/e2e_test.go содержит 7 сквозных тестов:
| Тест | Что проверяет |
|---|---|
TestE2E_PLM_AbsDiff |
PL/M → HIR → MIR2 → QBE → native |
TestE2E_PLM_Fib |
Фибоначчи с циклом |
TestE2E_Nanz_SumArray |
Арифметика указателей, цикл ptr[i] |
TestE2E_Nanz_AbsDiff |
Поток управления (if/else) |
TestE2E_Nanz_StructFields |
Доступ к полям глобальных структур |
TestE2E_Nanz_UFCS |
Диспетчеризация методов на глобальных |
TestE2E_Nanz_Interface_ZeroCost |
Мономорфизация диспетчеризации интерфейсов |
Тесты автоматически пропускаются, если qbe отсутствует в PATH (exec.LookPath("qbe")).
Ключевые соответствия (pkg/mir2qbe/codegen.go):
- Все целочисленные типы (u8, u16, i8, i16, bool) → QBE
w(32-битное слово) ptr→ QBEl(64-битное длинное, нативный указатель)- Параметры блоков → phi-узлы (QBE использует SSA с phi, а не аргументы блоков)
- Z80-специфичные операции (SMC, push/pop, встроенный ассемблер) → пропускаются
См. Приложение D.
PL/M-80 послужил загрузочным корпусом для бэкенда MIR2. 26 файлов Intel 80 Tools (ALGOL-M, BASIC-80, макроассемблер ML80 и др.) — всё реальный производственный код 1970-х годов — предоставили 1 338 функций и 11 661 операторов для тестирования конвейера HIR→MIR2→Z80 ещё до появления Nanz.
Рабочий процесс:
Step 1: Parse PL/M corpus (26/26 files, 100% coverage)
Step 2: Lower to HIR → verify correctness
Step 3: Lower to MIR2 → verify optimizations
Step 4: Emit Z80 → compare with Intel's PL/M-80 V4.0 output
Step 5: --emit=nanz → generate Nanz source from HIR
Step 6: Write idiomatic Nanz by hand, guided by the mechanical translation
Это означает, что каждый узел HIR, каждая оптимизация MIR2 и каждый паттерн генерации кода Z80 были сначала проверены на реальном коде PL/M до того, как программы Nanz их использовали.
Флаг --emit=nanz запускает plm.Compile() → HIR → nanz.Print(), производя синтаксически корректный исходный код Nanz. Трансляция структурная — она точно сохраняет логику программы PL/M.
Исходный код PL/M:
SUM_ARRAY: PROCEDURE (PTR, N) BYTE;
DECLARE PTR ADDRESS;
DECLARE (N, S, I) BYTE;
S = 0;
I = 0;
DO WHILE I < N;
S = S + PTR(I);
I = I + 1;
END;
RETURN S;
END SUM_ARRAY;
Механический вывод Nanz (mz sum.plm --emit=nanz):
fun sum_array(ptr: u16, n: u8) -> u8 {
var s: u8 = 0
var i: u8 = 0
while i < n {
s = s + ptr[i]
i = i + 1
}
return s
}
Синтаксическое соответствие:
| PL/M-80 | Nanz | Примечания |
|---|---|---|
PROCEDURE name(a,b) BYTE; |
fun name(a: u8, b: u8) -> u8 |
Типы встроены |
DECLARE X BYTE; |
var x: u8 |
Соглашение о нижнем регистре |
DECLARE (A,B) WORD; |
var a: u16; var b: u16 |
Множественное объявление развёрнуто |
DO WHILE cond; ... END; |
while cond { ... } |
|
DO I = 0 TO N; ... END; |
for i in 0..n { ... } |
Счётный цикл |
DO CASE X; ... END; |
switch x { case 0: ...; } |
|
IF cond THEN s1; ELSE s2; |
if cond { ... } else { ... } |
|
ARR(I) |
arr[i] |
Индексная нотация |
CALL fn(a,b); |
fn(a, b) |
|
DECLARE X LITERALLY 'Y' |
(раскрыто до парсинга) | Макросы устранены |
Оба компилируются в идентичный Z80 ассемблер — тот же HIR, тот же MIR2, та же генерация кода.
Один и тот же алгоритм суммирования массива демонстрирует три уровня:
Уровень 1 — Механическая трансляция PL/M (индексированный цикл):
fun sum_array(ptr: u16, n: u8) -> u8 {
var s: u8 = 0
var i: u8 = 0
while i < n {
s = s + ptr[i] // random-access: ADD HL,DE per element (~15-20T)
i = i + 1
}
return s
}
Уровень 2 — Идиоматический Nanz (последовательный обход):
fun sum_array(ptr: ^u8, n: u8) -> u8 {
var s: u8 = 0
for x: u8 in ptr[0..n] { // sequential: INC HL per element (6T)
s = s + x
}
return s
}
Уровень 3 — Цепочка итераторов с замыканием (полностью слитая):
fun sum_array(ptr: ^u8, n: u8) -> u8 {
var s: u8 = 0
ptr.forEach(|x: u8| { s = s + x }, n) // DJNZ loop, s in register
return s
}
Стоимость на Z80 на элемент:
| Уровень | Ключевая инструкция | Тактов/элемент | Примечания |
|---|---|---|---|
| 1 (индексированный) | ADD HL, DE |
~64T | Вычисление ptr+i каждую итерацию |
| 2 (for-each) | INC HL |
~43T | Последовательное продвижение указателя |
| 3 (forEach) | INC HL + DJNZ |
~38T | Слитый, s в регистре |
На 3,5 МГц со 100 элементами: Уровень 1 = 1,83 мс, Уровень 3 = 1,09 мс — на 40% быстрее благодаря чисто синтаксическому изменению.
Наиболее значимая трансляция: ручные циклы DO WHILE из PL/M → цепочки итераторов Nanz.
PL/M: фильтрация + обработка
I = 0;
DO WHILE I < N;
V = BUF(I) * 2;
IF V > THRESHOLD THEN CALL PROCESS(V);
I = I + 1;
END;
Nanz механический:
var i: u8 = 0
while i < n {
let v = buf[i] * 2
if v > threshold { process(v) }
i = i + 1
}
Nanz идиоматический (слитая цепочка):
buf.map(|x: u8| (x * 2))
.filter(|x: u8| (x > threshold))
.forEach(|x: u8| { process(x) }, n)
Версия с цепочкой: один цикл DJNZ, ноль промежуточных массивов, все лямбды встроены. Три стадии слиты в ~6 инструкций Z80 на элемент.
PL/M: поиск максимума
MAX = 0;
I = 0;
DO WHILE I < N;
IF BUF(I) > MAX THEN MAX = BUF(I);
I = I + 1;
END;
Nanz идиоматический (forEach с захватом):
var m: u8 = 0
buf.forEach(|x: u8| {
if x > m { m = x }
}, n)
Захваченная переменная m передаётся как параметр блока через цикл DJNZ — она живёт в регистре, никогда не сбрасывается в память.
PL/M: преобразование на месте
I = 0;
DO WHILE I < N;
BUF(I) = BUF(I) + 2;
I = I + 1;
END;
Nanz идиоматический:
buf.mapInPlace(|x: u8| (x + 2), n)
Один цикл: загрузка, преобразование, обратная запись. Флаг MutateInPlace запускает обратную запись.
Эти возможности Nanz не имеют эквивалента в PL/M:
| Возможность Nanz | Эквивалент в PL/M | Почему это важно |
|---|---|---|
u8<0..255> типы с диапазоном |
Нет | Позволяет LUTGen (генерация таблиц во время компиляции) |
^Struct типизированные указатели |
BASED (нетипизированный) | Разрешение полей, авторазыменование, диспетчеризация UFCS |
interface Animal { speak } |
Нет | Контракт времени компиляции, диспетчеризация с нулевой стоимостью |
buf.map().filter().forEach() |
Ручной DO WHILE | Один слитый цикл, без промежуточных массивов |
|x| { s = s + x } захват замыканий |
Нет | Переменные, переносимые через цикл, как параметры блоков |
| Перегрузка операторов | Нет | a + b на структурных типах |
Реальное сравнение (из отчёта #036) — один и тот же исходный код PL/M, скомпилированный оригинальным компилятором Intel и нашим бэкендом MIR2:
| Функция | Intel PL/M-80 V4.0 | MIR2 Z80 | Экономия |
|---|---|---|---|
abs_diff |
33 байта | 12 байт | −64% |
fib |
47 байт | 31 байт | −34% |
| Итого | 80 байт | 43 байта | −46% |
Компилятор Intel хранит все параметры и локальные переменные в памяти (соглашение о вызовах 8080). ABI MIR2 с приоритетом регистров хранит значения в A/B/C/D/HL — ноль обращений к памяти в горячих циклах.
Каждый блок кода ниже — фактический вывод компилятора из mz <file>.nanz -o <file>.a80 на текущей сборке master (2026-03-10). Исходные файлы архивированы в reports/showcase-src/2026-03-10/.
Исходный код (ex1_struct.nanz):
struct Color { r: u8, g: u8, b: u8 }
global palette: Color
fun set_rgb(rv: u8, gv: u8, bv: u8) -> void {
palette.r = rv
palette.g = gv
palette.b = bv
}
fun get_r() -> u8 { return palette.r }
fun get_g() -> u8 { return palette.g }
fun get_b() -> u8 { return palette.b }
Скомпилированный Z80 (ex1_struct.a80):
set_rgb:
LD HL, palette ; one base load for all three fields 10T
LD (HL), C ; palette.r = rv 7T
INC HL ; 6T
LD (HL), D ; palette.g = gv 7T
INC HL ; 6T
LD (HL), E ; palette.b = bv 7T
RET ; 10T
get_r:
LD A, (palette__r) ; direct addressing via EQU label 13T
RET
get_g:
LD A, (palette__g)
RET
get_b:
LD A, (palette__b)
RET
; globals
palette:
DB 0, 0, 0
palette__r EQU palette
palette__g EQU palette + 1
palette__b EQU palette + 2
Оптимизация: Обнаружение HL-цепочки сливает три записи полей в одну LD HL + последовательность INC HL: 53T против 79T наивного варианта (−33%).
Исходный код (ex2_ufcs.nanz):
struct Acc { val: u8 }
global acc_g: Acc
fun Acc.add(self: ^Acc, amount: u8) -> u8 {
self.val = self.val + amount
return self.val
}
fun Acc.reset(self: ^Acc) -> void { self.val = 0 }
fun sum_two(a: u8, b: u8) -> u8 {
acc_g.reset() // UFCS → Acc_reset(&acc_g)
acc_g.add(a) // UFCS → Acc_add(&acc_g, a)
acc_g.add(b)
return acc_g.val
}
Скомпилированный Z80 (ex2_ufcs.a80):
Acc_add:
LD D, (HL) ; load self.val (HL = pointer to Acc)
LD A, D
ADD A, C ; + amount (C = 2nd param)
LD C, A
LD (HL), C ; store back
LD A, (HL) ; return value
RET
Acc_reset:
LD C, 0
LD (HL), C ; self.val = 0
RET
sum_two:
LD HL, acc_g ; addr_of(acc_g) — direct CALL, no vtable
CALL Acc_reset
LD HL, acc_g
LD A, C ; a
CALL Acc_add
LD HL, acc_g
LD A, mem ; b (known bug: register spill for 2nd arg)
CALL Acc_add
LD A, (acc_g__val) ; direct-address return
RET
; globals
acc_g:
DB 0
acc_g__val EQU acc_g
Исходный код (ex3_iface.nanz):
interface Animal { speak }
struct Dog {}
struct Cat {}
global g_dog: Dog
global g_cat: Cat
fun Dog.speak(self: Dog) -> u8 { return 1 }
fun Cat.speak(self: Cat) -> u8 { return 2 }
fun demo() -> u8 { return g_dog.speak() }
Скомпилированный Z80 (ex3_iface.a80):
Dog_speak:
LD C, 1
LD A, C
RET
Cat_speak:
LD C, 2
LD A, C
RET
demo:
LD HL, g_dog
CALL Dog_speak ; direct CALL — no vtable, no indirection
RET
17T на диспетчеризацию (только CALL). Диспетчеризация интерфейсов в стиле Go: ~55T.
Исходный код (ex4a_abs_diff.nanz):
fun abs_diff(a: u8, b: u8) -> u8 {
if a == b { return 0 }
if a < b { return b - a }
return a - b
}
Скомпилированный Z80 (ex4a_abs_diff.a80):
abs_diff:
SUB D ; A = a - b, carry set if a < b
LD C, A ; (regression: contract assigned b→D, result→C)
RET NC ; a >= b → return a-b
.abs_diff_if_then3:
NEG ; A = -(a-b) = b-a
RET
BranchEquiv устранил защиту a == b. CondRetSink поднял sub перед cmp. CmpSubCarry устранил CP. Результат: 4 основные инструкции.
Исходный код (ex5_lut.nanz):
fun popcount(x: u8<0..255>) -> u8 {
var n: u8 = 0
var v: u8 = x
while v != 0 {
n = n + (v & 1)
v = v >> 1
}
return n
}
Скомпилированный Z80 (ex5_lut.a80):
popcount:
LD H, popcount_lut^H ; 7T — page base (high byte only)
LD L, C ; 4T — index (param in C)
LD A, (HL) ; 7T — table lookup
RET
ALIGN 256
popcount_lut:
DB 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4, ... ; 256 bytes
18T всего. Цикл while никогда не выполняется во время работы — он был вычислен во время компиляции виртуальной машиной MIR2 для всех 256 входов.
Исходный код (ex6_foreach.nanz):
fun sum_chain(buf: ^u8, n: u8) -> u8 {
var s: u8 = 0
buf.forEach(|x: u8| { s = (s + x) }, n)
return s
}
fun max_chain(buf: ^u8, n: u8) -> u8 {
var m: u8 = 0
buf.forEach(|x: u8| {
if x > m { m = x }
}, n)
return m
}
Скомпилированный Z80 (ex6_foreach.a80):
sum_chain:
LD D, 0 ; s = 0
LD B, C ; B = n (DJNZ counter)
LD C, D ; C = s
.sum_chain_fe_head1:
LD A, B
AND A ; n == 0?
JRS Z, .sum_chain_fe_exit4
.sum_chain_fe_body2:
LD D, (HL) ; x = *buf
LD A, C
ADD A, D ; s + x (lambda body inlined)
LD C, A
.sum_chain_fe_cont3:
INC HL ; buf++
DJNZ .sum_chain_fe_body2
.sum_chain_fe_exit4:
LD A, C
RET
max_chain:
LD D, 0 ; m = 0
LD B, C ; B = n
LD C, D ; C = m
.max_chain_fe_head1:
LD A, B
AND A
JRS Z, .max_chain_fe_exit4
.max_chain_fe_body2:
LD D, (HL) ; x = *buf
LD A, C
CP D ; m > x?
JRS NC, .max_chain_trmp0
.max_chain_if_then5:
.max_chain_fe_cont3:
INC HL
DEC B
LD C, D ; m = x (captured var update)
JRS .max_chain_fe_head1
.max_chain_fe_exit4:
LD A, C
RET
.max_chain_trmp0:
LD D, C
JRS .max_chain_if_join6
sum_chain: 38T/элемент. Без CALL лямбды. Захваченная s живёт в C на протяжении всего цикла.
Исходный код (ex7_mapinplace.nanz):
fun add2_inplace(buf: ^u8, n: u8) -> void {
buf.mapInPlace(|x: u8| (x + 2), n)
}
Скомпилированный Z80 (ex7_mapinplace.a80):
add2_inplace:
LD B, C ; B = n
.add2_inplace_fe_head1:
LD A, B
AND A
JRS Z, .add2_inplace_fe_exit4
.add2_inplace_fe_body2:
LD C, (HL) ; load element
LD D, 2 ; (dead load — regression)
INC C ; +1
INC C ; +1 (INC C × 2 instead of ADD A,2)
LD (HL), C ; write back
.add2_inplace_fe_cont3:
INC HL
DJNZ .add2_inplace_fe_body2
.add2_inplace_fe_exit4:
RET
lambda_0: ; standalone lambda emitted but never called
LD C, 2
ADD A, C
LD C, A
RET
Известная регрессия: LD D, 2 мёртвая инструкция, INC C; INC C заменяет ADD A, 2 (51T вместо 40T/элемент). Автономная lambda_0 тоже мёртвый код.
Исходный код (ex8_gcd.nanz):
fun gcd(a: u8, b: u8) -> u8 {
while a != b {
if a > b { a = a - b }
else { b = b - a }
}
return a
}
Скомпилированный Z80 (ex8_gcd.a80):
gcd:
.gcd_loop_head1:
LD A, C
CP D ; a == b?
JRS Z, .gcd_trmp0
.gcd_loop_body2:
LD A, D
CP C ; a > b?
JRS NC, .gcd_if_else6
.gcd_if_then4:
LD A, C
SUB D ; a = a - b
LD C, A
.gcd_if_join5:
JRS .gcd_loop_head1
.gcd_if_else6:
LD A, D
SUB C ; b = b - a
LD D, A
JRS .gcd_if_join5
.gcd_loop_exit3:
LD A, D
RET
.gcd_trmp0:
LD A, C
LD C, D
LD D, A
JRS .gcd_loop_exit3
Объединитель копий фазы 6c устранил все накладные расходы копирования на границах блоков в горячем цикле. Только выходной трамплин (холодный путь) содержит перестановки регистров.
| Язык | Расширение | Парсер | Бэкенд | Статус |
|---|---|---|---|---|
| MinZ | .minz |
pkg/parser/participle/ |
MIR1 → старая генерация кода | Заморожен |
| Nanz | .nanz |
pkg/nanz/parse.go |
HIR → MIR2 → Z80 | Активен |
| PL/M-80 | .plm |
pkg/plm/ |
HIR → MIR2 → Z80 | Активен |
Маршрутизатор в cmd/minzc/main.go:
if ext == ".plm" || ext == ".nanz" {
return compileViaHIR(src, ext)
} else {
// old MIR1 path for .minz
}Nanz — новые программы для Z80/CP/M, современный синтаксис, LUTGen, распределение PBQP.
MinZ — существующие .minz-программы, которые работают. Есть метафункции (@define, @print), ещё отсутствующие в Nanz.
PL/M-80 — перенос устаревшего ПО CP/M. 100% корпуса Intel 80 Tools парсится.
В настоящее время Nanz не имеет:
- Препроцессорных макросов
@define - Оптимизированного строкового вывода
@print - Строковой интерполяции в стиле Ruby (
"Hello #{name}") - Синтаксиса множественных возвращаемых значений (MIR2 поддерживает, парсер — нет)
- Перегрузки функций (MinZ имеет; Nanz требует уникальных имён)
@externс аннотациями классов регистров (документировано, но ещё не парсится)
Старый конвейер MinZ (флаг -b) поддерживает несколько целей. Они НЕ доступны для Nanz (который всегда проходит через MIR2 → Z80):
| Бэкенд | Флаг | Статус | Вывод |
|---|---|---|---|
| Z80 | -b z80 |
Продакшен | .a80 |
| 6502 | -b 6502 |
Бета | .s |
| 68000 | -b m68k |
Альфа | .s |
| i8080 | -b i8080 |
Бета | .s |
| Game Boy | -b gb |
Активен | .s |
| WASM | -b wasm |
Альфа | .wat |
| C | -b c |
Бета | .c |
| Crystal | -b crystal |
Бета | .cr |
| LLVM | -b llvm |
Планируется | .ll |
Они используют старый IR (MIR1), не MIR2. Долгосрочный план — отказаться от MIR1 и направить все фронтенды через HIR → MIR2.
module = top_decl*
top_decl = struct_decl
| interface_decl
| global_decl
| fun_decl
| '@extern' 'fun' fun_decl_inner
struct_decl = 'struct' IDENT '{' field_decl* '}'
field_decl = IDENT ':' type ','?
interface_decl = 'interface' IDENT '{' method_name* '}'
method_name = 'fun'? IDENT ','?
global_decl = 'global' IDENT ':' type at_clause? init_clause?
at_clause = 'at' '(' expr ')'
init_clause = '=' ('[' expr (',' expr)* ']' | expr)
fun_decl = ('fun' | 'fn') fun_decl_inner
fun_decl_inner = (op_symbol | IDENT ('.' IDENT)?) '(' params ')' ('->' type)?
('{' stmt* '}' | /* extern: no body */)
params = (IDENT ':' type (',' IDENT ':' type)*)?
op_symbol = '+' | '-' | '*' | '/' | '%'
| '==' | '!=' | '<' | '<=' | '>' | '>='
| '&' | '|' | '^'
type = '^' type
| '[' type ';' INT ']'
| 'u8' ('<' INT '..' INT '>')?
| 'u16' ('<' INT '..' INT '>')?
| 'i8' | 'i16' | 'bool' | 'void' | 'ptr'
| IDENT (* struct or interface name *)
stmt = var_decl | let_decl | if_stmt | while_stmt
| for_stmt | return_stmt | 'break' | 'continue'
| switch_stmt | block | expr_stmt
var_decl = 'var' IDENT ':' type at_clause? ('=' (array_init | expr))?
let_decl = 'let' IDENT (':' type)? '=' expr
array_init = '[' expr (',' expr)* ']'
if_stmt = 'if' expr block ('else' block)?
while_stmt = 'while' expr block
for_stmt = 'for' IDENT (':' type)? 'in'
(expr '[' expr? '..' expr ']' block (* ForEachStmt *)
| expr '..' expr block) (* ForRangeStmt *)
return_stmt = 'return' expr?
switch_stmt = 'switch' expr '{' case_clause* default_clause? '}'
case_clause = 'case' INT ':' stmt*
default_clause = 'default' ':' stmt*
block = '{' stmt* '}'
expr_stmt = expr ('=' expr)? (* assignment or bare call *)
expr = binary_expr
binary_expr = unary_expr (binop binary_expr)*
binop = '+' | '-' | '*' | '/' | '%' | '&' | '|' | '^'
| '<<' | '>>' | '==' | '!=' | '<' | '<=' | '>' | '>='
unary_expr = '-' unary_expr
| '!' unary_expr
| '~' unary_expr
| '&' IDENT
| postfix_expr
postfix_expr = primary
( '^' (* dereference *)
| '[' expr ']' (* index *)
| '.' IDENT (* field access *)
| '.' IDENT '(' args ')' (* UFCS method call *)
| '(' args ')' (* function call *)
)*
primary = INT | 'true' | 'false' | STRING
| ('u8' | 'u16' | 'i8' | 'i16') '(' expr ')' (* cast *)
| '@ptr' '(' type ',' expr ')'
| '|' lambda_params '|' (block | expr)
| '(' expr ')'
| IDENT
lambda_params = (IDENT (':' type)? (',' IDENT (':' type)?)*)?
args = (expr (',' expr)*)?Лексические замечания:
- Комментарии:
//(строчные) и/* */(блочные) - Пробелы не значимы
- Целые числа: десятичные или
0x/0Xшестнадцатеричные - Строки: в двойных кавычках, без escape-последовательностей
| Регистр | Класс | Стоимость | Примечания |
|---|---|---|---|
| A | ClassAcc | 0T | Аккумулятор АЛУ, возвращаемое значение, 1-й параметр u8 |
| B | ClassCounter | 0T | Счётчик DJNZ, 3-й параметр |
| C, D, E, H, L | ClassGeneral | 0T | Общие 8-битные |
| HL | ClassPointer | 0T | Основной указатель, 1-й параметр u16/ptr, возврат |
| DE | ClassIndex | 0T | 2-й параметр u16, источник LDIR |
| BC | ClassPair | 0T | 3-й параметр u16 |
| IX | ClassIX | 8T | Указатель переполнения (+4T DD-префикс на каждый доступ) |
| IY | ClassIY | 8T | Редко используется (зарезервирован системой на некоторых платформах) |
| IXH/IXL | ClassIXY8 | 8T | Недокументированные 8-битные половины |
| $F0xx | ClassMem | 26T | Расширение «регистрового файла» в памяти |
| Уровень | Классы | Стоимость | Механизм |
|---|---|---|---|
| 0 — Первичные | Acc, Counter, General, Pointer, Index, Pair, Flag | 0T | Прямое использование |
| 1 — IX/IY | IX, IY, IXY8 | 4-8T | DD/FD-префикс |
| 2 — Теневые | Shadow, AccShadow | 8T | EXX / EX AF,AF' |
| 3 — Стек | Stack | 21T | PUSH + POP |
| 4 — Память | Mem | 26T | LD (addr) / LD addr |
ClassFlag особый: он представляет флаги процессора Z80 (Z, CY и др.) и стоит 0T. Булевы результаты сравнений хранятся во флагах без материализации в регистр.
mz source.nanz -o output.a80 # compile to Z80 assembly
mz source.nanz -o output.a80 --target=cpm # target CP/M
mz source.nanz -o output.a80 --target=spectrum # target ZX Spectrum
mz source.nanz -o output.a80 --target=agon # target Agon Light 2mz source.nanz --emit=hir # HIR dump
mz source.nanz --emit=mir2-raw # MIR2 before optimization
mz source.nanz --emit=mir2 # MIR2 after optimization
mz source.plm --emit=nanz # PL/M → Nanz translationmz source.nanz --disable-optimize # disable all optimizations
mz source.nanz --disable-ir-opt # disable MIR-level opts
mz source.nanz --disable-asm-opt # disable peephole
mz source.nanz --disable-smc # disable self-modifying code
mz source.nanz --compile-trace # show all optimization stepscd minzc
# All Go tests (23+ packages)
go test ./pkg/... -vet=off
# Nanz parser tests only
go test ./pkg/nanz/... -vet=off -v
# MIR2 tests (LUTGen, contracts, PBQP)
go test ./pkg/mir2/... -vet=off -v
# QBE E2E tests (requires qbe and cc in PATH)
go test ./pkg/mir2qbe/... -vet=off -v| Файл | Описание |
|---|---|
examples/nanz/01_sum_array.nanz |
Цикл while с ptr[i] |
examples/nanz/02_sum_array_idiomatic.nanz |
For-each и итератор forEach |
examples/nanz/03_filter_map_chain.nanz |
Полная цепочка map/filter/forEach |
examples/nanz/04_lut_popcount.nanz |
Генерация LUT через тип с диапазоном |
examples/nanz/05_four_pointers.nanz |
PBQP: 4 регистра ClassPointer |
examples/nanz/06_pbqp_weighted.nanz |
Взвешенное распределение стоимости |
examples/nanz/07_ix_load_store.nanz |
Адресация переполнения IX |
QBE необходим только для запуска сквозных тестов корректности (pkg/mir2qbe/). Он НЕ нужен для обычной компиляции Nanz.
Linux (сборка из исходников):
git clone git://c9x.me/qbe.git
cd qbe
make
sudo cp qbe /usr/local/bin/macOS:
brew install qbeПроверка:
qbe --version # should print version
echo 'export function w $main() { @start ret 0 }' | qbeКомпилятор C также необходим (любой C99-компилятор: gcc, clang). Обычно предустановлен.
Если qbe отсутствует в PATH, сквозные тесты автоматически пропускаются с t.Skip("qbe not in PATH").
Внешняя установка не требуется. MZA — часть инструментария MinZ:
cd minzc && make mza
# or: make install-user (installs all tools to ~/.local/bin/)cd minzc && make mzeИспользуется для: запуска скомпилированных Z80-бинарников, вычисления констант внутри компилятора (LUTGen, BranchEquiv).
| Проблема | Статус | Обходной путь |
|---|---|---|
@extern с аннотациями params=/returns= |
Не реализовано | Используйте базовый @extern fun (автоматическое назначение регистров) |
| Перегрузка функций | Не реализовано | Используйте уникальные имена (abs_diff, abs_diff_u16) |
| Множественные возвращаемые значения | Не парсится | MIR2 поддерживает; парсер — нет |
| Escape-последовательности строк | Не реализовано | Нет \n, \t и т.д. |
Блоки встроенного ассемблера @asm |
Не реализовано | Используйте @extern с обёртками asm |
| Проблема | Статус | Подробности |
|---|---|---|
applySubSwapNeg для u16 |
Ошибка | Навязывает ClassAcc (8-бит) для 16-битного результата NEG. Отсутствует проверка Ty.Width() <= 8. |
| Глобальные структуры нулевого размера | Ошибка | struct Dog {} не генерирует данных; символ неопределён при компоновке. Исправление: генерировать Dog: EQU $. |
Маркер LD A, mem |
Ошибка | При неудаче распределения регистров в ассемблер выводится строка "mem" буквально. |
| Caller-save для 2-го аргумента | Ошибка | Второй аргумент при повторных вызовах может быть затёрт. |
| mapInPlace constant-add | Регрессия | ADD A, imm не срабатывает, когда элемент в C (не в A). |
| Проблема | Статус | Подробности |
|---|---|---|
enumerate |
Сломан на Z80 | B = счётчик конфликтует с B = индекс перечисления |
reduce |
Сломан на Z80 | A перезаписывается между двумя SMC-параметрами |
| Захват замыканий в неслитых цепочках | Неопределённое поведение | Лямбды, переданные как указатели на функции, не могут захватывать внешние локальные переменные |
| Проблема | Статус | Подробности |
|---|---|---|
| Регистры в памяти | Производительность | ~207T фактических против ~43T идеальных на элемент итератора при сбросе регистров в $F0xx |
| Дрейф оптимизатора контрактов | Известно | Может назначать неоптимальные классы (например, b → D вместо b → B для abs_diff) |
| Стоимости рёбер PBQP | Отложено | Коррелированные решения о распределении (BC★ для LUT) ещё не моделируются |
Nanz: современный синтаксис, абстракции с нулевой стоимостью, производительность Z80.
Конвейер: .nanz → nanz.Parse() → *hir.Module → hir.LowerModule() → *mir2.Module → оптимизация → распределение → Z80Codegen → .a80