Unicorn with delicious cookie
Nous utilisons des cookies pour améliorer votre expérience de navigation. En savoir plus
Accepter
to the top
>
>
>
Safe array handling? Never heard of it

Safe array handling? Never heard of it

28 Mar 2025

Sooner or later, any developer working with C-like languages gets the idea of treating a two-dimensional array as a one-dimensional one. The reasons vary, but the result is usually the same. In this short post, we'll talk about this dubious approach and explore the potential problems it could bring to your program.

What's this about?

Developers have already discussed array indexing many times, but today, we dive deeper. While analyzing some open-source projects using PVS-Studio, we found code fragments that freely use a multidimensional array as a one-dimensional one. As we tackled this topic, many people had the question, "It still works! Why are they wrong?"

Want an example?

Let's look at a small code fragment:

#define ROWS (2)
#define COLS (4)

int main()
{
  int a[ROWS][COLS] = { 0, 1, 2, 3, 4, 5, 6, 7 };
  for (int i = 0; i < ROWS * COLS; ++i)
  {
    printf(" %d", a[0][i]);
  }
  
  return 0;
}

Here, the two-dimensional a array is used as a one-dimensional array: within the loop, we access the elements via a[0][i], where i varies in the range [0; ROWS * COLS]. But what's wrong with that? After all, according to the standard, all array elements are stored sequentially in memory:

An array type describes a contiguously allocated nonempty set of objects with a particular member object type, called the element type. The element type shall be complete whenever the array type is specified.

In the example, developers access elements sequentially by implicit pointer arithmetic, starting with the very first element. To get a clearer picture, let's look at the standards of C and C++ languages and see what we can expect when writing such code:

6.5.3.2/2:

A postfix expression followed by an expression in square brackets [] is a subscripted designation of an element of an array object. The definition of the subscript operator [] is that E1[E2] is identical to (*((E1) + (E2))). Because of the conversion rules that apply to the binary + operator, if E1 is an array object (equivalently, a pointer to the initial element of an array object) and E2 is an integer,

E1[E2] designates the E2-th element of E1 (counting from zero).

6.5.7/9:

When an expression that has integer type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the pointer operand points to an element of an array object, and the array is large enough, the result points to an element offset from the original element such that the difference of the subscripts of the resulting and original array elements equals the integer expression. In other words, if the expression P points to the i-th element of an array object, the expressions

(P) + N (equivalently, N + (P)) and (P) - N (where N has the value n) point to, respectively, the i + n-th and i - n-th elements of the array object, provided they exist. Moreover, if the expression P points to the last element of an array object, the expression (P) + 1 points one past the last element of the array object, and if the expression Q points one past the last element of an array object, the expression (Q) - 1 points to the last element of the array object. If the pointer operand and the result do not point to elements of the same array object or one past the last element of the array object, the behavior is undefined. If the addition or subtraction produces an overflow, the behavior is undefined. If the result points one past the last element of the array object, it shall not be used as the operand of a unary * operator that is evaluated.

7.6.1.2/1:

A subscript expression is a postfix expression followed by square brackets containing a possibly empty, comma-separated list of initializer-clauses that constitute the arguments to the subscript operator. The postfix-expression and the initialization of the object parameter of any applicable subscript operator function is sequenced before each expression in the expression-list and also before any default argument. The initialization of a non-object parameter of a subscript operator function S, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other non-object parameter of S.

7.6.1.2/2:

With the built-in subscript operator, an expression-list shall be present, consisting of a single assignment-expression. One of the expressions shall be a glvalue of type "array of T" or a prvalue of type "pointer to T" and the other shall be a prvalue of unscoped enumeration or integral type. The result is of type "T". The type "T" shall be a completely-defined object type. The expression E1[E2] is identical (by definition) to *((E1)+(E2)), except that in the case of an array operand, the result is an lvalue if that operand is an lvalue and an xvalue otherwise.

7.6.6/4:

When an expression J that has integral type is added to or subtracted from an expression P of pointer type, the result has the type of P.

  • If P evaluates to a null pointer value and J evaluates to 0, the result is a null pointer value.
  • Otherwise, if P points to an array element i of an array object x with n elements, the expressions P + J and J + P (where J has the value j) point to the (possibly-hypothetical) array element i + j of x if 0 ≤ i + j ≤ n and the expression P - J points to the (possibly-hypothetical) array element i - j of x if 0 ≤ i − j ≤ n.
  • Otherwise, the behavior is undefined.

Briefly, both standards tell us that pointer arithmetic lets us iterate over the elements of an array, but behavior is undefined in case of array overrun.

Let's return to the example with the two-dimensional a[2][4] array. The result of the first subscripting operation, a[0], will be a pointer to int[4], which means that access is limited to the [0...3] range. Otherwise, developers catch an array overrun with undefined behavior. Problem found.

What could happen?

Just reading this article makes the answer clear.

But who are we to just believe "some" article? Let's arm ourselves with Compiler Explorer, use the GCC 14.2 compiler as an example, and dive into what really happens under the hood.

First, let's check the assembly code when indexing is correct—you can also find the source code at the link.

.LC2:
        .string " %d"
main:
; Function prologue
        push    r12
        push    rbp
        push    rbx
; Reserving memory on the stack for an int[2][4] array
        sub     rsp, 32
; Preparing to initialize a[0] with four elements via xmm0
        movdqa  xmm0, XMMWORD PTR .LC0[rip]
; Saving the stack pointer to rbx
        mov     rbx, rsp
; Saving one-past-the-end address of a[0] in r12
        lea     r12, [rsp+16]
; Initializing a[0] with four elements via xmm0
        movaps  XMMWORD PTR [rsp], xmm0
; Preparing to initialize arr[1] with four elements via xmm0
        movdqa  xmm0, XMMWORD PTR .LC1[rip]
; Saving the stack top to rbp
        mov     rbp, rbx
; Initializing a[1] with four elements via xmm0
        movaps  XMMWORD PTR [rsp+16], xmm0
.L2:
; Printing the next element of a[0], starting from the stack top
        mov     esi, DWORD PTR [rbp+0]
        mov     edi, OFFSET FLAT:.LC2
        xor     eax, eax
        add     rbp, 4
        call    printf
; Make a loop until the end of a[0] (r12)
        cmp     r12, rbp
        jne     .L2
.L3:
; Printing the next element of a[1]
        mov     esi, DWORD PTR [rbx+16]
        mov     edi, OFFSET FLAT:.LC2
        xor     eax, eax
        add     rbx, 4
        call    printf
; Make a loop until the end of a[0] (r12)
        cmp     r12, rbx
        jne     .L3
; Function epilogue
        add     rsp, 32
        xor     eax, eax
        pop     rbx
        pop     rbp
        pop     r12
        ret
; Initializers of two-dimensional array
.LC0:
        .long   0
        .long   1
        .long   2
        .long   3
.LC1:
        .long   4
        .long   5
        .long   6
        .long   7

If you believe that the array will be stored linearly in memory, for the x86-64 architecture this is indeed the case: the entire two-dimensional array gets allocated as one contiguous block on the stack, and the compiler actively uses it.

It optimizes the nested loop to two consecutive linear loops. The first iterates over the a[0] array and outputs every element up to the end of that array. The second loop also iterates over a[0], but outputs each element from the a[1] array using a 16-byte offset, i.e., the size of the a[0] array of four elements.

Here's what the output looks like:

ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 0
 0 1 2 3 4 5 6 7

Since the compiler generates code where the multidimensional array lays out sequentially, you might be wondering:

Let's rewrite the code for one loop and check this statement.

.LC1:
        .string " %d"
main:
        push    rbx
; Reserving stack memory for an int[2][4] array
        sub     rsp, 32
; Preparing to initialize a[0] with four elements via xmm0
        movdqa  xmm0, XMMWORD PTR .LC0[rip]
; Saving the stack top to rbx
        mov     rbx, rsp
; Preparing to initialize a[0] with four elements via xmm0
        movaps  XMMWORD PTR [rsp], xmm0
.L2: 
; Printing the next array element
        mov     esi, DWORD PTR [rbx]
        mov     edi, OFFSET FLAT:.LC1
        xor     eax, eax
        add     rbx, 4
        call    printf
; Evaluating the end array address
        lea     rax, [rsp+32]
; Looping until the end of the array
        cmp     rbx, rax
        jne     .L2
        add     rsp, 32
        xor     eax, eax
        pop     rbx
        ret
.LC0:
        .long   0
        .long   1
        .long   2
        .long   3

You can see the difference in assembly code here.

In this case, the compiler exploited undefined behavior and simply didn't initialize the a[1] array. Why waste time on this when outputting elements causes array overrun and the compiler is free to do whatever it wants.

Accordingly, the program output depends on pure luck, the Moon's phases, and your coffee fortune telling skills. That's what we got the first time:

ASM generation compiler returned: 0
Execution build compiler returned: 0
Program returned: 0
 0 1 2 3 -994840135 32766 100 0

An interesting twist—the GCC compiler actually issued a warning here:

<source>:11:5: warning: iteration 4 invokes
                        undefined behavior [-Waggressive-loop-optimizations]
   11 |     printf(" %d", a[0][i]);
      |     ^~~~~~~~~~~~~~~~~~~~~~
<source>:9:21: note: within this loop
    9 |   for (int i = 0; i < ROWS * COLS; ++i)
      |                     ^

PVS-Studio analyzer also issues a warning for the code above:

V557 Array overrun is possible. The value of 'i' index could reach 7.

How can you fix it?

The answer is simple: write loops correctly and don't rely on UB :)

But what if we need the array to be laid out sequentially in memory while still allowing both one-dimensional and multidimensional access? For C++, there's a solution: C++23 introduces std::mdspan, which lets you index a linear array in any dimensionality you need.

Let's look more closely at an example:

int main()
{
  int a[ROWS * COLS] = { 0, 1, 2, 3, 4, 5, 6, 7 }; 

  auto view_2d = std::mdspan { a, ROWS, COLS };
  for (auto i = 0uz; i < ROWS; ++i)
  {
    for (auto j = 0uz; j < COLS; ++j)
    {
      printf(" %d", view_2d[i, j]);
    }
  }
}

A one-dimensional array is created here, but we can use it as a two-dimensional array when needed. In this case, the std::mdspan object doesn't make any hidden copies of the original array, since it's a non-owning wrapper.

Note: As of this writing, std::mdspan is implemented in Clang 18 (libc++). To check support in other compilers, visit the C++ compiler support page and just search for "std::mdspan: a non-owning multidimensional array reference."

Conclusion

Our team hopes that this short post helped clarify why treating a two-dimensional array as a one-dimensional one is risky and leads to undefined behavior.

We've encountered these issues before and covered them in previous articles. However, to simplify finding dangerous code fragments in your project, you can try PVS-Studio and keep your projects clean, reliable, and error-free.

Voir tous les articles

Poll:

Do you use PVS-Studio?

Subscribe
and get the e-book
for free!

book terrible tips
Popular related articles

S'abonner

Comments (0)

close comment form
close form

Remplissez le formulaire ci‑dessous en 2 étapes simples :

Vos coordonnées :

Étape 1
Félicitations ! Voici votre code promo !

Type de licence souhaité :

Étape 2
Team license
Enterprise licence
** En cliquant sur ce bouton, vous déclarez accepter notre politique de confidentialité
close form
Demandez des tarifs
Nouvelle licence
Renouvellement de licence
--Sélectionnez la devise--
USD
EUR
* En cliquant sur ce bouton, vous déclarez accepter notre politique de confidentialité

close form
La licence PVS‑Studio gratuit pour les spécialistes Microsoft MVP
close form
Pour obtenir la licence de votre projet open source, s’il vous plait rempliez ce formulaire
* En cliquant sur ce bouton, vous déclarez accepter notre politique de confidentialité

close form
I want to join the test
* En cliquant sur ce bouton, vous déclarez accepter notre politique de confidentialité

close form
check circle
Votre message a été envoyé.

Nous vous répondrons à


Si l'e-mail n'apparaît pas dans votre boîte de réception, recherchez-le dans l'un des dossiers suivants:

  • Promotion
  • Notifications
  • Spam