十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CH32V307多外设整合实战:总线资源与中断优先级避坑指南

CH32V307多外设整合实战:总线资源与中断优先级避坑指南 简介基于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带单精度FPUFlash 256KBRAM 64KB。这个配置在同类MCU里不算顶级但它的外设丰富度确实适合做多驱动模板I2C、SPI、USART、定时器全都不止一套而且支持USB高速和以太网MAC后续想往上扩功能也留了余地。我这次的外设资源占用是这样的外设接口占用引脚/资源AHT20I2C1PB6SCL、PB7SDAMPU6050I2C1与AHT20共用总线同一组I2C引脚正交编码电机TIM2编码器模式PA0、PA1CH1/CH2CH9141蓝牙USART1PA9TX、PA10RXTAU1201 GPSUSART2PA2TX、PA3RXTFT屏幕ST7735SPI1PA5SCK、PA6MISO、PA7MOSI、PB0CS、PB1DC、PB2RST这么一列就能看出来这套方案其实是在考验三件事I2C总线能不能稳定挂多设备、两路串口的数据处理会不会互相干扰、SPI屏幕刷新会不会抢占CPU导致其他外设响应变慢。CH32V307的APB1和APB2外设时钟可以独立配置串口和SPI分开挂在不同的总线上这给后续调优先级留了很大操作空间。很多人在这一步会犯一个毛病先把所有外设初始化代码一股脑往上堆结果I2C和SPI引脚冲突了或者串口中断处理函数互相抢占导致GPS数据丢帧。我建议画一个类似上面的引脚占用表写清楚每个外设用的引脚和总线合入之前先拿原理图过一遍能省下大量排查时间。2. I2C总线上挂AHT20和MPU6050地址、时序与初始化顺序这两个传感器都是I2C接口我直接把它们挂在了同一条I2C1总线上。AHT20的7位地址是0x38MPU6050的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再初始化AHT20AHT20偶尔会返回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接CH9141USART2接TAU1201 GPS。两个串口都开接收中断但中断优先级必须分好GPS数据是持续不断输出的每秒钟大约1条GGARMC语句数据量不大但对连续性敏感蓝牙是交互式的用户可能在任意时刻发数据过来。我的分配是USART1蓝牙优先级设为抢占优先级1USART2GPS抢占优先级设为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只有64KBMPU6050的DMP固件约4KB加载本身没问题但如果你同时开了很多大数组RAM吃紧时DMP初始化可能会失败。我最后把MPU6050用在了非DMP的直读模式直接读加速度和角速度原始数据绕开DMP固件反而更省心计算量也不大。6.3 模板维护的小建议整套模板的代码结构我建议按硬件驱动层(BSP) 应用层分开组织每个外设的驱动单独放在一个.c/.h文件里只提供初始化函数和数据读取接口应用层只负责调用这些接口不直接操作寄存器。这样后面换MCU平台、加新外设时改动能控制在最小范围。我这次就是把AHT20和MPU6050的驱动封装成了标准接口后面移植到其他芯片时只改底层I2C读写函数就行传感器本身的逻辑完全不用动。最后再说一个个人习惯每个外设的驱动文件里我会在顶部写清楚初始化顺序要求和依赖关系比如AHT20必须先延时再初始化、编码器溢出中断必须开、CH9141上电后要清空缓冲区这些如果不说下次就会忘的细节全部写进注释里。三个月后回头维护代码时这些注释比任何文档都管用。本文还有配套的精品资源点击获取
返回列表