Приветствую вас, читатель! В этой статье я хотел бы немножко рассказать о бюджетных микроконтроллерах серии CH32V003 с ядром RISC-V2A от Nanjing Qinheng Microelectronics Co., Ltd и о том, как я до них "докатился". Этого производителя мы знаем по широко известным микросхемам CH340 (USB-UART), CH341 (USB-GPIO), CH552G (8051 МК с USB). Хоть статья и пишется в конце 2025 года, однако CH32V003 совсем не новинка. Еще в 2022 году мой коллега Марат уже что-то на них делал, а в 2023 мы с Евгением даже заказали несколько отладочных плат на Aliexpress. В те "голодные" годы (был кризис чипов), этот микроконтроллер был очень заманчивым, его стоимость была 24 рубля за полноценные 32 бита и богатство периферии! Так, для справки, в конце 2023 года цена на популярные STM32F030F4P6 подскочила до 130 рублей, а за STM32F407VET6 просили чуть больше 2000 руб.
Мы купили таки себе отладочные платы CH32V003F4P6 дабы освоить эти камни и дать миру много дешевых конкурентоспособных изделий.. Но не дали. Ни в 2023, ни в 2024. Не до них было: то работы полно, то просто страшно сесть и "убить" несколько дней на "учебу ради учебы". А вот в 2025 году возникла реальная потребность в них. И тут хочешь, не хочешь - осваивай.
Коллеги из Белгорода прислали сварочный аппарат Ресанта САИ 250 ПН. Бюджетный, надежный и жутко популярный. С какого-то времени платы управления этих аппаратов стали делать на микроконтроллерах. Вы понимаете, что если этот чип нагнётся, то весь инвертор просто отправится на помойку. Сервисные центры по ремонту сварочного оборудования просто откажутся от ремонта платы управления такого аппарата. На мои плечи была возложена мини миссия: создать такой чип для замены сгоревших. Китайцы использовали микроконтроллер HC89S003F4P6 (Enhanced 8051, 8 bit, TSSOP-20) коих в наших лабазах не сыскать. Я посмотрел, а CH32V003F4P6 функционально подходит как замена пин-в-пин. Я даже больше скажу, он сгодится для замены STM8S003F3P6 (без этих вот VCAP).
Я приступил к написанию программного обеспечения для сварочного аппарата в июне 2025 и справился примерно за 6 дней (по остаточному принципу, 1-2 часа в день). Я провел испытания сварочного аппарата на строительном участке, отшлифовал режим VRD и очень был доволен проделанной работой. Этот проект уже помог большому количеству мастеров в ремонте и продолжает помогать, мы его торгуем на Ozon https://ozon.ru/t/oEX4gID

А микроконтроллер CH32V003F4P6 заслужил мое уважение и свое место в сердце. Конец...
...еще не скоро. Я расскажу все, что мне особым образом запомнилось при работе с этими камнями, и многим это пригодится.
Подготовка
Архитектура RISC-V (читаем как Риск Пять) - открытый общественный стандарт, прямо таки звезда нашей с вами современности. Наверное пройдет 5-10 лет и ARM с Intel'ом останутся не у дел (но это не точно (с)). Инженеры из Нанкина доработали 32-битное ядро RISC-V под себя, получился QingKe RISC-V2A, который и используется в CH32V003. А его компилятор есть в составе GCC. Производитель чипов сделал на базе Eclipse студию разработки MounRiver и распространяет ее бесплатно.
Программатор для этих чипов WCH-Link продается на маркетплейсах за копейки. Названия вроде одни и те же, а выглядят по-разному. Я лично использую такой как на картинке ниже. Он когда-то продавался с набором: отладочная плата + программатор + 5 шт ИМС в футлярчике.

Микросхема выпускается в различных корпусах SSOP-20, QFN-20, SOIC-8, SOIC-16. Неизменно содержит в себе 16 кБ Флеша и 2 кБ SRAM. Даташит на нее https://static.chipdip.ru/lib/164/DOC045164018.pdf

Разработка для этого микроконтроллера начинается с установки среды разработки MounRiver Studio которую можно скачать по ссылке https://cloud.as.life/s/HGWH5YfDHxSDk4w
После установки данной студии, она предложит обновить компоненты. Выбираем все, хуже не будет.

Для того, чтобы создать новый проект выбираем File → New MounRiver Project

Выбираем интересующий нас камень CH32V004F4P6, снимаем галочку с Use solution location, чтобы вручную выбрать локацию проекта. Нажимаем finish. В фоновом режиме мастер создания проекта создаст все необходимые файлы Eclipse проекта. Если у вас активно окно Welcome, то можете его закрыть. Вы увидите мощный такой Hello, World с прописанным стартап кодом и базовой инициализацией UART периферии.

Давайте выберем Project → Build All для сборки и компиляции этого шаблонного кода.
Может быть вам будет интересно, что студия по умолчанию создает исходные файлы в экзотической для нас кодировке. Исходники проблематично будет открывать в блокноте. Если это для вас имеет значение, то исправляйте сразу же (файл, ПКМ -> Properties).

Структура кода и способ инициализации системы очень напоминают StdPeripheral Library STM32. И хотя это совсем не одно и тоже, зная подход с StdPeripheral вы будете лучше ориентироваться в коде инициализации.
Так, например, вы можете быстро указать интересующую вас тактовую частоту с использованием кварцевого резонатора (и без него).

В функции main, для настройки тактирования вызовите SetSysClock() (из <ch32v00x.h>)
Основы работы с GPIO
Принцип инициализации очень похож на STM32. Чтобы пользовать GPIO, нужно включить его тактирование
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // Вкл тактирование GPIOA
Дальнейшая конфигурация будет делаться через структуру GPIO_Init.
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_10MHz; // Средняя скорость
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // Выход Push-pull
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; // PA1
GPIO_Init(GPIOA, &GPIO_InitStructure); // Инициализировать PA1 как выход Push-Pull
Менять состояние выхода можете командой
GPIO_WriteBit(GPIOA, GPIO_Pin_1, 1);
GPIO_WriteBit(GPIOA, GPIO_Pin_1, 0);
А читать состояние пина (если это вход)
state = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_2);
Больше информации вы найдете в заголовочном файле <ch32v00x.h>
Сейчас я покажу вам наиболее ценную информацию, которую собирать было достаточно хлопотно.
Защита от чтения и PD7 как I/O
Эти вещи выставляются байтами опций, работа с которыми хорошо описана в разделе 16.5 Reference Manual. Я лишь приведу рабочий код, который одновременно выставляет нужные мне опции "Защита от чтения + PD7 как I/O а не Reset".
// Установка байтов опций
void UserOptionBytesSet(void) {
// Прочесть пользовательские опции
uint16_t * ob16p = (uint16_t *)OB_BASE;
if ((ob16p[0] == 0x5AA5) || (ob16p[1] != 0x00FF)) { // Защиты от чтения нет, PD7 как NRST
FLASH->KEYR = 0x45670123;
FLASH->KEYR = 0xCDEF89AB; // Разлочить системную Flash
FLASH->OBKEYR = 0x45670123;
FLASH->OBKEYR = 0xCDEF89AB; // Разлочить OB
while (FLASH->STATR & FLASH_BUSY) __NOP(); // Дождаться освобождения Flash
FLASH->CTLR |= FLASH_CTLR_OPTER; // Флаг "стереть опции" поставить
FLASH->CTLR |= FLASH_CTLR_STRT; // Запустить процедуру стирания. Флаг сотрется аппаратно
while (FLASH->STATR & FLASH_BUSY) __NOP(); // Дождаться освобождения Flash
FLASH->CTLR &= ~(FLASH_CTLR_OPTER); // Флаг "стереть опции" стереть
FLASH->CTLR |= FLASH_CTLR_OPTPG; // Флаг "записывать опции" поставить
while (FLASH->STATR & FLASH_BUSY) __NOP(); // Дождаться освобождения Flash
ob16p[0] = 0xFF00; // Поставить защиту от чтения
while (FLASH->STATR & FLASH_BUSY) __NOP(); // Дождаться освобождения Flash
ob16p[1] = 0x00FF; // Отключить NRST, чтобы PD7 был как I/O
FLASH->CTLR &= ~(FLASH_CTLR_OPTPG); // Флаг "записывать опции" стереть
FLASH->CTLR |= FLASH_CTLR_LOCK; // Залочить флешку
while (FLASH->STATR & FLASH_BUSY) __NOP(); // Дождаться освобождения Flash
}
}
Вход PD1 как I/O
Вход PD1 изначально служит для отладки, тем не менее, его можно использовать в качестве входа без каких-либо ухищрений, но он очень плохо работает в качестве выхода, хоть Open Drain, хоть Push-Pull – его ток ничтожно мал, 73 мкА (работает только резистор подтяжки). И так как он по умолчанию служит для отладки, его нужно как-то сделать "обычным" и потерять функцию отладки. Чтобы сделать его полноценным выходом с хорошим выходным током, запрещаем отладку (программатор потеряет доступ к МК через PD1 (SWIO), но через утилиту и кнопку Reset можно стереть память, чтобы вернуть доступ снова). Или можно в main, сделать задержку на отмену SWIO, секунды 3. То есть в первые 3 секунды после подачи питания SWIO работать будет.
//Сделать PD1 выходом и запретить отладку
void GPIO_Toggle_INIT(void) {
GPIO_InitTypeDef GPIO_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
GPIO_PinRemapConfig(GPIO_Remap_SDI_Disable,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOD, &GPIO_InitStructure);
}
Прерывание и SysTick
Для того, чтобы в программе работали прерывания вообще, нужно начать код main() с
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
Пример настройки SysTick на прерывание каждые 100 мс
void SysTick_Init(void) {
NVIC_EnableIRQ(SysTicK_IRQn);
// Настройка SysTick на прерывание каждые 100 мс
// При тактовой частоте 48 МГц:
// 48 000 000 / 8 = 6 000 000 (делитель на 8)
// 6 000 000 / 600 000 (10 раз в секунду = каждые 100 мс)
SysTick->CTLR = 0;
SysTick->SR = 0;
SysTick->CNT = 0;
SysTick->CMP = 600000 - 1; // Сравниваемое значение
SysTick->CTLR = 0xB; // Включаем таймер, прерывания и делитель на 8
}
Внутри файла добавляем обработчик прерывания
void SysTick_Handler(void) {
t100ms = true; // Булевый флаг, может быть объявлен в main.c как volatile bool t100ms = false; и объявлен в ch32v00x_it.c как extern bool t100ms;
SysTick->SR = 0; // Сбрасываем флаг прерывания
}
Инициализация АЦП и азы
Для примера сделаем вход PA1 аналоговым
void User_ADC_Init(void) {
//////// Инициализация АЦП /////////
ADC_InitTypeDef ADC_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // Вкл тактирование GPIOA
RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); // Тактировать АЦП
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AIN;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1; // PA1
GPIO_Init(GPIOA, &GPIO_InitStructure); // GPIOA ADCs
// Конфигурация АЦП
ADC_InitStructure.ADC_Mode = ADC_Mode_Independent; // Независимый режим
ADC_InitStructure.ADC_ScanConvMode = DISABLE; // Одиночное преобразование
ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // Одиночный режим (не непрерывный)
ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None; // Запуск по софту
ADC_InitStructure.ADC_DataAlign = ADC_DataAlign_Right; // Выравнивание по правому краю
ADC_InitStructure.ADC_NbrOfChannel = 1; // 1 канал
ADC_Init(ADC1, &ADC_InitStructure);
// Включение АЦП
ADC_Cmd(ADC1, ENABLE);
// Калибровка АЦП (обязательно!)
ADC_ResetCalibration(ADC1);
while(ADC_GetResetCalibrationStatus(ADC1));
ADC_StartCalibration(ADC1); // И еще раз (так делают)
while(ADC_GetCalibrationStatus(ADC1));
}
Функция выбора канала АЦП
// Установка канала
void ADC_SelectChannel(uint8_t channel) {
ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_241Cycles);
}
Для неблокирующего измерения каналов АЦП я использовал такой механизм
// Запустить замер и забрать результат
// вернет true, если замер готов. Или false, если не готов
bool ADC_Read_Single(uint8_t channel, uint16_t * value) {
static bool adcConversionStarted = false; // Флаг того, что замер уже начался
if (adcConversionStarted) {
if (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET) { // Замер еще не окончен
return false;
}
else { // Замер окончен, предоставить результат
*value = ADC_GetConversionValue(ADC1);
adcConversionStarted = false;
return true;
}
}
else { // Замер еще не стартовал
ADC_SelectChannel(channel); // Выбор канала
ADC_SoftwareStartConvCmd(ADC1, ENABLE); // Запуск преобразования
adcConversionStarted = true;
return false;
}
}
Алгоритм неблокирующей обработки АЦП с перебором каналов
// Обработка АЦП
void Process_ADC(void) {
static uint8_t currentADCChannel = ADC_CURRENT;
static uint16_t lastADCValue = 0;
if (ADC_Read_Single(currentADCChannel, &lastADCValue)) { // Если получили значение АЦП
switch (currentADCChannel) {
case ADC_Channel_1:
// Обработать, переключить другой канал
currentADCChannel = ADC_Channel_2;
break;
case ADC_Channel_2:
// Обработать, переключить другой канал
currentADCChannel = ADC_Channel_1;
break;
default:
currentADCChannel = ADC_Channel_1; // Если не обозначен канал, то выставить канал 1
}
}
}
И этот код обработки может крутиться в бесконечном основном цикле (в main())
int main(void) {
uint32_t i;
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
#ifdef IT_RELEASE
UserOptionBytesSet(); // Установка байтов опций (защита от чтения, PD7 как I/O)
#endif
SetSysClock();
SystemCoreClockUpdate(); // Настройка RCC на 48 МГц
i = 6000000;
while(i--) __NOP(); // 2 секунды после включения доступна отладка
User_GPIO_Init();
User_ADC_Init();
User_PWM_Init();
SysTick_Init(); // Настройка прерываний каждые 100 мс
for (;;) { // Бесконечный цикл
Process_ADC();
if (t100ms) {
t100ms = false;
Every100ms();
}
}
}
Заключение
В этой статье я лишь показал насколько просто программисту STM32 мигрировать на CH32V003, однако порог вхождения все таки достаточно высок. Я не описал многие темы, такие как работа с таймерами общего назначения в режиме ШИМ или любой другой периферией, заложенной в этот бюджетный камень. Однако я уверен, что разработчик, имеющий дело с StdPeriph библиотеками от STM легко и просто разберется во всем сам. Легкость освоения CH32V как раз и заключается в том, что чипмейкеры наверняка были большими фанатами философии подхода STMicroelectronics.
Комментарии приветствуются >> Пишите сюда <<
Следите за новыми статьями в нашем Telegram канале

