Совсем недавно вышло исправление, устраняющее полное зависание 32-битного ядра Linux при загрузке на процессорах Intel. Здесь небольшая история о том, откуда появилась ошибка и какие проводились исследования по поиску причин её возникновения.

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


  1. foxin
    04.06.2015 15:57

    А как вообще тестируются коммиты в ядро линукса?


    1. no111u3
      04.06.2015 16:22

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


    1. andy_shev Автор
      04.06.2015 16:25
      +8

      Отличный вопрос! На самом деле помимо разрозненных девелоперских тестов и тестов различных команд / подсистем (например, mmtests) существует проект 0-day kernel test. Подробнее можно почитать здесь: lwn.net/Articles/514278.


      1. no111u3
        04.06.2015 16:28

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


        1. andy_shev Автор
          04.06.2015 18:58

          Кстати, Intel продвигает Intel® Processor Trace, которая при помощи Trace Hub позволит обойтись без этих баснословно стоящих устройств, если я правильно понимаю.


          1. no111u3
            04.06.2015 19:05

            Ну да, т.к. помимо отладчика (который я видел только на картинках), нужна ещё и соответствующая мат-плата. Одна такая, правда под амд мне всё же попадалась, но скорее всего из-за задержки в релизе (выпустили дебаг версию, т.к. некогда было править рабочую версию).


            1. jcmvbkbc
              04.06.2015 19:09

              Обычным людям вполне хватает qemu.


              1. no111u3
                04.06.2015 19:12

                Обычные люди не пишут настолько низкоуровневый код, что вызовы в нём ещё и надо согласовывать между собой.


              1. no111u3
                04.06.2015 19:23

                Ну и да, qemu ну вот нисколько не предназначен для отладки ядра, равно как и другая виртуальная машина с «быстрой» симуляцией. Тут нужен полноценный эмулятор процессора и его окружения.


                1. jcmvbkbc
                  04.06.2015 19:38

                  qemu ну вот нисколько не предназначен для отладки ядра

                  Возможно вам тогда интересно будет узнать, что в течение трёх последних лет я разрабатываю и отлаживаю linux для xtensa преимущественно с помощью qemu. И могу сказать, что для подобных чисто софтверных багов он подходит отлично. А кроме того, «быстрая» симуляция не значит «плохая».


                  1. no111u3
                    04.06.2015 19:45

                    «быстрая» обозначает то что не все инструкции будут исполнятся также как и на реальном железе, с тем же отношением. Также большинство моделей процессоров и то что есть в qemu не сходятся по разным показателям. Никто не говорит что нельзя по qemu разрабатывать, но при этом не стоит забывать что это симулятор и то насколько он соответствует реальной модели не знает никто.
                    Поэтому без реального железа всё равно нельзя говорить что код исправен — типичный пример atmel: чередуя релизы разработчики ядра по очереди ломают одно из устройств, а точнее его драйвер.


                    1. jcmvbkbc
                      04.06.2015 19:56

                      Ну вообще-то «быстрая» в контексте qemu обозначает, что используется бинарная трансляция совместно с разнообразными трюками для поддержки MMU, что быстрее наивной интерпретации.
                      На качество эмуляции эта быстрота не влияет, насколько точно поддерживать архитектуру решает разработчик.

                      насколько он соответствует реальной модели не знает никто

                      Знает как минимум человек, знакомый с железом и заглянувший в реализацию qemu. А ещё есть тесты.


                      1. no111u3
                        04.06.2015 19:58

                        Не забываем что там не только MMU но и другие привилегированные инструкции, а также немного изменённое адресное пространство, которое работает не так, как реальный режим процессора.


                        1. jcmvbkbc
                          04.06.2015 20:03

                          немного изменённое адресное пространство, которое работает не так, как реальный режим процессора

                          Расскажите поподробнее об изменённом адресном пространстве? И о том, что может помешать разработчику учесть эти изменения?


                          1. no111u3
                            04.06.2015 20:07

                            То что это не соответствует тому что он ожидает, и это мешает ему увидеть ошибки подобные описанной в статье.


  1. jcmvbkbc
    04.06.2015 16:58
    +5

    Какая-то недосказанность в этой истории: вроде уже С-код исполняется, функции вызываются. Что особенного в функции sprintf?
    Исследований по поиску причин тоже не видно.


    1. no111u3
      04.06.2015 17:05

      Особенного ничего нету, просто для части функций нету проекции virt-to-real. Поэтому адреса получившиеся после линковки оказались недействительными, в этом и вся ошибка.


      1. jcmvbkbc
        04.06.2015 17:21

        для части функций нету проекции virt-to-real

        Да ладно.
        Функции из arch/x86/boot можно звать, потому что они слинкованы по адресам реального режима, а остальные — нет, так?


        1. no111u3
          04.06.2015 17:28

          Для экскурсии можете посмотреть System.map, в частности тому же spirntf отводится верхняя граница адресов в памяти (виртуальный адрес).


          1. jcmvbkbc
            04.06.2015 18:04

            System.map описывает vmlinux, там вообще ни одного адреса ниже 3G нет.
            Тем не менее, код выполняется в реальном режиме и startup_32 вызывает load_ucode_bsp в реальном режиме.

            c1ae13da T load_ucode_bsp
            

            и это работает, как я понимаю, только потому что код position-independent.


            1. no111u3
              04.06.2015 18:15

              Какая то часть да, в том числе и инициализация, загрузчик и распаковщик. Однако остальная часть кода имеет фиксированные адреса и точки входа. И после загрузки для них с помощью MMU меняется виртуальный адрес (для тех устройств где есть MMU).


              1. jcmvbkbc
                04.06.2015 18:20
                +1

                no111u3 «загрузчик» и «распаковщик» уже закончились к началу startup_32, MMU ещё не инициализирован на момент вызова load_ucode_bsp.
                У меня был простой вопрос: «что особенного в sprintf», если вам не терпится ответить — ответьте пожалуйста на него.


                1. 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 который был подставлен виртуальный, и чтобы его преобразовать в реальный нужно знать об этом.


                  1. jcmvbkbc
                    04.06.2015 18:59
                    +1

                    Да ни при чём тут адрес вызова sprintf. Вызывается она нормально, это легко проверить.


                    1. no111u3
                      04.06.2015 19:01

                      Вызовется то да, да вот работать не будет нормально (т.к. внутри него адреса то другие).


                1. jcmvbkbc
                  04.06.2015 18:37
                  +1

                  Сдаётся мне, ничего особенного в нём нет, и следующая замена скорее всего починила бы это место:

                          sprintf(name, __pa_nodebug("intel-ucode/%02x-%02x-%02x"), family, model, stepping);
                  

                  Но в целом, конечно, поддерживать этот код — это реальная жуть.


                  1. no111u3
                    04.06.2015 19:01

                    Ну лучше пока не сделали, да и не будут.


                  1. andy_shev Автор
                    04.06.2015 19:06
                    +1

                    Вы невнимательно прочитали мой пост. параметры не имеют значения, я даже проверил ваше предположение — не работает. Проблема в самом символе sprintf.


                    1. jcmvbkbc
                      04.06.2015 19:08
                      +1

                      Я тоже проверил своё предложение. Действительно не работает.
                      Однако я точно попадаю в sprintf и дальше в vsprintf (по реальным адресам, разумеется). Дальше пока не смотрел.


                      1. jcmvbkbc
                        04.06.2015 19:13

                        Ну и если вызывать strcpy то она вполне работает с такой заменой.


                      1. andy_shev Автор
                        04.06.2015 19:14

                        Будет интересно увидеть ваш анализ.


                        1. 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?


                          1. andy_shev Автор
                            04.06.2015 19:39

                            Процитирую Borislav'а: …even if we build the string properly, we choke later in get_builtin_firmware().
                            А так похоже, что это хорошее объяснение поведения sprintf().


                            1. jcmvbkbc
                              04.06.2015 19:44
                              +3

                              Это понятно, что весь этот код работает на честном слове, и даже если пофиксить здесь то повалится там.
                              Я реально удивлён что этот код никак не отделён от остального и не собирается как-то особенно. А например, начнёт завтра gcc все свитчи делать таблицами переходов…


                              1. no111u3
                                04.06.2015 19:47

                                Так никто и не рассчитывал что подобный код будет исполнятся из разных адресных пространств. Разбить на независимые модули — да, и ещё раз да. Но опять же необходимо проработать архитектуру, чтобы это работало.


                              1. KoCMoHaBT61
                                05.06.2015 15:45

                                Ну нифига себе! Это прямо жабство какое-то.
                                А как ещё надо свичи делать? Серией ifов?


                          1. 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 отладка.


                            1. jcmvbkbc
                              04.06.2015 20:46
                              +1

                              target remote :1235 читать как target remote :1234.
                              qemu запущенный с -s ожидает gdb на порте 1234, чтобы перевесить gdbserver на другой порт вместо -s можно указать -gdb tcp::port


                          1. jcmvbkbc
                            08.06.2015 17:11

                            Может это место скомпилировать с -fPIC?

                            Попробовал, не компилируется. Похоже из-за inline assembly или явного использования регистров.
                            Однако нашёл опцию -fno-jump-tables отключающую именно это поведение.


  1. jcmvbkbc
    04.06.2015 20:20
    +1

    Множество разработчиков тестирует свой код не на реальных машинах, а в виртуальных, с помощью того же QEMU. Так вот там всё прекрасно работает.

    Двусмысленно. sprintf вызванный из реального режима там точно так же падает. Т.е. «не работает». Т.е. qemu достаточно точно эмулирует для воспроизведения и отладки этого бага (до свитча реализованного таблицей я дошёл как раз в qemu). Т.е. «работает».


    1. andy_shev Автор
      04.06.2015 20:31

      А как запускали QEMU? Я ему указал на ядро, initrd и командную строку. Запускается без проблем.


      1. jcmvbkbc
        04.06.2015 20:44
        +1

        Ответил выше. Конечно дерево tip я не собирал, просто вставил sprintf в загрузчик микрокода и походил по нему.


  1. ilammy
    04.06.2015 22:58

    Стиль гиперссылок выдаёт разработчика ядра, привыкшего к простому тексту.


  1. 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);


    1. KoCMoHaBT61
      05.06.2015 08:20

      Надо-же, облажался… :(


      1. andy_shev Автор
        05.06.2015 11:24

        Причём функция преобразования доступна, вот я рассказывал здесь: habrahabr.ru/post/252453 (см. главу Бонусы). И предложенное мной исправление в этом же состояло. :-)