Você está na página 1de 300

Machine Translated by Google

Nível baixo
Programação
C, montagem e execução do programa em
Arquitetura Intel® 64
-

Igor Zhirkov
Machine Translated by Google

Programação de baixo nível


C, montagem e execução do programa em
Arquitetura Intel® 64

Igor Zhirkov
Machine Translated by Google

Programação de baixo nível: C, Assembly e execução de programas na arquitetura Intel® 64

Igor Zhirkov
São Petersburgo, Rússia

ISBN-13 (pbk): 978-1-4842-2402-1 DOI ISBN-13 (eletrônico): 978-1-4842-2403-8


10.1007/978-1-4842-2403-8

Número de controle da Biblioteca do Congresso: 2017945327

Copyright © 2017 por Igor Zhirkov

Este trabalho está sujeito a direitos autorais. Todos os direitos são reservados à Editora, quer se trate da totalidade ou de parte
do material, especificamente os direitos de tradução, reimpressão, reutilização de ilustrações, recitação, transmissão,
reprodução em microfilmes ou de qualquer outra forma física, e transmissão ou armazenamento de informações e recuperação,
adaptação eletrônica, software de computador ou por metodologia semelhante ou diferente agora conhecida ou desenvolvida
posteriormente.

Nomes, logotipos e imagens de marcas registradas podem aparecer neste livro. Em vez de usar um símbolo de marca registrada
em cada ocorrência de um nome, logotipo ou imagem de marca registrada, usamos os nomes, logotipos e imagens apenas
de forma editorial e para o benefício do proprietário da marca registrada, sem intenção de violar a marca registrada.

O uso nesta publicação de nomes comerciais, marcas registradas, marcas de serviço e termos semelhantes, mesmo que não
sejam identificados como tal, não deve ser tomado como uma expressão de opinião sobre se estão ou não sujeitos a direitos de
propriedade.

Embora os conselhos e as informações contidas neste livro sejam considerados verdadeiros e precisos na data de publicação, nem
os autores, nem os editores, nem a editora podem aceitar qualquer responsabilidade legal por quaisquer erros ou omissões
que possam ser cometidos. O editor não oferece nenhuma garantia, expressa ou implícita, com relação ao material aqui contido.

Imagem da capa desenhada por Freepik

Diretor Geral: Welmoed Spahr


Diretor Editorial: Todd Green
Editor de aquisições: Robert Hutchinson
Editora de Desenvolvimento: Laura Berendson
Revisor Técnico: Ivan Loginov
Editora Coordenadora: Rita Fernando
Editora: Lori Jacobs
Compositor: SPi Global
Indexador: SPi Global

Distribuído para o comércio de livros em todo o mundo pela Springer Science+Business Media New York,
233 Spring Street, 6th Floor, New York, NY 10013. Telefone 1-800-SPRINGER, fax (201) 348-4505, pedidos por e-mail-ny @
springer-sbm.com, ou visite www.springeronline.com. Apress Media, LLC é uma LLC da Califórnia e o único membro (proprietário)
é Springer Science + Business Media Finance Inc (SSBM Finance Inc).
SSBM Finance Inc é uma corporação de Delaware .

Para obter informações sobre traduções, envie um e-mail para rights@apress.com, ou visite http://www.apress.com/
permissões de direitos.

Os títulos da Apress podem ser adquiridos em grandes quantidades para uso acadêmico, corporativo ou promocional. Versões e licenças
de e-books também estão disponíveis para a maioria dos títulos. Para obter mais informações, consulte nossa página de vendas em massa de
impressão e e-books em http://www.apress.com/bulk-sales.

Qualquer código-fonte ou outro material suplementar referenciado pelo autor neste livro está disponível aos leitores no GitHub através
da página do produto do livro, localizada em www.apress.com/9781484224021. Para obter informações mais detalhadas, visite
http://www.apress.com/source-code.

Impresso em papel sem ácido


Machine Translated by Google

Resumo do conteúdo

Sobre o autor ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿxix

Sobre o Revisor Técnico ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxi

Agradecimentosÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxiii

Introdução ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿxxv

ÿParte I: Linguagem Assembly e Arquitetura de Computadoresÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 1

ÿCapítulo 1: Arquitetura Básica de Computadoresÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 3

ÿCapítulo 2: Linguagem Assemblyÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 17

ÿCapítulo 3: Legadoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ 39

ÿCapítulo 4: Memória Virtual ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 47

ÿCapítulo 5: Pipeline de compilação ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 63

ÿCapítulo 6: Interrupções e chamadas do sistemaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 91

ÿCapítulo 7: Modelos de Computação ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 101

ÿParte II: A linguagem de programação Cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ 127

ÿCapítulo 8: Noções básicas ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 12

ÿCapítulo 9: Sistema de tiposÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 147

ÿCapítulo 10: Estrutura do Código ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 181

ÿCapítulo 11: Memóriaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 201

ÿCapítulo 12: Sintaxe, Semântica e Pragmática ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿ 221

ÿCapítulo 13: Boas Práticas de Código ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 241

iii
Machine Translated by Google

ÿ Visão geral do conteúdo

ÿParte III: Entre C e a Montagem ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿ 263

ÿCapítulo 14: Detalhes da Tradução ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 265

ÿCapítulo 15: Objetos Compartilhados e Modelos de Códigoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿ 291

ÿCapítulo 16: Desempenhoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 327

ÿCapítulo 17: Multithreading ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 357

ÿParte IV: Apêndices ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 397

ÿCapítulo 18: Apêndice A. Usando gdb ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 399

ÿCapítulo 19: Apêndice B. Usando Make ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 409

ÿCapítulo 20: Apêndice C. Chamadas do sistemaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 415

ÿCapítulo 21: Apêndice D. Informações sobre testes de desempenhoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 421

ÿCapítulo 22: Bibliografiaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 425

Índiceÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 429

4
Machine Translated by Google

Conteúdo

Sobre o autor ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿxix

Sobre o Revisor Técnico ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxi

Agradecimentosÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxiii

Introdução ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿx

ÿParte I: Linguagem Assembly e Arquitetura de Computadoresÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 1

ÿCapítulo 1: Arquitetura Básica de Computadoresÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 3

1.1 A Arquitetura Central ............................................. .................................................. .... 3

1.1.1 Modelo de Computação ............................................ .................................................. ................................ 3

1.1.2 Arquitetura von Neumann ............................................ .................................................. ........................ 3

1.2 Evolução ................................................ .................................................. ...................... 5

1.2.1 Desvantagens da Arquitetura von Neumann ........................................... .................................................. ... 5

1.2.2 Arquitetura Intel 64 ........................................ .................................................. ................................... 6

1.2.3 Extensões de Arquitetura ............................................. .................................................. ............................ 6

1.3 Registros ................................................ .................................................. ..................... 7 1.3.1 Registros de uso

geral ...................... .................................................. .............................................. 8 1.3.2 Outros

Registros.................................................. .................................................. .................................... 11 1.3.3 Registros do

Sistema........ .................................................. .................................................. ........................ 12

1.4 Anéis de Proteção ............................................. .................................................. ......... 14

1.5 Pilha de Hardware ............................................. .................................................. .......... 14

1.6 Resumo ................................................ .................................................. ................... 16

v
Machine Translated by Google

ÿ Conteúdo

ÿCapítulo 2: Linguagem Assemblyÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 17

2.1 Configurando o Ambiente ............................................. ........................................... 17 2.1.1 Trabalhando com

Exemplos de código ................................................ .................................................. .............. 18

2.2 Escrevendo “Olá, mundo” ........................................... .................................................. ..... 18

2.2.1 Entrada e Saída Básicas ........................................... .................................................. ............................ 18

2.2.2 Estrutura do Programa............................................. .................................................. ................................... 19

2.2.3 Instruções Básicas ............................................. .................................................. ................................... 20

2.3 Exemplo: Conteúdo do Registrador de Saída .......................................... .................................. 22

2.3.1 Rótulos Locais............................................. .................................................. ........................................... 23

2.3.2 Endereçamento Relativo............................................. .................................................. ................................ 23

2.3.3 Ordem de Execução......................................... .................................................. ................................... 24

2.4 Chamadas de Função ............................................. .................................................. ............. 25

2.5 Trabalhando com Dados ............................................. .................................................. ....... 28

2.5.1 Endianidade ............................................. .................................................. ........................................... 28

2.5.2 Sequências ............................................. .................................................. .................................................. .29

2.5.3 Pré-computação Constante ............................................. .................................................. ........................ 30

2.5.4 Ponteiros e Diferentes Tipos de Endereçamento .......................................... .................................................. ... 30

2.6 Exemplo: Cálculo do comprimento da string ........................................... .................................. 32

2.7 Atribuição: Biblioteca de Entrada/Saída ........................................... .................................... 34

2.7.1 Autoavaliação ............................................ .................................................. ........................................ 35

2.8 Resumo ................................................ .................................................. ................... 36

ÿCapítulo 3: Legadoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿ 39

3.1 Modo real ............................................. .................................................. ................... 39

3.2 Modo Protegido ............................................. .................................................. .......... 40

3.3 Segmentação Mínima no Modo Longo ........................................... .............................. 44

3.4 Acessando Partes dos Registros ............................................. ........................................... 45

3.4.1 Um Comportamento Inesperado ............................................ .................................................. .......................... 45

3.4.2 CISC e RISC......................................... .................................................. ........................................... 45

3.4.3 Explicação................................................. .................................................. ........................................... 46

3.5 Resumo ................................................ .................................................. ................... 46

vi
Machine Translated by Google

ÿ Conteúdo

ÿCapítulo 4: Memória Virtual ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 4

4.1 Cache ................................................ .................................................. ...................... 47


4.2 Motivação ................................................ .................................................. .................. 47

4.3 Espaços de Endereço ............................................. .................................................. .......... 48


4.4 Recursos ................................................ .................................................. ..................... 49

4.5 Exemplo: Acessando Endereço Proibido .......................................... ........................... 50

4.6 Eficiência ................................................ .................................................. ................... 52

4.7 Implementação ................................................ .................................................. .......... 52


4.7.1 Estrutura de endereço virtual ........................................... .................................................. ........................... 53

4.7.2 Tradução de endereços em profundidade ........................................... .................................................. .................... 53

4.7.3 Tamanhos de página ............................................. .................................................. .............................................. 56

4.8 Mapeamento de memória ............................................. .................................................. ........ 56

4.9 Exemplo: Mapeando arquivo na memória ........................................... ................................ 57


4.9.1 Nomes mnemônicos para constantes ........................................... .................................................. .............. 57

4.9.2 Exemplo Completo............................................. .................................................. ................................... 58

4.10 Resumo ................................................ .................................................. ................. 60

ÿCapítulo 5: Pipeline de compilação ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 63

5.1 Pré-processador ................................................ .................................................. ............. 64


5.1.1 Substituições Simples............................................. .................................................. ................................ 64

5.1.2 Substituições com Argumentos .......................................... .................................................. ................. 65

5.1.3 Substituição Condicional Simples .......................................... .................................................. .............. 66

5.1.4 Condicionamento na Definição .......................................... .................................................. ....................... 67 5.1.5

Condicionamento na Identidade do Texto ................... .................................................. ........................................... 67 5.1.6

Condicionamento em Tipo de argumento ................................................ .................................................. ........... 68 5.1.7

Ordem de avaliação: Definir, xdefine, Atribuir ........................... .................................................. ................. 69

5.1.8 Repetição ............................................. .................................................. ............................................... 70

5.1.9 Exemplo: Calculando Números Primos .......................................... .................................................. ........ 71

5.1.10 Rótulos dentro de macros ........................................... .................................................. ............................ 72

5.1.11 Conclusão................................................. .................................................. ........................................... 73

vii
Machine Translated by Google

ÿ Conteúdo

5.2 Tradução ................................................ .................................................. ................. 74

5.3 Vinculação ................................................ .................................................. ....................... 74

5.3.1 Formato executável e vinculável ........................................... .................................................. .............. 74

5.3.2 Arquivos de Objetos Relocáveis ......................................... .................................................. ........................... 76

5.3.3 Arquivos de Objetos Executáveis......................................... .................................................. ........................... 80

5.3.4 Bibliotecas Dinâmicas............................................. .................................................. ................................... 81

5.3.5 Carregador................................................... .................................................. .................................................. .. 85

5.4 Tarefa: Dicionário ............................................. .................................................. 87

5.5 Resumo ................................................ .................................................. ................... 89

ÿCapítulo 6: Interrupções e chamadas do sistemaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 91

6.1 Entrada e Saída ............................................. .................................................. .......... 91

6.1.1 Registro TR e Segmento de Estado de Tarefa ..................................... .................................................. ........... 92

6.2 Interrupções ................................................ .................................................. ................... 94

6.3 Chamadas do Sistema ............................................. .................................................. ............... 97

6.3.1 Registros Específicos do Modelo ........................................... .................................................. ........................... 97

6.3.2 syscall e sysret......................................... .................................................. .................................... 97

6.4 Resumo ................................................ .................................................. ................... 99

ÿCapítulo 7: Modelos de Computação ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 101

7.1 Máquinas de Estados Finitos ............................................. .................................................. 101

7.1.1 Definição ............................................. .................................................. ............................................. 101

7.1.2 Exemplo: Paridade de Bits ........................................... .................................................. ................................ 103

7.1.3 Implementação em Linguagem Assembly ........................................... .................................................. .. 103

7.1.4 Valor Prático ............................................. .................................................. .................................... 105

7.1.5 Expressões regulares ............................................. .................................................. ............................ 106

7.2 Quarta Máquina ................................................ .................................................. ........... 109

7.2.1 Arquitetura ............................................. .................................................. ........................................... 109

7.2.2 Traçando um Programa Exemplificativo ........................................... .................................................. ...... 111

7.2.3 Dicionário ............................................. .................................................. ........................................... 112

7.2.4 Como as palavras são implementadas .......................................... .................................................. ................. 112

7.2.5 Compilador ............................................. .................................................. .............................................. 117

viii
Machine Translated by Google

ÿ Conteúdo

7.3 Tarefa: Quarto Compilador e Intérprete...................................... ................... 118


7.3.1 Dicionário Estático, Intérprete ........................................... .................................................. ................... 118

7.3.2 Compilação............................................. .................................................. ........................................... 121

7.3.3 Junto com Bootstrap .......................................... .................................................. .............................. 123

7.4 Resumo ................................................ .................................................. ................. 125

ÿParte II: A linguagem de programação Cÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿ 127

ÿCapítulo 8: Noções básicas ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 12

8.1 Introdução ................................................ .................................................. ............. 129

8.2 Estrutura do Programa ............................................. .................................................. .... 130


8.2.1 Tipos de dados ............................................. .................................................. ........................................... 132

8.3 Fluxo de controle................................................. .................................................. .............. 133

8.3.1 se ............................................. .................................................. .................................................. ........134

8.3.2 enquanto................................................. .................................................. .................................................. ..135

8.3.3 para................................................... .................................................. .................................................. ......135

8.3.4 ir para................................................... .................................................. .................................................. ....136

8.3.5 interruptor............................................... .................................................. .................................................. 137

8.3.6 Exemplo: Divisor.......................................... .................................................. .................................... 138

8.3.7 Exemplo: É um número de Fibonacci? .................................................. .................................................. 138

8.4 Declarações e Expressões ............................................. ........................................ 139


8.4.1 Tipos de declaração ............................................. .................................................. ...................................140

8.4.2 Construindo Expressões ............................................. .................................................. ............................ 141

8.5 Funções ................................................ .................................................. ................. 142

8.6 Pré-processador ................................................ .................................................. ........... 144

8.7 Resumo ................................................ .................................................. ................. 146

ÿCapítulo 9: Sistema de tiposÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 14

9.1 Sistema de Tipo Básico de C......................................... .................................................. 147


9.1.1 Tipos Numéricos ............................................. .................................................. .................................... 147

9.1.2 Fundição de Tipo ............................................. .................................................. ........................................... 149

9.1.3 Tipo Booleano ............................................. .................................................. ........................................ 150

9.1.4 Conversões Implícitas ............................................. .................................................. ............................ 150

ix
Machine Translated by Google

ÿ Conteúdo

9.1.5 Ponteiros ............................................. .................................................. ................................................ 151

9.1.6 Matrizes ............................................. .................................................. .................................................. 153

9.1.7 Matrizes como Argumentos de Função ........................................... .................................................. ................ 153

9.1.8 Inicializadores designados em arrays ........................................... .................................................. ............. 154

9.1.9 Aliases de tipo ............................................. .................................................. ........................................... 155

9.1.10 A Função Principal Revisitada...................................... .................................................. ................. 156

9.1.11 Tamanho do operador ............................................. .................................................. ................................... 157

9.1.12 Tipos Const......................................... .................................................. ........................................... 158

9.1.13 Sequências ............................................. .................................................. ................................................ 160

9.1.14 Tipos Funcionais............................................. .................................................. .................................. 160

9.1.15 Codificando bem......................................... .................................................. ........................................... 162

9.1.16 Atribuição: Produto Escalar ........................................... .................................................. .................. 166

9.1.17 Atribuição: Verificador de Números Primos ........................................... .................................................. ..... 167

9.2 Tipos marcados ............................................. .................................................. ............ 167


9.2.1 Estruturas............................................. .................................................. ........................................... 167

9.2.2 Sindicatos................................................. .................................................. .................................................. 169

9.2.3 Estruturas e Uniões Anônimas ........................................... .................................................. ......... 170

9.2.4 Enumerações............................................. .................................................. ........................................ 171

9.3 Tipos de dados em linguagens de programação ............................................ ........................ 172

9.3.1 Tipos de digitação......................................... .................................................. .................................... 172

9.3.2 Polimorfismo................................................. .................................................. .................................... 174

9.4 Polimorfismo em C ............................................. .................................................. ..... 175

9.4.1 Polimorfismo Paramétrico ............................................. .................................................. .................... 175

9.4.2 Inclusão ............................................. .................................................. .............................................. 177

9.4.3 Sobrecarga ............................................. .................................................. ........................................... 178

9.4.4 Coerções............................................. .................................................. ............................................. 179

9.5 Resumo ................................................ .................................................. ................. 179

ÿCapítulo 10: Estrutura do Código ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 181

10.1 Declarações e Definições ............................................. .................................... 181

10.1.1 Declarações de Função ............................................. .................................................. ........................... 182

10.1.2 Declarações de Estrutura ............................................. .................................................. ........................ 183

x
Machine Translated by Google

ÿ Conteúdo

10.2 Acessando código de outros arquivos ........................................... .................................. 184

10.2.1 Funções de outros arquivos ........................................... .................................................. ................... 184

10.2.2 Dados em outros arquivos ........................................... .................................................. ................................ 185

10.2.3 Arquivos de cabeçalho............................................. .................................................. ........................................ 187

10.3 Biblioteca Padrão ............................................. .................................................. ..... 188

10.4 Pré-processador ................................................ .................................................. ......... 190

10.4.1 Incluir proteção ............................................. .................................................. .................................... 192

10.4.2 Por que o pré-processador é ruim? .................................................. .................................................. ............. 194

10.5 Exemplo: Soma de uma Matriz Dinâmica .......................................... .................................. 195

10.5.1 Espiada na alocação dinâmica de memória ........................................ ........................................... 195

10.5.2 Exemplo............................................. .................................................. ............................................. 195

10.6 Atribuição: Lista Vinculada ............................................. ............................................... 197

10.6.1 Atribuição................................................. .................................................. ........................................ 197

10.7 A palavra-chave estática ............................................. .................................................. .. 198

10.8 Ligação ................................................ .................................................. .................. 199

10.9 Resumo ................................................ .................................................. ............... 200

ÿCapítulo 11: Memóriaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 20

11.1 Ponteiros revisitados ............................................. .................................................. ... 201

11.1.1 Por que precisamos de ponteiros?......................................... .................................................. ..................... 201

11.1.2 Aritmética de Ponteiros ............................................. .................................................. ................................ 202

11.1.3 O tipo void* ........................................... .................................................. .................................... 203

11.1.4 NULO ............................................. .................................................. .................................................. 203

11.1.5 Uma palavra sobre ptrdiff_t ........................................... .................................................. ................................ 204

11.1.6 Ponteiros de Função............................................. .................................................. ................................ 205

11.2 Modelo de Memória ............................................. .................................................. ........ 206

11.2.1 Alocação de memória ............................................. .................................................. .............................. 207

11.3 Matrizes e Ponteiros ............................................. .................................................. .209

11.3.1 Detalhes de Sintaxe............................................. .................................................. .................................... 210

11.4 Literais de String ............................................. .................................................. ......... 211

11.4.1 Estagiário de Strings............................................. .................................................. ................................... 213

XI
Machine Translated by Google

ÿ Conteúdo

11.5 Modelos de Dados ............................................. .................................................. ............ 213

11.6 Fluxos de dados ............................................. .................................................. .......... 215

11.7 Atribuição: Funções e Listas de Ordem Superior ........................................ .............. 217


11.7.1 Funções Comuns de Ordem Superior .......................................... .................................................. .......... 217

11.7.2 Atribuição................................................. .................................................. ........................................ 218

11.8 Resumo ................................................ .................................................. ............... 220

ÿCapítulo 12: Sintaxe, Semântica e Pragmática ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿ 221

12.1 O que é uma linguagem de programação? .................................................. ........................ 221

12.2 Sintaxe e gramáticas formais ............................................. .................................... 222


12.2.1 Exemplo: Números Naturais ........................................... .................................................. ................... 223

12.2.2 Exemplo: Aritmética Simples ........................................... .................................................. ................ 224

12.2.3 Descida Recursiva ............................................. .................................................. .............................. 224

12.2.4 Exemplo: Aritmética com Prioridades ........................................... .................................................. ...... 227

12.2.5 Exemplo: Linguagem Imperativa Simples ........................................... .................................................. .229

12.2.6 Hierarquia de Chomsky ............................................. .................................................. ............................ 229

12.2.7 Árvore de Sintaxe Abstrata ............................................ .................................................. ............................ 230

12.2.8 Análise Lexical ............................................. .................................................. ................................... 231

12.2.9 Resumo sobre análise ............................................ .................................................. ........................... 231

12.3 Semântica ................................................ .................................................. .............. 231


12.3.1 Comportamento indefinido ............................................. .................................................. ............................ 232

12.3.2 Comportamento não especificado ............................................. .................................................. ........................... 233

12.3.3 Comportamento definido pela implementação ........................................... .................................................. ........ 234

12.3.4 Pontos de Sequência............................................. .................................................. .................................. 234

12.4 Pragmática................................................. .................................................. ............. 235


12.4.1 Alinhamento ............................................. .................................................. ........................................... 235

12.4.2 Preenchimento da estrutura de dados ........................................... .................................................. ........................ 235

12.5 Alinhamento em C11 ............................................. .................................................. ..... 238

12.6 Resumo ................................................ .................................................. ............... 239

xii
Machine Translated by Google

ÿ Conteúdo

ÿCapítulo 13: Boas Práticas de Código ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ

ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 241 13.1 Fazendo escolhas ................... .................................................. ................................... 241

13.2 Elementos de Código ............................................. .................................................. ........ 242

13.2.1 Nomenclatura Geral............................................. .................................................. ................................... 242

13.2.2 Estrutura de Arquivos............................................. .................................................. .................................... 243

13.2.3 Tipos............................................. .................................................. .................................................. 243

13.2.4 Variáveis ............................................. .................................................. ........................................... 244

13.2.5 Sobre Variáveis Globais ............................................ .................................................. .............................. 245

13.2.6 Funções............................................. .................................................. ........................................... 246

13.3 Arquivos e Documentação ............................................. ........................................... 246

13.4 Encapsulamento ................................................ .................................................. ........ 248

13.5 Imutabilidade ................................................ .................................................. ........... 251

13.6 Asserções ................................................ .................................................. .............. 251

13.7 Tratamento de Erros ............................................. .................................................. ......... 252

13.8 Na alocação de memória......................................... ................................................ 254 13,9 Sobre

Flexibilidade ................................................... .................................................. .......... 255

13.10 Atribuição: Rotação de Imagem ............................................. .................................... 256

13.10.1 Formato de arquivo BMP ........................................... .................................................. ................................ 256

13.10.2 Arquitetura ............................................. .................................................. .................................... 258

13.11 Atribuição: Alocador de memória personalizado .......................................... ..................... 259

13.12 Resumo ................................................ .................................................. ............. 262

ÿParte III: Entre C e a Montagem ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿ 263

ÿCapítulo 14: Detalhes da Tradução ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 265

14.1 Sequência de Chamada de Função ............................................. ........................................ 265 14.1.1 Registros

XMM .... .................................................. .................................................. .......................... 265 14.1.2 Convenção de

Chamada .................. .................................................. .................................................. ....... 266

14.1.3 Exemplo: Função Simples e Sua Pilha ........................................ .................................................. .268

14.1.4 Zona Vermelha............................................. .................................................. ............................................. 271

14.1.5 Número Variável de Argumentos ........................................... .................................................. ............. 271

14.1.6 vprintf e amigos .......................................... .................................................. .............................. 273

xiii
Machine Translated by Google

ÿ Conteúdo

14.2 volátil ................................................ .................................................. ................... 273


14.2.1 Alocação lenta de memória ........................................... .................................................. ........................ 274

14.2.2 Código Gerado ............................................. .................................................. ................................... 274

14.3 Saltos não locais–setjmp........................................... .............................................. 276


14.3.1 Volátil e setjmp .......................................... .................................................. .............................. 277

14.4 em linha ................................................ .................................................. ...................... 280

14.5 restringir ................................................ .................................................. ................... 281

14.6 Alias estrito ........................................ .................................................. ......... 283

14.7 Questões de Segurança ............................................. .................................................. ....... 284


14.7.1 Sobrecarga do Buffer de Pilha ............................................ .................................................. ........................... 284

14.7.2 retorno à libc .......................................... .................................................. ........................................... 285

14.7.3 Vulnerabilidades de saída de formato ............................................ .................................................. .............. 285

14.8 Mecanismos de Proteção ............................................. ........................................... 287


14.8.1 Cookie de segurança......................................... .................................................. ................................... 287

14.8.2 Randomização do layout do espaço de endereço ........................................... .................................................. .288

14.8.3 DEP ............................................. .................................................. .................................................. .. 288

14.9 Resumo ................................................ .................................................. ............... 288

ÿCapítulo 15: Objetos Compartilhados e Modelos de Códigoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿ 291

15.1 Carregamento Dinâmico ............................................. .................................................. .... 291


15.2 Relocações e PIC ............................................. .................................................. .293

15.3 Exemplo: Biblioteca Dinâmica em C ........................................... .................................... 293


15.4 GOT e PLT ............................................. .................................................. ............. 294
15.4.1 Acessando Variáveis Externas ........................................... .................................................. ............... 294

15.4.2 Chamando Funções Externas ........................................... .................................................. ................... 297

15.4.3 Exemplo de PLT ............................................. .................................................. ........................................ 299

15.5 Pré-carregamento................................................... .................................................. .............. 301

15.6 Resumo de endereçamento de símbolos.................................... .................................... 302

15.7 Exemplos ................................................ .................................................. ............... 303


15.7.1 Chamando uma Função ............................................ .................................................. ................................ 303

15.7.2 Em Vários Linkers Dinâmicos ........................................... .................................................. ................. 305

XIV
Machine Translated by Google

ÿ Conteúdo

15.7.3 Acessando uma Variável Externa ........................................... .................................................. ............. 306

15.7.4 Exemplo de montagem completa .......................................... .................................................. .............. 307

15.7.5 Mistura C e Montagem......................................... .................................................. ........................... 308

15.8 Quais objetos estão vinculados? .................................................. .................................... 310

15.9 Otimizações ................................................ .................................................. ......... 313


15.10 Modelos de Código ............................................. .................................................. ......... 315

15.10.1 Modelo de código pequeno (sem PIC) ........................................ .................................................. .................... 317

15.10.2 Modelo de código grande (sem PIC) ........................................ .................................................. .................... 318

15.10.3 Modelo de Código Médio (Sem PIC) ........................................ .................................................. ................ 318

15.10.4 Modelo de código PIC pequeno......................................... .................................................. ........................... 319

15.10.5 Modelo de código PIC grande ........................................... .................................................. ........................... 320

15.10.6 Modelo de Código PIC Médio ........................................... .................................................. ..................... 322

15.11 Resumo ................................................ .................................................. ............. 324

ÿCapítulo 16: Desempenhoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 327

16.1 Otimizações ................................................ .................................................. ......... 327


16.1.1 Mito sobre linguagens rápidas ........................................... .................................................. ................. 327

16.1.2 Conselhos Gerais............................................. .................................................. .................................... 328

16.1.3 Omitir ponteiro do quadro de pilha ........................................... .................................................. ..................... 329

16.1.4 Recursão de cauda ............................................. .................................................. .................................... 330

16.1.5 Eliminação de Subexpressões Comuns ........................................... .................................................. .333

16.1.6 Propagação Constante ............................................. .................................................. ........................... 334

16.1.7 Otimização do valor de retorno (nomeado) ..................................... .................................................. ........ 336

16.1.8 Influência da previsão de ramificação ........................................... .................................................. ............. 338

16.1.9 Influência das Unidades de Execução ........................................... .................................................. ................. 338

16.1.10 Agrupando leituras e gravações no código ..................................... .................................................. ..... 340

16.2 Cache ................................................ .................................................. .................. 340


16.2.1 Como usamos o cache de maneira eficaz? .................................................. .................................................. 340

16.2.2 Pré-busca ............................................. .................................................. ........................................ 341

16.2.3 Exemplo: Pesquisa Binária com Pré-busca .................................... ................................................ 342

16.2.4 Ignorando Cache ............................................. .................................................. ................................ 345

16.2.5 Exemplo: Inicialização da Matriz ........................................... .................................................. ................ 346

xv
Machine Translated by Google

ÿ Conteúdo

16.3 Classe de Instrução SIMD............................................. ................................................ 348

16.4 Extensões SSE e AVX ............................................. ............................................. 349

16.4.1 Atribuição: Filtro Sépia ........................................... .................................................. ........................ 351

16.5 Resumo ................................................ .................................................. ............... 354

ÿCapítulo 17: Multithreading ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 357

17.1 Processos e Threads............................................. ............................................... 357

17.2 O que torna o multithreading difícil? .................................................. ........................ 358


17.3 Ordem de Execução ............................................. .................................................. ...... 358

17.4 Modelos de Memória Forte e Fraca .......................................... ................................ 359

17.5 Exemplo de reordenação ............................................. .................................................. 360


17.6 O que é volátil e o que não é .......................................... .................................... 362

17.7 Barreiras de Memória ............................................. .................................................. ..... 363


17.8 Introdução aos pthreads ........................................ .................................................. 365
17.8.1 Quando usar multithreading......................................... .................................................. ................. 365

17.8.2 Criando Threads ............................................. .................................................. ................................ 366

17.8.3 Gerenciando Threads ............................................. .................................................. .............................. 369

17.8.4 Exemplo: Fatoração Distribuída ........................................... .................................................. ....... 370

17.8.5 Mutexes ............................................. .................................................. ............................................. 374

17.8.6 Impasses............................................. .................................................. ........................................... 377

17.8.7 Livelocks............................................. .................................................. ........................................... 378

17.8.8 Variáveis de condição ............................................. .................................................. ............................ 379

17.8.9 Spinlocks ............................................. .................................................. ........................................... 381

17.9 Semáforos................................................. .................................................. ........... 382

17.10 Quão forte é o Intel 64? .................................................. ....................................... 385

17.11 O que é programação sem bloqueio? .................................................. ........................ 388

17.12 Modelo de memória C11 ............................................. .................................................. 390


17.12.1 Visão geral............................................. .................................................. ........................................... 390

17.12.2 Atômicos................................................. .................................................. ........................................... 390

17.12.3 Ordenações de memória em C11 ........................................... .................................................. ................... 392

17.12.4 Operações ............................................. .................................................. ........................................ 392

17.13 Resumo ................................................ .................................................. ............. 394


xvi
Machine Translated by Google

ÿ Conteúdo

ÿParte IV: Apêndices ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 397

ÿCapítulo 18: Apêndice A. Usando gdb ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 399

ÿCapítulo 19: Apêndice B. Usando Make ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 409

19.1 Makefile Simples ............................................. .................................................. ...... 409

19.2 Lançando Variáveis ............................................. ................................................ 410

19.3 Variáveis Automáticas ............................................. .................................................. 412

ÿCapítulo 20: Apêndice C. Chamadas do sistemaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 415

20.1 ler ................................................ .................................................. ........................ 415

20.1.1 Argumentos ............................................. .................................................. ........................................... 416

20.2 escrever ................................................ .................................................. ...................... 416

20.2.1 Argumentos ............................................. .................................................. ........................................... 416

20.3 aberto ................................................ .................................................. ........................ 416

20.3.1 Argumentos ............................................. .................................................. ........................................... 417

20.3.2 Sinalizadores................................................. .................................................. .................................................. 417

20.4 fechar ................................................ .................................................. ...................... 417

20.4.1 Argumentos ............................................. .................................................. ........................................... 418

20,5 mmmapa ................................................ .................................................. .................... 418

20.5.1 Argumentos ............................................. .................................................. ........................................... 418

20.5.2 Sinalizadores de Proteção............................................. .................................................. ................................... 419

20.5.3 Sinalizadores de Comportamento............................................. .................................................. .................................... 419

20.6 mapa mun................................................. .................................................. ................ 419

20.6.1 Argumentos ............................................. .................................................. ........................................... 419

20.7 saída ................................................ .................................................. ........................... 420

20.7.1 Argumentos ............................................. .................................................. ........................................... 420

ÿCapítulo 21: Apêndice D. Informações sobre testes de desempenhoÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 421

ÿCapítulo 22: Bibliografiaÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 425

Índiceÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 4

xvii
Machine Translated by Google

Sobre o autor

Igor Zhirkov ministra seu curso de grande sucesso “Linguagens de


Programação de Sistemas” na Universidade ITMO em São Petersburgo, que
é seis vezes vencedora do Campeonato Mundial Intercolegial de Programação
ACM-ICPC. Ele estudou na Universidade Acadêmica de São Petersburgo
e obteve seu mestrado na Universidade ITMO. Atualmente ele está
pesquisando refatorações C verificadas como parte de sua tese de
doutorado e formalização da biblioteca Bulk Synchronous Parallelism
em C no IMT Atlantique em Nantes, França. Seus principais interesses
são programação de baixo nível, teoria da linguagem de programação e teoria dos tipos.
Seus outros interesses incluem tocar piano, caligrafia, arte e filosofia da
ciência.

XIX
Machine Translated by Google

Sobre o Revisor Técnico

Ivan Loginov é pesquisador e professor na Universidade ITMO de São


Petersburgo, Rússia (Universidade de Tecnologias da Informação, Mecânica e
Óptica), ministrando o curso “Introdução às Linguagens de Programação”
para estudantes de bacharelado em ciência da computação.
Ele recebeu seu mestrado pela ITMO University. Sua pesquisa
concentra-se na teoria do compilador, bancadas de linguagem e programação
distribuída e paralela, bem como novas técnicas de ensino e sua aplicação à TI
(tecnologia da informação).
Atualmente, ele está escrevendo sua dissertação de doutorado sobre um kit de ferramentas de

modelagem baseado em nuvem para dinâmica de sistemas.

Seus hobbies incluem tocar trompete e ler literatura clássica (russa).

xxi
Machine Translated by Google

Agradecimentos

Tive a bênção de conhecer um grande número de pessoas, muito talentosas e extremamente dedicadas, que me ajudaram e muitas
vezes me orientaram em áreas de conhecimento que eu nunca poderia ter imaginado.
Agradeço a Vladimir Nekrasov, meu querido professor de matemática, por seu curso e sua influência sobre mim, que me
permitiu pensar melhor e de forma mais lógica.
Agradeço a Andrew Dergachev, que me confiou a criação e ministração do meu curso e me ajudou muito
durante esses anos, Boris Timchenko, Arkady Kluchev, Ivan Loginov (que também gentilmente concordou em ser o revisor
técnico deste livro) e todos os meus colegas da universidade ITMO, que me ajudaram a moldar este curso de uma forma ou de outra.

Agradeço a todos os meus alunos que deram feedback ou até mesmo me ajudaram no ensino. Você é a razão pela qual
estou fazendo isso. Vários alunos ajudaram na revisão do rascunho deste livro. Quero observar as observações mais úteis de
Dmitry Khalansky e Valery Kireev.
Para mim, os anos que passei na Universidade Acadêmica de São Petersburgo são facilmente os melhores da minha vida.
Nunca tive tantas oportunidades de estudar com especialistas de nível mundial trabalhando em empresas líderes junto com outros
estudantes, muito mais inteligentes do que eu. Quero expressar minha mais profunda gratidão a Alexander Omelchenko,
Alexander Kulikov, Andrey Ivanov e a todos que contribuem para a qualidade do ensino de ciência da computação na Rússia.
Agradeço também a Dmitry Boulytchev, Andrey Breslav e Sergey Sinchuk da JetBrains, meus supervisores que me ensinaram
muito.
Estou também muito grato aos meus colegas franceses: Ali Ed-Dbali, Frédéric Loulergue, Rémi Douence e Julien Cohen.

Também quero agradecer a Sergei Gorlatch e Tim Humernbrum por fornecerem o feedback necessário sobre
Capítulo 17, o que me ajudou a transformá-lo em uma versão muito mais consistente e compreensível. Agradecimentos especiais
a Dmitry Shubin por seu impacto muito útil na correção das imperfeições deste livro.
Sou muito grato ao meu amigo Alexey Velikiy e à sua agência CorpGlory.com, que se concentrou em visualizações de dados
e infográficos e criou as melhores ilustrações deste livro.
Por trás de cada pequeno sucesso meu está uma quantidade infinita de apoio da minha família e amigos. Eu não teria
conseguido nada sem você.
Por último, mas não menos importante, agradeço à equipe da Apress, incluindo Robert Hutchinson, Rita Fernando,
Laura Berendson e Susan McDermott, por confiarem em mim e neste projeto e por fazerem tudo o que puderam para tornar este
livro uma realidade.

XXIII
Machine Translated by Google

Introdução

Este livro tem como objetivo ajudá-lo a desenvolver uma visão consistente do domínio da programação de baixo nível. Queremos permitir
que um leitor atento

•Escreva livremente em linguagem assembly.

•Entender o modelo de programação Intel 64.

•Escrever código sustentável e robusto em C11.

• Compreender o processo de compilação e decifrar listagens de montagem.

•Depurar erros em código assembly compilado.

•Use modelos de computação apropriados para reduzir significativamente a complexidade do programa.

•Escrever código crítico para desempenho.

Existem dois tipos de livros técnicos: os que servem de referência e os que servem para aprender. Este livro
é, sem dúvida, o segundo tipo. É bastante denso propositalmente e, para digerir com sucesso as informações, sugerimos
fortemente a leitura contínua. Para memorizar rapidamente novas informações você deve tentar conectá-las com as informações com
as quais você já está familiarizado. Por isso procuramos, sempre que possível, basear a nossa explicação de cada tópico nas informações
que você recebeu dos tópicos anteriores.
Este livro foi escrito para estudantes de programação, programadores intermediários a avançados e entusiastas de programação
de baixo nível. Os pré-requisitos são um conhecimento básico de sistemas binários e hexadecimais e um conhecimento básico de
comandos Unix.

ÿ Perguntas e Respostas Ao longo deste livro você encontrará inúmeras perguntas. A maioria deles tem como
objetivo fazer você pensar novamente sobre o que acabou de aprender, mas alguns deles incentivam você a fazer
pesquisas adicionais, apontando para as palavras-chave relevantes.

Propomos as respostas a essas perguntas em nossa página GitHub, que também hospeda todas as listagens e informações iniciais
código para atribuições, atualizações e outras vantagens.
Consulte a página do livro no site da Apress para informações adicionais: http://www.apress.com/us/
livro/9781484224021.
Lá você também encontra diversas máquinas virtuais pré-configuradas com Debian Linux instalado, com e sem interface gráfica
de usuário (GUI), o que permite que você comece a praticar imediatamente, sem perder tempo configurando seu sistema. Você pode
encontrar mais informações na seção 2.1.
Começamos com ideias básicas muito simples sobre o que é um computador, explicando conceitos de modelo de
computação e arquitetura de computador. Expandimos o modelo central com extensões até que ele se torne adequado o
suficiente para descrever um processador moderno como um programador o vê. Do Capítulo 2 em diante começamos a programar na
linguagem assembly real para Intel 64 sem recorrer a arquiteturas mais antigas de 16 bits, que muitas vezes são ensinadas por razões
históricas. Ele nos permite ver as interações entre aplicativos e operações

xxxv
Machine Translated by Google

ÿ Introdução

sistema por meio da interface de chamadas do sistema e dos detalhes específicos da arquitetura, como endianness. Após uma
breve visão geral dos recursos da arquitetura legada, alguns dos quais ainda em uso, estudamos detalhadamente a memória virtual
e ilustramos seu uso com a ajuda de procfs e exemplos de uso de chamada de sistema mmap em assembly.
Em seguida, mergulhamos no processo de compilação, examinando o pré-processamento, a vinculação estática e dinâmica.
Depois de explorar mais detalhadamente os mecanismos de interrupções e chamadas de sistema, finalizamos a primeira parte
com um capítulo sobre diferentes modelos de computação, estudando exemplos de máquinas de estados finitos, máquinas de
pilha e implementando um compilador totalmente funcional da linguagem Forth em assembly puro.
A segunda parte é dedicada à linguagem C. Partimos da visão geral da linguagem, construindo um entendimento básico
de seu modelo de computação necessário para começar a escrever programas. No próximo capítulo estudamos o sistema de tipos
de C e ilustramos diferentes tipos de tipagem, terminando com uma discussão sobre polimorfismo e fornecendo implementações
exemplares para diferentes tipos de polimorfismo em C. Em seguida, estudamos as maneiras de estruturar corretamente o
programa dividindo-o em vários arquivos e também visualizar seu efeito no processo de vinculação. O próximo capítulo é
dedicado ao gerenciamento de memória, entrada e saída. Em seguida, elaboramos três facetas de cada linguagem: sintaxe,
semântica e pragmática e nos concentramos na primeira e na terceira. Vemos como as proposições da linguagem são
transformadas em árvores sintáticas abstratas, a diferença entre comportamento indefinido e não especificado em C e o efeito
da pragmática da linguagem no código assembly produzido pelo compilador. No final da segunda parte, dedicamos um
capítulo às boas práticas de código para dar ao leitor uma ideia de como o código deve ser escrito dependendo dos seus
requisitos específicos. A sequência de atribuições para esta parte termina com a rotação de um arquivo bitmap e um alocador
de memória personalizado.

A parte final é uma ponte entre as duas anteriores. Ele se aprofunda nos detalhes da tradução, como convenções de
chamada e stack frames e recursos avançados da linguagem C, exigindo um certo entendimento de assembly, como palavras-
chave voláteis e restritas. Fornecemos uma visão geral de vários bugs clássicos de baixo nível, como stack buffer overflow, que
podem ser explorados para induzir um comportamento indesejado no programa.
O próximo capítulo fala detalhadamente sobre objetos compartilhados e os estuda no nível assembly, fornecendo exemplos práticos
mínimos de bibliotecas compartilhadas escritas em C e assembly. Em seguida, discutimos um tópico relativamente raro de
modelos de código. O capítulo estuda as otimizações que os compiladores modernos são capazes de fazer e como esse
conhecimento pode ser usado para produzir código legível e rápido. Também fornecemos uma visão geral de técnicas de
amplificação de desempenho, como uso de instruções de montagem especializadas e otimização de uso de cache. Isso é
seguido por uma tarefa em que você implementará um filtro sépia para uma imagem usando instruções SSE especializadas e
medirá seu desempenho. O último capítulo introduz multithreading através do uso da biblioteca pthreads, modelos de memória
e reordenações, que qualquer pessoa que faz programação multithread deve estar ciente, e elabora a necessidade de barreiras de
memória.
Os apêndices incluem pequenos tutoriais sobre gdb (depurador), make (sistema de compilação automatizado) e uma tabela
das chamadas de sistema usadas com mais frequência para referência e informações do sistema para facilitar a reprodução
dos testes de desempenho fornecidos ao longo do livro. Eles devem ser lidos quando necessário, mas recomendamos que
você se acostume com o gdb assim que iniciar a programação em assembly no Capítulo 2.
A maioria das ilustrações foi produzida usando a biblioteca VSVG destinada a produzir gráficos vetoriais interativos
complexos, escrita por Alexey Velikiy (//www.corpglory.com). As fontes da biblioteca e ilustrações do livro estão disponíveis
na página do VSVG no Github: https://github.com/corpglory/vsvg.
Esperamos que este livro seja útil para você e desejamos uma boa leitura!

xxvi
Machine Translated by Google

PARTE I

Linguagem Assembly e
Arquitetura de Computadores
Machine Translated by Google

CAPÍTULO 1

Arquitetura Básica de Computadores

Este capítulo lhe dará uma compreensão geral dos fundamentos do funcionamento do computador. Descreveremos um modelo central de
computação, enumeraremos suas extensões e examinaremos mais de perto duas delas, a saber, registradores e pilha de hardware. Ele irá
prepará-lo para iniciar a programação assembly no próximo capítulo.

1.1 A Arquitetura Central


1.1.1 Modelo de Computação
O que um programador faz? Um primeiro palpite seria provavelmente “construção de algoritmos e sua implementação”. Então,
apreendemos uma ideia, depois codificamos, e esta é a forma comum de pensar.
Podemos construir um algoritmo para descrever alguma rotina diária, como sair para passear ou fazer compras?
A questão não parece particularmente difícil e muitas pessoas terão prazer em lhe fornecer suas soluções.
No entanto, todas estas soluções serão fundamentalmente diferentes. Operaremos com ações como “abrir a porta” ou “pegar a
chave”; o outro prefere “sair de casa”, omitindo detalhes. O terceiro, entretanto, será desonesto e fornecerá uma descrição detalhada do
movimento de suas mãos e pernas, ou mesmo descreverá seus padrões de contração muscular.

A razão pela qual essas respostas são tão diferentes é a incompletude da pergunta inicial.
Todas as ideias (incluindo algoritmos) precisam de uma forma de serem expressas. Para descrever uma nova noção usamos
outras noções mais simples. Também queremos evitar ciclos viciosos, por isso a explicação seguirá o formato de uma pirâmide.
Cada nível de explicação crescerá horizontalmente. Não podemos construir esta pirâmide infinitamente, porque a explicação tem
que ser finita, por isso paramos no nível das noções básicas, primitivas, que escolhemos deliberadamente não expandir mais. Então,
escolher o básico é um requisito fundamental para expressar qualquer coisa.
Isso significa que a construção do algoritmo é impossível a menos que tenhamos fixado um conjunto de ações básicas, que atuam como
seus blocos de construção.
Modelo de computação é um conjunto de operações básicas e seus respectivos custos.
Os custos são geralmente números inteiros e são usados para raciocinar sobre a complexidade dos algoritmos através do cálculo
do custo combinado de todas as suas operações. Não discutiremos complexidade computacional neste livro.

A maioria dos modelos de computação também são máquinas abstratas. Isso significa que eles descrevem uma situação hipotética
computador, cujas instruções correspondem às operações básicas do modelo. O outro tipo de modelo, as árvores de decisão, está
além do escopo deste livro.

1.1.2 Arquitetura von Neumann


Agora imaginemos que vivemos na década de 1930, quando os computadores de hoje ainda não existiam. As pessoas queriam
automatizar os cálculos de alguma forma, e diferentes pesquisadores estavam descobrindo diferentes maneiras de conseguir tal automação.
Exemplos comuns são o cálculo Lambda de Church ou a máquina de Turing. Estas são máquinas abstratas típicas, descrevendo
computadores imaginários.

© Igor Zhirkov 2017 3


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_1
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Um tipo de máquina logo se tornou dominante: o computador de arquitetura von Neumann.


A arquitetura de computadores descreve a funcionalidade, organização e implementação de sistemas de computador. É uma
descrição de nível relativamente alto, se comparada a um modelo de cálculo, que não omite nem um mínimo detalhe.

A arquitetura de von Neumann tinha duas vantagens cruciais: era robusta (num mundo onde os componentes eletrônicos
eram altamente instáveis e de curta duração) e fácil de programar.
Resumindo, este é um computador composto por um processador e um banco de memória, conectados a um
ônibus. Uma unidade central de processamento (CPU) pode executar instruções, buscadas na memória por uma unidade de controle.
A unidade lógica aritmética (ALU) executa os cálculos necessários. A memória também armazena dados. Consulte as Figuras 1-1
e 1-2.
A seguir estão os principais recursos desta arquitetura:

• A memória armazena apenas bits (uma unidade de informação, um valor igual a 0 ou 1).

• A memória armazena instruções codificadas e dados para operação. Não há meios de distinguir dados de
código: ambos são, na verdade, cadeias de bits.

• A memória é organizada em células, que são rotuladas com seus respectivos índices de forma natural
(por exemplo, a célula #43 segue a célula #42). Os índices começam em 0. O tamanho da célula
pode variar (John von Neumann pensava que cada bit deveria ter seu endereço); os computadores
modernos consideram um byte (oito bits) como tamanho de célula de memória. Portanto, o 0º byte contém
os primeiros oito bits da memória, etc.

• O programa consiste em instruções que são buscadas uma após a outra. Sua execução é sequencial,
a menos que uma instrução especial de salto seja executada.

Figura 1-1. Arquitetura von Neumann - Visão geral

A linguagem assembly para um processador escolhido é uma linguagem de programação que consiste em mnemônicos para
cada instrução codificada binária possível (código de máquina). Facilita muito a programação em códigos de máquina, pois o
programador não precisa memorizar a codificação binária das instruções, apenas seus nomes e parâmetros.

Observe que as instruções podem ter parâmetros de diferentes tamanhos e formatos.


Uma arquitetura nem sempre define um conjunto de instruções preciso, ao contrário de um modelo de computação.
Um computador pessoal moderno comum evoluiu a partir de computadores antigos com arquitetura von Neumann, então vamos
investigar essa evolução e ver o que distingue um computador moderno do esquema simples da Figura 1-2.

4
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Figura 1-2. Arquitetura von Neumann - Memória

ÿ Nota O estado da memória e os valores dos registradores descrevem completamente o estado da CPU (do ponto de vista de

um programador). Compreender uma instrução significa compreender seus efeitos na memória e nos registradores.

1.2 Evolução
1.2.1 Desvantagens da Arquitetura von Neumann
A arquitetura simples descrita anteriormente apresenta sérias desvantagens.
Em primeiro lugar, esta arquitetura não é nada interativa. Um programador é limitado pela edição manual da memória e pela visualização de seu
conteúdo de alguma forma. Nos primórdios dos computadores, era bastante simples, porque os circuitos eram grandes e os bits podiam ser invertidos
literalmente com as próprias mãos.
Além disso, esta arquitetura não é compatível com multitarefas. Imagine que seu computador está executando uma tarefa muito lenta
(por exemplo, controlar uma impressora). É lento porque a impressora é muito mais lenta que a CPU mais lenta. A CPU então tem que esperar pela
reação do dispositivo uma porcentagem de tempo próxima a 99%, o que é um desperdício de recursos (ou seja, tempo de CPU).

Então, quando todos puderem executar qualquer tipo de instrução, todo tipo de comportamento inesperado poderá ocorrer.
O objetivo de um sistema operacional (SO) é (entre outros) gerenciar os recursos (como dispositivos externos) para que os aplicativos do usuário
não causem caos ao interagir com os mesmos dispositivos simultaneamente.
Por isso, gostaríamos de proibir todas as aplicações de usuário de executar algumas instruções relacionadas à entrada/saída ou ao gerenciamento
do sistema.
Outro problema é que o desempenho da memória e da CPU diferem drasticamente.
Antigamente, os computadores não eram apenas mais simples: eram concebidos como entidades integrais.
Memória, barramento, interfaces de rede – tudo foi criado pela mesma equipe de engenharia. Cada peça foi especializada para ser utilizada neste
modelo específico. Portanto, as peças não estavam destinadas a serem intercambiáveis. Nessas circunstâncias, ninguém tentou criar uma peça
capaz de ter desempenho superior a outras peças, porque não seria possível aumentar o desempenho geral do computador.

Mas à medida que as arquiteturas se tornaram mais ou menos estáveis, os desenvolvedores de hardware começaram a trabalhar em diferentes
partes de computadores de forma independente. Naturalmente, eles tentaram melhorar o seu desempenho para fins de marketing. No entanto,
nem todas as peças eram fáceis e baratas1 de acelerar. Esta é a razão pela qual as CPUs logo se tornaram muito mais rápidas que a memória. É
possível acelerar a memória escolhendo outros tipos de circuitos subjacentes, mas seria muito mais caro [12].

1
Observe quantas vezes as soluções apresentadas pelos engenheiros são ditadas por razões econômicas e não por limitações técnicas.

5
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Quando um sistema consiste em partes diferentes e suas características de desempenho diferem muito, o mais lento
parte pode se tornar um gargalo. Isso significa que se a parte mais lenta for substituída por uma analógica mais rápida, o
desempenho geral aumentará significativamente. Foi aí que a arquitetura teve que ser fortemente modificada.

1.2.2 Arquitetura Intel 64


Neste livro descrevemos apenas a arquitetura Intel 64.2
A Intel vem desenvolvendo sua principal família de processadores desde a década de 1970. Cada modelo pretendia
preservar a compatibilidade binária com modelos mais antigos. Isso significa que mesmo os processadores modernos podem
executar código escrito e compilado para modelos mais antigos. Isso leva a uma enorme quantidade de legado. Os
processadores podem operar em vários modos: modo real, protegido, virtual, etc. Se não for especificado explicitamente,
descreveremos como uma CPU opera no mais novo, chamado modo longo.

1.2.3 Extensões de Arquitetura


Intel 64 incorpora múltiplas extensões da arquitetura de von Neumann. Os mais importantes estão listados aqui para uma rápida
visão geral.
Registradores São células de memória colocadas diretamente no chip da CPU. Em termos de circuito, eles são muito
mais rápidos, mas também são mais complicados e caros. Os acessos de registro não utilizam o barramento. O tempo de resposta
é bastante pequeno e geralmente equivale a alguns ciclos de CPU. Consulte a seção 1.3 “Registros”.
Pilha de hardware Uma pilha em geral é uma estrutura de dados. Suporta duas operações: empurrar um elemento
em cima dele e destacando o elemento superior. Uma pilha de hardware implementa essa abstração no topo da memória
por meio de instruções especiais e um registrador, apontando para o último elemento da pilha. Uma pilha é usada não apenas
em cálculos, mas para armazenar variáveis locais e implementar sequências de chamadas de funções em linguagens de
programação. Consulte a seção 1.5 “Pilha de hardware”.
Interrupções Este recurso permite alterar a ordem de execução do programa com base em eventos externos ao próprio
programa. Após a captura de um sinal (externo ou interno), a execução do programa é suspensa, alguns registros são salvos e a
CPU passa a executar uma rotina especial para lidar com a situação. A seguir estão exemplos de situações em que ocorre
uma interrupção (e um trecho de código apropriado é executado para lidar com ela):

• Um sinal de um dispositivo externo.

• Divisão zero.

• Instrução inválida (quando a CPU não consegue reconhecer uma instrução pela sua
representação binária).

• Uma tentativa de executar uma instrução privilegiada em modo não privilegiado.


Consulte a seção 6.2 “Interrupções” para uma descrição mais detalhada.

Anéis de proteção Uma CPU está sempre em um estado correspondente a um dos chamados anéis de proteção. Cada
ring define um conjunto de instruções permitidas. O anel zero permite executar qualquer instrução de todo o conjunto de instruções
da CPU e, portanto, é o mais privilegiado. O terceiro permite apenas os mais seguros. Uma tentativa de executar uma instrução
privilegiada resulta em uma interrupção. A maioria dos aplicativos funciona dentro do terceiro anel para garantir que não
modifiquem estruturas de dados cruciais do sistema (como tabelas de páginas) e não funcionem com dispositivos externos,
ignorando o sistema operacional. Os outros dois anéis (primeiro e segundo) são intermediários e os sistemas operacionais
modernos não os utilizam.
Consulte a seção 3.2 “Modo protegido” para uma descrição mais detalhada.
Memória virtual Esta é uma abstração da memória física, que ajuda a distribuí-la entre
programas de uma forma mais segura e eficaz. Também isola programas uns dos outros.

2 Também conhecido como x86_64 e AMD64.

6
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Consulte a seção 4.2 “Motivação” para uma descrição mais detalhada.


Algumas extensões não são diretamente acessíveis por um programador (por exemplo, caches ou registros de sombra). Mencionaremos
alguns deles também.
A Tabela 1-1 resume informações sobre algumas extensões da arquitetura von Neumann vistas na arquitetura moderna.

computadores.

Tabela 1-1. Arquitetura von Neumann: Extensões Modernas

Problema Solução

Nada é possível sem consultar a memória lenta Registros, caches

Falta de interatividade Interrupções

Não há suporte para isolamento de código em procedimentos ou para economia de contexto Pilha de hardware

Multitarefa: qualquer programa pode executar qualquer instrução Anéis de proteção

Multitarefa: os programas não estão isolados uns dos outros Memória virtual

ÿ Fontes de informação Nenhum livro deve cobrir completamente o conjunto de instruções e a arquitetura do processador.

Muitos livros tentam incluir informações completas sobre o conjunto de instruções. Fica desatualizado logo; além disso, incha o livro

desnecessariamente.

Freqüentemente indicaremos o Manual do desenvolvedor de software das arquiteturas Intel® 64 e IA-32 disponível on-line:

consulte [15]. Obtê-lo agora!

Não há virtude em copiar as descrições das instruções do local “original” em que aparecem; é muito mais maduro aprender a
trabalhar com a fonte.

O segundo volume cobre completamente o conjunto de instruções e possui um índice muito útil. Por favor, use-o sempre para obter

informações sobre o conjunto de instruções: não é apenas uma prática muito boa, mas também uma fonte bastante confiável.

Observe que muitos recursos educacionais dedicados à linguagem assembly na Internet estão frequentemente desatualizados (já

que poucas pessoas programam em assembly atualmente) e não cobrem o modo de 64 bits. As instruções presentes nos modos

mais antigos geralmente têm suas contrapartes atualizadas no modo longo e funcionam de maneira diferente. Esta é a razão pela

qual desencorajamos fortemente o uso de mecanismos de pesquisa para encontrar descrições de instruções, por mais tentador que seja.

1.3 Registros
A troca de dados entre a CPU e a memória é uma parte crucial dos cálculos em um computador von Neumann. As instruções
devem ser buscadas na memória, os operandos devem ser buscados na memória; algumas instruções armazenam resultados também na
memória. Isso cria um gargalo e leva ao desperdício de tempo da CPU ao aguardar a resposta dos dados do chip de memória. Para evitar
esperas constantes, um processador foi equipado com células de memória próprias, chamadas registradores. São poucos, mas rápidos. Os
programas geralmente são escritos de tal maneira que na maioria das vezes o conjunto funcional de células de memória é pequeno o suficiente.
Este fato sugere que os programas podem ser escritos de forma que a maior parte do tempo a CPU trabalhe com registradores.

7
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Os registros são baseados em transistores, enquanto a memória principal usa condensadores. Poderíamos ter implementado
memória principal em transistores e obteve um circuito muito mais rápido. Existem vários motivos pelos quais os engenheiros preferem
outras formas de acelerar os cálculos.

• Os registos são mais caros.

• As instruções codificam o número do registro como parte de seus códigos. Para abordar mais
registra as instruções precisam aumentar de tamanho.

• Os registros acrescentam complexidade aos circuitos para abordá-los. Circuitos mais complexos são mais
difíceis de acelerar. Não é fácil configurar um arquivo de registro grande para funcionar em 5 GHz.

Naturalmente, o uso de registros torna os computadores mais lentos na pior das hipóteses. Se tudo tiver que ser buscado
registradores antes dos cálculos serem feitos e liberados na memória depois, onde está o lucro?
Os programas geralmente são escritos de tal forma que possuem uma propriedade específica. É o resultado do uso de padrões
de programação comuns, como loops, funções e reutilização de dados, e não de alguma lei da natureza.
Essa propriedade é chamada de localidade de referência e existem dois tipos principais: temporal e espacial.
Localidade temporal significa que o acesso a um endereço provavelmente será próximo no tempo.
Localidade espacial significa que após acessar um endereço X o próximo acesso à memória provavelmente será próximo
para X, (como X ÿ 16 ou X + 28).
Essas propriedades não são binárias: você pode escrever um programa exibindo localidade mais forte ou mais fraca.
Programas típicos usam o seguinte padrão: o conjunto de trabalho de dados é pequeno e pode ser mantido dentro de registradores.
Depois de buscar os dados nos registradores, trabalharemos com eles por algum tempo e então os resultados serão descarregados na
memória. Os dados armazenados na memória raramente serão utilizados pelo programa. Caso precisemos trabalhar com esses dados
perderemos desempenho porque

• Precisamos buscar dados nos registros.

• Se todos os registradores estiverem ocupados com dados que ainda precisaremos mais tarde, teremos que
derramar alguns deles, o que significa salvar seu conteúdo em células de memória alocadas temporalmente.

ÿ Nota Uma situação comum para um engenheiro: diminuir o desempenho no pior caso para melhorá-lo no caso médio. Funciona
com bastante frequência, mas é proibido na construção de sistemas em tempo real, que impõem restrições ao pior tempo de
reação do sistema. Tais sistemas são obrigados a emitir uma reação aos eventos em não mais do que um determinado período
de tempo, portanto, diminuir o desempenho no pior caso para melhorá-lo em outros casos não é uma opção.

1.3.1 Registros de Uso Geral


Na maioria das vezes, o programador trabalha com registradores de uso geral. Eles são intercambiáveis e podem ser usados em muitos
comandos diferentes.
Estes são registradores de 64 bits com os nomes r0, r1,…, r15. Os primeiros oito deles podem ser nomeados
alternativamente; esses nomes representam o significado que possuem para algumas instruções especiais. Por exemplo, r1 é
alternativamente denominado rcx, onde c significa “ciclo”. Existe um loop de instruções que usa rcx como contador de ciclos, mas não
aceita operandos explicitamente. É claro que esse tipo de significado especial de registro é refletido na documentação dos comandos
correspondentes (por exemplo, como um contador para instruções de loop). A Tabela 1-2 lista todos eles; veja também a Figura 1-3.

8
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

ÿ Nota Ao contrário da pilha de hardware, que é implementada no topo da memória principal, os registradores são um
tipo de memória completamente diferente. Portanto não possuem endereços, como as células da memória principal!

Os nomes alternativos são de fato mais comuns por razões históricas. Forneceremos ambos para referência
e dê uma dica para cada um. Estas descrições semânticas são fornecidas como referência; você não precisa
memorizá-los agora.

Tabela 1-2. Registros de uso geral de 64 bits

Nome Alias Descrição

r0 rax Uma espécie de “acumulador”, usado em instruções aritméticas. Por exemplo, uma instrução div
é usada para dividir dois inteiros. Ele aceita um operando e usa rax implicitamente como o
segundo. Depois de executar div rcx, um grande número de 128 bits, armazenado em partes em
dois registradores rdx e rax, é dividido por rcx e o resultado é armazenado novamente em rax.
r3 rbx Cadastro básico. Foi usado para endereçamento básico nos primeiros modelos de processador.

r1 RCX Usado para ciclos (por exemplo, em loop).

r2 rdx Armazena dados durante operações de entrada/saída.


r4 rsp Armazena o endereço do elemento superior na pilha de hardware. Consulte a seção 1.5
“Pilha de hardware”.

r5 RBP Base do quadro de pilha. Consulte a seção 14.1.2 “Convenção de chamada”.


r6 rsi Índice de origem em comandos de manipulação de strings (como movsd)
r7 RDI Índice de destino em comandos de manipulação de strings (como movsd)
r8

r9… r15 não Apareceu mais tarde. Usado principalmente para armazenar variáveis temporais (mas às vezes
usado implicitamente, como r10, que salva os flags da CPU quando a instrução syscall é
executada. Consulte o Capítulo 6 “Interrupções e chamadas de sistema”).

Você geralmente não deseja usar registros rsp e rbp devido ao seu significado muito especial (mais tarde veremos
como eles corrompem a pilha e o quadro da pilha). No entanto, você pode realizar operações aritméticas diretamente neles, o
que os torna de uso geral.
A Tabela 1-3 mostra os registradores classificados por seus nomes seguindo uma convenção de indexação.

Tabela 1-3. Registros de uso geral de 64 bits – diferentes convenções de nomenclatura

r0 r1 r2 r3 r4 r5 r6 r7

rax RCX rdx rbx rsp RBP rsi RDI

É possível endereçar uma parte de um registro. Para cada registro você pode endereçar seus 32 bits mais baixos, 16 bits mais
baixos ou 8 bits mais baixos.
Ao usar os nomes r0,...,r15 isso é feito adicionando um sufixo apropriado ao nome de um registro:

• d para palavra dupla – menos de 32 bits;

• w para palavra – menos de 16 bits;

• b para byte – menos de 8 bits.

9
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Por exemplo,

• r7b é o byte mais baixo do registrador r7;

• r3w consiste nos dois bytes mais baixos de r3; e

• r0d consiste nos quatro bytes mais baixos de r0.

Os nomes alternativos também permitem endereçar as partes menores.


A Figura 1-4 mostra a decomposição de registradores amplos de uso geral em registradores menores.
A convenção de nomenclatura para acessar partes de rax, rbx, rcx e rdx segue o mesmo padrão; apenas a letra do meio (a
para rax) está mudando. Os outros quatro registros não permitem acesso aos seus segundos bytes mais baixos (como rax faz
com o nome de ah). A nomenclatura de bytes mais baixa difere ligeiramente para rsi, rdi, rsp e rbp.

• As menores partes de rsi e rdi são sil e dil (veja a Figura 1-5).

• As menores partes de rsp e rbp são spl e bpl (veja a Figura 1-6).

Na prática, os nomes r0-r7 raramente são vistos. Normalmente os programadores usam nomes alternativos para os
primeiros oito registradores de uso geral. Isso é feito tanto por razões herdadas quanto semânticas: o rsp relaciona muito mais
informações do que o r4. Os outros oito (r8-r15) só podem ser nomeados usando uma convenção indexada.

ÿ Inconsistência nas escritas Todas as leituras de registradores menores agem de maneira óbvia. As gravações em partes de

32 bits, entretanto, preenchem os 32 bits superiores do registro completo com bits de sinal. Por exemplo, zerar eax zerará todo o

rax, armazenar -1 em eax preencherá os 32 bits superiores com uns. Outras escritas (por exemplo, em partes de 16 bits) agem

conforme pretendido: elas deixam todos os outros bits inalterados. Consulte a seção 3.4.2 “CISC e RISC” para a explicação.

10
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

1.3.2 Outros Registros


Os demais registros têm significado especial. Alguns registros têm importância em todo o sistema e, portanto, não podem ser modificados,
exceto pelo sistema operacional.

Figura 1-3. Aproximação do Intel 64: registros de uso geral

11
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Um programador tem acesso ao registro rip. É um registrador de 64 bits, que sempre armazena o endereço da próxima instrução a
ser executada. As instruções de ramificação (por exemplo, jmp) estão, na verdade, modificando-o. Assim, toda vez que alguma instrução está
sendo executada, o rip armazena o endereço da próxima instrução a ser executada.

ÿ Nota Todas as instruções têm tamanhos diferentes!

Outro registro acessível é chamado de rflags. Ele armazena sinalizadores, que refletem o estado atual do programa – por
por exemplo, qual foi o resultado da última instrução aritmética: foi negativo, ocorreu um overflow, etc. Suas partes menores são chamadas
de eflags (32 bits) e flags (16 bits).

ÿ Pergunta 1 É hora de fazer uma pesquisa preliminar com base na documentação [15]. Consulte a seção 3.4.3

do primeiro volume para aprender sobre rflags de registro. Qual é o significado das bandeiras CF, AF, ZF, OF, SF? Qual é a diferença entre
OF e CF?

Figura 1-4. decomposição rax

Além desses registradores principais, também existem registradores usados por instruções que trabalham com dados flutuantes.
números de pontos ou instruções especiais paralelizadas capazes de executar ações semelhantes em vários pares de operandos ao
mesmo tempo. Essas instruções são frequentemente usadas para fins multimídia (elas ajudam a acelerar os algoritmos de decodificação de
multimídia). Os registros correspondentes têm 128 bits de largura e são denominados xmm0 - xmm15.
Falaremos sobre eles mais tarde.

Alguns registros apareceram como extensões não padronizadas, mas foram padronizados logo depois. Esses
são os chamados registros específicos do modelo. Consulte a seção 6.3.1 “Registradores específicos do modelo” para mais detalhes.

1.3.3 Registros do Sistema


Alguns registros são projetados especificamente para serem usados pelo sistema operacional. Eles não contêm valores usados em cálculos.
Em vez disso, eles armazenam informações exigidas pelas estruturas de dados de todo o sistema. Assim, o seu papel é apoiar um
framework, nascido de uma simbiose entre o sistema operacional e a CPU. Todos os aplicativos estão sendo executados dentro desta estrutura.
Este último garante que os aplicativos estejam bem isolados do próprio sistema e uns dos outros; também gerencia recursos de uma forma
mais ou menos transparente para um programador.
É extremamente importante que estes registos sejam inacessíveis pelas próprias aplicações (pelo menos as
os aplicativos não devem ser capazes de modificá-los). Este é o objetivo do modo privilegiado (ver seção 3.2).
Listaremos alguns desses registros aqui. Seu significado será explicado em detalhes posteriormente.

• cr0, cr4 armazenam flags relacionadas a diferentes modos de processador e memória virtual;

• cr2, cr3 são usados para suportar memória virtual (ver seções 4.2 “Motivação”, 4.7.1
“Estrutura de endereço virtual”);

12
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

• cr8 (também conhecido como tpr) é usado para realizar um ajuste fino do mecanismo de interrupções
(ver seção 6.2 “Interrupções”).

• efer é outro registrador de flag usado para controlar modos e extensões do processador
(por exemplo, modo longo e tratamento de chamadas de sistema).

• idtr armazena o endereço da tabela de descritores de interrupção (veja seção 6.2 “Interrupções”).

• gdtr e ldtr armazenam os endereços das tabelas descritoras (ver seção 3.2 "Protegido
modo").

• cs, ds, ss, es, gs, fs são os chamados registradores de segmento. O mecanismo de segmentação que
eles fornecem é considerado legado há muitos anos, mas parte dele ainda é usado para implementar
o modo privilegiado. Consulte a seção 3.2 "Modo protegido".

Figura 1-5. decomposição rsi e rdi

Figura 1-6. decomposição rsp e rbp

13
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

1.4 Anéis de Proteção


Os anéis de proteção são um dos mecanismos projetados para limitar as capacidades das aplicações por razões de segurança e robustez.
Eles foram inventados para o Multics OS, um antecessor direto do Unix. Cada anel corresponde a um determinado nível de privilégio. Cada
tipo de instrução está vinculado a um ou mais níveis de privilégio e não é executável em outros. O nível de privilégio atual é
armazenado de alguma forma (por exemplo, dentro de um registro especial).
O Intel 64 possui quatro níveis de privilégio, dos quais apenas dois são usados na prática: ring-0 (o mais privilegiado) e ring-3 (o
menos privilegiado). Os anéis intermediários foram planejados para serem usados para drivers e serviços de sistema operacional, mas
sistemas operacionais populares não adotaram essa abordagem.
No modo longo, o número do anel de proteção atual é armazenado nos dois bits mais baixos do registro cs (e
duplicado nos de ss). Ele só pode ser alterado ao lidar com uma interrupção ou chamada de sistema. Portanto, um aplicativo não
pode executar um código arbitrário com níveis de privilégio elevados: ele só pode chamar um manipulador de interrupções ou executar
uma chamada de sistema. Consulte o Capítulo 3 “Legado” para mais informações.

1.5 Pilha de Hardware


Se falamos de estruturas de dados em geral, uma pilha é uma estrutura de dados, um contêiner com duas operações: um novo elemento pode
ser colocado no topo da pilha (push); o elemento superior pode ser retirado da pilha (pop).
Existe suporte de hardware para tal estrutura de dados. Isso não significa que também exista uma pilha de memória separada. É
apenas uma espécie de emulação implementada com duas instruções de máquina (push e pop) e um registrador (rsp). O registrador rsp
contém o endereço do elemento mais alto da pilha. As instruções funcionam da seguinte forma:

• empurrar argumento

1. Dependendo do tamanho do argumento (são permitidos 2, 4 e 8 bytes), o valor de rsp é diminuído em


2, 4 ou 8.

2. Um argumento é armazenado na memória começando no endereço, retirado do


rsp modificado.

• argumento pop

1. O elemento superior da pilha é copiado para o registro/memória.

2. rsp é aumentado pelo tamanho do seu argumento. Uma arquitetura aumentada é


representado na Figura 1-7.

14
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

Figura 1-7. Intel 64, registros e pilha

A pilha de hardware é mais útil para implementar chamadas de função em linguagens de nível superior.
Quando uma função A chama outra função B, ela usa a pilha para salvar o contexto dos cálculos para retornar a ela após B
termina.
Aqui estão alguns fatos importantes sobre a pilha de hardware, muitos dos quais decorrem de sua descrição:

1. Não existe situação de pilha vazia, mesmo que executemos push zero vezes.
Um algoritmo pop pode ser executado de qualquer maneira, provavelmente retornando um elemento de
pilha “superior” de lixo.

2. A pilha cresce em direção ao endereço zero.

3. Quase todos os tipos de seus operandos são considerados inteiros com sinal e, portanto, podem ser
expandido com bit de sinal. Por exemplo, executar push com um argumento B916 resultará
no armazenamento da seguinte unidade de dados na pilha:

0xff b9, 0xffffffb9 ou 0xff ff ff ff ff ff ff b9.

Por padrão, push usa um tamanho de operando de 8 bytes. Assim, uma instrução
push -1 armazenará 0xff ff ff ff ff ff ff ff na pilha.

4. A maioria das arquiteturas que suportam pilha usam o mesmo princípio com seu topo
definido por algum registro. O que difere, porém, é o significado do respectivo endereço.
Em algumas arquiteturas é o endereço do próximo elemento, que será escrito no próximo
push. Em outros, é o endereço do último elemento já colocado na pilha.

15
Machine Translated by Google

Capítulo 1 ÿ Arquitetura Básica de Computador

ÿ Trabalhando com documentos da Intel: como ler as descrições das instruções Abra o segundo volume de [15].

Encontre a página correspondente à instrução push . Tudo começa com uma mesa. Para nosso propósito, investigaremos
apenas as colunas OPCODE, INSTRUCTION, 64-BIT MODE e DESCRIPTION. O campo OPCODE define a codificação de

máquina de uma instrução (código de operação). Como você vê, existem opções e cada opção corresponde a uma DESCRIÇÃO

diferente. Isso significa que às vezes não apenas os operandos variam, mas também os próprios códigos de operação.

INSTRUCTION descreve os mnemônicos da instrução e os tipos de operandos permitidos. Aqui R representa qualquer registro de

uso geral, M representa localização de memória, IMM representa valor imediato (por exemplo, constante inteira como 42 ou 1337).

Um número define o tamanho do operando. Se apenas registros específicos forem permitidos, eles serão nomeados. Por exemplo:

• push r/m16 — envia um registrador de uso geral de 16 bits ou um número de 16 bits retirado da memória
para a pilha.

• push CS — envia um registrador de segmento cs.

A coluna DESCRIÇÃO fornece uma breve explicação dos efeitos da instrução. Muitas vezes é suficiente compreender e usar as
instruções.

• Leia a explicação adicional sobre push. Quando o operando não tem sinal estendido?

• Explicar todos os efeitos da instrução push rsp na memória e nos registradores.

1.6 Resumo
Neste capítulo, fornecemos uma rápida visão geral da arquitetura de von Neumann. Começamos a adicionar recursos a este
modelo para torná-lo mais adequado para descrever processadores modernos. Até agora, examinamos mais de perto os
registradores e a pilha de hardware. O próximo passo é iniciar a programação em assembly, e é a isso que o próximo capítulo
é dedicado. Veremos alguns programas de amostra, identificaremos vários novos recursos de arquitetura (como endianness e
modos de endereçamento) e projetaremos uma biblioteca simples de entrada/saída para *nix para facilitar a interação com o usuário.

ÿ Pergunta 2 Quais são os princípios-chave da arquitetura von Neumann?

ÿ Pergunta 3 O que são registros?

ÿ Pergunta 4 O que é a pilha de hardware?

ÿ Pergunta 5 Quais são as interrupções?

ÿ Questão 6 Quais são os principais problemas que as extensões modernas do modelo de von Neumann estão tentando
resolver?

ÿ Pergunta 7 Quais são os principais registros de uso geral do Intel 64?

ÿ Pergunta 8 Qual é a finalidade do ponteiro de pilha?

ÿ Pergunta 9 A pilha pode estar vazia?

ÿ Pergunta 10 Podemos contar elementos em uma pilha?

16
Machine Translated by Google

CAPÍTULO 2

Linguagem Assembly

Neste capítulo começaremos a praticar a linguagem assembly escrevendo gradualmente programas mais complexos para Linux.
Observaremos alguns detalhes de arquitetura que impactam a escrita de todos os tipos de programas (por exemplo, endianness).
Escolhemos um sistema *nix neste livro porque é muito mais fácil programar em assembly do que no Windows.

2.1 Configurando o Ambiente


É impossível aprender programação sem tentar programar. Então vamos começar a programar em assembly agora mesmo.

Estamos usando a seguinte configuração para concluir as tarefas de assembler e C:

• Debian GNU\Linux 8.0 como sistema operacional.

• NASM 2.11.05 como um compilador de linguagem assembly.

• GCC 4.9.2 como compilador da linguagem C. Esta versão exata é usada para produzir montagem de
Programas C. O compilador Clang também pode ser usado.

• GNU Make 4.0 como sistema de compilação.

• GDB 7.7.1 como depurador.

• O editor de texto de sua preferência (de preferência com destaque de sintaxe). Defendemos o uso do ViM.

Se você deseja configurar seu próprio sistema, instale qualquer distribuição Linux de sua preferência e certifique-se de instalar
os programas que acabamos de listar. Até onde sabemos, o Windows Subsystem para Linux também é adequado
para realizar todas as tarefas. Você pode instalá-lo e depois instalar os pacotes necessários usando o apt-get. Consulte
o guia oficial localizado em: https://msdn.microsoft.com/en-us/commandline/wsl/install_guide.
No site da Apress para este livro, http://www.apress.com/us/book/9781484224021, você pode encontrar o seguinte:

• Duas máquinas virtuais pré-configuradas com todo o conjunto de ferramentas instalado. Um deles possui
ambiente desktop; o outro é apenas o sistema mínimo que pode ser acessado através de SSH (Secure
Shell). As instruções de instalação e outras informações de uso estão localizadas no arquivo README.txt
no arquivo baixado.

• Um link para a página do GitHub com todas as listagens do livro, respostas às perguntas e
soluções.

© Igor Zhirkov 2017 17


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_2
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

2.1.1 Trabalhando com exemplos de código


Ao longo deste capítulo, você verá vários exemplos de código. Compile-os e se tiver dificuldade em entender sua lógica, tente executá-
los passo a passo usando gdb. É uma grande ajuda no estudo de código. Veja o Apêndice A para um tutorial rápido sobre gdb.

O Apêndice D fornece mais informações sobre o sistema usado para testes de desempenho.

2.2 Escrevendo “Olá, mundo”


2.2.1 Entrada e Saída Básica
A ideologia Unix postula que “tudo é um arquivo”. Um arquivo, em sentido amplo, é qualquer coisa que se pareça com um fluxo de bytes.
Através de arquivos pode-se abstrair coisas como

• acesso a dados em disco rígido/SSD;

• troca de dados entre programas; e

• interação com dispositivos externos.

Seguiremos a tradição de escrever um simples “Olá, mundo!” programa para começar. Ele exibe um sinal de boas-vindas
mensagem na tela e termina. No entanto, tal programa deve mostrar caracteres na tela, o que não pode ser feito diretamente se um
programa não estiver sendo executado em bare metal, sem um sistema operacional cuidando de sua atividade. O propósito de um sistema
operacional é, entre outras coisas, abstrair e gerenciar recursos, e a exibição é certamente uma delas. Ele fornece um conjunto de
rotinas para lidar com a comunicação com dispositivos externos, outros programas, sistemas de arquivos e assim por diante. Um programa
geralmente não consegue ignorar o sistema operacional e interagir diretamente com os recursos que controla. Está limitado a chamadas de
sistema, que são rotinas fornecidas por um sistema operacional aos aplicativos do usuário.

O Unix identifica um arquivo com seu descritor assim que ele é aberto por um programa. Um descritor não é nada
mais do que um valor inteiro (como 42 ou 999). Um arquivo é aberto explicitamente invocando a chamada de sistema open ;
entretanto, três arquivos importantes são abertos assim que o programa é iniciado e, portanto, não devem ser gerenciados
manualmente. Estes são stdin, stdout e stderr. Seus descritores são 0, 1 e 2, respectivamente. stdin é usado para manipular entradas,
stdout para manipular saídas e stderr é usado para gerar informações sobre o processo de execução do programa, mas não seus
resultados (por exemplo, erros e diagnósticos).
Por padrão, a entrada do teclado está vinculada ao stdin e a saída do terminal está vinculada ao stdout. Significa que “Olá,
mundo!” deve escrever em stdout.
Portanto, precisamos invocar a chamada do sistema write . Ele grava uma determinada quantidade de bytes da memória começando
em um determinado endereço em um arquivo com um determinado descritor (no nosso caso, 1). Os bytes codificarão caracteres de string
usando uma tabela predefinida (tabela ASCII). Cada entrada é um personagem; um índice na tabela corresponde ao seu código no intervalo
de 0 a 255.
Veja a Listagem 2-1 para nosso primeiro exemplo completo de um programa assembly.

Listagem 2-1. olá.asm

_início global

seção .dados

mensagem: db 'olá, mundo!', 10

seção .texto

_começar:
movimento
rax, 1 ;o número de chamada do sistema deve ser armazenado em rax
movimento
rdi, 1 ; argumento nº 1 em rdi: onde escrever (descritor)?

18
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

movimento
rsi, mensagem ; argumento nº 2 em rsi: onde começa a string?
movimento
rdx, 14 ; argumento nº 3 em rdx: quantos bytes escrever?
syscall ; esta instrução invoca uma chamada de sistema

Este programa invoca uma chamada de sistema write com argumentos corretos nas linhas 6-9. É realmente a única coisa que
faz. As próximas seções explicarão este programa de amostra com mais detalhes.

2.2.2 Estrutura do Programa


Como lembramos da descrição da máquina de von Neumann, existe apenas uma memória, tanto para código quanto para dados;
esses são indistinguíveis. No entanto, um programador deseja separá-los. Um programa assembly geralmente é dividido em
seções. Cada seção tem sua utilidade: por exemplo, .text contém instruções, .data é para variáveis globais (dados disponíveis em
cada momento da execução do programa). Pode-se alternar entre as seções; no programa resultante todos os dados correspondentes
a cada seção serão reunidos em um só lugar.
Para se livrar dos valores de endereço numérico, os programadores usam rótulos. Eles são apenas nomes e endereços
legíveis. Eles podem preceder qualquer comando e geralmente são separados dele por dois pontos. Há um rótulo neste programa
na linha 5. _start.
Uma noção de variável é típica para linguagens de nível superior. Na linguagem assembly, na verdade, as noções de
variáveis e procedimentos são bastante sutis. É mais conveniente falar em rótulos (ou endereços).
Um programa assembly pode ser dividido em vários arquivos. Um deles deve conter o rótulo _start. É o ponto de entrada; marca
a primeira instrução a ser executada.
Este rótulo deve ser declarado global (ver linha 1). O significado disso ficará evidente mais tarde.
Os comentários começam com ponto e vírgula e duram até o final da linha.
A linguagem assembly consiste em comandos, que são mapeados diretamente em código de máquina. No entanto, nem todos
construções de linguagem são comandos. Outros controlam o processo de tradução e são geralmente chamados de diretivas.1
Na seção “Olá, mundo!” por exemplo, existem três diretivas: global, seção e db.

ÿ Nota A linguagem assembly, em geral, não diferencia maiúsculas de minúsculas, mas os nomes dos rótulos não!

mov, mOV, Mov são todos a mesma coisa, mas global _start e global _START não são! Os nomes das seções também diferenciam
maiúsculas de minúsculas: seção .DATA e seção .data são diferentes!

A diretiva db é usada para criar dados de bytes. Normalmente os dados são definidos usando uma destas diretivas, que diferem
pelo formato dos dados:

• banco de dados—bytes;

• dw – chamadas palavras, iguais a 2 bytes cada;

• dd – palavras duplas, iguais a 4 bytes; e

• dq — palavras quádruplas, iguais a 8 bytes.

Vejamos um exemplo na Listagem 2-2.

Listagem 2-2. dados_decl.asm

seção .dados
exemplo1: db 5, 16, 8, 4, 2, 1
exemplo2: vezes 999 db 42
exemplo3: dw 999

1 O manual NASM também usa o nome “pseudoinstrução” para um subconjunto específico de diretivas.
19
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

times n cmd é uma diretiva para repetir cmd n vezes no código do programa. Como se você tivesse copiado e colado n vezes. Isso também
funciona com instruções da unidade central do processador (CPU).
Observe que você pode criar dados dentro de qualquer seção, incluindo .text. Como dissemos anteriormente, para dados da CPU
e as instruções são todas iguais e a CPU tentará interpretar os dados como instruções codificadas quando solicitado.
Estas diretivas permitem definir vários objetos de dados um por um, como na Listagem 2-3, onde uma sequência
de caracteres é seguido por um único byte igual a 10.

Listagem 2-3. olá.asm

mensagem: db 'olá, mundo!', 10

Letras, dígitos e outros caracteres são codificados em ASCII. Os programadores concordaram com uma tabela, onde cada caractere
recebe um número exclusivo – seu código ASCII. Começamos no endereço correspondente à mensagem do rótulo. Armazenamos os códigos ASCII
para todas as letras da string “hello, world!”, depois adicionamos um byte igual a 10. Por que 10? Por convenção, para iniciar uma nova linha,
geramos um caractere especial com o código 10.

ÿ Caos terminológico É bastante comum referir-se ao formato inteiro mais nativo do computador como palavra de máquina. Como

estamos programando um computador de 64 bits, onde os endereços são de 64 bits e os registradores de uso geral são de 64 bits,

é bastante conveniente considerar o tamanho da palavra da máquina como 64 bits ou 8 bytes.

Na programação assembly para a arquitetura Intel, o termo palavra era de fato usado para descrever uma entrada de dados de 16 bits,

porque nas máquinas mais antigas era exatamente a palavra da máquina. Infelizmente, por motivos de legado, ainda é usado como

antigamente. É por isso que os dados de 32 bits são chamados de palavras duplas e os dados de 64 bits são chamados de palavras quádruplas.

2.2.3 Instruções Básicas


A instrução mov é usada para escrever um valor no registro ou na memória. O valor pode ser retirado de outro registrador ou da memória, ou pode
ser imediato. No entanto,

1. mov não pode copiar dados de memória para memória;

2. os operandos origem e destino devem ser do mesmo tamanho.

A instrução syscall é usada para realizar chamadas de sistema em sistemas *nix. As operações de entrada/saída dependem do hardware
(que também pode ser usado por vários programas ao mesmo tempo), portanto os programadores não têm permissão para controlá-las
diretamente, ignorando o sistema operacional.
Cada chamada de sistema possui um número exclusivo. Para realizá-lo

1. O registrador rax deve conter o número da chamada do sistema;

2. Os seguintes registradores devem conter seus argumentos: rdi, rsi, rdx, r10, r8 e r9.

A chamada do sistema não pode aceitar mais de seis argumentos.

3. Execute a instrução syscall.

Não importa a ordem em que os registradores são inicializados.


Observe que a instrução syscall altera rcx e r11! Explicaremos a causa mais tarde. Quando escrevemos
o “Olá, mundo!” programa, usamos um syscall de gravação simples. Aceita

1. Descritor de arquivo;

2. O endereço do buffer. Começamos a usar bytes consecutivos para escrita a partir daqui;

3. A quantidade de bytes a serem gravados.


20
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Para compilar nosso primeiro programa, salve o código em hello.asm2 e execute estes comandos no shell:

> nasm -felf64 olá.asm -o olá.o


> ld -o olá olá.o
> chmod u+x olá

Os detalhes do processo de compilação juntamente com os estágios de compilação serão discutidos no Capítulo 5. Vamos lançar
“Olá, mundo!”

> ./olá
Olá Mundo!
falha de segmentação

Nós produzimos claramente o que queríamos. No entanto, o programa parece ter causado um erro. O que
fizemos algo errado? Após executar uma chamada do sistema, o programa continua seu trabalho. Não escrevemos nenhuma
instrução após o syscall, mas a memória contém de fato alguns valores aleatórios nas próximas células.

ÿ Nota Se você não colocou nada em algum endereço de memória, certamente haverá algum tipo de lixo, e não
zeros ou qualquer tipo de instrução válida.

Um processador não tem ideia se esses valores foram destinados a codificar instruções ou não. Então, seguindo sua própria natureza,
tenta interpretá-los, pois o registro rasga aponta para eles. É altamente improvável que esses valores codifiquem instruções corretas, portanto
ocorrerá uma interrupção com o código 6 (instrução inválida).3
Então, o que fazemos? Temos que usar a chamada de sistema exit, que finaliza o programa de maneira correta, conforme mostrado
na Listagem 2.4.

Listagem 2-4. olá_proper_exit.asm

seção .dados

mensagem: db 'olá, mundo!', 10

seção .texto
_início global

_começar:
movimento
rax, 1 ; 'escrever' número do syscall
movimento
rdi, 1 rsi, ; descritor de saída padrão
movimento
mensagem rdx, ; endereço de string
movimento
14 syscall ; comprimento da string em bytes

movimento
rax, 60 rdi, ; número do syscall de 'saída'
xor rdi
chamada de sistema

2 Lembre-se: todo o código-fonte, incluindo listagens, pode ser encontrado em www.apress.com/us/book/9781484224021 e


também é armazenado no diretório inicial da máquina virtual pré-configurada!
3
Mesmo que não, em breve a execução sequencial levará o processador ao fim dos endereços virtuais alocados, ver seção 4.2.
No final, o sistema operacional encerrará o programa porque é improvável que este se recupere dele.

21
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

ÿ Pergunta 11 O que a instrução xor rdi, rdi faz ?

ÿ Pergunta 12 Qual é o código de retorno do programa?

ÿ Pergunta 13 Qual é o primeiro argumento da chamada do sistema exit ?

2.3 Exemplo: Conteúdo do Registro de Saída


É hora de tentar algo um pouco mais difícil. Vamos gerar o valor rax em formato hexadecimal, conforme mostrado na Listagem 2.5.

Listagem 2-5. Imprimir valor rax: print_rax.asm


seção .códigos de
dados:
db '0123456789ABCDEF'

seção .texto
global _start
_start: ;
número 1122... em formato hexadecimal mov
rax, 0x1122334455667788

mov rdi, 1
mov rdx, 1
mov rcx, 64 ;
Cada 4 bits deve ser gerado como um dígito hexadecimal; Use shift e
AND bit a bit para isolá-los; o resultado é o deslocamento
na matriz de 'códigos' .loop: push rax sub rcx, 4 ; cl é
um
registro, a
menor parte
de rcx; rax -- eax -- ax -- ah + al ; rcx -- ecx -- cx --
ch + cl sar rax, cl e rax, 0xf

lea rsi, [códigos + rax] mov


rax, 1

; syscall deixa rcx e r11 alterado push rcx


syscall
pop rcx

estourar
rax; O teste pode ser usado para o mais rápido 'é zero?' verificar ; veja
a documentação para o comando
'test' test rcx, rcx
jnz .loop

22
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

movimento
rax, 60; rdi, invocar chamada de sistema 'exit'
xor rdi
chamada de sistema

Ao mudar o valor rax e fazer um AND lógico com a máscara 0xF, transformamos o número inteiro em um de seus dígitos
hexadecimais. Cada dígito é um número de 0 a 15. Use-o como índice e adicione-o ao endereço dos códigos da etiqueta para
obter o caractere representativo.
Por exemplo, dado rax = 0x4A usaremos os índices 0x4 = 410 e 0xA = 1010. 4 O primeiro nos dará um
caractere '4' cujo código é 0x34. O segundo resultará no caracter 'a' cujo código é 0x61.

ÿ Questão 14 Verifique se os códigos ASCII mencionados no último exemplo estão corretos.

Podemos usar uma pilha de hardware para salvar e restaurar valores de registro, como em torno da instrução syscall.

ÿ Pergunta 15 Qual é a diferença entre sar e shr? Verifique os documentos da Intel.

ÿ Pergunta 16 Como você escreve números em diferentes sistemas numéricos de uma forma compreensível para NASM?
Verifique a documentação do NASM.

ÿ Nota Quando um programa é iniciado, o valor da maioria dos registradores não está bem definido (pode ser absolutamente aleatório).

É uma grande fonte de erros de novato, pois tende-se a presumir que eles foram zerados.

2.3.1 Rótulos Locais


Observe o nome incomum do rótulo .loop: ele começa com um ponto. Este rótulo é local. Podemos reutilizar os nomes dos
rótulos sem causar conflitos de nomes, desde que sejam locais.
O último rótulo global sem ponto usado é a base para todos os rótulos locais subsequentes (até que o próximo rótulo global
ocorra). O nome completo do rótulo .loop é _start.loop. Podemos usar esse nome para endereçá-lo de qualquer lugar do programa,
mesmo após a ocorrência de outros rótulos globais.

2.3.2 Endereçamento Relativo


Isto demonstra como endereçar a memória de uma forma mais complexa do que apenas por endereço imediato.

Listagem 2-6. Endereçamento relativo: print_rax.asm

lea rsi, [códigos + rax]

Colchetes indicam endereçamento indireto; o endereço está escrito dentro deles.

• mov rsi, rax — copia rax para rsi

• mov rsi, [rax] — copia o conteúdo da memória (8 bytes sequenciais) começando no endereço, armazenado
em rax, para rsi. Como sabemos que precisamos copiar exatamente 8 bytes? Como sabemos, mov
os operandos são do mesmo tamanho e o tamanho do rsi é de 8 bytes. Conhecendo esses fatos,
o montador é capaz de deduzir que exatamente 8 bytes devem ser retirados da memória.

4 O subscrito denota a base do sistema numérico.


23
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

As instruções lea e mov possuem uma diferença sutil entre seus significados. lea significa “carregar endereço efetivo”.

Ele permite calcular o endereço de uma célula de memória e armazená-lo em algum lugar. Isso nem sempre é trivial,
porque existem modos de endereço complicados (como veremos mais tarde): por exemplo, o endereço pode ser uma soma
de vários operandos.
A Listagem 2.7 fornece uma rápida demonstração do que lea e mov estão fazendo.

Listagem 2-7. lea_vs_mov.asm

; rsi <- endereço do rótulo 'códigos', um número mov rsi,


códigos

; rsi <- conteúdo da memória começando no endereço de 'códigos'; 8 bytes


consecutivos são obtidos porque rsi tem 8 bytes de comprimento mov rsi, [códigos]

; rsi <- endereço dos 'códigos'; neste


caso é equivalente a mov rsi, códigos; em geral o endereço pode
conter vários componentes como rsi, [códigos]

; rsi <- conteúdo da memória começando em (códigos+rax) mov rsi,


[códigos + rax]

; rsi <- códigos + rax;


equivalente de combinação: ; --mov rsi,
códigos; -- adicione rsi,
rax; Não posso fazer
isso com um único movimento! lea rsi,
[códigos + rax]

2.3.3 Ordem de Execução


Todos os comandos são executados consecutivamente, exceto quando ocorrem instruções especiais de salto. Existe
uma instrução de salto incondicional jmp addr. Pode ser visto como um substituto de mov rip, addr. 5
Os saltos condicionais dependem do conteúdo do registro rflags. Por exemplo, o endereço jz salta para o endereço somente se o sinalizador
zero estiver definido.
Normalmente usa-se uma instrução de teste ou cmp para configurar os sinalizadores necessários juntamente com a
instrução de salto
condicional. cmp subtrai o segundo operando do primeiro; ele não armazena o resultado em nenhum lugar, mas define
os sinalizadores apropriados com base nele (por exemplo, se os operandos forem iguais, ele definirá o sinalizador zero). test faz
a mesma coisa, mas usa AND lógico em vez de subtração.
Um exemplo mostrado na Listagem 2.8 incorpora escrever 1 em rbx se rax < 42, e 0 caso contrário.

5 Esta ação é impossível de codificar usando o comando mov. Verifique os documentos da Intel para verificar se não está implementado.

24
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Listagem 2-8. saltos_exemplo.asm

cmp rax, 42
jl sim
movimento rbx, 0
jmp ex
sim:
movimento rbx, 1
ex:

É uma maneira comum (e rápida) de testar se o valor do registro é zero com a instrução test reg,reg.
Existem pelo menos dois comandos para cada sinalizador aritmético F: jF e jnF. Por exemplo, sinalizador de sinal: js e jns.
Outros comandos úteis incluem

1. ja (salto se estiver acima)/jb (salto se estiver abaixo) para um salto após uma comparação de números
sem sinal com cmp.

2. jg (pular se for maior)/jl (pular se for menor) para assinado.

3. jae (pular se estiver acima ou igual), jle (pular se for menor ou igual) e similares. Alguns
instruções de salto comuns são mostradas na Listagem 2-9.

Listagem 2-9. Instruções de salto: jumps.asm

mov rax, -1
movimento rdx, 2

cmp rax, rdx


localização jg
localização ; lógica diferente!

cmp rax, rdx


minha localização ; se rax é igual a rdx
minha localização ; se rax não for igual a rdx

ÿ Pergunta 17 Qual é a diferença entre je e jz?

2.4 Chamadas de Função


As rotinas (funções) permitem isolar uma parte da lógica do programa e usá-la como uma caixa preta. É um mecanismo necessário para
fornecer abstração. A abstração permite construir sistemas mais complexos encapsulando algoritmos complexos em interfaces opacas.

A chamada de instrução <endereço> é usada para realizar chamadas. Ele faz exatamente o seguinte:

empurrar rasgar

jmp <endereço>

O endereço agora armazenado na pilha (antigo conteúdo da cópia) é chamado de endereço de retorno.
Qualquer função pode aceitar um número ilimitado de argumentos. Os primeiros seis argumentos são passados em rdi,
rsi, rdx, rcx, r8 e r9, respectivamente. O restante é passado para a pilha na ordem inversa.
O que consideramos o fim de uma rotina não está claro. A coisa mais direta a dizer é que ret
instrução denota o fim da função. Sua semântica é totalmente equivalente ao pop rip.

25
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Aparentemente, o frágil mecanismo de call e ret só funciona quando o estado da pilha é cuidadosamente
gerenciou. Não se deve invocar ret a menos que a pilha esteja exatamente no mesmo estado de quando a função foi iniciada. Caso contrário,
o processador pegará tudo o que estiver no topo da pilha como endereço de retorno e o usará como o novo conteúdo de extração, o que certamente
levará à execução de lixo.
Agora vamos falar sobre como as funções usam registradores. Obviamente, a execução de uma função pode alterar os registros.
Existem dois tipos de registros.

• Os registros salvos pelo chamado devem ser restaurados pelo procedimento que está sendo chamado. Então, se precisar
para mudá-los, é preciso mudá-los de volta.

Esses registros são salvos pelo chamado: rbx, rbp, rsp, r12-r15, um total de sete registros.

• Os registros salvos pelo chamador devem ser salvos antes de invocar uma função e restaurados depois. Não é necessário
salvá-los e restaurá-los se o seu valor não tiver importância depois.

Todos os outros registros são salvos pelo chamador.

Essas duas categorias são uma convenção. Ou seja, um programador deve seguir este acordo

• Salvar e restaurar registros salvos pelo chamador.

• Estar sempre ciente de que os registros salvos pelo chamador podem ser alterados durante a execução da função.

ÿ Uma fonte de bugs Um erro comum é não salvar os registros salvos pelo chamador antes da chamada e usá-los após retornar da função.

Lembrar:

1. Se você alterar rbx, rbp, rsp ou r12-r15, altere-os novamente!

2. Se você precisar de qualquer outro registro para sobreviver à chamada de função, salve-o antes de chamar!

Algumas funções podem retornar um valor. Este valor geralmente é a essência do motivo pelo qual a função é escrita e
executado. Por exemplo, podemos escrever uma função que aceita um número como argumento e o retorna ao quadrado.
Em termos de implementação, estamos retornando valores armazenando-os em rax antes que a função termine seu
execução. Se precisar retornar dois valores, você poderá usar rdx para o segundo.
Portanto, o padrão de chamada de uma função é o seguinte:

• Salve todos os registros salvos pelo chamador que você deseja que sobrevivam à chamada de
função (você pode usar push para isso).

• Armazene argumentos nos registros relevantes (rdi, rsi, etc.).

• Invoque a função usando call.

• Após o retorno da função, rax manterá o valor de retorno.

• Restaurar registros salvos pelo chamador armazenados antes da chamada de função.

ÿ Por que precisamos de convenções? Uma função é usada para abstrair uma parte da lógica, esquecendo completamente sua

implementação interna e alterando-a quando necessário. Tais mudanças devem ser completamente transparentes para o programa externo.

A convenção descrita anteriormente permite que você chame qualquer função de qualquer lugar e tenha certeza sobre seus efeitos (pode

alterar qualquer registro salvo pelo chamador; manterá intactos os registros salvos pelo chamador).

Algumas chamadas de sistema também retornam valores — tenha cuidado e leia a documentação!
Você nunca deve usar rbp e rsp. Eles são usados implicitamente durante a execução. Como você já sabe, rsp é usado como ponteiro de pilha.

26
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

ÿ Argumentos nas chamadas do sistema Os argumentos das chamadas do sistema são armazenados em um conjunto de

registros diferente daqueles das funções. O quarto argumento é armazenado em r10, enquanto uma função aceita o quarto argumento em rcx!

A razão é que a instrução syscall usa implicitamente rcx. As chamadas do sistema não podem aceitar mais de seis
argumentos.
Se você não seguir a convenção descrita, não poderá alterar suas funções sem
introduzindo bugs nos locais onde são chamados.
Agora é hora de escrever mais duas funções: print_newline imprimirá o caractere de nova linha; imprimir_hex
aceitará um número e o imprimirá em formato hexadecimal (veja Listagem 2.10).

Listagem 2-10. print_call.asm

seção .dados

newline_char: banco de dados 10


códigos: banco de dados '0123456789abcdef'

seção .texto
_início global

imprimir_nova linha:
mov rax, 1 ; 'escrever' identificador syscall
mov rdi, 1 ; descritor de arquivo stdout
mov rsi, newline_char ; de onde tiramos dados
mov rdx, 1 ; a quantidade de bytes para escrever
chamada de sistema

ret

imprimir_hex:
mov rax, rdi

mov rdi, 1
movimento rdx, 1
mov rcx, 64 ; até onde estamos mudando rax?
iterar:
empurre ; Salve o valor rax inicial
rax sub rcx, 4
sar rax, cl ; mude para 60, 56, 52, ... 4, 0
; o registrador cl é a menor parte do rcx
e rax, 0xf; limpe todos os bits, exceto os quatro mais baixos
lea rsi, [códigos + rax]; pegue um código de caractere de dígito hexadecimal

mov rax, 1 ;

enviar chamada ; syscall irá quebrar o rcx


de sistema rcx ; rax = 1 (31) -- o identificador de gravação,
; rdi = 1 para saída padrão,
; rsi = o endereço de um caractere, veja a linha 29

27
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

pop rcx
ˆ ˆ
teste pop ; veja a linha
rax rcx, rcx jnz 24; rcx = 0 quando todos os dígitos são mostrados
iterar

ret
_start:
mov rdi, 0x1122334455667788
chamada print_hex
chamada print_newline

mov rax, 60
xor rdi, syscall
rdi

2.5 Trabalhando com Dados


2.5.1 Endianidade
Vamos tentar gerar um valor armazenado na memória usando a função que acabamos de escrever. Faremos isso de duas
maneiras diferentes: primeiro enumeraremos todos os seus bytes separadamente e depois digitaremos normalmente (veja Listagem 2-11).

Listagem 2-11. endianness.asm

seção .dados
demonstração1: dq 0x1122334455667788
demonstração2: banco de dados 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88

seção .texto

_start:
mov rdi, [demo1]
chama print_hex
chama print_newline

mov rdi, [demo2]


chama print_hex
chama print_newline

mov rax, 60
xor rdi, syscall
rdi

Quando o lançamos, para nossa surpresa, obtemos resultados completamente diferentes para demo1 e demo2.

> ./principal
1122334455667788
8877665544332211

Como podemos ver, os números multibyte são armazenados na ordem inversa !

28
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Os bits em cada byte são armazenados de maneira direta, mas os bytes são armazenados a partir do menor
significativo para o mais significativo.
Isso se aplica apenas às operações de memória: nos registradores, os bytes são armazenados de forma natural. Diferente
processadores têm convenções diferentes sobre como os bytes são armazenados.

• Os números multibyte big endian são armazenados na memória começando pelo mais
bytes significativos.

• Números multibyte little endian são armazenados na memória começando com o menor
bytes significativos.

Como mostra o exemplo, o Intel 64 segue a convenção little endian. Em geral, escolher uma convenção em vez de outra é uma
questão de escolha feita pelos engenheiros de hardware.
Essas convenções não dizem respeito a arrays e strings. No entanto, se cada caractere for codificado usando 2
bytes em vez de apenas 1, esses bytes serão armazenados na ordem inversa.
A vantagem do little endian é que podemos descartar os bytes mais significativos, convertendo efetivamente o número de um formato mais
amplo para um mais estreito, como 8 bytes.
Por exemplo, demonstração3: dq 0x1234. Então, para converter esse número em dw, temos que ler um número dword
começando no mesmo endereço demo3. Consulte a Tabela 2-1 para obter um layout completo da memória.

Tabela 2-1. Little Endian e Big Endian para palavra quádrupla número 0x1234

ENDEREÇO VALOR – LE VALOR – SER

demonstração3 0x34 0x00

demonstração3 + 1 0x12 0x00

demonstração3 + 2 0x00 0x00

demonstração3 + 3 0x00 0x00

demonstração3 + 4 0x00 0x00

demonstração3 + 5 0x00 0x00

demonstração3 + 6 0x00 0x12

demonstração3 + 7 0x00 0x34

Big endian é um formato nativo frequentemente usado em pacotes de rede (por exemplo, TCP/IP). É também um formato de número
interno para Java Virtual Machine.
Middle endian é uma noção não muito conhecida. Suponha que queiramos criar um conjunto de rotinas para realizar operações aritméticas
com números de 128 bits. Então os bytes podem ser armazenados da seguinte forma: primeiro serão os 8 bytes menos significativos em ordem
inversa e depois os 8 bytes mais significativos também em ordem inversa:

7 6 5 4 3 2 1 0, 16 15 14 13 12 11 10 9 8

2.5.2 Sequências
Como já sabemos, os caracteres são codificados na tabela ASCII. Um código é atribuído a cada personagem.
Uma string é obviamente uma sequência de códigos de caracteres. No entanto, não diz nada sobre como determinar o seu comprimento.

1. As strings começam com seu comprimento explícito.

db 27, 'Vendendo a Inglaterra por libra'

29
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

2. Um caractere especial indica o final da string. Tradicionalmente, o código zero é usado.


Essas strings são chamadas de terminadas em nulo.

db 'Vendendo a Inglaterra por libra', 0

2.5.3 Pré-computação Constante


Não é incomum ver esse código:

laboratório: banco de dados 0

...
mov rax, laboratório + 1 + 2*3

NASM suporta expressões aritméticas com parênteses e operações de bits. Tais expressões só podem
inclui constantes conhecidas pelo compilador. Dessa forma, ele pode pré-calcular todas essas expressões e inserir os
resultados do cálculo (como números constantes) em código executável. Portanto, tais expressões NÃO são calculadas em
tempo de execução.
Um análogo de tempo de execução precisaria usar instruções como add ou mul.

2.5.4 Ponteiros e diferentes tipos de endereçamento


Ponteiros são endereços de células de memória. Eles podem ser armazenados na memória ou em registradores.
O tamanho do ponteiro é de 8 bytes. Os dados geralmente ocupam diversas células de memória (ou seja, vários
endereços consecutivos). Os ponteiros não contêm informações sobre o comprimento dos dados apontados. Ao tentar escrever em
algum lugar um valor cujo tamanho não é especificado e não pode ser deduzido (por exemplo, mov [minhavariável], 4), podemos
obter erros de compilação. Nesses casos, temos que fornecer o tamanho explicitamente, conforme mostrado abaixo:

seção .dados
teste: dq -1

seção .texto

mov byte[teste], 1 ;1
palavra mov[teste], 1 ;2
mov dword[teste], 1 ;4
mov qpalavra[teste], 1 ;8

ÿ Pergunta 18 Qual é a igualdade de teste após cada um dos comandos listados anteriormente?

Vamos ver como codificar operandos em instruções.

1. Imediatamente:

Uma instrução está contida na memória. Os operandos, de alguma forma, são suas partes;
essas partes têm endereços próprios. Muitas instruções podem conter os próprios valores dos
operandos.

Esta é a maneira de mover um número 10 para rax.

mov rax, 10

30
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

2. Através de um registo:

Esta instrução transfere o valor rbx para rax.

mov rax, rbx

3. Por endereçamento direto de memória:

Esta instrução transfere 8 bytes começando no décimo endereço para rax:

mov rax, [10]

Também podemos obter o endereço do registro:

mov r9, 10
mov rax, [r9]

Podemos usar pré-cálculos:

buffer: dq 8841, 99, 00


...
mov rax, [buffer+8]

O endereço dentro desta instrução foi pré-computado, porque tanto a base quanto o
deslocamento são constantes no controle do compilador. Agora é apenas um número.

4. Indexado por base com escala e deslocamento

A maioria dos modos de endereçamento são generalizados por este modo. O endereço
aqui é calculado com base nos seguintes componentes:

Endereço = base + índice ÿ escala + deslocamento

• A base é imediata ou cadastral;

• A escala só pode ser imediata igual a 1, 2, 4 ou 8;

• O índice é imediato ou registrado; e

• O deslocamento é sempre imediato.

A Listagem 2.12 mostra exemplos de diferentes tipos de endereçamento.

Listagem 2-12. endereçamento.asm

mov rax, [rbx + 4* rcx + 9] mov rax,


[4*r9] mov rdx, [rax
+ rbx] lea rax, [rbx + rbx *
4] adicione r8, [9 + rbx*8 + 7] ; rax = rbx * 5

Uma visão geral Você pode pensar em byte, palavra, etc. como especificadores de tipo. Por exemplo, você pode colocar
números de 16, 32 ou 64 bits na pilha. A instrução push 1 não está clara sobre quantos bits de largura o operando tem. Da
mesma forma, mov palavra[teste], 1 significa que [teste] é uma palavra; há uma informação sobre o formato do número codificado
na palavra push 1.

31
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

2.6 Exemplo: Cálculo do Comprimento da String


Vamos começar escrevendo uma função para calcular o comprimento de uma string terminada em nulo.
Como não temos uma rotina para imprimir algo na saída padrão, a única maneira de gerar o valor é retorná-lo como
um código de saída por meio da chamada do sistema exit. Para ver o código de saída do último processo use o $? variável.

>
verdadeiro > echo $?

0 > falso
> eco $?
1

Vamos escrever um programa assembly que imite o comando false shell, conforme mostrado na Listagem 2.13.

Listagem 2-13. falso.asm

_início global

seção .text _start:


mov rdi,
1 mov rax,
60 syscall

Agora temos tudo o que é necessário para calcular o comprimento da string. A Listagem 2-14 mostra o código.

Listagem 2-14. Comprimento da string: strlen.asm

_início global

seção .dados

string_teste: banco de dados "abcdef", 0

seção .texto

Strlen: ; pela nossa convenção, primeiro e único argumento; é retirado


de rdi ; rax manterá o
xor rax, rax comprimento da string. Se não é ; zerado primeiro, seu
valor será totalmente aleatório

.loop: ; o loop principal começa aqui


cmp byte [rdi+rax], 0; Verifique se o símbolo atual é terminador nulo.
; Precisamos absolutamente desse modificador 'byte' desde; a
parte esquerda e direita de cmp deve ser ; do mesmo tamanho.
O operando direito é imediato; e não contém informações
sobre seu tamanho; portanto, não sabemos quantos
bytes devem ter; retirado da memória e comparado a zero;
Pule se encontrarmos o terminador nulo
eu .end

32
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Inc Rax ; Caso contrário, vá para o próximo símbolo e aumente;


contador
jmp.loop

.fim:
ret ; Quando clicamos em 'ret', rax deve conter o valor de retorno

_começar:

mov rdi, test_string chama


strlen mov rdi,
rax

mov rax, 60
syscall

A parte importante (e a única que deixaremos) é a função strlen. Notar que

1. strlen altera os registros, portanto, após realizar a chamada strlen, os registros


podem alterar seus valores.

2. strlen não altera rbx ou qualquer outro registro salvo pelo receptor.

ÿ Pergunta 19 Você consegue identificar um ou dois bugs na Listagem 2.15? Quando eles ocorrerão?

Listagem 2-15. Versão alternativa de strlen: strlen_bug1.asm

_início global

seção .data
test_string: banco de dados "abcdef", 0

seção .texto

strlen: .loop: cmp byte [rdi+r13],


0 je .end
inc r13

jmp .loop .end: mov


rax, r13 ret

_start:
mov rdi, test_string chama
strlen mov rdi,
rax

mov rax, 60
syscall

33
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

2.7 Atribuição: Biblioteca de Entrada/Saída


Antes de começarmos a fazer qualquer coisa legal, vamos garantir que não teremos que codificar as mesmas rotinas básicas repetidamente. Por
enquanto não temos nada; até mesmo obter informações do teclado é uma dor. Então, vamos construir uma pequena biblioteca para funções básicas
de entrada e saída.
Primeiro você tem que ler os documentos da Intel [15] para as instruções a seguir (lembre-se, todas elas estão descritas em detalhes no
segundo volume):

• xor

• jmp, ja e similares

• cmp

• movimento

• aumento, dezembro

• adicionar, imul, mul, sub, idiv, div

• negativo

• ligar, retornar

• empurrar, estourar

Esses comandos são essenciais para nós e você deve conhecê-los bem. Como você deve ter notado, o Intel 64 suporta milhares de
comandos. Claro, não há necessidade de mergulharmos lá. Usar chamadas de sistema junto com as instruções listadas anteriormente nos
levará a praticamente qualquer lugar.
Você também precisa ler a documentação para a chamada de sistema read. Seu código é 0; caso contrário, é semelhante a escrever. Referir-se
o Apêndice C em caso de dificuldades.
Edite lib.inc e forneça definições para as funções em vez de instruções stub xor rax, rax. Consulte a Tabela 2-2 para obter a semântica das
funções necessárias. Recomendamos implementá-los na ordem indicada porque às vezes você poderá reutilizar seu código chamando funções que já
escreveu.

34
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

Tabela 2-2. Funções da biblioteca de entrada/ saída

Função Definição

saída Aceita um código de saída e encerra o processo atual.

string_length Aceita um ponteiro para uma string e retorna seu comprimento.

print_string Aceita um ponteiro para uma string terminada em nulo e o imprime em stdout.

print_char Aceita um código de caractere diretamente como seu primeiro argumento e o imprime em stdout.

print_newline Imprime um caractere com código 0xA.

print_uint Produz um número inteiro não assinado de 8 bytes em formato decimal.

Sugerimos que você crie um buffer na pilha6 e armazene os resultados da divisão lá. Cada vez que você divide
o último valor por 10 e armazena o dígito correspondente dentro do buffer. Não se esqueça que você
deve transformar cada dígito em seu código ASCII (por exemplo, 0x04 se torna 0x34).

print_int Produza um número inteiro assinado de 8 bytes em formato decimal.

read_char Leia um caractere do stdin e retorne-o. Se ocorrer o fim do fluxo de entrada, retorne 0.

read_word Aceita um endereço de buffer e tamanho como argumentos. Lê a próxima palavra do stdin
(pulando espaços em branco7 no buffer). Pára e retorna 0 se a palavra for muito grande para o buffer
especificado; caso contrário, retorna um endereço de buffer.

Esta função deve terminar em nulo a string aceita.

parse_uint Aceita uma string terminada em nulo e tenta analisar um número não assinado desde o início.

Retorna o número analisado em rax, seus caracteres contam em rdx.

parse_int Aceita uma string terminada em nulo e tenta analisar um número assinado desde o início.
Retorna o número analisado em rax; seus caracteres contam em rdx (incluindo sinal, se houver).
Não são permitidos espaços entre sinal e dígitos.

string_equals Aceita dois ponteiros para strings e os compara. Retorna 1 se forem iguais, caso contrário, 0.

string_copy Aceita um ponteiro para uma string, um ponteiro para um buffer e o comprimento do buffer. Copia a string
para o destino. O endereço de destino é retornado se a string couber no buffer; caso contrário, zero será
retornado.

Use test.py para realizar testes automatizados de correção. Basta executá-lo e ele fará o resto.
Lembre-se de que uma sequência de n caracteres precisa de n + 1 bytes para ser armazenada na memória por causa de um terminador nulo.
Leia o Apêndice A para ver como você pode executar o programa passo a passo observando as mudanças nos valores dos registradores
e no estado da memória.

2.7.1 Autoavaliação
Antes de testar ou diante de um resultado inesperado, verifique a seguinte lista rápida:

1. Os rótulos que denotam funções devem ser globais; outros deveriam ser locais.

2. Você não assume que os registradores mantêm zero “por padrão”.

3. Você salva e restaura registros salvos pelo receptor se os estiver usando.

6 Na verdade, ao diminuir o rsp você aloca memória na pilha.


7
Consideramos espaços, tabulação e quebras de linha como caracteres de espaço em branco. Seus códigos são 0x20, 0x9 e 0x10, respectivamente.

35
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

4. Você salva os registros salvos pelo chamador necessários antes da chamada e os restaura depois.

5. Você não usa buffers em .data. Em vez disso, você os aloca na pilha, o que
permite que você adapte o multithreading, se necessário.

6. Suas funções aceitam argumentos em rdi, rsi, rdx, rcx, r8 e r9.

7. Você não imprime números dígito após dígito. Em vez disso, você os transforma em sequências de
caracteres e use print_string.

8. parse_int e parse_uint estão configurando rdx corretamente. Será muito importante no


próxima tarefa.

9. Todas as funções de análise e read_word funcionam quando a entrada é finalizada via Ctrl-D.

Feito corretamente, o código não ocupará mais de 250 linhas.

ÿ Pergunta 20 Tente reescrever print_newline sem chamar print_char ou copiar seu código. Dica: leia sobre otimização de
chamada final.

ÿ Pergunta 21 Tente reescrever print_int sem chamar print_uint ou copiar seu código. Dica: leia sobre otimização de chamada
final.

ÿ Pergunta 22 Tente reescrever print_int sem chamar print_uint, copiar seu código ou usar jmp. Você precisará apenas de uma
instrução e de um posicionamento cuidadoso do código.

Leia sobre co-rotinas.

2.8 Resumo
Neste capítulo começamos a fazer coisas reais e a aplicar nosso conhecimento básico sobre linguagem assembly.
Esperamos que você tenha superado qualquer possível medo de reunião. Apesar de ser prolixo ao extremo, não é uma
linguagem difícil de usar. Aprendemos a fazer ramificações e ciclos e a realizar aritmética básica e chamadas de sistema;
também vimos diferentes modos de endereçamento, little e big endian. As seguintes tarefas de montagem usarão a pequena
biblioteca que construímos para facilitar a interação com o usuário.

ÿ Pergunta 23 Qual é a conexão entre rax, eax, ax, ah e al?

ÿ Pergunta 24 Como obtemos acesso às partes do r9?

ÿ Pergunta 25 Como você pode trabalhar com uma pilha de hardware? Descreva as instruções que você pode usar.

ÿ Pergunta 26 Quais destas instruções estão incorretas e por quê?

mov [rax], 0

cmp [rdx], bl
mov bh, bl
mov al, al

36
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

adicione
bpl, 9 adicione
[9], spl mov
r8d, r9d mov
r3b, al mov
r9w, r2d mov rcx, [rax + rbx +
rdx] mov r9, [r9 + 8*rax]
mov [r8+r7+10] , 6
movimentos [r8+r7+10], r6

ÿ Pergunta 27 Enumerar os registros salvos pelo chamado

ÿ Pergunta 28 Enumerar os registros salvos pelo chamador

ÿ Pergunta 29 Qual é o significado do registro rip ?

ÿ Pergunta 30 O que é a bandeira SF?

ÿ Pergunta 31 O que é a bandeira ZF?

ÿ Pergunta 32 Descreva os efeitos das seguintes instruções:

• sar

• sh

• xor

• jmp

• ja, jb e similares.

• cmp
• movimento

• inc, dez
• adicionar

• imul, mul
• sub

• idiv, div

• ligar, retornar

• empurrar, estourar

37
Machine Translated by Google

Capítulo 2 ÿ Linguagem Assembly

ÿ Pergunta 33 O que é uma etiqueta e ela tem um tamanho?

ÿ Pergunta 34 Como você verifica se um número inteiro está contido em um determinado intervalo (x, y)?

ÿ Pergunta 35 Qual é a diferença entre ja/jb e jg/jl?

ÿ Pergunta 36 Qual é a diferença entre je e jz?

ÿ Pergunta 37 Como testar se rax é zero sem o comando cmp ?

ÿ Pergunta 38 Qual é o código de retorno do programa?

ÿ Pergunta 39 Como multiplicamos rax por 9 usando exatamente uma instrução?

ÿ Questão 40 Usando exatamente duas instruções (a primeira é neg), obtenha um valor absoluto de um inteiro
armazenado em rax.

ÿ Pergunta 41 Qual é a diferença entre little e big endian?

ÿ Pergunta 42 Qual é o tipo de endereçamento mais complexo?

ÿ Pergunta 43 Onde começa a execução do programa?

ÿ Pergunta 44 rax = 0x112233445567788. Realizamos push rax. Qual será o conteúdo do byte no endereço [rsp+3]?

38
Machine Translated by Google

CAPÍTULO 3

Legado

Este capítulo apresentará os modos de processador legados, que não são mais usados, e seus principais
recursos legados, que ainda são relevantes hoje. Você verá como os processadores evoluíram e aprenderá os detalhes da implementação
dos anéis de proteção (modo privilegiado e usuário). Você também entenderá o significado da Tabela de Descritores Globais. Embora essas
informações ajudem a entender melhor a arquitetura, elas não são cruciais para a programação assembly no espaço do usuário.

À medida que os processadores evoluíram, cada novo modo aumentou o comprimento da palavra da máquina e adicionou novos recursos.
Um processador pode funcionar em um dos seguintes modos:

• Modo real (o mais antigo, 16 bits);

• Protegido (comumente referido como 32 bits);

• Virtual (para emular modo real dentro de ambiente protegido);

• Modo de gerenciamento do sistema (para modo sleep, gerenciamento de energia, etc.);

• Modo longo, com o qual já estamos um pouco familiarizados.

Vamos dar uma olhada mais de perto no modo real e protegido.

3.1 Modo real


O modo real é o mais antigo. Falta memória virtual; a memória física é endereçada diretamente e os registradores de uso geral têm 16
bits de largura.
Portanto, nem rax nem eax existem ainda, mas ax, al e ah existem.
Esses registradores podem conter valores de 0 a 65535, portanto, a quantidade de bytes que podemos endereçar usando um deles
é 65536 bytes. Essa região de memória é chamada de segmento. Não confunda com segmentos de modo protegido ou seções de arquivo ELF
(Executable and Linkable Format)!
Estes são os registros utilizáveis em modo real:

• ip, sinalizadores;

• ax, bx, cx, dx, sp, bp, si, di;

• Registradores de segmento: cs, ds, ss, es, (mais tarde também gs e fs).

Como não era fácil endereçar mais de 64 kilobytes de memória, os engenheiros criaram uma solução para usar registradores de
segmentos especiais da seguinte maneira:

• Cada endereço físico consiste em 20 bits (ou seja, 5 dígitos hexadecimais).

© Igor Zhirkov 2017 39


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_3
Machine Translated by Google

Capítulo 3 ÿ Legado

• Cada endereço lógico consiste em dois componentes. Um é retirado de um registrador de segmento e codifica o início
do segmento. O outro é um deslocamento dentro deste segmento. O hardware calcula o endereço físico desses
componentes da seguinte maneira:

endereço físico = base do segmento * 16 + deslocamento

Muitas vezes você pode ver endereços escritos na forma de segmento:offset, por exemplo: 4a40:0002,
ds:0001, 7bd3:ah.

Como já afirmamos, os programadores desejam separar o código dos dados (e da pilha), então pretendem usar
segmentos diferentes para essas seções de código. Os registradores de segmento são especializados para isso: cs armazena o endereço
inicial do segmento de código, ds corresponde ao segmento de dados e ss ao segmento de pilha. Outros registradores de segmento são usados
para armazenar segmentos de dados adicionais.
Observe que, estritamente falando, os registradores de segmento não contêm os endereços iniciais dos segmentos, mas sim suas partes
(os quatro dígitos hexadecimais mais significativos). Adicionando outro dígito zero para multiplicá-lo por 1610
obtemos o endereço inicial do segmento real.
Cada instrução que faz referência à memória assume implicitamente o uso de um dos registradores de segmento.
A documentação esclarece os registradores de segmento padrão para cada instrução. No entanto, o bom senso também pode ajudar. Por
exemplo, mov é usado para manipular dados, portanto o endereço é relativo ao segmento de dados.

movimento, [0004]; === mov al, ds:0004

É possível redefinir o segmento explicitamente:

mov al, cs:[0004]

Quando o programa é carregado, o carregador configura os registros ip, cs, ss e sp para que cs:ip corresponda ao
ponto de entrada e ss:sp aponta no topo da pilha.
A unidade central de processamento (CPU) sempre inicia no modo real e, em seguida, o carregador principal geralmente executa o código
para alterná-lo explicitamente para o modo protegido e depois para o modo longo.
O modo real tem inúmeras desvantagens.

• Torna a multitarefa muito difícil. O mesmo espaço de endereço é compartilhado entre todos
programas, portanto eles devem ser carregados em endereços diferentes. Seu posicionamento relativo geralmente
deve ser decidido durante a compilação.

• Os programas podem reescrever o código uns dos outros ou até mesmo o sistema operacional, pois todos residem no
mesmo espaço de endereço.

• Qualquer programa pode executar qualquer instrução, incluindo aquelas usadas para configurar o
estado do processador. Algumas instruções devem ser utilizadas apenas pelo sistema operacional (como
aquelas utilizadas para configurar a memória virtual, realizar o gerenciamento de energia, etc.), pois seu uso
incorreto pode travar todo o sistema.

O modo protegido pretendia resolver esses problemas.

3.2 Modo Protegido


Intel 80386 foi o primeiro processador a implementar o modo protegido de 32 bits.
Fornece versões mais amplas de registros (eax, ebx, ..., esi, edi), bem como novos mecanismos de proteção:
anéis de proteção, memória virtual e segmentação aprimorada.
Esses mecanismos isolavam os programas uns dos outros, de modo que o encerramento anormal de um deles
não prejudicar os outros. Além disso, os programas não conseguiram corromper a memória de outros processos.

40
Machine Translated by Google

Capítulo 3 ÿ Legado

A forma de obter o endereço inicial de um segmento mudou em relação ao modo real. Agora o começo é
calculado com base em uma entrada em uma tabela especial, e não por multiplicação direta do conteúdo do registrador de segmento.

Endereço linear = base do segmento (retirado da tabela do sistema) + deslocamento

Cada um dos registradores de segmento cs, ds, ss, es, gs e fs armazena o chamado seletor de segmento, contendo um índice em uma
tabela especial de descritores de segmento e algumas informações adicionais. Existem dois tipos de tabelas de descritores de segmento: possivelmente
numerosas LDT (Tabela de Descritores Locais) e apenas uma GDT (Tabela de Descritores Globais).

Os LDTs foram planejados para um mecanismo de troca de tarefas de hardware; entretanto, os fabricantes de sistemas
operacionais não o adaptaram. Hoje os programas são isolados pela memória virtual e LDTs não são usados.
GDTR é um registro para armazenar endereço e tamanho do GDT.
Os seletores de segmento são estruturados conforme mostrado na Figura 3-1.

Figura 3-1. Seletor de segmento (conteúdo de qualquer registro de segmento)

O índice indica a posição do descritor em GDT ou LDT. O bit T seleciona LDT ou GDT. Como LDTs
não são mais usados, será zero em todos os casos.
As entradas da tabela GDT/LDT também armazenam informações sobre qual nível de privilégio é atribuído ao segmento descrito. Quando
um segmento é acessado através do seletor de segmento, é realizada uma verificação do valor do Nível de Privilégio de Solicitação (RPL)
(armazenado no seletor = registrador de segmento) em relação ao Nível de Privilégio do Descritor (armazenado na tabela do descritor). Se o
RPL não tiver privilégios suficientes para acessar um segmento com alto privilégio, ocorrerá um erro. Dessa forma, poderíamos criar vários segmentos
com diversas permissões e usar valores RPL em seletores de segmento para definir quais deles estão acessíveis para nós no momento (dado nosso
nível de privilégio).
Os níveis de privilégio são iguais aos anéis de proteção!
É seguro dizer que o nível de privilégio atual (por exemplo, anel atual) é armazenado nos dois bits mais baixos de cs ou ss
(esses números devem ser iguais). Isto é o que afeta a capacidade de executar certas instruções críticas (por exemplo, alterar o próprio GDT).

É fácil deduzir que, para ds, alterar esses bits nos permite substituir o nível de privilégio atual para ser menos privilegiado, especificamente para
acesso a dados de um segmento selecionado.
Por exemplo, estamos atualmente em ring0 e ds= 0x02. Mesmo que os dois bits mais baixos de cs e ss sejam 0
(como estamos dentro do ring0), não podemos acessar dados em um segmento com nível de privilégio superior a 2 (como 1 ou 0).
Em outras palavras, o campo RPL armazena o quão privilegiados somos ao solicitar acesso a um segmento.
Os segmentos, por sua vez, são atribuídos a um dos quatro anéis de proteção. Ao solicitar acesso com um determinado nível de privilégio, o
nível de privilégio deverá ser superior ao nível de privilégio atribuído ao próprio segmento.

ÿ Nota Você não pode alterar cs diretamente.

A Figura 3-2 mostra o formato do descritor GDT1 .

1
Neste livro estamos aproximando um pouco as coisas porque certas estruturas de dados podem ter um formato diferente com base no tamanho da
página, etc. A documentação lhe dará respostas mais precisas (leia o volume 3, capítulo 3 de [15]

41
Machine Translated by Google

Capítulo 3 ÿ Legado

Figura 3-2. Descritor de segmento (dentro de GDT ou LDT)

G — Granularidade, por exemplo, o tamanho está em 0 = bytes, 1 = páginas com tamanho de 4.096 bytes cada.
D — Tamanho do operando padrão (0 = 16 bits, 1 = 32 bits).
L — É um segmento de modo de 64 bits?
V—Disponível para uso pelo software do sistema.
P—Presente na memória agora.
S — São dados/código (1) ou apenas algum detentor de informações do sistema (0).
X—Dados (0) ou código (1).
RW—Para segmento de dados, a gravação é permitida? (a leitura é sempre permitida); para segmento de código, a leitura é
permitida? (escrever é sempre proibido).
DC – Direção de crescimento: para endereços mais baixos ou mais altos? (para segmento de dados); pode ser executado em
níveis de privilégio mais altos? (se segmento de código)
A – Foi acessado?
DPL – Nível de Privilégio do Descritor (a qual anel ele está anexado?)

O processador sempre (ainda hoje) inicia em modo real. Para entrar no modo protegido é necessário criar o GDT
e configure o gdtr; defina um bit especial em cr0 e faça o chamado salto distante. Salto distante significa que o segmento (ou
seletor de segmento) é fornecido explicitamente (e, portanto, pode ser diferente do padrão), como segue:

jmp 0x08:endereço

A Listagem 3-1 mostra um pequeno trecho de como podemos ativar o modo protegido (assumindo que start32 é um rótulo no
código start de 32 bits).

Listagem 3-1. Habilitando o modo protegido loader_start32.asm

lgdtcs:[_gdtr]

mov eax, cr0 ou ; !! Instrução privilegiada


al, 1 mov ; este é o bit responsável pelo modo protegido
cr0, eax ; !! Instrução privilegiada

jmp (0x1 << 3):start32 ; atribuir o primeiro seletor de segmento ao cs

alinhar 16
_gdtr: ; armazena o último índice de entrada do GDT + endereço GDT
dw 47
dq_gdt

alinhar 16

_gdt:
; Descritor nulo (deve estar presente em qualquer GDT)
dd 0x00, 0x00

42
Machine Translated by Google

Capítulo 3 ÿ Legado

; Descritor de código x32:


banco de dados 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x9A, 0xCF, ; 0x00; diferem por bit executivo
descritor de dados x32:
banco de dados 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x92, 0xCF, ; 0x00; execução desativada (0x92)
tamanho tamanho base base base util util|tamanho base

As diretivas Align controlam o alinhamento, cuja essência explicaremos mais adiante neste livro.

ÿ Pergunta 45 Decifre este seletor de segmento: 0x08.

Você pode pensar que toda transação de memória precisa de outra agora para ler o conteúdo do GDT. Isto não é verdade: para cada
registro de segmento existe um chamado registro sombra, que não pode ser referenciado diretamente.
Serve como cache para conteúdo GDT. Isso significa que uma vez alterado um seletor de segmento, o registrador de sombra correspondente
é carregado com o descritor correspondente do GDT. Agora esse cadastro servirá como fonte de todas as informações necessárias
sobre esse segmento.
A flag D precisa de uma pequena explicação, pois depende do tipo de segmento.

• É um segmento de código: endereço padrão e tamanhos de operandos. Um significa endereços de 32 bits e


operandos de 32 ou 8 bits; zero corresponde a endereços de 16 bits e operandos de 16 ou 8 bits. Estamos
falando aqui sobre codificação de instruções de máquina. Este comportamento pode ser alterado precedendo
uma instrução por um prefixo 0x66 (para alterar o tamanho do operando) ou 0x67 (para alterar o tamanho do
endereço).

• Segmento de pilha (é um segmento de dados E estamos falando de um selecionado por ss).2 É novamente o
tamanho do operando padrão para call, ret, push/pop, etc. Se o sinalizador estiver definido, os operandos terão
largura de 32 bits e as instruções afetam esp; caso contrário, os operandos terão largura de 16 bits e sp será
afetado.

• Para segmentos de dados que crescem em direção a endereços baixos, indica seus limites (0 para 64 KB, 1
para 4 GB). Este bit deve sempre ser definido no modo longo.

Como você pode ver, a segmentação é bastante complicada. Há razões pelas quais ele não foi amplamente adotado tanto pelos sistemas
operacionais quanto pelos programadores (e agora está praticamente abandonado).

• Nenhuma segmentação é mais fácil para os programadores;

• Nenhuma linguagem de programação comumente usada inclui segmentação em sua memória


modelo. É sempre uma memória plana. Portanto, é tarefa do compilador configurar segmentos (o que é difícil
de implementar).

• Os segmentos tornam a fragmentação da memória um desastre.

• Uma tabela de descritores pode conter até 8.192 descritores de segmento. Como podemos usar essa pequena
quantidade de forma eficiente?

Após a introdução do modo longo, a segmentação foi eliminada do processador, mas não completamente. Ainda é usado para anéis de
proteção e, portanto, um programador deve entendê-lo.

2Nesse caso, a documentação nomeia esse sinalizador como B.

43
Machine Translated by Google

Capítulo 3 ÿ Legado

3.3 Segmentação Mínima no Modo Longo


Mesmo no modo longo, cada vez que uma instrução é selecionada, o processador usa segmentação. Ele nos fornece um endereço virtual linear
plano, que é então transformado em endereço físico por rotinas de memória virtual (ver seção 4.2).

O LDT faz parte de um mecanismo de troca de contexto de hardware que ninguém realmente adotou; por esse motivo, ele foi
completamente desativado no modo longo.
Todo endereçamento de memória através dos registradores do segmento principal (cs, ds, es e ss) não considera mais os
valores GDT de base e offset. A base do segmento é sempre fixada em 0x0 independentemente do conteúdo do descritor; os tamanhos
dos segmentos não são limitados de forma alguma. Os outros campos descritores, entretanto, não são ignorados.
Isso significa que no modo longo pelo menos três descritores devem estar presentes no GDT: o descritor nulo (deve
estar sempre presente em qualquer GDT), código e segmentos de dados. Se quiser usar anéis de proteção para implementar
modos privilegiados e de usuário, você também precisará de código e descritores de dados para código em nível de usuário.

ÿ Por que precisamos de descritores separados para código e dados? Nenhuma combinação de sinalizadores de descritor

permite que um programador configure permissões de leitura/gravação e permissão de execução simultaneamente.

Mesmo com a pouca experiência em linguagem assembly que já temos, não é difícil decifrar este fragmento do carregador,
mostrando um GDT exemplar. Foi retirado do Pure64, um carregador de sistema operacional de código aberto. Como é executado
antes do sistema operacional, ele não contém código de nível de usuário ou descritores de dados (veja Listagem 3-2).

Listagem 3-2. Um exemplo de GDT gdt64.asm

alinhar 16; Isso garante que o próximo comando ou elemento de dados seja
; armazenado começando em um endereço divisível por 16 (mesmo se precisarmos
; pular alguns bytes para conseguir isso).

; O seguinte será copiado para GDTR via instrução LGDTR:

GDTR64: ; Cadastro de Tabela de Descritores Globais


dw gdt64_end - gdt64 - 1 ; limite de GDT (tamanho menos um)
dq0x0000000000001000; endereço linear do GDT

; Esta estrutura é copiada para 0x0000000000001000


gdt64:
SYS64_NULL_SEL igual $-gdt64 dq ; Segmento Nulo
0x000000000000000
; Segmento de código, leitura/execução, não conforme
SYS64_CODE_SEL igual a $-gdt64
dq0x0020980000000000 ; 0x00209A0000000000
; Segmento de dados, leitura/gravação, expansão para baixo
SYS64_DATA_SEL igual a $-gdt64
dq 0x0000900000000000 ; 0x0020920000000000
gdt64_end:

; O cifrão indica o endereço de memória atual, então


; $-gdt64 significa um deslocamento do rótulo `gdt64` em bytes

44
Machine Translated by Google

Capítulo 3 ÿ Legado

3.4 Acessando Partes dos Registros


3.4.1 Um comportamento inesperado
Geralmente pensamos em eax, rax, ax, etc. como partes de um mesmo registro físico. O comportamento observável apoia principalmente esta
hipótese, a menos que estejamos escrevendo em uma parte de 32 bits de um registrador de 64 bits. Vamos dar uma olhada no exemplo
mostrado na Listagem 3-3.

Listagem 3-3. As maravilhas da terra dos registros risc_cisc.asm

mov rax, 0x1122334455667788 mov eax, ; rax = 0x1122334455667788


0x42 ; !rax = 0x00 00 00 00 00 00 00 42
; por que não rax = 0x1122334400000042 ??

mov rax, 0x1122334455667788 mov ; rax = 0x1122334455667788


machado, 0x9999 ; rax = 0x1111222233339999, conforme esperado
; isso funciona como esperado

mov rax, 0x1122334455667788 ; rax = 0x1122334455667788


xor eax, eax ; rax = 0x0000000000000000
; por que não rax = 0x1122334400000000?

Como você pode ver, escrever em partes de 8 ou 16 bits deixa o restante dos bits intacto. Escrevendo em partes de 32 bits, no entanto,
preenche a metade superior de um registro largo com bit de sinal!
A razão é que a forma como os programadores estão acostumados a perceber um processador é muito diferente de como as coisas
são realmente feitas dentro dele. Na realidade, os registos rax, eax e todos os outros não existem como entidades físicas fixas.
Para explicar esta inconsistência, temos que primeiro elaborar dois tipos de conjuntos de instruções: CISC e RISC.

3.4.2 CISC e RISC


Uma das possíveis classificações de processadores divide os processadores com base em seu conjunto de instruções. Ao projetar um, existem
dois extremos.

• Faça muitas instruções especializadas e de alto nível. Corresponde às arquiteturas CISC (Complete Instruction
Set Computer) .

• Utilizar apenas algumas instruções primitivas, formando uma arquitetura RISC (Reduced Instruction Set
Computer) .

As instruções CISC são geralmente mais lentas, mas também fazem mais; às vezes é possível implementar instruções complexas de
uma maneira melhor do que combinando instruções RISC primitivas (veremos um exemplo disso mais adiante neste livro, quando estudarmos
SSE (Streaming SIMD Extensions) no Capítulo 16) . Entretanto, a maioria dos programas são escritos em linguagens de alto nível e, portanto,
dependem de compiladores. É muito difícil escrever um compilador que faça bom uso de um rico conjunto de instruções.

O RISC facilita o trabalho dos compiladores e também é mais amigável para otimizações em um nível inferior de microcódigo, como
pipelines.

ÿ Pergunta 46 Leia sobre microcódigo em geral e pipelines de processador.

45
Machine Translated by Google

Capítulo 3 ÿ Legado

O conjunto de instruções Intel 64 é de fato CISC. Contém milhares de instruções – basta olhar para o segundo
volume de [15]! Entretanto, essas instruções são decodificadas e traduzidas em um fluxo de instruções de
microcódigo mais simples. Aqui, várias otimizações entram em vigor; as instruções do microcódigo são reordenadas e
algumas delas podem até ser executadas simultaneamente. Este não é um recurso nativo dos processadores, mas sim
uma adaptação que visa melhor desempenho juntamente com compatibilidade retroativa com softwares mais antigos.
É lamentável que não haja muita informação disponível sobre os detalhes em nível de microcódigo dos
processadores modernos. Lendo análises técnicas como [17] e manuais de otimização como o fornecido pela Intel,
você pode desenvolver uma certa intuição sobre isso.

3.4.3 Explicação
Agora voltemos ao exemplo mostrado na Listagem 3-3. Vamos pensar na decodificação de instruções. A parte de
uma CPU chamada decodificador de instruções traduz constantemente comandos de um sistema CISC mais
antigo para um RISC mais conveniente. Pipelines permitem a execução simultânea de até seis instruções menores.
Para conseguir isso, no entanto, a noção de registos deve ser virtualizada. Durante a execução do microcódigo,
o decodificador escolhe um registro disponível em um grande banco de registros físicos. Assim que a instrução
maior termina, os efeitos tornam-se visíveis para o programador: o valor de alguns registradores físicos pode ser
copiado para aqueles atualmente atribuídos como, digamos, rax.
As interdependências de dados entre as instruções paralisam o pipeline, diminuindo o desempenho. O pior
casos ocorrem quando o mesmo registrador é lido e modificado por diversas instruções consecutivas (pense em rflags!).

Se modificar eax significa manter intactos os bits superiores de rax, isso introduz uma dependência adicional
entre a instrução atual e qualquer instrução que tenha modificado rax ou suas partes antes. Ao descartar os 32 bits
superiores em cada gravação em eax eliminamos essa dependência, pois não nos importamos mais com o valor rax
anterior ou suas partes.
Este tipo de novo comportamento foi introduzido com o crescimento dos registros de uso geral mais recentes para
64 bits e não afeta as operações com suas partes menores por uma questão de compatibilidade. Caso contrário, a
maioria dos binários mais antigos teria parado de funcionar porque atribuir a, por exemplo, bl, teria modificado todo o ebx,
o que não era verdade quando os registradores de 64 bits ainda não haviam sido introduzidos.

3.5 Resumo
Este capítulo foi uma breve nota histórica sobre a evolução dos processadores nos últimos 30 anos. Também elaboramos
sobre o uso pretendido de segmentos na era de 32 bits, bem como quais sobras de segmentação estamos presos
por motivos de legado. No próximo capítulo examinaremos mais de perto o mecanismo de memória virtual e sua
interação com anéis de proteção.

46
Machine Translated by Google

CAPÍTULO 4

Memória virtual

Este capítulo aborda a memória virtual implementada no Intel 64. Começaremos motivando uma abstração sobre a memória física e, em
seguida, obtendo uma compreensão geral de sua aparência do ponto de vista de um programador. Por fim, nos aprofundaremos nos detalhes
da implementação para obter um entendimento mais completo.

4.1 Cache
Vamos começar com um conceito verdadeiramente onipresente chamado cache.
A Internet é um grande armazenamento de dados. Você pode acessar qualquer parte dele, mas o atraso após fazer uma consulta pode
ser significativo. Para facilitar sua experiência de navegação, o navegador armazena em cache as páginas da web e seus elementos (imagens,
folhas de estilo, etc.). Dessa forma, não é necessário baixar os mesmos dados repetidamente. Em outras palavras, o navegador salva os dados
no disco rígido ou na RAM (memória de acesso aleatório) para dar acesso muito mais rápido a uma cópia local. Porém, baixar toda a Internet não é
uma opção, pois o armazenamento no seu computador é muito limitado.

Um disco rígido é muito maior que a RAM, mas também muito mais lento. É por isso que todo trabalho com dados é feito
depois de pré-carregá-lo na RAM. Assim, a memória principal está sendo usada como cache para dados de armazenamento externo.
De qualquer forma, um disco rígido também possui um cache próprio...
No cristal da CPU existem vários níveis de cache de dados (geralmente três: L1, L2, L3). Seu tamanho é muito menor que o tamanho da
memória principal, mas também são muito mais rápidos (o nível mais próximo da CPU é quase tão próximo quanto os registros). Além disso, as CPUs
possuem pelo menos um cache de instruções (fila de instruções de armazenamento) e um buffer lookaside de tradução para melhorar o desempenho
da memória virtual.
Os registros são ainda mais rápidos que os caches (e menores), portanto, são um cache por si só.
Por que esta situação é tão generalizada? Num sistema de informação, que não necessita de dar garantias estritas sobre os seus níveis de
desempenho, a introdução de caches diminui frequentemente o tempo médio de acesso (o tempo entre um pedido e uma resposta). Para fazê-lo
funcionar, precisamos da nossa velha amiga localidade: em cada momento, temos apenas um pequeno conjunto funcional de dados.

O mecanismo de memória virtual nos permite, entre outras coisas, usar a memória física como cache para
pedaços de código e dados do programa.

4.2 Motivação
Naturalmente, dado um sistema de tarefa única onde há apenas um programa em execução a qualquer momento, é aconselhável colocá-lo
diretamente na memória física, começando em algum endereço fixo. Outros componentes (drivers de dispositivos, bibliotecas) também podem ser
colocados na memória em alguma ordem fixa.

© Igor Zhirkov 2017 47


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_4
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Em um sistema amigável para multitarefa, entretanto, preferimos uma estrutura que suporte uma execução paralela (ou
pseudoparalela) de múltiplos programas. Neste caso, um sistema operacional precisa de algum tipo de gerenciamento de memória
para lidar com estes desafios:

•Execução de programas de tamanho arbitrário (talvez até maior que a memória física
tamanho). Exige a capacidade de carregar apenas as partes do programa de que necessitamos no futuro
próximo.

•Ter vários programas na memória ao mesmo tempo.

Os programas podem interagir com dispositivos externos, cujo tempo de resposta costuma ser lento.
Durante uma solicitação a um hardware lento que pode durar milhares de ciclos, queremos emprestar
CPUs preciosas para outros programas. A alternância rápida entre programas só é possível se eles já
estiverem na memória; caso contrário, teremos que gastar muito tempo recuperando-os do
armazenamento externo.

•Armazenamento de programas em qualquer local da memória física.

Se conseguirmos isso, podemos carregar pedaços de programas em qualquer parte livre da memória,
mesmo que estejam usando endereçamento absoluto.

No caso de endereçamento absoluto, como mov rax, [0x1010ffba], todos os endereços,


incluindo o endereço inicial, tornam-se fixos: todos os valores exatos de endereço são escritos em
código de máquina.

•Liberando ao máximo os programadores das tarefas de gerenciamento de memória.

Durante a programação, não queremos pensar em como os diferentes chips de memória em nossas
arquiteturas alvo podem funcionar, qual é a quantidade de memória física disponível, etc. Em vez disso,
os programadores devem prestar mais atenção à lógica do programa.

•Uso efetivo de dados e códigos compartilhados.

Sempre que vários programas desejam acessar os mesmos arquivos de dados ou códigos (bibliotecas), é
um desperdício duplicá-los na memória para cada usuário adicional.

O uso da memória virtual aborda esses desafios.

4.3 Espaços de Endereço


O espaço de endereço é um intervalo de endereços. Vemos dois tipos de espaços de endereço:

•Endereço físico, que é utilizado para acessar os bytes no hardware real. Naturalmente, existe uma certa
capacidade de memória que um processador não pode exceder. É baseado em capacidades de
endereçamento. Por exemplo, um sistema de 32 bits não pode endereçar mais de 4 GB de memória por
processo, porque 232 endereços diferentes correspondem aproximadamente a 4 GB de memória endereçada.
Porém, poderíamos colocar menos memória dentro da máquina capaz de endereçar 4GB, tipo 1GB ou 2GB.
Neste caso alguns endereços do espaço de endereçamento físico serão proibidos, pois não existem
células de memória reais atrás deles.

48
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

•Endereço lógico é o endereço como um aplicativo o vê.

Na instrução mov rax, [0x10bfd] existe um endereço lógico: 0x10bfd.

Um programador tem a ilusão de que é o único usuário da memória. Seja qual for a célula de memória a
que ele se dirige, ele nunca vê dados ou instruções de outros programas, que estão rodando em paralelo
com o seu. Entretanto, a memória física contém vários programas ao mesmo tempo.

Nas nossas circunstâncias, endereço virtual é sinônimo de endereço lógico.

A tradução entre esses dois tipos de endereço é realizada por uma entidade de hardware chamada Memória
Unidade de Gerenciamento (MMU) com ajuda de múltiplas tabelas de tradução, residentes na memória.

4.4 Recursos
A memória virtual é uma abstração da memória física. Sem ele trabalharíamos diretamente com endereços de memória física.

Na presença de memória virtual podemos fingir que todo programa é o único consumidor de memória, pois está isolado dos
demais em seu próprio espaço de endereço.
O espaço de endereço de um único processo é dividido em páginas de igual comprimento (geralmente 4 KB). Essas páginas são
então gerenciadas dinamicamente. Alguns deles podem ser copiados para armazenamento externo (em um arquivo de troca) e recuperados
em caso de necessidade.
A memória virtual oferece alguns recursos úteis, atribuindo um significado incomum às operações de memória (leitura, gravação)
em determinadas páginas da memória.

•Podemos nos comunicar com dispositivos externos por meio de Entrada Mapeada de Memória/
Saída (por exemplo, escrevendo nos endereços atribuídos a algum dispositivo e lendo-os).

•Algumas páginas podem corresponder a arquivos retirados de armazenamento externo com a ajuda do
sistema operacional e sistema de arquivos.

•Algumas páginas podem ser compartilhadas entre vários processos.

•A maioria dos endereços são proibidos – seu valor não está definido e uma tentativa de acessá-los resulta em
erro.1 Essa situação geralmente resulta no encerramento anormal do programa.

Linux e outros sistemas baseados em Unix usam um mecanismo de sinal para notificar
aplicativos sobre situações excepcionais. É possível atribuir um manipulador a quase todos os tipos de
sinais.

O acesso a um endereço proibido será interceptado pelo sistema operacional, que lançará um sinal
SIGSEGV na aplicação. É bastante comum ver uma mensagem de erro, Falha de segmentação, nesta
situação.

•Algumas páginas correspondem a arquivos retirados do armazenamento (arquivo executável propriamente


dito, bibliotecas, etc.), mas outras não. Essas páginas anônimas correspondem a regiões de memória de
pilha e heap – memória alocada dinamicamente. Eles são chamados assim porque não há nomes no
sistema de arquivos aos quais correspondam. Pelo contrário, uma imagem dos arquivos e dispositivos de dados
executáveis em execução (que também são abstraídos como arquivos) tem nomes no sistema de arquivos.

1 Ocorre uma interrupção #PF (Falha de Página).

49
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Uma área contínua de memória é chamada de região se:

•Começa em um endereço que é múltiplo do tamanho da página (por exemplo, 4KB).

•Todas as suas páginas têm as mesmas permissões.

Caso a memória física livre acabe, algumas páginas podem ser trocadas para um armazenamento externo e armazenadas em um arquivo
de troca, ou simplesmente descartadas (caso correspondam a alguns arquivos do sistema de arquivos e não tenham sido alteradas, por exemplo).
No Windows, o arquivo é chamado PageFile.sys, em sistemas *nix uma partição dedicada geralmente é alocada no disco.
A escolha das páginas a serem trocadas é descrita por uma das estratégias de substituição, como:

• Menos usado recentemente.

•Última utilização recentemente.

•Aleatório (basta escolher uma página aleatória).

Qualquer tipo de sistema com cache possui uma estratégia de substituição.

ÿ Pergunta 47 Leia sobre diferentes estratégias de substituição. Que outras estratégias existem?

Cada processo possui um conjunto funcional de páginas. Consiste em suas páginas exclusivas presentes na memória física.

ÿ Alocação O que acontece quando um processo precisa de mais memória? Ele não consegue obter mais páginas sozinho,
então solicita mais páginas ao sistema operacional. O sistema fornece endereços adicionais.

A alocação dinâmica de memória em linguagens de nível superior (C++, Java, C#, etc.) eventualmente acaba consultando
páginas do sistema operacional, usando as páginas alocadas até que o processo fique sem memória e então consultando
mais páginas.

4.5 Exemplo: Acessando Endereço Proibido


Agora veremos com nossos próprios olhos um mapa de memória de um único processo. Mostra quais páginas estão disponíveis e a que
correspondem. Observaremos diferentes tipos de regiões de memória:

1. Correspondente ao arquivo executável, carregado na própria memória.

2. Correspondente a bibliotecas de código.

3. Correspondente a pilha e heap (páginas anônimas).

4. Apenas regiões vazias de endereços proibidos.

O Linux nos oferece um mecanismo fácil de usar para explorar diversas informações úteis sobre processos, chamado
procfs. Ele implementa um sistema de arquivos de propósito especial, onde ao navegar em diretórios e visualizar arquivos, pode-se obter
acesso à memória de qualquer processo, variáveis de ambiente, etc. Este sistema de arquivos é montado no /proc
diretório.
Mais notavelmente, o arquivo /proc/PID/maps mostra um mapa de memória do processo com identificador PID. 2

2
Para encontrar o identificador do processo, use programas padrão como ps ou top.

50
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Vamos escrever um programa simples, que entra em um loop (e portanto não termina) (Listagem 4-1). Ele vai
nos permite ver o layout da memória enquanto ele está em execução.

Listagem 4-1. mapeamentos_loop.asm

seção .data
correta: dq -1
seção .text

global _start
_start:
jmp _start

Agora temos que lançar um arquivo /proc/?/maps, onde ? é o ID do processo. Veja o conteúdo completo do terminal
na Listagem 4-2.

Listagem 4-2. mapeamentos_loop

> nasm -felf64 -o main.o mapeamentos_loop.asm > ld -o


main main.o > ./main & [1]
2186 > cat /
proc/2186/
maps 00400000-00401000 r-
xp 00000000 08:01 144225 /home /stud/main 00600000-00601000 rwxp 00000000
08:01 144225 /home/stud/main 7fff11ac0000-7fff11ae1000 rwxp 00000000 00:00 0
[pilha] 7fff11bfc000-7fff11 bfe000 r-xp 00000000 00:00 0 [vdso]
7fff11bfe000-7fff11c00000 r- -p 00000000 00:00 0 [vvar] ffffffffff600000-
ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall]

A coluna da esquerda define o intervalo da região de memória. Como você pode notar, todas as regiões estão contidas
entre endereços que terminam com três zeros hexadecimais. A razão é que são compostas por páginas cujo tamanho é de 4 KB
cada (= 0x1000 bytes).
Observamos que diferentes seções definidas no arquivo assembly foram carregadas como regiões diferentes. O primeiro
região corresponde à seção de código e contém instruções codificadas; o segundo corresponde aos dados.
Como você pode ver, o espaço de endereço é enorme e abrange de 0 a 264 ÿ1-ésimo byte. Contudo, apenas
alguns endereços são atribuídos; o resto está sendo proibido.
A segunda coluna mostra as permissões de leitura, gravação e execução nas páginas. Também indica se a página é
compartilhada entre vários processos ou é privada deste processo específico.

ÿ Pergunta 48 Leia sobre o significado da quarta (08:01) e da quinta (144225) colunas em man procfs.

Até agora não fizemos nada de errado. Agora vamos tentar escrever em um local proibido.

Listagem 4-3. Produzindo segfault: segfault_badaddr.asm

seção .data
correta: dq -1
seção .text global
_start _start: mov
rax,
[0x400000-1]

51
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

; saída
mov rax, 60
xor rdi, rdi
chamada de sistema

Estamos acessando a memória no endereço 0x3fffff, que está um byte antes do início do segmento de código. Este endereço
é proibido e portanto a tentativa de escrita resulta em falha de segmentação, como sugere a mensagem.

> ./main Falha de segmentação

4.6 Eficiência
Carregar uma página ausente na memória física a partir de um arquivo de troca é uma operação muito cara, envolvendo uma
enorme quantidade de trabalho do sistema operacional. Como é que esse mecanismo acabou não apenas sendo eficaz em
termos de memória, mas também com desempenho adequado? Os principais fatores de sucesso são:

1. Graças à localidade, raramente ocorre a necessidade de carregar páginas adicionais. Na pior das
hipóteses, temos de facto um acesso muito lento; no entanto, esses casos são extremamente raros.
O tempo médio de acesso permanece baixo.

Em outras palavras, raramente tentamos acessar uma página que não esteja carregada na
memória física.

2. É evidente que a eficiência não poderia ser alcançada sem a ajuda de hardware especial.
Sem um cache de endereços de páginas traduzidos TLB (Translation Lookaside Buffer),
teríamos que usar um mecanismo de tradução o tempo todo.
O TLB armazena os endereços físicos iniciais de algumas páginas com as quais provavelmente
trabalharemos. Se traduzirmos um endereço virtual dentro de uma dessas páginas, o início da página
será imediatamente obtido do TLB.

Em outras palavras, raramente tentamos traduzir um endereço de uma página que não localizamos
recentemente na memória física.

Lembre-se de que um programa que utiliza menos memória pode ser mais rápido porque produz menos falhas de página.

ÿ Pergunta 49 O que é um cache associativo? Por que o TLB é um?

4.7 Implementação
Agora vamos mergulhar nos detalhes e ver como exatamente acontece a tradução.

ÿ Nota Por enquanto estamos falando apenas de um caso dominante de páginas de 4KB. O tamanho da página pode ser ajustado e

outros parâmetros serão alterados de acordo; consulte a seção 4.7.3 e [15] para obter detalhes adicionais.

52
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

4.7.1 Estrutura de Endereço Virtual


Cada endereço virtual de 64 bits (por exemplo, aqueles que usamos em nossos programas) consiste em vários campos, conforme mostrado
na Figura 4-1.

Figura 4-1. Estrutura do endereço virtual

O endereço em si tem, na verdade, apenas 48 bits de largura; ele é estendido com sinal para um endereço canônico de 64 bits .
Sua característica é que seus 17 bits esquerdos são iguais. Se a condição não for satisfeita, o endereço será rejeitado imediatamente
quando usado.
Em seguida, 48 bits de endereço virtual são transformados em 52 bits de endereço físico com a ajuda de tabelas especiais.3

ÿ Erro de barramento Ao usar ocasionalmente um endereço não canônico, você verá outra mensagem de erro:
Erro de ônibus.

O espaço de endereço físico é dividido em slots a serem preenchidos com páginas virtuais. Esses slots são chamados de quadros
de página. Não há lacunas entre eles, então eles sempre partem de um endereço que termina com 12 zero bits.
Os 12 bits menos significativos do endereço virtual e da página física correspondem ao deslocamento do endereço dentro da
página, portanto são iguais.
As outras quatro partes do endereço virtual representam índices em tabelas de tradução. Cada tabela ocupa exatamente 4 KB
para preencher uma página inteira de memória. Cada registro tem 64 bits de largura; ele armazena uma parte do endereço inicial da
próxima tabela, bem como alguns sinalizadores de serviço.

4.7.2 Tradução de endereços em profundidade


A Figura 4-2 reflete o processo de tradução de endereços.

3
Teoricamente poderíamos suportar todos os 64 bits de endereços físicos, mas ainda não precisamos de tantos endereços.

53
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Figura 4-2. Esquema de tradução de endereço virtual

Primeiro, pegamos o endereço inicial da primeira tabela em cr3. A tabela é chamada de Mapa de Página Nível 4 (PML4).
A busca de elementos do PML4 é realizada da seguinte forma:

•Bits 51:12 são fornecidos pelo cr3.

•Bits 11:3 são bits 47:39 do endereço virtual.

•Os últimos três bits são zeros.

As entradas do PML4 são referidas como PML4E. A próxima etapa de busca de uma entrada da tabela Page Directory Pointer
imita a anterior:

•Bits 51:12 são fornecidos pelo PML4E selecionado.

•Bits 11:3 são bits 38:30 do endereço virtual.

•Os últimos três bits são zeros.

54
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

O processo percorre mais duas tabelas até que finalmente buscamos o endereço do quadro da página (para ser mais preciso,
seus 51:12 bits). O endereço físico irá utilizá-los e 12 bits serão retirados diretamente do endereço virtual.
Vamos realizar tantas leituras de memória em vez de uma agora? Sim, parece volumoso. Porém, graças ao cache de endereço de
página, TLB, normalmente acessamos a memória em páginas já traduzidas e memorizadas. Devemos apenas adicionar o deslocamento
correto dentro da página, o que é extremamente rápido.
Como o TLB é um cache associativo; ele está nos fornecendo rapidamente endereços de páginas traduzidos, com base em
endereço virtual da página.
Observe que as páginas de tradução podem ser armazenadas em cache para um acesso mais rápido. A Figura 4-3 especifica o formato de entrada da tabela
de páginas.

Figura 4-3. Entrada da tabela de páginas

P Presente (na memória física)


W gravável (escrita é permitida)
Usuário U (pode ser acessado a partir do ring3)
Um acessado

D Dirty (a página foi modificada após ser carregada – por exemplo, do disco)
EXB Execution-Disabled Bit (proíbe a execução de instruções nesta página)
AVL disponível (para desenvolvedores de sistemas operacionais)
Desativar cache de página PCD
PWT Page Write-Through (ignorar cache ao gravar na página)

Se P não estiver definido, uma tentativa de acesso à página resulta em uma interrupção com o código #PF (Falha de página). O
sistema operacional pode lidar com isso e carregar a respectiva página. Também pode ser usado para implementar mapeamento lento
de memória de arquivo. As partes do arquivo serão carregadas na memória conforme necessário.
O sistema operacional usa o bit W para proteger a página contra modificações. É necessário quando queremos compartilhar código ou
dados entre processos, evitando duplicações desnecessárias. As páginas compartilhadas marcadas com W podem ser usadas para troca de
dados entre processos.
As páginas do sistema operacional têm o bit U apagado. Se tentarmos acessá-los a partir do ring3, ocorrerá uma interrupção.
Na ausência de proteção de segmento, a memória virtual é o mecanismo definitivo de proteção de memória.

ÿ Sobre falhas de segmentação Em geral, as falhas de segmentação ocorrem quando há uma tentativa de acessar
a memória com permissões insuficientes (por exemplo, escrever em memória somente leitura). No caso de endereços
proibidos podemos considerá-los sem permissões válidas, portanto acessá-los é apenas um caso particular de acesso à
memória com permissões insuficientes.

O bit EXB (também chamado de NX) proíbe a execução de código. A tecnologia DEP (Data Execution Prevention) é baseada nele.
Quando um programa está sendo executado, partes de sua entrada podem ser armazenadas em uma pilha ou em sua seção de dados. Um
usuário mal-intencionado pode explorar suas vulnerabilidades para misturar instruções codificadas na entrada e depois executá-las. No entanto,
se as páginas de dados e de seção de pilha estiverem marcadas com EXB, nenhuma instrução poderá ser executada a partir delas. O texto
seção, no entanto, permanecerá executável, mas geralmente é protegida de qualquer modificação pelo bit W de qualquer maneira.

55
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

4.7.3 Tamanhos de página

A estrutura das tabelas de um nível hierárquico diferente é muito semelhante. O tamanho da página pode ser ajustado para 4 KB, 2 MB ou 1 GB.
Dependendo da estrutura, esta hierarquia pode diminuir para um mínimo de dois níveis. Neste caso o PDP funcionará como uma tabela de páginas e
armazenará parte de um quadro de 1GB. Consulte a Figura 4-4 para ver como o formato de entrada muda dependendo do tamanho da página.

Figura 4-4. Tabela de ponteiro do diretório de páginas e formato de entrada da tabela do diretório de páginas

Isso é controlado pelo 7º bit na respectiva entrada PDP ou PD. Se estiver definido, a respectiva tabela mapeia as páginas; caso contrário,
armazena endereços das tabelas do próximo nível.

4.8 Mapeamento de Memória


Mapeamento significa “projeção”, fazendo correspondência entre entidades (arquivos, dispositivos, memória física) e regiões de memória virtual.
Quando o carregador preenche o espaço de endereço do processo, quando um processo solicita páginas do sistema operacional, quando o sistema
operacional projeta arquivos de um disco nos espaços de endereço dos processos – esses são exemplos de mapeamento de memória.

Uma chamada de sistema mmap é usada para todos os tipos de mapeamento de memória. Para realizá-lo seguimos os mesmos passos
simples descritos no Capítulo 2. A Tabela 4-1 mostra seus argumentos.

Tabela 4-1. Chamada do sistema mmap

SIGNIFICADO DO VALOR DE REGISTRO

rax 9 Identificador de chamada do sistema

RDI endereço Um sistema operacional tenta mapear páginas a partir deste endereço específico. Este endereço deve
corresponder ao início de uma página. Um endereço zero indica que o sistema operacional é livre para escolher
qualquer início.

rsi lento Tamanho da região

rdx lucro Sinalizadores de proteção (leitura, gravação, execução…)

r10 bandeiras Sinalizadores de utilitários (compartilhados ou privados, páginas anônimas, etc.)

r8 fd Descritor opcional de um arquivo mapeado. O arquivo deve, portanto, ser aberto.

r9 desvio Deslocamento no arquivo.

56
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Após uma chamada ao mmap, o rax manterá um ponteiro para as páginas recém-alocadas.

4.9 Exemplo: Mapeando Arquivo na Memória


Precisamos de outra chamada de sistema, ou seja, open. É utilizado para abrir um arquivo pelo nome e adquirir seu descritor.
Consulte a Tabela 4-2 para obter detalhes.

Tabela 4-2. abra a chamada do sistema

REGISTRO VALOR SIGNIFICADO

rax 2 Identificador de chamada do sistema

RDI nome do arquivo Ponteiro para uma string terminada em nulo, arquivo name.holding

rsi bandeiras Uma combinação de sinalizadores de permissão (somente leitura, somente gravação ou ambos).

rdx modo Se sys open for chamado para criar um arquivo, ele manterá as permissões do sistema de arquivos.

O mapeamento do arquivo na memória é feito em três etapas simples:

•Abrir arquivo usando chamada de sistema aberta. rax conterá o descritor de arquivo.

•Chame mmap com argumentos relevantes. Um deles será o descritor de arquivo, adquirido em
passo 1.

•Use a rotina print_string que criamos no Capítulo 2. Por uma questão de brevidade, omitimos o fechamento de arquivos
e a verificação de erros.

4.9.1 Nomes Mnemônicos para Constantes


O Linux foi escrito em C, portanto, para facilitar a interação com ele, algumas constantes úteis são predefinidas em C. A linha

#define NOME 42

define uma substituição executada em tempo de compilação. Sempre que um programador escreve NAME, o compilador o substitui por 42. Isso
é útil para criar nomes mnemônicos para várias constantes. NASM fornece funcionalidade semelhante usando

% definir diretiva
% definir NOME 42

Consulte a seção 5.1 “Pré-processador” para obter mais detalhes sobre como tais substituições são feitas.
Vamos dar uma olhada na página man da chamada de sistema mmap, descrevendo seu terceiro argumento prot.
O argumento prot descreve a proteção de memória desejada do mapeamento (e não deve entrar em conflito com o modo aberto do arquivo).
É PROT_NONE ou o OR bit a bit de um ou mais dos seguintes sinalizadores:

PROT_EXEC Páginas podem ser executadas.

PROT_READ As páginas podem ser lidas.

57
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

PROT_WRITE Páginas podem ser escritas.

PROT_NONE As páginas não podem ser acessadas.

PROT_NONE e seus amigos são exemplos de nomes mnemônicos para inteiros usados para controlar mmap
comportamento. Lembre-se de que tanto C quanto NASM permitem realizar cálculos em tempo de compilação em valores constantes,
incluindo operações AND e OR bit a bit. A seguir está um exemplo de tal cálculo:

%define PROT_EXEC 0x4


%define PROT_READ 0x1

mov rdx, PROT_READ | PROT_EXEC

A menos que você esteja escrevendo em C ou C++, você terá que verificar esses valores predefinidos em algum lugar e copiá-los
para o seu programa.
A seguir está como saber os valores específicos dessas constantes para Linux:

1. Pesquise-os nos arquivos de cabeçalho da API do Linux em /usr/include.

2. Use uma das referências cruzadas do Linux (lxr) online, como: http://lxr.free
Electrons.com.

Recomendamos a segunda forma por enquanto, pois ainda não conhecemos C. Você pode até usar um mecanismo de pesquisa
como o Google e digitar lxr PROT_READ como uma consulta de pesquisa para obter resultados relevantes imediatamente após seguir o
primeiro link.
Por exemplo, aqui está o que o LXR mostra ao ser consultado PROT_READ:

PROT_READ

Definido como uma macro de pré-processador em:


arch/mips/include/uapi/asm/mman.h, linha 18
arch/xtensa/include/uapi/asm/mman.h, linha 25
arch/alpha/include/uapi/asm/mman.h, linha 4
arch/parisc/include/uapi/asm/mman.h, linha 4
include/uapi/asm-generic/mman-common.h, linha 9

Seguindo um desses links você verá

18 #define PROT_READ 0x01 /* página pode ser lida */

Assim, podemos digitar %define PROT_READ 0x01 no início do arquivo assembly para utilizar esta constante sem memorizar
seu valor.

4.9.2 Exemplo Completo


Crie um arquivo test.txt com qualquer conteúdo e então compile e execute o arquivo listado na Listagem 4-4 no mesmo diretório.
Você verá o conteúdo do arquivo gravado em stdout.

58
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

Listagem 4-4. mmap.asm

; Essas macrodefinições são copiadas de fontes Linux; Linux é escrito em C,


então as definições pareciam um pouco; diferente aí.

; Poderíamos apenas ter pesquisado seus valores e uso; -los diretamente


nos lugares certos; No entanto, isso teria
tornado o código muito menos legível

%define O_RDONLY
0%define PROT_READ
0x1%define MAP_PRIVATE 0x2

seção .dados
; Este é o nome do arquivo. Você é livre para mudar isso. nomef: banco
de dados 'test.txt', 0

seção .texto
_início global

; Essas funções são usadas para imprimir uma string terminada em nulo print_string:
push rdi call
string_length
pop rsi mov rdx, rax mov
rax, 1
mov rdi, 1 syscall
ret

comprimento_string:
xor rax, rax .loop:
cmp
byte [rdi+rax], 0 je .end inc rax

jmp.loop.end:

ret

_começar: ;
chamar open
mov rax, 2 mov rdi,
fname mov rsi, ; Abrir arquivo somente leitura
O_RDONLY mov rdx, 0 ; Não estamos criando um arquivo;
então esse argumento não tem sentido
chamada de sistema

59
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

; mmap
mov r8, rax ; rax mantém o descritor de arquivo aberto
; é o quarto argumento do mmap
mov rax, 9 ; número do mapa
mov rdi, 0 mov ; o sistema operacional escolherá o destino do mapeamento
rsi, 4096 mov rdx, ; tamanho da página

PROT_READ mov r10, ; a nova região da memória será marcada como somente leitura
MAP_PRIVATE ; páginas não serão compartilhadas

mov r9, 0 ; deslocamento dentro de test.txt


chamada de sistema ; agora rax apontará para o local mapeado

mov rdi, rax


chamar print_string

mov rax, 60 xor ; use a chamada do sistema exit para desligar corretamente
rdi, rdi
chamada de sistema

4.10 Resumo
Neste capítulo estudamos o conceito e a implementação da memória virtual. Nós o elaboramos como um caso particular de cache.
Em seguida, revisamos os diferentes tipos de espaços de endereçamento (físicos, virtuais) e a conexão entre eles através de
um conjunto de tabelas de tradução. Em seguida, mergulhamos nos detalhes da implementação da memória virtual.

Finalmente, fornecemos um exemplo mínimo de trabalho do mapeamento de memória usando o sistema Linux
chamadas. Iremos usá-lo novamente na tarefa do Capítulo 13, onde basearemos nosso alocador de memória dinâmica
nele. No próximo capítulo estudaremos o processo de tradução e ligação para ver como um sistema operacional utiliza o mecanismo
de memória virtual para carregar e executar programas.

ÿ Pergunta 50 O que é região de memória virtual?

ÿ Pergunta 51 O que acontecerá se você tentar modificar o código de execução do programa durante sua execução?

ÿ Pergunta 52 O que são endereços proibidos?

ÿ Pergunta 53 O que é um endereço canônico?

ÿ Pergunta 54 O que são as tabelas de tradução?

ÿ Pergunta 55 O que é uma moldura de página?

ÿ Pergunta 56 O que é uma região de memória?

ÿ Pergunta 57 O que é o espaço de endereço virtual? Como é diferente do físico?

ÿ Pergunta 58 O que é um buffer lookaside de tradução?

ÿ Pergunta 59 O que torna o mecanismo de memória virtual eficiente?

60
Machine Translated by Google

Capítulo 4 ÿ Memória Virtual

ÿ Pergunta 60 Como o espaço de endereço é trocado?

ÿ Pergunta 61 Quais mecanismos de proteção a memória virtual incorpora?

ÿ Pergunta 62 Qual é a finalidade do bit EXB ?

ÿ Pergunta 63 Qual é a estrutura do endereço virtual?

ÿ Pergunta 64 Um endereço virtual e um endereço físico têm algo em comum?

ÿ Pergunta 65 Podemos escrever uma string na seção .text ? O que acontece se lermos? E se sobrescrevermos?

ÿ Pergunta 66 Escreva um programa que chamará chamadas de sistema stat, open e mmap (verifique a tabela de chamadas de sistema

no Apêndice C). Deve mostrar o comprimento do arquivo e seu conteúdo.

ÿ Questão 67 Escreva os programas a seguir, todos mapeando um arquivo de texto input.txt contendo um inteiro x em

memória usando uma chamada de sistema mmap e produza o seguinte:

1.x ! (fatorial, x! = 1 · 2 · · · · · (x ÿ 1) · x). É garantido que x ÿ 0.

2. 0 se o número de entrada for primo, 1 caso contrário.

3. Soma de todos os dígitos do número.

4. x-ésimo número de Fibonacci.

5. Verifica se x é um número de Fibonacci.

61
Machine Translated by Google

CAPÍTULO 5

Pipeline de compilação

Este capítulo cobre o processo de compilação. Nós o dividimos em três etapas principais: pré-processamento, tradução e
vinculação. A Figura 5-1 mostra um processo de compilação exemplar. Existem dois arquivos de origem: first.asm e
second.asm. Cada um é tratado separadamente antes do estágio de vinculação.

Figura 5-1. Pipeline de compilação

© Igor Zhirkov 2017 63


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_5
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

O pré-processador transforma a fonte do programa para obter outro programa na mesma linguagem. As transformações
geralmente são substituições de uma string em vez de outras.
O compilador transforma cada arquivo fonte em um arquivo com instruções de máquina codificadas. No entanto, esse arquivo ainda
não está pronto para ser executado porque não possui as conexões corretas com os outros arquivos compilados separadamente. Estamos
falando de casos em que instruções endereçam dados ou instruções, que são declaradas em outros arquivos.

O Linker estabelece conexões entre arquivos e cria um arquivo executável. Depois disso, o programa está pronto para ser
executado. Linkers operam com arquivos objeto, cujos formatos típicos são ELF (Executable and Linkable Format) e COFF (Common
Object File Format).
O Loader aceita um arquivo executável. Esses arquivos geralmente possuem uma visão estruturada com metadados incluídos. Em
seguida, ele preenche o novo espaço de endereço de um processo recém-nascido com suas instruções, pilha, dados definidos globalmente e
código de tempo de execução fornecido pelo sistema operacional.

5.1 Pré-processador
Cada programa é criado como um texto. A primeira etapa da compilação é chamada de pré-processamento. Durante esta fase, um
programa especial avalia as diretivas do pré-processador encontradas na fonte do programa. Segundo eles, são feitas substituições
textuais. Como resultado obtemos um código-fonte modificado sem diretivas de pré-processador escritas na mesma linguagem de
programação. Nesta seção discutiremos o uso do macroprocessador NASM.

5.1.1 Substituições Simples


Uma das diretivas básicas do pré-processador é chamada %define. Ele realiza uma substituição simples.
Dado o código mostrado na Listagem 5-1, um pré-processador substituirá cat_count por 42 sempre que encontrar tal substring
no código-fonte do programa.

Listagem 5-1. define_cat_count.asm

% definir cat_count 42

mov rax, contagem_de_gatos

Para ver os resultados do pré-processamento de um arquivo de entrada.asm, execute nasm -E file.asm. Muitas vezes é muito útil para
fins de depuração. Vamos ver o resultado na Listagem 5-2 para o arquivo da Listagem 5-1.

Listagem 5-2. define_cat_count_preprocessed.asm

% linha 2+1 define_cat_count.asm

mov rax, 42

Os comandos para declarar substituições são chamados de macros. Durante um processo chamado expansão macro
suas ocorrências são substituídas por trechos de texto. Os fragmentos de texto resultantes são chamados de instâncias de macro.
Na Listagem 5-2, um número 42 na linha mov rax, cat_count é uma instância de macro. Nomes como cat_count são frequentemente
chamados de símbolos de pré-processador.

ÿ Redefinição NASM permite redefinir símbolos de pré-processador existentes.

64
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

É importante que o pré-processador saiba pouco ou nada sobre a sintaxe da linguagem de programação.
Este último define construções de linguagem válidas.
Por exemplo, o código mostrado na Listagem 5-3 está correto. Não importa se nem a nem b por si só constituem uma
construção de montagem válida; contanto que o resultado final das substituições seja sintaticamente válido, o compilador estará bem com
isso.

Listagem 5-3. macro_asm_parts.asm

%define um mov rax,


% definir b rbx

ab

Em outro exemplo, em linguagens de nível superior, uma instrução if tem a forma if (<expressão>) then <instrução> else
<instrução>. As macros podem operar partes desta construção que por si só não são sintaticamente corretas (por exemplo, uma
única cláusula else <instrução> ). Contanto que o resultado esteja sintaticamente correto, o compilador não terá problemas com ele.

Ao contrário, existem outros tipos de macros, nomeadamente macros sintáticas, ligadas à estrutura da linguagem e operando
com as suas construções. Tais macros os modificam de forma estruturada. Linguagens como LISP, OCaml e Scala usam macros sintáticas.

Por que estamos usando macros? Além da automação, que veremos mais adiante, eles fornecem
mnemônicos para pedaços de código.1
Para constantes, permite-nos distinguir as ocorrências de 42 que são usadas para contar gatos daquelas usadas para contar cães
ou qualquer outra coisa. Caso contrário, certas modificações do programa seriam mais dolorosas e propensas a erros, uma vez que
teríamos que tomar mais decisões com base no significado deste número específico.
Para pacotes de construções de linguagem, isso nos fornece uma certa automatização, assim como as sub-rotinas.
As macros são expandidas em tempo de compilação, enquanto as rotinas são executadas em tempo de execução. A escolha é sua.
Para montagem, nenhuma otimização é realizada nos programas. No entanto, em linguagens de nível superior, as pessoas
usam variáveis constantes globais para esse assunto. Um bom compilador substituirá suas ocorrências por seu valor. Um mau, porém,
não pode estar atento às otimizações, o que pode ser o caso na programação de microcontroladores ou aplicações para sistemas
operacionais exóticos. Nesses casos, as pessoas geralmente fazem o trabalho de um compilador usando macros como na linguagem
assembly.

ÿ Estilo É uma boa prática nomear todas as constantes do seu programa.

Em assembly e C, as pessoas geralmente definem constantes globais usando definições de macro.

5.1.2 Substituições com Argumentos


Macros são melhores que isso: podem ter argumentos. A Listagem 5.4 mostra uma macro simples com três argumentos.

1
D. Knuth leva essa ideia ao extremo em sua abordagem chamada Literate Programming

65
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-4. macro_simple_3arg.asm

% teste de macro 3
dq % 1
dq % 2
dq % 3
% endmacro

Sua ação é simples: para cada argumento criará uma entrada de dados com quatro palavras. Como você pode ver, os
argumentos são referidos por seus índices começando em 1. Quando esta macro for definida, uma linha de teste 666, 555, 444
será substituída por aquelas mostradas na Listagem 5-5

Listagem 5-5. macro_simple_3arg_inst.asm

dq 666
dq 555
dq 444

ÿ Pergunta 68 Encontre mais exemplos de uso de %define e %macro na documentação do NASM.

5.1.3 Substituição Condicional Simples


As macros no NASM suportam várias condicionais. O mais simples deles é% se. A Listagem 5-6 mostra um
exemplo mínimo.

Listagem 5-6. macroif.asm

BITS
64% definir x 5

% se x == 10

mov rax, 100

%elif x == 15

mov rax, 115

% elif x == 200
mov rax, 0%
else
mov rax, rbx %
endif

A Listagem 5.7 mostra uma macro instanciada. Lembre-se, você pode verificar o resultado do pré-processamento usando nasm -E.

Listagem 5-7. macroif_preprocessed.asm

% linha 1+1 if.asm


[bits 64]

66
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

% linha 15+1 if.asm


mov rax, rbx

A condição é uma expressão semelhante ao que você pode ver em linguagens de alto nível: aritmética e
conjecturas lógicas (e, ou, não).

5.1.4 Condicionamento na Definição É possível decidir em

tempo de compilação se uma parte do arquivo deve ser montada ou não. Uma das muitas contrapartes %if é %ifdef.
Funciona de maneira semelhante, mas a condição é satisfeita se um determinado símbolo do pré-processador for
definido. Um exemplo mostrado na Listagem 5-8 incorpora tal diretiva.

Listagem 5-8. definindo_in_cla.asm

%ifdef flag
hellostring: db "Olá",0 %endif

Como você pode ver, o sinalizador do símbolo não é definido aqui usando a diretiva %define. Assim, temos a linha
rotulado por hellostring.
Vale ressaltar que os símbolos do pré-processador podem ser definidos diretamente ao chamar o NASM graças à tecla
-d. Por exemplo, a condição macro na Listagem 5.8 será satisfeita quando NASM for chamado com o argumento -d myflag.

ÿ Pergunta 69 Verifique a saída do pré-processador no arquivo, mostrada na Listagem 5-8.

Nas próximas seções veremos mais diretivas de pré-processador semelhantes a %if.

5.1.5 Condicionamento na identidade do texto %ifidn é usado para

testar se duas sequências de texto são iguais (diferenças de espaçamento não são levadas em consideração).
Dependendo do resultado da comparação, o código subsequente será ou não montado.
Isto nos permite criar macros muito flexíveis que dependerão, por exemplo, do nome do argumento.
Para ilustrar, vamos criar uma instrução macro pushr (veja Listagem 5-9). Ela funcionará exatamente da mesma maneira
que uma instrução push assembly, mas também aceitará registros rip e rflags.

Listagem 5-9. pushr.asm

% macro pushr 1
% ifidn % 1, rflags
pushf
% else
push %
1%
endif % endmacro

pushr rax
pushr rflags

67
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

A Listagem 5.10 mostra o que as duas macros da Listagem 5.9 se tornam após a instanciação.

Listagem 5-10. pushr_preprocessed.asm

% linha 8+1 pushr/pushr.asm

empurre
rax pushf

Como você pode ver, a macro ajustou seu comportamento com base na representação de texto do argumento. Observe que
as cláusulas %else são permitidas assim como para %if normal. Para tornar a comparação insensível a maiúsculas e minúsculas,
use a diretiva %ifidni.

5.1.6 Condicionamento ao tipo de argumento O pré-processador NASM

está um pouco ciente dos elementos da linguagem assembly (tipos de token). Ele pode distinguir strings entre aspas de
números e identificadores. Há um triplo de %if homólogos para este propósito: %ifid para verificar se seu argumento é um
identificador, %ifstr para uma verificação de string e %ifnum para verificar se é um número ou não.
A Listagem 5.11 mostra um exemplo de macro que imprime um número ou uma string (usando um identificador).
Ele usa várias rotinas desenvolvidas durante a primeira tarefa para calcular o comprimento da string, a string de saída e o
número inteiro de saída.

Listagem 5-11. macro_arg_types.asm

%macro print 1
%ifid %1
mov rdi, %1
chamada print_string
%else

%ifnum %1
mov rdi, %1
call print_uint %else
%error
"String literais ainda não são suportados" %endif %endif

%endmacro

myhello: db 'hello', 10, 0 _start: print


myhello
print 42 mov rax,
60 syscall

68
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

O recuo é totalmente opcional e é feito para facilitar a leitura.


Caso o argumento não seja string nem identificador, usamos a diretiva %error para forçar o NASM a entrar
lançando um erro. Se tivéssemos usado %fatal em vez disso, teríamos parado completamente a montagem e quaisquer
erros adicionais seriam ignorados; um simples% de erro, no entanto, dará ao NASM a chance de sinalizar também sobre os
seguintes erros antes de parar de processar os arquivos de entrada.
Vamos observar as instanciações de macro na Listagem 5-12

Listagem 5-12. macro_arg_types_preprocessed.asm

%linha 73+1 macro_arg_types/macro_arg_types.asm

meuolá: db 'olá', 10, 0


_começar:
mov rdi, meu olá
%linha 76+0 macro_arg_types/macro_arg_types.asm
chamar print_string

%linha 77+1 macro_arg_types/macro_arg_types.asm

%linha 77+0 macro_arg_types/macro_arg_types.asm


mov rdi, 42
chame print_uint

%linha 78+1 macro_arg_types/macro_arg_types.asm


mov rax, 60
chamada de sistema

5.1.7 Ordem de Avaliação: Definir, xdefine, Atribuir


Todas as linguagens de programação possuem uma noção de estratégia de avaliação. Descreve a ordem de avaliação em
expressões complexas. Como devemos avaliar f (g(1), h(4))? Deveríamos avaliar g(1) e h(4) primeiro e depois deixar f agir sobre
os resultados? Ou deveríamos incorporar g(1) e h(4) dentro do corpo de f e adiar suas próprias avaliações até que sejam
realmente necessárias?
As macros são avaliadas pelo macroprocessador NASM e possuem uma estrutura complexa, como qualquer macro
a instanciação pode incluir outras macros a serem instanciadas. Um ajuste fino da ordem de avaliação é possível, porque
o NASM fornece versões ligeiramente diferentes de diretivas de definição de macro, nomeadamente

•%define para uma substituição diferida. Se o corpo da macro contiver outras macros, elas serão
expandido após a substituição.

•%xdefine realiza substituições ao ser definido. Então a string resultante será


usado em substituições.

•%assign é como %xdefine, mas também força a avaliação de expressões aritméticas e gera um erro
se o resultado do cálculo não for um número.

Para entender melhor a diferença sutil entre %define e %xdefine dê uma olhada no exemplo mostrado na Listagem 5-13.

69
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-13. define.asm

% definir eu 1

% definir di * 3
%xdefine xd i * 3
%atribuir ai * 3

mov rax, d
mov rax, xd
mov rax, um

; vamos redefinir eu
% definir i 100
mov rax, d
mov rax, xd
mov rax, um

A Listagem 5.14 mostra o resultado do pré-processamento.

Listagem 5-14. define_preprocessed.asm

% linha 2+1 define.asm

% linha 6+1 define.asm

mov rax, 1 * mov 3


rax, 1 * mov rax, 3
3

mov rax, 100 * mov 3


rax, 1 * 3
mov rax, 3

As principais diferenças são que

•%define pode alterar seu valor entre instanciações se partes dele forem redefinidas.

•%xdefine possui outras macros das quais depende diretamente coladas a ele após ser definido.

•%atribuir avaliação de forças e valores substitutos. Onde xdefine teria deixado você com o símbolo do pré-
processador igual a 4+2+3, %assign irá calculá-lo e atribuir o valor 9 a ele.

Usaremos as maravilhosas propriedades de %assign para mostrar um pouco de magia depois de nos familiarizarmos com
as repetições de macro.

5.1.8 Repetição
A diretiva times é executada depois que todas as definições de macro são totalmente expandidas e, portanto, não pode ser usada
para repetir partes de macros.
Mas há outra maneira de o NASM fazer loops de macro: colocando o corpo do loop entre %rep e
%endrep diretivas. Os loops podem ser executados apenas um número fixo de vezes, especificado como argumento %rep.
Um exemplo na Listagem 5.15 mostra como um pré-processador calcula uma soma de números inteiros de 1 a 10 e então usa esse valor
para inicializar o resultado de uma variável global.

70
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-15. representante.asm

%atribuir x
1%atribuir 0%rep
10%atribuir
machado + a%atribuir
xx + 1%endrep

resultado: dq a

Após o pré-processamento, o valor do resultado é inicializado corretamente em 55 (veja Listagem 5-16). Você pode verificar
manualmente.2

Listagem 5-16. rep_preprocessed.asm

% linha 7+1 rep/rep.asm

resultado: dq 55

Podemos usar% exitrep para sair imediatamente do ciclo. Portanto, é análogo interromper a instrução em linguagens de alto
nível.

5.1.9 Exemplo: Calculando Números Primos A macro mostrada na Listagem 5-17 é

usada para produzir uma peneira de números primos. Isso significa que define uma matriz estática de bytes, onde cada i-ésimo
byte é igual a 1 se e somente se i for um número primo.
Um número primo é um número natural maior que 1, tal que não possui divisores positivos além de 1 e ele mesmo.

O algoritmo é simples:

• 0 e 1 não são primos.

•2 é um número primo.

•Para cada corrente até o limite verificamos se nenhum i de 2 até n/2 é divisor de n.

Listagem 5-17. prime.asm

% atribuir limite 15
is_prime: db 0, 0, 1 % atribuir
n 3 % limite de
repetição %
atribuir corrente 1 %
atribuir i 1 % rep
n/2 %
atribuir i i+1 % se n
%i=0
%atribuir atual 0%exitrep

2 Uma fórmula simples para a soma dos primeiros n números naturais é: n n( ) +1


2

71
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

%endif

%endrep banco

de dados atual; n %atribuir n

n+1 %endrep

Acessando o n-ésimo elemento do array is_prime podemos descobrir se n é um número primo ou

não. Após o pré-processamento, será gerado o seguinte código da Listagem 5-18 :

Listagem 5-18. prime_preprocessed.asm

% linha 2+1 prime/prime.asm is_prime: db 0, 0, 1 %

linha 16+1 prime/prime.asm db 1

% linha 16+0 prime/prime.asm db 0

banco de dados 1

banco de dados 0

banco de dados 1

banco de dados 0

banco de dados 0

banco de dados 0

banco de dados 1

banco de dados 0

banco de dados 1

banco de dados 0

banco de dados 0

banco de dados 0

banco de dados 1

Ao ler o i-ésimo byte começando em is_prime obtemos 1 se i for primo; 0 caso contrário.

ÿ Pergunta 70 Modifique a macro da forma como ela produziria uma tabela de bits, ocupando oito vezes menos espaço
na memória. Adicione uma função que verificará o número principalmente e retornará 0 ou 1, com base nesta tabela pré-computada.

ÿ Dica para a macro que você provavelmente terá que copiar e colar bastante.

5.1.10 Etiquetas dentro de macros


Não há muito que possamos fazer na montagem sem rótulos. Usar nomes de rótulos fixos dentro de macros não é muito comum. Quando a macro é instanciada muitas vezes dentro do mesmo arquivo,

os rótulos definidos de forma múltipla podem produzir conflitos que interrompem a compilação.

Existe uma opção para usar rótulos locais de macro, que são rótulos que você não pode acessar fora da instanciação de macro atual. Para fazer isso, você pode prefixar esse

nome de rótulo com porcentagem dupla, como segue: %%labelname. Cada rótulo local da macro receberá um prefixo aleatório, que mudará entre as instâncias da macro, mas permanecerá o

mesmo dentro de uma instância. A Listagem 5-19 mostra um exemplo. A Listagem 5.20 contém os resultados do pré-processamento.

72
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-19. macro_local_labels.asm

%macro minhamacro 0 %
%nomedorótulo:
%%nome do rótulo:
%endmacro

Minha macro

Minha macro

minha macro

A macro mymacro é instanciada três vezes. Cada vez que o rótulo local recebe um nome exclusivo. O nome base (após o dobro da
porcentagem) é anexado a um prefixo numérico diferente em cada instância. O primeiro prefixo é ..@0., o segundo é ..@1. e assim por diante.

Listagem 5-20. macro_local_labels_inst.asm

%linha 5+1 macro_local_labels/macro_local_labels.asm

..@0.labelname: %linha
6+0 macro_local_labels/macro_local_labels.asm ..@0.labelname: %linha 7+1
macro_local_labels/
macro_local_labels.asm

..@1.labelname: %linha
8+0 macro_local_labels/macro_local_labels.asm ..@1.labelname: %linha 9+1
macro_local_labels/
macro_local_labels.asm

..@2.labelname: %linha
10+0 macro_local_labels/macro_local_labels.asm ..@2.labelname:

5.1.11 Conclusão
Você pode pensar nas macros como uma metalinguagem de programação executada durante a compilação. Ele pode fazer cálculos bastante complexos
e é limitado de duas maneiras:

•Esses cálculos não podem depender da entrada do usuário (portanto, eles só podem operar
constantes).

•Os ciclos não podem ser executados mais do que um número fixo de vezes. Significa que enquanto
construções semelhantes são impossíveis de codificar.

73
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

5.2 Tradução
Um compilador geralmente traduz o código-fonte de uma linguagem para outra. No caso de tradução de linguagens de programação
de alto nível para código de máquina, este processo incorpora múltiplas etapas internas.
Durante esses estágios, empurramos gradualmente o código IR (Representação Intermediária) em direção ao idioma alvo.
Cada impulso de IR está mais próximo do idioma alvo. Logo antes de produzir o código assembly, o IR estará muito próximo do assembly,
para que possamos liberar o assembly em uma listagem legível em vez de instruções de codificação.
A tradução não é apenas um processo complexo, mas também perde informações sobre a estrutura do código-fonte, portanto
reconstruir código legível de alto nível a partir do arquivo assembly é impossível.
Um compilador funciona com entidades de código atômico chamadas módulos. Um módulo geralmente corresponde a um código
arquivo de origem (mas não um arquivo de cabeçalho ou de inclusão). Cada módulo é compilado independentemente dos outros
módulos. O arquivo objeto é produzido a partir de cada módulo. Ele contém instruções codificadas em binário, mas geralmente não pode
ser executado imediatamente. Existem vários motivos.
Por exemplo, o arquivo objeto é preenchido separadamente de outros arquivos, mas refere-se a códigos e dados externos.
Ainda não está claro se esse código ou dados residirão na memória ou a posição do próprio arquivo objeto.
A tradução da linguagem assembly é bastante direta porque a correspondência entre os mnemônicos assembly e as
instruções de máquina é quase um para um. Além da resolução do rótulo, não há muito trabalho não trivial. Assim, por enquanto
vamos nos concentrar na próxima etapa de compilação, a saber, a vinculação.

5.3 Vinculação
Voltemos aos nossos primeiros exemplos de programas assembly. Para transformar um “Olá, mundo!” programa do código-fonte ao
arquivo executável, usamos os dois comandos a seguir:

> nasm -f elf64 -o olá.o olá.asm


> ld -o olá olá.o

Usamos NASM primeiro para produzir um arquivo objeto. Seu formato, elf64, foi especificado pela tecla -f. Depois usamos outro
programa, ld (um vinculador), para produzir um arquivo pronto para ser executado. Tomaremos esse formato de arquivo como exemplo
para mostrar o que o vinculador realmente faz.

5.3.1 Formato executável e vinculável


ELF (Executable and Linkable Format) é um formato para arquivos objeto bastante típico para sistemas *nix. Vamos nos limitar à sua
versão de 64 bits.
ELF permite três tipos de arquivos.

1. Arquivos de objetos relocáveis são arquivos .o, produzidos pelo compilador.

A realocação é um processo de atribuição de endereços definitivos a várias partes do programa e


alteração do código do programa da forma como todos os links são atribuídos corretamente. Estamos
falando de todos os tipos de acessos à memória por endereços absolutos. A realocação é necessária,
por exemplo, quando o programa consiste em vários módulos, que fazem referência entre si. A ordem
em que serão colocados na memória ainda não foi fixada, portanto os endereços absolutos não foram
determinados. Os vinculadores podem combinar esses arquivos para produzir o próximo tipo de arquivo
objeto.

2. O arquivo objeto executável pode ser carregado na memória e executado imediatamente. É


essencialmente um armazenamento estruturado para código, dados e informações utilitárias.

74
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

3. Arquivos de objetos compartilhados podem ser carregados quando necessário pelo programa principal. Eles
estão vinculados a ele dinamicamente. No sistema operacional Windows, esses são arquivos DLL bem
conhecidos; em sistemas *nix, seus nomes geralmente terminam com .so.

O objetivo de qualquer vinculador é criar um arquivo-objeto executável (ou compartilhado), dado um conjunto de arquivos relocáveis.
uns. Para fazer isso, um vinculador deve realizar as seguintes tarefas:

• Realocação

• Resolução de símbolos. Cada vez que um símbolo (função, variável) é desreferenciado, um vinculador
tem que modificar o arquivo objeto e preencher a parte da instrução, correspondente ao endereço do operando,
com o valor correto.

5.3.1.1 Estrutura
Um arquivo ELF começa com o cabeçalho principal, que armazena metainformações globais.
Consulte a Listagem 5.21 para ver um cabeçalho ELF típico. O arquivo hello é o resultado da compilação de um arquivo “Hello,
world!” programa mostrado na Listagem 2-4.

Listagem 5-21. hello_elfheader Cabeçalho ELF:

Cabeçalho ELF:
Magia: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Aula: ELF64
Dados: Complemento de 2, little endian
Versão: 1 (atual)
SO/ABI: UNIX - Sistema V
Versão ABI: 0

Tipo: EXEC (arquivo executável)


Máquina: Microdispositivos avançados X86-64
Versão: 0x1

Endereço do ponto de entrada: 0x4000b0


Início dos cabeçalhos do programa: 64 (bytes no arquivo)
Início dos cabeçalhos da seção: 552 (bytes no arquivo)
Bandeiras: 0x0
Tamanho deste cabeçalho: 64 (bytes)
Tamanho dos cabeçalhos do programa: 56 (bytes)
Número de cabeçalhos de programa: 2
Tamanho dos cabeçalhos das seções: 64 (bytes)
Número de cabeçalhos de seção: 6
Índice da tabela de strings do cabeçalho da seção: 3

Os arquivos ELF fornecem informações sobre um programa que podem ser observadas de dois pontos de vista:

• Visualização de vinculação, composta por seções.

É descrito pela tabela de seção, que pode ser acessada através de readelf -S.

Cada seção, por sua vez, pode ser:

– Dados brutos a serem carregados na memória.

– Metadados formatados sobre outras seções, usados pelo carregador (por exemplo, .bss), vinculador (por exemplo, relocação
tabelas) ou depurador (por exemplo, .line).

75
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Código e dados são armazenados dentro de seções.

• Visualização de execução, composta por segmentos.

É descrito por uma tabela de cabeçalho de programa, que pode ser estudada usando readelf -l.
Veremos isso mais de perto na seção 5.3.5.

Cada entrada pode descrever

– Algum tipo de informação que o sistema precisa para executar o programa.

– Um segmento ELF, contendo zero ou mais seções. Eles têm o mesmo conjunto de permissões (leitura, gravação, execução) impostas
pela memória virtual. Cada segmento possui um endereço inicial e é carregado em uma região de memória separada, composta
por páginas consecutivas.

Após revisar a Listagem 5-21, notamos que ela descreve precisamente a posição e as dimensões dos cabeçalhos de programa e de seção.

Começamos com a visualização das seções, pois o vinculador trabalha principalmente com elas.

5.3.1.2 Seções em arquivos ELF


A linguagem assembly permite controles manuais de seção. A seção do NASM corresponde às seções do arquivo objeto. Você já viu alguns deles, a
saber, .text e .data. Segue a lista das seções mais utilizadas; a lista completa pode ser encontrada em [24].

.text armazena instruções de máquina.


.rodata armazena dados somente leitura.
.data armazena variáveis globais inicializadas.
.bss armazena variáveis globais legíveis e graváveis, inicializadas em zero. Não há necessidade de despejar seu conteúdo em um arquivo
objeto, pois todos eles são preenchidos com zeros de qualquer maneira. Em vez disso, um tamanho total da seção é armazenado. Um sistema
operacional pode conhecer maneiras mais rápidas de inicializar essa memória do que zerá-la manualmente.
Na montagem, você pode colocar dados aqui colocando resb, resw e diretivas semelhantes após a seção .bss.
.rel.text armazena tabela de realocação para a seção .text. É usado para memorizar locais onde um linker
deve modificar .text após escolher o endereço de carregamento para este arquivo objeto específico.
.rel.data armazena uma tabela de realocação para dados referenciados no módulo.
.debug armazena uma tabela de símbolos usada para depurar o programa. Se o programa foi escrito em C ou C++, ele armazenará
informações não apenas sobre variáveis globais (como faz .symtab ), mas também sobre variáveis locais.
.line define correspondência com pedaços de código e números de linha no código-fonte. Precisamos disso porque a correspondência entre
as linhas de código-fonte em linguagens de nível superior e as instruções assembly não é direta. Esta informação permite depurar um programa em
uma linguagem de nível superior linha por linha.
.strtab armazena sequências de caracteres. É como uma série de strings. Outras seções, como .symtab e .debug,
não use strings imediatas, mas seus índices em .strtab.
.symtab armazena uma tabela de símbolos. Sempre que um programador define um rótulo, o NASM cria um símbolo para ele.3 Esta tabela
também armazena informações de utilidade, que examinaremos mais tarde.
Agora que temos uma compreensão geral da visualização de vinculação de arquivos ELF, observaremos alguns exemplos
para mostrar particularidades de três tipos diferentes de arquivos ELF.

5.3.2 Arquivos Objeto Relocáveis


Vamos investigar um arquivo objeto, obtido pela compilação de um programa simples, mostrado na Listagem 5.22.

3
Não deve ser confundido com símbolos de pré-processador!

76
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-22. símbolos.asm

seção .dados
var de dados1: dq 1488
var de dados2: dq 42

seção .bss
bssvar1: resq 4*1024*1024 bssvar2:
resq 1

seção .texto

externo em algum lugar


global _start mov
rax, datavar1 mov rax,
bssvar1 mov rax,
bssvar2 mov rdx,
datavar2 _start: jmp _start
ret

rótulo de texto: dq 0

Este programa usa diretivas externas e globais para marcar símbolos de uma maneira diferente. Estas duas diretivas
controlam a criação de uma tabela de símbolos. Por padrão, todos os símbolos são locais para o módulo atual. extern define um
símbolo que é definido em outros módulos, mas referenciado no módulo atual. Por outro lado, global define um símbolo disponível
globalmente ao qual outros módulos podem se referir, definindo-o como externo dentro deles.

ÿ Evite confusão Não confunda símbolos globais e locais com rótulos globais e locais!

O GNU binutils é uma coleção de ferramentas binárias usadas para trabalhar com arquivos objeto. Inclui várias ferramentas
usado para explorar o conteúdo do arquivo objeto. Vários deles são de particular interesse para nós.

•Se você precisar apenas consultar a tabela de símbolos, use nm.

•Use objdump como uma ferramenta universal para exibir informações gerais sobre um arquivo objeto. Além do
ELF, ele suporta outros formatos de arquivo objeto.

•Se você sabe que o arquivo está no formato ELF, readelf geralmente é o melhor e mais
escolha informativa.

Vamos alimentar este programa no objdump para produzir os resultados mostrados na Listagem 5.23.

Listagem 5-23. Símbolos

> nasm -f elf64 main.asm && objdump -tf -m intel main.o main.o:
formato de arquivo elf64-x86-64

arquitetura: i386:x86-64, sinalizadores 0x00000011:


HAS_RELOC, endereço
inicial HAS_SYMS 0x000000000000000

77
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

TABELA DE SÍMBOLOS:

000000000000000l df *ABS* 0000000000000000 principal.asm


0000000000000000l d.dados 0000000000000000.dados
000000000000000l d.bss0000000000000000.bss
000000000000000l d .texto 0000000000000000 .texto
000000000000000l .dados 0000000000000000 datavar1
0000000000000008l 000 .dados 0000000000000000 datavar2
litros 0000000002000000 .bss 0000000000000000 bssvar1
litros 000000000000029 .bss 0000000000000000 bssvar2
litros .text 0000000000000000 rótulo de texto
000000000000000 *UND* 0000000000000000 em algum lugar
000000000000028g .texto 0000000000000000 _início

É mostrada uma tabela de símbolos, onde cada símbolo é anotado com informações úteis. O que significam suas colunas?

1. Endereço virtual do símbolo fornecido. Por enquanto não sabemos os endereços iniciais da seção,
então todos os endereços virtuais são fornecidos em relação ao início da seção. Por exemplo,
datavar1 é a primeira variável armazenada em .data, seu endereço é 0 e seu tamanho é 8 bytes.
A segunda variável, datavar2, está localizada na mesma seção com deslocamento maior de 8,
próximo a datavar1. Como algum lugar é definido como externo, ele está obviamente localizado em
algum outro módulo, então por enquanto seu endereço não tem significado e é deixado em zero.

2. Uma sequência de sete letras e espaços; cada letra caracteriza um símbolo de alguma forma.
Alguns deles são de nosso interesse.

(a) l, g,- – local, global ou nenhum dos dois.

(b)…

(c)…

(d)…

(e) I,- – um link para outro símbolo ou um símbolo comum.

(f) d, D,- – símbolo de depuração, símbolo dinâmico ou um símbolo comum.

(g) F, f, O,- – nome da função, nome do arquivo, nome do objeto ou um símbolo comum.

3. A que seção corresponde esta etiqueta? *UND* para seção desconhecida (o símbolo é
referenciado, mas não definido aqui), *ABS* significa nenhuma seção.

4. Normalmente, este número mostra um alinhamento (ou ausência).

5. Nome do símbolo.

Por exemplo, vamos investigar o primeiro símbolo mostrado na Listagem 5.23. Isso é

nome do arquivo,
d necessário apenas para fins de depuração,
l local para este módulo.

O rótulo global _start (que também é um ponto de entrada) está marcado com a letra g na segunda coluna.

ÿ Observação Os nomes dos símbolos diferenciam maiúsculas de minúsculas: _start e _STaRT são diferentes.

78
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Como os endereços na tabela de símbolos ainda não são endereços virtuais reais , mas relativos a seções,
podemos nos perguntar: como isso aparece no código de máquina? O NASM já cumpriu sua função e as instruções
da máquina devem ser montadas. Podemos examinar seções interessantes de arquivos de objeto invocando
objdump com os parâmetros -D (desmontar) e, opcionalmente, -M intel-mnemônico (para fazer com que ele mostre a
sintaxe no estilo Intel em vez da sintaxe da AT&T). A Listagem 5-24 mostra os resultados.

ÿ Como ler dumps de desmontagem A coluna da esquerda geralmente é o endereço absoluto onde os dados serão
carregados. Antes de vincular, é um endereço relativo ao início da seção.

A segunda coluna mostra bytes brutos como números hexadecimais.

A terceira coluna pode conter os resultados da desmontagem dos mnemônicos do comando assembly.

Listagem 5-24. objdump_d

> objdump -D -M intel-mnemônico main.o


main.o: formato de arquivo elf64-x86-64
Desmontagem da seção .data:
000000000000000 <datavar1>: ...
000000000000008 <datavar2>: ...
Desmontagem da seção .bss:
000000000000000 <bssvar1>: ...
0000000002000000 <bssvar2>: ...
Desmontagem da seção .text:
000000000000000 <_start-0x28>:
0: 48 b8 00 00 00 00 00 movebs rax,0x0
7:00 00 00
a: 48 b8 00 00 00 00 00 movebs rax,0x0
11h00 00 00
14: 48 b8 00 00 00 00 00 1b: movebs rax,0x0
00 00 00
1e: 48 ba 00 00 00 00 00 movebs rdx,0x0
25:00 00 00
000000000000028 <_início>:
28: c3 ret
0000000000000029 <rótulo de texto>:

O operando mov na seção .text com deslocamentos 0 e 14 relativos ao início da seção deve ser datavar1
endereço, mas é igual a zero! A mesma coisa aconteceu com bssvar. Isso significa que o vinculador precisa alterar o código
de máquina compilado, preenchendo os endereços absolutos corretos nos argumentos das instruções. Para conseguir
isso, para cada símbolo todas as referências a ele são lembradas na tabela de relocação. Assim que o vinculador entende
qual será seu verdadeiro endereço virtual, ele percorre a lista de ocorrências de símbolos e preenche as lacunas.
Existe uma tabela de realocação separada para cada seção que necessita de uma.
Para ver as tabelas de realocação, use readelf --relocs. Consulte a Listagem 5-25.

79
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-25. readelf_relocs

> readelf --relocs main.o


A seção de realocação '.rela.text' no deslocamento 0x440 contém 4 entradas:
Desvio Informações
Digite Sym. Nome do valor+Adição
000000000002 000200000001 R_X86_64_64 000000000000000 .dados + 0
00000000000c 000300000001 R_X86_64_64 000000000000000.bss + 0

000000000016 000300000001 R_X86_64_64 000000000000000.bss + 2000000


000000000020 000200000001 R_X86_64_64 000000000000000.dados + 8

Uma forma alternativa de exibir a tabela de símbolos é usar um utilitário nm mais leve e minimalista.
Para cada símbolo mostra o endereço virtual, tipo e nome do símbolo. Observe que o sinalizador de tipo está em um formato
diferente do objdump. Veja a Listagem 5.26 para um exemplo mínimo.

Listagem 5-26. nm

> nm principal.o
000000000000000 b bssvar
000000000000000 d datavar
Você está em algum lugar

00000000000000a T _início
00000000000000b t etiqueta de texto

5.3.3 Arquivos Objeto Executáveis


O segundo tipo de arquivo objeto pode ser executado imediatamente. Ele mantém sua estrutura, mas os endereços agora estão
vinculados a valores exatos.
Daremos uma olhada em outro exemplo, mostrado na Listagem 5-27. Inclui duas variáveis globais, em algum lugar e
privada, uma das quais está disponível para todos os módulos (marcada como global). Além disso, uma função de símbolo é
marcada como global.

Listagem 5-27. objeto_executável.asm

global em algum lugar


função global

seção .dados

em algum lugar: dq 999


privado: dq 666

seção .texto

função:
mov rax, em algum lugar
ret

Vamos compilá-lo normalmente usando nasm -f elf64 e, em seguida, vinculá-lo usando ld ao objeto anterior
arquivo, obtido compilando o arquivo mostrado na Listagem 5-22. A Listagem 5-28 mostra as alterações na saída do objdump.

80
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-28. objdump_tf

> nasm -f elf64 símbolos.asm


> nasm -f elf64 executável_object.asm
> ld símbolos.o executável_object.o -o principal
> objdump -tf principal

principal: formato de arquivo elf64-x86-64


arquitetura: i386:x86-64, sinalizadores 0x00000112:
EXEC_P, HAS_SYMS, D_PAGED

endereço inicial 0x0000000000000000

TABELA DE SÍMBOLOS:

0000000004000b0l d .código 0000000000000000 .código


00000000006000bcl d.dados 0000000000000000.dados
000000000000000 litros df *ABS* 0000000000000000 executável_object.asm
0000000006000c4l .dados 0000000000000000 privados
00000000006000bcg .data 0000000000000000 em algum lugar
000000000000000 *UND* 0000000000000000 _iniciar
0000000006000ccg .dados 000000000000000 __bss_start
00000000004000b0g F.código 0000000000000000 função
00000000006000ccg .dados 0000000000000000 _edados
00000000006000d0g .dados 0000000000000000 _fim

Os flags são diferentes: agora o arquivo pode ser executado (EXEC_P); não há mais tabelas de relocação (o sinalizador
HAS_RELOC está desmarcado). Os endereços virtuais agora estão intactos, assim como os endereços em código. Este arquivo
está pronto para ser carregado e executado. Ele mantém uma tabela de símbolos, e se você quiser cortá-la, tornando o
executável menor, use o utilitário strip.

ÿ Pergunta 71 Por que ld emite um aviso se _start não estiver marcado como global? Procure o endereço do ponto de entrada
neste caso usando readelf com argumentos apropriados.

ÿ Pergunta 72 Descubra a opção ld para remover automaticamente a tabela de símbolos após a vinculação.

5.3.4 Bibliotecas Dinâmicas


Quase todos os programas usam código de bibliotecas. Existem dois tipos de bibliotecas: estáticas e dinâmicas.
Bibliotecas estáticas consistem em vários arquivos de objetos relocáveis. Eles estão vinculados ao programa principal e são
mesclado com o arquivo executável resultante.

No mundo Windows, esses arquivos têm a extensão .lib.

No mundo Unix, estes são arquivos .o ou arquivos .a contendo vários arquivos .o.

Bibliotecas dinâmicas também são conhecidas como arquivos de objetos compartilhados, o terceiro dos três tipos de arquivos de
objetos que definimos anteriormente.

Eles estão vinculados ao programa durante sua execução.

No mundo Windows, esses são os infames arquivos .dll.

No mundo Unix, esses arquivos possuem uma extensão .so (objetos compartilhados).

81
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Enquanto as bibliotecas estáticas são apenas executáveis mal preparados, sem pontos de entrada, as bibliotecas dinâmicas têm
algumas diferenças que veremos agora.
Bibliotecas dinâmicas são carregadas quando necessárias. Como são arquivos objeto por si só, eles possuem todo tipo de meta-
informação sobre qual código fornecem para uso externo. Essas informações são usadas por um carregador para determinar os
endereços exatos das funções e dados exportados.
Bibliotecas dinâmicas podem ser enviadas separadamente e atualizadas de forma independente. É bom e ruim.
Embora o fabricante da biblioteca possa fornecer correções de bugs, ele também pode quebrar a compatibilidade com versões
anteriores, por exemplo, alterando argumentos de funções, enviando efetivamente uma mina de ação atrasada.
Um programa pode funcionar com qualquer quantidade de bibliotecas compartilhadas. Essas bibliotecas devem poder ser
carregadas em qualquer endereço. Caso contrário, eles ficariam presos no mesmo endereço, o que nos colocaria exatamente na mesma
situação de quando tentamos executar vários programas no mesmo espaço de endereço da memória física. Existem duas maneiras de
conseguir isso:

•Podemos realizar uma relocação em tempo de execução, quando a biblioteca estiver sendo carregada. Porém,
ele nos rouba uma característica muito atrativa: a possibilidade de reutilizar o código da biblioteca na
memória física sem sua duplicação quando vários processos o utilizam. Se cada processo realizar a
realocação da biblioteca para um endereço diferente, as páginas correspondentes serão corrigidas com
valores de endereço diferentes e, portanto, tornar-se-ão diferentes para processos diferentes.

Efetivamente, a seção .data seria realocada de qualquer maneira devido à sua natureza mutável.
Renunciar às variáveis globais nos permite descartar tanto a seção quanto a necessidade de realocá-la.

Outro problema é que a seção .text deve ser deixada gravável para realizar sua modificação durante o
processo de realocação. Introduz certos riscos de segurança, tornando possível a sua modificação
por código malicioso. Além disso, alterar .text de cada objeto compartilhado quando várias bibliotecas
são necessárias para a execução de um executável pode levar muito tempo.

•Podemos escrever PIC (Código Independente de Posição). Agora é possível escrever código que pode
ser executado independentemente de onde resida na memória. Para isso temos que nos livrar
completamente dos endereços absolutos. Hoje em dia, os processadores suportam endereçamento relativo
a rip, como mov rax, [rip + 13]. Este recurso facilita a geração de PIC.

Esta técnica permite o compartilhamento de seções .text . Hoje os programadores são fortemente
encorajados a usar PIC em vez de relocações.

ÿ Observação Sempre que você usa variáveis globais não constantes, você evita que seu código seja
redigitado, ou seja, seja executável dentro de vários threads simultaneamente sem alterações. Conseqüentemente,
você terá dificuldades em reutilizá-lo em uma biblioteca compartilhada. É um dos muitos argumentos contra um estado
global mutável no programa.

Bibliotecas dinâmicas economizam espaço em disco e memória. Lembre-se de que as páginas podem ser marcadas como privadas
ou compartilhadas entre vários processos. Se uma biblioteca for usada por vários processos, a maior parte dela não será duplicada na
memória física.
Mostraremos como construir um objeto compartilhado mínimo agora. No entanto, adiaremos a explicação
coisas como tabelas de deslocamento global e tabelas de ligação de procedimentos até o Capítulo 15.
A Listagem 5.29 mostra o conteúdo mínimo do objeto compartilhado. Observe o símbolo externo _GLOBAL_OFFSET_TABLE
e: especificação de função para a função de símbolo global. A Listagem 5.30 mostra um inicializador mínimo que chama uma função em
um arquivo de objeto compartilhado e sai corretamente.

82
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-29. libso.asm

_GLOBAL_OFFSET_TABLE_ externo

função global:função

seção .rodata

mensagem: db "Objeto compartilhado escreveu isto", 10, 0

seção .texto
função:
movimento
rax, 1
movimento
rdi, 1 rsi,
movimento
mensagem rdx, 14
movimento
syscall

ret

Listagem 5-30. libso_main.asm

_início global

função externa

seção .text _start:

mov rdi,
10 chamada func
mov rdi, rax
mov rax, 60 syscall

A Listagem 5.31 mostra comandos de construção e duas visualizações de um arquivo ELF.


Observe que a biblioteca dinâmica possui seções mais específicas, como .dynsym. Seções .hash, .dynsym e
.dynstr são necessários para realocação. .dynsym
armazena símbolos visíveis de fora da biblioteca. .hash é uma tabela
hash, necessária para diminuir o tempo de busca de símbolos para .dynsym. .dynstr armazena strings,
solicitadas por seus índices de .dynsym.

Listagem 5-31. biblioteca

> nasm -f elf64 -o main.o main.asm > nasm -f elf64


-o libso.o libso.asm > ld -o main main.o -d libso.so

> ld -shared -o libso.so libso.o --dynamic-linker=/lib64/ld-linux-x86-64.so.2 > readelf -S libso.so Existem 13 cabeçalhos
de seção, começando no

deslocamento 0x5a0:

83
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Cabeçalhos de seção:
[Nº] Nome Tipo Endereço Desvio
Tamanho EntSize Alinhamento de informações de link de bandeiras

[0] NULO 000000000000000 00000000


000000000000000 0000000000000000 [1] .hash 0 0 0
CERQUILHA 0000000000000e8 000000e8
00000000000002c 0000000000000004 A 2 0 8
[2] .dynsym DYNSYM 0000000000000090 000000000000118 00000118
0000000000000018 A 3 2 8
[3] .dynstr STRTAB 0000000000001a8 000001a8
00000000000001e 0000000000000000A 0 0 1
[ 4] .rela.dyn RELA 0000000000000018 00000000000001c8 000001c8
000000000000018 A [ 5] .text 000000000000001c 2 0 8
PROGBITOS 00000000000001e0 000001e0
000000000000000 AX 0 0 16
[6] .rodata PROGBITOS 00000000000001fc 000001fc
00000000000001a 0000000000000000A 0 0 4
[7] .eh_frame PROGBITOS 000000000000218 00000218
000000000000000 000000000000000 A 0 0 8
[8] .dinâmico DINÂMICO 000000000200218 00000218
00000000000000f0 000000000000010 WA PROGBITS 3 0 8
[9] .got.plt 000000000200308 00000308
000000000000018 0000000000000008 WA [10] .shstrtab 0 0 8
STRTAB 000000000000065 000000000000000 00000320
0000000000000 000 0 0 1
[11] .symtab SIMTAB 000000000000000 00000388
0000000000001c8 0000000000000018 12 15 8
[12] .strtab STRTAB 000000000000000 00000550
00000000000004f 0000000000000000 0 0 1
Chave para bandeiras:
W (gravar), A (alocar), X (executar), M (mesclar), S (strings), l (grande)
I (informações), L (ordem dos links), G (grupo), T (TLS), E (excluir), x (desconhecido)
O (processamento extra do SO necessário) o (específico do SO), p (específico do processador)

> readelf -S principal


Existem 14 cabeçalhos de seção, começando no deslocamento 0x650:

Cabeçalhos de seção:
[Nº] Nome Tipo Endereço Desvio
Tamanho EntSize Alinhamento de informações de link de bandeiras

[0] NULO 000000000000000 00000000


000000000000000 0000000000000000 0 0 0
[1]. 8 000000000000018 A STRTAB 000000000400158 00000158
0 0 1
000000000400168 00000168
3 0 8
000000000400190 00000190
4 1 8
[4] .dynstr 000000000400208 00000208
000000000000027 0000000000000000 A [ 5] .rela.plt 0 0 1
RELA 0000000000000018 000000000400230 00000230
000000000000018 AI 3 6 8

84
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

[6] .plt PROGBITOS 000000000400250 00000250


000000000000020 0000000000000010 AX [7] .text 0 0 16
PROGBITOS 000000000400270 00000270
0000000000000014 0000000000000000 AX 0 0 16
[8] .eh_frame PROGBITOS 000000000400288 00000288
000000000000000 000000000000000 A 0 0 8
[9] .dinâmico DINÂMICO 000000000600288 00000288
000000000000110 0000000000000010 WA 4 0 8
[10] .got.plt PROGBITOS 000000000600398 00000398
000000000000020 0000000000000008 WA [11] .shstrtab 0 0 8
0000000000000065 STRTAB 000000000000000000003b8
0000000000000000 0 0 1
[12] .symtab SIMTAB 000000000000000 00000420
0000000000001e0 0000000000000018 13 15 8
[13] .strtab STRTAB 000000000000000 00000600
00000000000004d 0000000000000000 0 0 1

ÿ Pergunta 73 Estude as tabelas de símbolos para um objeto compartilhado obtido usando readelf --dyn-
syms e objdump -ft.

ÿ Pergunta 74 Qual é o significado por trás da variável de ambiente LD_LIBRARY_PATH?

ÿ Pergunta 75 Separe a primeira tarefa em dois módulos. O primeiro módulo armazenará todas as funções
definidas em lib.inc. O segundo terá o ponto de entrada e chamará algumas destas funções.

ÿ Pergunta 76 Use um dos utilitários padrão do Linux (de coreutils). Estude sua estrutura de arquivo objeto
usando readelf e objdump.

As coisas que observamos nesta seção se aplicam à maioria das situações. No entanto, há uma visão mais ampla dos
diferentes modelos de código que afetam o endereçamento. Iremos nos aprofundar nesses detalhes no Capítulo 15 depois de nos
familiarizarmos com assembly e C. Lá também revisaremos novamente as bibliotecas dinâmicas e introduziremos as noções de Tabela
de Offset Global e Tabela de Ligação de Procedimentos.

5.3.5 Carregador
Loader é uma parte do sistema operacional que prepara o arquivo executável para execução. Inclui o mapeamento de suas seções
relevantes na memória, a inicialização de .bss e, às vezes, o mapeamento de outros arquivos do disco.
Os cabeçalhos de programa para um arquivo symbol.asm, mostrado na Listagem 5-22, são mostrados na Listagem 5-32.

Listagem 5-32. símbolos_pht

> nasm -f elf64 símbolos.asm


> nasm -f elf64 executável_object.asm
> ld símbolos.o executável_object.o -o principal
> readelf -l principal
O tipo de arquivo Elf é EXEC (arquivo executável)
Ponto de entrada 0x4000d8
Existem 2 cabeçalhos de programa, começando no deslocamento 64

85
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Cabeçalhos do programa:
Tipo Desvio VirtAddr Endereço físico
Tamanho do arquivo MemSiz Alinhamento de bandeiras
CARREGAR 0x000000000000000 0x000000000400000 0x0000000000400000
0x0000000000000e3 0x0000000000000e3 RÉ 200.000
CARREGAR 0x0000000000000e4 0x00000000006000e4 0x00000000006000e4
0x000000000000010 0x000000000200001c RW 200.000

Mapeamento de seção para segmento:


Seções de segmento...
00 .texto
01 .dados .bss

A tabela nos diz que dois segmentos estão presentes.

1.00 segmento

• É carregado em 0x400000 alinhado em 0x200000.

• Contém seção .text.

• Pode ser executado e lido. Não pode ser gravado (portanto, você não pode substituir o
código).

2. 01 segmento

• É carregado em 0x6000e4 alinhado a 0x200000.

• Pode ser lido e escrito.

Alinhamento significa que o endereço real será o mais próximo do início, divisível por 0x200000.
Graças à memória virtual, você pode carregar todos os programas no mesmo endereço inicial. Geralmente é
0x400000.
Existem algumas observações importantes a serem feitas:

•Seções de montagem com nomes semelhantes, definidas em arquivos diferentes, são mescladas.

•Uma tabela de realocação não é necessária em um arquivo executável puro. As realocações permanecem parcialmente
para objetos compartilhados.

Vamos iniciar o arquivo resultante e ver seu arquivo /proc/<pid>/maps como fizemos no Capítulo 4. Listagem 5-33
mostra seu conteúdo de amostra. O executável foi criado para fazer um loop infinito.

Listagem 5-33. símbolos_mapas

00400000-00401000 rxp 00000000 08:01 1176842


/home/sayon/repos/spbook/en/listings/chap5/main

00600000-00601000 rwxp 00000000 08:01 1176842


/home/sayon/repos/spbook/en/listings/chap5/main

00601000-02601000 rwxp 00000000 00:00 0

7ffe19cf2000-7ffe19d13000 rwxp 00000000 00:00 0


[pilha]
7ffe19d3e000-7ffe19d40000 r-xp 00000000 00:00 0
[vdso]

86
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

7ffe19d40000-7ffe19d42000 r--p 00000000 00:00 0


[vvar]
ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0
[vsyscall]

Como podemos ver, o cabeçalho do programa nos diz a verdade sobre o posicionamento das seções.

ÿ Observação Em alguns casos, você descobrirá que o vinculador precisa ser ajustado com precisão. Os endereços de
carregamento da seção e o posicionamento relativo podem ser ajustados usando scripts de linker, que descrevem o arquivo
resultante. Esses casos geralmente ocorrem quando você está programando um sistema operacional ou firmware de
microcontrolador. Este tópico está além do escopo deste livro, mas recomendamos que você leia [4] caso você encontre tal necessidade.

5.4 Tarefa: Dicionário


Esta tarefa nos avançará ainda mais para um intérprete ativo do Forth. Algumas coisas podem parecer forçadas, como o
design macro, mas será uma boa base para um intérprete que faremos mais tarde.
Nossa tarefa é implementar um dicionário. Ele fornecerá uma correspondência entre chaves e valores.
Cada entrada contém o endereço da próxima entrada, uma chave e um valor. Chaves e valores em nosso caso são strings
terminadas em nulo.
As entradas do dicionário que formam uma estrutura de dados são chamadas de lista vinculada. Uma lista vazia é
representada por um ponteiro nulo, igual a zero. Uma lista não vazia é um ponteiro para seu primeiro elemento. Cada elemento
contém algum tipo de valor e um ponteiro para o próximo elemento (ou zero, se for o último elemento).
A Listagem 5-34 mostra um exemplo de lista vinculada, contendo os elementos 100, 200 e 300. Ela pode ser referida por
um ponteiro para seu primeiro elemento, ou seja, x1.

Listagem 5-34. linked_list_ex.asm

seção .dados

x1:
dq x2
dq 100

x2:
dq x3
qd 200

x3:
dq 0
qd 300

As listas vinculadas costumam ser úteis em situações que possuem inúmeras inserções e remoções no meio da
lista. Acessar elementos por índice, entretanto, é difícil porque não se resume à simples adição de ponteiros. As posições
mútuas dos elementos da lista vinculada na memória plana geralmente não são previsíveis.
Nesta tarefa, o dicionário será construído estaticamente como uma lista e cada elemento recém-definido será anexado a
ela. Você deve usar macros com rótulos locais e redefinição de símbolos para automatizar a criação de listas vinculadas.
Instruímos explicitamente você a fazer dois pontos macro com dois argumentos, onde o primeiro conterá uma string de
chave do dicionário e o segundo conterá o nome da representação do elemento interno. Essa diferenciação é necessária
porque as cadeias de chaves às vezes podem conter caracteres que não fazem parte de nomes de rótulos válidos (espaço,
pontuação, sinais aritméticos, etc.). A Listagem 5.35 mostra um exemplo desse dicionário.

87
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

Listagem 5-35. linked_list_ex_macro.asm

seção .dados

dois pontos "terceira palavra", terceira_palavra


db "explicação da terceira palavra", 0

dois pontos "segunda palavra", segunda_palavra


db "explicação da segunda palavra", 0

dois pontos "primeira palavra", primeira_palavra


db "explicação da primeira palavra", 0

A tarefa conterá os seguintes arquivos:

1. principal.asm

2.lib.asm

3. ditar.asm

4. dois pontos.inc

Siga estas etapas para concluir a tarefa:

1. Crie um arquivo assembly separado contendo funções que você já escreveu


na primeira tarefa. Vamos chamá-lo de lib.o.

Não se esqueça de marcar todos os rótulos necessários como globais, caso contrário eles não serão
visíveis fora deste arquivo objeto!

2. Crie um arquivo colon.inc e defina uma macro de dois pontos para criar palavras de dicionário.

Esta macro terá dois argumentos:

•Chave do dicionário (entre aspas).

•Nome da etiqueta da montagem. As chaves podem conter espaços e outros caracteres, que não são
permitido em nomes de rótulos.

Cada entrada deve começar com um ponteiro para a próxima entrada e, em seguida, manter uma chave
como uma string terminada em nulo. O conteúdo é então descrito diretamente por um programador —
por exemplo, usando diretivas db, como no exemplo mostrado na Listagem 5.35.

3. Crie uma função find_word dentro de um novo arquivo dict.asm. Aceita dois argumentos:

(a) Um ponteiro para uma string de chave terminada em nulo.

(b) Um ponteiro para a última palavra do dicionário. Tendo um ponteiro para a última palavra definido,
podemos seguir os links consecutivos para enumerar todas as palavras do dicionário.

find_word percorrerá todo o dicionário, comparando uma determinada chave com cada chave do
dicionário. Se o registro não for encontrado, retorna zero; caso contrário, ele retornará o endereço
do registro.

4. Um arquivo de inclusão separado words.inc para definir palavras do dicionário usando dois pontos
macro. Inclua-o em main.asm.

88
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

5. Uma função _start simples. Deve realizar as seguintes ações:

• Leia a string de entrada em um buffer com no máximo 255 caracteres.

• Tente encontrar esta chave no dicionário. Se encontrado, imprima o valor correspondente. Caso contrário,
imprima uma mensagem de erro.

Não se esqueça: todas as mensagens de erro devem ser escritas em stderr e não em stdout!
Enviamos um conjunto de arquivos stub (consulte a Seção 2.1 “Configurando o Ambiente”); você é livre para usá-los.
Um Makefile adicional descreve o processo de construção; digite make no diretório de atribuição para construir um arquivo executável main.
Um rápido tutorial do sistema GNU Make está disponível no Apêndice B.
Assim como na primeira tarefa, existe um arquivo test.py para realizar testes automatizados.

5.5 Resumo
Neste capítulo, examinamos os diferentes estágios de compilação. Estudamos detalhadamente o macroprocessador NASM e
aprendemos condicionais e loops. Em seguida, falamos sobre três tipos de arquivos objeto: relocáveis, executáveis e compartilhados. Elaboramos
a estrutura do arquivo ELF e observamos o processo de realocação realizado pelo linker. Já tocamos nos arquivos de objetos compartilhados
e os revisitaremos novamente no Capítulo 15.

ÿ Pergunta 77 O que é a lista vinculada?

ÿ Pergunta 78 Quais são as etapas de compilação?

ÿ Pergunta 79 O que é pré-processamento?

ÿ Pergunta 80 O que é uma instanciação macro?

ÿ Pergunta 81 O que é a diretiva %define ?

ÿ Pergunta 82 O que é a diretiva %macro ?

ÿ Pergunta 83 Qual é a diferença entre %define, %xdefine e %assign?

ÿ Pergunta 84 Por que precisamos do operador %% dentro da macro?

ÿ Pergunta 85 Que tipos de condições são suportadas pelo macroprocessador NASM?


Quais diretivas são usadas para isso?

ÿ Pergunta 86 Quais são os três tipos de arquivos objeto ELF?

ÿ Pergunta 87 Que tipos de cabeçalhos estão presentes em um arquivo ELF?

ÿ Pergunta 88 O que é a relocalização?

ÿ Pergunta 89 Quais seções podem estar presentes em arquivos ELF?

ÿ Pergunta 90 O que é uma tabela de símbolos? Que tipo de informação ele armazena?

ÿ Pergunta 91 Existe uma conexão entre seções e segmentos?

ÿ Pergunta 92 Existe uma conexão entre as seções de montagem e as seções ELF?

89
Machine Translated by Google

Capítulo 5 ÿ Pipeline de Compilação

ÿ Pergunta 93 Que símbolo marca o ponto de entrada do programa?

ÿ Pergunta 94 Quais são os dois tipos diferentes de bibliotecas?

ÿ Pergunta 95 Existe alguma diferença entre uma biblioteca estática e um arquivo de objeto relocável?

90
Machine Translated by Google

CAPÍTULO 6

Interrupções e chamadas do sistema

Neste capítulo vamos discutir dois tópicos.


Primeiro, como a arquitetura de von Neumann carece de interatividade, as interrupções foram introduzidas para mudar isso.
Embora não estejamos mergulhando na parte de hardware das interrupções, aprenderemos exatamente como o programador vê as
interrupções. Além disso, falaremos sobre portas de entrada e saída usadas para comunicação com dispositivos externos.

Em segundo lugar, o sistema operacional (SO) geralmente fornece uma interface para interagir com os recursos que controla:
memória, arquivos, CPU (unidade central de processamento), etc.
Transferir o controle para as rotinas do sistema operacional requer um mecanismo bem definido de escalonamento de privilégios, e
veremos como isso funciona na arquitetura Intel 64.

6.1 Entrada e Saída


Quando estendemos a arquitetura von Neumann para funcionar com dispositivos externos, mencionamos as interrupções apenas como
forma de comunicação com eles. Na verdade, existe um segundo recurso, portas de entrada/saída (E/S), que o complementa e permite a
troca de dados entre CPU e dispositivos.
As aplicações podem acessar portas de E/S de duas maneiras:

1. Através de um espaço de endereço de E/S separado.

Existem 216 portas de E/S endereçáveis de 1 byte, de 0 a FFFFH. Os comandos in e out são usados para
troca de dados entre portas e registrador eax (ou suas partes).

As permissões para realizar gravações e leituras nas portas são controladas verificando:

• Campo IOPL (nível de privilégio de E/S) dos registros rflags

• Mapa de bits de permissão de E/S de um segmento de estado de tarefa. Falaremos sobre


isso na seção 6.1.1.

2. Através de E/S mapeada em memória.

Uma parte do espaço de endereço é mapeada especificamente para fornecer interação com dispositivos
externos que respondem como componentes de memória. Consecutivamente, quaisquer instruções de
endereçamento de memória (mov, movsb, etc.) podem ser usadas para realizar E/S com estes dispositivos.

Mecanismos padrão de segmentação e proteção de paginação são aplicados a essas tarefas de E/S.

© Igor Zhirkov 2017 91


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_6
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

O campo IOPL no registrador rflags funciona da seguinte forma: se o nível de privilégio atual for menor ou igual ao
IOPL, as seguintes instruções podem ser executadas:

• entrada e saída (entrada/saída normal).

• entradas e saídas (entrada/saída de string).

• cli e sti (limpar/definir sinalizador de interrupção).

Assim, definir IOPL em uma aplicação individualmente nos permite proibi-la de escrever mesmo que esteja funcionando
em um nível de privilégio mais alto que os aplicativos do usuário.
Além disso, o Intel 64 permite um controle de permissão ainda mais preciso por meio de um mapa de bits de permissão de E/S.
Se a verificação IOPL for aprovada, o processador verifica o bit correspondente à porta utilizada. A operação prossegue somente se
este bit não estiver definido.
O mapa de bits de permissão de E/S faz parte do Task State Segment (TSS), que foi criado para ser uma entidade exclusiva
de um processo. No entanto, como o mecanismo de troca de tarefas de hardware é considerado obsoleto, apenas um TSS (e mapa
de bits de permissão de E/S) pode existir no modo longo.

6.1.1 Registro TR e Segmento de Estado de Tarefa


Existem alguns artefatos do modo protegido que ainda são usados de alguma forma no modo longo. A segmentação é um exemplo,
agora usada principalmente para implementar anéis de proteção. Outro é um par de registro tr e estrutura de controle de segmento
de estado de tarefa .
O registro tr contém o seletor de segmento para o descritor TSS. Este último reside no GDT (Global
Tabela de Descritores) e possui formato semelhante aos descritores de segmento.
Da mesma forma para registradores de segmento, existe um registrador shadow, que é atualizado do GDT quando tr é
atualizado via instrução ltr (carregar registro de tarefa).
O TSS é uma região de memória usada para armazenar informações sobre uma tarefa na presença de um mecanismo de
hardware de troca de tarefas. Como nenhum sistema operacional popular o utilizou no modo protegido, esse mecanismo foi
removido do modo longo. No entanto, o TSS em modo longo ainda é utilizado, embora com uma estrutura e finalidade
completamente diferentes.
Atualmente existe apenas um TSS usado por um sistema operacional, com a estrutura descrita na Figura 6-1.

92
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

Figura 6-1. Segmento de estado de tarefa em modo longo

Os primeiros 16 bits armazenam um deslocamento para um mapa de permissão de porta de entrada/saída, que já discutimos na
seção 6.1. O TSS então contém oito ponteiros para tabelas especiais de pilha de interrupções (ISTs) e ponteiros de pilha para
diferentes anéis. Cada vez que um nível de privilégio é alterado, a pilha é automaticamente alterada de acordo. Normalmente, o novo
valor de rsp será retirado do campo TSS correspondente ao novo anel de proteção. O significado das IST é explicado na secção 6.2.

93
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

6.2 Interrupções
As interrupções nos permitem alterar o fluxo de controle do programa em um momento arbitrário. Enquanto o programa está em execução,
eventos externos (dispositivo requer atenção da CPU) ou eventos internos (divisão por zero, nível de privilégio insuficiente para executar
uma instrução, endereço não canônico) podem provocar uma interrupção, o que resulta na execução de algum outro código. Esse código
é chamado de manipulador de interrupção e faz parte de um sistema operacional ou software de driver.

Em [15], A Intel separa as interrupções assíncronas externas das exceções síncronas internas, mas ambas são tratadas da
mesma forma.
Cada interrupção é rotulada com um número fixo, que serve como seu identificador. Para nós não é importante
exatamente como o processador adquire o número de interrupção do controlador de interrupção.
Quando ocorre a enésima interrupção, a CPU verifica a Interrupt Descriptor Table (IDT), que reside em
memória. Analogamente ao GDT, seu endereço e tamanho são armazenados em idtr. A Figura 6-2 descreve o idtr.

Figura 6-2. registro idtr

Cada entrada no IDT ocupa 16 bytes e a enésima entrada corresponde à enésima interrupção. A entrada incorpora algumas
informações de utilidade, bem como um endereço do manipulador de interrupção. A Figura 6-3 descreve o formato do descritor de interrupção.

Figura 6-3. Descritor de interrupção

Nível de privilégio do descritor DPL

O nível de privilégio atual deve ser menor ou igual ao DPL para chamar esse manipulador usando a
instrução int. Caso contrário a verificação não ocorre.

Digite 1110 (gate de interrupção, IF é automaticamente apagado no manipulador) ou 1111 (trap gate, IF não é apagado).
As primeiras 30 interrupções são reservadas. Isso significa que você pode fornecer manipuladores de interrupção para eles, mas a
CPU os usará para seus eventos internos, como codificação de instruções inválidas. Outras interrupções podem ser usadas pelo
programador do sistema.
Quando o sinalizador IF é definido, as interrupções são tratadas; caso contrário, eles serão ignorados.

94
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

ÿ Pergunta 96 O que são interrupções não mascaráveis? Qual é a conexão deles com a interrupção com código 2 e
sinalizador IF ?

O código do aplicativo é executado com poucos privilégios (no ring3). O controle direto do dispositivo só é possível em níveis de privilégio
mais elevados. Quando um dispositivo requer atenção enviando uma interrupção para a CPU, o manipulador deve ser executado em um anel de
privilégio mais alto, exigindo assim a alteração do seletor de segmento.
E a pilha? A pilha também deve ser trocada. Aqui temos várias opções com base em como
configuramos o campo IST do descritor de interrupção.

• Se o IST for 0, o mecanismo padrão é usado. Quando ocorre uma interrupção, ss é


carregado com 0 e o novo rsp é carregado do TSS. O campo RPL de ss é então definido com um nível de privilégio
apropriado. Então os antigos ss e rsp são salvos nesta nova pilha.

• Se um IST for definido, um dos sete ISTs definidos no TSS será usado. A razão pela qual os ISTs são criados é
que algumas falhas graves (interrupções não mascaráveis, falha dupla, etc.) podem lucrar com a execução em uma
pilha conhecidamente boa. Portanto, um programador de sistema pode criar várias pilhas até mesmo para ring0 e usar
algumas delas para lidar com interrupções específicas.

Existe uma instrução int especial, que aceita o número da interrupção. Ele invoca um manipulador de interrupção manualmente em relação
ao conteúdo do seu descritor. Ele ignora o sinalizador IF: esteja definido ou desmarcado, o manipulador será invocado. Para controlar a execução de
código privilegiado usando a instrução int, existe um campo DPL.
Antes de um manipulador de interrupção iniciar sua execução, alguns registros são salvos automaticamente na pilha. Estes são ss, rsp, rflags,
cs e rip. Veja um diagrama de pilha na Figura 6-4. Observe como os seletores de segmento são preenchidos com zeros para 64 bits.

Figura 6-4. Empilhar quando um manipulador de interrupção é iniciado

95
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

Às vezes, um manipulador de interrupção precisa de informações adicionais sobre o evento. Um código de erro de
interrupção é então colocado na pilha. Este código contém diversas informações específicas para este tipo de interrupção.
Muitas interrupções são descritas usando mnemônicos especiais na documentação da Intel. Por exemplo, a 13ª interrupção
é chamada de #GP (proteção geral).1 Você encontrará uma breve descrição de algumas interrupções interessantes na Tabela 6-1.

Tabela 6-1. Algumas interrupções importantes

VETOR MNEMÔNICO DESCRIÇÃO

0 #DE Erro de divisão

2 Interrupção externa não mascarável


3 #BP Ponto de interrupção

6 #UD Opcode de instrução inválido


8 #DF Uma falha ao lidar com a interrupção
13 #GP Proteção geral

14 #PF Falha de página

Nem todo código binário corresponde a instruções de máquina codificadas corretamente. Quando rip não está endereçando uma
instrução válida, a CPU gera a interrupção #UD.
A interrupção #GP é muito comum. É gerado quando você tenta desreferenciar um endereço proibido (que não corresponde
a nenhuma página alocada), ao tentar realizar uma ação que requer um nível de privilégio maior e assim por diante.

A interrupção #PF é gerada ao endereçar uma página que tem seu flag atual desmarcado na entrada correspondente
da tabela de páginas. Esta interrupção é usada para implementar o mecanismo de troca e mapeamento de arquivos em geral.
O manipulador de interrupção pode carregar páginas ausentes do disco.
Os depuradores dependem muito da interrupção #BP. Quando o TF é configurado em rflags, a interrupção com este
código é gerada após a execução de cada instrução, permitindo a execução passo a passo do programa.
Evidentemente, esta interrupção é tratada por um sistema operacional. Portanto, é responsabilidade do sistema operacional fornecer uma interface
para aplicativos de usuário que permita aos programadores escrever seus próprios depuradores.
Resumindo, quando ocorre uma enésima interrupção, as seguintes ações são executadas do ponto de vista do programador:

1. O endereço IDT é obtido de idtr.

2. O descritor de interrupção está localizado a partir de 128 × n-ésimo byte do IDT.

3. O seletor de segmento e o endereço do manipulador são carregados da entrada IDT em cs e rip,


possivelmente alterando o nível de privilégio. Os antigos ss, rsp, rflags, cs e rip
são armazenados na pilha conforme mostrado na Figura 6-4.

4. Para algumas interrupções, um código de erro é colocado no topo da pilha do manipulador. Ele fornece
informações adicionais sobre a causa da interrupção.

5. Se o campo de tipo do descritor o definir como Interrupt Gate, o sinalizador de interrupção IF será
limpo. O Trap Gate, entretanto, não o limpa automaticamente, permitindo o tratamento de interrupções
aninhadas.

1
Veja a seção 6.3.1 do terceiro volume de [15]

96
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

Se o sinalizador de interrupção não for limpo imediatamente após o início do manipulador de interrupção, não poderemos ter nenhum
uma espécie de garantia de que executaremos até mesmo a primeira instrução sem que outra interrupção apareça de forma assíncrona
e exija nossa atenção.

ÿ Pergunta 97 O sinalizador TF é limpo automaticamente ao inserir manipuladores de interrupção? Consulte [15].

O manipulador de interrupção é finalizado por uma instrução iretq, que restaura todos os registros salvos na pilha, como
mostrado na Figura 6-4, em comparação com a instrução de chamada simples, que restaura apenas rip.

6.3 Chamadas do Sistema


As chamadas de sistema são, como você já sabe, funções que um sistema operacional fornece para aplicativos de usuário. Esta seção
descreve o mecanismo que permite sua execução segura com maior nível de privilégio.
Os mecanismos usados para implementar chamadas de sistema variam em diferentes arquiteturas. No geral, qualquer instrução
resultando em uma interrupção fará, por exemplo, divisão por zero ou qualquer instrução codificada incorretamente.
O manipulador de interrupção será chamado e então a CPU cuidará do resto. No modo protegido na arquitetura Intel, a interrupção com
código 0x80 foi utilizada pelos sistemas operacionais *nix. Cada vez que um usuário executava int 0x80, o manipulador de interrupção verificava
o conteúdo do registro em busca de números e argumentos de chamada do sistema.
As chamadas do sistema são bastante frequentes e você não pode realizar nenhuma interação com o mundo exterior sem
eles. As interrupções, entretanto, podem ser lentas, especialmente no Intel 64, pois requerem acesso à memória do IDT.
Portanto, no Intel 64 existe um novo mecanismo para realizar chamadas de sistema, que usa syscall e sysret
instruções para implementá-los.
Comparado às interrupções, este mecanismo tem algumas diferenças importantes:

• A transição só pode acontecer entre ring0 e ring3. Como praticamente ninguém usa
ring1 e ring2, esta limitação não é considerada importante.

• Os manipuladores de interrupção são diferentes, mas todas as chamadas do sistema são tratadas pelo mesmo código com apenas
um ponto de entrada.

• Alguns registradores de uso geral agora são usados implicitamente durante chamadas de sistema.

– rcx é usado para armazenar rip antigo

– r11 é usado para armazenar rflags antigos

6.3.1 Registros Específicos do Modelo


Às vezes, quando surge uma nova CPU, ela possui registros adicionais, que outras, mais antigas, não possuem. Muitas vezes estes são
os chamados Registros Específicos do Modelo. Quando esses registros raramente são modificados, sua manipulação é realizada por meio
de dois comandos: rdmsr para lê-los e wrmsr para alterá-los. Esses dois comandos operam no número de identificação do registrador.

rdmsr aceita o número MSR em ecx, retorna o valor do registro em edx:eax.


wrmsr aceita o número MSR em ecx e armazena o valor obtido de edx:eax nele.

6.3.2 syscall e sysret


A instrução syscall depende de vários MSRs.

97
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

• STAR (número MSR 0xC0000081), que contém dois pares de valores cs e ss: para manipulador de
chamada de sistema e para instrução sysret. A Figura 6-5 mostra sua estrutura.

Figura 6-5. MSR ESTRELA

• LSTAR (número MSR 0xC0000082) contém o endereço do manipulador de chamadas do sistema (novo rip).

• SFMASK (número MSR 0xC0000084) mostra quais bits em rflags devem ser limpos em
o manipulador de chamadas do sistema.

O syscall executa as seguintes ações:

• Carrega cs do STAR;

• Altera rflags em relação ao SFMASK;

• Salva rip em rcx; e

• Inicializa rip com valor LSTAR e obtém novos cs e ss de STAR.

Observe que agora podemos explicar por que as chamadas e procedimentos do sistema aceitam argumentos em conjuntos de
registradores ligeiramente diferentes. Os procedimentos aceitam seu quarto argumento em rcx, que, como sabemos, é usado para
armazenar o antigo valor rip.
Ao contrário das interrupções, mesmo que o nível de privilégio mude, o ponteiro da pilha deve ser alterado pelo próprio
manipulador.
O tratamento de chamadas do sistema termina com a instrução sysret, que carrega cs e ss do STAR e extrai do rcx.
Como sabemos, a mudança do seletor de segmento leva a uma leitura do GDT para atualizar seu registro de sombra
emparelhado . Porém, ao executar o syscall, esses shadow registradores são carregados com valores fixos e nenhuma leitura do
GDT é realizada.
Aqui estão esses dois valores fixos em forma decifrada:

• Registro de sombra de segmento de código:

–Base = 0

– Limite = FFFFFH

– Tipo = 112 (pode ser executado, foi acessado)

– S = 1 (Sistema)

– DPL = 0

–P=1

– L = 1 (modo longo)

–D=0

– G = 1 (sempre o caso no modo longo)

98
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

Além disso, o CPL (nível de privilégio atual) está definido como 0

• Registro de sombra do segmento de pilha:

–Base = 0

– Limite = FFFFFH

– Tipo = 112 (pode ser executado, foi acessado)

– S = 1 (Sistema)

– DPL = 0

–P=1

– L = 1 (modo longo)

–D=1

–G=1

Contudo, o programador do sistema é responsável por cumprir um requisito: o GDT deve ter o
descritores correspondentes a esses valores fixos.
Portanto, o GDT deve armazenar dois descritores específicos para código e dados especificamente para suporte a syscall.

6.4 Resumo
Neste capítulo, fornecemos uma visão geral das interrupções e dos mecanismos de chamada do sistema. Estudamos sua
implementação até as estruturas de dados do sistema que residem na memória. No próximo capítulo revisaremos diferentes
modelos de computação, incluindo máquinas de pilha semelhantes a Forth e autômatos finitos e, finalmente, trabalharemos
em um interpretador e compilador Forth em linguagem assembly.

ÿ Pergunta 98 O que é uma interrupção?

ÿ Pergunta 99 O que é IDT?

ÿ Pergunta 100 O que a configuração IF muda?

ÿ Pergunta 101 Em que situação ocorre o erro #GP?

ÿ Pergunta 102 Em quais situações ocorre o erro #PF?

ÿ Pergunta 103 Como o erro #PF está relacionado à troca? Como o sistema operacional o utiliza?

ÿ Pergunta 104 Podemos implementar chamadas de sistema usando interrupções?

ÿ Pergunta 105 Por que precisamos de uma instrução separada para implementar chamadas de sistema?

ÿ Pergunta 106 Por que o manipulador de interrupções precisa de um campo DPL?

ÿ Pergunta 107 Qual é a finalidade das tabelas de pilha de interrupções?

99
Machine Translated by Google

Capítulo 6 ÿ Interrupções e chamadas de sistema

ÿ Pergunta 108 Um aplicativo de thread único possui apenas uma pilha?

ÿ Pergunta 109 Que tipos de mecanismos de entrada/saída o Intel 64 oferece?

ÿ Pergunta 110 O que é um registro específico de modelo?

ÿ Pergunta 111 O que são os registros sombra?

ÿ Pergunta 112 Como os registros específicos do modelo são usados no mecanismo de chamada do sistema?

ÿ Pergunta 113 Quais registros são usados pela instrução syscall ?

100
Machine Translated by Google

CAPÍTULO 7

Modelos de Computação

Neste capítulo estudaremos dois modelos de computação: máquinas de estados finitos e máquinas de pilha.

Modelo de computação é semelhante à linguagem que você está usando para descrever a solução para um problema.
Normalmente, um problema que é realmente difícil de resolver corretamente em um modelo de computação pode ser quase trivial em
outro. Esta é a razão pela qual os programadores que conhecem muitos modelos diferentes de computação podem ser mais produtivos.
Eles resolvem problemas no modelo de computação mais adequado e depois implementam a solução com as ferramentas que têm à
disposição.
Quando você estiver tentando aprender um novo modelo de computação, não pense nele do “antigo” ponto de vista, como tentar pensar
em máquinas de estados finitos em termos de variáveis e atribuições. Tente começar do zero e construir logicamente o novo sistema de noções.

Já sabemos muito sobre o Intel 64 e seu modelo de computação, derivado do de von Neumann. Esse
O capítulo apresentará máquinas de estados finitos (usadas para implementar expressões regulares) e máquinas de pilha semelhantes à
máquina Forth.

7.1 Máquinas de Estados Finitos


7.1.1 Definição
Máquina determinística de estados finitos (autômato finito determinístico) é uma máquina abstrata que atua sobre uma string de entrada,
seguindo algumas regras.
Usaremos “autômatos finitos” e “máquinas de estado” de forma intercambiável. Para definir um autômato finito, as seguintes partes
devem ser fornecidas:

1. Um conjunto de estados.

2. Alfabeto – um conjunto de símbolos que pode aparecer na string de entrada.

3. Um estado inicial selecionado.

4. Um ou vários estados finais selecionados

5. Regras de transição entre estados. Cada regra consome um símbolo da string de entrada.
Sua ação pode ser descrita como: “se o autômato estiver no estado S e um símbolo de entrada C
ocorrer, o próximo estado atual será Z.”

Se o estado atual não possui regra para o símbolo de entrada atual, consideramos o comportamento do autômato indefinido.

O comportamento indefinido é um conceito conhecido mais pelos matemáticos do que pelos engenheiros. Por uma questão
Por uma questão de brevidade, estamos descrevendo apenas os “bons” casos. Os casos “ruins” não nos interessam, portanto não estamos
definindo o comportamento da máquina neles. Entretanto, ao implementar tais máquinas, consideraremos todos os casos indefinidos como
errôneos e levando a um estado de erro especial.

© Igor Zhirkov 2017 101


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_7
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Por que se preocupar com autômatos? Algumas tarefas são particularmente fáceis de resolver quando se aplica tal paradigma de
pensamento. Essas tarefas incluem controlar dispositivos embarcados e pesquisar substrings que correspondam a um determinado padrão.

Por exemplo, estamos verificando se uma string pode ser interpretada como um número inteiro. Vamos desenhar um
diagrama, mostrado na Figura 7-1. Define vários estados e mostra possíveis transições entre eles.

•O alfabeto consiste em letras, espaços, dígitos e sinais de pontuação.

•O conjunto de estados é {A, B, C}.

•O estado inicial é A.

•O estado final é C.

Figura 7-1. Reconhecimento de número

Iniciamos a execução a partir do estado A. Cada símbolo de entrada nos faz mudar o estado atual com base nas transições
disponíveis.

ÿ Nota Setas rotuladas com intervalos de símbolos como 0. . . 9 na verdade denotam múltiplas regras. Cada uma dessas regras descreve uma transição para um

único caractere de entrada.

A Tabela 7-1 mostra o que acontecerá quando esta máquina estiver sendo executada com uma string de entrada +34. Isso é
chamado de traço de execução.

Tabela 7-1. Rastreando uma máquina de estados finitos mostrada na Figura 7-1, a entrada é: +34

ESTADO ANTIGO REGRA NOVO ESTADO

A + B

B 3 C

C 4 C

A máquina chegou ao estado final C. No entanto, dada uma entrada idkfa, não poderíamos ter
chegou a qualquer estado, porque não existem regras para reagir a tais símbolos de entrada. É aqui que o comportamento do
autômato é indefinido. Para torná-lo total e sempre chegar ao estado sim ou ao estado não, temos que adicionar mais um estado final e
adicionar regras em todos os estados existentes. Essas regras devem direcionar a execução para o novo estado caso nenhuma regra antiga
corresponda ao símbolo de entrada.

102
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

7.1.2 Exemplo: Paridade de Bits


Recebemos uma sequência de zeros e uns. Queremos descobrir se existe um número par ou ímpar de unidades. A Figura 7-2
mostra o solucionador na forma de uma máquina de estados finitos.

Figura 7-2. O número de unidades é par na string de entrada?

A string vazia possui zero ; zero é um número par. Por causa disso, o estado A é tanto o estado inicial quanto o final.

Todos os zeros são ignorados, independentemente do estado. Porém, cada um que ocorre na entrada altera o estado para o
oposto. Se, dada uma string de entrada, chegarmos ao estado finito A, então o número de unidades é par. Se chegarmos ao estado
finito B, então é ímpar.

ÿ Confusão Em máquinas de estados finitos, não há memória, nem atribuições, nem construções se-então-senão.
Esta é, portanto, uma máquina abstrata completamente diferente em comparação com a de von Neumann. Na verdade, não
há nada além de estados e transições entre eles. No modelo de von Neumann, o estado é o estado da memória e dos
valores do registro.

7.1.3 Implementação em Linguagem Assembly


Depois de projetar uma máquina de estados finitos para resolver um problema específico, é trivial implementar esta máquina em uma
linguagem de programação imperativa como assembly ou C.
A seguir está uma maneira simples de implementar essas máquinas em montagem:

1. Torne total o autômato projetado: cada estado deve possuir regras de transição para qualquer símbolo
de entrada possível. Se não for esse o caso, adicione um estado separado para projetar um erro ou
uma resposta “não” ao problema que está sendo resolvido.

Para simplificar, chamaremos isso de regra else.

2. Implemente uma rotina para obter um símbolo de entrada. Tenha em mente que um símbolo não é
necessariamente um personagem: pode ser um pacote de rede, uma ação do usuário e outros tipos
de eventos globais.

3. Para cada estado devemos

• Crie uma etiqueta.

• Chame a rotina de leitura de entradas.

• Combine o símbolo de entrada com aqueles descritos nas regras de transição e salte para os
estados correspondentes se forem iguais.

• Trate todos os outros símbolos pela regra else.

103
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Para implementar o autômato exemplar em montagem, primeiro o tornaremos total, conforme mostrado na Figura 7-3

Figura 7-3. Verifique se a string é um número: um autômato total

Modificaremos um pouco esse autômato para forçar a string de entrada a ter terminação nula, conforme mostrado na Figura 7-4.
A Listagem 7-1 mostra um exemplo de implementação.

Figura 7-4. Verifique se a string é um número: um autômato total para uma string terminada em nulo

Listagem 7-1. automaton_example_bits.asm

seção .texto
; getsymbol é uma rotina para ; leia um
símbolo (por exemplo, de stdin); em tudo

_A:
chame getsymbol
cmp al, '+' je
_B
cmp al, '-' je _B

; Os índices dos caracteres de dígitos em ASCII; tabelas preenchem


um intervalo de '0' = 0x30 a '9' = 0x39
; Esta lógica implementa as transições para rótulos
; _E e _C cmp
al, '0' jb _E

104
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

cmp al, '9' e _E


jmp _C

_B:
chame getsymbol
cmp al, '0' jb
_E
cmp al, '9' ja
_E jmp
_C

_C:
chame getsymbol
cmp al, '0' jb
_E
cmp al, '9' ja
_E
teste al, al jz _D
jmp _C

_D: ; código para notificar sobre sucesso

_E: ; código para notificar sobre falha

Este autômato está chegando aos estados D ou E; o controle será passado para as instruções no rótulo _D ou _E.

O código pode ser isolado dentro de uma função retornando 1 (verdadeiro) no estado _D ou 0 (falso) no estado _E.

7.1.4 Valor Prático


Em primeiro lugar, existe uma limitação importante: nem todos os programas podem ser codificados como máquinas de estados
finitos. Este modelo de computação não é Turing completo, ele não pode analisar textos complexos construídos recursivamente, como
código XML.
C e linguagem assembly são Turing completos, o que significa que são mais expressivos e podem ser usados para resolver uma
gama mais ampla de problemas.
Por exemplo, se o comprimento da string não for limitado, não poderemos contar seu comprimento ou as palavras nela contidas.
Cada resultado teria sido um estado, e há apenas um número limitado de estados em máquinas de estados finitos, enquanto a contagem
de palavras pode ser arbitrariamente grande, assim como as próprias strings.

ÿ Questão 114 Desenhe uma máquina de estados finitos para contar as palavras na string de entrada. O comprimento de entrada

não é superior a oito símbolos.

As máquinas de estados finitos são frequentemente usadas para descrever sistemas embarcados, como máquinas de café. O
o alfabeto consiste em eventos (botões pressionados); a entrada é uma sequência de ações do usuário.

105
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Os protocolos de rede muitas vezes também podem ser descritos como máquinas de estados finitos. Cada regra pode ser anotada
com uma ação de saída opcional: “se um símbolo X for lido, mude o estado para Y e produza um símbolo Z.” A entrada consiste em
pacotes recebidos e eventos globais, como tempos limite; a saída é uma sequência de pacotes enviados.
Existem também várias técnicas de verificação, como a verificação de modelos, que permitem provar certas propriedades de
autômatos finitos – por exemplo, “se o autômato atingiu o estado B, ele nunca alcançará o estado C” . Essas provas podem ser de
grande valor na construção de sistemas que devem ser altamente confiáveis.

ÿ Questão 115 Desenhe uma máquina de estados finitos para verificar se há um número par ou ímpar de palavras na string de

entrada.

ÿ Questão 116 Desenhe e implemente uma máquina de estados finitos para responder se uma string deve ser aparada da
esquerda, da direita ou de ambas, ou se não deve ser aparada. Uma string deve ser cortada se começar ou terminar
com espaços consecutivos.

7.1.5 Expressões Regulares


Expressões regulares são uma forma de codificar autômatos finitos. Eles são frequentemente usados para definir padrões textuais a
serem comparados. Pode ser usado para procurar ocorrências de um padrão específico ou para substituí-las. Seu editor de texto
favorito provavelmente já os implementa.
Existem vários dialetos de expressões regulares. Tomaremos como exemplo um dialeto semelhante ao usado no utilitário
egrep.
Uma expressão regular R pode ser:

1. Uma carta.

2. Uma sequência de duas expressões regulares: R Q.


ˆ
3. Metassímbolos e $, combinando com o início e o fim da linha.

4. Um par de parênteses de agrupamento com uma expressão regular dentro: (R).

5. Uma expressão OR: R | Q.

6. R* denota zero ou mais repetições de R.

7. R+ denota uma ou mais repetições de R.

8. R? denota zero ou uma repetição de R.

9. Um ponto corresponde a qualquer caractere.

10. Os colchetes indicam uma série de símbolos, por exemplo [0-9] é equivalente a
(0|1|2|3|4|5|6|7|8|9).

Você pode testar expressões regulares usando o utilitário egrep. Ele processa sua entrada padrão e filtra apenas as linhas
que correspondem a um determinado padrão. Para evitar que seja processado pelo shell, coloque-o entre aspas simples como esta:
egrep 'expression'.
A seguir estão alguns exemplos de expressões regulares simples:

•hello .+ partidas contra hello Frank ou hello 12; não corresponde ao olá.

•[0-9]+ corresponde a um número inteiro sem sinal, possivelmente começando com zeros.

•-?[0-9]+ corresponde a um número inteiro possivelmente negativo, possivelmente começando com zeros.

•0|(-?[1-9][0-9]*) corresponde a qualquer número inteiro que não comece com zero (a menos que
é zero).

106
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Essas regras nos permitem definir um padrão de pesquisa complexo. O mecanismo de expressões regulares tentará
corresponder ao padrão começando com cada posição do texto.
Os mecanismos de expressão regular geralmente seguem uma destas duas abordagens:

•Usando uma abordagem direta, tentando combinar todas as sequências de símbolos descritas. Por exemplo,
combinar uma string ab com uma expressão regular aa?a?b pode resultar na seguinte sequência de eventos:

1. Tentando combinar com aaab – falha.

2. Tentando igualar aab – fracasso.

3. Tentando igualar ab - sucesso.

Portanto, estamos experimentando diferentes ramos de decisões até atingirmos uma decisão bem-
sucedida ou até vermos definitivamente que todas as opções levam ao fracasso.

Essa abordagem geralmente é bastante rápida e simples de implementar. No entanto, existe o pior
cenário em que a complexidade começa a crescer exponencialmente.
Imagine combinar uma string:

aaa...a (repetir n vezes)

contra uma expressão regular:

a?a?a?...a?aaa...a (repetir uma? n vezes, depois repetir n vezes)

A string fornecida certamente corresponderá à expressão regular. No entanto, ao aplicar uma


abordagem simples, o mecanismo terá que passar por todas as strings possíveis que correspondam
a esta expressão regular. Para isso, considerará duas opções possíveis para cada a? expressão, ou
seja, aqueles que contêm a e aqueles que não o contêm. Haverá 2n dessas strings. São tantos
quantos subconjuntos existem em um conjunto de n elementos. Você não precisa de mais símbolos
do que os existentes nesta linha de texto para escrever uma expressão regular, que um computador
moderno avaliará por dias ou até anos. Mesmo para um comprimento n = 50, o número de opções
atingirá 250 = 1125899906842624 opções.

Essas expressões regulares são chamadas de “patológicas” porque, devido à natureza do algoritmo
de correspondência, elas são tratadas de forma extremamente lenta.

•Construir uma máquina de estados finitos baseada numa expressão regular.

Geralmente é um NFA (Autômato Finito Não Determinístico). Ao contrário do DFA (Autômato Finito
Determinístico), eles podem ter múltiplas regras para o mesmo estado e símbolo de entrada. Quando tal
situação ocorre, o autômato realiza ambas as transições e passa a ter vários estados simultaneamente.
Em outras palavras, não existe um estado único, mas um conjunto de estados em que um autômato se
encontra.

Esta abordagem é um pouco mais lenta em geral, mas não apresenta o pior cenário com tempo de trabalho
exponencial. Utilitários Unix padrão, como grep, estão usando essa abordagem.

Como construir um NFA a partir de uma expressão regular? As regras são bastante
diretas:

– Um caractere corresponde a um autômato, que aceita uma string de um desses caracteres, conforme
mostrado na Figura 7-5.

– Podemos ampliar o alfabeto com símbolos adicionais, que colocamos no início


e final de cada linha.

107
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Figura 7-5. NFA para um personagem

ˆ
– Desta forma lidamos e $ como qualquer outro símbolo.

– Os parênteses de agrupamento permitem aplicar regras aos grupos de símbolos. Eles são usados
apenas para análise correta de expressões regulares. Em outras palavras, eles fornecem
as informações estruturais necessárias para uma correta construção do autômato.

– OR corresponde à combinação de dois NFAs fundindo o seu estado inicial. Figura 7-5
ilustra a ideia.

Figura 7-6. Combinando NFAs via OR

– Um asterisco tem uma transição para si mesmo e uma coisa especial chamada regra ÿ. Esta
regra ocorre sempre. A Figura 7-7 mostra o autômato para uma expressão a*b.

Figura 7-7. NFA: implementando asterisco

– ? é implementado de maneira semelhante a *. R+ é codificado como RR*.

108
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

ÿ Pergunta 117 Usando qualquer linguagem que você conheça, implemente um análogo do grep baseado na construção do NFA . Você

pode consultar [11] para obter informações adicionais.

ÿ Questão 118 Estude esta expressão regular: ˆ1?$|ˆ(11+?)\1+$. Qual pode ser o seu propósito? Imagine que a entrada é uma string

composta exclusivamente pelos caracteres 1 . Como o resultado dessa correspondência de expressão regular se correlaciona com o

comprimento da string?

7.2 Quarta Máquina


Forth é uma linguagem criada por Charles Moore em 1971 para o radiotelescópio de 11 metros operado pelo Observatório Nacional de
Radioastronomia (NRAO) em Kitt Peak, Arizona. Este sistema funcionava em dois primeiros minicomputadores unidos por um link
serial. Tanto um sistema multiprogramado quanto um sistema multiprocessador (em que ambos os computadores compartilhavam a
responsabilidade de controlar o telescópio e seus instrumentos científicos), ele controlava o telescópio, coletava dados e apoiava um terminal
gráfico interativo para interagir com o telescópio e analisar os dados registrados.

Hoje, Forth possui uma linguagem única e interessante, divertida de aprender e ótima para mudar a perspectiva. Ainda é utilizado,
principalmente em software embarcado, devido ao incrível nível de interatividade.
Forth também pode ser bastante eficiente.
Quartos intérpretes podem ser vistos em lugares como

•Carregador FreeBSD.

•Firmwares de robôs.

•Software embarcado (impressoras).

•Software de naves espaciais.

Portanto, é seguro chamar Forth de uma linguagem de programação de sistema.


Não é difícil implementar o interpretador e compilador Forth para Intel 64 em linguagem assembly. O restante deste capítulo explicará os
detalhes. Existem quase tantos dialetos do Forth quanto programadores do Forth; usaremos nosso próprio dialeto simples.

7.2.1 Arquitetura
Vamos começar estudando uma máquina abstrata Forth. Ele consiste em um processador, duas pilhas separadas para dados e endereços de
retorno e memória linear, conforme mostrado na Figura 7-8.

109
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Figura 7-8. Quarta máquina: arquitetura

As pilhas não devem necessariamente fazer parte do mesmo espaço de endereço de memória.
A máquina Forth possui um parâmetro chamado tamanho da célula. Normalmente, é igual ao tamanho da palavra de máquina do
arquitetura alvo. No nosso caso, o tamanho da célula é de 8 bytes. A pilha consiste em elementos do mesmo tamanho.
Os programas consistem em palavras separadas por espaços ou novas linhas. As palavras são executadas
consecutivamente. As palavras inteiras denotam o envio para a pilha de dados. Por exemplo, para inserir os números 42, 13 e 9 na
pilha de dados, você pode escrever simplesmente 42 13 9.
Existem três tipos de palavras:

1. Palavras inteiras, descritas anteriormente.

2. Palavras nativas, escritas em montagem.

3. Palavras de dois pontos, escritas em Forth como uma sequência de outras palavras Forth.

A pilha de retorno é necessária para poder retornar das palavras com dois pontos, como veremos mais tarde.
A maioria das palavras manipula a pilha de dados. De agora em diante, quando falarmos sobre a pilha em diante, consideraremos
implicitamente a pilha de dados, a menos que seja especificado de outra forma.
As palavras pegam seus argumentos da pilha e colocam o resultado lá. Todas as instruções que operam na pilha consomem
seus operandos. Por exemplo, as palavras +, -, * e / consomem dois operandos da pilha, realizam uma operação aritmética e colocam
*
seu resultado de volta na pilha. Um programa 1 4 8 8 + + calcula a expressão (8 + 8) * 4 + 1.

Seguiremos a convenção de que o segundo operando é retirado primeiro da pilha. Isso significa que o programa '1 2 -' é
avaliado como ÿ1, não 1.
A palavra: é usada para definir novas palavras. É seguido pelo nome da nova palavra e uma lista de outras palavras terminadas
pela palavra;. Ponto e vírgula e dois pontos são palavras por si só e, portanto, devem ser separados por espaços.

Uma palavra sq, que pega um argumento da pilha e empurra seu quadrado para trás, terá a seguinte aparência:

: quadrado dup * ;

Cada vez que usarmos sq no programa, duas palavras serão executadas: dup (célula duplicada no topo da pilha)
e * (multiplique duas palavras no topo da pilha).
Para descrever as ações da palavra em Forth é comum usar diagramas de pilha:

trocar (ab - ba)

110
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Entre parênteses você vê o estado da pilha antes e depois da execução do Word. As células da pilha são nomes para
destaque as alterações no conteúdo da pilha. Portanto, a palavra swap troca os dois elementos superiores da pilha.
O elemento superior está à direita, então o diagrama 1 2 corresponde a Forth empurrando primeiro 1, depois 2 como um
resultado da execução de algumas palavras.
rot coloca no topo o terceiro número da pilha:

podridão
(abc - bca)

7.2.2 Traçando um Programa Exemplar


A Listagem 7-2 mostra um programa simples para calcular o discriminante de uma equação quadrática 1x2 + 2x + 3 = 0.

Listagem 7-2. adiante_discr

: sq dup * : ;
**
discr rot 4 1 2 3 trocar sq trocar - ;
discr

Agora vamos executar discr abc passo a passo para alguns números a, b e c. O estado da pilha no final de cada
etapa é mostrado à direita.

(a)
ab (ab)
c (abc)

Então a palavra discr é executada. Estamos entrando nisso.

podridão (bca)
(bc4)
4 * (bc (a*4) )
*
( b (c * a * 4) )
trocar ( (c*a*4) b )
quadrado ( (c*a*4) (b*b) )
trocar ( (b*b) (c*a*4) )
-
((b*b - c*a*4) )

Agora fazemos o mesmo desde o início, mas para a = 1, b = 2 e c = 3.

1 (1)
2 (12)
3 (1 2 3)
apodrecer (2 3 1)
(2 3 1 4)
4 * (2 3 4)
*
(2 12)
trocar ( 12 2 )
quadrado (12 4)
trocar (4 12)
-
(-8)

111
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

7.2.3 Dicionário
Um dicionário faz parte de uma máquina Forth que armazena definições de palavras. Cada palavra é um cabeçalho seguido por uma
sequência de outras palavras.
O cabeçalho armazena o link para a palavra anterior (como em listas vinculadas), o próprio nome da palavra como uma string
terminada em nulo e alguns sinalizadores. Já estudamos uma estrutura de dados semelhante no trabalho descrito na seção 5.4. Você
pode reutilizar grande parte de seu código para facilitar a definição de novas palavras Forth. Veja a Figura 7-9 para o cabeçalho da
palavra gerado para a palavra discr descrita na seção 7.2.2

Figura 7-9. Cabeçalho do Word para discr

7.2.4 Como as palavras são implementadas


Existem três maneiras de implementar palavras.

•Código encadeado indireto

•Código encadeado direto

• Código encadeado de sub-rotina

Estamos usando um método clássico de código encadeado indireto. Este tipo de código necessita de duas células especiais (que
podemos chamar de registros Forth):

PC aponta para o próximo comando Forth. Veremos em breve que o comando Forth é o endereço de um
endereço do código de implementação do assembly da respectiva palavra.
Em outras palavras, este é um ponteiro para um código assembly executável com dois níveis de indireção.

W é usado em palavras não nativas. Quando a palavra inicia sua execução, esse registrador aponta
para sua primeira palavra.

Esses dois registradores podem ser implementados através de um uso real de registradores. Alternativamente, o seu conteúdo pode
ser armazenado na memória.
A Figura 7.10 mostra como as palavras são estruturadas ao usar a técnica de código encadeado indireto. Isto
incorpora duas palavras: uma palavra nativa dup e uma palavra dois pontos quadrado.

112
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Figura 7-10. Código encadeado indireto

Cada palavra armazena o endereço de sua implementação nativa (código assembly) imediatamente após o
cabeçalho. Para palavras com dois pontos a implementação é sempre a mesma: docol. A implementação é chamada usando a
instrução jmp.
Token de execução é o endereço desta célula, apontando para uma implementação. Portanto, um token de execução é o
endereço de um endereço da palavra implementação. Em outras palavras, dado o endereço A de uma entrada de palavra no
dicionário, você pode obter seu token de execução simplesmente adicionando o tamanho total do cabeçalho a A.
A Listagem 7-3 nos fornece um exemplo de dicionário. Ele contém duas palavras nativas (começando com w_plus e
w_dup) e uma palavra com dois pontos (w_sq).

Listagem 7-3. adiante_dict_sample.asm

seção .dados
w_plus:
dq 0 ; O ponteiro da primeira palavra para a palavra anterior é zero
db '+',0
banco ; Sem bandeiras
de dados 0 xt_plus: ; Token de execução para `plus`, igual a
; o endereço de sua implementação
dq plus_impl
w_dup:
dq w_plus
banco de dados 'dup', 0
banco de dados 0

xt_dup:
dq dup_impl
w_duplo:
dq w_dup
banco de dados 'duplo', 0
banco de dados 0

dq docol ; O endereço `docol` - um nível de indireção


dq xt_dup ; As palavras que consistem em `dup` começam aqui.

113
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

dq xt_plus dq
xt_exit

last_word: dq w_double seção .text


plus_impl: pop rax

add rax, [rsp]


mov [rsp],
rax jmp próximo
dup_impl: push qword
[rsp] jmp
próximo

O núcleo do mecanismo Forth é o interpretador interno. É uma rotina de montagem simples que busca código de
memória. Isso é mostrado na Listagem 7-4.

Listagem 7-4. adiante_próximo.asm

próximo:

mov w, pc
adicionar pc, 8 ; o tamanho da célula é 8 bytes mov w,
[w] jmp [w]

Ele faz duas coisas:

1. Ele lê a memória começando no PC e configura o PC para a próxima instrução. Lembre-se, aquele PC aponta
para uma célula de memória, que armazena o token de execução de uma palavra.

2. Ele configura W para o valor do token de execução. Em outras palavras, após a execução do next, W armazena
o endereço de um ponteiro para a implementação do assembly da palavra.

3. Finalmente, salta para o código de implementação.

Cada implementação de palavra nativa termina com a instrução jmp next. Garante que o próximo
a instrução será buscada.
Para implementar palavras com dois pontos, precisamos usar uma pilha de retorno para salvar e restaurar o PC antes e depois de uma
chamada.

Embora W não seja útil ao executar palavras nativas, é muito importante para palavras com dois pontos. Vamos levar
uma olhada em docol, a implementação de todas as palavras com dois pontos, mostrada na Listagem 7-5. Ele também apresenta exit, outra palavra
projetada para terminar todas as palavras com dois pontos.

Listagem 7-5. adiante_docol.asm

docol:
sub rstack, 8 mov
[rstack], pc add w, 8 ;
mov pc, w jmp próximo 8

114
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

saída:
mov pc, [rstack]
adicione rstack,
8 jmp próximo

docol salva o PC na pilha de retorno e configura o novo PC para o primeiro token de execução armazenado dentro do
palavra atual. O retorno é realizado por exit, que restaura o PC da pilha.
Este mecanismo é semelhante a um par de instruções call/ret.

ÿ Pergunta 119 Leia [32]. Qual é a diferença entre nossa abordagem (código encadeado indireto) e código
encadeado direto e código encadeado de sub-rotina? Que vantagens e desvantagens você pode citar?

Para compreender melhor o conceito de código encadeado indireto e as entranhas do Forth, preparamos
um exemplo mínimo mostrado na Listagem 7-6. Ele utiliza rotinas desenvolvidas na primeira tarefa da seção 2.7.
Não tenha pressa para iniciá-lo (o código-fonte é enviado com o livro) e verifique se ele realmente lê um
palavra da entrada e a envia de volta.

Listagem 7-6. itc.asm

%incluir "lib.inc"

_início global

% definir pc r15 %
definir w r14 %
definir rstack r13

seção .bss
resq 1023
rstack_start: resq 1
input_buf: resb 1024

seção .texto

; esta célula é o programa main_stub:


dq xt_main

; O dicionário começa aqui


; A primeira palavra é mostrada por extenso
; Em seguida, omitimos sinalizadores e links entre nós por questões de brevidade
; Cada palavra armazena um endereço de sua implementação de montagem

; Elimina o elemento superior da pilha dq 0 ; Não há nó


anterior db "drop", 0 db 0 ; Flags = 0
xt_drop: dq
i_drop i_drop:
adicione rsp, 8 jmp
próximo

115
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

; Inicializa os registradores
xt_init: dq i_init i_init:
mov
rstack, rstack_start mov pc,
main_stub jmp next

; Salva o PC quando a palavra com dois


pontos começa xt_docol:
dq
i_docol i_docol:
sub rstack, 8 mov
[rstack],
pc add w,
8 mov pc, w jmp next

; Retorna da palavra com dois


pontos xt_exit: dq
i_exit
i_exit: mov pc,
[rstack] add
rstack, 8 jmp next

; Pega um ponteiro de buffer da pilha


; Lê uma palavra da entrada e a armazena;
começando no buffer fornecido
xt_word: dq i_word
i_word:
pop rdi
call read_word
push rdx
jmp next
; Pega um ponteiro para uma string da pilha; e
imprime xt_prints:
dq i_prints i_prints: pop rdi
call

print_string jmp next

; Sai do programa
xt_bye: dq i_bye
i_bye:
mov rax, 60
xor rdi, rdi
syscall

; Carrega o endereço do buffer predefinido


xt_inbuf: dq i_inbuf
i_inbuf:
push qword input_buf
jmp next

116
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

; Esta é uma palavra com dois pontos, ela armazena


; tokens de execução. Cada token
; corresponde a uma quarta palavra a ser
; executado
xt_main: dq i_docol
dq xt_inbuf
dq xt_palavra
dq xt_drop
dq xt_inbuf
dq xt_prints
dq xt_tchau

; O intérprete interno. Estas três linhas


; buscar a próxima instrução e iniciar sua
; execução
próximo:

mov w, [pc]
adicionar computador, 8

jmp [w]

; O programa inicia a execução a partir da palavra inicial


_start: jmp i_init

7.2.5 Compilador
Forth pode funcionar no modo intérprete ou compilador. O intérprete apenas lê comandos e os executa.
Ao executar dois pontos: palavra, Forth muda para o modo de compilador. Além disso, os dois pontos : lê a próxima
palavra e a usa para criar uma nova entrada no dicionário com docol como implementação. Em seguida, Forth lê as palavras,
localiza-as no dicionário e as adiciona à palavra atual que está sendo definida.
Então, temos que adicionar outra variável aqui, que armazena o endereço da posição atual para escrever
palavras em modo de compilação. Cada gravação avançará aqui uma célula.
Para sair do modo compilador, precisamos de palavras imediatas especiais. Eles são executados independentemente
do modo em que estamos. Sem eles nunca seríamos capazes de sair do modo de compilador. As palavras imediatas são
marcadas com uma bandeira imediata.
O intérprete coloca números na pilha. O compilador não pode incorporá-los diretamente em palavras, porque
caso contrário, serão tratados como tokens de execução. Tentando iniciar um comando por um token de execução 42
certamente resultará em uma falha de segmentação. Porém, a solução é usar uma palavra especial acesa seguida do próprio
número. O objetivo do lit é ler o próximo inteiro para o qual o PC aponta e avançar o PC mais uma célula, de modo que o PC
nunca aponte para o operando incorporado.

7.2.5.1 Quarta Condicional


Faremos com que duas palavras se destaquem em nosso dialeto Forth: branch n e 0branch n. Eles só são permitidos no modo
de compilação!
Eles são semelhantes a lit n porque o deslocamento é armazenado imediatamente após seu token de execução.

117
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

7.3 Tarefa: Quarto Compilador e Intérprete


Esta seção descreverá uma grande tarefa: escrever seu próprio intérprete Forth.
Antes de começarmos, certifique-se de ter entendido os fundamentos da linguagem Forth. Se você não tem certeza disso,
você pode brincar com qualquer intérprete gratuito do Forth, como o gForth.

ÿ Pergunta 120 Procure na documentação os comandos sete, setl e seus equivalentes.

ÿ Pergunta 121 O que a instrução cqo faz ? Consulte [15].

É conveniente armazenar PC e W em alguns registradores de uso geral, especialmente aqueles que são
garantido para sobreviver às chamadas de função inalteradas (salvas pelo chamador): r13, r14 ou r15.

7.3.1 Dicionário Estático, Intérprete


Começaremos com um dicionário estático de palavras nativas. Adapte o conhecimento que você recebeu na seção 5.4.
De agora em diante não podemos definir novas palavras em tempo de execução.

Para esta tarefa usaremos as seguintes definições de macro:

•nativo, que aceita três argumentos:

– Nome da palavra;

– Uma parte do identificador da palavra; e

- Bandeiras.

Ele cria e preenche o cabeçalho em .data e um rótulo em .text. Este rótulo denotará o código assembly após a instância da macro.

Como a maioria das palavras não usa sinalizadores, podemos sobrecarregar o nativo para aceitar dois ou três argumentos. Para fazer
isso, criamos uma definição de macro semelhante que aceita dois argumentos e inicia o nativo com três argumentos, sendo o terceiro
substituído por zero e os dois primeiros passados como estão, conforme mostrado na Listagem 7-7 .

Listagem 7-7. Native_overloading.asm

%macro nativo 2 nativo

%1, %2, 0 %endmacro

Compare duas maneiras de definir o dicionário Forth: sem macros (mostradas na Listagem 7-8) e com elas (mostradas na Listagem 7-9).

Listagem 7-8. adiante_dict_example_nomacro.asm

seção .dados
w_plus: dq
w_mul ; banco de dados anterior
'+',0 banco de
dados 0

xt_plus: dq
plus_impl

118
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

seção .texto
mais_impl:
pop rax
adicione [rsp], rax
jmp próximo

Listagem 7-9. adiante_dict_example_macro.asm

nativo '+', mais


pop rax
adicione [rsp], rax
jmp próximo

Em seguida, defina um macro dois pontos, análogo ao anterior. A Listagem 7.10 mostra seu uso.

Listagem 7-10. adiante_colon_usage.asm

dois pontos '>', maior


dq xt_swap
dq xt_less
saída dq

Não se esqueça do endereço docol em cada palavra com dois pontos! Em seguida, crie e teste as seguintes rotinas de
montagem:

•find_word, que aceita um ponteiro para uma string terminada em nulo e retorna o endereço do início do cabeçalho
da palavra. Se não houver nenhuma palavra com esse nome, zero será retornado.

•cfa (código do endereço), que pega a palavra header start e pula todo o cabeçalho até atingir o valor
XT .

Usando essas duas funções e aquelas que você já escreveu na seção 2.7, você pode escrever um loop de intérprete. O
intérprete colocará um número na pilha ou preencherá o stub especial, que consiste em duas células, mostrado na Listagem 7.11.

Ele deve gravar o token de execução recém-encontrado em program_stub. Em seguida, ele deve apontar o PC para o
início do stub e pular para o próximo. Ele executará a palavra que acabamos de analisar e então passará o controle de volta ao
intérprete.
Lembre-se de que um token de execução é apenas o endereço de um endereço de um código assembly. É por isso que
a segunda célula do stub aponta para a terceira, e a terceira armazena o endereço do intérprete - simplesmente alimentamos esses
dados no maquinário Forth existente.

Listagem 7-11. adiante_program_stub.asm

esboço_do programa: dq 0
xt_interpretador: dq.interpretador
.interpretador: dq interpreter_loop

A Figura 7-11 mostra o pseudocódigo que ilustra a lógica do interpretador.

119
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

Figura 7-11. Quarto intérprete: pseudocódigo

Lembre-se de que a máquina Forth também possui memória. Vamos pré-alocar 65536 células Forth para isso.

ÿ Pergunta 122 Devemos alocar essas células na seção .data ou existem opções melhores?

Para que Forth saiba onde está a memória, vamos criar a palavra mem, que simplesmente pressionará o botão
endereço inicial da memória no topo da pilha.

7.3.1.1 Lista de palavras

Você deve primeiro criar um intérprete que suporte as seguintes palavras:

•.S – imprime todo o conteúdo da pilha; não muda isso. Para implementá-lo, salve o rsp antes
início do intérprete.

•Aritmética: + - *
/, = <. As operações de comparação colocam 1 ou 0 no topo do
pilha.

•Lógica: e, não. Todos os valores diferentes de zero são considerados verdadeiros; o valor zero é considerado falso.
Em caso de sucesso, essas instruções pressionam 1, caso contrário, 0. Elas também destroem
seus operandos.

•Manipulações simples de pilha:

podridão (abc - bca)


trocar (ab - ba)
dup (a - aa)
soltar (a -- )

• . ( a -- ) retira o número da pilha e o gera.

120
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

•Entrada/saída:

key ( -- c) — lê um caractere de stdin; A célula superior da pilha armazena 8 bytes, é um código de


caracteres estendidos com zero.

emit ( c -- ) — grava um símbolo em stdout. número ( -- n ) —

lê um número inteiro assinado de stdin (garantido que caiba em uma célula).

•mem — armazena o endereço inicial da memória do usuário no topo da pilha.

•Trabalhando com memória:

! (dados de endereço -- ) — armazena dados da pilha começando no endereço.

c! (address char -- ) — armazena um único byte por endereço. @ (endereço

-- valor) — lê uma célula começando no endereço c@ (endereço -- charvalue)

— lê um único byte começando no endereço Em seguida, teste o interpretador resultante.

Em seguida, crie uma região de memória para a pilha de retorno e implemente docol e saia. Recomendamos alocar um
registro para apontar para o topo da pilha de retorno.
Implemente palavras de dois pontos ou superiores usando macro dois pontos e teste-as.

7.3.2 Compilação Agora vamos

implementar a compilação. Isso é fácil!

1. Precisamos alocar outras células 65536 Forth para a parte extensível do dicionário.

2. Adicione um estado de variável, que é igual a 1 no modo de compilação e 0 no modo de


interpretação.

3. Adicione uma variável aqui, que aponta para a primeira célula livre no dicionário pré-alocado
espaço.

4. Adicione uma variável last_word, que armazena o endereço da última palavra definida.

5. Adicione duas novas palavras com dois pontos, a saber, : e ;.

Cólon:

1: palavra ÿ stdin

2: Preencha o cabeçalho da nova palavra começando aqui. Não se esqueça de atualizá-lo!

3: Adicione o endereço docol imediatamente aqui; atualize aqui.

4: Atualize last_word.

5: estado ÿ 1;

6: Pule para o próximo.

121
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

O ponto e vírgula deve ser marcado como Imediato!

1: aqui ÿ XT da palavra exit ; atualize aqui.

2: estado ÿ 0;

3: Pule para o próximo.

6. Esta é a aparência do loop do compilador. Você pode implementá-lo separadamente ou misturar


com loop de intérprete que você já implementou.

1: loop do compilador:

2: palavra ÿ palavra de stdin

3: se a palavra estiver vazia então

4: saída

5: se a palavra estiver presente e tiver endereço addr então

6: xt ÿ cf a(endereço)

7: se a palavra estiver marcada como Imediata então

8: interpretar palavra

9: mais

10: [aqui] ÿ xt

11: aqui ÿ aqui + 8

12: mais

13: se a palavra for um número n então

14: se a palavra anterior era branch ou 0branch então

15: [aqui] ÿn

16: aqui ÿ aqui + 8

17: mais

18: [aqui] ÿ xt iluminado

19: aqui ÿ aqui + 8

20: [aqui] ÿn

21: aqui ÿ aqui + 8

22: mais

23: Erro: palavra desconhecida

Implemente 0branch e branch e teste-os (consulte a seção 7.3.3 para obter uma lista completa das palavras Forth com seus significados).

ÿ Pergunta 123 Por que precisamos de um caso separado para branch e 0branch?

122
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

7.3.3 Junto com Bootstrap


Podemos dividir o intérprete Forth em duas partes. O muito necessário é chamado de intérprete interno;
está escrito em assembly. Seu objetivo é buscar o próximo XT da memória. Esta é a próxima rotina, mostrada
na Listagem 7-4.
A outra parte é o interpretador externo, que aceita a entrada do usuário e compila a palavra para a
definição atual ou a executa imediatamente. O interessante é que esse intérprete pode ser definido como dois
pontos. Para conseguir isso, temos que definir algumas palavras adicionais do Forth.
Criamos Forthress, um dialeto Forth descrito neste capítulo. O interpretador e o compilador são
enviado com este livro também. Aqui está o conjunto completo de palavras conhecidas por Forthress.

• drop( a -- )

• trocar (ab - ba)

•dup(a -- aa )

• podridão (abc - bca)


•Aritmética:

– + (yx--[x + y])
– *
* ( yx -- [ x você] )

– / (yx--[x/y])

–% ( yx -- [ x mod y ] )

- (yx -- [x - y] )

•Lógica:

– não (a -- a' ) a' = 0 se a != 0 a' = 1 se a == 0

– =( ab -- c ) c = 1 se a == bc = 0 se a != b

•count( str -- len ) Aceita uma string terminada em nulo e calcula seu comprimento.
• . Elimina o elemento da pilha e o envia para stdout.

•.S Mostra o conteúdo da pilha. Não pop elementos.


•init Armazena a base da pilha de dados. É útil para .S.

•docol Esta é a implementação de qualquer palavra com dois pontos. O XT em si não é usado, mas
a implementação (conhecida como docol) é.

•exit Sai da palavra com dois pontos.

•>r Empurre da pilha de retorno para a pilha de dados.

•r> Sair da pilha de dados para a pilha de retorno.

•r@ Cópia não destrutiva do topo da pilha de retorno para o topo da pilha de dados.

•find( str -- header addr ) Aceita um ponteiro para uma string, retorna um ponteiro para o
cabeçalho da palavra no dicionário.

•cfa( word addr -- xt ) Converte o endereço inicial do cabeçalho da palavra no token de execução.

•emit( c -- ) Gera um único caractere para stdout.

123
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

•word( addr -- len ) Lê a palavra do stdin e a armazena começando no endereço addr.


O comprimento da palavra é colocado na pilha.

•number ( str -- num len) Analisa um número inteiro de uma string.

•prints ( addr -- ) Imprime uma string terminada em nulo.

•tchau sai da fortaleza

•syscall ( call num a1 a2 a3 a4 a5 a6 -- new rax ) Executa syscall O


os seguintes registros armazenam argumentos (de acordo com a ABI) rdi, rsi, rdx, r10, r8 e r9.

•branch <offset> Ir para um local. Localização é um deslocamento relativo ao final do argumento


Por exemplo:

|filial| 24 | <próximo comando>


ˆ
branch adiciona 24 a este endereço e o armazena no PC

•0branch <offset> Branch é uma palavra somente de compilação. Salte para um local se TOS = 0.
A localização é calculada de maneira semelhante. 0branch é uma palavra somente de compilação.

•lit <valor> Envia um valor imediatamente após este XT.

•inbuf Endereço do buffer de entrada (é usado pelo interpretador/compilador).

•mem Endereço da memória do usuário.

•última palavra Cabeçalho do endereço da última palavra.

•estado Endereço da célula do estado. A célula de estado armazena 1 (modo de compilação) ou


0 (modo de interpretação).

•here Aponta para a última célula da palavra que está sendo definida atualmente.

•execute ( xt -- ) Executa a palavra com este token de execução no TOS.

•@ ( addr -- value ) Busca valor da memória.

•! (addr val --) Armazena valor por endereço.

•@c ( addr -- char ) Lê um byte começando em addr.


• , (x -- ) Adicione x à palavra que está sendo definida.

•c, ( c -- ) Adiciona um único byte à palavra que está sendo definida.

•create (flags name -- ) Cria uma entrada no dicionário cujo nome é o novo
nome. Apenas a sinalização imediata é implementada no ATM.

•: Leia a palavra do stdin e comece a defini-la.

•; Terminar a definição atual da palavra

•intérprete Intérprete/compilador Forthress.

Nós encorajamos você a tentar construir seu próprio Forth inicializado. Você pode começar com um intérprete ativo
loop escrito em Forth. Modifique o arquivo itc.asm, mostrado na Listagem 7-6, introduzindo a palavra interpreter
e escrevê-lo usando apenas palavras Forth.

124
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

7.4 Resumo
Este capítulo nos apresentou dois novos modelos de computação: máquinas de estados finitos (também conhecidas
como autômatos finitos) e máquinas de pilha semelhantes à máquina Forth. Vimos a conexão entre máquinas de
estados finitos e expressões regulares, usadas em vários editores de texto e outros utilitários de processamento de texto.
Concluímos a primeira parte de nossa jornada construindo um interpretador e compilador Forth, que consideramos
um resumo maravilhoso de nossa introdução à linguagem assembly. No próximo capítulo, mudaremos para a
linguagem C para escrever código de nível superior. Seu conhecimento de montagem servirá como base para sua
compreensão de C devido ao quão próximo seu modelo de computação é do modelo clássico de computação de von
Neumann.

ÿ Pergunta 124 O que é um modelo de computação?

ÿ Pergunta 125 Que modelos de computação você conhece?

ÿ Pergunta 126 O que é uma máquina de estados finitos?

ÿ Pergunta 127 Quando as máquinas de estados finitos são úteis?

ÿ Pergunta 128 O que é um autômato finito?

ÿ Pergunta 129 O que é uma expressão regular?

ÿ Pergunta 130 Como as expressões regulares e os autômatos finitos estão conectados?

ÿ Pergunta 131 Qual é a estrutura da quarta máquina abstrata?

ÿ Pergunta 132 Qual é a estrutura do dicionário em Forth?

ÿ Pergunta 133 O que é um token de execução?

ÿ Pergunta 134 Qual é a diferença de implementação entre palavras incorporadas e dois pontos?

ÿ Pergunta 135 Por que duas pilhas são usadas em Forth?

ÿ Pergunta 136 Quais são os dois modos distintos em que Forth está operando?

ÿ Pergunta 137 Por que existe o sinalizador imediato?

ÿ Pergunta 138 Descreva a palavra dois pontos e a palavra ponto e vírgula.

ÿ Pergunta 139 Qual é a finalidade dos registradores PC e W ?

ÿ Pergunta 140 Qual é o propósito do próximo passo?

ÿ Pergunta 141 Qual é a finalidade do docol?

ÿ Pergunta 142 Qual é o propósito da saída?

ÿ Pergunta 143 Quando um literal inteiro é encontrado, o interpretador e o compilador se comportam da mesma forma?

ÿ Pergunta 144 Adicione uma palavra incorporada para verificar o restante de uma divisão de dois números. Escreva uma palavra

para verificar se um número é divisível por outro.

125
Machine Translated by Google

Capítulo 7 ÿ Modelos de Computação

ÿ Pergunta 145 Adicione uma palavra incorporada para verificar o restante de uma divisão de dois números. Escreva uma palavra para

verificar a prioridade do número.

ÿ Pergunta 146 Escreva uma palavra Forth para gerar o primeiro n número da sequência de Fibonacci.

ÿ Pergunta 147 Escreva uma palavra Forth para realizar chamadas de sistema (ela pegará o conteúdo do registrador da pilha).

Escreva uma palavra que imprimirá “Olá, mundo!” em saída padrão.

126
Machine Translated by Google

PARTE II

A linguagem de programação C
Machine Translated by Google

CAPÍTULO 8

Fundamentos

Neste capítulo começaremos a explorar outra linguagem chamada C. É uma linguagem de baixo nível com abstrações mínimas
sobre assembly. Ao mesmo tempo, é suficientemente expressivo para que possamos ilustrar alguns conceitos e ideias muito gerais
aplicáveis a todas as linguagens de programação (como sistema de tipos ou polimorfismo).

C quase não fornece abstração sobre a memória, portanto a tarefa de gerenciamento de memória é de responsabilidade do
programador. Ao contrário de linguagens de nível superior, como C# ou Java, o programador deve alocar e liberar ele mesmo a
memória reservada, em vez de depender de um sistema automatizado de coleta de lixo.
C é uma linguagem portátil, portanto, se você escrever corretamente, seu código poderá muitas vezes ser
executado em outras arquiteturas após uma simples recompilação. A razão é que o modelo de computação em C é praticamente o
mesmo velho modelo de von Neumann, o que o aproxima dos modelos de programação da maioria dos processadores.
Ao aprender C lembre-se que apesar da ilusão de ser uma linguagem de nível superior, ela não tolera
erros, nem o sistema será gentil o suficiente para sempre notificá-lo sobre coisas em seu programa que foram quebradas. Um
erro pode aparecer muito mais tarde, em outra entrada, em uma parte completamente irrelevante do programa.

ÿ Padrão de linguagem descrito O documento muito importante sobre a linguagem é o padrão da linguagem C.
Você pode adquirir um arquivo PDF do rascunho padrão online gratuitamente [7]. Este documento é tão importante para
nós quanto o Manual do desenvolvedor de software Intel [15].

8.1 Introdução
Antes de começarmos, precisamos declarar vários pontos importantes.

•C sempre faz distinção entre maiúsculas e minúsculas.

•C não se preocupa com espaçamento, desde que o analisador possa separar lexemas uns dos outros. Os
programas mostrados na Listagem 8-1 e na Listagem 8-2 são equivalentes.

Listagem 8-1. espaçamento_1.c

int principal (int argc, char * * argv)


{
retornar 0;
}

© Igor Zhirkov 2017 129


I. Zhirkov, Programação de Baixo Nível, DOI 10.1007/978-1-4842-2403-8_8
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Listagem 8-2. espaçamento_2.c

int principal(int argc, char** argv)


{
retornar 0;
}

•Existem diferentes padrões de linguagem C. Não estudamos o GNU C (uma versão que possui
diversas extensões), que é suportado principalmente pelo GCC. Em vez disso, nos
concentramos em C89 (também conhecido como ANSI C ou C90) e C99, que são suportados por
muitos compiladores diferentes. Mencionaremos também vários novos recursos do C11, alguns
dos quais não são obrigatórios para implementação em compiladores.

Infelizmente, o C89 ainda continua sendo o padrão mais difundido, portanto, existem
compiladores que suportam o C89 para praticamente todas as plataformas existentes. É por isso que
vamos nos concentrar primeiro nesta revisão específica e depois estendê-la com os recursos mais recentes.

Para forçar o compilador a usar apenas os recursos suportados por um determinado padrão,
usamos o seguinte conjunto de flags:

– -std=c89 ou -std=c99 para selecionar o padrão C89 ou C99.

– -pedantic-errors para desativar extensões de idioma não padrão.

– -Parede para mostrar todos os avisos, por mais importantes que sejam.

– -Werror para transformar avisos em erros para que você não consiga compilar o código com
avisos.

ÿ Avisos são erros É uma prática muito ruim enviar código que não é compilado sem avisos.

Os avisos são emitidos por um motivo.

Às vezes há casos muito específicos em que as pessoas são forçadas a fazer coisas fora do padrão, como chamar uma função com

mais argumentos do que ela aceita, mas tais casos são extremamente raros. Nestes casos é muito melhor desligar um tipo de aviso

específico para um arquivo específico através de uma chave de compilador correspondente. Às vezes, as diretivas do compilador

podem fazer com que o compilador omita um determinado aviso para uma região de código selecionada, o que é ainda melhor.

Por exemplo, para compilar um arquivo executável main a partir dos arquivos de origem file1.c e file2.c você pode
usar o seguinte comando:

> gcc -o principal -ansi -pedantic-errors -Wall -Werror arquivo1.c arquivo2.c

Este comando fará uma compilação completa, incluindo geração e vinculação de arquivos de objeto.

8.2 Estrutura do Programa


Qualquer programa em C consiste em

•Definições de tipos de dados (estruturas, novos tipos, etc.) que se baseiam em outros tipos
existentes. Por exemplo, podemos criar um novo nome new_int_type_name_t para um tipo
inteiro int.

typedef int new_int_type_name_t;


130
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

•Variáveis globais (declaradas fora das funções). Por exemplo, podemos criar um sistema global
variável i_am_global do tipo int inicializada com 42 fora de todos os escopos de função. Observe que
variáveis globais só podem ser inicializadas com valores constantes.

int i_am_global = 42;

•Funções. Por exemplo, uma função chamada quadrado, que aceita um argumento x de
digite int e retorna seu quadrado.

*
int quadrado(int x) {retorna x x; }

•Comentários entre /* e */.

/* este é um comentário bastante complexo


que se estendem por várias linhas */

•Comentários começando em // até o final da linha (em C99 e mais recentes).

interno x; // este é um comentário de uma linha, que termina no final da linha

•Diretrizes de pré-processador e compilador. Eles geralmente começam com #.

#define CATS_COUNT 42
#define ADD(x, y) (x) + (y)

Dentro das funções, podemos definir variáveis ou tipos de dados locais para esta função, ou executar ações. Cada ação
é uma afirmação; geralmente são separados por ponto e vírgula. As ações são executadas sequencialmente.
Você não pode definir funções dentro de outras funções.
As instruções declararão variáveis, realizarão cálculos e atribuições e executarão diferentes ramos de código
dependendo das condições. Um caso especial é um bloco entre chaves {}, que é usado para agrupar instruções.

A Listagem 8-3 mostra um programa C exemplar. Ele gera Olá, mundo! y=42 x=43. Ele define uma função main, que
declara duas variáveis x e y, a primeira é igual a 43 e a segunda é calculada como o valor de x menos um. Em seguida, uma
chamada à função printf é executada.
A função printf é usada para gerar strings em stdout. A string tem algumas partes (chamadas de formato
especificadores) substituídos pelos seguintes argumentos. O especificador de formato, como o próprio nome sugere,
fornece informações sobre a natureza do argumento, que geralmente inclui seu tamanho e presença de sinal. Por enquanto,
usaremos poucos especificadores de formato.

•%d para argumentos int, como no exemplo.

•%f para argumentos flutuantes.

Declarações de variáveis, atribuições e chamadas de função, todas terminadas com ponto e vírgula, são instruções.

ÿ Printf sobressalente para saída de formato Sempre que possível, use puts em vez de printf. Esta função só pode gerar uma
única string (e termina com uma nova linha); nenhum especificador de formato é levado em consideração. Não só é mais rápido,

mas funciona uniformemente com todas as strings e não apresenta as falhas de segurança descritas na seção 14.7.3.

131
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Por enquanto, sempre iniciaremos nossos programas com a linha #include <stdio.h>. Ele nos permite acessar uma parte da
biblioteca C padrão. No entanto, afirmamos firmemente que esta não é uma importação de qualquer tipo de biblioteca e nunca deve
ser tratada como tal.

Listagem 8-3. olá.c

/* Isto é um comentário. A próxima linha possui uma diretiva de pré-processador */


#include <stdio.h>

/* `main` é o ponto de entrada para o programa, como _start em assembly


* Na verdade, a função oculta _start está chamando `main`.
* `main` retorna o `código de retorno` que é então fornecido ao sistema `exit`
* chamar.
* A palavra-chave `void` em vez da lista de argumentos significa que `main` não aceita

*
argumentos */
int principal(void) {
/* Uma variável local para `main`. Será destruído assim que `main` terminar*/
interno x = 43;
interno;
y =x - 1;
/* Chamando uma função padrão `printf` com três argumentos.
* Irá imprimir 'Olá, mundo! y = 42 x = 43
* Todos os %d serão substituídos pelos argumentos consecutivos */
printf("Olá, mundo! y=%dx=%d\n", y, x);

retornar 0;
}

Literal é uma sequência de caracteres no código-fonte que representa um valor imediato. Em C,


literais existem para

•Inteiros, por exemplo, 42.

•Números de ponto flutuante, por exemplo, 42,0.

•Código ASCII de caracteres, escrito entre aspas simples, por exemplo, 'a'.

• Ponteiros para strings terminadas em nulo, por exemplo, "abcde".

A execução de qualquer programa C é essencialmente uma manipulação de dados.


A máquina abstrata C tem uma arquitetura von Neumann. Isso é feito propositalmente, pois C é uma linguagem
que deve estar o mais próxima possível do hardware. As variáveis são armazenadas na memória linear e cada uma delas possui
um endereço inicial.
Você pode pensar em variáveis como rótulos em assembly.

8.2.1 Tipos de Dados


Como praticamente tudo o que acontece é uma manipulação de dados, a natureza desses dados é de particular
interesse para nós. Todos os tipos de dados em C possuem um tipo, o que significa que eles se enquadram em uma das
categorias (geralmente) distintas. A digitação em C é fraca e estática.

132
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

A tipagem estática significa que todos os tipos são conhecidos em tempo de compilação. Pode haver absoluta incerteza sobre
tipos de dados. Esteja você usando uma variável, um literal ou uma expressão mais complexa, que avalia alguns dados, seu tipo
será conhecido.
Tipagem fraca significa que às vezes um elemento de dados pode ser convertido implicitamente para outro tipo quando
apropriado.
Por exemplo, ao avaliar 1 + 3,0, fica evidente que esses dois números têm tipos diferentes. Um deles é inteiro; o outro é um
número real. Você não pode adicionar diretamente um ao outro, porque a representação binária deles é diferente. Você precisa
convertê-los para o mesmo tipo (provavelmente, número de ponto flutuante).
Só então você poderá realizar uma adição. Em linguagens fortemente tipadas, como OCaml, esta operação não é permitida;
em vez disso, existem duas operações separadas para adicionar números: uma atua em números inteiros (e é escrita +), a outra em
números reais (é escrita +. em OCaml).
A digitação fraca está em C por uma razão: em assembly, é absolutamente possível pegar praticamente qualquer dado e
interpretá-lo como dados de outro tipo (ponteiro como um número inteiro, parte da string como um número inteiro, etc.)
Vamos ver o que acontece quando tentamos gerar um valor de ponto flutuante como um número inteiro (veja a Listagem 8-4). O
o resultado será o valor do ponto flutuante reinterpretado como um número inteiro, o que não faz muito sentido.

Listagem 8-4. float_reinterpret.c

#include <stdio.h>

int principal(void) {
printf("42.0 como um inteiro %d \n", 42.0);
retornar 0;
}

A saída deste programa depende da arquitetura alvo. No nosso caso, a saída foi

42,0 como um número inteiro -266654968

Para esta breve seção introdutória, consideraremos que todos os tipos em C se enquadram em uma destas categorias:

•Números inteiros (int, char, …).

•Números de ponto flutuante (duplo e flutuante).

•Tipos de ponteiro.

•Tipos compostos: estruturas e uniões.

•Enumerações.

No Capítulo 9 exploraremos o sistema de tipos com mais detalhes. Se você tiver experiência em uma linguagem de nível superior,
poderá encontrar alguns itens comumente conhecidos faltando neste bloco. Infelizmente, não há tipos string e booleanos no C89. Um
valor inteiro igual a zero é considerado falso; qualquer valor diferente de zero é considerado verdade.

8.3 Fluxo de Controle


De acordo com os princípios de von Neumann, a execução do programa é sequencial. Cada instrução é executada uma após a outra.
Existem várias instruções para alterar o fluxo de controle.

133
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

8.3.1 se
A Listagem 8-5 mostra uma instrução if com uma parte else opcional. Se a condição for satisfeita, o primeiro
bloco será executado. Se a condição não for satisfeita, o segundo bloco é executado, mas o segundo bloco
não é obrigatório.

Listagem 8-5. if_exemplo.c

interno x = 100;
if (42)
{ puts("42 não é igual a zero e portanto é considerado verdade");
}

if (x > 3)
{ puts("X é maior que 3");

}
outro {
coloca("X é menor que 3");
}

As chaves são opcionais. Sem colchetes, apenas uma instrução será considerada parte de cada ramificação, conforme
mostrado na Listagem 8-6.

Listagem 8-6. if_no_braces.c

if (x == 0)
puts("X é zero"); outro

puts("X não é zero");

Observe que há uma falha de sintaxe, chamada de dangling else. Verifique a Listagem 8.7 e veja se você
certamente pode atribuir o ramo else ao primeiro ou ao segundo if. Para resolver esta desambiguação no caso de ifs
aninhados, use colchetes.

Listagem 8-7. pendurado_else.c

if (x == 0) if (y == 0) { puts("A"); } else { coloca("B"); }

/* Você pode ter considerado uma das seguintes interpretações.


* O compilador pode emitir um aviso para impedir você */

if (x == 0) { if (y
== 0) { printf("A"); } else { coloca("B"); }

if (x == 0) { if (y
== 0) { puts("A"); } } else
{ coloca("B"); }

134
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

8.3.2 enquanto
Uma instrução while é usada para fazer ciclos.

Listagem 8-8. while_example.c

interno x = 10;
enquanto ( x != 0 ) {
coloca("Olá");
x=x-1;
}

Se a condição for satisfeita, o corpo será executado. Em seguida, a condição é verificada novamente e, se for
satisfeita, o corpo é executado novamente e assim por diante.
Uma forma alternativa do... while (condição); permite verificar as condições após a execução do corpo do loop,
garantindo assim pelo menos uma iteração. A Listagem 8-9 mostra um exemplo.
Observe que um corpo pode estar vazio, como segue: while (x == 0);. O ponto e vírgula após os parênteses
encerra esta instrução.

Listagem 8-9. do_while_example.c

interno x = 10;
fazer {
printf("Olá\n"); x=x-1;
}
enquanto ( x != 0 );

8.3.3 para
Uma instrução for é ideal para iterar coleções finitas, como listas vinculadas ou matrizes. Tem o seguinte formato:
for (inicializador; condição; etapa) corpo. A Listagem 8-10 mostra um exemplo.

Listagem 8-10. para_exemplo.c

int uma[] = {1, 2, 3, 4}; /* um array de 4 elementos */


int eu = 0;
para (eu = 0; eu < 4; eu++) {
printf("%d", a[i])
}

Primeiro, o inicializador é executado. Em seguida, há uma verificação de condição e, se for mantida, o corpo do
loop é executado e, em seguida, a instrução step.
Neste caso, a instrução step é um operador de incremento ++, que modifica uma variável aumentando seu valor
em um. Depois disso, o loop começa novamente verificando a condição e assim por diante. A Listagem 8-11 mostra
dois loops equivalentes.

135
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Listagem 8-11. while_for_equiv.c

int eu;

/* como um loop `while` */ i =


0; while
(i < 10) { puts("Olá!");
eu = eu + 1;

/* como um loop `for` */


for( i = 0; i < 10; i = i + 1 ) { puts("Olá!");

A instrução break é usada para encerrar o ciclo prematuramente e passar para a próxima instrução no código.
continue encerra a iteração atual e inicia a próxima iteração imediatamente. A Listagem 8-12 mostra um exemplo.

Listagem 8-12. loop_cont.c

interno n =
0; for( n = 0; n < 20; n++ ) { if (n %
2) continuar; printf("%d é
ímpar", n);
}

Observe também que no loop for, as expressões do inicializador, da etapa ou da condição podem ser deixadas vazias.
A Listagem 8-13 mostra um exemplo.

Listagem 8-13. infinito_para.c

for( ; ; ) { /* este
ciclo irá repetir para sempre, a menos que `break` seja emitido em seu corpo */ break; /*
`break` está aqui, então paramos de iterar */
}

8.3.4 goto Uma

instrução goto permite que você pule para um rótulo dentro da mesma função. Assim como no assembly, os rótulos
podem marcar qualquer instrução, e a sintaxe é a mesma: rótulo: instrução. Isso geralmente é descrito como um estilo
de código incorreto; entretanto, pode ser bastante útil ao codificar máquinas de estados finitos. O que você
não deve fazer é abandonar condicionais e loops bem pensados para goto-spaghetti.
A instrução goto às vezes é usada como uma forma de interromper vários ciclos aninhados. No entanto, isso
geralmente é um sintoma de um design ruim, porque os loops internos podem ser abstraídos dentro de uma função (graças às
otimizações do compilador, provavelmente sem nenhum custo de tempo de execução). A Listagem 8.14 mostra como usar
goto para romper todos os loops internos.

Listagem 8-14. ir para.c

int eu;
intj;
para (eu = 0; eu < 100; eu++ )

136
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

para( j = 0; j < 100; j++ ) {


se (eu * j == 432)
ir para o fim;
outro
printf("%d * %d != 432\n", i, j );
}
fim:

A instrução goto misturada com o estilo imperativo torna a análise do comportamento do programa mais difícil para humanos
e máquinas (compiladores), de modo que as otimizações cafonas que os compiladores modernos são capazes de realizar
tornam-se menos prováveis e o código se torna mais difícil de manter. Defendemos restringir o uso de goto aos trechos de código
que não realizam atribuições, como as implementações de máquinas de estados finitos. Desta forma você não terá que rastrear
todas as possíveis rotas de execução do programa e como os valores de certas variáveis mudam quando o programa é executado
de uma forma ou de outra.

8.3.5 interruptor
Uma instrução switch é usada como vários ifs aninhados quando a condição é alguma variável inteira igual a um ou outro
valor. A Listagem 8-15 mostra um exemplo.

Listagem 8-15. caso_exemplo.c

int eu = 10;
mudar (eu) {
caso 1: /* se i for igual a 1...*/
puts("É um");
quebrar; /* A pausa é obrigatória */

caso 2: /* se i for igual a 2...*/


puts("São dois");
quebrar;

padrão: /* caso contrário... */


puts("Não é um nem dois");
quebrar;
}

Cada caso é, na verdade, um rótulo. Os casos não são limitados por nada além de uma instrução break opcional
para sair do bloco switch. Ele permite alguns hacks interessantes.1 No entanto, uma pausa esquecida geralmente é uma
fonte de bugs. A Listagem 8.16 mostra esses dois comportamentos: primeiro, vários rótulos são atribuídos ao mesmo
caso, ou seja, não importa se x é 0, 1 ou 10, o código executado será o mesmo. Então, como o break não está
encerrando neste caso, após executar o primeiro printf o controle cairá para a próxima instrução denominada case 15, outro printf.

Listagem 8-16. case_magic.c

mudar ( x ) {
caso 0:
caso 1:
caso 10:
puts("Primeiro caso: x = 0, 1 ou 10");

1
Um dos hacks mais conhecidos é chamado de dispositivo de Duff e incorpora um ciclo que é definido dentro de um switch
e contém vários casos.

137
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

/* Observe a ausência de `break`! */


caso 15:
puts("Segundo caso: x = 0, 1, 10 ou 15");
quebrar;
}

8.3.6 Exemplo: Divisor


A Listagem 8.17 mostra um programa que procura o primeiro divisor, que é então impresso em stdout. A função
first_divisor aceita um argumento n e procura um inteiro r de 1 exclusivo a n inclusive, tal que n seja um múltiplo de r.
Se r = n, obviamente encontramos um número primo.
Observe como a instrução após for não foi colocada entre chaves porque é a única instrução dentro do loop. O
mesmo aconteceu com o corpo if, que consiste em um único retorno i. É claro que você pode colocá-lo entre colchetes,
e alguns programadores realmente o incentivam.

Listagem 8-17. divisor.c

#include <stdio.h>

int primeiro_divisor(int n) {
int eu;
se (n == 1) retornar 1;
para (eu = 2; eu <= n; eu++)
se (n% i == 0) retornar i;
retornar 0;
}

int principal(void) {
int eu;
para (eu = 1; eu < 11; eu++)
printf("%d \n", primeiro_divisor(i));

retornar 0;
}

8.3.7 Exemplo: É um número de Fibonacci?


A Listagem 8.18 mostra um programa que verifica se um número é um número de Fibonacci ou não. A série de Fibonacci
é definida recursivamente da seguinte forma:

f1 = 1
f2 = 1
fn = fnÿ1 + fnÿ2

Esta série possui um grande número de aplicações, principalmente em combinatória. Sequências de Fibonacci aparecem
mesmo em ambientes biológicos, como ramificações em árvores, disposição das folhas em um caule, etc.
Os primeiros números de Fibonacci são 1, 1, 2, 3, 5, 8, etc. Como você pode ver, cada número é a soma dos dois números
anteriores.
Para verificar se um determinado número n está contido em uma sequência de Fibonacci, adotamos
uma abordagem direta (não necessariamente ótima) de cálculo de todos os membros da sequência anteriores a n. O

138
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

A natureza de uma sequência de Fibonacci implica que ela é ascendente, portanto, se encontramos um membro maior
que n e ainda não enumeramos n, concluímos que n não está na sequência. A função is_fib aceita um inteiro n e
calcula todos os elementos menores ou iguais a n. Se o último elemento desta sequência for n, então n é um número
de Fibonacci e retorna 1; caso contrário, ele retornará 0.

Listagem 8-18. is_fib.c

#include <stdio.h>

int é_fib(int n) {

intuma = 1;
int b = 1; se
(n == 1) retornar 1;

enquanto (a <= n && b <= n) { int t =


b;

se (n == a || n == b) retornar 1; b = uma;
uma =
t + uma;

} retornar 0;

void check(int n) { printf( "%d -> %d\n", n, is_fib( n ) ); }

int principal(void)
{ int i;
para (eu = 1; eu <11; eu = eu + 1) {verificar
(eu);

} retornar 0;
}

8.4 Declarações e Expressões


A linguagem C é baseada em noções de declarações e expressões. As expressões correspondem a entidades de dados.
Todos os literais e nomes de variáveis são expressões. Além disso, expressões complexas podem ser
construídas usando operações (+, - e outras operações lógicas, aritméticas e de bits) e chamadas de função
(com exceção de rotinas que retornam void). A Listagem 8-19 mostra algumas expressões exemplares.

Listagem 8-19. expr_exemplo.c


1
13 + 37
17 + 89 * quadrado (1)
x

139
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Expressões são dados, portanto podem ser usadas no lado direito do operador de atribuição =. Algumas das
expressões também podem ser usadas no lado esquerdo da tarefa. Devem corresponder a entidades de dados com
um endereço na memória.2
Tais expressões são chamadas lvalue; todas as outras expressões, que não possuem endereço, são chamadas de rvalue. Esse
a diferença é na verdade muito intuitiva, desde que você pense em termos de máquina abstrata. Expressões como as
mostradas na Listagem 8.20 não têm significado, porque uma atribuição significa mudança de memória.

Listagem 8-20. rvalue_example.c

4 = 2;
"abc"="bcd";
quadrado(3) = 9;

8.4.1 Tipos de declaração


Instruções são comandos para a máquina abstrata C. Cada comando é um imperativo: faça alguma coisa! Daí o nome
“programação imperativa”: é uma sequência de comandos.
Existem três tipos de declarações:

1. Expressões terminadas por ponto e vírgula.

1 + 3;
42;
quadrado(3);

O objetivo dessas declarações é o cálculo das expressões fornecidas. Se estes não


invocarem atribuições (diretamente como parte da própria expressão ou dentro de uma
das funções invocadas) ou operações de entrada/saída, seu impacto no estado do programa
não será observável.

2. Um bloco delimitado por { e }. Ele contém um número arbitrário de sentenças. Um bloco não
deve terminar com ponto e vírgula (mas as instruções dentro dele provavelmente
deveriam). A Listagem 8.21 mostra um bloco típico.

Listagem 8-21. bloco_exemplo.c

int y = 1 + 3;
{
interno x;
x = quadrado (2) + y;
printf("%d\n",x);
}

3. Instruções de fluxo de controle: if, while, for, switch. Eles não exigem ponto e vírgula.

2
Estamos falando de memória de máquina C abstrata aqui. É claro que o compilador tem o direito de otimizar variáveis e nunca alocar
memória real para elas no nível do assembly. O programador, entretanto, não está limitado por isso e pode pensar que toda variável é
um endereço de uma célula de memória.

140
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Já falamos sobre atribuições; a verdade maligna é que as atribuições são expressões


eles mesmos, o que significa que eles podem ser acorrentados. Por exemplo, a = b = c significa

•Atribuir c a b;

•Atribuir o novo valor b a a.

Uma atribuição típica é, portanto, uma declaração da primeira categoria: expressão terminada por ponto e vírgula.
A atribuição é uma operação associativa à direita. Isso significa que ao ser analisado por um compilador (ou pelo seu
olho) os parênteses são implicitamente colocados da direita para a esquerda, a parte mais à direita tornando-se a mais
profundamente aninhada. A Listagem 8.22 fornece um exemplo de duas maneiras equivalentes de escrever uma tarefa complexa.

Listagem 8-22. atribuição_assoc.c

x = y = z;
(x = (y = z));

Por outro lado, as operações associativas à esquerda consideram a ordem de aninhamento oposta, conforme mostrado em
Listagem 8-23

Listagem 8-23. div_assoc.c

40/2/4
((40/2)/4)

8.4.2 Construindo Expressões


Uma expressão é construída usando outras expressões conectadas a operadores e chamadas de função. Os operadores
podem ser classificados

•Com base na aridade (contagem de operandos)

– Unário (como unário menos: - expr)

– Binário (como multiplicação binária: expr1 * expr2)

– Ternário. Existe apenas um operador ternário: cond ? expr1: expr2. Se a condição


é válido, o valor é igual a expr1, caso contrário, expr2

•Baseado no significado

– Operadores Aritméticos: * / + - % ++ --

– Operadores Relacionais: == != > < >= <=

- Operadores lógicos: ! && || << >>

– Operadores bit a bit: ÿ ˆ&|

– Operadores de Atribuição = += -= *= /= %= <<= >>= &= ˆ= |=

– Operadores diversos:

1. sizeof(var) como “substitua pelo tamanho de var em bytes”

2. & como “obter endereço de um operando”

3. como “desreferenciar este ponteiro”

4. ?: qual é o operador ternário de que falamos antes.

5. ->, que é usado para se referir a um campo do tipo estrutural ou sindical.

141
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

A maioria dos operadores tem um significado evidente. Mencionaremos alguns dos menos utilizados e mais obscuros.

•Os operadores de incremento e decremento podem ser usados na forma de prefixo ou pós-
fixo: ou para uma variável i é i++ ou ++i. Ambas as expressões terão um efeito
imediato em i, o que significa que é incrementado em 1. No entanto, o valor de i++
é o i “antigo”, enquanto o valor de ++i é o i “novo”, incrementado.

•Há uma diferença entre operadores lógicos e bit a bit. Para operadores lógicos, qualquer número
diferente de zero tem essencialmente o mesmo significado, enquanto as operações bit a
bit são aplicadas a cada bit separadamente. Por exemplo, 2 && 4 é igual a zero, porque
nenhum bit é definido em 2 e 4. No entanto, 2 && 4 retornará 1, porque 2 e 4 são números
diferentes de zero (valores verdadeiros).

•Os operadores lógicos são avaliados de forma preguiçosa. Considere o operador lógico &&.
Quando aplicado a duas expressões, a primeira expressão será calculada. Se o seu valor
for zero, o cálculo termina imediatamente, devido à natureza da operação AND. Se
algum de seus operandos for zero, o resultado da grande conjunção também será zero,
portanto não há necessidade de avaliá-lo posteriormente. É importante para nós porque
esse comportamento é perceptível. A Listagem 8-24 mostra um exemplo onde o programa
produzirá F e nunca executará a função g.

Listagem 8-24. logic_lazy.c

#include <stdio.h>

int f(void) { coloca("F"); retornar 0; }


int g(void) { coloca("G"); retornar 1; }

int principal(void) {
f() && g();
retornar 0;
}

•Til (ÿ) é uma negação unária bit a bit, hat (ˆ) é um xor binário bit a bit.

Nos capítulos seguintes revisitaremos alguns deles, como operandos de manipulação de endereço e sizeof.

8.5 Funções
Podemos traçar uma linha entre procedimentos (que não retornam um valor) e funções (que retornam um valor de um
determinado tipo). A chamada de procedimento não pode ser incorporada em uma expressão mais complexa, diferentemente
da chamada de função.
A Listagem 8-25 mostra um procedimento exemplar. Seu nome é myproc; ele retorna nulo, portanto não retorna
nada. Ele aceita dois parâmetros inteiros denominados a e b.

Listagem 8-25. proc_example.c

void meuproc ( int a, int b )


{
printf("%d",a+b);
}

142
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

A Listagem 8-26 mostra uma função exemplar. Aceita dois argumentos e retorna um valor do tipo int.
Uma chamada para esta função é usada posteriormente como parte de uma expressão mais complexa.

Listagem 8-26. função_exemplo.c

int minhafunc (int a, int b) {

retornar a + b;
}

int other(int x) { return 1


+ minhafunc( 4, 5 );
}

A execução de cada função termina com a instrução return; caso contrário, o valor que ele retornará será
indefinido. Os procedimentos podem ter a palavra-chave return omitida; ainda pode ser usado sem um operando
para retornar imediatamente do procedimento.
Quando não há argumentos, uma palavra-chave void deve ser usada na declaração da função, conforme mostrado em
Listagem 8-27.

Listagem 8-27. no_arguments_ex.c

int sempre_return_0(void) { return 0; }

O corpo da função é uma instrução de bloco, portanto, é colocado entre colchetes e não termina com ponto e vírgula.
Cada bloco define um escopo léxico para variáveis.
Todas as variáveis devem ser declaradas no início do bloco, antes de qualquer instrução. Essa restrição está
presente em C89, mas não em C99. Iremos segui-lo para tornar o código mais portátil.
Além disso, força uma certa autodisciplina. Se você tiver uma grande quantidade de variáveis locais declaradas
no início do escopo, ele parecerá confuso. Ao mesmo tempo, geralmente é sinal de má decomposição do programa e/
ou má escolha de estruturas de dados.
A Listagem 8.28 mostra exemplos de declarações de variáveis boas e ruins.

Listagem 8-28. variáveis_de_bloco.c


/* Bom */
vazio f(vazio) { int
x;
...
}

/* Ruim: `x` é declarado após a chamada `printf` */

vazio f(vazio) { int


y = 12;
printf("%d",y); interno
x = 10;
...
}

/* Ruim: `i` não pode ser declarado no inicializador `for` */


for(int i = 0; i < 10; i++ ) {
...
}

143
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

/* Bom: `i` é declarado antes de `for` */ int f(void)


{ int i; para(eu
= 0;
eu < 10; eu++) {
...
}
}

/* Bom: qualquer bloco pode ter variáveis adicionais declaradas em seu início */ /* `x` é local
para uma iteração `for` e é sempre reinicializado para 10 */ for( i = 0; i < 10; i++ ) { interno x
= 10;

Se uma variável em um determinado escopo tiver o mesmo nome da variável já declarada em um escopo
superior, a variável mais recente oculta a antiga. Não há como endereçar a variável oculta sintaticamente (não
armazenando seu endereço em algum lugar e usando o endereço).
É claro que as variáveis locais em diferentes funções podem ter os mesmos nomes.

ÿ Nota As variáveis ficam visíveis até o final de seus respectivos blocos. Portanto, uma noção comumente usada de variáveis

'locais' é, na verdade, local de bloco, não local de função. A regra prática é: torne as variáveis tão locais quanto possível (incluindo

variáveis locais para corpos de loop, por exemplo. Isso reduz bastante a complexidade do programa, especialmente em grandes projetos.

8.6 Pré-processador
O pré-processador C age de forma semelhante ao pré-processador NASM. Seu poder, porém, é muito mais
limitado. As diretivas de pré-processador mais importantes você vai ver são
•#definir

•#incluir

•#ifndef

•#fim se

A diretiva #define é muito semelhante à sua contraparte NASM %define. Tem três usos principais.

•Definir constantes globais (veja um exemplo na Listagem 8-29 ).

Listagem 8-29. define_exemplo1.c

#define MY_CONST_VALUE 42

•Definir substituições de macro parametrizadas (conforme mostrado na Listagem 8-30).

144
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

Listagem 8-30. define_exemplo2.c

#define MACRO_SQUARE( x ) ((x) * (x))

•Definição de bandeiras; dependendo de qual, algum código adicional pode ser incluído ou
excluído das fontes.

É importante colocar entre parênteses todas as ocorrências de argumentos dentro das definições de macro. A razão
por trás disso está o fato de as macros C não serem sintáticas, o que significa que o pré-processador não tem conhecimento da
estrutura do código. Às vezes, isso resulta em um comportamento inesperado, como mostra a Listagem 8.31. A Listagem 8-32 mostra
o código pré-processado.

Listagem 8-31. define_parênteses.c

#define QUADRADO( x ) (x * x)

int x = QUADRADO (4+1)

Como você pode ver, o valor de x não será 25, mas 4+(1ÿ4)+1 porque a multiplicação tem uma prioridade mais alta em
comparação à adição.

Listagem 8-32. define_parênteses_preprocessed.c

int x = 4+1 * 4+1

A diretiva #include cola o conteúdo do arquivo fornecido em seu lugar. O nome do arquivo está entre
aspas (#include "file.h") ou colchetes angulares (#include <stdio.h>).

•No caso de colchetes angulares, o arquivo é pesquisado em um conjunto de diretórios predefinidos. Para CCG
geralmente é:

– /usr/local/incluir

– <libdir>/gcc/target/versão/include

Aqui <libdir> representa o diretório que contém bibliotecas (uma configuração do GCC) e
geralmente é /usr/lib ou /usr/local/lib por padrão.

– /usr/target/incluir

– /usr/incluir

Usando a tecla -I é possível adicionar diretórios a esta lista. Você pode fazer uma inclusão/
diretório na raiz do seu projeto e adicione-o à lista de pesquisa de inclusão do GCC.

•No caso de citações, os arquivos também são pesquisados no diretório atual.

Você pode obter a saída do pré-processador avaliando um arquivo filename.c da mesma forma que ao trabalhar
com NASM: gcc -E nome do arquivo.c. Isso executará todas as diretivas do pré-processador e liberará os resultados no
stdout sem fazer nada.

145
Machine Translated by Google

Capítulo 8 ÿ Noções básicas

8.7 Resumo
Neste capítulo elaboramos os fundamentos do C. Todas as variáveis são rótulos na memória da máquina abstrata
da linguagem C, cuja arquitetura se assemelha muito à arquitetura de von Neumann. Depois de descrever uma
estrutura universal de programa (funções, tipos de dados, variáveis globais,...), definimos duas categorias
sintáticas: declarações e expressões. Vimos que as expressões são lvalues ou rvalues e aprendemos a
controlar a execução do programa usando chamadas de função e instruções de controle como if e while. Já
somos capazes de escrever programas simples que realizam cálculos em números inteiros. No próximo capítulo
discutiremos o sistema de tipos em C e os tipos em geral para ter uma visão mais ampla de como os tipos são
usados em diferentes linguagens de programação. Graças à noção de arrays, nossos possíveis dados de entrada
e saída se tornarão muito mais diversificados.

ÿ Pergunta 148 O que é literal?

ÿ Pergunta 149 O que são lvalue e rvalue?

ÿ Pergunta 150 Qual é a diferença entre as afirmações e as expressões?

ÿ Pergunta 151 O que é um bloco de afirmações?

ÿ Pergunta 152 Como você define um símbolo de pré-processador?

ÿ Pergunta 153 Por que o break é necessário no final de cada caso de switch ?

ÿ Pergunta 154 Como os valores verdadeiros e falsos são codificados em C89?

ÿ Pergunta 155 Qual é o primeiro argumento da função printf ?

ÿ Pergunta 156 O printf está verificando os tipos de seus argumentos?

ÿ Pergunta 157 Onde você pode declarar variáveis em C89?

146
Machine Translated by Google

CAPÍTULO 9

Tipo Sistema

A noção de tipo é uma das principais. Um tipo é essencialmente uma tag atribuída a uma entidade de dados. Cada transformação de dados é
definida para tipos de dados específicos, o que garante sua correção (você não gostaria de adicionar a quantidade de usuários ativos do Reddit à
temperatura média ao meio-dia no Saara, porque não faz sentido).
Este capítulo estudará o sistema do tipo C em profundidade.

9.1 Sistema de tipo básico de C


Todos os tipos em C se enquadram em uma destas categorias:

•Tipos numéricos predefinidos (int, char, float, etc.).

•Arrays, múltiplos elementos do mesmo tipo ocupando células de memória consequentes.

• Ponteiros, que são essencialmente células que armazenam endereços de outras células. O ponteiro
type codifica o tipo de célula para a qual está apontando. Um caso particular de ponteiros são ponteiros de função.

•Estruturas, que são pacotes de dados de diferentes tipos. Por exemplo, uma estrutura pode armazenar um número
inteiro e um número de ponto flutuante. Cada um dos elementos de dados tem seu próprio
nome.

•Enumerações, que são essencialmente números inteiros, assumem um dos valores explicitamente definidos.
Cada um desses valores possui um nome simbólico ao qual se referir.

•Tipos funcionais.

•Tipos constantes, construídos sobre algum outro tipo e tornando os dados imutáveis.

•Digite aliases para outros tipos.

9.1.1 Tipos Numéricos


Os tipos C mais básicos são os numéricos. Eles têm tamanhos diferentes e são assinados ou não.
Devido a uma evolução linguística longa e pouco controlada, a sua descrição pode parecer por vezes misteriosa e muitas vezes muito ad hoc. A
seguir está uma lista dos tipos básicos:

1. caractere

• Pode ser assinado e não assinado. Por padrão, geralmente é um número assinado, mas não é exigido pelo
padrão do idioma.

• Seu tamanho é sempre de 1 byte;

© Igor Zhirkov 2017 147


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_9
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

• Apesar do nome fazer uma referência direta à palavra “personagem”, este é um


tipo inteiro e deve ser tratado como tal. Geralmente é usado para armazenar o código ASCII de um
caractere, mas pode ser usado para armazenar qualquer número de 1 byte.

• Um 'x' literal e corresponde a um código ASCII do caractere “x”. Seu tipo é int
mas é seguro atribuí-lo a uma variável do tipo char. 1

A Listagem 9-1 mostra um exemplo.

Listagem 9-1. char_exemplo.c

número do caracter = 5;
char símbolo_código = 'x';
char null_terminator = '\0';

2. interno

• Um número inteiro.

• Pode ser assinado e não assinado. Ele é assinado por padrão.

• Pode ter um alias simplesmente como: assinado, assinado int (semelhante para não assinado).

• Pode ser curto (2 bytes), longo (4 bytes em arquiteturas de 32 bits, 8 bytes em Intel 64). A maioria dos
compiladores também suporta long long, mas até C99 não fazia parte do padrão.

• Outros aliases: short, short int, assinado short, assinado short int.

• O tamanho de int sem modificadores varia dependendo da arquitetura. Ele foi projetado para ser igual ao
tamanho da palavra da máquina. Na era de 16 bits, o tamanho interno era obviamente de 2 bytes; em
máquinas de 32 bits, era de 4 bytes. Infelizmente, isso não impediu que os programadores
confiassem em um int de tamanho 4 na era da computação de 32 bits.
Devido ao grande conjunto de software que poderia quebrar se alterássemos o tamanho de int, seu
tamanho permanece intacto e permanece em 4 bytes.

• É importante observar que todos os literais inteiros possuem o formato int por padrão. Se adicionarmos os
sufixos L ou UL, afirmaremos explicitamente que esses números são do tipo long int ou unsigned int.
Às vezes é de extrema importância não esquecer esses sufixos.

Considere uma expressão 1 << 48. Seu valor não é 248 como você poderia imaginar, mas 0. Por
quê? A razão é que 1 é um literal do tipo int, que ocupa 4 bytes e, portanto, pode variar de ÿ231 a 231
ÿ 1. Ao deslocar 1 para a esquerda 48 vezes, estamos movendo o único bit definido fora do
formato inteiro. Assim o resultado é zero. Contudo, se adicionarmos um sufixo correto, a resposta será
mais evidente. Uma expressão 1L << 48 é avaliada como 248, porque 1L agora tem 8 bytes.

3. muito longo

• Na arquitetura x64 é o mesmo que long (exceto para Windows, onde long é
4 bytes).

• Seu tamanho é de 8 bytes.

• Seu alcance é: ÿ263 … 263 – 1 para assinado e 0...264 –1 para não assinado.

1 Essa falha de design de linguagem é corrigida em C++, onde 'x' possui o tipo char.

148
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

4. flutuar

• Número de ponto flutuante.

• Seu tamanho é de 4 bytes.

• Seu intervalo é: ±1, 17549 × 10ÿ38 … ± 3, 40282 × 1038 (precisão de aproximadamente seis dígitos).

5. duplo

• Número de ponto flutuante.

• Seu tamanho é de 8 bytes.

• Seu intervalo é: ±2, 22507 × 10ÿ308 … ± 1, 79769 × 10308 (precisão de aproximadamente 15 dígitos).

6. longo duplo

• Número de ponto flutuante.

• Seu tamanho geralmente é de 80 bits.

• Foi introduzido apenas no padrão C99.

ÿ Nota sobre aritmética de ponto flutuante

Em primeiro lugar, lembre-se de que os tipos de ponto flutuante são uma aproximação muito aproximada dos números reais. Por

exemplo, eles são mais precisos perto de 0 e menos precisos para valores grandes. Esta é exatamente a razão pela qual seu alcance é tão

grande em comparação até mesmo com os longos.

Como consequência, fazer aritmética de ponto flutuante com valores mais próximos de zero produz resultados mais precisos.

Finalmente, em certos contextos (por exemplo, programação kernel) a aritmética de ponto flutuante não está disponível. Como regra

geral, evite-o quando não precisar dele. Por exemplo, se seus cálculos puderem ser realizados manipulando um quociente e um resto,

calculados usando os operadores / e % , você deverá segui-los.

9.1.2 Fundição de Tipo


A linguagem permite converter dados entre tipos com relativa liberdade. Para fazer isso você deve escrever o nome do novo tipo entre
parênteses antes da expressão que deseja converter.
A Listagem 9-2 mostra um exemplo.

Listagem 9-2. type_cast.c

intuma = 4;

duplo b = 10,5 * (duplo)a; /* agora a é um duplo */

interno b = 129;
caractere k = (caractere)b; //???

Certamente, este maravilhoso mundo aberto de possibilidades é melhor controlado pela sua ditadura benevolente porque estas
conversões implícitas muitas vezes levam a erros sutis quando uma expressão não é avaliada de acordo com o que “deveria” ser avaliada.

149
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Por exemplo, as char é um número (geralmente) assinado no intervalo -128 . . . 127, o número 129 é grande demais
para caber nesse intervalo. O resultado de uma ação, mostrado na Listagem 9-2, não é descrito no padrão de linguagem,
mas dado o funcionamento típico dos processadores e compiladores, o resultado será provavelmente um número negativo,
consistindo nos mesmos bits que uma representação não assinada de 129 .

ÿ Pergunta 158 Qual será o valor de k? Tente compilar e ver no seu próprio computador.

9.1.3 Tipo Booleano


Já afirmamos que o C89 não possui booleanos. No entanto, C99 introduziu Booleanos como um tipo _Bool. Se você incluir
stdbool.h, terá acesso aos valores true/false e ao tipo bool, que é um alias de _Bool. O raciocínio por trás disso é simples.
Muitos projetos existentes já possuem o tipo booleano definido para si, geralmente como bool. Para evitar conflitos de
nomenclatura, o nome do tipo C99 para booleanos é _Bool. Incluir o arquivo stdbool.h significa que seu código está livre de
qualquer definição bool personalizada e você está escolhendo aquela que está em conformidade com o padrão, mas com
um nome mais humano. Recomendamos que você use o tipo de alias bool sempre que possível. No futuro, o nome do tipo
_Bool provavelmente será declarado obsoleto e, após várias versões padrão, não será mais usado.

9.1.4 Conversões Implícitas


Por ser uma linguagem de tipo fraco, C permite, às vezes, omitir conversões, mesmo quando se usam dados de tipo
diferente do pretendido.
Quando o tipo numérico necessário não é igual ao tipo real, uma conversão implícita é executada,
que é chamado de promoção inteira. Se o tipo for menor que um int, ele será promovido para int assinado ou int não
assinado, dependendo de sua natureza inicial assinada ou não assinada.2 Então, se eles ainda forem diferentes, subimos a
escada, mostrada na Figura 9-1

Figura 9-1. Conversões inteiras

ÿ Nota Lembre-se de que long long e long double apareceram apenas em C99. Eles são, no entanto,
suportados como extensão de linguagem por muitos compiladores que ainda não suportam C99.

A regra “converter para int primeiro” significa que os overflows em tipos menores podem ser tratados de maneira diferente
do próprio tipo int. O exemplo mostrado na Listagem 9-3 assume que sizeof(int) == 4.

Listagem 9-3. int_promotion_pitfall.c

/* Os tipos menores */
caractere não assinado x = 100, y = 100, z = 100;
caractere não assinado r = x + y + z; /* lhe dará 300% 256 = 44 */

2 A palavra-chave são conversões aritméticas usuais.

150
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

int não assinado r_int = x + y + z; /* é igual a 300, porque a promoção para


números inteiros são executados primeiro */

/* Agora com os tipos maiores */

int sem sinal x = 1e9, y = 2e9, z = 3e9;

int não assinado r_int = x + y + z; /* 1705032704 é igual a 6000000000% (2ˆ32) */

longo sem sinal r_long = x + y + z; /* o mesmo resultado: 1705032704 */

Na última linha, nem x, y, nem z são promovidos para longos, porque não são exigidos pelo padrão. O
a aritmética será realizada dentro do tipo int e então o resultado será convertido para longo.

ÿ Seja compreendido Como regra geral, quando tiver dúvidas, sempre forneça os tipos explicitamente! Por exemplo, você pode escrever

x longo = (longo)a + (longo)b + (longo)c.

Embora o código possa parecer mais detalhado depois disso, pelo menos funcionará conforme planejado.

Vejamos um exemplo mostrado na Listagem 9-4. A expressão na terceira linha será calculada da seguinte forma:

1. O valor de i será convertido em float (é claro, a variável em si não


mudar);

2. Este valor é adicionado ao valor de f, o tipo resultante é float novamente; e

3. Este resultado é convertido em double para ser armazenado em d.

Listagem 9-4. int_float_conv.c

int eu;
flutuar f;
duplo d = f + i;

Todas essas operações não são gratuitas e são codificadas como instruções de montagem. Isso significa que sempre que você
estiver atuando em vários formatos diferentes, provavelmente haverá custos de tempo de execução. Tente evitá-lo especialmente em ciclos.

9.1.5 Ponteiros
Dado um tipo T, sempre se pode construir um tipo T*. Este novo tipo corresponde a unidades de dados que contêm endereço
de outra entidade do tipo T.
Como todos os endereços têm o mesmo tamanho, todos os tipos de ponteiro também têm o mesmo tamanho. É específico para
arquitetura e, no nosso caso, tem 8 bytes de largura.
operandos & e * pode-se pegar o endereço de uma variável ou desreferenciar um ponteiro (veja Usando
memória pelo endereço que este ponteiro armazena). A Listagem 9-5 mostra um exemplo.
Na seção 2.5.4 discutimos um problema sutil: se um ponteiro é apenas um endereço, como sabemos o tamanho de uma
entidade de dados que estamos tentando ler a partir desse endereço? Na montagem, era simples: ou o tamanho poderia ter sido
deduzido com base no fato de que dois operandos mov deveriam ter o mesmo tamanho ou o tamanho deveria ter sido fornecido
explicitamente, por exemplo, mov qword [rax], 0xABCDE. Aqui o sistema de tipos cuida disso: se um ponteiro for do tipo int*,
certamente sabemos que desreferencia-lo produz um valor de tamanho sizeof(int).

151
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-5. ptr_deref.c

interno x = 10;
int*px = &x; /* Pegou o endereço de `x` e atribuiu-o a `px` */

*px = 42; /* Modificamos `x` aqui! */


printf("*px = %d\n", *px ); /* saída: '*px = 42' */
printf("x = %d\n",x); /* saída: 'x = 42' */

Quando você programa em C, os ponteiros são o seu pão com manteiga. Contanto que você não introduza um ponteiro
para dados inexistentes, as dicas serão úteis para você.
Um valor especial de ponteiro é 0. Quando usado no contexto de ponteiro (especificamente, comparação com 0), 0
significa “um valor especial para um ponteiro para lugar nenhum”. No lugar de 0 você também pode escrever NULL, e é
aconselhável fazê-lo. É uma prática comum atribuir NULL aos ponteiros que ainda não foram inicializados com um endereço
de objeto válido, ou retornar NULL de funções que retornam um endereço de algo para alertar o chamador sobre um erro.

ÿ Zero é zero? Existem dois contextos nos quais você pode usar a expressão 0 em C. O primeiro contexto espera
apenas um número inteiro normal. O segundo é um contexto de ponteiro, quando você atribui um ponteiro a 0 ou o
compara com 0. No segundo contexto 0 nem sempre significa um valor inteiro com todos os bits limpos, mas sempre será
igual a esse valor de “ponteiro inválido”. . Em algumas arquiteturas pode ser, por exemplo, um valor com todos os bits
definidos. Mas este código funcionará independentemente da arquitetura devido a esta regra:

int*px = ...;

if (px) /* se `px` não for NULL */

if ( px == 0 ) /* o mesmo que o seguinte: */


if (!px ) /* se `px` for NULL */

Existe um tipo especial de ponteiro: void*. Este é o ponteiro para qualquer tipo de dado. C nos permite atribuir qualquer
tipo de ponteiro a uma variável do tipo void*; no entanto, esta variável não pode ser desreferenciada. Antes de fazermos isso,
precisamos pegar seu valor e convertê-lo para um tipo de ponteiro legítimo (por exemplo, int*). Uma conversão simples é usada para
fazer isso (consulte a seção 9.1.2). A Listagem 9-6 mostra um exemplo.

Listagem 9-6. void_deref.c

intuma = 10;
vazio* pa = &a;

printf("%d\n", *( (int*) pa) );

Você também pode passar um ponteiro do tipo void* para qualquer função que aceite um ponteiro para algum outro tipo.
Os ponteiros têm muitos propósitos e listaremos alguns deles.

•Alterar uma variável criada fora de uma função.

•Criar e navegar em estruturas de dados complexas (por exemplo, listas vinculadas).

• Chamar funções por ponteiros significa que, ao alterar o ponteiro, alternamos entre
diferentes funções sendo chamadas. Isso permite soluções arquitetônicas bastante elegantes.

152
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Os ponteiros estão intimamente ligados aos arrays, que serão discutidos na próxima seção.

9.1.6 Matrizes
Em C, um array é uma estrutura de dados que contém uma quantidade fixa de dados do mesmo tipo. Então, para trabalhar com um
array precisamos saber seu início, tamanho de um único elemento e a quantidade de elementos que ele pode armazenar.
Consulte a Listagem 9-7 para ver diversas variações de declaração de array.

Listagem 9-7. array_decl.c

/* O tamanho deste array é calculado pelo compilador */


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

/* Este array é inicializado com zeros, seu tamanho é 256 bytes */


matriz longa[32] = {0};

Como a quantidade de elementos deve ser fixa, ela não pode ser lida de uma variável.3 Para alocar memória para tais arrays
cujas dimensões não conhecemos antecipadamente, são utilizados alocadores de memória (que nem sempre estão à sua
disposição, por exemplo, ao programar kernels). Aprenderemos a usar o alocador de memória C padrão (malloc/free) e até
escreveremos o nosso próprio.
Você pode endereçar elementos por índice. Os índices começam em 0. A origem desta solução está na natureza de
espaço de endereço. O elemento zero está localizado no endereço inicial de uma matriz mais 0 vezes o tamanho do elemento.
A Listagem 9.8 mostra uma declaração de array, duas leituras e uma gravação.

Listagem 9-8. array_exemplo_rw.c

int minhamatriz[1024];
int y = minhamatriz[64];

int primeiro = minhamatriz[0];

minhamatriz[10] = 42;

Se pensarmos um pouco sobre a máquina abstrata C, os arrays são apenas regiões de memória contínua contendo os
dados do mesmo tipo. Não há informações sobre o tipo em si ou sobre o comprimento do array. É totalmente responsabilidade do
programador nunca endereçar um elemento fora de um array alocado.
Sempre que você escreve o nome do array alocado, na verdade você está se referindo ao seu endereço. Você pode pensar
sobre isso como um valor de ponteiro constante. Aqui é o lugar onde a analogia entre rótulos de montagem e variáveis é mais
forte. Portanto, na Listagem 9.8, uma expressão myarray tem na verdade um tipo int*, porque é um ponteiro para o primeiro
elemento do array!
Isso também significa que uma expressão *myarray será avaliada para seu primeiro elemento, assim como myarray[0].

9.1.7 Matrizes como Argumentos de Função


Vamos falar sobre funções que aceitam arrays como argumentos. A Listagem 9-9 mostra uma função que retorna um primeiro
elemento do array (ou -1 se o array estiver vazio).

3 Até C99; mas mesmo hoje em dia os arrays de comprimento variável são desencorajados por muitos porque se o tamanho do array for
grande o suficiente, a pilha não será capaz de mantê-lo e o programa será encerrado.

153
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-9. fun_array1.c

int primeiro (int array[], size_t sz ) {


se (sz == 0) retornar -1;
retornar matriz[0];
}

Não é novidade que a mesma função pode ser reescrita mantendo o mesmo comportamento, conforme mostrado na Listagem 9.10.

Listagem 9-10. fun_array2.c

int primeiro (int* array, size_t sz ) {


se (sz == 0) retornar -1;
retornar *matriz;
}

Mas isso não é tudo. Na verdade, você pode misturá-los e usar a notação de indexação com ponteiros, conforme mostrado na
Listagem 9.11.

Listagem 9-11. fun_array3.c

int primeiro (int* array, size_t sz ) {


se (sz == 0) retornar -1;
retornar matriz[0];
}

O compilador rebaixa imediatamente construções como int array[] na lista de argumentos para um array de ponteiro
int* e então trabalha com ele como tal. Sintaticamente, entretanto, você ainda pode especificar o comprimento do array,
conforme mostrado na Listagem 9.12. Este número indica que o array fornecido deve ter pelo menos essa quantidade de
elementos. No entanto, o compilador o trata como um comentário e não executa verificações em tempo de execução ou em tempo de compilação.

Listagem 9-12. array_param_size.c

int primeiro( int array[10], size_t sz ) { ... }

C99 introduziu uma sintaxe especial, que corresponde essencialmente à sua promessa feita a um compilador, de
que a matriz correspondente terá pelo menos essa quantidade de elementos. Ele permite que o compilador execute
algumas otimizações específicas com base nesta suposição. A Listagem 9-13 mostra um exemplo.

Listagem 9-13. array_param_size_static.c

int fun(int array[estático 10] ) {...}

9.1.8 Inicializadores designados em arrays


C99 apresenta uma maneira interessante de inicializar os arrays. É possível inicializar implicitamente uma matriz com
valores padrão, exceto aqueles em diversas posições designadas, para as quais outros valores são fornecidos. Por
exemplo, para inicializar um array de oito elementos int com todos zeros, exceto os índices 1 e 5 que conterão os valores 15
e 29, respectivamente, o seguinte código pode ser usado:

int a[8] = { [5] = 29, [1] = 15 };

154
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

A ordem de inicialização é irrelevante. Muitas vezes é útil usar valores enum ou valores de caracteres como índices.
A Listagem 9-14 mostra um exemplo.

Listagem 9-14. designados_initializers_arrays.c

int espaço em branco[256] = {


[' ' ] = 1,
['\t'] = 1,
['\f'] = 1,
['\n'] = 1,
['\r'] = 1 };

enum cores {
VERMELHO,

VERDE,
AZUL,
MAGENTA,
AMARELO

};

int bom[5] = { [VERMELHO] = 1, [MAGENTA] = 1};

9.1.9 Aliases de tipo


Você pode definir seus próprios tipos usando tipos existentes por meio da palavra-chave typedef.
O código mostrado na Listagem 9.15 está criando um novo tipo mytype_t. É absolutamente equivalente a não assinado
short int exceto pelo seu nome. Esses dois tipos tornam-se totalmente intercambiáveis (a menos que alguém altere o
typedef posteriormente).

Listagem 9-15. typedef_example.c

typedef não assinado short int mytype_t;

Você pode ver o sufixo _t nos nomes dos tipos com bastante frequência. Todos os nomes que terminam com _t são reservados pelo
padrão POSIX.4

Desta forma, os padrões mais recentes poderão introduzir novos tipos sem medo de colidir com tipos
em projetos existentes. Portanto, o uso desses nomes de tipo é desencorajado. Falaremos sobre convenções práticas de
nomenclatura mais tarde.
Para que servem esses novos tipos?

1. Às vezes, eles melhoram a facilidade de leitura do código.

2. Podem melhorar a portabilidade, pois alterar o formato de todas as variáveis do


seu tipo personalizado, você deve alterar apenas o typedef.

3. Os tipos são essencialmente outra forma de documentar programas.

4. Os aliases de tipo são extremamente úteis ao lidar com tipos de ponteiros de função devido
à sua sintaxe complicada.

4
POSIX é uma família de padrões especificada pela IEEE Computer Society. Inclui a descrição de utilitários, interface
de programação de aplicativos (API), etc. Seu objetivo é facilitar a portabilidade de software, principalmente entre diferentes
ramos de sistemas derivados de UNIX.

155
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Um exemplo muito importante de alias de tipo é size_t. Este é um tipo definido no padrão da linguagem (requer a
inclusão de um dos cabeçalhos da biblioteca padrão, por exemplo, #include <stddef.h>). Seu objetivo é armazenar
comprimentos e índices de array. Geralmente é um apelido para unsigned long; portanto, no Intel 64 normalmente é um
número inteiro não assinado de 8 bytes.

ÿ Nunca use int para índices de array A menos que você esteja lidando com uma biblioteca mal projetada que força você a
usar int como índice, sempre dê preferência a size_t.

Sempre use tipos apropriadamente. A maioria das funções de biblioteca padrão que lidam com tamanhos retornam um valor do tipo
size_t (até mesmo o operador sizeof() retorna size_t!). Vamos dar uma olhada no exemplo mostrado na Listagem 9.16.
Uma expressão s do tipo size_t poderia ter sido obtida de uma das chamadas de biblioteca, como strlen. Existem vários
problemas que surgem devido ao uso do int:

•int tem 4 bytes e está assinado, então seu valor máximo é 231 ÿ 1. E se i for usado como
índice de matriz? É mais do que possível criar um array maior em sistemas modernos, portanto nem todos
os elementos podem ser indexados. O padrão diz que os arrays são limitados em tamanho por uma
quantidade de elementos codificáveis usando uma variável size_t (número inteiro não assinado de 64 bits).

•Cada iteração só é realizada se o valor atual de i for menor que s. Assim um


é necessária comparação, mas essas duas variáveis têm um formato diferente! Por causa disso,
um código especial de conversão de número será executado a cada iteração, o que pode ser
bastante significativo para pequenos loops com muitas iterações.

•Ao lidar com matrizes de bits (não tão incomuns), é provável que um programador calcule i/8
para um deslocamento de bytes em uma matriz de bytes e i%8 para ver a qual bit específico estamos
nos referindo. Essas operações podem ser otimizadas em turnos em vez de divisão real, mas apenas
para números inteiros sem sinal. A diferença de desempenho entre turnos e divisão “justa” é radical.

Listagem 9-16. size_int_difference.c

tamanho_ts;
int eu;
...
para(eu = 0; eu <s; eu++) {
...
}

9.1.10 A Função Principal Revisitada


Já estamos acostumados a escrever a função principal, que serve como ponto de entrada, como uma função
sem parâmetros. No entanto, ele deveria aceitar dois parâmetros: a contagem de argumentos da linha de comando e uma
matriz dos próprios argumentos. O que são argumentos de linha de comando? Bem, toda vez que você inicia um programa
(como ls), você pode especificar argumentos adicionais, por exemplo, ls -l -a. A aplicação ls será lançada e terá acesso a
esses argumentos em sua função principal. Nesse caso

•argv conterá três ponteiros para sequências de caracteres:

CADEIA DE ÍNDICE

0 "ls"
1 "-eu"
2 "-a"

156
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

O shell dividirá toda a string de chamada em pedaços por espaços, tabulações e símbolos
de nova linha, e o carregador e a biblioteca padrão C garantirão que main obtenha
essas informações.

•argc será igual a 3 pois é um número de elementos em argv.

A Listagem 9-17 mostra um exemplo. Este programa imprime todos os argumentos fornecidos, cada um em uma linha separada.

Listagem 9-17. main_revisited.c

#include <stdio.h>

int principal( int argc, char* argv[] ) { int i; for( i


= 0; i <
argc; i++ ) puts( argv[i] ); retornar 0;

9.1.11 Operador sizeof Já mencionamos

o operador sizeof na seção 8.4.2. Ele retorna um valor do tipo size_t que contém o tamanho do operando em bytes. Por
exemplo, sizeof(long) retornará 8 em computadores x64. sizeof não é uma função porque
deve ser computado em tempo de compilação. sizeof tem um uso interessante:
você pode calcular o tamanho total de um array, mas apenas se o argumento estiver em
esta matriz exata. A Listagem 9-18 mostra um exemplo.

Listagem 9-18. sizeof_array.c

#include <stdio.h>

matriz longa[] = { 1, 2, 3 };

int main(void)
{ printf( "%zu \n", sizeof( array ) ); /* saída: 24 */ printf( "%zu \n",
sizeof( array[0] ) ); /* saída: 8 */ return 0;

Observe como você não pode usar sizeof para obter o tamanho de um array aceito por uma função como argumento.
A Listagem 9-19 mostra um exemplo. Este programa produzirá 8 em nossa arquitetura

Listagem 9-19. sizeof_array_fun.c

#include <stdio.h>
const int arr[] = {1, 2, 3, 4}; void f(int
const arr[]) { printf("%zu\n",
sizeof( arr ) );

} int principal(void)
{ f(arr);
retornar 0;
}

157
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

ÿ Qual especificador de formato? A partir de C99 você pode usar um especificador de formato %zu para size_t. Nas
versões anteriores você deveria usar %lu que significa unsigned long.

ÿ Pergunta 159 Crie programas de exemplo para estudar os valores destas expressões:

•tamanho(void)
•tamanho(0)
•tamanho('x')
•tamanho("olá")

ÿ Pergunta 160 Qual será o valor de x?

interno x = 10;
tamanho_t t = tamanhode(x=90);

ÿ Pergunta 161 Como calcular quantos elementos um array armazena usando sizeof?

9.1.12 Tipos Const Para cada tipo

T também podemos usar um tipo T const (ou, equivalentemente, const T). Variáveis desse tipo não podem ser
alteradas diretamente, portanto são imutáveis. Isso significa que tais dados devem ser inicializados simultaneamente
com uma declaração. A Listagem 9.20 mostra um exemplo de inicialização e trabalho com variáveis constantes.

Listagem 9-20. const_def.c


interno;
uma = 42; /* OK */

...

const int a; /* Erro de compilação */

...

const int a = 42; /* ok */ a =


99; /* erro de compilação, não deve alterar valor constante */

int const a = 42; /* ok */ const


int b = 99; /* ok, const int === int const */

158
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

É interessante notar como o modificador const interage com o modificador asterisk *. O tipo é lido da direita para a
esquerda e, portanto, os modificadores const, bem como o asterisco, são aplicados nesta ordem. A seguir estão as opções:

•int const* x significa “um ponteiro mutável para um int imutável”. Assim, *x = 10 não é permitido, mas
modificar o próprio x é permitido.

Uma sintaxe alternativa é const int* x.

•int* const x = &y; significa “um ponteiro imutável para um int y mutável”. Em outro
palavras, x nunca estará apontando para nada além de y.

•Uma superposição dos dois casos: int const* const x = &y; é “um ponteiro imutável para um int
y imutável”.

ÿ Regra simples O modificador const à esquerda do asterisco protege os dados para os quais apontamos; o modificador
const à direita protege o próprio ponteiro.

Tornar uma variável constante não é infalível. Ainda há uma maneira de modificá-lo. Vamos demonstrar isso para
uma variável const int x (veja Listagem 9-21).

•Aponte para ele. Terá o tipo const int*.

• Transforme este ponteiro em int*.

•Desreferenciar este novo ponteiro. Agora você pode atribuir um novo valor a x.

Listagem 9-21. const_cast.c

#include <stdio.h>

int principal(void) {
const intx = 10;
*((int*)&x) = 30;

printf("%d\n",x);
retornar 0;
}

Essa técnica é fortemente desencorajada, mas você pode precisar dela ao lidar com código legado mal
projetado. Os modificadores const são feitos por um motivo e, se o seu código não o compilar, isso não é de forma alguma
uma justificativa para tais hacks.
Observe que você não pode atribuir um ponteiro int const* a int* (isso é verdadeiro para todos os tipos). O
primeiro ponteiro garante que seu conteúdo nunca será alterado, enquanto o segundo não. A Listagem 9-22 mostra um exemplo.

Listagem 9-22. const_discard.c

interno x;
interno;

int const* px = &x;


int * py = &y;

py =px; /* Erro, o qualificador const foi descartado */


px = pi; /* OK */

159
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

ÿ Devo usar const ? É complicado. Absolutamente. Em projetos grandes, você pode economizar uma vida inteira de
depuração. Eu mesmo me lembro de vários bugs muito sutis que foram detectados pelo compilador e resultaram em erro de
compilação. Sem as variáveis protegidas por const, o compilador teria aceitado o programa, o que teria resultado em
comportamento errado.

Além disso, o compilador pode usar essas informações para realizar otimizações úteis.

9.1.13 Sequências
Em C, as strings são terminadas em nulo. Um único caractere é representado por seu código ASCII do tipo char. Uma string
é definida por um ponteiro para o seu início, o que significa que o equivalente de um tipo de string seria char*. Strings também
podem ser consideradas matrizes de caracteres, cujo último elemento é sempre igual a zero.
O tipo de literais de string é char*. Modificá-los, entretanto, embora sintaticamente possível (por exemplo,
"hello"[1] = 32), produz um resultado indefinido. É um dos casos de comportamento indefinido em C. Isso geralmente
resulta em um erro de execução, que explicaremos no próximo capítulo.
Quando dois literais de string são escritos um após o outro, eles são concatenados (mesmo que estejam separados
com quebras de linha). A Listagem 9-23 mostra um exemplo.

Listagem 9-23. string_literal_breaks.c

char const* olá = "Hel" "lo"


"mundo!";

ÿ Observação A linguagem C++ (diferentemente de C) força o tipo literal de string para char const*, portanto, se você quiser
que seu código seja portátil, considere isso. Além disso, força a imutabilidade das strings (que é o que você deseja com
frequência) no nível da sintaxe. Portanto, sempre que puder, atribua literais de string às variáveis const char* .

9.1.14 Tipos Funcionais


Uma parte bastante obscura de C são os tipos funcionais. Ao contrário da maioria dos tipos, eles não podem ser
instanciados como variáveis, mas de certa forma as próprias funções são literais desses tipos. No entanto, você pode
declarar argumentos de função de tipos funcionais, que serão automaticamente convertidos em ponteiros de função.
A Listagem 9.24 mostra um exemplo de argumento de função f de um tipo funcional.

Listagem 9-24. fun_type_example.c

#include <stdio.h>

double g(int número) {retorna 0,5 + número; }

aplicação dupla (duplo (f) (int), int x) {


retornar f(x);
}

160
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

int main(void)
{ printf("%f\n",apply(g, 10)); retornar 0;

A sintaxe, como você pode ver, é bastante particular. A declaração de tipo é misturada com o próprio nome do
argumento, então o padrão geral é:

tipo_retorno (nome_ponteiro) (arg1, arg2, ...)

Você vê um programa equivalente na Listagem 9.25.

Listagem 9-25. fun_type_example_alt.c


#include <stdio.h>

double g(int número) {retorna 0,5 + número; }

double apply( double (*f)(int), int x ) { return f( x ) ;

int main(void)
{ printf("%f\n",apply(g, 10)); retornar 0;

Para que servem esses tipos? Como os tipos de ponteiro de função são bastante difíceis de escrever e ler, eles
geralmente ficam ocultos em um typedef. A prática ruim (mas muito comum) é adicionar um asterisco dentro da declaração
de alias do tipo. A Listagem 9.26 mostra um exemplo onde é criado um tipo para um procedimento que não retorna nada.

Listagem 9-26. typedef_bad_fun_ptr.c

Typedef void(*proc)(void);

Neste caso você pode escrever diretamente proc my_pointer = &some_proc. No entanto, isso esconde uma informação
sobre proc ser um ponteiro: você pode deduzir mas não vê de imediato, o que é ruim. A natureza da linguagem C é, obviamente, abstrair as
coisas tanto quanto possível, mas os ponteiros são um conceito tão fundamental e tão difundido em C que você não deve abstraí-los,
especialmente na presença de tipagem fraca .
Portanto, uma solução melhor seria anotar o que é mostrado na Listagem 9.27.

Listagem 9-27. typedef_good_fun_ptr.c

typedef void(proc)(void);

...

proc* meu_ptr = &some_proc;

Além disso, esses tipos podem ser usados para escrever declarações de funções. A Listagem 9-28 mostra um exemplo.

161
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-28. fun_types_decl.c

typedef duplo(proc)(int);

/* declaração */
proc meuproc;

/* ... */

/* definição */
double meuproc(int x) { return 42,0 + x; }

9.1.15 Codificando bem


9.1.15.1 Considerações Gerais
Neste livro forneceremos diversas tarefas a serem escritas em C. Mas primeiro queremos estabelecer diversas regras que você
deve seguir, não apenas aqui e agora, mas praticamente sempre que estiver escrevendo um programa.

1. Sempre separe a lógica do programa das operações de entrada e saída. Isso vai
permitir uma melhor reutilização de código. Se uma função executa ações em dados e envia
mensagens ao mesmo tempo, você não será capaz de reutilizar sua lógica em outra situação (por
exemplo, ela pode enviar mensagens para um aplicativo com uma interface gráfica de usuário e,
em outro caso, você pode deseja usá-lo em um servidor remoto).

2. Sempre comente seu código em inglês simples.

3. Nomeie suas variáveis com base no significado delas para o programa. É muito difícil
deduza o que significam variáveis com nomes sem sentido como aaa.

4. Lembre-se de colocar const sempre que puder.

5. Use tipos apropriados para indexação.

9.1.15.2 Exemplo: somatório de array


Esta seção é uma leitura absolutamente obrigatória se você for um iniciante em C e ainda mais se você for um autodidata.
programador.
Vamos escrever um programa simples no “estilo iniciante”, ver o que há de errado com ele e modificá-lo
adequadamente para torná-lo melhor.
Aqui está a tarefa: implementar uma funcionalidade de soma de array. Por mais simples que seja, há uma enorme
diferença entre uma solução escrita por um iniciante ou uma escrita por um programador mais experiente.
O iniciante criará um programa semelhante ao mostrado na Listagem 9.29.

Listagem 9-29. beg1.c

#include <stdio.h>
matriz interna[] = {1,2,3,4,5};

int principal( int argc, char** argv ) {


int eu;
soma interna;
para (eu = 0; eu < 5; eu++)

162
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

soma = soma + matriz[i];


printf("A soma é: %d\n", soma); retornar 0;

Antes de começarmos a polir o código, podemos detectar imediatamente um bug: o valor inicial de sum não está
definido e pode ser aleatório. Variáveis locais em C não são inicializadas por padrão, então você tem que fazer isso manualmente.
Verifique a Listagem 9-30.

Listagem 9-30. beg2.c

#include <stdio.h>
matriz interna[] = {1,2,3,4,5};

int principal( int argc, char** argv ) { int i; soma


interna
= 0; for(i = 0; i
< 5; i++ ) soma = soma +
array[i]; printf("A soma é:
%d\n", soma); retornar 0;

Em primeiro lugar, este código não é totalmente reutilizável. Vamos extrair uma parte da lógica em um procedimento array_sum,
mostrado na Listagem 9.31.

Listagem 9-31. beg3.c

#include <stdio.h> int


array[] = {1,2,3,4,5};

void array_sum(void) { int i;


soma
interna = 0;
for(i = 0; i < 5; i++ ) soma =
soma + array[i]; printf("A
soma é: %d\n", soma);

int main( int argc, char** argv ) { array_sum();


retornar 0;

O que é esse número mágico 5? Cada vez que alteramos um array, temos que alterar esse número também, então
provavelmente queremos calculá-lo dinamicamente, conforme mostrado na Listagem 9.32.

Listagem 9-32. beg4.c

#include <stdio.h>
matriz interna[] = {1,2,3,4,5};

163
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

void array_sum(void) { int i;


soma
interna = 0;
for(i = 0; i < sizeof(array) / 4; i++ ) soma = soma
+ array[i]; printf("A soma
é: %d\n", soma);

int main( int argc, char** argv ) { array_sum();


retornar 0;

Mas por que estamos dividindo o tamanho do array por 4? O tamanho de int varia dependendo da arquitetura, então
terá que calculá-lo também (em tempo de compilação), conforme mostrado na Listagem 9.33.

Listagem 9-33. beg5.c


#include <stdio.h>
matriz interna[] = {1,2,3,4,5};

void array_sum(void) { int i;


soma
interna = 0;
for(i = 0; i < sizeof(array) / sizeof(int); i++ ) soma = soma +
array[i]; printf("A soma é:
%d\n", soma);
}

int main( int argc, char** argv ) { array_sum();


retornar 0;

Imediatamente enfrentamos um problema: sizeof retorna um número do tipo size_t, não int. Então, temos que
mudar o tipo de i e estamos fazendo isso por um bom motivo (veja seção 9.1.9). A Listagem 9-34 mostra o resultado.

Listagem 9-34. beg6.c


#include <stdio.h>

matriz interna[] = {1,2,3,4,5};

void array_sum( void ) { size_t


i; soma
interna = 0;
for(i = 0; i < sizeof(array) / sizeof(int); i++ ) soma = soma +
array[i]; printf("A soma é:
%d\n", soma);
}

164
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

int main( int argc, char** argv ) { array_sum();


retornar 0;

No momento, array_sum funciona apenas em arrays definidos estaticamente, porque eles são os únicos cujo tamanho
pode ser calculado por sizeof. Em seguida, queremos adicionar parâmetros suficientes a array_sum para que seja capaz de
somar qualquer array. Você não pode adicionar apenas um ponteiro a um array, porque o tamanho do array é desconhecido por
padrão, então você fornece dois parâmetros: o próprio array e a quantidade de elementos no array, conforme mostrado na Listagem 9.35 .

Listagem 9-35. beg7.c


#include <stdio.h>

matriz interna[] = {1,2,3,4,5};

void array_sum( int* array, size_t contagem ) { size_t


i; soma
interna = 0;
for(i = 0; i < contagem; i++ ) soma
= soma + array[i]; printf("A
soma é: %d\n", soma);
}

int main( int argc, char** argv )


{ array_sum(array, sizeof(array) / sizeof(int)); retornar 0;

Este código é muito melhor, mas ainda quebra a regra de não misturar entrada/saída e lógica. Você não pode usar
array_sum em nenhum lugar em programas gráficos, também não pode fazer nada com seu resultado. Vamos nos livrar
da saída da função de soma e fazer com que ela retorne seu resultado. Verifique a Listagem 9-36.

Listagem 9-36. beg8.c


#include <stdio.h>

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

int array_sum( int* array, size_t contagem ) { size_t i;


soma
interna = 0;
for(i = 0; i < contagem; i++ ) soma
= soma + array[i]; soma
de retorno;
}

int main( int argc, char** argv ) { printf(

"A soma é: %d\n",


array_sum(g_array, sizeof(g_array) / sizeof(int))
);
retornar 0;
}
165
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Por conveniência, renomeamos a variável de array global como g_array, mas isso não é necessário.
Finalmente, temos que pensar em adicionar qualificadores const. O lugar mais importante são os argumentos da função
de tipos de ponteiro. Nós realmente queremos declarar que array_sum nunca mudará o array para o qual seu argumento
está apontando. Também podemos gostar da ideia de proteger o próprio array global de ser alterado adicionando um
qualificador const.
Lembre-se de que se tornarmos g_array constante, mas não marcarmos array na lista de argumentos como tal,
não seremos capazes de passar g_array para array_sum, porque não há garantias de que array_sum não alterará os
dados para os quais seu argumento está apontando. A Listagem 9-37 mostra o resultado final.

Listagem 9-37. beg9.c

#include <stdio.h>

const int g_array[] = {1,2,3,4,5};

int array_sum( const int* array, size_t contagem ) { size_t i; soma


interna =
0; for(i = 0; i <
contagem; i++ ) soma = soma +
array[i]; soma de retorno;

int main( int argc, char** argv ) { printf(

"A soma é: %d\n",


array_sum(g_array, sizeof(g_array) / sizeof(int))
);
retornar 0;
}

Ao escrever uma solução para uma tarefa deste livro, lembre-se de todos os pontos declarados anteriormente
e verifique se o seu programa está em conformidade com eles e, caso não esteja, como pode ser melhorado.
Este programa pode ser melhorado ainda mais? Claro, e vamos dar algumas dicas sobre como.

•A matriz de ponteiros pode ser NULL? Se sim, como sinalizamos isso sem desreferenciar um
Ponteiro NULL, que provavelmente resultará em falha?
•A soma pode transbordar?

9.1.16 Atribuição: Produto Escalar Um produto escalar de dois

vetores (a1 , a2 , … , an) e (b1 , b2 , … , bn) é a soma

å =um+bi eu+ + aba


11 22 um bilhão n

eu
=1

Por exemplo, o produto escalar dos vetores (1, 2, 3) e (4, 5, 6) é

1,4 + 2,5 + 3,6 = 4 + 10 + 18 = 32

166
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

A solução deverá consistir em

•Dois arrays globais de int do mesmo tamanho.

•Uma função para calcular o produto escalar de dois arrays dados.

•Uma função principal que chama os cálculos do produto e exibe seus resultados.

9.1.17 Atribuição: Verificador de Números Primos


Você tem que escrever uma função para testar a primariedade do número. O interessante é que o número será do tipo unsigned
long e será lido a partir do stdin.

•Você tem que escrever uma função int is_prime( unsigned long n ), que verifica se n é um número
primo ou não. Se for o caso, a função retornará 1; caso contrário, 0.

•A função principal lerá um número longo não assinado e chamará a função is_prime nele. Então,
dependendo do resultado, a saída será sim ou não.

Leia man scanf e use a função scanf com o especificador de formato% lu.
Lembre-se, is_prime aceita unsigned long, o que não é a mesma coisa que unsigned int!

9.2 Tipos etiquetados


Existem três tipos de tipos “marcados” em C: estruturas, uniões e enumerações. Nós os chamamos assim porque seus
nomes consistem em uma palavra-chave struct, union ou enum seguida por uma tag mnemônica, como struct pair ou union pixel.

9.2.1 Estruturas
A abstração é absolutamente fundamental para toda programação. Ele substitui os conceitos de nível inferior e mais detalhados
por aqueles mais próximos do nosso pensamento: de nível superior, menos detalhados. Quando você pensa em visitar sua
pizzaria favorita e planeja um roteiro ideal, você não pensa em “avançar X centímetros com o pé direito”, mas sim em “atravessar
a rua” ou “virar à direita”. Enquanto para a lógica do programa o mecanismo de abstração é implementado por meio de
funções, a abstração de dados é implementada por meio de tipos de dados complexos.
Uma estrutura é um tipo de dados que contém vários campos. Cada campo é uma variável de seu próprio tipo. Matemática
provavelmente ficaria feliz em chamar estruturas de “tuplas com campos nomeados”.
Para criar uma variável de tipo estrutural podemos consultar o exemplo mostrado na Listagem 9-38. Lá nós
defina uma variável d que possua dois campos: a e b dos tipos int e char, respectivamente. Então da e db
tornam-se expressões válidas que você pode usar da mesma forma que usa nomes de variáveis.

Listagem 9-38. struct_anon.c

estrutura {int a; caractere b; }d;


da = 0;
banco de dados = 'k';

Dessa forma, entretanto, você cria apenas uma estrutura única. Na verdade, você está descrevendo um tipo de d mas não
está criando um novo tipo estrutural nomeado. O último pode ser feito usando a sintaxe mostrada na Listagem 9.39.

167
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-39. struct_named.c

par de estruturas {
interno;
intb;
};

...

par de estruturas d;
da = 0;

banco de dados = 1;

Esteja ciente de que o nome do tipo não é pair, mas struct pair, e você não pode omitir a palavra-chave struct
sem confundir o compilador. A linguagem C tem um conceito de namespaces bastante diferente dos namespaces de
outras linguagens (incluindo C++). Existe um namespace de tipo global e, em seguida, há um namespace de tag,
compartilhado entre os tipos de dados struct, union e enum. O nome após a palavra-chave struct é uma tag. Você pode definir
um tipo estrutural cujo nome seja igual a outro tipo, e o compilador irá distingui-los com base na presença da palavra-chave
struct.
Um exemplo mostrado na Listagem 9.40 demonstra duas variáveis dos tipos struct type e type, que são perfeitamente
aceitas pelo compilador.

Listagem 9-40. struct_namespace.c

typedef tipo int não assinado;


tipo de estrutura {
caracter c;
};

int principal( int argc, char** argv ) {


estrutura tipo st;
digite t;
retornar 0;
}

Isso não significa, porém, que você realmente deva criar tipos com nomes semelhantes.
No entanto, como struct type é um nome de tipo perfeitamente adequado, ele pode receber o alias de type
usando a palavra-chave typedef, conforme mostrado na Listagem 9.41. Então, os nomes do tipo e do tipo struct serão
completamente intercambiáveis.

Listagem 9-41. typedef_struct_simple.c

tipo de tipo de estrutura typedef;

ÿ Por favor, não faça isso. Não é uma boa prática criar alias para tipos estruturais usando typedef, pois isso oculta
informações sobre a natureza do tipo.

As estruturas podem ser inicializadas de forma semelhante aos arrays (veja Listagem 9.42).

168
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-42. struct_init.c

estrutura S {char const* nome; valor interno; };


...
struct S new_s = { "meunome", 4 };

Você também pode atribuir 0 a todos os campos de uma estrutura, conforme mostrado na Listagem 9.43.

Listagem 9-43. struct_zero.c

par de estrutura { int a; intb; };

...
par de estruturas p = { 0 };

No C99, existe uma sintaxe melhor para inicialização de estrutura, que permite nomear os campos para
inicializar. Os campos não mencionados serão inicializados com zeros. A Listagem 9-44 mostra um exemplo.

Listagem 9-44. struct_c99_init.c

par de estrutura
{char a;
caractere b;
};

par de estruturas st = { .a = 'a',.b = 'b' };

É garantido que os campos das estruturas não se sobrepõem; entretanto, diferentemente dos arrays, as estruturas não são contínuas
no sentido de que pode haver espaço livre entre seus campos. Assim, o tamanho de um tipo estrutural pode ser maior que a soma dos tamanhos
dos elementos devido a essas lacunas. Falaremos sobre isso no Capítulo 12.

9.2.2 Sindicatos
Os sindicatos são muito parecidos com estruturas, mas os seus campos estão sempre sobrepostos. Em outras palavras, todos os
campos de união começam no mesmo endereço. Os sindicatos compartilham seu namespace com estruturas e enumerações.
A Listagem 9-45 mostra um exemplo.

Listagem 9-45. união_exemplo.c

união dword {int


inteiro; shorts
curtos[2];
};

...
teste dword;
teste.inteiro = 0xAABBCCDD;

Acabamos de definir uma união que armazena um número de bytes de tamanho 4 (em arquiteturas x86 ou x64).
Ao mesmo tempo, ele armazena uma matriz de dois números, cada um com 2 bytes de largura. Esses dois campos
(um número de 4 bytes e um par de números de 2 bytes) se sobrepõem. Ao alterar o campo .integer, também
modificamos o array .shorts. Se atribuirmos .integer = 0xAABBCCDD e tentarmos gerar shorts[0] e shorts[1], veremos ccdd aabb.

169
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

ÿ Pergunta 162 Por que essas posições curtas parecem invertidas? Será sempre assim ou depende da
arquitetura?

Ao misturar estruturas e uniões podemos alcançar resultados interessantes. Um exemplo mostrado na Listagem 13-17
demonstra como se pode endereçar partes de uma estrutura de 3 bytes usando índices.5

Listagem 9-46. pixel.c

pixel de união
{estrutura
{char a,b,c;
};
caractere em[3];
};

Lembre-se de que se você atribuiu um campo de união a um valor, o padrão não garante nada sobre os valores
de outros campos. Uma exceção é feita para as estruturas que possuem a mesma sequência inicial de campos.

A Listagem 9-47 mostra um exemplo.

Listagem 9-47. union_guarantee.c

estrutura sa
{ int x;
char;
caractere z;
};

estrutura sb
{ int x;
char; int
notz;
};

teste de união
{ struct sa as_sa;
estrutura sb as_sb;
};

9.2.3 Estruturas e Uniões Anônimas


A partir de C11, os sindicatos e estruturas podem ser anônimos quando dentro de outras estruturas ou sindicatos.
Permite uma sintaxe menos detalhada ao acessar campos internos.
No exemplo mostrado na Listagem 9.48, para acessar o campo x de vec, você precisa escrever vec.named.x. Você
não pode omitir o nome.

5
Observe que isso pode não funcionar imediatamente para tipos mais amplos devido a possíveis lacunas entre os campos struct.

170
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Listagem 9-48. anon_no.c

união vec3d
{ struct
{ double x;
duplo y;
duplo z; }
nomeado;
duplo bruto[3];
};

união vec3d vec;

Agora, no próximo exemplo, mostrado na Listagem 9.49, nos livramos do nome do primeiro campo (named). Isso é
uma estrutura anônima, e agora podemos acessar seus campos como se fossem os campos do próprio vec: vec.x.

Listagem 9-49. anon_struct.c

união vec3d
{ struct
{ double x;
duplo y;

duplo z;
};
duplo bruto[3];
};

união vec3d vec;

9.2.4 Enumerações
As enumerações são um tipo de dados simples baseado no tipo int. Ele fixa certos valores e lhes dá nomes, semelhante ao modo
como DEFINE funciona.
Por exemplo, o semáforo pode estar em um dos seguintes estados (com base em quais semáforos estão acesos):

•Vermelho.

•Vermelho e amarelo.

•Amarelo.

•Verde.

•Sem luzes.

Isso pode ser codificado em C conforme mostrado na Listagem 9-50.

Listagem 9-50. enum_exemplo.c

enum luz {
VERMELHO,

VERMELHO E AMARELO,
AMARELO,
VERDE,

171
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

NADA
};

...
enum light l = nada;
...

Quando é útil? É frequentemente usado para codificar o estado de uma entidade, por exemplo, como parte de um
autômato finito; pode servir como um pacote de códigos de erro ou mnemônicos de código.
O valor constante 0 foi denominado RED, RED_AND_YELLOW significa 1, etc.

9.3 Tipos de dados em linguagens de programação


Fornecemos uma visão geral dos tipos de dados em C; agora vamos dar um passo atrás em C e observar o panorama geral e os
tipos de sistemas em linguagens de programação.
Em muitas áreas da ciência da computação e da programação, a evolução passou do universo não digitado para o
digitado. Por exemplo, as seguintes entidades não são digitadas:

1. Termos lambda em cálculo lambda não tipado;

2. Conjuntos em muitas teorias de conjuntos, por exemplo, ZF;

3. Expressões S em linguagem LISP; e

4. Sequências de bits.

Estamos mais interessados em strings de bits no momento. Para o computador, tudo é uma sequência de bits de tamanho
fixo. Eles podem ser interpretados como números (inteiros ou reais), sequências de códigos de caracteres ou qualquer outra coisa.
Podemos dizer que o assembly é uma linguagem não tipada.
Porém, quando começamos a trabalhar em um ambiente não digitado, tentamos dividir os objetos em vários
categorias. Estamos trabalhando com objetos da mesma categoria de maneira semelhante. Portanto, estabelecemos uma
convenção: essas cadeias de bits são números inteiros, esses são números de ponto flutuante, etc.
É isso, a digitação? Ainda não. Ainda não estamos limitados em nossas capacidades e podemos adicionar um número de ponto
flutuante a um ponteiro de string, porque a linguagem de programação não impõe nenhum controle de tipo. Esta verificação de tipo
pode ser realizada em tempo de compilação (digitação estática) ou em tempo de execução (digitação dinâmica).
Portanto, não estamos apenas dividindo todos os tipos de objetos possíveis em categorias, mas também declarando quais
operações podem ser executadas em cada tipo. Os dados de diferentes tipos também são frequentemente codificados de maneira diferente.

9.3.1 Tipos de digitação


Além da digitação estática e dinâmica, existem também outras classificações ortogonais.
A digitação forte significa que todas as operações requerem exatamente o argumento de que precisam. Não implícito
conversões de outros tipos para os necessários são permitidas.
Digitação fraca significa que existem conversões implícitas entre tipos que tornam possíveis as operações em dados que não são
exatamente do tipo requerido (mas existe uma conversão para um tipo requerido).
Esta divisão não é estritamente binária; no mundo real, os idiomas tendem a estar mais próximos de um desses dois
pólos. Temos casos bastante extremos, como Ada para digitação forte e JavaScript para digitação fraca.
Às vezes também dividimos os idiomas com base na verbosidade.
Com a digitação explícita, sempre anotamos os dados com tipos.
Com a digitação implícita , permitimos que o compilador infira o tipo sempre que possível.
Agora daremos exemplos do mundo real de todas as combinações de tipagem estática/dinâmica e forte/fraca.

172
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

9.3.1.1 Digitação forte estática


Os tipos são verificados em tempo de compilação e o compilador é pedante com relação a eles.
Na linguagem OCaml existem dois operadores de adição diferentes: + para números inteiros e +. por reais. Então,
este código gerará um erro em tempo de compilação:

4+. 1,0

Usamos os dados do tipo int quando o compilador esperava um float e, ao contrário de C, onde teria ocorrido uma conversão,
gerou um erro. Esta é a essência de uma digitação muito forte.

9.3.1.2 Digitação Estática Fraca


A linguagem C possui exatamente esse tipo de digitação. Todos os tipos são conhecidos em tempo de compilação, mas as conversões
implícitas ocorrem com bastante frequência.
A linha quase idêntica double x = 4 + 3,0; não causa erros no compilador, porque 4 é automaticamente promovido para double
e depois adicionado a 3.0. A fraqueza se expressa no fato de que o programador não especifica explicitamente as operações de
conversão.

9.3.1.3 Digitação Dinâmica Forte


Este é o tipo de digitação usado em Python. Python não permite conversões implícitas entre tipos tanto quanto o JavaScript. No
entanto, os erros de tipo não serão relatados até que você inicie o programa e tente executar a instrução errada.

Python possui um interpretador onde você pode digitar expressões e instruções e executá-las imediatamente. Se você
tentar avaliar uma expressão "3" + 2 e ver seu resultado em um interpretador Python interativo, receberá um erro porque o
primeiro objeto é uma string e o segundo é um número. Mesmo que esta string contenha um número (portanto, uma conversão
poderia ter sido escrita), a adição não é permitida. A Listagem 9-51 mostra o dump.

Listagem 9-51. Erro de digitação em Python

>>> "3" + 2
Traceback (última chamada mais recente):
Arquivo "<stdin>", linha 1, em <module>
TypeError: não é possível concatenar objetos 'str' e 'int'

Agora vamos tentar avaliar uma expressão 1 se True, caso contrário, "3" + 2. Esta expressão é avaliada como 1 se True
for true (o que obviamente é válido); caso contrário, seu valor será resultado da mesma operação inválida "3" + 2.
No entanto, como nunca chegaremos ao ramo else, não haverá erro gerado, mesmo em tempo de execução.
A Listagem 9-52 mostra o dump do terminal. Quando aplicado a duas strings, o sinal de mais atua como um operador de
concatenação.

Listagem 9-52. Digitação Python: nenhum erro porque a instrução não foi executada

>>> 1 se Verdadeiro , senão "3" + 2


1
>>> "1" + "2"
'12'

173
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

9.3.1.4 Digitação Dinâmica Fraca


Provavelmente a linguagem mais usada com essa digitação é o JavaScript.
No exemplo que fornecemos para Python, tentamos adicionar um número a uma string. Apesar de o
string continha um número decimal válido, um erro foi relatado, porque uma string é uma string, seja lá o que for que possa conter.
Seu tipo não será alterado automaticamente.
No entanto, o JavaScript é muito menos rigoroso quanto ao que você pode fazer. Usaremos o console JavaScript interativo
(que você pode acessar em praticamente qualquer navegador moderno) e digitar algumas expressões. A Listagem 9-53 mostra o resultado.

Listagem 9-53. Conversões implícitas de JavaScript

>>> 3 == '3'
verdadeiro

>>> 3 == '4'
falso
>>> "7,0" == 7

verdadeiro

Estudando apenas este exemplo podemos deduzir que quando um número e uma string são comparados, ambos os lados são
aparentemente convertidos em um número e depois comparados. Não está claro se os números são inteiros ou reais, mas a quantidade
de operações implícitas em ação aqui é bastante surpreendente.

9.3.2 Polimorfismo
Agora que temos um entendimento geral sobre tipagem, vamos atrás de um dos conceitos mais importantes relacionados aos
sistemas de tipos, a saber, polimorfismo.
Polimorfismo (do grego: polys, “muitos, muito” e morph, “forma, formato”) é a possibilidade de chamar diferentes ações para
diferentes tipos de maneira uniforme. Você também pode pensar de outra forma: as entidades de dados podem assumir diferentes tipos.

Existem quatro tipos diferentes de polimorfismo [8], que também podemos dividir em duas categorias:

1. Polimorfismo universal, quando uma função aceita um argumento de um número infinito de tipos
(incluindo talvez até aqueles que ainda não estão definidos) e se comporta de maneira semelhante
para cada um deles.

• Polimorfismo paramétrico, onde uma função aceita um argumento adicional, definindo o tipo de outro
argumento.

Em linguagens como Java ou C#, as funções genéricas são um exemplo de polimorfismo


paramétrico em tempo de compilação.

• Inclusão, onde alguns tipos são subtipos de outros tipos. Então, quando dado um
argumento de um tipo filho, a função se comportará da mesma maneira que quando o tipo pai é
fornecido.

2. Ad hoc, onde as funções aceitam um parâmetro de um conjunto fixo de tipos e estes


as funções podem operar de maneira diferente em cada tipo.

• Sobrecarga, existem várias funções com o mesmo nome e uma delas é chamada
com base em um tipo de argumento.

• Coerção, onde existe uma conversão do tipo X para o tipo Y e uma função aceitando
um argumento do tipo Y é chamado com um argumento do tipo X.

174
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

O popular paradigma de programação orientada a objetos popularizou a noção de polimorfismo, mas de uma forma
muito particular. A programação orientada a objetos geralmente se refere a apenas um tipo de polimorfismo, a saber,
subtipagem, que é essencialmente o mesmo que inclusão, porque os objetos do tipo filho formam um subconjunto de
objetos do tipo pai.
Às vezes é difícil dizer que tipo de polimorfismo é usado em um determinado local. Considere as quatro linhas
a seguir:

3+4
3 + 4,0
3,0 + 4
3,0 + 4,0

A operação “mais” aqui é obviamente polimórfica, porque é usada da mesma forma com todos os tipos de operandos int
e duplos. Mas como isso é realmente implementado? Podemos pensar em diferentes opções, por exemplo,

•Este operador possui quatro sobrecargas para todas as combinações.

•Este operador possui duas sobrecargas para os casos int + int e double + double.
Além disso, é definida uma coerção de int para double.

•Este operador só pode somar dois reais, e todos os ints são forçados a dobrar.

9.4 Polimorfismo em C
A linguagem C permite diferentes tipos de polimorfismos, e alguns podem ser emulados através de pequenos truques.

9.4.1 Polimorfismo Paramétrico


Podemos criar uma função que se comporte de maneira diferente para diferentes tipos de argumentos com base em um tipo
explicitamente determinado? Podemos fazer isso até certo ponto, mesmo em C89. No entanto, necessitaremos de alguma
maquinaria macro bastante pesada para alcançar um resultado suave.
Primeiro, precisamos saber o que esse símbolo sofisticado # faz em um contexto macro. Quando usado dentro de uma macro, o
# símbolo citará o conteúdo do símbolo. A Listagem 9-54 mostra um exemplo.

Listagem 9-54. macro_str.c

#define mystr olá


#define res #mystr

coloca(res); /* será substituído por `puts("hello")`

O operador ## é ainda mais interessante. Isso nos permite formar nomes de símbolos dinamicamente. Listagem 9-55
mostra um exemplo.

Listagem 9-55. macro_concat.c

#define x1 "Olá"
#define x2 " Mundo"

#define str(i)x##i

coloca(str(1)); /* str(1) -> x1 -> "Olá" */


coloca(str(2) ); /*str(2) -> x2 -> " Mundo" */

175
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Alguns recursos da linguagem de nível superior podem ser resumidos à lógica do compilador realizando uma análise
do programa e fazendo uma chamada para uma ou outra função, usando uma ou outra estrutura de dados, etc. Em C podemos
imitá-lo contando com um pré-processador.
A Listagem 9-56 mostra um exemplo.

Listagem 9-56. c_parametric_polimorfismo.c


#include <stdio.h>
#include <stdbool.h>

#define par(T) par_##T


#define DEFINE_PAIR(T) par de estruturas(T) {\
T primeiro;\
Tsnd;\
};\
bool pair_##T##_any(estrutura par(T) par, bool (*predicado)(T)) {\
retornar predicado(par.fst) || predicado(par.snd); \
}

#define qualquer(T) par_##T##_qualquer

DEFINE_PAIR(int)

bool é_positivo(int x) { return x > 0; }


int principal( int argc, char** argv ) {
estrutura par(int) obj;
obj.fst = 1;
obj.snd = -1;
printf("%d\n", any(int)(obj, is_positivo) );
retornar 0;
}

Primeiro, incluímos o arquivo stdbool.h para obter acesso ao tipo bool, como dissemos na seção 9.1.3.

•pair(T) quando chamado assim: pair(int) será substituído pela string pair_int.

•DEFINE_PAIR é uma macro que, quando chamada assim: DEFINE_PAIR(int), será


substituído pelo código mostrado na Listagem 9-57.

Observe as barras invertidas no final de cada linha: elas são usadas para escapar do caractere
de nova linha, fazendo com que essa macro se estenda por várias linhas. A última linha da
macro não termina com uma barra invertida.

Este código define um novo tipo estrutural chamado struct pair_int, que contém
essencialmente dois inteiros como campos. Se instanciarmos esta macro com um
parâmetro diferente de T, teríamos um par de elementos de tipo diferente.

Em seguida é definida uma função, que terá um nome específico para cada
instanciação da macro, já que o nome do parâmetro T está codificado em seu nome.
No nosso caso é pair_int_any, cujo objetivo é verificar se algum dos dois elementos
do par satisfaz a condição. Ele aceita o próprio par como primeiro argumento e a
condição como segundo. A condição é essencialmente um ponteiro para uma
função que aceita T e retorna bool, um predicado, como o próprio nome sugere.

176
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

pair_int_any inicia a função de condição no primeiro elemento e depois no segundo


elemento.

Quando usado, DEFINE_PAIR define a estrutura que contém dois elementos de um


determinado tipo e funções para trabalhar com ela. Podemos ter apenas uma cópia dessas
funções e definição de estrutura para cada tipo, mas precisamos delas, então queremos
instanciar DEFINE_PAIR uma vez para cada tipo com o qual queremos trabalhar.

Listagem 9-57. macro_define_pair.c

estrutura par_int {
int primeiro;
int snd;
};
bool pair_int_any(struct pair_int pair, bool (*predicado)(int)) {
retornar predicado(par.fst) || predicado(par.snd);
}

•Então uma macro #define any(T) pair_##T##_any é definida. Observe que seu único
aparentemente, o objetivo é apenas formar um nome de função válido, dependendo do tipo. Isso nos
permite chamar pair_##T##_any de uma forma bastante elegante: any(int), como se fosse uma
função retornando um ponteiro para uma função.

Assim, sintaticamente chegamos muito perto de um conceito de polimorfismo paramétrico: estamos


fornecendo um argumento adicional (int) que serve para determinar o tipo de outro argumento (struct pair_int).
Claro, não é tão bom quanto os argumentos de tipo em linguagens funcionais ou mesmo parâmetros de tipo genérico
em C# ou Scala, mas é alguma coisa.

9.4.2 Inclusão
A inclusão é bastante fácil de conseguir em C para tipos de ponteiro. A ideia é que o endereço de cada estrutura seja igual
ao endereço de seu primeiro membro.
Dê uma olhada no exemplo mostrado na Listagem 9-58.

Listagem 9-58. c_inclusão.c


#include <stdio.h>

estrutura pai {
const char* campo_parent;
};

estrutura filho {
estrutura base pai;
const char* campo_filho;
};

void parent_print(estrutura pai* this) {


printf("%s\n", this->field_parent );
}

177
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

int principal( int argc, char** argv ) {


estrutura filho c;
c.base.field_parent = "pai";
c.field_child = "criança";
parent_print( (estrutura pai*) &c );

retornar 0;
}

A função parent_print aceita um argumento do tipo parent*. Como sugere a definição de criança,
seu primeiro campo possui um tipo pai. Portanto, toda vez que temos um ponteiro válido filho*, existe um ponteiro
para uma instância de pai que é igual ao anterior. Portanto, é seguro passar um ponteiro para um filho quando se espera
um ponteiro para o pai.
O sistema de tipos, entretanto, não está ciente disso; portanto, você deve converter o ponteiro filho* em pai*,
como visto na chamada parent_print( (struct parent*) &c );. Poderíamos substituir o tipo struct parent*
com void* neste caso, porque qualquer tipo de ponteiro pode ser convertido em void* (veja seção 9.1.5).

9.4.3 Sobrecarga
A sobrecarga automatizada não era possível em C até C11. Até recentemente, as pessoas incluíam os nomes dos tipos de
argumentos nos nomes das funções para fornecer diferentes “sobrecargas” com algum nome base. Agora, o padrão mais
recente incluiu uma macro especial que se expande com base no tipo de argumento: _Generic. Tem uma ampla gama de usos.
A macro _Generic aceita uma expressão E e depois muitas cláusulas de associação, separadas por vírgula.
Cada cláusula tem o formato do tipo nome: string. Quando instanciado, o tipo de E é verificado em relação a todos os tipos
na lista de associações, e a sequência correspondente à direita dos dois pontos será o resultado da instanciação.
No exemplo mostrado na Listagem 9.59, definiremos uma macro print_fmt, que pode escolher um especificador de
formato printf apropriado com base no tipo de argumento, e uma macro print, que forma uma chamada válida para printf e
então gera uma nova linha.
print_fmt corresponde ao tipo da expressão x com um dos dois tipos: int e double. Caso o tipo de x não esteja
nesta lista, o caso padrão é executado, fornecendo um especificador %x bastante genérico. Entretanto, na ausência
do caso padrão, o programa não seria compilado caso você fornecesse ao print_fmt uma expressão do tipo, digamos,
long double. Portanto, neste caso, provavelmente seria sensato apenas omitir o caso padrão, forçando o aborto da
compilação quando não sabemos realmente o que fazer.

Listagem 9-59. c_overload_11.c

#include <stdio.h>

#define print_fmt(x) (_Generic( (x), \


int: "%d",\
duplo: "%f",\
padrão: "%x"))

#define imprimir(x) printf( imprimir_fmt(x), x ); coloca("");

int principal(void) {
interno x = 101;
duplo y = 42,42;
imprimir(x);
imprimir(y);
retornar 0;
}

178
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

Podemos usar _Generic para escrever uma macro que agrupará uma chamada de função e selecionará uma com nome diferente
funções baseadas em um tipo de argumento.

9.4.4 Coerções
C possui diversas coerções incorporadas na própria linguagem. Estamos falando essencialmente sobre conversões de ponteiro
para void* e conversões inversas e inteiras, descritas na seção 9.1.4. Até onde sabemos, não há como adicionar coerções definidas
pelo usuário ou qualquer coisa que pareça pelo menos remotamente semelhante, semelhante às funções implícitas do Scala ou
às conversões implícitas do C++.
Como você pode ver, de alguma forma, C permite todos os quatro tipos de polimorfismo.

9.5 Resumo
Neste capítulo fizemos um extenso estudo do sistema do tipo C: arrays, ponteiros, tipos constantes. Aprendemos a fazer ponteiros de
função simples, vimos as advertências de sizeof, revisamos strings e começamos a nos acostumar com melhores práticas de código.
Depois aprendemos sobre estruturas, uniões e enumerações. No final, falamos brevemente sobre sistemas de tipos em linguagens de
programação convencionais e polimorfismo e fornecemos alguns exemplos de código avançados para demonstrar como obter
resultados semelhantes usando C simples. código em um projeto e as propriedades da linguagem que são importantes neste contexto.

ÿ Pergunta 163 Qual é a finalidade dos operadores & e * ?

ÿ Pergunta 164 Como lemos um número inteiro de um endereço 0x12345?

ÿ Pergunta 165 Que tipo tem o literal 42 ?

ÿ Pergunta 166 Como criamos um literal dos tipos unsigned long, long e long long?

ÿ Pergunta 167 Por que precisamos do tipo size_t ?

ÿ Pergunta 168 Como convertemos valores de um tipo para outro?

ÿ Pergunta 169 Existe um tipo booleano em C89?

ÿ Pergunta 170 O que é um tipo de ponteiro?

ÿ Pergunta 171 O que é NULL?

ÿ Pergunta 172 Qual é a finalidade do tipo void* ?

ÿ Pergunta 173 O que é um array?

ÿ Pergunta 174 Qualquer célula de memória consecutiva pode ser interpretada como uma matriz?

ÿ Pergunta 175 O que acontece ao tentar acessar um elemento fora dos limites do array?

ÿ Pergunta 176 Qual é a conexão entre arrays e ponteiros?

179
Machine Translated by Google

Capítulo 9 ÿ Sistema de tipos

ÿ Pergunta 177 É possível declarar um ponteiro para uma função?

ÿ Pergunta 178 Como criamos um alias para um determinado tipo?

ÿ Pergunta 179 Como os argumentos são passados para a função principal?

ÿ Pergunta 180 Qual é a finalidade do operador sizeof ?

ÿ Pergunta 181 O sizeof é avaliado durante a execução do programa?

ÿ Pergunta 182 Por que a palavra-chave const é importante?

ÿ Pergunta 183 O que são tipos de estrutura e por que precisamos deles?

ÿ Pergunta 184 Quais são os tipos de sindicato? Como eles diferem dos tipos de estrutura?

ÿ Pergunta 185 O que são tipos de enumeração? Como eles diferem dos tipos de estrutura?

ÿ Pergunta 186 Que tipos de digitação existem?

ÿ Pergunta 187 Que tipos de polimorfismo existem e qual a diferença entre eles?

180
Machine Translated by Google

CAPÍTULO 10

Estrutura de código

Neste capítulo estudaremos como dividir melhor seu código em vários arquivos e quais recursos de linguagem
relevantes existem. Ter um único arquivo com uma confusão de funções e definições de tipo está longe de ser
conveniente para grandes projetos. A maioria dos programas é dividida em vários módulos. Vamos estudar quais
benefícios ele traz e como fica cada módulo antes da vinculação.

10.1 Declarações e Definições


Os compiladores C historicamente foram escritos como programas de passagem única. Isso significa que eles deveriam
ter percorrido o arquivo uma vez e traduzido imediatamente. No entanto, isso significa muito para nós. Quando uma função
é chamada e ainda não está definida, o compilador rejeitará tal programa porque não sabe o que esse nome significa.
Embora estejamos cientes de nossa intenção de chamar uma função neste local, para ela este é apenas um
identificador indefinido e, devido à tradução de passagem única, o compilador não pode olhar para frente e tentar encontrar a definição.
Em casos simples de dependência linear podemos simplesmente definir todas as funções antes de serem usadas.
Porém, há casos de dependências circulares, quando este esquema não funciona, nomeadamente, as definições
recursivas mútuas, sejam elas estruturas ou funções.
No caso de funções, existem duas funções que se chamam. Aparentemente, seja qual for a ordem em que os
definimos, não podemos definir ambos antes que a chamada seja vista pelo compilador. A Listagem 10-1 mostra um exemplo.

Listagem 10-1. fun_mutual_recursive_bad.c

vazio f(vazio) {
g(); /* O que é `g`, pergunta o Sr. Compilador? */
}

vazio g(vazio) {
f();
}

No caso de estruturas, estamos falando de dois tipos estruturais. Cada um deles possui um campo do tipo ponteiro,
apontando para uma instância da outra estrutura. A Listagem 10-2 mostra um exemplo.

Listagem 10-2. struct_mutual_recursive_bad.c

estruturar um {
estrutura b*foo;
};
estrutura b {
estrutura uma* barra;
};

© Igor Zhirkov 2017 181


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_10
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

A solução está no uso de declarações e definições divididas. Quando uma declaração precede a definição, ela
é chamada de declaração direta.

10.1.1 Declarações de Função


Para funções, a declaração se parece com uma definição sem corpo, terminada por ponto e vírgula. A Listagem 10-3 mostra
um exemplo.

Listagem 10-3. fun_decl_def.c


/* Esta é a declaração */
vazio f(int x);

/* Esta é a definição */
vazio f(int x) {
coloca("Olá!");
}

Tais declarações são algumas vezes chamadas de protótipos de função. Cada vez que você está usando uma função
cujo corpo ainda não está definido OU está definido em outro arquivo, você deve escrever seu protótipo primeiro.
No protótipo da função, os nomes dos argumentos podem ser omitidos, conforme mostrado na Listagem 10-4.

Listagem 10-4. fun_proto_omit_arguments.c

int quadrado(int x);


/* igual a */
int quadrado(int);

Resumindo, dois cenários são considerados corretos para funções.

1. A função é definida primeiro e depois chamada (veja Listagem 10-5).

Listagem 10-5. fun_sc_1.c


*
int quadrado(int x) {retorna x x; }

...
int z = quadrado(5);

2. Primeiro crie um protótipo, depois chame e então a função é definida (veja Listagem 10-6).

Listagem 10-6. fun_sc_2.c

int quadrado(int x);

...
int z = quadrado(5);

...

*
int quadrado(int x) {retorna x x; }

182
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

A Listagem 10.7 mostra uma situação de erro típica, onde o corpo da função é declarado após a chamada, mas
nenhuma declaração precede a chamada.

Listagem 10-7. fun_sc_3.c

int z = quadrado (5);


...

*
int quadrado(int x) {retorna x x; }

10.1.2 Declarações de Estrutura


É bastante comum definir uma estrutura de dados recursiva, como uma lista vinculada. Cada elemento armazena um valor e um link
para o próximo elemento. O último elemento armazena NULL em vez de um ponteiro válido para marcar o fim da lista. Listagem 10-8
mostra a definição da lista vinculada.

Listagem 10-8. lista_definição.c

lista de estruturas {
valor interno;
lista de estruturas* próximo;
};

No entanto, no caso de duas estruturas mutuamente recursivas, é necessário adicionar uma declaração direta para pelo menos
um deles. A Listagem 10-9 mostra um exemplo.

Listagem 10-9. estruturas_recursivas mútuas.c

estrutura b; /* declaração de encaminhamento */


estruturar um {
valor interno;
estrutura b* próximo;
};

/* não há necessidade de encaminhar a declaração da struct a porque ela já está definida */


estrutura b {
estruturar um* outro;
};

Se não houver definição de um tipo marcado, mas apenas uma declaração, ele é chamado de tipo incompleto. Neste
caso podemos trabalhar livremente com ponteiros para ela, mas nunca podemos criar uma variável desse tipo, desreferencia-
la ou trabalhar com arrays desse tipo. As funções não devem retornar uma instância deste tipo, mas, da mesma forma,
podem retornar um ponteiro. A Listagem 10-10 mostra um exemplo.

Listagem 10-10. incompleto_tipo_exemplo.c

struct lista_t;

struct llist_t* f() { ... } /* ok */


struct llist_t g(); /* OK */
struct llist_t g() { ... } /* ruim */

Esses tipos têm um caso de uso muito específico que iremos elaborar no Capítulo 13.

183
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.2 Acessando código de outros arquivos


10.2.1 Funções de outros arquivos
É claro que é possível chamar funções ou fazer referência a variáveis globais de outros arquivos. Para realizar uma
chamada, você deve adicionar o protótipo da função chamada ao arquivo atual. Por exemplo, você tem dois arquivos:
square.c, que contém uma função square, e main_square.c, que contém a função principal. A Listagem 10-11 e a Listagem
10-12 mostram esses arquivos.

Listagem 10-11. quadrado.c


* x; }
int quadrado(int x) {retorna x

Listagem 10-12. main_square.c


#include <stdio.h>
int quadrado(int x);

int principal(void) {
printf("%d\n", quadrado(5));
retornar 0;
}

Cada arquivo de código é um módulo separado e, portanto, é compilado de forma independente, assim como no
assembly. Um arquivo .c é traduzido em um arquivo objeto. Quanto aos nossos fins educacionais, optamos pelos arquivos ELF
(Executable and Linkable Format); vamos abrir os arquivos-objeto resultantes e ver o que há dentro. Consulte a Listagem 10-13
para ver a tabela de símbolos dentro do arquivo objeto main_square.o e a Listagem 10-14 para o arquivo square.o. Consulte a
seção 5.3.2 para obter a explicação do formato da tabela de símbolos.

Listagem 10-13. Praça principal

> gcc -c -std=c89 -pedantic -Wall main_square.c


> objdump -t main_square.o

principal.o: formato de arquivo elf64-x86-64

TABELA DE SÍMBOLOS:

.note.GNU-stack df *ABS* 0000000000000000 principal.c


d .texto 0000000000000000 .texto
d.dados 0000000000000000.dados
d.bss0000000000000000.bss
d .note.pilha GNU

000000000000000ld.eh_frame
000000000000000.eh_frame
000000000000000 ld .comentar
000000000000000 .comentar
000000000000000g F .text 000000000000001c principal
0000000000000000 *UND* 0000000000000000 quadrado

184
Machine Translated by Google

Capítulo 10 ÿ Estrutura do Código

Listagem 10-14. quadrado

> gcc -c -std=c89 -pedantic -Wall square.c


> objdump -t quadrado.o
quadrado.o: formato de arquivo elf64-x86-64

TABELA DE SÍMBOLOS:

000000000000000 litros df *ABS* 0000000000000000 quadrado.c


000000000000000 litros d .texto 0000000000000000 .texto
000000000000000 litros d.dados 0000000000000000.dados
000000000000000 litros d.bss0000000000000000.bss
000000000000000 litros d .note.pilha GNU
000000000000000 .note.pilha GNU
000000000000000 litros d.eh_frame
000000000000000.eh_frame
000000000000000 litros d.comentário
000000000000000 .comentar
000000000000000g F .texto 0000000000000010 quadrado

Como você pode ver, todas as funções (ou seja, quadrada e principal) tornaram-se símbolos globais, como sugere a letra g
na segunda coluna, apesar de não estarem marcadas de alguma forma especial. Isso significa que todas as funções são como
rótulos marcados com palavras-chave globais em assembly – em outras palavras, visíveis para outros módulos.
O protótipo da função square, localizado em main_square.c, é atribuído a uma seção indefinida.

000000000000000 *UND* 0000000000000000 quadrado

O GCC fornece acesso a todo o conjunto de ferramentas do compilador, o que significa que ele não está apenas
traduzindo arquivos, mas chamando o vinculador com argumentos apropriados. Ele também vincula arquivos à biblioteca C padrão.
Após a vinculação, a tabela de símbolos fica mais preenchida devido à biblioteca padrão e aos símbolos utilitários,
como .gnu.version.

ÿ Pergunta 188 Compile o arquivo main usando gcc -o main main_square.o square.o line. Estude sua tabela de
objetos usando objdump -t main. O que você pode dizer sobre as funções principal e quadrada?

10.2.2 Dados em outros arquivos


Se houver uma variável global definida em outro arquivo .c que desejamos endereçar, ela deverá ser declarada, preferencialmente,
mas não necessariamente, com a palavra-chave extern. Você não deve inicializar variáveis externas; caso contrário, o
compilador emitirá um aviso.
A Listagem 10-15 e a Listagem 10-16 mostram o primeiro exemplo de uso de uma variável global de outro arquivo.

Listagem 10-15. quadrado_ext.c

externo int z;
*
int quadrado(int x) {retorna x x+z; }

185
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Listagem 10-16. main_ext.c

int z = 0;
int quadrado(int x);

int principal(void) {
printf("%d\n", quadrado(5));
retornar 0;
}

O padrão C marca a palavra-chave extern como opcional. Recomendamos que você nunca omita extern
palavra-chave para que você possa distinguir facilmente em qual arquivo exatamente deseja criar uma variável.
No entanto, caso você omita a palavra-chave extern, como o compilador distingue entre definição e declaração de variável,
quando nenhuma inicialização é fornecida? É especialmente interessante porque os arquivos são compilados separadamente.

Para estudar esta questão, daremos uma olhada nas tabelas de símbolos para arquivos de objetos usando o utilitário nm.

Anotamos os arquivos main.c e other.c, e então os compilamos em arquivos .o usando o sinalizador -c e depois os vinculamos.
A Listagem 10-17 mostra a sequência de comandos.

Listagem 10-17. glob_build

> gcc -c -std=c89 -pedantic -Wall -o main.o main.c


> gcc -c -std=c89 -pedantic -Wall -o outro.o outro.c
> gcc -o principal principal.o outro.o

Existe uma variável global chamada x. Não é atribuído um valor em main.c, mas é inicializado em other.c.

Usando nm podemos visualizar rapidamente a tabela de símbolos, conforme mostrado na Listagem 10-18. Encurtamos a tabela
para o arquivo executável principal propositalmente para evitar sobrecarregar a listagem com símbolos de serviço.

Listagem 10-18. glob_nm

> nm principal.o
000000000000000 T principal
Você imprime
000000000000004 C x

> nm outro.o
000000000000000 D x

>nm principal
000000000400526 T principal
U printf@@GLIBC_2.2.5
000000000601038D x

Como vemos, em main.o o símbolo x, correspondente à variável int x, é marcado com a flag C (global
comum), enquanto no outro arquivo objeto main.o está marcado como D (dados globais). Pode haver quantos símbolos comuns
globais semelhantes você desejar e, no arquivo executável resultante, todos eles serão compactados em um.
Entretanto, você não pode ter múltiplas declarações do mesmo símbolo no mesmo arquivo de origem; você está limitado a
no máximo uma declaração e uma definição.

186
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.2.3 Arquivos de cabeçalho

Portanto, agora sabemos como dividir o código em vários arquivos. Todo arquivo que utiliza uma definição externa deve
ter sua declaração escrita antes do uso real. No entanto, quando a quantidade de arquivos aumenta, manter a consistência
torna-se difícil. Uma prática comum é usar arquivos de cabeçalho para facilitar a manutenção.
Digamos que existam dois arquivos: main_printer.c e Printer.c. As listagens 10-19 e 10-20 mostram-nas.

Listagem 10-19. impressora_principal.c

void print_one(void);
vazio print_dois(void);
int principal(void) {
print_one();

print_dois();
retornar 0;
}

Listagem 10-20. impressora.c

#include <stdio.h>

void print_one(void) {
coloca("Um");
}
void print_two(void) {
coloca("Dois");
}

Aqui está o cenário do mundo real. Para usar uma função do arquivo impressora.c em algum arquivo other.c,
você deve anotar os protótipos das funções definidas em print.c em algum lugar no início de other.c. Para usá-los
no terceiro arquivo, você terá que escrever seus protótipos também no terceiro arquivo. Então, por que fazer isso
manualmente quando podemos criar um arquivo separado que conterá apenas declarações de funções e variáveis
globais, mas não definições, e então incluí-lo com a ajuda de um pré-processador?
Vamos modificar este exemplo introduzindo um novo arquivo de cabeçalho Printer.h, contendo todos
declarações da impressora.c. A Listagem 10.21 mostra o arquivo de cabeçalho.

Listagem 10-21. impressora.h

void print_one(void);
void print_dois(void);

Agora, toda vez que você quiser usar funções definidas em impressora.c basta colocar a seguinte linha
no início do arquivo de código atual:

#include "impressora.h"

O pré-processador substituirá esta linha pelo conteúdo de impressora.h. A Listagem 10.22 mostra o novo arquivo
principal.

187
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Listagem 10-22. impressora_principal_nova.c

#include "impressora.h"

int principal(void) {
print_one();
print_dois();
retornar 0;
}

ÿ Observação Os arquivos de cabeçalho não são compilados. O compilador os vê apenas como partes de arquivos .c .

Este mecanismo, que é semelhante aos módulos ou bibliotecas importados de linguagens como Java
ou C#, é por natureza muito diferente. Então, dizer que a linha #include "some.h" significa “importar uma biblioteca chamada some”
é muito errado. Incluir um arquivo de texto não é importar uma biblioteca! Bibliotecas estáticas, como sabemos, são
essencialmente os mesmos arquivos-objeto produzidos pela compilação de arquivos .c. Portanto, a imagem de um arquivo fc
exemplar é a seguinte:

•A compilação do fc é iniciada.

•O pré-processador encontra as diretivas #include e inclui o .h correspondente


arquivos “como estão”.

•Cada arquivo .h contém protótipos de função, que se tornarão entradas no símbolo


tabela após a tradução do código.

•Para cada entrada semelhante a importação, o vinculador pesquisará em todos os arquivos de objeto em sua
entrada um símbolo definido (na seção .data, .bss, ou .texto). Em um lugar, ele encontrará esse símbolo e
vinculará a ele a entrada semelhante à importação.

Este símbolo pode ser encontrado na biblioteca padrão C.


Mas espere, estamos dando ao vinculador a biblioteca padrão como entrada? Vamos discutir isso na próxima seção.

10.3 Biblioteca Padrão


Já utilizamos os cabeçalhos, correspondentes a partes da biblioteca padrão, como stdio.h. Eles não contêm as funções padrão
em si, mas seus protótipos. Você não precisa acreditar, porque você pode verificar por si mesmo.

Para isso, crie um arquivo pc que contenha apenas uma linha: #include <stdio.h>. Em seguida, inicie o GCC
nele, fornecendo o sinalizador -E para parar após o pré-processamento e gerar os resultados em stdout. Use o utilitário grep
para procurar a ocorrência de printf e você encontrará seu protótipo, conforme mostrado na Listagem 10.23.

Listagem 10-23. printf_check_header

> computador gato


#include <stdio.h>

> gcc -E -pedante -ansi pc | grep "imprimir"


extern int printf (const char *__restrict__format, ...);

188
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Não falaremos sobre a palavra-chave restrita ainda, então vamos fingir que ela não está aqui. O arquivo
stdio.h, incluído em nosso arquivo de teste pc, obviamente contém o protótipo da função printf (preste atenção ao
ponto e vírgula no final da linha!), que não possui corpo. Três pontos no lugar do último argumento significam uma
contagem arbitrária de argumentos. Esse recurso será discutido no Capítulo 14. O mesmo experimento pode ser
conduzido para qualquer função à qual você obtenha acesso incluindo stdio.h.
GCC é uma espécie de interface universal: você pode usá-la para compilar arquivos únicos separadamente sem ligação
(sinalizador -c), você pode executar todo o ciclo de compilação incluindo ligação em vários arquivos, mas também pode chamar
o vinculador indiretamente, fornecendo ao GCC com .o arquivos como entrada:

gcc -o arquivo_executável obj1.o obj2.o ...

Ao realizar a ligação, o GCC não chama ld cegamente. Ele também fornece a versão correta
da biblioteca C, ou bibliotecas. Bibliotecas adicionais podem ser especificadas com a ajuda do sinalizador -l.
No cenário mais comum, a biblioteca C consiste em duas partes:

•A parte estática (geralmente chamada de crt0 – C RunTime, zero significa “o início”) contém a
rotina _start, que executa a inicialização das estruturas de utilitários padrão, exigidas
por esta implementação específica da biblioteca. Então ele chama o principal
função. No Intel 64, os argumentos da linha de comando são passados para a pilha. Isso significa
que _start deve copiar argc e argv da pilha para rdi e rsi para respeitar a convenção de
chamada de função.

Se você vincular um único arquivo e verificar sua tabela de símbolos antes e depois da
vinculação, verá muitos símbolos novos, originados em crt0, por exemplo, um familiar _start,
que é o verdadeiro ponto de entrada.

•Parte dinâmica, que contém as próprias funções e variáveis globais. Como estes são usados pela
grande maioria dos aplicativos em execução, é aconselhável não copiá-los, mas compartilhá-los
entre eles para obter um menor consumo geral de memória e melhor localização.
Provaremos sua existência usando o utilitário ldd em um arquivo de amostra compilado
main_ldd.c, mostrado na Listagem 10.24. Isso nos ajudará a localizar a biblioteca C padrão. A
Listagem 10.25 mostra a saída do ldd.

Listagem 10-24. main_ldd.c

#include <stdio.h>

int principal(void)
{
printf("Olá mundo!\n");
retornar 0;
}

Listagem 10-25. ldd_locating_libc

> gcc principal.c -o principal


>ldd principal
linux-vdso.so.1 (0x00007fff4e7fc000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2b7f6bf000)
/lib64/ld-linux-x86-64.so.2 (0x00007f2b7fa76000)

189
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Este arquivo está vinculado a três bibliotecas dinâmicas.

1. O ld-linux é o próprio carregador de biblioteca dinâmica, que pesquisa e carrega


todas as bibliotecas dinâmicas, exigidas pelo executável.

2. vdso, que significa “objeto virtual dinâmico compartilhado”, é uma pequena biblioteca de utilitários
usado pela biblioteca padrão C para acelerar a comunicação com o kernel em algumas situações.

3. Finalmente, a própria libc contém o código executável para funções padrão.

Então, como a biblioteca padrão é apenas mais um arquivo ELF, iniciaremos o readelf para imprimir sua tabela de símbolos
e ver a entrada printf por nós mesmos. A Listagem 10-26 mostra o resultado. A primeira entrada é de fato o printf
Nós estamos usando; a tag após @@ marca a versão do símbolo e é usada para fornecer diferentes versões da mesma função. O
software antigo, que usa versões de funções mais antigas, continuará a usá-las, enquanto o novo software poderá mudar para
uma variante mais recente e melhor escrita sem quebrar a compatibilidade.

Listagem 10-26. printf_lib_entry

> readelf -s /lib/x86_64-linux-gnu/libc.so.6 | grep "imprimir"


596: 0000000000050d50 161 FUNÇÃO PADRÃO GLOBAL 12
imprimirf@@GLIBC_2.2.5
1482: 0000000000050ca0 31 FUNC PADRÃO GLOBAL 12
printf_size_info@@GLIBC_2.2.5
1890: 000000000050480 2070 FUNC PADRÃO GLOBAL 12
printf_size@@GLIBC_2.2.5

ÿ Pergunta 189 Tente encontrar os mesmos símbolos usando o utilitário nm em vez do readelf.

10.4 Pré-processador
Além de definir constantes globais com #define, o pré-processador também é usado como solução alternativa para resolver um
problema de inclusão múltipla. Primeiro, revisaremos brevemente os recursos relevantes do pré-processador.
A diretiva #define é usada nas seguintes formas típicas:

•#define FLAG significa que o símbolo do pré-processador FLAG está definido, mas seu valor é uma
string vazia (ou, você poderia dizer que não tem valor). Este símbolo é praticamente inútil em
substituições, mas podemos verificar se existe alguma definição e incluir algum código baseado nela.

•#define MY_CONST 42 é uma maneira familiar de definir constantes globais. Toda vez
MY_CONST ocorre no texto do programa e é substituído por 42.

•#define MAX(a, b) ((a)>(b))?(a):(b) é uma macrossubstituição com parâmetros.

Uma linha int x = MAX(4+3, 9) será então substituída por: int x = ((4+3)>(9))?(4+3):(9).

190
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

ÿ Parâmetros de macro entre parênteses Observe que todos os parâmetros em um corpo de macro devem estar entre
parênteses. Garante que as expressões complexas, fornecidas à macro como parâmetros, sejam analisadas corretamente.
Imagine uma macro SQ simples.

#define SQ(x) x*x

Uma linha int z = SQ(4+3) será então substituída por

int z = 4 + 3 * 4+3

que, devido à multiplicação ter uma prioridade maior que a adição, será analisada como 4 + (3*4) + 3, o que não é bem uma
expressão que pretendíamos formar.

Se desejar que símbolos adicionais de pré-processador sejam definidos, você também pode fornecê-los ao iniciar o GCC com
a tecla -D. Por exemplo, em vez de escrever #define SYM VALUE, você pode iniciar gcc -DSYM=VALUE ou apenas gcc -DSYM para
um #define SYM simples.
Finalmente, precisamos de uma macro condicional: #ifdef. Esta directiva permite-nos incluir ou excluir alguns
fragmento de texto do arquivo pré-processado, dependendo se um símbolo está definido ou não.
Você pode incluir as linhas entre #ifdef SYMBOL e #endif se o SYMBOL estiver definido, conforme mostrado em
Listagem 10-27.

Listagem 10-27. ifdef_ex.c

#ifdef SÍMBOLO
/*código*/
#fim se

Você pode incluir as linhas entre #ifdef SYMBOL e #endif se o SYMBOL estiver definido, OU ELSE incluir
outro código, conforme mostrado na Listagem 10-28.

Listagem 10-28. ifdef_else_ex.c

#ifdef SÍMBOLO
/*código*/
#outro
/*outro código*/

#fim se

Você também pode afirmar que algum código só será incluído se um determinado símbolo não estiver definido, conforme mostrado
na Listagem 10-29.

Listagem 10-29. ifndef_ex.c

#ifndef MINHAFLAG
/*código*/
#outro
/*outro código*/
#fim se

191
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.4.1 Incluir proteção


Um arquivo pode conter no máximo uma declaração e uma definição para qualquer símbolo. Embora você não
escreva declarações duplicadas, provavelmente usará arquivos de cabeçalho, que podem incluir outros arquivos de
cabeçalho e assim por diante. Saber quais declarações estarão presentes no arquivo atual não é fácil: você precisa navegar
por cada arquivo de cabeçalho e por cada arquivo de cabeçalho que eles incluem, e assim por diante.
Por exemplo, existem três arquivos: ah, bh e main.c, mostrados na Listagem 10.30.

Listagem 10-30. inc_guard_motivation.c

/* ah */ void
a(void);

/* bh */
#include "ah" void
b(void);

/* main.c */
#include "ah"
#include "bh"

Qual será a aparência do arquivo main.c pré-processado? Vamos lançar gcc -E main.c. A Listagem 10-31 mostra o
resultado.

Listagem 10-31. multiple_inner_includes.c

# 1 "main.c" # 1
"<integrado>" # 1
"<linha de comando>" # 1
"/usr/include/stdc-predef.h" 1 3 4 # 1 "<linha de
comando> " 2 # 1 "main.c" #
1 "ah" 1 void
a(void); # 2
"main.c" 2 # 1
"bh" 1 # 1 "ah" 1
void a(void); #
2 "bh" 2

vazio b(vazio); #
2 "principal.c" 2

Agora main.c contém uma declaração de função duplicada void a(void), que resulta em uma compilação
erro. A primeira declaração vem diretamente do arquivo ah; o segundo vem do arquivo bh que inclui ah sozinho.

Existem duas técnicas comuns para evitar isso.

•Usando uma diretiva #pragma uma vez no início do cabeçalho. Esta é uma forma não
padronizada de proibir a inclusão múltipla de um arquivo de cabeçalho. Muitos compiladores
o suportam, mas como não faz parte do padrão C, seu uso é desencorajado.

•Usando os chamados protetores Incluir.

192
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

A Listagem 10.32 mostra uma proteção de inclusão para algum arquivo file.h.

Listagem 10-32. arquivo.h

#ifndef _FILE_H_
#define _FILE_H_

vazio a(vazio);

#fim se

O texto entre as diretivas #ifndef _FILE_H_ e #endif só será incluído se o símbolo X não for
definiram. Como podemos ver, a primeira linha deste texto é: #define _FILE_H_. Isso significa que na próxima vez todo esse
texto será incluído como resultado da execução da diretiva #include; a mesma diretiva #ifndef _FILE_H_ impedirá que o
conteúdo do arquivo seja incluído pela segunda vez.
Normalmente, as pessoas nomeiam esses símbolos do pré-processador com base no nome do arquivo, uma dessas convenções
foi mostrada e consiste em

– Colocando o nome do arquivo em maiúscula.

– Substituindo pontos por sublinhados.

– Acrescentar e anexar um ou mais sublinhados.

Elaboramos um arquivo include típico para você observar sua estrutura. A Listagem 10-33 mostra este exemplo.

Listagem 10-33. par.h

#ifndef _PAIR_H_
#define _PAIR_H_

#include <stdio.h>

par de estruturas {
interno x;
interno;
};

void pair_apply( par de estrutura* par, void (*f)(par de estrutura));


void pair_tofile (estrutura par* par, ARQUIVO* arquivo);

#fim se

A proteção include é a primeira coisa que observamos neste arquivo. Depois vêm outras inclusões. Por que voce precisa
incluir arquivos em arquivos de cabeçalho? Às vezes, suas funções ou estruturas dependem de tipos externos, definidos
em outro lugar. Neste exemplo, a função pair_tofile aceita um argumento do tipo FILE*, que é definido no arquivo de cabeçalho
padrão stdio.h (ou em um dos cabeçalhos que ele inclui por conta própria). A definição do tipo vem depois disso e depois os
protótipos da função.

193
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.4.2 Por que o pré-processador é ruim?


O uso extensivo do pré-processador é considerado ruim por vários motivos:

•Muitas vezes torna o código menor, mas também muito menos legível.

•Introduz abstrações desnecessárias.

•Na maioria dos casos, isso dificulta a depuração.

•As macros muitas vezes confundem IDEs (ambientes de desenvolvimento integrados) e seus
motores de preenchimento automático, bem como diferentes analisadores estáticos. Não seja
esnobe com relação a isso porque em projetos maiores eles são de grande ajuda.

O pré-processador não sabe nada sobre a estrutura da linguagem, então cada estrutura do pré-processador isoladamente
pode ser uma instrução de idioma inválida. Por exemplo, uma macro #define OR else { pode se tornar parte de uma
instrução válida após todas as substituições, mas não é uma instrução válida sozinha. Quando as macros se misturam e
os limites das instruções não estão bem definidos, é difícil entender esse código.
Algumas tarefas podem ser quase impossíveis de resolver por causa do pré-processador. Limita a quantidade de
inteligência que pode ser colocada no ambiente de programação ou nas ferramentas de análise estática. Vamos explorar várias
armadilhas:

1. Quão inteligente deve ser o analisador de código estático para entender o que foo retorna (veja
Listagem 10-34)?

Listagem 10-34. ifdef_pitfall_sig.c

#ifdef ALGUMA BANDEIRA

int foo() {
#outro
void foo() {
#fim se
/* ... */
}

2. Você deve encontrar todas as ocorrências da macro min, que é definida como

#define min(x,y) ((x) < (y) ? (x) : (y)).

Como você viu no exemplo anterior, para analisar o programa você deve primeiro realizar
passos de pré-processamento, caso contrário a ferramenta pode nem entender os limites
das funções. Depois de realizar o pré-processamento, todas as macros mínimas são
substituídas e, portanto, tornam-se indetectáveis e indistinguíveis de linhas como

int z = ((10) < (y)? (5): (3)).

3. A análise estática (e até mesmo a compreensão do seu próprio programa) será prejudicada
pelo uso de macros. Sintaticamente, instanciações de macro com parâmetros são
indistinguíveis de chamadas de função. No entanto, enquanto os argumentos da função são
avaliados antes de uma chamada de função ser executada, os argumentos da macro são
substituídos e as linhas de código resultantes são executadas.

Por exemplo, pegue a mesma macro #define min(x,y) ((x) < (y) ? (x) : (y)).
A instanciação com os argumentos a e b-- será semelhante a: ((a) < (b--) ? (a) : (b--)). Como
você pode ver, se a >= b, então a variável b será decrementada duas vezes. Se mínimo
fosse uma função, b-- teria sido executada apenas uma vez.

194
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.5 Exemplo: Soma de um array dinâmico 10.5.1 Uma prévia

da alocação dinâmica de memória Para concluir a próxima tarefa,


você precisa aprender a usar as funções malloc e free. Iremos discuti-los com mais
detalhes posteriormente, mas por enquanto faremos uma rápida introdução.
Tanto as variáveis locais quanto as globais permitem alocar uma quantidade fixa de bytes. No entanto, quando
o tamanho da memória alocada depende da entrada, você pode alocar tanta memória quanto achar suficiente em
todos os casos ou usar a função malloc, que aloca tanta memória quanto você solicitar.
void* malloc(size_t sz) retorna o início de um buffer de memória alocado de tamanho sz (em bytes) ou NULL em
caso de falha. Este buffer contém valores aleatórios no início. Como retorna void*, esse ponteiro pode ser atribuído a
um ponteiro de qualquer outro tipo.
Todas essas regiões de memória alocadas devem ser liberadas quando não forem mais usadas, chamando-as
free.
Para usar essas duas funções, você deve incluir malloc.h. A listagem 10-35 mostra um valor mínimo
exemplo de malloc e uso gratuito.

Listagem 10-35. simples_malloc.c

#include <malloc.h>

int principal(void) { int*


matriz;

/* malloc retorna o endereço inicial da memória alocada


* Observe que seu argumento é o tamanho do byte, contagem de elementos multiplicada *
pelo tamanho do elemento
*/ array = malloc( 10 * sizeof( int ));

/* ações no array são executadas aqui */

grátis(matriz); /* agora a região de memória relacionada está desalocada */ return 0;

10.5.2 Exemplo A Listagem

10-36 mostra o exemplo. Ele contém três funções de interesse:

•array_read para ler um array de stdin. A alocação de memória acontece aqui.

Observe o uso da função scanf para ler stdin. Não se esqueça que ele não aceita os valores das variáveis,
mas seus endereços, para que possa realizar uma escrita real neles.

•array_print para imprimir um determinado array em stdout.

•array_sum para somar todos os elementos de um array.

Observe que o array alocado em algum lugar usando malloc persiste até o momento em que free é chamado em
seu endereço inicial. Liberar um array já liberado é um erro.

195
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Listagem 10-36. soma_malloc.c


#include <stdio.h>

#include <malloc.h>

int* array_read( size_t* out_count ) { int*


array;
tamanho_t
eu;
tamanho_t cnt;
scanf("%zu", &cnt); array = malloc(cnt * sizeof(int));

para (eu = 0; eu <cnt; eu++)


scanf("%d",&array[i]);

*out_count = cnt;
matriz de retorno;
}

void array_print( int const* array, size_t contagem ) { size_t i;

for(i = 0; i < contagem; i++ )


printf("%d ", matriz[i]); coloca("");

int array_sum( int const* array, size_t contagem ) { size_t


i; soma
interna = 0;
for(i = 0; i < contagem; i++ ) soma
= soma + array[i]; soma
de retorno;
}

int principal(void) { int*


matriz;
contagem de tamanho_t;

array = array_read(&contagem);
array_print(matriz, contagem);
printf("A soma é: %d\n", array_sum( array, contagem ) );
grátis(matriz);
retornar 0;
}

196
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.6 Tarefa: Lista Vinculada


10.6.1 Atribuição
O programa aceita um número arbitrário de inteiros por meio de stdin. O que você tem que fazer é

1. Salve todos eles em uma lista vinculada Em ordem inversa.

2. Escreva uma função para calcular a soma dos elementos em uma lista vinculada.

3. Use esta função para calcular a soma dos elementos na lista salva.

4. Escreva uma função para gerar o n-ésimo elemento da lista. Se a lista for muito curta,
sinalize.

5. Libere a memória alocada para a lista vinculada.

Você precisa aprender a usar

•Tipos estruturais para codificar a própria lista encadeada.

•A constante EOF. Leia a seção “Valor de retorno” do man scanf.

Você pode ter certeza de que

•A entrada não contém nada além de números inteiros separados por espaços em branco.

•Todos os números de entrada podem estar contidos em variáveis int.

A seguir está a lista recomendada de funções para implementar:

•list_create – aceita um número, retorna um ponteiro para o novo nó da lista vinculada.

•list_add_front – aceita um número e um ponteiro para um ponteiro para a lista vinculada.


Acrescenta um número ao novo nó na lista.

Por exemplo: uma lista (1,2,3), um número 5 e a nova lista é (5,1,2,3).

•list_add_back, adiciona um elemento ao final da lista. A assinatura é igual a list_add_front.

•list_get obtém um elemento por índice ou retorna 0 se o índice estiver fora dos limites da lista.

•list_free libera a memória alocada para todos os elementos da lista.

•list_length aceita uma lista e calcula seu comprimento.

•list_node_at aceita uma lista e um índice, retorna um ponteiro para struct list, correspondente ao nó
neste índice. Se o índice for muito grande, retorna NULL.

•list_sum aceita uma lista, retorna a soma dos elementos.

Estes são alguns requisitos adicionais:

•Todas as peças de lógica que são usadas mais de uma vez (ou aquelas que podem ser conceitualmente
isolado) deve ser abstraído em funções e reutilizado.

•A exceção ao requisito anterior ocorre quando a queda de desempenho se torna crucial porque a reutilização de
código está alterando o algoritmo de uma forma radicalmente ineficaz.
Por exemplo, você pode usar a função list_at para obter o n-ésimo elemento de uma lista em um loop
para calcular a soma de todos os elementos. Porém, o primeiro precisa passar por toda a lista para chegar
ao elemento. À medida que você aumenta n, você passará pelos mesmos elementos repetidas vezes.

197
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

Na verdade, para uma lista de comprimento N, podemos calcular o número de vezes que os elementos serão endereçados
para calcular uma soma.

NN( ) +1
1 +2 +3 + + =... N
2

Começamos com uma soma igual a 0. Depois somamos o primeiro elemento, para isso precisamos endereçá-lo sozinho (1).
Depois adicionamos o segundo elemento, abordando o primeiro e o segundo (2). Em seguida, adicionamos o terceiro
elemento, abordando o primeiro, o segundo e o terceiro à medida que examinamos a lista desde o início. No final, o que
obtemos é algo como O(N2 ) para aqueles familiarizados com a notação O. Essencialmente, isso significa que, ao aumentar
o tamanho da lista em 1, o tempo para somar essa lista terá N adicionado a ela.
Nesse caso, é realmente mais sensato apenas percorrer a lista, adicionando um elemento atual ao acumulador.

•Escrever pequenas funções é muito bom na maioria das vezes.

•Considere escrever funções separadas para: adicionar um elemento na frente, adicionar no verso,
crie um novo nó de lista vinculada.

•Não se esqueça de usar const extensivamente, especialmente em funções que aceitam ponteiros como
argumentos!

10.7 A palavra-chave estática


Em C, a palavra-chave static tem vários significados dependendo do contexto.

1. Aplicando estática a variáveis ou funções globais, as disponibilizamos apenas em


o módulo atual (arquivo .c).

Para ilustrar isso, vamos compilar um programa simples mostrado na Listagem 10.37 e iniciar o nm para examinar
a tabela de símbolos. Lembre-se de que nm marca os símbolos globais com letras maiúsculas.

Listagem 10-37. exemplo_estático.c

intglobal_int;
estático int módulo_int;

static int módulo_função() {


static int static_local_var;
int var_local;
retornar 0;
}
int principal( int argc, char** argv ) {
retornar 0;
}

O que vemos é que todos os nomes de símbolos são marcados como globais, exceto aqueles marcados como estáticos em
C. No nível assembly, isso significa que a maioria dos rótulos são marcados como globais e, para evitar isso, temos que ser explícitos e
usar a palavra-chave estática.

> gcc -c --ansi --pedantic -o exemplo_estático.o exemplo_estático.c


> nm static_example.o
000000000000004 C global_int
00000000000000b T principal

198
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

000000000000000 t módulo_função
000000000000000 b módulo_int
000000000000004 b static_local_var.1464

2. Ao aplicar static à variável local, nós a tornamos global, mas nenhuma outra
função pode acessá-lo diretamente. Em outras palavras, ele persiste entre chamadas de
função após ser inicializado uma vez. Da próxima vez que a mesma função for chamada, o
valor de uma variável estática local será o mesmo de quando esta função foi encerrada da última vez.

A Listagem 10-38 mostra um exemplo.

Listagem 10-38. static_loc_var_example.c

demonstração interna (void)

{
estático int a = 42;
printf("%d\n", a++);
}

...

demonstração(); //saída 42
demonstração(); //saída 43
demonstração(); //saída 44

10.8 Ligação
O conceito de ligação é definido no padrão C e sistematiza o que estudamos neste capítulo até agora. Segundo ele, “um
identificador declarado em escopos diferentes ou no mesmo escopo mais de uma vez pode ser referenciado ao mesmo objeto
ou função por um processo denominado ligação” [7] .
Assim, cada identificador (variável ou nome de função) possui um atributo denominado ligação. Existem três tipos de
ligação:

•Sem ligação, que corresponde ao local (a um bloco) variáveis.

•Ligação externa, que disponibiliza um identificador a todos os módulos que queiram tocá-lo. Este é o caso
de variáveis globais e quaisquer funções.

– Todas as instâncias de um nome particular com ligação externa referem-se ao mesmo objeto em
o programa.

– Todos os objetos com ligação externa devem ter uma e apenas uma definição. No entanto,
o número de declarações em arquivos diferentes não é limitado.

•Link interno, que restringe a visibilidade do identificador ao arquivo .c onde ele estava
definiram.

É fácil mapear os tipos de entidades linguísticas que conhecemos para os tipos de ligação:

•Funções regulares e variáveis globais – ligação externa.

•Funções estáticas e variáveis globais – ligação interna.

•Variáveis locais (estáticas ou não) – ligação interna.

Embora seja importante compreender para ler a norma livremente, este conceito raramente é encontrado
nas atividades diárias de programação.

199
Machine Translated by Google

Capítulo 10 ÿ Estrutura do código

10.9 Resumo
Neste capítulo aprendemos como dividir o código em arquivos separados. Revisamos os conceitos de arquivos de
cabeçalho e estudamos incluir guardas e aprendemos a isolar funções e variáveis dentro de um arquivo. Também
vimos como são as tabelas de símbolos dos programas C básicos e os efeitos que a palavra-chave static
produz nos arquivos-objeto. Concluímos uma tarefa e implementamos listas vinculadas (uma das estruturas
de dados mais fundamentais). No próximo capítulo estudaremos a memória da perspectiva C com mais detalhes.

ÿ Pergunta 190 Qual é a diferença entre uma declaração e uma definição?

ÿ Pergunta 191 O que é uma declaração antecipada?

ÿ Pergunta 192 Quando as declarações de funções são necessárias?

ÿ Pergunta 193 Quando as declarações de estrutura são necessárias?

ÿ Pergunta 194 Como podem ser chamadas as funções definidas em outros arquivos?

ÿ Pergunta 195 Que efeito uma declaração de função tem na tabela de símbolos?

ÿ Pergunta 196 Como acessamos os dados definidos em outros arquivos?

ÿ Pergunta 197 Qual é o conceito de arquivos de cabeçalho? Para que eles são normalmente usados?

ÿ Pergunta 198 Em quais partes consiste a biblioteca C padrão?

ÿ Pergunta 199 Como o programa aceita argumentos de linha de comando?

ÿ Pergunta 200 Escreva um programa em assembly que exiba todos os argumentos da linha de comando, cada um em
uma linha separada.

ÿ Pergunta 201 Como podemos usar as funções da biblioteca C padrão?

ÿ Pergunta 202 Descreva o mecanismo que permite ao programador usar funções externas, incluindo cabeçalhos relevantes.

ÿ Pergunta 203 Leia sobre ld-linux.

ÿ Pergunta 204 Quais são as principais diretivas do pré-processador C?

ÿ Pergunta 205 Para que serve a proteção include e como a escrevemos?

ÿ Pergunta 206 Qual é o efeito das variáveis e funções globais estáticas na tabela de símbolos?

ÿ Pergunta 207 O que são variáveis locais estáticas?

ÿ Pergunta 208 Onde são criadas as variáveis locais estáticas?

ÿ Pergunta 209 O que é ligação? Que tipos de ligação existem?

200
Machine Translated by Google

CAPÍTULO 11

Memória

A memória é uma parte central do modelo de computação usado em C. Ela armazena todos os tipos de variáveis, bem
como funções. Este capítulo estudará de perto o modelo de memória C e os recursos de linguagem relacionados.

11.1 Ponteiros revisitados

ÿ B. Kernighan e D. Ritchie sobre ponteiros “Os ponteiros foram agrupados com a instrução goto como uma
maneira maravilhosa de criar programas impossíveis de entender. Isto é certamente verdade quando eles são usados
de forma descuidada, e é fácil criar ponteiros que apontam para algum lugar inesperado. Com disciplina, porém, os indicadores
também podem ser usados para alcançar clareza e simplicidade.” [18]

11.1.1 Por que precisamos de ponteiros?


Como a linguagem C possui um modelo de cálculos de von Neumann, a execução do programa é essencialmente uma
sequência de comandos de manipulação de dados. Os dados residem na memória endereçável, e a endereçabilidade dos dados
é a propriedade que permite uma manipulação de dados mais refinada e eficaz. Muitas linguagens de nível superior não
possuem essa propriedade porque as manipulações diretas de endereços são proibidas.
No entanto, essa vantagem tem um preço: fica mais fácil produzir erros sutis e geralmente irrecuperáveis no código.

A necessidade de armazenar e manipular endereços é a razão pela qual precisamos de ponteiros. Realizando um estudo de
caso típico para a Listagem 11-1, observamos que, em termos da máquina C abstrata:

• a - é o nome das células de dados da máquina abstrata, contendo o número 4 do tipo int.

• p_a - é o nome das células de dados da máquina abstrata, que contém o endereço de uma variável
do tipo int.

• p_a armazena o endereço de a.

• *p_a é igual a a;

• &a é igual a p_a, mas essas duas entidades não são iguais. Enquanto p_a é o nome de algumas células
de dados consecutivas, &a é o conteúdo de p_a, uma sequência de bits que representa um endereço.

© Igor Zhirkov 2017 201


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_11
Machine Translated by Google

Capítulo 11 ÿ Memória

Listagem 11-1. ponteiros_ex.c

intuma = 4;
int* p_a = &a;
*p_a = 10; /* a = 10*/

ÿ Nota Você só pode aplicar & uma vez, porque para qualquer x a expressão &x já não será um lvalue.

11.1.2 Aritmética de Ponteiros


A seguir estão as únicas ações que você pode executar em ponteiros:

• Somar ou subtrair números inteiros (também negativos);

Portanto, temos ponteiros e eles contêm endereços. Para um computador, não há diferença
entre o endereço de um número inteiro e o endereço de uma string. Na linguagem assembly, como
vimos, todos os endereços são do mesmo tipo. Por que precisamos manter as informações de
tipo sobre para onde o ponteiro aponta? Qual é a diferença entre int* e char*?

O tamanho do elemento para o qual estamos apontando é importante. Ao adicionar ou subtrair um


valor inteiro X do ponteiro do tipo T *, nós, de fato, o alteramos por X * sizeof( T ).
Vejamos um exemplo mostrado na Listagem 11-2.

Listagem 11-2. ptr_change_ex.c

intuma = 42; /* Suponha que o endereço deste número inteiro seja 1000 */
int* p_a = &a;
p_a += 42; /* 1000 + 42 * sizeof(int) */
p_a = p_a + 1; p_a /* 1168 + 1 * sizeof(int) */
--; /* 1172 - 1 * sizeof(int) */

• Leve seu próprio endereço. Se o ponteiro for uma variável, ele está localizado em algum lugar da memória
também. Então, ele tem um endereço próprio! Use o operador & para pegá-lo.

• Desreferência, que é uma operação básica que também vimos. Estamos pegando uma entrada
de dados da memória começando no endereço armazenado no ponteiro fornecido.
O operador * faz isso. A Listagem 11-3 mostra um exemplo.

Listagem 11-3. deref_ex.c

int gatosAreCool = 0;
int* ptr = &catsAreCool;
*ptr = 1; /* gatosAreCool = 1 */

• Compare (com <, >, == e similares).

Podemos comparar dois ponteiros. O resultado só é definido se ambos apontarem para o mesmo
bloco de memória (por exemplo, para elementos diferentes do mesmo array). Caso contrário, o
resultado será aleatório, indefinido pelo padrão da linguagem.

202
Machine Translated by Google

Capítulo 11 ÿ Memória

• Subtraia outro ponteiro.

Se, e somente se, tivermos dois ponteiros, que certamente apontam para o bloco de memória
contíguo, então, subtraindo um de menor valor de um de maior valor, obteremos a quantidade de
elementos entre eles. Para ponteiros x e y, estamos falando de uma gama de elementos de *x
inclusive a *y exclusivo (então x ÿ x = 0).

A partir de C99, o tipo da expressão ptr2 - ptr1 é um tipo especial ptrdiff_t. É um tipo
assinado do mesmo tamanho que size_t.

Observe que o resultado é diferente da quantidade de bytes entre *x e *y! A diferença calculada
ingenuamente seria a quantidade de bytes, enquanto o resultado da subtração é a quantidade
de bytes dividida pelo tamanho de um elemento. A Listagem 11-4 mostra um exemplo.

Listagem 11-4. ptr_diff_calc.c

int arr[128];
int* ptr1 = &arr[50]; /* endereço `array` + 50 tamanhos int */
int* ptr2 = &arr[90]; /* endereço `array` + 90 tamanhos int */
ptrdiff_t d = ptr2 - ptr1; /* exatamente 40 */

Em todos os outros casos (subtraindo o ponteiro maior do menor, subtraindo os ponteiros que apontam para áreas
diferentes, etc.) o resultado pode ser absolutamente aleatório.
Adição, multiplicação e divisão de dois ponteiros são sintaticamente incorretas; portanto, eles acionam um erro de
compilação imediato.

11.1.3 O tipo void*


Além dos tipos de ponteiro regulares, existe um tipo void*, que é meio especial. Esquece todas as informações sobre a entidade
para a qual aponta, exceto o seu endereço. A aritmética do ponteiro é proibida para ponteiros void*, porque o tamanho da entidade
para a qual estamos apontando é desconhecido e, portanto, não pode ser adicionado ou subtraído.
Antes de poder trabalhar com esse ponteiro, você deve convertê-lo explicitamente para outro tipo. Alternativamente, C
permite atribuir este ponteiro a qualquer outro ponteiro (e atribuir a void* um ponteiro de qualquer tipo) sem nenhum aviso. Em
outras palavras, embora atribuir short* a long seja um erro claro, as atribuições tratam void* como igual a qualquer tipo de ponteiro.

A Listagem 11-5 mostra um exemplo.

Listagem 11-5. void_ptr_ex.c

vazio* a = (vazio*)4;
curto* b = (curto*) a;
b++; /* correto, b = 6 */
b = uma; /* correto */
uma = b; /* correto */

11.1.4 NULO
C define uma constante especial de pré-processador NULL igual a 0. Significa um ponteiro “apontando para lugar nenhum”, um
ponteiro inválido. Ao escrever esse valor em um ponteiro, podemos ter certeza de que ele ainda não foi inicializado com um endereço válido.
Caso contrário, não seríamos capazes de distinguir ponteiros inicializados.
Na maioria das arquiteturas as pessoas reservam um valor especial para ponteiros inválidos, assumindo que nenhum programa irá
na verdade, mantém um valor útil por este endereço.

203
Machine Translated by Google

Capítulo 11 ÿ Memória

Como já sabemos, 0 no contexto de ponteiro nem sempre significa um número binário com todos os bits apagados.
O ponteiro-0 pode ser igual a 0, mas isso não é aplicado por padrão. A história conhece arquiteturas onde o ponteiro nulo
foi escolhido de forma bastante exótica. Por exemplo, alguns computadores da série Prime 50 usaram o segmento 07777,
deslocamento 0 para o ponteiro nulo; alguns mainframes Honeywell-Bull usam o padrão de bits 06000 para uma espécie
de ponteiro nulo.
A Listagem 11-6 mostra as maneiras corretas de verificar se o ponteiro é NULL ou não.

Listagem 11-6. null_check.c

se(x) {...}
se(NULO!=x) {...}
se(0!=x) {...}

se(x!=NULO) {...}
se(x!=0) {...}

11.1.5 Uma palavra sobre ptrdiff_t


Dê uma olhada no exemplo mostrado na Listagem 11-7. Você consegue identificar um bug?

Listagem 11-7. ptrdiff_bug.c

int* máximo;
int* cur;

int f(int sem sinal e)


{
se (max - cur > e)
retornar 1;
outro
retornar 0;
}

O que acontece se cur > max? Isso implica que a diferença entre cur e max é negativa. Seu tipo é
ptrdiff_t. Compará-lo com um valor do tipo unsigned int é um caso interessante para estudar.
ptrdiff_t possui tantos bits quanto o endereço na arquitetura de destino. Vamos estudar dois casos:

• Sistema de 32 bits, onde sizeof( unsigned int ) == 4 e sizeof( ptrdiff_t ) == 4.


Nesse caso, os tipos da nossa comparação passarão por essas conversões.

int <int não assinado


(int não assinado)int <int não assinado

O compilador emitirá um aviso, porque a conversão de int para unsigned int nem sempre
preserva valores. Você não pode mapear livremente valores no intervalo ÿ231 ...
231 ÿ 1 para o intervalo 0 . . . 232-1 .

Por exemplo, caso o lado esquerdo seja igual a -1, após a conversão para o
tipo unsigned int ele se tornará o valor máximo representável no tipo unsigned int
(232 ÿ 1). Aparentemente, o resultado dessa comparação será quase sempre igual
a 0, o que é errado, pois -1 é menor que qualquer número inteiro sem sinal.

204
Machine Translated by Google

Capítulo 11 ÿ Memória

• Sistema de 64 bits, onde sizeof( unsigned int ) == 4 e sizeof( ptrdiff_t ) == 8.


Nesta situação, ptrdiff_t provavelmente terá o alias do comprimento assinado.

assinado longo <unsigned int


longo < (assinado longo)unsigned int

Aqui o lado direito será lançado. Essa conversão preserva as informações, portanto o
compilador não emitirá nenhum aviso.

Como você pode ver, o comportamento desse código depende da arquitetura alvo, o que é um grande não.
Para evitá-lo, ptrdiff_t deve sempre estar no mesmo nível de size_t, pois só assim seus tamanhos serão garantidos como iguais.

11.1.6 Ponteiros de Função


O modelo de computação de von Neumann implica que o código e os dados residem na mesma memória
endereçável. Portanto, as funções possuem endereços próprios. Podemos pegar os endereços iniciais das funções,
passá-los para outras funções, chamar funções por meio de ponteiros, armazená-los em variáveis ou arrays, etc.
Por que, entretanto, faríamos tudo isso? Isso nos permite melhores abstrações. Podemos escrever uma função
que inicia outra função e mede seu tempo de trabalho, ou transforma um array aplicando a função a todos os seus elementos.
Essa técnica permite que o código seja reutilizado em um nível totalmente novo.
O ponteiro de função armazena informações sobre o tipo de função da mesma forma que os ponteiros de
dados. O tipo de função inclui os tipos de argumento e o tipo de valor de retorno. Uma sintaxe que imita a declaração
da função é usada para declarar um ponteiro de função:

<return_value_type> (*nome) (arg1, arg2, ...);

A Listagem 11.8 mostra um exemplo.

Listagem 11-8. fun_ptr_example.c


*
double doubler (int a) { retornar a 2,5; }
...
duplo (*fptr)(int);
duplo a;
fptr = &doubler;
a = fptr(10); /* a = 25,0 */

Descrevemos o ponteiro fptr do tipo “um ponteiro para uma função que aceita int e retorna double”.
Em seguida, atribuímos a ele o endereço da função duplicadora e realizamos uma chamada por este ponteiro
com argumento 10, armazenando o valor retornado na variável a.
typedef funciona e às vezes é uma grande ajuda. O exemplo anterior pode ser reescrito conforme mostrado na
Listagem 11-9.

Listagem 11-9. fun_ptr_example_typedef.c


*
double doubler (int a) { retorna 2,5; }
typedef duplo (megapointer_type)(int);

...
duplo a;
megapointer_type* variável = &doubler;
a = variável(10); /* a = 25,0 */

205
Machine Translated by Google

Capítulo 11 ÿ Memória

Agora, por meio de typedef, criamos um tipo de função que não pode ser instanciado diretamente.
No entanto, podemos criar variáveis do referido tipo de ponteiro. Não podemos criar variáveis dos tipos de função diretamente,
então adicionamos um asterisco.
Objetos de primeira classe em linguagens de programação são as entidades que podem ser passadas como parâmetro,
retornado de funções ou atribuído a uma variável.
Como vemos, as funções não são objetos de primeira classe em C. Às vezes são chamadas de “objetos de segunda classe”
porque os ponteiros para eles são objetos de primeira classe.

11.2 Modelo de Memória


A memória da máquina abstrata C, embora uniforme, possui diversas regiões. Pragmaticamente, cada região é mapeada para uma
região de memória diferente, consistindo em páginas consecutivas.
A Figura 11-1 mostra esse modelo.

Figura 11-1. Modelo de memória C

206
Machine Translated by Google

Capítulo 11 ÿ Memória

As regiões que quase todos os programas C possuem são

• Código, que contém instruções de máquina.

• Dados, que armazenam variáveis globais regulares.

• Dados constantes, que armazenam todos os dados imutáveis, como literais de string e globais
variáveis, marcadas como const. O sistema operacional normalmente está protegendo as páginas
correspondentes através do mecanismo de memória virtual, permitindo ou não as leituras/
escreve.

• Heap, que armazena dados alocados dinamicamente (por meio de malloc, como mostraremos em
seção 11.2.1).

• Pilha, que armazena todas as variáveis locais, endereços de retorno e outras informações de utilidade.
Se o programa for executado em vários threads, cada um receberá sua própria pilha.

11.2.1 Alocação de memória


Antes de poder usar células de memória, é necessário alocar memória. Existem três tipos de alocação de memória em C.

• A alocação automática de memória ocorre quando estamos entrando em uma rotina. Quando nós
entrar na função, uma parte da pilha é dedicada às suas variáveis locais. Quando saímos da função, todas
as informações sobre essas variáveis são perdidas. A vida útil desses dados é limitada pela vida útil
de uma instância de função. Assim que a função termina, a memória fica indisponível.

• No nível de montagem, já fizemos isso na primeira tarefa. As funções que executavam a impressão inteira
alocaram um buffer na pilha para armazenar a string resultante. Isso foi conseguido simplesmente
diminuindo o rsp pelo tamanho do buffer.

ÿ Nota Nunca retorne ponteiros para variáveis locais a partir de funções! Eles apontam para os dados que não existem mais.

• A alocação de memória estática acontece durante a compilação nos dados ou dados constantes
região. Essas variáveis existem até o programa terminar. Por padrão, as variáveis são inicializadas com zeros e,
portanto, terminam em .bss seção. Os dados constantes são alocados em .rodata; os dados mutáveis são alocados
em .data.

• A alocação dinâmica de memória é necessária quando não sabemos o tamanho da memória que precisamos
alocar até que ocorram alguns eventos externos. Este tipo de alocação depende de uma implementação
na biblioteca C padrão. Isso significa que quando a biblioteca padrão C não está disponível (por exemplo,
programação bare metal), esse tipo de alocação de memória também não está disponível.

Este tipo de alocação de memória usa o heap.

Uma parte da biblioteca padrão controla os endereços de memória reservados e disponíveis. A


interface desta parte consiste nas seguintes funções, cujos protótipos estão localizados no
arquivo de cabeçalho malloc.h.

– void* malloc(size_t size) aloca bytes de tamanho no heap e retorna o endereço do primeiro. Retorna NULL
se falhar.

207
Machine Translated by Google

Capítulo 11 ÿ Memória

Esta memória não é inicializada e, portanto, contém valores aleatórios.

– void* calloc(size_t size, size_t count) aloca size * count bytes no heap e os inicializa como zero.
Retorna o endereço do primeiro ou NULL
se falhar.

– void free(void* p) libera memória, alocada no heap.

– void* realloc(void* ptr, size_t newsize) altera o tamanho de um bloco de memória começando em
ptr para newsize bytes. A memória adicionada não será inicializada. O conteúdo é copiado para
o novo bloco e o bloco antigo é liberado. Retorna um ponteiro para o novo bloco de memória ou
NULL em caso de falha.

Quando não precisamos mais de um bloco de memória temos que liberá-lo, caso contrário ele ficará no estado “reservado”
para sempre, para nunca mais ser reutilizado. Essa situação é chamada de vazamento de memória. Quando você está usando
um software pesado, que contém bugs relacionados ao gerenciamento de memória, seu consumo de memória pode aumentar
significativamente ao longo do tempo, sem que o programa realmente precise de tanta memória.
Normalmente, o sistema operacional fornece ao programa um número de páginas antecipadamente. Essas páginas são
usadas até que o programa precise de mais memória dinâmica para alocar. Quando isso acontece, a chamada malloc pode
acionar internamente uma chamada de sistema (como mmap) para solicitar mais páginas.
Como o tipo de ponteiro void* pode ser atribuído a qualquer tipo de ponteiro, o código a seguir não emitirá nenhum aviso
(veja Listagem 11-10) ao compilá-lo como um código C.

Listagem 11-10. malloc_no_cast.c

#include <malloc.h>

...
int* a = malloc(200);
uma[4] = 2;

No entanto, em C++, uma linguagem popular que foi originalmente derivada de C (e que tenta manter
compatibilidade com versões anteriores), o ponteiro void* deve ser convertido explicitamente para o tipo de ponteiro ao
qual você está atribuindo. A Listagem 11-11 mostra a diferença.

Listagem 11-11. malloc_cast_explicit.c

int* arr = (int*)malloc( sizeof(int) * 42 );

ÿ Por que alguns programadores recomendam omitir a conversão Os padrões C mais antigos tinham uma regra “int
implícita” sobre declarações de funções. Na falta de uma declaração de função válida, seu primeiro uso foi considerado
uma declaração. Se um nome que não foi declarado anteriormente ocorrer em uma expressão e for seguido por um
parêntese esquerdo, ele declara um nome de função. Esta função também deve retornar um valor int . O compilador pode
até criar uma função stub retornando 0 para ela (se não encontrar uma implementação).

Caso você não inclua um arquivo de cabeçalho válido, contendo uma declaração malloc , esta linha irá disparar um erro,
pois a um ponteiro é atribuído um valor inteiro, retornado por malloc:

int*x=malloc(40);

208
Machine Translated by Google

Capítulo 11 ÿ Memória

No entanto, a conversão explícita ocultará esse erro, porque em C podemos converter o que quisermos para qualquer tipo que desejarmos.

int* x = (int*)malloc( 40 );

As versões modernas do padrão C (a partir de C99) abandonam esta regra e as declarações tornam-se obrigatórias, portanto este raciocínio

torna-se inválido.

Um benefício na conversão explícita é uma melhor compatibilidade com C++.

11.3 Matrizes e Ponteiros


Matrizes em C são particulares, porque qualquer conjunto de valores residindo consecutivamente na memória pode ser
considerado uma matriz.
Uma máquina abstrata considera que o nome do array é o endereço do primeiro elemento, portanto, um valor de ponteiro!

O i-ésimo elemento de uma matriz pode ser obtido por uma das seguintes construções equivalentes:

uma[eu] = 2;
*(uma+eu) = 2

O endereço do i-ésimo elemento pode ser obtido por uma das seguintes construções:

&a[i];
uma+eu;

Como podemos ver, toda operação com ponteiros pode ser reescrita usando a sintaxe de array! E vai ainda mais longe.
Na verdade, a sintaxe dos colchetes a[i] é imediatamente traduzida em a + i, que é a mesma coisa que i+a. Por causa disso,
construções exóticas como 4[a] também são possíveis (porque 4+a é legítimo).
Arrays podem ser inicializados com zeros usando a seguinte sintaxe:

int a[10] = {0};

As matrizes têm um tamanho fixo. No entanto, existem duas exceções notáveis a esta regra, que são válidas no C99 e em
versões mais recentes.

• Os arrays alocados na pilha podem ter um tamanho determinado em tempo de execução. Estes são chamados
matrizes de comprimento variável. É evidente que estes não podem ser marcados como estáticos
porque o último implica alocação em .data seção.

• A partir de C99, você pode adicionar um membro flexível do array como o último membro de uma
estrutura, conforme mostrado na Listagem 11-12.

Listagem 11-12. flex_array_def.c

estrutura char_array {
tamanho_t comprimento; dados de caracteres[];
};

209
Machine Translated by Google

Capítulo 11 ÿ Memória

Neste caso, o operador sizeof, aplicado a uma instância de estrutura, retornará o tamanho da
estrutura sem o array. A matriz se referirá à memória imediatamente após a instância da
estrutura. Portanto, no exemplo dado na Listagem 11.12, sizeof(struct char_array) ==
sizeof(size_t). Supondo que seja igual a 8, data[0] refere-se ao 8º byte (contando a partir
de 0) do endereço inicial da instância da estrutura.

A Listagem 11-13 mostra um exemplo.

Listagem 11-13. flex_array.c

#include <string.h>
#include <malloc.h>

estrutura int_array {
tamanho_t tamanho;
matriz interna[];
};

struct int_array* array_create(tamanho_t tamanho) {


estrutura int_array* array = malloc(
sizeof(*matriz)
+ sizeof(int) *tamanho);
matriz-> tamanho = tamanho;
memset(array->array, 0, tamanho);
matriz de retorno;
}

11.3.1 Detalhes de Sintaxe


C nos permite definir várias variáveis seguidas.

intuma,b = 4, c;

Para declarar vários ponteiros, entretanto, você deve adicionar um asterisco antes de cada ponteiro.
A Listagem 11.14 mostra um exemplo: aeb são ponteiros, mas o tipo de c é int.

Listagem 11-14. ptr_mult_decl.c

int* a, *b, c;

Esta regra pode ser contornada criando um alias de tipo para int* usando typedef, ocultando um asterisco.
Definir múltiplas variáveis seguidas é uma prática geralmente desencorajada, pois na maioria dos casos torna o código
mais difícil de ler.
É possível criar definições de tipos bastante complexas misturando ponteiros de função, arrays, ponteiros, etc.
Você pode usar o seguinte algoritmo para decifrá-los:

1. Encontre um identificador e comece a partir dele.

2. Vá para a direita até o primeiro parêntese de fechamento. Encontre seu par à esquerda. Interpretar
uma expressão entre esses parênteses.

3. Suba um nível, em relação à expressão que analisamos durante o anterior


etapa. Encontre os parênteses externos e repita a etapa 2.

210
Machine Translated by Google

Capítulo 11 ÿ Memória

Ilustraremos esse algoritmo em um exemplo mostrado na Listagem 11-15. A Tabela 11-1 descreve o processo de
análise.

Listagem 11-15. complexo_decl_1.c

int* (* (*fp) (int) ) [10];

Tabela 11-1. Análise de definição complexa

Expressão Interpretação

fp Primeiro identificador.

(*fp) É um ponteiro.

(* (*fp) (int)) int* (* Uma função aceitando int e retornando um ponteiro…

(*fp) (int)) [10] … para uma matriz de dez ponteiros para int

Como você pode ver, o processo de decifrar declarações complexas não é fácil. Pode ser simplificado por
usando typedefs para partes das declarações.

11.4 Literais de String


Qualquer sequência de elementos char terminada por um terminador nulo pode ser vista como uma string em C. Aqui, entretanto,
queremos falar sobre as strings imediatamente codificadas, portanto, strings literais. A maioria dos literais de string são armazenados
em .rodata se eles forem grandes o suficiente.
A Listagem 11-16 mostra um exemplo de uma string literal.

Listagem 11-16. str_lit_example.c

char* str = "quando a música acabar, apague as luzes";

str é apenas um ponteiro para o primeiro caractere da string.


De acordo com o padrão da linguagem, literais de string (ou ponteiros para strings criados dessa forma) não podem
ser alterado.1 A Listagem 11-17 mostra um exemplo.

Listagem 11-17. string_literal_mut.c

char* str = "olá mundo abcdefghijkl";


/* a linha a seguir produz um erro de execução */
str[15] = '\'';

Em C++, os literais de string têm o tipo char const* por padrão, o que reflete sua natureza imutável.
Considere usar variáveis do tipo char const* sempre que possível, quando as strings com as quais você está lidando não
devem sofrer mutação.
As construções mostradas na Listagem 11-18 também estão corretas, embora você provavelmente nunca usará a segunda.

1
Para ser mais preciso, o resultado de tal operação não está bem definido.

211
Machine Translated by Google

Capítulo 11 ÿ Memória

Listagem 11-18. str_lit_ptr_ex.c

char will_be_o = "olá, mundo!"[4]; /* é 'o' */


char const* cauda = "abcde"+3; /* é "de", pulando 3 símbolos */

Ao manipular strings, existem vários cenários comuns com base no local onde a string está alocada.

1. Podemos criar uma string entre variáveis globais. Será mutável e sob nenhuma
circunstâncias, será duplicado em região de dados constante. A Listagem 11-19 mostra um exemplo.

Listagem 11-19. str_glob.c

char str[] = "algo_global";


vazio f (vazio) {...}

Em outras palavras, é apenas um array global inicializado com códigos de caracteres.

2. Podemos criar uma string em uma pilha, em uma variável local. Listagem 11-20
mostra um exemplo.

Listagem 11-20. str_loc.c

função nula(void) {
char str[] = "algo_local";
}

A própria string "something_local", entretanto, deve ser mantida em algum lugar porque as
variáveis locais são inicializadas toda vez que a função é iniciada, e temos que saber os
valores com os quais elas devem ser inicializadas.

No caso de strings relativamente curtas, o compilador tentará incorporá-las no fluxo


de instruções. Aparentemente, para strings menores, é mais sensato dividi-las em
pedaços de 8 bytes e executar instruções mov com cada pedaço como um operando
imediato.

As strings longas, entretanto, são melhor mantidas em .rodata. A instrução, mostrada na


Listagem 11.20, alocará bytes suficientes na pilha e, em seguida, executará uma cópia dos
dados somente leitura para esse buffer de pilha local.

3. Podemos alocar uma string dinamicamente via malloc. O arquivo de cabeçalho string.h
contém algumas funções muito úteis, como memcpy, usada para realizar cópias rápidas.

A Listagem 11-21 mostra um exemplo.

Listagem 11-21. str_malloc.c

#include <malloc.h>
#include <string.h>

int principal( int argc, char** argv )


{
char*str = (char*)malloc( 25 );
strcpy(str, "uau, que string legal!" );

grátis(str);
}

212
Machine Translated by Google

Capítulo 11 ÿ Memória

ÿ Pergunta 210 Por que alocamos 25 bytes para uma string de 24 caracteres?

ÿ Pergunta 211 Leia man para as funções: memcpy, memset, strcpy.

11.4.1 Estágio de String


“Estágio em String” é um termo mais acostumado a programadores Java ou C#. Porém, na realidade, algo semelhante está
acontecendo em C (mas apenas em tempo de compilação). O compilador tenta evitar a duplicação de strings na região de
dados somente leitura. Isso significa que normalmente endereços iguais serão atribuídos a todas as três variáveis no código
mostrado na Listagem 11-22.

Listagem 11-22. str_intern.c

char* best_guitar_solo = "Quinta de quinta";


char* good_genesis_song = "Firth of quinto";
char* best_1973_live = "Quinto do quinto";

A internação de strings seria impossível se os literais de strings não fossem protegidos contra reescrita. Caso contrário,
ao alterar essas strings num local de um programa estamos a introduzir uma alteração imprevisível nos dados utilizados noutro
local, uma vez que ambos partilham a mesma cópia da string.

11.5 Modelos de Dados


Já falamos sobre os tamanhos de diferentes tipos de números inteiros. O padrão de linguagem está impondo um conjunto
de regras como “o tamanho do longo não é menor que o tamanho do curto” ou “o tamanho do curto assinado deve ser tal
que possa conter valores no intervalo . . . 216-1 . ” A última regra, porém, não nos fornece um tamanho fixo,
-216 porque o curto poderia ter 8 bytes amplo e ainda satisfaz esta restrição. Portanto, esses requisitos estão longe de
definir os tamanhos exatos da pedra. Para sistematizar diferentes conjuntos de tamanhos, foram criadas as convenções
denominadas modelo de dados . Cada um deles define tamanhos para tipos básicos. A Figura 11-2 mostra alguns modelos
de dados notáveis que podem ser do nosso interesse.

Figura 11-2. Modelos de dados

213
Machine Translated by Google

Capítulo 11 ÿ Memória

Como escolhemos o sistema GNU/Linux de 64 bits para fins de estudo, nosso modelo de dados é LP64. Quando
você desenvolve para o sistema Windows de 64 bits, o tamanho do comprimento será diferente.
Todo mundo quer escrever código portátil que possa ser reutilizado em diferentes plataformas e, felizmente, existe uma maneira em
conformidade com o padrão para nunca sofrer alterações no modelo de dados.
Antes do C99, era uma prática comum criar um conjunto de aliases de tipo no formato int32 ou uint64 e usá-los exclusivamente em
todo o programa, em vez de ints ou longs em constante mudança. Caso a arquitetura de destino mudasse, os aliases de tipo seriam fáceis de
corrigir. No entanto, criou um caos porque cada um criou seu próprio conjunto de tipos.

C99 introduziu tipos independentes de plataforma. Para usá-los, você deve apenas incluir um cabeçalho stdint.h.
Dá acesso aos diferentes tipos inteiros de tamanho fixo. Cada um deles possui um formulário:

• u, se o tipo não for assinado;

• interno;

• Tamanho em bits: 8, 16, 32 ou 64; e

• _t.

Por exemplo, uint8_t, int64_t, int16_t.

As funções da função printf (e de entrada/saída de formato semelhante) receberam um tratamento semelhante,


introduzindo macros especiais para selecionar os especificadores de formato corretos. Eles são definidos
no arquivo inttypes.h.

Nos casos comuns, você deseja ler ou escrever números inteiros ou ponteiros. Então o nome da macro será
formado da seguinte forma:

• PRI para saída (printf, fprintf etc.) ou SCN para entrada (scanf, fscanf etc.).

• Especificador de formato:

– d para formatação decimal.

– x para formatação hexadecimal.

– o para formatação octal.

– você para formatação int não assinada.

– i para formatação de número inteiro.

• Informações adicionais incluem um dos seguintes:

– N para números inteiros de N bits.

– PTR para ponteiros.

– MAX para tamanho máximo de bits suportado.

– FAST é a implementação definida.

Temos que aproveitar o fato de que vários literais de string, delimitados por espaços, são concatenados automaticamente.
A macro produzirá uma string contendo um especificador de formato correto, que será concatenado com tudo o que estiver ao seu redor.

A Listagem 11-23 mostra um exemplo.

214
Machine Translated by Google

Capítulo 11 ÿ Memória

Listagem 11-23. inttypes.c

#include <inttypes.h>
#include <stdio.h>

vazio f(vazio) {
int64_t i64 = -10;
uint64_t u64 = 100;
printf("Inteiro assinado de 64 bits: %" PRIi64 "\n", i64 );
printf("Inteiro não assinado de 64 bits: %" PRIu64 "\n", u64 );
}

Consulte a seção 7.8.1 de [7] para obter uma lista completa dessas macros.

11.6 Fluxos de dados


A biblioteca padrão C nos fornece uma maneira de trabalhar com arquivos de forma independente de plataforma. Ele abstrai arquivos
como fluxos de dados, dos quais podemos ler e escrever.
Vimos como os arquivos são tratados no Linux no nível das chamadas do sistema: a chamada do sistema open abre um arquivo
e retorna seu descritor, um número inteiro, as chamadas de sistema write e read são usadas para realizar escrita e leitura,
respectivamente, e a chamada de sistema close garante que o arquivo seja fechado corretamente. Como a linguagem C foi criada
em paridade com o sistema operacional Unix, eles possuem a mesma abordagem para interações de arquivos. As contrapartes
da biblioteca dessas funções são chamadas fopen, fwrite, fread e fclose. Em sistemas do tipo Unix, eles atuam como um adaptador
para chamadas de sistema, fornecendo funcionalidade semelhante, exceto que também funcionam em outras plataformas da mesma
maneira. As principais diferenças são as seguintes:

1. No lugar dos descritores de arquivo, usamos um tipo especial FILE, que armazena todas as informações
sobre um determinado fluxo. Sua implementação está oculta e você nunca deve alterar seu estado interno
manualmente. Portanto, em vez de trabalhar com descritores de arquivos numéricos (que dependem
da plataforma), usamos FILE como uma caixa preta.

A instância FILE é alocada em heap internamente pela própria biblioteca C, portanto, a qualquer
momento trabalharemos com um ponteiro para ela, não diretamente com a própria instância.

2. Embora as operações de arquivo no Unix sejam mais ou menos uniformes, existem dois tipos de dados
fluxos em C.

• Os fluxos binários consistem em bytes brutos que são tratados “como estão”.

• Os fluxos de texto incluem símbolos agrupados em linhas; cada linha termina com um final de
caractere de linha (dependente da implementação).

Os fluxos de texto são limitados de várias maneiras em alguns sistemas.

• O comprimento da linha pode ser limitado.

• Talvez eles só consigam trabalhar com impressão de caracteres, novas


linhas, espaços e tabulações.

• Os espaços antes da nova linha podem desaparecer.

Em alguns sistemas operacionais, os fluxos de texto e binários utilizam diferentes formatos de arquivo e,
portanto, para trabalhar com um arquivo de texto de forma compatível entre todos os seus programas, o
uso de fluxos de texto é obrigatório.

Embora a biblioteca GNU C, geralmente associada ao GCC, não faça diferença entre fluxos binários e
de texto, em outras plataformas este não é o caso, portanto, distingui-los é crucial.

215
Machine Translated by Google

Capítulo 11 ÿ Memória

Por exemplo, vi uma situação em que a leitura de um bloco grande de um arquivo de imagem no
Windows (o compilador era MSVC) terminava prematuramente porque a imagem era obviamente
binária, enquanto o fluxo associado era criado em modo de texto.

A biblioteca padrão fornece máquinas para criar e trabalhar com fluxos. Algumas funções que define
deve ser usado apenas em fluxos de texto (como fscanf). O arquivo de cabeçalho relevante é denominado stdio.h.
Vamos analisar o exemplo mostrado na Listagem 11-24.

Listagem 11-24. arquivo_exemplo.c

int smth[]={1,2,3,4,5};
ARQUIVO* f = fopen( "olá.img", "rwb" );

pão( smth, sizeof(int), 1, f);

/* Esta linha é opcional. Através da função `fseek` podemos navegar no arquivo */


fseek(f, 0, SEEK_SET);

fwrite(smth, 5 * sizeof(int), 1, f);


ffechar(f);

• A instância de FILE é criada através de uma chamada à função fopen. Este último aceita o caminho
para arquivar e um conjunto de sinalizadores, comprimidos em uma string.

As bandeiras importantes do fopen estão listadas aqui.

– b - abre o arquivo em modo binário. É isso que faz uma distinção real entre fluxos de texto e
binários. Por padrão, os arquivos são abertos em modo texto.

– w - abre um fluxo com a possibilidade de escrever nele.

– r - abre um fluxo com a possibilidade de lê-lo.

– + - se você escrever simplesmente w, o arquivo será sobrescrito. Quando + estiver


presente, as gravações anexarão dados ao final do arquivo.

Se o arquivo não existir, ele será criado.

O arquivo hello.img é aberto em modo binário para leitura e gravação.


O conteúdo do arquivo será substituído.

• Depois de criado, o ARQUIVO mantém uma espécie de ponteiro para uma posição dentro do arquivo,
uma espécie de cursor. Leituras e gravações movem este cursor ainda mais.

• A função fseek é usada para mover o cursor sem realizar leituras ou gravações.
Permite mover o cursor relativamente à sua posição atual ou ao início do arquivo.

• As funções fwrite e fread são usadas para escrever e ler dados do FILE aberto
instância.

Tomando fread, por exemplo, ele aceita o buffer de memória para leitura. Os dois parâmetros inteiros são o tamanho de um
bloco individual e a quantidade de blocos lidos. O valor retornado é a quantidade de blocos lidos com sucesso do arquivo. A
leitura de cada bloco é atômica: ou é completamente lida ou não é lida.
Neste exemplo, o tamanho do bloco é igual a sizeof(int) e a quantidade de blocos é um.
O uso do fwrite é simétrico.

• fclose deve ser chamado quando o trabalho com o arquivo for concluído.

216
Machine Translated by Google

Capítulo 11 ÿ Memória

Existe uma constante especial EOF. Quando é retornado por uma função que trabalha com um arquivo, significa que o final do
arquivo foi atingido.
Outra constante BUFSIZ armazena o tamanho do buffer que funciona melhor no ambiente atual para operações de entrada e
saída.
Streams podem usar buffer. Isso significa que eles têm um buffer interno que faz proxy de todas as leituras e gravações. Ele
permite chamadas de sistema mais raras (que são caras em termos de desempenho devido à troca de contexto). Às vezes, quando
o buffer está cheio, a gravação aciona uma chamada de sistema de gravação. Um buffer pode ser liberado manualmente
usando o comando fflush. Quaisquer gravações atrasadas serão executadas e o buffer será redefinido.
Quando o programa é iniciado, três instâncias FILE* são criadas e anexadas aos fluxos com descritores 0, 1 e 2. Elas podem
ser chamadas de stdin, stdout e stderr. Todos os três geralmente usam um buffer, mas o stderr
está liberando automaticamente o buffer após cada gravação. É necessário não atrasar ou perder mensagens de erro.

ÿ Observação Novamente, os descritores são inteiros, as instâncias FILE não. A função int fileno( FILE* stream ) é
usada para obter o descritor subjacente para o fluxo de arquivo.

ÿ Pergunta 212 Leia man para funções: fread, fread, fwrite, fprintf, fscanf, fopen, fclose, fflush.

ÿ Pergunta 213 Pesquise e descubra o que acontecerá se a função fflush for aplicada a um fluxo bidirecional (aberto
para leitura e escrita) quando a última ação no fluxo antes dele for a leitura.

11.7 Atribuição: Funções e Listas de Ordem Superior


11.7.1 Funções Comuns de Ordem Superior
Nesta tarefa, implementaremos diversas funções de ordem superior em listas vinculadas, que devem ser familiares para aqueles
acostumados com o paradigma de programação funcional.
Essas funções são conhecidas pelos nomes foreach, map, map_mut e foldl.

• foreach aceita um ponteiro para o início da lista e uma função (que retorna void e aceita um int). Ele
inicia a função em cada elemento da lista.

• map aceita uma função f e uma lista. Ele retorna uma nova lista contendo os resultados de f aplicado a
todos os elementos da lista de origem. A lista de fontes não é afetada.

Por exemplo, f (x) = x + 1 mapeará a lista (1, 2, 3) em (2, 3, 4).

• map_mut faz o mesmo, mas altera a lista de fontes.

• foldl é um pouco mais complicado. Aceita:

– O valor inicial do acumulador.

– Uma função f (x, a).

– Uma lista de elementos.

Retorna um valor do mesmo tipo do acumulador, calculado da seguinte forma:

1. Lançamos f no acumulador e no primeiro elemento da lista. O resultado é o novo


valor do acumulador aÿ.

2. Lançamos f em aÿ e no segundo elemento da lista. O resultado é novamente o novo valor do


acumulador aÿÿ.
217
Machine Translated by Google

Capítulo 11 ÿ Memória

3. Repetimos o processo até que a lista seja consumida. No final, o valor final do acumulador é o resultado final.

Por exemplo, vamos considerar f (x, a) = x * a. Ao iniciar o foldl com o valor do acumulador 1 e esta função
calcularemos o produto de todos os elementos da lista.

• iterate aceita o valor inicial s, o comprimento da lista n e a função f. Em seguida, ele gera uma lista
de comprimento n da seguinte forma:

ÿsf , ÿ ( )s … ÿ )ÿf s() , fff ( ) , ( )sf ( ( )

As funções descritas acima são chamadas de funções de ordem superior, porque aceitam outras
funcionam como argumentos. Outro exemplo de tal função é a função de classificação de array qsort.

void qsort( void *base,


tamanho_t nmemb,
tamanho_t tamanho,
int (*comparar)(const void *, const void *));

Ele aceita a base de endereço inicial da matriz, contagem de elementos nmemb, tamanho do tamanho dos elementos individuais e
a função comparadora comparar. Esta função é o tomador de decisão que informa qual dos elementos fornecidos deve estar mais próximo
do início do array.

ÿ Pergunta 214 Leia man qsort.

11.7.2 Atribuição
A entrada contém um número arbitrário de inteiros.

1. Salve esses números inteiros em uma lista vinculada.

2. Transfira todas as funções escritas na tarefa anterior para arquivos .h e c separados.


Não se esqueça de colocar uma proteção incluída!

3. Implementar cada um; usando-o, imprima a lista inicial para stdout duas vezes: na primeira vez, separe os
elementos com espaços, na segunda vez imprima cada elemento na nova linha.

4. Implementar mapa; usando-o, produza os quadrados e os cubos dos números da lista.

5. Implementar dobramento; usando-o, produza a soma e o elemento mínimo e máximo


na lista.

6. Implementar map_mut; usando-o, produza os módulos dos números de entrada.

7. Implementar iteração; usando-o, crie e produza a lista das potências de dois (primeiros 10 valores: 1, 2, 4,
8,…).

8. Implemente uma função bool save(struct list* lst, const char* filename);, que gravará todos os elementos da lista
em um arquivo de texto filename. Deve retornar verdadeiro caso a gravação seja bem-sucedida, caso
contrário, falso.

9. Implemente uma função bool load(struct list** lst, const char* filename);, que lerá todos os números inteiros
de um arquivo de texto filename e gravará a lista salva em *lst. Deve retornar verdadeiro caso a gravação
seja bem-sucedida, caso contrário, falso.

218
Machine Translated by Google

Capítulo 11 ÿ Memória

10. Salve a lista em um arquivo de texto e carregue-a novamente usando as duas funções acima. Verifique se o
salvamento e o carregamento estão corretos.

11. Implemente uma função bool serialize(struct list* lst, const char*
filename);, que gravará todos os elementos da lista em um arquivo binário filename. Deve retornar
verdadeiro caso a gravação seja bem-sucedida, caso contrário, falso.

12. Implemente uma função bool deserialize(struct list** lst, const char* filename);, que lerá todos os
números inteiros de um arquivo binário filename e gravará a lista salva em *lst. Deve retornar
verdadeiro caso a gravação seja bem-sucedida, falso
de outra forma.

13. Serialize a lista em um arquivo binário e carregue-a de volta usando as duas funções acima. Verifique se a
serialização e a desserialização estão corretas.

14. Libere toda a memória alocada.

Você terá que aprender a usar

• Ponteiros de função.

• limites.h e constantes dele. Por exemplo, para encontrar o elemento mínimo em


um array, você deve usar foldl com o valor int máximo possível como um acumulador e uma função que retorna
no mínimo dois elementos.

• A palavra-chave estática para funções que você deseja usar apenas em um módulo.

Você está garantido, que

• O fluxo de entrada contém apenas números inteiros separados por caracteres de espaço em branco.

• Todos os números da entrada podem ser contidos como int.

Provavelmente é aconselhável escrever uma função separada para ler uma lista de FILE.
A solução ocupa cerca de 150 linhas de código, sem contar as funções, definidas na tarefa anterior.

ÿ Pergunta 215 Em linguagens como C#, é possível um código como o seguinte:

var contagem = 0;

minhalista.Foreach( x => contagem += 1 );

Aqui lançamos uma função anônima (ou seja, uma função que não tem nome, mas cujo endereço pode ser
manipulado, por exemplo, passado para outra função) para cada elemento de uma lista. A função é escrita como x =>
count += 1 e é equivalente a

void no_name(int x) { contagem += 1; }

O interessante é que esta função conhece algumas das variáveis locais do chamador e, portanto, pode modificá-las.

Você pode reescrever a função forall para que ela aceite um ponteiro para uma espécie de “contexto”, que pode conter
um número arbitrário de endereços de variáveis e então passar o contexto para a função chamada para cada elemento?

219
Machine Translated by Google

Capítulo 11 ÿ Memória

11.8 Resumo
Neste capítulo estudamos o modelo de memória. Obtivemos uma melhor compreensão das dimensões de tipo e dos
modelos de dados, estudamos aritmética de ponteiros e aprendemos a decifrar declarações de tipos complexos. Além
disso, vimos como usar as funções da biblioteca padrão para realizar a entrada e a saída. Praticamos isso implementando
várias funções de ordem superior e fazendo algumas entradas e saídas de arquivos.
Aprofundaremos ainda mais nossa compreensão do layout da memória no próximo capítulo, onde
elaboraremos a diferença entre as três “facetas” de uma linguagem (sintaxe, semântica e pragmática), estudaremos as
noções de comportamento indefinido e não especificado e mostraremos por que o o alinhamento dos dados é importante.

ÿ Pergunta 216 Que operações aritméticas você pode realizar com ponteiros e em que condições?

ÿ Pergunta 217 Qual é o propósito de void*?

ÿ Pergunta 218 Qual é o propósito de NULL?

ÿ Pergunta 219 Qual é a diferença entre 0 no contexto de ponteiro e 0 como um valor inteiro?

ÿ Pergunta 220 O que é ptrdiff_t e como é usado?

ÿ Pergunta 221 Qual é a diferença entre size_t e ptrdiff_t?

ÿ Pergunta 222 O que são objetos de primeira classe?

ÿ Pergunta 223 As funções são objetos de primeira classe em C?

ÿ Pergunta 224 Quais regiões de dados a máquina abstrata C contém?

ÿ Pergunta 225 A região de dados constantes geralmente é protegida contra gravação por hardware?

ÿ Pergunta 226 Qual é a conexão entre ponteiros e arrays?

ÿ Pergunta 227 Qual é a alocação dinâmica de memória?

ÿ Pergunta 228 Qual é o operador sizeof ? Quando é computado?

ÿ Pergunta 229 Quando os literais de string são armazenados em .rodata?

ÿ Pergunta 230 O que é internação de string?

ÿ Pergunta 231 Qual modelo de dados estamos usando?

ÿ Pergunta 232 Qual cabeçalho contém tipos independentes de plataforma?

ÿ Pergunta 233 Como concatenamos strings literais em tempo de compilação?

ÿ Pergunta 234 O que é o fluxo de dados?

ÿ Pergunta 235 Existe alguma diferença entre um fluxo de dados e um descritor?

ÿ Pergunta 236 Como obtemos o descritor do stream?

ÿ Pergunta 237 Há algum fluxo aberto quando o programa é iniciado?

ÿ Pergunta 238 Qual é a diferença entre fluxos binários e de texto?

ÿ Pergunta 239 Como abrimos um fluxo binário? Um fluxo de texto?

220
Machine Translated by Google

CAPÍTULO 12

Sintaxe, Semântica e Pragmática

Neste capítulo, revisaremos a própria essência do que é a linguagem de programação. Esses fundamentos nos
permitirão compreender melhor a estrutura da linguagem, o comportamento do programa e os detalhes da tradução que você deve
conhecer.

12.1 O que é uma linguagem de programação?


Uma linguagem de programação é uma linguagem formal de computador projetada para descrever algoritmos de uma
forma compreensível por uma máquina. Cada programa é uma sequência de caracteres. Mas como diferenciamos os programas
de todas as outras strings? Precisamos definir a linguagem de alguma forma.
A maneira mais grosseira é dizer que o próprio compilador é a definição da linguagem, uma vez que analisa programas
e os traduz em código executável. Essa abordagem é ruim por vários motivos. O que fazemos com bugs do compilador? Eles
são realmente bugs ou afetam a definição da linguagem? Como escrevemos outros compiladores? Por que deveríamos misturar
a definição da linguagem e os detalhes de implementação?
Outra maneira é fornecer uma maneira mais limpa e independente de implementação de descrever a linguagem. É bastante
comum visualizar três facetas de uma única linguagem.

•As regras de construção de enunciados. Freqüentemente, a descrição de programas corretamente


estruturados é feita por meio de gramáticas formais. Essas regras formam a sintaxe da linguagem.

•Os efeitos da construção de cada linguagem na máquina abstrata. Isto é o


semântica da linguagem .

•Em qualquer língua existe também um terceiro aspecto, denominado pragmática. Descreve a
influência da implementação no mundo real no comportamento do programa.

– Em algumas situações, o padrão de linguagem não fornece informações suficientes sobre o


comportamento do programa. Então cabe inteiramente ao compilador decidir como ele traduzirá
este programa, por isso ele geralmente atribui algum comportamento específico a tal
programas.

Por exemplo, na chamada f(g(x), h(x)) a ordem de avaliação de g(x) e h(x) não é definida
por padrão. Podemos calcular g(x) e depois h(x), ou vice-versa. Mas o compilador escolherá
uma determinada ordem e gerará instruções que realizarão chamadas exatamente nessa
ordem.

– Às vezes, existem diferentes maneiras de traduzir as construções da linguagem no código-alvo.


Por exemplo, queremos proibir o compilador de incorporar certas funções ou seguimos a
estratégia laissez-faire?

Neste capítulo vamos explorar essas três facetas das linguagens e aplicá-las a C.

© Igor Zhirkov 2017 221


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_12
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.2 Sintaxe e Gramáticas Formais


Em primeiro lugar, uma linguagem é um subconjunto de todas as strings possíveis que podemos construir a partir de um determinado alfabeto.
Por exemplo, uma linguagem de expressões aritméticas tem um alfabeto ÿ = {0, 1, 2, 3, 4 , 5, 6, 7, 8, 9, +, ÿ, ×, /, .}, assumindo apenas estes quatro
aritméticos operações são usadas e o ponto separa uma parte inteira. Nem todas as combinações desses símbolos formam uma string válida –
por exemplo, +++-+ não é uma frase válida nesta linguagem.

As gramáticas formais foram formalizadas pela primeira vez por Noam Chomsky. Eles foram criados na tentativa de formalizar línguas naturais,
como o inglês. Segundo eles, as frases possuem uma estrutura em forma de árvore, onde as folhas são uma espécie de “blocos básicos” e a partir delas
(e outras partes complexas) são construídas partes mais complexas de acordo com algumas regras.

Todas essas partes primitivas e compostas são geralmente chamadas de símbolos. Os símbolos atômicos são chamados de terminais e
os complexos são não-terminais.
Esta abordagem foi adotada para construir linguagens sintéticas com gramáticas muito simples (em comparação com linguagens naturais).

Formalmente, uma gramática consiste em

•Um conjunto finito de símbolos terminais.

•Um conjunto finito de símbolos não-terminais.

•Um conjunto finito de regras de produção, que contém informações sobre a estrutura da linguagem.

•Um símbolo inicial, um não-terminal que corresponderá a qualquer símbolo construído corretamente
declaração de linguagem. É um ponto de partida para analisarmos qualquer afirmação.

A classe de gramáticas que nos interessa possui uma forma muito particular de regras de produção. Cada um
eles se parecem

<nonterminal> ::= sequência de terminais e não terminais

Como vemos, esta é exatamente a descrição de uma estrutura complexa não-terminal. Podemos escrever múltiplas regras possíveis para o
mesmo não-terminal e a mais conveniente será aplicada. Para torná-lo menos detalhado, usaremos a notação com o símbolo | para denotar “ou”, assim
como em expressões regulares.
Essa forma de descrever regras gramaticais é chamada de BNF (forma Backus-Naur): os terminais são denotados por strings entre aspas,
as regras de produção são escritas usando caracteres ::= e os nomes não-terminais são escritos entre colchetes.

Às vezes também é bastante conveniente introduzir um terminal ÿ, que, durante a análise, será correspondido com uma (sub)string vazia.

Portanto, as gramáticas são uma forma de descrever a estrutura da linguagem. Eles permitem que você execute os seguintes tipos de tarefas:

•Teste uma declaração de linguagem quanto à correção sintática.

•Gerar declarações de linguagem corretas.

•Analisar instruções de linguagem em estruturas hierárquicas onde, por exemplo, o if


A condição é separada do código ao seu redor e desdobrada em uma estrutura semelhante a uma árvore, pronta para
ser avaliada.

222
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.2.1 Exemplo: Números Naturais


A linguagem dos números naturais pode ser representada por meio de uma gramática.
Tomaremos este conjunto de caracteres como o alfabeto: ÿ = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}. No entanto, queremos uma
representação mais decente do que apenas todas as strings possíveis construídas com os caracteres de ÿ, porque os números
com zeros à esquerda (000124) não parecem bonitos.
Definimos vários símbolos não-terminais: primeiro, <notzero> para qualquer dígito exceto zero, <digit> para qualquer dígito
e <raw> para qualquer sequência de <digit>s.
Como sabemos, várias regras são possíveis para um não-terminal. Então, para definir <notzero>, podemos escrever
tantas regras quantas forem as diferentes opções:

<não zero> ::= '1'


<nãozero> ::= '2'
<não zero> ::= '3'
<não zero> ::= '4'
<não zero> ::= '5'
<não zero> ::= '6'
<não zero> ::= '7'
<não zero> ::= '8'
<não zero> ::= '9'

Porém, como é muito complicado e não tão fácil de ler, usaremos notações diferentes para descrever exatamente as mesmas
regras:

<nãozero> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'

Esta notação faz parte da BNF canônica.


Depois de adicionar um zero, obtemos uma regra para <dígito> não terminal, que codifica qualquer dígito.

<dígito> ::= '0' | <diferente de zero>

Em seguida, definimos o não terminal <raw> para codificar todas as sequências de dígitos. Uma sequência de dígitos é definida em um
forma recursiva como um dígito ou um dígito seguido por outra sequência de dígitos.

<bruto> ::= <dígito> | <dígito> <bruto>

O <número> nos servirá como símbolo inicial. Ou lidamos com um número de um dígito, que não tem restrições sobre si
mesmo, ou temos vários dígitos, e então o primeiro não deve ser zero (caso contrário, é um zero à esquerda que não queremos
ver); o resto pode ser arbitrário.
A Listagem 12-1 mostra o resultado final.

Listagem 12-1. gramática_naturals

<nãozero> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
<dígito> ::= '0' | <diferente de zero>
<bruto> ::= <dígito> | <dígito> <bruto>
<número> ::= <dígito> | <não zero> <bruto>

223
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.2.2 Exemplo: Aritmética Simples


Vamos adicionar algumas operações binárias simples. Para começar, nos limitaremos à adição e multiplicação.
Basearemos isso em um exemplo mostrado na Listagem 12-1.
Vamos adicionar um <expr> não terminal que servirá como um novo símbolo inicial. Uma expressão é um número
ou um número seguido por um símbolo de operação binária e outra expressão (portanto, uma expressão também é definida
recursivamente).
A Listagem 12-2 mostra um exemplo.

Listagem 12-2. gramática_nat_pm

<nãozero> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
<dígito> ::= '0' | <diferente de zero>
<bruto> ::= <dígito> | <dígito> <bruto>
<número> ::= <dígito> | <não zero> <bruto>

<expr> ::= <número> | <número> '+' <expr> | <número> '-' <expr>

A gramática nos permite construir uma estrutura em forma de árvore no topo do texto, onde cada folha é um terminal, e
cada outro nó é um não terminal. Por exemplo, vamos aplicar o conjunto atual de regras a uma string 1+42 e ver como ela é
desconstruída. A Figura 12-1 mostra o resultado.

Figura 12-1. Árvore de análise para a expressão 1+42

A primeira expansão é realizada de acordo com a regra <expr> ::= número '+' <expr>. A última expressão é apenas
um número, que por sua vez é uma sequência de dígitos e um número.

12.2.3 Descida Recursiva


Escrever analisadores manualmente não é difícil. Para ilustrar isso, mostraremos um analisador que aplica nosso novo
conhecimento sobre gramáticas para traduzir literalmente a descrição da gramática no código de análise.
Vamos pegar uma gramática para números naturais que já descrevemos na seção 12.2.1 e adicionar apenas
mais uma regra para isso. O novo símbolo inicial será str, que corresponde a “um número terminado por um terminador
nulo”. A Listagem 12-3 mostra a definição gramatical revisada.

224
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Listagem 12-3. gramática_naturals_nullterm

<nãozero> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' <dígito> ::= '0' | <notzero> <raw> ::=
<dígito> | <dígito> <bruto>
<número> ::= <dígito> | <não zero> <bruto>

<str> ::= <número> '\0'

As pessoas geralmente operam com uma noção de fluxo ao realizar a análise de regras gramaticais. Um fluxo
é uma sequência de tudo o que é considerado símbolo. Sua interface consiste em duas funções:

•bool expect(symbol) aceita um único terminal e retorna verdadeiro se o fluxo contiver


exatamente esse tipo de terminal na posição atual.

•bool accept(symbol) faz o mesmo e então avança a posição do stream em um em


caso de sucesso.

Até agora, operamos com abstrações como símbolos e fluxos. Podemos mapear todo o resumo
noções para as instâncias concretas. No nosso caso, o símbolo corresponderá a um único caractere. 1
A Listagem 12-4 mostra um exemplo de processador de texto construído com base em definições de regras
gramaticais. Este é um verificador sintático, que verifica se a string contém um número natural sem zeros à esquerda e
nada mais (como espaços ao redor do número).

Listagem 12-4. rec_desc_nat.c

#include <stdio.h>
#include <stdbool.h>

char const* fluxo = NULL;

bool aceitar(char c) { if
(*stream == c) { stream+
+; retornar
verdadeiro;

} senão retorna falso;

} bool notzero( void ) { return


accept( '1' ) || aceitar( '2' ) || aceitar( '3' ) || aceitar( '4' ) || aceitar( '5' ) ||
aceitar( '6' ) || aceitar( '7' ) || aceitar( '8' ) || aceitar('9');

} bool dígito (void) { return


aceitar ('0') || diferente de zero();
}

1
Para analisadores de linguagens de programação é muito mais simples escolher palavras-chave e classes de palavras (como
identificadores ou literais) como símbolos terminais. Dividi-los em caracteres únicos introduz uma complexidade desnecessária.

225
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

bool bruto (vazio) {


if (dígito()) { bruto(); retornar verdadeiro; }
retorna falso;
}
bool número(vazio) {
if (não zero()) {
cru();
retornar verdadeiro;
} senão retorne aceitar('0');
}
bool str(vazio) {
retornar número() && aceitar( 0 );
}
verificação vazia (const char* string) {
fluxo = string;
printf("%s -> %d\n", string, str() );
}
int principal(void) {
verifique("12345");
verifique("olá12");
verifique("0002");
check("10db");
verifique("0");
retornar 0;
}

Este exemplo mostra como cada não-terminal é mapeado para uma função com o mesmo nome que tenta
aplicar as regras gramaticais relevantes. A análise ocorre de cima para baixo: começamos com o símbolo inicial mais
geral e tentamos dividi-lo em partes e analisá-las.
Quando as regras começam iguais, nós as fatoramos aplicando primeiro a parte comum e depois tentando
consumir o resto, como na função numérica. As duas ramificações começam com não-terminais sobrepostos: <dígito>
e <diferente de zero>. Cada um deles contém o intervalo de 1 a 9, sendo a única diferença o intervalo de <dígito> incluindo zero.
Portanto, se encontrarmos um terminal no intervalo 1...9, tentaremos consumir o máximo de dígitos que pudermos e teremos
sucesso de qualquer maneira. Caso contrário, verificamos se o primeiro dígito é 0 e paramos se for assim, não consumindo mais terminais.
A função <notzero> será bem-sucedida se pelo menos um dos símbolos no intervalo 1-9 for encontrado. Devido
à aplicação lenta de ||, nem todas as chamadas de aceitação serão executadas. O primeiro deles que obtiver sucesso
encerrará a avaliação da expressão, portanto ocorrerá apenas um avanço no fluxo.
A função <digit> será bem-sucedida se um zero for encontrado ou se <notzero> for bem-sucedido, o que é uma tradução literal de
uma regra:

<dígito> ::= '0' | <diferente de zero>

As demais funções estão funcionando da mesma maneira. Não deveríamos nos limitar a um terminador nulo,
a análise nos responderia a uma pergunta: “esta sequência de símbolos começa com uma sentença de
linguagem válida?”
Na Listagem 12-4 usamos uma variável global propositalmente para facilitar o entendimento. Nós ainda
desaconselho fortemente seu uso em programas reais.

226
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Os analisadores para linguagens de programação reais são geralmente bastante complexos. Para escrevê-los, os
programadores usam um conjunto de ferramentas especial que pode gerar analisadores a partir da descrição declarativa próxima ao BNF.
Caso você precise escrever um analisador para uma linguagem complexa, recomendamos que você dê uma olhada nos geradores de
analisadores ANTLR ou yacc.
Outra técnica popular de analisadores de caligrafia é chamada de combinadores de analisadores. Incentiva a criação
analisadores para os elementos de texto genéricos mais básicos (um único caractere, um número, o nome de uma variável, etc.).
Em seguida, esses pequenos analisadores são combinados (OR, AND, sequência…) e transformados (uma ou muitas ocorrências,
zero ou mais ocorrências…) para produzir analisadores mais complexos. Esta técnica, no entanto, é fácil de aplicar quando a
linguagem suporta um estilo funcional de programação, porque muitas vezes depende de funções de ordem superior.

ÿ Sobre recursão em gramáticas As regras gramaticais podem ser recursivas, como vemos. No entanto, dependendo
da técnica de análise, usar certos tipos de recursão pode ser desaconselhável. Por exemplo, uma regra expr ::= expr '+' expr,
embora seja válida, não nos permitirá construir um analisador facilmente. Para escrever bem uma gramática neste
sentido, você deve evitar regras recursivas à esquerda como a listada anteriormente, pois, codificada ingenuamente, só
produzirá uma recursão infinita, quando a função expr() iniciará sua execução com outra chamada para expr(). As regras
que refinam o primeiro não-terminal do lado direito da produção evitam esse problema.

ÿ Pergunta 240 Escreva um analisador descendente recursivo para aritmética de ponto flutuante com multiplicação,
subtração e adição. Para esta tarefa, consideramos que não existem literais negativos (então, em vez de escrever
-1,20, escreveremos 0-1,20.

12.2.4 Exemplo: Aritmética com Prioridades


A parte interessante das expressões é que operações diferentes têm prioridades diferentes. Por exemplo, a operação de adição
tem uma prioridade mais baixa do que a operação de multiplicação, portanto todas as multiplicações são feitas antes da adição.

Vejamos a gramática ingênua para números naturais com adição e multiplicação na Listagem 12-5.

Listagem 12-5. gramática_nat_pm_mult

<nãozero> ::= '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
<dígito> ::= '0' | <diferente de zero>
<bruto> ::= <dígito> | <dígito> <bruto>
<número> ::= <dígito> | <não zero> <bruto>

<expr> ::= <número> | <número> '+' <expr>


| <número> '-' <expr> | <número> '*' <expr>

227
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Sem levar em conta a prioridade de multiplicação, a árvore de análise para a expressão 1*2+3 terá a aparência mostrada
na Figura 12-2.

Figura 12-2. Analisar árvores sem prioridades para a expressão 1*2+3

Porém, como notamos, a multiplicação e a adição são iguais aqui: são expandidas em ordem de aparecimento. Por conta
disso, a expressão 1*2+3 é analisada como 1*(2+3), quebrando a ordem de avaliação comum, vinculada à estrutura em árvore.

Do ponto de vista do analisador, a prioridade significa que na árvore de análise os nós “adicionar” devem estar
mais próximos da raiz do que os nós “multiplicar”, uma vez que a adição é realizada nas partes maiores da expressão. A
avaliação das expressões aritméticas é realizada, informalmente, começando pelas folhas e terminando na raiz.

Como priorizamos algumas operações em detrimento de outras? É adquirido dividindo uma categoria sintática <expr>
em várias classes. Cada classe é uma espécie de refinamento da classe anterior. A Listagem 12-6 mostra um exemplo.

Listagem 12-6. gramática_prioridades

<expr> ::= <expr0> "<" <expr> | <expr0> "<=" <expr>


| <expr0> "==" <expr> | <expr0> ">" <expr> | <expr0> ">=" <expr> | <expr0>

"-"
<expr0> = <expr1> "+" <expr> | <expr1> <expr1> ::= <expr> | <expr1>
<atom> "*" <atom> ::= "(" <expr1> | <átomo> "/" <expr1> | <átomo>
<expr> ")" | <NÚMERO>

Podemos entender este exemplo da seguinte maneira:

•<expr> é realmente qualquer expressão.

•<expr0> é uma expressão sem <, >, == e outros terminais, que estão presentes no
primeira regra.

•<expr1> também está livre de adição e subtração.

228
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.2.5 Exemplo: Linguagem Imperativa Simples


Para ilustrar que esse conhecimento pode ser aplicado a linguagens de programação, daremos um exemplo de sintaxe.
Esta descrição de sintaxe fornece definições para as instruções, compreendendo construções imperativas típicas: if, while,
print e atribuições. As palavras-chave podem ser tratadas como terminais atômicos.
A Listagem 12-7 mostra a gramática.

Listagem 12-7. criança levada

<declarações> ::= <declarações> | <declaração> ";" <declarações>


<declaração> ::= "{" <declarações> "}" | <atribuição> | <se> | <enquanto> | <imprimir>
<imprimir> ::= "imprimir" "(" <expr> ")"
<atribuição> ::= IDENT "=" <expr>
<if> ::= "<if>" "(" <expr> ")" <instrução> "<else>" <instrução>
<while> ::= "<while>" "(" <expr> ")" <instrução>

<expr> ::= <expr0> "<" <expr> | <expr0> "<=" <expr>


| <expr0> "==" <expr> | <expr0> ">" <expr> | <expr0> ">=" <expr> | <expr0>
"-"
<expr0> = <expr1> "+" <expr> | <expr1> <expr> | <expr1>
<expr1> ::= <atom> "*" <expr1> | <átomo> "/" <expr1> | <átomo>
<atom> ::= "(" <expr> ")" | NÚMERO

12.2.6 Hierarquia de Chomsky


As gramáticas formais tal como as estudamos são, na verdade, apenas uma subclasse de gramáticas formais tal como
Chomsky as via. Esta classe é chamada de gramáticas livres de contexto por razões que logo ficarão aparentes.
A hierarquia consiste em quatro níveis que variam de 3 a 0, sendo os níveis inferiores mais expressivos e
poderosos.

3. As gramáticas regulares são surpreendentemente descritas pelos nossos velhos amigos regulares
expressões. Os autômatos finitos são o tipo mais fraco de analisadores porque não conseguem lidar
com estruturas fractais, como expressões aritméticas.

Mesmo no caso mais simples, <expr> ::= número '+' <expr>, a parte da expressão no
lado direito de '+' é semelhante à expressão inteira. Esta regra pode ser aplicada
recursivamente por um período de tempo arbitrário.

2. As gramáticas livres de contexto, que já estudamos, possuem regras que são de


a forma

não terminal ::=


<sequência de símbolos terminais e não terminais>

Qualquer expressão regular também pode ser descrita em termos de gramáticas livres de contexto.

1. As gramáticas sensíveis ao contexto possuem regras de forma:

a A b ::= ayb

a e b denotam uma sequência arbitrária (possivelmente vazia) de terminais e/ou não


terminais, y denota uma sequência não vazia de terminais e/ou não terminais, e A é o não
terminal sendo expandido.

229
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

A diferença entre os níveis 2 e 1 é que o não-terminal do lado esquerdo é substituído por y somente

quando ocorre entre a e b (que permanecem intocados).


Lembre-se de que tanto a quanto b podem ser bastante complexos.

0. As gramáticas irrestritas possuem regras de forma:

sequência de símbolos terminais e não terminais ::=


sequência de símbolos terminais e não terminais

Como não há absolutamente nenhuma restrição nos lados esquerdo e direito das regras, essas
gramáticas são mais poderosas. Pode-se mostrar que esses tipos de gramáticas podem ser usados
para codificar qualquer programa de computador, portanto essas gramáticas são Turing-completas.

As verdadeiras linguagens de programação quase nunca são verdadeiramente livres de contexto. Por exemplo, o uso de uma variável
declarada anteriormente é aparentemente uma construção sensível ao contexto, porque só é válida quando segue uma declaração
de variável correspondente. No entanto, para simplificar, elas são frequentemente aproximadas com gramáticas livres de contexto e, em
seguida, são feitas passagens adicionais na transformação da árvore de análise para verificar se tais condições sensíveis ao contexto são
satisfeitas.

12.2.7 Árvore de Sintaxe Abstrata


Existe uma noção de sintaxe abstrata. Ele descreve as árvores construídas a partir do código-fonte. A sintaxe concreta descreve o
mapeamento exato entre palavras-chave e os tipos de nós de árvore para os quais elas são mapeadas. Por exemplo, imagine que
reescrevemos o compilador C para que a palavra-chave while seja substituída por _while_. Então imagine que reescrevemos todos os
programas para que esta nova palavra-chave seja usada em vez de while. A sintaxe concreta realmente mudou, mas a sintaxe
abstrata é a mesma, porque as construções da linguagem permaneceram as mesmas. Pelo contrário, se adicionarmos uma cláusula
final a if, ela incorpora uma instrução a ser executada independentemente do valor da condição, e também alteraremos a sintaxe abstrata.

A árvore de sintaxe abstrata geralmente também é muito mais minimalista em comparação com as árvores de análise. O
a árvore de análise conteria informações relevantes apenas para análise (veja a Figura 12-3).

Figura 12-3. Árvore de análise e árvore de sintaxe abstrata da expressão 1 + 2*3

Como podemos ver, a árvore à direita é muito mais concisa e direta. Esta árvore pode ser diretamente
avaliado por um intérprete ou algum código executável para calcular o que pode ser gerado.

230
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.2.8 Análise Lexical


Na realidade, aplicar regras gramaticais diretamente aos caracteres individuais é um exagero. Pode ser conveniente adicionar uma
pré-passagem chamada análise lexical. O texto bruto é primeiro transformado em uma sequência de lexemas (também
chamados de tokens). Cada token é descrito com uma expressão regular e extraído do fluxo de caracteres. Por exemplo, um
número pode ser descrito com uma expressão regular [0-9]+ e um identificador pode ser [a-zA-Z_]
[0-9a-zA-Z_]*. Após realizar esse processamento, o texto não será mais uma sequência plana de caracteres, mas sim uma lista
vinculada de tokens. Cada token será marcado com seu tipo e para o analisador, os tipos de token serão mapeados para terminais.

É fácil ignorar todos os detalhes de formatação (como quebras de linha e outros símbolos de espaço em branco) durante
esta etapa.

12.2.9 Resumo da análise


O compilador analisa o código-fonte em várias etapas. Duas etapas importantes são a análise lexical e sintática.
Durante a análise lexical, o texto do programa é dividido em lexemas, como literais inteiros ou palavras-chave.
A formatação do texto não é mais relevante após esta etapa. Cada tipo de lexema é melhor descrito usando uma expressão
regular.
Durante a análise sintática, uma estrutura em árvore é construída sobre o fluxo de tokens. Esta estrutura é
chamada de árvore de sintaxe abstrata. Cada nó corresponde a uma construção de linguagem.

12.3 Semântica
A semântica da linguagem é uma correspondência entre as sentenças como construções sintáticas e seu significado. Cada frase
é geralmente descrita como um tipo de nó na árvore de sintaxe abstrata do programa. Esta descrição é realizada de uma
das seguintes maneiras:

• Axiomaticamente. O estado atual do programa pode ser descrito com um conjunto de


fórmulas. Então cada passo da máquina abstrata transformará essas fórmulas de uma certa maneira.

• Denotacionalmente. Cada frase da linguagem é mapeada em um objeto matemático de uma


determinada teoria (por exemplo, teoria do domínio). Então os efeitos do programa podem ser
descritos em termos desta teoria. É de particular interesse ao raciocinar sobre o comportamento
de diferentes programas escritos em diferentes linguagens.

• Operacionalmente. Cada frase produz uma certa mudança de estado na máquina abstrata, que
está sujeita à descrição. As descrições no padrão C são informais, mas se assemelham mais à
descrição semântica operacional do que as outras duas.

O padrão de linguagem é a descrição da linguagem em formato legível por humanos. No entanto, embora seja mais
compreensível para quem não está preparado, é mais detalhado e às vezes menos inequívoco. Para escrever descrições concisas,
geralmente é usada uma linguagem de lógica matemática e cálculo lambda. Não entraremos em detalhes neste livro, porque este
tópico exige uma abordagem pedante por si só. Nós o encaminhamos para os livros [29] e [35] para um estudo imaculado da
teoria dos tipos e da semântica da linguagem.

231
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.3.1 Comportamento Indefinido


A integridade da descrição semântica não é imposta. Isso significa que algumas construções de linguagem são definidas
apenas para um subconjunto de todas as situações possíveis. Por exemplo, uma desreferência de ponteiro *x só
tem garantia de se comportar de maneira consistente quando x aponta para um local de memória “válido”. Quando x é
NULL ou aponta para memória desalocada, ocorre o comportamento indefinido . No entanto, tal expressão é
absolutamente correta sintaticamente.
O padrão introduz intencionalmente casos de comportamento indefinido. Por que?
Em primeiro lugar, é mais fácil escrever compiladores que produzam código com menos garantias. Segundo, todo
comportamento definido deve ser implementado. Se quisermos que a desreferenciação do ponteiro nulo acione um erro, o
compilador terá que fazer duas coisas cada vez que qualquer ponteiro for desreferenciado:

•Tente deduzir que neste exato local o ponteiro nunca poderá ter NULL como valor.

•Se o compilador não consegue deduzir que este ponteiro nunca é NULL, ele emite código assembly
para verificá-lo. Se o ponteiro for NULL, este código executará um manipulador para ele. Caso
contrário, ele continuará desreferenciando o ponteiro.

A Listagem 12-8 mostra um exemplo.

Listagem 12-8. ptr_análise1.c

interno x = 0;
int* p = &x;
...
/* não há escritas em `p` nestas linhas */
...
*p = 10; /* este ponteiro não pode ser NULL */

No entanto, isso é muito mais complicado do que pode parecer. No exemplo da Listagem 12-8, poderíamos ter
assumido que, como nenhuma gravação na variável p é realizada, ela sempre contém o endereço de x. Entretanto, isso nem
sempre é verdade, conforme ilustrado pelo exemplo mostrado na Listagem 12-9.

Listagem 12-9. ptr_análise2.c

interno x = 0;
int* p = &x;
...
/* não há escritas em `p` nestas linhas */
int**z = &p;
*z = NULO; /* Ainda não é uma gravação direta em `p` */
...
*p = 10; /* este ponteiro não pode ser NULL -- não é mais verdadeiro */

Portanto, resolver este problema requer, na verdade, uma análise muito complexa na presença de aritmética de ponteiros.
Uma vez obtido o endereço da variável, ou pior ainda, seu endereço é passado para uma função, você deve analisar toda a
sequência de chamada da função, levar em consideração os ponteiros de função, os ponteiros para os ponteiros, etc.
A análise nem sempre produzirá resultados corretos (no caso mais geral esse problema é até
teoricamente indecidível) e o desempenho pode ser prejudicado por causa disso. Assim, de acordo com o
espírito C laissez-faire, a correção da desreferenciação do ponteiro é deixada à responsabilidade do próprio
programador.

232
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Em linguagens gerenciadas como Java ou C#, o comportamento definido de desreferenciação de ponteiro é muito mais fácil
alcançar. Primeiro, eles geralmente são executados dentro de uma estrutura, que fornece código para levantamento e
tratamento de exceções. Em segundo lugar, a análise de nulidade é muito mais simples na ausência de aritmética de
endereços. Finalmente, eles geralmente são compilados just-in-time, o que significa que o compilador tem acesso às informações
de tempo de execução e pode usá-las para realizar algumas otimizações indisponíveis para um compilador antecipado. Por
exemplo, após o programa ter sido iniciado e fornecido a entrada do usuário, um compilador deduziu que o ponteiro x nunca será
NULO se uma determinada condição P for válida. Então ele pode gerar duas versões da função f contendo essa desreferência:
uma com verificação e outra sem verificação. Então, toda vez que f é chamado, apenas uma das duas versões é chamada.
Se o compilador puder provar que P é válido em uma situação de chamada, a versão não verificada será chamada; caso contrário,
o verificado será chamado.
O comportamento indefinido pode ser perigoso (e geralmente é). Isso leva a bugs sutis, pois não garante erro na
compilação ou no tempo de execução. O programa pode encontrar uma situação com comportamento indefinido e continuar a
execução silenciosamente; entretanto, seu comportamento mudará aleatoriamente após uma certa quantidade de instruções ser
executada.
Uma situação típica é a corrupção do heap. A pilha está de fato estruturada; cada bloco é delimitado com informações de
utilidade, utilizadas pela biblioteca padrão. Escrever fora dos limites do bloco (mas próximo a eles) provavelmente corromperá
essas informações, o que resultará em uma falha durante uma das futuras chamadas para malloc de free, tornando esse bug uma
bomba-relógio.
Aqui estão alguns casos de comportamento indefinido, especificados explicitamente pelo padrão C99. Não
fornecemos a lista completa porque são pelo menos 190 casos.

• Estouro de inteiro assinado.

•Desreferenciando um ponteiro inválido.

•Comparar os ponteiros com elementos de dois blocos de memória diferentes.

• Chamar função com argumentos que não correspondem à sua assinatura inicial (possível por
pegando um ponteiro para ele e lançando para outro tipo de função).

•Leitura de uma variável local não inicializada.

•Divisão por 0.

•Acessar um elemento do array fora de seus limites.

•Tentativa de alterar uma string literal.

•O valor de retorno de uma função, que não possui uma instrução de retorno executada.

12.3.2 Comportamento não especificado


É importante distinguir entre comportamento indefinido e comportamento não especificado. O comportamento não
especificado define um conjunto de comportamentos que podem acontecer, mas não especifica qual exatamente será
selecionado. A seleção dependerá do compilador.
Por exemplo,

•A ordem de avaliação do argumento da função não é especificada. Significa que enquanto


avaliando f(g(), h()) não temos garantias de que g() será avaliado primeiro e h()
segundo. No entanto, é garantido que g() e h() serão avaliados antes de f().

•A ordem de avaliação da subexpressão em geral, f(x) + g(x), não obriga que f seja executado antes de g.
O comportamento não especificado descreve os casos de não determinismo na máquina C abstrata.

233
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.3.3 Comportamento definido pela implementação


O padrão também define o comportamento definido pela implementação, como o tamanho de int (que, como dissemos, depende da
arquitetura). Podemos pensar nessas escolhas como parâmetros abstratos da máquina: antes de iniciá-la, temos que escolher
esses parâmetros.
Outro exemplo desse comportamento é a operação do módulo x % y. O resultado no caso de y negativo é
definido pela implementação.
Qual é a diferença entre comportamento definido pela implementação e comportamento não especificado? A resposta é que a
implementação (compilador) deve documentar explicitamente as escolhas que faz, enquanto em casos de comportamento não especificado
pode ocorrer qualquer coisa dentre um conjunto de comportamentos possíveis.

12.3.4 Pontos de Sequência


Os pontos de sequência são os locais do programa onde o estado da máquina abstrata é coerente com o estado da máquina
alvo. Podemos pensar neles desta forma: quando depuramos um programa, podemos executá-lo passo a passo, onde cada
passo é aproximadamente equivalente a uma instrução C. Geralmente paramos em ponto e vírgula, chamadas de função, ||
operador, etc. No entanto, podemos mudar para a visão assembly, onde cada instrução será codificada por possivelmente muitas
instruções, e executar essas instruções da mesma maneira. Ele nos permite executar apenas uma parte da instrução, parando no
meio. Neste momento, o estado da máquina C abstrata não está bem definido. Assim que terminamos de executar instruções que
implementam uma única instrução, os estados das máquinas “sincronizam”, permitindo-nos explorar não apenas o estado do nível
assembly, mas também o estado do próprio programa C. Este é o ponto de sequência.

A segunda definição equivalente de ponto de sequência é o local no programa onde os efeitos colaterais do
as expressões anteriores já foram aplicadas, mas os efeitos colaterais das seguintes ainda não foram aplicados.
Os pontos de sequência são

•Ponto e vírgula.

•Vírgula (que em C pode funcionar da mesma forma que um ponto e vírgula, mas também agrupa instruções.
Seu uso é desencorajado.).

•Lógica E/OU (não versões bit a bit!).

•Quando os argumentos da função são avaliados, mas a função não iniciou seu
execução ainda.

•Ponto de interrogação no operador ternário.

Vários casos do mundo real de comportamento indefinido estão vinculados à noção de pontos de sequência. Listagem 12-10
mostra um exemplo.

Listagem 12-10. seq_points.c

int eu = 0;
eu = eu++ *
10;

A que sou igual? Infelizmente, a melhor resposta que podemos dar é a seguinte: existe um comportamento indefinido neste
código. Aparentemente, não sabemos se i será incrementado antes de atribuir i*10
para eu ou depois disso. Existem duas escritas no mesmo local de memória antes do ponto de sequência e não está definido em
que ordem elas ocorrerão.
A causa disso é, como vimos na seção 12.3.2, a ordem de avaliação da subexpressão não é fixa.
Como as subexpressões podem ter efeitos no estado da memória (pense em chamadas de função ou operadores pré ou pós-
incremento), e não há ordem imposta na qual esses efeitos ocorrem, até mesmo o resultado de uma subexpressão pode depender dos
efeitos da outra.

234
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

12.4 Pragmática
12.4.1 Alinhamento
Do ponto de vista da máquina abstrata, estamos trabalhando com bytes de memória. Cada byte tem seu endereço. Os
protocolos de hardware usados no chip são, entretanto, bem diferentes. É bastante comum que o processador só possa ler pacotes
de, digamos, 16 bytes, que começam em um endereço divisível por 16. Em outras palavras, ele pode ler o primeiro pedaço de 16
bytes da memória ou o segundo, mas não um pedaço que começa em um endereço arbitrário.

Dizemos que os dados estão alinhados no limite de N bytes se começarem a partir de um endereço divisível por N.
Aparentemente, se os dados estiverem alinhados no limite de kn bytes, eles serão automaticamente alinhados no limite de n bytes.
Por exemplo, se a variável estiver alinhada no limite de 16 bytes, ela será simultaneamente alinhada no limite de 8 bytes.

Dados alinhados (limite de 8 bytes):


0x00 00 00 00 00 00 00 00: 11 22 33 44 55 66 77 88

Dados não alinhados (limite de 8 bytes):


0x00 00 00 00 00 00 00 00: .. .. .. 11 22 33 44 55
0x00 00 00 00 00 00 00 07: 66 77 88 .. .. .. .. ..

O que acontece quando o programador solicita a leitura de um valor multibyte que abrange dois desses blocos (por exemplo,
um valor de 8 bytes, cujos primeiros três bytes estão em um bloco e o restante está em outro)? Arquiteturas diferentes dão respostas
diferentes a esta pergunta.
Algumas arquiteturas de hardware proíbem o acesso desalinhado à memória. Isso significa que uma tentativa de ler qualquer
valor que não esteja alinhado, por exemplo, com um limite de 8 bytes resulta em uma interrupção. Um exemplo dessa arquitetura
é SPARC. Os sistemas operacionais podem emular acessos desalinhados interceptando a interrupção gerada e colocando a lógica
de acesso complexa no manipulador. Tais operações, como você pode imaginar, são extremamente caras porque o tratamento de
interrupções é relativamente lento.
Intel 64 adapta um comportamento menos rígido. Os acessos não alinhados são permitidos, mas acarretam uma sobrecarga. Para
Por exemplo, se quisermos ler 8 bytes a partir do endereço 6 e só pudermos ler pedaços com 8 bytes de comprimento, a
CPU (unidade central de processamento) realizará duas leituras em vez de uma e então comporá o valor solicitado a partir das
partes de duas palavras quádruplas.
Assim, os acessos alinhados são mais baratos, pois exigem menos leituras. O consumo de memória é frequentemente
uma preocupação menor para um programador do que o desempenho; assim, os compiladores ajustam automaticamente o alinhamento
das variáveis na memória, mesmo que isso crie lacunas de bytes não utilizados. Isso é comumente chamado de preenchimento de
estrutura de dados.
O alinhamento é um parâmetro da geração de código e execução do programa, por isso geralmente é visto como um
parte da pragmática da linguagem.

12.4.2 Preenchimento da Estrutura de Dados


Para estruturas, o alinhamento existe em dois sentidos diferentes:

•O alinhamento da própria instância da estrutura. Afeta o endereço em que a estrutura começa.

•O alinhamento dos campos da estrutura. O compilador pode introduzir intencionalmente lacunas entre
os campos da estrutura para tornar o acesso a eles mais rápido. Estrutura de dados
o preenchimento está relacionado a isso.

235
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Por exemplo, criamos uma estrutura mostrada na Listagem 12-11.

Listagem 12-11. alinhar_str_ex1

estrutura mystr {
uint16_ta;
uint64_t b;
};

Supondo um alinhamento em um limite de 8 bytes, o tamanho dessa estrutura, retornado por sizeof, será de 16 bytes.
O campo a começa em um endereço divisível por 8 e, em seguida, seis bytes são desperdiçados para alinhar b em um
limite de 8 bytes.
Existem vários casos em que devemos estar cientes disso:

•Você pode querer alterar a compensação entre consumo de memória e


desempenho com menor consumo de memória. Imagine que você está criando um milhão
de cópias de estruturas e cada estrutura desperdiça 30% do seu tamanho devido a lacunas de
alinhamento. Forçar o compilador a diminuir essas lacunas levará a um ganho de uso de
memória de 30%, o que não é nada desprezível. Também traz benefícios de uma melhor
localização que pode ser muito mais benéfica do que o alinhamento de campos individuais.

•A leitura de cabeçalhos de arquivos ou a aceitação de dados de rede em estruturas deve levar em


consideração possíveis lacunas entre os campos da estrutura. Por exemplo, o cabeçalho do arquivo
contém um campo de 2 bytes e depois um campo de 8 bytes. Não há lacunas entre eles. Agora
estamos tentando ler esse cabeçalho em uma estrutura, conforme mostrado na Listagem 12-12.

Listagem 12-12. alinhar_str_read.c

estrutura str {
uint16_ta; /* um intervalo de 4 bytes */
uint64_tb;
};
struct str mystr;
fread(&mystr, sizeof(str), 1, f);

O problema é que o layout da estrutura possui lacunas dentro dela, enquanto o arquivo armazena campos em uma área contígua.
caminho. Supondo que os valores no arquivo sejam a=0x1111 e b=0x 22 22 22 22 22 22 22, a Figura 12-4 mostra o
estado da memória após a leitura.

Figura 12-4. Estrutura de layout de memória e dados lidos do arquivo

Existem maneiras de controlar o alinhamento; até C11 eles são específicos do compilador. Vamos estudá-los primeiro.
A palavra-chave #pragma nos permite emitir um dos comandos pragmáticos para o compilador. É suportado
no MSVC, compilador C da Microsoft, e também é entendido pelo GCC por motivos de compatibilidade.

236
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

A Listagem 12-13 mostra como usá-lo para alterar localmente a estratégia de escolha de alinhamento usando o pacote
pragma.

Listagem 12-13. pragma_pack.c

#pacotepragma(push, 2)
estrutura mystr {
curto a;
longo b;
};
#pragma pack(pop)

O segundo argumento do pacote é o tamanho presumido do pedaço que a máquina é capaz de ler.
memória no nível do hardware.
O primeiro argumento do pack é push ou pop. Durante o processo de tradução, o compilador acompanha o valor
de preenchimento atual verificando o topo da pilha interna especial. Podemos substituir temporariamente o valor de
preenchimento atual, inserindo um novo valor nesta nova pilha e restaurando o valor antigo quando terminarmos.
Alterar o valor do preenchimento globalmente é possível usando a seguinte forma deste pragma:

#pacotepragma(2)

No entanto, é muito perigoso porque leva a mudanças sutis e imprevisíveis em outras partes do mundo.
programa, que são muito difíceis de rastrear.
Vamos ver como o valor do alinhamento afeta o alinhamento do campo individual analisando um exemplo
mostrado na Listagem 12-14.

Listagem 12-14. pacote_2.c

#pacotepragma(push, 2)
estrutura mystr {
uint16_ta;
int64_t b;
};
#pragma pack(pop)

O valor de preenchimento nos informa quantos bytes um computador de destino hipotético pode buscar da memória
em uma leitura. O compilador tenta minimizar a quantidade de leituras para cada campo. Não há razão para pular bytes
entre a e b aqui, pois isso não traz nenhum benefício em relação ao valor do preenchimento. Supondo que a=0x11 11 e
b=0x22 22 22 22 22 22 22 22, o layout da memória será semelhante ao seguinte:

11 11 22 22 22 22 22 22 22 22

A Listagem 12-15 mostra outro exemplo com valor de preenchimento igual a 4.

Listagem 12-15. pacote_4.c

#pacote pragma(push, 4)
estrutura mystr {
uint16_ta;
int64_t b;
};
#pragma pack(pop)

237
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

E se adaptarmos o mesmo layout de memória sem lacunas? Como só podemos ler 4 bytes por vez, não é
ótimo. Delimitamos os limites dos pedaços de memória que podem ser lidos atomicamente.

Pacote: 2
11 11 | 22 22 | 22 22 | 22 22 | 22 22 | ?? ??
Pacote: 4, mesmo layout de memória
11 11 22 22 | 22 22 22 22 | 22 22 Pacote: 4, layout de ?? ??
memória realmente usado
11 11 ?? ?? | 22 22 22 22 | 22 22 22 22

Como podemos ver, quando o preenchimento é definido como 4, a adaptação de um layout de memória sem intervalos força a CPU a executar
três leituras para acessar b. Então, basicamente, a ideia é minimizar a quantidade de leituras enquanto coloca os membros
da estrutura o mais próximo possível.
A maneira específica do GCC de fazer aproximadamente a mesma coisa é a especificação compactada do __attribute__
directiva. Em geral, __attribute__ descreve a especificação adicional de uma entidade de código, como um tipo ou função. Esta
palavra-chave compactada significa que os campos da estrutura são armazenados consecutivamente na memória, sem nenhuma
lacuna. A Listagem 12-16 mostra um exemplo.

Listagem 12-16. str_attribute_packed.c

Struct__attribute__((empacotado)) mystr {
uint8_t primeiro;
delta flutuante;
posição flutuante;
};

Lembre-se de que as estruturas compactadas não fazem parte da linguagem e não são suportadas em algumas
arquiteturas (como SPARC), mesmo no nível de hardware, o que significa não apenas um impacto no desempenho, mas também
travamentos do programa ou leitura de valores inválidos.

12.5 Alinhamento em C11


C11 introduziu uma forma padronizada de controle de alinhamento. Isso consiste de

•Duas palavras-chave:

– _Alinhas

– _Alinhar

• arquivo de cabeçalho stdalign.h, que define aliases de pré-processador para _Alignas e _Alignof
como alinhamentos e alinhamento

•funçãoaligned_alloc.

O alinhamento só é possível às potências de 2: 1, 2, 4, 8, etc.


alignof é usado para saber o alinhamento de uma determinada variável ou tipo. É calculado em tempo de compilação, assim
como sizeof. A Listagem 12-17 mostra um exemplo de seu uso. Observe o especificador de formato "%zu" usado para imprimir ou
digitalizar valores do tipo size_t.

238
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

Listagem 12-17. alinharof_ex.c

#include <stdio.h>
#include <stdalign.h>

int principal(void) {
x curto;
printf("%zu\n",alignof(x));
retornar 0;
}

Na verdade, alignof(x) retorna a maior potência de dois x está alinhado em, já que alinhar qualquer coisa em, por
por exemplo, 8 implica alinhamento em 4, 2 e 1 também (todos os seus divisores).
Prefira usar alignof a _Alignof e alignas a _Alignas.
alinhados aceita uma expressão constante e é usado para forçar um alinhamento em uma determinada variável ou array.
A Listagem 12-18 mostra um exemplo. Uma vez lançado, ele gera 8.

Listagem 12-18. alinharas_ex.c

#include <stdio.h>
#include <stdalign.h>

int principal(void) {
alinharas( 8 ) x curto;
printf("%zu\n",alignof( x ) );
retornar 0;
}

Combinando alignof e alignas podemos alinhar variáveis no mesmo limite que outras variáveis.
Você não pode alinhar variáveis com um valor menor que seu tamanho e alignas não pode ser usado para produzir o
mesmo efeito que __attribute__((packed)).

12.6 Resumo
Neste capítulo estruturamos e ampliamos nosso conhecimento sobre o que é linguagem de programação.
Vimos os fundamentos da escrita de analisadores e estudamos as noções de comportamento indefinido e não especificado
e por que elas são importantes. Introduzimos então a noção de pragmática e elaboramos uma das coisas mais importantes

Adiamos uma tarefa deste capítulo para o próximo, onde elaboraremos os aspectos mais importantes
boas práticas de código. Supondo que nossos leitores ainda não estejam muito familiarizados com C, queremos que eles
adaptem bons hábitos o mais cedo possível ao longo de sua jornada C.

ÿ Pergunta 241 Qual é a sintaxe da linguagem?

ÿ Pergunta 242 Para que servem as gramáticas?

ÿ Pergunta 243 Em que consiste uma gramática?

ÿ Pergunta 244 O que é BNF?

ÿ Pergunta 245 Como escrevemos um analisador descendente recursivo tendo a descrição gramatical em BNF?

239
Machine Translated by Google

Capítulo 12 ÿ Sintaxe, Semântica e Pragmática

ÿ Pergunta 246 Como incorporamos prioridades na descrição gramatical?

ÿ Pergunta 247 Quais são os níveis da hierarquia de Chomsky?

ÿ Pergunta 248 Por que as linguagens regulares são menos expressivas do que as gramáticas livres de contexto?

ÿ Pergunta 249 O que é a análise lexical?

ÿ Pergunta 250 Qual é a semântica da linguagem?

ÿ Pergunta 251 O que é comportamento indefinido?

ÿ Pergunta 252 O que é comportamento não especificado e como ele difere do comportamento indefinido?

ÿ Pergunta 253 Quais são os casos de comportamento indefinido em C?

ÿ Pergunta 254 Quais são os casos de comportamento não especificado em C?

ÿ Pergunta 255 O que são pontos de sequência?

ÿ Pergunta 256 O que é pragmática?

ÿ Pergunta 257 O que é preenchimento de estrutura de dados? É portátil?

ÿ Pergunta 258 Qual é o alinhamento? Como isso pode ser controlado no C11?

240
Machine Translated by Google

CAPÍTULO 13

Boas práticas de código

Neste capítulo queremos nos concentrar no estilo de codificação. Ao escrever código, um desenvolvedor se depara constantemente
com um procedimento de tomada de decisão. Que tipos de estruturas de dados ele deve usar? Como eles deveriam ser nomeados?
Onde e quando devem ser alocados? Programadores experientes tomam essas decisões de uma maneira diferente dos iniciantes, e
achamos extremamente importante falar sobre esse processo de tomada de decisão.

13.1 Fazendo escolhas


As decisões muitas vezes exigem um equilíbrio entre dois pólos que são mutuamente exclusivos. O exemplo clássico é que você não
pode enviar um produto de qualidade de maneira barata e rápida. O ajuste fino do desempenho do código geralmente dificulta a leitura
e a depuração. Portanto, algumas características do código devem ser priorizadas em detrimento de outras com base no bom senso e na
tarefa em si. Por causa disso, essas diretrizes de código são um bom começo, mas segui-las cegamente não é o caminho a seguir.

Nossos conselhos para escrever código são baseados nas seguintes premissas:

1. Queremos que o código seja o mais reutilizável possível. Isso geralmente requer planejamento e coordenação
cuidadosos entre os desenvolvedores, o que não permite escrever código muito rápido
mas compensa muito em breve porque poupa tempo para depuração e permite que você escreva software
complexo. Depurar programas geralmente é considerado mais difícil do que escrevê-los. Portanto,
menos código geralmente significa menos tempo gasto na depuração e funções mais robustas. É
especialmente importante para linguagens como C, que são

• Inseguro em um sentido amplo (permite aritmética de ponteiro, não executa limite


cheques, etc.)

• Falta um sistema de tipos expressivos, visto em linguagens como Scala, Haskell ou OCaml.
Tais tipos impõem uma série de restrições ao programa que devem ser satisfeitas, caso contrário o
compilador irá rejeitá-lo.

Esta regra tem uma exceção notável. Se a reutilização de funções resultar em uma diminuição drástica de desempenho, o
algoritmo terá uma grande complexidade O desnecessária. Por exemplo, fizemos uma tarefa com listas vinculadas no Capítulo 10.
Havia uma função para calcular a soma de todos os números inteiros em uma determinada lista. Uma maneira de criá-lo é mostrada
aproximadamente na Listagem 13-1.

© Igor Zhirkov 2017 241


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_13
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

Listagem 13-1. list_sum_bad.c

int list_sum( const struct lista* l ) {


tamanho_t eu;
soma interna = 0;
/* Não queremos iniciar o cálculo completo
*de tamanho em cada iteração do ciclo */
tamanho_t sz = tamanho_lista(l);
for(i = 0; i <sz; l = l-> próximo)
soma = soma + l->valor;
soma de retorno;
}

Neste exemplo, para cada i no intervalo de 0 inclusive ao comprimento da lista exclusivo, na verdade começamos a
percorrer a lista desde seu primeiro elemento. Isso resulta em uma diminuição drástica no desempenho em comparação com a
soma única passando pela lista. No último caso, anexar outro elemento à lista resulta em um acesso adicional à lista, enquanto no
programa mostrado na Listagem 13-1 isso leva a acessos adicionais à lista list_length(l)!

2. O programa deve ser fácil de modificar. Este ponto é interdependente do anterior. Funções
menores costumam ser mais reutilizáveis e, portanto, as modificações tornam-se mais fáceis,
porque mais código da versão anterior pode permanecer intacto.

3. O código deve ser o mais fácil de ler possível. Os principais fatores aqui são

• Nomenclatura sensata. Mesmo que você não seja um falante nativo de inglês, não deve escrever
nomes de variáveis, nomes de funções ou comentários em seu idioma nativo.

• Consistência. Use as mesmas convenções de nomenclatura e formas uniformes de realizar operações


semelhantes.

• Funções curtas e concisas. Se a descrição lógica for excessivamente detalhada, geralmente é um sinal
de falta de decomposição sensata ou de que você precisa de uma camada de abstração. Também
tem um bom efeito na manutenção.

4. O código deve ser fácil de testar. Os testes nos garantem que, pelo menos em alguns casos elaborados,
o código se comporta conforme pretendido.

Às vezes a tarefa exige o oposto. Por exemplo, se estivermos escrevendo o código para um controlador na ausência
de um bom compilador otimizador e com recursos muito restritos, podemos ser forçados a abandonar a bela estrutura do
código porque o compilador não pode funcionar corretamente; assim, cada chamada terá impacto no desempenho, muitas
vezes de forma inaceitável.

13.2 Elementos de Código

13.2.1 Nomenclatura Geral


A convenção de nomenclatura específica é frequentemente imposta pela própria linguagem. Nos casos em que o projeto é
baseado em uma base de código existente, pode ser razoável não se desviar dela por uma questão de consistência.
Neste livro, estamos usando as seguintes convenções de nomenclatura:

• Todos os nomes são escritos em letras minúsculas.

• As partes do nome são separadas por um sublinhado, como segue: list_count.

242
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

O restante desta seção concentra-se em diferentes recursos de linguagem e nomenclatura e uso associados.
convenções.

13.2.2 Estrutura do arquivo


Os arquivos de inclusão devem ter um protetor de inclusão.
Eles devem ser independentes, o que significa que para cada arquivo de cabeçalho este arquivo.ha .c com apenas o
a linha #include "thisfile.h" deve ser compilada. A ordem das inclusões geralmente é escolhida da seguinte forma:

• Cabeçalho relacionado.

• Biblioteca C.

• Outras bibliotecas.h.

• O .h. do seu projeto.

Em seguida, siga uma ordem consistente de declaração de macros, tipos, funções, variáveis, etc.
simplifica a navegação no projeto. Uma ordem típica é

• para cabeçalhos:

1. Incluir arquivos.

2. Macros.

3. Tipos.

4. Variáveis (globais).

5. Funções.

• para arquivos .c

1. Incluir arquivos.

2. Macros.

3. Tipos.

4. Variáveis (globais).

5. Variáveis estáticas.

6. Funções.

7. Funções estáticas.

13.2.3 Tipos
• Quando possível (C99 ou mais recente), prefira os tipos definidos em stdint.h, como uint64_t
ou uint8_t.

• Se você deseja ser compatível com POSIX, não defina seus próprios tipos com o sufixo _t. É reservado para
tipos padrão, portanto, os novos tipos que podem ser introduzidos em futuras revisões do padrão não
entrarão em conflito com os tipos personalizados definidos em alguns programas.

• Os tipos geralmente são nomeados com um prefixo comum ao projeto. Por exemplo, se você quiser escrever uma
calculadora, as tags de tipo serão prefixadas com calc_.

243
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

• Ao definir estruturas e se puder escolher a ordem dos campos, defina


eles na seguinte ordem:

– Primeiro tente minimizar as perdas de memória causadas pelo preenchimento da estrutura de dados.

– Em seguida, ordene os campos por tamanho.

– Por fim, classifique-os em ordem alfabética.

– Às vezes, as estruturas possuem campos que não devem ser modificados diretamente pelo usuário.
Por exemplo, uma biblioteca define a estrutura mostrada na Listagem 13-2.

Listagem 13-2. struct_private_ex.c

estruturar meu par {


interno x;
interno;
int _refcount;
};

Os campos dessa estrutura podem ser modificados diretamente usando a sintaxe de ponto ou seta.
Nossa convenção, entretanto, implica que apenas funções específicas da biblioteca devem
modificar o campo _refcount, e o usuário da biblioteca nunca deve fazer isso manualmente.

C não possui um conceito de estrutura de campos privados, então é o mais próximo que
podemos chegar sem usar hacks mais ou menos sujos.

– Os membros da enumeração devem ser escritos em letras maiúsculas, como constantes. O


prefixo comum é sugerido para os membros de uma enumeração. Um exemplo é mostrado na Listagem
13-3.

Listagem 13-3. enum_ex.c

Enum exit_code {
EX_SUCESSO,
EX_FAILURE,
EX_INVALID_ARGUMENTOS
};

13.2.4 Variáveis
Escolher os nomes corretos para variáveis e funções é crucial.

• Use substantivos para nomes.

• Variáveis booleanas também devem ter nomes significativos. Prefixando-os com is_ is
aconselhável. Em seguida, anexe a propriedade exata que está sendo verificada. is_good é provavelmente
muito amplo para ser um bom nome na maioria dos casos, ao contrário de is_prime ou is_before_last.

Prefira nomes positivos aos negativos, pois o cérebro humano os analisa facilmente.
por exemplo, is_even over is_not_odd.

• Não é aconselhável usar nomes sem significado, como a, b ou x4. A exceção notável é o código que ilustra
um artigo ou artigo, que descreve um algoritmo em pseudocódigo usando tais nomes. Nesse caso,
é mais provável que qualquer mudança de nomenclatura confunda os leitores do que traga mais clareza. Os
índices são tradicionalmente chamados de i e j e você será compreendido se os seguir.

244
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

• Incluir as unidades de medida pode ser uma boa ideia – por exemplo,
uint32_t atraso_msecs.

• Outros sufixos também são úteis, como cnt, max, etc.

Por exemplo, tentativas_max (máximo de tentativas permitidas), tentativas_cnt


(tentativas feitas).

• As constantes globais são nomeadas em letras maiúsculas. Variáveis mutáveis globais são prefixadas
com g_.

• A tradição diz que as constantes globais devem ser definidas usando a diretiva #define.
No entanto, a abordagem moderna é usar variáveis const estáticas ou apenas const globais.
Ao contrário de #defines, eles são digitados e também melhor visualizados durante a depuração. Se você tiver
acesso a um compilador de qualidade, ele os incorporará de qualquer maneira (se decidir que será mais rápido).

• Use o modificador const sempre que apropriado. C99 permite criar variáveis em
locais arbitrários dentro de funções, não apenas no início do bloco. Use-o para armazenar resultados
intermediários em constantes nomeadas.

• Não defina variáveis globais em arquivos de cabeçalho! Defina-os em arquivos .c e declare-os em arquivo .h
como externos.

13.2.5 Sobre Variáveis Globais


Não use variáveis mutáveis globais, se puder. Não podemos enfatizar isso o suficiente. Aqui estão os problemas mais importantes que
eles trazem:

• Em projetos de média escala e mais em grandes projetos com um número enorme de linhas, todos
as informações sobre os efeitos da função são melhor localizadas em sua assinatura. Uma função f pode
chamar outra função g, e assim por diante, e em algum lugar desta cadeia uma variável global será
alterada. Não podemos ver que esta mudança possa ocorrer olhando para f; temos que estudar todas as
funções que ele chama, e as funções que eles chamam, e assim por diante.

• Eles criam funções que não podem ser acessadas novamente. Isso significa que uma função f não
pode ser chamada se já estiver sendo executada. Este último pode acontecer em dois casos:

– A função f está chamando outras funções, que após algumas chamadas internas poderão chamar f novamente, quando a
primeira instância de f ainda não tiver sido finalizada.

A Listagem 13-4 mostra um exemplo de função f que não pode ser acessada novamente.

Listagem 13-4. reentradabilidade.c

sinalizador bool = verdadeiro;


int var = 0;
vazio g(vazio) {
f();
bandeira = falso;
}
vazio f(vazio) {
if (sinalizador) g();
}

– O programa é paralelizado e a função está sendo usada em vários threads (o que costuma acontecer
em computadores modernos).

245
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

No caso de uma hierarquia de chamadas complexa, saber se a função pode ser acessada novamente ou não requer uma análise
adicional.

• Introduzem riscos de segurança, porque normalmente os seus valores devem ser verificados antes de serem
modificados ou utilizados. Os programadores tendem a esquecer essas verificações. Se algo pode dar errado, dará
errado.

• Eles dificultam a função de teste devido à dependência de dados que estão introduzindo.
Escrever código sem testes, entretanto, é sempre uma prática a ser evitada.

Variáveis mutáveis estáticas globais também são más, mas pelo menos não poluem o namespace global em outros arquivos.
Variáveis imutáveis estáticas globais (const static) são, no entanto, perfeitamente adequadas e muitas vezes podem ser incorporadas pelo
compilador.

13.2.6 Funções
• Use verbos para nomear funções — por exemplo, packet_checksum_calc.

• O prefixo is_ também é bastante comum para condições de verificação de funções — por exemplo,
int is_prime (num longo).

• As funções que operam em uma estrutura com uma determinada tag geralmente são prefixadas com o nome
da respectiva tag — por exemplo, bool list_is_empty(struct list* lst );.

Como C não permite um controle preciso do namespace, esta parece ser a forma mais simples de controlar o caos que surge quando
a maioria das funções está acessível de qualquer lugar.

• Use o modificador estático para todas as funções, exceto aquelas que você deseja que estejam disponíveis para todos.

• Provavelmente o lugar mais importante para usar const é para argumentos de função do tipo “ponteiro para
dados imutáveis”. Ele garante que a função não os altere ocasionalmente devido a um erro do programador.

13.3 Arquivos e Documentação


À medida que o projeto cresce, o número de arquivos aumenta e fica mais difícil navegar por eles. Para poder lidar com projetos volumosos, é
necessário estruturá-los desde o início.
A seguir está um modelo comum para o diretório raiz do projeto.

fonte/ Arquivos Fonte

documento/ Documentação

res/ Arquivos de recursos (como imagens).

biblioteca/ Bibliotecas estáticas que serão vinculadas ao arquivo executável.

construir/ Os artefatos: um arquivo executável e outros arquivos gerados.

incluir/ Incluir arquivos. Este diretório é adicionado ao compilador e inclui o caminho de pesquisa pelo sinalizador -I.

obj/ Arquivos de objeto gerados. Eles são montados nos arquivos executáveis e bibliotecas pelo vinculador e não são
necessários após o término da compilação.

configurar O script de configuração inicial que deve ser iniciado antes da construção. Ele pode configurar diferentes arquiteturas
de destino ou ativar e desativar recursos.

Makefile Contém instruções para o sistema de compilação automatizado.


O nome e o formato do arquivo variam dependendo do sistema usado.

246
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

Existem muitos sistemas de construção; alguns dos mais populares para C são make, cmake e automake.
Linguagens diferentes têm ecossistemas diferentes e muitas vezes possuem ferramentas de construção dedicadas (por exemplo,
Gradle ou OCamlBuild).

• Recomendamos que você estude esses projetos, que, até onde sabemos, estão bem organizados
www.gnu.org/software/gsl/
• www.gnu.org/software/gsl/design/gsl-design.html
• www.kylheku.com/kaz/kazlib.html
Doxygen é um padrão de fato para criação de documentação para programas C e C++. Permite-nos gerar um
conjunto totalmente estruturado de páginas HTML ou LATEX a partir do código fonte do programa. As descrições de
funções e variáveis são retiradas de comentários formatados especificamente. A Listagem 13-5 mostra um exemplo de
arquivo fonte que é aceito pelo Doxygen.

Listagem 13-5. doxygen_example.h

#pragma uma vez


#include <common.h>
#include <vm.h>

/** @defgroup const_pool Conjunto constante */

/** Libera memória alocada para o conteúdo do pool


*/
void const_pool_deinit(estrutura vm_const_pool* pool);

/** Combinação de pool constante não destrutiva


*
@param a Primeira piscina.
*
@param b Segunda piscina.
*
@returns Um conjunto de constantes inicializado combinando o conteúdo de ambos os argumentos
* */
estrutura vm_const_pool const_combine(
estrutura vm_const_pool const* a,
estrutura vm_const_pool const* b);

/** Altere o pool constante adicionando o conteúdo do outro pool em seu final.
* @param[out] src O pool de origem que será modificado.
*
@param fresco O pool a ser mesclado com o pool `src`.
*/
void const_merge(
estrutura vm_const_pool* src,
estrutura vm_const_pool const* fresco);

/**@} */

Os comentários especialmente formatados (começando com /** e contendo comandos como @defgroup) são
processados pelo Doxygen para gerar documentação para as respectivas entidades de código. Para obter mais informações,
consulte a documentação do Doxygen.

247
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

13.4 Encapsulamento
Um dos fundamentos do pensamento é a abstração. Na engenharia de software, é um processo de ocultar detalhes e dados de
implementação.
Se quisermos implementar um determinado comportamento como a rotação de uma imagem, gostaríamos de pensar apenas na
rotação da imagem. O formato do arquivo de entrada, o formato de seus cabeçalhos, tem pouca importância para nós. O que é realmente
importante é saber trabalhar com os pontos que formam a imagem e conhecer as suas dimensões. No entanto, você não pode escrever
um programa sem considerar todas essas informações que são, na verdade, independentes do próprio algoritmo de rotação.

Vamos dividir o programa em partes; cada parte cumprirá seu propósito e somente ele. Esta lógica pode ser usada chamando um
conjunto de funções expostas e/ou um conjunto de variáveis globais expostas. Juntos eles formam uma interface para esta parte do
programa. Para implementá-las, entretanto, normalmente temos que escrever mais funções, que ficam melhor escondidas do usuário final.

ÿ Trabalhando com sistemas de controle de versão Ao trabalhar em uma equipe onde muitas pessoas realizam
alterações simultaneamente, é muito importante criar funções menores. Se uma função executa muitas ações e seu
código é enorme, será mais difícil mesclar várias alterações independentes automaticamente.

Em linguagens de programação que suportam pacotes ou classes, estes são usados para ocultar pedaços de código e criar interfaces
para eles. Infelizmente, C não possui nenhum deles; além disso, não existe o conceito de “campos privados” nas estruturas: todos os
campos são vistos por todos.
O suporte para arquivos de código separados, chamados de unidades de tradução, é o único recurso de linguagem real que nos ajuda
isolar partes do código do programa. Usamos a noção de módulo como sinônimo de unidade de tradução, um arquivo .c.
O padrão C não define uma noção de módulo. Neste livro nós os usaremos de forma intercambiável porque para a linguagem C
eles são aproximadamente equivalentes.
Como sabemos, funções e variáveis globais tornam-se símbolos públicos por padrão e, portanto, acessíveis a outros arquivos. O
que é razoável é marcar todas as funções “privadas” e variáveis globais como estáticas no arquivo .c e declarar todas as funções “públicas”
no arquivo .h.
Como exemplo, vamos escrever um módulo que implementa uma pilha.
O arquivo de cabeçalho descreverá a estrutura e as funções que podem operar suas instâncias. Parece
programação orientada a objetos sem subtipagem (sem herança).
A interface consistirá nas seguintes funções:

• Criar ou destruir uma pilha;

• Empurrar e retirar elementos de uma pilha.

• Verifique se a pilha está vazia.

• Inicie uma função para cada elemento da pilha.

O arquivo de código definirá todas as funções e provavelmente mais algumas, que não estarão acessíveis fora dele
e são criados apenas para fins de decomposição e reutilização de código.
As listagens 13-6 e 13-7 mostram o código resultante. stack.h descreve uma interface. Tem um guarda incluído,
enumera todos os outros cabeçalhos (primeiro os cabeçalhos padrão, depois os personalizados) e define os tipos personalizados.

248
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

Listagem 13-6. pilha.h

#ifndef _STACK_H_
#define _STACK_H_

#include <stddef.h>
#include <stdint.h> #include
<stdbool.h>

lista de estruturas;

struct stack { struct


lista* primeiro; lista de
estruturas* último;
contagem de tamanho_t;
};

pilha de estrutura stack_init (void); void


stack_deinit(estrutura pilha* st);

void stack_push(estrutura pilha* s, int valor); int stack_pop


(estrutura pilha*s); bool stack_is_empty(estrutura
pilha const* s);

void stack_foreach( struct stack* s, void (f)(int) );

#endif /* _STACK_H_ */

Existem dois tipos definidos: lista e pilha. O primeiro é usado apenas internamente dentro da pilha, por isso o
declaramos um tipo incompleto. Somente ponteiros para instâncias desse tipo são permitidos, a menos que sua
definição seja especificada posteriormente.
Para todos que incluem stack.h, a lista de estruturas de tipo permanecerá incompleta. A implementação
o arquivo stack.c, porém, definirá a estrutura, completando o tipo e permitindo o acesso aos seus campos (mas
apenas no stack.c).
Em seguida, a pilha struct é definida e as funções que funcionam com ela são declaradas (stack_push,
stack_pop, etc.) (veja Listagem 13-7).

Listagem 13-7. pilha.c

#include <malloc.h>
#include "stack.h"

lista de estrutura { int valor; lista de estruturas* próximo; };

lista de estrutura estática* list_new(int item, lista de estrutura* next) { lista de


estrutura* lst = malloc(sizeof( *lst)); lst->valor = item; lst-
>próximo = próximo;
retornar lst;

249
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

void stack_push( struct stack* s, int valor ) { s->first =


list_new( valor, s->first ); if ( s->último == NULL )
s->último = s-> primeiro; s->contar++;

int stack_pop(estrutura pilha*s) {


lista de estrutura* const head = s->first; valor
interno; if
(head) { if (head-
>next) s->first = head->next; valor = cabeça->valor;
livre(cabeça); if( -- s-
>contar ) { s-
>primeiro = s->último =
NULL;

} valor de retorno;

} retornar 0;
}

void stack_foreach( struct stack* s, void (f)(int) ) { struct list* cur;


for( cur = s->primeiro;
cur; cur = cur-> próximo ) f( cur->valor );

bool stack_is_empty(estrutura pilha const* s) {


retornar s->contagem == 0;
}

pilha de estrutura stack_init (void) {pilha


de estrutura vazia = {NULL, NULL, 0}; retornar
vazio;
}

void stack_deinit (estrutura pilha * st) { while (!


stack_is_empty (st)) stack_pop (st); st-> primeiro = NULO; st-
> último = NULO;

Este arquivo define todas as funções declaradas no cabeçalho. Ele pode ser dividido em vários arquivos .c,
o que às vezes será bom para a estrutura do projeto; o importante é que o compilador aceite todos eles e então o
código compilado chegue ao vinculador.
Uma função estática list_new é definida para isolar a inicialização da instância da lista struct. Não está
exposto ao mundo exterior. Durante as otimizações, o compilador não apenas pode incorporá-la, mas também
excluir a própria função, eliminando efetivamente quaisquer implicações possíveis no desempenho do código.
Marcar a função como estática é necessário (mas não suficiente) para que essa otimização ocorra. Além disso, as instruções
das funções estáticas podem ser colocadas mais próximas dos seus respectivos chamadores, melhorando a localidade.
Ao dividir o programa em módulos com interfaces bem descritas, você reduz a complexidade geral e obtém
melhor capacidade de reutilização.
250
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

A necessidade de criar arquivos de cabeçalho torna as modificações um pouco complicadas porque a consistência dos
cabeçalhos com o próprio código é de responsabilidade do programador. No entanto, também podemos nos beneficiar especificando
uma descrição clara da interface, que não contém detalhes de implementação.

13.5 Imutabilidade
É bastante comum ter que escolher entre criar uma nova cópia modificada de uma estrutura e realizar modificações no local.

Aqui estão algumas vantagens e desvantagens de ambas as escolhas.

• Criando cópia:

– Mais fácil de escrever: você não passará acidentalmente a instância errada para uma função.

– Mais fácil de depurar, porque você não precisa rastrear alterações de variáveis.

– Pode ser otimizado pelo compilador.

– Amigável à paralelização.

– Pode ser mais lento.

• Mutação de instância existente.

- Mais rápido.

– Pode ser muito difícil depurar, especialmente em um ambiente multithread.

– Às vezes mais simples porque você não precisa copiar cuidadosa e recursivamente estruturas com vários
ponteiros para outras estruturas (esse processo é chamado de cópia profunda).

– Para objetos com uma identidade distinta, esta abordagem pode ser mais intuitiva e também é
robusto o suficiente.

Nossa percepção do mundo real é baseada em objetos mutáveis, porque os objetos do mundo real muitas vezes
têm uma identidade distinta. Quando você liga o telefone, o telefone não é substituído por sua cópia, mas o estado do mesmo telefone
é alterado. Em outras palavras, a identidade do telefone é mantida, enquanto seu estado muda. Assim, em situações em que você
possui apenas uma instância de um determinado tipo e alterações consecutivas são realizadas nela, não há problema em alterá-la em
vez de fazer uma cópia a cada alteração.

13.6 Asserções
Existe um mecanismo que permite testar certas condições durante a execução do programa. Quando tal condição não é satisfeita,
um erro é produzido e o programa é encerrado de forma anormal.
Para usar o mecanismo de asserção, temos que usar #include <assert.h> e depois usar a macro assert.
A Listagem 13-8 mostra um exemplo.

Listagem 13-8. afirmar.c

#include <assert.h>
int principal() {
interno x = 0;
afirmar(x! = 0);
retornar 0;
}

251
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

A condição dada à macro assert é obviamente falsa; portanto, o programa será encerrado de forma
anormal e nos informará sobre a falha na afirmação:

assert: assert.c:6: main: Asserção `x!= 0' falhou.

Se o símbolo do pré-processador NDEBUG estiver definido (o que pode ser conseguido usando -D NDEBUG compilador
opção ou diretiva #define NDEBUG), a afirmação é substituída por uma string vazia e, portanto, desativada.
Portanto, as asserções produzirão sobrecarga zero e as verificações não serão realizadas.
Você deve usar declarações para verificar condições impossíveis que signifiquem a inconsistência do estado do programa.
Nunca use declarações para realizar verificações na entrada do usuário.

13.7 Tratamento de Erros


Embora as linguagens de nível superior tenham algum tipo de mecanismo de tratamento de erros (que não interfere
na descrição da lógica principal), C não possui um. Existem três maneiras principais de lidar com erros:
1. Use códigos de retorno. Uma função não deve retornar um resultado como tal, mas um
código que mostre se foi bem processado ou não. Neste último caso, o código reflete
o tipo exato de erro que ocorreu. O resultado do cálculo é atribuído por um ponteiro que
é aceito como argumento adicional.

A Listagem 13-9 mostra um exemplo.

Listagem 13-9. código_erro.c

enum div_res{
DIV_OK,
DIV_BYZERO
};

enum div_res div(int x, int y, int* resultado) {


if ( y != 0 ) { *resultado = x/y; retornar DIV_OK; }
caso contrário, retorne DIV_BYZERO;
}

Simetricamente, você pode retornar valores e configurar o código de erro usando um ponteiro para uma respectiva
variável.
Os códigos de erro podem ser descritos usando um enum ou vários #defines. Então você pode usá-los como índices
em uma matriz estática de mensagens ou use uma instrução switch. A Listagem 13-10 mostra um exemplo.

Listagem 13-10. err_switch_arr.c

enum error_code {
ERRO1,
ERRO2
};
...
enum error_code err;
...
mudar (errar) {
caso ERRO1: ... quebra;
caso ERRO2: ... quebra;

252
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

padrão: ... pausa;


}

/* alternativamente */

static const char* const mensagens[] = {


"É o primeiro erro\n",
"O segundo erro é\n"
};

fprintf(stderr, mensagens[err]);

Nunca use variáveis globais como detentores de códigos de erro (ou para retornar um valor de uma função).
De acordo com o padrão C, existe uma entidade padrão semelhante a uma variável errno. Deve ser um valor modificável
e não deve ser declarado explicitamente. Seu uso é semelhante a uma variável global, embora seu valor seja local de thread.
As funções da biblioteca o utilizam como um detentor de código de erro, portanto, após ver uma falha em uma função (por
exemplo, fopen retornou NULL), deve-se verificar o valor errno para um código de erro. As páginas man da respectiva
função enumeram possíveis valores errno (por exemplo, EEXIST).
Apesar desse recurso ter entrado furtivamente na biblioteca padrão, ele é amplamente considerado um antipadrão
e não deve ser imitado.

2. Usando retornos de chamada.

Retornos de chamada são ponteiros de função passados como argumentos e chamados pela função que os aceita.
Eles podem ser usados para isolar o código de tratamento de erros, mas muitas vezes parecem estranhos para pessoas que
estão mais acostumadas com o uso tradicional do código de retorno. Além disso, a ordem de execução torna-se menos óbvia.
A Listagem 13-11 mostra um exemplo.

Listagem 13-11. div_cb.c


#include <stdio.h>

int div(int x, int y, void (onerror)(int, int)) { if ( y != 0 ) return


x/y; senão
{ onerror(x,y);
retornar
0;

}
}

vazio estático div_by_zero(int x, int y) {


fprintf(stderr, "Divisão por zero: %d / %d\n", x, y );
}

int main(void)
{ printf("%d %d\n",
div( 10, 2, div_by_zero ),
div( 10, 0, div_by_zero ) );
retornar 0;
}

253
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

3. Usando longjmp. Esta técnica avançada será explicada na seção 14.3.

Existe uma técnica clássica de recuperação de erros, que requer o uso de goto. A Listagem 13-12 mostra um exemplo.

Listagem 13-12. goto_error_recover.c

vazio foo (vazio)

{
if (!doA()) vai para sair;
if (!doB()) vai para revertA;
if (!doC()) vai para revertB;

/* doA, doB e doC bem-sucedidos */


retornar;

reverterB:
desfazerB();
reverterA:
desfazerA();
saída:
retornar;
}

Neste exemplo, três ações foram executadas e todas podem falhar. A natureza dessas ações é tal que temos que fazer
uma limpeza depois. Por exemplo, doA pode acionar a alocação dinâmica de memória. Caso doA tenha sucesso, mas doB não,
temos que liberar essa memória para evitar vazamento de memória. Isso é o que o código denominado revertA faz.

As recuperações são realizadas na ordem inversa. Se doA e doB tiveram sucesso, mas doC falhou, temos que reverter
para B e depois para A. Portanto, rotulamos os estágios de reversão com os rótulos e deixamos o controle passar por eles.
Portanto, goto revertB reverterá primeiro para doB e depois cairá no código, revertendo para doA. Esse truque geralmente pode
ser visto em um kernel Linux. No entanto, tenha cuidado, pois os gotos geralmente tornam a verificação muito mais difícil, e é por
isso que às vezes são banidos.

13.8 Sobre Alocação de Memória


• Muitos programadores desaconselham arrays flexíveis alocados em uma pilha. É uma maneira fácil de
obter um estouro de pilha se você não verificar o comprimento bem o suficiente. O que é ainda pior,
não há como saber se você pode alocar com segurança uma determinada quantidade de bytes em uma
pilha ou não.

• Não abuse do malloc! Como você verá na última tarefa deste capítulo, malloc não é nada barato. Sempre
que quiser alocar algo razoavelmente pequeno, faça-o em uma pilha, como uma variável local. Se
alguma função precisar do endereço de uma estrutura, você pode pegar o endereço de uma estrutura
alocada na pilha e passá-la para ela. Isso evita vazamentos de memória e melhora o desempenho e
a legibilidade do código.

• As variáveis globais não representam qualquer ameaça desde que sejam constantes. Variáveis locais
estáticas são as mesmas. Use-os se quiser limitar o uso de uma determinada constante por uma função.

254
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

13.9 Sobre Flexibilidade


De fato, defendemos a reutilização do código. No entanto, levar isso ao extremo resulta em uma quantidade absurda de
camadas de abstração e código clichê que só está presente para dar suporte a uma possível necessidade futura de recursos
adicionais (o que pode nunca acontecer).
Não existe solução mágica, no sentido amplo. Cada estilo de programação, cada modelo de computação,
é bom e conciso em alguns casos e volumoso e detalhado em outros. Da mesma forma, a melhor ferramenta é
especializada, e não um pau para toda obra. Você poderia transformar um visualizador de imagens em um editor poderoso,
capaz de reproduzir vídeo e editar tags IDv3, mas a faceta do visualizador de imagens certamente sofrerá, assim como a
experiência do usuário.
Escrever código mais abstrato pode trazer benefícios porque é mais fácil adaptá-lo a novos contextos. Ao mesmo tempo,
introduz complexidade que pode ser desnecessária. Generalize apenas até um ponto que não cause danos. Para saber quando
parar, você precisa responder a várias perguntas, como

• Qual é o propósito do seu programa ou biblioteca?

• Quais são os limites de funcionalidade que você imagina para o seu programa?

• Será mais fácil escrever, usar e/ou depurar esta função se ela for escrita de uma forma mais geral
caminho?

Embora as duas primeiras questões sejam muito subjetivas, a última pode receber um exemplo. Vamos
dê uma olhada no código mostrado na Listagem 13-13.

Listagem 13-13. dump_1.c

void dump( char const* nome do arquivo ) {


ARQUIVO* f = fopen(nome do arquivo, "w" );
fprintf(f, "este é o dump %d", 42 );
ffechar(f);
}

Compare-o com outra versão com a mesma lógica, dividida em duas funções, mostrada na Listagem 13-14.

Listagem 13-14. dump_2.c

void dump(ARQUIVO* f) {
fprintf(f, "este é o dump %d", 42 );
}
diversão vazia(void) {
ARQUIVO* f = fopen( "dump.txt", "w" );
despejar(f);
ffechar(f);
}

A segunda versão é preferível por dois motivos:

• A primeira versão requer um nome de arquivo, o que significa que você não pode usá-lo para gravar
em stderr ou stdout.

• A segunda versão separa a lógica de abertura de arquivos e a lógica de gravação de arquivos.


Se quiser lidar com erros que podem ocorrer nas chamadas fprintf, fopen ou fclose, você fará
isso separadamente para fopen, mantendo as funções relativamente simples. A função dump não
tratará erros de abertura de arquivo: ela não será chamada se a abertura falhar.

255
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

A Listagem 13-15 mostra um exemplo da mesma lógica com tratamento de erros. Como você vê, não há erro
manipulação para abertura e fechamento de arquivos na função dump; em vez disso, é realizado de forma divertida.

Listagem 13-15. arquivo_open_sep.c

#include <stdio.h>

enum estatística {
STAT_OK,
STAT_ERR_OPEN,
STAT_ERR_CLOSE,
STAT_ERR_WRITE
};

enum stat dump( FILE * f ) { if ( fprintf( f,


"este é o dump %d", 42 ) ) return STAT_OK; retornar STAT_ERR_WRITE;

enum estatística fun(void) {

enum stat dump_stat;


ARQUIVO *f;

f = fopen("dump.txt", "w"); se (!f) retornar


STAT_ERR_OPEN; dump_stat = dump(f);
if (dump_stat! = STAT_OK)
retornar dump_stat; if (! fclose( f ) ) retorna STAT_ERR_CLOSE;

retornar STAT_OK;
}

No caso de múltiplas gravações na função dump, a função ficará sobrecarregada e, portanto, menos legível.

13.10 Tarefa: Rotação de Imagem


Você precisa criar um programa para girar uma imagem BMP de qualquer resolução em 90 graus no sentido horário.

13.10.1 Formato de arquivo BMP


O formato BMP (BitMaP) é um formato gráfico raster, o que significa que armazena uma imagem como uma tabela de pontos coloridos
(pixels). Neste formato a cor é codificada com números de tamanho fixo (podem ser 1, 4, 8, 16 ou 24 bits).
Se 1 bit for usado por pixel, a imagem ficará em preto e branco. Se forem usados 24 bits, o número de cores diferentes possíveis é
de aproximadamente 16 milhões. Implementamos apenas a rotação de imagens de 24 bits.
O subconjunto de arquivos BMP com os quais seu programa deve ser capaz de trabalhar é descrito pela estrutura
mostrado na Listagem 13-16. Representa o cabeçalho do arquivo, seguido imediatamente pelos dados do pixel.

256
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

Listagem 13-16. bmp_struct.c


#include <stdint.h>
Estrutura __attribute__((empacotado))
bmp_header
{ uint16_t bfType;
uint32_t bfileSize;
uint32_t bfReserved;
uint32_t bOffBits;
uint32_t biSize;

uint32_t largura
dupla; uint32_t biHeight;
uint16_t biplanos;
uint16_t biBitCount;
uint32_t biCompressão;
uint32_t biSizeImage;
uint32_t biXPelsPerMeter;
uint32_t biYPelsPerMeter;
uint32_t biClrUsed;
uint32_t biClrImportante;
};

ÿ Pergunta 259 Leia as especificações do arquivo BMP para identificar quais são as responsabilidades desses campos.

O formato do arquivo depende da contagem de bits por pixel. Não há paletas de cores quando são usados 16 ou
24 bits por pixel.
Cada pixel é codificado em 24 bits ou 3 bytes, conforme mostrado na Listagem 13-17. Cada componente é um
número de 0 a 255 (um byte) que mostra a presença da cor azul, verde ou vermelha neste pixel. A cor resultante é
uma superposição dessas três cores básicas.

Listagem 13-17. pixel.c

struct pixel {unsigned


char b, g, r;
}

Cada linha de pixels é preenchida de modo que seu comprimento seja um múltiplo de 4. Por exemplo, a largura da imagem
é de 15 pixels. Corresponde a 15 × 3 = 45 bytes. Para preenchê-lo, pulamos 3 bytes (para o múltiplo mais próximo de
4, 48) antes de iniciar a nova linha de pixels. Por causa disso, o tamanho real da imagem será diferente do produto da largura,
altura e tamanho do pixel (3 bytes).

ÿ Nota Lembre-se de abrir a imagem em modo binário!

257
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

13.10.2 Arquitetura
Queremos pensar em uma arquitetura de programa que seja extensível e modular.

1. Descreva a estrutura do pixel struct pixel para não funcionar diretamente com a tabela raster (como
acontece com dados completamente sem estrutura). Isso sempre deve ser evitado.

2. Separe a representação da imagem interna do formato de entrada. A rotação é


executado no formato de imagem interno, que é então serializado de volta para BMP. Pode haver
alterações no formato BMP, talvez você queira oferecer suporte a outros formatos e não queira
acoplar firmemente o algoritmo de rotação ao BMP.

Para isso, defina uma estrutura de imagem para armazenar o array de pixels (contínuo, agora sem preenchimento) e
algumas informações que realmente devem ser mantidas. Por exemplo, não há absolutamente nenhuma necessidade de
armazenar assinatura BMP aqui, ou qualquer um dos campos de cabeçalho nunca usados. Podemos nos safar com a
largura e a altura da imagem em pixels.
Você precisará criar funções para ler uma imagem do arquivo BMP e gravá-la no arquivo BMP (provavelmente também
para gerar um cabeçalho BMP a partir da representação interna).

3. Separe a abertura do arquivo da sua leitura.

4. Unifique o tratamento de erros e trate-os exatamente em um só lugar (para este mesmo programa
é suficiente).

Para isso, defina a função from_bmp, que lerá um arquivo do stream e retornará um dos códigos que mostram se a
operação foi concluída com sucesso ou não.
Lembre-se das preocupações com flexibilidade. Seu código deve ser fácil de usar em aplicativos com interface gráfica de
usuário (GUI), bem como naqueles sem GUI, portanto, jogar impressões em stderr em todos os lugares não é uma boa opção:
restrinja-os ao trecho de código de tratamento de erros . Seu código também deve ser facilmente adaptável a diferentes
formatos de entrada.
A Listagem 13-18 mostra vários trechos do código inicial.

Listagem 13-18. imagem_rot_stub.c

#include <stdint.h>
#include <stdio.h>

estrutura pixel { uint8_t b,g,r; };

estruturar imagem {
largura uint64_t, altura;
estrutura pixel_t* dados;
};

/* desserializador */
enum read_status {
LEIA_OK = 0,
READ_INVALID_SIGNATURE,
READ_INVALID_BITS,
READ_INVALID_HEADER
/* mais códigos */
};

enum read_status from_bmp( FILE* in, struct image* const read );

258
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

/* image_t from_jpg( FILE* );... * e outros


desserializadores são possíveis * Todas as
informações necessárias serão *
armazenadas na estrutura da imagem */

/* faz uma cópia girada */ struct


image rotate( struct image const source );

/* serializador */ enum
write_status {
ESCREVER_OK = 0,

WRITE_ERROR /* mais códigos */


};

enum write_status to_bmp(FILE* out, struct image const* img);

ÿ Pergunta 260 Implementar desfoque. Isso é feito de uma forma muito simples: para cada pixel você calcula seus novos
componentes como uma média em uma janela de 3 × 3 pixels (chamada kernel). Os pixels da borda permanecem intactos.

ÿ Pergunta 261 Implemente a rotação em um ângulo arbitrário (não apenas 90 ou 180 graus).

ÿ Pergunta 262 Implementar transformações de “dilatação” e “erosão”. Eles são semelhantes ao desfoque, mas em vez de
fazer uma média em uma janela, você deve calcular os valores mínimos (erosão) ou máximos (dilatação) dos componentes.

13.11 Atribuição: Alocador de Memória Personalizado


Nesta tarefa, implementaremos nossa própria versão de malloc e free com base na chamada do sistema de mapeamento
de memória mmap e em uma lista vinculada de pedaços de tamanhos arbitrários. Ele pode ser visto como uma versão
simplificada de um gerenciador de memória típico da biblioteca C padrão e compartilha a maioria de seus pontos fracos.
Para esta atribuição é proibido o uso de malloc/calloc, free e realloc.
Como sabemos, essas funções são usadas para manipular o heap. A pilha consiste em páginas anônimas
e é na verdade uma lista vinculada de pedaços. Cada pedaço consiste em um cabeçalho e os próprios dados. O cabeçalho é
descrito por uma estrutura mostrada na Listagem 13-19.

Listagem 13-19. mem_str.c

struct mem
{ struct mem* próximo;
tamanho_t
capacidade; bool é_livre;
};

259
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

O cabeçalho é imediatamente seguido pela área utilizável.


Precisamos armazenar o tamanho e o link para o próximo bloco porque no nosso caso o heap pode ter lacunas por dois motivos.

• O heap start pode ser colocado entre duas regiões já mapeadas.

• O heap pode atingir um tamanho arbitrário.

Uma alocação em um heap é dividir o primeiro pedaço disponível em dois (dado que seu tamanho é suficiente). Marca a primeira parte
como não gratuita e retorna seu endereço. Se não houver pedaços livres grandes o suficiente para o tamanho solicitado, o alocador tenta
obter mais memória do sistema operacional chamando mmap.
Não faz sentido alocar blocos de 1 ou 3 bytes; eles são muito pequenos. Geralmente é um desperdício, já que o tamanho do
cabeçalho é superior de qualquer maneira. Portanto, vamos introduzir uma constante BLOCK_MIN_SIZE para o tamanho mínimo
permitido do bloco (sem incluir o cabeçalho).
Dada uma solicitação de bytes de consulta, primeiro alteramos para BLOCK_MIN_SIZE se for muito pequeno. Então nós iteramos
sobre a cadeia de blocos e aplique a seguinte lógica a cada bloco:

• consulta <= capacidade-sizeof(struct mem) - MINIMAL_BLOCK_SIZE

Nesse caso, podemos dividir o bloco em dois e usar a primeira parte como pedaço de memória alocada, deixando a segunda livre.

• Caso contrário, o bloco não será grande o suficiente para conter a quantidade solicitada de bytes.

– Se o bloco não for o último, passamos para o próximo bloco.

– Caso contrário, precisaremos mapear mais páginas (o suficiente para alocar bytes de consulta).

Primeiro tentamos fazer isso imediatamente após o final do bloco (flag MAP_FIXED para mmap), e se conseguirmos, ampliamos
o bloco atual para incorporar novas páginas. No final dividimos em dois e usamos o primeiro do par.
Se não pudermos mapear mais páginas imediatamente no final do heap, tentaremos mapeá-las em qualquer lugar (o suficiente para
armazenar bytes de consulta). Depois dividimos em dois e usamos o primeiro do par.
Se todos os mapeamentos falharem, retornaremos NULL, assim como malloc.
O gratuito é mais fácil de implementar. Dado o início do bloco, temos que calcular o respectivo início do cabeçalho,
que muda seu status de “alocado” para “livre”. Se for seguido imediatamente por um bloco livre, eles serão mesclados. Este não é o
caso quando o bloco é o último em sua região de memória e o próximo é mapeado após um determinado intervalo. Você pode usar o arquivo
de cabeçalho mostrado na Listagem 13-20 como ponto de partida.

Listagem 13-20. mem.h

#ifndef _MEM_H_
#define _MEM_H_

#define _USE_MISC

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

#include <sys/mman.h>

#define HEAP_START ((void*)0x04040000)

estrutura mem;

260
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

#pragma pack(push, 1) struct


mem { struct
mem* próximo;
tamanho_t capacidade;
bool é_livre;
};
#pragma pack(pop)

void* _malloc(consulta size_t); void


_free(void* mem); void*
heap_init(tamanho_t tamanho_inicial);

#define DEBUG_FIRST_BYTES 4

void memalloc_debug_struct_info (ARQUIVO* f, struct


mem const* endereço const);

void memalloc_debug_heap( FILE* f, struct mem const* ptr );

#fim se

Lembre-se de que a lógica complexa exige uma decomposição bem pensada em funções menores.
Você pode usar o código mostrado na Listagem 13.21 para depurar o estado do heap. Não esqueça que você também pode
aguarde a entrada do usuário e verifique o arquivo /proc/PID/maps para ver os mapeamentos reais de um processo com o
identificador PID.

Listagem 13-21. mem_debug.c

#include "mem.h"

void memalloc_debug_struct_info(ARQUIVO*f,
struct mem const* const endereço) {

tamanho_t eu;

fprintf( f, "start:
%p\nsize: %lu\nis_free: %d\n", (void*)endereço,
endereço->
capacidade, endereço->
is_free ); for ( i = 0; i <

DEBUG_FIRST_BYTES && i < endereço-> capacidade; ++i ) fprintf( f, "%hhX",

((char*)endereço)
[ sizeof( struct mem_t ) + i ] ); putc('\n', f);

void memalloc_debug_heap( FILE* f, struct mem const* ptr ) {


for( ; ptr; ptr = ptr->next )
memalloc_debug_struct_info( f, ptr );
}

Um número estimado de linhas de código é de 150 a 200. Não se esqueça de escrever um Makefile.

261
Machine Translated by Google

Capítulo 13 ÿ Boas Práticas de Código

13.12 Resumo
Neste capítulo estudamos extensivamente algumas das recomendações mais importantes considerando o
estilo de codificação e a arquitetura do programa. Vimos as convenções de nomenclatura e as razões por trás
das diretrizes de código comum. Quando escrevemos código, devemos aderir a certas restrições derivadas de
nossos requisitos para o código, bem como do próprio processo de desenvolvimento. Vimos conceitos importantes
como encapsulamento. Por fim, fornecemos mais duas tarefas avançadas, nas quais você poderá aplicar seus
novos conhecimentos sobre arquitetura de programas. Na próxima parte vamos nos aprofundar nos detalhes da
tradução, revisar alguns recursos da linguagem que são mais fáceis de entender no nível assembly e
falar sobre desempenho e otimizações do compilador.

262
Machine Translated by Google

PARTE III

Entre C e Montagem
Machine Translated by Google

CAPÍTULO 14

Detalhes da tradução

Neste capítulo, revisitaremos a noção de convenção de chamada para aprofundar nossa compreensão e trabalhar nos detalhes da
tradução. Este processo requer a compreensão do funcionamento do programa no nível assembly e um certo grau de familiaridade
com C. Também revisaremos algumas vulnerabilidades clássicas de segurança de baixo nível que podem ser abertas por um
programador descuidado. Compreender esses detalhes de tradução de baixo nível às vezes é crucial para erradicar bugs muito
sutis que não se revelam a cada execução.

14.1 Sequência de Chamada de Função


No Capítulo 2 estudamos como chamar os procedimentos, como eles retornam valores e como aceitam argumentos.
A sequência completa de chamada é descrita em [24] e é altamente recomendável que você dê uma olhada nele. Vamos revisitar esse
processo e adicionar detalhes valiosos.

14.1.1 Registros XMM


Além dos registros de que já falamos, os processadores modernos possuem vários conjuntos de registros especiais que vêm de
extensões de processador. Uma extensão fornece circuitos adicionais, expande um conjunto de instruções e, às vezes, adiciona
registradores utilizáveis. Uma extensão notável é chamada SSE (Streaming SIMD Extensions) e descreve um conjunto de registros
xmm: xmm0, xmm1, ..., xmm15. Eles têm 128 bits de largura e geralmente são usados para dois tipos de tarefas:

•Aritmética de ponto flutuante; e

•Instruções SIMD (essas instruções executam uma ação em vários dados).

O comando mov normal não funciona com registros xmm. O comando movq é usado para copiar dados entre a metade menos
significativa dos registradores xmm (64 bits de 128) de um lado e os registradores xmm, registradores de uso geral ou memória do outro
lado (também 64 bits).
Para preencher todo o registro xmm, você tem duas opções: movdqa e movdqu. O primeiro é decifrado como
“mover palavra quádrupla dupla alinhada”, a segunda é a versão não alinhada.
A maioria das instruções SSE exige que os operandos de memória estejam devidamente alinhados. As versões não alinhadas
dessas instruções geralmente existem com mnemônicos diferentes e implicam em uma penalidade de desempenho devido a
uma leitura desalinhada. Como as instruções SSE são frequentemente usadas em locais sensíveis ao desempenho, geralmente é mais
sensato seguir as instruções que exigem alinhamento de operandos.
Usaremos as instruções SSE para realizar cálculos de alto desempenho na seção 16.4.1.

ÿ Pergunta 263 Leia sobre as instruções movq, movdqa e movdqu em [15].

© Igor Zhirkov 2017 265


I. Zhirkov, Programação de baixo nível, DOI 10.1007/978-1-4842-2403-8_14
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

14.1.2 Convenção de Chamada


Convenção de chamada é um conjunto de regras sobre a sequência de chamada de função que um programador segue voluntariamente.
Se todos seguirem as mesmas regras, é garantida uma interoperabilidade tranquila. Porém, uma vez que alguém quebra as regras, por
exemplo, faz alterações e não restaura o rbp em uma determinada função, tudo pode acontecer: nada, uma falha retardada ou imediata. A
razão é que outras funções são escritas com a implicação de que essas regras são respeitadas e contam com que o rbp permaneça intocado.

As convenções de chamada declaram, entre outras coisas, o algoritmo de passagem de argumentos. No caso da convenção típica
*nix x86 64 que estamos usando (descrita completamente em [24]), a descrição a seguir é uma aproximação suficientemente precisa de
como a função é chamada.

1. Primeiramente são salvos os registros que precisam ser preservados. Todos os registros, exceto sete
registros salvos pelo chamador (rbx, rbp, rsp e r12-r15) podem ser alterados pela função chamada,
portanto, se seu valor for de alguma importância, eles deverão ser armazenados (provavelmente em
uma pilha).

2. Os registros e a pilha são preenchidos com argumentos.

O tamanho de cada argumento é arredondado para 8 bytes.

Os argumentos são divididos em três listas:

(a) Argumentos inteiros ou de ponteiro.

(b) Flutuadores e duplos.

(c) Argumentos passados na memória via pilha (“memória”).

Os primeiros seis argumentos da primeira lista são passados em registradores de uso geral (rdi, rsi, rdx,
rcx, r8 e r9). Os primeiros oito argumentos da segunda lista são passados nos registradores xmm0 a
xmm7. Se houver mais argumentos dessas listas para passar, eles serão passados para a pilha na ordem
inversa. Isso significa que o último argumento estará no topo da pilha antes da chamada ser realizada.

Embora números inteiros e flutuantes sejam bastante triviais de manusear, as estruturas são um pouco mais complicadas.

Se uma estrutura for maior que 32 bytes, ou tiver campos desalinhados, ela é passada
memória.

Uma estrutura menor é decomposta em campos e cada campo é tratado separadamente e, se estiver
em uma estrutura interna, recursivamente. Portanto, uma estrutura de dois elementos pode ser passada
da mesma forma que dois argumentos. Se um campo de uma estrutura for considerado “memória”, ele
se propagará para a própria estrutura.

O registrador rbp, como veremos, é usado para endereçar os argumentos passados na memória e
nas variáveis locais.

E quanto aos valores de retorno? Valores inteiros e ponteiros são retornados em rax e rdx.
Valores de ponto flutuante são retornados em xmm0 e xmm1. Grandes estruturas são retornadas através
de um ponteiro, fornecido como um argumento oculto adicional, no espírito do exemplo a seguir:

266
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

estrutura s {
char vals[100];
};

estrutura sf(int x) {
struct é meu;
meus.vals[10] = 42;
devolva o meu;
}

void f(int x, estrutura s* ret) {


ret->vals[10] = 42;
}

3. Em seguida, a instrução de chamada deverá ser chamada. Seu parâmetro é o endereço da


primeira instrução de uma função chamada. Ele coloca o endereço de retorno na pilha.

Cada programa pode ter múltiplas instâncias da mesma função iniciadas ao mesmo tempo,
não apenas em threads diferentes, mas também devido à recursão. Cada instância de
função é armazenada na pilha, porque seu princípio principal – “último a entrar, primeiro a
sair” – corresponde a como as funções são iniciadas e finalizadas. Se uma função f
é iniciado e então invoca uma função g, g é finalizado primeiro (mas foi invocado por último)
e f é finalizado por último (enquanto é invocado primeiro).

O quadro de pilha é parte de uma pilha dedicada a uma única instância de função. Ele
armazena os valores das variáveis locais, variáveis temporais e registros salvos.

O código da função geralmente é colocado dentro de um par de prólogo e epílogo, que


são semelhantes para todas as funções. O prólogo ajuda a inicializar o quadro de pilha e o
epílogo o desinicializa.

Durante a execução da função, rbp permanece inalterado e aponta para o início de seu
stack frame. É possível endereçar variáveis locais e empilhar argumentos relativamente
ao rbp. Isso está refletido no prólogo da função mostrado na Listagem 14-1.

Listagem 14-1. prólogo.asm

função:

empurrar rbp
mov rbp, rsp

sub rsp, 24 ; dado 24 é o tamanho total das variáveis locais

O valor rbp antigo é salvo para ser restaurado posteriormente no epílogo. Em seguida, um
novo rbp é configurado no topo atual da pilha (que agora armazena o valor antigo do rbp).
Em seguida, a memória para as variáveis locais é alocada na pilha subtraindo seu
tamanho total de rsp. Esta é a alocação automática de memória em C e a técnica que
usamos na primeira tarefa para alocar buffers na pilha.

As funções terminam com um epílogo mostrado na Listagem 14-2.

Listagem 14-2. epílogo.asm

mov rsp, rbp


pop rbp
ret

267
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Ao mover o quadro da pilha do endereço inicial para rsp, podemos ter certeza de que toda a memória
alocada na pilha será desalocada. Em seguida, o valor antigo de rbp é restaurado e agora rbp aponta
para o início do quadro de pilha anterior. Finalmente, ret coloca o endereço de retorno da pilha em rip.

Às vezes, uma forma alternativa totalmente equivalente é escolhida pelo compilador. Isso é
mostrado na Listagem 14-3.

Listagem 14-3. epílogo_alt.asm

Deixar
ret

A instrução de licença é feita especialmente para destruição de stack frame. Sua contraparte,
enter, nem sempre é usada pelos compiladores porque é mais funcional que a sequência de instruções
mostrada na Listagem 14-1. Destina-se a linguagens com suporte a funções internas.

4. Depois de sair da função, nosso trabalho nem sempre termina. Caso houvesse
argumentos que foram passados na memória (pilha), temos que nos livrar deles também.

14.1.3 Exemplo: Função Simples e Sua Pilha


Vamos dar uma olhada em uma função simples que calcula no máximo dois valores. Vamos compilá-lo sem otimizações e ver a
listagem do assembly.
A Listagem 14-4 mostra um exemplo.

Listagem 14-4. máximo.c

int máximo(int a, int b) {


buffer de caracteres[4096];
se (a <b) retorne b;
retornar um;
}

int principal(void) {
int x = máximo( 42, 999 );
retornar 0;
}

A Listagem 14-5 mostra a desmontagem produzida pelo objdump.

Listagem 14-5. máximo.asm

0000000004004b6 <máximo>:
4004b6: 55 empurrar rbp
4004b7: 48 89 e5 movimento
rbp, rsp
4004ba: 48 81 ec 90 0f 00 00 89 bd fc sub rsp,0xf90
4004c1: ef ff ff 89 b5 f8 ef ff ff 8b movimento
DWORD PTR [rbp-0x1004],edi
4004c7: 85 fc ef ff ff 3b 85 f8 ef ff movimento
DWORD PTR [rbp-0x1008],esi
4004cd: ff movimento
eax, DWORD PTR [rbp-0x1004]
4004d3: cmp eax, DWORD PTR [rbp-0x1008]

268
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

4004d9: 7d 08 jge 4004e3 <máximo+0x2d>


4004db: 8b 85 f8 ef ff ff eb 06 movimento
eax, DWORD PTR [rbp-0x1008]
4004e1: 8b 85 jmp 4004e9 <máximo+0x33>
4004e3: fc ef ff ff c9 sair eax, DWORD PTR [rbp-0x1004]
4004e9:
4004ea: c3 ret

00000000004004eb <principal>:
4004eb: empurrar rbp
4004ec: 55 48 89 mov rbp, rsp
4004ef: e5 48 83 ec 10 sub rsp,0x10
4004f3: ser e7 03 00 00 bf sim,0x3e7
movimento

4004f8: 2a 00 00 00 e8 b4 mov edi,0x2a


4004fd: ff ff ff 89 45 fc chamada 4004b6 <máximo>
400502: movimento
DWORD PTR [rbp-0x4],eax

Após um pouco de limpeza, obtemos um código assembly puro e mais legível, que é mostrado na Listagem 14-6.

Listagem 14-6. máximo_refinado.asm

mov rsi, 999


mov rdi, 42
chamada máxima

...
máximo:
empurrar rbp
mov rbp, rsp
sub rsp, 3984

mov [rbp-0x1004], edição


mov [rbp-0x1008], esi
mov eax, [rbp-0x1004]
...

Deixar
ret

ÿ Atribuição de registro Consulte a seção 3.4.2 para obter a explicação sobre por que alterar o esi significa uma
alteração em todo o rsi.

Vamos rastrear a chamada da função e seu prólogo (ver Listagem 14.6) e mostrar o conteúdo da pilha imediatamente
após sua execução.

269
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

chamada máxima

empurrar rbp

mov rbp, rsp

270
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

sub rsp, 3984

14.1.4 Zona Vermelha


A zona vermelha é uma área de 128 bytes que abrange desde rsp até endereços inferiores. Flexibiliza a regra “nenhum
dado abaixo do rsp”; é seguro alocar dados lá e eles não serão substituídos por chamadas de sistema ou interrupções. Estamos
falando sobre gravações diretas na memória em relação ao rsp sem alterar o rsp. As chamadas de função, entretanto, ainda
substituirão a zona vermelha.
A zona vermelha foi criada para permitir uma otimização específica. Se uma função nunca chama outras funções, ela pode
omitir a criação do quadro de pilha (alterações de rbp). Variáveis e argumentos locais serão então endereçados em relação ao
rsp, não ao rbp.

•O tamanho total das variáveis locais é inferior a 128 bytes.

•Uma função é uma função folha (não chama outras funções).

•Função não altera rsp; caso contrário, será impossível endereçar a memória relativa
a ela.

Avançando o rsp, você ainda pode obter mais espaço livre para alocar seus dados do que 128 bytes na pilha.
Consulte também a seção 16.1.3.

14.1.5 Número Variável de Argumentos


A convenção de chamada que estamos usando suporta a contagem de argumentos variáveis. Isso significa que a função pode
aceitar um número arbitrário de argumentos. É possível porque a passagem de argumentos (e a limpeza da pilha após o
término da função) é de responsabilidade da função chamadora.
A declaração de tais funções contém as chamadas reticências – três pontos em vez do último argumento.
A função típica com número variável de argumentos é a nossa velha amiga printf.

void printf(char const* formato, ...);

271
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Como printf sabe o número exato de argumentos? Ele sabe com certeza que pelo menos um argumento foi passado
(formato char const*). Ao analisar esta string e contar os especificadores, ele calculará o número total de argumentos, bem
como seus tipos (em quais registros eles deveriam estar).

ÿ Nota No caso de número variável de argumentos, al deve conter o número de registros xmm usados pelos
argumentos.

Como você pode ver, não há absolutamente nenhuma maneira de saber quantos argumentos foram aprovados
exatamente. A função deduz isso dos argumentos que certamente estão presentes (formato neste caso). Se houver mais
especificadores de formato do que argumentos, printf não saberá disso e tentará obter o conteúdo dos respectivos
registros e memória ingenuamente.
Aparentemente, esta funcionalidade não pode ser codificada em C diretamente por um programador, porque os
registradores não podem ser acessados diretamente. No entanto, existe um mecanismo portátil de declaração de funções
com contagem de argumentos variáveis que faz parte da biblioteca padrão. Cada plataforma possui sua própria
implementação desse mecanismo. Ele pode ser usado após a inclusão do arquivo stdarg.h e consiste no seguinte:

•va_list–uma estrutura que armazena informações sobre argumentos.

•va_start – uma macro que inicializa va_list.

•va_end–uma macro que desinicializa va_list.

•va_arg–uma macro que pega o próximo argumento da lista de argumentos quando recebe um
instância de va_list e um tipo de argumento.

A Listagem 14-7 mostra um exemplo. A função impressora aceita vários argumentos e um número arbitrário deles.

Listagem 14-7. vararg.c

#include <stdarg.h>
#include <stdio.h>

void impressora(unsigned long argcount, ...) {


lista_va argumentos;
não assinado há muito tempo;

va_start(args,argcount);
for (i = 0; i <argcount; i++ )
printf(" %d\n", va_arg(args, int ) );

va_end(argumentos);
}

int principal() {
impressora (10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0);
retornar 0;
}

Primeiro, va_list é inicializado com o nome do último argumento antes dos pontos por va_start. Então, cada chamada
para va_arg obtém o próximo argumento. O segundo parâmetro é o nome do tipo do novo argumento. No final, va_list é
desinicializado usando va_end.

272
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Como o nome do tipo se torna um argumento e va_list é usado pelo nome, mas sofre mutação, este exemplo
pode parecer confuso.

ÿ Pergunta 264 Você consegue imaginar uma situação em que uma função, e não uma macro, aceita uma variável
pelo nome (sintaticamente) e a altera? Qual deve ser o tipo dessa variável?

14.1.6 vprintf e amigos


Funções como printf, fprintf, etc., possuem versões especiais. Aqueles aceitam va_list como seus últimos argumentos.
Seus nomes são prefixados com a letra v, por exemplo,

int vprintf(const char *formato, va_list ap);

Eles estão sendo usados dentro de funções personalizadas que, por sua vez, aceitam um número arbitrário
de argumentos.
A Listagem 14-8 mostra um exemplo.

Listagem 14-8. vsprintf.c

#include <stdarg.h>
#include <stdio.h>

void logmsg(int client_id, const char* const str, ...) {


lista_va argumentos;
buffer de caracteres[1024];
char* bufptr = buffer;

va_start(args,str);

bufptr += sprintf(bufptr, "do cliente %d:", client_id );


vsprintf(bufptr,str,args);
fprintf(stderr, "%s", buffer);

va_end(argumentos);
}

14,2 volátil
A palavra-chave volátil afeta muito a maneira como o compilador otimiza o código.
O modelo de computação para C é de von Neumann. Não suporta execução paralela de programas
e o compilador geralmente tenta fazer tantas otimizações quanto possível sem alterar o comportamento
observável do programa. Pode incluir reordenação de instruções e cache de variáveis em registradores. A leitura de
um valor da memória que não está escrito em nenhum lugar é omitida.
Porém, a leitura e a escrita em variáveis voláteis sempre acontecem. A ordem das operações também é
preservada.

273
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Os principais casos de uso são os seguintes:

• E/S mapeada em memória, quando a comunicação com dispositivos externos é realizada


interagindo com uma determinada região de memória dedicada. Escrever um caractere na memória de
vídeo (o que resulta na exibição dele na tela) realmente significa isso.

• Compartilhamento de dados entre threads. Se a memória for usada para se comunicar com outros threads,
você não deseja que as gravações ou leituras sejam otimizadas.

Observe que o volátil por si só não é suficiente para realizar uma comunicação robusta entre threads.
Assim como o qualificador const, no caso de um ponteiro, volátil pode ser aplicado aos dados para os quais aponta, bem como
ao próprio ponteiro. A regra é a mesma: volátil à esquerda do asterisco refere-se aos dados para os quais aponta, e à direita – ao próprio
ponteiro.

14.2.1 Alocação de memória lenta


Muitos sistemas operacionais mapeiam páginas preguiçosamente, no momento do primeiro uso, e não logo após a chamada mmap (ou
equivalente).
Se o programador não quiser atrasos no uso da primeira página, ele poderá optar por abordar cada página
individualmente para que o sistema operacional realmente o crie, conforme mostrado na Listagem 14-9.

Listagem 14-9. lma_bad.c

char*ptr;
for(ptr = início; ptr < início + tamanho; ptr += tamanho da página)
*ptr;

No entanto, este código não tem efeito observável do ponto de vista do compilador, portanto pode ser completamente
otimizado. No entanto, quando o ponteiro estiver marcado como volátil, este não será o caso.
A Listagem 14-10 mostra um exemplo.

Listagem 14-10. lma_good.c

caractere volátil* ptr;


for(ptr = início; ptr < início + tamanho; ptr += tamanho da página)
*ptr;

ÿ Ponteiros voláteis no padrão de linguagem Se o ponteiro volátil estiver apontando para a memória não
volátil, de acordo com o padrão não há garantias! Eles existem apenas quando o ponteiro e a memória são
voláteis. Portanto, de acordo com a norma, o exemplo acima está incorreto. Porém, como os programadores estão
utilizando os ponteiros voláteis exatamente com esse raciocínio, os compiladores mais utilizados (MSVC, GCC,
clang) não otimizam a desreferenciação dos ponteiros voláteis. Não existe uma maneira padrão de fazer isso.

14.2.2 Código Gerado


Estudaremos o exemplo mostrado na Listagem 14-11.

274
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Listagem 14-11. volátil_ex.c

#include <stdio.h>

int principal( int argc, char** argv ) {


int comum = 0;
volátil int vol = 4;
comum++;
volume++;

printf("%d\n", comum);
printf("%d\n", volume);
retornar 0;
}

Existem duas variáveis: uma é volátil, a outra não. Ambos são incrementados e dados ao printf como
argumentos. O GCC irá gerar o seguinte código (com nível de otimização -O2), mostrado na Listagem 14-12:

Listagem 14-12. volátil_ex.asm

; estes são dois argumentos para `printf`


movimento
sim,0x1
movimento
edi,0x4005d4

; volume = 4
movimento
DWORD PTR [rsp+0xc],0x4

; volume++
mov
eax,DWORD PTR [rsp+0xc]
adicionar eax,0x1
movimento
DWORD PTR [rsp+0xc],eax

xor eax, eax

; printf("%d\n", comum)
; o `comum` nem mesmo é criado no stack frame
; seu valor final pré-computado 1 foi colocado em `rsi` na primeira linha!
ligue para 4003e0 <printf@plt>

; o segundo argumento é retirado da memória, é volátil!


movimento
esi,DWORD PTR [rsp+0xc]

; O primeiro argumento é o endereço de "%d\n"


movimento
edi,0x4005d4
xor eax, eax

; printf("%d\n", volume)
ligue para 4003e0 <printf@plt>
xor eax, eax

Como vemos, o conteúdo de uma variável volátil é realmente lido e escrito cada vez que ocorre em C. A variável
ordinária nem mesmo será criada: os cálculos serão realizados em tempo de compilação e o resultado final será armazenado
em rsi, aguardando para ser usado como o segundo argumento de uma chamada.

275
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

14.3 Saltos não locais – setjmp


A biblioteca C padrão contém máquinas para realizar um tipo de hack muito complicado. Permite armazenar um
contexto de computação e restaurá-lo. O contexto descreve o estado de execução do programa , com exceção do seguinte:

•Tudo relacionado ao mundo externo (por exemplo, descritores abertos).

•Contexto de cálculos de ponto flutuante.

•Variáveis de pilha.

Permite salvar o contexto e voltar a ele caso sintamos que precisamos retornar. Não estamos limitados
pelo mesmo escopo de função.
Inclua o setjmp.h para obter acesso às seguintes máquinas:

•jmp_buf é um tipo de variável que pode armazenar o contexto.

•int setjmp(jmp_buf env) é uma função que aceita uma instância jmp_buf e armazena o contexto atual
nela. Por padrão, ele retorna 0.

•void longjmp(jmp_buf env, int val) é usado para retornar a um contexto salvo, armazenado em um
certa variável do tipo jmp_buf.

Ao retornar do longjmp, setjmp não retorna necessariamente 0, mas o valor val alimentado para longjmp.
A Listagem 14-13 mostra um exemplo. O primeiro setjmp retornará 0 por padrão e também o valor val.
No entanto, o longjmp aceita 1 como argumento e a execução do programa continuará a partir do setjmp
chamada (porque eles estão vinculados através do uso do jb). Desta vez setjmp retornará 1 e este é o valor que será
atribuído a val.

Listagem 14-13. longjmp.c

#include <stdio.h>
#include <setjmp.h>

int principal(void) {
jmp_bufjb;
valor interno;
val = setjmp(jb);
coloca("Olá!");
if (val == 0) longjmp( jb, 1 );
senão coloca("Fim");
retornar 0;
}

Variáveis locais que não são marcadas como voláteis manterão valores indefinidos após longjmp. Esta é a fonte
de bugs, bem como de problemas relacionados à liberação de memória: é difícil analisar o fluxo de controle na presença
de longjmp e garantir que toda a memória alocada dinamicamente seja liberada.
Em geral, é permitido chamar setjmp como parte de uma expressão complexa, mas apenas em casos raros. Na maioria dos
casos, este é um comportamento indefinido. Então, é melhor não fazer isso.
É importante lembrar que todo esse maquinário é baseado no uso de stack frames. Isso significa que você
não é possível executar longjmp em uma função com um stack frame desinicializado. Por exemplo, o código mostrado na
Listagem 14-14 produz um comportamento indefinido exatamente por esse motivo.

276
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Listagem 14-14. longjmp_ub.c

jmp_bufjb;
vazio f(vazio)
{ setjmp( jb );
}

vazio g(vazio)
{ f();
longjmp(jb);
}

A função f já terminou, mas estamos executando longjmp nela. O comportamento do programa é


indefinido porque estamos tentando restaurar um contexto dentro de um stack frame destruído.
Em outras palavras, você só pode pular para a mesma função ou para uma função iniciada.

14.3.1 Volátil e setjmp


O compilador pensa que setjmp é apenas uma função. No entanto, isso não é verdade, porque este é o ponto a partir do qual
o programa pode começar a ser executado novamente. Em condições normais, algumas variáveis locais podem ter sido armazenadas
em cache nos registradores (ou nunca alocadas) antes da chamada para setjmp. Quando retornarmos a este ponto devido a uma
chamada longjmp, eles não serão restaurados.
Desativar as otimizações altera esse comportamento. Portanto, as otimizações foram desativadas, ocultando bugs relacionados
ao uso do setjmp.
Para escrever corretamente, lembre-se de que apenas variáveis locais voláteis mantêm valores definidos
após longjmp. Eles não são restaurados para seus valores antigos, porque jmp_buf não salva variáveis de pilha,
mas mantém os valores anteriores a longjmp.
A Listagem 14-15 mostra um exemplo.

Listagem 14-15. setjmp_volatile.c

#include <stdio.h>
#include <setjmp.h>

jmp_buf buf;

int main( int argc, char** argv ) { int var = 0;


volátil int b =
0; setjmp(buf); se (b <
3) {b++;

var++;
printf("\n\n%d\n",var); longjmp( buf,
1 );
}

retornar 0;
}

Vamos compilá-lo sem otimizações (gcc -O0, Listagem 14-16) e com otimizações (gcc -O2, Listagem 14-17).

Sem otimizações,

277
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Listagem 14-16. volátil_setjmp_o0.asm

principal:

empurrar RBP
movimento
rbp, rsp
sub rsp,0x20

; `argc` e `argv` são salvos na pilha para disponibilizar `rdi` e `rsi`


movimento
DWORD PTR [rbp-0x14],edi
movimento
QWORD PTR [rbp-0x20],rsi

; var = 0
movimento
PTR DWORD [rbp-0x4],0x0

;b=0
movimento
PTR DWORD [rbp-0x8],0x0

; 0x600a40 é o endereço de `buf` (uma variável global do tipo `jmp_buf`)


movimento
edi,0x600a40
ligue para 400470 <_setjmp@plt>

; se (b <3), a ramificação boa é executada


; Isso é codificado pulando várias instruções para `.endlabel` se b> 2
movimento
eax,DWORD PTR [rbp-0x8]
cmp eax,0x2
jg .endlabel

; Um incremento justo
; b++
movimento
eax,DWORD PTR [rbp-0x8]
adicionar
eax,0x1
movimento
DWORD PTR [rbp-0x8],eax

; var++
adicionar
PTR DWORD [rbp-0x4],0x1

; chamada `printf`
movimento
eax,DWORD PTR [rbp-0x4]
movimento
esi,eax
movimento
edição,0x400684
; Não há argumentos de ponto flutuante, portanto rax = 0
movimento
eax,0x0
ligue para 400450 <printf@plt>

; chamando `longjmp`
movimento
sim,0x1
movimento
edi,0x600a40
ligue para 400490 <longjmp@plt>

.endlabel:
Movimento
eax,0x0
Deixar
ret

278
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

A saída do programa será

1
2
3

Com otimizações,

Listagem 14-17. volátil_setjmp_o2.asm

principal:

; alocando memória na pilha


sub rsp,0x18

; um argumento `setjmp`, o endereço de `buf`

movimento
edi,0x600a40

;b=0
movimento
DWORD PTR [rsp+0xc],0x0
; as instruções são colocadas em uma ordem diferente
; de instruções C para fazer melhor uso do pipeline e de outros recursos internos
; Mecanismos de CPU.
ligue para 400470 <_setjmp@plt>

; `b` é lido e verificado de maneira justa


movimento
eax,DWORD PTR [rsp+0xc]
cmp eax,0x2
jle .filial

; retornar 0
xou eax, eax
adicionar rsp,0x18
ret

.filial:

movimento
eax,DWORD PTR [rsp+0xc]

; o segundo argumento de `printf` é var + 1


; Nem foi lido da memória nem alocado.
; Os cálculos foram realizados em tempo de compilação
movimento
sim,0x1

; O primeiro argumento de `printf`


movimento
edição,0x400674

;b=b+1
adicione eax,0x1
movimento
DWORD PTR [rsp+0xc],eax

279
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

xor eax, eax


ligue para 400450 <printf@plt>

; longjmp( buf, 1 )
movimento
sim,0x1
movimento
edi,0x600a40
ligue para 400490 <longjmp@plt>

A saída do programa será

1
1
1

A variável volátil b, como você pode ver, comportou-se conforme pretendido (caso contrário, o ciclo nunca teria terminado).
A variável var sempre foi igual a 1, apesar de ser “incrementada” conforme o texto do programa.

ÿ Pergunta 265 Como você implementa construções semelhantes a “try-catch” usando setjmp e longjmp?

14,4 em linha
inline é um qualificador de função introduzido em C99. Ele imita o comportamento de sua contraparte C++.
Antes de ler uma explicação, por favor, não presuma que esta palavra-chave é usada para forçar o inlining da
função!
Antes do C99, havia um qualificador estático, que era frequentemente usado no seguinte cenário:

•O arquivo de cabeçalho inclui não a declaração da função, mas a definição completa da função ,
marcada como estática.

•O cabeçalho é então incluído em múltiplas unidades de tradução. Cada um deles recebe uma cópia do
código emitido, mas como o símbolo correspondente é local do objeto, o vinculador não o vê como
um conflito de múltiplas definições.

Em um grande projeto, isso dá ao compilador acesso ao código-fonte da função, o que permite realmente
inline a função, se necessário. Obviamente, o compilador também pode decidir que é melhor deixar a função não embutida.
Neste caso, começamos a obter clones desta função em praticamente todos os lugares. Cada arquivo está chamando sua própria
cópia, o que é ruim para a localidade e sobrecarrega a imagem da memória e também o próprio executável.
A palavra-chave inline aborda esse problema. Seu uso correto é o seguinte:

•Descreva uma função embutida em um cabeçalho relevante, por exemplo,

inline int inc(int x) { return x+1; }

•Em exatamente uma unidade de tradução (ou seja, um arquivo .c), adicione a declaração externa

extern inline int inc(int x);

280
Machine Translated by Google

Capítulo 14 ÿ Detalhes da Tradução

Este arquivo conterá o código da função, que será referenciado por todos os outros arquivos, onde a função não foi
incorporada.

ÿ Mudança semântica No GCC anterior ao 4.2.1 a palavra-chave inline tinha um significado ligeiramente diferente. Veja a postagem

[14] para uma análise aprofundada.

14,5 restrição
restringir é uma palavra-chave semelhante a volátil e const que apareceu pela primeira vez no padrão C99. É utilizado para
marcar ponteiros e, portanto, é colocado à direita do asterisco, da seguinte forma:

interno x;
int* restringir p_x = &x;

Se criarmos um ponteiro restrito para um objeto, prometemos que todos os acessos a esse objeto passarão pelo valor
desse ponteiro. Um compilador pode ignorar isso ou utilizá-lo para certas otimizações, o que geralmente é possível.

Em outras palavras, qualquer escrita por outro ponteiro não afetará o valor armazenado por um ponteiro restrito.
Quebrar essa promessa leva a erros sutis e é um caso claro de comportamento indefinido.
Sem restrição, todo ponteiro é uma fonte de possíveis alias de memória, quando você pode acessar as mesmas células de
memória usando nomes diferentes para elas. Considere um exemplo muito simples, mostrado na Listagem 14-18. O corpo de f
é igual a *x += 2 * (*add);?

Listagem 14-18. restringir_motiv.c

void f(int* x, int* adicionar) {


*x += *adicionar;
*x += *adicionar;
}

A resposta é, surpreendentemente, não, eles não são iguais. E se add e x estiverem apontando para o mesmo endereço?
Neste caso, alterar *x altera *add também. Portanto, no caso x == add, a função irá adicionar *x a *x tornando-o duas vezes o
valor inicial, e então repetirá tornando-o quatro vezes o valor inicial. No entanto, quando x != add, mesmo que *x == *add o *x
final será três vezes o valor inicial.
O compilador está bem ciente disso e mesmo com as otimizações ativadas ele não otimizará dois
lê, conforme mostrado na Listagem 14-19.

Listagem 14-19. restringir_motiv_dump.asm

000000000000000 <f>:
0: 8b 06 mov eax,DWORD PTR [rsi]
2: 03 07 89 adicionar eax,DWORD PTR [rdi]
6: 07 4: mov DWORD PTR [rdi],eax
03 06 8: 89 07 adicionar eax,DWORD PTR [rsi]
mov DWORD PTR [rdi],eax
a: c3 ret

281

Você também pode gostar