Strings and Character Handling
C has no string type. It has a convention: an array of characters with a zero byte marking the end. Everything in <string.h> follows from that single decision, and so does a remarkable share of the security vulnerabilities of the last forty years.
- Explain the null terminator and account for it in every buffer you size.
- Say why writing to a string literal crashes, and how to get a modifiable copy.
- Use the
<string.h>functions correctly, including the ones that are traps. - Choose between
strcpy,strncpy, andsnprintfand justify the choice. - Use the block memory functions and explain when
memmoveis required.
1The convention
A C string is a sequence of characters followed by '\0', the character with value zero. Nothing records the length; every function that needs it scans forward until it finds the terminator.
char greeting[6] = { 'h', 'e', 'l', 'l', 'o', '\0' };
char greeting[] = "hello"; /* identical — the compiler adds '\0' */Five characters, six bytes. The gap between strlen and sizeof is the source of most buffer bugs.
Two consequences follow immediately, and both cost real money every year.
Length is O(n), not O(1). strlen walks the whole string. Calling it in a loop condition — for (i = 0; i < strlen(s); i++) — makes an O(n) loop into an O(n²) one. Compute it once.
Every buffer needs one extra byte. A buffer for ten characters must be char buf[11]. Off-by-one here is not a wrong answer; it is memory corruption.
2Literals are read-only
char array[] = "hello"; /* a modifiable copy in the array */
char *pointer = "hello"; /* a pointer to read-only storage */
array[0] = 'H'; /* fine */
pointer[0] = 'H'; /* undefined behavior — usually crash */The distinction is not stylistic. The first line creates a six-byte array on the stack and copies the literal into it. The second stores the address of the literal, which lives in a read-only section of the executable — the .rodata section that week 52 inspects directly. Writing there fails at the hardware level.
Always declare a pointer to a literal as const, so the compiler catches the mistake rather than the operating system:
const char *pointer = "hello";
pointer[0] = 'H'; /* now a compile error */C23 finally made string literals const-qualified by default. Until a codebase is on C23, writing it yourself is the only protection.
3The <string.h> functions
| Function | Does | Watch out for |
|---|---|---|
strlen(s) | Length, excluding the terminator | O(n); returns size_t, so strlen(s) - 1 wraps when empty |
strcpy(dst, src) | Copy including the terminator | No bound. Overflows silently. |
strncpy(dst, src, n) | Copy at most n bytes | Does not terminate if src is too long. See below. |
strcmp(a, b) | <0, 0, or >0 | Returns 0 for equal — the opposite of a boolean |
strcat(dst, src) | Append | No bound, and rescans dst every call |
strchr(s, c) | Pointer to the first c, or NULL | Searches for a char, not a string |
strstr(h, n) | Pointer to the first occurrence of n | Returns a pointer into h, not a copy |
strtok(s, delims) | Split into tokens | Modifies s and keeps hidden static state |
strcmp returns 0 for equal
if (strcmp(a, b) == 0) { /* the strings are equal */ }
if (strcmp(a, b)) { /* the strings DIFFER */ }The second reads like "if a equals b" and means the opposite. Always compare explicitly against 0.
strncpy is not the safe strcpy
It looks like a bounded copy and is widely used as one. It is not. If the source is at least n bytes, no terminator is written, and you are left with an unterminated character array that the next strlen will run off the end of.
char dst[5];
strncpy(dst, "hello world", 5); /* dst = "hello" with NO terminator */
printf("%s\n", dst); /* reads past the end */It also pads with zeros to the full length when the source is shorter, which is wasted work on a large buffer. It was designed in the 1970s for fixed-width database fields, not for safety. Use snprintf instead:
snprintf(dst, sizeof dst, "%s", source); /* always terminates */strtok modifies its input
char text[] = "a,b,c"; /* must be modifiable — not a literal */
char *token = strtok(text, ",");
while (token != NULL) {
puts(token);
token = strtok(NULL, ","); /* NULL means "continue the same string" */
}
/* text is now "a\0b\0c" */It writes terminators into your buffer and remembers its position in a hidden static variable, which makes it unusable from two places at once and unsafe with threads. Week 41 returns to this under reentrancy; the thread-safe variant is strtok_r.
4Block memory operations
These work on raw bytes and know nothing about terminators, so they need an explicit length. They are also the right tool for any array, not just characters.
memcpy(dst, src, n); /* copy n bytes; regions must not overlap */
memmove(dst, src, n); /* copy n bytes; overlap is handled */
memset(buf, 0, n); /* fill n bytes with a value */
memcmp(a, b, n); /* compare n bytes; 0 means equal */Why both memcpy and memmove
memcpy promises the regions do not overlap, which lets it copy in whatever order is fastest — several bytes at a time, possibly backwards. Give it overlapping regions and the result is undefined: it may copy a byte it has already overwritten.
char buf[] = "abcdef";
memcpy(buf + 1, buf, 5); /* UNDEFINED: regions overlap */
memmove(buf + 1, buf, 5); /* correct: "aabcde" */memmove checks the direction and copies accordingly. It is marginally slower and always correct. When in doubt, use it.
memset sets bytes, which is why it is only useful for 0 and for single-byte values. memset(arr, 1, sizeof arr) on an int array does not fill it with ones; it fills every byte with 1, giving elements of 0x01010101.
5<ctype.h> and safe construction
#include <ctype.h>
isalpha(c) isdigit(c) isalnum(c) isspace(c)
isupper(c) islower(c) ispunct(c)
toupper(c) tolower(c)One subtlety with a real consequence: these take an int whose value must be representable as unsigned char, or be EOF. Passing a plain char that is negative — which happens with byte values above 127 where char is signed, as week 4 noted — is undefined behavior. Cast:
if (isalpha((unsigned char)s[i])) { … }Building strings safely
snprintf is the workhorse. It writes at most size bytes including the terminator, always terminates, and returns the length it would have written — which is how you detect truncation.
char line[32];
int needed = snprintf(line, sizeof line, "%s: %d", name, score);
if (needed < 0) {
/* encoding error */
} else if ((size_t)needed >= sizeof line) {
/* truncated: needed bytes were required */
}That return value is the part people miss. A program that ignores it silently produces truncated output, which in a filename or a command is a security problem rather than a cosmetic one.
6Worked example: a string toolkit, by hand and by library
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <stdbool.h>
/* --- written by hand, to show what the library does --- */
static size_t my_strlen(const char *s)
{
const char *start = s;
while (*s != '\0') {
s++;
}
return (size_t)(s - start); /* pointer subtraction: week 15 */
}
static void my_strcpy(char *dst, const char *src)
{
while ((*dst++ = *src++) != '\0') {
/* copies, including the terminator, then stops */
}
}
static int my_strcmp(const char *a, const char *b)
{
while (*a != '\0' && *a == *b) {
a++;
b++;
}
return (unsigned char)*a - (unsigned char)*b;
}
/* --- a safe copy, which the library does not provide --- */
static bool copy_into(char *dst, size_t dst_size, const char *src)
{
int needed = snprintf(dst, dst_size, "%s", src);
return needed >= 0 && (size_t)needed < dst_size;
}
static void to_upper_in_place(char *s)
{
for (; *s != '\0'; s++) {
*s = (char)toupper((unsigned char)*s);
}
}
static size_t count_words(const char *s)
{
size_t words = 0;
bool in_word = false;
for (; *s != '\0'; s++) {
if (isspace((unsigned char)*s)) {
in_word = false;
} else if (!in_word) {
in_word = true;
words++;
}
}
return words;
}
int main(void)
{
const char *sample = "the quick brown fox";
puts("== hand-written versus library ==");
printf(" my_strlen = %zu strlen = %zu\n",
my_strlen(sample), strlen(sample));
char a[32], b[32];
my_strcpy(a, sample);
strcpy(b, sample);
printf(" my_strcpy = \"%s\"\n", a);
printf(" strcpy = \"%s\"\n", b);
printf(" my_strcmp(\"abc\",\"abd\") = %d strcmp = %d\n",
my_strcmp("abc", "abd"), strcmp("abc", "abd"));
printf(" equal strings give 0: %d\n", strcmp("same", "same"));
puts("\n== strlen versus sizeof ==");
char buffer[32] = "hello";
printf(" strlen(buffer) = %zu sizeof buffer = %zu\n",
strlen(buffer), sizeof buffer);
puts(" the difference is room for growth plus the terminator");
puts("\n== truncation, detected ==");
char small[8];
if (copy_into(small, sizeof small, "short")) {
printf(" \"short\" fitted: \"%s\"\n", small);
}
if (!copy_into(small, sizeof small, "a much longer string")) {
printf(" long string truncated to \"%s\" — and we know it\n", small);
}
puts("\n== strncpy does not always terminate ==");
char danger[6];
memset(danger, 'X', sizeof danger); /* poison, to make it visible */
strncpy(danger, "hello world", 5);
printf(" first five bytes: %.5s\n", danger);
printf(" byte 5 is '%c' (%d) — not a terminator\n",
danger[5], danger[5]);
puts(" printing this with %s would read past the end");
puts("\n== memmove versus memcpy ==");
char overlap[] = "abcdef";
memmove(overlap + 1, overlap, 5);
printf(" memmove overlapping: %s\n", overlap);
puts(" memcpy on the same input is undefined behavior");
puts("\n== tokenizing modifies the buffer ==");
char csv[] = "red,green,blue";
printf(" before: %s\n", csv);
for (char *tok = strtok(csv, ","); tok != NULL; tok = strtok(NULL, ",")) {
printf(" token: %s\n", tok);
}
printf(" after: %s <-- only the first token; commas became '\\0'\n", csv);
puts("\n== character classification ==");
char text[32];
strcpy(text, sample);
printf(" words in \"%s\" = %zu\n", text, count_words(text));
to_upper_in_place(text);
printf(" upper-cased: %s\n", text);
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o strings strings.c
./stringsThe crash worth causing
Add these two lines and run it:
char *literal = "hello"; /* no const — the compiler allows it */
literal[0] = 'H'; /* undefined behavior */Segmentation fault (core dumped)Now add const and rebuild:
const char *literal = "hello";
literal[0] = 'H';error: assignment of read-only location '*literal'The same defect, moved from a run-time crash to a compile-time error by one keyword. That is the entire argument for const correctness, which week 16 develops into a discipline.
The overflow worth causing
char tiny[4];
strcpy(tiny, "far too long"); /* no bound checking anywhere */Without the sanitizer this may appear to work, corrupting a neighbouring variable exactly as week 12's array overflow did. With -fsanitize=address:
==12345==ERROR: AddressSanitizer: stack-buffer-overflow
WRITE of size 13 at 0x7ffd... thread T0
#0 0x... in main strings.c:118This is the defect class that produced the Morris worm in 1988 and has not stopped since. Week 36 examines why it is so exploitable.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
char buf[5]; strcpy(buf, "hello"); | Six bytes into five: overflow | Size for length + 1, or use snprintf. |
if (strcmp(a, b)) | True when the strings differ | strcmp(a, b) == 0. |
Trusting strncpy to terminate | Unterminated buffer; later strlen runs off the end | snprintf(dst, sizeof dst, "%s", src). |
char *p = "x"; p[0] = 'y'; | Crash — literals are read-only | Use an array, or declare const char *. |
strlen in a loop condition | O(n²) instead of O(n) | Compute once into a variable. |
strtok on a literal | Crash — it writes terminators | Tokenize a modifiable copy. |
memcpy with overlapping regions | Undefined; garbled result | memmove. |
isalpha(s[i]) with signed char | Undefined for bytes above 127 | Cast to unsigned char. |
Ignoring snprintf's return value | Silent truncation | Compare it against the buffer size. |
8Check yourself
Why must a buffer holding ten characters be declared with size 11?
Because a C string carries no length field; the end is marked by a '\0' byte that occupies storage like any other character. strlen reports the characters before it, so the array must hold length + 1 bytes. Sizing for the length alone is not a wrong answer, it is a buffer overflow.
Why does char *p = "hi"; p[0] = 'H'; crash while char a[] = "hi"; a[0] = 'H'; does not?
The array form copies the literal into modifiable storage that belongs to your function. The pointer form stores the address of the literal itself, which the compiler places in a read-only section of the executable; the write is refused by the memory protection hardware. Declaring such pointers const char * turns the crash into a compile error.
Is strncpy the safe version of strcpy?
No. It bounds the number of bytes written, but if the source fills that bound it writes no terminator, leaving an unterminated array that the next string function will read past. It was designed for fixed-width records, not for safety. snprintf always terminates and reports whether truncation occurred.
When must you use memmove instead of memcpy?
Whenever the source and destination regions can overlap. memcpy is permitted to assume they do not and may copy in any order, so overlapping input gives undefined results. memmove detects the direction and copies safely, at a negligible cost.
Why is calling strlen(s) in a loop condition a performance bug?
Because the length is not stored anywhere — strlen scans to the terminator every time it is called. Evaluating it once per iteration turns an O(n) loop into O(n²). Compute it once before the loop, provided the string does not change inside it.
9Where this leads
That is the Basic level complete. You can build, format, version, and reason about a C program; you know how values are represented, how types convert, how control flows, and how pointers and strings work. Week 15 opens the Intermediate level by developing pointer arithmetic properly — the s - start in my_strlen above was a preview — and weeks 19 and 20 then apply it to memory you allocate yourself.