Text data in C usually travels through arrays of char that end in a special byte, the null terminator. That model looks small on the surface but it controls how storage is laid out in memory, how length checks work, and how standard library functions behave. C strings live as contiguous bytes with a trailing '\0' marker that tells functions where text stops, so off by one mistakes and missing terminators can spill into memory that was never meant to hold characters. Careful habits around calls like strcpy, strncpy, and strlen, along with attention to buffer sizes and terminators, keep string handling predictable instead of fragile.
C String Layout In Memory
Strings in C sit in memory as plain bytes with very little structure added by the language itself. The compiler arranges characters in order inside an array, and the runtime library relies on that layout plus a single zero byte to mark where text stops. The result is compact and fast to scan, but it also means every detail about array size, indexes, and the final terminator byte matters.
Arrays Of char In Memory
C treats a string as a sequence of char values stored in consecutive addresses. After the last visible character, the array holds a byte with numeric value zero, written as '\0' in source code. That storage can live on the stack as a local variable, in static storage for globals or string literals, or in a region returned by malloc.
This declaration leads the compiler to create an array of 6 char values. Bytes 0 through 4 hold 'h', 'e', 'l', 'l', 'o', and byte 5 holds '\0'. The name greeting refers to the whole array in this scope, not only a pointer, and the size is fixed at compile time.
This code builds a string byte by byte inside a fixed array. The storage has room for seven visible characters plus the terminator. Any bytes that were present in city before these assignments no longer matter, because string functions will stop as soon as they see the '\0' at index 7.
Local arrays such as these live in automatic storage for the duration of the block where they are declared. When code passes an array expression to a function, the expression usually converts to a pointer to the first element. That conversion affects how function parameters are written like:
The parameter is declared as const char * rather than an array type, even though the argument is an array variable. When header appears in the call, it turns into a pointer value that holds the address of header[0]. The function receives that address and can walk forward in memory until it reaches the null terminator.
Static storage behaves in a similar way, except that the lifetime spans the entire run of the process.
This array sits in a static segment, yet from the perspective of string handling it still presents the same structure. It carries characters followed by a zero byte, and code that passes app_name to library functions hands them a pointer to the first char.
Dynamic allocation with malloc builds on the same idea but uses heap storage, such as:
Here, the function asks for n + 1 bytes, where n is the number of visible characters, and returns a pointer to the first char in that heap block. The layout is no different from the automatic arrays earlier. Characters occupy the first n slots, and the terminator sits at index n. The fact that copy came from malloc only affects how the memory is reclaimed later, not how string functions view it.
Null Terminator As String End
The null terminator is the single marker that tells string functions where to stop reading. It holds numeric value zero and is written with the escape sequence '\0'. It lives in memory like any other byte, yet library routines treat it as the boundary for text.
The array named project contains the characters L, o, g, space, V, and so on, ending with a zero byte added by the compiler. The strlen call starts at project[0] and advances across the bytes until it reaches that terminator. The printed length matches the number of visible characters and does not include the final zero.
If the terminator is missing, code that expects a valid C string loses its stopping point and keeps reading into memory that was meant for other data. That situation leads to undefined behavior, because the language standard does not place limits on what may happen when a function reads past the bounds of an array.
The array id has no '\0' byte, so strlen continues scanning beyond id[3] into whichever bytes follow in memory. Those bytes could include unrelated local variables, saved registers, or anything else that shares the same stack frame. On one run the call may appear to work, and on another run the same binary may crash or print unexpected data.
Writing code that builds strings manually needs careful control over that final byte. Making a small change in logic can remove or overwrite the terminator in ways that are not obvious at first:
In this, the assignment replaces the terminator with an exclamation mark. The array still has length 6, but now it holds no zero byte. Any call that treats label as a C string will walk through all 6 bytes and then continue past the array boundary into surrounding memory.
This example updates a string safely, because the array has space for extra characters. The original literal Idle creates bytes for I, d, l, e, and '\0'. The buffer status has length 7, so indexes 5 and 6 are available for later use. After the strlen call, len is 4, and the code places a dash at index 4 and a terminator at index 5. Library functions that receive status stop at that new terminator and do not read index 6, which still holds '\0' from the original initializer but lies beyond the visible characters.
Null termination also affects how partial copies behave. Any manual loop that copies characters from one array to another has to reserve space for the terminator and write it explicitly, even when the copied text does not fill the entire destination buffer. Without that final write, the destination stops being a valid C string, no matter how careful the character loop looked.
String Functions Safety
Library functions in C that work with strings expect very specific layouts in memory. They read from a char * pointer, walk forward one byte at a time, and stop when they reach a '\0' terminator. That model is compact, but it leaves very little room for mistakes with indexes, buffer sizes, and length checks. Small arithmetic slips around array bounds can turn into writes past the end of storage or unbounded reads into unrelated data.
Most problems fall into three related groups. Some loops walk one element too far and touch memory that does not belong to a string buffer. Some calls to strlen or copy functions rely on arrays that never received a terminator. Other problems come from calling functions such as strcpy and strncpy without tracking the destination size carefully. The rest of this section looks at those three situations and connects them back to how arrays and null terminators work.
Indexing Off By One Errors
In C, arrays use zero based indexing, so a buffer with N elements has valid indexes from 0 up to N - 1. When code bumps a loop boundary from < to <=, or forgets to reserve space for the terminator, the last step of the loop lands just past the buffer and touches unrelated memory. That extra write or read changes data that another variable depends on and can also disturb the saved state the compiler keeps on the stack.
That loop was meant to protect the destination by checking dest_size, yet the condition i <= dest_size allows i to reach dest_size and write to dest[dest_size]. If the buffer was declared as char dest[dest_size]; then the last valid index is dest_size - 1. The loop also does not guarantee room for the terminator, so a long src string can fill the destination and still cause one past the end writes.
This version guards the last slot in the buffer and sets the terminator explicitly. It treats dest_size as the total capacity in bytes, copies at most dest_size - 1 characters, and then writes a '\0' byte. If src is shorter than dest_size - 1, the loop stops when it reaches the existing terminator and the result string uses less than the full buffer. If src is longer, the loop stops when it hits the bound and the terminator is still placed at the end.
Off by one problems are not limited to custom copy loops. They also appear around calls to strlen, because that function reports the count of visible characters without the terminator. Code that treats the length as the final index can end up overwriting the terminator or writing just past it, depending on the buffer size.
For this, the sequence triggers undefined behavior, because code has space for only six bytes. The literal "ALPHA" occupies indexes 0 through 4 and installs a terminator at index 5. After the strlen call, len holds 5, so writing '!' at code[5] overwrites the terminator and writing '\0' at code[6] steps past the array into unrelated memory. The declaration uses a length of 6, so code[5] is the last valid index. Small changes in that arithmetic change which locations stay in bounds and which do not.
A safer habit is to treat the highest index that can hold a character as capacity - 2 and treat capacity - 1 as reserved for '\0'. For a buffer with length 6, indexes 0 through 4 can hold visible characters and index 5 should hold the terminator. Loops and updates that respect those ranges stay inside the array while still leaving room for string growth.
Length Functions With Copies
String length functions such as strlen do not know the array size that holds a string. They start at the pointer you pass in and scan forward until they see a byte with value zero. That rule works only when the array already holds a terminator somewhere inside its bounds. When the terminator is missing, strlen keeps reading into whatever data sits next in memory, and that behavior is not defined by the C standard.
The tag array has three bytes and none of them hold '\0'. The call to strlen moves past index 2 and touches whatever happens to follow tag on the stack or in global storage. On some runs that may land on a zero byte by chance, and on other runs the same binary may run into memory that does not belong to the process. Code that builds a string in a fixed buffer needs to treat the terminator as part of the data, not as an optional extra.
The corrected array adds one more byte and stores the terminator explicitly. strlen now walks through the three visible characters and stops at tag_ok[3], never touching memory past the final index.
Copy functions stack more constraints on top of that behavior, because they read from a source buffer and write into a destination. The classic strcpy function assumes that the destination has enough room for the entire source string plus its terminator. It does not perform bounds checks on its own.
This call reads bytes from src up to and including the terminator and writes them into dst. The literal "Configuration" generates many more than seven visible characters, so strcpy writes past the end of dst and corrupts nearby storage. The C standard library does not stop that write, and the outcome depends on what lies in memory after dst.
The strncpy function adds a size parameter but has its own hazards. Its contract is based on the number of bytes to copy, not on C string length rules. It copies at most n bytes. If it reaches a terminator in src before hitting n, it stops early and fills remaining destination bytes with zero. If src has length greater than or equal to n, it copies exactly n bytes and does not write '\0' at the end.
After this call, buf holds the first five characters of the word and no terminator. Any call that treats buf as a C string reads beyond the array, because strlen and friends never see '\0' inside the buffer.
One common pattern treats n as one less than the destination capacity and then forces the final byte to zero:
This sequence leaves space for the terminator by limiting the copy and then writes '\0' into the last slot. If src2 is shorter than four characters, strncpy copies the shorter string and fills some positions with zero, and the final assignment keeps a terminator at the end. If src2 is long, the function copies only the first four bytes, and the final assignment still places the terminator at index 4.
Some libraries offer strlcpy as an extension. That function always writes a terminator when the destination size parameter is greater than zero and returns the full length of the source string, which helps call sites decide whether truncation occurred. strlcpy is not part of the ISO C standard though, so portable code has to check availability before relying on it or fall back to strncpy combined with explicit terminator writes.
Safer Habits For Library Calls
String handling becomes more predictable when code treats buffer sizes and terminators as first class information instead of side details. The C library leaves most safety checks to the caller. That means the caller needs to choose functions that match the situation, pass correct lengths, and insert terminators at the right positions, rather than leaning on the library to correct mistakes.
One practical habit is to keep length or capacity information near every char * that refers to modifiable storage. Many projects use small structs that carry a pointer and a size field together, so functions cannot forget the size when they receive the pointer. Even when code holds separate variables, passing both buffer and buffer_size into helper routines makes it easier to keep track of bounds. Another habit is to favor copies that accept a destination size and then write the terminator inside the helper. That allows the caller to know that any string that passes through the helper ends in '\0' as long as the buffer size is correct. Functions such as strcpy and strcat stay useful in contexts where the destination arrays are fixed and clearly large enough, but in many real systems those assumptions do not hold.
This helper takes both the destination pointer and its capacity. It writes at most dest_size - 1 characters, always places '\0' inside the buffer, and returns a nonzero value when the entire source string fit. Call sites can check the return value to see whether truncation occurred and decide how to handle that case without risking buffer overruns.
C11 and C17 include an optional group of bounds checked functions such as strcpy_s and strncpy_s in Annex K, guarded by feature macros. Those functions combine copy behavior with additional checks and constraint handlers, but support across compilers and platforms varies. Many code bases still rely on manual helpers like copy_string_bounded, coupled with static analysis and careful reviews, to keep string handling predictable across different systems.
Conclusion
C strings rest on a small set of rules about arrays of char, a terminating zero byte, and functions that scan memory until that byte appears, so mechanics around layout and indexing directly control how text is stored and read. Code that tracks buffer sizes, leaves space for '\0', and treats calls like strcpy, strncpy, and strlen as raw byte operations stays aligned with what the hardware and library actually do. That model ties null terminators, buffer capacities, and off by one errors directly to index arithmetic and memory layout, so string handling in C stays tied to how arrays occupy bytes in memory.


![char greeting[] = "hello"; char greeting[] = "hello";](https://substackcdn.com/image/fetch/$s_!D_9G!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F27e01c4e-26e5-4555-b8b0-3ab47da36a3b_1627x69.png)
![char city[8]; city[0] = 'E'; city[1] = 'a'; city[2] = 'u'; city[3] = ' '; city[4] = 'C'; city[5] = 'l'; city[6] = 'a'; city[7] = '\0'; char city[8]; city[0] = 'E'; city[1] = 'a'; city[2] = 'u'; city[3] = ' '; city[4] = 'C'; city[5] = 'l'; city[6] = 'a'; city[7] = '\0';](https://substackcdn.com/image/fetch/$s_!c7RD!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F1aef9966-bd0b-4a23-b3e5-23aff48754be_1648x561.png)
![void print_label(const char *label); void call_print(void) { char header[] = "Status"; print_label(header); } void print_label(const char *label); void call_print(void) { char header[] = "Status"; print_label(header); }](https://substackcdn.com/image/fetch/$s_!jl-j!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F748685c0-5f12-40d6-af34-031a5c95fde8_1675x340.png)
![static char app_name[] = "Monitor"; static char app_name[] = "Monitor";](https://substackcdn.com/image/fetch/$s_!_n6J!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Ffa7a48ec-2909-4029-83a5-17479a26e3ee_1613x67.png)
![#include <stdlib.h> #include <string.h> char *make_copy(const char *src) { size_t n = strlen(src); char *copy = malloc(n + 1); if (copy != NULL) { for (size_t i = 0; i < n; i++) { copy[i] = src[i]; } copy[n] = '\0'; } return copy; } #include <stdlib.h> #include <string.h> char *make_copy(const char *src) { size_t n = strlen(src); char *copy = malloc(n + 1); if (copy != NULL) { for (size_t i = 0; i < n; i++) { copy[i] = src[i]; } copy[n] = '\0'; } return copy; }](https://substackcdn.com/image/fetch/$s_!FUI8!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F102742e4-ac3c-458b-8f01-a63d675f4779_1691x895.png)
![#include <stdio.h> #include <string.h> void report_project(void) { char project[] = "Log Viewer"; size_t length = strlen(project); printf("Project name: %s\n", project); printf("Visible characters: %zu\n", length); } #include <stdio.h> #include <string.h> void report_project(void) { char project[] = "Log Viewer"; size_t length = strlen(project); printf("Project name: %s\n", project); printf("Visible characters: %zu\n", length); }](https://substackcdn.com/image/fetch/$s_!ELR3!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F9a5a766a-21e5-4a34-988f-a3d4319a11ed_1654x560.png)
![char id[4] = { 'A', '1', '7', 'Z' }; size_t n = strlen(id); /* undefined behavior */ char id[4] = { 'A', '1', '7', 'Z' }; size_t n = strlen(id); /* undefined behavior */](https://substackcdn.com/image/fetch/$s_!_9Hj!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fe351aa2f-a414-48c0-a708-99212505c37f_1684x113.png)
![char label[6] = "Ready"; label[5] = '!'; /* overwrites '\0' */ char label[6] = "Ready"; label[5] = '!'; /* overwrites '\0' */](https://substackcdn.com/image/fetch/$s_!brbJ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fb2896281-bda1-4c96-88aa-fec6421e56fe_1681x111.png)
![char status[7] = "Idle"; size_t len = strlen(status); status[len] = '-'; status[len + 1] = '\0'; char status[7] = "Idle"; size_t len = strlen(status); status[len] = '-'; status[len + 1] = '\0';](https://substackcdn.com/image/fetch/$s_!dCJZ!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F62c833b9-957d-40e7-afc4-389a31e93e59_1685x282.png)
![void copy_bad(char *dest, const char *src, size_t dest_size) { size_t i; for (i = 0; i <= dest_size; i++) { dest[i] = src[i]; if (src[i] == '\0') { break; } } } void copy_bad(char *dest, const char *src, size_t dest_size) { size_t i; for (i = 0; i <= dest_size; i++) { dest[i] = src[i]; if (src[i] == '\0') { break; } } }](https://substackcdn.com/image/fetch/$s_!2E_l!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fa52df962-f0d3-4f54-a5af-ba4962065b7c_1597x420.png)
![void copy_with_limit(char *dest, const char *src, size_t dest_size) { size_t i = 0; if (dest_size == 0) { return; } while (i < dest_size - 1 && src[i] != '\0') { dest[i] = src[i]; i++; } dest[i] = '\0'; } void copy_with_limit(char *dest, const char *src, size_t dest_size) { size_t i = 0; if (dest_size == 0) { return; } while (i < dest_size - 1 && src[i] != '\0') { dest[i] = src[i]; i++; } dest[i] = '\0'; }](https://substackcdn.com/image/fetch/$s_!8SEB!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F7ef2f607-0f0a-4ba6-8c62-ccb5a48959e3_1590x590.png)
![char code[6] = "ALPHA"; size_t len = strlen(code); code[len] = '!'; /* replaces '\0' at index 5 with '!' */ code[len + 1] = '\0'; /* writes '\0' one past the array at index 6 */ char code[6] = "ALPHA"; size_t len = strlen(code); code[len] = '!'; /* replaces '\0' at index 5 with '!' */ code[len + 1] = '\0'; /* writes '\0' one past the array at index 6 */](https://substackcdn.com/image/fetch/$s_!imoS!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F882c6182-d459-429f-b3e5-2a3315be268b_1614x210.png)
![char tag[3] = { 'Q', '1', '0' }; size_t n = strlen(tag); /* undefined behavior */ char tag[3] = { 'Q', '1', '0' }; size_t n = strlen(tag); /* undefined behavior */](https://substackcdn.com/image/fetch/$s_!jMyf!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F93900fab-6f94-4073-8348-5498040f9f4f_1668x111.png)
![char tag_ok[4] = { 'Q', '1', '0', '\0' }; size_t n_ok = strlen(tag_ok); char tag_ok[4] = { 'Q', '1', '0', '\0' }; size_t n_ok = strlen(tag_ok);](https://substackcdn.com/image/fetch/$s_!bBI6!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F11a6cec6-730e-4e64-8816-e851a4c132eb_1686x111.png)
![char src[] = "Configuration"; char dst[8]; strcpy(dst, src); /* undefined behavior when src is longer than dst */ char src[] = "Configuration"; char dst[8]; strcpy(dst, src); /* undefined behavior when src is longer than dst */](https://substackcdn.com/image/fetch/$s_!itwy!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2Fd21d357c-3981-4d4c-baff-c750d110bcbb_1608x167.png)
![char src2[] = "Wisconsin"; char buf[5]; strncpy(buf, src2, sizeof buf); /* buf holds 'W', 'i', 's', 'c', 'o' with no '\0' */ char src2[] = "Wisconsin"; char buf[5]; strncpy(buf, src2, sizeof buf); /* buf holds 'W', 'i', 's', 'c', 'o' with no '\0' */](https://substackcdn.com/image/fetch/$s_!-RKB!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F8ef7b75b-9915-4b2c-8612-8f620f020b3a_1673x144.png)
![char buffer[5]; strncpy(buffer, src2, sizeof buffer - 1); buffer[sizeof buffer - 1] = '\0'; char buffer[5]; strncpy(buffer, src2, sizeof buffer - 1); buffer[sizeof buffer - 1] = '\0';](https://substackcdn.com/image/fetch/$s_!mcAP!,w_1456,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F25c04e6f-9b04-49fc-a91c-0ebaf1302536_1681x224.png)
![int copy_string_bounded(char *dest, size_t dest_size, const char *src) { size_t i; if (dest_size == 0) { return 0; } for (i = 0; i < dest_size - 1 && src[i] != '\0'; i++) { dest[i] = src[i]; } dest[i] = '\0'; return (src[i] == '\0'); } int copy_string_bounded(char *dest, size_t dest_size, const char *src) { size_t i; if (dest_size == 0) { return 0; } for (i = 0; i < dest_size - 1 && src[i] != '\0'; i++) { dest[i] = src[i]; } dest[i] = '\0'; return (src[i] == '\0'); }](https://substackcdn.com/image/fetch/$s_!tc4H!,w_2400,c_limit,f_auto,q_auto:good,fl_progressive:steep/https%3A%2F%2Fsubstack-post-media.s3.amazonaws.com%2Fpublic%2Fimages%2F5dd9dfa5-dea8-409e-b030-5cf5f9735f10_1589x633.png)