Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Вбудована асемблерна мова

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);
}
}

Це додасть 5 до входу в змінній i і запише результат у змінну o. Особливий спосіб, у який ця асемблерна мова це робить, полягає спершу в копіюванні значення з i до виходу, а потім у додаванні до нього 5.

Цей приклад показує кілька речей:

По-перше, ми можемо бачити, що asm! дозволяє кілька аргументів шаблонного рядка; кожен з них обробляється як окремий рядок асемблерного коду, ніби їх усі було об’єднано разом із новими рядками між ними. Це полегшує форматування асемблерного коду.

По-друге, ми можемо бачити, що входи оголошуються написанням in замість out.

По-третє, ми можемо бачити, що ми можемо вказати номер аргументу або ім’я, як у будь-якому рядку форматування. Для шаблонів вбудованої асемблерної мови це особливо корисно, оскільки аргументи часто використовуються більше одного разу. Для складнішої вбудованої асемблерної мови використання цього засобу загалом рекомендоване, оскільки це покращує читабельність і дозволяє переставляти інструкції без зміни порядку аргументів.

Ми можемо далі уточнити наведений вище приклад, щоб уникнути інструкції 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:

#![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);
}
}

Пізні вихідні операнди

Компілятор Rust є консервативним у виділенні операндів. Припускається, що out може бути записаний у будь-який момент, і тому не може спільно використовувати своє місце з будь-яким іншим аргументом. Однак для гарантування оптимальної продуктивності важливо використовувати якомога менше регістрів, щоб їх не доводилося зберігати й повторно завантажувати навколо блоку вбудованої асемблерної мови. Щоб досягти цього, Rust надає специфікатор lateout. Його можна використовувати для будь-якого виходу, який записується лише після того, як усі входи вже було спожито. Також існує варіант inlateout цього специфікатора.

Ось приклад, де inlateout не можна використовувати в режимі release або інших оптимізованих випадках:

#![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, через що перевірка зазнає невдачі.

Це тому, що в оптимізованих випадках компілятор вільний призначити один і той самий регістр для входів 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.

Зіпсовані регістри

У багатьох випадках вбудована асемблерна мова змінюватиме стан, який не потрібен як вихід. Зазвичай це відбувається або тому, що нам доводиться використовувати тимчасовий регістр в асемблерній мові, або тому, що інструкції змінюють стан, який нам не потрібно далі перевіряти. Цей стан загалом називають “зіпсованим”. Ми маємо повідомити про це компілятор, оскільки йому може знадобитися зберегти й відновити цей стан навколо блоку вбудованої асемблерної мови.

use std::arch::asm;

#[cfg(target_arch = "x86_64")]
fn main() {
    // три елементи по чотири байти кожен
    let mut name_buf = [0_u8; 12];
    // Рядок зберігається як ascii в ebx, edx, ecx у такому порядку
    // Оскільки ebx зарезервовано, asm має зберегти його значення.
    // Тому ми зберігаємо і відновлюємо його навколо основної asm.
    // 64-бітний режим на 64-бітних процесорах не дозволяє зберігати/відновлювати
    // 32-бітні регістри (такі як ebx), тому нам доводиться використовувати розширений регістр rbx.

    unsafe {
        asm!(
            "push rbx",
            "cpuid",
            "mov [rdi], ebx",
            "mov [rdi + 4], edx",
            "mov [rdi + 8], ecx",
            "pop rbx",
            // Ми використовуємо вказівник на масив для зберігання значень, щоб спростити
            // код Rust ціною кількох додаткових інструкцій asm
            // Однак це більш явно відображає, як працює asm, на відміну
            // від явних вихідних регістрів, таких як `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 Manufacturer ID: {}", name);
}

#[cfg(not(target_arch = "x86_64"))]
fn main() {}

У наведеному вище прикладі ми використовуємо інструкцію cpuid, щоб прочитати ідентифікатор виробника CPU. Ця інструкція записує в eax максимальний підтримуваний аргумент cpuid, а в ebx, edx і ecx — ідентифікатор виробника CPU як ASCII-байти в такому порядку.

Хоча eax ніколи не читається, нам усе одно потрібно повідомити компілятор, що регістр було змінено, щоб компілятор міг зберегти будь-які значення, які були в цих регістрах до asm. Це робиться шляхом оголошення його як виходу, але з _ замість імені змінної, що вказує на те, що вихідне значення має бути відкинуто.

Цей код також обходить обмеження, що ebx є зарезервованим регістром LLVM. Це означає, що LLVM припускає, що має повний контроль над регістром і його потрібно відновити до початкового стану перед виходом із блоку asm, тож його не можна використовувати як вхід або вихід за винятком випадку, якщо компілятор використовує його для задоволення загальної класової групи регістрів (наприклад, in(reg)). Це робить операнди reg небезпечними під час використання зарезервованих регістрів, оскільки ми можемо несвідомо пошкодити наш вхід або вихід, бо вони використовують той самий регістр.

Щоб обійти це, ми використовуємо rdi для зберігання вказівника на вихідний масив, зберігаємо ebx через push, читаємо з ebx усередині блоку asm у масив, а потім відновлюємо ebx до його початкового стану через pop. push і pop використовують повну 64-бітну версію регістра rbx, щоб гарантувати, що весь регістр буде збережено. На 32-бітних цільових платформах код натомість використовував би ebx у push/pop.

Це також можна використовувати із загальним класом регістрів, щоб отримати тимчасовий регістр для використання всередині коду asm:

#![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! припускає, що будь-який регістр, не вказаний як вихід, збереже свій вміст завдяки коду асемблерної мови. Аргумент clobber_abi до asm! повідомляє компілятору автоматично вставити необхідні операнди зіпсування відповідно до заданого 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);
    arg * 2
}

fn call_foo(arg: i32) -> i32 {
    unsafe {
        let result;
        asm!(
            "call {}",
            // Вказівник на функцію для виклику
            in(reg) foo,
            // 1-й аргумент у rdi
            in("rdi") arg,
            // Повернене значення в rax
            out("rax") result,
            // Позначити всі регістри, які не зберігаються ABI виклику "C",
            // як зіпсовані.
            clobber_abi("C"),
        );
        result
    }
}
}
}

Модифікатори шаблону регістра

У деяких випадках потрібен тонкий контроль над тим, як назва регістра форматуватиметься під час вставлення в шаблонний рядок. Це потрібно, коли асемблерна мова архітектури має кілька назв для того самого регістра, кожна з яких зазвичай є “вікном” у підмножину регістра (наприклад, нижні 32 біти 64-бітного регістра).

За замовчуванням компілятор завжди обиратиме назву, що відповідає повному розміру регістра (наприклад, rax на x86-64, eax на x86 тощо).

Це значення за замовчуванням можна перевизначити, використовуючи модифікатори в операндах шаблонного рядка, так само як ви б робили це з рядками форматування:

#![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, щоб обмежити розподілювач регістрів чотирма класичними x86-регістрами (ax, bx, cx, dx), для перших двох байтів яких можна звертатися незалежно.

Припустімо, що розподілювач регістрів вирішив розмістити x у регістрі ax. Модифікатор h виведе назву регістра для старшого байта цього регістра, а модифікатор l — назву регістра для молодшого байта. Отже, код asm буде розгорнуто як mov ah, al, що копіює молодший байт значення в старший байт.

Якщо ви використовуєте менший тип даних (наприклад, u16) з операндом і забудете використати модифікатори шаблону, компілятор видасть попередження й запропонує правильний модифікатор для використання.

Операнди адреси пам’яті

Іноді інструкції асемблерної мови вимагають операндів, переданих через адреси пам’яті/розташування пам’яті. Вам потрібно вручну використовувати синтаксис адреси пам’яті, визначений цільовою архітектурою. Наприклад, на 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! або кілька разів у різних блоках.
  • неявно через inline: компілятор може створити кілька копій блоку asm!, наприклад, коли функцію, яка його містить, вбудовано в кількох місцях.
  • неявно через LTO: LTO може спричинити, що код з інших крейтів буде розміщено в тій самій одиниці генерації коду, і таким чином може принести довільні мітки.

Як наслідок, у коді вбудованої асемблерної мови слід використовувати лише числові [локальні мітки] GNU assembler. Визначення символів у коді асемблерної мови може призвести до помилок асемблера та/або лінкера через дублікати визначень символів.

Більше того, на x86 під час використання стандартного інтелівського синтаксису, через [помилку LLVM], вам не слід використовувати мітки, що складаються виключно з цифр 0 і 1, наприклад 0, 11 або 101010, оскільки їх можуть інтерпретувати як двійкові значення. Використання 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 означає, що код asm не має спостережуваних побічних ефектів і що його вихід залежить лише від його входів. Це дозволяє оптимізатору компілятора викликати вбудований asm менше разів або навіть повністю його усунути.
  • nomem означає, що код asm не читає й не записує в пам’ять. За замовчуванням компілятор припускатиме, що вбудована асемблерна мова може читати або записувати будь-яку адресу пам’яті, до якої має доступ (наприклад, через вказівник, переданий як операнд, або глобальну змінну).
  • nostack означає, що код asm не поміщає жодних даних у стек. Це дозволяє компілятору використовувати оптимізації, такі як червона зона стека на x86-64, щоб уникнути коригувань вказівника стека.

Це дозволяє компілятору краще оптимізувати код, що використовує asm!, наприклад, усуваючи блоки asm!, виходи яких не потрібні.

Дивіться довідник для повного списку доступних параметрів і їхніх ефектів.