What a C string is

C has no string type. A null-terminated string is a sequence of char values in memory, usually in an array, followed by a byte with the value zero. That zero byte is the null terminator, written '\0'. It is not a printable character; it only marks where the text ends. A string of seven letters therefore needs eight bytes.

The array char name[] = "Kestrel" in memory: eight bytes with indexes 0 to 7, holding K, e, s, t, r, e, l and the null terminator at index 7. sizeof name is 8, strlen(name) is 7. char name[] = "Kestrel"; 'K' 'e' 's' 't' 'r' 'e' 'l' '\0' 0 1 2 3 4 5 6 7 strlen(name) = 7 sizeof = 8
Seven characters and one terminator. Every string function walks from index 0 until it meets the zero byte.

The example prints every byte of the array as a number, so the terminator becomes visible:

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

int main(void)
{
    char name[] = "Kestrel";            /* the compiler counts: 8 bytes */
    const char *text = "Kestrel";       /* a pointer to the same text */

    printf("sizeof name = %zu, strlen(name) = %zu\n", sizeof name, strlen(name));
    printf("sizeof text = %zu (the size of a pointer)\n", sizeof text);

    for (size_t i = 0; i < sizeof name; i++) {
        printf("%d ", name[i]);
    }
    printf("\n");
    return 0;
}
sizeof name = 8, strlen(name) = 7
sizeof text = 8 (the size of a pointer)
75 101 115 116 114 101 108 0

Because there is no stored length, strlen must look at every character until it finds the zero: its cost grows with the length of the text. And if the terminator is missing, every string function keeps reading the bytes that happen to follow in memory. The array and pointer basics behind this (why name turns into a pointer when you pass it to a function, and why a function cannot know the size of the array it received) are on Arrays and Pointer Arithmetic.

String literals are read-only

A string literal is text between double quotes in the source code, such as "Kestrel". The compiler stores it, with its terminator, in memory that exists for the whole run of the program. Two uses look alike but behave very differently:

  • char name[] = "Kestrel"; creates an array of 8 bytes and copies the literal into it. The array is yours to change.
  • char *text = "Kestrel"; makes a pointer to the literal itself. Changing it is undefined behavior; on Linux, literals live in a read-only part of the program, so the write crashes.
#include <stdio.h>

int main(void)
{
    char copy[] = "kestrel";
    copy[0] = 'K';                  /* fine: the array is a copy */
    printf("%s\n", copy);

    char *literal = "kestrel";      /* compiles without a warning in C */
    literal[0] = 'K';               /* writes into read-only memory */
    printf("%s\n", literal);
    return 0;
}
Kestrel
Segmentation fault

Neither -Wall nor -Wextra warns about this, because in C a string literal has the type char[], not const char[] (C++ changed that). Make the habit explicit instead: declare pointers to literals as const char *; then an accidental write is a compile error.

The string.h functions

The header <string.h> declares the classic string functions. All of them take pointers and rely on the terminator; none of them knows how big the arrays are.

Function What it does Trap
strlen(s) Number of bytes before the terminator Returns size_t (unsigned); reads until a zero byte
strcmp(a, b) Negative, zero or positive, like a dictionary order by byte value Returns 0 for equal strings: if (strcmp(a, b)) means "different"
strchr(s, c), strstr(s, t) Pointer to the first matching character or substring, or NULL Check for NULL before using the result
strcpy(dst, src), strcat(dst, src) Copy or append src, including the terminator No size check at all: the main source of overflows
strncpy(dst, src, n) Copy at most n bytes Leaves dst without a terminator when src is too long
strtok(s, delims) Split into tokens Overwrites s and keeps hidden state between calls
memcpy, memset, memchr Work on a given number of bytes, terminator or not You pass the size, so you can still pass the wrong one
#include <stdio.h>
#include <string.h>

int main(void)
{
    const char *meter = "E-001";
    const char *topic = "kestrel/home/h0017/p1/power";

    printf("strlen: %zu\n", strlen(meter));
    printf("strcmp: %d %d %d\n",
           strcmp("E-001", "E-002") < 0, strcmp("kWh", "kW") > 0,
           strcmp(meter, "E-001"));

    const char *dash = strchr(meter, '-');
    if (dash != NULL) {
        printf("after the dash: %s\n", dash + 1);
    }
    const char *p1 = strstr(topic, "/p1/");
    if (p1 != NULL) {
        printf("offset of /p1/: %td\n", p1 - topic);
    }

    char unit[8] = "kW";            /* room for 7 characters + '\0' */
    strcat(unit, "h");              /* fits: "kWh" needs 4 bytes */
    printf("unit: %s\n", unit);
    return 0;
}
strlen: 5
strcmp: 1 1 0
after the dash: 001
offset of /p1/: 18
unit: kWh

Only the sign of strcmp's result is defined, so the example compares it with zero instead of printing it. The difference of two pointers into the same string, p1 - topic, is the offset of the match and has the type ptrdiff_t (printed with %td).

Comparing strings: never with ==

== on two strings compares two addresses, not the text. With the warnings this site uses, gcc refuses the most common form of the mistake:

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

int main(void)
{
    char status[16];
    strcpy(status, "OPEN");
    if (status == "OPEN") {         /* compares addresses */
        puts("ticket is open");
    }
    return 0;
}
example.c:8:16: error: comparison with string literal results in unspecified behavior [-Werror=address]

The fix is strcmp(status, "OPEN") == 0. Without -Wall the program compiles and simply never prints the line, which is how this bug survives in old code.

Splitting with strtok

strtok splits a string at any of the delimiter characters you give it. The first call takes the string, every next call passes NULL to continue where the previous one stopped:

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

int main(void)
{
    char line[] = "E-001;2026-10-07;350";   /* an array: strtok writes into it */

    for (char *field = strtok(line, ";"); field != NULL; field = strtok(NULL, ";")) {
        printf("[%s]", field);
    }
    printf("\n");
    printf("line is now: %s\n", line);
    return 0;
}
[E-001][2026-10-07][350]
line is now: E-001

Afterward line reads only E-001: strtok replaced every delimiter it found with '\0'. It also remembers its position in a hidden static variable, so it cannot split two strings at the same time and is unsafe in threads. POSIX offers strtok_r, which keeps that position in a variable you pass; it needs #define _POSIX_C_SOURCE 200809L when you compile with -std=c17. Never pass a string literal to strtok: it would write into read-only memory.

How buffer overflows happen

A buffer overflow is a write (or read) past the end of a buffer: the program puts more bytes into a block of memory than it has room for, and the extra bytes land in whatever lies next to it. C never checks, so the write simply happens. What breaks depends on where the buffer is: other variables change value, the program crashes later in an unrelated place, or, in the worst case, an attacker who controls the input can change what the program does. The C standard calls it undefined behavior.

A stack buffer overflow is the case where the buffer is a local array in a function's stack frame. Do not confuse it with a stack overflow, which means running out of stack space, typically through recursion that is too deep. Overflows of memory from malloc are heap overflows, covered on Dynamic Memory.

A teaching example with the Kestrel Home P1 dongle's serial number, KHP1-000231: eleven characters, so twelve bytes with the terminator. A function that keeps a short device ID in an 8-byte array and copies with strcpy works for short IDs and overflows for this one.

strcpy copies the twelve bytes of "KHP1-000231" including its terminator into char id[8]. The first eight bytes fill the array; the last four bytes, 2, 3, 1 and the null terminator, are written into the memory next to the array, which belongs to something else. strcpy(id, "KHP1-000231"); K H P 1 - 0 0 0 2 3 1 \0 char id[8]: the buffer 4 bytes too many memory of something else indexes 0 to 7
The copy does not stop at the end of the array. The last four bytes overwrite neighboring memory: another variable, saved registers or the function's return address.

When the compiler can see it

When both sizes are known at compile time, gcc warns, and with -Werror it refuses to build:

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

int main(void)
{
    char id[8];
    strcpy(id, "KHP1-000231");
    printf("device %s\n", id);
    return 0;
}
example.c:7:5: error: ‘__builtin_memcpy’ writing 12 bytes into a region of size 8 overflows the destination [-Werror=stringop-overflow=]

(gcc reports __builtin_memcpy because it turned the copy of a known string into a plain memory copy.) In real code the serial number comes from a label, a configuration file or the network, and the compiler cannot know its length. Then the program compiles cleanly:

gcc -std=c17 -Wall -Wextra -Werror -O0 -o device device.c
./device
device KHP1-07
device KHP1-000231

The program printed the full serial number and exited with status 0. Nothing looked wrong, yet four bytes of the stack were overwritten. That is what makes overflows dangerous: on another machine, with other compiler options or with a longer input, the same code crashes or misbehaves, and a quick test shows nothing. Do not rely on this output; it is undefined behavior.

Detecting overflows at run time

AddressSanitizer (ASan), switched on with -fsanitize=address, surrounds every buffer with guard bytes and stops the program at the first byte written out of bounds. The same program, with the serial number passed in as a parameter so the compiler cannot see it:

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

static void show_device(const char *serial)
{
    char id[8];                     /* room for 7 characters + '\0' */
    strcpy(id, serial);             /* no size check */
    printf("device %s\n", id);
}

int main(void)
{
    show_device("KHP1-07");
    show_device("KHP1-000231");
    return 0;
}
device KHP1-07
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425 in __interceptor_strcpy

The summary line names strcpy, because ASan checks the copy inside its own version of that function. The full report shows where it was called from and which variable overflowed (trimmed; addresses and the process ID differ per run):

==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffff16f86c8 at pc 0x7fffa2c9e888 bp 0x7ffff16f7df0 sp 0x7ffff16f7e48
WRITE of size 12 at 0x7ffff16f86c8 thread T0
    #0 0x7fffa2c9e884 in __interceptor_strcpy ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425
    #1 0x555564c70d84 in show_device example.c:7
    …
  This frame has 1 object(s):
    [32, 40) 'id' (line 6) <== Memory access at offset 40 overflows this variable
…
SUMMARY: AddressSanitizer: stack-buffer-overflow ../../../../src/libsanitizer/asan/asan_interceptors.cpp:425 in __interceptor_strcpy

Line 7, the strcpy into id (declared on line 6): exactly the bug. Two protections that are cheap enough for production builds catch the same overflow without a sanitizer, by stopping the program instead of letting it continue with corrupted memory. Both were recorded with the program above:

device KHP1-07
device KHP1-000231
*** stack smashing detected ***: terminated
Aborted
device KHP1-07
*** buffer overflow detected ***: terminated
Aborted

The stack protector places a check value (a "canary") between the local arrays and the return address and checks it when the function returns. _FORTIFY_SOURCE makes glibc replace calls such as strcpy with versions that know the size of the destination where the compiler can work it out; it needs optimization (-O1 or higher). On this machine's gcc (Debian 12) neither is on by default; many distributions enable them in their own package builds. Hardening flags and sanitizers are covered in depth on Undefined Behavior and Safer C and Debugging C.

gets is gone

The old function gets read a line from standard input into a buffer without any way to say how big the buffer was. Every call was an overflow waiting for a long line, so C11 removed it from the language. Under -std=c17 it is not even declared any more:

#include <stdio.h>

int main(void)
{
    char name[16];
    gets(name);
    printf("%s\n", name);
    return 0;
}
example.c:6:5: error: implicit declaration of function ‘gets’; did you mean ‘fgets’? [-Werror=implicit-function-declaration]

gcc already suggests the replacement: fgets, which takes the buffer size. Reading input safely is covered on Input, Output and Files. The same applies to scanf("%s", name) without a width: write %15s for a 16-byte buffer.

Safe copying and formatting

snprintf() is the printf variant that writes into a buffer of a given size. It never writes more than that size, always ends the result with a terminator (for a size of at least 1), and returns the number of characters the complete result would have needed. If that number is equal to or larger than the buffer size, the text was cut off. That makes it the standard tool for both formatting and copying strings safely:

#include <stdbool.h>
#include <stdio.h>

/* Formats a ticket ID such as "INC-33004" into buf.
   Returns false if the result did not fit. */
static bool format_ticket(char *buf, size_t size, const char *prefix, int number)
{
    int needed = snprintf(buf, size, "%s-%05d", prefix, number);
    return needed >= 0 && (size_t)needed < size;
}

int main(void)
{
    char small[8];
    char large[16];

    bool ok = format_ticket(large, sizeof large, "INC", 33004);
    printf("%-5s \"%s\"\n", ok ? "ok" : "CUT", large);

    ok = format_ticket(small, sizeof small, "INC", 33004);
    printf("%-5s \"%s\"\n", ok ? "ok" : "CUT", small);
    return 0;
}
ok    "INC-33004"
CUT   "INC-330"

The 8-byte buffer received INC-330 and a terminator, no overflow, and the function reported that the ID was cut off. Whether a cut-off value is acceptable is your decision: for a log line it may be, for an ID it is not, because a truncated ID can point to the wrong record. Always check the return value.

The same function copies a string safely, with "%s" as the format: snprintf(dst, sizeof dst, "%s", src). Never pass the text itself as the format (snprintf(dst, size, src)): a % in the text would be read as a conversion. That mistake is a format-string bug.

strncpy is not a safe strcpy

Many tutorials recommend strncpy as the safe strcpy. It was designed for fixed-width fields in old Unix directory entries, not for strings: when the source is too long, it copies exactly n bytes and adds no terminator.

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

static void copy_and_check(const char *src)
{
    char dst[4];
    strncpy(dst, src, sizeof dst);
    printf("%-8s terminated: %s\n", src,
           memchr(dst, '\0', sizeof dst) != NULL ? "yes" : "no");
}

int main(void)
{
    copy_and_check("kWh");
    copy_and_check("E-001");
    return 0;
}
kWh      terminated: yes
E-001    terminated: no

The second copy left four bytes without a terminator; printing dst would read past its end. Prefer snprintf. You may also meet strcpy_s and the other "secure" functions of the C standard's Annex K: that annex is optional, and glibc (like most C libraries outside Microsoft's) does not implement it, so code that uses them is not portable.

Parsing numbers from text

Numbers arrive as text: from a configuration file, a command-line argument, a P1 telegram. atoi looks like the obvious choice and has no way to report an error: atoi("12x4") is 12 and atoi("abc") is 0, the same as a real zero. Use strtol (or strtoul, strtoll, strtod) instead. It reports three things:

  • Through its second argument, a pointer to the first character it did not use. If that is the start of the text, there was no number at all; if it is not the terminator, there was something after the number.
  • Through errno: ERANGE when the number does not fit in a long (the result is then LONG_MAX or LONG_MIN).
  • The value itself, which you still check against your own limits.
#include <errno.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>

/* A meter reading in kWh: digits only, 0 to 100000. */
static bool parse_kwh(const char *text, long *out)
{
    char *end;
    errno = 0;
    long value = strtol(text, &end, 10);
    if (end == text || *end != '\0') {
        return false;               /* no digits, or text after them */
    }
    if (errno == ERANGE || value < 0 || value > 100000) {
        return false;               /* out of range */
    }
    *out = value;
    return true;
}

int main(void)
{
    const char *inputs[] = {"350", "12x4", "", "-5", " 42", "99999999999999999999"};

    printf("atoi(\"12x4\") = %d, atoi(\"abc\") = %d\n", atoi("12x4"), atoi("abc"));
    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; i++) {
        long kwh;
        if (parse_kwh(inputs[i], &kwh)) {
            printf("\"%s\" -> %ld kWh\n", inputs[i], kwh);
        } else {
            printf("\"%s\" -> rejected\n", inputs[i]);
        }
    }
    return 0;
}
atoi("12x4") = 12, atoi("abc") = 0
"350" -> 350 kWh
"12x4" -> rejected
"" -> rejected
"-5" -> rejected
" 42" -> 42 kWh
"99999999999999999999" -> rejected

Note " 42": strtol skips leading white space. Whether that is acceptable depends on the data; for a strict format, check the first character yourself. Setting errno to 0 before the call matters, because strtol only sets it on an error and never clears it. The errno mechanism is explained on Error Handling.

Too lenient: the P1 CRC field

Kestrel's BUG-1060, described on Smart Meters and the P1 Port, was about the last line of a P1 telegram: the four hexadecimal digits of the CRC after the !. The pilot dongle's parser accepted exactly four digits, and one meter sent three, B9F. The dongle's firmware is the outside manufacturer's C code. A tempting quick fix in C is to hand the field to strtoul(field, &end, 16), which reads any number of hexadecimal digits. The example shows why that is far too lenient for device input, next to a strict parser that accepts one to four hexadecimal digits followed by exactly \r\n:

#include <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Strict: one to four hex digits, then CR LF, nothing else. */
static bool parse_crc(const char *field, uint16_t *out)
{
    size_t n = 0;
    uint16_t value = 0;
    while (n < 4 && isxdigit((unsigned char)field[n])) {
        int c = toupper((unsigned char)field[n]);
        value = (uint16_t)(value * 16 + (isdigit(c) ? c - '0' : c - 'A' + 10));
        n++;
    }
    if (n == 0 || strcmp(field + n, "\r\n") != 0) {
        return false;
    }
    *out = value;
    return true;
}

int main(void)
{
    const char *fields[] = {"F1F4\r\n", "B9F\r\n", " 0xB9F\r\n", "-1\r\n", "F1F4F\r\n", "\r\n"};

    for (size_t i = 0; i < sizeof fields / sizeof fields[0]; i++) {
        const char *field = fields[i];
        int shown = (int)strcspn(field, "\r\n");    /* print without CR LF */
        char *end;
        unsigned long lenient = strtoul(field, &end, 16);
        uint16_t crc;

        printf("%-8.*s strtoul: %-16lX  strict: ", shown, field, lenient);
        if (parse_crc(field, &crc)) {
            printf("%04X\n", crc);
        } else {
            printf("rejected\n");
        }
    }
    return 0;
}
F1F4     strtoul: F1F4              strict: F1F4
B9F      strtoul: B9F               strict: 0B9F
 0xB9F   strtoul: B9F               strict: rejected
-1       strtoul: FFFFFFFFFFFFFFFF  strict: rejected
F1F4F    strtoul: F1F4F             strict: rejected
         strtoul: 0                 strict: rejected

strtoul happily accepted a leading space and a 0x prefix, turned "-1" into the largest unsigned long (it negates the value in unsigned arithmetic), and read five digits as one number. Each of those would let a corrupted telegram through as long as some CRC happened to match. The strict parser accepts the harmless quirk (three digits, printed as 0B9F) and rejects the rest. Note also the (unsigned char) casts: the <ctype.h> functions are only defined for values of unsigned char and EOF, and on systems where char is signed a byte above 127 would be passed as a negative number. (On a 64-bit Linux machine (ARM64) char is unsigned; on most x86-64 systems it is signed. The cast makes the code correct on both.)

Preventing buffer overflows

No single trick prevents all overflows. In practice it is a set of habits and tools, each catching what the others miss. The SEI CERT C Coding Standard sums up the rule as STR31-C: "Guarantee that storage for strings has sufficient space for character data and the null terminator".

Habit or tool What it does
Pass sizes with buffers Every function that writes into a buffer takes its size (char *buf, size_t size), and callers pass sizeof buf of the real array
Bounded functions snprintf instead of sprintf, strcpy and strcat; fgets instead of gets; a width in every scanf %s
Check every result Truncation from snprintf, the end pointer and errno from strtol, lengths before memcpy
Validate input early Reject input that is too long or has the wrong format at the edge of the program (input validation)
Compiler warnings -Wall -Wextra (and -Werror) catch overflows with known sizes, as shown above
Sanitizers and fuzzing in testing -fsanitize=address,undefined in test builds, fed by unit tests and a fuzzer
Hardening in release builds -fstack-protector-strong and -D_FORTIFY_SOURCE=2 (or 3) with -O2, so that an overflow that slips through stops the program instead of continuing silently

For new code where none of this is acceptable, the alternative is a language with bounds-checked strings. That debate, and the government guidance behind it, is on Undefined Behavior and Safer C.