Когда я начал работать с CH32V203G6U6 и обнаружил, что там есть 64-битный аппаратный таймер, то подумал: ух, это же отлично, раз таймер аппаратный, то и о чтении/записи заботится сам микроконтроллер. И какое-то время это не вызывало вопросов. А потом…
А потом испытатели изделия, которое содержит устройство с этим МК, «мягко» заявили, что устройство не работает. Из логов конечного изделия было видно, что uptime устройства, который должен был монотонно возрастать, превратился в кривую.
Ну окей, подумал я, давайте разбираться. Видим сброс времени по логам, видим нештатное снаружи. Первая мысль: гуляем по памяти. Тем более что операций с памятью в коде с помощью указателей предостаточно. Анализ кода позволил найти некоторые потенциальные ошибки в работе с указателями, но их фикс ситуацию не изменил. Какие ещё варианты: watchdog или hard fault. Ни то ни другое не подтвердилось — устройство не перезагружалось. Описали проблему коллегам, которые уже работали с этим микроконтроллером, но получили примерно следующий ответ: не используйте SysTick, возьмите какой-нибудь TIM. Да, совет оказался рабочим, но что за фокусы?!
Понаставили брейкпойнтов и в конце концов поймали момент скачка. Стабильности и повторяемости не было, мы могли увидеть скачок то в первую минуту, то через час. В дебаге увидели, что uptime содержит значение, которого в этот момент там быть не должно. Было приятно — поймали всё-таки, но ясности не прибавилось. Тем не менее при очередной остановке, ковыряясь в цепочке вычислений, мы обнаружили, что в регистрах SysTick лежит не то же самое значение, что в uptime. Посчитали, покрутили биты, появилось предложение по воспроизведению ошибки: заполнить младшие 32 бита счётчика. И снова ждать, на этот раз случилось быстрее. Потом ещё и ещё.
Лучшее, что можно сделать с ошибкой, не считая её исправления, — научиться воспроизводить.
Проблема возникает в момент чтения 64-битного счётчика SysTick->CNT. Было очевидно, что нужно всего-то запретить прерывания на время чтения. Окружаем чтение классическим набором __disable_irq() / __enable_irq(), и… ничего, ничего не меняется в конечном поведении…
Тогда, год назад, мы оставили дальнейшие попытки разобраться, время поджимало, TIM4 работал исправно (и до сих пор работает).
Но вот, у меня появились время и ChatGPT, и я решил разворошить старое.
Почему обычное чтение не работает
Тогда всю критичность мышления подавила вера в то, что МК сам заботится о чтении этого регистра, но 64-битное значение нельзя прочитать одной обычной операцией: ядро всё равно отдельно читает младшие и старшие 32 бита, STK_CNTL и STK_CNTH соответственно.
В стандартном заголовочном файле WCH поле CNT структуры SysTick_Type объявлено как volatile uint64_t. Поэтому строка
uint64_t ticks = SysTick->CNT;
выглядит как одно чтение. Ключевое слово volatile гарантирует, что компилятор действительно обратится к аппаратным регистрам и не удалит эти операции. Но оно не объединяет два обращения в одну атомарную операцию. И для 32-битного ядра это всё равно две отдельные инструкции: одна читает младшие 32 бита из STK_CNTL, а другая — старшие из STK_CNTH. Между этими чтениями проходит пусть небольшое, но не нулевое время. Вот тут-то и порылась собака.

Что в этот момент происходит внутри микроконтроллера
Здесь полезно разделить сам счётчик и способ доступа к нему. Внутри SysTick действительно работает один 64-битный аппаратный счётчик, но 32-битное ядро QingKe V4 видит его через два окна: STK_CNTL для младшей половины и STK_CNTH для старшей. Эти регистры отображены в адресное пространство периферии, поэтому ядро получает каждую половину отдельной 32-битной инструкцией загрузки, а затем собирает из них 64-битное значение. Объявление volatile uint64_t существует только на уровне языка C и не превращает два обращения к регистрам в одно.
Пока ядро выполняет эти операции, SysTick продолжает считать. Первое чтение не останавливает счётчик и, судя по описанию периферии, не защёлкивает вторую половину. Получается следующая последовательность: ядро читает одну половину, счётчик может сделать ещё несколько шагов, затем ядро читает вторую половину. Обычно это не имеет значения, потому что старшие 32 бита остаются прежними. Но при переходе младшей части с 0xFFFFFFFF на 0x00000000 возникает перенос в старшую часть, и два чтения могут относиться уже к разным состояниям счётчика.
Именно поэтому отключение прерываний само по себе не решает исходную проблему. Оно не даёт обработчику вклиниться между чтениями, но не останавливает SysTick и не превращает два обращения к периферии в одно.
Проблема возникает, если STK_CNTL переполняется между двумя чтениями. Например, счётчик переходит из состояния
CNTH = 0x00000015 CNTL = 0xFFFFFFFF
в состояние
CNTH = 0x00000016 CNTL = 0x00000000
Предположим, ядро сначала прочитало CNTL и получило 0xFFFFFFFF. Сразу после этого произошёл перенос, и при чтении CNTH ядро получило уже 0x00000016. В результате программа соберёт:
ticks = 0x00000016FFFFFFFF
Такого состояния у счётчика в течение этих двух чтений не было: первое значение находилось около 0x00000015FFFFFFFF, а второе — около 0x0000001600000000. Склеенный результат опережает реальное время почти на 2^32 тактов. Следующее чтение снова даст нормальное значение, и uptime резко вернётся назад.
Если SysTick тактируется от HCLK с частотой 96 МГц, младшие 32 бита переполняются через 2^32 / 96 000 000 ≈ 44,739 с. Это не означает, что ошибка будет возникать при каждом переполнении. Опасное окно занимает всего несколько машинных инструкций, поэтому дефект может проявляться редко и выглядеть случайным.
Так как же читать SysTick правильно?
Нужно дважды прочитать старшую часть и убедиться, что между чтениями она не изменилась.
Если CNTH не изменился, прочитанное значение CNTL соответствует той же старшей части счётчика. Если изменился, значит, CNTL успел переполниться и его нужно прочитать ещё раз — уже для нового значения CNTH.
#include "ch32v20x.h" #define SYSTICK_CNTL \ (*(volatile uint32_t *)((uintptr_t)&SysTick->CNT)) #define SYSTICK_CNTH \ (*(volatile uint32_t *)((uintptr_t)&SysTick->CNT + sizeof(uint32_t))) uint64_t systick_get_ticks(void) { const uint32_t hi_before = SYSTICK_CNTH; uint32_t lo = SYSTICK_CNTL; const uint32_t hi_after = SYSTICK_CNTH; if (hi_before != hi_after) { lo = SYSTICK_CNTL; } return ((uint64_t)hi_after << 32) | lo; }
Выполняются три обязательных чтения и, при попадании в узкое окно переполнения, одно дополнительное. Алгоритм предполагает, что CNTL не переполнится дважды за время выполнения функции. Проверка CNTH → CNTL → CNTH обеспечивает согласованность самого значения, а запрет прерываний нужен, чтобы ограничить время выполнения этой последовательности.
Доверяй, но прерывай
Если чуть развить мысль про прерывания, то становится понятно, что между чтениями может вклиниться обработчик прерывания и задержать выполнение функции systick_get_ticks(). А на этот раз хочется доверять счётчику.
Чтобы исключить такую задержку, на время чтения можно запретить прерывания. На первый взгляд достаточно окружить чтение привычной парой вызовов __disable_irq() / __enable_irq(). Загвоздка в том, что __enable_irq() безусловно разрешает прерывания, а не восстанавливает состояние, существовавшее до вызова __disable_irq(). Например, systick_get_ticks() может быть вызвана из более крупной критической секции:
__disable_irq(); update_shared_data(); const uint64_t ticks = systick_get_ticks(); finish_update(); __enable_irq();
То есть если внутри systick_get_ticks() безусловно вызвать __enable_irq(), прерывания включатся раньше, чем ожидает вызывающий код, — ещё до finish_update(). Такая же проблема возникает при вложенных вызовах и при вызове функции из обработчика прерывания.
Поэтому функция должна не просто выключить прерывания, а выполнить три действия:
Сохранить их текущее состояние.
Запретить их на время чтения SysTick.
Восстановить сохранённое состояние перед выходом.
Такой подход не является чем-то новым или специфичным для SysTick. Сохранение состояния прерываний при входе в критическую секцию и его восстановление при выходе применяется и в других SDK. Например, в компонентах STM32Cube при входе в критическую секцию сохраняется значение PRIMASK, после чего вызывается __disable_irq(), а при выходе прежнее состояние восстанавливается через __set_PRIMASK(). Здесь используется тот же принцип, но реализованный через gintenr ядра QingKe V4.
В используемом стандартном startup WCH прикладной код не имеет прямого доступа к регистру mstatus, поэтому сохранить и затем восстановить его из обычной функции нельзя. Для управления глобальным разрешением прерываний WCH предоставляет доступный приложению CSR gintenr, который отображает только биты MIE и MPIE регистра mstatus. Это позволяет работать с состоянием прерываний, не затрагивая остальные поля mstatus.
Регистр gintenr
В QingKe V4 для управления глобальным разрешением прерываний предусмотрен специальный регистр gintenr (Global Interrupt Enable Register).
Регистр | Номер CSR | Доступ | Назначение |
|---|---|---|---|
|
| URW (User Read/Write) | Глобальное разрешение и блокировка маскируемых прерываний; отображение битов |
gintenr является вендорным CSR от WCH и привязан к ядрам QingKe V4. Регистр служит отображением относящихся к прерываниям битов mstatus. Преимущество gintenr в том, что можно работать только с состоянием глобального разрешения прерываний, не записывая целиком весь mstatus. Работа с gintenr не изменяет индивидуальные настройки источников в PFIC и не распространяет свое влияние на NMI и исключения.
В регистре отображаются следующие два поля mstatus:
Бит | Название | Доступ | Описание | Значение после сброса |
|---|---|---|---|---|
7 |
| MRW | Состояние разрешения прерываний до входа в обработчик. При входе в прерывание или исключение сюда копируется предыдущее значение | 0 |
3 |
| MRW | Глобальное разрешение прерываний в машинном режиме: | 0 |
На этой основе можно сделать две вспомогательные функции: одна сохраняет состояние MIE и MPIE и запрещает прерывания, другая восстанавливает сохранённое состояние.
#define GINTENR_MIE_MASK (1UL << 3) #define GINTENR_MIE_MPIE_MASK ((1UL << 3) | (1UL << 7)) static inline uint32_t irq_save_and_disable(void) { uint32_t saved_state; __asm volatile ( "csrrc %0, 0x800, %1\n" "fence.i" : "=r" (saved_state) : "r" (GINTENR_MIE_MPIE_MASK) : "memory" ); return saved_state; } static inline void irq_restore(uint32_t saved_state) { const uint32_t state_to_restore = saved_state & GINTENR_MIE_MPIE_MASK; if ((state_to_restore & GINTENR_MIE_MASK) == 0U) { __asm volatile ( "csrw 0x800, %0\n" "fence.i" : : "r" (state_to_restore) : "memory" ); } else { __asm volatile ( "csrw 0x800, %0" : : "r" (state_to_restore) : "memory" ); } }
Чуть глубже про csrrc, csrw и fence.i
При входе в критическую секцию нужно сохранить текущее состояние прерываний и сразу же запретить их. Инструкция csrrc выполняет оба действия над gintenr за одно обращение к CSR: записывает его прежнее значение в saved_state и очищает биты, заданные маской MIE | MPIE. Поэтому между сохранением состояния и изменением регистра нет отдельной инструкции, во время которой мог бы быть вызван обработчик.
После изменения gintenr выполняется fence.i. Такую же последовательность использует штатная функция __disable_irq() из SDK WCH: сначала она очищает биты MIE и MPIE, затем выполняет fence.i. В нашей функции эта инструкция гарантирует, что новое состояние прерываний будет учтено ядром до первого чтения SysTick.
При выходе сохранённые значения MIE и MPIE записываются обратно инструкцией csrw. Это псевдоинструкция для csrrw x0, csr, rs1: прежнее содержимое CSR отбрасывается, а новое записывается целиком. Поэтому восстановление не приходится разбивать на отдельные очистку и установку битов.
Если восстановлено состояние с MIE = 0, после csrw выполняется fence.i, чтобы ядро учло запрет до продолжения программы. При MIE = 1 восстановление, как и штатная __enable_irq() из SDK WCH, заканчивается без fence.i. Ожидающее прерывание после этого может быть принято сразу — защищённая последовательность чтений к этому моменту уже завершена.
Итого
Теперь отключение прерываний не останавливает SysTick, а лишь не позволяет обычному ISR задержать выполнение между чтениями. Благодаря этому CNTL не успеет переполниться дважды из-за задержки в обработчике.
uint64_t systick_get_ticks(void) { const uint32_t irq_state = irq_save_and_disable(); const uint32_t hi_before = SYSTICK_CNTH; uint32_t lo = SYSTICK_CNTL; const uint32_t hi_after = SYSTICK_CNTH; if (hi_before != hi_after) { lo = SYSTICK_CNTL; } const uint64_t ticks = ((uint64_t)hi_after << 32) | lo; irq_restore(irq_state); return ticks; }
Ну и никакой мистики в поведении uptime не оказалось. Сам SysTick всё это время работал как ему и полагается. Ошибкой было считать, что объявленный как uint64_t аппаратный регистр читается одной атомарной операцией на 32-битном ядре.
UPD: @KivApple сделал ценное замечание, поэтому чуть формализую его посыл. Когда вызов функции завершается быстрее периода переполнения CNTL, для согласованного чтения не нужны ни цикл, ни запрет прерываний:
uint64_t systick_get_ticks(void) { const uint32_t hi_before = SYSTICK_CNTH; uint32_t lo = SYSTICK_CNTL; const uint32_t hi_after = SYSTICK_CNTH; if (hi_before != hi_after) { lo = SYSTICK_CNTL; } return ((uint64_t)hi_after << 32) | lo; }
Переполнение CNTL обнаруживается по изменению CNTH и компенсируется повторным чтением младшей части. Ошибка возможна только в том случае, если CNTL успеет переполниться второй раз в рамках того же вызова. При частоте SysTick 96 МГц между двумя переполнениями проходит около 44,7 секунды, поэтому в нормально работающей прошивке это условие выполняется с большим запасом.
При подготовке статьи использовался OpenAI Codex с моделью gpt-5.6-sol в режиме рассуждения high. AI помогал с поиском и сверкой документации, анализом кода и редактированием текста.