Перейти к основному содержимому

Взаимодействие с C и C++

Разработчику

Контекст: Linux x86-64, NASM + GCC. Соглашение System V AMD64 ABI. Windows использует другой порядок регистров — см. подпрограммы.


Общая схема

  1. Ассемблерная функция с именем, видимым линкеру (global).
  2. C-файл с extern объявлением с тем же именем (без подчёркивания в Linux для C; C++ требует extern "C").
  3. Сборка: nasm -f elf64.o, затем gcc линкует с main.c.

Имя в объектнике должно совпадать: в NASM для C-линковки часто пишут global my_add и в C — long my_add(long, long);.

Это лучший практический формат применения asm в реальных проектах: "тонкая" горячая функция на ассемблере + остальная логика на C/C++. Такой подход сохраняет производительность там, где она нужна, и не превращает весь код в трудносопровождаемый низкоуровневый монолит.


Ассемблер вызывается из C

add.asm:

section .text
global my_add

my_add:
mov rax, rdi ; 1-й аргумент (long)
add rax, rsi ; 2-й аргумент
ret

Разбор:

  • section .text указывает, что дальше идёт исполняемый код.
  • global my_add экспортирует функцию для линковки с C-модулем.
  • В System V AMD64 первый long приходит в RDI, второй — в RSI.
  • mov rax, rdi переносит первый аргумент в регистр возврата.
  • add rax, rsi добавляет второй аргумент и формирует результат.
  • ret возвращает управление вызывающему C-коду, результат остаётся в RAX.

main.c:

#include <stdio.h>

long my_add(long a, long b);

int main(void) {
printf("%ld\n", my_add(10, 32));
return 0;
}

Разбор:

  • Объявление long my_add(long a, long b); сообщает компилятору сигнатуру внешней asm-функции.
  • В main вызывается my_add(10, 32), а возвращённый long передаётся в printf.
  • Формат %ld соответствует типу long на целевой платформе Linux x86-64.
  • return 0; завершает программу с кодом успеха.

Сборка:

nasm -f elf64 add.asm -o add.o
gcc main.c add.o -o demo

Разбор:

  • nasm -f elf64 компилирует ассемблер в 64-битный ELF-объект.
  • gcc main.c add.o -o demo компилирует C и линкует его с add.o в один исполняемый файл.
  • На этапе линковки my_add из C сопоставляется с global my_add из NASM.

my_add возвращает значение в RAX (целые до 64 бит). Вещественные — в XMM0 и далее по ABI.

Функция с сохранением callee-saved регистра:

Код ITЗагрузка примера кода…

Разбор:

  • RBX — callee-saved: перед изменением его сохраняют push rbx, перед ret восстанавливают pop rbx.
  • RDI, RSI, RDX — аргументы по System V AMD64 (указатель, длина, множитель).
  • test rsi, rsi / jz .done обрабатывает пустой массив без лишней работы.
  • imul qword [rbx], rdx умножает текущий элемент на месте.
  • Цикл сдвигает указатель на 8 байт (add rbx, 8) и уменьшает счётчик RSI.
  • Без push/pop rbx вызов из C мог бы испортить значение RBX у вызывающего кода.

Возврат float из asm в C:

Код ITЗагрузка примера кода…

float dot_product(const float *a, const float *b, long n);

int main(void) {
float a[] = {1.0f, 2.0f, 3.0f};
float b[] = {4.0f, 5.0f, 6.0f};
printf("%f\n", dot_product(a, b, 3)); /* 32.0 */
}

Разбор:

  • Скалярные float передаются указателями в RDI и RSI, длина — в RDX.
  • xorps xmm0, xmm0 обнуляет аккумулятор (чистый старт суммы).
  • movss / mulss / addss обрабатывают по одному элементу за итерацию.
  • Итоговая сумма остаётся в XMM0 — так C получает возвращаемый float.
  • Указатели сдвигаются на 4 байта (add rdi, 4), потому что float занимает 32 бита.
  • Сборка та же: nasm -f elf64 dot.asm -o dot.o && gcc main.c dot.o -o demo.

C вызывается из ассемблера

main.asm:

Код ITЗагрузка примера кода…

Разбор:

  • global main делает asm-функцию точкой входа уровня libc (main), а не _start.
  • extern printf импортирует функцию из стандартной библиотеки C.
  • Пролог push rbp / mov rbp, rsp / sub rsp, 16 создаёт стековый кадр и сохраняет выравнивание.
  • lea rdi, [rel fmt] кладёт адрес форматной строки в первый аргумент printf.
  • mov rsi, 42 задаёт второй аргумент %ld.
  • xor rax, rax устанавливает AL=0 для variadic-вызова без XMM-аргументов.
  • call printf печатает строку, затем xor eax, eax готовит код возврата 0.
  • leave и ret корректно закрывают кадр и возвращают управление runtime.

Сборка: nasm -f elf64 main.asm -o main.o && gcc main.o -o demo -no-pie (или точка входа main через стандартный crt).

Важно для вызовов libc:

  • Перед call к функциям вроде printf стек RSP должен быть кратен 16 (после call внутри libc снова выровняют; типичный пролог push rbp; mov rbp,rsp; sub rsp,N с нечётным числом push ломает выравнивание).
  • Для variadic (printf, scanf) в AL передают число использованных XMM-аргументов.

Кто сохраняет регистры

По System V AMD64 (кратко):

Сохраняет вызывающий (caller-saved)Сохраняет вызываемый (callee-saved)
RAX, RCX, RDX, RSI, RDI, R8–R11, XMM0–XMM15RBX, RBP, R12–R15

Если ассемблерная функция портит RBX, она обязана восстановить его до ret. Если C вызывает ваш код и вы меняете R12R15 — то же самое.

Подробнее — Команды и подпрограммы.


C++ и extern "C"

Имена в C++ декорируются (_Z3fooil). Ассемблерный модуль для линковки с C++:

extern "C" int asm_helper(int x);

Разбор:

  • extern "C" отключает C++ name mangling для указанной функции.
  • Благодаря этому линкер ищет символ asm_helper в "C-форме" имени.
  • Сигнатура в C++ должна совпадать с реальной asm-реализацией по ABI и типам.

и в NASM: global asm_helper.


Точка входа — _start и main

ТочкаКто инициализирует
_startникто — сами syscall, без libc
mainкод запуска из crt (libc), можно printf, malloc

Смешивать в одном проекте два global main или _start нельзя.


Практический минимум для горячего пути

Типичный паттерн оптимизации: ядро на ассемблере, обвязка на C.

// crypto_wrapper.c
extern void block_transform(const unsigned char *in, unsigned char *out);

void process_buffer(const unsigned char *buf, size_t len) {
unsigned char tmp[16];
for (size_t i = 0; i < len; i += 16)
block_transform(buf + i, tmp);
}

Разбор:

  • extern void block_transform(...) объявляет внешнюю asm-функцию блочной обработки.
  • tmp[16] задаёт временный буфер размером в один блок.
  • Цикл шагает по входному буферу с шагом 16 байт.
  • На каждой итерации передаются адрес текущего блока buf + i и адрес выходного буфера tmp.
  • Основная логика управления остаётся на C, а вычислительно дорогая часть уходит в asm.

Ассемблер реализует block_transform с фиксированным ABI; C не знает про регистры внутри.

Реализация block_transform на asm:

Код ITЗагрузка примера кода…

Разбор:

  • Сигнатура совпадает с C: два указателя в RDI и RSI.
  • mov rcx, 16 задаёт фиксированный размер блока (16 байт).
  • loop .copy повторяет тело, пока RCX не станет нулём (удобно для фиксированного размера).
  • xor al, 0x5A — учебная "обработка" байта; в реальном коде здесь будет криптопримитив.
  • ret возвращает управление в C-цикл process_buffer без изменения callee-saved регистров.
  • C-обёртка и asm-ядро линкуются одной командой gcc wrapper.c block.o -o demo.

Ошибки при стыковке

СимптомВероятная причина
Segfault в printfстек не выровнен на 16
Неверный результатаргументы не в rdi/rsi/...
undefined referenceнет global в .asm или другое имя
Работает в C, падает из asmиспорчен callee-saved регистр

Короткий pre-flight перед линковкой

Перед запуском полезно проверить 6 пунктов:

  1. Совпадают ли имена символов в C и NASM.
  2. Совпадают ли размеры типов (int, long, указатели) для целевой платформы.
  3. Соблюдён ли ABI целевой ОС (System V vs Microsoft x64).
  4. Выровнен ли стек перед вызовом функций libc.
  5. Восстановлены ли callee-saved регистры.
  6. Нет ли смешения 32/64-битных объектных файлов.

После такой проверки интеграция asm/C обычно проходит с первого-второго запуска.


Связанные материалы