Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

Verilog: такт, регистры и память

senior~90 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Verilog: такт, регистры и память

Всё, что ты писал на Verilog до этого урока, не помнило ничего. Мультиплексор и ALU это функции: подал входы, получил выходы, убрал входы, и от схемы не осталось следа. Процессор так жить не может, ему нужно помнить регистры, счётчик команд, флаги и содержимое памяти между инструкциями. Память в схеме появляется вместе с тактом. В этом уроке мы вводим clk, фронт и always_ff, разбираемся, почему внутри always_ff присваивание пишется стрелкой <=, а не знаком равенства, и собираем два самых больших блока будущего процессора: регистровый файл Y86-64 на пятнадцать регистров с двумя портами чтения и двумя портами записи, и память с байтовой адресацией, из которой этап fetch читает десять байт по любому адресу. Попутно ты научишься смотреть на схему во времени, а не одним срезом: снимать waveform (временную диаграмму сигналов) в формате VCD и открывать её в просмотрщике.

Цели урока

  • Понимать, что такое такт и фронт, и почему схема без такта не может помнить ничего.
  • Писать always_ff @(posedge clk) и различать, что в схеме комбинационное, а что тактируемое.
  • Объяснять, почему внутри always_ff пишут только неблокирующее присваивание <=, и показывать на примере, во что превращается схема с блокирующим =.
  • Читать и писать регистровый файл с несколькими портами: два чтения, две записи, номер 4'hf как признак отсутствующего регистра.
  • Понимать правило приоритета при совпадении портов записи и знать, какая инструкция Y86-64 от него зависит.
  • Описывать память с байтовой адресацией: десять байт для выборки инструкции с любого адреса, слово в порядке little-endian, флаг ошибки на обращении за границу.
  • Формулировать дисциплину такта: за такт всё читается и вычисляется, по фронту записывается. Понимать, почему именно она делает возможной последовательную машину SEQ.
  • Связывать период такта с самым длинным комбинационным путём и понимать, откуда возьмётся выигрыш конвейера.
  • Снимать waveform в VCD через $dumpfile и $dumpvars и смотреть его в GTKWave или surfer.

Идея: память это такт

Комбинационная схема из прошлого урока это чистая функция от входов. Выход ALU зависит только от aluA, aluB и alufun прямо сейчас. Убери входы, и выход перестанет что-либо значить. Никакого состояния внутри такой схемы нет и быть не может: провод не умеет хранить, он умеет только передавать.

Между тем процессор целиком построен на памяти. Регистр %rax держит значение между инструкциями, счётчик команд помнит, куда идти дальше, флаги помнят результат прошлого сравнения, память помнит массив, который ты сортируешь. Симулятор из урока про машину на Zig держал всё это в полях структуры, и вопрос “когда именно поле меняется” там не стоял: программа выполняется по шагам, присваивание случается в тот момент, когда до него дошла строка.

В схеме нет строк и нет “дошло”. Все провода живут одновременно и непрерывно. Чтобы в этом мире появилось понятие “шаг”, нужен внешний сигнал, который делит время на равные отрезки и говорит: вот сейчас, в эту наносекунду, состояние меняется. Такой сигнал называется тактовым и обозначается clk.

Три вопроса, на которые отвечает урок. Как записать на Verilog элемент, который помнит один бит между тактами. Как из таких элементов собрать регистровый файл и память, у которых больше одного порта, и что делать, когда два порта хотят писать в одно место. И почему из дисциплины “читаем весь такт, пишем по фронту” автоматически получается работающая последовательная машина, где каждая инструкция целиком выполняется за один такт.

Один разряд памяти: фронт и always_ff

Такт это сигнал, который повторяет одно и то же: единица, ноль, единица, ноль. Интересны в нём не уровни, а моменты перехода. Переход из нуля в единицу называется передним фронтом и записывается как posedge clk, обратный переход это negedge clk.

Самый простой элемент с памятью хранит один бит и обновляет его на каждом переднем фронте:

// Один разряд памяти. Значение со входа d становится выходом q
// в момент переднего фронта такта и держится до следующего фронта.
module dreg (
    input  logic clk,
    input  logic d,
    output logic q
);

  always_ff @(posedge clk) q <= d;

endmodule

Читается это так: “на каждом переднем фронте clk регистр q принимает значение d”. Между фронтами d может дёргаться сколько угодно, q этого не заметит. Вот в чём разница с always_comb из прошлого урока: always_comb следит за входами постоянно, always_ff просыпается один раз за такт.

Ключевое слово always_ff не добавляет ничего к тому, что умеет обычный always, оно объявляет намерение. Написав always_ff, ты обещаешь синтезатору и читателю, что этот блок описывает регистр, и любой инструмент проверит обещание: если внутри окажется присваивание вне фронта, будет ошибка. Ровно как always_comb обещает, что блок комбинационный.

Регистр на целое слово пишется так же, только шире, и обычно у него есть сброс:

// Регистр на 64 разряда со сбросом. Сброс синхронный: он тоже
// действует только по фронту, а не мгновенно.
module wreg (
    input  logic        clk,
    input  logic        rst,
    input  logic        en,
    input  logic [63:0] d,
    output logic [63:0] q
);

  always_ff @(posedge clk) begin
    if (rst)      q <= 64'd0;
    else if (en)  q <= d;
  end

endmodule

Три вещи стоит заметить. Первая: сброс тут синхронный, он срабатывает по фронту вместе со всем остальным, а не в момент, когда rst стал единицей. Так проще рассуждать, потому что во всей схеме остаётся ровно один момент изменения. Вторая: сигнал разрешения en это способ пропустить такт, оставив значение прежним. Ветки else без присваивания достаточно, чтобы регистр держал старое значение, и именно этим always_ff отличается от always_comb, где пропуск присваивания означал бы защёлку по ошибке. Третья: приоритет читается сверху вниз, rst сильнее en.

Две стрелки присваивания

Внутри always_ff есть один способ ошибиться, который стоит разобрать до того, как ты его совершишь. В SystemVerilog два вида присваивания. Знак равенства это блокирующее присваивание: значение вычисляется и записывается немедленно, следующая строка блока видит уже новое значение. Стрелка <= это неблокирующее присваивание: правые части всех строк вычисляются по значениям, какими они были до фронта, а сами записи применяются одновременно, когда фронт закончился.

Разница видна на двух почти одинаковых модулях. Оба хотят быть сдвиговым регистром на два разряда: вход попадает в q1, а то, что было в q1, уезжает в q2.

// Одна и та же пара строк с разными присваиваниями.
// Неблокирующее: правые части берутся до фронта, поэтому q2 получает
// то значение q1, которое было в прошлом такте. Настоящий сдвиг.
module shift_nb (
    input  logic clk,
    input  logic d,
    output logic q1,
    output logic q2
);

  always_ff @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
  end

endmodule

// Блокирующее: первая строка меняет q1 немедленно, вторая читает уже
// новое значение. Сдвига нет, оба разряда повторяют вход.
module shift_b (
    input  logic clk,
    input  logic d,
    output logic q1,
    output logic q2
);

  always_ff @(posedge clk) begin
    q1 = d;
    q2 = q1;
  end

endmodule

Тестбенч подаёт на оба модуля один и тот же импульс длиной в такт и печатает состояние после каждого фронта:

// Оба варианта сдвигового регистра рядом, на одном такте и одном входе.
module tb_shift;

  logic clk = 1'b0;
  logic d;
  logic nb1, nb2, b1, b2;

  shift_nb u_nb (.clk(clk), .d(d), .q1(nb1), .q2(nb2));
  shift_b  u_b  (.clk(clk), .d(d), .q1(b1),  .q2(b2));

  always #5 clk = ~clk;

  initial begin
    d = 1'b0;
    @(negedge clk); d = 1'b0;
    for (int t = 0; t < 5; t++) begin
      @(posedge clk);
      #1 $display("takt %0d: d=%b  nb q1=%b q2=%b   b q1=%b q2=%b", t, d, nb1, nb2, b1, b2);
      @(negedge clk);
      d = (t == 0) ? 1'b1 : 1'b0;
    end
    $finish(0);
  end

endmodule

Запуск и вывод:

$ iverilog -g2012 -o shift.out shift.sv tb_shift.sv && vvp -n shift.out
takt 0: d=0  nb q1=0 q2=0   b q1=0 q2=0
takt 1: d=1  nb q1=1 q2=0   b q1=1 q2=1
takt 2: d=0  nb q1=0 q2=1   b q1=0 q2=0
takt 3: d=0  nb q1=0 q2=0   b q1=0 q2=0
takt 4: d=0  nb q1=0 q2=0   b q1=0 q2=0

Смотри на такты 1 и 2. У версии с <= импульс сначала доходит до q1, а в следующем такте переезжает в q2: два разряда памяти, как и задумано. У версии с = импульс в тот же такт оказывается сразу в обоих разрядах, а на следующем такте исчезает из обоих: второй разряд не хранит ничего, схема выродилась в один регистр с двумя одинаковыми выходами.

Отсюда правило, из которого не бывает исключений в нормальном коде: внутри always_ff только <=, внутри always_comb только =. Причина не в стиле, а в том, что настоящие регистры в железе именно так себя и ведут. Все они защёлкивают вход от одного фронта, и ни один из них не успевает увидеть результат соседа в этом же фронте: сигнал от соседа физически ещё не дошёл. Стрелка <= описывает эту одновременность точно, знак равенства навязывает схеме порядок строк, которого в железе нет.

Полезное следствие: порядок строк внутри always_ff с неблокирующими присваиваниями не влияет ни на что, кроме случая, когда две строки пишут в одно и то же место. Этот случай встретится в регистровом файле через страницу, и там порядок важен.

Регистровый файл: два чтения и две записи

Пятнадцать регистров Y86-64 живут не по одному, а вместе, в одном блоке с несколькими портами. Число портов диктует система команд. Почти любая инструкция читает два регистра сразу: addq %rsi, %rdi берёт оба операнда в одном такте, значит нужны два независимых порта чтения, srcA и srcB. И некоторые инструкции меняют два регистра сразу: popq %rax кладёт в %rax слово из памяти и одновременно увеличивает %rsp, значит нужны два порта записи, dstE для результата ALU и dstM для значения из памяти. Это та же пара действий, которую ты читал в ассемблере x86-64 в уроке про вызовы и кадры стека, только теперь она должна уложиться в один такт схемы, а не в две микрооперации.

Пятнадцать регистров, а не шестнадцать, потому что номер 4'hf занят под особое значение: “регистра нет”. В книге он называется RNONE. Порт чтения с таким номером обязан отдать ноль, порт записи с таким номером не делает ничего. Это дешевле отдельного управляющего сигнала: этап decode ставит 4'hf там, где регистр не нужен, и никаких дополнительных проводов “читать или не читать” не появляется.

Из пакета y86_pkg, который мы завели в прошлом уроке вместе с ALU, этому уроку нужны две константы: RNONE для регистрового файла и MEM_BYTES для памяти. Пакет лежит в hdl/lib/y86_pkg.sv и приведён здесь целиком, с кодами инструкций, функциями ALU и кодами состояния, чтобы листинги урока собирались без правок:

// Общие константы Y86-64: коды инструкций, функции АЛУ, номера регистров,
// коды состояния. Каждый модуль начинается со строки import y86_pkg::*,
// поэтому имена icode и Stat читаются так же, как в главе 4 CS:APP.
// Пакет общий на весь проект, поэтому любой отдельно взятый модуль берёт
// из него лишь горстку констант. Verilator считает неиспользованной каждую
// остальную и на одном только alu выдаёт три десятка предупреждений, так что
// проверка на неиспользованный параметр здесь выключена целиком.
/* verilator lint_off UNUSEDPARAM */
package y86_pkg;

  // Разрядность машинного слова и размер памяти в байтах.
  // WORD ни в один модуль не подставляется: разрядность портов записана
  // числом [63:0], как её рисует книга. Константа стоит здесь как единственное
  // место, где разрядность машины названа словом.
  localparam int WORD      = 64;
  localparam int MEM_BYTES = 4096;

  // icode: старший полубайт первого байта инструкции.
  localparam logic [3:0] IHALT   = 4'h0;
  localparam logic [3:0] INOP    = 4'h1;
  localparam logic [3:0] IRRMOVQ = 4'h2;  // сюда же попадают cmovXX
  localparam logic [3:0] IIRMOVQ = 4'h3;
  localparam logic [3:0] IRMMOVQ = 4'h4;
  localparam logic [3:0] IMRMOVQ = 4'h5;
  localparam logic [3:0] IOPQ    = 4'h6;
  localparam logic [3:0] IJXX    = 4'h7;
  localparam logic [3:0] ICALL   = 4'h8;
  localparam logic [3:0] IRET    = 4'h9;
  localparam logic [3:0] IPUSHQ  = 4'hA;
  localparam logic [3:0] IPOPQ   = 4'hB;
  localparam logic [3:0] IIADDQ  = 4'hC;  // расширение из упражнения 4.3

  // ifun для OPq, он же код операции АЛУ.
  localparam logic [3:0] ALUADD = 4'h0;
  localparam logic [3:0] ALUSUB = 4'h1;
  localparam logic [3:0] ALUAND = 4'h2;
  localparam logic [3:0] ALUXOR = 4'h3;

  // ifun для jXX и cmovXX.
  localparam logic [3:0] CALWAYS = 4'h0;
  localparam logic [3:0] CLE     = 4'h1;
  localparam logic [3:0] CL      = 4'h2;
  localparam logic [3:0] CE      = 4'h3;
  localparam logic [3:0] CNE     = 4'h4;
  localparam logic [3:0] CGE     = 4'h5;
  localparam logic [3:0] CG      = 4'h6;

  // Регистры. Их пятнадцать, номер 4'hF означает «регистра нет».
  localparam logic [3:0] RRSP  = 4'h4;
  localparam logic [3:0] RNONE = 4'hF;

  // Коды состояния машины.
  localparam logic [2:0] SAOK = 3'h1;  // всё хорошо
  localparam logic [2:0] SHLT = 3'h2;  // выполнен halt
  localparam logic [2:0] SADR = 3'h3;  // обращение за границу памяти
  localparam logic [2:0] SINS = 3'h4;  // неизвестная инструкция

endpackage
/* verilator lint_on UNUSEDPARAM */

Вот весь модуль регистрового файла:

// Регистровый файл: пятнадцать 64-разрядных регистров, два порта чтения и два порта записи.
// Чтение комбинационное, запись по фронту такта. Номер 4'hF означает «регистра нет»:
// такой порт чтения даёт ноль, такой порт записи не делает ничего.
module regfile (
  input  logic        clk,
  input  logic        rst,

  input  logic [3:0]  srcA,
  input  logic [3:0]  srcB,
  output logic [63:0] valA,
  output logic [63:0] valB,

  input  logic        write_en,
  input  logic [3:0]  dstE,
  input  logic [63:0] valE,
  input  logic [3:0]  dstM,
  input  logic [63:0] valM
);

  import y86_pkg::*;

  logic [63:0] regs [0:14];

  always_comb valA = (srcA == RNONE) ? 64'd0 : regs[srcA];
  always_comb valB = (srcB == RNONE) ? 64'd0 : regs[srcB];

  // Оба порта пишут на одном фронте. Если они указывают на один регистр,
  // побеждает порт M: неблокирующие присваивания применяются по порядку,
  // и последнее переписывает предыдущее. Именно поэтому popq %rsp
  // оставляет в %rsp значение, прочитанное из памяти, а не rsp + 8.
  always_ff @(posedge clk) begin
    if (rst) begin
      for (int i = 0; i < 15; i++) regs[i] <= 64'd0;
    end else if (write_en) begin
      if (dstE != RNONE) regs[dstE] <= valE;
      if (dstM != RNONE) regs[dstM] <= valM;
    end
  end

endmodule

Разбор по частям. Массив объявлен как logic [63:0] regs [0:14], и порядок скобок тут важен: первая пара задаёт разрядность одной ячейки, вторая число ячеек. Поменяешь местами и получишь 64 ячейки по пятнадцать разрядов, что не то же самое.

Чтение живёт в always_comb и работает весь такт: valA следует за содержимым regs[srcA] непрерывно, как выход мультиплексора. Это и есть мультиплексор на пятнадцать входов, только записанный индексом вместо case. Проверка на RNONE стоит перед обращением к массиву, поэтому индекса 15 в regs никогда не возникает.

Запись живёт в always_ff и случается один раз за такт. Обе строки внутри пишут по фронту, и если dstE совпал с dstM, обе целят в одну ячейку. Здесь и всплывает порядок строк: неблокирующие присваивания к одной ячейке применяются в порядке записи, поэтому последнее переписывает предыдущее, и побеждает порт M. Это не произвол, а требование системы команд. Инструкция popq %rsp должна оставить в %rsp значение, прочитанное из памяти, а не результат сложения rsp и восьми, который в тот же такт посчитал ALU. Поставь порт E вторым, и popq %rsp начнёт врать.

Сигнал write_en нужен, чтобы оборвать инструкцию на полпути: когда код состояния отличается от AOK, машина замирает и ничего не записывает. В следующем уроке он придёт из логики Stat.

Тестбенч регистрового файла

Тестбенч показывает три вещи, ради которых он написан. Первая: чтение внутри такта видит старое содержимое. Вторая: запись видна только после фронта. Третья: при совпадении портов побеждает M.

// Тестбенч регистрового файла. Показывает три вещи, ради которых он написан:
// чтение внутри такта видит старое значение, запись применяется по фронту,
// и при совпадении dstE с dstM побеждает порт M.
// Запуск:
//   iverilog -g2012 -s tb_regfile -o /tmp/tb_regfile \
//     hdl/lib/y86_pkg.sv hdl/lib/regfile.sv hdl/tb/tb_regfile.sv
//   vvp -n /tmp/tb_regfile
module tb_regfile;

  import y86_pkg::*;

  logic        clk;
  logic        rst;
  logic [3:0]  srcA, srcB;
  logic [63:0] valA, valB;
  logic        write_en;
  logic [3:0]  dstE, dstM;
  logic [63:0] valE, valM;

  regfile dut (
    .clk      (clk),
    .rst      (rst),
    .srcA     (srcA),
    .srcB     (srcB),
    .valA     (valA),
    .valB     (valB),
    .write_en (write_en),
    .dstE     (dstE),
    .valE     (valE),
    .dstM     (dstM),
    .valM     (valM)
  );

  // Номера регистров Y86-64 в том же порядке, что в ассемблере.
  localparam logic [3:0] RRAX = 4'h0;
  localparam logic [3:0] RRCX = 4'h1;
  localparam logic [3:0] RRDX = 4'h2;
  localparam logic [3:0] RRBX = 4'h3;

  initial clk = 1'b0;
  always #5 clk = ~clk;

  // Один такт: входы выставляются сразу после спада, чтение печатается до
  // фронта (старое состояние), потом фронт, потом печать после фронта.
  task automatic cycle(string label,
                       logic [3:0] a, logic [3:0] b,
                       logic en,
                       logic [3:0] de, logic [63:0] ve,
                       logic [3:0] dm, logic [63:0] vm);
    @(negedge clk);
    srcA = a; srcB = b; write_en = en;
    dstE = de; valE = ve; dstM = dm; valM = vm;
    #1 $display("%s: до фронта    valA=0x%016h valB=0x%016h", label, valA, valB);
    @(posedge clk);
    #1 $display("%s: после фронта valA=0x%016h valB=0x%016h", label, valA, valB);
  endtask

  initial begin
    $dumpfile("regfile.vcd");
    $dumpvars(0, tb_regfile);

    rst = 1'b1; write_en = 1'b0;
    srcA = RNONE; srcB = RNONE;
    dstE = RNONE; dstM = RNONE; valE = 64'd0; valM = 64'd0;
    @(posedge clk);
    #1 rst = 1'b0;

    // Порт E пишет в %rax, читаем тот же регистр в этом же такте.
    cycle("E: rax = 0x11",  RRAX, RNONE, 1'b1, RRAX, 64'h11, RNONE, 64'd0);
    // Оба порта пишут в разные регистры.
    cycle("E: rcx, M: rdx", RRCX, RRDX,  1'b1, RRCX, 64'h22, RRDX, 64'h33);
    // Оба порта пишут в один регистр: побеждает M.
    cycle("E и M в rbx",    RRBX, RRBX,  1'b1, RRBX, 64'hAAA, RRBX, 64'hBBB);
    // Порт чтения с номером 0xf всегда даёт ноль, запись выключена.
    cycle("srcA=0xf, rbx",  RNONE, RRBX, 1'b0, RNONE, 64'd0, RNONE, 64'd0);

    $display("итог: rax=0x%016h rcx=0x%016h rdx=0x%016h rbx=0x%016h",
             dut.regs[0], dut.regs[1], dut.regs[2], dut.regs[3]);
    $finish(0);
  end

endmodule

Прогон:

$ iverilog -g2012 -s tb_regfile -o /tmp/tb_regfile \
    hdl/lib/y86_pkg.sv hdl/lib/regfile.sv hdl/tb/tb_regfile.sv
$ vvp -n /tmp/tb_regfile
VCD info: dumpfile regfile.vcd opened for output.
E: rax = 0x11: до фронта    valA=0x0000000000000000 valB=0x0000000000000000
E: rax = 0x11: после фронта valA=0x0000000000000011 valB=0x0000000000000000
E: rcx, M: rdx: до фронта    valA=0x0000000000000000 valB=0x0000000000000000
E: rcx, M: rdx: после фронта valA=0x0000000000000022 valB=0x0000000000000033
E и M в rbx: до фронта    valA=0x0000000000000000 valB=0x0000000000000000
E и M в rbx: после фронта valA=0x0000000000000bbb valB=0x0000000000000bbb
srcA=0xf, rbx: до фронта    valA=0x0000000000000000 valB=0x0000000000000bbb
srcA=0xf, rbx: после фронта valA=0x0000000000000000 valB=0x0000000000000bbb
итог: rax=0x0000000000000011 rcx=0x0000000000000022 rdx=0x0000000000000033 rbx=0x0000000000000bbb

Первая пара строк это главное наблюдение урока. В такте, где порт E пишет 0x11 в %rax, порт чтения смотрит на тот же %rax и видит ноль. Не потому, что схема тормозит, а потому, что записи ещё не было: она случится в конце такта. Ровно на этом свойстве держится всё дальше. Инструкция addq %rsi, %rdi читает %rdi и пишет в %rdi в одном такте, и её результат правилен только потому, что чтение отдало старое значение.

Третья пара строк это правило приоритета в действии: порт E принёс 0xaaa, порт M принёс 0xbbb, в регистре оказалось 0xbbb, и оба порта чтения после фронта показывают его.

Последняя пара строк проверяет 4'hf: порт A читает несуществующий регистр и отдаёт ноль, при том что порт B на том же такте нормально читает %rbx.

Виджет выше даёт покрутить то же самое руками. Ты выставляешь номера портов и значения, нажимаешь “такт” и смотришь, как меняется содержимое регистров и как это выглядит на диаграмме за последние такты. Три готовых набора нажимаются кнопками: чтение и запись одного регистра в один такт, оба порта записи в одну ячейку и popq %rsp целиком, с подготовительным тактом. Последний пригодится в уроке про этапы execute и memory, где эта инструкция разбирается по проводам.

Память: байты, слова и десять байт для fetch

Второй тактируемый блок это память. Она отличается от регистрового файла двумя вещами.

Первая: адресация побайтовая. Массив внутри модуля это массив байтов, а слово в 64 разряда собирается из восьми соседних байтов в порядке little-endian. Выравнивание не нужно: слово можно прочитать с любого адреса, и для учебной машины это проще, чем ограничение на кратность восьми.

Вторая: у памяти два интерфейса. Порт данных читает и пишет слова, это обычная работа инструкций mrmovq и rmmovq. Порт инструкций отдаёт десять байт подряд начиная с адреса, и вот тут стоит остановиться. Почему десять? Потому что самая длинная инструкция Y86-64 занимает ровно десять байт: байт кода, байт регистров и восемь байт константы, как у irmovq. Этап fetch не знает заранее, какая инструкция лежит по адресу PC, и вынужден получить максимум сразу, а уже потом, разглядев первый байт, решить, сколько байтов на самом деле нужно. Схема так и работает: она не умеет сходить в память ещё раз, ей нужно всё за один такт.

Оба интерфейса поднимают флаг ошибки, когда обращение выходит за границу памяти. Из этих флагов на верхнем уровне получается код состояния ADR.

// Память на 4096 байт с двумя интерфейсами.
// Порт инструкций отдаёт десять байт подряд начиная с imem_addr: этого хватает
// на самую длинную инструкцию Y86-64. Порт данных читает и пишет 8-байтные слова
// в порядке little-endian по любому адресу, выравнивание не требуется.
// Выход за границу памяти поднимает флаг ошибки, по нему верхний уровень ставит Stat = ADR.
module memory (
  input  logic        clk,

  input  logic [63:0] imem_addr,
  output logic [79:0] imem_bytes,
  output logic        imem_error,

  input  logic [63:0] dmem_addr,
  input  logic        dmem_read,
  input  logic        dmem_write,
  input  logic [63:0] dmem_wdata,
  output logic [63:0] dmem_rdata,
  output logic        dmem_error
);

  import y86_pkg::*;

  logic [7:0]  mem [0:MEM_BYTES-1];
  logic [63:0] iaddr;
  logic [63:0] daddr;

  // Десять байт для этапа выборки. Байты за границей памяти читаются нулями,
  // а сам факт выхода за границу отмечает imem_error.
  always_comb begin
    imem_bytes = 80'd0;
    for (int i = 0; i < 10; i++) begin
      iaddr = imem_addr + 64'(i);
      imem_bytes[8*i +: 8] = (iaddr < 64'(MEM_BYTES)) ? mem[iaddr[11:0]] : 8'h00;
    end
  end

  always_comb imem_error = (imem_addr >= 64'(MEM_BYTES));

  // Слово данных лежит младшим байтом по младшему адресу.
  always_comb begin
    dmem_rdata = 64'd0;
    for (int i = 0; i < 8; i++) begin
      daddr = dmem_addr + 64'(i);
      dmem_rdata[8*i +: 8] = (daddr < 64'(MEM_BYTES)) ? mem[daddr[11:0]] : 8'h00;
    end
  end

  // Обращение считается ошибочным, если хотя бы один из восьми байт за границей.
  always_comb dmem_error = (dmem_read | dmem_write) &&
                           (dmem_addr + 64'd8 > 64'(MEM_BYTES));

  always_ff @(posedge clk) begin
    if (dmem_write && !dmem_error) begin
      for (int i = 0; i < 8; i++) begin
        mem[dmem_addr[11:0] + 12'(i)] <= dmem_wdata[8*i +: 8];
      end
    end
  end

endmodule

Что стоит заметить в этом листинге. Блоков always_comb четыре: два собирают прочитанное, десять байт для выборки и слово для порта данных, ещё два поднимают флаги ошибки. Чтение из памяти комбинационное: адрес меняется, данные меняются следом, в том же такте. Запись одна, и она в always_ff: восемь байтов слова ложатся в массив по фронту, и только если ошибки нет.

Конструкция imem_bytes[8*i +: 8] это выбор среза переменной ширины. Читается как “восемь разрядов начиная с разряда 8*i вверх”. Без неё пришлось бы выписывать диапазон на каждый байт руками: десять штук для выборки и восемь для слова данных. А так один цикл покрывает и сборку десяти байт для fetch, и разборку слова на байты при записи. Обрати внимание, что цикл for внутри always_comb это не цикл во времени: он разворачивается в схему из десяти одинаковых кусков, работающих одновременно.

Индекс iaddr[11:0] берёт младшие двенадцать разрядов адреса, потому что памяти 4096 байт, а это ровно двенадцать разрядов. Проверка границы стоит отдельно, до индексации, и именно она отвечает за флаг ошибки.

Тестбенч памяти

// Тестбенч памяти. Показывает порт инструкций (десять байт с любого адреса),
// порт данных (слово little-endian, чтение и запись по фронту) и флаги ошибки
// на обращении за границу.
// Запуск:
//   iverilog -g2012 -s tb_memory -o /tmp/tb_memory \
//     hdl/lib/y86_pkg.sv hdl/lib/memory.sv hdl/tb/tb_memory.sv
//   vvp -n /tmp/tb_memory
module tb_memory;

  import y86_pkg::*;

  logic        clk;
  logic [63:0] imem_addr;
  logic [79:0] imem_bytes;
  logic        imem_error;
  logic [63:0] dmem_addr;
  logic        dmem_read, dmem_write;
  logic [63:0] dmem_wdata, dmem_rdata;
  logic        dmem_error;

  memory dut (
    .clk        (clk),
    .imem_addr  (imem_addr),
    .imem_bytes (imem_bytes),
    .imem_error (imem_error),
    .dmem_addr  (dmem_addr),
    .dmem_read  (dmem_read),
    .dmem_write (dmem_write),
    .dmem_wdata (dmem_wdata),
    .dmem_rdata (dmem_rdata),
    .dmem_error (dmem_error)
  );

  initial clk = 1'b0;
  always #5 clk = ~clk;

  // Печатает десять байт порта инструкций в порядке возрастания адреса.
  task automatic show_fetch(logic [63:0] addr);
    string line;
    @(negedge clk);
    imem_addr = addr;
    #1;
    line = "";
    for (int i = 0; i < 10; i++) line = {line, $sformatf("%02h ", imem_bytes[8*i +: 8])};
    $display("fetch 0x%04h: %s error=%b", addr[15:0], line, imem_error);
  endtask

  task automatic read_word(logic [63:0] addr);
    @(negedge clk);
    dmem_addr = addr; dmem_read = 1'b1; dmem_write = 1'b0;
    #1 $display("read  0x%04h: 0x%016h error=%b", addr[15:0], dmem_rdata, dmem_error);
  endtask

  task automatic write_word(logic [63:0] addr, logic [63:0] data);
    @(negedge clk);
    dmem_addr = addr; dmem_read = 1'b0; dmem_write = 1'b1; dmem_wdata = data;
    #1 $display("write 0x%04h: 0x%016h error=%b", addr[15:0], data, dmem_error);
    @(posedge clk);
    #1 dmem_write = 1'b0;
  endtask

  initial begin
    dmem_addr = 64'd0; dmem_read = 1'b0; dmem_write = 1'b0; dmem_wdata = 64'd0;
    imem_addr = 64'd0;
    for (int i = 0; i < MEM_BYTES; i++) dut.mem[i] = 8'h00;

    // Байты двух настоящих инструкций Y86-64, как их печатает ассемблер:
    // 0x000  irmovq $0x100, %rsp   30 f4 00 01 00 00 00 00 00 00
    // 0x00a  call   0x038          80 38 00 00 00 00 00 00 00
    dut.mem[0] = 8'h30; dut.mem[1] = 8'hf4;
    dut.mem[2] = 8'h00; dut.mem[3] = 8'h01;
    dut.mem[10] = 8'h80; dut.mem[11] = 8'h38;

    show_fetch(64'h000);
    show_fetch(64'h00a);

    // Слово данных: младший байт по младшему адресу.
    write_word(64'h100, 64'h0123456789abcdef);
    read_word(64'h100);
    $display("byte  0x100 = 0x%02h, byte 0x107 = 0x%02h", dut.mem[12'h100], dut.mem[12'h107]);

    // Соседнее слово ещё нулевое, а сдвинутое на четыре байта чтение
    // захватывает половину записанного и половину нулей.
    read_word(64'h104);

    // Обращения за границу. Памяти 4096 байт, значит слово по адресу 0xffc
    // не помещается целиком, а выборка с 0x1000 читать нечего.
    read_word(64'hffc);
    show_fetch(64'h1000);

    $finish(0);
  end

endmodule

Прогон:

$ iverilog -g2012 -s tb_memory -o /tmp/tb_memory \
    hdl/lib/y86_pkg.sv hdl/lib/memory.sv hdl/tb/tb_memory.sv
$ vvp -n /tmp/tb_memory
fetch 0x0000: 30 f4 00 01 00 00 00 00 00 00  error=0
fetch 0x000a: 80 38 00 00 00 00 00 00 00 00  error=0
write 0x0100: 0x0123456789abcdef error=0
read  0x0100: 0x0123456789abcdef error=0
byte  0x100 = 0xef, byte 0x107 = 0x01
read  0x0104: 0x0000000001234567 error=0
read  0x0ffc: 0x0000000000000000 error=1
fetch 0x1000: 00 00 00 00 00 00 00 00 00 00  error=1

Разбор вывода по строкам.

Первая строка это выборка с адреса 0. В памяти лежат байты 30 f4 00 01 и шесть нулей, то есть инструкция irmovq $0x100, %rsp целиком. Порт отдал все десять байт, хотя инструкция могла бы оказаться и однобайтовой.

Вторая строка это выборка с адреса 0x00a, некратного ничему. Порт отработал так же: адрес не обязан быть выровнен, и это принципиально, потому что инструкции в Y86-64 лежат вплотную и начинаются с любого адреса. Здесь лежит 80 38 и дальше нули, то есть call 0x38.

Третья и четвёртая строки это запись слова и чтение его обратно. Пятая строка доказывает little-endian напрямую: байт 0xef, младший в значении 0x0123456789abcdef, оказался по младшему адресу 0x100, а старший байт 0x01 по адресу 0x107.

Шестая строка это чтение со сдвигом на четыре байта. Оно захватило старшую половину записанного слова и четыре нулевых байта за ним, и собрало из них 0x0000000001234567. Никакой ошибки нет: адрес не обязан быть кратным восьми.

Седьмая строка это обращение за границу. Памяти 4096 байт, слово по адресу 0xffc заканчивалось бы на адресе 0x1003, которого нет, поэтому поднялся dmem_error. Восьмая строка то же самое для порта инструкций: с адреса 0x1000 читать нечего, байты отдаются нулями, но флаг ошибки поднят, и наверху из него получится Stat равный ADR.

Дисциплина такта: читаем и считаем весь такт, пишем по фронту

Теперь можно сформулировать правило, на котором держится вся четвёртая глава книги, и заодно понять рисунок 4.16 оттуда, где нарисовано, как значение регистра меняется во времени.

Правило звучит так. Всё тактируемое состояние машины (пятнадцать регистров, счётчик команд, три флага, содержимое памяти) меняется только в момент переднего фронта и только одновременно. Весь остальной такт это состояние неподвижно, а комбинационная логика, глядя на него, спокойно вычисляет, каким оно должно стать к следующему фронту.

Из этого правила сразу следует, что за один такт можно выполнить целую инструкцию. Смотри, как это работает. Такт начинается: счётчик команд держит адрес, регистры держат старые значения, память держит старое содержимое. Комбинационная логика читает десять байт по адресу PC, разбирает их на icode, ifun, rA, rB и valC, решает, какие регистры читать, читает valA и valB, считает на ALU valE, вычисляет адрес обращения к памяти, получает valM, решает, куда всё это писать и каким будет новый PC. Всё это чистые функции без единого элемента памяти, одна большая комбинационная схема, растянутая на весь такт. И только в конце, на фронте, результаты этой схемы защёлкиваются: новый PC в счётчик, valE и valM в регистры, слово в память, новые флаги во флаги.

Порядок этапов внутри такта существует только как порядок в схеме, а не во времени. Fetch не “происходит раньше” decode, просто выход fetch это вход decode, и сигнал физически идёт по проводам от одного к другому. Этапы это не шаги, это последовательность зависимостей.

Отсюда же берётся ответ на вопрос, который обычно возникает при чтении таблиц из книги: почему на этапе execute можно использовать valA, прочитанное на этапе decode, если запись в тот же регистр случится на этапе write back в том же такте. Ответ ты уже видел в выводе тестбенча: запись случится по фронту, то есть после того, как всё вычислено. Чтение весь такт отдаёт старое значение, и никакого конфликта нет.

Есть и обратная сторона. Раз за такт нужно успеть пройти всю цепочку от чтения памяти инструкций до записи результата, такт получается длинным. В уроке про принципы конвейеризации мы разрежем эту цепочку на части и получим за то же время больше инструкций, но заплатим тем, что инструкция перестанет помещаться в один такт.

Период такта и критический путь

Схема считает не мгновенно. У каждого вентиля есть задержка, у провода тоже, и результат на выходе появляется через какое-то время после того, как изменились входы. Пока результат не появился, защёлкивать его нельзя: в регистр попадёт мусор.

Значит, период такта не может быть меньше, чем время прохождения сигнала по самому длинному пути от одного набора регистров до другого. Этот путь называется критическим, и он один определяет частоту всей машины. Все остальные пути успевают раньше и просто ждут.

В SEQ критический путь проходит через всё сразу. Грубая прикидка по задержкам из книги выглядит так.

Участок путиЗадержка, условных единиц
чтение памяти инструкций200
разбор байтов и управляющая логика30
чтение регистрового файла100
ALU120
чтение или запись памяти данных200
выбор и запись результата30
итого за такт680

Числа условные, важны пропорции. Видно, что два обращения к памяти съедают больше половины такта, и что все шесть этапов складываются в одну сумму, потому что идут друг за другом по проводам. Единицу задержки книга меряет пикосекундами, и если взять её же, такт в 680 пикосекунд даёт частоту около полутора гигагерц. На каждый такт при этом приходится ровно одна инструкция.

Отсюда и берётся идея конвейера: если разрезать этот путь регистрами на пять кусков примерно по 140, такт станет короче почти в пять раз, а инструкций за то же время пройдёт почти в пять раз больше, хотя каждая отдельная инструкция будет идти через машину столько же.

Счёт тактов при этом ухудшается, а не улучшается. Программа sum на машине, которую ты соберёшь в ближайших уроках, проходит за 34 такта на SEQ и за 50 тактов на конвейерной версии: пузырьки и остановы добавляют шестнадцать тактов на пустом месте. Выигрыш весь целиком спрятан в длине такта, и именно поэтому число тактов само по себе ничего не говорит о скорости. Разбирать этот размен мы начнём через три урока.

Waveform: смотреть на схему во времени

Печать через $display показывает срезы: вот что было в такте 1, вот что в такте 2. Когда сигналов десяток, срезов не хватает, хочется видеть картину целиком. Для этого симулятор умеет записывать VCD, файл со всеми изменениями сигналов, а просмотрщик рисует по нему временную диаграмму.

Включается это двумя системными задачами в тестбенче:

initial begin
  $dumpfile("regfile.vcd");
  $dumpvars(0, tb_regfile);
  ...
end

Первая задаёт имя файла. Вторая говорит, что писать: первый аргумент это глубина обхода иерархии (ноль означает “всё, включая вложенные модули”), второй это модуль, с которого начинать. В tb_regfile из этого урока обе строки уже стоят, поэтому после прогона рядом появился файл на полторы сотни строк.

Внутри он устроен просто: сначала единица измерения времени и объявления сигналов с однобуквенными кодами, потом отметки времени и новые значения только тех сигналов, которые изменились.

$timescale
	1s
$end
...
$var reg 1 ' clk $end
$var reg 4 ( dstE [3:0] $end
$var reg 4 + srcA [3:0] $end
$var reg 64 - valE [63:0] $end
...
#5
1'
#6
0*
#10
b10001 -

Читается это так: в момент 5 сигнал с кодом ' (это clk) стал единицей, в момент 6 сигнал * (это rst) стал нулём, в момент 10 сигнал - (это valE) стал двоичным 10001, то есть шестнадцатеричным 0x11. Числа это модельное время в единицах из заголовка $timescale, а не секунды на твоих часах: задержки в тестбенче записаны как #5 и #1, и симулятор просто складывает их. Единица здесь получилась крупной, потому что тестбенч не объявляет `timescale, и Icarus берёт свою по умолчанию. Если поставить в начало файла директиву `timescale 1ns/1ps, те же события окажутся в моментах 5000, 6000 и 10000 пикосекунд, а картинка на диаграмме не изменится ни на пиксель: важны не абсолютные числа, а порядок событий. Руками такое читать незачем, это работа просмотрщика.

Просмотрщиков два разумных. Классический это GTKWave, он ставится одной строкой: brew install --cask gtkwave на macOS, sudo apt install gtkwave на Debian и Ubuntu. Современный это surfer, он на Rust и ставится через cargo install --git https://gitlab.com/surfer-project/surfer.git surfer. Оба открывают файл командой вида gtkwave regfile.vcd или surfer regfile.vcd.

Что ты увидишь. Слева список сигналов иерархией: сначала tb_regfile с его clk, rst, srcA, valA и остальными, внутри вложенный dut с теми же портами и массивом regs. Перетаскиваешь нужные в правую панель, и они появляются как дорожки. Справа время идёт слева направо. Дорожка clk это ровная гребёнка прямоугольников, по одному на такт. Дорожки шин показаны лентами с шестнадцатеричным значением внутри, и в момент смены значения лента перечёркнута.

Главное, ради чего стоит один раз это открыть: поставь курсор на фронт clk в первом рабочем такте и посмотри на valE и valA рядом. Видно, что valE держит 0x11 уже весь такт до фронта, а valA меняется ровно на фронте и ни мгновением раньше. Та самая дисциплина, которую мы обсуждали словами, нарисована на экране: комбинационные сигналы дёргаются где угодно внутри такта, тактируемые только на вертикальных линиях фронтов.

Практика

Собери регистровый файл сам. Заготовка даёт готовые порты, тебе нужно завести массив, описать комбинационное чтение с обработкой номера 4'hf и запись по фронту двумя портами с правильным приоритетом. Грейдер печатает пару valA и valB дважды за такт, до фронта и после, поэтому ошибка в дисциплине такта видна сразу.

Одно отличие от листинга выше: в задаче нет портов rst и write_en, они появятся, когда файл встанет в машину целиком. Всё остальное совпадает, включая правило приоритета.

Упражнения

Итоги

  • Комбинационная схема не помнит ничего. Память в железе появляется вместе с тактом: сигналом, который делит время на равные отрезки, и всё состояние машины меняется только в момент его переднего фронта.
  • always_ff @(posedge clk) описывает тактируемое состояние, always_comb комбинационное. Это не два способа написать одно и то же, а объявление намерения, которое проверяют инструменты.
  • Внутри always_ff только неблокирующее присваивание <=. Правые части всех строк берутся до фронта, записи применяются одновременно. Блокирующее = навязывает схеме порядок строк, которого в железе нет, и сдвиговый регистр от него схлопывается в один разряд.
  • Регистровый файл Y86-64 это пятнадцать 64-разрядных ячеек, два порта чтения и два порта записи. Число портов диктует система команд: два операнда у арифметики, два изменённых регистра у popq.
  • Номер 4'hf это не регистр, а признак его отсутствия. Порт чтения с таким номером отдаёт ноль, порт записи не делает ничего. Так экономится целый управляющий сигнал.
  • При совпадении dstE и dstM побеждает порт M, потому что его строка стоит второй. От этого зависит правильность popq %rsp.
  • Память адресуется байтами, слово собирается из восьми соседних байтов в порядке little-endian, выравнивание не нужно. Порт инструкций отдаёт десять байт с любого адреса, потому что столько занимает самая длинная инструкция, а этап fetch не знает заранее, какая инструкция перед ним.
  • Обращение за границу памяти поднимает флаг ошибки, из которого на верхнем уровне получается код состояния ADR.
  • Дисциплина такта: весь такт состояние неподвижно и комбинационная логика считает по нему следующее состояние, по фронту всё защёлкивается одновременно. Именно поэтому инструкция целиком помещается в один такт, а чтение регистра не конфликтует с записью в него же.
  • Период такта не может быть меньше задержки критического пути. В SEQ этот путь проходит через два обращения к памяти и ALU подряд, и это тот запас, который позже заберёт конвейер.
  • $dumpfile и $dumpvars пишут VCD, а GTKWave или surfer рисуют по нему временную диаграмму. На ней видно то же правило: комбинационные сигналы меняются внутри такта, тактируемые строго на фронтах.

Дальше

У тебя теперь есть весь набор деталей: вентили и мультиплексоры из урока про комбинационные схемы, ALU с флагами из прошлого урока, а с этого урока регистровый файл и память. Дальше начинается сборка. Следующий урок берёт шесть этапов, которые в симуляторе на Zig были шестью функциями, и превращает первые два в схему: fetch читает десять байт по адресу PC, определяет icode и ifun, решает, есть ли у инструкции байт регистров и восьмибайтовая константа, и считает valP. Decode по таблицам из книги выбирает, какие регистры читать и куда потом писать, и там ты впервые увидишь, что каждый управляющий сигнал в процессоре это просто принадлежность icode какому-то множеству.

домашка

Домашка