Farid Zakaria

10 min read

Stamping build info in constant memory


This is a fun little trick I came across at $DAYJOB. I did not invent it, but I thought it was interesting enough to understand better and share.

At $WORK we build with buck2 and we stamp our executables with build information: build-id, timestamp, author, the usual suspects using llvm-objcopy as a step after the link.

$ cat buildinfo.json
{"revision":"9f3c1ad","built_at":"2026-08-25T12:00:00Z",
 "builder":"buck2","host":"nyx"}

# attach it as a section
$ llvm-objcopy --add-section .buildinfo=buildinfo.json app app.stamped

# read it back out
$ llvm-objcopy --dump-section .buildinfo=- app.stamped /dev/null
{"revision":"9f3c1ad","built_at":"2026-08-25T12:00:00Z",
 "builder":"buck2","host":"nyx"}

The reason it is a separate step is caching. If the build info was generated at link time then everytime we link the binary it would produce different bytes causing it to not be bit-reproducible. When something is bit-reproducible, it is safe to cache it, and the build system can apply early cut-off optimizations.

That works, until the binaries get big. We noticed that llvm-objcopy’s memory use scales with the size of the file it is stamping. Stamping is exactly the kind of step that runs massively parallel at the end of a build so this can cause a lot of memory pressure.

Why is the stamping step reading the binary at all? 🤔

§The memory problem

Let’s measure the claim that the memory use of llvm-objcopy scales with the size of the file. We will attach a JSON build info blob to an increasingly large synthetic executable and measure the peak RSS of the stamping step.

1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/

The graph confirms the claim. The memory use of llvm-objcopy scales linearly with the size of the file being stamped. Surprisingly, the slope is two. The peak RSS is roughly twice the size of the file being stamped irrespective of the size of the build info being attached.11For those thinking this is an LLVM specific issue, GNU objcopy exhibits the same behavior. 

I am helping to shepherd a PR open against LLVM to stream the ELF output rather than materialize it, which roughly halves the peak. That is a definite improvement but the problem remains that in order to add a tiny section to a large binary, the whole binary has to be read into memory. The memory use is still linear in the size of the file.

§A section is three things

The problem is not poor implementation on the part of llvm-objcopy. Adding a section to an ELF touches three separate things:

  1. the section’s bytes, somewhere in the file
  2. a 64-byte entry in the section header table describing where those bytes are
  3. the section’s name, which is not in the entry itself but rather the entry holds a sh_name offset into .shstrtab, so the name has to be appended to that string table
Elf64_Shdr — one 64-byte entry sh_name sh_type sh_flags sh_addr sh_offset sh_size sh_link … 0x78 PROGBITS 0 0x0 0x401021 0x58 0 .shstrtab 0x78 bytes in .interp\0 .buildinfo\0 .rela… 0x70 0x78 0x83 the section's bytes 88 bytes of JSON, at 0x401021 The entry holds neither the name nor the bytes. It only refers to them — and only one of those two references can be repointed in place.

In order to account for the new section, the section header table has to grow by one entry, and .shstrtab has to grow by the length of the new name.

The current model for llvm-objcopy is to read the whole file into memory, add the new section, and write the whole file back out. That is why the memory use scales with the size of the file.

§Pay the byte at link time

How can we avoid having to rebuild the whole file just to add a tiny section? The trick is to pay the cost at link time rather than at stamp time.

We can have the linker emit a placeholder section with the right name and a single byte of content. The section header table entry is already there, and the name is already in .shstrtab. The post-link stamping step can then append the payload to the end of the file and update the section header entry to point to it. 💡

We make linker emit the section during the normal build. It does not need to hold anything; it just needs to exist so that it owns a name and a header.

/* A placeholder the linker will emit a section header for.
   It holds one byte and is deliberately not SHF_ALLOC,
   so it is metadata rather than image. */
__asm__(".section .buildinfo,\"\",@progbits\n"
        ".byte 0\n"
        ".previous");

Our “stamp” step is now incredibly simple. It does not need to read the file at all, it just needs to write the new payload and update the section header entry.

  1. append the payload to the end of the file
  2. write the new sh_offset and sh_size into the placeholder’s section header entry

Nothing that already exists moves. e_shoff does not move, the section header table does not move, no other sh_offset changes. The edit is sixteen bytes, at a file offset you can compute from the ELF header, plus a cat.

objcopy --add-section every byte copied ehdr phdrs .text .rodata … .shstrtab section headers read into a model, serialized again — 1,238 bytes of it actually differ reserve one byte, then append 16 bytes + a tail ehdr phdrs .text .rodata … .shstrtab section headers payload the reserved byte sh_offset, sh_size

Note Why 1 byte? Turns out that llvm-objcopy and GNU objcopy disagree on whether an empty section is a valid ELF. The one byte is a cheap way to make both linkers happy.

The payload lands after the section header table, which looks alarming the first time you see it but is completely legal. Nothing in ELF says section contents must precede the section header table. The kernel also never looks at section headers also, it loads PT_LOAD segments out of the program headers, which we do not touch.

§Benchmark

I wrote a small C version to benchmark it in contrast, please be mindful that this graph is log-log.

elfstamp.c
/* elfstamp -- point a pre-reserved ELF section at data appended to the file.
 *
 * usage: elfstamp <elf> <section-name> <payload-file>
 *
 * The section must already have a header in the file; this never adds one.
 * Nothing that already exists is moved, so the only things ever held in
 * memory are one section header, the section-name string table and a fixed
 * copy buffer -- regardless of how large the ELF is. */
#define _GNU_SOURCE
#include <elf.h>
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

/* Fixed staging buffer for the append; the whole point is that this number
   does not depend on the size of the executable being stamped. */
#define COPY_CHUNK (64 * 1024)

/* Offsets of the two fields inside an Elf64_Shdr that this tool rewrites. */
#define SHDR_OFF_SH_OFFSET offsetof(Elf64_Shdr, sh_offset)
#define SHDR_OFF_SH_SIZE offsetof(Elf64_Shdr, sh_size)

/* Payload placement alignment. Nothing requires more than this for a
   non-allocated note, and it keeps the arithmetic obvious. */
#define PAYLOAD_ALIGN 8

static void die(const char *what) {
  fprintf(stderr, "elfstamp: %s: %s\n", what, strerror(errno));
  exit(1);
}

static void read_exact(int fd, void *buf, size_t n, off_t off) {
  if (pread(fd, buf, n, off) != (ssize_t)n) {
    die("short read");
  }
}

int main(int argc, char **argv) {
  if (argc != 4) {
    fprintf(stderr, "usage: %s <elf> <section> <payload>\n", argv[0]);
    return 2;
  }
  const char *elf_path = argv[1], *want = argv[2], *payload_path = argv[3];

  int fd = open(elf_path, O_RDWR);
  if (fd < 0) {
    die(elf_path);
  }

  /* The header tells us where the section header table is; that table is the
     only index we need, and we walk it one entry at a time. */
  Elf64_Ehdr eh;
  read_exact(fd, &eh, sizeof eh, 0);
  if (memcmp(eh.e_ident, ELFMAG, SELFMAG) != 0 ||
      eh.e_ident[EI_CLASS] != ELFCLASS64) {
    fprintf(stderr, "elfstamp: not a 64-bit ELF\n");
    return 1;
  }

  /* Section names live in their own string table; read just that section. */
  Elf64_Shdr sh;
  read_exact(fd, &sh, sizeof sh, eh.e_shoff + (off_t)eh.e_shstrndx * eh.e_shentsize);
  char *shstr = malloc(sh.sh_size);
  if (!shstr) {
    die("malloc");
  }
  read_exact(fd, shstr, sh.sh_size, sh.sh_offset);

  /* Find the placeholder section header the linker already emitted. */
  off_t target = -1;
  for (unsigned i = 0; i < eh.e_shnum; i++) {
    off_t at = eh.e_shoff + (off_t)i * eh.e_shentsize;
    read_exact(fd, &sh, sizeof sh, at);
    if (strcmp(shstr + sh.sh_name, want) == 0) {
      target = at;
      break;
    }
  }
  free(shstr);
  if (target < 0) {
    fprintf(stderr, "elfstamp: no section named '%s'\n", want);
    return 1;
  }
  if (sh.sh_flags & SHF_ALLOC) {
    fprintf(stderr, "elfstamp: '%s' is SHF_ALLOC; it is mapped and cannot move\n", want);
    return 1;
  }

  /* Append the payload past everything, aligned. Nothing already in the file
     is read or rewritten, so this is a pure O(payload) copy. */
  off_t end = lseek(fd, 0, SEEK_END);
  if (end < 0) {
    die("lseek");
  }
  off_t where = (end + PAYLOAD_ALIGN - 1) & ~(off_t)(PAYLOAD_ALIGN - 1);
  if (ftruncate(fd, where) != 0) {
    die("ftruncate");
  }

  int pfd = open(payload_path, O_RDONLY);
  if (pfd < 0) {
    die(payload_path);
  }
  char buf[COPY_CHUNK];
  uint64_t written = 0;
  for (;;) {
    ssize_t n = read(pfd, buf, sizeof buf);
    if (n < 0) {
      die("read payload");
    }
    if (n == 0) {
      break;
    }
    if (pwrite(fd, buf, n, where + written) != n) {
      die("write payload");
    }
    written += n;
  }
  close(pfd);

  /* Repoint the section header: sixteen bytes, in place. */
  uint64_t off64 = (uint64_t)where;
  if (pwrite(fd, &off64, sizeof off64, target + SHDR_OFF_SH_OFFSET) != sizeof off64 ||
      pwrite(fd, &written, sizeof written, target + SHDR_OFF_SH_SIZE) != sizeof written) {
    die("patch section header");
  }
  close(fd);

  printf("%s: .%s -> %llu bytes at 0x%llx\n", elf_path, want,
         (unsigned long long)written, (unsigned long long)where);
  return 0;
}
1980-01-01T00:00:00+00:00 image/svg+xml Matplotlib v3.10.5, https://matplotlib.org/

Our trick works! The “append + 16 bytes” approach is constant memory. Not only is the peak RSS constant, but the wall time is also constant and much faster by avoiding the read and write of the whole file.

§One trick pony

It is often easy to reach for general-purpose tools like objcopy as they are a swiss-army knife for manipulating object files. What I like about this trick though is that there are meaningful improvements to be made by writing special purpose tools and that does not mean we have to accrue large maintenance costs. In this case it was a tiny 200-line C program.

The economics of these tools is also changing with the rise of LLMs in our workflow. While many are concerned about the influx of generated code, I remain optimistic that we we can use them to find such opportunities.

Don’t be afraid to write a small tool to solve a specific problem.