Variáveis em C
Entendendo como variáveis em C são representadas em Assembly.
Como já vimos no capítulo A base, variáveis nada mais são do que um espaço de memória que pode ser manipulado pelo programa. Em C existem diversas nuances em como variáveis são alocadas e mantidas pelo compilador e aqui vamos entender essas diferenças.
Na linguagem C existem palavra-chaves que são chamadas de storage-class specifiers, onde elas determinam o storage-class de uma variável. Na prática isso determina como a variável deve ser armazenada no programa. No C11 existem os seguintes storage-class specifiers:
- extern
- static
- _Thread_local
- auto (esse é o padrão)
- register
As variáveis globais em C são alocadas na seção
.data
ou .bss
, dependendo se ela foi inicializada ou não. Como no exemplo:int data_var = 1;
int bss_var;
Se compilamos com
gcc main.c -S -o main.s -fno-asynchronous-unwind-tables
obtemos a seguinte saída:main.s
.globl data_var
.data
.align 4
.type data_var, @object
.size data_var, 4
data_var:
.long 1
.comm bss_var,4,4
A variável
data_var
foi alocada na seção .data
e teve seu símbolo exportado com a diretiva .globl data_var
, que é equivalente a diretiva global
do NASM.Já a variável
bss_var
foi declarada com a diretiva .comm symbol, size, aligment
que serve para declarar commom symbols (símbolos comuns). Onde ela recebe como argumento o nome do símbolo seguido de seu tamanho (em bytes) e opcionalmente um valor de alinhamento. Em arquivos objetos ELF o argumento de alinhamento é um alinhamento em bytes, nesse exemplo a variável será alocada em um endereço alinhado por 4 bytes.Já em arquivos objetos PE (do Windows) o alinhamento é um valor em potência de dois, logo para alinhar em 4 bytes deveríamos passar 2 como argumento (
). Se a gente passar 4 como argumento então seria um alinhamento de
que daria um alinhamento de 16 bytes.
Os símbolos declarados com a diretiva
.comm
que não foram inicializados em qualquer arquivo objeto são alocados na seção .bss
. Logo nesse caso o uso da diretiva seria equivalente ao uso de res*
do NASM, com a diferença que no NASM precisamos usar explicitamente na seção onde o espaço será alocado.As variáveis globais com storage-class
static
funcionam da mesma maneira que as variáveis globais comum, com a diferença que seu símbolo não é exportado para que possa ser acessado em outro arquivo objeto. Como no exemplo:static int data_var = 1;
static int bss_var;
Onde obtemos a saída:
.data
.align 4
.type data_var, @object
.size data_var, 4
data_var:
.long 1
.local bss_var
.comm bss_var,4,4
Repare que dessa vez o símbolo
data_var
não foi exportado com a diretiva .globl
, enquanto o bss_var
foi explicitamente declarado como local com a diretiva .local
(já que a diretiva .comm
exporta como global por padrão).Variáveis
extern
em C são basicamente variáveis que são definidas em outro módulo. O GAS tem uma diretiva .extern
que é equivalente a diretiva extern
do NASM, isto é, indica que o símbolo será definido em outro arquivo objeto. Porém qualquer símbolo não declarado já é considerado externo por padrão pelo GAS. Experimente ver o código de saída do exemplo abaixo:extern int extern_var;
int main(void)
{
int x = extern_var;
return 0;
}
Você vai reparar que na função
main
o símbolo extern_var
foi lido porém ele não foi declarado.Variáveis locais em C são comumente alocadas no stack frame da função, porém em alguns casos o compilador também pode reservar um registrador para armazenar o valor da variável.
Em C existe o storage-class
register
que serve como um "pedido" para o compilador alocar aquela variável de forma que o acesso a mesma seja o mais rápido possível, que geralmente é em um registrador (daí o nome da palavra-chave). Mas isso não garante que a variável será realmente alocada em um registrador. Na prática o único efeito colateral garantido é que você não poderá obter o endereço na memória daquela variável com o operador de endereço (&
), e muitas vezes o compilador já vai alocar a variável em um registrador mesmo sem o uso da palavra-chave.Variáveis
static
local são armazenadas da mesma maneira que as variáveis static
global, a única coisa que muda é no ponto de vista do código-fonte em C onde a visibilidade da variável é limitada para o escopo onde ela foi declarada. Isso faz com o que o compilador gere um símbolo de nome único para a variável, como no exemplo abaixo:test.c
test.s
int test(void)
{
static int data_var = 5;
static int bss_var;
return data_var + bss_var;
}
.data
.align 4
.type data_var.1913, @object
.size data_var.1913, 4
data_var.1913:
.long 5
.local bss_var.1914
.comm bss_var.1914,4,4
Repare como
data_var.1913
não teve seu símbolo exportado e bss_var.1914
foi declarado como local.O storage-class
_Thread_local
foi adicionado no C11. Assim como o nome sugere ele serve para alocar variáveis em uma região de memória que é local para cada thread do processo. Vamos analisar o exemplo:test.c
test.s
_Thread_local int global_thread_data = 5;
_Thread_local int global_thread_bss;
int test(void)
{
_Thread_local static int local_thread_data = 5;
_Thread_local static int local_thread_bss;
return global_thread_data
+ global_thread_bss
+ local_thread_data
+ local_thread_bss;
}
.text
.globl global_thread_data
.section .tdata,"awT",@progbits
.align 4
.type global_thread_data, @object
.size global_thread_data, 4
global_thread_data:
.long 5
.globl global_thread_bss
.section .tbss,"awT",@nobits
.align 4
.type global_thread_bss, @object
.size global_thread_bss, 4
global_thread_bss:
.zero 4
.section .tdata
.align 4
.type local_thread_data.1915, @object
.size local_thread_data.1915, 4
local_thread_data.1915:
.long 5
.section .tbss
.align 4
.type local_thread_bss.1916, @object
.size local_thread_bss.1916, 4
local_thread_bss.1916:
.zero 4
.text
.globl test
.type test, @function
test:
endbr64
pushq %rbp
movq %rsp, %rbp
movl %fs:[email protected], %edx
movl %fs:[email protected], %eax
addl %eax, %edx
movl %fs:[email protected], %eax
addl %eax, %edx
movl %fs:[email protected], %eax
addl %edx, %eax
popq %rbp
ret
No Linux, em x86-64, a região de memória local para cada thread (thread-local storage - TLS) fica no segmento apontado pelo registrador de segmento FS, por isso os valores das variáveis estão sendo lidos desse segmento.
Repare que as seções são diferentes,
.tdata
(equivalente a .data
só que thread-local) e .tbss
(equivalente a .bss
) são utilizadas para armazenar as variáveis.O sufixo
@tpoff
(thread pointer offset) usado nos símbolos indica que o offset do símbolo deve ser calculado levando em consideração a TLS como endereço de origem. Por padrão o offset é calculado com o segmento de dados "normal" como origem.Agora que já entendemos onde e como as variáveis são alocadas em C, só falta entender "o que" está sendo armazenado.
O tipo array em C é meramente uma sequência de variáveis do mesmo tipo na memória. Por exemplo podemos inicializar um
int arr[4]
na sintaxe do GAS da seguinte forma:arr:
.long 1, 2, 3, 4
Onde os valores
1
, 2
, 3
e 4
são despejados em sequência.Em C não existe um tipo string porém por convenção as strings são uma array de
char
, onde o último char
contém o valor zero (chamado de terminador nulo). Esse último caractere '\0'
é usado para denotar o final da string e funções da libc que lidam com strings esperam por isso. Exemplos:string1:
.ascii "Hello World", 0
string2:
.ascii "Hello World\0"
string3:
.asciz "Hello World"
As três strings acima são equivalentes na sintaxe do GAS.
Sobre a passagem de arrays (incluindo obviamente strings) como argumento para uma função, isso é feito passando um ponteiro para o primeiro elemento da array.
Ponteiros em C, na arquitetura x86/x86-64, são traduzidos meramente como o offset do objeto na memória. O segmento não é especificado como parte do valor do ponteiro.
Experimente ler o código de saída do seguinte programa:
#include <stdio.h>
_Thread_local int my_var = 111;
int main(void)
{
int *test = &my_var;
*test = 777;
printf("%d, %d\n", my_var, *test);
}
A leitura do endereço de
my_var
vai ser compilada para algo como:movq %fs:0, %rax
addq [email protected], %rax
movq %rax, -8(%rbp)
# Com otimização ligada o GCC usa LEA:
movq %fs:0, %rax
leaq [email protected](%rax), %rdi
Onde primeiro é obtido o endereço do início do segmento FS que depois é somado ao offset de
my_var
. Assim obtendo o endereço efetivo da variável na memória.As estruturas em C são compiladas de forma que os valores dos campos da estrutura são dispostos em sequência na memória, seguindo a mesma ordem que foram declarados na estrutura. Existe a possibilidade do GCC adicionar alguns bytes extras no final da estrutura afim de manter o alinhamento dos dados, esses bytes extras são chamados de padding. Exemplo:
#include <stdio.h>
typedef struct
{
int x;
char y;
} my_test_t;
my_test_t test = {
.x = 5,
.y = 'A',
};
int main(void)
{
printf("%d, %c | sizeof: %zu\n", test.x, test.y, sizeof test);
}
Isso produziria o seguinte código para a inicialização da variável
test
: .globl test
.data
.align 8
.type test, @object
.size test, 8
test:
.long 5
.byte 65
.zero 3
Repare a diretiva
.zero 3
que foi usada para despejar 3 bytes zero no final da estrutura, afim de alinhar a mesma em 4 bytes. No total a estrutura acaba tendo 8 bytes de tamanho: 4 bytes do int
, 1 byte do char
e 3 bytes de padding.As unions são bem simples, são alocadas com o tamanho do maior tipo declarado para a union. Por exemplo:
typedef union
{
int x;
char y;
} my_test_t;
Essa union é alocada na memória da mesma forma que um
int
, que tem 4 bytes de tamanho.Last modified 9mo ago