TamgaOS (yula)
RSS github
05 June 2026 TamgaOS ELF Biber Zig vs C

Zig vs C — ELF Anatomy

Same kernel logic, same linker script, different compilers. Inspecting both ELF outputs with Biber to understand what each compiler decided to do differently.

Project: github.com/hrasityilmaz/TamgaOs

Raw Biber Output

Both kernels inspected with Biber after building with IDT, GDT, serial port, and ISR handlers in place.

Zig Kernel

Entry: 0x00101060
Program headers: 6
Section headers: 10
.text size: 0x26E (622 bytes)
.data: 0x1E (30 bytes)
.bss: 0x808 (2056 bytes)
.rodata: 0xA8 (168 bytes)

C Kernel

Entry: 0x00101040
Program headers: 6
Section headers: 9
.text size: 0x30E (782 bytes)
.data: none (0 bytes)
.bss: 0x824 (2084 bytes)
.rodata: 0xEC (236 bytes)

Entry Point — 32 Bytes Apart

entry points
Zig kernel  →  Entry: 0x00101060
C kernel    →  Entry: 0x00101040

Difference: 0x20 = 32 bytes

Both kernels load .text at 0x00101000, but _start lands at different offsets within that section. The C kernel's entry is 32 bytes closer to the start of .text — meaning Zig places more code before _start, likely compiler-generated stubs or function preambles that LLVM emits before the entry point.

Text Section — C is Larger

.text section size
Zig kernel  →  .text  0x26E  =  622 bytes
C kernel    →  .text  0x30E  =  782 bytes

Difference: +160 bytes in C

Surprising at first — C code is more verbose than Zig? The reason is the ISR stubs. isr.asm is compiled separately and linked into both kernels the same way, so that's not it. The difference comes from how GCC and LLVM/Zig generate function prologue and epilogue code. GCC tends to be more explicit with frame setup instructions; LLVM can be more aggressive about eliminating them under ReleaseSmall.

Also, the C kernel includes isr.c separately while Zig has isr_handler inlined into idt.zig — the linker may have merged some paths differently.

The Interesting One — .data vs .bss

This is the most meaningful difference between the two kernels.

data and bss layout
Zig kernel:
  .data   0x103000  FileSize: 0x1E (30 bytes)   ← exists!
  .bss    0x104000  MemSize:  0x808 (2056 bytes)

C kernel:
  .data   0x103000  FileSize: 0x00 (0 bytes)    ← empty
  .bss    0x103000  MemSize:  0x824 (2084 bytes)

In the C kernel, static struct idt_entry idt[256] is zero-initialized by default in C — so the compiler puts it in .bss. BSS doesn't take up space in the file; the OS (or in our case, the bootloader) zeroes it at load time.

In the Zig kernel, var idt: [256]IdtEntry = undefined — Zig's undefined means "I don't care what's here, don't zero it." But when the linker sees a non-zero-initialized mutable global, it may still end up in .data. This is why the Zig kernel has a 30-byte .data section that the C kernel doesn't.

Practical difference: The C kernel's IDT table doesn't exist in the ISO file — it's created in RAM at boot time, zeroed by the loader. The Zig kernel carries some initialized data in the binary itself. Neither approach is wrong for a kernel, but it's interesting to see the compilers make different decisions from the same logical code.

Rodata — String Constants

.rodata size
Zig kernel  →  .rodata  0xA8  =  168 bytes
C kernel    →  .rodata  0xEC  =  236 bytes  (+ eh_frame: 0x1EC total LOAD)

Difference: +68 bytes in C

The C kernel's rodata is larger because GCC stores exception message strings ("#DE Divide Error\n", "#GP General Protection\n", etc.) more verbosely. It also includes more .eh_frame unwinding data — GCC generates more detailed stack unwinding information than LLVM does under ReleaseSmall.

Section Count — 10 vs 9

NrNameZig kernelC kernel
1.multiboot0x100000 · 0x180x100000 · 0x18
2.text0x101000 · 0x26E0x101000 · 0x30E
3.rodata0x102000 · 0xA80x102000 · 0xD8
4.eh_frame_hdr0x1020A8 · 0x2C0x1020D8 · 0x3C
5.eh_frame0x1020D4 · 0x9C0x102114 · 0xD8
6.data0x103000 · 0x1E ← Zig only
7.bss0x104000 · 0x8080x103000 · 0x824
8.comment0x13 bytes0x28 bytes
9.shstrtab0x50 bytes0x4A bytes

The extra section in the Zig kernel is .data. Because Zig has a .data section, .bss starts at 0x104000 instead of 0x103000 — one page higher. In the C kernel, .bss immediately follows .rodata at 0x103000.

Also note the .comment section difference: 0x13 bytes in Zig vs 0x28 bytes in C. This section contains compiler version strings — GCC writes a longer comment than LLVM.

Why Zig Has .data — The Root Cause

C — goes to .bss

static struct idt_entry idt[256];

In C, uninitialized static variables are guaranteed to be zero. Compiler puts them in .bss — no file space used, zeroed at load time.

Zig — goes to .data

var idt: [256]IdtEntry = undefined;

undefined in Zig means "don't initialize this." The compiler may still place it in .data rather than .bss depending on how it handles mutable globals with no defined initial value.

The fix if you want to match C behavior in Zig:

force .bss in Zig — zero initialize
// undefined → .data (compiler decides)
var idt: [IDT_ENTRIES]IdtEntry = undefined;

// explicitly zero → .bss
var idt: [IDT_ENTRIES]IdtEntry = std.mem.zeroes([IDT_ENTRIES]IdtEntry);

For a kernel, this distinction matters because .bss doesn't inflate the ISO file size. The IDT table is 256 × 8 = 2048 bytes — not huge, but a kernel should be deliberate about what it puts in the binary vs what it expects to be zeroed in RAM.

Summary

PropertyZig kernelC kernelNotes
Entry point0x001010600x0010104032 bytes apart
.text size622 bytes782 bytesC is +160 bytes larger
.rodata size168 bytes236 bytesC has more string + eh_frame data
.data section30 bytesnoneZig only — undefined globals
.bss size2056 bytes2084 bytesC is slightly larger
Section count109Zig has extra .data
.comment0x13 bytes0x28 bytesGCC writes longer version string

Both kernels implement identical logic — GDT, IDT, serial port, ISR handlers — but the compilers make meaningfully different decisions about how to lay out the binary. Neither is wrong. Understanding these differences helps when debugging with Biber: knowing which section a symbol lands in tells you where to look when an address in a crash log doesn't match your expectations.