Оглавление
§ Описание флагов
Что такое компьютер? Само по себе слово "компьютер" произошло от слова "compute" – вычислять, а значит, основная задача компьютера является вычисление, и, на самом деле, так оно и есть. Основная нагрузка происходит как раз на АЛУ – арифметико-логическое устройство.
Любые, даже самые сложные, операции, вроде нахождения косинуса или арктангенса, можно разбить на серию более мелких, сведя вообще все операции вычисления к сложению. Да, это слишком примитивно, но вместе с тем, основными элементарными операциями в x86 являются как раз 8 функции:
ADD, ADC – сложение двух операндов без переноса и с учетом переноса
SUB, SBB – вычитание одного из другого с переносом (sbb) и без переноса (sub)
CMP – аналогично SUB, но результат вычисления не сохраняется, только обновляются флаги
XOR – логическое битовое исключающее или
AND – логическое битовое "и"
OR – логическое битовое "или"
Три из восьми – это логические инструкции, пять оставшихся – арифметика. Я упомянул о флагах. В процессорах, которые используют флаги, каждый флаг что-то обозначает. К процессоре x86 флагов очень много, но в конкретном разрабатываемом мной процессоре их будет всего лишь 9:
11 10 9 8 | 7 6 5 4 3 2 1 0
O D I T | S Z - A - P - C
В регистрах 5 и 3 находятся нули, вероятно, в силу исторических причин, поскольку флаги расположены почти что идентично тем, что в z80 (хотя я в этом не уверен, что из-за этого), а в бите 1 регистра флага всегда установлена 1.
Что означают каждый флаг?
C – carry, флаг, который устанавливается в 1, когда результат предыдущей инструкции превысил максимальное значение. К примеру, если был добавлен 255+1 в 8-битном регистре, что дает результат 0, но флаг C=1 говорит о том, что только что был перенос
P – parity, флаг четности, который устанавливается в случае, если младшие 8 бит результата – четные, или количество единиц – четно, флаг этот весьма устаревший и почти никогда нигде не применяется и нужен лишь для обеспечения совместимости с возможным старым ПО. Говорят, что он был нужен для приема данных с внешнего мира, считать биты четности. Но это давно уже все устарело
A – aux, полуперенос из разряда 3 в разряд 4, аналогично предыдущему флагу P, он устарел, применяясь только для инструкции коррекции DAA, DAS, AAA, AAS. Применялся для двоично-десятичной логики, но сейчас это стало настолько неактуально, что сами инструкции, которые работают с этим флагом, вообще удалили из x86-64
Z – zero, ставится в 1, если результат выполнения АЛУ был нуль
S – sign, всегда копируется из старшего бита результата, 7-го, 15-го, 31-го или 63-го, в зависимости от того, с какой разрядностью была исполнена инструкция
T – trap, если установлен в 1, то после исполнения каждой инструкции вызывается int 1, нужен был раньше для отладчиков, сейчас уже устарел
I – interrupt, при установленном =1, разрешается вызов прерываний во время исполнения кода
D – direction, нужен для работы строковых инструкции, если D=1, то строка идет вниз, иначе вверх, то есть по инкременту
O – overflow, [[/cpu/flag_overflow самый сложный флаг]] из всех, который ставится в 1, если произошло знаковое переполнение, к примеру, если вычесть из -128 единицу (в 8-битном числе), то должно получиться -129, но на самом деле получится +127. Поскольку получится неправильный результат со знаками, говорят, что произошло знаковое переполнение.
§ Разработка АЛУ
Приступаем к созданию кода на верилоге. Считаю, что будет нелишним сначала объявить именованные константы для положения флагов в регистре флагов:
localparam
CF = 0, PF = 2, AF = 4, ZF = 6, SF = 7,
TF = 8, IF = 9, DF = 10, OF = 11;
Например, CF находится в позиции 0, OF – в 11. В этой статье константы не понадобятся, но пускай будут объявлены. А теперь нужно объявить константы номеров функции АЛУ:
localparam
alu_add = 3'h0, alu_or = 3'h1,
alu_adc = 3'h2, alu_sbb = 3'h3,
alu_and = 3'h4, alu_sub = 3'h5,
alu_xor = 3'h6, alu_cmp = 3'h7;
Именно в таком порядке и никаком ином находятся номера функции АЛУ. Это касается не только положения в опкодах, но и специальных случаях с участием байта modrm.
Объявления сделаны, а теперь и сами вычисления:
wire [16:0] alu_r =
alu == alu_add ? op1 + op2 :
alu == alu_or ? op1 | op2 :
alu == alu_adc ? op1 + op2 + flags[CF] :
alu == alu_sbb ? op1 - op2 - flags[CF] :
alu == alu_and ? op1 & op2:
alu == alu_xor ? op1 ^ op2:
op1 - op2;
Здесь реализован мультиплексор, на вход которого подаются различные операции (16 битные вычисления), и в качестве провода выбора выступает 3-х битный регистр alu. Поскольку sub и cmp выполняют одинаковую функцию, здесь они объединены.
То что alu_r не является 16-битным, а 17-битным, важно, потому что именно в 17 бит и находится тот самый флаг CF при сложении и вычитании. Создадим провод alu_top, он будет содержать старший бит, который зависит от текущего size, потому что флаги будут меняться в зависимости от того, какая в данный момент битность операндов, 8 или 16 бит.
wire [ 3:0] alu_top = size ? 4'hF : 3'h7;
Важным также является выбор группы для некоторых функции:
wire is_add = alu == alu_add || alu == alu_adc;
wire is_lgc = alu == alu_xor || alu == alu_and || alu == alu_or;
Если is_add=1, то текущая функция АЛУ – сложение, соответственно, тогда is_lgc=0. Если же is_add=0 и is_lgc = 1, это значит, что в данный момент выбрана группа логических операциях, ну и при is_add=0 и is_lgc=0 – группа вычитаний.
Рассмотрим теперь вычисления флага CF:
wire alu_cf = alu_r[alu_top + 1'b1];
Флаг будет равен либо 9-му биту alu_r[8], либо 17 alu_r[16], для этого как раз и используется провод alu_top.
wire alu_af = op1[4] ^ op2[4] ^ alu_r[4];
Почему флаг AF равен XOR между 4-ми битами первого, второго операнда и результат, можно [[/cpu/flag_half узнать]] из этой статьи.
wire alu_sf = alu_r[alu_top];
Флаг SF устанавливается тривиально, либо копируя бит alu_r[7], либо alu_r[15] – то есть, знаковый бит.
wire alu_zf = size ? ~|alu_r[15:0] : ~|alu_r[7:0];
Вот тут интересная хитрость. Когда size=1, то производится побитовое "ИЛИ" над всеми младшими 16 битами результата. Если там 0, то с помощью ~ это становится 1, сигнализируя о наличии нуля, и если хоть одна единица появится в одном из битов, то статус разрушается и zf превращается в 0. Аналогично с 8-битным результатом при size=0.
wire alu_pf = ~^alu_r[7:0];
С четностью так же как и с zero, но только примеряется не "OR", а "XOR". Эта операция проводит пере-xor-ивание между всеми битами, и при четном количестве единиц, получает 0, соответственно, с помощью инверсии ~ переворачивая в 1, и наоборот.
Пришло время для самого сложного вычисления overflow:
wire alu_of = (op1[alu_top] ^ op2[alu_top] ^ is_add) & (op1[alu_top] ^ alu_r[alu_top]);
Знающие люди скажут, что ничего сложного нет и что логическая его схема проста, но смотря на эту конструкцию, сомневаешься.
Существуют 2 случая, если is_add = 0 (вычитание) и is_add = 1 (сложение). Алгоритм вычисления этого флага меняется в зависимости от того, после какой инструкции был он рассчитан. Если после ADD, ADC, то результат op1[alu_top] ^ op2[alu_top] инвертируется, если после SUB,SBB,CMP, то нет.
И теперь, зная все флаги, можно сформировать их в конечном итоге в такую конструкцию:
wire [11:0] alu_f = {
alu_of & ~is_lgc,
flags[10:8],
alu_sf,
alu_zf,
1'b0,
alu_af & ~is_lgc,
1'b0,
alu_pf,
1'b1,
alu_cf & ~is_lgc
};
Флаги DIT при АЛУ-операциях не затрагиваются и потому просто копируются, биты 1,3,5 ставится 1,0,0 соответственно, а PF, ZF и SF записывается из результата непосредственно решение, а вот с OF, AF и CF не все так просто. С ними так – если вычисляется именно арифметическая инструкция, то is_lgc равен 0, то есть, alu_cf/pf/of присваиваются результату. В случае, если выполняется логическая инструкция (is_lgc=1), то cf, af и of устанавливаются в 0.
Как ни удивительно, но на этом моменте с АЛУ пока что все.
§ Исполнение инструкции
В этом материале я рассмотрю алгоритм работы инструкции АЛУ из набора [[/cpu/x86_table опкодов 00-3F]]. Маски опкодов будут выбраны следующие:
8'b00_xxx_0xx АЛУ с байтом modrm
8'b00_xxx_10x с непосредственным операндом 8 или 16 бит
Схему работы первого метода я рассмотрел в предыдущем материале. Представим, что к фазе exec процессор пришел с правильными значениями op1, op2, alu и size. Раз все правильно, то будет исполнена та схема, которую я описал выше, то есть, будут доступны результата alu_r и alu_f.
8'b00_xxx_0xx: begin
flags <= alu_f;
wb <= alu_r;
t <= alu == alu_cmp ? init : wback;
end
Итак, во флаги будет записано новое вычисленное значение alu_f (в любом случае), в регистр wb будет записан результат. Если номер функции АЛУ равен CMP, то инструкция завершает свою работу и выходит к фазе init, иначе, переходит к фазе wback – записи результата в левый операнд байта modrm.
Регистр wb объявляется так:
reg [15:0] wb = 16'h0000;
То есть, это некий 16-разрядный регистр, который нужен для записи обратно в память или регистр. После того, как были вычислены операнды из байта modrm, в левый операнд и будет записано новое полученное значение. Дополним новые фазы:
localparam
init = 1'b0,
...
wback = 4'hA,
wback2 = 4'hB,
wback3 = 4'hC;
Фаза wback отвечает за сохранение в регистр или в память (младший байт); wback2 записывает в память старший байт регистр wb; wback3 останавливает запись, выключает we и переходит либо к фазе init, либо указанной ранее фазе в регистре tnext:
reg [ 5:0] tnext = 1'b0;
reg [ 2:0] m = 1'b0;
Здесь m – регистр для обозначения номер строки внутри фазы exec, поскольку инструкции обычно у меня не выполняются за один такт. Значение tnext всегда сбрасывается в init:
init: begin
...
tnext <= init;
Приведу код фазы wback:
wback: begin
if (dir || modrm[7:6] == 2'b11) begin
case (dir ? modrm[5:3] : modrm[2:0])
3'b000: if (size) ax <= wb; else ax[ 7:0] <= wb[7:0];
3'b001: if (size) cx <= wb; else cx[ 7:0] <= wb[7:0];
3'b010: if (size) dx <= wb; else dx[ 7:0] <= wb[7:0];
3'b011: if (size) bx <= wb; else bx[ 7:0] <= wb[7:0];
3'b100: if (size) sp <= wb; else ax[15:8] <= wb[7:0];
3'b101: if (size) bp <= wb; else cx[15:8] <= wb[7:0];
3'b110: if (size) si <= wb; else dx[15:8] <= wb[7:0];
3'b111: if (size) di <= wb; else bx[15:8] <= wb[7:0];
endcase
t <= tnext;
end
else begin
t <= size ? wback2 : wback3;
o_data <= wb[7:0];
we <= 1'b1;
end
end
Здесь довольно много кода, но всего лишь 2 случая.
- Случай, в котором dir=1, а это значит, что левый операнд – заведомо регистр, номер регистра записан в modrm[5:3]
- Также, если dir=0, но mod=11, то это тоже регистр, но номер регистра записан в modrm[2:0] – что как раз и выбирается в case (dir ? modrm[5:3] : modrm[2:0])
- И второй случай, когда операнд находится в памяти. Если это так, то в любом случае на o_data ставится младший байт регистра wb, и ставится we=1
- Если size=0, то после записи операнда происходит переход на завершение записи в память:
wback3: begin we <= 1'b0; t <= tnext; end
Иначе, при 16-битном операнде, переходит к wback2:
wback2: begin
ea <= ea + 1;
o_data <= wb[15:8];
t <= wback3;
end
Там же записывается старший байт wb, сдвигается ea+1 и после этой микро-операции переходит к wback3, который и завершает работу фазы записи в память.
Таким образом, мы сделали сразу аж 32 инструкции! 8 функции на 4 разных типов операндов. Но поскольку будет очень удобно доделать АЛУ в этой секции опкодов в диапазоне 00-3F, то я доделаю.
8'b00_xxx_10x: case (m)
0: begin
m <= size ? 1 : 2;
op1 <= ax;
op2 <= i_data;
ip <= ip + 1;
end
1: begin op2[15:8] <= i_data; ip <= ip + 1; m <= 2; end
2: begin
t <= init;
flags <= alu_f;
if (alu != alu_cmp)
begin if (size) ax <= alu_r; else ax[7:0] <= alu_r[7:0]; end
end
endcase
Это АЛУ, но с той разницей, что левый операнд либо регистр AL, либо AX, а правый – непосредственный операнд, идущий следом за опкодом.
Инструкция разделена на 3 строки. Как мы видим, при попадании в m=0 (линия 0), происходит формирование операндов op1, op2, второй операнд равен следующему байту после опкода. В зависимости от size, переход происходит либо на 1-ю линию (size=1), либо на 2-ю (size=0).
- В линии 1 дочитывается старший байт в op2.
- В линии 2 уже записывается результат во флаги и в al/ax, что зависит от size. Как и обычно, если функция равна CMP, то никакой результат в аккумулятор ax не пишется.
Вот теперь на этом можно сказать, все.