
* Link para a biblioteca e vídeo de demonstração no final do artigo. Para entender o que está acontecendo e quem são essas pessoas, recomendo a leitura do artigo anterior .
No último artigo, nos familiarizamos com uma abordagem que permite uma recarga quente do código c ++. "Código", neste caso, são funções, dados e seu trabalho coordenado entre si. Não há problemas especiais com funções, redirecionamos o fluxo de execução da função antiga para a nova e tudo funciona. O problema surge com os dados (variáveis estáticas e globais), nomeadamente com a estratégia de sincronização no código antigo e no novo. Na primeira implementação, essa estratégia era muito desajeitada: simplesmente copiamos os valores de todas as variáveis estáticas do código antigo para o novo, para que o novo código, referente às novas variáveis, trabalhe com os valores do código antigo. É claro que isso está incorreto, e hoje tentaremos corrigir essa falha resolvendo simultaneamente vários problemas pequenos, mas interessantes.
O artigo omite detalhes sobre o trabalho mecânico, como a leitura de caracteres e realocações de arquivos elf e mach-o. A ênfase está nos pontos sutis que encontrei no processo de implementação e que podem ser úteis para alguém que, como eu recentemente, está procurando respostas.
Essence
Vamos imaginar que temos uma classe (exemplos sintéticos, por favor, não procure significado neles, apenas o código é importante):
Nada de especial além de uma variável estática. Agora imagine que queremos alterar o método printDescription()
para:
void Entity::printDescription() { std::cout << "DESCRIPTION: " << m_description << std::endl; }
O que acontece após o recarregamento do código? Além dos métodos da classe Entity
, a variável estática m_livingEntitiesCount
também entra na biblioteca com o novo código. Nada de ruim acontecerá se simplesmente copiarmos o valor dessa variável do código antigo para o novo e continuarmos a usar a nova variável, esquecendo a antiga, porque todos os métodos que usam essa variável diretamente estão na biblioteca com o novo código.
C ++ é muito flexível e rico. E embora a elegância de resolver alguns problemas nas bordas de c ++ no código de mau cheiro, eu adoro essa linguagem. Por exemplo, imagine que seu projeto não use rtti. Ao mesmo tempo, você precisa ter uma implementação da classe Any
com uma interface um tanto segura quanto ao tipo:
class Any { public: template <typename T> explicit Any(T&& value) { ... } template <typename T> bool is() const { ... } template <typename T> T& as() { ... } };
Não entraremos em detalhes da implementação desta classe. O que é importante para nós é que, para implementação, precisamos de algum tipo de mecanismo para mapeamento inequívoco do tipo (entidade em tempo de compilação) para o valor de uma variável, por exemplo, uint64_t
(entidade em tempo de execução), ou seja, "enumere" tipos. Ao usar o rtti, coisas como type_info
e, mais adequado para nós, type_index
estão disponíveis para nós. Mas não temos rtti. Nesse caso, um hack bastante comum (ou solução elegante?) É esta função:
template <typename T> uint64_t typeId() { static char someVar; return reinterpret_cast<uint64_t>(&someVar); }
Em seguida, a implementação da classe Any
será mais ou menos assim:
class Any { public: template <typename T> explicit Any(T&& value) : m_typeId(typeId<std::decay<T>::type>())
Para cada tipo, a função será instanciada exatamente 1 vez, respectivamente, cada versão da função terá sua própria variável estática, obviamente com seu próprio endereço exclusivo. O que acontece quando recarregamos o código usando esta função? As chamadas para a versão antiga da função serão redirecionadas para a nova. O novo terá sua própria variável estática já inicializada (copiamos a variável value e guard). Mas não estamos interessados no significado, usamos apenas o endereço. E o endereço da nova variável será diferente. Assim, os dados se tornaram inconsistentes: nas instâncias já criadas da classe Any
, o endereço da variável estática antiga será armazenado, e o método is()
comparará com o endereço da nova, e "this Any
não Any
mais o mesmo Any
" ©.
Planejar
Para resolver esse problema, você precisa de algo mais inteligente do que apenas copiar. Depois de passar algumas noites no Google, lendo a documentação, os códigos-fonte e a API do sistema, o seguinte plano foi construído em minha mente:
- Depois de criar o novo código, passamos pelas realocações .
- A partir dessas realocações, obtemos todos os lugares no código que usam variáveis estáticas (e às vezes globais).
- Em vez de endereços para novas versões de variáveis, substituímos endereços de versões antigas no local da realocação.
Nesse caso, não haverá links para novos dados, o aplicativo inteiro continuará trabalhando com versões antigas de variáveis até o endereço. Isso deve funcionar. Isso não pode falhar no trabalho.
Realocações
Quando o compilador gera código de máquina, ele insere vários bytes suficientes para gravar o endereço real da variável ou função neste local em cada local em que a função é chamada ou o endereço da variável é carregado e também gera uma realocação. Ele não pode registrar imediatamente o endereço real, porque, nesse estágio, ele não conhece esse endereço. Funções e variáveis após a vinculação podem estar em diferentes seções, em diferentes locais das seções, nas seções finais podem ser carregadas em diferentes endereços no tempo de execução.
A realocação contém informações:
- Em que endereço você precisa escrever o endereço da função ou variável
- O endereço de qual função ou variável gravar
- A fórmula pela qual esse endereço deve ser calculado
- Quantos bytes estão reservados para este endereço
Em sistemas operacionais diferentes, as realocações são representadas de maneira diferente, mas no final todas elas funcionam com o mesmo princípio. Por exemplo, no elf (Linux), as realocações estão localizadas em seções .rela
especiais (na versão de 32 bits, é .rel
), que se referem à seção com o endereço que precisa ser corrigido (por exemplo, .rela.text
- a seção na qual as realocações estão localizadas, aplicado à seção .text
) e cada entrada armazena informações sobre o símbolo cujo endereço você deseja inserir no site de realocação. No mach-o (macOS), o oposto é o caso; não há seção separada para realocações; em vez disso, cada seção contém um ponteiro para uma tabela de realocações que deve ser aplicada a esta seção, e cada registro nesta tabela tem uma referência a um símbolo relacional.
Por exemplo, para esse código (com a opção -fPIC
):
int globalVariable = 10; int veryUsefulFunction() { static int functionLocalVariable = 0; functionLocalVariable++; return globalVariable + functionLocalVariable; }
o compilador criará uma seção com realocações no Linux:
Relocation section '.rela.text' at offset 0x1a0 contains 4 entries: Offset Info Type Symbol's Value Symbol's Name + Addend 0000000000000007 0000000600000009 R_X86_64_GOTPCREL 0000000000000000 globalVariable - 4 000000000000000d 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 0000000000000016 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4 000000000000001e 0000000400000002 R_X86_64_PC32 0000000000000000 .bss - 4
e uma tabela de realocação no macOS:
RELOCATION RECORDS FOR [__text]: 000000000000001b X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000015 X86_64_RELOC_SIGNED _globalVariable 000000000000000f X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable 0000000000000006 X86_64_RELOC_SIGNED __ZZ18veryUsefulFunctionvE21functionLocalVariable
E aqui está a função veryUsefulFunction()
(no Linux):
0000000000000000 <_Z18veryUsefulFunctionv>: 0: 55 push rbp 1: 48 89 e5 mov rbp,rsp 4: 48 8b 05 00 00 00 00 mov rax,QWORD PTR [rip+0x0] b: 8b 0d 00 00 00 00 mov ecx,DWORD PTR [rip+0x0] 11: 83 c1 01 add ecx,0x1 14: 89 0d 00 00 00 00 mov DWORD PTR [rip+0x0],ecx 1a: 8b 08 mov ecx,DWORD PTR [rax] 1c: 03 0d 00 00 00 00 add ecx,DWORD PTR [rip+0x0] 22: 89 c8 mov eax,ecx 24: 5d pop rbp 25: c3 ret
e depois de vincular o objeto à biblioteca dinâmica:
00000000000010e0 <_Z18veryUsefulFunctionv>: 10e0: 55 push rbp 10e1: 48 89 e5 mov rbp,rsp 10e4: 48 8b 05 05 21 00 00 mov rax,QWORD PTR [rip+0x2105] 10eb: 8b 0d 13 2f 00 00 mov ecx,DWORD PTR [rip+0x2f13] 10f1: 83 c1 01 add ecx,0x1 10f4: 89 0d 0a 2f 00 00 mov DWORD PTR [rip+0x2f0a],ecx 10fa: 8b 08 mov ecx,DWORD PTR [rax] 10fc: 03 0d 02 2f 00 00 add ecx,DWORD PTR [rip+0x2f02] 1102: 89 c8 mov eax,ecx 1104: 5d pop rbp 1105: c3 ret
Existem 4 locais em que 4 bytes são reservados para o endereço de variáveis reais.
Em sistemas diferentes, o conjunto de possíveis realocações é seu. No Linux em x86-64, até 40 tipos de realocações . Existem apenas 9 deles no macOS no x86-64. Todos os tipos de realocações podem ser condicionalmente divididos em 2 grupos:
- Realocações de tempo de link - realocações usadas no processo de vinculação de arquivos de objetos a um arquivo executável ou biblioteca dinâmica
- Realocações de tempo de carregamento - realocações aplicadas no momento em que a biblioteca dinâmica é carregada na memória do processo
O segundo grupo inclui realocações de funções e variáveis exportadas. Quando uma biblioteca dinâmica é carregada na memória do processo, para todas as realocações dinâmicas (incluindo realocações de variáveis globais), o vinculador procura a definição de símbolos em todas as bibliotecas já carregadas, inclusive no próprio programa, e o endereço do primeiro símbolo adequado é usado para realocação. Portanto, nada precisa ser feito com essas realocações; o vinculador encontrará a variável do próprio aplicativo, uma vez que cairá na sua lista de bibliotecas e programas carregados anteriormente e substituirá seu endereço no novo código, ignorando a nova versão dessa variável.
Há um ponto sutil associado ao macOS e seu vinculador dinâmico. O MacOS implementa o chamado mecanismo de namespace de dois níveis. Se for grosseiro, ao carregar uma biblioteca dinâmica, o vinculador primeiro procurará caracteres nessa biblioteca e, se não encontrar, procurará em outras pessoas. Isso é feito para fins de desempenho, para que as realocações sejam resolvidas rapidamente, o que, em geral, é lógico. Mas isso interrompe nosso fluxo em relação às variáveis globais. Felizmente, no ld no macOS, existe um sinalizador especial - -flat_namespace
, e se você criar uma biblioteca com esse sinalizador, o algoritmo de pesquisa de caracteres será idêntico ao do Linux.
O primeiro grupo inclui a realocação de variáveis estáticas - exatamente o que precisamos. O único problema é que essas realocações não estão na biblioteca compilada, pois já foram resolvidas pelo vinculador. Portanto, vamos lê-los a partir dos arquivos de objeto dos quais a biblioteca foi montada.
Os possíveis tipos de realocações também são limitados se o código montado depende da posição ou não. Como coletamos nosso código no modo PIC (código independente de posição), as realocações são usadas apenas em termos relativos. As realocações totais que nos interessam são:
- Realocações da seção
.rela.text
no Linux e as realocações referenciadas pela seção __text
no macOS e - Que usa caracteres das seções
.bss
e .bss
no Linux e __data
, __bss
e __common
no macOS e - As realocações são do tipo
R_X86_64_PC32
e R_X86_64_PC64
no Linux e X86_64_RELOC_SIGNED
, X86_64_RELOC_SIGNED_1
, X86_64_RELOC_SIGNED_2
e X86_64_RELOC_SIGNED_4
e X86_64_RELOC_SIGNED_4
no macOS
O ponto sutil associado à seção __common
. O Linux também possui uma seção *COM*
semelhante. Variáveis globais podem cair nesta seção . Mas, enquanto eu testava e compilava vários trechos de código, no Linux, as realocações de caracteres das seções *COM*
eram sempre dinâmicas, como variáveis globais regulares. Ao mesmo tempo, no macOS, esses caracteres às vezes eram realocados durante o vínculo se a função e o caractere estivessem no mesmo arquivo. Portanto, no macOS, faz sentido considerar esta seção ao ler caracteres e realocações.
Bem, agora temos um conjunto de todas as realocações necessárias, o que fazer com elas? A lógica aqui é simples. Quando o vinculador vincula a biblioteca, ele grava o endereço do símbolo calculado por uma determinada fórmula no endereço de realocação. Para nossas realocações nas duas plataformas, esta fórmula contém o endereço do símbolo como um termo. Assim, o endereço calculado já registrado no corpo das funções tem a forma:
resultAddr = newVarAddr + addend - relocAddr
Ao mesmo tempo, sabemos os endereços das duas versões das variáveis - antigas, que já vivem no aplicativo e novas. Resta mudá-lo pela fórmula:
resultAddr = resultAddr - newVarAddr + oldVarAddr
e escreva-o no endereço de realocação. Depois disso, todas as funções no novo código usarão as versões existentes das variáveis e as novas variáveis simplesmente mentirão e não farão nada. O que você precisa! Mas há um ponto sutil.
Fazendo Download da Biblioteca com o Novo Código
Quando o sistema carrega uma biblioteca dinâmica na memória do processo, é livre para colocá-la em qualquer lugar do espaço de endereço virtual. No Ubuntu 18.04, meu aplicativo é carregado em 0x00400000
e nossas bibliotecas dinâmicas imediatamente após ld-2.27.so
em endereços na área 0x7fd3829bd000
. A distância entre os endereços de download do programa e a biblioteca é muito maior que o número que caberia no número inteiro de 32 bits assinado. E nas realocações em tempo de link, apenas 4 bytes são reservados para endereços de caracteres de destino.
Depois de fumar a documentação para compiladores e vinculadores, decidi tentar a opção -mcmodel=large
. Isso força o compilador a gerar código sem nenhuma suposição sobre a distância entre caracteres; portanto, todos os endereços são assumidos como sendo de 64 bits. Mas essa opção não é compatível com PIC, pois se -mcmodel=large
não pode ser usado com -fPIC
, pelo menos no macOS. Ainda não entendo qual é o problema, talvez no macOS não haja realocações adequadas para essa situação.
Na biblioteca do Windows, esse problema é resolvido da seguinte maneira. As mãos alocam uma parte da memória virtual próxima ao local de download do aplicativo, suficiente para acomodar as seções necessárias da biblioteca. Em seguida, as seções são carregadas nele com as mãos, os direitos necessários são definidos nas páginas de memória com as seções correspondentes, todas as realocações são descompactadas manualmente e todo o resto é corrigido. Eu sou preguiçoso Eu realmente não queria fazer todo esse trabalho com realocações em tempo de carregamento, especialmente no Linux. E por que o que um vinculador dinâmico já sabe fazer? Afinal, as pessoas que escreveram sabem muito mais do que eu.
Felizmente, a documentação encontrou as opções necessárias para indicar onde baixar nossa biblioteca dinâmica:
- Apple ld:
-image_base 0xADDRESS
- LLVM lld:
--image-base=0xADDRESS
- GNU ld:
-Ttext-segment=0xADDRESS
Essas opções devem ser passadas para o vinculador no momento da vinculação da biblioteca dinâmica. Existem 2 dificuldades.
O primeiro está relacionado ao GNU ld. Para que essas opções funcionem, você precisa:
- No momento do carregamento da biblioteca, a área em que queremos carregá-la era gratuita
- O endereço especificado na opção deve ser múltiplo do tamanho da página (no x86-64 Linux e macOS é
0x1000
) - Pelo menos no Linux, o endereço especificado na opção deve ser múltiplo do alinhamento do segmento
PT_LOAD
Ou seja, se o vinculador definir o alinhamento como 0x10000000
, essa biblioteca não poderá ser carregada no endereço 0x10001000
, mesmo levando em consideração que o endereço está alinhado ao tamanho da página. Se uma dessas condições não for atendida, a biblioteca será carregada "como de costume". Eu tenho o GNU ld 2.30 no meu sistema e, ao contrário do LLVM lld, por padrão, define o alinhamento do segmento 0x20000
como 0x20000
, o que está muito fora de cena. Para contornar isso, além da opção -Ttext-segment=...
, especifique -z max-page-size=0x1000
. Passei um dia até perceber por que a biblioteca não está carregando para onde eu preciso.
A segunda dificuldade - o endereço de download deve ser conhecido no estágio de vinculação da biblioteca. Não é muito difícil de organizar. No Linux, basta analisar o pseudo-arquivo /proc/<pid>/maps
, encontrar a parte mais desocupada mais próxima do programa, na qual a biblioteca se encaixará, e usar o endereço do início dessa parte ao vincular. O tamanho da futura biblioteca pode ser estimado aproximando-se dos tamanhos dos arquivos de objeto ou analisando-os e calculando os tamanhos de todas as seções. No final, não precisamos de um número exato, mas de um tamanho aproximado com uma margem.
O MacOS não possui /proc/*
; em vez disso, é recomendável que você use o utilitário vmmap
. A saída do vmmap -interleaved <pid>
contém as mesmas informações que proc/<pid>/maps
. Mas aqui surge outra dificuldade. Se um aplicativo criar um processo filho que executa esse comando e o identificador do processo atual for especificado como <pid>
, o programa ficará travado. Pelo que entendi, o vmmap
interrompe o processo para ler seus mapeamentos de memória e, aparentemente, se esse é o processo de chamada, algo dá errado. Nesse caso, você precisa especificar o sinalizador adicional -forkCorpse
para que o vmmap
crie um processo filho vazio do nosso processo, remova o mapeamento e o mate, sem interromper o programa.
Isso é basicamente tudo o que precisamos saber.
Juntando tudo
Com essas modificações, o algoritmo final de recarga de código fica assim:
- Compile o novo código em arquivos de objetos
- Para arquivos de objetos, estimamos o tamanho da futura biblioteca
- Lendo arquivos de objeto de realocação
- Estamos procurando por um pedaço de memória virtual livre ao lado do aplicativo
- Construímos uma biblioteca dinâmica com as opções necessárias,
dlopen
via dlopen
- Código de correção de acordo com as realocações em tempo de link
- Função Patch
- Copie variáveis estáticas que não participaram da etapa 6
Somente variáveis de guarda de variáveis estáticas se enquadram na etapa 8, para que possam ser copiadas com segurança (preservando, assim, a "inicialização" das próprias variáveis estáticas).
Conclusão
Como essa é exclusivamente uma ferramenta de desenvolvimento, não destinada a nenhuma produção, a pior coisa que pode acontecer se a próxima biblioteca com o novo código não couber na memória ou carregar acidentalmente em um endereço diferente é a reinicialização do aplicativo depurado. Ao executar testes, 31 bibliotecas com código atualizado são carregadas na memória por sua vez.
Para completar, faltam mais 3 peças pesadas na implementação:
- Agora a biblioteca com o novo código é carregada na memória ao lado do programa, embora o código de outra biblioteca dinâmica carregada agora possa entrar nela. Para corrigir, você precisa rastrear a propriedade das unidades de tradução para uma ou outra biblioteca e programa e dividir a biblioteca com o novo código, se necessário.
- O recarregamento do código em um aplicativo com vários threads ainda não é confiável (com certeza você pode recarregar apenas o código que é executado no mesmo thread da biblioteca runloop). Para fixação, é necessário mover parte da implementação para um programa separado, e esse programa, antes do patch, deve interromper o processo com todos os threads, patch e retorná-lo ao trabalho. Eu não sei como fazer isso sem um programa externo.
- Prevenção de falha acidental do aplicativo após a recarga do código. Depois de corrigir o código, você pode desreferenciar acidentalmente o ponteiro inválido no novo código, após o qual precisará reiniciar o aplicativo. Nada de errado, mas ainda assim. Parece magia negra, ainda estou pensando.
Mas já que a implementação atual começou a me beneficiar pessoalmente, é suficiente para ser usada no meu trabalho principal. Demora um pouco para se acostumar, mas o vôo é normal.
Se eu chegar a esses três pontos e encontrar na implementação deles uma quantidade suficiente de coisas interessantes, eu definitivamente o compartilharei.
Demo
Como a implementação permite adicionar novas unidades de transmissão em tempo real, decidi gravar um pequeno vídeo no qual escrevo um jogo obsceno e simples do zero sobre uma nave espacial que explora as extensões do universo e fotografa asteróides quadrados. Tentei não escrever no estilo de "tudo em um arquivo", mas, se possível, organizando tudo nas prateleiras, gerando muitos arquivos pequenos (portanto, surgiram tantos rabiscos). Obviamente, a estrutura é usada para desenhar, entradas, janelas e outras coisas, mas o código do jogo em si foi escrito do zero.
A principal característica - executei o aplicativo apenas três vezes: no início, quando havia apenas uma cena vazia, e duas vezes após a queda devido à minha negligência. Todo o jogo foi incrementado no processo de escrever código. Tempo real - cerca de 40 minutos. Em geral, você é bem-vindo.
Como sempre, terei prazer em qualquer crítica, obrigado!
Link para implementação