ARM64 inline asm 포팅 가이드 (커널/Clang 포팅 관점)

ARM64(AArch64)에서 inline asm은 문법이 어렵다기보다, 컴파일러가 더 엄격하고 최적화가 더 공격적이라서 사고가 난다. 특히 벤더 커널을 Clang으로 옮길 때는 constraint 선택, clobber, barrier 세 가지가 대부분의 원인이다.

이 글에서는 ARM64에서 inline asm이 실제로 어떻게 깨지고, 어떻게 안전하게 쓰는지를 정리한다.

AArch64 레지스터/상태를 먼저 고정하기

  • GPR: x0 ~ x30 (64-bit), w0 ~ w30 (하위 32-bit)
  • x31은 인코딩상 SP 또는 XZR로 해석된다(문맥에 따라 다름)
  • flags: NZCV (커널 asm에서 조건 플래그에 영향이 있으면 "cc" clobber를 고려)
flowchart LR
  C[C 코드 변수] -->|constraint| A[Reg Allocator]
  A --> R[x0..x30 / v0..v31]
  R -->|template| S[Inline ASM]
  S -->|outputs| C2[C 변수 갱신]
  S -->|side effects| M[memory/cc/system state]

핵심은 컴파일러가 asm 텍스트를 의미를 모르는 블랙박스로 취급한다는 점이다. 컴파일러가 아는 건 constraint와 clobber뿐이다.

ARM64에서 많이 쓰는 constraint 정리 (심화)

"r": 일반 레지스터 (GPR)

register unsigned long x;
asm volatile("add %0, %0, #1" : "+r"(x));
  • "r"는 보통 xN으로 할당된다.
  • 32-bit 연산이면 wN을 쓴다(asm 템플릿에서 %w0 같은 형태로 지정하는 패턴이 흔함)

커널 스타일 팁: 32-bit로 자르고 싶으면 %w0 / %w1을 명시하는 쪽이 안전하다.

"w": SIMD/FP 레지스터 (NEON v0..v31)

u64 val;
asm volatile(
  "fmov %d0, %1\n"
  : "=w"(/* v-reg */)  /* 예시는 개념용 */
  : "r"(val)
);
  • "w"는 GPR이 아니라 벡터/FP 레지스터로 간다.
  • 커널에서 FPSIMD를 건드리면 컨텍스트 저장/복구와 엮이므로, 일반 코드에서 남용하면 위험하다.

"I" / "J" / "K" 류: 즉시값(immediate)

ARM64는 즉시값이 아무 값이나 되는 게 아니라 인코딩 제약이 있다. 그래서 커널 코드에는 보통 __builtin_constant_p() + 분기, 또는 asm 매크로로 제약을 조절한다.

#define my_add_imm(x, imm)                                     \
do {                                                           \
    if (__builtin_constant_p(imm))                             \
        asm volatile("add %0, %0, %1" : "+r"(x) : "I"(imm));   \
    else                                                       \
        asm volatile("add %0, %0, %1" : "+r"(x) : "r"(imm));   \
} while (0)
  • 상수라고 해도 "I"가 받는 범위가 좁으면 깨진다.
  • 이 패턴은 GCC에서도 유효하지만, Clang은 즉시값 제약을 더 엄격히 적용하는 편이라 더 자주 걸린다.

"m" / "Q": 메모리 operand

  • "m": 메모리를 의미하지만, 주소 형태 제약이 느슨할 수 있다.
  • "Q": ARM 계열에서 단순 베이스 레지스터 주소 형태를 요구하는 경우가 많다(컴파일러/버전에 따라 의미가 다소 다름).

커널에서는 메모리 operand만으로 순서가 보장되지 않는다. 필요하면 "memory" clobber가 같이 가야 한다.

u64 *p = ...;
u64 v;

asm volatile("ldr %0, %1"
             : "=r"(v)
             : "m"(*p)
             : "memory");   /* 순서가 중요하면 고려 */

%0 vs %x0 vs %w0 (ARM64에서 제일 자주 헷갈리는 부분)

AArch64 inline asm에서는 같은 operand라도 어떤 폭으로 출력할지 지정할 수 있다.

  • %x0: 64-bit 레지스터 이름 사용(예: x3)
  • %w0: 32-bit 레지스터 이름 사용(예: w3)

예를 들어 32-bit add를 확실히 하고 싶다면:

u32 x;
asm volatile("add %w0, %w0, #1" : "+r"(x));

이렇게 쓰는 편이 버그가 적다.

tied operand는 +r로 정리하는 게 ARM64에서도 정답

GCC 벤더 코드에 흔한 형태:

asm volatile("eor %0, %0, %1"
             : "=r"(x)
             : "0"(x), "r"(y));

ARM64/Clang 포팅에서는 보통 아래가 더 안전하다.

asm volatile("eor %0, %0, %1"
             : "+r"(x)
             : "r"(y));
  • "+r"는 read/write를 한 operand로 모델링한다.
  • 레지스터를 묶는 "0" 표기에서 발생하는 실수를 줄여준다.

early-clobber (=&r)가 필요한 ARM64 패턴들

ARM64에서는 특히 ldxr/stxr 루프, 멀티 출력, 출력이 입력보다 먼저 덮이는 템플릿에서 early-clobber 누락이 잦다.

ldxr/stxr 기반 원자적 업데이트 (대표 케이스)

static inline u64 atomic_add_return(u64 i, u64 *p)
{
    u64 old, new;
    u32 tmp;

    asm volatile(
        "1: ldxr   %0, [%3]\n"
        "   add    %1, %0, %4\n"
        "   stxr   %w2, %1, [%3]\n"
        "   cbnz   %w2, 1b\n"
        : "=&r"(old), "=&r"(new), "=&r"(tmp)   /* early-clobber 자주 필요 */
        : "r"(p), "r"(i)
        : "memory");

    return new;
}

포인트:

  • %w2처럼 tmp는 32-bit 결과로 쓰는 경우가 많다.
  • "memory"가 없으면 컴파일러가 주변 메모리 접근을 재배치할 수 있다(원자 루프의 의미가 깨질 수 있음).
  • =&r는 입력과 같은 레지스터를 쓰지 말라는 것을 강제해, Clang의 엄격한 레지스터 모델과의 충돌을 피한다.

실전 팁: 이런 코드는 커널이 이미 검증한 매크로/헬퍼(arch/arm64의 atomic 구현)를 쓰는 편이 낫다. 벤더가 커스텀으로 짠 코드는 Clang에서 잘 깨진다.

clobber 심화: "memory""cc"의 정확한 의미

"memory" clobber

  • 컴파일러에게 이 asm은 메모리에 side-effect가 있다고 알린다.
  • 결과적으로 컴파일러가 asm 전후의 메모리 접근을 이동하지 못하게 된다(완전한 barrier에 가까운 효과).

하지만 주의할 점이 있다.

  • "memory"는 CPU barrier(dmb)가 아니다.
  • 즉, 컴파일러 재배치만 막는다. CPU 순서 보장은 dmb ish 같은 명령으로 따로 해야 한다.

"cc" clobber (ARM64에서는 NZCV)

조건 플래그를 바꾸는 asm이라면 "cc"를 넣는다.

asm volatile("subs %0, %0, #1"
             : "+r"(x)
             :
             : "cc");

ARM64에서 subs, adds, cmp 같은 명령은 NZCV에 영향을 준다. 커널 코드에서 C 코드가 플래그에 직접 의존하는 형태는 많지 않아서(대부분 asm 내부에서 분기) "cc"가 빠져도 우연히 동작하는 경우가 있는데, Clang 최적화에서 발목 잡힐 수 있다.

barrier(컴파일러/CPU) 구분을 확실히 하기

컴파일러 barrier

asm volatile("" ::: "memory");
  • 명령은 없고, 컴파일러 재배치만 막는다.

CPU barrier (ARM64 dmb)

asm volatile("dmb ish" ::: "memory");
  • CPU의 메모리 순서까지 보장한다(아키텍처 의미).
  • "memory" clobber를 같이 넣어야 컴파일러 재배치와 CPU barrier가 함께 성립한다.

system register 접근 (mrs/msr)에서 자주 깨지는 지점

예: cntvct_el0 읽기

static inline u64 read_cntvct(void)
{
    u64 v;
    asm volatile("mrs %0, cntvct_el0" : "=r"(v));
    return v;
}

문제 패턴:

  • 결과를 읽었는데 주변 코드가 재배치되면 의미가 바뀌는 경우
  • 타이밍/측정 목적이면 "memory"를 넣어 측정 전후 코드 이동을 막고 싶을 때가 있다(용도에 따라 다름)
asm volatile("mrs %0, cntvct_el0" : "=r"(v) :: "memory");

커널엔 이미 read_sysreg() 같은 매크로가 있으니, 벤더 코드가 직접 박아 쓴 asm은 그걸로 치환하는 게 포팅 안정성에 좋다.

Clang + IAS(통합 어셈블러)에서만 터지는 ARM64 패턴

벤더 .S 또는 inline asm에 아래가 섞여 있으면 IAS가 민감하게 반응한다.

  • GNU as 특화 directive
  • .arch_extension, .cpu, .fpu 처리
  • .inst 사용 방식 차이
  • 매크로/리로케이션 표현 차이

대응 전략:

  1. 원인 분리: 해당 파일만 따로 clang -c로 재현
  2. 임시 회피: make LLVM_IAS=0 (외부 GNU as 사용)
  3. 근본 해결: directive/매크로를 IAS 친화적으로 수정

디버깅/검증 루틴 (커널 포팅 실전)

해당 TU만 asm 확인

clang -S -O2 -target aarch64-linux-gnu -o test.s test.c

컴파일러가 뭘 넣었는지 더 보기

  • -fverbose-asm(가능한 환경에서)
  • objdump -drwC 또는 llvm-objdump -drwC로 최종 오브젝트 확인

asm이 맞는데 동작이 이상하다면

체크 순서:

  1. "memory" clobber가 필요한데 빠졌나?
  2. CPU barrier(dmb/dsb/isb)가 필요한데 빠졌나?
  3. +r로 충분한데 "0" tied operand로 꼬였나?
  4. early-clobber(=&r)가 필요한데 =r로 써서 레지스터가 겹쳤나?
  5. %w0/%x0 폭 지정이 맞나?

ARM64 inline asm 작성 체크리스트 (Clang 포팅용)

  • 같은 변수가 input/output이면 "+r"를 우선 사용
  • 출력이 입력보다 먼저 덮이면 "=&r" 고려
  • 메모리 의미가 있으면 "memory" clobber 검토
  • CPU 순서 보장이면 dmb ish 등 barrier 명령 + "memory"
  • flags를 건드리면 "cc" 고려
  • 레지스터 폭은 %wN/%xN으로 의도를 명시
  • 즉시값은 "I" 같은 constraint 범위를 확인하고, 안 되면 "r"로 폴백

참고 사이트

답글 남기기