Arrays and Strings
An array is a row of values sitting next to each other, and a string is an array of characters with a zero byte on the end. Both are simple. Both are also where firmware goes wrong most often, because C never checks whether you have run off the end. This volume shows exactly where the edge is, and the habits that keep you inside it.
- How an array sits in memory, and why counting starts at zero
- How to walk an array without stepping past its last element
- What the NUL terminator is, and why strlen and sizeof give different answers
- Why strcpy is dangerous, and what strncpy still gets wrong
- How to copy text safely, and how a buffer overflow really happens
6.1 Arrays in memory
An array is a row of values of one type, sitting next to each other in memory. The places are counted from 0, so an array of five has places 0 to 4.
uint16_t readings[5] = { 100u, 200u, 300u, 400u, 500u };
readings[0] = 111u; /* the first */
readings[4] = 555u; /* the last */
readings[0] = 100, readings[4] = 500
the whole array takes 10 bytes
one element takes 2 bytes
so it holds 5 elements
the gap between element 0 and element 1 is 2 bytes
buffer: 1 2 0 0 0 0 0 0
= { 1, 2 } filled the rest with zeros
grid[1][2] = 6, and the whole grid takes 6 bytes
Counting the elements
Never write the size twice. Let the compiler work it out:
#define ARRAY_SIZE(a) (sizeof (a) / sizeof (a)[0])
The whole array is 10 bytes and one element is 2, so ARRAY_SIZE gives 5. Change the initialiser
later and every loop follows automatically.
ARRAY_SIZE only works where the compiler can see the real array. Pass the array to a function and
it becomes a pointer, sizeof gives the size of that pointer, and the macro quietly returns
nonsense. That is why array functions always take a count as a second parameter - Volume 07
explains why the array turns into a pointer at all.
A partly filled initialiser sets everything else to zero. uint8_t buffer[8] = { 1u, 2u }; gives
1, 2 and then six zeros, and = { 0 } is the usual way to clear a whole array at declaration.
uint32_t data[10]; How many bytes does the array take, and what is the last valid index?
Show the answer
Answer: C. Ten elements of four bytes each is 40 bytes. The indexes run from 0 to 9, because counting starts at zero - data[10] is one past the end.
6.2 Looping over arrays safely
C never checks an index. Walking off the end of an array is not an error. It is just a read or a write somewhere it should not be, so the loop has to be right.
for (unsigned i = 0; i < count; i++) { /* not <= */
total += values[i];
}
Starting at 0 and testing i < count runs exactly count times and never touches
values[count]. That one habit removes most off-by-one bugs.
the array holds 6 samples
their total is 3477
their average is 579
first sample above 700 is at index 2
first sample above 5000 is at index -1 (meaning: none)
valid indexes are 0 to 5
samples[6] would be one past the end: reading it is undefined behaviour
Two habits worth copying
Keep the total wider than the values. Six samples of up to 1023 fit easily, but a hundred of
them would overflow a uint16_t total. The function above accumulates into a uint32_t.
Say "not found" clearly. Returning −1 works because the function returns int. Returning 0
would be wrong: 0 is a perfectly good index.
The classic off-by-one:
for (unsigned i = 0; i <= ARRAY_SIZE(samples); i++) { /* <= runs 7 times */
total += samples[i]; /* samples[6] does not exist */
}
Nothing complains. The extra read picks up whatever byte sits after the array - another variable, padding, or nothing at all. If the loop *writes*, it changes that neighbour instead.
Which loop visits every element of uint8_t buf[10] exactly once, and nothing else?
Show the answer
Answer: B. Indexes run 0 to 9. The first goes one too far, the third misses buf[0] and reads buf[10], and the last never ends when i is unsigned, because i >= 0 is always true.
6.3 Strings and the NUL character
C has no string type. A string is a char array with a zero byte on the end, and every string function hunts for that terminator.
name is "GPIO"
strlen(name) = 4 (letters, not counting the terminator)
sizeof name = 8 (the whole box, terminator and spare space)
byte by byte: G P I O \0 \0 \0 \0
'A' takes 1 byte, "A" takes 2 bytes
built by hand: "UART", length 4
Three things follow from that picture, and they explain most string bugs:
- A string of n characters needs n + 1 bytes. The terminator is not optional.
'A'and"A"are different. Single quotes give one character; double quotes give an array of two bytes, the letter and the zero.- Building a string by hand means writing the terminator yourself. Without it, every string function reads on into whatever follows.
Comparing strings with ==:
if (command == "on") { ... } /* compares addresses, not letters */
Use strcmp(command, "on") == 0. The == 0 catches people out too: strcmp returns 0 when the
strings match, and a negative or positive number to say which sorts first.
char msg[16] = "ready"; What do sizeof msg and strlen(msg) give?
Show the answer
Answer: D. sizeof is the size of the box: 16 bytes. strlen counts the letters up to the terminator: 5. The other 10 bytes are still part of the array, just not part of the string.
6.4 String functions and their dangers
The classic string functions do no checking at all. strcpy writes until it meets a terminator, however far away that is. And strncpy, the one people reach for instead, may leave you with no terminator at all.
after strncpy, the 8 bytes are: temperat
there is no terminator in there at all
after adding the terminator: "tempera"
snprintf wrote "temp 23.45 "
it wanted 12 characters, and had room for 11
the text was cut short: yes
strcmp("ok", "ok") = 0
strcmp("ok", "no") = positive
so the test for equality is strcmp(a, b) == 0
What each one really does
| Function | What it does | The catch |
|---|---|---|
strcpy(dst, src) |
copies until the terminator | no limit at all: it will write past the end |
strncpy(dst, src, n) |
copies at most n bytes | if the text is n or longer, no terminator is added |
snprintf(dst, n, ...) |
formats, at most n − 1 characters | always terminates; returns what it *wanted* to write |
strlen(s) |
counts up to the terminator | reads for ever if there is not one |
strcmp(a, b) |
0 when equal | not a yes/no: negative and positive mean order |
memcpy(dst, src, n) |
copies exactly n bytes | knows nothing about terminators; you count |
strncpy(small, "temperature", 8) filled all eight bytes with temperat and stopped. There is no
zero byte anywhere in the array, so printf("%s", small) would read on into the next variable
until it happened to find one. Always follow it with small[sizeof small - 1] = '\0'; - or do not
use strncpy at all.
snprintf is the one to reach for
It formats, it never writes more than you allow, and it always terminates. Its return value is the length it *wanted*, which is how you detect truncation:
int wanted = snprintf(line, sizeof line, "temp %d.%02d C", whole, frac);
if (wanted < 0 || wanted >= (int)sizeof line) {
/* the text was cut short - decide what to do about it */
}
Going deeper: printf on a small chip
Full printf with floats can cost several kilobytes of flash and a lot of stack - often more than
the rest of a small program. Most embedded toolchains offer a cut-down version (a "nano" or
integer-only printf), and many projects write their own two-hundred-byte formatter. Volume 17 shows
what printf over a UART really costs, and the alternatives.
After strncpy(dst, src, sizeof dst) where src is longer than dst, what is wrong with dst?
Show the answer
Answer: B. strncpy copies at most n bytes and adds a terminator only if there is room left. When the text is as long as the buffer, all n bytes are letters, and the next function to read it runs off the end.
6.5 Buffers and buffer overflows
A buffer overflow is writing past the end of an array. On a microcontroller nothing stops it: the extra bytes land in the variable next door, and the program goes wrong somewhere else entirely.
The check that prevents it
static int safe_copy(char *dst, size_t size, const char *src)
{
size_t len = strlen(src);
size_t room = size - 1u; /* leave space for the terminator */
size_t take = (len < room) ? len : room;
memcpy(dst, src, take);
dst[take] = '\0'; /* always terminated */
return len <= room; /* 0 means the text was cut short */
}
"fan" fitted: "fan"
"temperature" did not fit in 8 bytes
it was cut to "tempera", which is 7 characters plus a terminator
without the check, the other 4 bytes would have gone past the end
a char line[16] holds at most 15 characters, plus the terminator
an incoming 16-character message needs a buffer of 17 bytes
it fits in line[16]? no
Read that last pair again. A sixteen-character message does not fit in char line[16], because
the terminator needs a byte of its own. Off-by-one errors in buffer sizing are exactly this.
Three rules that between them prevent most overflows:
- Always pass the size of the destination alongside the pointer.
- Never trust a length that arrived from outside - from a serial port, a sensor, a network.
- Write the terminator yourself after any copy that might have been cut short.
Trusting a length field in an incoming message. A packet that says "payload is 200 bytes" when your buffer is 64 is the oldest attack and the oldest bug in embedded systems. Check the length against the buffer size *before* copying, and drop the message if it is too long.
How many characters of text can char buffer[32] hold?
Show the answer
Answer: C. One byte is needed for the terminator, so at most 31 characters plus the zero byte. Sizing a buffer without allowing for the terminator is one of the commonest off-by-one bugs there is.
What you learned
- An array is one block of memory, counted from 0, with n − 1 as its last index.
- ARRAY_SIZE works out the count, but only where the real array is visible.
- C never checks an index:
i < countis the loop shape that keeps you inside. - A string is a char array ending in a zero byte, so n characters need n + 1 bytes.
- strlen counts to the terminator; sizeof measures the whole array.
- strcpy has no limit, and strncpy may leave no terminator; snprintf always terminates.
- An overflow writes into the next variable, silently, with no error at all.
- Pass the buffer size with the buffer, and check lengths that came from outside.
Key words from this volume
Every word below has a plain-English entry in the glossary.
Practice
Size the buffer
A sensor sends messages of at most 20 characters, and your code must store one as a C string. How big must the buffer be? What if the sender can also send a 20-character message with no terminator of its own?
Show the solution
21 bytes. Twenty characters plus one for the terminator.
If the sender does not send a terminator, you add it yourself after receiving. The buffer still needs 21 bytes: 20 for the text, and one for the zero you write. The receiving code should also refuse anything longer than 20, rather than trusting the sender:
char msg[21];
uint8_t len = read_bytes(msg, 20u); /* never more than 20 */
msg[len] = '\0'; /* the terminator is ours to add */
Fix the loop
What is wrong here, and what does it do?
uint8_t data[4] = { 10u, 20u, 30u, 40u };
uint16_t total = 0u;
for (unsigned i = 1; i <= 4; i++) {
total += data[i];
}
Show the solution
Two bugs in one loop. It starts at 1, so it never adds data[0], and it ends at 4, so it
reads data[4], which is one past the end of the array.
The total is therefore 20 + 30 + 40 plus whatever byte happens to follow the array in memory. It may look right in testing and change the day a variable is added nearby.
for (unsigned i = 0; i < 4u; i++) {
total += data[i];
}
Safe copy
Write a function that copies a name into a char dst[16], never overflowing, and returns 1 if the
whole name fitted.
Show the solution
/* by hand */
int copy_name(char *dst, size_t size, const char *src)
{
size_t len = strlen(src);
if (len + 1u > size) {
len = size - 1u; /* cut it short */
}
memcpy(dst, src, len);
dst[len] = '\0';
return strlen(src) + 1u <= size;
}
/* or simply let snprintf do it */
int fitted = snprintf(dst, size, "%s", src) < (int)size;
The snprintf version is shorter and always terminates, which is why most projects settle on it.
Either way, the size of the destination travels with the pointer - a function that takes only
char *dst cannot be written safely.
Read the memory
char buf[6] = "hi"; What are the six bytes, and what does strlen(buf) give? What happens if
you then write buf[2] = '!';?
Show the solution
The six bytes are 'h', 'i', '\0', '\0', '\0', '\0': the initialiser fills the rest
with zeros. strlen(buf) is 2.
Writing buf[2] = '!'; overwrites the terminator. The string is now h, i, ! followed by
zeros, so strlen gives 3 and printing it shows hi!. It still works only because the
remaining bytes happened to be zero - the fourth byte is now acting as the terminator. In a buffer
that was not zero-filled, the string would run on into whatever was there.
Interview corner
strlen versus sizeof
"For char name[32] = "temp"; what do sizeof(name) and strlen(name) return, and why?"
Show the solution
"sizeof is 32 and strlen is 4. sizeof is the size of the array in bytes, worked out at compile time. It counts the terminator and all the unused space too. strlen walks the memory at run time counting characters until it meets the zero byte. The difference matters when sizing buffers: a 32-byte array holds at most 31 characters plus the terminator."
Make this copy safe
"You see strcpy(dst, src) in a code review, where src comes from a serial port. What do you say?"
Show the solution
"That it is a buffer overflow waiting to happen. strcpy writes until it finds a terminator. A message from outside can be any length, or have no terminator at all. I would replace it with a bounded copy that always terminates - snprintf with the destination size, or a memcpy of a checked length followed by writing the terminator myself. I would also check the incoming length against the buffer size before copying, and reject anything too long rather than silently truncating, so the problem is reported rather than hidden."
Next, Volume 07 is the one everyone worries about, and the one that makes the rest of C make sense: pointers.