Начну с небольшого экскурса в процесс загрузки. Большинство из вас уже знает, что в загрузке ОС существует множество фаз, две из которых, к примеру, загрузка загрузчика (простите за тавтологию) и загрузка им ядра ОС, в нашем случае Linux. Давайте чуть копнём в сторону, что же происходит в момент после передачи управления загрузчиком ядру Linux.
Условно ядро Linux можно разделить на две части по исполнению — загрузочная и исполняемая. После получения управления ядром оно исполняет загрузочную часть, на которую приходится декомпрессия и расположение ядра в физической памяти системы. Затем происходит минимальная настройка менеджера памяти, детектирования типа процессора и его флагов и т.п. После осуществления этих шагов передаётся управление в код, где уже непосредственно начинает работать непривязанная к архитектуре часть ядра (строго говоря это не совсем так, но здесь мы подчеркиваем переход от ассемблерного кода к Си коду). Более подробно процесс описан в [1].
Теперь вспомним ещё такой факт, что в современных процессорах используется т.н. «микрокод», конфигурирующий исполнение некоторых инструкций процессора. Также он позволяет устранять некоторые недочёты железа без перевыпуска кристалла.
Естественным желанием разработчиков ядра любой ОС является возможность наложить исправления как можно раньше в процессе загрузки. Ранее, в Linux, этим вопросом занимались специальные демоны в пространстве пользователя, загружающиеся на довольно позднем этапе.
Несколько лет назад Fenghua Yu предложил (см. [2]) класть файл микрокода в начальный образ RAM-диска (initrd) и использовать его на ранних стадиях. Изменение сильно улучшило ситуацию, но остались всё ещё недостатки, в частности необходимость начального образа диска и невозможность держать микрокод для разных версий процессоров, так как имя файла фиксированное.
Совсем недавно Borislav Petkov решил исправить первый из них, опубликовав изменение [3].
Вот тут-то и начинаются пляски. Вызов функции
load_ucode_bsp()
для 64-битных и 32-битных ядер происходит из разных по своей сути мест процесса загрузки ядра. В 64-битном окружении вызов совершается уже из Си-кода, где MMU и программный менеджер памяти проинициализированы, но в 32-битном случае он происходит сильно раньше.Эффектом сего поведения стало вот что. Рассмотрим причинную функцию load_builtin_intel_microcode(), которая исполняется на раннем этапе.
static bool __init load_builtin_intel_microcode(struct cpio_data *cp)
{
unsigned int eax = 0x00000001, ebx, ecx = 0, edx;
unsigned int family, model, stepping;
char name[30];
native_cpuid(&eax, &ebx, &ecx, &edx);
family = __x86_family(eax);
model = x86_model(eax);
stepping = eax & 0xf;
sprintf(name, "intel-ucode/%02x-%02x-%02x", family, model, stepping);
return get_builtin_firmware(cp, name);
}
Обратите внимание на вызов внутриядерной библиотечной функции
sprintf()
. Именно её вызов в независимости от параметров (при условии их корректности) рушит систему.Что же там происходит? Мой коллега, MIka Westerberg, предположил, что причина именно в столь раннем вызове кода, когда на самом деле функции вызываются по их физическим адресам, а не виртуальным. Пока не настроено MMU и не проинициализирован менеджер памяти, виртуальные адреса не работают, поэтому для выполнения необходимо соответствие между виртуальными и физическими адресами 1-в-1, чего не наблюдается для части функционала. (Кстати, если попытаться вызвать
strcpy()
, то результат будет таким же.)Невдалеке маячит merge window (о нём я немного рассказывал ранее в [4]), и Borislav решил пока что отключить своё изменение для 32-битных ядер, выслав обновление [5].
Мораль басни такова, что загрузка ОС — весьма тонкий процесс, требующий довольно глубоких знаний архитектуры, чтобы понимать происходящее там.
[1] www.ibm.com/developerworks/library/l-linuxboot
[2] lwn.net/Articles/530346
[3] www.spinics.net/lists/linux-tip-commits/msg28000.html
[4] habrahabr.ru/post/253421
[5] permalink.gmane.org/gmane.linux.kernel/1969480
UPDATE.
Совершенно забыл дописать одно важное замечание. Множество разработчиков тестирует свой код не на реальных машинах, а в виртуальных, с помощью того же QEMU. Так вот там всё прекрасно работает.
В комментариях jcmvbkbc поделился своим анализом происходящего.
Комментарии (46)
jcmvbkbc
04.06.2015 16:58+5Какая-то недосказанность в этой истории: вроде уже С-код исполняется, функции вызываются. Что особенного в функции sprintf?
Исследований по поиску причин тоже не видно.no111u3
04.06.2015 17:05Особенного ничего нету, просто для части функций нету проекции virt-to-real. Поэтому адреса получившиеся после линковки оказались недействительными, в этом и вся ошибка.
jcmvbkbc
04.06.2015 17:21для части функций нету проекции virt-to-real
Да ладно.
Функции из arch/x86/boot можно звать, потому что они слинкованы по адресам реального режима, а остальные — нет, так?no111u3
04.06.2015 17:28Для экскурсии можете посмотреть System.map, в частности тому же spirntf отводится верхняя граница адресов в памяти (виртуальный адрес).
jcmvbkbc
04.06.2015 18:04System.map описывает vmlinux, там вообще ни одного адреса ниже 3G нет.
Тем не менее, код выполняется в реальном режиме и startup_32 вызывает load_ucode_bsp в реальном режиме.
c1ae13da T load_ucode_bsp
и это работает, как я понимаю, только потому что код position-independent.no111u3
04.06.2015 18:15Какая то часть да, в том числе и инициализация, загрузчик и распаковщик. Однако остальная часть кода имеет фиксированные адреса и точки входа. И после загрузки для них с помощью MMU меняется виртуальный адрес (для тех устройств где есть MMU).
jcmvbkbc
04.06.2015 18:20+1no111u3 «загрузчик» и «распаковщик» уже закончились к началу startup_32, MMU ещё не инициализирован на момент вызова load_ucode_bsp.
У меня был простой вопрос: «что особенного в sprintf», если вам не терпится ответить — ответьте пожалуйста на него.no111u3
04.06.2015 18:29/*
* Calculate the delta between where we were compiled to run
* at and where we were actually loaded at.
То что он вычислил куда прыгнуть, а вот несчастный sprintf он таким образом не может использовать, т.к. он был встроен при компиляции и вызов для него рассчитывался исходя из виртуальной адресации. Как говорится всё бы было хорошо, но адрес вызова sprintf который был подставлен виртуальный, и чтобы его преобразовать в реальный нужно знать об этом.
jcmvbkbc
04.06.2015 18:37+1Сдаётся мне, ничего особенного в нём нет, и следующая замена скорее всего починила бы это место:
sprintf(name, __pa_nodebug("intel-ucode/%02x-%02x-%02x"), family, model, stepping);
Но в целом, конечно, поддерживать этот код — это реальная жуть.andy_shev Автор
04.06.2015 19:06+1Вы невнимательно прочитали мой пост. параметры не имеют значения, я даже проверил ваше предположение — не работает. Проблема в самом символе
sprintf
.jcmvbkbc
04.06.2015 19:08+1Я тоже проверил своё предложение. Действительно не работает.
Однако я точно попадаю в sprintf и дальше в vsprintf (по реальным адресам, разумеется). Дальше пока не смотрел.andy_shev Автор
04.06.2015 19:14Будет интересно увидеть ваш анализ.
jcmvbkbc
04.06.2015 19:31+5Проблема из-за того, что компилятор реализовал switch в vsnprintf через таблицу переходов. Я вижу два вот таких стрёмных места:
c12b905b: ff 24 85 54 c6 7f c1 jmp *-0x3e8039ac(,%eax,4) c12b905e: R_386_32 .rodata
Разумеется в .rodata абсолютные виртуальные адреса.
Может это место скомпилировать с -fPIC?andy_shev Автор
04.06.2015 19:39Процитирую Borislav'а: …even if we build the string properly, we choke later in get_builtin_firmware().
А так похоже, что это хорошее объяснение поведенияsprintf()
.jcmvbkbc
04.06.2015 19:44+3Это понятно, что весь этот код работает на честном слове, и даже если пофиксить здесь то повалится там.
Я реально удивлён что этот код никак не отделён от остального и не собирается как-то особенно. А например, начнёт завтра gcc все свитчи делать таблицами переходов…no111u3
04.06.2015 19:47Так никто и не рассчитывал что подобный код будет исполнятся из разных адресных пространств. Разбить на независимые модули — да, и ещё раз да. Но опять же необходимо проработать архитектуру, чтобы это работало.
KoCMoHaBT61
05.06.2015 15:45Ну нифига себе! Это прямо жабство какое-то.
А как ещё надо свичи делать? Серией ifов?
jcmvbkbc
04.06.2015 20:38+5Немного подробностей:
вставляем sprintf в удобное место, я сделал так:
void __init load_ucode_bsp(void) { int vendor, family; + char str[100]; + + sprintf(str, __pa_nodebug(":%d"), 1234); if (check_loader_disabled_bsp()) return;
конфигурируем ядро, включаем CONFIG_DEBUG_INFO и собираем ядро. Загружаем его в qemu:
$ qemu-system-i386 -kernel arch/x86/boot/bzImage -s -S
Запускаем gdb, загружаем символы, соединяемся с qemu:
$ gdb (gdb) target remote :1235 Remote debugging using :1235 0x0000fff0 in ?? () (gdb) add-symbol-file vmlinux 0x1000000 add symbol table from file "vmlinux" at .text_addr = 0x1000000 (y or n) y Reading symbols from /home/jcmvbkbc/ws/tensilica/linux/z/vmlinux...done. (gdb) b sprintf Breakpoint 1 at 0x12b9150: file /home/jcmvbkbc/ws/tensilica/linux/linux-xtensa/lib/vsprintf.c, line 2120. (gdb) c Continuing. Breakpoint 1, sprintf (buf=<error reading variable: can't compute CFA for this frame>, fmt=<error reading variable: can't compute CFA for this frame>) at /home/jcmvbkbc/ws/tensilica/linux/linux-xtensa/lib/vsprintf.c:2120 .... 1885 switch (spec.type) { 1: x/10i $pc => 0x12b8e88 <vsnprintf+232>: jmp *-0x3e8039cc(,%eax,4) (gdb) si 0x00000000 in ?? () 1: x/10i $pc => 0x0: push %ebx
В сессии gdb работает source-level отладка.jcmvbkbc
04.06.2015 20:46+1target remote :1235 читать как target remote :1234.
qemu запущенный с -s ожидает gdb на порте 1234, чтобы перевесить gdbserver на другой порт вместо -s можно указать -gdb tcp::port
jcmvbkbc
08.06.2015 17:11Может это место скомпилировать с -fPIC?
Попробовал, не компилируется. Похоже из-за inline assembly или явного использования регистров.
Однако нашёл опцию -fno-jump-tables отключающую именно это поведение.
jcmvbkbc
04.06.2015 20:20+1Множество разработчиков тестирует свой код не на реальных машинах, а в виртуальных, с помощью того же QEMU. Так вот там всё прекрасно работает.
Двусмысленно. sprintf вызванный из реального режима там точно так же падает. Т.е. «не работает». Т.е. qemu достаточно точно эмулирует для воспроизведения и отладки этого бага (до свитча реализованного таблицей я дошёл как раз в qemu). Т.е. «работает».
KoCMoHaBT61
05.06.2015 08:13#define GETASCII(a) a>9? a+'a': a+'\0'
char name[]=«intel-ucode/00-00-00»;
char b;
b=family>>8;
name[12]=GETASCII(b);
b=family & 0x0F;
name[13]=GETASCII(b);
b=model>>8;
name[15]=GETASCII(b);
b=model & 0x0F;
name[16]=GETASCII(b);
name[19]=GETASCII(stepping);KoCMoHaBT61
05.06.2015 08:20Надо-же, облажался… :(
andy_shev Автор
05.06.2015 11:24Причём функция преобразования доступна, вот я рассказывал здесь: habrahabr.ru/post/252453 (см. главу Бонусы). И предложенное мной исправление в этом же состояло. :-)
foxin
А как вообще тестируются коммиты в ядро линукса?
no111u3
Тестируется на собираемость и на запускаемость в той же кему. А то что много различных устройств нельзя так протестировать, и что без тестов и всесторонней проверки кода ядра, это опускается.
andy_shev Автор
Отличный вопрос! На самом деле помимо разрозненных девелоперских тестов и тестов различных команд / подсистем (например, mmtests) существует проект 0-day kernel test. Подробнее можно почитать здесь: lwn.net/Articles/514278.
no111u3
Вообще хорошим подходом, было бы вычленять системы из реального кода и прогонять их на реакцию. Но в виду того что опять же не всё поддаётся отладке (отладчики на тот же интел стоят баснословных денег и редки в природе), и не везде можно собрать статистику появляются различные мигрирующие и случайные баги.
andy_shev Автор
Кстати, Intel продвигает Intel® Processor Trace, которая при помощи Trace Hub позволит обойтись без этих баснословно стоящих устройств, если я правильно понимаю.
no111u3
Ну да, т.к. помимо отладчика (который я видел только на картинках), нужна ещё и соответствующая мат-плата. Одна такая, правда под амд мне всё же попадалась, но скорее всего из-за задержки в релизе (выпустили дебаг версию, т.к. некогда было править рабочую версию).
jcmvbkbc
Обычным людям вполне хватает qemu.
no111u3
Обычные люди не пишут настолько низкоуровневый код, что вызовы в нём ещё и надо согласовывать между собой.
no111u3
Ну и да, qemu ну вот нисколько не предназначен для отладки ядра, равно как и другая виртуальная машина с «быстрой» симуляцией. Тут нужен полноценный эмулятор процессора и его окружения.
jcmvbkbc
Возможно вам тогда интересно будет узнать, что в течение трёх последних лет я разрабатываю и отлаживаю linux для xtensa преимущественно с помощью qemu. И могу сказать, что для подобных чисто софтверных багов он подходит отлично. А кроме того, «быстрая» симуляция не значит «плохая».
no111u3
«быстрая» обозначает то что не все инструкции будут исполнятся также как и на реальном железе, с тем же отношением. Также большинство моделей процессоров и то что есть в qemu не сходятся по разным показателям. Никто не говорит что нельзя по qemu разрабатывать, но при этом не стоит забывать что это симулятор и то насколько он соответствует реальной модели не знает никто.
Поэтому без реального железа всё равно нельзя говорить что код исправен — типичный пример atmel: чередуя релизы разработчики ядра по очереди ломают одно из устройств, а точнее его драйвер.
jcmvbkbc
Ну вообще-то «быстрая» в контексте qemu обозначает, что используется бинарная трансляция совместно с разнообразными трюками для поддержки MMU, что быстрее наивной интерпретации.
На качество эмуляции эта быстрота не влияет, насколько точно поддерживать архитектуру решает разработчик.
Знает как минимум человек, знакомый с железом и заглянувший в реализацию qemu. А ещё есть тесты.
no111u3
Не забываем что там не только MMU но и другие привилегированные инструкции, а также немного изменённое адресное пространство, которое работает не так, как реальный режим процессора.
jcmvbkbc
Расскажите поподробнее об изменённом адресном пространстве? И о том, что может помешать разработчику учесть эти изменения?
no111u3
То что это не соответствует тому что он ожидает, и это мешает ему увидеть ошибки подобные описанной в статье.