Divagações sobre Programação e Eletrônica

Logging em 8kHz em um ESP32-S3 no Arduino: executando o programa da PSRAM (XiP)

NNeil Bowen11 min de leitura

O IMU do Jupiter está migrando para SPI de 10MHz na próxima placa para que possa registrar a 8kHz, acima dos 2kHz que consegue em I2C atualmente. No Rocketry Forum alguém levantou uma questão legítima sobre se um ESP32-S3 a 240MHz realmente consegue "registrar" a 8kHz, em vez de apenas ler o sensor nessa velocidade. É uma pergunta válida e, embora eu estivesse confiante de que conseguíríamos fazer isso, ainda não tinha verificado, então em vez de adivinhar fizemos uma medição apropriada. Isto é o que descobrimos, e no final há a biblioteca Arduino reconstruída que acabamos por usar, se quiser experimentar em seus próprios projetos.

Não é realmente a velocidade do relógio

O que impede a maioria dos projetos ESP32 de registrar rapidamente não é a CPU, é a flash. Em uma compilação normal, o programa é executado diretamente da mesma chip de flash em que você está registrando, através de um pequeno cache. Toda vez que você escreve nessa flash, o ESP desativa o cache em ambos os núcleos até que a escrita seja concluída, portanto qualquer código que não esteja já sentado na RAM interna simplesmente para. Não desacelera, para. Uma escrita de página dura cerca de um milissegundo, e apagar um bloco de 64KB pode congelar tudo por 100 a 200ms. Para um computador de voo executando um loop de taxa fixa, esse é o verdadeiro problema.

O que estamos registrando

Cada amostra do IMU tem o giroscópio como três inteiros de 16 bits e a aceleração como três inteiros de 24 bits, os acelerômetros de baixa e alta g fundidos em uma única leitura, então 15 bytes por amostra.

Começamos planejando armazenar a aceleração fundida como floats, mas inteiros se mostraram o melhor ajuste. A unidade é exatamente 1/2048 g, que é o próprio passo do acelerômetro de baixa g em sua faixa de 16g (o 0.488mg da ST é esse número arredondado), portanto suas leituras caem em números inteiros e nada do que o sensor mede é perdido. Convertê-lo de volta para g é uma divisão por 2048, que é exata, e 24 bits cobrem aproximadamente ±4000g, bem mais do que os 256g que a faixa de alta g precisa. São 3 bytes por eixo em vez de 4, e diferentemente de um float, pode ser codificado em delta depois se quisermos (mais sobre isso abaixo).

Todo o resto que o Jupiter registra (o estado de voo de 500Hz, baro, magnetômetro, GNSS, energia e assim por diante) adiciona cerca de 20kB/s qualquer que seja a taxa do IMU, portanto quanto mais rápido vamos, menor é a parcela dos dados que isso representa: 40% a 2kHz, um quarto a 4kHz e apenas 14% a 8kHz.

Taxa de IMU Dados de IMU (kB/s) Outros dados (kB/s) Total (kB/s) Com delta (kB/s)
2kHz 30 20 50 42
4kHz 60 20 80 64
8kHz 120 20 140 108

A cifra de outros dados é aproximada. A coluna com delta é nossa estimativa do total para flash no boost, onde a vibração do motor torna a codificação delta menos eficaz, com apenas os dados do IMU codificados em delta. Na plataforma e em voo planado seria ainda menor. Há mais sobre codificação delta mais abaixo.

Como o Jupiter registra

  • O IMU agrupa suas amostras em seu próprio FIFO, portanto o loop não está fazendo 8000 leituras por segundo. Ele é executado a 500Hz e puxa tudo o que está esperando em um lote a cada passagem, que são 16 amostras por passagem a 8kHz.
  • Cada amostra bruta vai para buffers circulares no PSRAM. Nada é mexido, os anéis são blocos fixos de memória com posições de leitura e escrita, e o logger apenas caminha por eles no lugar. Também significa que os dados de pré-lançamento já estão lá quando o lançamento é detectado.
  • Um pequeno buffer de 4KB na RAM interna reúne os registros e os escreve em uma partição flash bruta dedicada. Essa partição é apagada antes do voo, portanto em voo são apenas escritas de página, sem apagamentos e sem sistema de arquivos no caminho.
  • Depois do pouso, comprime todo o voo em um único arquivo, transmitido para que nunca precise do voo inteiro na memória, qualquer que seja a taxa em que foi registrado.

Tudo isso ainda esbarra no problema da flash acima, porém, que é onde a próxima parte entra.

Executando o programa a partir do PSRAM

O ESP32-S3 pode copiar o programa no PSRAM na inicialização e executá-lo de lá em vez da flash (Espressif chama de XIP do PSRAM). Uma vez que esteja fazendo isso, uma escrita em flash não precisa mais desativar o cache, portanto o loop continua enquanto a flash é escrita. O problema para usuários do Arduino é que é uma configuração não utilizada nas bibliotecas pré-compiladas da Espressif, portanto não é algo que você possa ativar a partir de seu sketch, então reconstruímos as bibliotecas do S3 para o núcleo do Arduino com ela ativada.

ECC, porque foguetes esquentam

Enquanto estávamos nisso, ativamos ECC para o PSRAM. Espressif classifica o PSRAM octal no S3R8 para 65C, e 85C com ECC ativado. 65C parece muito, mas um foguete escuro sentado em uma plataforma ao sol fica surpreendentemente quente por dentro, portanto para um computador de voo pareceu o lugar sensato para começar. Custa um décimo sexto do PSRAM, que podemos facilmente poupar.
Vale notar que esta é a classificação de temperatura ambiente, não a classificação de temperatura interna do chip. E como o Jupiter não usa RF (Wifi/BT etc) provavelmente se estenderá um pouco mais.

Mais um pequeno: onde o buffer de escrita fica

Descobrimos que o buffer de escrita de 4KB também importa. Se estiver no PSRAM, o driver de flash ESP-IDF copia através de um buffer minúsculo de 32 bytes e escreve cada 32 bytes como um comando separado para o chip de flash. Se estiver na RAM interna, ele passa diretamente, 64 bytes por comando, que é o máximo que o controlador de flash do S3 aceitará de uma vez. Cada comando custa aproximadamente o mesmo qualquer que seja o que ele carrega, portanto metade dos comandos é quase o dobro da velocidade: em plena velocidade medimos 209kB/s do PSRAM e 335kB/s da RAM interna. 4KB de RAM interna não é nada, portanto esse foi uma mudança fácil.

O teste

Adicionamos um teste ao firmware que escreve o buffer de voo da mesma forma que o logger de voo faz enquanto o loop de 500Hz continua funcionando, então relata o quanto o loop foi prejudicado. Ele escreve a 55kB/s, depois 166kB/s, depois em plena velocidade, depois apaga tudo o que escreveu. Essas duas taxas foram estabelecidas a partir de nosso plano anterior de float (18 bytes por amostra) para logging de 2kHz e 8kHz, portanto são um pouco acima do que o formato inteiro precisa, o que torna o teste um pouco mais difícil que a vida real. Para deixar claro uma coisa, o IMU nesta placa ainda está em I2C a 2kHz, portanto as fases mais rápidas são a carga de flash de logging mais rápido, não amostragem mais rápida. Mesma placa, mesmo teste, apenas as bibliotecas Arduino trocadas.

Cada célula é o núcleo Arduino padrão primeiro, depois nossas bibliotecas reconstruídas com o programa no PSRAM e ECC ativado. O loop deveria funcionar a 500Hz, uma passagem a cada 2ms, e o pior intervalo é o tempo mais longo entre duas passagens.

Enquanto a flash está Taxa de loop Pior intervalo Transbordos de FIFO
Escrevendo a 55kB/s 349 → 499Hz 15.8 → 5.4ms 42 → 0
Escrevendo a 166kB/s 193 → 499Hz 15.0 → 5.2ms 77 → 0
Escrevendo em plena velocidade 196 → 499Hz 12.9 → 4.6ms 24 → 0
Apagando (blocos de 64KB) 17 → 499Hz 202 → 4.9ms 3 → 0

No núcleo padrão, o loop cai aos pedaços assim que a flash fica ocupada, ele nem conseguia acompanhar a fase de 166kB/s (conseguiu 160), e o apagamento o para por 200ms. Cada transbordamento é o FIFO do IMU enchendo e amostras sendo perdidas. Com o programa no PSRAM, mantém 499Hz através de tudo isso, incluindo o apagamento. O pior intervalo de cerca de 5ms é apenas a passagem mais lenta do loop (ele faz isso sem nenhuma escrita de flash acontecendo), o FIFO do IMU absorve, e nada é perdido.

Codificação delta

Codificação delta significa armazenar o quanto cada leitura mudou desde a anterior em vez da própria leitura. Nessas taxas, uma amostra é muito próxima da próxima, portanto a maioria das mudanças cabe em um ou dois bytes em vez de dois ou três. É aqui que os inteiros ganham seu valor: a mudança entre dois inteiros é ela mesma um pequeno inteiro, enquanto os bits de um float não diminuem quando o valor mal se move. Em passos de 1/2048 g, uma mudança de até cerca de 4g entre amostras cabe em 2 bytes.

O quanto economiza depende do que o foguete está fazendo. Sentado na plataforma ou em voo planado, a maioria das amostras cairia para 6 a 8 bytes, mas no boost com o motor fazendo tudo vibrar é mais como 11, e o boost é o que importa pois é onde a taxa de logging está em seu pico. Essa é nossa estimativa aproximada em vez de uma medição por enquanto. O Jupiter não codifica em delta o buffer de voo no momento, pois não precisa para velocidade. Onde ajudaria é no tempo de gravação: o buffer de voo de 7.8MB tem cerca de 58 segundos a 8kHz completo bruto, ou cerca de 75 segundos codificados em delta, antes do logging híbrido abaixar a taxa.

Então quão rápido poderia registrar?

Juntando as velocidades de flash medidas com os tamanhos de dados acima fornece o máximo que cada configuração consegue manter:

Configuração Flash, em plena velocidade (kB/s) Taxa máxima, bruto / delta (kHz) Dados de 8kHz, bruto / delta (kB/s e parcela de flash)
Núcleo padrão, buffer no PSRAM* 160 9 / 12 140 / 108
88% / 68%
Núcleo padrão, buffer na RAM interna* 306 19 / 25 140 / 108
46% / 35%
Bibliotecas reconstruídas, buffer no PSRAM 209 12 / 17 140 / 108
67% / 52%
Bibliotecas reconstruídas, buffer na RAM interna (o Jupiter) 335 21 / 28 140 / 108
42% / 32%

* A flash acompanha no núcleo padrão mas o loop não. Caiu abaixo de 200Hz e perdeu amostras do IMU em cada taxa que tentamos, portanto na prática não consegue registrar limpo a nenhuma dessas.

Bruto é 15 bytes por amostra, codificado em delta é nossa estimativa de boost de 11 bytes, ambos incluem os 20kB/s de tudo mais, e são as velocidades em plena velocidade medidas com o loop funcionando, arredondadas para baixo sem nada guardado de sobra. O barramento SPI de 10MHz também não é o limite em nenhuma dessas, poderia carregar em torno de 59.000 amostras por segundo.

Portanto 8kHz precisa de cerca de 140kB/s contra os 335kB/s que a flash suporta em plena velocidade, cerca de 42% sem nenhuma codificação delta, ou cerca de 108kB/s (32%) com ela. A CPU também não está trabalhando muito: a 2kHz a maior parte de cada passagem do loop é gasta esperando no barramento I2C, e no SPI de 10MHz a mesma leitura a 8kHz deveria levar cerca de 0.3ms. O próprio IMU limita-se a 8kHz de qualquer forma, portanto o resto é margem. Ainda é teste de bancada até a placa SPI chegar, e os padrões serão mais baixos de qualquer forma, com o logging abaixando sua taxa após o boost, mas o S3 parece bem preparado para isso.

Experimente você mesmo

Já que fizemos o trabalho pesado, as bibliotecas ESP32-S3 reconstruídas estão nos downloads no final desta postagem para qualquer um que esteja usando Arduino. Elas são para o esp32 core 3.3.12 do Boards Manager apenas, e substituem esta pasta:

Windows  %LOCALAPPDATA%\Arduino15\packages\esp32\tools\esp32s3-libs\3.3.12
Mac      ~/Library/Arduino15/packages/esp32/tools/esp32s3-libs/3.3.12
Linux    ~/.arduino15/packages/esp32/tools/esp32s3-libs/3.3.12

No Windows, feche a IDE Arduino, depois abra um Prompt de Comando (digite cmd no menu Iniciar, não PowerShell) e cole isto. Encontra o zip na sua pasta Downloads qualquer que seja o que o download adicionou ao seu nome, e mantém sua pasta original como 3.3.12.stock:

cd /d "%LOCALAPPDATA%\Arduino15\packages\esp32\tools\esp32s3-libs"
ren 3.3.12 3.3.12.stock && for %f in ("%USERPROFILE%\Downloads\*esp32s3-libs-3.3.12-xip-ecc*.zip") do tar -xf "%f"
rmdir /s /q "%LOCALAPPDATA%\arduino\cores"

A última linha limpa a cópia em cache da IDE do núcleo compilado para que se recompile contra as novas bibliotecas. Para voltar ao padrão:

cd /d "%LOCALAPPDATA%\Arduino15\packages\esp32\tools\esp32s3-libs"
ren 3.3.12 3.3.12.xip-ecc && ren 3.3.12.stock 3.3.12
rmdir /s /q "%LOCALAPPDATA%\arduino\cores"

Não use Extrair Tudo do Explorer no zip, alguns dos caminhos dentro são muito longos e será silenciosamente ignorado arquivos. O comando acima ou 7-Zip são bons.

Algumas coisas a saber:

  • Testamos em um S3 com 8MB de PSRAM octal (o S3R8), com PSRAM definido para OPI PSRAM no menu Ferramentas. Não testamos em PSRAM quad, e como o programa é executado a partir do PSRAM não servirá para placas sem nenhum.
  • O programa é copiado no PSRAM na inicialização, portanto você perde essa quantidade de PSRAM, mais um décimo sexto disso para ECC.
  • Foi compilado com o esp32-arduino-lib-builder da Espressif no ESP-IDF 5.5.5, o mesmo das bibliotecas oficiais 3.3.12, com apenas duas configurações adicionadas. Todo o crédito vai para Espressif. É não oficial e como está, portanto mantenha sua pasta stock.

Se preferir compilá-lo você mesmo, é o esp32-arduino-lib-builder da Espressif (Linux ou WSL), verificado nas versões listadas no arquivo versions.txt na pasta de bibliotecas oficiais 3.3.12, com estas duas linhas adicionadas a configs/defconfig.esp32s3 antes de executar a compilação:

CONFIG_SPIRAM_XIP_FROM_PSRAM=y
CONFIG_SPIRAM_ECC_ENABLE=y