沁恒CH32V103 RISC-V MCU实战:从PWM呼吸灯入门到外设驱动解析
1. 初识沁恒CH32V103:RISC-V MCU新选择
第一次拿到CH32V103开发板时,我注意到它比常见的STM32板子更小巧。这款由南京沁恒微电子推出的RISC-V架构MCU,最吸引我的是它80MHz主频和丰富的外设资源。作为国产芯片,它的性价比确实让人惊喜——64KB Flash加上20KB SRAM,完全能满足大多数嵌入式项目的需求。
记得当时我特意对比了不同型号的区别:CH32V103R8T6是48脚LQFP封装,而C系列是64脚版本。对于呼吸灯这种基础实验,R8T6已经绰绰有余。开发环境搭建也很简单,官方提供的MounRiver Studio基于Eclipse,支持标准的RISC-V GCC工具链,从安装到编译第一个程序不超过15分钟。
2. 呼吸灯背后的硬件原理
2.1 PWM是如何让LED"呼吸"的
呼吸灯效果本质上是通过PWM(脉冲宽度调制)控制LED亮度渐变。就像用开关快速点亮熄灭灯泡,当开关频率够高时(通常>100Hz),人眼看到的就是持续亮度。PWM通过调节高电平时间占比(占空比)来控制亮度——占空比0%时LED全灭,100%时最亮。
在CH32V103上,这个功能由定时器模块实现。以TIM1为例,它有个自动重装载寄存器ARR决定PWM周期,捕获比较寄存器CCR决定高电平持续时间。通过不断修改CCR值,就能产生渐亮渐暗的效果。实测发现,当PWM频率设置在100Hz-1kHz时,呼吸效果最平滑。
2.2 硬件连接注意事项
开发板上的LED通常已经连接了限流电阻,但自己外接LED时要注意:
- 典型LED工作电流5-20mA
- 计算公式:R=(Vcc-Vf)/I
- CH32V103的GPIO输出电压约3.3V
- 红色LED正向压降约1.8V
比如驱动10mA红色LED:(3.3V-1.8V)/0.01A=150Ω。我习惯用220Ω电阻,既保证亮度又留有余量。还要注意GPIO的驱动能力,CH32V103单个IO最大可输出25mA,但整个端口总和不要超过80mA。
3. 从零编写PWM驱动代码
3.1 时钟树配置实战
CH32V103的时钟系统比ARM MCU简单很多,但也需要正确初始化。呼吸灯实验我通常使用内部8MHz HSI时钟,通过PLL倍频到72MHz:
void Clock_Init(void) { RCC_DeInit(); //复位时钟配置 RCC_HSEConfig(RCC_HSE_OFF); //关闭外部时钟 RCC_HSICmd(ENABLE); //开启内部8MHz时钟 while(RCC_GetFlagStatus(RCC_FLAG_HSIRDY) == RESET); //等待时钟就绪 RCC_PLLConfig(RCC_PLLSource_HSI_Div2, RCC_PLLMul_18); //8MHz/2*18=72MHz RCC_PLLCmd(ENABLE); while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); //系统时钟选择PLL while(RCC_GetSYSCLKSource() != 0x08); //等待切换完成 }这里有个坑:PLL输入时钟不能超过8MHz,所以需要先二分频。如果直接使用8MHz*9=72MHz会导致芯片不稳定。
3.2 定时器PWM模式详解
配置TIM1的通道1输出PWM需要多个步骤:
void PWM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 使能TIM1和GPIOA时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1 | RCC_APB2Periph_GPIOA, ENABLE); // 配置PA8为复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器基础设置 TIM_TimeBaseStructure.TIM_Period = 999; // PWM周期=1000 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz/72=1MHz TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); // PWM模式配置 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM2; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // 初始占空比50% TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM1, ENABLE); TIM_CtrlPWMOutputs(TIM1, ENABLE); TIM_Cmd(TIM1, ENABLE); // 启动定时器 }这段代码会产生1kHz的PWM波(1MHz/1000)。特别注意TIM_OCMode_PWM2模式与PWM1的区别:PWM1是CNT<CCR时输出有效电平,PWM2是CNT≥CCR时输出有效电平。
4. 实现平滑呼吸效果
4.1 动态调整占空比算法
简单的线性变化会让呼吸灯显得机械。我参考了Breathing LED的常用算法,使用三角函数曲线让变化更自然:
void Breath_LED_Update(void) { static uint16_t counter = 0; static uint8_t direction = 0; uint16_t brightness; // 使用正弦曲线计算亮度 brightness = (sin(counter * 3.14159 / 180) + 1) * 500; // 映射到0-1000 TIM_SetCompare1(TIM1, brightness); // 更新占空比 counter += 2; if(counter >= 360) counter = 0; Delay_Ms(20); // 控制呼吸速度 }这个实现中,brightness会在0-1000之间平滑变化。调整Delay_Ms参数可以改变呼吸节奏,我实测20ms间隔效果最接近自然呼吸。
4.2 使用DMA自动更新PWM
当系统需要处理其他任务时,频繁调用TIM_SetCompare会影响实时性。这时可以用DMA自动搬运占空比数据:
void PWM_DMA_Config(void) { DMA_InitTypeDef DMA_InitStructure; uint16_t pwm_buffer[100]; // 存储100个占空比值 // 填充正弦波数据 for(int i=0; i<100; i++){ pwm_buffer[i] = (sin(i * 3.14159 / 50) + 1) * 500; } RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM1->CCR1; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)pwm_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 100; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); TIM_DMACmd(TIM1, TIM_DMA_Update, ENABLE); DMA_Cmd(DMA1_Channel5, ENABLE); }这种方法完全解放了CPU,DMA会循环将预计算的波形数据搬运到CCR寄存器。如果需要动态修改波形,只需更新pwm_buffer数组即可。
5. 进阶:多通道PWM同步控制
5.1 主从定时器配置
CH32V103支持定时器同步,可以实现多路PWM完全同步输出。比如用TIM1作为主定时器,TIM2作为从定时器:
void Timer_Sync_Config(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; // TIM1主模式配置 TIM_SelectOutputTrigger(TIM1, TIM_TRGOSource_Update); TIM_SelectMasterSlaveMode(TIM1, TIM_MasterSlaveMode_Enable); // TIM2从模式配置 TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_SelectInputTrigger(TIM2, TIM_TS_ITR0); // 使用TIM1作为触发源 TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Trigger); TIM_Cmd(TIM2, ENABLE); }这样TIM2的计数器会与TIM1完全同步,特别适合需要精确控制多个LED的场景,比如RGB调光。
5.2 硬件互补PWM输出
对于需要死区控制的电机驱动应用,CH32V103的高级定时器TIM1支持互补PWM输出:
void Complementary_PWM_Init(void) { TIM_BDTRInitTypeDef TIM_BDTRInitStructure; // 常规PWM配置... // 死区时间配置(单位:时钟周期) TIM_BDTRInitStructure.TIM_DeadTime = 72; // 1us死区@72MHz TIM_BDTRInitStructure.TIM_LOCKLevel = TIM_LOCKLevel_1; TIM_BDTRInitStructure.TIM_Break = TIM_Break_Disable; TIM_BDTRInitStructure.TIM_BreakPolarity = TIM_BreakPolarity_Low; TIM_BDTRInitStructure.TIM_AutomaticOutput = TIM_AutomaticOutput_Enable; TIM_BDTRConfig(TIM1, &TIM_BDTRInitStructure); // 使能互补输出 TIM_OCInitStructure.TIM_OutputNState = TIM_OutputNState_Enable; TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_CtrlPWMOutputs(TIM1, ENABLE); }这个配置可以产生带死区的互补PWM信号,直接用于驱动半桥电路。实测发现死区时间至少要设置50ns以上才能避免上下管直通。
