Программирование для Famicom/NES/Денди в Nesicide+ca65: архитектура MOS 6502 (2)

Еще немного про сегменты

Если вы до этого программировали на каком-нибудь другом ассемблере для 8-биток, то возможно, что все эти заморочки с сегментами на первый взгляд могут показаться ненужными. Почему бы не использовать директиву .ORG и явно не указывать где находятся код и данные?

Здесь следует вспомнить, что ассемблер ca65 является лишь частью пакета cc65 — языка программирования Си для MOS 6502 под самые разнообразные ПК и консоли на базе этого процессора и его потомков. И высокоуровневый код на Си с которым куски ассемблерного кода можно спрягать уже настаивает на сегментации и вот почему:
Достаточно сложную программу следует разбивать на как можно менее зависимые модули — разные файлы с исходным кодом. В каждом таком файле компилятор/ассемблер будет заполнять сегменты кода, данных и BSS в принципе не зная какие другие модули в программе существуют и какие адреса в этих же сегментах они используют. Поэтому объектный код генерируемый ассемблером при такой системе еще не привязан к конкретным адресам в сегментах, а решение о том куда именно в сегментах попадут код и данные из всех модулей откладывается на шаг линковки и решает эту задачу линкер (cl65). Линкер собирает весь код и данные по каждому из сегментов в одну кучу, компонует их в один фрагмент памяти расставляя в этот момент конкретные адреса и получившиеся законченные сегменты записывает в куски памяти описываемые в конфигурационном файле nes.ini.
В связи с этим директиву .ORG лучше вообще не использовать, а если надо что-то «прибить» к конкретному адресу в машине, то делать это через атрибут start в специально заведённых сегментах же. Если же вы попытаетесь по старым привычкам использовать директиву .ORG, то скорее всего всё поломаете — читайте внимательно документацию.
Теперь вот должно быть понятно почему в шаблоне тестового проекта Damian Yerrick когда захотел использовать первые 16 байт zero page под временные «локальные» переменные он не стал включать их в сегмент, а напротив, вывел из сегментов вообще — это чтобы переменные выделяемые в сегменте ZEROPAGE не смогли наложится на никак не обозначенную область. Он не смог бы сделать это просто разместив директиву .ORG $0010 в этом сегменте. Впрочем, имхо, такое решение немного кривовато (хотя есть как недостаки так и преимщуества).
По той же самое причине сегмент BSS не включает в себя область стека (вторую страницу памяти RAM от $100 до $1FF) — это самый простой способ избежать выделения переменных в стеке, что приведёт к обрушению программы. А так же в сегменте BSS сразу же проигнорированы адреса $0200-02FF поскольку их часто используют под промежуточный буфер спрайтов для DMA о чём будет сказано в следующих частях.
Немаловажно еще заметить, что управление блоками памяти в .ini файле линкера помогает писать под самые разные форматы разных платформ в cc65 и сегменты тоже здесь логично встраиваиваются в логику этого процесса.
Сам же «сегментированный» модульный и высокоуровневый подход не редкость и в других ассемблерах и активно использовался и в программировании на Intel 8086, так что наверняка для многих из вас открытием он не будет. Но тем кто привык под «модулями» понимать просто включаемые в основной файл директивой INCLUDE тексты цельной программы компилируемые в один проход — тем надо немного переучится.

Вкратце об архитектуре MOS 6502

В Famicom/NES/Денди использовался чип Ricoh 2A03 который реализовывал подавляющую часть архитектуры процессора MOS 6502 и кроме того содержал генераторы звука и DMA важные для консоли (поэтому они управляются через близкие порты ввода-вывода).
Я не буду сильно подробно останавливаться на архитектуре этого процессора и ассемблера для него, но опишу их вкратце достаточно для понимания материала если вы в принципе уже знаете какие либо архитектуры процессоров и ассемблеры. Если такого краткого изложения не хватит — вам следует поискать более подробные материалы.
Процессор 6502 максимально восьмибитен — единственный 16-битный регистр в нём — это счётчик инструкций PC который без команд перехода линейно проходит под 16-битному адресному пространству.
Остальные пять регистров восьмибитны и это:
  • A — аккумулятор, первый аргумент и приёмник результата всего спектра арифметико-логических команд которые процессор способен выполнять
  • X и Y — индексные регистры помогающие просто адресовать массивы по 256 байт
  • S — указатель стека. Т.к. S восьмибитен, то он может указывать только на 256 байт с адресами вида $01XX, т.е. стек процессора может находится только во вторых 256 байтах его адресного пространства и в целом его возможности по хранению локальных переменных или параметров крайне сильно снижены — главным образом он служит только для хранения адресов возврата из процедур
  • P — флаги процессора
Набор флагов довольно типичен — нуля, переноса, отрицательности, переполнения последней арифметико-логической операции. Правда флаги нуля и отрицательности здесь затрагиваются даже обычными инструкциями передачи данных.
Плюс еще 3 флага текущего состояния процессора: признак запрещённости маскируемых прерываний, признак нахождения в прерывании и признак десятичной арифметики.
Десятичная арифметика полезная для калькуляторов и BCD-архифметики в Ricoh 2A03 была выпилена (часто встречается мнение, что с целью обхода патентов), поэтому общепринятой практикой является выключение её командой CLD (CLear Decimal flag) в начале программы. Это вроде как нужно не для логики процесса, т.к. этот флаг в Ricoh просто ни на что не влияет, а больше для того чтобы не смущать отладчики.

Первым делом рассмотрим команды сброса и выставления отдельных бит регистра флагов. У них нет параметров и их мнемоники имеют вид SEn или CLn от «SEt n» или «CLear n», т.е. установить или сбросить бит n в регистре флагов.
Их немного:
  • SEC и CLC — установка и сброс бита переноса/carry
  • SED и CLD — установка и сброс бита десятичной арифметики
  • SEI и CLI — установка и сброс бита запрета маскируемых прерываний
  • CLV — сброс бита переполнения/overflow
Вас немного покорёжило от того что у CLV нет пары SEV? Образовалась некоторая асимметричность. Но не волнуйтесь — асимметричность это кредо этого процессора, к чему мы еще не раз в будущем вернёмся.

Вторым делом рассмотрим команды передачи данных между регистрами. Они имеют вид Tnm где n и m это имена соответствющих регистров. Мнемоники есть сокращения от «Transfer n to m», т.е. передать n в m. Их всего шесть:
  • TAX и TXA — передать из A в X и обратно
  • TAY и TYA — передать из A в Y и обратно
  • TXS и TSX — передать из X в S и обратно
Опять есть некоторая асимметричность по регистрам X и S, но неважно, надо просто запомнить что для операций с указателем стека S надо будет пробрасывать данное через X.

Следующим делом посмотрим на команды загрузки и сохранения регистров в память:
  • LDA, LDX, LDY — загрузка соответствующего регистра A, X или Y из памяти.
  • STA, STX, STY — сохранение соответствующего регистра в память.
У всех этих команд должен быть аргумент — адрес в памяти, а для команд загрузок аргументом так же может быть непосредственное данное (байт). И вот тут возникает следующий интересный момент — режимы адресации.
При всей скудности регистров и кодов инструкций MOS 6502 довольно сильный упор делает на многочисленные режимы адресации. Его генеральная архитектура довольно родственна многим компьютерным архитектурам 50-х и 60-х годов и иногда описывается как «аккумулятор-память». Это значит, что наиболее широкий спектр вычислительных команд (такие как сложение или вычитание) имеют вторым аргументом ячейку в памяти, а первым аргументом и приёмником результата — единственный аккумулятор. При этом в архитектурах старых годов адрес ячейки памяти зачастую задавался в машинной команде конкретным числом, но позже появились разной сложности режимы индексации.
В MOS 6502 режимов адресации было немало для 8-битной архитектуры. Давайте рассмотрим те из них которые работают с инструкцией LDA (эти варианты адресаций применимы к самому важному и широкому набору инструкций процессора):
  • «LDA # 10» — непосредственное данное, в A загрузится байт со значением 10 (общий размер инструкции — 2 байта: код + данное). Знак # обозначает как раз непосредственность данного.
  • «LDA 10» — данное в аккумулятор загрузится из байта с адресом 10 в zero page. Тут важное замечание: для скорости первые 256 байт ячеек памяти (нулевая страница) можно адресовать одним байтом, т.е. эта инструкция загрузит в A байт из адреса 10 в zero page и полный размер инструкции будет 2 байта (байт кода + байт адреса).
  • «LDA 10, X» — адрес в zero page плюс X — регистр X складывается с аргументом 10 и в A грузится байт из получившегося адреса в zero page. Важное замечание — результирущий адрес никогда не покидает zero page и «проворачивается вокруг 0» если сумма превышает 255. Т.е. LDA 255, X при X равном 1 загрузит в аккумулятор байт из адреса 0. Общий размер инструкции — 2 байта.
  • «LDA 1000» — байт по двухбайтовому адресу 1000 в полном 16-битном адресном пространстве грузится в аккмулятор. На полный адрес нужно 2 байта непосредственного данного, так что вместе с кодом инструкции она занимает 3 байта.
  • «LDA 1000, X» — то же самое что предыдущий вариант, но к полному 16-битному адресу прибавляется (как беззнаковый) байт из регистра X и уже из результирующего адресу загружается байт в A. Важно, что при переполнении границ между страницами ценой одного машинного такта адрес всё-таки вычисляется правильно. Длина инструкции — 3 байта.
  • «LDA 1000, Y» — то же самое что предыдущий вариант, но с регистром Y как индексом. Всё остально точно такое же — можно пересекать границы страниц и полная длина инструкции — 3 байта.
  • «LDA (10, X)» — вот это уже по настоящему сложный вариант — косвенная адресация — байтовый адрес в zero page (тут 10) складывается с регистром X и получившийся адрес (проворачивающийся вокруг нуля, т.е. не выходящий за zero page) используется для загрузки сперва младшего байта 16-битного адреса, а потом старшего (после инкремента адреса, но опять же не покидающего zero page). И вот уже из получившегося 16-битного адреса и грузится байт в аккумулятор. Здесь можно заметить, что если хоть что-то вовлекает адресацию внутри zero page, то адрес внутри zero page её никогда не покидает. Он всегда «провернётся вокруг нуля». Но вот финальный адрес извлечённый из zero page уже может быть где угодно в памяти консоли.
  • «LDA (10), Y» — косвенная адресация с регистром Y делает похожую, но и сильно другую вещь. байтовый аргумент 10 означает адрес внутри zero page откуда так же (без покидания zero page) извлекаются сперва младший, а потом старший байты 16-битного адреса и вот получившийся адрес будет сложен с регистром Y с правильным пересечением страниц — и данное будет загружено с этого адреса.
Последние два режима адресации означают, что «честная» произвольная 16-битная адресация памяти по произвольным адресам, например для обобщённой процедуры MemMove в 6502 требует чтобы адрес хранился в zero page. В регистрах произвольный адрес просто не помещается.
Назовём набор этих адресаций «полным».

Все эти адресации работают с наибольшим спектром инструкций MOS 6502, но прямо сейчас же мы столкнёмся с еще одной асимметрией — как правило они работают с инструкциями где первый неявный аргумент — аккумулятор. Такие как LDA/STA. Правда тут же можно заметить, что STA # data просто не имеет смысла и такой команды нет, но это логично — сохранение в непосредственное данное вещь реально ненужная.
Но вот LDX/STX и LDY/STY которые первым неявным аргументом имеют регистры X и Y уже другой сорт инструкций и ограничивают свои режимы адресации менее полными вариантами.
Давайте посмотрим какими именно:
Команды LDX/LDY работают со следующими режимами адресации (они присутствуют в полном наборе, так что я их не поясняю, только перечислю на примере команды LDY):
  • LDY # 10
  • LDY 10
  • LDY 10, X
  • LDY 1000
  • LDY 1000, X
С командами вида LDX всё то же самое, но единственным индексирующим регистром является уже Y.
Т.е., например, «LDX 1000, Y» — тут уже загрузка в регистр X байта из адреса 1000 + Y.

Еще хуже с инструкциями вида STX и STY, т.к. сохранять в непосредственное данное бесмысленно, то не бывает и вариантов с символом #. Но исчезает так же и вариант 16-битного адреса с индексацией:
  • STX 10
  • STX 10, Y
  • STX 1000
Инструкции вида STY имеют все те же самые режимы адресации, но регистр Y в индексации заменён на X.
Интересно, что режим адресации «zeropage, Y» в 6502 используется ровно в двух инструкциях — LDX и STX и больше нигде. Почему? Потому что 6502.

Здесь вы уже наверное поняли, что с адресациями по каждой команде надо смотреть отдельно. Nesicide здесь очень удобен — если навести в нём мышку на инструкцию ассемблера он покажет её краткое описание вместе со всеми режимами адресации. Еще мне нравится описание инструкций тут: www.masswerk.at/6502/6502_instruction_set.html Довольно удобно сделано всё в виде и сводной таблицы и расшифровки по каждой команде отдельно.

Перейдём к арифметико-логическому набору команд:
  • ORA — логический OR аргумента с аккумулятором (результат помещается в аккумулятор)
  • AND — логический AND
  • EOR — исключающее «ИЛИ» или XOR
  • ADC — сложение с учётом флага переноса
  • SBC — вычитание с учётом флага переноса
  • CMP — сравнение, т.е. вычет из аккумулятора аргумента (без флага переноса) без сохранения в аккумулятор, а только с влиянием на флаги
Заметьте, что для того чтобы складывать без переноса надо сперва очищать флаг Carry командой CLC. А вот чтобы вычесть без заёма, то нужно наоборот выставить Carry командой SEC, т.к. операция SBC воспринимает и выставляет бит Carry как инверсию признака того произошёл заём или нет.
Все эти команды могут использовать полный набор адресаций (и на деле команды LDA и STA являются их компаньонами в битовом представлении инструкций процессора).
Далее идут сдвиги и прокрутки:
  • ASL — арифметический сдвиг влево (выпадающий бит попадает в Carry, вдвигается 0)
  • LSR — логический сдвиг вправо (выпадающий бит попадает в Carry, вдвигается 0)
  • ROL — прокрутка влево через Carry
  • ROR — прокрутка вправо через Carry
Эти инструкции работают либо с аккумулятором (он явно пишется, например, «ROL A») либо с сокращённым набором адресаций как у команды LDY, но без #.
Отдельным особняком стоят команды инкремента/декремента. Они недоступны для аккумулятора, потому что 6502, а могут выполняться над ячейками памяти или индексными регистрами:
  • INC и DEC — инкремент и декремент ячейки памяти (адресации как у команд сдвига, но без аккумулятора)
  • INX и DEX — инкремент и декремент X (явного аргумента нет)
  • INY и DEY — инкремент и декремент Y (явного аргумента нет)
Здесь как бы подчёркивается индексная сущность индексных регистров, но инкремент/декремент памяти тоже позволяет организовывать в ней счётчики, что всё хорошо ослабляет необходимость привлекать по каждому чиху аккумулятор. Но почему остался незатронутым аккумулятор при том что ADC/SBC требуют еще зачистки CLC — мне вообще непонятно. Самое интересное, что в системе команд слоты под команды INC A и DEC A просто не задействованы — места где они по логике битового представления инструкций есть и ничем не заняты.
Для индексных регистров существует еще две полезных команды:
  • CPX — сравнение с X
  • CPY — сравнение с Y
Адресации такие же как у команды LDY. С этими командами индексировать становится еще проще.
Еще с этими же режимами адресации есть забавная команда BIT — она выставляет флаг нуля по результату логического AND аргумента и аккумулятора, а так же заносит в биты отрицательности и переполнения седьмой и шестой (нумерация с нуля) биты аргумента соответственно. Поэтому бывает удобно в этих битах в памяти хранить какие то флаги которые нужно быстро тестировать.

Операции со стеком представлены довольно скудно:
  • PHA и PLA — положить и взять аккумулятор из стека
  • PHP и PLP — положить и взять регистра флагов из стека
Поэтому если надо сохранять в стек полный набор регистров, то приходится перебрасывать их через аккумулятор.

И остались вроде бы только команды переходов.
Безусловные виды переходов:
  • JMP addr16 — безусловный переход до полному 16-битному адресу задаваемому в инструкции (никаких других режимов адресации)
  • JMP (addr16) — косвенный переход по адресу который находится в ячейке памяти addr16
  • JSR addr16 — вызов подпрограммы — в стек вталкивается адрес возврата (следующей инструкции) и происходит переход по заданному адресу
  • RTS — возврат из подпрограммы — из вершины стека выталкивается адрес и заносится в PC
  • BRK — вызвать программное прерывание — помещает в стек сперва PC, а потом регистр флагов и переходит по слову лежащему по адресу $FFFE (вектор прерываний)
  • RTI — вернуться из перывания — сперва вытаскивает из стека регистр флагов, а потом PC

Условные переходы все переходят на -128..+127 байт вперед или назад от текущего адреса (имеют размер 2 байта):
  • BCC — (Branch if Carry is Clear) перейти если флаг переноса = 0
  • BCS — (Branch if Carry is Set) перейти если флаг переноса = 1
  • BEQ — (Branch if EQual) перейти если флаг нуля = 1 (что в командах сравнения понимается в равенство «EQuality»)
  • BNE — (Branch if Not Equal) перейти если флаг нуля = 0
  • BMI — (Branch if MInus) перейти если флаг отрицательности = 1
  • BPL — (Branch if PLus) перейти если флаг отрицательности = 0
  • BVC — (Branch if oVerflow is Clear) перейти если флаг переполнения = 0
  • BVS — (Branch if oVerflow is Set) перейти если флаг переполнения = 1
На флаги влияют арифметико-логические команды, а на флаги нуля и отрицательности влияют даже команды загрузки регистров (т.е. LDA или TAX), что позволяет изменять ход выполения программы.

Вот вроде весь набор инструкций MOS 6502 — крайне экономный и сильно неортогональный, но по моему впечатлению довольно продуманный именно с той точки зрения, что команд, регистров и режимов их адресаций в целом достаточно для решения самых актуальных на 8-битке практических задач. В следующей главе мы уже начнём ими пользоваться и создавать программу прокручивающую задний фон и там увидим всё это на практике, а так же вплотную уже познакомимся с ассемблером ca65.

В первую часть (оглавление)...

7 комментариев

avatar
CA65/CC65 — пакет не для конкретно NES, а для любых систем на 6502 (и частично 65816) в принципе, изначально для восьмибитных Atari (1989 год, между прочим). Соответственно сегментная модель в линкере позволяет задать любую карту памяти для любого компьютера с этим процессором. Также бывают ситуации, когда часть памяти кода переключаемая (разная память в одних и тех же адресах, т.е. банки), часть обязательно должна быть фиксированной, есть разные виды памяти, и всё это дело сохраняется в какой-то образ определённого формата — всё это легко позволяют задать сегменты (для NES: хидер iNES, расположение ОЗУ, банки PRG и CHR).
avatar
CA65/CC65 — пакет не для конкретно NES, а для любых систем на 6502
Нигде я не говорил что CC65 это только для NES, более того в начале этой части статьи написано про любой MOS 6502, но как то абстрактно и действительно стоит обозначить что cc65 имеет широкий охват. Это стоит сделать еще в предыдущей части. Действительно.

Также бывают ситуации, когда часть памяти кода переключаемая (разная память в одних и тех же адресах, т.е. банки)
Ни видел упоминаний сегментов в нескольких серьёзных исходниках, например Mighty Final Fight (под SjAsmPlus), но я тут реально слабо понимаю в том как именно в таком коде метки привязываются к переключению страниц.
У меня сложилось ощущение что просто через пару еще других ключевых слов и, вероятно, привязанных к виртуальным образам самого спектрума. Вообще не уверен.
Но разбираясь с секциями MEMORY и SEGMENTS ini-файла линкера cc65 очень быстро и легко понял как оно должно ложится на мапперы и любую структуру памяти любой машины. Это действительно сложно на первый взгляд, но понять нетрудно как и все выгоды — на самом деле даже очень просто.
Т.е. как переключать банки памяти на CA65 я понял даже быстрее чем на SjAsmPlus несмотря на то что со вторым столкнулся намного раньше.
Забавно, ведь во втором это сделано явно проще и для конкретной платформы, но глядя в документацию я не смог понять как.
А в CC65 просто поглядел пару минут в файл .ini с описаниями кусков памяти и сегментов и понял всё очень быстро.
Дальше только уточнения насчёт align и т.п. из официальной документации. И сразу понял кристально как сюда встраивать переключения банков.
Действительно вроде и сложнее, но по факту много проще.
avatar
Есть ассемблеры без линкеров, есть с линкерами. Для того, чтобы были упоминания сегментов, нужно, чтобы исходник был под ассемблер с линкером. SjAsmPlus — ассемблер без линкера. В среде любителей-энтузиастов это более типичная архитектура ассемблера, т.к. её проще сделать (любители же сами эти ассемблеры и писали) и проще пользоваться в рамках одной целевой платформы. Из любительских кросс-ассемблеров с линкером сходу вспоминаю разве что WLA DX.

В SjAsmPlus код в банках размещается как раз ORG'ами в одни и те же адреса и сохраняется в бинарники по мере трансляции, т.е. в те моменты, когда в нужных адресах виртуальной памяти сгенерирован нужный код, а дальше по ходу трансляции он может быть перезаписан.
avatar
Процитирую себя же из начала статьи:
В целом такой же «сегментированный» модульный и высокоуровневый подход не редкость и в ассемблерах и активно использовался и в программировании на Intel 8086, так что наверняка для многих из вас открытием он не будет. Но тем кто привык под «модулями» понимать просто включаемые в основной файл директивой INCLUDE тексты цельной программы компилируемые в один проход — тем надо немного переучится.
Не оно разве?
avatar
В SjAsmPlus код в банках размещается как раз ORG'ами в одни и те же адреса и сохраняется в бинарники по мере трансляции, т.е. в те моменты, когда в нужных адресах виртуальной памяти сгенерирован нужный код, а дальше по ходу трансляции он может быть перезаписан.
О, кстати, вчитался и да — это то что мне было непонятно, вчера уже сонный был, так что цитата выше немного не по делу и касается только первой части. Получается что путало само наличие директивы PAGE X, и это воспринималось как раз как то наподобие сегментации, но непонятно как работающее в рамках многостраничных моделей. А это просто как бы переключение виртуальной памяти по схеме реальных 128Кб для возможных упрощений в каких то моментах, но сбрасывать на диск всё-равно надо постранично в момент активации нужных страниц.
Теперь и с SjAsmPlus всё понятно, спасибо.
avatar
P.S.
А ведь если задуматься, то такой подход (как у SjAsm) ничем не мешает и созданию кросс-платформенных форматов — генерацию каких то заголовков и служебных вещей тоже легко положить на какие то макросы из SAVEBIN OUTPUT_FILE которые обязательно надо выполнить в оговоренном порядке. В общем то дело техники. И у меня возникает подозрение, что FASM так и работает, поэтому конкретные форматы конкретных PE и COFF там дело библиотечного уровня.
avatar
Нда, сам не заметил как скатился к полному разжёвыванию каждого нового встреченного ключевого слова и объяснения как байты лежат в памяти, так что заявление из первой статьи о том, что я не буду учить как программировать в ассемблере и архитектуре 6502 вообще пошло ломанными трещинами и начало рассыпаться в пух и прах.
Уже даже не пойму какая аудитория выйдет идеальной для этого всего, уже даже закрадывается что как тот PHP-шник который стал делать выступления на конференциях как он стал писать на NES за месяц. :D
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.