At the end of Episode 4, this code appeared:

*(uint32_t*)0x40020018 = (1UL << 5);   // write directly to BSRR

“It uses a pointer,” we said — but honestly, does it still feel scary? This episode fully decodes its true nature.

Pointer = typed address. That’s all.

With this “typed address” perspective, GPIOA->BSRR and *(uint32_t*)0x40020018 are doing exactly the same thing. Once you understand pointers, you can instantly read what CMSIS headers are doing.


📖 Previous Article

#4: The World of Bits — Register Operations and the BSRR Design

📍 Series Index

Full 13-Part Series: The Embedded World Beyond Pointers


✅ What You'll Be Able to Do After This Article

  • Explain uint32_t* ptr, &x, and *ptr in your own words
  • Instantly distinguish the * in a declaration from the * as an operator
  • Decompose (uint32_t*)0x40020018 from the inside out and read it
  • Explain what GPIOA->BSRR is doing internally
  • Open a CMSIS header and read GPIO_TypeDef yourself
  • Write and run LED-blink code that directly manipulates GPIO with raw pointers

Table of Contents

  1. Three Reasons Pointers Trip People Up
  2. What Is a Pointer?
  3. Declaration, Referencing, and Dereferencing
  4. Type and Pointer Arithmetic
  5. What Casts Mean
  6. Memory-Mapped I/O — Touching Hardware with Pointers
  7. What CMSIS Does — Decoding GPIO_TypeDef
  8. Practice: Directly Manipulate GPIO with Pointers
  9. Pointer Applications — Passing Pointers as Function Arguments
  10. Frequently Asked Questions (FAQ)
  11. Episode 5 Summary

Three Reasons Pointers Trip People Up

Before the main content, let’s clarify “why do pointers feel hard?” Knowing the causes means fewer stumbling blocks as you read.

Reason 1: * has two faces

uint32_t* ptr;   // * in declaration: marks "this is a pointer variable"
*ptr = 100;      // * as operator: "read/write the thing ptr points to"

The same symbol has completely different meanings. This duality is the biggest source of confusion. We’ll handle this with extra care.

Reason 2: “Addresses” feel abstract

0x20000000 doesn’t mean anything at a glance — and that’s natural. But swap in “street address” and it becomes concrete. A pointer is a variable that remembers “where to find the data.”

Reason 3: Bugs “break silently”

Pointer misuse doesn’t cause compilation errors — it causes unpredictable behavior at runtime. Not knowing why it breaks keeps it scary. Episode 6 covers “how things break” in depth. For now, this episode is about fully understanding correct usage.


1. What Is a Pointer?

1-1. Variable Review — A Variable Is a “Named Box”

Start with variables.

uint32_t x = 42;

This code “creates a 32-bit-wide box named x and puts 42 inside it.”

From the CPU’s perspective:

Memory (RAM)
Address    Contents
0x20000000: [ 42 ]  ← x lives here
0x20000004: [  0 ]
0x20000008: [  0 ]
...

Variable x is just a 32-bit region allocated somewhere in RAM (for example, 0x20000000). The name “x” is a label managed by the compiler — to the CPU, it’s just an address.

1-2. Why Are Pointers Needed?

“If variables have names, why do we need pointers?” Three situations where pointers are necessary:

Situation 1: Modify two or more values simultaneously

“Just use return” you might think — but return can only return one value.

For example, “get both minimum and maximum from ADC readings” with one function:

// return can only give back one value
uint32_t get_min(uint32_t* buf, int len) { ... }  // just min
uint32_t get_max(uint32_t* buf, int len) { ... }  // just max
// have to call twice

// With pointers, one call can modify both
void get_min_max(uint32_t* buf, int len, uint32_t* out_min, uint32_t* out_max) {
    *out_min = buf[0];
    *out_max = buf[0];
    for (int i = 1; i < len; i++) {
        if (buf[i] < *out_min) *out_min = buf[i];
        if (buf[i] > *out_max) *out_max = buf[i];
    }
}

// Caller side
uint32_t sensor_buf[4] = {30, 5, 80, 12};
uint32_t min_val, max_val;

get_min_max(sensor_buf, 4, &min_val, &max_val);

// After the function returns
// min_val → 5   (modified via pointer)
// max_val → 80  (modified via pointer)

In embedded systems, reading “temperature and humidity in one call” or “returning both error code and result simultaneously” are common. This is why many HAL function arguments are uint32_t*.

Situation 2: Pass large data to a function efficiently

Passing an array of 100 sensor readings by value creates 100 copies. Pointer passing sends just the address (4 bytes). With limited memory in embedded systems, this matters.

// ❌ Struct by value: 400 bytes (100 items) copied onto the stack
typedef struct { uint32_t data[100]; } SensorBuf;

void process_copy(SensorBuf buf) {
    // buf is a copy of the original. Consumes 400 bytes of stack.
}

// ✅ Pointer passing: only 4 bytes (the address) are passed
void process_ptr(uint32_t* buf, int len) {
    // buf is the start address of the original array. No copy at all.
}

SensorBuf sensor_data;
uint32_t  raw_data[100];

process_copy(sensor_data);        // 400 bytes of stack consumed
process_ptr(raw_data, 100);       // only 4 bytes (the address)

Note: C can’t pass raw arrays by value Even writing void f(uint32_t buf[100]), C doesn’t copy the array — it becomes the same as void f(uint32_t* buf) internally. Wrap in a struct or use pointer passing.

The STM32F401 has only 96 KB of RAM. Copying large structs every function call would quickly exhaust the stack.

Situation 3: Manipulate hardware registers (the embedded main point)

// "Write to address 0x40020018" cannot be written without pointers
*(volatile uint32_t*)0x40020018 = (1UL << 5);

Hardware registers have fixed addresses. Writing to them directly requires “a pointer pointing to that address.” This is the fundamental reason pointers are studied in this series.

1-3. A Pointer Is a “Variable That Carries an Address”

So what is a pointer variable?

Pointer variable = a variable that stores an address

If an ordinary variable is “a box that holds a value,” a pointer variable is “a box that holds an address.”

⚠️ 画像が見つかりません: /posts/stm32-episode05/pointer_concept.jpg

Think of a delivery label:

Ordinary variable Pointer variable
The box itself (apples inside) A label saying “the goods are at this address”
Open the box to get contents Go to that address to get the contents
Memory (RAM)
Address         Contents                   Description
0x20000000: [    42    ]  ← x itself (the apple)
0x20000004: [0x20000000]  ← ptr ("go to 0x20000000" — the label)

Pointer variable ptr lives somewhere in memory too, but its contents are “an address to another location.”

Critical point: ptr and *ptr are different things

ptr  = 0x20000000    ← the "address" written on the label
*ptr = 42            ← the "contents" found at that address

ptr and *ptr are completely different values. When using pointers, always be conscious: “am I talking about an address, or about the contents at that address?”

1-4. The Essence: “Typed Address”

Here’s the core.

A pointer isn’t just a number (address). It’s an address with type information attached.

uint32_t* ptr;   // "pointer to a uint32_t — points to where a uint32_t lives"
uint8_t*  ptr8;  // "pointer to a uint8_t  — points to where a uint8_t lives"

Why is the type necessary? Two reasons:

Reason Explanation
Determines read/write size uint32_t* accesses 4 bytes, uint8_t* accesses 1 byte
Determines pointer arithmetic step size ptr + 1 advances the address by the size of the type

An address alone doesn’t tell you “how many bytes to read.” The type is what determines “how big a chunk to read/write.”

Reading address 0x20000000 as uint32_t → read 4 bytes
Reading address 0x20000000 as uint8_t  → read 1 byte

“Address + type” is the true nature of a pointer.


2. Declaration, Referencing, and Dereferencing

2-1. Declaration

uint32_t* ptr;
//  ↑         ↑
// type info  * indicating "this is a pointer"

This declares a variable ptr that “holds the address where a uint32_t is stored.”

⚠️ The * position problem: this is the source of confusion

These three are all the same:

uint32_t* ptr;   // * attached to type (this series' style)
uint32_t *ptr;   // * attached to variable name (older C style)
uint32_t * ptr;  // * in the middle

The compiler generates identical code for all three. Since different people use different styles, reading others’ code often leads to “wait, which is this?”

Multiple declarations are tricky:

uint32_t* a, b;   // a is a pointer, b is an ordinary uint32_t (!)
uint32_t *a, *b;  // both a and b are pointers

The uint32_t* style makes it look like “a and b are both uint32_t* type,” but * only applies to variable a. Many people have been caught by this.

This series uses uint32_t* ptr consistently (one variable per declaration, so no practical issue).


🔑 Column: Understanding *’s Two Faces

The biggest confusion with pointers. * has completely different meanings depending on context.

* in a declaration (part of the type)

uint32_t* ptr;   // part of the type declaration: "ptr is a pointer variable"

This isn’t an operation — it’s a notation in the declaration meaning “this variable stores an address.” Read uint32_t* as “uint32_t pointer type.”

* in an expression (dereference operator)

*ptr = 100;      // operator: "go to the address ptr holds and read/write there"

Putting * in front of an existing pointer variable ptr means “go to the address held by ptr and read/write there.”

How to distinguish:

Location Meaning How to tell
Right of type, left of variable name (declaration) “This is a pointer type” mark Appears to the left of =
Inside an expression (assignment, calculation) “Operate on what’s pointed to” operator Appears in front of an existing variable name

Same character, different meanings — a quirk of C’s design. With practice, you distinguish them instantly.


2-2. Getting an Address with & (Address Operator)

Use & (ampersand) to get the address of variable x:

uint32_t  x   = 42;
uint32_t* ptr = &x;  // assign x's address to ptr

&x returns “the memory address where x lives.”

What’s happening, line by line:

① uint32_t x = 42;

   The compiler allocates a 4-byte region for x somewhere in RAM
   Example: allocates at 0x20000200 and writes 42 there

   RAM
   0x20000200: [ 42 ]  ← x's actual location

② &x

   "Tell me the address where x lives"
   → returns the number 0x20000200

③ uint32_t* ptr = &x;

   Assign that address (0x20000200) to variable ptr

   RAM
   0x20000200: [     42     ]  ← x
   0x20000204: [ 0x20000200 ]  ← ptr (remembers x's address)

How to read & Read &x as “address of x” — not “ampersand x” but “address of x” said aloud helps the meaning sink in.

About the actual address value 0x20000200 is just an example. The actual address changes each build (depending on where other variables are placed). “The concrete value isn’t known until runtime” is fine — the debugger shows ptr’s value for confirmation.

What do you do with the obtained address?

After ptr = &x, what you actually want is to “pass that address to another function”:

uint32_t x = 42;
uint32_t* ptr = &x;

// Use ①: modify x via ptr (dereference)
*ptr = 100;
// → x becomes 100

// Use ②: pass ptr to a function so the function can modify x
void set_value(uint32_t* p) {
    *p = 999;  // modify the value at the received address
}
set_value(&x);   // pass x's address
// → x becomes 999

// Use ③: pass to HAL functions (typical embedded usage)
uint32_t adc_result;
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 100);
adc_result = HAL_ADC_GetValue(&hadc1);
// ↑ &hadc1 passes "the address of hadc1"
//   the HAL function reads/writes hadc1's internals through it

&x is almost never used standalone — it’s either “passed to someone” or “assigned to a pointer variable.”

& can only be used on variables, not literals or expressions:

uint32_t x = 42;

uint32_t* p1 = &x;    // OK: get address of a variable
uint32_t* p2 = &42;   // Error: 42 is not stored anywhere
uint32_t* p3 = &(x+1);// Error: computation result has no address

2-3. Dereferencing with *

To read or write the value that a pointer points to, use * (asterisk). This is called dereferencing.

uint32_t  x   = 42;
uint32_t* ptr = &x;

uint32_t val = *ptr;  // read the value at ptr's target (x) → 42
*ptr = 100;           // write 100 to ptr's target → x becomes 100

*ptr means “go to the address held by ptr and read/write there.”

ptr = 0x20000000

What *ptr means:
  → go to address 0x20000000
  → read/write using uint32_t size (4 bytes)

Stepping through:

① After: uint32_t x = 42;

  Memory
  0x20000000: [ 42 ]   ← x

② After: uint32_t* ptr = &x;

  Memory
  0x20000000: [   42   ]  ← x
  0x20000004: [0x20000000] ← ptr (holds x's address)

③ After: *ptr = 100;

  "Go to the address ptr holds (0x20000000) and write 100 there"

  Memory
  0x20000000: [  100  ]   ← x changed to 100!
  0x20000004: [0x20000000] ← ptr itself unchanged

  → Since x and *ptr refer to the same location, x's value is now 100

🔩 Column: What the CPU Does When *ptr = 100

Writing *ptr = 100; in C becomes the CPU instruction STR (Store Register):

C language                    ARM assembly (Cortex-M)
*ptr = 100;           →       MOV  r1, #100       ; put 100 into r1
                              STR  r1, [r0]       ; write r1 to the address in r0

r0 holds the pointer value (address), r1 holds the value to write. The STR instruction electrically executes the write to that address.

Conversely, val = *ptr; becomes LDR (Load Register):

val = *ptr;           →       LDR  r1, [r0]       ; load from address in r0 into r1

The register write GPIOA->BSRR = (1UL << 5) is ultimately the same STR instruction. From C to assembly to actual electrical signals — everything connects in a single line.

In Episode 12, we’ll verify the generated assembly directly using objdump.


2-4. Summary of Three Operations

Notation Meaning Example
uint32_t* ptr Declare a pointer variable “Create a box that holds an address”
&x Get the address of a variable ptr = &x
*ptr Read/write the value it points to val = *ptr / *ptr = 100

✅ Chapter 2 Checklist

  • Can explain in words what uint32_t* ptr means
  • Can say what &x returns
  • Can say what *ptr = 100 does
  • Can distinguish * in a declaration from * as an operator

2-5. Confirm ptr and *ptr Values in the Debugger

“Are they really pointing to the same place?” Let’s see with our own eyes.

In STM32CubeIDE debug mode, enter the following in the Variables or Expressions (Watch) view:

// Debug code (write inside main function)
uint32_t  x   = 42;
uint32_t* ptr = &x;
*ptr = 100;
// put a breakpoint here
Expression Expected value Meaning
x 100 contents of x
ptr 0x20000xxx address held by ptr (varies per run)
*ptr 100 contents at ptr’s target (same as x)
&x 0x20000xxx address of x (same value as ptr)

Confirm in practice that ptr == &x holds, and *ptr == x holds.

⚠️ 画像が見つかりません: /posts/stm32-episode05/debug_variables_ptr.png


3. Type and Pointer Arithmetic

3-1. What Pointer + 1 Means

First, imagine “what if there were no types?”

uint32_t arr[] = {10, 20, 30, 40};

This array is laid out in memory like this:

Address       Contents    Element
0x20000000: [ 10 ]    ← arr[0]  (occupies 4 bytes)
0x20000001: [    ]
0x20000002: [    ]
0x20000003: [    ]
0x20000004: [ 20 ]    ← arr[1]  (occupies 4 bytes)
0x20000005: [    ]
0x20000006: [    ]
0x20000007: [    ]
0x20000008: [ 30 ]    ← arr[2]
...

If arr[0] is at 0x20000000, arr[1] is at 0x20000004 (4 bytes further).

If there were no types and +1 meant “1 byte ahead”:

// Imaginary world without types
ptr + 0  0x20000000  byte 1 of arr[0]
ptr + 1  0x20000001  byte 2 of arr[0] (!)   intrudes into the same element
ptr + 2  0x20000002  byte 3 of arr[0] (!!)
ptr + 4  0x20000004  finally reaches arr[1]

You’d have to write +4 to get to the next element — and every time you change uint32_t to uint8_t, you’d have to rewrite all your code.

That’s why the compiler automatically multiplies by the type size:

uint32_t  arr[] = {10, 20, 30, 40};
uint32_t* ptr   = &arr[0];

// Compiler automatically converts "+1 → +4 bytes"
*(ptr + 0)  // → reads 0x20000000 → 10
*(ptr + 1)  // → reads 0x20000004 → 20  (+1 × 4 bytes)
*(ptr + 2)  // → reads 0x20000008 → 30  (+2 × 4 bytes)

uint8_t* advances 1 byte per +1; uint32_t* advances 4 bytes per +1same +1, different distances depending on type. That’s what pointer arithmetic means.

uint8_t*  +1 → 1 byte ahead  (1 × 1)
uint16_t* +1 → 2 bytes ahead (1 × 2)
uint32_t* +1 → 4 bytes ahead (1 × 4)

Without a type, “advance by 1 element” can’t be calculated. This is why pointers need types — a pointer must be a “typed address,” not just an “address.”

3-2. Array [ ] Is Syntactic Sugar for Pointer Arithmetic

In C, array subscript arr[i] is defined to be exactly the same as *(arr + i):

arr[2]       internally the compiler treats it as...
*(arr + 2)   this (generates identical machine code)

Verification:

uint32_t arr[] = {10, 20, 30, 40};

// All of these produce the same result
uint32_t a = arr[2];         // → 30
uint32_t b = *(arr + 2);     // → 30
uint32_t c = *(&arr[0] + 2); // → 30

When you write arr[2], the compiler interprets it as “read the location 2 elements (= 8 bytes) ahead of the start address.” Pointer arithmetic and array access are fundamentally the same operation.


⚠️ Foreshadowing: Alignment

uint32_t must be placed at an address that’s a multiple of 4.

OK:  0x20000000 (multiple of 4) ← can place uint32_t here
OK:  0x20000004 (multiple of 4) ← can place uint32_t here
NG:  0x20000001 (not multiple of 4) ← must not place uint32_t here

This is called an alignment constraint. You must align to a boundary equal to the type size (uint32_t is 4 bytes, so 4-byte boundary).

In normal code, the compiler handles this automatically. But when using pointers to force a specific address, you must enforce this constraint yourself.

Ignoring alignment in a pointer cast causes a HardFault (CPU exception) on STM32.

Episode 6’s “Pointer Accidents” will intentionally trigger and observe this. For now, remember: “uint32_t needs a 4-byte boundary.”


4. What Casts Mean

4-1. The Problem: “I Know the Address, but There’s No Type”

So far we’ve been creating pointers with &x. &x returns “the address of x,” so the type comes automatically:

uint32_t  x   = 42;
uint32_t* ptr = &x;   // x is uint32_t, so ptr automatically becomes uint32_t*

But in embedded systems, we sometimes write hardware addresses directly rather than “addresses of variables”:

0x40020018   // address of GPIOA's BSRR register (from datasheet)

This is just an integer value. Address known, but no type information attached:

The number 0x40020018
    ↑
    "how many bytes of data are here?" — the compiler can't know
    1 byte? 2 bytes? 4 bytes?

Cast is what gives type information to this “typeless address.”

4-2. How to Write a Cast

(uint32_t*)0x40020018
//  ↑
// "treat this address as a location where a uint32_t lives"

Just prepend (uint32_t*). This tells the compiler “read/write at this address in 4-byte chunks.”

Assign it to a pointer variable:

uint32_t* ptr32 = (uint32_t*)0x40020018;
//                 ↑ attach type first, then put into pointer variable

// write to BSRR via ptr32
*ptr32 = (1UL << 5);

Why can’t you assign without a cast?

uint32_t* ptr = 0x40020018;  // Compilation error

0x40020018 is an integer; uint32_t* is a pointer type — C forbids assigning integers directly to pointers. “Don’t operate without knowing how many bytes to read” is a safety mechanism. A cast is an explicit declaration: “I know. I take responsibility.”

4-3. Cast and Dereference Combined

Combining cast and dereference:

*(uint32_t*)0x40020018 = (1UL << 5);

At first glance it looks like a block of symbols, but read from inside to outside:

*(uint32_t*)0x40020018 = (1UL << 5);
 ↑           ↑
 ② outer *   ① inner cast

① (uint32_t*)0x40020018
    └─ attach the type "this is a uint32_t location" to the number 0x40020018
       → becomes "a uint32_t* pointer pointing to address 0x40020018"

② *( ... )
    └─ read/write at the location that pointer points to (dereference)
       → "read/write the 4 bytes at address 0x40020018"

③ = (1UL << 5)
    └─ write this value to that location

“Read/write address 0x40020018 as uint32_t” — that’s the operation.

Reading tip “Read parentheses from the inside” is the rule for this type of expression. *(uint32_t*)address → say aloud: “cast the address to uint32_t pointer, then read/write what it points to.”

Compare with the BSRR operation from last episode:

GPIOA->BSRR = (1UL << 5);           // CMSIS style
*(uint32_t*)0x40020018 = (1UL << 5); // raw address style

GPIOA’s BSRR address is 0x40020018, so these are exactly the same operation.


5. Memory-Mapped I/O — Touching Hardware with Pointers

5-1. Peripherals Live in the Memory Space Too

In Episode 1 when we studied the memory map, we noted that “peripherals (GPIO, UART, timers) are also managed by address.”

STM32F401 memory map (excerpt):

Address range                    Contents
0x00000000 - 0x1FFFFFFF   : Flash (program code)
0x20000000 - 0x3FFFFFFF   : SRAM (variables, stack)
0x40000000 - 0x5FFFFFFF   : Peripheral registers (GPIO, UART, timers, etc.)
0xE0000000 - 0xFFFFFFFF   : CPU internals (debug, SysTick)

Peripheral registers are at addresses starting with 0x40000000 — which means they’re directly accessible with pointers.

This is called Memory-Mapped I/O (MMIO).

5-2. Manually Calculate GPIO Addresses

Let’s hand-calculate the address of GPIOA’s ODR from the datasheet.

Steps:

  1. STM32F401 AHB1 bus base address: 0x40020000 (from datasheet Table 1)
  2. GPIOA base address: 0x40020000 + 0x0000 = 0x40020000
  3. ODR offset: +0x14 (from GPIO register map)
  4. GPIOA->ODR address: 0x40020000 + 0x14 = 0x40020014
Register Offset Address
MODER 0x00 0x40020000
OTYPER 0x04 0x40020004
OSPEEDR 0x08 0x40020008
PUPDR 0x0C 0x4002000C
IDR 0x10 0x40020010
ODR 0x14 0x40020014
BSRR 0x18 0x40020018

The 0x40020018 we used earlier is confirmed to be the BSRR address, right from the datasheet.

5-3. Write Directly with Pointers

Writing to GPIOA’s BSRR using raw addresses:

// Set PA5 HIGH (set BSRR bit5)
*(volatile uint32_t*)0x40020018 = (1UL << 5);

// Set PA5 LOW (set BSRR bit21 ← bit16+5)
*(volatile uint32_t*)0x40020018 = (1UL << (16 + 5));

About volatile volatile tells the compiler “don’t skip this read/write with optimization.” Registers can have their values changed by hardware even if C code hasn’t written to them. Adding volatile says “always read/write this every time.” → Explained in detail in Episode 3


6. What CMSIS Does — Decoding GPIO_TypeDef

6-0. What Is CMSIS?

When you create a project in CubeIDE, a large number of header files are automatically added. Among them is a file group called CMSIS:

<project root>/
└── Drivers/
    └── CMSIS/
        └── Device/
            └── ST/
                └── STM32F4xx/
                    └── Include/
                        └── stm32f401xe.h   ← this is the CMSIS header

Relative path: Drivers/CMSIS/Device/ST/STM32F4xx/Include/stm32f401xe.h

In CubeIDE’s project explorer, navigate to this path, or Ctrl+click on GPIOA in code (or right-click → Open Declaration) to jump directly.

CMSIS (Cortex Microcontroller Software Interface Standard) is ARM’s standard for microcontroller common interfaces. Its main role is “giving names to register addresses.”

Raw address CMSIS name
*(volatile uint32_t*)0x40020018 GPIOA->BSRR
*(volatile uint32_t*)0x40020014 GPIOA->ODR
*(volatile uint32_t*)0x40020000 GPIOA->MODER

Without CMSIS headers, all register operations would use raw addresses. CMSIS is the header file that takes over that work.

GPIOA->BSRR is not magic CubeIDE samples and HAL code use GPIOA->BSRR as if it’s obvious. But its true form is defined in CMSIS headers using pointers and structs. This section fully decodes that.

6-1. Writing Raw Addresses Every Time Is Painful

Using raw addresses as in the previous section makes code hard to read:

// What register is this? Unclear.
*(volatile uint32_t*)0x40020018 = (1UL << 5);

// This is much clearer.
GPIOA->BSRR = (1UL << 5);

CMSIS headers solve this problem using struct pointers.

6-2. The True Nature of GPIO_TypeDef

Opening the STM32 CMSIS header (stm32f401xe.h) reveals this definition. In STM32CubeIDE, Ctrl+click on GPIOA (or right-click → “Open Declaration”) to jump directly:

⚠️ 画像が見つかりません: /posts/stm32-episode05/cmsis_header.jpg

typedef struct {
  volatile uint32_t MODER;    // offset 0x00
  volatile uint32_t OTYPER;   // offset 0x04
  volatile uint32_t OSPEEDR;  // offset 0x08
  volatile uint32_t PUPDR;    // offset 0x0C
  volatile uint32_t IDR;      // offset 0x10
  volatile uint32_t ODR;      // offset 0x14
  volatile uint32_t BSRR;     // offset 0x18
  volatile uint32_t LCKR;     // offset 0x1C
  volatile uint32_t AFR[2];   // offset 0x20-0x24
} GPIO_TypeDef;

The knowledge from Episode 3 — “struct members are laid out in memory in declaration order” — connects directly here. With uint32_t (4 bytes) members in sequence:

  • MODER → +0x00 (0 bytes from start)
  • OTYPER → +0x04 (4 bytes from start)
  • BSRR → +0x18 (24 bytes from start)

This struct layout is designed to exactly match the GPIO register memory layout.

⚠️ 画像が見つかりません: /posts/stm32-episode05/gpio_typedef_layout.jpg

Episode 3’s “struct members are laid out in declaration order” is paying off right here.Episode 3: How C Represents Memory

📌 Section 6-2 key point: GPIO_TypeDef is just a struct. Its member ordering is designed to exactly match the hardware’s register layout.


6-3. The GPIOA Macro — Attaching a Name to an Address

① Constant-ize the address with GPIOA_BASE

The same header file also has:

#define GPIOA_BASE   0x40020000UL

Just attaching the name GPIOA_BASE to the raw address 0x40020000.

② Make it usable as a pointer with GPIOA

#define GPIOA        ((GPIO_TypeDef*)GPIOA_BASE)

Casting GPIOA_BASE (= 0x40020000) to GPIO_TypeDef* type. In other words:

GPIOA = (GPIO_TypeDef*)0x40020000

“A pointer to GPIO_TypeDef at address 0x40020000.” When the macro is expanded, every GPIOA in code is replaced with this pointer value.

③ Accessing a member with -> determines the address

GPIOA->BSRR means:
  → interpret address 0x40020000 as GPIO_TypeDef
  → BSRR member is 0x18 bytes from the start
  → read/write at address 0x40020000 + 0x18 = 0x40020018 as uint32_t

That’s why GPIOA->BSRR and *(uint32_t*)0x40020018 are completely equivalent.

📌 Section 6-3 key point: GPIOA is a macro expanding to ((GPIO_TypeDef*)0x40020000). Just a “named pointer” — an address with type information attached via pointer cast.

6-4. What the -> Operator Means

-> is the operator for “accessing a struct member through a pointer.”

First, with a non-pointer variable, struct member access uses .:

GPIO_TypeDef reg;            // actual variable (not a pointer)
reg.BSRR = (1UL << 5);      // access member with .

With a pointer, there’s an extra step. Since a pointer “just holds an address,” you must first reach the contents with *, then access the member with .:

GPIO_TypeDef* ptr = GPIOA;   // pointer (holds an address)
(*ptr).BSRR = (1UL << 5);
//↑ get "the actual thing ptr points to" with *, then access member with .

However, (*ptr).BSRR needs parentheses and is hard to read. Writing it as *ptr.BSRR changes the meaning due to operator precedence:

*ptr.BSRR    // NG: interpreted as "dereference ptr's BSRR member as a pointer"
(*ptr).BSRR  // OK: "ptr's actual target" → ".BSRR" in that order

-> is a shorthand that consolidates the (*ptr).member pattern into one symbol:

// These two mean exactly the same thing and generate identical code
(*ptr).BSRR = (1UL << 5);   // using * and .
  ptr->BSRR = (1UL << 5);   // using -> (the common form)

-> is called the “arrow operator” — “the member named X of what this pointer points to.”

The full expansion:

GPIOA->BSRR = (1UL << 5);

① GPIOA = (GPIO_TypeDef*)0x40020000
   └─ a GPIO_TypeDef* pointer to address 0x40020000

② ->BSRR
   └─ BSRR member inside GPIO_TypeDef (offset +0x18)

③ Result: write to address 0x40020000 + 0x18 = 0x40020018

So GPIOA->BSRR and *(volatile uint32_t*)0x40020018 are completely identical operations.

The mechanism behind how CMSIS headers enable GPIOA->BSRR notation is now fully explained.

Notation True form
GPIOA->BSRR = x same as *(volatile uint32_t*)0x40020018 = x
GPIOA->ODR = x same as *(volatile uint32_t*)0x40020014 = x
GPIOB->BSRR = x same as *(volatile uint32_t*)0x40020418 = x

7. Practice: Directly Manipulate GPIO with Pointers

7-1. Goal

Implement Episode 4’s LED blink using raw pointers (no CMSIS definitions). By using only datasheet addresses to control GPIO, experience the feeling that “CMSIS headers are just giving convenient names.”

7-2. Prerequisites

Same as Episode 4: assume CubeMX-generated initialization code (MX_GPIO_Init()) has already handled clock enable and pin configuration. If you’ve forgotten how BSRR works, revisit Episode 4: The World of Bits.

Pin used: external LED connected to PA0 (same circuit as Episode 4) NUCLEO-F401RE’s onboard LED (PA5) works too. Just replace 0 with 5 in the code.

Circuit is the same as Episode 4:

PA0 ──[330Ω]──[LED]── GND

7-3. Code

/* USER CODE BEGIN Includes */
#include "main.h"
/* USER CODE END Includes */

/* USER CODE BEGIN PV */
// GPIOA register addresses (from datasheet)
#define GPIOA_BASE_ADDR  0x40020000UL
#define GPIOA_ODR_ADDR   (GPIOA_BASE_ADDR + 0x14UL)  // 0x40020014
#define GPIOA_BSRR_ADDR  (GPIOA_BASE_ADDR + 0x18UL)  // 0x40020018

// Pointer definitions
volatile uint32_t* const GPIOA_ODR  = (volatile uint32_t*)GPIOA_ODR_ADDR;
volatile uint32_t* const GPIOA_BSRR = (volatile uint32_t*)GPIOA_BSRR_ADDR;
/* USER CODE END PV */

/* USER CODE BEGIN 3 */
while (1) {
    // PA0 HIGH (LED ON)
    *GPIOA_BSRR = (1UL << 0);          // set BSRR BS0
    HAL_Delay(500);

    // PA0 LOW (LED OFF)
    *GPIOA_BSRR = (1UL << (16 + 0));   // set BSRR BR0
    HAL_Delay(500);
}
/* USER CODE END 3 */

For PA5 (onboard LED): replace (1UL << 0) with (1UL << 5) and (1UL << (16 + 0)) with (1UL << (16 + 5)).

Reading the code:

Code Meaning
(volatile uint32_t*)GPIOA_BSRR_ADDR pointer to 0x40020018, treating it as uint32_t
volatile uint32_t* const GPIOA_BSRR declare that pointer as a constant
*GPIOA_BSRR = (1UL << 0) write to 0x40020018 = write to BSRR

const here means “the pointer’s own value (address) cannot be changed.” It prevents GPIOA_BSRR = some_other_address.

7-4. Comparison with CMSIS Version

// CMSIS version
GPIOA->BSRR = (1UL << 0);   // PA0 ON

// Raw pointer version
*GPIOA_BSRR = (1UL << 0);   // PA0 ON

The generated assembly is nearly identical. This demonstrates that CMSIS is “just a convenient wrapper that organizes names and struct layout.”

7-5. Confirm with the Debugger

In STM32CubeIDE debugger, add to Watch:

(uint32_t*)0x40020014    ← ODR address
*((uint32_t*)0x40020014) ← ODR value

Check *((uint32_t*)0x40020014) when LED is ON vs OFF:

State ODR bit0
LED ON (PA0 HIGH) 1
LED OFF (PA0 LOW) 0

Using pointer casts in Watch expressions to peek directly at any address is a debugger technique worth knowing.

Monitoring *((uint32_t*)0x40020014) with Live Expressions while LED blinks. ODR bit0 toggles 1 → 0 → 1.

✅ Chapter 7 Checklist

  • Wrote and ran the raw pointer LED blink code
  • Can explain the meaning of volatile uint32_t* const
  • Confirmed that CMSIS version and raw pointer version have identical behavior

8. Pointer Applications — Passing Pointers as Function Arguments

8-1. “Pass by Value” vs “Pass by Pointer”

Another reason pointers matter in embedded: efficiently passing large data.

// Pass by value (a copy is made)
void setValue_copy(uint32_t data) {
    data = 100;  // modifying the copy doesn't affect the original
}

// Pass by pointer (the address is passed)
void setValue_ptr(uint32_t* ptr) {
    *ptr = 100;  // modify the original via pointer
}

// Usage
uint32_t x = 0;
setValue_copy(x);     // x is still 0
setValue_ptr(&x);     // x becomes 100

When passing a large struct to a function, pass by value creates a copy consuming stack (RAM). Pointer passing sends just the address (4 bytes) — efficient. Stack mechanics are covered in Episode 2: Where Variables Live.

8-2. Using const Pointers

When “passing data for read-only access,” add const:

// Function that reads data but doesn't modify it
void printConfig(const uint32_t* config) {
    // *config = 100;  // Compile error: const prevents writing
    uint32_t val = *config;  // Reading is OK
}

In embedded systems, this also serves to “prevent accidentally writing to a register.”


9. Frequently Asked Questions (FAQ)

Q1. Which is correct: int* or int *?

A: Both are correct and mean exactly the same. But note: int* a, b; makes a a pointer and b a regular int. Writing int *a, *b; makes both pointers. This series uses int* attached to the type, declaring one variable at a time.

Q2. What is a NULL pointer?

A: NULL (= 0) is a special pointer value meaning “not pointing anywhere.”

uint32_t* ptr = NULL;  // "not pointing to anything yet"

if (ptr != NULL) {
    *ptr = 100;  // only use after NULL check
}

Dereferencing a NULL pointer causes a HardFault (CPU illegal access exception). Covered as a pointer accident in Episode 6.

Q3. What is void* used for?

A: void* is a “typeless pointer” that can receive any pointer type. Used in malloc() return values and memcpy() arguments.

void* generic_ptr;
uint32_t x = 42;
generic_ptr = &x;  // assigning uint32_t* to void* (OK)

// Cast is needed to use it
uint32_t val = *((uint32_t*)generic_ptr);

In embedded systems, void* appears in generic callback function arguments.

Q4. What is a pointer to a pointer (uint32_t**)?

A: “A variable that holds the address of a pointer.” Since uint32_t* ptr itself lives somewhere in memory, you can hold its address.

uint32_t  x   = 42;
uint32_t* ptr  = &x;     // pointer holding x's address
uint32_t** pp  = &ptr;   // pointer-to-pointer holding ptr's address

**pp = 100;  // traverse pp → ptr → x to modify x

In embedded systems, used for 2D arrays and callback registration. Just knowing this exists is enough for now.

Q5. Why does an error occur when using . instead of -> with GPIOA->MODER?

A: . accesses struct members directly on a variable; -> accesses struct members through a pointer. Since GPIOA is a pointer, . doesn’t work.

GPIO_TypeDef  reg = *GPIOA;  // dereference pointer to copy into variable
reg.BSRR = (1UL << 5);       // variable → use . (but this doesn't reflect to actual register!)

GPIOA->BSRR = (1UL << 5);   // write to actual register via pointer

Episode 5 Summary

This episode untangled C’s most feared concept — “pointers” — from the “typed address” perspective.

What We Learned

Pointer = typed address: combination of an address and type information
Declaration, referencing, dereferencing: how to use uint32_t* ptr, &x, *ptr
The role of type: determines read/write size and pointer arithmetic step
Cast: converting a number to a pointer with (uint32_t*)0x40020018
Memory-mapped I/O: peripheral registers can be directly accessed with pointers
CMSIS’s true nature: just attaching names to addresses using GPIO_TypeDef struct pointer
Raw pointer LED blink: controlled GPIO directly with raw pointers, no CMSIS

Next Steps

Having turned pointers into a weapon, the next episode is about “learning through breaking.”

Pointers are powerful, but misuse causes embedded systems to behave unpredictably. We’ll observe the “breakage patterns” in the debugger to thoroughly internalize the mechanics of why accidents happen.


Next Episode: Pointer Accidents (Why Does It Break?)

Episode 6: Pointer Accidents learns from intentionally creating and breaking real pointer bugs from the field.

What you’ll learn:

  • NULL dereference: the true nature of HardFault
  • Dangling pointers: the danger of pointing to vanished variables
  • Stack lifetime: why you must never return a local variable’s address
  • Out-of-bounds access: silently corrupting neighboring variables
  • UB (Undefined Behavior): when the compiler does unpredictable things

With today’s thorough understanding of “pointer fundamentals,” it becomes clear why these are dangerous.


📖 Previous Episode

#4: The World of Bits — Register Operations and the BSRR Design

📚 Next Episode

Episode 6: "Pointer Accidents — Why Does It Break?"
NULL dereference, dangling pointers, stack lifetime, out-of-bounds — observe crashes intentionally.

📖 Read Episode 6

📍 Series Index

Full 13-Part Series: The Embedded World Beyond Pointers