Hardware e sistemas operacionais · parte 15 de 27
Assembly na prática: como um laço em C vira instruções em x86-64 e ARM64
Como um laço de soma em C vira instruções em x86-64 e ARM64, com os bytes conferidos no clang e no objdump, desvios calculados e flags explicadas.
intermediárioPublicada em · 28 min de leitura
Assembly na prática: como um laço em C vira instruções em x86-64 e ARM64
Imagine pedir a um amigo “some todos os números desta lista”. Para você, é uma tarefa só. Para quem precisa executá-la passo a passo, a frase não diz quantos números são, onde está o primeiro, quando parar nem o que fazer com o total. A CPU é esse amigo: ela não entende “todos”, só entende “leia este endereço, some, avance o ponteiro, compare o contador e volte se ainda não acabou”. Assembly é essa lista de passos escrita de um jeito que a CPU reconhece, com um nome para cada instrução. O compilador é quem faz a tradução do C para essa lista, e ele faz isso com mais liberdade do que a maioria das pessoas imagina.
Esta aula responde a uma pergunta: como um laço em C vira instruções em x86-64 e em ARM64? A aula ISA, CISC e RISC mostrou o contrato entre programa e processador e comparou a mesma soma nas três famílias. A aula Ciclo de instrução e unidade de controle mostrou como a CPU busca, decodifica e executa uma instrução por vez, com o contador de programa avançando. Aqui os dois assuntos se encontram. Vamos pegar um laço real, pedir ao compilador da Apple (clang) o código das duas arquiteturas, ler os bytes com o objdump e seguir o desvio de volta até a CPU. A chamada de função, com pilha e endereço de retorno, fica para Pilha e chamada de função.
Mapa da aula
| Seção | Pergunta que responde |
|---|---|
| 1. O que é assembly | Qual é a diferença entre código C, assembly e bytes, e quem faz cada conversão? |
| 2. Para que serve | Por que alguém precisa ler assembly se escreve em C? |
| 3. Exemplos em contextos diferentes | Em que situações reais o assembly aparece? |
| 4. Como funciona | Como um laço vira instruções, quais flags ele usa e como o desvio de volta é codificado? |
| 5. Detalhes e bordas | O que muda com a otimização, com a vetorização e com a plataforma? |
| 6. Erros comuns e boas práticas | Quais enganos aparecem quando alguém lê o primeiro assembly da vida? |
| 7. O programa que confere | Quais comandos reproduzem a saída desta aula? |
| 8. Exercícios | Como praticar, com as respostas? |
| 9. Glossário | Quais termos a aula usa? |
1. O que é assembly
Assembly é uma linguagem em que cada linha corresponde a uma instrução da ISA, em texto legível. Uma linha como addq (%rdi,%rcx,8), %rax diz à CPU x86-64 que some ao registrador rax o valor da memória no endereço rdi mais rcx vezes 8. Essa linha não é executada diretamente. Ela é transformada em bytes, a mesma forma que a CPU lê, e é essa transformação que a aula da ISA chamou de codificação. Por isso assembly fica entre o C e a máquina: é quase a mesma coisa que os bytes, só que com nomes que um humano consegue ler.
As três formas do mesmo código têm papéis diferentes, e vale separá-las logo no começo. O código C é o que você escreve e o que a linguagem garante. O assembly é o texto que o compilador produz, uma escolha entre muitas traduções possíveis. Os bytes são o que fica no arquivo objeto e o que a CPU executa. Quem converte C em assembly é o compilador, e quem converte assembly em bytes é o montador, que no LLVM vem junto com o clang. A conversão de volta, de bytes para texto, é feita pelo desmontador, e aqui usamos o objdump.
A figura abaixo mostra esse caminho com as ferramentas usadas nesta aula, e a seta pontilhada que sai dos bytes de volta ao texto é o que permite conferir o que o compilador de fato gerou.
Leia a figura da esquerda para a direita. O laco.c é o código que você escreve, em laranja. A seta laranja com o rótulo clang -S compila gera o laco.s, o texto em assembly, que aparece com a marca da arquitetura. Daí o montador, à direita de cima, transforma o texto em bytes no arquivo soma.o, em verde. O objdump, embaixo, lê esses bytes e devolve texto, e a seta pontilhada fecha o ciclo voltando ao assembly. A figura não mostra o endereço de cada byte, e a seção 4.4 faz isso para o desvio.
2. Para que serve
A maior parte dos programadores nunca escreve assembly à mão, e não precisa. O motivo para ler assembly é outro: o compilador é um tradutor com opiniões, e às vezes a opinião não bate com a nossa intuição. Um laço que parece lento pode estar sendo desenrolado em duas vias. Uma chamada que parece barata pode estar sendo feita por uma instrução que lê a memória duas vezes. Quando a dúvida é de desempenho, o texto gerado é a única resposta confiável, porque o código-fonte diz o que o programa deve fazer e não o que a CPU faz.
Sem essa leitura, o desempenho vira palpite. Alguém troca um for por um while e espera uma diferença, sem olhar se o compilador já tinha gerado o mesmo código nos dois casos. Lendo o assembly, a pergunta muda: o laço está mantendo o contador em registrador ou na pilha? Ele usa a instrução de soma com a memória, ou carrega o valor antes? Essas perguntas têm resposta no texto gerado e ajudam a escolher o que vale a pena mudar.
Há um segundo motivo, mais prático: depuração. Quando um programa trava e o depurador mostra só um endereço, o desmontador é o que transforma esse endereço em algo que se pode entender. Em um crash de produção sem símbolos, ler a instrução que falhou pode ser a diferença entre achar a causa em minutos e passar a tarde procurando.
3. Exemplos em contextos diferentes
O primeiro exemplo é o de uma função de cálculo que ficou lenta depois de uma atualização do compilador. A equipe não sabe se o problema está no código ou na ferramenta. Comparando o assembly gerado pelas duas versões do compilador para o mesmo arquivo, é possível ver se o laço interno mudou de forma, por exemplo de um acesso à memória por iteração para um acesso a cada duas. A resposta vem do texto, e não de uma medição de tempo que pode ter outro ruído.
O segundo exemplo é o de um binário que roda bem em um Mac com chip ARM e falha em um servidor com x86-64. Um desmontador mostra qual instrução de ARM aparece no lugar que o servidor não reconhece, e a partir daí a causa costuma ser uma biblioteca compilada para a outra arquitetura. Sem a leitura dos bytes, a mensagem de erro do sistema diz só que o arquivo não é executável.
O terceiro exemplo é o de conferir se o compilador usou uma instrução especial. Algumas funções de criptografia e de compressão têm instruções dedicadas no processador, e o programador só sabe se elas foram usadas olhando o assembly. Essa é a situação em que a leitura vira uma prova, e não uma curiosidade.
4. Como funciona
Esta seção acompanha um único laço do começo ao fim. O código em C é uma soma de todos os elementos de um vetor, e ele é o mesmo para as duas arquiteturas.
long soma_ate(const long *v, long n) {
long s = 0;
for (long i = 0; i < n; i++) {
s += v[i];
}
return s;
}
A função recebe um ponteiro para o vetor e o número de elementos, e devolve a soma. Essa é a única fonte de verdade da aula. Tudo o que vem depois é a forma como um compilador escolhe representá-la.
4.1 O laço em C e a versão sem otimização
Um compilador sem otimização faz uma tradução quase literal. Cada variável do C vira uma posição na pilha, e cada passo do laço carrega essas variáveis da memória para um registrador, faz a conta e grava de volta. Esse resultado é o que o clang gera com a opção de nível zero, e ele é útil justamente por ser fácil de seguir. Para x86-64, a função tem o corpo do laço espalhado entre blocos com rótulos como LBB0_1, e cada bloco começa e termina de forma previsível.
A listagem abaixo é a saída real do clang para x86-64 com a opção -O0. As linhas de diretiva do assembler, que começam com ponto, foram removidas por um filtro, e nada mais foi editado.
_soma_ate: ## @soma_ate
## %bb.0:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -8(%rbp)
movq %rsi, -16(%rbp)
movq $0, -24(%rbp)
movq $0, -32(%rbp)
LBB0_1: ## =>This Inner Loop Header: Depth=1
movq -32(%rbp), %rax
cmpq -16(%rbp), %rax
jge LBB0_4
## %bb.2: ## in Loop: Header=BB0_1 Depth=1
movq -8(%rbp), %rax
movq -32(%rbp), %rcx
movq (%rax,%rcx,8), %rax
addq -24(%rbp), %rax
movq %rax, -24(%rbp)
## %bb.3: ## in Loop: Header=BB0_1 Depth=1
movq -32(%rbp), %rax
addq $1, %rax
movq %rax, -32(%rbp)
jmp LBB0_1
LBB0_4:
movq -24(%rbp), %rax
popq %rbp
retq
## -- End function
Cada variável do C aparece como um endereço na pilha, relativo a rbp. A soma s fica em -24(%rbp), o índice i fica em -32(%rbp), e o tamanho n fica em -16(%rbp). O laço começa no rótulo LBB0_1, compara i com n (cmpq) e sai se i não for menor (jge). O corpo carrega o valor do vetor, soma a s e grava s de volta na pilha. O incremento de i faz o mesmo percurso de ida e volta, e um jmp leva o fluxo de volta ao cabeçalho.
Esse estilo de código é lento de propósito, e a lentidão não tem nada de mistério: o compilador não tem razão para guardar nada em registrador enquanto não for pedido para otimizar. Para o leitor de hoje, a versão sem otimização tem uma vantagem clara, que é mostrar a estrutura do laço sem disfarce. Os blocos e os desvios estão todos ali, e a figura abaixo os desenha como uma cadeia de decisões.
A figura 02 usa a versão com -O2, que é a próxima seção. Ela está aqui porque sua leitura serve para comparar os dois ISAs lado a lado. Na versão de -O0 há o mesmo caminho, mas com as variáveis indo e voltando da pilha em cada passo.
4.2 A versão com -O2: quatro instruções por volta
Com otimização, o compilador mantém o índice e a soma em registradores e elimina os acessos à pilha. Para deixar o laço escalar fácil de ler, a compilação abaixo desliga a vetorização e o desenrolamento de laço, dois recursos que tratamos na seção 5. As duas listagens saíram da mesma função, com as mesmas opções, uma para cada ISA.
_soma_ate: ## @soma_ate
testq %rsi, %rsi
jle LBB0_1
xorl %ecx, %ecx
xorl %eax, %eax
LBB0_4: ## =>This Inner Loop Header: Depth=1
addq (%rdi,%rcx,8), %rax
incq %rcx
cmpq %rcx, %rsi
jne LBB0_4
retq
LBB0_1:
xorl %eax, %eax
retq
_soma_ate: ; @soma_ate
cmp x1, #1
b.lt LBB0_4
mov x8, #0 ; =0x0
LBB0_2: ; =>This Inner Loop Header: Depth=1
ldr x9, [x0], #8
add x8, x9, x8
subs x1, x1, #1
b.ne LBB0_2
mov x0, x8
ret
LBB0_4:
mov x8, #0 ; =0x0
mov x0, x8
ret
As duas listagens são saídas reais, com as diretivas do assembler removidas por filtro, como na seção 4.1. O corpo do laço tem quatro instruções em cada arquitetura, e elas fazem a mesma coisa de formas diferentes.
No x86-64, a instrução addq já lê a memória no endereço rdi mais rcx vezes 8, soma o valor a rax e grava o resultado em rax. Ela é a mesma soma com acesso à memória que a aula da ISA mostrou, e aqui ela aparece dentro do laço. A seguir vem incq, que avança o índice rcx, e cmpq, que compara rcx com o tamanho rsi e ajusta as flags. O jne lê a flag de zero e volta ao corpo enquanto a comparação não for igual.
No ARM64, o laço se reorganiza. A instrução ldr x9, [x0], #8 carrega o valor e avança o ponteiro x0 em oito bytes depois do acesso, o que o desmontador mostra com o sufixo pós-indexado. A soma é feita em outro passo, com add x8, x9, x8, e o contador é uma contagem regressiva: subs x1, x1, #1 subtrai um de x1 e grava as flags, e b.ne volta enquanto o resultado não for zero. O compilador trocou a variável do laço. Em vez de subir um índice até n, ele desce o tamanho até zero e anda com o ponteiro. As duas formas computam a mesma soma, e o compilador escolheu a que mais combina com a instrução de cada arquitetura.
Há um detalhe que o leitor de assembly precisa ter em mente: a ordem dos operandos muda entre as duas listagens. No x86 da saída, o operando de origem vem antes do de destino, como em addq origem, destino. No ARM, o destino vem primeiro, como em add x8, x9, x8. Essa diferença explica boa parte dos erros de leitura da seção 6.
Vale notar também o que a saída do -O2 não mostra. A instrução nopl que aparece no objeto x86, na seção 4.4, é preenchimento de alinhamento antes do corpo do laço e não é executada dentro dele. O laço em si tem quatro instruções nos dois ISAs.
4.3 Flags: quem grava e quem lê
Nos dois ISAs, o desvio do fim do laço não compara números diretamente. Ele lê um bit de estado que uma instrução anterior gravou, e é aqui que a aula ULA e registradores entra: a flag é o resultado da subtração guardado em bits que o desvio consulta depois.
No x86-64, cmpq rcx, rsi faz a subtração rcx menos rsi, descarta o resultado e grava as flags como a instrução SUB faria. O Manual de Referência da Intel diz que o CMP compara fazendo a subtração do segundo operando ao primeiro e ajustando as flags do mesmo modo que o SUB, sem guardar o resultado. A flag que interessa ao jne é ZF, a flag de zero. Se a subtração deu zero, ZF vale 1, e o jne não desvia. Se não deu zero, o desvio acontece.
No ARM64, subs x1, x1, #1 faz as duas coisas ao mesmo tempo: grava o resultado em x1 e atualiza as flags N, Z, C e V. O espelho do manual da Arm que consultamos descreve a instrução como uma subtração que atualiza as flags com base no resultado. O b.ne lê a flag Z, e desvia se ela estiver em zero. Repare na convenção: no ARM, b.ne pergunta “o resultado não foi zero?” usando Z, enquanto no x86 jne pergunta “ZF é zero?”. As duas perguntas são a mesma, escritas de jeitos diferentes.
A figura abaixo coloca essas duas formas ao lado da comparação direta do RISC-V, que a aula 3.3 mostrou.
Leia a figura de cima para baixo. As duas primeiras linhas são o mesmo modelo: uma instrução que grava flags (em roxo), um registrador de estado que as guarda (em verde) e um desvio que lê uma delas (em rosa). A seta verde com rótulo grava flags é a escrita, e a seta com lê ZF ou lê Z é a leitura. A linha de baixo, em laranja, mostra o RISC-V: não há registrador de flags, porque o próprio bne compara dois registradores. A aula 3.3 explicou essa escolha, e a figura só põe as três lado a lado.
4.4 O desvio de volta em bytes
O desvio do laço é a parte que mais confunde quem está começando. Ele não guarda o endereço de destino. Guarda um deslocamento, e o endereço é calculado somando esse deslocamento a alguma referência. A pergunta que importa é: a referência é o endereço da própria instrução, ou o da instrução seguinte? A resposta muda de ISA para ISA.
O objdump mostra os bytes de cada instrução e o endereço em que ela está dentro do objeto. Para o x86-64 da seção 4.2, a listagem é esta.
x86_o2.o: file format mach-o 64-bit x86-64
Disassembly of section __TEXT,__text:
0000000000000000 <_soma_ate>:
0: 48 85 f6 testq %rsi, %rsi
3: 7e 18 jle 0x1d <_soma_ate+0x1d>
5: 31 c9 xorl %ecx, %ecx
7: 31 c0 xorl %eax, %eax
9: 0f 1f 80 00 00 00 00 nopl (%rax)
10: 48 03 04 cf addq (%rdi,%rcx,8), %rax
14: 48 ff c1 incq %rcx
17: 48 39 ce cmpq %rcx, %rsi
1a: 75 f4 jne 0x10 <_soma_ate+0x10>
1c: c3 retq
1d: 31 c0 xorl %eax, %eax
1f: c3 retq
O jne está no endereço 0x1a e ocupa dois bytes, 75 f4. O byte 75 é o código de jne e o byte f4 é o deslocamento, em complemento de dois, que vale -12. O alvo é 0x10, o endereço do addq do corpo. A conta que confere esse resultado é 0x1a + 2 = 0x1c, que é o endereço da instrução seguinte, e 0x1c + (-12) = 0x10. Ou seja, no x86 o deslocamento é somado ao endereço que vem depois do desvio. Isso é o que o manual da Intel descreve ao definir o Jcc, em que o alvo é EIP mais o deslocamento sinalizado. Os bytes confirmam a leitura: a soma é feita a partir do endereço seguinte.
Para o ARM64 da mesma seção, a listagem é esta.
arm_o2.o: file format mach-o arm64
Disassembly of section __TEXT,__text:
0000000000000000 <ltmp0>:
0: f100043f cmp x1, #0x1
4: 5400010b b.lt 0x24 <ltmp0+0x24>
8: d2800008 mov x8, #0x0 ; =0
c: f8408409 ldr x9, [x0], #0x8
10: 8b080128 add x8, x9, x8
14: f1000421 subs x1, x1, #0x1
18: 54ffffa1 b.ne 0xc <ltmp0+0xc>
1c: aa0803e0 mov x0, x8
20: d65f03c0 ret
24: d2800008 mov x8, #0x0 ; =0
28: aa0803e0 mov x0, x8
2c: d65f03c0 ret
A palavra do b.ne é 0x54ffffa1, e o campo imm19 ocupa os bits 23 a 5. Esse campo vale -3, e a conta é a seguinte: o alvo é o endereço da própria instrução, 0x18, mais o deslocamento em instruções, -3, multiplicado por 4, que dá -12. O resultado é 0x18 - 12 = 0x0c, o ldr do corpo. O espelho do manual da Arm diz que o deslocamento do desvio, a partir do endereço da instrução, é codificado como o imm19 multiplicado por 4, e o comportamento está nessa mesma ordem de ideias.
O programa da seção 7 faz essas contas com a mesma aritmética dos manuais, e o resultado coincide com o do objdump em todos os casos: o jle do x86 cai em 0x1d, o jne cai em 0x10, o b.lt do ARM cai em 0x24 e o b.ne cai em 0x0c.
A figura abaixo põe os dois cálculos lado a lado, com os mesmos endereços.
Siga a figura de baixo para cima, começando pela instrução de desvio em cada coluna. No x86, a seta laranja que sai do jne em 0x1a e volta ao addq em 0x10 tem o rótulo com a conta que usa o endereço 0x1c, o seguinte. No ARM, a seta sai do b.ne em 0x18 e chega ao ldr em 0x0c, com a conta que usa o próprio 0x18. A saída não tomada, em laranja de borda com o rótulo não, segue para a instrução de saída em cada coluna. As duas colunas usam a mesma régua de endereços, e a diferença de referência é o que a figura quer mostrar.
4.5 Uma volta do laço na CPU
Até aqui o assembly foi lido como texto. Vale ver o que a CPU faz com ele, usando o modelo da aula 3.3. Cada instrução do corpo passa pela busca, pela decodificação e pela execução, e o contador de programa avança a cada busca. Quando o desvio é tomado, o contador recebe o alvo, e a sequência recomeça no corpo.
Os números que a aula tira do código são estes. Pela leitura das listagens, uma volta do corpo executa quatro instruções, e a chamada com n igual a 3 executa, no total, 17 instruções, contando o teste inicial, as três voltas e o retorno. Esse número foi derivado do código, e não medido em nenhum processador: ele não diz quantos ciclos a CPU gastou, porque a aula 3.3 usou um modelo didático de ciclos, e este texto não mede nenhum processador real.
Essa é a parte que liga o assembly à unidade de controle descrita na aula 3.3. O desvio de volta é a instrução que decide, a cada volta, se o contador vai para o corpo ou para a saída. Sem ele, o laço seria uma sequência reta de quatro instruções. Com ele, a mesma sequência se repete até que a flag mande sair.
4.6 Como ler o rastro de uma execução
Um rastro é a lista das instruções na ordem em que a CPU as executou. Para o laço desta aula, o rastro com n igual a 3 é o seguinte: o teste inicial, depois quatro instruções repetidas três vezes, e por fim o retorno. Em cada volta, o desvio de volta cai no corpo, e na última o desvio não é tomado e o fluxo segue para a saída. Ler o rastro com essa estrutura facilita ver por que o laço termina no número certo de voltas, e não no número de instruções executadas.
5. Detalhes e bordas
5.1 A vetorização que o -O2 faz por padrão
Compilando a mesma função com -O2 e sem as opções que desligamos, o clang gera uma versão bem diferente. Ele processa vários elementos por iteração, com instruções que operam sobre registradores vetoriais. No x86 aparecem movdqu para carregar e paddq para somar, com registradores xmm, e no ARM aparecem ldp e add.2d com registradores v. O laço vetorizado é mais longo de ler, mas faz menos voltas, porque cada volta soma vários elementos.
Esta aula não mediu o ganho de desempenho dessa versão, e a escolha de desligar a vetorização foi para ler o laço escalar. A vetorização é um tema da fase 5 da trilha, sobre paralelismo dentro de uma única CPU. Para esta aula, basta saber que o assembly que você lê depende das opções de compilação, e que uma comparação de desempenho sem dizer as opções não diz nada.
5.2 Símbolos e convenções de plataforma
Repare que o nome da função aparece como _soma_ate nas listagens. O clang para macOS coloca um sublinhado na frente de cada símbolo, e isso é uma convenção do formato de arquivo Mach-O, usado pela Apple. Em Linux, o mesmo código ganha outro formato de arquivo e outra convenção de nome. Ler um símbolo com sublinhado como se fosse diferente do C é um engano pequeno e comum. A função é a mesma, só mudou o nome no arquivo.
A plataforma também muda a sintaxe do desmontador. O objdump do x86 usa a sintaxe AT&T, com o operando de origem antes do de destino e com prefixos de tamanho como o q de addq. A sintaxe da Intel, usada em muitos manuais, inverte a ordem. Ao comparar exemplos de fontes diferentes, confirme a sintaxe antes de ler a ordem dos operandos.
5.3 O que a saída não diz
A listagem mostra o que o compilador escolheu, mas não mostra quanto cada instrução custa na CPU real. Duas instruções curtas podem ser lentas se dependerem uma da outra, e uma instrução longa pode ser rápida se o processador a executar em paralelo. Medir o tempo, com um benchmark bem feito, é o que responde à pergunta de desempenho. O assembly responde à pergunta de estrutura, e as duas respostas se complementam.
6. Erros comuns e boas práticas
O primeiro erro é ler o assembly de -O0 como se fosse o que roda em produção. A versão sem otimização mostra cada variável na pilha, e nenhum build sério de produção a usa. Antes de tirar conclusões sobre desempenho, confirme o nível de otimização que foi usado na compilação que você está lendo.
O segundo erro é inverter a ordem dos operandos. No x86 da saída, addq origem, destino soma a origem ao destino. No ARM, add destino, origem1, origem2 escreve o resultado no primeiro operando. Quem lê as duas listagens sem notar a diferença pode concluir que o laço faz contas diferentes, quando na verdade computa a mesma soma com a ordem que cada sintaxe pede.
O terceiro erro é calcular o alvo do desvio com a referência errada. No x86, a referência é o endereço da instrução seguinte. No ARM, é o endereço da própria instrução. Quem usa a mesma regra para os dois obtém um endereço que parece plausível e que cai em outra instrução do laço. A boa prática é escrever a conta com os endereços ao lado, como na seção 4.4, e conferir o resultado com o objdump, que mostra o alvo ao lado da instrução.
O quarto erro é tomar o número de instruções como medida de velocidade. Os dois laços desta aula têm quatro instruções por volta, mas isso não diz que são igualmente rápidos em qualquer CPU. A contagem serve para comparar estruturas, e não para prever tempo.
O quinto erro, o mais sutil, é esquecer que o desvio depende de uma flag gravada antes. No ARM, se o subs fosse trocado por um sub sem o s, o b.ne leria flags antigas, sem relação com o contador. A boa prática é ler a instrução que grava a flag junto com a que a lê, e não só a que desvia.
A boa prática geral é a mesma da aula 3.3: confira o que você acha que o código faz lendo o rastro e os bytes, e não só o texto. O objdump é a ferramenta que fecha essa conta, porque mostra o que o compilador de fato gerou.
7. O programa que confere
A conferência desta aula tem três partes, feitas neste Mac com o clang da Apple, versão 21.0.0, e com o Python isolado. Só a terceira parte executa código, e só para arm64.
A primeira parte compila o laço para as duas arquiteturas e lista os objetos, com as opções já mostradas. Os comandos foram:
clang -std=c11 -target x86_64-apple-macos13 -O2 -fno-vectorize -fno-unroll-loops -fomit-frame-pointer -c soma.c -o x86_o2.o
objdump -d x86_o2.o
clang -std=c11 -target arm64-apple-macos13 -O2 -fno-vectorize -fno-unroll-loops -fomit-frame-pointer -c soma.c -o arm_o2.o
objdump -d arm_o2.o
A saída desses comandos está nas seções 4.2 e 4.4. A listagem da seção 4.2 tem as diretivas do assembler removidas por filtro, e a da seção 4.4 é o objdump completo, sem edição.
A segunda parte confere os alvos dos desvios com um programa independente, que decodifica os bytes que o objdump mostrou. O comando foi python3 -I decodifica_desvios.py, e a saída real foi esta.
x86-64 (Jcc rel8, alvo = próximo PC + rel8)
jle em 0x03: bytes 7e 18, proximo=0x05, rel8=24, alvo=0x1d
jne em 0x1a: bytes 75 f4, proximo=0x1c, rel8=-12, alvo=0x10
ARM64 (B.cond, alvo = PC da instrução + imm19 * 4)
b.lt em 0x04: palavra 0x5400010b, cond=0xb, imm19=8, deslocamento=32, alvo=0x24
b.ne em 0x18: palavra 0x54ffffa1, cond=0x1, imm19=-3, deslocamento=-12, alvo=0x0c
Os quatro alvos batem com o objdump: 0x1d, 0x10, 0x24 e 0x0c. A parte que o programa não verifica é a regra de cada arquitetura, que foi lida na fonte citada na seção de fontes. O programa aplica essa regra aos bytes, e o objdump serve de árbitro independente.
A terceira parte executa a função. O código de teste soma {1, 2, 3} e também chama a função com n igual a zero. A compilação e a execução foram feitas para arm64 nativo, e a saída real foi esta.
soma_ate = 6
soma_ate vazio = 0
A soma 1 mais 2 mais 3 dá 6, e com zero elementos a função devolve 0. A versão para x86-64 foi compilada com a mesma linha de comando, mas não foi executada: a máquina não tem o Rosetta instalado, e o binário x86-64 falhou com a mensagem de tipo de CPU incompatível. Essa parte fica declarada como lacuna.
8. Exercícios
Exercício 1. No x86-64 da seção 4.4, o jne fica no endereço 0x1a e tem o byte de deslocamento f4. Qual é o endereço do alvo?
Resposta: o endereço seguinte ao jne é 0x1c, e o deslocamento f4 vale -12. Então o alvo é 0x1c - 12, que dá 0x10, o início do corpo do laço.
Exercício 2. No ARM64, o b.ne está em 0x18 e o campo imm19 vale -3. Por que o deslocamento em bytes é -12, e não -3?
Resposta: o imm19 conta instruções de 4 bytes, não bytes. Por isso o deslocamento em bytes é o imm19 multiplicado por 4, que dá -12. O alvo é 0x18 - 12, que dá 0x0c, o ldr do corpo.
Exercício 3. Se a instrução subs x1, x1, #1 fosse trocada por sub x1, x1, #1, o que aconteceria com o b.ne?
Resposta: o sub sem o s faz a subtração, mas não atualiza as flags. O b.ne leria as flags deixadas por alguma instrução anterior, e essas flags não têm relação com o contador. O laço deixaria de parar no momento certo. A regra vem da descrição de SUBS, que diz que a instrução atualiza as flags.
Exercício 4. Por que a versão -O0 tem muito mais instruções do que a -O2 para a mesma função?
Resposta: a versão -O0 guarda cada variável na pilha e recarrega o valor a cada uso, com instruções de carga e de gravação entre as contas. A versão -O2 mantém o índice e a soma em registradores, o que elimina essas idas e vindas. Os números de linhas do objdump foram 21 para o x86-64 e 25 para o ARM64 na versão -O0, contra 12 e 12 na -O2, mas essas contagens incluem o prólogo e o epílogo da função, e não medem o tempo de execução.
Exercício 5 (sem resposta fechada). Escreva um laço que some apenas os elementos pares de um vetor, compile com -O2, -fno-vectorize e -fno-unroll-loops para x86-64 e ARM64, e compare as quatro instruções do corpo com as da seção 4.2. Diga quais instruções mudaram e por quê. Este exercício depende do código que você escrever, e a resposta não é fechada.
9. Glossário
Assembly: linguagem em que cada linha corresponde a uma instrução da ISA, escrita em texto legível.
Montador: programa que transforma assembly em bytes de máquina e grava o resultado em um arquivo objeto.
Desmontador: programa que lê bytes de máquina e devolve as instruções em texto. Esta aula usou o objdump.
Flag: bit de estado que uma instrução grava e um desvio lê. No x86 e no ARM, as flags de comparação decidem o desvio condicional.
Registrador de flags: lugar onde a CPU guarda as flags. No x86 é o EFLAGS, e no ARM64 os bits N, Z, C e V.
Desvio de volta: desvio que leva o fluxo para uma instrução anterior, e é o que forma o laço.
Deslocamento: valor com sinal que o desvio soma a uma referência para obter o alvo. A referência muda de ISA para ISA.
Pós-indexado: modo de endereçamento do ARM em que o endereço usado é o do registrador base, e o registrador é avançado depois do acesso.
Vetorização: transformação do compilador que faz uma instrução processar vários elementos de uma vez.
Sintaxe AT&T: sintaxe do x86 em que o operando de origem vem antes do de destino, com sufixos de tamanho como o q.
Mach-O: formato de arquivo usado no macOS, que coloca um sublinhado na frente do nome das funções.
O que não foi confirmado ou medido
Nenhum laço desta aula foi medido em tempo, frequência ou consumo. As contagens de instruções são leituras das listagens, não medidas de execução, e a contagem de 17 instruções para n igual a 3 é uma derivação do código.
A função x86-64 não foi executada, porque o Rosetta não está instalado neste Mac. A versão arm64 foi executada e devolveu 6 e 0, como mostrado na seção 7. A conferência do comportamento x86 fica como lacuna declarada.
O manual DDI 0487 da Arm, que é a fonte primária do ARM64, não foi alcançado. O que a aula usou foi espelho do XML oficial de 2026-03 e uma página mantida por terceiros. Os textos citados sobre B.cond e SUBS vêm desses espelhos, e a descrição do LDR pós-indexado veio de um espelho que não traz o texto de descrição da forma pós-indexada, apenas o pseudocódigo. A aula trata o comportamento como conferido pelos espelhos e pelo desmontador, e não pela fonte primária.
A afirmação de que processadores x86 atuais decodificam instruções em micro-operações não foi conferida nesta aula. A aula 3.2 registra o mesmo limite.
A vetorização descrita na seção 5.1 foi observada na saída do clang, mas o ganho de desempenho dela não foi medido.
Fontes
Consultadas em 1 de novembro de 2026.
- Intel, Intel 64 and IA-32 Architectures Software Developer’s Manual, edição combinada de setembro de 2026, volume 2A: CMP, página 3-161, com a descrição da subtração e do ajuste das flags. Jcc, página 3-499, com a operação EIP mais o deslocamento sinalizado. Arquivo consultado: https://cdrdv2.intel.com/v1/dl/getContent/671200 (PDF de 26 MB, lido com pdftotext). Página de download: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- Arm, A64 instruction set do A-profile, release 2026-03, espelho do XML oficial, página de B.cond: https://kib.kiev.ua/x86docs/ARM/XML/2026-09/ISA_A64_xml_A_profile_2026-03_96-2026-03_rel/b_cond.xml (usado para imm19 e para a soma do deslocamento multiplicado por 4). Espelho não oficial.
- Página de SUBS (immediate), mantida por terceiros, espelho de pseudocódigo da Arm: https://www.scs.stanford.edu/~zyedidia/arm64/subs_addsub_imm.html (usado para as flags NZCV). Espelho não oficial.
- Página de LDR (immediate), forma gen, mantida por terceiros: https://www.scs.stanford.edu/~zyedidia/arm64/ldr_imm_gen.html (usada para o pseudocódigo do modo pós-indexado). Espelho não oficial.
- Ferramentas usadas: Apple clang 21.0.0 (clang-2100.3.34.2) com os alvos x86_64-apple-macos13 e arm64-apple-macos13, objdump da LLVM do macOS, Python 3.12 no modo isolado (python3 -I).
Relacionados
- Trilha de Hardware
- ISA, CISC e RISC
- Ciclo de instrução e unidade de controle
- Pilha e chamada de função
- ULA e registradores
- CPU