Skip to content

populate HWCap from auxv on linux - #1

Merged
wwqgtxx merged 1 commit into
MetaCubeX:mainfrom
artbred:fix-linux-hwcap
Sep 30, 2026
Merged

wwqgtxx merged 1 commit into
MetaCubeX:mainfrom
artbred:fix-linux-hwcap

Conversation

@artbred

@artbred artbred commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Problem

On linux, every feature that is derived from HWCap/HWCap2 reads false. Affected architectures:

  • arm64 (including android): all ARM64 fields (AES, PMULL, SHA1/2/3/512, CRC32, ATOMICS, CPUID, DIT, IsNeoverse)
  • arm: ARM.HasVFPv4, ARM.HasIDIVA
  • loong64: Loong64.HasLSX, HasLASX
  • mips64/mips64le: MIPS64X.HasMSA
  • ppc64/ppc64le: PPC64.IsPOWER8/9/10, HasDARN, HasSCV
  • s390x: S390X.HasVX, HasVXE

Example from a UniFi Express 7 (linux/arm64, kernel 5.4, /proc/cpuinfo features fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid), running v0.1.1:

metacubex/cpu ARM64 HasAES=false HasPMULL=false HasSHA2=false HasCRC32=false HasATOMICS=false HWCap=0x0
x/sys/cpu     ARM64 HasAES=true  HasPMULL=true  HasSHA2=true  HasCRC32=true  HasATOMICS=false

Downstream, github.com/metacubex/tls computes hasGCMAsmARM64 = cpu.ARM64.HasAES && cpu.ARM64.HasPMULL. That value is now false, so on arm64 TLS 1.3 clients offer ChaCha20-Poly1305 first. In mihomo this includes every QUIC transport, and Poly1305 then runs in generic Go.

Root cause

This package is a copy of internal/cpu. In internal/cpu, the runtime's archauxv (runtime/os_linux_*.go) fills HWCap and HWCap2 from the ELF auxiliary vector before initialization. The runtime has no knowledge of github.com/metacubex/cpu.HWCap, so that variable stays 0. The single-argument //go:linkname HWCap only lets other packages link to this symbol; it does not alias internal/cpu.HWCap.

Fix

Only new files are added. No file copied from internal/cpu changes, so future syncs are unaffected.

  • hwcap_linux.go (linux; arm, arm64, loong64, mips64x, ppc64x, s390x): sets HWCap from AT_HWCAP, only if it is still zero. The value is assigned from a package-level variable initializer. Those run before any init function, so before init calls doinit. An init function would not work: init functions run in file order, so one in hwcap_linux.go would run after the one in cpu.go.
  • hwcap2_linux.go (linux; arm, ppc64x): does the same for HWCap2 from AT_HWCAP2.
  • runtime_auxv_go121.go: on Go 1.21+, the auxiliary vector comes from runtime.getAuxv via linkname, as in golang.org/x/sys/cpu. The runtime marks this symbol as linkname-able (go.dev/issue/57336, go.dev/issue/67401), so the Go 1.23+ linker accepts it. This path also works for non-dumpable processes, such as binaries with file capabilities, which cannot read /proc/self/auxv.
  • runtime_auxv_go120.go: Go 1.20 (the module's go directive) has no runtime.getAuxv, so the code falls back to reading /proc/self/auxv in native word size and byte order.

Why not //go:linkname ... internal/cpu.HWCap directly? With Go 1.26 the linker allows that only on arm64. On arm, loong64, mips64, ppc64le and s390x it fails with invalid reference to internal/cpu.HWCap.

What this PR does not change:

  • Platform (AT_PLATFORM) is still not populated on linux/arm, so ARM.HasV7Atomics stays false as before.
  • Non-linux systems are unchanged.

Verification

  • gofmt -l . prints nothing.
  • go vet ./... and go test ./... pass on darwin/arm64 with Go 1.20.14, 1.21.13, 1.22.12, 1.23.12, 1.24.5, 1.25.4 and 1.26.8.
  • With the same seven toolchains, go vet ./... and go test -c pass for linux/{arm64, arm, amd64, 386, ppc64le, ppc64, s390x, loong64, mips64, mips64le, riscv64}, android/arm64, freebsd/arm64 and darwin/arm64.
  • With Go 1.24.5 they also pass for linux/{mips, mipsle}, freebsd/arm, openbsd/arm64, netbsd/arm, windows/{amd64, arm64}, aix/ppc64, js/wasm and wasip1/wasm. openbsd/mips64 fails inside std syscall with Go 1.24.5, and it fails the same way on main.

On the UniFi Express 7 (probe built with Go 1.26.8, golang.org/x/sys v0.30.0):

v0.1.1               metacubex/cpu ARM64 HasAES=false HasPMULL=false HasSHA2=false HasCRC32=false HasATOMICS=false HWCap=0x0
this PR              metacubex/cpu ARM64 HasAES=true HasPMULL=true HasSHA2=true HasCRC32=true HasATOMICS=false HWCap=0x8ff
this PR (go1.20.14)  metacubex/cpu ARM64 HasAES=true HasPMULL=true HasSHA2=true HasCRC32=true HasATOMICS=false HWCap=0x8ff
x/sys/cpu            ARM64 HasAES=true HasPMULL=true HasSHA2=true HasCRC32=true HasATOMICS=false

HasATOMICS=false is correct here because this CPU has no LSE atomics.

  • linux/arm, same router: a GOARM=7 build runs as a 32-bit process. With v0.1.1 it reports ARM HasVFPv4=false HasIDIVA=false HWCap=0x0. With this PR it reports HasVFPv4=true HasIDIVA=true HWCap=0x37b0d6, which matches x/sys/cpu.
  • Non-dumpable process: the probe called PR_SET_DUMPABLE=0 before this package initialized and ran as uid 65534, so reading /proc/self/auxv failed with permission denied. This PR built with Go 1.26.8 still reports HasAES=true HWCap=0x8ff. Built with Go 1.20.14, or with v0.1.1, it reports false.
  • Test binaries on the router: go test -c builds for linux/arm64 (Go 1.26.8 and 1.20.14) and linux/arm (Go 1.26.8) pass TestCpu, TestAuxvValue, TestHWCap and TestHWCapBeforeDoinit.
  • qemu-user (Docker): on ppc64le, HasDARN, IsPOWER8 and IsPOWER9 go from false to true. On s390x, HasVX and HasVXE go from false to true. On loong64, HasLSX and HasLASX go from false to true. mips64 and mips64le pass the tests; the emulated CPU reports no MSA in AT_HWCAP.

New tests (linux, the affected architectures only):

  • TestAuxvValue checks auxv parsing: the first entry, an entry after another tag, a value equal to a tag, entries after AT_NULL, and a truncated vector.
  • TestHWCap compares HWCap with /proc/self/auxv. On Go 1.21+ that is a source independent of the runtime copy.
  • TestHWCapBeforeDoinit checks that the features derived at init match those derived from HWCap now.

Each of these mutations was confirmed to make a test fail:

  • population missing
  • wrong tag
  • lookup not stopping at AT_NULL
  • stepping by one word instead of by pairs
  • skipping the first entry
  • overrunning an odd-length vector
  • decoding 32-bit words on arm64
  • populating from init() instead of a variable initializer

Impact (mihomo)

These numbers come from mihomo v1.19.31 on the same router, built with the same toolchain. The stock build was compared with a build containing an equivalent patch to this package, which read AT_HWCAP from /proc/self/auxv.

  • QUIC switched from chacha20poly1305 + poly1305.updateGeneric to gcmAesDec.
  • Crypto's share of mihomo CPU dropped from 27.7% to 8.6%.
Throughput per CPU core (Mbps) stock patched change
ShadowQUIC download 132 160 +21%
ShadowQUIC upload 175 250 +43%
TrustTunnel HTTP/3 download 172 213 +24%
TrustTunnel HTTP/3 upload 138 164 +19%

A TCP/uTLS carrier that already negotiated AES-GCM was measured as a control and did not change.

mihomo picks up the fix by bumping github.com/metacubex/cpu.

The Go runtime fills internal/cpu.HWCap and HWCap2 from the ELF auxiliary
vector before package initialization, but it does not know about this
copy of the package. On linux they stayed zero, so every feature derived
from them read false: all ARM64 features (AES, PMULL, SHA1/2/3/512, CRC32,
ATOMICS, CPUID, DIT), ARM VFPv4/IDIVA, Loong64 LSX/LASX, MIPS64X MSA,
PPC64 POWER8/9/10, DARN, SCV and S390X VX/VXE.

Fill them in from a package-level variable initializer, which runs before
init calls doinit, unless they are already set. The auxiliary vector comes
from runtime.getAuxv on Go 1.21+, which also works for non-dumpable
processes such as binaries with file capabilities, and from
/proc/self/auxv on Go 1.20.
@wwqgtxx
wwqgtxx merged commit a5b11c5 into MetaCubeX:main Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants