Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе
Средний 22 мин Блог компании Timeweb Cloud FPGA *Производство и разработка электроники *DIY или Сделай сам Электроника для начинающих Туториал После того, как Я реализовал битовый контроллер I2C Master — уж очень чесались руки опробовать его в реальной задаче. Теперь можно начинать строить уровни абстракции от манипуляции отдельными битами и уже формировать полноценные транзакции, которые приводят к какому-либо действию с подчиненным устройством. Я подумал, что было бы классно сделать такую проверку своего автомата во взаимодействии с простейшей I2C 2K-bit EEPROM.
Идея простая — читаем и записываем данные по нажатию клавиш на одной из отладок с Cyclone IV, которые я рассматривал в одном из своих обзоров.
Если материал вам кажется интересным — добро пожаловать, с удовольствием и в свойственной мне манере расскажу, чего мне удалось добиться, а чего не удалось. 🙂
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Содержание статьи:
- 1 ❯ Давайте для начала определимся с общей идеей
- 2 ❯ Что необходимо для выполнения задачи?
- 3 ❯ Шаг нулевой. Создаем проект и размечаем пины
- 4 ❯ Шаг первый. Драйвер LED-ов
- 5 ❯ Шаг второй. Драйвер кнопок
- 6 ❯ Шаг третий. Управление семисегментным индикатором
- 7 ❯ Шаг четвертый. Делитель частоты для I2C Bit Controlle
- 8 ❯ Шаг пятый. Главный модуль управления I2C сигналами
- 9 ❯ Шаг шестой. Проверяем как работает, ищем баги
- 10 ❯ Баг №1. Некорректная частота SCL
- 11 ❯ Баг №2. Некорректная установка ACK-бита
- 12 ❯ Заключение
❯ Давайте для начала определимся с общей идеей
Первым шагом нужно определиться, к чему стремимся и чего хотим в итоге получить.
В первую очередь нужно будет подключить к отладке плату с несколькими кнопками и сделать обработку входящих сигналов с антидребезгом. Каждая из кнопок должна будет выполнять свою функцию.
Вывод всех данных будет осуществляться на 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
Теперь необходимо подготовить делитель частоты для основного контроллера I2C, который мы создавали в прошлой статье. Создадим файл clock_divider.v и создадим модуль делителя:
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-автомат 😀. Так что буду по всей видимости писать еще и его 😀
Возможно, захочется почитать и это:
- ➤ IR remote control, а без микроконтроллеров можно? Да не вопрос
- ➤ Как запустить сотовую сеть стандарта AMPS при помощи SDR
- ➤ Hello World на Tang Primer 20K под Linux
- ➤ Выполняем сторонние программы на микроконтроллерах с Гарвардской архитектурой: как загружать программы без знания ABI?
- ➤ Atomic Heart или как забилось сердце русского геймдева
Теги:
- timeweb_статьи
- FPGA
- I2C
- I2C Master
- I2C master controller
- verilog
- quartus
- signaltap
- dsview
- dslogic
Хабы:
- Блог компании Timeweb Cloud
- FPGA
- Производство и разработка электроники
- DIY или Сделай сам
- Электроника для начинающих
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку Задонатить