The Standard Library and Portability
A tour of the C standard library — sorting, searching, math and time — the C libraries that implement it, POSIX versus ISO C, and the traps that make C code behave differently on another machine.
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
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 afterqsort. -
Library details: the
randsequence, the text ofstrerrorandperrormessages, whatprintf("%p", NULL)prints ((nil)in glibc), and glibc's own error messages such asfree(): 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
gnu23by default, older versions and Clang asgnu17, 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-Wconversionfor code that does arithmetic on sizes and money). -
Use
<stdint.h>types for anything with a required size,size_tfor sizes and counts, andunsigned charfor 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.