Как синхронизируется кеш инструкций 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-память выше) и делает недействительным кеш, когда он не соответствует (все это?), но я надеюсь получить более точную информацию об этом. В частности, я хотел бы знать, можно ли считать это поведение предсказуемым (не допуская каких-либо различий в оборудовании и os) и полагаться?

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

Ответ 1

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

11.6 САМО-ИЗМЕНЕНИЕ КОДА

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

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

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

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

3.3.1.2.1 Самомодифицирующийся код

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

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

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

Надеюсь на эту помощь.

Ответ 2

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

Ответ 3

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

Ответ 4

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

Ответ 5

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

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

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

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