
* Link para a biblioteca no final do artigo. O próprio artigo descreve os mecanismos implementados na biblioteca, com detalhes médios. A implementação para o macOS ainda não está concluída, mas não é muito diferente da implementação para Linux. Isso é principalmente uma implementação para Linux.
Andando no github em uma tarde de sábado, deparei-me com uma biblioteca que implementa a atualização do código c ++ rapidamente para o windows. Eu mesmo desci do Windows há alguns anos, não me arrependi nem um pouco, e agora toda a programação é feita no Linux (em casa) ou no macOS (no trabalho). Pesquisando um pouco no Google, descobri que a abordagem da biblioteca acima é bastante popular e o msvc usa a mesma técnica para a função "Editar e continuar" no Visual Studio. O único problema é que eu não encontrei nenhuma implementação em não-janelas (eu estava mal?). Para a pergunta ao autor da biblioteca acima, se ele fará uma porta para outras plataformas, a resposta foi não.
Devo dizer imediatamente que estava interessado apenas na opção em que não precisaria alterar o código do projeto existente (como, por exemplo, no caso do RCCPP ou cr , onde todo o código potencialmente recarregado deve estar em uma biblioteca separada carregada dinamicamente).
"Como assim?" - pensei, e comecei a acender incenso.
Porque
Eu principalmente gamedev. Passo a maior parte do tempo trabalhando escrevendo a lógica do jogo e o layout de qualquer visual. Eu também uso o imgui para utilitários auxiliares. Meu ciclo de trabalho com o código, como você provavelmente adivinhou, é Write -> Compile -> Run -> Repeat. Tudo acontece muito rapidamente (construção incremental, todos os tipos de ccache, etc.). O problema aqui é que esse ciclo precisa ser repetido com frequência suficiente. Por exemplo, estou escrevendo uma nova mecânica de jogo, seja "Jump", um salto válido e controlado:
1. Escreveu um rascunho de implementação com base no momento, montado, lançado. Vi que apliquei um pulso acidentalmente em cada quadro, e não uma vez.
2. Fixo, montado, lançado, agora normal. Mas seria necessário levar mais o valor absoluto do impulso.
3. Fixo, montado, lançado, funcionando. Mas de alguma forma parece errado. É necessário tentar com base na força para fazer.
4. Escreveu um rascunho de implementação com base em trabalhos robustos, montados, lançados,. Seria necessário apenas mudar a velocidade instantânea no momento do salto.
...
10. Fixo, montado, lançado, funcionando. Mas ainda não é isso. Provavelmente precisa tentar uma implementação baseada em uma alteração no gravityScale
.
...
20. Ótimo, parece super! Agora, retiramos todos os parâmetros no editor para gamediz, test e fill.
...
30. O salto está pronto.
E a cada iteração, você precisa coletar o código e, no aplicativo iniciado, chegar ao local onde eu posso pular. Isso geralmente leva pelo menos 10 segundos. E se eu puder pular em uma área aberta, que ainda precisa ser alcançada? E se eu precisar pular em blocos com uma altura de N unidades? Aqui eu já preciso coletar uma cena de teste, que também precisa ser depurada e que também precisa gastar tempo. É para essas iterações que uma recarga quente de código seria ideal. Obviamente, isso não é uma panacéia, não é adequado para tudo e, após a reinicialização, às vezes você precisa recriar parte do mundo do jogo, e isso deve ser levado em consideração. Mas, em muitas coisas, isso pode ser útil e poupar atenção e muito tempo.
Requisitos e declaração do problema
- Ao alterar o código, a nova versão de todas as funções deve substituir as versões antigas das mesmas funções
- Isso deve funcionar no Linux e macOS
- Isso não deve exigir alterações no código do aplicativo existente.
- Idealmente, essa deve ser uma biblioteca, estaticamente ou dinamicamente vinculada ao aplicativo, sem utilitários de terceiros
- É desejável que essa biblioteca não afete muito o desempenho do aplicativo.
- Suficiente se isso funcionar com cmake + make / ninja
- Basta que funcione com as compilações da debazine (sem otimizações, sem aparar caracteres etc.)
Esse é o conjunto mínimo de requisitos que uma implementação deve atender. No futuro, descreverei brevemente o que foi implementado adicionalmente:
- Transferindo valores de variáveis estáticas para um novo código (consulte a seção "Transferindo variáveis estáticas" para descobrir por que isso é importante)
- Recarregando com base em dependências (cabeçalho alterado -> reconstruído
meio projeto todos os arquivos dependentes) - Recarregando código de bibliotecas dinâmicas
Implementação
Até aquele momento, eu estava completamente longe da área de assunto, então tive que coletar e assimilar informações do zero.
Em um nível alto, o mecanismo fica assim:
- Monitoramos o sistema de arquivos em busca de alterações na fonte
- Quando a fonte é alterada, a biblioteca a reconstrói usando o comando de compilação que este arquivo já foi compilado
- Todos os objetos coletados são vinculados a uma biblioteca carregada dinamicamente
- A biblioteca é carregada no espaço de endereço do processo
- Todas as funções da biblioteca substituem as mesmas funções no aplicativo.
- Os valores das variáveis estáticas são transferidos do aplicativo para a biblioteca
Vamos começar com o mais interessante - o mecanismo de recarregamento de funções.
Recarregando funções
Aqui estão três maneiras mais ou menos populares de substituir funções no (ou quase) tempo de execução:
- Truque com LD_PRELOAD - permite criar uma biblioteca carregada dinamicamente com, por exemplo, a função
strcpy
, e fazer com que, quando você iniciar o aplicativo, strcpy
minha versão do strcpy
vez da biblioteca - Alterar tabelas PLT e GOT - permite sobrecarregar as funções exportadas
- Hooking de função - permite redirecionar o thread de execução de uma função para outra
As 2 primeiras opções, obviamente, não são adequadas, pois funcionam apenas com funções exportadas e não queremos marcar todas as funções de nossa aplicação com nenhum atributo. Portanto, a função de enganchar é a nossa opção!
Em resumo, ligar funciona assim:
- O endereço da função foi encontrado
- Os primeiros bytes da função são substituídos por uma transição incondicional para o corpo de outra função
- ...
- Lucro!
No msvc, existem 2 sinalizadores para este - /hotpatch
e /FUNCTIONPADMIN
. O primeiro no início de cada função grava 2 bytes, que não fazem nada, para sua reescrita subsequente com um "salto curto". O segundo permite que você deixe um espaço vazio na frente do corpo de cada função na forma de instruções nop
para um "salto em distância" para o local desejado; portanto, em 2 saltos, você pode alternar da função antiga para a nova. Você pode ler mais sobre como isso é implementado no Windows e no MSVC, por exemplo, aqui .
Infelizmente, não há nada semelhante no clang e no gcc (pelo menos no Linux e no macOS). Na verdade, esse não é um problema tão grande que vamos escrever diretamente sobre a função antiga. Nesse caso, corremos o risco de ter problemas se nosso aplicativo for multithread. Se geralmente em um ambiente multithread, restringimos o acesso aos dados por um thread enquanto outro thread os modifica, precisamos limitar a capacidade de executar o código em um thread enquanto outro thread modifica esse código. Como eu não descobri como fazer isso, a implementação se comportará de maneira imprevisível em um ambiente multithread.
Há um ponto sutil. Em um sistema de 32 bits, 5 bytes são suficientes para "pularmos" para qualquer lugar. Em um sistema de 64 bits, se não queremos estragar os registros, precisamos de 14 bytes. A linha inferior é que 14 bytes na escala de código de máquina são bastante, e se o código tiver alguma função de stub com um corpo vazio, é provável que tenha menos de 14 bytes de comprimento. Não sei a verdade, mas passei algum tempo atrás do desmontador enquanto pensava, escrevia e depurava o código, e notei que todas as funções estão alinhadas em um limite de 16 bytes (compilação de depuração sem otimizações, não tenho certeza sobre o código otimizado). E isso significa que, entre o início de quaisquer duas funções, haverá pelo menos 16 bytes, o que é suficiente para "atolá-los". O Google pesquisou superficialmente aqui , no entanto, não tenho certeza, tive sorte ou hoje todos os compiladores fazem isso. Em qualquer caso, em caso de dúvida, apenas declare algumas variáveis no início da função stub para que ela se torne grande o suficiente.
Portanto, temos o primeiro grão - um mecanismo para redirecionar funções da versão antiga para a nova.
Procurar funções em um programa copiado
Agora, precisamos obter de alguma forma os endereços de todas as funções (não apenas exportadas) do nosso programa ou de uma biblioteca dinâmica arbitrária. Isso pode ser feito simplesmente usando a API do sistema se os caracteres não forem cortados do seu aplicativo. No Linux, essas são api de elf.h
link.h
, no macOS, loader.h
nlist.h
.
- Usando
dl_iterate_phdr
, percorremos todas as bibliotecas carregadas e, de fato, o programa - Encontre o endereço em que a biblioteca está carregada
- Na seção
.symtab
, obtemos todas as informações sobre os caracteres, como nome, tipo, índice da seção em que ele se encontra, tamanho e também calculamos seu endereço "real" com base no endereço virtual e no endereço de carregamento da biblioteca
Há uma sutileza. Ao baixar um arquivo elf, o sistema não carrega a seção .symtab
(corrija se estiver incorreta) e a seção .dynsym
não é adequada para nós, pois não podemos extrair caracteres com a visibilidade STV_INTERNAL
e STV_HIDDEN
. Simplificando, não veremos essas funções:
e tais variáveis:
Portanto, no parágrafo 3, não estamos trabalhando com o programa que o dl_iterate_phdr
forneceu, mas com o arquivo que dl_iterate_phdr
do disco e analisamos por algum analisador de elf (ou na API nua). Então não perdemos nada. No macOS, o procedimento é semelhante, apenas os nomes das funções da API do sistema são diferentes.
Depois disso, filtramos todos os caracteres e salvamos apenas:
- Funções que podem ser recarregadas são caracteres do tipo
STT_FUNC
localizados na seção .text
, com tamanho diferente de zero. Esse filtro ignora apenas as funções cujo código está realmente contido neste programa ou biblioteca - Variáveis estáticas cujos valores você deseja transferir são caracteres do tipo
STT_OBJECT
localizados na seção .bss
Unidades de transmissão
Para recarregar o código, precisamos saber onde obter os arquivos de código-fonte e como compilá-los.
Na primeira implementação, li essas informações na seção .debug_info
, que contém informações de depuração no formato DWARF. Para que cada unidade de compilação (ET) no DWARF obtenha uma linha de compilação para esse ET, você deve passar -grecord-gcc-switches
durante a compilação. DWARF, analisei a biblioteca libdwarf, que vem junto com o libelf
. Além do comando de compilação do DWARF, você pode obter informações sobre as dependências de nossos ETs em outros arquivos. Mas eu recusei essa implementação por vários motivos:
- Bibliotecas são bastante pesadas
- A análise de um aplicativo DWARF compilado a partir de ~ 500 ET, com análise de dependência, levou um pouco mais de 10 segundos
10 segundos para iniciar o aplicativo é demais. Após algumas reflexões, reescrevi a lógica de analisar DWARF para analisar compile_commands.json
. Esse arquivo pode ser gerado simplesmente adicionando set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
ao seu CMakeLists.txt. Assim, obtemos todas as informações que precisamos.
Manipulação de Dependências
Desde que abandonamos o DWARF, precisamos encontrar outra opção, como lidar com dependências entre arquivos. Eu realmente não queria analisar arquivos com minhas mãos e procurar include
neles, e quem sabe mais sobre dependências do que o próprio compilador?
Existem várias opções no clang e no gcc que geram os chamados depfiles quase de graça. Esses arquivos usam os sistemas de criação make e ninja para resolver dependências entre arquivos. Depfiles têm um formato muito simples:
CMakeFiles/lib_efsw.dir/libs/efsw/src/efsw/DirectorySnapshot.cpp.o: \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/base.hpp \ /home/ddovod/_private/_projects/jet/live/libs/efsw/src/efsw/sophist.h \ /home/ddovod/_private/_projects/jet/live/libs/efsw/include/efsw/efsw.hpp \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/c++/7.3.0/string \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/c++config.h \ /usr/bin/../lib/gcc/x86_64-linux-gnu/7.3.0/../../../../include/x86_64-linux-gnu/c++/7.3.0/bits/os_defines.h \ ...
O compilador coloca esses arquivos ao lado dos arquivos de objeto para cada ET, resta analisá-los e colocá-los em um hashmap. A análise total de compile_commands.json
+ depfiles para os mesmos 500 ET leva um pouco mais de 1 segundo. Para que tudo funcione, precisamos adicionar o sinalizador -MD
globalmente para todos os arquivos de projeto na opção de compilação.
Há uma sutileza associada ao ninja. Esse sistema de construção gera depfiles, independentemente da presença do sinalizador -MD
para suas necessidades. Mas, depois que são gerados, ele os converte em seu formato binário e exclui os arquivos de origem. Portanto, ao iniciar o ninja, você deve passar o sinalizador -d keepdepfile
. Além disso, por motivos desconhecidos para mim, no caso do make (com a opção some_file.cpp.d
), o arquivo é chamado some_file.cpp.d
, enquanto no ninja é chamado some_file.cpp.od
. Portanto, você precisa verificar as duas versões.
Transferência variável estática
Suponha que tenhamos esse código (um exemplo muito sintético):
Queremos alterar a função veryUsefulFunction
para isso:
int veryUsefulFunction(int value) { return value * 3; }
Ao recarregar, na biblioteca dinâmica com novo código, além de veryUsefulFunction
, a variável static Singleton ins;
e o método Singletor::instance
. Como resultado, o programa começará a chamar novas versões de ambas as funções. Mas o estático estático nesta biblioteca ainda não foi inicializado e, portanto, na primeira vez em que for acessado, o construtor da classe Singleton
será chamado. Claro, não queremos isso. Portanto, a implementação transfere os valores de todas essas variáveis que encontra na biblioteca dinâmica montada do código antigo para essa biblioteca muito dinâmica com o novo código junto com suas variáveis de guarda .
Há um momento sutil e geralmente insolúvel.
Suponha que tenhamos uma classe:
class SomeClass { public: void calledEachUpdate() { m_someVar1++; } private: int m_someVar1 = 0; };
O método calledEachUpdate
chamado 60 vezes por segundo. Nós o alteramos adicionando um novo campo:
class SomeClass { public: void calledEachUpdate() { m_someVar1++; m_someVar2++; } private: int m_someVar1 = 0; int m_someVar2 = 0; };
Se uma instância dessa classe estiver localizada na memória dinâmica ou na pilha, após recarregar o código, é provável que o aplicativo falhe. A instância alocada contém apenas a variável m_someVar1
, mas após a reinicialização, o método calledEachUpdate
tentará alterar m_someVar2
, alterando o que realmente não pertence a esta instância, o que leva a consequências imprevisíveis. Nesse caso, a lógica de transferência de estado é transferida para o programador, que deve, de alguma forma, salvar o estado do objeto e excluir o próprio objeto antes que o código seja recarregado, e criar um novo objeto após a reinicialização. A biblioteca fornece eventos nos métodos delegados onCodePostLoad
e onCodePostLoad
que o aplicativo pode processar.
Não sei como (e se) é possível resolver essa situação de uma maneira geral, pensarei. Agora, este caso "mais ou menos normal" funcionará apenas para variáveis estáticas, ele usa a seguinte lógica:
void* oldVarPtr = ...; void* newVarPtr = ...; size_t oldVarSize = ...; size_t newVarSize = ...; memcpy(newVarPtr, oldVarPtr, std::min(oldVarSize, newVarSize));
Isso não está muito correto, mas é o melhor que eu criei.
Como resultado, o código se comportará de forma imprevisível se o tempo de execução alterar o conjunto e o layout dos campos nas estruturas de dados. O mesmo se aplica aos tipos polimórficos.
Juntando tudo
Como tudo funciona juntos.
- A biblioteca itera sobre os cabeçalhos de todas as bibliotecas carregadas dinamicamente no processo e, de fato, o próprio programa analisa e filtra caracteres.
- Em seguida, a biblioteca tenta localizar o arquivo
compile_commands.json
no diretório do aplicativo e nos diretórios pai recursivamente e extrai de lá todas as informações necessárias sobre o ET. - Conhecendo o caminho para arquivos de objetos, a biblioteca carrega e analisa depfiles.
- Depois disso, o diretório mais comum para todos os arquivos de código fonte do programa é calculado e o monitoramento desse diretório começa recursivamente.
- Quando um arquivo é alterado, a biblioteca verifica se está no hashmap de dependências e, se houver, inicia vários processos de compilação dos arquivos alterados e suas dependências em segundo plano, usando os comandos de compilação de
compile_commands.json
. - Quando o programa solicita que você recarregue o código (no meu aplicativo, a combinação
Ctrl+r
é atribuída a isso), a biblioteca aguarda a conclusão dos processos de compilação e vincula todos os novos objetos à biblioteca dinâmica. - Essa biblioteca é então carregada no espaço de endereço do processo
dlopen
função dlopen
. - As informações sobre símbolos são carregadas nesta biblioteca e toda a interseção do conjunto de símbolos dessa biblioteca e dos símbolos que já vivem no processo é recarregada (se for uma função) ou transferida (se for uma variável estática).
Isso funciona muito bem, especialmente quando você sabe o que está por trás e o que esperar, pelo menos em um nível alto.
Pessoalmente, fiquei muito surpreso com a falta dessa solução para Linux, alguém está realmente interessado nisso?
Ficarei feliz em qualquer crítica, obrigado!
Link para implementação