target/loongarch: bceqz/bcnez are missing CHECK_FPE, so an FP branch can read another task's fcc
Host environment
- Operating system: Devuan GNU/Linux 6 (excalibur)
- OS/kernel version: Linux 6.12.88+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.88-1 (2026-05-15)
- Architecture: x86_64
- QEMU flavor: qemu-system-loongarch64
- QEMU version: Reproduced on master built from source, 11.1.50 (fa19879b2b, "Merge
tag 'pull-block-jobs-2026-08-17'"), and on 10.0.11 (Debian
1:10.0.11+ds-0+deb13u1).
gen_cz_bc()is identical in both. - QEMU command line:
Reproduces identically with
qemu-system-loongarch64 \ -machine virt \ -cpu la464 \ -accel tcg,tb-size=2048,thread=single \ -m 8G \ -smp 2 \ -bios /usr/share/qemu-efi-loongarch64/QEMU_EFI.fd \ -kernel vmlinuz-7.1.7+deb14-loong64 \ -initrd initrd.img-7.1.7+deb14-loong64 \ -append "root=/dev/vda rw console=ttyS0,115200 net.ifnames=0" \ -drive file=disk.qcow2,if=virtio,cache=writeback,aio=threads \ -netdev user,id=net0,hostfwd=tcp::4459-:22,ipv6=off \ -device virtio-net-pci,netdev=net0 \ -display nonethread=multi;-smp 2is enough.
Emulated/Virtualized environment
- Operating system: Debian GNU/Linux forky/sid
- OS/kernel version: Linux 7.1.7+deb14-loong64 #1 SMP PREEMPT Debian 7.1.7-1 (2026-08-07)
- Architecture: loongarch64
Description of problem
gen_cz_bc(), the translator for bceqz/bcnez, loads env->cf[cj] without CHECK_FPE
(target/loongarch/tcg/insn_trans/trans_branch.c.inc, unchanged from 10.0 through master):
static bool gen_cz_bc(DisasContext *ctx, arg_c_offs *a, TCGCond cond)
{
TCGv src1 = tcg_temp_new();
TCGv src2 = tcg_constant_tl(0);
tcg_gen_ld8u_tl(src1, tcg_env,
offsetof(CPULoongArchState, cf[a->cj]));
gen_bc(ctx, src1, src2, a->offs, cond);
return true;
}Every other translator that accesses env->cf[] does check: trans_fcmp.c.inc (2 uses) and
trans_fmov.c.inc (movcf2gr, movgr2cf, movcf2fr, movfr2cf, fsel). CHECK_FPE is defined
in trans_farith.c.inc, which translate.c includes at line 277, before trans_branch.c.inc at
line 282 - so the macro is already in scope where it is missing.
Linux/LoongArch manages the FPU lazily: switch_to() calls lose_fpu(), which saves the outgoing
task's FP state (fcc included) and clears CSR.EUEN.FPE. The next FP instruction that task executes
is expected to raise a Floating-Point-Disabled exception so the kernel can restore that task's FP
context - which is what real Loongson hardware does; see the hardware control below. Since
bceqz/bcnez never raise it under TCG, they branch on whatever env->cf[] happens to hold - i.e.
on the condition flag left behind by whichever task last owned the FPU.
The visible symptom is a guest program taking a branch it must not take. It shows up as Go binaries dying at startup, roughly 1 per 1000 process starts whenever there are more runnable tasks than vCPUs:
# math
fatal error: float64nan1
runtime: panic before malloc heap initialized
runtime stack:
runtime.throw({0xc3f33f?, 0x0?})
runtime/panic.go:1229 +0x38
runtime.check()
runtime/runtime1.go:257 +0x3b4
runtime.rt0_go()
runtime/asm_loong64.s:106 +0x84runtime.check() is Go's startup self-test. It is an unusually good canary for this bug because the
compiler emits a single fcmp.ceq.d and then reads $fcc0 twice, with a conditional branch in
between:
runtime1.go:253 MOVV R7, F0 ; F0 = 0xffff_ffff_ffff_ffff (a NaN)
runtime1.go:253 CMPEQD F0, F0, FCC0 ; fcmp.ceq.d -> fcc0 = 0; has CHECK_FPE, so it guards this TB
runtime1.go:253 BFPF 2(PC) ; bceqz, read #1 -- same TB as the fcmp, always correct
runtime1.go:256 BFPT 2(PC) ; bcnez, read #2 -- new TB, nothing in it checks FPEA conditional branch ends the translation block, so the second read lands in a block containing no
FPE-checked instruction and is therefore unguarded. That predicts failures only ever on the bcnez
assertions - float64nan1 and float64nan3, never float64nan/float64nan2 - which is what six
separate occurrences here show.
The crash is only the loud case. A bceqz/bcnez that reads a stale flag and silently takes the
wrong path is the same bug without the diagnostic.
Note CHECK_FPE is compiled out under CONFIG_USER_ONLY, correctly - qemu-user is unaffected.
Steps to reproduce
- Boot a loongarch64 Linux guest with >= 2 vCPUs (Debian forky loong64, kernel 7.1.7 here).
gcc -O2 -o fccbug qemu-la-fcc-bug.c(attached, and inlined below)../fccbug
Expected: OK. Actual, every run (master 11.1.50 gives 89 misfires, 10.0.11 gives 107-113):
pid 44999: bcnez taken at iteration 120970 while fcc0 reads 0
pid 45000: bcnez taken at iteration 22083 while fcc0 reads 0
pid 45001: bcnez taken at iteration 31860 while fcc0 reads 0
pid 45002: bcnez taken at iteration 137840 while fcc0 reads 0
BUG: 113 misfires in 20000000 bcnez executions across 4 tasksA guest install is not needed: statically linking the reproducer, prepending it to the distro
initrd as an uncompressed cpio segment and booting -kernel vmlinuz -initrd <initrd> -append "rdinit=/fccbug console=ttyS0,115200 panic=1" -no-reboot -smp 2 with no disk and no network runs the
whole test in under a minute, from firmware to result.
The program sets fcc0 = 0 with fcmp.ceq.d on a NaN, then executes a bcnez that must fall
through. It forks its own load (2 spinners that keep fcc0 = 1 while they are on-CPU, 4 workers),
because the bug needs another FP-using task to run in between. When the branch is wrongly taken, the
very next instruction - movcf2gr, which does check FPE, so it traps and lets the kernel restore
this task's fcc - reports the true value, 0.
Additional information
Measurements on the guest above, all under the same load:
| test | result |
|---|---|
fcmp.ceq.d + bceqz read + bcnez read, Go's exact sequence, 4x5M |
bceqz wrong 0, bcnez wrong 96 |
on misfire, sample fcc0 with movcf2gr immediately after the branch |
reads 0, 72 of 72 |
same code with that movcf2gr moved in front of the branch |
0 misfires in 20M |
fcmp.ceq.d + sched_yield(2) + movcf2gr, 800k round trips |
clean |
integer beqz with a false condition, 20M |
clean |
| trivial Go binary, run serially | 0 failures in 1000 execs |
| trivial Go binary, 4 concurrent loops on 2 vCPUs | 3 failures in 3000 execs |
So FP comparison semantics and FP context save/restore are both fine; the discriminator is purely whether an FPE-checked instruction precedes the branch in its translation block.
Hardware control. The same sources, built and run the same way on a real Loongson-3C5000
(Fedora Linux 42, Linux 6.18.0-66.fc42.loongarch64, gcc 14.2.1, Go 1.26.5), everything pinned with
taskset -c 0,1 so the CPU:task ratio matches the 2-vCPU guest exactly:
| test | qemu-system-loongarch64 10.0.11 | Loongson-3C5000 |
|---|---|---|
qemu-la-fcc-bug.c (20M bcnez per run) |
BUG on every run, 113 misfires in the run quoted above | OK, 10 runs, 0 in 200M |
| 4x100M bcnez windows + 2 fcc0 polluters | misfires | 0 in 400M |
| Go startup canary, 4 concurrent loops x 750 execs | 3 failures in 3000 | 0 in 3000 |
The guest kernel's lazy-FPU scheme therefore does work on silicon - the branch traps there, and the kernel restores the task's fcc before it is read. Only TCG skips it.
Suggested fix - one line, macro already in scope:
--- a/target/loongarch/tcg/insn_trans/trans_branch.c.inc
+++ b/target/loongarch/tcg/insn_trans/trans_branch.c.inc
@@ -64,6 +64,8 @@ static bool gen_cz_bc(DisasContext *ctx, arg_c_offs *a, TCGCond cond)
TCGv src1 = tcg_temp_new();
TCGv src2 = tcg_constant_tl(0);
+ CHECK_FPE;
+
tcg_gen_ld8u_tl(src1, tcg_env,
offsetof(CPULoongArchState, cf[a->cj]));
gen_bc(ctx, src1, src2, a->offs, cond);HW_FLAGS_EUEN_FPE is part of tb->flags, so a block translated with the FPU disabled will raise
EXCCODE_FPD at the branch, which is what the guest kernel's lazy-FPU scheme relies on and what the
hardware control above shows silicon doing.
Verified by building master both ways and running the same guest kernel, initrd and reproducer against each binary:
| qemu-system-loongarch64 | result |
|---|---|
| master fa19879b2b, unpatched | BUG, 89 misfires in 20M |
| master fa19879b2b + the patch below | OK, 5 runs, 0 misfires in 100M |
| Loongson-3C5000 hardware | OK, 0 in 600M |
As an FP regression check, a Go program doing 3M iterations of sqrt/sin/cos/log/exp2 prints a
bit-identical result on the patched build, on 10.0.11 and on the hardware
(FPCHECK bits: 0x4163d54ac62d5f63).
For context: golang/go#59000 reported the same user-visible symptom on real Loongson hardware in
2023. That one was fixed on the Go side (CL 475577, save/restore fcc in async preemption) and is a
different bug; it does not apply here, since runtime.check() runs from rt0_go before Go installs
any signal handler.
qemu-la-fcc-bug.c
/*
* qemu-la-fcc-bug.c -- LoongArch: bceqz/bcnez ignore CSR.EUEN.FPE under TCG.
*
* target/loongarch/tcg/insn_trans/trans_branch.c.inc:gen_cz_bc() loads
* env->cf[cj] without CHECK_FPE, which every other fcc-touching translator
* (trans_fcmp.c.inc, trans_fmov.c.inc) performs. When the guest kernel has
* lazily disabled the FPU for this task, bceqz/bcnez therefore skip the
* FPU-disabled exception and branch on the condition flag left behind by
* whichever task last owned the FPU.
*
* fcmp.ceq.d does have CHECK_FPE, so it guards its own translation block; a
* conditional branch ends the TB, so a *following* bcnez sits in a TB with no
* FPE-checked instruction and reads env->cf[0] raw. Each iteration below sets
* fcc0 = 0 and then executes such a bcnez, which must never be taken. When it
* is taken anyway, the very next instruction -- movcf2gr, which *does* check
* FPE, so it traps and lets the kernel restore this task's fcc -- reports the
* true value, 0.
*
* Needs more runnable FP-using tasks than vCPUs, so it forks its own load.
*
* gcc -O2 -o fccbug qemu-la-fcc-bug.c && ./fccbug
*
* Real hardware and qemu-system-loongarch64 must both print "OK". Observed on
* QEMU 10.0.11, -machine virt -cpu la464 -accel tcg,thread=single -smp 2,
* Debian forky guest, Linux 7.1.7: dozens of misfires per run. thread=multi
* behaves the same; QEMU master is still missing the check.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#define WORKERS 4
#define LOAD 2
#define ITERS 5000000L
static long worker(void)
{
unsigned long nanbits = ~0UL;
double nanv;
long misfires = 0, i;
memcpy(&nanv, &nanbits, sizeof nanv);
for (i = 0; i < ITERS; i++) {
unsigned long path, sample;
__asm__ __volatile__(
"fcmp.ceq.d $fcc0, %2, %2\n\t" /* fcc0 <- 0; CHECK_FPE guards this TB */
"bceqz $fcc0, 1f\n\t" /* taken, and ends the TB */
"1:\n\t" /* new TB: nothing here checks FPE */
"move %0, $r0\n\t"
"move %1, $r0\n\t"
"bcnez $fcc0, 2f\n\t" /* fcc0 is 0: MUST NOT be taken */
"addi.w %0, $r0, 1\n\t"
"b 3f\n\t"
"2:\n\t"
"addi.w %0, $r0, 2\n\t"
"movcf2gr %1, $fcc0\n\t" /* has CHECK_FPE: reports the true fcc0 */
"3:\n\t"
: "=&r"(path), "=&r"(sample)
: "f"(nanv)
: "$fcc0");
if (path == 2) {
if (!misfires++)
fprintf(stderr, "pid %d: bcnez taken at iteration %ld "
"while fcc0 reads %lu\n", (int)getpid(), i, sample);
}
}
return misfires;
}
int main(void)
{
pid_t load[LOAD], work[WORKERS];
long total = 0;
int i, status;
for (i = 0; i < LOAD; i++) /* keep fcc0 = 1 on the other tasks */
if (!(load[i] = fork())) {
double one = 1.0;
for (;;)
__asm__ __volatile__("fcmp.ceq.d $fcc0, %0, %0"
:: "f"(one) : "$fcc0");
}
for (i = 0; i < WORKERS; i++)
if (!(work[i] = fork())) {
long n = worker();
_exit(n > 255 ? 255 : (int)n);
}
for (i = 0; i < WORKERS; i++) {
waitpid(work[i], &status, 0);
total += WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
for (i = 0; i < LOAD; i++)
kill(load[i], SIGKILL);
printf("%s: %ld misfires in %ld bcnez executions across %d tasks\n",
total ? "BUG" : "OK", total, (long)WORKERS * ITERS, WORKERS);
return total != 0;
}Tooling disclosure, per this template: this bug was investigated with the assistance of an LLM
(Claude Code). The reproducer, every measurement in the table above, and the source-code claims
about trans_branch.c.inc were validated by running them on the machine described.