Every R object lives in memory behind a fixed-size C header, the
SEXPREC: a word of bitfields, three pointers and, for a
vector, its length. A fixed-size record is exactly what
bin_layout() describes, so the header makes a small worked
example of a layout over bytes that zubin did not write: native byte
order, a bitfield word, pointers, and a body at a known offset.
This is a way to look, not a way to program. The structure is private
to R (it is in R’s Defn.h, not in its API), it changes
between R versions, and nothing here belongs in a package. To inspect an
object in earnest use lobstr::sxp(), or
.Internal(inspect(x)) at the console; the last section says
how each reads the header. The page assumes a 64-bit build of R 4.x,
where the header is 48 bytes.
Getting the bytes
zubin reads raw vectors, and no R function hands over an object’s own
memory, so the page compiles a three-line C function that copies the
first n bytes of the header into one:
stopifnot(.Machine$sizeof.pointer == 8)
library(zubin)
src <- tempfile(fileext = ".c")
writeLines(c(
"#include <string.h>",
"#include <Rinternals.h>",
"SEXP peek(SEXP x, SEXP n) {",
" SEXP out = PROTECT(Rf_allocVector(RAWSXP, Rf_asInteger(n)));",
" memcpy(RAW(out), (const void *) x, (size_t) XLENGTH(out));",
" UNPROTECT(1);",
" return out;",
"}"
), src)
system2(file.path(R.home("bin"), "R"), c("CMD", "SHLIB", shQuote(src)), stdout = FALSE)
dll <- dyn.load(sub("\\.c$", .Platform$dynlib.ext, src))
peek <- function(x, n = 48L) .Call(dll$peek, x, n)peek() copies as many bytes as it is asked for, and
nothing checks them against the object: the caller asks for no more than
the object holds. An integer vector of three elements holds 48 bytes of
header and 12 of data.
x <- c(10L, 20L, 30L)
b <- peek(x, 60L)
bin_hexdump(b)
#> 00000000: 0d00 0040 0300 0000 200f b6ce 3556 0000 ...@.... ...5V..
#> 00000010: b8d8 05d3 3556 0000 38d9 05d3 3556 0000 ....5V..8...5V..
#> 00000020: 0300 0000 0000 0000 0000 0000 0000 0000 ................
#> 00000030: 0a00 0000 1400 0000 1e00 0000 ............The layout
On a 64-bit build the header is:
| Offset | Field | C type |
|---|---|---|
| 0 | sxpinfo |
64 bits of bitfields |
| 8 | attrib |
SEXP, the attribute pairlist |
| 16 | gengc_next_node |
SEXP, the garbage collector’s list |
| 24 | gengc_prev_node |
SEXP |
| 32 | length |
R_xlen_t, vectors only |
| 40 | truelength |
R_xlen_t, vectors only |
| 48 | the data | what INTEGER(x), REAL(x) and the rest
point to |
This is memory of the running process, so the byte order is the
host’s: =, never a guess at little-endian.
sxpinfo is two u32 because a u64
that holds bitfields can pass 2^53, past what a double holds exactly;
the pointers are user-space addresses, far below 2^53, and read as
b8 to be printed in hexadecimal. next is a
reserved word in R, so the pointer fields take other names.
header <- bin_layout(
"= info:u32[2] attrib:b8 gc_next:b8 gc_prev:b8 length:i64 truelength:i64"
)
header
#> <zubin_layout: 48 bytes, 6 fields>
#> name type count size offset endian
#> info u32 2 8 0 little
#> attrib b8 1 8 8
#> gc_next b8 1 8 16
#> gc_prev b8 1 8 24
#> length i64 1 8 32 little
#> truelength i64 1 8 40 little
h <- bin_unpack(b, header)
h[c("length", "truelength")]
#> length truelength
#> 1 3 0
bin_decode(b, "i32", offset = 48, n = h$length, endian = "native")
#> [1] 10 20 30The bitfields
zubin has no bitfield type: a field is whole bytes. The bits come out with arithmetic on the two words. GCC and Clang allocate bitfields from the least significant bit on the targets R runs on, which gives, in R 4.x:
| Word | Bits | Field |
|---|---|---|
| 1 | 0–4 |
type, the SEXPTYPE (13 is
INTSXP) |
| 1 | 5, 6, 7 |
scalar, obj (has a class),
alt (ALTREP) |
| 1 | 8–23 |
gp, general-purpose bits |
| 1 | 24–27 |
mark, debug, trace,
spare
|
| 1 | 28, 29–31 |
gcgen, gccls (the collector’s generation
and size class) |
| 2 | 0–15 |
named, the reference count |
| 2 | 16–31 | extra |
bits <- function(v, from, width) (v %/% 2^from) %% 2^width
sxpinfo <- function(h) {
w1 <- h$info.1
w2 <- h$info.2
data.frame(
type = bits(w1, 0, 5), scalar = bits(w1, 5, 1), obj = bits(w1, 6, 1),
alt = bits(w1, 7, 1), gp = bits(w1, 8, 16), gcgen = bits(w1, 28, 1),
gccls = bits(w1, 29, 3), named = bits(w2, 0, 16)
)
}
address <- function(p) paste0("0x", sub("^0+", "", paste(rev(as.character(p)), collapse = "")))
sxpinfo(h)
#> type scalar obj alt gp gcgen gccls named
#> 1 13 0 0 0 0 0 2 3What the header shows
A second binding to the same vector raises its reference count, and copies nothing:
refs <- function(x) sxpinfo(bin_unpack(peek(x), header))$named
before <- refs(x)
y <- x
c(before = before, after = refs(x))
#> before after
#> 4 6Without attributes, attrib points to NULL,
which is one object of its own, so every bare object points to the same
address. An attribute makes it a pairlist of its own:
attrib <- function(x) address(bin_unpack(peek(x), header)$attrib[[1]])
z <- structure(c(1, 2), units = "cm")
c(double = attrib(c(1, 2)), string = attrib("a"), with_attr = attrib(z))
#> double string with_attr
#> "0x5635ceb60f20" "0x5635ceb60f20" "0x5635d4a29c20"1:3 is an ALTREP object, a compact sequence that stores
its start and end, not its elements: alt is set, and the 16
bytes at offset 32 hold ALTREP’s own pointers, not a length.
sxpinfo(bin_unpack(peek(1:3), header))[c("type", "alt")]
#> type alt
#> 1 13 1A class sets obj:
sxpinfo(bin_unpack(peek(factor("a")), header))[c("type", "obj")]
#> type obj
#> 1 13 1The same fields, as R prints them; the ATTRIB address is
the one attrib() read above:
.Internal(inspect(z))
#> @5635d49e82c8 14 REALSXP g0c2 [REF(2),ATT] (len=2, tl=0) 1,2
#> ATTRIB:
#> @5635d4a29c20 02 LISTSXP g0c0 [REF(1)]
#> TAG: @5635cec6ef48 01 SYMSXP g1c0 [MARK,REF(155),LCK,gp=0x4000] "units" (has value)
#> @5635d49c0d48 16 STRSXP g0c1 [REF(6)] (len=1, tl=0)
#> @5635cf323848 09 CHARSXP g1c1 [MARK,REF(97),gp=0x61] [ASCII] [cached] "cm"How the real tools read the header
peek() is one of three ways to see these fields, and the
only one that can break without warning.
-
.Internal(inspect(x))is part of R. It is compiled from R’s own sources against the privateDefn.h, in the same build as the structure it prints, so it can read every bit and never falls out of step with the layout. Its output format is not promised, and.Internal()is for R’s own code: use it at the console, never in a package. -
lobstr::sxp()reads nothing directly. It asks R through the API:TYPEOF(),ALTREP(),Rf_isObject(),XLENGTH(),ANY_ATTRIB()andR_mapAttrib(), and for the reference countNO_REFERENCES()andMAYBE_SHARED(). The address it prints is the pointer’s value, never dereferenced. It shows less as a result: references as 0, 1 or 2+, and nogpbits, collector state ortruelength. Where R 4.5 replaced an accessor, lobstr follows behind#if R_VERSIONguards. -
peek()and a layout copy the memory and decode it against a description of a private structure. That is how it shows the exact reference count and the collector’s size class, which the API does not expose; it is also why a change to the structure in a later R makes it print wrong numbers rather than fail.
Code that runs anywhere but your own console belongs with the second.