USB хост контроллер

USB host controller schema in Intel Quartus Prime

Я сделал USB хост контроллер в FPGA для подключения Low Speed устройств, клавиатур или мышей. Немного расскажу про этот проект. Контроллер делается для платы Марсоход3/Марсоход3bis плюс шилд разъемов. Тем не менее, так же все должно работать и на платах Марсоход2 или Марсоход2bis или других FPGA платах (если сделать правильные назначения сигналов проекта). В этом проекте есть два компонента, аппаратный и программный:

  • USB хост контроллер выполнен в среде Intel Quartus Prime Lite, топ модуль в виде схемы, остальные модули - это Verilog HDL;
  • Программа управления, написана на C/C++ в среде Visual Studio. Программа открывает последовательный порт, который связывает FPGA плату и компьютер. Через последовательный порт программа отправляет управляющие команды USB хост контроллеру и получает от него результат исполнения команд и принятые данные.

Ниже постараюсь рассказать подробнее, но больше про программный компонент.

Работать с шиной USB не очень просто. Даже в режиме Low Speed. Есть определенные сложности и на физическом уровне и на уровне протокола передачи. Тем не менее, если не пытаться все сделать абсолютно правильно, то можно логику значительно упростить. В моем хост контроллере не все сделано правильно, но зато все относительно просто. Могу сразу перечислить получившиеся ограничения:

  • поддерживаются только Low Speed устройства, с небольшим объемом передаваемых данных, максимальная длина пакетов данных 8 байт;
  • поддерживаются только Interrupt Transfers, Bulk и тем более Isochronous не поддерживаются. Это ограничение отчасти связано с первым, ведь для Bulk нужен хотя бы Full Speed, с объемом пакета данных 64 байта. По этой же причине не будут работать устройства с USB HUB. Isochronous вообще требует особого подхода;
  • контроллер не считает и не проверяет контрольные суммы передаваемых пакетов. Для передаваемых пакетов управляющая программа должна сама посчитать контрольные суммы;
  • контроллер не проверяет очередность следования пакетов.

В принципе, при желании, все это можно позже добавить в проект или исправить, ну разве что Isochronous режим не просто реализовать.

На рисунке выше представлена схема топ модуля проекта. На модуль usbhost подается с PLL две частоты. Первая частота это cclk - частота контроллера, 12МГц, необходимая для работы шины USB. Вторая частота iclk - это частота интерфейса управления контроллером, частота на которой идет обмен данными между контроллером и внешними устройствами. Внешним устройством может быть процессор или что-то другое. В моем проекте используется модуль serial, который позволяет из компьютера посылать данные через этот модуль serial в USB хост контроллер. Модуль serial принимает байты и передает их в USB хост контроллер через шину wdata[7:0] с управляющим сигналом wr.

Контроллер реализует интерфейс FIFO: в него можно записать байтами последовательность специальных команд и эти команды тут же начинают последовательно исполняться. Теоретически у контроллера есть выходной сигнал wr_level[1:0] по которому можно определить сколько еще места осталось в FIFO на запись, но я этим не буду пользоваться. Программное обеспечение для обслуживания порта должно знать, что количество байт в FIFO не более 64, то есть пачки байт, которые ПО должно посылать в контроллер должны быть длиной менее 64. Программное обеспечение записывает набор/последовательность команд в контроллер и ожидает получить результат исполнения этих команд. Результат считывается из контроллера так же, как из FIFO: по шине данных rdata[7:0] сигналом rd. Сигнал rdata_ready говорит о том, что в выходном FIFO контроллера скопились данные, которые нужно забрать.

Вообще, тема USB хоста довольно обширная. Можно ее рассматривать с разных точек зрения, смотреть на физический уровень, на сам протокол USB. Я cейчас опишу только последовательный протокол передачи между ПК и хост контроллером. Это мой собственный протокол, который я разработал для этого проекта. Здесь будет описание тех команд, которые можно послать в контроллер и которые можно прочитать из контроллера. Команд всего шесть, они определены в Verilog модуле usbhost.v:
//controller serial bytes protocol
localparam CMD_GET_LINES = 4'h1; //controller must once poll and return one byte state of USB lines
localparam CMD_BUS_CTRL = 4'h2; //controller must set bus to reset/normal and/or or enabled/disabled state
                                 //reset state - dm/dm in zero
                                 //enabled state when no reset and 1ms periodic SE0 impulses go
localparam CMD_WAIT_EOF = 4'h3; //controller just waits EOF/SE0 condition
                                //any bus operation should start from this, so any read/write start //at begin of frame
localparam CMD_SEND_PKT = 4'h4; //new packed will be sent to bus. high 4 bits of cmd byte mean data length
localparam CMD_READ_PKT = 4'h5;
localparam CMD_AUTO_ACK = 4'h6;

Команда CMD_GET_LINES - это один байт, в шестнадцатеричном виде 0x01. Если записать в контроллер этот байт, то на его выходной шине rdata[7:0] появится ответ на эту команду 0x?1. В старшей тетраде выходного байта, в битах 4 и 5, будет отображено состояние линий USB Dp/Dm. По значениям в этих линиях программное обеспечение может определить какое устройство подключено к шине. В исходном состоянии линии Dp и Dm притянуты к земле резисторами и, таким образом, на линиях всегда ноль.

USB connection

При подключении низкоскоростного устройства линия Dm оказывается в единице, так как внутри этого устройства должен стоять подтягивающий резистор к питанию.

USB connection

При подключении полноскоростного устройства наоборот Dp оказывается в единице, так как сигнал D+ притянут к питанию через резистор.

Теперь, программное обеспечение (ПО), опрашивая состояние линий Dp/Dm, может определять подключено ли какое-то устройство к шине или нет. ПО должно периодически посылать команду CMD_GET_LINES в контроллер и читать из него ответ. Если ответ 0x01 - ничего не подключено, если же ответ будет 0x11 - то подключено низкоскоростное устройство, то ли клавиатура, то ли мышь или еще что-то такое.. Если ответ 0x21 - полноскоростное устройство, но мой контроллер пока не может с этим работать. В программе на C/C++ ожидание условия подключения устройства к шине USB может выглядеть вот так:


        //wait device attach
	while (1)
	{
		cmd = CMD_GET_LINES;
		send(1, &cmd);

		lines_status=0;
		r = read_byte(&lines_status, 100);
		if (r)
		{
			//controller replied with lines status
			printf("%02X\n", lines_status);
			if ( ((lines_status &0x0F) == CMD_GET_LINES) && ((lines_status &0xF0) != 0x00) )
			{
				printf("Attached!\n");
				break;
			}
			Sleep(1000);
		}
	}

Посылаем один байт команды CMD_GET_LINES и читаем один байт статуса. Анализируем статус, если линии Dp/Dm в нуле - цикл повторяется, ждем дальше.

После того, как подключенное устройство обнаружено программным обеспечением, оно должно послать на шину USB команду сброса и после этого "активировать" шину. Для этих целей у меня предусмотрена команда CMD_BUS_CTRL. В шестнадцатеричном виде это 0x?2. На месте знака вопроса в битах 4 и 5 нужно установить желаемое состояние шины. Бит 4 - это Bus Reset, а бит 5 - это Bus Enable. Бит Bus Reset имеет больший приоритет. Если он установлен, то USB хост, получив такую команду, опускает обе линии Dp/Dm в ноль, и, таким образом, сообщает подключенным устройствам, что они должны вернутсья в свое исходное состояние. Программное обеспечение управляет состоянием сброса. ПО должно поставить Bus Reset, затем подождать хотя бы 20 миллисекунд и затем снять сигнал Bus Reset. При снятии сигнала сброса одновременно ПО устанавливает сигнал Bus Enable. В режиме Bus Enable контроллер хоста USB обязан периодически, раз в 1 миллисекунду, посылать в шину сигнал SE0. Состояние SE0 - это когда обе линии шины USB Dp/Dm опущены в ноль на длительность двух Low Speed передаваемых битов (два периода частоты 1,5МГц). Это в режиме Low Speed. В режиме Full Speed каждый кадр в одну миллисекунду посылается специальный пакет SOF (Start of Frame). Эти периодичные сигналы SE0 или SOF не дают подключенном устройству уйти в сон.

Эта логика может быть просто описана в программе на C/C++:


printf("USB Reset\n");
cmds[0] = 0x30 | CMD_BUS_CTRL;
send(1, &cmds[0]);

Sleep(20);

//USB enable, then wait nearest SE0/EOF, then read USB lines status
cmds[0] = 0x20 | CMD_BUS_CTRL;
send(1, &cmds[0]);

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

Минимум, что нужно сделать - это отправить в устройство команду SetAddress. На шине теоретически может быть несколько устройств, соединенных USB хабами. Каждое устройство должно получить свой уникальный USB адрес. Без этого оно работать не будет. Как понять, что нужно посылать в шину USB?

Вообще для аппаратной разработки связанной с USB всегда полезно иметь специальное устройство USB-Tracker - это анализатор протокола USB, того, что передается по USB проводам. У меня есть несколько таких анализаторов. Один из них USB 480 TOTALPHASE. Вот он на фото с подключенной платой Марсоход3:

FPGA плата Марсоход3 и USB анализатор

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

Посмотрим внимательно на команду SetAddress на шине USB:

USB analizer

Из этого снимка видно, что казалось бы простая команда SetAddress состоит из множества более мелких передач пакетов.
Причем некоторые пакеты отправляются хост контроллером, а некоторые отправляются устройством.
Сперва нужно отправить из хоста два пакета подряд SETUP (0x2d,0x00, 0x10) и DATA0 (0xc3, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xEB, 0x25), при этом устройство должно тут же ответить подтверждением приема пакетом ACK (0xD2). Потом нужно отправить пакет IN (0x69, 0x00, 0x10) и устройство в ответ должно прислать DATA1 (0x4B, 0x00, 0x00) и если контроллер принял IN правильно, то должен тут же ответить пакетом подтверждения ACK (0xD2). Я тут просто перечислил передаваемые данные в виде байтов, но на самом деле у каждого из этих байтов есть свой смысл. Рассказывать про это все у меня букв не хватит. Я помню когда-то выкачали одну из первых спецификаций USB и распечатали - получилась толстенная книга чуть не на 500 листов. Вообще-то когда-то давно у нас на сайте я немного писал про протокол USB. Кстати, спецификацию на протокол USB 1.1 можно скачать у нас на сайте:

Возвращаюсь к теме статьи и реализации хост контроллера. Следующая важная команда - это CMD_SEND_PKT, 0x?4. Сам код команды - это число 4, но в старшей тетраде нужно указать длину данных, которые сразу следуют после команды. Данные так и будут отправляться в той же последовательности. Однако, нужно сказать, что после кода команды перед актуальными данными нужно послать еще служебный байт 0x80, это преамбула каждого пакета.

Есть еще важный ньюанс. Мой хост контроллер исполняет приходящие команды сразу по поступлении их во входное FIFO, однако, на самом деле просто так это делать нельзя. Нельзя начинать передачу пакетов в произвольное время. Там же еще идут периодические SE0, каждую миллисекунду. Если начинать передачу в произвольный момент времени, то передаваемый пакет может случайно попасть на это SE0, возникнет коллизия и пакет будет испорчен.

Чтобы этого не происходило я придумал еще одну команду - CMD_WAIT_EOF, 0x03. Получив эту команду контроллер как бы замирает ожидая конца текущего фрейма и начала следующего. Если послать в контроллер две подряд команды CMD_WAIT_EOF, то контроллер задержит свою работу до конца текущего фрейма и подождет еще 1 миллисекунду следующий фрейм. Таким образом, что бы начинать передачу нужно сперва послать CMD_WAIT_EOF (или лучше две таких команды) и затем уже CMD_SEND_PKT. Это гарантирует отправку пакета в самом начале фрейма.

Если программа знает, что после нескольких команд подключенное устройство будет отвечать пакетом данных, то она должна послать в хост контроллер команду ожидания пакета CMD_READ_PKT, 0x05. По этой команде контроллер активизирует модуль приемника и ждет принимаемых данных. Принятые данные складываются во внутреннее временное FIFO. Как только пакет принят контроллер будет знать его длину и сможет отправить в FIFO на вывод команду-результат, ту же чтения пакета CMD_READ_PKT 0x?5, где вместо старшей тетрады будет значение длины принятого пакета. Ну и потом из временного фифо данные переносятся в выходное фифо. Таким образом, прикладная программа сможет читать пакеты принятые с шины USB. Мне пришлось в FPGA модуль вставить временное FIFO для хранения принятых данных только по тому, что заранее неизвестна длина принятого пакета. Для программного протокола это важно. Иначе программа не знает сколько байтов нужно читать из порта.

Рассмотрим пример программы на C, как она отправляет команду SetAddress:


bool set_address()
{
	unsigned char sbuffer[] = {
		CMD_WAIT_EOF,
		CMD_WAIT_EOF,
		0x40 | CMD_SEND_PKT, 0x80, 0x2d,0x00, 0x10,
		0xC0 | CMD_SEND_PKT, 0x80, 0xc3, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0xEB, 0x25,
		CMD_READ_PKT
	};
	send(sizeof(sbuffer), sbuffer);
	unsigned char rbuffer[32];
	int got_length = 0;
	bool r = recv_packet(rbuffer, sizeof(rbuffer), &got_length, 100);
	if (!r) return false;
	//check that ACK received here
	if (rbuffer[0] == 0x80 && rbuffer[1] == 0xD2)
	{
		//got ACK, so fine
	}

	unsigned char sbuffer_in[] = {
		CMD_WAIT_EOF,
		0x40 | CMD_SEND_PKT, 0x80, 0x69, 0x00, 0x10,
		CMD_READ_PKT,
		CMD_AUTO_ACK
	};
	send(sizeof(sbuffer_in), sbuffer_in);
	got_length = 0;
	r = recv_packet(rbuffer, sizeof(rbuffer), &got_length, 100);
	if (!r) return false;
	
	return true;
}

Здесь в массиве sbuffer собраны все команды которые нужно отправить в хост контроллер функцией send(). Программа отправляет две команд ожидания начала фрейма, затем отправляет пакет SETUP, тут же отправляет пакет DATA0 и тут же отправляет команду на чтение пакета от устройства, ожидая получить пакет ACK в вызове функции recv_packet().

Второй этап, выполнение чтения из подключенного устройства. В массиве sbuffer_in заранее подготовлены команды к отправке в контроллер: ожидание начала фрейма, отправляется пакет IN, затем дается команда на чтение из устройства CMD_READ_PKT и напоследок, команда CMD_AUTO_ACK. Про эту последнюю команду я еще не писал.

Команда CMD_AUTO_ACK, 0x06, заставляет контроллер автоматически отправлять пакет ACK после приема последнего пакета из подключенного устройства. Причем, делается это "умным способом". Команда ACK отсылается только если приняты данные из устройства. А они могут быть и не приняты, если мы читаем из устройства, а у него нет никаких данных для нас и он отвечает пакетом NAK. Все выглядит довольно мудрено, но тут никак проще не сделать, так как у USB вот такая сложная логика.

После установка адреса из USB устройства можно читать данные. Функция для чтения данных из моего контроллера может выглядеть вот так:


bool read_pipe()
{
	unsigned char sbuffer[] = {
		CMD_WAIT_EOF,
		CMD_WAIT_EOF,
		0x40 | CMD_SEND_PKT, 0x80, 0x69, 0x81, 0x58,
		CMD_READ_PKT,
		CMD_AUTO_ACK
	};
	send(sizeof(sbuffer), sbuffer);
	unsigned char rbuffer[32];
	int got_length = 0;
	bool r = recv_packet(rbuffer, sizeof(rbuffer), &got_length, 100);
	if (!r) return false;
	if (got_length == 2 && rbuffer[1] == 0x5A)
		return true; //no data NAK
	process_mouse_packet( &rbuffer[2] );
	return true;
}

Она отправляет команду IN и ждет пакета от устройства. Если у уствойства есть данные - оно их отсылает. Иначе - присылает пакет NAK, 0x5A. В моей программе я пытаюсь работать с USB мышью, поэтому принятые данные я пытаюсь разобрать как пакет от мыши. Конкретно к моей мыши я разобрался в формате данных. К сожалению, производители иногда придумывают свои собственные форматы передаваемых данных. Их все заранее знать очень не просто. Поэтому в спецификации USB для HID устройств предусмотрены HID Report Descriptor - описатели формата данных устройств ввода. То есть если по правилам, то после установки адреса командой SetAddress я должен считать из устройства HID Report Descriptor и следуя этому описанию разбирать побитно содержимое пакетов. Все это довольно не просто.. К сожалению. Вся эта логика уже реализована в HID драйверах операционных систем. Если же пытаться писать самому с нуля - это не очень просто, для поддежки широкого спектра мышей и клавиатур придется довольно много кода написать..

Но заставить одну конкретную мышь работать - легко.
В моем случае я даже написал простую функцию на C/C++ которая разбирает пакет от мыши и генерирует событие на десктопе - движение указателя мыши или даже клики кнопками мыши через функцию Windows API mouse_event(). То есть мышь подключенная к плате Марсоход3, которая подключена к компьютеру кабелем USB работает и управляет рабочим столом!

Все это видно на моем маленьком демонстрационном видео:

Весь код проекта (и для FPGA, Quartus Prime и С/С++, Visual Studio) можно взять на github:

https://github.com/marsohod4you/UsbHost

О Verilog симуляции этого простого USB хост контроллера читайте в следующей статье.
А в этой статье получилось развитие этого проекта: USB хост контроллер с двумя портами для мыши и клавиатуры.


Добавить комментарий