Я продолжаю описывать создание I2C-контроллера на Verilog. В предыдущих статьях мы протестировали ядро контроллера который выполняет атомарные функции работы с шиной в т.ч. в пограничных ситуациях типа clock stretching и пр. Теперь необходимо разработать управляющий контроллер для этого ядра, чтобы выполнять необходимые нам функции, но уже на следующем уровне абстракции и стать на шаг ближе к нашей цели - к рабочему коду I2C Controller который мы будем использовать с EEPROM, а в последующих статьях все это переиспользуем в Zynq и подключим к Linux.

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

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

На чем мы там остановились…

На первом шаге проекта мы написали i2c_master_core - ядро I2C мастер-контроллера - и проверили его в симуляции. Десять тестов в Icarus Verilog подтвердили: FSM переключает состояния правильно, биты на шине формируются корректно, ACK/NACK обрабатываются, clock stretching и арбитраж работают. Казалось бы, дело сделано.

Но симуляция - это модель. Реальная микросхема FPGA, реальная шина I2C с физическими проводниками, реальная микросхема EEPROM - всё это вносит факторы, которых не было в симуляции: задержки распространения сигналов, ёмкость шины, реальные pull-up резисторы, электрические помехи, нюансы тайминга конкретной EEPROM. Единственный способ убедиться, что контроллер работает на железе - загрузить его в FPGA и подключить к настоящему I2C устройству.

Для этого нужна тестовая оболочка (test shell) - небольшой проект, который оборачивает голое ядро i2c_master_core во всё необходимое для работы на конкретной плате: генератор тактирования, управление кнопками, индикация результатов на дисплее и светодиодах. Тестовая оболочка в данном случае не является финальной точкой, это будет очередным инструметом разработчика и демонстрационным стендом для проверки и подтверждения что ядро действительно работает на железе.

Проверять будем на плате Alinx AX301 - это учебная плата с ПЛИС на базе Intel (Altera) Cyclone IV EP4CE6F17C8. Это одна из самых доступных и распространённых плат для обучения FPGA-разработке, обзор на нее я делал в одной из своих прошлых статей. И на борту есть всё необходимое для нашего теста.

Что понадобится на плате, перечислю списком:

  • Cyclone IV FPGA. Там будет вся основная логика, без комментариев;

  • 50 МГц кварцевый осциллятор. Системное тактирование, будет использован в качестве источника клока для всех модулей;

  • 24LC04 EEPROM. Используется в качестве цели для тестирования, как I2C slave-устройства, записываем и читаем байты;

  • 7-сегментный дисплей из 6 разрядов. Отображение результатов тестов, с номером, pass/fail. Два из семи разрядов не используется, потому что они подключены к тем же выводам что и EEPROM.

  • 4 светодиода будут использоваться в качестве индикаторов успешных тестов, дублирует статус.

  • Кнопка KEY1 будет использоваться для запуска и перезапуска тестов;

  • Кнопка RESET будет использоваться для аппаратного сброса, для возврата всего в начальное состояние.

Помимо этого нужен USB Blaster (JTAG) для загрузки прошивки. 

Теперь чуть подробнее взглянем на slave-устройство EEPROM 24LC04. Это энергонезависимая память на 512 байт (4 Кбит) от Microchip с I2C интерфейсом. 

Ключевые характеристики:

  • Объем памяти 512 байт (2 блока по 256 байт);

  • Адрес устройства - 0xA0 для записи, 0xA1 для чтения;

  • Максимальная частота SCL - 400 кГц;

  • Время записи одного байта - до 5 мс;

  • Напряжение питания 2.5-5.5 V;

Особенность 24LC04 - цикл записи. После того как мастер отправил STOP, EEPROM внутренне записывает данные в ячейку. Этот процесс занимает до 5 мс, и в течение этого времени микросхема не отвечает на I2C запросы (NACK). Наш тестовый контроллер учитывает это: после записи он выжидает 6 мс перед чтением.

Протокол обмена с 24LC04 для записи одного байта:

Протокол для чтения одного байта (random read):

Обратите внимание: для чтения из определённого адреса нужно сначала “установить указатель” записью адреса ячейки, а затем выполнить переключение направления через RESTART и повторную адресацию с битом чтения (0xA1 вместо 0xA0). 

Общая архитектура тестовой оболочки

Тестовая оболочка состоит из пяти модулей. Каждый решает свою задачу и общается с остальными через чётко определённые интерфейсы:

Поток данных, то есть как информация проходит через систему при одном нажатии кнопки:

Разберем код

Теперь разберем внутреннюю механику из которой построен проект. Итоговая иерархия модулей выглядит следующим образом:

i2c_test_top                           ← top-level
├── прескалер (встроен в top)          ← генерация ena для ядра
├── ax_debounce                        ← подавление дребезга кнопки
├── i2c_master_core                    ← I2C ядро (из rtl/)
├── i2c_test_ctrl                      ← FSM управления тестами
└── seg_scan                           ← мультиплексор дисплея

В проекте top-level файл - i2c_test_top.v. Он не содержит собственной логики обработки данных, а выполняет роль “скелета”: объявляет внешние порты FPGA, создает прескалер и соединяет четыре подмодуля между собой. Разберем каждый блок.

Объявляем модуль и порты:

module i2c_test_top (
    input  wire       clk,         // 50 MHz
    input  wire       rst_n,       // Active-low reset (active-low button)
    input  wire       key1,        // Start button (active-low, active = pressed)
    output wire [3:0] led,         // 4 LEDs
    output wire [5:0] seg_sel,     // 7-segment digit select (active-low)
    output wire [7:0] seg_data,    // 7-segment data (active-low, bit 7 = DP)
    inout  wire       i2c_sda,     // I2C data (open-drain)
    inout  wire       i2c_scl      // I2C clock (open-drain)
);

endmodule

Каждый порт в этом списке соответствует физическому пину (или группе пинов) FPGA, привязанному через .qsf-файл, который мы сформируем в конце статьи когда будем создавать проект:

# Clock 50 MHz
set_location_assignment PIN_E1  -to clk

# Reset (active-low push button)
set_location_assignment PIN_N13 -to rst_n

# Start button (active-low)
set_location_assignment PIN_M15 -to key1

# LEDs
set_location_assignment PIN_E10 -to led[0]
set_location_assignment PIN_F9  -to led[1]
set_location_assignment PIN_C9  -to led[2]
set_location_assignment PIN_D9  -to led[3]

# I2C bus (24LC04 EEPROM)
set_location_assignment PIN_D1  -to i2c_scl
set_location_assignment PIN_E6  -to i2c_sda

# 7-segment display: digit select (active-low, 6 digits)
set_location_assignment PIN_M11 -to seg_sel[0]
set_location_assignment PIN_P11 -to seg_sel[1]
set_location_assignment PIN_N11 -to seg_sel[2]
set_location_assignment PIN_M10 -to seg_sel[3]
set_location_assignment PIN_P9 -to seg_sel[4]
set_location_assignment PIN_N9 -to seg_sel[5]

# 7-segment display: segment data (active-low, bit 7 = DP)
set_location_assignment PIN_R14 -to seg_data[0]
set_location_assignment PIN_N16 -to seg_data[1]
set_location_assignment PIN_P16 -to seg_data[2]
set_location_assignment PIN_T15 -to seg_data[3]
set_location_assignment PIN_P15 -to seg_data[4]
set_location_assignment PIN_N12 -to seg_data[5]
set_location_assignment PIN_N15 -to seg_data[6]
set_location_assignment PIN_R16 -to seg_data[7]

Ключевое слово inout для I2C-линий означает двунаправленный порт. В Quartus синтезатор превращает его в триаду: входной буфер, выходной буфер и сигнал output-enable. Для input/output портов направление фиксировано, и синтезатор конфигурирует IO-ячейку FPGA соответственно.

Все выходы объявлены как wire (не reg), потому что они управляются подмодулями через непрерывные соединения, а не присваиваются в always-блоках top-level модуля.

Прескалер. Генерация тактирования для I2C ядра

I2C ядро i2c_master_core продвигает свой автомат только на тактах, когда вход ena_i = 1. Один импульс ena соответствует четверти периода SCL - за 4 импульса ena происходит один полный цикл SCL (LOW-HIGH-HIGH-LOW). Чтобы получить SCL на нужной частоте, нужно подавать ena с определённой периодичностью.

Стандартный режим I2C - 100 кГц. Период SCL = 10 мкс. Четверть периода = 2.5 мкс. При тактовой частоте FPGA 50 МГц (период 20 нс) нужен один импульс ena каждые 2500 нс / 20 нс = 125 тактов.

Формула:   PRESCALE = f_CLK / (4 × f_SCL) − 1 = 50 000 000 / (4 × 100 000) − 1 = 124

localparam вместо parameter в объявлении PRESCALE - осознанный выбор. parameter можно переопределить при инстанциации модуля (#(...)), а localparam - нельзя. Значение прескалера жёстко зафиксировано, потому что на реальном железе оно определяется парой “тактовая частота + целевая частота SCL”. Менять его извне бессмысленно - тактовая частота платы фиксирована.

Прескалер - это обычный счётчик с обратным отсчётом, который мы поместим в top-level модуль:

// ---------------------------------------------------------------
// Prescaler for I2C core
//   SCL = 50 MHz / (4 × (PRESCALE+1)) = 100 kHz
// ---------------------------------------------------------------

localparam [15:0] PRESCALE = 16'd124;

reg [15:0] pre_cnt;
reg        core_ena;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        pre_cnt  <= 16'd0;
        core_ena <= 1'b0;
    end else if (pre_cnt == 16'd0) begin
        pre_cnt  <= PRESCALE;       // перезагрузить: 124
        core_ena <= 1'b1;           // импульс!
    end else begin
        pre_cnt  <= pre_cnt - 16'd1;
        core_ena <= 1'b0;
    end
end

Каждые 125 тактов (2.5 мкс) ядро получает один ena-импульс. Четыре таких импульса образуют один бит на шине. 9 бит (8 данных + 1 ACK) x 4 фазы = 36 импульсов = один байт. Время передачи одного байта: 36 × 2.5 мкс = 90 мкс.

В симуляции мы использовали ENA_DIV = 4 для ускорения. На реальном железе используется PRESCALE = 124 для получения стандартных 100 кГц SCL. Временная диаграмма выглядит следующим образом:

Подавление дребезга кнопки. Модуль ax_debounce

Классическая история - фильтрация шума с механической кнопки. Механическая кнопка - это два кусочка металла, которые при нажатии соударяются. В момент контакта происходит дребезг (bouncing): контакт многократно замыкается и размыкается за несколько миллисекунд. FPGA на частоте 50 МГц “видит” каждое размыкание как отдельное событие. Без подавления дребезга одно нажатие кнопки превращается в десятки “нажатий”. Для нашего проекта это означало бы: одно нажатие KEY1 запустило бы тесты несколько раз подряд.

Модуль ax_debounce работает по принципу “ждать стабильности”. Два D-триггера формируют задержку для детектирования изменений. Счётчик считает, сколько тактов сигнал остаётся стабильным. Только когда счетчик доходит до порога (20 мс x 50 МГц = 1 000 000 тактов), модуль фиксирует новое состояние кнопки.

Модуль генерирует три выходных сигнала:

  1. button_out - стабильное текущее состояние кнопки;

  2. button_posedge - за 1 такт выдает импульс при отпускании кнопки (0 →1);

  3. button_negedge - за 1 такт выдает импульс при нажатии кнопки (1→0);

На плате AX301 кнопки active-low: нажатие = LOW, отпускание = HIGH. Мы используем button_negedge — однотактный импульс в момент нажатия кнопки. Именно этот сигнал подается в контроллер тестов как start.

module ax_debounce #(
    parameter N        = 32,
    parameter FREQ     = 50,    // MHz
    parameter MAX_TIME = 20     // ms
)(
    input  wire clk,
    input  wire rst,
    input  wire button_in,
    output reg  button_posedge,
    output reg  button_negedge,
    output reg  button_out
);

    localparam TIMER_MAX_VAL = MAX_TIME * 1000 * FREQ;

    reg [N-1:0] q_reg, q_next;
    reg         DFF1, DFF2;
    reg         button_out_d0;

    wire q_reset = (DFF1 ^ DFF2);
    wire q_add   = ~(q_reg == TIMER_MAX_VAL);

    always @(*) begin
        case ({q_reset, q_add})
            2'b00:   q_next = q_reg;
            2'b01:   q_next = q_reg + 1;
            default: q_next = {N{1'b0}};
        endcase
    end

    always @(posedge clk or posedge rst) begin
        if (rst) begin
            DFF1  <= 1'b0;
            DFF2  <= 1'b0;
            q_reg <= {N{1'b0}};
        end else begin
            DFF1  <= button_in;
            DFF2  <= DFF1;
            q_reg <= q_next;
        end
    end

    always @(posedge clk or posedge rst) begin
        if (rst)
            button_out <= 1'b1;
        else if (q_reg == TIMER_MAX_VAL)
            button_out <= DFF2;
    end

    always @(posedge clk or posedge rst) begin
        if (rst) begin
            button_out_d0  <= 1'b1;
            button_posedge <= 1'b0;
            button_negedge <= 1'b0;
        end else begin
            button_out_d0  <= button_out;
            button_posedge <= ~button_out_d0 &  button_out;
            button_negedge <=  button_out_d0 & ~button_out;
        end
    end

endmodule

В top-level модуле подключаем его следующим образом:

wire btn_negedge;

ax_debounce u_debounce (
    .clk            (clk),
    .rst            (~rst_n),      // ← инверсия!
    .button_in      (key1),
    .button_posedge (),            // ← не используется
    .button_negedge (btn_negedge),
    .button_out     ()             // ← не используется
);

 Модуль ax_debounce имеет active-HIGH сброс (rst), а наш системный сброс active-LOW (rst_n). Поэтому при подключении сигнал инвертируется: .rst(~rst_n). Когда rst_n = 0 (кнопка сброса нажата), ~rst_n = 1, и debounce сбрасывается.

Два порта .button_posedge() и .button_out() подключены к пустым скобкам - эти выходы не используются. В Verilog пустые скобки означают “подключить к ничему” (dangling wire). Синтезатор Quartus оптимизирует неиспользуемую логику и просто не создаёт для неё схему.

Имя экземпляра u_debounce - конвенция “u_-prefix” распространена в RTL-дизайне. Она помогает отличать экземпляры модулей от обычных сигналов при чтении иерархических путей: u_debounce.button_out очевидно относится к подмодулю, а не к регистру верхнего уровня.

Семисегментный дисплей

Семисегментный индикатор - это набор из 7 светодиодов (сегментов), расположенных в форме цифры “8”, плюс восьмой сегмент - десятичная точка (DP). Каждый сегмент обозначается буквой от a до g:

Чтобы показать цифру, нужно зажечь определённую комбинацию сегментов. На плате AX301 дисплей active-low: 0 на сегменте = сегмент горит, 1 = погашен. Поэтому при формировании символа: 8'b0000_0000 - все сегменты горят (цифра 8), а 8'b1111_1111 - всё погашено (пусто).

На плате 6 разрядов дисплея, но все сегменты (a-g, DP) соединены параллельно - одни и те же 8 проводов seg_data[7:0] идут ко всем шести цифрам. Какая именно цифра активна в данный момент, определяется сигналом выбора seg_sel[5:0]. Это сделано для экономии пинов FPGA: вместо 6 х 8 = 48 проводов нужно всего 8 + 6 = 14.

Принцип отображения цифр - быстрое последовательное переключение. Если переключение происходит достаточно быстро (> 60 Гц на каждый разряд), человеческий глаз не замечает мерцания - все цифры, даже если они разные, кажутся горящими одновременно. В нашем проекте частота сканирования - 200 Гц на полный цикл из 6 разрядов. Каждый разряд обновляется ~33 раза в секунду. Этого достаточно для комфортного восприятия.

Счётчик scan_cnt считает от 0 до 41 665 и по переполнению переключает scan_idx (0 → 1 → 2 → 3 → 4 → 5 → 0 → …). В каждый момент активен один разряд.

Код модуля:

`timescale 1ns / 1ps
// ---------------------------------------------------------------------------
// 7-Segment Display Scanner — 6 digits, active-low select and data
// ---------------------------------------------------------------------------
module seg_scan #(
    parameter CLK_FREQ  = 50_000_000,
    parameter SCAN_FREQ = 200
)(
    input  wire       clk,
    input  wire       rst_n,
    output reg  [5:0] seg_sel,       // digit select (active-low)
    output reg  [7:0] seg_data,      // segment data (active-low, bit 7 = DP)
    input  wire [7:0] seg_data_0,    // rightmost digit
    input  wire [7:0] seg_data_1,
    input  wire [7:0] seg_data_2,
    input  wire [7:0] seg_data_3,
    input  wire [7:0] seg_data_4,
    input  wire [7:0] seg_data_5     // leftmost digit
);

    localparam SCAN_MAX = CLK_FREQ / (SCAN_FREQ * 6) - 1; // = 50_000_000 / (200 × 6) - 1 = 41 665

    reg [31:0] scan_cnt;
    reg [2:0]  scan_idx;

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            scan_cnt <= 32'd0;
            scan_idx <= 3'd0;
        end else if (scan_cnt >= SCAN_MAX) begin
            scan_cnt <= 32'd0;
            scan_idx <= (scan_idx == 3'd5) ? 3'd0 : scan_idx + 3'd1;
        end else begin
            scan_cnt <= scan_cnt + 32'd1;
        end
    end

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            seg_sel  <= 6'b111_111;
            seg_data <= 8'hFF;
        end else begin
            case (scan_idx)
                3'd0: begin seg_sel <= 6'b111_110; seg_data <= seg_data_0; end
                3'd1: begin seg_sel <= 6'b111_101; seg_data <= seg_data_1; end
                3'd2: begin seg_sel <= 6'b111_011; seg_data <= seg_data_2; end
                3'd3: begin seg_sel <= 6'b110_111; seg_data <= seg_data_3; end
                3'd4: begin seg_sel <= 6'b101_111; seg_data <= seg_data_4; end
                3'd5: begin seg_sel <= 6'b011_111; seg_data <= seg_data_5; end
                default: begin seg_sel <= 6'b111_111; seg_data <= 8'hFF; end
            endcase
        end
    end

endmodule

Контроллер тестов формирует содержимое шести разрядов в зависимости от состояния FSM:

  • в ожидании IDLE будет отображать прочерки;

  • во время выполнения теста:

  • результат теста:

    Пример при успешном тесте 1: 1 P A 5 A 5 - записали 0xA5, прочитали 0xA5, Pass.
    Пример при ошибке теста 3: 3 F F F E E - записали 0xFF, прочитали 0xEE (ошибка), Fail.

  • итоговый результат:

    Пример при всех успешных тестах: P 4 F 0 - 4 Pass, 0 Fail.

В модуле top-level поключим его следующим образом:

// ---------------------------------------------------------------
// 7-segment display scanner (6 digits)
// ---------------------------------------------------------------

    seg_scan #(
    .CLK_FREQ  (50_000_000),
    .SCAN_FREQ (200)
    ) u_seg (
       .clk        (clk),
       .rst_n      (rst_n),
       .seg_sel    (seg_sel),     // → напрямую к выходным пинам
       .seg_data   (seg_data),    // → напрямую к выходным пинам
       .seg_data_0 (dig0),        // ← от контроллера тестов
       .seg_data_1 (dig1),
       .seg_data_2 (dig2),
       .seg_data_3 (dig3),
       .seg_data_4 (dig4),
       .seg_data_5 (dig5)
    );

Параметры CLK_FREQ и SCAN_FREQ передаются явно. CLK_FREQ = 50_000_000 (50 МГц) нужен для расчёта делителя частоты сканирования. Подчёркивания в числах (50_000_000) - синтаксический сахар Verilog для читаемости, компилятор их игнорирует.

Выходы seg_sel и seg_data подключены напрямую к одноимённым output-портам top-level модуля - сигналы идут прямиком на пины FPGA без промежуточных регистров.

Светодиоды

Четыре светодиода led[3:0] индицируют результат каждого теста: led[k] = 1 означает, что тест k+1 пройден. При успешном завершении всех тестов горят все 4 LED. При сбросе все LED гаснут.

Все тесты OK:      ● ● ● ●     (led = 4'b1111)
Тест 3 не прошёл:  ● ● · ●     (led = 4'b1011)
До запуска:        · · · ·    (led = 4'b0000)

Выводы шины I2C

В симуляции I2C шина моделировалась через wire с pullup. На реальном железе всё немного иначе - FPGA подключается к физическим проводам SDA и SCL, на которых стоят резисторы подтяжки к питанию (на плате AX301 они уже распаяны).

Два оператора assign - единственное место во всём проекте, где inout-пины I2C получают значение. 1'bz — литерал «высокий импеданс» (Hi-Z). Как уже было описано выше - для синтезатора Quartus эта конструкция однозначно описывает tri-state буфер: когда scl_oen = 1, выходной драйвер отключён (Hi-Z), когда scl_oen = 0 - драйвер выводит логический 0.

assign i2c_scl = scl_oen ? 1'bz : 1'b0;
assign i2c_sda = sda_oen ? 1'bz : 1'b0;

В Quartus такой inout порт автоматически превращается в три сигнала: выход данных, сигнал output-enable и вход данных. Фиттер правильно разводит это на IO-ячейку FPGA. Это и есть open-drain: мы можем только притянуть к нулю или отпустить. Мы никогда не “выталкиваем” единицу. Pull-up резистор делает единицу за нас. Плюсом не забываем, что I2C - мультимастерная шина. На одной шине может быть несколько мастеров и несколько slave-устройств. Open-drain гарантирует, что если хотя бы одно устройство тянет линию к нулю, вся шина в нуле (wired-AND). 

I2C-ядро в составе тестовой оболочки

Контроллер тестов i2c_test_ctrl общается с ядром i2c_master_core через тот же интерфейс, который мы тестировали в симуляции.

Протокол взаимодействия такой же, как в тестбенче: ждём ready = 1, подаем команду через cmd_valid, ждём ready = 0 (ядро приняло), ждём ready = 1 (ядро завершило работу), читаем dout/rx_ack. Разница только в том, что в тестбенче мы описывали это через task в initial-блоке, а здесь - через синтезируемый автомат (FSM).

Внутри top-level модуля соединения эти объявляются с помощью wire:

wire        core_cmd_valid;
wire [2:0]  core_cmd;
wire [7:0]  core_din;
wire [7:0]  core_dout;
wire        core_rx_ack;
wire        core_ready;
wire        core_arb_lost;
wire        core_arb_lost_clr;
wire        core_busy;

wire        scl_oen, sda_oen;

Блок объявлений wire перед инстанциацией - это внутренние “провода” top-level модуля. Они соединяют выходы одного подмодуля со входами другого. Все объявлены как wire, а не reg, потому что их значения определяются подмодулями, а не always-блоками текущего модуля.

Префикс “core_” выделяет сигналы, принадлежащие интерфейсу i2c_master_core, и отделяет их от одноименных портов контроллера тестов.

i2c_master_core u_core (
    .clk_i            (clk),
    .rstn_i           (rst_n),
    .ena_i            (core_ena),       // ← от прескалера
    .cmd_valid_i      (core_cmd_valid), // ← от i2c_test_ctrl
    .cmd_i            (core_cmd),       // ← от i2c_test_ctrl
    .din_i            (core_din),       // ← от i2c_test_ctrl
    .dout_o           (core_dout),      // → к i2c_test_ctrl
    .rx_ack_o         (core_rx_ack),    // → к i2c_test_ctrl
    .ready_o          (core_ready),     // → к i2c_test_ctrl
    .arb_lost_o       (core_arb_lost),  // → к i2c_test_ctrl
    .arb_lost_clear_i (core_arb_lost_clr), // ← от i2c_test_ctrl
    .busy_o           (core_busy),      // не подключён дальше (информационный)
    .scl_i            (i2c_scl),        // ← от inout пина
    .scl_oen_o        (scl_oen),        // → к tri-state assign
    .sda_i            (i2c_sda),        // ← от inout пина
    .sda_oen_o        (sda_oen)         // → к tri-state assign
);

Подключение портов происходит через именованное сопоставление (.port_name(signal_name)). Это безопаснее, чем позиционное подключение, потому что порядок портов не имеет значения - важно только имя. Если перепутать порядок, компилятор выдаст ошибку “port not found”, а не молча подключит неправильный сигнал.

Обратите внимание на соединения .scl_i(i2c_scl) и .sda_i(i2c_sda) - ядро читает состояние шины напрямую с inout-пина FPGA. Это работает, потому что входной буфер inout-пина активен всегда, независимо от output-enable.

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

wire [7:0] dig5, dig4, dig3, dig2, dig1, dig0;

i2c_test_ctrl #(
    .SHOW_TICKS (25_000_000),  // 500 ms @ 50 MHz
    .WR_TICKS   (300_000)      // 6 ms @ 50 MHz
) u_ctrl (
    .clk          (clk),
    .rst_n        (rst_n),
    .start        (btn_negedge),      // ← от debounce
    .cmd_valid    (core_cmd_valid),    // → к ядру
    .cmd          (core_cmd),
    .din          (core_din),
    .dout         (core_dout),         // ← от ядра
    .rx_ack       (core_rx_ack),
    .ready        (core_ready),
    .arb_lost     (core_arb_lost),
    .arb_lost_clr (core_arb_lost_clr), // → к ядру
    .seg5         (dig5),              // → к seg_scan
    .seg4         (dig4),
    .seg3         (dig3),
    .seg2         (dig2),
    .seg1         (dig1),
    .seg0         (dig0),
    .led          (led)                // → напрямую к выходным пинам
);

Конструкция #(.SHOW_TICKS(25_000_000), .WR_TICKS(300_000)) - переопределение параметров при инстанциации. Модуль i2c_test_ctrl объявляет эти параметры со значениями по умолчанию, но при подключении мы можем задать другие значения. Это ключевой механизм для симуляции: в тестбенче мы подставляем SHOW_TICKS = 200 и WR_TICKS = 100, чтобы тесты выполнялись за микросекунды вместо секунд.

Шесть wire [7:0] dig5...dig0- промежуточные провода, несущие 7-сегментные паттерны от контроллера тестов к мультиплексору дисплея. Каждый провод - 8 бит (7 сегментов + десятичная точка).

Сигнал led - единственный output top-level модуля, который подключён напрямую к выходному порту подмодуля. Промежуточный wire не нужен - Verilog позволяет подключить порт подмодуля напрямую к порту вышестоящего модуля.

Теперь прочитав все описанное выше - обратите внимание на изображение итоговой архитектуры описанное выше, чтобы подытожить.

Основа тестовой оболочки

Разберем самый большой и сложный модуль проекта (~420 строк), который выполняет все необходимые действия. Объявляем модуль и параметры:

module i2c_test_ctrl #(
    parameter SHOW_TICKS = 25_000_000,
    parameter WR_TICKS   = 300_000
)(
    input  wire        clk,
    input  wire        rst_n,
    input  wire        start,         // однотактный импульс от debounce

    output reg         cmd_valid,     // → к ядру I2C
    output reg  [2:0]  cmd,
    output reg  [7:0]  din,
    input  wire [7:0]  dout,          // ← от ядра I2C
    input  wire        rx_ack,
    input  wire        ready,
    input  wire        arb_lost,
    output reg         arb_lost_clr,

    output reg  [7:0]  seg5, seg4, seg3, seg2, seg1, seg0,
    output reg  [3:0]  led
);

endmodule;

parameter (а не localparam) - эти значения можно и нужно менять при объявлении. На реальном железе SHOW_TICKS = 25_000_000 (500 мс), в тестбенче - 200. WR_TICKS = 300_000 (6 мс) на железе, 100 в тестбенче. Это единственная точка настройки, которая позволяет одному и тому же коду работать и на плате, и в симуляции.

Выходы к ядру I2C (cmd_valid, cmd, din) объявлены как output reg - они присваиваются в always-блоке FSM. Входы от ядра (dout, rx_ack, ready) - input wire. Это типичный паттерн: управляющий модуль имеет reg-выходы (генерирует команды), а контролируемый модуль - wire-входы (принимает их).

Шесть выходов seg5...seg0 тоже output reg - они присваиваются через task display_* внутри FSM.

Далее объявляем константы I2C-команд:

localparam [2:0]
    I2C_START   = 3'd1,
    I2C_WRITE   = 3'd2,
    I2C_READ    = 3'd3,
    I2C_STOP    = 3'd4,
    I2C_RESTART = 3'd5;

localparam [7:0] SLAVE_W = 8'hA0;
localparam [7:0] SLAVE_R = 8'hA1;

Коды команд дублируют аналогичные из i2c_master_core. Это осознанный дубликат, а не ошибка - контроллер тестов не зависит от исходного кода ядра. Пока числовые значения совпадают, всё работает. Альтернатива - include-файл с общими определениями, но для такого маленького проекта явные константы проще.

SLAVE_W = 8'hA0 - полный адресный байт EEPROM 24LC04 для записи. 8'hA0 = 1010_000_0 - верхние 7 бит (1010_000 = 0x50) - это 7-битный I2C-адрес, младший бит 0 - бит направления (write). SLAVE_R = 8'hA1 = 1010_000_1 - тот же адрес, но с битом чтения.

Для работы с семисегментными паттернами тоже объявляем предопределенные символы:

localparam [7:0]
    S_BLK = 8'hFF,                       // blank (все сегменты погашены)
    S_DSH = {1'b1, 7'b011_1111},         // '-'  (только сегмент g)
    S_P   = {1'b1, 7'b000_1100},         // 'P'  (a, b, e, f, g)
    S_FC  = {1'b1, 7'b000_1110};         // 'F'  (a, e, f, g)

Каждый паттерн - 8 бит. Старший бит (бит 7) - десятичная точка (DP), всегда 1 (погашена). Младшие 7 бит - сегменты g f e d c b a. Поскольку дисплей active-low, 0 = горит, 1 = погашен.

Конструкция {1'b1, 7'b011_1111} - конкатенация битов. Фигурные скобки {} в Verilog склеивают значения в одну шину. Здесь: 1 бит DP + 7 бит сегментов = 8 бит.

    function [7:0] seg_hex;
        input [3:0] h;
        reg [6:0] s;
        begin
            case (h) // synopsys full_case parallel_case
                4'h0: s = 7'b100_0000;  // verilator lint_off BLKSEQ
                4'h1: s = 7'b111_1001;
                4'h2: s = 7'b010_0100;
                4'h3: s = 7'b011_0000;
                4'h4: s = 7'b001_1001;
                4'h5: s = 7'b001_0010;
                4'h6: s = 7'b000_0010;
                4'h7: s = 7'b111_1000;
                4'h8: s = 7'b000_0000;
                4'h9: s = 7'b001_0000;
                4'hA: s = 7'b000_1000;
                4'hB: s = 7'b000_0011;
                4'hC: s = 7'b100_0110;
                4'hD: s = 7'b010_0001;
                4'hE: s = 7'b000_0110;
                4'hF: s = 7'b000_1110;  // verilator lint_on BLKSEQ
                default: s = 7'b111_1111;
            endcase
            seg_hex = {1'b1, s};        // verilator lint_off BLKSEQ
        end                             // verilator lint_on BLKSEQ
    endfunction

function в Verilog - это комбинаторная подпрограмма. В отличие от task, функция не может содержать временных задержек (#, @), не может модифицировать внешние reg-переменные и всегда возвращает значение. Она превращается в чистую комбинаторную логику при синтезе - мультиплексор 16:1, на входе 4-битный hex-nibble, на выходе 7-битный паттерн сегментов.

Последняя строка seg_hex = {1'b1, s} - присваивание возвращаемого значения. Имя функции seg_hex одновременно является и именем выходной “переменной”. DP всегда погашен (1'b1).

В реальном коде case снабжен директивой “// synopsys full_case parallel_case”. Это указание для синтезатора Synopsys (и совместимых): full_case говорит “все варианты покрыты, не создавай защёлку”, parallel_case - “варианты взаимоисключающие, оптимизируй как параллельный MUX”. Для hex-декодера это безопасно - 4-битный вход имеет ровно 16 значений, и все 16 покрыты.

Комментарии // verilator lint_off BLKSEQ и // verilator lint_on BLKSEQ - прагмы для линтера Verilator. Он предупреждает о блокирующих присваиваниях (=) внутри функций, хотя это абсолютно корректно - функции должны использовать =, а не <=. Прагмы подавляют ложное предупреждение.

Далее объявление данных тестовых ROM - адреса и данные.

    // ----- Test ROM: {reg_addr[7:0], write_data[7:0]} -----

    localparam NUM_TESTS = 4;
    reg [7:0] tst_addr;
    reg [7:0] tst_data;

    always @(*) begin
        case (test_idx)
            2'd0: begin tst_addr = 8'h00; tst_data = 8'hA5; end
            2'd1: begin tst_addr = 8'h01; tst_data = 8'h5A; end
            2'd2: begin tst_addr = 8'h10; tst_data = 8'hFF; end
            2'd3: begin tst_addr = 8'h11; tst_data = 8'h00; end
            default: begin tst_addr = 8'h00; tst_data = 8'h00; end
        endcase
    end

always @(*) - комбинаторный блок. Звёздочка означает “реагировать на изменение любого сигнала, от которого зависит результат”. Синтезатор превращает это в мультиплексор: при test_idx = 0 на выходе tst_addr = 0x00, tst_data = 0xA5, при test_idx = 1 - 0x01, 0x5A, и т.д.

Ветка default обязательна - без неё синтезатор Quartus создаст защёлку (latch), потому что не для всех значений test_idx определен выход. Защёлки в FPGA-дизайне - источник тонких ошибок тайминга, их следует избегать.

Хотя test_idx - 2-битный регистр ([1:0]), и 2'd0..2'd3 покрывают все 4 значения, default служит страховкой: если когда-нибудь ширину test_idx расширят, код не сломается.

Далее объявляем состояние FSM и подавтомата:

localparam [3:0]
    ST_IDLE     = 4'd0,
    ST_PREP     = 4'd1,
    ST_WR_SEQ   = 4'd2,
    ST_WR_DELAY = 4'd3,
    ST_RD_SEQ   = 4'd4,
    ST_VERIFY   = 4'd5,
    ST_SHOW     = 4'd6,
    ST_NEXT     = 4'd7,
    ST_SUMMARY  = 4'd8,
    ST_ERR_STOP = 4'd9;

localparam [1:0]
    SS_ISSUE  = 2'd0,
    SS_ACCEPT = 2'd1,
    SS_DONE   = 2'd2;

10 состояний основного автомата кодируются 4 битами (вмещает до 16 значений). 3 подсостояния - 2 битами. Именные localparam вместо “магических чисел” делают код самодокументирующимся: state <= ST_WR_DELAY читается как предложение на английском.

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

reg [3:0]  state;        // текущее состояние FSM
reg [1:0]  sub;          // подсостояние (issue/accept/done)
reg [2:0]  step;         // шаг внутри write- или read-последовательности (0..6)
reg [1:0]  test_idx;     // номер текущего теста (0..3)
reg [31:0] delay_cnt;    // универсальный счётчик задержки
reg [7:0]  rd_data;      // данные, прочитанные из EEPROM
reg [3:0]  pass_flags;   // бит k = 1, если тест k пройден
reg [3:0]  fail_flags;   // бит k = 1, если тест k провален
reg        test_error;   // флаг: текущий тест ошибочен

delay_cnt используется дважды: в ST_WR_DELAY считает до WR_TICKS (задержка записи EEPROM), в ST_SHOW считает до SHOW_TICKS (задержка отображения результата). Один 32-битный счётчик для обеих задач - экономия регистров. Каждый раз при входе в состояние с задержкой счётчик обнуляется.

pass_flags и fail_flags - 4-битные регистры, где каждый бит соответствует одному тесту. pass_flags[0] = 1 означает “тест 0 прошёл”. Для итогового дисплея нужно подсчитать количество единиц:

wire [2:0] pass_count = {2'd0, pass_flags[0]} + {2'd0, pass_flags[1]}
                      + {2'd0, pass_flags[2]} + {2'd0, pass_flags[3]};
wire [2:0] fail_count = {2'd0, fail_flags[0]} + {2'd0, fail_flags[1]}
                      + {2'd0, fail_flags[2]} + {2'd0, fail_flags[3]};

Это population count (подсчет единичных бит). Каждый бит флага расширяется до 3 бит ({2'd0, flag[k]} → 3'b00x), затем четыре 3-битных значения складываются. Результат - от 0 до 4. Синтезатор превращает это в дерево однобитных сумматоров.

Далее. Генераторы команд write и read:

reg [2:0] wr_cmd;
reg [7:0] wr_din;
always @(*) begin
    case (step)
        3'd0: begin wr_cmd = I2C_START; wr_din = 8'h00;    end
        3'd1: begin wr_cmd = I2C_WRITE; wr_din = SLAVE_W;   end  // 0xA0
        3'd2: begin wr_cmd = I2C_WRITE; wr_din = tst_addr;  end  // адрес ячейки
        3'd3: begin wr_cmd = I2C_WRITE; wr_din = tst_data;  end  // данные
        3'd4: begin wr_cmd = I2C_STOP;  wr_din = 8'h00;     end
        default: begin wr_cmd = I2C_STOP; wr_din = 8'h00;   end
    endcase
end

Ещё один комбинаторный мультиплексор. По значению step (номер текущего шага) выбираются команда и данные для I2C ядра. FSM в состоянии ST_WR_SEQ просто инкрементирует step от 0 до 4 - сам мультиплексор подставляет нужную команду.

Этот паттерн - разделение данных и управления. Данные (какие команды подавать) описаны в комбинаторном блоке-таблице. Управление (когда переходить к следующему шагу) - в FSM. Такое разделение упрощает модификацию: чтобы добавить шаг, достаточно добавить строку в case и увеличить границу step в FSM.

Аналогичный генератор для чтения - 7 шагов:

reg [2:0] rd_cmd;
reg [7:0] rd_din;
always @(*) begin
    case (step)
        3'd0: begin rd_cmd = I2C_START;    rd_din = 8'h00;           end
        3'd1: begin rd_cmd = I2C_WRITE;    rd_din = SLAVE_W;         end
        3'd2: begin rd_cmd = I2C_WRITE;    rd_din = tst_addr;        end
        3'd3: begin rd_cmd = I2C_RESTART;  rd_din = 8'h00;           end
        3'd4: begin rd_cmd = I2C_WRITE;    rd_din = SLAVE_R;         end  // 0xA1
        3'd5: begin rd_cmd = I2C_READ;     rd_din = {7'd0, 1'b1};    end  // NACK
        3'd6: begin rd_cmd = I2C_STOP;     rd_din = 8'h00;           end
        default: begin rd_cmd = I2C_STOP;  rd_din = 8'h00;           end
    endcase
end

На шаге 5 rd_din = {7'd0, 1'b1} - конкатенация: 7 нулевых бит + 1 единичный бит. Ядро i2c_master_core интерпретирует din_i[0] как “ACK/NACK от мастера при чтении”: 1 = NACK (конец чтения), 0 = ACK (продолжить).

После объявляем провода проверки ACK:

wire wr_check_ack = (step == 3'd1 || step == 3'd2 || step == 3'd3);
wire rd_check_ack = (step == 3'd1 || step == 3'd2 || step == 3'd4);

Не все шаги требуют проверки ACK. START и STOP - управляющие условия, ACK для них не существует. READ с NACK - мы сами решаем послать NACK, проверять нечего. ACK нужно проверять только на шагах WRITE с адресом или данными - именно там EEPROM подтверждает приём.

Эти провода используются в FSM: if (wr_check_ack && rx_ack) - если на текущем шаге нужна проверка ACK и ядро сообщает rx_ack = 1 (NACK) - это ошибка.

Таски и отображение данных

Создадим таски для удобства и . В Verilog task внутри always-блока - это просто встроенная подпрограмма для группировки присваиваний. Она не создаёт отдельную аппаратную сущность - это синтаксическое удобство. Все <= (неблокирующие присваивания) внутри task-а выполняются в контексте вызывающего always-блока.

    // ----- Display update task (active display depends on state) -----
    task display_idle;
        begin
            seg5 <= S_DSH; seg4 <= S_DSH; seg3 <= S_DSH;
            seg2 <= S_DSH; seg1 <= S_DSH; seg0 <= S_DSH;
        end
    endtask

    task display_running;
        begin
            seg5 <= seg_hex({2'b0, test_idx} + 4'd1);
            seg4 <= S_DSH;
            seg3 <= seg_hex(tst_data[7:4]);
            seg2 <= seg_hex(tst_data[3:0]);
            seg1 <= S_DSH;
            seg0 <= S_DSH;
        end
    endtask

    task display_result;
        begin
            seg5 <= seg_hex({2'b0, test_idx} + 4'd1);
            seg4 <= test_error ? S_FC : S_P;
            seg3 <= seg_hex(tst_data[7:4]);
            seg2 <= seg_hex(tst_data[3:0]);
            seg1 <= seg_hex(rd_data[7:4]);
            seg0 <= seg_hex(rd_data[3:0]);
        end
    endtask

Выражение {2'b0, test_idx} + 4'd1 - расширяет 2-битный test_idx до 4 бит и прибавляет 1. При test_idx = 0 результат 4'd1seg_hex покажет “1”. При “test_idx = 3” → “4”. Человеку удобнее видеть номера тестов 1-4, а не 0-3.

tst_data[7:4] и tst_data[3:0] - старший и младший nibble (полубайт). Каждый преобразуется в hex-цифру через seg_hex. Так 0xA5 отображается как два разряда: A и 5.

Тернарный оператор test_error ? S_FC : S_P - если ошибка, показать “F” (Fail), иначе “P” (Pass).

Основной FSM - always-блок

Разберем код основного FSM-блока:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state       <= ST_IDLE;
        sub         <= SS_ISSUE;
        step        <= 3'd0;
        test_idx    <= 2'd0;
        delay_cnt   <= 32'd0;
        cmd_valid   <= 1'b0;
        cmd         <= 3'd0;
        din         <= 8'd0;
        arb_lost_clr <= 1'b0;
        rd_data     <= 8'd0;
        pass_flags  <= 4'd0;
        fail_flags  <= 4'd0;
        test_error  <= 1'b0;
        led         <= 4'b0000;
        seg5 <= S_DSH; seg4 <= S_DSH; seg3 <= S_DSH;
        seg2 <= S_DSH; seg1 <= S_DSH; seg0 <= S_DSH;
    end else begin
        arb_lost_clr <= 1'b0;   // ← auto-clear: импульс длится один такт
        case (state)
            ...
        endcase
    end
end

Блок сброса инициализирует все регистры модуля. Это критически важно для FPGA - в отличие от ASIC, регистры Cyclone IV могут стартовать в произвольном состоянии (зависит от конфигурации), и без явного сброса поведение будет недетерминированным.

Строка arb_lost_clr <= 1'b0 вне case - сброс флага по умолчанию в каждом такте. Если ни одно состояние не выставит arb_lost_clr <= 1'b1, он останется 0. Это паттерн “однотактный импульс”: в ST_PREP устанавливается arb_lost_clr <= 1'b1, а уже в следующем такте “авто-clear” возвращает его в 0. Ядро видит импульс длиной 1 такт.

Состояние ST_IDLE

ST_IDLE: begin
    display_idle;
    if (start) begin
        test_idx   <= 2'd0;
        pass_flags <= 4'd0;
        fail_flags <= 4'd0;
        led        <= 4'b0000;
        state      <= ST_PREP;
    end
end

Каждый такт вызывается display_idle - дисплей непрерывно обновляется паттерном ------. При получении импульса start (от debounce) - обнуляются счетчики тестов, гасятся LED, и FSM переходит в ST_PREP.

Состояние ST_PREP - подготовка к тесту

ST_PREP: begin
    step       <= 3'd0;
    sub        <= SS_ISSUE;
    test_error <= 1'b0;
    arb_lost_clr <= 1'b1;
    display_running;
    state      <= ST_WR_SEQ;
end

ST_PREP - транзитное состояние (1 такт). Оно выполняет “домашнюю уборку” перед каждым тестом:

  • step <= 3'd0 - сброс шага I2C-последовательности на начало

  • sub <= SS_ISSUE - подавтомат начнёт с ожидания ready

  • test_error <= 1'b0 - очистка флага ошибки от предыдущего теста

  • arb_lost_clr <= 1'b1 - импульс очистки arb_lost в ядре (сбросится в 0 следующим тактом благодаря auto-clear в строке arb_lost_clr <= 1'b0 вне case)

  • display_running - показать на дисплее номер теста и записываемые данные;

  • безусловный переход в ST_WR_SEQ.

Без ST_PREP пришлось бы дублировать эти инициализации в ST_IDLE (для первого теста) и в ST_NEXT (для последующих). Выделение в отдельное состояние убирает дублирование.

Состояние ST_WR_SEQ - ядро взаимодействия с подавтоматом

ST_WR_SEQ: begin
    case (sub)
        SS_ISSUE: begin
            if (ready) begin
                cmd_valid <= 1'b1;
                cmd       <= wr_cmd;     // ← из комбинаторного генератора
                din       <= wr_din;
                sub       <= SS_ACCEPT;
            end
        end
        SS_ACCEPT: begin
            if (!ready) begin
                cmd_valid <= 1'b0;
                sub       <= SS_DONE;
            end
        end
        SS_DONE: begin
            if (ready) begin
                if (wr_check_ack && rx_ack) begin
                    test_error <= 1'b1;
                    state <= ST_ERR_STOP;
                    sub   <= SS_ISSUE;
                end else if (step == 3'd4) begin
                    delay_cnt <= 32'd0;
                    state     <= ST_WR_DELAY;
                end else begin
                    step <= step + 3'd1;
                    sub  <= SS_ISSUE;
                end
            end
        end
        default: sub <= SS_ISSUE;
    endcase
end

Это двухуровневый автомат: внешний уровень (state) определяет фазу теста, внутренний (sub) - этап рукопожатия с I2C-ядром.

SS_ISSUE: Ждёт ready = 1 от ядра. Как только ядро готово - выставляет cmd_valid, cmd и din. Значения wr_cmd/wr_din автоматически подставлены комбинаторным генератором в зависимости от step.

SS_ACCEPT: Ждёт ready = 0 - подтверждение, что ядро приняло команду и начало выполнение. Снимает cmd_valid.

SS_DONE: Ждёт ready = 1 - команда выполнена. Здесь развилка:

  • Если ACK-проверка активна и пришёл NACK (rx_ack = 1) → ошибка, переход в ST_ERR_STOP

  • Если текущий шаг последний (step == 4) → переход к задержке записи ST_WR_DELAY

  • Иначе → инкремент step, возврат в SS_ISSUE для следующего шага

Состояние ST_WR_DELAY - ожидание цикла записи EEPROM

ST_WR_DELAY: begin
    if (delay_cnt >= WR_TICKS) begin
        step  <= 3'd0;
        sub   <= SS_ISSUE;
        state <= ST_RD_SEQ;
    end else begin
        delay_cnt <= delay_cnt + 32'd1;
    end
end

После отправки STOP (конец записи) EEPROM занята внутренней записью в энергонезависимую память. В это время она не отвечает на I2C-запросы. Счётчик delay_cnt считает от 0 до WR_TICKS (300 000 тактов = 6 мс), после чего переходит к чтению. Перед переходом обнуляются step и sub для следующей (read) последовательности.

ST_RD_SEQ устроен аналогично ST_WR_SEQ, с двумя отличиями: используются rd_cmd/rd_din вместо wr_cmd/wr_din, и на шаге 5 (READ) сохраняются прочитанные данные:

if (step == 3'd5)
    rd_data <= dout;        // ← сохранить прочитанный из EEPROM байт
if (step == 3'd6) begin
    state <= ST_VERIFY;     // все шаги выполнены — сравнить
end

Состояния ST_VERIFY и ST_SHOW

ST_VERIFY: begin
    if (rd_data != tst_data)
        test_error <= 1'b1;
    display_result;
    delay_cnt <= 32'd0;
    state     <= ST_SHOW;
end

ST_VERIFY - транзитное состояние, занимающее ровно 1 такт. Оно сравнивает прочитанное значение rd_data с ожидаемым tst_data. Если не совпадают - устанавливает флаг ошибки. Затем безусловный переход в ST_SHOW.

ST_SHOW: begin
    if (!test_error)
        display_result;
    if (test_error) begin
        fail_flags[test_idx] <= 1'b1;
        display_result;
    end else begin
        pass_flags[test_idx] <= 1'b1;
        led[test_idx]        <= 1'b1;
    end
    if (delay_cnt >= SHOW_TICKS)
        state <= ST_NEXT;
    else
        delay_cnt <= delay_cnt + 32'd1;
end

ST_SHOW задерживается на SHOW_TICKS тактов для того, чтобы пользователь успел прочитать результат на дисплее. Одновременно записываются флаги pass/fail и зажигается LED при успехе.

Обратите внимание на led[test_idx] <= 1'b1 - это побитовое присваивание: зажигается только светодиод текущего теста, остальные LED сохраняют значение от предыдущих тестов. К концу 4-го теста горят все 4 LED (при условии успеха).

Состояния ST_NEXT и ST_SUMMARY

ST_NEXT: begin
    if (test_idx == 2'd3)
        state <= ST_SUMMARY;
    else begin
        test_idx <= test_idx + 2'd1;
        state    <= ST_PREP;
    end
end

Точка ветвления: если выполнены все 4 теста (test_idx == 3) - итоговый экран. Иначе - увеличить индекс и перейти к следующему тесту.

ST_SUMMARY: begin
    seg5 <= S_P;
    seg4 <= seg_hex({1'b0, pass_count});
    seg3 <= S_BLK;
    seg2 <= S_FC;
    seg1 <= seg_hex({1'b0, fail_count});
    seg0 <= S_BLK;
    if (start)
        state <= ST_IDLE;
end

Итоговый экран обновляет дисплей каждый такт и ждёт повторного нажатия кнопки. {1'b0, pass_count} расширяет 3-битный pass_count до 4 бит для seg_hex.

Состояние ST_ERR_STOP - обработка ошибок

ST_ERR_STOP: begin
    case (sub)
        SS_ISSUE: begin
            if (ready) begin
                cmd_valid <= 1'b1;
                cmd       <= I2C_STOP;
                din       <= 8'd0;
                sub       <= SS_ACCEPT;
            end
        end
        SS_ACCEPT: begin
            if (!ready) begin
                cmd_valid <= 1'b0;
                sub       <= SS_DONE;
            end
        end
        SS_DONE: begin
            if (ready) begin
                rd_data <= 8'hEE;       // ← маркер ошибки
                display_result;
                delay_cnt <= 32'd0;
                state <= ST_SHOW;
                sub   <= SS_ISSUE;
            end
        end
        default: sub <= SS_ISSUE;
    endcase
end

При ошибке (NACK) FSM отправляет I2C_STOP через тот же подавтомат sub. После завершения STOP записывает 0xEE в rd_data - визуальный маркер ошибки на дисплее. Затем переходит в ST_SHOW, где тест будет помечен как FAIL.

Значение 0xEE выбрано специально: оно визуально отличимо от любых тестовых данных (0xA5, 0x5A, 0xFF, 0x00). Увидев на дисплее EE вместо ожидаемых данных, пользователь сразу поймёт, что произошла ошибка I2C-коммуникации, а не ошибка данных.

Проект в Quartus - файлы и настройки

Теперь создадим проект и проверим его в железе. Структура проекта будет содержать следующий набор уже знакомых файлов:

quartus/
├── i2c_test.qpf               ← Файл проекта Quartus (метаданные)
├── i2c_test_top.qsf           ← Настройки: устройство, исходники, пины
├── i2c_test_top.sdc           ← Временные ограничения
└── src/
    ├── i2c_test_top.v         ← Top-level модуль
    ├── i2c_test_ctrl.v        ← Контроллер тестов (FSM)
    ├── seg_scan.v             ← Мультиплексор дисплея
    └── ax_debounce.v          ← Подавление дребезга кнопки

../rtl/
    └── i2c_master_core.v      ← I2C ядро (используется из основного RTL) 

Первый файл, который мы сделаем сами, уже без мастера проектов - i2c_test.qpf. Это минимальный файл, который Quartus использует для идентификации проекта. Ключевая строка:

PROJECT_REVISION = "i2c_test_top"

Итоговое типовое содержание:

QUARTUS_VERSION = "25.1"
DATE = "21:44:27  April 07, 2026"

# Revisions
PROJECT_REVISION = "i2c_test_top"

Ревизия - это набор настроек (QSF), привязанный к конкретному top-level модулю. В нашем случае ревизия одна: i2c_test_top.

Следующий важный файл - QSF (Quartus Settings File) - основной конфигурационный файл. Он содержит директиву выбора устройства:

# ---- Device ----
set_global_assignment -name FAMILY "Cyclone IV E"
set_global_assignment -name DEVICE EP4CE6F17C8

EP4CE6F17C8 расшифровывается:

  • EP4CE6 - Cyclone IV E, 6 272 логических элементов

  • F1 - корпус FBGA 256 пинов

  • C8 - коммерческая температура (0-85°C), speed grade 8

Далее перечисляются константы:

set_global_assignment -name TOP_LEVEL_ENTITY               i2c_test_top
set_global_assignment -name PROJECT_OUTPUT_DIRECTORY       output_files
set_global_assignment -name MIN_CORE_JUNCTION_TEMP         0
set_global_assignment -name MAX_CORE_JUNCTION_TEMP         85
set_global_assignment -name ERROR_CHECK_FREQUENCY_DIVISOR  1
set_global_assignment -name NOMINAL_CORE_SUPPLY_VOLTAGE    1.2V
set_global_assignment -name STRATIX_DEVICE_IO_STANDARD    "2.5 V"

Исходные файлы:

# ---- Source files ----
set_global_assignment -name VERILOG_FILE src/i2c_test_top.v
set_global_assignment -name VERILOG_FILE src/i2c_test_ctrl.v
set_global_assignment -name VERILOG_FILE src/seg_scan.v
set_global_assignment -name VERILOG_FILE src/ax_debounce.v
set_global_assignment -name VERILOG_FILE ../rtl/i2c_master_core.v
set_global_assignment -name SDC_FILE i2c_test_top.sdc

Обратите внимание: i2c_master_core.v подключается из директории ./rtl/ - это то самое ядро, которое мы верифицировали на первом шаге. Мы не копируем его в папку проекта Quartus, а ссылаемся на оригинал. Это гарантирует, что при обновлении ядра проект Quartus автоматически получает обновлённую версию.

# ---- Partitions ----
set_global_assignment -name PARTITION_NETLIST_TYPE SOURCE -section_id Top
set_global_assignment -name PARTITION_FITTER_PRESERVATION_LEVEL PLACEMENT_AND_ROUTING -section_id Top
set_global_assignment -name PARTITION_COLOR 16764057 -section_id Top

Стандарты ввода-вывода:

# ---- IO Standards ----
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to clk
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to rst_n
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to key1
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to i2c_scl
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to i2c_sda
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to led[0]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to led[1]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to led[2]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to led[3]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[0]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[1]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[2]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[3]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[4]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_sel[5]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[0]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[1]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[2]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[3]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[4]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[5]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[6]
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to seg_data[7]

Все пины настроены на стандарт 3.3V LVTTL - это логические уровни, совместимые с EEPROM 24LC04 и периферией платы. Каждый сигнал top-level модуля привязан к конкретному физическому пину FPGA:

# ---- Pin assignments (ALINX AX301 v2 board) ----

# Clock 50 MHz
set_location_assignment PIN_E1  -to clk

# Reset (active-low push button)
set_location_assignment PIN_N13 -to rst_n

# Start button (active-low)
set_location_assignment PIN_M15 -to key1

# LEDs
set_location_assignment PIN_E10 -to led[0]
set_location_assignment PIN_F9  -to led[1]
set_location_assignment PIN_C9  -to led[2]
set_location_assignment PIN_D9  -to led[3]

# I2C bus (24LC04 EEPROM)
set_location_assignment PIN_D1  -to i2c_scl
set_location_assignment PIN_E6  -to i2c_sda

# 7-segment display: digit select (active-low, 6 digits)
set_location_assignment PIN_M11 -to seg_sel[0]
set_location_assignment PIN_P11 -to seg_sel[1]
set_location_assignment PIN_N11 -to seg_sel[2]
set_location_assignment PIN_M10 -to seg_sel[3]
set_location_assignment PIN_P9 -to seg_sel[4]
set_location_assignment PIN_N9 -to seg_sel[5]

# 7-segment display: segment data (active-low, bit 7 = DP)
set_location_assignment PIN_R14 -to seg_data[0]
set_location_assignment PIN_N16 -to seg_data[1]
set_location_assignment PIN_P16 -to seg_data[2]
set_location_assignment PIN_T15 -to seg_data[3]
set_location_assignment PIN_P15 -to seg_data[4]
set_location_assignment PIN_N12 -to seg_data[5]
set_location_assignment PIN_N15 -to seg_data[6]
set_location_assignment PIN_R16 -to seg_data[7]

# Altera config pins
set_location_assignment PIN_C1  -to ~ALTERA_ASDO_DATA1~
set_location_assignment PIN_D2  -to ~ALTERA_FLASH_nCE_nCSO~
set_location_assignment PIN_H1  -to ~ALTERA_DCLK~
set_location_assignment PIN_H2  -to ~ALTERA_DATA0~
set_location_assignment PIN_F16 -to ~ALTERA_nCEO~

Ну и служебные данные:

set_global_assignment -name LAST_QUARTUS_VERSION "25.1std.0 Standard Edition"
set_instance_assignment -name PARTITION_HIERARCHY root_partition -to | -section_id Top%  

Следующий файл это i2c_test_top.sdc - Synopsys Design Constraints, файл, который объясняет инструменту временного анализа (TimeQuest), какие требования предъявляются к тактированию:

# SDC constraints for I2C EEPROM Test — AX301 board
# 50 MHz oscillator
create_clock -name clk -period 20.000 [get_ports {clk}]

# I2C is slow (100 kHz) — relax I/O timing
set_false_path -from [get_ports {i2c_sda i2c_scl key1 rst_n}]
set_false_path -to   [get_ports {i2c_sda i2c_scl led[*] seg_sel[*] seg_data[*]}]

create_clock - определяет такт с периодом 20 нс (50 МГц). Quartus будет проверять, что вся внутренняя логика укладывается в этот период.

set_false_path - говорит анализатору: “не проверяй тайминг на этих путях”. Это допустимо потому что:

  • I2C работает на 100 кГц - в 500 раз медленнее тактовой. Любая задержка IO незначительна.

  • Кнопки - асинхронные входы, переключаются раз в секунды.

  • LED и дисплей - выходы для человеческого глаза, обновляются с частотой ~200 Гц.

Без set_false_path Quartus мог бы выдать ложные ошибки тайминга на этих путях.

Сборка и загрузка прошивки

Теперь необходимо открыть Quartus. Открывам проект: File → Open Project → выбрать quartus/i2c_test.qpf. Запускаем компиляцию: Processing → Start Compilation (или Ctrl+L). Компиляция проходит 4 этапа:

  • Analysis & Synthesis: Verilog-код преобразуется в таблицу соединений (netlist) из логических элементов, регистров и памяти Cyclone IV.

  • Fitter: Каждый элемент нетлиста размещается в конкретной ячейке FPGA, и между ними прокладываются межсоединения.

  • Assembler: Генерируется файл прошивки output_files/i2c_test_top.sof.

  • TimeQuest: Проверяется, что все пути укладываются в заданные временные ограничения.

Загружаем в FPGA: Tools → Programmer. Подключаем плату через USB-Blaster к ПК. Нажимаем Start. Через ~2 секунды осуществляется прошивка в FPGA и происходит запуск нашего кода.

Помимо этого можно сделать загрузку через консоль:

LIB=$(find ~/altera/25.1std/quartus/linux64 -name libdb_asgn.so -print -quit)
LIBDIR=$(dirname "$LIB")
export LD_LIBRARY_PATH="$LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"

export PATH=$PATH:/home/megalloid/altera/25.1std/quartus/linux64

cd ~/sources/I2C_Master_Controller/quartus/

# Полная компиляция (синтез + размещение + сборка + тайминг)
quartus_sh --flow compile i2c_test_top

# Загрузка .sof в FPGA через JTAG
quartus_pgm -m jtag -o "P;output_files/i2c_test_top.sof"

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

  • output_files/i2c_test_top.sof - размер ~270 КБ - прошивка SRAM (загружается через JTAG, теряется при выключении);

  • output_files/i2c_test_top.pof - размер ~270 КБ - прошивка для заливки в Flash (сохраняется при выключении);

  • output_files/i2c_test_top.fit.summary - отчёт о ресурсах (сколько LE, памяти, пинов использовано)

  • output_files/i2c_test_top.sta.summary - отчёт тайминга (Fmax, slack)

Итоговый расход ресурсов:

Ресурс

Использовано

Доступно

Процент

Logic Elements

549

6 272

~10%

Registers

276

6 272

~5%

Memory bits

0

276 480

0%

IO pins

23

180

12%

Проект занимает около 10% ресурсов FPGA - маленькая и простая конструкция, оставляющая 90% ресурсов свободными.

Работа на плате - что ожидаем увидеть

Этап 1. Включение. После загрузки прошивки (или подачи питания, если прошивка во Flash). Все шесть разрядов показывают дефисы. Светодиоды погашены. Контроллер в состоянии ST_IDLE - ждёт нажатия кнопки.

После нажатия на KEY1 - происходит запуск тестов. 

Тест 1 (запись 0xA5 в адрес 0x00):
  Шаг «running»:   1 — A 5 — —      (~90 мс — время I2C транзакции)
  Шаг «delay»:     (не виден)        (6 мс — ожидание записи EEPROM)
  Шаг «read»:      (не виден)        (~90 мс — чтение обратно)
  Шаг «result»:    1 P A 5 A 5       (500 мс — отображение результата)
  LED:              ● · · ·

Тест 2 (запись 0x5A в адрес 0x01):
  result:           2 P 5 A 5 A
  LED:              ● ● · ·

Тест 3 (запись 0xFF в адрес 0x10):
  result:           3 P F F F F
  LED:              ● ● ● ·

Тест 4 (запись 0x00 в адрес 0x11):
  result:           4 P 0 0 0 0
  LED:              ● ● ● ●

Итог:
  summary:          P 4   F 0
  LED:              ● ● ● ●

Каждый тест отображается ~500 мс (параметр SHOW_TICKS = 25_000_000 при 50 МГц). Полный цикл из 4 тестов занимает ~4 × (0.18 + 0.006 + 0.18 + 0.5) ≈ 3.5 секунды.

Чтобы повторить заново после отображения итогов (P4 F0) - нажмите KEY1 снова. Контроллер вернётся в ST_IDLE, погасит LED, покажет 6 прочерков и начнёт тесты заново. Нажатие кнопки RESET немедленно возвращает всё в начальное состояние в т.ч. индикацию на дисплее, все LED погашены, I2C шина отпущена.

Посмотрим что на логическом анализаторе

Тест 1. Выглядит как и полагается. Первым этапом производится запись значения 0xA5 в адрес ячейки 0x00. Судя по трем ACK - успешно: 

Производим контрольное чтение:

Тест 2. Запись 0х5А в адрес 0х01:

И делаем контрольное чтение:

Тест 3. Запись 0xFF по адресу 0x10

И производим контрольное чтение:

И заключительный тест 4. Записываем 0x00 в ячейку 0x11:

С контрольным чтением:

О том же успехе нам сообщает и сама плата: 

Отладка типичных проблем

Приведу список типовых проблем, как их продиагностировать и как их решить. 

Если все тесты FAIL:

Возможная причина

Диагностика

Решение

EEPROM не установлена или повреждена

Дисплей показывает EE в позициях прочитанных данных (маркер ошибки NACK)

Проверить наличие 24LC04 на плате

Неправильные пины I2C

EEPROM не отвечает (NACK)

Проверить .qsf — пины D1 (SCL) и E6 (SDA)

Отсутствуют pull-up резисторы

SDA/SCL "висят" в неопределённом состоянии

На AX301 они распаяны; если используете внешнюю EEPROM — добавить 4.7 кОм к 3.3V

Неправильный адрес EEPROM

EEPROM другого типа (не 24LC04 или другой блок)

Изменить SLAVE_W/SLAVE_R в i2c_test_ctrl.v

Дисплей не светится:

Возможная причина

Решение

Неверная полярность

Плата AX301 v1 и v2 могут отличаться; проверить схему

Неправильные пины seg_sel /seg_data

Перепроверить .qsf по схеме платы

Частота сканирования слишком низкая

Увеличить SCAN_FREQ (по умолчанию 200 — достаточно)

Нестабильные результаты, иногда PASS, иногда FAIL:

Возможная причина

Решение

Слишком малая задержка после записи

Увеличить WR_TICKS (по умолчанию 300 000 = 6 мс)

Помехи на I2C шине

Укоротить провода, проверить pull-up

Проблемы с питанием EEPROM

Проверить напряжение

Помимо этого для отладки можно использовать SignalTap. Если базовая отладка по дисплею и LED недостаточна, Quartus предоставляет встроенный логический анализатор SignalTap II. Он позволяет захватить реальные сигналы внутри FPGA и просмотреть их в виде осциллограмм - аналог GTKWave, но для реального железа:

  1. Tools → SignalTap II Logic Analyzer

  2. Добавить сигналы: scl_oen, sda_oen, i2c_scl, i2c_sda, core_cmd, core_ready, u_ctrl.state

  3. Настроить триггер: например, core_cmd_valid == 1

  4. Перекомпилировать проект (SignalTap добавляет логику захвата в FPGA)

  5. Запустить захват и нажать KEY1

  6. Просмотреть осциллограмму реальных сигналов

SignalTap использует внутреннюю BRAM FPGA для хранения захваченных данных и JTAG для передачи на PC. Глубина захвата ограничена объемом доступной памяти. Глубоко не буду разбирать этот вопрос в рамках этой статьи.

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

На первом шаге проекта мы написали и верифицировали i2c_master_core в симуляции. На этом, втором шаге, мы:

  1. Выбрали аппаратную платформу - плата ALINX AX301 с Cyclone IV FPGA и EEPROM 24LC04 на борту.

  2. Спроектировали тестовую оболочку из пяти модулей:

    1. Прескалер для генерации 100 кГц SCL из 50 МГц тактовой

    2. Debounce для надёжного считывания кнопки

    3. I2C ядро (без изменений из rtl/)

    4. FSM контроллер тестов: 4 теста записи-чтения EEPROM

    5. Мультиплексор дисплея для 6-разрядного семисегментного индикатора

  3. Создали Quartus проект с привязкой пинов, IO стандартами и временными ограничениями.

  4. Получили прошивку, которая при загрузке в FPGA запускает автоматические тесты I2C по нажатию кнопки и показывает результат на дисплее и светодиодах.

Два вида верификации дополняют друг друга. Симуляция находит логические ошибки быстро и дёшево. Аппаратный тест подтверждает, что логика работает в реальных физических условиях. Следующий шаг - сделаем потоковую запись в дисплей OLED с контроллером SSD1306 и проверим работоспособность burst-записи с использованием нашего кода.


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

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

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

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


  1. Kan_Timur
    15.05.2026 03:56

    В модуле ax_debounce счетчик реализован в виде конечного автомата? По-моему присутствует небольшой оверкилл. Отсюда и два вектора q_reg, q_next на один счетчик.

    Почему не используете $clog2 для вычисления разрядности? Так в модулях появляются зависимые параметры. Если в проекте нет зависимых параметров – меняешь любой без головной боли, а если есть хоть один, то нужно помнить все или каждый раз лезть в код.


    1. andreyzaostrovnykh Автор
      15.05.2026 03:56

      Да, согласен, может выглядеть как оверкилл. Это скорее не преднамеренное задействование из предложенного китайцами исходника, взятого as is.

      Здесь применён классический двухпроцессный стиль _reg/_next - и это вроде как правильный и рекомендуемый паттерн для конечных автоматов, когда состояния нетривиально вычисляются и нужно явно разделить "комбинаторику next-state" и "регистр состояния".

      Но тут да, у нас счётчик с двумя приоритетами (reset выше, инкремент ниже), и весь case сворачивается в один if-else внутри одного always @(posedge clk). Хотя при этом вне зависимости от типа реализации никакой синтез разницы не увидит - RTL даст идентичную схему (тот же FF + тот же инкрементер + тот же mux), но то что код станет короче и читается "как счётчик", а не "как FSM" - это факт. В качестве улучшения к работающей схеме можно переделать будет.

      А про декларацию $clog2 я не знал, ибо я простой энтузиаст не-профессионал :) погляжу


    1. andreyzaostrovnykh Автор
      15.05.2026 03:56

      Плюс еще есть косяк с ним. Параметр N = 32 намертво прибит к декларации, хотя зависит от MAX_TIME x 1000 x FREQ:

      • при FREQ=50 МГц, MAX_TIME=20 мс → TIMER_MAX_VAL = 1_000_000 → нужно 20 бит. N=32 даёт 12 лишних FF (немного, но бесплатных бит не бывает);

      • при FREQ=100, MAX_TIME=100 → TIMER_MAX_VAL = 10_000_000 → нужно 24 бит, N=32 ещё подойдёт;

      • при FREQ=200, MAX_TIME=100 → TIMER_MAX_VAL = 20_000_000 → нужно 25 бит, N=20 уже тихо переполнится и debounce-таймер просто не сработает.

      Надо будет переделать на новый вариант


  1. Kan_Timur
    15.05.2026 03:56

    Я как раз и имел в виду, что разрядность векторов должна автоматом вычисляться.

    Вместо

    module ax_debounce #(
        parameter N        = 32,
        parameter FREQ     = 50,    // MHz
        parameter MAX_TIME = 20     // ms
    )(

    Гораздо удобнее и нет зависимых друг от друга параметров:

    module ax_debounce #(
        parameter FREQ     = 50,    // MHz
        parameter MAX_TIME = 20     // ms
    )(
      ...
      localparam TIMER_MAX_VAL = MAX_TIME * 1000 * FREQ;
      localparam N = $clog2(TIMER_MAX_VAL);
    
      reg [N-1:0] cnt;

    Так же в модуле ax_debounce детектор фронтов реализован три раза: детектор обоих фронтов (DFF1^DFF2), button_posedge, button_negedge, плюс в проекте используются детекторы на шине i2c. Детектор фронтов можно вынести в отдельный подмодуль и использовать внутри ax_debounce. Можно и счетчик вынести, но декомпозиция кода в verilog отдельная тема. Плюсы в том, что каждый отдельный подмодуль удобнее отлаживать, переиспользовать и оптимизировать, чем один "begemoth" модуль. Плюс не смешивается логика разных уровней. И, что немаловажно, удобно смотреть во что синтезируется код каждого отдельного подмодуля и крутить у него ручки.

    Еще заметил, что не хватает синхронизатора на входе кнопки.