Decode LPDDR5/LPDDR5X .spd.hex dumps into spd_tools-compatible JSON. The default output is a single-part memory_parts.json-style document without the bits that spd_gen adds automatically. Signed-off-by: Sean Rhodes <sean@starlabs.systems> Change-Id: I49874bcd42cf3981277abbfa997ec185088f0715 Reviewed-on: https://review.coreboot.org/c/coreboot/+/89785 Reviewed-by: Matt DeVillier <matt.devillier@gmail.com> Tested-by: build bot (Jenkins) <no-reply@coreboot.org>
311 lines
8.7 KiB
Python
Executable file
311 lines
8.7 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
#
|
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
|
"""
|
|
Decode an LPDDR5/LPDDR5X SPD text hex file into JSON.
|
|
|
|
Default output format is compatible with coreboot's spd_tools `spd_gen`:
|
|
it emits a `memory_parts.json` containing a single part entry.
|
|
|
|
This tool is intentionally named `spd-decode` even though it currently only
|
|
supports LPDDR5/LPDDR5X SPDs, so it can be extended to other SPD types later.
|
|
|
|
Usage:
|
|
spd-decode <path/to/spd.hex> [--pretty] [--name NAME]
|
|
[--include-nondefault-timings]
|
|
spd-decode <path/to/spd.hex> --format raw [--pretty]
|
|
|
|
Options:
|
|
--format spd_gen Output a `memory_parts.json`-style document for
|
|
coreboot's spd_tools `spd_gen`.
|
|
--format raw Output the fully decoded SPD fields as JSON.
|
|
--name NAME Override the part name in spd_gen output.
|
|
--include-nondefault-timings
|
|
Include timing fields in spd_gen output only when they
|
|
differ from spd_gen defaults.
|
|
--pretty Pretty-print JSON (tabs) and include a command header.
|
|
|
|
Only LPDDR5 (0x13) and LPDDR5X (0x15) SPDs are supported.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
def _json_dumps_tabs(obj) -> str:
|
|
# Dump JSON with one-space indent and convert leading spaces to tabs.
|
|
s = json.dumps(obj, indent=1, separators=(",", ": "), ensure_ascii=False)
|
|
lines = []
|
|
for line in s.splitlines():
|
|
n = 0
|
|
while n < len(line) and line[n] == " ":
|
|
n += 1
|
|
if n:
|
|
line = ("\t" * n) + line[n:]
|
|
lines.append(line)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def _to_signed_byte(b: int) -> int:
|
|
return b - 256 if b >= 128 else b
|
|
|
|
|
|
def _parse_hex_file(path: str) -> List[int]:
|
|
data: List[int] = []
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
for p in line.split():
|
|
if len(p) != 2:
|
|
raise ValueError(f"Invalid hex byte '{p}' in {path}")
|
|
data.append(int(p, 16))
|
|
return data
|
|
|
|
|
|
def _decode_time_ps(mtb_byte: int, ftb_byte: int) -> int:
|
|
# MTB unit = 125 ps. FTB is signed 8-bit offset in ps.
|
|
return (mtb_byte * 125) + _to_signed_byte(ftb_byte)
|
|
|
|
|
|
def _decode_time_ns_from_16bit_mtb(lsb: int, msb: int) -> float:
|
|
# 16-bit MTB value in units of 125ps -> convert to ns (float)
|
|
mtb = (msb << 8) | lsb
|
|
ps = mtb * 125
|
|
return ps / 1000.0
|
|
|
|
|
|
def _round_int(x: float) -> int:
|
|
return int(round(x))
|
|
|
|
|
|
def _closest_speed_from_tck_ps(tck_ps: int, set_rev: int) -> int:
|
|
# Allowed LP5 speed bins in Mbps
|
|
bins = [5500, 6400, 7500, 8533]
|
|
if set_rev == 0x10:
|
|
# ADL set encodes CK tCKmin = 1 / (WCK rate / 4) = 8e6 / speed
|
|
guess = 8_000_000.0 / max(tck_ps, 1)
|
|
else:
|
|
# Default SPD encodes WCK tCKmin = 2e6 / speed
|
|
guess = 2_000_000.0 / max(tck_ps, 1)
|
|
return int(min(bins, key=lambda b: abs(b - guess)))
|
|
|
|
|
|
def _default_part_name_from_path(path: str) -> str:
|
|
base = os.path.basename(path)
|
|
for suffix in (".spd.hex", ".hex"):
|
|
if base.endswith(suffix):
|
|
base = base[: -len(suffix)]
|
|
break
|
|
return base
|
|
|
|
|
|
def _lp5_defaults_for_optional_attribs(density_gb: Optional[int]) -> Dict[str, int]:
|
|
# Match util/spd_tools/src/spd_gen/lp5.go defaults.
|
|
trfc = {
|
|
4: (180, 90),
|
|
6: (210, 120),
|
|
8: (210, 120),
|
|
12: (280, 140),
|
|
16: (280, 140),
|
|
24: (380, 190),
|
|
32: (380, 190),
|
|
}
|
|
trfcab, trfcpb = trfc.get(density_gb, (0, 0))
|
|
return {
|
|
"trfcabNs": trfcab,
|
|
"trfcpbNs": trfcpb,
|
|
"trcdMinNs": 18,
|
|
"trpabMinNs": 21,
|
|
"trppbMinNs": 18,
|
|
}
|
|
|
|
|
|
def decode_lp5(spd: List[int]) -> Dict:
|
|
if len(spd) < 349:
|
|
raise ValueError("LPDDR5 SPD must contain at least 349 bytes")
|
|
|
|
# Byte indices (match util/spd_tools/src/spd_gen/lp5.go)
|
|
IDX = {
|
|
"REV": 1,
|
|
"MEM_TYPE": 2,
|
|
"DENSITY_BANKS": 4,
|
|
"ADDR": 5,
|
|
"PKG_TYPE": 6,
|
|
"OPT_FEATURES": 7,
|
|
"OTHER_OPT_FEATURES": 9,
|
|
"MODULE_ORG": 12,
|
|
"BUS_WIDTH": 13,
|
|
"TIMEBASES": 17,
|
|
"TCK_MIN": 18,
|
|
"TAA_MIN": 24,
|
|
"TRCD_MIN": 26,
|
|
"TRPAB_MIN": 27,
|
|
"TRPPB_MIN": 28,
|
|
"TRFCAB_LSB": 29,
|
|
"TRFCAB_MSB": 30,
|
|
"TRFCPB_LSB": 31,
|
|
"TRFCPB_MSB": 32,
|
|
"TRPPB_FINE": 120,
|
|
"TRPAB_FINE": 121,
|
|
"TRCD_FINE": 122,
|
|
"TAA_FINE": 123,
|
|
"TCK_FINE": 125,
|
|
"MPN_START": 329,
|
|
"MPN_END": 348,
|
|
}
|
|
|
|
mem_type = spd[IDX["MEM_TYPE"]]
|
|
rev = spd[IDX["REV"]]
|
|
if mem_type == 0x13:
|
|
mem_type_str = "LPDDR5"
|
|
lp5x = False
|
|
elif mem_type == 0x15:
|
|
mem_type_str = "LPDDR5X"
|
|
lp5x = True
|
|
else:
|
|
mem_type_str = f"Unknown(0x{mem_type:02x})"
|
|
lp5x = False
|
|
|
|
density_code = spd[IDX["DENSITY_BANKS"]] & 0x0F
|
|
density_map = {
|
|
0x4: 4,
|
|
0xB: 6,
|
|
0x5: 8,
|
|
0x8: 12,
|
|
0x6: 16,
|
|
0x9: 24,
|
|
0x7: 32,
|
|
}
|
|
density_per_die_gb = density_map.get(density_code)
|
|
|
|
dies_per_package = ((spd[IDX["PKG_TYPE"]] >> 4) & 0x7) + 1
|
|
ranks_per_channel = ((spd[IDX["MODULE_ORG"]] >> 3) & 0x7) + 1
|
|
bit_width_per_channel = (spd[IDX["MODULE_ORG"]] & 0x7) * 8
|
|
channels_per_pkg = 1 << ((spd[IDX["PKG_TYPE"]] >> 2) & 0x3)
|
|
|
|
tck_ps = _decode_time_ps(spd[IDX["TCK_MIN"]], spd[IDX["TCK_FINE"]])
|
|
speed_mbps = _closest_speed_from_tck_ps(tck_ps, rev)
|
|
if not lp5x and speed_mbps >= 7500:
|
|
lp5x = True
|
|
|
|
taa_ps = _decode_time_ps(spd[IDX["TAA_MIN"]], spd[IDX["TAA_FINE"]])
|
|
trcd_ns = _round_int(_decode_time_ps(spd[IDX["TRCD_MIN"]], spd[IDX["TRCD_FINE"]]) / 1000.0)
|
|
trpab_ns = _round_int(_decode_time_ps(spd[IDX["TRPAB_MIN"]], spd[IDX["TRPAB_FINE"]]) / 1000.0)
|
|
trppb_ns = _round_int(_decode_time_ps(spd[IDX["TRPPB_MIN"]], spd[IDX["TRPPB_FINE"]]) / 1000.0)
|
|
trfcab_ns = _round_int(_decode_time_ns_from_16bit_mtb(spd[IDX["TRFCAB_LSB"]], spd[IDX["TRFCAB_MSB"]]))
|
|
trfcpb_ns = _round_int(_decode_time_ns_from_16bit_mtb(spd[IDX["TRFCPB_LSB"]], spd[IDX["TRFCPB_MSB"]]))
|
|
|
|
mpn_bytes = spd[IDX["MPN_START"] : IDX["MPN_END"] + 1]
|
|
try:
|
|
mpn = bytes(mpn_bytes).decode("ascii").strip()
|
|
except Exception:
|
|
mpn = ""
|
|
|
|
return {
|
|
"memoryType": mem_type_str,
|
|
"revision": f"0x{rev:02x}",
|
|
"channelsPerPackage": channels_per_pkg,
|
|
"manufacturerPartNumber": mpn or None,
|
|
"attributes": {
|
|
"densityPerDieGb": density_per_die_gb,
|
|
"diesPerPackage": dies_per_package,
|
|
"bitWidthPerChannel": bit_width_per_channel,
|
|
"ranksPerChannel": ranks_per_channel,
|
|
"speedMbps": speed_mbps,
|
|
"lp5x": lp5x,
|
|
"tckMinPs": tck_ps,
|
|
"taaMinPs": taa_ps,
|
|
"trcdMinNs": trcd_ns,
|
|
"trpabMinNs": trpab_ns,
|
|
"trppbMinNs": trppb_ns,
|
|
"trfcabNs": trfcab_ns,
|
|
"trfcpbNs": trfcpb_ns,
|
|
},
|
|
}
|
|
|
|
|
|
def detect_and_decode(spd: List[int]) -> Dict:
|
|
if len(spd) < 3:
|
|
raise ValueError("SPD data too short")
|
|
mem_type = spd[2]
|
|
if mem_type in (0x13, 0x15):
|
|
return decode_lp5(spd)
|
|
raise NotImplementedError(f"Unsupported or unknown SPD memory type 0x{mem_type:02x}")
|
|
|
|
|
|
def _to_spd_gen_memory_parts(decoded: Dict, part_name: str, include_nondefault_timings: bool) -> Dict:
|
|
attribs_in = decoded.get("attributes", {})
|
|
|
|
attribs_out = {
|
|
"densityPerDieGb": attribs_in.get("densityPerDieGb"),
|
|
"diesPerPackage": attribs_in.get("diesPerPackage"),
|
|
"bitWidthPerChannel": attribs_in.get("bitWidthPerChannel"),
|
|
"ranksPerChannel": attribs_in.get("ranksPerChannel"),
|
|
"speedMbps": attribs_in.get("speedMbps"),
|
|
}
|
|
|
|
if attribs_in.get("lp5x"):
|
|
attribs_out["lp5x"] = True
|
|
|
|
if include_nondefault_timings:
|
|
defaults = _lp5_defaults_for_optional_attribs(attribs_in.get("densityPerDieGb"))
|
|
for k in ("trfcabNs", "trfcpbNs", "trcdMinNs", "trpabMinNs", "trppbMinNs"):
|
|
v = attribs_in.get(k)
|
|
if v is None:
|
|
continue
|
|
if defaults.get(k) and v != defaults[k]:
|
|
attribs_out[k] = v
|
|
|
|
part = {"name": part_name, "attribs": attribs_out}
|
|
return {"parts": [part]}
|
|
|
|
|
|
def main(argv: List[str]) -> int:
|
|
ap = argparse.ArgumentParser(description="Decode LPDDR5 SPD .hex file to JSON")
|
|
ap.add_argument("hexfile", help="Path to SPD hex file (text with space-separated bytes)")
|
|
ap.add_argument(
|
|
"--format",
|
|
choices=("spd_gen", "raw"),
|
|
default="spd_gen",
|
|
help="Output JSON format: spd_gen (memory_parts.json) or raw",
|
|
)
|
|
ap.add_argument(
|
|
"--name",
|
|
help="Part name for spd_gen output (defaults to MPN or input filename)",
|
|
)
|
|
ap.add_argument(
|
|
"--include-nondefault-timings",
|
|
action="store_true",
|
|
help="Include timing fields only when they differ from spd_gen defaults (spd_gen output only)",
|
|
)
|
|
ap.add_argument(
|
|
"--pretty",
|
|
action="store_true",
|
|
help="Pretty-print JSON output with a command header",
|
|
)
|
|
args = ap.parse_args(argv)
|
|
|
|
spd = _parse_hex_file(args.hexfile)
|
|
decoded = detect_and_decode(spd)
|
|
|
|
if args.format == "raw":
|
|
out_obj = decoded
|
|
else:
|
|
part_name = args.name or decoded.get("manufacturerPartNumber") or _default_part_name_from_path(args.hexfile)
|
|
out_obj = _to_spd_gen_memory_parts(decoded, part_name, args.include_nondefault_timings)
|
|
|
|
if args.pretty:
|
|
header = f"// Generated by:\n// {' '.join([sys.argv[0]] + argv)}\n\n"
|
|
print(header + _json_dumps_tabs(out_obj), end="")
|
|
else:
|
|
print(json.dumps(out_obj, separators=(",", ":"), ensure_ascii=False))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main(sys.argv[1:]))
|