C estruturado e ferramentas
Tipos, conversões, módulos, compilação separada e bibliotecas estáticas em C.
Um contador de ticks precisa de um inteiro que persista entre eventos. Uma posição precisa de coordenadas que possam diminuir. Um registo do teclado precisa de oito bits. A escolha do tipo e do módulo determina como o programa representa e altera estes dados.
De C++ para C
C tem funções, estruturas, apontadores e controlo de fluxo. Não tem classes, referências de C++, sobrecarga de funções nem new e delete. Uma struct guarda dados; funções recebem o seu endereço para os consultar ou alterar. Para alocar memória usamos malloc e para a devolver usamos free.
C e C++ partilham muita sintaxe, mas não aceitam todos os mesmos programas. Em C, escreve struct position se não tiveres criado um typedef. A declaração int f(void) indica que f não recebe argumentos. Os protótipos permitem conferir os tipos usados nas chamadas.
#include <stdbool.h>
#include <stdint.h>
typedef struct {
int16_t x;
int16_t y;
} position;
bool position_equal(position a, position b) {
return a.x == b.x && a.y == b.y;
}
Esta função recebe cópias das posições, por isso alterar a.x dentro dela não altera a posição do chamador. Para alterar um objeto do chamador, a função precisa de receber um apontador para esse objeto.
Tipos e saída formatada
O tamanho de int depende da implementação. Quando o tamanho faz parte do contrato de um dispositivo, usa os tipos de <stdint.h>: uint8_t para um byte sem sinal, uint16_t para 16 bits e uint32_t para 32 bits. Usa size_t para tamanhos e índices de objetos na memória.
printf não deduz os tipos pela cadeia de formato. Cada conversão tem de corresponder ao argumento: %d recebe int, %u recebe unsigned int, %zu recebe size_t e %p recebe void *. Para tipos de largura fixa, <inttypes.h> fornece macros como PRIu32.
#include <inttypes.h>
#include <stdio.h>
int main(void) {
uint32_t ticks = 300;
uint8_t status = 0xA5;
printf("ticks=%" PRIu32 " status=0x%02X\n",
ticks, (unsigned)status);
return 0;
}
O cast explicita o tipo pedido por %X, por isso o programa imprime ticks=300 status=0xA5. Uma incompatibilidade de formato não é apenas uma apresentação errada, porque pode causar comportamento indefinido. Ativa os avisos do compilador.
Promoções e conversões de inteiros
Antes de muitas operações, C promove valores mais pequenos do que int. Num sistema onde int representa todos os valores de uint8_t, um byte torna-se um int. A operação deixa de ter apenas oito bits.
Considera uint8_t value = 0x5A. O complemento de oito bits seria 0xA5. Porém, ~value opera sobre o valor promovido. Com int de 32 bits e representação em complemento para dois, o resultado tem os bits de 0xFFFFFFA5. Deslocar esse valor negativo à direita tem resultado definido pela implementação em C (C11 §6.5.7): o gcc do laboratório documenta extensão de sinal, mas código portátil não pode contar com esse comportamento. Não derives um resultado portátil a partir de um deslocamento de um inteiro negativo.
Para complementar exatamente o byte, reduz o resultado a uint8_t antes do deslocamento:
uint8_t value = 0x5A;
uint8_t result = (uint8_t)~value >> 4;
O cast produz 0xA5. Para o deslocamento, este valor promove-se a um int positivo. O resultado é 0x0A, ou 10. Converter apenas no fim não corrige um deslocamento já efetuado sobre um inteiro negativo.
Outro caso frequente mistura um inteiro negativo com um inteiro sem sinal:
int delta = -1;
unsigned int width = 10;
bool inside = delta < width;
Na comparação, delta converte-se para unsigned int. Num sistema com este tipo de 32 bits, -1 passa a 4294967295, maior do que 10. O teste dá falso. Valida primeiro delta >= 0 e só depois compara (unsigned int)delta < width. Para posições com movimento negativo, mantém as contas num tipo com sinal até validares os limites.
Apontadores, arrays e tamanho
&x obtém o endereço de x. Se p aponta para um int válido, *p lê ou altera esse inteiro. p + 1 avança o tamanho de um int, não um byte. O tipo do apontador determina a unidade da aritmética.
int values[] = {10, 20, 30};
int *p = values;
p++;
*p = 25;
Agora values contém {10, 25, 30}, porque p++ avançou um int e *p = 25 alterou o segundo elemento. Em muitas expressões, o array converte-se num apontador para o primeiro elemento. O array e o apontador continuam a ser objetos diferentes: sizeof values dá o tamanho de todo o array, mas sizeof p dá o tamanho de um endereço.
Um parâmetro int values[] é ajustado para int *values. A função não recebe o comprimento do array. Passa-o à parte e nunca acedas fora do intervalo. Podes formar um apontador para uma posição imediatamente a seguir ao fim para controlar um ciclo, mas não o podes desreferenciar.
Strings e fim do array
Uma string de C termina no carácter nulo \0. Esse byte faz parte do armazenamento, mas não do comprimento devolvido por strlen, de <string.h>. O excerto seguinte distingue os dois tamanhos:
char name[] = "LC";
size_t length = strlen(name); /* 2 caracteres antes de \0 */
size_t storage = sizeof name; /* 3 bytes, incluindo \0 */
O array contém L, C e \0. Uma função que procura o fim da string exige que esse terminador exista dentro do objeto. Não passes um array de bytes de hardware diretamente a %s ou strlen: um pacote PS/2 pode conter zeros nos dados e não tem um terminador de string.
char name[] = "LC" cria um array modificável. const char *name = "LC" aponta para um literal, que não deves alterar. Para copiar uma string para um array, reserva espaço para todos os caracteres e o terminador; um limite de bytes copiados que deixa o destino sem \0 não produz uma string válida.
Módulos e encapsulamento
O ficheiro .h declara o que outros módulos podem usar. O .c implementa essas operações. Inclui o próprio cabeçalho no .c, para o compilador conferir que a definição corresponde à declaração.
O exemplo seguinte é um módulo de contador completo e portátil, sem dependências do Minix:
#ifndef COUNTER_H
#define COUNTER_H
#include <stdint.h>
void counter_reset(void);
void counter_tick(void);
uint32_t counter_value(void);
#endif
#include "counter.h"
static uint32_t ticks;
void counter_reset(void) { ticks = 0; }
void counter_tick(void) { ++ticks; }
uint32_t counter_value(void) { return ticks; }
#include "counter.h"
#include <inttypes.h>
#include <stdio.h>
int main(void) {
counter_reset();
counter_tick();
counter_tick();
printf("%" PRIu32 "\n", counter_value());
return 0;
}
A variável ticks tem duração estática e visibilidade limitada ao ficheiro porque foi declarada com static. Os outros módulos chamam as funções, sem modificar o contador por acesso direto. Não definas uma variável global num cabeçalho incluído por vários .c. Se a partilha for necessária, declara-a com extern no cabeçalho e define-a uma única vez.
Compilar e ligar
A construção tem quatro etapas: o pré-processador expande #include e macros; o compilador traduz C para assembly; o assembler produz código objeto; o linker resolve referências entre objetos e bibliotecas. Um .o ainda pode conter chamadas a funções cuja definição está noutro objeto.
Com os três ficheiros anteriores na mesma pasta:
cc -std=c11 -Wall -Wextra -Wpedantic -c counter.c
cc -std=c11 -Wall -Wextra -Wpedantic -c main.c
cc main.o counter.o -o demo
./demo
Incluir counter.h dá a declaração, mas não acrescenta counter.c ao executável. A ligação dos dois objetos resolve as chamadas e ./demo imprime 2. Um erro de sintaxe ou de tipo aparece na compilação. Uma referência a counter_tick sem definição aparece na ligação.
Segue o diagnóstico pela fase. Se a mensagem fala de declaração em falta ou tipo incompatível, volta ao .c e ao cabeçalho. Se fala de referência indefinida a counter_tick, o problema está na linha de ligação. Compila primeiro cada fonte com cc -c e só depois liga com cc main.o counter.o -o demo. Se mudares counter.h, recompila os dois objetos, porque ambos dependem dele.
Para criar e usar uma biblioteca estática:
ar rcs libcounter.a counter.o
cc main.o -L. -lcounter -o demo
ar agrupa código objeto num arquivo. -L. acrescenta a pasta atual à procura; -lcounter procura uma biblioteca com nome como libcounter.a. Com linkers que processam arquivos por ordem, coloca a biblioteca depois dos objetos que usam os seus símbolos. A ligação estática copia para o executável o código necessário do arquivo, não liga o programa ao código fonte.
Make, Git e documentação
Um Makefile descreve alvos e dependências. Se mudares counter.h, tanto counter.o como main.o precisam de ser recompilados. As linhas das receitas começam com uma tabulação.
CC = cc
CFLAGS = -std=c11 -Wall -Wextra -Wpedantic
demo: main.o counter.o
$(CC) main.o counter.o -o demo
main.o: main.c counter.h
counter.o: counter.c counter.h
clean:
rm -f demo main.o counter.o
.PHONY: clean
make compara dependências e datas para construir o que mudou. Os Makefiles do ambiente de LC também ligam as bibliotecas de apoio necessárias; não substituas esse ambiente por este exemplo portátil.
No Git, revê git diff antes de guardar uma alteração. diff -u old.c new.c produz um patch de texto; patch aplica-o a uma base compatível. Um patch não é um executável nem resolve automaticamente alterações incompatíveis na mesma linha.
Documenta a interface com Doxygen: o que a operação faz, pré-condições, parâmetros, resultado e quem liberta a memória. Uma descrição como “incrementa contador” não explica a política de overflow nem o instante em que o contador reinicia. A documentação útil permite chamar a função sem ler a sua implementação.
Exercícios
Complementar um byte
Com uint8_t byte = 0x96, quanto vale
(uint8_t)~byte >> 3 em decimal? Considera
int capaz de representar todos os valores de
uint8_t.
Pista
Solução
0x96 tem complemento de oito bits 0x69, que vale 105. Depois do cast, a promoção produz um inteiro positivo. O deslocamento dá o quociente inteiro de 105/8, ou 13.
Erros frequentes
Fazer o complemento num inteiro com sinal e deslocar antes do cast não corresponde à expressão pedida.
Ligação e dependências do cabeçalho
main.c e counter.c incluem counter.h. Ambos compilam sem erros,
mas cc main.o -o demo falha com uma referência indefinida a
counter_tick, definida em counter.c.
- Explica a causa e corrige o comando de ligação, usando
counter.o. - Depois, alteras a declaração da função em
counter.he adaptas a definição e a chamada. Que objetos recompilas antes de construir o novo executável? Justifica.
Pista
O cabeçalho permite conferir a chamada. Que ficheiro fornece as instruções da função?
Solução
A inclusão do cabeçalho fornece a declaração, não a definição. main.o
conserva uma referência externa que o linker ainda precisa de resolver.
Usa cc main.o counter.o -o demo.
Após a alteração da interface, recompila main.c e counter.c, pois ambos
dependem de counter.h. Volta a ligar os dois objetos novos. Ligar um objeto
antigo com outro novo pode misturar chamadas e definições incompatíveis.
Confere a tua resposta:
- Distingui declaração, definição e fase de ligação.
- Liguei main.o com counter.o.
- Recompilei os dois objetos após a alteração do cabeçalho e voltei a ligar.
Comparar uma posição negativa
Num sistema com int e unsigned int de 32
bits, delta vale -2 e width é unsigned int com valor 100.
Explica por que delta < width dá falso e escreve um teste seguro para 0 ≤
delta < width.
Pista
Solução
Como unsigned int representa valores que
int não representa, delta converte-se para unsigned
int. O valor 4294967294 é maior do que 100. Um teste seguro
é delta >= 0 && (unsigned int)delta < width.
O segundo operando só é avaliado se a posição não for negativa.
Confere a tua resposta:
- Identifiquei a conversão para unsigned.
- Validei a posição negativa antes do cast.
- Mantive a comparação com o limite coerente.
Erros frequentes
Converter delta para unsigned antes de testar o sinal perde a possibilidade de detetar diretamente a posição negativa.