O modelo mental do Nix (pra quem vive em bare metal)
Derivations, store, flakes e por que "funciona na minha máquina" é especialmente catastrófico quando a máquina é um Cortex-M4 no campo.
Dev novo entra no time. Primeiro dia: um PDF com 47 passos pra configurar o ambiente (se vc tiver essa sorte toda). Instala o Simplicity Studio, baixa o Gecko SDK 4.3.2 (não o 4.4, que mudou headers do BLE stack), configura o PATH pro ARM GCC que o time usa (versão de 5 anos atrás), seta variáveis de ambiente do CMake. Três dias depois, o primeiro build compila.
Duas semanas depois, tudo quebra. Alguém atualizou o GCC na máquina de build sem avisar. GCC 12.2 e 12.3 geram código diferente pro Cortex-M4. O handler de interrupção não salva um registrador que deveria. O bug aparece no campo três meses depois, sem stack trace, sem log, sem nenhum caminho de volta pro compilador errado.
“Funciona na minha máquina” é irritante em web. Em embedded, é catastrófico.
Nix resolve isso. Não parcialmente, tipo Docker. Resolve de verdade, com garantia matemática. E esse post explica o modelo mental que faz Nix funcionar, sem assumir que você já sabe o que é uma derivation ou um flake.
Derivations como funções puras
A ideia central do Nix veio de uma tese de PhD de 2006 que propôs tratar package management como memory management: o filesystem é a memória, pacotes são valores, e instalar software é alocar memória. A consequência prática dessa ideia é que cada pacote no Nix é descrito por uma derivation, que funciona como uma função pura.
Uma derivation especifica: um builder (o executável que roda o build), argumentos, variáveis de ambiente e referências a outros arquivos no Nix store. Se você já escreveu um Makefile, a derivation é conceitualmente parecida com um target. A diferença é que o “target” roda num sandbox isolado, sem acesso a nada que não foi declarado explicitamente. Nenhuma dependência implícita, nenhum estado global, nenhum “ah, mas eu tinha o Python 3.11 instalado no sistema”.
A propriedade que importa: se os inputs não mudam, o output é idêntico. Sempre. Não “geralmente”. Não “na maioria dos casos”. Sempre.
O Nix store e a identidade por hash
Todo pacote buildado pelo Nix vive em /nix/store/ com um path assim:
/nix/store/ixrr0fcxbp91w73kfsfh8pmxahfi54cr-gcc-arm-embedded-14.2.rel1
Aquele ixrr0fcxbp91w73kfsfh8pmxahfi54cr é um hash de 32 caracteres que captura todo o grafo de dependências do build. Source code, versão do compilador, flags, patches, tudo. Se qualquer input muda (incluindo um comentário no source, incluindo uma flag de otimização), o hash muda e o pacote vai pra um path completamente diferente.
Pacotes nunca são sobrescritos. Nunca. O GCC 12.2 e o GCC 12.3 coexistem no store com paths diferentes, e nenhum interfere no outro. É imutabilidade aplicada ao filesystem.
O cálculo do hash é deliberadamente pessimista. O Nix serializa a derivation inteira num formato chamado ATerm, limpa os atributos de output, computa SHA-256 do resultado, comprime pra 20 bytes via XOR folding, e encoda em base32. Inputs idênticos geram o mesmo hash. Inputs minimamente diferentes geram hashes completamente diferentes. Não existe “quase igual”.
/nix/store/m36izdi2zraf3ijk5bizqs37lj89rs8l-gcc-arm-embedded-14.2.rel1
Mude qualquer input. O hash muda completamente. Não existe "quase igual".
A analogia mais útil é pensar no Nix store como um Git pra binários. Git usa content-addressable storage (o hash do conteúdo determina onde o arquivo fica). O Nix store usa input-addressable storage (o hash dos inputs determina onde o output fica). Múltiplas versões coexistem sem conflito. Rollback é trivial, basta apontar pro hash antigo. E assim como git log mostra o histórico, nix-store --query --tree mostra o grafo completo de dependências de qualquer pacote.
A analogia quebra num ponto: Git hasheia o output, Nix hasheia os inputs. Dois builds com inputs diferentes mas que por acaso geram o mesmo binário ocupam paths diferentes no store. Na prática isso raramente importa, mas é bom saber.
E o garbage collection? Tudo sob /nix/var/nix/gcroots é uma raiz. Profiles e suas gerações são raízes. nix-collect-garbage --delete-older-than 30d remove gerações antigas e libera espaço. Sem garbage collection disciplinado, o store cresce rápido (2-3 GB só pra um toolchain ARM com OpenOCD e CMake), então vale configurar isso cedo.
Flakes
Antes de flakes, o Nix tinha um problema sério de reproducibilidade irônica: uma ferramenta que prometia builds reprodutíveis não conseguia garantir que dois devs executando o mesmo nix-shell obtivessem o mesmo ambiente. O motivo era que nix-shell usava channels (basicamente um ponteiro pro estado do nixpkgs naquele momento), e channels podiam mudar a qualquer hora. Não tinha lock file. Não tinha estrutura padrão. A avaliação podia referenciar <nixpkgs>, que resolvia pro channel local do usuário.
Flakes resolvem os três problemas de uma vez: lock file automático, estrutura obrigatória, avaliação pura.
Um flake é um diretório com um arquivo flake.nix que segue essa estrutura:
{
description = "Firmware EFR32BG21";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
in {
devShells.default = pkgs.mkShell {
packages = [
pkgs.gcc-arm-embedded
pkgs.cmake
pkgs.openocd
];
};
});
}
inputs são as dependências do flake. Cada uma é pinada no flake.lock, um JSON que captura o git hash exato, o narHash e o timestamp de cada input. Dois devs em lados opostos do mundo rodam nix develop e obtêm exatamente o mesmo ambiente. Não “provavelmente o mesmo”. Exatamente o mesmo.
outputs é uma função que recebe os inputs resolvidos e produz um attribute set. Os outputs típicos pra embedded são devShells (ambientes de desenvolvimento), packages (firmware compilado) e overlays (extensões pra nixpkgs, tipo vendor SDKs).
O flake.lock é gerado automaticamente na primeira vez que você roda nix build, nix develop ou nix flake lock. Deve ser commitado no git. nix flake update atualiza todos os inputs pra versão mais recente e regenera o lock.
Se você já usou Cargo.lock, package-lock.json ou poetry.lock, o flake.lock é o equivalente, mas pra todo o ambiente de desenvolvimento. Não só as libs de uma linguagem. O compilador, o linker, o debugger, o flasher, o SDK. Tudo pinado num único arquivo.
Clique nas seções coloridas pra inspecionar
← clique numa seção
A Nix Flake, Line by Line
devShells, packages e overlays
Esses três conceitos aparecem como outputs de um flake, e cada um serve pra uma coisa diferente.
devShells são ambientes de desenvolvimento. Quando você roda nix develop, o Nix monta um shell com todas as ferramentas declaradas no PATH. Pra embedded, é o que você usa no dia a dia: arm-none-eabi-gcc, cmake, openocd, gdb. O devShell não produz nenhum artefato. Ele é o workspace.
packages são derivations que produzem outputs concretos. No contexto embedded, o package é o firmware compilado: o .elf, o .bin, o .hex. nix build executa a derivation e gera o resultado em ./result, que é um symlink pro path no Nix store. Quando alguém diz “firmware como Nix package”, quer dizer que nix build produz o binário pronto pra flash, bit-a-bit idêntico toda vez.
overlays são funções que modificam ou estendem nixpkgs. A assinatura é (final: prev: { ... }), onde final é nixpkgs com o overlay aplicado e prev é nixpkgs sem. Pra que serve isso? Pra empacotar SDKs vendor como pacotes Nix reutilizáveis. Quer que o Gecko SDK da Silicon Labs seja um pacote Nix que qualquer projeto do time pode importar? Overlay. Precisa de um OpenOCD com patch pra suportar um chip específico? Overlay. Quer que a modificação se propague pra todas as dependências transitivas? Overlay.
Na prática do dia a dia:
- devShells pra trabalhar
- packages pra builds reprodutíveis (CI, releases)
- overlays pra vendor SDKs e ferramentas customizadas
Primeiro contato
O caminho mais rápido pra sentir o Nix funcionando é criar um flake.nix no root de um projeto embedded existente. O exemplo mínimo com gcc-arm-embedded, cmake e openocd já basta pra um projeto STM32 ou EFR32 que usa CMake.
Com o direnv configurado, o workflow fica automático. Um .envrc com use flake no root do projeto faz o ambiente ser carregado toda vez que você entra no diretório. O editor detecta as ferramentas via PATH. cmake -B build && cmake --build build compila normalmente. openocd -f interface/stlink.cfg -f target/stm32g4x.cfg -c "program build/firmware.elf verify reset exit" flasha. arm-none-eabi-gdb build/firmware.elf debugga.
O workflow é o mesmo de sempre (edit, build, flash, debug), só que agora é reprodutível. Qualquer pessoa que clonar o repositório e rodar nix develop (ou simplesmente cd no diretório com direnv) vai ter exatamente as mesmas ferramentas, nas mesmas versões. Sem wiki de onboarding com 47 passos. Sem “ah, você precisa do GCC 12.2, não o 12.3”.
O shellHook é onde você coloca configurações que precisam acontecer quando o shell inicia:
devShells.default = pkgs.mkShell {
packages = [ pkgs.gcc-arm-embedded pkgs.cmake pkgs.openocd ];
shellHook = ''
export CMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake
'';
};
Variáveis de ambiente, aliases, mensagens de boas-vindas. O que você colocaria num .bashrc do projeto, mas declarativo e versionado.
Uma nota sobre o pacote gcc-arm-embedded especificamente: nixpkgs tem duas formas de obter o ARM GCC. A primeira é pkgs.gcc-arm-embedded, que é a redistribuição binária oficial da ARM, inclui newlib-nano e multilib, e é o que a maioria dos tutoriais e vendor SDKs assume. A segunda é pkgsCross.arm-embedded.buildPackages.gcc, que compila do source usando nixpkgs e usa newlib padrão (sem nano). Pra projetos que usam --specs=nano.specs (a maioria dos projetos embedded reais), use gcc-arm-embedded. A história de como esses dois pacotes coexistem é longa e cheia de idas e voltas, mas o resultado prático é que o binary package é o mais battle-tested pro caso de uso embedded.
Nix-shell for embedded development environments
Por que Docker não resolve
A primeira reação de qualquer time quando enfrenta problemas de toolchain é Docker. E Docker resolve parcialmente. Você cria um Dockerfile, coloca o GCC, o CMake, o OpenOCD, faz docker build, e agora todo mundo tem “o mesmo ambiente”. Até a próxima vez que alguém roda docker build.
O problema é que o mesmo Dockerfile pode gerar imagens diferentes. apt-get install gcc-arm-none-eabi baixa a versão que estiver no repositório naquele momento. Se alguém buildou a imagem em janeiro e outro em março, e o repositório atualizou o pacote entre esses meses, as imagens são diferentes. Mesmo Dockerfile, builds diferentes. Você pode pinar versões no apt-get, mas alguém sempre esquece, e as imagens base (ubuntu:22.04) mudam silenciosamente.
Nix elimina essa classe inteira de problemas. Cada input é checksumado. O flake.lock captura o estado exato do universo de dependências. Dois builds com o mesmo flake.lock produzem o mesmo resultado, bit a bit.
Tem outra diferença que pesa pra embedded: Docker virtualiza. Containers rodam num namespace isolado do kernel. Nix apenas gerencia paths. Não tem overhead de kernel, não tem mapeamento de USB passthrough pra acessar o debugger, não tem layer de filesystem pra adicionar latência. nix develop te dá um shell no seu sistema, com acesso direto ao hardware. Pra quem precisa plugar um ST-Link e flashar um chip, isso faz diferença.
A comparação justa: Docker é bom pra distribuir ambientes de execução. Nix é melhor pra garantir que builds são reprodutíveis. Pra CI de firmware, os dois se complementam (roda Nix dentro de Docker pra isolar o ambiente de CI e ter builds reprodutíveis dentro dele). Mas como ferramenta de desenvolvimento local pra embedded, Nix ganha de lavada.
Passe o mouse sobre as linhas pra ver a correspondência
Docker vs Nix: The Quest for True Reproducibility
De volta ao GCC 12.2
Aquele bug sutil do GCC 12.3 que apareceu no campo três meses depois? Com Nix, ele não teria acontecido. Não porque Nix previne bugs de compilador, mas porque a versão do compilador é declarada, pinada e versionada junto com o código. O CI builda com exatamente o mesmo toolchain que o dev. O dev novo que entrou no time ontem builda com exatamente o mesmo toolchain que o dev que saiu há seis meses. nix flake update atualiza o toolchain quando o time decide, atomicamente, pra todos.
O investimento pra chegar aqui é real. Nix tem uma linguagem funcional com lazy evaluation, conceitos novos (derivations, store, closures), e error messages que frequentemente não ajudam. A documentação é espalhada e às vezes desatualizada. Pra embedded devs acostumados com C, Makefiles e pragmatismo, é uma curva de aprendizado íngreme. O trade-off é pagar esse investimento uma vez e nunca mais perder uma tarde debugando por que o build do CI não reproduz o bug que aparece na máquina do colega.
No próximo post, a gente olha pro que muda na prática quando um time de firmware adota Nix. Onboarding, CI, workflow no dia a dia, e as perguntas que todo mundo faz: “e o meu editor?”, “e o Windows?”, “quanto esforço?”.
prtzl/stm32