Все туториалы по «точному времени на Raspberry Pi» сломаны на современных системах. Разбираю почему — и показываю как сделать правильно.
Предыстория
Хотелось иметь дома автономный NTP-сервер, который не зависит от интернета. Причина простая: при пропадании связи часы на сетевом оборудовании начинают плыть, логи теряют смысл, TLS-сертификаты перестают валидироваться.
Взял Raspberry Pi 4, DCF77-приёмник RC8000 и GPS-модуль ublox NEO-M8T с PPS-выходом. Начал гуглить готовые решения — и столкнулся с типичной проблемой Raspberry Pi-экосистемы: каждый второй туториал написан для Buster или Bullseye двухлетней давности и не работает на современной системе.
Написал с нуля на C++20. Под катом — что именно сломалось в старых решениях, как устроена архитектура, и несколько технических деталей, которые нигде нормально не документированы.
Что не так со старыми решениями
Проблема 1: libgpiod v2 сломала весь старый код
Большинство проектов работают с GPIO через libgpiod v1 или вовсе через устаревший sysfs-интерфейс (/sys/class/gpio). На Raspberry Pi OS Bookworm/Trixie установлена libgpiod v2, которая полностью удалила API v1 — без обратной совместимости.
Код, который работал вчера:
// libgpiod v1 — НЕ КОМПИЛИРУЕТСЯ с v2 struct gpiod_chip *chip = gpiod_chip_open_by_name("gpiochip0"); struct gpiod_line *line = gpiod_chip_get_line(chip, 17); gpiod_line_request_both_edges_events(line, "dcf77");
Ошибка компилятора будет примерно такая:
error: use of undeclared identifier 'gpiod_chip_open_by_name' error: use of undeclared identifier 'gpiod_chip_get_line'
Функций больше не существует. Новый API v2 построен на принципиально другой объектной модели:
// libgpiod v2 — правильный подход const SettingsPtr settings(gpiod_line_settings_new()); gpiod_line_settings_set_edge_detection(settings.get(), GPIOD_LINE_EDGE_BOTH); gpiod_line_settings_set_event_clock(settings.get(), GPIOD_LINE_CLOCK_REALTIME); const LineCfgPtr line_cfg(gpiod_line_config_new()); gpiod_line_config_add_line_settings(line_cfg.get(), &offset, 1, settings.get()); const RequestPtr request(gpiod_chip_request_lines(chip.get(), req_cfg.get(), line_cfg.get()));
Многословнее, но зато появилась возможность, которая критична для NTP: GPIOD_LINE_CLOCK_REALTIME. Теперь каждое GPIO-событие несёт в себе метку времени от ядра — не от userspace clock_gettime().
Проблема 2: ntpd вытеснен chrony
Все старые руководства используют ntpd (пакет ntp). На Raspberry Pi OS Bookworm ntpd не установлен — вместо него стоит chrony. Можно поставить ntpd принудительно (apt install ntp), но он конфликтует с системным chrony, и это путь в никуда.
Это не просто «замените одно на другое». Chrony принципиально лучше для embedded:
ntpd | chrony | |
|---|---|---|
Сходимость после старта | несколько минут | секунды ( |
Работа при потере сигнала | деградация | holdover ( |
PPS через ядро | отдельные патчи и костыли | нативный |
Точность с PPS | ~100 мкс | <1 мкс |
Единственное, что совместимо между ними — протокол NTP SHM (shared memory): внешняя программа пишет время в разделяемую память, демон читает. Именно это мы и используем.
Проблема 3: неправильный номер GPIO-чипа
Жёстко зашитый gpiochip0 ломается на RPi 5 и Trixie:
gpiodetect # RPi 4, Bookworm: pinctrl-bcm2711 [gpiochip0] ✓ # RPi 5, Trixie: pinctrl-rp1 [gpiochip4] ← нужен gpiochip4
Архитектура
Два независимых источника времени и отдельный веб-монитор:
┌──────────────┐ ┌─────────────────┐ │ RC8000 │ │ dcf77_decoder │ │ (DCF77) ├────▶│ GPIO → NTP SHM ├─── NTP SHM #2 ────────────────┐ │ GPIO 17 │ └─────────────────┘ │ └──────────────┘ ┌─────────▼───────┐ │ chrony ├──▶ LAN ┌──────────────┐ ┌─────────────────┐ │ Stratum 1 │ │ NEO-M8T │ │ gpsd │ └─────────▲───────┘ │ (GPS + PPS) ├────▶│ UART 115200 ├─── NTP SHM #0 ────────────────┤ │ │ └─────────────────┘ │ │ GPIO 18 ├──────────────────────────── PPS (/dev/pps0) ──────────┘ └──────────────┘ ┌──────────────┐ ┌───────────────────────────────────────┐ │ Браузер │◀────┤ dcf77_web │ └──────────────┘ │ chronyc + journald ── HTTP :8080 │ └───────────────────────────────────────┘
Chrony.conf:
# PPS — основной источник, ±10 нс refclock PPS /dev/pps0 refid PPS precision 1e-9 poll 0 dpoll -2 lock NMEA prefer # NMEA — только для привязки PPS к секунде UTC, не используется напрямую refclock SHM 0 refid NMEA precision 1e-3 poll 0 dpoll -2 offset 0.0 delay 0.2 noselect # DCF77 — независимый fallback и детектор GPS-спуфинга refclock SHM 2 refid DCF7 precision 1e-2 offset 0.0 delay 0.008 poll 6 dpoll 5 # Holdover при потере обоих сигналов local stratum 10 DCF77 здесь играет двойную роль: fallback при потере GPS, и **независимая проверка GPS**. Если GPS внезапно показывает время, расходящееся с DCF77 более чем на несколько секунд — это повод для расследования. --- ## Декодер DCF77: ключевая техническая деталь ### Откуда берётся точность Наивная реализация декодера делает `clock_gettime(CLOCK_REALTIME, &ts)` в момент обнаружения фронта сигнала. Проблема: между реальным фронтом и вызовом `clock_gettime` проходит неопределённое время — планировщик Linux может поставить процесс на паузу в любой момент. Джиттер userspace — 1–50 мс. Это катастрофа для NTP. libgpiod v2 решает это правильно: каждое событие несёт **метку времени ядра**, установленную в обработчике прерывания — до того, как userspace вообще узнал о событии. Запрашиваем `GPIOD_LINE_CLOCK_REALTIME`: ```cpp gpiod_line_settings_set_event_clock(settings.get(), GPIOD_LINE_CLOCK_REALTIME);
Теперь timestamp события — это CLOCK_REALTIME момента прерывания, а не момента чтения. StdDev в NTP SHM падает с ~40 мс до <5 мс без какого-либо аппаратного изменения.
NTP SHM: подводные камни на 64-bit
Протокол NTP SHM определён с time_t для временны́х меток. На 32-bit системах time_t = 4 байта, на 64-bit = 8 байт. Структура из большинства туториалов написана для 32-bit и на 64-bit системе даст неправильный layout — chrony будет читать мусор.
Правильная структура для 64-bit с явным паддингом:
struct NtpShmTime { int32_t mode; int32_t count; int64_t clockTimeStampSec; // 64-bit! int32_t clockTimeStampUSec; int32_t _pad; // явный паддинг под alignment int64_t int64_t receiveTimeStampSec; // 64-bit! int32_t receiveTimeStampUSec; int32_t leap; int32_t precision; int32_t nsamples; int32_t valid; };
Поля shm_ должны быть volatile — иначе компилятор технически имеет право оптимизировать запись как мёртвую (другой процесс, читающий эту память, невидим компилятору):
volatile NtpShmTime *shm_ = nullptr;
Протокол декодирования DCF77
DCF77 кодирует время в BCD с чётностью. Каждую минуту передаётся 59 бит:
100 мс HIGH → бит 0
200 мс HIGH → бит 1
Пауза >1.5 с → маркер минуты (второй 59 без импульса)
Биты 21–58 несут время следующей минуты в BCD: минуты, часы, день недели, число, месяц, год — с раздельными битами чётности. Декодер проверяет три независимые группы чётности + валидность через timegm():
// Проверяем не только чётность, но и что дата реально существует tm normalized{}; gmtime_r(&t, &normalized); if (normalized.tm_mday != tm_buf.tm_mday) { spdlog::warn("Impossible calendar date: {:02d}.{:02d}.{:02d}", ...); return std::nullopt; }
Bootstrap-логика: первые два подряд успешных фрейма должны отличаться ровно на 60 секунд. Это отсекает случайно совпавший мусор на старте.
Конфигурация ublox NEO-M8T
NEO-M8T — специализированный timing-модуль. Ключевые отличия от обычных GPS-чипов:
Прошивка TIM, а не стандартная навигационная. TIM-версия имеет улучшенную модель тактового генератора и поддержку RAIM (Receiver Autonomous Integrity Monitoring). PPS-джиттер у TIM-прошивки значительно ниже.
dynModel = Stationary. Фильтр Калмана знает, что приёмник не движется. Весь «вычислительный бюджет» уходит на уточнение временно́й метки, а не на сглаживание траектории. Разница в PPS-джиттере:
dynModel | Jitter PPS |
|---|---|
Portable (смартфон) | ~20–50 нс |
Stationary | ~5–10 нс |
Airborne (самолёт) | ~100+ нс |
antCableDelay = 50 нс — компенсация задержки кабеля антенны. В коаксиальном RG-58 сигнал распространяется со скоростью ~0.67c, каждые 10 м дают ~50 нс задержки. Это прошивается в модуль, и PPS-фронт выдаётся уже с поправкой. Без компенсации PPS будет смещён относительно UTC на время прохождения сигнала.
4 констелляции одновременно (GPS + GLONASS + Galileo + BeiDou). Больше спутников → лучше геометрия → точнее позиция → точнее временна́я метка. Одна GPS-система даёт 6–12 видимых спутников, четыре системы — 20–30.
lpMode = Continuous — никакого power saving. PSM-режимы периодически отключают RF-часть, что вносит джиттер в PPS в сотни микросекунд.
Веб-монитор
Отдельный процесс dcf77_web поднимает HTTP-сервер (порт 8080) и парсит вывод chronyc в JSON. Встроенный веб-UI собирается в бинарник при компиляции через configure_file в CMake — никаких внешних файлов на диске.
// Из генерируемого заголовка html += INDEX_HTML_HEAD; html += "<script>window.__INITIAL_DATA__="; html += get_full_status(); // данные прямо в HTML, без второго запроса html += ";</script>\n"; html += INDEX_HTML_TAIL;
API доступен как машиночитаемый JSON:
curl http://rpi:8080/api/status | jq '.tracking.system_time'
POST /api/prefer — управление preferred source в chrony — принимает запросы только с localhost (проверка req.remote_addr), чтобы случайно не открыть управление NTP наружу.
Сборка и деплой
Зависимости подтягиваются через vcpkg: spdlog, cpp-httplib, CLI11, zlib. Никакого ручного apt-get для C+±библиотек.
cmake -B build -DCMAKE_BUILD_TYPE=Release -DTIMESERVER_AUTHOR="you@example.com" cmake --build build -j$(nproc)
Версия берётся из git-тега автоматически:
git tag v1.2.3 cmake -B build # → PROJECT_VERSION = 1.2.3
Между тегами: v1.2.3-4-gabcdef → PROJECT_VERSION = 1.2.3.4. Хеш и дата сборки вшиваются в бинарник через configure_file:
$ ./dcf77_decoder --version timeServer dcf77_decoder v1.0.0+a3f9b1c DCF77 + ublox NEO-M8T NTP time server Built: 2025-04-02
Деплой на RPi — одна команда через Ansible:
ansible-playbook -i ansible/inventory/hosts.yml ansible/site.yml
Плейбук синхронизирует исходники, собирает на RPi, устанавливает systemd-юниты с systemd hardening (ProtectSystem=strict, MemoryDenyWriteExecute, CapabilityBoundingSet=CAP_SYS_TIME).
Результаты
После нескольких часов работы chrony показывает:
chronyc tracking Reference ID : 50505300 (PPS) Stratum : 1 System time : 0.000000042 seconds fast of NTP time Last offset : +0.000000038 seconds RMS offset : 0.000000051 seconds Frequency : 2.187 ppm fast Residual freq : +0.000 ppm Skew : 0.003 ppm Root delay : 0.000000001 seconds Root dispersion : 0.000000015 seconds
42 наносекунды отклонения. DCF77 при этом показывает ~3 мс — нормально для радиосигнала через 1500 км.
Что можно улучшить
Survey-In режим для NEO-M8T (
CFG-TMODE2) — приёмник несколько часов усредняет свою позицию и фиксирует её. После этого PPS-точность улучшается ещё на 20–30%.Аппаратный PPS на UART DCD — некоторые UART-контроллеры поддерживают захват метки времени на пине DCD с точностью до такта. Для RPi требует кастомного DTS-оверлея.
