Оглавление
§ Баг верилятора
Сегодня моя задача состоит в том, чтобы что-то запустить, чтобы процессор уже как-то начал работать, в том числе, и в реальной отладочной плате. Пора запускать!
В процессе работы над этим материалом обнаружился очень неприятный баг в вериляторе, который надо было учесть. Когда я отлаживал код, то понял, что надо переписать функцию tick25().
void tick25() {
if (cpu_mod->we) memory[ cpu_mod->address & 0xFFFFF ] = cpu_mod->o_data;
cpu_mod->i_data = memory[ cpu_mod->address & 0xFFFFF ];
...
ps2_mod->clock = 0; ps2_mod->eval();
vga_mod->clock = 0; vga_mod->eval();
cpu_mod->clock = 0; cpu_mod->eval();
ps2_mod->clock = 1; ps2_mod->eval();
vga_mod->clock = 1; vga_mod->eval();
cpu_mod->clock = 1; cpu_mod->eval();
...
vga(vga_mod->hs, vga_mod->vs, cl);
}
В чем суть? Оказывается, верилятор как-то неправильно считает такты, и когда происходит исполнение такта при clock=0, то защелкивается i_data, а потом никак не меняется при защелкивании на 1, так что я поменял код, что данные начинают извлекаться до того, как будет исполнен "тик-так".
На самом деле, для целей отладки я добавил в код дизассемблер, но приводить в статье его не буду, потому что это займет огромную "простыню" кода, так что этого делать не стоит. Если что, в коде, который прикреплен к статье, это все есть.
Еще важная вещь, о которой я совершенно забыл, так это выставление 1 на вход cpu:
cpu_mod->reset_n = 1;
cpu_mod->locked = 1;
§ Добавление новых инструкций
Сегодня я добавлю только те инструкции, которые потребуются, чтобы запустить простую программу. Список новых инструкции:
- Условные переходы на короткие (short) ссылки из одного байта
- Короткий переход
- Дальний переход
- АЛУ с непосредственным операндом
- Перемещение (MOV) из регистра в память, регистра в регистр, из памяти в регистр
- Перемещение в сегментные и из сегментных регистров
- MOV в регистр из непосредственного значения
Список минимальных дополнений инструкции оказался достаточно внушительным.
Некоторые из этих инструкции требуют считывания байта modrm, потому часть из них пойдет в t <= tmodrm вместо exec. К таким относятся инструкции АЛУ и перемещения данных.
8'b00_xxx_0xx,
8'b10_001_0xx: t <= tmodrm;
8'b1000_00xx: begin t <= tmodrm; dir <= 1'b0; end
8'b1000_11x0: begin t <= tmodrm; size <= 2'b1; end
8'b1111_0100: begin t <= init; ip <= ip; end
Кстати интересной особенностью для некоторых инструкции является то, что в них есть отход от определенного порядка, например, dir = 0 для АЛУ, где левый операнд всегда будет r/m, а правый – непосредственным значением, либо же при перемещении между сегментными префиксами размер всегда 16 бит.
Здесь есть также инструкция HLT, которая делает, фактически, ничего – то есть оставляет ip где был с помощью ip <= ip, и переходит к стадии инициализации. Уже там, в той стадии, будет решена судьба дальнейшего исполнения. А обычно это делает любое пришедшее прерывание. Прерываний в процессоре пока что нет, так что эта инструкция будет полностью завешивать процессор и ему может помочь только HARD RESET (как говорят в народе, семь бед – один ресет).
§ Условный переход
Начну с одной из самых важных инструкции, это условный переход, который происходит при некоторых конфигурациях флагов.
70 JO | 71 JNO -- переход, если O=1 (JO) или если O=0 (JNO)
72 JB | 73 JNB -- CF=1
74 JZ | 75 JNZ -- ZF=1
76 JBE | 77 JA -- CF=1 OR ZF=1
78 JS | 79 JNS -- SF=1
7A JP | 7B JNP -- PF=1
7C JL | 7D JNL -- (SF != OF)
7E JLE | 7F JG -- (SF != OF) || ZF=1
К примеру, при JL переход будет выполнен в том случае, если флаг SF не равен флагу OF (то есть, если SF ^ OF = 1). При JNL переход выполнится в обратном случае, то есть когда SF=OF, или SF ^ OF = 0.
Получается, что условий перехода всего лишь 8, остальные 8 – это лишь инверсия условия. Чтобы выбирать необходимые условия, я сформирую провод битностью 8:
wire [7:0] branches = {
(flags[SF] ^ flags[OF]) | flags[ZF],
(flags[SF] ^ flags[OF]),
flags[PF],
flags[SF],
flags[CF] | flags[ZF],
flags[ZF],
flags[CF],
flags[OF]
};
Этот провод выступает в качестве всех возможных состоянии мультиплексора на 3 бита. А вот и реализация уже непосредственно в фазе exec:
8'b0111_xxxx: begin
t <= init;
if (branches[ opcode[3:1] ] ^ opcode[0])
ip <= ip + 1'b1 + signex;
else
ip <= ip + 1'b1;
end
Чтобы с ходу не запутаться:
wire [15:0] signex = {{8{i_data[7]}}, i_data[7:0]};
Поскольку signex – это знакорасширенный i_data с 8 до 16 бит. Знакорасширение является копированием старшего бита в старший байт. Если в старшем бите младшего байта 1, то весь старший байт будет целиком из единиц (или FFh).
Инструкции $70-$7F работают так:
- Номер условия получается из битов [3:1] опкода
- По номеру условия (это 3 бита) проверяется указанный бит в проводе branches
- Если там 1, то условие выполняется, но при это opcode[0]=0; если же opcode[0]=1, то условие выполняется, если получен 0
При выполнении условия добавляется к текущему IP+1 значение из непосредственного операнда (смещение), иначе просто происходит переход к следующему IP.
А вот безусловный переход работает так же как и условный с коротким операндом в 1 байт, просто никакие условия не проверяются:
8'b1110_1011: begin t <= init; ip <= ip + 1'b1 + signex; end
Сразу переходя к метке.
§ АЛУ с непосредственным операндом
Исполнение инструкции уже многоступенчатое, потому применяется m:
8'b1000_00xx: case (m)
0: begin
m <= opcode[1:0] == 2'b11 ? 2 : (size ? 1 : 2);
op2 <= opcode[1:0] == 2'b11 ? signex : i_data;
alu <= modrm[5:3];
ip <= ip + 1;
end
1: begin m <= 2; op2[15:8] <= i_data; ip <= ip + 1; end
2: begin
flags <= alu_f;
t <= alu == alu_cmp ? init : wback;
wb <= alu_r;
end
endcase
Эта инструкция имеет довольно-таки интересное исполнение.
80 rm, i8
81 rm, i16
82 rm, i8 (дубликат 80)
83 rm16, i8
Как можно заметить, в случае 3 левый операнд 16-битный, а правый – 8 бит, но эти 8 бит расширяются до 16 с помощью знакорасширения signex.
Такт m=0:
- Во второй операнд записывается лишь 1 байт, либо знакорасширенный байт.
- Если знакорасширение, то переходит к m=2, иначе либо к m=1, если size=1, либо m=2 при указанном 8-битном значении
- Записывается номер функции АЛУ, полученный из reg-части байта modrm, так что здесь эта часть как раз используется не как выбор регистра в качестве операнда, а как выбор функции АЛУ
Такт m=1: просто дочитывает старший байт в правый операнд
Такт m=2: записывает флаги результата, и, если АЛУ не CMP, то записывает в регистр или память полученный результат
Так что функция кажется и сложной, но на самом деле все просто. Единственная ее проблема в том, что все эти инструкции кушают много тактов.
§ Перемещения
А вот реализация инструкции перемещения данных из регистра в память и т.д. довольно просто:
8'b1000_10xx: begin t <= wback; wb <= op2; end
То есть просто пишется правый операнд в левый и все. Не составляет труда понять схему работу перемещения из сегмента в регистр или память:
8'b1000_1100: begin
t <= wback;
case (modrm[5:3])
3'h0: wb <= es; 3'h1: wb <= cs;
3'h2: wb <= ss; 3'h3: wb <= ds;
endcase
end
И наоборот, из памяти/регистра в сегментный регистр:
8'b1000_1110: begin
t <= init;
case (modrm[5:3])
3'h0: es <= op2;
3'h2: ss <= op2;
3'h3: ds <= op2;
endcase
end
В первом случае результат уходит в регистр wb и далее происходит процедура обратной записи wback, а во втором случае правый операнд записывается в допустимые регистры es, ss, ds. А вот cs нельзя! Хотя... на 8088 как раз и можно было, но лучше уж запретить.
Теперь идет блок с реализацией достаточно часто используемых инструкции перемещения непосредственного значения в один из 8 регистров:
8'b1011_xxxx: case (m)
0: begin
t <= opcode[3] ? exec : wback;
m <= 1;
dir <= 1'b1;
wb <= i_data;
size <= opcode[3];
ip <= ip + 1'b1;
modrm[5:3] <= opcode[2:0];
end
1: begin
t <= wback;
ip <= ip + 1'b1;
wb <= {i_data, wb[7:0]};
end
endcase
При получении первого такта, на всякий случай записываются все необходимые регистры:
- m=1, чтобы перейти на этот этап, но происходит это лишь при t=exec, который появляется лишь при size=1 (то есть, чтение 16 бит)
- dir=1 и modrm[5:3] – номер регистра, куда будет записан результат
- Обязательно указываем size, поскольку он формируется нестандартным способом, находясь в 3-м бите опкода
- Ну и записывая в wb полученный младший байт непосредственного значения
При size=0 выполнился t=wback для 8-битного значения, а при size=1 произойдет переход к этапу m=1 и уже оттуда запишется полученное в wb 16-битное значение.
§ Дальний переход
Ну и последнее, что на сегодня я рассмотрю по поводу инструкции, это будет важная инструкция дальнего перехода, которая исполняется при старте процессора:
8'b1110_1010: case (m)
0: begin ip <= ip + 1; m <= 1; wb[ 7:0] <= i_data; end
1: begin ip <= ip + 1; m <= 2; wb[15:8] <= i_data; end
2: begin ip <= ip + 1; m <= 3; segment[7:0] <= i_data; end
3: begin {cs, ip} <= {i_data, segment[7:0], wb}; t <= init; end
endcase
Эта инструкция последовательно забирает 4 байта и после их приема отсылает на новый CS:IP.
§ Небольшая программа
Приведу текст программы на ассемблере, которая будет способна на выполнение простых действий – очистки экрана и пропечатывание надписи "Hello World":
mov ax, $b800
mov es, ax
mov ax, cs
mov ds, ax
mov ax, $17F9
mov bx, $0000
mov cx, 2000
@@: mov [es:bx], ax
add bx, 2
sub cx, 1
jne @b
mov si, master
mov bx, 2*80+4
@@: mov al, [cs:si]
cmp al, 0
je $
add si, 1
mov [es:bx], ax
add bx, 2
jmp @b
master: db "Hello World!", 0
Результатом исполнения программы будет следующее:
§ Синтез на ПЛИС
После проверки на вериляторе, всегда хочется проверить и то, как это заработает на ПЛИС, потому, после того, как создана схема из шаблона, добавляю блоки памяти:
wire [7:0] i_data_bios;
bios BiosMemory
(
.clock (clock_100),
.address_a (address[15:0]),
.q_a (i_data_bios),
.data_a (o_data),
.wren_a (we & we_bios)
);
Здесь будет храниться 64 Кб BIOS, к пинам на выход подключено на провод i_data_bios. Для памяти видеоадаптера будет так:
wire [ 7:0] i_data_vga;
wire [12:0] address_vga;
font UnitFont
(
.clock (clock_100),
.address_a (address_vga),
.q_a (data),
.address_b (address[12:0]),
.q_b (i_data_vga),
.data_b (o_data),
.wren_b (we & we_vga),
);
Эта память двухпортовая, в блоке памяти может читаться и писаться одновременно как из модуля видеадаптера (выбирается через адрес address_vga), так и из процессора address[12:0] и i_data_vga.
В обоих этих модулях есть запись в память через o_data, we и разрешающего запись провода we_bios и we_vga.
wire we_bios = address >= 20'hF0000;
wire we_vga = address >= 20'hB8000 && address < 20'hBA000;
На них будет 1 или 0 в зависимости от того, куда указывает сейчас адрес. Соответственно, в общее i_data будет выбрано соответственно:
wire [ 7:0] i_data =
we_bios ? i_data_bios :
we_vga ? i_data_vga : 8'h00;
Что не попадает в эти области памяти, будет равно 0. Получается, что на ПЛИС рабочие области памяти будут такие пока что:
- F0000-FFFFF BIOS
- B8000-B9FFF VIDEOMEM
Объявление CPU:
cpu CPU8088
(
.clock (clock_25),
.reset_n (1'b1),
.locked (locked),
.address (address),
.i_data (i_data),
.o_data (o_data),
.we (we)
);
Тут все достаточно просто, процессор работает на 25 мгц, когда память работает в 4 раза быстрее. Частоты задаются через PLL:
de0pll unit_pll
(
.clkin (CLOCK_50),
.m25 (clock_25),
.m100 (clock_100),
.locked (locked)
);