Inconsistent i24 representation - llvm vs no-llvm

Hi everyone!
I’m building a small audio file library, when I stumbled upon a weird behavior handling i24 specifically:

Source code

const std = @import("std");

pub fn main() void {
    const foo = std.mem.bytesToValue(i24, "\x00\xfd\xff");
    std.debug.print("type {} value {} value_hex {x} value_bin {b}\n", .{@TypeOf(foo), foo, foo, foo});
}

Output

~/Development/zig/ZigTemp/test_i24
❯ uname -a
Linux AMBROGIO 7.0.1-1-cachyos #1 SMP PREEMPT Thu, 23 Apr 2026 13:37:54 +0000 x86_64 GNU/Linux

~/Development/zig/ZigTemp/test_i24
❯ zig version
0.16.0

~/Development/zig/ZigTemp/test_i24
❯ zig run main.zig -fllvm
type i24 value -768 value_hex -300 value_bin -1100000000

~/Development/zig/ZigTemp/test_i24
❯ zig run main.zig -fno-llvm
type i24 value 16776448 value_hex fffd00 value_bin 111111111111110100000000

Thanks for the help!
Marco

1 Like

Looks like a sign confusion, signed -300 hex is the same as unsigned FFFD00 hex (eg the second case are the same bits when viewed through the type u24 instead of i24).

Maybe different sign extension when widening to register width (eg replicating the MSB vs filling up with zero bits). Not sure how an i24 would ever print as unsigned FFFD00 though, since technically that’s out of range for an i24.

1 Like

My guess was self-hosted backend incorrectly doing @intCast (in the hex print code), but changing foo to @bitCast(@as(u24, 0xfffd00)) fixes it.

So I inspected the raw bytes and the unused bits are different between your foo init and mine which seems to affect the sign extension. Looks like @floooh’s guess is correct.

I took a look at the assembly and I can tell you I have no idea how sign extension works in assembly :3, but the backends are definitely doing it in different ways. Self-hosted is using movslq (hardware sign extension) whereas LLVM does something that looks more correct shl, sar, ..

1 Like

I believe what you are doing is actually IB. Note that @sizeOf(i24) == 4 and std.mem.bytesToValue is basically just a pointer cast and a dereference of this pointer.
I think what you probably want to do instead is use std.mem.readInt or directly use @bitCast.

Using @bitCast also shouldn’t work according to its doc comment.

Asserts that @sizeOf(@TypeOf(value)) == @sizeOf(DestType).

But what it actually does is check whether @bitSizeOf(@TypeOf(value)) == @bitSizeOf(DestType)

6 Likes

Thanks for the replies! I ended up using std.mem.readInt for now, it works well enough.
I understand why this happens for the most part, but just to confirm @floooh’s point about FFFD00 being handled as unsigned in i24, it is affecting calculations as well: I found out about this problem by getting some extreme clipping when playing audio, and discovered samples like FFFD00 being converted into into :f128 = 1.9999086856733185855530006352663797, when it should’ve been a value between -1 and 1.

I wonder if this behavioral difference is intentional in the zig backend or not

1 Like

The behavior of non-UB code shouldn’t be affected by which backend. This is a bug.

2 Likes