Создаем 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