§ Баг верилятора

Сегодня моя задача состоит в том, чтобы что-то запустить, чтобы процессор уже как-то начал работать, в том числе, и в реальной отладочной плате. Пора запускать!
В процессе работы над этим материалом обнаружился очень неприятный баг в вериляторе, который надо было учесть. Когда я отлаживал код, то понял, что надо переписать функцию tick25().
1// Один такт 25 Mhz
2void tick25() {
3
4    // Если есть запись, записать, и потом прочесть новое значение
5    if (cpu_mod->we) memory[ cpu_mod->address & 0xFFFFF ] = cpu_mod->o_data;
6    cpu_mod->i_data = memory[ cpu_mod->address & 0xFFFFF ];
7
8    ...
9
10    // Сначала ставится 0 для всех
11    ps2_mod->clock = 0; ps2_mod->eval();
12    vga_mod->clock = 0; vga_mod->eval();
13    cpu_mod->clock = 0; cpu_mod->eval();
14
15    // Потом ставится 1
16    ps2_mod->clock = 1; ps2_mod->eval();
17    vga_mod->clock = 1; vga_mod->eval();
18    cpu_mod->clock = 1; cpu_mod->eval();
19
20    ...
21
22    vga(vga_mod->hs, vga_mod->vs, cl);
23}
В чем суть? Оказывается, верилятор как-то неправильно считает такты, и когда происходит исполнение такта при clock=0, то защелкивается i_data, а потом никак не меняется при защелкивании на 1, так что я поменял код, что данные начинают извлекаться до того, как будет исполнен "тик-так".
На самом деле, для целей отладки я добавил в код дизассемблер, но приводить в статье его не буду, потому что это займет огромную "простыню" кода, так что этого делать не стоит. Если что, в коде, который прикреплен к статье, это все есть.
Еще важная вещь, о которой я совершенно забыл, так это выставление 1 на вход cpu:
1cpu_mod->reset_n = 1;
2cpu_mod->locked  = 1;

§ Добавление новых инструкций

Сегодня я добавлю только те инструкции, которые потребуются, чтобы запустить простую программу. Список новых инструкции:
  • Условные переходы на короткие (short) ссылки из одного байта
  • Короткий переход
  • Дальний переход
  • АЛУ с непосредственным операндом
  • Перемещение (MOV) из регистра в память, регистра в регистр, из памяти в регистр
  • Перемещение в сегментные и из сегментных регистров
  • MOV в регистр из непосредственного значения
Список минимальных дополнений инструкции оказался достаточно внушительным.
Некоторые из этих инструкции требуют считывания байта modrm, потому часть из них пойдет в t <= tmodrm вместо exec. К таким относятся инструкции АЛУ и перемещения данных.
1// Предвычисление modrm
28'b00_xxx_0xx,
3// MOV rmr
48'b10_001_0xx: t <= tmodrm;
5// ALU rm,i
68'b1000_00xx: begin t <= tmodrm; dir <= 1'b0; end
7// MOV sreg, r16
88'b1000_11x0: begin t <= tmodrm; size <= 2'b1; end
9// HLT
108'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:
1wire [7:0] branches = {
2
3    /*7*/ (flags[SF] ^ flags[OF]) | flags[ZF],
4    /*6*/ (flags[SF] ^ flags[OF]),
5    /*5*/  flags[PF],
6    /*4*/  flags[SF],
7    /*3*/  flags[CF] | flags[ZF],
8    /*2*/  flags[ZF],
9    /*1*/  flags[CF],
10    /*0*/  flags[OF]
11};
Этот провод выступает в качестве всех возможных состоянии мультиплексора на 3 бита. А вот и реализация уже непосредственно в фазе exec:
18'b0111_xxxx: begin
2
3    t <= init;
4    if (branches[ opcode[3:1] ] ^ opcode[0])
5        ip <= ip + 1'b1 + signex;
6    else
7        ip <= ip + 1'b1;
8end
Чтобы с ходу не запутаться:
1wire [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 байт, просто никакие условия не проверяются:
18'b1110_1011: begin t <= init; ip <= ip + 1'b1 + signex; end
Сразу переходя к метке.

§ АЛУ с непосредственным операндом

Исполнение инструкции уже многоступенчатое, потому применяется m:
18'b1000_00xx: case (m)
2
3    // 8 bit
4    0: begin
5
6        m   <= opcode[1:0] == 2'b11 ? 2 : (size ? 1 : 2);
7        op2 <= opcode[1:0] == 2'b11 ? signex : i_data;
8        alu <= modrm[5:3];
9        ip  <= ip + 1;
10
11    end
12
13    // 16 bit
14    1: begin m <= 2; op2[15:8] <= i_data; ip <= ip + 1; end
15
16    // Запись результата
17    2: begin
18
19        flags <= alu_f;
20        t     <= alu == alu_cmp ? init : wback;
21        wb    <= alu_r;
22
23    end
24
25endcase
Эта инструкция имеет довольно-таки интересное исполнение.
  • 80 <ALU> rm, i8
  • 81 <ALU> rm, i16
  • 82 <ALU> rm, i8 (дубликат 80)
  • 83 <ALU> 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, то записывает в регистр или память полученный результат
Так что функция кажется и сложной, но на самом деле все просто. Единственная ее проблема в том, что все эти инструкции кушают много тактов.

§ Перемещения

А вот реализация инструкции перемещения данных из регистра в память и т.д. довольно просто:
18'b1000_10xx: begin t <= wback; wb <= op2; end
То есть просто пишется правый операнд в левый и все. Не составляет труда понять схему работу перемещения из сегмента в регистр или память:
18'b1000_1100: begin
2
3    t <= wback;
4    case (modrm[5:3])
5    3'h0: wb <= es; 3'h1: wb <= cs;
6    3'h2: wb <= ss; 3'h3: wb <= ds;
7    endcase
8
9end
И наоборот, из памяти/регистра в сегментный регистр:
1// MOV sreg, r16
28'b1000_1110: begin
3
4    t <= init;
5    case (modrm[5:3])
6    3'h0: es <= op2;
7//  3'h1: cs <= op2; -- можно только на 8088
8    3'h2: ss <= op2;
9    3'h3: ds <= op2;
10    endcase
11
12end
В первом случае результат уходит в регистр wb и далее происходит процедура обратной записи wback, а во втором случае правый операнд записывается в допустимые регистры es, ss, ds. А вот cs нельзя! Хотя... на 8088 как раз и можно было, но лучше уж запретить.
Теперь идет блок с реализацией достаточно часто используемых инструкции перемещения непосредственного значения в один из 8 регистров:
1// MOV r,i
28'b1011_xxxx: case (m)
3
4    // Byte
5    0: begin
6
7        t    <= opcode[3] ? exec : wback;
8        m    <= 1;
9        dir  <= 1'b1;
10        wb   <= i_data;
11        size <= opcode[3];
12        ip   <= ip + 1'b1;
13        modrm[5:3] <= opcode[2:0];
14
15    end
16
17    // Word
18    1: begin
19
20        t    <= wback;
21        ip   <= ip + 1'b1;
22        wb   <= {i_data, wb[7:0]};
23
24    end
25
26endcase
При получении первого такта, на всякий случай записываются все необходимые регистры:
  • 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-битное значение.

§ Дальний переход

Ну и последнее, что на сегодня я рассмотрю по поводу инструкции, это будет важная инструкция дальнего перехода, которая исполняется при старте процессора:
18'b1110_1010: case (m)
2
3    0: begin ip <= ip + 1; m <= 1; wb[ 7:0] <= i_data; end
4    1: begin ip <= ip + 1; m <= 2; wb[15:8] <= i_data; end
5    2: begin ip <= ip + 1; m <= 3; segment[7:0] <= i_data; end
6    3: begin {cs, ip} <= {i_data, segment[7:0], wb}; t <= init; end
7
8endcase
Эта инструкция последовательно забирает 4 байта и после их приема отсылает на новый CS:IP.

§ Небольшая программа

Приведу текст программы на ассемблере, которая будет способна на выполнение простых действий - очистки экрана и пропечатывание надписи "Hello World":
1        ; Установка сегментов
2        mov     ax, $b800
3        mov     es, ax
4        mov     ax, cs
5        mov     ds, ax
6
7        ; Очистка экрана
8        mov     ax, $17F9
9        mov     bx, $0000
10        mov     cx, 2000
11@@:     mov     [es:bx], ax
12        add     bx, 2
13        sub     cx, 1
14        jne     @b
15
16        ; Надпись
17        mov     si, master
18        mov     bx, 2*80+4
19@@:     mov     al, [cs:si]
20        cmp     al, 0
21        je      $
22        add     si, 1
23        mov     [es:bx], ax
24        add     bx, 2
25        jmp     @b
26
27master: db      "Hello World!", 0
Результатом исполнения программы будет следующее:

§ Синтез на ПЛИС

После проверки на вериляторе, всегда хочется проверить и то, как это заработает на ПЛИС, потому, после того, как создана схема из шаблона, добавляю блоки памяти:
1wire [7:0] i_data_bios;
2
3bios BiosMemory
4(
5    .clock      (clock_100),
6    .address_a  (address[15:0]),
7    .q_a        (i_data_bios),
8    .data_a     (o_data),
9    .wren_a     (we & we_bios)
10);
Здесь будет храниться 64 Кб BIOS, к пинам на выход подключено на провод i_data_bios. Для памяти видеоадаптера будет так:
1wire [ 7:0] i_data_vga;
2wire [12:0] address_vga;
3
4font UnitFont
5(
6    .clock      (clock_100),
7    .address_a  (address_vga),
8    .q_a        (data),
9    .address_b  (address[12:0]),
10    .q_b        (i_data_vga),
11    .data_b     (o_data),
12    .wren_b     (we & we_vga),
13);
Эта память двухпортовая, в блоке памяти может читаться и писаться одновременно как из модуля видеадаптера (выбирается через адрес address_vga), так и из процессора address[12:0] и i_data_vga.
В обоих этих модулях есть запись в память через o_data, we и разрешающего запись провода we_bios и we_vga.
1wire we_bios = address >= 20'hF0000;
2wire we_vga  = address >= 20'hB8000 && address < 20'hBA000;
На них будет 1 или 0 в зависимости от того, куда указывает сейчас адрес. Соответственно, в общее i_data будет выбрано соответственно:
1wire [ 7:0] i_data =
2    we_bios ? i_data_bios :
3    we_vga  ? i_data_vga : 8'h00;
Что не попадает в эти области памяти, будет равно 0. Получается, что на ПЛИС рабочие области памяти будут такие пока что:
  • F0000-FFFFF BIOS
  • B8000-B9FFF VIDEOMEM
Объявление CPU:
1cpu CPU8088
2(
3    .clock      (clock_25),
4    .reset_n    (1'b1),
5    .locked     (locked),
6    .address    (address),
7    .i_data     (i_data),
8    .o_data     (o_data),
9    .we         (we)
10);
Тут все достаточно просто, процессор работает на 25 мгц, когда память работает в 4 раза быстрее. Частоты задаются через PLL:
1de0pll unit_pll
2(
3    .clkin     (CLOCK_50),
4    .m25       (clock_25),
5    .m100      (clock_100),
6    .locked    (locked)
7);
Все эти коды будут находится в прикрепленном файле, в том числе в файле map.html будет обновленная таблица доступных инструкции.