Закончив в предыдущей статье описание того, как должны осуществляться атомарные операции и каким образом осуществляется выполнение команд я бодро перешел к написанию HDL-кода. Пришлось разобраться с тем, как организовать FSM, как организовать считывание и выставление данных на шине.  

Весь этот процесс перехода от идеи и результатов моделирования к написанию кода — я и хотел бы описать в данной статье. 

Всем интересующимся — добро пожаловать под кат! =)

image

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

Шаг нулевой. Что в итоге делаем и к чему стремимся?


В ходе реализации конечного автомата я пришел к выводу, что я не буду дополнительно усложнять и так непростую для себя задачу и заморачиваться над возможностью работы автомата в Standard Mode т.к. подавляющее большинство I2C Slave устройств умеют работать в Fast Mode.

В результате написания HDL-кода — я хочу получить конечный автомат который:

  • имеет возможность асинхронного сброса;
  • исполняет указанные команды при наличии разрешающего сигнала, в четком соответствии с задумкой из прошлой статьи;
  • записывает на шину данные выставленные на входных портах модуля (адрес, байты данных);
  • выставляет прочитанные данные после окончания операции на выходной порт;
  • выставляет сигнал ACK/NACK при завершении транзакции на соответствующий порт;
  • управляется внешним автоматом, который командует когда и какую команду нужно выполнить.

После написания кода — протестируем полученный результат в ModelSim, как это делать я рассказывал в этой статье.

Я не стал перегружать дополнительными функциями данный автомат и получилась просто логика считывания и записи отдельных бит информации на шину на уровне простых транзакций. Это будет первый уровень абстракции, которым уже можно будет управлять через вышестоящий автомат, уже непосредственно решая конкретные прикладные задачи. Но об этом позже. 

Шаг первый. Начинаем с модуля и его интерфейса.


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

Перейдем к перечислению входных и выходных сигналов модуля, обозвав его i2c_bit_controller:

`timescale 1ns/1ps

module i2c_bit_controller (
	input rstn_i,              	// Входной сигнал для асинхронный сброс
	input clk_i,                  	// Входной сигнал тактирования
	
	input wr_i2c_i,               	// Входной сигнал на включение записи
	input [2:0] cmd_i,            	// Входной сигнал с командой
	
	input [7:0] din_i,            	// Входной сигнал с полезными данными
	output [7:0] dout_o,	     	// Выходной сигнал с полезными данными
	output ack_o,			// Выходной сигнал с сигналом ACK/NACK

	output [3:0] state_o,         	// Выходной сигнал текущего состояния автомата
	output ready_o,               	// Выходной сигнал сообщающий о готовности автомата
	output [4:0] bit_count_o,	// Счетчик количества уже выставленных бит в транзакции
		
	inout tri sda_io,             	// Вход/выход для сигнала SDA
	output tri scl_io		// Выходной сигнал SCL
);

Коротко поясню про каждый из них:

  • rstn_i — это вход для общего сигнала асинхронного сброса всей схемы и приведения ее к исходному состоянию;
  • clk_i — это вход уже готового тактового сигнала в 10 МГц;
  • wr_i2c_i— это сигнал разрешающий начало выполнения транзакций, соответственно сначала выставляем команду и потом дергаем этот спусковой крючок;
  • cmd_i — это как раз порт для выставления команды;
  • din_i — это порт для выставления данных которые должны быть отправлены на шину;
  • dout_o — это порт на который будут выставлены данные, которые получены при выполнении транзакции;
  • ack_o — это порт на который выставляется ACK/NACK сигнал после завершения транзакции;
  • state_o — этот отладочный порт, на который выставляется текущей стадии работы конечного автомата;
  • ready_o — это сигнал означающий, что конечный автомат находится в стадии либо Idle, либо Hold;
  • bit_count_o — это отладочный сигнал, который показывает сколько бит уже отправлено в текущей транзакции;
  • sda_io — это  интерфейс с тремя состояниями, которой считывает и записывает данные на шине SDA;
  • scl_io — это выходной интерфейс с тремя состояниями для выдачи на шину сигнала SCL.

Кажется тут все предельно ясно, идем дальше.

Шаг второй. Параметры и необходимые регистры.


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

// Константы для обозначения команд
localparam START_CMD   		= 3'b001;
localparam WR_CMD      		= 3'b010;
localparam RD_CMD      		= 3'b011;
localparam STOP_CMD    		= 3'b100;
localparam RESTART_CMD 		= 3'b101;

// Возможные состояния FSM
localparam IDLE_STATE 		= 4'b0001;	// 1
localparam START1_STATE		= 4'b0010;	// 2
localparam START2_STATE		= 4'b0011;	// 3
localparam HOLD_STATE		= 4'b0100;	// 4
localparam RESTART1_STATE  	= 4'b0101;	// 5
localparam RESTART2_STATE  	= 4'b0110;	// 6
localparam STOP1_STATE		= 4'b0111;	// 7
localparam STOP2_STATE		= 4'b1000;	// 8
localparam STOP3_STATE		= 4'b1001;	// 9
localparam DATA1_STATE		= 4'b1010;	// 10
localparam DATA2_STATE		= 4'b1011;	// 11
localparam DATA3_STATE		= 4'b1100;	// 12
localparam DATA4_STATE		= 4'b1101;	// 13
localparam DATAEND_STATE 	= 4'b1110;	// 14

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

Шаг третий. “Ногодрыг”


Теперь перейдем к логике управления выходными сигналами. Коротко объясню логику формирования сигналов на шинах SDA и SCL. Поскольку сигнал на шине, из-за наличия подтягивающих резисторов, всегда находится в логической единице, если не притянут к нулю — то мы будем выставлять только логический ноль, не заморачиваясь о том, когда нужно будет выставить единицу — она сама автоматически будет выставлена если мы отпустим шину выставив Z-состояние на выходном сигнале.

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

reg sda_out_r;
reg scl_out_r;

reg sda_r;
reg scl_r;

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

always @(posedge clk_i, negedge rstn_i)
begin
  if (~rstn_i) 
  begin

	sda_r <= 1'b1;
	scl_r <= 1'b1;

  end else 
  begin

	sda_r <= sda_out_r;
	scl_r <= scl_out_r;

  end
end

После этого назначим соответствие  между портами и их регистрами. Для SCL получается следующее:

assign scl_io = (scl_r) ? 1'bz : 1'b0;

Получается, если значение scl_r будет равно 1 — то выставляем порт в Z-состояние, если нужно выставить 0 — то выставляем 0. Кажется все очевидно. После синтеза получится следующая конструкция:

image

В случае с SDA — все немного сложнее. В фазы, когда данные должны быть считаны — необходимо ввести дополнительное состояние, которое обозначало данную фазу обмена, назвал я ее into_w. Для этого нужно ввести несколько регистров и выражений:


reg data_phase_r;		// Регистр c индикацией процесса передачи полезных данных
reg [3:0] cmd_r;		// Регистр для хранения текущей команды
reg [3:0] cmd_next_r;		// Вспомогательный регистр для FSM

reg [4:0] bit_r;		// Регистр для хранения номера текущего бита

wire into_w;			// Сигнал-проводник, обозначающий момент получения данных

assign into_w = (data_phase_r && cmd_r == RD_CMD && bit_r < 8) || (data_phase_r && cmd_r == WR_CMD && bit_r == 8); 
assign sda_io = (into_w || sda_r) ? 1'bz : 1'b0;

Таким образом получается достаточно объемная конструкция, которая сообщает, что когда идет data_phase_r (когда FSM в одной из DATA_PHASE, дальше будет понятно о чем идет речь) и когда выполняется команда RD_CMD, и были прочитаны не все биты в текущей транзакции или когда выполняется команда WR_CMD и записаны все 8 бит и ожидаем считывание ACK-бита. 

Если данные условия выполняются — значит однозначно идет процесс считывания данных с шины и нужно занять Z-состояние. Ну или если нужно выставить 0 на SDA — общее правило срабатывает и на шине выставляем ноль.

После там еще будет накручена логика входного буфера но об этом позже. Надеюсь не сильно сложно расписал ????.

Шаг четвертый. State-машина


Для того, чтобы перемещаться по стадиям для выполнения атомарных действий и для формирования транзакций, которые я описывал в предыдущих статьях — необходимо создать State-машину с драйвером обновления текущих значений регистров. 

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


reg [7:0] state_r;		// Регистр состояния
reg [7:0] state_next_r;		// Вспомогательный регистр для переходов

assign state_o = state_r;	// Назначаем регистр к выходному сигналу


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


always @(posedge clk_i, negedge rstn_i)
begin
  if (~rstn_i) 
  begin

      state_r <= IDLE_STATE;

  end else 
  begin

      state_r <= state_next_r;

  end
end 

И можно создать поведенческую Next-state логику, которая будет чувствительна ко всем изменениям переменных и будет осуществляться переход от состояния к состоянию. 

Также на этом этапе я ввел в оборот регистры обозначающие переменную Ready, переменную data_phase_r и счетчик переданных битов bit_r:


reg ready_r;			// Переменная для определения готовности автомата
reg [4:0] bit_next_r;		// Вспомогательная переменная для счетчика битов

assign ready_o = ready_r;	// Вывод состояние готовности FSM для отладки
assign bit_count_o = bit_r;	// Вывод для отладки текущего бита транзакции

// Next-state машина
always @(*)
begin

	state_next_r = state_r;	// Задаем для переменных значения по умолчанию 
	ready_r = 1'b0;		// Для переменной состояния
	data_phase_r = 1'b0;	// Для фазы передачи данных
	cmd_next_r = cmd_r;	// Для регистра текущей команды
	bit_next_r = bit_r;	// Для регистра значения счетчика

	case (state_r)
		IDLE_STATE: begin	
          
			ready_r = 1'b1;	// Обозначаем, что автомат готов
				
			if(wr_i2c_i && cmd_i == START_CMD)	// Если разрешены транзакции
          		begin					// И подана команда START
				state_next_r = START1_STATE;	// Переходим в новое состояние
			end				
		end
			
		START1_STATE: begin 				  				
			state_next_r = START2_STATE;	// Идем к следующему шагу
		end
			
		START2_STATE: begin
			state_next_r = HOLD_STATE;	// Идем к следующему шагу	
		end
			
		HOLD_STATE: begin
			ready_r = 1'b1;	// Обозначаем что автомат готов
			
			if (wr_i2c_i)	// Если разрешены транзакции
   			begin
				cmd_next_r = cmd_i;		
					
				case (cmd_i) // Идем в шаг который указан на входе автомата
                  
					RESTART_CMD:
						state_next_r = RESTART1_STATE; 
						
					STOP_CMD:
						state_next_r = STOP1_STATE;
							
					default: begin
						bit_next_r   = 0;	// Обнуляем счетчик битов
						state_next_r = DATA1_STATE;										
                    			end
				endcase
			end			
		end
			
		DATA1_STATE: begin 
			
			data_phase_r = 1'b1;		// Обозначаем что идет фаза данных
			state_next_r = DATA2_STATE; 	// Идем к следующему шагу
				
		end
			
		DATA2_STATE: begin 
							
			data_phase_r = 1'b1;		// Обозначаем что идет фаза данных									 
                        state_next_r = DATA3_STATE;	// Идем к следующему шагу
				
		end
			
		DATA3_STATE: begin 
				
			data_phase_r = 1'b1;		// Обозначаем что идет фаза данных					 
                        state_next_r = DATA4_STATE;	// Идем к следующему шагу
				
		end
			
		DATA4_STATE: begin 
			
                        data_phase_r = 1'b1; 	// Обозначаем что идет фаза данных
				
			if (bit_r == 8)		// Если переданы все биты
			begin
				state_next_r = DATAEND_STATE;	// Переходим к фазе завершения
			end else 
			begin

				bit_next_r = bit_r + 1;		// Инкрементируем счетчик
				state_next_r = DATA1_STATE;	// Идем к следующему шагу
					
			end
		end
			
		DATAEND_STATE: begin
			
			state_next_r = HOLD_STATE;	// Идем к следующему шагу
				
		end
			
		RESTART1_STATE: begin 
				
			state_next_r = RESTART2_STATE;	// Идем к следующему шагу

		end
			
		RESTART2_STATE: begin 

			state_next_r = START1_STATE;	// Идем к следующему шагу
		end
			
		STOP1_STATE: begin 

			state_next_r = STOP2_STATE;	// Идем к следующему шагу
		end
			
		STOP2_STATE: begin 	
			
           		state_next_r = STOP3_STATE;	// Идем к следующему шагу
		end
			
      		default: begin				// А-ля STOP3 состояние 

			state_next_r = IDLE_STATE;	// Возвращаемся в Idle
		end
			
	endcase
end

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


always @(posedge clk_i, negedge rstn_i)
begin
    if (~rstn_i) 
    begin
      
      	state_r <= IDLE_STATE;
      	bit_r   <= 0;               	// Добавляем 
  	cmd_r   <= 0;               	// Добавляем 
  
    end else 
    begin
	
      	state_r <= state_next_r;
    	bit_r   <= bit_next_r;        	// Добавляем 
	cmd_r   <= cmd_next_r;       	// Добавляем 
  
    end
end 

По результатам синтеза получается FSM, выглядит как прям то что нужно:

image

Понятно, что масштаб картинки не самый удобный — лучше открыть ее на полную и просмотреть флоу работы State-машины. Если прочитать Verilog-код — то можно сверить с тем, что планировалось в предыдущей статье

Код кажется великолепно читаемым и по всей видимости не нуждается в дополнительном комментировании. Вдумчиво прочитайте его и думаю, вопросов не должно возникать на этом этапе.

Шаг пятый. Управление сигналом SCL


Поскольку сигналом SCL управляет только Master-устройство — тут вообще ничего сложного. Расставим в нужных местах значения сигнала SCL когда он должен быть в значении логического нуля в соответствии со стадиями, как это было описано в прошлой статье. Обратим внимание на изображения с таймингами, где SCL принимал данное значение. Для удобства я выделил жирным изменение существующем в коде.

Зададим сначала значение по умолчанию в Next-state машину:


// Next-state машина
always @(*)
begin

	state_next_r = state_r;		// Задаем для переменных значения по умолчанию 
	ready_r = 1'b0;			// Для переменной состояния
	data_phase_r = 1'b0;		// Для фазы передачи данных
	cmd_next_r = cmd_r;		// Для регистра текущей команды
	bit_next_r = bit_r;		// Для регистра значения счетчика
		
	scl_out_r = 1'b1;             	// Добавляем 


Во время исполнения этапа START2_STATE:


START2_STATE: begin
  	scl_out_r = 1'b0;		// Добавляем
	state_next_r = HOLD_STATE;	// Идем к следующему шагу	
end

Во время этапа HOLD_STATE:


HOLD_STATE: begin
		
	ready_r = 1'b1;		// Обозначаем что автомат готов
	scl_out_r = 1'b0;	// Добавляем
	
  	if (wr_i2c_i)		// Если разрешены транзакции
   	begin

		cmd_next_r = cmd_i;		
					
		case (cmd_i)	// Идем в шаг который указан на входе автомата
			RESTART_CMD:
				state_next_r = RESTART1_STATE; 
						
			STOP_CMD:
			    state_next_r = STOP1_STATE;
							
			default: begin
				bit_next_r   = 0;	// Обнуляем счетчик битов
				state_next_r = DATA1_STATE;										
            		end
					
	    endcase
	end			
end

Для этапа DATA1_STATE:


DATA1_STATE: begin 
  
	scl_out_r = 1'b0;		// Добавляем
	data_phase_r = 1'b1;		// Обозначаем что идет фаза данных
	state_next_r = DATA2_STATE;	// Идем к следующему шагу
				
end

Для этапа DATA4_STATE:


DATA4_STATE: begin 

	scl_out_r = 1'b0;             	// Добавляем
    	data_phase_r = 1'b1;		// Обозначаем что идет фаза данных
				
    	if (bit_r == 8)			// Если переданы все биты
	begin
		
      		state_next_r = DATAEND_STATE;	// Переходим к фазе завершения
					
	end else 
	begin

		bit_next_r = bit_r + 1;		// Инкрементируем счетчик
		state_next_r = DATA1_STATE;	// Идем к следующему шагу
				
	end			
end

В этап DATAEND_STATE:


DATAEND_STATE: begin
		 
    scl_out_r = 1'b0;                     	// Добавляем 
    state_next_r = HOLD_STATE;			// Идем к следующему шагу
				
end

В этап RESTART1_STATE:


RESTART1_STATE: begin 
		
    scl_out_r = 1'b0;                     	// Добавляем 	
    state_next_r = RESTART2_STATE;		// Идем к следующему шагу

end

И в этап STOP1_STATE:


STOP1_STATE: begin 
		
	scl_out_r = 1'b0;			// Добавляем 
  	state_next_r = STOP2_STATE;		// Идем к следующему шагу

end

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

Шаг шестой. Управление сигналом SDA.


Теперь самый интересный и важный этап — выставление данных и считывание. Как вы помните из предыдущей статьи мной была предложена концепция наличия двух сдвиговых регистров для Tx и Rx для оперирования данными на шине. 

В первую очередь определим этапы в которых SDA выставляется безусловно в значение логического нуля. 

Это происходит во время этапа START1_STATE:

START1_STATE: begin 
  
    	sda_out_r = 1'b0;		// Добавляем			
	state_next_r = START2_STATE;	// Идем к следующему шагу
      
end

Во время этапа START2_STATE:

START2_STATE: begin
  
	scl_out_r = 1'b0;
  	sda_out_r = 1'b0;		// Добавляем
	state_next_r = HOLD_STATE;	// Идем к следующему шагу	
      
end

Во время этапа HOLD_STATE:

HOLD_STATE: begin
		
    ready_r = 1'b1;		// Обозначаем, что автомат готов
		
    scl_out_r = 1'b0;
    sda_out_r = 1'b0;    	// Добавляем
	
	if (wr_i2c_i) 		// Если разрешены транзакции
   	begin

		cmd_next_r = cmd_i;		
					
		case (cmd_i)	 // Идем в шаг который указан на входе автомата
			RESTART_CMD:
				state_next_r = RESTART1_STATE; 
						
			STOP_CMD:
				state_next_r = STOP1_STATE;
							
			default: begin
				bit_next_r   = 0;	// Обнуляем счетчик битов
				state_next_r = DATA1_STATE;										
            		end
					
		endcase
	end			
end

И во время этапа DATAEND_STATE:


DATAEND_STATE: begin
  
	scl_out_r = 1'b0;
	sda_out_r = 1'b0;		// Добавляем

	state_next_r = HOLD_STATE;	// Идем к следующему шагу		
end

Добавим также значение по умолчанию в Next-state машину:


// Next-state машина
always @(*)
begin

	state_next_r = state_r;	// Задаем для переменных значения по умолчанию 
	ready_r = 1'b0;		// Для переменной состояния
	data_phase_r = 1'b0;	// Для фазы передачи данных
  	cmd_next_r = cmd_r;	// Для регистра текущей команды
	bit_next_r = bit_r;	// Для регистра значения счетчика
		
	scl_out_r = 1'b1;
	sda_out_r = 1'b0;	// Добавляем

Теперь перейдем к объявлению нужных нам 9-битных регистров из которых 8 бит полезных данных и 1 бит для сигнала ACK:


reg [8:0] tx_r;
reg [8:0] tx_next_r;
	
reg [8:0] rx_r;
reg [8:0] rx_next_r;


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


always @(posedge clk_i, negedge rstn_i)
begin
	if (~rstn_i) 
	begin
      
		state_r <= IDLE_STATE;
		bit_r   <= 0;
		cmd_r   <= 0;
		tx_r    <= 0;                   // Добавляем
		rx_r    <= 0;                   // Добавляем

    end else 
    begin	
      
	state_r <= state_next_r;
	bit_r   <= bit_next_r;
	cmd_r   <= cmd_next_r;
	tx_r    <= tx_next_r;             // Добавляем
	rx_r    <= rx_next_r;             // Добавляем

    end
end 

И в Next-state машину:


// Next-state машина
always @(*)
begin

	state_next_r = state_r;		// Задаем для переменных значения по умолчанию 
	ready_r = 1'b0;			// Для переменной состояния
	data_phase_r = 1'b0;		// Для фазы передачи данных
	cmd_next_r = cmd_r;		// Для регистра текущей команды
	bit_next_r = bit_r;		// Для регистра значения счетчика
		
	scl_out_r = 1'b1;
	sda_out_r = 1'b0;

	tx_next_r = tx_r;           // Добавляем
	rx_next_r = rx_r;           // Добавляем

Подключим их сразу к выходным портам модуля:


assign dout_o = rx_r[8:1];	// 8 бит полезных данных
assign ack_o = rx_r[0];    	// Разряд в который помещается ACK-сигнал

Не забываем про NACK-сигнал который должен быть выставлен когда происходит чтение данных из Slave:


wire nack_w;
assign nack_w = din_i[0];

Для того, чтобы определить, что отправить нужно собрать переменную tx_next_r из двух частей и подготовить эти данные для отправки на этапе HOLD_STATE:


HOLD_STATE: begin
		
	ready_r = 1'b1;			// Обозначаем что автомат готов
		
	scl_out_r = 1'b0;
	sda_out_r = 1'b0;
	
	if (wr_i2c_i)			// Если разрешены транзакции
   	begin

		cmd_next_r = cmd_i;		
					
		case (cmd_i)		// Идем в шаг который указан на входе автомата
			RESTART_CMD:
				state_next_r = RESTART1_STATE; 
						
			STOP_CMD:
				state_next_r = STOP1_STATE;
							
			default: begin
				bit_next_r   = 0;			// Обнуляем счетчик битов
				state_next_r = DATA1_STATE;
				tx_next_r = {din_i, nack_w};		// Добавляем
                	end
					
		endcase
	end			
end

Сигнал SDA на шине будем выставлять всегда 9-й бит tx_r в DATA-стадиях и путем сдвига будем каждую итерацию данных обновлять значение этого бита:


DATA1_STATE: begin 

	sda_out_r = tx_r[8];		// Добавляем
	scl_out_r = 1'b0;		// Добавляем
			
	data_phase_r = 1'b1;		// Обозначаем, что идет фаза данных
	state_next_r = DATA2_STATE; 	// Идем к следующему шагу
				
end
			
DATA2_STATE: begin 
	
	sda_out_r = tx_r[8];		// Добавляем
	scl_out_r = 1'b0;		// Добавляем

	data_phase_r = 1'b1;		// Обозначаем, что идет фаза данных				
	state_next_r = DATA3_STATE;	// Идем к следующему шагу
				
end
			
DATA3_STATE: begin 

	sda_out_r = tx_r[8];		// Добавляем
	scl_out_r = 1'b0;		// Добавляем
		
	data_phase_r = 1'b1;		// Обозначаем, что идет фаза данных				
	state_next_r = DATA4_STATE;	// Идем к следующему шагу
				
end

А в стадии DATA4_STATE помимо этой конструкции добавим еще в условие сдвиг:


DATA4_STATE: begin 
		
	sda_out_r = tx_r[8];		// Добавляем
	scl_out_r = 1'b0;
			
        data_phase_r = 1'b1;		// Обозначаем, что идет фаза данных
				
	if (bit_r == 8)			// Если переданы все биты
	begin
		state_next_r = DATAEND_STATE;	// Переходим к фазе завершения
					
	end else 
	begin
          
		tx_next_r = {tx_r[7:0], 1'b0};		// Добавляем
		bit_next_r = bit_r + 1;			// Инкрементируем счетчик
		state_next_r = DATA1_STATE;		// Идем к следующему шагу
				
	end			
end

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

Осталось добавить в фазу DATA2_STATE таким же образом считывание данных со сдвигом:


DATA2_STATE: begin 
	
	sda_out_r = tx_r[8];
	scl_out_r = 1'b0;						

	data_phase_r = 1'b1;			// Обозначаем, что идет фаза данных			
	state_next_r = DATA3_STATE;		// Идем к следующему шагу

	// Добавляем
	rx_next_r = {rx_r[7:0], sda_io};	// Сдвигаем данные с шины в регистр
			
end

Вот и все. Выглядит очень незамысловато. Полученный результат я залил на GitHub: github.com/megalloid/I2C_Master_Controller.

Шаг седьмой. Проверка полученного результата


Теперь можно создать testbench в проект и проверить, что все происходит так как ожидается. Очень сильно может помочь моя предыдущая статья на эту тему. Я накидал очень быстро простую тестовую программу, которая просимулирует нужные сигналы и покажет нам поведение автомата. 

До навыков профессионального верификатора мне еще далеко, поэтому не судите строго:

`timescale 1ns / 1ps

module i2c_bit_controller_tb;
	
	// Clock
	reg clk_r;

	localparam CLK_PERIOD = 10;
	always #(CLK_PERIOD/2) clk_r = ~clk_r;

	// Registers
	reg rstn_r = 1'b1;
	reg [2:0] cmd_r;
	reg [3:0] state_r;

	reg ready_r;
	reg wr_i2c_r = 0;

	reg [4:0] bit_count_r;
	reg [4:0] counter_r = 0;
	
	reg [7:0] din_r;
	
	// Wires
	wire scl_w;
	wire sda_w;

	// UUT
	i2c_master_controller uut(
		.rstn_i(rstn_r),
		.clk_i(clk_r),
		
		.wr_i2c_i(wr_i2c_r),
		.cmd_i(cmd_r),
		
		.din_i(din_r),
		
		.state_o(state_r),
		.ready_o(ready_r),
		.bit_count_o(bit_count_r),
		
		.sda_io(sda_w),
		.scl_io(scl_w)
	);	

	// Commands constants
	localparam START_CMD   = 3'b001;
	localparam WR_CMD      = 3'b010;
	localparam RD_CMD      = 3'b011;
	localparam STOP_CMD    = 3'b100;
	localparam RESTART_CMD = 3'b101;
	
	initial begin
	
		rstn_r = 0;
		clk_r = 0;
		
		#10;
		
		rstn_r = 1;
		
		#10;
		
		cmd_r = START_CMD;	
		
		#10; 
		
		wr_i2c_r = 1;
		din_r = 8'b11111111;
		
		#400;
		
		din_r = 8'b10101010;
		
		#1000;
		$stop;
		
	end
	
	always @(posedge ready_r) begin
		counter_r = counter_r + 1;
		
		if (counter_r == 3)
		begin
			cmd_r = RESTART_CMD;
			din_r = 8'b10101010;
		end else if (counter_r == 4) 
		begin
			cmd_r = WR_CMD;
		end else if (counter_r == 5) 
		begin
			cmd_r = STOP_CMD;
		end
	end
	
endmodule

Запускаем RTL-симуляцию и видим следующее:

image

Рассмотрим полученное несколько более пристально и обратим внимание на то, как происходит управление линиями SCL, SDA при выполнении команды START после IDLE_STATE: 

image

Четко видно START-сигнал, данные передаются только путем прижатия линии к нулю, четко видно как работает State-машина и начинается обмен информацией. Вся посылка из 11111111 верно выставлена на шине:

image

Также видно адекватное исполнение команды RESTART:

image

Ну и сигнал STOP — также работает верно, кажется все соответствует ожидаемому результату.

image

Что ж, теперь остается проверить все в реальном железе. Словом, успех!

Заключение


Теперь у вас есть возможность самостоятельно поиграться с автоматом, почитать код и, при желании, подробно вникнуть в суть происходящего. Дополнительно можете рассмотреть уже у себя в ModelSIm значения отладочных сигналов и то как работает данный конечный автомат.

А следующим шагом на пути к поставленной цели — необходимо будет проверить работу данного автомата уже при взаимодействии с реальным железом. Теперь я хочу создать управляющий автомат для модуля, чтобы произвести простые операции чтения и записи в EEPROM на плате с Cyclone IV и сделать простую логику записи и чтения произвольных значений в ячейки памяти при помощи кнопок на плате, но об этом уже в следующей статье. 

Большое спасибо за внимание! До встречи в следующих статьях! ???? 

Ссылка на репозиторий с кодом: github.com/megalloid/I2C_Master_Controller/tree/main



Возможно, захочется почитать и это:



Комментарии (24)


  1. KeisN13
    29.11.2023 08:44
    +5

    из такого цикла статей пора делать методичку. Молодец


    1. megalloid Автор
      29.11.2023 08:44

      Стараюсь. Может где-то кому-то неопределившимуся в жизни - поможет пробудить интерес и провести профессиональное самоопределение :)


  1. Ivanii
    29.11.2023 08:44

    Интересно насколько сложно написать скалер монитора Full HD?

    Ведь даже небольшой организации это под силу?


    1. Dima_Sharihin
      29.11.2023 08:44

      1. Скалеру монитора нужна, в первую очередь, память хотя бы под один фрейм, а 6 мегабайт на плиссину обычно никто не кладет. Есть HyperBus RAM, и совмещенные варианты в духе Gowin GW1NDR, но это уже экзотика

      2. Скалеру с VGA-входом нужно скоростное АЦП, которого не бывать почти никогда в ПЛИС

      3. ПЛИС - это как правило не сильно тиражируемое решение (дорого, жрет много), поэтому в массовые приборы их не так часто кладут


  1. SIISII
    29.11.2023 08:44

    Только почему до сих пор Verilog? Уже, пожалуй, все средства разработки под ПЛИС поддерживают SystemVerilog, а он имеет изрядное число преимуществ, и не только для верификации, но и для синтеза. В частности, используя вместо always специализированные always_ff, always_latch, always_comb, можно сказать компилятору, что ты хочешь получить, и это поможет избежать неявных ошибок.


    1. AlexanderS
      29.11.2023 08:44

      Автору так привычнее/удобнее. Я вообще за VHDL - в нём контроля за пользователем больше и вероятность себе ногу отстрелить ниже)


      1. fpga500
        29.11.2023 08:44
        +2

        Про выстрел в ногу. Недавно у нас была забавная проблема. У меня был модуль, на входе которого 4-х байтная (32-х битная) шина данных. Коллега написал модуль, передающий мне данные. И что то мы закрутились, и в итоге он сделал у себя на выходе шину 8 бит (1 байт). Всё подключилось, собралось, и даже иногда что то работало, но конечно же не то и так. Верилог такие вещи пропускает, в то время как VHDL такой косяк на этапе компиляции сразу увидит и зарубит.

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


        1. AlexanderS
          29.11.2023 08:44
          +1

          В верилоге присвоение шин разных разрядностей - это классика косяка. Особенно интересно получается, когда старшие разряды редко используются и в целом оно работает хорошо, но иногда ничего непонятно)

          В VHDL контроля больше, но и писать на нём нудно с его объявлениями, требованиями соответствия разрядностей и типов. Хотя это дело привычки, конечно.


          1. megalloid Автор
            29.11.2023 08:44
            +1

            Warning-и на эту тему Quartus пишет всегда. А вот забить на это или нет - уже выбор лично каждого. Плюсом если открыть результаты синтеза - видно когда лишние разряды тупо на "землю" коннектятся и все.


            1. fpga500
              29.11.2023 08:44
              +1

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

              И по моему мнению, несоответствие разрядностей шин - это уже не ворнинг, а именно ошибка. Представьте ситуацию - у вас есть физическая вилка, которую нужно воткнуть в розетку. У розетки 20 контактов, у вилки 30. Сразу понятно - что то тут не то)

              p.s. кстати, по поводу Квартуса. Раньше он писал ворнинг на защелки, теперь не пишет. А ее как раз очень легко можно получить, забыв написать какой-нить else


              1. AlexanderS
                29.11.2023 08:44

                Так обычно это косяк получается от невнимательности. Человек отвлёкся или в одном месте поправил, закопался, а другое место "уплыло".


            1. SIISII
              29.11.2023 08:44
              +1

              Как уже отметили, предупреждений в проектах очень много, но, в отличие от обычного программирования, от них невозможно избавиться. Скажем, если пишешь на це++ и у тебя вылетает предупреждение, ты вполне можешь проверить не понравившееся компилятору место и внести ту или иную корректировку. А с HDLами -- облом-с :( И в результате пустопорожних предупреждений появляется море, в котором тонут редкие реально важные.


            1. AlexanderS
              29.11.2023 08:44
              +1

              У меня как-то был небольшой проект, в котором варнингов практически не было. Я прямо прямо эстетическое удовольствие испытывал от этого =)

              Выше писали о куче варнингов в больших проектах. В принципе, большие проекты можно сделать без варнингов, если делаешь всё сам. Но иногда от них просто так не избавишься. Например, берешь какой-то сильно параметрический крупный сторонний модуль и этих варнингов может быть вагон с тележкой - где-то за разрядности вылазит, где-то что-то не используется, что-то меж тактовых доменов без контроля попадает, где-то ещё что-то - но всё это рабочее при данных параметрах (но может задействоваться при других наборах параметров). Понятно дело, что в идеале нужно писать "резиновые" модули где всё рассчитано параметрически (разрядности, задержки, таймауты и прочее). Но это может быть очень трудоёмко, а иногда и невыполнимо, когда требуется обязательно оставлять какой-нибудь резерв.


              1. fpga500
                29.11.2023 08:44

                Именно так. Чем больше параметризован модуль, тем сложнее избавиться от ворнингов. И даже не только в параметризации дело. Вот для примера - у меня есть модуль пакетного FIFO на основе кольцевого буфера. Полностью самописный. На выходе кроме самих пакетов он еще выдает информацию о размере пакета, количестве принятых, переданных, дропнутых и т.д. В одном и том же проекте это FIFO подключается в нескольких местах. Где то мне важен размер читаемого из FIFO пакета, а где то - нет. Там где не важен - этот выход остается неподключенным. Вот и ворнинг. А еще разные среды разработки могут разные вещи ворнингами считать, потому я уже давно не обращаю на них внимания. Кто то скажет, что это плохо, но вот так)

                p.s. Само собой, когда я модуль пишу и отлаживаю - то я на ворнинги внимания обращаю и отслеживаю их. Но когда всё собирается вместе, то там мне важнее что и как происходит на модели, как передаются данные между модулями


          1. fpga500
            29.11.2023 08:44
            +2

            Да, у каждого языка есть свои плюсы и минусы, что поделать. Мне больше нравится VHDL из-за своей строгости. Но это дело вкуса. Грамотный инженер может писать на обоих языках


    1. megalloid Автор
      29.11.2023 08:44

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

      Но совет я услышал, это очень ценно. Я попробую одним глазком подглядеть в ту сторону. Благодарствую :)


    1. Flammmable
      29.11.2023 08:44
      +1

      Вот-вот. В SV есть enum и нет нужды вручную писать перечисление, вроде этого:

      localparam IDLE_STATE 		= 4'b0001;	// 1
      localparam START1_STATE		= 4'b0010;	// 2
      localparam START2_STATE		= 4'b0011;	// 3
      localparam HOLD_STATE		= 4'b0100;	// 4
      ...


  1. Abo73
    29.11.2023 08:44

    Поскольку сигналом SCL управляет только Master-устройство — тут вообще ничего сложного.

    Вообще то это не так, что усложняет полную реализацию мастера I2C.

    https://ru.wikipedia.org/wiki/I²C#Синхронизация


    1. megalloid Автор
      29.11.2023 08:44

      Я понимаю, что не во всех случаев. Это было сказано про те случаи которые я реализую. А clock stretching я не реализую в своем варианте ввиду сложности. Это в предыдущих статьях мной отдельно проговаривалось.

      Мне бы с моим уровнем просто что-то реализовать бы :)


  1. MaxPro33
    29.11.2023 08:44

    Какие основные преимущества вы видите в использовании Verilog для разработки I2C Master Controller по сравнению с другими языками программирования?


    1. megalloid Автор
      29.11.2023 08:44
      +1

      VHDL показался на мой субъективный и дилетантский взгляд сильно перегруженным с точки зрения синтаксиса.

      Первые книги по языкам описания цифровой архитектуры по Verilog.

      Поэтому это скорее то, на чем быстрее всего было возможно реализовать задумку и не более того. Да и предыдущие статьи писал как раз на Verilog.

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

      В общем все мои доводы актуальны только для меня. Когда придёт пора совершенствоваться в этом - разумеется можно будет присмотреться к другим IDE, уйти в OpenSource, на SystemVerilog и прочее. Пока что есть что есть :)


      1. megalloid Автор
        29.11.2023 08:44

        Имею первые книги читал именно про Verilog. Торопился, фразу не дописал)


  1. DX168B
    29.11.2023 08:44
    +2

    Однозначно одобряю такие статьи. Всегда интересно, как коллеги по цеху реализуют интерфейсы для общения с внешним миром для ПЛИС. Где-то свои выводы подтвердишь чужим опытом, где-то что-то новое узнаешь.


    1. megalloid Автор
      29.11.2023 08:44

      Я думаю, что мой опыт далек от профессионализма и вряд ли можно считать эталоном.

      Я тут скорее рассказываю, о том как именно я смог достичь маленькой победы, собрав горы граблей, описав это так, чтобы какой-нибудь новичок мог взять повторить и возможно заинтересоваться темой ПЛИС-ов.

      Но тем не менее - спасибо за хорошую оценку моих материалов! Я стараюсь