§ Формат инструкции

Вот и пришло время самого главного начала начал. Перед тем как начну писать код на верилоге, скажу, что процессор не имеет буфера предварительной загрузки из-за упрощения, то есть считывается байт за байтом, и еще опишу примерный вид инструкции:
| Префиксы | Опкод | Байт ModRM | Смещение | Непосредственный операнд
| 1-...    | 1     | 1          | 1-2      | 1-2
Префиксы необходимы для того, чтобы явно указывать то, чем будет заниматься инструкция, с какими сегментными регистрами, например. Префиксов не очень много:
  • 26 ES: 2E CS: 36 SS: 3E DS: — сегментные префиксы
  • 64 FS: 65 GS: — тоже сегментные префиксы, но в 8088 их нет, начиная с 386
  • 66 Opsize 67 Adsize — префиксы расширения регистра и адреса, 386+
  • F0 LOCK — блокировка шины, в моем процессоре вообще не нужен
  • F2 REPNZ F3 REPZ — префиксы для инструкции работы со строками
Опкод в переводе как "ОПерации КОД", или просто код операции, это номер инструкции, которая будет выполняться по определенному алгоритму.
Особое место занимает байт modrm, это очень важный байт, о нем речь пойдет позже. Он позволяет определить, как инструкции работать с операндами. То есть, это конфигурационный байт, который задает два операнда, но и не только. Есть инструкции, где байт modrm также задает то, какая инструкция будет исполнена, например add,sub,and и прочие.
Смещение появляется только если в байте modrm есть выбранная опция чтения из памяти со смещением, то есть смещение это адрес, который прибавляется к некоторому другому адресу в памяти, который определяет modrm-байт.
Непосредственный операнд используется в некоторых инструкциях, чтобы загрузить значение прямо из инструкции в регистр или память.
Задача на сегодня в том, чтобы прочитать префиксы и код операции. Эта довольно просто сделать.

§ Настройка регистров

В целях экономии нервов на отладку, всегда будет использоваться 1 такт на то, чтобы начать инструкцию и 1 такт, чтобы ее выполнить. Получается, что даже самая простая инструкция будет выполняться за 2 такта.
В такте инициализации будет сбрасываться всё, что можно, чтобы начать инструкцию с нуля, так сказать. Также будет проверяться наличие внешнего прерывания, но это потом.
Однако перед тем, как я начну делать этот код, необходимо объявить все используемые в процессоре регистры:
1// Регистры общего назначения
2reg [15:0]  ax = 16'h0000;
3reg [15:0]  bx = 16'h0000;
4reg [15:0]  cx = 16'h0000;
5reg [15:0]  dx = 16'h0000;
6reg [15:0]  sp = 16'h0000;
7reg [15:0]  bp = 16'h0000;
8reg [15:0]  si = 16'h0000;
9reg [15:0]  di = 16'h0000;
10
11// Сегментные регистры
12reg [15:0]  es = 16'h0000;
13reg [15:0]  cs = 16'hF000;
14reg [15:0]  ss = 16'h0000;
15reg [15:0]  ds = 16'h0000;
16
17// Флаги                ODIT SZ A  P C
18reg [11:0]  flags = 12'b0000_0000_0000;
19reg [15:0]  ip    = 16'hFFF0;
Все эти регистры я объявил в файле decl.v, а подключил через `include "decl.v" в любом удобном пространстве файла cpu.v.
У процессора 8088 есть регистров флагов flags, биты которого обозначают некоторый флаг в данный момент и регистр ip, который завязан на сегментные регистр cs, образуя пару cs:ip. Эта пара 16-битных регистров указывают на некоторую область памяти, которая вычисляется как address = cs*16 + ip. На самом деле, даже стоит пока что сделать так:
1assign address = {cs,4'h0} + ip; // Равнозначно cs*16+ip
И получится, что теперь, наконец-то, address будет куда-то указывать, а не висеть отсоединенным проводом. Всего памяти можно адресовать 65535*16 + 65535 = 1114095 (10FFEFh), но, как мы понимаем, так не получится в данном случае, ибо бит 20 не то что не активирован, его просто нет, так что в любом случае, при превышении адреса считываться данные будут, начиная опять с 0, просто потому, что адрес не влезает в 20 бит.
При запуске процессора, его регистры обычно находятся в неопределенном состоянии, но в регистрах ПЛИС можно выставить необходимые значения, но не во всех ПЛИС это работает. Например в 5-м Циклоне не работает. Потому начнем с того, что будет выставлено значение CS:IP = F000:FFF0
1always @(posedge clock)
2// Либо сброс, либо инициализация
3if (!reset_n || !locked) begin cs <= 16'hF000; ip <= 16'hFFF0; end
4else begin
5    // здесь будет код
6end
Ясное дело, что если F000h*10h + FFF0h, то будет указывать адрес FFFF0h в памяти, а это последние 16 байт. В них обычно находится информация о версии BIOS и инструкция JMP FAR куда-то в область кода. Раз так, значит, надо загрузить в память эти данные. Для того, чтобы программа была, ее нужно написать на ассемблере и скомпилировать, а потом загрузить в память.

§ Загрузка программы в testbench

Поскольку я не планирую делать так, чтобы эти 16 байт как-то потом менялись, то выглядеть они будут всегда так:
1        0  1  2  3  4  5  6  7  8  9  A  B  C  D  E  F
2FFFF0h EA 00 00 00 F0 32 37 2F 30 34 2F 32 32 00 FE 00
3       JMP F000:0000   2  7  /  0  4  /  2  2
Первые 5 байт здесь это инструкция перехода, потом идет дата 27/04/22 — текущее число написания этой статьи и потом идет непонятно что, честно говоря, но надо это записать, наверное, какая-то сигнатура BIOS, которую дос-программы читают.
На этапе разработки простого процессора 8088, я сделаю так, чтобы 64КБ занимал биос, а там как пойдет. Для этого может пригодится программа на ассемблере:
1        org     0h                  ; Начало программы в F000h:0000h
2bios_start:
3        jmp     $                   ; "подвесить" процессор
4        times   65536-16-$ db 0     ; Дополнить нулями
5        jmp     far 0F000h : 0      ; Переход к метке
6        db      "27/04/22"
7        db      0x00, 0xFE, 0x00
Если ассемблировать этот файл
1all:
2	fasm bios.asm bios.bin
То получится файлом размером ровно 65536 байт.
Теперь, необходимо загрузить эти данные в память. В app.cc все достаточно просто делается:
1fp = fopen("bios/bios.bin", "rb");
2if (fp) {
3    fread(memory + 0xF0000, 1, 65536, fp);
4    fclose(fp);
5} else {
6    printf("bios/bios.bin not found\n");
7}
Открыть файл с bios, который находится в папке bios, и сохранить данные в последние 64Кб памяти.
А вот с tb.v для icarus verilog придется сначала сконвертировать bin-файл в hex-файл. Этот код я сделаю на php. Создам файл convert.php в папке bios.
1<?php
2$out  = '';
3$bios = file_get_contents(__DIR__ . '/bios.bin');
4for ($i = 0; $i < strlen($bios); $i++) {
5    $out .= sprintf("%02x\n", ord($bios[$i]));
6}
7file_put_contents("bios.hex", $out);
И потом обновляется makefile:
1all:
2	fasm bios.asm bios.bin
3	php convert.php
4	cd .. && make
Теперь после каждой компиляции появляется файл bios, и запускается перегенерация данных в другом makefile, уровнем выше. Чтобы загрузить файл bios.hex в memory в tb.v, необходимо добавить строчку
1initial begin $readmemh("bios/bios.hex", memory, 20'hF0000); end
Которая загружает 64Кб файл в memory, начиная с адреса 20'hF0000.

§ Считывание префиксов и опкода

Вот, после всех приготовлений, можно уже начинать писать код процессора. Для исполнения кода инструкции нам понадобятся некоторые регистры, которые отвечают за то текущее состояние инструкции, которая будет далее выполнена:
1reg [ 5:0]  t           = 1'b0;
2reg [ 7:0]  opcode      = 1'b0;
3reg [15:0]  segment     = 16'h0000;
4reg         segover     = 1'b0;
5reg [ 1:0]  rep         = 2'b00;
Теперь разберу каждый из них для чего требуется. Это далеко не первый раз, когда будут добавлены новые регистры.
  • t — основная исполнительная линия, главная фаза исполнения инструкции, например инициализация, считывание опкода, запись регистров, чтение из памяти т.д.
  • opcode — сохраненный код операции, он может требоваться далее
  • segment — сегмент по умолчанию, обычно там находится значение ds, но может меняться префиксами или байтом modrm, а также используется для различных манипуляциях с памятью
  • segover — если =1, то это сигнал идущим далее обработчикам о том, что сегмент был префиксирован, то есть, до того, как опкод был получен, там был префикс. Этот сигнал не позволяет заменить в байте modrm сегмент ds на ss, когда используется адресация по bp, с данным вопросом будет разобрано позже
  • rep — если он равен 0, то префикса REP: перед опкодом не было; =2 значит REPNZ, =3 REPZ. Важный префикс для работы со строковыми инструкциями.
Итак, инициализирующий такт. Он будет исполняться тогда, когда t = init:
1always @(posedge clock)
2if (!reset_n || !locked) begin cs <= 16'hF000; ip <= 16'hFFF0; t <= init; end
3else case (t)
4
5    // Первый такт сбрасывает инструкцию перед запуском
6    init: begin
7
8        t       <= fetch;   // Переход к фазе `fetch`
9        we      <= 1'b0;    // Сброс запись в память, если был
10        segment <= ds;      // Сегмент по умолчанию ds
11        segover <= 1'b0;    // Убрать сегментные префиксы, если были
12        rep     <= 2'b00;   // И также префиксы REPNZ/REPZ
13
14    end
15
16endcase
В этом коде есть некоторые константы, такие как init, например, при сбросе процессора t <= init;. Они определяются таким образом:
1localparam
2    init    = 1'b0,  // Стадия подготовки инструкции
3    fetch   = 1'b1,  // Считывание опкода и префиксов
4    exec    = 2'h2;  // Исполнение инструкции
Это список будет пополняться по мере надобности. Я сделал так потому, что обозначать главные стадии удобнее именованными константами, чем цифрами, да и запутаться невозможно.
Так что, как только будет выполнен первый такт, после него сразу будет переход к стадии fetch:
1fetch: begin
2
3    case (i_data)
4
5        // Обработка префиксов
6        8'h26: begin segment <= es; segover <= 1'b1; end
7        8'h2E: begin segment <= cs; segover <= 1'b1; end
8        8'h36: begin segment <= ss; segover <= 1'b1; end
9        8'h3E: begin segment <= ds; segover <= 1'b1; end
10        8'hF2, 8'hF3: begin rep <= i_data[1:0]; end
11        8'hF0, 8'h64, 8'h65, 8'h66, 8'h67, 8'h0F: begin /* ничего не делать */ end
12        // Запись опкода или частичное исполнение
13        default: t <= exec;
14
15    endcase
16
17    ip      <= ip + 1'b1;
18    opcode  <= i_data;
19end
Он уже посложнее. В качестве опкода принимается текущее значение на шине i_data, то есть будет считан байт, куда указывает cs:ip. Если этот байт будет равен 36, 2e, 36, 3e, то тогда в segover запишется 1, что сигнализирует о замещении сегментным префиксом, и будет обновлено значение segment требуемым сегментным регистром.
Для кодов f2, f3 в rep запишется либо 2, либо 3, а вот при всех остальных префиксах f0, 64, 65, 66, 67, 0f не будет вообще ничего исполнено, они просто пропускаются без всяких изменений.
При получении любого другого кода, в opcode будет записан этот полученный код i_data и произойдет переход к фазе exec. Однако! Это не всегда так. Если инструкция простая, то она исполнится прямо тут и перейдет к фазе init. Вот именно такой код и будем писать далее.
И в заключении, после считывания опкода или префикса, счетчик ip будет увеличиваться на +1, пока не будет считан опкод. Защита от переполнения префиксами здесь отсутствует, так что наш процессор можно легко "подвесить", заполнив однородными префиксами все 64Кб. Вот такой вот сверхбаг, но мне все равно.
Думаю, что на сегодня с этим материалом все. Следующим будет материал по считыванию байта modrm и разборке операндов.
Скачать коды к статье.