kernel/services/smp/smp.html · kernel/services/smp/smp.rst 공식 원문 ↗

Zephyr 3.7.0 · Kernel · SMP

Symmetric Multiprocessing

여러 physical CPU가 모든 Zephyr thread를 대칭적으로 실행하고 spinlock과 IPI로 state를 조정합니다.

Official pathkernel/services/smp/smp.html
Source filekernel/services/smp/smp.rst
Source versionZephyr 3.7.0
TranslationFull · reviewed

본문 기준: Zephyr 3.7.0 LTS3 · 이후 유지보수 패치는 별도입니다.

Part 1

요약·해설

Application 투명성부터 per-CPU data, IPI cascade와 architecture switch contract까지 SMP 전체 수명주기를 설명합니다.

Enable

CONFIG_SMP

Locking

spinlock · local IRQ mask

Cross-CPU

IPI · fallback · cascade

Part 2

접을 수 있는 영어 원문 전체

영어 원문 전체 펼치기
원문 SHA-256 41633765F460C9B270BF636CF9605065680A80B1C9950FE6DB1906FE8FFCDE2C
.. _smp_arch:

Symmetric Multiprocessing
#########################

On multiprocessor architectures, Zephyr supports the use of multiple
physical CPUs running Zephyr application code.  This support is
"symmetric" in the sense that no specific CPU is treated specially by
default.  Any processor is capable of running any Zephyr thread, with
access to all standard Zephyr APIs supported.

No special application code needs to be written to take advantage of
this feature.  If there are two Zephyr application threads runnable on
a supported dual processor device, they will both run simultaneously.

SMP configuration is controlled under the :kconfig:option:`CONFIG_SMP` kconfig
variable.  This must be set to "y" to enable SMP features, otherwise
a uniprocessor kernel will be built.  In general the platform default
will have enabled this anywhere it's supported. When enabled, the
number of physical CPUs available is visible at build time as
:kconfig:option:`CONFIG_MP_MAX_NUM_CPUS`.  Likewise, the default for this will be the
number of available CPUs on the platform and it is not expected that
typical apps will change it.  But it is legal and supported to set
this to a smaller (but obviously not larger) number for special
purposes (e.g. for testing, or to reserve a physical CPU for running
non-Zephyr code).

Synchronization
***************

At the application level, core Zephyr IPC and synchronization
primitives all behave identically under an SMP kernel.  For example
semaphores used to implement blocking mutual exclusion continue to be
a proper application choice.

At the lowest level, however, Zephyr code has often used the
:c:func:`irq_lock`/:c:func:`irq_unlock` primitives to implement fine grained
critical sections using interrupt masking.  These APIs continue to
work via an emulation layer (see below), but the masking technique
does not: the fact that your CPU will not be interrupted while you are
in your critical section says nothing about whether a different CPU
will be running simultaneously and be inspecting or modifying the same
data!

Spinlocks
=========

SMP systems provide a more constrained :c:func:`k_spin_lock` primitive
that not only masks interrupts locally, as done by :c:func:`irq_lock`, but
also atomically validates that a shared lock variable has been
modified before returning to the caller, "spinning" on the check if
needed to wait for the other CPU to exit the lock.  The default Zephyr
implementation of :c:func:`k_spin_lock` and :c:func:`k_spin_unlock` is built
on top of the pre-existing :c:struct:`atomic_` layer (itself usually
implemented using compiler intrinsics), though facilities exist for
architectures to define their own for performance reasons.

One important difference between IRQ locks and spinlocks is that the
earlier API was naturally recursive: the lock was global, so it was
legal to acquire a nested lock inside of a critical section.
Spinlocks are separable: you can have many locks for separate
subsystems or data structures, preventing CPUs from contending on a
single global resource.  But that means that spinlocks must not be
used recursively.  Code that holds a specific lock must not try to
re-acquire it or it will deadlock (it is perfectly legal to nest
**distinct** spinlocks, however).  A validation layer is available to
detect and report bugs like this.

When used on a uniprocessor system, the data component of the spinlock
(the atomic lock variable) is unnecessary and elided.  Except for the
recursive semantics above, spinlocks in single-CPU contexts produce
identical code to legacy IRQ locks.  In fact the entirety of the
Zephyr core kernel has now been ported to use spinlocks exclusively.

Legacy irq_lock() emulation
===========================

For the benefit of applications written to the uniprocessor locking
API, :c:func:`irq_lock` and :c:func:`irq_unlock` continue to work compatibly on
SMP systems with identical semantics to their legacy versions.  They
are implemented as a single global spinlock, with a nesting count and
the ability to be atomically reacquired on context switch into locked
threads.  The kernel will ensure that only one thread across all CPUs
can hold the lock at any time, that it is released on context switch,
and that it is re-acquired when necessary to restore the lock state
when a thread is switched in.  Other CPUs will spin waiting for the
release to happen.

The overhead involved in this process has measurable performance
impact, however.  Unlike uniprocessor apps, SMP apps using
:c:func:`irq_lock` are not simply invoking a very short (often ~1
instruction) interrupt masking operation.  That, and the fact that the
IRQ lock is global, means that code expecting to be run in an SMP
context should be using the spinlock API wherever possible.

CPU Mask
********

It is often desirable for real time applications to deliberately
partition work across physical CPUs instead of relying solely on the
kernel scheduler to decide on which threads to execute.  Zephyr
provides an API, controlled by the :kconfig:option:`CONFIG_SCHED_CPU_MASK`
kconfig variable, which can associate a specific set of CPUs with each
thread, indicating on which CPUs it can run.

By default, new threads can run on any CPU.  Calling
:c:func:`k_thread_cpu_mask_disable` with a particular CPU ID will prevent
that thread from running on that CPU in the future.  Likewise
:c:func:`k_thread_cpu_mask_enable` will re-enable execution.  There are also
:c:func:`k_thread_cpu_mask_clear` and :c:func:`k_thread_cpu_mask_enable_all` APIs
available for convenience.  For obvious reasons, these APIs are
illegal if called on a runnable thread.  The thread must be blocked or
suspended, otherwise an ``-EINVAL`` will be returned.

Note that when this feature is enabled, the scheduler algorithm
involved in doing the per-CPU mask test requires that the list be
traversed in full.  The kernel does not keep a per-CPU run queue.
That means that the performance benefits from the
:kconfig:option:`CONFIG_SCHED_SCALABLE` and :kconfig:option:`CONFIG_SCHED_MULTIQ`
scheduler backends cannot be realized.  CPU mask processing is
available only when :kconfig:option:`CONFIG_SCHED_DUMB` is the selected
backend.  This requirement is enforced in the configuration layer.

SMP Boot Process
****************

A Zephyr SMP kernel begins boot identically to a uniprocessor kernel.
Auxiliary CPUs begin in a disabled state in the architecture layer.
All standard kernel initialization, including device initialization,
happens on a single CPU before other CPUs are brought online.

Just before entering the application :c:func:`main` function, the kernel
calls :c:func:`z_smp_init` to launch the SMP initialization process.  This
enumerates over the configured CPUs, calling into the architecture
layer using :c:func:`arch_cpu_start` for each one.  This function is
passed a memory region to use as a stack on the foreign CPU (in
practice it uses the area that will become that CPU's interrupt
stack), the address of a local :c:func:`smp_init_top` callback function to
run on that CPU, and a pointer to a "start flag" address which will be
used as an atomic signal.

The local SMP initialization (:c:func:`smp_init_top`) on each CPU is then
invoked by the architecture layer.  Note that interrupts are still
masked at this point.  This routine is responsible for calling
:c:func:`smp_timer_init` to set up any needed stat in the timer driver.  On
many architectures the timer is a per-CPU device and needs to be
configured specially on auxiliary CPUs.  Then it waits (spinning) for
the atomic "start flag" to be released in the main thread, to
guarantee that all SMP initialization is complete before any Zephyr
application code runs, and finally calls :c:func:`z_swap` to transfer
control to the appropriate runnable thread via the standard scheduler
API.

.. figure:: ../../../../../kernel/services/smp/smpinit.svg
   :align: center
   :alt: SMP Initialization
   :figclass: align-center

   Example SMP initialization process, showing a configuration with
   two CPUs and two app threads which begin operating simultaneously.

Interprocessor Interrupts
*************************

When running in multiprocessor environments, it is occasionally the
case that state modified on the local CPU needs to be synchronously
handled on a different processor.

One example is the Zephyr :c:func:`k_thread_abort` API, which cannot return
until the thread that had been aborted is no longer runnable.  If it
is currently running on another CPU, that becomes difficult to
implement.

Another is low power idle.  It is a firm requirement on many devices
that system idle be implemented using a low-power mode with as many
interrupts (including periodic timer interrupts) disabled or deferred
as is possible.  If a CPU is in such a state, and on another CPU a
thread becomes runnable, the idle CPU has no way to "wake up" to
handle the newly-runnable load.

So where possible, Zephyr SMP architectures should implement an
interprocessor interrupt.  The current framework is very simple: the
architecture provides at least a :c:func:`arch_sched_broadcast_ipi` call,
which when invoked will flag an interrupt on all CPUs (except the current one,
though that is allowed behavior). If the architecture supports directed IPIs
(see :kconfig:option:`CONFIG_ARCH_HAS_DIRECTED_IPIS`), then the
architecture also provides a :c:func:`arch_sched_directed_ipi` call, which
when invoked will flag an interrupt on the specified CPUs. When an interrupt is
flagged on the CPUs, the :c:func:`z_sched_ipi` function implemented in the
scheduler will get invoked on those CPUs. The expectation is that these
APIs will evolve over time to encompass more functionality (e.g. cross-CPU
calls), and that the scheduler-specific calls here will be implemented in
terms of a more general framework.

Note that not all SMP architectures will have a usable IPI mechanism
(either missing, or just undocumented/unimplemented).  In those cases
Zephyr provides fallback behavior that is correct, but perhaps
suboptimal.

Using this, :c:func:`k_thread_abort` becomes only slightly more
complicated in SMP: for the case where a thread is actually running on
another CPU (we can detect this atomically inside the scheduler), we
broadcast an IPI and spin, waiting for the thread to either become
"DEAD" or for it to re-enter the queue (in which case we terminate it
the same way we would have in uniprocessor mode).  Note that the
"aborted" check happens on any interrupt exit, so there is no special
handling needed in the IPI per se.  This allows us to implement a
reasonable fallback when IPI is not available: we can simply spin,
waiting until the foreign CPU receives any interrupt, though this may
be a much longer time!

Likewise idle wakeups are trivially implementable with an empty IPI
handler.  If a thread is added to an empty run queue (i.e. there may
have been idle CPUs), we broadcast an IPI.  A foreign CPU will then be
able to see the new thread when exiting from the interrupt and will
switch to it if available.

Without an IPI, however, a low power idle that requires an interrupt
will not work to synchronously run new threads.  The workaround in
that case is more invasive: Zephyr will **not** enter the system idle
handler and will instead spin in its idle loop, testing the scheduler
state at high frequency (not spinning on it though, as that would
involve severe lock contention) for new threads.  The expectation is
that power constrained SMP applications are always going to provide an
IPI, and this code will only be used for testing purposes or on
systems without power consumption requirements.

IPI Cascades
============

The kernel can not control the order in which IPIs are processed by the CPUs
in the system. In general, this is not an issue and a single set of IPIs is
sufficient to trigger a reschedule on the N CPUs that results with them
scheduling the highest N priority ready threads to execute. When CPU masking
is used, there may be more than one valid set of threads (not to be confused
with an optimal set of threads) that can be scheduled on the N CPUs and a
single set of IPIs may be insufficient to result in any of these valid sets.

.. note::
    When CPU masking is not in play, the optimal set of threads is the same
    as the valid set of threads. However when CPU masking is in play, there
    may be more than one valid set--one of which may be optimal.

    To better illustrate the distinction, consider a 2-CPU system with ready
    threads T1 and T2 at priorities 1 and 2 respectively. Let T2 be pinned to
    CPU0 and T1 not be pinned. If CPU0 is executing T2 and CPU1 executing T1,
    then this set is is both valid and optimal. However, if CPU0 is executing
    T1 and CPU1 is idling, then this too would be valid though not optimal.

In those cases where a single set of IPIs is not sufficient to generate a valid
set, the resulting set of executing threads are expected to be close to a valid
set, and subsequent IPIs can generally be expected to correct the situation
soon. However, for cases where neither the approximation nor the delay are
acceptable, enabling :kconfig:option:`CONFIG_SCHED_IPI_CASCADE` will allow the
kernel to generate cascading IPIs until the kernel has selected a valid set of
ready threads for the CPUs.

There are three types of costs/penalties associated with the IPI cascades--and
for these reasons they are disabled by default. The first is a cost incurred
by the CPU producing the IPI when a new thread preempts the old thread as checks
must be done to compare the old thread against the threads executing on the
other CPUs. The second is a cost incurred by the CPUs receiving the IPIs as
they must be processed. The third is the apparent sputtering of a thread as it
"winks in" and then "winks out" due to cascades stemming from the
aforementioned first cost.

SMP Kernel Internals
********************

In general, Zephyr kernel code is SMP-agnostic and, like application
code, will work correctly regardless of the number of CPUs available.
But in a few areas there are notable changes in structure or behavior.


Per-CPU data
============

Many elements of the core kernel data need to be implemented for each
CPU in SMP mode.  For example, the ``_current`` thread pointer obviously
needs to reflect what is running locally, there are many threads
running concurrently.  Likewise a kernel-provided interrupt stack
needs to be created and assigned for each physical CPU, as does the
interrupt nesting count used to detect ISR state.

These fields are now moved into a separate struct :c:struct:`_cpu` instance
within the :c:struct:`_kernel` struct, which has a ``cpus[]`` array indexed by ID.
Compatibility fields are provided for legacy uniprocessor code trying
to access the fields of ``cpus[0]`` using the older syntax and assembly
offsets.

Note that an important requirement on the architecture layer is that
the pointer to this CPU struct be available rapidly when in kernel
context.  The expectation is that :c:func:`arch_curr_cpu` will be
implemented using a CPU-provided register or addressing mode that can
store this value across arbitrary context switches or interrupts and
make it available to any kernel-mode code.

Similarly, where on a uniprocessor system Zephyr could simply create a
global "idle thread" at the lowest priority, in SMP we may need one
for each CPU.  This makes the internal predicate test for "_is_idle()"
in the scheduler, which is a hot path performance environment, more
complicated than simply testing the thread pointer for equality with a
known static variable.  In SMP mode, idle threads are distinguished by
a separate field in the thread struct.

Switch-based context switching
==============================

The traditional Zephyr context switch primitive has been :c:func:`z_swap`.
Unfortunately, this function takes no argument specifying a thread to
switch to.  The expectation has always been that the scheduler has
already made its preemption decision when its state was last modified
and cached the resulting "next thread" pointer in a location where
architecture context switch primitives can find it via a simple struct
offset.  That technique will not work in SMP, because the other CPU
may have modified scheduler state since the current CPU last exited
the scheduler (for example: it might already be running that cached
thread!).

Instead, the SMP "switch to" decision needs to be made synchronously
with the swap call, and as we don't want per-architecture assembly
code to be handling scheduler internal state, Zephyr requires a
somewhat lower-level context switch primitives for SMP systems:
:c:func:`arch_switch` is always called with interrupts masked, and takes
exactly two arguments.  The first is an opaque (architecture defined)
handle to the context to which it should switch, and the second is a
pointer to such a handle into which it should store the handle
resulting from the thread that is being switched out.
The kernel then implements a portable :c:func:`z_swap` implementation on top
of this primitive which includes the relevant scheduler logic in a
location where the architecture doesn't need to understand it.

Similarly, on interrupt exit, switch-based architectures are expected
to call :c:func:`z_get_next_switch_handle` to retrieve the next thread to
run from the scheduler. The argument to :c:func:`z_get_next_switch_handle`
is either the interrupted thread's "handle" reflecting the same opaque type
used by :c:func:`arch_switch`, or NULL if that thread cannot be released
to the scheduler just yet. The choice between a handle value or NULL
depends on the way CPU interrupt mode is implemented.

Architectures with a large CPU register file would typically preserve only
the caller-saved registers on the current thread's stack when interrupted
in order to minimize interrupt latency, and preserve the callee-saved
registers only when :c:func:`arch_switch` is called to minimize context
switching latency. Such architectures must use NULL as the argument to
:c:func:`z_get_next_switch_handle` to determine if there is a new thread
to schedule, and follow through with their own :c:func:`arch_switch` or
derivative if so, or directly leave interrupt mode otherwise.
In the former case it is up to that switch code to store the handle
resulting from the thread that is being switched out in that thread's
"switch_handle" field after its context has fully been saved.

Architectures whose entry in interrupt mode already preserves the entire
thread state may pass that thread's handle directly to
:c:func:`z_get_next_switch_handle` and be done in one step.

Note that while SMP requires :kconfig:option:`CONFIG_USE_SWITCH`, the reverse is not
true.  A uniprocessor architecture built with :kconfig:option:`CONFIG_SMP` set to No might
still decide to implement its context switching using
:c:func:`arch_switch`.

API Reference
**************

.. doxygengroup:: spinlock_apis

Part 3

한국어 전문 번역

Multiprocessor architecture에서 Zephyr는 여러 physical CPU가 application code를 실행하도록 지원합니다. 특정 CPU를 기본적으로 특별 취급하지 않는다는 의미에서 symmetric이며, 모든 processor가 모든 Zephyr thread와 지원 API를 실행할 수 있습니다.

Application은 SMP를 위해 특별한 code를 작성할 필요가 없습니다. Dual processor에서 runnable application thread가 두 개면 동시에 실행됩니다. CONFIG_SMP를 y로 설정하면 SMP kernel을, 아니면 uniprocessor kernel을 build합니다.

CONFIG_MP_MAX_NUM_CPUS는 build time에 사용할 physical CPU 수입니다. 보통 platform의 실제 CPU 수가 default이고 일반 application은 바꾸지 않지만, test 또는 non-Zephyr code 전용 CPU 예약을 위해 실제 수보다 작게 설정할 수 있습니다. 실제 수보다 크게는 설정할 수 없습니다.

동기화

Application 수준의 Zephyr IPC와 synchronization primitive는 SMP에서도 동일하게 동작합니다. 예를 들어 blocking mutual exclusion에는 semaphore를 계속 사용할 수 있습니다.

Spinlock

k_spin_lock()은 local interrupt를 mask하는 동시에 shared lock variable을 atomic하게 검사·변경합니다. 다른 CPU가 lock을 보유하면 해제할 때까지 spin합니다. Default 구현은 보통 compiler intrinsic 기반 atomic_ layer 위에 있지만 architecture가 성능을 위해 자체 구현할 수 있습니다.

IRQ lock은 global이라 critical section 안에서 recursive acquire가 자연스럽게 가능했습니다. Spinlock은 subsystem이나 data structure별로 여러 개를 둘 수 있어 global contention을 줄이지만, 같은 lock을 recursive하게 다시 획득하면 deadlock됩니다. 서로 다른 spinlock을 nested acquire하는 것은 허용되며 validation layer가 오류를 감지할 수 있습니다.

Uniprocessor에서는 atomic lock variable이 필요 없어 제거됩니다. Recursive semantics 차이를 제외하면 single-CPU spinlock은 legacy IRQ lock과 같은 code가 되며 Zephyr core kernel은 모두 spinlock 사용으로 port됐습니다.

Legacy irq_lock() emulation

SMP의 irq_lock()/irq_unlock()은 nesting count가 있는 single global spinlock으로 구현됩니다. 모든 CPU에서 한 thread만 보유하게 하고 context switch 때 release하며, locked thread로 돌아올 때 state 복원을 위해 atomic하게 reacquire합니다. 다른 CPU는 그동안 spin합니다.

이 과정은 uniprocessor의 약 한 instruction짜리 interrupt mask보다 측정 가능한 overhead가 크고 global lock contention도 만듭니다. SMP에서 실행될 code는 가능한 한 spinlock API를 사용해야 합니다.

CPU mask

Real-time application은 scheduler에 전적으로 맡기지 않고 physical CPU별로 work를 분할할 수 있습니다. CONFIG_SCHED_CPU_MASK는 thread마다 실행 가능한 CPU set을 연결합니다.

새 thread는 기본적으로 모든 CPU에서 실행할 수 있습니다. k_thread_cpu_mask_disable()은 특정 CPU 실행을 막고 enable()은 다시 허용합니다. clear()와 enable_all()도 있습니다. Runnable thread에는 호출할 수 없고 blocked 또는 suspended 상태여야 하며 아니면 -EINVAL을 반환합니다.

SMP boot process

SMP kernel은 uniprocessor와 똑같이 boot를 시작합니다. Auxiliary CPU는 architecture layer에서 disabled 상태이고 device initialization을 포함한 standard kernel initialization은 한 CPU에서 끝냅니다.

Application main() 직전에 kernel이 z_smp_init()을 호출해 configured CPU를 순회하고 각각 arch_cpu_start()를 부릅니다. Foreign CPU용 stack memory(실제로 그 CPU의 interrupt stack), 그 CPU에서 실행할 smp_init_top() callback 주소, atomic signal로 쓸 start flag pointer를 전달합니다.

각 CPU의 smp_init_top()은 interrupt가 mask된 상태에서 실행됩니다. Per-CPU timer가 필요한 architecture에서는 smp_timer_init()으로 auxiliary timer state를 설정하고, main thread가 atomic start flag를 release할 때까지 spin합니다. 모든 SMP init이 끝난 뒤 z_swap()으로 standard scheduler가 고른 runnable thread에 control을 넘깁니다.

두 CPU와 두 application thread의 SMP initialization
CPU0 · primary

Kernel·device initialization → z_smp_init()

CPU1 · auxiliary

arch_cpu_start() → interrupt stack → smp_init_top()

Per-CPU init

smp_timer_init() 후 atomic start flag에서 spin

동시 시작

CPU0가 flag release → 각 CPU가 z_swap() → app thread 2개 실행

모든 application code는 SMP initialization이 끝난 뒤 시작합니다.

Interprocessor interrupt

Local CPU가 바꾼 state를 다른 processor가 synchronous하게 처리해야 할 때가 있습니다. k_thread_abort()는 abort한 thread가 더 이상 runnable이 되기 전 반환할 수 없는데 대상이 다른 CPU에서 실행 중일 수 있습니다. Low-power idle CPU도 다른 CPU에서 새 thread가 runnable이 됐음을 스스로 알 수 없습니다.

가능한 SMP architecture는 IPI를 구현해야 합니다. arch_sched_broadcast_ipi()는 보통 current CPU를 제외한 모든 CPU에 interrupt를 걸고, CONFIG_ARCH_HAS_DIRECTED_IPIS가 있으면 arch_sched_directed_ipi()가 지정 CPU만 대상으로 합니다. Interrupt를 받은 CPU에서는 scheduler의 z_sched_ipi()가 실행됩니다. 이 scheduler-specific API는 향후 cross-CPU call을 포함하는 일반 framework로 발전할 수 있습니다.

사용 가능한 IPI가 없는 architecture에는 정확하지만 느린 fallback이 있습니다. 다른 CPU에서 실행 중인 thread를 abort할 때 broadcast IPI 뒤 thread가 DEAD가 되거나 queue로 돌아오기를 spin하며 기다립니다. Aborted check는 모든 interrupt exit에서 하므로 IPI handler 자체에 특별 logic이 필요 없습니다. IPI가 없으면 foreign CPU가 아무 interrupt나 받을 때까지 기다립니다.

Idle run queue에 새 thread가 들어오면 empty IPI handler만으로도 idle CPU를 깨울 수 있습니다. IPI가 없으면 interrupt가 필요한 low-power idle은 새 thread를 synchronous하게 실행할 수 없으므로 system idle handler에 들어가지 않고 idle loop에서 scheduler state를 자주 검사합니다. Lock contention을 피하려고 state 자체를 붙잡고 spin하지는 않습니다. Power-constrained SMP application은 IPI를 제공해야 하며 fallback은 test 또는 power 요구가 없는 system용입니다.

IPI cascade

Kernel은 CPU들이 IPI를 처리하는 순서를 통제하지 못합니다. CPU mask가 없다면 한 번의 IPI set으로 N개 CPU가 priority가 가장 높은 N개 ready thread를 schedule할 수 있습니다. CPU mask가 있으면 valid thread set이 여러 개일 수 있어 한 번으로 valid set에 도달하지 못할 수 있습니다.

후속 IPI가 보통 근사 상태를 곧 교정하지만 delay를 허용할 수 없다면 CONFIG_SCHED_IPI_CASCADE를 켜 valid ready-thread set을 고를 때까지 cascading IPI를 만듭니다.

Cascade가 default로 꺼진 이유는 세 비용 때문입니다. 새 thread가 old thread를 preempt할 때 sender CPU가 다른 CPU의 running thread와 비교하는 비용, receiver CPU가 IPI를 처리하는 비용, cascade 때문에 thread가 잠깐 실행됐다 다시 사라지는 겉보기 sputtering 비용입니다.

SMP kernel internals

대부분의 Zephyr kernel code는 CPU 수를 몰라도 동작하지만 일부 구조와 behavior는 달라집니다.

Per-CPU data

_current thread pointer, kernel interrupt stack, ISR state 판단용 interrupt nesting count는 CPU마다 필요합니다. 이 field들은 _kernel 안의 별도 _cpu structure로 이동했고 cpus[] array를 ID로 index합니다. Legacy uniprocessor code가 예전 syntax와 assembly offset으로 cpus[0] field를 읽을 수 있도록 compatibility field도 제공합니다.

Kernel context에서 current CPU struct pointer를 빠르게 얻어야 합니다. arch_curr_cpu()는 arbitrary context switch와 interrupt를 지나도 값을 보존하고 kernel code가 즉시 읽을 수 있는 CPU register 또는 addressing mode를 사용해야 합니다.

Uniprocessor의 global idle thread 하나와 달리 SMP는 CPU마다 idle thread가 필요합니다. Hot path인 scheduler의 _is_idle()가 static pointer equality만 검사할 수 없으므로 SMP에서는 thread struct의 별도 field로 idle thread를 구분합니다.

Switch-based context switching

전통적 z_swap()은 switch할 thread argument를 받지 않고 scheduler가 미리 계산해 cache한 next-thread pointer를 architecture code가 struct offset으로 읽었습니다. SMP에서는 다른 CPU가 그 사이 scheduler state를 바꾸거나 그 cached thread를 이미 실행할 수 있어 이 방식이 맞지 않습니다.

SMP의 switch-to 결정은 swap과 synchronous해야 합니다. Architecture assembly가 scheduler 내부를 다루지 않도록 low-level arch_switch()는 interrupt가 mask된 상태에서 두 argument를 받습니다. 첫째는 switch할 context의 opaque architecture handle, 둘째는 switch-out thread에서 나온 handle을 저장할 pointer입니다. Portable z_swap()이 이 primitive 위에서 scheduler logic을 수행합니다.

Interrupt exit에서는 z_get_next_switch_handle()로 next thread를 얻습니다. Argument는 interrupted thread의 opaque handle이거나, 아직 scheduler에 release할 수 없으면 NULL입니다. 선택은 CPU interrupt mode 구현에 달렸습니다.

Register file이 큰 architecture는 interrupt latency를 줄이려고 entry에서 caller-saved register만 stack에 보존하고 arch_switch()에서 callee-saved register를 보존하는 경우가 많습니다. 이때 z_get_next_switch_handle(NULL)로 새 thread를 확인하고, 있으면 자체 arch_switch 계열을 실행하며 없으면 interrupt mode를 바로 나갑니다. Switch code는 context를 완전히 저장한 뒤 resulting handle을 thread의 switch_handle field에 저장해야 합니다.

Interrupt entry에서 전체 thread state를 이미 보존하는 architecture는 handle을 z_get_next_switch_handle()에 직접 전달해 한 단계로 끝낼 수 있습니다. SMP는 CONFIG_USE_SWITCH가 필요하지만 반대는 성립하지 않아 CONFIG_SMP=No인 uniprocessor도 arch_switch()를 사용할 수 있습니다.

API 참고: spinlock_apis

K_SPINLOCK의 사용 예제입니다.

K_SPINLOCK(&mylock) {

  ...execute statements with the lock held...

  if (some_condition) {
    ...release the lock and leave the guarded section prematurely:
    K_SPINLOCK_BREAK;
  }

  ...execute statements with the lock held...

}

내부적으로는 body를 정확히 한 번 실행하는 for-loop pattern으로 확장됩니다.

for (k_spinlock_key_t key = k_spin_lock(&mylock); ...; k_spin_unlock(&mylock, key)) {
    ...
}
K_SPINLOCK_BREAK

K_SPINLOCK으로 보호한 code block에서 lock을 해제한 뒤 조기에 빠져나옵니다.

K_SPINLOCK(lck)

주어진 spinlock을 자동으로 획득한 뒤 code block을 정확히 한 번 실행하고, block 끝 또는 K_SPINLOCK_BREAK에서 lock을 자동 해제합니다.

매개변수

  • lck: 둘러싼 code block을 보호할 spinlock
k_spinlock_key_t

k_spin_lock() 호출 시 system interrupt state를 저장하고 대응하는 k_spin_unlock()에 전달하는 opaque key type입니다. Application code가 내부 값을 검사하면 안 됩니다.

k_spin_lock(l)

Spinlock을 잠그고 unlock에 필요한 interrupt-state key를 반환합니다. 반환 뒤 caller는 현재 CPU에서 suspend·interrupt되지 않으며, system 전체에서 정확히 한 CPU의 한 thread만 lock을 보유합니다. 다른 CPU는 해제될 때까지 implementation-defined busy loop에서 spin합니다.

서로 다른 spinlock은 nested acquire할 수 있지만 같은 lock은 recursive하게 획득할 수 없으며 deadlock됩니다. Single CPU에서는 spin 없이 interrupt masking으로 최적화할 수 있습니다.

매개변수

  • l: 잠글 spinlock pointer

반환값

  • k_spinlock_key_t: 해제할 때 같은 lock의 k_spin_unlock()에 전달할 key
k_spin_trylock(l, k)

Spinlock 획득을 한 번만 시도하며 성공하면 key를 k에 저장합니다.

매개변수

  • l: 잠글 spinlock pointer
  • k: spinlock key를 받을 output pointer

반환값

  • 0: 성공
  • -EBUSY: 다른 thread가 lock을 보유 중
k_spin_unlock(l, key)

k_spin_lock()으로 얻은 lock을 해제해 다른 CPU가 획득할 수 있게 합니다. 기다리는 CPU가 있으면 그중 정확히 하나가 lock을 보유한 채 반환합니다.

가장 최근에 획득한 lock을 그때 반환된 정확한 key로 해제해야 합니다. 잘못 nested된 unlock, 보유하지 않은 lock 해제, 다른 key 전달은 illegal이며 CONFIG_SPIN_VALIDATE가 일부 오류를 감지할 수 있습니다.

매개변수

  • l: 해제할 spinlock pointer
  • key: 이 lock을 획득한 k_spin_lock()이 반환한 값
k_spinlock

CPU가 k_spin_lock()으로 기다릴 수 있는 kernel spin lock record입니다. Application code에서 필요한 수만큼 정의할 수 있으며 include 경로는 spinlock.h입니다.

Source

출처

영어 원문에는 문단, 문서 지시문, 표, 코드, 코드 심벌, 경로를 그대로 보존했습니다.