Добрый день, уважаемые хабровчане. В этом цикле статей мы с вами пройдем достаточно длинный, но весьма интересный путь по превращению обычного роутера в мини-компьютер с LCD-дисплеем. Для этого мы разработаем сначала USB-видеокарту на базе микроконтроллера STM32F103, потом тестовый драйвер, который позволит нам выводить на него графику, и, наконец – полноценный драйвер фреймбуффера, благодаря которому можно будет запустить настоящие графические приложения, такие как x-сервер. Заодно мы научимся включать наш код в дерево исходников OpenWRT, допиливать его ядро и делать прочие полезные вещи.
Ну а в самом конце мы получим результат, который, я надеюсь, вызовет ностальгическую слезу у многих читателей. Я постараюсь излагать материал таким образом, чтобы в конце каждого этапа мы получали осязаемый результат, не дающий угаснуть энтузиазму. Итак, начнем.
Железо
Традиционно, посмотрим, что из железа нам потребуется. Тех, кто не любит паять, я могу сразу успокоить — все что мы будем делать – чисто фёрмварное и софтварное, так что собственно паять нам не придется. Но зато на понадобится отладочная на контроллере STM32F103VE и QVGA дисплеем, о которой я уже упоминал в своей статье.
Также нам понадобится, собственно, роутер, который я также упоминал в другой статье, но к нему мы вернемся позже. Сейчас же сосредоточимся на разработке самой USB-видеокарты на базе нашей отладочной платы.
У STM32F103 есть в составе два блока, которые нам очень пригодятся. Первый из них – это, разумеется, аппаратный USB-контроллер, который отлично подойдет для организации соединения между нашей видеокартой и хостом. Второй – FSMC – Flexible Static Memory Controller. Он представляет собой контроллер статической памяти, который может быть настроен для использования совместно с микросхемами SRAM, NAND/NOR FLASH и тому подобными устройствами, причем ширина шины и тайминги в нем поддаются настройке. При этом микросхемы мапируются на адресное пространство и, при обращении по соответствующему адресу, FSMC сам генерирует необходимые сигналы на управляющих линиях и шинах адреса и данных, так что для программиста этот процесс является полностью прозрачным.
В случае с дисплеем это нам очень поможет, так как дисплеи подобного рода снабжены интерфейсом, почти полностью совпадающим с интерфейсом NOR-флешки и SRAM: 16-битная шина данных, стробы CS, ~WR, ~RD. С этими сигналами все просто – CS активизирует дисплей, с него начинается цикл обмена данными. ~WR либо ~RD активизируются при обращении на чтение либо запись соответственно.
К сожалению, дисплей не предоставляет нам своей шины адреса и данных для доступа напрямую к видеопамяти, вместо этого нам доступна единая 16-битная шина и дополнительный сигнал RS, Register Select. При активном сигнале RS, значение, выставленное на эту шину, воспринимается как адрес регистра (ячейки RAM) контроллера дисплея, к которому следует обратиться, а последующее чтение либо запись с неактивным RS – операцией с RAM дисплея.
Следует отметить, что RAM в данном случае не является видеопамятью, это память контроллера дисплея, к которой открыт доступ извне посредством описанного выше механизма. Видеопамять же фигурирует в документации как GRAM, Graphics RAM, и ее содержимое доступно через «окошко» в виде одного из регистров. При этом логика контроллера дисплея сама инкрементирует/декрементирует внутренний адрес видеопамяти при последовательном чтении из одного и того же регистра (CTR_WRITE_DATA, CTR_READ_DATA)
FSMC не имеет специализированного сигнала RS, поэтому для этого применяется один трюк: какой-нибудь из доступных сигналов шины адреса FSMC подключается к сигналу RS.
Предположим, что мы подключили сигнал A0 к RS. Тогда при обращении к адресу памяти 0х00000000 на запись (относительно базового адреса, на который мапирован данный банк FSMC) сигнал RS будет неактивен и дисплей воспримет это как установку адреса регистра.
При обращении же к 0х00000001 адресная линия A0 будет активна, и чтение либо запись выполнятся уже для ячейки RAM, то есть, для регистра, адрес которого был задан при обращении по нулевому адресу.
Подробнее про это можно прочитать в апноуте от СТМ, посвященном данному вопросу.
Описание регистров контроллера дисплея доступно его даташите.
Кстати, с даташитами следует быть поосторожнее и внимательно смотреть на его версию, потому что китайские товарищи обожают сначала копировать саму микросхему (не полностью, а как получится), а потом – копировать даташит от этой микросхемы. Поэтому в процессе чтения даташита можно с удивлением обнаружить регистры и функции, которых данный контроллер никогда не поддерживал и которые в следующей версии даташита уже подтерты.
Так, например, ранние версии даташита на данный контроллер сообщают, что дисплей умеет делать аппаратные побитовые операции, включая аппаратную маску для реализации прозрачности, однако, если покопаться глубже, то окажется, что эта строчка попала в даташит на ILI9325 из даташита другого, японского контроллера дисплея, который китайцы благополучно скопировали и обозвали совместимым.
Так как дисплей уже подключен к Mini-STM32, все что нам нужно – узнать, к какому из сигналов выбора чипа он подключен и какая из адресных линий используется в качестве сигнала RS.
Согласно схеме, в качестве сигнала CS используется FSMC_NE1, а в качестве RS – FSMC_A16.
Также дисплей имеет сигнал Reset, выведенный на PE1 и сигнал управления подсветкой, соединенный с PD13.
Заодно посмотрим, какой из сигналов используется для подключения подтяжки USB, о которой поговорим позже – в данной схеме это PC13.
Итак, переходим к коду.
Софт
Работа с LCD
Начнем с разработки небольшой библиотеки для работы с дисплеем. Работать будем в CooCox IDE. Вынесем в заголовочный файл все адреса регистров из даташита:
Объявление регистров LCD
#define CTR_OSC_START 0x0000
#define CTR_DRV_OUTPUT1 0x0001
#define CTR_DRV_WAVE 0x0002
#define CTR_ENTRY_MODE 0x0003
#define CTR_RESIZE 0x0004
#define CTR_DISPLAY1 0x0007
#define CTR_DISPLAY2 0x0008
#define CTR_DISPLAY3 0x0009
#define CTR_DISPLAY4 0x000A
#define CTR_RGB_INTERFACE1 0x000C
#define CTR_FRM_MARKER 0x000D
#define CTR_RGB_INTERFACE2 0x000F
#define CTR_POWER1 0x0010
#define CTR_POWER2 0x0011
#define CTR_POWER3 0x0012
#define CTR_POWER4 0x0013
#define CTR_HORZ_ADDRESS 0x0020
#define CTR_VERT_ADDRESS 0x0021
#define CTR_WRITE_DATA 0x0022
#define CTR_READ_DATA 0x0022
#define CTR_POWER7 0x0029
#define CTR_FRM_COLOR 0x002B
#define CTR_GAMMA1 0x0030
#define CTR_GAMMA2 0x0031
#define CTR_GAMMA3 0x0032
#define CTR_GAMMA4 0x0035
#define CTR_GAMMA5 0x0036
#define CTR_GAMMA6 0x0037
#define CTR_GAMMA7 0x0038
#define CTR_GAMMA8 0x0039
#define CTR_GAMMA9 0x003C
#define CTR_GAMMA10 0x003D
#define CTR_HORZ_START 0x0050
#define CTR_HORZ_END 0x0051
#define CTR_VERT_START 0x0052
#define CTR_VERT_END 0x0053
#define CTR_DRV_OUTPUT2 0x0060
#define CTR_BASE_IMAGE 0x0061
#define CTR_VERT_SCROLL 0x006A
#define CTR_PIMG1_POS 0x0080
#define CTR_PIMG1_START 0x0081
#define CTR_PIMG1_END 0x0082
#define CTR_PIMG2_POS 0x0083
#define CTR_PIMG2_START 0x0084
#define CTR_PIMG2_END 0x0085
#define CTR_PANEL_INTERFACE1 0x0090
#define CTR_PANEL_INTERFACE2 0x0092
#define CTR_PANEL_INTERFACE4 0x0095
#define CTR_OTP_VCMPROGRAM 0x00A1
#define CTR_OTP_VCMSTATUS 0x00A2
#define CTR_OTP_IDKEY 0x00A5
Мы помним, что с точки зрения кода, обращение к FSMC будет простой записью/чтением из памяти, поэтому нам нужно определить по каким именно адресам обращаться. Смотрим в референс мануал на STM32, раздел FSMC, и видим, что для NOR/SRAM выделены адреса, начинающиеся с 0x60000000.
Под банками в широком смысле в мануале подразумеваются большие регионы, выделенные для устройств разного типа, так, банк #1 – это NOR/SRAM, банки #2 и #3 – NAND, банк #4- PC Card.
В свою очередь банк #1 может быть использован для доступа к целым 4 чипам памяти, каждый из которых может независимо от других быть NOR либо SRAM. Так как дисплей подключен как NE1, нас интересует банк, объявленный как FSMC_Bank1_NORSRAM1. Исходя из базового адреса, можно сразу же записать определение
#define LCDRegister (*((volatile uint16_t*) 0x60000000))
Адресом, активизирующим RS будет адрес, в котором активна линия A16, то есть, к примеру 0x60000000 + (2<<16), то есть 0x60020000, поэтому запишем
#define LCDMemory (*((volatile uint16_t*) 0x60020000))
И сразу же определим соответствующие макросы для записи значений в регистры дисплея и в его память:
#define LCD_WRITE_REGISTER(REG, DATA) LCDRegister=REG;LCDMemory=DATA;
#define LCD_BEGIN_RAM_WRITE LCDRegister=CTR_WRITE_DATA;
#define LCD_WRITE_RAM(DATA) LCDMemory=DATA
Заодно определим дефайнами имена пинов и их портов, отвечающих за сброс дисплея и за включение подсветки:
#define BacklightPin GPIO_Pin_13
#define BacklightPort GPIOD
#define ResetPin GPIO_Pin_1
#define ResetPort GPIOE
Теперь напишем код инициализации FSMC и сопутствующей периферии:
Код инициализации FSMC
void LCDInitHardware()
{
SysTick_Config(SystemCoreClock/1000);
GPIO_InitTypeDef GPIO_InitStructure;
FSMC_NORSRAMInitTypeDef FSMC_InitStructure;
FSMC_NORSRAMTimingInitTypeDef FSMC_Timing;
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_FSMC, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD | RCC_APB2Periph_GPIOE | RCC_APB2Periph_AFIO, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_4 | GPIO_Pin_5 |
GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10|
GPIO_Pin_11| GPIO_Pin_14| GPIO_Pin_15; //Interface
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_Init(GPIOD, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7 | GPIO_Pin_8 | GPIO_Pin_9 | GPIO_Pin_10 |
GPIO_Pin_11| GPIO_Pin_12| GPIO_Pin_13| GPIO_Pin_14 |
GPIO_Pin_15; //Interface
GPIO_Init(GPIOE, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = BacklightPin; //Backlight
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(BacklightPort, &GPIO_InitStructure);
GPIO_InitStructure.GPIO_Pin = ResetPin; //Reset
GPIO_Init(ResetPort, &GPIO_InitStructure);
GPIO_SetBits(ResetPort,ResetPin);
FSMC_Timing.FSMC_AddressSetupTime = 1;
FSMC_Timing.FSMC_AddressHoldTime = 0;
FSMC_Timing.FSMC_DataSetupTime = 5;
FSMC_Timing.FSMC_BusTurnAroundDuration = 0;
FSMC_Timing.FSMC_CLKDivision = 0;
FSMC_Timing.FSMC_DataLatency = 0;
FSMC_Timing.FSMC_AccessMode = FSMC_AccessMode_B;
FSMC_InitStructure.FSMC_Bank = FSMC_Bank1_NORSRAM1;
FSMC_InitStructure.FSMC_DataAddressMux = FSMC_DataAddressMux_Disable;
FSMC_InitStructure.FSMC_MemoryType = FSMC_MemoryType_SRAM;
FSMC_InitStructure.FSMC_MemoryDataWidth = FSMC_MemoryDataWidth_16b;
FSMC_InitStructure.FSMC_BurstAccessMode = FSMC_BurstAccessMode_Disable;
FSMC_InitStructure.FSMC_WaitSignalPolarity = FSMC_WaitSignalPolarity_Low;
FSMC_InitStructure.FSMC_WrapMode = FSMC_WrapMode_Disable;
FSMC_InitStructure.FSMC_WaitSignalActive = FSMC_WaitSignalActive_BeforeWaitState;
FSMC_InitStructure.FSMC_WriteOperation = FSMC_WriteOperation_Enable;
FSMC_InitStructure.FSMC_WaitSignal = FSMC_WaitSignal_Disable;
FSMC_InitStructure.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable;
FSMC_InitStructure.FSMC_AsynchronousWait = FSMC_AsynchronousWait_Disable;
FSMC_InitStructure.FSMC_WriteBurst = FSMC_WriteBurst_Disable;
FSMC_InitStructure.FSMC_ReadWriteTimingStruct = &FSMC_Timing;
FSMC_InitStructure.FSMC_WriteTimingStruct = &FSMC_Timing;
FSMC_NORSRAMInit(&FSMC_InitStructure);
FSMC_NORSRAMCmd(FSMC_Bank1_NORSRAM1, ENABLE);
}
Здесь все довольно просто – мы настраиваем системный таймер на интервал в одну миллисекунду (что будет необходимо для организации задержек в процессе инициализации контроллера дисплея), потом настраиваем все сигналы, которыми владеет FSMC как управляемые “alternative functions”, настраиваем пины Reset и Backlight как Output Push-Pull, после чего переходим к настройкам FSMC.
Настройки таймингов можно попробовать посчитать самому, я же взял рекомендуемые из апноута от STM и они подошли отлично. Выставляем тип памяти как SRAM, ширину шины в 16 бит, отключая все дополнительные фичи, описание которых занимает не одну страницу даташита.
Объявим вспомогательную функцию, позволяющую нам сделать достаточно точную задержку:
Функция задержки на SysTick-таймере
volatile uint32_t Tick = 0x00000000;
void SysTick_Handler()
{
if(Tick>0)
Tick--;
}
void SysTickDelay(uint32_t msDelay)
{
Tick=msDelay;
while(Tick);
}
Далее пишем функцию инициализации непосредственно дисплея:
Инициализация LCD
void LCDInit()
{
LCDHardwareReset();
LCD_WRITE_REGISTER(CTR_OSC_START, 0x0001);
LCD_WRITE_REGISTER(CTR_DRV_OUTPUT1, 0x0100);
LCD_WRITE_REGISTER(CTR_DRV_WAVE, 0x0700);
LCD_WRITE_REGISTER(CTR_ENTRY_MODE, 0x1038);
LCD_WRITE_REGISTER(CTR_RESIZE, 0x0000);
LCD_WRITE_REGISTER(CTR_DISPLAY2, 0x0202);
LCD_WRITE_REGISTER(CTR_DISPLAY3, 0x0000);
LCD_WRITE_REGISTER(CTR_DISPLAY4, 0x0000);
LCD_WRITE_REGISTER(CTR_RGB_INTERFACE1, 0x0001);
LCD_WRITE_REGISTER(CTR_FRM_MARKER, 0x0000);
LCD_WRITE_REGISTER(CTR_RGB_INTERFACE2, 0x0000);
LCD_WRITE_REGISTER(CTR_POWER1, 0x0000);
LCD_WRITE_REGISTER(CTR_POWER2, 0x0007);
LCD_WRITE_REGISTER(CTR_POWER3, 0x0000);
LCD_WRITE_REGISTER(CTR_POWER4, 0x0000);
SysTickDelay(200);
LCD_WRITE_REGISTER(CTR_POWER1, 0x1590);
LCD_WRITE_REGISTER(CTR_POWER2, 0x0227);
SysTickDelay(50);
LCD_WRITE_REGISTER(CTR_POWER3, 0x009C);
SysTickDelay(50);
LCD_WRITE_REGISTER(CTR_POWER4, 0x1900);
LCD_WRITE_REGISTER(CTR_POWER7, 0x1900);
LCD_WRITE_REGISTER(CTR_FRM_COLOR, 0x000E);
SysTickDelay(50);
LCD_WRITE_REGISTER(CTR_HORZ_ADDRESS, 0x0000);
LCD_WRITE_REGISTER(CTR_VERT_ADDRESS, 0x0000);
LCD_WRITE_REGISTER(CTR_HORZ_START, 0x0000);
LCD_WRITE_REGISTER(CTR_HORZ_END, 239);
LCD_WRITE_REGISTER(CTR_VERT_START, 0x0000);
LCD_WRITE_REGISTER(CTR_VERT_END, 319);
LCD_WRITE_REGISTER(CTR_DRV_OUTPUT2, 0x2700);
LCD_WRITE_REGISTER(CTR_BASE_IMAGE, 0x0001);
LCD_WRITE_REGISTER(CTR_VERT_SCROLL, 0x0000);
GPIO_SetBits(BacklightPort,BacklightPin);
}
Последовательность берется из даташита на контроллер дисплея, все что нужно сделать – просто в нужном порядке инициализировать определенные регистры, включив его осциллятор и питание, а также выдержать рекомендованные задержки, давая схематике дисплея время устояться. Также в данном коде задаются режимы работы дисплея, такие, например, как поведение счетчика адреса при записи в GRAM – можно заставить дисплей увеличивать либо уменьшать счетчик, сдвигать указатели вниз либо вверх – то есть задать направление, в котором будут закрашиваться пиксели при их последовательном выводе.
В данном коде функция LCDHardwareReset – небольшой блочок кода, выставляющий активный уровень на линии Reset, выжидающий некоторое время и сбрасывающий его в неактивное состояние:
void LCDHardwareReset()
{
GPIO_ResetBits(ResetPort,ResetPin);
SysTickDelay(50);
GPIO_SetBits(ResetPort,ResetPin);
SysTickDelay(10);
}
Введем еще пару управляющих функций, отвечающих за включение и выключение дисплея на уровне его контроллера:
void LCDOn()
{
LCD_WRITE_REGISTER(CTR_DISPLAY1, 0x0133);
}
void LCDOff()
{
LCD_WRITE_REGISTER(CTR_DISPLAY1, 0x0131);
}
Осталось совсем немного — объявляем очень важную для нас функцию, которая задает прямоугольник, в пределах которого будет выводиться графика. При этом работа с адресом вывода лежит на плечах контроллера дисплея, все что нам нужно – задать границы этой области:
void LCDSetBounds(uint16_t left, uint16_t top, uint16_t right, uint16_t bottom)
{
LCD_WRITE_REGISTER(CTR_VERT_START, left);
LCD_WRITE_REGISTER(CTR_VERT_END, right);
LCD_WRITE_REGISTER(CTR_HORZ_START, top);
LCD_WRITE_REGISTER(CTR_HORZ_END, bottom);
LCD_WRITE_REGISTER(CTR_HORZ_ADDRESS, top);
LCD_WRITE_REGISTER(CTR_VERT_ADDRESS, left);
}
Из названия регистров сразу понятно, что функция задает левую, правую, верхнюю и нижнюю границы, а затем выставляет указатель в позицию, соответствующую верхнему левому пикселю.
Так как эти указатели относятся к вертикальному положению дисплея, а мы его будем использовать в горизонтальном, то нам следует поменять left и top местами, занося значение left в регистры, относящиеся к VERTICAL, а top – к HORIZONTAL.
Ну и наконец функция, которой мы сможем сразу проверить правильность работы дисплея – функция очистки. Представляет собой простую последовательную запись одного и того же цветового значения в GRAM дисплея:
void LCDClear(uint16_t color)
{
LCDSetBounds(0,0,320-1,240-1);
LCD_BEGIN_RAM_WRITE;
uint32_t i;
for(i=0;i<320*240;i++)
LCD_WRITE_RAM(color);
}
Все, что нам нужно сделать, чтобы включить наш дисплей – выполнить
LCDInitHardware();
LCDInit();
LCDOn();
LCDClear(0x0000);
Если все сделано правильно, дисплей включится и закрасится черным. При замене аргумента функции LCDClear и перезапуске программы дисплей должен окрасится в выбранный цвет.
Теперь перейдем к более сложной части – USB.
Работа с USB
Работа с USB – очень обширная и многогранная тема, для которой явно не хватит одной статьи.
Поэтому я рекомендую для начала ознакомиться с очень полезным документом, называющимся USB In A Nutshell (английская версия, русская версия) прежде чем продолжать.
Если коротко подвести итог, то одним из основных понятий в интерфейсе USB является Endpoint, который можно с некоторой натяжкой назвать аналогом сокета.
Также как сокет можно открыть для UDP либо для TCP соединения, эндпоинты тоже разделяются по типам.
- Control Endpoints используются для асинхронной передачи небольших управляющих сообщений, до 64 байт длиной в случае Full Speed USB. Нулевой эндпоинт должен быть в любом USB устройстве и он должен быть именно Control. Через него хост запрашивает начальную информацию и производит инициализацию устройства. Доставка пакета через эндпоинты этого типа гарантирована, в случае ошибки хост автоматически пытается перепослать данные. Control Endpoint – единственный двунаправленный тип эндпоинтов, все остальные работают либо только на получение, либо только на передачу данных.
- Interrupt Endpoints – эндпоинты для которых важен опрос хостом с заданной периодичностью (мы ведь помним, что USB-устройства не могут сами инициировать передачу, они ждут запроса от хоста). Обычно этот тип точек применяется в HID-устройствах типа клавиатуры и мыши. Размер пакета также может составлять до 64 байт в FS USB. Доставка гарантирована.
- Isochronous Endpoint – эндпоинт, использующийся в основном для передачи аудио и видео информации, где не страшна потеря пакета – в этом типе эндпоинта только проверяется целостность данных, переотправка в случае ошибки не производится. Размер пакета до 1024 байт в FS USB.
- Bulk Enpoints – основной тип эндпоинтов, использующийся в устройствах хранения информации.
Предполагает посылку пакетов данных с гарантированной доставкой и размером пакета до 64 байтов для FS USB (для High Speed размер пакета может доходить до 1023 байт). В контроллере USB нашей STM32 есть возможность организовать аппаратную двойную буферизацию для пакетов данного типа, увеличивая пропускную способность.Вся доступная полоса пропускания, оставшаяся после Interrupt, Control и Isochronous делится между Bulk-эндпоинтами – поэтому следует обращать внимание на то, какие еще передачи идут параллельно с Bulk.
Наиболее подходящий для нашего случая тип эндпоинта — Bulk, мы будем слать блоки графической информации, которая будет отображена на дисплее, и в последствие можно будет организовать двойную буферизацию. При этом нам не требуется какая-либо периодичность в посылках и «потоковая» сущность Isochronous эндпоинтов, т.к. графические данные будут идти в несжатом формате в произвольные позиции дисплея и терять пакеты нам совершенно ни к чему.
Чтобы хост понял, что у нас за устройство, что у него есть за эндпоинты и какие у него возможности, хост запрашивает несколько дескрипторов через Контрол Эндпоинт #0.
Описываемая ими структура отлично показана на рисунке из упомянутой выше статьи:
Вернемся к ними немного позже, а сейчас перейдем к рассмотрению того, что нужно добавить в нашу прошивку, чтобы заставить устройство работать с USB. Будем работать последовательно и по пунктам.
- Для начала скачиваем библиотеку от STM для работы с USB с официального сайтаи помещаем папку STM32_USB-FS-Device_Driver из недр скачанного архива (лежит в папке Libraries) в папку с нашим проектом. Добавляем его в проект, выбрав File – Add Linked Folder. Добавляем новую папку в наш проект, обозвав ее как-нибудь типа usb_user, в которой создаем файлы hw_config.h и usb_conf.h – эти файлы требует библиотека от STM.
В hw_config.h сразу же пишем
#include "stm32f10x.h"
иначе будет куча эрроров от неразрешенных типов (uint8_t, uint16_t, uint32_t,…)
Не забываем в свойствах проекта указать папку с библиотекой и нашу usb_user как дополнительные пути для поиска инклудов.
- Добавим в usb_user новый заголовочный файл, который будет содержать объявления, необходимые для дескрипторов, назовем его, допустим, usb_desc.h, поместив туда следующий код:
#include "usb_lib.h" #define SIZ_DEVICE_DESC 0x12 #define SIZ_CONFIG_DESC 0x19 #define SIZ_STRING_LANGID 0x04 #define SIZ_STRING_VENDOR 0x10 #define SIZ_STRING_PRODUCT 0x10 #define SIZ_STRING_SERIAL 0x12 extern const uint8_t USB_ConfigDescriptor[SIZ_CONFIG_DESC]; extern ONE_DESCRIPTOR Device_Descriptor; extern ONE_DESCRIPTOR Config_Descriptor; extern ONE_DESCRIPTOR String_Descriptor[4];
Здесь дефайны, начинающиеся на «SIZ_» содержат размеры будущих дескрипторов. В процессе проектирования эти размеры определяются после того, как дескрипторы уже написаны, но т.к. я уже спроектировал девайс, можно просто копировать.
extern const uint8_t USB_ConfigDescriptor мы выносим в хедер только потому, что нам потребуется доступ к этой структуре из основного модуля. К остальным дескрипторам не потребуется, по той причине, что в библиотеку мы будем отдавать не сами дескрипторы в виде массивов uint_8, а специальные структуры, которые зовутся ONE_DESCRIPTOR, которые, по такому случаю, объявлены ниже. В них ничего страшного нет, это просто структуры из двух членов, первый из которых – указатель на тот самый дескриптор в виде uint8_t*, второй – шестнадцатибитная длина этого дескриптора.
Теперь перейдем к, собственно, дескрипторам, добавив новый файл usb_desc.c и подключив туда наш заголовок.
Начинаем с описателя самого девайса. Вся необходимая информация о полях есть в статье USB In A Nutshell, отмечу только, что все дескрипторы начинаются с байтовой длины дескриптора (поэтому мы их и вынесли в дефайны), за которой следует байт – тип дескриптора.
Вот так выглядит дескриптор устройства:
Дескриптор устройстваconst uint8_t USB_DeviceDescriptor[SIZ_DEVICE_DESC] = { 0x12, /* bLength */ 0x01, /* bDescriptorType */ 0x00, 0x02, /* bcdUSB = 2.00 */ 0xFF, /* bDeviceClass: Vendor Specific */ 0x00, /* bDeviceSubClass */ 0x00, /* bDeviceProtocol */ 0x40, /* bMaxPacketSize0 */ 0xAD, 0xDE, /* idVendor*/ 0x0D, 0xF0, /* idProduct*/ 0x00, 0x01, /* bcdDevice = 2.00 */ 1, /* Index of string descriptor describing manufacturer */ 2, /* Index of string descriptor describing product */ 3, /* Index of string descriptor describing the device's serial number */ 0x01 /* bNumConfigurations */ };
Мы создаем «vendor-specific» устройство (не пренадлежащие ни к какому конкретному предопределенному классу вроде HID), с VID= 0xDEAD и PID = 0xF00D, единственной конфигурацией и максимальным размером пакета 64 байта.
Дальше объявляем дескриптор конфигурации, который включает в себя дескрипторы интерфейса и эндпоинтов:
Дескриптор конфигурацииconst uint8_t USB_ConfigDescriptor[SIZ_CONFIG_DESC] = { /*Configuration Descriptor*/ 0x09, /* bLength: Configuration Descriptor size */ 0x02, /* bDescriptorType: Configuration */ SIZ_CONFIG_DESC, /* wTotalLength:no of returned bytes */ 0x00, 0x01, /* bNumInterfaces: 1 interface */ 0x01, /* bConfigurationValue: Configuration value */ 0x00, /* iConfiguration: Index of string descriptor describing the configuration */ 0xE0, /* bmAttributes: bus powered */ 0x32, /* MaxPower 100 mA */ /*Interface Descriptor*/ 0x09, /* bLength: Interface Descriptor size */ 0x04, /* bDescriptorType: Interface */ 0x00, /* bInterfaceNumber: Number of Interface */ 0x00, /* bAlternateSetting: Alternate setting */ 0x01, /* bNumEndpoints: One endpoints used */ 0xFF, /* bInterfaceClass: Vendor Specific*/ 0x00, /* bInterfaceSubClass*/ 0x00, /* bInterfaceProtocol*/ 0x00, /* iInterface: */ /*Endpoint 1 Descriptor*/ 0x07, /* bLength: Endpoint Descriptor size */ 0x05, /* bDescriptorType: Endpoint */ 0x01, /* bEndpointAddress: (OUT1) */ 0x02, /* bmAttributes: Bulk */ 0x40, /* wMaxPacketSize: */ 0x00, 0x00 /* bInterval: */ };
Тут надо быть внимательным – первым байтом идет размер только, собственно, конфиг-дескриптора, который всегда равен 0х09 байтам. Дальше идет тип этого дескриптора, а вот следом идет двухбайтовая длина всего этого массива, включая конфиг дескриптор, дескрипторы интерфейсов и эндпоинтов. В данном случае она вписалась в один байт, так что второй оставляем нулем.
Дальше пишем что у нас один интерфейс, одна конфигурация (расположенная по индексу 0), что устройство питается от шины и потребляет не более 100 мА.
Дальше в том же массиве идет дескриптор интерфейса, размером в те же 0х09 байт, два индекса, оба нули, по которым хост обращается к конкретно этому интерфейсу, количество эндпоинтов не считая нулевого – у нас будет один, класс устройства, опять «Vendor Specific», никаких сабклассов, никаких протоколов, никаких строковых описателей интерфейса (нули во всех соответствующих байтах).
Наконец, последним идет дескриптор нашего единственного эндпоинта. Эндпоинт 0 в таковом не нуждается, поэтому описываем сразу Bulk Endpoin 1. Байт адреса устанавливает не только номер эндпоинта но и направление передачи своим старшим битом. Задаем тип – Bulk, предельный размер пакета (внимание, под него выделены два байта!) и оставляем последний байт равным нулю, т.к. он не играет роли для Bulk-эндпоинтов.
Дальше объявляем строковые дескрипторы:
Строковые дескрипторы/* USB String Descriptors */ const uint8_t USB_StringLangID[SIZ_STRING_LANGID] = { SIZ_STRING_LANGID, /* bLength */ 0x03, /* String descriptor */ 0x09, 0x04 /* LangID = 0x0409: U.S. English */ }; const uint8_t USB_StringVendor[SIZ_STRING_VENDOR] = { SIZ_STRING_VENDOR, /* Size of Vendor string */ 0x03, /* bDescriptorType*/ /* Manufacturer: "Amon-Ra" */ 'A', 0, 'm', 0, 'o', 0, 'n', 0, '-', 0, 'R', 0, 'a', 0 }; const uint8_t USB_StringProduct[SIZ_STRING_PRODUCT] = { SIZ_STRING_PRODUCT, /* bLength */ 0x03, /* bDescriptorType */ /* Product name: "USB LCD" */ 'U', 0, 'S', 0, 'B', 0, ' ', 0, 'L', 0, 'C', 0, 'D', 0 }; uint8_t USB_StringSerial[SIZ_STRING_SERIAL] = { SIZ_STRING_SERIAL, /* bLength */ 0x03, /* bDescriptorType */ 'U', 0, 'S', 0, 'B', 0, 'L', 0, 'C', 0, 'D', 0, '0', 0, '1', 0 };
Строки записаны юникодом и их можно поменять по желанию.
Наконец заполняем структуры, которые требуются библиотеке:
Структуры для библиотекиONE_DESCRIPTOR Device_Descriptor = { (uint8_t*)USB_DeviceDescriptor, SIZ_DEVICE_DESC }; ONE_DESCRIPTOR Config_Descriptor = { (uint8_t*)USB_ConfigDescriptor, SIZ_CONFIG_DESC }; ONE_DESCRIPTOR String_Descriptor[4] = { {(uint8_t*)USB_StringLangID, SIZ_STRING_LANGID}, {(uint8_t*)USB_StringVendor, SIZ_STRING_VENDOR}, {(uint8_t*)USB_StringProduct, SIZ_STRING_PRODUCT}, {(uint8_t*)USB_StringSerial, SIZ_STRING_SERIAL} };
- Внесем некоторый описательный код в usb_conf.h:
Код usb_conf.h#define EP_NUM 0x02 #define BTABLE_ADDRESS (0x00) /* EP0 */ /* rx/tx buffer base address */ #define ENDP0_RXADDR (0x40) #define ENDP0_TXADDR (0x80) /* EP1 */ /* tx buffer base address */ #define ENDP1_RXADDR (0xC0) /* IMR_MSK */ /* mask defining which events has to be handled */ /* by the device application software */ #define IMR_MSK (CNTR_CTRM | CNTR_RESETM)
Почти все из вышеперечисленного не требуется библиотекой и нужно только для улучшения читаемости кода в нашем главном модуле. Исключение — IMR_MSK, маска, показывающая, какие прерывания USB используются. Выставим ее в необходимый минимум – прерывание Correct Transfer и Reset.
Адреса эндпоинтов задаются в адресном пространстве так называемой PMA, Packet Memory Area, с учетом длины пакетов. Так как для обоих эндпоинтов максимальный размер пакета задан в 64 байта, размещаем их с соответствующим шагом, не забывая о таблице этих самых адресов эндпоинтов, которая хранится там же и тоже занимает место.
- Теперь нам нужно определить колбэки, которые требует библиотека. Эти колбэки объединяются в структуры DEVICE_PROP и USER_STANDARD_REQUESTS, при этом библиотека будет искать их экземпляры под именами Device_Property и User_Standard_Requests.
Начнем с колбэка, вызываемого в самом-самом начале, при инициализации USB-контроллера, чья функция сводится к сбросу USB-клока, вызова инициализирующих функций библиотеки и активизации подтяжки на линии USB, что заставляет хост увидеть нас на шине.
void Device_init()void Device_init() { DEVICE_INFO *pInfo = &Device_Info; pInfo->Current_Configuration = 0; _SetCNTR(CNTR_FRES); //Reset USB block _SetCNTR(0); //Deassert reset signal _SetISTR(0); //Clear pending interrupts USB_SIL_Init(); GPIO_ResetBits(GPIOC, GPIO_Pin_13); //Enable pull-up }
Следующий колбэк вызывется, когда хост затребует сброс нашего устройства:
void Device_Reset()void Device_Reset() { //Set device as not configured pInformation->Current_Configuration = 0; pInformation->Current_Interface = 0; //the default Interface /* Current Feature initialization */ pInformation->Current_Feature = USB_ConfigDescriptor[7]; SetBTABLE(BTABLE_ADDRESS); /* Initialize Endpoint 0 */ SetEPType(ENDP0, EP_CONTROL); SetEPTxStatus(ENDP0, EP_TX_STALL); SetEPRxAddr(ENDP0, ENDP0_RXADDR); SetEPTxAddr(ENDP0, ENDP0_TXADDR); Clear_Status_Out(ENDP0); SetEPRxCount(ENDP0, Device_Property.MaxPacketSize); SetEPRxValid(ENDP0); SetEPType(ENDP1, EP_BULK); SetEPRxAddr(ENDP1, ENDP1_RXADDR); SetEPRxCount(ENDP1, 0x40); SetEPRxStatus(ENDP1, EP_RX_VALID); SetEPTxStatus(ENDP1, EP_TX_DIS); /* Set this device to response on default address */ SetDeviceAddress(0); }
Тут мы задаем все определенные заранее адреса и прочие параметры эндпоинтов, после чего выставляем устройству адрес 0, чтобы хост мог обратиться к нему и назначить новый.
Дальше идет пара колбэков, которые мы вообще не будем обрабатывать
#define Device_Status_In NOP_Process #define Device_Status_Out NOP_Process
Честно говоря, я не нашел даже места, где они зовутся, видимо, их предполагается звать из нашего кода, если нужно.
Следующая пара колбэков предназначена для случая, когда мы хотим передать какую-нибудь информацию через Control Endpoint 0, не являющуюся частью стандартного протокола:
RESULT Device_Data_Setup(uint8_t RequestNo) { return USB_UNSUPPORT; } RESULT Device_NoData_Setup(uint8_t RequestNo) { return USB_UNSUPPORT; }
Так как мы этого не делаем, то, в случае такого обращения, скажем хосту, что мы это не поддерживаем. В будущем сюда можно будет запихать какие-нибудь команды управления подсветкой, электропитанием или разрешением дисплея.
Дальше идет колбэк, который дернется, когда хост захочет переключить интерфейсы. Так как у нас интерфейс только один, говорим USB_UNSUPPORT при обращении по любым индексам кроме нуля.
RESULT Device_Get_Interface_Setting(uint8_t Interface, uint8_t AlternateSetting) { if (AlternateSetting > 0) { return USB_UNSUPPORT; } else if (Interface > 0) { return USB_UNSUPPORT; } return USB_SUCCESS; }
Последние три колбэка предназначены для возвращения различных строковых дескрипторов и написаны в рекомендуемой инженерами STM манере, вызовом их библиотечной функции:
Функции для возврата строковых дескрипторовuint8_t *Device_GetDeviceDescriptor(uint16_t Length) { return Standard_GetDescriptorData(Length, &Device_Descriptor); } uint8_t *Device_GetConfigDescriptor(uint16_t Length) { return Standard_GetDescriptorData(Length, &Config_Descriptor); } uint8_t *Device_GetStringDescriptor(uint16_t Length) { uint8_t wValue0 = pInformation->USBwValue0; if (wValue0 > 4) { return NULL; } else { return Standard_GetDescriptorData(Length, &String_Descriptor[wValue0]); } }
Теперь объединяем все, что получилось в структуру:
DEVICE_PROP Device_Property = { Device_init, Device_Reset, Device_Status_In, Device_Status_Out, Device_Data_Setup, Device_NoData_Setup, Device_Get_Interface_Setting, Device_GetDeviceDescriptor, Device_GetConfigDescriptor, Device_GetStringDescriptor, 0, 0x40 /*MAX PACKET SIZE*/ };
- Теперь опишем колбэки следующей структуры, User_Standard_Requests.
Это будет нетрудно — т.к. мы не являемся HID или еще каким-нибудь устройством, для которого есть строгий стандарт на подобные реквесты, мы можем безбоязненно определить их все как NOP_Process. При этом не стоит пугаться, что не определив User_SetDeviceAddress мы останемся без адреса – этот колбэк носит информационный характер и вызывается библиотекой уже после того, как адрес был установлен.
Структура User_Standard_Requests#define Device_GetConfiguration NOP_Process #define Device_SetConfiguration NOP_Process #define Device_GetInterface NOP_Process #define Device_SetInterface NOP_Process #define Device_GetStatus NOP_Process #define Device_ClearFeature NOP_Process #define Device_SetEndPointFeature NOP_Process #define Device_SetDeviceFeature NOP_Process #define Device_SetDeviceAddress NOP_Process USER_STANDARD_REQUESTS User_Standard_Requests = { Device_GetConfiguration, Device_SetConfiguration, Device_GetInterface, Device_SetInterface, Device_GetStatus, Device_ClearFeature, Device_SetEndPointFeature, Device_SetDeviceFeature, Device_SetDeviceAddress };
- Теперь опишем несколько глобальных переменных, требуемых библиотекой:
__IO uint16_t wIstr; DEVICE Device_Table = { EP_NUM, 1 };
Это глобальная переменная с текущими флагами прерывания, и глобальная структура с количеством эндпоинтов (включая нулевой) и количеством доступных конфигураций.
- Далее объявим пару вспомогательных функций – они не требуются библиотекой, мы просто вызовем их из главной:
void USB_Interrupts_Config(void) и void Set_USBClock()void USB_Interrupts_Config(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1); NVIC_InitStructure.NVIC_IRQChannel = USB_LP_CAN1_RX0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = USB_HP_CAN1_TX_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); } void Set_USBClock() { /* Select USBCLK source */ RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_1Div5); /* Enable the USB clock */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_USB, ENABLE); }
Здесь мы конфигурируем два прерывания.
Low-Priority вызывается всеми усб событиями (успешными передачами, резетом и прочими), поэтому в нем необходимо проверять источник интеррапта.
Высокоприоритетное вызывается только при успешной передаче Isochronous либо Bulk эндпоинта с двойной буферизацией, предназначено для сокращения времени обработки (не приходится проверять источник). Так как у нас пока нет двойной буферизации, его можно не активировать.
Вторая функция устанавливает частоту тактового сигнала USB в 1.5 от системной (48 МГц) и включает тактирование блока USB.
- Далее пишем код обработки высокоприоритетного и низкоприоритетного прерывания, который сводится к вызову библиотечного обработчика для высокоприоритетного и проверки источника, после чего вызова библиотечных обработчиков либо наших колбэков для низкоприоритетного.
Высокоприоритетное и низкоприоритетные прерыванияvoid USB_HP_CAN1_TX_IRQHandler(void) { CTR_HP(); } void USB_LP_CAN1_RX0_IRQHandler(void) { wIstr = _GetISTR(); #if (IMR_MSK & ISTR_CTR) if (wIstr & ISTR_CTR & wInterrupt_Mask) { CTR_LP(); } #endif #if (IMR_MSK & ISTR_RESET) if (wIstr & ISTR_RESET & wInterrupt_Mask) { _SetISTR((uint16_t)CLR_RESET); Device_Property.Reset(); } #endif }
Все, что нам осталось сделать в меине – настроить пин подтяжки USB на выход, вызвать инициализирующие функции для USB и инициализирующие функции для LCD, после чего зависнуть в бесконечном цикле. С этого момента основная работа будет происходить в колбэке, вызываемом при передаче по Bulk Endpoint 1 данных, который мы сейчас и напишем.
- Принцип работы простой – у нас будет два состояния, NOT_ADDRESSED и TRANSFER_IN_PROGRESS. Начинаем мы в первом, и в нем мы воспринимаем первые 8 байт пакета как заголовок, в котором записаны координаты X и Y (два 16-битных числа) а также количество пикселей, которое надо вывести, начиная с этих координат. Получив этот заголовок мы переходим во второе состояние и все пришедшие потом данные (включая конец первого пакета) выводим сразу на наш дисплей, пока не выведем столько пикселей, сколько указано в заголовке. Это, правда, чревато тем, что если вдруг что-то помешает передаче хотя бы одного пакета, то следующий пакет с заголовком будет воспринят как данные и на экран полетит мусор. Однако доставка гарантирована, так что подобная ситуация возможна только в случае проблем на шине. Чтобы ее избежать можно дополнительно ввести несколько байт сигнатуры в заголовок, но в моем коде этого в данный момент нет.
Единственное, что следует отметить при реализации – имеет смысл сделать свою функцию, аналогичную библиотечной PMAToUserBufferCopy, которая копирует байты из памяти буфера эндпоинта в оперативную память контроллера. Так как дисплей у нас теперь тоже видится как часть оперативной памяти, к чему нам дважды гонять туда-сюда байты?
Я взял код PMAToUserBufferCopy за основу, назвал функцию PMAToLCDBufferCopy, и просто поменял в ее коде инкрементирующийся указатель на целевой буфер на постоянный указатель на память дисплея:
void PMAToLCDBufferCopy(uint16_t wPMABufAddr, uint16_t offset ,uint16_t wNBytes) { uint32_t n = (wNBytes + 1) >> 1; uint32_t i; uint32_t *pdwVal; pdwVal = (uint32_t *)(wPMABufAddr * 2 + PMAAddr+offset); for (i = n; i != 0; i--) LCD_WRITE_RAM(*pdwVal++); }
Сам колбэк выглядит так:
void EP1_OUT_Callback(void) { uint16_t dataLen = GetEPRxCount(EP1_OUT & 0x7F); uint16_t offset=0; if(GraphicsState==NOT_ADDRESSED) { if(dataLen<=8) { SetEPRxStatus(ENDP1, EP_RX_VALID); return; } PMAToUserBufferCopy(buffer, GetEPRxAddr(EP1_OUT & 0x7F), 8); uint16_t horz = *((uint16_t*)(buffer)); uint16_t vert = *(uint16_t*)(buffer+2); dataTotal = *(uint32_t*)(buffer+4); LCD_WRITE_REGISTER(CTR_HORZ_ADDRESS,vert); //экран повернут LCD_WRITE_REGISTER(CTR_VERT_ADDRESS,horz); offset=16; dataTransfered=0x00; GraphicsState=TRANSFER_IN_PROGRESS; dataLen-=8; } LCD_BEGIN_RAM_WRITE; PMAToLCDBufferCopy(GetEPRxAddr(EP1_OUT & 0x7F), offset, dataLen); dataTransfered+=(dataLen)>>1; if(dataTransfered>=dataTotal) GraphicsState=NOT_ADDRESSED; SetEPRxStatus(ENDP1, EP_RX_VALID); }
Все, осталось записать колбэк в глобальную структуру, которую требует библиотека, установив колбэки от других эндпоинтов (которых у нас нет) в NOP_Process:
Структуры с колбэками для эндпоинтовvoid (*pEpInt_IN[7])(void) = { NOP_Process, NOP_Process, NOP_Process, NOP_Process, NOP_Process, NOP_Process, NOP_Process, }; void (*pEpInt_OUT[7])(void) = { EP1_OUT_Callback, NOP_Process, NOP_Process, NOP_Process, NOP_Process, NOP_Process, NOP_Process, };
Проверка
Вот и пришла самая приятная пора – пожинание результатов первого этапа.
Проверку будем осуществлять из-под windows (хотя и из-под линукса проблем не будет), пока используюя юзерспейсную библиотеку LibUSB.
Для этого я воспользовался ее биндингом для C#, LibUSB.Net, скачать который можно отсюда
Подключаем наш девайс к компьютеру – если все хорошо, система должна сообщить, что устройство работает, но не найдены драйверы, и отобразить его под заданным нами именем в диспетчере задач.
В принципе, можно даже не писать код. Просто качаем эту библиотеку, в папке с ней запускаем InfWizard. Выбираем в списке наше устройство и генерируем для него inf-файл, после чего устанавливаем через него драйвер от libusb.
Запускаем идущую в комплекте Test_bulk, выбираем наше устройство, жмем Open, и вводим в строку больше 8 символов. После того, как мы нажмем «Write», они должны прийти в наш колбэк и быть интерпретированы как заголовок и графические данные, после чего отобразиться на дисплее в виде нескольких цветных точек.
Разумеется, это не очень впечатляет, поэтому открываем исходники этого самого Test_Bulk, идем в обработчик кнопки Write и, вместо отправления данных из строки, делаем загрузку из бинарного файла.
var bytesToWrite = File.ReadAllBytes("D:\\myfile.raw");
Тут нам больше ничего не нужно, пойдем готовить файл. Выбираем подходящую картинку размером 320х240, я выбрал вот эту:
Поворачиваем ее набок, потом вспоминаем, что пиксели в BMP хранятся снизу вверх, поэтому отражаем картинку по вертикали, чтобы не заниматься этим в коде. Сохраняем в формате 16-бит RGB565.
Отрезаем первые 0х40 байт (заголовок) от файла в каком-нибудь хекс-редакторе, остальное – сырой битмэп, который можно скармливать нашей видеокарте. Дополняем его заголовком для USB-девайса – адресом вывода (0000, 0000) и длиной данных в пикселях (320х240) — 002C0100
Все, сохраняем его под тем именем, которое указали в Test_bulk, запускаем эту прогу, жмем Write и получаем
На этом у меня все. Статья получилась длинная, но разбивать ее на более мелкие не хотелось, так как тогда потерялась бы целостность этапа. Надеюсь, вы осилили ее до конца и готовы перейти к этапу, который будет изложен в следующей статье – мы наконец возьмемся за роутер и напишем небольшой драйвер, позволяющий работать с нашей видеокартой из-под OpenWRT.
До следующей статьи!