Skip to content

Latest commit

 

History

History
1866 lines (1410 loc) · 84 KB

File metadata and controls

1866 lines (1410 loc) · 84 KB

Язык Nanz: Полное руководство (v2)

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/


Оглавление

  1. Что такое Nanz?
  2. Справочник по синтаксису
  3. Система типов
  4. Конвейер компиляции
  5. Промежуточное представление MIR2
  6. Проходы оптимизации
  7. Генерация кода для Z80
  8. Цепочки итераторов и слияние
  9. Абстракции с нулевой стоимостью
  10. Оракул корректности QBE
  11. PL/M-80: Формирование корпуса и идиоматический перевод
  12. Галерея скомпилированного вывода
  13. Связь с MinZ и PL/M

Глава 1: Что такое Nanz?

Nanz (расширение .nanz) — активный фронтенд-язык компилятора MinZ. Он статически типизирован, императивен и предназначен для двух аудиторий:

  1. Разработчики, пишущие программы для Z80 / ретро-платформ, которым нужен современный синтаксис с абстракциями нулевой стоимости.
  2. Команда компилятора MinZ, разрабатывающая и тестирующая бэкенд MIR2.

1.1 Конвейер компиляции

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.a80

1.2 Nanz vs. MinZ

MinZ (.minz) — оригинальный фронтенд со своим парсером и генератором кода, нацеленным на старый IR MIR1. Этот конвейер заморожен — он работает, но не развивается.

Nanz нацелен на MIR2, который обеспечивает:

  • SSA-подобный поток данных с параметрами блоков (стиль Cranelift/MLIR, не phi-узлы)
  • Распределитель регистров PBQP со взвешенными векторами стоимости
  • Генерация LUT: вычисление во время компиляции для чистых функций с ограниченной областью определения
  • Межпроцедурная оптимизация соглашений о вызовах
  • Эмулятор Z80, используемый как вычислитель констант и средство доказательства эквивалентности ветвлений

Правило: Новые ретро-программы пишите на Nanz. Существующие .minz-программы оставляйте как есть.

1.3 Nanz vs. PL/M-80

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 в исходный код Nanz

1.4 Философия проектирования

Nanz намеренно минималистичен. Грамматика умещается на одном экране. Без сборщика мусора, без рантайма, без динамической диспетчеризации. Каждая абстракция — лямбды, итераторы, методы структур, интерфейсы — компилируется в прямые инструкции Z80 без накладных расходов сверх того, что вы написали бы вручную.


Глава 2: Справочник по синтаксису

Парсер — рукописный рекурсивный нисходящий парсер с выражениями по методу Пратта (pkg/nanz/parse.go). Исходный код: ~2000 строк на Go.

2.1 Структура модуля

Исходный файл 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 }

Импортов нет; компоновка выполняется на уровне ассемблера.

2.2 Типы

Синтаксис Ширина Отображение на 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).

2.3 Функции

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 }
}

2.4 Внешние функции

Функции, реализованные за пределами модуля Nanz, объявляются с помощью @extern:

@extern fun process(x: u8) -> void
@extern fun rom_print(s: ptr) -> void

Тело опускается. Компилятор назначает классы регистров параметрам в соответствии со стандартным соглашением о вызовах.

Статус: Аннотированная форма @extern("sym", params=[z80_a], returns=[z80_a]), описанная в некоторой документации, ещё не реализована в парсере. В настоящее время функции @extern получают только автоматическое назначение регистров.

2.5 Объявления переменных

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).

2.6 Глобальные переменные

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.

2.7 Литералы

42          // decimal — u8 if ≤255, u16 otherwise
0xFF        // hexadecimal
0           // zero (u8)
true        // bool
false       // bool
"hello"     // string → ptr to NUL-terminated bytes (interned)

2.8 Операторы

Арифметические (левоассоциативные): + - * / %

Побитовые: & | ^ (XOR) ~ (NOT, унарный) << >>

Сравнения (результат — bool): == != < <= > >=

Логические: ! (NOT, унарный)

Приоритет (от высшего к низшему):

Уровень Операторы
8 * / %
7 + -
6 << >>
5 < <= > >=
4 == !=
3 &
2 ^ (XOR)
1 |

Скобки переопределяют приоритет: (a + b) * c.

2.9 Операции с указателями

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.

2.10 Приведение типов

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; компилятор не будет это скрывать.

2.11 Поток управления

// 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 или }.

2.12 Структуры

struct Point { x: u8, y: u8 }
struct Vec3d { x: u16, y: u16, z: u8 }

Поля размещаются последовательно, без выравнивания. Смещения вычисляются во время парсинга:

  • Point.x → смещение 0, Point.y → смещение 1
  • Vec3d.y → смещение 2, Vec3d.z → смещение 4

Значения структур всегда передаются по указателю (HL на Z80). Доступ к полям: Load(ptr + offset).

2.13 Методы структур и UFCS

Методы объявляются с синтаксисом 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 (во время парсинга):

  1. Парсер видит base.method(args).
  2. Проверяет exprTy(base) — если это структура, ищет в methodTable[structName][method].
  3. Если base — указатель ^Struct, проверяет varPtrElem[name] для определения типа структуры.
  4. Если base — переменная с типом интерфейса, вызывает findImplementors().
  5. Перезаписывает в 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 с полной информацией о типе возврата.

2.14 Перегрузка операторов

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, независимо от перегрузок в области видимости. Перегрузки срабатывают только когда левый операнд — тип структуры.

2.15 Интерфейсы

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 разрешается к единственному реализатору.

2.16 Лямбды

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. Ноль кучи, ноль сбросов.
  • Неслитые лямбды (указатели на функции): не могут захватывать внешние локальные переменные — нет фрейма стека. Доступны только глобальные.

2.17 Методы итераторов (UFCS на указателях)

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.


Глава 3: Система типов

3.1 Типы MIR2

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
}

3.2 Назначение классов регистров

Параметрам назначаются классы регистров на основе позиции и типа (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

Возвращаемые значения: u8ClassAcc (A), u16/ptrClassPointer (HL).

3.3 Размещение структур

Поля упакованы последовательно, без выравнивания:

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.


Глава 4: Конвейер компиляции

Полный конвейер оркестрируется модулем pkg/pipeline/pipeline.go.

4.1 Стадии конвейера

func CompileHIRSteps(hm *hir.Module) (Steps, error)

Стадия 1 — Понижение HIR → MIR2 (hir.LowerModule):

  • Именованные переменные → виртуальные регистры (новый регистр на каждое присваивание)
  • Структурный поток управления → базовые блоки с параметрами блоков
  • Мутации в циклах → параметры блоков в заголовках циклов (обнаруживаются через scanMutations)
  • ForEachStmt → цикл указатель+счётчик, дружественный к DJNZ

Стадия 2 — Оптимизации для каждой функции (по порядку):

  1. EliminateDeadBlocks — удаление недостижимых блоков
  2. ReorderBlocks — улучшение проваливания (fall-through)
  3. Конвейер констант (итерируется до фиксированной точки):
    • PropagateConstants — отслеживание констант через перемещения
    • FoldConstants — вычисление чистых операций во время компиляции
    • SimplifyIdentitiesPtrAdd(x, 0)Move(x) и т.д.
    • ConstantCallElim — свёртка вызовов с константными аргументами через ВМ
  4. DeadStoreElim — удаление чистых инструкций с неиспользуемыми результатами (итеративно)
  5. BranchEquiv — устранение ветвлений на основе ВМ (доказывает избыточность защит)
  6. CondRetSink — подъём тривиальных else-блоков в условные возвраты

Стадия 3 — На уровне модуля: LUTGen:

  • Чистые функции с параметрами-диапазонами → таблицы поиска времени компиляции

Стадия 4 — Верификация (Verify):

  • Уникальные метки блоков, корректные терминаторы, согласованность типов

Стадия 5 — Межпроцедурная оптимизация:

  • OptimizeContracts — жадное ДП на графе вызовов для назначения классов регистров
  • ApplyContracts — перезапись сигнатур функций

Стадия 6 — Распределение регистров:

  • ComputeLiveness — обратный анализ потока данных до фиксированной точки
  • PBQPAllocate — взвешенное назначение виртуальных регистров физическим регистрам Z80

Стадия 7 — Генерация кода:

  • Z80Codegen → текст ассемблера

4.2 Форматы вывода

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)

Глава 5: Промежуточное представление MIR2

MIR2 — основной IR. Он использует аргументы блоков (стиль Cranelift/MLIR) вместо phi-узлов.

5.1 Структура модуля

Module
├── Funcs[] — каждая с Blocks[]
├── Globals[] — данные уровня модуля
├── Strings[] — интернированный пул строк
└── Structs[] — определения типов структур

5.2 Инструкции

Каждая инструкция: %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 (примитивы самомодифицирующегося кода)

5.3 Параметры блоков

block @loop_head(%ptr: u16 [pointer], %cnt: u8 [counter]):
    %cond = cmp.gt %cnt, %limit : bool [flag]
    br_if %cond, @exit(), @body(%ptr, %cnt)

Аргументы блоков определяют регистры на входе. Аргументы передаются по каждому ребру (переход/ветвление). Это заменяет phi-узлы и делает параллельные копии явными для каждого ребра.

5.4 Терминаторы

Терминатор Семантика Вывод 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.


Глава 6: Проходы оптимизации

6.1 Конвейер констант (фиксированная точка)

Четыре прохода итерируются до стабилизации:

  1. PropagateConstants — если %r = const 42, все использования %r заменяются на 42.
  2. FoldConstantsconst(3) + const(5)const(8).
  3. SimplifyIdentitiesPtrAdd(x, Const(0))Move(x). Устраняет избыточную арифметику указателей с нулевым смещением (критично для приёмников ^Struct).
  4. ConstantCallElim — чистая функция со всеми константными аргументами → вычисление через ВМ MIR2.

6.2 Устранение мёртвых записей

Удаляет чистые инструкции, результаты которых нигде не используются. Итерируется до фиксированной точки, поскольку удаление одной мёртвой инструкции может сделать мёртвыми её источники.

Никогда не удаляются: OpStore, OpCall, OpCallIndirect, OpAsm, OpPatch (побочные эффекты).

6.3 BranchEquiv (устранение ветвлений на основе ВМ)

Доказывает избыточность условных ветвлений путём исчерпывающего тестирования на ВМ.

Пример: abs_diff с защитой if a == b { return 0 }. BranchEquiv прогоняет все 256 входов (v, v) через исходную и модифицированную функции. Обе возвращают 0 → ветвление доказуемо избыточно → BrIf(eq, @zero, @diff) заменяется на Jmp(@diff).

Корректность для u8: исчерпывающий тест на 256 значениях. Для более широких типов: эвристическая выборка (безопасно для расширения в будущем).

6.4 CondRetSink

Находит 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.

6.5 LUTGen (на уровне модуля)

Чистые функции с одним параметром-диапазоном → таблица поиска.

Критерии: 1 параметр типа u8<lo..hi> или u16<lo..hi>, диапазон ≤ 256, одно возвращаемое значение, без вызовов extern, без записи в глобальные.

Процесс:

  1. ВМ-вычисление функции для каждого входа в диапазоне
  2. Генерация выровненной по странице таблицы DB как глобальной
  3. Замена тела функции на поиск по таблице:
    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 инструкциями.

6.6 Распределитель регистров PBQP

Заменяет старый жадный распределитель. 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

6.7 Объединение копий после распределения

После того как PBQP назначает физические расположения, coalesceAllocResult устраняет избыточные копии на границах блоков:

  • Сбор рёбер аффинности из OpMove и пар параметр блока↔аргумент.
  • Однопроходная перекраска: если ни один сосед в графе интерференции не использует расположение цели, перекрасить для совпадения. Блокировка recolored предотвращает циклы вращения в phi-сетях циклов.

6.8 Межпроцедурная оптимизация контрактов

OptimizeContracts выполняет жадное ДП на графе вызовов:

  1. Топологическая сортировка (сначала листья).
  2. Для каждой функции: перебор кандидатных векторов классов регистров.
  3. Стоимость = внутренняя стоимость адаптера + стоимость рёбер по всем вызывающим.
  4. Выбор назначения с минимальной стоимостью.

Это выбирает соглашения о вызовах для каждой функции глобально, уменьшая перемещения регистров на месте вызовов.


Глава 7: Генерация кода для Z80

pkg/mir2/z80codegen.go — конвертирует распределённый MIR2 в текст ассемблера.

7.1 Генерация инструкций

Операция 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

7.2 Адресация IX/IY

Когда 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) вместо этого.

7.3 Глазковые оптимизации при генерации кода

Подавление мёртвых констант: 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/StoreLD A, (sym__field) напрямую (13T).

Обнаружение HL-цепочек: Несколько последовательных записей полей в одну и ту же структуру → одна LD HL, sym + цепочка LD (HL), r; INC HL (экономия на перезагрузке HL).


Глава 8: Цепочки итераторов и слияние

8.1 Четыре метода итераторов

Метод Сигнатура Семантика
forEach(lambda, n) Выполнить лямбду для n элементов Терминальный
map(lambda) Преобразовать каждый элемент Промежуточный
filter(lambda) Оставить элементы, где лямбда возвращает true Промежуточный
mapInPlace(lambda, n) Преобразовать и записать обратно Терминальный

Они распознаются как UFCS на выражениях-указателях. Понижатель HIR tryLowerIterChain сливает цепочки.

8.2 Слияние

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 лямбды. Три стадии, один цикл.

8.3 Захват замыканий в слитых цепочках

fun sum(buf: ^u8, n: u8) -> u8 {
    var s: u8 = 0
    buf.forEach(|x: u8| { s = s + x }, n)
    return s
}

s — свободная переменная в лямбде. Компилятор:

  1. Обнаруживает свободную ссылку через hasFreeVars().
  2. Пропускает автономное понижение lambda_N.
  3. Передаёт s как параметр блока через цикл DJNZ.

Результат: s живёт в регистре (например, C) на протяжении всего цикла — ноль сбросов, ноль кучи.

8.4 Стоимость на элемент (sum_chain)

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 накладных расходов до начала какой-либо работы.

8.5 Обратная запись mapInPlace

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

8.6 Известные неработающие итераторы

  • enumerate: Конфликт регистра B — B одновременно счётчик DJNZ и индекс перечисления.
  • reduce: Регистр A перезаписывается между двумя SMC-параметрами.

Глава 9: Абстракции с нулевой стоимостью

Каждая абстракция в Nanz компилируется в прямые инструкции Z80. Вот доказательство.

9.1 UFCS — нулевая стоимость

obj.method(args) разворачивается в method(obj, args) во время парсинга. Таблица методов — это map[string]map[string]methodInfo, к которой обращаются только во время парсинга.

Стоимость: ноль. CALL Acc_add идентичен рукописному вызову.

9.2 Интерфейсы — нулевая стоимость

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.

Ограничение: Конкретный тип должен быть известен статически. Настоящая динамическая диспетчеризация не реализована (и нарушила бы принцип нулевых накладных расходов).

9.3 Лямбды — нулевая стоимость

Каждое |x| expr становится lambda_N — обычной функцией. При использовании в цепочках итераторов тело встраивается. При использовании как указателя на функцию — стандартный CALL.

Стоимость: ноль аллокаций, ноль структур замыканий. Накладные расходы CALL только при отсутствии встраивания.

9.4 Методы структур — нулевая стоимость

fun Vec2.add(self: Vec2, ...) хранится как Vec2_add. Декорирование имён — единственные «накладные расходы» (во время компиляции, не во время выполнения). Сгенерированный ассемблер неотличим от свободной функции.

9.5 Перегрузка операторов — нулевая стоимость

a + b на структурных типах диспетчеризуется в op_add(a, b) — обычный вызов функции. Без проверки типов во время выполнения.


Глава 10: Оракул корректности QBE

10.1 Что такое QBE

QBE (https://c9x.me/compile/) — небольшой, быстрый компиляторный бэкенд, преобразующий QBE IL (простой SSA-формат) в нативный ассемблер x86-64 или arm64. Это НЕ целевая платформа Nanz — это инструмент тестирования.

10.2 Конвейер оракула корректности

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 выполняет собственное распределение регистров.

10.3 Сквозные тесты

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")).

10.4 Трансляция MIR2 → QBE

Ключевые соответствия (pkg/mir2qbe/codegen.go):

  • Все целочисленные типы (u8, u16, i8, i16, bool) → QBE w (32-битное слово)
  • ptr → QBE l (64-битное длинное, нативный указатель)
  • Параметры блоков → phi-узлы (QBE использует SSA с phi, а не аргументы блоков)
  • Z80-специфичные операции (SMC, push/pop, встроенный ассемблер) → пропускаются

10.5 Установка QBE

См. Приложение D.


Глава 11: PL/M-80: Формирование корпуса и идиоматический перевод

11.1 Роль PL/M в создании экосистемы Nanz

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 их использовали.

11.2 Механическая трансляция: mz program.plm --emit=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, та же генерация кода.

11.3 От механического к идиоматическому: три уровня

Один и тот же алгоритм суммирования массива демонстрирует три уровня:

Уровень 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% быстрее благодаря чисто синтаксическому изменению.

11.4 Паттерны PL/M → цепочки итераторов Nanz

Наиболее значимая трансляция: ручные циклы 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 запускает обратную запись.

11.5 Что PL/M не может выразить

Эти возможности 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 на структурных типах

11.6 PL/M-80 V4.0 vs MIR2: качество кода

Реальное сравнение (из отчёта #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 — ноль обращений к памяти в горячих циклах.


Глава 12: Галерея скомпилированного вывода

Каждый блок кода ниже — фактический вывод компилятора из mz <file>.nanz -o <file>.a80 на текущей сборке master (2026-03-10). Исходные файлы архивированы в reports/showcase-src/2026-03-10/.

12.1 Доступ к полям структуры — оптимизация HL-цепочек

Исходный код (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%).

12.2 Диспетчеризация методов UFCS — без Vtable

Исходный код (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

12.3 Диспетчеризация интерфейсов с нулевой стоимостью

Исходный код (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.

12.4 abs_diff — пять проходов оптимизации до минимума

Исходный код (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 основные инструкции.

12.5 LUTGen — цикл времени компиляции → поиск из 3 инструкций

Исходный код (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 входов.

12.6 forEach с захватом замыкания — цикл DJNZ

Исходный код (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 на протяжении всего цикла.

12.7 mapInPlace — преобразование на месте с обратной записью

Исходный код (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 тоже мёртвый код.

12.8 GCD — цикл с двумя мутабельными переменными

Исходный код (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 устранил все накладные расходы копирования на границах блоков в горячем цикле. Только выходной трамплин (холодный путь) содержит перестановки регистров.


Глава 13: Связь с MinZ и PL/M

13.1 Три фронтенда

Язык Расширение Парсер Бэкенд Статус
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
}

13.2 Когда использовать какой

Nanz — новые программы для Z80/CP/M, современный синтаксис, LUTGen, распределение PBQP. MinZ — существующие .minz-программы, которые работают. Есть метафункции (@define, @print), ещё отсутствующие в Nanz. PL/M-80 — перенос устаревшего ПО CP/M. 100% корпуса Intel 80 Tools парсится.

13.3 Пробелы в функциональности (Nanz vs. MinZ)

В настоящее время Nanz не имеет:

  • Препроцессорных макросов @define
  • Оптимизированного строкового вывода @print
  • Строковой интерполяции в стиле Ruby ("Hello #{name}")
  • Синтаксиса множественных возвращаемых значений (MIR2 поддерживает, парсер — нет)
  • Перегрузки функций (MinZ имеет; Nanz требует уникальных имён)
  • @extern с аннотациями классов регистров (документировано, но ещё не парсится)

13.4 Другие бэкенды (только MinZ)

Старый конвейер 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.


Приложение A: Полная грамматика синтаксиса

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-последовательностей

Приложение B: Классы регистров и таблица стоимостей

Физические регистры Z80

Регистр Класс Стоимость Примечания
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. Булевы результаты сравнений хранятся во флагах без материализации в регистр.


Приложение C: Справочник по CLI

Компиляция программ Nanz

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 2

Промежуточные представления

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 translation

Флаги оптимизации

mz 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 steps

Запуск тестов

cd 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

Приложение D: Установка внешних инструментов

QBE (оракул корректности)

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 (встроенный)

Внешняя установка не требуется. MZA — часть инструментария MinZ:

cd minzc && make mza
# or: make install-user (installs all tools to ~/.local/bin/)

Эмулятор MZE (встроенный)

cd minzc && make mze

Используется для: запуска скомпилированных Z80-бинарников, вычисления констант внутри компилятора (LUTGen, BranchEquiv).


Приложение E: Известные ошибки и ограничения

Парсер

Проблема Статус Обходной путь
@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.

Конвейер: .nanznanz.Parse()*hir.Modulehir.LowerModule()*mir2.Module → оптимизация → распределение → Z80Codegen → .a80