What the standard library is

The C standard library is the set of functions, macros and types that the ISO C standard requires every hosted implementation to provide, declared in a fixed list of standard headers such as <stdio.h>, <stdlib.h>, <string.h> and <math.h>. The C23 text lists 31 standard headers; three of them (<complex.h>, <stdatomic.h> and <threads.h>) are optional. A program uses it by including the header and, for a few parts such as the math functions, linking the right library.

The headers you will use most

Header What it provides Explained in
<stdio.h> printf, fgets, fopen, streams Input, Output and Files
<stdlib.h> malloc/free, strtol, qsort, bsearch, exit, getenv, rand Dynamic Memory, this page
<string.h> strlen, strcmp, memcpy, memset Strings and Buffer Overflows
<stdint.h>, <inttypes.h>, <limits.h> int64_t, uint8_t, PRId64, INT_MAX Variables, Types and Integers
<math.h> sqrt, pow, round, floor, isnan this page
<time.h> time, gmtime, mktime, strftime this page
<ctype.h> isdigit, isxdigit, toupper this page
<errno.h>, <assert.h> errno, assert Error Handling
<stdbool.h>, <stddef.h> bool, size_t, NULL, offsetof Variables, Types and Integers

Two smaller groups are worth knowing by name: <signal.h> and <setjmp.h> for signals and non-local jumps (see Error Handling), and <threads.h> and <stdatomic.h>, added in C11, for threads and atomic operations. In practice most C programs on Linux use POSIX threads (pthread_create) instead.

Sorting and searching

qsort sorts an array of any type in place, and bsearch finds an element in a sorted array with a binary search. Because C has no generics, both take the array as a void *, the number of elements, the size of one element, and a pointer to a comparison function (see function pointers). The comparison function receives pointers to two elements and returns a negative number if the first comes before the second, zero if they are equal, and a positive number otherwise.

#include <stdio.h>
#include <stdlib.h>

static int compare_ints(const void *a, const void *b)
{
    int x = *(const int *)a;
    int y = *(const int *)b;
    return (x > y) - (x < y);   /* -1, 0 or 1, never overflows */
}

int main(void)
{
    int watts[] = {1500, 95, 130, 120, 2300};
    size_t count = sizeof watts / sizeof watts[0];

    qsort(watts, count, sizeof watts[0], compare_ints);
    for (size_t i = 0; i < count; i++) {
        printf("%s%d", i > 0 ? " " : "", watts[i]);
    }
    printf("\n");

    int wanted = 1500;
    int *hit = bsearch(&wanted, watts, count, sizeof watts[0], compare_ints);
    if (hit != NULL) {
        printf("%d W found at index %td\n", wanted, hit - watts);
    }
    wanted = 1000;
    hit = bsearch(&wanted, watts, count, sizeof watts[0], compare_ints);
    printf("%d W %s\n", wanted, hit != NULL ? "found" : "not found");
    return 0;
}
95 120 130 1500 2300
1500 W found at index 3
1000 W not found

bsearch returns a pointer to the element, or NULL; subtracting the start of the array gives the index. It only works on an array sorted with the same comparison function. Sorting structs works the same way: the comparison function casts to the struct type and decides the order, here the highest power first and, for equal power, by home ID:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct home {
    char id[6];
    int watts;
};

/* Highest power first; equal power sorted by home ID. */
static int by_power_desc(const void *a, const void *b)
{
    const struct home *x = a;
    const struct home *y = b;
    if (x->watts != y->watts) {
        return (x->watts < y->watts) - (x->watts > y->watts);
    }
    return strcmp(x->id, y->id);
}

int main(void)
{
    struct home homes[] = {
        {"h0042", 860}, {"h0017", 1234}, {"h0108", 860}, {"h0093", 2950},
    };
    size_t count = sizeof homes / sizeof homes[0];
    qsort(homes, count, sizeof homes[0], by_power_desc);
    for (size_t i = 0; i < count; i++) {
        printf("%s %5d W\n", homes[i].id, homes[i].watts);
    }
    return 0;
}
h0093  2950 W
h0017  1234 W
h0042   860 W
h0108   860 W

qsort is not guaranteed to be stable: elements that compare equal may end up in any order, and that order can differ between C libraries. That is why the comparison above breaks the tie on the home ID instead of relying on the input order. Despite its name, the standard does not say which algorithm qsort uses either.

Writing the comparison function

Many tutorials write the comparison as a subtraction, return x - y;. That is short and wrong: for values far apart the subtraction overflows, which is undefined behavior, and the sign of the result can come out reversed. A sanitizer build shows it:

#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

static int compare_ints(const void *a, const void *b)
{
    return *(const int *)a - *(const int *)b;   /* can overflow */
}

int main(void)
{
    int values[] = {1000, INT_MIN, -5, INT_MAX};
    qsort(values, 4, sizeof values[0], compare_ints);
    printf("qsort returned, but the order cannot be trusted\n");
    return 0;
}
example.c:7:28: runtime error: signed integer overflow: 1000 - -2147483648 cannot be represented in type 'int'
qsort returned, but the order cannot be trusted

On the machine used for this page, the array came out as 1000 2147483647 -2147483648 -5: not sorted at all. The form (x > y) - (x < y) used above only compares, so it cannot overflow.

Math functions

<math.h> works with double (and has f and l variants such as sqrtf for float and sqrtl for long double). On Linux the math functions live in a separate library, libm: link with -lm at the end of the command, or the linker reports undefined reference to `sqrt' (see Compiling and Running C).

#include <math.h>
#include <stdio.h>

int main(void)
{
    printf("sqrt(2)       = %.4f\n", sqrt(2.0));
    printf("pow(2, 10)    = %.1f\n", pow(2.0, 10.0));
    printf("round(2.5)    = %.1f\n", round(2.5));
    printf("round(-2.5)   = %.1f\n", round(-2.5));
    printf("floor(-2.5)   = %.1f\n", floor(-2.5));
    printf("ceil(-2.5)    = %.1f\n", ceil(-2.5));
    printf("trunc(-2.5)   = %.1f\n", trunc(-2.5));
    printf("fmod(7.5, 2)  = %.1f\n", fmod(7.5, 2.0));
    printf("hypot(3, 4)   = %.1f\n", hypot(3.0, 4.0));

    double nothing = 0.0;
    double not_a_number = nothing / nothing;
    printf("isnan(0.0/0.0) = %d\n", isnan(not_a_number));
    printf("NAN == NAN     = %d\n", NAN == NAN);
    return 0;
}
sqrt(2)       = 1.4142
pow(2, 10)    = 1024.0
round(2.5)    = 3.0
round(-2.5)   = -3.0
floor(-2.5)   = -3.0
ceil(-2.5)    = -2.0
trunc(-2.5)   = -2.0
fmod(7.5, 2)  = 1.5
hypot(3, 4)   = 5.0
isnan(0.0/0.0) = 1
NAN == NAN     = 0

Three details matter. round rounds halves away from zero (2.5 becomes 3, −2.5 becomes −3), unlike Python's round, which rounds halves to even. printf("%.2f") again rounds the exact binary value, so it can disagree with both. And a NaN (not a number) is never equal to anything, not even itself: test for it with isnan. The floating-point background is in Floating-Point Numbers.

For money, keep amounts as integers. Kestrel's fixed tariff of €0.2700 per kWh is 2700 in ten-thousandths of a euro, and the rounding to cents is one integer step that you control:

#include <math.h>
#include <stdio.h>

int main(void)
{
    /* Prices in ten-thousandths of a euro: 0.2700 EUR/kWh is 2700. */
    long price = 2700;
    long kwh = 350;
    long cost = price * kwh;                 /* 945000 = 94.5000 EUR */
    long cents = (cost + 50) / 100;          /* round half up to cents */
    printf("cost: %ld.%02ld EUR\n", cents / 100, cents % 100);

    printf("(long)(0.29 * 100)  = %ld\n", (long)(0.29 * 100.0));
    printf("lround(0.29 * 100)  = %ld\n", lround(0.29 * 100.0));
    return 0;
}
cost: 94.50 EUR
(long)(0.29 * 100)  = 28
lround(0.29 * 100)  = 29

The last two lines show why: 0.29 is stored as slightly less than 0.29, so a cast to an integer cuts 28.999… down to 28, while lround rounds to the nearest integer. The adding of 50 before dividing works for amounts that are never negative; see Variables, Types and Integers for more.

Dates and times

<time.h> counts time as a time_t, on Linux and other POSIX systems the number of seconds since 1 January 1970 00:00 UTC. gmtime splits it into a struct tm in UTC, localtime in the local time zone (from the TZ environment variable or the system setting), and strftime formats it. mktime goes the other way: it turns a struct tm in local time into a time_t and fills in the day of the week and the day of the year.

#include <stdint.h>
#include <stdio.h>
#include <time.h>

int main(void)
{
    time_t reading = 1791401400;        /* seconds since 1970-01-01 UTC */
    char text[64];
    strftime(text, sizeof text, "%Y-%m-%d %H:%M:%S UTC, %A", gmtime(&reading));
    printf("%s\n", text);

    struct tm day = {0};
    day.tm_year = 2026 - 1900;          /* years since 1900 */
    day.tm_mon = 9;                     /* months since January: 9 is October */
    day.tm_mday = 25;
    day.tm_isdst = -1;                  /* let mktime work out daylight saving */
    if (mktime(&day) == (time_t)-1) {
        return 1;
    }
    strftime(text, sizeof text, "%Y-%m-%d is a %A, day %j of the year", &day);
    printf("%s\n", text);

    time_t last32 = INT32_MAX;          /* the last second a 32-bit time_t holds */
    strftime(text, sizeof text, "%Y-%m-%d %H:%M:%S UTC", gmtime(&last32));
    printf("32-bit time_t ends at %s\n", text);
    printf("time_t here: %zu bytes\n", sizeof(time_t));
    return 0;
}
2026-10-07 19:30:00 UTC, Wednesday
2026-10-25 is a Sunday, day 298 of the year
32-bit time_t ends at 2038-01-19 03:14:07 UTC
time_t here: 8 bytes

The fields of struct tm are full of traps: years count from 1900, months from 0, days of the month from 1. The last two lines show the year 2038 problem: a signed 32-bit time_t overflows one second after 03:14:07 UTC on 19 January 2038. On 64-bit Linux time_t has 8 bytes, but older 32-bit systems, embedded devices and file formats that store the value in 32 bits are still affected.

Characters and other utilities

<ctype.h> classifies and converts single characters: isdigit, isxdigit, isspace, isalpha, toupper, tolower. They take an int that must be EOF or a value of an unsigned char; passing a plain char that happens to be negative is undefined behavior. Hence the cast in this helper, which normalizes the CRC field of a P1 telegram (the field behind BUG-1060, see Strings and Buffer Overflows):

#include <ctype.h>
#include <stdio.h>

/* Normalize a CRC field from a P1 telegram: upper case, hex digits only. */
static int normalize_hex(const char *in, char *out, size_t size)
{
    size_t n = 0;
    for (; in[n] != '\0'; n++) {
        unsigned char c = (unsigned char)in[n];   /* ctype needs 0-255 or EOF */
        if (!isxdigit(c) || n + 1 >= size) {
            return -1;
        }
        out[n] = (char)toupper(c);
    }
    out[n] = '\0';
    return n > 0 ? 0 : -1;
}

int main(void)
{
    const char *fields[] = {"b9f", "F1F4", "B9G", ""};
    char clean[5];
    for (int i = 0; i < 4; i++) {
        if (normalize_hex(fields[i], clean, sizeof clean) == 0) {
            printf("\"%s\" -> %s\n", fields[i], clean);
        } else {
            printf("\"%s\" -> rejected\n", fields[i]);
        }
    }
    return 0;
}
"b9f" -> B9F
"F1F4" -> F1F4
"B9G" -> rejected
"" -> rejected

The is… functions also depend on the program's locale (set with setlocale); in the default "C" locale they only recognize ASCII. Other useful parts of <stdlib.h>: strtol and strtoul for parsing numbers with error checking (see Strings and Buffer Overflows), getenv for environment variables, and rand for pseudo-random numbers:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    srand(1);
    int first = rand();
    int second = rand();
    printf("rand() after srand(1): %d %d\n", first, second);
    printf("RAND_MAX: %d\n", RAND_MAX);
    return 0;
}
rand() after srand(1): 1804289383 846930886
RAND_MAX: 2147483647

These numbers are glibc's. The standard does not fix the algorithm or even RAND_MAX (only that it is at least 32767), so the same seed gives a different sequence with another C library. rand is fine for a simulation or a test, never for passwords, tokens or keys: use the operating system's random source (getrandom on Linux) or a cryptographic library.

The C libraries that implement it

The standard only describes the library; a C library implements it, usually together with the POSIX functions and the wrappers around the operating system's system calls. On Linux nearly every program, whatever language it is written in, is linked against one:

  • glibc, the GNU C Library, is the C standard library and POSIX library used by most Linux distributions, such as Debian, Ubuntu, Fedora and Red Hat Enterprise Linux. As of October 2026 the newest release is 2.44 (July 2026); a distribution keeps the version it shipped with for its whole life, so Debian 12 has 2.36 and Debian 13 has 2.41.
  • musl is a smaller C library designed for static linking and simplicity, best known from Alpine Linux, the base of many small container images (see Building Good Images). Its newest release is 1.2.6.
  • newlib and picolibc are C libraries for microcontrollers, where there is no operating system underneath.
  • Windows has Microsoft's own C runtime, and macOS and the BSD systems have their own C libraries as well.

A program can ask which library it runs on; the <gnu/libc-version.h> header is of course itself a glibc extension, and the #ifdef keeps the program compiling elsewhere:

#include <stdio.h>
#ifdef __GLIBC__
#include <gnu/libc-version.h>
#endif

int main(void)
{
#ifdef __GLIBC__
    printf("compiled against glibc %d.%d\n", __GLIBC__, __GLIBC_MINOR__);
    printf("running on glibc %s\n", gnu_get_libc_version());
#else
    printf("not built with glibc\n");
#endif
    return 0;
}
compiled against glibc 2.36
running on glibc 2.36

Programs are normally linked dynamically against the C library: the code is loaded from libc.so.6 when the program starts, so a glibc security update fixes every program at once. The flip side is a classic operations problem: a program built on a newer distribution can refuse to start on an older one, with an error such as version `GLIBC_2.38' not found, because it needs functions the older glibc does not have. Build on the oldest system you need to support, or link statically (which musl makes easy) and take over the job of rebuilding for security updates.

Hosted and freestanding

The standard knows two kinds of implementation. A hosted implementation runs programs under an operating system: the program starts at main, and the whole standard library is available, including files, malloc and printf. A freestanding implementation is for code that runs without an operating system, such as firmware, boot loaders and kernels: the start-up function is implementation-defined, and only a small part of the library is required. In the C23 text that is the headers with types, limits and macros (<stddef.h>, <stdint.h>, <limits.h>, <stdbool.h>, <stdarg.h>, <float.h> and a few more) plus most of <string.h>; no <stdio.h>, no malloc.

The macro __STDC_HOSTED__ is 1 in a hosted implementation and 0 in a freestanding one; GCC and Clang switch with -ffreestanding. The Linux kernel is built freestanding and brings its own versions of the functions it needs (printk instead of printf). Firmware frameworks sit in between: ESP-IDF, the framework for the ESP32 family, provides much of the hosted library on top of FreeRTOS, so a dongle's firmware can still call snprintf (see Firmware and Embedded Programming).

#include <stdio.h>

int main(void)
{
    printf("__STDC_HOSTED__ = %d\n", __STDC_HOSTED__);
    printf("__STDC_VERSION__ = %ldL\n", __STDC_VERSION__);
    return 0;
}
__STDC_HOSTED__ = 1
__STDC_VERSION__ = 201710L

POSIX versus ISO C

Much of what C programmers use every day is not in the C standard at all but in POSIX, the standard interface of Unix-like systems: open/read/write on file descriptors, fork and exec, sockets, pthread_create, getline, strtok_r, and until C23 also strdup, strndup and gmtime_r. On Linux, glibc provides both. But when you compile with a strict standard such as -std=c17, glibc hides everything that is not ISO C, so a program that uses strdup no longer compiles:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char *id = strdup("KHP1-000231");
    if (id == NULL) {
        return EXIT_FAILURE;
    }
    printf("copied %s\n", id);
    free(id);
    return 0;
}
example.c:7:16: error: implicit declaration of function ‘strdup’; did you mean ‘strcmp’? [-Werror=implicit-function-declaration]

The fix is a feature test macro: a #define before the first include that tells the C library which interfaces to declare. _POSIX_C_SOURCE 200809L asks for POSIX.1-2008; _GNU_SOURCE asks for everything, including GNU extensions, and is not portable:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    char *id = strdup("KHP1-000231");
    if (id == NULL) {
        return EXIT_FAILURE;
    }
    printf("copied %s\n", id);
    free(id);
    return 0;
}
copied KHP1-000231

The default mode of GCC and Clang (gnu17, or gnu23 from GCC 15) declares these functions without a macro, which is why code that compiled for years can fail the moment someone adds -std=c17. The macro also documents what the code depends on: a POSIX program builds on Linux, macOS and the BSDs, but not natively on Windows.

Portability traps

Portability is the degree to which source code builds and behaves the same with another compiler, C library, operating system or processor. C was designed to be portable, but it achieves that by leaving many details to the implementation (see Undefined Behavior and Safer C), and those details are exactly where code that "always worked" breaks after a move. The examples below were run on a 64-bit Linux machine (ARM64); the comments name what differs elsewhere.

The signedness of char

Whether plain char is signed or unsigned is implementation-defined (see Variables, Types and Integers). On ARM64 Linux it is unsigned; on most x86-64 systems it is signed:

#include <limits.h>
#include <stdio.h>

int main(void)
{
    char byte = (char)0xE9;           /* 'é' in Latin-1, 233 */
    printf("plain char range: %d to %d\n", CHAR_MIN, CHAR_MAX);
    printf("(char)0xE9 as int: %d\n", byte);
    printf("byte > 127: %s\n", byte > 127 ? "yes" : "no");

    unsigned char safe = 0xE9;        /* the same on every machine */
    printf("unsigned char: %d\n", safe);
    return 0;
}
plain char range: 0 to 255
(char)0xE9 as int: 233
byte > 127: yes
unsigned char: 233

GCC's -fsigned-char makes char signed, as on x86-64. With the same warning flags, the program no longer even compiles, because the compiler can now see that the comparison is always false; without -Werror it prints different numbers:

$ gcc -std=c17 -Wall -Wextra -Werror -fsigned-char -o chars chars.c
chars.c: In function ‘main’:
chars.c:9:37: error: comparison is always false due to limited range of data type [-Werror=type-limits]
    9 |     printf("byte > 127: %s\n", byte > 127 ? "yes" : "no");
      |                                     ^
cc1: all warnings being treated as errors
$ gcc -std=c17 -fsigned-char -o chars chars.c && ./chars
plain char range: -128 to 127
(char)0xE9 as int: -23
byte > 127: no
unsigned char: 233

A realistic case, in general terms: a legacy library, built for years on x86-64, reads a file with char c; while ((c = getchar()) != EOF). Moved to an ARM64 build server, the loop never ends, because an unsigned char can never be −1 (on x86-64 it ended too early instead, at the first byte 0xFF). The rule: use int for getchar results, unsigned char or uint8_t for bytes, and plain char only for text.

Type sizes

The standard sets only minimum ranges for the integer types; the actual sizes come from the platform's data model (see Variables, Types and Integers):

#include <stdio.h>

int main(void)
{
    printf("int         %zu bytes\n", sizeof(int));
    printf("long        %zu bytes\n", sizeof(long));
    printf("long long   %zu bytes\n", sizeof(long long));
    printf("void *      %zu bytes\n", sizeof(void *));
    printf("long double %zu bytes\n", sizeof(long double));
    printf("wchar_t     %zu bytes\n", sizeof(L'x'));
    return 0;
}
int         4 bytes
long        8 bytes
long long   8 bytes
void *      8 bytes
long double 16 bytes
wchar_t     4 bytes

On 64-bit Windows long stays 4 bytes and wchar_t is 2; on x86-64 Linux long double is usually the 80-bit extended format stored in 16 bytes, on ARM64 Linux a true 128-bit format; on small 8- and 16-bit microcontrollers int is often 2 bytes (32-bit ones such as the ESP32 use 4). Code that stores a pointer in a long, or a meter total in an int, works on one platform and silently truncates on another. Use int64_t, uint32_t, size_t and intptr_t, which say what they mean.

Byte order

Endianness is the order in which the bytes of a multi-byte value are stored in memory or sent over a connection: little-endian puts the least significant byte first, big-endian the most significant byte. x86-64 and ARM64 as normally used are little-endian; network protocols traditionally use big-endian ("network byte order"). C code that copies a number's memory straight into a file or a packet therefore writes the CPU's order, and a reader on another system may get a completely different number. Writing the bytes with shifts fixes the order on every machine:

#include <stdint.h>
#include <stdio.h>
#include <string.h>

/* Write a 32-bit value in a fixed byte order (big-endian), whatever the CPU. */
static void put_be32(unsigned char out[4], uint32_t value)
{
    out[0] = (unsigned char)(value >> 24);
    out[1] = (unsigned char)(value >> 16);
    out[2] = (unsigned char)(value >> 8);
    out[3] = (unsigned char)value;
}

static uint32_t get_be32(const unsigned char in[4])
{
    return (uint32_t)in[0] << 24 | (uint32_t)in[1] << 16 |
           (uint32_t)in[2] << 8 | (uint32_t)in[3];
}

int main(void)
{
    uint32_t energy_wh = 412338;            /* 0x00064AB2 */
    unsigned char raw[4];
    memcpy(raw, &energy_wh, sizeof raw);    /* the CPU's own order */
    printf("in memory: %02X %02X %02X %02X\n", raw[0], raw[1], raw[2], raw[3]);

    unsigned char wire[4];
    put_be32(wire, energy_wh);              /* a defined order for files and networks */
    printf("big-endian: %02X %02X %02X %02X\n", wire[0], wire[1], wire[2], wire[3]);
    printf("read back: %u Wh\n", (unsigned)get_be32(wire));
    return 0;
}
in memory: B2 4A 06 00
big-endian: 00 06 4A B2
read back: 412338 Wh
The value 412338, hexadecimal 00 06 4A B2, stored in four bytes. Little-endian memory holds B2, 4A, 06, 00 from the lowest address up; big-endian holds 00, 06, 4A, B2. 412338 = 0x00064AB2 little-endian (x86-64, ARM64) B2 4A 06 00 big-endian (network order) 00 06 4A B2 address +0 +3
The same four bytes in two orders. The least significant byte, B2, comes first in little-endian memory and last in big-endian.

Structure layout

The same applies to whole structs. Padding between members, alignment, the size of each member and the byte order are all decided by the platform's ABI (application binary interface), so fwrite(&reading, sizeof reading, 1, file) produces a file that only the same kind of machine, compiled the same way, can read back (see Structs, Unions and Enums). For files and network messages, write each field explicitly in a defined order and size, as in put_be32 above, or use a text format such as CSV or JSON, or a serialization library.

Behavior that differs

Some differences show up only at run time:

  • Undefined behavior gives different results with another compiler, optimization level or processor. Integer division by zero printed 0 on ARM64 and typically crashes with "Floating point exception" on x86-64 (see Debugging C).
  • Implementation-defined results such as -8 >> 1 (−4 with GCC, which shifts in sign bits; another compiler may differ), and unspecified ones such as the order of equal elements after qsort.
  • Library details: the rand sequence, the text of strerror and perror messages, what printf("%p", NULL) prints ((nil) in glibc), and glibc's own error messages such as free(): double free detected in tcache 2. Never parse those texts in scripts; use exit statuses and error codes.
  • Compiler defaults: GCC 15 and later compile as gnu23 by default, older versions and Clang as gnu17, so the same file can mean something different (see Modern C and Where C Is Used).

A portability checklist

  • Compile with an explicit standard and warnings: -std=c17 -Wall -Wextra -Wpedantic (and -Wconversion for code that does arithmetic on sizes and money).
  • Use <stdint.h> types for anything with a required size, size_t for sizes and counts, and unsigned char for bytes.
  • Define byte order and layout for everything that leaves the process: files, network messages, shared memory.
  • Put POSIX and platform-specific code behind feature test macros and #ifdef, in as few files as possible.
  • Build and test with at least two compilers (GCC and Clang) and, where it matters, on two architectures (x86-64 and ARM64); a CI pipeline matrix makes that cheap (see Testing and Quality in Pipelines). Run the tests with sanitizers.