Это одна из самых сложных вещей, которая касается процессора. Именно здесь больше всего происходит путаницы и страданий при попытке сделать декодер, поскольку сообщество давно привыкло к такому процессору как RISC-V при реализации, а вот CISC кажется сложным. Но не сегодня. Мы же знаем, что делаем учебный процессор на основе самых простейших и базовых принципов реализации, так что сделать декодер будет легче простого. Как по мне, реализовать процессорный вычислительный конвейер в десятки раз сложнее!
У меня уже есть две статьи на сайте в папке "Проекты", где я делал декодер не только для 8086, но и даже для 386 процессора. В отличии от тех статей, в этот раз я хочу разобрать всё настолько детально, насколько это будет возможно.
Первое что могу сказать, что вся логика будет исполняться в t=MODRM, а номер линии будет записан в регистр m1:
Строка #0 – основной такт
Читается байт modrm из памяти в регистр (кеш)
Считывается значение регистра из поля REG
Также считывается значение регистра из поля MEM: если выбран MOD=11
Вычисляется эффективный адрес
Вычисляется сегмент (DS: или SS:) либо же используется тот, который был выбран префиксом
Инкремент IP + 1
И все это делается за один такт.
Строка #1 – считывание disp8
Дочитывание 8-битного смещения
Сложение с эффективным адресом
Инкремент IP + 1
Строка #2, #3 – считывание disp16
Дочитывание 16-битного смещение
Сложение с эффективным адресом
Инкремент IP + 2
Строка #4, #5 – считывание операнда из памяти
Считывание операнда 8/16 бит из памяти
Выход из процедуры MODRM к t=RUN
Как видим, при задании 16-битных операндов и 16-битном смещении выполнение процедуры может занять до 6 тактов процессора. Это еще без учета такта, который считывает операнд, а также тактов, которые будут потрачены на запись результатов в память или регистр. Так что скорость исполнения инструкции может быть невысокой, если инструкция достаточно сложная.
§ Управляющие регистры
Перед тем как попасть в стадию считывания операндов, вначале необходимо прочитать префиксы и код операции. Префикс, в некотором роде считается кодом операции, но с тем отличием что при его исполнении он лишь только устанавливает значения в некоторые внутренние регистры, не завершая инструкцию в целом.
Когда инструкция завершается, то на последнем ее такте сбрасываются все управляющие потоком регистры, такие как m и префиксы для того чтобы новая инструкция не "тащила" за собой ранее установленный префикс от предыдущей.
Сделаем объявления всех управляющих регистров и разберем каждый из них подробно.
reg cp; // =1 Указатель на SGN:EA =0 Иначе CS:IP
reg cpm; // =0 Устанавливается cp после MODRM
reg size; // =1 16bit =0 8bit
reg dir; // =0 rm,r; =1 r,rm
reg cpen; // =0 То пропускает чтение операндов
reg over; // =1 Сегмент переопределен
reg intrc; // Предыдущее значение intr
reg [ 1:0] rep; // Наличие REP:
reg [ 3:0] t, next; // Исполняемая команда (t) в данный момент
reg [ 3:0] m, m1; // Фаза исполнения T (m), MODRM и субфаза
reg [ 2:0] m2, m3, m4; // Фаза исполнения WB, PUSH/POP
В этой статье я разместил все необходимые управляющие регистры для всего процессора в целом и на будущее, так что некоторые регистры тут будут пока не нужны, но потребуются в дальнейшем.
cp – регистр, который устанавливает источник, откуда будет взят текущий адрес. Если он равен =0 то адрес будет рассчитываться исходя из cs:ip, иначе, если =1, то из sgn:ea, где sgn – сегмент, а ea – эффективный адрес. Это два важных регистра, в которых как раз и будет указатель на память, откуда будем извлекать 8 или 16 битные операнды и не только они, а в целом он позволяет работать с памятью произвольно.
cpm – данный регистр указывает, что после того как исполнится разбор операндов, считывая байт modrm, какое значение будет установлено для регистра cp. По умолчанию cpm=1, но бывают разные вариации инструкции, которые требуют установки cp=0 после считывания операндов из-за, к примеру, того, чтобы прочитать непосредственное значение, идущее в конце инструкции.
size – указатель размера рабочего операнда, если =0 то это 8-битный операнд, если =1 то 16-битный. По умолчанию копируется бит 0 из опкода.
dir – направление операндов, по умолчанию бит 1 из опкода. Указывает на то, с какой стороны будет операнд, который отвечает за чтение из памяти или из регистра (MEM-часть из регистра modrm). Если =0, то слева, иначе справа.
cpen – в некоторых ситуациях чтение операндов из памяти не нужно, это к примеру, когда из регистра происходит запись в память. При установленном cpen=1 чтение из памяти операнда пропускается, вычисляется только эффективный адрес.
over – при установленном значении сигнализирует что был использован сегментный префикс, это значение будет необходимо для того чтобы при чтении операндов, при использовании регистра BP в эффективном адресе не брался сегментный регистр SS: в качестве базы.
intrc понадобится при реализации аппаратных прерываний, записывается последнее значение на входе intr.
rep при ненулевом значении означает что был запрошен префикс =10 REPNZ =11 REPZ.
t номер текущей исполняемой процедуры для инструкции. В данной главе мы рассмотрим только две процедуры RUN и MODRM.
next это адрес возврата в предыдущую процедуру. Эта ситуация возникает когда, к примеру, в процедуре INTERRUPT исполняется три раза процедура PUSH и потому чтобы после того как процедура PUSH была исполнена, то управление передавалось не t=RUN, а тому, который вызвал PUSH. Эту тему будем рассматривать в следующих главах при реализации инструкции.
m, m1, m2, m3, m4 – фазы исполнения отдельных процедур. В данной главе рассматриваем только две фазы m (RUN) и m1 (MODRM). Остальные три предназначены для процедур таких как WB (m2), PUSH/POP (m3), INTERRUPT (m4).
alu номер арифметико-логической операции от 0 до 7, включая кстати, не только базовые операции, но инструкции сдвига тоже.
modrm, opcache – сохраненные в кеш опкод и байт modrm.
interrupt – номер либо аппаратного, либо вызываемого пользователем прерывания для подачи на вход процедуры INTERRUPT.
sgn, ea – эффективный адрес и опорный сегмент для него.
op1, op2 – прочитанные операнды при помощи t=MODRM. Это как раз то, что сегодня будет разобрано. После исполнения стадии считывания операндов в эти два регистра как раз и записываются значения, а сами операнды подключены ко входу АЛУ.
wb – результат для записи в регистр или в память. После того как будет вычислено новое значение, его можно будет записать в определенный регистр или обратно в память или регистр (операнд-назначение) в соответствии как раз с сохраненным ранее в кеш кодом modrm.
t16 – временный 16-битный регистр для некоторых операции.
Как видно, количество временных регистров достаточно обширное, но не так велико, как это можно было себе представить. Большинство их них будут использоваться для чтения операндов. Для начала стоит исправить получение адреса в памяти:
assign a = cp ? sgn*16 + ea : cs*16 + ip;
И теперь информация будет читаться либо из памяти программ (cp=0) либо из памяти данных (cp=1). Но память данных и программ находятся в одном адресном пространстве, так что здесь это можно говорить скорее о выборе источника данных как такового. Для того чтобы читать операнды, требуется эффективный адрес, а использовать связку cs:ip мы можем только для чтения инструкции.
§ Чтение префиксов и опкода
До того как процессор дойдет до разбора и чтения операндов, он должен будет как минимум определить код операции. В предыдущей статье есть лишь минимальные сведения о том, как записать код операции в opcache, но сейчас я его дополню.
Скажем так, все действия выполняются в конструкции if (ce) begin /* вот тут */ end. Поскольку начало инструкции, которое ранее обозначили на проводе m0 теперь может быть не только началом реальной инструкции, но еще и префиксированной инструкции, то следует ввести еще один провод:
wire m0 = (t == RUN && m == 0);
wire c0 = m0 && {over, rep} == 3'b0;
Это означает следующее:
m0 равен 1 тогда, когда мы a) находимся в процедуре исполнения опкода и b) находимся в стадии считывания опкода (или префикса) во временные регистры
c0 равен 1 еще и тогда, когда префиксов до опкода нет. Эта ситуация когда инструкция считывается с самого начала, то есть когда cs:ip установлен не на опкод, а на префикс или опкод. Если в случае m0 он может быть равен и когда установлен программный счетчик на префикс, и когда на опкод, то в случае c0 программный счетчик установлен исключительно на начало инструкции вместе с возможными префиксами. Это крайне важно.
В соответствии с заведенными новыми регистрами первый такт для считывания новой инструкции или префикса теперь будет дополнен.
if (m0) begin
opcache <= i; // Записать опкод в кеш
next <= RUN; // По умолчанию возврат на RUN
ip <= ip + 1; // Инкремент программного счетчика
cpm <= 1; // По умолчанию ставить cp=1 после modrm
cpen <= 1; // Читать операнды из памяти из modrm
dir <= i[1]; // Направление операндов по умолчанию
size <= i[0]; // Битность 8/16 по умолчанию
m <= 1; // К следующей "строке" процедуры RUN
m1 <= 0; // Очистка m1, m2, m3, m4
m2 <= 0;
m3 <= 0;
m4 <= 0;
if (c0) sgn <= ds; // В начале инструкции sgn по умолчанию равен DS
end
Можно отметить то, что в начале инструкции мы видим что ни over ни rep не очищаются, поскольку это должно происходить исключительно только в конце исполнения каждой инструкции, иначе бы каждый раз терялись установленные префиксы, считанные до опкода.
Обозначим одну ветвь RUN для исполнения микро-операции.
case (t)
// ===============
RUN: casex (opcode)
// Выполнение опкодов или группы
[маска опкода]: case (m)
0: begin ... end
1: begin ... end
...
endcase
endcase
// ===============
endcase
Данная конструкция означает следующее:
Выбирает процедуру t (в данном случае RUN), с которой работаем.
Выбирает маску опкода, к примеру 8'b00_xxx_0xx, которая означает работу с АЛУ на 8 функции и 4 варианта расположения операнда.
И выбирает номер исполняемой строки m в процедуре.
Далее при детальной реализации каждой инструкции я буду расписывать их примерно в таком виде:
8'b001xx110: case (m) // ### Префикс ES/CS/SS/DS [1T]
0: begin
m <= 0; // Продолжим считывать префиксы или опкод
over <= 1; // Установим отметку, что был сегментный префикс
// Выбираем новый сегмент
case (opcode[4:3])
0: sgn <= es;
1: sgn <= cs;
2: sgn <= ss;
3: sgn <= ds;
endcase
end
endcase
Сама по себе инструкция расписана очень детально, из пояснений разве что могу сказать что в отличии от других реализации, в опкодах, которые означают префикс, не нужно завершать инструкцию, достаточно лишь указать что m=0. Для светлого будущего добавлю на всякий случай также обработку префикса REPNZ/REPZ:
8'b1111001x: case (m) // ### REPNZ, REPZ [1T]
0: begin m <= 0; rep <= opcode[1:0]; end
endcase
При указании префикса записывается 2 бита в регистр rep. Старший бит всегда равен 1, поскольку opcode[1]=1 по условию, а младший показывает тип префикса 0=REPNZ, 1=REPZ. Декодирования операндов это никак не касается, поскольку префикс REPx управляет строковыми инструкциями, но на всякий случай он был добавлен в этой главе.
§ Считывание операндов-регистров
Рассмотрим самый первый такт инструкции АЛУ для примера.
8'b00xxx0xx: case (m) // ### ALU-операции с операндами ModRM [3T+]
0: begin t <= MODRM; alu <= opcode[5:3]; end
endcase
На первом же такте из процедуры RUN передается управление процедуре MODRM, одновременно записывая в регистр alu номер функции АЛУ, которая будет впоследствии использована (в следующей главе подробнее об этом). Не стоит забывать то что ранее на том же такте #0 будет установлено значение m=1, так что когда процедура MODRM обратно передаст управление к RUN (возврат), то выполнение инструкции продолжится на такте #1 – то есть, выполнение будет именно продолжено. Это аналогично вызову процедуры из основной программы.
Кстати, в квадратных скобках буду указывать либо точное количество тактов, которые требуются на инструкцию, либо минимальное количество тактов, которое необходимо для исполнения инструкции. Например, инструкция АЛУ, описанная выше, может быть выполнена не менее чем за 3Т:
#1 сохранение опкода и вызов процедуры MODRM
#2 чтение операндов-регистров
#3 запись результата во флаги и завершение, если это CMP инструкция
В случае если нужно будет записать результат в регистр, то потребуется еще 1 такт для этой цели – вызов процедуры WB.
Если мы получим байт modrm на вход, то гарантированно получим из него номер REG-части (биты 5..3), откуда необходимо будет извлечь значение указанного регистра. Однако, не стоит забывать о том моменте, что помимо REG-части еще есть и MEM-часть (биты 2..0), которая также может выступать как регистр, если задан MOD=11, так что придется подготовить два очень больших мультиплексора i20 и i53.
I. Мультиплексор на выбор регистров из диапазона [2:0]:
wire [15:0] i20 =
i[2:0] == 3'h0 ? (size ? ax : ax[ 7:0]) :
i[2:0] == 3'h1 ? (size ? cx : cx[ 7:0]) :
i[2:0] == 3'h2 ? (size ? dx : dx[ 7:0]) :
i[2:0] == 3'h3 ? (size ? bx : bx[ 7:0]) :
i[2:0] == 3'h4 ? (size ? sp : ax[15:8]) :
i[2:0] == 3'h5 ? (size ? bp : cx[15:8]) :
i[2:0] == 3'h6 ? (size ? si : dx[15:8]) :
(size ? di : bx[15:8]);
II. Мультиплексор на выбор регистров из диапазона [5:3]
wire [15:0] i53 =
i[5:3] == 3'h0 ? (size ? ax : ax[ 7:0]) :
i[5:3] == 3'h1 ? (size ? cx : cx[ 7:0]) :
i[5:3] == 3'h2 ? (size ? dx : dx[ 7:0]) :
i[5:3] == 3'h3 ? (size ? bx : bx[ 7:0]) :
i[5:3] == 3'h4 ? (size ? sp : ax[15:8]) :
i[5:3] == 3'h5 ? (size ? bp : cx[15:8]) :
i[5:3] == 3'h6 ? (size ? si : dx[15:8]) :
(size ? di : bx[15:8]);
Данные два мультиплексора в схеме занимают немалое количество логических элементов, учитывая также еще тот факт, что выбирать приходится не только регистры на 16 бит, но и 8 регистров по 8 бит, основываясь на указанной ранее размерности операнда size. Хотя если подумать о том, какие огромные мультиплексоры предстоит реализовать в процедуре RUN, то эти два уже не кажутся такими огромными.
С учетом уже выше сказанного, создаем новую процедуру:
MODRM: case (m1)
0: begin
modrm <= i;
ip <= ip + 1;
op1 <= dir ? i53 : i20;
op2 <= dir ? i20 : i53;
// Здесь вычислим эффективный адрес
// Определим, куда перейти дальше
// Выбираем sgn=ss если необходимо
end
endcase
Пока что это лишь заготовка кода для 0-й строки процедуры MODRM, в которой происходит следующее:
Сохраняется modrm значение с шины данных на будущее использование
Инкрементируется программный счетчик ip, так как мы прочли очередной байт
Выбирается операнд-назначение op1: если dir=1, то в этом случае, значение слева берется из REG-части modrm, иначе из MEM-части
Выбирается операнд-источник op2: аналогично, но при dir=1 выбирается уже из MEM-части, иначе из REG-части
Тем самым, если dir=0, то у реализуется порядок следования операндов reg/mem, reg, и при dir=1, наоборот, будет reg, reg/mem.
По сути, если бы нам не надо было бы читать операнд из памяти, если он там не указан, то этого было бы достаточно для того чтобы вернуться к исполнению процедуры RUN и так оно и будет при условии MOD=11. Но так бывает далеко не всегда, так что придется вычислить и эффективный адрес, и смещение, если оно нужно, и прочесть операнд из памяти.
§ Считывание эффективного адреса
Зачастую, операнд бывает в памяти, а не в регистрах. Для того чтобы его извлечь, вначале требуется вычислить эффективный адрес, который идет вкупе с сегментными регистрами (по умолчанию DS). Для его расчета в 16-битном режиме используется определенная заранее заданная таблица, но я приведу вычисление этого адреса теперь в виде кода.
case (i[2:0])
3'b000: ea <= bx + si;
3'b001: ea <= bx + di;
3'b010: ea <= bp + si;
3'b011: ea <= bp + di;
3'b100: ea <= si;
3'b101: ea <= di;
3'b110: ea <= i[7:6] ? bp : 0;
3'b111: ea <= bx;
endcase
Этот код в целом достаточно просто понять, за исключением разве что только случая 3'b110. Дело в том что i[7:6] это и есть MOD часть, а i[2:0] отвечает за MEM часть байта modrm, и ранее было сказано что если mod=00 и mem=110, то вместо эффективного адреса, заданного косвенно регистрами или суммой регистров берется 16-битный адрес в памяти.
Но почему тут тогда в ea записывается 0? Это связано с тем, что когда процессор берет 16-битный адрес, то в данной ситуации, он еще дочитает 2 байта смещения в обычном режиме, как бы если был задан mod=10. В такой ситуации будет принудительно добавлено 16-битное смещение.
Теперь мы как раз и приходим к необходимости решить все случаи, что делать в том или ином значении mod и байта modrm, потому добавляется еще код, который занимается этой задачей.
casex (i)
8'b00_xxx_110: begin m1 <= 2; end// OFFSET16
8'b00_xxx_xxx: begin m1 <= 4; `CPEN; end// Читать операнд
8'b01_xxx_xxx: begin m1 <= 1; end// +Смещение 8 бит
8'b10_xxx_xxx: begin m1 <= 2; end// +Смещение 16 бит
8'b11_xxx_xxx: begin m1 <= 0; t <= RUN; end// Регистры. Вернуться к RUN
endcase
В коде использован макрос CPEN:
`define CPEN cp <= cpen; if (!cpen) begin m1 <= 0; t <= RUN; end
Согласно этому макросу, если ранее где-то указано cpen=0, то есть, не читать операнды и тогда после вычисления эффективного адреса и сегмента процессор вернется из процедуры назад в RUN. В данной ситуации эффективный адрес вычисляется без необходимости дочитать какое-либо смещение (ни 8, ни 16 битное).
В ситуации с OFFSET16 и +Смещение 16 бит переход осуществляется на ту же самую строку процедуры (m=2).
Если указан mod=11, то операнды прочитаны, так как эти операнды являются регистрами и переходит обратно к RUN.
И последний момент, который завершает логику работы #0 такта, будет выбор сегмента ss: в тех случаях, когда используется регистр bp в вычислении эффективного адреса.
Для любого mod при mem=010 или 011 (bp как компонент суммы регистров)
Но если есть over=1, то сегмент SS не выбирается, поскольку префикс имеет больший приоритет.
Итак, когда был завершен первый такт, то, в зависимости от разного типа операндов и эффективного адреса, может быть направлен ход исполнения инструкции в разные процедуры – либо продолжен считывания, в том числе, смещения к эффективному адресу, либо сразу выход к RUN, либо переход к чтению операндов.
Рассмотрим чтение смещений в процедуре MODRM:
1: begin m1 <= 4; ip <= ip + 1; ea <= ea + sign; `CPEN; end
2: begin m1 <= 3; ip <= ip + 1; ea <= ea + {8'h00, i}; end
3: begin m1 <= 4; ip <= ip + 1; ea <= ea + {i, 8'h00}; `CPEN; end
Здесь добавился еще один провод sign, который расширяет знак входящего числа с шины данных с 8 до 16 бит путем копирования бита 7 в старшие биты 8..15:
wire [15:0] sign = {{8{i[7]}}, i};
Из кода выше видно, что m1=1 считывает 8-битное смещение, добавляет его к эффективному адресу, сдвигает на +1 программный счетчик и в зависимости от макроса `CPEN либо выходит к процедуре RUN, либо переходит к считыванию операнда из памяти.
Для m1=2,3 последовательно считывается сначала младший байт смещения, потом старший, прибавляясь к эффективному адресу, и как и в предыдущем случае, переходит либо чтению операнда, либо к исполнению RUN.
§ Считывание операндов
Наконец, после того как был прочитан эффективный адрес, указатель в памяти на него установлен, пришло время прочитать уже сами операнды из памяти. Когда процессор приходит в точку m1=4, то он уже должен переключить cp=1, а sgn:ea должны быть настроены на правильно вычисленный адрес для того чтобы как минимум прочитать 8-битное число либо во временный регистр op1 (операнд-назначение, слева) либо в op2 (операнд-источник, справа).
Чтение операнда из памяти не такая сложная задача. Рассмотрим получение младшего байта.
4: begin
if (dir) op2 <= i; else op1 <= i;
if (size) begin m1 <= 5; ea <= ea + 1; end
elsebegin m1 <= 0; cp <= cpm; t <= RUN; end
end
В зависимости от dir запись идет либо в op2 (если dir=1) либо в op1. Это местонахождение MEM-части.
Далее распределяется то, надо ли дочитывать еще байт. Если надо, то это только в случае size=1, и в этой ситуации эффективный адрес сдвигается на +1 и переход к строке m1=5, иначе очищается m1, устанавливается cp (обычно там 1) и переход к RUN
5: begin
if (dir) op2[15:8] <= i; else op1[15:8] <= i;
t <= RUN;
m1 <= 0;
ea <= ea - 1;
cp <= cpm;
end
Все аналогично предыдущей строке исполнения, но отличие в том, что после выхода из процедуры указатель эффективного адреса ea обратно устанавливается на верную позицию, поскольку это будет необходимо при записи результата вычислений обратно в память, но об этом уже в следующей главе.
Конечно, нельзя сказать что разработка декодера операндов – простая задача, но и не настолько сложная, если понимать принципы работы базового, 16 битного декодера, хотя и в 32-битном тоже вполне можно разобраться.
§ Диаграмма исполнения
Дана инструкция: 36 01 06 06 00 и данные 90 EF BE, которые идут после нее. Диаграмма выполнения инструкции будет следующей:
На схеме после исполнения считывания операндов инструкция "обрывается" на полуслове (m=1,m1=0,t=RUN), потому что она не была завершена до конца нужным образом, но этот момент оставляем на будущие главы.