CardOS: Agora compilando sem Arduino!

Traduções: en
Data de publicação: 31/07/2024
Rótulos: cardputer, esp32, so

No artigo "CardOS: Escrevendo um SO para o Cardputer" eu falei sobre o SO que eu estou escrevendo para o Cardputer e que o próximo passo era remover a dependência da toolchain do Arduino. Levou dois meses mas finalmente eu consegui. O produto final foi esse commit mas eu gostaria de descrever o processo para chegar até aqui neste artigo.

Eu comecei rodando os comandos do Arduino para compilar e gravar com saída verbose habilitada e salvando essa saída em um arquivo para usar de referência. Assim eu conseguiria ver exatamente o que o Arduino estava fazendo e entender as etapas necessárias para ir do código-fonte até o binário gravado na memória da placa.

Então eu fiz as mudanças óbvias para converter de Arduino para C: Eu renomeei o único arquivo de código-fonte do projeto de cardOS.ino para cardOS.c, converti as funções setup e loop em uma função main, e mudei a etapa de compilação no meu Makefile para ao invés de chamar o Arduino, chamar o compilador cruzado de C para a ESP32 (xtensa-esp32s3-elf-gcc), que já havia sido instalado na minha máquina pelo Arduino e cujo caminho eu descobri pela saída do Arduino.

Em seguida eu rodei a compilação, e consertei cada erro que o compilador gerou. Especificamente, eu tive que adicionar alguns includes, protótipos de funções, mudar o jeito que algumas variáveis eram definidas e passar o parâmetro -fno-builtin para o compilador para que ele me deixasse definir minhas próprias funções da stdlib. Feito isso, eu agora tinha um arquivo ELF e precisava descobrir como gravar ele na placa.

Lendo por cima a saída do Arduino, eu aprendi que esptool.py era o comando usado para converter o arquivo ELF em um binário, e que ele era chamado de novo para gravar o binário na placa. Além do código da aplicação, outras coisas também estavam sendo gravadas: um bootloader e uma tabela de partição. Para não complicar, eu fiz um pequeno teste para verificar se eu podia ignorar eles por enquanto (ou seja, presumir que eles já estavam gravados): Eu fiz uma pequena mudança no código do SO e usei a toolchain do Arduino para compilar e gravar apenas ele e vi que a mudança apareceu no Cardputer, então a resposta era sim.

Com isso em mente, eu atualizei o Makefile para chamar o esptool.py para converter o ELF gerado pelo compilador e então gravar na placa. A esta altura, eu já tinha resolvido (eu achava) todo o procedimento para ir do código-fonte até o binário gravado na placa, então eu rodei ele com make upload. Mas não funcionou, a tela no Cardputer nem ligava.

Eu percebi que presumir que todo o código fosse funcionar de cara no novo procedimento era muito otimista e decidi simplificar o teste o máximo possível: Eu mudei o código na main para apenas ligar o pino GPIO1 e conectei o pino a um LED. Mas ainda assim, o LED não ligou.

Foi aqui que eu fiquei travado por um tempo. O problema estava claramente no binário da aplicação em algum lugar. Minhas duas teorias eram que ou o binário em si tinha algum problema, ou algum código de inicialização estava faltando, já que o Arduino incluia vários arquivos adicionais durante a compilação.

Configurando os endereços no arquivo ELF

Na esperança de que o problema era no binário em si, já que identificar código de inicialização do Arduino que estivesse fazendo falta parecia mais difícil, eu decidi começar a investigar ele.

Eu usei o readelf para ver o conteúdo tanto do meu ELF quando do gerado pelo Arduino e comparei eles. A maior differença no header foi essa:

Meu:

Entry point address:               0x40017d

Arduino:

Entry point address:               0x40376778

O ELF do Arduino tinha muito mais código, então eu já esperava que ele fosse ter endereços maiores, mas essa diferença era grande demais. Parecia que algum endereço de base diferente estava sendo usado.

Eu pesquisei o significado do "Entry point address" em um ELF e confirmei que esse é o endereço de onde começa a execução. Então errar esse endereço com certeza faria meu código nem executar.

Olhando mais adiante no conteúdo do ELF:

Meu:

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x000000 0x00400000 0x00400000 0x0183c 0x0183c R E 0x1000
  LOAD           0x00183c 0x0040283c 0x0040283c 0x001a1 0x0075c RW  0x1000

 Section to Segment mapping:
  Segment Sections...
   00     .text .rodata .eh_frame
   01     .ctors .dtors .data .bss

Arduino:

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001020 0x3c000020 0x3c000020 0x2a678 0x2a678 RW  0x1000
  LOAD           0x02c000 0x3fc88000 0x3fc88000 0x0c018 0x0d8a8 RW  0x1000
  LOAD           0x039000 0x40374000 0x40374000 0x0ca97 0x0ca98 RWE 0x1000
  LOAD           0x046020 0x42000020 0x42000020 0x1f347 0x1f347 R E 0x1000
  LOAD           0x000000 0x50000000 0x50000000 0x00000 0x00010 RW  0x1000

 Section to Segment mapping:
  Segment Sections...
   00     .flash_rodata_dummy .flash.appdesc .flash.rodata
   01     .dram0.dummy .dram0.data .dram0.bss
   02     .iram0.vectors .iram0.text .iram0.data
   03     .flash.text
   04     .rtc_noinit

Depois de ler na internet sobre program headers e sections em um ELF, tudo isso começou a fazer sentido.

Olhando em "program headers" no ELF do Arduino, realmente há um endereço muito próximo do "entry point address": 0x40374000 no segmento 2. Esse é o endereço de base que eu estava suspeitando. O código que começa a executar então deve estar na seção iram0.text, já que seções com text no nome representam código e essa é a que está mapeada ao segmento 2. As perguntas que vem à mente são:

  • Por que esse endereço é usado?
  • Como eu configuro esse endereço no meu arquivo ELF?

Para responder a primeira pergunta, eu fui olhar no manual do ESP32-S3. A figura 4-1 mostra um diagrama de como cada região de memória é mapeada. O endereço inicial do segmento usado para o código de entrada no Arduino, 0x40374000, está dentro da região 0x40370000 - 0x403dffff que está mapeada à SRAM através de um barramento de instruções, o que faz todo sentido!

Mas lendo mais adiante, a tabela 4-1 subdivide as regiões de memória e dá o nome de "Internal SRAM0" para a região contendo 0x40374000. Em sua descrição, é mencionado que os primeiros 16KB do espaço podem ser reservados como cache para instruções armazenados na memória flash. Fazendo as contas, isso significa que a memória usável para instruções começa em... 0x40374000, exatamente o endereço que foi usado para o segmento contendo código no ELF do Arduino! Então isso explica de onde esse endereço veio.

Em resumo, o problema que eu descobri foi que o arquivo ELF que eu estava gerando tinha o código associado a endereços que não mapeiam para uma região de memória que pode ser acessada pelo processador do ESP32 para ler instruções (ou seja, acessível através de um barramento de instruções) e por isso não podia ser executado.

O que estava faltando era responder a segunda pergunta: Como eu configuro o endereço certo no meu arquivo ELF?

Isso era um grande lacuna no meu conhecimento, eu não fazia ideia. Mas olhando na saída do Arduino, eu vi que o compilador estava sendo passado alguns arquivos .ld através de um parâmetro -T. Dentro de um deles chamado memory.ld eu encontrei alguns endereços sendo definidos! A definição do parâmetro -T no manual do compilador revelou que esses arquivos eram linker scripts, então eu já sabia a próxima coisa que eu precisava aprender.

Eu encontrei esse excelente artigo sobre linker scripts que me ensinou tudo que eu precisava saber. Com essa informação eu consegui escrever meu linker script e associar os endereços corretos a cada seção do ELF: Não só para o código (.text), mas também para as variáveis inicializadas em zero (.bss) e outras variáveis (.data), cujos endereços eu descobri do mesmo jeito olhando o manual e o ELF do Arduino. Algumas seções adicionais também tiveram que ser declaradas para resolver erros no linker.

Como você pode ter reparado nos conteúdos dos ELF, o do Arduino tem duas seções de código, iram0.text e flash.text, enquanto o meu só tem uma, .text. Por esse motivo eu inicialmente decidi apontar minha seção .text para a memória flash já que ela tem muito mais espaço. Porém o Cardputer ainda não mostrou nenhum sinal de vida. Já que eu percebi que meu código era pequeno o suficiente para caber inteiramente na SRAM, e o código do Arduino começava na SRAM, eu decidi experimentar isso, e funcionou!!

{image}/led.jpg

A documentação do procedimento de inicialização da aplicação do ESP-IDF descreve o que o bootloader do segundo estágio (que é o que eu ainda estou usando do Arduino) faz e não faz, e ela menciona que é responsabilidade da aplicação acabar de configurar a MMU da memória flash. Esse provavelmente é o motivo pelo qual usar o endereço da memória flash para o código não funcionou e porque o Arduino divide o código entre uma parte na SRAM e outra na flash. Então em algum momento eu vou precisar configurar o acesso a flash, mas por enquanto a SRAM era o suficiente.

Código de inicialização

Agora que pelo menos o LED estava ligando, eu voltei o código para a funcionalidade normal, ou seja, inicializar a tela, renderizar o shell e ler o teclado.

A tela começou a ser limpa como de costume mas parou no meio do caminho. Eu removi a rotina de limpeza de tela temporariamente e o prompt do shell apareceu na tela. Eu conseguia escrever mas depois de um curto intervalo a minha posição voltava para a inicial. Eu percebi que o Cardputer estava sendo resetado depois um intervalo preciso de tempo.

Eu lembrei da documentação do procedimento de inicialização da aplicação que o bootloader habilita o watchdog. E como agora o código da aplicação era totalmente providenciado por mim, eu tinha que cuidar disso eu mesmo: ou continuamente resetar o watchdog ou desabilitar ele.

Eu escolhi disabilitar o watchdog por simplicidade (como sempre), e depois de ler a seção sobre watchdog do manual do ESP32-S3 e adicionar algumas escritas de registradores ao código, estava feito: o sistema não resetava mais. Isso permitiu que o shell permanecesse na tela, mas ele estava cheio de lixo:

{image}/bss.jpg

Mais uma vez, lembrando da documentação do procedimento de inicialização da aplicação, o código da aplicação (eu!) é o responsável por inicializar a seção .bss para zero. Como essa seção contém as variáveis que devem ser inicializadas em zero, se eu não inicializar ela, todas essas variáveis vão conter lixo, que é o que eu estava vendo aqui.

Eu não sabia um jeito bom de fazer isso, mas eu sabia como fazer com gambiarra: listar manualmente todas as variáveis globais em uma função e zerar elas 🙈. E foi isso que eu fiz e funcionou! (Nota: De lá para cá eu aprendi o jeito certo de fazer e limpei a gambiarra nesse commit)

E com isso o SO estava finalmente funcional e sem precisar do Arduino para compilar! E essa é a história completa por trás do commit "Move from Arduino to generic C build flow".

Porém ainda tinha uma diferença de antes da mudança, ele estava muito mais lento. Isso é porque, pela última vez olhando na página de procedimento de inicialização da aplicação, a aplicação que deve configurar a frequência do clock da CPU para o valor desejado. A frequência padrão é bem lenta, mas o código de inicialização implicitamente adicionado pelo Arduino configurava ela para um valor mais alto. Eu consertei isso em um commit subsequente.

Outras melhorias

Com a remoção do Arduino feita, eu finalmente pude fazer algumas melhorias que eu queria há muito tempo.

A primeira coisa que eu fiz foi separar o código-fonte que era em um único arquivo em vários arquivos diferentes nesse commit. Eu estava muito ansioso para essa mudança e me senti muito bem em fazê-la 😌. Agora o código está muito mais organizado, é mais fácil encontrar as coisas e focar em um único componente.

Eu também habilitei todos os principais warnings do compilador nesse commit, incluindo um warning sobre fallthrough implícito em case switches que teria me salvo alguns minutos investigando um bug no começo desse projeto.

Conclusão

Esse foi mais um grande passo para o projeto. Eu me diverti bastante e aprendi muito.

O próximo grande passo é implementar leitura e escrita no cartão SD e um sistema de arquivos. Provavelmente vai levar um bom tempo até eu acabar isso e voltar com uma atualização no blog. Fique à vontade para checar o repositório para acompanhar as últimas atualizações no meio tempo se tiver curiosidade!