Forth-процессор на VHDL

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

    Процессор попадает под класс soft-процессоров.

    Архитектура


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

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

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

    В ПЛИС существует блочная память с конфигурацией 18 бит * 1024 ячейки. Ориентируясь на неё выбираю разрядность команды в 9 бит (в один блок памяти уместится 2048 команд).
    Разрядность памяти данных пусть будет «стандартной» в 32 бита.
    «Общение» с периферийными устройствами реализую с помощью шины.

    Схема всего этого безобразия получится примерно следующая.


    Система команд


    С архитектурой определились, теперь «попробуем со всем этим взлететь». Теперь необходимо придумать систему команд.
    Все команды процессора можно разделить на несколько групп:
    • Загрузка литерала (чисел) на стек
    • Переходы (условный переход, вызов подпрограммы, возврат)
    • Обращение к памяти данных (чтение и запись)
    • Обращение к шине (по смыслу то же самое, что и обращение к памяти).
    • Команды АЛУ.
    • Прочие команды.

    Итак, у нас есть 9 разрядов команды, в которые нам и нужно уложиться.

    Загрузка литералов

    Разрядность команды меньше разрядности данных, поэтому нужно придумать механизм загрузки чисел.

    Я выбрал следующий формат команды для загрузки литералов на стек:
    Мнемоника 8 7 6 5 4 3 2 1 0
    LIT 1 LIT

    Старший, 8 бит команды будет признаком загрузки числа. Остальные 8 бит – непосредственно число, загружаемое на стек.
    Но разрядность данных 32 бита, а загрузить пока что можно только 8 бит.
    Условимся, что если идет несколько команд LIT подряд, то это считается загрузкой одного числа. Первая команда загружается число на стек (знакорасширяя его), каждая последующая модифицирует верхнее число на стеке, сдвигая его на 8 бит влево и вписывая в младшую часть значение из команды. Таким образом, можно загрузить число любой разрядности последовательностью нескольких команд LIT.
    Для разделения нескольких чисел можно использовать любую команду (например, NOP).

    Группировка команд

    Я решил разбить все остальные команды на группы для удобства декодирования. Группировать будем по тому, как они влияют на стек.
    Мнемоника 8 7 6 5 4 3 2 1 0
    LIT 0 Группа Команда


    Группы команд:
    Группа Берет со стека Кладет на стек Пример
    0 0 0 NOP
    1 0 1 DEPTH
    2 1 0 DROP
    3 1 1 DUP, @
    4 2 0 !, OUTPORT
    5 2 1 Арифметика (+, -, AND)


    Переходы:
    Мнемоника 8 7 6 5 4 3 2 1 0
    JMP 0 2 0
    CALL 0 2 1
    IF 0 4 0
    RET 0 0 1

    Команды JMP и CALL берут адрес со стека и переходят по нему (call дополнительно кладет адрес возврата на соответствующий стек).
    Команда IF берет адрес перехода (верхнее число на стеке) и признак перехода (следующее число). Если признак равен нулю, то осуществляется переход по адресу.
    Команда RET работает со стеком возвратов, забирая верхнее число и переходя по нему.
    Если команда не является переходом, то счетчик команд увеличивается на единицу.

    Таблица команд

    Для описания команд используется стековая нотация, выглядящая следующим образом:
    <Состояние стека до выполнения слова> — <состояние стека после выполнения
    слова>

    Вершина стека находится справа, т.е. запись 2 3 — 5 означает, что до выполнения слова
    на вершине стека находилось число 3, а под ним число 2; после выполнения эти числа
    оказались удалены, а на вершине вместо них оказалось число 5.
    Пример:
    DUP (a — a a)
    DROP (a b — a)

    Возьмем минимальный набор команд, с которым можно будет хоть что-то сделать.
    H\L 0 1 2 3 4 5 6 7 8 9
    0 NOP RET
    1 TEMP> DEPTH RDEPTH DUP OVER
    2 JMP CALL DROP
    3 @ INPORT NOT SHL SHR SHRA
    4 IF ! OUTPORT
    5 NIP + - AND OR XOR = > < *


    Команда Стековая нотация Описание
    NOP No operation. Один процессорный такт ожидания
    DEPTH — D Помещение на стек количества чисел на стеке данных до выполнения этого слова
    RDEPTH — D Помещение на стек количества чисел на стеке возвратов до выполнения этого слова
    DUP A — A A Дублирование верхнего числа
    OVER A B — A B A Копирование на вершину второго сверху числа
    DROP A — Удаление верхнего числа
    @ A — D Чтение памяти данных по адресу A
    INPORT A — D Чтение данных с шины по адресу A
    NOT A — 0|-1 Логическое НЕ верхнего числа (0 заменяется на -1, любое другое число заменяется на 0)
    SHL A — B Сдвиг верхнего числа на 1 разряд влево
    SHR A — B Сдвиг верхнего числа на 1 разряд вправо
    SHRA A — B Арифметический сдвиг верхнего числа на 1 разряд вправо (знак числа сохраняется)
    ! D A — Запись данных D по адресу A в память данных
    OUTPORT D A — Запись данных D по адресу A в «шину» (на один такт будет выставлен сигнал iowr, периферия должна «поймать» свой адрес с высоким уровнем этого сигнала)
    NIP A B — B Удаление второго сверху числа со стека (число сохраняется в регистр TempReg)
    TEMP> — A Извлечение содержимого регистра TempReg
    + A B — A+B Сложение верхних чисел на стеке
    - A B — A-B Вычитание из второго сверху числа верхнего числа
    AND A B — A and B Побитовый AND над верхними числами
    OR A B — A or B Побитовый OR над верхними числами
    XOR A B — A xor B Побитовый XOR над верхними числами
    = A B — 0|-1 Проверка равенства верхних чисел. Если числа равны, оставляет на стеке -1, иначе 0
    > A B — 0|-1 Сравнение верхних чисел. Если A > B, оставляет на стеке -1, иначе 0. Сравнение с учетом знака
    < A B — 0|-1 Сравнение верхних чисел. Если A < B, оставляет на стеке -1, иначе 0. Сравнение с учетом знака
    * A B — A*B Умножение верхних чисел


    На стек за один процессорный такт можно записать 1 число; в форте есть команда SWAP, которая меняет местами 2 верхних числа на стеке. Для её реализации нужно 2 команды. Первая команда — NIP (a b — b), удаляет второе сверху число «a» и сохраняет его во временном регистре, а вторая команда TEMP> (-- a) извлекает это число из временного регистра и кладет на вершину стека.

    Приступаем к кодированию


    Реализация памяти.
    Память кода и данных реализована через шаблон:
    process(clk)
      if rising_edge(clk) then
        if WeA = '1' then
          Ram(AddrA) <= DinA;
        end if;
        DoutA <= Ram(AddrA);
        DoutB <= Ram(AddrB);
      end if;
    end process;
    


    Ram – это сигнал, объявленный следующим образом:
    subtype RamSignal is std_logic_vector(RamWidth-1 downto 0);
    type TRam is array(0 to RamSize-1) of RamSignal;
    signal Ram: TRam;
    


    Память можно инициализировать следующим образом:
    signal Ram: TRam :=
    (0 => conv_std_logic_vector(0, RamWidth),
     1 => conv_std_logic_vector(1, RamWidth),
     2 => conv_std_logic_vector(2, RamWidth),
     -- ...
     others => (others => '0'));
    


    Стеки реализованы через похожий шаблон
    process(clk)
      if rising_edge(clk) then
        if WeA = '1' then
          Stack(AddrA) <= DinA;
          DoutA <= DinA;
        else
          DoutA <= Stack(AddrA);  
        end if;
        DoutB <= Stack(AddrB);
      end if;
    end process;
    


    Отличие от шаблона памяти только в том, что она «пробрасывает» записываемое значение на выход. С предыдущим шаблоном записанное значение было бы получено на следующем, после записи, такте.

    Синтезатор автоматически распознаёт эти шаблоны и генерирует соответствующие блоки памяти. Это видно в отчете. Например, для стека данных он выглядит следующим образом:
    -----------------------------------------------------------------------
    | ram_type           | Distributed                         |          |
    -----------------------------------------------------------------------
    | Port A                                                              |
    |     aspect ratio   | 16-word x 32-bit                    |          |
    |     clkA           | connected to signal <clk>           | rise     |
    |     weA            | connected to signal <DSWeA>         | high     |
    |     addrA          | connected to signal <DSAddrA>       |          |
    |     diA            | connected to signal <DSDinA>        |          |
    |     doA            | connected to internal node          |          |
    -----------------------------------------------------------------------
    | Port B                                                              |
    |     aspect ratio   | 16-word x 32-bit                    |          |
    |     addrB          | connected to signal <DSAddrB>       |          |
    |     doB            | connected to internal node          |          |
    -----------------------------------------------------------------------
    


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

    Основной цикл работы процессора – на первом такте делается выборка команды, на втором – исполнение. Чтобы определить, на каком такте находится процессор, сделан сигнал fetching.
    process(clk)
    begin
      if rising_edge(clk) then
        if reset = '1' then
          -- обнуление сигналов     
          ip <= (others => '0');
          fetching <= '1';
        else      
          if fetching = '1' then
            fetching <= '0';
          else
            fetching <= '1';
            -- исполнение команды, формирование адреса для выборки
          end if;
        end if;
      end if;
    end process;
    

    Самый простой вариант декодирования и исполнения команды – это большой «case» по всем вариантам. Для простоты написания лучше разделить его на несколько составляющих.
    В этом проекте я разбил его на 3 части:
    • кейс, который будет отвечать за формирование адреса стека данных, и формировать сигнал записи;
    • кейс исполнения команды ;
    • кейс формирования нового счетчика команд (ip).


    -- Data stack addr and we
    case conv_integer(cmd(8 downto 4)) is
      when 16 to 31 => -- LIT
        if PrevCmdIsLIT = '0' then
          DSAddrA <= DSAddrA + 1;
        end if;
        DSWeA <= '1';          
      when 0 => -- group 0; pop 0; push 0
        null;
      when 1 => -- group 1; pop 0; push 1;
        DSAddrA <= DSAddrA + 1;
        DSWeA <= '1';          
      when 2 => -- group 2; pop 1; push 0;
        DSAddrA <= DSAddrA - 1;                        
      when 3 => -- group 3; pop 1; push 1;
        DSWeA <= '1';          
      when 4 => -- group 4; pop 2; push 0;
        DSAddrA <= DSAddrA - 2;          
      when 5 => -- group 5; pop 2; push 1;
        DSAddrA <= DSAddrA - 1;
        DSWeA <= '1';             
      when others => null;
    end case;
    


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

    Следующий кейс будет отвечать за исполнение команды. В нем формируются данные для стека данных (простите за тавтологию), сигнал iowr для команды OUTPORT и т.д.
    -- Data stack value
    case conv_integer(cmd) is
      when 256 to 511 => -- LIT
        if PrevCmdIsLIT = '1' then
          DSDinA <= DSDoutA(DataWidth - 9 downto 0) & Cmd(7 downto 0);
        else
          DSDinA <= sxt(Cmd(7 downto 0), DataWidth);              
        end if;
                  
      when cmdPLUS =>            
        DSDinA <= DSDoutA + DSDoutB;
        
      when others => null;
    end case;
    


    Пока реализовано только 2 команды. Загрузка чисел на стек и сложение двух верхних чисел на стеке. Этого хватит для «тестирования идеи», и, если эти 2 команды заработают, большинство остальных будет реализовано «по шаблону» без особых проблем.

    И последний кейс – формирование следующего адреса для счетчика команд:
    -- New ip and ret stack;
    case conv_integer(cmd) is
      when cmdJMP => -- jmp
        ip <= DSDoutA(ip'range);
        
      when cmdIF => -- if
        if conv_integer(DSDoutB) = 0 then
          ip <= DSDoutA(ip'range);
        else
          ip <= ip + 1;
        end if;
        
      when cmdCALL => -- call
        RSAddrA <= RSAddrA + 1;
        RSDinA <= ip + 1;
        RSWeA <= '1';
        ip <= DSDoutA(ip'range);
        
      when cmdRET => -- ret
        RSAddrA <= RSAddrA - 1;            
        ip <= RSDoutA(ip'range);
    
      when others => ip <= ip + 1;
    end case;
    


    Реализованы базовые команды переходов. Адрес перехода берется со стека.

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


    Прежде чем двигаться дальше, желательно оттестировать уже написанный код. Я создал TestBench, в который вписал только выдачу сигнала сброса на процессор в первые 100 ns.

    Память кода инициализировал следующим образом:
    signal CodeMemory: TCodeMemory := (
      0  => "000000000", -- lit tests
      1  => "100000000",
      2  => "100000001",
      3  => "100000010",
      4  => "000000000",
      5  => "100001111",
      6  => "000000000",
      7  => "100010000",
      8  => "100001000",
      9  => conv_std_logic_vector(cmdPLUS, CodeWidth),
      10 => conv_std_logic_vector(cmdPLUS, CodeWidth),
      11 => conv_std_logic_vector(cmdDROP, CodeWidth),
      12 => "100010011",
      13 => conv_std_logic_vector(cmdJMP, CodeWidth), -- jmp to 19
      14 => "100000010",
      15 => "000000000",
      16 => "100000010",
      17 => conv_std_logic_vector(cmdPLUS, CodeWidth),
      18 => conv_std_logic_vector(cmdRET, CodeWidth), -- ret
      19 => "100001110",
      20 => conv_std_logic_vector(cmdCALL, CodeWidth), -- call to 14
      21 => "111111111",
      others => (others => '0')
    );
    


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

    Результат моделирования показан на следующих картинках (кликабельно):

    Тест целиком:



    Тест загрузки чисел:



    Разбор загрузки чисел



    На рисунке показано выполнение команды Lit 0. После снятия сигнала сброса счетчик команд равняется нулю (ip = 0) и процессору сказано, что он находится на фазе выборке команды (fetching = '1'). На первом такте совершается выборка. Первая команда NOP, которая ничего, кроме увеличения счетчика команд не делает (впрочем, любая неизвестная команда увеличит счетчик команд, и также, может что-то сделать со стеком данных, в зависимости от той группы, в которой она находится).

    Команда #1 – это загрузка числа 0 на стек. На такте исполнения выставляются 3 сигнала: адрес стека данных увеличивается на 1, выставляются данные и выставляется сигнал разрешения записи.
    На следующем такте выборки в стек по адресу «1» записывается значение «0». Значение, также, сразу «пробрасывается» на выход (чтобы следующая команда оперировала уже новым значением). Сигнал разрешения записи снимается.

    Команда #2 – это тоже команда загрузки числа на стек. Т.к. она идет следом за командой LIT, то новое число на стек не будет загружено, а модифицируется верхнее. Оно сдвигается на 8 бит влево, в младшую часть пишется значение из команды (которое 0x01).

    Команда #3 выполняет те же самые операции, что и команда #2. Число на стеке, после её работы равняется 0x0102.

    Заключение


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

    Проект на гитхабе: github.com/whiteTigr/vhdl_cpu
    Код процессора: github.com/whiteTigr/vhdl_cpu/blob/master/cpu.vhd
    Код testbench'а (хотя в нем практически ничего нет): github.com/whiteTigr/vhdl_cpu/blob/master/cpu_tb.vhd
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 25
    • +2
      А я в майнкрафте свой 4-ёх битный процессор так и не достроил…
      • +1
        Я там только АЛУ начинал строить. Забросил на моменте, когда понял, что будет куча «копипаста» вручную по одному блоку. Хотя, проблему копипаста можно решить через моды админки.
        • +1
          WorldEdit-ом копипаст легко реализуется
      • +1
        Офигенно круто! и настолько же не понятно… )
        • +3
          Чего я и боялся. :)
          Хотелось и уместиться в объем статьи, и при этом сделать что-то более-менее законченное.

          Хм, как всегда хорошая мысля, приходит опосля. Надо было начать статью с рассказа о конечных автоматах и раскрутить их идею до уровня процессора.
          • 0
            какие книги бы вы могли порекомендовать для новичка в этой области (vhdl и дизайн железа). На английском — ok.
            • +1
              Бибило П.Н. — Основы языка VHDL (поможет разобраться с синтаксисом.)
              Лично мне понравился Pong P. Chu:
              1)RTL Hardware Design Using VHDL
              2)FPGA Prototyping by VHDL Examples
        • +1
          Целью статьи было показать, что процессор можно написать самому

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

          P.S. Помнится сильно мечтал в детстве о процессоре с двумя аппаратными стеками как раз для реализации Forth-машины — очень тогда Forth-понравился.
          • +2
            В результате трансляции проекта получается файл, который можно залить в ПЛИС(раз, два).

            Взгляд на проект с высоты птичьего полета:
            один
            два

            Можно протестировать уже на вполне реальном железе, а потом отдать на перевод в кремний (и впоследствии «китаю» на производство).
            • +1
              VHDL-код (если он удовлетворяет критерию «синтезируемости») в конечном итоге может быть преобразован в принципиальную схему, где будут триггеры и логические элементы, а из этой схемы можно уже сделать топологию кристалла, то есть расположить на кристалле элементарные ячейки и нарисовать разводку между ними.
            • +2
              Пара полезных ссылок из закладок (только там примеры на Altera HDL):

              * Проектирование конфигурируемых процессоров на базе ПЛИС
              www.kit-e.ru/articles/plis/2006_2_78.php
              * Микропроцессор своими руками.
              www.kit-e.ru/articles/cpu/2002_06_80.php
              • 0
                Тащемта, автор статей по первой ссылке является непосредственным научным руководителем автора данной статьи. =)
              • 0
                Сколько MIPS и на какой частоте заработает на вашей FPGA?
              • 0
                Заметил у вас синхронный сброс, хорошо что вы знакомы с xilinx recommendation.

                Чтобы развить идея остаётся добавить прерывания, перейти на популярную шину (к примеру Wishbone), добавить контроллер динамической памяти и дельной периферии. А там уже и не далеко портировать ОС реального времени.

                Ступеней конвейера тоже можно доделать.
                • 0
                  А что, если выборку делать по фронту, а исполнение по спаду? Тогда будет один такт на команду вместо двух.
                  Говорят, еще иногда юзают смещенные по фазе тактовые сигналы (одной и той же частоты), чтобы получить подтакты, но в реальности с таким я не сталкивался.
                  • 0
                    А смысл? Проще повысить частоту вдвое — по сути тоже самое, а прозрачность дизайна увеличивается. Это если говорить об имплементации на FPGA
                    • +1
                      Ну, чем выше частота, тем ярче проявляются неприятные ВЧ эффекты, вроде как… К тому же если и так умножитель уже использован внутренний, то придетется искать соответствующие кварцы (в два раза более высокой частоты) — думаю, все таки может быть оправдано.
                      • +1
                        С точки зрения схемы что выборки делать в 2 раза чаще, что частоту повышать в два раза: все это приводит к одному — уменьшению вдвое временных ограничений. За этим следить надо. По идее, конечно, это САПР должна все разводить, но практика показывает что не всегда это делается хорошо.
                  • 0
                    А сколько логических элементов занимает процессор в результате? Насколько он компактный?
                    • +1
                      Дописал загрузчик, теперь я уверен, что он из процессора точно ничего не выкидывает при трансляции проекта:
                      Финальный отчет

                      Заодно отвечу на комментарий:
                      Сколько MIPS и на какой частоте заработает на вашей FPGA?

                      Проваленная временная константа — это из-за высокой заданой частоты на входе. При заданой частоте в 50МГц (стандартная для стартер кита для Spartan3E) он[транслятор ISE :)] не особо старается упаковывать проект и максимально возможную частоту работы не показывает.
                      Максимальная частота работы получилась 79.5Мгц для кристала Spartan3E xc3s500. Самая длинная линия на умножителе. Следом за ней идут линии на сумматоре и компараторе.
                      Простор для оптимизации есть — сейчас процессор такт выборки команды просто простаивает, в этот такт можно вынести какие-либо предварительные вычисления.

                      MIPS будет в 2 раза ниже частоты, на которой работает процессор.
                    • 0
                      Вот тут winglion.ru/Forth-CPU/index2.php тоже самое на AHDL. На мой взгляд AHDL немного понятней…
                      • +2
                        Кстати, в онлайне есть классическая книга по построению стековых процессоров:Philip J. Koopman — Stack Computers: the new wave.

                        1989й год, но содержит всё, чтобы помочь человеку, обдумывающему архитектуру своего стекового процессора.
                        • 0
                          А перевода нет?
                          • +1
                            Вряд ли, больно специфическая книга, если только во времена СССР успели перевести.

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.