Как синхронизируется кэш инструкций x86?

Мне нравятся примеры, поэтому я написал немного самоизменяющегося кода на C...

#include <stdio.h>
#include <sys/mman.h> // linux

int main(void) {
    unsigned char *c = mmap(NULL, 7, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|
                            MAP_ANONYMOUS, -1, 0); // get executable memory
    c[0] = 0b11000111; // mov (x86_64), immediate mode, full-sized (32 bits)
    c[1] = 0b11000000; // to register rax (000) which holds the return value
                       // according to linux x86_64 calling convention 
    c[6] = 0b11000011; // return
    for (c[2] = 0; c[2] < 30; c[2]++) { // incr immediate data after every run
        // rest of immediate data (c[3:6]) are already set to 0 by MAP_ANONYMOUS
        printf("%d ", ((int (*)(void)) c)()); // cast c to func ptr, call ptr
    }
    putchar('\n');
    return 0;
}

... который работает, по-видимому:

>>> gcc -Wall -Wextra -std=c11 -D_GNU_SOURCE -o test test.c; ./test
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29

Но, честно говоря, я не ожидал, что это сработает вообще. Я ожидал инструкции, содержащей c[2] = 0 быть кэшированным при первом вызове cпосле чего все последовательные звонки c будет игнорировать повторные изменения, внесенные в c (если я каким-то образом явно не сделал кэш недействительным). К счастью, мой процессор оказался умнее этого.

Я думаю, что процессор сравнивает оперативную память (при условии, c даже находится в ОЗУ) с кешем инструкций всякий раз, когда указатель инструкций делает скачок большого размера (как при обращении к mmapped памяти выше), и делает недействительным кеш, когда он не совпадает (все это?), но я Я надеюсь получить более точную информацию об этом. В частности, я хотел бы знать, можно ли считать это поведение предсказуемым (за исключением каких-либо различий в оборудовании и ОС) и на которое можно положиться?

(Вероятно, мне следует обратиться к руководству Intel, но эта страница насчитывает тысячи страниц, и я, как правило, теряюсь в ней...)

5 ответов

Решение

То, что вы делаете, обычно называют самоизменяющимся кодом. Платформы Intel (и, вероятно, тоже AMD) выполняют работу по поддержанию когерентности i/d-кэша, как указано в руководстве ( Руководство 3A, Системное программирование)

11.6 КОД САМОМОДИФИКАЦИИ

Запись в область памяти в сегменте кода, который в настоящее время кэшируется в процессоре, приводит к тому, что соответствующая строка (или строки) кэша становится недействительной.

Но это утверждение верно, если для модификации и выборки используется один и тот же линейный адрес, что не относится к отладчикам и двоичным загрузчикам, поскольку они не работают в одном и том же адресном пространстве:

Приложения, которые включают самоизменяющийся код, используют один и тот же линейный адрес для изменения и извлечения инструкции. Системное программное обеспечение, такое как отладчик, которое может изменить инструкцию, используя линейный адрес, отличный от того, который использовался для извлечения инструкции, выполнит операцию сериализации, такую ​​как инструкция CPUID, перед выполнением модифицированной инструкции, которая автоматически выполнит повторную синхронизацию кеш инструкций и очередь предвыборок.

Например, операция сериализации всегда запрашивается многими другими архитектурами, такими как PowerPC, где она должна выполняться явно ( Руководство по ядру E500):

3.3.1.2.1 Самоизменяющийся код

Когда процессор изменяет любую область памяти, которая может содержать инструкцию, программное обеспечение должно гарантировать, что кэш команд сделан совместимым с памятью данных и чтобы изменения были видны механизму выборки команд. Это необходимо сделать, даже если кеш отключен или страница помечена как запрещенная.

Интересно отметить, что PowerPC требует выдачи инструкции для синхронизации контекста, даже когда кэши отключены; Я подозреваю, что это вызывает сброс более глубоких блоков обработки данных, таких как буферы загрузки / хранения.

Код, который вы предложили, ненадежен на архитектурах без отслеживания или расширенных возможностей когерентности кэша и, следовательно, может привести к сбою.

Надеюсь, это поможет.

Это довольно просто; запись по адресу, который находится в одной из строк кэша в кэше инструкций, делает его недействительным из кэша инструкций. Никакой "синхронизации" не происходит.

Кстати, многие процессоры x86 (над которыми я работал) отслеживают не только кэш команд, но и конвейер, окно команд - инструкции, которые в данный момент находятся в полете. Таким образом, самоизменяющийся код вступит в силу в следующей инструкции. Но вам рекомендуется использовать команду сериализации, такую ​​как CPUID, чтобы гарантировать, что ваш вновь написанный код будет выполнен.

Я только что попал на эту страницу в одном из моих поисков и хочу поделиться своими знаниями в этой области ядра Linux!

Ваш код выполняется должным образом, и для меня здесь нет сюрпризов. Системный протокол mmap() и протокол Cache Coherency делают это за вас. Флаги "PROT_READ|PROT_WRITE|PROT_EXEC" просит mmamp() правильно установить iTLB, dTLB кеша L1 и TLB кеша L2 этой физической страницы. Этот код ядра для архитектуры низкого уровня делает это по-разному в зависимости от архитектуры процессора (x86,AMD,ARM,SPARC и т. Д.). Любая ошибка в ядре может испортить вашу программу!

Это только для объяснения. Предположим, что ваша система мало что делает, и между "a[0]=0b01000000;" нет переключателей процессов. и запуск "printf("\n"):"... Кроме того, предположим, что у вас 1 Кбайт L1 iCache, 1 Кбайт dCache в вашем процессоре и немного кеша L2 в ядре, . (Теперь дни это порядка нескольких МБ)

  1. mmap() устанавливает ваше виртуальное адресное пространство и iTLB1, dTLB1 и TLB2.
  2. "А [0] = 0b01000000;" будет фактически перехватывать (H/W magic) код ядра, и ваш физический адрес будет настроен, и все TLB процессора будут загружены ядром. Затем вы вернетесь в режим пользователя, и ваш процессор фактически загрузит 16 байтов (H/W magic a[0] to [3]) в L1 dCache и L2 Cache. Процессор снова действительно войдет в память, только когда вы ссылаетесь на [4] и так далее (пока игнорируйте загрузку прогноза!). К тому времени, как вы завершите "a[7]=0b11000011;", ваш процессор выполнил 2 пакетных чтения по 16 байтов каждый на вечной шине. До сих пор нет реальных ЗАПИСЕЙ в физическую память. Все WRITE происходят внутри L1 dCache(H/W magic, Processor знает) и L2 кеша, поэтому для строки Cache установлен бит DIRTY.
  3. "А [3]++;" будет иметь инструкцию STORE в коде сборки, но процессор сохранит ее только в L1 dCache&L2 и не перейдет в физическую память.
  4. Давайте перейдем к вызову функции "a()". Снова процессор выполняет извлечение инструкций из кэша L2 в iCache L1 и так далее.
  5. Результат этой программы в пользовательском режиме будет одинаковым на любом Linux под любым процессором из-за правильной реализации низкоуровневого syscall и протокола Cache Coherency!
  6. Если вы пишете этот код в любой среде встроенного процессора без помощи ОС с помощью системного вызова mmap(), вы обнаружите ожидаемую проблему. Это потому, что вы не используете ни H / W механизм (TLB), ни программный механизм (инструкции барьера памяти).

Процессор автоматически обрабатывает аннулирование кэша, вам не нужно ничего делать вручную. Программное обеспечение не может разумно предсказать, что будет или не будет в кэше ЦП в любой момент времени, поэтому об этом позаботится аппаратное обеспечение. Когда процессор увидел, что вы изменили данные, он соответствующим образом обновил свои различные кэши.

Другие вопросы по тегам