Как разработать кросс-компилятор Форта для микроконтроллеров
Форт для микроконтроллеров на протяжении уже долгих лет остается подобным птице Феникс — экзотическое, красивое и вечно возрождающееся создание. Крайняя редкость использования не в последнюю очередь является следствием неимоверной гибкости на грани аморфности, когда буквально с первых шагов работы с конкретной версией Форта ее можно модифицировать до потери совместимости не только с другими версиями, но с ней самой в другом варианте модификации. Однако простота правил, заложенных в язык, и простота методов разработки самого Форта приводит к тому, что поток новых реализаций не прекращается с момента появления языка в 1970 году. Что важно для нас, многие из этих реализаций являются разработками единственных авторов, поэтому написать такое в одиночку более чем реально.
Что следует отметить из особенностей Форта? Во-первых, это конкатенативная компиляция. Конкатенация — это «склеивание», когда фрагменты кода просто пишутся один за другим, без перекрестных связей друг с другом. Это позволяет сопоставить такие фрагменты с символическими обозначениями, и при компиляции оказывается достаточным разбирать текст программы на такие кусочки текста (с точки зрения теории их можно назвать лексемами или токенами), и после получения каждого кусочка немедленно вставлять в программу вызов связанного с ней кода. Например, если для токенов proc1, proc2 и proc3 написаны подпрограммы в машинных кодах, начинающиеся с адресов 123, 456 и 789, то текст на Форте «proc1 proc2 proc3» должен быть скомпилирован в последовательность команд call 123 call 456 call 789. Для чисел («непосредственных операндов») здесь делается исключение, поскольку на все многообразие чисел нельзя написать подпрограмм. Как числа обрабатываются компилятором, мы разберем чуть дальше, но с ними связана вторая особенность Форта, а поэтому сейчас будет «во-вторых».
Во-вторых, при конкатенативной компиляции естественным решением будет постфиксная запись выражений (сначала аргументы, потом операция с ними). Дело в том, что конкатенативная компиляция означает, что, скомпилировав команду самой операции, мы уже не должны возвращаться к ней, вписывая неизвестный пока операнд. Поэтому вместо A + B мы пишем A B +, получая очевидное решение в виде call A call B call +. Имеется в виду, что команды call переходят на соответствующие указанным токенам подпрограммы. Из-за унифицированного порядка организации подпрограмм Форт не использует деление на зарезервированные слова, переменные, константы, идентификаторы и т.д. - все токены называются словами. Ну а поскольку при постфиксной записи может накопиться немалое число уже принятых операндов, которые надо где-то хранить, еще одним естественным приемом является стековая машина. Надо сказать, что для МК она не является абсолютно обязательным элементом, о чем речь также пойдет ниже, однако стек — отработанное в практике программирование решение, и его применение в Форте продиктовано вполне определенными соображениями, а именно — достижением максимальной простоты компилятора.
Для микроконтроллеров в силу самых разных соображений может оказаться весьма полезным кросс-компилятор Форта — т.е. программа, которая обрабатывает текст на Форте на обычном PC, но компилирует код, который должен работать на МК. Разработка кросс-компилятора Форта для МК — достаточно интересное и своеобразное занятие, которое позволяет разработчику не только обеспечить себя собственным инструментом, но и реализовать различные варианты построения компилятора, от наиболее быстродействующего (настолько, насколько это позволяет подход, принятый в Форте) до наиболее компактного. Поскольку в самом Форте для PC уже есть многие механизмы обработки текста, нужные нам для создания кросс-компилятора, рассмотрим порядок разработки Форта для МК с помощью Форта для PC. Фраза «готовые примеры скачайте там-то» здесь встречаться не будет, поскольку основная цель данного материала — помочь разобраться (а не дать готовое решение, сопровождаемое бодрыми комментариями о том, как все хорошо).
Что нам понадобится для работы. Во-первых, собственно МК. Желательно, чтобы он был в наличии «вживую», а не в виде программного симулятора, и чтобы имелась возможность загрузить в него «мгновенный снимок» памяти. Зачем нужен мгновенный снимок? Потому что в процессе разработки кросс-компилятора мы в конце концов получим массив, содержащий образ памяти нашего МК. Поэтому важно, чтобы вот такой «сырой» образ, без заголовков, таблиц и прочих структур данных, мог быть один в один перенесен в память МК и запущен.
Во-вторых, нужен транслятор Форта для PC. Какой — практически все равно. Я реализовывал кросс-компиляторы на трех различных версиях Форта, и ни в одном случае не испытывал принципиальных неудобств.
В-третьих, необходима документация на систему команд ядра выбранного МК. Также нужен startup код, если он должен выполнять неочевидные вещи, которые компилируются стандартными для этого МК компиляторами, а вот мы по незнанию можем их не скомпилировать, и в итоге ничего не получится. Сюда обычно относится настройка критичной периферии, такой как встроенные контроллеры памяти и средства связи с PC.
Итак, начинаем. Условимся, что мы разрабатываем наиболее простой вариант кросс-компилятора, создающий машинный код для нашего МК. Договоримся также, что МК либо имеет гарвардскую архитектуру (раздельное пространство кода и данных), либо разделить код и данные можно искусственно — поместив их в разные устройства памяти. Зачастую оно так и есть, поскольку МК имеют флеш-память (достаточно большого объема), которую мы используем для хранения программы, и статическое ОЗУ (а его объем сравнительно небольшой), где естественно хранить данные. Кросс-компилятор не будет помещать в выходной код элементы Форт-транслятора, т.е. ничего лишнего в памяти МК не окажется, но самостоятельно принимать от других устройств программы на Форте и транслировать их он потом не сможет.
Начинаем мы с создания массивов, где будут храниться образы памяти целевой программы. Посмотрим документацию на предмет размеров памяти МК, и напишем нечто вроде:
Код:
32768 CONSTANT CODESIZE
1024 CONSTANT DATASIZE
CREATE CODE[] CODESIZE ALLOT
CREATE DATA[] DATASIZE ALLOT
VARIABLE C^
VARIABLE D^
Что у нас получилось? Мы задали константы, определяющие размеры областей памяти МК. Затем создали массивы CODE[] и DATA[] (квадратные скобки необязательны, но они рекомендуются для создания читаемого стиля). Наконец, объявили переменные-указатели, которые будут хранить смещения от начала массивов. Не забываем, что компиляция у нас будет конкатенативная, то есть код будет помещаться байт за байтом, поэтому достаточно помнить смещение первого свободного байта.
Теперь попробуем написать минимальный управляющий код.
Код:
: START
0 C^ !
0 D^ !
;
: ORG \ addr –
C^ !
;
: TC-C, \ byte -
CODE[] C^ @ + C!
C^ @ 1 + C^ ! \ в Форте часто можно написать здесь 1 C^ +!
;
Что получилось? START просто инициализирует указатели, ORG принудительно задает место, с которого будет продолжаться компиляция, а TC-C, составлено из двух частей. Запятая в Форте переносит число со стека в память (просто «как есть», т.е. вписывает это значение по первому же свободному адресу, передвигая указатель). Префикс C означает Char, а TC является сокращением от Target Compilation. Все вместе это означает «снять число со стека и перенести его на первое же свободное место в образе памяти целевой системы». Теперь мы уже можем создать программу, зная нужные нам машинные коды:
Код:
START
0 ORG
12 TC-C,
23 TC-C,
45 TC-C,
Пока не очень удобно. Кстати, вместо TC у меня прижилось слово ZC,. Первым целевым процессором был Z80, от него и осталась буква Z. Конечно же, Форт не накладывает никаких ограничений на имена слов, поэтому тут допустим определенный произвол, если он помогает правильно осмысливать программу.
Идем дальше. Для МК есть собственный вариант «Hello, world» - помигать светодиодом. Это намек на то, что заниматься компиляцией пересылок «регистр-регистр» и арифметических команд — несколько тупиковое занятие, в том смысле, что только из них показательную программу не составить. Гораздо полезнее посмотреть, как компилировать программу, состоящую из загрузки константы в регистр, отправке ее в какой-либо порт, и переходе на начало. Если мы выберем соответствующий порт, у нас загорится светодиод на плате с МК, а это значит, что компилятор все сделал правильно. Итак, смотрим в систему команд и пытаемся найти там что-либо простое. Здесь и далее коды операций будут совершенно абстрактные, и не относящиеся к какому-то конкретному процессору.
Код:
: NOP 0 TC-C, ;
Собственно, это уже элемент кросс-ассемблера. Если мы в предыдущем фрагменте кода ставили TC-C, «руками» то сейчас достаточно написать NOP, и код 0 сам окажется в массиве, отвечающем за память программ. Очевидно, что все безоперандные команды можно сделать таким образом. Посмотрим документацию еще немного, и обнаруживаем, что у нас в компиляторе оказалось приличное количество кодов.
Код:
: CLI 12 TC-C, ;
: STI 34 TC-C, ;
И так далее. Кстати говоря, если уж у нас все равно не будет полного совпадения с существующими форматами ассемблера, можно немного украсить синтаксис... а заодно и существенно облегчить себе жизнь.
Код:
: A->B 56 TC-C, ;
: A++ 57 TC-C, ;
: A+=B 58 TC-C, ;
Что у нас получилось сейчас? А мы попросту заменили команды с операндами на безоперандные варианты. Первая строка — это аналог команды пересылки, которая записалась бы в варианте mov b, a (или mov %a, %b, смотря какой порядок операндов принят). Если мы посмотрим, какой код соответствует такой команде, то его можно вписать прямо в наш компилятор, назначив приятное глазу имя. Далее идет команда инкремента, и наконец — сложение регистров A и B (напоминаю, что процессор — абстрактный).
Вообще-то подобные команды могут быть реализованы и иначе. Здесь следует привести конкретный пример — процессор i8080 (580ВМ80). Для него команда MOV имела вариант 01xxxyyy, где xxx и yyy — коды регистров, от 0 до 7. Если мы теперь зададим константы для регистров A, B, C, D, E, H, L, а потом еще для слов «A,» «B,» и так далее (разница — в запятой!), то потом можно будет «мимикрировать» под стандартный ассембер с помощью вот такой команды MOV
Код:
: MOV 8 * + 64 + TC-C, ;
A, B MOV
Этот пример (как и полный ассемблер для 580ВМ80, потрясающе компактный) описан в обязательной для прочтения книге Баранова и Ноздрунова «Язык Форт и его реализации». Как он работает? Слова A, и B оставят на стеке по одному числу. Верхнее умножится на 8, а это есть сдвиг на три разряда влево. Теперь мы его складываем с тем, которое под ним, и у нас уже получается двоичный код xxxyyy. Останется поставить единичку в 6-м разряде, добавив к числу 64. С тем же успехом у нас сработает код 2 3 MOV, поскольку то, что мы принимаем за имена регистров, на самом деле просто константы, отвечающие за эти регистры в машинном коде.
Почему мы так не сделали в нашем примере? В действительности, по многим причинам. Первая — для практического использования кросс-компилятора необходим контроль ошибок. Что будет с нашим кодом, если вместо чисел от 0 до 7 на стеке окажется 20? Неважно, случайно или намеренно. Вторая, и более важная — не все процессоры имеют такой уникальный для каждой разновидности команд синтаксис ассемблера, как 580ВМ80. У него MOV — это пересылка регистр-регистр, и только она. Например, загрузка в регистр числа имеет обозначение MVI (от Move Indirect), и так далее, поэтому ошибиться, приняв номер регистра за число, здесь невозможно. А вот в x86 любая команда пересылки имеет имя MOV, и тут уже необходимо серьезно задуматься о контроле типов операндов. Кстати, уже в Z80 все виды пересылок выполнялись командой LD (от Load), что уже заставляет контролировать типы. Это возможно, но способно увести нас в необязательные дебри, а нам хочется побыстрее увидеть работающий код. Поэтому, пока это возможно, превращаем команды в безоперандные. Кстати, необязательно набирать сотни строк кода, реализуя в компиляторе все сочетания операндов, какие только предлагает система команд. Для запуска нужно очень немного. Более того, я могу сослаться на подобный ассемблер для x86, в котором присутствуют даже такие слова, как EAX->[EBX+ECX*2]. Разумеется, там присутствуют далеко не все команды, а если говорить более точно, то лишь малая часть. Тем не менее, 700 строк Форта (около 15 кб) в свое время позволили написать 32-битную реализацию Форта для защищенного режима.
Резюмируя эту часть: опять-таки смотрим в документацию и выписываем команды, которые можно привести к безоперандному виду. В первую очередь, это команды «регистр-регистр». Теперь у нас уже должны появиться десятки слов в нашем целевом ассемблере.
Но мы приближаемся к чуть более сложной проблеме — а как, например, поместить в регистр число? Написать слова 0->A 1->A 2->A и так далее? Конечно, если постараться, да и автоматизировать генерацию таких строчек, то для 8-разрядного процессора окажется всего-навсего 256 вариантов загрузки на регистр. Но все равно громоздко, да и непонятно, а что же делать для 16- и 32-разрядных операндов??? Ведь 64к, а тем более 4Г вариантов, это, извините за каламбур, совсем не вариант.
Хорошо, смотрим в документацию. И видим там, что никаких особенных чудес вобщем-то нет. Команда загрузки числа в регистр представляет собой какой-то определенный код, за которым потом помещается загружаемое число. Допустим, что для загрузки числа 12 в регистр A мы должны скомпилировать коды 62 12, где 62 — это код команды. Тогда мы можем опять немного украсить синтаксис ассемблера.
Код:
: ->A \ x –
62 TC-C,
TC-C,
;
Сначала мы заносим в память код 62, а потом... какое-то число? Посмотрим на комментарий (стековую нотацию) нашего слова — там на стеке должен находиться некий x. Например, если мы напишем 5 ->A, то сначала при работе слова скомпилируется 62, а потом TC-C, начнет забирать число со стека, где так и лежит 5. Значит, в коде МК окажутся числа 62 и 5. Как же обеспечить загрузку 12? Очевидно, строкой 12 ->A. И ведь это уже решение проблемы! Нам осталось еще немного почитать документацию и выписать прочие команды, которые требуют операнды. Кстати, именно операнды, а не один операнд — смотрим на такую запись.
Код:
12 23 ->[A+OFFSET]
Такое слово заберет со стека два операнда. Да, OFFSET немного непривычно, зато результат сразу.
Теперь мы подходим к немного нетривиальному механизму кросс-компилятора — переходам по меткам. Нет, конечно же, мы и переходы можем сделать в стиле 125 JMP. Вот только вычислять вручную адреса конкретных команд очень и очень трудоемко. Крайне хотелось бы иметь метки. Попробуем и тут обойтись минимальными усилиями.
Код:
1000 CONSTANT MAXLABELS
CREATE LABEL[] MAXLABELS 4 * ALLOT
Создаем массив, в котором на каждую из 1000 возможных меток выделено по 4 байта. Умножить на 4 можно еще словом CELLS, оно правильнее с точки зрения стилистики, но ведь про него еще нужно знать. Кстати, тут можно было умножать и на 2 — главное, чтобы в выделенное место влезал адрес.
Теперь пишем так:
Код:
: LABEL: \ n –
4 * LABEL[] + \ на стеке адрес n-й ячейки массива
C^ @ SWAP ! \ пишем туда текущее значение указателя свободного байта
;
: LABEL \ n – a
4 * LABEL[] + @
;
Скорее пример!
0 ORG
NOP \ адрес = 0
12 ->A \ эта команда, видимо, 2 байта, и займет адреса 1 и 2
1 LABEL: \ теперь в массив меток запишется адрес 3 для метки с номером 1
1 LABEL JMP
После исполнения 1 LABEL на стеке окажется ранее записанный для этой метки адрес. В нашем случае это 3. Теперь нам останется реализовать JMP таким образом, чтобы это слово снимало со стека адрес, по которому необходимо сделать переход.
Да, это и непривычно, и как-то чрезмерно просто. А что будет, например, если мы напишем 23 JMP? Ничего правильного не будет, потому что метка 23 не была определена ранее. А уж если написать 10000 LABEL, то в процессе вычисления адреса программа вылезет далеко за пределы массива, с неопределенными последствиями. Хорошо, диапазон можно и проверять, а вот что делать с переходами вперед? Придется чуть посложнее.
Код:
CREATE FORWARD-ADDR[] 1000 4 * ALLOT
CREATE FORWARD-LABEL[] 1000 4 * ALLOT
Почему тут два массива? А потому, что на каждую метку может быть несколько ссылок вперед. Другими словами, в программе может несколько раз встречаться попытка перейти к метке 1, прежде чем встретится метка с этим номером, и станет понятно, куда же мы так активно стремились. Поэтому в один массив мы будем писать адреса, на которых мы «споткнулись», обнаружив, что пока не знаем, куда идти, а в другой — на какую метку мы хотели пойти. Для такой операции придется завести и другое слово.
Код:
VARIABLE FREE-SLOT
: FORWARD \ n –
FIND-FREE-SLOT \ внимание! Это еще не описано
FREE-SLOT @ 4 * FORWARD-LABEL[] + ! \ сняли со стека номер метки, к которой хотим перейти
C^ @ FREE-SLOT @ 4 * FORWARD-ADDR[] + ! \ запомнили адрес, где это произошло
C^ 2 + C! \ зарезервировали место для адреса
;
Что такое FIND-FREE-SLOT? Это слово, которое лежит несколько в стороне от основной линии изложения, и его желательно реализовать самостоятельно. Оно должно записать в переменную FREE-SLOT индекс ячейки, которую можно занять для новой ссылки вперед. В принципе, можно и просто перемещать указатель, каждый раз занимая новую ячейку, и при памяти современных PC мы еще нескоро добьемся их исчерпания. Однако, если уж подходить правильно, как только мы определим метку с нужным нам номером и впишем все адреса на их места, все записи, относящиеся к этой метке, будут нам не нужны. Поэтому можно инициализировать массив FORWARD-LABEL[] числами -1, считая этот номер признаком свободной ячейки. Слово FIND-FREE-SLOT должно будет пройти по массиву и вернуть номер ячейки, в которой лежит -1.
Что теперь с этим делать, когда мы наконец объявим метку с нужным нам номером? Необходимо будет произвести разрешение метки (в русском языке слово выглядит двусмысленным, но оно происходит не от enabling, а от resolving, т.е. мы не «даем позволение», а «решаем задачу»). Представим, что в какой-то момент мы определили метку, и теперь мы знаем, чему же равен искомый адрес (подсказка для тех, кто забыл, с чего мы начали — для этого достаточно выполнить C^ @). Делаем так.
Код:
: ?RESOLVE-REF \ n --
1000 0 DO
FORWARD-LABEL[] I 4 * + @ OVER = IF \ в этом слоте находится нужная нам метка?
C^ @ \ вот наш адрес
FORWARD-ADDR[] I 4 * + @ \ а вот место, куда мы его хотели вписать, но еще не знали
! \ пишем
-1 FORWARD-LABEL[] I 4 * + \ теперь нам этот слот уже не нужен, помечаем его как незанятый
THEN
LOOP
DROP
;
Теперь необходимо вернуться немного назад, к слову LABEL. Дело в том, что когда мы определяем метку, следует проверить, не было ли на нее ссылок. Теперь-то адрес стал известен, так вдруг его кто-то с нетерпением ждет? Следовательно, в конце слова LABEL необходимо добавить ?RESOLVE-REF. Соответственно, и LABEL следует переставить по тексту ниже, чтобы не получить ошибку «слово не определено». А рассматривали мы эти механизмы в порядке возрастания сложности, поэтому и LABEL оказались в наши примерах перед более сложным FORWARD.
В кросс-компиляторах я предпочитаю использовать следующий формат:
Код:
1 LABEL:
JMP 1 LABEL
JMP 2 FORWARD
2 LABEL:
Это означает то, что слово JMP не пытается снять со стека адрес, а просто компилирует машинный код команды перехода. А вот уж потом слово LABEL само допишет нужный адрес (в нашем примере получилось немного не так, но достаточно дописать TC-W, - это компиляция word, или, если речь о 32-разрядных адресах, TC-,). Соответственно, слово FORWARD передвинет указатель свободного места, зарезервировав его под адрес, который будет туда записан словом ?RESOLVE-REF, которое, в свою очередь, вызовется из LABEL:
Промежуточный вывод. Сейчас у нас уже есть возможность писать довольно мощные в плане синтаксиса программы, в которых есть и команды пересылки, и команды загрузки, и переходы с автоматической расстановкой меток. Пока это ближе к ассемблеру, т.е. мы можем путем некоторого отступления от стандартных для МК форматов заставить Форт обработать наш текст и сформировать в памяти образ кода, который должен быть загружен в память контроллера. Далее мы посмотрим, как дополнить этот кросс-компилятор возможностью транслировать тексты на Форте и компилировать их в код для МК.