Neste artigo, gostaria de falar sobre os recursos de implementação de uma interface gráfica de usuário com widgets em um microcontrolador e como ter uma interface de usuário familiar e um FPS decente. Eu gostaria de me concentrar não em qualquer biblioteca gráfica específica, mas em coisas gerais - memória, cache do processador, dma e assim por diante. Como sou um desenvolvedor da equipe Embox , os exemplos e experimentos estarão neste sistema operacional RT.
Anteriormente, já falamos sobre a execução da biblioteca Qt em um microcontrolador . A animação acabou sendo bastante suave, mas ao mesmo tempo os custos de memória, mesmo para armazenar o firmware, eram significativos - o código foi executado a partir da memória flash externa QSPI. Claro que quando uma interface complexa e multifuncional é necessária, que também saiba fazer algum tipo de animação, então o custo dos recursos de hardware pode ser bastante justificado (principalmente se você já tem esse código desenvolvido para Qt).
Mas e se você não precisar de todas as funcionalidades do Qt? E se você tiver quatro botões, um controle de volume e alguns menus pop-up? Ao mesmo tempo, você deseja que “tenha uma boa aparência e funcione rápido” :) Então será aconselhável usar ferramentas mais leves, por exemplo, a biblioteca lvgl ou similar.
Em nosso projeto Embox algum tempo atrás, Nuklear foi portado - um projeto para criar uma biblioteca muito leve consistindo em um cabeçalho e permitindo que você crie facilmente uma GUI simples. Decidimos utilizá-lo para criar um pequeno aplicativo no qual haverá um widget com um conjunto de elementos gráficos e que poderá ser controlado via touchscreen.
STM32F7-Discovery com Cortex-M7 e tela sensível ao toque foi escolhido como plataforma.
Primeiras otimizações. Economizar memória
Portanto, a biblioteca de gráficos é selecionada e a plataforma também. Agora vamos entender quais são os recursos. É importante notar aqui que a SRAM da memória principal é várias vezes mais rápida do que a SDRAM externa, então se o tamanho da tela permitir, é claro que é melhor colocar o framebuffer na SRAM. Nossa tela tem resolução de 480x272. Se quisermos uma cor de 4 bytes por pixel, teremos cerca de 512 KB. Ao mesmo tempo, o tamanho da RAM interna é de apenas 320 e fica imediatamente claro que a memória de vídeo será externa. Outra opção é reduzir a profundidade de bits de cor para 16 (ou seja, 2 bytes) e, assim, reduzir o consumo de memória para 256 KB, que já pode caber na RAM principal.
A primeira coisa que você pode tentar é economizar em tudo. Vamos fazer um buffer de vídeo de 256 Kb, colocá-lo na RAM e desenhar nele. O problema que encontramos imediatamente foi a “oscilação” da cena que ocorre ao desenhar diretamente na memória de vídeo. O Nuklear redesenha toda a cena do zero, portanto, sempre que a tela inteira for preenchida primeiro, o widget será desenhado, um botão será colocado nele, no qual o texto será colocado e assim por diante. Como resultado, a olho nu pode ver como toda a cena é redesenhada e a imagem “pisca”. Ou seja, uma simples colocação na memória interna não salva.
Tampão intermediário. Otimizações do compilador. FPU
Depois de mexermos um pouco no método anterior (colocação na memória interna), as memórias do X Server e do Wayland imediatamente começaram a vir à mente. Sim, de fato, os gerenciadores de janela estão envolvidos no processamento de solicitações de clientes (apenas nosso aplicativo personalizado) e, em seguida, coletam os elementos para a cena final. Por exemplo, o kernel do Linux envia eventos de dispositivos de entrada para o servidor por meio do driver evdev. O servidor, por sua vez, determina qual cliente abordará o evento. Os clientes, tendo recebido um evento (por exemplo, pressionando uma tela de toque), executam sua lógica interna - eles destacam o botão, exibem um novo menu. Além disso (de forma um pouco diferente para X e Wayland), o próprio cliente ou o servidor desenha as alterações no buffer. E então o compositor está juntando todas as peças para desenhar na tela.Explicação bastante simples e esquemática aquiaqui .
Ficou claro que precisamos de uma lógica semelhante, mas realmente não queremos colocar o X Server no stm32 por causa de um pequeno aplicativo. Portanto, vamos tentar desenhar não na memória de vídeo, mas na memória comum. Depois de renderizar a cena inteira, ele copiará o buffer para a memória de vídeo.
Código do widget
if (nk_begin(&rawfb->ctx, "Demo", nk_rect(50, 50, 200, 200),
NK_WINDOW_BORDER|NK_WINDOW_MOVABLE|
NK_WINDOW_CLOSABLE|NK_WINDOW_MINIMIZABLE|NK_WINDOW_TITLE)) {
enum {EASY, HARD};
static int op = EASY;
static int property = 20;
static float value = 0.6f;
if (mouse->type == INPUT_DEV_TOUCHSCREEN) {
/* Do not show cursor when using touchscreen */
nk_style_hide_cursor(&rawfb->ctx);
}
nk_layout_row_static(&rawfb->ctx, 30, 80, 1);
if (nk_button_label(&rawfb->ctx, "button"))
fprintf(stdout, "button pressed\n");
nk_layout_row_dynamic(&rawfb->ctx, 30, 2);
if (nk_option_label(&rawfb->ctx, "easy", op == EASY)) op = EASY;
if (nk_option_label(&rawfb->ctx, "hard", op == HARD)) op = HARD;
nk_layout_row_dynamic(&rawfb->ctx, 25, 1);
nk_property_int(&rawfb->ctx, "Compression:", 0, &property, 100, 10, 1);
nk_layout_row_begin(&rawfb->ctx, NK_STATIC, 30, 2);
{
nk_layout_row_push(&rawfb->ctx, 50);
nk_label(&rawfb->ctx, "Volume:", NK_TEXT_LEFT);
nk_layout_row_push(&rawfb->ctx, 110);
nk_slider_float(&rawfb->ctx, 0, &value, 1.0f, 0.1f);
}
nk_layout_row_end(&rawfb->ctx);
}
nk_end(&rawfb->ctx);
if (nk_window_is_closed(&rawfb->ctx, "Demo")) break;
/* Draw framebuffer */
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);
memcpy(fb_info->screen_base, fb_buf, width * height * bpp);
Este exemplo cria uma janela de 200 x 200 px e desenha gráficos nela. A cena final em si é desenhada no buffer fb_buf, que alocamos para SDRAM. E então, na última linha, memcpy é simplesmente chamado. E tudo se repete em um ciclo sem fim.
Se apenas construirmos e executarmos este exemplo, obteremos cerca de 10-15 FPS. O que certamente não é muito bom, porque é perceptível até mesmo a olho nu. Além disso, como o código de renderização do Nuklear contém muitos cálculos de ponto flutuante, habilitamos seu suporte inicialmente , sem ele o FPS teria sido ainda menor. A primeira e mais simples otimização (gratuita) é, obviamente, o sinalizador do compilador -O2.
Vamos construir e executar o mesmo exemplo - obtemos 20 FPS. Melhor, mas ainda não o suficiente para um bom trabalho.
Habilitando caches de processador. Modo Write-Through
Antes de passar para outras otimizações, direi que estamos usando o plugin rawfb como parte do Nuklear, que desenha diretamente na memória. Conseqüentemente, a otimização da memória parece muito promissora. A primeira coisa que vem à mente é o cache.
Em versões mais antigas do Cortex-M, como o Cortex-M7 (nosso caso), um cache de processador adicional (cache de instrução e cache de dados) é integrado. É habilitado através do registro CCR do Bloco de Controle do Sistema. Mas, com a inclusão do cache, surgem novos problemas - a inconsistência de dados no cache e na memória. Existem várias maneiras de gerenciar o cache, mas neste artigo não irei me alongar sobre elas, portanto, passarei para uma das mais simples, em minha opinião. Para resolver o problema de inconsistência de cache / memória, podemos simplesmente marcar toda a memória disponível como “não armazenável em cache”. Isso significa que todas as gravações nesta memória irão sempre para a memória e não para o cache. Mas se marcarmos toda a memória dessa forma, também não haverá ponto no cache. Existe outra opção. Este é um modo de "passagem", no qual todas as gravações na memória marcadas como gravação são enviadas simultaneamente para o cache,e na memória. Isso cria uma sobrecarga de gravação, mas, por outro lado, acelera muito a leitura, de modo que o resultado dependerá do aplicativo específico.
Para Nuklear, o modo write-through acabou sendo muito bom - o desempenho subiu de 20 FPS para 45 FPS, o que por si só já é muito bom e suave. O efeito é claro que interessante, até tentamos desabilitar o modo write through, sem prestar atenção à inconsistência dos dados, mas o FPS subiu apenas para 50 FPS, ou seja, não houve aumento significativo em comparação com write through. A partir disso, concluímos que nosso aplicativo requer muitas operações de leitura, não gravações. A questão é, claro, onde? Talvez por causa do número de transformações no código rawfb, que costuma acessar a memória para ler o próximo coeficiente ou algo parecido.
Buffer duplo (até agora com um buffer intermediário). Habilitando DMA
Eu não queria parar nos 45 FPS, então decidimos fazer mais experiências. A próxima ideia foi o buffer duplo. A ideia é amplamente conhecida e, em geral, simples. Desenhamos a cena usando um dispositivo para um buffer, enquanto o outro dispositivo exibe a partir de outro buffer. Se você olhar o código anterior, poderá ver claramente um loop no qual a cena é primeiro desenhada no buffer e, em seguida, o conteúdo é copiado para a memória de vídeo usando memcpy. É claro que memcpy usa CPU, ou seja, a renderização e a cópia acontecem sequencialmente. Nossa ideia era que a cópia pudesse ser feita em paralelo usando DMA. Em outras palavras, enquanto o processador desenha uma nova cena, o DMA copia a cena anterior na memória de vídeo.
Memcpy foi substituído pelo seguinte código:
while (dma_in_progress()) {
}
ret = dma_transfer((uint32_t) fb_info->screen_base,
(uint32_t) fb_buf[fb_buf_idx], (width * height * bpp) / 4);
if (ret < 0) {
printf("DMA transfer failed\n");
}
fb_buf_idx = (fb_buf_idx + 1) % 2;
Aqui fb_buf_idx é inserido - o índice do buffer. fb_buf_idx = 0 é o buffer frontal, fb_buf_idx = 1 é o buffer traseiro. A função dma_transfer () leva o destino, a origem e um número de palavras de 32 bits. Em seguida, o DMA é carregado com os dados necessários e o trabalho continua com o próximo buffer.
Depois de tentar este mecanismo, o desempenho aumentou para cerca de 48 FPS. Um pouco melhor que memcpy (), mas apenas ligeiramente. Não quero dizer que o DMA acabou sendo inútil, mas neste exemplo particular, o impacto do cache no quadro geral se mostrou melhor.
Depois de uma pequena surpresa de que o DMA teve um desempenho pior do que o esperado, surgiu uma “excelente”, como nos pareceu então, a ideia de usar vários canais de DMA. Qual é o ponto? O número de dados que podem ser carregados no DMA de uma vez em stm32f7xx é 256 KB. Ao mesmo tempo, lembre-se de que nossa tela tem 480x272 e a memória de vídeo tem cerca de 512 KB, o que significa que você pode colocar a primeira metade dos dados em um canal DMA e a segunda metade no segundo. E tudo parece estar bem ... Mas o desempenho cai de 48 FPS para 25-30 FPS. Ou seja, estamos voltando à situação em que o cache ainda não foi habilitado. Com o que pode ser conectado? Na verdade, devido ao fato de que o acesso à memória SDRAM é sincronizado, até mesmo a memória é chamada de Memória de Acesso Aleatório Dinâmico Síncrono (SDRAM), então esta opção apenas adiciona sincronização adicional,sem fazer a gravação na memória paralela, conforme desejado. Depois de um pouco de reflexão, percebemos que não há nada de surpreendente aqui, porque a memória é uma, e os ciclos de escrita e leitura são gerados para um microcircuito (em um barramento), e como outra fonte / receptor é adicionada, então o árbitro, que resolve as chamadas no barramento , você precisa misturar ciclos de comando de diferentes canais de DMA.
Buffer duplo. Trabalhando com LTDC
Copiar de um buffer intermediário é certamente bom, mas como descobrimos, isso não é suficiente. Vamos dar uma olhada em outra melhoria óbvia - buffer duplo. Na grande maioria dos controladores de vídeo modernos, você pode definir o endereço da memória de vídeo usada. Assim, você pode evitar a cópia por completo e simplesmente reorganizar o endereço da memória de vídeo para o buffer preparado, e o controlador de tela pegará os dados da maneira ideal por conta própria via DMA. Este é um buffer duplo real, sem um buffer intermediário como era antes. Também existe uma opção quando o controlador de exibição pode ter dois ou mais buffers, o que é essencialmente a mesma coisa - gravamos em um buffer e o outro é usado pelo controlador, enquanto a cópia não é necessária.
O LTDC (controlador de exibição LCD-TFT) em stm32f74xx tem duas camadas de sobreposição de hardware - Camada 1 e Camada 2, onde a Camada 2 é sobreposta na Camada 1. Cada uma das camadas pode ser configurada independentemente e pode ser ativada ou desativada separadamente. Tentamos habilitar apenas a camada 1 e reorganizar o endereço da memória de vídeo no buffer frontal ou no buffer traseiro. Ou seja, damos um para o display, e no outro desenhamos neste momento. Mas tivemos um tremor perceptível ao alternar as sobreposições.
Tentamos a opção quando usamos as duas camadas com uma delas ligada / desligada, ou seja, quando cada camada tem seu próprio endereço de memória de vídeo, que não muda, e o buffer é alterado ligando uma das camadas enquanto desliga a outra. A variação também resultou em jitter. E por fim, tentamos a opção quando a camada não estava desligada, mas o canal alfa estava configurado para zero 0 ou máximo (255), ou seja, controlamos a transparência, tornando uma das camadas invisível. Mas esta opção não correspondeu às expectativas, o tremor ainda estava presente.
O motivo não estava claro - a documentação diz que as atualizações de estado da camada podem ser executadas em tempo real. Fizemos um teste simples - desligamos os caches, ponto flutuante, desenhamos uma imagem estática com um quadrado verde no centro da tela, o mesmo para a Camada 1 e Camada 2, e começamos a alternar os níveis em um loop, na esperança de obter uma imagem estática. Mas tivemos o mesmo tremor novamente.
Ficou claro que era outra coisa. E então nos lembramos do alinhamento do endereço do framebuffer na memória. Como os buffers foram alocados no heap e seus endereços não foram alinhados, alinhamos seus endereços em 1 KB - obtivemos a imagem esperada sem jitter. Em seguida, eles descobriram na documentação que o LTDC subtrai dados em lotes de 64 bytes e que a irregularidade dos dados causa uma perda significativa de desempenho. Neste caso, tanto o endereço do início do framebuffer quanto sua largura devem estar alinhados. Para testar, alteramos a largura 480x4 para 470x4, que não é divisível por 64 bytes, e obtivemos o mesmo jitter.
Como resultado, alinhamos os dois buffers em 64 bytes, certificamo-nos de que a largura também estava alinhada em 64 bytes e executamos nuklear - o jitter desapareceu. A solução que funcionou é parecida com esta. Em vez de alternar entre as camadas desabilitando completamente a Camada 1 ou a Camada, use transparência. Ou seja, para desativar o nível, defina sua transparência como 0 e, para ativá-lo, como 255.
BSP_LCD_SetTransparency_NoReload(fb_buf_idx, 0xff);
fb_buf_idx = (fb_buf_idx + 1) % 2;
BSP_LCD_SetTransparency(fb_buf_idx, 0x00);
Temos 70-75 FPS! Muito melhor do que o original 15.
Deve-se notar que a solução funciona por meio de controle de transparência, e as opções com desabilitar um dos níveis e a opção de reorganizar o endereço do nível dão o jitter da imagem em FPS grande 40-50, o motivo atualmente é desconhecido para nós. Além disso, avançando, direi que esta é uma solução para esta placa.
Preenchimento de cena de hardware via DMA2D
Mas este não é o limite, nossa última otimização para aumentar o FPS é o preenchimento da cena do hardware. Antes disso, fazíamos o preenchimento programaticamente:
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);
Vamos agora dizer ao plugin rawfb que não há necessidade de preencher a cena, mas apenas pintar:
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 0);
Vamos preencher o cenário com a mesma cor 0xff303030, apenas em hardware via controlador DMA2D. Uma das principais funções do DMA2D é copiar ou preencher um retângulo na RAM. A principal comodidade aqui é que não se trata de um pedaço contínuo de memória, mas de uma área retangular, que está localizada na memória com intervalos, o que significa que o DMA comum não pode ser feito imediatamente. Na Embox, ainda não trabalhamos com este dispositivo, então vamos apenas usar as ferramentas STM32Cube - a função BSP_LCD_Clear (uint32_t Color). Ele programa a cor de preenchimento e o tamanho de toda a tela em DMA2D.
Período de supressão vertical (VBLANK)
Mas mesmo a 80 FPS, um problema perceptível permaneceu - partes do widget moviam-se com pequenas “pausas” ao se mover pela tela. Ou seja, o widget parecia estar dividido em 3 (ou mais) partes que se moviam lado a lado, mas com um pequeno atraso. Descobriu-se que o motivo era uma atualização incorreta da memória de vídeo. Mais precisamente, atualizações em intervalos de tempo errados.
O controlador de exibição tem uma propriedade como VBLANK, também é VBI ou período de apagamento vertical . Ele denota o intervalo de tempo entre os quadros de vídeo adjacentes. Ou mais precisamente, o tempo entre a última linha do quadro de vídeo anterior e a primeira linha do próximo. Nesse intervalo, nenhum dado novo é transferido para o display, a imagem fica estática. Por esse motivo, é seguro atualizar a memória de vídeo dentro do VBLANK.
Na prática, o controlador LTDC tem uma interrupção que é configurada para ser disparada após o processamento da próxima linha de framebuffer (registro de configuração de posição de interrupção de linha LTDC (LTDC_LIPCR)). Assim, se você configurar esta interrupção para o último número da linha, então obteremos apenas o início do intervalo VBLANK. Neste ponto, fazemos a troca de buffer necessária.
Como resultado de tais ações, a imagem voltou ao normal, as lacunas desapareceram. Mas ao mesmo tempo o FPS caiu de 80 para 60. Vamos entender qual pode ser a razão para esse comportamento.
A seguinte fórmula pode ser encontrada na documentação :
LCD_CLK (MHz) = total_screen_size * refresh_rate,
onde total_screen_size = total_width x total_height. LCD_CLK é a frequência na qual o controlador de vídeo carregará pixels da memória de vídeo para a tela (por exemplo, via Display Serial Interface (DSI)). Mas refresh_rate já é a taxa de atualização da própria tela, sua característica física. Acontece que, sabendo a taxa de atualização da tela e suas dimensões, você pode configurar a frequência para o controlador de exibição. Após verificar os registros da configuração que o STM32Cube cria, descobrimos que ele ajusta o controlador para uma tela de 60 Hz. Então, tudo veio junto.
Um pouco sobre os dispositivos de entrada em nosso exemplo
Voltemos ao nosso aplicativo e vejamos como funciona a touchscreen, pois como você entende, uma interface moderna implica em interatividade, ou seja, interação com o usuário.
Tudo está organizado de forma bastante simples aqui. Os eventos dos dispositivos de entrada são processados no loop do programa principal imediatamente antes de renderizar a cena:
/* Input */
nk_input_begin(&rawfb->ctx);
{
switch (mouse->type) {
case INPUT_DEV_MOUSE:
handle_mouse(mouse, fb_info, rawfb);
break;
case INPUT_DEV_TOUCHSCREEN:
handle_touchscreen(mouse, fb_info, rawfb);
break;
default:
/* Unreachable */
break;
}
}
nk_input_end(&rawfb->ctx);
O próprio tratamento de eventos da tela de toque ocorre na função handle_touchscreen ():
handle_touchscreen
static void handle_touchscreen(struct input_dev *ts, struct fb_info *fb_info,
struct rawfb_context *rawfb) {
struct input_event ev;
int type;
static int x = 0, y = 0;
while (0 <= input_dev_event(ts, &ev)) {
type = ev.type & ~TS_EVENT_NEXT;
switch (type) {
case TS_TOUCH_1:
x = normalize_coord((ev.value >> 16) & 0xffff, 0, fb_info->var.xres);
y = normalize_coord(ev.value & 0xffff, 0, fb_info->var.yres);
nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 1);
nk_input_motion(&rawfb->ctx, x, y);
break;
case TS_TOUCH_1_RELEASED:
nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 0);
break;
default:
break;
}
}
}
Na verdade, é aqui que os eventos do dispositivo de entrada são convertidos em um formato que a Nuklear entende. Na verdade, provavelmente isso é tudo.
Lançar em outra placa
Tendo recebido resultados bastante decentes, decidimos reproduzi-los em outra placa. Tínhamos outra placa semelhante - STM32F769I-DISCO. Existe o mesmo controlador LTDC, mas uma tela diferente com resolução de 800x480. Após o lançamento, obteve 25 FPS. Ou seja, uma queda perceptível no desempenho. Isso é facilmente explicado pelo tamanho do framebuffer - é quase 3 vezes maior. Mas o principal problema acabou sendo outro - a imagem estava muito distorcida, não havia imagem estática no momento em que o widget deveria estar em um lugar.
O motivo não estava claro, então examinamos os exemplos padrão do STM32Cube. Houve um exemplo com buffer duplo para esta placa em particular. Neste exemplo, os desenvolvedores, ao contrário do método com mudança de transparência, simplesmente movem o ponteiro para o framebuffer na interrupção VBLANK. Já tentamos esse método antes para a primeira placa, mas não funcionou. Mas usando este método para STM32F769I-DISCO, obtivemos uma mudança de imagem bastante suave de 25 FPS.
Encantados, testamos este método novamente (com ponteiros de reorganização) na primeira placa, mas ainda não funcionou em FPS alto. Como resultado, o método com transparências de camada (60 FPS) funciona em uma placa, e o método com ponteiros de reorganização (25 FPS) na outra. Depois de discutir a situação, decidimos adiar a unificação até um estudo mais profundo da pilha de gráficos.
Resultado
Então, vamos resumir. O exemplo mostrado representa um padrão de GUI simples, mas comum para microcontroladores - alguns botões, um controle de volume ou algo mais. O exemplo carece de qualquer lógica associada a eventos, pois a ênfase foi colocada nos gráficos. Em termos de desempenho, obtivemos um valor de FPS bastante decente.
As nuances acumuladas para otimizar o desempenho levam à conclusão de que os gráficos estão se tornando mais complicados nos microcontroladores modernos. Agora, assim como em grandes plataformas, você precisa monitorar o cache do processador, colocar algo na memória externa e algo na memória mais rápida, usar DMA, usar DMA2D, monitorar VBLANK e assim por diante. Tudo começou a parecer grandes plataformas, e talvez seja por isso que já me referi ao X Server e ao Wayland várias vezes.
Talvez uma das partes menos otimizadas seja a própria renderização, redesenhamos toda a cena do zero, inteiramente. Não posso dizer como isso é feito em outras bibliotecas para microcontroladores, talvez em algum lugar esse estágio esteja embutido na própria biblioteca. Mas com base nos resultados de trabalhar com Nuklear, parece que neste local é necessário um análogo do X Server ou Wayland, claro, mais leve, o que novamente nos leva à ideia de que os pequenos sistemas seguem o caminho dos grandes.
UPD1
Como resultado, não foi necessário o método com a mudança da transparência. Em ambas as placas, um código comum funcionou - com a troca do endereço do buffer por v-sync. Além disso, o método com transparências também é correto, simplesmente não é necessário.
UPD2
Quero agradecer muito a todas as pessoas que sugeriram o buffer triplo, ainda não chegamos a ele. Mas agora você pode ver que esta é a maneira clássica (especialmente para FPS de altas taxas de quadros para a tela), que, entre outras coisas, nos permitirá eliminar atrasos devido à espera pela v-sync (ou seja, quando o software está visivelmente à frente da imagem). Ainda não encontramos isso, mas é apenas uma questão de tempo. E um agradecimento especial pela discussão sobre buffer triplo que quero dizerBesitzeruf e belav!
Nossos contatos:
Github: https://github.com/embox/embox
Boletim informativo: embox-ru [at] googlegroups.com
Bate-papo por telegrama: t.me/embox_chat