Foundations · Module 3 · Lesson 6 of 9
C-Style Strings and Buffer Bounds
Understand null-terminated byte strings, trace linear scans in strlen, and secure string operations against buffer overflows.
By the end you can
- Contrast compile-time buffer capacity with runtime null-terminated string length
- Trace how string manipulation functions navigate memory up to the null byte sentinel
- Diagnose off-by-one allocation errors that omit space for the null terminator
- Replace unsafe standard library string functions with bounded alternatives
01 · A question first
Why does strlen("hello") return 5 while sizeof("hello") returns 6? And why did a missing single-byte null terminator cause some of the most catastrophic remote code execution vulnerabilities in software history?
Make a guess, then step through the trace below to check it.
02 · Watch it run
char buf[5] = "hi";size_t len = strlen(buf);buf[2] = '!';buf[3] = '\0';- byte 00x7ffd10: 'h'(looked at in this step)
- byte 10x7ffd11: 'i'(looked at in this step)
- byte 20x7ffd12: '\0'(looked at in this step)
- byte 30x7ffd13: '\0'
- byte 40x7ffd14: '\0'
Stack allocates 5 contiguous bytes at 0x7ffd10: 'h', 'i', '\0', '\0', '\0'.
Machine work: movw 0x6968, -5(%rbp); movb0, -3(%rbp)...
03 · The idea
Start with the problem
Unlike modern high-level languages, C does not have a native string data type. In C, a string is a contiguous sequence of characters terminated by a single zero byte: the null terminator ('\0').
Because strings are raw byte arrays without embedded length headers, software must rely entirely on that trailing zero byte to know where text stops.
Consider an engineer copying a user token into a fixed-size destination:
#include <stdio.h>
#include <string.h>
void duplicate_token(const char *input, char copy[6]) {
/* BUG: The destination must have room for strlen(input) + 1 bytes. */
strcpy(copy, input);
}If input is "token", strlen(input) returns 5.
The destination has room for only 5 characters, but "token" requires 6 bytes in memory: 't', 'o', 'k', 'e', 'n', and the terminating '\0'.
When strcpy() runs, it writes all 6 bytes into a 5-byte destination, so it writes one byte past the array. The result is undefined behavior: it may corrupt a neighboring local variable, produce surprising output, or crash.
Now consider an unterminated read:
char name[4] = {'B', 'o', 'b', '!'}; /* No null terminator */
printf("User: %s\n", name);Because printf with %s keeps reading memory until it finds a byte with value 0x00, it reads past the 4 bytes of name, printing stack garbage or crashing with a segmentation fault if it crosses into an unmapped virtual memory page.
How do C strings function at the byte level, and how can systems developers manipulate text without exposing applications to memory corruption?
How the idea works
A C string (formally, a null-terminated byte string) is an array of contiguous char elements ending with the ASCII NUL character (numerical value 0, written as '\0').
String: "C-Code" in Memory
Indices: 0 1 2 3 4 5 6
Characters:['C'] ['-'] ['C'] ['o'] ['d'] ['e'] ['\0']
Hex Bytes: 0x43 0x2D 0x43 0x6F 0x64 0x65 0x00
└─────────── strlen = 6 ──────────┘ ▲
└──────────── sizeof = 7 ─────────────┘ Sentinel Byte1. sizeof vs. strlen
sizeof: Evaluates the total byte capacity of the storage container at compile time. Forchar buf[32] = "hi";,sizeof(buf)evaluates to32. For the literal"hi",sizeof("hi")evaluates to3(counting'h','i', and'\0').strlen: A runtime algorithm that scans forward from the pointer address byte by byte until it encounters a byte equal to0x00. It returns the count of characters excluding the null terminator. In step 2 of the trace,strlenstops at index 2 and returns 2.
2. String Literals vs. Character Arrays
The declaration syntax dictates where the bytes reside in virtual memory:
/* Pointer to Read-Only Memory: */
const char *literal = "Hello";
/* Mutable Stack Array: */
char array[] = "Hello"; literalpoints directly to data stored in the.rodatasegment. Writing toliteral[0] = 'M'triggers a hardware write-protection page fault.arrayallocates 6 bytes directly on the active call stack frame and copies"Hello\0"into it at runtime. Elements inarrayare fully mutable.
3. Safe vs. Dangerous Standard Library Functions
The historical string manipulation functions in <string.h> assume target buffers have infinite capacity. Modern systems replace them with bounded alternatives:
| Dangerous Function | Safe Replacement | Reason for Replacement |
|---|---|---|
gets(buf) | fgets(buf, sz, stdin) | gets() has no bound; causes unavoidable stack overflows |
strcpy(dst, src) | snprintf(dst, sz, "%s", src) | strcpy() writes unbounded bytes past destination |
strcat(dst, src) | strncat(dst, src, sz - strlen(dst) - 1) | strcat() overflows destination buffer |
sprintf(buf, ...) | snprintf(buf, sz, ...) | sprintf() does not check destination capacity |
snprintf() guarantees null-termination (as long as sz > 0) and returns the number of characters that would have been written, allowing callers to detect truncation:
char path[64];
int written = snprintf(path, sizeof(path), "/var/log/%s.log", service_name);
if (written >= (int)sizeof(path)) {
/* String was truncated: handle error safely */
}Why it is correct
Why did C choose null-terminated sentinels instead of storing an explicit length integer alongside strings?
The Memory Conservation Decision (1972)
When C was designed on the PDP-11, computers had 24 KB of total RAM.
Competing languages of the era (like early Pascal) used length-prefixed strings (storing a 1-byte length at index 0):
- Strings were limited to a maximum length of 255 characters.
- Every string carried prefix overhead regardless of length.
Dennis Ritchie chose a trailing \0 sentinel because:
- Strings can scale to any arbitrary length without an artificial 255-character cap.
- It saved memory on small strings.
- String operations mapped directly to hardware test-and-branch instructions (
TSTB,BEQ) on PDP-11 processors.
What it costs
Using null sentinels introduces performance and algorithmic penalties:
| Operation | Complexity | Underlying Cost |
|---|---|---|
String length (strlen) | O(N) | Must linearly scan every byte from memory into CPU |
String concatenation (strcat) | O(N + M) | Scans to end of destination before copying source |
| Repeated concatenation loop | O(N^2) | Schlemiel the Painter algorithm (repeated rescanning) |
Bounded copy (snprintf) | O(N) | Formats and scans bytes with bounds tracking |
The O(N^2) Concatenation Trap
Consider appending words in a loop using strcat():
char document[10000] = "";
for (int i = 0; i < 1000; i++) {
strcat(document, words[i]);
}strcat does not remember where the previous string ended. On every iteration, it scans from the beginning of document to find \0, appends the new word, and writes a new \0.
As the buffer grows, the CPU rescans previously written bytes over and over, degrading performance from linear time to quadratic O(N^2). Systems programmers avoid this by maintaining an explicit pointer or length offset to the end of the buffer.
Pitfalls and variations
Pitfall 1: The strncpy Trap
Developers often assume strncpy is a safe drop-in replacement for strcpy. It is not:
char dest[4];
strncpy(dest, "code", sizeof(dest));If strlen(src) >= sizeof(dest), strncpy fills the buffer to the limit, but does not write a trailing \0. dest is left unterminated. Downstream reads read past the array boundary.
If strncpy is used, always manually null-terminate the final byte:
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';Pitfall 2: Off-By-One Buffer Capacity
When calculating a destination buffer's capacity for a string, always reserve + 1 for the null sentinel:
const char *source = "token";
char dest[sizeof("token")]; /* Includes space for '\0'. */
memcpy(dest, source, sizeof("token"));Where it is used
Null-terminated strings interface with the core foundation of operating systems:
- POSIX Kernel System Calls: System calls that take file paths (
open(),unlink(),mkdir()) accept null-terminatedconst char *paths. - Environment Variables: The process environment vector (
char *envp[]) is an array of null-terminated strings ("KEY=VALUE"). - Redis Simple Dynamic Strings (SDS): High-performance databases like Redis implement an optimized string abstraction: they store an explicit length header before the string in memory, but keep a trailing
\0byte at the end so the buffer can be passed to standard C library functions without copying.
In the next lesson, we will see what happens when pointers reference other pointers: mastering double pointers, pointer indirection, and dynamic matrix memory management.
04 · Prove it
Question 1 of 3 · answer from memory
What is the primary operational difference between sizeof(str) and strlen(str) for a stack buffer declared as char str[32] = "code";?
05 · Practice
Something wrong with this lesson? Report a problem