Como os tamanhos de matrizes C se tornaram parte da interface binária da biblioteca

A maioria dos compiladores C permite acessar uma matriz extern com limites indefinidos, por exemplo:

 extern int external_array[]; int array_get (long int index) { return external_array[index]; } 

A definição de external_array pode estar em outra unidade de tradução e pode ser assim:

 int external_array[3] = { 1, 2, 3 }; 

A questão é o que acontece se essa definição separada mudar assim:

 int external_array[4] = { 1, 2, 3, 4 }; 

Ou então:

 int external_array[2] = { 1, 2 }; 

A interface binária será preservada (desde que exista um mecanismo que permita ao aplicativo determinar o tamanho da matriz em tempo de execução)?

Curiosamente, em muitas arquiteturas, aumentar o tamanho da matriz viola a compatibilidade de interface binária (ABI). Reduzir o tamanho da matriz também pode causar problemas de compatibilidade. Neste artigo, examinaremos mais de perto a compatibilidade com a ABI e explicaremos como evitar problemas.

Links na seção de dados do arquivo executável


Para entender como o tamanho da matriz se torna parte da interface binária, primeiro precisamos examinar os links na seção de dados do arquivo executável. Obviamente, os detalhes dependem da arquitetura específica, e aqui vamos nos concentrar na arquitetura x86-64.

A arquitetura x86-64 suporta endereçamento relativo ao contador do programa, ou seja, o acesso à variável global da matriz, como na função array_get mostrada anteriormente, pode ser compilado em uma única instrução movl :

 array_get: movl external_array(,%rdi,4), %eax ret 

A partir disso, o assembler cria um arquivo de objeto no qual a instrução é marcada como R_X86_64_32S .

 0000000000000000 : 0: mov 0x0(,%rdi,4),%eax 3: R_X86_64_32S external_array 7: retq 

Essa movimentação informa ao vinculador ( ld ) como preencher o local correspondente da variável external_array durante a vinculação ao criar o executável.

Isso tem duas consequências importantes.

  • Como o deslocamento da variável é determinado no tempo de construção, no tempo de execução, não há custos indiretos para determiná-lo. O único preço é o acesso à própria memória.
  • Para determinar o deslocamento, você precisa conhecer os tamanhos de todos os dados variáveis. Caso contrário, seria impossível calcular o formato da seção de dados durante o layout.

Para implementações em C orientadas ao Executable e Link Format (ELF) , como no GNU / Linux, as referências a variáveis extern não contêm tamanhos de objeto. No exemplo array_get tamanho do objeto é desconhecido até para o compilador. De fato, o arquivo assembler inteiro se parece com isso (omitindo apenas as informações de promoção de -fno-asynchronous-unwind-tables , que são tecnicamente necessárias para a conformidade com o psABI):

  .file "get.c" .text .p2align 4,,15 .globl array_get .type array_get, @function array_get: movl external_array(,%rdi,4), %eax ret .size array_get, .-array_get .ident "GCC: (GNU) 8.3.1 20190223 (Red Hat 8.3.1-2)" .section .note.GNU-stack,"",@progbits 

Não há informações de tamanho para external_array nesse arquivo assembler: a única referência de caractere está na linha com a instrução movl e os únicos dados numéricos na instrução são o tamanho do elemento da matriz (implícito em movl multiplicado por 4).

Se o ELF exigir tamanhos para variáveis ​​indefinidas, será impossível compilar a função array_get .

Como o vinculador obtém o tamanho real dos caracteres? Ele examina a definição do símbolo e usa as informações de tamanho que encontra lá. Isso permite que o compilador calcule o layout da seção de dados e preencha os movimentos de dados com as compensações apropriadas.

Objetos ELF comuns


As implementações C para ELF não exigem que o programador adicione marcações ao código-fonte para indicar se a função ou variável está localizada no objeto atual (que pode ser a biblioteca ou o arquivo executável principal) ou em outro objeto. O vinculador e o carregador dinâmico cuidarão disso.

Ao mesmo tempo, havia um desejo de arquivos executáveis ​​para não reduzir o desempenho alterando o modelo de compilação. Isso significa que, ao compilar o código-fonte do programa principal (ou seja, sem -fPIC e, neste caso específico, sem -fPIE ), a função array_get compilada exatamente na mesma sequência de comandos antes de introduzir objetos compartilhados dinâmicos. Além disso, não importa se a variável external_array está definida no arquivo executável mais básico ou se algum objeto compartilhado é carregado separadamente no tempo de execução. As instruções criadas pelo compilador são as mesmas nos dois casos.

Como isso é possível? Afinal, objetos ELF comuns são independentes de posição. Eles são carregados em endereços aleatórios imprevisíveis em tempo de execução. No entanto, o compilador gera uma sequência de código de máquina que requer que essas variáveis ​​sejam localizadas em um deslocamento fixo calculado durante a vinculação , muito antes do início do programa.

O fato é que apenas um objeto carregado (o principal arquivo executável) usa esses deslocamentos corrigidos. Todos os outros objetos (o próprio carregador dinâmico, a biblioteca de tempo de execução C e qualquer outra biblioteca usada pelo programa) são compilados e compilados como objetos completamente independentes da posição (PICs). Para esses objetos, o compilador carrega o endereço real de cada variável da tabela de deslocamento global (GOT). Podemos ver essa rotatória se compilarmos o exemplo array_get com -fPIC , o que leva a esse código de montagem:

 array_get: movq external_array@GOTPCREL(%rip), %rax movl (%rax,%rdi,4), %eax ret 

Como resultado, o endereço da variável external_array não é mais codificado e pode ser alterado no tempo de execução, inicializando adequadamente o registro GOT. Isso significa que, no tempo de execução, a definição de external_array pode estar no mesmo objeto compartilhado, em outro objeto compartilhado ou no programa principal. O carregador dinâmico encontrará a definição apropriada com base nas regras de pesquisa de caracteres ELF e associará a referência de símbolo indefinida à sua definição, atualizando o registro GOT para seu endereço real.

Retornamos ao exemplo original, onde a função array_get está no programa principal, portanto o endereço da variável é especificado diretamente. A idéia principal implementada no vinculador é que o programa principal forneça uma definição de variável external_array , mesmo que seja realmente definida em um objeto comum em tempo de execução . Em vez de indicar a definição original da variável no objeto compartilhado, o carregador dinâmico selecionará uma cópia da variável na seção de dados do arquivo executável.

Isso tem duas consequências importantes. Antes de tudo, lembre-se de que external_array é definido da seguinte maneira:

 int external_array[3] = { 1, 2, 3 }; 

Há um inicializador aqui que deve ser aplicado à definição no arquivo executável principal. Para fazer isso, no arquivo executável principal, é colocado um link para o local da cópia de cópia do símbolo. O readelf -rW mostra como mover R_X86_64_COPY .

  A seção de realocação '.rela.dyn' no deslocamento 0x408 contém 3 entradas:
     Tipo de informação deslocada Valor do símbolo Nome do símbolo + Adenda
 0000000000403ff0 0000000100000006 R_X86_64_GLOB_DAT 0000000000000000 __libc_start_main@GLIBC_2.2.5 + 0
 0000000000403ff8 0000000200000006 R_X86_64_GLOB_DAT 0000000000000000 __gmon_start__ + 0
 0000000000404020 0000000300000005 R_X86_64_COPY 0000000000404020 external_array + 0 

Como outros movimentos, o movimento da cópia é tratado pelo carregador dinâmico. Inclui uma operação de cópia simples, bit a bit. O destino da cópia é determinado pelo deslocamento de deslocamento ( 0000000000404020 no exemplo). A origem é determinada no tempo de execução com base no nome do símbolo ( external_array ) e seu valor. Ao criar uma cópia, o carregador dinâmico também examinará o tamanho do caractere para obter o número de bytes que precisam ser copiados. Para tornar tudo isso possível, o símbolo external_array é exportado automaticamente do arquivo executável como um símbolo específico, para que fique visível para o carregador dinâmico em tempo de execução. A tabela de símbolos dinâmicos ( .dynsym ) reflete isso, conforme mostrado pelo comando readelf -sW :

  A tabela de símbolos '.dynsym' contém 4 entradas:
    Num: Valor Tamanho Tipo Vinculação Vis Ndx Name
      0: 0000000000000000 0 0 NÃO-PADRÃO LOCAL UND 
      1: 0000000000000000 0 PADRÃO GLOBAL FUNC UND __libc_start_main@GLIBC_2.2.5 (2)
      2: 0000000000000000 0 NOTYPE WEAK DEFAULT UND __gmon_start__
      3: 0000000000404020 12 PADRÃO GLOBAL DE OBJETOS 22 external_array 

De onde vêm as informações sobre o tamanho do objeto (12 bytes, neste exemplo)? O vinculador abre todos os objetos comuns, procura sua definição e obtém informações sobre o tamanho. Como antes, isso permite que o vinculador calcule o layout da seção de dados para que compensações fixas possam ser usadas. Novamente, o tamanho da definição no executável principal é fixo e não pode ser alterado em tempo de execução.

O vinculador dinâmico também redireciona links simbólicos em objetos compartilhados para a cópia movida no executável principal. Isso garante que em todo o programa haja apenas uma cópia da variável, conforme a semântica da linguagem C. Caso contrário, se a variável for alterada após a inicialização, as atualizações do arquivo executável principal não serão visíveis para objetos compartilhados dinâmicos e vice-versa.

Impacto na compatibilidade binária


O que acontece se alterarmos a definição de external_array em um objeto compartilhado sem vincular (ou recompilar) o programa principal? Primeiro, considere adicionar um elemento de matriz.

 int external_array[4] = { 1, 2, 3, 4 }; 

Isso irá gerar um aviso do carregador dinâmico em tempo de execução:

main-program: Symbol `external_array' has different size in shared object, consider re-linking

O programa principal ainda contém uma definição external_array com espaço para apenas 12 bytes. Isso significa que a cópia está incompleta: apenas os três primeiros elementos da matriz são copiados. Como resultado, o acesso ao elemento do array extern_array[3] não extern_array[3] definido. Essa abordagem afeta não apenas o programa principal, mas também todo o código no processo, porque todas as referências a extern_array foram redirecionadas para a definição no programa principal. Isso inclui um objeto genérico que fornece uma definição extern_array . Ele provavelmente não está pronto para enfrentar uma situação em que um elemento da matriz em sua própria definição desapareceu.

Que tal mudar na direção oposta, remover um elemento?

 int external_array[2] = { 1, 2 }; 

Se o programa evitar acessar o elemento da matriz extern_array[2] , porque de alguma forma detecta o comprimento reduzido da matriz, isso funcionará. Após a matriz, há alguma memória não utilizada, mas isso não interromperá o programa.

Isso significa que obtemos a seguinte regra:

  • A adição de elementos a uma variável de matriz global viola a compatibilidade binária.
  • A remoção de itens pode quebrar a compatibilidade se não houver um mecanismo que evite o acesso aos itens excluídos.

Infelizmente, o aviso do carregador dinâmico parece mais inofensivo do que realmente é, e para elementos remotos não há aviso.

Como evitar esta situação


Detectar alterações da ABI é bastante fácil com ferramentas como libabigail .

A maneira mais fácil de evitar essa situação é implementar uma função que retorna o endereço da matriz:

 static int local_array[3] = { 1, 2, 3 }; int * get_external_array (void) { return local_array; } 

Se a definição da matriz não puder ser estática devido à maneira como é usada na biblioteca, podemos ocultar sua visibilidade e também impedir sua exportação e, portanto, evitar o problema de truncamento:

 int local_array[3] __attribute__ ((visibility ("hidden"))) = { 1, 2, 3 }; 

Tudo é muito mais complicado se a variável da matriz for exportada por motivos de compatibilidade com versões anteriores. Como a matriz da biblioteca está truncada, o programa principal antigo, com uma definição de matriz mais curta, não poderá fornecer acesso à matriz completa para o novo código do cliente se for usado com a mesma matriz global. Em vez disso, a função de acesso pode usar uma matriz separada (estática ou oculta) ou talvez uma matriz separada para adicionar elementos ao final. A desvantagem é que não é possível armazenar tudo em uma matriz contínua se a variável da matriz for exportada para compatibilidade com versões anteriores. O design da interface secundária deve refletir isso.

Usando o controle de versão de caracteres, você pode exportar várias versões com tamanhos diferentes, nunca alterando o tamanho em uma versão específica. Usando esse modelo, novos programas relacionados sempre usarão a versão mais recente, presumivelmente com o maior tamanho. Como a versão e o tamanho do símbolo são fixados pelo editor de links ao mesmo tempo, eles são sempre consistentes. A biblioteca GNU C usa essa abordagem para as variáveis ​​históricas sys_errlist e sys_siglist . No entanto, isso ainda não fornece uma única matriz contínua.

Em suma, uma função acessadora (por exemplo, a função get_external_array acima) é a melhor abordagem para evitar esse problema de compatibilidade ABI.

Source: https://habr.com/ru/post/pt451182/


All Articles