Structs, pointers and ring buffers
Objectives
Section titled “Objectives”By the end of this lesson, you will be able to
- Design a struct for one sensor sample, and pass it to a function by const pointer when it doesn’t need to be changed.
- Write a fixed-size ring buffer that can be read from and written to without overwriting data that hasn’t been read yet.
- Explain the risk when an ISR and a task share the same buffer, and how to guard against it.
Takes about 70 minutes (concepts 15 · practice 25 · lab 25 · check 5).
Before you start
Section titled “Before you start”Two review questions from lesson 1.2.
- What happens to a 512-byte buffer declared as a local variable in a task with a 256-word stack, and what should it be declared as instead?
- Why is
head - tailon auint32_tcounter still correct even afterheadhas wrapped around past zero (the hint is in lesson 1.1)?
See it work first
Section titled “See it work first”Open examples/03_sensor_sample.c. Predict before you run it: what is sizeof(imu_loose_t)? Its members are one uint8_t, one uint32_t, three int16_t, and one more uint8_t, which add up to 12 bytes. Then run it.
gcc -std=c11 -Wall -Wextra -o sensor_sample examples/03_sensor_sample.c./sensor_sampleOn a typical computer and on Cortex-M, you get 16, not 12, because the compiler inserts padding so the uint32_t starts on a 4-byte boundary. Reorder the same members from largest to smallest and the struct shrinks back to 12 bytes. When sensor data is stored by the thousand in a buffer, a 4-byte difference per sample adds up to several kilobytes.
Concepts
Section titled “Concepts”1. A struct is one bundle of data, and a const pointer is a promise
Section titled “1. A struct is one bundle of data, and a const pointer is a promise”Values that arise together should stay together. Three-axis acceleration read from the same conversion, the time it was read, and a flag saying whether the data is valid — if these are spread across several separate variables, a function ends up with a long, awkward parameter list, and it becomes easy to accidentally pair the X axis from this round with the Y axis from a previous one. The SDK uses structs like this throughout — for example, health_t in 07_engine_health.c holds six counters read in the same instant, and sample(health_t *h) fills them in through a pointer.
Rules for passing a struct to a function:
| The function will | Pass it as | Example |
|---|---|---|
| Only read it | const T * |
int32_t magnitude_sq(const imu_sample_t *s) |
| Fill in or change it for the caller | T * (an “output slot”) |
The SDK’s bool radar_dsp_snapshot(ipc_radar_range_t *out) |
| Needs its own copy, and the struct is small | by value | Gets a copy; changing the copy doesn’t touch the original, but every byte must be copied onto the stack |
const doesn’t make code faster — it lets the compiler catch you when you accidentally change something you promised not to, and it tells anyone reading the code right away that this function is safe. The SDK’s 04_gpio_led_button.c example uses a static const led_def_t s_leds[] table, and picks up one row with const led_def_t *mirror (line 203) — a pointer to data that must never be changed.
2. Ring buffers
Section titled “2. Ring buffers”When data arrives in a stream — bytes from a UART, or readings from a sensor — and the consumer runs on a different schedule from the producer, we need a fixed-size holding area that doesn’t need malloc. A ring buffer is an array reused in a circle: the writer advances head, the reader advances tail.
data: [ . | . | A | B | C | . | . | . ] A, B, C have not been read yet ^tail ^head count = head - tail = 3The design used in this lesson’s exercise relies on three techniques.
- A power-of-two size. The position is computed with
counter & MASKinstead of%, which is slower on some CPUs (the SDK’s radar buffer, sized0x4000, uses the same trick). headandtailare counters that only ever increase. The number of items is alwayshead - tail, which distinguishes “empty” (equal) from “full” (differing by the capacity) without needing to sacrifice one slot.- Reject when full.
pushreturnsfalse, and the caller decides for itself whether that counts as lost data or a reason to wait.
The policy for what happens when full is a design decision, not a detail. The SDK’s radar task chooses the opposite of what we do here: when full, it “keep[s] newest samples, drop[s] oldest two” (radar_task.c lines 466-480), because for detecting a person, the latest reading matters more than an old one — but for protocol bytes, dropping an old byte wrecks the whole frame. Another decision to make is what value to return when “empty”: the SDK’s TACP (the mtb-mpy variant) returns -1 when empty and 0 to 255 when there’s data, and warns that you must store it in an int and check for -1 before converting to uint8_t, because “a byte of 0xFF is real data and is not the empty marker” (08_tacp_host_protocol.c lines 46-48). Our exercise avoids this problem by keeping the bool return value separate from the data handed back through a pointer.
3. When an ISR and a task share the same buffer
Section titled “3. When an ISR and a task share the same buffer”An ISR can interrupt a task at any moment — even in the middle of a line like rb->head = rb->head + 1;, which is a read, modify, write. If both sides change the same variable, an update can be lost. If the writer advances head before it has written the data into the slot, the reader might pick up a slot that’s still empty; and if a task reads several bytes of data while an ISR is overwriting them, it gets a mix of old and new (a torn read). The SDK uses four real guards against this.
| Method | Use when | Where the SDK uses it |
|---|---|---|
| A single-writer, single-reader ring buffer: each side only ever changes its own counter, writes the data before advancing the counter, and a barrier separates the two | Bytes or small values arriving continuously | TACP’s lock-free ring (mtb-mpy variant), where tacp_request_delete_main_from_isr() can push a byte in from ISR context |
Have the ISR send a copy into an RTOS queue with xQueueSendFromISR(), and let a task pick it up |
Chunk-sized messages, needing the task to wake immediately | sensor_auto_task.c lines 274-299 |
| A seqlock: the writer makes the sequence number odd while writing, the reader copies then checks that the sequence number hasn’t changed and is even; if not, it reads again | A frequently read snapshot that can tolerate re-reads | radar_dsp.c lines 284-320 |
| A critical section: disable interrupts for the shortest possible time while reading or writing | Several variables of data that must change together | FreeRTOS’s taskENTER_CRITICAL() |
Two things never to do: call malloc or printf inside an ISR (lesson 1.2 and module 4), and assume volatile alone is enough. volatile forces the read and write to actually happen, but it does not turn a read-modify-write into a single step.
Worked example
Section titled “Worked example”examples/03_sensor_sample.c runs in three parts.
- Part 1 compares two structs with the same members in a different order, printing
sizeofandoffsetofto show where the padding falls. - Part 2:
sample_fill()takes animu_sample_t *to fill in values, whilemagnitude_sq()takes aconst imu_sample_t *because it only reads. - Part 3: the struct lives on
main’s stack and its address is passed on, whiletry_to_reset()takes it by value, so it can only change its own copy.
Try changing things one at a time, predicting the result before you run it each time.
- Uncomment the line
s->ax = 0;insidemagnitude_sq(). What does the compiler say? - Move
validandseqto the very front ofimu_sample_t. Does the size change? Why? - Change
try_to_reset()to take animu_sample_t *and change the value through the pointer. What does the last line’s result change to?
Keep reading in the real SDK: the reader side of the seqlock in radar_dsp.c copies the whole struct through the out pointer, then loops and re-reads until it gets a set that wasn’t torn in the middle.
bool radar_dsp_snapshot(ipc_radar_range_t *out){ if (out == NULL) { return false; } if (!s_ready) { memset(out, 0, sizeof(*out)); out->initialized = s_inited ? 1 : 0; return false; } uint32_t s1, s2; do { s1 = s_snap_seq; memcpy(out, (const void *)&s_snap, sizeof(*out)); s2 = s_snap_seq; } while ((s1 != s2) || (s1 & 1u)); /* retry on torn/odd */ return true;}Source: radar_dsp.c lines 303-320 (Apache-2.0, tesaiot-pse84-devkit-sdk). The writer side (lines 284-297) bumps the sequence number to odd, calls __DMB(), writes every field, calls __DMB() again, then bumps the sequence number to even. Notice that the function returns false when there is no first frame yet, which is different from “no target” — a result that tells the truth is the subject of lesson 3.2.
Practice
Section titled “Practice”Open practice/03_ring_buffer.c. There are 5 gaps to fill in — more than the previous two lessons, because you now have the full set of tools. rb_count(), rb_is_empty(), rb_is_full(), rb_push() and rb_pop(). The tests cover three cases.
- The normal case: first in, first out, and reading from an empty buffer must return
false. - An edge case: fill it with exactly 16 bytes, and the 17th byte must be rejected, with the first byte still unchanged.
- An edge case: the counter starts at
UINT32_MAX - 3and runs past zero over 1000 rounds of writing and reading.
gcc -std=c11 -Wall -Wextra -o ring practice/03_ring_buffer.c && ./ringSolution
Section titled “Solution”Try it yourself for at least 15 minutes first, then open solution/03_ring_buffer.c. What the solution adds beyond what the tests require is a C11 atomic_thread_fence() between “write the data” and “advance head”, and between “read the data” and “advance tail”. On a computer, the tests pass even without this line — but when the writer is an ISR or another core, it’s what stops the compiler or the CPU from reordering things. On Cortex-M in the SDK, CMSIS’s __DMB() does this job. This is an example of something a test on the host computer cannot prove, which we’ll come back to in lesson 6.2.
Check your understanding
Section titled “Check your understanding”Answer the 5 questions in quiz.yaml (shown at the bottom of this page on the website), covering all three objectives. Getting 4 or more right counts as finishing the lesson.
Task: watch a struct passed by pointer, and a seqlock, work for real on the board, then read the code to find out who owns the data.
- Build the SDK’s template with
make build -j ENABLE_PAGE_EXAMPLES=1, thenmake program. Unplug the USB cable all the way and plug it back in. (The full steps are in lesson 2.1.) - On the screen, tap SDK Examples and run
cm55/sensors/02_radar_presence. This example readsradar_dsp_snapshot()every 200 ms. Watch the top line: with no target, it showsno target seq ... bin width ... mm, andseqkeeps climbing. Slowly move your hand toward the board and watch the line change totarget ... mm bin .... - Note the
seqvalue twice, about ten seconds apart, and estimate how many new snapshots the radar announces per second. - Read ipc_tesaiot_handler.c lines 330-363. This function runs in ISR context, copies a message into a single variable
s_pending, then wakes a task with a semaphore. In your notes, answer: if a second message arrives before the task finishes reading the first, what happens tos_pending? Then search the same file, or the CM55-side files, for anything that guarantees this can’t happen (if you can’t find one, write that down instead of guessing). - Compare this with sensor_auto_task.c lines 274-299, which sends a copy of a struct into a queue. Which approach copes with back-to-back messages better, and what’s the trade-off?
Evidence to keep in your portfolio: screenshots of both states of the radar example, the seq values you noted with your calculation, and your answers to questions 4 and 5.
Going further
Section titled “Going further”- Extend the exercise so the buffer holds whole
imu_sample_tstructs instead of bytes, and think through what has to change (hint: the size of each slot, and copying a whole struct at a time). - Challenge: add a
droppedcounter thatrb_push()increments every time it rejects a value, then ask yourself who owns this counter — the writer or the reader?
Next lesson, moving into module 2: lesson 2.1, the toolchain and your first build
Reflect
Section titled “Reflect”- In work you’ve done before, which piece of data should drop the old value when full, and which must never be dropped at all?
- If a test passes on a computer every time, but the board’s data is occasionally corrupted, what would you suspect first?
References
Section titled “References”- SDK: cm33/connectivity/08_tacp_host_protocol.c (a ring buffer and its _from_isr function)
- SDK: tesaiot-radar/radar_dsp.c (the snapshot’s seqlock)
- SDK: cm55/sensors/02_radar_presence.c (reading the snapshot from the display side)
- B3 — The IPC backbone: setup, deferred binding, snapshots (SDK docs built from commit ef72c1b)
- FreeRTOS documentation
Review questions
Answer on your own first, then open the answer.
-
print_sample() only reads an imu_sample_t and prints it. How should its parameter be declared? (Objective 1)
- void print_sample(imu_sample_t s)
- void print_sample(imu_sample_t *s)
- void print_sample(const imu_sample_t *s)
- void print_sample(volatile imu_sample_t *s)
Show answer
Answer: C. void print_sample(const imu_sample_t *s)
const pointer ไม่คัดลอกทั้งก้อนลง stack และคอมไพเลอร์จะไม่ยอมถ้าฟังก์ชันเผลอแก้ค่า by value ใช้ได้แต่เสียการคัดลอก pointer ธรรมดาไม่บอกว่าฟังก์ชันจะไม่แก้ ส่วน volatile ไม่เกี่ยวกับเรื่องนี้
-
struct { uint8_t a; uint32_t b; uint8_t c; } on Cortex-M with GCC is usually how big, and how can it shrink? (Objective 1)
- 6 ไบต์ ลดไม่ได้
- 12 ไบต์ เรียงใหม่เป็น b, a, c จะเหลือ 8 ไบต์
- 8 ไบต์ เรียงใหม่แล้วเหลือ 6 ไบต์
- 12 ไบต์ เรียงแบบไหนก็ได้ 12 ไบต์
Show answer
Answer: B. 12 ไบต์ เรียงใหม่เป็น b, a, c จะเหลือ 8 ไบต์
b ต้องเริ่มที่ขอบ 4 ไบต์ จึงมีช่องว่าง 3 ไบต์หลัง a และ struct ทั้งก้อนต้องยาวเป็นพหุคูณของ 4 จึงได้ 12 เรียงตัวใหญ่ก่อนเป็น b, a, c ได้ 4 + 1 + 1 แล้วเติมท้ายเป็น 8
-
A capacity-16 ring uses free-running uint32_t counters with head = 5 and tail = 0xFFFFFFF9. How many unread items are there? (Objective 2)
- ติดลบ แปลว่าบัฟเฟอร์เสีย
- 12
- 16 เพราะเต็ม
- 4294967284
Show answer
Answer: B. 12
5 - 0xFFFFFFF9 ใน uint32_t วนกลับได้ 12 (0xFFFFFFF9 คือ -7 ถ้ามองแบบมีเครื่องหมาย 5 - (-7) = 12) การลบของ unsigned เป็น modulo 2^32 ตัวนับจึงวนผ่านศูนย์ได้โดย count ยังถูก
-
Order the steps of an rb_push() that never overwrites and is safe with a reader in another context. (Objective 2)
- rb->head = h + 1u; (ประกาศว่ามีข้อมูลใหม่)
- if (rb_is_full(rb)) return false;
- atomic_thread_fence(memory_order_release); (หรือ __DMB() บน Cortex-M)
- rb->data[h & RB_MASK] = byte;
Show answer
Correct order: B. if (rb_is_full(rb)) return false; → D. rb->data[h & RB_MASK] = byte; → C. atomic_thread_fence(memory_order_release); (หรือ __DMB() บน Cortex-M) → A. rb->head = h + 1u; (ประกาศว่ามีข้อมูลใหม่)
ตรวจเต็มก่อนเพื่อไม่ทับข้อมูลที่ยังไม่ถูกอ่าน เขียนข้อมูลลงช่อง ใส่ barrier ให้ข้อมูลถึงหน่วยความจำ แล้วจึงเลื่อน head ถ้าเลื่อน head ก่อน ผู้อ่านอาจหยิบช่องที่ยังว่างไป
-
An ISR receives a message from the other core and must hand it to a task. Which approaches are sound? (choose all that apply) (Objective 3)
- คัดลอกข้อความเข้าคิวด้วย xQueueSendFromISR() แล้วเรียก portYIELD_FROM_ISR()
- เรียก malloc() สร้างก้อนใหม่ใน ISR แล้วส่ง pointer ให้ task
- ใส่ลง ring buffer ที่ ISR แก้เฉพาะ head และ task แก้เฉพาะ tail โดยเขียนข้อมูลก่อนเลื่อน head
- ประกาศตัวแปรร่วมเป็น volatile แล้วให้ทั้ง ISR และ task ทำ count++ กับมันได้เลย
Show answer
Answer: A. คัดลอกข้อความเข้าคิวด้วย xQueueSendFromISR() แล้วเรียก portYIELD_FROM_ISR() · C. ใส่ลง ring buffer ที่ ISR แก้เฉพาะ head และ task แก้เฉพาะ tail โดยเขียนข้อมูลก่อนเลื่อน head
คิวของ RTOS คัดลอกข้อมูลให้และปลุก task ส่วน ring แบบผู้เขียนหนึ่ง ผู้อ่านหนึ่งให้แต่ละฝ่ายแก้เฉพาะตัวนับของตัวเอง malloc ใน ISR ผิดกติกาของบทเรียน 1.2 และ volatile ไม่ได้ทำให้ count++ ที่สองฝ่ายแก้พร้อมกันปลอดภัย
Cite this lesson
If you teach from this lesson or reuse it in slides or documents, credit it with the text below. If you changed it, add (adapted) after the title.
"Structs, pointers and ring buffers" from TESA Open Knowledge by the Thai Embedded Systems Association (TESA), https://github.com/tesaiot/tesa-qualification-program, licensed under CC BY-NC 4.0
Thai attribution: "struct, pointer และบัฟเฟอร์วงแหวน" จาก TESA Open Knowledge โดยสมาคมสมองกลฝังตัวไทย (Thai Embedded Systems Association: TESA) https://github.com/tesaiot/tesa-qualification-program สัญญาอนุญาต CC BY-NC 4.0
This lesson adapts the source below; keep its credit too.
https://github.com/tesaiot/tesaiot-pse84-devkit-sdk/tree/ef72c1b658178eee8c38b1e47d28b006f80a59b5 · SDK examples and docs are linked at this commit, not copied into this course. Lessons quote short excerpts (at most 25 lines) with a link to the file at this commit and the credit (Apache-2.0, tesaiot-pse84-devkit-sdk).
TESA Open Knowledge · © 2026 สมาคมสมองกลฝังตัวไทย (TESA) · CC BY-NC 4.0
Content is licensed CC BY-NC 4.0. Reuse it non-commercially and credit the Thai Embedded Systems Association (TESA) every time. · How to cite TESA