Keil代码_阻塞IIC
153.7.2 Keil 代码:阻塞式 I2C 与输入捕获显示
/* 配套工程:视频配套/3、STM32单片机裸机部分/code/KEIL/8IIC_LOED_IC */
/* Core/Src/tim.c:TIM2_CH2 在 PA1 上捕获,计数频率为 1 MHz */
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71;
htim2.Init.Period = 0xffff;
HAL_TIM_Base_Start_IT(&htim2);
HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_2);
/* Core/Src/iic.c:I2C1 使用 PB6/PB7,阻塞式发送 */
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000;
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7;
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;
/* Core/Src/main.c:配套工程的实际显示逻辑 */
if (ic_state == 2)
{
us_count = ic_count + period_count * 0xFFFF;
printf("High level duration: %ldus \n", us_count);
ic_state = 0;
oled_count++;
}
原题中的 “IIC” 统一按现行规范写作 I2C;文件名保留原称呼以便对照。配套 Keil 工程名为
8IIC_LOED_IC,其中LOED是目录命名笔误。它将前面 TIM2 输入捕获工程、USART1 串口输出和 I2C1 OLED 显示合并在一起。上方代码中的period_count * 0xFFFF是原工程存在的计算错误,正文会给出正确公式,不能照抄。
一、捕获功能实现
本节目标是在上一节已验证的阻塞式 I2C OLED 驱动基础上,把 TIM2 输入捕获得到的高电平宽度显示到串口和 OLED。工程的数据流是:
外部信号 -> PA1 / TIM2_CH2
-> TIM2 捕获寄存器与更新中断
-> HAL_TIM_IC_CaptureCallback / HAL_TIM_PeriodElapsedCallback
-> 主循环计算高电平宽度(us_count)
-> USART1 输出 + 定期刷新 OLED
这里的 IC 是 Input Capture(输入捕获),不是 I2C,也不是 OLED 的 “IC”。输入捕获是由定时器硬件在边沿到来时锁存计数值,I2C 则是主机向 OLED 发送数据的串行总线,两者在本工程中配合但职责完全不同。
工程使用 STM32F103ZE、HSE + PLL 得到 72 MHz 系统时钟,APB1 分频为 2。STM32F1 在 APB 预分频不为 1 时,挂在 APB1 的 TIM2 时钟为 2 × PCLK1 = 72 MHz;TIM2 预分频器设为 71 后,计数频率为:
fTIM = 72 MHz / (71 + 1) = 1 MHz
Ttick = 1 / fTIM = 1 us
因此捕获计数值可直接以微秒解释。这一结论依赖当前时钟树,改动系统时钟、APB1 分频或 Prescaler 后都必须重新计算,不能把“一个计数等于一微秒”当作芯片固有属性。
1.1 复制工程
视频从输入捕获工程复制出一个新工程,再在其中加入 OLED。复制时必须同时复制工程描述文件(.uvprojx)、Core、Drivers 和启动文件的相对目录关系;仅复制 .c 文件不能形成可构建工程。
配套工程的 MDK-ARM/temp.uvprojx 已列出 main.c、uart.c、tim.c、iic.c、oled.c 与 HAL 源文件。若在资源管理器中复制了新文件但没有在 Keil 的 Project 窗口执行 Add Existing Files to Group...,编译器不会编译该 .c 文件,随后链接阶段会出现 Undefined symbol。
工程名称只是标识,不影响 MCU 外设。可改为便于理解的名称,例如 tim2_capture_oled;改名后仍要确认 Keil 工程引用的源码、头文件搜索路径和输出目录没有指向旧位置。
1.2 修改代码
本节把调试串口改为 USART1,实际引脚是:
| 功能 | 引脚 | 源码依据 | 作用 |
|---|---|---|---|
| USART1_TX | PA9 | uart.c | 向 USB 转串口模块发送日志 |
| USART1_RX | PA10 | uart.c | 接收串口数据;本节未使用接收逻辑 |
| TIM2_CH2 | PA1 | tim.c | 输入捕获外部信号边沿 |
| I2C1_SCL | PB6 | iic.c | OLED 时钟 |
| I2C1_SDA | PB7 | iic.c | OLED 数据 |
原笔记把“输入捕获 PA10”写错了:PA10 在本工程中是 USART1_RX,输入捕获引脚实际为 PA1。因此 USART1 与 TIM2_CH2 没有引脚冲突。UART 的 TX 必须接到 USB 转串口模块的 RX,GND 必须共地;若只需要打印而不接收,PA10 可以不接外部模块,但仍不能误接为捕获输入。
改 UART2 为 UART1 时,应同步修改句柄、初始化函数、时钟使能、GPIO、头文件声明和 printf 重定向/宏。只替换函数名而遗漏 __HAL_RCC_USART1_CLK_ENABLE() 或 PA9/PA10 配置,会得到“程序能编译、串口无输出”的问题。
1.3 添加IIC代码
本工程的 iic.c 是手工从 CubeMX 生成代码拆分出来的 I2C1 初始化,oled.c 调用 HAL_I2C_Master_Transmit() 完成阻塞发送。它没有调用 HAL_I2C_Master_Transmit_IT()、HAL_I2C_Master_Transmit_DMA(),也没有 I2C 事件/错误中断服务函数,因此本节实际是阻塞式 I2C。
“工程中有 DMA 库文件”不等于“本功能使用 DMA”。Keil 工程列表中的 stm32f1xx_hal_dma.c、stm32f1xx_ll_dma.c 是 HAL 工程常见的库文件;只要 I2C 句柄没有 DMA 关联、代码没有启动 I2C DMA API,就没有 DMA 传输。不要为减少文件而随意删除 HAL 库,删除后可能引入其他链接问题。
相反,TIM2 中断是输入捕获工作所必需的:HAL_TIM_Base_Start_IT() 用于记录更新(溢出)事件,HAL_TIM_IC_Start_IT() 用于响应捕获边沿,TIM2_IRQHandler() 再调用 HAL_TIM_IRQHandler(&htim2)。它与 I2C 的传输方式无关,不能因为 OLED 采用阻塞模式就删掉 TIM2 中断。
1.4 配置IIC
I2C1 的配置与上一节相同:PB6/PB7 为复用开漏输出、标准模式 100 kbit/s、7 位寻址、NoStretchMode 关闭。总线需要上拉电阻,OLED 模块常自带上拉;供电电压和上拉电平必须与 STM32 的 I/O 电平兼容。
驱动向 OLED 地址 0x78 发起写传输。对于本 STM32F1 HAL,0x78 是 OLED 7 位地址 0x3C 左移一位后的 DevAddress,不是未移位的 7 位地址。一次命令帧为 {0x00, 命令},一次数据帧为 {0x40, 数据};0x00 与 0x40 是 SSD1306 风格控制字节。
I2C 通信异常的优先排查顺序是:电源和共地 -> PB6/PB7 是否接反或短路 -> 上拉是否存在且电平正确 -> 从机是否 ACK 地址 -> 0x3C 与 HAL 传参是否匹配 -> 初始化顺序和返回值。不要通过不断增大超时或插入延时来代替定位 NACK。
1.5 添加库文件
Keil 工程的 C 代码至少需要包含:应用源文件、设备启动文件、CMSIS 头文件和本项目实际使用的 HAL 模块。本工程项目配置中的宏为 USE_HAL_DRIVER,STM32F103xE,头文件搜索路径包括 Core/Inc、HAL 的 Inc 与 CMSIS 的设备头文件目录。
缺少 stm32f1xx_hal_i2c.c 会导致 HAL_I2C_Init、HAL_I2C_Master_Transmit 等未定义;缺少 stm32f1xx_hal_tim.c 或 stm32f1xx_hal_tim_ex.c 会影响 TIM2 初始化与输入捕获;缺少 stm32f1xx_hal_uart.c 会影响串口。应根据编译/链接错误确认所缺模块,而不是盲目把整个 HAL 目录的所有源文件全部加入工程。
1.6 添加OLED代码
OLED 驱动的最小文件关系如下:
main.c -> oled.h -> OLED_Init / OLED_ShowString / OLED_ShowNum
oled.c -> oled.h、oledfont.h、iic.h
iic.c -> iic.h -> hi2c1 与 Init_I2C1
oled.c 必须作为源文件加入 Keil 工程;oled.h、oledfont.h 只需位于已配置的头文件搜索路径或用相对路径包含。oledfont.h 中的字模表应保持 const,它是只读数据;不要因为迁移到 STM32 或 Keil 就删除 const。
现有 oled.h 将 u8、u32 映射为 uint8_t、uint32_t,可兼容旧代码。新代码更推荐直接使用标准定宽类型。显示字符串接口当前声明为 u8 *,但主函数传入的是字符串字面量;更严谨的接口应把只读参数声明为 const char * 或 const uint8_t *,而不是通过强制转换掩盖类型警告。
1.7 配置主函数
主函数的正确初始化顺序是:
HAL_Init -> SystemClock_Config
-> Init_GPIO / Init_UART1 / Init_TIM2 / Init_I2C1
-> OLED_Init -> OLED_Clear -> OLED 显示初始内容
-> while(1) 中处理完成的捕获结果
时钟配置必须早于外设初始化;I2C 初始化必须早于 OLED 初始化;TIM2 捕获要在外部信号到来前启动。配套源码已遵循此顺序。
当前代码把状态变量 ic_state、period_count、ic_count 在 TIM2 回调和主循环之间共享,却没有声明为 volatile。这在优化编译下可能导致主循环读取到过期值。它们应至少声明为 volatile;对于“读取多字段后清除状态”的交接,还应在极短临界区内取得一致快照,或使用单独的完成标志和双缓冲。不要在 TIM2 回调中直接刷新 OLED 或 printf,因为这会把耗时 I2C/UART 工作放入高优先级中断。
1.8 配置烧写设置
Keil 下载配置应以实际调试器和芯片为准:在 Options for Target 的 Debug 选项中选择 ST-LINK(或实际使用的调试器),在其 Settings 中确认 SWD 接口和连接速度,在 Flash Download 中选择与 STM32F103ZE 匹配的片内 Flash 算法。项目文件中的 InvalidFlash=1 只是当时工程配置状态,不能作为“已正确配置下载算法”的证明。
需要下载后自动开始运行时,启用调试器设置中对应的 Reset and Run/运行选项;不同 Keil 版本和调试器插件的文字位置会略有不同。不要笼统地“取消某个 enable 或 Pack 勾选”,那不是通用修复方法。若下载后必须手按复位,检查复位策略、NRST 连接、BOOT0/BOOT1 启动配置、供电稳定性和调试器日志。
1.9 修改屏幕显示内容
初始汉字显示用于确认 OLED 驱动和字库可用。运行期间的关键显示代码为:
OLED_ShowString(0, 4, "Time:", 16);
OLED_ShowNum(40, 4, us_count / 1000, 1, 16);
OLED_ShowString(48, 4, ".", 16);
OLED_ShowNum(53, 4, us_count % 1000 / 100, 1, 16);
OLED_ShowString(61, 4, "ms", 16);
它意图把微秒数显示成一位整数毫秒和一位小数,例如 1500 us 显示为 1.5ms。修改文案、坐标或字库索引后,应同时检查是否越过 128×64 屏幕边界。16 点 ASCII 字符在本驱动中约占 8 个像素列,"Time:" 五个字符约占 40 列,因此后续坐标从 40 开始是合理的。
1.10 配置数字显示接口
OLED_ShowNum(x, y, num, len, size) 的参数含义如下:
| 参数 | 含义 | 本工程示例 |
|---|---|---|
x、y | 显示起始坐标;y 是页坐标而非绝对像素行 | 40, 4 |
num | 待显示的无符号数 | us_count / 1000 |
len | 显示位数/字段宽度 | 1 |
size | 字符字号,当前驱动以 16 走 8×16 字模路径 | 16 |
len 不是时间长度,也不是像素长度。实现会只提取 len 位数字:当 len = 1 时,12 只会显示末位 2。这对本课程常见的 1.0 ms、1.5 ms、2.0 ms 脉宽恰好够用,但测量大于 9.9 ms 的信号时会产生误导。应把整数毫秒位数改为足够的值,或实现固定宽度/自动位数的格式化函数。
表达式 us_count % 1000 / 100 的运算顺序等价于 (us_count % 1000) / 100,得到 0 到 9 的十分之一毫秒位。若要显示三位小数或四舍五入,应明确设计格式,不能靠手调字符坐标拼凑。
1.11 编译并显示数字
输入捕获完成后,主循环计算高电平时间。计数器 Period = 0xFFFF 实际经历的计数序列是 0 到 65535,一次更新周期包含 65536 个计数。因此正确公式是:
ticks = capture_count + overflow_count × (ARR + 1)
= ic_count + period_count × 0x10000
time_us = ticks × 1 us
配套源码用了 period_count * 0xFFFF,每跨越一次溢出就少算 1 个 tick(当前配置即少算 1 us)。短于 65.536 ms 的脉宽没有溢出,所以暂时看不出该错误;长脉宽或多次溢出时误差会累积。不要把 0xFFFF 同时当作 ARR 值和周期长度。
TIM2 的捕获寄存器在硬件边沿到来时锁存,阻塞式 OLED 发送运行在主循环中,且 TIM2 中断优先级被设置为 0。因此 OLED 传输本身不会改变已经锁存的捕获时刻,原笔记“主循环 I2C 一定破坏微秒级捕获精度”的表述过于绝对。真正的风险是:
- 当前程序只有一组共享变量和简单状态机,主循环尚未取走结果时若发生新的脉冲,旧结果可能被覆盖。
- OLED 驱动每显示一个 8×16 ASCII 字符就需发送页/列命令和 16 个数据字节,并且每个字节都单独发起阻塞 I2C 事务。一次
Time:x.xms刷新约有 190 笔事务,在 100 kbit/s 下仅总线时钟时间就约 50 ms,前台响应会明显变慢。 printf宏先用无边界的sprintf写入 256 字节u_buf,再阻塞发送串口数据。格式字符串或数据变长时存在缓冲区溢出风险,日志也会占用主循环时间。
因此源码以 oled_count > 20 节流,实际是每 21 个完成脉冲才更新一次 OLED。这个策略能减少前台 I2C 负担,但不是通用的并发保护。更可靠的方案是让中断只产生时间戳/事件,主循环以固定刷新周期读取最近一次完整快照;字符串格式化应使用 snprintf 并检查返回值。
1.12 规避屏幕乱码问题
屏幕乱码、跳字和串口数据异常应分层定位:
| 现象 | 优先检查项 |
|---|---|
| 完全无显示 | 电源、GND、I2C 上拉、PB6/PB7、从机地址 ACK、初始化命令 |
| 偶发乱码 | I2C NACK/总线波形、供电波动、驱动返回值、缓冲区或字库越界 |
| 数值跳变 | 捕获边沿信号质量、状态机、溢出公式、共享变量并发访问 |
| 显示滞后 | OLED 刷新频率、逐字节阻塞传输、串口打印量,而非先怀疑定时器硬件 |
不要在每次捕获回调中清屏并重绘整屏。保持静态标签不变,只更新改变的数字区域;把同一页数据打包为连续 I2C 写;限制刷新频率,都会比任意增加 HAL_Delay() 更有效。若需要较高刷新率,再考虑完整实现的 I2C 中断/DMA 队列,而不是直接替换 API 名称。
二、阻塞IIC代码编写
2.1 修改阈值
原笔记中“将参数从 200 改为 20”指的是 OLED 刷新节流阈值,而不是输入捕获的硬件阈值。当前源码为 if (oled_count > 20),由于判断使用严格大于,它会在计数为 21 时刷新,而非 20 时刷新。
阈值应由输入信号频率和可接受的显示延迟决定。例如 50 Hz PWM 每 20 ms 一次,21 个样本约 420 ms 刷新一次;若阈值为 200,则约 4 s 才刷新一次。降低阈值会提高显示更新速度,也会增加 I2C 和前台处理负担,不能简单认为数值越小测量越准确。
2.2 问题分析与解决
2.2.1 显示过程问题解决
排查顺序应当是先保留串口和捕获,确认 us_count 合理;再只显示固定文字,确认 OLED 通信;最后加入动态数值并降低刷新频率。这样可以明确问题发生在定时器、I2C 还是字符串格式化层。
阻塞 I2C 适合当前低频显示任务,但每次调用都要等待传输完成。将 OLED 重绘放在主循环、按完成事件节流,已经比在中断回调中重绘安全得多。若后续增加多个任务,应设计非阻塞状态机或 RTOS 任务间通信,而不是让不同位置随意调用 OLED 函数。
2.2.2 字符串显示问题解决
OLED_ShowString() 以 \0 结尾判断字符串结束,传入的数据必须是有效的以零结尾字符数组或字符串字面量。不要把未初始化的字节缓冲区当字符串传入,也不要让字符串越过屏幕宽度后继续写而不处理换行或裁剪。
本驱动的接口应改为接收只读字符串指针;显示数值时优先用受限长度的格式化缓冲区,例如 snprintf(buf, sizeof buf, "Time:%lu.%lu ms", ...),随后一次显示字符串。这里仍应根据屏幕宽度决定字符数和字号。
2.2.3 按键问题解决
本节源码没有按键扫描逻辑,视频中的“按键”实际是在观察输入脉冲宽度,不应把它当作独立的 GPIO 按键驱动。对于典型高电平脉宽:
2500 us = 2.5 ms
1000 us = 1.0 ms
1500 us = 1.5 ms
单位换算必须基于当前 TIM2 的 1 us tick。若计数频率改变为 2 MHz,则一个 tick 为 0.5 us,原来的除法和显示逻辑都需要调整。
2.2.4 优化屏幕显示效果
坐标应按字模大小计算,而不是反复试出 “40、48、53、61”。当前 16 点 ASCII 字符宽约 8 像素,Time: 占 5 × 8 = 40 像素;一位数字在 x=40;小数点在 x=48;十分位在 x=53 是为了让小数点和数字更接近;ms 在 x=61。
这种手工排版适合固定一位整数和一位小数。若数字位数可变,应通过格式化完整字符串或计算字符宽度来布局,否则数值从 9.9 变为 10.0 时会重叠或截断。
2.2.5 数字与点靠近问题解决
小数点字模宽度通常小于数字字模,使用统一的 8 像素步进会显得间距偏大。源码以 x=53 而非 x=56 显示小数位,是人为缩短视觉间距的做法,功能上没有问题。
更可维护的做法是由显示函数返回实际字宽,或者把整数、小数点、小数位组成一个字符串后使用同一套字距规则。不要通过修改测量数值来移动显示位置,显示布局和计时计算必须彼此独立。
2.3 课程总结
本节完成了“TIM2 输入捕获 -> 串口/ OLED 显示”的工程移植,重点是将 I2C1 阻塞驱动正确加入 Keil 工程,并理解长耗时显示应在主循环节流执行。当前 OLED 只演示了主机发送;要学习完整 I2C 读写,可再使用串行 EEPROM 做随机读、页写、写周期 ACK 轮询和掉电保持实验。
原笔记中 AT24C02 “2KB” 的表述需要更正:常见 AT24C02/AT24C02C 是 2 Kbit = 256 × 8 bit = 256 B,不是 2 KiB。型号中的容量通常以 Kbit 表示,具体页大小、电压范围、写保护和可级联数量还会随厂商及料号变化,应以购买模块所用器件的数据手册为准。Microchip 的 AT24C02C 原厂资料 明确给出 2 Kbit、256 x 8 组织方式。
完成本节后,应能独立检查:Keil 是否已将源文件加入工程;I2C 引脚是否为开漏且有正确上拉;TIM2 的 tick 周期与溢出公式是否匹配;中断和主循环共享的数据是否安全;动态 OLED 刷新是否被限制在合理频率。它们比“能显示一次”更接近可维护的嵌入式工程实践。