A string is bytes up to a NUL¶
Level: 101 → 201 · for anyone starting from zero
One line: A C string is an array of char that ends at the first zero byte, the length is not stored anywhere but found by counting to that byte, and everything surprising about C strings follows from those two facts — a sizeof that disagrees with strlen, a \0 in the middle that hides half the data, and an array that forgets how long it is the moment you pass it to a function.
There is no string type in C. There is char, there is an array of it, and there is a convention that a run of characters ends at the first byte equal to zero — the NUL terminator. Nothing records the length. strlen does not read a stored number; it starts at the pointer and counts bytes until it finds the zero. Every other lesson in this chapter is a consequence of that, so this is the one to read first.
The program¶
string_nul_c.c declares four things and dumps the bytes behind each, using a hand-written hex dump so that nothing stops at a NUL the way the library functions would:
string_nul_c.c in full — pasted here by tools/run_examples.py from the file CI runs.
/* A C string is a run of bytes, and the only thing that says where it ends
is a zero byte. Nothing else -- not the array, not the pointer -- carries a
length. This program dumps the bytes behind four declarations so the NUL is
visible rather than assumed. */
#include <stdio.h>
#include <string.h>
/* The bytes of `p`, `n` of them, as hex -- no library function, so nothing
here stops at a NUL. */
static void dump(const char *label, const void *p, size_t n)
{
const unsigned char *b = p;
printf("%-26s", label);
for (size_t i = 0; i < n; i++) {
printf(" %02x", b[i]);
}
printf("\n");
}
/* An array parameter is a pointer: inside the function `sizeof s` is the
size of a pointer, whatever the array was. */
static int sizeof_is_the_pointer(const char s[])
{
return sizeof s == sizeof (const char *);
}
int main(void)
{
/* 1. The literal is five letters; the compiler adds the sixth byte. */
char word[] = "hello";
printf("char word[] = \"hello\"\n");
printf(" sizeof word = %zu, strlen(word) = %zu\n", sizeof word, strlen(word));
dump(" bytes:", word, sizeof word);
/* 2. Five bytes asked for, five letters supplied: legal C, and no NUL.
This is an array of char, not a string -- strlen(five) would read past
it, so this program only dumps it. */
char five[5] = "hello";
printf("char five[5] = \"hello\"\n");
printf(" sizeof five = %zu, and no strlen: there is no NUL to stop at\n", sizeof five);
dump(" bytes:", five, sizeof five);
/* 3. A NUL in the middle ends the string there. The array is still six
bytes long; every string function believes it is two. */
char cut[] = "ab\0cd";
printf("char cut[] = \"ab\\0cd\"\n");
printf(" sizeof cut = %zu, strlen(cut) = %zu, printf(\"%%s\") prints \"%s\"\n",
sizeof cut, strlen(cut), cut);
dump(" bytes:", cut, sizeof cut);
printf(" memcmp sees all six: memcmp(cut, \"ab\\0cd\", 6) == 0 is %s\n",
memcmp(cut, "ab\0cd", 6) == 0 ? "true" : "false");
/* 4. A pointer into the middle is a string too -- the same NUL ends it. */
const char *tail = word + 2;
printf("const char *tail = word + 2\n");
printf(" tail = \"%s\", strlen(tail) = %zu\n", tail, strlen(tail));
/* 5. Pass the array to a function and its length is gone. */
printf("inside a function, sizeof s == sizeof (const char *): %s\n",
sizeof_is_the_pointer(word) ? "true" : "false");
return 0;
}
Verified output of string_nul_c.c — regenerated by tools/run_examples.py, never hand-typed.
char word[] = "hello"
sizeof word = 6, strlen(word) = 5
bytes: 68 65 6c 6c 6f 00
char five[5] = "hello"
sizeof five = 5, and no strlen: there is no NUL to stop at
bytes: 68 65 6c 6c 6f
char cut[] = "ab\0cd"
sizeof cut = 6, strlen(cut) = 2, printf("%s") prints "ab"
bytes: 61 62 00 63 64 00
memcmp sees all six: memcmp(cut, "ab\0cd", 6) == 0 is true
const char *tail = word + 2
tail = "llo", strlen(tail) = 3
inside a function, sizeof s == sizeof (const char *): true
What the four cases show¶
The literal is five letters and the array is six. "hello" is five characters, and the compiler adds the sixth byte, 00, so that strlen and printf("%s") know where to stop. That is why sizeof word is 6 and strlen(word) is 5: sizeof measures the storage, strlen counts to the terminator. The gap of one is the terminator itself.
An array can hold a string without room for its terminator. char five[5] = "hello" is legal C: five bytes asked for, five letters supplied, and no room left for the 00. It is an array of char, not a string — calling strlen on it would run off the end looking for a zero that was never written. clang warns about this exact declaration; GCC 13 does not. So the program only dumps five, and shows five bytes and no sixth.
A NUL in the middle ends the string there. char cut[] = "ab\0cd" is six bytes, but every string function believes it is two: strlen returns 2 and printf("%s") prints ab, because both stop at the 00 after ab. The data after it is still in the array — memcmp over all six bytes proves it — but no function that stops at a NUL can see it. This is not a corner case. It is the single most common way text silently disappears when it crosses into C, and the encodings library's page on the NUL byte ↗ collects the five different doors it slams.
The length is gone the moment the array is passed. Inside main, sizeof word is 6 — the compiler knows the array. Pass that array to a function and the parameter is not an array, it is a pointer to the first element; sizeof s inside the function is the size of a pointer, whatever the array was. This is array decay, and it is why almost every C function that takes a string also takes a length: the length could not survive the call otherwise.
The same string in Python¶
Python stores the length, so a NUL is a character like any other — until the value is handed to something written in C, which is where the terminator convention comes back. string_nul_py.py:
string_nul_py.py in full — pasted here by tools/run_examples.py from the file CI runs.
"""Python has no NUL terminator: a str knows its length and a NUL is a
character like any other -- until the value is handed to something written in
C, where the same byte ends the string. The last two lines are that boundary,
reached from Python: ctypes, and the operating system's file names."""
import ctypes
word = "hello"
print(f'len("hello") = {len(word)} -- no sixth byte, the length is stored')
cut = "ab\0cd"
print(f'len("ab\\0cd") = {len(cut)}, and "ab\\0cd".find("\\0") = {cut.find(chr(0))}')
print(f'"ab\\0cd".encode() = {cut.encode()!r} -- six bytes, the NUL among them')
# Hand those bytes to C and the string ends where C says it does.
as_c_string = ctypes.c_char_p(cut.encode())
print(f"ctypes.c_char_p(...).value = {as_c_string.value!r}")
# The OS's file APIs are C: Python refuses at the door rather than let a
# name be silently cut short.
try:
open(cut)
except ValueError as e:
print(f"open('ab\\0cd') -> ValueError: {e}")
Verified output of string_nul_py.py — regenerated by tools/run_examples.py, never hand-typed.
len("hello") = 5 -- no sixth byte, the length is stored
len("ab\0cd") = 5, and "ab\0cd".find("\0") = 2
"ab\0cd".encode() = b'ab\x00cd' -- six bytes, the NUL among them
ctypes.c_char_p(...).value = b'ab'
open('ab\0cd') -> ValueError: embedded null byte
len("ab\0cd") is 5, and .find("\0") locates the NUL rather than stopping at it. The moment those bytes reach C, though — through ctypes, or through the operating system's file APIs — the string ends at the NUL again: ctypes.c_char_p reports b'ab', and open() refuses the name outright rather than let it be silently cut short.
And in Rust¶
A Rust &str carries its length too, and CString is the explicit C shape: bytes plus a terminating zero, with a check that there is no zero anywhere else. string_nul_rs.rs:
string_nul_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.
// A Rust `str` carries its length and may hold a NUL; a `CString` is the
// C shape -- bytes plus a terminating zero -- and building one from data
// with a NUL inside is refused, because C would end the string there.
use std::ffi::{CStr, CString};
fn main() {
let word = "hello";
println!("\"hello\".len() = {} -- the length is stored, no terminator", word.len());
let cut = "ab\0cd";
println!("\"ab\\0cd\".len() = {}, find('\\0') = {:?}", cut.len(), cut.find('\0'));
// Crossing into C: the NUL has to go on the end, and only there.
let c = CString::new(word).unwrap();
println!("CString::new(\"hello\") -> {} bytes with the NUL: {:?}",
c.as_bytes_with_nul().len(), c.as_bytes_with_nul());
match CString::new(cut) {
Ok(_) => println!("CString::new(\"ab\\0cd\") -> ok"),
Err(e) => println!("CString::new(\"ab\\0cd\") -> Err: {e}"),
}
// Since Rust 1.77 a C-string literal writes the terminator for you.
let lit: &CStr = c"hello";
println!("c\"hello\": count_bytes() = {}, to_bytes_with_nul().len() = {}",
lit.count_bytes(), lit.to_bytes_with_nul().len());
}
Verified output of string_nul_rs.rs — regenerated by tools/run_examples.py, never hand-typed.
"hello".len() = 5 -- the length is stored, no terminator
"ab\0cd".len() = 5, find('\0') = Some(2)
CString::new("hello") -> 6 bytes with the NUL: [104, 101, 108, 108, 111, 0]
CString::new("ab\0cd") -> Err: nul byte found in provided data at position: 2
c"hello": count_bytes() = 5, to_bytes_with_nul().len() = 6
CString::new("ab\0cd") fails, and the error names the position of the interior NUL — the conversion cannot succeed, because C would end the string there and lose the rest. Since Rust 1.77 the c"hello" literal writes the terminator for you, which is the closest Rust comes to a C string literal.
If you are coming from another language¶
ABAP. (Not machine-checked — CI cannot run ABAP.) An ABAP string is a length-carrying type and there is no terminator convention at all; a NUL (cl_abap_conv will produce one, or cl_abap_char_utilities=>null) is just a character. The place the terminator returns is the same as everywhere else: a call out to the kernel or to C, where the byte string is handed across.
See also¶
- A
charis a byte, not a character — the next surprise: the byte the terminator is counting is not a letter - The functions that do not check — what happens when a copy trusts a terminator that is not there
- The NUL byte ↗ — one byte, five jobs; the terminator is only the first
- Control characters ↗ — where
\0sits among the first 32 codes, andsizeofagainststrlenfrom the encodings side - The anatomy of a
String↗ — the three words Rust stores instead of a terminator: pointer, length, capacity - Six kinds of string ↗ — where
CStringandCStrsit, and why the C boundary needs its own pair stris notbytes↗ — Python's answer: two types, and no implicit crossing