11. Ветвление и циклы


Содержание:

  • Управление потоком в языке ассемблера
  • Ветвление
  • Обзор опкодов циклов и ветвления
  • Ещё один пример ветвления
  • Выполнение сравнений
  • Использование сравнений в циклах

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

Управление потоком в языке ассемблера


За исключением JMP, весь рассмотренный нами ассемблерный код был совершенно линейным: процессор считывает байт из следующего адреса памяти и обрабатывает его, двигаясь от начала файла ROM к его концу. Управление потоком — это возможность написания кода, который может вычислять определённые условия и в зависимости от результата выбирать, какой код будет выполняться далее. У процессора 6502 есть два вида управления потоком. JMP — это «безусловный» переход в другую часть ROM (он не выполняет никаких проверок или вычислений). Второй вид управления потоком называется «ветвлением», поскольку он выполняет проверку, а затем двигается по одному из двух различных «ветвей» кода в зависимости от результата.

Регистр состояния процессора


Важнейшим аспектом ветвления является элемент процессора 6502, называемый регистром состояния процессора (processor status register) и часто обозначаемый как P. Регистр состояния, как и все остальные регистры 6502, имеет размер восемь бит. В отличие от других регистров, регистр состояния недоступен программисту напрямую. Каждый раз, когда процессор выполняет операцию, регистр состояния изменяется, отражая результаты этой операции. Каждый бит регистра состояния сообщает информацию об отдельном аспекте последней операции.


Восемь бит регистра состояния процессора (NV-BDIZC).

Для нас двумя самыми важными битами (или «флагами») регистра состояния процессора являются биты Z («zero», флаг нуля) и C («carry», флаг переноса). Флаг нуля установлен (равен 1), если результат последней операции был равен нулю. Флаг нуля сбрасывается (равен 0), если результат последней операции был чем угодно, кроме нуля. Аналогично, флаг переноса устанавливается, если последняя операция вызвала «перенос» и сбрасывается в противоположном случае.

Перенос


С флагом нуля всё понятно, а флаг переноса требует дополнительных объяснений. Подумаем, что происходит при сложении десятичных чисел 13 и 29. Если бы мы выполняли это сложение вручную, то мы бы сначала прибавили «3» из 13 к «9» из 29. Результат равен 12, то есть слишком велик, чтобы поместиться в одну десятичную цифру. Поэтому мы записываем «2» и переносим «1» на один столбец влево. В нём мы складываем «1» из 13, «2» из 29 и перенесённую «1». Результат равен 4, и мы записываем его в этот столбец, получив в итоге 42.

Флаг переноса процессора 6502 выполняет ту же функцию, но для байтов. При операции сложения флаг переноса устанавливается, если результат сложения больше того, что может поместиться в один байт (т. е. если результат больше 255). Обычно мы будем использовать опкод, принудительно сбрасывающий флаг переноса перед выполнением сложения, чтобы флаг переноса, установленный предыдущей операцией, сохранялся для текущего сложения.

[Зачем вообще нужно сохранять предыдущий флаг переноса перед выполнением сложения? Если не сбрасывать флаг переноса, то это позволяет складывать многобайтные числа, сначала сложив самые младшие байты двух чисел, а затем позволив сложению при необходимости установить флаг переноса. При сложении следующих по старшинству байтов двух чисел будет автоматически прибавлен флаг переноса.]

Вычитание работает похожим образом, только мы обычно задаём флаг переноса перед выполнением вычитания. При операции вычитания флаг переноса сбрасывается, если вычитаемое число больше того, из которого выполняется вычитание. Например, если мы вычитаем 17 из 15, в результате получится -2. Это число меньше нуля, поэтому чтобы выполнить вычитание, нам нужно «позаимствовать» из следующего по старшинству столбца, использовав флаг переноса, который мы установили перед началом вычитания.

Важно также заметить, что аналогично одноразрядным десятичным числам, байты «оборачиваются» при сложении выше 255 или вычитании ниже нуля. Если байт (или регистр, или значение в памяти) равен 253 и мы прибавляем 7, то результат будет не равен 260, он равен 4 с установленным флагом переноса. Аналогично, если байт равен 4 и мы вычитаем 7, то результат равен 253 со сброшенным флагом переноса, а не -3.

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

Ветвление


Теперь, когда мы знаем о регистре состояния процессора, можно использовать результаты операций для ветвления в различные части кода. Проще всего использовать ветвление, создав цикл, выполняющий какие-то действия 256 раз. В своём самом простейшем виде он выглядит вот так


Прежде чем переходить к изучению работы цикла, нам нужно рассказать о двух новых опкодах.

INX расшифровывается как «increment X» («инкремент X»); он прибавляет единицу к значению регистра X и сохраняет результат обратно в регистр X. BNE обозначает «Branch if Not Equal
to zero» («ветвление, если не равно нулю»), этот опкод изменяет обычный поток выполнения кода, если сброшен флаг нуля в регистре состояния процессора (т. е. если результат последней операции не был равен нулю).

Цикл начинается с загрузки непосредственного значения $00 в регистр X. Далее идёт метка (LoopStart), которая будет использоваться командой ветвления в конце цикла. После метки мы делаем то, что должен выполнять наш цикл, а затем производим инкремент регистра X. Как и все математические операции, это обновит флаги в регистре состояния процессора. Последняя строка цикла проверяет значение флага нуля. Если флаг нуля установлен, не происходит ничего особенного — программа просто продолжает выполнение и далее происходит то, что идёт после ; end of loop. Если флаг нуля сброшен, то BNE LoopStart приказывает процессору найти метку LoopStart и исполнить далее то, что расположено по ней — иными словами, выполнить следующую итерацию цикла.

При запуске программы цикл будет выполнен 256 раз. На первой итерации цикла значение регистра X равно нулю. После INX значение регистра X равно единице. Так как результат INX не равен нулю, флаг нуля сбрасывается. Когда мы добираемся до BNE LoopStart, поскольку флаг нуля сброшен, процессор вернётся к метке LoopStart и снова выполнит цикл. В этот раз регистр X будет равен двум, что тоже не равно нулю, и цикл запустится снова. Постепенно значение регистра X доберётся до 255. Когда мы запустим при этом INX, регистр X «перекинется» к нулю и будет задан флаг переноса.

[Стоит заметить, что в этом случае мы могли выполнять ветвление на основании или флага нуля, или флага переноса. Когда регистр X «перекинется» с 255 до 0, будут установлены и флаг нуля, и флаг переноса.]

Теперь, когда результатом последней операции стал ноль, BNE LoopStart больше не будет срабатывать, и процессор продолжит выполнение всего того, что идёт после цикла.

Прежде чем двигаться дальше, нужно упомянуть ещё один аспект. После обработки этого кода ассемблером и компоновщиком все метки (например, LoopStart) будут вырезаны и заменены настоящими адресами памяти. Чтобы ветвление не занимало ненадлежащее количество времени процессора, данные, следующие за командой ветвления, являются не адресом памяти, а однобайтовым числом со знаком, которое прибавляется к тому адресу памяти, который находится в счётчике программ. Поэтому код, к которому вы хотите выполнить ветвление, должен располагаться не более чем на 127 бит до или не более чем на 128 бит после команды ветвления. Если вам нужно выполнить ветвление к чему-то, что расположено дальше, то необходимо воспользоваться переходом к этой метке через JMP. Маловероятно, что такое будет встречаться часто, если только вы не пишете очень сложный код, но это интересная подробность реализации, которая может привести к сложно отслеживаемому багу.

Обзор опкодов циклов и ветвления


Мы уже видели INX и BNE, но это лишь два из того набора опкодов, которые можно использовать для создания циклов. Давайте рассмотрим ещё десять новых опкодов, которые вам необходимо освоить.

Опкоды инкремента и декремента


Эти опкоды позволяют прибавлять или вычитать единицу за один опкод. Перед использованием одного из этих опкодов нет необходимости явным образом устанавливать или сбрасывать флаг переноса.

INX и INY добавляют единицу («инкремент») к регистру X или Y. DEX и DEY выполняют обратную задачу — вычитают единицу («декремент») из регистра X или Y. Опкоды INC и DEC можно использовать для выполнения инкремента или декремента содержимого адреса памяти. Например, можно использовать INC $05, чтобы прибавить к содержимому адреса памяти $05 единицу и сохранить результат обратно в $05.

Все опкоды инкремента/декремента обновляют значения флагов нуля и переноса в регистре состояния процессора.

Опкоды ветвления


Существуют опкоды ветвления для каждого флага регистра состояния процессора. Каждому флагу соответствует два опкода — один выполняет ветвление, когда флаг установлен, второй — когда он сброшен. В нашем случае единственными опкодами ветвления, которые нам пригодятся, будут проверяющие значения флагов нуля и переноса.

BEQ («Branch if Equals zero», «ветвление, если равно нулю») и BNE («Branch if Not Equals zero», «ветвление, если не равно нулю») меняют поток программы в случае установленного или сброшенного флага нулы. BCS («Branch if Carry Set», «ветвление, если установлен перенос») и BCC («Branch if Carry Cleared», «ветвление, если сброшен перенос») делают то же самое для флага переноса. То, что следует за каждым опкодом, обычно должно являться меткой кода, который должен выполниться дальше, если условия опкода ветвления удовлетворены.

Ещё один пример ветвления


Хочу поделиться ещё одним примером ветвления. На этот раз цикл будет выполняться восемь раз, а не 256.


Как и в предыдущем примере цикла, здесь мы сначала устанавливаем предварительные условия цикла, присвоив регистру Y значение $08. Затем идёт метка, которую опкод ветвления использует позже. После завершения того, что должен делать цикл в каждой итерации, мы выполняем декремент регистра Y, а затем производим ветвление к началу цикла, если флаг нуля сброшен.

На более современных C-подобных языках программирования (например, JavaScript) весь этот цикл можно переписать следующим образом:

for (y = 8; y != 0; y--) {
  // do something
}

Выполнение сравнений


Рассмотренные нами ранее циклы полезны, но они требуют тщательной подготовки. Для завершения цикла счётчик цикла должен стать равным нулю. Чтобы создавать более гибкие и мощные циклы, нам нужна возможность выполнения произвольных сравнений. В языке ассемблера 6502 есть следующие опкоды сравнения: CMP, «Compare (with accumulator)» («сравнение с накопителем»), CPX, «Compare
with X register» («сравнение с регистром X») и CPY, «Compare with Y register» («сравнение с регистром Y»).

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

  1. Регистр больше сравниваемого значения: флаг переноса установлен, флаг нуля сброшен.
  2. Регистр равен сравниваемому значению: флаг переноса установлен, флаг нуля установлен.
  3. Регистр меньше сравниваемого значения: флаг переноса сброшен, флаг нуля сброшен.

Можно использовать эту информацию для создания более сложной логики программ. Рассмотрим случай, в котором мы загружаем значение из памяти, а затем проверяем, больше ли оно, равно или меньше $80.


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

Использование сравнений в циклах


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


Здесь перед началом цикла мы присваиваем регистру X значение 0. После каждой итерации цикла мы выполняем инкремент регистра X, а затем сравниваем X с $08. Если регистр X не равен восьми, флаг нуля не будет установлен и мы вернёмся к loop_start. В противном случае CPX установит флаг нуля (потому что восемь минус восемь равно нулю) и цикл завершится.

Подведём итог: в этой главе мы изучили следующие опкоды:

  • INX
  • INY
  • INC
  • DEX
  • DEY
  • DEC
  • BNE
  • BEQ
  • BCC
  • BCS
  • CMP
  • CPX
  • CPY

Ещё 13 новых опкодов, которые можно использовать!

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

13. Циклы на практике


Содержание:

  • Индексированный режим
  • Загрузка палитр и спрайтов
  • Домашняя работа

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

Чтобы воспользоваться циклами в полной мере, мы соединим изученные в прошлой главе опкоды циклов с новым режимом адресации. Когда мы впервые говорили об опкодах в Главе 5, было изучено два режима адресации: абсолютный режим (например, LDA $8001) и непосредственный режим (например, LDA #$a0). Теперь мы изучим третий режим адресации: индексированный.

Индексированный режим


Индексированный режим сочетает фиксированный, абсолютный адрес памяти с переменным содержимым индексного регистра (отсюда и название «индексный регистр»). Для использования индексированного режима адресации записывается адрес памяти, точка, а затем имя регистра.


Показанный выше пример кода получает содержимое адреса памяти ($8000 + значение регистра X). Если текущее значение регистра X равно $05, то команда LDA $8000,X получит содержимое адреса памяти $8005.

Использование индексированного режима позволяет нам с лёгкостью выполнять действия с диапазоном адресов памяти. Например, вот фрагмент кода, присваивающий 256 байтам памяти с $3000 по $30FF значение $00.


Строка 1 присваивает накопителю значение 0 (#$00), а затем строка 2 копирует этот ноль в регистр X. Строка 4 сохраняет ноль из накопителя в адрес памяти ($3000 плюс регистр X), который при первом проходе цикла будет равен $3000. В строке 5 выполняется инкремент регистра X, а строка 6 проверяет состояние флага нуля в регистре состояния процессора. Если результат последней операции не был равен нулю, мы возвращаемся к метке в строке 3. Затем выполняется инкремент регистра X с нуля до единицы, результат последней операции равен единице, поэтому флаг нуля не будет установлен и цикл повторится снова. При следующем выполнении цикла в строке 4 ноль из накопителя снова будет сохранён в адрес памяти ($3000 плюс регистр X), который теперь будет адресом памяти $3001. Цикл будет повторяться, пока регистр X не будет равен $ff, а инкремент в строке 5 не сменит значение регистра X на $00.

Загрузка палитр и спрайтов


Разобравшись с индексированным режимом, давайте используем его для упрощения нашего кода загрузки палитр и спрайтов. В коде из Главы 10 загрузка палитр и спрайтов — это монотонный, повторяющийся и подверженный ошибкам процесс. В основном это вызвано тем, что код тесно связывает данные и логику. При помощи циклов и индексированной адресации можно отделить данные палитр и спрайтов от кода, передающего эти данные в PPU, что упростит обновление данных, снизив шанс что-то непреднамеренно испортить.

Код загрузки данных палитр из Главы 10 выглядит так:


Давайте отделим значения палитр и сохраним их в какое-нибудь другое место. Здесь значения палитр — это данные только для чтения, поэтому мы будем хранить их в сегменте RODATA, а не в текущем сегменте CODE. Это будет выглядеть примерно так:


Мы задаём метку (palettes), чтобы легко находить начало данных палитр, а затем используем директиву .byte, чтобы сообщить ассемблеру «далее следует последовательность простых байтов данных, не пытайся интерпретировать их как опкоды».

Далее нам нужно изменить код записи палитр, чтобы он обходил в цикле данные в RODATA. Мы оставим строки 21-26, присваивающие адресу PPU значение $3f00, но начиная со строки 27 мы используем цикл:


Вместо того, чтобы прописывать в коде каждое значение палитры, мы загрузим их как «адрес метки palettes плюс значение регистра X». Выполняя инкремент регистра X при каждом прохождении цикла (INX), мы можем последовательно получать доступ ко всем значениям палитр.

Обратите внимание, что в конце цикла мы выполняем сравнение с #$04. Это гарантирует, что цикл выполнится для четырёх, и только четырёх значений. Если задать операнду сравнения какое-то большее значение, то может оказаться, что мы считаем память за пределами того, что мы определили как хранилище палитр, что может привести к непредсказуемым результатам.

Теперь, когда палитры загружаются более чистым образом, давайте обратим внимание на спрайтовые данные. Как и в случае с палитрами, мы можем хранить спрайтовые данные в RODATA и считывать их в цикле. Сейчас код загрузки спрайтов выглядит так:


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


Этот код слегка отличается от кода загрузки палитр. Обратите внимание, что в строке 40 вместо записи в фиксированный адрес (PPUDATA) мы используем индексированный режим для инкремента адреса, в который будет выполняться запись, а также адреса, откуда будет выполняться считывание.

И ещё один шаг: нам нужно переместить спрайтовые данные в RODATA. Вот как выглядят спрайтовые данные в гораздо более читаемом формате «одна строка на спрайт»:


Домашняя работа


Теперь, когда вы знаете, как использовать циклы и ветвление для повышения читаемости и удобства поддержки ассемблерного кода, настало время попробовать их самостоятельно. Дополните существующий код так, чтобы он загружал четыре полных палитры (с цветами на ваш выбор) и отрисовывал на экране как минимум четыре спрайта. Вам нужно будет изменить данные палитр и спрайтов в RODATA, а также изменить счётчики циклов в циклах загрузки палитр и спрайтов. Не забудьте заново ассемблировать файлы исходников и скомпоновать их в новый файл .nes (напоминание о том, как это сделать, см. в конце Главы 8).

Весь код для этой главы можно скачать в файле zip.

Комментарии (3)


  1. alex_231
    25.03.2022 03:23

    Поэтому код, к которому вы хотите выполнить ветвление, должен располагаться не более чем на 127 бит до или не более чем на 128 бит после команды ветвления.

    Не знаю, у вас или у автора, но тут ошибка - не бит, а байт.


  1. Neburrarh
    25.03.2022 06:54

    Ах, асм ...

    Вот пробежался по листингам - и глаз отдохнул. Я, правда, только DOS-овский Асм изучал. В наше время сплошных стрессов есть смысл так успокаиваться - просто почитать ассемблерный код.


  1. spag002
    25.03.2022 13:30

    Для использования индексированного режима адресации записывается адрес памяти, точка, а затем имя регистра.

    Очевидно, речь о запятой. В оригинале - "comma".

    Возможно, объяснялось в предыдущих статьях, но почему вы регистр А - аккумулятор - упорно именуете неким "накопителем"? Аж глаз режет.