인라인 어셈블리
Rust는 asm! 매크로를 통해 인라인 어셈블리를 지원합니다. 이를 통해 컴파일러가 생성하는 어셈블리 출력에 수기로 작성한 어셈블리를 삽입할 수 있습니다. 일반적으로 이는 필요하지 않지만, 다른 방법으로는 필요한 성능이나 타이밍을 달성할 수 없는 경우에 유용할 수 있습니다. 커널 코드와 같은 저수준 하드웨어 프리미티브에 접근하는 경우에도 이 기능이 필요할 수 있습니다.
참고: 여기의 예제들은 x86/x86-64 어셈블리로 제공되지만, 다른 아키텍처도 지원됩니다.
인라인 어셈블리는 현재 다음 아키텍처에서 지원됩니다:
- x86 및 x86-64
- ARM
- AArch64
- RISC-V
기본 사용법
가장 간단한 예제부터 시작해 봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
unsafe {
asm!("nop");
}
}
}
이 코드는 컴파일러가 생성한 어셈블리에 NOP (no operation) 명령어를 삽입합니다. asm! 호출은 임의의 명령어를 삽입하여 다양한 불변성을 깰 수 있으므로 모두 unsafe 블록 안에 있어야 한다는 점에 유의하세요. 삽입할 명령어는 asm! 매크로의 첫 번째 인자에 문자열 리터럴로 나열됩니다.
입력과 출력
아무것도 하지 않는 명령어를 넣는 것은 꽤 지루합니다. 실제로 데이터에 작용하는 무언가를 해봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let x: u64;
unsafe {
asm!("mov {}, 5", out(reg) x);
}
assert_eq!(x, 5);
}
}
이 코드는 값 5를 u64 변수 x에 씁니다. 명령어를 지정하는 데 사용하는 문자열 리터럴이 실제로는 템플릿 문자열이라는 것을 알 수 있습니다. 이는 Rust 형식 문자열과 동일한 규칙의 적용을 받습니다. 하지만 템플릿에 삽입되는 인자들은 여러분에게 익숙한 것과는 조금 달라 보일 수 있습니다. 먼저 변수가 인라인 어셈블리의 입력인지 출력인지를 지정해야 합니다. 이 경우에는 출력입니다. 우리는 out을 작성하여 이를 선언했습니다. 또한 어셈블리가 변수를 어떤 종류의 레지스터에 두기를 기대하는지도 지정해야 합니다. 이 경우에는 reg를 지정하여 임의의 범용 레지스터에 넣었습니다. 컴파일러는 템플릿에 삽입할 적절한 레지스터를 선택하고, 인라인 어셈블리 실행이 끝난 후 해당 레지스터에서 변수를 읽어올 것입니다.
입력을 사용하는 또 다른 예제를 살펴봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let i: u64 = 3;
let o: u64;
unsafe {
asm!(
"mov {0}, {1}",
"add {0}, 5",
out(reg) o,
in(reg) i,
);
}
assert_eq!(o, 8);
}
}
이 코드는 변수 i의 입력에 5를 더하고 그 결과를 변수 o에 씁니다. 이 어셈블리가 이를 수행하는 구체적인 방식은 먼저 i의 값을 출력으로 복사한 다음, 거기에 5를 더하는 것입니다.
이 예제는 몇 가지를 보여줍니다:
첫째, asm!이 여러 템플릿 문자열 인자를 허용한다는 것을 알 수 있습니다. 각각은 별도의 어셈블리 코드 줄로 처리되며, 마치 줄바꿈으로 모두 연결된 것처럼 작동합니다. 이를 통해 어셈블리 코드를 쉽게 포맷팅할 수 있습니다.
둘째, out 대신 in을 작성하여 입력을 선언한다는 것을 알 수 있습니다.
셋째, 다른 형식 문자열에서처럼 인자 번호나 이름을 지정할 수 있음을 알 수 있습니다. 인라인 어셈블리 템플릿에서는 인자가 한 번 이상 사용되는 경우가 많기 때문에 이 기능이 특히 유용합니다. 복잡한 인라인 어셈블리의 경우 이 기능을 사용하는 것이 일반적으로 권장되는데, 가독성을 높이고 인자 순서를 변경하지 않고도 명령어를 재배치할 수 있게 해주기 때문입니다.
위의 예제를 더 개선하여 mov 명령어를 피할 수 있습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut x: u64 = 3;
unsafe {
asm!("add {0}, 5", inout(reg) x);
}
assert_eq!(x, 8);
}
}
inout이 입력과 출력 모두로 사용되는 인자를 지정하는 데 사용됨을 알 수 있습니다. 이는 입력과 출력을 따로 지정하는 것과 다른데, inout은 두 경우 모두 동일한 레지스터에 할당됨을 보장하기 때문입니다.
inout 피연산자의 입력과 출력 부분에 대해 서로 다른 변수를 지정하는 것도 가능합니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let x: u64 = 3;
let y: u64;
unsafe {
asm!("add {0}, 5", inout(reg) x => y);
}
assert_eq!(y, 8);
}
}
늦은(Late) 출력 피연산자
The Rust compiler is conservative with its allocation of operands. It is assumed that an out can be written at any time, and can therefore not share its location with any other argument. However, to guarantee optimal performance it is important to use as few registers as possible, so they won’t have to be saved and reloaded around the inline assembly block. To achieve this Rust provides a lateout specifier. This can be used on any output that is written only after all inputs have been consumed. There is also an inlateout variant of this specifier.
release 모드나 기타 최적화된 경우에 inlateout을 사용할 수 없는 예시는 다음과 같습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
let c: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
"add {0}, {2}",
inout(reg) a,
in(reg) b,
in(reg) c,
);
}
assert_eq!(a, 12);
}
}
최적화되지 않은 경우(예: Debug 모드), 위 예제에서 inout(reg) a를 inlateout(reg) a로 대체해도 계속해서 예상된 결과를 얻을 수 있습니다. 그러나 release 모드나 기타 최적화된 경우에는 inlateout(reg) a를 사용하면 최종 값이 a = 16이 되어 단언(assertion)이 실패할 수 있습니다.
이는 최적화된 경우 컴파일러가 입력 b와 c에 대해 동일한 값을 가지고 있음을 알고 있으므로 동일한 레지스터를 할당할 수 있기 때문입니다. 또한 inlateout이 사용될 때 a와 c가 동일한 레지스터에 할당될 수 있는데, 이 경우 첫 번째 add 명령어가 변수 c로부터의 초기 로드 값을 덮어쓰게 됩니다. 이는 inout(reg) a를 사용하여 a에 대해 별도의 레지스터가 할당되도록 보장하는 것과 대조적입니다.
하지만 다음 예제에서는 모든 입력 레지스터를 읽은 후에만 출력이 수정되므로 inlateout을 사용할 수 있습니다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!("add {0}, {1}", inlateout(reg) a, in(reg) b);
}
assert_eq!(a, 8);
}
}
보시다시피, a와 b가 동일한 레지스터에 할당되더라도 이 어셈블리 조각은 여전히 올바르게 작동할 것입니다.
명시적 레지스터 피연산자
일부 명령어는 피연산자가 특정 레지스터에 있어야 함을 요구합니다. 따라서 Rust 인라인 어셈블리는 몇 가지 더 구체적인 제약 지정자를 제공합니다. reg는 일반적으로 모든 아키텍처에서 사용할 수 있지만, 명시적 레지스터는 아키텍처에 따라 매우 다릅니다. 예를 들어 x86의 경우 eax, ebx, ecx, edx, ebp, esi, edi 등의 범용 레지스터를 이름으로 지정할 수 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let cmd = 0xd1;
unsafe {
asm!("out 0x64, eax", in("eax") cmd);
}
}
}
이 예제에서는 out 명령어를 호출하여 cmd 변수의 내용을 포트 0x64로 출력합니다. out 명령어는 eax (및 그 하위 레지스터)만을 피연산자로 받아들이므로 eax 제약 지정자를 사용해야 했습니다.
참고: 다른 피연산자 타입과 달리 명시적 레지스터 피연산자는 템플릿 문자열에서 사용할 수 없습니다.
{}를 사용할 수 없으며 대신 레지스터 이름을 직접 작성해야 합니다. 또한 다른 모든 피연산자 타입 뒤에 피연산자 목록의 마지막에 나타나야 합니다.
x86 mul 명령어를 사용하는 이 예제를 살펴보세요:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
fn mul(a: u64, b: u64) -> u128 {
let lo: u64;
let hi: u64;
unsafe {
asm!(
// x86 mul 명령어는 rax를 암시적 입력으로 받고
// 128비트 곱셈 결과를 rax:rdx에 씁니다.
"mul {}",
in(reg) a,
inlateout("rax") b => lo,
lateout("rdx") hi
);
}
((hi as u128) << 64) + lo as u128
}
}
}
이 코드는 mul 명령어를 사용하여 두 개의 64비트 입력을 곱해 128비트 결과를 얻습니다. 유일한 명시적 피연산자는 레지스터이며, 변수 a로 채웁니다. 두 번째 피연산자는 암시적이며 rax 레지스터여야 하는데, 이는 변수 b로 채웁니다. 결과의 하위 64비트는 rax에 저장되어 변수 lo를 채우고, 상위 64비트는 rdx에 저장되어 변수 hi를 채웁니다.
클로버(Clobbered) 레지스터
많은 경우 인라인 어셈블리는 출력으로 필요하지 않은 상태를 수정합니다. 보통 어셈블리 내에서 스크래치 레지스터를 사용해야 하거나, 명령어가 더 이상 검사할 필요가 없는 상태를 수정하기 때문입니다. 이러한 상태를 일반적으로 “클로버(clobbered)” 되었다고 합니다. 컴파일러가 인라인 어셈블리 블록 전후로 이러한 상태를 저장하고 복원해야 할 수도 있으므로 이를 컴파일러에 알려야 합니다.
use std::arch::asm;
#[cfg(target_arch = "x86_64")]
fn main() {
// 각각 4바이트인 3개의 엔트리
let mut name_buf = [0_u8; 12];
// 문자열은 ebx, edx, ecx 순서대로 ASCII로 저장됩니다.
// ebx는 예약되어 있으므로, 어셈블리는 이 값을 보존해야 합니다.
// 따라서 메인 어셈블리 전후로 push와 pop을 수행합니다.
// 64비트 프로세서의 64비트 모드에서는 32비트 레지스터(ebx와 같은)를
// push/pop 하는 것을 허용하지 않으므로, 대신 확장된 rbx 레지스터를 사용해야 합니다.
unsafe {
asm!(
"push rbx",
"cpuid",
"mov [rdi], ebx",
"mov [rdi + 4], edx",
"mov [rdi + 8], ecx",
"pop rbx",
// 몇 가지 어셈블리 명령어를 더 사용하는 대신 Rust 코드를 단순화하기 위해
// 값을 저장하는 데 배열 포인터를 사용합니다.
// 하지만 이는 `out("ecx") val`과 같은 명시적 레지스터 출력과는 반대로
// 어셈블리가 어떻게 작동하는지 더 명시적으로 보여줍니다.
// *포인터 자체*는 비록 그 뒤에 값이 쓰여지더라도 입력일 뿐입니다.
in("rdi") name_buf.as_mut_ptr(),
// cpuid 0을 선택하고, eax를 클로버된 것으로 지정합니다.
inout("eax") 0 => _,
// cpuid는 이 레지스터들도 클로버합니다.
out("ecx") _,
out("edx") _,
);
}
let name = core::str::from_utf8(&name_buf).unwrap();
println!("CPU 제조사 ID: {}", name);
}
#[cfg(not(target_arch = "x86_64"))]
fn main() {}
위의 예제에서는 cpuid 명령어를 사용하여 CPU 제조사 ID를 읽습니다. 이 명령어는 eax에 지원되는 최대 cpuid 인자를 쓰고, ebx, edx, ecx 순서대로 CPU 제조사 ID를 ASCII 바이트로 씁니다.
eax는 결코 읽히지 않지만, 컴파일러가 어셈블리 이전의 레지스터 값을 저장할 수 있도록 레지스터가 수정되었음을 알려야 합니다. 이는 출력으로 선언하되 변수 이름 대신 _를 사용하여 수행하며, 이는 출력 값을 버림을 나타냅니다.
이 코드는 ebx가 LLVM에 의해 예약된 레지스터라는 제한 사항도 해결합니다. 이는 LLVM이 해당 레지스터를 완전히 제어한다고 가정하며 어셈블리 블록을 종료하기 전에 원래 상태로 복원해야 함을 의미합니다. 따라서 컴파일러가 일반 레지스터 클래스(예: in(reg))를 충족하기 위해 사용하는 경우를 제외하고는 입력이나 출력으로 사용할 수 없습니다. 이로 인해 예약된 레지스터를 사용할 때 reg 피연산자는 위험할 수 있는데, 동일한 레지스터를 공유하여 자신도 모르게 입력이나 출력을 손상시킬 수 있기 때문입니다.
이를 해결하기 위해 rdi를 사용하여 출력 배열에 대한 포인터를 저장하고, push를 통해 ebx를 저장한 후, 어셈블리 블록 내에서 ebx로부터 배열로 읽어 들인 다음, pop을 통해 ebx를 원래 상태로 복원합니다. 전체 레지스터가 저장되도록 push와 pop은 레지스터의 64비트 버전인 rbx를 사용합니다. 32비트 타겟의 경우 코드에서 대신 ebx를 push/pop에 사용할 것입니다.
이는 어셈블리 코드 내부에서 사용할 스크래치 레지스터를 얻기 위해 일반 레지스터 클래스와 함께 사용될 수도 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
// 시프트와 더하기를 사용하여 x에 6을 곱함
let mut x: u64 = 4;
unsafe {
asm!(
"mov {tmp}, {x}",
"shl {tmp}, 1",
"shl {x}, 2",
"add {x}, {tmp}",
x = inout(reg) x,
tmp = out(reg) _,
);
}
assert_eq!(x, 4 * 6);
}
}
심볼 피연산자와 ABI 클로버
기본적으로 asm!은 출력으로 지정되지 않은 모든 레지스터의 내용이 어셈블리 코드에 의해 보존된다고 가정합니다. asm!의 clobber_abi 인자는 컴파일러가 주어진 호출 규약 ABI에 따라 필요한 클로버 피연산자를 자동으로 삽입하도록 지시합니다. 해당 ABI에서 완전히 보존되지 않는 모든 레지스터는 클로버된 것으로 취급됩니다. 여러 clobber_abi 인자를 제공할 수 있으며, 지정된 모든 ABI의 모든 클로버가 삽입됩니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
extern "C" fn foo(arg: i32) -> i32 {
println!("인자 = {}", arg);
arg * 2
}
fn call_foo(arg: i32) -> i32 {
unsafe {
let result;
asm!(
"call {}",
// 호출할 함수 포인터
in(reg) foo,
// 첫 번째 인자는 rdi에
in("rdi") arg,
// 반환 값은 rax에
out("rax") result,
// "C" 호출 규약에 의해 보존되지 않는 모든 레지스터를 클로버된 것으로 표시합니다.
clobber_abi("C"),
);
result
}
}
}
}
레지스터 템플릿 수정자
어떤 경우에는 템플릿 문자열에 삽입될 때 레지스터 이름이 포맷팅되는 방식에 대해 세밀한 제어가 필요합니다. 이는 아키텍처의 어셈블리 언어에 동일한 레지스터에 대한 여러 이름이 있을 때 필요하며, 각 이름은 일반적으로 레지스터 일부에 대한 “뷰(view)“입니다(예: 64비트 레지스터의 하위 32비트).
기본적으로 컴파일러는 항상 전체 레지스터 크기를 참조하는 이름을 선택합니다(예: x86-64의 rax, x86의 eax 등).
이 기본값은 형식 문자열에서처럼 템플릿 문자열 피연산자에 수정자를 사용하여 재정의할 수 있습니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut x: u16 = 0xab;
unsafe {
asm!("mov {0:h}, {0:l}", inout(reg_abcd) x);
}
assert_eq!(x, 0xabab);
}
}
이 예제에서는 reg_abcd 레지스터 클래스를 사용하여 레지스터 할당자를 처음 두 바이트를 독립적으로 주소 지정할 수 있는 4개의 레거시 x86 레지스터(ax, bx, cx, dx)로 제한합니다.
레지스터 할당자가 x를 ax 레지스터에 할당하기로 선택했다고 가정해 봅시다. h 수정자는 해당 레지스터의 상위 바이트에 대한 레지스터 이름을 출력하고, l 수정자는 하위 바이트에 대한 레지스터 이름을 출력합니다. 따라서 어셈블리 코드는 mov ah, al로 확장되어 값의 하위 바이트를 상위 바이트로 복사합니다.
피연산자와 함께 더 작은 데이터 타입(예: u16)을 사용하면서 템플릿 수정자를 사용하는 것을 잊은 경우, 컴파일러는 경고를 내보내고 사용할 올바른 수정자를 제안합니다.
메모리 주소 피연산자
때로는 어셈블리 명령어가 메모리 주소/메모리 위치를 통해 전달되는 피연산자를 요구합니다. 타겟 아키텍처에 지정된 메모리 주소 구문을 수동으로 사용해야 합니다. 예를 들어, Intel 어셈블리 구문을 사용하는 x86/x86_64에서는 입력/출력을 []로 감싸서 메모리 피연산자임을 나타내야 합니다.
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
fn load_fpu_control_word(control: u16) {
unsafe {
asm!("fldcw [{}]", in(reg) &control, options(nostack));
}
}
}
}
레이블
이름이 지정된 레이블(로컬이든 아니든)을 재사용하면 어셈블러나 링커 오류가 발생하거나 기타 이상한 동작이 발생할 수 있습니다. 이름이 지정된 레이블의 재사용은 다음과 같은 다양한 방식으로 발생할 수 있습니다.
- 명시적으로: 하나의
asm!블록에서 레이블을 두 번 이상 사용하거나, 블록 간에 여러 번 사용하는 경우. - 인라이닝을 통한 암시적 방식: 컴파일러는
asm!블록의 여러 복사본을 인스턴스화할 수 있습니다. 예를 들어 해당 블록을 포함하는 함수가 여러 위치에서 인라이닝되는 경우가 이에 해당합니다. - LTO를 통한 암시적 방식: LTO는 _다른 크레이트_의 코드를 동일한 코드 생성 단위에 배치할 수 있으며, 이로 인해 임의의 레이블이 유입될 수 있습니다.
결과적으로, 인라인 어셈블리 코드 내부에서는 GNU 어셈블러 숫자 로컬 레이블만 사용해야 합니다. 어셈블리 코드에서 심볼을 정의하면 심볼 중복 정의로 인해 어셈블러 및/또는 링커 오류가 발생할 수 있습니다.
또한 x86에서 기본 Intel 구문을 사용할 때, LLVM 버그로 인해 0, 11 또는 101010과 같이 0과 1 숫자로만 구성된 레이블은 사용하지 말아야 합니다. 이는 바이너리 값으로 해석될 수 있기 때문입니다. options(att_syntax)를 사용하면 모호함을 피할 수 있지만, 이는 asm! 블록 _전체_의 구문에 영향을 미칩니다. (options에 대한 자세한 내용은 아래의 옵션 섹션을 참조하세요.)
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a = 0;
unsafe {
asm!(
"mov {0}, 10",
"2:",
"sub {0}, 1",
"cmp {0}, 3",
"jle 2f",
"jmp 2b",
"2:",
"add {0}, 2",
out(reg) a
);
}
assert_eq!(a, 5);
}
}
이 코드는 {0} 레지스터 값을 10에서 3으로 감소시킨 다음, 2를 더하고 그 결과를 a에 저장합니다.
이 예제는 몇 가지를 보여줍니다:
- 첫째, 동일한 숫자를 하나의 인라인 블록 내에서 레이블로 여러 번 사용할 수 있습니다.
- 둘째, 숫자 레이블이 참조(예: 명령어 피연산자)로 사용될 때는 숫자 레이블에 접미사 “b” (“backward”, 뒤로) 또는 ”f” (“forward”, 앞으로)를 추가해야 합니다. 그러면 해당 방향에서 이 숫자로 정의된 가장 가까운 레이블을 참조하게 됩니다.
옵션
기본적으로 인라인 어셈블리 블록은 커스텀 호출 규약을 사용하는 외부 FFI 함수 호출과 동일하게 취급됩니다. 즉, 메모리를 읽거나 쓰고, 관찰 가능한 부수 효과가 있을 수 있다고 가정합니다. 하지만 많은 경우 컴파일러가 더 나은 최적화를 수행할 수 있도록 어셈블리 코드가 실제로 무엇을 하는지에 대한 더 많은 정보를 제공하는 것이 바람직합니다.
이전의 add 명령어 예제를 다시 살펴봅시다:
#![allow(unused)]
fn main() {
#[cfg(target_arch = "x86_64")] {
use std::arch::asm;
let mut a: u64 = 4;
let b: u64 = 4;
unsafe {
asm!(
"add {0}, {1}",
inlateout(reg) a, in(reg) b,
options(pure, nomem, nostack),
);
}
assert_eq!(a, 8);
}
}
옵션은 asm! 매크로의 선택적인 마지막 인자로 제공될 수 있습니다. 여기서는 세 가지 옵션을 지정했습니다:
pure는 어셈블리 코드가 관찰 가능한 부수 효과가 없으며 출력이 입력에만 의존함을 의미합니다. 이를 통해 컴파일러 최적화 프로그램은 인라인 어셈블리를 더 적게 호출하거나 아예 제거할 수 있습니다.nomem은 어셈블리 코드가 메모리를 읽거나 쓰지 않음을 의미합니다. 기본적으로 컴파일러는 인라인 어셈블리가 접근 가능한 모든 메모리 주소(예: 피연산자로 전달된 포인터나 전역 변수를 통해)를 읽거나 쓸 수 있다고 가정합니다.nostack은 어셈블리 코드가 스택에 어떤 데이터도 푸시하지 않음을 의미합니다. 이를 통해 컴파일러는 x86-64의 스택 레드 존(stack red zone)과 같은 최적화를 사용하여 스택 포인터 조정을 피할 수 있습니다.
이러한 옵션들은 컴파일러가 asm!을 사용하는 코드를 더 잘 최적화할 수 있게 해줍니다. 예를 들어 출력이 필요하지 않은 순수(pure) asm! 블록을 제거할 수 있습니다.
사용 가능한 전체 옵션 목록과 그 효과에 대해서는 레퍼런스를 참조하세요.