Все туториалы по «точному времени на 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

Сходимость после старта

несколько минут

секунды (makestep 0.5 3)

Работа при потере сигнала

деградация

holdover (local stratum 10)

PPS через ядро

отдельные патчи и костыли

нативный refclock 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-gabcdefPROJECT_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-оверлея.


Ссылки