Escolar Documentos
Profissional Documentos
Cultura Documentos
Nível baixo
Programação
C, montagem e execução do programa em
Arquitetura Intel® 64
-
Igor Zhirkov
Machine Translated by Google
Igor Zhirkov
Machine Translated by Google
Igor Zhirkov
São Petersburgo, Rússia
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.
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.
Resumo do conteúdo
Agradecimentosÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxiii
iii
Machine Translated by Google
4
Machine Translated by Google
Conteúdo
Agradecimentosÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿxxiii
v
Machine Translated by Google
ÿ Conteúdo
vi
Machine Translated by Google
ÿ Conteúdo
vii
Machine Translated by Google
ÿ Conteúdo
viii
Machine Translated by Google
ÿ Conteúdo
ix
Machine Translated by Google
ÿ Conteúdo
x
Machine Translated by Google
ÿ Conteúdo
XI
Machine Translated by Google
ÿ Conteúdo
xii
Machine Translated by Google
ÿ Conteúdo
xiii
Machine Translated by Google
ÿ Conteúdo
XIV
Machine Translated by Google
ÿ Conteúdo
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
xv
Machine Translated by Google
ÿ Conteúdo
ÿ Conteúdo
xvii
Machine Translated by Google
Sobre o autor
XIX
Machine Translated by Google
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
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
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.
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.
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.
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.
4
Machine Translated by Google
ÿ 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
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.
• Divisão zero.
• Instrução inválida (quando a CPU não consegue reconhecer uma instrução pela sua
representação binária).
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.
6
Machine Translated by Google
computadores.
Problema Solução
Não há suporte para isolamento de código em procedimentos ou para economia de contexto Pilha de hardware
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:
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
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.
• 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
• 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.
8
Machine Translated by Google
ÿ 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.
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.
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.
r0 r1 r2 r3 r4 r5 r6 r7
É 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:
9
Machine Translated by Google
Por exemplo,
• 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
11
Machine Translated by Google
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.
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?
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.
• 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
• 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".
13
Machine Translated by Google
• empurrar argumento
• argumento pop
14
Machine Translated by Google
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.
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:
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
ÿ 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.
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?
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.
ÿ Questão 6 Quais são os principais problemas que as extensões modernas do modelo de von Neumann estão tentando
resolver?
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.
• 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.
• 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.
O Apêndice D fornece mais informações sobre o sistema usado para testes de desempenho.
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.
_início global
seção .dados
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
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.
ÿ 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;
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
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.
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,
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.
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
2. Os seguintes registradores devem conter seus argumentos: rdi, rsi, rdx, r10, r8 e r9.
1. Descritor de arquivo;
2. O endereço do buffer. Começamos a usar bytes consecutivos para escrita a partir daqui;
Para compilar nosso primeiro programa, salve o código em hello.asm2 e execute estes comandos no shell:
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.
seção .dados
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
21
Machine Translated by Google
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
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
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.
Podemos usar uma pilha de hardware para salvar e restaurar valores de registro, como em torno da instrução syscall.
ÿ 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.
• 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.
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.
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
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.
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.
mov rax, -1
movimento rdx, 2
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
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.
Essas duas categorias são uma convenção. Ou seja, um programador deve seguir este acordo
• 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:
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).
ÿ 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
ÿ 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).
seção .dados
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 ;
27
Machine Translated by Google
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
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 rax, 60
xor rdi, syscall
rdi
Quando o lançamos, para nossa surpresa, obtemos resultados completamente diferentes para demo1 e demo2.
> ./principal
1122334455667788
8877665544332211
28
Machine Translated by Google
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
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.
29
Machine Translated by Google
...
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.
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?
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.
mov rax, 10
30
Machine Translated by Google
2. Através de um registo:
mov r9, 10
mov rax, [r9]
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.
A maioria dos modos de endereçamento são generalizados por este modo. O endereço
aqui é calculado com base nos seguintes componentes:
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
>
verdadeiro > echo $?
0 > falso
> eco $?
1
Vamos escrever um programa assembly que imite o comando false shell, conforme mostrado na Listagem 2.13.
_início global
Agora temos tudo o que é necessário para calcular o comprimento da string. A Listagem 2-14 mostra o código.
_início global
seção .dados
seção .texto
32
Machine Translated by Google
.fim:
ret ; Quando clicamos em 'ret', rax deve conter o valor de retorno
_começar:
mov rax, 60
syscall
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?
_início global
seção .data
test_string: banco de dados "abcdef", 0
seção .texto
_start:
mov rdi, test_string chama
strlen mov rdi,
rax
mov rax, 60
syscall
33
Machine Translated by Google
• xor
• jmp, ja e similares
• cmp
• movimento
• aumento, dezembro
• 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
Função Definição
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.
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).
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.
parse_uint Aceita uma string terminada em nulo e tenta analisar um número não assinado desde o início.
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.
35
Machine Translated by Google
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.
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.
9. Todas as funções de análise e read_word funcionam quando a entrada é finalizada via Ctrl-D.
ÿ 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.
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 25 Como você pode trabalhar com uma pilha de hardware? Descreva as instruções que você pode usar.
mov [rax], 0
cmp [rdx], bl
mov bh, bl
mov al, al
36
Machine Translated by Google
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
• 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
ÿ Pergunta 34 Como você verifica se um número inteiro está contido em um determinado intervalo (x, y)?
ÿ Questão 40 Usando exatamente duas instruções (a primeira é neg), obtenha um valor absoluto de um inteiro
armazenado em rax.
ÿ 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:
• ip, sinalizadores;
• 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:
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:
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.
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.
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.
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.
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.
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
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).
lgdtcs:[_gdtr]
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
As diretivas Align controlam o alinhamento, cuja essência explicaremos mais adiante neste livro.
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.
• 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).
• 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.
43
Machine Translated by Google
Capítulo 3 ÿ Legado
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
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).
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).
44
Machine Translated by Google
Capítulo 3 ÿ Legado
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.
• 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.
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.
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.
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.
Se conseguirmos isso, podemos carregar pedaços de programas em qualquer parte livre da memória,
mesmo que estejam usando endereçamento absoluto.
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.
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.
•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
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.
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.
•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.
49
Machine Translated by Google
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:
ÿ 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.
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
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.
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.
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.
seção .data
correta: dq -1
seção .text global
_start _start: mov
rax,
[0x400000-1]
51
Machine Translated by Google
; 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.
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.
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
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.
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
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:
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:
54
Machine Translated by Google
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.
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
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.
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.
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.
56
Machine Translated by Google
Após uma chamada ao mmap, o rax manterá um ponteiro para as páginas recém-alocadas.
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.
•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.
#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:
57
Machine Translated by Google
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:
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:
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
Assim, podemos digitar %define PROT_READ 0x01 no início do arquivo assembly para utilizar esta constante sem memorizar
seu valor.
58
Machine Translated by Google
%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
; 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 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 51 O que acontecerá se você tentar modificar o código de execução do programa durante sua execução?
60
Machine Translated by Google
ÿ 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
ÿ Questão 67 Escreva os programas a seguir, todos mapeando um arquivo de texto input.txt contendo um inteiro x em
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.
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.
% definir cat_count 42
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.
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.
64
Machine Translated by Google
É 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.
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.
1
D. Knuth leva essa ideia ao extremo em sua abordagem chamada Literate Programming
65
Machine Translated by Google
% 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
dq 666
dq 555
dq 444
BITS
64% definir x 5
% se x == 10
%elif x == 15
% 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.
66
Machine Translated by Google
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).
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.
%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.
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.
% macro pushr 1
% ifidn % 1, rflags
pushf
% else
push %
1%
endif % endmacro
pushr rax
pushr rflags
67
Machine Translated by Google
A Listagem 5.10 mostra o que as duas macros da Listagem 5.9 se tornam após a instanciação.
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.
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.
%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
68
Machine Translated by Google
•%define para uma substituição diferida. Se o corpo da macro contiver outras macros, elas serão
expandido após a substituição.
•%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
% 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
•%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
%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
resultado: dq 55
Podemos usar% exitrep para sair imediatamente do ciclo. Portanto, é análogo interromper a instrução em linguagens de alto
nível.
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:
•2 é um número primo.
•Para cada corrente até o limite verificamos se nenhum i de 2 até n/2 é divisor de n.
% 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
71
Machine Translated by Google
%endif
%endrep banco
n+1 %endrep
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.
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
%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.
..@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
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:
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.
74
Machine Translated by Google
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.
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
Os arquivos ELF fornecem informações sobre um programa que podem ser observadas de dois pontos de vista:
É descrito pela tabela de seção, que pode ser acessada através de readelf -S.
– 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
É 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.
– 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.
3
Não deve ser confundido com símbolos de pré-processador!
76
Machine Translated by Google
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
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.
•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.
> nasm -f elf64 main.asm && objdump -tf -m intel main.o main.o:
formato de arquivo elf64-x86-64
77
Machine Translated by Google
TABELA DE SÍMBOLOS:
É 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.
(b)…
(c)…
(d)…
(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.
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
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 terceira coluna pode conter os resultados da desmontagem dos mnemônicos do comando assembly.
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
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
seção .dados
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
TABELA DE SÍMBOLOS:
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.
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.
No mundo Unix, esses arquivos possuem uma extensão .so (objetos compartilhados).
81
Machine Translated by Google
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
_GLOBAL_OFFSET_TABLE_ externo
função global:função
seção .rodata
seção .texto
função:
movimento
rax, 1
movimento
rdi, 1 rsi,
movimento
mensagem rdx, 14
movimento
syscall
ret
_início global
função externa
mov rdi,
10 chamada func
mov rdi, rax
mov rax, 60 syscall
> 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
Cabeçalhos de seção:
[Nº] Nome Tipo Endereço Desvio
Tamanho EntSize Alinhamento de informações de link de bandeiras
Cabeçalhos de seção:
[Nº] Nome Tipo Endereço Desvio
Tamanho EntSize Alinhamento de informações de link de bandeiras
84
Machine Translated by Google
ÿ Pergunta 73 Estude as tabelas de símbolos para um objeto compartilhado obtido usando readelf --dyn-
syms e objdump -ft.
ÿ 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.
85
Machine Translated by Google
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
1.00 segmento
• Pode ser executado e lido. Não pode ser gravado (portanto, você não pode substituir o
código).
2. 01 segmento
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.
86
Machine Translated by Google
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.
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
seção .dados
1. principal.asm
2.lib.asm
3. ditar.asm
4. dois pontos.inc
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.
•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:
(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
• 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 90 O que é uma tabela de símbolos? Que tipo de informação ele armazena?
89
Machine Translated by Google
ÿ 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
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.
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:
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.
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:
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.
92
Machine Translated by Google
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
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.
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.
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
ÿ 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 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.
95
Machine Translated by Google
À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.
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:
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
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.
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.
• 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.
97
Machine Translated by Google
• 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.
• 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.
• Carrega cs do 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:
–Base = 0
– Limite = FFFFFH
– S = 1 (Sistema)
– DPL = 0
–P=1
– L = 1 (modo longo)
–D=0
98
Machine Translated by Google
–Base = 0
– Limite = FFFFFH
– 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 103 Como o erro #PF está relacionado à troca? Como o sistema operacional o utiliza?
ÿ Pergunta 105 Por que precisamos de uma instrução separada para implementar chamadas de sistema?
99
Machine Translated by Google
ÿ Pergunta 112 Como os registros específicos do modelo são usados no mecanismo de chamada do sistema?
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.
1. Um conjunto de estados.
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.
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 estado inicial é A.
•O estado final é C.
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
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
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
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.
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.
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.
• Combine o símbolo de entrada com aqueles descritos nas regras de transição e salte para os
estados correspondentes se forem iguais.
103
Machine Translated by Google
Para implementar o autômato exemplar em montagem, primeiro o tornaremos total, conforme mostrado na Figura 7-3
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
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
104
Machine Translated by Google
_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
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.
ÿ Questão 114 Desenhe uma máquina de estados finitos para contar as palavras na string de entrada. O comprimento de entrada
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
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.
1. Uma carta.
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
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:
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:
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.
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.
107
Machine Translated by Google
ˆ
– 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.
– 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.
108
Machine Translated by Google
ÿ Pergunta 117 Usando qualquer linguagem que você conheça, implemente um análogo do grep baseado na construção do NFA . Você
ÿ 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?
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.
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
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:
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:
110
Machine Translated by Google
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)
: 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)
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) )
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
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
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
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).
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
113
Machine Translated by Google
dq xt_plus dq
xt_exit
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.
próximo:
mov w, pc
adicionar pc, 8 ; o tamanho da célula é 8 bytes mov w,
[w] jmp [w]
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.
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.
docol:
sub rstack, 8 mov
[rstack], pc add w, 8 ;
mov pc, w jmp próximo 8
114
Machine Translated by Google
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.
%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
115
Machine Translated by Google
; Inicializa os registradores
xt_init: dq i_init i_init:
mov
rstack, rstack_start mov pc,
main_stub jmp next
; Sai do programa
xt_bye: dq i_bye
i_bye:
mov rax, 60
xor rdi, rdi
syscall
116
Machine Translated by Google
mov w, [pc]
adicionar computador, 8
jmp [w]
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.
117
Machine Translated by Google
É 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.
– Nome da palavra;
- 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 .
Compare duas maneiras de definir o dicionário Forth: sem macros (mostradas na Listagem 7-8) e com elas (mostradas na Listagem 7-9).
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
seção .texto
mais_impl:
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.
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.
esboço_do programa: dq 0
xt_interpretador: dq.interpretador
.interpretador: dq interpreter_loop
119
Machine Translated by Google
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.
•.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.
120
Machine Translated by Google
•Entrada/saída:
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.
1. Precisamos alocar outras células 65536 Forth para a parte extensível do dicionário.
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.
Cólon:
1: palavra ÿ stdin
4: Atualize last_word.
5: estado ÿ 1;
121
Machine Translated by Google
2: estado ÿ 0;
1: loop do compilador:
4: saída
6: xt ÿ cf a(endereço)
8: interpretar palavra
9: mais
10: [aqui] ÿ xt
12: mais
15: [aqui] ÿn
17: mais
20: [aqui] ÿn
22: mais
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
• drop( a -- )
•dup(a -- aa )
– + (yx--[x + y])
– *
* ( yx -- [ x você] )
– / (yx--[x/y])
–% ( yx -- [ x mod y ] )
–
- (yx -- [x - y] )
•Lógica:
– =( 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.
•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) é.
•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.
123
Machine Translated by Google
•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.
•here Aponta para a última célula da palavra que está sendo definida atualmente.
•create (flags name -- ) Cria uma entrada no dicionário cujo nome é o novo
nome. Apenas a sinalização imediata é implementada no ATM.
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
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 134 Qual é a diferença de implementação entre palavras incorporadas e dois pontos?
ÿ Pergunta 136 Quais são os dois modos distintos em que Forth está operando?
ÿ 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
125
Machine Translated by Google
ÿ Pergunta 145 Adicione uma palavra incorporada para verificar o restante de uma divisão de dois números. Escreva uma palavra para
ÿ 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).
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 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.
•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:
– -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.
À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:
Este comando fará uma compilação completa, incluindo geração e vinculação de arquivos de objeto.
•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.
•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.
•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; }
#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.
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
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.
*
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;
}
•Código ASCII de caracteres, escrito entre aspas simples, por exemplo, 'a'.
132
Machine Translated by Google
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.
#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
Para esta breve seção introdutória, consideraremos que todos os tipos em C se enquadram em uma destas categorias:
•Tipos de ponteiro.
•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.
133
Machine Translated by Google
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.
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.
if (x == 0)
puts("X é zero"); outro
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.
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
8.3.2 enquanto
Uma instrução while é usada para fazer ciclos.
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.
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.
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
int eu;
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.
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.
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 */
}
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.
int eu;
intj;
para (eu = 0; eu < 100; eu++ )
136
Machine Translated by Google
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.
int eu = 10;
mudar (eu) {
caso 1: /* se i for igual a 1...*/
puts("É um");
quebrar; /* A pausa é obrigatória */
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.
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
#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;
}
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
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.
#include <stdio.h>
int é_fib(int n) {
intuma = 1;
int b = 1; se
(n == 1) retornar 1;
se (n == a || n == b) retornar 1; b = uma;
uma =
t + uma;
} retornar 0;
int principal(void)
{ int i;
para (eu = 1; eu <11; eu = eu + 1) {verificar
(eu);
} retornar 0;
}
139
Machine Translated by Google
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.
4 = 2;
"abc"="bcd";
quadrado(3) = 9;
1 + 3;
42;
quadrado(3);
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.
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
•Atribuir c a b;
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.
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
40/2/4
((40/2)/4)
•Baseado no significado
– Operadores Aritméticos: * / + - % ++ --
– Operadores diversos:
141
Machine Translated by Google
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.
#include <stdio.h>
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.
142
Machine Translated by Google
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.
retornar a + b;
}
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.
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.
143
Machine Translated by Google
/* 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.
#define MY_CONST_VALUE 42
144
Machine Translated by Google
•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.
#define QUADRADO( x ) (x * x)
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.
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.
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
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 153 Por que o break é necessário no final de cada caso de switch ?
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.
• 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.
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.
• 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
número do caracter = 5;
char símbolo_código = 'x';
char null_terminator = '\0';
2. interno
• Um número inteiro.
• 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 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
4. flutuar
• Seu intervalo é: ±1, 17549 × 10ÿ38 … ± 3, 40282 × 1038 (precisão de aproximadamente seis dígitos).
5. duplo
• Seu intervalo é: ±2, 22507 × 10ÿ308 … ± 1, 79769 × 10308 (precisão de aproximadamente 15 dígitos).
6. longo duplo
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
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,
intuma = 4;
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
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.
ÿ 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.
/* 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 */
150
Machine Translated by Google
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
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:
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
interno x = 10;
int*px = &x; /* Pegou o endereço de `x` e atribuiu-o a `px` */
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 = ...;
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.
intuma = 10;
vazio* pa = &a;
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.
• 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
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.
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.
int minhamatriz[1024];
int y = minhamatriz[64];
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].
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
Não é novidade que a mesma função pode ser reescrita mantendo o mesmo comportamento, conforme mostrado na Listagem 9.10.
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.
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.
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.
154
Machine Translated by Google
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.
enum cores {
VERMELHO,
VERDE,
AZUL,
MAGENTA,
AMARELO
};
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?
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
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).
•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.
tamanho_ts;
int eu;
...
para(eu = 0; eu <s; eu++) {
...
}
CADEIA DE ÍNDICE
0 "ls"
1 "-eu"
2 "-a"
156
Machine Translated by Google
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.
A Listagem 9-17 mostra um exemplo. Este programa imprime todos os argumentos fornecidos, cada um em uma linha separada.
#include <stdio.h>
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.
#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
#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
ÿ 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á")
interno x = 10;
tamanho_t t = tamanhode(x=90);
ÿ Pergunta 161 Como calcular quantos elementos um array armazena usando sizeof?
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.
...
...
158
Machine Translated by Google
É 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.
•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).
•Desreferenciar este novo ponteiro. Agora você pode atribuir um novo valor a x.
#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.
interno x;
interno;
159
Machine Translated by Google
ÿ 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.
ÿ 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* .
#include <stdio.h>
160
Machine Translated by Google
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 é:
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.
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.
typedef void(proc)(void);
...
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
typedef duplo(proc)(int);
/* declaração */
proc meuproc;
/* ... */
/* definição */
double meuproc(int x) { return 42,0 + x; }
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).
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.
#include <stdio.h>
matriz interna[] = {1,2,3,4,5};
162
Machine Translated by Google
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.
#include <stdio.h>
matriz interna[] = {1,2,3,4,5};
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.
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.
#include <stdio.h>
matriz interna[] = {1,2,3,4,5};
163
Machine Translated by Google
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.
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.
164
Machine Translated by Google
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 .
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.
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.
#include <stdio.h>
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?
eu
=1
166
Machine Translated by Google
•Uma função principal que chama os cálculos do produto e exibe seus resultados.
•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.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.
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
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.
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.
ÿ 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
Você também pode atribuir 0 a todos os campos de uma estrutura, conforme mostrado na Listagem 9.43.
...
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.
par de estrutura
{char a;
caractere 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.
...
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
ÿ 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
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.
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;
};
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
união vec3d
{ struct
{ double x;
duplo y;
duplo z; }
nomeado;
duplo bruto[3];
};
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.
união vec3d
{ struct
{ double x;
duplo y;
duplo z;
};
duplo bruto[3];
};
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.
enum luz {
VERMELHO,
VERMELHO E AMARELO,
AMARELO,
VERDE,
171
Machine Translated by Google
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.
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.
172
Machine Translated by Google
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.
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.
>>> "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
173
Machine Translated by Google
>>> 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.
• 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.
• 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
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 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.
O operador ## é ainda mais interessante. Isso nos permite formar nomes de símbolos dinamicamente. Listagem 9-55
mostra um exemplo.
#define x1 "Olá"
#define x2 " Mundo"
#define str(i)x##i
175
Machine Translated by Google
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.
DEFINE_PAIR(int)
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.
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
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.
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.
estrutura pai {
const char* campo_parent;
};
estrutura filho {
estrutura base pai;
const char* campo_filho;
};
177
Machine Translated by Google
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.
#include <stdio.h>
int principal(void) {
interno x = 101;
duplo y = 42,42;
imprimir(x);
imprimir(y);
retornar 0;
}
178
Machine Translated by Google
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 166 Como criamos um literal dos tipos unsigned long, long e long long?
ÿ 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?
179
Machine Translated by Google
ÿ 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 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.
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.
estruturar um {
estrutura b*foo;
};
estrutura b {
estrutura uma* barra;
};
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.
/* 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.
...
int z = quadrado(5);
2. Primeiro crie um protótipo, depois chame e então a função é definida (veja Listagem 10-6).
...
int z = quadrado(5);
...
*
int quadrado(int x) {retorna x x; }
182
Machine Translated by Google
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.
*
int quadrado(int x) {retorna x x; }
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.
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.
struct lista_t;
Esses tipos têm um caso de uso muito específico que iremos elaborar no Capítulo 13.
183
Machine Translated by Google
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.
TABELA DE SÍMBOLOS:
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
TABELA DE SÍMBOLOS:
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.
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?
externo int z;
*
int quadrado(int x) {retorna x x+z; }
185
Machine Translated by Google
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.
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.
> 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
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.
void print_one(void);
vazio print_dois(void);
int principal(void) {
print_one();
print_dois();
retornar 0;
}
#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.
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
#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.
•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.
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.
188
Machine Translated by Google
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:
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.
#include <stdio.h>
int principal(void)
{
printf("Olá mundo!\n");
retornar 0;
}
189
Machine Translated by Google
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.
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.
ÿ 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.
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
ÿ 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.
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.
#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.
#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.
#ifndef MINHAFLAG
/*código*/
#outro
/*outro código*/
#fim se
191
Machine Translated by Google
/* 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.
# 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.
•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.
192
Machine Translated by Google
A Listagem 10.32 mostra uma proteção de inclusão para algum arquivo file.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
Elaboramos um arquivo include típico para você observar sua estrutura. A Listagem 10-33 mostra este exemplo.
#ifndef _PAIR_H_
#define _PAIR_H_
#include <stdio.h>
par de estruturas {
interno x;
interno;
};
#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
•Muitas vezes torna o código menor, mas também muito menos legível.
•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)?
int foo() {
#outro
void foo() {
#fim se
/* ... */
}
2. Você deve encontrar todas as ocorrências da macro min, que é definida como
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
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
#include <malloc.h>
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.
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
#include <malloc.h>
*out_count = cnt;
matriz de retorno;
}
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
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.
•A entrada não contém nada além de números inteiros separados por espaços em branco.
•list_get obtém um elemento por índice ou retorna 0 se o índice estiver fora dos limites da lista.
•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.
•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
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.
•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!
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.
intglobal_int;
estático int módulo_int;
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.
198
Machine Translated by Google
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.
{
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:
•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:
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
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 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 197 Qual é o conceito de arquivos de cabeçalho? Para que eles são normalmente usados?
ÿ Pergunta 200 Escreva um programa em assembly que exiba todos os argumentos da linha de comando, cada um em
uma linha separada.
ÿ Pergunta 202 Descreva o mecanismo que permite ao programador usar funções externas, incluindo cabeçalhos relevantes.
ÿ Pergunta 206 Qual é o efeito das variáveis e funções globais estáticas na tabela de símbolos?
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.
ÿ 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]
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 é 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.
Capítulo 11 ÿ Memória
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.
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*?
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.
int gatosAreCool = 0;
int* ptr = &catsAreCool;
*ptr = 1; /* gatosAreCool = 1 */
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
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.
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.
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.
se(x) {...}
se(NULO!=x) {...}
se(0!=x) {...}
se(x!=NULO) {...}
se(x!=0) {...}
int* máximo;
int* cur;
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:
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
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.
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.
...
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.
206
Machine Translated by Google
Capítulo 11 ÿ Memória
• 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.
• 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.
– 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
– 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* 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.
#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.
ÿ 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.
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:
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.
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.
#include <string.h>
#include <malloc.h>
estrutura int_array {
tamanho_t tamanho;
matriz interna[];
};
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.
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:
2. Vá para a direita até o primeiro parêntese de fechamento. Encontre seu par à esquerda. Interpretar
uma expressão entre esses parênteses.
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.
Expressão Interpretação
fp Primeiro identificador.
(*fp) É 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.
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
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.
2. Podemos criar uma string em uma pilha, em uma variável local. Listagem 11-20
mostra um exemplo.
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.
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.
#include <malloc.h>
#include <string.h>
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?
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.
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:
• interno;
• _t.
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:
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.
214
Machine Translated by Google
Capítulo 11 ÿ Memória
#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.
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).
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.
int smth[]={1,2,3,4,5};
ARQUIVO* f = fopen( "olá.img", "rwb" );
• 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.
– 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.
• 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.
• 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.
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:
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.
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.
11.7.2 Atribuição
A entrada contém um número arbitrário de inteiros.
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.
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.
• Ponteiros de função.
• A palavra-chave estática para funções que você deseja usar apenas em um módulo.
• O fluxo de entrada contém apenas números inteiros separados por caracteres de espaço em branco.
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.
var contagem = 0;
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
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 219 Qual é a diferença entre 0 no contexto de ponteiro e 0 como um valor inteiro?
ÿ Pergunta 225 A região de dados constantes geralmente é protegida contra gravação por hardware?
220
Machine Translated by Google
CAPÍTULO 12
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.
•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.
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.
Neste capítulo vamos explorar essas três facetas das linguagens e aplicá-las a C.
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).
•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
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:
222
Machine Translated by Google
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'
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.
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.
<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
<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>
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.
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.
224
Machine Translated by Google
<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>
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:
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).
#include <stdio.h>
#include <stdbool.h>
bool aceitar(char c) { if
(*stream == c) { stream+
+; retornar
verdadeiro;
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
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:
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
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.
Vejamos a gramática ingênua para números naturais com adição e multiplicação na Listagem 12-5.
<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>
227
Machine Translated by Google
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.
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.
"-"
<expr0> = <expr1> "+" <expr> | <expr1> <expr1> ::= <expr> | <expr1>
<atom> "*" <atom> ::= "(" <expr1> | <átomo> "/" <expr1> | <átomo>
<expr> ")" | <NÚMERO>
•<expr0> é uma expressão sem <, >, == e outros terminais, que estão presentes no
primeira regra.
228
Machine Translated by Google
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.
Qualquer expressão regular também pode ser descrita em termos de gramáticas livres de contexto.
a A b ::= ayb
229
Machine Translated by Google
A diferença entre os níveis 2 e 1 é que o não-terminal do lado esquerdo é substituído por y somente
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.
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).
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
É 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.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:
• 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
•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.
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.
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
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.
• 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).
•Divisão por 0.
•O valor de retorno de uma função, que não possui uma instrução de retorno executada.
•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
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.).
•Quando os argumentos da função são avaliados, mas a função não iniciou seu
execução ainda.
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.
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
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.
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.
•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
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:
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.
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
A Listagem 12-13 mostra como usá-lo para alterar localmente a estratégia de escolha de alinhamento usando o pacote
pragma.
#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.
#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
#pacote pragma(push, 4)
estrutura mystr {
uint16_ta;
int64_t b;
};
#pragma pack(pop)
237
Machine Translated by Google
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.
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.
•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.
238
Machine Translated by Google
#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.
#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 245 Como escrevemos um analisador descendente recursivo tendo a descrição gramatical em BNF?
239
Machine Translated by Google
ÿ Pergunta 248 Por que as linguagens regulares são menos expressivas do que as gramáticas livres de contexto?
ÿ Pergunta 252 O que é comportamento não especificado e como ele difere do comportamento indefinido?
ÿ Pergunta 258 Qual é o alinhamento? Como isso pode ser controlado no C11?
240
Machine Translated by Google
CAPÍTULO 13
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.
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
• 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.
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.
• 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.
242
Machine Translated by Google
O restante desta seção concentra-se em diferentes recursos de linguagem e nomenclatura e uso associados.
convenções.
• Cabeçalho relacionado.
• Biblioteca C.
• Outras bibliotecas.h.
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
– Primeiro tente minimizar as perdas de memória causadas pelo preenchimento da estrutura de dados.
– À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.
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.
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.
• 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
• Incluir as unidades de medida pode ser uma boa ideia – por exemplo,
uint32_t atraso_msecs.
• 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.
• 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.
– 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
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.
documento/ Documentação
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.
246
Machine Translated by Google
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.
/** 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
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:
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
#ifndef _STACK_H_
#define _STACK_H_
#include <stddef.h>
#include <stdint.h> #include
<stdbool.h>
lista de estruturas;
#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).
#include <malloc.h>
#include "stack.h"
249
Machine Translated by Google
} valor de retorno;
} retornar 0;
}
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
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.
• 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.
– Amigável à paralelização.
- Mais rápido.
– À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.
#include <assert.h>
int principal() {
interno x = 0;
afirmar(x! = 0);
retornar 0;
}
251
Machine Translated by Google
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:
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.
enum div_res{
DIV_OK,
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.
enum error_code {
ERRO1,
ERRO2
};
...
enum error_code err;
...
mudar (errar) {
caso ERRO1: ... quebra;
caso ERRO2: ... quebra;
252
Machine Translated by Google
/* alternativamente */
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.
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.
}
}
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
Existe uma técnica clássica de recuperação de erros, que requer o uso de goto. A Listagem 13-12 mostra um exemplo.
{
if (!doA()) vai para sair;
if (!doB()) vai para revertA;
if (!doC()) vai para revertB;
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.
• 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
• 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.
Compare-o com outra versão com a mesma lógica, dividida em duas funções, mostrada na Listagem 13-14.
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 primeira versão requer um nome de arquivo, o que significa que você não pode usá-lo para gravar
em stderr ou stdout.
255
Machine Translated by Google
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.
#include <stdio.h>
enum estatística {
STAT_OK,
STAT_ERR_OPEN,
STAT_ERR_CLOSE,
STAT_ERR_WRITE
};
retornar STAT_OK;
}
No caso de múltiplas gravações na função dump, a função ficará sobrecarregada e, portanto, menos legível.
256
Machine Translated by Google
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.
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).
257
Machine Translated by Google
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.
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).
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.
#include <stdint.h>
#include <stdio.h>
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 */
};
258
Machine Translated by Google
/* serializador */ enum
write_status {
ESCREVER_OK = 0,
ÿ 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.
struct mem
{ struct mem* próximo;
tamanho_t
capacidade; bool é_livre;
};
259
Machine Translated by Google
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:
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.
– 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.
#ifndef _MEM_H_
#define _MEM_H_
#define _USE_MISC
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/mman.h>
estrutura mem;
260
Machine Translated by Google
#define DEBUG_FIRST_BYTES 4
#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.
#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 <
((char*)endereço)
[ sizeof( struct mem_t ) + i ] ); putc('\n', f);
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
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.
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.
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).
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
estrutura s {
char vals[100];
};
estrutura sf(int x) {
struct é meu;
meus.vals[10] = 42;
devolva o meu;
}
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.
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.
função:
empurrar rbp
mov rbp, rsp
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.
267
Machine Translated by Google
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.
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.
int principal(void) {
int x = máximo( 42, 999 );
retornar 0;
}
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
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
Após um pouco de limpeza, obtemos um código assembly puro e mais legível, que é mostrado na Listagem 14-6.
...
máximo:
empurrar rbp
mov rbp, rsp
sub rsp, 3984
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
chamada máxima
empurrar rbp
270
Machine Translated by Google
•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.
271
Machine Translated by Google
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_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.
#include <stdarg.h>
#include <stdio.h>
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
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?
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.
#include <stdarg.h>
#include <stdio.h>
va_start(args,str);
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
• 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.
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.
ÿ 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.
274
Machine Translated by Google
#include <stdio.h>
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:
; volume = 4
movimento
DWORD PTR [rsp+0xc],0x4
; volume++
mov
eax,DWORD PTR [rsp+0xc]
adicionar eax,0x1
movimento
DWORD PTR [rsp+0xc],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>
; 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
•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:
•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.
#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
jmp_bufjb;
vazio f(vazio)
{ setjmp( jb );
}
vazio g(vazio)
{ f();
longjmp(jb);
}
#include <stdio.h>
#include <setjmp.h>
jmp_buf buf;
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
principal:
empurrar RBP
movimento
rbp, rsp
sub rsp,0x20
; var = 0
movimento
PTR DWORD [rbp-0x4],0x0
;b=0
movimento
PTR DWORD [rbp-0x8],0x0
; 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
1
2
3
Com otimizações,
principal:
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>
; retornar 0
xou eax, eax
adicionar rsp,0x18
ret
.filial:
movimento
eax,DWORD PTR [rsp+0xc]
;b=b+1
adicione eax,0x1
movimento
DWORD PTR [rsp+0xc],eax
279
Machine Translated by Google
; longjmp( buf, 1 )
movimento
sim,0x1
movimento
edi,0x600a40
ligue para 400490 <longjmp@plt>
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:
•Em exatamente uma unidade de tradução (ou seja, um arquivo .c), adicione a declaração externa
280
Machine Translated by Google
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,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);?
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.
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