Введение в архитектуру и язык ассемблера CPU16

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

Здесь опущены детали: точные правила допустимости кодировок, все крайние случаи флагов, полная грамматика языка. Если нужна точность, обращайтесь к двум документам:

Инструменты:

Инструмент Назначение
cpu16asm ассемблер: cpu16asm prog.asm → prog.bin; ключи -l (листинг), -m (карта символов)
cpu16 эмулятор: cpu16 prog.bin; ввод программы — из stdin, -t — трассировка
веб-отладчик редактор, сборка, пошаговая отладка в браузере
stdlib.asm стандартная библиотека (приложение А), подключается через .include

1. Первая программа

        LD      SP, #$FF00      ; стек
        LD      R0, #hello      ; адрес строки
        CALL    puts            ; печать (из библиотеки)
        WFI                     ; остановка
hello:  .asciz  "Hello, CPU16!\n"
        .include "stdlib.asm"
Hello, CPU16!

Сборка и запуск в командной строке (stdlib.asm лежит рядом с программой):

cpu16asm hello.asm
cpu16 hello.bin

В веб-отладчике: вставьте текст на вкладку «Source», нажмите Ctrl+B, затем F9. Библиотека встроена в отладчик, .include "stdlib.asm" работает и там.

WFI (wait for interrupt) — ожидание прерывания. Пока прерывания запрещены и никто их не запрашивает, процессор просто стоит. Эмулятор в этом состоянии завершает работу, поэтому WFI удобно использовать как «стоп».

2. Процессор в общих чертах

2.1. Память и данные

2.2. Регистры

Шестнадцать 16-битных регистров:

Регистр Имя Назначение
R0..R11 — общего назначения
R12 LR адрес возврата: команда CALL пишет его сюда, а не в стек
R13 SP указатель стека, стек растёт вниз
R14 IP указатель команд
R15 FLAGS флаги

Важная особенность: IP и FLAGS — обычные регистры. Запись в IP — это переход (LD IP, LR — возврат из подпрограммы), а флаги можно читать, сохранять в стек и менять командами AND, OR, XOR.

По соглашению стандартной библиотеки R0..R3 — оперативные регистры: через них передаются аргументы и результаты, и подпрограмма может их портить. R4..R11 подпрограмма обязана сохранить (раздел 5.6).

2.3. Флаги

Флаг Бит Маска Смысл
ZF 0 $01 результат равен нулю
CF 1 $02 перенос при сложении; при вычитании - заём: после SUB или CMP CF = 1, если уменьшаемое меньше вычитаемого (без знака)
SF 2 $04 знак результата (бит 15)
IF 3 $08 прерывания разрешены
OF 4 $10 знаковое переполнение; у умножения и деления — свой смысл (раздел 5.2)

Флаги меняют только команды арифметики, логики и сдвигов. Пересылки (LD, ST, PUSH, POP) флаги не меняют. Чтобы проверить загруженное значение, нужен TEST R0, R0 или CMP R0, #0.

2.4. Общие принципы системы команд

3. Язык ассемблера в двух словах

Строка программы состоит из необязательной метки, команды или директивы и комментария после ;:

; комментарий до конца строки
        .equ    COUNT, 10       ; константа COUNT = 10
start:  LD      R0, #COUNT      ; метка start, команда LD, операнды R0 и #COUNT
table:  .word   1, 2, $FFFF     ; метка table и данные: три слова
text:   .asciz  "abc"           ; метка text и строка с нулём в конце

3.1. Метки

Метка — это имя с двоеточием в начале строки. Её значение — адрес того, что идёт за ней: команды или данных. Дальше в программе имя метки можно использовать везде, где нужен этот адрес: как цель перехода (JMP start), как адрес в памяти (LD R0, [table]), как константу-указатель (LD R0, #text).

Метки бывают двух видов.

clear:  LD      R2, #0          ; обычная метка clear открывает область
@loop:  STB     [R0++], R2      ; @loop из области clear
        DEC     R1
        JNZ     @loop           ; переход на @loop этой же области
        RET
count:  LD      R2, #0          ; обычная метка count открывает новую область
@loop:  INC     R2              ; другая метка: @loop из области count
        DEC     R1
        JNZ     @loop           ; переход на свой @loop, а не на тот, что в clear
        RET

Из другой области локальная метка не видна: если в третьей подпрограмме написать JMP @loop, а своей @loop там нет, ассемблер сообщит «undefined symbol». Если на место нужно переходить из нескольких подпрограмм, ему нужна обычная метка.

3.2. Числа, выражения, директивы

Основные директивы:

Директива Действие
.org адрес дальнейший код и данные размещаются с этого адреса
.word, .byte слова и байты
.ascii, .asciz строка (у .asciz — с нулём в конце)
.space n n байт, заполненных нулями
.align n выровнять адрес на n
.equ имя, значение константа
.include "файл" вставить текст файла

4. Способы адресации

4.1. Метки, адреса и указатели

Метка — это просто число, адрес. Поэтому одно и то же имя даёт разные вещи в зависимости от записи операнда:

Запись Что получается Пример: numbers лежит по адресу $0100, там слово 100
#numbers сам адрес, то есть указатель LD R1, #numbers → R1 = $0100
[numbers] содержимое памяти по этому адресу LD R0, [numbers] → R0 = 100
[R1] содержимое по адресу, который лежит в регистре при R1 = $0100: LD R0, [R1] → R0 = 100

Указатель в регистре можно сдвигать: на следующее слово — на 2 (ADD R1, #2 или автоинкремент [R1++]), на следующий байт — на 1. Со смещением [R1+4] читается слово на 4 байта дальше, то есть третий элемент от указателя. Запись [R2+numbers] — это элемент массива по байтовому смещению из R2: для слов индекс надо умножить на 2.

        LD      SP, #$FF00
        LD      R1, #numbers    ; R1 = адрес массива (указатель)
        LD      R0, R1
        CALL    print_hex16     ; $0100 - сам указатель
        CALL    newline
        LD      R0, [numbers]   ; содержимое по адресу метки
        CALL    print_u16       ; 100
        CALL    newline
        LD      R1, #numbers
        LD      R0, [R1+4]      ; третий элемент: смещение 2 слова = 4 байта
        CALL    print_u16       ; 300
        CALL    newline
        LD      R2, #3          ; индекс 3
        ADD     R2, R2          ; * 2: байтовое смещение
        LD      R0, [R2+numbers]
        CALL    print_u16       ; 400
        WFI
        .org    $0100
numbers: .word  100, 200, 300, 400, 500
        .include "stdlib.asm"
0100
100
300
400

Обратите внимание: LD R1, #numbers не читает память, а только кладёт адрес в регистр. Все обращения к самим данным — это операнды в квадратных скобках.

4.2. Таблица способов адресации

Способ адресации есть только у второго операнда. Пусть R1 = $2000:

Запись Операнд Пример
R1 значение регистра ADD R0, R1
#выр константа ADD R0, #100
[R1] слово по адресу R1 LD R0, [R1]
[R1++] слово по адресу R1, затем R1 += 2 ADD R0, [R1++]
[--R1] R1 −= 2, затем слово по адресу R1 LD R0, [--R1]
[адрес] слово по абсолютному адресу LD R0, [counter]
[R1+выр] слово по адресу R1 + смещение LD R0, [R1+4]
R1+выр сумма без обращения к памяти LEA R0, R1+4

Пример: обход массива. R1 — указатель на текущий элемент: [R1++] читает элемент, на который он указывает, и сразу сдвигает указатель на следующее слово.

        LD      SP, #$FF00
        LD      R1, #numbers    ; R1 = адрес первого элемента
        LD      R2, #5          ; счётчик элементов
        LD      R0, #0          ; сумма
@sum:   ADD     R0, [R1++]      ; прибавить элемент по адресу R1, R1 += 2
        DEC     R2
        JNZ     @sum
        CALL    print_u16
        WFI
numbers: .word  100, 200, 300, 400, 500
        .include "stdlib.asm"
1500

5. Команды

5.1. Пересылка данных

Команда Действие
LD Rd, src (или MOV) Rd = src
ST [адрес], Rs слово Rs в память
LDZX Rd, src / LDSX Rd, src байт из src с расширением нулём / знаком
STB [адрес], Rs младший байт Rs в память
XCHB [адрес], Rs атомарный обмен байтом (для семафоров)
LEA Rd, Rs+выр Rd = Rs + выр (адрес без обращения к памяти)
PUSH Rs, POP Rd стек
        LD      R0, R1          ; копия регистра
        LD      R0, #msg        ; адрес метки
        LD      R0, [msg]       ; слово по адресу метки
        ST      [result], R0    ; запись в память
        LDZX    R2, [R3++]      ; очередной байт строки
        PUSH    R4              ; сохранить регистр
        POP     R4
        LD      IP, R5          ; переход по адресу в R5

5.2. Арифметика

Команда Действие Флаги
ADD, ADC Rd = Rd + src (+ CF) все
SUB, SBC Rd = Rd − src (− CF); CF = заём все
CMP как SUB, но Rd не меняется все
NEG Rd Rd = −Rd все
INC Rd, DEC Rd ± 1 (это ADD/SUB с #1, CF тоже меняется) все
MUL Rd, src младшее слово произведения CF = OF = 1, если старшее слово не 0
MULHU Rd, src старшее слово произведения без знака CF = OF = 0
DIVU Rd, src частное без знака OF = 1 при делении на 0
REMU Rd, src остаток без знака OF = 1 при делении на 0

Пример: 10! в 32 битах через mul32 из библиотеки.

        LD      SP, #$FF00
        LD      R0, #1          ; R1:R0 = 1
        LD      R1, #0
        LD      R4, #2          ; множитель (R4 библиотека сохраняет)
@next:  LD      R2, R4          ; R3:R2 = R4
        LD      R3, #0
        CALL    mul32           ; R1:R0 *= R3:R2
        INC     R4
        CMP     R4, #10
        JBE     @next
        CALL    print_u32
        WFI
        .include "stdlib.asm"
3628800

5.3. Логика и биты

Команда Действие Флаги
AND, OR, XOR побитовые операции ZF, SF; CF = OF = 0
TEST Ra, src как AND, но Ra не меняется ZF, SF; CF = OF = 0
NOT Rd инверсия не меняет
BSX Rd знаковое расширение младшего байта не меняет

Приёмы с битом номер 5 (маска $20):

        OR      R0, #$20        ; установить
        AND     R0, #~$20       ; сбросить
        XOR     R0, #$20        ; инвертировать
        TEST    R0, #$20        ; проверить: JNZ - бит установлен
        JNZ     bit_set

5.4. Сдвиги

Команда Действие
SHL Rd, n логический влево
SHR Rd, n логический вправо
SAR Rd, n арифметический вправо (копирует знак)
ROR Rd, n циклический вправо

Пример: подсчёт единичных битов, где выдвинутый бит попадает в CF и прибавляется через ADC.

        LD      SP, #$FF00
        LD      R1, #$BEEF      ; число
        LD      R0, #0          ; счётчик
@bit:   SHR     R1, #1          ; младший бит -> CF
        ADC     R0, #0          ; R0 += CF
        TEST    R1, R1
        JNZ     @bit
        CALL    print_u16
        WFI
        .include "stdlib.asm"
13

5.5. Переходы

JMP метка — безусловный переход. Условные переходы проверяют флаги после CMP, SUB, TEST и т. п.:

Условие Без знака Со знаком
равно / не равно JZ (JE) / JNZ (JNE) те же
меньше JB (JC) JL
меньше или равно JBE JLE
больше JA JG
больше или равно JAE (JNC) JGE
знак, переполнение JS, JNS JO, JNO

Пример: одна и та же пара чисел как беззнаковые и как знаковые.

        LD      SP, #$FF00
        LD      R4, #$FFFF      ; 65535 без знака, -1 со знаком
        CMP     R4, #1
        JB      @u_less
        LD      R0, #msg_u_ge
        JMP     @u_done
@u_less: LD     R0, #msg_u_lt
@u_done: CALL   puts
        CMP     R4, #1
        JL      @s_less
        LD      R0, #msg_s_ge
        JMP     @s_done
@s_less: LD     R0, #msg_s_lt
@s_done: CALL   puts
        WFI
msg_u_lt: .asciz "unsigned: less\n"
msg_u_ge: .asciz "unsigned: greater or equal\n"
msg_s_lt: .asciz "signed: less\n"
msg_s_ge: .asciz "signed: greater or equal\n"
        .include "stdlib.asm"
unsigned: greater or equal
signed: less

Пример: таблица переходов.

        LD      SP, #$FF00
        LD      R0, #2          ; номер случая 0..2
        ADD     R0, R0          ; * 2: размер слова
        JMP     [R0+cases]      ; адрес берётся из таблицы
cases:  .word   case0, case1, case2
case0:  LD      R0, #'A'
        JMP     show            ; общая метка: локальная @show из case0 не видна
case1:  LD      R0, #'B'
        JMP     show
case2:  LD      R0, #'C'
show:   CALL    putc
        WFI
        .include "stdlib.asm"
C

5.6. Подпрограммы

leaf:   ADD     R0, R1          ; листовая: стек не нужен
        RET

outer:  PUSH    LR              ; вызывает другие: сохранить LR
        PUSH    R4              ; и все R4..R11, которые портит
        ...
        CALL    leaf
        ...
        POP     R4
        POP     IP              ; возврат по сохранённому LR

Соглашение о вызове (его соблюдает стандартная библиотека):

Пример: пятый аргумент в стеке.

        LD      SP, #$FF00
        LD      R0, #1
        LD      R1, #2
        LD      R2, #3
        LD      R3, #4
        LD      R4, #5
        PUSH    R4              ; 5-й аргумент
        CALL    sum5
        ADD     SP, #2          ; снять аргумент
        CALL    print_u16
        WFI

; sum5: R0 = R0 + R1 + R2 + R3 + [SP] (5-й аргумент)
sum5:   ADD     R0, R1
        ADD     R0, R2
        ADD     R0, R3
        ADD     R0, [SP]        ; CALL ничего не кладёт в стек: аргумент на вершине
        RET
        .include "stdlib.asm"
15

Если подпрограмма сделала PUSH LR, тот же аргумент окажется по адресу [SP+2].

Хвостовой вызов. Если последнее действие подпрограммы — вызов другой подпрограммы, вместо CALL и RET достаточно JMP: LR уже указывает на вызывающего. Так в библиотеке устроены print_s16 и print_hex32.

5.7. Прерывания

Вектор Назначение
0..7 IRQ0..IRQ7
8 ERRCMD
9..15 программные вызовы TRAP #9..TRAP #15

При входе в прерывание процессор кладёт в стек IP, затем FLAGS, сбрасывает IF и переходит по вектору. IRET восстанавливает FLAGS и IP.

Команда Действие
STI, CLI разрешить / запретить прерывания (CLI попутно обнуляет OF)
TRAP #n программное прерывание по вектору n
IRET возврат из обработчика
WFI ждать прерывания

Обработчик обязан сохранить всё, что меняет, включая R0..R3 и LR. Прерванный код никого не вызывал и не ожидает, что эти регистры изменятся. Флаги восстановит IRET.

on_irq: PUSH    LR
        PUSH    R0
        PUSH    R1
        PUSH    R2
        PUSH    R3
        ...                     ; работа, в том числе вызовы библиотеки
        POP     R3
        POP     R2
        POP     R1
        POP     R0
        POP     LR
        IRET

Критическая секция, которая восстанавливает прежнее состояние IF:

        PUSH    FLAGS
        CLI
        ...                     ; прерывания запрещены
        POP     FLAGS

5.8. Ввод-вывод

Консоль занимает порт $FF20, в библиотеке он доступен как константа CONSOLE:

Порт нужно читать байтовыми командами, а байт 0 во вводе неотличим от «ввода нет». В эмуляторе командной строки ввод идёт из stdin, в веб-отладчике — из панели консоли.

Это лишь самый простой способ работы с консолью. Та же консоль — дисплей 80×25 с цветом и выводом в любую позицию, а клавиатура выдаёт ещё и сканкоды клавиш; рядом есть таймер 50 Гц. Всё это описано в документе «Машина CPU16: память и устройства», подпрограммы — в приложении А, а примеры display_demo.asm, keys.asm, stopwatch.asm и snake.asm показывают их в работе.

Пример: прочитать строку и вывести её задом наперёд.

        LD      SP, #$FF00
        LD      R0, #buf
        LD      R1, #32         ; размер буфера
        LD      R2, #0          ; без эха
        CALL    gets            ; R0 = длина
        LD      R4, R0
        ADD     R4, #buf        ; R4 = адрес за последним символом
@back:  CMP     R4, #buf
        JZ      @done
        DEC     R4
        LDZX    R0, [R4]
        CALL    putc
        JMP     @back
@done:  CALL    newline
        WFI
buf:    .space  32
        .include "stdlib.asm"
desserts

6. Память системы и запуск программы

6.1. Типичная раскладка памяти

Адреса Что лежит
$0000..$7FFF ПЗУ: код, константы (строки, таблицы), библиотека
$8000.. ОЗУ: переменные, растут вверх
..$FEFF ОЗУ: стек, растёт вниз от $FF00
$FF00..$FF1F ОЗУ: таблица векторов прерываний (16 слов)
$FF20..$FFFF устройства ввода-вывода ($FF20 — консоль)
$0000 +----------------------+
      | код, константы, ПЗУ  |
$8000 +----------------------+
      | переменные     |     |
      |                v     |
      |                      |
      |                ^     |
      | стек           |     |
$FF00 +----------------------+
      | векторы прерываний   |
$FF20 +----------------------+
      | ввод-вывод           |
$FFFF +----------------------+

Два следствия:

В эмуляторе вся память — ОЗУ, и образ загружается целиком. Но если программа следует этим правилам, она будет работать и на настоящей системе.

6.2. Что происходит при сбросе

После сброса:

Значит, первая команда программы должна лежать по адресу 0, а первым делом нужно установить стек.

6.3. Стандартный пролог

  1. Стек: LD SP, #$FF00. Первое слово ляжет в $FEFE, сразу под таблицей векторов.
  2. Вектор ERRCMD — первым делом. Тогда ошибка в любом последующем коде будет поймана.
  3. Остальные векторы — на заглушку, которая сообщает о неожиданном прерывании. Затем ставятся рабочие обработчики.
  4. Обнуление переменных (memset по области .space).
  5. STI — разрешить прерывания.
  6. CALL main, а после возврата — остановка: CLI, затем WFI в цикле.

6.4. Скелет программы

Полный пример (файл skeleton.asm).

        .equ    RAM_START,  $8000
        .equ    VECTORS,    $FF00       ; 16 слов: IRQ0..7, ERRCMD (8), TRAP 9..15
        .equ    VEC_ERRCMD, VECTORS + 2*8
        .equ    STACK_TOP,  $FF00

; ---------------- ПЗУ: код ----------------
        .org    $0000
reset:  LD      SP, #STACK_TOP          ; 1. стек
        LD      R0, #on_errcmd          ; 2. вектор ERRCMD - первым делом
        ST      [VEC_ERRCMD], R0
        LD      R0, #VECTORS            ; 3. все векторы - на заглушку
        LD      R1, #on_unexpected
        LD      R2, #16
@vec:   ST      [R0++], R1
        DEC     R2
        JNZ     @vec
        LD      R0, #on_errcmd          ;    и рабочие обработчики
        ST      [VEC_ERRCMD], R0
        LD      R0, #on_tick
        ST      [VECTORS + 2*0], R0     ;    IRQ0 - таймер
        LD      R0, #bss_start          ; 4. обнулить переменные
        LD      R1, #0
        LD      R2, #bss_end - bss_start
        CALL    memset
        STI                             ; 5. разрешить прерывания
        CALL    main                    ; 6. программа
halt:   CLI                             ;    и остановка
@stop:  WFI
        JMP     @stop

main:   PUSH    LR
        LD      R0, #msg_hello
        CALL    puts
        LD      R0, #msg_ask
        CALL    puts
        LD      R0, #1                  ; с эхом
        CALL    read_dec
        TEST    R1, R1
        JNZ     @bad
        LD      R4, R0
        LD      R0, #msg_hex
        CALL    puts
        LD      R0, R4
        CALL    print_hex16
        LD      R0, #msg_square
        CALL    puts
        LD      R0, R4
        LD      R1, R4
        CALL    mulu16                  ; R1:R0 = R4 * R4
        CALL    print_u32
        CALL    newline
        JMP     @wait
@bad:   LD      R0, #msg_bad
        CALL    puts
@wait:  LD      R0, #msg_wait
        CALL    puts
@tick:  WFI                             ; ждать прерываний
        LD      R0, [ticks]
        CMP     R0, #3
        JB      @tick
        LD      R0, #msg_done
        CALL    puts
        POP     IP

on_tick:                                ; IRQ0: сохраняет всё, что трогает
        PUSH    LR
        PUSH    R0
        PUSH    R1
        PUSH    R2
        PUSH    R3
        LD      R0, [ticks]
        INC     R0
        ST      [ticks], R0
        LD      R0, #'.'
        CALL    putc
        POP     R3
        POP     R2
        POP     R1
        POP     R0
        POP     LR
        IRET

on_errcmd:                              ; в стеке: [SP] - FLAGS, [SP+2] - адрес команды
        LD      R0, #msg_errcmd
        CALL    puts
        LD      R0, [SP+2]
        CALL    print_hex16
        CALL    newline
        JMP     halt

on_unexpected:
        LD      R0, #msg_unexp
        CALL    puts
        JMP     halt

msg_hello:  .asciz  "CPU16 skeleton\n"
msg_ask:    .asciz  "Enter a number: "
msg_hex:    .asciz  "hex: $"
msg_square: .asciz  ", square: "
msg_bad:    .asciz  "\nnot a number\n"
msg_wait:   .asciz  "Waiting for 3 timer interrupts (IRQ 0)"
msg_done:   .asciz  " done\n"
msg_errcmd: .asciz  "\nERRCMD at $"
msg_unexp:  .asciz  "\nunexpected interrupt\n"

        .include "stdlib.asm"

; ---------------- ОЗУ: переменные ----------------
        .org    RAM_START
bss_start:
ticks:  .space  2
bss_end:

Вывод в эмуляторе командной строки (ввод 250; ожидание прерываний не заканчивается):

CPU16 skeleton
Enter a number: 250
hex: $00FA, square: 62500
Waiting for 3 timer interrupts (IRQ 0)

В веб-отладчике после трёх нажатий IRQ 0 к последней строке добавится ... done.

Если в такой программе встретится недопустимая команда, обработчик ERRCMD напечатает её адрес, например ERRCMD at $0044, и остановит процессор.

7. Инструменты

Приложение А. Стандартная библиотека

Файл stdlib.asm. Подключается строкой .include "stdlib.asm", обычно после основного кода: библиотека не использует переменных в памяти и может лежать в ПЗУ. Она также определяет константу CONSOLE = $FF20.

32-битные числа передаются парой регистров: старшее слово в R1 (R3), младшее в R0 (R2); запись R1:R0.

Вывод

Подпрограмма Вход Результат
putc R0 — символ —
newline — перевод строки
puts R0 — адрес строки с нулём в конце —
print_u16 / print_s16 R0 число без знака / со знаком
print_hex8 / print_hex16 R0 2 / 4 шестнадцатеричные цифры
print_u32 / print_s32 R1:R0 32-битное число без знака / со знаком
print_hex32 R1:R0 8 шестнадцатеричных цифр

Ввод

Подпрограмма Вход Результат
getc — R0 — байт ввода; ждёт его появления
getc_nb — R0 — байт или 0 без ожидания; ZF = 1, если 0
gets R0 — буфер, R1 — его размер (≥ 1), R2 — эхо (0/1) строка без \n с нулём в конце; R0 — длина; обрабатывает возврат на символ
read_dec R0 — эхо (0/1) R0 — число (может быть со знаком -); R1 = 0 — успех, 1 — нет цифр или переполнение

read_dec пропускает начальные пробелы и забирает из ввода символ, на котором число закончилось, например перевод строки.

Память и строки

Подпрограмма Вход Результат
memcpy R0 — куда, R1 — откуда, R2 — число байт —
memset R0 — куда, R1 — байт, R2 — число байт —
strlen R0 — строка R0 — длина

32-битная арифметика (a = R1:R0, b = R3:R2)

Подпрограмма Результат
add32 / sub32 R1:R0 = a ± b; CF — перенос / заём
neg32 R1:R0 = −a
cmp32u флаги для JZ, JNZ, JB, JAE, JA, JBE (сравнение без знака)
mulu16 R1:R0 = R0 × R1 (16 × 16 = 32 бита)
mul32 R1:R0 = младшие 32 бита a × b
divu32 a / R2: R1:R0 — частное, R2 — остаток; деление на 0 — OF = 1, частное $FFFFFFFF

Дисплей, клавиатура, таймер

Устройства описаны в документе «Машина CPU16: память и устройства». Библиотека определяет константы портов (CON_CTRL, CON_ATTR, CON_COL, CON_ROW, CON_CMD, TIMER), режимов (MODE_TTY, MODE_CURSOR, MODE_ATTR), команд (CMD_CLEAR, CMD_CLEOL…), цветов (BLACK…WHITE, UNDERLINE, INVERSE; атрибут — ФОН << 3 | ТЕКСТ) и сканкодов (SC_ESC, SC_ENTER, SC_UP, SC_F1…).

Подпрограмма Вход Результат
scr_mode R0 — режим (MODE_TTY | MODE_CURSOR | MODE_ATTR; 0 — координатный без курсора) —
scr_attr R0 — атрибут —
scr_goto R0 — столбец, R1 — строка —
scr_clear — экран очищен текущим атрибутом, позиция (0, 0)
scr_print_at R0 — столбец, R1 — строка, R2 — строка текста —
scr_paint R0 — атрибут, R1 — число ячеек атрибуты ячеек от позиции изменены, текст цел
scr_box R0, R1 — левый верхний угол, R2 — ширина, R3 — высота двойная рамка (в координатном режиме)
getkey — R0 — символ (младший байт) и сканкод (старший); ждёт нажатия
getkey_nb — R0 — символ и сканкод или 0 без ожидания; ZF = 1, если 0
timer_on, timer_off — таймер 50 Гц на IRQ0 включён / выключен

Специальные клавиши (стрелки, Esc, F1–F12) проверяйте по сканкоду, печатаемые (пробел, буквы) — по символу: при вводе из stdin в эмуляторе командной строки у обычных символов сканкода нет.

Все подпрограммы сохраняют R4..R11.

Исходный текст

; stdlib.asm - небольшая стандартная библиотека CPU16 (спецификация, ревизия 6).
;
; Подключение: .include "stdlib.asm" в том месте программы, где должен лежать код
; библиотеки (обычно после основного кода, в ПЗУ). Библиотека не использует
; переменных в памяти, поэтому её можно размещать в ПЗУ.
;
; Соглашение о вызове
;   - аргументы в R0..R3 (если их больше - через стек, их снимает вызывающий);
;   - результат в R0..R3;
;   - R0..R3, LR и FLAGS подпрограмма может портить (оперативные регистры);
;   - R4..R11 и SP подпрограмма обязана сохранить;
;   - 32-битные числа - в паре регистров: старшее слово в R1 (R3), младшее в R0 (R2),
;     запись R1:R0.
;
; Консоль - порт $FF20 («Машина CPU16: память и устройства»): запись байта выводит символ,
; чтение байта даёт символ следующего нажатия или 0, если нажатий нет; чтение слова - символ
; и сканкод. Байт 0 во вводе неотличим от «ввода нет», поэтому getc ждёт ненулевой байт.
; Раздел «Дисплей, клавиатура, таймер» в конце файла - координатный вывод, цвет, клавиши
; со сканкодами и таймер 50 Гц.

        .equ    CONSOLE, $FF20

; ============================================================================
; Вывод
; ============================================================================

; putc - вывести символ. R0 - символ.
putc:   STB     [CONSOLE], R0
        RET

; newline - перевод строки.
newline:
        LD      R0, #10
        STB     [CONSOLE], R0
        RET

; puts - вывести строку с нулём в конце. R0 - адрес строки.
puts:   LDZX    R1, [R0++]
        CMP     R1, #0
        JZ      @done
        STB     [CONSOLE], R1
        JMP     puts
@done:  RET

; print_u16 - вывести число без знака в десятичном виде. R0 - число.
print_u16:
        LD      R2, #0          ; число цифр в стеке
@div:   LD      R1, R0
        REMU    R1, #10         ; младшая цифра
        DIVU    R0, #10
        ADD     R1, #'0'
        PUSH    R1
        INC     R2
        CMP     R0, #0
        JNZ     @div
@out:   POP     R1              ; цифры из стека идут от старшей к младшей
        STB     [CONSOLE], R1
        DEC     R2
        JNZ     @out
        RET

; print_s16 - вывести число со знаком в десятичном виде. R0 - число.
print_s16:
        TEST    R0, R0
        JNS     print_u16       ; неотрицательное: печатаем как есть
        LD      R1, #'-'
        STB     [CONSOLE], R1
        NEG     R0              ; -32768 даёт $8000 = 32768, что и нужно
        JMP     print_u16       ; хвостовой вызов: LR указывает на вызывающего

; print_hex8 - вывести младший байт R0 двумя шестнадцатеричными цифрами.
print_hex8:
        SHL     R0, #8          ; байт в старшую половину
        LD      R2, #2
        JMP     _hex_digits

; print_hex16 - вывести R0 четырьмя шестнадцатеричными цифрами.
print_hex16:
        LD      R2, #4
_hex_digits:                    ; R0 - значение (старшие цифры вверху), R2 - число цифр
        ROR     R0, #12         ; циклический сдвиг влево на 4: старшая тетрада вниз
        LD      R1, R0
        AND     R1, #15
        CMP     R1, #10
        JB      @digit
        ADD     R1, #'A'-'0'-10
@digit: ADD     R1, #'0'
        STB     [CONSOLE], R1
        DEC     R2
        JNZ     _hex_digits
        RET

; print_hex32 - вывести R1:R0 восемью шестнадцатеричными цифрами.
print_hex32:
        PUSH    LR
        PUSH    R0
        LD      R0, R1
        CALL    print_hex16     ; старшее слово
        POP     R0
        POP     LR
        JMP     print_hex16     ; младшее слово, хвостовой вызов

; print_u32 - вывести R1:R0 без знака в десятичном виде.
print_u32:
        PUSH    LR
        PUSH    R4
        LD      R4, #0          ; число цифр в стеке
@div:   LD      R2, #10
        CALL    divu32          ; R1:R0 = R1:R0 / 10, R2 = остаток
        ADD     R2, #'0'
        PUSH    R2
        INC     R4
        LD      R3, R0
        OR      R3, R1          ; частное ещё не ноль?
        JNZ     @div
@out:   POP     R0
        STB     [CONSOLE], R0
        DEC     R4
        JNZ     @out
        POP     R4
        POP     IP              ; возврат: на вершине сохранённый LR

; print_s32 - вывести R1:R0 со знаком в десятичном виде.
print_s32:
        TEST    R1, R1
        JNS     print_u32
        LD      R2, #'-'
        STB     [CONSOLE], R2
        NOT     R0              ; R1:R0 = -R1:R0
        NOT     R1
        ADD     R0, #1
        ADC     R1, #0
        JMP     print_u32

; ============================================================================
; Ввод
; ============================================================================

; getc - дождаться байта ввода. Результат: R0 - байт (не 0).
; Если ввод закончился, ждёт вечно (0 во вводе неотличим от «ввода нет»).
getc:   LDZX    R0, [CONSOLE]
        TEST    R0, R0
        JZ      getc
        RET

; getc_nb - взять байт ввода без ожидания. Результат: R0 - байт или 0; ZF = 1, если 0.
getc_nb:
        LDZX    R0, [CONSOLE]
        TEST    R0, R0
        RET

; gets - прочитать строку до перевода строки.
;   R0 - буфер, R1 - размер буфера в байтах (не меньше 1), R2 - эхо (0 - нет, иначе да).
; В буфер кладётся строка без '\n' с нулём в конце; лишние символы отбрасываются.
; Возврат (8 или 127) удаляет последний символ, CR (13) игнорируется.
; Результат: R0 - длина строки.
gets:   PUSH    LR
        PUSH    R4
        PUSH    R5
        PUSH    R6
        PUSH    R7
        LD      R4, R0          ; начало буфера
        LD      R5, R0          ; текущая позиция
        LD      R6, R1
        DEC     R6              ; сколько символов ещё поместится
        LD      R7, R2          ; эхо
@next:  CALL    getc
        CMP     R0, #10
        JZ      @end
        CMP     R0, #13
        JZ      @next
        CMP     R0, #8
        JZ      @back
        CMP     R0, #127
        JZ      @back
        TEST    R6, R6
        JZ      @next           ; буфер полон
        STB     [R5++], R0
        DEC     R6
        TEST    R7, R7
        JZ      @next
        STB     [CONSOLE], R0
        JMP     @next
@back:  CMP     R5, R4
        JZ      @next           ; удалять нечего
        DEC     R5
        INC     R6
        TEST    R7, R7
        JZ      @next
        LD      R0, #8          ; эхо: шаг назад, пробел, шаг назад
        STB     [CONSOLE], R0
        LD      R0, #' '
        STB     [CONSOLE], R0
        LD      R0, #8
        STB     [CONSOLE], R0
        JMP     @next
@end:   LD      R0, #0
        STB     [R5], R0
        TEST    R7, R7
        JZ      @len
        LD      R0, #10
        STB     [CONSOLE], R0
@len:   LD      R0, R5
        SUB     R0, R4
        POP     R7
        POP     R6
        POP     R5
        POP     R4
        POP     IP

; read_dec - прочитать десятичное число: пробелы, необязательный '-', цифры.
; Чтение заканчивается на первом символе, который не цифра (он забирается из ввода).
;   R0 - эхо (0 - нет, иначе да).
; Результат: R0 - число (отрицательное - в дополнительном коде),
;            R1 - 0, если число прочитано; 1 - нет цифр или переполнение
;                 (больше 65535, а для отрицательных меньше -32768).
read_dec:
        PUSH    LR
        PUSH    R4
        PUSH    R5
        PUSH    R6
        PUSH    R7
        LD      R6, R0          ; эхо
        LD      R4, #0          ; значение
        LD      R5, #0          ; 1 - был минус
        LD      R7, #0          ; 0 - цифр нет, 1 - есть, 2 - переполнение
@next:  CALL    getc
        TEST    R6, R6
        JZ      @check
        STB     [CONSOLE], R0
@check: CMP     R0, #'0'
        JB      @other
        CMP     R0, #'9'
        JA      @other
        SUB     R0, #'0'
        MUL     R4, #10
        JC      @ovf            ; произведение не поместилось в 16 бит
        ADD     R4, R0
        JC      @ovf
        CMP     R7, #2
        JZ      @next           ; переполнение уже было
        LD      R7, #1
        JMP     @next
@ovf:   LD      R7, #2
        JMP     @next
@other: TEST    R7, R7
        JNZ     @done           ; конец числа
        CMP     R0, #' '
        JZ      @next           ; пробелы перед числом
        CMP     R0, #'-'
        JNZ     @done           ; ни цифры, ни знака: ошибка
        TEST    R5, R5
        JNZ     @done           ; второй минус: ошибка
        LD      R5, #1
        JMP     @next
@done:  LD      R1, #1          ; по умолчанию - ошибка
        CMP     R7, #1
        JNZ     @ret
        TEST    R5, R5
        JZ      @ok
        CMP     R4, #$8000
        JA      @ret            ; меньше -32768
        NEG     R4
@ok:    LD      R1, #0
@ret:   LD      R0, R4
        POP     R7
        POP     R6
        POP     R5
        POP     R4
        POP     IP

; ============================================================================
; Память и строки
; ============================================================================

; memcpy - скопировать R2 байт из R1 в R0.
memcpy: TEST    R2, R2
        JZ      @done
@loop:  LDZX    R3, [R1++]
        STB     [R0++], R3
        DEC     R2
        JNZ     @loop
@done:  RET

; memset - заполнить R2 байт по адресу R0 значением R1.
memset: TEST    R2, R2
        JZ      @done
@loop:  STB     [R0++], R1
        DEC     R2
        JNZ     @loop
@done:  RET

; strlen - длина строки с нулём в конце. R0 - адрес. Результат: R0 - длина.
strlen: LD      R1, R0
@loop:  LDZX    R2, [R1++]
        TEST    R2, R2
        JNZ     @loop
        SUB     R1, R0
        DEC     R1
        LD      R0, R1
        RET

; ============================================================================
; 32-битная арифметика. Операнды: a = R1:R0, b = R3:R2.
; ============================================================================

; add32 - R1:R0 = a + b. CF - перенос из старшего слова.
add32:  ADD     R0, R2
        ADC     R1, R3
        RET

; sub32 - R1:R0 = a - b. CF - заём (a < b без знака).
sub32:  SUB     R0, R2
        SBC     R1, R3
        RET

; neg32 - R1:R0 = -a.
neg32:  NOT     R0
        NOT     R1
        ADD     R0, #1
        ADC     R1, #0
        RET

; cmp32u - сравнить a и b без знака; результат во флагах
; для JZ, JNZ, JB, JAE, JA, JBE (знаковые условия после неё не работают).
cmp32u: CMP     R1, R3
        JNZ     @done
        CMP     R0, R2
@done:  RET

; mulu16 - R1:R0 = R0 * R1 (16 x 16 = 32 бита, без знака).
mulu16: LD      R2, R0
        MUL     R0, R1          ; младшее слово
        MULHU   R2, R1          ; старшее слово
        LD      R1, R2
        RET

; mul32 - R1:R0 = младшие 32 бита a * b (одинаково для чисел со знаком и без).
mul32:  PUSH    R4
        LD      R4, R0
        MULHU   R4, R2          ; старшее слово a0*b0
        MUL     R1, R2          ; a1*b0, младшее слово
        ADD     R4, R1
        LD      R1, R0
        MUL     R1, R3          ; a0*b1, младшее слово
        ADD     R1, R4          ; старшее слово результата
        MUL     R0, R2          ; младшее слово a0*b0
        POP     R4
        RET

; divu32 - деление R1:R0 на 16-битное R2 без знака.
; Результат: R1:R0 - частное, R2 - остаток, OF = 0.
; Деление на 0: R1:R0 = $FFFFFFFF, R2 = младшее слово делимого, OF = 1 (как у DIVU).
divu32: TEST    R2, R2
        JNZ     @go
        LD      R2, R0
        LD      R0, #$FFFF
        LD      R1, R0
        OR      FLAGS, #$10     ; OF = 1
        RET
@go:    PUSH    R4
        LD      R3, #0          ; остаток
        LD      R4, #32         ; бит частного
@loop:  ADD     R0, R0          ; сдвиг делимого влево, старший бит - в остаток
        ADC     R1, R1
        ADC     R3, R3
        JC      @sub            ; остаток стал 17-битным: точно не меньше делителя
        CMP     R3, R2
        JB      @next
@sub:   SUB     R3, R2
        INC     R0              ; очередной бит частного = 1
@next:  DEC     R4
        JNZ     @loop
        LD      R2, R3
        POP     R4
        AND     FLAGS, #$FFEF   ; OF = 0, остальные флаги не трогаем
        RET

; ============================================================================
; Дисплей, клавиатура, таймер («Машина CPU16: память и устройства»)
; ============================================================================

        .equ    CON_CTRL,   $FF22       ; режим: MODE_TTY | MODE_CURSOR | MODE_ATTR
        .equ    CON_ATTR,   $FF23       ; текущий атрибут
        .equ    CON_COL,    $FF24       ; столбец позиции 0..79
        .equ    CON_ROW,    $FF25       ; строка позиции 0..24
        .equ    CON_CMD,    $FF26       ; команда
        .equ    CON_COLS,   $FF27       ; ширина экрана (80)
        .equ    CON_ROWS,   $FF28       ; высота экрана (25)
        .equ    TIMER,      $FF29       ; бит 0 - таймер 50 Гц на IRQ0

        .equ    MODE_TTY,    1          ; телетайп: \n \r \b \t, прокрутка
        .equ    MODE_CURSOR, 2          ; курсор виден
        .equ    MODE_ATTR,   4          ; запись меняет только атрибуты

        .equ    CMD_CLEAR,  1           ; очистить экран, позиция (0, 0)
        .equ    CMD_CLEOL,  2           ; очистить до конца строки
        .equ    CMD_CLEOS,  3           ; очистить до конца экрана
        .equ    CMD_SCROLLUP,   4       ; прокрутить вверх
        .equ    CMD_SCROLLDOWN, 5       ; прокрутить вниз

        ; цвета; атрибут = ФОН << 3 | ТЕКСТ, плюс UNDERLINE и INVERSE
        .equ    BLACK, 0
        .equ    BLUE, 1
        .equ    GREEN, 2
        .equ    CYAN, 3
        .equ    RED, 4
        .equ    YELLOW, 5
        .equ    GRAY, 6
        .equ    WHITE, 7
        .equ    UNDERLINE, $40
        .equ    INVERSE, $80

        ; сканкоды (старший байт кода клавиши)
        .equ    SC_ESC,   $01
        .equ    SC_BACKSPACE, $0E
        .equ    SC_TAB,   $0F
        .equ    SC_ENTER, $1C
        .equ    SC_SPACE, $39
        .equ    SC_F1,    $3B           ; F1..F10 - $3B..$44 подряд
        .equ    SC_F11,   $57
        .equ    SC_F12,   $58
        .equ    SC_HOME,  $47
        .equ    SC_UP,    $48
        .equ    SC_PGUP,  $49
        .equ    SC_LEFT,  $4B
        .equ    SC_RIGHT, $4D
        .equ    SC_END,   $4F
        .equ    SC_DOWN,  $50
        .equ    SC_PGDN,  $51
        .equ    SC_INS,   $52
        .equ    SC_DEL,   $53

; scr_mode - режим дисплея. R0 - MODE_TTY | MODE_CURSOR | MODE_ATTR.
scr_mode:
        STB     [CON_CTRL], R0
        RET

; scr_attr - текущий атрибут. R0 - атрибут.
scr_attr:
        STB     [CON_ATTR], R0
        RET

; scr_goto - позиция. R0 - столбец, R1 - строка.
scr_goto:
        STB     [CON_COL], R0
        STB     [CON_ROW], R1
        RET

; scr_clear - очистить экран текущим атрибутом, позиция (0, 0).
scr_clear:
        LD      R0, #CMD_CLEAR
        STB     [CON_CMD], R0
        RET

; scr_print_at - вывести строку в позицию. R0 - столбец, R1 - строка, R2 - адрес строки.
scr_print_at:
        STB     [CON_COL], R0
        STB     [CON_ROW], R1
        LD      R0, R2
        JMP     puts                    ; хвостовой вызов

; scr_paint - сменить атрибут R1 ячеек от позиции, не меняя текст. R0 - атрибут, R1 - число ячеек.
; Позиция сдвигается на R1 ячеек.
scr_paint:
        LDZX    R2, [CON_CTRL]          ; запомнить режим
        LD      R3, R2
        OR      R3, #MODE_ATTR
        STB     [CON_CTRL], R3
        TEST    R1, R1
        JZ      @done
@loop:  STB     [CONSOLE], R0           ; в режиме атрибутов запись меняет только атрибут
        DEC     R1
        JNZ     @loop
@done:  STB     [CON_CTRL], R2
        RET

; scr_box - двойная рамка в координатном режиме. R0 - столбец, R1 - строка левого верхнего угла,
; R2 - ширина, R3 - высота (обе не меньше 2). Рисуется текущим атрибутом.
scr_box:
        PUSH    LR                      ; вызывает _box_run: LR нужно сохранить
        PUSH    R4
        PUSH    R5
        STB     [CON_COL], R0
        STB     [CON_ROW], R1
        LD      R4, R2
        SUB     R4, #2                  ; ширина без углов
        LD      R5, #'╔'
        STB     [CONSOLE], R5
        LD      R5, #'═'
        CALL    _box_run
        LD      R5, #'╗'
        STB     [CONSOLE], R5
        LD      R5, R3                  ; боковые стороны: R5 - сколько строк осталось
        SUB     R5, #2
@side:  TEST    R5, R5
        JZ      @bottom
        INC     R1
        STB     [CON_ROW], R1
        STB     [CON_COL], R0
        LD      R4, #'║'
        STB     [CONSOLE], R4
        LD      R4, R0
        ADD     R4, R2
        DEC     R4
        STB     [CON_COL], R4
        LD      R4, #'║'
        STB     [CONSOLE], R4
        DEC     R5
        JMP     @side
@bottom:
        INC     R1
        STB     [CON_ROW], R1
        STB     [CON_COL], R0
        LD      R4, R2
        SUB     R4, #2
        LD      R5, #'╚'
        STB     [CONSOLE], R5
        LD      R5, #'═'
        CALL    _box_run
        LD      R5, #'╝'
        STB     [CONSOLE], R5
        POP     R5
        POP     R4
        POP     IP
_box_run:                               ; R4 раз вывести символ R5 (листовая: LR не трогает)
        TEST    R4, R4
        JZ      @done
@loop:  STB     [CONSOLE], R5
        DEC     R4
        JNZ     @loop
@done:  RET

; getkey - дождаться нажатия. Результат: R0 - символ (младший байт) и сканкод (старший).
getkey: LD      R0, [CONSOLE]           ; чтение слова: символ и сканкод одного нажатия
        TEST    R0, R0
        JZ      getkey
        RET

; getkey_nb - нажатие без ожидания. Результат: R0 - символ и сканкод или 0; ZF = 1, если 0.
getkey_nb:
        LD      R0, [CONSOLE]
        TEST    R0, R0
        RET

; timer_on, timer_off - таймер 50 Гц на IRQ0. Перед включением заполните вектор IRQ0.
timer_on:
        LD      R0, #1
        STB     [TIMER], R0
        RET
timer_off:
        LD      R0, #0
        STB     [TIMER], R0
        RET

Приложение Б. Шпаргалка по командам

Группа Команды
Пересылка LD/MOV ST LDZX LDSX STB XCHB LEA PUSH POP
Арифметика ADD ADC SUB SBC CMP NEG INC DEC MUL MULHU DIVU REMU
Логика AND OR XOR TEST NOT BSX
Сдвиги SHL SHR SAR ROR
Переходы JMP JZ JNZ JC/JB JNC/JAE JA JBE JS JNS JO JNO JL JGE JG JLE
Подпрограммы CALL RET
Прерывания STI CLI TRAP IRET WFI
Флаги STC CLC CMC (и AND/OR/XOR над FLAGS)
Прочее NOP