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

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Зачем тестировать и что такое тестбенч?
Я решил сделать небольшой вводный рассказ, вдруг мои материалы будут читать абсолютные новички и рассказать, как же тестируется результаты и наработки под ПЛИС. Когда мы пишем Verilog-модуль, мы описываем поведение аппаратной логики - набор регистров и проводов, которые переключаются по фронтам тактового сигнала. Но до загрузки в FPGA или если у нас нет физической платы, осциллографа и реальных устройств на шине - сложно в полной мере оценить работоспособность полученного результата.
На этом этапе у нас ещё нет оберток, регистровых интерфейсов, прерываний - ничего, кроме голого ядра. И это прекрасно, потому что тестировать нужно именно сейчас, пока модуль изолирован и прост. Если мы найдём ошибки сейчас - исправить её элементарно. Если ошибка всплывет позже, в составе большой системы - придётся разбираться, кто из десятка компонентов виноват.
Поэтому мы моделируем всё окружение заранее программно - в так называемом тестбенче (testbench, сокращённо TB). Тестбенч - это модуль-обёртка, который существует только для целей моделирования. Он не синтезируется в реальное железо. Его задача - подать на входы тестируемого модуля (DUT - Design Under Test) нужные сигналы, дождаться ответов на выходах, и проверить, совпадают ли они с ожиданием.
Тестбенч для i2c_master_core состоит из целого ряда компонентов:
Генератор клока и
ena- создаёт тактовый сигналclkи разрешающий импульсena_iУправляющая логика - initial-блок с 10 тестовыми сценариями
DUT - сам
i2c_master_coreДве модели slave-устройств - обычный на адресе 0x50 и slave с clock stretching на адресе 0x51
SCL-hold логика - удерживает SCL в LOW для имитации clock stretching
ext_sda_drive- внешний драйвер SDA для имитации потери арбитража;
Выдает результат - вердикт PASS или FAIL для каждого сценария, плюс файл осциллограмм (VCD), который можно открыть в GTKWave и увидеть каждый сигнал по тактам.
В модуль i2c_master_core мы будем подавать прямые команды, сигналы и смотреть, все ли работает корректно:
Single WRITE + ACK
Single READ + NACK (write 0xA5, read back)
Full transaction START→WRITE→WRITE→STOP, проверка busy_o
Repeated START (RESTART), чтение через RESTART
NACK от slave (неправильный адрес) + восстановление
Clock stretching (через отдельный slave на адресе 0x51)
Arbitration lost (внешний интерферер на SDA) + блокировка + сброс
Reset посередине передачи + проверка работоспособности после
CMD_NOP (не меняет состояние)
Последовательное чтение 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 файлы и собирает бинарный файл .vvpvvp- 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. После запуска откроется окно с тремя основными панелями:

Пошаговые действия:
Добавить сигналы. В левой панели (SST - Signal Search Tree) раскройте иерархию: i2c_core_tb → dut → интересующие сигналы. Выделите сигнал и нажмите Append (или перетащите мышью в область диаграмм).
-
Навигация по времени:
Колёсико мыши - масштаб (zoom in/out)
Средняя кнопка (drag) - перемещение по времени
Клавиши + / - - zoom in / zoom out
Ctrl+Home / Ctrl+End - начало / конец симуляции
Маркеры. Клик левой кнопкой в области диаграмм ставит основной маркер (жёлтая вертикальная линия). Время маркера показано в статусной строке. Это удобно для измерения длительности - поставьте маркер на начало бита, затем на конец, и посмотрите разницу.
-
Формат отображения. Правый клик на имени сигнала → Data Format:
Hex - для данных (tx_shift_r, dout_o)
Unsigned Decimal - для счётчиков (bit_cnt_r, phase_r)
Binary - для побитового анализа
ASCII - для отладки строковых данных
Группировка. Выделите несколько сигналов → правый клик → Combine Down - объединение в группу. Удобно для шины (SDA + SCL) или FSM (state + phase + bit_cnt).
-
Поиск переходов. Выберите сигнал, затем:
→ (стрелка вправо) - следующий переход (фронт)
← (стрелка влево) - предыдущий переход
Быстрый поиск конкретного значения: Edit → Find Value → введите значение
Сохранение конфигурации. После настройки сигналов: 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:
CMD_NOP (
3'd0) - Не должно ничего происходить (игнорируется)CMD_START (
3'd1) - Генерирует START-условие на шине;CMD_WRITE (
3'd2) - Передаёт 8 бит из din_i, принимает ACK/NACK от slaveCMD_READ (
3'd3) - Принимает 8 бит от slave, отправляет ACK или NACK (зависит отdin_i[0])CMD_STOP (
3'd4) - Генерирует STOP-условие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 работают три устройства:
Мастер (DUT): scl_oen/sda_oen
Slave-модели: внутренний sda_out_en
Интерферер: 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 по завершении
Что может пойти не так?
rx_ack_o = 1 (NACK) - sda_input_mode не активируется на 9-м бите → ядро само держит SDA=1
Тайм-аут (ready не поднимается) - bit_cnt_r не доходит до 8
Данные на шине неправильные - 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 можно считать проверенным на базовом уровне. Можно переходить к следующему шагу проектирования для реализации вышестоящих модулей:
Написать прескалер - делитель частоты, генерирующий
ena_iиз системного клока.
Формула:f_SCL = f_CLK / (4 × (PRESCALE + 1))Написать регистровую обёртку - набор memory-mapped регистров, через которые софт или процессор будут управлять ядром (
CTRL, STATUS, CMD, TX_DATA, RX_DATA, PRESCALE)Написать секвенсер - логику составных команд (например, “START + WRITE” одним регистровым доступом);
Написать тестбенч для всей системы - уже через регистровый интерфейс.
Фундамент - проверен. Можно строить дальше. Об этом и многом другом - в остальных статьях расскажу позже.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.
