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

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

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

0) 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뿐이다.

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

1-1. "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를 명시하는 쪽이 안전하다.

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

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

1-3. "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에서 더 자주 터진다(즉시값 제약을 더 엄격히 적용하는 편).

1-4. "m" / "Q": 메모리 operand

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

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

u64 *p = ...;
u64 v;

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

2) %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));

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

3) 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") 실수를 줄임

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

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

4-1) 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에서 잘 깨진다.

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

5-1) "memory" clobber

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

하지만 주의:

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

5-2) "cc" clobber (ARM64에서는 NZCV)

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

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

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

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

6-1) 컴파일러 barrier

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

6-2) CPU barrier (ARM64 dmb)

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

7) 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은 그걸로 치환하는 게 포팅 안정성에 좋다.

8) 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 친화적”으로 수정

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

9-1) 해당 TU만 asm 확인

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

9-2) 컴파일러가 뭘 넣었는지 더 보기

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

9-3) “asm이 맞는데 동작이 이상하다”면

체크 순서:

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

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

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

참고:

답글 남기기