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.
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:
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!!
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.
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:
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.
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.
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!