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:

// Escreve um digito hexadecimal na linha l(0 a 7), coluna c(0 a 19)
void Display_write_6x8 (byte l, byte c, byte dig) {
  uint8_t *gc = &font_6x8[dig*6];

  // Endereça o caracter
  Display_sendcmd (SSD1306_COLUMNADDR);
  Display_sendcmd (c*6);
  Display_sendcmd (c*6 + 5);
  Display_sendcmd (SSD1306_PAGEADDR);
  Display_sendcmd (l);
  Display_sendcmd (l);
  
  // Escreve
  Wire.beginTransmission(DISP_ADDR);
  Wire.write(0x40);  // Co=0, DC = 1
  for (uint8_t x = 0;  x < 6; x++) 
  {
      Wire.write(pgm_read_byte(gc++));
  }
  Wire.endTransmission();
}

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:

// Escreve um dígito hexadecimal na linha l(0 a 3), coluna c(0 a 15)
void Display_write_8x16 (byte l, byte c, byte dig)
{
  uint8_t *gc = &font_8x16[dig*16];

  // Endereça e escreve a parte de cima
  Display_sendcmd (SSD1306_COLUMNADDR);
  Display_sendcmd (c << 3);
  Display_sendcmd ((c << 3) + 7);
  Display_sendcmd (SSD1306_PAGEADDR);
  Display_sendcmd (l << 1);
  Display_sendcmd (l << 1);
  Wire.beginTransmission(DISP_ADDR);
  Wire.write(0x40);  // Co=0, DC = 1
  for (uint8_t x = 0;  x < 8; x++) 
  {
      Wire.write(pgm_read_byte(gc++));
  }
  Wire.endTransmission();

  // Endereça e escreve a parte de baixo
  Display_sendcmd (SSD1306_COLUMNADDR);
  Display_sendcmd (c << 3);
  Display_sendcmd ((c << 3) + 7);
  Display_sendcmd (SSD1306_PAGEADDR);
  Display_sendcmd ((l << 1) + 1);
  Display_sendcmd ((l << 1) + 1);
  Wire.beginTransmission(DISP_ADDR);
  Wire.write(0x40);  // Co=0, DC = 1
  for (uint8_t x = 0;  x < 8; x++) 
  {
      Wire.write(pgm_read_byte(gc++));
  }
  Wire.endTransmission();
}

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.

// Cópia da imagem da tela na Ram
uint8_t tela [SSD1306_LCDWIDTH*SSD1306_LCDHEIGHT/8];

// Tabelas para facilitar o endereçamento
const int     addrLin[] = { 0x000, 0x100, 0x180, 0x280, 0x300};
const uint8_t pagLin [] = { 0, 2, 3, 5, 6};

// Escreve um "string hexadecimal" a partir da linha l(0 a 4), coluna c(0 a 15)
void Display_print_8x12 (byte l, byte c, char *str) {

  // Atualiza na Ram
  int addr = addrLin[l];
  while (*str != 0) {
    byte dig = 0xFF;
    if ((*str >= '0') && (*str <= '9')) {
      dig = *str-'0';
    } else if ((*str >= 'A') && (*str <= 'F')) {
      dig = *str-'A'+10;
    }
    if (dig != 0xFF) {
      uint8_t *gc = &font_8x12[dig*16];
      if ((l == 2) || (l == 4)) {
        // Alinhada em baixo
        for (int x = 0; x < 8; x++) {
          tela[addr+c*8+x] = (tela[addr+c*8+x] & 0x0F) | ((pgm_read_byte(gc+x) & 0x0F) << 4);
        }
        for (int x = 0; x < 8; x++) {
          tela[addr+0x80+c*8+x] = ((pgm_read_byte(gc+x) & 0xF0) >> 4) | ((pgm_read_byte(gc+x+8) & 0x0F) << 4);
        }
      } else {
        // Alinhada em cima
        for (int x = 0; x < 8; x++) {
          tela[addr+c*8+x] = pgm_read_byte(gc+x);
        }
        for (int x = 0; x < 8; x++) {
          tela[addr+0x80+c*8+x] = (tela[addr+0x80+c*8+x] & 0xF0) | (pgm_read_byte(gc+x+8) & 0x0F);
        }
      }
    }
    str++;
    c++;
  }

  // Copia para o controlador
  uint8_t pag = pagLin[l];
  copyPag (pag);
  copyPag (pag+1);
}

// Copia uma página da Ram para o controlador
void copyPag (uint8_t pag) {
  int addr = pag << 7;  // pag * 128 = pag * SSD1306_LCDWIDTH
  Display_sendcmd (SSD1306_COLUMNADDR);
  Display_sendcmd (0);
  Display_sendcmd (SSD1306_LCDWIDTH-1);
  Display_sendcmd (SSD1306_PAGEADDR);
  Display_sendcmd (pag);
  Display_sendcmd (pag);
  for (uint16_t i = 0; i < SSD1306_LCDWIDTH/16; i++) 
  {
      Wire.beginTransmission(DISP_ADDR);
      Wire.write(0x40);  // Co=0, DC = 1
      for (uint8_t x=0; x<16; x++) 
      {
          Wire.write(tela[addr++]);
      }
      Wire.endTransmission();
  }
}

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: