terça-feira, agosto 11, 2020

Investigando Fontes de Caracteres Para Display OLED 128x64 Pontos

Os displays OLED de 0,96", com resolução de 128x64, são bastante interessantes e já apareceram várias vezes aqui no blog. Neste post vou falar sobre as minhas experiências com escrita de texto.

Por simplificação estou usando sempre caracteres mono-espaçados e usando nos exemplos fontes somente com os dígitos hexadecimais.



Algumas considerações sobre o display e seu controlador
A primeira coisa a considerar é o mapeamento dos pontos na memória do controlador SSD1306:


Repare que cada byte da memória corresponde a 8 pontos na vertical. Por este motivo é mais simples trabalhar com fontes que tenham o tamanho vertical múltiplo de 8.

Normalmente eu utilizo a interface I2C para comunicar com o display, o que traz algumas limitações adicionais. A primeira é que a memória do controlador não pode ser lida, se você precisar do conteúdo atual vai ter que manter uma cópia da imagem no controlador. A memória para armazenar a tela toda é 128*64/8 = 1024 bytes, o que é metade da memória disponível no ATmega328 usado nos Arduinos Uno, Nano, etc.

Uma outra questão é que a biblioteca Wire do Arduino limita uma transmissão ao tamanho do seu buffer interno (32 bytes no caso do Uno e cia). Isto obriga, por exemplo, a quebrar a limpeza de tela em várias transmissões.

Por último temos a velocidade. O datasheet fala que o ciclo mínimo do clock é 2,5 us, o que equivale a uma frequência máxima de 400KHz (existem relatos de operar com frequência maior). O default da biblioteca Wire do Arduino é 100KHz, lembrando que se tiver outros dispositivos barramento I2C deve ser adotada a menor frequência máxima.

Vamos calcular o tempo mínimo para enviar uma tela inteira via I2C para o controlador usando o clock default:
  • Vamos dividir o envio dos 1024 bytes em 64 transmissões de 16 bytes
  • Cada bloco de 16 bytes precisa ser precedido de mais dois bytes: o endereço e o byte de controle
  • A transferência de cada byte envolve 9 bits (8 de dados + 1 de ACK/NAK)
  • Total de bits: 64*18*9
  • Tempo usando um clock de 100Khz: (64*18*9)/100000 = 0,1 seg (aproximadamente)
0,1 segundo não parece muito, mas é perceptível e é um tempo grande demais para atualizar um menu na tela comandado por um encoder com 20 posições por volta.

Caracteres n x 8

Este é o que tenho usado normalmente. Uma altura de 8 pixels implica em termos 8 linhas de texto. Um tamanho tradicional de caracteres é 5x7, acrescentando um pixel de margem horizontal e vertical obtemos uma célula de 6x8, o que resulta em 21 colunas. Podemos também usar caracteres um pouco mais largos, resultando em células de 8x8 e 16 colunas de caracteres.Cada caracter ocupa n bytes no gerador de caracteres.

As rotinas para escrita de caracteres com os fontes nx8 são bem simples, abaixo uso uma célula de 6x8:

  1. // Escreve um digito hexadecimal na linha l(0 a 7), coluna c(0 a 19)  
  2. void Display_write_6x8 (byte l, byte c, byte dig) {  
  3.   uint8_t *gc = &font_6x8[dig*6];  
  4.   
  5.   // Endereça o caracter  
  6.   Display_sendcmd (SSD1306_COLUMNADDR);  
  7.   Display_sendcmd (c*6);  
  8.   Display_sendcmd (c*6 + 5);  
  9.   Display_sendcmd (SSD1306_PAGEADDR);  
  10.   Display_sendcmd (l);  
  11.   Display_sendcmd (l);  
  12.     
  13.   // Escreve  
  14.   Wire.beginTransmission(DISP_ADDR);  
  15.   Wire.write(0x40);  // Co=0, DC = 1  
  16.   for (uint8_t x = 0;  x < 6; x++)   
  17.   {  
  18.       Wire.write(pgm_read_byte(gc++));  
  19.   }  
  20.   Wire.endTransmission();  
  21. }  

Caracteres n x 16

Os caracteres n x 8 são bem pequenos. A próxima altura, sem malabarismos, é 16 pixels, e resulta em 4 linhas de texto. As rotinas são um pouco mais complicadas, o exemplo abaixo usa uma célula de 8x16, com cada caracter ocupando 16 bytes:

  1. // Escreve um dígito hexadecimal na linha l(0 a 3), coluna c(0 a 15)  
  2. void Display_write_8x16 (byte l, byte c, byte dig)  
  3. {  
  4.   uint8_t *gc = &font_8x16[dig*16];  
  5.   
  6.   // Endereça e escreve a parte de cima  
  7.   Display_sendcmd (SSD1306_COLUMNADDR);  
  8.   Display_sendcmd (c << 3);  
  9.   Display_sendcmd ((c << 3) + 7);  
  10.   Display_sendcmd (SSD1306_PAGEADDR);  
  11.   Display_sendcmd (l << 1);  
  12.   Display_sendcmd (l << 1);  
  13.   Wire.beginTransmission(DISP_ADDR);  
  14.   Wire.write(0x40);  // Co=0, DC = 1  
  15.   for (uint8_t x = 0;  x < 8; x++)   
  16.   {  
  17.       Wire.write(pgm_read_byte(gc++));  
  18.   }  
  19.   Wire.endTransmission();  
  20.   
  21.   // Endereça e escreve a parte de baixo  
  22.   Display_sendcmd (SSD1306_COLUMNADDR);  
  23.   Display_sendcmd (c << 3);  
  24.   Display_sendcmd ((c << 3) + 7);  
  25.   Display_sendcmd (SSD1306_PAGEADDR);  
  26.   Display_sendcmd ((l << 1) + 1);  
  27.   Display_sendcmd ((l << 1) + 1);  
  28.   Wire.beginTransmission(DISP_ADDR);  
  29.   Wire.write(0x40);  // Co=0, DC = 1  
  30.   for (uint8_t x = 0;  x < 8; x++)   
  31.   {  
  32.       Wire.write(pgm_read_byte(gc++));  
  33.   }  
  34.   Wire.endTransmission();  
  35. }  

Caracteres n x 12

Este caso é bem mais complicado e requer manter uma cópia da imagem da tela e utilizar operações de manipulação de bits para combinar o conteúdo anterior com o novo. As escritas são feitas primeiro na cópia em memória e depois a memória interna do controlador é atualizada.

Com altura 12 conseguimos 5 linhas de texto e sobram 4 linhas gráficas. Para utilizar melhor os displays onde a parte de cima da tela tem cor diferente, vou usar um mapeamento onde a primeira linha tem altura 16 (com as 4 linhas gráficas inferiores sempre apagadas) e as demais 12.

O código abaixo usa uma célula de 8x12. Por simplificação, a rotina abaixo escreve vários caracteres em uma linha e depois copia sempre a linha inteira ao invés de somente as posições alteradas.

  1. // Cópia da imagem da tela na Ram  
  2. uint8_t tela [SSD1306_LCDWIDTH*SSD1306_LCDHEIGHT/8];  
  3.   
  4. // Tabelas para facilitar o endereçamento  
  5. const int     addrLin[] = { 0x000, 0x100, 0x180, 0x280, 0x300};  
  6. const uint8_t pagLin [] = { 0, 2, 3, 5, 6};  
  7.   
  8. // Escreve um "string hexadecimal" a partir da linha l(0 a 4), coluna c(0 a 15)  
  9. void Display_print_8x12 (byte l, byte c, char *str) {  
  10.   
  11.   // Atualiza na Ram  
  12.   int addr = addrLin[l];  
  13.   while (*str != 0) {  
  14.     byte dig = 0xFF;  
  15.     if ((*str >= '0') && (*str <= '9')) {  
  16.       dig = *str-'0';  
  17.     } else if ((*str >= 'A') && (*str <= 'F')) {  
  18.       dig = *str-'A'+10;  
  19.     }  
  20.     if (dig != 0xFF) {  
  21.       uint8_t *gc = &font_8x12[dig*16];  
  22.       if ((l == 2) || (l == 4)) {  
  23.         // Alinhada em baixo  
  24.         for (int x = 0; x < 8; x++) {  
  25.           tela[addr+c*8+x] = (tela[addr+c*8+x] & 0x0F) | ((pgm_read_byte(gc+x) & 0x0F) << 4);  
  26.         }  
  27.         for (int x = 0; x < 8; x++) {  
  28.           tela[addr+0x80+c*8+x] = ((pgm_read_byte(gc+x) & 0xF0) >> 4) | ((pgm_read_byte(gc+x+8) & 0x0F) << 4);  
  29.         }  
  30.       } else {  
  31.         // Alinhada em cima  
  32.         for (int x = 0; x < 8; x++) {  
  33.           tela[addr+c*8+x] = pgm_read_byte(gc+x);  
  34.         }  
  35.         for (int x = 0; x < 8; x++) {  
  36.           tela[addr+0x80+c*8+x] = (tela[addr+0x80+c*8+x] & 0xF0) | (pgm_read_byte(gc+x+8) & 0x0F);  
  37.         }  
  38.       }  
  39.     }  
  40.     str++;  
  41.     c++;  
  42.   }  
  43.   
  44.   // Copia para o controlador  
  45.   uint8_t pag = pagLin[l];  
  46.   copyPag (pag);  
  47.   copyPag (pag+1);  
  48. }  
  49.   
  50. // Copia uma página da Ram para o controlador  
  51. void copyPag (uint8_t pag) {  
  52.   int addr = pag << 7;  // pag * 128 = pag * SSD1306_LCDWIDTH  
  53.   Display_sendcmd (SSD1306_COLUMNADDR);  
  54.   Display_sendcmd (0);  
  55.   Display_sendcmd (SSD1306_LCDWIDTH-1);  
  56.   Display_sendcmd (SSD1306_PAGEADDR);  
  57.   Display_sendcmd (pag);  
  58.   Display_sendcmd (pag);  
  59.   for (uint16_t i = 0; i < SSD1306_LCDWIDTH/16; i++)   
  60.   {  
  61.       Wire.beginTransmission(DISP_ADDR);  
  62.       Wire.write(0x40);  // Co=0, DC = 1  
  63.       for (uint8_t x=0; x<16; x++)   
  64.       {  
  65.           Wire.write(tela[addr++]);  
  66.       }  
  67.       Wire.endTransmission();  
  68.   }  
  69. }  

Uma possibilidade para reduzir a memória Ram utilizada seria armazenar a tela alfanumérica (com tamanho 5x16 = 80 bytes). Com isso as rotinas de escrita ficariam ainda mais complicadas.

Outros Tamanhos

Para suportar outros tamanhos (e fontes proporcionais) as rotinas seriam bem mais complexas e teria que guardar a imagem da tela na Ram. A estratégia de muitas bibliotecas é fazer todas as operações sobre a cópia na Ram e exigir a chamada de uma rotina refresh() para atualizar a imagem no controlador.


O código completo dos testes pode ser baixado do meu github.

Nenhum comentário: