В прошлой статье мы собрали готовый образ Linux и запустили I2C Master Controller который живет в ПЛИС и управляется драйвером предоставляющим в ОС доступ к нему как типовому I2C-контроллеру. В этой, заключительной части из общей серии статей, я хотел бы филигранно отшлифовать все мелкие недочеты и привести примеры утилит, которые могли бы отправлять нужные нам данные на I2C OLED-дисплей SSD1306, например температуру кристалла, время или что-нибудь еще. 

Всем кому интересна тема - го под кат! :) 

Важно! Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте, с чего можно начать, при изучении отладочных плат на базе Zynq. Я не являюсь профессиональным разработчиком под ПЛИС и SoC Zynq, не являюсь системным программистом под Linux и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется.

Вступление

В прошлых статьях мы собрали первый работающий вариант проекта: подготовили аппаратную часть в Vivado, добавили в PL собственный I2C Master Controller, подключили его к процессорной системе Zynq через AXI и запустили Linux, в котором этот IP-блок стал виден как обычный I2C-контроллер.

Мне не хотелось писать отдельный узкоспециализированный драйвер только под OLED-дисплей, а пришла идея сделать кажущуюся более правильной интеграцию. IP-ядро в ПЛИС должно выглядеть для Linux как стандартный I2C master adapter. После этого все, что находится на этой I2C-шине, может обслуживаться уже обычными механизмами ядра: через Device Tree, I2C subsystem и существующие драйверы устройств. 

В нашем случае на эту шину подключен OLED-дисплей SSD1306 с адресом 0x3C. Подробное отражение архитектуры проекта я составил в облюбованном мной Mermaid следующим образом:

И в финальной точке я хотел получить не просто факт обмена байтами по I2C, а нормальное Linux-устройство /dev/fb0, на которое можно выводить текст, часы, диагностические паттерны, температуру кристалла, uptime или любую другую небольшую статусную информацию.

На первый взгляд после прошлой статьи оставалось сделать совсем немного: написать маленькую userspace-утилиту, открыть framebuffer, нарисовать пару строк текста и вывести их на OLED. Но при переходе от "первого запуска" к более аккуратному и воспроизводимому состоянию обнаружилось несколько мелких, но важных недочетов.

Загрузчик стартовал, UART-консоль работала, SD-карта определялась, но в логе были строки которые надо было шлифануть:

U-Boot 2024.01 (Jun 04 2026 - 04:40:03 +0300)

CPU:   Zynq 7z020
Silicon: v3.1
Model: Xilinx ZC706 board
DRAM:  ECC disabled 1 GiB
Core:  29 devices, 19 uclasses, devicetree: embed
Flash: 0 Bytes
NAND:  0 MiB
MMC:   mmc@e0100000: 0
Loading Environment from FAT... *** Error - No Valid Environment Area found
*** Warning - bad env area, using default environment

In:    serial@e0001000
Out:   serial@e0001000
Err:   serial@e0001000
Net:   Could not get PHY for eth0: addr 7
No ethernet found.

Во-первых. Физически используется не ZC706, а ZYNQ MINI Rev B. Это означает, что U-Boot был собран или сконфигурирован с чужим описанием платы. Потом ошибка Ethernet PHY:

Could not get PHY for eth0: addr 7
No ethernet found.

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

Потом не заработала USB-клавиатура, вероятно из-за того, что чего-то не хватило в описании DTS. 

Поэтому в этой заключительной части я хочу не просто "дорисовать часы на OLED из userspace", а привести проект в более аккуратное состояние:

  • причесать DTS под мою ZYNQ MINI Rev B, чтобы работал Ethernet, USB;

  • убрать все ошибки при инициализации железа на этапе загрузки U-Boot;

  • разобрать init-скрипты которые выполняют всю работу чтобы плата стартовала в состоянии которое нам необходимо;

  • понять, в какой момент появляются модули, I2C-шина и /dev/fb0;

  • добавить userspace-утилиты для вывода кастомных данных на SSD1306;

  • сделать переключение между режимом Linux console и режимом небольшого статусного дисплея.

Поэтому статья начнется не с OLED и Linux, а начать придется с исправления косяков. Начнем с того места, где проблема стала видна первой: с лога U-Boot и неверного определения платы.

Исправляем ошибки в U-Boot

U-Boot показал, что проект формально загружается, но описание платы не соответствует реальному железу. На первый взгляд приведенный выше лог не выглядит полностью аварийным. Загрузчик стартовал, процессор определился, DDR инициализирована, UART работает, SD-карта видна. Но в нем есть несколько строк, которые нельзя игнорировать.

Первая строка, которая меня сразу смутила: Model: Xilinx ZC706 board и сразу стало понятно, что U-Boot использует описание другой платы. 

В строке Core:  29 devices, 19 uclasses, devicetree: embed важна последняя часть devicetree: embed.

Это означает, что U-Boot использует control Device Tree, встроенный непосредственно в образ U-Boot. То есть описание платы не загружается отдельным файлом с SD-карты и не передается загрузчиком предыдущей стадии. Оно уже находится внутри самого U-Boot и собирается вместе с ним.

И исходя из этого отсюда вытекают все остальные ошибки связанные с некорректной периферией. Что ж, сделаем свой кастомный DTS для U-Boot и исправим все. 

Готовим каталог для U-Boot DTS:

export WORK=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux
export BR_SRC="$WORK/buildroot-2024.02.7"
export BR_OUT="$WORK/br-output"
export BR_EXT="$WORK/board-support"
mkdir -p "$BR_EXT/uboot-dts"

Файл назовем так: 

$BR_EXT/uboot-dts/zynq-mini-revb-u-boot.dts

Суффикс u-boot здесь нужен только для ясности. Он показывает, что этот DTS предназначен для control DTB самого U-Boot, а не для Linux kernel.

На первом шаге не нужно описывать все железо платы. Для исправления загрузчика достаточно описать то, что нужно самому U-Boot:

  • модель платы;

  • memory node;

  • UART1 для консоли;

  • SDIO0 для загрузки с microSD;

  • QSPI flash W25Q128;

  • GEM0 и Ethernet PHY;

  • aliases;

  • stdout-path.

Создадим файл:

cat > "$BR_EXT/uboot-dts/zynq-mini-revb-u-boot.dts" << 'EOF'
/dts-v1/;

#include "zynq-7000.dtsi"

/ {
    model = "ZYNQ MINI Rev B";
    compatible = "custom,zynq-mini-revb", "xlnx,zynq-7000";

    aliases {
        serial0 = &uart1;
        mmc0 = &sdhci0;
        ethernet0 = &gem0;
        spi0 = &qspi;
    };

    chosen {
        stdout-path = "serial0:115200n8";
    };

    memory@0 {
        device_type = "memory";
        reg = <0x0 0x20000000>;
    };
};

&uart1 {
    status = "okay";
    bootph-all;
};

&sdhci0 {
    status = "okay";
    bus-width = <4>;
    broken-cd;
    disable-wp;
    bootph-all;
};

&qspi {
    status = "okay";
    is-dual = <0>;
    num-cs = <1>;
    bootph-all;

    flash@0 {
        compatible = "winbond,w25q128", "jedec,spi-nor";
        reg = <0>;
        spi-max-frequency = <50000000>;
    };
};

&gem0 {
    status = "okay";
    phy-mode = "rgmii-id";
    phy-handle = <&ethernet_phy>;

    ethernet_phy: ethernet-phy@0 {
        reg = <1>;
        device_type = "ethernet-phy";
    };
};
EOF

Во-первых я изменил MDIO-адрес PHY на реальный с 0 на 1:

ethernet_phy: ethernet-phy@0 { reg = <1>; };

После сборки U-Boot не увидел PHY и этот адрес нужно заменить просмотрев как подключены strap-пины RTL8211E.

Далее надо поправить memory node. 

memory@0 {
    device_type = "memory";
    reg = <0x0 0x20000000>;
};

Значение 0x20000000 соответствует 512 MiB: 0x20000000 = 536870912 bytes = 512 MiB

Это важная правка, потому что в текущем логе U-Boot было:

DRAM: ECC disabled 1 GiB

Если фактическая память платы около 512 MiB, а U-Boot считает, что доступен 1 GiB, система может работать нестабильно при обращении выше физически установленного объема памяти. Поэтому на первом исправленном варианте безопаснее явно задать 512 MiB.

Теперь нужно сказать U-Boot, что его default device tree - это не ZC706, а наш файл.

В Buildroot для этого удобно использовать два механизма:

  1. BR2_TARGET_UBOOT_CUSTOM_DTS_PATH - передать U-Boot внешний DTS.

  2. uboot.fragment - зафиксировать Kconfig-опции U-Boot.

Сначала добавим путь к custom DTS в Buildroot defconfig.

Откроем файл:

vim $BR_EXT/configs/zynq_mini_revb_defconfig

В секцию U-Boot добавим строку:

BR2_TARGET_UBOOT_CUSTOM_DTS_PATH="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/uboot-dts/zynq-mini-revb-u-boot.dts"

Итоговый блок U-Boot должен выглядеть примерно так:

# --- U-Boot ----------------------------------------------------------
BR2_TARGET_UBOOT=y
BR2_TARGET_UBOOT_LATEST_VERSION=y
BR2_TARGET_UBOOT_BOARD_DEFCONFIG="xilinx_zynq_virt"
BR2_TARGET_UBOOT_CONFIG_FRAGMENT_FILES="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/board/zynq_mini_revb/uboot.fragment"
BR2_TARGET_UBOOT_CUSTOM_DTS_PATH="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/uboot-dts/zynq-mini-revb-u-boot.dts"
BR2_TARGET_UBOOT_FORMAT_BIN=y
BR2_TARGET_UBOOT_FORMAT_IMG=y
BR2_TARGET_UBOOT_FORMAT_ELF=y
BR2_TARGET_UBOOT_NEEDS_DTC=y
BR2_TARGET_UBOOT_NEEDS_PYLIBFDT=y
BR2_TARGET_UBOOT_NEEDS_OPENSSL=y
BR2_TARGET_UBOOT_NEEDS_GNUTLS=y

Если в используемой версии Buildroot опции BR2_TARGET_UBOOT_CUSTOM_DTS_PATH нет, есть запасной вариант: положить DTS в дерево сборки U-Boot через patch или post-patch hook. Но для Buildroot 2024.02 нормальный путь - использовать custom DTS path.

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

# --- Device Tree control ------------------------------------------------
# При SD-boot legacy-FSBL прыгает в U-Boot, но НЕ устанавливает r2 на DTB.
# По умолчанию U-Boot может быть собран с CONFIG_OF_BOARD=y и ждать DTB
# от board-specific кода или от предыдущего загрузчика. В нашем SD-сценарии
# FSBL не передает такой DTB, поэтому U-Boot должен использовать DTB,
# встроенный прямо в u-boot.elf.
CONFIG_OF_CONTROL=y

# CONFIG_OF_SEPARATE is not set
# CONFIG_OF_BOARD is not set
CONFIG_DEFAULT_DEVICE_TREE="zynq-mini-revb-u-boot"

# Файловые системы и команды для SD-загрузки.
CONFIG_FS_FAT=y
CONFIG_CMD_FAT=y
CONFIG_FAT_WRITE=y
CONFIG_FS_EXT4=y
CONFIG_CMD_EXT2=y
CONFIG_CMD_EXT4=y

CONFIG_LEGACY_IMAGE_FORMAT=y
CONFIG_CMD_BOOTM=y
CONFIG_CMD_MMC=y

CONFIG_SYS_PROMPT="zynq-mini> "

# --- Boot image and filesystems -----------------------------------------
# Файловые системы и команды для SD-загрузки.
CONFIG_LEGACY_IMAGE_FORMAT=y
CONFIG_CMD_BOOTM=y

CONFIG_FS_FAT=y
CONFIG_CMD_FAT=y
CONFIG_FAT_WRITE=y

CONFIG_FS_EXT4=y
CONFIG_CMD_EXT2=y
CONFIG_CMD_EXT4=y

# --- MMC / SD0 boot -----------------------------------------------------
# SD0 на Zynq PS используется как загрузочный носитель.
CONFIG_CMD_MMC=y
CONFIG_MMC=y
CONFIG_MMC_SDHCI=y
CONFIG_MMC_SDHCI_ZYNQ=y

# --- SPI / QSPI / SPI NOR flash -----------------------------------------
# На плате установлена Winbond W25Q128, 128 Mbit = 16 MiB.
# Проверяется командой sf probe.
CONFIG_SPI=y
CONFIG_DM_SPI=y
CONFIG_ZYNQ_QSPI=y

CONFIG_SPI_FLASH=y
CONFIG_SPI_FLASH_WINBOND=y

CONFIG_CMD_SPI=y
CONFIG_CMD_SF=y

# --- MTD diagnostics for SPI NOR ----------------------------------------
# Нужны для дополнительной диагностики SPI NOR через mtd list.
CONFIG_MTD=y
CONFIG_CMD_MTD=y
# --- Ethernet core ------------------------------------------------------
# Базовая сеть U-Boot и Driver Model Ethernet.
CONFIG_NET=y
CONFIG_CMD_NET=y
CONFIG_NETDEVICES=y
CONFIG_DM_ETH=y

# --- Zynq GEM Ethernet and PHY ------------------------------------------
# GEM0 в PS7 + внешний Realtek RTL8211E PHY.
CONFIG_ZYNQ_GEM=y
CONFIG_PHYLIB=y
CONFIG_PHY_REALTEK=y
CONFIG_MII=y

# --- Ethernet diagnostic commands ---------------------------------------
# Нужны для диагностики MDIO/MII:
# mdio list
# mii device
# mii info
CONFIG_CMD_MII=y
CONFIG_CMD_MDIO=y
CONFIG_CMD_PING=y
CONFIG_CMD_DHCP=y

# --- Driver Model diagnostics -------------------------------------------
# Нужен для:
# dm tree
# dm uclass eth
# dm uclass mdio
# dm uclass spi
# dm uclass mtd
CONFIG_CMD_DM=y

Ключевые строки здесь:

CONFIG_OF_CONTROL=y
CONFIG_OF_EMBED=y
# CONFIG_OF_BOARD is not set
CONFIG_DEFAULT_DEVICE_TREE="zynq-mini-revb-u-boot"

Их смысл:

  • CONFIG_OF_CONTROL=y- U-Boot использует Device Tree для описания своих устройств

  • CONFIG_OF_EMBED=y - control DTB встраивается внутрь U-Boot

  • # CONFIG_OF_BOARD is not set- U-Boot не ждет DTB от board code или предыдущего загрузчика

  • CONFIG_DEFAULT_DEVICE_TREE=... - выбирает наш DTS как основной

Строка # CONFIG_OF_BOARD is not setособенно важна для этого проекта. FSBL на Zynq в нашей схеме не передает U-Boot указатель на Device Tree через регистр CPU. Если оставить режим, при котором U-Boot ожидает внешний DTB от предыдущей стадии, можно снова получить зависание или странное поведение до нормального вывода баннера U-Boot.

Перед полной сборкой нужно применить defconfig и проверить, что Buildroot действительно увидел наши настройки.

cd "$BR_SRC"
make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" zynq_mini_revb_defconfig

Проверяем строки в Buildroot .config:

grep -E 'UBOOT|CUSTOM_DTS|CONFIG_FRAGMENT' "$BR_OUT/.config"

Ожидаем увидеть примерно:

BR2_TARGET_UBOOT=y
BR2_TARGET_UBOOT_BOARD_DEFCONFIG="xilinx_zynq_virt"
BR2_TARGET_UBOOT_CONFIG_FRAGMENT_FILES="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/board/zynq_mini_revb/uboot.fragment"
BR2_TARGET_UBOOT_CUSTOM_DTS_PATH="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/uboot-dts/zynq-mini-revb-u-boot.dts"

Далее можно открыть меню U-Boot:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" uboot-menuconfig

В меню нужно проверить:

Device Tree Control
  [*] Run-time configuration via Device Tree
  [*] Provider of DTB for DT control: Embedded DTB
  Default Device Tree for DT control: zynq-mini-revb-u-boot

Если каких-то опций из uboot.fragment нет или они называются иначе, это нормально для U-Boot: имена Kconfig-опций иногда меняются между версиями. В таком случае нужно найти актуальное имя через поиск в uboot-menuconfig.

Если U-Boot уже был собран раньше, лучше очистить именно его сборочный каталог:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" uboot-dirclean

Затем собрать U-Boot заново:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" uboot-rebuild

Или выполнить общую сборку:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" -j"$(nproc)"

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

ls -l "$BR_OUT/images"/u-boot*

Ожидаемые файлы: u-boot.bin, u-boot.img, u-boot

Для Zynq boot flow нам в первую очередь нужен:

$BR_OUT/images/u-boot

Именно u-boot.elf будет включен в итоговый BOOT.BIN. Проверяем, что U-Boot собрался с нашим DTB.

grep -E 'CONFIG_OF_CONTROL|CONFIG_OF_EMBED|CONFIG_OF_BOARD|CONFIG_DEFAULT_DEVICE_TREE' "$BR_OUT/build/uboot-"*/.config

Ожидаемый результат:

CONFIG_OF_CONTROL=y
CONFIG_OF_EMBED=y
# CONFIG_OF_BOARD is not set
CONFIG_DEFAULT_DEVICE_TREE="zynq-mini-revb-u-boot"

Потом проверим строки внутри u-boot.elf:

strings "$BR_OUT/images/u-boot" | grep -E 'ZYNQ MINI|ZC706|zynq-mini'

Хороший признак: ZYNQ MINI Rev B, а плохой признак: Xilinx ZC706 board

Если strings все еще показывает Xilinx ZC706 board, значит U-Boot по-прежнему собран с чужим DTS. В таком случае нужно проверить:

grep BR2_TARGET_UBOOT_CUSTOM_DTS_PATH "$BR_OUT/.config"
grep CONFIG_DEFAULT_DEVICE_TREE "$BR_OUT/build/uboot-"*/.config
find "$BR_OUT/build/uboot-"* -name '*zynq-mini*' -o -name '*zc706*'

И последним шагом пересоберем BOOT.BIN через bootgen. Buildroot собирает u-boot.elf, но полноценный BOOT.BIN для Zynq обычно собирается отдельно, потому что в него также входят FSBL и bitstream из Vivado/Vitis. 

У нас должны быть три входных файла:

  • fsbl.elf - Vitis, собранный из XSA

  • system.bit или другой .bit - Vivado implementation

  • u-boot - Buildroot output

Пример переменных:

export VIVADO_PROJ=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/
export BITSTREAM="$VIVADO_PROJ/zynq_mini_oled.runs/impl_1/zynq_mini_oled_top.bit"
export FSBL_ELF="$WORK/../vitis/zynq_mini_oled_platform/zynq_fsbl/build/fsbl.elf"
export UBOOT_ELF="$BR_OUT/images/u-boot"
export BOOT_OUT="$BR_OUT/images/BOOT.BIN"

Пути нужно заменить на фактические для своего проекта. Создаем boot.bif:

cat > "$BR_OUT/images/boot.bif" << EOF
the_ROM_image:
{
  [bootloader] $FSBL_ELF
  $BITSTREAM
  $UBOOT_ELF
}
EOF

Подключаем окружение Vitis, чтобы появился bootgen:

source /opt/xilinx/2025.2/Vitis/settings64.sh 
which bootgen

Собираем BOOT.BIN:

bootgen -arch zynq -image "$BR_OUT/images/boot.bif" -o "$BOOT_OUT" -w

Проверяем файл:

ls -lh "$BOOT_OUT"

Залил на BOOT-раздел SD-карты новый BOOT.BIN и запускаю. Вижу это:

U-Boot 2024.01 (Jun 07 2026 - 01:47:00 +0300)

CPU:   Zynq 7z020
Silicon: v3.1
Model: ZYNQ MINI Rev B
DRAM:  ECC disabled 512 MiB
Core:  20 devices, 16 uclasses, devicetree: embed
Flash: 0 Bytes
NAND:  0 MiB
MMC:   mmc@e0100000: 0
Loading Environment from FAT... *** Error - No Valid Environment Area found
*** Warning - bad env area, using default environment

In:    serial@e0001000
Out:   serial@e0001000
Err:   serial@e0001000
Net:   Could not get PHY for eth0: addr 1
No ethernet found.

Hit any key to stop autoboot:  0 

Так, плата теперь пишется правильная, объем RAM - тоже. Посмотрим почему Flash 0 Bytes:

zynq-mini> sf probe
SF: Detected w25q128 with page size 256 Bytes, erase size 64 KiB, total 16 MiB

Так, QSPI Flash обнаруживается, видимо не надо трактовать эту надпись как неисправность. Для этой сборки U-Boot это, вероятнее всего, вывод legacy flash layer, а не результат sf probe для SPI NOR.

zynq-mini> mtd list
List of MTD devices:
* nor0
  - device: flash@0
  - parent: spi@e000d000
  - driver: jedec_spi_nor
  - path: /axi/spi@e000d000/flash@0
  - type: NOR flash
  - block size: 0x10000 bytes
  - min I/O: 0x1 bytes
  - 0x000000000000-0x000001000000 : "nor0"

Попробуем для полной уверенности прочитать что-нибудь ?

zynq-mini> mtd read nor0 0x100000 0x0 0x100
Reading 256 byte(s) at offset 0x00000000
zynq-mini> md.b 0x100000 0x100
00100000: fe ff ff ea fe ff ff ea fe ff ff ea fe ff ff ea  ................
00100010: fe ff ff ea fe ff ff ea fe ff ff ea fe ff ff ea  ................
00100020: 66 55 99 aa 58 4e 4c 58 00 00 00 00 00 00 01 01  fU..XNLX........
00100030: 00 17 00 00 08 00 01 00 00 00 00 00 00 00 00 00  ................
00100040: 08 00 01 00 01 00 00 00 30 45 17 fc 00 00 00 00  ........0E......
00100050: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00100060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00100070: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00100080: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
00100090: 00 00 00 00 00 00 00 00 c0 08 00 00 80 0c 00 00  ................
001000a0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................
001000b0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................
001000c0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................
001000d0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................
001000e0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................
001000f0: ff ff ff ff 00 00 00 00 ff ff ff ff 00 00 00 00  ................

Ну, что-то есть. Если бы нам потребовалась сейчас загрузка с QSPI Flash - нужно было бы знать точные смещения и можно было бы образы скормить как raw:

mtd list
mtd read kernel 0x3000000
mtd read dtb 0x2A00000
bootm 0x3000000 - 0x2A00000

Но это так, отступление. Надо разобраться с Ethernet. С ним пришлось повеселиться, пока в ходе дебага я не обнаружил, что тупо не включил шину MDIO в Vivado. После этого пришлось полностью пересобирать весь дизайн от генерации block design, bitstream, генерации платформы в Vitis и пересборки FSBL, который инициализирует PS-ную часть. 

Расскажу об этом. Вдруг кому-то пригодится в будущем в реальном дебаге неисправностей.

Изначально в старом логе U-Boot была такая ошибка:

Net:   Could not get PHY for eth0: addr 7
No ethernet found.

Адрес 7 был первым первым признаком того, что U-Boot использует чужой DTS тот что от ZC706. После перехода на собственный DTS для ZYNQ MINI Rev B ситуация изменилась:

Net:   Could not get PHY for eth0: addr 1
No ethernet found.

Это уже был прогресс. U-Boot перестал искать PHY по адресу 7 и начал использовать адрес 1, который был задан в нашем DTS:

&gem0 {
    status = "okay";
    local-mac-address = [00 0a 35 01 02 03];

    phy-mode = "rgmii-id";
    phy-handle = <&ethernet_phy>;

    ethernet_phy: ethernet-phy@1 {
        reg = <1>;
        device_type = "ethernet-phy";
    };
};

Но Ethernet по-прежнему не поднимался. Первым шагом - я решил убедиться, что U-Boot загружен с правильным embedded Device Tree.

Для этого в консоли U-Boot были выполнены команды:

fdt addr $fdtcontroladdr
fdt print / model
fdt print /aliases

Результат:

Working FDT set to 40bfd10
model = "ZYNQ MINI Rev B"
aliases {
        serial0 = "/axi/serial@e0001000";
        mmc0 = "/axi/mmc@e0100000";
        ethernet0 = "/axi/ethernet@e000b000";
        spi0 = "/axi/spi@e000d000";
};

Это подтвердило, что U-Boot использует уже не ZC706, а наш DTS. Одновременно стало понятно, что Ethernet-узел находится не в корне дерева и не в /amba, а по пути /axi/ethernet@e000b000

Поэтому старые диагностические команды вида fdt print /amba/ethernet@e000b000 или fdt print /ethernet@e000b000 возвращали ошибку libfdt fdt_path_offset() returned FDT_ERR_NOTFOUND

Правильная команда для нашего дерева fdt print /axi/ethernet@e000b000

Вывод fdt print /axi/ethernet@e000b000 показал, что узел существует и включен:

ethernet@e000b000 {
        compatible = "xlnx,zynq-gem", "cdns,gem";
        reg = <0xe000b000 0x00001000>;
        status = "okay";
        interrupts = <0x00000000 0x00000016 0x00000004>;
        clocks = <0x00000001 0x0000001e 0x00000001 0x0000001e 0x00000001 0x0000000d>;
        clock-names = "pclk", "hclk", "tx_clk";
        #address-cells = <0x00000001>;
        #size-cells = <0x00000000>;
        phy-mode = "rgmii-id";
        phy-handle = <0x00000008>;

        ethernet-phy@1 {
                reg = <0x00000001>;
                device_type = "ethernet-phy";
                phandle = <0x00000008>;
        };
};

Здесь были важны следующие признаки:

status = "okay"
phy-mode = "rgmii-id"
phy-handle = <...>
ethernet-phy@1
reg = <1>

На уровне DTS все выглядело правдоподобно. Но команды MDIO тогда еще не показывали рабочую шину:

mdio list
No MDIO bus found

А команда mii device возвращала пустой список:

MII devices:

Это означало, что U-Boot либо не создал Ethernet-устройство, либо драйвер GEM не дошел до регистрации MDIO bus. На этом месте до меня еще не дошло что шина MDIO не включена, я продолжил копать глубже :D

Следующий шаг который я сделал - я решил проверить итоговый .config U-Boot, вдруг в нем что-то не так. В Buildroot фрагмент конфигурации был дополнен Ethernet, PHY и диагностическими командами:

CONFIG_NET=y
CONFIG_CMD_NET=y
CONFIG_NETDEVICES=y
CONFIG_DM_ETH=y

CONFIG_ZYNQ_GEM=y
CONFIG_PHYLIB=y
CONFIG_PHY_REALTEK=y
CONFIG_MII=y

CONFIG_CMD_MII=y
CONFIG_CMD_MDIO=y
CONFIG_CMD_PING=y
CONFIG_CMD_DHCP=y

CONFIG_CMD_DM=y

Проверка:

grep -E 'CONFIG_NET=|CONFIG_DM_ETH=|CONFIG_NETDEVICES=|CONFIG_ZYNQ_GEM=|CONFIG_PHYLIB=|CONFIG_PHY_REALTEK=|CONFIG_MII=|CONFIG_CMD_MII=|CONFIG_CMD_MDIO=|CONFIG_CMD_DM=' "$BR_OUT/build/uboot-"*/.config

CONFIG_CMD_DM=y
CONFIG_CMD_MII=y
CONFIG_CMD_MDIO=y
CONFIG_NET=y
CONFIG_PHYLIB=y
CONFIG_PHY_REALTEK=y
CONFIG_DM_ETH=y
CONFIG_NETDEVICES=y
CONFIG_MII=y
CONFIG_ZYNQ_GEM=y

Вывод подтвердил, что все нужные опции попали в сборку. Значит, проблема была уже не в том, что U-Boot собран без Ethernet-драйвера.

Ну тогда я решил посмотреть, правильный ли драйвер привязан к узлу устройства. Для проверки привязки драйверов использовалась команда dm tree

На одном из этапов отладки вывод узла ethernet выглядел так:

ethernet      0  [   ]   zynq_gem              |   |-- ethernet@e000b000

Это важная строка, которая означала что драйвер zynq_gem в U-Boot найден, устройство ethernet@e000b000 создано, но устройство еще не прошло probe, потому что в колонке Probed стояло [ ].

Я попробовал закинуть конкретную привязку к драйверу:

ethernet_phy: ethernet-phy@1 {
   compatible = "realtek,rtl8211e", "ethernet-phy-id001c.c915", "ethernet-phy-ieee802.3-c22";
   reg = <1>;
   device_type = "ethernet-phy";
};

И на удивление после дополнительной правки DTS Ethernet-устройство таки появилось в uclass:

dm uclass ethernet
uclass 40: ethernet
0     ethernet@e000b000 @ 1eaf03d0, seq 0

Но mdio list все еще показывал:

No MDIO bus found

На этом этапе стало понятно, что U-Boot видит MAC, но не может корректно дойти до PHY.  Тут я пустился в проверку схемы платы ZYNQ MINI Rev B. На странице 7 схемы есть два PHY RTL8211E:

  • U18 - RTL8211E-VLPS Gigabit Ethernet

  • U17 - RTL8211E-VBPL Gigabit Ethernet

Для U-Boot gem0 относится именно к PS Ethernet, то есть к U18. На странице 4 схемы видно, что PS Ethernet-сигналы заведены на MIO банка 501, включая PHY_MDIO и PHY_MDC. На странице 7 видно, что PS Ethernet использует RTL8211E-VL, линии PHY_MDIO, PHY_MDC, PHY_nRST, RGMII-сигналы и отдельный 25 MHz кварц X5.

Особенно важными оказались strap-пины адреса PHY:

  • pin 34 - PHY_AD0/PHY_LED0 - подтяжка к VCC_1V8

  • pin 35 - PHY_AD1/PHY_LED1 - подтяжка к GND

  • pin 13 - PHY_AD2/PHY_RXCTL - подтяжка к GND

То есть адрес PHY_AD2 PHY_AD1 PHY_AD0 = 0 0 1 = 1

Схема подтвердила, что reg = <1> выбран правильно. Менять адрес на иной точно не требовалось.

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

Сначала смотрим интерфейсы:

ip link | grep eth0 -A1

2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
    link/ether c6:92:6f:9f:5d:df

Дальше смотрим PHY device:

# readlink -f /sys/class/net/eth0/phydev
/sys/devices/soc0/axi/e000b000.ethernet/mdio_bus/e000b000.ethernet-ffffffff/e000b000.ethernet-ffffffff:01

Последняя часть :01 означает MDIO-адрес PHY. То есть Linux подтвердил , что PHY address = 1

Проверяем MDIO bus:

ls -l /sys/bus/mdio_bus/devices/
e000b000.ethernet-ffffffff:01 -> ../../../devices/soc0/axi/e000b000.ethernet/mdio_bus/e000b000.ethernet-ffffffff/e000b000.ethernet-ffffffff:01

Потом смотрим PHY ID и драйвер:

for d in /sys/bus/mdio_bus/devices/*; do
    echo "$d"
    [ -f "$d/phy_id" ] && cat "$d/phy_id"
    [ -f "$d/uevent" ] && cat "$d/uevent"
    echo
done

Результат:

/sys/bus/mdio_bus/devices/e000b000.ethernet-ffffffff:01
0x001cc915
DEVTYPE=PHY
DRIVER=RTL8211E Gigabit Ethernet
OF_NAME=ethernet-phy
OF_FULLNAME=/axi/ethernet@e000b000/ethernet-phy@1
OF_TYPE=ethernet-phy

Это дало сразу несколько точных значений:

  • MAC - e000b000.ethernet

  • PHY address -1

  • PHY ID -0x001cc915

  • PHY driver - RTL8211E Gigabit Ethernet

  • PHY path - /axi/ethernet@e000b000/ethernet-phy@1

Также Linux показал режим интерфейса:

# cat /proc/device-tree/axi/ethernet@e000b000/phy-mode
rgmii-id

А reg PHY-узла был подтвержден через hexdump:

hexdump -Cv /proc/device-tree/axi/ethernet@e000b000/ethernet-phy@1/reg
00000000  00 00 00 01                                       |....|

Значит, адрес и phy-mode были закрыты как подтвержденные.

В U-Boot DTS был добавлен явный compatible для PHY по ID, который показал Linux:

ethernet_phy: ethernet-phy@1 {
    compatible = "realtek,rtl8211e", "ethernet-phy-id001c.c915", "ethernet-phy-ieee802.3-c22";
    reg = <1>;
    device_type = "ethernet-phy";
};

Формат строки ethernet-phy-id001c.c915 получен из PHY ID 0x001cc915 

То есть 001c -> старшие 16 бит, c915 -> младшие 16 бит

После этого U-Boot начал создавать Ethernet-устройство:

Net:
ZYNQ GEM: e000b000, mdio bus e000b000, phyaddr 1, interface rgmii-id
eth0: ethernet@e000b000

А mdio list начал показывать PHY:

eth0:
1 - RealTek RTL8211E <--> ethernet@e000b000

На этом этапе казалось, что Ethernet уже исправлен. Но следующая проверка показала, что MDIO-обмен еще не работает физически. Я попробовал прочитать MDIO-регистры:

mii device eth0
mii read 1 2
mii read 1 3
mii read 1 0
mii read 1 1

Также через mdio:

mdio read eth0 1 2
mdio read eth0 1 3
mdio read eth0 1 0
mdio read eth0 1 1

Результат был не тем, что ожидался, все по нулям:

mii read 1 2 -> 0000
mii read 1 3 -> 0000
mdio read eth0 1 2 -> 0x0
mdio read eth0 1 3 -> 0x0

Для RTL8211E ожидалось:

reg 2 = 0x001c
reg 3 = 0xc915

То есть U-Boot уже создал объект PHY на основе DTS, но реальные чтения MDIO-регистров возвращали нули. Это объясняло и ошибку auto-negotiation:

Waiting for PHY auto negotiation to complete......... TIMEOUT !

Если регистр 1, Basic Status Register, читается как 0x0000, U-Boot не увидит ни link up, ни autoneg complete. На этом этапе стало понятно, что проблема не в PHY address, не в compatible и не в phy-mode. Проблема находилась ниже: в аппаратной инициализации PS Ethernet/MDIO.

После проверки проекта Vivado выяснилось, что в PS7 configuration не была включена MDIO-шина для Ethernet. Device Tree сообщает U-Boot, что есть GEM0 и PHY, но MIO-мультиплексор, тактирование и часть PS-периферии настраиваются не DTS, а кодом ps7_init(), который попадает в FSBL из XSA.

Поэтому просто изменить DTS или пересобрать U-Boot было недостаточно. Нужно было:

  1. включить MDIO в Vivado PS7 configuration        

  2. перегенерировать bitstream        

  3. экспортировать новый XSA с bitstream        

  4. пересобрать platform в Vitis        

  5. пересобрать FSBL из нового XSA        

  6. собрать новый BOOT.BIN из нового FSBL, нового bitstream и u-boot.elf

  7. заменить BOOT.BIN на SD-карте

После полной пересборки U-Boot начал читать PHY по MDIO.

Новый лог U-Boot выглядел уже как нужно:

Net:
ZYNQ GEM: e000b000, mdio bus e000b000, phyaddr 1, interface rgmii-id
eth0: ethernet@e000b000

Но надо было проверить, читаем MDIO:

mdio list
eth0:
1 - RealTek RTL8211E <--> ethernet@e000b000

Проверяем PHY ID:

mii read 1 2
001C

То есть U-Boot наконец начал физически читать PHY ID-регистр. Ну и после этого сеть заработала, по DHCP получили адрес:

setenv serverip 192.168.2.1
dhcp
BOOTP broadcast 1
BOOTP broadcast 2
DHCP client bound to address 192.168.2.142 (545 ms)

Ну и ping стал успешным:

ping 192.168.2.1
Using ethernet@e000b000 device
host 192.168.2.1 is alive

Очень удобными при дебаге, кстати, оказались команды fdt:

zynq-mini> fdt addr $fdtcontroladdr
Working FDT set to 40bfd10
  
zynq-mini> fdt print / model
model = "ZYNQ MINI Rev B"
  
zynq-mini> fdt print /aliases
aliases {
        serial0 = "/axi/serial@e0001000";
        mmc0 = "/axi/mmc@e0100000";
        ethernet0 = "/axi/ethernet@e000b000";
        spi0 = "/axi/spi@e000d000";
};
  
zynq-mini> fdt print /axi/ethernet@e000b000
ethernet@e000b000 {
        compatible = "xlnx,zynq-gem", "cdns,gem";
        reg = <0xe000b000 0x00001000>;
        status = "okay";
        interrupts = <0x00000000 0x00000016 0x00000004>;
        clocks = <0x00000001 0x0000001e 0x00000001 0x0000001e 0x00000001 0x0000000d>;
        clock-names = "pclk", "hclk", "tx_clk";
        #address-cells = <0x00000001>;
        #size-cells = <0x00000000>;
        local-mac-address = [00 0a 35 01 02 03];
        phy-mode = "rgmii-id";
        phy-handle = <0x00000008>;
        phandle = <0x00000023>;
        ethernet-phy@1 {
                compatible = "realtek,rtl8211e", "ethernet-phy-id001c.c915", "ethernet-phy-ieee802.3-c22";
                reg = <0x00000001>;
                device_type = "ethernet-phy";
                phandle = <0x00000008>;
        };
};
zynq-mini> fdt print /axi/ethernet@e000b000 status
status = "okay"
  
zynq-mini> fdt print /axi/ethernet@e000b000 phy-mode
phy-mode = "rgmii-id"
  
zynq-mini> fdt print /axi/ethernet@e000b000 phy-handle
phy-handle = <0x00000008>

Эти команды помогли понять:

  • какой DTS реально использует U-Boot;

  • где находится Ethernet-узел;

  • включен ли он;

  • какой phy-mode задан;

  • какой PHY node связан через phy-handle.

Еще не менее полезными в дебаге оказались команды из Driver Model:

zynq-mini> dm tree
 Class     Index  Probed  Driver                Name
-----------------------------------------------------------
 root          0  [ + ]   root_driver           root_driver
 rsa_mod_ex    0  [   ]   mod_exp_sw            |-- mod_exp_sw
 simple_bus    0  [ + ]   simple_bus            |-- axi
 gpio          0  [   ]   gpio_zynq             |   |-- gpio@e000a000
 serial        0  [ + ]   serial_zynq           |   |-- serial@e0001000
 spi           0  [   ]   zynq_qspi             |   |-- spi@e000d000
 spi_flash     0  [   ]   jedec_spi_nor         |   |   `-- flash@0
 ethernet      0  [ + ]   zynq_gem              |   |-- ethernet@e000b000
 bootdev       0  [   ]   eth_bootdev           |   |   |-- ethernet@e000b000.bootdev
 eth_phy_ge    0  [ + ]   eth_phy_generic_drv   |   |   `-- ethernet-phy@1
 mmc           0  [ + ]   arasan_sdhci          |   |-- mmc@e0100000
 blk           0  [ + ]   mmc_blk               |   |   |-- mmc@e0100000.blk
 partition     0  [ + ]   blk_partition         |   |   |   |-- mmc@e0100000.blk:1
 partition     1  [ + ]   blk_partition         |   |   |   `-- mmc@e0100000.blk:2
 bootdev       1  [   ]   mmc_bootdev           |   |   `-- mmc@e0100000.bootdev
 simple_bus    1  [ + ]   simple_bus            |   |-- slcr@f8000000
 clk           0  [ + ]   zynq_clk              |   |   `-- clkc@100
 timer         0  [ + ]   arm_twd_timer         |   `-- timer@f8f00600
 bootstd       0  [   ]   bootstd_drv           `-- bootstd
 bootmeth      0  [   ]   bootmeth_extlinux         |-- extlinux
 bootmeth      1  [   ]   bootmeth_pxe              |-- pxe
 bootmeth      2  [   ]   vbe_simple                `-- vbe_simple
zynq-mini> dm uclass ethernet
uclass 40: ethernet
0   * ethernet@e000b000 @ 1eaf03d0, seq 0

zynq-mini> dm uclass mdio

Эти команды помогли разделить проблемы:

  • нет ethernet@e000b000драйвер или DTS не сработали

  • есть zynq_gem, но [ ]устройство создано, но probe не завершился

  • есть zynq_gem и [ + ]MAC-драйвер поднялся

  • есть eth_phy_generic_drvPHY-узел разобран

  • нет mdio uclassэто не всегда блокер, важнее фактические mii read

Ну и держите на вооружении команды MDIO/MII:

zynq-mini> mdio list
eth0:
1 - RealTek RTL8211E <--> ethernet@e000b000
  
zynq-mini> mii device
MII devices: 'eth0' 
Current device: 'eth0'
  
zynq-mini> mii info
PHY 0x00: OUI = 0x0732, Model = 0x11, Rev = 0x05,  10baseT, HDX
PHY 0x01: OUI = 0x0732, Model = 0x11, Rev = 0x05,  10baseT, HDX
  
zynq-mini> mii read 1 2
001C
  
zynq-mini> mii read 1 3
C915
  
zynq-mini> mii read 1 0
1140
  
zynq-mini> mii read 1 1
7969
  
zynq-mini> mdio read eth0 1 2
Reading from bus eth0
PHY at address 1:
2 - 0x1c
  
zynq-mini> mdio read eth0 1 3
Reading from bus eth0
PHY at address 1:
3 - 0xc915

Эти команды были ключевыми. Именно mii read 1 2 и mii read 1 3 показали, что U-Boot не читает PHY-регистры, когда MDIO не был включен в Vivado.

Основной урок который я для себя извлек: если речь идет о PS-периферии и MIO, нужно проверять всю цепочку:

Vivado PS7 configuration
    -> XSA
    -> FSBL / ps7_init
    -> U-Boot DTS
    -> U-Boot driver
    -> MDIO/MII diagnostics
    -> DHCP / ping

Именно разделение этой цепочки на уровни позволило не гадать, а последовательно найти ошибку. 

Ну и последнее, что оставалось поправить в этом безобразии - ошибка про отсутствие валидного uboot.env и нерабочий environment с переменными и прочим. Чтобы saveenv работал и U-Boot не ругался, нужно создать именно бинарный environment-файл, а не uEnv.txt. Будем поправлять, благо это не сложно. 

Проверяем, как настроен U-Boot:

grep -E 'CONFIG_ENV_IS_IN_FAT|CONFIG_ENV_FAT_INTERFACE|CONFIG_ENV_FAT_DEVICE_AND_PART|CONFIG_ENV_FAT_FILE|CONFIG_ENV_SIZE' \
  "$BR_OUT/build/uboot-"*/.config

Типичный результат может быть таким:

CONFIG_ENV_SIZE=0x20000
CONFIG_ENV_IS_IN_FAT=y
CONFIG_ENV_FAT_INTERFACE="mmc"
CONFIG_ENV_FAT_DEVICE_AND_PART="0:auto"
CONFIG_ENV_FAT_FILE="uboot.env"
CONFIG_ENV_FAT_FILE_REDUND="uboot-redund.env"

Тогда U-Boot ищет файл:

mmc 0:auto /uboot.env

Что ж, теперь необходимо его создать. Достаточно добавить только то, что вы реально хотите переопределять:

cat > /tmp/uboot-env.txt << 'EOF'
bootdelay=1
ethaddr=00:0a:35:01:02:03
ipaddr=192.168.2.145
netmask=255.255.255.0
gatewayip=192.168.2.1
serverip=192.168.2.1
bootcmd=mmc rescan; mmc dev 0; if fatload mmc 0 0x100000 uEnv.txt 10000; then env import -t 0x100000 10000; if test -n "$uenvcmd"; then run uenvcmd; fi; fi; fatload mmc 0 0x3000000 uImage; fatload mmc 0 0x2A00000 zynq-mini-revb.dtb; bootm 0x3000000 - 0x2A00000
EOF

Создать бинарный uboot.env:

"$BR_OUT/host/bin/mkenvimage" -s 0x20000 -o /tmp/uboot.env /tmp/uboot-env.txt

Если mkenvimage не найден:

find "$BR_OUT" -name mkenvimage -type f

Скопировать на FAT-раздел SD:

sudo mount /dev/sdX1 /mnt
sudo cp /tmp/uboot.env /mnt/uboot.env
sync
sudo umount /mnt

После этого при старте видим:

U-Boot 2024.01 (Jun 07 2026 - 02:34:34 +0300)

CPU:   Zynq 7z020
Silicon: v3.1
Model: ZYNQ MINI Rev B
DRAM:  ECC disabled 512 MiB
Core:  19 devices, 15 uclasses, devicetree: embed
Flash: 0 Bytes
NAND:  0 MiB
MMC:   mmc@e0100000: 0
Loading Environment from FAT... OK
In:    serial@e0001000
Out:   serial@e0001000
Err:   serial@e0001000
Net:   
ZYNQ GEM: e000b000, mdio bus e000b000, phyaddr 1, interface rgmii-id
eth0: ethernet@e000b000
Hit any key to stop autoboot:  0 
zynq-mini> 

Всё! Красота! Можно идти дальше в дебаг проблем с USB, но это уже связано с Linux. 

Фиксим проблемы в Linux DTS

После старта Linux Kernel сразу стало видно, что Ethernet завёлся. Класс. Одной проблемо меньше.

[    1.086279] macb e000b000.ethernet eth0: Cadence GEM rev 0x00020118 at 0xe000b000 irq 40 (00:0a:35:01:02:03)
[    3.333741] macb e000b000.ethernet eth0: PHY [e000b000.ethernet-ffffffff:01] driver [RTL8211E Gigabit Ethernet] (irq=POLL)
[    3.344859] macb e000b000.ethernet eth0: configuring for phy/rgmii-id link mode
[    7.537190] macb e000b000.ethernet eth0: Link is Up - 1Gbps/Full - flow control tx

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

[  132.412403] usb 1-1: new low-speed USB device number 2 using ci_hdrc
[  132.659561] input:   USB Keyboard as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.0/0003:04D9:1702.0001/input/input0
[  132.862800] hid-generic 0003:04D9:1702.0001: input: USB HID v1.10 Keyboard [  USB Keyboard] on usb-ci_hdrc.0-1/input0
[  132.890783] input:   USB Keyboard System Control as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.1/0003:04D9:1702.0002/input/input1
[  132.973063] input:   USB Keyboard Consumer Control as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.1/0003:04D9:1702.0002/input/input2
[  132.986432] hid-generic 0003:04D9:1702.0002: input: USB HID v1.10 Device [  USB Keyboard] on usb-ci_hdrc.0-1/input1

Но ввод не работал, Num Lock не включался. Питания не было. Как оказалось в ходе копаний - USB host на этой плате требует не только включенного &usb0, но и двух управляющих GPIO-линий:

  • USBPHY_nRSET - сброс ULPI PHY USB3320C.

  • USBCP_EN - включение питания VCC_USB_5.0 на USB host VBUS.

В исходном DTS у меня было есть только:

&usb0 {
	status = "okay";
	dr_mode = "host";
	usb-phy = <&usb_phy0>;
};

Этого недостаточно, если питание VBUS управляется отдельным GPIO.

На странице 10 схемы стоит USB PHY U19 USB3320C-EZK. У него есть линия RESETB, заведенная в сеть USBPHY_nRSET, а ULPI-сигналы называются USBPHY_DATA0..7, USBPHY_CLKOUT, USBPHY_STP, USBPHY_NXT, USBPHY_DIR.

Там же видно управление питанием USB host: сеть USBCP_EN управляет ключами Q3 AO3407 и Q4 AO3400, которые формируют VCC_USB_5.0 для разъема USB. То есть без активного USBCP_EN на VBUS физически не будет 5 В.

На странице 4 схемы USB-сигналы заведены в PS MIO bank 501. Это значит, что в Vivado PS7 configuration должен быть включен USB0 через MIO, а не через EMIO.

Проверяем:

Вроде все верно. 

И тут. Ключевой момент такой же, как с MDIO: если в Vivado PS7 не включен нужный MIO или GPIO, то Linux DTS сам по себе не сможет физически поднять линию.

Теперь необходимо внести правки в DTS, который загружается в память при старте Linux. Слава богу тут ничего не надо будет перетыкать взад-вперед и нужно будет только собрать blob-файл и перекинуть его по сети на плату.

Открываем файл dts: 

vim $WORK/board-support/dts/zynq-mini-revb.dts

И после блока &usb0 вставим следующее:

&gpio0 {
        status = "okay";

        usb0_phy_reset {
                gpio-hog;
                gpios = <7 GPIO_ACTIVE_HIGH>;
                output-high;
                line-name = "usb0-phy-reset";
        };

        usb0_vbus_en {
                gpio-hog;
                gpios = <9 GPIO_ACTIVE_HIGH>;
                output-high;
                line-name = "usb-vbus-en";
        };
};
  • MIO7 - USBPHY_nRSET - держит USB3320C вне reset

  • MIO9 - USBCP_EN - включает ключ питания VBUS

  • output-high - высокий уровень- reset отпущен, VBUS включен

Теперь необходимо пересобрать бинарный DTS и перезалить его на SD-карту. 

export WORK=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux
export BR_SRC="$WORK/buildroot-2024.02.7"
export BR_OUT="$WORK/br-output"
export BR_EXT="$WORK/board-support"

cd "$BR_SRC"

Если DTS лежит во внешнем дереве, например: $BR_EXT/dts/zynq-mini-revb.dts и подключен через BR2_LINUX_KERNEL_CUSTOM_DTS_PATH="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/dts/zynq-mini-revb.dts"

то достаточно выполнить команду: 

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" linux-rebuild

После сборки проверить DTB:

ls -lh "$BR_OUT/images/"*.dtb

Должен появиться файл: $BR_OUT/images/zynq-mini-revb.dtb

Если файл не обновился по timestamp, можно сделать жестче:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" linux-dirclean
make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" linux-rebuild

Но обычно для DTS достаточно linux-rebuild.

Теперь заливаем на плату файл подсмотрев ее IP:

# ifconfig | grep inet
          inet addr:192.168.2.142  Bcast:192.168.2.255  Mask:255.255.255.0
          inet6 addr: fe80::20a:35ff:fe01:203/64 Scope:Link
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host

На ПК делаем следующее: 

export TARGET=root@192.168.2.145
scp -O "$BR_OUT/images/zynq-mini-revb.dtb" "$TARGET:/tmp/zynq-mini-revb.dtb"

Прям через SSH выполним все необходимо на плате Zynq:

ssh "$TARGET" 'mkdir -p /mnt/boot && mount /dev/mmcblk0p1 /mnt/boot'
  
ssh "$TARGET" 'ls -la /mnt/boot'
root@192.168.2.142's password: 
total 15985
drwx------    2 root     root           512 Jan  1 00:00 .
drwxr-xr-x    3 1000     1000          1024 Jan  1 00:28 ..
-rwx------    1 root     root       5101256 Jun  7  2026 BOOT.BIN
-rwx------    1 root     root           957 Jun  4  2026 uEnv.txt
-rwx------    1 root     root      10990144 Jun  4  2026 uImage
-rwx------    1 root     root        131072 Jan  1  1980 uboot-redund.env
-rwx------    1 root     root        131072 Jun  7  2026 uboot.env
-rwx------    1 root     root         11534 Jun  4  2026 zynq-mini-revb.dtb

Заменяем сохраняя backup рабочей версии:

ssh "$TARGET" '
set -e
cp /mnt/boot/zynq-mini-revb.dtb /mnt/boot/zynq-mini-revb.dtb.bak
cp /tmp/zynq-mini-revb.dtb /mnt/boot/zynq-mini-revb.dtb
sync
ls -lh /mnt/boot/zynq-mini-revb.dtb /mnt/boot/zynq-mini-revb.dtb.bak
'

Размонтируем и перезагружаем:

ssh "$TARGET" 'umount /mnt/boot'
ssh "$TARGET" 'reboot'

После перезагрузки проверяем на устройстве:

# cat /proc/device-tree/model
ZYNQ MINI Rev B (manual Linux port)

# mount -t debugfs none /sys/kernel/debug 2>/dev/null || true
# cat /sys/kernel/debug/gpio | grep -iE 'usb|vbus|reset'
 gpio-519 (                    |usb0-phy-reset      ) out lo 
 gpio-521 (                    |usb-vbus-en         ) out hi 

Проверяем USB:

# dmesg | grep -iE 'usb|ulpi|ci_hdrc|ehci|phy|vbus'
[    0.000000] Booting Linux on physical CPU 0x0
[    0.353653] usbcore: registered new interface driver usbfs
[    0.359228] usbcore: registered new interface driver hub
[    0.364595] usbcore: registered new device driver usb
[    0.369930] usb_phy_generic usb-phy: dummy supplies not allowed for exclusive requests
[    1.128957] pegasus: Pegasus/Pegasus II USB Ethernet driver
[    1.134565] usbcore: registered new interface driver pegasus
[    1.140295] usbcore: registered new interface driver asix
[    1.145736] usbcore: registered new interface driver ax88179_178a
[    1.151875] usbcore: registered new interface driver cdc_ether
[    1.157755] usbcore: registered new interface driver smsc75xx
[    1.163533] usbcore: registered new interface driver smsc95xx
[    1.169320] usbcore: registered new interface driver net1080
[    1.175007] usbcore: registered new interface driver cdc_subset
[    1.180966] usbcore: registered new interface driver zaurus
[    1.186582] usbcore: registered new interface driver cdc_ncm
[    1.196093] usbcore: registered new interface driver usb-storage
[    1.204001] ci_hdrc ci_hdrc.0: EHCI Host Controller
[    1.209000] ci_hdrc ci_hdrc.0: new USB bus registered, assigned bus number 1
[    1.285657] ci_hdrc ci_hdrc.0: USB 2.0 started, EHCI 1.00
[    1.292029] hub 1-0:1.0: USB hub found
[    1.419767] usbcore: registered new interface driver usbhid
[    1.425373] usbhid: USB HID core driver
[    1.601955] usb 1-1: new low-speed USB device number 2 using ci_hdrc
[    1.859948] input:   USB Keyboard as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.0/0003:04D9:1702.0001/input/input0
[    2.082699] hid-generic 0003:04D9:1702.0001: input: USB HID v1.10 Keyboard [  USB Keyboard] on usb-ci_hdrc.0-1/input0
[    2.116164] input:   USB Keyboard System Control as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.1/0003:04D9:1702.0002/input/input1
[    2.202358] input:   USB Keyboard Consumer Control as /devices/soc0/axi/e0002000.usb/ci_hdrc.0/usb1/1-1/1-1:1.1/0003:04D9:1702.0002/input/input2
[    2.209758] hid-generic 0003:04D9:1702.0002: input: USB HID v1.10 Device [  USB Keyboard] on usb-ci_hdrc.0-1/input1
[    3.313396] macb e000b000.ethernet eth0: PHY [e000b000.ethernet-ffffffff:01] driver [RTL8211E Gigabit Ethernet] (irq=POLL)
[    3.324553] macb e000b000.ethernet eth0: configuring for phy/rgmii-id link mode

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

Разбираемся с init-скриптами: сеть, модули, OLED, USB и SSH

После того как U-Boot загрузил uImage и передал ядру Linux правильный zynq-mini-revb.dtb, начинается следующая часть загрузки: старт userspace. На этом этапе ядро уже инициализировало базовое железо, смонтировало rootfs и запустило первый процесс /sbin/init.

В нашем Buildroot-образе используется классическая BusyBox init-схема. Поэтому последовательность запуска определяется простыми shell-скриптами в /etc/init.d.

Главный стартовый скрипт - rcS. Он проходит по всем файлам вида /etc/init.d/S??* и запускает их в числовом порядке.

Упрощенно логика такая:

for i in /etc/init.d/S??* ; do
    [ ! -f "$i" ] && continue
    $i start
done

То есть порядок запуска определяется именем файла:

S01...
S02...
S03...
S39...
S40...
S45...
S50...

Для остановки системы используется обратная логика. Скрипт rcK проходит по тем же S??*, но в обратном порядке и вызывает stop.

  1. rcS: запуск сервисов по возрастанию номеров

  2. rcK: остановка сервисов по убыванию номеров

Это важно, потому что сеть, дисплей и SSH зависят друг от друга не формально через dependency graph, как в systemd, а только через выбранные номера скриптов.

В текущем образе важные init-скрипты идут так:

S01seedrng
S01syslogd
S02klogd
S02sysctl
S03modules
S39set-eth0-mac
S40network
S45oled-console
S50dropbear

На практике ключевая логика такая:

  • модули должны загрузиться до OLED-консоли

  • MAC должен быть задан до ifup

  • сеть должна подняться до SSH

  • /dev/fb0 должен появиться до запуска oled-console

S01seedrng: состояние генератора случайных чисел

Первым запускается S01seedrng. Он сохраняет и восстанавливает seed для генератора случайных чисел между перезагрузками.

Для нашего OLED-проекта этот скрипт напрямую не нужен, но он важен для сетевых сервисов и SSH. Dropbear при первом старте генерирует host keys, и ему нужен нормальный источник случайных чисел.

Скрипт читает дополнительные параметры из /etc/default/seedrng и затем вызывает seedrng $SEEDRNG_ARGS || true

Принципиально важно, что скрипт не должен ломать загрузку. Поэтому ошибка seedrng игнорируется через || true.

S01syslogd и S02klogd: логи userspace и ядра

Дальше стартуют два логирующих демона: S01syslogd и S02klogd. syslogd принимает сообщения userspace, а klogd забирает сообщения ядра.

Для отладки и bring-up это весьма полезно. Если модуль i2c-master-axi не загрузился, если ssd1307fb не создал /dev/fb0, если network startup завершился ошибкой, это должно быть видно в логах.

Проверка на плате:

ps | grep -E 'syslogd|klogd'
logread
dmesg

Если в системе есть logread, то через него удобно смотреть накопленные сообщения BusyBox syslog.

S02sysctl: применение параметров ядра

S02sysctl применяет параметры из:

/etc/sysctl.d/*.conf
/usr/local/lib/sysctl.d/*.conf
/usr/lib/sysctl.d/*.conf
/lib/sysctl.d/*.conf
/etc/sysctl.conf

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

net.ipv4.ip_forward
kernel.printk
vm.overcommit_memory

Если таких файлов нет, скрипт просто проходит без существенного эффекта.

S03modules: загрузка модулей ядра

S03modules - один из ключевых скриптов для этого проекта. Он загружает модули из файлов /etc/modules-load.d/*.conf

Логика простая:

for f in /etc/modules-load.d/*.conf; do
    while read mod; do
        modprobe "$mod" 2>/dev/null || true
    done < "$f"
done

Пустые строки и комментарии игнорируются. Именно здесь удобно автоматически грузить out-of-tree драйвер i2c-master-axi.Например, можно создать файл:

cat > /etc/modules-load.d/i2c-master-axi.conf << 'EOF'
i2c-master-axi
EOF

После старта S03modules должен произойти такой путь:

  1. Загружается модуль i2c-master-axi

  2. Регистрируется платформенный драйвер

  3. Драйвер сопоставляется с compatible = “user,i2c-master-axi-1.0”

  4. Функция probe отображает в память регистровую область по адресу 0x43c00000

  5. Драйвер регистрирует i2c_adapter

  6. Linux видит дочерний узел oled@3c

  7. Драйвер ssd1307fb выполняет probe для OLED-дисплея

  8. Появляется устройство /dev/fb0

Проверки:

lsmod
dmesg | grep -iE 'i2c|axi|ssd|fb'
ls /dev/i2c-*
ls /dev/fb*
ls /sys/bus/i2c/devices/

Если i2c-master-axi собран не модулем, а встроен в ядро, S03modules для него не нужен. Тогда драйвер должен привязаться еще на этапе инициализации ядра. Но для out-of-tree разработки модульный вариант удобнее: можно менять драйвер, копировать .ko на плату и перезагружать только модуль.

S39set-eth0-mac: MAC-адрес до поднятия сети

S39set-eth0-mac запускается перед S40network. Это правильный порядок. Скрипт делает следующее:

[ -d /sys/class/net/eth0 ] || exit 0
  
MAC=$(grep ... /etc/eth0-mac | head -1)
[ -n "$MAC" ] || MAC="00:0a:35:01:02:03"
  
ip link set dev eth0 down
ip link set dev eth0 address "$MAC"

ip link set dev eth0 up

То есть если файл /etc/eth0-mac существует и содержит валидный MAC-адрес, он будет применен. Если файла нет, будет использован fallback 00:0a:35:01:02:03

Это удобно для прототипа, но для реального устройства такой MAC нельзя оставлять одинаковым на всех платах. Для статьи лучше прямо указать, что это временный адрес для bring-up.

Файл /etc/eth0-mac должен содержать одну строку 02:00:00:00:00:01

Для локально назначенного адреса лучше использовать MAC с установленным local bit, например диапазон 02:....

Проверка:

cat /etc/eth0-mac
ip link show eth0
cat /sys/class/net/eth0/address

Если eth0 на этом этапе не существует, скрипт просто завершится. Это значит, что Ethernet-драйвер или PHY еще не поднялись.

S40network: ifup -a

S40network поднимает сеть через ifupdown: /sbin/ifup -a. Перед этим создается каталог: mkdir -p /run/network. Это нужно для lock-файлов ifupdown. 

Фактическая настройка сети лежит не в S40network, а в /etc/network/interfaces.

Пример DHCP-конфигурации:

auto lo
iface lo inet loopback
auto eth0
iface eth0 inet dhcp

Пример статической конфигурации:

auto lo
iface lo inet loopback
auto eth0
iface eth0 inet static
    address 192.168.2.145
    netmask 255.255.255.0
    gateway 192.168.2.1

Проверки:

ip link show eth0
ip addr show eth0
ip route
cat /etc/network/interfaces
dmesg | grep -iE 'macb|gem|phy|rtl|link'

Если S40network пишет FAIL, нужно отдельно смотреть:

ifup -v eth0
udhcpc -i eth0 -f -q

S45oled-console: включение OLED-консоли

S45oled-console - второй ключевой проектный скрипт после S03modules.

Он делает две вещи:

  1. Ждет появления /dev/fb0.

  2. Запускает /usr/local/bin/oled-console.

Фрагмент логики:

i=0
while [ ! -c /dev/fb0 ] && [ "$i" -lt 40 ]; do
    sleep 0.25
    i=$((i + 1))
done

[ -x /usr/local/bin/oled-console ] && /usr/local/bin/oled-console

Итого ожидание длится до 10 секунд: 40 * 0.25 s = 10 s

Это правильно, потому что /dev/fb0 появляется не сразу после старта userspace. Он появляется только после того, как:

  1. i2c-master-axi зарегистрировал I2C-шину

  2. ssd1307fb привязался к oled@3c

  3. framebuffer subsystem создал fb0

  4. devtmpfs создал /dev/fb0

Если /dev/fb0 появился, вызывается oled-console. Этот скрипт:

  • останавливает oled-clock

  • привязывает fbcon обратно к /dev/fb0

  • запускает getty на tty1

То есть OLED начинает работать как маленькая Linux-консоль. Проверки:

ls -l /dev/fb0
cat /sys/class/graphics/fb0/name 2>/dev/null
dmesg | grep -iE 'ssd|fb|framebuffer|console'
cat /sys/class/vtconsole/vtcon1/bind
ps | grep '[g]etty.*tty1'

Если нужно переключиться обратно в режим часов (создадим позже), используется другой userspace-скрипт:

/usr/local/bin/oled-clock-start

Он останавливает getty на tty1, отвязывает fbcon от framebuffer и запускает демон oled-clock.

S50dropbear: SSH после сети

S50dropbear запускает SSH-сервер Dropbear. Он стоит после S40network, что логично: SSH имеет смысл запускать после настройки интерфейса eth0.

Скрипт читает опции из /etc/default/dropbear и запускает /usr/sbin/dropbear

Ключевая строка: DROPBEAR_ARGS="$DROPBEAR_ARGS -R"

Опция -R означает, что Dropbear может сгенерировать host keys при старте, если они отсутствуют. Поэтому здесь снова важен ранний S01seedrng.

Проверки:

ps | grep dropbear
netstat -ltnp 2>/dev/null | grep ':22'
ip addr show eth0

Что стоит улучшить

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

  1. Добавить логирование в S03modules. Сейчас ошибки modprobe подавляются modprobe "$mod" 2>/dev/null true и для bring-up лучше хотя бы временно писать результат: echo “Loading module: $mod; echo "Failed to load module: $mod"
    Иначе можно не заметить, что i2c-master-axi вообще не загрузился.

  2. В S45oled-console проверять результат ожидания /dev/fb0. Сейчас после ожидания скрипт все равно пытается вызвать oled-console, если файл исполняемый. Лучше явно диагностировать timeout:
    if [ ! -c /dev/fb0 ];
    then   
      echo "S45oled-console: /dev/fb0 not found"   
      exit 0
    fi

  3. Развести режимы OLED console и OLED clock. Ниже я покажу второй режим. Первый console mode: fbcon + getty on tty1, а второй status display mode: oled-clock writes directly to /dev/fb0. Они конфликтуют, потому что оба пишут в один framebuffer. Поэтому перед запуском oled-clock нужно отвязать fbcon, а перед возвратом к консоли - остановить oled-clock и снова привязать fbcon.

  4. Не полагаться на фиксированный eth0, если интерфейсов станет больше. Для текущей платы eth0 допустим. Но если появится USB Ethernet или второй MAC, лучше будет привязываться к MACB/GEM интерфейсу более явно.

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

Разбираемся с userspace-утилитами

После того как Linux загрузился, модуль i2c-master-axi зарегистрировал I2C-адаптер, а драйвер ssd1307fb создал /dev/fb0, можно переходить к последнему уровню проекта - userspace-приложению которое я упоминал выше - приложение для вывода нужной нам информации на экран. Назвал я его oled_clock.

На этом уровне уже не нужно вручную дергать регистры AXI IP, отправлять I2C-команды или знать внутреннюю последовательность инициализации SSD1306. 

Приложение будет работать с обычным framebuffer-устройством:

Это и есть смысл всей предыдущей работы: кастомный контроллер в ПЛИС становится частью стандартной Linux-модели. Пользовательское приложение пишет картинку в /dev/fb0, а ядро само превращает обновление framebuffer в I2C-транзакции к адресу 0x3c.

Для проверки и эксплуатации OLED в проекте удобно иметь несколько утилит:

  • oled-clock - Основная C-утилита: тестовые паттерны, информация о framebuffer, часы, uptime, температура, напряжения

  • fb_test.sh - Простой shell/python тест, который пишет сырые 1024 байта в /dev/fb0

  • oled-clock-start - Переключение OLED в режим кастомной картинки или часов

  • oled-console - Возврат OLED в режим Linux console через fbcon и getty

  • deploy.sh - Удобная обертка на хосте: скомпилить утилиту и залить бинарник по SSH, запустить тест, переключить режим.

Общая архитектура userspace-вывода

OLED SSD1306 128x64 в режиме monochrome framebuffer дает небольшой буфер: 128 * 64 / 8 = 1024 bytes. То есть один полный кадр занимает всего 1024 байта. Это удобно для отладки: можно генерировать весь кадр в userspace и целиком отправлять его в /dev/fb0.

Общая схема работы oled-clock такая:

Ключевой момент: oled-clock не вызывает i2c_transfer() напрямую. Он работает с /dev/fb0. Это упрощает userspace-код и сохраняет стандартную модель Linux: графика отдельно, I2C-контроллер отдельно, OLED-драйвер отдельно.

Предварительные проверки перед запуском userspace

Перед тем как запускать oled-clock, нужно убедиться, что низшие уровни уже работают.

Проверяем, что загружен модуль:

lsmod | grep i2c
dmesg | grep -i i2c-master

Ожидаемая строка:

i2c-master-axi 43c00000.i2c: input=50000000 Hz, bus=100000 Hz, prescale=124

Проверяем I2C-адаптер:

ls /sys/class/i2c-adapter/

for d in /sys/class/i2c-adapter/i2c-*; do
    echo "$d"
    cat "$d/name" 2>/dev/null || true
done

Ожидаем адаптер с именем, связанным с нашим platform-устройством 43c00000.i2c

Проверяем OLED-клиент:

ls /sys/bus/i2c/devices/

Типичный вариант:

0-003c  i2c-0

Номер шины может отличаться. Важно не число 0 или 1, а наличие клиента 003c.

Проверяем framebuffer:

ls -l /dev/fb0
cat /sys/class/graphics/fb0/name 2>/dev/null || true

Если /dev/fb0 отсутствует, userspace-утилиты для framebuffer запускать рано. Сначала нужно смотреть DTS, ssd1307fb, i2c-master-axi, питание OLED и XDC.

Минимальный сырой тест через /dev/fb0

Самый простой тест - записать в framebuffer ровно 1024 байта. Например, погасить экран (если на плату затащили python):

python3 -c "import sys; sys.stdout.buffer.write(b'\x00' * 1024)" > /dev/fb0

Или залить экран:

python3 -c "import sys; sys.stdout.buffer.write(b'\xff' * 1024)" > /dev/fb0

Но такой тест не всегда надежен. Драйвер ssd1307fb может использовать deferred I/O, то есть не каждый одиночный короткий write() немедленно уходит на I2C. Поэтому для диагностики лучше использовать отдельную утилиту, которая:

  • читает параметры framebuffer через ioctl;

  • учитывает line_length;

  • формирует полный кадр;

  • пишет весь кадр целиком;

  • при необходимости использует mmap;

  • может дублировать write() после mmap, чтобы гарантировать обновление.

Для быстрых ручных тестов можно использовать fb_test.sh.

fb_test.sh: быстрые паттерны без C-кода

fb_test.sh - это небольшой shell-скрипт, который через Python генерирует 1024 байта и отправляет их прямо в /dev/fb0.

Код скрипта:

#!/bin/sh
# Test framebuffer bit-order/orientation on SSD1306 via /dev/fb0.
# Usage: fb_test.sh A|B|C|D
set -eu
killall -q oled-clock 2>/dev/null || true
sleep 0.3

case "${1:-A}" in
A)
  # only bit 0 of byte 0 (LSB-first => pixel (0,0); MSB-first => pixel (7,0))
  python3 -c "import sys; b=bytearray(1024); b[0]=0x01; sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "A: byte[0]=0x01"
  ;;
B)
  # only bit 7 of byte 0 (MSB)
  python3 -c "import sys; b=bytearray(1024); b[0]=0x80; sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "B: byte[0]=0x80"
  ;;
C)
  # whole first row lit (row-major)  =>  thin horizontal line on top (y=0)
  python3 -c "import sys; b=bytearray(1024); 
[setattr(b, '__setitem__', None) for _ in []]
for i in range(16): b[i]=0xFF
sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "C: row 0 all white"
  ;;
D)
  # whole first column lit (column 0 => bit 0 of every row's byte 0)
  python3 -c "import sys; b=bytearray(1024)
for y in range(64): b[y*16]=0x01
sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "D: col 0 LSB on each row"
  ;;
E)
  # whole first column lit assuming MSB-first => bit7 of every row's byte 0
  python3 -c "import sys; b=bytearray(1024)
for y in range(64): b[y*16]=0x80
sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "E: col 0 MSB on each row"
  ;;
F)
  # fill: every other row white  => stripes
  python3 -c "import sys; b=bytearray(1024)
for y in range(64):
  v = 0xFF if (y%2)==0 else 0x00
  for x in range(16): b[y*16+x]=v
sys.stdout.buffer.write(bytes(b))" > /dev/fb0
  echo "F: horizontal stripes 1px"
  ;;
G)
  # solid white
  python3 -c "import sys; sys.stdout.buffer.write(b'\\xFF'*1024)" > /dev/fb0
  echo "G: all white"
  ;;
Z)
  python3 -c "import sys; sys.stdout.buffer.write(b'\\x00'*1024)" > /dev/fb0
  echo "Z: all black"
  ;;
*)
  echo "unknown pattern $1"; exit 1
  ;;
esac

Типовые паттерны:

Паттерн

Что делает

Что проверяет

Z

Все байты 0x00

Экран гаснет

G

Все байты 0xff

Экран заливается

A

byte[0] = 0x01

LSB-first, точка в начале буфера

B

byte[0] = 0x80

MSB-first, другой бит первого байта

C

Первые 16 байт 0xff

Первая строка или первая область кадра

D

Первый бит в каждой строке

Вертикальная линия

E

Старший бит в каждой строке

Проверка порядка битов

F

Полосы через строку

Проверка ориентации и stride

Пример запуска:

killall -q oled-clock 2>/dev/null || true

echo 0 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true

sh /usr/local/bin/fb_test.sh Z
sleep 1
sh /usr/local/bin/fb_test.sh G
sleep 1
sh /usr/local/bin/fb_test.sh A
sleep 1
sh /usr/local/bin/fb_test.sh F

Если A и B показывают точку не там, где ожидается, проблема может быть в порядке битов при упаковке кадра или в параметрах solomon,* в Device Tree.

Для Buildroot-образа нужно учитывать: fb_test.sh требует python3 на плате. Если Python в rootfs не включен, этот скрипт не подойдет. Тогда лучше использовать C-утилиту oled-clock.

oled-clock: основная userspace-утилита

oled-clock - это C-программа, которая умеет работать в нескольких режимах. Код программы следующий:

/*
 * oled-clock  -  live OLED status display for Zynq Mini Rev B
 *
 * Каждую секунду:
 *   1) читает PS XADC температуру  (/sys/bus/iio/devices/iio:device0/in_temp0_*)
 *   2) читает время/дату            (localtime_r)
 *   3) читает uptime + voltages
 *   4) растрит 128x64 1bpp кадр (шрифты 6x8 и 8x16 в формате ssd1306xled —
 *      «page-mode», т.е. байт описывает 8 вертикальных пикселей одной колонки)
 *   5) пишет 1024 байта в /dev/fb0 (ssd1307fb ждёт row-major LSB-first MONO10,
 *      driver сам преобразует в SSD1306 page format)
 *
 * Layout жёстко выровнен по 8-пиксельным страницам SSD1306, чтобы избежать
 * визуальных «провалов» на горизонтальных page-boundary:
 *   page 0  (y=0..7)   : header   6x8  "zynq mini rev. b"
 *   page 1-2(y=8..23)  : big time 8x16 "HH:MM:SS"
 *   page 3  (y=24..31) : date     6x8  "YYYY-MM-DD"
 *   page 4-5(y=32..47) : big T    8x16 "T=XX.X C"
 *   page 6  (y=48..55) : voltages 6x8  "Vi=X.XXV Va=X.XXV"
 *   page 7  (y=56..63) : uptime   6x8  "up HhMMmSSs"
 */

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/sysinfo.h>
#include <linux/fb.h>

#define W            128
#define H            64
#define FB_BYTES     (W * H / 8)        /* = 1024 */

#define XADC_TEMP_RAW    "/sys/bus/iio/devices/iio:device0/in_temp0_raw"
#define XADC_TEMP_OFF    "/sys/bus/iio/devices/iio:device0/in_temp0_offset"
#define XADC_TEMP_SCALE  "/sys/bus/iio/devices/iio:device0/in_temp0_scale"
#define XADC_VINT_RAW    "/sys/bus/iio/devices/iio:device0/in_voltage3_vccpint_raw"
#define XADC_VINT_SCALE  "/sys/bus/iio/devices/iio:device0/in_voltage3_vccpint_scale"
#define XADC_VAUX_RAW    "/sys/bus/iio/devices/iio:device0/in_voltage4_vccpaux_raw"
#define XADC_VAUX_SCALE  "/sys/bus/iio/devices/iio:device0/in_voltage4_vccpaux_scale"

/* -------------------------------------------------------------------------
 * Стандартный 6x8 ssd1306xled-шрифт.
 * Формат: 6 байт на символ; каждый байт описывает одну колонку (8 пикселей
 * по вертикали), bit 0 = верхний пиксель, bit 7 = нижний.
 * ------------------------------------------------------------------------- */
static const uint8_t FONT6x8[][6] = {
    {0x00,0x00,0x00,0x00,0x00,0x00}, /* sp */
    {0x00,0x00,0x00,0x2F,0x00,0x00}, /* !  */
    {0x00,0x00,0x07,0x00,0x07,0x00}, /* "  */
    {0x00,0x14,0x7F,0x14,0x7F,0x14}, /* #  */
    {0x00,0x24,0x2A,0x7F,0x2A,0x12}, /* $  */
    {0x00,0x23,0x13,0x08,0x64,0x62}, /* %  */
    {0x00,0x36,0x49,0x55,0x22,0x50}, /* &  */
    {0x00,0x00,0x05,0x03,0x00,0x00}, /* '  */
    {0x00,0x00,0x1C,0x22,0x41,0x00}, /* (  */
    {0x00,0x00,0x41,0x22,0x1C,0x00}, /* )  */
    {0x00,0x14,0x08,0x3E,0x08,0x14}, /* *  */
    {0x00,0x08,0x08,0x3E,0x08,0x08}, /* +  */
    {0x00,0x00,0x00,0xA0,0x60,0x00}, /* ,  */
    {0x00,0x08,0x08,0x08,0x08,0x08}, /* -  */
    {0x00,0x00,0x60,0x60,0x00,0x00}, /* .  */
    {0x00,0x20,0x10,0x08,0x04,0x02}, /* /  */
    {0x00,0x3E,0x51,0x49,0x45,0x3E}, /* 0  */
    {0x00,0x00,0x42,0x7F,0x40,0x00}, /* 1  */
    {0x00,0x42,0x61,0x51,0x49,0x46}, /* 2  */
    {0x00,0x21,0x41,0x45,0x4B,0x31}, /* 3  */
    {0x00,0x18,0x14,0x12,0x7F,0x10}, /* 4  */
    {0x00,0x27,0x45,0x45,0x45,0x39}, /* 5  */
    {0x00,0x3C,0x4A,0x49,0x49,0x30}, /* 6  */
    {0x00,0x01,0x71,0x09,0x05,0x03}, /* 7  */
    {0x00,0x36,0x49,0x49,0x49,0x36}, /* 8  */
    {0x00,0x06,0x49,0x49,0x29,0x1E}, /* 9  */
    {0x00,0x00,0x36,0x36,0x00,0x00}, /* :  */
    {0x00,0x00,0x56,0x36,0x00,0x00}, /* ;  */
    {0x00,0x08,0x14,0x22,0x41,0x00}, /* <  */
    {0x00,0x14,0x14,0x14,0x14,0x14}, /* =  */
    {0x00,0x00,0x41,0x22,0x14,0x08}, /* >  */
    {0x00,0x02,0x01,0x51,0x09,0x06}, /* ?  */
    {0x00,0x32,0x49,0x59,0x51,0x3E}, /* @  */
    {0x00,0x7C,0x12,0x11,0x12,0x7C}, /* A  */
    {0x00,0x7F,0x49,0x49,0x49,0x36}, /* B  */
    {0x00,0x3E,0x41,0x41,0x41,0x22}, /* C  */
    {0x00,0x7F,0x41,0x41,0x22,0x1C}, /* D  */
    {0x00,0x7F,0x49,0x49,0x49,0x41}, /* E  */
    {0x00,0x7F,0x09,0x09,0x09,0x01}, /* F  */
    {0x00,0x3E,0x41,0x49,0x49,0x7A}, /* G  */
    {0x00,0x7F,0x08,0x08,0x08,0x7F}, /* H  */
    {0x00,0x00,0x41,0x7F,0x41,0x00}, /* I  */
    {0x00,0x20,0x40,0x41,0x3F,0x01}, /* J  */
    {0x00,0x7F,0x08,0x14,0x22,0x41}, /* K  */
    {0x00,0x7F,0x40,0x40,0x40,0x40}, /* L  */
    {0x00,0x7F,0x02,0x0C,0x02,0x7F}, /* M  */
    {0x00,0x7F,0x04,0x08,0x10,0x7F}, /* N  */
    {0x00,0x3E,0x41,0x41,0x41,0x3E}, /* O  */
    {0x00,0x7F,0x09,0x09,0x09,0x06}, /* P  */
    {0x00,0x3E,0x41,0x51,0x21,0x5E}, /* Q  */
    {0x00,0x7F,0x09,0x19,0x29,0x46}, /* R  */
    {0x00,0x46,0x49,0x49,0x49,0x31}, /* S  */
    {0x00,0x01,0x01,0x7F,0x01,0x01}, /* T  */
    {0x00,0x3F,0x40,0x40,0x40,0x3F}, /* U  */
    {0x00,0x1F,0x20,0x40,0x20,0x1F}, /* V  */
    {0x00,0x3F,0x40,0x38,0x40,0x3F}, /* W  */
    {0x00,0x63,0x14,0x08,0x14,0x63}, /* X  */
    {0x00,0x07,0x08,0x70,0x08,0x07}, /* Y  */
    {0x00,0x61,0x51,0x49,0x45,0x43}, /* Z  */
    {0x00,0x00,0x7F,0x41,0x41,0x00}, /* [  */
    {0x00,0x55,0x2A,0x55,0x2A,0x55}, /* \  */
    {0x00,0x00,0x41,0x41,0x7F,0x00}, /* ]  */
    {0x00,0x04,0x02,0x01,0x02,0x04}, /* ^  */
    {0x00,0x40,0x40,0x40,0x40,0x40}, /* _  */
    {0x00,0x00,0x01,0x02,0x04,0x00}, /* `  */
    {0x00,0x20,0x54,0x54,0x54,0x78}, /* a  */
    {0x00,0x7F,0x48,0x44,0x44,0x38}, /* b  */
    {0x00,0x38,0x44,0x44,0x44,0x20}, /* c  */
    {0x00,0x38,0x44,0x44,0x48,0x7F}, /* d  */
    {0x00,0x38,0x54,0x54,0x54,0x18}, /* e  */
    {0x00,0x08,0x7E,0x09,0x01,0x02}, /* f  */
    {0x00,0x18,0xA4,0xA4,0xA4,0x7C}, /* g  */
    {0x00,0x7F,0x08,0x04,0x04,0x78}, /* h  */
    {0x00,0x00,0x44,0x7D,0x40,0x00}, /* i  */
    {0x00,0x40,0x80,0x84,0x7D,0x00}, /* j  */
    {0x00,0x7F,0x10,0x28,0x44,0x00}, /* k  */
    {0x00,0x00,0x41,0x7F,0x40,0x00}, /* l  */
    {0x00,0x7C,0x04,0x18,0x04,0x78}, /* m  */
    {0x00,0x7C,0x08,0x04,0x04,0x78}, /* n  */
    {0x00,0x38,0x44,0x44,0x44,0x38}, /* o  */
    {0x00,0xFC,0x24,0x24,0x24,0x18}, /* p  */
    {0x00,0x18,0x24,0x24,0x18,0xFC}, /* q  */
    {0x00,0x7C,0x08,0x04,0x04,0x08}, /* r  */
    {0x00,0x48,0x54,0x54,0x54,0x20}, /* s  */
    {0x00,0x04,0x3F,0x44,0x40,0x20}, /* t  */
    {0x00,0x3C,0x40,0x40,0x20,0x7C}, /* u  */
    {0x00,0x1C,0x20,0x40,0x20,0x1C}, /* v  */
    {0x00,0x3C,0x40,0x30,0x40,0x3C}, /* w  */
    {0x00,0x44,0x28,0x10,0x28,0x44}, /* x  */
    {0x00,0x1C,0xA0,0xA0,0xA0,0x7C}, /* y  */
    {0x00,0x44,0x64,0x54,0x4C,0x44}, /* z  */
};
#define FONT6x8_FIRST  0x20  /* ' ' */
#define FONT6x8_LAST   0x7A  /* 'z' */

/* -------------------------------------------------------------------------
 * Стандартный 8x16 ssd1306xled-шрифт (старый/правый half stacked):
 *   bytes  0..7  — top page (y =  0..7)  для каждой из 8 колонок
 *   bytes  8..15 — bot page (y =  8..15) для каждой из 8 колонок
 * Каждый байт — 8 вертикальных пикселей; bit 0 = top.
 * Здесь храним только символы '0'..'9', ':', '.' и '-' — достаточно для
 * времени и температурной строки (включая дробную часть и отрицательные
 * значения).
 * ------------------------------------------------------------------------- */
static const uint8_t FONT8x16[][16] = {
    /* '0' */ {0x00,0xE0,0x10,0x08,0x08,0x10,0xE0,0x00, 0x00,0x0F,0x10,0x20,0x20,0x10,0x0F,0x00},
    /* '1' */ {0x00,0x10,0x10,0xF8,0x00,0x00,0x00,0x00, 0x00,0x20,0x20,0x3F,0x20,0x20,0x00,0x00},
    /* '2' */ {0x00,0x70,0x08,0x08,0x08,0x88,0x70,0x00, 0x00,0x30,0x28,0x24,0x22,0x21,0x30,0x00},
    /* '3' */ {0x00,0x30,0x08,0x88,0x88,0x48,0x30,0x00, 0x00,0x18,0x20,0x20,0x20,0x11,0x0E,0x00},
    /* '4' */ {0x00,0x00,0xC0,0x20,0x10,0xF8,0x00,0x00, 0x00,0x07,0x04,0x24,0x24,0x3F,0x24,0x00},
    /* '5' */ {0x00,0xF8,0x08,0x88,0x88,0x08,0x08,0x00, 0x00,0x19,0x21,0x20,0x20,0x11,0x0E,0x00},
    /* '6' */ {0x00,0xE0,0x10,0x88,0x88,0x18,0x00,0x00, 0x00,0x0F,0x11,0x20,0x20,0x11,0x0E,0x00},
    /* '7' */ {0x00,0x38,0x08,0x08,0xC8,0x38,0x08,0x00, 0x00,0x00,0x00,0x3F,0x00,0x00,0x00,0x00},
    /* '8' */ {0x00,0x70,0x88,0x08,0x08,0x88,0x70,0x00, 0x00,0x1C,0x22,0x21,0x21,0x22,0x1C,0x00},
    /* '9' */ {0x00,0xE0,0x10,0x08,0x08,0x10,0xE0,0x00, 0x00,0x00,0x31,0x22,0x22,0x11,0x0F,0x00},
    /* ':' */ {0x00,0x00,0x00,0xC0,0xC0,0x00,0x00,0x00, 0x00,0x00,0x00,0x30,0x30,0x00,0x00,0x00},
    /* '.' */ {0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x30,0x30,0x00,0x00,0x00},
    /* '-' */ {0x00,0x00,0x80,0x80,0x80,0x80,0x80,0x00, 0x00,0x00,0x01,0x01,0x01,0x01,0x01,0x00},
};
/* отображение '0'..'9'->[0..9], ':'->10, '.'->11, '-'->12.
 * Возвращает индекс или -1 если символ не поддержан. */
static int idx_8x16(char c)
{
    if (c >= '0' && c <= '9') return c - '0';
    if (c == ':')             return 10;
    if (c == '.')             return 11;
    if (c == '-')             return 12;
    return -1;
}

/* -------------------------------------------------------------------------
 * Framebuffer: row-major bool grid 64x128, пакуется в 1024 байта LSB-first.
 * ------------------------------------------------------------------------- */
static uint8_t pix[H][W];

static void clear_fb(void) { memset(pix, 0, sizeof pix); }

static inline void set_px(int x, int y)
{
    if ((unsigned)x < W && (unsigned)y < H) pix[y][x] = 1;
}

static void hline(int y, int x0, int x1) __attribute__((unused));
static void hline(int y, int x0, int x1)
{
    for (int x = x0; x <= x1; x++) set_px(x, y);
}

/* Рисуем 6x8 символ в (x, y).  y ДОЛЖЕН быть кратен 8, иначе появится
 * page-split (буква пересечёт границу страниц SSD1306). */
static void draw_6x8(char c, int x, int y)
{
    unsigned uc = (unsigned char)c;
    if (uc < FONT6x8_FIRST || uc > FONT6x8_LAST) uc = '?';
    const uint8_t *gl = FONT6x8[uc - FONT6x8_FIRST];
    for (int col = 0; col < 6; col++) {
        uint8_t byte = gl[col];
        for (int row = 0; row < 8; row++)
            if (byte & (1u << row))
                set_px(x + col, y + row);
    }
}

static void text_6x8(const char *s, int x, int y)
{
    for (; *s; s++) {
        draw_6x8(*s, x, y);
        x += 6;
    }
}

static int width_6x8(const char *s) { return (int)strlen(s) * 6; }

static void center_6x8(const char *s, int y)
{
    text_6x8(s, (W - width_6x8(s)) / 2, y);
}

/* Рисуем 8x16 символ в (x, y).  y ДОЛЖЕН быть кратен 8 (идеально кратен 16),
 * иначе появится разрыв между верхней и нижней половиной глифа. */
static void draw_8x16(char c, int x, int y)
{
    int i = idx_8x16(c);
    if (i < 0) {
        /* для пробела/неизвестных просто оставим пустое место */
        return;
    }
    const uint8_t *gl = FONT8x16[i];
    /* top page */
    for (int col = 0; col < 8; col++) {
        uint8_t byte = gl[col];
        for (int row = 0; row < 8; row++)
            if (byte & (1u << row))
                set_px(x + col, y + row);
    }
    /* bottom page */
    for (int col = 0; col < 8; col++) {
        uint8_t byte = gl[8 + col];
        for (int row = 0; row < 8; row++)
            if (byte & (1u << row))
                set_px(x + col, y + 8 + row);
    }
}

static void text_8x16(const char *s, int x, int y)
{
    for (; *s; s++) {
        draw_8x16(*s, x, y);
        x += 8;
    }
}

static int width_8x16(const char *s) { return (int)strlen(s) * 8; }

static void center_8x16(const char *s, int y)
{
    text_8x16(s, (W - width_8x16(s)) / 2, y);
}

/* -------------------------------------------------------------------------
 * Сборка кадра /dev/fb0.
 *
 * ssd1307fb (mainline) хранит screen_buffer как row-major mono с LSB-first
 * упаковкой битов внутри байта:
 *   pixel(x, y) = (vmem[y * line_length + x/8] >> (x % 8)) & 1
 *
 * Драйвер использует fb_deferred_io, поэтому write()-only через char-устройство
 * НЕ всегда триггерит обновление контроллера: правильный способ — mmap()
 * страничного буфера + msync(MS_INVALIDATE), чтобы кадр поехал по I2C.
 * ------------------------------------------------------------------------- */
static uint8_t *g_fbmem = NULL;          /* указатель на mmap-region */
static size_t   g_fbsize = 0;            /* реальный line_length * yres */
static int      g_line_len = W / 8;      /* line_length из ядра */

static int fb_setup(int fd)
{
    struct fb_var_screeninfo v;
    struct fb_fix_screeninfo f;
    if (ioctl(fd, FBIOGET_VSCREENINFO, &v) == 0 &&
        ioctl(fd, FBIOGET_FSCREENINFO, &f) == 0) {
        g_line_len = (int)f.line_length;
        g_fbsize   = (size_t)f.line_length * v.yres_virtual;
        if (g_fbsize < (size_t)FB_BYTES) g_fbsize = FB_BYTES;
    } else {
        g_line_len = W / 8;
        g_fbsize   = FB_BYTES;
    }
    g_fbmem = mmap(NULL, g_fbsize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (g_fbmem == MAP_FAILED) {
        g_fbmem = NULL;
        return -1;
    }
    return 0;
}

/* Параметр: 0 = LSB-first within byte (стандарт ssd1307fb mainline),
 *           1 = MSB-first within byte (на случай других ядер/прошивок). */
static int g_bit_order_msb = 0;

/* Параметр: путь записи кадра.  ssd1307fb через defio регистрирует
 * fb_sys_write_with_damage — это самый надёжный способ прокинуть кадр в
 * контроллер.  mmap+msync формально тоже работает, но требует чтобы ядро
 * выловило write-fault — что не всегда устойчиво на 6.6.51 sysmem mappings. */
static void pack_buf(uint8_t *buf, int stride)
{
    memset(buf, 0, stride * H);
    for (int y = 0; y < H; y++) {
        uint8_t *row = buf + y * stride;
        for (int X = 0; X < W / 8; X++) {
            uint8_t byte = 0;
            if (g_bit_order_msb) {
                for (int b = 0; b < 8; b++)
                    if (pix[y][X * 8 + b]) byte |= (uint8_t)(0x80u >> b);
            } else {
                for (int b = 0; b < 8; b++)
                    if (pix[y][X * 8 + b]) byte |= (uint8_t)(1u << b);
            }
            row[X] = byte;
        }
    }
}

static void flush_to_fb(int fd)
{
    uint8_t buf[FB_BYTES];
    int stride = (g_line_len > 0) ? g_line_len : (W / 8);
    pack_buf(buf, stride);

    /* mmap путь: пишем в страничный буфер и просим ядро инвалидировать. */
    if (g_fbmem) {
        memset(g_fbmem, 0, g_fbsize);
        memcpy(g_fbmem, buf, (size_t)stride * H);
        msync(g_fbmem, g_fbsize, MS_SYNC | MS_INVALIDATE);
    }

    /* Дублируем write(): defio_write гарантированно вызовет damage_range. */
    if (lseek(fd, 0, SEEK_SET) == (off_t)-1) return;
    ssize_t left = (ssize_t)stride * H, off = 0;
    while (left > 0) {
        ssize_t n = write(fd, buf + off, left);
        if (n <= 0) { if (errno == EINTR) continue; break; }
        left -= n; off += n;
    }
}

static void fill_test_pattern(const char *name)
{
    clear_fb();
    if (!strcmp(name, "Z")) {
        /* all black */
    } else if (!strcmp(name, "G")) {
        for (int y = 0; y < H; y++)
            for (int x = 0; x < W; x++) set_px(x, y);
    } else if (!strcmp(name, "A")) {
        set_px(0, 0);                       /* единичный pixel */
    } else if (!strcmp(name, "D")) {
        for (int y = 0; y < H; y++) set_px(0, y);   /* левая колонка */
    } else if (!strcmp(name, "F")) {
        for (int y = 0; y < H; y += 2)
            for (int x = 0; x < W; x++) set_px(x, y);
    } else if (!strcmp(name, "T")) {
        /* угловые маркеры + диагональ */
        for (int i = 0; i < 8; i++) { set_px(i, 0); set_px(0, i); }
        for (int i = 0; i < 8; i++) { set_px(W - 1 - i, 0); set_px(W - 1, i); }
        for (int i = 0; i < 8; i++) { set_px(i, H - 1); set_px(0, H - 1 - i); }
        for (int i = 0; i < 8; i++) { set_px(W - 1 - i, H - 1); set_px(W - 1, H - 1 - i); }
        for (int i = 0; i < 64; i++) set_px(i, i);
    } else if (!strcmp(name, "R")) {
        /* "ABC" слева сверху простым 6x8 */
        text_6x8("ABC", 0, 0);
    } else if (!strcmp(name, "H")) {
        /* "HELLO" по центру первой страницы */
        center_6x8("HELLO", 0);
    } else if (!strcmp(name, "L")) {
        /* L-маркер: гориз (0..7, 0) + верт (0, 0..7) + одиночка (16, 4) */
        for (int i = 0; i < 8; i++) set_px(i, 0);
        for (int i = 0; i < 8; i++) set_px(0, i);
        set_px(16, 4);
    } else if (!strcmp(name, "B")) {
        /* Рамка по всему экрану — 4 линии шириной 1px */
        for (int x = 0; x < W; x++) { set_px(x, 0); set_px(x, H - 1); }
        for (int y = 0; y < H; y++) { set_px(0, y); set_px(W - 1, y); }
    } else if (!strcmp(name, "8")) {
        /* Одна цифра '0' шрифтом 8x16 в углу (0,0) */
        text_8x16("0", 0, 0);
    } else if (!strcmp(name, "9")) {
        /* "00:00" 8x16 в углу (0,0) */
        text_8x16("00:00", 0, 0);
    } else if (!strcmp(name, "P")) {
        /* Диагональ из 8 одиночных точек: (0,0),(8,8),(16,16),...,(56,56) */
        for (int i = 0; i < 8; i++) set_px(i * 8, i * 8);
    } else if (!strcmp(name, "Q")) {
        /* Точки на всех пересечениях page-boundary y=0,8,16,...,56 */
        for (int y = 0; y < H; y += 8) set_px(0, y);
    } else {
        text_6x8("?pattern", 0, 0);
    }
}

/* -------------------------------------------------------------------------
 * sysfs helpers
 * ------------------------------------------------------------------------- */
static int read_int_file(const char *path)
{
    int fd = open(path, O_RDONLY);
    if (fd < 0) return 0;
    char b[32];
    ssize_t n = read(fd, b, sizeof b - 1);
    close(fd);
    if (n <= 0) return 0;
    b[n] = 0;
    return atoi(b);
}

static double read_double_file(const char *path)
{
    int fd = open(path, O_RDONLY);
    if (fd < 0) return 0.0;
    char b[64];
    ssize_t n = read(fd, b, sizeof b - 1);
    close(fd);
    if (n <= 0) return 0.0;
    b[n] = 0;
    return atof(b);
}

static int    g_temp_offset = 0;
static double g_temp_scale  = 0.0;
static double g_vint_scale  = 0.0;
static double g_vaux_scale  = 0.0;

static void sensors_init(void)
{
    g_temp_offset = read_int_file(XADC_TEMP_OFF);
    g_temp_scale  = read_double_file(XADC_TEMP_SCALE);
    g_vint_scale  = read_double_file(XADC_VINT_SCALE);
    g_vaux_scale  = read_double_file(XADC_VAUX_SCALE);
}

static double temp_celsius(void)
{
    int raw = read_int_file(XADC_TEMP_RAW);
    return (raw + g_temp_offset) * g_temp_scale / 1000.0;
}

static double vint_volts(void)
{
    return read_int_file(XADC_VINT_RAW) * g_vint_scale / 1000.0;
}

static double vaux_volts(void)
{
    return read_int_file(XADC_VAUX_RAW) * g_vaux_scale / 1000.0;
}

/* -------------------------------------------------------------------------
 * Отвязываем fbcon от /dev/fb0, чтобы kernel console не накладывался поверх.
 * ------------------------------------------------------------------------- */
static void detach_fbcon(void)
{
    for (int i = 0; i < 8; i++) {
        char path[64];
        snprintf(path, sizeof path, "/sys/class/vtconsole/vtcon%d/bind", i);
        int fd = open(path, O_WRONLY);
        if (fd < 0) continue;
        (void)write(fd, "0", 1);
        close(fd);
    }
}

static void print_fb_info(int fd)
{
    struct fb_var_screeninfo v;
    struct fb_fix_screeninfo f;
    if (ioctl(fd, FBIOGET_VSCREENINFO, &v) == 0) {
        fprintf(stderr,
            "var: xres=%u yres=%u xres_virtual=%u yres_virtual=%u bpp=%u "
            "red.length=%u grayscale=%u rotate=%u\n",
            v.xres, v.yres, v.xres_virtual, v.yres_virtual, v.bits_per_pixel,
            v.red.length, v.grayscale, v.rotate);
    }
    if (ioctl(fd, FBIOGET_FSCREENINFO, &f) == 0) {
        fprintf(stderr,
            "fix: id=%.16s type=%u visual=%u line_length=%u smem_len=%u\n",
            f.id, f.type, f.visual, f.line_length, f.smem_len);
    }
}

static void usage(const char *p)
{
    fprintf(stderr,
        "usage: %s [options]\n"
        "  --fb PATH       framebuffer device (default /dev/fb0)\n"
        "  --info          print FBIOGET_VSCREENINFO/FSCREENINFO and exit\n"
        "  --msb           pack pixels MSB-first within byte\n"
        "  --lsb           pack pixels LSB-first within byte (default)\n"
        "  --no-mmap       do not mmap, write() only\n"
        "  --no-detach     do not detach fbcon\n"
        "  --test NAME     draw single pattern and exit\n"
        "                  Z=black G=white A=pixel(0,0) D=left-col F=stripes\n"
        "                  T=corners+diag R=\"ABC\"6x8 H=HELLO6x8 L=L-corner\n"
        "                  B=border 8=\"0\"8x16 9=\"00:00\"8x16\n"
        "                  P=8 points on (i*8,i*8) Q=points at every y=0,8,...,56\n",
        p);
}

int main(int argc, char **argv)
{
    const char *fb_path = "/dev/fb0";
    const char *test = NULL;
    int do_info = 0, use_mmap = 1, detach = 1;

    for (int i = 1; i < argc; i++) {
        if (!strcmp(argv[i], "--fb") && i + 1 < argc) fb_path = argv[++i];
        else if (!strcmp(argv[i], "--info"))      do_info = 1;
        else if (!strcmp(argv[i], "--msb"))       g_bit_order_msb = 1;
        else if (!strcmp(argv[i], "--lsb"))       g_bit_order_msb = 0;
        else if (!strcmp(argv[i], "--no-mmap"))   use_mmap = 0;
        else if (!strcmp(argv[i], "--no-detach")) detach = 0;
        else if (!strcmp(argv[i], "--test") && i + 1 < argc) test = argv[++i];
        else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { usage(argv[0]); return 0; }
        else if (argv[i][0] != '-')               fb_path = argv[i];
        else { usage(argv[0]); return 2; }
    }

    int fb_fd = open(fb_path, O_RDWR);
    if (fb_fd < 0) {
        fprintf(stderr, "open(%s): %s\n", fb_path, strerror(errno));
        return 1;
    }

    if (do_info) { print_fb_info(fb_fd); close(fb_fd); return 0; }

    sensors_init();
    if (detach) detach_fbcon();
    if (use_mmap && fb_setup(fb_fd) != 0)
        fprintf(stderr, "mmap fallback: используем только write()\n");

    if (test) {
        print_fb_info(fb_fd);
        fill_test_pattern(test);
        flush_to_fb(fb_fd);
        fprintf(stderr, "pattern '%s' drawn (mmap=%d msb=%d)\n",
                test, g_fbmem ? 1 : 0, g_bit_order_msb);
        close(fb_fd);
        return 0;
    }

    while (1) {
        time_t now = time(NULL);
        struct tm tm; localtime_r(&now, &tm);
        struct sysinfo si; sysinfo(&si);

        clear_fb();
        char line[64];

        /* Page 0 (y=0..7): header — 6x8 */
        center_6x8("zynq mini rev. b", 0);

        /* Pages 1-2 (y=8..23): HH:MM:SS — 8x16, ровно две выровненных страницы */
        snprintf(line, sizeof line, "%02d:%02d:%02d",
                 tm.tm_hour, tm.tm_min, tm.tm_sec);
        center_8x16(line, 8);

        /* Page 3 (y=24..31): YYYY-MM-DD — 6x8 */
        snprintf(line, sizeof line, "%04d-%02d-%02d",
                 tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday);
        center_6x8(line, 24);

        /* Pages 4-5 (y=32..47): T=XX.X C — 8x16.  В 8x16 поддержаны цифры,
         * ':', '.' и '-', поэтому "%4.1f" (вкл. отрицательные значения и
         * дробную часть) рисуется целиком; подпись "T=" и "C" — 6x8. */
        if (g_temp_scale > 0.0) {
            double tC = temp_celsius();
            snprintf(line, sizeof line, "%4.1f", tC);
            /* Подпись слева 6x8, цифры по центру справа 8x16.
             * "%4.1f" даёт 4 символа для значений 0..99.9 ("XX.X" или " X.X")
             * и 5 символов для |T|>=100 — 5*8 = 40 px по ширине. */
            text_6x8("T=", 8, 36);
            text_8x16(line, 28, 32);
            text_6x8("C", 28 + 40 + 4, 36);
        } else {
            center_6x8("T = N/A", 36);
        }

        /* Page 6 (y=48..55): voltages — 6x8.
         * Шрифт 6x8 даёт 128/6 = 21 символ на строку, поэтому используем
         * укороченные подписи "Vi=" / "Va=", чтобы строка гарантированно
         * влезала и не уезжала за край при центрировании. */
        if (g_vint_scale > 0.0 && g_vaux_scale > 0.0) {
            snprintf(line, sizeof line, "Vi=%4.2fV Va=%4.2fV",
                     vint_volts(), vaux_volts());
            center_6x8(line, 48);
        } else {
            center_6x8("PS XADC", 48);
        }

        /* Page 7 (y=56..63): uptime — 6x8 */
        long s = (long)si.uptime;
        long h = s / 3600, m = (s % 3600) / 60, sec = s % 60;
        snprintf(line, sizeof line, "up %ldh%02ldm%02lds", h, m, sec);
        center_6x8(line, 56);

        flush_to_fb(fb_fd);

        /* Просыпаемся на границе следующей секунды — без дрейфа. */
        struct timespec ts;
        clock_gettime(CLOCK_REALTIME, &ts);
        struct timespec req = {
            .tv_sec  = 0,
            .tv_nsec = 1000000000L - ts.tv_nsec,
        };
        nanosleep(&req, NULL);
    }

    close(fb_fd);
    return 0;
}

Примерный набор режимов:

oled-clock --info
oled-clock --test Z
oled-clock --test A
oled-clock --test F
oled-clock --test T
oled-clock --test A --msb
oled-clock --test A --no-mmap
oled-clock

Режимы:

Режим

Назначение

--info

Показать параметры framebuffer через FBIOGET_VSCREENINFO и FBIOGET_FSCREENINFO

--test <NAME>

Нарисовать один тестовый паттерн

--msb

Упаковывать биты в байте в обратном порядке

--no-mmap

Не использовать mmap, писать через write()

без аргументов

Запустить демон часов и системной информации

Режим --info: проверяем framebuffer

Первый запуск лучше делать с --info: /usr/local/bin/oled-clock --info

Ожидаемый вывод концептуально такой:

var: xres=128 yres=64 ... bpp=1 ... grayscale=1
fix: id=SSD1307 ... line_length=16 smem_len=1024

Поля, которые важно проверить:

Поле

Ожидание

Что означает отклонение

xres

128

Неверная геометрия в DTS или не тот framebuffer

yres

64

Неверная геометрия в DTS

bits_per_pixel

1

Ожидается monochrome framebuffer

line_length

16

128 пикселей / 8 = 16 байт на строку

smem_len

1024

128 * 64 / 8

id

SSD1307 или похожее

Привязался драйвер ssd1307fb

Если --info не может открыть /dev/fb0, надо вернуться к проверкам ядра. Если /dev/fb0 есть, но геометрия не 128x64, значит проблема не в userspace, а в DTS или в выбранном framebuffer.

Тестовые паттерны oled-clock

После --info проверяем картинку:

/usr/local/bin/oled-clock --test Z

sleep 1

/usr/local/bin/oled-clock --test G

sleep 1

/usr/local/bin/oled-clock --test A

sleep 1

/usr/local/bin/oled-clock --test F

sleep 1

/usr/local/bin/oled-clock --test T

Рекомендуемый набор паттернов:

Паттерн

Смысл

Z

Очистить экран

G

Полная заливка

A

Один пиксель или один бит в начале буфера

D

Вертикальная линия

F

Горизонтальные полосы

T

Углы и диагональ

R

Короткий текст

H

Центрированная строка

B

Рамка по периметру

8

Крупная цифра шрифтом 8x16

9

Строка времени

P

Диагональные точки

Q

Точки по page-boundary SSD1306

Если паттерн есть в памяти, но экран не обновляется, нужно проверить конфликт с fbcon:

cat /sys/class/vtconsole/vtcon1/bind 2>/dev/null

Если там 1, консоль ядра может перерисовывать экран поверх пользовательского кадра. Для режима тестов лучше отвязать fbcon:

echo 0 > /sys/class/vtconsole/vtcon1/bind

mmap или write

Для /dev/fb0 есть два практических способа записи:

Способ

Плюсы

Минусы

mmap

Быстрый доступ к framebuffer-памяти

Нужно правильно учитывать line_length и размер

write

Простой и предсказуемый путь

Может быть медленнее

mmap + write fallback

Наиболее надежно для отладки

Дублирует работу

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

/usr/local/bin/oled-clock --test A --no-mmap

Если через --no-mmap картинка появляется стабильнее, значит проблема может быть в deferred I/O, msync, размере mmap-области или конфликте с fbcon.

Демон часов и системной информации

Без --test программа работает как демон: раз в секунду собирает данные, рисует кадр и отправляет его в /dev/fb0.

Пример запуска:

killall -q oled-clock 2>/dev/null
echo 0 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true
nohup /usr/local/bin/oled-clock >/tmp/oled.log 2>&1 &
sleep 1
pgrep -a oled-clock
tail -20 /tmp/oled.log

Типовой набор данных для вывода:

Строка на OLED

Источник в Linux

Время

localtime_r()

Дата

localtime_r()

Uptime

sysinfo()

Температура кристалла

XADC через /sys/bus/iio/devices/iio:device*/in_temp0_*

Напряжение VCCPINT

in_voltage*_vccpint_*

Напряжение VCCPAUX

in_voltage*_vccpaux_*

Hostname

/proc/sys/kernel/hostname

IP-адрес

ioctl(SIOCGIFADDR) или парсинг /proc/net/fib_trie

Load average

/proc/loadavg

Частота обновления

внутренний таймер программы

Минимальный макет экрана:

Для 128x64 лучше не пытаться выводить слишком много. На таком дисплее полезнее 4-6 коротких строк, чем полная системная сводка мелким шрифтом.

Температура кристалла через XADC

На Zynq температуру PS/кристалла обычно можно получить через IIO/XADC. На разных ядрах и DTS путь может отличаться, поэтому утилита должна не жестко открывать один файл, а искать подходящее устройство.

Типовая проверка на плате find /sys/bus/iio/devices -type f | grep -E 'temp|voltage|scale|offset'

Возможные файлы:

/sys/bus/iio/devices/iio:device0/in_temp0_raw
/sys/bus/iio/devices/iio:device0/in_temp0_scale
/sys/bus/iio/devices/iio:device0/in_temp0_offset

Расчет обычно имеет вид temperature = (raw + offset) * scale / 1000

Но конкретные единицы лучше проверить по sysfs-атрибутам и драйверу XADC. Для статьи достаточно заложить безопасную логику: если XADC найден - вывести температуру, иначе вывести T=N/A

Отсутствие температуры на OLED означает, что нужен XADC в DTS и соответствующий драйвер в ядре.

Cross-компиляция oled-clock

Userspace-утилиту нужно собирать тем же toolchain, которым Buildroot собрал rootfs. Это снижает риск несовместимости libc и ABI.

Задаем пути:

export WORK=~/devel/xilinx/projects/zynq_mini_oled/linux/
export BR_OUT="$WORK/br-output"
export PATH="$BR_OUT/host/bin:$PATH"

Находим toolchain:

ls "$BR_OUT/host/bin" | grep -E 'arm.*gcc$'

Для Buildroot glibc ARM hard-float имя обычно похоже на arm-buildroot-linux-gnueabi-gcc

Создадим директорию и туда положим файл oled-clock.c:

mkdir -p /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux/userspace/oled-clock/

Сборка вручную:

arm-buildroot-linux-gnueabi-gcc \
    -O2 -Wall -Wextra \
    -o oled-clock oled-clock.c

Проверка бинарника на хосте:

# file oled-clock
ELF 32-bit LSB executable, ARM, EABI5, dynamically linked

Если rootfs собран на glibc, динамически связанный бинарник подходит. Если rootfs собран на musl или uClibc, нужно использовать именно toolchain этого Buildroot output.

Вариант через Makefile:

CC ?= gcc
CFLAGS ?= -O2 -Wall -Wextra
LDFLAGS ?=
  
all: oled-clock
oled-clock: oled-clock.c

$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)

clean:
rm -f oled-clock

Сборка:

make clean
make CC="$BR_OUT/host/bin/arm-buildroot-linux-gnueabi-gcc"

Или через префикс если Makefile поддерживает CROSS_COMPILE:

make CROSS_COMPILE="$BR_OUT/host/bin/arm-buildroot-linux-gnueabi-"

Доставка бинарника на плату

Если сеть и SSH уже работают:

scp oled-clock root@192.168.2.142:/usr/local/bin/oled-clock
ssh root@192.168.2.142 'chmod +x /usr/local/bin/oled-clock'

Если scp не работает из-за отсутствия sftp-server, можно использовать поток через ssh:

cat oled-clock | ssh root@192.168.2.145 \
    'cat > /usr/local/bin/oled-clock && chmod +x /usr/local/bin/oled-clock'

Если сети нет, варианты:

  1. Положить бинарник на FAT-раздел SD.

  2. Добавить его в rootfs_overlay.

  3. Добавить userspace-пакет в Buildroot.

  4. Временно скопировать через UART, если есть подходящие инструменты, но это неудобно.

Для быстрой отладки удобно использовать deploy.sh.

deploy.sh: быстрый цикл “собрал - залил - проверил”

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

#!/bin/bash
# deploy.sh — заливает свежий oled-clock на плату и помогает гонять диагностики.
#
# Запускать с твоего хоста (там, где есть sshpass, ip связь с платой):
#   bash linux/userspace/oled-clock/deploy.sh                    # залить и запустить (LSB+mmap)
#   bash linux/userspace/oled-clock/deploy.sh stop               # остановить демон
#   bash linux/userspace/oled-clock/deploy.sh info               # FBIOGET_*SCREENINFO
#   bash linux/userspace/oled-clock/deploy.sh test A             # один паттерн (Z,G,A,D,F,T,R,H,L,B,8,9,P,Q)
#   bash linux/userspace/oled-clock/deploy.sh test A --msb       # тот же паттерн, MSB-first
#   bash linux/userspace/oled-clock/deploy.sh test A --no-mmap   # тот же, только write()
#   bash linux/userspace/oled-clock/deploy.sh run --msb          # запустить часы с MSB-форматом
#   bash linux/userspace/oled-clock/deploy.sh run --no-mmap      # запустить только через write()
#   bash linux/userspace/oled-clock/deploy.sh console            # OLED в режим Linux console + getty (USB-клавиатура)
#   bash linux/userspace/oled-clock/deploy.sh clock              # OLED в режим часов (по умолчанию)
#   bash linux/userspace/oled-clock/deploy.sh shell '<cmd>'      # выполнить <cmd> на плате
set -eu

HOST=${OLED_HOST:-root@192.168.2.142}
PASS=${OLED_PASS:-root}
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
BIN="$SCRIPT_DIR/oled-clock"
SWITCH_CLK="$SCRIPT_DIR/oled-clock-start.sh"
SWITCH_CON="$SCRIPT_DIR/oled-console.sh"

ssh_cmd() {
  sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -o LogLevel=ERROR "$HOST" "$@"
}

copy_to_remote() {
  local src="$1" dst="$2"
  sshpass -p "$PASS" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
    -o LogLevel=ERROR "$HOST" "cat > '$dst' && chmod +x '$dst'" < "$src"
}

ensure_module_and_bin() {
  ssh_cmd 'mkdir -p /usr/local/bin && modprobe i2c-master-axi 2>/dev/null; \
           ls /dev/fb0 /dev/i2c-0 2>&1 | head'
  if [ "${SKIP_UPLOAD:-0}" != 1 ] && [ -x "$BIN" ]; then
    copy_to_remote "$BIN" /usr/local/bin/oled-clock
  fi
  # Скрипты-переключатели режимов (clock ↔ Linux-console на /dev/fb0).
  [ -r "$SWITCH_CLK" ] && copy_to_remote "$SWITCH_CLK" /usr/local/bin/oled-clock-start
  [ -r "$SWITCH_CON" ] && copy_to_remote "$SWITCH_CON" /usr/local/bin/oled-console
}

case "${1-deploy}" in
  stop)
    ssh_cmd 'killall -q oled-clock 2>/dev/null; sleep 0.3; pgrep -laf oled-clock || echo stopped'
    ;;
  info)
    ensure_module_and_bin
    ssh_cmd 'killall -q oled-clock 2>/dev/null; sleep 0.2; /usr/local/bin/oled-clock --info'
    ;;
  test)
    PATTERN=${2:-A}
    shift 2 2>/dev/null || shift 1
    ensure_module_and_bin
    ssh_cmd "killall -q oled-clock 2>/dev/null; sleep 0.2; /usr/local/bin/oled-clock --test $PATTERN $*"
    ;;
  run)
    shift
    ensure_module_and_bin
    # передаём дополнительные флаги в демона
    ssh_cmd "killall -q oled-clock 2>/dev/null; sleep 0.2; \
             nohup /usr/local/bin/oled-clock $* >/tmp/oled.log 2>&1 & \
             sleep 0.6; pgrep -laf oled-clock; cat /tmp/oled.log"
    ;;
  shell)
    shift
    ssh_cmd "$*"
    ;;
  console)
    ensure_module_and_bin
    ssh_cmd '/usr/local/bin/oled-console'
    ;;
  clock)
    ensure_module_and_bin
    ssh_cmd '/usr/local/bin/oled-clock-start'
    ;;
  deploy|"")
    ensure_module_and_bin
    ssh_cmd 'killall -q oled-clock 2>/dev/null; sleep 0.2; \
             nohup /usr/local/bin/oled-clock >/tmp/oled.log 2>&1 & \
             sleep 0.6; pgrep -laf oled-clock; cat /tmp/oled.log'
    ;;
  *)
    echo "unknown subcommand: $1"
    exit 1
    ;;
esac

deploy.sh работает на хосте и выполняет типовые действия по SSH:

export OLED_HOST=root@192.168.2.145
export OLED_PASS=root

bash deploy.sh info
bash deploy.sh test A
bash deploy.sh test F --msb
bash deploy.sh run
bash deploy.sh stop
bash deploy.sh console
bash deploy.sh clock
bash deploy.sh shell 'dmesg | grep -i i2c'

Назначение подкоманд:

Подкоманда

Что делает

info

Загружает бинарник и запускает oled-clock --info

test A

Запускает один тестовый паттерн

run

Запускает демон oled-clock

stop

Останавливает демон

console

Переключает OLED в режим Linux console

clock

Переключает OLED в режим часов

shell '<cmd>'

Выполняет произвольную команду на плате

deploy.sh полезен именно после того, как плата уже доступна по сети. На этапе первичного bring-up он не заменяет UART.

Переключение режимов: Linux console и кастомная картинка

OLED может использоваться в двух взаимоисключающих режимах.

Режим

Кто пишет в /dev/fb0

Что видно

Linux console

fbcon + getty tty1

login prompt, shell, kernel messages

Custom display

oled-clock или тестовая утилита

часы, температура, паттерны

Оба режима используют один и тот же framebuffer. Если одновременно оставить fbcon и запустить oled-clock, они будут перерисовывать экран друг поверх друга.

Переключение в режим кастомной картинки

Для передачи OLED под управление oled-clock нужно:

  1. Остановить getty на tty1, если он мешает.

  2. Отвязать fbcon от framebuffer.

  3. Запустить oled-clock.

Скрипт oled-clock-start:

cat > /usr/local/bin/oled-clock-start << 'EOF'
#!/bin/sh
VTCON=/sys/class/vtconsole/vtcon1/bind
LOG=/tmp/oled.log

ps | grep -v grep | grep -E '[g]etty[^/]*tty1' | awk '{print $1}' | xargs -r kill 2>/dev/null

sleep 0.2

if [ -w "$VTCON" ]; then
    echo 0 > "$VTCON"
fi

if ! pidof oled-clock >/dev/null 2>&1; then
    nohup /usr/local/bin/oled-clock >"$LOG" 2>&1 &
    sleep 0.5
fi

echo "OLED clock running (log: $LOG)"
pidof oled-clock | xargs -r echo "pid:"
EOF

Делаем файл исполняемым: 

chmod +x /usr/local/bin/oled-clock-start

Запуск:

/usr/local/bin/oled-clock-start

Проверка:

cat /sys/class/vtconsole/vtcon1/bind
pgrep -a oled-clock
tail -20 /tmp/oled.log

Ожидаем:

# /usr/local/bin/oled-clock-start
OLED clock running (log: /tmp/oled.log)
pid: 329

Переключение в режим Linux console на OLED

Для возврата к консоли нужно:

  1. Остановить oled-clock.

  2. Привязать fbcon обратно.

  3. Запустить getty на tty1.

Скрипт oled-console:

cat > /usr/local/bin/oled-console << 'EOF'
#!/bin/sh
VTCON=/sys/class/vtconsole/vtcon1/bind

killall -q oled-clock 2>/dev/null
sleep 0.2

if [ -w "$VTCON" ]; then
    echo 1 > "$VTCON"
else
    echo "oled-console: cannot write $VTCON" >&2
    exit 1
fi

if ! ps | grep -v grep | grep -E '[g]etty[^/]*tty1' >/dev/null; then
    setsid /sbin/getty -L tty1 0 linux </dev/null >/dev/null 2>&1 &
fi

echo "OLED console ready: login on tty1, input from USB keyboard"
EOF

Делаем файл исполняемым: 

chmod +x /usr/local/bin/oled-console

Запуск:

# /usr/local/bin/oled-console
[28149.446330] Console: switching to mono frame buffer device 32x10
OLED console ready: login on tty1, input from USB keyboard

Проверка:

# cat /sys/class/vtconsole/vtcon1/bind
1
# ps | grep '[g]etty.*tty1'
  333 root     /sbin/getty -L tty1 0 linux

Ввод в этой консоли идет с USB-клавиатуры, а не через UART.

Добавление userspace-утилит в Buildroot rootfs

Копировать бинарник вручную удобно для отладки, но для воспроизводимого образа лучше добавить его в rootfs.

Простой способ - через rootfs_overlay:

mkdir -p "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/usr/local/bin"
cp oled-clock "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/usr/local/bin/oled-clock"
cp oled-clock-start.sh "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/usr/local/bin/oled-clock-start"
cp oled-console.sh "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/usr/local/bin/oled-console"
chmod +x "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/usr/local/bin/"*

В defconfig должен быть указан overlay: BR2_ROOTFS_OVERLAY="$(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/board/zynq_mini_revb/rootfs_overlay"

Более правильный способ - сделать отдельный Buildroot-пакет oled-clock. Тогда Buildroot будет сам cross-компилировать утилиту и класть ее в /usr/local/bin или /usr/bin.

Рабочее дерево проекта находится в /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux

Внешнее дерево Buildroot находится в /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux/board-support

Создаем в директории board-support/package/ каталог для пакета:

export WORK=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux
export BR_EXT="$WORK/board-support"
mkdir -p "$BR_EXT/package/oled-clock/src"

В результате должна получиться такая структура:

board-support/
└── package/
    └── oled-clock/
        ├── Config.in
        ├── oled-clock.mk
        └── src/
            ├── oled-clock.c
            ├── oled-clock-start.sh
            └── oled-console.sh

Здесь:

Файл

Назначение

Config.in

Добавляет пункт oled-clock в меню Buildroot

oled-clock.mk

Описывает правила сборки и установки пакета

src/oled-clock.c

Исходный код userspace-утилиты

src/oled-clock-start.sh

Переключение OLED в режим часов или статусного экрана

src/oled-console.sh

Переключение OLED обратно в режим Linux console

Копируем исходники и скрипты в каталог пакета:

cp /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux/userspace/oled-clock/oled-clock.c "$BR_EXT/package/oled-clock/src/oled-clock.c"
cp /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux/userspace/oled-clock/oled-clock-start.sh "$BR_EXT/package/oled-clock/src/oled-clock-start.sh"
cp /home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux/userspace/oled-clock/oled-console.sh "$BR_EXT/package/oled-clock/src/oled-console.sh"
chmod +x "$BR_EXT/package/oled-clock/src/oled-clock-start.sh"
chmod +x "$BR_EXT/package/oled-clock/src/oled-console.sh"

Если файлы уже лежат в проекте, можно использовать конкретные пути репозитория. Например:

cp "$WORK/userspace/oled-clock/oled-clock.c" \
   "$BR_EXT/package/oled-clock/src/oled-clock.c"

cp "$WORK/userspace/oled-clock/oled-clock-start.sh" \
   "$BR_EXT/package/oled-clock/src/oled-clock-start.sh"

cp "$WORK/userspace/oled-clock/oled-console.sh" \
   "$BR_EXT/package/oled-clock/src/oled-console.sh"

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

find "$BR_EXT/package/oled-clock" -maxdepth 3 -type f -print

Ожидаемый вывод:

.../package/oled-clock/src/oled-clock.c
.../package/oled-clock/src/oled-clock-start.sh
.../package/oled-clock/src/oled-console.sh

Теперь создаем структуру пакета. Файл Config.in описывает опцию Buildroot, которая включает или отключает пакет.

Создаем файл:

cat > "$BR_EXT/package/oled-clock/Config.in" << 'EOF'
config BR2_PACKAGE_OLED_CLOCK
bool "oled-clock"
depends on BR2_LINUX_KERNEL
help

  Userspace utility for the SSD1306 framebuffer on ZYNQ MINI Rev B.
  The utility can print framebuffer information, draw test patterns,
  show clock/status information, and work together with helper scripts
  that switch the OLED between fbcon console mode and custom display mode.

EOF

Смысл основных строк:

Строка

Смысл

config BR2_PACKAGE_OLED_CLOCK

Имя опции Buildroot

bool "oled-clock"

В menuconfig появится галочка oled-clock

depends on BR2_LINUX_KERNEL

Пакет имеет смысл только в Linux-системе

help

Справочный текст для menuconfig

Имя BR2_PACKAGE_OLED_CLOCK потом будет использоваться в defconfig: BR2_PACKAGE_OLED_CLOCK=y

И подключаем пакет в корневой Config.in BR2_EXTERNAL. Внешнее дерево board-support уже имеет корневой файл $BR_EXT/Config.in

В нем нужно подключить новый пакет. Если файл уже содержит меню вида:

menu "ZYNQ MINI Rev B (manual)"
source "$BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH/package/i2c-master-axi/Config.in"
endmenu

добавляем еще одну строку source:

cat > "$BR_EXT/Config.in" << 'EOF'
menu "ZYNQ MINI Rev B (manual)"
source "$BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH/package/i2c-master-axi/Config.in"
source "$BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH/package/oled-clock/Config.in"
endmenu
EOF

Теперь в menuconfig рядом с i2c-master-axi появится пакет oled-clock.

Проверяем файл $BR_EXT/external.mk, он должен подключать все .mk из подкаталогов package/.

Ожидаемое содержимое:

include $(sort $(wildcard $(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/package/*/*.mk))

Если такая строка уже есть, менять ничего не нужно. Благодаря маске package/*/*.mk новый файл package/oled-clock/oled-clock.mk будет подключен автоматически.

И создаем oled-clock.mk. Файл oled-clock.mk описывает, как именно собрать и установить утилиту.

Создаем рецепт:

cat > "$BR_EXT/package/oled-clock/oled-clock.mk" << 'EOF'

################################################################################

# oled-clock - userspace SSD1306 framebuffer utility

################################################################################

OLED_CLOCK_VERSION = 1.0

OLED_CLOCK_SITE = $(BR2_EXTERNAL_ZYNQ_MINI_I2C_PATH)/package/oled-clock/src

OLED_CLOCK_SITE_METHOD = local

OLED_CLOCK_LICENSE = MIT

define OLED_CLOCK_BUILD_CMDS

$(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \

-O2 -Wall -Wextra \

-o $(@D)/oled-clock $(@D)/oled-clock.c

endef

define OLED_CLOCK_INSTALL_TARGET_CMDS

$(INSTALL) -D -m 0755 $(@D)/oled-clock \

$(TARGET_DIR)/usr/local/bin/oled-clock

$(INSTALL) -D -m 0755 $(@D)/oled-clock-start.sh \

$(TARGET_DIR)/usr/local/bin/oled-clock-start

$(INSTALL) -D -m 0755 $(@D)/oled-console.sh \

$(TARGET_DIR)/usr/local/bin/oled-console

endef

$(eval $(generic-package))

EOF

Разбор ключевых строк:

Строка

Смысл

OLED_CLOCK_VERSION = 1.0

Локальная версия пакета

OLED_CLOCK_SITE = .../src

Откуда Buildroot берет исходники

OLED_CLOCK_SITE_METHOD = local

Источник не скачивается, а берется с диска

$(TARGET_CC)

Целевой ARM-компилятор Buildroot

$(TARGET_CFLAGS)

Флаги целевой сборки

$(TARGET_DIR)

Корень будущей rootfs

$(INSTALL) -D -m 0755

Установка файла с созданием каталогов и правами executable

$(eval $(generic-package))

Подключение стандартной инфраструктуры Buildroot для userspace-пакета

В отличие от i2c-master-axi, здесь не используется $(kernel-module), потому что oled-clock - обычная userspace-программа, а не модуль ядра.

Включаем пакет в defconfig. Открываем defconfig платы:

vim $BR_EXT/configs/zynq_mini_revb_defconfig

Добавляем строку

BR2_PACKAGE_OLED_CLOCK=y

После этого Buildroot будет автоматически собирать oled-clock при полной сборке образа.

Применяем defconfig. Переходим в дерево Buildroot:

export WORK=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux
export BR_SRC="$WORK/buildroot-2024.02.7"
export BR_OUT="$WORK/br-output"
export BR_EXT="$WORK/board-support"

cd "$BR_SRC"

Применяем defconfig:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" zynq_mini_revb_defconfig

Проверяем, что пакет включился:

grep BR2_PACKAGE_OLED_CLOCK "$BR_OUT/.config"

Ожидаемый результат:

BR2_PACKAGE_OLED_CLOCK=y

Если строки нет, значит пакет не добавлен в defconfig или Config.in не подключен в корневом board-support/Config.in.

Проверяем пакет в menuconfig. Можно дополнительно открыть menuconfig:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" menuconfig

Пакет должен быть виден в меню.

Если пакет не виден, нужно проверить три файла:

cat "$BR_EXT/Config.in"
cat "$BR_EXT/external.mk"
cat "$BR_EXT/package/oled-clock/Config.in"

Типовые ошибки:

Симптом

Причина

Пакет не виден в menuconfig

Не добавлен source .../oled-clock/Config.in

Пакет виден, но не собирается

Нет BR2_PACKAGE_OLED_CLOCK=y

Buildroot не видит .mk

Неверный external.mk

Сборка падает на oled-clock.c

Нет файла в src/ или ошибка в C-коде

Бинарник не попал в rootfs

Ошибка в OLED_CLOCK_INSTALL_TARGET_CMDS

Собираем пакет. Для первой сборки можно запустить общую сборку:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" -j"$(nproc)"

Если ядро и rootfs уже собраны, а нужно пересобрать только oled-clock, используем:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" oled-clock-rebuild
make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT"

Первая команда пересобирает пакет. Вторая команда обновляет rootfs и итоговые образы. Проверяем, что бинарник появился в target rootfs:

ls -l "$BR_OUT/target/usr/local/bin/oled-clock"
ls -l "$BR_OUT/target/usr/local/bin/oled-clock-start"
ls -l "$BR_OUT/target/usr/local/bin/oled-console"

Ожидаемый результат:

-rwxr-xr-x ... oled-clock
-rwxr-xr-x ... oled-clock-start
-rwxr-xr-x ... oled-console

Проверяем тип бинарника:

file "$BR_OUT/target/usr/local/bin/oled-clock"
ELF 32-bit LSB executable, ARM, EABI5

Если file показывает x86-64, значит утилита была собрана хостовым gcc, а не $(TARGET_CC). Нужно проверить OLED_CLOCK_BUILD_CMDS.

Проверяем установку в итоговом rootfs. После сборки образа можно проверить содержимое rootfs:

find "$BR_OUT/target/usr/local/bin" -maxdepth 1 -type f -name 'oled*' -print

Ожидаемо:

/usr/local/bin/oled-clock
/usr/local/bin/oled-clock-start
/usr/local/bin/oled-console

Если используется rootfs.ext2, можно проверить уже готовый образ через loop mount:

mkdir -p /tmp/zynq-rootfs
sudo mount -o loop "$BR_OUT/images/rootfs.ext2" /tmp/zynq-rootfs
ls -l /tmp/zynq-rootfs/usr/local/bin/oled*
sudo umount /tmp/zynq-rootfs

Если используется sdcard.img, rootfs будет на втором разделе:

LOOP=$(sudo losetup --find --show --partscan "$BR_OUT/images/sdcard.img")
sudo mount "${LOOP}p2" /tmp/zynq-rootfs
ls -l /tmp/zynq-rootfs/usr/local/bin/oled*
sudo umount /tmp/zynq-rootfs
sudo losetup -d "$LOOP"

Проверяем на плате. После записи нового образа на SD и загрузки платы:

ls -l /usr/local/bin/oled-clock
ls -l /usr/local/bin/oled-clock-start
ls -l /usr/local/bin/oled-console

Проверяем framebuffer:

ls -l /dev/fb0
/usr/local/bin/oled-clock --info

Проверяем тестовые паттерны:

/usr/local/bin/oled-clock --test Z
sleep 1
/usr/local/bin/oled-clock --test G
sleep 1
/usr/local/bin/oled-clock --test F
sleep 1
/usr/local/bin/oled-clock --test T

Запускаем режим часов или статусного экрана:

/usr/local/bin/oled-clock-start

Возвращаем режим консоли:

/usr/local/bin/oled-console

Автозапуск oled-clock или console mode

Сам пакет только устанавливает бинарники. Автоматически запускать oled-clock при старте можно отдельным init-скриптом, например S45oled-console или S46oled-clock.

Если по умолчанию нужен режим консоли на OLED, оставляем init-скрипт /etc/init.d/S45oled-console

Если по умолчанию нужен кастомный статусный экран, можно сделать отдельный скрипт:

cat > "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/etc/init.d/S46oled-clock" << 'EOF'
#!/bin/sh
case "$1" in
  start)
    echo "Starting oled-clock..."
    [ -x /usr/local/bin/oled-clock-start ] && /usr/local/bin/oled-clock-start
    ;;
  stop)
    echo "Stopping oled-clock..."
    killall -q oled-clock 2>/dev/null || true
    ;;
  restart)
    "$0" stop
    sleep 0.2
    "$0" start
    ;;
  *)
    echo "Usage: $0 {start|stop|restart}"
    exit 1
    ;;
esac
exit 0
EOF

Делаем его исполняемым:

chmod +x "$BR_EXT/board/zynq_mini_revb/rootfs_overlay/etc/init.d/S46oled-clock"

Но одновременно держать S45oled-console и S46oled-clock не стоит, если оба пишут в /dev/fb0

И после оформления oled-clock как Buildroot-пакета userspace-часть проекта становится воспроизводимой. Теперь для получения готовой SD-карты не нужно отдельно собирать утилиту и копировать ее на плату. Достаточно выполнить обычную сборку Buildroot, и в rootfs автоматически попадут все необходимые файлы.

Такой подход удобнее и полезнее для дальнейшего развития проекта: драйвер i2c-master-axi собирается как kernel module, а oled-clock собирается как обычный userspace-пакет Buildroot.

Ускорение вывода картинки через PRESCALE

Стандартная частота I2C в проекте - 100 kHz. Для SSD1306 это работает, но полный кадр по I2C обновляется не мгновенно. Каждый кадр 1024 байта превращается в I2C-трафик с адресом, управляющими байтами, командами и задержками. Поэтому повышение частоты SCL может заметно ускорить обновление экрана.

В нашем IP частота SCL задается регистром PRESCALE: SCL = input_clock / (4 * (PRESCALE + 1))

При input_clock = 50 MHz:

PRESCALE

Примерная SCL

124

100 kHz

61

около 201.6 kHz

31

около 390.6 kHz

30

около 403.2 kHz

24

500 kHz

12

около 961.5 kHz

Для SSD1306 разумный первый шаг - около 400 kHz. Это соответствует fast mode I2C и обычно подходит для коротких проводов на плате или макетном подключении. Но гарантировать это нельзя: нужно смотреть качество фронтов, pull-up, длину проводов, питание и конкретный OLED-модуль.

Можно попытаться вручную изменить регистр PRESCALE:

BASE=0x43c00000
devmem $((BASE + 0x00)) 32 0
devmem $((BASE + 0x14)) 32 31
devmem $((BASE + 0x00)) 32 1

Здесь:

  • BASE + 0x00 - CTRL

  • BASE + 0x14 - PRESCALE

Но делать это при загруженном i2c-master-axi и привязанном ssd1307fb небезопасно:

  1. Ядро может в этот момент выполнять I2C-транзакцию.

  2. ssd1307fb может отправлять deferred update.

  3. Ручная запись в CTRL=0 выключает контроллер за спиной драйвера.

  4. Ручная запись в PRESCALE не меняет состояние драйвера и его представление о bus frequency.

  5. Следующий modprobe i2c-master-axi снова выставит PRESCALE по DTS и перезапишет ручное значение.

Симптом из такой ситуации: ssd1307fb 0-003c: Couldn't send I2C command.

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

Корректный способ - поменять желаемую частоту в Device Tree:

i2c_pl: i2c@43c00000 {
    compatible = "user,i2c-master-axi-1.0";
    reg = <0x43c00000 0x1000>;
    input-clock-frequency = <50000000>;
    clock-frequency = <400000>;
    ...
};

После этого пересобрать DTB, положить его на FAT-раздел SD и перезагрузить плату:

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" linux-rebuild
make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT"

и затем заменить zynq-mini-revb.dtb на FAT-разделе SD.

После загрузки проверяем:dmesg | grep -i i2c-master

Ожидаем что-то вроде:

i2c-master-axi 43c00000.i2c: input=50000000 Hz, bus=400000 Hz, prescale=30

Из-за целочисленного деления точный prescale может отличаться от расчетного "идеального" значения. Важно не само число, а фактическая устойчивая работа OLED.

Для более точного таргета на PRESCALE = 31 можно задать частоту около 390625:

clock-frequency = <390625>;

Тогда расчет даст:

50000000 / (4 * 390625) - 1 = 31

Можно экспериментально попробовать PRESCALE без пересборки DTB, делать это лучше при выгруженном модуле и отвязанном framebuffer.

Последовательность:

killall -q oled-clock 2>/dev/null
echo 0 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true
rmmod i2c-master-axi 2>/dev/null || true
BASE=0x43c00000
devmem $((BASE + 0x00)) 32 0
devmem $((BASE + 0x14)) 32 31
devmem $((BASE + 0x18)) 32 3
devmem $((BASE + 0x00)) 32 1

Но важное ограничение: после modprobe i2c-master-axi драйвер снова выполнит hw_init() и запишет свой PRESCALE из DTS. Поэтому такой devmem-эксперимент имеет ограниченную пользу. Он может показать, что железо в принципе принимает новое значение, но не является устойчивой настройкой системы.

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

rmmod: can't unload module 'ssd1307fb': No such file or directory

Это значит не то, что драйвера нет, а то, что он встроен в ядро, а не загружен как модуль. При удалении i2c-master-axi исчезает I2C-адаптер, и framebuffer-консоль может переключиться на dummy device:

Console: switching to colour dummy device 80x30

При повторном modprobe i2c-master-axi адаптер появится снова, ssd1307fb заново привяжется к OLED, и /dev/fb0 зарегистрируется повторно.

Ускоряем вывод на OLED: runtime-настройка частоты I2C в драйвере i2c-master-axi

Теперь перейдем на следующий шаг. На текущий момент чтобы попробовать 200 kHz или 400 kHz, приходится:

  1. править DTS;

  2. пересобирать DTB;

  3. заменять DTB на FAT-разделе SD;

  4. перезагружать плату.

То есть нам нужен не обходной доступ к MMIO, а нормальный runtime-интерфейс самого драйвера.

Сейчас же хочется дать пользователю возможность менять частоту I2C без пересборки DTB, без перезагрузки платы и без прямой записи в MMIO через devmem

Для этого в драйвер добавим sysfs-атрибут bus_hz. Он находится на platform device, соответствующем узлу i2c@43c00000.

Пример использования:

BUS=$(dirname $(readlink -f /sys/bus/i2c/devices/i2c-1/device))
cat "$BUS/bus_hz"
echo 400000 > "$BUS/bus_hz"
dmesg | tail -3

После записи нового значения драйвер сам:

  1. разбирает число из userspace;

  2. берет mutex;

  3. обновляет поле i->bus_hz;

  4. вызывает уже существующую i2c_master_axi_hw_init();

  5. пересчитывает PRESCALE;

  6. кратковременно выключает контроллер;

  7. пишет новый PRESCALE;

  8. снова включает контроллер;

  9. отпускает mutex.

Такой путь остается согласованным с состоянием драйвера и не требует ручной записи в регистры. Логично было бы спросить, мол, а почему sysfs, а не module parameter. Можно было бы добавить параметр модуля: 

modprobe i2c-master-axi bus_hz=400000

Это тоже рабочий вариант, но он требует выгрузки и повторной загрузки модуля при каждой смене частоты. И sysfs удобнее:

echo 400000 > "$BUS/bus_hz"

Поэтому для экспериментов с OLED лучше подходит sysfs. Итоговый алгоритм доработки: 

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

Изменение 1: добавляем документацию в комментарий драйвера

В шапке файла можно добавить короткое описание новой возможности:

/*
 * Runtime bus speed (without DT rebuild): sysfs on the platform device
 *   .../bus_hz   (read/write, Hz)
 * Example: echo 400000 > .../bus_hz   (Fast-mode I2C, if wiring/OLED allow)
 */

Это полезно, потому что пользовательский интерфейс теперь не ограничивается Device Tree. Начальное значение по-прежнему берется из clock-frequency, но после загрузки модуля его можно переопределить через sysfs.

Изменение 2: добавляем include-файлы

Для mutex и sysfs нужны дополнительные заголовки:

#include <linux/mutex.h>
#include <linux/sysfs.h>

mutex.h нужен для:

struct mutex
mutex_init()
mutex_lock()
mutex_unlock()

sysfs.h нужен для:

sysfs_emit()
DEVICE_ATTR_RW()
struct attribute_group
devm_device_add_group()

Изменение 3: добавляем mutex в структуру драйвера

В структуру экземпляра контроллера добавляется поле:

struct i2c_master_axi {
    void __iomem        *regs;
    struct device       *dev;
    struct clk          *clk;
    struct i2c_adapter  adap;
    struct completion   cmd_done;
    struct mutex        lock;
    int                 irq;
    bool                use_irq;
    u32                 input_hz;
    u32                 bus_hz;
};

Назначение lock - защитить регистры IP-блока от одновременного доступа из двух путей:

  1. обычная I2C-транзакция через axi_master_xfer()

  2. смена частоты через запись в sysfs bus_hz

Без mutex возможна ситуация:

  1. ssd1307fb отправляет байт на OLED

  2. в этот момент пользователь пишет echo 400000 > bus_hz

  3. драйвер выключает CTRL.

  4. EN и меняет PRESCALE

  5. текущая I2C-транзакция ломается

  6. ssd1307fb получает ошибку отправки команды

С mutex такая гонка блокируется: либо сначала заканчивается текущий master_xfer, либо сначала выполняется перенастройка частоты. Важно добавить инициализацию mutex в probe():

init_completion(&i->cmd_done);
mutex_init(&i->lock);

Без mutex_init() поведение mutex не определено.

Изменение 4: защищаем axi_master_xfer

Раньше axi_master_xfer() выполнял передачу без внешней блокировки:

status = axi_read(i, I2C_REG_STATUS);
if (status & STATUS_BUSY)
    return -EAGAIN;

for (k = 0; k < num; k++) {
    ret = axi_xfer_one(i, &msgs[k], (k == 0), (k == num - 1));
    if (ret < 0)
        return ret;
}
return num;

После добавления runtime-смены частоты нужно защитить весь xfer:

static int axi_master_xfer(struct i2c_adapter *adap,
                           struct i2c_msg *msgs,
                           int num)
{
    struct i2c_master_axi *i = i2c_get_adapdata(adap);
    int k, ret;
    u32 status;
    mutex_lock(&i->lock);
    status = axi_read(i, I2C_REG_STATUS);

    if (status & STATUS_BUSY) {
        dev_dbg(i->dev, "bus busy at xfer start (status=0x%02x)\n",
                status);
        ret = -EAGAIN;
        goto out_unlock;
    }

    for (k = 0; k < num; k++) {
        ret = axi_xfer_one(i, &msgs[k], (k == 0), (k == num - 1));
        if (ret < 0)
            goto out_unlock;
    }
    ret = num;
  
out_unlock:
    mutex_unlock(&i->lock);
    return ret;
}

Здесь важно не только наличие mutex_lock(), но и единая точка выхода через out_unlock.

Если сделать прямой return -EAGAIN внутри критической секции, mutex останется захваченным, и все следующие I2C-транзакции зависнут.

Изменение 5: добавляем чтение bus_hz через sysfs

Функция чтения атрибута:

static ssize_t bus_hz_show(struct device *dev,
                           struct device_attribute *attr,
                           char *buf)
{

    struct i2c_master_axi *i = dev_get_drvdata(dev);
    return sysfs_emit(buf, "%u\n", i->bus_hz);

}

Она возвращает текущее значение i->bus_hz. Важно: это не измеренная осциллографом частота SCL, а программное целевое значение, которое драйвер использовал для расчета PRESCALE

Изменение 6: добавляем запись bus_hz через sysfs

Функция записи:

static ssize_t bus_hz_store(struct device *dev,
                            struct device_attribute *attr,
                            const char *buf,
                            size_t count)

{
    struct i2c_master_axi *i = dev_get_drvdata(dev);
    unsigned int hz;
    int ret;
    if (kstrtouint(buf, 0, &hz) || hz == 0)
        return -EINVAL;

    mutex_lock(&i->lock);
    i->bus_hz = hz;
    ret = i2c_master_axi_hw_init(i);
    mutex_unlock(&i->lock);
    return ret ? ret : count;
}

Разбор по шагам:

Шаг

Код

Смысл

1

kstrtouint(buf, 0, &hz)

Разобрать строку из userspace

2

hz == 0

Нулевая частота запрещена

3

mutex_lock(&i->lock)

Дождаться конца текущей I2C-транзакции

4

i->bus_hz = hz

Обновить программное целевое значение

5

i2c_master_axi_hw_init(i)

Пересчитать PRESCALE и перенастроить IP

6

mutex_unlock(&i->lock)

Отпустить шину

7

return ret ? ret : count

Вернуть ошибку или количество принятых байт

Функция kstrtouint() с основанием 0 позволяет писать значения в разных форматах:

echo 400000 > "$BUS/bus_hz"

echo 0x61a80 > "$BUS/bus_hz"

При успехе в dmesg снова появится строка из i2c_master_axi_hw_init():

i2c-master-axi 43c00000.i2c: input=50000000 Hz, bus=400000 Hz, prescale=30

Важное замечание про ошибку восстановления bus_hz. Делаем rollback

В простой реализации bus_hz_store() есть тонкий дефект: поле i->bus_hz обновляется до вызова i2c_master_axi_hw_init().

Если пользователь запишет слишком большую частоту, hw_init() вернет ошибку, но программное состояние драйвера уже будет содержать нерабочее значение. Например, при input_hz = 50000000 запись заведомо невозможной частоты приведет к отказу в hw_init(), но cat bus_hz после этого может показать уже новое, не примененное значение.

Поэтому в обработчик записи нужно добавить rollback: сохранить старое значение, попробовать применить новое, а при ошибке вернуть старое значение и повторно вызвать hw_init(), чтобы восстановить не только поле i->bus_hz, но и состояние регистров контроллера.

Итоговая реализация bus_hz_store():

static ssize_t bus_hz_store(struct device *dev, struct device_attribute *attr,
			    const char *buf, size_t count)
{
	struct i2c_master_axi *i = dev_get_drvdata(dev);
	unsigned int hz;
	u32 old_hz;
	int ret, rollback;

	if (kstrtouint(buf, 0, &hz) || hz == 0)
		return -EINVAL;

	mutex_lock(&i->lock);

	old_hz = i->bus_hz;
	i->bus_hz = hz;

	ret = i2c_master_axi_hw_init(i);
	if (ret) {
		i->bus_hz = old_hz;
		rollback = i2c_master_axi_hw_init(i);
		if (rollback)
			dev_err(i->dev,
				"bus_hz rollback to %u Hz failed (%d)\n",
				old_hz, rollback);
	}

	mutex_unlock(&i->lock);

	return ret ? ret : count;
}

Логика получается такой:

Шаг

Действие

Назначение

1

kstrtouint(buf, 0, &hz)

Разобрать значение из sysfs

2

hz == 0

Запретить нулевую частоту

3

mutex_lock(&i->lock)

Исключить параллельную I2C-транзакцию

4

old_hz = i->bus_hz

Сохранить предыдущее рабочее значение

5

i->bus_hz = hz

Подготовить новое целевое значение

6

i2c_master_axi_hw_init(i)

Пересчитать PRESCALE и применить настройки

7

if (ret)

Если новая частота не принята

8

i->bus_hz = old_hz

Вернуть старое значение в состояние драйвера

9

повторный hw_init()

Восстановить старый PRESCALE в железе

10

dev_err() при ошибке rollback

Зафиксировать, если восстановление не удалось

11

mutex_unlock()

Освободить контроллер

12

return ret ? ret : count

Вернуть ошибку или успешную запись

Важный момент: простого возврата i->bus_hz = old_hz недостаточно. Если i2c_master_axi_hw_init() успела выключить контроллер, очистить ISR или частично записать регистры, программное поле и железо могут оказаться в разных состояниях. Поэтому при ошибке выполняется повторный hw_init() уже со старой частотой.

Также в probe() обязательно нужно инициализировать mutex:

i->dev = dev;
init_completion(&i->cmd_done);
mutex_init(&i->lock);
platform_set_drvdata(pdev, i);

Без mutex_init(&i->lock) использование mutex_lock() и mutex_unlock() некорректно.

После этой доработки запись в bus_hz ведет себя предсказуемо:

cat "$BUS/bus_hz"
# 100000

echo 400000 > "$BUS/bus_hz"

dmesg | tail -2

# i2c-master-axi ... input=50000000 Hz, bus=400000 Hz, prescale=30

cat "$BUS/bus_hz"
# 400000

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

echo 50000000 > "$BUS/bus_hz"
# write error: Invalid argument
cat "$BUS/bus_hz"
# 400000

Если при этом восстановление старого PRESCALE в железе не удалось, в dmesg появится диагностическое сообщение: bus_hz rollback to 400000 Hz failed (-...)

Такой rollback делает sysfs-интерфейс безопаснее: userspace может экспериментировать с частотой шины, но не оставляет драйвер в заведомо неконсистентном состоянии после неудачной записи.

Изменение 7: регистрируем sysfs-атрибут

Создаем атрибут и группу:

static DEVICE_ATTR_RW(bus_hz);

static struct attribute *i2c_master_axi_dev_attrs[] = {
    &dev_attr_bus_hz.attr,
    NULL,
};

static const struct attribute_group i2c_master_axi_dev_group = {
    .attrs = i2c_master_axi_dev_attrs,
};

Макрос DEVICE_ATTR_RW(bus_hz) создает атрибут с правами чтения и записи и связывает его с функциями bus_hz_show, bus_hz_store. В результате в sysfs появляется файл .../bus_hz

Он находится у platform device, то есть у устройства 43c00000.i2c, а не напрямую в каталоге /sys/bus/i2c/devices/i2c-1/.

Изменение 8: добавляем sysfs-группу в probe

После успешного i2c_add_adapter() регистрируем группу:

ret = i2c_add_adapter(&i->adap);
if (ret)
    goto err_disable;

ret = devm_device_add_group(dev, &i2c_master_axi_dev_group);

if (ret)
    goto err_del_adap;

return 0;

err_del_adap:
    i2c_del_adapter(&i->adap);

err_disable:
    axi_write(i, I2C_REG_CTRL, 0);

err_clk:
    if (i->clk)
        clk_disable_unprepare(i->clk);
    return ret;

Порядок здесь такой:

  1. сначала регистрируем i2c_adapter;

  2. затем создаем sysfs-атрибут;

  3. если sysfs-группа не создалась, снимаем уже зарегистрированный адаптер через i2c_del_adapter().

Так мы не оставляем в системе "висячую" I2C-шину без управляющего sysfs-интерфейса.

devm_device_add_group() автоматически удалит группу при удалении устройства, поэтому в remove() отдельный sysfs_remove_group() не нужен.

Как найти путь к bus_hz на плате

После загрузки модуля и появления I2C-шины:

ls /sys/bus/i2c/devices/

Допустим, адаптер называется i2c-1. Тогда путь к platform device можно получить так:

BUS=$(dirname $(readlink -f /sys/bus/i2c/devices/i2c-1/device))
echo "$BUS"

Типичный результат:

/sys/devices/soc0/amba_pl@0/43c00000.i2c

Проверяем атрибут:

cat "$BUS/bus_hz"

Ожидаемо после загрузки 100000

Меняем частоту:

echo 400000 > "$BUS/bus_hz"
dmesg | tail -5

Ожидаем строку i2c-master-axi 43c00000.i2c: input=50000000 Hz, bus=400000 Hz, prescale=30

Проверяем, что OLED все еще отвечает:

i2cdetect -y 1

Если адрес 0x3c занят драйвером, в таблице может быть UU, а не 3c. Это нормально.

Типовые значения bus_hz и PRESCALE

При input-clock-frequency = 50000000 расчет такой: PRESCALE = input_hz / (4 * bus_hz) - 1

bus_hz

PRESCALE

Примерная частота SCL

Комментарий

100000

124

100000 Hz

Standard mode, значение по умолчанию

200000

61

около 201613 Hz

Промежуточный режим

390625

31

390625 Hz

Точное значение для prescale 31

400000

30

около 403226 Hz

Fast mode около 400 kHz

500000

24

500000 Hz

Может работать не на каждом модуле

1000000

11 или 12

около 1 MHz

Риск NACK, зависит от платы

Формально многие OLED-модули SSD1306 работают на fast-mode I2C 400 kHz. Но реальная надежность зависит не только от микросхемы.

Фактор

Влияние

Длина проводов до OLED

Чем длиннее, тем хуже фронты SDA/SCL

Номинал pull-up

Слишком слабые подтяжки замедляют фронты

Емкость шины

Большая емкость ухудшает форму сигнала

Уровень питания

3.3 V обычно нормально, но зависит от модуля

Качество XDC и разводки PL-пинов

Ошибка пинов даст NACK на любой скорости

Реализация RTL IP

Тайминги должны держать выбранную SCL

Поведение ssd1307fb

Использует framebuffer/deferred I/O, не только I2C

Поэтому не будем утверждать "400 kHz всегда работает", а формулируем так: 400 kHz - разумный первый fast-mode режим для проверки, если проводка и OLED-модуль его выдерживают.

Тесты после изменения частоты

После смены bus_hz нужно проверить не только dmesg, но и фактическую работу framebuffer. Последовательность:

dmesg | grep -i i2c-master

/usr/local/bin/oled-clock --info
/usr/local/bin/oled-clock --test Z
/usr/local/bin/oled-clock --test G
/usr/local/bin/oled-clock --test F
/usr/local/bin/oled-clock --test T
/usr/local/bin/oled-clock

Пересборка драйвера

Если менялся только файл linux/drivers/i2c-master-axi/i2c-master-axi.c

ядро и DTB пересобирать не нужно. Достаточно пересобрать Buildroot-пакет с модулем:

export WORK=/home/megalloid/devel/xilinx/projects/zynq_mini_oled/linux
export BR_SRC="$WORK/buildroot-2024.02.7"
export BR_OUT="$WORK/br-output"
export BR_EXT="$WORK/board-support"

cd "$BR_SRC"

make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT" i2c-master-axi-rebuild
make BR2_EXTERNAL="$BR_EXT" O="$BR_OUT"

Первый вызов пересобирает модуль. Второй обновляет rootfs и итоговые образы. Проверяем, что модуль появился в target rootfs:

find "$BR_OUT/target/lib/modules" -name 'i2c-master-axi.ko' -print

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

killall -q oled-clock 2>/dev/null
echo 0 > /sys/class/vtconsole/vtcon1/bind 2>/dev/null || true
rmmod i2c-master-axi
modprobe i2c-master-axi

Но стоит сразу оговориться, что Runtime-настройка через bus_hz не сохраняется между перезагрузками и перезагрузкой модуля.

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

Пример init-скрипта:

cat > /etc/init.d/S44i2c-bus-speed << 'EOF'

#!/bin/sh
case "$1" in
    start|"")
        for i2cdev in /sys/bus/i2c/devices/i2c-*; do
            [ -e "$i2cdev/device" ] || continue
            busdir=$(readlink -f "$i2cdev/device")
            [ -n "$busdir" ] || continue
            if [ -w "$busdir/bus_hz" ]; then
                if echo 400000 > "$busdir/bus_hz"; then
                    echo "Set I2C bus speed to 400000 Hz on $busdir"
                else
                    echo "Failed to write bus_hz on $busdir" >&2
                    exit 1
                fi
                exit 0
            fi
        done
        echo "i2c-master-axi bus_hz not found" >&2
        ;;
    stop|restart|reload) : ;;
    *) echo "usage: $0 {start|stop|restart|reload}"; exit 1 ;;
esac
exit 0
EOF

chmod 0755 /etc/init.d/S44i2c-bus-speed

Скрипт лучше запускать после загрузки модуля i2c-master-axi, но до запуска oled-clock. Если используется текущая последовательность S03modules и S45oled-console то логичное место для установки скорости S44i2c-bus-speed.

Порядок будет таким:

S03modules
    -> modprobe i2c-master-axi
    -> появляется bus_hz

S44i2c-bus-speed
    -> echo 400000 > bus_hz

S45oled-console или S46oled-clock
    -> начинается активный вывод на экран

В результате можно быстро сравнивать 100 kHz, 200 kHz и 400 kHz без пересборки DTB и без перезагрузки платы. Для статьи это хороший финальный штрих: проект не только запускает OLED через кастомный I2C-контроллер в PL, но и получает управляемый способ подбирать скорость шины под конкретную плату, проводку и OLED-модуль.

Заключение

Что ж. На этом цикл статей про I2C Master Controller под ПЛИС можно считать завершенным.

Он начался с идеи собственного I2C Master Controller, мне пришлось заново сформулировать требования и перепроектировать ядро, которое поддерживает START, STOP, READ, WRITE, RESTART, ACK/NACK, clock stretching, потерю арбитража, настраиваемый prescaler, IRQ-события и пакетную передачу. В первой части цикла эта задача была поставлена как переход от старой версии контроллера к более полноценной реализации, которую сначала нужно проверить в симуляции, затем на реальном железе, а позже интегрировать в Zynq через AXI и Linux-драйвер.

Дальше мы прошли через тестирование низкоуровневого ядра. Для i2c_master_core были разобраны тестбенчи, модели slave-устройств, сценарии single read/write, repeated START, NACK, clock stretching, arbitration lost, reset в середине передачи и последовательное чтение нескольких байт. Это был важный этап: пока модуль изолирован, ошибки в протоколе, FSM и обработке пограничных состояний искать проще, чем в составе большой системы.

После этого появился следующий уровень абстракции: управляющий контроллер вокруг ядра I2C. Он нужен был для того, чтобы перейти от отдельных низкоуровневых команд к более удобной логике работы с реальными устройствами, в том числе EEPROM и последующей интеграцией в Zynq/Linux. Затем цикл был расширен до burst-транзакций и OLED SSD1306: была разобрана организация видеопамяти, режимы адресации дисплея, пакетная передача по I2C, framebuffer в BRAM, генерация изображения и пример с выводом 3D-куба и текста целиком в аппаратной логике, без процессора и микрокода.

В этой заключительной части мы перенесли тот же смысловой контур уже в Linux-мир на Zynq. Кастомный IP-блок в PL был подключен через AXI4-Lite, описан в Device Tree, поднят out-of-tree драйвером как стандартный i2c_adapter, после чего штатный ssd1307fb создал /dev/fb0 для OLED SSD1306. В финале появились userspace-утилиты, Buildroot-пакеты, init-скрипты, переключение между framebuffer-консолью и кастомным статусным экраном, а также runtime-настройка скорости I2C через bus_hz без пересборки DTB.

Итоговая цепочка получилась очень внушительной, кажется тянет на целую книгу.

Главный результат здесь не только в том, что на OLED удалось вывести часы, тестовые паттерны или системную информацию. Более важен сам путь: низкоуровневая цифровая логика, написанная на Verilog, была постепенно доведена до состояния нормального устройства в Linux. Один и тот же I2C-контроллер прошел через симуляцию, проверку на FPGA, расширение до burst-передач, работу с реальными I2C-устройствами, интеграцию в Zynq и включение в стандартную драйверную модель ядра.

Проект остается учебным и экспериментальным. В нем еще можно развивать RTL, улучшать тайминги, добавлять DMA, формализовать devicetree binding, сделать поддержку нескольких экземпляров контроллера, расширить userspace-утилиты и аккуратнее оформить upstream-like интеграцию. Но базовая задача цикла выполнена: от самодельного I2C Master Controller на Verilog мы дошли до полноценной цепочки "PL -> Linux kernel -> userspace", где аппаратная логика, драйвер и пользовательское приложение работают как единая система.

Большое вам спасибо что хотя бы доскороллили до этого момента. В следующих материалах будем курить OLED по SPI, тот что установлен на самой плате ?.

 До встречи в других материалах!


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (0)