Hello! Welcome to the second lesson in our module on "Multi-Core Programming Concepts."
In our last lesson, we contrasted the high-level architectures of Symmetric Multiprocessing (SMP) and Asymmetric Multiprocessing (AMP). We learned that whether you're balancing load across identical cores (SMP) or isolating functions on specialized cores (AMP), you will inevitably face situations where multiple cores need to access a shared resource. This could be a peripheral's control register, a message queue in shared memory, or a simple status flag.
Today, we address the fundamental problem of how to manage this shared access safely. Uncontrolled, simultaneous access leads to chaos. Our learning outcome is to implement a spinlock using ARM exclusive access instructions (LDREX/STREX) for multi-core synchronization. This is a core competency for any developer working on multi-processor systems.
The Problem: Race Conditions
Imagine two CPU cores trying to increment a shared counter in memory. The operation counter++ seems simple, but at the machine level, it's a three-step read-modify-write sequence:
- Read the current value of
counterfrom memory into a register. - Modify the value in the register (increment it).
- Write the new value from the register back to memory.
Now, consider this sequence of events with two cores, Core 0 and Core 1, both trying to increment counter when its initial value is 10:
| Time | Core 0 Action | Core 1 Action | counter in Memory |
|---|---|---|---|
| 1 | Reads counter (value = 10) |
- | 10 |
| 2 | - | Reads counter (value = 10) |
10 |
| 3 | Increments its register (to 11) | - | 10 |
| 4 | - | Increments its register (to 11) | 10 |
| 5 | Writes 11 back to counter |
- | 11 |
| 6 | - | Writes 11 back to counter |
11 |
Even though two increment operations occurred, the final value is 11, not 12. Core 1's work was completely lost because it started with a stale value. This is a classic race condition. The section of code that performs the read-modify-write is called a critical section, and we need a mechanism to ensure only one core can be inside it at a time. This property is called mutual exclusion.
Spinlocks: A Busy-Wait Solution
A spinlock is a basic and powerful synchronization primitive used to enforce mutual exclusion. The idea is simple:
- A single shared variable (the "lock") represents the state:
LOCKEDorUNLOCKED. - Before entering a critical section, a core tries to acquire the lock.
- If the lock is
UNLOCKED, the core atomically sets it toLOCKEDand enters the critical section. - If the lock is already
LOCKED, the core "spins" in a tight loop, repeatedly checking the lock until it becomes free. This is known as busy-waiting. - After leaving the critical section, the core releases the lock, setting it back to
UNLOCKED.
To see how this is structured in a real-world C codebase and to introduce the key components, let's look at an implementation from the xv6 operating system.
What is a spinlock? //Source Dive// 002
The video 'What is a spinlock?' from Low Byte Productions provides an excellent walkthrough of a spinlock implementation. It introduces the high-level functions (acquire, release) and the underlying concepts of atomic operations and memory barriers.
Please watch the segment from 41:45 to 49:47. Pay close attention to: The explanation of the __sync_lock_test_and_set primitive for atomic locking (starts around 43:30). This is a compiler built-in function that provides an atomic test-and-set. The discussion on __sync_synchronize (or fence), which is a memory barrier needed to prevent harmful instruction reordering (starts around 46:11).
The Need for Hardware Support: Atomic Operations
As the video explained, the "test and set" operation must be atomic. A C implementation like if (lock == 0) lock = 1; is not atomic and suffers from the same race condition we saw earlier. Between reading lock == 0 and writing lock = 1, another core could sneak in.
To solve this, processors provide special atomic instructions. In older ARM architectures, this was the SWP (Swap) instruction. However, SWP had performance limitations, especially in multi-core systems, as it would lock the memory bus. Modern ARMv7/v8 architectures use a more sophisticated and scalable approach: exclusive access instructions.
Let's dive into how these work.
Migrating a software application from ARMv5 to ARMv7-A/R
To understand how atomicity is implemented at the hardware level on modern ARM processors, we need to study the exclusive access instructions. This document from ARM provides a clear explanation.
Read the section titled '4.4 Replacing ARMv5 synchronization primitives with equivalent ARMv7 synchronization primitives'. Focus on the descriptions of LDREX, STREX, and CLREX. Understand how they rely on an 'exclusive access monitor' to operate.
In summary, LDREX and STREX work as a pair:
LDREX Rd, [Rn](Load-Exclusive): It loads a value from the memory address inRnintoRd. Crucially, it also "tags" this physical memory address within a local monitor inside the processor.STREX Rd, Rm, [Rn](Store-Exclusive): It attempts to store the value fromRmto the memory address inRn. The store only succeeds if the processor's exclusive tag for that address is still valid.- If the store succeeds, the memory is updated, and
Rdis set to0. - If the store fails (because another core wrote to that address, clearing the tag), the memory is not updated, and
Rdis set to1.
- If the store succeeds, the memory is updated, and
This mechanism allows you to build a robust read-modify-write sequence. You LDREX the old value, modify it, and then use STREX to write the new value. If the STREX fails, you know a race occurred, and you must retry the entire sequence.
The "exclusive monitor" is the key hardware component here. It can be local to a core or global in the memory system, depending on the memory configuration.

Implementing a Spinlock in ARM Assembly
Now we have the tools to build our spinlock. The logic for the lock function is:
- Use
LDREXto read the current state of the lock variable. - Check if it is already locked. If so, spin and retry from step 1.
- If it is unlocked, attempt to write the "locked" value using
STREX. - Check the return status of
STREX. If it failed (returned 1), another core got the lock in the tiny window between ourLDREXandSTREX. We must go back to step 1 and retry. - If
STREXsucceeded (returned 0), we now own the lock.
Let's examine a canonical assembly implementation.
Migrating a software application from ARMv5 to ARMv7-A/R
Now, let's look at a concrete assembly implementation of a spinlock using these instructions. This example from the same ARM document shows the classic 'test-and-set' loop.
Study 'Example 4-6 Spin-lock'. Trace the logic: the LDREX to read the lock state, the loop (BEQ lock) if it's already taken, and the STREX attempt to acquire it, followed by a check (BNE lock) to see if the acquisition was successful.
Here is an analysis of that spinlock code, with an unlock function added for completeness. Assume r0 holds the address of the lock variable, and a value of 0 means UNLOCKED, 1 means LOCKED.
// void lock(int *lock_addr)
lock:
// 1. Read the lock value exclusively
ldrex r1, [r0]
// 2. Check if it's already locked (is it non-zero?)
cmp r1, #0
bne lock // Spin: If not zero, loop back and try again
// 3. Try to acquire the lock. Move 1 (LOCKED) into a register
mov r1, #1
// Attempt to store 1 into the lock address. r2 gets the status.
strex r2, r1, [r0]
// 4. Check if the store succeeded.
cmp r2, #0 // Was STREX successful (status == 0)?
bne lock // No: another core got there first. Retry everything.
// 5. Success. We have the lock. But we need a memory barrier before proceeding.
dmb
// Return
bx lr
// void unlock(int *lock_addr)
unlock:
// Before releasing the lock, ensure all memory accesses in the critical section are complete.
dmb
// Atomically write 0 (UNLOCKED) to the lock address.
mov r1, #0
str r1, [r0] // A simple store is sufficient for release.
bx lr
Test your understanding!
In the unlock function above, a simple STR (Store) instruction is used instead of a LDREX/STREX pair. Why is this safe for releasing the lock?
Show answer
Releasing the lock is a simple write operation, not a read-modify-write sequence. We are unconditionally setting the lock variable to 0. This operation is already atomic at the hardware level for aligned word-sized accesses. There is no risk of a race condition because we don't care about the previous value of the lock; we are simply overwriting it. The LDREX/STREX machinery is only needed when the update depends on the previously read value.
Advanced Topic: The Crucial Role of Memory Barriers
You almost certainly noticed the DMB (Data Memory Barrier) instruction in the lock and unlock functions. This is not optional; it is essential for correctness.
Modern processors use complex techniques like out-of-order execution and store buffering to improve performance. This means the processor might reorder your memory access instructions. A compiler can do the same thing for optimization.
A lock is useless if the code it's meant to protect is reordered to execute before the lock is acquired or after it's released!
Atomic Operations With Exclusive Access
The DMB instruction is a memory barrier, and it's non-negotiable. On modern processors with 'weakly-ordered' memory models, exclusive access alone is not enough to guarantee correctness. This article explains why.
Read the short section 'Is Exclusive Access Enough?'. It highlights that barrier instructions are required in addition to exclusive locking to build a complete atomic solution.
A DMB instruction ensures ordering. In our spinlock:
- The
DMBafter acquiring the lock ensures that no memory access from within the critical section can be moved before the lock acquisition. - The
DMBbefore releasing the lock ensures that all memory accesses within the critical section are completed before the lock is released.
We will cover memory barriers in much more detail in our next lesson.
Interview Corner
This topic is a staple in interviews for embedded multi-core roles.
Q1: What is a spinlock and when is it an appropriate synchronization mechanism?
A: A spinlock is a mutual exclusion mechanism where a processor busy-waits in a loop until a lock becomes available. It's appropriate for protecting very short critical sections, typically in low-level code like device drivers or an OS kernel. For short waits, the time spent spinning is less than the overhead of putting a task to sleep and performing a context switch, which is what a mutex would do.
Q2: How would you implement a multi-core safe lock in ARM assembly? Explain the instructions you would use.
A: I would use ARM's exclusive access instructions. The core of the lock function is a loop that uses LDREX to atomically read the lock's state and tag the address. If it's free, I use STREX to attempt to write the 'locked' value. STREX will only succeed if no other core has written to that address since my LDREX. If STREX fails, I have to restart the entire sequence. To ensure correct ordering of memory operations around the critical section, I must also use a DMB memory barrier after acquiring the lock and before releasing it.
Q3: Why is a simple read-modify-write of a flag variable in C not atomic on a multi-core processor?
A: A C statement like if(flag==0) flag=1; is compiled into multiple machine instructions: a load, a compare, and a store. A different core can interrupt this sequence after the load and before the store, leading to a race condition where both cores read flag==0 and both believe they have acquired the lock. This is why hardware-level atomic instructions like LDREX/STREX are necessary.
Q4: What is the difference between a spinlock and a mutex?
A: Both provide mutual exclusion, but they differ in their waiting strategy. A spinlock uses busy-waiting, where the CPU actively spins in a loop, consuming CPU cycles. A mutex is a blocking lock, typically provided by an OS. If a task fails to acquire a mutex, the OS puts the task to sleep (into a Blocked state) and schedules another task to run. The sleeping task consumes no CPU cycles until the mutex is released and the OS wakes it up.
Conclusion
Today we've gone from a high-level problem—the race condition—down to a complete, hardware-level solution. You've learned how to build one of the most fundamental multi-core synchronization primitives.
Key Takeaways:
- Simultaneous read-modify-write operations on shared data by multiple cores lead to race conditions.
- Spinlocks provide mutual exclusion using a busy-wait strategy.
- ARM processors provide
LDREXandSTREXinstructions to implement atomic operations by using an exclusive monitor to detect intervening writes. - A correct spinlock implementation must use a memory barrier (
DMB) to prevent the processor and compiler from reordering memory accesses into or out of the critical section.
Next Lesson Preview:
We've touched on the critical importance of memory barriers. In our next lesson, we will do a deep dive into this topic. We will explore the different types of barriers (DMB, DSB, ISB), understand ARM's weakly-ordered memory model, and see practical examples of where and why these barriers are essential for writing correct multi-core code.