В этой статье пойдет речь о первом поколении микроконтроллеров от Raspberry Pi Foundation — RP2040 на основе двух ядер ARM Cortex‑M0+, и о плате, которая базируется на этом чипе — Raspberry Pi Pico.

Статья призвана облегчить понимание некоторых принципов работы RP2040 и микроконтроллеров в целом.

RP2040 — очень дешевый и сравнительно мощный микроконтроллер (МК) с двухъядерной архитектурой, поддержкой USB и уникальными блоками PIO (Programmable I/O), чего хватает, к примеру, для эмуляции Macintosh 128K с поддержкой VGA, клавиатуры и мыши.

Общая теория по RP2040 будет чередоваться с примерами кода по тем или иным пунктам с более детальным разъяснением оных: обсудим загрузчик, структуру простейшей прошивки, подготовим базис для написания программ.

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

Все примеры доступны в репозитории.

Выбор языка

Разработчики предлагают программировать плату на MicroPython, CircuitPython, C/C++ или в среде Arduino IDE. Все примеры в этой статье написаны на Си (+ немного ассемблера), так как курс взят на низкоуровневое программирование и более глубокое понимание принципов работы микроконтроллера.

Raspberry Pi Foundation предоставляет официальный Pico C/C++ SDK. Также для VS Code есть расширение, которое позволяет расставлять в коде breakpoint’ы, собирать проект, прошивать плату и производить отладку через SWD и консоль.

Pico SDK резервирует некоторые аппаратные ресурсы RP2040 (например, спинлоки), предоставляя разработчику готовые методы для реализации своих идей, что, очевидно, делает разработку кода легкой, но более абстрактной. Используя SDK, вы работаете с предопределенной за вас логикой управления железом.

RP2040 — довольно гибкий и многофункциональный микроконтроллер с интересной системой загрузки, и для составления более детальной картины его работы дальнейшее повествование будет вестись без опоры на SDK.

Обзор RP2040 и Raspberry Pi Pico

Даташит к RP2040 здесь.

Рассмотрим аппаратный арсенал микроконтроллера и платы.

Распиновка Raspberry Pi Pico
Распиновка Raspberry Pi Pico
Схема RP2040
Схема RP2040

Из основного:

  • 2 ядра ARM Cortex‑M0+ с тактовой частотой до 133 МГц

  • 264КБ ОЗУ (SRAM) + 16КБ BootROM

  • Поддержка до 16МБ внешней Flash‑памяти (2 МБ в Pico)

  • 30 контактов GPIO (25 доступно в Pico)

  • По 2 блока UART, SPI и I2C

  • PWM, 12-битный ADC, RTC, таймер

  • DMA‑контроллер

  • USB 2.0 контроллер (Host/Device)

  • 2 блока PIO (программируемый ввод‑вывод)

  • FIFO‑буфер обмена данными между ядрами

Питается Raspberry Pi Pico от 1.8–5.5В, а логическое напряжение контактов — 3.3В, поэтому будьте бдительны: подключение к входным пинам каких‑нибудь 5-вольтовых адруиновских датчиков напрямую может спалить устройство.

Из 26 выведенных на плате GPIO пинов 25 доступны для монтажа, один занят под светодиод. Выведены три пина под SWD‑отладчик.

Также на плате распаяна внешняя Flash‑память объемом 2 МБ и кварцевый генератор стабильного тактового сигнала частотой 12 МГц.

RP2040 поддерживает прошивочный формат UF2 от Microsoft. Чтобы перевести чип в режим загрузки прошивки, необходимо подключать плату к компьютеру по USB с зажатой кнопкой SELBOOT. RP2040 будет имитировать внешний накопитель, и для загрузки прошивки достаточно будет просто перетащить в окно с диском файл прошивки в формате.uf2

Drag and drop
Drag and drop

Модель памяти

Адресное пространство:

  • 0×00000000…. — BootROM: Зашитая в чип на заводе область памяти, в которой содержится первичный загрузчик, а также некоторые другие данные

  • 0×10000000…. — Flash‑XIP: Доступ к Flash‑памяти в режиме Read‑Only для возможности запуска кода прямо из Flash, без копирования в ОЗУ

  • 0×20000000…. — SRAM: Область статической оперативной памяти, основное рабочее пространство МК. Здесь хранятся и модифицируются переменные.

  • 0×40000000…. — Периферия, регистры ядер и прочее

Так как Flash‑память внешняя, RP2040 использует интерфейс SSI (реализован через SPI) для общения с ней. С его же помощью реализуется технология XIP (eXecute‑In‑Place), которая, как указывалось ранее, позволяет отобразить Flash‑память прямо в адресное пространство RP2040, но только для чтения. Дальнейшее повествование будет вестись с ориентиром на Raspberry Pi Pico, где объем Flash‑памяти составляет 2 МБ.

Ядра Cortex‑M0+ не содержат FPU, но в ROM располагается набор высокооптимизированных функций для работы с вещественными числами.

В Rp2040 реализована следующая система атомарного доступа к периферийным регистрам на уровне шины:

  • Обычный доступ (без смещения) — прямая запись в регистр или его чтение

  • XOR (смещение 0×1000) — инвертирование нужных битов в регистре (исключающее «или»)

  • CLR (смещение 0×2000) — сброс нужных битов в регистре

  • SET (смещение 0×1000) — выставление нужных битов в регистре («или»)

Пример на языке Си:

#define XOR_OFFSET 0x1000
#define SET_OFFSET 0x2000
#define CLR_OFFSET 0x3000

static inline void mmio_write_32(uint32_t addr, uint32_t value) {
    *(volatile uint32_t *)addr = value;
}

static inline uint32_t mmio_read_32(uint32_t addr) {
    return *(volatile uint32_t *)addr;
}

static inline void setBits(uint32_t addr, uint32_t value) {
    mmio_write_32(addr + SET_OFFSET, value);
}

static inline void clearBits(uint32_t addr, uint32_t value) {
    mmio_write_32(addr + CLR_OFFSET, value);
}

static inline void invertBits(uint32_t addr, uint32_t value) {
    mmio_write_32(addr + XOR_OFFSET, value);
}

Все эти операции атомарны, то есть неделимы. Это сделано для гарантии безопасности доступа к периферии в условиях двухъядерной архитектуры Rp2040.

Таким образом, каждый периферийный блок занимает 4 килобайта. Доступ к PIO, USB и DMA будет осуществляться за 1 такт шины, а к таким блокам как UART, SPI, I2C, PWM и пр. (см. схему выше) — за 3 такта.

Барьеры инструкций и памяти

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

В архитектуре ARM существует три инструкции‑барьера:

  • DMB (Data Memory Barrier) гарантирует, что перед выполнением последующего кода все операции чтения/записи будут завершены.

  • DSB (Data Synchronization Barrier) останавливает выполнение любых инструкций до тех пор, пока все операции чтения/записи не будут завершены

  • ISB (Instruction Synchronization Barrier) очищает конвейер процессора и гарантирует, что последующие инструкции будут взяты из памяти заново

static inline void __DMB() {
    asm volatile ("dmb" : : : "memory");
}

static inline void __DSB() {
    asm volatile ("dsb" : : : "memory");
}

static inline void __ISB() {
    asm volatile ("isb" : : : "memory");
}

Спецификатор «memory» сигнализирует компилятору о непредсказуемом изменении памяти и гарантирует отсутствие оптимизаций с его стороны.

Внешние прерывания

Список внешних прерываний с их номерами IRQ (Interrupt ReQuest)
Список внешних прерываний с их номерами IRQ (Interrupt ReQuest)

Почти все периферийные устройства RP2040 имеют регистры прерываний, привязанные к своим IRQ:

  • INTR (Interrupt Raw Status) — показывает «сырой» статус прерывания. Даже если прерывание замаскировано, факт срабатывания будет зафиксирован в этом регистре. Запись единицы в нужный бит регистра сбрасывает соответствующее прерывание на уровне устройства

  • INTE (Interrupt Enable) — позволяет разрешить/замаскировать прерывание.

  • INTF (Interrupt Force) — позволяет принудительно вызвать прерывание

  • INTS (Interrupt Status) — показывает результирующий статус прерывания. Запись единицы в нужный бит регистра сбрасывает соответствующее прерывание на уровне устройства

Если прерывание было разрешено в INTE, то в момент его срабатывания сигнал дойдет до NVIC (контроллер прерываний) обоих ядер. Если соответствующее прерывание было разрешено в NVIC конкретного ядра, то оно дойдет до ядра.

Подобная двухуровневая система прерываний (на уровне устройства и на уровне ядра) требует учитывать следующее:

  1. Чтобы прерывание дошло до ядра, оно должно быть разрешено и на уровне устройства, и в NVIC

  2. Записи в INTE или INTR устройства недостаточно для выхода прерывания: если не сбросить его в NVIC, ядро будет бесконечно крутиться в обработчике

Сам NVIC в каждом ядре ARM Cortex‑M0+ поддерживает выбор уровня приоритетов прерываний и имеет регистры для разрешения, маскирования, сброса и форсирования прерываний.

Каждое ядро ARM Cortex‑M также имеет флаговый регистр PRIMASK, отвечающий за глобальное маскирование прерываний. Если он активен, то все внешние прерывания, получаемые от NVIC, будут игнорироваться. По умолчанию он не активен. Управлять состоянием регистра можно двумя ассемблерными инструкциями.

Схема линий прерывания для периферийных устройств rp2040
Схема линий прерывания для периферийных устройств rp2040
#define CORTEX_BASE 0xE0000000

#define NVIC_IRQ_SET_EN         (CORTEX_BASE + 0xE100) 
#define NVIC_IRQ_CLR_EN         (CORTEX_BASE + 0xE180) 
#define NVIC_IRQ_SET_PENDING    (CORTEX_BASE + 0xE200) 
#define NVIC_IRQ_CLR_PENDING    (CORTEX_BASE + 0xE280)

static inline void NVIC_EnableIRQ(uint32_t irq_number) {
    mmio_write_32(NVIC_IRQ_SET_EN, (1 << irq_number));
    __DSB();
    __ISB();
}

static inline void NVIC_DisableIRQ(uint32_t irq_number) {
    mmio_write_32(NVIC_IRQ_CLR_EN, (1 << irq_number));
    __DSB();
    __ISB();
}

static inline void NVIC_SetPendingIRQ(uint32_t irq_number) {
    mmio_write_32(NVIC_IRQ_SET_PENDING, (1 << irq_number));
    __DSB();
    __ISB();
}

static inline void NVIC_ClearPendingIRQ(uint32_t irq_number) {
    mmio_write_32(NVIC_IRQ_CLR_PENDING, (1 << irq_number));
    __DSB();
    __ISB();
}
static inline void __enable_irq() {
    asm volatile ("cpsie i" : : : "memory");
}

static inline void __disable_irq() {
    asm volatile ("cpsid i" : : : "memory");
}

Межъядерная коммуникация (Mailbox)

Реализована через два односторонних FIFO‑буфера (First In, First Out) для каждого ядра, оба глубиной в 8 машинных слов. Чтение регистра входного буфера (RX) извлекает из него самое старое сообщение. Ядра 32-разрядные, соответственно машинное слово у них составляет 32 бита.

Каждый буфер также имеет регистр статуса.

#define SIO_BASE 0xD0000000

#define FIFO_TX_REG (SIO_BASE + 0x054)
#define FIFO_RX_REG (SIO_BASE + 0x058)

#define FIFO_STATUS_REG (SIO_BASE + 0x050)
/* 1: True, 0: False */
/* [0] RX-буфер не пуст */ 
/* [1] TX-буфер не полон */
/* [2] TX-буфер был переполнен */
/* [3] RX-буфер был прочитан будучи пустым */

Для каждого ядра адреса обращения к своим буферам совпадают.

Последние два флага очищаются при записи любого значения в буфер. Передача сообщения, а также срабатывание флагов переполнения и чтения пустого буфера вызывают прерывание на ядре‑получателе по принципу логического «или».

О режимах сна

Процессоры ARM Cortex‑M поддерживают два режима низкого энергопотребления — обычный сон (sleep) и глубокий (sleepdeep). Выбор режима сна осуществляется в отдельном регистре SCR.

Существует две инструкции для перевода ядра в режим ожидания (сна) — WFE и WFI.

static inline void __WFE() {
    asm volatile ("wfe");
}
static inline void __WFI() {
    asm volatile ("wfi");
}

WFE (Wait For Event) погружает ядро в сон до возникновения события. Вот некоторые из них:

  1. Прерывание, разрешенное в NVIC (даже если IRQ запрещены в PRIMASK)

  2. Выполнение инструкции SEV (Send‑event) на другом ядре

    Ядра соединены сигнальной шиной, поэтому выполнение SEV на одном ядре взводит регистр событий на другом, тем самым выводя его из WFE.

static inline void __SEV() {
    asm volatile ("sev");
}

WFI (Wait For Interrupt) погружает ядро в сон до возникновения прерывания, разрешенного в NVIC.

Таким образом, функции для межъядерной коммуникации могут выглядеть так:

static inline uint32_t mailbox_read() {
    return mmio_read_32(FIFO_RX_REG);
}

static inline uint32_t mailbox_read_blocking() {
    while (!(mmio_read_32(FIFO_STATUS_REG) & (1 << 0))) // пока RX-буфер пуст
        __WFE();
    return mailbox_read();
}

static inline void mailbox_send(uint32_t data) {
    mmio_write_32(FIFO_TX_REG, data);
    __SEV();
}

static inline void mailbox_send_blocking(uint32_t data) {
    while (!(mmio_read_32(FIFO_STATUS_REG) & (1 << 1))); // Пока TX-буфер полон
    mmio_write_32(FIFO_TX_REG, data);
    __SEV();
}

Инициализация второго ядра

В то время как первое ядро (Core 0) выполняет пользовательский код, второе ядро (Core 1) согласно коду в BootROM находится в режиме WFE.

Исходный код BootROM опубликован здесь. Стартовый код обоих ядер расположен в ассемблерном файле bootrom_rt0.S (функция _start)

Алгоритм для второго ядра внутри BootROM:

  1. Очистка входного FIFO (RX) от мусора

  2. WFE, если TX‑буфер полон

  3. Эхо предыдущего прочитанного числа в TX FIFO (ноль в начале)

  4. SEV (сигнал Core 0 о передаче сообщения)

  5. WFE (ожидание новых данных от Core 0)

  6. Чтение FIFO

Core 1 ожидает следующую последовательность чисел от Core 0:

  • Единица

  • Адрес таблицы векторов (записывается во VTOR)

  • Адрес указателя стека (записывается в MSP)

  • Точка входа (адрес main‑функции)

Пункты 2–6 для Core 1 повторяются циклично, пока ядро не получит все 4 числа из FIFO от Core 0. После этого Core 1 продублирует в TX FIFO (Core 1 → Core 0) точку входа (пункты 2–4) и прыгнет в неё.

Если Core 1 получает от Core 0 ноль, то начинает цикл заново, и его следует отправить перед указанными четырьмя числами: в случае возможной рассинхронизации между ядрами (если эхо не сходится с отправленным числом) Core 0 начнет цикл отправки заново, и Core 1, получив ноль, сделает то же самое. Таким образом, оба ядра начнут процедуру из правильной точки. В примере от Raspberry Pi Foundation ноль отправляется дважды.

Алгоритм для Core 0 следующий:

Перед отправкой первого нуля необходимо очистить входной буфер и разбудить Core 1 (согласно пункту 2 алгоритма Core 1). В рамках цикла проще прописать эти действия для каждого отправляемого нуля.
Далее Core 0 отправляет число, снова выполняет SEV и ждет эхо. Если ответ не сошелся с отправленным числом, следует начать цикл заново. Иначе — продолжить.

Итоговая функция может выглядеть так:

void core1_start(uint32_t *vector_table, uint32_t *stack_pointer, void (*func_ptr) (void)) {
    uint32_t init_buf[] = 
    {
    0,
    0,
    1,
    (uint32_t) vector_table,
    (uint32_t) stack_pointer,
    (uint32_t) func_ptr,
    };

    uint32_t seq_ind = 0;
    uint32_t mbox_rx;
    while (seq_ind < 6) {

        if (init_buf[seq_ind] == 0) {
            mailbox_clear();
            __SEV();
        }

        mailbox_send_blocking(init_buf[seq_ind]);
        mbox_rx = mailbox_read_blocking();
        seq_ind = (mbox_rx == init_buf[seq_ind]) ? seq_ind + 1 : 0;
    }
}

Спинлоки

Для предотвращения одновременного обращения обоих ядер к одному и тому же аппаратному ресурсу RP2040 имеет набор из 32 спинлоков (spinlocks). Например, если одно ядро собирается обратиться к периферийному регистру с классической последовательностью «чтение‑модификация‑запись», оно должно сперва убедиться, что соответствующий спинлок свободен. Иначе обращение одного ядра может вклиниться в обращение другого, что недопустимо. Если спинлок занят, ядро ждет («крутится» — «spin»), пока он не освободится. Таким образом, спинлоки — удобные аппаратные флаги, с помощью которых можно эффективно дирижировать работой ядер.

Механизм захвата/освобождения довольно элегантный: Чтение спинлока — это попытка захвата. Если он был свободным, возвращается ненулевое значение, иначе — ноль. Запись любого значения освобождает спинлок. Если оба ядра одновременно пытаются захватить спинлок, предпочтение отдается первому ядру.

Под каждый спинлок выделен свой 32-битный регистр, также есть регистр состояния всех спинлоков в виде битового вектора.

Опасность в работе со спинлоками состоит в так называемый взаимной блокировке (deadlock): Если, например, спинлок был захвачен в основном коде, а затем до его освобождения сработало прерывание, в обработчике которого прописана работа с тем же (уже взятым) спинлоком, то ядро никогда не выйдет из цикла ожидания и зависнет.

Чтобы избежать подобных ситуаций, можно сохранять состояние PRIMASK в локальную переменную перед захватом, блокировать прерывания, и восстанавливать контекст PRIMASK после освобождения. По этому принципу прописана работа со спинлоками в официальном SDK.

#define SPINLOCK_STATUS_REG (SIO_BASE + 0x05C)

/* Spinlocks (0-31) registers */
#define SPINLOCK_BASE (SIO_BASE + 0x100) // RW
#define SPINLOCK_SIZE 0x04
/* Reading values: */
/* 0:       Spinlock is already locked */
/* Nonzero: Spinlock was succesfully claimed */

static inline uint32_t __save_PRIMASK() {
    uint32_t state_var;
    asm volatile("mrs %0, PRIMASK" : "=r" (state_var) :: "memory");
    return state_var;
}

static inline void __set_PRIMASK(uint32_t state_var) {
    asm volatile("msr PRIMASK,%0" :: "r" (state_var) : "memory");
}

static inline uint32_t claim_spinlock_blocking(uint32_t number) {
    uint32_t primask_state = __save_PRIMASK();
    __disable_irq();
    while (!mmio_read_32(SPINLOCK_BASE + SPINLOCK_SIZE * number));
    __DMB();
    return primask_state;

}

static inline void free_spinlock(uint32_t number, uint32_t primask_state) {
    __DMB();
    mmio_write_32(SPINLOCK_BASE + SPINLOCK_SIZE * number, 1);
    __set_PRIMASK(primask_state);

Периферия

Далее будут рассмотрены некоторые из периферийных возможностей RP2040.

Система Resets

Большинство устройств МК после подачи питания находятся в состоянии сброса (Reset state).

В этом состоянии на устройства не подается тактовый сигнал, регистры сброшены до значений по умолчанию. Таким образом, для использования периферийного устройства по назначению его необходимо вывести из состояния сброса. В RP2040 за это отвечает регистр RESET:

#define RESET	0x4000C000 // RW
/*
[31:25] Reserved
[24]    USB
[23]    UART1
[22]    UART0
[21]    TIMER
[20]    TBMAN
[19]    SYSINFO
[18]    SYSCONFIG
[17]    SPI1
[16]    SPI0
[15]    RTC
[14]    PWM
[13]    PLL_USB
[12]    PLL_SYS
[11]    PIO1
[10]    PIO0
[9]     PADS_QSPI
[8]     PADS_BANK0
[7]     JTAG
[6]     IO_ASPI
[5]     IO_BANK0
[4]     I2C1
[3]     I2C0
[2]     DMA
[1]     BUSCTRL
[0]     ADC
*/

По умолчанию все биты с 0 по 24 равны единице, что соответствует состоянию сброса. Запись нуля в нужный бит запускает процедуру выведения соответствующего устройства из состояния сброса. По окончании тот же бит будет взведен в регистре RESET DONE:

#define RESET_DONE      (RESET + 0x008) // RO

Функция вывода из сброса:

static inline void enable_devices (uint32_t resets) {
    clearBits(RESET, resets);
    while((mmio_read_32(RESET_DONE) & (resets)) != resets);
}

GPIO

Каждый пин RP2040 может использоваться в классическом режиме GPIO (режим 5). Другие функциональные режимы пинов — UART, SPI, I2C, PIO, PWM и др. Таблица с номерами функций каждого из пинов приведена в даташите (раздел 2.19.2).

Блок SIO не подключен к общей периферийной шине, но у него тоже есть свои регистры атомарного доступа со схожей логикой и доступом в 1 такт. Поэтому можно, к примеру, выключить нужный пин GPIO одной записью в регистр, без последовательности чтение‑модификация‑запись. Атомарность операций исключает необходимость использования спинлоков.

#define GPIO(X)             (1UL << (X))

#define SIO_BASE 0xD0000000

#define GPIO_INPUT_VAL      (SIO_BASE + 0x004) // [29:0] RO
#define GPIO_OUTPUT_VAL     (SIO_BASE + 0x010) // [29:0] RW

#define GPIO_OUTPUT_SET     (SIO_BASE + 0x014) // [29:0] WO
#define GPIO_OUTPUT_CLR     (SIO_BASE + 0x018) // [29:0] WO
#define GPIO_OUTPUT_XOR     (SIO_BASE + 0x01C) // [29:0] WO

/* 1: Output, 0: Input */
#define GPIO_OUTPUT_EN      (SIO_BASE + 0x020) // [29:0] RW
#define GPIO_OUTPUT_EN_SET  (SIO_BASE + 0x024) // [29:0] WO
#define GPIO_OUTPUT_EN_CLR  (SIO_BASE + 0x028) // [29:0] WO
#define GPIO_OUTPUT_EN_XOR  (SIO_BASE + 0x02C) // [29:0] WO

static inline void gpio_set(uint32_t gpio_num) {
    mmio_write_32(GPIO_OUTPUT_SET, GPIO(gpio_num));
}

static inline void gpio_clear(uint32_t gpio_num) {
    mmio_write_32(GPIO_OUTPUT_CLR, GPIO(gpio_num));
}

static inline void gpio_invert(uint32_t gpio_num) {
    mmio_write_32(GPIO_OUTPUT_XOR, GPIO(gpio_num));
}

static inline uint32_t gpio_read(uint32_t gpio_num) {
    return (mmio_read_32(GPIO_INPUT_VAL) & GPIO(gpio_num));
}

static inline void gpio_mode_output(uint32_t gpio_num) {
    mmio_write_32(GPIO_OUTPUT_EN_SET, GPIO(gpio_num));
}

static inline void gpio_mode_input(uint32_t gpio_num) {
    mmio_write_32(GPIO_OUTPUT_EN_CLR, GPIO(gpio_num));
}

Каждый GPIO‑пин поддерживает генерацию прерываний в четырех классических сценариях:

  • High Level (логическая единица)

  • Low Level (логический ноль)

  • Edge High (переход от нуля к единице)

  • Edge Low (переход от единицы к нулю)

High/Low Level прерывания не фиксируются и активны только в соответствующем состоянии пина. Для сброса Edge High/Low прерываний требуется запись в регистр INTR.

Для каждого ядра предусмотрена отдельная линия IRQ с группами регистров INTE, INTF, INTS (По 7–8 пинов на каждый регистр, 4 бита под каждый сценарий). Группа регистров INTR общая для обоих ядер.

Основной банк GPIO начинается с адреса 0×40014000, каждый пин занимает 8 байт. Регистр настройки располагается по смещению 0×004 относительно базового адреса пина. В нем можно:

  1. Включить/выключить инвертирование сигнала линии прерывания/входного сигнала/выходного сигнала/режима выхода

  2. Выставить низкий/высокий уровень для линии прерывания/входного сигнала/выходного сигнала/режима выхода вне зависимости от реального сигнала или режима выхода

  3. Выбрать функцию пина

#define GPIO_BASE 			0x40014000
#define GPIO_STATUS_BASE    (GPIO_BASE + 0x000)
#define GPIO_CTRL_BASE      (GPIO_BASE + 0x004)
/*
[31:30] Reserved
[29:28] IRQOVER
    - 0x0 -> Normal
    - 0x1 -> Invert the interrupt
    - 0x2 -> Drive the interrupt low
    - 0x3 -> Drive the interrupt high
[27:18] Reserved
[17:16] INOVER
    - 0x0 -> Normal
    - 0x1 -> Invert the input signal 
    - 0x2 -> Drive the input signal low
    - 0x3 -> Drive the input signal high
[15:14] Reserved
[13:12] OEOVER
    - 0x0 -> Normal
    - 0x1 -> Invert output enable value
    - 0x2 -> Disable output (forced)
    - 0x3 -> Enable output (forced)
[11:10] Reserved
[9:8]   OUTOVER
    - 0x0 -> Normal
    - 0x1 -> Invert the output signal 
    - 0x2 -> Drive the output signal low
    - 0x3 -> Drive the output signal high
[7:6]   Reserved
[4:0]   FUNCSEL
*/
#define GPIO_SIZE           0x08

#define GPIO_INTR_BASE      (GPIO_BASE + 0x0F0)

#define GPIO_INTE_BASE      (GPIO_BASE + 0x100)
#define GPIO_INTF_BASE      (GPIO_BASE + 0x110)
#define GPIO_INTS_BASE      (GPIO_BASE + 0x120)
#define GPIO_CORE_SIZE      0x30

#define GPIO_EDGE_HIGH      0x3
#define GPIO_EDGE_LOW       0x2
#define GPIO_LEVEL_HIGH     0x1
#define GPIO_LEVEL_LOW      0x0

static inline void gpio_enable_interrupt(uint32_t core, uint32_t gpio_num, uint32_t int_mode) {
    uint32_t addr = GPIO_INTE_BASE + (GPIO_CORE_SIZE * core) + (0x04 * (gpio_num / 8));
    uint32_t mask = 1 << (((gpio_num % 8) * 4) + int_mode);
    setBits(addr, mask);
}

static inline void gpio_mask_interrupt(uint32_t core, uint32_t gpio_num, uint32_t int_mode) {
    uint32_t addr = GPIO_INTE_BASE + (GPIO_CORE_SIZE * core) + (0x04 * (gpio_num / 8));
    uint32_t mask = 1 << (((gpio_num % 8) * 4) + int_mode);
    clearBits(addr, mask);
}

static inline void pinmux_set(uint32_t gpio_num, uint32_t func) {
    mmio_write_32(GPIO_CTRL_BASE + GPIO_SIZE * gpio_num, func & 0x1F);
}

Таймер

Встроенный в rp2040 таймер имеет 64-битный счетчик микросекунд (2 регистра по 32 бита) и 4 будильника — Alarm0-Alarm3

Счетчик использует тики, генерируемые сторожевым таймером Watchdog. Задача самого Watchdog — следить за тем, чтобы система не зависла, в противном же случае перезагрузить её (soft reset). Но в данном примере задействовать сторожевой таймер мы не будем.

Будильник (Alarm) можно привязать к младшему регистру счетчика, и по достижении счетчиком указанного времени (лимит примерно 72 минуты) будильник вызовет прерывание и выключится. Для каждого будильника предусмотрен свой IRQ.

Будильники можно использовать для планирования callback‑функций по будильнику или для относительно больших задержек (например, >100 мкс), когда ядро можно перевести в режим низкого потребления инструкцией WFI (Wait For Interrupt).

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

Пример реализации:

void sleep_us(uint32_t delay, uint32_t alarm) {
    uint32_t start_time = mmio_read_32(TIME_L_READ); // Считываем текущее время
    uint32_t target_time = start_time + delay; // Считаем нужное время
    mmio_write_32(TIMER_ALARM0 + (ALARM_SIZE * alarm), target_time); // Заводим будильник

    setBits(TIME_INTE, (1 << alarm)); // Разрешаем прерывания от будильника

    if (mmio_read_32(TIME_L_READ) - start_time >= delay) { // Проверка, не прошло ли уже нужное время
        clearBits(TIME_INTE, (1 << 0));
        return;
    }

    while(1) {
        __disable_irq(); // Замаскировать все прерывания

        if (mmio_read_32(TIME_L_READ) - start_time >= delay) { // Если время прошло
            clearBits(TIME_INTE, (1 << alarm));
            __enable_irq(); // Разрешить прерывания
            break; // и выйти
        }
        __WFI(); // Заснуть до возникновения любого прерывания
        __enable_irq(); // Разрешить прерывания, чтобы они могли отработать
    }
}

static inline void sleep_ms(uint32_t delay, uint32_t alarm) {
    sleep_us(delay * 1000, alarm);
}

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

В обработчике прерываний необходимо сбросить бит в INTR/INTE таймера и в NVIC (пример для Alarm0):

void Timer0_IRQHandler() {
    mmio_write_32(TIME_INTR, (1 << 0)); // Сброс прерывания в Alarm0
    NVIC_ClearPendingIRQ(TIMER_IRQ_0); // Сброс прерывания в NVIC
}

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

static inline void busy_wait_us(uint32_t delay) {
    uint32_t start_time = mmio_read_32(TIME_L_READ);
    while(mmio_read_32(TIME_L_READ) - start_time < delay); // Пока не нужное время...
}

static inline void busy_wait_ms(uint32_t delay) {
    busy_wait_us(delay * 1000);
}

Помимо системного таймера МК каждое ядро имеет классический для Cortex‑M 24-битный таймер SysTick, сигнал к которому по умолчанию идет с счетчика watchdog.

PIO

Одна из отличительных особенностей RP2040 — блоки программируемого ввода‑вывода (PIO). Всего их два, каждый содержит 4 конечных автомата и память на 32 инструкции.

Конечные автоматы могут управлять любыми пинами GPIO, переключая их с очень высокой скоростью, что позволяет реализовывать различные интерфейсы передачи данных, работающие независимо от процессорных ядер. Каждый конечный автомат может общаться с основными ядрами через свой FIFO‑буфер (см. схему выше). Для программирования PIO используется специальный язык ассемблера, состоящий всего из 9 инструкций.

DMA

Микроконтроллер оснащен 12-канальным DMA‑контроллером. Он предназначен для копирования данных в памяти без участия ядер. В RP2040 особенно выигрышно смотрится связка DMA‑контроллера и PIO: она позволяет свести к минимуму трату процессорного времени на передачу данных по специфичным интерфейсам.

Каждый канал может производить операции чтения/записи, причем пропускная способность DMA значительно выше, чем каждого ядра. За каждый такт DMA (но не канал) способен выполнять чтение и запись данных в размере 32 бит. При активной работе нескольких каналов доступ к шине распределяется равномерно.

Доступны три сценария:

  1. Память → Периферия

    Периферийное устройство запрашивает у DMA данные. Например, когда в TX‑буфере UART освобождается место

  2. Периферия → Память

    Периферийное устройство сигнализирует DMA о появлении новых данных

  3. Память → Память

    DMA копирует данные из одной области памяти в другую. Размер передачи может составлять 32, 16 или 8 бит

Каналы можно объединять в цепочки: по окончании передачи один канал запустит другой (возможно предварительно сконфигурировав его).

Процесс конфигурации переноса данных через тот или иной канал состоит из настройки следующих регистров:

  1. READ_ADDR Содержит адрес следующей операции чтения (откуда)

  2. WRITE_ADDR Содержит адрес следующей операции записи (куда)

  3. TRANS_COUNT Показывает оставшееся количество транзакций (убывающий счетчик). Последнее записанное значение будет являться точкой отсчета при каждом запуске канала

  4. CTRL Настройка автоматической инкрементации READ_ADDR_ и WRITE_ADDR, размера данных и пр. Здесь также можно привязать периферийное устройство как источник запросов на транзакции (DREQ — Data Request)

У каждого из регистров есть 3 псевдонима (алиаса). В итоге доступно 4 набора регистров в разной последовательности, причем запись в последний (триггерный) регистр запускает передачу:

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

#define DMA_BASE                0x50000000

#define CH_SIZE                 0x040

#define CH0_READ_ADDR           (DMA_BASE + 0x000)
#define CH0_WRITE_ADDR          (DMA_BASE + 0x004)
#define CH0_COUNT               (DMA_BASE + 0x008)
#define CH0_CTRL_TRIG           (DMA_BASE + 0x00C)

/*
[31]    AHB_ERROR       // RO
[30]    READ_ERROR      // WC
[29]    WRITE_ERROR     // WC
[28:25] Reserved
[24]    BUSY_FLAG       // RO
[23]    SNIFF_EN        // RW
[22]    BSWAP_EN        // RW
[21]    IRQ_QUIET       // RW
[20:15] DREQ_SEL        // RW
[14:11] CHAIN_TO        // RW
[10]    RING_SEL        // RW
[9:6]   RING_SIZE       // RW
[5]     INCR_WRITE      // RW
[4]     INCR_READ       // RW
[3:2]   DATA_SIZE       // RW
        - 0x0 BYTE
        - 0x1 HALFWORD
        - 0x2 WORD
[1]     HIGH_PRIORITY   // RW
[0]     ENABLE          // RW
*/

#define CH0_AL1_CTRL            (DMA_BASE + 0x010)
#define CH0_AL1_READ_ADDR       (DMA_BASE + 0x014)
#define CH0_AL1_WRITE_ADDR      (DMA_BASE + 0x018)
#define CH0_AL1_COUNT_TRIG      (DMA_BASE + 0x01C)

#define DMA_INTE_0              (DMA_BASE + 0x404)
#define DMA_INTE_1              (DMA_BASE + 0x414)

/* some flags */
#define IRQ_QUIET               (1UL << 21)
#define INCR_WRITE              (1UL << 5)
#define INCR_READ               (1UL << 4)
#define SIZE_BYTE               (0UL << 2)
#define SIZE_HALFWORD           (1UL << 2)
#define SIZE_WORD               (2UL << 2)
#define ENABLE                  (1UL << 0)

static inline void dma_config(uint32_t channel, uint32_t ctrl_flags) {
    mmio_write_32(CH0_AL1_CTRL + channel * CH_SIZE, ctrl_flags);
}

static inline void dma_toggle(uint32_t channel, uint32_t *read_addr, uint32_t *write_addr, uint32_t count) {
    __DMB();

    mmio_write_32(CH0_READ_ADDR      + CH_SIZE * channel, (uint32_t) read_addr);
    mmio_write_32(CH0_WRITE_ADDR     + CH_SIZE * channel, (uint32_t) write_addr);
    mmio_write_32(CH0_AL1_COUNT_TRIG + CH_SIZE * channel,            count);
}

Более подробно DMA‑контроллер описан в даташите (раздел 2.5).

Примитивный main()

Простейшая программа работы с периферией может выглядеть так:

#define TIMER_RST       (1 << 21)
#define GPIO_RST        (1 << 5)
#define TIMER_IRQ_0 	0
#define LED 			25
#define GPIO_FUNC 		5

void core0_main()
{
    // 1. Вывод GPIO и таймера из сброса
    enable_devices(GPIO_RST | TIMER_RST);
	
	// 2. Разрешение прерывания от Alarm0 в NVIC
    NVIC_EnableIRQ(TIMER_IRQ_0);

	// 3. Настройка встроенного светодиода как GPIO Output
    pinmux_set(LED, GPIO_FUNC);
    gpio_mode_output(LED);

	// 4. Основной цикл
    while (1) {
        sleep_ms(1000); // ждать 1 секунду
        gpio_invert(LED); // включить/выключить светодиод
    }
}

Далее будет рассмотрено, что происходит до вызова main.

Порядок загрузки RP2040

Согласно спецификации ARM, после подачи питания ядра начинают свою работу с обработчика сброса — функции Reset Handler. Reset Handler входит в состав таблицы векторов прерываний . Адрес самой таблицы хранится в регистре VTOR, и по умолчанию он равен 0×00000000, что соответствует базовому адресу BootROM.

Из даташита к rp2040
Из даташита к rp2040

Обработчик сброса исторически предназначен для первичной инициализации микроконтроллера.

Ключевые шаги:

  1. Идентифицировав себя, второе ядро засыпает до получения определенных инструкций от первого ядра.

  2. Первое же смотрит, нажата ли кнопка SELBOOT.
    Если да, то чип переходит в режим USB‑UF2 для загрузки прошивки. В противном случае производится первичная настройка SSI для загрузки первых 256 байт прошивки из Flash‑памяти в SRAM (в самый её верх: 0×20041F00-0×20041FFF).

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

После копирования вычисляется контрольная сумма загрузчика (boot2 на схеме выше) и сравнивается с данной в прошивке (CRC32 на схеме).
Если значения совпали, то загрузчик второй фазы признается валидным, и управление передается на него. Иначе чип переходит в режим USB‑UF2. На этом работа BootROM заканчивается и начинается работа программиста.

Загрузчик второй фазы переводит Flash в нужный режим работы. После этого производится финальная подготовка микроконтроллера к выполнению основной программы — обычно это работа Reset Handler уже из пользовательской таблицы прерываний. В дальнейших упоминаниях обработчика сброса речь будет идти именно о нем.

Структура прошивки

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

Итоговый исполняемый файл может складываться из следующих частей:

  1. boot2.S Вторичный загрузчик (переводит Flash‑память и SSI в режим XIP)

  2. startup.c Пользовательский Reset Handler (настраивает тактовую частоту, инициализирует память и вызывает main)

  3. core0_main.c Код основной программы Core 0

  4. core1_main.c Код основной программы Core 1

Инструментарий, необходимый для сборки прошивки RP2040 — arm‑none‑eabi.

Секции кода и данных в программе

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

Рассмотрим типичную структуру программы. Если обобщить, в состав программы входят: сам код, то есть процессорные инструкции; переменные; константы.

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

Вот как именуются основные секции:

  • text — процессорные инструкции

  • data — глобальные/статические инициализированные переменные

  • bss — глобальные/статические неинициализированные переменные

  • rodata — константные (неизменяемые) данные

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

Простой пример для большего понимания того, куда попадают какие данные:

#include <stdlib.h>

int x = 5; 	// -> .data
int a;	    // -> .bss

const float pi = 3.142; // -> .rodata

int main() {
	static int b;	//  -> .bss
	char c = 'F';	//	-> stack (стек)
	char *s = (char *) malloc(5); //  -> heap (куча)
	
	...
}

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

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

Линковка

Следующий этап после компиляции — компоновка, она же линковка. Линковщик берет разрозненные объектные файлы машинного кода, полученные от компилятора, и соединяет их в единый исполняемый файл, раскладывая все секции по нужным адресам и расставляя ссылки на переменные и функции. Так как мы пишем прошивку от начала и до конца и у загрузчика RP2040 есть определенные требования к расположению данных в ней, нам необходимо самим указать линковщику, какую секцию куда следует поместить, чтобы во время загрузки прошивки в память все легло на свои места.

Формирование секций

Напомню, что прошивка будет лежать во Flash‑памяти, которая доступна микроконтроллеру через XIP в режиме Read‑only. Поэтому мы должны учитывать следующее:

  1. Секция .data, лежащая изначально во Flash, должна быть перемещена в SRAM, чтобы процессор смог модифицировать инициализированные переменные

  2. Под секцию .bss, не занимающую места во Flash‑памяти, должно быть выделено место в SRAM, откуда неинициализированные переменные и будут использоваться. При этом выделенная область памяти заполняется нулями согласно требованию стандарта языка С, чтобы все переменные .bss не имели неопределенного значения.

Перечисленные выше два пункта и являются инициализацией памяти, которую будет производить Reset Handler из startup.c. Чтобы знать, откуда, куда и сколько копировать (в случае с .data), он получит от линковщика адреса начал и концов нужных секций.

Помимо стандартных секций, получаемых от компилятора, уже программистом должны быть предусмотрены секции вторичного загрузчика (.boot2), стеков для двух ядер (.stack0 и .stack1) и собственной таблицы векторов прерываний (.vector).

Мы можем оставить таблицу векторов во Flash, а можем, как .data, скопировать её в SRAM для чуть более быстрой реакции на прерывания и возможности менять обработчики на лету. Именно такой вариант и будет рассмотрен. Будем также предполагать, что таблица векторов будет общей для обоих ядер.

SRAM в RP2040 разделена на 6 банков. Все они работают параллельно друг к другу, что позволяет обращаться к каждому из них одновременно. Логично выделить по одному банку под стек для каждого ядра. Для этого лучше всего подходят два самых маленьких банка по 4 КБ в самом конце ОЗУ.

А что делать с кучей? Она не привязана к железу. Реализация динамического выделения памяти — опциональная задача программиста, поэтому в данном примере просто выделим в SRAM отдельную секцию .heap в качестве общей для обоих ядер кучи.

Таким образом, все описанные секции по принципу размещения в памяти делятся на три типа:

  1. Лежащие во Flash и читаемые оттуда же: .boot2, .text, .rodata

  2. Выделяемые в SRAM и используемые оттуда же: .bss, .heap, .stack0, .stack1

  3. Изначально лежащие во Flash, но подлежащие копированию в SRAM и использованию оттуда: .data, .vector

Визуализация размещения секций
Визуализация размещения секций

Далее поговорим о реализации вышесказанного в скрипте линковщика (linker.ld).

Скрипт линковщика

Пользовательский скрипт линковщика имеет свой синтаксис. Первым делом опишем регионы (области) памяти, в которые будут укладываться секции. Указываются название, атрибуты (чтение/запись и пр.), базовый адрес и размер. Это делается в блоке MEMORY по следующему шаблону:

название_региона (атрибуты) : ORIGIN = базовый_адрес, LENGTH = размер_региона

Под секции .boot2, .stack0 и .stack1 для наглядности выделнены отдельные регионы, так как мы задали для них четкие адреса в памяти и нам принципиально, чтобы эти секции располагались именно по ним. Остальные секции будут расположены произвольным образом в регионах sram и flash (хотя фактически, разумеется, .boot2, .stack0 и .stack1 тоже будут лежать во Flash‑памяти или ОЗУ).

MEMORY
{
    boot2   (rx)    : ORIGIN = 0x10000000, LENGTH = 256
    flash   (rx)    : ORIGIN = 0x10000100, LENGTH = 2040k
    sram    (rwx)   : ORIGIN = 0x20000000, LENGTH = 256k
    stack_x (rwx)   : ORIGIN = 0x20040000, LENGTH = 4k
    stack_y (rwx)   : ORIGIN = 0x20041000, LENGTH = 4k
}

Атрибут rx означает, что регион доступен для чтения и исполнения, rwx — для чтения, записи и исполнения

Следующим за MEMORY является раздел SECTIONS, в котором и описываются все секции программы. В простом варианте синтаксис выглядит так:

имя_выходной_секции :
{
	*(имя_входной_секции)
} > регион_памяти

Пример:

SECTIONS {	
	.text : ALIGN(4)
    {
        *(.text*)
    } > flash

    .rodata : ALIGN(4)
    {
        *(.rodata*)
    } > flash
}

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

Директива ALIGN(4) выполняет выравнивание секции по 4 байтам, чтобы процессор смог корректно обратиться к её адресам.

Мы пишем

*(.text*)

А не

*(.text)

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

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

Поэтому, чтобы линковщик не удалил её «за ненадобностью», её необходимо поместить в секцию с атрибутом KEEP.

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

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

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

.boot2 : ALIGN(4)
    {
        KEEP(*(.boot2))
        . = ORIGIN(boot2) + 252;
        LONG(0x00000000)
    } > boot2

Команда LONG(0×00000000) Заполнит нужные нам для контрольной суммы 32 бита нулями, поскольку вычисляться она будет гораздо позже. Выравнивание этой секции фактически необязательно, так как вторичный загрузчик всегда будет начинаться с адреса 0×10000000, в самом начале региона flash.

Секции .bss, .stack0, .stack1 и .heap не хранятся в прошивке, нам лишь нужно выделить под них место в SRAM. В таких случаях используется директива NOLOAD.

Для кучи выделим 8 килобайт.

Также, как было оговорено ранее, Reset Handler’у нужны будут адреса начал и концов некоторых секций. В скрипте линковщика они сохраняются как метки, а в startup.c будут объявлены как внешние переменные.

В процессорах ARM Cortex‑M стек растет сверху вниз.

.bss (NOLOAD) : ALIGN(4)
{
    _bss_st = .;
    *(.bss*)
    *(COMMON)
	. = ALIGN(4);
    _bss_end = .;
} > sram

.heap (NOLOAD) : ALIGN(4)
{
    _heap_st = .;
    . = . + 8k;
    _heap_end = .;
} > sram

.stack0 (NOLOAD) :
{
    _stack0_bot = .;
    . = . + LENGTH(stack_x);
    _stack0_top = .;
} > stack_x

.stack1 (NOLOAD) :
{
    _stack1_bot = .;
    . = . + LENGTH(stack_y);
    _stack1_top = .;
} > stack_y

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

Описывая секции .data и .vector мы должны дать понять линковщику, что поместить их нужно во Flash‑память, но использоваться они будут из‑под SRAM. То есть, расставляя ссылки, линковщик должен предполагать, что данные секций находятся именно в SRAM, тогда как изначально они лежат во Flash.

Этот механизм реализуется через концепцию загрузочных и виртуальных адресов — LMA (Load Memory Address) и VMA (Virtual Memory Address), где LMA — это адрес, по которому линковщик укладывает секцию, а VMA — адрес, который он использует при вычислении адресов для обращения к данным.

Пример для .data

И если у секции кода .text, к примеру, LMA совпадает с VMA, а у .bss LMA отсутствует, то для явного указания региона загрузочного адреса используется ключевое слово AT:

имя_выходной_секции :
{
	...
	...
} > регион_vma AT > регион_lma

Для получения загрузочного адреса используется команда LOADADDR. Также таблица векторов должна быть выровнена по 256 байтам согласно требованиям архитектуры ARM, и, по аналогии с .boot2, мы должны включить секцию входную .vector с директивой KEEP.

.data : ALIGN(4)
{
    _data_st = .;
    *(.data*)
	. = ALIGN(4);
    _data_end = .;
} > sram AT > flash
_data_src = LOADADDR(.data);

.vector : ALIGN(256)
{
    _vector_st = .;
    KEEP(*(.vector))
	. = ALIGN(4);
    _vector_end = .;
} > sram AT > flash
_vector_src = LOADADDR(.vector);

Последнее, что будет добавлено в скрипт линковщика — это указание точки входа в программу через директиву ENTRY:

ENTRY(boot2)

Итоговый linker.ld может выглядеть примерно так:

ENTRY(boot2)

MEMORY
{
    boot2   (rx)    : ORIGIN = 0x10000000, LENGTH = 256
    flash   (rx)    : ORIGIN = 0x10000100, LENGTH = 2040k
    sram    (rwx)   : ORIGIN = 0x20000000, LENGTH = 256k
    stack_x (rwx)   : ORIGIN = 0x20040000, LENGTH = 4k
    stack_y (rwx)   : ORIGIN = 0x20041000, LENGTH = 4k
}

SECTIONS
{   
    .boot2 :
    {
        KEEP(*(.boot2))
        . = ORIGIN(boot2) + 252;
        LONG(0x00000000)
    } > boot2

    .vector : ALIGN(256)
    {
        _vector_st = .;
        KEEP(*(.vector))
        . = ALIGN(4);
        _vector_end = .;
    } > sram AT > flash
    _vector_src = LOADADDR(.vector);

    .text : ALIGN(4)
    {
        *(.text*)
    } > flash

    .rodata : ALIGN(4)
    {
        *(.rodata*)
    } > flash

    .data : ALIGN(4)
    {
        _data_st = .;
        *(.data*)
        . = ALIGN(4);
        _data_end = .;
    } > sram AT > flash
    _data_src = LOADADDR(.data);

    .bss (NOLOAD) : ALIGN(4)
    {
        . = ALIGN(4);
        _bss_st = .;
        *(.bss*)
        *(COMMON)
        . = ALIGN(4);
        _bss_end = .;
    } > sram

    .heap (NOLOAD) : ALIGN(4)
    {
        . = ALIGN(4);
        _heap_st = .;
        . = . + 8k;
        _heap_end = .;
    } > sram

    .stack0 (NOLOAD) :
    {
        _stack0_bot = .;
        . = . + LENGTH(stack_x);
        _stack0_top = .;
    } > stack_x

    .stack1 (NOLOAD) :
    {
        _stack1_bot = .;
        . = . + LENGTH(stack_y);
        _stack1_top = .;
    } > stack_y

}

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

Код прошивки

Итак, мы наметили скелет будущей прошивки. Поговорим подробнее о её содержании.

Вторичный загрузчик

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

Задача вторичного загрузчика (он же загрузчик второй фазы) — перевести SSI‑контроллер в режим XIP, а также произвести настройку для наиболее быстрого доступа к Flash‑памяти.

Почему это нельзя было сделать в первичном загрузчике? На то есть несколько причин:

  • Во‑первых, разные чипы Flash‑памяти могут иметь разные алгоритмы настройки, поэтому универсального кода, который можно было бы поместить в BootROM, нет

  • Во‑вторых, такой подход позволяет отказаться от XIP и просто скопировать прошивку целиком в SRAM с целью получения большего быстродействия: даже с учетом кэширования XIP обращение к ОЗУ быстрее обращения к Flash‑памяти. Но этот вариант мы рассматривать не будем

Интерфейс SSI в RP2040 поддерживает несколько режимов чтения Flash‑памяти:

  1. Standart SPI (он же Single SPI) — по 1 линии для данных, команды и адреса

  2. Dual SPI — 2 линии для данных, по 1 линии для команды и адреса

  3. Quad‑Output SPI — 4 линии для данных, по 1 линии для команды и адреса

  4. Quad‑IO SPI — по 4 линии для данных, команды и адреса

Именно последний режим обычно представляет наибольший интерес как наиболее быстрый (в репозитории есть пример и для Single‑SPI, и для Quad‑IO). Разумеется, нужно чтобы и внешний Flash‑чип поддерживал нужный режим. В оригинальной Raspberry Pi Pico используются Flash от компании Winbond, которые без проблем можно перевести в режим Quad‑IO SPI.

Даташит для Flash лежит здесь.

Передача указателя стека

Стек неразрывно связан с вызовом функций: в нем сохраняются локальные переменные вызываемой функции и адрес возврата к вызывающей. Это реализуется принципом LIFO (Last In, First Out) — «Последним пришел, первым вышел».

Чтобы основная программа первого ядра могла задействовать выделенный нами для него стек, мы должны передать адрес его вершины в процессорный регистр MSP (Main Stack Pointer). Это последнее, что сделает вторичный загрузчик перед вызовом Reset Handler. До этого момента ядро использует временный стек, определенный BootROM.

Напомню, что в linker.ld мы сохранили метки верхушек и концов стеков. MSP всегда указывает на последнюю занятую ячейку и растет сверху вниз: перед добавлением нового элемента адрес сначала уменьшается на 4, чтобы указывать на следующее свободное 32-битное поле.

Мы выделили под стек первого ядра область SRAM 0×20004000-0×20004FFF, а наша метка stack0_top изначально будет соответствовать адресу 0×200041000 (0×20040FFF + 1). При первом добавлении элемента в стек по адресу 0×200040FFC (0×2004100 — 4) будет записано 32-битное число, что как раз займет 4 ячейки с 0×200040FFC по 0×200040FFF.

Далее boot2 передает управление пользовательскому обработчику сброса.

Reset_handler

Как обсуждалось ранее, BootROM‑овский обработчик сброса настраивает МК лишь на базовом уровне для первичной инициализации. Вторичный загрузчик строго ограничен 252 байтами, поэтому для полноценной подготовки микроконтроллера к выполнению основной программы прописывают пользовательский Reset_Handler. Рассмотрим по очереди его задачи.

Настройка тактовой частоты

Из даташита к RP2040
Из даташита к RP2040

RP2040 имеет единственный встроенный генератор тактового сигнала — ROSC (кольцевой осциллятор), который не требует много энергии, но имеет низкую точность: сигнал, генерируемый им, имеет непостоянную частоту, которая зависит от температуры и напряжения. Чип стартует именно на нем на частоте примерно в 6–12 МГц, что относительно мало. Частоту можно разогнать, однако для задач, требующих точного тактирования (например, для работы UART), ROSC не подходит. ROSC также можно использовать в качестве генератора псевдо‑случайных чисел. Для этого предусмотрен отдельный бит.

В RP2040 также есть блок XOSC (кварцевый осциллятор), сам кварц — внешний. В Raspberry Pi Pico он выдает стабильную частоту 12 МГц согласно эталонному дизайну RP2040. Сигнал с XOSC является основой для модулей Фазовой автоподстройки частоты (PLL), которые умножают сигнал от кварца, позволяя получить нужную частоту на нужной линии.

Rp2040 имеет отдельные линии тактирования для разных блоков: процессорных ядер, периферии, таймеров и прочего. Для каждой линии предусмотрено несколько возможных источников сигнала на выбор (см. схему выше).

PLL_SYS классически является источником тактового сигнала для процессорных ядер и периферии. Им соответствуют линии clk_sys и clk_peri. Для блоков USB и ADC, требующих частоты 48 МГц, предусмотрен PLL_USB. В то время как частоту системной шины можно менять в процессе работы МК, линия clk_ref, отвечающая за работу таймеров, требует стабильности, поэтому на неё сигнал идет обычно напрямую с XOSC.

Пример схемы тактирования
Пример схемы тактирования

Алгоритм настройки:

  1. Включение XOSC

  2. Включение и настройка PLL

  3. Перевод линий на нужные источники

  4. (Опционально) Выключение ROSC

Настройка тактовой частоты состоит из множества записей в регистры. В этой статье упомяну лишь пример из репозитория с целевой частотой ядер 125 МГц. Там также прописана настройка счетчика сторожевого таймера (Watchdog), сигнал с которого используется для счетчика системного таймера rp2040. Счетчик получает тактовый сигнал с линии clk_ref (12 МГц), соответственно делитель счетчика должен быть равен 12 (12 тиков = 1 мкс, 12 000 000 тиков = 1 с):

#define WATCHDOG_BASE   0x40058000
#define WATCHDOG_TICK   (WATCHDOG_BASE + 0x2C)
//      [9]     Enable (0x1 by default)
//      [8:0]   Cycles (12 for 12 MHz)
...
void Reset_Handler() {
	...
	clearBits(WATCHDOG_TICK, 0x1FF); // [8:0]
	setBits(WATCHDOG_TICK, (12 << 0));
	...
}

Инициализация памяти

Суть этого этапа мы обсудили, разбирая скелет прошивки, задаваемый скриптом линковщика: нужно скопировать .vector и .data из Flash в SRAM, чтобы процессорные ядра могли изменять переменные; а также обнулить выделенную под .bss область памяти. Здесь же можно передать указатель стека, но я прописал это в boot2.

В начале startup.c объявим как внешние переменные сохраненные в linker.ld метки, а также адрес регистра VTOR.

#include <stdint.h>

extern uint32_t _vector_src, _vector_st, _vector_end;

extern uint32_t _data_src, _data_st, _data_end;

extern uint32_t _bss_st, _bss_end;

Также необходимо прописать таблицу векторов, которая и будет скопирована как секция .vector. Чтобы указать данным конкретную секцию, используется атрибут section:

typedef void (*vectFunc) (void); // тип указателя на функцию

vectFunc vector_table[] __attribute__((section(".vector"))) =
{
...
};

Табица векторов прерываний — это по своей сути массив 32-битных адресов. В Cortex‑M она имеет следующую структуру:

Внешние прерывания предназначены для работы процессорного ядра с периферийными устройствами конкретного микроконтроллера (определены дизайном RP2040), тогда как внутренние прерывания генерируются в самом ядре (Определены дизайном Cortex‑M0+).

В startup.c функции‑обработчики объявлены с атрибутом weak («слабый»), чтобы по умолчанию они ссылались на функцию‑заглушку (Default Handler, атрибут alias), но могли быть переопределены в дальнейшем.

Теперь добавим в Reset_Handler копирование .data и .vector из Flash в SRAM и инициализацию нулями .bss. Также необходимо изменить регистр VTOR, по которому контроллер прерываний (NVIC) при возникновении прерывания перейдет к таблице векторов и найдет в ней нужный обработчик.

void Reset_Handler() {
	
	// Настройка тактирования
	...

	// Копирование .vector в SRAM

    uint32_t *src = &_vector0_src; // LMA
    uint32_t *st = &_vector0_st; // Начальный VMA

    while (st < &_vector0_end) // Текущий VMA < конечного VMA
        *st++ = *src++;

	st = &_vector_st;
    mmio_write_32(VTOR_ADDR, (uint32_t) st); // Теперь VTOR указывает на нашу таблицу векторов

    // Копирование .data в SRAM
    src = &_data_src;
    st = &_data_st;

    while (st < &_data_end)
        *st++ = *src++;

    // Инициализация .bss в SRAM
    st = &_bss_st;
    while (st < &_bss_end)
        *st++ = 0;
	
	...
}

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

Также для после копирования секций (а особенно после изменения VTOR) рекомендуется прописать барьеры памяти и инструкций.

Защита стека от переполнения и вызов main()

Стек растет сверху вниз, и процессор не знает, где его нижняя граница. Поэтому необходимо гарантировать, что ядро не заденет данные, лежащие перед стеком, при очередном уменьшении адреса указателя стека. В процессорах серии ARM Cortex‑M для таких целей предусмотрен MPU — Memory Protection Unit (Модуль защиты памяти). Он позволяет выбрать атрибуты для нескольких произвольных регионов памяти (с минимальным размером в 32 байта). Например, можно запретить выполнение кода из выбранного региона или чтение/запись данных из него.

В качестве одного из вариантов можно выделить нижние 32 байта стека первого ядра под красную зону: обращение по соответствующим адресам вызовет HardFault — критическое аппаратное исключение (внутреннее прерывание).

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

...
volatile uint32_t mpu_ctrl;

void mpu_config(uint32_t ctrl_flags) {

    mpu_ctrl = ctrl_flags;
    mmio_write_32(MPU_CTRL, ctrl_flags);

    __DSB();
    __ISB();
}

void mpu_set_region(uint8_t region, uint32_t addr, uint32_t region_flags) {

    mmio_write_32(MPU_RBAR, VALID | REGION(region) | REGION_ADDR(addr));
    mmio_write_32(MPU_RASR, region_flags);

    __DSB();
    __ISB();
}

void Reset_Handler() {
	...
	mpu_config(MPU_ENABLE | PRIVDEFENA);
	mpu_set_region(0, (uint32_t) &_stack0_bot, REGION_SIZE(5) | NO_ACCESS | NO_EXECUTE | EN_REGION);
	...
}

Последнее действие Reset_Handler — вызов main. На этом инициализация МК заканчивается, и Core 0 приступает к выполнению своей основной программы (была прописана ранее).

Сборка прошивки

Makefile доступен здесь. В нем используется динамический поиск зависимостей, чтобы не приходилось прописывать все header’ы вручную: компилятор сам формирует необходимые зависимости для каждого source‑файла и сохраняет их в файлы с расширением.d, содержание которых make сам подставит в соответствующие цели.

RP2040_Baremetal_Project/
├── build/
│   ├── core0_main.o
│   ├── core0_main.d
│   ├── ...
│   └── output/
│ 		├── firmware.uf2
│ 		└── ...
├── drivers/
│   ├── mpu/
│   │	├── mpu.c
│   │	└── mpu.h
│   └── ...
├── startup/
│   ├── boot2.S
│   └── startup.c
├── tools/
│   ├── add_crc32/
│   │	└── rp2040_crc32.py
│   └── uf2conv/
│   	├── uf2conv.py
│       └── uf2families.json
├── core0_main.c
├── core1_main.c
├── linker.ld
└── Makefile

Файлы исходного кода будут преобразованы в объектные файлы в результате первичной компиляции при помощи arm‑none‑eabi‑gcc. Объектные файлы сохраняются в папку build.

GCC			:= arm-none-eabi-gcc
OBJDUMP 	:= arm-none-eabi-objdump
OBJCOPY		:= arm-none-eabi-objcopy
LD 			:= arm-none-eabi-ld

UF2			:= tools/uf2/uf2conv.py
CRC32		:= tools/add_crc32/crc32_rp2040.py

drivers_dir := $(wildcard drivers/*)
src_drivers := $(notdir $(wildcard drivers/*/*.c))
src_c		:= core0_main.c core1_main.c startup.c
src_c		+= $(src_drivers)
src_asm		:= boot2_QuadIO.S

BUILD_DIR	:= build
OUTPUT_DIR	:= $(BUILD_DIR)/output
ASMFLAGS	:= -mcpu=cortex-m0plus $(addprefix -I, $(drivers_dir)) -g
GCCFLAGS	:= -mcpu=cortex-m0plus $(addprefix -I, $(drivers_dir)) -g -O3 -nostdlib -ffreestanding
LDFLAGS		:= -Map=$(OUTPUT_DIR)/memory.map -T linker.ld -nostdlib

vpath %.S startup
vpath %.c startup $(drivers_dir)

objects		:= $(addprefix $(BUILD_DIR)/, $(patsubst %.c, %.o, $(src_c))) \
				$(addprefix $(BUILD_DIR)/, $(patsubst %.S, %.o, $(src_asm)))
deps		:= $(patsubst %.o, %.d, $(objects))

all: $(OUTPUT_DIR)/pico_fw.uf2

$(BUILD_DIR) $(OUTPUT_DIR):
	mkdir -p $@


...

-include $(deps)

$(BUILD_DIR)/%.o: %.c | $(BUILD_DIR)
	$(GCC) $(GCCFLAGS) -MMD -MP -MF $(patsubst %.o, %.d, $@) -c $< -o $@

$(BUILD_DIR)/%.o: %.S | $(BUILD_DIR)
	$(GCC) $(ASMFLAGS) -MMD -MP -MF $(patsubst %.o, %.d, $@) -c $< -o $@

clean:
	rm -rf $(BUILD_DIR)

.PHONY: clean

Флаги ‑nostdlib и ‑ffreestanding сигнализируют компилятору о том, что программу следует собрать без использования стандартных библиотек Си и что использоваться она будет прямо на железе (отсутствие системных вызовов).

Объектные файлы будут скомпонованы в единый ELF‑файл (Executable and Linkable Format) с использованием linker.ld. ELF помещается в build/output. Там же будут созданы файлы objdump и map, в которых расписаны итоговая структура и дизассемблированный код прошивки. Помогает при отладке.

$(OUTPUT_DIR)/pico_fw_raw.elf: $(objects) | $(OUTPUT_DIR)
	$(LD) $(LDFLAGS) $(objects) -o $@
	$(OBJDUMP) -hSD $@ > $(OUTPUT_DIR)/rp2040.objdump

Дальнейший путь от промежуточного ELF‑файла до готовой прошивки имеет множество вариантов, я же использую следующий алгоритм:

ELF → bin → bin с CRC32 → uf2

ELF содержит таблицы символов, секции и прочие данные, в то время как bin — это просто сырой набор байт, поэтому конвертация elf → bin нужна для записи контрольной суммы в нужное место.

Для её добавления целесообразно использовать Python‑скрипт (в папке tools), который вычислит её и запишет в конец первых 256 байт бинарника.

Отличная статья про CRC

$(OUTPUT_DIR)/pico_fw.bin: $(OUTPUT_DIR)/pico_fw_raw.elf
	$(OBJCOPY) -O binary $< $@
	python3 $(CRC32) $@

Итоговый бинарный файл удобно конвертировать в uf2 при помощи uf2conv (также на Python) от Microsoft.

$(OUTPUT_DIR)/pico_fw.uf2: $(OUTPUT_DIR)/pico_fw.bin
	python3 $(UF2) -b 0x10000000 -f 0xe48bff56 -c $< -o $@

Аргументы:

  • ‑b задает базовый адрес прошивки в памяти микроконтроллера (0×10000000 — начало flash)

  • ‑f определяет так называемый. Family ID (0xe48bff56 для rp2040). Перед запуском прошивки микроконтроллер проверяет поле Family ID, игнорируя прошивки для других чипов

  • ‑c — конвертировать, но не прошивать

Запустив make и закинув получившийся uf2-файл на плату можно убедиться, что светодиод стал моргать!

Усложненный main() с использованием Core 1

Для демонстрации работы сразу двух ядер и их взаимодействия немного изменим сценарий: пусть Core 0 зажигает светодиод, а Core 1 гасит.

В core0_main.c запустим инициализацию Core 1:

extern uint32_t _stack1_top, _stack1_bot;
extern uint32_t _vector_st;

extern void core1_main();

#define STACK1_DEFAULT			&_stack1_top
#define VECTOR_TABLE_DEFAULT	&_vector_st

void core0_main()
{
	...

    core1_start(VECTOR_TABLE_DEFAULT, STACK1_DEFAULT, core1_main);
	
	...
}

Алгоритм такой:

  1. Core 0 зажигает светодиод и ждет 1 с

  2. Core 0 отправляет второму ядру сигнал и ждет ответа

  3. Core 1 получает сигнал, гасит светодиод, ждет 1 с

  4. Core 1 сигналит другому ядру, и цикл начинается заново

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

define MAILBOX_MAGIC   0x53594E43 // "SYNC"

static inline void mailbox_sync_master() {
    uint32_t primask_state = __save_PRIMASK(); // Cохранение состояния регистра PRIMASK
    mailbox_send_blocking(MAILBOX_MAGIC); // Сигнал другому ядру о готовности к сихронизации
    __DMB(); // Гарантия того, что сигнал был отправлен вовремя
    while (mailbox_read_blocking() != MAILBOX_MAGIC); // Ожидание подтверждения
    __set_PRIMASK(primask_state); // Восстановление состояния регистра PRIMASK
}

static inline void mailbox_sync_slave() {
    uint32_t primask_state = __save_PRIMASK();
    while (mailbox_read_blocking() != MAILBOX_MAGIC); // Ожидание сигнала
    mailbox_send_blocking(MAILBOX_MAGIC); // Подтверждение
    __DMB();
    __set_PRIMASK(primask_state);
}

В начале своей программы Core 1 должен установить ограничения на свой стек аналогично Core 0. Для задержек Core 1 выделим ALARM1.

Итоговые файлы:

/* core0_main.c */

#include <stdint.h>
#include "gpio.h"
#include "resets.h"
#include "interrupts.h"
#include "mailbox.h"
#include "time.h"
#include "mmio.h"
#include "cores.h"

#define LED             25
#define GPIO_FUNC       5
#define ALARM0          0
#define MAILBOX_CMD_0   0

extern void core1_main();

void core0_main()
{
    /* Вывод устройств из состояния сброса */
    enable_devices(GPIO_RST | TIMER_RST);

    /* Резрешение ALARM0 в NVIC */
    NVIC_EnableIRQ(TIMER_IRQ_0);

    /* GP25 -> GPIO-mode */
    pinmux_set(LED, GPIO_FUNC);

    /* GP25 -> Output */
    gpio_mode_output(LED);

    /* Инициализация Core 1 */
    core1_start(VECTOR_TABLE_DEFAULT, STACK1_DEFAULT, core1_main);

    /* Синхронизация */
    mailbox_sync_master();

    while (1) {
        gpio_set(LED); // Включить LED
        sleep_ms(1000, ALARM0); // Ждать 1 с
        mailbox_send_blocking(MAILBOX_CMD_0); // Отправить сигнал Core 1
        mailbox_read_blocking(); // Ждать сигнала от Core 1
    }

}
/* core1_main.c */

#include <stdint.h>
#include "gpio.h"
#include "mmio.h"
#include "mpu.h"
#include "mailbox.h"
#include "interrupts.h"
#include "time.h"

extern uint32_t _stack1_bot;

#define MAILBOX_CMD_1   1
#define LED             25

void core1_main() {

    /* Защита stack1 */
    mpu_config(MPU_ENABLE | PRIVDEFENA);
    mpu_set_region(0, (uint32_t) &_stack1_bot, REGION_SIZE(5) | NO_ACCESS | NO_EXECUTE | EN_REGION);

    /* Разрешение ALARM1 в NVIC */
    NVIC_EnableIRQ(TIMER_IRQ_1);

    /* Синхронизация */
    mailbox_sync_slave();

    while (1) {
        mailbox_read_blocking(); // Ждать сигнала от Core 0
        gpio_clear(LED);        // Выключить LED
        sleep_ms(1000, ALARM1); // Ждать 1 с
        mailbox_send_blocking(MAILBOX_CMD_1); // Отправить сигнал Core 0
    }

}

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

Заключение

В этой статье была разобрана не вся периферия, некоторые аспекты RP2040 были упомянуты вскользь, а некоторые и не упомянуты вовсе (например, интерполятор))). Полноценная работа с PIO, PWM, ADC и вовсе заслуживает отдельного материала. К слову, даташит у RP2040 весьма подробный.

Еще пара слов о Baremetal: на практике в большинстве случаев, конечно, целесообразнее использовать готовый SDK, но всегда лучше при этом иметь понимание того, что происходит на более низком уровне.

Лично для меня RP2040 стал огромным непаханым полем для изучения: у меня не очень большой опыт взаимодействия с микроконтроллерами/embedded‑системами. Поэтому написание данной статьи — это в том числе попытка структурировать, усвоить и применить на практике новую информацию. Рад, если она оказалась кому‑то полезной.

Большое спасибо за внимание!