☰
CH32V307多外设整合实战:总线资源与中断优先级避坑指南
2026/10/24 17:16:00 网站建设 项目流程

简介:基于CH32V307微控制器的一套外设多驱动模板,面向嵌入式开发者、物联网爱好者和电子竞赛学生,适合在项目原型、毕业设计或模块评估阶段使用,能显著减少常用外设驱动重复开发的工作量。模板整合了AHT20温湿度模块、MPU6050/ICM20602六轴陀螺仪、IMU600RA/IMU963RA九轴陀螺仪、正交编码电机、CH9141蓝牙模块、TAU1201 GPS模块、IPS1.14/ST7735S/0.96OLED屏幕以及CH573无线模块等驱动,并附一个基于ST7735S驱动128×160屏幕加AHT10显示温湿度的简单示例,便于对照验证。资源共165个文件,压缩包仅477KB;其中74个C源文件、77个头文件构成核心驱动层,另含少量库文件、链接脚本、工程配置和批处理清理脚本,目录结构清晰,可快速定位外设模块。各外设驱动独立封装,代码风格统一,便于按需裁剪与移植,适合需要系统学习CH32V307驱动框架、快速搭建多传感器显示工程的开发者。目前已有820人学习下载。 做单片机多外设整合这件事,最容易翻车的不是单个外设调不通,而是把所有东西堆到一起之后,资源冲突、总线争抢、初始化顺序全来了。我这次把AHT20温湿度、MPU6050六轴、正交编码电机、CH9141蓝牙、TAU1201 GPS和TFT屏幕全塞进CH32V307一个项目里,折腾了一个多星期,把里面那些文档不会明说的坑基本都踩了一遍,整理成这篇模板笔记,给后面要做多驱动合入的朋友当个参考。

1. 这块板子的资源账本:CH32V307凭什么扛下六个外设

先说选型。CH32V307是沁恒青稞V4F内核的RISC-V芯片,主频能到144MHz,带单精度FPU,Flash 256KB,RAM 64KB。这个配置在同类MCU里不算顶级,但它的外设丰富度确实适合做多驱动模板:I2C、SPI、USART、定时器全都不止一套,而且支持USB高速和以太网MAC,后续想往上扩功能也留了余地。

我这次的外设资源占用是这样的:

外设接口占用引脚/资源
AHT20I2C1PB6(SCL)、PB7(SDA)
MPU6050I2C1(与AHT20共用总线)同一组I2C引脚
正交编码电机TIM2编码器模式PA0、PA1(CH1/CH2)
CH9141蓝牙USART1PA9(TX)、PA10(RX)
TAU1201 GPSUSART2PA2(TX)、PA3(RX)
TFT屏幕(ST7735)SPI1PA5(SCK)、PA6(MISO)、PA7(MOSI)、PB0(CS)、PB1(DC)、PB2(RST)

这么一列就能看出来,这套方案其实是在考验三件事:I2C总线能不能稳定挂多设备、两路串口的数据处理会不会互相干扰、SPI屏幕刷新会不会抢占CPU导致其他外设响应变慢。CH32V307的APB1和APB2外设时钟可以独立配置,串口和SPI分开挂在不同的总线上,这给后续调优先级留了很大操作空间。

很多人在这一步会犯一个毛病:先把所有外设初始化代码一股脑往上堆,结果I2C和SPI引脚冲突了,或者串口中断处理函数互相抢占导致GPS数据丢帧。我建议画一个类似上面的引脚占用表,写清楚每个外设用的引脚和总线,合入之前先拿原理图过一遍,能省下大量排查时间。

2. I2C总线上挂AHT20和MPU6050:地址、时序与初始化顺序

这两个传感器都是I2C接口,我直接把它们挂在了同一条I2C1总线上。AHT20的7位地址是0x38,MPU6050的AD0引脚接地时地址是0x68,两者不冲突,物理上完全可以共存。但"地址不冲突"只是第一步,真正容易出问题的是时序和初始化顺序。

2.1 上电等待:AHT20和MPU6050谁先初始化

AHT20有个比较坑的地方:它上电后需要至少40ms才能完成内部校准,如果在这之前发测量命令,它会一直不响应或者返回错误数据。MPU6050倒是没有这么长的上电等待要求,但它对I2C的时序比较敏感,如果总线上有其他设备在通信,最好等它稳定后再操作。

我的初始化顺序是:

void sensor_init(void) { delay_ms(50); // 等待AHT20内部校准完成 aht20_init(); // 初始化AHT20,发送初始化命令0xBE mpu6050_init(); // 初始化MPU6050,配置电源管理、陀螺仪量程等 }

实测下来,如果先初始化MPU6050再初始化AHT20,AHT20偶尔会返回0xFF或者数据完全不变,这大概率是上电等待不够导致的。所以在模板里我统一在系统启动后先延时50ms,再初始化传感器,这样最稳妥。

2.2 采集频率与总线复用

AHT20的测量周期建议不要超过2Hz,因为每次测量需要约80ms才能完成,而且测量期间总线会被占用。MPU6050如果开了DMP,数据输出速率可以到100Hz甚至更高。两者共用一条I2C总线时,我的做法是:

// 每500ms采集一次AHT20,每10ms读取一次MPU6050 void sensor_task(void) { if (systick_flag_500ms) { aht20_read_data(&temp, &humi); systick_flag_500ms = 0; } if (systick_flag_10ms) { mpu6050_read_accel_gyro(&accel, &gyro); systick_flag_10ms = 0; } }

因为MPU6050的读取频率远高于AHT20,在I2C总线上每次读取MPU6050的数据包比较短(14字节),而AHT20一次测量要读6字节,但好在频率低,实测下来总线占用率不到30%,没有出现数据冲突或者ACK丢失的问题。

2.3 上拉电阻和线缆长度

这里特别提醒一下:I2C总线上挂两个设备,上拉电阻的值要按并联后的等效电阻来算。我这块板子上I2C1的两个引脚各接了4.7k上拉,两个设备的SCL和SDA都挂在这条总线上,等效上拉约2.35k,在100kHz标准模式下是OK的。如果你用的是400kHz快速模式,建议换成2.2k上拉,否则长线缆下容易出现波形畸变导致通信不稳定。

3. 正交编码电机:定时器编码器模式的配置与计数溢出处理

电机测速用的是正交编码器,输出A、B两相信号,相位差90度。CH32V307的TIM2支持编码器接口模式,可以直接把A、B相接在CH1和CH2上,硬件解码出方向和位置,不需要外部计数器芯片。

3.1 编码器模式的本质

编码器模式其实是用定时器的CH1和CH2两个输入通道做边沿检测,根据两路信号的相位关系判断正反转,然后自动增减计数器的值。这样CPU就完全不用干预,只需要在需要的时候读一下TIM2->CNT寄存器,省下的CPU时间可以干别的事。

CH32V307的定时器编码器模式和STM32非常相似,配置代码如下:

void encoder_tim2_init(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM2, 0); TIM_Cmd(TIM2, ENABLE); }

注意TIM_EncoderMode_TI12是"两路都计数"的模式,也就是A、B相每个边沿都触发计数,4倍频。如果电机一圈输出200个脉冲,4倍频后就是800个计数点,分辨率更高。

3.2 计数溢出处理:别让CNT回绕毁掉你的测速

TIM2的计数器是16位的,最大值65535。如果电机持续往一个方向转,计数器溢出后会回绕到0,如果你直接用"当前值减上次值"来算增量,会得到错误的结果。

处理方式有两种:一是读定时器溢出标志,溢出时把计数器值加上65536再计算;二是把计数器初始值设在中间位置(比如0x8000),这样正反转各有32768的余量,大多数应用场景够用。

我实际用的是方案一,在定时器更新中断里维护一个32位的计数器扩展变量:

volatile int32_t encoder_total = 0; volatile uint16_t last_count = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { // 溢出时根据方向修正 if (TIM_GetCounter(TIM2) > 0x8000) { encoder_total -= 0x10000; } else { encoder_total += 0x10000; } TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } }

这样encoder_total就变成了一个32位的脉冲累计值,测速时只需要定时读取更新,不会因为16位回绕出错。

3.3 编码器速度和方向的计算

测速我用的是M法(固定时间窗口内计数):

int32_t last_total = 0; uint32_t last_time = 0; int32_t get_encoder_speed(void) { uint32_t now = systick_ms(); int32_t delta = encoder_total - last_total; uint32_t dt = now - last_time; last_total = encoder_total; last_time = now; // 返回每秒脉冲数,单位: pulse/s return (int32_t)((float)delta * 1000.0f / dt); }

方向判断不用额外处理,编码器模式会自动根据A、B相相位关系决定计数器加还是减,负值就代表反转,拿去做PID闭环控制非常直观。

4. 两个UART外设(CH9141蓝牙与TAU1201 GPS):中断优先级与数据帧解析

CH9141是沁恒自家的蓝牙串口透传模块,主机通过UART跟它通信,数据会透传到手机APP或者另一个蓝牙模块。TAU1201 GPS模块通过UART输出NMEA格式的定位数据。这两个外设都是串口,而且都是"异步主动上报"的类型,处理不好就会互相干扰。

4.1 串口资源分配与中断优先级

我用了USART1接CH9141,USART2接TAU1201 GPS。两个串口都开接收中断,但中断优先级必须分好:GPS数据是持续不断输出的,每秒钟大约1条GGA+RMC语句,数据量不大但对连续性敏感;蓝牙是交互式的,用户可能在任意时刻发数据过来。

我的分配是:USART1(蓝牙)优先级设为抢占优先级1,USART2(GPS)抢占优先级设为2。这样即使GPS正在接收,蓝牙数据到达也能立即打断,保证人机交互的响应速度。

NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

4.2 GPS日志解析的缓存思路

TAU1201输出的NMEA格式,以$开头,以\r\n结尾,常见的语句有GGA(定位信息)、RMC(推荐最小定位信息)等。我处理的时候不是逐字节解析,而是先把一行完整的数据收进缓冲区,收到\n后再解析整行。

这样做的原因很实际:如果数据还没收完就解析,很容易出现半行数据导致解析失败。用环形缓冲区加帧结束判断,简单可靠:

uint8_t gps_rx_buf[128]; uint8_t gps_rx_index = 0; uint8_t gps_frame_ready = 0; void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE)) { uint8_t ch = USART_ReceiveData(USART2); if (ch == '\n') { gps_rx_buf[gps_rx_index] = '\0'; gps_frame_ready = 1; gps_rx_index = 0; } else if (gps_rx_index < sizeof(gps_rx_buf) - 1) { gps_rx_buf[gps_rx_index++] = ch; } } }

主循环里检测到gps_frame_ready后,用strstr找GGA和RMC的关键字段,解析出经纬度、UTC时间、定位状态和卫星数。这一步要注意:经纬度是DDMM.MMMM格式,比如4807.038表示北纬48度07.038分,转换成度分秒时别搞错了。

4.3 CH9141蓝牙模块的初始化要点

CH9141上电后默认工作在透传模式,不需要额外的AT指令配置就能直接用。但只要一上电,模块会给主机发一包版本信息,这包数据如果被当成用户数据处理,就会污染协议帧。

我的做法是:上电后延时500ms,然后清空蓝牙串口的接收缓冲区,把模块启动时发来的版本信息丢弃。这样做之后,后续收到的数据就都是干净的用户数据了。

void ch9141_init(void) { delay_ms(500); clear_uart1_rx_buffer(); // 丢弃模块启动信息 }

蓝牙透传的数据帧如果要用于控制,建议自己定义帧格式,比如0xAA 0x55 cmd len data checksum,在接收端做校验。因为蓝牙链路不像有线那么可靠,丢包、粘包都有可能,没有帧校验的话,控制指令偶尔会被错误执行,这在电机控制场景里是很危险的事。

5. TFT屏幕刷新策略:如何让显示不拖垮整个系统

ST7735这类小尺寸TFT屏幕,SPI接口时钟最高能到几十MHz,单纯刷一屏数据并不慢,但问题在于主控通过SPI逐字节发送数据时,CPU一直处于等待状态,如果刷屏频率过高,其他外设的响应就会被严重拖慢。

5.1 不要在主循环里整屏刷新

我一开始的做法是每隔100ms调用一次全屏刷新函数,结果发现GPS数据开始丢帧、蓝牙响应变得迟钝。原因是整屏刷新一次需要发送128×160×2字节的数据(RGB565),按SPI 20MHz算,需要约4ms,但CPU在SPI发送期间被占用的时间远不止4ms,加上填充缓冲区的计算时间,100ms周期里可能有一半时间都在刷屏。

后来的做法是分区刷新:只有数据变化的部分才更新,比如温度显示区域单独开一个小窗口:

void tft_update_temp(float temp) { char buf[16]; snprintf(buf, sizeof(buf), "%.1f C", temp); // 只刷温度显示所在的小区域 ST7735_FillRect(TEMP_X, TEMP_Y, 60, 20, ST7735_BLACK); ST7735_DrawString(TEMP_X, TEMP_Y, buf, ST7735_WHITE, ST7735_BLACK); }

这样每次刷新只需要写几十个字节,耗时微乎其微。

5.2 DMA发送减少CPU占用

如果你用的是CH32V307这种带DMA的芯片,强烈建议用DMA方式发送SPI数据。把要显示的数据准备好后,启动DMA传输,CPU就可以立即去做别的事,传输完成后触发DMA中断再处理下一帧数据。实测下来CPU占用率从之前的40%多降到不到10%,整个系统的流畅度明显提升。

5.3 屏幕刷新频率的合理选择

对于环境监控类显示,温湿度数据5秒刷新一次足够,MPU6050的倾角数据可以刷快一点,但也没必要超过30Hz,因为人眼感知不到那么高的刷新率,反而白白增加CPU和SPI总线的压力。

6. 模板合入后的调试顺序与几个值得记住的坑

所有外设单独调通之后,把它们合到同一个工程里,还有几个坑是我这次踩过的,写下来供参考。

6.1 推荐的调试顺序

  • 第一步:跑通系统时钟和延时函数,确认systick正常。
  • 第二步:点亮TFT屏幕,显示简单的字符。屏幕通了,后面调试其他外设时能直接看输出信息。
  • 第三步:I2C总线挂AHT20和MPU6050,把数据打到屏幕上验证。
  • 第四步:接上编码器电机,测试计数方向和溢出处理。
  • 第五步:接GPS和蓝牙,把NMEA数据和蓝牙收到的字节流分别打屏验证。
  • 第六步:整合所有任务调度,调中断优先级,用示波器或逻辑分析仪观察是否有时序冲突。

这个顺序的核心逻辑是:先把"能显示"的能力搞定,因为后续所有调试信息都要靠屏幕输出,然后再逐个加外设,每加一个就验证一个,不要一次性全堆上去。

6.2 合入后实际踩过的坑

第一个坑是I2C引脚的复用冲突。CH32V307的PB6和PB7既可以用作I2C1,也可以用作其他功能,我的板子上这两个引脚还连了其他设备,导致I2C时钟线上出现额外负载,信号质量下降,AHT20读数偶尔异常。最后是飞线把AHT20移到另一个I2C接口才解决。

第二个坑是GPS模块的PPS秒脉冲引脚。TAU1201除了串口数据外,还会输出一个PPS脉冲信号,如果你没用到这个信号,最好别把它接到MCU的中断引脚上,否则每秒一次的脉冲会触发中断,白白消耗CPU时间。我用的是PA8引脚,初始以为是普通IO口,结果每次PPS到来都进一次外部中断,查了很久才找到原因。

第三个坑是蓝牙模块的流控引脚。CH9141的CTS/RTS如果不接,模块默认是关闭流控的,但有些批次的模块默认状态不一致。如果发现蓝牙传输大量数据时偶尔丢字节,先检查模块的流控配置,在透传模式下建议直接开启硬件流控,两个引脚接上后丢包问题基本消失。

第四个坑是MPU6050的DMP固件加载问题。CH32V307的RAM只有64KB,MPU6050的DMP固件约4KB,加载本身没问题,但如果你同时开了很多大数组,RAM吃紧时DMP初始化可能会失败。我最后把MPU6050用在了非DMP的直读模式,直接读加速度和角速度原始数据,绕开DMP固件,反而更省心,计算量也不大。

6.3 模板维护的小建议

整套模板的代码结构我建议按"硬件驱动层(BSP) + 应用层"分开组织:每个外设的驱动单独放在一个.c/.h文件里,只提供初始化函数和数据读取接口,应用层只负责调用这些接口,不直接操作寄存器。这样后面换MCU平台、加新外设时,改动能控制在最小范围。我这次就是把AHT20和MPU6050的驱动封装成了标准接口,后面移植到其他芯片时只改底层I2C读写函数就行,传感器本身的逻辑完全不用动。

最后再说一个个人习惯:每个外设的驱动文件里,我会在顶部写清楚初始化顺序要求和依赖关系,比如AHT20必须先延时再初始化、编码器溢出中断必须开、CH9141上电后要清空缓冲区,这些"如果不说下次就会忘"的细节,全部写进注释里。三个月后回头维护代码时,这些注释比任何文档都管用。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询