Создаем I2C Master Controller на Verilog. Создаем контроллер ядра I2C

Страницы:  1

Ответить
 

Professor Seleznov


Я продолжаю описывать создание I2C-контроллера на Verilog. В предыдущих статьях мы протестировали ядро контроллера который выполняет атомарные функции работы с шиной в т.ч. в пограничных ситуациях типа clock stretching и пр. Теперь необходимо разработать управляющий контроллер для этого ядра, чтобы выполнять необходимые нам функции, но уже на следующем уровне абстракции и стать на шаг ближе к нашей цели - к рабочему коду I2C Controller который мы будем использовать с EEPROM, а в последующих статьях все это переиспользуем в Zynq и подключим к Linux.
Всем заинтересовавшимся - добро пожаловать под кат!
pic
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке 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 во всё необходимое для работы на конкретной плате: генератор тактирования, управление кнопками, индикация результатов на дисплее и светодиодах. Тестовая оболочка в данном случае не является финальной точкой, это будет очередным инструметом разработчика и демонстрационным стендом для проверки и подтверждения что ядро действительно работает на железе.
pic
Проверять будем на плате 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 для записи одного байта:
pic
Протокол для чтения одного байта (random read):
pic
Обратите внимание: для чтения из определённого адреса нужно сначала “установить указатель” записью адреса ячейки, а затем выполнить переключение направления через RESTART и повторную адресацию с битом чтения (0xA1 вместо 0xA0). 
Общая архитектура тестовой оболочки
Тестовая оболочка состоит из пяти модулей. Каждый решает свою задачу и общается с остальными через чётко определённые интерфейсы:
pic
Поток данных, то есть как информация проходит через систему при одном нажатии кнопки:
pic
Разберем код
Теперь разберем внутреннюю механику из которой построен проект. Итоговая иерархия модулей выглядит следующим образом:
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 соответственно.
pic
Все выходы объявлены как 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. Временная диаграмма выглядит следующим образом:
pic
Подавление дребезга кнопки. Модуль 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:
pic
Чтобы показать цифру, нужно зажечь определённую комбинацию сегментов. На плате 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 будет отображать прочерки;
  • во время выполнения теста:
    pic
  • результат теста:
    pic
    Пример при успешном тесте 1: 1 P A 5 A 5 - записали 0xA5, прочитали 0xA5, Pass.
    Пример при ошибке теста 3: 3 F F F E E - записали 0xFF, прочитали 0xEE (ошибка), Fail.
  • итоговый результат:
    pic
    Пример при всех успешных тестах: 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 через тот же интерфейс, который мы тестировали в симуляции.
pic
Протокол взаимодействия такой же, как в тестбенче: ждём 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 - ждёт нажатия кнопки.
      pic
      После нажатия на 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 - успешно: 
      pic
      Производим контрольное чтение:
      pic
      Тест 2. Запись 0х5А в адрес 0х01:
      pic
      И делаем контрольное чтение:
      pic
      Тест 3. Запись 0xFF по адресу 0x10
      pic
      И производим контрольное чтение:
      pic
      И заключительный тест 4. Записываем 0x00 в ячейку 0x11:
      pic
      С контрольным чтением:
      pic
      О том же успехе нам сообщает и сама плата: 
      pic
      Отладка типичных проблем
      Приведу список типовых проблем, как их продиагностировать и как их решить. 
      Если все тесты 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, но для реального железа:
      • 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% при первом пополнении.
      pic
      Воспользоваться-Источник
  •  
    Loading...
    Error