5. Шины и тестирование последовательностной логики

Дана задача:

Пусть есть точки на отрезке. Каждая точка характеризуется двумя параметрами: 8 бит координат и 3 бита цвет. В систему каждый из параметров поступает последовательно по каналу шириной 1 бит.

Модуль обработки обрабатывает 11 бит за раз. Необходимо написать конвертер двух последовательных входов в один параллельный.

На примере данной задачи и рассмотрим темы данного занятия.

Шины

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

Для решения данной проблемы используются шинные протоколы, которые достаточно хорошо стандартизованы. Одни из наиболее известных протоколов: AMBA AXI[1], WishBone, Avalon Bus. Протоколы часто рассчитаны на различные топологии подключения: master-slave, конвейер данных, общая шина и другие.

Мы будем рассматривать топологию конвейер данных.

bus

Каждый модуль одновременно является и master (префикс m_), и slave (префикс s_).

Когда один модуль готов отправить данные он записывает данные в m_data и поднимает сигнал m_valid, а затем ждёт готовности следующего модуля в цепочке. Как только поднимается сигнал m_ready, происходит обмен данными. Важно, что m_valid не может зависеть от m_ready.

Конвертер последовательного сигнала в параллельный

Самым простым решением нашей задачи является использования трех блоков: один будет выдавать 8-битный параллельный сигнал, второй 3-битный параллельный сигнал, а третий будет их объединять.

Начнём решать нашу задачу с создания конвертера последовательного сигнала в параллельный.

src/05-bus/serial_to_parallel/serial_to_parallel.sv
module serial_to_parallel #(
    parameter int OUT_WIDTH = 8  (1)
) (
    input logic clk,
    input logic aresetn, (2)

    // Interface inspired by AXI Stream
    // Input from master
    input  logic s_valid,
    output logic s_ready,
    input  logic s_data,

    // Output to slave
    output logic m_valid,
    input logic m_ready,
    output logic [OUT_WIDTH-1:0] m_data
);

  logic [OUT_WIDTH-1:0] data_ff;
  logic [$clog2(OUT_WIDTH)-1:0] counter;  (3)
  logic valid_ff;

  always_ff @(posedge clk or negedge aresetn) begin  (4)
    if (~aresetn) begin
      data_ff <= '0;
    end else if (s_ready & s_valid) begin  (5)
      data_ff <= {s_data, data_ff[OUT_WIDTH-1:1]};  (6)
    end
  end

  always_ff @(posedge clk or negedge aresetn) begin  (7)
    if (~aresetn) begin
      counter  <= '0;
      valid_ff <= 1'b0;
    end else if (s_ready & s_valid) begin
      counter <= counter + 1;

      if (counter == (OUT_WIDTH - 1)) begin
        valid_ff <= 1'b1;
        counter  <= '0;
      end else begin
        valid_ff <= 1'b0;
      end

    end
  end

  always_comb begin  (8)
    m_valid = valid_ff;
    s_ready = ~(m_valid & ~m_ready);  (9)
    m_data  = data_ff;
  end

endmodule
1 Параметризуем наш модуль шириной последовательного порта.
2 Здесь будет использовать асинхронный (префикс a) сигнал сброса с активным нижним уровнем (суффикс n). Асинхронность означает, что мы обязаны реагировать на сигнал сброса в любое время, а не только по фронту clk. Активный нижний уровень означает, что обычное значение сигнала — 1, а для сброса его необходимо сделать 0.
3 Используем встроенную функцию $clog2, которая вычисляет логарифм по основанию 2, а затем округляет число вверх, чтобы вычислить сколько бит необходимо для того, чтобы досчитать до числа OUT_WIDTH-1.
4 Данный блок отвечает за сохранение валидных битов. Здесь в списке чувствительности указываем не только clk, но и aresetn.
5 Проверяем стоит ли нам забирать входные данные.
6 Такая конструкция часто называется сдвиговый регистр.
7 Данный блок отвечает за учёт сохраненных битов.
8 Данный блок отвечает за вывод сигналов.
9 Единственный случай, когда мы не готовы принимать данные — мы сформировали вывод, а следующее устройство в цепочке не готово его принять.

Тестирование кода

Теперь наконец поговорим о более сложных тестбенчах. На самом деле SystemVerilog предоставляет богатые возможности по написанию тестбенчей. Здесь показан наиболее простой код, демонстрирующий основные концепции. Дальше данный код можно расширять используя функции, задачи (аналог процедур), а также классы[2].

src/05-bus/serial_to_parallel/serial_to_parallel_tb.sv
`timescale 1ns / 1ps  (1)

module serial_to_parallel_tb;

  localparam int OutWidth = 11;  (2)

  logic clk;
  logic aresetn;

  logic s_valid;
  logic s_ready;
  logic s_data;

  logic m_valid;
  logic m_ready;
  logic [OutWidth-1:0] m_data;

  serial_to_parallel #(
      .OUT_WIDTH(OutWidth)
  ) DUT (
      .clk(clk),
      .aresetn(aresetn),
      .s_valid(s_valid),
      .s_ready(s_ready),
      .s_data(s_data),
      .m_valid(m_valid),
      .m_ready(m_ready),
      .m_data(m_data)
  );

  localparam int ClkPeriod = 10;

  initial begin
    clk <= '0;
    forever begin
      #(ClkPeriod / 2) clk <= ~clk;
    end
  end

  initial begin
    aresetn <= '0;
    #(ClkPeriod);
    aresetn <= '1;
  end

  localparam int NOfIterations = 1000;

  initial begin  (2)
    wait (~aresetn);
    s_valid <= '0;
    s_data  <= '0;
    wait (aresetn);

    repeat (NOfIterations) begin  (3)
      @(posedge clk);
      s_valid <= '1;
      s_data  <= $urandom();  (4)
      do begin
        @(posedge clk);
      end while (~s_ready);  (5)
      s_valid <= '0;  (6)
    end
    $finish();  (7)
  end

  initial begin  (8)
    wait (~aresetn);
    m_ready <= $urandom();
    wait (aresetn);

    forever begin
      @(posedge clk);
      m_ready <= $urandom();
    end
  end

  logic [OutWidth-1:0] parallel_out = '0;
  int counter = 0;

  initial begin  (9)
    wait (aresetn);
    forever begin
      @(posedge clk);
      if (s_valid & s_ready) begin
        parallel_out <= {s_data, parallel_out[OutWidth-1:1]};

        if (counter == OutWidth) begin
          counter <= 1;
        end else begin
          counter <= counter + 1;
        end
      end
    end
  end

  initial begin  (10)
    wait (aresetn);
    forever begin
      @(posedge clk);
      if (counter == OutWidth) begin
        if (~(m_valid)) begin
          $error("%0t Incorrect m_valid. Expected: 1, actual: %d", $time(), m_valid);
        end
      end
      if (m_data !== parallel_out) begin
        $error("%0t Incorrect m_data. Expected: %d, actual: %d", $time(), parallel_out, m_data);
      end
    end
  end

  // initial begin  (11)
  //   $monitor("%0t\t", $time(), "aresetn=%d\t", aresetn, "s_valid=%d\t", s_valid, "s_ready=%d\t",
  //            s_ready, "s_data=%d\t", s_data, "m_valid=%d\t", m_valid, "m_ready=%d\t", m_ready,
  //            "m_data=%b\t", m_data, "parallel_out=%b", parallel_out);
  // end

  initial begin  (12)
    repeat (100000) @(posedge clk);
    $stop();
  end

`ifdef __ICARUS__  (13)
  initial begin
    $dumpfile("serial_to_parallel_tb.vcd");
    $dumpvars(0, serial_to_parallel_tb);
  end
`endif

endmodule
1 Укажем время и временную точность симуляции[3].
2 Конструкции выше уже должны быть знакомы с прошлой темы. Этот блок отвечает за генерацию входных сигналов.
3 Цикл, который повторится заданное количество раз.
4 Генерируем случайный вход.
5 Держим данные пока принимающее устройство не готово.
6 "Облегчаем" процесс коммуникации. На самом деле обнулять сигнал s_valid нет необходимости.
7 Именно этот блок контролирует момент конца симуляции.
8 В данном блоке симулируем следующее устройство в цепи.
9 Реализуем функциональность нашего блока в тестах, чтобы проверить корректность.
10 Блок, в котором происходит проверка корректности работы.
11 Данный блок закомментирован, но может быть удобен для отладки. Обратите внимание на непривычный способ форматирования строки.
12 Данный блок закончит симуляцию по таймауту. Может быть очень полезен, если ваша симуляция зависла.
13 Выгружаем сигналы для GTKWave. В данном случае при помощи препроцессора запускаем данное действие только при использовании Icarus Verilog.

Итого, для тестирования модуля стоит использовать более близкие к обычным языкам программирования возможности SystemVerilog, а так же для рандомизированного тестирования стоит иметь модель модуля для сравнения результатов. Однако создание модели на SystemVerilog бывает крайне неудобно, об альтернативных способах тестирования мы расскажем в следующих блоках.

Объединение двух параллельных сигналов

Теперь нам нужно объединить два параллельных сигнала в один. Посмотрим на код модуля, который реализует данное действие.

src/05-bus/merge_parallel/merge_parallel.sv
module merge_parallel #(
    parameter int IN_WIDTH_1 = 3,
    parameter int IN_WIDTH_2 = 8,
    parameter int OUT_WIDTH  = IN_WIDTH_1 + IN_WIDTH_2  (1)
) (
    input logic clk,
    input logic aresetn,

    // Input from master 1
    input logic s_valid_1,
    output logic s_ready_1,
    input logic [IN_WIDTH_1-1:0] s_data_1,

    // Input from master 2
    input logic s_valid_2,
    output logic s_ready_2,
    input logic [IN_WIDTH_2-1:0] s_data_2,

    // Output to slave
    output logic m_valid,
    input logic m_ready,
    output logic [OUT_WIDTH-1:0] m_data
);

  logic [OUT_WIDTH-1:0] data_ff;
  logic valid_1_ff, valid_2_ff;

  always_ff @(posedge clk or negedge aresetn) begin  (2)
    if (~aresetn) begin
      data_ff <= '0;
    end else begin
      if (s_ready_1 & s_valid_1) begin
        data_ff[IN_WIDTH_1-1:0] <= s_data_1;
      end
      if (s_ready_2 & s_valid_2) begin
        data_ff[OUT_WIDTH-1:IN_WIDTH_1] <= s_data_2;
      end
    end
  end

  always_ff @(posedge clk or negedge aresetn) begin  (3)
    if (~aresetn) begin
      valid_1_ff <= '0;
      valid_2_ff <= '0;
    end else begin
      if (s_ready_1 & s_valid_1) begin
        valid_1_ff <= '1;
      end
      if (s_ready_2 & s_valid_2) begin
        valid_2_ff <= '1;
      end

      // This condition isn't perfect,
      // as it requires excess cycle to reset flags
      if (m_valid & m_ready) begin
        valid_1_ff <= '0;
        valid_2_ff <= '0;
      end
    end
  end

  always_comb begin
    m_valid = valid_1_ff & valid_2_ff;
    s_ready_1 = ~valid_1_ff;
    s_ready_2 = ~valid_2_ff;
    m_data = data_ff;
  end

endmodule
1 Параметры тоже могут зависеть от параметров. К сожалению у такого подхода есть один минус: этот параметр всё так же можно менять при инстанциации.
2 Правило хорошего тона: разбиваем логику работы с данными и с управляющими сигналами на два always_ff блока с одинаковым списком чувствительности. В этом блоке работает с данными.
3 А в этом с управляющими сигналами.

Однако теперь мы видим проблему: как только мы преобразовали все три бита в одном конвертере, он будет простаивать и дожидаться когда отработает второй конвертер. Обычно такая проблема как раз возникает при использовании внешней периферии и для того, чтобы её смягчить используют более быстрый или более медленный управляющий сигнал. К сожалению, перемещение сигнала между различными управляющими сигналами — непростая задача, которая не будет освещена в данном курсе[4].

Итоговый модуль может выглядеть примерно так:

src/05-bus/merge_serial_to_parallel.sv
module merge_serial_to_parallel #(
    parameter int WIDTH_1   = 3,
    parameter int WIDTH_2   = 8,
    parameter int OUT_WIDTH = WIDTH_1 + WIDTH_2
) (
    input logic clk,
    input logic aresetn,

    // Input from master 1
    input  logic s_valid_1,
    output logic s_ready_1,
    input  logic s_data_1,

    // Input from master 2
    input  logic s_valid_2,
    output logic s_ready_2,
    input  logic s_data_2,

    // Output to slave
    output logic m_valid,
    input logic m_ready,
    output logic [OUT_WIDTH-1:0] m_data
);

  logic s2p_1_valid, s2p_1_ready, s2p_2_valid, s2p_2_ready;
  logic [WIDTH_1-1:0] s2p_1_data;
  logic [WIDTH_2-1:0] s2p_2_data;

  serial_to_parallel #(
      .OUT_WIDTH(WIDTH_1)
  ) s2p_1 (
      .clk,
      .aresetn,
      .s_valid(s_valid_1),
      .s_ready(s_ready_1),
      .s_data (s_data_1),
      .m_valid(s2p_1_valid),
      .m_ready(s2p_1_ready),
      .m_data (s2p_1_data)
  );

  serial_to_parallel #(
      .OUT_WIDTH(WIDTH_2)
  ) s2p_2 (
      .clk,
      .aresetn,
      .s_valid(s_valid_2),
      .s_ready(s_ready_2),
      .s_data (s_data_2),
      .m_valid(s2p_2_valid),
      .m_ready(s2p_2_ready),
      .m_data (s2p_2_data)
  );

  merge_parallel #(
      .IN_WIDTH_1(WIDTH_1),
      .IN_WIDTH_2(WIDTH_2)
  ) merge (
      .clk,
      .aresetn,
      .s_valid_1(s2p_1_valid),
      .s_ready_1(s2p_1_ready),
      .s_data_1 (s2p_1_data),
      .s_valid_2(s2p_2_valid),
      .s_ready_2(s2p_2_ready),
      .s_data_2 (s2p_2_data),
      .m_valid,
      .m_ready,
      .m_data
  );

endmodule
Обязательно порешайте упражнения.

1. Менее формальное описание можно найти здесь: https://habr.com/ru/articles/572926. Пример реализации можно найти, например, тут: https://github.com/alexforencich/verilog-axis
2. Подробнее о возможностях языка рассказано в курсе Школы синтеза цифровых схем в лекциях 9-15 по адресу https://www.youtube.com/playlist?list=PLi3mfxNhwAi-Jul8__xY9Nhig8DZNxJFZ
4. Почитать подробнее про способы перехода между управляющими сигналами можно, например, тут: https://zipcpu.com/blog/2017/10/20/cdc.html