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사용 방식 차이- 매크로/리로케이션 표현 차이
대응 전략:
- 원인 분리: 해당 파일만 따로
clang -c로 재현 - 임시 회피:
make LLVM_IAS=0(외부 GNU as 사용) - 근본 해결: directive/매크로를 IAS 친화적으로 수정
디버깅/검증 루틴 (커널 포팅 실전)
해당 TU만 asm 확인
clang -S -O2 -target aarch64-linux-gnu -o test.s test.c
컴파일러가 뭘 넣었는지 더 보기
-fverbose-asm(가능한 환경에서)objdump -drwC또는llvm-objdump -drwC로 최종 오브젝트 확인
asm이 맞는데 동작이 이상하다면
체크 순서:
"memory"clobber가 필요한데 빠졌나?- CPU barrier(dmb/dsb/isb)가 필요한데 빠졌나?
+r로 충분한데"0"tied operand로 꼬였나?- early-clobber(
=&r)가 필요한데=r로 써서 레지스터가 겹쳤나? %w0/%x0폭 지정이 맞나?
ARM64 inline asm 작성 체크리스트 (Clang 포팅용)
- 같은 변수가 input/output이면
"+r"를 우선 사용 - 출력이 입력보다 먼저 덮이면
"=&r"고려 - 메모리 의미가 있으면
"memory"clobber 검토 - CPU 순서 보장이면
dmb ish등 barrier 명령 +"memory" - flags를 건드리면
"cc"고려 - 레지스터 폭은
%wN/%xN으로 의도를 명시 - 즉시값은
"I"같은 constraint 범위를 확인하고, 안 되면"r"로 폴백
참고 사이트
- Linux kernel 문서: Building Linux with Clang/LLVM
- LLVM Language Reference: Inline Assembler Expressions
- 커널
arch/arm64의 atomic/asm 구현은 실제 검증된 정답 참고용으로 매우 좋다(벤더 커널이라도 해당 디렉터리와 비교해볼 것을 추천)