简介:这份资源面向STM32嵌入式开发初学者与进阶工程师,围绕STM32F401CCU6微控制器展开,重点演示USB、USART与IIC三种通信接口的配置与协同使用,并通过IIC总线驱动OLED显示屏,帮助读者掌握外设初始化、时钟配置、中断处理与数据传输的完整流程。压缩包共318个文件,约14.47MB,以C源码与头文件为主,包含HAL库驱动、Keil工程文件、编译脚本及调试配置,覆盖从底层寄存器操作到上层应用逻辑的完整工程结构。目前已有322人学习下载,适合作为通信接口与显示模块的实战参考。读者可从中获取可直接编译运行的工程模板、各接口初始化代码、OLED显示驱动逻辑以及常见配置问题的排查思路,便于快速移植到自己的项目中。
1. 一块 F401 最小系统板,怎么把 USB、串口和 OLED 同时跑起来
手头这块 STM32F401CCU6 核心板,USB 口、CH340 串口、0.96 寸 IIC OLED 三样东西同时接上,代码一烧进去就翻车——USB 枚举失败、串口乱码、OLED 花屏,三个外设互相打架。这不是玄学,是 F401 这颗芯片的时钟树和外设复用没理清楚。STM32F401CCU6 属于 F4 系列的入门款,Cortex-M4 内核跑 84MHz,64 引脚封装,USB OTG FS 和 USART、IIC 都挂在 APB 总线上,共享时钟源和中断向量。把这三路外设同时调通,本质上是在解决三件事:USB 的 48MHz 时钟从哪来、USART 的波特率怎么算准、IIC 的时序怎么在 84MHz 主频下不跑飞。适合手里有 F401 核心板、想做一个带 USB 通信和本地显示的小型数据采集节点的开发者。下面按我实际调通的顺序,从时钟配置讲到三路外设的代码落地。
2. 时钟树先理清:USB 的 48MHz 和 USART 的波特率不能互相拆台
2.1 F401 时钟源选型:HSE 晶振还是 HSI
F401CCU6 核心板上一般焊的是 25MHz 无源晶振,也有部分板子用 8MHz。USB OTG FS 要求 48MHz 时钟,且精度必须在 ±0.25% 以内,否则枚举阶段就会失败。HSI 内部 RC 振荡器虽然出厂校准到 ±1%,但温漂大,跑 USB 基本是靠运气。我一般直接用 HSE 外部晶振,PLL 配置成 M=25、N=336、P=4、Q=7,这样:
- VCO 输入 = 25MHz / 25 = 1MHz
- VCO 输出 = 1MHz × 336 = 336MHz
- 系统时钟 = 336MHz / 4 = 84MHz
- USB 时钟 = 336MHz / 7 = 48MHz
这套参数在 STM32CubeMX 里填进去,生成的时钟树是自洽的。注意 PLLQ 必须整除出 48MHz,很多人只改 PLLN 和 PLLP,忘了 PLLQ,结果 USB 死活不认。
// system_stm32f4xx.c 中关键 PLL 参数(CubeMX 生成后核对) #define PLL_M 25 #define PLL_N 336 #define PLL_P 4 #define PLL_Q 7 // 使能 HSE 并等待就绪 RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLM = PLL_M; RCC_OscInitStruct.PLL.PLLN = PLL_N; RCC_OscInitStruct.PLL.PLLP = PLL_P; RCC_OscInitStruct.PLL.PLLQ = PLL_Q; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); // 晶振起不来会卡在这里 }这段代码的逻辑是先把 HSE 拉起来,再配置 PLL 倍频和分频。参数说明:PLLM 是输入分频,PLLN 是倍频系数,PLLP 决定系统时钟,PLLQ 专门给 USB 用。如果板子用的是 8MHz 晶振,PLLM 改成 8,PLLN 改成 336,PLLP 和 PLLQ 不变,结果一样。改完用示波器量 MCO 引脚输出,确认是 84MHz 再往下走。
2.2 APB 分频与 USART 波特率误差
系统时钟 84MHz 出来后,APB1 最大 42MHz,APB2 最大 84MHz。USART1 挂在 APB2 上,USART2 挂在 APB1 上。假设用 USART1,APB2 不分频,时钟 84MHz,波特率 115200:
- USARTDIV = 84,000,000 / (16 × 115200) = 45.5729
- 整数部分 45,小数部分 0.5729 × 16 ≈ 9.17,取 9
- 实际波特率 = 84,000,000 / (16 × 45.5625) ≈ 115,226,误差 0.02%
这个误差完全可接受。但如果 APB2 分了频,比如 2 分频到 42MHz,USARTDIV = 22.786,小数部分 0.786 × 16 ≈ 12.58,取 13,实际波特率偏差会到 0.3% 左右,短帧还能忍,长帧就出乱码。所以我的习惯是 USART1 尽量走 APB2 不分频,USART2 走 APB1 时把波特率降到 57600 或更低来换精度。
// USART1 初始化,APB2 时钟 84MHz huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; // 16 倍过采样,误差最小 if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); }OverSampling 选 16 倍是默认值,也是精度最高的模式。如果波特率特别高(比如 921600),可以切到 8 倍过采样,但误差会变大,需要实测。初始化完成后,先发一个固定字符串,用串口助手看是否稳定,再往下调 USB。
2.3 USB OTG FS 时钟使能和中断优先级
USB OTG FS 的时钟来自 PLLQ,48MHz 直接进 USB 外设。使能时钟后,USB 中断优先级要设得比 USART 和 IIC 高,因为 USB 主机对时序敏感,枚举阶段如果被串口中断打断,可能直接失败。
// USB OTG FS 时钟使能 __HAL_RCC_USB_OTG_FS_CLK_ENABLE(); // 中断优先级:USB 最高,USART 次之,IIC 最低 HAL_NVIC_SetPriority(OTG_FS_IRQn, 0, 0); HAL_NVIC_EnableIRQ(OTG_FS_IRQn); HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); HAL_NVIC_SetPriority(I2C1_EV_IRQn, 2, 0); HAL_NVIC_EnableIRQ(I2C1_EV_IRQn);优先级分组用 NVIC_PRIORITYGROUP_4,全部占先优先级。USB 放 0 组,串口放 1 组,IIC 放 2 组。这样 USB 枚举时不会被其他外设打断。注意 F401 的 USB OTG FS 和 IIC1 有引脚复用冲突,PA11/PA12 是 USB DM/DP,PB6/PB7 是 IIC1,这两组不冲突,但 PA9/PA10 是 USART1,和 USB 也不冲突。真正要小心的是 PA11/PA12 不能同时配成其他功能。
3. USB CDC 虚拟串口:从描述符到收发回环的完整落地
3.1 CDC 类描述符配置与端点分配
USB CDC(Communication Device Class)虚拟串口是 F401 上最实用的 USB 功能,插上电脑就能识别成一个 COM 口,不需要额外驱动(Win10 以上自带 CDC 驱动)。CubeMX 里选 USB_OTG_FS 为 Device_Only,Middleware 选 CDC,生成代码后核心在 usbd_cdc_if.c。
端点分配上,CDC 需要三个端点:控制端点 EP0、数据 IN 端点 EP1、数据 OUT 端点 EP2。F401 的 USB OTG FS 有 4 个双向端点,够用。描述符里 bInterfaceClass 填 0x02(CDC),bInterfaceSubClass 填 0x02(ACM),bInterfaceProtocol 填 0x01(AT 命令)。这些在 CubeMX 里自动生成,不用手写。
// usbd_cdc_if.c 中的收发缓冲区定义 #define APP_RX_DATA_SIZE 64 #define APP_TX_DATA_SIZE 64 uint8_t UserRxBufferFS[APP_RX_DATA_SIZE]; uint8_t UserTxBufferFS[APP_TX_DATA_SIZE]; // 接收回调:主机发来的数据会进这里 static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // 把收到的数据原样回发,做回环测试 USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, *Len); USBD_CDC_TransmitPacket(&hUsbDeviceFS); // 重新挂接收 USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); }这段代码的逻辑是:主机通过 CDC 发数据过来,触发 CDC_Receive_FS 回调,把数据指针和长度拿到后,直接塞进发送缓冲区回发。参数说明:APP_RX_DATA_SIZE 和 APP_TX_DATA_SIZE 都是 64,对应 USB FS 的最大包长。如果数据量大于 64 字节,主机会分包,回调会多次触发。回环测试通过后,再把数据转发到 USART 或 OLED。
3.2 收发回环测试与主机端验证
烧录后,电脑设备管理器会出现一个 USB 串行设备,用串口助手打开对应 COM 口,波特率随便填(CDC 不受波特率影响),发什么就回什么。如果设备管理器显示未知设备,说明描述符有问题,重点查 PID/VID 和 bcdUSB 版本。如果枚举成功但收发无响应,查 CDC_Receive_FS 里有没有重新调用 USBD_CDC_ReceivePacket,漏了这句只能收一次。
# Linux 下用 minicom 验证 CDC 回环 minicom -D /dev/ttyACM0 -b 115200 # 输入任意字符,回车后应原样返回Windows 下用串口助手,选对应的 COM 口,发送 "hello",接收区应显示 "hello"。如果收到乱码,检查 USB 时钟是不是准确的 48MHz,PLLQ 有没有配对。
3.3 把 CDC 收到的数据转发到 USART 和 OLED
回环通了之后,把 CDC_Receive_FS 里的逻辑改成:数据同时发到 USART1 和 OLED 显示。USART 用 HAL_UART_Transmit 阻塞发送,OLED 用 IIC 写显存。
static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // 转发到 USART1 HAL_UART_Transmit(&huart1, Buf, *Len, 100); // 转发到 OLED 显示(假设 OLED_ShowString 已实现) OLED_ShowString(0, 0, (char*)Buf); // 回环给主机 USBD_CDC_SetTxBuffer(&hUsbDeviceFS, Buf, *Len); USBD_CDC_TransmitPacket(&hUsbDeviceFS); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); }这里要注意:HAL_UART_Transmit 是阻塞的,超时 100ms,如果串口被占用会卡住 USB 中断。更好的做法是用 DMA 发送,或者把数据丢进环形缓冲区,在主循环里发。OLED 的 IIC 写也是阻塞的,一帧 128×64 的显存全刷要十几毫秒,放在 USB 中断里会拖慢枚举后的通信响应。我的做法是 USB 中断里只把数据存进缓冲区,主循环里再处理 USART 和 OLED。
4. IIC 驱动 OLED:时序、显存和刷新率的取舍
4.1 硬件 IIC 还是软件模拟 IIC
F401 有 3 个硬件 IIC,I2C1 在 PB6/PB7,I2C2 在 PB10/PB11,I2C3 在 PA8/PC9。0.96 寸 OLED 一般用 SSD1306 驱动,IIC 地址 0x78(写)或 0x7A(读)。硬件 IIC 速度快,但 F4 的 IIC 外设有历史遗留的锁死问题,在某些错误状态下会卡住总线。软件模拟 IIC 用两个 GPIO 翻转,时序可控,不会锁死,但速度慢。
我一般先用软件模拟 IIC 把 OLED 点亮,确认屏和地址没问题,再切硬件 IIC 提速度。软件 IIC 的延时用 NOP 或 SysTick 都行,84MHz 下延时 2μs 左右对应 250kHz 左右的速率,SSD1306 最高支持 400kHz,够用。
// 软件 IIC 写一个字节 void IIC_WriteByte(uint8_t data) { for (uint8_t i = 0; i < 8; i++) { IIC_SDA_Set((data & 0x80) >> 7); // 先发高位 data <<= 1; IIC_SCL_Set(1); Delay_us(2); IIC_SCL_Set(0); Delay_us(2); } // 第 9 个时钟读 ACK IIC_SDA_Set(1); // 释放 SDA IIC_SCL_Set(1); Delay_us(2); // 读 SDA 电平,忽略或处理 IIC_SCL_Set(0); Delay_us(2); }这段代码是软件 IIC 的核心,SCL 上升沿采样 SDA,第 9 个时钟释放 SDA 读 ACK。参数说明:Delay_us(2) 决定 IIC 速率,2μs 对应约 250kHz。如果 OLED 不亮,先用逻辑分析仪抓 SCL/SDA,看地址字节 0x78 有没有发出去,ACK 有没有拉低。
4.2 SSD1306 显存布局与页地址模式
SSD1306 的显存是 128×64 位,分成 8 页,每页 8 行,每行 128 列。写入时先设页地址(0xB0~0xB7),再设列地址低 4 位和高 4 位,然后连续写数据。页地址模式是最好理解的,每次写一页的 128 字节。
// 设置页地址和列地址 void OLED_SetPos(uint8_t page, uint8_t col) { IIC_WriteCmd(0xB0 + page); // 页地址 IIC_WriteCmd(col & 0x0F); // 列低 4 位 IIC_WriteCmd(0x10 | (col >> 4)); // 列高 4 位 } // 全屏刷新 void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_SetPos(page, 0); IIC_WriteCmd(0x40); // 写数据模式 for (uint8_t col = 0; col < 128; col++) { IIC_WriteData(OLED_GRAM[page][col]); } } }OLED_GRAM 是 8×128 的显存数组,所有绘图操作先改显存,最后统一刷新。参数说明:0x40 是写数据命令,0x00 是写命令。如果花屏,检查列地址高低 4 位有没有拼错,0x10 | (col >> 4) 里的 col 是 0~127,右移 4 位后是 0~7,对应高 4 位。
4.3 刷新率与 USB 中断的配合
全屏刷新一次要写 8×128=1024 字节,软件 IIC 下每字节 9 个时钟,每个时钟 4μs,总共约 36ms。如果放在 USB 中断里,USB 响应会严重延迟。我的做法是主循环里每 100ms 刷一次 OLED,USB 中断只更新显存数组,不直接刷屏。
// 主循环 while (1) { if (HAL_GetTick() - last_oled_tick > 100) { OLED_Refresh(); // 100ms 刷一次,约 10fps last_oled_tick = HAL_GetTick(); } // 其他任务 }10fps 对于显示文本和简单波形够用,如果要显示动画,得换硬件 IIC 或 SPI 接口的 OLED。硬件 IIC 下 400kHz,全屏刷新约 23ms,能到 40fps 左右。
5. 避坑与排查:USB 枚举失败、串口乱码、OLED 不亮的现场记录
5.1 USB 插上没反应,设备管理器显示未知设备
现象:USB 线插上,电脑没有任何 COM 口出现,设备管理器里有个黄色感叹号的未知 USB 设备。原因:PLLQ 没配成 48MHz,或者 HSE 晶振没起振,USB 时钟不对。解决:用示波器量 PA8(MCO)输出,确认系统时钟 84MHz;再查 RCC 配置里 PLLQ 是不是 7,PLLN 是不是 336。如果 HSE 起不来,检查晶振负载电容是不是 20pF,有些板子焊的是 8pF,起振慢。
5.2 CDC 回环只能收一次,第二次没反应
现象:第一次发数据能回,第二次发就没反应了。原因:CDC_Receive_FS 回调里忘了重新调用 USBD_CDC_ReceivePacket,USB 栈没有重新挂接收。解决:在回调末尾加上 USBD_CDC_ReceivePacket(&hUsbDeviceFS),确保每次收完都重新挂。
5.3 串口发数据,接收端乱码
现象:USART1 发 "hello",串口助手收到乱码。原因:APB2 分频导致波特率误差过大,或者 HSE 实际频率和代码里写的不一致。解决:先确认 HSE 是 25MHz 还是 8MHz,改 PLLM;再把 APB2 分频设为 1,USARTDIV 重新算。如果还乱,用示波器量 TX 引脚,看一个位的时间是不是 8.68μs(115200 波特率),偏差超过 2% 就会乱。
5.4 OLED 不亮,但 IIC 地址能扫到
现象:用 IIC 扫描程序能扫到 0x78,但屏幕全黑。原因:初始化命令序列不对,或者对比度设成了 0。解决:检查 SSD1306 初始化命令里 0x81(对比度)后面跟的值是不是 0xCF,0xA4(全局显示开启)有没有发。如果命令都对,量 VCC 是不是 3.3V,有些 OLED 模块要 5V 才亮。
5.5 三路外设同时跑,USB 频繁掉线
现象:USB 连着,串口一发数据,USB 就掉。原因:USART 中断优先级比 USB 高,串口中断里处理时间太长,USB 中断被延迟。解决:把 USB 中断优先级设到最高(0 组),串口降到 1 组,IIC 降到 2 组。另外串口中断里不要做耗时操作,收到数据存缓冲区就退出。
6. 进阶:用环形缓冲区把三路外设解耦,顺带聊聊我的调试习惯
三路外设同时跑,最怕的就是在中断里做阻塞操作。我的做法是给 USB CDC、USART1、IIC OLED 各配一个环形缓冲区,中断里只负责存数据,主循环里统一处理。
// 环形缓冲区结构 typedef struct { uint8_t buf[256]; volatile uint16_t head; volatile uint16_t tail; } ringbuf_t; // 写一个字节(中断里调用) void ringbuf_put(ringbuf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % sizeof(rb->buf); if (next != rb->tail) { // 没满 rb->buf[rb->head] = data; rb->head = next; } } // 读一个字节(主循环里调用) uint8_t ringbuf_get(ringbuf_t *rb, uint8_t *data) { if (rb->head == rb->tail) return 0; // 空 *data = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % sizeof(rb->buf); return 1; }USB 中断里把收到的数据 ringbuf_put 到 usb_rb,USART 中断里把收到的数据 ringbuf_put 到 uart_rb,主循环里检查两个缓冲区,有数据就转发到对方和 OLED。这样中断执行时间极短,不会互相阻塞。
验证方法:用串口助手以 10ms 间隔连续发 1000 字节,同时 USB 端也发数据,观察 OLED 显示是否卡顿、USB 是否掉线。如果稳定跑 10 分钟不掉,基本就没问题了。
调试习惯上,我一般会留一个 GPIO 做状态指示,USB 枚举成功翻转一次,串口收到数据翻转一次,OLED 刷新翻转一次,用逻辑分析仪抓波形,一眼就能看出哪路卡住了。另外,PLLQ 和 APB 分频这两个参数,我每次新建工程都会先算一遍,写在注释里,省得后面忘了。
希望帮到你。
本文还有配套的精品资源,点击获取