No post sobre a configuração do PYNQ tem uma seção chamada “Organizando os arquivos do overlay”. Ela existe porque o PYNQ só carrega o overlay se o .hwh tiver exatamente o mesmo nome do .bit, e o Vivado coloca os dois em lugares bem diferentes do projeto.

Na prática, cada vez que eu gerava um bitstream novo o ritual era o mesmo: achar o .bit dentro de impl_1, achar o .hwh dentro de hw_handoff, copiar os dois para uma pasta, renomear, e mandar para a placa por Samba ou scp. Não é difícil. É chato, e quando dá errado o erro aparece só lá no Jupyter.

Então fiz uma CLI para facilitar um pouco esse processo.

O que ele faz

O dpynq é uma CLI em Go que pega os arquivos de um projeto Vivado (.bit, .hwh, .xsa), renomeia todos para o nome base do .bit e envia para a placa por SSH/SFTP. Se o bitstream se chama system_wrapper.bit, chegam na placa system_wrapper.bit, system_wrapper.hwh e system_wrapper.xsa, que é o formato que o PYNQ espera.

O uso mais simples é rodar o comando na raiz do projeto Vivado:

dpynq

Os padrões já batem com a imagem do PYNQ (IP 192.168.2.99, usuário xilinx), mas dá para mudar tudo por flag:

dpynq --host 192.168.2.99 --user xilinx --project meu_projeto

O destino padrão é /home/<user>/jupyter_notebooks/<project>/, ou seja, os arquivos já aparecem no Jupyter. Além dessas, existem --port, --dir, --dest, --password, --key, --timeout, --no-color e --version.

Para instalar, no Linux e no macOS:

curl -fsSL https://raw.githubusercontent.com/xoesae/dpynq/main/install.sh | sh

Quem tem Go instalado pode usar go install github.com/xoesae/dpynq@latest.

Por que Go

Eu queria um único binário que funcionasse no Windows, no macOS e no Linux sem pedir nada além dele mesmo. Um script em shell resolveria no meu Linux, mas dependeria de find, ssh e scp, e no Windows isso já vira um problema.

Go resolve isso de graça com CGO_ENABLED=0 o binário sai estático, e a cross-compilação é só trocar GOOS e GOARCH. SSH e SFTP vêm de bibliotecas em Go puro (golang.org/x/crypto/ssh e pkg/sftp), então não existe dependência do ssh do sistema.

Quem publica as versões é o GoReleaser. Quando eu crio uma tag v*, um workflow do GitHub Actions compila para Linux (amd64 e arm64), macOS (amd64 e arm64) e Windows (amd64), e sobe tudo na release junto com um checksums.txt.

E também porque continuo meus estudos sobre Go 😁

Achando os arquivos certos

O .bit é o tranquilo: o dpynq procura em <projeto>.runs/impl_1/*.bit e, se não achar, aceita qualquer .bit que esteja num diretório impl_1.

O .hwh dá mais trabalho, porque o caminho depende do nome do block design e da versão do Vivado. O nome do BD sai do próprio bitstream: <bd>_wrapper.bit vira <bd>, e qualquer outro nome cai no padrão system. Com o nome em mãos, a busca segue esta ordem:

  1. <projeto>.gen/sources_1/bd/<bd>/hw_handoff/<bd>.hwh, onde o Vivado 2020.2 em diante coloca o arquivo
  2. o mesmo caminho em .srcs, para versões anteriores
  3. qualquer <bd>.hwh no projeto
  4. qualquer .hwh

A primeira versão tinha o nome system fixo. Funcionava no meu projeto e em quase todos os tutoriais, mas num projeto com dois block designs ela podia mandar o .hwh de um junto com o .bit do outro. O .hwh descreve os IPs e os endereços do hardware, então o PYNQ passaria a enxergar um hardware que não é o que está na FPGA. É o pior tipo de bug: não quebra na hora do deploy.

O .xsa é opcional. Se existir até dois níveis abaixo da raiz, vai junto, se não, o dpynq só avisa com um [INFO] e segue.

Autenticação

A imagem do PYNQ vem com usuário e senha xilinx, então o caminho óbvio seria pedir a senha sempre. Mas quem usa SSH no dia a dia já tem chave, e digitar senha a cada deploy cansa rápido.

A ordem que ficou é:

  1. chaves: as do ssh-agent e as chaves sem senha em ~/.ssh (id_ed25519, id_ecdsa, id_rsa). Com --key, só a indicada
  2. senha: --password, depois a variável DPYNQ_PASSWORD, depois um prompt no terminal.

O prompt só aparece se as chaves falharem. Rodando ssh-copy-id xilinx@192.168.2.99 uma vez, o dpynq nunca mais pede senha.

A variável de ambiente existe porque --password fica visível no ps e no histórico do shell. Para scripts e CI, DPYNQ_PASSWORD é bem menos exposta. Já chaves protegidas por senha o dpynq não tenta abrir: a mensagem pede para adicioná-las ao agent com ssh-add.

MaxAuthTries

Um detalhe curioso que acabei esbarrando é que o servidor SSH limita quantas tentativas de autenticação cada conexão pode fazer, cada chave oferecida conta como uma tentativa.

Se a mesma chave está no agent e em ~/.ssh, ela seria oferecida duas vezes. Com algumas chaves no agent, as tentativas acabam antes de chegar na senha, e o usuário recebe um erro de autenticação mesmo com a senha certa. Por isso cada chave entra uma vez só, comparando a chave pública:

internal/deploy/deploy.go
1
add := func(ss ...ssh.Signer) {
2
for _, s := range ss {
3
k := string(s.PublicKey().Marshal())
4
if !seen[k] {
5
seen[k] = true
6
signers = append(signers, s)
7
}
8
}
9
}

A chave do host não é verificada

O dpynq usa ssh.InsecureIgnoreHostKey(), ou seja, não confere se a placa é quem diz ser.

Bom, para meu caso de uso isso não importa, a placa fica na rede local (literalmente ao meu lado), e a chave do host muda toda vez que o cartão SD é regravado, o que acontece bastante quando se mexe com PYNQ. Verificar a chave faria o dpynq falhar justamente no uso normal.

Um servidor SSH dentro do teste

Para testar o deploy sem placa, os testes sobem um servidor SSH/SFTP de verdade dentro do próprio teste, com um ssh-agent em memória e HOME e SSH_AUTH_SOCK isolados. Assim dá para testar chave no agent, chave no disco, senha errada e prompt sem tocar no ~/.ssh de ninguém.

O finder é testado com 11 layouts de projeto montados em diretórios temporários. A suíte roda com -race e tem cerca de 79% de cobertura (96% no finder, 91% no deploy).

Conclusão

O dpynq surgiu de uma necessidade minha que começou como um script e virou o projeto onde eu mais aprendi sobre alguns detalhes de SSH e Golang.

Código e sugestões no GitHub.