☰
STM32F401CCU6三路外设并发:USB CDC、USART、IIC
2026/10/30 5:36:35 网站建设 项目流程

简介:面向嵌入式初学者与STM32开发者的通信外设综合实践工程,围绕STM32F401CCU6实现USB全速接口、USART串口通信与IIC驱动OLED显示,可用于课程设计、毕业设计及外设联调练习,帮助读者理解多外设协同工作的固件组织方式。压缩包共318个文件,约14.47MB,包含头文件、C源码、目标文件、编译中间文件以及Hex烧录文件、IOC配置和Keil工程文件,完整保留从外设初始化、时钟与中断配置到工程编译输出的目录结构,便于直接打开对照学习。已有323人学习下载,适合需要参考HAL库工程模板、梳理USB枚举与串口收发流程、掌握IIC OLED驱动写法的读者;通过阅读源码可快速定位各通信模块的配置入口与调用关系,并据此裁剪或移植到自己的项目。

1. 一块 F401 小板子把 USB、USART、IIC 三路外设同时跑通,难点在哪

手头这块 STM32F401CCU6 最小系统板,256KB Flash、64KB SRAM、84MHz 主频,引脚数和价格都刚好卡在“够用”这条线上。它上面挂着三样东西:一路 USB 全速设备口、一路 USART 串口、一路 IIC 接的 0.96 寸 OLED。单独跑通任意一路都不算难,难的是三路同时在线——USB 枚举要求精确的 48MHz 时钟,USART 要求稳定的波特率分频,IIC 又是慢速阻塞总线,三者抢的是同一套时钟树和同一张中断向量表。这套工程就是把这些凑在一起的完整可跑版本,适合刚上手 F4 系列、想找个能把多个通信外设一次性串起来的练手项目的开发者,也适合拿它当板级调试底座,后面往上叠自己的业务逻辑。

2. 工程骨架:时钟树、引脚分配与目录分层

拿到一个能跑的多外设工程,我第一件事不是看 main 里写了什么,而是先翻引脚和时钟。这两个地方错了,后面所有代码都是白搭。这一章把硬件资源怎么分、时钟为什么只能那么配、目录怎么分层讲清楚,后面读代码才不会迷路。

2.1 引脚与外设资源分配

F401CCU6 是 UFQFPN48 封装,48 个引脚里能用的 GPIO 大概是 36 个,看起来富余,但一旦 USB 占掉 PA11/PA12、IIC 占掉 PB6/PB7、USART 占掉 PA9/PA10,再留出 SWD 的 PA13/PA14 和两个调试串口,剩下的就不多了。这套工程的实际分配是这样:

外设引脚复用功能说明
USB OTG_FSPA11 / PA12AF10DM / DP,全速设备模式
USART1PA9 / PA10AF7TX / RX,主日志口
USART2PA2 / PA3AF7TX / RX,外接模块口
I2C1PB6 / PB7AF4SCL / SDA,接 OLED
SWDPA13 / PA14AF0下载调试,必须保留
LEDPC13GPIO板载指示灯,做心跳

USART1 和 USART2 都挂上是有原因的:USART1 接调试器那一路,专门打印日志;USART2 留给外部设备,比如接个 GPS 模块或者另一个单片机,两边互不干扰。I2C1 选 PB6/PB7 而不是 PB8/PB9,是因为大部分现成的 OLED 模块排针位置就是按这组走的,飞线少。

提示:PA11/PA12 一旦配置成 USB 功能,就不能再当普通 GPIO 用。有些板子把 LED 接在 PA12 上,烧进去发现灯不亮、USB 也认不到,两边打架。

2.2 时钟树:48MHz 是 USB 的硬性约束

F401 的 USB OTG_FS 要求时钟精确等于 48MHz,误差超过 ±0.25% 就会枚举失败或者枚举成功但传输出错。这个 48MHz 只能从主 PLL 的 Q 分频出来,不能从系统时钟随便分。所以整个时钟树的配置顺序是反过来的:先定 USB 要的 48MHz,再倒推 PLL 参数,最后才是系统主频。

这套工程用的是内部 HSI 16MHz 作为 PLL 源,省掉外部晶振,成本低、焊接简单,代价是 HSI 精度不如 HSE,做 USB 通信时对温漂更敏感。参数这么配:

void SystemClock_Config(void) { RCC_OscInitTypeDef osc = {0}; RCC_ClkInitTypeDef clk = {0}; /* HSI 16MHz 进 PLL:16 / M(16) = 1MHz 作为 PLL 输入 */ osc.OscillatorType = RCC_OSCILLATORTYPE_HSI; osc.HSIState = RCC_HSI_ON; osc.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT; osc.PLL.PLLState = RCC_PLL_ON; osc.PLL.PLLSource = RCC_PLLSOURCE_HSI; osc.PLL.PLLM = 16; /* 分频后 1MHz */ osc.PLL.PLLN = 336; /* VCO = 336MHz */ osc.PLL.PLLP = RCC_PLLP_DIV4; /* 336/4 = 84MHz 系统时钟 */ osc.PLL.PLLQ = 7; /* 336/7 = 48MHz,专供 USB */ if (HAL_RCC_OscConfig(&osc) != HAL_OK) { Error_Handler(); } /* AHB 不分频,APB1 二分频到 42MHz,APB2 不分频到 84MHz */ clk.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; clk.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; clk.AHBCLKDivider = RCC_SYSCLK_DIV1; clk.APB1CLKDivider = RCC_HCLK_DIV2; clk.APB2CLKDivider = RCC_HCLK_DIV1; if (HAL_RCC_ClockConfig(&clk, FLASH_LATENCY_2) != HAL_OK) { Error_Handler(); } }

几个参数值得单独说。PLLM 设 16 是为了让 PLL 输入落在 1MHz,这是手册推荐的低抖动区间;PLLN 取 336 是 F401 在 84MHz 主频下的标准值;PLLQ 必须是 7,因为 336/7 正好 48,写成 6 得 56MHz、写成 8 得 42MHz,都会让 USB 直接罢工。FLASH_LATENCY_2 是 84MHz 下必须的等待周期,漏了这行程序会在高频下随机跑飞,症状是“有时候能跑有时候不能”,特别难查。

APB1 二分频到 42MHz 也不是随便定的,F401 的 APB1 最大就是 42MHz,超了 I2C、USART2 这些挂在上面的外设全都不正常。

2.3 目录分层与初始化顺序

工程按功能切成四层,每层职责单一,改起来不会牵一发动全身:

Core/ Inc/ main.h usb_device.h gpio.h i2c.h usart.h Src/ main.c stm32f4xx_it.c stm32f4xx_hal_msp.c USB_DEVICE/ App/ usbd_cdc_if.c # CDC 类回调,收发都在这 Target/ usb_device.c # 设备初始化入口 Drivers/ BSP/ oled.c oled.h # SSD1306 驱动 BSP/ ringbuf.c ringbuf.h # 通用环形缓冲 Middlewares/ Third_Party/ (USB 设备库)

初始化顺序是有讲究的,不能凭感觉排。正确顺序是:HAL 库 → 时钟 → GPIO → DMA → I2C → USART → OLED → USB。USB 必须放最后,因为它一旦使能就会开始响应主机,这时候如果串口还没初始化好,收到数据想打印日志就会进 HardFault。

int main(void) { HAL_Init(); /* 先配 Flash 等待周期和 SysTick */ SystemClock_Config(); /* 时钟树,USB 的 48MHz 在这里定下来 */ MX_GPIO_Init(); MX_DMA_Init(); /* 串口 DMA 要在串口之前 */ MX_I2C1_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); OLED_Init(); /* 屏幕先亮起来,后面出问题至少能看到状态 */ MX_USB_DEVICE_Init(); /* 最后开 USB,避免主机过早枚举 */ while (1) { /* 主循环只做业务,重活全在中断和 DMA 里 */ } }

把 USB 放最后还有个附带好处:调试阶段如果 USB 配置有问题导致枚举失败,前面的串口日志已经能正常输出了,能从串口看到卡在哪一步,不用瞎猜。

3. USB CDC 与 USART:两条数据通道的收发链路

三路外设里,USB 是最容易让人打退堂鼓的一路。协议栈代码几千行,描述符一堆,出了问题时主机端只回一句“无法识别的 USB 设备”,什么线索都没有。这一章不铺开讲 USB 协议,只讲这套工程里实际用到的部分:CDC 类怎么配、回调怎么接、收到的数据怎么安全地交给主循环。

3.1 USB CDC 的设备描述符与端点配置

CDC 类的作用是把 USB 设备模拟成一个串口。插上电脑,系统里多出一个 COM 口,用任意串口助手就能收发,不需要装驱动(Windows 10 以后自带 CDC 驱动)。这套工程配置成两个端点:一个批量输出端点 0x81 用于设备发数据,一个批量输入端点 0x01 用于设备收数据,包长都设 64 字节,全速设备下这是批量端点的标准值。

描述符这块不用手写,配置工具生成之后基本不用动,但有个参数要确认:VID/PID。默认是 0x0483 / 0x5740,这是 CDC 类的通用组合,Windows 和 Linux 都认。如果改成自定义值,Windows 下就需要额外的 inf 文件,反而麻烦,练手项目没必要折腾。

真正要自己写的是usbd_cdc_if.c里那几个回调。最关键的接收回调长这样:

static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { /* 中断上下文,只做搬运,绝不做解析和打印 */ for (uint32_t i = 0; i < *Len; i++) { ring_push(&usb_rx_ring, Buf[i]); /* 单字节入环形缓冲 */ } /* 关键:重新挂上接收,否则只能收到第一包 */ USBD_CDC_SetRxBuffer(&hUsbDeviceFS, &Buf[0]); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return USBD_OK; }

Buf是协议栈内部的接收缓冲区指针,Len是本包实际字节数。这个回调运行在 USB 中断里,优先级不低,所以里面只做最轻的搬运工作——把字节塞进环形缓冲就返回。解析、打印、刷屏这些活儿全留给主循环。很多人图省事直接在回调里printf,串口输出一次几毫秒,USB 主机那边等不到 ACK 就会重传甚至判定设备掉线。

USBD_CDC_SetRxBuffer和USBD_CDC_ReceivePacket这两行是必须的。协议栈处理完一包之后不会自动重新挂接收,必须手动调一次。漏掉的症状特别好认:电脑发一条命令设备能收到,再发第二条就没反应了,拔插一下又能收一条。

发送方向的封装:

uint8_t USB_Send(const uint8_t *data, uint16_t len) { /* 上一包还没发完就直接丢弃,避免阻塞主循环 */ if (USBD_OK != CDC_Transmit_FS((uint8_t *)data, len)) { return 1; } return 0; }

CDC_Transmit_FS在总线忙的时候会返回USBD_BUSY,这时候要么等,要么丢。这套工程选择丢弃并返回错误码,因为 OLED 刷新的实时性比丢一帧日志重要。如果业务上不允许丢,就得在外面加一个发送队列,主循环里轮询发送,那是二次开发要做的事。

3.2 USART 的环形缓冲与空闲中断

串口收不定长数据是个老问题。用HAL_UART_Receive阻塞读,长度固定,来多少收多少,多一个字节就错位;用中断逐字节收,每来一个字节进一次中断,115200 波特率下每秒一万多次中断,CPU 光进进出出就忙不过来。

这套工程用的是 DMA + 空闲中断的组合:DMA 负责把数据搬进缓冲区,不占 CPU;空闲中断负责在“这一帧发完了”的时候通知主循环去取。空闲中断的触发条件是接收线上连续一个字节时间没有新数据,正好对应一帧结束。

/* USART2 空闲中断处理,DMA 已经在后台把数据搬进 rx_dma_buf */ void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); /* 先清标志,顺序不能反 */ HAL_UART_DMAStop(&huart2); /* 停 DMA 才能安全读计数器 */ uint16_t remain = __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint16_t recv_len = USART2_RX_SIZE - remain; /* 总长减剩余 = 已收长度 */ if (recv_len > 0) { ring_push_block(&usart2_rx_ring, rx_dma_buf, recv_len); } /* 重启 DMA,缓冲区指针和长度都要重新给 */ HAL_UART_Receive_DMA(&huart2, rx_dma_buf, USART2_RX_SIZE); } HAL_UART_IRQHandler(&huart2); }

__HAL_DMA_GET_COUNTER返回的是 DMA 还剩多少字节没搬,用缓冲区总长减掉它就是本帧实际长度。这个值必须在停掉 DMA 之后读,运行中读会拿到一个跳变的值,算出来的长度时对时错,非常难查。

HAL_UART_IRQHandler放在最后调是必须的,它负责处理溢出错误、帧错误这些异常标志。漏了这行,一旦发生一次溢出(ORE),RXNE 标志会一直挂着,后续数据全丢,而且串口看起来“还活着”,只是再也收不到东西。

初始化部分要把空闲中断和 DMA 都打开:

void USART2_Init(void) { huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; huart2.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart2.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart2); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); /* 开空闲中断 */ HAL_UART_Receive_DMA(&huart2, rx_dma_buf, USART2_RX_SIZE); }

波特率 115200 在 42MHz 的 APB1 时钟下分频误差大约 0.15%,完全够用。如果换成 921600 这种高波特率,就得重新算一遍分频,误差超过 2% 就开始丢包。

3.3 日志重定向与双通道分流

调试阶段最想要的功能是printf能直接打到串口上。HAL 库默认的printf走的是半主机模式,需要调试器挂着才能输出,脱机运行就废了。重定向的做法是覆盖fputc:

int fputc(int ch, FILE *f) { /* 只往 USART1 打,不碰 USB,避免日志和业务数据抢同一个通道 */ HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 10); return ch; }

这里用阻塞发送、超时 10ms,是因为日志量小、实时性要求低,简单可靠优先。如果日志量大,就该换成 DMA 发送加发送完成回调,否则主循环会被日志拖慢。

两个通道的职责要分清楚,不然后面会乱:

  • USB CDC:面向 PC 的业务数据通道,收命令、回状态,数据有格式、有协议头。
  • USART1:纯调试日志,人看的,可以随时关掉。
  • USART2:对外接设备的数据通道,二进制或文本都行。

主循环里三路数据各有各的环形缓冲,处理逻辑互相独立:

while (1) { /* USB 通道:按行切分,交给命令解析器 */ if (ring_pop_line(&usb_rx_ring, cmd_line, sizeof(cmd_line))) { handle_command(cmd_line); } /* USART2 通道:整帧透传,原样转发到 USB */ uint16_t n = ring_pop_block(&usart2_rx_ring, fwd_buf, sizeof(fwd_buf)); if (n > 0) { USB_Send(fwd_buf, n); } /* 心跳灯,顺便看一眼主循环有没有被卡死 */ if (HAL_GetTick() - last_blink > 500) { last_blink = HAL_GetTick(); HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); } }

心跳灯这个细节看着不起眼,实际调试时特别有用。程序跑飞、HardFault、死循环,第一眼看不出来,但灯不闪了就说明主循环停了,能把问题范围一下子缩小到“卡在哪一行”而不是“整个系统都有问题”。

4. IIC 驱动 SSD1306:从总线时序到显存刷新

OLED 这一路看起来最简单——两根线,一个地址,发命令发数据就完事。但实际调起来,IIC 的死锁和 OLED 的花屏是这套工程里出问题最多的两块。这一章把硬件 IIC 的用法、SSD1306 的命令格式、显存刷新的策略讲透。

4.1 硬件 IIC 与软件 IIC 的取舍

F401 有硬件 I2C 外设,配置好之后一个HAL_I2C_Master_Transmit就能发一包,代码短、CPU 占用低。但硬件 I2C 有个众所周知的毛病:从机在传输中途掉电或者复位,会把 SDA 一直拉低,主机检测到总线忙就永远等下去,HAL_I2C_Master_Transmit超时返回,之后再调全是超时,只能断电重启。

这套工程用的是硬件 I2C,但加了总线恢复逻辑。如果你对稳定性要求更高、板子上 IIC 只挂一个 OLED,软件 IIC 其实更省心——两个 GPIO 翻转,时序自己控,卡住了直接打时钟脉冲就能救回来,不用跟 HAL 库的状态机较劲。

硬件 I2C 的初始化:

void I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 400000; /* 快速模式 400kHz */ hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; hi2c1.Init.OwnAddress1 = 0; hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&hi2c1); }

400kHz 是 SSD1306 支持的上限,再高就不保证能工作了。NoStretchMode必须关掉,因为 SSD1306 在处理命令的时候会拉低 SCL 做时钟延展,主机这边要允许它这么做,禁止了就会传输出错。

4.2 SSD1306 的命令与数据切换

SSD1306 走 I2C 的时候,从机地址是 0x3C(7 位地址,写操作时左移一位变成 0x78),有些模块是 0x3D,具体看模块背面电阻的焊法。区分“这一包是命令还是数据”靠的是一个控制字节:0x00 表示后面全是命令,0x40 表示后面全是显存数据。

HAL 库的HAL_I2C_Mem_Write刚好能直接拿来用,它的MemAddress参数会作为第一个字节发出去,正好就是控制字节:

#define OLED_ADDR 0x3C #define OLED_CMD 0x00 #define OLED_DATA 0x40 static void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR << 1, OLED_CMD, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100); } static void OLED_WriteData(const uint8_t *buf, uint16_t len) { HAL_I2C_Mem_Write(&hi2c1, OLED_ADDR << 1, OLED_DATA, I2C_MEMADD_SIZE_8BIT, (uint8_t *)buf, len, 100); }

地址参数要注意:HAL 库的DevAddress要的是 8 位地址,所以 0x3C 得左移一位变成 0x78。这个细节漏了就是全程超时,而且错误码只告诉你“没有 ACK”,不会告诉你是因为地址错了。

初始化序列按 SSD1306 手册来,顺序不能乱,尤其是0xAE(关显示)必须放在最前面,0xAF(开显示)放在最后:

void OLED_Init(void) { HAL_Delay(100); /* 上电后等电荷泵稳定 */ OLED_WriteCmd(0xAE); /* 关显示 */ OLED_WriteCmd(0x20); OLED_WriteCmd(0x00); /* 水平寻址模式 */ OLED_WriteCmd(0xB0); /* 页地址从 0 开始 */ OLED_WriteCmd(0xC8); /* COM 扫描方向反着来 */ OLED_WriteCmd(0xA1); /* 段重映射,左右别翻 */ OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); /* 开电荷泵,这步漏了屏幕全黑 */ OLED_WriteCmd(0xAF); /* 开显示 */ }

0x8D那个电荷泵命令是最坑的一步。手册里写的是“可选”,但 0.96 寸模块基本都是靠内部电荷泵升压驱动 OLED 的,不开就是黑屏,代码全对、I2C 也有 ACK,就是一点反应没有。

4.3 显存缓冲与页刷新策略

128×64 的屏,一个像素一位,总共 1024 字节。这套工程在 RAM 里开一块 1024 字节的gram数组作为显存镜像,所有绘图操作(画点、画线、写字符)都只改内存,改完调用一次刷新把整屏推过去。

static uint8_t gram[8][128]; /* 8 页,每页 128 列 */ void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); /* 定位到第 page 页 */ OLED_WriteCmd(0x00); /* 列地址低 4 位 = 0 */ OLED_WriteCmd(0x10); /* 列地址高 4 位 = 0 */ OLED_WriteData(gram[page], 128); } }

先定位页再连续写 128 字节,是因为 SSD1306 在写入过程中列地址会自动加一,写到 127 就绕回 0、页地址不变,所以一次连续写正好填满一页。分 8 次写而不是一次写 1024 字节,是为了让每次 I2C 传输控制在 128 字节以内,避免单次传输时间过长被其他中断打断导致时序出错。

整屏刷新 1024 字节,400kHz 下大约 25ms,够用但不算快。如果要做动画,就得改成脏页标记,只刷变化的那几页:

static uint8_t dirty_mask; void OLED_MarkDirty(uint8_t page) { dirty_mask |= (1 << page); } void OLED_RefreshDirty(void) { for (uint8_t page = 0; page < 8; page++) { if (dirty_mask & (1 << page)) { OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); OLED_WriteData(gram[page], 128); dirty_mask &= ~(1 << page); } } }

画点函数顺带把所在页标记成脏页,这样只有真正变化的页才会被推送到屏上。做一个秒表界面,只有显示秒数的那一页需要重刷,其余七页不动,刷新时间从 25ms 降到 3ms 左右。

5. 避坑与排查:调试阶段最常见的 5 个翻车点

前面几章讲的是“应该怎么写”,这一章讲“写完了不работает怎么办”。下面这 5 条都是我在调这套板子时真实踩过的,现象、原因、解决办法一条条列清楚。

5.1 USB 枚举不稳定,插拔几次才认一次

现象:插上电脑,有时候能认出一个 COM 口,有时候提示“无法识别的 USB 设备”,有时候认出来了但传几个字节就断开。

原因:九成是 48MHz 时钟不准。常见写法错误有三种:PLLQ 写成 6 或 8、PLLN 改过之后忘了重算 Q、用了 HSE 但起振失败回退到 HSI 却没重新配 PLL。F401 的 USB 对时钟极其敏感,偏差超过 0.25% 就开始出问题,而且症状是概率性的,很容易误判成“线接触不良”。

解决:把 PLL 的 M/N/P/Q 四个参数按 16/336/4/7 固定死,先在SystemClock_Config里读一下RCC->PLLCFGR确认写入成功,再用 MCO 引脚把 PLLQ 输出到 PA8,示波器量一下是不是 48.000MHz。没有示波器的话,把HAL_RCC_GetPCLK1Freq()和 USB 相关寄存器的值从串口打出来对一遍也行。另外,USB 中断优先级要设成高于 I2C 和串口,否则枚举过程中被一次 25ms 的 OLED 刷新打断,主机端就会判定设备无响应。

5.2 电脑发第一条命令能收到,第二条就没反应

现象:上位机发一条命令,板子正常处理并回包;再发第二条,板子毫无反应;拔插 USB 之后又能收一条。

原因:CDC_Receive_FS回调里漏了重新挂接收的那两行。USB 协议栈处理完一包之后,接收缓冲区处于未就绪状态,必须显式调USBD_CDC_SetRxBuffer和USBD_CDC_ReceivePacket才会接受下一包。

解决:在回调末尾补上这两行,注意顺序——先设缓冲区再挂接收。另外别在回调里做任何耗时操作,printf、HAL_Delay、I2C 写屏全都不能放,否则下一次传输会被拖慢。

5.3 I2C 卡在 Busy,所有传输全部超时

现象:程序跑一段时间后 OLED 不刷新了,单步进去看HAL_I2C_Master_Transmit一直返回HAL_BUSY,I2C1->SR2的 BUSY 位一直是 1,怎么等都不消。

原因:从机在传输中途被打断(比如复位、上电时序不对、电源抖动),SDA 被一直拉低,主机认为总线还在被占用。硬件 I2C 没法自己恢复,只能手动打时钟脉冲把从机的移位寄存器清空。

解决:加一个总线恢复函数,检测到 BUSY 位持续置位超过 10ms 就执行:

void I2C_BusRecover(void) { GPIO_InitTypeDef gpio = {0}; /* 先把 I2C 外设关掉,把引脚切成普通 GPIO */ __HAL_I2C_DISABLE(&hi2c1); gpio.Pin = GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_OUTPUT_OD; gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &gpio); /* 打 9 个 SCL 脉冲,把从机里卡住的位全部顶出来 */ for (int i = 0; i < 9; i++) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } /* 补一个 STOP 条件:SCL 高的时候 SDA 由低变高 */ HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_I2C_Init(&hi2c1); /* 重新初始化外设,引脚复用自动恢复 */ }

9 这个数字不是随便取的,一个字节 8 位加一个 ACK 位,最多 9 个脉冲就能让从机退出当前字节。

5.4 OLED 花屏、上下偏移或只亮一半

现象:显示内容位置不对,字跑到屏幕上半部分或者错位几行,有时候上电前几秒正常,之后慢慢变花。

原因:三类。一是显存没清零,上电时gram数组里是随机值,初始化后直接刷新就是一片雪花;二是页地址和列地址的设置顺序错了,SSD1306 要求先设页再设列;三是初始化序列里0xAE没放最前面,屏幕在配置过程中就显示了一堆中间状态。

解决:OLED_Init末尾加一句memset(gram, 0, sizeof(gram))清空显存再刷新一次。页地址命令0xB0 + page和列地址命令必须在同一页写入之前发完,中间不能插数据。初始化序列严格按手册顺序,0xAE打头、0xAF收尾。

5.5 串口收长数据,后半段凭空消失

现象:发 100 字节的数据,前 30 多字节能收到,后面的全丢了;或者偶尔能收全,大部分时候丢。

原因:两种可能。一是溢出错误 ORE 没清,串口硬件检测到接收寄存器被覆盖后会置 ORE,此时 RXNE 也会一直挂着,HAL 库的状态机就卡在那里,后续数据全进不来。二是空闲中断里先清了标志再读 DMA 计数器,中间这段时间新数据进来,长度就算错了。

解决:HAL_UART_IRQHandler必须在中断函数最后调用,它内部会处理 ORE 并清标志。如果用的是自己写的中断处理,要显式读一下huart->Instance->DR再读SR来清 ORE 标志,这是 F4 系列清 ORE 的标准序列。DMA 计数器必须在停掉 DMA 之后读,顺序不能颠倒。

6. 二次开发:把这套工程改成一台三通道数据终端

三路外设跑通之后,它本身还只是个 Demo。真正有价值的是把这三条通道拼起来,做成一个有实际用途的东西。我给这套工程后来加的功能是:USB 当配置口,USART2 当数据采集口,OLED 当本地状态屏,整体做成一个能挂在外设旁边的数据中转终端。

6.1 加一层轻量命令协议

USB 收到的数据不能直接当字符串处理,得有个简单的帧格式,不然粘包断包没法区分。我用的是最土但最可靠的行协议:以\n结尾,一行一条命令,命令格式是CMD ARG1 ARG2。

/* 环形缓冲里按行切分,返回 1 表示切出完整一行 */ uint8_t ring_pop_line(ring_t *r, char *out, uint16_t max_len) { uint16_t idx = 0; while (ring_count(r) > 0) { uint8_t ch; ring_pop(r, &ch); if (ch == '\n') { out[idx] = '\0'; return 1; /* 拿到一整行 */ } if (idx < max_len - 1) { out[idx++] = ch; /* 超长直接截断,不越界 */ } } return 0; /* 还没收全,下次再来 */ }

命令处理用一张表分发,比一长串if-else好维护:

typedef struct { const char *name; void (*handler)(const char *args); } cmd_entry_t; static const cmd_entry_t cmd_table[] = { { "BAUD", cmd_set_baud }, /* 改 USART2 波特率 */ { "POLL", cmd_set_poll }, /* 开关定时上报 */ { "CLR", cmd_clear_scr }, /* 清屏 */ { "INFO", cmd_show_info }, /* 打印版本和状态 */ }; void handle_command(const char *line) { char name[16] = {0}; const char *args = line; /* 取第一个空格前的部分作为命令名 */ while (*args && *args != ' ' && (args - line) < 15) { name[args - line] = *args; args++; } if (*args == ' ') args++; for (uint32_t i = 0; i < sizeof(cmd_table) / sizeof(cmd_table[0]); i++) { if (strcmp(name, cmd_table[i].name) == 0) { cmd_table[i].handler(args); return; } } USB_Send((const uint8_t *)"ERR unknown cmd\r\n", 17); }

BAUD这条命令特别有用。USART2 接的外设如果波特率不确定,可以先通过 USB 发一条BAUD 9600把串口切过去,不用重新烧程序。这种“运行时改配置”的能力是二次开发里性价比最高的改造点。

6.2 验证方法与改造清单

改完之后怎么确认三路都对?我的习惯是分层验证,一层不通不往下走:

步骤验证方法通不过看哪
1. 时钟MCO 输出 PLLQ,量 48MHzPLL 参数、HSI 校准值
2. USART1上电打印一行版本号波特率、TX 引脚复用
3. OLED开机显示 logo 和版本电荷泵命令、I2C 地址
4. USART2短接 TX/RX 自发自收空闲中断、DMA 配置
5. USB电脑发INFO看回包描述符、接收回调重挂
6. 三路并发USB 发POLL 1,看 OLED 更新、USART2 转发中断优先级、缓冲深度

第 6 步是三路真正合在一起的地方。我一开始只把 USB 中断设成最高优先级,结果串口 DMA 一收大数据,OLED 刷新就被拖慢,屏幕出现撕裂。后来给 OLED 刷新加了脏页机制,单次刷新从 25ms 降到 3ms 以内,撕裂就没了。环形缓冲的深度也要调,USB 那边主机可能一次发几百字节,缓冲开小了就溢出,我最后把 USB 接收环开到 512 字节,串口环开到 1024 字节。

再往上加功能,方向无非是三个:USB 那边挂个上位机做波形显示,USART2 那边接传感器定时轮询,OLED 上做个简单菜单用板载按键翻页。这套骨架的分层做得比较干净,加功能基本只需要在Core/Src里改业务逻辑,Drivers/BSP和USB_DEVICE那两层很少需要动。

有个教训值得说一下。我一开始图省事,把 OLED 刷新、串口解析、USB 处理全塞在主循环里顺序执行,烧进去能跑,但 USB 每次传大包的时候屏幕就卡一下。后来拆成“中断只搬运、主循环只处理”,每个通道独立环形缓冲,才真正稳下来。从那以后我每次开新工程,第一件事都是先把缓冲层和中断分工画出来再写代码,而不是先写功能再补架构。希望这些内容能帮你少走一点弯路,早点把板子上那三路跑起来。

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

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

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

立即咨询