Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе

в 8:01, , рубрики: DSLogic, DSView, fpga, I2C, i2c master, i2c master controller, Quartus, SignalTap, timeweb_статьи, Verilog
После того, как Я реализовал битовый контроллер I2C Master — уж очень чесались руки опробовать его в реальной задаче. Теперь можно начинать строить уровни абстракции от манипуляции отдельными битами и уже формировать полноценные транзакции, которые приводят к какому-либо действию с подчиненным устройством. Я подумал, что было бы классно сделать такую проверку своего автомата во взаимодействии с простейшей I2C 2K-bit EEPROM.

Идея простая — читаем и записываем данные по нажатию клавиш на одной из отладок с Cyclone IV, которые я рассматривал в одном из своих обзоров.

Если материал вам кажется интересным — добро пожаловать, с удовольствием и в свойственной мне манере расскажу, чего мне удалось добиться, а чего не удалось. 🙂

image

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

❯ Давайте для начала определимся с общей идеей

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

В первую очередь нужно будет подключить к отладке плату с несколькими кнопками и сделать обработку входящих сигналов с антидребезгом. Каждая из кнопок должна будет выполнять свою функцию.

Вывод всех данных будет осуществляться на 7-сегментный индикатор с 8 разрядами который установлен на отладку. Поэтому нужно будет написать соответствующий драйвер для вывода информации на этот индикатор.

Необходимо также выводить ACK-сигнал на плату, чтобы увидеть что транзакция выполнена успешно.

Раз мы хотим организовать общение с EEPROM — то:

  1. Первой клавишей будет активировано действие на чтение данных из ячейки с заданным адресом и вывод прочитанных данных на семисегментный дисплей;
  2. Вторая клавиша будет производить запись выбранного значения в заданную ячейку памяти;
  3. Третья клавиша будет предназначена для инкремента значения текущего адреса памяти EEPROM в который будет произведена запись значения в диапазоне от 0x00 до 0xFF, ну или чтение;
  4. Четвертая клавиша будет декрементировать значение адреса памяти;
  5. Пятая клавиша будет предназначена для инкремента значения полезных данных, которые будут записаны в выбранный адрес ячейки памяти;
  6. Шестая, соответственно, будет декрементировать это значение;
  7. На плате клавиша RESET будет отвечать за асинхронный сброс.

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

❯ Что необходимо для выполнения задачи?

Итак, для реализации задачи понадобится:

  1. Отладочная плата Saylinx с Cyclone IV, которую я обозревал в этой статье. Она подходит для моей цели как раз потому что на плате есть EEPROM и семисегментный индикатор;
  2. Программатор Altera USB Blaster для прошивки платы и отладки;
  3. На плате понадобится семисегментный индикатор. У индикатора есть 8 разрядов, первые два из которых мы задействуем под указание того, какой адрес памяти сейчас выбран, третий и четвертый — под выбор полезных данных для записи в ячейку, пятый и шестой — под вывод считанных данных из EEPROM;
  4. На плате должен быть EEPROM. И он там есть 🙂;
  5. Платка с кнопками и соединительными проводками для PLS-гребенки, потому что на отладке их не так много как хочется;
  6. Логический анализатор, типа какого-нибудь 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:

image

И подключен CLK к ножке E1:

image

Найдем в схеме светодиоды. На плате у нас их всего 4 штуки:

image

Переходим к разделу схемы где цепи LEDx подключаются к ПЛИС:

image

Отлично. LED0 — E10, LED1 — F9, LED2 — C9, LED3 — D9. Я обычно записываю эти данные на на отдельный листочек, чтобы потом в Pin Planner сразу указать нужные пины, не перерывая схематик снова.

Идём дальше. Кнопки. Качество китайских схематиков как обычно “на высоте” и ссылок по цепям нет, поэтому включаем поиск и ищем по ключевым словам.

image

Находим кнопки KEY1, KEY2, KEY3 на ножках M15, M16, E16 соответственно:

image

И кнопку RESET — тут, на N13:

image

Далее необходимо определиться к каким пинам подключить плату с кнопками и я выбрал левую гребенку на плате и следующие пины:

image

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

  • кнопка записи — подключаем к пину N2;
  • кнопка чтения — подключаем к пину P1;
  • кнопка прибавления единицы к значению адреса ячейки памяти — пин P2;
  • кнопка вычитания единицы из значения адреса выбранной ячейки памяти — пин R1;
  • кнопка прибавления единицы к записываемому числу в ячейку памяти — пин P8;
  • кнопка вычитания единицы из записываемого числа в ячейку памяти — пин K9.

Далее переходим к семисегментному индикатору:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 10

Тут пины все подписаны. Не буду их дополнительно перечислять. Хоть где-то сделали нормальное указание цепей, чтобы не блуждать по схематику 😀 в поисках пина, к которому подключена периферия. О принципе работы семисегментника я расскажу чуть позже.

И остается последний штрих — пины SDA и SCL микросхемы EEPROM:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 11

И самые внимательные читатели заметят — что на пинах D1, E6 находятся сигналы выбора SEL6 и SEL7 и сигналы SCL, SDA. Поэтому последние два разряда мы не сможем задействовать в нашем проекте. И они будут постоянно показывать всякую хрень, будем держать это во внимании.

Теперь можно скомпилировать проект и перейти в Pin Planner (Assignments — Pin Planner) чтобы занести значения пинов. У меня получился вот такой внушительный список:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 12

Обратите внимание, что 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”.

Выглядит эта ситуация вот таким образом:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 13

Итак. Добавим в проект файл с именем 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-битных значений регистров на символы для каждого из сегментов. Второй — это главный драйвер, который будет осуществлять вывод данных на сегменты.

Разберемся сначала со схемотехникой индикатора, откроем схему:

image

У семисегментников, в каждом разряде используются одни и те же пины для включения конкретно взятых сегментов. И 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-машину:

image

Описываем ее следующим образом и расставим управление сигналом старта транзакций 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 видно, каким образом осуществляется чтение:

image

Вы можете самостоятельно сопоставить то, что происходит в коде с тем, как должна быть организована транзакция на Random Read, т.е. на чтение рандомной ячейки памяти.

Перейдем к операции Random Write в Datasheet:

image

Тоже достаточно очевидно что дожно происходить при записи. Опишем секцию 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 можно через главное меню:

image

Основные кнопки в окне я выделил красным:

image

В первую очередь необходимо выбрать источник тактирования в секции Clock. Тут я выбрал поделенную частоту, чтобы охватить необходимое количество семплов т.к. память захвата ограничена и надо чтобы всё влезло. Этот параметр устанавливается через параметр Sample depth.

После необходимо накидать во вкладке Setup наблюдаемые сигналы и выбрать логическую функцию Basic OR для триггеров от этих сигналов. После добавления изменений — необходимо, чтобы модуль наблюдения попал в прошивку. Для этого необходимо перекомпилировать ее и прошить в плату.

После компиляции нужно выбрать триггер, я выбрал от сигнала READ_STATE и WRITE_STATE. Можно запустить Run Analysis для единичного захвата и перейти в секцию Data нажать кнопку, которой присвоено действие на чтение:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 20

Подробно рассмотрев, видим, что все сигналы на команду Read отрабатывают как нужно т.е. читаем из регистра 0x02 заранее записанное значение 0xCE. Теперь можно посмотреть что происходит на команду Write. Запишем в ячейку 0x02 новое значение 0xF1:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 21

Кажется все работает как нужно. Подключим для проверки DSLogic к ножкам EEPROM и с помощью программы DSView и декодера I2C протокола проверить правильность транзакций.

На чтение:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 22

На запись:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 23

Кажется при корректных данных все выполняется правильно, в соответствии с Datasheet. Плюсом если повторно нажимать кнопки транзакций, выбирать данные и регистры для записи и чтения — то все на первый взгляд работает корректно. Но стоит немного углубиться в изучение — и сходу можно найти несколько багов. Перечислю их.

❯ Баг №1. Некорректная частота SCL

Рассмотрению частоту тактирования, получилось значение 390.62kHz:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 24

Получилось конечно не 400kHz ровно, но кажется что этого для данного уровня “развития” автомата будет достаточно. Можно считать за первый баг.

❯ Баг №2. Некорректная установка ACK-бита

В ходе просмотра транзакций — Я обнаружил хаотическую установку ACK-бита. Пока не понятно откуда берется во взаимодействии с железом этот косяк. Надо будет разбираться после.

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 25

В качестве неприятного дополнения к этому — при подаче адреса для Slave-устройства которого нет на шине — один фиг приходит сигнал ACK.

Других проблем я пока не обнаружил. Думаю в коммитах в репозитории можно будет отслеживать прогресс по доработке.

❯ Заключение

В целом результат можно считать удовлетворительным т.к. основная задача выполнена:

Создаем I2C Master Controller на Verilog. Проверим работу на реальном железе - 26

Этот материал мне дался достаточно большой ценой — куча вариантов реализации, куча времени на отладку. Но результат стоил того. Куча опыта в отладке, в разборе вариантов реализации, часы просмотра результатов RTL-синтеза. Кажется базовый Verilog-кодинг стал одной из моих компетенций, но безусловно есть куда расти.

Сейчас стоит обозначить дальнейшие планы:

  • пофиксить баги;
  • перенести код в Xilinx Vivado, чтобы запустить его на плате Zynq Mini (которую я обозревал в этой статье);
  • подключить данный модуль к AXI шине чтобы можно было взаимодействовать с ним из Linux.

Поэтому с этими планами можно идти дальше. До встречи в следующих статьях!

P. S. Забыл сказать. Самое дурацкое, что выяснилось сравнительно недавно — OLED-дисплей SSD1306, с которым планировалось взаимодействие полученного автомата оказывается подключен по SPI и необходимо будет придумать SPI-автомат 😀. Так что буду по всей видимости писать еще и его 😀


Автор: Андрей

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js