Pure-Go PE/COFF tooling for building UEFI applications without binutils or LLD. Zero third-party dependencies, 100 % test coverage.
Two packages:
| Package | Import | Job |
|---|---|---|
linker |
github.com/go-coff/peln/linker |
turn relocatable objects (or a PIE) into a PE32+/EFI image — the pure-Go replacement for lld-link /subsystem:efi_application |
appender |
github.com/go-coff/peln/appender |
add sections to an existing PE (UKI assembly) — the equivalent of objcopy --add-section |
fwimg |
github.com/go-coff/peln/fwimg |
non-UEFI bare-metal images: ELF→flat binary (objcopy -O binary), Motorola SREC, Intel HEX, and U-Boot uImage (mkimage) |
A reference CLI lives in github.com/go-coff/pectl.
go get github.com/go-coff/pelnlinker.Link merges one or more COFF/PE or ELF relocatable objects (.o,
ET_REL — e.g. from TinyGo or clang with a *-pc-windows-gnu / ELF target)
into a self-contained PE32+ EFI application. The machine is taken from the first
object; supported: amd64, arm64, riscv64, loongarch64.
import "github.com/go-coff/peln/linker"
data, _ := os.ReadFile("main-riscv64.o")
obj, _ := linker.ReadObject(bytes.NewReader(data), "main-riscv64.o")
out, err := linker.Link([]*linker.Object{obj}, linker.LinkOptions{
AllowUnresolved: true, // zero-fill externals the EFI runtime ignores
})
if err != nil { log.Fatal(err) }
_ = os.WriteFile("BOOTRISCV64.EFI", out, 0o644)It motivated by riscv64 and loongarch64, which LLD's COFF driver does not
support. The pipeline is Resolve → ComputeLayout → ApplyRelocations → emit;
each architecture has its own relocation backend (reloc_x86_64*.go,
reloc_aarch64*.go, reloc_rv64.go, reloc_loongarch64.go).
linker.LinkPIE converts an already-linked position-independent
executable (ET_DYN, as produced by go build -buildmode=pie for a
bare-metal GOOS such as TamaGo) into a PE32+/EFI image. Such an image is
self-contained — its only dynamic relocations are the architecture's RELATIVE
type — so there is nothing to resolve; LinkPIE just:
- maps each
PT_LOADsegment to a PE section atRVA = p_vaddr − ImageBase; - pre-applies every
R_*_RELATIVE(writing the absolute target VA into the image) and records an equivalentIMAGE_REL_BASED_DIR64base relocation, so UEFI firmware rebases the image correctly at load; - takes the entry point from
e_entry.
elf, _ := os.ReadFile("hello-pie.elf") // GOOS=tamago GOARCH=loong64 -buildmode=pie
out, err := linker.LinkPIE(bytes.NewReader(elf), linker.PIEOptions{})
if err != nil { log.Fatal(err) }
_ = os.WriteFile("BOOTLOONG64.EFI", out, 0o644)Supported machines: amd64, arm64, riscv64, loongarch64. This is the route for
turning a pure-Go TamaGo bare-metal binary into a UEFI .efi with no external
linker. (debug/pe cannot read machine 0x6264, but the bytes are a valid
PE32+; firmware and objdump/llvm-readobj parse it.)
appender.Append adds new sections at the end of an existing PE32/PE32+ image
while leaving every existing section's RVA, file offset and contents untouched —
exactly the constraint for assembling UEFI Unified Kernel Images (UKIs):
take a systemd UEFI stub and add .linux, .initrd, .cmdline, .osrel,
.uname.
import "github.com/go-coff/peln/appender"
stub, _ := os.ReadFile("linuxx64.efi.stub")
out, err := appender.Append(stub, []appender.Section{
{Name: ".osrel", Data: osrelBytes, Characteristics: appender.DefaultCharacteristics},
{Name: ".cmdline", Data: cmdlineBytes, Characteristics: appender.DefaultCharacteristics},
{Name: ".linux", Data: kernelBytes, Characteristics: appender.DefaultCharacteristics},
{Name: ".initrd", Data: initrdBytes, Characteristics: appender.DefaultCharacteristics},
})
if err != nil { log.Fatal(err) }
_ = os.WriteFile("BOOTX64.EFI", out, 0o644)The stub must reserve enough header padding to absorb the new section-table
entries (SizeOfHeaders is not grown); all systemd UEFI stubs do.
Not every bare-metal target is UEFI. For images loaded at a fixed address by
a bootloader or ROM, fwimg converts an ELF executable into the relevant
flat/firmware format, again with no binutils/mkimage/srec_cat:
import "github.com/go-coff/peln/fwimg"
elf, _ := os.ReadFile("kernel.elf")
flat, base, _ := fwimg.Flatten(bytes.NewReader(elf), fwimg.FlattenOptions{}) // objcopy -O binary
_ = os.WriteFile("kernel.img", flat, 0o644) // e.g. Raspberry Pi
hex := fwimg.IHEX(uint32(base), flat, 0) // Intel HEX for a flasher
srec := fwimg.SREC(uint32(base), flat, "fw", 0, 0)
uimg := fwimg.UImage(flat, fwimg.UImageOptions{ // U-Boot bootm payload
Load: 0x80000, Entry: 0x80000, Name: "linux", Arch: fwimg.ArchARM64,
})The CLI exposes this as pectl objcopy -O binary|srec|ihex|uimage.
To remove a host build-time dependency on binutils/LLD for tools that build UEFI images in Go (custom installers, boot managers, embedded pipelines), and to cover targets LLD's COFF driver doesn't (riscv64, loongarch64). Cross-compiling Go to a host that doesn't ship binutils (macOS, Alpine minimal) is otherwise painful.
