Why wouldn't the binary size increase when a string is added?

65 points kwhali

TL;DR: I rubber duckied this while writing it up and it appears to be just a coincidence.

I'm submitting the post anyway in case the insights might benefit anyone that lands here :)


This produces a 13,464 bytes executable (while with cargo-zigbuild it's a 3,200 bytes executable)

  • It seems that rust isn't able to do some dead code elimination with musl/libc like Zig was doing?
  • The -Z args that follow the cargo +nightly build don't affect the size in any way either, just longer build times in this case (same observation with cargo +nightly zigbuild).
  • Without the -C link-arg=-lc it'll fail to compile:
    • Even though nightly is used to build std which should handle the panic() need for core?
    • Zig however compiles fine without the added link arg, so it must be doing something differently on it's side?

Reproduction

#![no_std]
#![no_main]

#[no_mangle]
pub extern "C" fn main(_argc: i32, _argv: *const *const u8) -> i32 {
    0
}

#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! { loop {} }
[package]
name = "example"
version = "0.1.0"
edition = "2021"

[profile.release]
codegen-units = 1
lto = true
opt-level = "z"
strip = true
panic = "abort"
RUSTFLAGS="-C link-arg=-lc -Z location-detail=none -C relocation-model=static -C target-feature=+crt-static" \\
  cargo +nightly build \\
    -Z build-std=std,panic_abort \\
    -Z build-std-features=panic_immediate_abort \\
    --target x86_64-unknown-linux-musl --release

Reproduction environment used if anyone is interested (Nightly is presently 1.83.0):

# Docker with Fedora 41 for the base reproduction environment:
$ docker run --rm -it --workdir /example fedora:41

# Prep environment (zig optional):
$ dnf install -y gcc rustup nano zig
$ rustup-init -y --profile minimal --default-toolchain nightly --target x86_64-unknown-linux-gnu x86_64-unknown-linux-musl --component rust-src
$ . "$HOME/.cargo/env" && cargo install cargo-zigbuild

# From here add the `Cargo.toml` + `src/main.rs` shared at the top of this post,
# then run the build command.
$ cargo init

Minor change for main() with no impact on size

A bizarre observation for me though was if I swap out the main() method for:

#[no_mangle]
pub extern "C" fn main() -> isize {
    const HELLO: &'static str = "Hello, world!\
";
    unsafe { write(1, HELLO.as_ptr() as *const i8, HELLO.len()) };
    0
}

extern "C" {
    fn write(fd: i32, buf: *const i8, count: usize) -> isize;
}

This still builds to the exact same size of 13,464 bytes, even though when it runs it'll now output a string:

# `rm -rf target` was used before building again:
du --bytes target/x86_64-unknown-linux-musl/release/example
13464   target/x86_64-unknown-linux-musl/release/example

# Without the implicit `--apparent-size` from `--bytes` (same for both builds):
du --block-size=1 target/x86_64-unknown-linux-musl/release/example
16384   target/x86_64-unknown-linux-musl/release/example

# For the build with Zig, it increases by 168 bytes:
du --bytes target/x86_64-unknown-linux-musl/release/example
3368

strings output

Yet if I run strings on it, only this 2nd variation differs by the "Hello, world!" line? Something seems off?

strings target/x86_64-unknown-linux-musl/release/example

%w      H
D$PH
H9D$xu
AUATI
AUHc
ATUH
H+C(H
H+{(
Z[]A\\A]
tdu"H
Hello, world!
/dev/null
GCC: (GNU) 9.4.0
rustc version 1.83.0-nightly (14f303bc1 2024-10-04)
.shstrtab
.note.gnu.build-id
.init
.text
.fini
.rodata
.eh_frame_hdr
.eh_frame
.init_array
.fini_array
.got
.data
.bss
.comment

For Zig builds the hello world string output:

strings target/x86_64-unknown-linux-musl/release/example

Hello, world!
/dev/null
AVSH
%w      I
D$PH
\\$ H
CXH;C`u
AVSP
AWAVSH
[A^A_
AWAVATSPH
AWAVATSPH
I+G(I
[A\\A^A_
AVSPH
AVSPH
>v$H
Linker: LLD 18.1.7
rustc version 1.83.0-nightly (14f303bc1 2024-10-04)
clang version 18.1.7 (Fedora 18.1.7-6.fc42)
.note.gnu.build-id
.rodata
.text
.init
.fini
.got
.relro_padding
.data
.bss
.comment
.shstrtab

The original main.rs only differs by not having the hello world line + has L+5=% instead (after the 2nd AWAVATSPH line).

Conclusion

Perhaps it's just a weird coincidence with whatever the compiler is doing that both versions without Zig involved result in 13,464 bytes?

The original without "hello world" string builds for the -gnu target as static with Eyra at 5,592 bytes for comparison (5,712 bytes with "hello world" variant).

  • Zig cannot be compared as it does not support static linking gnu targets (similar to how musl targets are not supported for dynamic linking).
  • Without eyra, static glibc has a 224 bytes difference (4,760 vs 4,984).
    • So the observation only seems to apply to musl?
    • However this segfaults when run, and the -C link-arg=-lc causes glibc to be linked dynamically unlike with musl (or eyra, where it's irrelevant). Potentially similar to Zig when providing static libc.a (which prevents the dynamic linking, but Zig still configures the ELF with an interpreter).
    • UPDATE: Instead of -C link-arg=-lc, for the Fedora 41 reproduction environment adding the glibc-static package and instead adding -C link-arg=/usr/lib64/libc.a -C link-arg=/usr/lib/gcc/x86_64-redhat-linux/14/libgcc_eh.a to the RUSTFLAGS like I had for attempting static build with Zig in the past does work. This results in a 692,976 bytes executable that works, which is likely similar to the musl bloat concern in that Rust is unable to optimize away what isn't used?

UPDATE2:

  • The musl builds change from 13,464 bytes down to 3,770 bytes and 3,952 bytes when changing the linker to LLD with -C link-arg=-fuse-ld=lld. So I guess confirms the size was more of a coincidence.
  • The static glibc build didn't seem to benefit much from that switch, decreased by roughly 1KB.