LearnLearn CC in Practice

Strings and stdlib

strncat - a hands-on Linux lab on a real virtual machine.

A C string is a char array ending in a NUL terminator, and every string.h function trusts that stop mark. Measure the same char[6] two ways (strlen=4 vs sizeof=6), watch strcpy silently overflow a too-small box (9 bytes of campsite flatten the neighboring admin to te, zero warnings, exit 0), append safely with strncat and the sizeof - strlen - 1 size math, and meet the classic crash: writing into a read-only string literal earns Segmentation fault and exit code 139. The fix is one declaration: char word[] copies the literal into your own writable array.

Here is a tiny C program. It stores the word camp, changes the first letter to a capital C, then prints the result. That is the whole job. One new face: puts prints a string and adds a newline, a compact cousin of the printf you know.

#include <stdio.h>

int main(void) { char *word = "camp"; word[0] = 'C'; puts(word); return 0; }

gcc -Wall compiles it without a single complaint. Remember from the Hello, machine lesson, -Wall asks gcc to warn about anything suspicious. It found nothing suspicious here.

Run it, and the program does not print a wrong answer. It does not print anything. It never reaches the puts line. The operating system kills it on the spot, and the shell reports two words every C programmer learns to respect: Segmentation fault.

This lesson explains that crash completely. To get there, you need the one fact that explains almost everything about text in C: a string is not a special type. It is a row of bytes with a stop mark at the end.

The black boxes below are a practice terminal: a safe sandbox that checks the one command each step teaches. Every output is real, captured from the actual lab machine: Debian 12 with gcc 12.2.0, the same image your VM labs boot. A few values are per-machine, like memory addresses and process ids. Where one appears, a note tells you exactly what varies.

C has no string type. There is no string keyword the way there is an int keyword. What C has is char: a type holding a single byte, one character.

A string in C is a convention. It is a row of char bytes sitting next to each other in memory, ending with a byte whose value is zero. That zero byte is called the NUL terminator, written '\0' in C code.

Think of a freight train. Each boxcar carries one character. The NUL is the end-of-train marker. Anything walking the train knows to stop at the marker: not before, not after.

Every string function in C trusts that marker completely. strlen walks the bytes and counts until it hits the NUL. printf with %s prints bytes until it hits the NUL. If the marker is missing, they keep walking into memory that has nothing to do with your string.

Text between double quotes in C source, like "camp", is called a string literal. The compiler stores those bytes inside your program, adds the NUL for you, and hands you their address. Where it stores them is the heart of the crash. Hold that thought.

Because a string is just bytes, C cannot compare, copy, or measure one with an operator. word1 == word2 compares two addresses, not two spellings. Remember from the pointers lesson: an array name used in an expression is the address of its first element.

The standard library fills the gap. string.h is the header that declares the string toolbox: strlen to measure, strcpy to copy, strncat to append, strcmp to compare. Its sibling stdlib.h holds general helpers like atoi, the text-to-integer converter you used in the module 1 lab.

This is real daily work. Splitting a CSV line into fields, building a file path, checking whether a config value says yes: all of it is string.h work on rows of bytes. The CSV parser lab at the end of this module is exactly that.

Two questions about the same string sound identical and are not. How long is the text? How big is the box holding it? strlen answers the first. sizeof, which you met in the types lesson, answers the second.

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

int main(void) { char word[6] = "camp"; printf("strlen=%zu sizeof=%zu\n", strlen(word), sizeof word); return 0; }

One detail before you predict. char word[6] declares a box of exactly 6 bytes. The literal "camp" only needs 5 of them: four letters plus the NUL. C fills the leftover byte with zero. %zu is the printf marker for the unsigned size values that strlen and sizeof produce.

prompt: operator@camp:~$ answer: gcc -Wall len.c -o len && ./len output: strlen=4 sizeof=6 hint: Compile and run in one line: gcc -Wall len.c -o len && ./len

strlen walked the bytes, counted c, a, m, p, hit the NUL and stopped: 4. sizeof never looked at the bytes. It reports the size of the box you declared: 6. Same variable, two different questions. Here is the actual memory of word from a real run:

{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee0", "bytes": "63", "label": "word[0]: c" }, { "addr": "0x7ffd5e189ee1", "bytes": "61", "label": "word[1]: a" }, { "addr": "0x7ffd5e189ee2", "bytes": "6d", "label": "word[2]: m" }, { "addr": "0x7ffd5e189ee3", "bytes": "70", "label": "word[3]: p" }, { "addr": "0x7ffd5e189ee4", "bytes": "00", "label": "word[4]: NUL, strlen stops here" }, { "addr": "0x7ffd5e189ee5", "bytes": "00", "label": "word[5]: spare, filled with zero" } ] }

The addresses are from one real run on the lab machine. Yours will differ every run, because the kernel randomizes where the stack lands, a defense called ASLR. The byte pattern is what matters, and that is identical everywhere.

sizeof only knows the box when it can see the array declaration. Applied to a char * pointer it reports the pointer size, 8 bytes on this 64-bit machine, no matter how long the text is. Most engineers get bitten by this at least once.

strcpy(dst, src) copies bytes from src into dst, one by one, until it copies the NUL. Read that again: until it copies the NUL. It does not know how big dst is, and it does not stop when dst is full, because it has no way to tell.

If src is longer than dst, strcpy keeps writing past the end of the box, straight over whatever lives next in memory. That is a buffer overflow, the most famous bug class in the history of C.

Silent is the dangerous part. No compiler message, no crash, just wrong data later. Watch it happen. This program builds a table of two rows, each a 6-byte box, sitting side by side in memory. It puts admin into row 1, then copies campsite into row 0. campsite needs 9 bytes: 8 letters plus the NUL. The box holds 6.

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

int main(void) { char rows[2][6]; strcpy(rows[1], "admin"); strcpy(rows[0], "campsite"); printf("rows[0]=%s rows[1]=%s\n", rows[0], rows[1]); return 0; }

prompt: operator@camp:~$ answer: gcc -Wall spill.c -o spill && ./spill output: rows[0]=campsite rows[1]=te hint: Same shape as always: gcc -Wall spill.c -o spill && ./spill

Exit code 0, not one message, and a string the code never touched by name is ruined. strcpy copied all 9 bytes of campsite because the NUL in the source is the only stop mark it has; the destination carries no size for it to respect. Bytes 7, 8, and 9, which are t, e, and the NUL, landed in rows[1] and flattened admin to te. One more twist: rows[0] prints as the full campsite even though its box is 6 bytes. Its own bytes now hold no NUL, so printf walks straight across the boundary and stops at the NUL sitting in rows[1]. This is silent corruption: the bug plants wrong data now, and the symptom surfaces later, somewhere else, with nothing pointing back here.

The safer pattern is to tell the function how much room is left. strncat(dst, src, n) appends at most n characters from src onto the end of dst, then writes the NUL itself. The size math for n is worth memorizing: sizeof buf - strlen(buf) - 1. The whole box, minus the bytes already used, minus one byte reserved for the NUL.

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

int main(void) { char buf[6] = "camp"; strncat(buf, "!!", sizeof buf - strlen(buf) - 1); puts(buf); return 0; }

prompt: operator@camp:~$ answer: gcc -Wall grow.c -o grow && ./grow output: camp! hint: Same shape as before: gcc -Wall grow.c -o grow && ./grow

The box holds 6 bytes. camp uses 4, and one byte must stay reserved for the NUL, so exactly 1 byte of new text fits: 6 - 4 - 1 = 1. strncat copied one bang, wrote the NUL, and stopped. Off-by-one errors live and die in that final - 1. Drop it and the cap becomes 2, strncat appends both bangs, and its NUL lands one byte PAST the end of the box. That is a real overflow, one byte wide, and it can sit silent for months before it corrupts something that matters.

string.h also has strncpy, the size-capped cousin of strcpy. It carries its own trap: if src fills all n bytes, strncpy writes no NUL at all, and the result is not a string. When you use it, set the last byte of the box to zero yourself.

Back to the program from the cold open. Here it is again, with the two lines that matter highlighted.

#include <stdio.h>

int main(void) { char *word = "camp"; word[0] = 'C'; puts(word); return 0; }

Line 4 does not build an array. Remember from the pointers lesson: char *word declares a pointer, one 8-byte variable holding an address. The compiler stores the literal camp somewhere, then puts that somewhere into word.

The question is where somewhere is. On Linux, gcc places string literals in a read-only region of the program, alongside the machine code. Keeping literals read-only lets the system share one copy safely. The price: writing there is forbidden, and the hardware enforces the ban.

The C language itself is blunter. The standard says writing into a string literal is undefined behavior: the language makes no promise at all about what happens. On this machine it reliably segfaults, and that is the kindest possible outcome, because you find the bug immediately. Other compilers or systems could silently corrupt data instead. Never rely on undefined behavior doing the same thing twice.

prompt: operator@camp:~$ answer: gcc -Wall crash.c -o crash && ./crash output: /tmp/gtc.sh: line 100: 1009 Segmentation fault ./crash hint: Compile and run: gcc -Wall crash.c -o crash && ./crash

The shape of the message depends on how the program was launched. This output was captured by a script, so bash added the script name, a line number, and the process id 1009. Run it by hand and your shell prints just Segmentation fault. The kill itself is identical.

prompt: operator@camp:~$ answer: echo $? output: 139 hint: The last exit code lives in the $? variable. Print it: echo $?

word[0] = 'C' told the CPU to write one byte at the literal's address. The hardware saw a write into a read-only region and refused. The kernel then killed the process with signal 11, named SIGSEGV, short for segmentation violation. The shell reports a signal death as 128 plus the signal number: 128 + 11 = 139. Nothing here is random. The program did not almost work. It was stopped at the exact instruction that broke the rule.

{ "kind": "memory", "cells": [ { "addr": "0x7ffd5e189ee8", "bytes": "04 60 55 55 55 55 00 00", "label": "word: a pointer on the stack", "pointer": "0x555555556004" }, { "addr": "0x555555556004", "bytes": "63 61 6d 70 00", "label": "the camp literal: READ-ONLY" } ] }

Two address flavors, both from real captures. The stack address changes every run under ASLR. The 0x5555 form is where the program and its literals load under gdb, which turns randomization off by default, the same convention you saw in the functions lesson.

The fix changes one declaration. Compare line 4 with the crashing version:

#include <stdio.h>

int main(void) { char word[] = "camp"; word[0] = 'C'; puts(word); return 0; }

char word[] instead of char *word. With square brackets, word is an array, and the compiler sizes it from the literal: 5 bytes, four letters plus the NUL. When main starts, those 5 bytes are copied out of the read-only region into the array on the stack: your own writable box.

Same literal, same contents, completely different rule. The pointer version aims your write at shared read-only memory. The array version gives you a private copy you may edit freely, so word[0] = 'C' now succeeds and puts prints the result.

This one difference, * versus [], causes the most common first segfault in C. You will spot it in code reviews for years.

Three failures you will meet in string work. For each: the literal text, what it means, what to check first.

The runtime kill:

Segmentation fault

The kernel killed your process for touching memory it was not allowed to touch. In string code, check first: are you writing through a char * that points at a literal? Then: are you reading or writing past the end of an array?

The exit code that names the signal:

139

That is echo $? after a crash. Any exit code above 128 means a signal ended the program: subtract 128 to name it. 139 - 128 = 11, and signal 11 is SIGSEGV. Check first: run the program again and watch for the Segmentation fault message.

The compile error that never goes away:

broken.c: In function 'main':
broken.c:5:13: error: expected ';' before '}' token

A missing semicolon, the same error you first met in the Hello, machine lesson. String-heavy code is dense with quotes, commas, and parentheses, so it happens more here. Check first: the end of the line ABOVE the one gcc names. gcc points at where it got confused, which is usually one line after the real mistake.

One failure has no fixed message at all. Overwrite a NUL terminator and strlen or printf will walk into neighboring memory, printing whatever bytes they find until a zero shows up by luck. You watched exactly this in spill.c: rows[0] printed the full campsite by walking into the next row. When string output suddenly grows a tail of junk characters, hunt for the lost NUL.

Scaffolding off. No command is shown this time.

You have fixed.c on disk, the array version from two steps ago. Prove the repair in one line: compile it with warnings on into a binary named fixed, run it, and print the exit code, so anyone can see the crash is gone. You know all three pieces: the gcc line this track always uses, the run, and the shell variable that holds the last exit code.

prompt: operator@camp:~$ answer: gcc -Wall fixed.c -o fixed && ./fixed; echo $?|||gcc -Wall fixed.c -o fixed && ./fixed && echo $? output: Camp 0 hint: Chain all three: gcc -Wall fixed.c -o fixed && ./fixed; echo $?

There it is: Camp on the screen and exit code 0. The program performed the exact same write, word[0] = 'C', but into a writable private copy instead of the read-only literal, so it ran to the end and printed the changed word. This pair of runs, the crash at 139 and the fix printing Camp at 0, is precisely how you demonstrate a segfault and its repair to a teammate.

You earned this cheat sheet. Every row is something you just ran or predicted:

The one thing to burn in: every string function trusts the NUL. Your job, always, is to make sure the NUL exists and fits inside the box.

You now hold the facts behind most C string bugs. A string is bytes plus a NUL stop mark. strlen measures the text, sizeof measures the box. Copies need size math or they overflow. Literals are read-only, and writing into one earns a Segmentation fault and exit code 139.

The Lab: CSV Parser at the end of this module is where this becomes real. A VM boots with a raw CSV file waiting. You will write a program that reads it, splits every line into fields, and stores each field as a proper NUL-terminated string in a box you sized yourself. The grader never reads your source. It compiles your program, runs it against real inputs, and checks the outcome, exactly like the module 1 lab did.

Two lessons stand between you and it. File I/O teaches you to read the file, and struct and the heap teaches you to build boxes at run time. Finish those, then go parse.

Practice Strings and stdlib in a real Linux terminal at The Linux Camp. Progress is verified automatically as you type commands on the machine.