DEV Community

Cover image for Sharing Reusable C Headers Between Perl XS Distributions
LNATION for LNATION

Posted on

Sharing Reusable C Headers Between Perl XS Distributions

Introduction

Since I last wrote a XS tutorial my knowledge within C has increased this has come from improvements in LLM software that has assisted in improving my knowledge where previously I would be stuck. This knowledge and software has since enabled me to craft more elegant and efficient XS implementations.

Today I will share with you my technique for writing reusable C/XS code.

One of the most powerful patterns in XS development is writing your core logic in pure C header files. This gives you:

  • Zero-cost reuse - no runtime linking, no shared libraries, just a #include line.
  • No Perl dependency in the C layer - your headers work in any C project
  • Compile-time inlining - the compiler sees everything, optimises aggressively
  • Simple distribution - headers are installed alongside the Perl module via PM

This tutorial walks through the complete pattern step by step, using a minimal working example you can build and run yourself.


The Example

We will create two distributions:

  1. Abacus - a provider distribution that ships a reusable pure-C abacus_math.h header containing simple arithmetic functions
  2. Tally - a consumer distribution that #includes the Abacus header to build its own XS module, without duplicating any C code

As always lets start by creating the distributions that we will need for this tutorial. Open your terminal and run module-starter. If you are using a modern version of Module::Starter then the command has changed slightly since my last posts.

  module-starter --module=Abacus --author="LNATION <email@lnation.org>"
  module-starter --module=Tally --author="LNATION <email@lnation.org>"
Enter fullscreen mode Exit fullscreen mode

Part 1: The Provider Distribution (Abacus)

Write the pure-C header

This is the reusable part. It has zero Perl dependencies - just standard C.

Now enter the Abacus and create the include directory:

  cd Abucus
  mkdir include
Enter fullscreen mode Exit fullscreen mode

Then create a new file abacus_math.h:

  touch abacus_math.h
  vim abacus_math.h
Enter fullscreen mode Exit fullscreen mode

Paste the following code into the file:

#ifndef ABACUS_MATH_H
#define ABACUS_MATH_H

/*
 * abacus_math.h - Pure C arithmetic library (no Perl dependencies)
 *
 * This header is the reusable entry point for any C or XS project
 * that needs basic arithmetic operations. It has ZERO Perl/XS
 * dependencies.
 *
 * Usage from another XS module:
 *
 *     #include "abacus_math.h"
 *
 * Build: add -I/path/to/Abacus/include to your compiler flags.
 */

#include <stdint.h>

/* ── Error handling hook ─────────────────────────────────────────
 *
 * Consumers can #define ABACUS_FATAL(msg) before including this
 * header to route errors through their own mechanism.
 *
 * In an XS module you would typically do:
 *
 *     #define ABACUS_FATAL(msg) croak("%s", (msg))
 *     #include "abacus_math.h"
 *
 * In plain C the default behaviour is fprintf + abort.
 */
#ifndef ABACUS_FATAL
#  include <stdio.h>
#  include <stdlib.h>
#  define ABACUS_FATAL(msg) do { \
       fprintf(stderr, "abacus fatal: %s\n", (msg)); \
       abort(); \
   } while (0)
#endif

/* ── Arithmetic operations ───────────────────────────────────── */

static inline int32_t
abacus_add(int32_t a, int32_t b) {
    return a + b;
}

static inline int32_t
abacus_subtract(int32_t a, int32_t b) {
    return a - b;
}

static inline int32_t
abacus_multiply(int32_t a, int32_t b) {
    return a * b;
}

static inline int32_t
abacus_divide(int32_t a, int32_t b) {
    if (b == 0) {
        ABACUS_FATAL("division by zero");
    }
    return a / b;
}

static inline int32_t
abacus_factorial(int32_t n) {
    int32_t result = 1;
    int32_t i;
    if (n < 0) {
        ABACUS_FATAL("factorial of negative number");
    }
    for (i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

#endif /* ABACUS_MATH_H */
Enter fullscreen mode Exit fullscreen mode

The code above demonstrates three critical design patterns for reusable C headers:

static inline functions eliminate linker complications by giving each translation unit its own copy of the function. The compiler can then inline these small arithmetic operations directly into the call site, producing zero-overhead abstractions. This is key to the "zero-cost reuse" principle—there is no shared library dependency, no function call overhead, just pure generated code.

The ABACUS_FATAL macro hook provides a customization point for error handling. By default, it calls fprintf() and abort() in standalone C programs; but consumers can #define ABACUS_FATAL(msg) croak("%s", (msg)) before including the header to integrate seamlessly with Perl's exception system. This single mechanism allows the same C header to work across Perl XS, plain C, and other environments without code duplication.

The use of only stdint.h integers and no Perl types ensures the header remains truly portable. There are no SV* pointers, no pTHX context variables, no XSUB.h includes—just standard C99 types. This purity is what allows the header to be #included into any C or XS project without creating hidden Perl dependencies at the C layer.

Write the Perl-facing XS header

Next we will add another header which will hold the Perl/XS specific logic. Inside the include directory create a new file called abacus.h. The rational behind this thin wrapper is to pull in Perl's headers and sets up the ABACUS_FATAL macro to use croak(). To reiterate only a XS distribution should include this header, whereas abacus_math.h is generic and could be used by other languages which bind C.

    touch abacus.h
    vim abacus.h
Enter fullscreen mode Exit fullscreen mode

Paste the following code into the file

#ifndef ABACUS_H
#define ABACUS_H

/*
 * abacus.h - Perl XS wrapper header for the Abacus library
 *
 * This header sets up Perl-specific error handling and includes
 * the pure C core library.
 *
 * For reuse from OTHER XS modules without Perl overhead, include
 * abacus_math.h directly instead (see that header for usage).
 */

#define PERL_NO_GET_CONTEXT
#include "EXTERN.h"
#include "perl.h"
#include "XSUB.h"
#include "ppport.h"

/* Route fatal errors through Perl's core croak() */
#define ABACUS_FATAL(msg) croak("%s", (msg))

/* Pull in the pure-C library */
#include "abacus_math.h"

#endif /* ABACUS_H */
Enter fullscreen mode Exit fullscreen mode

Now any .xs file can #include "abacus.h" and get the full Perl/XS environment plus all the pure-C functions, with errors properly integrated.

Write the XS file

Next we will create the XS file, return to the root directory and then enter the lib directoy where you should see the Abacus.pm file already. Create a new XS file called Abacus.xs. This will be the glue that exposes the C functions to Perl.

  cd ../lib
  touch Abacus.xs
  vim Abacus.xs
Enter fullscreen mode Exit fullscreen mode
#include "abacus.h"

MODULE = Abacus  PACKAGE = Abacus

PROTOTYPES: DISABLE

int
add(a, b)
    int a
    int b
  CODE:
    RETVAL = abacus_add(a, b);
  OUTPUT:
    RETVAL

int
subtract(a, b)
    int a
    int b
  CODE:
    RETVAL = abacus_subtract(a, b);
  OUTPUT:
    RETVAL

int
multiply(a, b)
    int a
    int b
  CODE:
    RETVAL = abacus_multiply(a, b);
  OUTPUT:
    RETVAL

int
divide(a, b)
    int a
    int b
  CODE:
    RETVAL = abacus_divide(a, b);
  OUTPUT:
    RETVAL

int
factorial(n)
    int n
  CODE:
    RETVAL = abacus_factorial(n);
  OUTPUT:
    RETVAL
Enter fullscreen mode Exit fullscreen mode

As you can see we include abacus.h which pulls in all the C that we need to create our XS module. We then define add, subtract, multiply, divide and factorial as XSUBs. As you should know by now XSUBs can be called directly from your perl code.

Write the Perl module with include_dir()

Next open the pm file and update to add an exporter for the XSUBS we have just created.

package Abacus;

use 5.008003;
use strict;
use warnings;

our $VERSION = '0.01';

use Exporter 'import';
our @EXPORT_OK = qw(add subtract multiply divide factorial);

require XSLoader;
XSLoader::load('Abacus', $VERSION);

Enter fullscreen mode Exit fullscreen mode

Now the critical piece that makes header sharing work. A include_dir() method which returns the path to the installed headers so that consumer distributions can find them at build time.

sub include_dir {
    my $dir = $INC{'Abacus.pm'};
    $dir =~ s{Abacus\.pm$}{Abacus/include};
    return $dir;
}

1;
Enter fullscreen mode Exit fullscreen mode

How include_dir() works:

  1. When Perl loads Abacus.pm, it records the full path in %INC (e.g. /usr/lib/perl5/site_perl/Abacus.pm)
  2. include_dir() replaces Abacus.pm with Abacus/include
  3. That directory exists because Makefile.PL installs the headers there (see next step)

Write the Makefile.PL that installs headers

The PM hash is what makes headers available to other distributions after install. It maps source files to their installation destinations.

Abacus/Makefile.PL

use 5.008003;
use strict;
use warnings;
use ExtUtils::MakeMaker;

WriteMakefile(
    NAME             => 'Abacus',
    AUTHOR           => 'Your Name <you@example.com>',
    VERSION_FROM     => 'lib/Abacus.pm',
    ABSTRACT_FROM    => 'lib/Abacus.pm',
    LICENSE          => 'artistic_2',
    MIN_PERL_VERSION => '5.008003',
    CONFIGURE_REQUIRES => {
        'ExtUtils::MakeMaker' => '0',
    },
    TEST_REQUIRES => {
        'Test::More' => '0',
    },
    PREREQ_PM => {},
    XSMULTI => 1,
    # XS configuration
    INC    => '-I. -Iinclude',
    OBJECT => '$(O_FILES)',

    # *** THIS IS THE KEY PART ***
    # Install headers alongside the module so dependent
    # distributions can find them via Abacus->include_dir()
    PM => {
        'lib/Abacus.pm'           => '$(INST_LIB)/Abacus.pm',
        'include/abacus.h'        => '$(INST_LIB)/Abacus/include/abacus.h',
        'include/abacus_math.h'   => '$(INST_LIB)/Abacus/include/abacus_math.h',
    },

    dist  => { COMPRESS => 'gzip -9f', SUFFIX => 'gz' },
    clean => { FILES => 'Abacus-*' },
);
Enter fullscreen mode Exit fullscreen mode

The PM hash does two things:

  1. Installs Abacus.pm as normal
  2. Copies the header files into Abacus/include/ alongside the module

After make install, the filesystem looks like:

site_perl/
  Abacus.pm
  Abacus/
    include/
      abacus.h
      abacus_math.h
Enter fullscreen mode Exit fullscreen mode

Write a test

Abacus/t/01-basic.t

use strict;
use warnings;
use Test::More;
use Abacus qw(add subtract multiply divide factorial);

is(add(2, 3),       5,   'add');
is(subtract(10, 4), 6,   'subtract');
is(multiply(3, 7),  21,  'multiply');
is(divide(20, 4),   5,   'divide');
is(factorial(5),     120, 'factorial');

eval { divide(1, 0) };
like($@, qr/division by zero/, 'divide by zero croaks');

eval { factorial(-1) };
like($@, qr/negative/, 'negative factorial croaks');

done_testing;
Enter fullscreen mode Exit fullscreen mode

Build and install Abacus

cd Abacus
perl Makefile.PL
make
make test
make install          # installs headers into site_perl
Enter fullscreen mode Exit fullscreen mode

Part 2: The Consumer Distribution (Tally)

Tally is a separate distribution that reuses Abacus's C arithmetic without duplicating any code. It adds its own "running total" functionality on top.

Write the Makefile.PL that finds Abacus headers

This is where the consumer locates the provider's headers. The two-step resolution strategy supports both installed (CPAN) and development (sibling
directory) scenarios.

Tally/Makefile.PL

use 5.008003;
use strict;
use warnings;
use ExtUtils::MakeMaker;

# Resolve Abacus include directory:
#   1. Try installed Abacus module (CPAN / system)
#   2. Fall back to sibling directory (development)
my $abacus_inc;
eval {
    no warnings 'redefine';
    local *XSLoader::load = sub {};  # skip XS bootstrap
    require Abacus;
    my $dir = Abacus->include_dir();
    $abacus_inc = $dir if $dir && -d $dir;
};
if (!$abacus_inc && -d '../Abacus/include') {
    $abacus_inc = '../Abacus/include';
}
die "Cannot find Abacus include directory.\n"
  . "Install Abacus or place it as a sibling directory.\n"
    unless $abacus_inc;

WriteMakefile(
    NAME             => 'Tally',
    AUTHOR           => 'Your Name <you@example.com>',
    VERSION_FROM     => 'lib/Tally.pm',
    ABSTRACT_FROM    => 'lib/Tally.pm',
    LICENSE          => 'artistic_2',
    MIN_PERL_VERSION => '5.008003',
    CONFIGURE_REQUIRES => {
        'ExtUtils::MakeMaker' => '0',
        'Abacus'              => '0.01',
    },
    TEST_REQUIRES => {
        'Test::More' => '0',
    },
    PREREQ_PM => {
        'Abacus' => '0.01',
    },

    # Point the compiler at Abacus's installed headers
    INC    => "-I$abacus_inc",
    OBJECT => '$(O_FILES)',

    dist  => { COMPRESS => 'gzip -9f', SUFFIX => 'gz' },
    clean => { FILES => 'Tally-*' },
);
Enter fullscreen mode Exit fullscreen mode

Let's walk through the header resolution:

  1. Try the installed path first - require Abacus loads the module, then Abacus->include_dir() returns the path where the headers were installed. We stub out XSLoader::load because we only need the pure-Perl include_dir() method, not the XS functions.
  2. Fall back to sibling directory - during development, Abacus and Tally often live side by side. ../Abacus/include handles this case.
  3. Die with a clear message if neither path works.

The resolved path is passed to INC, which adds it to the C compiler's include search path (-I/path/to/Abacus/include).

Abacus is listed in both CONFIGURE_REQUIRES and PREREQ_PM:

  • CONFIGURE_REQUIRES ensures Abacus is installed before Makefile.PL runs (needed because we require Abacus at configure time)
  • PREREQ_PM ensures it is available at runtime too

Write the XS file

This is where the reuse happens. Tally includes abacus_math.h directly -
no Perl coupling, just pure C function calls.

Tally/Tally.xs

#define PERL_NO_GET_CONTEXT
#include "EXTERN.h"
#include "perl.h"
#include "XSUB.h"

/* Hook Abacus errors into Perl's croak() */
#define ABACUS_FATAL(msg) croak("%s", (msg))

/* Include the pure-C header from Abacus - no Perl deps in the header */
#include "abacus_math.h"

/* ── Tally's own C logic, built on top of Abacus ─────────────── */

typedef struct {
    int32_t total;
} tally_state_t;

static inline void
tally_init(tally_state_t *state) {
    state->total = 0;
}

static inline int32_t
tally_add(tally_state_t *state, int32_t value) {
    state->total = abacus_add(state->total, value);
    return state->total;
}

static inline int32_t
tally_subtract(tally_state_t *state, int32_t value) {
    state->total = abacus_subtract(state->total, value);
    return state->total;
}

static inline int32_t
tally_multiply_total(tally_state_t *state, int32_t value) {
    state->total = abacus_multiply(state->total, value);
    return state->total;
}

static inline int32_t
tally_get(tally_state_t *state) {
    return state->total;
}

static inline void
tally_reset(tally_state_t *state) {
    state->total = 0;
}

/* ── XS bindings ─────────────────────────────────────────────── */

MODULE = Tally  PACKAGE = Tally

PROTOTYPES: DISABLE

SV *
new(class)
    const char *class
  CODE:
    tally_state_t *state;
    Newxz(state, 1, tally_state_t);
    tally_init(state);
    RETVAL = newSV(0);
    sv_setref_pv(RETVAL, class, (void *)state);
  OUTPUT:
    RETVAL

int
add(self, value)
    SV *self
    int value
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    RETVAL = tally_add(state, value);
  OUTPUT:
    RETVAL

int
subtract(self, value)
    SV *self
    int value
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    RETVAL = tally_subtract(state, value);
  OUTPUT:
    RETVAL

int
multiply_total(self, value)
    SV *self
    int value
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    RETVAL = tally_multiply_total(state, value);
  OUTPUT:
    RETVAL

int
total(self)
    SV *self
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    RETVAL = tally_get(state);
  OUTPUT:
    RETVAL

void
reset(self)
    SV *self
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    tally_reset(state);

void
DESTROY(self)
    SV *self
  CODE:
    tally_state_t *state = INT2PTR(tally_state_t *, SvIV(SvRV(self)));
    Safefree(state);
Enter fullscreen mode Exit fullscreen mode

Notice that Tally includes abacus_math.h (the pure C header), not abacus.h (the Perl-facing wrapper). This is intentional - Tally has its own Perl/XS setup and only needs the C functions.

Write the Perl module

Tally/lib/Tally.pm

package Tally;

use 5.008003;
use strict;
use warnings;

our $VERSION = '0.01';

require XSLoader;
XSLoader::load('Tally', $VERSION);

1;

__END__

=head1 NAME

Tally - Running total calculator using Abacus C headers

=head1 SYNOPSIS

    use Tally;

    my $t = Tally->new;
    $t->add(10);        # total is now 10
    $t->add(5);         # total is now 15
    $t->subtract(3);    # total is now 12
    $t->multiply_total(2);  # total is now 24
    say $t->total;      # 24
    $t->reset;          # back to 0

=cut
Enter fullscreen mode Exit fullscreen mode

Write a test

Tally/t/01-basic.t

use strict;
use warnings;
use Test::More;

use_ok('Tally');

my $t = Tally->new;
isa_ok($t, 'Tally');

is($t->total, 0, 'starts at zero');

is($t->add(10), 10, 'add 10');
is($t->add(5),  15, 'add 5');
is($t->subtract(3), 12, 'subtract 3');
is($t->multiply_total(2), 24, 'multiply by 2');
is($t->total, 24, 'total is 24');

$t->reset;
is($t->total, 0, 'reset to zero');

done_testing;
Enter fullscreen mode Exit fullscreen mode

Build Tally (development mode)

cd Tally
perl Makefile.PL     # finds ../Abacus/include automatically
make
make test
Enter fullscreen mode Exit fullscreen mode

I hope you found this tutorial useful! If you have questions about XS, C header reuse, or building modular Perl/C libraries, please leave a message.

Top comments (0)