Здравствуйте! В этой статье я расскажу какие шаги нужно пройти для создания простого процессора и окружения для него.
Архитектура набора команд (ISA)
Для начала нужно определиться с тем, каким будет процессор. Важны такие параметры как:
- Размер машинного слова и регистров(разрядность/"битность" процессора)
- Машинные команды (инструкции) и их размер
Архитектуры процессоров можно разделить по размеру инструкций на 2 вида (на самом деле их больше, но другие варианты менее популярны):
Основное их отличие в том, что RISC процессоры имеют одинаковый размер инструкций. Их инструкции простые и выполняются сравнительно быстро, тогда как CISC процессоры могут иметь разный размер инструкций, некоторые из которых могут выполняться достаточно продолжительное время.
Я решил сделать RISC процессор во многом похожий на MIPS.
Я это сделал по целому ряду причин:
- Довольно просто создать прототип такого процессора.
- Вся сложность такого вида процессоров перекладывается на такие программы как ассемблер и/или компилятор.
Вот основные характеристики моего процессора:
- Машинное слово и размер регистров — 32 бита
- 64 регистра (включая счетчик команд)
- 2 типа инструкций
Register type(досл. Регистровый тип) выглядит вот так:
Особенность таких инструкций заключается в том, что они оперируют с тремя регистрами.
Immediate type(досл. Немедленный тип):
Инструкции этого типа оперируют с двумя регистрами и числом.
OP — это номер инструкции, которую нужно выполнить (или же для указания, что эта инструкция Register type).
R0, R1, R2 — это номера регистров, которые служат операндами для инструкции.
Func — это дополнительное поле, которое служит для указания вида Register type инструкций.
Imm — это поле куда записывается то значение, которое мы хотим явно предоставить инструкции в качестве операнда.
- Всего 28 инструкций
Полный список инструкций можно посмотреть в github репозитории.
Вот лишь пару из них:
nor r0, r1, r2
NOR это Register type инструкция, которая делает логическое ИЛИ НЕ на регистрах r1 и r2, после записывает результат в регистр r0.
Для того, чтобы использовать эту инструкцию нужно изменить поле OP на 0000 и поле Func на 0000000111 в двоичной системе счисления.
lw r0, n(r1)
LW это Immediate type инструкция, которая загружает значение памяти по адресу r1 + n в регистр r0.
Для того, чтобы использовать эту инструкцию в свою очередь нужно изменить поле OP на 0111, а в поле IMM записать число n.
Написание кода процессора
После создания ISA можно приступить к написанию процессора.
Для этого нам нужно знание какого нибудь языка описания оборудования. Вот некоторые из них:
- Verilog
- VHDL (не путать с предыдущим!)
Я выбрал Verilog, т.к. программирование на нем было частью моего учебного курса в университете.
Для написания процессора нужно понимать логику его работы:
- Получение инструкции по адресу Счетчика команд (PC)
- Декодирование инструкции
- Выполнение инструкции
- Прибавление к Cчетчику команды размера выполненной инструкции
И так до бесконечности.
Получается нужно создать несколько модулей:
- Регистровый файл
- Декодер
- АЛУ
Разберем по отдельности каждый модуль.
Регистровый файл
Регистровый файл предоставляет доступ к регистрам. С его помощью нужно получать значения каких то регистров, или изменять их.
В моем случае у меня 64 регистра. В один из регистров записывается результат операции над двумя другими, так что мне нужно предоставить возможность изменять только один, а получать значения из двух других.
Декодер
Декодер это тот блок, который отвечает за декодирование инструкций. Он указывает какие операции нужно выполнить АЛУ и другим блокам.
Например, инструкция addi должна сложить значение регистра $zero(Он всегда хранит 0) и 20 и положить результат в регистр $t0.
addi $t0, $zero, 20
На этом этапе декодер определяет, что эта инструкция:
- Immediate type
- Должна записать результат в регистр
И передает эти сведения следующим блокам.
АЛУ
После управление переходит в АЛУ. В нем обычно выполняются все математические, логические операции, а также операции сравнения чисел.
То есть, если рассмотреть ту же инструкцию addi, то на этом этапе происходит сложение 0 и 20.
Другие
По мимо вышеперечисленных блоков, процессор должен уметь:
- Получать и изменять значения в памяти
- Выполнять условные переходы
Тут и там можно увидеть как это выглядит в коде.
Ассемблер
После написания процессора нам нужна программа, которая бы преобразовывала текстовые команды в машинный код, чтобы не делать этого вручную. Поэтому нужно написать ассемблер.
Я решил реализовать его на языке программирования Си.
Так как мой процессор имеет RISC архитектуру, то для того, чтобы упростить себе жизнь, я решил спроектировать ассемблер так, чтобы в него можно было легко добавлять свои псевдоинструкции(комбинации из нескольких элементарных инструкций или из других псевдоинструкций).
Можно реализовать это с помощью структуры данных, хранящей в себе тип инструкции, ее формат, указатель на функцию, которая возвращает машинные коды инструкции, и ее название.
Обычная программа начинается с объявления сегмента.
Для нас достаточно двух сегментов .text — в котором будет храниться исходный код наших программ — и .data — в котором будет хранится наши данные и константы.
Инструкция может выглядеть вот так:
.text
jie $zero, $zero, $zero # Ветвление
addi $t1, $zero, 2 # $t1 = $zero + 2
lw $t1, 5($t2) # $t1 = *($t2 + 5)
syscall 0, $zero, $zero # syscall(0, 0, 0)
la $t1, label# $t1 = label
Сначала указывается название инструкции, потом операнды.
В .data же указываются объявления данных.
.data
.byte 23 # Константа размером 1 байт
.half 1337 # Константа размером 2 байта
.word 69000, 25000 # Константы размером 4 байта
.asciiz "Hello World!" # Константная нуль терминируемая строка (Си строка)
.ascii "12312009" # Константная строка (без терминатора)
.space 45 # Пропуск 45 байтов
Объявление должно начинаться с точки и названия типа данных, после же идут константы или аргументы.
Удобно парсить (сканировать) ассемблер файл в таком виде:
- Сначала сканируем сегмент
- Если это .data сегмент, то мы парсим разные типы данных или .text сегмент
- Если это .text сегмент, то мы парсим команды или .data сегмент
Для работы ассемблеру нужно проходить исходный файл 2 раза. В первый раз он считает по каким смещениям находятся ссылки (они служат для), они обычно выглядят вот так:
la $s4, loop # Загружаем адрес loop в s4
loop: # Ссылка!
mul $s2, $s2, $s1 # s2 = s2 * s1
addi $s1, $s1, -1 # s1 = s1 - 1
jil $s3, $s1, $s4 # если s3 < s1 то перейди на метку
А во второй проход можно уже и генерировать файл.
Итог
В дальнейшем, можно запускать выходной файл из ассемблера на нашем процессоре и оценивать результат.
Также готовый ассемблер можно использовать в Си компиляторе. Но это уже позже.
Ссылки:
- Designing Digital Computer Systems with Verilog. David J. Lilja and Sachin S. Sapatnekar
- Исходный код
- Исходный код другого процессора