An array of pointers in C is an array where every element stores a memory address instead of a value. For example, int *ptr[5]; creates five pointers to int. Each element can point to a different variable, string, array, or function, which makes it ideal for string tables, jagged arrays, menus, and command dispatch tables in embedded firmware.
Picture a small device with a 16×2 LCD and a menu: Start, Settings, Calibrate sensor, About. Store those labels in a 2D char array and every row gets padded to the length of the longest label. Store them in an array of pointers and each label uses only the bytes it needs.
That’s just one reason arrays of pointers show up everywhere in real firmware: error message tables, UART command parsers, state machines, even the interrupt vector table at the start of your flash.
In this guide, you’ll learn:
An array of pointers is a normal array whose elements are all pointers of the same type. Think of the contacts list on your phone: each entry doesn’t contain the person, only the number that reaches them. Each slot in an array of pointers works the same way. It holds an address, and the real data lives somewhere else.
int *, char *, struct sensor *, or a function pointer type)NULL| Platform | Typical Pointer Size |
|---|---|
| 8051 (Keil C51, generic pointer) | 3 bytes |
| 8-bit AVR (Arduino Uno) | 2 bytes |
| ARM Cortex-M | 4 bytes |
| 64-bit PC | 8 bytes |
Always confirm with sizeof(int *) on your own toolchain.
Here’s the picture to keep in your head:
ptr (array of pointers) Targets in memory
+-----------+ +------+
| ptr[0] | ---------------> | 10 | a
+-----------+ +------+
| ptr[1] | ---------------> | 20 | b
+-----------+ +------+
| ptr[2] | ---------------> | 30 | c
+-----------+ +------+data_type *array_name[size];int *ptr[5];ptr[5] means “array of 5”* means “pointers to”intResult: ptr is an array of 5 pointers to int. This works because [] binds tighter than * in a declaration. Hold onto that rule, because it matters again in a moment.
#include
int main(void)
{
int a = 10, b = 20, c = 30;
int *ptr[3] = { &a, &b, &c };
for (int i = 0; i < 3; i++) {
printf("ptr[%d] holds %p and points to %d\n", i, (void *)ptr[i], *ptr[i]);
}
*ptr[1] = 99; /* changes b through the array */
printf("b is now %d\n", b); /* prints 99 */
return 0;
}Output:
ptr[0] holds 0x7ffd5c3a1a24 and points to 10
ptr[1] holds 0x7ffd5c3a1a28 and points to 20
ptr[2] holds 0x7ffd5c3a1a2c and points to 30
b is now 99(Your addresses will differ.)
ptr[i] is the address stored in slot i*ptr[i] is the value at that address ([] is evaluated first, then *)*ptr[i] changes the original variable%p expects a void *, so cast the pointer when printingA string literal like "Timeout" sits in memory as a read-only block of characters, and a const char * can point to it. Put several of those pointers in an array and you get a string table.
#include
int main(void)
{
const char *errors[] = {
"OK",
"Timeout",
"Checksum mismatch",
"Buffer overflow",
"Sensor not found"
};
size_t count = sizeof errors / sizeof errors[0];
for (size_t i = 0; i < count; i++) {
printf("[%u] %s\n", (unsigned)i, errors[i]);
}
return 0;
}Output:
[0] OK
[1] Timeout
[2] Checksum mismatch
[3] Buffer overflow
[4] Sensor not founderrors[status]char *argv[] in main is an array of pointers to strings, ending with a NULL pointerOn a microcontroller, where the table lives matters as much as how it looks. This is what typically happens on a Cortex-M build with file-scope or static declarations:
| Declaration | Placement |
|---|---|
const char *msg[] = {...}; | Text in flash, pointer table in RAM (the pointers are writable) |
const char *const msg[] = {...}; | Text and pointer table both in flash, zero RAM |
char msg[5][18] = {...}; | One 90-byte block in RAM, plus a flash copy used to initialize it |
Exact section names depend on your linker script, so check your .map file to confirm.
Heads-up for Arduino and AVR users: on 8-bit AVR chips, string literals are copied into SRAM at startup by default. To keep text in flash, use PROGMEM (and the F() macro for Serial.print), then read it back with the pgm_read_* helpers instead of normal dereferencing.
In short: write lookup tables as static const and use const char *const for text, so the compiler can keep them in flash.
These two declarations look almost identical, yet they mean very different things:
int *p[5]; /* array of 5 pointers to int */
int (*q)[5]; /* ONE pointer to an array of 5 ints */| Array of pointers | Pointer to an array | |
|---|---|---|
| Declaration | int *p[5]; | int (*p)[5]; |
| Meaning | 5 separate pointers to int | 1 pointer to a block of 5 ints |
| sizeof(p) | 5 × pointer size | 1 × pointer size |
| Element access | *p[i] | (*p)[i] |
| Typical use | String tables, jagged arrays, dispatch tables | Passing rows of a 2D array to functions |
int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} };
int (*row_ptr)[3] = matrix; /* points to the first row (3 ints) */
printf("%d\n", row_ptr[1][2]); /* prints 6 */*name mean the name is a pointer first| Feature | 2D array: char s[5][18] | Array of pointers: const char *s[5] |
|---|---|---|
| Row length | Fixed, padded to the longest | Each string can have its own length |
| Memory layout | One contiguous block | Pointer table plus separate strings |
| Extra cost | Wasted padding bytes | One pointer per element |
| Reorder or sort | Copy whole rows | Swap pointers only |
| Edit the text | Allowed (if not const) | Not allowed for string literals |
| Access cost | Direct | One extra memory read |
Real numbers for the five error messages above (longest string is 17 characters plus the terminator):
"OK" wastes 15 of its 18 bytes)const placement the whole thing can cost zero RAMmemcpy, for example)A jagged array is a table where each row has a different length, with no wasted space:
#include
int main(void)
{
int row0[] = { 1, 2, 3 };
int row1[] = { 4, 5 };
int row2[] = { 6, 7, 8, 9 };
int *jagged[] = { row0, row1, row2 };
const size_t lengths[] = { 3, 2, 4 };
for (size_t i = 0; i < 3; i++) {
for (size_t j = 0; j < lengths[i]; j++) {
printf("%d ", jagged[i][j]);
}
printf("\n");
}
return 0;
}Output:
1 2 3
4 5
6 7 8 9jagged[i][j] uses the same syntax as a 2D arraylengths[] array or a sentinel valuesizeof jagged[i] returns the size of a pointer, not the row, which is a very common bugWhen row sizes are only known at runtime, each slot can own a heap block:
#include
#include
#define ROWS 3
#define COLS 4
int main(void)
{
int *table[ROWS]; /* the array of pointers */
for (int i = 0; i < ROWS; i++) {
table[i] = malloc(COLS * sizeof *table[i]);
if (table[i] == NULL) {
for (int k = 0; k < i; k++) { /* roll back earlier rows */
free(table[k]);
}
return 1;
}
for (int j = 0; j < COLS; j++) {
table[i][j] = i * 10 + j;
}
}
for (int i = 0; i < ROWS; i++) {
for (int j = 0; j < COLS; j++) {
printf("%2d ", table[i][j]);
}
printf("\n");
}
for (int i = 0; i < ROWS; i++) {
free(table[i]);
}
return 0;
}malloc() for NULL, and free what you already allocated if a later call failsint **table = malloc(rows * sizeof *table);int ** is not the same as int arr[2][3]. The memory layouts differ, so a true 2D array can’t be passed where int ** is expectedmalloc() and free(), which is why safety-critical projects such as automotive firmware avoid themEach element holds the address of a function, so you can pick an action by index instead of writing a long if-else chain.
void (*handlers[3])(void);Read it the same way: handlers is an array of 3 pointers to functions that take no arguments and return nothing. A typedef makes it far easier on the eyes:
typedef void (*handler_t)(void);
handler_t handlers[3];#include
#include
typedef void (*handler_t)(void);
static void led_on(void) { printf("LED ON\n"); }
static void led_off(void) { printf("LED OFF\n"); }
static void led_toggle(void) { printf("LED TOGGLE\n"); }
static const handler_t handlers[] = { led_on, led_off, led_toggle };
#define HANDLER_COUNT (sizeof handlers / sizeof handlers[0])
static void dispatch(uint8_t cmd)
{
if (cmd < HANDLER_COUNT) {
handlers[cmd]();
} else {
printf("Invalid command: %u\n", (unsigned)cmd);
}
}
int main(void)
{
dispatch(0); /* LED ON */
dispatch(2); /* LED TOGGLE */
dispatch(9); /* Invalid command: 9 */
return 0;
}On real hardware, replace printf with GPIO writes.
switch chains with a one-line lookupconst and live in flashstate_table[current_state]();[STATE_IDLE] = state_idle (a C99 feature, so older compilers such as Keil C51 may not support it)#include
typedef struct {
const char *name; /* text typed by the user */
handler_t run; /* function to call */
} command_t;
static const command_t commands[] = {
{ "on", led_on },
{ "off", led_off },
{ "toggle", led_toggle }
};
void run_command(const char *input)
{
for (size_t i = 0; i < sizeof commands / sizeof commands[0]; i++) {
if (strcmp(input, commands[i].name) == 0) {
commands[i].run();
return;
}
}
/* unknown command: report an error over UART */
}This is technically an array of structs that contain pointers, but it’s the natural next step once arrays of pointers feel comfortable.
GPIO_TypeDef *const led_ports[] = { GPIOA, GPIOB, GPIOC };
const uint16_t led_pins[] = { GPIO_PIN_5, GPIO_PIN_0, GPIO_PIN_13 };
for (size_t i = 0; i < sizeof led_ports / sizeof led_ports[0]; i++) {
HAL_GPIO_TogglePin(led_ports[i], led_pins[i]);
}Adjust the pins for your board, and keep the two arrays the same length.
errors[status] instead of a switchinit() and read() function pointers (C-style polymorphism)| Mistake | What Goes Wrong | Fix |
|---|---|---|
| Dereferencing an uninitialized element | Writes to a random address: corruption or HardFault | Initialize every slot to a valid address or NULL, and check before use |
| Storing addresses of local variables beyond their scope | Dangling pointers and garbage values | Use static or global storage, or let the caller own the buffer |
Editing a string literal through const char * | Undefined behavior: may fault, or silently do nothing | Copy into a char buf[N] if you need to change text |
Using sizeof on a row accessed through a pointer | Returns pointer size, not row length | Keep a lengths array or pass the length explicitly |
Using sizeof arr inside a function that received the array | The array decayed to a pointer, so the result is wrong | Pass the element count as a parameter |
Calling table[i]() without a bounds check | Jumps to a garbage address | Validate i before the call |
| Not freeing every malloc’d row | Memory leaks and fragmentation | Free each row, then the table, or avoid the heap entirely |
Mixing up int *p[3] and int (*p)[3] | Wrong type, warnings, or silent bugs | Read declarations right to left and use typedefs |
static const so they can live in flashconst char *const for text tablestypedef your function pointer typessizeof arr / sizeof arr[0], and never hard-code themNULL terminator as a sentinel when you don’t want a length array: { "a", "b", NULL }volatile-Wall -Wextra and run static analysis to catch pointer problems early| Goal | Declaration |
|---|---|
Array of 5 int pointers | int *p[5]; |
| Array of 5 string pointers | const char *s[5]; |
| Read-only string table (flash) | static const char *const s[] = {...}; |
Pointer to an array of 5 ints | int (*p)[5]; |
| Array of 4 function pointers | void (*f[4])(void); |
| Array of pointers to structs | struct sensor *list[8]; |
| Pointer to pointer (dynamic table) | int **table; |
int *p[5] is five pointers to int[] binds before *, and parentheses change the meaning completelyconst char *const string tables keep both text and pointers in flashint *p[5] is five pointers to int. int (*p)[5] is a single pointer to a block of five ints. The parentheses decide which one is the main thing.
Each string uses only the space it needs, while a 2D array pads every row to the longest string. With const, the table and text can also stay in flash and save RAM. A 2D array is better only when you need editable, fixed-size buffers.
Use sizeof arr / sizeof arr[0]. This works only in the scope where the array is declared. Once the array is passed to a function it decays to a pointer, so pass the count separately.
Not directly, because all elements share one type. You can use void * elements or a struct with a type tag, but you must cast correctly and track the type yourself (see our guide to void pointers in C).
It builds dispatch tables: command handlers, state machines, menu actions, and callback registries. You pick the action by index instead of a long switch.
Indian Institute of Embedded Systems – IIES