数据显示

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

188.1.11 数据显示

/* OLED/oled.h:本课为 STM32 工程保留和调整的关键声明。 */
#ifndef __OLED_H /* 如果尚未定义 OLED 头文件保护宏,就展开本文件。 */
#define __OLED_H /* 定义保护宏,防止重复包含。 */
#include "main.h" /* 引入 STM32 HAL 与 uint8_t、uint32_t 等固定宽度类型。 */

#define u8 uint8_t /* 保留原驱动的 u8 名称,并把它映射到 STM32 工程使用的 8 位无符号类型。 */
#define u32 uint32_t /* 保留原驱动的 u32 名称,并把它映射到 32 位无符号类型。 */
#define OLED_CMD  0 /* OLED_WR_Byte() 的第二个参数为 0 时发送命令。 */
#define OLED_DATA 1 /* OLED_WR_Byte() 的第二个参数为 1 时发送显示数据。 */
#define Max_Column 128 /* 定义屏幕一行包含 128 个像素列。 */

void OLED_WR_Byte(unsigned dat,unsigned cmd); /* 声明按 cmd 选择“命令”或“数据”的统一写接口。 */
void OLED_Init(void); /* 声明 OLED 控制器初始化函数。 */
void OLED_Clear(void); /* 声明清空 128×64 显存显示内容的函数。 */
void OLED_ShowChar(u8 x,u8 y,u8 chr,u8 Char_Size); /* 声明显示单个字符的函数。 */
void OLED_ShowString(u8 x,u8 y, u8 *p,u8 Char_Size); /* 声明从指定坐标显示字符串的函数。 */
void Write_IIC_Command(unsigned char IIC_Command); /* 声明通过硬件 I²C 发送一条 OLED 命令的函数。 */
void Write_IIC_Data(unsigned char IIC_Data); /* 声明通过硬件 I²C 发送一个显存数据字节的函数。 */
#endif /* 结束 OLED 头文件保护范围。 */

/* OLED/oled.c:本课把原软件 I²C 写操作替换成以下硬件 I²C 调用。 */
#include "oled.h" /* 引入 OLED 类型、宏和函数声明。 */
#include "oledfont.h" /* 引入 6×8、8×16 字模数组。 */
#include "i2c.h" /* 引入 CubeMX 生成的 hi2c1 和 HAL I²C 声明。 */

void Write_IIC_Command(unsigned char IIC_Command) /* 发送一个 SSD1306 命令字节。 */
{
	uint8_t IIC_Cmd_Buff[]={0x00, IIC_Command}; /* 组成“控制字节 0x00 + 命令”两个字节。 */
	HAL_I2C_Master_Transmit(&hi2c1, 0x78, IIC_Cmd_Buff, 2, 100); /* 用 I2C1 阻塞发送,设备地址参数为 0x78,超时 100 ms。 */
}

void Write_IIC_Data(unsigned char IIC_Data) /* 发送一个写入 OLED GDDRAM 的数据字节。 */
{
	uint8_t IIC_Data_Buff[]={0x40, IIC_Data}; /* 组成“控制字节 0x40 + 显示数据”两个字节。 */
	HAL_I2C_Master_Transmit(&hi2c1, 0x78, IIC_Data_Buff, 2, 100); /* 用相同 I²C 地址发送两字节数据包。 */
}

void OLED_WR_Byte(unsigned dat,unsigned cmd) /* 根据 cmd 选择数据通道或命令通道。 */
{
	if(cmd) /* cmd 非 0 时进入显示数据分支。 */
	{
		Write_IIC_Data(dat); /* 把 dat 作为显存数据发送。 */
	}
	else /* cmd 为 0 时进入控制命令分支。 */
	{
		Write_IIC_Command(dat); /* 把 dat 作为控制器命令发送。 */
	}
}

void OLED_ShowString(u8 x,u8 y,u8 *chr,u8 Char_Size) /* 从 (x,y) 开始逐字符显示以 \0 结尾的字符串。 */
{
	unsigned char j=0; /* 从字符串第 0 个字节开始。 */
	while (chr[j]!='\0') /* 遇到 C 字符串结束符前持续显示。 */
	{
		OLED_ShowChar(x,y,chr[j],Char_Size); /* 在当前坐标显示第 j 个字符。 */
		x+=8; /* 当前 8×16 字体每个字符向右推进 8 像素。 */
		if(x>120){x=0;y+=2;} /* 一行放不下下一个 8 像素字符时,回到第 0 列并下移两个页。 */
		j++; /* 处理下一个字符。 */
	}
}

/* main.c:本课新增的显示端业务代码。 */
#include "oled.h" /* 引入 OLED 初始化、清屏和字符串显示接口。 */
#define ZET6 1 /* 1 编译 ZET6 接收/显示代码,0 编译 C8T6 采集/发送代码。 */

uint8_t OLED_BUFF[20]; /* 保存 sprintf() 生成的温湿度显示字符串。 */

MX_I2C1_Init(); /* CubeMX 生成的 I2C1 初始化调用:标准模式 100 kHz,PB6/PB7。 */

#if ZET6 /* 只在 ZET6 接收端初始化并清空 OLED。 */
	OLED_Init(); /* 按厂商驱动中的命令序列初始化显示控制器。 */
	OLED_Clear(); /* 把各页显存写 0,清除上电后的残留图案。 */
	OLED_ShowString(32, 0, "Can Test",16); /* 在第 0 页起始、横坐标 32 处显示 8×16 标题。 */
#endif /* 结束 OLED 初始化条件编译。 */

/* 以下语句位于 ZET6 接收分支的 Recv_Len > 0 代码块中。 */
sprintf(OLED_BUFF,"H:%u  T:%u",Rx_Data[0],Rx_Data[2]); /* 把湿度整数和温度整数格式化成字符串。 */
OLED_ShowString(0, 2, OLED_BUFF,16); /* 从第 0 列、第 2 页开始显示 16 像素高的测量结果。 */
memset(Rx_Data, 0, 8); /* 显示完成后再清空接收缓冲区;顺序不能提前。 */

源码范围说明:本课使用的是中景园提供的 51 单片机 OLED 驱动,课程真正修改的是底层 I²C 写命令/写数据接口、类型与头文件,以及 main.c 的显示调用。字模表、图片数组和整套厂商初始化命令没有在课堂中逐行编写,因此不重复抄录;上方保留了实际修改与重点讲解的业务代码。所有函数调用和常量依照配套 04Can_DHT11_OLED 工程保留,只在注释中说明风险。

一、OLED屏幕代码编写

原笔记多次把 OLED 识别成“LED 屏幕”,把 I²C 识别成“FMC/iPhone c”。本课使用的是一块 128×64 单色 OLED 模块,配套驱动注释表明控制器按 SSD1306 方式初始化;通信总线是 I²C,不是 FSMC/FMC,也不是普通 LED 点阵扫描。

课程把 51 单片机的 GPIO 模拟 I²C 驱动移植到 STM32F103 的硬件 I2C1。移植后数据路径为:

OLED_ShowString()
  → OLED_ShowChar()
    → OLED_WR_Byte(dat, OLED_CMD / OLED_DATA)
      → Write_IIC_Command() / Write_IIC_Data()
        → HAL_I2C_Master_Transmit()
          → PB6/SCL、PB7/SDA → OLED

1.1 工程复制

课程从 03Can_DHT11 复制出 04Can_DHT11_OLED,再把公共 OLED 文件夹复制进新工程。复制进工程目录可以避免项目移动后丢失驱动或继续引用外部旧文件。

当前工程中:

  • C8T6 继续读取 DHT11 并通过 CAN 发送;
  • ZET6 通过 CAN 接收 5 字节数据,并在 OLED 上显示;
  • OLED 只接到 ZET6,因此初始化与显示代码需要受接收端条件编译控制。

1.2 添加oled文件

在 Keil 的 Bsp 分组中加入新工程自己的 OLED/oled.c,并在 Include Paths 中加入 04Can_DHT11_OLED/OLED。必须核对路径属于第四个工程,避免编辑新副本却仍然编译第三个工程的驱动。

厂商驱动还引用 oledfont.h 和可能的 bmp.h。它们是头文件数据资源,只要目录在包含路径中即可;真正需要作为编译单元加入工程的是 .c 文件。

1.3 编译错误

原驱动面向 Keil C51,包含 8051 寄存器头文件、软件 I²C 引脚宏、code 存储区限定符和自定义整数别名。直接加入 STM32 工程会出现“找不到 reg*.h”、语法错误或类型展开错误。

课程删除了仅服务于 51 软件 I²C 的内容,包括 SDA/SCL 位定义、毫秒延时、Start、Stop、WaitAck 和逐位 WriteByte。删除的理由不是这些概念在 I²C 协议中消失了,而是 STM32 的 I2C1 外设和 HAL 驱动已经在硬件/驱动层完成起始条件、停止条件、ACK 检查及字节移位。

1.4 配置工程

在 CubeMX 中启用 I2C1,选择默认的 100 kHz 标准模式,不启用 I²C 中断。本工程生成的硬件配置为:

信号STM32F103ZET6 引脚GPIO 模式
I2C1_SCLPB6复用开漏输出
I2C1_SDAPB7复用开漏输出

I²C 使用开漏结构,SCL 和 SDA 都必须有上拉电阻才能产生高电平。很多 OLED 模块板已经集成上拉,但并非所有模块都相同;如果总线始终为低、没有 ACK 或边沿很慢,应查看模块原理图并测量,而不是盲目再并联电阻。

CubeMX 生成 MX_I2C1_Init(),其关键参数为 100000 Hz、7 位寻址、关闭双地址和广播呼叫。生成后在 Keil 中 Reload,并确认自定义 OLED 文件及 Include Path 没被工程重生成覆盖。

1.5 移植代码

1.5.1 移植 I²C 启动和停止函数

软件 I²C 需要程序逐次拉高/拉低 SCL、SDA,所以驱动里有 Start、Stop 和等待 ACK 等函数。硬件 I2C1 模式下,程序只需提交设备地址和数据缓冲区,外设负责波形与 ACK/NACK 流程,因此这些旧函数从本移植版本删除。

“硬件 I²C 不需要等待 ACK”并不准确:协议仍然需要 ACK,只是等待和错误检测由 I2C 外设/HAL 完成。应用仍应检查 HAL_I2C_Master_Transmit() 的返回值,区分成功、超时、忙和 NACK;课堂代码没有检查,这一限制在代码块中保留。

1.5.2 移植 I²C 写字节函数

旧 Write_IIC_Byte() 只负责在已经开始的软件事务中逐位发一个字节;HAL 的 HAL_I2C_Master_Transmit() 则一次接收设备地址、内存缓冲区、长度和超时,抽象层级不同,不能仅把函数名替换掉。

本课最终让每次 OLED 写操作都形成一个独立的两字节 I²C 事务:

  • 命令:[0x00, command];
  • 数据:[0x40, data]。
1.5.3 移植 I²C 写命令函数

IIC_Cmd_Buff[0] = 0x00 是 SSD1306 控制字节:Co=0、D/C#=0,表示随后内容作为命令;IIC_Cmd_Buff[1] 才是具体命令。

HAL_I2C_Master_Transmit() 的 DevAddress 参数要求把数据手册中的 7 位地址左移一位。本模块使用的 7 位地址为 0x3C,所以传给 HAL 的值是:

0x3C << 1 = 0x78

因此 0x78 是包含地址左移位置的 HAL 参数形式,不应再把它当作另一个独立数据字节;真正的数据长度是 2,而不是 3。

1.5.4 移植 I²C 写数据函数

显示数据包使用控制字节 0x40:Co=0、D/C#=1,表示后续字节写入 GDDRAM。命令和数据的设备地址相同,区别在第一个控制字节。

1.5.5 移植发送相关函数

硬件发送调用的五个参数分别是:

参数本课取值含义
hi2c&hi2c1使用 I2C1 外设
DevAddress0x787 位地址 0x3C 左移一位后的 HAL 参数
pData两字节数组控制字节加命令/显示数据
Size2本次发送两个数据字节
Timeout100阻塞调用最多等待 100 ms,而不是“随便填一个无所谓的数”

超时值太短可能在总线拥塞或异常时提前失败,太长则会让故障时阻塞更久。课堂选择 100 ms 对当前两字节、100 kHz 实验足够宽松,但仍应检查函数返回值。

1.5.6 统一命令与数据写接口

OLED_WR_Byte(dat, cmd) 是上层统一入口:第二个参数非 0 时调用 Write_IIC_Data(),为 0 时调用 Write_IIC_Command()。驱动中的 OLED_CMD 与 OLED_DATA 宏分别为 0 和 1,使调用处可读性更好。

1.5.7 应用案例
1.5.7.1 例题:I²C 写数据函数应用

初始化、设置页/列地址等控制操作使用 OLED_WR_Byte(value, OLED_CMD);字模像素真正写入显存时使用 OLED_WR_Byte(value, OLED_DATA)。同一个 value 是否改变屏幕配置或点亮像素,由控制字节中的 D/C# 位决定。

1.5.7.2 例题:OLED 屏幕初始化应用

OLED_Init() 按驱动既定顺序发送关闭显示、列/页起始、扫描方向、多路复用比、预充电、COM 配置、VCOMH、充电泵和打开显示等命令。本课没有重新推导每个常量;换控制器或不同分辨率模块时,不能不加核对地照搬该序列。

SSD1306 的 I²C 格式、0x00/0x40 控制字节及 0x3C/0x3D 地址选择可对照 Solomon Systech SSD1306 原厂数据手册镜像。

1.5.7.3 例题:屏幕清屏操作应用

OLED_Init() 只配置控制器,不保证显示 RAM 全为 0;随后调用 OLED_Clear(),会遍历 8 个页、每页 128 列写入 0。128×64 单色屏共 128 × 64 / 8 = 1024 字节显存,因此逐字节、每次又发一个独立 I²C 事务的清屏过程并不轻量,但适合当前入门实验。

1.6 编译错误处理

1.6.1 警告与错误分析

课程按第一个编译错误逐步处理,这是正确的排错顺序,因为前面的语法或宏错误可能制造大量后续连锁报错。

C51 的 code 关键字用于指定对象存放在程序存储区,它不是标准 C,也不是 ARM Compiler 对普通数组使用的同名限定符。移植到 STM32 时删除字模/图片数组前的 code,数组会按 ARM 工具链规则放入只读段或相应存储区。

1.6.2 代码修改与编译尝试

课程检查并删除了字模文件中多处 code,每改一轮立即重新 Build。不能因为报错行号落在某个大数组附近就删除数组本身;应只移除不兼容限定符并保留字模数据。

1.6.3 特定文件缺失问题

实际生成文件名是 i2c.h,不是 OCR 中的 a2c/l2c。#include 名称必须与文件一致;Windows 默认文件系统通常不区分大小写,但工程移到大小写敏感环境后会暴露问题,所以名称和大小写都应统一。

1.6.4 数据类型与编译器问题

编译器跳到 core_cm3.h 并不表示 CMSIS 内核文件有错误。宏展开、前一个头文件的语法错误或名称污染都可能让错误在后续系统头文件中才显现。应检查最近加入的 oled.h 及其包含顺序。

1.6.5 代码重构与问题解决

课程把旧驱动的 u8/u32 映射改为 uint8_t/uint32_t,并包含 STM32 工程头文件,使类型宽度清晰。更现代的代码可以直接使用标准类型而不保留短别名,但本课为减少厂商驱动改动,保留了 u8/u32 接口。

1.6.6 最终编译与验证

改完 C51 头文件、code、I²C 头文件名和类型后,工程编译通过。“编译通过”只能证明声明、类型和链接基本成立;I²C 是否收到 ACK、屏幕地址是否正确,仍需实物或逻辑分析仪验证。

1.6.7 冗余代码删除

IIC_Start()、IIC_Stop()、IIC_Wait_Ack() 和旧 Write_IIC_Byte() 已没有调用者,应同时删除头文件声明,避免留下“能声明但没有定义”的误导接口。删除前可用全工程引用搜索确认没有其他模块依赖。

1.7 编译测试

先调用 OLED_ShowString(32, 0, "Can Test", 16) 做最小测试。Char_Size=16 时,驱动用 8×16 字模:每个字符宽 8 像素、高 16 像素,占两个 8 像素页。

"Can Test" 包含 8 个字符(空格也占一个字符位),总宽 8 × 8 = 64 像素。128 像素宽屏幕的居中起点为:

x = (128 - 64) / 2 = 32

烧写时课程先误把接收固件写入 C8T6,随后把条件值改为 0 重编译并恢复发送固件,再把条件值改回 1,切换 ST-Link 到 ZET6 后烧写接收/显示固件。这再次说明每次切换目标板都要核对条件编译、Build 结果和物理连接。

1.8 应用案例

1.8.1 例题:测试屏幕显示应用

sprintf(OLED_BUFF, "H:%u T:%u", Rx_Data[0], Rx_Data[2]) 把两个无符号整数转为以 \0 结尾的字符串,例如 H:24 T:17。20 字节缓冲区足以容纳当前 DHT11 数值范围和结束符,但若以后扩充格式,应重新计算最坏长度,避免 sprintf 越界。

OLED 的 y 参数按页而不是单个像素行解释。16 像素高字符会占两个页,所以常用 y=0、2、4、6 排列四行 16 像素文字;y=2 是第二个 16 像素文本行的起始页。

OLED_BUFF 类型为 uint8_t[],而标准 sprintf() 的目标类型是 char *;许多 ARM 编译器只给出指针符号属性警告或接受它,但严格接口设计可使用 char 缓冲区,再在显示接口边界明确转换。课堂源码按原样保留。

1.8.2 例题:串口助手接收数据并显示

串口日志先验证 ZET6 确实收到 ID 0x12、DLC 5 和随触摸变化的数据;OLED 再显示 Rx_Data[0] 与 Rx_Data[2]。串口与屏幕同时变化,有助于把问题定位为 CAN 接收、格式化还是 OLED 输出,而不是只看一块黑屏猜测。

二、问题解答

第一次运行时,串口数据正确而 OLED 显示 0。根因是程序先执行 memset(Rx_Data, 0, 8),后执行 sprintf(),显示逻辑读到的自然是清零后的数组。

修正后的数据生命周期为:

CAN 写入 Rx_Data → 串口打印 → sprintf 格式化 → OLED 显示 → memset 清零

这说明同一个缓冲区被多个步骤共享时,必须先完成所有消费者的读取,再释放或清零。清零不是接收成功的必要动作;如果下一帧会按 DLC 覆盖有效区域,并且程序只在成功接收后使用数据,也可以不依赖清零判断新旧数据。

三、OLED代码分析

上层显示函数最终都会落到 OLED_WR_Byte():

  • OLED_ShowNum() 把数字拆成字符;
  • OLED_ShowString() 逐个调用 OLED_ShowChar();
  • OLED_ShowChar() 从字模数组取每列像素数据;
  • OLED_Set_Pos() 用命令设置页与列地址;
  • OLED_WR_Byte() 再决定写命令还是写 GDDRAM 数据。

本课移植的关键不是重写字体和绘图算法,而是把最底层原软件 I²C 的 39~46 行附近替换成两个硬件 I²C 函数。只要这个传输边界正确,上层清屏、字符、字符串、数字、汉字和图片函数就能继续复用。

当前实现每发一个命令或数据字节都调用一次阻塞式 HAL_I2C_Master_Transmit(),开销较大。实际项目可以把连续控制命令或整段字模打包发送,但这属于后续优化,不能反向改写本课源码。

四、工程代码分析

同一个工程轮流为 C8T6 和 ZET6 构建,课程最终用:

#define ZET6 1

统一控制初始化区和 while(1) 业务区:

  • ZET6 == 1:编译 CAN 接收、OLED 初始化和数据显示;
  • ZET6 == 0:编译 DHT11 读取和 CAN 发送。

相比在两处分别手改 #if 1/#if 0,单一宏能避免一处改了、另一处忘改。不过宏名表达的是板型而不是业务角色;未来如果 ZET6 也要发送或 C8T6 也要接收,更清楚的命名应描述 RECEIVER_NODE 等角色。课堂使用 ZET6,代码块按原样保留。

条件编译发生在构建阶段,不会让同一个已烧录固件在运行时自动识别板型。每次改宏后都必须重新编译,并把对应构建结果烧入正确目标板。