Хакер - Мейкерство на максималках. Заводим и разгоняем оперативную память на STM32 и Arduino

Хакер - Мейкерство на максималках. Заводим и разгоняем оперативную память на STM32 и Arduino

hacker_frei

https://t.me/hacker_frei

faberge

Содержание статьи

  • Подготовка
  • Интерфейс памяти
  • Настройка FMC
  • Запускаем SDRAM
  • Правим ldscript
  • Реализуем sbrk
  • Добавляем в main
  • Заключение

Ты помигал светодиодиком, собрал метеостанцию и робота на радиоуправлении, а потом успел разочароваться в этих маленьких кусочках кремния, что зовутся микроконтроллерами? Напрасно! Сегодня я покажу, как научить Arduino работать с внешней памятью и выжать из нее максимум для твоих проектов.

Сердцем мира Arduino всегда был и остается крохотный микроконтроллер ATmega328P. Однако сегодня его скромные характеристики (16 МГц, 2 Кбайт ОЗУ и 32 Кбайт ПЗУ) и устаревшая восьмибитная архитектура AVR уже становятся препятствием при работе с аудио, графикой и сетью. К счастью, за эти годы вокруг Arduino успело сложиться огромное сообщество любителей и разработчиков. Общими усилиями в проект была добавлена поддержка самых разных микроконтроллеров, в том числе очень популярное семейство STM32.

Их основой служит 32-разрядное процессорное ядро компании ARM. Параметры конкретной микросхемы зависят от модельной линейки (Value Line, Mainstream, Performance), но даже самые слабые модели уверенно обгоняют ATmega AVR в доступных возможностях и производительности. Если добавить сюда богатую периферию, многочисленные интерфейсы для связи с внешним миром и разумную цену, то совсем неудивительно, что эти микроконтроллеры полюбились сообществу и получили широкое распространение.

INFO

В компании ARM инженеры не лишены чувства юмора. Сегодня существуют три семейства процессорных ядер для встраиваемых систем — Cortex-A (application), Cortex-R (real-time) и Cortex-M (microcontroller). Индексы в названиях образуют имя архитектуры — ARM. Знакомые буквы можно найти и в сокращенном названии основного справочного документа — ARM Architecture Reference Manual (PDF). Такие вот шуточки для тех, кто в теме.

В предыдущих статьях («Как реализовать шифрование для самодельного гаджета», «Ищем энтропию на микросхеме, чтобы повысить стойкость шифров») я использовал плату Discovery с микроконтроллером F746NG. Это очень мощное решение на основе Cortex-M7, с частотой в 216 МГц, 320 Кбайт оперативной памяти и мегабайтом флеша. Но что делать, если вдруг и этого мало? К примеру, если попытаться работать с размещенным на этой же плате дисплеем с разрешением 480 на 272 пикселя, то при глубине цвета в 32 бита нам потребуется как минимум 510 Кбайт памяти, чтобы хранить буфер кадра.

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

К счастью, у F746NG есть периферийный блок FMC (Flexible Memory Control) для работы с интерфейсами внешней памяти. А на самой плате уже распаяна микросхема SDRAM на 128 Мбит (не байт!). Но почему-то при портировании в Arduino IDE авторы STM32duino не захотели добавлять ее поддержку в файлах для платы Discovery. Если сделать все грамотно и аккуратно, то после инициализации аппаратного уровня для нашей программы не будет никакой разницы, работать ли с внутренней памятью или с внешней. Я считаю, надо попробовать!

INFO

Сейчас порой кажется, что в эпоху AVR микроконтроллеры обладали только стандартным набором периферии и ничего сложного подцепить к ним было нельзя. Однако это не так — к некоторым из них тоже можно было подключить внешнюю память через интерфейс XMEM и отобразить ее в адресное пространство ЦПУ. Например, на МК ATmega8515 можно было увеличить ОЗУ со скромных 512 байт до внушительных 64 Кбайт! Тогда это, конечно, поражало воображение.

Подготовка

Итак, нашей главной целью будет размещение 8 Мбайт оперативной памяти в адресном пространстве процессора Cortex-M7. Да, я не ошибся — хоть у нас и 128 Мбит (16 Мбайт) во внешней микросхеме, но из-за того, что на плате разведена 16-битная шина данных (вместо максимально возможных 32 бит), нам по факту доступна только половина этого объема. Это, конечно, неприятно, но тут уж ничего не поделать.

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

WWW

  • Документация на микроконтроллер DS10916
  • Документация на микросхему памяти SDRAM
  • Документация на плату UM1907
  • Референс на микроконтроллер RM0385

Посмотрим, как выглядит адресное пространство на нашем микроконтроллере. Это восемь блоков по 512 Мбайт (итого 4 Гбайт — максимум для 32-разрядных процессоров). Системное ОЗУ располагается в первом блоке и занимает адреса с 0x20000000 по 0x2004FFFF, предоставляя в наше распоряжение 320 Кбайт статической памяти. Больше можно получить только с помощью FMC и внешней микросхемы. FMC работает с адресами из блоков с третьего по шестой, но подключить SDRAM можно только в два последних.

Я предлагаю разместить внешнюю память в пятом блоке. Так мы получим дополнительные 8 Мбайт по адресам с 0xC0000000 по 0xC07FFFFF. Периферийный блок FMC будет обрабатывать все запросы на доступ к памяти в этой области и по параллельной шине переправлять в микросхему SDRAM. В целом такое взаимодействие между ЦПУ и внешним ОЗУ мало чем отличается от работы с памятью на любом компьютере, смартфоне или ноутбуке. Да, там все это гораздо быстрее и объемы существенно больше, но принципиальных отличий немного.

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

void xmem_init() {
  pin_init();
  fmc_init();
  sdram_init();
}

Осталось только последовательно написать реализацию для каждого этапа и органично вставить это куда-нибудь в код скетча.

Интерфейс памяти

Прежде всего нужно определиться с физическим представлением нашего интерфейса памяти. У нас есть отдельные сигнальные линии для адресов (A[25:0]), данных (D[31:0]), команд (CAS, RAS, WE, CS) и тактирования (CLK, CKE). Дело несколько облегчается тем, что используется далеко не полный набор (про урезанную шину данных я уже упоминал), но и всего этого немало.

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

Теперь остается только сконфигурировать все выводы GPIO для работы в альтернативном режиме с FMC.

#include "stm32f746xx.h" /* for CMSIS defines */

static inline void pin_init() {
  /* Enable clock for GPIO ports */
  RCC -> AHB1ENR |= RCC_AHB1ENR_GPIOCEN | RCC_AHB1ENR_GPIODEN |
                    RCC_AHB1ENR_GPIOEEN | RCC_AHB1ENR_GPIOFEN |
                    RCC_AHB1ENR_GPIOGEN | RCC_AHB1ENR_GPIOHEN;

  /* Configure GPIO ports */
  /* PC3: AF PP + VHS + AF12 */
  GPIOC -> MODER   |= GPIO_MODER_MODER3_1;
  GPIOC -> OSPEEDR |= GPIO_OSPEEDER_OSPEEDR3_1 | GPIO_OSPEEDER_OSPEEDR3_0;
  GPIOC -> AFR[0]  |= GPIO_AFRL_AFRL3_3 | GPIO_AFRL_AFRL3_2;
  ...
}

Здесь мы последовательно подаем тактирование на порты GPIO (без этого никак), затем для PC3 задаем режим работы, устанавливаем максимальную скорость и выбираем его в качестве вывода FMC. Остается только повторить операции выше для всех остальных сигналов.

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

Настройка FMC

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

По умолчанию FMC тактируется от частоты процессора — HCLK. Для F746NG максимальное значение составляет 216 МГц, и базовая настройка в скетче Arduino (до вызовов функций setup и loop) выставляет именно его. Конечно же, выбрать такую частоту для микросхемы памяти было бы слишком, ведь по документации она работает только вплоть до 167 МГц.

Поэтому блок FMC позволяет выбрать предделитель для частоты SDRAM. На выбор дается только два значения: /2 и /3. И это крайне неприятно, так как у нас есть дополнительное ограничение на максимальную частоту самого FMC и больше 100 МГц на нем выставлять вроде как нельзя. Но очень хочется!

Что же делать — снижать частоту ядра до 200 МГц или выбирать делитель /3 и довольствоваться скромными 72 МГц на оперативной памяти? Конечно же, это все неправильные варианты. Надо разгонять, и разгонять по максимуму! Окей, звучит несколько самоуверенно, но на самом деле у нас очень хорошие шансы на успех, и я постараюсь кратко объяснить почему.

Работа любой микросхемы гарантируется ее производителем только в определенном диапазоне температур и напряжения питания (такую информацию всегда можно найти в даташите). Ключевое слово здесь — «гарантируется». Эти цифры — не уловки пиарщиков и маркетологов и эфемерные «+200% эффективности и скорости», которые замерили в идеальных условиях, а по факту в реальной жизни никто не увидит.

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

У нас же почти «тепличные» условия использования для микросхемы — комнатная температура и стабильное напряжение на уровне 3,3 В. Как раз ровно посередине рекомендуемого диапазона 3,0–3,6 В. Так что все будет уверенно работать и на 108 МГц, еще и тайминги можно будет занизить.

Кстати, о таймингах. Теперь, когда известна тактовая частота, можно подсчитать длительность периода. Это примерно 9 нс, большая точность нам не требуется (несмотря на то что в секундах это цифра крохотная и в ней прилично нулей после запятой). И да, обрати внимание, у нас SDR (Single Data Rate) память, а не более привычная по миру персональных компьютеров DDR (Double Data Rate). Это значит, что синхронизация происходит только по одному фронту тактового сигнала вместо двух. Грубо говоря, в два раза медленнее при той же частоте.

Зная длительность периода, теперь легко вычислить величину всех нужных таймингов (для этого посмотри таблицы 12 и 13 в даташите на микросхему памяти). Там, где их значение указывается в наносекундах, нужно подобрать такое количество тактов, чтобы их суммарная длительность была бы больше. Как пример — значение TXSR в таблице составляет 70 нс, а значит, надо указывать для него задержку в восемь тактов (8 х 9 = 72 > 70).

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

static inline void fmc_init() {
  /* Enable clock for FMC */
  RCC -> AHB3ENR |= RCC_AHB3ENR_FMCEN;

  /* Configure Control Register:
   * 1. Use FIFO for Burst reads
   * 2. SDCLK is HCLK / 2 (108 MHz)
   * 3. CAS latency is 2 clock cycles
   * 4. Four internal banks
   * 5. Data bus width is 16 bits
   * 6. 4096 rows (12 bits)
   * 7. 256 columns (8 bits)
   */
  FMC_Bank5_6 -> SDCR[0]  = FMC_SDCR1_RBURST | FMC_SDCR1_SDCLK_1 |
                            FMC_SDCR1_CAS_1 | FMC_SDCR1_NB |
                            FMC_SDCR1_MWID_0 | FMC_SDCR1_NR_0;

  /* Configure Timing register:
   * 1. TRCD = 2 CLK cycles
   * 2. TRP = 2 CLK cycles
   * 3. TWR = 2 CLK cycles
   * 4. TRC = 7 CLK cycles
   * 5. TRAS = 5 CLK cycles
   * 6. TXSR = 8 CLK cycles
   * 7. TMRD = 2 CLK cycles
   */
  FMC_Bank5_6 -> SDTR[0]  = FMC_SDTR1_TRCD_0 | FMC_SDTR1_TRP_0 |
                            FMC_SDTR1_TWR_0 | FMC_SDTR1_TRC_2 |
                            FMC_SDTR1_TRC_1 | FMC_SDTR1_TRAS_2 |
                            FMC_SDTR1_TXSR_2 | FMC_SDTR1_TXSR_1 |
                            FMC_SDTR1_TXSR_0 | FMC_SDTR1_TMRD_0;
}

INFO

Мы потратили немало времени, листая даташит на память и выясняя, с какими настройками ее нужно запускать. Но откуда эту информацию берет обычный персональный компьютер, ведь там планки памяти — это практически plug & play?

Оказывается, помимо нескольких микросхем оперативной памяти, на каждой планке дополнительно присутствует энергонезависимая SPD EEPROM. Именно она хранит значения частоты и таймингов и сообщает их системе по последовательному интерфейсу SMBus (аналог I2C).

В функции fmc_init значения таймингов гарантированно рабочие и потому несколько завышены. Забегая вперед, скажу, что у меня получалось запустить плату с TRAS=4 и TXSR=7 без каких-либо ошибок. От знакомых слышал, что и это не предел и разгонять можно и дальше. Но уже разве что из спортивного интереса — особого прироста тут не получить.

Запускаем SDRAM

Предыдущие две функции настраивали периферию только на самом контроллере. Теперь пришло время «пробудить» микросхему SDRAM и послать ей стартовые команды. Весь процесс детально описан на странице 35 документации на оперативную память. В коде это выглядит следующим образом:

#define CAS_POS  (4)
#define CAS_2    (2 << CAS_POS)
#define CAS_3    (3 << CAS_POS)

#define MODE_REG_POS (9)
#define MODE_REG     (CAS_2 << MODE_REG_POS);

#define REFRESH_RATE (1665)

static inline void sdram_init() {
  /* Clock Configuration Command */
  FMC_Bank5_6 -> SDCMR = FMC_SDCMR_CTB1 | FMC_SDCMR_MODE_0;

  while (FMC_bank5_6 -> SDSR & FMC_SDSR_BUSY) {
    /* wait until FMC is ready */
  }

  __WFI(); /* now wait until SDRAM is ready (~100 us) */

  /* Precharge All Command */
  FMC_Bank5_6 -> SDCMR = FMC_SDCMR_CTB1 | FMC_SDCMR_MODE_1;

  while (FMC_bank5_6 -> SDSR & FMC_SDSR_BUSY) {
    /* wait until FMC is ready */
  }

  /* Auto Refresh Command x4 */
  FMC_Bank5_6 -> SDCMR = FMC_SDCMR_NRFS_1 | FMC_SDCMR_NRFS_0 |
                         FMC_SDCMR_CTB1 | FMC_SDCMR_MODE_1 |FMC_SDCMR_MODE_0;

  while (FMC_bank5_6 -> SDSR & FMC_SDSR_BUSY) {
    /* wait until FMC is ready */
  }

  /* Load Mode Register Command (CL = 2) */
  FMC_Bank5_6 -> SDCMR = MODE_REG | FMC_SDCMR_CTB1 | FMC_SDCMR_MODE_2;

  while (FMC_bank5_6 -> SDSR & FMC_SDSR_BUSY) {
    /* wait until FMC is ready */
  }

  /* Set Refresh Rate for SDRAM */
  FMC_Bank5_6 -> SDRTR |= REFRESH_RATE << 1;
}

Блок FMC выкинет на шину памяти несколько команд, среди которых нас больше всего интересуют ровно две: загрузка регистра режима (Mode Register) и таймера регенерации (Refresh Timer). Регистр режима выглядит так.

Большинство комбинаций битов тут зарезервировано (очень интересно, зачем), а оставшиеся вполне можно оставить по умолчанию. Главное — не забыть выставить параметр CAS Latency, который определяет количество тактов между отправкой команды на чтение и появлением данных на шине.

Также следует указать, как часто данные в SDRAM нужно обновлять, ведь ячейки динамической памяти построены на основе конденсаторов и имеют свойство разряжаться со временем. Для этого сверимся с документацией на FMC (с. 379) и воспользуемся калькулятором.

Все, самое сложное теперь позади! Достаточно только вызывать функцию xmem_init в setup и настроить SDRAM. С нашей микросхемой уже можно работать, и сейчас лучший момент проверить это, записав что-нибудь в память по новым адресам. Если не случилось никакого Bus Error (с экстраполяцией до Hard Fault), то прими мои заслуженные поздравления.

Правим ldscript

Уже на этом этапе мы можем работать с внешней памятью примерно таким образом:

#define BR_IN_SIZE    (0x2000)
#define BR_OUT_SIZE   (0x2000)
#define INPUT_SIZE    (0x8000)
#define BUFFER_SIZE   (0x8000)

#define BR_IN_OFFSET  (0xC00FF000)
#define BR_OUT_OFFSET (BR_IN_OFFSET + BR_IN_SIZE)
#define INPUT_OFFSET  (BR_OUT_OFFSET + BR_OUT_SIZE)
#define BUFFER_OFFSET (INPUT_OFFSET + INPUT_SIZE)

static uint8_t *const iobuf_in   = (uint8_t *const)BR_IN_OFFSET;
static uint8_t *const iobuf_out  = (uint8_t *const)BR_OUT_OFFSET;
static uint8_t *const input      = (uint8_t *const)INPUT_OFFSET;
static uint8_t *const buffer     = (uint8_t *const)BUFFER_OFFSET;

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

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

Изначально куча размещается в той же области внутренней SRAM, что и стек. Если получится перекинуть кучу во внешнюю память, то malloc начнет раздавать адреса оттуда. Это удобно, так как, во-первых, у нас освободится больше места под стек, во-вторых, менеджер памяти сможет нарезать нам кусочки целыми мегабайтами.

Для этого нам нужно добавить новую область памяти в скрипт загрузчика. В файлах Arduino по адресу packages/STM32/hardware/stm32/1.5.0/variants/DISCO_746NG ищем ldscript.ld и открываем его в любом текстовом редакторе (исходную версию предварительно все же лучше сохранить). Перед секциями там указаны типы памяти в нашей системе. Исправляем на

/* Specify the memory areas */
MEMORY
{
  RAM (xrw)      : ORIGIN = 0x20000000, LENGTH = 320K
  FLASH (rx)      : ORIGIN = 0x8000000, LENGTH = 1024K
  SDRAM (xrw)     : ORIGIN = 0xC0000000, LENGTH = 8M
}

После этого ищем описание секции user_heap_stack. Оно выглядит как-то так:

/* User_heap_stack section, used to check that there is enough RAM left */
._user_heap_stack :
{
  . = ALIGN(8);
  PROVIDE ( end = . );
  PROVIDE ( _end = . );
  . = . + _Min_Heap_Size;
  . = . + _Min_Stack_Size;
  . = ALIGN(8);
} >RAM

Здесь происходит выравнивание данных по адресам в памяти и экспорт нескольких дополнительных переменных для компилятора. Это все уже не требуется, поэтому удаляем секцию полностью и пишем следующее:

/* User heap section */
._user_heap :
{
  PROVIDE ( _heap_min = . );
  PROVIDE ( _heap_max = _heap_min + LENGTH(SDRAM));
} >SDRAM

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

Реализуем sbrk

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

Прежде всего стоит взглянуть на файл syscalls.c, который можно найти по адресу packages/STM32/hardware/stm32/1.5.0/cores/arduino. Тут находится реализация sbrk по умолчанию, от нее и будем отталкиваться. Атрибут weak намекает, что в своих проектах эту функцию можно переопределять без последствий.

Создаем скетч в Arduino и добавляем новый файл sbrk.c. Пишем внутри:

#include <sys/stat.h>
#include <errno.h>

extern char _heap_min;
extern char _heap_max;
extern int errno;

caddr_t _sbrk(int incr) {
  static char *current = &_heap_min;
  char *previous = current;

  if (current + incr > (char*)&_heap_max) {
    errno = ENOMEM;
    return (caddr_t) -1;
  }

  current += incr;
  return (caddr_t) previous ;
}

Теперь функция malloc из стандартной библиотеки будет зависеть уже от нашей реализации системного вызова. А мы сделали все для того, чтобы он раздавал адреса из нужного диапазона [0xC0000000, 0xC07FFFFF]. Последнее неудобство заключается в том, что в каждой нашей программе, которая использует внешнюю SDRAM, нам необходимо вызывать xmem_init в setup перед динамическим распределением памяти. Иначе сказка закончится и карета превратится в тыкву значительно раньше полуночи. В смысле malloc вернет нулевой указатель, разумеется.

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

Добавляем в main

Проблема заключается в том, что функция main у нас одна для всех плат из пакета STM32duino, а Discovery F746 со своей внешней SDRAM в этом плане уникальна. Поэтому придется вводить дополнительные условия при компиляции скетча.

Снова идем по адресу packages/STM32/hardware/stm32/1.5.0/variants/DISCO_746NG (в этой папке мы уже правили скрипт загрузчика). Открываем заголовочный файл платы variant.h и добавляем в любое место строчку с новым определением:

#define EX_SDRAM

Далее переходим в папку packages/STM32/hardware/stm32/1.5.0/cores/arduino. Ищем main.cpp, в него предстоит внести заключительные изменения, чтобы все заработало. Во-первых, добавляем файл xmem.h с объявлением функции инициализации:

#ifdef EX_SDRAM
  #include "xmem.h"
#endif

И вставляем ее вызов прямо перед setup:

#ifdef EX_SDRAM
  xmem_init();
#endif

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

Заключение

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

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

Читайте ещё больше платных статей бесплатно: https://t.me/hacker_frei

Report Page