B16: руководство программиста

Руководство для тех, кто уверенно пишет на C и не боится ассемблера. Оно не повторяет спецификацию по пунктам: здесь объясняется, как думать на B16, во что превращается каждая конструкция и как писать короткий и быстрый код для CPU16. Точные правила языка и все ключи компилятора — в справочнике B16_reference.md, краткая справка по синтаксису — в README.md, архитектура процессора описана в cpu16_spec_r6.md, устройства машины — в cpu16_machine.md.

Руководство соответствует второй ревизии спецификации (типы long и ulong); что изменилось по сравнению с первой, описано в разделе 14 справочника.

Все листинги ниже — настоящий вывод b16c; для краткости в них опущены директивы .line (раздел 12).

1. Идея языка

B16 задуман как язык, в котором видно машину. Каждый оператор превращается в предсказуемую короткую последовательность команд, а дорогие вещи выглядят дорого. Прямых предков два: B (типы почти не мешают, всё — слово) и C (указатели, структуры, синтаксис выражений).

Из этого вытекают несколько правил, которые полезно держать в голове с первой строки.

Слово — единственная единица вычислений. Все выражения 16-битные. byte и char — это форматы хранения в памяти: при чтении байт расширяется до слова (нулём или знаком), при записи усекается. Целочисленных продвижений в стиле C нет, потому что продвигать некуда.

Знаковость важна там, где её различает железо. Сложение, вычитание, младшее слово умножения, битовые операции и << одинаковы для знаковых и беззнаковых чисел. Сравнения < > <= >=, деление, остаток и >> — разные. Ровно в этих операциях B16 требует, чтобы операнды были одной знаковости. Это заменяет весь свод правил «usual arithmetic conversions» одной проверкой, которая ловит реальные ошибки.

Переменная живёт в регистре, пока ничто этому не мешает. Локальные скаляры по умолчанию регистровые. В память их вытесняет только взятие адреса, тип массива или структуры, либо нехватка регистров.

Компилятор не прячет вызовы. Единственный невидимый вызов — знаковое деление и остаток на переменную (__divs, __mods), и он упомянут в документации; деление на константу выполняется на месте. Копирования структур, конструкторов, неявных преобразований и кучи нет.

2. Быстрый старт

make                                      # собрать b16c
./run.sh examples/universal/hello.b16     # компиляция + ассемблер + эмулятор
./run.sh examples/bare/screen.b16 --screen  # ключи после файла уходят в cpu16
./run.sh examples/universal/echo.b16 < examples/universal/echo.txt

run.sh вызывает b16c -I lib, затем cpu16asm, затем cpu16. Дополнительные ключи компилятора передаются через переменную B16FLAGS, например B16FLAGS=-s ./run.sh prog.b16. Пути к ассемблеру и эмулятору берутся из переменных CPU16ASM и CPU16 или из PATH. Рядом с исходником остаются .s (вывод компилятора), .bin и .lst (листинг ассемблера). Листинг стоит держать открытым, пока не привыкнете к кодогенерации: в нём видны адреса и машинные слова.

Самая короткая программа (io.b16 — вывод, который работает и на голой машине, и под монитором):

include "io.b16"

fn main() {
    puts("Привет, CPU16!\n")
}

Чаще всего нужны ключи -o файл, -I каталог и -s (исходный текст в выводе, раздел 12). Всё, что относится к самой программе — цель, адреса кода и данных, поля заголовка CX16, ассемблерные модули, — записывается в исходном тексте объявлениями option и строками include asm (раздел «Параметры образа» ниже), так что сборка любой программы — одна и та же команда. Полное описание всех ключей, их значений по умолчанию и действия в каждой цели — в справочнике языка B16_reference.md, раздел 13.

Ошибки и предупреждения выводятся в формате файл:строка: error: текст и файл:строка: warning: текст. После ошибки компилятор продолжает разбор со следующего оператора или объявления и за один запуск сообщает до 20 ошибок.

Как устроен образ

Программа — исполняемый файл CX16 («Монитор CPU16: формат исполняемого файла и запуск программ»). Тот же образ запускается и монитором, и эмулятором на голой машине. По адресам он устроен так:

  1. заголовок CX16 (__header, 28 байт): JMP #__start, сигнатура "CX16", версия, image_end, bss_end, min_free, max_top;
  2. стартовый код __start, остановка __halt и пустой обработчик __irq_ignore;
  3. функции в порядке исходного текста (включённые файлы оказываются там, где стоит include);
  4. рантайм деления, если он понадобился;
  5. инициализированные глобальные переменные и строковые литералы;
  6. .align 2, метка __image_end, неинициализированные глобальные переменные в резерве .reserve и метка __bss_end. Резерв в файл не попадает.

Стартовый код определяет способ запуска по сигнатуре по адресу $0004. Под монитором там лежит PIB ("CXPB"): стек, векторы и таймер принадлежат монитору, bss он уже обнулил, а в R0 и R1 передал argc и argv. Стартовый код сохраняет LR, вызывает main и возвращается по LR с кодом завершения в R0. На голой машине по $0004 — сам заголовок ("CX16"), и стартовый код готовит машину сам:

main может получать аргументы и возвращать код завершения:

fn main(argc: uint, argv: ptr ptr byte): int {
    var i: uint = 1
    while i < argc { puts(argv[i]); putnl(); i++ }
    return 0
}

Допустимы fn main(), fn main(): int, fn main(argc: uint, argv: ptr ptr byte) и с результатом int. Без результата монитор получает код 0.

Стек растёт вниз от вершины (top под монитором, $FF00 на голой машине) навстречу концу bss. Его можно найти из программы: __image_end и __bss_end — адреса границ образа типа ptr, от __bss_end удобно отсчитывать начало кучи.

Данные отдельно от кода. С option data = $8000 все данные начинаются с $8000, а инициализированные переменные лежат рядом с кодом как образец __data_init; стартовый код копирует его в ОЗУ. Так программа может работать из ПЗУ. Строковые литералы при этом остаются рядом с кодом и не копируются, поэтому изменять их нельзя.

В образ попадают только функции, достижимые из main, из функций с пометкой export, из обработчиков прерываний, из инициализаторов глобальных переменных и из текста asm-блоков. Поэтому подключать библиотеку целиком не страшно: неиспользуемое в код не попадёт.

3. Синтаксис глазами программиста на C

Синтаксис намеренно похож на C. Ниже только отличия.

C B16
объявление int x = 5; var x: int = 5 или var x = 5
функция int f(int a, int b) fn f(a, b: int): int
несколько результатов — fn divmod(a, b: uint): uint, uint
указатель char *p p: ptr byte
массив int a[10] a: [10]int
указатель на функцию int (*f)(int) f: ptr fn(int): int
приведение (unsigned)x uint(x)
поле по указателю p->x p.x (и для (*p).x)
бесконечный цикл for (;;) loop { }
фигурные скобки у if/while необязательны обязательны
условие в скобках обязательно не нужно: if x < 5 { }
; обязательна по желанию

Поскольку тела всегда в скобках, проблемы «висячего else» нет. Условие не нужно брать в скобки, поэтому if (a) (b) и подобные двусмысленности не возникают.

Точка с запятой и переводы строк

Как в Go, перевод строки завершает оператор, если строка кончается на что-то, что может закончить выражение:

Отсюда два следствия:

x = a +         // так можно: строка кончается оператором
    b
x = a           // а так нельзя: "- b" станет отдельным оператором,
    - b         // его значение вычислится и пропадёт

else можно ставить на следующей строке после }: это единственное исключение из правила. Точка с запятой нужна только для нескольких операторов на одной строке: i++; j--.

Литералы

Исходники пишутся в UTF-8, а литералы перекодируются в кодировку машины. В неё входят ASCII, кириллица А–я и ёЁ, псевдографика (╔═╗░▒▓█) и значки (☺ ♥ → ▲). Поэтому 'Ж' — это число $C6, а "╔═╗" — три байта. Символ, которого в кодировке нет (€, «, №), вызывает ошибку компиляции. Escape-последовательности \n \r \t \0 \\ \' \" \xHH задают байт напрямую.

Строковый литерал — это массив байтов с нулём в конце, лежащий в области данных. В выражении он означает свой адрес.

Константы

const N = 64
const MASK = N - 1
const IRQ_TIMER = 0

const существует только при компиляции. Значение подставляется в код и сворачивается вместе с другими константами, поэтому MASK выше стоит ровно столько же, сколько литерал 63. Константы 0..15 особенно дёшевы: они помещаются в команду (раздел 5).

4. Типы и их цена

Тип Размер В регистре Чтение из памяти Запись
byte 1 0..255 LDZX STB
char 1 −128..127 LDSX STB
uint 2 0..65535 LD ST
int 2 −32768..32767 LD ST
ulong 4 0..4294967295 LD ×2 ST ×2
long 4 −2147483648..2147483647 LD ×2 ST ×2
ptr T 2 адрес LD ST
[N]T N·size(T) — — —
struct сумма полей — — —

char здесь — знаковый байт, а не «символ». Для текста обычно удобнее byte: кириллица в кодировке машины занимает $C0–$FF, и со знаковым char буквы стали бы отрицательными. ptr byte служит универсальным указателем, как char * в C.

Байтовые переменные в регистрах

Регистровая переменная типа byte всегда хранит уже расширенное значение 0..255. При записи в неё результат усекается одной однословной командой LDZX Rd, Rs (для char — LDSX):

var b: byte
b = x + y        // ADD ...; затем LDZX Rb, Rb

Если правая часть уже имеет тип byte (чтение из байтового массива, приведение byte(...), константа 0..255), усечения нет.

Знаковость в смешанных выражениях

var i: int
var u: uint
i + u            // можно: сложение не зависит от знака
i == u           // можно
i < u            // ошибка: смешаны знаковый и беззнаковый операнды
i < int(u)       // так
i / 3            // можно: литерал принимает знаковость другого операнда

Нетипизированные константы подстраиваются под другой операнд, поэтому сравнения с литералами работают без приведений. Тип результата смешанной операции — тип левого операнда (после продвижения byte→uint, char→int); если один из операндов long или ulong, результат имеет его тип. У >> знаковость определяет только левый операнд: x >> n для int даёт SAR, для uint — SHR.

Приведения

Запись Код
int(x), uint(x), ptr T(x) ничего: меняется только тип
byte(x) LDZX Rd, Rs (1 слово)
char(x) LDSX Rd, Rs (1 слово)
long(x), ulong(x) LD Rhi, #0 (из uint, byte) или LD+SAR Rhi, #15 (из int, char); из ulong/long — ничего
uint(x), int(x) от long/ulong младшее слово: LD или ничего

Для констант приведение выполняется при компиляции; значение, не помещающееся в тип, — ошибка.

Двойные слова: long и ulong

long и ulong — 32-битные знаковый и беззнаковый типы. В памяти младшее слово лежит по меньшему адресу, как в 32-битных полях ФС16. В регистрах значение занимает пару регистров, не обязательно соседних; компилятор подбирает её сам. Операции над парой выполняются цепочкой команд с переносом:

var a, b: long
a = a + b           // ADD Ra, Rb ; ADC Ra', Rb'
if a < b { ... }    // CMP Ra, Rb ; SBC Ra', Rb' ; JGE (флаги относятся ко всему значению)

Константы считаются точно и принимают тип по контексту. Значение, не помещающееся в тип, — ошибка, а не молчаливое усечение:

var n = 100000       // long: не помещается в int и uint
var u: uint = 70000  // ошибка: constant 70000 does not fit in uint
var m: ulong = $FFFFFFFF
var k: long = $FFFFFFFF   // -1: битовый образ сохраняется

Расширение неявно, сужение явно. Более узкий операнд расширяется по знаку своего типа, результат имеет тип более широкого операнда. Присвоить long в uint или int можно только приведением:

var l: long
var u: uint
l = u + l             // u расширяется нулями; результат long
u = uint(l)           // младшее слово; без приведения - ошибка
l < u                 // можно: uint без потери помещается в long
ul < i                // ошибка: ulong и int смешивают знаковость

Размер и знаковость результата *, /, % и сравнений подчиняются тем же правилам, что у слов. Операции + - * & | ^ знак не различают, поэтому ulong + int допустимо.

Половины без стоимости. uint(x) берёт младшее слово, uint(x >> 16) — старшее, (ulong(h) << 16) | l собирает значение из двух слов. Во всех трёх случаях вычислений нет: читаются или записываются регистры и слова памяти. ulong(a) * ulong(b) для двух слов — это MUL и MULHU, шесть команд без вызова.

Параметры и результаты. long и ulong занимают два слота: fn f(a: long, b: uint): long получает a в R0:R1, b в R2 и возвращает результат в R0:R1. Если аргументу не хватило слотов R0–R3, он и все следующие уходят в стек. Результатов не больше четырёх слов.

Что стоит дорого. Сложение, вычитание, логика, сравнения, сдвиги на константу и ++ — две-четыре команды. Умножение и деление дороже:

Операция Код Команд
a * b вызов __mul32 16
ulong(x) * ulong(y), x * 2^k MUL+MULHU, сдвиг 6 и меньше
a / b, a % b вызов __divu32, __divs32, __modu32, __mods32 ≈390 (обе части меньше 65536: 20)
a / k, a % k для ulong, k = 3…255 вызов __udivc32, __umodc32 ≈45
a / 2^k, a % 2^k для ulong сдвиг, маска 1–5
a << n, a >> n для переменной n вызов __shl32, __shr32, __sar32 ≈17

Деление ulong на ноль даёт $FFFFFFFF, остаток равен делимому; long: -2147483648 / -1 даёт -2147483648, остаток 0. Счётчик сдвига по переменной берётся по модулю 32, а константный сдвиг на 32 и больше даёт 0 (для long >> — знак) с предупреждением.

Выражение с long ограничено регистрами. Если функция считает над long, под регистровые переменные отдаётся не больше шести регистров, а остальные остаются под промежуточные значения. При слишком глубоком выражении компилятор выводит expression too complex: разбейте его на два оператора.

Доступ к long не атомарен: чтение и запись — две команды. Значение, общее с обработчиком прерывания, читайте и пишите внутри atomic.

Указатели проверяются строго

Ошибка в типе указателя меняет шаг ++ и команду загрузки, поэтому компилятор не пропускает её молча:

var CONSOLE at $FF20: byte
var p: ptr byte = &CONSOLE        // адрес at-переменной уже ptr byte
var q: ptr uint = ptr uint($8000) // число -> указатель только явно
var a: uint = uint(q) & 1         // проверка выравнивания

Нетипизированный указатель ptr — адрес данных неизвестного типа, для функций, работающих с памятью вообще. Любой ptr T (кроме указателя на функцию) присваивается ptr и обратно без приведения, но над самим ptr нельзя ничего, что требует размера элемента: *p, p[i], p.f, ++, + i. Для адресной арифметики его приводят к ptr byte или присваивают типизированному:

fn checksum(buf: ptr, n: uint): uint {
    var p: ptr uint = buf          // неявно: ptr -> ptr uint
    var s: uint = 0
    while n > 0 { s += *p; p++; n -= 2 }
    return s
}

Перевод строки после ptr заканчивает оператор, как после int, поэтому тип элемента указателя на следующую строку не переносится.

Арифметика указателей масштабируется размером элемента, как в C: p + 1 у ptr int прибавляет 2. Разность двух указателей даёт число элементов.

Массивы

[N]T — непрерывный блок. Имя массива в выражении означает адрес первого элемента. Многомерные массивы — это массивы массивов: [3][5]int, индексы a[i][j].

Массивы не копируются и не передаются по значению. Функция получает указатель:

fn sum(p: ptr int, n: uint): int
...
sum(&data[0], 16)        // или просто sum(data, 16)

Структуры

struct Node {
    next: ptr Node       // ссылка на саму себя допустима
    key: int
    tag: byte
}

Поля uint, int и ptr выравниваются по слову, байтовые — нет, размер округляется до чётного. Здесь sizeof(Node) = 6: next по смещению 0, key по 2, tag по 4 и байт выравнивания. Порядок полей влияет на размер: группируйте байтовые поля вместе.

Структура — только раскладка памяти. Присваивания, сравнения и передачи структур целиком нет, а копирование пишется явно (memcpy). Взамен доступ к полю всегда стоит одной команды: поле по смещению 0 — это [Rp] (1 слово), остальные — [Rp+смещение] (2 слова).

fn f1(p: ptr P, d: int) { p.x += d; p.y = g }
f1:
	LD    R2, [R0]
	ADD   R2, R1
	ST    [R0], R2
	LD    R2, [g]
	ST    [R0+2], R2
	RET

Структуры могут вкладываться друг в друга, содержать массивы и указатели на функции: r.b.y, pr.name[2], pr.f(x).

5. Модель стоимости

Набор команд CPU16 двухадресный, с богатым вторым операндом. Почти любая команда АЛУ принимает вторым операндом один из восьми режимов. Генератор B16 построен вокруг этого: если правая часть бинарной операции укладывается в режим, она подставляется прямо в команду без промежуточной загрузки.

Правая часть Режим Слов в команде
регистровая переменная Rn 1
константа 0..15 #k 1
другая константа, адрес глобальной #imm 2
глобальная переменная (слово) [g] 2
локальная в стеке [SP+d] 2 (1 при d = 0)
*p [Rp] 1
*p++, *--p [Rp++], [--Rp] 1
p.f [Rp+off] 2
buf[i] (байтовый глобальный массив) [Ri+buf] 2
&x (адрес в стеке) SP+d 2

Отсюда практические выводы:

Флаги и условия

Условия компилируются прямо в CMP/TEST и переход, без вычисления 0/1. Компилятор помнит, по какому регистру выставлены флаги после последней команды АЛУ, и не повторяет проверку:

fn f2(a: ptr int, n: uint): int { var s = 0; while n != 0 { s += *a++; n-- }; return s }
f2:
	LD    R2, #0
	TEST  R1, R1
	JZ    @L4
@L2:
	ADD   R2, [R0++]
	SUB   R1, #1
	JNZ   @L2           ; флаги уже от SUB: TEST не нужен
@L4:
	LD    R0, R2
	RET

Тело цикла здесь — три команды. x & M в условии превращается в одну команду TEST, if x & 4 { } — в TEST R0, #4 и JZ.

Условие while проверяется перед входом в цикл и затем внизу (цикл «повёрнут»). На каждой итерации выполняется один условный переход.

Сравнение как значение (x = a < b) стоит дороже: загрузка 0, проверка, условная загрузка 1. Если результат нужен только для ветвления, пишите его прямо в if.

Умножение, деление, сдвиги

Операция Код
a * b MUL (младшее слово, одинаково для int и uint)
mulhu(a, b) MULHU (старшее слово беззнакового произведения)
a / b, a % b для uint DIVU, REMU
a / b, a % b для int вызов __divs, __mods (≈15 команд, функция становится нелистовой)
x * 2^k SHL (с NEG для отрицательной степени)
x / 2^k, x % 2^k для uint SHR, AND
x / 2^k, x % 2^k для int SAR с поправкой для отрицательного делимого (5 команд)
x / k, x % k для int, другие константы умножение на обратное через MULHU (≈11 команд, без вызова)
x << k, x >> k SHL, SHR/SAR

Знаковое деление на переменную — единственная операция, которая неявно вызывает функцию. Деление на константу вызова не требует и функцию листовой оставляет. Если делимое заведомо неотрицательно, приведите операнды к uint: uint(a) / 10 компилируется в одну команду DIVU. Деление int через >> для отрицательных чисел округляет вниз, а / — к нулю.

Сдвиг на переменную величину берёт младшие 4 бита счётчика: так работает железо, сдвиг на 16 одной командой невозможен. Сдвиг на константу ≥ 16 компилятор сворачивает в 0 (для int >> — в знак).

Деление на ноль исключения не вызывает. DIVU даёт $FFFF, REMU возвращает делимое. Для знакового деления результат не определён языком (на практике 1 или −1). Переполнение int молча заворачивается по модулю 2^16.

Порядок вычисления

Порядок вычисления операндов не определён, как в C. Компилятор обычно сначала вычисляет вызовы функций и более «тяжёлое» поддерево, чтобы занять меньше регистров. Не пишите a[i] = i++ и не рассчитывайте на порядок побочных эффектов внутри одного выражения. && и || вычисляются слева направо с сокращением, как в C.

6. Переменные и регистры

Где живёт локальная переменная

Локальные скаляры (и параметры) получают регистры по взвешенному числу обращений: обращение внутри цикла весит в 4 раза больше, во вложенном — в 16. Остальное зависит от функции.

Листовая функция (ничего не вызывает). Параметры остаются в тех регистрах, где пришли (R0–R3). Остальные переменные занимают свободные регистры с R0, всего до девяти регистровых переменных. Временным значениям остаётся минимум три регистра. Если хватает R0–R3, у функции нет ни пролога, ни эпилога, кроме RET.

Нелистовая функция. Переменные размещаются в R4–R11, которые переживают вызовы. Параметры копируются туда в прологе. Пролог сохраняет LR и используемые R4–R11, эпилог восстанавливает их и возвращается прямо со стека командой POP IP.

В стек ([SP+d]) попадают:

Кадр выделяется одной командой SUB SP, #размер. Указателя кадра нет: компилятор статически знает смещение SP в каждой точке функции.

Когда регистров не хватает

Временным значениям достаются регистры, не занятые переменными. В нелистовой функции с восемью переменными заняты все R4–R11, и на промежуточные результаты остаются только R0–R3. Выражению вроде (*cur)[y][x] нужно два регистра (база и индекс), сложной формуле с вложенными сравнениями и индексами — больше.

Компилятор генерирует каждую бинарную операцию, сравнение, индекс и список аргументов сначала обычным способом. Если регистры кончились, он откатывает этот участок и генерирует его заново: правый операнд вычисляется первым и ждёт в стеке (PUSH), пока считается левый, затем снимается (POP). Код при этом получается длиннее и медленнее, но правильным. Сообщение expression too complex: out of registers остаётся только для патологических случаев.

Если в горячем цикле видны лишние PUSH/POP (проверяйте по выводу b16c -s), помогает следующее:

Две практические рекомендации:

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

Глобальная переменная всегда в памяти, и каждое обращение — это команда с адресом [g]. Компилятор никогда не держит её копию в регистре между операторами. У этого полезное следствие: глобальные переменные ведут себя как volatile в C. Переменную, которую меняет обработчик прерывания, можно опрашивать в цикле без дополнительных пометок:

while ticks < 60 { wfi() }      // каждое обращение - новое чтение памяти

Инициализаторы глобальных — константы, строки, адреса глобальных и имена функций, а для массивов и структур — списки, позиционные и с именами полей:

var count: uint = 10
var msg = "текст"                       // [6]byte: байты прямо в переменной
var p: ptr byte = "текст"               // указатель на отдельную строку
var tbl: [4]int = {1, -2, 3}            // остаток заполняется нулями
var first: ptr int = &tbl[1]
var ops: [2]ptr fn(int): int = {sq, inc}
var keys: [2]Key = { { $48, "^" }, { .scan = $50, .name = "v" } }

Такие таблицы заменяют параллельные массивы. Поле без значения получает 0, после .поле = ... позиционные значения продолжаются со следующего поля.

Обратите внимание на разницу между var msg = "..." (массив) и var p: ptr byte = "..." (указатель плюс строка в данных). У локальной переменной var s = "..." тип ptr byte, а строка лежит в области данных.

Локальные переменные без инициализатора содержат мусор, и компилятор предупреждает, если переменная может читаться до присваивания. Локальный массив или структуру можно инициализировать списком, и тогда объект заполняется при каждом выполнении объявления:

var a: [4]int = { n, n * 2 }     // присваивания, остаток - нули
var big: [32]int = { 1, 2, n }   // копирование из образца main.i0, затем big[2] = n

Объект до 16 байт заполняется присваиваниями, больший копируется пословно из образца в области данных (метка функция.i<n>), а неконстантные значения присваиваются после.

Статическая локальная переменная static var n: uint = 10 хранится в области данных, как глобальная, но видна только в своём блоке. Её значение сохраняется между вызовами, инициализатор — константный. В ассемблере она называется функция.имя.

Видимость в пределах файла. Пометка static перед fn или var верхнего уровня делает имя видимым только в своём файле. Две библиотеки могут иметь функции с одинаковыми именами; внутри файла его static-имя скрывает одноимённое глобальное. В ассемблере такие имена выводятся как файл.имя (string.helper).

Отображённая память

var CONSOLE at $FF20: byte
var VEC     at $FF00: [16]ptr interrupt fn()

var ... at адрес объявляет переменную по фиксированному адресу. Места она не занимает: каждое обращение компилируется в команду с абсолютным адресом, например STB [$FF20], R0. Тип определяет команду: byte даёт LDZX/STB, слово — LD/ST. Так описываются порты устройств, таблица векторов, экранная память — всё, что лежит по известному адресу.

7. Управление выполнением

Циклы

while cond { ... }
loop { ... }                 // бесконечный; выход через break, return, goto
break
continue

Отдельного for нет. Шаблон с явной переменной цикла короче, чем кажется, и компилируется в «повёрнутый» цикл с одним условным переходом на итерацию:

var i: uint = 0
while i < N { ...; i++ }

Обратный отсчёт до нуля ещё дешевле, потому что флаги дают сами SUB или --:

while n != 0 { ...; n-- }     // SUB Rn, #1 / JNZ

goto

fn find(v: int): int, int {
    var i, j: uint
    i = 0
    while i < 3 {
        j = 0
        while j < 5 {
            if grid[i][j] == v { goto found }
            j++
        }
        i++
    }
    return -1, -1
found:
    return int(i), int(j)
}

Правила:

goto на уровне машины — один JMP, без накладных расходов. Конечные автоматы на goto получаются короче и быстрее, чем switch в цикле (см. examples/universal/fsm.b16):

space:
    c = *s++
    if c == 0 { goto done }
    if classify(c) == 2 { words++; goto word }
    goto space
word:
    c = *s++
    if classify(c) != 0 { goto word }
    s--
    goto space

Оператора switch нет. Короткую цепочку пишите через if ... else if, длинную или плотную — через таблицу указателей на функции (раздел 8) или таблицу данных.

8. Функции

Объявление и вызов

fn sum3(a, b, c: int): int { return a + b + c }
sum3:
	ADD   R0, R1
	ADD   R0, R2
	RET

Параметры одного типа можно группировать: a, b, c: int. Параметры и результаты — только скаляры. Порядок объявлений на верхнем уровне не важен: компилятор сначала собирает все объявления файла, поэтому функцию можно вызывать выше её определения. Прототип без тела (fn f(x: int): int) нужен в двух случаях:

Соглашение о вызовах

Это соглашение — общий интерфейс с ассемблерным кодом, поэтому его стоит знать точно.

Стоимость вызова для нелистовой функции:

  1. вызывающий раскладывает аргументы по R0–R3;
  2. CALL (1 или 2 слова, короткую форму выбирает ассемблер);
  3. пролог вызываемой: PUSH LR и по PUSH на каждый используемый R4–R11;
  4. эпилог: столько же POP.

Листовая функция с переменными в R0–R3 не стоит ничего сверх CALL и RET.

Если вызов стоит внутри выражения, а у вычисления уже есть промежуточные значения в R0–R3, они сохраняются вокруг вызова через PUSH/POP:

fn caller(x: int): int { return sum3(x, 2, 3) + sum3(x, x, x) }
	CALL  sum3
	PUSH  R0            ; первый результат переживает второй вызов
	LD    R0, R4
	...
	CALL  sum3
	LD    R1, R0
	POP   R0
	ADD   R0, R1

Если это в горячем месте, сохраните результат в локальную переменную: она будет жить в R4+, и PUSH/POP исчезнут.

Несколько результатов

Функция возвращает до четырёх значений, все в регистрах. Это заменяет выходные параметры-указатели, которые в C заставляли бы переменную жить в памяти:

fn divmod(a, b: uint): uint, uint { return a / b, a % b }
...
var q, r: uint
q, r = divmod(v, 10)
_, r = divmod(v, 10)          // ненужный результат пропускается через _

Справа от множественного присваивания должен стоять вызов. Если результатов больше, чем переменных слева, лишние отбрасываются. В листовой функции return a, b сам переставляет значения между регистрами, даже если это циклическая перестановка (return b, a).

Хороший приём для разбора текста — возвращать значение вместе с позицией. Так устроены atou в библиотеке и калькулятор в examples/universal/calc.b16:

fn primary(s: ptr byte): int, ptr byte {
    ...
    v, s = expr(s + 1)
    ...
}

Указатели на функции

fn apply(f: ptr fn(int): int, x: int): int { return f(x) }
var ops: [2]ptr fn(int): int = {sq, inc}
...
apply(sq, 5)
ops[1](5)
c.run(arg)                    // поле структуры

Сигнатура сверяется точно: функция присваивается указателю, только если число и типы параметров и результатов совпадают. Функциональный тип может иметь несколько результатов, они приходят в R0–R3, как у обычного вызова:

var sys: [8]ptr fn(int, ptr byte, uint): int, uint    // код ошибки и данные
var err: int
var n: uint
err, n = sys[k](fd, buf, len)

Запятая после типа результата продолжает список результатов ближайшего fn. Чтобы список закончился раньше, его берут в скобки: ptr fn(ptr fn(): (int), uint) — функция от двух параметров. В объявлении функции скобки необязательны: fn divmod(a, b: uint): (uint, uint) и fn divmod(a, b: uint): uint, uint — одно и то же. Обработчик прерывания имеет свой тип ptr interrupt fn(): он выходит через IRET, поэтому с ptr fn() несовместим, и вызвать его через указатель нельзя.

Имя функции в выражении означает её адрес, &f пишется по желанию. Указатель можно сравнивать с 0 и между собой, хранить в массивах и полях структур, возвращать из функций. Приёмы — обратные вызовы, таблица переходов, «объекты» с таблицей методов, автомат, где состояние — указатель на функцию, — собраны в examples/universal/funcptr.b16. Вызов через указатель — это CALL Rn, CALL [g] или CALL [Rp+off], без загрузки адреса в регистр, если указатель уже лежит в одном из этих мест.

Рекурсия

Рекурсия работает обычным образом, глубина ограничена только стеком (от $FF00 вниз до конца данных). Проверки переполнения стека нет. Хвостовые вызовы не оптимизируются, поэтому глубокую хвостовую рекурсию пишите циклом.

Пример ограничения глубины — qsort в examples/universal/sort.b16: рекурсия идёт в меньшую половину, а большая обрабатывается циклом, так что глубина O(log n).

9. Указатели и память

Идиомы, которые ложатся на режимы адресации

B16 Команда
x = *p LD Rx, [Rp]
x = *p++ LD Rx, [Rp++] (шаг 2 у ptr int, 1 у ptr byte)
*--p = x ST [--Rp], Rx
x = p.f LD Rx, [Rp+off]
x = g[3] LD Rx, [g+6]
b = buf[i] (глобальный [N]byte) LDZX Rb, [Ri+buf]
x = tbl[i] (глобальный [N]int) LD t, Ri / SHL t, #1 / LD Rx, [t+tbl]
p = &a[k] LD Rp, #a+2k

Автоинкремент получается только у регистровой переменной-указателя и только тогда, когда шаг указателя совпадает с размером доступа. Поэтому *p++ на ptr byte и ptr int — одна команда, а на ptr Node нет: там это сложение.

Копирование строки — классика, ради которой эти режимы существуют:

fn copy(d, s: ptr byte) { while (*d++ = *s++) != 0 {} }
@L7:
	LDZX  R2, [R1++]
	STB   [R0++], R2
	TEST  R2, R2
	JNZ   @L7

Индексы против указателей

Индексирование глобального байтового массива регистровой переменной — одна команда. Индексирование массива слов требует умножения индекса на 2, это ещё две команды. Для последовательного прохода указатель почти всегда лучше индекса: ADD Rs, [Rp++] против трёх команд адресации на каждой итерации.

Указатель на указатель

Типичный приём для списков — удаление без особого случая для головы:

fn remove_all(head: ptr Node, key: int): ptr Node {
    var link: ptr ptr Node = &head
    while *link != 0 {
        var n = *link
        if n.key == key { *link = n.next; release(n) } else { link = &n.next }
    }
    return head
}

Здесь &head переносит параметр head в стековый кадр, и это нормально: он и так используется по адресу.

Строки

Строка — ptr byte на последовательность с нулём в конце. Длина не хранится, функции работы со строками в lib/string.b16. Строковые литералы — объекты только для чтения: запись в литерал — неопределённое поведение. При option data они остаются рядом с кодом, в ПЗУ, и не копируются в ОЗУ. Изменяемый текст объявляйте глобальным массивом var s = "..." или локальным var s: [16]byte = "...". Одинаковые литералы не объединяются.

10. Прерывания и атомарность

Обработчик

var VEC   at $FF00: [16]ptr interrupt fn()
var TIMER at $FF29: byte
var ticks: uint

interrupt fn on_tick() { ticks++ }

fn main() {
    VEC[0] = on_tick          // сначала вектор,
    TIMER = 1                 // потом источник,
    ei()                      // потом STI
    while ticks < 50 { wfi() }
    TIMER = 0
    di()
}

Свойства interrupt fn:

Обработчик, работающий только с глобальными переменными, получается коротким:

on_tick:
	PUSH  R0
	LD    R0, [ticks]
	ADD   R0, #1
	ST    [ticks], R0
	POP   R0
	IRET

Под монитором векторы принадлежат монитору, программа их не трогает. На голой машине стартовый код ставит ERRCMD на остановку, а остальные векторы — на пустую заглушку, поэтому прерывание, о котором программа не знает, её не сломает. Векторы ставятся записью в таблицу $FF00: IRQ0..IRQ7 — векторы 0..7, ERRCMD — 8, 9..15 свободны для trap(n). Программный вызов trap(9) попадает в обработчик вектора 9 с запрещёнными прерываниями. Это удобный способ сделать системный вызов.

atomic

atomic { s = sec; c = cs }

Блок atomic компилируется в PUSH FLAGS, CLI, тело, POP FLAGS. Внутри прерывания запрещены, а при выходе восстанавливается прежнее состояние IF: вложенные блоки и блоки, исполняемые при уже запрещённых прерываниях, работают правильно. Выход через return, break, continue или goto тоже снимает FLAGS со стека.

Стоимость — три однословные команды. Блок нужен, когда несколько обращений к общим с обработчиком данным должны выглядеть одним. Одиночное чтение или запись слова атомарны сами по себе, потому что команда не прерывается посередине. А вот ticks++ над глобальной — это три команды (LD, ADD, ST): если обработчик тоже меняет ticks, основная программа должна делать это внутри atomic.

xchb и спинлоки

xchb(p, v) — атомарный обмен байта: команда XCHB, шина захвачена на всё время обмена. Возвращает старое значение:

var lock: byte
fn acquire() { while xchb(&lock, 1) != 0 {} }
fn release() { lock = 0 }

На однопроцессорной машине спинлок полезен в основном против обработчиков прерываний, которые не должны входить в критическую секцию. Чаще достаточно atomic.

Ожидание

wfi() останавливает процессор до прерывания. Эмулятор считает программу завершённой, когда процессор стоит в WFI, запросов нет и таймер выключен; так и останавливается стартовый код после main. Поэтому ждать на wfi() имеет смысл только при включённом источнике прерываний. Стандартное ожидание события:

while !done { wfi() }

11. Устройства машины

Библиотека lib/console.b16 описывает порты консоли и даёт функции поверх них. Всё в этом разделе относится к голой машине: под монитором таймер, векторы и клавиатура принадлежат ему, а программа получает их через системные вызовы (примеры examples/monitor/keys.b16 и ticks.b16). Основное, что нужно знать про устройство:

Экран 80×25 работает в одном из трёх режимов, они переключаются записью в CON_CTRL:

Атрибут собирается как attr(текст, фон), к нему можно добавить ATTR_UNDERLINE и ATTR_INVERSE. Пример всего этого вместе — examples/bare/screen.b16, его результат показывает cpu16 --screen.

Таймер ($FF29, бит 0) выставляет IRQ0 каждые 20 мс реального времени. Сколько команд пройдёт между тиками, зависит от хоста, поэтому время считается в тиках (examples/bare/clock.b16). Стартовый код выключает таймер после main и принимает уже выставленный запрос (запрос, повисший при запрещённых прерываниях, иначе будил бы WFI снова и снова), но если программа заканчивается иначе (например, через бесконечный цикл с wfi()), выключите его сами.

12. Ассемблер рядом с B16

Как читать вывод

Вывод b16c — обычный исходник для cpu16asm, его можно читать и править руками. Он устроен так, чтобы карта памяти (cpu16asm -m), листинг и сообщения ассемблера описывали программу на B16, а не сгенерированный текст.

Ассемблер не принимает метки, совпадающие с мнемониками и регистрами (inc, neg, push, ret, lo, hi, r1…, без учёта регистра букв). В B16 такие имена разрешены, а в ассемблер они выводятся с префиксом: функция neg становится меткой __neg. Имена, начинающиеся с __, в B16 зарезервированы, поэтому коллизий не бывает. static-имена выводятся как файл.имя: имя с точкой не совпадает ни с мнемоникой, ни с регистром.

Проверки при трансляции. Раскладки, заданные внешними форматами, проверяются объявлением assert:

assert sizeof(DirEntry) == 64
assert offsetof(Header, image_end) == 12, "заголовок CX16"

Исходный текст в выводе: ключ -s

С ключом -s перед командами каждого оператора выводится его исходная строка комментарием. Перед функцией показывается её заголовок и комментарии над ним. Если между двумя показанными строками не больше восьми строк того же файла, промежуточные строки (объявления, комментарии) тоже выводятся. Уже показанная строка повторяется, только когда её код снова встречается дальше, например условие while внизу цикла. Номера строк — номера в исходном файле, смена файла (после include) отмечается строкой ; ---- файл.

remove_all:
	;   51      var link: ptr ptr Node = &head
	LD    R4, SP
	;   52      while *link != 0 {
	LD    R0, [R4]
	TEST  R0, R0
	JZ    @L32
@L30:
	;   53          var n = *link
	LD    R5, [R4]
	;   54          if n.key == key { *link = n.next; release(n) } else { link = &n.next }
	CMP   R6, [R5+2]
	JNZ   @L33
	...
@L34:
	;   52      while *link != 0 {
	LD    R0, [R4]
	TEST  R0, R0
	JNZ   @L30

Здесь хорошо видно «повёрнутый» цикл: условие строки 52 проверяется и перед входом, и внизу. Код с -s и без него одинаков, меняются только комментарии.

asm-блоки

fn addpair(alo, ahi, blo, bhi: uint): uint, uint {
    asm {
        ADD   R0, R2
        ADC   R1, R3
    }
    return alo, ahi
}

Текст блока вставляется в вывод как есть. Компилятор не разбирает команды и не знает, какие регистры они меняют. Поэтому вокруг блока действуют такие правила:

Самый предсказуемый вариант — функция, тело которой целиком на ассемблере, а B16 даёт ей только сигнатуру:

fn ror8(x: uint): uint { asm { ROR R0, #8 } return x }
ror8:
	ROR R0, #8
	RET

Функции в отдельном файле на ассемблере

Объявите функцию в B16 как extern fn — прототип без тела, определение вне B16 — и определите её в .asm, соблюдая соглашение о вызовах:

// prog.b16
extern fn bswap(x: uint): uint    // определена в bswap.asm
; bswap.asm
bswap:  ROR     R0, #8
        RET
; top.asm - собираем вместе
        .include "prog.s"
        .include "bswap.asm"

Скомпилируйте prog.b16 в prog.s и ассемблируйте top.asm. Так модуль оказывается за __bss_end, и под монитором такая программа не работает: монитор загружает из файла только байты до __image_end, а на голой машине эмулятор загружает файл целиком, и ошибка не видна. cpu16asm --exec отвергает такую сборку. Штатный способ — строка include asm: компилятор сам выводит .include модуля после функций и рантайма, перед данными, и код с инициализированными данными модуля (.word, .byte) попадает в образ до __image_end:

// prog.b16
include asm "bswap.asm"           // путь - как у include: от этого файла, затем -I
extern fn bswap(x: uint): uint
b16c prog.b16 -o prog.s
cpu16asm --exec -o prog.bin prog.s

Строку include asm удобно держать рядом с прототипами модуля: библиотека, которой нужен ассемблерный модуль, подключает его сама, и программе достаточно include библиотеки. Так устроен sys.b16 монитора. Повторные include asm того же файла игнорируются. Ключ --asm файл делает то же из командной строки.

В модуле допустимы только код и инициализированные данные; неинициализированные объявляйте в B16. Обычный прототип без extern тоже скомпилируется, но для него нет определения на B16, и компилятор предупредит: function bswap is declared but not defined. Это предупреждение ловит забытое тело функции, поэтому функции из ассемблера всегда помечайте extern: тогда сборка с -Werror не ломается, а настоящие предупреждения не тонут в шуме. Определить extern-функцию ещё и на B16 — ошибка. Обратный вызов, из ассемблера в B16, — обычный CALL имя или CALL Rn по указателю, с аргументами в R0–R3.

Функцию B16, которую ассемблерный модуль вызывает по имени, а B16-код нигде не упоминает, компилятор удалит как недостижимую: о внешних .asm он не знает. Пометьте её export: такая функция всегда попадает в образ, её имя в ассемблере не меняется, и от неё считается достижимость остальных функций.

export fn sys_read(fd: int, buf: ptr byte, n: uint): int, uint { ... }
``` Полный пример с вызовами в обе стороны, двумя результатами из ассемблерной функции и обратным вызовом через указатель — каталог `examples/universal/asmlink`.

### Ядро и модули: option target = kernel

Код, который не является самостоятельной программой — ядро монитора, модули, вызываемые из ассемблера, — объявляет `option target = kernel`. Заголовка и стартового кода нет, недостижимые функции по-прежнему отбрасываются, точки входа — функции `export` и обработчики прерываний. `option code` задаёт начало кода, `option data` — начало данных. С `option data` компилятор выводит подпрограмму `__init_data`: она копирует инициализированные данные из образца и обнуляет bss, и пролог ядра вызывает её сам. Без `option data` данные лежат сразу за кодом, а обнулить bss (`__image_end` … `__bss_end`) должен тот, кто загружает ядро. Пример — `tests/kernel`. Раскладка образа в каждой цели и отбор функций описаны в `B16_reference.md`, разделы 13.3 и 13.4.

// kernel.b16
option target = kernel
option code = $E000
option data = $F000


b16c kernel.b16 -o kernel.s


; boot.asm
LD SP, #$FF00
CALL __init_data
CALL kmain
.include "kernel.s"


В тексте `asm`-блока имена `static`-функций и переменных текущего файла и статических локальных переменных пишутся как в B16: компилятор сам заменяет их на `файл.имя`.

### Параметры образа: option

Параметры, без которых программа не работает, пишутся в её главном файле:

const PTOP = $A000 // память процесса - до PTOP: выше лежат окна кадров
option max_top = PTOP // поле max_top заголовка CX16
option min_free = 4096 // запас под стек и кучу
var WB at PTOP: [8192]byte // то же число - в адресе окна


| Параметр | Значение | По умолчанию | Цель |
| --- | --- | --- | --- |
| `target` | `std` или `kernel` | `std` | — |
| `code` | начало кода | 0 | `kernel` |
| `data` | начало данных | сразу за кодом | обе |
| `min_free` | память под стек и кучу | 2048 | `std` |
| `max_top` | верхняя граница памяти процесса, 0 — вся | 0 | `std` |

Значение — константное выражение; константы в нём объявляются выше. Параметры допустимы только в главном файле: библиотека не должна менять раскладку чужой программы. Каждый параметр — один раз, параметр не той цели (`code` в программе, `max_top` в ядре) — ошибка. `option` — не ключевое слово: переменная или функция с таким именем по-прежнему допустима.

Ключи `--target`, `--code`, `--data`, `--min-free`, `--max-top` переопределяют параметры исходного текста — для разовой сборки, например стенда с другим адресом. Если значение ключа отличается от `option`, компилятор предупреждает, а с `-Werror` останавливается: забытый в сценарии ключ виден сразу. Какие значения действуют и откуда они взяты, видно в начале `.s`.

## 13. include и библиотека

include "console.b16"
include "../lib/string.b16"


Правила:

- `include "файл"` стоит в начале строки и подставляет текст файла на её место; `include asm "файл"` подключает ассемблерный модуль (раздел 12).
- Путь ищется сначала относительно включающего файла, затем в каталогах `-I`. `run.sh` добавляет `-I lib`.
- Каждый файл включается **один раз**, повторные `include` того же файла (в том числе через другие файлы) игнорируются. Отдельные «стражи» не нужны.
- Глубина вложенности не больше 16.
- Ошибки сообщаются с именем и строкой включённого файла.

Порядок объявлений не важен, а недостижимые функции в образ не попадают. Поэтому библиотеку можно подключать целиком и в любом месте файла. Платить придётся только за глобальные переменные библиотеки: они остаются в образе, даже если не используются.

### Стандартная библиотека

**`io.b16`** — текстовый ввод-вывод для программ, которые работают и на голой машине, и под монитором. Способ запуска определяется по сигнатуре по адресу `$0004` при каждом обращении (`on_monitor()`). На голой машине вывод идёт в порт консоли, ввод — опросом клавиатуры; под монитором — вызовами `WRITE` и `READ` в стандартные потоки, поэтому `prog > файл` и `prog < файл` работают. Вызовы монитора сделаны вставками `asm`, `sys.asm` не нужен. Имена функций вывода те же, что в `console.b16`, поэтому две библиотеки вместе не подключаются. Только для цели `std`.

| Функция | Действие |
|---|---|
| `putc`, `puts`, `putnl`, `putu`, `puti`, `puthex`, `putul`, `putl`, `puthexl` | как в `console.b16` |
| `eputs(s)` | сообщение об ошибке: под монитором — в поток 2, на голой машине — на консоль |
| `readc(): uint` | следующий символ, ждёт; `IO_EOF` — ввод кончился (только под монитором) |
| `readline(buf, max): uint, uint` | строка до `\n` и 1, или `0, 0` в конце ввода |
| `on_monitor(): uint` | 1 — программу запустил монитор |

**`console.b16`** — консоль голой машины: порты дисплея и клавиатуры. Под монитором из неё допустим только вывод на дисплей (он не перенаправляется), клавиатуру читает монитор («Системные вызовы», разд. 2.2).

| Функция | Действие |
|---|---|
| `putc(c)`, `puts(s)`, `putnl()` | вывод символа, строки, перевода строки |
| `putu(v)`, `puti(v)`, `puthex(v, цифр)` | числа в десятичном и шестнадцатеричном виде |
| `putul(v)`, `putl(v)`, `puthexl(v, цифр)` | то же для `ulong` и `long` (до 8 шестнадцатеричных цифр) |
| `getc(): byte` | символ клавиши или 0, не ждёт |
| `getkey(): byte, byte` | символ и сканкод |
| `waitc(): byte` | ждать символ (опрос) |
| `readline(buf, max): uint` | строка до `\n`, результат — длина |
| `cls()`, `gotoxy(col, row)`, `setattr(a)`, `setmode(ctrl)` | экран |
| `attr(fg, bg)` | собрать атрибут |

Кроме функций, файл объявляет константы цветов, режимов и команд экрана и порты `CON_*`.

**`string.b16`** — строки.

| Функция | Действие |
|---|---|
| `strlen`, `strcpy`, `strcmp` | как в C |
| `memcpy(d, s, n)`, `memset(d, v, n)` | блоки памяти |
| `toupper(c)` | латиница, кириллица, `ё` |
| `isdigit`, `isspace` | классы символов |
| `atou(s): uint, ptr byte` | число и остаток строки |
| `utoa(v, buf): ptr byte` | число в строку (буфер от 6 байт) |
| `atoul(s): ulong, ptr byte` | то же для `ulong` |
| `ultoa(v, buf): ptr byte` | то же для `ulong` (буфер от 11 байт) |

**`math.b16`** — математика над `int`, `uint`, `long` и `ulong`.

| Функция | Действие |
|---|---|
| `abs`, `min`, `max`, `umin`, `umax` | обычные |
| `gcd`, `isqrt` | НОД, целый корень |
| `mul16x16(a, b): ulong` | полное 32-битное произведение двух слов (MUL + MULHU) |
| `labs`, `lmin`, `lmax`, `ulmin`, `ulmax` | модуль, минимум и максимум для `long` и `ulong` |
| `lgcd`, `ulsqrt` | НОД и целый корень для `ulong` |
| `srand(seed)`, `rand()` | ГПСЧ xorshift16 |

## 14. Как писать быстрый и короткий код

Список ниже — то, что заметно меняет вывод компилятора. Проверять стоит по `.s`: всё видно сразу.

1. **Держите горячие данные в локальных переменных.** Глобальная в цикле — два слова на каждое обращение и лишние команды загрузки; локальная — регистр.
2. **Предпочитайте `uint`, если знак не нужен.** Деление и остаток `uint` — одна команда, `int` на переменную — вызов функции. Сравнения стоят одинаково.
3. **Считайте вниз до нуля.** `while n != 0 { ...; n-- }` не тратит команду на сравнение: флаги даёт `SUB`.
4. **Проходите массивы указателем.** `*p++` — режим адресации, `a[i]` со словами — три команды.
5. **Не берите адрес у локальных переменных без нужды.** `&x` переносит `x` в память во всей функции. Если нужен «выходной параметр», верните второе значение.
6. **Используйте несколько результатов** вместо структуры или указателей для возврата пары значений.
7. **Сохраняйте результат вызова в переменную,** если после него в том же выражении есть ещё вызов. Иначе промежуточное значение пойдёт через `PUSH`/`POP`.
8. **Оставляйте маленькие функции листовыми.** Листовая функция с переменными в R0–R3 не имеет пролога. Один вызов внутри (в том числе знаковое деление на переменную) добавляет `PUSH LR`, `POP IP` и сохранение R4+.
9. **Константы 0..15 дешевле.** Маски, шаги и смещения малых размеров помещаются в команду. Поля структуры по смещению 0 доступны однословной командой.
10. **Группируйте байтовые поля структур,** чтобы не платить за выравнивание.
11. **Не держите много переменных в одной большой функции.** Анализа времени жизни нет, и девятая переменная уйдёт в стек. `long` занимает два регистра.
12. **Для `ulong` делите на константу 3…255, а не на переменную.** Это вызов в девять раз короче общего 32-битного деления; степень двойки — сдвиг или маска.
13. **Умножайте слова в `ulong` приведением:** `ulong(a) * ulong(b)` — `MUL` и `MULHU` без вызова.

## 15. Подводные камни

- **Перевод строки как конец оператора.** `x = a`, перенос и `- b` на следующей строке — это два оператора: второй вычисляется и пропадает, компилятор предупреждает «value of the expression is not used» (с `+` будет ошибка, унарного плюса нет). При переносе оставляйте операцию в конце строки.
- **`char` знаковый.** Для текста, особенно кириллицы, используйте `byte`.
- **`var s = "..."` локально и глобально — разные типы.** Глобальная получает массив, локальная — указатель на строку в данных, писать в неё нельзя. Изменяемая строка в функции — массив с типом: `var s: [8]byte = "tmp0000"`.
- **Знаковое `/` на переменную неявно вызывает функцию.** Функция с ним перестаёт быть листовой.
- **Сдвиг на переменную берётся по модулю 16 (для `long` и `ulong` — по модулю 32).** `x << n` при `n = 16` даёт `x`, а не 0.
- **Порядок побочных эффектов в одном выражении не определён.**
- **`asm`-блок ничего не сообщает компилятору.** Если он портит регистр переменной или R4–R11, последствия ваши.
- **Ввод после конца stdin — нули.** `waitc()` и `readline()` будут ждать вечно.
- **Стек не проверяется.** Глубокая рекурсия или большие локальные массивы молча залезут в данные: стек растёт вниз навстречу `__bss_end`. Под монитором запас задаёт `option min_free`.
- **Строковые литералы при `option data` не копируются в ОЗУ.** Писать в литерал нельзя; изменяемый текст объявляйте массивом: глобальным `var s = "..."` или локальным `var s: [N]byte = "..."`.

## 16. Чего нет и почему

- **`switch`, `for`, `do-while`.** Их покрывают `if`/`else if`, `while`, `loop` и `goto`. Таблицы переходов удобнее строить явно через указатели на функции.
- **Структуры по значению, их присваивание и сравнение.** Это скрытое копирование, противоречащее идее языка.
- **Плавающая точка и 64-битные типы.** На CPU16 это библиотечный код, его стоит видеть. Есть целые 32 бита (`long`, `ulong`); 64 бита и числа с плавающей точкой пишутся библиотекой поверх них.
- **Препроцессор.** Константы дают `const`, подключение файлов — `include`, макросов нет.
- **Анализ времени жизни и глобальное распределение регистров.** Это сознательное ограничение реализации: генератор остаётся простым и предсказуемым, вывод можно читать без отладчика. Цена — лишние пересылки в нелистовых функциях (параметры копируются в R4+, даже если нужны только до первого вызова) и ограничение на число регистровых переменных.
- **Оптимизация хвостовых вызовов**, объединение одинаковых строк.

Примеры в `examples/` показывают все возможности языка на работающих программах. Они разложены по тому, где работают (подробно — `examples/README.md`):

- **`universal/`** — один образ работает и на голой машине, и под монитором. Текст выводится через `io.b16` (раздел 13): на голой машине — в порт консоли, под монитором — в стандартный поток, который можно перенаправить в файл.
- **`bare/`** — только голая машина: своя таблица векторов, таймер, клавиатура из порта, экран.
- **`monitor/`** — только под монитором: аргументы, файлы, процессы, клавиатура и время через системные вызовы. Собираются сценарием монитора `tools/b16build.sh`.

Примеры `universal/` и `bare/` проверяются в `make test` против эталонного вывода, `universal/` — ещё и в имитации монитора; примеры `monitor/` тест только собирает.

| Пример | Где | Что показывает |
|---|---|---|
| `hello` | universal | минимальная программа, `include` |
| `sieve` | universal | массивы, вложенные циклы, константы |
| `sort` | universal | рекурсия, указатели на функции, арифметика указателей, строки |
| `list` | universal | структуры, пул памяти, указатель на указатель, несколько результатов |
| `calc` | universal | рекурсивный разбор, пары «значение + позиция», знаковое деление |
| `fsm` | universal | конечный автомат на `goto` |
| `dispatch` | universal | таблица команд: структуры с указателями на функции |
| `funcptr` | universal | обратные вызовы (map/filter/reduce), таблица переходов для байт-кода, «объекты» с таблицей методов, автомат на указателях, функция как результат |
| `bignum` | universal | `ulong`: факториалы и числа Фибоначчи до 2³², `putul` |
| `echo` | universal | ввод строк (клавиатура или стандартный ввод), кириллица в `toupper` |
| `asmlink/` | universal | компоновка с отдельным `.asm`-модулем (`include asm`), вызовы в обе стороны |
| `tetris` | universal | одна игра для обоих способов запуска: векторы и таймер на голой машине, `TICKS`, `SLEEP`, `CON_GETEV` под монитором |
| `inline_asm` | bare | функции на ассемблере, флаги и перенос, локальные метки, глобальные по имени, вызов B16 из `asm`, обработчики ERRCMD и TRAP |
| `devices` | bare | регистры консоли как структура по адресу, клавиатура со сканкодами |
| `life` | bare | «Жизнь»: двумерные массивы, указатели на массивы, `char`, экран |
| `clock` | bare | таймер, обработчик прерывания, `atomic` |
| `timer` | bare | минимальный обработчик IRQ0 и `wfi` |
| `screen` | bare | координатный режим, псевдографика, цвета, режим атрибутов |
| `demo` | bare | обзор языка в одном файле |
| `tetris_bare` | bare | тетрис только для голой машины |
| `args` | monitor | `argv`, флаги перенаправления в PIB, `MEMINFO`, поток ошибок, код завершения, `option max_top` |
| `wc` | monitor | файлы: `OPEN`, `READ`, `CLOSE`, ошибки в стиле утилит, счётчики `ulong` |
| `keys` | monitor | сырой режим консоли, события `CON_GETEV` с модификаторами |
| `ticks` | monitor | `TICKS`, `SLEEP`, обработчик тика `TICKHOOK`, обработчик Ctrl+C `ONEVENT` |
| `run` | monitor | `SPAWN` с перенаправлением вывода в файл, код завершения потомка |