x86에는 IRQ 마스크와 무관하게 항상 들어오는 진짜 NMI(Non-Maskable Interrupt) 핀이 있지만, ARM에는 그런 하드웨어가 없다. 그런데 PMU(성능 카운터) 오버플로우 인터럽트처럼 “일반 IRQ가 막혀 있는 구간에서도 반드시 들어와야” 정확한 프로파일링이 되는 인터럽트가 있다. GICv3는 8비트 우선순위 필드를 이용해 이 요구를 소프트웨어적으로 흉내내는데, 이게 Pseudo-NMI다(진짜 NMI 핀이 아니라 우선순위로 흉내 낸 것이라 “가짜”). 이 글에서는 실제 커널 소스로 이 메커니즘의 동작 방식과 실제 사용처를 정리한다.
우선순위 3단계
GIC는 숫자가 낮을수록 우선순위가 높다. PMR(Priority Mask Register)에 값을 쓰면 그 값보다 낮은 숫자(=더 높은 우선순위)의 인터럽트만 통과시킨다. 커널은 이 필드에 쓸 값 3가지를 미리 정의해둔다.
/*
* GICV3_PRIO_UNMASKED is the PMR view of the priority to use to permit both
* IRQs and pseudo-NMIs.
*
* GICV3_PRIO_IRQ is the PMR view of the priority of regular interrupts. This
* can be written to the PMR to mask regular IRQs.
*
* GICV3_PRIO_NMI is the PMR view of the priority of pseudo-NMIs. This can be
* written to the PMR to mask pseudo-NMIs.
*/
#define GICV3_PRIO_UNMASKED 0xe0
#define GICV3_PRIO_IRQ 0xc0
#define GICV3_PRIO_NMI 0x80
static_assert(GICV3_PRIO_NMI < GICV3_PRIO_IRQ);
static_assert(GICV3_PRIO_IRQ < GICV3_PRIO_UNMASKED);일반 인터럽트는 우선순위 0xc0으로 등록되고, Pseudo-NMI로 취급할 인터럽트는 그보다 낮은(=더 높은 우선순위인) 0x80으로 등록된다. PMR에 0xc0(GICV3_PRIO_IRQ)을 쓰면 0xc0 이상인 일반 인터럽트는 막히지만, 0x80으로 등록된 인터럽트는 그대로 통과한다 — 이게 “마스킹을 뚫고 들어온다”는 말의 실체다.
local_irq_disable()이 실제로 하는 일
arm64는 원래 PSTATE.I 비트(daifset/daifclr 명령)로 IRQ를 켜고 끈다. Pseudo-NMI가 활성화된 시스템에서는 이 대신 ICC_PMR_EL1에 우선순위 값을 쓰는 방식으로 바뀐다.
static __always_inline void __pmr_local_irq_enable(void)
{
write_sysreg_s(GIC_PRIO_IRQON, SYS_ICC_PMR_EL1);
pmr_sync();
}
static __always_inline void __pmr_local_irq_disable(void)
{
write_sysreg_s(GIC_PRIO_IRQOFF, SYS_ICC_PMR_EL1);
}
static __always_inline void arch_local_irq_disable(void)
{
if (system_uses_irq_prio_masking()) {
__pmr_local_irq_disable();
} else {
__daif_local_irq_disable(); /* msr daifset, #3 */
}
}즉 커널 어디선가 local_irq_disable()을 불러도, Pseudo-NMI가 켜진 시스템에서는 실제로는 PSTATE.I를 세우는 게 아니라 PMR에 0xc0을 쓸 뿐이다. 우선순위 0x80으로 등록된 인터럽트 입장에서는 아무것도 막히지 않은 것과 같다.
커널 설정
CONFIG_ARM64_PSEUDO_NMI는 커널 빌드 옵션에서 다음과 같이 설명된다.
config ARM64_PSEUDO_NMI
bool "Support for NMI-like interrupts"
help
Adds support for mimicking Non-Maskable Interrupts through the use of
GIC interrupt priority. This support requires version 3 or later of
ARM GIC.
This high priority configuration for interrupts needs to be
explicitly enabled by setting the kernel parameter
"irqchip.gicv3_pseudo_nmi" to 1.
If unsure, say N빌드 옵션을 켜는 것만으로는 부족하고, 부팅 파라미터 irqchip.gicv3_pseudo_nmi=1을 줘야 실제로 동작한다. 이 조건이 다 갖춰져야 system_uses_irq_prio_masking()이 ARM64_HAS_GIC_PRIO_MASKING CPU capability를 보고 true를 반환하고, 앞서 본 arch_local_irq_disable()이 PMR 경로를 타기 시작한다.
실제 사용처 — ARM PMU 드라이버
PMU 오버플로우 인터럽트는 IRQ가 막힌 코드 구간(예: 다른 인터럽트 핸들러 안)에서도 정확한 스택을 뜨려면 그 구간을 뚫고 들어와야 한다. drivers/perf/arm_pmu.c는 먼저 NMI로 요청해보고, 실패하면(Pseudo-NMI가 꺼져 있는 등) 일반 IRQ로 물러난다.
err = request_percpu_nmi(irq, handler, "arm-pmu", affinity, pcpu_armpmu);
/* If cannot get an NMI, get a normal interrupt */
if (err) {
err = request_percpu_irq_affinity(irq, handler, "arm-pmu",
affinity, pcpu_armpmu);
irq_ops = &percpu_pmuirq_ops;
} else {
has_nmi = true;
irq_ops = &percpu_pmunmi_ops;
}request_percpu_nmi()가 실패하는 조건이 바로 앞 절의 두 조건(CONFIG_ARM64_PSEUDO_NMI 빌드 여부 + irqchip.gicv3_pseudo_nmi=1 부팅 여부)이 안 갖춰졌을 때다. 이 경우 perf는 여전히 동작하지만, IRQ가 막힌 구간에서 발생한 오버플로우는 그 구간이 끝날 때까지 지연된다.
주의사항
| 항목 | 내용 |
|---|---|
| 기본값은 꺼져 있다 | CONFIG_ARM64_PSEUDO_NMI를 켜서 빌드해도 부팅 파라미터 irqchip.gicv3_pseudo_nmi=1을 명시하지 않으면 동작하지 않는다(Kconfig 기본값도 N) |
| GICv3 이상 필수 | GICv2 이하에는 이만큼 세밀한 우선순위 마스킹 메커니즘이 없어 이 기능 자체가 성립하지 않는다 |
| “가짜”라는 이름의 이유 | 진짜 NMI 핀이 아니라 인터럽트 컨트롤러의 우선순위 마스킹을 이용한 소프트웨어적 흉내다. PMR 자체를 조작하는 극히 일부 코드 경로(초저수준 예외 처리 등)에서는 이 마스킹이 통하지 않을 수 있다 |
| 이 글의 코드는 소스 인용이다 | Linux 커널 v7.1.5 기준 elixir.bootlin.com에서 실제 소스를 확인해 인용했으며, 실제 GICv3 하드웨어에서 부팅 파라미터를 켜고 동작을 검증하지는 않았다 |
마무리
Pseudo-NMI는 새로운 하드웨어 기능이 아니라, GICv3가 이미 갖고 있던 우선순위 마스킹 레지스터(PMR)를 커널이 영리하게 재활용한 결과다. local_irq_disable()의 구현을 daifset에서 PMR 쓰기로 바꿔치기하고, PMU 같은 특정 인터럽트만 그보다 낮은 우선순위 값으로 등록해두는 것만으로 x86의 NMI와 비슷한 효과를 낸다. 커널 빌드 옵션과 부팅 파라미터가 둘 다 갖춰져야 실제로 켜진다는 점만 기억해두면, ARM 플랫폼에서 profiling 인터럽트가 예상대로 안 들어온다는 문제를 만났을 때 이 두 가지부터 확인해볼 수 있다.