Подключение OLED дисплея ssd1306 к STM32 (SPI+DMA)

В данной статье будет описан процесс подключение oled дисплея с контроллером ssd1306 разрешением 128x64 к микроконтроллеру stm32f103C8T6 по интерфейсу SPI. Также мне хотелось добиться максимальной скорости обновления дисплея, поэтому целесообразно использовать DMA, а программирование микроконтроллера производить с помощью библиотеки CMSIS.

Подключение


Подключать дисплей к микроконтроллеру будем по интерфейсу SPI1 по следующей схеме:

  • VDD-> +3.3В
  • GND-> Земля
  • SCK -> PA5
  • SDA -> PA7(MOSI)
  • RES-> PA1
  • CS-> PA2
  • DS-> PA3

imageimage

Передача данных происходит по возрастающему фронту сигнала синхронизации по 1 байту за кадр. Линии SCK и SDA служат для передачи данных по интерфейсу SPI, RES — перезагружает контроллер дисплея при низком логическом уровне, CS отвечает за выбор устройства на шине SPI при низком логическом уровне, DS определяет тип данных (команда — 1/данные — 0) которые передаются дисплею. Так как с дисплея ничего считать нельзя, вывод MISO использовать не будем.

Организация памяти контроллера дисплея


Перед тем, как выводить что-либо на экран, необходимо разобраться как в контроллере ssd1306 организована память.

image
image

Вся графическая память (GDDRAM) представляет собой область 128*64=8192 бит=1 Кбайт. Область разбита на 8 страниц, которые представлены в виде в виде совокупности из 128-ми 8-ми битных сегментов. Адресация памяти происходит по номеру страницы и номеру сегмента соответственно.

При таком методе адресации есть очень неприятная особенность — невозможность записать в память 1 бит информации, так как запись происходит по сегменту (по 8 бит). А так как для корректного отображения единичного пикселя на экране, необходимо знать состояние остальных пикселей в сегменте, целесообразно создать в памяти микроконтроллера буфер размером 1 Кбайт и циклически загружать его в память дисплея (тут и пригодится DMA), соответственно, производя его полное обновление. При использовании такого метода возможно пересчитать положение каждого бита в памяти на классические координаты x,y. Тогда для вывода на экран точки с координатами x и y воспользуемся следующим способом:

displayBuff[x+(y/8)*SSD1306_WIDTH]|=(1<<(y%8));

А для того, чтобы стереть точку

displayBuff[x+(y/8)*SSD1306_WIDTH]&=~(1<<(y%8));


Настройка SPI


Как говорилось выше, подключать дисплей будем к SPI1 микроконтроллера STM32F103C8.

image

Для удобства написания кода объявим некоторые константы и создадим функцию для инициализации SPI.

#define SSD1306_WIDTH 128
#define SSD1306_HEIGHT 64
#define BUFFER_SIZE 1024
//Макросы для активации устройства на шине, сброса экрана и выбора команды/данных
#define CS_SET GPIOA->BSRR|=GPIO_BSRR_BS2
#define CS_RES GPIOA->BSRR|=GPIO_BSRR_BR2
#define RESET_SET GPIOA->BSRR|=GPIO_BSRR_BS1
#define RESET_RES GPIOA->BSRR|=GPIO_BSRR_BR1
#define DATA GPIOA->BSRR|=GPIO_BSRR_BS3
#define COMMAND GPIOA->BSRR|=GPIO_BSRR_BR3

void spi1Init()
{
    return;
}

Включим тактирование и произведем настройку выходов GPIO, как показано в таблице выше.


RCC->APB2ENR|=RCC_APB2ENR_SPI1EN | RCC_APB2ENR_IOPAEN;//Включить тактирование SPI1 и GPIOA
RCC->AHBENR|=RCC_AHBENR_DMA1EN;//Включить тактирование DMA
GPIOA->CRL|= GPIO_CRL_MODE5 | GPIO_CRL_MODE7;//PA4,PA5,PA7 в режим выходов 50MHz
GPIOA->CRL&= ~(GPIO_CRL_CNF5 | GPIO_CRL_CNF7);
GPIOA->CRL|=  GPIO_CRL_CNF5_1 | GPIO_CRL_CNF7_1;//PA5,PA7 - выход с альтернативной функцией push-pull, PA4 - выход push-pull

Далее произведем настройку SPI в режим master и частотой 18 Мгц.

SPI1->CR1|=SPI_CR1_MSTR;//Режим ведущего
SPI1->CR1|= (0x00 & SPI_CR1_BR);//Делитель частоты на 2
SPI1->CR1|=SPI_CR1_SSM;//Программный NSS
SPI1->CR1|=SPI_CR1_SSI;//NSS - high
SPI1->CR2|=SPI_CR2_TXDMAEN;//Разрешить запросы DMA
SPI1->CR1|=SPI_CR1_SPE;//включить SPI1

Настроим DMA.

DMA1_Channel3->CCR|=DMA_CCR1_PSIZE_0;//Размер периферии 1байт
DMA1_Channel3->CCR|=DMA_CCR1_DIR;//Режим DMA из памяти в периферию
DMA1_Channel3->CCR|=DMA_CCR1_MINC;//Включить инкремент памяти
DMA1_Channel3->CCR|=DMA_CCR1_PL;//Высокий приоритет DMA

Далее напишем функцию отправки данных по SPI (пока без DMA). Процесс обмена данными заключается в следующем:

  1. Ожидаем, пока SPI освободится
  2. CS=0
  3. Отправка данных
  4. CS=1


void spiTransmit(uint8_t data)
{
	CS_RES;	
	SPI1->DR = data;
	while((SPI1->SR & SPI_SR_BSY))
	{};
	CS_SET;
}

Также напишем функцию непосредственно отправки команды экрану (Переключение линии DC производим только при передаче команды, а затем возвращаем ее в состояние «данные», так как команды передавать будем не так часто и в производительности не потеряем).

void ssd1306SendCommand(uint8_t command)
{
	COMMAND;
	spiTransmit(command);
	DATA;
}

Далее займемся функциями для работы непосредственно с DMA, для этого объявим буфер в памяти микроконтроллера и создадим функции для начала и остановки циклической отправки этого буфера в память экрана.

static uint8_t displayBuff[BUFFER_SIZE];//Буфер экрана

void ssd1306RunDisplayUPD()
{
	DATA;
	DMA1_Channel3->CCR&=~(DMA_CCR1_EN);//Выключить DMA
	DMA1_Channel3->CPAR=(uint32_t)(&SPI1->DR);//Занесем в DMA адрес регистра данных SPI1
	DMA1_Channel3->CMAR=(uint32_t)&displayBuff;//Адрес данных
	DMA1_Channel3->CNDTR=sizeof(displayBuff);//Размер данных
	DMA1->IFCR&=~(DMA_IFCR_CGIF3);
	CS_RES;//Выбор устройства на шине
	DMA1_Channel3->CCR|=DMA_CCR1_CIRC;//Циклический режим DMA
	DMA1_Channel3->CCR|=DMA_CCR1_EN;//Включить DMA
}

void ssd1306StopDispayUPD()
{
	CS_SET;//Дезактивация устройства на шине
	DMA1_Channel3->CCR&=~(DMA_CCR1_EN);//Выключить DMA
	DMA1_Channel3->CCR&=~DMA_CCR1_CIRC;//Выключить циклический режим
}

Инициализация экрана и вывод данных


Теперь создадим функцию для инициализации самого экрана.

void ssd1306Init()
{

}

Для начала настроим CS, RESET и линию DC, а также произведем сброс контроллера дисплея.

uint16_t i;
GPIOA->CRL|= GPIO_CRL_MODE2 |GPIO_CRL_MODE1 | GPIO_CRL_MODE3;
GPIOA->CRL&= ~(GPIO_CRL_CNF1 | GPIO_CRL_CNF2 | GPIO_CRL_CNF3);//PA1,PA2,PA3 в режим выхода
//Сброс экрана и очистка буфера
RESET_RES;
for(i=0;i<BUFFER_SIZE;i++)
{
	displayBuff[i]=0;
}
RESET_SET;
CS_SET;//Выбор устройства на шине

Далее отправим последовательность команд для инициализации (Более подробно о них можно узнать в документации на контроллер ssd1306).

ssd1306SendCommand(0xAE); //display off
ssd1306SendCommand(0xD5); //Set Memory Addressing Mode
ssd1306SendCommand(0x80); //00,Horizontal Addressing Mode;01,Vertical
ssd1306SendCommand(0xA8); //Set Page Start Address for Page Addressing
ssd1306SendCommand(0x3F); //Set COM Output Scan Direction
ssd1306SendCommand(0xD3); //set low column address
ssd1306SendCommand(0x00); //set high column address
ssd1306SendCommand(0x40); //set start line address
ssd1306SendCommand(0x8D); //set contrast control register
ssd1306SendCommand(0x14);
ssd1306SendCommand(0x20); //set segment re-map 0 to 127
ssd1306SendCommand(0x00); //set normal display
ssd1306SendCommand(0xA1); //set multiplex ratio(1 to 64)
ssd1306SendCommand(0xC8); //
ssd1306SendCommand(0xDA); //0xa4,Output follows RAM
ssd1306SendCommand(0x12); //set display offset
ssd1306SendCommand(0x81); //not offset
ssd1306SendCommand(0x8F); //set display clock divide ratio/oscillator frequency
ssd1306SendCommand(0xD9); //set divide ratio
ssd1306SendCommand(0xF1); //set pre-charge period
ssd1306SendCommand(0xDB); 
ssd1306SendCommand(0x40); //set com pins hardware configuration
ssd1306SendCommand(0xA4);
ssd1306SendCommand(0xA6); //set vcomh
ssd1306SendCommand(0xAF); //0x20,0.77xVcc

Создадим функции для заполнения всего экрана выбранным цветом и отображения одного пикселя.

typedef enum COLOR
{
	BLACK,
	WHITE
}COLOR;

void ssd1306DrawPixel(uint16_t x, uint16_t y,COLOR color){
	if(x<SSD1306_WIDTH && y <SSD1306_HEIGHT && x>=0 && y>=0)
	{
		if(color==WHITE)
		{
			displayBuff[x+(y/8)*SSD1306_WIDTH]|=(1<<(y%8));
		}
		else if(color==BLACK)
		{
			displayBuff[x+(y/8)*SSD1306_WIDTH]&=~(1<<(y%8));
		}
	}
}

void ssd1306FillDisplay(COLOR color)
{
	uint16_t i;
	for(i=0;i<SSD1306_HEIGHT*SSD1306_WIDTH;i++)
	{
		if(color==WHITE)
			displayBuff[i]=0xFF;
		else if(color==BLACK)
			displayBuff[i]=0;
	}
}

Далее в теле основной программы инициализируем SPI и дисплей.

RccClockInit();
spi1Init();
ssd1306Init();

Функция RccClockInit() предназначена для настройки тактирования микроконтроллера.

RccClockInit код
int RccClockInit()
{
	//Enable HSE
	//Setting PLL
	//Enable PLL
	//Setting count wait cycles of FLASH
	//Setting AHB1,AHB2 prescaler
	//Switch to PLL	
	uint16_t timeDelay;
	RCC->CR|=RCC_CR_HSEON;//Enable HSE
	for(timeDelay=0;;timeDelay++)
	{
		if(RCC->CR&RCC_CR_HSERDY) break;
		if(timeDelay>0x1000)
		{
			RCC->CR&=~RCC_CR_HSEON;
			return 1;
		}
	}	
	RCC->CFGR|=RCC_CFGR_PLLMULL9;//PLL x9
	RCC->CFGR|=RCC_CFGR_PLLSRC_HSE;//PLL sourse:HSE
	RCC->CR|=RCC_CR_PLLON;//Enable PLL
	for(timeDelay=0;;timeDelay++)
	{
		if(RCC->CR&RCC_CR_PLLRDY) break;
		if(timeDelay>0x1000)
		{
			RCC->CR&=~RCC_CR_HSEON;
			RCC->CR&=~RCC_CR_PLLON;
			return 2;
		}
	}
	FLASH->ACR|=FLASH_ACR_LATENCY_2;
	RCC->CFGR|=RCC_CFGR_PPRE1_DIV2;//APB1 prescaler=2
	RCC->CFGR|=RCC_CFGR_SW_PLL;//Switch to PLL
	while((RCC->CFGR&RCC_CFGR_SWS)!=(0x02<<2)){}
	RCC->CR&=~RCC_CR_HSION;//Disable HSI
	return 0;
}


Зальем весь дисплей белым цветом и посмотрим результат.

ssd1306RunDisplayUPD();
ssd1306FillDisplay(WHITE);

image

Нарисуем на экране в сетку шагом в 10 пикселей.

for(i=0;i<SSD1306_WIDTH;i++)
{
	for(j=0;j<SSD1306_HEIGHT;j++)
	{
		if(j%10==0 || i%10==0)
			ssd1306DrawPixel(i,j,WHITE);
	}
}

image

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

Частота обновления дисплея


Так как буфер отправляется в память дисплея циклически, для приблизительного определения частоты обновления дисплея достаточно будет узнать время, за которое DMA осуществляет полную передачу данных. Для отладки в реальном времени воспользуемся библиотекой EventRecorder из Keil.

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

DMA1_Channel3->CCR|=DMA_CCR1_TCIE;//Прерывание по завершении передачи
DMA1->IFCR&=~DMA_IFCR_CTCIF3;//Сбрасываем флаг прерывания
NVIC_EnableIRQ(DMA1_Channel3_IRQn);//Включить прерывание

Промежуток времени будем отслеживать с помощью функций EventStart и EventStop.

image

Получаем 0.00400881-0.00377114=0.00012767 сек, что соответствует частоте обновления 4.2 Кгц. На самом деле частота не такая большая, что связано с неточностью способа измерения, но явно больше стандартных 60 Гц.

Ссылки


AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 30

    +5
    На этом контроллере stm32f103c8t6 максимальная скорость передачи по SPI как раз без DMA.
    Да и экран маленький, не надо ему столько. Попробуйте лучше с ILI 9341 SPI. И вытянуть 30 ФПС.
      0
      Кстати да тоже столкнулся с таким что ногодрыгом получается быстрее, ну и на самом деле для этого дисплея дма точно без надобности.
        0
        Если нагрузить МК параллельно вычислениями и работой с другой периферией, то с DMA будет быстрее, а так да, можно и на прерываниях сделать, работать будет так же.
        Насчет ILI 9341 давно думал, надо заказать китайцам, но думаю 30 фпс по spi при полном обновлении экрана врятли получится, а вот через параллельный интерфейс вполне возможно.
          0

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

            0
            подтверждаю и хочу добавить что порой хватает только процессора на всё:
            Я на подобном OLED дисплее и stm32f100 cделал «умные» часы в корпусе обычных наручных круглых часов: вообще всё реализовал без DMA и питались они от обычной часовой CR2032 батарейки и даже 8мгц хватало чтоб и флешку читать и 30фпс обновлять и отрисовывать интерфейс с плавными свайпами вправо и влево, при этом, во время свайпа, оба окна продолжали динамически обновляться (экранного буфера не было из-за 2к озу — рисовал динамически).
              +1
              аж прям ностальгия ) картинка рисовалась в двух буферах и шло переключение между буферами в одном рисуем второй показываем и оставалось еще много ресурсов
        0
        На этом контроллере stm32f103c8t6 максимальная скорость передачи по SPI как раз без DMA.

        Как это возможно?
        +4
        uint16_t i;
        for(i=0;i<SSD1306_HEIGHT*SSD1306_WIDTH;i++)
        {
        if(color==WHITE)
        displayBuff[i]=0xFF;
        else if(color==BLACK)
        displayBuff[i]=0;
        }
        Это а ) ужасно. вынесите условие из цикла
        б) неверно. В 8 раз больше чем нужно.
          –1
          Более того, SSD1306_HEIGHT*SSD1306_WIDTH нужно вынести в отдельную константу
            +1
            Это необязательно. А вот переменную цикла безопасней сделать не uint16_t а просто int. Потому что при копипасте на чуть больший дисплей например 320х240х16бит она уже переполнится. А 32 двух разрядной архитектуре stm32 все равно как индексировать массив по 32 бита нативной переменной или по 16 битной. То есть выигрыша от 16битного индекса нет, а опасность есть.
            То есть я бы написал просто " int i"
              0
              Вы уверены в том что int всегда состоит из 32 бит?
              www.keil.com/support/man/docs/c166/c166_le_datatypes.htm
                0
                Конечно не всегда. Но речь идёт именно о stm32.
                На других платформах она как правило как минимум 16 на 8 и 16 разрядных машинах и 32 на 32 и 64 разрядных машинах. Впрочем по стандарту должно быть что то вроде size_t для индексов. И даже на 8 и 16 разрядных машинах лучше использовать int, а не int32_t.
                  0
                  Данный фрагмент кода никак не специфицен для stm32, его можно перенести на любую другую платформу без какой либо переделки. Поэтому, если вы уж хотите сделать код действительно безопасным, о таком типе данных как int следует забыть, и использовать типы данных объявленные в файле stdint.h.
                  Ваше же предложение о замене uint16_t на просто int (кстати, почему именно на int, а не на unsigned int?) снижает безопасность кода. Более того, так как стандарт C не определяет размер данных, вы можете даже просто сменив компилятор — словить массу проблем за счет использования int'ов вместо uint32_t. И в теории такое может произойти банально за счет обновления версии компилятора.
                  The actual size of the integer types varies by implementation. The standard requires only size relations between the data types and minimum sizes for each data type:

                  The relation requirements are that the long long is not smaller than long, which is not smaller than int, which is not smaller than short. As char's size is always the minimum supported data type, no other data types (except bit-fields) can be smaller.

                  The minimum size for char is 8 bits, the minimum size for short and int is 16 bits, for long it is 32 bits and long long must contain at least 64 bits.

                  en.wikipedia.org/wiki/C_data_types
                  Вы все еще хотите использовать тип данных int?
                    0
                    Именно int. Во первых есть куча старых 8-16 бит компиляторов, в которых просто нет stdint.h. Во вторых, на 16 битной платформе int32 и uint32 ужасно неэффективны, да и память доступна через окна. И в третьих можно на эту тему спорить без конца и остаться при своем мнении. Я не навязываю своего стиля, а высказываю мнение. С — древний язык, я пишу на нем лет 30 уже и рекомендации к стилям менялись неоднократно. Тип int и его арифметика реализованы на всех платформах с максимальной эффективностью для данной платформы. А его ограничения просто нужно держать в голове…
            0
            Увидел, плохо сделал.
            Можно исправить так:
            if(color==WHITE)
                memcpy(displayBuff,0xFF,BUFFER_SIZE);
            else if(color==BLACK)
                memcpy(displayBuff,0x00,BUFFER_SIZE);
            
              0
              Memset а не memcpy. Но направление правильное. Впрочем и for нормально.
                +1
                Или так
                typedef enum COLOR
                {
                	BLACK=0x00,
                	WHITE=0xFF
                }COLOR;
                ....
                memset(displayBuff,color,BUFFER_SIZE);
              0
              с помощью библиотеки CMSIS

              Уже за это спасибо
                0
                В свое время мне было лень писать самому графику для этого дисплея и я нашел в сети подходящую библиотеку. А вот с цветными дисплеями я играться люблю. Особенно, если он позволяет микроконтроллеру читать свою память. Так можно фреймбуфер держать только в дисплее и если нужно что-то там модифицировать, то достаточно прочесть интересующий блок памяти, изменить его и записать обратно.
                  0
                  Что то я не понимаю:
                  1. Вам нужно передать 128*64=8192 бита по SPI
                  2. SPI передает 1 бит за такт частоты 18 МГц.
                  3. Максимальная частота обновления 18000000/8192=2197Гц~2.2кГц
                  4. Откуда 4.2кГц?
                    0
                    Так вышло потому, что SPI тут сконфигурирован на частоте 36Мгц. SPI1 сидит на шине APB2, частота которой 72Мгц, делитель SPI выставлен на 2. При такой частоте и получаем 4.2кГц.
                    На самом деле я сам удивился, почему оно так заработало (Cube не позволяет сконфигурировать SPI на такой частоте, хотя через регистры все работает). Было бы конечно неплохо посмотреть с помощью осциллографа как там на самом деле, но такой возможности сейчас нет. Конечно никакого толку от такой частоты нет, все было сделано сугубо в академических целях в процессе обучения.
                      +1
                      Ну тогда понятно, все сходится.
                      А что касается настроек через библиотеки (КУБ — это тоже библиотеки) и напрямую, то часто сохраняются старые ограничения, которые давно уже сняты. Единственное, чем следует руководствоваться — это документация на конкретный МК и, если она допускает высокие частоы, то их и следует использовать.
                      И это совсем не академические цели, а вполне себе продакшн — умение выжать все соки из конкретного МК.
                        0
                        Только в доке на SSD1306 максимальная частота клока 10 МГц.
                          0
                          А не подскажите страницу? У меня явного ограничения найти не получилось, да и все прекрасно работает.
                            0
                            Боюсь, что «все прекрасно работает» — это не аргумент.
                            Таблицы 13.3 и 13.4 дата — период тактовой частоты интерфейса SPI — не менее 100нс.
                              0
                              Да, из таблицы 13.4: Clock Cycle Time — 100 ns Min.
                      0
                      while((SPI1->SR & SPI_SR_BSY))
                      Кстати проверка флага SPI_SR_BSY при отправке данных, не лучшее решение, так вы проверяете есть ли еще данные в сдвиговом регистре, ну и как пишут в RM:
                      The BSY flag must be used with caution: refer to Section28.5.10: SPI status flags and
                      Procedure for disabling the SPI on page767.

                      Вместо этого лучше проверять освободился ли буфер — SPI_SR_RXNE, так по идее должно быть еще и быстрее (немного)
                        0
                        «SPI_SR_RXNE»
                        Это --Rx buffer not empty (RXNE)

                        правильно будет TXE перед отправкой:
                        Tx buffer empty flag (TXE)
                          +1
                          Вы абсолютно правы, в данном случае — TXE. Бывает )
                          +1
                          Решил таки произвести замер скорости передачи строки в 1000 символов, разница… ну она все же есть:

                          SPI_SR_TXE
                          image

                          SPI_SR_BSY
                          image

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

                        Самое читаемое