简介:这份资源面向嵌入式开发初学者与进阶工程师,围绕STM32F401CCU6微控制器展开,整合USB、USART与IIC三大通信接口,并通过IIC驱动OLED显示屏,帮助读者掌握多协议协同开发与显示控制的完整流程。压缩包共318个文件,约14.47MB,以C源码与头文件为主体,辅以Keil工程文件、编译中间产物、HAL库驱动及少量配置脚本,覆盖从外设初始化到数据传输的各个环节。资源中已包含USB OTG设备枚举、USART波特率与帧格式配置、IIC从机地址读写及OLED显示驱动等关键实现,读者可据此搭建可运行工程,理解时钟配置、中断处理与库函数调用逻辑。目前已有322人学习下载,适合作为STM32F4系列通信与显示综合练习的参考模板,便于快速上手与二次开发。
1. STM32F401CCU6 四外设同调:一块核心板跑通 USB、USART、IIC 与 OLED 的真实门槛
很多人第一次把 STM32F401CCU6 核心板插上电脑,看到 USB 设备被识别、串口能打印、OLED 也亮了,就以为四外设同调不过如此。真正上手才会发现,USB 和 USART 抢中断、IIC 被 USB 的 SOF 打断、OLED 刷新一卡一卡,这些才是把标题里四个技术点放在同一块板子上的真实门槛。这篇笔记面向已经能点灯、能跑通单个外设,但一合起来就翻车的嵌入式从业者。我会按「先讲清每个外设在这颗芯片上的资源边界,再给可复现的初始化与调度代码,最后把踩过的坑逐条摊开」的顺序写,目标是你照着能在自己的 F401CCU6 板上把 USB 虚拟串口、USART 打印、IIC 驱动 OLED 三条链路同时跑稳,而不是各自为战。
STM32F401CCU6 属于 F4 系列里的入门型号,Cortex-M4 内核带 FPU,主频常见配置到 84MHz,封装通常是 UFQFPN48,GPIO 数量有限,外设复用冲突比大封装型号更明显。USB OTG FS 只有一组,USART 有 USART1/2/6 和 UART4 等,IIC 有 I2C1/2/3。标题里四个点放在一起,本质是在问:这颗小芯片能不能同时承担设备侧 USB 通信、异步串口调试、以及一条低速 IIC 显示链路。答案是能,但前提是你得先把时钟树、中断优先级和缓冲区这三件事定死,否则后面全是玄学。
2. 先把四个外设的引脚与时钟账算清楚
2.1 F401CCU6 上 USB、USART、IIC 的复用关系
在 UFQFPN48 封装上,USB OTG FS 的 DP/DM 固定在 PA11/PA12,这两个脚没有太多替代方案,所以一旦决定用 USB,PA11 和 PA12 就不能再分配给别的功能。USART1 常见落在 PA9/PA10 或 PB6/PB7,USART2 落在 PA2/PA3,I2C1 常见落在 PB6/PB7 或 PB8/PB9。问题在于 PB6/PB7 同时是 USART1 和 I2C1 的复用位置,如果你既想用 USART1 又想用 I2C1,就必须把其中一个挪走,否则初始化时后配置的那个会把前一个的复用覆盖掉,表现出来就是串口突然不出数或者 OLED 不亮。
我一般会先画一张引脚分配表,把每个外设需要的信号列出来,再对照数据手册的复用表逐脚确认。下面这张表是我在 F401CCU6 上常用的一组分配,避开了 PB6/PB7 的冲突:
| 外设 | 信号 | 引脚 | 复用编号 | 备注 |
|---|---|---|---|---|
| USB OTG FS | DP | PA12 | AF10 | 固定 |
| USB OTG FS | DM | PA11 | AF10 | 固定 |
| USART1 | TX | PA9 | AF7 | 调试打印 |
| USART1 | RX | PA10 | AF7 | 接收命令 |
| I2C1 | SCL | PB8 | AF4 | OLED 时钟 |
| I2C1 | SDA | PB9 | AF4 | OLED 数据 |
这张表不是唯一解,但它的价值在于把冲突提前暴露。如果你手上板子的 OLED 模块已经焊死在 PB6/PB7,那就把 USART1 换到 PA9/PA10,或者干脆用 USART2 的 PA2/PA3,别硬扛。
2.2 时钟树配置:84MHz 下三个外设的时钟来源
F401CCU6 的时钟树里,USB OTG FS 要求 48MHz 时钟,这个时钟通常由 PLL 的 Q 分频提供,而 USART 和 I2C 挂在 APB1/APB2 上。常见配置是 HSE 8MHz 或 HSI 16MHz 进 PLL,PLLM、PLLN、PLLP、PLLQ 四个参数决定 SYSCLK 和 USB 时钟。以 HSE 8MHz 为例,要得到 84MHz 系统时钟和 48MHz USB 时钟,一组可用参数是 PLLM=8、PLLN=336、PLLP=4、PLLQ=7。算一下:8MHz / 8 = 1MHz,1MHz × 336 = 336MHz,336 / 4 = 84MHz,336 / 7 = 48MHz。这四个数必须同时满足,PLLQ 不对 USB 就枚举失败。
USART 的波特率来自 APB 时钟,I2C 的时序也依赖 APB1 时钟。如果你把 APB1 分频设得太大,I2C 在 400kHz 下可能因为时钟源太低而算不出合适的 CCR 值。我一般会把 APB1 保持在 42MHz、APB2 保持在 84MHz,这样 USART1 挂在 APB2 上,波特率误差小,I2C1 挂在 APB1 上,400kHz 也能配得比较准。
// 时钟配置片段,基于 HSE 8MHz,目标 SYSCLK 84MHz,USB 48MHz RCC->CR |= RCC_CR_HSEON; while (!(RCC->CR & RCC_CR_HSERDY)); RCC->PLLCFGR = (8 << RCC_PLLCFGR_PLLM_Pos) // PLLM = 8 | (336 << RCC_PLLCFGR_PLLN_Pos) // PLLN = 336 | (0 << RCC_PLLCFGR_PLLP_Pos) // PLLP = 4,编码 0b00 | (7 << RCC_PLLCFGR_PLLQ_Pos) // PLLQ = 7,给 USB | RCC_PLLCFGR_PLLSRC_HSE; RCC->CR |= RCC_CR_PLLON; while (!(RCC->CR & RCC_CR_PLLRDY)); // APB1 = 42MHz,APB2 = 84MHz,AHB = 84MHz RCC->CFGR = RCC_CFGR_HPRE_DIV1 | RCC_CFGR_PPRE1_DIV2 | RCC_CFGR_PPRE2_DIV1; RCC->CFGR |= RCC_CFGR_SW_PLL; while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);这段代码的关键在 PLLQ 和 PPRE1。PLLQ 决定 USB 时钟,必须是 48MHz;PPRE1 决定 APB1,I2C 挂在这条总线上,分频太大会让 I2C 时序寄存器算不出合法值。配置完时钟后,记得使能 USB、USART1、I2C1 和对应 GPIO 的时钟,顺序上先开 GPIO 再开外设,避免复用功能还没生效就去写外设寄存器。
2.3 中断优先级:USB 和 USART 谁先谁后
USB OTG FS 的中断在 F401 上通常是 OTG_FS_IRQn,USART1 是 USART1_IRQn,I2C1 是 I2C1_EV_IRQn 和 I2C1_ER_IRQn。这三个外设如果都用中断,优先级排不好就会出现 USB 枚举过程中被 USART 接收中断打断,导致 USB 控制传输超时;或者 I2C 事件中断被 USB 的 SOF 中断频繁抢占,OLED 刷新出现肉眼可见的撕裂。
我的做法是把 USB 中断设为最高优先级,USART 次之,I2C 最低。在 Cortex-M4 的 NVIC 里,优先级数值越小越高。具体配置时,USB 用抢占优先级 0,USART 用 1,I2C 用 2。这样 USB 的控制传输不会被串口数据打断,I2C 的慢速传输也不会反过来影响 USB 枚举。
// NVIC 优先级配置,数值越小优先级越高 NVIC_SetPriority(OTG_FS_IRQn, 0); NVIC_SetPriority(USART1_IRQn, 1); NVIC_SetPriority(I2C1_EV_IRQn, 2); NVIC_SetPriority(I2C1_ER_IRQn, 2); NVIC_EnableIRQ(OTG_FS_IRQn); NVIC_EnableIRQ(USART1_IRQn); NVIC_EnableIRQ(I2C1_EV_IRQn); NVIC_EnableIRQ(I2C1_ER_IRQn);这里有个细节:I2C 的错误中断和事件中断我给了同一个优先级,因为错误处理通常要立刻停掉当前传输,和事件中断抢优先级没有意义。如果你用的是 HAL 库,NVIC 配置会被 HAL_Init 里的默认分组影响,记得先调用 HAL_NVIC_SetPriorityGrouping 把分组定成 4 位抢占、0 位子优先级,再设具体优先级,否则你写的数值可能被分组规则重新解释。
3. 三个外设的初始化与最小可用代码
3.1 USB OTG FS 做虚拟串口的初始化要点
在 F401CCU6 上把 USB 做成虚拟串口,常见做法是用 CDC 类。初始化分三块:GPIO 复用、USB 核心寄存器、以及端点缓冲区。PA11 和 PA12 要配成复用功能 AF10,输出速度建议设成非常高,因为 USB 全速虽然只有 12Mbps,但边沿太缓会导致枚举不稳定。USB 核心部分要开 OTG_FS 的时钟,配置 GUSBCFG 里的物理层参数,然后等 AHB 空闲、复位核心、设置设备模式。
端点缓冲区这块是新手最容易翻车的地方。F401 的 USB OTG FS 有一块专用 RAM,用来存放端点 FIFO,大小和分配方式要通过 DIEPTXF 和 RXFSIZ 寄存器设置。如果你用 HAL 库,这些会被 MX_USB_OTG_FS_Init 里的底层初始化带过,但如果你自己写寄存器,就得手动算。我一般会留出至少 128 字节给控制端点 0,其余按 CDC 的批量端点需求分配。
// USB OTG FS GPIO 与核心复位片段 RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; GPIOA->MODER &= ~(3 << (11 * 2)); GPIOA->MODER |= (2 << (11 * 2)); // PA11 复用 GPIOA->MODER &= ~(3 << (12 * 2)); GPIOA->MODER |= (2 << (12 * 2)); // PA12 复用 GPIOA->AFR[1] |= (10 << ((11 - 8) * 4)); GPIOA->AFR[1] |= (10 << ((12 - 8) * 4)); GPIOA->OSPEEDR |= (3 << (11 * 2)) | (3 << (12 * 2)); RCC->AHB2ENR |= RCC_AHB2ENR_OTGFSEN; while (USB_OTG_FS->GRSTCTL & USB_OTG_GRSTCTL_AHBIDL); USB_OTG_FS->DCTL |= USB_OTG_DCTL_SDIS; // 后续按设备模式初始化,设置 GUSBCFG 和端点 FIFO这段代码里 AFR 寄存器的偏移要注意,PA11 和 PA12 属于 AFR[1],计算方式是 (pin - 8) * 4。OSPEEDR 设成 3 是为了让 USB 差分信号边沿足够陡。复位核心前要等 AHBIDL 位,否则复位可能不生效。如果你发现设备插上电脑后枚举失败,先查 PA11/PA12 的复用和速度设置,再查 PLLQ 是不是 48MHz,这两处占枚举失败原因的八成以上。
3.2 USART1 中断收发与环形缓冲区
USART1 在这套方案里承担调试打印和命令接收。初始化时波特率常用 115200,数据位 8,停止位 1,无校验。关键是接收不能只靠一个字节的寄存器,要用环形缓冲区加中断,否则 USB 和 I2C 一忙,串口数据就丢。我一般会开一个 256 字节的环形缓冲区,USART1_IRQHandler 里只做一件事:把 DR 读出来塞进缓冲区,然后更新写指针。
#define UART_RX_BUF_SIZE 256 static volatile uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; static volatile uint16_t uart_rx_head = 0; static volatile uint16_t uart_rx_tail = 0; void USART1_IRQHandler(void) { if (USART1->SR & USART_SR_RXNE) { uint8_t data = USART1->DR; // 读 DR 自动清 RXNE uint16_t next = (uart_rx_head + 1) % UART_RX_BUF_SIZE; if (next != uart_rx_tail) { // 留一个空位区分满和空 uart_rx_buf[uart_rx_head] = data; uart_rx_head = next; } // 缓冲区满则丢弃,不阻塞中断 } } uint16_t uart_read(uint8_t *out, uint16_t len) { uint16_t count = 0; while (count < len && uart_rx_tail != uart_rx_head) { out[count++] = uart_rx_buf[uart_rx_tail]; uart_rx_tail = (uart_rx_tail + 1) % UART_RX_BUF_SIZE; } return count; }环形缓冲区的核心是 head 和 tail 的追赶关系,留一个空位是为了区分「满」和「空」两种状态。中断里不做任何耗时操作,只搬数据,解析放到主循环。如果你在中断里直接 printf,USB 中断一来就可能因为串口阻塞导致 USB 超时,这是很典型的翻车点。波特率误差方面,84MHz 的 APB2 时钟配 115200,USARTDIV 算出来是 45.5625,实际误差在 0.1% 以内,可以接受。
3.3 I2C1 驱动 OLED 的时序与刷新策略
OLED 模块常见是 SSD1306 或 SH1106 控制器,I2C 地址通常是 0x3C 或 0x3D。F401 的 I2C1 挂在 APB1 上,42MHz 时钟下配 400kHz 快速模式,CCR 值大约是 42MHz / (2 × 400kHz) = 52.5,取 52 或 53,TRISE 设成 42MHz × 300ns / 1000 + 1 ≈ 13。这些值不是拍脑袋,是参考手册里快速模式的公式算出来的,配错了波形就会畸变,OLED 可能时亮时不亮。
// I2C1 快速模式 400kHz 时序配置,APB1 = 42MHz RCC->APB1ENR |= RCC_APB1ENR_I2C1EN; I2C1->CR1 &= ~I2C_CR1_PE; I2C1->CR2 = 42; // FREQ = APB1 频率,单位 MHz I2C1->CCR = 52; // 400kHz 下的 CCR I2C1->TRISE = 13; // 最大上升时间 I2C1->CR1 |= I2C_CR1_PE; // 写 OLED 命令的简化流程 void oled_write_cmd(uint8_t cmd) { while (I2C1->SR2 & I2C_SR2_BUSY); I2C1->CR1 |= I2C_CR1_START; while (!(I2C1->SR1 & I2C_SR1_SB)); I2C1->DR = 0x3C << 1; // 从机地址,写方向 while (!(I2C1->SR1 & I2C_SR1_ADDR)); (void)I2C1->SR2; // 清 ADDR I2C1->DR = 0x00; // 控制字节,命令模式 while (!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->DR = cmd; while (!(I2C1->SR1 & I2C_SR1_TXE)); I2C1->CR1 |= I2C_CR1_STOP; }OLED 刷新策略上,我不建议每次改一个像素就整屏刷。SSD1306 的显存是 128×64 位,整屏 1024 字节,400kHz I2C 下传完要 20ms 以上,如果放在主循环里频繁调用,USB 和串口的响应都会变差。常见做法是在 MCU 里开一块 1024 字节的显存缓冲,界面逻辑只改缓冲,定时器每 100ms 触发一次整屏刷新,或者只刷新变化的那几页。这样 I2C 占用时间可控,也不会因为 OLED 拖慢其他外设。
4. 四外设同调时的避坑与排查记录
4.1 USB 枚举失败但串口正常
现象是插上电脑后设备管理器里 USB 设备带感叹号,或者干脆不出现,但 USART1 打印一切正常。原因通常是 PLLQ 没配成 48MHz,或者 PA11/PA12 的复用被其他初始化覆盖。排查时先用示波器看 PA12 上有没有 1.5k 上拉和 SOF 包,如果没有,说明 USB 核心根本没起来。解决方法是回头检查 RCC_PLLCFGR 的 PLLQ 字段,以及 GPIOA 的 AFR 和 OSPEEDR 是否在 USB 初始化之后又被别的代码改过。我遇到过一种情况是 I2C 初始化里顺手把 GPIOA 整个端口的 AFR 清了,结果 PA11/PA12 复用丢失,USB 直接不枚举。
4.2 OLED 亮但内容乱码或局部不刷新
现象是 OLED 能亮,但显示的文字有残影,或者某几行不更新。原因多半是 I2C 时序偏差导致个别字节传输出错,或者显存刷新时没有等 I2C 总线空闲就发 START。解决方法是先用逻辑分析仪抓 SCL/SDA 波形,看 400kHz 下上升沿是否被拉平,如果上升时间超过 300ns,就要减小上拉电阻或者降低到 100kHz。另外在每次整屏刷新前加一句等 BUSY 位清零,能避免 START 在总线忙时发出,导致从机不响应。
4.3 串口打印丢数据,尤其 USB 插拔时
现象是 USB 插拔瞬间,USART1 的输出会缺几个字符。原因是 USB 中断优先级高于 USART,插拔时 USB 中断密集触发,USART 接收中断被延迟,环形缓冲区如果太小就会溢出。解决方法一是把串口环形缓冲区从 64 字节加到 256 字节以上,二是检查 USART 中断里有没有调用耗时函数。如果用了 printf 重定向,确认它走的是阻塞发送还是中断发送,阻塞发送在 USB 忙的时候会拖长中断关闭时间,进一步加剧丢数据。
4.4 I2C 死锁导致整个系统卡住
现象是 OLED 刷着刷着整个程序不动了,USB 和串口也没反应。原因是 I2C 从机在某个时刻把 SDA 拉低不放,主机等 ADDR 或 TXE 标志时死循环。解决方法是给 I2C 等待循环加超时计数,超时后手动发 STOP 或者复位 I2C 外设。更稳妥的做法是在 I2C 的 SCL 上额外用一个 GPIO 做时钟脉冲恢复,但 F401CCU6 引脚紧张,我一般先用超时复位,配合看门狗兜底。这个坑的血泪经验是:任何 while 等标志的 I2C 代码,都必须有超时退出,否则现场设备一跑就是几小时,死锁一次就得断电重启。
4.5 中断优先级分组被库函数改掉
现象是你明明设了 USB 优先级 0、USART 优先级 1,但实际运行时串口还是能打断 USB。原因是 HAL 库或者某些启动文件里调用了 NVIC_SetPriorityGrouping,把优先级分组改成了别的模式,你写的抢占优先级数值被重新解释。解决方法是在 main 函数开头、任何外设初始化之前,先调用一次 NVIC_SetPriorityGrouping(4),把分组固定成 4 位抢占、0 位子优先级,然后再设各个中断的优先级。这个坑很隐蔽,因为代码看起来完全正确,但实际优先级关系和你以为的不一样。
5. 把三条链路收进一个主循环:调度与验证技巧
走到这一步,四个外设的初始化都跑通了,剩下的问题是怎么让它们在同一个主循环里不互相拖累。我的习惯是把主循环做成一个极简的时间片轮询:USB 的 CDC 收发由中断和端点回调驱动,主循环只检查接收标志;USART 的解析放在主循环,每次最多处理 32 字节;OLED 刷新用 SysTick 计数,每 100ms 触发一次,且只在显存有变化时才真正走 I2C。这样三个外设的实时性要求被分开:USB 靠中断保证,串口靠缓冲区保证,OLED 靠低频刷新保证。
验证方法上,我会分三步走。第一步,单独跑 USB,用主机端发 1KB 数据,看回环是否完整;第二步,USB 回环的同时,USART1 每 10ms 打印一次计数器,看串口输出有没有断档;第三步,前两步都跑着,OLED 每 100ms 刷新一帧变化的画面,用逻辑分析仪看 I2C 的 SCL 有没有被拉长到超过 1ms。如果三步都稳,基本可以认为四外设同调成立。下面这个调度骨架是我常用的结构:
volatile uint32_t tick_ms = 0; void SysTick_Handler(void) { tick_ms++; } int main(void) { clock_init(); gpio_init(); usb_cdc_init(); usart1_init(115200); i2c1_init_400k(); oled_init(); NVIC_SetPriorityGrouping(4); nvic_config(); uint32_t last_oled = 0; uint8_t rx_buf[32]; while (1) { // USB 接收处理,非阻塞 usb_cdc_poll(); // 串口解析,每次最多 32 字节 uint16_t n = uart_read(rx_buf, sizeof(rx_buf)); if (n > 0) { usart1_send(rx_buf, n); // 回显,实际项目里换成命令解析 } // OLED 每 100ms 刷新一次,且只在脏页时刷 if (tick_ms - last_oled >= 100) { last_oled = tick_ms; if (oled_is_dirty()) { oled_flush(); } } } }这个骨架里,usb_cdc_poll 只做标志检查和缓冲区搬运,不阻塞;uart_read 从环形缓冲区取数据,取不到就返回 0;oled_flush 内部有 I2C 超时保护。整个主循环没有一处死等,任何一个外设出问题都不会把 CPU 卡死。如果你发现某个环节耗时太长,优先怀疑 I2C 的 while 等待和 USB 的端点回调里有没有长操作。
最后一个技巧是关于调试顺序的。很多人一上来就把四个外设全初始化,然后发现不工作,再一个个注释掉排查,效率很低。我一般会按「时钟 → GPIO → USART → I2C → USB」的顺序逐个加,每加一个就用串口打印一次状态,确认前一个没被影响再加下一个。USB 放最后是因为它对时钟和中断最敏感,前面都稳了再上 USB,出问题时变量最少。这个习惯帮我省过很多次通宵,希望帮到你。
本文还有配套的精品资源,点击获取