8/23/2026

The X-macro, from #define to runtime reflection

C · Preprocessor

C forgets the name of every field the moment it finishes compiling. The X-macro is how a C programmer gets those names back — by writing the list once and replaying it as an enum, a string table, a descriptor table, and a set of registration calls that run before main.

Almost everyone meets #define as a way to name the number 100 and then never thinks about it again. That is a shame, because the preprocessor is the only part of C that can write C for you, and there is one pattern in particular that turns it from a curiosity into something load-bearing.

The problem it solves is dull enough that it hides in plain sight: one piece of knowledge, written down in two places. An enum and the array of names that goes with it. A struct and the code that serialises it. A config table and the parser that reads it. Each pair is fine on the day it is written and wrong three months later, when someone edits one half.

This is written as a path. Part 1 is the mechanics — what a macro actually does, and how a list grows a hole in it. Part 2 builds the useful thing: a runtime table describing a struct, with offsets the compiler computes. Part 3 makes that table reachable, first from elsewhere in the program and then from Python. Part 4 is the part people learn the hard way.

Everything here was compiled and run before publishing — gcc 11.4.0 on x86-64, CPython 3.10 — and every output block is copied from a real run, including the error message.

Contents

  1. The preprocessor — what is really happening before the compiler starts
  2. Describing a struct — the payoff: a table the compiler fills in
  3. Making it reachable — from a table nobody can find to one everybody can
  4. Practice — traps, tooling, and knowing when to stop
  5. Appendix — the three programs in full, and how to build them

Part 1 · The preprocessor

A macro is text, not a value

Most people meet #define as a way to name a constant, and stop there.

the half everyone knows
#define MAX_USERS 100

The other half is that a macro can take parameters, and that what it does is substitute text before the compiler ever sees the file.

the other half
#define SQUARE(x) ((x) * (x))

int n = SQUARE(3 + 1);      /* becomes ((3 + 1) * (3 + 1)), so 16 */

Nothing is evaluated. No types are checked. There is no function call, no scope, no x anywhere at runtime. The preprocessor pastes characters and moves on, and the compiler then reads the result as if you had typed it yourself.

Hold on to that sentence, because every clever thing below is just careful pasting. The parentheses around (x) in that snippet are the classic example of why: without them, SQUARE(3 + 1) pastes into 3 + 1 * 3 + 1, which is 7.

Mental model

A macro is not a function that runs early. It is a find-and-replace rule that runs on your source text. When an expansion surprises you, the answer is always visible in the pasted text — see debugging expansions.

Two lists that must agree

Say you have three colours. You need an enum for the code, and names for logging.

the obvious way
enum color { COLOR_RED, COLOR_GREEN, COLOR_BLUE, COLOR_COUNT };

static const char *color_names[] = { "RED", "GREEN", "BLUE" };

This is fine. It stays fine right up until someone adds COLOR_YELLOW to the enum and does not touch the array. Now color_names[COLOR_YELLOW] reads past the end of the array, and nothing warns you — as far as the compiler is concerned these are two unrelated declarations that happen to sit next to each other. Keeping them in sync is a rule that lives only in someone's memory.

The same shape shows up everywhere once you start looking for it: an enum and the switch over it, a struct and its serialiser, a set of command-line flags and the help text. Two lists, one fact, and a silent failure when they disagree.

The X-macro: give the list a parameter

The idea is to write the list exactly once, but leave a hole where the action goes.

the list
#define COLOR_LIST(X) \
    X(RED)            \
    X(GREEN)          \
    X(BLUE)

COLOR_LIST is not a value and cannot be used on its own. It is a list that does not yet know what to do with its items — X is the hole. By convention that parameter is named X, which is the whole reason the pattern is called an X-macro; any name works.

Now define what a single item should become, and hand it in.

two actions, one list
#define AS_ENUM(name)   COLOR_##name,
#define AS_STRING(name) #name,

enum color { COLOR_LIST(AS_ENUM) COLOR_COUNT };

static const char *color_names[] = { COLOR_LIST(AS_STRING) };

COLOR_LIST(AS_ENUM) pastes into COLOR_RED, COLOR_GREEN, COLOR_BLUE, and COLOR_LIST(AS_STRING) pastes into "RED", "GREEN", "BLUE",. Two completely different pieces of code out of one list, and the trailing comma is harmless in both because COLOR_COUNT follows the first and C allows a trailing comma in an initialiser.

Add X(YELLOW) to the list and both the enum and the name array grow. The synchronisation bug is not fixed by discipline here; it has been made impossible to write.

./demo
colors: 3
  0 = RED
  1 = GREEN
  2 = BLUE
Note

Notice where the comma lives: inside AS_ENUM and AS_STRING, not in COLOR_LIST. The list carries no punctuation at all, because different actions want different separators — an enum wants commas, a sequence of statements wants none.

The two operators: # and ##

Those action macros used two operators that exist nowhere else in C.

OperatorNameGiven name = RED
#namestringifyproduces the string literal "RED"
A##Btoken pasteCOLOR_##name produces the single token COLOR_RED

The property that makes # worth the trouble is that the string comes from the actual token you wrote. You cannot end up with a name string that disagrees with the identifier beside it, because they are literally the same characters. Every hand-written "id" next to a field called id is a copy waiting to go stale; #M is not a copy.

## is what lets one list generate distinct identifiers — COLOR_RED, sensor_reading_fields, FIELD_IDX_timestamp. It has one sharp edge, which we will hit in the section on unique names: ## suppresses macro expansion of its own operands.

Part 2 · Describing a struct

What C throws away

Colours are a warm-up. Here is the use that actually earns the pattern its place.

C discards field names at compile time. After the compiler is done, a struct is a size and a set of byte offsets; the word timestamp exists in the debug info, if you asked for it, and nowhere else. So the moment something outside the compiler needs to know that "byte 16 of this struct is a double called timestamp" — a serialiser, a logger, a test harness, another language — somebody has to write that down.

Writing it down by hand is the two-lists problem again, with worse consequences, because now the second list contains numbers.

demo.c — the struct
struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};
demo.c — what we want to produce
typedef enum { FIELD_INT, FIELD_FLOAT, FIELD_DOUBLE, FIELD_PTR } field_type;

typedef struct {
    const char *name;
    field_type  type;
    unsigned    offset;
    unsigned    size;
} field_desc;

Building the field table

Same shape as before: write the list once, with a hole. Each item now carries three pieces of information instead of one — the struct it belongs to, the member, and a type tag.

demo.c — the list
#define SENSOR_FIELDS(X)                               \
    X(struct sensor_reading, id, FIELD_INT)            \
    X(struct sensor_reading, sample_count, FIELD_INT)  \
    X(struct sensor_reading, temperature, FIELD_FLOAT) \
    X(struct sensor_reading, timestamp, FIELD_DOUBLE)  \
    X(struct sensor_reading, label, FIELD_PTR)
demo.c — one action, one table
#define AS_FIELD_DESC(S, M, T) \
    { #M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M) },

static const field_desc sensor_layout[] = { SENSOR_FIELDS(AS_FIELD_DESC) };

Two things in that action macro are worth stopping on.

offsetof(S, M) is a standard macro from <stddef.h>, and the crucial word is standard: the compiler computes the byte offset. You never type a number, so the table cannot drift from the real layout when a field is inserted, when the order changes, or when padding rules differ between targets.

sizeof(((S *)0)->M) is the idiom for "the size of member M of type S" when you have no instance to point at. It looks like a null dereference and is not one: sizeof does not evaluate its operand, it only inspects the operand's type, so nothing is ever read from address zero. C11 also offers sizeof(((S){0}).M) if the cast makes you uncomfortable.

Here is what the preprocessor genuinely emits for the first entry, lifted out of gcc -E -P demo.c:

gcc -E -P demo.c — after pasting
{"id", (FIELD_INT), (unsigned)__builtin_offsetof (struct sensor_reading, id),
 (unsigned)sizeof(((struct sensor_reading *)0)->id)},

…and what the compiler folds that into, before anything runs:

constant-folded
{"id", 0, 0, 4}

There is no runtime cost to any of this. The table is a static array of constants in .rodata, exactly as if it had been typed out by hand — the difference is only in who typed it.

The padding you would have got wrong

The full table, printed at runtime:

./demo
sizeof(struct sensor_reading) = 32
field count = 5
name           type  offset  size
id             0     0       4
sample_count   0     4       4
temperature    1     8       4
timestamp      2     16      8
label          3     24      8

Look at temperature: it starts at byte 8 and is 4 bytes long, so it ends at 12. But timestamp starts at 16, not 12. The compiler inserted four bytes of padding so the double lands on an 8-byte boundary. The five field sizes add up to 28; the struct is 32.

A hand-written table would have said 12, and every read through it would have been off by four bytes from that field onward — the kind of bug that produces plausible-looking garbage rather than a crash. offsetof did not get it wrong, and cannot.

ABI

These numbers are for the x86-64 System V ABI. On a different target the offsets and the total size may differ. That is an argument for offsetof rather than a caveat about it: the same source produces the right table on each target.

The same list, a different output

The list is now a reusable asset, and this is where the pattern starts paying compound interest. A different action over the same list gives you named indices into the table:

demo.c
#define AS_INDEX(S, M, T) FIELD_IDX_##M,

enum { SENSOR_FIELDS(AS_INDEX) SENSOR_FIELD_COUNT };
./demo
FIELD_IDX_timestamp = 3

Note that AS_INDEX ignores two of its three parameters. That is normal and fine — the list is a fixed shape, and each action takes what it needs. Adding one field to the struct and one line to the list now updates the descriptor table, the index enum, and the count, in one edit.

Other actions people write over the same list, without changing it: a switch that converts a field name string back to an index, a printf-style dumper with the right format specifier per type tag, a JSON writer, a comparison function, a fuzzer that fills each field with the right kind of noise.

Renaming a field breaks the build — on purpose

This is the property that makes the syntax worth tolerating, and it is easy to miss because nothing visibly happens on a good day.

Rename id to reading_id in the struct and forget the list. The build stops:

gcc -std=c11 -Wall -Wextra -o demo demo.c
error: ‘struct sensor_reading’ has no member named ‘id’
   50 |     {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},
      |                         ^~~~~~~~
   43 |     X(struct sensor_reading, id, FIELD_INT)
      |     ^ in expansion of macro ‘AS_FIELD_DESC’

Compare that with the hand-written table, where the string "id" would go on cheerfully naming a field that no longer exists, and the offset next to it would now describe a different field entirely. A stale hand-written mirror is silent; a stale X-macro list is a compile error. The entire pattern can be justified on that one sentence.

Reading the error

The compiler points at line 50 — inside AS_FIELD_DESC, which is not where your mistake is — and then adds an "in expansion of macro" note pointing at line 43, which is. Always read down to the note.

Part 3 · Making it reachable

Registering yourself before main

A table is only useful if something can find it. The usual arrangement is a small registry that types add themselves to:

reflect.c — the registry API
void layout_register(const char *type_name, const field_desc *fields,
                     size_t count, size_t struct_size);

const field_desc *layout_lookup(const char *type_name, size_t *count_out);

You could call layout_register from main. But then every new type means editing main, and a central list of every type in the program is exactly the drift problem from part 1, moved up one level. Better to have the call happen by itself:

reflect.c — one line adds a type
#define REGISTER_LAYOUT(NAME, STRUCT, FIELDS)                               \
    static const field_desc NAME##_fields[] = { FIELDS(AS_FIELD_DESC) };    \
    __attribute__((constructor)) static void NAME##_register_layout(void) { \
        layout_register(#NAME, NAME##_fields,                               \
                        sizeof(NAME##_fields) / sizeof(NAME##_fields[0]),   \
                        sizeof(STRUCT));                                    \
    }

REGISTER_LAYOUT(sensor_reading, struct sensor_reading, SENSOR_FIELDS)

That one invocation generates three things at once: a static table built from the field list, a function that hands it to the registry, and the annotation that makes the function run on its own. #NAME supplies the lookup key, and sizeof(arr)/sizeof(arr[0]) supplies the count without anyone counting.

__attribute__((constructor)) marks a function to run before main — or, in a shared library, at load time. It is a GCC and Clang extension rather than ISO C; MSVC has its own mechanism via .CRT$XCU, and the portable fallback is an explicit init() that you do have to remember to call. In C++ the same effect falls out of a global object's constructor, which is why you see the trick spelled as a dummy static Registrar r{} there.

Why this shape keeps showing up

  • The knowledge sits next to the thing it describes. The field list lives beside the struct, not in a registry file three directories away.
  • Adding a type is a local edit. No shared file to touch, so no merge conflict when two people add a type in the same week.
  • Nothing can be half-added. The table, the registration and the name all come from one macro invocation, so there is no state where one exists without the others.

Unique names when the macro is used twice

NAME##_fields works because the caller supplies a distinct NAME. Sometimes a macro has to be usable twice in one file with nothing to distinguish the uses — two registrations of the same struct under different keys, say. The standard-ish trick is __COUNTER__, which expands to 0, 1, 2, … each time it is read.

the two-step dance
#define UNIQUE_IMPL2(ctr) static int tmp_var_##ctr;
#define UNIQUE_IMPL(ctr)  UNIQUE_IMPL2(ctr)
#define MAKE_UNIQUE()     UNIQUE_IMPL(__COUNTER__)
Trap

The middle layer is not decoration. ## suppresses expansion of its operands, so without UNIQUE_IMPL you get a variable literally named tmp_var___COUNTER__ — and a redefinition error on the second use. The extra indirection forces __COUNTER__ to expand to a number before it reaches the paste.

__COUNTER__ is supported by GCC, Clang and MSVC but is not in the standard. __LINE__ is the portable substitute and is fine whenever one use per line is an acceptable restriction.

Reading the table from another language

Once the table lives in a shared library behind a plain C function, anything with an FFI can read it. Python's ctypes is in the standard library, so this needs no dependencies:

read_layout.py
class FieldDesc(ctypes.Structure):
    _fields_ = [("name", ctypes.c_char_p), ("type", ctypes.c_int),
                ("offset", ctypes.c_uint), ("size", ctypes.c_uint)]

lib = ctypes.CDLL("./libreflect.so")
lib.layout_lookup.restype = ctypes.POINTER(FieldDesc)

count = ctypes.c_size_t(0)
table = lib.layout_lookup(b"sensor_reading", ctypes.byref(count))

The FieldDesc declaration on the Python side does have to match the C one by hand — that is the one mirror you cannot escape. But it is a single four-field descriptor type that changes approximately never, and it buys you the description of every other struct in the program.

Building the mirror at runtime

Because the table carries names, type tags and offsets, the caller can construct a matching structure definition while the program runs, instead of hand-writing one per struct:

read_layout.py
TAGS = {0: ctypes.c_int, 1: ctypes.c_float, 2: ctypes.c_double, 3: ctypes.c_void_p}

fields = [(table[i].name.decode(), TAGS[table[i].type]) for i in range(count.value)]
Mirror = type("SensorReading", (ctypes.Structure,), {"_fields_": fields})
python3 read_layout.py
id             tag=0 offset=0   size=4
sample_count   tag=0 offset=4   size=4
temperature    tag=1 offset=8   size=4
timestamp      tag=2 offset=16  size=8
label          tag=3 offset=24  size=8

built mirror : SensorReading
python sizeof: 32
c sizeof     : 32
mirror.timestamp offset: 16

The two sizeof values agreeing is the check that matters, and it is worth asserting rather than eyeballing. If they differ, the description is wrong somewhere and every subsequent read through the mirror lands on the wrong bytes — silently, with plausible values.

This is the whole payoff, and it is worth stating plainly: the struct is defined once, in C, and everybody else asks it what it looks like. No generated bindings to regenerate, no Python file to update when a field moves, no version skew between a library and the script reading it — because the description ships inside the library.

Part 4 · Practice

Pitfalls

The trailing backslash must be the last character on the line

One trailing space after it and the continuation silently ends, taking the rest of your list with it. Some compilers warn; do not count on it. This is the single most common way to lose an hour to this pattern, and it is invisible in a diff.

Put the punctuation in the action, not the list

Different actions need different separators. Keep the list free of commas and semicolons so it can serve an enum, an initialiser and a sequence of statements without special cases.

Order matters when position matters

If consumers index the table by position, the list must follow the struct's declaration order. Nothing enforces this — the build still succeeds, and only the offset column looks odd. If you can, index by name instead.

Macros do not recurse

A list cannot include itself, directly or through another macro. Nested lists work only in a strict hierarchy. If you find yourself wanting recursion, you have outgrown the pattern.

Keep the list next to the thing it describes

A field list in a different file from the struct can drift in the one way the pattern exists to prevent: somebody adds a field, never sees the list, and the table is quietly incomplete. Note that this failure is not caught by the compiler — the rename check only fires for fields the list mentions.

The one gap

An X-macro list catches renamed and deleted fields. It does not catch added ones. If completeness matters, add a static assertion that the field sizes plus known padding equal sizeof the struct, or simply assert the struct size and let the build break when it changes.

Debugging with the preprocessor, not by staring

Nearly every X-macro bug becomes obvious the moment you look at the pasted text. Do not reason about it; print it.

see what the compiler sees
gcc -E -P demo.c                      # preprocess only, strip line markers
gcc -E -P demo.c | clang-format       # ...and make it readable
gcc -E -P demo.c | sed -n '/sensor_layout/,/;/p'

-E stops after preprocessing and -P drops the #line markers that otherwise dominate the output. Everything the compiler will see is in there, including the expansion you were sure was correct.

When not to reach for it

When the list has exactly one consumer

If you only need the enum, write the enum. The pattern costs readability up front and repays it per consumer; it breaks even at two and clearly pays at three.

When the list is large, or comes from somewhere else

Past a few dozen entries, or when the real source of truth is a schema file, a protobuf definition or a database, generate a .c file from a script. Generated code can be read, diffed and stepped through in a debugger; a wall of backslash continuations cannot.

When you want type dispatch, not list replay

C11's _Generic selects an expression based on a type. It solves a different problem and reads far better for that one.

When the language has real reflection

This entire pattern is a workaround for C discarding names. In a language that keeps them, use what the language gives you.

Cheat sheet

Preprocessor operators and predefined macros

#xStringify the argument as written
a##bPaste two tokens into one; suppresses expansion of both operands
__COUNTER__0, 1, 2, … per read — needs a two-step macro to paste with
__LINE__, __FILE__Portable alternatives; one use per line
__VA_ARGS__Variadic macro arguments, for actions of varying arity

Layout introspection

offsetof(S, M)Byte offset of a member, from <stddef.h>
sizeof(((S *)0)->M)Size of a member with no instance; operand is not evaluated
sizeof(a) / sizeof(a[0])Element count of a real array — not of a pointer
_Static_assert(c, "msg")Compile-time check; useful for pinning struct size

Running code before main

__attribute__((constructor))GCC and Clang; also runs at dlopen time
Global object constructorC++; the same effect, portably
.CRT$XCU sectionMSVC equivalent
Explicit init()Portable fallback, at the cost of a central call site

Commands

gcc -E -P f.cShow the pasted text the compiler will read
gcc -Wall -WextraCatches trailing-comma and unused-macro-argument mistakes
pahole ./a.outPrint struct layout and padding, independently of your table

Wrapping up

What actually matters here

The X-macro has a reputation for being a clever trick, which undersells it. It is a way of making a class of bug unrepresentable, and the syntax is the price.

The short list

  • The list with a hole is the whole idea. LIST(X) plus an action macro; everything else in this document is a choice of action.
  • Let the compiler compute the numbers. offsetof and sizeof mean the table cannot disagree with the struct, on any target, ever.
  • A stale hand-written mirror is silent; a stale list is a compile error. That trade — a little syntax now for a loud failure later — is the reason to use it.

The registry and the FFI reader in part 3 are a step further than most code needs, but they are the natural end of the road: once the description exists as data, the interesting question stops being "how do I keep these in sync" and becomes "who else would like to read this". Usually the answer is more people than you expected.

Further reading

  • The C Preprocessor — GCC's manual; the chapters on stringizing, concatenation and argument prescan are the relevant ones
  • offsetof — including exactly when it is undefined behaviour
  • ctypes — the FFI used in part 3, standard library, no build step
  • The Linux kernelinclude/linux/kvm_host.h and the tracepoint headers are X-macros at industrial scale, for better and worse

Appendix · The complete programs

Building and running

Everything above came from three short files, reproduced in full below so this page stands on its own — copy the three snippets into a directory and the commands work as written.

build and run everything
gcc -std=c11 -Wall -Wextra -o demo demo.c && ./demo

gcc -std=c11 -Wall -Wextra -shared -fPIC -o libreflect.so reflect.c
python3 read_layout.py

gcc -E -P demo.c | head -40        # see what the preprocessor produced

Verified on gcc 11.4.0 (Ubuntu 22.04, x86-64) and CPython 3.10.12. Every output block on this page is copied from a real run, including the compiler error in the rename section, which was produced by renaming id to reading_id in a copy of demo.c.

demo.c 74 lines

The warm-up and the field table, in one standalone program. Nothing outside the C standard library.

demo.c
#include <stddef.h>
#include <stdio.h>

/* ---------- Warm-up: one list, two outputs ---------- */

#define COLOR_LIST(X) \
    X(RED)            \
    X(GREEN)          \
    X(BLUE)

#define AS_ENUM(name)   COLOR_##name,
#define AS_STRING(name) #name,

enum color { COLOR_LIST(AS_ENUM) COLOR_COUNT };

static const char *color_names[] = {COLOR_LIST(AS_STRING)};

/* ---------- Main: a runtime field table ---------- */

struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};

typedef enum {
    FIELD_INT = 0,
    FIELD_FLOAT = 1,
    FIELD_DOUBLE = 2,
    FIELD_PTR = 3
} field_type;

typedef struct {
    const char *name;
    field_type  type;
    unsigned    offset;
    unsigned    size;
} field_desc;

#define SENSOR_FIELDS(X)                              \
    X(struct sensor_reading, id, FIELD_INT)           \
    X(struct sensor_reading, sample_count, FIELD_INT) \
    X(struct sensor_reading, temperature, FIELD_FLOAT)\
    X(struct sensor_reading, timestamp, FIELD_DOUBLE) \
    X(struct sensor_reading, label, FIELD_PTR)

#define AS_FIELD_DESC(S, M, T) \
    {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},

static const field_desc sensor_layout[] = {SENSOR_FIELDS(AS_FIELD_DESC)};

#define AS_INDEX(S, M, T) FIELD_IDX_##M,

enum { SENSOR_FIELDS(AS_INDEX) SENSOR_FIELD_COUNT };

int main(void) {
    printf("colors: %d\n", (int)COLOR_COUNT);
    for (int i = 0; i < COLOR_COUNT; i++)
        printf("  %d = %s\n", i, color_names[i]);

    printf("\nsizeof(struct sensor_reading) = %zu\n",
           sizeof(struct sensor_reading));
    printf("field count = %d\n", (int)SENSOR_FIELD_COUNT);
    printf("%-14s %-5s %-7s %s\n", "name", "type", "offset", "size");
    for (size_t i = 0; i < sizeof(sensor_layout) / sizeof(sensor_layout[0]); i++)
        printf("%-14s %-5d %-7u %u\n", sensor_layout[i].name,
               (int)sensor_layout[i].type, sensor_layout[i].offset,
               sensor_layout[i].size);

    printf("\nFIELD_IDX_timestamp = %d\n", (int)FIELD_IDX_timestamp);
    return 0;
}

reflect.c 83 lines

The registry plus a type that puts itself in it. Built as a shared library; it has no main, and the only thing that runs is the constructor.

reflect.c
#include <stddef.h>
#include <string.h>

typedef struct {
    const char *name;
    int         type;
    unsigned    offset;
    unsigned    size;
} field_desc;

typedef struct {
    const char       *type_name;
    const field_desc *fields;
    size_t            count;
    size_t            struct_size;
} type_layout;

/* A tiny fixed-capacity registry. */
static type_layout g_registry[16];
static size_t      g_registry_count;

void layout_register(const char *type_name, const field_desc *fields, size_t count,
                     size_t struct_size) {
    if (g_registry_count >= 16) return;
    g_registry[g_registry_count].type_name = type_name;
    g_registry[g_registry_count].fields = fields;
    g_registry[g_registry_count].count = count;
    g_registry[g_registry_count].struct_size = struct_size;
    g_registry_count++;
}

const field_desc *layout_lookup(const char *type_name, size_t *out_count) {
    for (size_t i = 0; i < g_registry_count; i++) {
        if (strcmp(type_name, g_registry[i].type_name) == 0) {
            if (out_count) *out_count = g_registry[i].count;
            return g_registry[i].fields;
        }
    }
    if (out_count) *out_count = 0;
    return NULL;
}

size_t layout_sizeof(const char *type_name) {
    for (size_t i = 0; i < g_registry_count; i++)
        if (strcmp(type_name, g_registry[i].type_name) == 0)
            return g_registry[i].struct_size;
    return 0;
}

/* ---- a type that registers itself ---- */

struct sensor_reading {
    int    id;
    int    sample_count;
    float  temperature;
    double timestamp;
    char  *label;
};

#define FIELD_INT 0
#define FIELD_FLOAT 1
#define FIELD_DOUBLE 2
#define FIELD_PTR 3

#define SENSOR_FIELDS(X)                               \
    X(struct sensor_reading, id, FIELD_INT)            \
    X(struct sensor_reading, sample_count, FIELD_INT)  \
    X(struct sensor_reading, temperature, FIELD_FLOAT) \
    X(struct sensor_reading, timestamp, FIELD_DOUBLE)  \
    X(struct sensor_reading, label, FIELD_PTR)

#define AS_FIELD_DESC(S, M, T) \
    {#M, (T), (unsigned)offsetof(S, M), (unsigned)sizeof(((S *)0)->M)},

#define REGISTER_LAYOUT(NAME, STRUCT, FIELDS)                                 \
    static const field_desc NAME##_fields[] = {FIELDS(AS_FIELD_DESC)};        \
    __attribute__((constructor)) static void NAME##_register_layout(void) {   \
        layout_register(#NAME, NAME##_fields,                                 \
                        sizeof(NAME##_fields) / sizeof(NAME##_fields[0]),     \
                        sizeof(STRUCT));                                      \
    }

REGISTER_LAYOUT(sensor_reading, struct sensor_reading, SENSOR_FIELDS)
Note

field_desc is declared identically in both C files rather than shared through a header, so that each one can be read on its own. In a real program it would live in a header, and layout_register would be declared there too.

read_layout.py 37 lines

The other side of the wall: no C compiler involved, no generated bindings, just the library and the standard library.

read_layout.py
import ctypes


class FieldDesc(ctypes.Structure):
    _fields_ = [
        ("name", ctypes.c_char_p),
        ("type", ctypes.c_int),
        ("offset", ctypes.c_uint),
        ("size", ctypes.c_uint),
    ]


lib = ctypes.CDLL("./libreflect.so")

lib.layout_lookup.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_size_t)]
lib.layout_lookup.restype = ctypes.POINTER(FieldDesc)
lib.layout_sizeof.argtypes = [ctypes.c_char_p]
lib.layout_sizeof.restype = ctypes.c_size_t

TAGS = {0: ctypes.c_int, 1: ctypes.c_float, 2: ctypes.c_double, 3: ctypes.c_void_p}

count = ctypes.c_size_t(0)
table = lib.layout_lookup(b"sensor_reading", ctypes.byref(count))

fields = []
for i in range(count.value):
    f = table[i]
    print(f"{f.name.decode():14} tag={f.type} offset={f.offset:<3} size={f.size}")
    fields.append((f.name.decode(), TAGS[f.type]))

Mirror = type("SensorReading", (ctypes.Structure,), {"_fields_": fields})

print()
print("built mirror :", Mirror.__name__)
print("python sizeof:", ctypes.sizeof(Mirror))
print("c sizeof     :", lib.layout_sizeof(b"sensor_reading"))
print("mirror.timestamp offset:", Mirror.timestamp.offset)

Setting argtypes and restype is not optional decoration. Without them ctypes assumes every argument and the return value are int, which on x86-64 truncates pointers to 32 bits and produces a segfault that looks like it came from the C side.

No comments:

Post a Comment