Volume 06 Beginner 5 sub-modules ~15 min read

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.

You will learn
  • 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
You need

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
Five 16-bit readings laid out in memory, with their indexes and addresses uint16_t readings[5]; 100 200 300 400 500 [0] [1] [2] [3] [4] 200 202 204 206 208 addresses, two bytes apart because each element is two bytes [5] ? [5] is not yours: reading or writing there is undefined behaviour
Figure 6.1 - An array is one block of memory. Each element takes the same number of bytes, and they follow each other with no gaps, so element n starts exactly n elements after the first. The index is not an address - it is how many elements along you are.

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.

Common mistake

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.

Remember

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.

Quick check

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.

Common mistake

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.

Quick check

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.

The string GPIO stored in an eight-byte char array char name[8] = "GPIO"; G P I O \0 ? ? ? [0] [1] [2] [3] [4] [5] [6] [7] strlen(name) = 4 sizeof name = 8 the terminator
Figure 6.2 - A string in memory. Four letters, then the zero byte that ends it. The last three bytes are still part of the array, but not part of the string. strlen counts up to the terminator and stops, while sizeof measures the whole box.

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:

Common mistake

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.

Quick check

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
The strncpy trap, in one line

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.

Quick check

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.

Twelve bytes written into an eight-byte buffer, and what they land on char label[8]; uint16_t count; uint8_t flag; label[8] - eight bytes, and that is all count flag t e m p e r a t u r e \0 the eight bytes you asked for four bytes that were never yours strcpy(label, "temperature"); /* 12 bytes into an 8-byte buffer */ No error, no warning at run time. count and flag now hold letters.
Figure 6.3 - What an overflow actually does. The buffer holds eight bytes, so the last four of a twelve-byte copy land in whatever the linker put next - here a counter and a flag. Nothing reports an error. The program carries on with a counter it never changed.

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.

Remember

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.
Common mistake

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.

Quick check

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

Key words from this volume

Every word below has a plain-English entry in the glossary.

Practice

Practice 1

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 */
Practice 2

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];
}
Practice 3

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.

Practice 4

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

Interview question 1

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."

Interview question 2

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.