Заставить ассемблер «z80asm» разместить инструкцию по известному адресу памяти.

Dec 30 2020

Я пишу очень простую ОС для своего доморощенного компьютера Z80. Как абсолютный новичок в языке ассемблера, мне удалось получить работающую «ОС плюс монитор памяти», которая может отображать содержимое памяти и загружать байты в ОЗУ. При этом я написал несколько «системных процедур» для взаимодействия с некоторыми устройствами ввода-вывода. Например, у меня есть процедура «Printc», которая считывает байт и рисует соответствующий символ ASCII на экране.

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

Теперь я хотел бы вызвать подпрограмму Printc из динамически загружаемой программы. Я могу сказать, где ассемблер поместил первый байт подпрограммы в ПЗУ, благодаря -lфлагу, который производит вывод, содержащий:

...
Print:    equ $043a Printc: equ $043e
Readc:    equ $0442 Readline: equ $0446
...

Теперь я могу написать такую ​​программу:

ld a, 0x50     ; ASCII code for P
call 0x043e    ; Calls Printc

Эта программа успешно печатает букву P: я вызвал свою подпрограмму Printc, используя ее адрес в памяти.

Это нормально, если я не изменяю ассемблерный код, предшествующий объявлению Printc в моей «os». Если я это сделаю, метка Printc будет назначена другому адресу, и моя существующая программа перестанет работать.

Какое каноническое решение этой проблемы? Единственное, что приходит мне в голову, - это создать «таблицу переходов» в начале моего ассемблерного кода, перед любым импортом, со списком системных вызовов, надеясь, что они будут получать каждый раз один и тот же адрес. Что-то вроде:

...
; System routines
Sys_Print:
call Print
ret
Sys_Printc:
call Printc
ret
.... and so on

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

Ответы

10 Raffzahn Dec 30 2020 at 04:31

Какое каноническое решение этой проблемы?

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

Единственное, что мне приходит в голову, это создать "таблицу переходов" в начале.

Что очень хорошо. За исключением того, что обычно вместо вызовов используются переходы для уменьшения длины кода, ускорения выполнения и уменьшения нагрузки на стек.


JUMP_TABLE:
PRINT    JP  _I_PRINT    ; First Function
READC    JP  _I_READC    ; Second Function
...

Но это кажется довольно хакерским ...

Нет, многие системы 8080 и Z80 так работают.

Главный шаг вперед - это то, что все точки входа находятся в одном определенном месте и последовательности.

Можно ли указать ассемблеру z80asm поместить первую инструкцию подпрограммы в адрес памяти, выбранный мной?

Конечно, используйте ORG, чтобы поместить его по любому адресу, который вы хотите (* 1). Но это было бы хакерством или, по крайней мере, не очень перспективным. Наличие такой таблицы переходов по определенному адресу - отличное начало. Конечно место съедает. Три байта на запись, но только два - это адрес. Не лучше ли составить адресную таблицу? Нравиться:

SYS_TABLE:
         DW    _I_PRINT    ; First Function
         DW    _I_READC    ; Second Function

Вызов функции был бы похож на

         LD    HL, (SYS_TABLE+0)   ; Load Address of First Function - PRINT
         JP    (HL)                ; Do it

Это легко комбинировать с каким-то переключателем функций:

SYS_ENTRY:
         PUSH  HL
         LD    H,0
         LD    L,A
         ADD   HL,HL
         ADD   HL,SYS_TABLE
         JP    (HL)

Теперь даже таблицу переходов можно перемещать в ПЗУ (или ОЗУ) по мере необходимости.

Вызов его будет с использованием номера функции - как и во многих ОС - просто введите номер функции в A и вызовите системную точку входа по умолчанию (SYS_ENTRY).

         LD    A,0   ; Print
         CALL  SYS_ENTRY

Конечно, он становится более читабельным, если ОС предоставляет набор эквивалентов для номеров функций :)

Пока что загруженной программе все еще необходимо знать адрес таблицы (SYS_TABLE) или точку входа для селектора (SYS_ENTRY). На следующем уровне абстракции их адрес переместится в определенное место, например 0100h, лучше всего в форме JP, поэтому любая пользовательская программа всегда вызывает этот фиксированный адрес (0100h), независимо от того, находится ли ваша ОС в ПЗУ или ОЗУ или где-то еще.

И да, если это кажется знакомым, это так, поскольку CP / M обрабатывает системные вызовы точно так же, как и MS-DOS.

Говоря о MS-DOS, он предоставляет дополнительный (и более известный способ) вызова функции ОС, так называемые программные прерывания, такие как хорошо известный INT 21h. И есть кое-что очень похожее, что предлагает Z80 (и 8080 ранее): набор из восьми различных векторов ReSTart (0/8/16 / ...). Restart 0 зарезервирован для сброса, все остальные можно использовать. Так почему бы не использовать вторую (RST 8h) для вашей ОС? Тогда вызовы функций будут выглядеть так:

         LD    A,0   ; Print
         RST   8h

Теперь код пользовательской программы максимально отделен от структуры ОС и разметки памяти - без необходимости какого-либо перемещения или чего-либо еще. Самое приятное то, что, если немного повозиться, весь селектор умещается в 8 доступных байтов, что делает его оптимальным кодированием.


Небольшое предложение:

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

  • Номер версии ABI
  • Максимальное количество поддерживаемых функций.

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

  • Структура, содержащая дополнительную информацию об ОС, например
    • читаемое имя / версия
    • Адреса разных источников
    • «специальные» точки входа
    • Информация о машине, например размер ОЗУ
    • доступные интерфейсы и др.

Просто говорю...


* 1 - И нет, за исключением того, что некоторые могут предположить, ORG никогда не должен добавлять отступы или что-то подобное само по себе. Ассемблеры, поступающие так, - плохой выбор. Организация должна только изменять уровень адреса, а не определять, что в какой-либо области «перепрыгнуло». Это увеличивает уровень потенциальных ошибок - по крайней мере, как только будет выполнено расширенное использование ORG - поверьте мне, ORG - очень универсальный инструмент при создании сложных структур.

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

Так что неопределенная память должна быть именно такой, undefined. И именно поэтому даже самые ранние форматы вывода / загрузчика ассемблера (например, Motorola SREC или Intel HEX ), используемые для доставки программ во что угодно, от изготовления ПЗУ до пользовательских программ, поддерживали способ исключения областей.

Короче говоря: если кто-то хочет, чтобы он был заполнен, это должно быть сделано явно. z80asm все делает правильно.

12 WillHartung Dec 30 2020 at 04:55

Проблема с Z80ASM в том, что он принимает входные данные сборки и выдает статический двоичный файл. Это и хорошо и плохо.

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

Поскольку Z80ASM выдает буквальные двоичные изображения, а не «объектные» файлы, компоновщик ему не нужен. Но это также не позволит вам делать то, что вы хотите.

Рассмотрим повсеместную директиву ORG.

ORG сообщает ассемблеру, каков начальный адрес (источник - таким образом, ORG) для будущего кода сборки.

Это означает, что если вы сделаете это:

    ORG 0x100
L1: jp L1

Ассемблер соберет инструкцию JP для JUMP по адресу 0x100 (L1).

НО, когда он выдаст двоичный файл, файл будет всего 3 байта. Инструкция перехода, за которой следует 0x100 в двоичном формате. В этом файле нет ничего, что говорило бы о том, что он должен быть загружен по адресу 0x100, чтобы "работать". Эта информация отсутствует.

Если вы это сделаете:

    ORG 0x100
L1: jp L2

    ORG 0x200
L2: jp L1

Это создаст файл длиной 6 байт. Эти две инструкции JP будут помещены друг за другом. Единственное, что делает оператор ORG, это говорит, какими должны быть метки. Это не то, чего вы ожидали.

Таким образом, простое добавление ORG в ваш файл не приведет к тому, что вы хотите сделать, если у вас нет альтернативного метода для загрузки кода в конкретное место, в котором вы хотите, чтобы ваш код находился.

Единственный способ сделать это с Z80ASM из коробки - заполнить выходной файл блоками байтов, пустым пространством, которое заполнит двоичный файл, чтобы поместить ваш код в нужное место.

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

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

Итак, для предыдущего примера было бы создано две записи. Один предназначен для 0x100, другой - для 0x200, а затем программа шестнадцатеричной загрузки поместит все в нужное место. Это еще одна альтернатива, но Z80ASM, похоже, ее тоже не поддерживает.

Так.

Z80ASM отлично подходит, если вы создаете образы ROM, начиная, скажем, произвольно, с 0x1000. Вы должны организовать это, получить результирующий двоичный файл и загрузить весь записанный файл в EPROM. Это идеально для этого.

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

5 GeorgePhillips Dec 30 2020 at 03:16

orgДиректива должна делать конкретно , что вы просите. Однако формат вывода z80asm несколько упрощен. Вместо этого вы можете использовать dsдля размещения подпрограмм по определенным адресам:

        ds     0x1000
printc:
        ...
        ret

        ds     0x1100-$
readc:
        ...
        ret

Это всегда будет printc0x1000 и readc0x1100. Есть много недостатков. Если printcвырастет больше, чем 0x100, программа не будет собираться, и вам нужно будет printcкаким-то образом разделиться и поместить дополнительный код в другое место. По этой и другим причинам таблица переходов в фиксированном месте в памяти проще в управлении и более гибкая:

           ds    0x100
v_printc:  jp    printc
v_readc:   jp    readc
           ...

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

И вместо того, чтобы делать CALLточку входа, поместите ее в одно из специальных RSTмест (0, 8, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38), где вы можете использовать RST 0x18как однобайтовый вызов ячейки памяти 0x18. Обычно RST 0и RST 0x38избегают, поскольку они являются точками входа pwoer-on и местоположениями обработчика модели 1 прерывания соответственно.