Объяснение работы загрузчиков: как они запускают устройства

Содержание

BootLoader in Embedded System

Что такое загрузчик?

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

Подобно прикладной программе, загрузчик — это автономная программа, которая содержит такие важные компоненты, как код запуска, прерывания, основная программа (функция Boot_main) и, опционально, операционная система. Несмотря на свой небольшой размер, загрузчик включает в себя критически важные функции.

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

Режимы работы загрузчика

Большинство загрузчиков имеют два различных режима работы: режим загрузки и режим загрузки.

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

Режим загрузки:

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

Режим загрузки:

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

Как работает загрузчик?

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

Загрузчик, этап 1

Инициализация аппаратного устройства

  • Отключение всех прерываний: Обработка прерываний обычно входит в обязанности драйверов устройств ОС, поэтому загрузчик может игнорировать ответы на прерывания на протяжении всего своего выполнения. Маскирование прерываний можно осуществить путем изменения регистра маски прерываний или регистра состояния процессора (например, регистра CPSR в ARM).
  • Установка скорости процессора и тактовой частоты.
  • Инициализация ОЗУ: это включает в себя правильную настройку функциональных регистров контроллера памяти системы и различных регистров управления банками памяти.
  • Инициализация светодиодов: светодиоды часто управляются через GPIO для индикации состояния системы (OK или Error). Если светодиоды отсутствуют, для этой цели можно использовать инициализацию UART для вывода логотипа загрузчика или информации о символах через последовательную связь.
  • Отключение внутреннего кэша инструкций/данных ЦП.

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

Для более быстрого выполнения этап 2 обычно загружается в ОЗУ. Поэтому для загрузки этапа 2 загрузчика необходимо выделить доступный диапазон памяти.

Поскольку этап 2 обычно содержит код на языке C, необходимо учитывать как размер исполняемого файла этапа 2, так и пространство стека. Кроме того, пространство должно быть предпочтительно выровнено по размеру страницы памяти (обычно 4 КБ). Как правило, достаточно 1 МБ оперативной памяти. Конкретный диапазон адресов можно выбрать произвольно. Например, обычным подходом является выделение исполняемого образа этапа 2 для выполнения в пространстве 1 МБ, начиная с базового адреса системной ОЗУ 0xc0200000. Однако рекомендуется выделять этап 2 в верхнем 1 МБ всего пространства ОЗУ (т. е. (RamEnd-1MB) — RamEnd).

Обозначим размер выделенного диапазона пространства ОЗУ как «stage2_size» (в байтах), а начальный и конечный адреса как «stage2_start» и «stage2_end» (оба адреса выровнены по границам 4 байта). Таким образом:

stage2_end = stage2_start + stage2_size

Кроме того, необходимо убедиться, что выделенный диапазон адресов действительно является пространством ОЗУ, доступным для записи и чтения. Для этого необходимо протестировать выделенный диапазон адресов. Подходящий метод тестирования, подобный тому, который используется в «blob», заключается в тестировании первых двух слов каждой страницы памяти на возможность чтения и записи.

Копирование второго этапа загрузчика в пространство ОЗУ

Для этого необходимо убедиться в двух моментах:

  1.  Расположение исполняемого образа этапа 2 на твердотельном устройстве хранения.
  2. Начальный адрес пространства ОЗУ.

Установить указатель стека (SP)

Установка указателя стека (sp) подготавливает к выполнению кода на языке C. Обычно значение sp может быть установлено на (stage2_end-4), что представляет верхнюю границу 1 МБ оперативной памяти, выделенной в разделе 3.1.2 (стек растет вниз).

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

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

Bootloader RAM Memory Layout
Bootloader RAM Memory Layout

Перейти к точке входа C этапа 2

После того, как все вышеперечисленное будет готово, можно перейти к этапу 2 загрузчика для выполнения. Например, в системах ARM это можно сделать, изменив регистр ПК на соответствующий адрес. Расположение системной памяти, когда исполняемый образ этапа 2 загрузчика только что был скопирован в пространство ОЗУ, показано на рисунке выше.

Загрузчик, этап 2

Инициализация аппаратного устройства

  • Инициализируйте как минимум один последовательный порт для связи ввода-вывода с пользователями терминалов.
  • Инициализируйте таймеры и другие аппаратные компоненты.

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

Обнаружение отображения системной памяти

Отображение памяти относится к распределению диапазонов адресов в пределах всего физического адресного пространства объемом 4 ГБ для адресации модулей оперативной памяти системы. Например, в процессоре SA-1100 адресный пространство объемом 512 МБ, начинающееся с 0xC000,0000, служит адресным пространством оперативной памяти системы. В случае процессора Samsung S3C44B0X в качестве адресного пространства оперативной памяти системы используется 64 МБ адресного пространства между 0x0c00,0000 и 0x1000,0000. Хотя процессоры обычно резервируют значительную часть адресного пространства для оперативной памяти системы, при создании определенных встроенных систем не все зарезервированное адресные пространство оперативной памяти может быть использовано. Таким образом, встроенные системы часто отображают только часть зарезервированного адресного пространства ОЗУ процессора на блоки ОЗУ, оставляя часть зарезервированного адресного пространства ОЗУ неиспользованной. Учитывая этот факт, этап 2 загрузчика должен проверить все отображение памяти системы, прежде чем предпринимать какие-либо действия (такие как чтение образа ядра, хранящегося во флэш-памяти, в пространство ОЗУ). Ему необходимо знать, какие части зарезервированного адресного пространства ОЗУ процессора действительно отображаются на блоки адресов ОЗУ, а какие находятся в «неиспользуемом» состоянии.

Описание отображения памяти

Следующая структура данных может быть использована для описания непрерывного диапазона адресов в адресной пространстве ОЗУ:

				
					typedef struct memory_area_struct {
    u32 start; /* the base address of the memory region */
    u32 size; /* the byte number of the memory region */
    int used;
} memory_area_t;

				
			

Такие непрерывные диапазоны адресов в адресном пространстве ОЗУ могут находиться в одном из двух состояний:

  1. used=1 указывает, что непрерывный диапазон адресов реализован и действительно сопоставлен блокам ОЗУ.
  2. used=0 указывает, что непрерывный диапазон адресов не реализован в системе и остается неиспользованным.

На основе описанной выше структуры данных memory_area_t все зарезервированное адресным пространством ОЗУ ЦП может быть представлено массивом типа memory_area_t, как показано ниже:

				
					memory_area_t memory_map[NUM_MEM_AREAS] = {
    [0 ... (NUM_MEM_AREAS - 1)] = {
        .start = 0,
        .size = 0,
        .used = 0
    },
};

				
			
Обнаружение отображения памяти

Вот простой, но эффективный алгоритм для обнаружения ситуации отображения памяти во всем адресном пространстве ОЗУ:

				
					/* Initialize the array */
for(i = 0; i < NUM_MEM_AREAS; i++)
    memory_map[i].used = 0;
/* Write 0 to all memory locations */
for(addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE)
    *(u32 *)addr = 0;
for(i = 0, addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE) {
    /*
     * Check whether the address space starting from base address
     * MEM_START + i * PAGE_SIZE, with a size of PAGE_SIZE, is a valid RAM address space.
     */
    Call the algorithm test_mempage() from section 3.1.2;
    if (current memory page is not a valid RAM page) {
        /* no RAM here */
        if (memory_map[i].used )
            i++;
        continue;
    }
     
    /*
     * The current page is a valid address range mapped to RAM.
     * However, we need to determine if it's an alias of some address page within the 4GB address space.
     */
    if (*(u32 *)addr != 0) { /* alias? */
        /* This memory page is an alias of an address page within the 4GB address space. */
        if (memory_map[i].used )
            i++;
        continue;
    }
     
    /*
     * The current page is a valid address range mapped to RAM,
     * and it's not an alias of an address page within the 4GB address space.
     */
    if (memory_map[i].used == 0) {
        memory_map[i].start = addr;
        memory_map[i].size = PAGE_SIZE;
        memory_map[i].used = 1;
    } else {
        memory_map[i].size += PAGE_SIZE;
    }
} /* end of for (…) */

				
			

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

Загрузка образов ядра и корневой файловой системы

Планирование расположения памяти

Это включает в себя два аспекта:

  1. область памяти, занимаемая образом ядра;
  2. область памяти, занимаемая корневой файловой системой. При планировании распределения памяти учитывайте базовый адрес и размер образов.

Образ ядра обычно копируется в диапазон памяти, начинающийся с (MEM_START + 0x8000), размером примерно 1 МБ (встроенные ядра Linux обычно занимают менее 1 МБ). Зачем оставлять 32 КБ пространства от MEM_START до MEM_START + 0x8000? Это связано с тем, что ядро Linux размещает в этом сегменте памяти определенные глобальные структуры данных, такие как параметры загрузки и таблицы страниц ядра.

Образ корневой файловой системы обычно копируется в место, начинающееся с MEM_START + 0x0010,0000. Если в качестве образа корневой файловой системы используется Ramdisk, размер без сжатия обычно составляет около 1 МБ.

Копирование из Flash

Поскольку встроенные процессоры, такие как ARM, обычно обращаются к Flash и другим твердотельным устройствам хранения данных в рамках единого адресного пространства памяти, чтение данных из Flash аналогично чтению из модулей RAM. Для копирования образа из устройства Flash достаточно простого цикла:

				
					while (count) {
    *dest++ = *src++; /* they are all aligned with word boundary */
    count -= 4; /* byte number */
};

				
			

Настройка параметров загрузки ядра

После копирования образа ядра и образа корневой файловой системы в пространство ОЗУ можно приступить к подготовке запуска ядра Linux. Но перед вызовом ядра необходимо выполнить подготовительный шаг: настроить параметры загрузки ядра Linux.

Ядра Linux, начиная с версии 2.4.x, ожидают, что параметры загрузки будут переданы в виде списка с тегами. Список тегов параметров загрузки начинается с тега ATAG_CORE и заканчивается тегом ATAG_NONE. Каждый тег состоит из структуры tag_header, идентифицирующей параметр, за которой следуют структуры данных, содержащие значения параметров. Структуры данных tag и tag_header определены в заголовочном файле include/asm/setup.h исходного кода ядра Linux.

В встроенных системах Linux общие параметры загрузки, которые необходимо установить с помощью загрузчика, включают ATAG_CORE, ATAG_MEM, ATAG_CMDLINE, ATAG_RAMDISK и ATAG_INITRD.

Например, вот как установить ATAG_CORE:

				
					params = (struct tag *)BOOT_PARAMS;
params->hdr.tag = ATAG_CORE;
params->hdr.size = tag_size(tag_core);
params->u.core.flags = 0;
params->u.core.pagesize = 0;
params->u.core.rootdev = 0;
params = tag_next(params);

				
			

Здесь BOOT_PARAMS представляет начальный базовый адрес параметров загрузки ядра в памяти, а указатель params имеет тип struct tag. Макрокоманда tag_next() вычисляет начальный адрес следующего тега, следующего сразу за текущим тегом. Важно отметить, что здесь устанавливается идентификатор устройства для корневой файловой системы ядра.

Ниже приведен пример кода для установки информации о сопоставлении памяти:

				
					for (i = 0; i < NUM_MEM_AREAS; i++) {
    if (memory_map[i].used) {
        params->hdr.tag = ATAG_MEM;
        params->hdr.size = tag_size(tag_mem32);
        params->u.mem.start = memory_map[i].start;
        params->u.mem.size = memory_map[i].size;
        params = tag_next(params);
    }
}

				
			

В массиве memory_map[] каждый действительный сегмент памяти соответствует тегу параметра ATAG_MEM.

Ядро Linux может получать информацию в виде параметров командной строки во время запуска. Это позволяет нам предоставлять информацию о параметрах оборудования, которую ядро не может обнаружить самостоятельно, или переопределять информацию, обнаруженную ядром. Например, мы используем строку параметра командной строки «console=ttyS0,115200n8», чтобы указать ядру использовать ttyS0 в качестве консоли с настройками «115200 бит/с, без контроля четности, 8 битов данных». Вот пример кода для установки строки параметра командной строки ядра:

				
					char *p;
for (p = commandline; *p == ' '; p++)
    ;
if (*p == '\0')
    return;
params->hdr.tag = ATAG_CMDLINE;
params->hdr.size = (sizeof(struct tag_header) + strlen(p) + 1 + 4) >> 2;
strcpy(params->u.cmdline.cmdline, p);
params = tag_next(params);

				
			

Обратите внимание, что в приведенном выше коде при установке размера tag_header он должен включать завершающий символ '\0' в строке и быть округлен до ближайшего кратного 4 байтам, так как член size структуры tag_header представляет количество слов.

Ниже приведен пример кода для установки ATAG_INITRD, указывающего место в ОЗУ, где находится образ initrd (в сжатом формате), а также его размер:

				
					params->hdr.tag = ATAG_INITRD2;
params->hdr.size = tag_size(tag_initrd);
params->u.initrd.start = RAMDISK_RAM_BASE;
params->u.initrd.size = INITRD_LEN;
params = tag_next(params);

				
			

Наконец, установите тег ATAG_NONE, чтобы завершить весь список параметров запуска:

				
					static void setup_end_tag(void) {
    params->hdr.tag = ATAG_NONE;
    params->hdr.size = 0;
}
				
			

Вызов ядра

Загрузчик вызывает ядро Linux, переходя непосредственно к первой инструкции ядра, т. е. переходя непосредственно к адресу MEM_START + 0x8000. При переходе должны быть выполнены следующие условия:

  1. Настройки регистров ЦП:
    R0 = 0
    R1 = Идентификатор типа машины (номер типа машины см. в linux/arch/arm/tools/mach-types)
    R2 = Начальный базовый адрес списка тегов параметров загрузки в ОЗУ

  2. Режим ЦП:
    отключить прерывания (IRQ и FIQ)
    ЦП должен находиться в режиме SVC

  3. Настройки кэша и MMU:
    MMU должен быть отключен.
    Кэш инструкций может быть включен или выключен.
    Кэш данных должен быть выключен.

При использовании C вызов ядра можно выполнить следующим образом:

				
					void (*theKernel)(int zero, int arch, u32 params_addr) =
    (void (*)(int, int, u32))KERNEL_RAM_BASE;

theKernel(0, ARCH_NUMBER, (u32)kernel_params_start);

				
			

Обратите внимание, что вызов функции Kernel() никогда не должен возвращаться. Если он возвращается, значит произошла ошибка.

Загрузчик встроенной системы против загрузчика ПК

В архитектуре ПК загрузчик состоит из BIOS (по сути, прошивки) и загрузчика ОС, расположенного в MBR жесткого диска (например, LILO, GRUB). После того, как BIOS завершает обнаружение оборудования и распределение ресурсов, он загружает загрузчик из MBR жесткого диска в оперативную память системы и передает управление загрузчику ОС. Основная задача загрузчика состоит в том, чтобы прочитать образ ядра с жесткого диска в оперативную память, а затем перейти к точке входа ядра, чтобы запустить операционную систему.

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

Процессор и встроенная плата, поддерживаемые загрузчиком

Каждая архитектура ЦП имеет свой собственный загрузчик. Некоторые загрузчики также поддерживают ЦП с несколькими архитектурами. Например, U-Boot поддерживает как архитектуру ARM, так и архитектуру MIPS. Помимо архитектуры ЦП, загрузчик также зависит от конфигурации конкретных встроенных устройств на уровне платы. То есть загрузчик не обязательно подходит для двух разных встроенных плат, даже если они построены на основе одного и того же ЦП.

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

При включении или перезагрузке системы процессоры обычно извлекают инструкции из заранее определенного адреса, установленного производителем процессора. Например, процессоры на базе ядра ARM7TDMI обычно извлекают свою первую инструкцию из адреса 0x00000000 после перезагрузки. Встроенные системы, построенные на архитектуре процессора, часто сопоставляют какой-либо тип твердотельного запоминающего устройства (такого как ROM, EPROM или FLASH) с этим заранее определенным адресом. Поэтому после включения системы процессор сначала выполняет программу загрузчика.

Подписаться

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

Об авторе

Picture of Aidan Taylor
Aidan Taylor

I am Aidan Taylor and I have over 10 years of experience in the field of PCB Reverse Engineering, PCB design and IC Unlock.

Нужна помощь?

Прокрутить вверх

Instant Quote