Entendendo a memória com Assembly
- Francisco Junior
- Linux , Programação
- 25 de março de 2021
Entenda como funciona a memória do seu computador, utilizando a linguagem Assembly e suas funcionalidades para tirar proveito!
Introdução ao Assembly
Sabemos que quando falamos sobre Assembly, muitas pessoas acabam se assustando. Porém, aos que não tem ideia do que estou falando, daremos uma cobertura inicial caso você queira iniciar e entender um pouco mais sobre esse mundo de linguagens low-level.
Mas antes, deixo um recado, esse post é para apenas saber como funciona assembly.
Saber e entender assembly são duas medidas bem diferentes.
O assembly surgiu em meados dos anos 50, dando início à segunda geração de linguagens de programação, que anteriormente eram feitas através de cartões marcados. Foi desenvolvida essa linguagem visando a corrida espacial que ocorria na época entre os EUA e Rússia, para poder tirar mais proveito do processamento da máquina.
Com a invenção do transistor, os computadores ficaram mais compactos e rápidos, com isso, surgiu a necessidade de se criar um modelo de programação. Esse modelo deveria ter leitura e escrita mais simples que cartões perfurados, mas mantendo o nível de programação próximo da linguagem de máquina. E foi assim que surgiram as linguagens de baixo nível e, consequentemente, a primeira linguagem de baixo nível, o assembly.
Fonte: dsc.ufcg.edu.br
Entendendo conceitos
Imagine que você tem um papel e que você vai escrevendo instruções do início da página até o final. E a cada linha que você escreve você tem um índice que serve para você identificar o número daquela linha. Isso é como uma memória funciona.
mov rax, rsi
add, rbx, 0x1
cmp rax, rbx
jz , 0xA4
Não se preocupe em entender o código acima, é apenas uma demonstração que, para cada linha temos uma instrução e os números ao lado (0, 1, 2 e 3) são identificadores na memória. Claro que a memória é muito grande, então eventualmente você irá ver números em hexadecimal como 0x000A3B.
A linguagem assembly no que lhe concerne acaba sendo mais intimidadora do que difícil. Listo alguns pontos resumidamente do que te espera na linguagem, mas não desista de ler até o final.
Você tem 8-32 variáveis globais com tamanho fixo para trabalhar com elas. Basicamente são chamadas de registradores. A quantidade de variáveis globais dependerá do seu processador, se ele é 32-bits, 64-bits ou até mesmo 16-bits.
As variáveis globais têm o mesmo propósito de quando utilizamos linguagens de programação de alto nível como Python, C ou Java. Temos um registrador/variável que serve para armazenar alguma categoria de dado específico.
Por exemplo, em uma arquitetura de 32-bits temos variáveis como:
mov rax, rsi ;rax e rsi são variáveis
add, rbx, 0x1 ;rbx é outra variável
cmp rax, rbx ;Onde você pode armazenar valores
jz , 0xA4 ...
Vendo o código acima, os registradores rax, rsi e rbx são locais onde podemos armazenar valores para poder realizar cálculos aritméticos, multiplicar, dividir, somar e ai vai da sua imaginação.
E a partir de agora começam algumas complicações. Em uma arquitetura de 32-bits os registradores apenas podem suportar 32-bits em suas variáveis, assim como um registrador de uma arquitetura de 64-bits pode suportar até 64 bits.
Por exemplo, em uma arquitetura de 16 bits, ele suportaria até 16 bits. Trazendo para uma linguagem de alto nível, compararemos a um vetor em Java.
int bits = new int[16];
for(int i=0; i<16; i++){
bits[i]=1;
}
System.out.println(bits);
A saída desse programa seria basicamente:
1111 1111 1111 1111
O que representaria 16 bits alocados nessa memória. Claro que isso tudo muito superficialmente e se fossemos mexer com bytes e bits em Java, seria de outro modo.
Para aqueles que programam em Java sabem, que quando colocamos em um vetor de 16 lugares, um 17.º valor, o programa retornará um NullException. Assim como os registradores, mas em vez de retornar um erro, ele simplesmente sobrescreve o primeiro bit.
Por exemplo, temos um registrador rax que receberá 16 bits.
mov rax, 0xF
add rax,0x1
Nesse caso, movemos o valor de 15 para o registrador rax e depois adicionamos mais 1 a ele. Provavelmente a máquina ficará sem entender nada e executará o seguinte comando:
Temos a quantidade de bit abaixo e seus índices para poder ficar mais fácil de compreender:
O que aconteceu foi que o registrador não tinha mais onde alocar valor e simplesmente jogou fora o primeiro valor que tinha no índice 0.
Então a maior dificuldade no início é poder lidar com a quantidade limitada do tamanho do registrador conforme sua arquitetura.
Para o computador poder entender qual instrução ele deve seguir, temos um dos registradores mais importantes na memória que é chamado de contador do programa ou “Program Counter”.
Nas arquiteturas o PC (Program Counter) é referenciado por IP (Instruction Pointer) que é o apontador para a próxima instrução.
Dependendo da estrutura os registradores podem ser chamados de EIP, RIP ou somente IP.
Então quando o IP recebe um valor, é referente a próxima linha que ele deve executar. Um exemplo abaixo em assembly:
mov rax, rsi ; RIP aqui tem o valor de 1, pois é a próxima linha
add, rbx, 0x1 ; RIP = 2
cmp rax, rbx ;RIP = 3
mov rip, 0x74 ; O rip recebe o valor de 7, que é aonde ele vai executar5
... ; sua próxima linha. Então basicamente, ele ignorará 6
... ; o que tiver na linha 4, 5 e 6 e ir direto para o 7
...
add rax, 0x1
Uma das coisas mais simples de fazer em assembly são operações aritméticas como somar, e subtrair. Podemos fazer do seguinte modo:
Para adicionarmos um valor a um registrador podemos utilizar a instrução mov, que recebe dois argumentos. Sendo eles, o registrador que receberá o valor e o valor .
Para fazermos uma soma dentro de um registrador podemos utilizar o comando add, que também recebe dois argumentos. Sendo eles, o registrador com um valor e o valor a ser adicionado.
mov rax, 0x3 ; Nosso registrador rax agora tem o valor de 3
add rax, 0x3 ; Agora adicionamos em nosso registrador mais 3. 3 + 3 = 6
Viu como operações de adição são simples?
Vamos agora fazer uma subtração entre os dois números para facilitar também.
mov rax, 0x3 ; rax = 3
add rax, 0x3 ; rax = 6 rax = rax + 32
mov rbx, 0x6 ; rbx = 63
sub rbx, rax ; rbx = 0 rbx = rbx - rax
Porém, agora que começa a nossa dificuldade. Imagina programarmos um jogo como Sonic em assembly utilizando apenas 16 variáveis, um pouco impossível não?
Você teria que armazenar as posições Y/X, inimigos, fases, vida, moedas, e outra infinidades de coisas. Imagina um jogador conseguir tantas moedas que não cabe mais em 16 bits esse valor?
Por isso, em assembly podemos dizer que, o que não cabe em registradores, cabe na memória. Você pode utilizar outros locais que não estão sendo utilizados por instruções de assembly para poder armazenar valores.
mov rax, 0x3 ; rax = 3
add rax, 0x3 ; rax = 6 rax = rax + 32
mov rbx, 0x6 ; rbx = 63
sub rbx, rax ; rbx = 0 rbx = rbx - rax4
jmp 0x85
...
0x57
0x3
mov rax, [0x6] ;Move para rax o valor da linha 6, então rax=0x5
mov [0x5], 0x1 ;Move para a linha 5 o valor de 1, então L 5 = 0x1
Nós podemos utilizar o comando mov para podermos recuperar esses valores dos endereços de memória, em nosso caso, nossas linhas, para dentro de um registrador. E também o processo contrário, podemos armazenar um valor a um endereço de memória.
O que podemos notar é que quando referenciamos um local da memória colocamos entre parênteses. Assim como referenciaremos um valor em um array em Java colocamos em chaves.
int wow = teste[1];
A variável wow receberá o valor que está na posição 1 desse array. Assim funciona a memória, um array gigante.
Conclusão
Assembly pode parecer um pouco complicado a primeira vista, mas você perceberá ao decorrer ser apenas necessário, lógica. Temos variáveis e termos bem simples de se entender quando se entende um quadro complexo. E quando entendemos todo esse quadro podemos, tirar o máximo de poder do nosso equipamento físico.
Caso você deseje treinar um pouco mais sobre assembly deixarei alguns links no final da postagem com as fontes.
Jogos:
- http://microcorruption.com
- Human Resource Machine
Fontes: