В предыдущей главе было подробно и максимально детально разобран процесс считывания префикса, опкода, эффективного адреса и операндов. В этой главе поговорим и реализуем процедуры, которые будут необходимы в дальнейшем исполнении инструкции. Эти процедуры, которые будут реализованы в данной главе, являются "сердцем" всей системы, поскольку сами по себе инструкции и их выполнение – задача уже рутинная.
Обычно полный цикл инструкции заключается в следующем:
1 Чтение префикса (если есть)
2 Чтение опкода – обязательно
3 Разбор эффективного адреса (реализовано)
4 Чтение операндов (реализовано)
5 Исполнение инструкции – частично реализовано в качестве примера
6 Запись результатов в память или регистр (будет реализовано в данной главе)
В самом деле эти этапы вполне могут помещаться в вычислительный конвейер, если его начать делать, но, как все понимают, никто сейчас делать его точно не будет, по крайней мере, на данный момент. Возможно что в будущих главах или специальном дополнении.
Когда результат выполнения инструкции или же какой-то отдельной операции готов, то необходимо чтобы у нас была процедура, которая занимается тем, что записывает результаты. В качестве входящих данных у этой процедуры будут следующие регистры:
Байт modrm – именно на его основе сможем определить, куда и в какой регистр (или память) будут записаны результаты
Значения dir и size для того чтобы знать, какую часть modrm выбрать (MEM или REG) и какой будет размер операнда (8 или 16 бит)
Регистр wb содержит значение на запись
Если запись в память, то потребуется правильный адрес в сегменте sgn и ea. После чтения операндов эти регистры сохраняются, но могут быть изменены инструкцией, если будет необходимо.
Так что список аргументов для процедуры под названием WB будет весьма немалым и за этим необходимо будет строго следить, чтобы не упустить необходимых деталей.
§ Доработка инструкции
Для начала давайте доработаем инструкцию, которую начали делать в прошлой главе.
8'b00xxx0xx: case (m) // ### ALU-операции с операндами ModRM [3T+]
0: begin t <= MODRM; alu <= opcode[5:3]; end
1: begin t <= alu == CMP ? RUN : WB; wb <= ar; flags <= af; `TERMend
endcase
Касательно первого такта, тут все понятно. Записывается номер функции АЛУ, которую необходимо выполнить и передается управление процедуре MODRM, которая считывает необходимые операнды в регистры op1, op2.
Второй такт инструкции делает следующее:
Сравнивает, что если номер функции АЛУ был CMP (=7) то в этом случае остаемся в RUN, иначе выполняется переход к процедуре WB
Записывается результат выполнения операции из ar (выход АЛУ) в регистр wb. Достаточно лишь только этого, поскольку dir, size, modrm, sgn:ea были ранее уже установлены на предыдущих этапах
Обновляются флаги из выхода АЛУ af – результирующие флаги
Выполняется макрос TERM:
`define TERM {m, rep, over} <= 1'b0;
При помощи этого макроса нам не придется раз за разом писать обновления некоторых регистров в нулевое значение. Достаточно просто написать макрос, который является признаком окончания инструкции в том смысле, что он и очищает предыдущие префиксы, если они были, и переходит к нулевой строке инструкции (m=0).
Отмечу то что этот макрос не переводит t=RUN, потому что хоть инструкция и будет завершаться, но она необязательно переходит именно к стадии выполнения из-за той причины, когда мы вызываем процедуру WB, например, которая сначала запишет результаты в память или регистр и уже потом, по завершении, перейдет к считыванию новой инструкции.
Завершать инструкцию строго необходимо именно так, очисткой префиксов. Если бы этого не было, то ранее установленный префикс так бы и "тащился" от инструкции к инструкции, и это было бы ошибкой.
Если упомянули про номера функции к АЛУ, то следует их выписать в виде констант:
localparamADD = 0, OR = 1, ADC = 2, SBB = 3, AND = 4, SUB = 5, XOR = 6, CMP = 7;
localparamCF = 0, PF = 2, AF = 4, ZF = 6, SF = 7, TF = 8, IF = 9, DF = 10, OF = 11;
Выпишу сразу всё, включая номера позиции флагов в регистре флагов. Например, флаг SF занимает 7-й бит в регистре флагов, а флаг OF – 11-й бит. Про то как работают остальные функции, например SBB или SHR – об этом будет написано в следующих главах, а пока мы знаем только их номера.
В качестве заглушки поставим на выход АЛУ строгие нули.
wire [15:0] ar = 1'b0;
wire [11:0] af = 1'b0;
Сегодня реализовывать работу АЛУ не будем, но для того чтобы запустить схему, необходимо, чтобы эти провода были где-то и имели определенное значение.
§ Процедура WB
Переходим, наконец, к реализации второй по значимости процедуры после считывания операндов, а именно, записи результата. Для начала определимся с порядком действий. Когда мы попадаем в данную процедуру, в качестве основного аргумента, конечно, будет значение в wb, которое может быть как 8 битным (size=0) так и 16-битным (size=1). Но как определить, куда записывать?
Известно, что если dir=1, то это означает следующий порядок операндов: reg, reg/mem. Слева всегда будет регистровая часть, а она хранится всегда в битах [5:3] байта modrm, который был получен ранее
Также, если dir=0, то тогда порядок следования операндов будет такой: reg/mem, reg что означает, что в качестве операнда-назначения может быть либо регистр, либо же память. Тогда, чтобы точно знать, что там регистр, номер которого теперь в [2:0] битах байта modrm, мы знаем, что в MOD части (биты [7:6]) байта modrm должно быть значение 11
Это значит, что в зависимости от условия в dir и modrm возможно однозначно определить, писать ли результат в регистр, и какой именно по счёту. Реализуем первый такт процедуры WB.
case (t)
...
WB: case (m2)
// Записать в регистры, если это явно указано
0: if (dir || modrm[7:6] == 2'b11) begin
case (dir ? modrm[5:3] : modrm[2:0])
0: if (size) ax <= wb; else ax[ 7:0] <= wb[7:0];
1: if (size) cx <= wb; else cx[ 7:0] <= wb[7:0];
2: if (size) dx <= wb; else dx[ 7:0] <= wb[7:0];
3: if (size) bx <= wb; else bx[ 7:0] <= wb[7:0];
4: if (size) sp <= wb; else ax[15:8] <= wb[7:0];
5: if (size) bp <= wb; else cx[15:8] <= wb[7:0];
6: if (size) si <= wb; else dx[15:8] <= wb[7:0];
7: if (size) di <= wb; else bx[15:8] <= wb[7:0];
endcase
t <= next;
cp <= 0;
end
// Либо записать в память младший байт wb
elsebegin w <= 1; cp <= 1; m2 <= 1; o <= wb[7:0]; end
endcase// Завершение m2
...
endcase// Завершение t
Теперь разберем код. Как видим, было добавлена новая процедура WB, в которой в качестве номера строки выступает регистр m2, всегда начинающийся с 0.
В такте #0 данной процедуры есть ветвление:
В первом случае, когда выбрана REG-часть (это либо dir=1 либо dir=0 && mod=11), то запись производится в регистр, номер которого зависит от dir: если =1, то запись в номер регистра, который располагается в REG-части [5:3], или наоборот, в [2:0], когда dir=0
Если size=1, то запись происходит в 16-битный регистр (8 разных регистров), либо в 8-битный: 0=AL, 1=CL, 2=DL, 3=BL, 4=AH, 5=CH, 6=DH, 7=BH
После чего обнуляется указатель на память (cp=0) – на всякий случай, если он там ранее был и переходит к процедуре next. Она по умолчанию равна RUN, тем самым либо совершая переход к следующей инструкции, либо ее продолжая, либо вообще возвращаясь к другой процедуре, указанной как раз в регистре next. Здесь этот регистр означает номер процедуры, в которую надо вернуться
Во втором случае, если запись результата выполняется не в регистр, то тогда устанавливается сигнал записи в память w=1, выбирается сама память sgn:ea, если не выбрана, через установку cp=1, и в выходной пин o записывается младший байт результата. Тем самым подаем сигнал в память, которая должна будет обновить данные.
Это то, что происходит именно на первом такте. А вот #1 такт сделан весьма необычным способом.
1: begin
w <= size;
cp <= size;
m2 <= size;
t <= size ? WB : next;
ea <= ea + 1;
o <= wb[15:8];
size <= 0;
end
Здесь могут быть 2 варианта.
Когда size=0. В этом случае происходит обнуление регистра cp, w и m2 в том числе, чтобы предотвратить запись старшего байта из wb, но указатель эффективного адреса все равно смещается на +1, и, хотя в выводной пин o запись байта тоже происходит, это ни на что не влияет. Как и в прошлом такте t=next – совершается выход из процедуры
Но когда size=1, то устанавливается запись w=1, cp=1, а строка m2 так и остается равной 1, как и t=WB тоже остается в текущей процедуре, то есть, в следующий раз будет снова выполнена эта строка, но уже с size=0.
Это значит, что когда size=1, то сначала дополнительно дописывается старший байт wb в память, а уже потом, на следующем такте, завершается запись и происходит возврат из процедуры. Вот такая вот хитроумная конструкция!
На этот раз инструкцию реализовать удалось в почти полном объеме. Единственная деталь, немаловажная конечно, это вычисление самого значения АЛУ. Сейчас запись в память или регистр будет производиться, но лишь только нуль.
§ Работа со стеком
Еще две важные процедуры, которые рассмотрим. Стек – это некоторый указатель в память по адресу ss:sp, в котором по методу LIFO складываются временные значения – аргументы процедур, обратные адреса возврата, флаги и прочие данные. Отличие стека от других видов памяти в том что он работает, упрощенно говоря так: "первый зашел – последний вышел", или если представить себе груду тарелок: сначала мы кладем первую тарелку, и так накладываем их до верху, и когда начинаем извлекать тарелки, то та тарелка, которая была положена последней, будет взята именно первой. А первая положенная тарелка будет взята последней.
Этот принцип только с виду кажется нелогичным. На деле он совершенно необходим в работе программ. Когда программа вызывает очередную процедуру через инструкцию CALL, то она укладывает в стек значение адреса возврата. Стек увеличивается на +1 и если будет вызвана еще одна процедура внутри первой процедуры, то в стек укладывается еще одно значение адреса возврата и так далее. Когда придет время вернуться из процедуры, то тогда RET вернет значение ip не к тому значению первого вызова CALL, а именно к последнему вызову, и следующий RET – к предпоследнему и так до тех пор, пока не будет возвращен адрес первого вызова процедуры.
Необходимо реализовать две процедуры: PUSH – вставка нового значения в стек, и POP – извлечение. Вершина стека всегда находится по адресу ss:sp и сам стек всегда бывает либо 16-битным, либо 32-х битным. Когда он становится 32-х битным, зависит от размерности селектораss в защищенном режиме работы процессора. То есть, при вызове CALL в стек добавляется 32-битное число только если селектор 32 битный. Но это тонкости, которые сейчас не важны на данном этапе.
Код для реализации PUSH, который "заталкивает" 16-битное значение регистра wb довольно прост:
PUSH: case (m3)
0: begin cp <= 1; sp <= sp - 2; sgn <= ss;
m3 <= 1; ea <= sp - 2; w <= 1; o <= wb[ 7:0]; end
1: begin m3 <= 2; ea <= ea + 1; w <= 1; o <= wb[15:8]; end
2: begin m3 <= 0; cp <= 0; t <= next; sgn <= ds; end
endcase
Сама по себе процедура состоит из 3 тактов. Разберу их подробнее.
#0 Вначале устанавливается новый эффективный адрес, равный sgn=ss и ea=sp-2, и в то же время сразу же делается уменьшение самого регистра указателя стека на 2. Особенность в том, что при записи в стек, сначала процессор вычитает sp-2 и начинает записывать именно туда (установкой w=1 и o, равному младшему байту wb). Тем самым, на первом такте сразу же идет запись младшего байта в стек
#1 Передвигаем эффективный адрес на +1 (инкремент) и пишем уже старший байт регистра wb
#2 Остановка записи (т.к. w=0 по умолчанию), переход к next процедуре и самое главное, занесение в регистр sgn значения по умолчанию ds
Очень важно именно записать в sgn значение ds, поскольку после того как будет выполнена инструкция, в этом регистре будет значение сегмента ss, что может поломать работу некоторых, например, строковых инструкции или тех, которые зависят от сегмента sgn уже на первом такте. Таких инструкции немного, но они есть и это надо учитывать. Да и вообще это как минимум хороший тон, подчищать хвосты после очередной инструкции.
Реализация процедуры POP происходит примерно похожим образом. Только вместо записи wb, из памяти регистр этот читается.
POP: case (m3)
0: begin m3 <= 1; cp <= 1; ea <= sp; sp <= sp + 2; sgn <= ss; end
1: begin m3 <= 2; wb <= i; ea <= ea + 1; end
2: begin m3 <= 0; wb[15:8] <= i; cp <= 0; t <= next; sgn <= ds; end
endcase
И как и в прошлом случае, разберем по тактам.
#0 Установка указателя эффективного адреса, увеличение sp на +2 – имитация "выталкивания" из стека". Как и в прошлый раз, в sgn ставится ss, что, кстати, удаляет предыдущую информацию о том что там было, потому в некоторых инструкциях придется сохранять предыдущее значение sgn чтобы не потерять информацию об этом. Могу обратить внимание, что в этот раз ставится именно sp в качестве эффективного адреса, а не sp+2 как можно было подумать. Это связано с тем, что данные будут извлекаться с вершины стека.
#1 Читается младший байт из памяти (из стека)
#2 Читается старший байт из стека, и процедура завершается: устанавливается t=next, в том числе и sgn, как и в прошлой процедуре PUSH.
Данные две процедуры, реализованные сегодня в этой главе очень активно участвуют в выполнении инструкции, которые как-то связаны с работой со стеком. Это и PUSH, POP, это и CALL, RET, INT и другие.
§ Вызов прерывания
Последняя процедура, которую разберем сегодня, это будет процедура прерывания. Она не последняя из общего списка, поскольку есть еще DIV (деление) и даже UNDEF, но процедура прерывания как раз использует вызов подпрограммы (PUSH).
Прерывания могут вызываться разными способами.
Софтовый – то есть мы намеренно вызываем прерывание interrupt от 0 до 255 через специализированные инструкции вроде INT
Исключение – когда при выполнении инструкции возникло некоторое исключение или ошибка, к примеру, деление на 0 или выход за пределы массива в BOUND
Прерывание – в случае если процессор получил внешнее прерывание из контроллера прерываний, и если установлен флаг IF=1, и если выполняется начало инструкции, то производится переход к вызову прерывания
Сама по себе она выполняет следующие действия:
Вставляет в стек значение cs, ip и значение flags, чтобы сохранить их в стеке сразу, а не потом в самом обработчике прерываний, что хорошо экономит итоговое время в тактах, да и просто, это нужно из-за других причин даже: для того чтобы не случилось так, что когда восстановили значение флагов, в тот момент не произошло еще одно аппаратное прерывание, а так как обработчик прерываний так и не завершен, то это может привести к сбою программы. Специальная для этого инструкция IRET как раз и предотвращает появление таких ошибок, потому что восстанавливает сразу же флаги как атомарная операция.
После того как были записаны необходимый адрес возврата и флаги, вычисляется адрес таблицы прерываний (она называется IVT – Interrupt Vector Table) и, поскольку мы в 16-битном Real Mode, то адрес этой таблицы всегда лежит в физическом адресе 0
Извлекает новый адрес и переходит по нему, устанавливая флаг IF=0 чтобы предотвратить повторные аппаратные прерывания.
Теперь сам код процедуры.
INTERRUPT: case (m4)
0: begin m4 <= 1; t <= PUSH; wb <= {4'hF, flags}; next <= INTERRUPT; end
1: begin m4 <= 2; t <= PUSH; wb <= cs; end
2: begin m4 <= 3; t <= PUSH; wb <= ip; end
3: begin m4 <= 4; ea <= {interrupt, 2'b00}; sgn <= 0; cp <= 1; end
4: begin m4 <= 5; ip[ 7:0] <= i; ea <= ea + 1; end
5: begin m4 <= 6; ip[15:8] <= i; ea <= ea + 1; end
6: begin m4 <= 7; cs[ 7:0] <= i; ea <= ea + 1; end
7: begin m4 <= 0; cs[15:8] <= i; cp <= 0; t <= RUN; flags[IF] <= 1'b0; sgn <= ds; end
endcase
Это одна из самых длинных процедур. Как видно, делаются 3 вызова подпрограммы PUSH и на каждый из них тратится 3 такта (+1 такт самого вызова), и по итогу сама по себе процедура вызова прерывания составляет 17 тактов! Хотя для такой процедуры – это не настолько уж и много, если так подумать.
#0 Вызов процедуры PUSH с аргументом wb, равным значению флагов плюс 4 единичных бита и установлением адреса возврата next на текущую процедуру. Так как регистр flags всего лишь 12 битный, то необходимо заполнить старшие 4 бита и они всегда заполняются единицами.
#1, #2 Запись в стек последовательно cs и ip. То есть, после того как прерывание будет вызвано, на вершине стека будет лежать именно ip. Это можно использовать для определенных целей трассировки, допустим, при включенном флаге TF=1, который вызывает INT#1 после каждой выполненной инструкции.
#3 Вычисление адреса в таблице прерываний, откуда уже далее будет считываться адрес обработчика. Ставим sgn=0 и через cp=1 даем сработать адресному мультиплексору, который направит указатель на sgn:ea или даже так 0:interrupt*4.
#4-7 Вначале читается 16-битный ip, а потом новое значение cs. Другими словами, IVT представляет из себя таблицу размером 256 элементов по 4 байта каждый, который является адресом, и потому такая таблица в памяти занимает 1024 байт для 16-битного режима работы процессора. Конечно, в 32-битном режиме эта таблица увеличивается вдвое, и ее стартовый адрес уже можно задавать отдельной командой. Хотя и в 16-битном режиме можно эту команду (называется LIDT) вызывать, на самом деле.
На последнем такте инструкции #7 как и обычно, происходит стандартная процедура возврата к началу выполнения следующей инструкции, по аналогии с ранее разобранными. Единственное отличие заключается только в том, что ставится флаг IF=0. Как я и писал выше, это нужно для предотвращения повторного вызова во время исполнения обработчика прерываний.
§ Временная диаграмма
В отличии от предыдущей главы, теперь можно увидеть полный цикл обработки одной достаточно длинной инструкции.
На графике видно, что в конце происходит перезапись результатов (wb=0000h) в область памяти. И хотя это пока что неправильная работа инструкции, поскольку она еще не до конца реализована сейчас, самое важное, что принцип работы здесь верный именно с точки зрения самой логики процедуры обратной записи.