Подключение
Подключать дисплей к микроконтроллеру будем по интерфейсу SPI1 по следующей схеме:
- VDD-> +3.3В
- GND-> Земля
- SCK -> PA5
- SDA -> PA7(MOSI)
- RES-> PA1
- CS-> PA2
- DS-> PA3


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


Вся графическая память (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.

Для удобства написания кода объявим некоторые константы и создадим функцию для инициализации 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_BR3void spi1Init(){ return;}
Включим тактирование и произведем настройку выходов GPIO, как показано в таблице выше.
RCC->APB2ENR|=RCC_APB2ENR_SPI1EN | RCC_APB2ENR_IOPAEN;//Включить тактирование SPI1 и GPIOARCC->AHBENR|=RCC_AHBENR_DMA1EN;//Включить тактирование DMAGPIOA->CRL|= GPIO_CRL_MODE5 | GPIO_CRL_MODE7;//PA4,PA5,PA7 в режим выходов 50MHzGPIOA->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);//Делитель частоты на 2SPI1->CR1|=SPI_CR1_SSM;//Программный NSSSPI1->CR1|=SPI_CR1_SSI;//NSS - highSPI1->CR2|=SPI_CR2_TXDMAEN;//Разрешить запросы DMASPI1->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). Процесс обмена данными заключается в следующем:
- Ожидаем, пока SPI освободится
- CS=0
- Отправка данных
- 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);//Выключить DMADMA1_Channel3->CPAR=(uint32_t)(&SPI1->DR);//Занесем в DMA адрес регистра данных SPI1DMA1_Channel3->CMAR=(uint32_t)&displayBuff;//Адрес данныхDMA1_Channel3->CNDTR=sizeof(displayBuff);//Размер данныхDMA1->IFCR&=~(DMA_IFCR_CGIF3);CS_RES;//Выбор устройства на шинеDMA1_Channel3->CCR|=DMA_CCR1_CIRC;//Циклический режим DMADMA1_Channel3->CCR|=DMA_CCR1_EN;//Включить DMA}void ssd1306StopDispayUPD(){CS_SET;//Дезактивация устройства на шинеDMA1_Channel3->CCR&=~(DMA_CCR1_EN);//Выключить DMADMA1_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 offssd1306SendCommand(0xD5); //Set Memory Addressing Modessd1306SendCommand(0x80); //00,Horizontal Addressing Mode;01,Verticalssd1306SendCommand(0xA8); //Set Page Start Address for Page Addressingssd1306SendCommand(0x3F); //Set COM Output Scan Directionssd1306SendCommand(0xD3); //set low column addressssd1306SendCommand(0x00); //set high column addressssd1306SendCommand(0x40); //set start line addressssd1306SendCommand(0x8D); //set contrast control registerssd1306SendCommand(0x14);ssd1306SendCommand(0x20); //set segment re-map 0 to 127ssd1306SendCommand(0x00); //set normal displayssd1306SendCommand(0xA1); //set multiplex ratio(1 to 64)ssd1306SendCommand(0xC8); //ssd1306SendCommand(0xDA); //0xa4,Output follows RAMssd1306SendCommand(0x12); //set display offsetssd1306SendCommand(0x81); //not offsetssd1306SendCommand(0x8F); //set display clock divide ratio/oscillator frequencyssd1306SendCommand(0xD9); //set divide ratiossd1306SendCommand(0xF1); //set pre-charge periodssd1306SendCommand(0xDB); ssd1306SendCommand(0x40); //set com pins hardware configurationssd1306SendCommand(0xA4);ssd1306SendCommand(0xA6); //set vcomhssd1306SendCommand(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() предназначена для настройки тактирования микроконтроллера.
int RccClockInit(){//Enable HSE//Setting PLL//Enable PLL//Setting count wait cycles of FLASH//Setting AHB1,AHB2 prescaler//Switch to PLLuint16_t timeDelay;RCC->CR|=RCC_CR_HSEON;//Enable HSEfor(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 x9RCC->CFGR|=RCC_CFGR_PLLSRC_HSE;//PLL sourse:HSERCC->CR|=RCC_CR_PLLON;//Enable PLLfor(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=2RCC->CFGR|=RCC_CFGR_SW_PLL;//Switch to PLLwhile((RCC->CFGR&RCC_CFGR_SWS)!=(0x02<<2)){}RCC->CR&=~RCC_CR_HSION;//Disable HSIreturn 0;}
Зальем весь дисплей белым цветом и посмотрим результат.
ssd1306RunDisplayUPD();ssd1306FillDisplay(WHITE);

Нарисуем на экране в сетку шагом в 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);}}

Функции работают корректно, буфер непрерывно записывается в память контроллера дисплея, что позволяет при отображении графических примитивов пользоваться декартовой системой координат.
Частота обновления дисплея
Так как буфер отправляется в память дисплея циклически, для приблизительного определения частоты обновления дисплея достаточно будет узнать время, за которое DMA осуществляет полную передачу данных. Для отладки в реальном времени воспользуемся библиотекой EventRecorder из Keil.
Для того, чтобы узнать момент окончания передачи данных, настроим прерывание DMA на окончание передачи.
DMA1_Channel3->CCR|=DMA_CCR1_TCIE;//Прерывание по завершении передачиDMA1->IFCR&=~DMA_IFCR_CTCIF3;//Сбрасываем флаг прерыванияNVIC_EnableIRQ(DMA1_Channel3_IRQn);//Включить прерывание
Промежуток времени будем отслеживать с помощью функций EventStart и EventStop.

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