Atomic operations allow thread-safe access to shared resources without the use of locks in multi-threaded systems. On Cortex-M0/M0+ multi-core microcontrollers, atomic operations can be implemented using special instructions like LDREX and STREX or by disabling interrupts during critical sections. The choice depends on performance requirements and how critical the shared resource is.
What are atomic operations?
An atomic operation is a series of instructions that is guaranteed to execute atomically, meaning no other thread can observe or modify the state in the middle of the atomic operation. This avoids race conditions and ensures data consistency in multi-threaded environments without using locks.
Atomic operations typically read, modify and write a value to memory. The read-modify-write sequence is guaranteed to be uninterrupted. For example, atomic increment involves reading a value, incrementing it, and writing back the result atomically.
Why use atomic operations?
Atomic operations are useful for implementing thread-safe data structures like queues and maps that are accessed by multiple threads. They allow safe modification of shared data without locks. This improves performance and scalability in multi-core systems.
Locks have some disadvantages likepriority inversion, deadlock, and performance bottlenecks. Atomic operations avoid these by ensuring atomic access without locks.
Challenges of atomicity on Cortex-M0/M0+
Implementing atomic operations efficiently on Cortex-M0/M0+ can be challenging because:
- M0/M0+ lacks cache making atomicity harder to guarantee
- NoLoad/Store exclusive instructions like on Cortex-M3/M4
- Few registers make implementing software routines tricky
- Interrupt latency can disturb atomicity
Using LDREX and STREX
Cortex-M0+ provides LDREX (Load Exclusive Register) and STREX (Store Exclusive Register) instructions that can be used to implement atomic operations.
LDREX loads a value from memory into a register exclusively. This reserves the memory location. STREX will then store a value to that location and set a status flag if the store succeeded.
If no other access occurred between LDREX and STREX, the store succeeds atomically. The sequence can be retried if interrupted by an interrupt. LDREX R1, [R2] // Load exclusively … // operate on value … STREX R3, R1, [R2] // Attempt to store result atomically CBZ R3, done // Retry if interrupted
This construct implements an atomic read-modify-write sequence. M0 does not have LDREX/STREX support so cannot directly use this method.
Advantages
- Faster than disabling interrupts
- Does not affect interrupt latency like disabling does
Disadvantages
- Retries incur overhead if operations are frequently interrupted
- Only works for single word operations
Disabling Interrupts
All Cortex-M cores allow disabling interrupts by setting the PRIMASK register bit. This prevents preemption during a critical section: CPSID I // Disable interrupts … // Access shared resource CPSIE I // Re-enable interrupts
This approach makes a section of code atomic by preventing context switches. Simple for small critical sections on both M0 and M0+.
Advantages
- Very easy to implement
- Works on both M0 and M0+
Disadvantages
- Increases interrupt latency which can disturb real-time behavior
- Only suits small critical sections due to latency impact
Implementing atomic counters
A common use of atomics is an atomic counter accessed by multiple threads. This can be implemented on Cortex-M0/M0+ using:
LDREX / STREX (M0+ only)
UINT32 counter; void atomic_increment(void) { UINT32 tmp; UINT32 status; do { LDREX tmp, [counter] ADD tmp, tmp, #1 STREX status, tmp, [counter] } while(status != 0) }
Disable interrupts
UINT32 counter; void atomic_increment(void) { PRIMASK = 1; // Disable interrupts counter++; PRIMASK = 0; // Enable interrupts }
The interrupt disable method works for both M0 and M0+ at the cost of higher latency. LDREX/STREX is faster but only works on M0+. The best method depends on performance requirements.
Implementing thread-safe queues
Thread-safe queues allow safe access from multiple threads without locks. The key operations of enqueue and dequeue must be atomic. This can be done on Cortex-M0/M0+ using:
LDREX / STREX (M0+ only)
struct queue_t { UINT32 *buffer; size_t head; size_t tail; size_t size; }; void enqueue(struct queue_t *q, UINT32 data) { size_t head; do { LDREX head, [q->head] // Calculate new head position … // Check queue is not full … STREX status, head, [q->head] } while(status) // Update tail if wrapped around … q->buffer[head] = data; } UINT32 dequeue(struct queue_t *q) { size_t tail; UINT32 data; do { LDREX tail, [q->tail] // Check queue not empty … // Calculate return data pointer … STREX status, tail, [q->tail] } while(status) data = q->buffer[tail]; // Update head pointer if wrapped around … return data; }
Disable interrupts
void enqueue(struct queue_t *q, UINT32 data) { PRIMASK = 1; // Disable interrupts q->buffer[q->head++] = data; PRIMASK = 0; // Enable interrupts } UINT32 dequeue(struct queue_t *q) { UINT32 data; PRIMASK = 1; // Disable interrupts data = q->buffer[q->tail++]; PRIMASK = 0; // Enable interrupts return data; }
Again LDREX/STREX provides optimal performance on M0+ while interrupt disable gives a simple solution for both M0 and M0+. The queue pointers must be read/written atomically so LDREX/STREX is ideal.
Conclusion
Atomic operations are critical for building reliable multi-threaded applications. On Cortex-M0/M0+ this can be achieved using:
- LDREX/STREX instructions on M0+ – fast without affecting interrupt latency
- Disabling interrupts on both M0 and M0+ – simple to implement but affects interrupt latency
The right method depends on performance requirements. For highly critical data, LDREX/STREX is best on M0+ while interrupt disable gives a simple option when sharing is less critical.