Идея простая — читаем и записываем данные по нажатию клавиш на одной из отладок с Cyclone IV, которые я рассматривал в одном из своих обзоров.
Если материал вам кажется интересным — добро пожаловать, с удовольствием и в свойственной мне манере расскажу, чего мне удалось добиться, а чего не удалось. 🙂
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
❯ Давайте для начала определимся с общей идеей
Первым шагом нужно определиться, к чему стремимся и чего хотим в итоге получить.
В первую очередь нужно будет подключить к отладке плату с несколькими кнопками и сделать обработку входящих сигналов с антидребезгом. Каждая из кнопок должна будет выполнять свою функцию.
Вывод всех данных будет осуществляться на 7-сегментный индикатор с 8 разрядами который установлен на отладку. Поэтому нужно будет написать соответствующий драйвер для вывода информации на этот индикатор.
Необходимо также выводить ACK-сигнал на плату, чтобы увидеть что транзакция выполнена успешно.
Раз мы хотим организовать общение с EEPROM — то:
- Первой клавишей будет активировано действие на чтение данных из ячейки с заданным адресом и вывод прочитанных данных на семисегментный дисплей;
- Вторая клавиша будет производить запись выбранного значения в заданную ячейку памяти;
- Третья клавиша будет предназначена для инкремента значения текущего адреса памяти EEPROM в который будет произведена запись значения в диапазоне от 0x00 до 0xFF, ну или чтение;
- Четвертая клавиша будет декрементировать значение адреса памяти;
- Пятая клавиша будет предназначена для инкремента значения полезных данных, которые будут записаны в выбранный адрес ячейки памяти;
- Шестая, соответственно, будет декрементировать это значение;
- На плате клавиша RESET будет отвечать за асинхронный сброс.
Выглядит как набор небольших задач, при выполнении которых получится то что нужно. Поехали.
❯ Что необходимо для выполнения задачи?
Итак, для реализации задачи понадобится:
- Отладочная плата Saylinx с Cyclone IV, которую я обозревал в этой статье. Она подходит для моей цели как раз потому что на плате есть EEPROM и семисегментный индикатор;
- Программатор Altera USB Blaster для прошивки платы и отладки;
- На плате понадобится семисегментный индикатор. У индикатора есть 8 разрядов, первые два из которых мы задействуем под указание того, какой адрес памяти сейчас выбран, третий и четвертый — под выбор полезных данных для записи в ячейку, пятый и шестой — под вывод считанных данных из EEPROM;
- На плате должен быть EEPROM. И он там есть 🙂;
- Платка с кнопками и соединительными проводками для PLS-гребенки, потому что на отладке их не так много как хочется;
- Логический анализатор, типа какого-нибудь DSLogic Basic и программа DSView (можно найти на Али). Он понадобится также для наблюдения за транзакциями в реальном железе.
Плюсом к этому потребуется, конечно же рабочий HDL-код для описания цифровой схемы, которая позволит реализовать желаемое. Ну что ж, давайте попробуем сделать задумку!)
❯ Шаг нулевой. Создаем проект и размечаем пины
Этот шаг я описывать отдельно не буду, думаю вы уже научились из прошлых статей создавать новый проект и добавлять в него новые файлы. После этого необходимо добавить Top-Level Design File и указать его в настройках проекта.
Следующий подготовительный этап — создать главный модуль и определить входные и выходные сигналы:
module top_module
(
input clk_i, // Входной сигнал для тактирования
input rstn_i, // Входной сигнал для сброса, 0 - сброс
input [2:0] btn_main_i, // Входной сигнал от кнопок на плате
input [5:0] btn_brd_i, // Входные сигналы от платы с кнопками
output [3:0] led_brd_o, // Выходные сигналы для светодиодов
output [5:0] seg_sel_o, // Выбор активного разряда семисегментного дисплея
output [7:0] seg_data_o, // Данные для отображения на семисегментном дисплее
inout sda_io, // Линия I2C SDA
output scl_io // Линия I2C SCL
);
endmodule
После этого можно сразу обозначить пины, которые будут передавать эти сигналы, чтобы не возвращаться к этому вопросу. Для этого необходимо открыть схематик платы и определить пины, к которым подключено то, что нам нужно. Сразу же стоит обратить внимание каким образом осуществлено подключение, чтобы выбрать правильный способ управления сигналами.
Начнем с сигнала системного тактирования. Видим, что на плате установлен кварцевый генератор с частотой 50MHz:
И подключен CLK к ножке E1:
Найдем в схеме светодиоды. На плате у нас их всего 4 штуки:
Переходим к разделу схемы где цепи LEDx подключаются к ПЛИС:
Отлично. LED0 — E10, LED1 — F9, LED2 — C9, LED3 — D9. Я обычно записываю эти данные на на отдельный листочек, чтобы потом в Pin Planner сразу указать нужные пины, не перерывая схематик снова.
Идём дальше. Кнопки. Качество китайских схематиков как обычно “на высоте” и ссылок по цепям нет, поэтому включаем поиск и ищем по ключевым словам.
Находим кнопки KEY1, KEY2, KEY3 на ножках M15, M16, E16 соответственно:
И кнопку RESET — тут, на N13:
Далее необходимо определиться к каким пинам подключить плату с кнопками и я выбрал левую гребенку на плате и следующие пины:
К сожалению, кривой китайский схематик показывает данный элемент кверху ногами, но шелкография на обратной стороне платы позволяет достаточно быстро сориентироваться в распиновке. Получается следующее:
- кнопка записи — подключаем к пину N2;
- кнопка чтения — подключаем к пину P1;
- кнопка прибавления единицы к значению адреса ячейки памяти — пин P2;
- кнопка вычитания единицы из значения адреса выбранной ячейки памяти — пин R1;
- кнопка прибавления единицы к записываемому числу в ячейку памяти — пин P8;
- кнопка вычитания единицы из записываемого числа в ячейку памяти — пин K9.
Далее переходим к семисегментному индикатору:
Тут пины все подписаны. Не буду их дополнительно перечислять. Хоть где-то сделали нормальное указание цепей, чтобы не блуждать по схематику 😀 в поисках пина, к которому подключена периферия. О принципе работы семисегментника я расскажу чуть позже.
И остается последний штрих — пины SDA и SCL микросхемы EEPROM:
И самые внимательные читатели заметят — что на пинах D1, E6 находятся сигналы выбора SEL6 и SEL7 и сигналы SCL, SDA. Поэтому последние два разряда мы не сможем задействовать в нашем проекте. И они будут постоянно показывать всякую хрень, будем держать это во внимании.
Теперь можно скомпилировать проект и перейти в Pin Planner (Assignments — Pin Planner) чтобы занести значения пинов. У меня получился вот такой внушительный список:
Обратите внимание, что I/O Standard указан 3.3-V LVTTL. Указываем и закрываем данное окно. Теперь можно к этому этапу больше не возвращаться, если не собираетесь менять имена цепей, иначе их придется размечать заново на новые имена.
❯ Шаг первый. Драйвер LED-ов
В первую очередь стоит начать с самых простых задач, чтобы раскачать энтузиазм — сделаем простой драйвер для LED-индикаторов.
Добавляем в проект файл led_driver.v и в нём мы пишем простую логику управления сигналами. Думаю в дополнительном комментировании она не нуждается:
module led_driver
(
input clk_i, // Сигнал тактирования
input rstn_i, // Сигнал асинхронного сброса
input state_i, // Входное значение, 1 - горит, 0 - не горит
output led_o // Выходной сигнал для LED
);
reg led_r;
always @ (posedge clk_i or negedge rstn_i)
begin
if (~rstn_i) begin
led_r <= 0;
end
else if(state_i) begin
led_r <= 1'b1;
end
else begin
led_r <= 1'b0;
end
end
assign led_o = led_r;
endmodule
После этого можно добавить эти модули в Top Level Design и идти дальше:
//#################################################
// LED Drivers
//#################################################
reg ack_bit_r; // Регистр для хранения значения ACK
wire ack_bit_w; // Провод который будет идти из I2C Bit Controller
reg led_write_pulse_r; // Для отладки: индикатор нажатия кнопки Write
reg led_read_pulse_r; // Для отладки: индикатор нажатия кнопки Read
// ACK bit LED
led_driver led_driver_m0 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.state_i (ack_bit_w),
.led_o (led_brd_o[0])
);
// Pulse Write LED
led_driver led_driver_m1 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.state_i (led_read_pulse_r),
.led_o (led_brd_o[1])
);
// Pulse Read LED
led_driver led_driver_m2 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.state_i (led_write_pulse_r),
.led_o (led_brd_o[2])
);
Последний, 4-й светодиод оставим незадействованным. Идем дальше.
❯ Шаг второй. Драйвер кнопок
Следующим шагом необходимо накидать модуль обработки входных сигналов с ножек GPIO для того, чтобы использовать их потом в качестве “рычагов” для определенных экшенов.
Все знают, что дребезг механических кнопок и переключателей — это стандартная проблема, которая требует дополнительного модуля обработки и фильтрации. Об этом я писал в этой статье, в главе “Модуль Debouncer”.
Выглядит эта ситуация вот таким образом:
Итак. Добавим в проект файл с именем gpio_debouncer.v. В этот раз я решил накидать несколько видоизмененный модуль. Общий принцип остается таким же как и в прошлых статьях — когда нажата кнопка и удерживается необходимое количество времени (очень короткий по человеческим ощущениям период) — запускается счетчик который достигая определенного значения — передает значение на выходной порт. Если кнопка отжимается — то сигнал устанавливается в ноль. В дополнение к этому генерируется импульс на нажатие кнопки, и импульс на момент отжатия кнопки. Они, точнее один из импульсов — нам очень пригодится в будущем.
Итак. Опишу коротко как я создавал этот модуль. В первую очередь я определил какие входные, выходные сигналы будут у данного модуля:
module gpio_debouncer
// Порты
(
input clk_i, // Сигнал тактирования
input rstn_i, // Сигнал асинхронного сброса
input button_i, // Сигнал с физической кнопки
output reg button_posedge_r, // Импульс на нажатие кнопки
output reg button_negedge_r, // Импульс на отжатие кнопки
output reg button_out_r // Фильтрованный сигнал с кнопки
);
endmodule
Следующим шагом необходимо определить какой величины будет счётчик, какова минимальная длительность нажатия, чтобы ее считать стабильным нажатием а не набором импульсов, которым из себя представляет момент нажатия механической кнопки.
Для этого я сделал ряд служебных параметров у данного модуля:
// Глобальные параметры
#(
parameter CNT_WIDTH = 32, // Разрядность счетчика таймера
parameter FREQ = 50, // Глобальная частота тактирования
parameter MAX_TIME = 20 // Длительность стабильного удержание кнопки
)
// Локальные параметры
localparam TIMER_MAX_VAL = MAX_TIME * 1000 * FREQ; // Максимальное значение таймера
Далее необходимо объявить несколько вспомогательных регистров (D-триггеров) и флаг сброса счётчика если детектирован дребезг. Они будут выполнять функцию сброса счетчика, если их значения будут отличаться, т.е. когда будет дребезг контактов в виде хаотичного изменения входного сигнала — сбрасываем счётчик:
// Input flip-flops
reg DFF1;
reg DFF2;
wire q_reset;
assign q_reset = (DFF1 ^ DFF2); // XOR для наблюдения за дребезгом
Еще нужно объявить два регистра для счётчика и один вспомогательный флаг:
// Timing regs
reg [CNT_WIDTH-1:0] q_reg; // Регистр счетчика
reg [CNT_WIDTH-1:0] q_next; // Вспомогательный регистр
wire q_add;
assign q_add = ~(q_reg == TIMER_MAX_VAL); // Флаг на разрешение инкремента счётчика
Теперь нужно сделать поведенческий блок, который будет осуществлять прибавление счётчика. Сигналов на изменение поведения будет несколько:
- q_reset — это сигнал о том, что нужно сбросить счетчик;
- q_add — это сигнал о том, что можно производить инкремент счётчика;
- q_reg — это сам счётчик.
Общая идея заключается в том, что если сигнала q_reset нет, а есть сигнал на q_add, т.е. не достигнут максимум счётчика — то прибавляем значение.
always @(q_reset, q_add, q_reg)
begin
case({q_reset , q_add})
2'b00 : // Достигнут максимум счётчика
q_next <= q_reg; // Оставляем значение тем же
2'b01 : // Не достигнут максимум
q_next <= q_reg + 1; // Добавляем к значению +1
default : // Все остальные случаи
q_next <= {CNT_WIDTH {1'b0}}; // Обнуляем значение
endcase
end
И добавим блок, который будет обновлять значение q_reg и реагировать на входной сигнал:
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin // Если произошел сброс то обнуляем значения
DFF1 <= 1'b0;
DFF2 <= 1'b0;
q_reg <= {CNT_WIDTH {1'b0}};
end
else begin
DFF1 <= button_i; // Фиксируем входное значение
DFF2 <= DFF1; // Передаем его второму D-триггеру
q_reg <= q_next; // Обновляем значение счётчика
end
end
Добавим блок для формирования выходного сигнала в случае если достигнут предел счёта:
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0)
button_out_r <= 1'b1;
else if(q_reg == TIMER_MAX_VAL)
button_out_r <= DFF2;
else
button_out_r <= button_out_r;
end
И добавим блок для формирования импульсов:
reg button_out_d0_r;
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin
button_out_d0_r <= 1'b1;
button_posedge_r <= 1'b0;
button_negedge_r <= 1'b0;
end
else begin
button_out_d0_r <= button_out_r;
button_posedge_r <= ~button_out_d0_r & button_out_r;
button_negedge_r <= button_out_d0_r & ~button_out_r;
end
end
В итоге получился следующий модуль:
module gpio_debouncer
// Global parameters
#(
parameter CNT_WIDTH = 32, // Debounce timer bitwidth
parameter FREQ = 50, // Global clock (MHz)
parameter MAX_TIME = 20 // Total delay time in ms
)
// Ports
(
input clk_i, // Clock input
input rstn_i, // Reset input
input button_i,
output reg button_posedge_r,
output reg button_negedge_r,
output reg button_out_r
);
localparam TIMER_MAX_VAL = MAX_TIME * 1000 * FREQ; // Maximum timer value
// Timing regs
reg [CNT_WIDTH-1:0] q_reg;
reg [CNT_WIDTH-1:0] q_next;
// Input flip-flops
reg DFF1;
reg DFF2;
// Control flags
wire q_add;
wire q_reset;
reg button_out_d0_r;
// Continous assignment for counter control
assign q_reset = (DFF1 ^ DFF2);
assign q_add = ~(q_reg == TIMER_MAX_VAL);
// Combo counter to manage q_next
always @(q_reset, q_add, q_reg)
begin
case({q_reset , q_add})
2'b00 :
q_next <= q_reg;
2'b01 :
q_next <= q_reg + 1;
default :
q_next <= {CNT_WIDTH {1'b0}};
endcase
end
// Flip flop inputs and q_reg update
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin
DFF1 <= 1'b0;
DFF2 <= 1'b0;
q_reg <= {CNT_WIDTH {1'b0}};
end
else begin
DFF1 <= button_i;
DFF2 <= DFF1;
q_reg <= q_next;
end
end
// Counter control
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0)
button_out_r <= 1'b1;
else if(q_reg == TIMER_MAX_VAL)
button_out_r <= DFF2;
else
button_out_r <= button_out_r;
end
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin
button_out_d0_r <= 1'b1;
button_posedge_r <= 1'b0;
button_negedge_r <= 1'b0;
end
else begin
button_out_d0_r <= button_out_r;
button_posedge_r <= ~button_out_d0_r & button_out_r;
button_negedge_r <= button_out_d0_r & ~button_out_r;
end
end
endmodule
В итоге можете сделать testbench-файл в котором можно поэкспериментировать с входными сигналами и отследить как работает данный модуль. Но если останавливаться на этом в этой статье, она получится крайне объемной, поэтому идем дальше.
Сразу же добавим в Top Level Design все экземпляры модуля для обработки сигналов с кнопок:
//#################################################
// GPIO Buttons Debouncers
//#################################################
wire btn_read_negedge_w;
wire btn_write_negedge_w;
wire btn_reg_p_negedge_w;
wire btn_reg_n_negedge_w;
wire btn_data_p_negedge_w;
wire btn_data_n_negedge_w;
gpio_debouncer gpio_debouncer_m0 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[0]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_read_negedge_w)
);
gpio_debouncer gpio_debouncer_m1 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[1]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_write_negedge_w)
);
gpio_debouncer gpio_debouncer_m2 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[2]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_reg_p_negedge_w)
);
gpio_debouncer gpio_debouncer_m3 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[3]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_reg_n_negedge_w)
);
gpio_debouncer gpio_debouncer_m4 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[4]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_data_p_negedge_w)
);
gpio_debouncer gpio_debouncer_m5 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.button_i (btn_brd_i[5]),
.button_out_r (),
.button_negedge_r (),
.button_posedge_r (btn_data_n_negedge_w)
);
Перейдем дальше к следующему элементу нашей конструкции.
❯ Шаг третий. Управление семисегментным индикатором
Данная задача делится на два этапа. Первый — это декодер 8-битных значений регистров на символы для каждого из сегментов. Второй — это главный драйвер, который будет осуществлять вывод данных на сегменты.
Разберемся сначала со схемотехникой индикатора, откроем схему:
У семисегментников, в каждом разряде используются одни и те же пины для включения конкретно взятых сегментов. И 4 пина которые отвечают за зажигание отдельно взятого разряда.
Общая логика управления состоит в том, что нужно сначала выставить нужные данные на пинах данных (отмечены буквами) и потом выбрать на каком сегменте их отобразить. Чтобы вывести сложный набор цифр, надо постоянно чередовать разряды выставляя соответствующие ему значения сегментов. Как этим управлять — чуть позже.
Сделаем декодер бинарных данных в формат для вывода на дисплей. Создаем файл seg_decoder.v и пишем код модуля. Тут все просто и очевидно:
module seg_decoder
(
input[3:0] bin_data_i, // Binary data input
output reg[6:0] seg_data_o // Seven segments LED output
);
always@(*)
begin
case(bin_data_i)
4'd0: seg_data_o <= 7'b1000000;
4'd1: seg_data_o <= 7'b1111001;
4'd2: seg_data_o <= 7'b0100100;
4'd3: seg_data_o <= 7'b0110000;
4'd4: seg_data_o <= 7'b0011001;
4'd5: seg_data_o <= 7'b0010010;
4'd6: seg_data_o <= 7'b0000010;
4'd7: seg_data_o <= 7'b1111000;
4'd8: seg_data_o <= 7'b0000000;
4'd9: seg_data_o <= 7'b0010000;
4'hA: seg_data_o <= 7'b0001000;
4'hB: seg_data_o <= 7'b0000011;
4'hC: seg_data_o <= 7'b1000110;
4'hD: seg_data_o <= 7'b0100001;
4'hE: seg_data_o <= 7'b0000110;
4'hF: seg_data_o <= 7'b0001110;
default:seg_data_o <= 7'b1111111;
endcase
end
endmodule
Создадим модуль для вывода данных. Создаем файл seg_scan.v и сделаем заготовку модуля. В целом он также достаточно простой:
module seg_scan
(
input clk_i,
input rstn_i,
output reg[7:0] seg_sel_o, // Выбор разряда
output reg[7:0] seg_data_o, // Выбор сегмента
input[7:0] seg_data_0_i,
input[7:0] seg_data_1_i,
input[7:0] seg_data_2_i,
input[7:0] seg_data_3_i,
input[7:0] seg_data_4_i,
input[7:0] seg_data_5_i,
input[7:0] seg_data_6_i,
input[7:0] seg_data_7_i
);
endmodule
Добавляем параметры для тонкой настройки:
// Global parameters
#(
parameter SCAN_FREQ = 200; // Частота обновления данных
parameter CLK_FREQ = 50000000; // Системная частота тактирования
parameter SCAN_COUNT = CLK_FREQ / (SCAN_FREQ * 8) - 1;
)
Введем несколько вспомогательных регистров:
reg [31:0] scan_timer_r; // Scan time counter
reg [3:0] scan_sel_r; // Scan select counter
Добавляем поведенческий блок, который будет с определенным таймаутом включать сегменты:
always@(posedge clk_i or negedge rstn_i)
begin
if(~rstn_i)
begin
scan_timer_r <= 32'd0;
scan_sel_r <= 4'd0;
end
else if(scan_timer_r >= SCAN_COUNT)
begin
scan_timer_r <= 32'd0;
if(scan_sel_r == 4'd5)
scan_sel_r <= 4'd0;
else
scan_sel_r <= scan_sel_r + 4'd1;
end
else begin
scan_timer_r <= scan_timer_r + 32'd1;
end
end
И добавляем управление пином выбора сегмента с параллельным выставлением данных на сегменты:
always@(posedge clk_i or negedge rstn_i)
begin
if(~rstn_i)
begin
seg_sel_o <= 8'b1111_1111;
seg_data_o <= 8'hFF;
end
else
begin
// Digital LEDs choose
case(scan_sel_r)
4'd0: begin
seg_sel_o <= 8'b1111_1110;
seg_data_o <= seg_data_0_i;
end
4'd1: begin
seg_sel_o <= 8'b1111_1101;
seg_data_o <= seg_data_1_i;
end
4'd2: begin
seg_sel_o <= 8'b1111_1011;
seg_data_o <= seg_data_2_i;
end
4'd3: begin
seg_sel_o <= 8'b1111_0111;
seg_data_o <= seg_data_3_i;
end
4'd4: begin
seg_sel_o <= 8'b1110_1111;
seg_data_o <= seg_data_4_i;
end
4'd5: begin
seg_sel_o <= 8'b1101_1111;
seg_data_o <= seg_data_5_i;
end
4'd6: begin
seg_sel_o <= 8'b1011_1111;
seg_data_o <= seg_data_6_i;
end
4'd7: begin
seg_sel_o <= 8'b0111_1111;
seg_data_o <= seg_data_7_i;
end
default: begin
seg_sel_o <= 8'b1111_1111;
seg_data_o <= 8'hFF;
end
endcase
end
end
Тут тоже все очень просто, кажется что комментировать тут нечего. Значение 0 в конкретном разряде выбирает конкретный сегмент, потому что установлены PNP-транзисторы для управления сопротивлением канала. При этом выставляется соответствующее значение для набора сегментов, в соответствии с данными.
Добавим в Top Level модуль экземпляры вышеописанных модулей для работы с семисегментным дисплеем:
//#################################################
// 7 Segments Display Drivers
//#################################################
wire[6:0] seg_data_0_w;
wire[6:0] seg_data_1_w;
wire[6:0] seg_data_2_w;
wire[6:0] seg_data_3_w;
wire[6:0] seg_data_4_w;
wire[6:0] seg_data_5_w;
seg_decoder seg_decoder_m0 (
.bin_data_i (reg_addr_r[7:4]),
.seg_data_o (seg_data_0_w)
);
seg_decoder seg_decoder_m1 (
.bin_data_i (reg_addr_r[3:0]),
.seg_data_o (seg_data_1_w)
);
seg_decoder seg_decoder_m2 (
.bin_data_i (data_write_r[7:4]),
.seg_data_o (seg_data_2_w)
);
seg_decoder seg_decoder_m3 (
.bin_data_i (data_write_r[3:0]),
.seg_data_o (seg_data_3_w)
);
seg_decoder seg_decoder_m4 (
.bin_data_i (read_data_r[7:4]),
.seg_data_o (seg_data_4_w)
);
seg_decoder seg_decoder_m5 (
.bin_data_i (read_data_r[3:0]),
.seg_data_o (seg_data_5_w)
);
// Main driver for 7-seg display
seg_scan seg_scan_m0 (
.clk_i (clk_i),
.rstn_i (rstn_i),
.seg_sel_o (seg_sel_o),
.seg_data_o (seg_data_o),
.seg_data_0_i ({1'b1, seg_data_0_w}),
.seg_data_1_i ({1'b1, seg_data_1_w}),
.seg_data_2_i ({1'b1, seg_data_2_w}),
.seg_data_3_i ({1'b1, seg_data_3_w}),
.seg_data_4_i ({1'b1, seg_data_4_w}),
.seg_data_5_i ({1'b1, seg_data_5_w}),
.seg_data_6_i ({1'b1, 7'b1111_111}), // Don't use this segments, busy by I2C
.seg_data_7_i ({1'b1, 7'b1111_111}) // Don't use this segments, busy by I2C
);
Данный HDL-код легко читаем и в дополнительном комментировании, уверен, не нуждается. Идём дальше.
r
❯ Шаг четвертый. Делитель частоты для I2C Bit Controlle
module clock_divider
(
input clk_i,
output reg clk_o
);
endmodule
Добавляем параметры для тонкой настройки:
// Global parameters
#(
parameter DIVISOR = 32; // Частота обновления данных,
)
Логика делителя очень простая. На вход модуля подается основной тактовый сигнал в 50 MHz и с помощью счетчика, при достижении определенного значения параметра DIVISOR, производится инверсия выходного сигнала.
Добавляем регистр счётчика и поведенческий блок:
reg[27:0] counter = 28'd0;
always @(posedge clk_i)
begin
counter <= counter + 28'd1;
if(counter >= (DIVISOR - 1))
counter <= 28'd0;
clk_o <= (counter < DIVISOR / 2) ? 1'b1 : 1'b0;
end
Вставляем данный модуль в Top Level Design модуль:
//#################################################
// Clock Divider for I2C Bit Controller
//#################################################
wire clk_div_w;
clock_divider clock_divider_m0 (
.clk_i (clk_i),
.clk_o (clk_div_w),
);
Так. Со всеми простыми элементами дизайна мы разобрались — осталось самое сложное (для меня), с чем я дольше всего ломал голову.
❯ Шаг пятый. Главный модуль управления I2C сигналами
Следующим этапом передо мной стояла задача организовать управление получившимся в прошлом уроке битовым контроллером, который абстрагирован от отдельных транзакций и чисто крутит отдельными битиками когда ему скажут по определенным командам.
Решение я “рожал” достаточно долго, потому что приходилось учитывать целую совокупность факторов, которые должны были сойтись и синхронно отрабатывать то что мне нужно. И главный вопрос, который нужно было решить — каким образом детектировать момент когда можно переходить к выставлению следующей команды и очередной порции данных. Самый простой и очевидный способ — это ввести счетчик завершения выполнения отдельных транзакций, который будет инкрементироваться от изменения сигнала ready.
Пришлось потратить большое количество времени, чтобы получить работу автомата такой, как я ее ожидал. В остальном кажется многое просто требовало немного логического размышления, прокручивания алгоритма действия в голове и наблюдательности.
Итак, опишу, что в итоге получилось. Добавим модуль и вспомогательные элементы сразу же в Top Level Design модуль. Первый элемент это адрес Slave-устройства, т.е. нашей EEPROM. Посмотрев в Datasheet данной EEPROM и на подключение ее ножек адреса, стало ясно, что адрес на чтение будет 0xA1, а на запись 0xA0. Поэтому Contol Byte мы будем клеить из двух частей 7'b1010000 и бита операции. Далее увидите как это выглядит.
Добавим это в модуль:
//#################################################
// Main Operation FSM
//#################################################
// Адрес EEPROM
localparam SLAVE_ADDR = 7'b1010000;
Я постоянно забывал, какой бит выставляется в Control Byte с адресом Slave-устройства для чтения, а какой бит для записи. В итоге просто записал константы для удобства использования:
// Биты для подставления в Control Byte I2C
localparam READ_BIT = 1'b1;
localparam WRITE_BIT = 1'b0;
Для управления транзакциями — мне потребовался отдельный автомат с конечными состояниями и его возможные варианты состояний и регистр для их хранения сразу же и объявим:
// Состояния FSM и регистр для них
localparam IDLE_STATE = 4'd1;
localparam WRITE_STATE = 4'd2;
localparam READ_STATE = 4'd3;
localparam WAIT_STATE = 4'd4;
reg [3:0] state_r = IDLE_STATE;
Набор команд, которые мы будем подавать на вход I2C Bit Controller — тоже заранее объявим тут:
// Те самые команды из прошлого урока
localparam START_CMD = 4'd1;
localparam WR_CMD = 4'd2;
localparam RD_CMD = 3'd3;
localparam STOP_CMD = 4'd4;
localparam RESTART_CMD = 4'd5;
reg [2:0] cmd_r = START_CMD;
В определенных местах мне понадобилась искусственная задержка и пришлось подставить костыль, когда не срабатывала последняя команда STOP. Позже покажу где я его поставил, может вы придумаете более изящное решение получившейся у меня проблемы. Объявим регистр для хранения значения таймера:
// Счетчик таймера
reg [31:0] timer_r;
Для старта операций в I2C Bit Controller необходим специальный сигнал — wr_i2c. Объявим для него свой регистр:
// Команда для старта операций в I2C Bit Ctrl
reg wr_i2c_r;
Для передачи адреса Slave устройства, адреса ячейки памяти и значения — объявим три регистра:
reg [6:0] slave_addr_r = SLAVE_ADDR;
reg [7:0] reg_addr_r = 0;
reg [7:0] data_write_r = 0;
Для читаемых и записываемых данных объявим регистры-буферы:
// Data buffers
reg [7:0] read_data_r;
wire [7:0] read_data_w;
reg [7:0] write_data_r;
Для бита ACK-так же необходимо своё хранилище и провод который будет идти от модуля I2C Bit Controller:
reg ack_bit_r;
wire ack_bit_w;
Для сигнала готовности модуля — тоже необходим отдельный сигнал:
wire ready_w;
Добавим в Top Level Design модуль из прошлого урока (отладочный сигнал state_o, который я использовал в прошлом уроке — я убрал):
//#################################################
// I2C Bit Controller
//#################################################
i2c_bit_controller i2c_bit_controller_m0 (
.rstn_i(rstn_i), // Сигнал асинхронного сброса
.clk_i(clk_div_w), // Поделенная частота
.wr_i2c_i(wr_i2c_r), // Сигнал на старт транзакций
.cmd_i(cmd_r), // Команда для исполнения
.din_i(write_data_r), // Данные для записи
.dout_o(read_data_w), // Прочитанные данные
.ack_o(ack_bit_w), // ACK-бит
.ready_o(ready_w), // Сигнал готовности модуля
.sda_io(sda_io), // Линия данных SDA
.scl_io(scl_io) // Линия тактового сигнала SCL
);
Поскольку напрямую регистры для данных и ACK-бита подключить к модулю не получится (на самом деле не понял до конца почему), необходимо сделать поведенческий блок, который будет сохранять значение прочитанных данных и ACK-бита в регистр:
always @(*) begin
read_data_r <= read_data_w;
ack_bit_r <= ack_bit_w;
end
Следующим шагом сделаем обработчик действий на кнопки для выбора регистра записи и данных для записи:
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin
reg_addr_r <= 0;
data_write_r <= 0;
end
else begin
if(btn_reg_p_negedge_w) begin
reg_addr_r = reg_addr_r + 1;
end
if(btn_reg_n_negedge_w) begin
reg_addr_r = reg_addr_r - 1;
end
if(btn_data_p_negedge_w) begin
data_write_r = data_write_r + 1;
end
if(btn_data_n_negedge_w) begin
data_write_r = data_write_r - 1;
end
end
end
Выглядит очень просто, и кажется что никаких дополнительных пояснений тут не требуется.
Добавим поведенческий блок, который будет подсчитывать количество выполненных транзакций в случае записи или чтения данных. Каждое возведение сигнала ready_w в значение логической единицы — будет основным сигналом для поведенческого блока и счётчик будет увеличиваться. У каждой из операций — есть конечное количество отдельных транзакций, которые нужно сделать, послеих выполнения — нужно сбросить счётчик.
Далее вы увидите как это было использовано, а пока добавим HDL-код в Top-модуль:
// Counter for transactions
reg [4:0] counter_r = 0;
always @(posedge ready_w or negedge rstn_i) begin
if(rstn_i == 1'b0) begin
counter_r = 0;
end
else begin
counter_r = counter_r + 1;
case(state_r)
READ_STATE: begin
if (counter_r == 7) begin
counter_r = 0;
end
end
WRITE_STATE: begin
if (counter_r == 5) begin
counter_r = 0;
end
end
default: begin
counter_r = 0;
end
endcase
end
end
Перейдем к созданию основного блока, который будет реализовывать транзакции и делаем сброс значений при асинхронном сбросе:
always @(posedge clk_i or negedge rstn_i)
begin
if(rstn_i == 1'b0) begin
led_write_pulse_r <= 0;
led_read_pulse_r <= 0;
cmd_r <= START_CMD;
state_r <= IDLE_STATE;
write_data_r <= 0;
wr_i2c_r <= 0;
end
else begin
end
end
В основном блоке создаем простую State-машину:
Описываем ее следующим образом и расставим управление сигналом старта транзакций wr_i2c_r:
case(state_r)
IDLE_STATE: begin
wr_i2c_r = 0;
end
READ_STATE: begin
wr_i2c_r = 1;
end
WRITE_STATE: begin
wr_i2c_r = 1;
end
WAIT_STATE: begin
wr_i2c_r = 1;
end
default: begin
wr_i2c_r = 0;
end
endcase
В первую очередь сделаем обработку импульсов на исполнение команды записи или чтения с кнопок, если автомат готов. Если приходит импульс — то приходим в следующее состояние, в зависимости от того с какой кнопки пришла команда:
IDLE_STATE: begin
wr_i2c_r = 0;
// Button for Read operation
if(btn_read_negedge_w) begin
if(ready_w) begin
state_r = READ_STATE;
end
end
// Button for Write operation
if(btn_write_negedge_w) begin
if(ready_w) begin
state_r = WRITE_STATE;
end
end
end
Опишем операции на READ_STATE. Тут все просто — реагируем на каждое увеличение счетчика counter_r, это означает, что автомат готов выполнять следующую операцию. Получилось следующее:
READ_STATE: begin
led_read_pulse_r <= ~led_read_pulse_r; // Для отладки
wr_i2c_r = 1; // Стартуем транзакции
case(counter_r)
0: begin end
1: begin
write_data_r = {slave_addr_r, WRITE_BIT}; // Выставляем данные
end
2: begin
write_data_r = reg_addr_r;
end
3: begin
cmd_r = RESTART_CMD;
write_data_r = {slave_addr_r, READ_BIT};
end
4: begin
cmd_r = WR_CMD;
write_data_r = {slave_addr_r, READ_BIT};
end
5: begin
cmd_r = RD_CMD;
end
6: begin
cmd_r = STOP_CMD;
state_r = WAIT_STATE;
timer_r = 0;
end
default: begin
cmd_r = START_CMD;
state_r = IDLE_STATE;
write_data_r = 0;
end
endcase
end
Тут в целом все легко читается, понятно что происходит каждую посылку. Открыв даташит на EEPROM видно, каким образом осуществляется чтение:
Вы можете самостоятельно сопоставить то, что происходит в коде с тем, как должна быть организована транзакция на Random Read, т.е. на чтение рандомной ячейки памяти.
Перейдем к операции Random Write в Datasheet:
Тоже достаточно очевидно что дожно происходить при записи. Опишем секцию WRITE_STATE. Тут даже несколько проще чем в READ_STATE:
WRITE_STATE: begin
led_write_pulse_r <= ~led_write_pulse_r;
wr_i2c_r = 1;
case(counter_r)
0: begin end
1: begin
write_data_r = {slave_addr_r, WRITE_BIT};
end
2: begin
write_data_r = reg_addr_r;
end
3: begin
write_data_r <= data_write_r;
end
4: begin
cmd_r = STOP_CMD;
state_r = WAIT_STATE;
timer_r = 0;
end
default: begin
cmd_r = START_CMD;
state_r = IDLE_STATE;
write_data_r = 0;
end
endcase
end
Следующий state, который необходимо добавить, в основном для покостыливания невыполнения STOP-команды — это WAIT_STATE:
WAIT_STATE: begin
wr_i2c_r = 1;
if(timer_r >= 32'd1000) begin
state_r <= IDLE_STATE;
write_data_r = 0;
cmd_r = START_CMD;
end
else
timer_r <= timer_r + 32'd1;
end
Ждём условно 1000 тактов и переходим в IDLE_STATE.
Добавим также обработчик для всех остальных случаев:
default: begin
wr_i2c_r = 0;
state_r <= IDLE_STATE;
end
Полный текст исходного когда главного модуля — вы можете найти в моем Github-репозитории.
❯ Шаг шестой. Проверяем как работает, ищем баги
Итак. Мы собрали все необходимое и теперь можно провести проверку и простейший дебаг. Способов вижу два — припаяться к ножкам SDA и SCL у EEPROM и подключить DSLogic или сделать через встроенный в Quartus SignalTap логический анализатор и по JTAG посмотреть, что происходит.
Коротко расскажу про второй способ. Запустить Signal Tap можно через главное меню:
Основные кнопки в окне я выделил красным:
В первую очередь необходимо выбрать источник тактирования в секции Clock. Тут я выбрал поделенную частоту, чтобы охватить необходимое количество семплов т.к. память захвата ограничена и надо чтобы всё влезло. Этот параметр устанавливается через параметр Sample depth.
После необходимо накидать во вкладке Setup наблюдаемые сигналы и выбрать логическую функцию Basic OR для триггеров от этих сигналов. После добавления изменений — необходимо, чтобы модуль наблюдения попал в прошивку. Для этого необходимо перекомпилировать ее и прошить в плату.
После компиляции нужно выбрать триггер, я выбрал от сигнала READ_STATE и WRITE_STATE. Можно запустить Run Analysis для единичного захвата и перейти в секцию Data нажать кнопку, которой присвоено действие на чтение:
Подробно рассмотрев, видим, что все сигналы на команду Read отрабатывают как нужно т.е. читаем из регистра 0x02 заранее записанное значение 0xCE. Теперь можно посмотреть что происходит на команду Write. Запишем в ячейку 0x02 новое значение 0xF1:
Кажется все работает как нужно. Подключим для проверки DSLogic к ножкам EEPROM и с помощью программы DSView и декодера I2C протокола проверить правильность транзакций.
На чтение:
На запись:
Кажется при корректных данных все выполняется правильно, в соответствии с Datasheet. Плюсом если повторно нажимать кнопки транзакций, выбирать данные и регистры для записи и чтения — то все на первый взгляд работает корректно. Но стоит немного углубиться в изучение — и сходу можно найти несколько багов. Перечислю их.
❯ Баг №1. Некорректная частота SCL
Рассмотрению частоту тактирования, получилось значение 390.62kHz:
Получилось конечно не 400kHz ровно, но кажется что этого для данного уровня “развития” автомата будет достаточно. Можно считать за первый баг.
❯ Баг №2. Некорректная установка ACK-бита
В ходе просмотра транзакций — Я обнаружил хаотическую установку ACK-бита. Пока не понятно откуда берется во взаимодействии с железом этот косяк. Надо будет разбираться после.
В качестве неприятного дополнения к этому — при подаче адреса для Slave-устройства которого нет на шине — один фиг приходит сигнал ACK.
Других проблем я пока не обнаружил. Думаю в коммитах в репозитории можно будет отслеживать прогресс по доработке.
❯ Заключение
В целом результат можно считать удовлетворительным т.к. основная задача выполнена:
Этот материал мне дался достаточно большой ценой — куча вариантов реализации, куча времени на отладку. Но результат стоил того. Куча опыта в отладке, в разборе вариантов реализации, часы просмотра результатов RTL-синтеза. Кажется базовый Verilog-кодинг стал одной из моих компетенций, но безусловно есть куда расти.
Сейчас стоит обозначить дальнейшие планы:
- пофиксить баги;
- перенести код в Xilinx Vivado, чтобы запустить его на плате Zynq Mini (которую я обозревал в этой статье);
- подключить данный модуль к AXI шине чтобы можно было взаимодействовать с ним из Linux.
Поэтому с этими планами можно идти дальше. До встречи в следующих статьях!
P. S. Забыл сказать. Самое дурацкое, что выяснилось сравнительно недавно — OLED-дисплей SSD1306, с которым планировалось взаимодействие полученного автомата оказывается подключен по SPI и необходимо будет придумать SPI-автомат 😀. Так что буду по всей видимости писать еще и его 😀
Автор: Андрей