Embedded HandbookПриложения
Все разделы › III. Периферия
STM32F103 · уровень средний · 32 мин чтения
Открыть в интерактивном справочнике

16г. MP3-модуль DFPlayer Mini по UART: кадр команды, контрольная сумма, очередь треков, громкость и ответы модуля

Голосовая подсказка, звонок, сигнал тревоги словами — звук нужен многим приборам, а свой декодер MP3 на F103 не поместится. Готовое решение — модуль DFPlayer Mini: на нём слот microSD, декодер и усилитель на динамик. Микроконтроллеру остаётся UART на 9600 бод и короткие кадры по 10 байт: «играй папку 1, файл 3», «громкость 20», «пауза». Модуль отвечает такими же кадрами и сам сообщает, что трек закончился, карту вынули или команда не понята.

Что внутри: какие бывают модули и как разложить файлы на карте; кадр команды и его сумма; команды и запросы; разбор ответов и событий без ожидания в цикле; плейлист поверх событий «трек закончился» — и почему у клонов это событие приходит дважды, а вывод BUSY его не заменяет; щелчки и помехи по питанию; грабли.

Опоры: UART и уровни — из 16 и 16.7, кольцо приёма по прерыванию и разбор без блокировки — как в 106г.3, порядок разделов на драйвер и модель — как в 16в. Свой звук по I2S с кодеком — в 18а. Документа на DFPlayer Mini и микросхему YX5200 в проекте нет: коды команд, ответов и ошибок, назначение выводов, напряжения и поведение клонов — по памяти о документации производителя и помечены «не проверено». Всё, что в разделе названо прогоном, — драйвер _verify/mp3_16e/dfplayer.c на компьютере (gcc 12.2.0) с моделью модуля на Python 3.11; прошивка собрана armclang 6.24 под Cortex-M3. Задержки модуля в прогоне — параметры модели, а не замер.

Как это называется в документации

Оригинал Перевод
command frame: start, version, length, command, feedback, parameter, checksum, end кадр команды: начало, версия, длина, команда, просьба подтвердить, параметр, сумма, конец
feedback (ACK) подтверждение приёма кадра
track, folder трек (файл), папка на карте
play mode, repeat режим воспроизведения, повтор
equalizer (EQ) эквалайзер: готовые наборы тембра
BUSY pin вывод «играет»
online devices носители, которые модуль видит
track finished, card inserted / removed трек закончился, карту вставили / вынули

16г.1 Модуль: YX5200 и клоны, карта, папки, выходы и BUSY

Что внутри. На плате DFPlayer Mini — микросхема декодера (в оригинале YX5200, позже YX5300 и YX6300; на клонах — другие, с той же системой команд), слот microSD и усилитель на динамик, по памяти до 3 Вт. Кроме выводов динамика (SPK1, SPK2) есть выход ЦАП на внешний усилитель (DAC_L, DAC_R), вывод BUSY, UART (RX, TX) и питание. Названия и число выводов — по документации производителя, не проверено.

Карта. Карта — FAT16 или FAT32. Файлы раскладывают по папкам 01…99 с именами 001.mp3, 002.mp3 и так далее; тогда команда «папка/файл» находит файл по имени. Есть и вторая нумерация — сквозной номер трека, и она идёт по порядку записи файлов на карту, а не по именам (16г.3). Поведение файловой системы и пределы номеров — по памяти, не проверено.

Питание и уровни. Модуль питают от 3,3…5 В; от 5 В громче, и усилитель меньше тянет питание вниз (по памяти, не проверено). UART модуля — 3,3-вольтовый. С F103 линии соединяются напрямую, но в линию TX микроконтроллера → RX модуля ставят резистор 1 кОм — так советует документация, чтобы гасить помехи (по памяти, не проверено). Земля общая, TX одного — в RX другого (16.7).

BUSY. Вывод в нуле, пока трек играет, в единице — когда тихо. Его подают на вход PB5 с подтяжкой к питанию. Ниже видно, почему это лишь запасной признак конца трека (16г.5).

Слева — STM32F103 и DFPlayer Mini: PA9 TX через резистор 1 кОм на RX модуля, TX модуля на PA10 RX, BUSY модуля на PB5, питание модуля 5 В, общий провод; модуль подключён к динамику через SPK1 и SPK2 и читает microSD с файлами вида 01/001.mp3; UART 9600 бод 8N1, BUSY = 0 — играет, выводы и уровни по документации, не проверено. Справа — кадр команды из 10 байт: 7E, FF, 06, CMD, FB, P1, P2, CH, CL, EF; сумма берётся от шести байтов от FF до P2, CH CL = 0 минус сумма; пример: трек 1 — 7E FF 06 03 00 00 01 FE F7 EF; FB = 1 — модуль подтвердит кадром 0x41; громкость 0…30; кадр идёт 10,4 мс. Внизу слева — обмен: команда 0F папка/файл с FB = 1, подтверждение 41, без ответа 200 мс — повтор до 3 раз; трек играет при BUSY = 0; событие 3D трек закончился, команда следующего пункта, у клона второе 3D, которое в первые 1000 мс после запуска не относится к этому треку. Внизу справа — конец трека: событие 0x3D сразу и с номером, запасной путь — BUSY = 1 дольше 500 мс; BUSY не говорит, какой трек кончился, и в единице на паузе и до начала звука; трек выбирают командой папка/файл, её повтор безвреден; номер трека — порядок записи файла на карту


16г.2 Кадр команды и контрольная сумма

Всё общение — кадрами по 10 байт в обе стороны, 9600 бод, 8N1. У F103 на 72 МГц BRR = 7500 ровно; байт идёт 1041,7 мкс, кадр — 10,4 мс.

Байт Значение Смысл
0 7E начало
1 FF версия
2 06 длина: шесть байтов от версии до параметра
3 CMD команда
4 FB 1 — прислать подтверждение 0x41, 0 — не надо
5, 6 PAR1, PAR2 параметр, старший байт первым
7, 8 CHK_H, CHK_L сумма
9 EF конец

Сумма — 16 бит: ноль минус сумма байтов с 1 по 6, то есть от FF до PAR2. Тогда сумма этих шести байтов вместе с CHK по модулю 65 536 даёт ноль. Формат — по документации производителя, не проверено; правильность счёта проверена прогоном — Си против независимой реализации на Python:

uint16_t df_sum(const uint8_t *f)
{
    uint16_t s = 0;
    for (uint8_t i = 1; i < 7; i++)                     /* FF 06 CMD FB PAR1 PAR2 — без 7E, суммы и EF */
        s = (uint16_t)(s + f[i]);
    return (uint16_t)(0u - s);
}
Команда Кадр Сумма байтов 1…6
трек 1, без подтверждения 7E FF 06 03 00 00 01 FE F7 EF 0x109
трек 1, FB = 1 7E FF 06 03 01 00 01 FE F6 EF 0x10A
громкость 20, FB = 1 7E FF 06 06 01 00 14 FE E0 EF 0x120
папка 2, файл 3, FB = 1 7E FF 06 0F 01 02 03 FE E6 EF 0x11A
сброс 7E FF 06 0C 00 00 00 FE EF EF 0x111
запрос состояния 7E FF 06 42 00 00 00 FE B9 EF 0x147

Кадр «трек 1» — тот, что обычно приводят в примерах к модулю; кадры Си и Python совпали байт в байт.


16г.3 Команды: трек, папка, громкость, эквалайзер, пауза, сброс, запросы

Коды — по документации производителя, не проверено; модель стенда понимает ровно эти:

CMD Параметр Что делает
01, 02 — следующий, предыдущий трек
03 1…число файлов трек по сквозному номеру
06 0…30 громкость
07 0…5 эквалайзер: обычный, поп, рок, джаз, классика, бас
0C — сброс модуля
0D, 0E — играть (продолжить), пауза
0F папка, файл папка 01…99, файл 001…
16 — стоп
42, 43, 44 — запрос: состояние, громкость, эквалайзер
48, 4C — запрос: файлов на карте, номер текущего трека
4E папка запрос: файлов в папке

Сквозной номер — порядок записи. На карте модели шесть файлов, записанных в порядке 01/002, 01/001, 01/003, 02/001, 02/002, 03/001. Прогон: команда 03 с номерами 1, 2, 3, 4 включила 01/002, 01/001, 01/003, 02/001 — номер 1 достался файлу, записанному первым, а не 001.mp3. Команда 0F с папкой 1 и файлом 1 включила 01/001. Запрос 48 вернул 6, 4E для папки 1 — 3. Отсюда правило: трек выбирать по папке и файлу, а сквозной номер оставить для «сыграть всё подряд».

Громкость. 0…30; драйвер сам обрезает больше 30: df_volume(35) ушла кадром 7E FF 06 06 01 00 1E FE D6 EF, и запрос 43 вернул 30. Что делает настоящий модуль с числом больше 30 — не проверено.

Запросы отвечают кадром с тем же кодом: 43 — громкость в PAR2, 48 — число файлов. Подтверждение 41 на запрос драйвер не просит: ответом служит само значение.


16г.4 Ответы и события модуля: разбор потока без блокировки

От модуля приходят кадры трёх видов: подтверждение 41, ответ на запрос и события, которые никто не спрашивал. События и ошибки — по документации производителя, не проверено:

CMD Параметр Когда
3F носители: 2 — карта модуль готов после включения или сброса
3A, 3B носитель карту вставили, вынули
3D номер трека трек с карты закончился
40 код ошибки 1 — занят, 2 — спит, 3 — ошибка кадра, 4 — неверная сумма, 5 — номер вне диапазона, 6 — файла нет
41 — подтверждение кадра с FB = 1

Байты складывает в кольцо прерывание RXNE, а df_poll в главном цикле собирает их в кадры — как кольцо и строки в 106г.3, только вместо конца строки здесь длина 10 и проверка вида кадра. Сбившийся кадр не тащит за собой следующий: начало ищется заново внутри собранного:

    if (d->f[1] != 0xFF || d->f[2] != 0x06 || d->f[9] != 0xEF) {
        d->n_junk++;
        for (uint8_t i = 1; i < 10; i++)
            if (d->f[i] == 0x7E) {
                memmove(d->f, d->f + i, (size_t)(10u - i));
                d->fn = (uint8_t)(10u - i);
                break;
            }
        return;
    }
    if ((uint16_t)(d->f[7] << 8 | d->f[8]) != df_sum(d->f)) {
        d->n_badsum++;
        if (!d->lax && !d->n_good && ++d->badsum_run >= DF_BADSUM_N) {
            d->lax = 1;                                 /* целые кадры, ни одной верной суммы — клон считает иначе */
            emit(d, DF_EV_MODE, DF_MODE_LAX, 0);
        }
        if (!d->lax)
            return;
    } else {
        d->n_good++;
        d->badsum_run = 0;
    }

Одна команда в пути. Драйвер держит очередь на 8 команд и отправляет следующую, только когда прошлая закончилась: пришло 41, ответ на запрос или ошибка 40. Без ответа 200 мс — повтор, всего до 3 попыток. Ошибки 3 и 4 — модуль принял кадр испорченным — повтор сразу; ошибка 1 «занят» — повтор через 250 мс, до 12 раз; остальные — команда не выполнена, и это событие для приложения.

Прогон: включение. Модель глуха 1500 мс после включения, затем присылает 3F с параметром 2. Драйвер ждёт этот кадр (не больше 3000 мс) и только потом шлёт громкость, эквалайзер и запрос числа файлов: 3F пришёл через 1511 мс, настройка закончилась на 1637 мс. На каждую команду подтверждение приходило через 35…49 мс — 10,4 мс на кадр туда, обработка в модели 10…30 мс и кадр обратно. В кольце приёма больше 1 байта не лежало: при опросе раз в 1 мс байт на 9600 бод приходит реже.


16г.5 Очередь воспроизведения: плейлист, «трек закончился» дважды, BUSY против событий

Плейлист — массив пар «папка, файл». Драйвер ставит в очередь 0F первого пункта; по событию 3D — следующего. Пункт, которого нет на карте (ошибка 6), пропускается. Пауза и продолжение — 0E и 0D.

Прогон: плейлист. Шесть пунктов, пятого (02/005) на карте нет, посреди второго трека — пауза на 2000 мс. Прозвучали 01/001, 01/002, 01/003, 02/001, 03/001 по порядку: пять концов по событию, один пропуск, ни одного по BUSY. От события до звука следующего трека прошло 122…163 мс. На паузе BUSY 2000 мс держался в единице, и конец трека драйвер не засчитал — на паузе запасной путь по BUSY выключен.

Событие дважды. Про модули с клонами микросхемы часто пишут, что 3D приходит два раза подряд (по памяти, не проверено). Модель «клона» шлёт второй 3D через 10…60 мс после первого. Если на него просто запустить следующий пункт, плейлист прыгает через трек. Защита — защитный интервал: в первые 1000 мс после запуска трека событие «закончился» — не про него:

static void track_end(df_t *d, uint16_t par)
{
    if (!d->use_events)
        return;
    if (d->st != DF_ST_PLAY || (d->dedup && !after(d->now, d->play_at + DF_GUARD_MS))) {
        d->n_stale++;                                   /* второй раз о том же или о прежнем треке */
        emit(d, DF_EV_STALE, par, 0);
        return;
    }
    d->n_end_ev++;
    finished(d, DF_END_EVENT);
}
Клон «дважды», 6 пунктов без защиты с защитой
прозвучало 4 из 6: 01/001, 01/003, 02/002, 03/001 6 из 6
лишних событий отброшено 0 6

Цена — трек короче 1 с не может кончиться по событию. Сравнивать номер в 3D с номером запущенного трека нельзя: в событии сквозной номер (порядок записи), а трек запускался по папке и файлу.

BUSY против событий. BUSY не говорит, какой трек кончился; он в единице и на паузе, и после команды, пока звук ещё не начался (в модели 60…120 мс). Поэтому основной признак — событие, а BUSY — запасной: при игре, позже защитного интервала, единица дольше 500 мс значит «трек кончился, а событие пропало»:

    /* BUSY: 0 — играет. При игре 1 дольше DF_BUSY_MS — трек кончился, а событие пропало */
    if (d->st == DF_ST_PLAY && busy_pin && after(now, d->play_at + DF_GUARD_MS)) {
        if (!d->busy_hi) {
            d->busy_hi = 1;
            d->busy_since = now;
        } else if (after(now, d->busy_since + DF_BUSY_MS)) {
            d->n_end_busy++;
            finished(d, DF_END_BUSY);
        }
    } else {
        d->busy_hi = 0;
    }

Прогон того же плейлиста из 6 треков: по событиям пауза между треками — 128…136 мс, на одном BUSY без событий — 598…633 мс.

Клон без подтверждений. Модель такого клона не шлёт 41 и не шлёт 3F при включении. Драйвер дождался 3000 мс и начал без 3F; первая же команда трижды осталась без ответа — за 600 мс, и раз ни одного 41 ещё не было, драйвер перестал просить подтверждение и дальше выдерживает 100 мс после каждой команды. Все 6 треков прозвучали.

Клон со своей суммой. Модель считает сумму своих кадров без байта FF и чужую сумму не проверяет. Драйвер отбросил два кадра, а на третьем целом кадре с чужой суммой — ни одной верной до того — перестал проверять сумму (через 3245 мс после включения). Все 6 треков прозвучали. Цена — испорченный помехой кадр с таким модулем не отличить от верного.

Карту вынули. Посреди третьего трека модуль прислал 3B, драйвер очистил очередь и встал в «нет карты». Через 2000 мс карту вставили: 3A, драйвер запустил тот же пункт сначала; модуль, пока читал карту, трижды ответил «занят», и трек зазвучал через 977 мс после вставки. Плейлист доиграл до конца.

Помехи. Модель искажает каждый байт на проводах с вероятностью 0,005 в обе стороны. Плейлист из 30 треков по кругу, громкость меняется раз в 10 треков, 100 концов трека:

Что Прогон
концов трека по событию / по BUSY / пропусков 95 / 5 / 0
порядок треков без нарушений
команд отправлено / повторов / без ответа 122 / 11 / 8
ошибок от модуля (неверная сумма) 4
кадров с неверной суммой / мусора у драйвера 8 / 13
трек запущен заново тем же 0F 6
время опыта 248 984 мс

Пять событий «трек закончился» не дошли целыми — эти концы трека нашёл BUSY. Одна команда 0F трижды осталась без ответа; драйвер не выбросил пункт, а оставил решение BUSY, и трек прозвучал. Шесть раз пропадало подтверждение, повтор запускал тот же трек сначала — неприятно, но порядок цел. Будь вместо 0F команда «следующий» (01), такой повтор перескочил бы через трек: повторяемая команда должна быть безвредной при повторе.


16г.6 Питание и звук: щелчки, помехи, общий провод

Здесь всё качественно: модуль не подключался, чисел в проекте нет.

Щелчки. Усилитель на динамик берёт ток толчками в такт звуку. Если модуль питается от того же провода, что и микроконтроллер, просадки идут в питание платы и в землю UART. Типичные следствия: щелчок при включении и на старте трека, треск на громких местах, сбои кадров в момент громкого звука. Средства: модулю — своё питание или хотя бы отдельный провод от источника и электролитический конденсатор у его выводов питания; громкость не держать на пределе, если источник слабый.

Общий провод. Земля одна, но ток динамика не должен течь по земле UART и микроконтроллера: провода питания модуля идут к источнику отдельно, а к плате микроконтроллера — только сигнальные и одна земля. Выход динамика мостовой (SPK1 и SPK2), ни один из них не сажать на землю (по памяти о документации, не проверено).

Внешний усилитель. Выход ЦАП модуля — для своего усилителя; помехи по земле здесь слышны сильнее, чем на встроенном. Если звук нужен качественный и свой, а не из файла на карте, — I2S и кодек (18а).

Резистор 1 кОм в линии к RX модуля ограничивает ток через защитные диоды входа и вместе с ёмкостью входа сглаживает фронты (по памяти о документации, не проверено).


16г.7 Грабли

Модуль молчит в ответ на любую команду. Перепутаны TX и RX, нет общей земли (16.7), не та скорость: модулю нужно 9600 бод. Или команда ушла слишком рано после включения: модуль ещё читает карту.

Громкость и эквалайзер после сброса не применились. Команды ушли сразу после 0C, пока модуль перезагружался. В прогоне без ожидания 3F: 8 сроков без ответа, громкость и эквалайзер ушли по 3 раза и пропали, модуль остался со своими 30, а драйвер считал, что громкость 20. С ожиданием 3F модуль готов через 1521 мс после сброса, и настройка проходит.

Играет «не тот» файл. Команда 03 берёт сквозной номер — порядок записи файлов на карту, а не имя. Копирование папки целиком не обещает порядок. Выбирать трек командой 0F по папке и файлу.

Плейлист перескакивает через трек. Клон шлёт «трек закончился» дважды (16г.5). Защитный интервал после запуска трека.

Плейлист встал навсегда. Событие 3D испортилось помехой — и ждать больше нечего. Запасной путь — BUSY: единица при игре дольше 500 мс.

Ни одного ответа, а музыка играет. Клон без подтверждений 41 (16г.5) — или ответы приходят, но с чужой суммой. Смотреть счётчики драйвера: «кадров с неверной суммой» или «сроков без ответа».

Повтор команды прыгает через трек. Повторяли «следующий» (01). Повторять только команды, повтор которых безвреден: «папка/файл», громкость, эквалайзер.

Трек не находится, хотя файл на карте. Имя не по правилу (01/001.mp3: папка из двух цифр, файл из трёх) — по памяти о документации, не проверено. Модуль отвечает ошибкой 6; драйвер пропускает пункт.


16г.8 Итоги

Полные исходники

dfplayer.h — драйвер: константы, коды команд и событий, состояние и прототипы.

/* dfplayer.h — раздел 16г: MP3-модуль DFPlayer Mini (YX5200 и клоны) по UART, 9600 бод.
 * Кадр: 7E FF 06 CMD FB PAR1 PAR2 CHK_H CHK_L EF; CHK = 0 − (FF + 06 + CMD + FB + PAR1 + PAR2), 16 бит.
 * dfplayer.c — кадр и сумма, разбор потока ответов без блокировки, одна команда в пути с подтверждением
 * и повтором, очередь команд, плейлист «папка/файл», события модуля (трек закончился, карта, ошибка), BUSY.
 * Коды команд и ответов — по документации производителя (в проекте её нет), не проверено.
 * Платформа даёт одну функцию: df_hw_tx (байты в USART). Тот же код собирается в прошивку (fw16e.c)
 * и в стенд на компьютере (host16e.c). */
#ifndef DFPLAYER_H
#define DFPLAYER_H
#include <stdint.h>

#define DF_RXQ       128u       /* кольцо приёма */
#define DF_Q         8u         /* очередь команд */
#define DF_LIST      32u        /* плейлист */
#define DF_ACK_MS    200u       /* срок ответа на команду */
#define DF_TRIES     3u         /* попыток одной команды */
#define DF_BUSY_TRY  12u        /* попыток, пока модуль отвечает «занят» (ошибка 1) */
#define DF_BUSY_WAIT 250u       /* «занят» — повторить через столько мс */
#define DF_GAP_MS    100u       /* модуль без подтверждений: пауза после команды */
#define DF_NOACK_N   3u         /* столько сроков подряд, ни одного подтверждения — модуль «без ответов» */
#define DF_BADSUM_N  3u         /* столько целых кадров подряд с чужой суммой, ни одного верного — сумму не проверять */
#define DF_BOOT_MS   3000u      /* ждать 0x3F после включения или сброса */
#define DF_GUARD_MS  1000u      /* после запуска трека: «трек закончился» — не про него, BUSY не смотреть */
#define DF_BUSY_MS   500u       /* BUSY = 1 столько мс при игре — трек кончился, а событие пропало */
#define DF_VOL_MAX   30u

/* команды (микроконтроллер → модуль) */
enum {
    DF_NEXT = 0x01, DF_PREV = 0x02, DF_TRACK = 0x03, DF_VOL = 0x06, DF_EQ = 0x07, DF_RESET = 0x0C,
    DF_PLAY = 0x0D, DF_PAUSE = 0x0E, DF_FOLDER = 0x0F, DF_STOP = 0x16,
    DF_Q_STATUS = 0x42, DF_Q_VOL = 0x43, DF_Q_EQ = 0x44, DF_Q_FILES = 0x48, DF_Q_TRACK = 0x4C, DF_Q_INFOLDER = 0x4E
};
/* ответы и события (модуль → микроконтроллер) */
enum { DF_R_IN = 0x3A, DF_R_OUT = 0x3B, DF_R_END_U = 0x3C, DF_R_END = 0x3D, DF_R_END_F = 0x3E, DF_R_ONLINE = 0x3F,
       DF_R_ERR = 0x40, DF_R_ACK = 0x41 };
/* коды ошибки в ответе 0x40 */
enum { DF_E_BUSY = 1, DF_E_SLEEP = 2, DF_E_FRAME = 3, DF_E_SUM = 4, DF_E_RANGE = 5, DF_E_NOFILE = 6 };

enum { DF_ST_BOOT, DF_ST_READY, DF_ST_PLAY, DF_ST_PAUSE, DF_ST_NOCARD };
enum { DF_EV_STATE, DF_EV_READY, DF_EV_DONE, DF_EV_REPLY, DF_EV_END, DF_EV_STALE, DF_EV_ERR, DF_EV_CARD, DF_EV_MODE,
       DF_EV_LIST_END };
enum { DF_END_EVENT = 1, DF_END_BUSY = 2, DF_END_SKIP = 3 };   /* чем замечен конец трека */
enum { DF_MODE_NOACK = 1, DF_MODE_LAX = 2 };

typedef struct df df_t;
typedef void (*df_event_fn)(df_t *d, uint8_t ev, uint16_t a, uint16_t b);

typedef struct {
    uint8_t cmd;
    uint16_t par;
} df_cmd_t;

typedef struct {
    uint8_t folder, file;
} df_item_t;

struct df {
    /* кольцо приёма: head пишет прерывание, tail — df_poll */
    uint8_t rx[DF_RXQ];
    volatile uint16_t rx_head;
    uint16_t rx_tail, rx_hiwater;
    volatile uint16_t rx_lost;
    uint8_t f[10], fn;          /* собираемый кадр */
    /* команды */
    df_cmd_t q[DF_Q];
    uint8_t qh, qt;
    df_cmd_t cur;               /* команда в пути */
    uint8_t busy, tries, btries;
    uint32_t deadline, next_at;
    uint8_t ack, lax;           /* ack = 1 — просить подтверждение; lax = 1 — сумму принятых кадров не проверять */
    uint8_t noack_run, badsum_run;
    /* плейлист */
    df_item_t list[DF_LIST];
    uint8_t list_n, idx, loop, resume;
    uint32_t play_at, busy_since;
    uint8_t busy_hi;
    /* состояние */
    uint8_t st, vol, eq, dedup, boot_wait, use_events;
    uint16_t files, online;
    uint32_t now, boot_at;
    df_event_fn on_event;
    uint32_t n_tx, n_retry, n_ack, n_tmo, n_err, n_rx, n_good, n_badsum, n_junk, n_stale, n_end_ev, n_end_busy, n_skip;
};

/* платформа */
void df_hw_tx(const uint8_t *p, uint16_t n);

uint16_t df_sum(const uint8_t *f);                              /* сумма по байтам 1…6 кадра */
uint8_t df_frame(uint8_t cmd, uint8_t fb, uint16_t par, uint8_t *out);  /* 10 байт */

void df_init(df_t *d, uint32_t now_ms, uint8_t vol, uint8_t eq, df_event_fn ev);
void df_rx_isr(df_t *d, uint8_t b);
void df_poll(df_t *d, uint32_t now_ms, uint8_t busy_pin);       /* busy_pin: 0 — играет, 1 — тихо */
int df_cmd(df_t *d, uint8_t cmd, uint16_t par);                 /* 0 — в очереди, −1 — очередь полна */
int df_volume(df_t *d, uint8_t v);                              /* 0…30, больше — 30 */
int df_eq(df_t *d, uint8_t eq);                                 /* 0…5 */
int df_play(df_t *d, const df_item_t *list, uint8_t n, uint8_t loop);
void df_pause(df_t *d);
void df_resume(df_t *d);
void df_stop(df_t *d);
void df_reset(df_t *d);

#endif

dfplayer.c — кадр и сумма, разбор ответов, очередь команд, плейлист, события и BUSY.

/* dfplayer.c — раздел 16г: DFPlayer Mini по UART. Ни одного ожидания в цикле: df_poll разбирает то, что уже
 * пришло, проверяет срок команды в пути, отправляет следующую из очереди и следит за BUSY.
 * Коды команд, ответов и ошибок — по документации производителя (в проекте её нет), не проверено. */
#include <string.h>
#include "dfplayer.h"

static int after(uint32_t now, uint32_t t)
{
    return (int32_t)(now - t) >= 0;
}

static int is_query(uint8_t c)
{
    return c >= 0x42u && c <= 0x4Fu;
}

static void emit(df_t *d, uint8_t ev, uint16_t a, uint16_t b)
{
    if (d->on_event)
        d->on_event(d, ev, a, b);
}

static void set_st(df_t *d, uint8_t st)
{
    if (d->st != st) {
        d->st = st;
        emit(d, DF_EV_STATE, st, 0);
    }
}

/* ── кадр ────────────────────────────────────────────────────────────── */
uint16_t df_sum(const uint8_t *f)
{
    uint16_t s = 0;
    for (uint8_t i = 1; i < 7; i++)                     /* FF 06 CMD FB PAR1 PAR2 — без 7E, суммы и EF */
        s = (uint16_t)(s + f[i]);
    return (uint16_t)(0u - s);
}

uint8_t df_frame(uint8_t cmd, uint8_t fb, uint16_t par, uint8_t *out)
{
    out[0] = 0x7E;
    out[1] = 0xFF;                                      /* версия */
    out[2] = 0x06;                                      /* длина: от версии до PAR2 */
    out[3] = cmd;
    out[4] = fb;                                        /* 1 — прислать подтверждение 0x41 */
    out[5] = (uint8_t)(par >> 8);
    out[6] = (uint8_t)par;
    uint16_t c = df_sum(out);
    out[7] = (uint8_t)(c >> 8);
    out[8] = (uint8_t)c;
    out[9] = 0xEF;
    return 10;
}

/* ── очередь команд: одна в пути ─────────────────────────────────────── */
int df_cmd(df_t *d, uint8_t cmd, uint16_t par)
{
    uint8_t nx = (uint8_t)((d->qh + 1u) % DF_Q);
    if (nx == d->qt)
        return -1;
    d->q[d->qh].cmd = cmd;
    d->q[d->qh].par = par;
    d->qh = nx;
    return 0;
}

static void send_cur(df_t *d)
{
    uint8_t w[10];
    uint8_t q = (uint8_t)is_query(d->cur.cmd);
    uint8_t fb = (uint8_t)(d->ack && !q);
    df_hw_tx(w, df_frame(d->cur.cmd, fb, d->cur.par, w));
    d->n_tx++;
    d->busy = 1;
    d->deadline = d->now + (fb || q ? DF_ACK_MS : DF_GAP_MS);
}

static void play_item(df_t *d)
{
    const df_item_t *it = &d->list[d->idx];
    df_cmd(d, DF_FOLDER, (uint16_t)(it->folder << 8 | it->file));
    d->play_at = d->now;
    d->busy_hi = 0;
    set_st(d, DF_ST_PLAY);
}

/* конец трека: событием, по BUSY или пропуском (файла нет) — следующий пункт плейлиста */
static void finished(df_t *d, uint8_t how)
{
    emit(d, DF_EV_END, d->idx, how);
    if (++d->idx >= d->list_n) {
        if (!d->loop) {
            d->list_n = 0;
            set_st(d, DF_ST_READY);
            emit(d, DF_EV_LIST_END, 0, 0);
            return;
        }
        d->idx = 0;
    }
    play_item(d);
}

static void done(df_t *d, uint8_t res)                 /* res: 0 — выполнена, иначе код ошибки, 0xFF — молчание */
{
    uint8_t c = d->cur.cmd;
    d->busy = 0;
    emit(d, DF_EV_DONE, c, res);
    if ((!res || res == 0xFF) && (c == DF_FOLDER || c == DF_PLAY))
        d->play_at = d->now;                            /* трек пошёл (или пропал ответ — решит BUSY) */
    if (res && res != 0xFF && c == DF_FOLDER && d->st == DF_ST_PLAY) {    /* модуль сказал «нет файла» */
        d->n_skip++;
        finished(d, DF_END_SKIP);
    }
}

static void retry(df_t *d)
{
    if (d->tries >= DF_TRIES) {
        done(d, 0xFF);
        return;
    }
    d->tries++;
    d->n_retry++;
    send_cur(d);
}

static void on_timeout(df_t *d)
{
    uint8_t q = (uint8_t)is_query(d->cur.cmd);
    if (!q && !d->ack) {
        done(d, 0);                                     /* без подтверждений: пауза прошла — считаем выполненной */
        return;
    }
    d->n_tmo++;
    if (!q && !d->n_ack && ++d->noack_run >= DF_NOACK_N) {
        d->ack = 0;                                     /* ни одного 0x41 — клон без подтверждений */
        emit(d, DF_EV_MODE, DF_MODE_NOACK, 0);
        done(d, 0);                                     /* ушла DF_NOACK_N раз: «папка/файл» и громкость повторять безвредно */
        return;
    }
    retry(d);
}

static void on_error(df_t *d, uint8_t code)
{
    d->n_err++;
    if (d->busy != 1) {
        emit(d, DF_EV_ERR, code, 0);                    /* ошибка без команды в пути */
        return;
    }
    if (code == DF_E_FRAME || code == DF_E_SUM) {
        retry(d);                                       /* модуль принял кадр испорченным */
    } else if (code == DF_E_BUSY && d->btries < DF_BUSY_TRY) {
        d->btries++;
        d->busy = 2;                                    /* занят (читает карту) — отправить снова позже */
        d->deadline = d->now + DF_BUSY_WAIT;
    } else {
        done(d, code);
    }
}

static void ready(df_t *d)
{
    emit(d, DF_EV_READY, d->online, (uint16_t)(d->now - d->boot_at));
    set_st(d, DF_ST_READY);
    df_volume(d, d->vol);
    df_eq(d, d->eq);
    df_cmd(d, DF_Q_FILES, 0);
    if (d->resume && d->list_n)
        play_item(d);
    d->resume = 0;
}

static void track_end(df_t *d, uint16_t par)
{
    if (!d->use_events)
        return;
    if (d->st != DF_ST_PLAY || (d->dedup && !after(d->now, d->play_at + DF_GUARD_MS))) {
        d->n_stale++;                                   /* второй раз о том же или о прежнем треке */
        emit(d, DF_EV_STALE, par, 0);
        return;
    }
    d->n_end_ev++;
    finished(d, DF_END_EVENT);
}

static void on_frame(df_t *d, uint8_t cmd, uint16_t par)
{
    switch (cmd) {
    case DF_R_ACK:
        d->n_ack++;
        d->noack_run = 0;
        if (d->busy == 1 && !is_query(d->cur.cmd))
            done(d, 0);
        return;
    case DF_R_ERR:
        on_error(d, (uint8_t)par);
        return;
    case DF_R_ONLINE:                                   /* модуль готов; PAR2 — носители: 2 — карта */
        d->online = par;
        if (d->st == DF_ST_BOOT)
            ready(d);
        return;
    case DF_R_OUT:
        emit(d, DF_EV_CARD, 0, par);
        if (d->st == DF_ST_PLAY)
            d->resume = 1;
        d->qh = d->qt = 0;
        d->busy = 0;
        set_st(d, DF_ST_NOCARD);
        return;
    case DF_R_IN:
        emit(d, DF_EV_CARD, 1, par);
        if (d->st != DF_ST_NOCARD)
            return;
        set_st(d, DF_ST_READY);
        if (d->resume && d->list_n)
            play_item(d);                               /* тот же пункт сначала; пока карта читается — «занят» */
        d->resume = 0;
        return;
    case DF_R_END_U:
    case DF_R_END:
    case DF_R_END_F:
        track_end(d, par);
        return;
    default:
        if (is_query(cmd) && d->busy == 1 && d->cur.cmd == cmd) {
            if (cmd == DF_Q_FILES)
                d->files = par;
            emit(d, DF_EV_REPLY, cmd, par);
            done(d, 0);
        } else {
            d->n_junk++;                                /* ответ не на нашу команду */
        }
    }
}

/* кадр собирается по байту; не тот вид — ищем следующий 7E внутри собранного */
static void rx_byte(df_t *d, uint8_t b)
{
    if (d->fn == 0 && b != 0x7E) {
        d->n_junk++;
        return;
    }
    d->f[d->fn++] = b;
    if (d->fn < 10)
        return;
    d->fn = 0;
    if (d->f[1] != 0xFF || d->f[2] != 0x06 || d->f[9] != 0xEF) {
        d->n_junk++;
        for (uint8_t i = 1; i < 10; i++)
            if (d->f[i] == 0x7E) {
                memmove(d->f, d->f + i, (size_t)(10u - i));
                d->fn = (uint8_t)(10u - i);
                break;
            }
        return;
    }
    if ((uint16_t)(d->f[7] << 8 | d->f[8]) != df_sum(d->f)) {
        d->n_badsum++;
        if (!d->lax && !d->n_good && ++d->badsum_run >= DF_BADSUM_N) {
            d->lax = 1;                                 /* целые кадры, ни одной верной суммы — клон считает иначе */
            emit(d, DF_EV_MODE, DF_MODE_LAX, 0);
        }
        if (!d->lax)
            return;
    } else {
        d->n_good++;
        d->badsum_run = 0;
    }
    d->n_rx++;
    on_frame(d, d->f[3], (uint16_t)(d->f[5] << 8 | d->f[6]));
}

/* ── общий вход ──────────────────────────────────────────────────────── */
int df_volume(df_t *d, uint8_t v)
{
    if (v > DF_VOL_MAX)
        v = DF_VOL_MAX;
    d->vol = v;
    return df_cmd(d, DF_VOL, v);
}

int df_eq(df_t *d, uint8_t eq)
{
    if (eq > 5u)
        return -1;
    d->eq = eq;
    return df_cmd(d, DF_EQ, eq);
}

int df_play(df_t *d, const df_item_t *list, uint8_t n, uint8_t loop)
{
    if (!n || n > DF_LIST)
        return -1;
    memcpy(d->list, list, n * sizeof *list);
    d->list_n = n;
    d->idx = 0;
    d->loop = loop;
    if (d->st == DF_ST_BOOT || d->st == DF_ST_NOCARD)
        d->resume = 1;                                  /* начнётся, когда модуль будет готов */
    else
        play_item(d);
    return 0;
}

void df_pause(df_t *d)
{
    if (d->st != DF_ST_PLAY)
        return;
    df_cmd(d, DF_PAUSE, 0);
    set_st(d, DF_ST_PAUSE);
}

void df_resume(df_t *d)
{
    if (d->st != DF_ST_PAUSE)
        return;
    df_cmd(d, DF_PLAY, 0);
    d->play_at = d->now;
    d->busy_hi = 0;
    set_st(d, DF_ST_PLAY);
}

void df_stop(df_t *d)
{
    df_cmd(d, DF_STOP, 0);
    d->list_n = 0;
    set_st(d, DF_ST_READY);
}

void df_reset(df_t *d)
{
    uint8_t w[10];
    d->resume = (uint8_t)(d->st == DF_ST_PLAY);
    d->qh = d->qt = 0;
    d->busy = 0;
    df_hw_tx(w, df_frame(DF_RESET, 0, 0, w));           /* подтверждения не ждём: модуль перезагружается */
    d->n_tx++;
    d->boot_at = d->now;
    set_st(d, DF_ST_BOOT);
}

void df_init(df_t *d, uint32_t now, uint8_t vol, uint8_t eq, df_event_fn ev)
{
    memset(d, 0, sizeof *d);
    d->now = d->boot_at = now;
    d->vol = vol > DF_VOL_MAX ? DF_VOL_MAX : vol;
    d->eq = eq;
    d->ack = d->dedup = d->boot_wait = d->use_events = 1;
    d->on_event = ev;
    d->st = DF_ST_BOOT;
}

void df_rx_isr(df_t *d, uint8_t b)
{
    uint16_t nx = (uint16_t)((d->rx_head + 1u) % DF_RXQ);
    if (nx == d->rx_tail) {
        d->rx_lost++;
        return;
    }
    d->rx[d->rx_head] = b;
    d->rx_head = nx;
}

void df_poll(df_t *d, uint32_t now, uint8_t busy_pin)
{
    d->now = now;
    uint16_t used = (uint16_t)((d->rx_head - d->rx_tail + DF_RXQ) % DF_RXQ);
    if (used > d->rx_hiwater)
        d->rx_hiwater = used;
    while (d->rx_tail != d->rx_head) {
        uint8_t b = d->rx[d->rx_tail];
        d->rx_tail = (uint16_t)((d->rx_tail + 1u) % DF_RXQ);
        rx_byte(d, b);
    }
    if (d->st == DF_ST_BOOT && (!d->boot_wait || after(now, d->boot_at + DF_BOOT_MS)))
        ready(d);                                       /* 0x3F так и не пришёл — клон молчит при включении */
    if (d->busy == 1 && after(now, d->deadline))
        on_timeout(d);
    else if (d->busy == 2 && after(now, d->deadline))
        send_cur(d);
    if (!d->busy && d->st != DF_ST_BOOT && d->qt != d->qh) {
        d->cur = d->q[d->qt];
        d->qt = (uint8_t)((d->qt + 1u) % DF_Q);
        d->tries = 1;
        d->btries = 0;
        send_cur(d);
    }
    /* BUSY: 0 — играет. При игре 1 дольше DF_BUSY_MS — трек кончился, а событие пропало */
    if (d->st == DF_ST_PLAY && busy_pin && after(now, d->play_at + DF_GUARD_MS)) {
        if (!d->busy_hi) {
            d->busy_hi = 1;
            d->busy_since = now;
        } else if (after(now, d->busy_since + DF_BUSY_MS)) {
            d->n_end_busy++;
            finished(d, DF_END_BUSY);
        }
    } else {
        d->busy_hi = 0;
    }
}

fw16e.c — прошивка STM32F103: USART1 на 9600 бод с прерываниями, BUSY, кнопка паузы, главный цикл.

/* fw16e.c — раздел 16г: DFPlayer Mini на STM32F103 (BluePill), 72 МГц.
 * USART1, 9600 бод: PA9 — TX через резистор 1 кОм к RX модуля, PA10 — RX от TX модуля.
 * PB5 — вход BUSY модуля с подтяжкой к питанию (0 — играет). PA0 — кнопка к земле: пауза и продолжение.
 * PC13 — светодиод: горит, пока трек играет. Приём — прерывание RXNE кладёт байт в кольцо драйвера;
 * передача — своё кольцо и прерывание TXE. SysTick раз в 1 мс — только счёт времени; df_poll — в главном
 * цикле. Тактирование до 72 МГц — как в разделе 6 (здесь не повторяется); USART1 на APB2 = 72 МГц. */
#include "stm32f10x.h"
#include "dfplayer.h"

#define CPU_HZ  72000000u
#define BAUD    9600u
#define TXQ     64u

_Static_assert(CPU_HZ / BAUD == 7500u && CPU_HZ % BAUD == 0u, "USART1: BRR = 7500 — 9600 бод");

static df_t D;
static volatile uint32_t ms;
static uint8_t txq[TXQ];
static volatile uint16_t tx_head, tx_tail;
static uint32_t key_at;
static uint8_t key_last = 1;

static const df_item_t LIST[] = {{1, 1}, {1, 2}, {1, 3}, {2, 1}};

void SysTick_Handler(void)
{
    ms++;
}

void USART1_IRQHandler(void)
{
    uint32_t sr = USART1->SR;
    if (sr & (USART_SR_RXNE | USART_SR_ORE))       /* чтение DR снимает и RXNE, и ORE */
        df_rx_isr(&D, (uint8_t)USART1->DR);
    if ((USART1->CR1 & USART_CR1_TXEIE) && (sr & USART_SR_TXE)) {
        if (tx_tail != tx_head) {
            USART1->DR = txq[tx_tail];
            tx_tail = (uint16_t)((tx_tail + 1u) % TXQ);
        } else {
            USART1->CR1 &= ~USART_CR1_TXEIE;    /* очередь пуста */
        }
    }
}

/* ── платформа драйвера ───────────────────────────────────────────────── */
void df_hw_tx(const uint8_t *p, uint16_t n)
{
    for (uint16_t i = 0; i < n; i++) {
        uint16_t nx = (uint16_t)((tx_head + 1u) % TXQ);
        if (nx == tx_tail)
            break;                              /* в пути не больше одного кадра — 10 байт */
        txq[tx_head] = p[i];
        tx_head = nx;
    }
    USART1->CR1 |= USART_CR1_TXEIE;
}

/* ── приложение ───────────────────────────────────────────────────────── */
static void on_event(df_t *d, uint8_t ev, uint16_t a, uint16_t b)
{
    (void)d;
    (void)b;
    if (ev == DF_EV_STATE)
        GPIOC->BSRR = a == DF_ST_PLAY ? 1u << 29 : 1u << 13;   /* PC13 = 0 — светодиод горит */
}

static void board_init(void)
{
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN | RCC_APB2ENR_IOPCEN | RCC_APB2ENR_USART1EN;
    GPIOB->BSRR = 1u << 5;                      /* PB5 — подтяжка к питанию */
    GPIOB->CRL = (GPIOB->CRL & ~(0xFu << 20)) | 0x8u << 20;    /* PB5 — вход с подтяжкой */
    GPIOA->BSRR = 1u << 0;                      /* PA0 — подтяжка к питанию */
    GPIOA->CRL = (GPIOA->CRL & ~0xFu) | 0x8u;   /* PA0 — вход с подтяжкой */
    GPIOA->CRH = (GPIOA->CRH & ~0xFF0u)
               | 0xBu << 4                      /* PA9 — USART1_TX, альтернативная функция */
               | 0x4u << 8;                     /* PA10 — USART1_RX, вход без подтяжки */
    GPIOC->BSRR = 1u << 13;
    GPIOC->CRH = (GPIOC->CRH & ~(0xFu << 20)) | 0x2u << 20;    /* PC13 — светодиод платы */
    USART1->BRR = CPU_HZ / BAUD;
    USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
    NVIC_EnableIRQ(USART1_IRQn);
    SysTick_Config(CPU_HZ / 1000u);
}

int main(void)
{
    board_init();
    df_init(&D, ms, 20, 0, on_event);
    df_play(&D, LIST, sizeof LIST / sizeof LIST[0], 1);    /* начнётся после 0x3F */
    for (;;) {
        uint32_t now = ms;
        df_poll(&D, now, (uint8_t)(GPIOB->IDR >> 5 & 1u));
        uint8_t key = (uint8_t)(GPIOA->IDR & 1u);
        if (key != key_last && now - key_at >= 20u) {      /* дребезг: 20 мс после прошлой смены */
            key_last = key;
            key_at = now;
            if (!key) {
                if (D.st == DF_ST_PLAY)
                    df_pause(&D);
                else
                    df_resume(&D);
            }
        }
        __WFI();                                /* до следующего прерывания: байта или тика */
    }
}

Дальше: раздел 17 «Шина I²C» — два провода, много устройств и самая капризная периферия из всех.

Открыть в интерактивном справочнике