Arrays & Strings · beginner · ~8 min

Character arrays

By the end of this lesson you will be able to: - Explain that a `char` is a 1-byte integer and that `char buf[N]` is just `N` bytes laid out side by side. - Tell the difference between a raw `char` buffer (any bytes) and a NUL-terminated *string* (bytes that end in `\0`). - Predict the exact size of a string literal such as `"hello"` and explain where the extra byte comes from. - Distinguish a single character `'a'` from a one-character string `"a"`. - Safely create, modify, and truncate a writable `char` array, and recognise why writing into a string literal is undefined behaviour. - Walk through how standard string functions find the end of a string using the NUL terminator.

Overview

A huge amount of real software is just text: usernames, file paths, JSON, HTTP headers, log lines, error messages. In C there is no built-in string type the way other languages have. Instead, C represents text using something you already know from the Arrays lesson — an array, specifically an array of char.

This lesson connects two ideas. From Arrays you know that int nums[5] reserves five int slots in a row in memory and lets you reach each one by index. A character array is the same idea with a different element type: char buf[16] reserves sixteen char slots in a row. What is new here is a convention that turns a plain array of bytes into something the C library treats as text: a special marker byte, \0, placed after the last visible character to say "the text stops here".

So there are two layers to keep straight:

  • A buffer is raw storage — just bytes. C makes no assumptions about what they mean.
  • A string is a buffer that follows the C convention: it contains a \0 (NUL) byte that marks the end of the text.

Every standard function that works on text — strlen, printf("%s", ...), strcpy, and the rest — depends on that \0 being present. Get the terminator right and strings are simple. Get it wrong and you reach past the end of your data, which is one of the most common sources of bugs and security holes in C. Understanding character arrays is the foundation for every later string topic.

Why it matters

Strings are everywhere in real systems, and in C they are manually managed arrays. That manual control is powerful and fast, but it also means the language will not stop you from making a mistake.

  • Correctness: If the \0 terminator is missing or in the wrong place, functions like strlen and printf("%s") keep reading memory past your data until they happen to hit a zero byte. That produces garbage output or a crash.
  • Security: The classic buffer overflow — writing more bytes into a char array than it can hold — overwrites neighbouring memory. Decades of serious vulnerabilities trace back to exactly this. Knowing the difference between the array's capacity and the string's length is the first line of defence.
  • Interoperability: Files, network protocols, and operating-system calls all hand you bytes that you must interpret as characters. Treating those bytes correctly — knowing when they are terminated and when they are not — is everyday C work.

This lesson gives you the mental model that prevents those mistakes before you ever call a string function.

Core concepts

1. A char is a tiny integer

Definition. A char is an integer type that occupies exactly 1 byte. On almost every system that means 8 bits, holding 256 distinct values.

Plain explanation. When you write char c = 'A'; you are storing the number 65 — the code for the letter A in ASCII. The character and the number are the same thing; the 'A' notation is just a convenient way to write 65. That is why you can do arithmetic on characters: 'A' + 1 is 'B'.

How it works internally. Each char is one addressable byte in memory. An array of char is a run of these bytes with no gaps.

char buf[6] = "hello";

 index:   0    1    2    3    4    5
         +----+----+----+----+----+----+
  bytes: | h  | e  | l  | l  | o  | \0 |
         +----+----+----+----+----+----+
 values:  104  101  108  108  111   0

When to use / not to use. Use a single char for one character or a small integer. Use a char array when you need a sequence of characters (text).

Common pitfall. Forgetting that char is a number. printf("%d", 'A') prints 65, not A; printf("%c", 65) prints A.

Knowledge check: What integer value does '0' hold? (Hint: it is not 0 — look up the ASCII code for the digit zero, which is 48.)

2. Buffer vs. string: the NUL terminator

Definition. A string in C is a char array whose characters are followed by a \0 byte (the NUL terminator, a byte whose value is zero). A buffer is any char array, terminated or not.

Plain explanation. The \0 is a sentinel — a marker value that means "stop here". C does not store a separate length for a string; instead it stores this end marker inside the data. To find where text ends, the library scans forward until it sees a zero byte.

How it works internally. strlen literally counts bytes until it reaches \0:

char buf[16] = "hi";

 index:  0    1    2    3   ... 15
        +----+----+----+----+   +----+
        | h  | i  | \0 | \0 |...| \0 |   (uninitialised tail is 0 here
        +----+----+----+----+   +----+    because we used = "hi")
                   ^
                   strlen stops counting here -> length 2

Structure. Two separate numbers matter: the capacity (how many bytes the array can hold — here 16) and the length (how many characters precede the \0 — here 2). The terminator must fit inside the capacity, so a 16-byte buffer can hold at most 15 visible characters plus the \0.

When to use / not to use. Always terminate a buffer before passing it to a %s format or a str* function. Do not assume bytes from a file or socket are terminated — they usually are not.

Common pitfall. A buffer with no \0 anywhere. strlen and printf("%s") then run off the end of the array reading whatever bytes follow until they hit a stray zero — undefined behaviour.

Knowledge check (predict the output): Given char buf[3] = {'h','i','!'}; (no \0), is printf("%s\n", buf); safe? Why or why not?

3. String literals

Definition. A string literal is text in double quotes, like "hello". Its type is char[N] where N is the number of visible characters plus one for the automatically added \0.

Plain explanation. Writing "hello" tells the compiler to lay out six bytes: h e l l o \0. The trailing terminator is added for you. This is exactly why "abc" occupies 4 bytes, not 3.

How it works internally. String literals are stored in a read-only region of the program. When you write char *p = "hi";, p points into that read-only storage. When you write char buf[16] = "hi";, the literal's bytes are copied into your own writable array.

char *p = "hi";          char buf[16] = "hi";

 p ──► [ h | i | \0 ]      buf: [ h | i | \0 | 0 | ... | 0 ]
       (read-only)              (your own writable storage)

When to use / not to use. Use a literal directly when you only need to read the text. If you need to modify it, copy it into a char array first.

Common pitfall. Writing through a pointer to a literal: char *p = "hi"; p[0] = 'H'; modifies read-only memory and typically crashes.

Knowledge check (explain in your own words): Why does char buf[16] = "hi"; let you do buf[0] = 'H'; safely, while char *p = "hi"; p[0] = 'H'; does not?

Syntax notes

Several ways to create character data, with what each means annotated:

char c = 'A';            // one byte, value 65 — a single character, NOT a string

char a[6] = "hello";     // array of 6 chars: 'h','e','l','l','o','\0'
char b[] = "hello";      // size deduced as 6 (5 letters + terminator)
char d[16] = "hi";       // 'h','i','\0', then 13 more bytes all set to 0
char e[3] = {'h','i','x'}; // a BUFFER, not a string: no '\0' anywhere

char *p = "hello";       // pointer into read-only literal storage (read only!)
const char *q = "hello"; // same, but const documents/enforces read-only intent

Key reminders:

  • 'a' (single quotes) is a char. "a" (double quotes) is a 2-byte string.
  • When you initialise an array with a quoted literal, the \0 is included automatically; with a brace list {...} it is not — you must add it yourself if you want a string.
  • Prefer const char * for pointers to literals so the compiler flags accidental writes.

Lesson

A char is a tiny integer

In C, a char is just a 1-byte integer. char buf[16] is simply 16 of those bytes sitting next to each other in memory.

What makes a buffer a string

A string is a char array whose contents end in a \0 byte. (\0 is the NUL terminator — a byte whose value is zero.)

This NUL byte marks where the text ends. Every standard library string function relies on this convention to know where to stop.

So a buffer holds bytes; a string is a buffer that happens to contain a \0 to mark its end.

String literals

A string literal like "hello" has type char[6]: 5 visible characters plus the trailing NUL.

String literals live in read-only memory. Trying to write into one is undefined behaviour (the standard places no constraints on what happens — often a crash).

Code examples

#include <stdio.h>
#include <string.h>

int main(void) {
    /* A writable buffer with capacity 16. The = "hi" copies
       'h','i','\0' into it and zero-fills the remaining 13 bytes. */
    char buf[16] = "hi";

    printf("start : \"%s\"  length=%zu  capacity=%zu\n",
           buf, strlen(buf), sizeof buf);

    /* buf is writable, so we may change its bytes. */
    buf[2] = '!';            /* now bytes are 'h','i','!', then the old zeros */
    printf("append: \"%s\"  length=%zu\n", buf, strlen(buf));

    /* Truncate back to "hi" by putting the terminator at index 2. */
    buf[2] = '\0';
    printf("trunc : \"%s\"  length=%zu\n", buf, strlen(buf));

    /* A read-only literal: q points into read-only storage. */
    const char *q = "abc";
    printf("literal \"%s\" occupies %zu bytes (3 chars + NUL)\n",
           q, sizeof "abc");
    /* q[0] = 'A';  <-- would be undefined behaviour: do NOT do this. */

    /* Show that a char is just a number. */
    char ch = 'A';
    printf("'A' as number = %d, as char = %c\n", ch, ch);

    return 0;
}

What it does. It creates a writable 16-byte buffer initialised to "hi", prints its length and capacity, appends a !, truncates back to "hi" by moving the terminator, then shows the size of a literal and the dual number/character nature of a char.

Expected output:

start : "hi"  length=2  capacity=16
append: "hi!"  length=3
trunc : "hi"  length=2
literal "abc" occupies 4 bytes (3 chars + NUL)
'A' as number = 65, as char = A

Note the final line: the same char prints as 65 with %d and as A with %c — proof that a character is a number.

Edge cases. strlen counts up to the first \0; if you wrote buf[2] = '!' but the original "hi" had only filled three bytes, the byte at index 3 is still \0, so "hi!" is still a valid terminated string. sizeof buf is the array capacity (16), independent of how much text it currently holds — this only works because buf is an array, not a pointer.

Line by line

Tracing the key lines and the contents of buf as the program runs:

Step Statement buf bytes (indices 0..3) strlen(buf) Notes
1 char buf[16] = "hi"; h i \0 \0 2 literal copied in; rest of the 16 bytes are 0
2 printf(... strlen ... sizeof ...) unchanged 2 length=2 (text), capacity=16 (sizeof buf)
3 buf[2] = '!'; h i ! \0 3 index 2 overwritten; index 3 was already \0, so still terminated
4 buf[2] = '\0'; h i \0 \0 2 moving the terminator earlier truncates the string
5 const char *q = "abc"; (q points elsewhere) n/a q references read-only literal storage
6 sizeof "abc" evaluates to 4: three letters plus the automatic \0

The central idea: the visible length of a string is decided by where the \0 sits, while the capacity is fixed when the array is declared. In step 3 we made the string longer by writing a character; in step 4 we made it shorter by writing a terminator. The capacity never changed.

Why sizeof buf is 16 but strlen(buf) is 2: sizeof is a compile-time property of the array type (16 bytes of storage), whereas strlen is a run-time scan that stops at the first \0 (2 characters of text).

Common mistakes

Mistake 1 — Confusing 'a' with "a".

char c = "a";   // WRONG: "a" is a char[2], you are assigning a pointer to a char

Why it is wrong: "a" is a two-byte string ('a' plus '\0'), not a single character. The compiler will warn or error.

Corrected:

char c = 'a';   // single character, value 97

Recognise it: the compiler complains about "initialization makes integer from pointer". Remember: single quotes = one char, double quotes = string.

Mistake 2 — Writing into a string literal.

char *p = "hi";
p[0] = 'H';     // WRONG: modifies read-only memory -> undefined behaviour, often a crash

Why it is wrong: the literal lives in read-only storage; p only points at it. Corrected — copy into a writable array first:

char buf[16] = "hi";  // copy the bytes into your own storage
buf[0] = 'H';         // fine: "Hi"

Prevent it: declare pointers to literals as const char * so the compiler rejects the write at compile time.

Mistake 3 — Forgetting the terminator with a brace list.

char greeting[3] = {'h', 'i', '!'};   // no '\0' anywhere
printf("%s\n", greeting);             // WRONG: not a valid string

Why it is wrong: there is no \0, so printf("%s") reads past the array. Corrected — leave room and terminate:

char greeting[4] = {'h', 'i', '!', '\0'};  // or simply: char greeting[] = "hi!";

Mistake 4 — Off-by-one: no room for the terminator.

char name[5] = "hello";  // WRONG: 5 letters + '\0' needs 6 bytes

The terminator does not fit. Either size the array for the text plus one (char name[6]) or let the compiler count it (char name[] = "hello";).

Debugging tips

Compiler warnings to enable. Always compile with -Wall -Wextra. The compiler catches several of the mistakes above for free — assigning "a" to a char, writing through a non-const pointer to a literal (with -Wwrite-strings), and initialisers that overflow.

Symptom: garbage characters or extra junk after your text.

  • Likely cause: the buffer is not terminated. Check that a \0 was actually placed where you think the string ends.
  • Debug step: print the length with printf("%zu\n", strlen(buf));. If it is far larger than expected, the terminator is missing.

Symptom: program crashes (segmentation fault) on a string write.

  • Likely cause: writing through a pointer to a literal (char *p = "hi"; p[0] = ...).
  • Debug step: search for any char * initialised directly from a quoted literal that you later write to. Change it to a char array.

Symptom: string is shorter or longer than expected.

  • Inspect the bytes directly. In a debugger such as gdb, run x/16xb buf to dump 16 bytes as hex and look for where the 00 byte sits.
  • Or print byte by byte: for (size_t i = 0; i < sizeof buf; i++) printf("%d ", buf[i]); and find the first zero.

Questions to ask when it doesn't work.

  • Is there a \0 inside the array, and is it where I intended?
  • Is the array big enough for the text plus one for the terminator?
  • Am I treating a pointer-to-literal as if it were writable?
  • Did I confuse sizeof (capacity) with strlen (length)?

Memory safety

Character arrays are where many C memory-safety bugs begin. Keep these in mind for this topic specifically:

  • Buffer overflow (out-of-bounds write). An array char buf[N] has valid indices 0 through N-1. Writing to buf[N] or beyond corrupts neighbouring memory and is undefined behaviour. Always leave one byte for the \0: a buffer of capacity N holds at most N-1 visible characters.
  • Missing terminator (out-of-bounds read). If a buffer has no \0, every function that scans for one (strlen, printf("%s"), strcpy) reads past the end. This is undefined behaviour even though nothing was written — the read alone is the bug.
  • Writing to read-only literals. char *p = "hi"; p[0] = 'H'; is undefined behaviour. Use const char * for literals, and copy into a writable array before modifying.
  • Uninitialised bytes. char buf[16]; with no initialiser contains indeterminate values — there is no guaranteed \0. Do not read it as a string until you have written text and a terminator. Note that char buf[16] = "hi"; does zero-fill the unused tail, which is why it is safe.
  • Integer/char sign surprises. Whether plain char is signed is implementation-defined. When testing or doing arithmetic on bytes that might be ≥ 128 (or with functions like isalpha), cast to unsigned char first to avoid negative values causing undefined behaviour.

The single most useful habit: always track capacity and length as two separate numbers, and never let length (plus the terminator) exceed capacity.

Real-world uses

Concrete uses.

  • Operating systems and files: file paths, command-line arguments (argv is an array of char *), and environment variables are all C strings.
  • Networking: HTTP headers, hostnames, and protocol fields arrive as byte buffers that you must terminate or length-track before treating as text.
  • Embedded systems: fixed-size char buffers hold sensor labels, display text, and serial-port messages where memory is tight and every byte is counted deliberately.
  • Tooling: parsers, configuration readers, and loggers spend most of their time slicing and copying character arrays.

Professional best-practice habits.

Beginner rules:

  • Always size a buffer for the text plus one for the \0.
  • Use const char * for any pointer to a literal you only read.
  • Compile with -Wall -Wextra and fix every warning.
  • Initialise buffers (char buf[16] = ""; or = {0}) so you never read uninitialised bytes.

Advanced habits:

  • Track capacity and length explicitly; prefer length-bounded functions (covered in later lessons) over ones that assume termination.
  • Name buffers and their sizes consistently (e.g. name and NAME_CAP) so the bound is obvious at every call.
  • Validate any external input's length before copying it into a fixed buffer.
  • Keep ownership and lifetime clear: know who allocated a buffer and who frees it.

Practice tasks

Beginner 1 — Size detective. Objective: predict and then verify the size of several literals. Declare "a", "abc", and "hello\0there", and print sizeof each. Write down your prediction before running. Expected: 2, 4, and 12 (the embedded \0 still counts as a byte, and the automatic terminator is added after there). Concepts: string-literal sizing, the automatic terminator.

Beginner 2 — Manual string builder. Objective: build the string "hi" one byte at a time. Declare char buf[8];, assign buf[0]='h', buf[1]='i', buf[2]='\0', then print it with %s and print strlen(buf). Requirement: do not use any string function except strlen/printf. Hint: the \0 is what makes it printable. Concepts: buffer vs. string, the terminator.

Intermediate 1 — Truncate in place. Objective: write a function void truncate_at(char *s, size_t n) that shortens string s to at most n characters by writing a \0 at the right index. Example: truncate_at(buf, 3) turns "hello" into "hel". Constraints: do not write past the existing terminator; if the string is already shorter than n, leave it unchanged. Hint: compare strlen(s) with n. Concepts: terminator placement, length vs. capacity.

Intermediate 2 — Count without strlen. Objective: write size_t my_strlen(const char *s) that returns the length of s by scanning for \0 yourself, then test it against the library strlen. Constraints: no library string functions inside your version; treat the empty string correctly (length 0). Hint: loop, incrementing an index until s[i] == '\0'. Concepts: how the terminator drives every string scan.

Challenge — Safe copy with capacity. Objective: write int safe_copy(char *dest, size_t cap, const char *src) that copies src into dest without ever writing past cap bytes, always terminates dest, and returns 0 on success or -1 if src did not fit. Requirements: dest must be a valid string afterwards even on failure (terminate what fits). Input/output example: copying "hello" into a buffer with cap == 4 yields "hel" and returns -1. Hint: you may copy at most cap - 1 characters and must reserve one byte for \0. Concepts: capacity vs. length, off-by-one avoidance, defensive termination. Do not use strcpy.

Summary

  • A char is a 1-byte integer; char buf[N] is N of those bytes laid out in a row, just like any other array.
  • A buffer is raw bytes; a string is a buffer that ends in a \0 (NUL) terminator. The library uses that \0 to find where text stops.
  • Keep capacity (the array's fixed size, via sizeof) and length (characters before \0, via strlen) as two separate numbers. A buffer of capacity N holds at most N-1 visible characters plus the terminator.
  • A literal like "hello" has type char[6] — five letters plus the automatic \0; that is why "abc" is 4 bytes.
  • Single quotes 'a' make one char; double quotes "a" make a 2-byte string.
  • String literals are read-only: write into one (char *p = "hi"; p[0]=...) and you get undefined behaviour. Copy into a writable char array first, and prefer const char * for read-only literals.
  • The most common bugs are a missing terminator (over-read) and writing past the end (overflow). Always leave room for the \0, and compile with -Wall -Wextra.

Practice with these exercises