Pull to refresh

Собственный софт-процессор на ПЛИС с компилятором языка высокого уровня или Песнь о МышЕ

Reading time17 min
Views8.5K
Собственный софт-процессор на ПЛИС с компилятором языка высокого уровня или Песнь о МышЕ — опыт адаптации компилятора языка высокого уровня к стековому процессорному ядру.

Распространенной проблемой для софт-процессоров является отсутствие средств разработки для них, особенно, если их система команд не является подмножеством команд одного их популярных процессорных ядер. Разработчики в этом случае вынуждены будут решать эту проблему. Прямым её решением является создание компилятора языка ассемблера. Однако в современных реалиях не всегда удобно работать на Ассемблере, так как в процессе развития проекта может изменяться система команд в связи, например, с изменившимися требованиями. Поэтому задача легкой реализации компилятора языка высокого уровня (ЯВУ) для софт-процессора является актуальной.

Компилятор языка Python — Uzh представляется легким и удобным инструментарием для разработки программного обеспечения для софт-процессоров. Инструментарий определения примитивов и макросов как функций целевого языка позволяет критичные места реализовывать на ассемблере процессора. В данной работе рассмотрены основные моменты адаптации компилятора для процессоров стековой архитектуры.

Вместо эпиграфа:

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

Если этого ежа,
Нос заткнув, чтоб не дышал,
Где поглубже, бросить в речку
Вы получите ерша.

Если этого ерша,
Головой в тисках зажав,
Посильней тянуть за хвост
Вы получите ужа.

Если этого ужа,
Приготовив два ножа…
Впрочем, он наверно сдохнет,
Но идея хороша!


Введение


Во многих случаях при реализации измерительных приборов, научно-исследовательского оборудования в качестве основного ядра системы предпочтительнее применять реконфигурируемые решения на базе ПЛИС/FPGA. Данный подход имеет множество преимуществ, благодаря возможности легкого и быстрого внесения изменений в логику работы, а также за счет аппаратного ускорения операций обработки данных и сигналов управления.

Для широкого круга задач, таких, как цифровая обработка сигналов, встраиваемые системы управления, системы сбора и анализа данных, хорошо зарекомендовал себя подход, заключающийся в сочетании в одном решении блоков, реализуемых логикой ПЛИС для критических процессов, и элементов программного управления на основе одного или нескольких софт-процессоров для общего управления и координации, а также для реализации взаимодействия с пользователем или внешними устройствами/узлами. Применение софт-процессоров в данном случае позволяет несколько снизить временные затраты на отладку и верификацию алгоритмов управления системой или алгоритмов взаимодействия отдельных узлов.

Типовые «хотелки»


Зачастую от софт-процессоров в данном случае не требуется сверхвысокая производительность (т.к. её проще добиться, использую логические и аппаратные ресурсы ПЛИС). Они могут быть достаточно простыми (а с точки зрения современных микроконтроллеров – почти примитивными), т.к. они могут обойтись без сложной системы прерываний, работать только с определенными узлами или интерфейсами, нет необходимости поддерживать ту или иную систему команд. Их может быть много, при этом каждый из них может выполнять только определенный набор алгоритмов или подпрограмм. Разрядность софт-процессоров также может быть любой, в том числе не кратной байту – в зависимости от требований текущей задачи.

Типовыми целевыми показателями для софт-процессоров являются:

  • достаточная функциональность системы команд, возможно оптимизированная под задачу;
  • высокая плотность программного кода, т.к. это позволит экономить ресурсы памяти ПЛИС;
  • компактность – не хотелось бы, чтобы вспомогательные элементы занимали дефицитные ресурсы программируемой логики.

Безусловно, некоторой проблемой для софт-процессоров является отсутствие средств разработки для них, особенно, если их система команд не является подмножеством команд одного их популярных процессорных ядер. Разработчики в этом случае вынуждены будут решать эту проблему. Прямым её решением является создание компилятора языка Ассемблера для софт-процессора. Однако в современных реалиях не всегда удобно работать на Ассемблере, особенно если в процессе развития проекта будет изменяться система команд в связи, например, с изменившимися требованиями. Поэтому к выше перечисленным требованиям логично добавить еще требование легкой реализации компилятора языка высокого уровня (ЯВУ) для софт-процессора.

Исходные компоненты


Этим требованиям с большим процентом соответствия удовлетворяют стековые процессоры, т.к. нет необходимости адресовать регистры, разрядность команды может быть небольшой.
Разрядность данных для них может варьироваться и не привязана к разрядности системы команд. Являясь де-факто (пусть и с небольшими оговорками) аппаратной реализацией промежуточного представления программного кода при компиляции (виртуальная стековая машина, или в терминах контекстно-свободных грамматик – магазинный автомат) позволяют с низкими трудозатратами перевести грамматику любого языка в исполнимый код. Кроме того, для стековых процессоров практически «родным» языком является язык Форт. Трудозатраты на реализацию Форт-компилятора для стекового процессора сравнимы с затратами на Ассемблер, при гораздо большей гибкости и эффективности в реализации программ в дальнейшем.

Имея задачу на построение системы сбора данных с интеллектуальных датчиков в режиме, близком к режиму реального времени, в качестве опорного решения (т.н. Reference Design) софт-процессора был выбран Форт-процессор, описанный в работах [1] (в дальнейшем будет иногда называться как процессор whiteTiger по нику его автора).

Его основные особенности:

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

    1. выборка команды и операндов;
    2. исполнение команды и сохранение результата.

Процессор дополнен UART-загрузчиком программного кода, что позволяет менять исполняемую программу без перекомпиляции проекта для ПЛИС.

С оглядкой на конфигурацию блочной памяти в ПЛИС разрядность команд установлена равной 9 бит. Разрядность данных задана в 32 бита, но может быть в принципе любой.

Код процессора написан на VHDL без применения каких-либо специфических библиотек, что позволяет работать с данным проектом на ПЛИС от любого производителя.

Для относительно широкого применения, снижения «входного порога», а также для повторного использования кода и применения наработок кода, целесообразнее перейти на ЯВУ, отличный от Форта (отчасти это связано с суевериями и заблуждениями майн-стрим программистов относительно сложностей данного языка и читабельности его кода (к слову, один из авторов данной работы аналогичного мнения о С-подобных языках)).

Исходя из ряда факторов для эксперимента по «привязке» софт-процессора и ЯВУ был выбран язык Питон (Python). Это высокоуровневый язык программирования общего назначения, ориентированный на повышение производительности разработчика и читаемости кода, поддерживающий несколько парадигм программирования, в том числе структурное, объектно-ориентированное, функциональное, императивное и аспектно-ориентированное [2].

Для начинающих разработчиков интересно его расширение MyHDL [3, 4], позволяющее описывать аппаратные элементы и структуры на Python и транслировать их в код на VHDL или Verilog.

Некоторое время назад был анонсирован компилятор Uzh [5] — небольшой компилятор для программного процессора FPGA Zmey (32-битная стековая архитектура с поддержкой многопоточности – если проследить цепочку версий/модификаций/верификаций – Zmey – дальний потомок процессора whiteTiger).
Uzh – это также статически скомпилированное подмножество Python, основывается на перспективном инструментарии raddsl (набор инструментов для быстрого создания прототипов DSL-компиляторов) [6, 7].

Таким образом, факторы, повлиявшие на выбор направления работ можно сформулировать примерно так:

  • интерес к средствам, понижающим «порог вхождения» для начинающих разработчиков устройств и систем на ПЛИС (синтаксически Python не такой «страшный» для начинающего, как VHDL);
  • стремление к гармонии и единому стилю в проекте (теоретически возможно описать требуемые аппаратные блоки и программное обеспечение софт-процессора на Python);
  • случайное стечение обстоятельств.

Небольшие, «почти» ничего не значащие нюансы


Исходный код процессора Zmey не является открытым, но доступно описание принципов его работы и некоторых особенностей архитектуры. Хотя он и является также стековым, есть ряд ключевых отличий от процессора whiteTiger:

  • стеки являются программными – т.е. представлены указателями и размещаются в памяти данных по разным адресам;
  • в систему команд введен ряд команд, оптимизирующих процессор для выполнения кода С-подобных ЯВУ;
  • отличаются способы загрузки и представления чисел и констант в памяти программ;
  • процессор является многопоточным, но в контексте данной работы это не является существенным.

Соответственно, компилятор Uzh данные особенности учитывает. Компилятор принимает код на языке Python и формирует на выходе загрузочный поток для инициации памяти программ и памяти данных процессора, ключевым моментом является то, что на этапе компиляции доступен весь функционал языка.

Для установки компилятора Uzh достаточно скачать его архив и распаковать в любую удобную папку (лучше придерживаться общих рекомендаций для специализированного программного обеспечения – избегать путей, содержащих кириллицу и пробелы). Также необходимо скачать и распаковать в основную папку компилятора инструментарий raddsl.

Папка test компилятора содержит примеры программ для софт-процессора, папка src – исходные тексты элементов компилятора. Для удобства работы лучше создать небольшой командный файл (расширение .cmd) с содержимым: c.py C:\D\My_Docs\Documents\uzh-master\tests\abc.py , где abc.py – имя файла с программой для софт-процессора.

Змея, кусающая себя за хвост или притирка железа и софта


Для адаптации Uzh-а к процессору whiteTiger потребуются некоторые изменения, также как и немного придется подкорректировать и сам процессор.

К счастью, мест, подлежащих корректировке в компиляторе не много. Основные «аппаратно-зависимые» файлы:

  • asm.py – ассемблер и формирование чисел (литералов);
  • gen.py – низкоуровневые правила формирования кода (функции, переменные, переходы и условия);
  • stream.py – формирование загрузочного потока;
  • macro.py – макроопределения, по факту – расширения базового языка аппаратно-специфичными функциями.

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

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

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

Поскольку память данных задействована в логике работы процессорного ядра, необходимо мультиплексировать её линии данных и управления. Для этого вводятся дополнительные сигналы DataDinBtemp, LoaderAddrB, DataWeBtemp – данные, адрес и разрешение записи для порта В памяти.

Код загрузчика теперь выглядит так:

uart_unit: entity work.uart
--uart_unit: entity uart
  Generic map(
    ClkFreq => 50_000_000,
    Baudrate => 115200)
  port map(
    clk => clk,
    rxd => rx,
    txd => tx,
    dout => receivedByte,
    received => received,
    din => transmitByte,
    transmit => transmit);
    
process(clk)
begin
  if rising_edge(clk) then
    if received = '1' then
      case conv_integer(receivedByte) is
      -- 0-F   - 0-3 bits
        when 0 to 15 => CodeDinA(3 downto 0) <= receivedByte(3 downto 0);
		                  DataDinBtemp(3 downto 0) <= receivedByte(3 downto 0);
      -- 10-1F -4-7bits
        when 16 to 31 => CodeDinA(7 downto 4) <= receivedByte(3 downto 0);
		                   DataDinBtemp(7 downto 4) <= receivedByte(3 downto 0); 
      -- 20-2F -8bit 
        when 32 to 47 => CodeDinA(8) <= receivedByte(0);
	                   DataDinBtemp(11 downto 8) <= receivedByte(3 downto 0);
	  when 48 to 63 => DataDinBtemp(15 downto 12) <= receivedByte(3 downto 0);
	  when 64 to 79 => DataDinBtemp(19 downto 16) <= receivedByte(3 downto 0);
	  when 80 to 95 => DataDinBtemp(23 downto 20) <= receivedByte(3 downto 0);
	  when 96 to 111 => DataDinBtemp(27 downto 24) <= receivedByte(3 downto 0);
        when 112 to 127 => DataDinBtemp(31 downto 28) <= receivedByte(3 downto 0);

      -- F0 addr=0
        when 240 => CodeAddrA <= (others => '0');
      -- F1 - WE=1
        when 241 => CodeWeA <= '1';
      -- F2 WE=0 addr++
        when 242 => CodeWeA <= '0'; CodeAddrA <= CodeAddrA + 1;
      -- F3 RESET=1
        when 243 => int_reset <= '1';
      -- F4 RESET=0
        when 244 => int_reset <= '0';

      -- F5 addr=0
        when 245 => LoaderAddrB <= (others => '0');
      -- F6 - WE=1
        when 246 => DataWeBtemp <= '1';
      -- F7 WE=0 addr++
        when 247 => DataWeBtemp <= '0'; LoaderAddrB <= LoaderAddrB + 1;
		  
		  
        when others => null;
      end case;
    end if;
  end if;
end process;

---- end of loader


При активном уровне сброса сигналы DataDinBtemp, LoaderAddrB, DataWeBtemp подключаются к соответствующим портам памяти данных.

…
    if reset = '1' or int_reset = '1' then
      DSAddrA <= (others => '0');      
      
      RSAddrA <= (others => '0');
      RSAddrB <= (others => '0');
      RSWeA <= '0';
      
      DataAddrB <= LoaderAddrB;
		DataDinB<=DataDinBtemp;
		DataWeB<=DataWeBtemp;
      DataWeA <= '0';
…

В соответствии с алгоритмом работы загрузчика необходимо модифицировать модуль stream.py. Сейчас в нем две функции. Первая функция — get_val() — разбивает входное слова на нужное количество тетрад. Так, для 9-битных команд процессора whiteTiger они будут транформированы в группы по три тетрады, а 32-битные данные – в последовательности из восьми тетрад. Вторая функция make() формирует непосредственно загрузочный поток.
Финальный вид модуля stream:

def get_val(x, by_4):
  r = []
  for i in range(by_4):
    r.append((x & 0xf) | (i << 4))
    x >>= 4
  return r

def make(code, data, core=0):
  # передаем команду сброса и задаем начальный адрес 0 памяти данных
  stream = [243,245] 
  for x in data:
    # по тетрадам передается 32-битное слово
    # выставляется и сбрасывается бит разрешения записи в память данных
    stream += get_val(x, 8) + [246, 247]
  # начальный адрес памяти команд устанавливается в 0
  stream += [240]
  for x in code:
    # по тетрадам передается 9-битное слово 
    # выставляется и сбрасывается бит разрешения записи в память команд
    stream += get_val(x, 3) + [241, 242]
  #снимается сигнал сброса
  stream.append(244)

  return bytearray(stream)


Следующие изменения в компиляторе коснутся модуля asm.py в котором описывается система команд процессора (прописываются мнемоники команд и опкоды команд) и способ представления/компиляции числовых значений – литералов.

Команды упаковываются в словарь, а за литералы отвечает функция lit(). Если с системой команд все просто – просто меняется список мнемоноик и соответсвующих им опкодов, то с литералами дело обстоит немного иначе. В процессоре Zmey команды 8-битные и есть ряд специализированных команд для работы с литералами. В whiteTiger 9-ый бит служит признаком — является ли опкод командой или частью числа.

Если старший (9-ый) бит слова равен 1, то опкод интерпретируется как число – так, к примеру, четыре идущих подряд опкода с признаком числа формируют в итоге 32-битной число. Признаком окончания числа является наличие опкода команды – для определенности и обеспечения единообразия окончанием определения числа служит опкод команды NOP («нет операций»).

В итоге модифицированная функция lit() выглядит так:


def lit(x):
  x &= 0xffffffff
  r = [] 
  if (x>>24) & 255 :
    r.append(int((x>>24) & 255) | 256)
  if (x>>16) & 255:
    r.append(int((x>>16) & 255) | 256)
  if (x>>8) & 255:
    r.append(int((x>>8) & 255) | 256)
  r.append(int(x & 255) | 256)
  r += asm("NOP")
  return list(r)


Основные и самые ответственные изменения/определения – в модуле gen.py. Данный модуль определяет основную логику работы/исполнения высокоуровневого кода на уровне ассемблера:

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

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

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

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

Для работы с локальными переменными был добавлен выделенный регистр LocalReg, задача которого – хранить указатель на область памяти, отведенную для локальных переменных (своего рода heap). Добавлены также операции для работы с ним (файл cpu.vhd – область определения команд):


          -- group 1; pop 0; push 1;
          when cmdLOCAL => DSDinA <= LocalReg;
			 when cmdLOCALadd => DSDinA <= LocalReg; LocalReg <= LocalReg+1;
			 when cmdLOCALsubb => DSDinA <= LocalReg; LocalReg <= LocalReg-1;
…
          -- group 2; pop 1; push 0;
          when cmdSETLOCAL => LocalReg <= DSDinA;
…

LOCAL – возвращает на стек данных текущее значение указателя LocalReg;
SETLOCAL – устанавливает новое значение указателя, принятое со стека данных;
LOCALadd – оставляет на стеке данных текущее значение указателя и увеличивает его на 1;
LOCALsubb — оставляет на стеке данных текущее значение указателя и уменьшает его на 1.
LOCALadd и LOCALsubb добавлены для уменьшения количества тактов при операциях передачи параметров функции и наоборот.

В отличие от оригинального whiteTiger немного были изменены подключения памяти данных – теперь порт В памяти постоянно адресуется выходом первой ячейки стека данных, на его вход подается выход второй ячейки стека данных:

-- ++
DataAddrB <= DSDoutA(DataAddrB'range);
DataDinB <= DSDoutB;

Логика выполнения команд STORE и FETCH также немного подкорректировалась – FETCH принимает на вершину стека данных выходное значение порта В памяти, а STORE просто управляет сигналом разрешения записи порта В:

…
          -- group 3; pop 1; push 1;
          when cmdFETCH => DSDinA <= DataDoutB;
…
          when cmdSTORE =>            
            DataWeB <= '1';
…

В рамках тренировки, а также для некоторой аппаратной поддержки циклов на низком уровне (и на уровне компилятора языка Форт) к ядру whiteTiger был добавлен стек счетчиков циклов (действия аналогичные, как при объявлении стеков данных и возвратов):

…
-- стек счетчиков
type TCycleStack is array(0 to LocalSize-1) of DataSignal;
signal CycleStack: TCycleStack;
signal CSAddrA, CSAddrB: StackAddrSignal;
signal CSDoutA, CSDoutB: DataSignal;
signal CSDinA, CSDinB: DataSignal;
signal CSWeA, CSWeB: std_logic;
…
-- стек счетчиков
process(clk)
begin
  if rising_edge(clk) then
    if CSWeA = '1' then
      CycleStack(conv_integer(CSAddrA)) <= CSDinA;
      CSDoutA <= CSDinA;
    else
      CSDoutA <= CycleStack(conv_integer(CSAddrA));
    end if;
  end if;
end process;


Были добавлены команды организации циклов со счетчиком.

DO – перемещающая число итераций цикла со стека данных на стек счетчиков и помещающая на стек возвратов инкрементированное на единицу значение счетчика команд.

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


	when cmdDO => -- DO - 
               RSAddrA <= RSAddrA + 1; -- 
               RSDinA <= ip + 1;
               RSWeA <= '1';
				
               CSAddrA <= CSAddrA + 1; --
         		CSDinA <= DSDoutA;
 		         CSWeA <= '1';
		         DSAddrA <= DSAddrA - 1; --
		         ip <= ip + 1;	-- 

      when cmdLOOP => --            
           if conv_integer(CSDoutA) = 0 then
	          ip <= ip + 1;	-- 
		         RSAddrA <= RSAddrA - 1; -- 
		         CSAddrA <= CSAddrA - 1; -- 
            else
		         CSDinA <= CSDoutA - 1;
		         CSWeA <= '1';
		         ip <= RSDoutA(ip'range);
            end if;
			 

Теперь можно приступить к модификации кода модуля gen.py.

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

Список STUB – временная заглушка для формирования места для адресов переходов с последующим их заполнением компилятором (текущие значения соответствуют 24-битному адресному пространству памяти кода).

Список STARTUP – задает последовательность действий, выполняемых ядром после сброса – в данном случае будет задан начальный адрес памяти локальных переменных – 900, и переход на точку старта (если ничего не менять, точка старта/входа в приложение прописывается компиляторов в ячейку памяти данных с адресом 2):

STARTUP = asm("""
900  SETLOCAL
2 NOP FETCH JMP
""")

Определение func() прописывает действия, производимые при вызове функции, а именно – перенос аргументов функции в область локальных переменных, выделение памяти для собственных локальных переменных функции.

@act
def func(t, X):
  t.c.entry = t.c.globs[X]
  t.c.entry["offs"] = len(t.c.code) # - 1
  args = t.c.entry["args"]
  temps_size = len(t.c.entry["locs"]) - args
# перенос аргументов в область локальных переменных
  t.out = asm("LOCALadd STORE " * args)
  if temps_size:
# отведение памяти под локальные переменные функции
    t.out += asm("LOCAL %d PLUS SETLOCAL" % temps_size)
  return True

Epilog() определяет действия при возвращении из функции – освобождение памяти временных переменных, возврат на точку вызова.

def epilog(t, X):
  locs_size = len(t.c.entry["locs"])
#  возврат из подпрограммы
  t.out = asm("RET")
  if locs_size:
# высвобождение памяти временных (внутренних) переменных функции
    t.out = asm("LOCAL %d MINUS SETLOCAL" % locs_size) + t.out
  return True


Работа с переменными идет посредством их адресов, ключевое определение для этого – push_local(), оставляющее на стеке данных адрес «высокоуровневой» переменной.

def push_local(t, X):
# берем текущее значение указателя локалов и отнимаем смещение относительного него
# требуемой переменной
  t.out = asm("LOCAL %d MINUS" % get_loc_offset(t, X))
  return True

Следующие ключевые моменты – это условный и безусловный переходы. Условный переход в процессоре whiteTiger проверяет на 0 второй элемент стека данных и переходит по адресу на вершине стека, если условие выполняется. Безусловный переход просто устанавливает значение счетчика команд равному значению на вершине стека.

@act
def goto_if_0(t, X):
  push_label(t, X)
  t.out += asm("IF")
  return True

@act
def goto(t, X):
  push_label(t, X)
  t.out += asm("JMP")
  return True


Следующие два определения задают операции битового сдвига – как раз на низком уровне применены циклы (даст некоторый выигрыш в размере кода – оригинале компилятор просто помещает подряд требуемое количество элементарных операций сдвига.

@act
def shl_const(t, X):
  t.out = asm("%d DO SHL LOOP" %(X-1))
  return True

@act
def shr_const(t, X):
  t.out = asm("%d DO SHR LOOP" %(X-1))
  return True

И основное определение компилятора на низком уровне – набор правил для операций языка и работы с памятью:

stmt = rule(alt(
  seq(Push(Int(X)), to(lambda v: asm("%d" % v.X))),
  seq(Push(Local(X)), push_local),
  seq(Push(Global(X)), push_global),
  seq(Load(), to(lambda v: asm("NOP FETCH"))),
  seq(Store(), to(lambda v: asm("STORE"))),
  seq(Call(), to(lambda v: asm("CALL"))),
  seq(BinOp("+"), to(lambda v: asm("PLUS"))),
  seq(BinOp("-"), to(lambda v: asm("MINUS"))),
  seq(BinOp("&"), to(lambda v: asm("AND"))),
  seq(BinOp("|"), to(lambda v: asm("OR"))),
  seq(BinOp("^"), to(lambda v: asm("XOR"))),
  seq(BinOp("*"), to(lambda v: asm("MUL"))),
  seq(BinOp("<"), to(lambda v: asm("LESS"))),
  seq(BinOp(">"), to(lambda v: asm("GREATER"))),
  seq(BinOp("=="), to(lambda v: asm("EQUAL"))),
  seq(BinOp("~"), to(lambda v: asm("NOT"))),
  seq(ShlConst(X), shl_const),
  seq(ShrConst(X), shr_const),
  seq(Func(X), func),
  seq(Label(X), label),
  seq(Return(X), epilog),
  seq(GotoIf0(X), goto_if_0),
  seq(Goto(X), goto),
  seq(Nop(), to(lambda v: asm("NOP"))),
  seq(Asm(X), to(lambda v: asm(v.X)))
))

Модуль macro.py позволяет несколько «расширить» словарь целевого языка за счет макроопределений на ассемблере целевого процессора. Для компилятора ЯВУ определения в macro.py не будут отличаться от «родных» операторов и функций языка. Так, к примеру, в оригинальном компиляторе были определены функции ввода-вывода значения во внешний порт. Были добавлены тестовые последовательности операций с памятью и локальными переменными и операция временной задержки.

@macro(1,0)
def testasm(c,x):
  return Asm("1 1 OUTPORT 0 1 OUTPORT 11 10 STORE 10 FETCH 1 OUTPORT  15 100 STORE 100  FETCH 1 OUTPORT")

@macro(1,0)
def testlocal(c,x):
   return Asm("1 100 STORE 2 101 STORE 100 SETLOCAL LOCAL NOP FETCH 1 OUTPORT LOCAL 1 PLUS NOP FETCH 1 OUTPORT")

@prim(1, 0)
def delay(c, val):
  return [val, Asm("DO LOOP")]


Тестирование


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

def fact(n):
  r = 1
  while n > 1:
    r *= n
    n -= 1
  return r


def main():
  n=1
  while True:
     digital_write(1, fact(n))
     delay(10)
     n=(n+1)&0x7


Запуск её на компиляцию можно произвести, например, простым скриптом или из командной строки последовательностью:
c.py C:\D\My_Docs\Documents\uzh-master\tests\fact2.py


В результате будет сформирован загрузочный файл stream.bin, который можно передавать процессорному ядру в ПЛИС через последовательный порт (в современных реалиях через любой виртуальный последовательный порт, который предоставляют преобразователи интерфейсов USB-UART). Программа в итоге занимает 146 слов (9-битных) памяти программ и 3 в памяти данных.

Заключение


В целом, компилятор Uzh представляется легким и удобным инструментарием для разработки программного обеспечения для софт-процессоров. Является прекрасной альтернативой ассемблеру, по крайней мере, в плане удобства работы программисту. Инструментарий определения примитивов и макросов как функций целевого языка позволяет критичные места реализовывать на ассемблере процессора. Для процессоров стековой архитектуры процедура адаптации компилятора не является слишком сложной и долгой. Можно сказать, что это как раз тот случай, когда наличие исходных текстов компилятора помогает – изменяются ключевые участки компилятора.

Результаты синтеза процессора (разрядность 32 бита, 4К-слов памяти программ и 1К ОЗУ) для FPGA Altera серии Cyclone V дает следующее:

Family	Cyclone V
Device	5CEBA4F23C7
Logic utilization (in ALMs)	694 / 18,480 ( 4 % )
Total registers	447
Total pins	83 / 224 ( 37 % )
Total virtual pins	0
Total block memory bits	72,192 / 3,153,920 ( 2 % )
Total DSP Blocks	2 / 66 ( 3 % )

Литература

  1. Forth-процессор на VHDL // m.habr.com/ru/post/149686
  2. Python — Википедия // ru.wikipedia.org/wiki/Python
  3. Начинаем FPGA на Python _ Хабр // m.habr.com/ru/post/439638
  4. MyHDL // www.myhdl.org
  5. GitHub — true-grue_uzh_ Uzh compiler // github.com/true-grue/uzh
  6. GitHub — true-grue_raddsl_ Tools for rapid prototyping of DSL compilers // github.com/true-grue/raddsl
  7. sovietov.com/txt/dsl_python_conf.pdf

Автор выражает благодарность разработчикам софт-процессора Zmey и компилятора Uzh за консультации и долготерпение.
Tags:
Hubs:
+12
Comments1

Articles