В данной статье я хочу рассказать про процесс разработки относительного простого модуля для ПЛИС (FPGA), а именно – контроллера (мастера) шины I2C. Он является ведущим устройством на шине. Я постараюсь показать последовательность всех этапов работ: проектирование, написание кода, моделирование и отладка в «железе».

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

В статье приводится большое количество исходных кодов контроллера (на языке VHDL) с их подробным разбором.

1. Зачем?

У читателей данной статьи может возникнуть вполне логичный вопрос – зачем тратить время и силы на разработку своего собственного контроллера I2C, если есть множество уже готовых реализаций? Не проще ли взять одну из них и использовать в своих проектах? На самом деле отнюдь не проще!

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

Во-вторых, если мы говорим про «Open Source» решения, то они предоставляются «AS-IS». Никто не гарантирует их полную работоспособность и отсутствие ошибок. А раз так, то придется тратить время на разбор чужого вам кода и его моделирование. И после всех проверок может выясниться, что этот код никуда не годится, и его попросту нельзя использовать в серьезном проекте. А значит, что время на анализ и моделирование было и вовсе потрачено впустую.

В-третьих, работая даже над такими казалось бы несложными проектами, мы подробно изучаем стандарты и спецификации, подмечаем малозаметные детали и тонкости, пробуем какие-то новые подходы. И в итоге получаем реальный опыт и растем как инженеры. Это я считаю самым важным. В чем доблесть постоянно заниматься лишь «Ctrl-C» и «Ctrl-V»? :)

2. Постановка задачи

Любая разработка начинается с постановки задачи, то есть что мы хотим сделать и как это должно работать. В серьезных коммерческих проектах начальным этапом (и этот этап обычно оплачивается заказчиком) будет составление технического задания. Но ТЗ – это в первую очередь документ юридический. И если разработка ведется для себя, то он будет излишним. Можно ограничиться набором технических требований.

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

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

  • модуль должен быть ведущим устройством на шине (master);

  • размер поля адреса для внутренних регистров ведомых устройств должен быть от 0 до 2 байтов;

  • слово записываемых/читаемых данных в одной транзакции должно быть от 0 до 4 байтов;

  • модуль должен иметь возможность изменять порядок записываемых/читаемых слов данных (MSB, LSB);

  • должна быть возможность формировать паузу после транзакции: от 0 до 255 мс (с точностью 1 мс);

  • модуль должен иметь набор команд, задаваемый на этапе перед синтезом (константа со списком команд), количество команд в наборе – не менее 32;

  • должна быть возможность выполнять операции безусловного и условного переходов после транзакции к произвольной команде из заранее заданных команд;

  • модуль должен иметь возможность обрабатывать отдельные внешние команды (не из заданного набора);

  • модуль должен работать на частоте системного клока;

  • исходный код модуля должен быть аппаратно независимым.

Завершив составление данного списка, я приступил к разработке структуры контроллера.

3. Структура контроллера

Ниже приведена структурная схема разрабатываемого контроллера I2C. Она состоит из двух частей: ядро (CORE) и верхний уровень (TOP). Я постарался изобразить структуру именно так как это принято в даташитах, хотя для кого то она может показаться слишком подробной.

Ядро отвечает за работу с сигналами шины I2C. Верхний уровень – за хранение команд и их выполнение. Также верхний уровень выполняет взаимодействие с прочими модулями внутри ПЛИС. Управление каждой частью осуществляется управляющей логикой, реализованной при помощи конечных автоматов (FSM). Кроме того, для задания таймингов шины I2C в модуле ядре есть набор счетчиков (DIV, PHASE). Эти счетчики также можно отнести к логике управления.

Каждой части структурной схемы соответствует свой файл на языке описания цифровых схем VHDL. Для ядра – это файл i2c_master_core.vhd,  для верхнего уровня –  i2c_master_ctrl.vhd. В этих двух файлах полностью реализовывается вся логика работы контроллера.

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

3.1 Ядро (CORE)

Описание любого модуля (Entity) на языке VHDL начинается с объявления настроечных констант (Generic), а затем входов и выходов (Port). Для модуля ядра описание будет следующим:

Entity i2c_master_core is
Generic (
	SYS_FREQ		: natural := 50*1000*1000;
    I2C_FREQ		: natural := 100*1000		
	);
Port  (
	---- sys
  	clk			    : in std_logic;
	n_arst			: in std_logic;
	srst			: in std_logic;
			
	---- Addr and Data
	saddr			: in std_logic_vector(6 downto 0);			
	raddr			: in std_logic_vector(15 downto 0);	
	data_in			: in array_vector8(3 downto 0);
    data_out		: out array_vector8(3 downto 0);
    ce_data_out		: out std_logic;	

	---- CMD
	cmd_rw			: in std_logic;			
	cmd_aMod		: in std_logic_vector(3 downto 0);
    cmd_dMod		: in std_logic_vector(3 downto 0);
    cmd_OrdMod		: in std_logic_vector(3 downto 0);
    cmd_start		: in std_logic;
	status			: out std_logic_vector(7 downto 0);

	---- I2C
	i2c_clk			: out std_logic := '1';
	i2c_dout		: out std_logic := '1';
	i2c_oe			: out std_logic := '0';
	i2c_din			: in std_logic			
    );
end i2c_master_core;

Модуль имеет две настроечные константы «SYS_FREQ» и «I2C_FREQ». Первая указывает значение системной частоты, вторая задает частоту шины I2C.

Порты (входы и выходы) разделены на 4 группы.

– «Системные» входы» – это тактовый сигнал (clk) и два сигнала сброса (асинхронный – n_arst, синхронный – srst).

– «Адреса и данные». Это адрес ведомого устройства на шине (saddr), адрес внутреннего регистра ведомого устройства (raddr), а также данные, которые будут записаны (data_in) или прочитаны (data_out) из ведомого устройства.

– Далее идут «командные» входы, определяющие режимы работы ядра контроллера. Их я опишу несколько позже.

– В заключении – порты, которые подключаются к шине I2C. Выход «i2c_clk» управляет линией SCL. Порты «i2c_dout», «i2c_oe» и «i2c_din» подключаются к двунаправленному IO-буферу ПЛИС, который затем уже подключается к линии SDA шины I2C. Подробно подключения к IO-буферам я рассмотрю отдельно.

Счетчики DIV и PHASE. После того как были описаны порты модуля, перейдем к логике его работы. Начну с формирования таймингов шины I2C. Согласно спецификации, она имеет следующие режимы работы – 100 кГц и 400 кГц (но встречаются и другие). Контроллер работает на частоте системного клока «sys_clk». Обычно это десятки, или даже сотни МГц. Следовательно, необходимо как то «понизить» эту частоту, до скорости шины. Делается это при помощи двух счетчиков – DIV и PHASE (cnt_div, cnt_phase). При этом используются настроечные константы «SYS_FREQ» и «I2C_FREQ».

Счетчик DIV формирует одиночные импульсы длительностью один такт системного клока с периодичностью ровно в 4 раза большей, чем скорость шины. Например, если скорость шины 400 кГц, то периодичность импульсов счетчика DIV – 1,6 МГц. Затем счетчик PHASE считает эти импульсы и сбрасывается в 0 на каждом четвертом. Таким образом получается требуемая скорость шины I2C. Но при этом каждый бит на шине дополнительно разделен на 4 фазы. Это разделение используется затем для более точного формирования сигналов шины.

Сигналы счетчиков объявляются так:

constant PHASE_FACTOR	: natural := 4;
constant CLK_DIV_FACTOR	: natural := SYS_FREQ/(I2C_FREQ * PHASE_FACTOR);

signal cnt_div						                : natural range 0 to CLK_DIV_FACTOR-1;
signal cnt_phase					                : natural range 0 to PHASE_FACTOR-1;
signal ce_cnt_div, rst_cnt_div, ok_cnt_div		    : std_logic;
signal ce_cnt_phase, rst_cnt_phase, ok_cnt_phase	: std_logic;

Для того чтобы не описывать каждый счетчик заново, я в своё время создал пакет, содержащий  часто используемые элементы. В том числе и счетчики. Теперь я просто подключаю их к модулю в виде компонентов, а затем задаю сигналы для управления. Например, так это выглядит для счетчика PHASE:

cnt_phase_inst : c_CRO_cnt
generic map	(PHASE_FACTOR-1)
port map	(clk, n_arst, srst,
		 ce_cnt_phase, rst_cnt_phase,
		 cnt_phase, ok_cnt_phase);

ce_cnt_phase	<= ok_cnt_div and busy;
rst_cnt_phase	<= ok_cnt_phase and ok_cnt_div;

Здесь «c_CRO_cnt» – это компонент счетчика с сигналами разрешения, сброса и контролем переполнения;

«PHASE_FACTOR -1» – максимальное значение счетчика;

«clk, n_arst, srst» – системные сигналы;

«ce_cnt_phase, rst_cnt_phase» – входные сигналы разрешения и сброса;

«cnt_phase, ok_cnt_phase» – выходные сигналы со значением счетчика и признаком достижения конца счета.

Конечный автомат (CORE FSM). Он достаточно простой, фактически – это описание набора состояний шины: ожидание, биты управления и подтверждения, адресация, передача и прием данных.

type state_type is (
	    	    st_idle, st_init,
	    	    st_start, st_slave_addr, st_addr_ACK, st_sub_addr,
	    	    st_wr, st_wr_ACK,
		        st_sr1, st_rd_addr, st_rd_addr_ACK, st_rd, st_rd_ACK, st_rd_nACK,
		        st_stop, st_end
		       );

signal state : state_type;

А вот так выглядит автомат в виде графа с состояниями и переходами:

Оранжевым цветом выделены состояния, относящиеся к адресации, светло-зеленым – к передаче данных, темно-зеленым – к приему.

Командные регистры (CMD REG). Они определяют режимы работы ядра контроллера. Здесь тоже нет ничего сложного:

process(clk, n_arst)
begin
	if (not n_arst) then	
		-- <асинхронные сбросы>
	elsif rising_edge(clk) then
		if (srst) then
			-- <синхронные сбросы>
		else

			if (not busy) then
				reg_rw		<= cmd_rw;
				reg_aMod	<= cmd_aMod;
				reg_dMod	<= cmd_dMod;
				reg_OrdMod	<= cmd_OrdMod;
			end if;

		end if;
	end if;
end process;

busy	<= '0' when (state = st_idle) else '1';

Данные со входов «CMD» записываются во внутренние регистры ядра, при условии, что нет сигнала «busy». То есть командные регистры возможно обновлять только во время ожидания. Как только началась транзакция, значения фиксируются до её окончания.

«cmd_rw» задает тип транзакции: передача (0) или прием (1).

«cmd_aMod» устанавливает режим адресации на шине I2C, возможны следующие значения:

  • x0 – передается только saddr (адрес устройства на шине);

  • x1 – сначала saddr, затем младший байт адреса регистра (raddr[7..0]);  

  • x2 – saddr, raddr[7..0], raddr[15..8];  

  • прочие значения – резерв.

«cmd_dMod» – количество байтов данных, которые будет передаваться или приниматься на шине I2C:

  • x0 – нет данных;

  • x1 – 1 байт данных; x2 – 2 байта; x3 – 3 байта; x4 – 4 байта.

«cmd_OrdMod» – порядок передачи/приема байтов данных:

  • x0 – 3-2-1-0 MSB (MSB --> LSB);

  • x1 – 1-0-3-2 выровненный по 16-и битным границам;

  • x2 – 0-1-2-3 LSB (LSB --> MSB);

  • x4 – 2-3-0-1 инверсный, выровненный по 16-и битным границам.

Здесь цифрами 3,2,1,0 обозначены байты входных данных: data_in(3), data_in(2), data_in(1), data_in(0).

Вот как реализовано формирование порядка передачи данных:

if rising_edge(clk) then
	if (init) then
		case reg_OrdMod is
			when x"0" =>reg_data_in <= (reg_data_in(3), reg_data_in(2), reg_data_in(1), reg_data_in(0));
			when x"1" =>reg_data_in <= (reg_data_in(1), reg_data_in(0), reg_data_in(3), reg_data_in(2));
			when x"2" =>reg_data_in <= (reg_data_in(0), reg_data_in(1), reg_data_in(2), reg_data_in(3));
			when x"3" =>reg_data_in <= (reg_data_in(2), reg_data_in(3), reg_data_in(0), reg_data_in(1));
			when others =>	reg_data_in <= reg_data_in;
		end case;
	elsif (not busy) then
		reg_data_in	<= data_in;
	end if;
end if;

Такое же преобразование порядка выполняется и при приеме данных после завершения транзакции чтения на шине I2C. Тип для данных (порт «data_in» и «reg_data_in») – это массив 8-и битных векторов. Он описан в отдельном пакете следующим образом:

type array_vector8 is array(natural range <>) of std_logic_vector(7 downto 0);

Сдвиговый регистр передачи (SHIFT TX). В этот регистр загружаются байты адресов или данных, которые необходимо передать по шине. Затем они побитно сдвигаются:

if rising_edge(clk) then
	if (ld_saddr_w) then
		shift_tx <= reg_saddr & '0';
	elsif (ld_saddr_r) then
		shift_tx <= reg_saddr & '1';
	elsif (ld_raddr) then
		case cnt_sub is
			when ux"2" => 		shift_tx <= reg_raddr(15 downto 8);
			when ux"1" =>		shift_tx <= reg_raddr(7 downto 0);
			when others =>		shift_tx <= x"00";
		end case;
	elsif (ld_data) then
		case cnt_data is
			when ux"4" => 		shift_tx <= reg_data_in(3);
			when ux"3" => 		shift_tx <= reg_data_in(2);
			when ux"2" => 		shift_tx <= reg_data_in(1);
			when ux"1" => 		shift_tx <= reg_data_in(0);
			when others =>		shift_tx <= x"00";
		end case;
	elsif (ce_shift_tx_d) then
		shift_tx <= shift_tx(6 downto 0) & shift_tx(7);
	end if;
end if;

Выходные сигналы (OUT). Это набор триггеров, формирующих: сигнал для линии SCL, выходной сигнал линии SDA, сигнал отключения от линии SDA. Выходы триггеров зависят от состояния FSM, а также от значения счетчика PHASE. Ниже приведены примеры кода для некоторых состояний (стартовый бит, передача адресов и данных, прием данных):

case state_d is

	-- START
	when st_start =>
		if (phase < 3) then
			i2c_clk <= '1';
		else
			i2c_clk <= '0';
		end if;
		--		
		if (phase < 1) then
			i2c_dout <= '1';
		else
			i2c_dout <= '0';
		end if;
		--
		i2c_oe 		<= '1';

	-- send ADDR/DATA *
	when st_slave_addr | st_sub_addr | st_rd_addr | st_wr =>
		if (phase = 1 or phase = 2) then
			i2c_clk <= '1';
		else
			i2c_clk <= '0';
		end if;
		--
		i2c_dout <= shift_tx(7);
		i2c_oe 	  <= '1';	
				
	-- receive ACK/DATA
	when st_addr_ACK | st_wr_ACK | st_rd_addr_ACK | st_rd =>
		if (phase = 1 or phase = 2) then
			i2c_clk <= '1';
		else
			i2c_clk <= '0';
		end if;
		--
		i2c_dout	<= '-';
		--
		i2c_oe		<= '0';
Скрытый текст

* по результатам тестирования на отладочной плате были обнаружены некоторые особенности в работе ведомого устройства на шине I2C; в часть кода, отвечающую за передачу адресов и данных (send ADDR/DATA) были внесены изменения (подробно это будет описано в главе «отладка в железе»)

Следует заметить, что здесь в условии для оператора «case» используется сигнал «state_d». Это сигнал, задержанный на 1 такт относительно сигнала state, отвечающего за состояние конечного автомата. Задержка необходима для учета такта, требующегося на загрузку значения в выходной сдвиговый регистр (SHIFT TX). Также задержка в один такт добавляется и для счетчика PHASE.

Сдвиговый приемный регистр (SHIFT RX), данные (DATA OUT) и бит ACK. В состоянии чтения принимаемые данные побитно записываются в сдвиговый регистр. После приема очередных восьми бит, полученный байт данных переносится в один из регистров из массива «DATA OUT». В самом конце транзакции байты данных в массиве «DATA OUT» упорядочиваются в соответствии со значением в командном регистре «reg_OrdMod».

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

check_ACK <= ce_cnt_phase when
	 (state = st_addr_ACK or state = st_wr_ACK or state = st_rd_addr_ACK) and (cnt_phase = 1) 
	 else '0';

process(clk, n_arst)
begin
	rising_edge(clk) then
			
		-- Ответы ACK для текущий транзакции
		if (init) then
			no_bit_ACK <= '0';
		elsif (check_ACK_d) then
			no_bit_ACK <= i2c_din_i or no_bit_ACK;
		end if;
			
		-- Сохраняем признак отсутствия ответа по завершении транзакции
		if (state = st_end) then
			no_ACK <= no_bit_ACK;
		end if; 
		
	end if;
end process;

Если во время транзакции отсутствовал хотя бы один ответный бит ACK, то формируется выходной сигнал «no_ACK», оповещающий о наличии проблемы на шине I2C.

Хочу указать на один момент, связанный с приемом данных на шине. Входной сигнал с линии SDA (i2c_din) не подается напрямую на сдвиговый регистр «SHIFT RX» и логику контроля ACK. Сначала он проходит через два последовательно включенных триггера для уменьшения вероятность появления метастабильного состояния. Поэтому на «SHIFT RX» и логику ACK идет другой сигнал – «i2c_din_i», взятый с выхода этих триггеров.

Также замечу, что выходы «i2c_clk» и «i2c_din» имеют задержку в 2 такта относительно состояний конечного автомата. Первая задержка – это «state_d», уже упоминавшаяся выше, и вторая – на выходных триггерах «OUT». Их нужно учитывать при приеме, чтобы не промахнуться с моментом защелкивания входных данных с линии SDA. В итоге, для корректного учета всех задержек, информация о них задается константами:

-- Задержка для защиты по входу i2c_din
constant I2C_PROTECT_DELAY		: natural := 2;
-- Суммарная задержка (в тактах)	
constant I2C_RD_FULL_DELAY		: natural := 2+I2C_PROTECT_DELAY;

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

check_ack_delay_inst : c_bit_delay
Generic map (I2C_RD_FULL_DELAY)
Port map 	(clk, n_arst, srst,
		     check_ACK, check_ACK_d);

Здесь «c_bit_delay» – это параметризованный компонент, формирующий триггеры задержки; «check_ACK» – сигнал от конечного автомата; «check_ACK_d» – задержанный сигнал.

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

3.2 Верхний уровень (TOP)

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

entity i2c_master_ctrl is
	 Generic (
		SYS_FREQ		: natural := 50*1000*1000;
		I2C_FREQ		: natural := 100*1000;				
		REG_OUT_NUM	    : natural range 1 to 16 := 8;	
		CMD_LIST		: array_vector96(0 to 31) := (others => (others => '0'))	
		);
	Port  (
		 clk			: in std_logic;
		n_arst			: in std_logic;
		srst			: in std_logic;
		pulse_ms		: in std_logic;
		---- Регистры данных 
		reg_out			: out array_vector32(REG_OUT_NUM-1 downto 0);
		reg_upd		    : out std_logic_vector(REG_OUT_NUM-1 downto 0);		
		---- CMD - входы для команд от внешнего модуля (если необходимо)
		irq			    : in std_logic;									
        irq_ok			: out std_logic;	
		cmd_in			: in std_logic_vector(95 downto 0);
		ce_cmd_in		: in std_logic;
		busy			: out std_logic;							
		data_out		: out std_logic_vector(31 downto 0);
		ce_data_out		: out std_logic;
		---- Дополнительно
		thrl_val			: in std_logic_vector(31 downto 0);			
		---- I2C
		i2c_clk			: out std_logic := '1';
		i2c_dout		: out std_logic := '1';
		i2c_oe			: out std_logic := '0';
		i2c_din			: in std_logic			
		);
end i2c_master_ctrl;

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

Конечный автомат (MASTER FSM). Его основная задача – это исполнение команд, записанных в памяти (CMD LIST). Также автомат обрабатывает запросы от внешних модулей, для выполнения передаваемых ими команд. Имеет следующий набор состояний:

type state_type is (
			st_init, st_idle,
			st_nop,  st_dummy, st_cmd, st_cmd_proc, st_cmd_end,
			st_cond, st_jmp, st_inc, st_pause,
			st_req_ok, st_ext_wait, st_ext_start, st_ext_nop, st_req_end						
           );

Граф конечного автомата выглядит следующим образом:

Желтым цветом обозначены состояния, когда управление передаётся ядру, которое выполняет транзакцию на шине. Зеленым цветом – состояния управления условными переходами и паузой. Синим – состояния, в которых команды принимаются извне (от какого-то внешнего модуля).

Память команд (CMD LIST) – вот это именно то, ради чего я и начал разработку собственного контроллера. Она представляет собой массив констант, содержащий команды, которые начнет выполнять контроллер сразу после сброса. Значения элементов массива задаются при помощи параметра «CMD_LIST». На данный момент размер массива – 32 элемента. Я ограничился этим значением, чтобы не увеличивать размер мультиплексора команд, и следовательно на заваливать частоту «fmax». Думаю, что для типовых задач этого количества хватит с избытком. Если же нужно будет большее количество, тогда для хранения команд буду использовать блочную память, сконфигурированную как ROM.

Длина каждой команды (разрядность элемента массива) – 96 бит. Структура следующая:

Название

Разрядность

Положение

Описание

saddr

8 бит   

[7..0]

[7] - резерв; [6..0] - адрес на шине I2C

raddr   

16 бит

[23..8]

Адрес внутреннего регистра в ведомом устройстве

data

32 бита

[55..24]

Данные для записи в ведомое устройство; в режиме чтения игнорируются

cop

2 бита

[57..56]

Код операции: 00 - NOP, 01 - RD, 10 - WR, 11 - резерв

amod

4 бита

[61..58]

Режим адресации

dmod

4 бита

[65..62]

Количество байтов данных (для записи или чтения)

ordmod

4 бита

[69..66]

Последовательность передачи/чтения байт данных

pause  

8 бит

[77..70]

Пауза после выполнения команды (в миллисекундах)

jmp

4 бита

[81..78]

Признак (и условие) перехода

jcmd

8 бит   

[89..82]

Порядковый номер команды, на которую может быть произведен переход

oreg

4 бита

[93..90]

Номер выходного регистра, в который будут записаны данные, прочитанные из ведомого устройства

резерв

2 бита

[95..94]

Резерв

При выполнении команды, значения полей «saddr», «raddr», «data», «amod», «dmod», «ordmod» передаются на соответствующие входы ядра (CORE).

Счетчик команд (CNT CMD) и мультиплексирование. Счетчик команд хранит указатель на номер команды, которая будет выполняться следующей. После включения (или сброса) начальное значение «0». То есть выполнение программы начнется с команды, расположенной по начальному (нулевому) адресе в памяти команд (CMD LIST).

if rising_edge(clk) then	

	if (rst_cnt_cmd) then
		cnt_cmd <= 0;
	elsif (ld_cnt_cmd) then
		if (unsigned(reg_jcmd) > CMD_LIST'length-1) then
			cnt_cmd <= CMD_LIST'length-1;
		else
			cnt_cmd <= to_integer(unsigned(reg_jcmd));
		end if;
	elsif (ce_cnt_cmd and not ok_cnt_cmd) then
		cnt_cmd <= cnt_cmd + 1;
	end if;

	-- счетчик дошел до конца - защелкиваем признак завершения программы
	if (ce_cnt_cmd and ok_cnt_cmd) then
		finished	 <= '1';
	end if;

end if;

ce_cnt_cmd	<= '1' when (state = st_inc or state = st_nop) else '0';
ld_cnt_cmd	<= '1' when (state = st_jmp) else '0';

Если не было сигнала перехода, то после выполнения очередной команды счетчик увеличивается на 1. Если сигнал перехода был, то в счетчик загружается номер команды, на которую производится переход. После выполнения последней команды (при отсутствии сигнала перехода) формируется флаг «finished». Он сигнализирует конечному автомату, что последовательность выполнения команд завершена.

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

mux_cmd <= CMD_LIST(cnt_cmd) when (not req_ok) else cmd_in;

Если обрабатываются команды из памяти команд (CMD LIST), то выход мультиплексора определяется значением счетчика команд. Иначе – происходит переключение на прием внешней команды (режим прерывания). В итоге получается мультиплексор «33 в 1». На выходе мультиплексора установлены регистры, что сделано для конвейеризации.

Логика переходов (JMP & COMPARE LOGIC) предназначена для выполнения шести условных и одного безусловного перехода к произвольной команде из памяти команд. Условие перехода задается в поле «jmp», номер команды для перехода – в поле «jcmd».

Условие – это один из результатов беззнакового сравнения значения выходного регистра «reg_out (0)» с заданным извне пороговым значением «reg_thrl» (THRL). Переход осуществляется после проверки значения флага, который соответствует  условию из поля jmp.  Для выполнения перехода значение флага должно быть «1».

-- нет перехода - всегда 0
compare_vect(0) <= '0';
-- безусловнный переход - всегда 1
compare_vect(1) <= '1';
-- равно
compare_vect(2) <= '1' when (compare_val = reg_thrl) else '0';
-- не равно
compare_vect(3) <= '1' when (compare_val /= reg_thrl) else '0';
-- больше или равно
compare_vect(4) <= '1' when (compare_val >= reg_thrl) else '0';			
-- меньше или равно
compare_vect(5) <= '1' when (compare_val <= reg_thrl) else '0';
-- больше 
compare_vect(6) <= '1' when (compare_val > reg_thrl) else '0';			
-- меньше
compare_vect(7) <= '1' when (compare_val < reg_thrl) else '0';

-- резерв - считатся отсутствием перехода
compare_vect(15 downto 8) <= (others => '0');

if rising_edge(clk) then	

	-- Регистр для результатов сравнения
	compare_result <= compare_vect;
			
	-- Выбор результата сравнения из регистра, исходя из заданного условия
	cond_ok <= compare_result(to_integer(unsigned(reg_jmp)));

end if;

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

Пауза (PAUSE). Иногда после выполнения очередной команды возникает необходимость сделать паузу и какое-то время не выполнять другие команды. Например, этого диктуется логикой работы ведомого устройства, или же в цикле чтении нет необходимости в непрерывном получении данных. Для задания величины паузы используется поле «pause». Значение «0» – отсутствие паузы. Остальные значения (от 1 до 255) – это величина паузы в условных интервалах времени, задаваемых внешним импульсом.

if rising_edge(clk) then

	-- счетчик
	if (rst_cnt_pause) then
		cnt_pause <= 0;
	elsif (ld_cnt_pause) then
		cnt_pause <= to_integer(reg_pause);
	elsif ((ce_cnt_pause and pulse_ms) and not ok_cnt_pause) then
		cnt_pause <= cnt_pause-1;
	end if;

	if (rst_cnt_pause) then
		ok_pause <= '0';
	-- если пауза задана 0, то сразу формируем сигнал завершения паузы
	elsif (ld_cnt_pause = '1' and reg_pause = 0) then
		ok_pause <= '1';
	-- защелкиваем сигнал завершения на следующем приходе импульса pulse_ms
	elsif (ce_cnt_pause and ok_cnt_pause and pulse_ms) then
		ok_pause <= '1';
	end if;

end if;

ok_cnt_pause <= '1' when (cnt_pause = 0) else '0';

Интервалы времени для работы счетчика паузы задает внешний сигнал «pulse_ms». Это одиночные импульсы (длительность 1 такт), следующие через равные промежутки времени. Я выбрал интервал в одну миллисекунду, но допустимо формировать импульсы и с любой другой периодичностью.

Внешний запрос прерывания (IRQ) обеспечивает возможность переключение контроллера в режим выполнения команд от внешнего модуля. Подтверждением переключения является выходной сигнал «irq_ok». В этом режиме часть полей команды (jmp, jcmd, pause, oreg) игнорируется.  После возврата в основной режим работы, продолжается выполнения команд из списка «CMD LIST». Обработка запросов прерывания выполняется непосредственно в автомате «FSM TOP».

4. Программирование (формирование списка команд)

Под термином «программирование» в данной статье я подразумеваю составление списка команд для массива «CMD LIST». Каждый элемент массива – это 96-и битный вектор. В принципе, все эти векторы возможно непосредственно задать в шестнадцатеричном виде. Например, так:

CMD_LIST(2) <= x"00000001060C0B0A09000873";
CMD_LIST(7) <= x"0C1044010500000000000C73";

Но такая запись крайне неудобна, не читаема и абсолютно не поддерживаема в дальнейшем. Какой-то «птичий язык», как называл подобное один мой коллега :)

Дабы сделать программирование контроллера I2C удобным, я создал отдельный пакет (pack_i2c_master). В нем я описал дополнительные типы и функции, предназначенные для составления команд в удобном для разработчика виде.

type i2c_cmd is record
saddr			: std_logic_vector(7 downto 0);
raddr			: std_logic_vector(15 downto 0);
data			: std_logic_vector(31 downto 0);
cop			    : t_i2c_cop;
--
amod			: natural range 0 to 15;
dmod			: natural range 0 to 15;
ordmod			: t_i2c_ordmod;
--
pause			: natural range 0 to 255;
jmp			    : t_i2c_jmp;
jcmd			: natural range 0 to 255;
oreg			: natural range 0 to 15;
end record;
	
-- массив записей для i2c_cmd
type arr_i2c_cmd is array(natural range <>) of i2c_cmd;

Тип «i2c_cmd  – это  основной тип, который позволяет полностью задать все поля команды. За ним следует тип, позволяющий создавать массив команд (программу). Также есть несколько вспомогательных типов, облегчающих запись полей «cop», «ordmod» и «jmp».

-- Набор команд
type t_i2c_cop is (NOP, RD, WR);

-- Набор допустимых вариантов последовательностей передачи/чтения байт данных
type t_i2c_ordmod is (B_3210, B_1032, B_0123, B_2301);
	
-- Набор условий для признаков перехода
type t_i2c_jmp is (NONE, JMP, JE, JNE, JAE, JBE, JA, JB);

Ниже приведен пример набора команд, записанного при помощи вышеприведенных типов:

constant MY_PROGRAM  : arr_i2c_cmd(0 to 31) := ( 	  
-- saddr   raddr    data         cop  amod  dmod  ordmod   pause  jmp   jcmd oreg
(SADDR,   x"0000",  x"04030201",  WR,  1,    4,   B_3210,    0,   NONE,  0,  0),       
(SADDR,   x"0004",  x"08070605",  WR,  1,    4,   B_3210,    0,   NONE,  0,  0),
(SADDR,   x"0008",  x"0C0B0A09",  WR,  1,    4,   B_3210,    0,   NONE,  0,  0),
(SADDR,   x"000C",  x"100F0E0D",  WR,  1,    4,   B_3210,    8,   NONE,  0,  0),
--
(SADDR,   x"0000",  x"00000000",  RD,  1,    4,   B_3210,    4,   NONE,  0,  0),       
(SADDR,   x"0004",  x"00000000",  RD,  1,    4,   B_3210,    4,   NONE,  0,  1),
(SADDR,   x"0008",  x"00000000",  RD,  1,    4,   B_3210,    4,   NONE,  0,  2),
(SADDR,   x"0008",  x"00000000",  RD,  1,    4,   B_3210,    4,    JMP,  4,  3),
--
others =>
(SADDR,   x"0000",  x"00000000", NOP,  0,    0,   B_3210,    0,   NONE,  0,	 0)	
);

Теперь даже при беглом взгляде на приведенный набор команд, почти сразу становится понятно какие действия он выполняет. Вначале идут 4 команды записи в ведомое устройство, имеющее адрес на шине – «SADDR». Запись производится последовательно по адресам 0, 4, 8, 12. На шину I2C выставляется только младший байт адреса (параметр amod = 1). Сами данные передаются группами по 4 байта (параметр dmod = 4). После 4-й команды записи формируется пауза (8 мс). Затем начинается цикл чтения. Читаются те же адреса, что и в командах записи. В конце каждого чтения – пауза на 4 мс. После выполнения последний команды чтения (точнее после паузы) производится переход на команду #4. И цикл чтения повторяется снова.

Для преобразования списка команд из массива записей в массив векторов в пакете имеется специальная функция. Используется она так:

constant VECT_ARR: array_vector96(0 to CMD_REC'length-1) := i2c_cmd_conv(MY_PROGRAM);

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

Промежуточный итог

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

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


  1. yamifa_1234
    02.09.2025 18:09

    статья качественная, и проработанная что даже вопросов не возникает)


  1. VT100
    02.09.2025 18:09

    Не забудьте контроль невалидных состояний шины: захват шины другим мастером или медленным ведомым (clock stretching), аппаратный сбой (нет подтяжек, К.З. на общий). Даже если не предполагается много ведущих, то второе и третье - могут случиться в любой системе.