Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.
Pointer Addresses in 2D Arrays
See exactly where every row of a two-dimensional array lives in memory, and why arr[i] and *(arr+i) are the same thing. Part of our free C tutorial for embedded C programming.
Understanding pointer addresses in 2D arrays is the step that turns pointers from a memorised trick into a tool you can reason about. In this lecture of our free C programming course you will print the address of every row of a 2D array, compare two ways of reaching the same row, and prove with real output that a row is simply a block of consecutive bytes. If you want to write embedded C programming code for ADC buffers, display frames or lookup tables, this is the mental model you need.
What You Will Learn
- How a 2D array is laid out in memory (row-major order)
- Why a row of a 2D array has its own size and type
- Why arr[i] and *(arr+i) give the same address
- How *(*(arr+i)+j) reaches the element that arr[i][j] names
- How to print byte offsets instead of unreliable raw addresses
- How to pass one row of a 2D array to a function
Prerequisites
You should already be comfortable with one-dimensional arrays, the address-of operator, dereferencing and pointer arithmetic from the earlier lectures in this chapter. A C compiler such as gcc is enough to run every example here, and it is all you need for this free embedded C course.
Memory Layout Behind Pointer Addresses in 2D Arrays
Every one of the pointer addresses in 2D arrays that we print later follows from one simple fact. C has no real “table” in memory. A declaration such as int readings[3][4] is an array of 3 elements, and each of those elements is itself an array of 4 ints. The compiler stores the 3 rows one after another with no gaps. This is called row-major order.
Imagine 3 temperature sensors and 4 samples from each. Row 0 holds sensor 0, row 1 holds sensor 1, and so on. If an int takes 4 bytes, one row takes 16 bytes, so row 1 starts 16 bytes after row 0 and row 2 starts 32 bytes after row 0.
Platform note for embedded targets
The +16 step is only true when sizeof(int) is 4. On a 16-bit microcontroller where int is 2 bytes, one row is 8 bytes. Never hard-code the step; let the compiler compute it. That is exactly what pointer arithmetic does for you.
Pointer Addresses in 2D Arrays: arr[i] vs *(arr+i)
When you use an array name in an expression, it decays into a pointer to its first element. For a 2D array the first element is a whole row, so the name readings becomes a pointer to an array of 4 ints. Its type is int (*)[4].
Adding 1 to a pointer moves it by the size of the thing it points to. Here that thing is a row, so readings + 1 moves 16 bytes forward and lands on the start of row 1. Dereferencing it with *(readings + 1) gives you the row itself, an array of 4 ints, which in turn decays to a pointer to its first int. The subscript form readings[1] is defined by the C language to mean exactly *(readings + 1).
| Expression | Type after decay | What it means |
|---|---|---|
| readings | int (*)[4] | Pointer to row 0 |
| readings + i | int (*)[4] | Pointer to row i (moves i rows) |
| *(readings + i) | int [4], decays to int * | Row i, pointer to its first element |
| readings[i] | int [4], decays to int * | Identical to the line above |
| *(readings + i) + j | int * | Address of element j in row i |
| *(*(readings + i) + j) | int | Value of element j in row i |
Decoding *(*(readings+i)+j) one step at a time
Read the expression from the inside out. Each step either moves a pointer or follows it.
Demo: Printing Pointer Addresses in 2D Arrays
Printing raw addresses is awkward because they change from run to run and must be printed with %p, not %u or %d. A cleaner trick is to cast to const char * and subtract the base address. The result is a byte offset that is the same on every run, so you can compare it with the numbers in the diagram above.
Save this as ep_row_addr.c.
#include <stdio.h>
/* Average of one row of samples. A row decays to a plain int pointer. */
static double avg_row(const int *row, size_t n)
{
long sum = 0;
for (size_t k = 0; k < n; k++)
sum += row[k];
return (double)sum / (double)n;
}
int main(void)
{
/* 3 sensors, 4 samples each */
int readings[3][4] = {
{10, 11, 12, 13},
{20, 21, 22, 23},
{30, 31, 32, 33}
};
const char *base = (const char *)readings;
printf("sizeof(int)=%zu sizeof(one row)=%zu sizeof(whole array)=%zu\n",
sizeof(int), sizeof readings[0], sizeof readings);
for (int i = 0; i < 3; i++) {
printf("sensor %d: readings[%d] at +%td, *(readings+%d) at +%td\n",
i, i, (const char *)readings[i] - base,
i, (const char *)*(readings + i) - base);
printf(" values via [i][j] :");
for (int j = 0; j < 4; j++)
printf(" %d", readings[i][j]);
printf("\n values via *(*(a+i)+j) :");
for (int j = 0; j < 4; j++)
printf(" %d", *(*(readings + i) + j));
printf("\n");
}
for (int i = 0; i < 3; i++)
printf("average of sensor %d = %.2f\n", i, avg_row(readings[i], 4));
return 0;
}
Build and run it with strict warnings turned on. They cost nothing and catch many pointer mistakes early.
gcc -std=c11 -Wall -Wextra -pedantic -o ep_row_addr ep_row_addr.c
./ep_row_addr
Expected output on a machine where int is 4 bytes:
sizeof(int)=4 sizeof(one row)=16 sizeof(whole array)=48
sensor 0: readings[0] at +0, *(readings+0) at +0
values via [i][j] : 10 11 12 13
values via *(*(a+i)+j) : 10 11 12 13
sensor 1: readings[1] at +16, *(readings+1) at +16
values via [i][j] : 20 21 22 23
values via *(*(a+i)+j) : 20 21 22 23
sensor 2: readings[2] at +32, *(readings+2) at +32
values via [i][j] : 30 31 32 33
values via *(*(a+i)+j) : 30 31 32 33
average of sensor 0 = 11.50
average of sensor 1 = 21.50
average of sensor 2 = 31.50
What the output proves
- Same address, two spellings: readings[i] and *(readings+i) print identical offsets, because the compiler treats the first as shorthand for the second.
- Rows are 16 bytes apart: sizeof of one row is 4 ints, and the offsets step by exactly that amount.
- Both element forms agree: the pointer expression and the double subscript read the same memory.
Passing One Row to a Function
Look at avg_row(readings[i], 4) in the demo. The expression readings[i] is a row, which decays to int *, so a function that expects a pointer to int and a count works with any single row. This is the easiest way to reuse your one-dimensional array functions on a 2D array. It is also why the function needs the length as a separate argument: once the array decays, the pointer no longer remembers how long the row is.
Same Address, Different Step Size
Three different expressions point at the very same first byte of the array: readings, readings[0] and &readings[0][0]. They are not interchangeable, because their types differ and so does the step size.
| Pointer | Points to | Adding 1 moves |
|---|---|---|
| readings | a row of 4 ints | 16 bytes |
| readings[0] | one int | 4 bytes |
| &readings[0][0] | one int | 4 bytes |
Rule to remember
The numeric value of a pointer says where. Its type says how far one step goes. Always think about both.
Why Embedded Engineers Care
Two-dimensional arrays show up constantly in firmware. An ADC driver may fill a buffer with several channels and several samples per channel. A small display driver may keep a frame as rows and columns. A calibration table may hold one row of coefficients per sensor. In all of these cases you want to hand a single row to a filter function without copying it, and you want to know that the row is contiguous so that a DMA controller or a memcpy can move it in one go. Understanding pointer addresses in 2D arrays gives you that confidence. It also makes disassembly and debugger memory views much easier to read, because you can predict the addresses before you look.
Common Mistakes With Pointer Addresses in 2D Arrays
| Mistake | Why it fails | Fix |
|---|---|---|
| Printing addresses with %u or %d | Pointers are not unsigned int; this is undefined behaviour on 64-bit systems | Cast to void * and use %p, or print byte offsets |
| Assuming readings+1 moves 4 bytes | It moves one whole row | Check the pointer type, not just the value |
| Writing *(readings + i)[j] | The subscript binds tighter than the star | Use *(*(readings + i) + j) or readings[i][j] |
| Reading past a row with a wrong count | Out-of-bounds access, undefined behaviour | Pass the row length and use named constants |
Best Practices for Pointer Addresses in 2D Arrays
- Use #define constants for the dimensions so that the declaration, the loops and the function calls all agree.
- Prefer the subscript form arr[i][j] in real code. Use the pointer form to understand, debug and explain it.
- Performance: because rows are contiguous, keep the column index in the inner loop. Walking memory in order is friendlier to caches and to DMA-based code.
- Safety: check indices that come from outside the program (a sensor ID in a packet, for instance) before using them to index a row.
- Mark read-only data const so that tables can be placed in flash on microcontrollers.
Summary and Key Takeaways
- A 2D array is an array of arrays stored row after row.
- The array name decays to a pointer to its first row, of type int (*)[COLS].
- arr + i moves i whole rows; arr[i] is just *(arr + i).
- *(*(arr + i) + j) and arr[i][j] name the same element.
- Byte offsets are a stable way to inspect layout; %p is the right format for real addresses.
Remember that pointer addresses in 2D arrays always move in steps of one whole row. With this foundation you are ready for the next lecture, where we store the row pointer in a variable and subscript it directly. Keep following this free C tutorial series, because every later topic builds on these ideas.
Frequently Asked Questions
Are arr[i] and *(arr+i) really identical?
Yes. The C standard defines the subscript expression E1[E2] as *((E1)+(E2)), so arr[i] is by definition *(arr+i).
Why do arr and arr[0] print the same address but behave differently?
They start at the same byte, but arr points to a whole row and arr[0] points to a single int. Adding 1 therefore moves a different number of bytes.
Is a 2D array stored row by row in every C compiler?
Yes. C guarantees row-major order for multi-dimensional arrays, so the last subscript varies fastest in memory.
Can I print an address with %d or %u?
Avoid it. Use %p with a (void *) cast. On 64-bit systems an int is too small to hold a pointer, so %d or %u gives wrong results or undefined behaviour.
How do I pass a single row to a function?
Pass arr[i] and the length. The row decays to a pointer to int, so a function taking (const int *row, size_t n) accepts it.
Does this matter for embedded C programming?
Very much. ADC buffers, display frames and calibration tables are often 2D arrays, and knowing their exact layout helps with DMA, memory maps and debugging.
Keep Learning for Free
This lecture is part of our free C programming course for embedded engineers and students. Continue to the next lecture, or browse the full course index for more free embedded C course material.
Need Help or More Information?
Questions about this free C programming course, embedded C training or career guidance? Get in touch.

2 Comments