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

Сегодня моя задача состоит в том, чтобы что-то запустить, чтобы процессор уже как-то начал работать, в том числе, и в реальной отладочной плате. Пора запускать!
В процессе работы над этим материалом обнаружился очень неприятный баг в вериляторе, который надо было учесть. Когда я отлаживал код, то понял, что надо переписать функцию tick25().
// Один такт 25 Mhz
void tick25() {

    // Если есть запись, записать, и потом прочесть новое значение
    if (cpu_mod->we) memory[ cpu_mod->address & 0xFFFFF ] = cpu_mod->o_data;
    cpu_mod->i_data = memory[ cpu_mod->address & 0xFFFFF ];

    ...

    // Сначала ставится 0 для всех
    ps2_mod->clock = 0; ps2_mod->eval();
    vga_mod->clock = 0; vga_mod->eval();
    cpu_mod->clock = 0; cpu_mod->eval();

    // Потом ставится 1
    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. К таким относятся инструкции АЛУ и перемещения данных.
// Предвычисление modrm
8'b00_xxx_0xx,
// MOV rmr
8'b10_001_0xx: t <= tmodrm;
// ALU rm,i
8'b1000_00xx: begin t <= tmodrm; dir <= 1'b0; end
// MOV sreg, r16
8'b1000_11x0: begin t <= tmodrm; size <= 2'b1; end
// HLT
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 = {

    /*7*/ (flags[SF] ^ flags[OF]) | flags[ZF],
    /*6*/ (flags[SF] ^ flags[OF]),
    /*5*/  flags[PF],
    /*4*/  flags[SF],
    /*3*/  flags[CF] | flags[ZF],
    /*2*/  flags[ZF],
    /*1*/  flags[CF],
    /*0*/  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)

    // 8 bit
    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

    // 16 bit
    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 <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, то записывает в регистр или память полученный результат
Так что функция кажется и сложной, но на самом деле все просто. Единственная ее проблема в том, что все эти инструкции кушают много тактов.

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

А вот реализация инструкции перемещения данных из регистра в память и т.д. довольно просто:
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
И наоборот, из памяти/регистра в сегментный регистр:
// MOV sreg, r16
8'b1000_1110: begin

    t <= init;
    case (modrm[5:3])
    3'h0: es <= op2;
//  3'h1: cs <= op2; -- можно только на 8088
    3'h2: ss <= op2;
    3'h3: ds <= op2;
    endcase

end
В первом случае результат уходит в регистр wb и далее происходит процедура обратной записи wback, а во втором случае правый операнд записывается в допустимые регистры es, ss, ds. А вот cs нельзя! Хотя... на 8088 как раз и можно было, но лучше уж запретить.
Теперь идет блок с реализацией достаточно часто используемых инструкции перемещения непосредственного значения в один из 8 регистров:
// MOV r,i
8'b1011_xxxx: case (m)

    // Byte
    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

    // Word
    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)
);
Все эти коды будут находится в прикрепленном файле, в том числе в файле map.html будет обновленная таблица доступных инструкции.