Can I Build 32-bit ARM Targets on ARM64 Runners?
Yes. Cross compile with an arm-linux-gnueabihf toolchain on an ARM64 runner such as warp-ubuntu-latest-arm64-4x at $0.006 per minute, then test on hardware.
Yes. A 32-bit ARM artifact is produced on an ARM64 runner by cross compiling with a toolchain that targets the 32-bit ABI, which keeps the compiler running as native aarch64 code and avoids standing up an emulated 32-bit environment for the build. WarpBuild provides Linux x64, Linux ARM64, macOS, and Windows runners, and these builds run on an ARM64 label such as warp-ubuntu-latest-arm64-4x at $0.006 per minute (cloud runners documentation, checked on 2026-08-13).
Answer
The 32-bit ARM world splits into three targets, and each one has its own toolchain package and its own flag set. Pick by ABI, then install the matching compiler in the job.
| Target | Triple or setting | Ubuntu package | Compiler entry point | Flags that select the target |
|---|---|---|---|---|
| ARMv7 Linux, hard float | arm-linux-gnueabihf | gcc-arm-linux-gnueabihf, libc6-dev-armhf-cross | arm-linux-gnueabihf-gcc | -march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mthumb |
| Older ARM Linux, soft float | arm-linux-gnueabi | gcc-arm-linux-gnueabi, libc6-dev-armel-cross | arm-linux-gnueabi-gcc | -march=armv5te -mfloat-abi=soft |
| Bare metal and RTOS | arm-none-eabi | gcc-arm-none-eabi | arm-none-eabi-gcc | -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 |
| Go | GOARCH=arm | none, the Go toolchain covers it | go build | GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 |
| Rust | armv7-unknown-linux-gnueabihf | rustup target plus gcc-arm-linux-gnueabihf | cargo build --target | CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABIHF_LINKER=arm-linux-gnueabihf-gcc |
The -march, -mfpu, -mfloat-abi and -mthumb flags are documented in the GCC ARM options reference. The Go setting comes from the GoArm wiki page, where GOARM=7 selects hardware floating point on ARMv7. The Rust triple is listed under tier 2 in the rustc platform support table.
None of the cross packages are preinstalled. The Ubuntu 24.04 and Ubuntu 26.04 ARM64 images are compatible with GitHub's ARM64 images and carry the package set published at actions/partner-runner-images, so the toolchain install is an explicit apt-get step in the job (preinstalled software documentation).
Detail
Why the 32-bit build lands at compile time
AArch32 execution at the application level is optional in the 64-bit Arm architecture, and server-class cores generally leave it out. AWS's Graviton getting-started repository states that its 64-bit cores do not run 32-bit Arm applications. On such a host, an armhf ELF binary fails at exec with a format error even though the CPU is an Arm part.
Cross compiling sidesteps that entirely. The compiler, linker, and build system are aarch64 binaries running at full speed on the runner, and only the emitted object code is 32-bit. Every step of the build stays native, so a 4 vCPU ARM64 runner compiles armhf code at the same rate it compiles aarch64 code.
The alternative shape is user-mode emulation. docker/setup-qemu-action registers binfmt handlers so linux/arm/v7 containers and binaries execute on an aarch64 host through QEMU translation. That is worth doing to run something, and it is a poor way to build something, because every instruction of the compiler itself is translated.
Installing the toolchain and the armhf sysroot
The cross packages supply a compiler and a glibc sysroot for the target. Third-party libraries come from multiarch instead, and this is the step where the host architecture matters:
- name: Install the 32-bit ARM toolchain
run: |
sudo dpkg --add-architecture armhf
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf \
libc6-dev-armhf-cross libssl-dev:armhf
arm-linux-gnueabihf-gcc -print-multiarchOn an arm64 Ubuntu image the apt sources already point at the Ubuntu ports archive, which carries armhf next to arm64, so dpkg --add-architecture armhf resolves armhf development packages with no edit to sources.list. On an x86-64 host the default archive does not carry armhf at all, so the same step needs a second source entry plus architecture pinning on the existing entries to stop apt from looking for armhf on the main archive (Debian multiarch HOWTO). Doing 32-bit Arm work on an ARM64 runner removes that piece of setup.
Two environment variables keep pkg-config honest once multiarch libraries are present: PKG_CONFIG_PATH=/usr/lib/arm-linux-gnueabihf/pkgconfig and PKG_CONFIG_SYSROOT_DIR=/. Without them, a configure script reads the aarch64 .pc files and links the wrong ABI.
One ARM64 job, several 32-bit artifacts
A target matrix on a single ARM64 label produces one artifact per ABI:
name: cross-build-32bit
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: warp-ubuntu-latest-arm64-4x
strategy:
fail-fast: false
matrix:
include:
- target: armhf
cc: arm-linux-gnueabihf-gcc
cflags: "-march=armv7-a -mfpu=neon-vfpv4 -mfloat-abi=hard -mthumb -O2"
packages: gcc-arm-linux-gnueabihf libc6-dev-armhf-cross
- target: armel
cc: arm-linux-gnueabi-gcc
cflags: "-march=armv5te -mfloat-abi=soft -O2"
packages: gcc-arm-linux-gnueabi libc6-dev-armel-cross
- target: cortex-m4
cc: arm-none-eabi-gcc
cflags: "-mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -Os"
packages: gcc-arm-none-eabi
steps:
- uses: actions/checkout@v4
- name: Install the cross toolchain
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ${{ matrix.packages }}
- name: Build
env:
CC: ${{ matrix.cc }}
CFLAGS: ${{ matrix.cflags }}
run: make clean all OUT=build/${{ matrix.target }}
- name: Record the produced ABI
run: file build/${{ matrix.target }}/* | tee build/${{ matrix.target }}/abi.txt
- uses: actions/upload-artifact@v4
with:
name: bin-${{ matrix.target }}
path: build/${{ matrix.target }}The file step is the cheap check that the flags did what the matrix says. For the armhf leg it prints ELF 32-bit LSB, ARM, EABI5 and a hard-float note, and abi.txt travels with the artifact so a release job can assert on it later.
When the apt install dominates the wall clock, collapse the three legs into one job and loop over the same table, installing all three compilers once and calling actions/upload-artifact per target directory. The tradeoff is straightforward: three jobs give parallel wall clock and pay three toolchain installs, one job pays a single install and runs the builds in sequence.
Where cross compilation stops
The build is the easy half. These failure classes only surface when the code executes:
- glibc symbol skew. The cross sysroot is Ubuntu 24.04 vintage, so a binary linked against it can reference symbol versions the device's older rootfs lacks, and the failure reads as a missing
GLIBC_2.xxat load time. - Float ABI mismatch. Linking hard-float objects against a soft-float vendor blob is accepted by some link steps and produces a binary that computes wrong values or faults on the first call.
- 32-bit type width.
time_t,off_t, and pointer arithmetic all change size, so Y2038 handling, large file access, and any struct laid out by hand behave differently from the aarch64 build that passes in the same repository. - Alignment and atomics. ARMv5 and ARMv7 differ in unaligned access behavior and in the atomic instructions available, and the compiler will happily emit code the target core cannot execute if
-marchis set higher than the hardware.
Unit tests that touch nothing beyond libc and the CPU can run on the runner under QEMU user-mode emulation, which is enough to catch the type-width and arithmetic classes above. Anything that touches device drivers, /dev nodes, interrupt latency, peripheral registers, a vendor BSP, or a kernel module needs real hardware. The GitHub Actions runner agent ships a linux-arm build for 32-bit hosts on github.com/actions/runner, so a device on a bench can register as a self-hosted runner and take the test job through runs-on: [self-hosted, linux, ARM] while the cross build stays on the managed ARM64 label. The cross compiling against native ARM64 builds guide covers how to split those two jobs, and the embedded firmware solution page covers the hardware-in-the-loop side.
What the ARM64 host costs
Every WarpBuild rate comes from the cloud runners documentation. GitHub rates come from the GitHub Actions billing reference and the GitHub pricing page, checked on 2026-08-13.
| Runner label | vCPU | RAM | Per minute | GitHub-hosted ARM64 equivalent | GitHub per minute |
|---|---|---|---|---|---|
| warp-ubuntu-latest-arm64-2x | 2 | 8 GB | $0.003 | ubuntu-24.04-arm | $0.005 |
| warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 | 4-core Linux ARM64 larger runner | $0.008 |
| warp-ubuntu-latest-arm64-8x | 8 | 32 GB | $0.012 | 8-core Linux ARM64 larger runner | $0.014 |
Worked model. A firmware repository runs the three-leg matrix above on every pull request, 400 pull request events a month, each leg taking 5 minutes on the 4 vCPU label. That is 400 x 3 x 5 = 6,000 minutes.
warp-ubuntu-latest-arm64-4xat $0.006 per minute: 6,000 x $0.006 = $36.00 per month.- GitHub 4-core Linux ARM64 larger runner at $0.008 per minute: 6,000 x $0.008 = $48.00 per month.
- Difference: $12.00 per month, $144.00 over twelve months, which is 25 percent lower list price (GitHub pricing, checked 2026-08-13).
- The $10 in signup credits covers the first $10 of that bill, so month one lands at $26.00 at this volume.
Caching the apt download or baking the toolchain into an image removes the install from every leg. A remote Docker builder is the route when the 32-bit output is a linux/arm/v7 container image rather than a bare binary. Labels, sizes, and regions are in the Linux ARM64 runner catalog.
Related Questions
Can I run the 32-bit binary I just built on the same ARM64 runner?
Not directly. Server-class ARM64 cores commonly ship without AArch32 support, so an armhf binary fails to exec. Register the QEMU binfmt handlers with docker/setup-qemu-action to run it under user-mode emulation, or send the test job to a self-hosted 32-bit device while the build stays on a label from the Linux ARM64 runner catalog.
Which package do I need for armhf against bare-metal Cortex-M?
Linux userspace targets use gcc-arm-linux-gnueabihf with libc6-dev-armhf-cross. Bare-metal and RTOS targets use gcc-arm-none-eabi, which ships newlib instead of glibc, so there is no dynamic loader and no libc syscall layer to match against a rootfs. Both install on the ARM64 Ubuntu image with apt-get (preinstalled software documentation), and the embedded firmware solution page walks the bare-metal case end to end.
Do Go and Rust need the apt cross toolchain?
Pure Go does not. Set GOOS=linux, GOARCH=arm, GOARM=7 and CGO_ENABLED=0 and the Go toolchain emits the 32-bit binary on its own (GoArm wiki page). Rust needs rustup target add armv7-unknown-linux-gnueabihf plus arm-linux-gnueabihf-gcc as the linker, because the target is tier 2 and cargo still shells out to a C linker (rustc platform support).
Is there a 32-bit ARM runner label instead?
No. The catalog runs 64-bit hosts, so Linux ARM64 is the closest host architecture and cross compiling from it is the supported path. The cross compilation glossary entry defines the terms, and the Linux ARM64 runner catalog lists the labels and per minute rates.
Start the 32-bit matrix on a label from the Linux ARM64 runner catalog, price it against your own pull request volume on the pricing page, and read the cross compiling against native ARM64 builds guide before you decide which jobs still need real hardware.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.