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:
    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 none
    Reproduces identically with thread=multi; -smp 2 is 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 +0x84

runtime.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 FPE

A 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

  1. Boot a loongarch64 Linux guest with >= 2 vCPUs (Debian forky loong64, kernel 7.1.7 here).
  2. gcc -O2 -o fccbug qemu-la-fcc-bug.c (attached, and inlined below).
  3. ./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 tasks

A 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.