C has no string type. A string is a row of chars with a zero byte at the end, and nothing else marks where it stops. Watch what happens when a copy does not fit.
Writing past the end of a string
A walk to the zero byte, then a copy that does not fit
size_t my_strlen(const char *s){ size_t n = 0; while (*s != '\0') { s++; n++; } return n;} int main(void){ char s[8] = "cat"; size_t a = my_strlen(s); size_t b = strlen(s); printf("my_strlen %zu, strlen %zu, sizeof %zu\n", a, b, sizeof s); return 0;}struct record { char name[8]; int score;}; void copy_string(char *dst, const char *src){ size_t i = 0; while (src[i] != '\0') { dst[i] = src[i]; i++; } dst[i] = '\0';} int main(void){ struct record r = { "dog", 42 }; copy_string(r.name, "countryside"); printf("name %s, score %d\n", r.name, r.score); return 0;}main has started: s has room for 8 chars.
- length
- ?
- score
- –
Output
Describe this picture
Two strips, with the code beside them: strlen.c and then spill.c, one after the other, the line about to run marked. The top strip is char s[8] = "cat", eight one-byte cells from 0x7fe8, holding c, a, t, the zero byte drawn as \0, and four more zeros. The clip takes about 18 seconds. In my_strlen a pointer marker s starts on cell 0 and steps along, each cell tinted as it is read, and stops on the zero, tinted as found; the readout “length” reads 3, and the output reads my_strlen 3, strlen 3, sizeof 8. Below it, greyed and empty until spill.c starts, is the record r, 12 bytes: the 8 bytes of name in one tint, which then hold d, o, g and zeros, and the 4 bytes of score in another, shown as the numbers 42 0 0 0; the name under the strip reads “r: name[8], score = 42”. copy_string writes “countryside” one byte at a time, the marker i moving along: c, o, u, n, t, r, y, s fill name, and then i, d, e and the zero land in score’s four bytes, drawn in the warning tone, while the name under the strip and the readout “score” change to 105, 25705 and then 6644841. Nothing stops the copy at the end of name. The output reads name countryside, score 6644841. The transport plays and pauses, steps one line back or forward, and scrubs; a paused step is kept in the page address as string-overflow.step.
Chars and the zero byte
A char is a small integer, one byte. Letters are stored by their codes: in ASCII, 'A' is 65, 'a' is 97 and the digit '0' is 48. 'c' and 99 are the same value written two ways.
A string is a row of chars followed by a char with the value 0, written '\0'. That zero is the only thing that says where the string ends: the array around it may be longer, and nothing records the length. So every function that works on a string walks along it until it meets the zero.
The code and its cost
The length
size_t my_strlen(const char *s)
{
size_t n = 0;
while (*s != '\0') {
s++;
n++;
}
return n;
}
char s[8] = "cat"; gives 8 bytes: 99, 97, 116, then 0, and the initialiser fills the other four with zeros as well. my_strlen(s) and the library’s strlen(s) both return 3, the chars before the first zero, while sizeof s is 8, the size of the array. A walk over a string of length n reads n + 1 bytes: the n chars and the zero.
The library functions
<string.h> has the usual walks. strlen(s) counts up to the zero. strcpy(dst, src) copies up to and including it. strcmp(a, b) compares char by char and returns a negative number, zero or a positive number. None of them knows how big dst is.
Sizing a buffer
A string of length L needs L + 1 bytes: the literal "cat" is 4 bytes with its zero. A buffer one byte short has nowhere for the zero, and the next walk over it runs on into whatever follows.
A copy that does not fit
struct record {
char name[8];
int score;
};
void copy_string(char *dst, const char *src)
{
size_t i = 0;
while (src[i] != '\0') {
dst[i] = src[i];
i++;
}
dst[i] = '\0';
}
spill.c starts with struct record r = { "dog", 42 }; and copies “countryside”, 11 letters, into r.name, which has room for 8. The record is 12 bytes: name at offsets 0 to 7 and score at 8 to 11. The copy writes the first 8 letters into name and goes on: i, d and e into score’s first three bytes and the zero into its fourth. Read low byte first, as x86-64 stores an int, score’s bytes 105, 100, 101, 0 are 0x00656469, which is 6,644,841. The program prints name countryside, score 6644841.
Neither tool complained. The program ran to the end, and the sanitiser build ran clean too: every byte it wrote lies inside the record, which is one object as far as the sanitiser knows. A write past an array inside a struct is invisible to it. It is still undefined behaviour: C allows writes only inside the 8 bytes of r.name, and the struct around them does not make the ninth legal. On this build the extra bytes happened to land in score; another compiler, or another flag, may do something else.
A copy past the object
string-overflow.c has char buf[8]; on its own and copies “spectrograms”, 12 letters, into it. Now the ninth byte lands outside any object, and the sanitiser build stops with stack-buffer-overflow, a write of 1 byte, in copy_string. This too is undefined behaviour, so the plain build owes nothing in particular. On this build it ran as far as main’s return and then stopped with *** stack smashing detected ***: terminated: gcc had put a guard value beside the array, and the copy overwrote it.
snprintf
snprintf(buf, size, format, …) formats like printf into buf, never writes more than size bytes, and, when size is more than 0, always ends what it wrote with a zero (with size 0 it writes nothing). It returns the length the whole result would have had, so a return value of size or more means the result was cut short.
Worked example
A buffer for “signal”. 6 letters and the zero: 7 bytes.
Truncation. In truncate.c, snprintf(out, 8, "%s-%d", name, year) with “dsp” and 2026 wants the 8 chars dsp-2026. It writes dsp-202 and the zero, 8 bytes, and returns 8. 8 ≥ 8, so the program prints truncated. (The call sits in a function that receives the values: with “dsp” and 2026 written in the same function, gcc sees them and warns that the output will be cut.)
Comparing. strcmp("cat", "car") is positive: the first difference is 't', 116, against 'r', 114, and 't' - 'r' is 2. The standard fixes only the sign of strcmp’s result, not its size, so test strcmp(a, b) > 0, never == 2.
Three checks. How many bytes does "hello" take? (6.) What does strlen cost on a string of length n? (n + 1 reads.) What would score become with “countrysides”, 12 letters? (Its bytes would be i, d, e and s, and the zero would land past the record: the case of string-overflow.c.)
Where it’s used
Text everywhere. File names, command lines, configuration files and the lines of network protocols are C strings when a C program handles them.
Security. Buffer overflows, a long-known class of security flaw, are this picture with an attacker choosing the bytes: a copy that runs past a buffer can overwrite whatever lies after it.
Safer interfaces. Functions that take the buffer’s size, such as snprintf, exist because the plain walks cannot know it.
Reference card
| Code or idea | Meaning |
|---|---|
'A', 'a', '0' | 65, 97, 48 in ASCII |
'\0' | the zero byte that ends every string |
"cat" | 4 bytes: c, a, t, 0 |
strlen(s) | chars before the zero: n + 1 reads |
sizeof s | the array’s size, not the string’s length |
| buffer for length L | L + 1 bytes |
strcpy(dst, src) | copies up to and including the zero; never checks dst’s size |
snprintf(buf, size, …) | at most size bytes, always a zero; returns the length wanted |
| return ≥ size | truncated |
Platform: C11, gcc on x86-64 Linux: int 4 bytes, pointers 8, little-endian. Addresses in the pictures are short stand-ins, by one rule for the whole course; sizes are the real ones.