C++ 인라인 어셈블리: GCC 확장 asm 제약 문자·clobber, MSVC 차이, 인트린식이 나은 경우

이 글의 핵심

인라인 어셈블리는 컴파일러가 코드 안을 들여다보지 못하게 만드는 도구라서, 제약과 clobber를 하나만 잘못 적어도 최적화 단계에서 레지스터가 덮어써지는 버그가 조용히 생깁니다. volatile을 남용하는 습관, early-clobber의 & 누락, std::atomic과 겹치는 배리어 문제를 짚고, 대부분의 SIMD 작업은 인트린식이나 컴파일러 builtin으로 충분하다는 판단 기준과 asm을 한곳에 가두는 프로덕션 패턴을 정리했습니다.

들어가며

인라인 어셈블리는 C++ 함수 안에 기계어 명령을 직접 끼워 넣는 컴파일러 확장입니다. 표준 C++에는 asm 선언이 있지만 그 의미는 전부 구현 정의라서, 실제 문법과 동작은 GCC·Clang과 MSVC가 완전히 다릅니다. 컴파일러는 asm 블록 안을 분석하지 않고 개발자가 적어 준 제약만 믿고 주변 코드를 최적화하기 때문에, 제약을 잘못 적으면 디버그 빌드에서는 멀쩡하다가 -O2에서만 값이 깨지는 버그가 생깁니다.

이 글은 GCC/Clang 확장 asm의 문법과 제약·클로버를 정확히 쓰는 법, MSVC와의 차이, 메모리 배리어와의 관계를 다루고, 대부분의 경우 인트린식이나 내장 함수가 더 나은 이유를 함께 설명합니다.


기본 문법

GCC/Clang (AT&T 문법)

#include <iostream>

int main() {
    int x = 10;
    int y = 20;
    int result;

    asm("addl %1, %0"
        : "=r"(result)        // 출력 %0
        : "r"(x), "0"(y)      // 입력 %1, %2(%0과 같은 레지스터)
    );

    std::cout << result << '\n';  // 30
}

"0"(y)는 y를 출력 0번과 같은 레지스터에 넣으라는 매칭 제약입니다. 그래서 addl %1, %0은 “y가 들어 있는 레지스터에 x를 더한다”가 되고, 그 레지스터가 곧 result가 됩니다. AT&T 문법은 명령 원본, 대상 순서이고 레지스터 앞에 %를 붙입니다. GCC·Clang에서 -masm=intel을 주면 Intel 문법 템플릿을 쓸 수도 있습니다.

MSVC (Intel 문법, x86 32비트 전용)

#include <iostream>

int main() {
    int x = 10;
    int y = 20;
    int result;

    __asm {
        mov eax, x
        add eax, y
        mov result, eax
    }

    std::cout << result << '\n';  // 30
}

MSVC의 __asm 블록은 C++ 변수 이름을 그대로 쓸 수 있어 읽기 쉽지만, 32비트 x86 대상에서만 동작합니다. x64와 ARM64 컴파일러는 인라인 어셈블리를 아예 지원하지 않으므로, 64비트 코드에서는 인트린식(<intrin.h>)이나 별도 .asm 파일을 써야 합니다. 또 MSVC는 블록 안에서 쓰인 레지스터를 스스로 파악해 저장·복원하므로 클로버 개념이 없는 대신, 블록 주변의 최적화가 크게 제한됩니다.


GCC 확장 asm(Extended asm) 문법

GCC·Clang의 인라인 asm은 단순히 문자열을 끼워 넣는 것이 아니라, 템플릿, 출력·입력 피연산자, 클로버 목록이 결합된 작은 언어에 가깝습니다. 컴파일러는 템플릿 안의 %0, %1 …을 레지스터 할당 결과로 치환한 문자열을 어셈블러에 넘길 뿐, 명령의 의미는 전혀 모릅니다.

출력을 쓰지 않는 asm이 volatile 유무에 따라 제거되거나 남는 x86_64 어셈블리와 확장 asm에서 %eax를 한 번만 쓸 때의 오류

그림: 컴파일 결과 — x86_64 대상으로 -O2 -S를 한 어셈블리다(이 맥은 arm64라 실행하지는 않았다). 출력 r을 쓰지 않는 asm은 volatile이 없으면 통째로 사라지고, asm volatile과 출력 없는 asm(“nop”)은 남는다. 피연산자가 있는 확장 asm에서 %eax를 한 번만 쓰면 “invalid % escape” 오류가 난다. (환경: macOS 27.0.1 · Apple clang 21.0.0 · x86_64 대상, 컴파일만(실행 안 함))

템플릿과 피연산자 번호

기본 형태는 asm [volatile] ( 템플릿 : 출력 : 입력 : 클로버 )입니다. 피연산자는 출력부터 시작해 입력까지 0번부터 차례로 번호가 매겨지고, 템플릿에서는 %n으로 참조합니다. [name] "r"(var)처럼 이름을 붙이고 %[name]으로 참조하면 번호를 세다가 틀리는 실수를 줄일 수 있습니다.

피연산자가 있는 확장 asm에서는 %가 치환 기호이므로, 레지스터 이름을 직접 쓰려면 %%eax처럼 퍼센트를 두 번 적습니다. 반대로 피연산자가 하나도 없는 기본(basic) asm에서는 치환이 일어나지 않아 %eax라고 한 번만 씁니다. 두 형식을 섞어 쓰다 어셈블러 에러가 나는 일이 흔합니다.

레지스터 제약 문자 (x86)

int add(int a, int b) {
    int result;
    asm("addl %2, %0"
        : "=r"(result)        // 출력 %0: 아무 범용 레지스터
        : "0"(a), "r"(b)      // 입력 %1(%0과 같은 레지스터), %2
        : "cc"                // add는 플래그를 바꿈
    );
    return result;
}
제약의미
r아무 범용 레지스터
a, b, c, deax/rax, ebx/rbx, ecx/rcx, edx/rdx
S, Desi/rsi, edi/rdi
m메모리 피연산자
i정수 즉시값
g레지스터, 메모리, 즉시값 중 아무것이나
xSSE 레지스터(xmm)
숫자("0")해당 번호 피연산자와 같은 위치

수식자: =, +, &

  • =: 쓰기 전용 출력입니다. 컴파일러는 asm이 이전 값을 읽지 않는다고 가정합니다.
  • +: 읽기-쓰기 피연산자입니다. 입력이면서 출력이므로, 위의 "=r"(result) + "0"(a) 조합은 result = a; 뒤 "+r"(result)로 써도 같은 의미가 됩니다. 매칭 제약보다 의도가 분명해서 대개 +가 읽기 쉽습니다.
  • &: 조기 손상(early-clobber) 출력입니다. 아래 클로버 절에서 설명합니다.

제약을 "g"처럼 넓게 잡으면 컴파일러가 메모리 피연산자를 골라 불필요한 로드·스토어가 생길 수 있고, 특정 레지스터로 좁게 잡으면 그 레지스터를 비우기 위한 이동이나 스필이 늘 수 있습니다. 핫 루프라면 Compiler Explorer 등으로 실제 명령을 확인하는 것이 안전합니다.

volatile의 의미와 남용

GCC는 출력 피연산자가 있는 asm을 “입력으로 출력을 계산하는 순수 함수”로 취급합니다. 그래서 출력 값을 아무도 쓰지 않으면 asm 전체를 제거하고, 입력이 같으면 루프 밖으로 끌어내거나 두 번의 호출을 하나로 합칠 수 있습니다. asm volatile은 이런 제거·이동·병합을 막습니다. 타이밍 측정(rdtsc), 하드웨어 레지스터 접근처럼 실행 자체가 의미인 경우에 필요합니다.

참고로 출력 피연산자가 없는 asm과 기본 asm은 GCC가 암묵적으로 volatile로 취급하므로, asm("nop")은 volatile을 붙이지 않아도 제거되지 않습니다. 반대로 순수 계산을 asm으로 옮겨 놓고 습관적으로 volatile을 붙이면, 공통 부분식 제거나 루프 불변식 이동 같은 최적화가 막혀 오히려 느려집니다. volatile은 동기화 보장이 아니라 최적화 억제라는 점을 기억해야 합니다.

asm goto

asm goto는 asm 안에서 C 레이블로 점프할 수 있게 하는 확장입니다. 리눅스 커널은 이를 이용해, 평소에는 nop이었다가 기능이 켜지면 점프로 바뀌는 정적 분기(static key, jump label)를 구현합니다. 오랫동안 asm goto에는 출력 피연산자를 쓸 수 없었고, Clang 11과 GCC 11부터 출력 피연산자를 허용합니다. 컴파일러 버전에 따라 지원 범위가 다르므로, 커널 같은 특수한 경우가 아니라면 일반 제어 흐름으로 대체하는 편이 낫습니다.


레지스터 할당과 클로버(clobber)

인라인 asm의 어려움은 컴파일러가 맡는 부분과 개발자가 보증해야 하는 부분을 정확히 나누는 데 있습니다. 컴파일러는 피연산자 레지스터를 할당해 주고, 개발자는 그 외에 asm이 바꾸는 모든 것을 클로버로 알려야 합니다.

클로버 목록: 레지스터·"cc"·"memory"

네 번째 인자에는 이 asm 블록이 피연산자 외에 덮어쓰는 자원을 나열합니다.

  • 레지스터 이름: "eax", "rdx", "xmm5"처럼 템플릿 안에서 직접 손댄 레지스터
  • "cc": 플래그 레지스터를 바꿈 (x86에서는 GCC가 항상 플래그가 바뀐다고 가정하지만, 이식성을 위해 적는 편이 좋습니다)
  • "memory": 피연산자로 드러나지 않은 메모리를 읽거나 쓸 수 있음 (아래 메모리 배리어 절 참고)

출력·입력 피연산자에 이미 쓰인 레지스터는 클로버에 넣으면 안 됩니다. GCC는 이를 충돌로 보고 컴파일 에러를 냅니다. 반면 템플릿 안에서 추가로 건드린 레지스터를 빠뜨리면, 컴파일러가 그 레지스터에 다른 변수를 둔 채로 asm을 끼워 넣어 데이터가 조용히 깨집니다.

// 잘못된 예: eax를 바꾸는데 컴파일러가 모름
asm volatile("movl $10, %%eax" ::: );

// 올바른 예: 클로버로 알림
asm volatile("movl $10, %%eax" ::: "eax");

이 버그는 주변 코드가 마침 eax를 쓰지 않으면 증상이 없다가, 인라이닝이나 최적화 수준이 바뀌는 순간 드러나서 찾기가 매우 어렵습니다.

조기 손상(early-clobber)과 &

컴파일러는 기본적으로 “asm은 모든 입력을 읽은 뒤에 출력을 쓴다”고 가정하고, 그래서 입력과 출력에 같은 레지스터를 배정하기도 합니다. 그런데 템플릿이 여러 명령으로 되어 있고 앞쪽 명령이 출력 레지스터에 쓴 뒤 뒤쪽 명령이 입력을 읽는다면, 같은 레지스터를 공유한 입력이 이미 덮어써진 상태가 됩니다.

// out = a * 2 + b  (두 명령)
int out;
asm("movl %1, %0\n\t"
    "addl %0, %0\n\t"
    "addl %2, %0"
    : "=&r"(out)          // &: 입력을 다 읽기 전에 out을 씀
    : "r"(a), "r"(b)
    : "cc");

&가 없으면 컴파일러가 out과 b를 같은 레지스터에 둘 수 있고, 첫 movl이 b를 덮어써 결과가 틀어집니다. &를 붙이면 출력이 어떤 입력과도 레지스터를 공유하지 않도록 할당합니다.

레지스터 변수와 ABI

GCC의 지역 레지스터 변수(register int x asm("ecx");)는 확장 asm 피연산자를 특정 레지스터에 넣는 용도로만 동작이 보장됩니다. x86에서는 "c" 같은 제약 문자로 대부분 해결되므로, 특정 레지스터에 고정해야 한다면 먼저 제약 문자로 표현할 수 있는지 확인합니다. 레지스터 변수는 제약 문자가 없는 레지스터를 써야 하는 ARM·RISC-V 시스템 콜 래퍼 같은 경우에 주로 쓰입니다.


메모리 배리어와 순서 보장

동시성·드라이버·MMIO 코드에서 asm은 자주 순서 문제와 얽힙니다. 이때는 C++ 메모리 모델, 컴파일러 재배치, CPU의 실제 실행 순서라는 세 층을 구분해야 합니다.

컴파일러 배리어 vs 하드웨어 펜스

asm volatile("" ::: "memory")는 명령을 하나도 만들지 않습니다. 다만 "memory" 클로버 때문에 컴파일러는 이 지점에서 레지스터에 캐시해 둔 메모리 값을 모두 버리고, 이 지점을 넘어 메모리 접근을 앞뒤로 옮기지 않습니다. 즉 컴파일러 재배치만 막는 컴파일러 배리어입니다.

mfence, sfence, lfence는 CPU가 실행하는 실제 명령입니다. x86은 TSO(Total Store Order) 모델이라 대부분의 재배치를 하드웨어가 하지 않지만, 앞선 스토어가 스토어 버퍼에 머무는 동안 뒤의 로드가 먼저 실행되는 스토어-로드 재배치는 허용합니다. 이것을 막는 것이 mfence(또는 lock 접두사가 붙은 명령)입니다. ARM처럼 약한 메모리 모델에서는 dmb 같은 다른 명령이 필요하므로, 배리어의 의미는 아키텍처마다 따로 확인해야 합니다.

"memory" 클로버의 비용

"memory" 클로버는 “이 asm이 아무 메모리나 읽고 쓸 수 있다”는 보수적인 선언입니다. 그래서 asm 앞뒤로 레지스터에 들고 있던 값을 메모리에 쓰고 다시 읽게 만들어, 핫 루프에 남발하면 성능이 떨어집니다. asm이 접근하는 메모리가 정해져 있다면 "m"(*ptr)이나 "+m"(*ptr)처럼 피연산자로 알려 주는 편이 더 좁고 정확합니다.

std::atomic과의 관계

동기화는 가능하면 std::atomic과 메모리 순서로 표현합니다. 컴파일러는 memory_order_release 스토어를 보면 컴파일러 재배치와 하드웨어 펜스를 아키텍처에 맞게 함께 처리합니다. x86에서 release 스토어는 일반 mov로, seq_cst 스토어는 xchg(또는 mov + mfence)로 내려갑니다. 여기에 asm 배리어를 덧붙이면 이미 보장된 순서를 중복으로 강제할 뿐입니다.

MMIO는 사정이 다릅니다. 장치 레지스터 접근은 volatile 포인터로 컴파일러가 접근을 생략·병합하지 못하게 하고, 장치가 요구하는 순서는 플랫폼 문서가 정한 배리어(리눅스 커널의 readl/writel, wmb() 등)를 따릅니다. std::atomic은 메모리 일관성 모델을 다룰 뿐 장치 레지스터 접근 규칙까지 보장하지는 않기 때문입니다.


SIMD 인트린식 대 인라인 asm

SIMD는 인트린식을 먼저 쓰는 것이 원칙에 가깝습니다. 컴파일러가 레지스터 할당, 명령 스케줄링, 대상 CPU(-march=, -mtune=)에 맞는 명령 선택을 대신해 주기 때문입니다.

인트린식이 유리한 이유

  • __m128i, __m256i 같은 타입이 있어 의도가 코드에 드러나고, 타입이 맞지 않으면 컴파일 에러가 납니다.
  • 같은 인트린식이라도 -mavx를 주면 VEX 인코딩 명령으로 바뀌는 식으로 대상 CPU에 맞게 내려갑니다. 단, _mm_* 인트린식은 x86 전용이고 ARM은 NEON 인트린식(arm_neon.h)을 따로 써야 합니다.
  • 컴파일러가 인트린식의 의미를 알기 때문에 상수 전파, 불필요한 연산 제거, 인라이닝 뒤의 레지스터 재배치가 그대로 동작합니다. asm 블록은 이 모든 최적화의 장벽이 됩니다.
#include <immintrin.h>

// 128비트 정수 벡터 덧셈
void add_epi32_example(__m128i a, __m128i b, __m128i* out) {
    *out = _mm_add_epi32(a, b);
}

인라인 asm이 남는 경우

  • 컴파일러가 아직 인트린식으로 노출하지 않은 새 명령을 시험할 때(단기적)
  • 레지스터 압력이 극단적인 암호·코덱 커널에서, 인트린식이 만든 코드를 검증한 뒤 손으로 고칠 때. 이런 코드는 대개 인라인 asm보다 별도 .S 파일로 작성합니다.
  • 특권 명령, 특수 레지스터 접근처럼 인트린식이 아예 없을 때

그 밖의 많은 명령은 이미 내장 함수가 있습니다. __builtin_popcount, __builtin_ctz, __builtin_bswap32, <cpuid.h>의 __get_cpuid, <x86intrin.h>의 __rdtsc 등입니다. asm을 쓰기 전에 내장 함수가 있는지 먼저 찾아보면 클로버 실수를 원천적으로 피할 수 있습니다.


실전 예시

CPUID

#include <cstdint>
#include <cstring>
#include <iostream>

void cpuid(uint32_t leaf, uint32_t subleaf,
           uint32_t& a, uint32_t& b, uint32_t& c, uint32_t& d) {
    asm volatile("cpuid"
        : "=a"(a), "=b"(b), "=c"(c), "=d"(d)
        : "a"(leaf), "c"(subleaf));
}

int main() {
    uint32_t a, b, c, d;
    cpuid(0, 0, a, b, c, d);

    char vendor[13];
    std::memcpy(vendor,     &b, 4);
    std::memcpy(vendor + 4, &d, 4);
    std::memcpy(vendor + 8, &c, 4);
    vendor[12] = '\0';

    std::cout << "CPU: " << vendor << '\n';  // 예: GenuineIntel
}

리프 4나 7처럼 하위 리프가 있는 경우 ecx에 하위 리프 번호를 넣어야 하므로 "c"(subleaf) 입력을 둡니다. 문자열을 만들 때 *(int*)vendor = b; 같은 포인터 캐스팅은 엄격한 별칭 규칙 위반이므로 memcpy를 씁니다. 32비트 PIC 코드에서는 ebx가 GOT 포인터로 예약되어 있어 구버전 GCC가 "=b" 제약을 거부할 수 있는데, 실무에서는 이런 차이를 처리해 둔 <cpuid.h>의 __get_cpuid_count를 쓰는 편이 낫습니다.

원자적 증가

#include <iostream>

int atomicFetchAdd(int* ptr, int delta) {
    int old = delta;
    asm volatile("lock xaddl %0, %1"
        : "+r"(old), "+m"(*ptr)
        :
        : "cc", "memory");
    return old;  // 증가 전 값
}

int main() {
    int counter = 0;
    for (int i = 0; i < 10; i++) {
        atomicFetchAdd(&counter, 1);
    }
    std::cout << counter << '\n';  // 10
}

xadd는 레지스터와 메모리 값을 바꾼 뒤 둘을 더해 메모리에 저장하므로, 레지스터에는 증가 전 값이 남습니다. lock 접두사가 다른 코어와의 원자성을 보장하고, "memory" 클로버는 이 연산 전후로 컴파일러가 메모리 접근을 옮기지 못하게 합니다. 이 코드는 원리 설명용이며, 실제로는 std::atomic<int>::fetch_add가 같은 명령으로 컴파일되고 메모리 순서까지 정확히 표현하므로 그쪽을 씁니다.

타임스탬프 카운터

#include <cstdint>
#include <iostream>

inline uint64_t rdtsc() {
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return (static_cast<uint64_t>(hi) << 32) | lo;
}

int main() {
    volatile uint64_t sink = 0;
    uint64_t start = rdtsc();
    for (int i = 0; i < 1000000; i++) {
        sink += i;  // volatile이 아니면 루프 자체가 최적화로 사라짐
    }
    uint64_t end = rdtsc();
    std::cout << "TSC ticks: " << (end - start) << '\n';
}

volatile이 없으면 컴파일러는 두 rdtsc 호출의 입력이 같다고 보고 하나로 합치거나 위치를 옮길 수 있습니다. 측정 결과를 해석할 때는 두 가지를 알아야 합니다. 첫째, 최신 x86의 TSC는 코어 클록과 무관하게 일정한 주파수로 증가하는 카운터라(invariant TSC), 값이 “CPU 사이클 수”와 일치하지 않습니다. 둘째, rdtsc는 직렬화 명령이 아니라서 앞뒤 명령과 겹쳐 실행될 수 있습니다. 짧은 구간을 정밀하게 재려면 lfence; rdtsc나 rdtscp를 쓰고, 일반적인 측정에는 std::chrono::steady_clock이 더 안전합니다. 컴파일러 내장 __rdtsc()(<x86intrin.h>, MSVC는 <intrin.h>)를 쓰면 asm 없이 같은 명령을 얻습니다.

컴파일러 배리어가 필요한 경우와 아닌 경우

#include <atomic>

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;
    ready.store(true, std::memory_order_release);  // 이것으로 충분
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}
    // 여기서 data == 42가 보장됨
}

release 스토어는 그 앞의 모든 메모리 쓰기가 스토어보다 먼저 보이도록 컴파일러와 CPU 양쪽 수준에서 보장합니다. 그래서 data = 42;와 스토어 사이에 asm volatile("" ::: "memory")를 넣을 필요가 없습니다. 컴파일러 배리어가 실제로 쓰이는 곳은 같은 스레드 안에서 시그널 핸들러와 순서를 맞출 때(std::atomic_signal_fence로 대체 가능), 또는 벤치마크에서 컴파일러가 계산을 측정 구간 밖으로 옮기지 못하게 할 때처럼 CPU가 아니라 컴파일러만 막으면 되는 경우입니다.


플랫폼별 차이

// x86-64: 64비트 레지스터에 값 넣기 (rax를 바꾸므로 클로버 필요)
asm volatile("movq %0, %%rax" : : "r"(value) : "rax");

// 32비트 ARM: r0에 값 넣기
asm volatile("mov r0, %0" : : "r"(value) : "r0");

레지스터 이름, 문법, 사용 가능한 제약 문자가 아키텍처마다 다르므로, 여러 플랫폼을 지원한다면 조건부 컴파일을 한곳에 모읍니다.

#include <cstdint>

inline uint64_t cycle_counter() {
#if defined(__x86_64__) || defined(__i386__)
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return (static_cast<uint64_t>(hi) << 32) | lo;
#elif defined(__aarch64__)
    uint64_t v;
    asm volatile("mrs %0, cntvct_el0" : "=r"(v));  // 가상 타이머 카운터
    return v;
#else
#error "Unsupported platform"
#endif
}

AArch64의 cntvct_el0도 코어 클록이 아니라 고정 주파수(cntfrq_el0로 확인) 타이머라는 점은 x86 TSC와 같습니다.


프로덕션에서의 어셈블리 패턴

한곳에 가두기

  • asm 문자열을 비즈니스 로직 곳곳에 흩뿌리지 않고, cpu_rdtsc(), cpu_pause() 같은 얇은 래퍼 함수로 모읍니다.
  • #if defined(__x86_64__) 같은 조건부 컴파일도 그 래퍼 파일 안에만 두고, 나머지 코드는 같은 시그니처만 호출합니다.
  • 스택을 직접 조작하거나 callee-saved 레지스터를 건드리는 코드는 인라인 asm으로 쓰지 않습니다. 이런 코드는 호출 규약을 직접 관리할 수 있는 별도 .S(GCC)나 .asm(MASM) 파일이 맞습니다.

검증

  • Compiler Explorer(godbolt.org)에서 같은 함수를 인트린식 버전과 asm 버전으로 컴파일해 생성 코드를 비교합니다.
  • 로컬에서는 g++ -S -O2 -masm=intel file.cpp로 어셈블리 목록을 만들어 레지스터 압력과 스필을 확인하고, 디버거에서는 GDB의 disassemble로 실제 실행 코드를 봅니다.
  • asm 함수에는 순수 C++ 참조 구현을 함께 두고, 무작위 입력으로 두 결과가 같은지 비교하는 테스트를 둡니다. 클로버 누락 버그는 최적화 수준과 인라이닝 여부에 따라 나타나므로 -O0과 -O2 빌드 모두에서 테스트를 돌립니다.

참고 자료