Understanding the Problem: const vs. Constant Expressions in C

In C, declaring a variable as const simply creates a read-only variable; it does not make the identifier a compile-time constant expression. When you moved your array definition from a header (static const) into a dedicated source file (extern const), the compiler lost visibility of the array's values while compiling other translation units (like bar.c).

Because variables with static storage duration (such as static uint8_t const shared_mode = ...) require an integer constant expression known strictly at compile time, the compiler raises an error:

error: initializer element is not a compile-time constant

In this guide, we explore why this happens and look at the best modern patterns—including new features in C23—to maintain single-instance binary storage while preserving compile-time constant capabilities.

Why extern const Fails as a Static Initializer

When compilation happens in C, each .c file (translation unit) is compiled into an object file independently. With an extern const declaration, the compiler only knows that the symbol exists somewhere in memory; it cannot look into the external object file to resolve array elements during compilation.

// bar.c
#include "pin_definitions.h"

void do_something_else(void) {
    // ERROR: pin_definitions is an incomplete extern symbol at compile time
    static uint8_t const shared_mode = pin_definitions[12].pin_mode;
}

Solution 1: Use C23 constexpr in a Shared Header

If you are working with standard C23 (available with gcc -std=c23 or -std=gnu23 in GCC 13+), you have access to the constexpr specifier. Objects declared constexpr are true compile-time constants.

In C23, constexpr object definitions at file scope have internal linkage by default if specified with static, but the compiler is free to eliminate them completely if their address is never taken:

// pin_definitions.h
#pragma once
#include <stdint.h>

typedef struct {
    uint32_t pin_address;
    uint8_t pin_mode;
} pin_definition_t;

constexpr uint_fast16_t PIN_COUNT = 345;

constexpr pin_definition_t PIN_DEFINITIONS[] = {
    { .pin_address = 0x0000000a, .pin_mode = 0xea },
    { .pin_address = 0x0000000f, .pin_mode = 0x12 },
    // ...
};

Now in bar.c, expressions indexing PIN_DEFINITIONS are evaluated as compile-time constants:

// bar.c
#include "pin_definitions.h"

void do_something_else(void) {
    // Valid in C23: PIN_DEFINITIONS[12].pin_mode is a constant expression
    static uint8_t const shared_mode = PIN_DEFINITIONS[12].pin_mode;
}

Solution 2: The X-Macro Pattern (Single Source of Truth)

If you want a single non-duplicated global array in memory (extern const) and the ability to evaluate specific elements at compile time across multiple files, the X-Macro pattern is the standard industry approach in C.

1. Define the Data in a Shared Header

// pin_data.def (or pin_definitions.h)
#define PIN_TABLE(X) \
    X(0, 0x0000000a, 0xea) \
    X(1, 0x0000000f, 0x12) \
    X(2, 0x00000014, 0x04)

2. Instantiate the Array in pin_definitions.c

// pin_definitions.c
#include "pin_definitions.h"

#define EXPAND_ARRAY(id, addr, mode) { .pin_address = addr, .pin_mode = mode },

const pin_definition_t pin_definitions[] = {
    PIN_TABLE(EXPAND_ARRAY)
};

const uint16_t PIN_COUNT = sizeof(pin_definitions) / sizeof(pin_definitions[0]);

3. Generate Compile-Time Constants in pin_definitions.h

// pin_definitions.h
#pragma once
#include <stdint.h>

typedef struct {
    uint32_t pin_address;
    uint8_t pin_mode;
} pin_definition_t;

#define PIN_TABLE(X) \
    X(0, 0x0000000a, 0xea) \
    X(1, 0x0000000f, 0x12)

// Generate compile-time enum constants for modes or addresses
#define EXPAND_ENUM(id, addr, mode) PIN_##id##_MODE = mode,
enum {
    PIN_TABLE(EXPAND_ENUM)
};
#undef EXPAND_ENUM

extern const pin_definition_t pin_definitions[];
extern const uint16_t PIN_COUNT;

With this approach, bar.c can directly initialize static variables using compile-time constants like PIN_0_MODE, while foo.c iterates over the runtime array pin_definitions without duplicating binary space.

Solution 3: GCC/Clang Linker Deduplication (__attribute__((weak)))

If you prefer keeping the entire array in a header file but want the linker to merge identical arrays into a single entry across translation units, you can mark the array as weak (or use inline variables in GNU C mode):

// pin_definitions.h
#pragma once
#include <stdint.h>

typedef struct {
    uint32_t pin_address;
    uint8_t pin_mode;
} pin_definition_t;

// GNU extension: weak symbol allows duplicate definitions to merge at link time
__attribute__((weak))
const pin_definition_t pin_definitions[] = {
    { .pin_address = 0x0000000a, .pin_mode = 0xea },
    { .pin_address = 0x0000000f, .pin_mode = 0x12 },
};

#define PIN_COUNT (sizeof(pin_definitions) / sizeof(pin_definitions[0]))

Note: While weak symbols prevent multiple definition linker errors and merge read-only memory, static variable initializers inside other translation units may still require values defined in that translation unit unless optimization flags like -flto (Link-Time Optimization) and constant propagation are enabled.

Summary

  • In C, const does not create a constant expression; it only creates a read-only variable.
  • In modern C23, prefer using constexpr in the header file for true compile-time constant arrays.
  • For pre-C23 or strict single-definition memory constraints across separate TUs, use the X-Macro pattern to separate compile-time scalar constants from the binary array table.