По результатам написания прошлой статьи у нас получился объемный модуль для реализации функций низкоуровневого управления шиной 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=0START начинает с обоих линий 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");
endNOP не проходит через 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% при первом пополнении.

























