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
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
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.
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
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
| Nr | Name | Zig kernel | C kernel |
|---|---|---|---|
| 1 | .multiboot | 0x100000 · 0x18 | 0x100000 · 0x18 |
| 2 | .text | 0x101000 · 0x26E | 0x101000 · 0x30E |
| 3 | .rodata | 0x102000 · 0xA8 | 0x102000 · 0xD8 |
| 4 | .eh_frame_hdr | 0x1020A8 · 0x2C | 0x1020D8 · 0x3C |
| 5 | .eh_frame | 0x1020D4 · 0x9C | 0x102114 · 0xD8 |
| 6 | .data | 0x103000 · 0x1E ← Zig only | — |
| 7 | .bss | 0x104000 · 0x808 | 0x103000 · 0x824 |
| 8 | .comment | 0x13 bytes | 0x28 bytes |
| 9 | .shstrtab | 0x50 bytes | 0x4A 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:
// 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
| Property | Zig kernel | C kernel | Notes |
|---|---|---|---|
| Entry point | 0x00101060 | 0x00101040 | 32 bytes apart |
| .text size | 622 bytes | 782 bytes | C is +160 bytes larger |
| .rodata size | 168 bytes | 236 bytes | C has more string + eh_frame data |
| .data section | 30 bytes | none | Zig only — undefined globals |
| .bss size | 2056 bytes | 2084 bytes | C is slightly larger |
| Section count | 10 | 9 | Zig has extra .data |
| .comment | 0x13 bytes | 0x28 bytes | GCC 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.