1i7

Лабораторная работа 5: делаем процессор MIPS (1)

Oct 09, 2012 01:25

Часть первая - архитектура.

На прошлой лабораторной работе ознакомились с понятием конечного автомата и способом его реализации на ПЛИС+Verilog. Там же мы смогли использовать этот механизм для создания простых роботов. На этот раз рассмотрим другую область его применения - процессор.

На этой лабораторной работе будут рассмотрены следующие вопросы:
1. Основные элементы архитектуры процессора на примере процессора MIPS
2. Понятие языка ассемблера и конвертация его в машинный код процессора.
3. Реализация простого однотактового процессора MIPS на языке Verilog, загрузка полученной имплементации на ПЛИС, подключение к нему базовых устройств ввода-вывода и запуск простой программы на ассемблере MIPS.

1. Основные элементы архитектуры процессора на примере 32-битного процессора MIPS

Архитектура процессора в общем определяется двумя вещами:
1. Набор регистров
2. Набор команд ассемблера

Плюс к этим двум сущностям для работы процессора необходимы также память инструкций (instruction memory) и память данных (data memory). Плюс индекс текущей выполняемой инструкции в памяти инструкций - счетчик программы (program counter - pc).

Общую схему работы процессора можно изобразить примерно так:




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

Далее все элементы на схеме будут разобраны подробно так, что все станет понятно.

Память инструкций и счетчик программы

Память инструкций (instruction memory) - адресуемая область памяти, хранящая последовательность команд, которые одну за одной выполняет процессор. Адресуемая - это значит, что каждый байт в этой области имеет свой порядковый номер, т.е. адрес - для 32-битной архитектуры адрес имеет длину 32 бита (4 байта), т.е. память инструкций теоретически может содержать максимально 2^32 байт. Каждая команда представляет из себя машинный код длиной 32 бита (4 байта - слово - word) для 32-битного процессора - это значит, что память инструкций может содержать 2^32/4=2^30 команд.

Например, следующая программа на ассемблере MIPS

lw $s0, 0 ($0)
lw $s1, 4 ($0)
add $s2, $s0, $s1
sw $s2, 8 ($0)
будет иметь такой вид в памяти инструкций:

0x00000000: 10001100 00010000 00000000 00000000 (hex: 8c 10 00 00)
0x00000004: 10001100 00010001 00000000 00000100 (hex: 8c 11 00 04)
0x00000008: 00000010 00010001 10010000 00100000 (hex: 02 11 90 20)
0x0000000C: 10101100 00010010 00000000 00001000 (hex: ac 12 00 08)

При старте работы процессор сначала выдернет из памяти инструкций первые 4 байта (1е слово) и выполнит их как команду, потом выдернет 2е 4 байта (2е слово) и выполнит их как команду и т.п.

Счетчик программы (program counter - pc) - область памяти 32 бит - содержит адрес команды, которую выполняет процессор на текущий такт. Адрес команды - это адрес 1го байта команды в памяти инструкций. Т.к. каждая команда занимает 4 байта, корректными значениями счетчика программы могут быть только адреса, которые кратны 4м - в примере выше, это 0, 4, 8, C (в шестнадцатиричной записи).

Память данных

Память данных (data memory) - адресуемая область памяти, хранящая произвольные данные, которыми может оперировать процессор во время выполнения программы. Аналогично памяти инструкций, длина адреса для 32-битной архитектуры составляет 32 бита, т.е. программа может адресовать максимум 2^32 байт. Байты также логически организованы в слова по 4 байта на слово (опять же для 32-битной архитектуры) - на физическом уровне это может быть никак не отражено - этот нюанс станет понятен ниже, когда будем рассматривать команды ассемблера, работающие с памятью данных, т.к. они оперируют как раз 4-байтовыми словами.

Файл регистров (register file)

Файл регистров (register file - к привычной всем дисковой файловой системе отношения не имеет) - по сути просто кусок памяти 32x32 бита (для 32-битной архитектуры MIPS - в общем случае разрядность может быть другой). Память регистров (в отличие от памяти данных) доступна процессорному блоку напрямую и располагается с ним на одном кристалле, поэтому операции с регистрами выполняются быстро, но их количество ограничено.

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

Каждый регистр может содержать 32 бита информации, каждый регистр имеет порядковый индекс от 0 до 31 (5 бит на индекс), при этом для удобства регистры разбиты на группы, а также имеют символьные имена и условные роли.

Имя
Номер
Назначение

$0
0
константа 0 (только чтение)

$at
1
временная ассемблера

$v0-$v1
2-3
возвращаемые значения процедур

$a0-$a3
4-7
аргументы процедур

$t0-$t7
8-15
временные переменные

$s0-$s7
16-23
сохраненные переменные

$t8-$t9
24-25
временные переменные

$k0-$k1
26-27
временные значения операционной системы

$gp
28
глобальный указатель

$sp
29
указатель стека

$fp
30
указатель стекового кадра (stack frame)

$ra
31
адрес возврата процедуры

В рамках данной лабы эти роли особого значения не имеют - таблица приведена для справки и для примера, в тестовой ассемблерной программе будем использовать регистры $0, $t0-$t7, $s0-$s7.

Двоичные индексы некоторых нужных нам регистров (5 бит на индекс) - для остальных регистров двоичные значения легко вычислить самостоятельно:

$0
...

00000

$t0

01000

$t1

01001

$t2

01010

$t3

01011

$t4

01100

$t5

01101

$t6

01110

$t7

01111

$s0

10000

$s1

10001

$s2

10010

$s3

10011

$s4

10100

$s5

10101

$s6

10110

$s7
...

10111

2. Понятие языка ассемблера и конвертация его в машинный код процессора

Переходим к командам ассемблера

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

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

Другими словами, набор и структура команд, которые будут рассмотрены ниже, спустилась не с неба - они выбраны и реализованы таким образом потому, что так посчитали нужным инженеры и архитекторы MIPS Technologies - команды, создававшие архитектуры других процессоров, решили эту же задачу другим образом. У выбора деталей реализации каждой из архитектур есть свои причины, инженерные и идеологические обоснования, которые в рамках текущего курса рассматриваться не будут. Мы могли бы тоже попробовать создать собственную архитектуру процессора со своим набором команд ассемблера и структурой машинных кодов, но вместо этого пока лучше попробуем реализовать подмножество процессора, который уже зарекомендовал себя в индустрии, те. 32-битный MIPS.

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

Эти команды будут: add (сложение), sub (вычитание), lw (загрузка значения из памяти данных), sw (сохранение значения в память данных), addi (сложение с константой), beq (условный переход), j (безусловный переход) - ниже каждая из них будет разобрана в деталях.

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

Команды ассемблера процессора MIPS разбиты на группы по типу принимаемых ими параметров (операндов) и как следствие - по структуре. Машинный код каждой команды вместе с операндами занимает 32 бита (для 32-битной архитектуры MIPS).

Команды типа R-type (register type)

Команды типа R-type имеют 3 операнда, все операнды - адреса регистров, 2 регистра-источника, 1 регистр - назначение.

Рассмотрим 2 команды этого типа - add и sub.

Команда add (add - сложить) очевидно складывает значения 2х регистров и кладет результат в 3й регистр.

синтаксис на ассемблере:
add rd, rs, rt

rd, rs, rt - адреса (имена) регистров - команда add должна взять значения из регистров rs и rt, вычислить сумму rs+rt и записать результат в регистр rd.

например:

add $s0, $s1, $s2
на практике должно быть выполнено как $s0=$s1+$s2 - вычислить сумму значений, хранящихся в регистрах $s1 и $s2, и записать результат в регистр $s0.

Команда sub (subtract - вычесть) очевидно вычисляет разность значений 2х регистров и кладет результат в 3й регистр.

синтаксис на ассемблере:
sub rd, rs, rt

rd, rs, rt - адреса (имена) регистров - команда sub должна взять значения из регистров rs и rt, вычислить разность rs-rt и записать результат в регистр rd

например:

sub $s0, $s1, $s2
на практике должно быть выполнено как $s0=$s1-$s2 - вычислить разность значений регистров $s1 и $s2 и записать результат в регистр $s0.

Теперь посмотрим, как они выглядят в машинном коде

На всякий случай еще раз вспомним, что в 32-битном процессоре на каждую команду отведено ровно 32 бита. Команды типа R-type разбиты на следующие поля по битам:
[ op (6 бит) ][ rs (5 бит) ][ rt (5 бит) ][ rd (5 бит) ][ shamt (5 бит) ][ funct (6 бит) ]

(проверяем, что в сумме получаем 32 бита: [посмотреть ответ]6+5+5+5+5+6=32).

op+funct=операция
op (opcode) = 0 для всех операций R-type
funct (function): для add=32, для sub=34
rs, rt, rd - операнды - адреса регистров ('r' везде от register)
rs, rt - источник1, источник2 (обозначение выбрано как s - source, а 't' просто идет в алфавите после 's')
rd - назначение (destination)
shamt - сдвиг (ammount of shift) = 0 для всех операций R-type

Перевод примеров в машинный код (адреса регистров смотрим в таблице выше):




Команды типа I-type (immediate type)

Команды типа I-type в отличие от команд типа R-type умеют работать с числовыми константами (immediates - надо понимать что-то типа "незамедлительные" значения, т.е. их не нужно ниоткуда получать), которые встроены прямо в код команды.

Из нашего списка к таковым относятся: lw, sw, addi, beq.

Команда lw (load word - загрузить слово) загружает значение из памяти данных в регистр.

синтаксис на ассемблере:
lw rt, imm (rs)

rt - адрес регистра-назначения
imm - константа - адрес загружаемого значения в памяти данных
rs - адрес регистра, содержащего значение сдвига для адреса загрузки

например:

lw $s0, 4 ($0)
сначала вычислит адрес загружаемого значения как значение imm + значение, содержащееся в регистре rs (4+0=4), затем считает значение по вычисленному адресу (4) из памяти данных и запишет его в регистр rt ($s0).

Команда sw (store word - сохранить слово) записывает (сохраняет) значение из регистра в память данных.

синтаксис на ассемблере:
sw rt, imm (rs)

rt - адрес регистра-источника
imm - константа - адрес сохранения значения в памяти данных
rs - адрес регистра, содержащего значение сдвига для адреса сохранения

например:

sw $s0, 4 ($0)
сначала вычислит адрес сохранения значения как значение imm + значение, содержащееся в регистре rs (4+0=4), затем считает значение из регистра rt ($s0) и сохранит его в память данных по вычисленному адресу (4).

Команда addi (add immediate - сложить с константой) складывает значение регистра с константой и записывает результат в регистр.

синтаксис на ассемблере:
addi rt, rs, imm

rt - адрес регистра-назначения
rs - адрес регистра, содержащего 1е складываемое значение
imm - константа - 2е складываемое значение

команда addi должна взять значения из регистра rs, вычислить сумму rs+imm и записать результат в регистр rt.

например:

addi $s0, $s1, 4
на практике должно быть выполнено как $s0=$s1+4 - вычислить сумму значения, хранящегося в регистре $s1 со значением константы 4, и записать результат в регистр $s0.

Команда beq (branch if equal - ответвиться, если равно) осуществляет условный переход - перевод счетчика программы в указанное место при выполнении определенного условия.

В обычной ситуации процессор выполняет программу строка за строкой. Этот механизм реализован при помощи счетчика программы (program counter - pc) - на каждый логический такт процессор выбирает из памяти инструкций команду, индекс которой хранит счетчик программы, после ее выполнения счетчик программы увеличивается на одну команду так, что на следующий такт процессор выберет для выполнения команду, которая в памяти инструкций следует за текущей, и так может продолжаться до тех пор, пока не закончится память инструкций.

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

При помощи команды условного перехода beq можно организовывать циклы и блоки проверки условий.

синтаксис на ассемблере:
beq rs, rt, imm

rs, rt - адреса регистров, значения которых сравнивает команда
imm - адрес перехода относительно адреса текущей инструкции (в ассемблерной программе задается при помощи специальных меток)

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

например:

label: addi $s0, $0, 4
beq $s0, $s1, label
сравнит значения регистров $s0 и $s1 и передаст управление инструкции, адрес которой в памяти инструкций равен адресу строки, помеченной меткой "label" (те в данном примере это одна строка выше), если значения равны; если значения не равны, то программа продолжит последовательное выполнение со следующей строчки.

Стоит обратить внимание на 2 вещи:

Замечание 1: Адрес инструкции - это индекс 1го байта инструкции в памяти инструкций - т.к. для 32-битной архитектуры каждая инструкция занимает ровно 4 байта (32 бита - слово), адреса перехода должны быть кратны 4м, другие адреса некорректны. Например 0 обозначает адрес 1й инструкции в программе, 4 - адрес 2й инструкции, 8 - адрес 3й инструкции и т.п. Значения адресов 1, 2, 3, 5, 6, 7 и т.п. указывают на середину инструкций, т.е. по сути не имеют смысла.

Замечание 2: Т.к. поле imm является частью 32-битной инструкции и само имеет длину 16 бит (для инструкции beq), длина прыжка ограничена максимальным значением, которое может уместиться в эти 16 бит (по 15 бит в каждую сторону - 16й бит на знак, определяющий направление перехода).

Теперь посмотрим, как они выглядят в машинном коде

Команды типа I-type разбиты на следующие поля по битам:
[ op (6 бит) ][ rs (5 бит) ][ rt (5 бит) ][ imm (16 бит) ]

6+5+5+16=32

op - код операции: для lw=35, для sw=43, для addi=8, для beq=4
rs, rt - адреса регистров-операндов - смысловые роли могут быть разные для разных команд
imm (immediate) - значение операнда-константы




Команды типа J-type (jump type)

Команда j (jump - прыжок) осуществляет безусловный переход - перевод счетчика программы в указанное место.

Принцип работы полностью аналогичен команде условного перехода beq, только переход осуществляется всегда и без всяких условий. Также диапазон перехода относительно текущего положения чуть больше, чем у команды beq, т.к. на адрес перехода внутри команды отведено 26 бит, а не 16.

синтаксис на ассемблере:
j addr

addr - адрес перехода относительно адреса текущей инструкции (в ассемблерной программе задается при помощи метки)

например:

label: addi $s0, $0, 4
j label
вернет управление на одну строку выше (в данном случае получим бесконечный цикл).

Машинный код

[ op (6 бит) ][ addr (26 бит) ]

6+26=32

op - код операции: для j=2
addr (address) - единственный операнд - константа - адрес перехода относительно текущего положения счетчика программы




Практическое упражнение - перевести простую программу на ассемблере в машинный код

Рассмотрим простую программу на ассемблере - загрузить из памяти 2 числа, сложить их и результат положить обратно в память.

lw $s0, 0 ($0)
lw $s1, 4 ($0)
add $s2, $s0, $s1
sw $s2, 8 ($0)
Она будет работать примерно таким образом:




Задание - перевести ассемблерное представление программы в машинный код.
[Смотреть ответ]

Ответ

lw $s0, 0 ($0)    # 100011 00000 10000 0000000000000000
lw $s1, 4 ($0)    # 100011 00000 10001 0000000000000100
add $s2, $s0, $s1 # 000000 10000 10001 10010 00000 100000
sw $s2, 8 ($0)    # 101011 00000 10010 0000000000001000

lw $s0, 0 ($0)
lw rt, imm (rs)
rt = $s0 = 16 = 10000
imm = 0 = 0000000000000000
rs = $0 = 00000
op = 35 = 100011
op(6) rs(5) rt(5) imm(16)
100011 00000 10000 0000000000000000
10001100 00010000 00000000 00000000 = 8c 10 00 00

lw $s1, 4 ($0)
lw rt, imm (rs)
rt = $s1 = 17 = 10001
imm = 4 = 0000000000000100
rs = $0 = 00000
op = 35 = 100011
op(6) rs(5) rt(5) imm(16)
100011 00000 10001 0000000000000100
10001100 00010001 00000000 00000100 = 8c 11 00 04

add $s2, $s0, $s1
add rd, rs, rt
rd = $s2 = 18 = 10010
rs = $s0 = 16 = 10000
rt = $s1 = 17 = 10001
op = 0 = 000000
shamt = 00000
funct = 32 = 100000
op(6) rs(5) rt(5) rd(5) shamt(5) funct(6)
000000 10000 10001 10010 00000 100000
00000010 00010001 10010000 00100000 = 02 11 90 20

sw $s2, 8 ($0)
sw rt, imm (rs)
rt = $s2 = 18 = 10010
imm = 8 = 0000000000001000
rs = $0 = 00000
op = 43 = 101011
op(6) rs(5) rt(5) imm(16)
101011 00000 10010 0000000000001000
10101100 00010010 00000000 00001000 = ac 12 00 08



Ввод-вывод

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

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

Т.е. например, чтобы установить значение семисегментного диодного дисплея, нам нужно записать 8-битное (7 бит на каждый сегмент + 1 точка) число в интерфейс вывода. Или чтобы считать данные при помощи совершенно нового потрясающего способа ввода данных - рычажкового, нам нужно считать (пусть будет) 4-битное (подключим 4 рычажка - по одному биту на каждый) число из интерфейса ввода.

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

Но по крайней мере для систем на основе процессоров MIPS было выбрано другое вполне элегантное и логичное решение - все внешние устройства проецируются на специально выделенную для этого область памяти данных - все адреса выше 0xffff0000 на самом деле ссылаются не на обычную память данных, куда можно сохранять и загружать значения, а на интерфейсы ввода-вывода внешних устройств. При этом запись и чтение данных из внешних источников осуществляется все теми же командами для работы с памятью lw (load word) и sw (store word).

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

Устройства подключаем последовательно - они будут спроецированы на 3 слова в памяти данных:

0xffff0000 - мог бы быть адресом для 7мисегментного дисплея.

И казалось бы все просто - следующая команда:

sw $s0, 0xffff0000 ($0)
могла бы отобразить текущее значение из регистра $s0 на дисплее, а точнее, использовать младшие 8 бит значения для включения/выключения соответствующих сегментов и точки на дисплее.

Однако вспоминаем разрядность поля immediate из предыдущего раздела для команды sw (i-type) и понимаем, что 32-битное значение 0xffff0000 в 16 бит не умещается. Правильное решение этой проблемы - использовать псевдо-команду li (load immediate) или ее аналог для работы с метками-адресами la (load address), которые позволяют загружать 32-битное значение в регистр одной строкой на ассемблере - компилятор в зависимости от длины аргумента преобразует ее в 1 или 3 машинные команды (если разрядность загружаемой константы больше 16ти бит, ее загрузку можно выполнить в 3 этапа - загрузить старшие биты в нижнюю область регистра, сдвинуть их наверх и загрузить младшие биты на свое место):

li $s1, 0xffff0000
sw $s0, 0 ($s1)
Но т.к. в рамках небольшой лабораторной работы мы помимо дополнительных команд сдвига пока не хотим реализовывать еще и компилятор, который будет транслировать псевдокоманду li в разные наборы машинных кодов в зависимости от контекста, немного облегчим себе жизнь так, чтобы обойтись уже перечисленными командами - просто сделаем так, что адреса устройств ввода-вывода будут умещаться в 16 бит, которые нам доступны сразу при работе с полем immediate - пусть для нашей реализации адреса проекций устройств ввода-вывода в памяти данных начинаются с адреса 0x0000f000 (в принципе, имеем полное право - на устройство самого процессора это не влияет, а разработчики контроллеров еще и не такие маппинги придумывают).

0x0000f000 - настоящий адрес для 7мисегментного дисплея.

sw $s0, 0x0000f000 ($0)
отобразит текущее значение из регистра $s0 на дисплее, а точнее, использует младшие 8 бит значения для включения/выключения соответствующих сегментов и точки на дисплее.

0x0000f004 - адрес для 4хрычажкового устройства ввода:

lw $s0, 0x0000f004 ($0)
считает текущее значение, выставленное рычажками через позиции вкл/выкл, в регистр $s0 - т.к. рычажка всего 4, получится задать значение от 1го до 16ти - старшие биты будут всегда забиты нулями.

0x0000f008 - адрес для однорычажковой кнопки Enter:

lw $s0, 0x0000f008 ($0)
считает текущее значение этого рычажка (позиция вкл/выкл - 1/0) в регистр $s0.

На этом по плану работ на реализацию архитектуры достаточно - далее реализация процессора на Verilog и запуск на ПЛИС >>

подсветка синтаксиса

плис, цифровая электроника для программистов, verilog, процессоры, mips

Previous post Next post
Up