По результатам написания прошлой статьи у нас получился объемный модуль для реализации функций низкоуровневого управления шиной I2C, который формирует управление линиями SCL/SDA, поддерживает мониторинг шины, ведет передачу и прием данных. В этой статье я предлагаю организовать полноценное вдумчивое тестирование всего что получилось.

Всем заинтересованным - добро пожаловать под кат! ?

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…

Зачем тестировать и что такое тестбенч?

Я решил сделать небольшой вводный рассказ, вдруг мои материалы будут читать абсолютные новички и рассказать, как же тестируется результаты и наработки под ПЛИС. Когда мы пишем Verilog-модуль, мы описываем поведение аппаратной логики - набор регистров и проводов, которые переключаются по фронтам тактового сигнала. Но до загрузки в FPGA или если у нас нет физической платы, осциллографа и реальных устройств на шине - сложно в полной мере оценить работоспособность полученного результата. 

На этом этапе у нас ещё нет оберток, регистровых интерфейсов, прерываний - ничего, кроме голого ядра. И это прекрасно, потому что тестировать нужно именно сейчас, пока модуль изолирован и прост. Если мы найдём ошибки сейчас - исправить её элементарно. Если ошибка всплывет позже, в составе большой системы - придётся разбираться, кто из десятка компонентов виноват.

Поэтому мы моделируем всё окружение заранее программно - в так называемом тестбенче (testbench, сокращённо TB). Тестбенч - это модуль-обёртка, который существует только для целей моделирования. Он не синтезируется в реальное железо. Его задача - подать на входы тестируемого модуля (DUT - Design Under Test) нужные сигналы, дождаться ответов на выходах, и проверить, совпадают ли они с ожиданием.

Тестбенч для i2c_master_core состоит из целого ряда компонентов:

  1. Генератор клока и ena - создаёт тактовый сигнал clk и разрешающий импульс ena_i

  2. Управляющая логика - initial-блок с 10 тестовыми сценариями

  3. DUT - сам i2c_master_core

  4. Две модели slave-устройств - обычный на адресе 0x50 и slave с clock stretching на адресе 0x51

  5. SCL-hold логика - удерживает SCL в LOW для имитации clock stretching

  6. ext_sda_drive - внешний драйвер SDA для имитации потери арбитража;

Выдает результат - вердикт PASS или FAIL для каждого сценария, плюс файл осциллограмм (VCD), который можно открыть в GTKWave и увидеть каждый сигнал по тактам.

В модуль i2c_master_core мы будем подавать прямые команды, сигналы и смотреть, все ли работает корректно:

  1. Single WRITE + ACK

  2. Single READ + NACK (write 0xA5, read back)

  3. Full transaction START→WRITE→WRITE→STOP, проверка busy_o

  4. Repeated START (RESTART), чтение через RESTART

  5. NACK от slave (неправильный адрес) + восстановление

  6. Clock stretching (через отдельный slave на адресе 0x51)

  7. Arbitration lost (внешний интерферер на SDA) + блокировка + сброс

  8. Reset посередине передачи + проверка работоспособности после

  9. CMD_NOP (не меняет состояние)

  10. Последовательное чтение 4 байт (ACK, ACK, ACK, NACK)

Все просто и понятно. Идем дальше.

Исходники, описание и текущие наработки лежат в этом репозитории: https://github.com/megalloid/I2C_Master_Controller

Инструменты которыми воспользуемся. Icarus Verilog

Опишу, то, какими инструментами мы будем пользоваться, при верификации i2c_master_core. Для каждого инструмента: что он делает, как устроен внутри, как установить, как запускать, какие ключи важны, и какие грабли поджидают.

Первый из них -  Icarus Verilog (iverilog + vvp) - симулятор. Icarus Verilog - open-source симулятор языков Verilog и SystemVerilog. Это наш основной инструмент для запуска тестбенчей. Он состоит из двух программ:

  • iverilog - компилятор: читает .v / .sv файлы и собирает бинарный файл .vvp

  • vvp - runtime-движок: исполняет .vvp и генерирует вывод в консоль + файлы осциллограмм (VCD)

Устанавливается очень просто (Ubuntu):

sudo apt install iverilog

Можно установить из исходников:

git clone https://github.com/steveicarus/iverilog.git
cd iverilog
sh autoconf.sh
./configure --prefix=/usr/local
make -j$(nproc)
sudo make install

Полная команда для нашего ядра:  

iverilog -g2012 -Wall -o sim/i2c_core_tb.vvp \
    rtl/i2c_master_core.v \
    tb/i2c_slave_model.sv \
    tb/i2c_core_tb.sv

Будем использовать со следующими ключами:

  • -g2012 - определяет стандарт IEEE 1800-2012 (SystemVerilog). Наши тестбенчи используют logic, always_ff, именованные блоки, begin : label и другие SV-конструкции;

  • -Wall - выводим все предупреждения. Ловит неподключённые порты, несовпадения ширин, неиспользуемые сигналы и т.п;

  • -o <file> - Выходной файл .vvp. Указывает куда положить скомпилированный байткод;

Порядок файлов важен: RTL перед тестбенчем, иначе компилятор может не найти модули, на которые ссылается тестбенч.

Далее можно запускать скомпилированный .vpp. Ключи:

  • -vcd - Принудительно включить VCD-дамп (даже если $dumpfile/$dumpvars не вызваны в коде);

  • -lxt2 - Дамп в формате LXT2 (компактнее VCD);

Команда: 

cd sim && vvp ../sim/i2c_core_tb.vvp

Отдельно отмечу, что в коде $dumpfile("i2c_core_tb.vcd") в тестбенче создаёт файл относительно текущей директории. Если запускать из корня проекта, VCD окажется в корне, а не в sim/. 

Но помимо этого стоит держать в голове, что Icarus Verilog имеет ряд ограничений:

  • Поддержка SystemVerilog неполная: нет interface, class, randomize, covergroup, assertion с property. Для нашего проекта это не проблема - мы используем только процедурный стиль.

  • Производительность ниже коммерческих симуляторов (Questa, VCS) в 10-100 раз на больших дизайнах. Но для нашего ядра (~700 строк RTL) это будет незаметно.

  • Нет встроенного wave-viewer - нужен GTKWave или аналог.

Инструменты которыми воспользуемся. Verilator

Следующий инструмент - это Verilator. Это open-source инструмент двойного назначения, выполняющий статический анализ Verilog/SystemVerilog без симуляции, и который может компилировать RTL в C++/SystemC для очень быстрой симуляции.

В нашем проекте мы будем использовать только lint-режим. Verilator проверяет синтаксис, стилистику, ширины сигналов, мёртвый код и десятки других потенциальных проблем, но без запуска симуляции.

Устанавливается тоже очень просто:

sudo apt install verilator

Можно установить из исходников:

git clone https://github.com/verilator/verilator.git
cd verilator
git checkout stable
autoconf
./configure --prefix=/usr/local
make -j$(nproc)
sudo make install

Полная команда для нашего ядра:  

verilator --lint-only -Wall -Wno-UNUSEDSIGNAL --top-module i2c_master_core \
    rtl/i2c_master_core.v

Будем использовать со следующими ключами:

  • --lint-only - только проверка, без генерации C++

  • -Wall - включить все предупреждения

  • -Wno-UNUSEDSIGNAL - подавить предупреждения о неиспользуемых сигналах (часто ложные в RTL с конфигурируемыми параметрами)

  • --top-module <name> - явно указать top-level модуль (иначе Verilator пытается угадать и может ошибиться)

Есть список ошибок, которые ловит Verilator, а Icarus - нет. Например, несовпадение ширин при присваивании, комбинаторные петли, неиспользуемые биты, знаковые ошибки и так далее. Перед каждой симуляцией лучше всего запускать линтер:

make lint-core && make sim-core

Инструменты которыми воспользуемся. GTKWave

Что такое GTKWave? Это open-source вьюер файлов осциллограмм (VCD, LXT, LXT2, FST, GHW). Это графическое приложение, в котором можно рассмотреть каждый сигнал по тактам - аналог логического анализатора, только для симуляции.

Устанавливается очень просто:

sudo apt install gtkwave

Запускается просто: 

gtkwave sim/i2c_core_tb.vcd

Если VCD-файл ещё не создан - сначала выполните make sim-core. После запуска откроется окно с тремя основными панелями:

Пошаговые действия:

  1. Добавить сигналы. В левой панели (SST - Signal Search Tree) раскройте иерархию: i2c_core_tb → dut → интересующие сигналы. Выделите сигнал и нажмите Append (или перетащите мышью в область диаграмм).

  2. Навигация по времени:

    1. Колёсико мыши - масштаб (zoom in/out)

    2. Средняя кнопка (drag) - перемещение по времени

    3. Клавиши + / - - zoom in / zoom out

    4. Ctrl+Home / Ctrl+End - начало / конец симуляции

  3. Маркеры. Клик левой кнопкой в области диаграмм ставит основной маркер (жёлтая вертикальная линия). Время маркера показано в статусной строке. Это удобно для измерения длительности - поставьте маркер на начало бита, затем на конец, и посмотрите разницу.

  4. Формат отображения. Правый клик на имени сигнала → Data Format:

    1. Hex - для данных (tx_shift_r, dout_o)

    2. Unsigned Decimal - для счётчиков (bit_cnt_r, phase_r)

    3. Binary - для побитового анализа

    4. ASCII - для отладки строковых данных

  5. Группировка. Выделите несколько сигналов → правый клик → Combine Down - объединение в группу. Удобно для шины (SDA + SCL) или FSM (state + phase + bit_cnt).

  6. Поиск переходов. Выберите сигнал, затем:

    1. → (стрелка вправо) - следующий переход (фронт)

    2. ← (стрелка влево) - предыдущий переход

    3. Быстрый поиск конкретного значения: Edit → Find Value → введите значение

  7. Сохранение конфигурации. После настройки сигналов: File → Write Save File → core_debug.gtkw. В следующий раз откройте так: gtkwave sim/i2c_core_tb.vcd core_debug.gtkw

GTKWave восстановит все добавленные сигналы, их порядок, форматы и масштаб.

Make оркестрация

Для упрощения жизни и избавления себя от необходимости запомнинания длинных команд я использую make. Одна команда - один осмысленный шаг:

make sim-core       # Скомпилировать и запустить тесты ядра
make lint-core      # Lint ядра через Verilator
make wave-core      # Скомпилировать, запустить, подсказать как открыть VCD
make sim            # Все симуляции
make lint           # Все lint-проверки
make clean          # Удалить все артефакты

В Makefile инструменты могут задаваться через ?= - можно переопределить извне:

# Использовать другой путь к iverilog
IVERILOG=/opt/iverilog-13/bin/iverilog make sim-core

# Использовать другой симулятор Questa
VSIM=/opt/questa/2024.1/bin/vsim make questa

Типовой workflow:

# 1. Lint - проверить синтаксис за <1 секунды
make lint-core

# 2. Симуляция - запустить тесты
make sim-core

# 3. Если FAIL - открыть осциллограммы
gtkwave sim/i2c_core_tb.vcd

# 4. Исправить RTL, повторить с шага 1

# 5. Всё зелёное - очистить артефакты
make clean

Все для вашего удобства.

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

Возвращаемся к тестированию нашего модуля. 

Интерфейс - наш “пульт управления” модулем

Итак. Прежде чем писать тесты, нужно понять, чем мы “крутим ручки” ядра и какие данные можем подавать. Детально описывать сами интерфейсы не буду, мы это сделали в прошлой статье, разберем набор данных которые будут использоваться в тестах.

Команды

Пять команд которые будем посылать на cmd_i:

  1. CMD_NOP (3'd0) - Не должно ничего происходить (игнорируется) 

  2. CMD_START (3'd1) - Генерирует START-условие на шине;

  3. CMD_WRITE (3'd2) - Передаёт 8 бит из din_i, принимает ACK/NACK от slave

  4. CMD_READ (3'd3) - Принимает 8 бит от slave, отправляет ACK или NACK (зависит от din_i[0])

  5. CMD_STOP (3'd4) - Генерирует STOP-условие

  6. CMD_RESTART (3'd5) - Генерирует повторный START (без промежуточного STOP)

Сигнал ena_i - источник тактирования фаз SCL

Это ключевой сигнал, который определяет скорость I2C. Ядро продвигает свой автомат только на тактах, когда приходит импульсena_i = 1. На всех остальных тактах ядро “стоит”. Каждый бит на I2C шине занимает 4 такта ena_i (4 фазы). Один байт - 9 бит (8 данных + 1 ACK/NACK) = 36 тактов ena_i. Реальный период SCL = 4 x период ena_i

В конечном устройстве ena_i будет генерироваться прескалером - делителем частоты. Но мы ещё не написали прескалер. 

На этом этапе мы создадим простой счётчик прямо в тестбенче:

localparam ENA_DIV = 4;   // ena каждые 4 такта clk

reg [7:0] ena_cnt;
always @(posedge clk or negedge rstn) begin
    if (!rstn) begin
        ena_cnt <= 0;
        ena     <= 0;
    end else begin
        if (ena_cnt == ENA_DIV - 1) begin
            ena_cnt <= 0;
            ena     <= 1;         // Один тик!
        end else begin
            ena_cnt <= ena_cnt + 1;
            ena     <= 0;
        end
    end
end

При clk = 100 МГц и ENA_DIV = 4 получаем ena каждые 40 нс. Период SCL = 4 x 40 нс = 160 нс. Это быстрее реальных 100 кГц (10 мкс), но для моделирования это удобно - тесты завершаются за микросекунды.

Протокол рукопожатия (handshake)

Управление ядром работает по простой схеме.

Это классический ready/valid протокол. Преимущество: тестбенч не привязан к тайминг-деталям ядра. Он просто ждёт ready_o и подаёт следующую команду.

Каркас тестбенча

Для формирования базового каркаса для последующих тестов объявим несколько параметров.

module i2c_core_tb;

// ----- Parameters -----

localparam CLK_PERIOD = 10;                   // 100 МГц
localparam ENA_DIV    = 4;                    // ena каждые 4 такта
localparam [6:0] SLAVE_ADDR     = 7'h50;      // Обычный slave
localparam [6:0] SLAVE_ADDR_STR = 7'h51;      // Slave с clock stretching
localparam       STRETCH_CYCLES = 80;         // Сколько тактов clk держать SCL
localparam       TIMEOUT_LIMIT  = 200_000;    // Защита от зависания

localparam [2:0]
        CMD_NOP     = 3'd0,
        CMD_START   = 3'd1,
        CMD_WRITE   = 3'd2,
        CMD_READ    = 3'd3,
        CMD_STOP    = 3'd4,
        CMD_RESTART = 3'd5;
  • Два адреса slave: обычный 0x50 для тестов 1-5, 7-10 и slave с clock stretching 0x51 для теста 6.

  • TIMEOUT_LIMIT: все ожидания в task-ах защищены таймаутом. Если ядро «зависнет», тест не застрянет навечно - через 200 000 тактов выдаст FAIL.

  • STRETCH_CYCLES = 80: slave удерживает SCL на 80 тактов clk (= 800 нс при 100 МГц).

Объявим все необходимые для тестирования сигналы:

    // ----- Signals -----
    reg        clk, rstn;
    reg        ena;
    reg        cmd_valid;
    reg  [2:0] cmd;
    reg  [7:0] din;
    wire [7:0] dout;
    wire       rx_ack, ready;
    wire       arb_lost, busy;
    reg        arb_lost_clear;
    wire       scl_oen, sda_oen;

Далее всё, что касается I2C шины и open-drain выводов:

    // ----- I2C bus with pull-ups -----
    wire sda, scl;
    pullup (sda);
    pullup (scl);

    assign scl = scl_oen ? 1'bz : 1'b0;
    assign sda = sda_oen ? 1'bz : 1'b0;

    // External interferer for arbitration-lost test
    reg ext_sda_drive;
    assign sda = (ext_sda_drive) ? 1'b0 : 1'bz;

    wire scl_i = scl;
    wire sda_i = sda;

На шине sda работают три устройства:

  1. Мастер (DUT): scl_oen/sda_oen

  2. Slave-модели: внутренний sda_out_en

  3. Интерферер: ext_sda_drive

Все три - open-drain (тянут к 0 или отпускают). Результирующее значение на шине - wired-AND: если хотя бы один тянет к 0, шина = 0.

Основное системное тактирование:

    // ----- Clock -----
    initial clk = 0;
    always #(CLK_PERIOD/2) clk = ~clk;

ENA-генератор: 

    // ----- ENA generator -----
    reg [7:0] ena_cnt;
    always @(posedge clk or negedge rstn) begin
        if (!rstn) begin
            ena_cnt <= 0;
            ena     <= 0;
        end else begin
            if (ena_cnt == ENA_DIV - 1) begin
                ena_cnt <= 0;
                ena     <= 1;
            end else begin
                ena_cnt <= ena_cnt + 1;
                ena     <= 0;
            end
        end
    end

Подключаем DUT:

    // ----- DUT -----
    i2c_master_core dut (
        .clk_i            (clk),
        .rstn_i           (rstn),
        .ena_i            (ena),
        .cmd_valid_i      (cmd_valid),
        .cmd_i            (cmd),
        .din_i            (din),
        .dout_o           (dout),
        .rx_ack_o         (rx_ack),
        .ready_o          (ready),
        .arb_lost_o       (arb_lost),
        .arb_lost_clear_i (arb_lost_clear),
        .busy_o           (busy),
        .scl_i            (scl_i),
        .scl_oen_o        (scl_oen),
        .sda_i            (sda_i),
        .sda_oen_o        (sda_oen)
    );

Подключаем несколько slave-устройства (их возьмите в репозитории):

    // ----- Normal slave (addr 0x50) -----
    i2c_slave_model #(.I2C_ADDR(SLAVE_ADDR)) slave (
        .sda_io (sda),
        .scl_io (scl)
    );

    // ----- Stretching slave (addr 0x51) -----
    i2c_slave_model #(.I2C_ADDR(SLAVE_ADDR_STR)) slave_str (
        .sda_io (sda),
        .scl_io (scl)
    );

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

Далее блок касающийся логики clock stretching. Clock stretching реализован прямо в тестбенче, а не внутри slave-модели:

    // SCL-hold logic for stretching slave
    reg scl_hold;
    assign scl = scl_hold ? 1'b0 : 1'bz;

    integer stretch_cnt;
    initial begin
        scl_hold    = 0;
        stretch_cnt = 0;
    end

    always @(negedge scl) begin
        if (slave_str.state == 4'd2 ||   // S_ADDR_ACK
            slave_str.state == 4'd4 ||   // S_REG_ACK
            slave_str.state == 4'd6) begin // S_WR_ACK
            scl_hold    <= 1;
            stretch_cnt <= STRETCH_CYCLES;
        end
    end

    always @(posedge clk) begin
        if (scl_hold && stretch_cnt > 0)
            stretch_cnt <= stretch_cnt - 1;
        else if (scl_hold && stretch_cnt == 0)
            scl_hold <= 0;
    end

Когда slave_str переходит в состояние ACK (после адреса, регистра или данных), мы захватываем SCL - тянем к 0 на STRETCH_CYCLES тактов. Мастер в это время пытается отпустить SCL, но не может из-за логики wired-AND. Через 80 тактов мы отпускаем SCL, мастер видит scl_i = 1 и продолжает свою работу. Мы подглядываем во внутренний state slave-модели через иерархический путь slave_str.state. Это допустимо в тестбенче - мы не синтезируем этот код.

Введем также счетчики для подсчета успешно пройденных и заваленных тестов:

    // ----- Counters -----
    integer pass_cnt, fail_cnt;

Далее формируем вспомогательные task-и:

    // =====================================================================
    // Helper tasks
    // =====================================================================

    task test_pass(input [80*8-1:0] msg);
        begin
            $display("  PASS: %0s", msg);
            pass_cnt = pass_cnt + 1;
        end
    endtask

    task test_fail(input [80*8-1:0] msg);
        begin
            $display("  FAIL: %0s", msg);
            fail_cnt = fail_cnt + 1;
        end
    endtask

Счётчики pass_cnt и fail_cnt инкрементируются при каждой проверке. В конце теста выводится итог: PASS=N FAIL=M.

Базовый task отправки команды — с защитой от зависания:

    task send_cmd(input [2:0] c, input [7:0] d);
        integer wcnt;
        begin
            @(posedge clk);
            wcnt = 0;
            while (!ready) begin				// 1. Ждём готовности
                @(posedge clk);
                wcnt = wcnt + 1;
                if (wcnt > TIMEOUT_LIMIT) begin
                    test_fail("TIMEOUT waiting for ready before cmd");
                    disable send_cmd;
                end
            end
            cmd       <= c;					// 2. Выставляем команду
            din       <= d;
            cmd_valid <= 1;
            @(posedge clk);
            wcnt = 0;
            while (ready) begin				// 3. Ждём, пока ядро примет
                @(posedge clk);
                wcnt = wcnt + 1;
                if (wcnt > TIMEOUT_LIMIT) begin
                    test_fail("TIMEOUT: ready never fell");
                    cmd_valid <= 0;
                    disable send_cmd;
                end
            end
            cmd_valid <= 0;					// 4. Снимаем запрос
            cmd       <= CMD_NOP;
            wcnt = 0;
            while (!ready) begin				// 5. Ждём завершения
                @(posedge clk);
                wcnt = wcnt + 1;
                if (wcnt > TIMEOUT_LIMIT) begin
                    test_fail("TIMEOUT waiting for ready after cmd");
                    disable send_cmd;
                end
            end
        end
    endtask

Каждое ожидание защищено: если ready не изменится за TIMEOUT_LIMIT тактов, task выходит через disable с сообщением FAIL. Без этого баг в ядре мог бы превратить тест в бесконечный цикл.

Добавим обертки:

    task do_start;
        begin send_cmd(CMD_START, 8'd0); end
    endtask

          
    task do_stop;
        begin send_cmd(CMD_STOP, 8'd0); end
    endtask

          
    task do_restart;
        begin send_cmd(CMD_RESTART, 8'd0); end
    endtask

          
    task do_write(input [7:0] data, output ack);
        begin
            send_cmd(CMD_WRITE, data);
            ack = rx_ack;
        end
    endtask

          
    task do_read(input nack_bit, output [7:0] data);
        begin
            send_cmd(CMD_READ, {7'd0, nack_bit});
            data = dout;
        end
    endtask

Задаем каркас который будем наполнять тестами:

    // =====================================================================
    // Main test sequence
    // =====================================================================
    initial begin

        $dumpfile("i2c_core_tb.vcd");
        $dumpvars(0, i2c_core_tb);

        pass_cnt = 0;
        fail_cnt = 0;
        ext_sda_drive = 0;

        rstn           = 0;
        cmd_valid      = 0;
        cmd            = CMD_NOP;
        din            = 8'd0;
        arb_lost_clear = 0;

        repeat (20) @(posedge clk);
        rstn = 1;
        repeat (20) @(posedge clk);

	    // =============================================================
        // TEST ...
        // =============================================================

    end

И сделаем завершающий блок с подведением итогов и watchdog-секции: 

        // =============================================================
        // SUMMARY
        // =============================================================
        $display("\n========================================");
        $display("  TEST SUMMARY:  PASS=%0d  FAIL=%0d", pass_cnt, fail_cnt);
        if (fail_cnt == 0)
            $display("  All tests PASSED");
        else
            $display("  *** FAILURES DETECTED ***");
        $display("========================================\n");

        $finish;
    end

Добавляем Watchdog. Если вся симуляция займёт больше 200_000 x 10 x 20 = 40 000 000 000 пс = 40 мс модельного времени, watchdog принудительно завершит её. Это защита от бесконечных циклов.

    // Watchdog
    initial begin
        #(TIMEOUT_LIMIT * CLK_PERIOD * 20);
        $display("WATCHDOG: simulation timeout");
        $finish;
    end

endmodule

Перейдем к составлению перечня тестовых кейсов. 

Тест 1. Single WRITE + ACK

Цель: Самый первый и самый простой тест. Убедиться, что ядро может передать один байт и услышать ACK от slave.

Что происходит внутри ядра по тактам: Когда мы подаём CMD_WRITE с din_i = 0xA0 (адресный байт: slave 0x50 + бит записи):

Такт 0 (ena): Ядро защёлкивает команду

    tx_shift_r <= {0xA0, 1'b0} = 9'b_1_0100_000_0
    bit_cnt_r  <= 0
    state_r    <= ST_DATA
    ready_o    <= 0 ← “Я занят”

Далее 9 бит-слотов × 4 фазы = 36 тактов ena:

Бит 0 (MSB = 1):
  Фаза 0: SCL=0, SDA=tx_shift_r[8]=1  (отпустил)
  Фаза 1: SCL=1, семплирование sda_i
  Фаза 2: SCL=1, удержание
  Фаза 3: SCL=0, сдвиг регистра, bit_cnt_r <= 1

...

Бит 8 (ACK-слот):
  Фаза 0: SCL=0, sda_input_mode=1 → sda_oen_o=1 (отпускаем SDA для slave)
  Фаза 1: SCL=1, семплируем sda_i → rx_shift_r[0]
  Фаза 3: bit_cnt_r == 8 → rx_ack_o <= rx_shift_r[0], ready_o <= 1

Итоговый тест получается следующий:

        // =============================================================
        // TEST 1: Single WRITE + ACK
        // =============================================================
        $display("\n=== TEST 1: Single WRITE + ACK ===");
        begin : test1
            reg ack;
            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);

            if (ack == 1'b0)
                test_pass("Slave ACK received (rx_ack_o = 0)");
            else
                test_fail("Expected ACK, got NACK");

            do_stop;
        end

Получается такая осциллограмма:

Необходимо обращать внимание на осциллограмме на следующее:

  • dut.state_r - 0=IDLE, 2=DATA

  • dut.phase_r - 0→1→2→3 в каждом бите

  • dut.bit_cnt_r - 0…8

  • dut.tx_shift_r - сдвиговый регистр, убывает побитно

  • sda, scl - сигналы на шине

  • dut.rx_ack_o - 0=ACK, 1=NACK

  • dut.ready_o - 0 во время работы, 1 по завершении

Что может пойти не так? 

  1. rx_ack_o = 1 (NACK) - sda_input_mode не активируется на 9-м бите → ядро само держит SDA=1

  2. Тайм-аут (ready не поднимается) - bit_cnt_r не доходит до 8

  3. Данные на шине неправильные - tx_shift_r сдвигается не в ту сторону

Тест 2. Single READ + NACK

Цель: Убедиться, что ядро корректно принимает 8 бит данных от slave и отправляет NACK.

Ключевая механика: 

  • При WRITE ядро передаёт (управляет SDA 8 бит, слушает 1 бит ACK);

  • При READ ядро принимает (слушает SDA 8 бит, управляет 1 бит ACK/NACK);

Переключение определяется проводом sda_input_mode:

wire sda_input_mode = (state_r == ST_DATA) && (
    (cmd_r == CMD_READ  && bit_cnt_r < 4'd8) ||
    (cmd_r == CMD_WRITE && bit_cnt_r == 4'd8)
);

tx_shift_r для READ инициализируется как {8'hFF, din_i[0]} - все единицы (отпускаем SDA) + бит ACK/NACK от мастера. din_i[0] = 1 → NACK, din_i[0] = 0 → ACK.

Сначала записываем 0xA5 в ячейку 0x10, потом читаем обратно:

        // =============================================================
        // TEST 2: Single READ + NACK (write 0xA5, read back)
        // =============================================================
        $display("\n=== TEST 2: Single READ + NACK ===");
        begin : test2
            reg ack;
            reg [7:0] rdata;

            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);
            do_write(8'h10, ack);
            do_write(8'hA5, ack);
            do_stop;

            repeat (50) @(posedge clk);

            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);
            do_write(8'h10, ack);
            do_restart;
            do_write({SLAVE_ADDR, 1'b1}, ack);
            do_read(1'b1, rdata);
            do_stop;

            if (rdata === 8'hA5)
                test_pass("Read 0xA5 matches written value");
            else
                test_fail("Read mismatch");
        end

Тут есть одна неочевидная деталь. tx_shift_r[8] одновременно означает и “значение бита на SDA” и “output enable”. Это работает благодаря инверсной логике open-drain: “хочу передать 1” = “не тяну линию” = oen = 1. Для NACK: din_i[0] = 1 → oen = 1 → pull-up → SDA = 1 → NACK.

Тест 3. Полная транзакция START + WRITE addr + WRITE data + STOP.

Цель: Проверить полный цикл записи, отслеживая переходы FSM и флаг busy_o.

Что проверяем: 

  • busy_o устанавливается после START и сбрасывается после STOP

  • Между командами в IDLE ядро не отпускает линии при busy_o = 1

  • Оба байта (адрес + данные) получают ACK от slave

Критичный момент: IDLE между командами

ST_IDLE: begin
    if (cmd_valid_i && !arb_lost_o) begin
        ...
    end else if (!busy_o) begin
        scl_oen_o <= 1'b1;    // Отпускаем только если шина свободна
        sda_oen_o <= 1'b1;
    end
    // busy_o=1 → линии не трогаем!
end

Если бы ядро отпустило SDA при SCL=1 внутри транзакции, slave увидел бы ложный STOP.

Код теста:

// =============================================================
// TEST 3: Full transaction START + WRITE + WRITE + STOP
// =============================================================
$display("\n=== TEST 3: Full transaction ===");
begin : test3
    reg ack1, ack2;

    do_start;

    if (busy !== 1'b1)
        test_fail("busy_o should be 1 after START");

    do_write({SLAVE_ADDR, 1'b0}, ack1);
    if (ack1 !== 1'b0)
        test_fail("Expected ACK on address byte");

    do_write(8'h42, ack2);
    if (ack2 !== 1'b0)
        test_fail("Expected ACK on data byte");

    do_stop;

    repeat (10) @(posedge clk);
    if (busy !== 1'b0)
        test_fail("busy_o should be 0 after STOP");
    else
        test_pass("Full transaction OK, busy cleared");
end

Тест 4. Repeated START (RESTART)

Цель: Проверить генерацию повторного START без освобождения шины.

Напомню, чем RESTART отличается от START:

RESTART:                              START:
  Фаза 0: SDA=1, SCL=0                  Фаза 0: SDA=1, SCL=1 (ждём scl_i)
  Фаза 1: SDA=1, SCL=1 (ждём)           Фаза 1: SDA=1, SCL=1 (удержание)
  Фаза 2: SDA=0, SCL=1 (START!)         Фаза 2: SDA=0, SCL=1 (START!)
  Фаза 3: SDA=0, SCL=0                  Фаза 3: SDA=0, SCL=0

START начинает с обоих линий HIGH (шина свободна). RESTART начинает с SCL=0 (мы только что передавали данные) - сначала поднимает SDA, потом отпускает SCL.

Код теста:

// =============================================================
// TEST 4: Repeated START (RESTART)
// =============================================================

$display("\n=== TEST 4: Repeated START (RESTART) ===");
begin : test4
    reg ack;
    reg [7:0] rdata;

    // Записываем 0xBE в ячейку 0x20
    do_start;
    do_write({SLAVE_ADDR, 1'b0}, ack);
    do_write(8'h20, ack);
    do_write(8'hBE, ack);
    do_stop;

    repeat (50) @(posedge clk);

    // Читаем обратно через RESTART
    do_start;
    do_write({SLAVE_ADDR, 1'b0}, ack);
    do_write(8'h20, ack);
    do_restart;

    if (busy !== 1'b1)
        test_fail("busy_o dropped during RESTART");

    do_write({SLAVE_ADDR, 1'b1}, ack);
    do_read(1'b1, rdata);
    do_stop;

    if (rdata === 8'hBE)
        test_pass("RESTART read-back OK");
    else
        test_fail("RESTART read-back mismatch");
end

Ключевая проверка заключается в том, что на осциллограмме busy_o должен быть непрерывной “1” от первого START до финального STOP. Если busy_o мигнёт в 0 - значит, ядро сгенерировало ложный STOP.

Тест 5. NACK от Slave + восстановление

Цель: Убедиться, что ядро фиксирует NACK, не зависает, и нормально работает после этого.

Код теста:

// =============================================================
// TEST 5: NACK from slave (wrong address) + recovery
// =============================================================
$display("\n=== TEST 5: NACK from slave ===");
begin : test5
    reg ack;

    do_start;
    do_write({7'h3F, 1'b0}, ack);         // Адрес 0x3F — нет такого slave

    if (ack === 1'b1)
        test_pass("Got NACK for nonexistent address 0x3F");
    else
        test_fail("Expected NACK, got ACK for 0x3F");

    do_stop;

    repeat (10) @(posedge clk);
    if (busy !== 1'b0)
        test_fail("busy_o not cleared after NACK + STOP");

    // Восстановление: правильный адрес после NACK
    do_start;
    do_write({SLAVE_ADDR, 1'b0}, ack);
    if (ack === 1'b0)
        test_pass("Normal ACK after NACK recovery");
    else
        test_fail("Controller stuck after NACK");
    do_stop;
end 

Тест проверяет две вещи: NACK на неправильном адресе и корректную работу после NACK + STOP. Это важно - ядро не должно «застревать» после ошибочной адресации.

Тест 6. Clock stretching

Цель: Убедиться, что ядро корректно ожидает, когда slave удерживает SCL в 0.

Как это работает в тестбенче: Вместо обычного slave (0x50) тест использует slave на адресе 0x51 (SLAVE_ADDR_STR). После каждого ACK от slave_str, SCL-hold логика тянет SCL к 0 на 80 тактов. Ядро отпускает SCL (scl_oen = 1), но scl_i = 0 (slave держит). Ядро ждёт в фазе 1 пока scl_i не станет 1.

Код теста:

// =============================================================
// TEST 6: Clock stretching (via stretching slave at 0x51)
// =============================================================
$display("\n=== TEST 6: Clock stretching ===");
begin : test6
    reg ack;
    reg [7:0] rdata;

    do_start;
    do_write({SLAVE_ADDR_STR, 1'b0}, ack);    // Slave 0x51
    if (ack !== 1'b0) begin
        test_fail("Stretching slave NACK on address");
    end else begin
        do_write(8'h30, ack);
        do_write(8'hCD, ack);
        do_stop;

        repeat (50) @(posedge clk);

        // Читаем обратно
        do_start;
        do_write({SLAVE_ADDR_STR, 1'b0}, ack);
        do_write(8'h30, ack);
        do_restart;
        do_write({SLAVE_ADDR_STR, 1'b1}, ack);
        do_read(1'b1, rdata);
        do_stop;

        if (rdata === 8'hCD)
            test_pass("Clock stretching handled OK");
        else
            test_fail("Data corrupted after stretching");
    end
end

Обратите внимание на if/else - если stretching-slave не ответит ACK на свой адрес, тест не будет пытаться продолжать (записывать/читать), а сразу зафиксирует FAIL.

Тест 7. Arbitration lost

Цель: Убедиться, что ядро обнаруживает конфликт на шине и немедленно отпускает её.

Как будем имитировать потерю арбитража: Используем ext_sda_drive - когда он = 1, SDA принудительно тянется к 0. Если ядро в этот момент отпустило SDA (ожидает 1), оно увидит sda_i = 0 → арбитраж потерян.

Тест 7 - самый сложный, потому что нужно “вмешаться” в нужный момент. Тест выдаёт 4 проверки: обнаружение, освобождение шины, блокировка команд, сброс флага. В send_cmd при таймауте используется disable send_cmd. Но в тесте 7 мы управляем командами вручную (не через send_cmd), поэтому используем disable test7 - это выход из всего блока begin : test7 ... end. Каждый while-цикл обёрнут в именованный блок с собственным счётчиком wc.

Код теста:

    // =============================================================
    // TEST 7: Arbitration lost
    // =============================================================
    $display("\n=== TEST 7: Arbitration lost ===");
        begin : test7
            do_start;

            // Issue WRITE command manually to catch the right moment
            cmd       <= CMD_WRITE;
            din       <= {SLAVE_ADDR, 1'b0};   // 0xA0, MSB=1
            cmd_valid <= 1;
            @(posedge clk);
            begin : test7_wait_accept
                integer wc;
                wc = 0;
                while (ready) begin
                    @(posedge clk);
                    wc = wc + 1;
                    if (wc > TIMEOUT_LIMIT) begin
                        test_fail("TIMEOUT waiting for core to accept WRITE");
                        disable test7;
                    end
                end
            end
            cmd_valid <= 0;

            // Wait for DATA state phase 0 (core sets up SDA)
            begin : test7_wait_data
                integer wc;
                wc = 0;
                while (!(dut.state_r == 3'd2 && dut.phase_r == 2'd0)) begin
                    @(posedge clk);
                    wc = wc + 1;
                    if (wc > TIMEOUT_LIMIT) begin
                        test_fail("TIMEOUT waiting for DATA phase 0");
                        disable test7;
                    end
                end
            end
            @(posedge clk);

            // Interfere: pull SDA low externally
            ext_sda_drive <= 1;

            begin : test7_wait_arb
                integer wc;
                wc = 0;
                while (arb_lost !== 1'b1) begin
                    @(posedge clk);
                    wc = wc + 1;
                    if (wc > TIMEOUT_LIMIT) begin
                        test_fail("TIMEOUT waiting for arb_lost");
                        ext_sda_drive <= 0;
                        disable test7;
                    end
                end
            end
            ext_sda_drive <= 0;

            if (arb_lost === 1'b1)
                test_pass("Arbitration lost detected");
            else
                test_fail("Arbitration lost NOT detected");

            if (dut.scl_oen_o === 1'b1 && dut.sda_oen_o === 1'b1)
                test_pass("Bus released after arb_lost");
            else
                test_fail("Bus NOT released after arb_lost");

            // Core should ignore commands while arb_lost=1
            cmd_valid <= 1;
            cmd       <= CMD_START;
            repeat (20) @(posedge clk);
            if (ready === 1'b1)
                test_pass("Core ignores commands while arb_lost=1");
            else
                test_fail("Core accepted command despite arb_lost=1");
            cmd_valid <= 0;
            cmd       <= CMD_NOP;

            // Clear arb_lost
            arb_lost_clear <= 1;
            @(posedge clk);
            arb_lost_clear <= 0;
            repeat (5) @(posedge clk);

            if (arb_lost === 1'b0)
                test_pass("arb_lost cleared");
            else
                test_fail("arb_lost NOT cleared");

            do_stop;
        end

Тест 8. Reset во время транзакции

Цель: убедиться, что аппаратный сброс возвращает ядро в начальное состояние из середины передачи, и после сброса ядро работает нормально.

Тонкость: sda_d_r сбрасывается в 1. Регистр sda_d_r (задержанная копия sda_i) сбрасывается в 1, а не в 0. Это исключает ложные фронты на SDA после снятия сброса: sda_rising = sda_i & ~sda_d_r. Если бы sda_d_r = 0 и sda_i = 1 (pull-up), то sda_rising = 1 при scl_i = 1 → ложный STOP → некорректный busy_o.

Код теста:


        // =============================================================
        // TEST 8: Reset during transaction
        // =============================================================
        $display("\n=== TEST 8: Reset during transaction ===");
        begin : test8
            reg ack;
            reg [7:0] rdata;

            do_start;

            cmd       <= CMD_WRITE;
            din       <= {SLAVE_ADDR, 1'b0};
            cmd_valid <= 1;
            @(posedge clk);
            begin : test8_wait
                integer wc;
                wc = 0;
                while (ready) begin
                    @(posedge clk);
                    wc = wc + 1;
                    if (wc > TIMEOUT_LIMIT) begin
                        test_fail("TIMEOUT in reset test setup");
                        disable test8;
                    end
                end
            end
            cmd_valid <= 0;

            // Let 3-4 bits transmit
            repeat (4) begin : test8_bits
                integer wc;
                wc = 0;
                while (dut.phase_r != 2'd3) begin
                    @(posedge clk);
                    wc = wc + 1;
                    if (wc > TIMEOUT_LIMIT) begin
                        test_fail("TIMEOUT waiting for phase 3");
                        disable test8;
                    end
                end
                @(posedge clk);
            end

            // Assert reset
            rstn <= 0;
            repeat (10) @(posedge clk);
            rstn <= 1;
            repeat (20) @(posedge clk);

            // Check post-reset state
            if (dut.state_r !== 3'd0)
                test_fail("state_r not IDLE after reset");
            if (dut.scl_oen_o !== 1'b1 || dut.sda_oen_o !== 1'b1)
                test_fail("Bus not released after reset");
            if (ready !== 1'b1)
                test_fail("ready_o not 1 after reset");
            if (busy !== 1'b0)
                test_fail("busy_o not 0 after reset");
            if (arb_lost !== 1'b0)
                test_fail("arb_lost_o not 0 after reset");

            // Verify core works after reset
            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);
            if (ack !== 1'b0)
                test_fail("NACK after reset — controller broken");

            do_write(8'h70, ack);
            do_write(8'hEE, ack);
            do_stop;

            repeat (50) @(posedge clk);

            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);
            do_write(8'h70, ack);
            do_restart;
            do_write({SLAVE_ADDR, 1'b1}, ack);
            do_read(1'b1, rdata);
            do_stop;

            if (rdata === 8'hEE)
                test_pass("Post-reset write/read OK");
            else
                test_fail("Post-reset data mismatch");
        end

Тест 9. CMD_NOP

Цель: Убедиться, что NOP не вызывает никаких побочных эффектов.

Код теста: 

      // =============================================================
      // TEST 9: CMD_NOP does nothing
      // =============================================================
      $display("\n=== TEST 9: CMD_NOP ===");
      begin : test9
            // NOP is ignored: ready stays 1, state stays IDLE
            @(posedge clk);
            cmd       <= CMD_NOP;
            din       <= 8'hFF;
            cmd_valid <= 1;
            repeat (20) @(posedge clk);
            cmd_valid <= 0;
            cmd       <= CMD_NOP;

            if (dut.state_r === 3'd0 && ready === 1'b1)
                test_pass("NOP: state stayed IDLE, ready=1");
            else
                test_fail("NOP: unexpected state change");
      end

NOP не проходит через send_cmd, потому что send_cmd ждёт падения ready. Но NOP - это “ничего не делать”, ядро его игнорирует, ready никогда не упадёт. Поэтому тут мы вручную держим cmd_valid = 1 с CMD_NOP 20 тактов и проверяем, что ничего не изменилось.

Тест 10. Последовательное чтение (4 байта)

Цель: Проверить, что ядро корректно выполняет серию READ с ACK, завершая NACK-ом.

Slave-модель при инициализации заполняет память mem[i] = i. Поэтому чтение с адреса 0x00 должно вернуть 0x00, 0x01, 0x02, 0x03. При ошибке тест выводит реально полученные значения - удобно для диагностики.

Код теста:

      // =============================================================
      // TEST 10: Sequential read (4 bytes)
      // =============================================================
      $display("\n=== TEST 10: Sequential read (4 bytes) ===");
      begin : test10
            reg ack;
            reg [7:0] r0, r1, r2, r3;
            integer seq_ok;

            // Slave memory is initialized as mem[i] = i.
            // Read from address 0x00.
            do_start;
            do_write({SLAVE_ADDR, 1'b0}, ack);
            do_write(8'h00, ack);
            do_restart;
            do_write({SLAVE_ADDR, 1'b1}, ack);

            do_read(1'b0, r0);  // ACK
            do_read(1'b0, r1);  // ACK
            do_read(1'b0, r2);  // ACK
            do_read(1'b1, r3);  // NACK

            do_stop;

            seq_ok = (r0 === 8'h00) && (r1 === 8'h01) &&
                     (r2 === 8'h02) && (r3 === 8'h03);

            if (seq_ok)
                test_pass("Sequential read 00,01,02,03 OK");
            else begin
                $display("    got: %02h %02h %02h %02h", r0, r1, r2, r3);
                test_fail("Sequential read mismatch");
            end
      end

А как запустить?

Берем тесты с репозитория https://github.com/megalloid/I2C_Master_Controller или руками заполняем тесты и запускаем:

make sim-core

...или...

mkdir -p sim
iverilog -g2012 -Wall -o sim/i2c_core_tb.vvp \
    rtl/i2c_master_core.v \
    tb/i2c_slave_model.sv \
    tb/i2c_core_tb.sv
cd sim && vvp ../sim/i2c_core_tb.vvp

Можно провести Lint-проверку ядра:

make lint-core

После этого можно еще и просмотреть временные диаграммы через GTK Wave:

gtkwave sim/i2c_core_tb.vcd

Сигналы накидываются во вьювер самостоятельно: 

Основные сигналы которые рекомендуются к просмотру для отладки и отсмотра:

  • Шина - sda, scl

  • Управление - cmd_valid, cmd, din, ready, ena

  • Результат - dout, rx_ack

  • Статус - busy, arb_lost

  • FSM (DUT) - dut.state_r, dut.phase_r, dut.bit_cnt_r

  • Сдвиговые - dut.tx_shift_r, dut.rx_shift_r

  • Open-drain - dut.scl_oen_o, dut.sda_oen_o

  • Slave - slave.state, slave.sr, slave.bcnt, slave.sda_out_en

  • Stretching - scl_hold, stretch_cnt, slave_str.state

  • Арбитраж - ext_sda_drive, arb_lost

В итоге после запуска будет ожидаемый ввод:

=== TEST 1: Single WRITE + ACK ===
  PASS: Slave ACK received (rx_ack_o = 0)

=== TEST 2: Single READ + NACK ===
  PASS: Read 0xA5 matches written value

=== TEST 3: Full transaction ===
  PASS: Full transaction OK, busy cleared

=== TEST 4: Repeated START (RESTART) ===
  PASS: RESTART read-back OK

=== TEST 5: NACK from slave ===
  PASS: Got NACK for nonexistent address 0x3F
  PASS: Normal ACK after NACK recovery

=== TEST 6: Clock stretching ===
  PASS: Clock stretching handled OK

=== TEST 7: Arbitration lost ===
  PASS: Arbitration lost detected
  PASS: Bus released after arb_lost
  PASS: Core ignores commands while arb_lost=1
  PASS: arb_lost cleared

=== TEST 8: Reset during transaction ===
  PASS: Post-reset write/read OK

=== TEST 9: CMD_NOP ===
  PASS: NOP: state stayed IDLE, ready=1

=== TEST 10: Sequential read (4 bytes) ===
  PASS: Sequential read 00,01,02,03 OK

========================================
  TEST SUMMARY:  PASS=14  FAIL=0
  All tests PASSED
========================================

Все тесты закончены. Ура! =)

В качестве заключения

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

  1. Написать прескалер - делитель частоты, генерирующий ena_i из системного клока.
    Формула: f_SCL = f_CLK / (4 × (PRESCALE + 1))

  2. Написать регистровую обёртку - набор memory-mapped регистров, через которые софт или процессор будут управлять ядром (CTRL, STATUS, CMD, TX_DATA, RX_DATA, PRESCALE)

  3. Написать секвенсер - логику составных команд (например, “START + WRITE” одним регистровым доступом);

  4. Написать тестбенч для всей системы - уже через регистровый интерфейс.

Фундамент - проверен. Можно строить дальше. Об этом и многом другом - в остальных статьях расскажу позже.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.

Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (0)