Zajęcia 10: wstawki assemblerowe

Data: 11.05.2021

Jak używać assemblera w programach napisanych w C

Jeśli chcemy użyć instrukcji assemblera w kodzie napisanym w języku C, mamy kilka możliwości:

  1. Uznać, że wcale nie chcemy dotykać assemblera, napisać przenośny kod i zaufać optymalizatorowi.

  2. Użyć rozszerzeń kompilatora niezależnych od architektury (__vector__, __builtin_popcount, …).

  3. Użyć rozszerzeń kompilatora zależnych od architektury (tzw. intrinsics, np. _mm_aesenc_si128).

  4. Użyć wstawki assemblerowej (__asm__).

  5. Napisać całą funkcję w assemblerze i wywołać ją w C.

Wstawki assemblerowe są najbardziej delikatnym i niebezpiecznym z powyższych mechanizmów.

Wstawki assemblerowe w gcc

Składnia wstawki assemblerowej jest zależna od kompilatora. W przypadku gcc i clanga jest ona następująca:

__asm__ <opcjonalnie volatile> (
   "<kod assemblera>"
   : <lista wyjść>
   : <lista wejść>
   : <lista nadpisanych rejestrów>
);

W gcc, pisząc wstawkę assemblerową efektywnie definiujemy nową instrukcję assemblera w kompilatorze, która będzie traktowana przez backend kompilatora na równi ze zwykłymi. Tak jak w przypadku zwykłych instrukcji, kompilator musi dokładnie znać zachowanie naszej wstawki – jej wejścia, wyjścia oraz możliwości optymalizacji.

Domyślnie, kod wstawki podlega optymalizacjom przepływu danych – gcc zakłada, że wstawka assemblera nie ma skutków ubocznych (innych niż zapisanie wyjść) i może usunąć wstawkę gdy jej wyjścia nie są używane, przestawić ją z innym kodem, bądź zduplikować. Żeby tego uniknąć, możemy użyć słowa volatile – to zagwarantuje, że nasza wstawka wykona się dokładnie jeden raz, gdy zostanie używa.

Kod assemblera we wstawce jest szablonem tekstu, który zostanie wyemitowany bezpośrednio do pliku assemblerowego będącego wyjściem kompilatora. W tym szablonie gcc podstawi zaalokowane wejścia i wyjścia w odpowiednie miejsca.

Listy wejść i wyjść opisują dane, których będzie używać nasza wstawka. Każdy wpis opisuje zmienną (bądź wyrażenie) w C, która zostanie podstawiona w odpowiednim miejscu szablonu oraz ograniczenia na miejsce, w którym powinna zostać umieszczona – np. "g" oznacza rejestr ogólnego przeznaczenia, a "m" oznacza pamięć.

Lista nadpisanych rejestrów opisuje, co nasza wstawka modyfikuje jako skutki uboczne. Możemy tam użyć nazw rejestrów, napisać "cc" jeśli nasza wstawka modyfikuje rejestr znaczników (rflags i jego odpowiedniki), bądź napisać "memory", jeśli nasza wstawka modyfikuje struktury w pamięci (inne niż te przekazane jako wyjścia).

Przykład (bezużyteczny) wstawki assemblerowej:

int a, b, c;

// a = b + c;
__asm__ (
    "addl %1, %0"
    : "=g"(a)
    : "gm"(b), "0"(c)
    : "cc"
);

W tym wypadku mówimy, że gcc może umieścić b w rejestrze bądź pamięci (dwa różne ograniczenia), a musi umieścić w rejestrze, a c musi umieścić w tym samym rejestrze, co argument 0 (czyli a). Kompilator wykona odpowiednią alokację rejestrów i podstawi wybrane rejestry (bądź adres pamięci) w miejsce %0 i %1 w trakcie emitowania kodu assemblera.

Czasem potrzebujemy dokładniejszego ograniczenia lokalizacji wejścia/wyjścia do konkretnego rejestru (np. w przypadku syscalli). Żeby to zrobić, używamy ograniczenia "g" w połączeniu ze zmienną zdefiniowaną w następujący sposób:

register int b __asm__("eax");
// a = b + c;
__asm__ (
    "addl %%eax, %0"
    : "=g"(a)
    : "g"(b), "0"(c)
    : "cc"
);

Wstawki assemblerowe mogą zawierać więcej niż jedną instrukcję (choć należy tego unikać) – oddzielamy je wtedy przez ; bądź \n w tekście szablonu. Jeśli potrzebujemy zdefiniować etykietę wewnątrz wstawki, nie powinniśmy nadawać jej nazwy (będzie ona kolidować, jeśli kompilator postanowi zduplikować kod wstawki) – zamiast tego, powinniśmy użyć etykiet lokalnych (https://sourceware.org/binutils/docs/as/Symbol-Names.html).

Dla przykładu, wykonanie syscalla read na x86_64:

ssize_t read (int fd_, void *ptr_, size_t len_) {
    register int sys_nr __asm__("eax") = __NR_read;
    register ssize_t res __asm__("rax");
    register int fd __asm__("edi") = fd_;
    register void *ptr __asm__("rsi") = ptr_;
    register size_t len __asm__("rdx") = len_;
    __asm__ volatile (
        "syscall"
        : "=g"(res)
        : "g"(sys_nr), "g"(fd), "g"(ptr), "g"(len)
        : "cc", "rcx", "r11", "memory"
    );
    return res;
}