AxlSpd — JEDEC SPD reader (DDR4/DDR5)

JEDEC Serial Presence Detect (SPD) reader for DDR4/DDR5 DIMMs.

Header: <axl/axl-spd.h>. Lazy on first call: AxlSpd opens an @ref AxlSmbus session against whatever controller the platform exposes (HC protocol, then I2C Master), probes the eight standard SPD addresses (0x50..0x57), and hands off to the codec selected by the memory-type byte at SPD offset 2.

DDR3 is intentionally out of scope for v1 — the motivating consumer use case (Linux-side dump-memory parity on UEFI) is DDR4/DDR5 server fleets. DDR3 is a small additional codec when a consumer asks for it.

Platform limitations — when AxlSpd doesn’t deliver

Direct SPD reads work only when the platform’s SMBus exposes the DIMM EEPROMs to non-firmware software. On many server boards this isn’t true and axl_spd_next returns NULL on first call. Always have an SMBIOS Type 17 fallback ready (see tools/memspd.c for the reference pattern).

Confirmed-affected platforms:

  • OEM-CPLD-routed DIMMs on some AMD server boards (FCH 1022:790b family). DDR5 SPDs are NOT on the FCH AUX SMBus on these platforms. They’re physically routed through an OEM-specific CPLD (“FPGA hub PLD”) behind a vendor UEFI protocol; BIOS reads them there and publishes the results via SMBIOS Type 17. The FCH AUX SMBus controller is electrically present but empty, returning the chipset- default “all-ACK + zero-data” pattern Linux warns about in drivers/i2c/busses/i2c-piix4.c lines 968-974. Linux’s spd5118 driver fails to bind for the same reason — verified on an AMD-EPYC server with kernel 6.19.10.

  • Platforms where the SPD-carrying SMBus isn’t published as EFI_SMBUS_HC_PROTOCOL or EFI_I2C_MASTER_PROTOCOL. AxlSpd’s auto-detect can’t find a transport. The AMD FCH PIIX4 direct-I/O fallback in axl-smbus-piix4.c covers boards where firmware is opinionated about which controller it advertises, but the chipset still has to deliver real bytes (see #1 above).

The cases where AxlSpd genuinely beats SMBIOS Type 17 are rare: live timing parameters (tCL, tRCD, tRP, tRAS) that aren’t in Type 17, and platforms where the BIOS publishes a stale or partial Type 17 (uncommon on modern firmware).

uint8_t *slot = NULL;
while ((slot = axl_spd_next(slot)) != NULL) {
    AxlSpdInfo info;
    if (axl_spd_read(*slot, &info) == 0 && info.ddr_generation != 0) {
        axl_printf("Slot 0x%02X  DDR%u  %u MT/s  %llu bytes  ECC=%s\n",
                   *slot, info.ddr_generation, info.speed_mts,
                   (unsigned long long)info.capacity_bytes,
                   info.has_ecc ? "yes" : "no");
    }
}

Cursor iteration follows the established pattern (matches axl_smbios_find_next, axl_acpi_find_next, axl_pci_next). The cursor is module-global — UEFI is single-threaded so two overlapping walks would corrupt each other; treat the iterator as exclusive.

Decoded info

typedef struct {
    uint8_t   ddr_generation;     /* 4, 5; 0 = unknown */
    uint16_t  mfg_code_module;    /* JEP-106 (bank<<8) | id; 0 if unset */
    uint16_t  mfg_code_dram;
    uint8_t   mfg_location;
    uint16_t  mfg_year;           /* 2000 + BCD year byte */
    uint8_t   mfg_week;           /* 1..53 */
    uint32_t  serial;             /* 4 bytes BE */
    char      part_number[31];    /* trimmed ASCII */
    uint64_t  capacity_bytes;
    uint16_t  speed_mts;          /* JEDEC speed grade in MT/s */
    bool      has_ecc;
    bool      registered;
} AxlSpdInfo;

Manufacturer fields are exposed as raw 16-bit JEP-106 codes — high byte is the continuation-bank index, low byte is the position within that bank as physically stored on the SPD (parity bit included). For human-readable rendering, the axl_spd_ids_* API loads a curated JSON5 sidecar (jedec.json5) into a process-global table and exposes lookup / format helpers parallel to axl_pci_ids_load and axl_pci_format_name:

if (axl_spd_ids_load(NULL) == AXL_SIDECAR_OK) {
    const char *name = axl_spd_vendor_name(info.mfg_code_module);
    /* NULL-safe; consumers fall back to numeric IDs */
}

char buf[AXL_SPD_NAME_COMPOSED_MAX];
axl_spd_format_name(info.mfg_code_module, buf, sizeof(buf));
/* known → "Micron Technology"; unknown → "0xCCCC" */

The handle API (axl_spd_ids_open / _open_from_buffer / _close / _vendor_name / _foreach_vendor / _format_name) mirrors AxlPciIds for consumers that want layered databases (public + private overlay). Schema 1 only — JEDEC has no subsystem dimension that motivated AxlPciIds’s v1/v2 split.

tools/memspd.c is the reference consumer: at startup it calls axl_spd_ids_load(--jedec-file or NULL) and renders manufacturer fields via axl_spd_vendor_name and axl_spd_format_name. share/jedec.json5 carries ~30 common server vendors; the file is hand-curated (no auto-converter — JEDEC publishes JEP-106 as PDF, not a canonical text database).

Wire-protocol notes

DDR4 (key byte 0x0C) — EE1004 hub. Lower 256 bytes are accessed directly. The upper 256 bytes (Module Manufacturing Information at offsets 320..511) are reached after a Set Page Address (SPA) write to the dedicated pseudo-slaves 0x36 (lower) / 0x37 (upper). AxlSpd attempts SPA via axl_smbus_write_byte; standards-compliant EE1004 devices ignore the data byte. SPA failure is non-fatal — the codec falls back to “lower page only” silently and the manufacturer fields remain at zero. QEMU’s smbus-eeprom doesn’t model SPA, so wire-path QEMU coverage is lower-page only.

DDR5 (key byte 0x12) — SPD5118 hub. The 1024-byte address space is divided into eight 128-byte pages (0..7); each page is read at hub-relative offsets 0x80..0xFF after writing the page index to MR11 (register 0x0B). AxlSpd handles paging internally and restores page 0 after every read — including mid-read failures — so subsequent consumers see a predictable hub state.

Pure-decoder API

axl_spd_decode(buf, len, *out) runs the same codec on a captured buffer with no SMBus involvement. Useful for offline analysis (decoded fields out of a raw blob captured on real hardware) and for cross-arch unit testing — the AxlPlatform suite’s DDR4/DDR5 decode tests use this entry point so coverage works the same on x86 (where QEMU has SMBus) and AArch64 (where it doesn’t).

Pair with axl_spd_dump_raw(addr, *buf, cap, *len) to capture a DIMM’s SPD bytes for off-box analysis.

tools/memspd.c

memspd list                       — one-line summary per populated slot
memspd show <slot>                — decoded fields for one slot
memspd decode <slot>              — raw hex dump + decoded fields

Common flag: --jedec-file <path> overrides axl_spd_ids_load’s autodiscovery (binary’s directory → ./jedec.json5). When no sidecar loads, manufacturer fields print as raw hex codes — the information still reaches the user, just unresolved.

Testing

Pure decoder coverage runs in AxlTestPlatform against canned blobs synthesised by test/data/gen-spd.py — 21 tests, balanced across x86 and AArch64.

Wire-path coverage lives in test/integration/test-spd-qemu.sh (auxiliary, opt-out of the test-axl.sh ratchet). It depends on:

  • A locally-patched QEMU built from scripts/qemu-patches/0001-smbus-eeprom-add-memdev-link.patch, which adds a memdev=<link<memory-backend>> property to the smbus-eeprom device. Stock QEMU 10.x rejects the argument.

  • SmbusHcShim.efi, which publishes EFI_I2C_MASTER_PROTOCOL on top of QEMU’s ICH9 SMBus controller (OVMF doesn’t ship a SMBUS HC driver).

The test attaches the canned DDR4 blob to slot 0x50 and verifies memspd.efi decodes it to “DDR4 / 8 GB / 2400 MT/s / ECC: yes”.

API Reference

JEDEC Serial Presence Detect (SPD) reader for DDR4/DDR5 DIMMs.

SPD EEPROMs sit on the platform SMBus at addresses 0x50–0x57 (one per DIMM slot) and carry the JEDEC-defined module-identification block written by the DIMM vendor at manufacture time. AxlSpd talks to them over AxlSmbus (so HC and I2C-Master transports both work), branches the codec on the memory-type byte at offset 2, and surfaces a decoded AxlSpdInfo struct.

Manufacturer fields are exposed as raw 16-bit JEP-106 codes (high byte = continuation-bank index, low byte = position in bank). For human-readable rendering, the axl_spd_ids_* API loads a curated JSON5 sidecar (jedec.json5) into a process- global table and exposes lookup / format helpers parallel to axl_pci_ids_load and axl_pci_format_name. Consumers that want a different DB (private OEM sheet, vendor-restricted list) layer their own handle on top via axl_spd_ids_open_from_buffer — same shape as AxlPciIds.

DDR3 is intentionally out of scope for v1. DDR5 modules use the SPD5118 hub protocol: the lower 128 bytes of each 128-byte page are addressed directly; the upper window is paged via MR11 (register 0x0B). AxlSpd handles the page selection internally.

Iteration mirrors the established cursor pattern (see axl_smbios_find_next, axl_pci_next):

uint8_t *slot = NULL;
while ((slot = axl_spd_next(slot)) != NULL) {
    AxlSpdInfo info;
    if (axl_spd_read(*slot, &info) == 0) {
        // ... decoded fields available in `info`
    }
}

There is no axl_spd_init() entry point — first call to a public function lazily opens an AxlSmbus session.

Platform limitations

Direct SPD reads work only when the platform’s SMBus exposes the DIMM EEPROMs to non-firmware software. On many server boards this isn’t true and the API returns 0 populated slots. Known affected classes (verified empirically):

  1. OEM-CPLD-routed DIMMs on some AMD server boards (FCH 1022:790b family) — DDR5 SPDs are NOT on the FCH AUX SMBus on these platforms. They’re physically routed through an OEM-specific CPLD (“FPGA hub PLD”) behind a vendor UEFI protocol; BIOS reads them there and publishes the results via SMBIOS Type 17. The FCH AUX SMBus is electrically present but empty, returning the chipset-default “all-ACK + zero-data” pattern Linux warns about in drivers/i2c/busses/i2c-piix4.c lines 968-974. Linux’s spd5118 driver fails to bind for the same reason — verified on an AMD-EPYC server with kernel 6.19.10.

  2. Platforms where the SMBus exposing DIMM SPDs isn’t published as or — AxlSpd’s auto-detect can’t find a transport. The PIIX4 direct-I/O fallback (when AMD FCH SMBus PCI device is present) helps on boards where firmware is opinionated about which controller it advertises, but the chipset still has to deliver real bytes (see #1).

  3. DDR3 / pre-2014 modules — codec coverage is DDR4 + DDR5 only. SPD3 EEPROMs respond but axl_spd_decode returns AxlSpdInfo{ ddr_generation = 0 } for them.

Recommended consumer pattern

Treat AxlSpd as opportunistic and always have an SMBIOS Type 17 fallback ready. Per-DIMM SMBIOS data — manufacturer, part number, capacity, configured speed, ECC bits, slot locator — is BIOS-populated on every system that ships SMBIOS, including all the platforms where AxlSpd silently finds nothing. See tools/memspd.c for the reference “try AxlSpd first, fall back to SMBIOS Type 17” pattern.

The cases where AxlSpd genuinely beats SMBIOS Type 17 are rare: live timing parameters (tCL, tRCD, tRP, tRAS) that aren’t exposed via Type 17, and platforms where the BIOS publishes a stale or partial Type 17 (uncommon on modern firmware).

Database iteration

Walk every vendor entry. Non-zero callback return stops iteration and propagates as the iter rc.

typedef int (*AxlSpdIdsVendorFn)(uint16_t code, const char *name, void *ctx)
int axl_spd_ids_foreach_vendor(const AxlSpdIds *ids, AxlSpdIdsVendorFn fn, void *ctx)

Defines

AXL_SPD_ADDR_FIRST

First SMBus address scanned for an SPD EEPROM.

AXL_SPD_ADDR_LAST

Last SMBus address scanned for an SPD EEPROM (inclusive).

AXL_SPD_PART_NUMBER_MAX

Maximum part-number length across DDR3/4/5 (DDR5 is 30 ASCII chars).

AXL_SPD_RAW_MAX

Maximum raw SPD payload size (DDR5 = 1024 bytes; DDR3/4 = 256/512).

AXL_SPD_VENDOR_NAME_MAX

Maximum bytes (including NUL) any vendor-name lookup can return. Sized for the longest real JEP-106 entry plus headroom.

AXL_SPD_NAME_COMPOSED_MAX

Maximum bytes (including NUL) for axl_spd_ids_format_name output. Vendor name plus the “<unknown>” → “0xCCCC” numeric fallback fit.

Typedefs

typedef struct AxlSpdIds AxlSpdIds

Opaque handle to a loaded JEDEC vendor-name database.

Created by axl_spd_ids_open or axl_spd_ids_open_from_buffer; destroyed by axl_spd_ids_close. Multiple handles can coexist — a consumer that ships an internal OEM sheet on top of the public set loads two handles and queries them in priority order, mirroring AxlPciIds.

The process-global API (axl_spd_ids_load and friends) wraps a single internal handle for the common case.

Functions

uint8_t *axl_spd_next(uint8_t *prev)

Walk populated SPD slots on the platform SMBus.

Probes 0x50..0x57 in order and returns each address that responds with a valid memory-type byte (DDR4=0x0C, DDR5=0x12). The returned pointer references an internal static cursor; pass NULL to restart, or the previous non-NULL return value to advance. The caller never owns the cursor’s storage.

Lazy: opens an AxlSmbus session on first call.

Parameters:
  • prev – previous result, or NULL to start

Returns:

pointer to the next populated SMBus address, or NULL when enumeration is complete (or no SMBus controller is available).

int axl_spd_read(uint8_t addr, AxlSpdInfo *out)

Read and decode the SPD at a specific SMBus address.

Issues the right sequence of SMBus byte reads for the device’s generation (auto-detected from the memory-type byte at offset 2), including SPD5118 page selection for DDR5 modules. On success out is populated; on failure out is left in an unspecified state.

Parameters:
  • addr – 7-bit SMBus address (0x50..0x57)

  • out – receives the decoded SPD on success

Returns:

AXL_OK on success, AXL_ERR if the slot is empty / unsupported / I/O error.

int axl_spd_dump_raw(uint8_t addr, uint8_t *buf, size_t cap, size_t *len)

Read raw SPD bytes for offline analysis.

For DDR4 reads the lower 256 bytes (or 512 if cap allows and the device supports it). For DDR5 reads up to 1024 bytes across all eight 128-byte pages, switching pages via MR11 as needed.

The buffer can later be fed to axl_spd_decode to obtain the same decoded view as axl_spd_read — useful for capturing SPDs on real hardware and decoding them off-box.

Parameters:
  • addr – 7-bit SMBus address.

  • buf – output buffer.

  • cap – buffer capacity in bytes.

  • len – (out) bytes actually written.

Returns:

AXL_OK on success, AXL_ERR on transport error or empty slot.

int axl_spd_decode(const uint8_t *buf, size_t len, AxlSpdInfo *out)

Decode an SPD buffer captured from a real DIMM.

Pure function — no SMBus, no allocations. Branches on buf[2] (the memory-type byte) to select the DDR4 or DDR5 codec.

Parameters:
  • buf – raw SPD bytes (256+ for DDR4, 1024 for full DDR5).

  • len – bytes available in buf.

  • out – (out) decoded info; zero-initialised by this call.

Returns:

AXL_OK on success, AXL_ERR if the memory type is unsupported or the buffer is too short for the detected generation.

AxlSidecarStatus axl_spd_ids_open(const char *path, AxlSpdIds **out)

Open a JEDEC vendor database from a JSON5 file.

Returns:

AXL_SIDECAR_OK on success (handle returned via out), AXL_SIDECAR_FILE_MISSING if path does not exist, AXL_SIDECAR_PARSE_ERROR on JSON5 / schema rejection.

AxlSidecarStatus axl_spd_ids_open_from_buffer(const char *json5, size_t len, AxlSpdIds **out)

Open a JEDEC vendor database from an in-memory JSON5 buffer.

Returns:

AXL_SIDECAR_OK on success, AXL_SIDECAR_PARSE_ERROR on parse / schema error. (No FILE_MISSING — the buffer is the input.)

void axl_spd_ids_close(AxlSpdIds *ids)

Free a database handle. NULL-safe.

const char *axl_spd_ids_vendor_name(const AxlSpdIds *ids, uint16_t code)

Vendor lookup against an explicit handle.

Parameters:
  • ids – handle from axl_spd_ids_load

  • code – packed JEP-106 (bank<<8 | id)

Returns:

database-owned string or NULL if unknown / handle empty.

int axl_spd_ids_format_name(const AxlSpdIds *ids, uint16_t code, char *buf, size_t buflen)

Compose a “vendor name or numeric fallback” display string.

Centralizes the rendering convention so every consumer prints the same string for the same JEP-106 code:

  • vendor known → "<vendor>"

  • vendor unknown → "0xCCCC" (uppercase 4-digit hex)

Returns:

number of bytes written excluding NUL (snprintf shape), or -1 on bad arguments.

AxlSidecarStatus axl_spd_ids_load(const char *override_path)

Load the curated JEDEC database into the process-global slot.

Two modes selected by override_path:

  • Explicit (override_path non-NULL): use exactly that path.

  • Autodiscover (override_path NULL): try jedec.json5 next to the running .efi, then in the current working directory.

Idempotent: a successful load is a no-op on subsequent calls. On the first successful load, registers an axl_atexit cleanup so the parsed table is freed at runtime cleanup automatically.

void axl_spd_ids_free(void)

Free the loaded database. Safe to call when none is loaded.

const char *axl_spd_vendor_name(uint16_t code)

Singleton-backed vendor lookup.

Returns:

database-owned string or NULL if no database loaded or code is not present.

int axl_spd_format_name(uint16_t code, char *buf, size_t buflen)

Singleton-backed convenience wrapper for axl_spd_ids_format_name.

struct AxlSpdInfo
#include <axl-spd.h>

Decoded module identification block.

Populated by axl_spd_read or axl_spd_decode. Caller-owned (no allocations); zero-initialise before passing in.

mfg_code_module and mfg_code_dram are packed JEP-106 codes: high byte is the continuation-bank index (0-based), low byte is the position within that bank. Look up the human-readable name via a vendor table at the tool layer.

Public Members

uint8_t ddr_generation

4 = DDR4, 5 = DDR5; 0 = unknown

uint16_t mfg_code_module

JEP-106 (bank<<8 | id) for module manufacturer; 0 if unset.

uint16_t mfg_code_dram

JEP-106 for DRAM die manufacturer; 0 if unset.

uint8_t mfg_location

Vendor-defined site code.

uint16_t mfg_year

Four-digit year (2000 + BCD year byte); 0 if unset.

uint8_t mfg_week

ISO week (1..53) decoded from BCD.

uint32_t serial

4 bytes, big-endian on the wire

char part_number[31]

ASCII, NUL-terminated, trimmed.

uint64_t capacity_bytes

Module capacity in bytes; 0 if not decodable.

uint16_t speed_mts

JEDEC speed grade (MT/s); 0 if not decodable.

bool has_ecc

True if the module exposes ECC bits.

bool registered

True for RDIMM / LRDIMM.