Я продолжаю описывать создание 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 тактов), модуль фиксирует новое состояние кнопки.
Модуль генерирует три выходных сигнала:
button_out- стабильное текущее состояние кнопки;button_posedge- за 1 такт выдает импульс при отпускании кнопки (0 →1);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'd1 → seg_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- подавтомат начнёт с ожиданияreadytest_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 могут отличаться; проверить схему |
Неправильные пины |
Перепроверить .qsf по схеме платы |
Частота сканирования слишком низкая |
Увеличить |
Нестабильные результаты, иногда PASS, иногда FAIL:
Возможная причина |
Решение |
Слишком малая задержка после записи |
Увеличить |
Помехи на I2C шине |
Укоротить провода, проверить pull-up |
Проблемы с питанием EEPROM |
Проверить напряжение |
Помимо этого для отладки можно использовать SignalTap. Если базовая отладка по дисплею и LED недостаточна, Quartus предоставляет встроенный логический анализатор SignalTap II. Он позволяет захватить реальные сигналы внутри FPGA и просмотреть их в виде осциллограмм - аналог GTKWave, но для реального железа:
Tools → SignalTap II Logic Analyzer
Добавить сигналы:
scl_oen, sda_oen, i2c_scl, i2c_sda, core_cmd, core_ready, u_ctrl.stateНастроить триггер: например,
core_cmd_valid == 1Перекомпилировать проект (SignalTap добавляет логику захвата в FPGA)
Запустить захват и нажать KEY1
Просмотреть осциллограмму реальных сигналов
SignalTap использует внутреннюю BRAM FPGA для хранения захваченных данных и JTAG для передачи на PC. Глубина захвата ограничена объемом доступной памяти. Глубоко не буду разбирать этот вопрос в рамках этой статьи.
В качестве заключения
На первом шаге проекта мы написали и верифицировали i2c_master_core в симуляции. На этом, втором шаге, мы:
Выбрали аппаратную платформу - плата ALINX AX301 с Cyclone IV FPGA и EEPROM 24LC04 на борту.
-
Спроектировали тестовую оболочку из пяти модулей:
Прескалер для генерации 100 кГц SCL из 50 МГц тактовой
Debounce для надёжного считывания кнопки
I2C ядро (без изменений из rtl/)
FSM контроллер тестов: 4 теста записи-чтения EEPROM
Мультиплексор дисплея для 6-разрядного семисегментного индикатора
Создали Quartus проект с привязкой пинов, IO стандартами и временными ограничениями.
Получили прошивку, которая при загрузке в FPGA запускает автоматические тесты I2C по нажатию кнопки и показывает результат на дисплее и светодиодах.
Два вида верификации дополняют друг друга. Симуляция находит логические ошибки быстро и дёшево. Аппаратный тест подтверждает, что логика работает в реальных физических условиях. Следующий шаг - сделаем потоковую запись в дисплей OLED с контроллером SSD1306 и проверим работоспособность burst-записи с использованием нашего кода.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

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

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" модуль. Плюс не смешивается логика разных уровней. И, что немаловажно, удобно смотреть во что синтезируется код каждого отдельного подмодуля и крутить у него ручки.
Еще заметил, что не хватает синхронизатора на входе кнопки.
Kan_Timur
В модуле ax_debounce счетчик реализован в виде конечного автомата? По-моему присутствует небольшой оверкилл. Отсюда и два вектора q_reg, q_next на один счетчик.
Почему не используете $clog2 для вычисления разрядности? Так в модулях появляются зависимые параметры. Если в проекте нет зависимых параметров – меняешь любой без головной боли, а если есть хоть один, то нужно помнить все или каждый раз лезть в код.
andreyzaostrovnykh Автор
Да, согласен, может выглядеть как оверкилл. Это скорее не преднамеренное задействование из предложенного китайцами исходника, взятого as is.
Здесь применён классический двухпроцессный стиль _reg/_next - и это вроде как правильный и рекомендуемый паттерн для конечных автоматов, когда состояния нетривиально вычисляются и нужно явно разделить "комбинаторику next-state" и "регистр состояния".
Но тут да, у нас счётчик с двумя приоритетами (reset выше, инкремент ниже), и весь
caseсворачивается в один if-else внутри одногоalways @(posedge clk). Хотя при этом вне зависимости от типа реализации никакой синтез разницы не увидит - RTL даст идентичную схему (тот же FF + тот же инкрементер + тот же mux), но то что код станет короче и читается "как счётчик", а не "как FSM" - это факт. В качестве улучшения к работающей схеме можно переделать будет.А про декларацию $clog2 я не знал, ибо я простой энтузиаст не-профессионал :) погляжу
andreyzaostrovnykh Автор
Плюс еще есть косяк с ним. Параметр
N = 32намертво прибит к декларации, хотя зависит отMAX_TIMEx 1000x 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-таймер просто не сработает.Надо будет переделать на новый вариант