Когда я начал работать с 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 помогал с поиском и сверкой документации, анализом кода и редактированием текста.
Комментарии (17)

HarmEr
14.08.2026 13:15Только запрет прерываний для этой комманды не поможет. Нужно использовать специальную обертку:
```static uint64_t __riscv_get_cycle64(void)
{
#if __riscv_xlen == 64
uint64_t val;
asm volatile(
"rdcycle %0"
: "=r"(val));
return val;
#else
uint32_t hi1, hi2, low;
do
{
asm volatile(
"rdcycleh %0\n"
"rdcycle %1\n"
"rdcycleh %2"
: "=r"(hi1), "=r"(low), "=r"(hi2));
} while (hi1 != hi2);
return ((uint64_t)hi1 << 32) | low;
#endif
}
```

KivApple
14.08.2026 13:15Я бы завернул в цикл просто вместо одиночного if. И даже без запрета прерываний оно бы в итоге смогло считать корректное значение.
Впрочем, и без цикла запрет прерываний не так уж нужен. Не исполняются обработчики прерываний десятки секунд. А если исполняются, то у нас уже всё сломалось.

bchirkov Автор
14.08.2026 13:15Не исполняются обработчики прерываний десятки секунд. А если исполняются, то у нас уже всё сломалось.
Согласен. Можно отказаться и от цикла, и от прерывания

andi123
14.08.2026 13:15Это реальная задача из собеса в яндекс. Как прочитать 64-битный каунтер 32-битными операциями.

jingobo
14.08.2026 13:15Как увидел uint64_t сразу стало понятно, подобная ошибка достаточно типичная. К примеру на MS430 такое сплошь и рядом, там даже операция чтения uint16_t атомарная, но в силу асинхронного источника тактирования - может разъехаться старший и младший байт. Прерывания я бы не маскировал, просто бесконечный цикл пока два чтения расходятся.

devprodest
14.08.2026 13:15Ну это же типичная ситуация. Да и самая часто встречающаяся задачка на всяких интервью ☺️
Amomum
Как интересно, счетчик сделали 64-битным, а атомарное 64-битное чтение не завезли?
Неужто инструкция LD неатомарная?
LinkToOS
В микроконтроллерах обычно предусмотрен аппаратный механизм, обеспечивающий “атомарность” при чтении таких регистров. Одна часть читается напрямую, а вторая через промежуточный регистр-защелку. При чтении младшей части, старшая защелкивается в промежуточном регистре (а может наоборот). Надо просто соблюдать последовательность чтения младший-старший.
В этом контроллере что-то нестандартно сделано. Надо документацию посмотреть. Или может это ранняя ревизия, инженерный образец.
HarmEr
Это абсолютно стандрартный подход для Performance Counters, в RISC-V найти аппаратную защелку для даннго типа инструкций практически нереально. Подобное поведение описано спецификацией.
LinkToOS
Этот аппаратный механизм не связан с набором команд и конкретной архитектурой. Он применяется в ситуации, когда разрядность регистра больше чем разрядность шины данных, если младшая и старшая часть должны читаться/записываться одновременно. Это еще с древних времен используется.
On 8-bit AVR microcontrollers, accessing 16-bit I/O registers requires a strict byte-access order and a hardware temporary high-byte register to ensure atomicity. You must write the Low byte first (storing it in the temp register), then write the High byte (which triggers a simultaneous update). For reading, you must read the Low byte first (capturing the high byte into the temp register), then read the High byte.
byman
Предположим, что есть защелка. Читаем младшую часть(старшая в защелку), получаем прерывание, уходим на обработчик в котором тоже читается этот счетчик и портит защелку. Возвращаемся и читаем уже непонятно что. Разве не так?
LinkToOS
Это само собой. В статье другая проблема описана, от которой запрещение прерываний не помогает.
Прерывания в любом случае отключаются, чтобы не было проблемы связанной с устареванием считанных данных. Это в статье обозначено.
bchirkov Автор
Нет, на QingKe V4 (RV32IMAC) не завезли. STK_CNTL и STK_CNTH читаются двумя отдельными LW
Amomum
Мда, гениальное решение конечно; от создателей 24битного систика на Cortex-M
Mike-M
Китай, что с него возьмешь...