ABI do RV32 e Tipos de Dados em C: Tamanhos, Alinhamento e Layout

TL;DR

O ABI é o contrato. Se você violá-lo (mesmo sem querer), surgem “bugs estranhos” que parecem corrupção de pilha, ponteiros ruins ou travamentos aleatórios.


1. RV32 em uma frase

Flags típicas do compilador

2. Funções dos registradores (a parte que você precisa memorizar)

RISC-V tem 32 registradores inteiros: x0..x31.

2.1. Quem preserva o quê? (A regra da “responsabilidade”)

3. Regras da pilha (a segunda parte que você precisa memorizar)

3.1. A pilha cresce para baixo

1
2
3
4
5
6
high addresses
   ...
   registradores salvos
   variáveis locais
sp → topo atual da pilha
low addresses

3.2. Alinhamento

O ABI exige que o ponteiro de pilha (sp) esteja alinhado a 16 bytes sempre que você chamar uma função.

Por que 16 bytes? Por que não só 4? Pense na pilha como um caminhão de entrega.

“Nos limites de chamada” significa: Antes de saltar para uma nova função, você precisa garantir que sp seja múltiplo de 16. Se você empilhar 1 word (4 bytes), precisa adicionar 12 bytes de padding para que a próxima função comece alinhada em 16 bytes.

4. Tamanhos de tipos C no RV32 (ILP32)

Estes são os tamanhos típicos (confirme na sua toolchain):

Tipo CBytes típicos (RV32 ILP32)
char1
short2
int4
long4
long long8
void*4
size_t4
float4
double8

5. Na prática: medir tamanhos e alinhamento

Crie:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// src/types.c
#include "types.h"
#include "uart.h"

// Compute the byte offset of a member inside a struct type.
// This does not access memory; it just uses the member's address from a null base.
#define OFFSETOF(type, member) ((u32)(usize)&(((type *)0)->member))

static void show_type(const char *name, u32 size, u32 align) {
  // Print a "name size=... align=..." line for one type.
  uart_puts(name);
  uart_puts(" size=");
  uart_putdec(size);
  uart_puts(" align=");
  uart_putdec(align);
  uart_puts("\n");
}

// Convenience macro: stringize the type name and show its size and alignment.
#define SHOW(T) show_type(#T, (u32)sizeof(T), (u32)_Alignof(T))

struct A {
  // Likely introduces padding between fields due to alignment.
  u8  a;
  u32 b;
  u16 c;
};

struct B {
  // Same fields as A but reordered to reduce padding.
  u32 b;
  u16 c;
  u8  a;
};

int main(void) {
  // Show basic scalar sizes/alignments for this target/compiler.
  SHOW(char);
  SHOW(short);
  SHOW(int);
  SHOW(long);
  SHOW(long long);
  SHOW(void *);
  SHOW(float);
  SHOW(double);

  // Compare layout of two structs with the same fields in different orders.
  uart_puts("\nstruct A size=");
  uart_putdec((u32)sizeof(struct A));
  uart_puts(" off(a)=");
  uart_putdec(OFFSETOF(struct A, a));
  uart_puts(" off(b)=");
  uart_putdec(OFFSETOF(struct A, b));
  uart_puts(" off(c)=");
  uart_putdec(OFFSETOF(struct A, c));
  uart_puts("\n");

  uart_puts("\nstruct B size=");
  uart_putdec((u32)sizeof(struct B));
  uart_puts(" off(b)=");
  uart_putdec(OFFSETOF(struct B, b));
  uart_puts(" off(c)=");
  uart_putdec(OFFSETOF(struct B, c));
  uart_puts(" off(a)=");
  uart_putdec(OFFSETOF(struct B, a));
  uart_puts("\n");

  return 0;
}

Compile e rode no QEMU:

1
2
3
4
5
riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 -T src/link.ld \
  src/start.s src/uart.c src/types.c -o build/types_rv32.elf

qemu-system-riscv32 -M virt -nographic -bios none -kernel build/types_rv32.elf

5.1. O que você deve observar

5.2. Mergulho profundo: alinhamento de tipo vs. alinhamento da pilha

Você observou double align=8, mas o ABI exige que sp esteja alinhado em 16 bytes. Essa confusão é comum. Vamos separar o Conteúdo do Contêiner.

5.2.1. A regra do conteúdo (alinhamento de tipo)

Cada variável tem um “alinhamento natural”.

Se você violar isso, a CPU gera uma exceção de acesso desalinhado (ou faz uma leitura em duas partes, mais lenta).

5.2.2. A regra do contêiner (alinhamento da pilha)

O stack frame é o contêiner para todas essas variáveis locais. Para ser um “contêiner universal”, a pilha precisa estar alinhada ao requisito mais estrito de qualquer variável que possa guardar.

A solução: O ABI do RISC-V força a pilha a estar alinhada em 16 bytes (divisível por 16). Como 16 é divisível por 1, 2, 4 e 8, um stack frame novo é garantido como ponto de partida seguro para qualquer tipo de dado padrão, incluindo vetores SIMD de 128-bit (float128 ou v128), sem ajustes complexos.

6. Padding em structs explicado (com diagrama)

6.1. Exemplo: struct A

1
2
3
4
5
struct A {
  uint8_t  a; // 1 byte
  uint32_t b; // precisa de alinhamento 4 bytes
  uint16_t c; // 2 bytes
};

Um layout comum em RV32:

Pense na memória como uma grade de palavras de 4 bytes (32-bit).

OffsetByte 0Byte 1Byte 2Byte 3Conteúdo
+0apadpadpada ocupa 1 byte. Pulamos 3 bytes para a próxima linha começar alinhada.
+4bbbbb (4 bytes) encaixa perfeitamente em uma nova palavra.
+8ccpadpadc (2 bytes) fica aqui. Preenchemos o final para alinhar o tamanho total da struct.

6.2. Exemplo complexo: misturando char, int, long, long long, double

Vamos ver uma struct usando todos os tipos que você perguntou, distinguindo long (32-bit) de long long (64-bit).

1
2
3
4
5
6
7
struct Mixed {
  char c;       // 1 byte
  int i;        // 4 bytes
  long l;       // 4 bytes (no RV32)
  long long ll; // 8 bytes (precisa de alinhamento 8)
  double d;     // 8 bytes (precisa de alinhamento 8)
};

Análise de layout:

  1. c fica em +0.
  2. i precisa de alinhamento 4. O próximo slot disponível é +1, então pulamos 3 bytes. i começa em +4.
  3. l precisa de alinhamento 4. Ele encaixa perfeitamente em +8. Termina em +12.
  4. ll precisa de alinhamento 8. +12 não é divisível por 8 (12 % 8 = 4). Precisamos de 4 bytes de padding. ll começa em +16.
  5. d precisa de alinhamento 8. ll termina em +24. 24 é divisível por 8. d começa em +24 imediatamente.

Grade de memória:

OffsetByte 0Byte 1Byte 2Byte 3Conteúdo
+0cpadpadpadAlinhando para i
+4iiii
+8lllllong tem 4 bytes no RV32
+12padpadpadpadAlinhando para ll (precisa ser % 8)
+16ll (lo)lllllllong long (primeira metade)
+20ll (hi)lllllllong long (segunda metade)
+24d (lo)ddddouble (primeira metade)
+28d (hi)ddddouble (segunda metade)

Tamanho total: 32 bytes.

Por que o padding existe:

7. Endianness e o que isso significa para C

A maioria dos alvos RV32 é little-endian.

Se você armazenar 0x11223344 na memória, os bytes aparecem como:

address+0+1+2+3
bytes44332211

7.1. Na prática: confirmar endianness

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/endian.c
#include "types.h"
#include "uart.h"

static void puthex8(u8 v) {
  // Print one byte as two lowercase hex digits.
  const char *digits = "0123456789abcdef";
  uart_putc(digits[(v >> 4) & 0x0f]);
  uart_putc(digits[v & 0x0f]);
}

int main(void) {
  // Store a known 32-bit pattern and examine its byte order in memory.
  u32 x = 0x11223344u;
  u8 *p = (u8 *)&x;
  // Emit the four bytes to reveal endianness (LSB first on little-endian).
  puthex8(p[0]); uart_putc(' ');
  puthex8(p[1]); uart_putc(' ');
  puthex8(p[2]); uart_putc(' ');
  puthex8(p[3]); uart_putc('\n');
  return 0;
}

Compile/rode:

1
2
3
4
5
riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 -T src/link.ld \
  src/start.s src/uart.c src/endian.c -o build/endian_rv32.elf

qemu-system-riscv32 -M virt -nographic -bios none -kernel build/endian_rv32.elf

8. ABI encontra o assembly: parâmetros e valores de retorno

Considere:

1
uint32_t add_u32(uint32_t a, uint32_t b) { return a + b; }

No nível do ABI:

No disassembly, você costuma ver:

9. Exercícios

  1. Altere struct A adicionando um uint8_t d; no final. Preveja o novo tamanho antes de compilar.
  2. Crie uma versão empacotada:
    1
    
    struct __attribute__((packed)) P { u8 a; u32 b; };
    
  3. Compare sizeof(struct P) com a versão não empacotada.
  4. Escreva uma função que retorne um u64. Observe quais registradores carregam o valor de retorno.

9.1. Como testar suas respostas

10. Resumo

Você aprendeu o “contrato” do ABI RV32 ILP32: funções dos registradores, regras da pilha e como tipos C mapeiam para bytes.

A seguir: C → assembly + otimizações - você vai ver como -O muda o que aparece no disassembly, e como volatile realmente afeta o código gerado.