A String Is a Convention, Not a Type

Chapter 4 ended on a promise, and this lesson cashes it in the first paragraph. A C string is a char array whose end is marked by one byte with the value zero. That is the entire definition. There is no string type in C, no object that knows its own length, no hidden size field the compiler maintains for you. There is a run of char objects, exactly the contiguous run chapter 4 described, plus a convention about how it ends. Every operation you will ever perform on a string works by looking for that final byte, which is why the byte has a name of its own: the null terminator.

It is written '\0', and it is a character constant like chapter 2's 'A'. 'A' is the int 65, and '\0' is the character whose value is 0. That makes it a completely different thing from '0', the digit, whose value is 48. The two look nearly identical in source and the compiler will never question a swap, because to C both are just small integers.

A string literal in your source is already an array in this shape. "hi" looks like two characters and is three, because the compiler appends the terminator itself:

"hi"    index:    0      1      2
        value:   'h'    'i'   '\0'
        as int:  104    105      0

Which gives you the rule the rest of this chapter keeps paying for. A buffer that holds an n-character string needs n + 1 bytes, one for each character and one for the terminator. Nearly every buffer overrun a beginner writes is that + 1 missing.

Writing char greeting[] = "hi"; declares an array and copies the literal's three bytes into it. This is the third and last of the decay exceptions chapter 4 named and deferred: a string literal initializing an array does not decay to a pointer, it initializes the array element by element. You leave the brackets empty because the initializer already knows the size, and what you get is an ordinary writable array of char that obeys chapter 4's rules and needs no new ones.

Counting to the Terminator

Since nothing records the length, finding it means walking from the start until you meet the zero byte. That loop is the most important shape in this chapter, and you are going to write it before the library that does it for you arrives next lesson.

#include <stdio.h>

int count_characters(const char *text)
{
    int count = 0;

    while (text[count] != '\0')
    {
        ++count;
    }

    return count;
}

int main(void)
{
    char greeting[] = "hi";
    char word[] = "memory";

    printf("greeting[0] is %c with value %d\n", greeting[0], greeting[0]);
    printf("greeting[2] has value %d\n", greeting[2]);
    printf("%s: %d characters, sizeof %zu\n", greeting, count_characters(greeting), sizeof(greeting));
    printf("%s: %d characters, sizeof %zu\n", word, count_characters(word), sizeof(word));

    return 0;
}
greeting[0] is h with value 104
greeting[2] has value 0
hi: 2 characters, sizeof 3
memory: 6 characters, sizeof 7

The first two lines are chapter 2's dual reading of char doing real work, since %c shows the character and %d shows the number, and the terminator is the one element where only %d has anything to show. The last two lines carry the point. Two numbers describe one array and they differ by one every time, because the character count stops at the terminator and excludes it, while sizeof on the array counts every byte and includes it. Both are true and they answer different questions, so hold them apart now: next lesson hands you a library function that computes the first one, and beginners reach for it when they wanted the second.

Notice the signature. count_characters takes a const char * and no count, because unlike every other array in chapter 4, a string carries its own end marker and needs no length parameter beside it. That is the one thing the convention buys you. The parameter is const because the function only reads, and the loop could equally have been written with a moving pointer, while (*text != '\0') { ++text; }, since text[i] is still *(text + i).

%s is the other walker. Handed a char *, printf starts at that address and writes bytes out until it meets the terminator, which is how one specifier prints a string of any length. It is also chapter 1's security rule made concrete, so write printf("%s", text) and never printf(text), because a string you did not write is data and must never be read as a format.

Two Declarations That Look Alike

Here is the distinction the syntax hides. char copy[] = "hi"; is an array that owns a copy of the literal. const char *literal = "hi"; is a pointer aimed at the literal itself, which is an object of its own with static storage duration, alive for the whole run and living in a part of memory the program does not get to write.

#include <stdio.h>

int main(void)
{
    char copy[] = "hi";
    const char *literal = "hi";

    printf("both print as %s and %s\n", copy, literal);
    printf("sizeof(copy) is %zu but sizeof(literal) is %zu\n", sizeof(copy), sizeof(literal));

    copy[0] = 'H';
    printf("copy is now %s and literal is still %s\n", copy, literal);

    return 0;
}
both print as hi and hi
sizeof(copy) is 3 but sizeof(literal) is 8
copy is now Hi and literal is still hi

Reading gives no hint that they differ, since %s prints the same two characters through both. sizeof does: 3 is the array, 8 is a pointer on this machine, chapter 4's decay-exception measurement applied to a case where it finally matters. Writing to the array is fine, as the third line shows. And the pointer is declared const for an honest reason. The next program is wrong on purpose.

#include <stdio.h>

int main(void)
{
    char *greeting = "hi";

    greeting[0] = 'H';
    printf("%s\n", greeting);

    return 0;
}

char *greeting = "hi"; is legal C and gcc compiles it at -Wall -Wextra without a word of complaint. Modifying a string literal is undefined behaviour, and on this platform the run ends before anything prints:

AddressSanitizer:DEADLYSIGNAL
==12==ERROR: AddressSanitizer: SEGV on unknown address 0x000000400980 (pc 0x0000004007d0 bp 0xffffdc5cd250 sp 0xffffdc5cd250 T0)
==12==The signal is caused by a WRITE memory access.
    #0 0x0000004007d0 in main /tmp/e.c:7
SUMMARY: AddressSanitizer: SEGV /tmp/e.c:7 in main

That is chapter 3's SEGV again and it reads the same way, except the cause is neither a null nor a dangling address. The address is a perfectly real one holding the literal, on a page the operating system mapped read-only, and the WRITE line names the operation that was refused. Do not let the crash teach the wrong lesson, though. Undefined behaviour is not a promise of a crash: the standard permits anything at all here, and another compiler or platform may quietly let the write through.

The rule falls out with no room for judgement. If you intend to modify a string, declare it as an array so you get a copy. If you only intend to read it, declare the pointer const char *, so chapter 3's read-only promise turns the accident into a compile error you can see rather than a run you have to survive.

The Byte That Is Not There

Everything above depends on the terminator being present, so the last question is what happens when it is not. Nothing stops you filling a char array to the brim. The next program is wrong on purpose.

#include <stdio.h>

int main(void)
{
    char word[2] = {'h', 'i'};

    printf("the word is %s\n", word);

    return 0;
}

That array is legal, complete and correctly initialized. It is two char objects holding 'h' and 'i', and every one of chapter 4's initializer-list rules is satisfied. What it is not is a string, because there is no room left for the terminator and the initializer did not supply one. printf cannot know that. Handed the address, %s walks forward looking for a zero byte, and having passed the end of the array it keeps walking:

==12==ERROR: AddressSanitizer: stack-buffer-overflow on address 0xfbff888f0022 at pc 0xffff8b32ee94
READ of size 3 at 0xfbff888f0022 thread T0
    #2 0xffff8b343450 in printf (/usr/local/lib64/libasan.so.8+0xb3450)
    #3 0x0000004008f4 in main /tmp/e.c:7
Address 0xfbff888f0022 is located in stack of thread T0 at offset 34 in frame
    #0 0x00000040086c in main /tmp/e.c:4
  This frame has 1 object(s):
    [32, 34) 'word' (line 5) <== Memory access at offset 34 overflows this variable
SUMMARY: AddressSanitizer: stack-buffer-overflow /tmp/e.c:7 in main

Read it with chapter 4's four lookups and it is a familiar report. The kind is stack-buffer-overflow, so an automatic array was touched outside itself. The operation is a READ rather than a write, which is the part worth sitting with, because a program that only prints a string can still be an out-of-bounds program. The location is the first frame naming your file, #3, the frames above it being inside printf walking on your behalf. And the last field solves the case: [32, 34) 'word' is a two-byte object and the access is at offset 34, the very first byte past its end. Nothing about the array is wrong. What is wrong is calling two bytes a string.

The fix is the n + 1 rule and it has two spellings. Write char word[3] = {'h', 'i', '\0'}; and terminate the array by hand, or write char word[] = "hi"; and let the literal do it, which is why the literal form is the one to reach for. When you do size a buffer yourself, size it for the characters and for the byte that ends them.

Key Takeaways

  • A C string is a convention, not a type: a char array whose end is marked by the null terminator '\0'. Nothing records a string's length, so every operation on one finds the end by walking to that byte.
  • '\0' is the character with value 0 and '0' is the digit with value 48. They are different characters and the compiler will not warn you about swapping them.
  • A buffer for an n-character string needs n + 1 bytes. "hi" is three chars, and char s[] = "hi"; has sizeof 3 while a walk to the terminator counts 2, because the count excludes the terminator and sizeof includes it.
  • char s[] = "hi"; copies the literal into a writable array; char *p = "hi"; points at the literal itself. The literal has static storage duration and modifying it is undefined behaviour, which this platform reports as a SEGV caused by a WRITE. Declare an array when you mean to modify, and const char * when you only mean to read.
  • printf's %s takes a char * and walks to the terminator. Always write printf("%s", text) rather than printf(text), because a string that came from outside your program must never be used as a format.
  • A char array with no terminator is not a string, and handing one to %s or to a counting loop reads past its end. AddressSanitizer reports it as a stack-buffer-overflow with a READ, naming the array and the offset one byte beyond it.