Miscompilation due to LLVM not having function pointer provenance, and a linker flag merging identical functions.
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 119k
- Forks
- 16.1k
- PR merge metrics
- PR metrics pending
Description
This is a consequence of LLVM not handling https://github.com/rust-lang/unsafe-code-guidelines/issues/589 correctly. See also https://github.com/rust-lang/rust/issues/160202. Prior discussion at #t-opsem > Weak provenance function pointers.
The following code, when ran with cargo run --release, outputs 1. The correct behavior is either outputting nothing or outputting 2. (Zip of all files, for your convenience: repro.zip)
.cargo/config.toml
[target."cfg(all())"]
linker = "lld"
rustflags = ["-Clink-args=--icf=safe"]
dep/src/lib.rs
pub type Arg = fn(*mut i32);
#[inline(never)]
pub fn lol(f_ptr: fn(Arg, Arg)) {
if std::ptr::fn_addr_eq(f_ptr, f1 as fn(Arg, Arg)) {
f_ptr(
|r: *mut i32| unsafe {
*r = 1;
},
#[inline(never)]
|p| unsafe {
*p = 2;
},
);
}
}
fn f1(mutate_inlined: Arg, mutate_opaque: Arg) {
let mut a = 0;
let p = &raw mut a;
g1(mutate_inlined, mutate_opaque, unsafe { &mut *p }, unsafe {
&*p
})
}
fn g1(mutate_inlined: Arg, mutate_opaque: Arg, r: &mut i32, p: &i32) {
mutate_inlined(r);
mutate_opaque((&raw const *p).cast_mut());
output(*r);
}
#[inline(never)]
pub fn output(x: i32) {
println!("{x}");
}
dep2/src/lib.rs:
use dep::{Arg, output};
pub fn f2(mutate_inlined: Arg, mutate_opaque: Arg) {
let mut a = 0;
let p = &raw mut a;
g2(mutate_inlined, mutate_opaque, p, p)
}
fn g2(mutate_inlined: Arg, mutate_opaque: Arg, r: *mut i32, p: *mut i32) {
mutate_inlined(r);
mutate_opaque(p);
unsafe {
output(*r);
}
}
src/main.rs:
use dep::{Arg, lol};
use dep2::f2;
fn main() {
lol(std::hint::black_box(f2 as fn(Arg, Arg)));
}
Here is what I believe is happening, mostly based on observation of end-to-end behavior. (I didn't check the optimization pipeline to be sure that this is accurate.)
The f1 and f2 functions are written carefully such that their generated assembly code are identical. However, f1 causes undefined behavior due to violating aliasing rules of references, while f2 does not. When f2 is called (with the appropriate arguments), it should print 2.
In lol(), we compare if the function pointer for f2 (in the variable f_ptr) has the same address as the function pointer for f1. If they do, we call f2. However, LLVM sees this comparison, and decides that f_ptr has to be pointing to f1, and therefore does devirtualization and inlines f1 into the body of lol. This is arguably incorrect, since the this arguably introduces UB to code without UB.
After some more inlining and devirtualization, the body of g1() effectively becomes the following:
*r = 1;
mutate_opaque((&raw const *p).cast_mut());
output(*r);
Since p is a &i32, and r is a &mut i32, LLVM concludes that mutate_opaque can't possibly mutate r via p. So the third line is optimized into output(1), causing the program to print 1.
For this whole sequence of events to matter, the function pointers for f1 and f2 have to point to the same address. This is accomplished by the icf option in the lld linker, which seems to deduplicate functions with identical generated assembly.
Note: The reproducer seems to be sensitive to the naming of f1 and f2. I don't know what's going on, but my blind guess is that this icf thing will merge only functions with the same prefix in the name.
Note: In lol(), replacing if std::ptr::fn_addr_eq(f_ptr, f1 as fn(Arg, Arg)) { with if true { results in code that prints 2 in Miri without any detected UB.
cc @RalfJung @nikic
Meta
rustc --version --verbose:
rustc 1.97.1 (8bab26f4f 2026-07-14)
binary: rustc
commit-hash: 8bab26f4f68e0e26f0bb7960be334d5b520ea452
commit-date: 2026-07-14
host: aarch64-apple-darwin
release: 1.97.1
LLVM version: 22.1.6
MacOS Tahoe 26.5.2
LLD version 22.1.8, installed via Homebrew.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start by reproducing the output with cargo run --release using the .cargo/config.toml, dep/src/lib.rs, dep2/src/lib.rs, and src/main.rs files shown. Compare behavior with and without --icf=safe, and inspect the fn_addr_eq case against the if true case. Done means the reproducer no longer miscompiles and the expected behavior is covered by an appropriate compiler test.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 32/100