STM32 USB Mass Storage Bootloader

в 10:46, , рубрики: bootloader, stm32, stm32f103, usb msd, программирование микроконтроллеров

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

Загрузчик — это удобно и полезно, не правда ли? А если загрузчик собственной реализации, то это еще более удобно, полезно и гибко и не стабильно. Ну и конечно же, очень круто!

Так же, это прекрасная возможность углубиться и изучить особенности используемой вычислительной машины — в нашем случае микроконтроллера STM32 с ядром ARM Cortex-M3.

На самом деле, загрузчик — это проще, чем кажется на первый взгляд. В доказательство, под cut'ом соберём свой собственный USB Mass Storage Bootloader!

imageimage

Работать мы будем с самодельной платой на микроконтроллере (дальше — МК) STM32F103RET. Что бы не переполнять публикацию лишними картинками, приведу усеченную схему этой железки:

image

При написании bootloader'а я руководствовался следующими принципами:

  1. Свой bootloader очень нужен и хватит откладывать это в TODO-лист, пора уже сесть и сделать;
  2. Bootloader должен иметь удобный для пользователя интерфейс загрузки программы. Никаких драйверов, сторонних программ, плат-переходников и жгутов МГТФ провода до целевого устройства. Что может быть проще автоматически определяемого USB флеш накопителя?
  3. Для работы в режиме bootloader'а микроконтроллеру необходима минимальная аппаратная обвязка (фактически, только USB, кварц и кнопка);
  4. Размер boot'а — не главное. Важен, конечно же, но не будем преследовать цель ужать его в пару килобайт. Без мук совести мы поднимем USB стек, поработаем с файловой системой, навтыкаем printf() через строку и вообще не будем особо ни в чем себе отказывать (hello, Standard Peripheral Libraries!);

Погнали

Немного о FLASH

Так как с собственной FLASH-памятью STM32 мы будем работать постоянно и часто, стоит сразу пояснить некоторые ключевые моменты, связанные с этим фактом.

В используемом МК содержится 512 Kbyte FLASH памяти. Она разбита на страницы по 2048 байт:

image

Для нас это означает, что записать несколько байт в произвольный адрес просто так не выйдет. При записи во FLASH возможно только обнулять нужные ячейки, а вот установка единиц выполняется с помощью операции стирания, минимально возможный объем для которой — одна страница. Для этого служит регистр FLASH_AR, в который достаточно записать любой адрес в пределах нужной нам страницы — и она будет заполнена байтами 0xFF. А еще нужно не забыть разблокировать FLASH перед операциями стирания/записи.

Виртуально разобьем FLASH на несколько областей, у каждой из которых будет свое, особое назначение:

image

  • BOOT_MEM — область памяти, выделенная под bootloader;
  • USER_MEM — тут мы будем хранить (и исполнять отсюда же) пользовательскую прошивку. Очевидно, что теперь она имеет ограничение в 200 Kbyte;
  • MSD_MEM — а тут будет MASS STORAGE диск, куда можно закинуть прошивку средствами компьютера и вашей любимой ОС;
  • OTHER_MEM — ну и оставим еще немного места на всякий случай;

USER_MEM будет соответствовать MSD_MEM по размеру. Это логично, т.к. два противоположных случая будут давать либо нехватку памяти в USER_MEM, либо избыток.

А теперь все то же самое, только для машины (и удобства программиста):

#define FLASH_PAGE_SIZE		2048 						//2 Kbyte per page
#define FLASH_START_ADDR	0x08000000					//Origin
#define FLASH_MAX_SIZE		0x00080000					//Max FLASH size - 512 Kbyte
#define FLASH_END_ADDR		(FLASH_START_ADDR + FLASH_MAX_SIZE)		//FLASH end address
#define FLASH_BOOT_START_ADDR	(FLASH_START_ADDR)				//Bootloader start address
#define FLASH_BOOT_SIZE		0x00010000					//64 Kbyte for bootloader
#define FLASH_USER_START_ADDR	(FLASH_BOOT_START_ADDR + FLASH_BOOT_SIZE)	//User application start address
#define FLASH_USER_SIZE		0x00032000					//200 Kbyte for user application
#define FLASH_MSD_START_ADDR	(FLASH_USER_START_ADDR + FLASH_USER_SIZE)	//USB MSD start address
#define FLASH_MSD_SIZE		0x00032000					//200 Kbyte for USB MASS Storage
#define FLASH_OTHER_START_ADDR	(FLASH_MSD_START_ADDR + FLASH_MSD_SIZE)		//Other free memory start address
#define FLASH_OTHER_SIZE	(FLASH_END_ADDR - FLASH_OTHER_START_ADDR)	//Free memory size

Договорившись о разбиении памяти на области, самое время прикинуть, как это все будет взаимодействовать. Нарисуем блок-схему:

image

Согласно такому алгоритму, bootloader имеет два основных режима, работающих независимо друг от друга, но имеющих общий ресурс — кусок памяти MSD_MEM. Однако, даже его использование происходит в разные моменты времени, что положительно влияет на стабильность работы bootloader'а и упрощает процесс программирования и отладки.

  1. Первый режим отвечает за получение и хранение пользовательского ПО в области MSD_MEM, которая доступна, как внешний накопитель.
  2. Второй режим проверяет MSD_MEM на наличие файла с именем «APP.BIN», проверяет его целостность, подлинность, а так же перемещает в USER_MEM, если там пусто или если прошивка «APP.BIN» более свежая.

Рассмотрим каждый из режимов подробнее:

USB Mass Storage Device

Запускается сразу же после входа в main() в случае, если выполнено соответствующее условие запуска — зажата кнопка. На моей плате это верхний ползунок двухпозиционного переключателя (который, кстати, заведен на ножки МК BOOT0 и BOOT1(PB2) — это позволяет задействовать аппаратный UART загрузчик МК в случае такой необходимости).

int main(void)
int main(void)
{
	Button_Config();
	if(GPIO_ReadInputDataBit(BUTTON_PORT, BUTTON_PIN) == SET) //Bootloader or Mass Storage?
	{
		LED_RGB_Config();
		USB_Config();
		Interrupts_Config();
		USB_Init();
		while(TRUE);
	}
//Bootloader mode
}

Работа в режиме Mass Storage взята из примеров от STMicroelectronics (STM32_USB-FS-Device_Lib_V4.0.0), которые можно скачать с их сайта. Там нам показывают, как нужно (или наоборот, не нужно — отношение к библиотекам от ST у народа не всегда положительное) работать с микроконтроллером и картой памяти, подключенной по интерфейсу SDIO в режиме USB MSD. В примере реализованы два Bulk In/Out Endpoint'а с длиной пакета 64 байта, а так же набор необходимых для работы SCSI команд. Выкидываем оттуда функции, связанные с SD картами или NAND памятью (mass_mal.c/.h) и заменяем их на работу с внутренней FLASH:

u16 MAL_Init(u8 lun)
u16 MAL_Init(u8 lun) 
{
	switch (lun)
	{
		case 0:
			FLASH_Unlock();
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

u16 MAL_Read(u8 lun, u32 memOffset, u32 *readBuff)
u16 MAL_Read(u8 lun, u32 memOffset, u32 *readBuff)
{
	u32 i;

	switch (lun)
	{
		case 0:
			LED_RGB_EnableOne(LED_GREEN);

			for(i = 0; i < MassBlockSize[0]; i += SIZE_OF_U32)
			{
				readBuff[i / SIZE_OF_U32] = *((volatile u32*)(FLASH_MSD_START_ADDR + memOffset + i));
			}

			LED_RGB_DisableOne(LED_GREEN);
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

u16 MAL_Write(u8 lun, u32 memOffset, u32 *writeBuff)
u16 MAL_Write(u8 lun, u32 memOffset, u32 *writeBuff)
{
	u32 i;

	switch (lun)
	{
		case 0:
			LED_RGB_EnableOne(LED_RED);

			while(FLASH_GetStatus() != FLASH_COMPLETE);
			FLASH_ErasePage(FLASH_MSD_START_ADDR + memOffset);

			for(i = 0; i < MassBlockSize[0]; i += SIZE_OF_U32)
			{
				while(FLASH_GetStatus() != FLASH_COMPLETE);
				FLASH_ProgramWord(FLASH_MSD_START_ADDR + memOffset + i, writeBuff[i / SIZE_OF_U32]);
			}

			LED_RGB_DisableOne(LED_RED);
			break;

		default:
			return MAL_FAIL;
	}

	return MAL_OK;
}

Если все сделано правильно, при подключении компьютер определит наше изделие, как USB Mass Storage Device и предложит его отформатировать, т.к. в области MSD_MEM лежит мусор. Стоит отметить, что в данном режиме работы МК является просто посредником между хостом и FLASH памятью, а операционная система самостоятельно решает, какие данные и по каким адресам будут лежать на нашем накопителе.

Отформатируем диск и посмотрим, как это отразилось на области MSD_MEM:

image

Объем совпадает, размер сектора Windows определила верный, нулевой сектор — загрузочный, расположение в памяти соответствует задуманному. Файлы пишутся, читаются, не исчезают после отключения питания — полноценная флешка на 200 Kbyte!

Bootloader

Запускается, если обновление прошивки не требуется. То есть, нормальный режим работы устройства. В нём нам предстоит совершить несколько базовых действий, необходимых для успешного запуска пользовательского ПО. Базовых — потому что при необходимости можно дополнять работу bootloader'а всякими фичами, такими как шифрование, проверка целостности, выводом отладочных сообщений и т.д.

Пусть мы уже создали средствами Windows файловую систему на USB накопителе и загрузили необходимое ПО. Теперь неплохо бы увидеть содержимое носителя «глазами» МК, а значит идем в гости к товарищу ChaN'у за FatFS (модуль простой файловой системы FAT, предназначенный для маленьких встраиваемых систем на микроконтроллерах). Скачиваем, закидываем в проект, прописываем функцию чтения с диска нужных данных:

DRESULT disk_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count)
DRESULT disk_read (
	BYTE pdrv,	/* Physical drive nmuber to identify the drive */
	BYTE *buff,	/* Data buffer to store read data */
	DWORD sector,	/* Sector address in LBA */
	UINT count	/* Number of sectors to read */
)
{
	u32 i;

	for(i = 0; i < count * SECTOR_SIZE; i++)
	{
		buff[i] = *((volatile u8*)(FLASH_MSD_START_ADDR + sector * SECTOR_SIZE + i));
	}

	return RES_OK;
}

disk_write() не понадобится и оставлена заглушкой, ибо смонтированная файловая система — Read Only. Это так же можно задать в конфигурационном файле ffconf.h, дополнительно отключив все ненужные и неиспользуемые функции.

Дальше все более-менее очевидно: монтируем файловую систему, открываем файл прошивки, начинаем читать. Изначально было реализовано так, что основное место хранения прошивки — MSD_MEM, а микроконтроллер каждый раз при включении перезаписывает свою FLASH память. Нет прошивки — отладочное сообщение об отсутствии и while(TRUE). Есть прошивка — закидываем её в USER_MEM. Однако очевидный минус такого решения — ресурс стирания/записи FLASH памяти имеет лимит и было бы глупо постепенно и осознанно убивать изделие.

Поэтому сравним «APP.BIN» и USER_MEM, тупо, байт за байтом. Возможно, сравнение хеш-сумм двух массивов выглядело бы более изящным решением, но уж точно не самым быстрым. Заглянем снова в main():

int main(void)
int main(void)
{
	Button_Config();
	if(GPIO_ReadInputDataBit(BUTTON_PORT, BUTTON_PIN) == SET) //Bootloader or Mass Storage?
	{
		//USB MSD mode
	}
	
	FATFS_Status = f_mount(&FATFS_Obj, "0", 1);
	if(FATFS_Status == FR_OK)
	{
		FILE_Status = f_open(&appFile, "/APP.BIN", FA_READ);
		if(FILE_Status == FR_OK)
		{
			appSize = f_size(&appFile);

			for(i = 0; i < appSize; i++) //Byte-to-byte compare files in MSD_MEM and USER_MEM
			{
				f_read(&appFile, &appBuffer, 1, &readBytes);

				if(*((volatile u8*)(FLASH_USER_START_ADDR + i)) != appBuffer[0]) 
				{
					//if byte of USER_MEM != byte of MSD_MEM
					break;
				}
			}

			if(i != appSize)//=> was done "break" instruction in for(;;) cycle => new firmware in MSD_FLASH
			{
				CopyAppToUserMemory();
			}

			FILE_Status = f_close(&appFile);
			FATFS_Status = f_mount(NULL, "0", 1);

			PeriphDeInit();
			GoToUserApp();
		}
		else //if FILE_Status != FR_OK
		{
			if(FILE_Status == FR_NO_FILE)
			{
				//No file error
			}
			else //if FILE_Status != FR_NO_FILE
			{
				//Other error
			}
			FATFS_Status = f_mount(NULL, "0", 1);
			while(TRUE);
		}
	}
	else //FATFS_Status != FR_OK
	{
		//FatFS mount error
		while(TRUE);
	}
}

Если в процессе сравнения мы не дошли до конца цикла, значит прошивки различны и самое время обновить USER_MEM с помощью CopyAppToUserMemory(). Ну а потом неплохо бы уничтожить следы работы bootloader'а вызовом PeriphDeInit() и затем GoToUserApp(). Но это чуть позже, а пока — процесс копирования:

void CopyAppToUserMemory(void)
void CopyAppToUserMemory(void)
{
	f_lseek(&appFile, 0); //Go to the fist position of file

	appTailSize = appSize % APP_BLOCK_TRANSFER_SIZE;
	appBodySize = appSize - appTailSize;
	appAddrPointer = 0;

	for(i = 0; i < ((appSize / FLASH_PAGE_SIZE) + 1); i++) //Erase n + 1 pages for new application
	{
		while(FLASH_GetStatus() != FLASH_COMPLETE);
		FLASH_ErasePage(FLASH_USER_START_ADDR + i * FLASH_PAGE_SIZE);
	}

	for(i = 0; i < appBodySize; i += APP_BLOCK_TRANSFER_SIZE)
	{
		/*
		 * For example, size of File1 = 1030 bytes
		 * File1 = 2 * 512 bytes + 6 bytes
		 * "body" = 2 * 512, "tail" = 6
		 * Let's write "body" and "tail" to MCU FLASH byte after byte with 512-byte blocks
		 */
		f_read(&appFile, appBuffer, APP_BLOCK_TRANSFER_SIZE, &readBytes); //Read 512 byte from file
		for(j = 0; j < APP_BLOCK_TRANSFER_SIZE; j += SIZE_OF_U32) //write 512 byte to FLASH
		{
			while(FLASH_GetStatus() != FLASH_COMPLETE);
			FLASH_ProgramWord(FLASH_USER_START_ADDR + i + j, *((volatile u32*)(appBuffer + j)));
		}
		appAddrPointer += APP_BLOCK_TRANSFER_SIZE; //pointer to current position in FLASH for write
	}

	f_read(&appFile, appBuffer, appTailSize, &readBytes); //Read "tail" that < 512 bytes from file

	while((appTailSize % SIZE_OF_U32) != 0)		//if appTailSize MOD 4 != 0 (seems not possible, but still...)
	{
		appTailSize++;				//increase the tail to a multiple of 4
		appBuffer[appTailSize - 1] = 0xFF;	//and put 0xFF in this tail place
	}

	for(i = 0; i < appTailSize; i += SIZE_OF_U32) //write "tail" to FLASH
	{
		while(FLASH_GetStatus() != FLASH_COMPLETE);
		FLASH_ProgramWord(FLASH_USER_START_ADDR + appAddrPointer + i, *((volatile u32*)(appBuffer + i))); 
	}
}

Копировать будем блоками по 512 байт. 512 — потому что я где-то видел, что при размере буфера больше этого значения f_read() может косячить. Я проверял этот момент — у меня все работало и с буфером большего размера. Но на всякий случай оставил 512 — почему бы и нет? Экономим RAM, да и на скорость не влияет, к тому же выполняется один раз — в момент включения устройства и лишь при условии, что прошивку пора обновлять.

Предварительно стираем во FLASH памяти местечко под файл. Размер стираемой области равен количеству страниц в памяти, которые полностью займет «APP.BIN» + еще одна (которую не полностью). А еще, виртуально бьем файл прошивки на «body» и «tail», где «body» — максимально возможный кусок файла, в который входит целое количество блоков по 512 байт, а «tail» — все остальное.

Кажется, что все бинарные файлы прошивок кратны 4-м байтам. Я не был в этом уверен точно (и до сих пор), так что на всякий случай — если прошивка не кратна sizeof(u32) — дополняем её байтами 0xFF. Повторюсь: кажется, что этого не нужно делать — но операция безобидна для кратных sizeof(u32) бинарников, так что оставим.

Hello, User Application!

Уже близко. Деинициализируем всю использованную периферию функцией PeriphDeInit() (а ее вообще почти ничего — GPIO для кнопки выбора режима и при желании UART для вывода отладочных сообщений; никаких прерываний не используется).

Заключительный жизненный этап загрузчика — начало исполнения пользовательской прошивки:

void GoToUserApp(void)
void GoToUserApp(void)
{
	u32 appJumpAddress;
	void (*GoToApp)(void);

	appJumpAddress = *((volatile u32*)(FLASH_USER_START_ADDR + 4));
	GoToApp = (void (*)(void))appJumpAddress;
	SCB->VTOR = FLASH_USER_START_ADDR;
	__set_MSP(*((volatile u32*) FLASH_USER_START_ADDR)); //stack pointer (to RAM) for USER app in this address
	GoToApp();
}

Всего-то 5 строк, но сколько всего происходит!

В ядре ARM Cortex M3, когда возникает какое-либо исключение, для него вызывается соответствующий обработчик. Что бы определить начальный адрес обработчика исключений, используется механизм векторной таблицы. Таблица векторов представляет собой массив слов данных внутри системной памяти, каждое из которых является начальным адресом одного типа исключений. Таблица перемещаема и перемещение управляется специальным регистром VTOR в SCB(System Control Block). (В мануале звучит круче, но я сломался: The vector table is relocatable, and the relocation is controlled by a relocation register in the NVIC). После RESET'а значение этого регистра равно 0, то есть таблица векторов лежит по адресу 0x0 (для STM32F103 в стартап файле мы уже самостоятельно двигаем её на 0x08000000). И что очень важно для нас, порядок следования там следующий:

image

  • Значение, лежащее по адресу 0x04 — это то место в программе, куда мы попадаем после Reset-исключения
  • Значение, лежащее по адресу 0x00 — это начальное значение Main Stack Pointer для пользовательского приложения

Все это вместе взятое, плюс немного магии с указателем на функцию, и Алиса прыгает вслед за кроликом.

Теперь проверим, работает ли оно вообще. Напишем простую программу мигания светодиодов, с циклами в main() и парочкой прерываний (SysTick и TIM4):

Test programm for MSD bootloader
#include "stm32f10x.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_rcc.h"
#include "stm32f10x_tim.h"
#include "misc.h"

#define SYSCLK_FREQ	72000000
#define TICK_1_KHz	((SYSCLK_FREQ / 1000) - 1)
#define TICK_1_MHz	((SYSCLK_FREQ / 1000000) - 1)

volatile u32 i, j;

int main(void)
{
	GPIO_InitTypeDef GPIO_Options;
	NVIC_InitTypeDef NVIC_Options;

	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);

	GPIO_Options.GPIO_Pin = GPIO_Pin_7;
	GPIO_Options.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Options.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_Init(GPIOA, &GPIO_Options);

	GPIO_Options.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1;
	GPIO_Options.GPIO_Speed = GPIO_Speed_2MHz;
	GPIO_Options.GPIO_Mode = GPIO_Mode_Out_PP;
	GPIO_Init(GPIOB, &GPIO_Options);

	GPIOB->BSRR = GPIO_Pin_0 | GPIO_Pin_1; //LEDs off
	GPIOA->BSRR = GPIO_Pin_7

	TIM4->PSC = 720 - 1;		//clock prescaller
	TIM4->ARR = 60000 - 1;		//auto-reload value
	TIM4->CR1 |= TIM_CounterMode_Up;//upcounter
	TIM4->DIER |= TIM_IT_Update;	//update interrupt enable
	TIM4->CR1 |= TIM_CR1_CEN;	//timer start

	NVIC_Options.NVIC_IRQChannel = TIM4_IRQn;
	NVIC_Options.NVIC_IRQChannelPreemptionPriority = 0;
	NVIC_Options.NVIC_IRQChannelSubPriority = 0;
	NVIC_Options.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&NVIC_Options);

	SysTick_Config(TICK_1_KHz);

	while(1)
	{
		__disable_irq();

		GPIOB->BSRR = GPIO_Pin_0 | GPIO_Pin_1; //Off
		for(i = 0; i < 10; i++)
		{
			for(j = 0; j < 500000; j++); //Pause
			GPIOA->ODR ^= GPIO_Pin_7; //Reverse
		}
		GPIOA->BSRR = GPIO_Pin_7; //Off

		__enable_irq();

		for(i = 0; i < 5000000; i++); //Pause
	}
}

void SysTick_Handler(void)
{
	volatile static u32 LED_Counter = 0;

	if(LED_Counter >= 40)
	{
		GPIOB->ODR ^= GPIO_Pin_1; //Reverse
		LED_Counter = 0;
	}

	LED_Counter++;
}

void TIM4_IRQHandler()
{
	TIM4->SR = ~TIM_SR_UIF;
	GPIOB->ODR ^= GPIO_Pin_0; //Reverse
}

Кстати, надо не забыть исправить в проекте пару вещей, без которых ничего работать не будет:

  1. Убрать из SystemInit() операцию перемещения таблицы векторов на какое либо значение (//SCB->VTOR = FLASH_BASE). Bootloader перемещает ее самостоятельно перед переходом в пользовательскую программу!
  2. В Linker script поменять начало нашей программы с адреса 0x08000000 на адрес начала USER_MEM (FLASH (rx): ORIGIN = 0x08010000, LENGTH = 200K);

И вот так этот код исполняется (ну, может не все видели, как моргают светодиоды...):

А вот так выглядит лог загрузки этой прошивки в МК через бутлоадер:

UART log message
---------------START LOG---------------

BOOT_MEM start addr: 0x08000000
BOOT_MEM size: 64K
USER_MEM start addr: 0x08010000
USER_MEM size: 200K
MSD_MEM start addr: 0x08042000
MSD_MEM size: 200K
OTHER_MEM start addr: 0x08074000
OTHER_MEM size: 48K

Total memory size: 512K

BOOTLOADER Mode…
FAT FS mount status = 0
Application file open status = 0
Difference between MSD_MEM and USER_MEM: 4 byte from 2212 byte
Start copy MSD_MEM to USER_MEM:

File size = 2212 byte
Body size = 2048 byte
Tail size = 164 byte

Sector 0 (0x08010000 — 0x08010800) erased
Sector 1 (0x08010800 — 0x08011000) erased

0 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010000 — 0x08010200
1 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010200 — 0x08010400
2 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010400 — 0x08010600
3 cycle, read status = 0, 512 byte read
512 byte programmed: 0x08010600 — 0x08010800
Tail read: read status = 0, 164 byte read, size of tail = 164
New size of tail = 164
164 byte programmed: 0x08010800 — 0x080108A4

File close status = 0
FAT FS unmount status = 0
DeInit peripheral and jump to 0x08010561…

Подведём итоги. Бутлоадер получился! И даже работает. С выводом отладочных сообщений в UART он занимает 31684 байт FLASH памяти, без — 25608 байт. Не так уж и мало, если еще и учесть, сколько памяти нужно отдать под Mass Storage диск. Исходники и рабочий проект (Atollic TrueSTUDIO) можно посмотреть на Bitbucket.

Спасибо за внимание!

Автор: Katbert

Источник

Поделиться новостью

* - обязательные к заполнению поля