|
|
发表于 2026-9-9 14:29:40
|
显示全部楼层
这是个在OLED 128×64 屏幕上显示8×16的ASCII字符的驱动程序
如果是自己用,这样写是可以的。如果是分享给他人品鉴,最好是给代码添加上一些注释,让他人更容易理解你的编程思路。
下面是让AI助手给你的代码添加注释后的样子
其中AI描述错误的地方也已手动更正
/*=====================================================================
* 文件名:oled_test
* 功能: 基于 STC89C52RC 单片机,通过软件模拟 I2C 总线驱动
* SSD1306 控制芯片的 OLED 显示屏(128x64 像素),显示8×16 ASCII 字符
*
* 硬件连接:
* SCL ---> P0.0
* SDA ---> P0.1
*
* 重要说明(P0口特性):
* STC89C52RC 的 P0 口上电复位后为【开漏输出】模式,
* 作为 I/O 口使用时需外加 10K~4.7K 上拉电阻。
* 市售开发板通常已为 P0 口配置上拉排阻,可直接使用;
* 若自搭最小系统则必须另外加上拉电阻。
*
* 编译环境:Keil C51
*=====================================================================*/
#include <STC89C5xRC.H>
#include "ASC.h" // OLED_F8x16 8x16点阵ASCII字库(外部头文件)
/* 定义 I2C 引脚:SCL 接 P0.0,SDA 接 P0.1 */
sbit SCL = P0^0;
sbit SDA = P0^1;
/* 类型重定义,方便书写 */
typedef unsigned char u8;
typedef unsigned int u16;
/*=====================================================================
* OLED 初始化命令表(SSD1306 标准初始化序列,共19条)注1
* 前18条为初始化命令,最后一条 0xAF 为开显示命令
*=====================================================================*/
u8 code oled_init_table[19] = {
0xae, // 关显示(Display OFF)
0xa8, // 设置多路复用率(Multiplex Ratio)
0x3f, // 1/64 多路复用(64行)
0xd3, // 设置显示偏移量(Display Offset)
0x00, // 偏移量为 0
0x40, // 设置起始行地址(Start Line = 0)
0xa1, // 段重映射(Segment Re-map):列地址 0 → SEG127,列地址 127 → SEG0,水平镜像
0xc8, // 行扫描方向(COM Scan Direction):自底向上扫描,垂直镜像
0xda, // 设置 COM 硬件配置
0x12, // 使用交替的 COM 引脚排列方式来驱动 64 行像素
0x81, // 设置对比度(Contrast)
0xff, // 对比度设为最大值 255(0~255)
0xa4, // 显示模式:正常显示(RAM 内容全部显示)
0xa6, // 显示模式:非反相(不取反),正常显示,不反色
0xd5, // 设置显示时钟分频比 / 振荡频率
0x80, // 高四位:振荡频率 = 8(0~15),低四位:分频因子 = 1((0~15) +1)
0x8d, // 电荷泵设置(Charge Pump)
0x14, // 启用电荷泵(使 OLED 内部升压电路工作)
0xaf // 开显示(Display ON)
};
/*=====================================================================
* 函数名:I2C_Start
* 功能: 产生 I2C 总线起始信号
* 时序: SCL 为高电平时,SDA 产生下降沿(1→0)
*=====================================================================*/
void I2C_Start(void)
{
SDA = 1; /* 确保 SDA 初始为高 */
SCL = 1; /* SCL 拉高,此时 SDA 发生跳变才有效 */
SDA = 0; /* SDA 由高变低,产生起始条件 */
SCL = 0; /* SCL 拉低,为后续数据传输做准备 */
}
/*=====================================================================
* 函数名:I2C_Stop
* 功能: 产生 I2C 总线停止信号
* 时序: SCL 为高电平时,SDA 产生上升沿(0→1)
*=====================================================================*/
void I2C_Stop(void)
{
SDA = 0; /* 确保 SDA 初始为低 */
SCL = 0; /* SCL 拉低 */
SDA = 1; /* SDA 由低变高,产生停止条件 */
SCL = 1; /* SCL 拉高(此时总线空闲) */
}
/*=====================================================================
* 函数名:I2C_Send
* 功能: 通过 I2C 总线发送一个字节的数据(8位)
* 说明: 从最高位(MSB,bit7)开始逐位发送,
* 每发送一位后 SCL 产生一个高电平脉冲锁存数据,
* 8 位发送完成后产生第 9 个时钟脉冲用于读取从机 ACK
* (本实现未检测 ACK 应答,OLED 正常响应时不影响使用)
*=====================================================================*/
void I2C_Send(u8 dat)
{
u8 i;
/* 逐位发送数据,从最高位 bit7 到最低位 bit0 */
for(i = 0; i < 8; i++)
{
/* 0x80>>i 依次为 0x80,0x40,0x20,...,0x01,
* 与 dat 相与后取出对应位的值赋给 SDA */
SDA = (dat & (0x80 >> i));
SCL = 1; /* SCL 拉高,数据有效,从机在此沿锁存 */
SCL = 0; /* SCL 拉低,完成一位传输 */
}
/* 第 9 个时钟:主机释放 SDA,等待从机拉低应答(ACK) */
SDA = 1; /* 释放 SDA(由从机驱动) */
SCL = 1; /* 第 9 个时钟高电平 */
SCL = 0; /* 第 9 个时钟低电平,ACK 周期结束 */
}
/*=====================================================================
* 函数名:oled_wc 注2
* 功能: 向 OLED 发送一个控制命令(写命令模式)
* 协议: I2C 从机地址 0x78(写),控制字节 0x00(Co=0, D/C#=0 表示命令)
*=====================================================================*/
void oled_wc(u8 wc)
{
I2C_Start(); /* I2C 起始信号 */
I2C_Send(0x78); /* 发送从机地址(0x78 = 0x3C<<1,写方向) */
I2C_Send(0x00); /* 控制字节:Co=0, D/C#=0 -> 后续字节为命令 */
I2C_Send(wc); /* 发送命令字节 */
I2C_Stop(); /* I2C 停止信号 */
}
/*=====================================================================
* 函数名:oled_wd
* 功能: 向 OLED 发送一个显示数据(写数据模式)
* 协议: I2C 从机地址 0x78(写),控制字节 0x40(Co=0, D/C#=1 表示数据)
*=====================================================================*/
void oled_wd(u8 wd)
{
I2C_Start(); /* I2C 起始信号 */
I2C_Send(0x78); /* 发送从机地址(0x78 = 0x3C<<1,写方向) */
I2C_Send(0x40); /* 控制字节:Co=0, D/C#=1 -> 后续字节为数据 */
I2C_Send(wd); /* 发送数据字节 */
I2C_Stop(); /* I2C 停止信号 */
}
/*=====================================================================
* 函数名:oled_setpox 注3
* 功能: 设置 OLED 的显示位置(页地址 + 列地址)
* 参数: x = 像素列地址(0~127)
* y = 页地址(0~7),每页包含 8 行像素
* 说明: SSD1306 显存分为 8 页(Page0~7)× 128 列(Column0~127)
*=====================================================================*/
void oled_setpox(u8 x, u8 y)
{
/* 页地址命令:0xB0 + 页号(0xB0~0xB7 对应页0~页7) */
oled_wc(0xb0 + y);
/* 列地址高 4 位命令:0x10~0x1F,取列地址的高 4 位 */
oled_wc(((x & 0xf0) >> 4) | 0x10);
/* 列地址低 4 位命令:0x00~0x0F,取列地址的低 4 位 */
oled_wc(x & 0x0f);
}
/*=====================================================================
* 函数名:oled_init 注4
* 功能: OLED 初始化
* 步骤: 1. 发送初始化命令序列(前 18 条)
* 2. 清空整个屏幕(写入 0x00,全黑)
* 3. 发送开显示命令(第 19 条,0xAF)
*=====================================================================*/
void oled_init(void)
{
u16 i;
/* 发送前 18 条初始化命令(不含开显示 0xAF) */
for(i = 0; i < 18; i++)
{
oled_wc(oled_init_table);
}
/* 清屏:128 列 × 8 页 = 1024 个字节(每字节=8个像素)全部写入 0x00(熄灭) */
for(i = 0; i < 1024; i++)
{
/* i & 0x7f = i 的低 7 位,得到列地址(0~127),
* i >> 7 = i 的高位,得到页地址(0~7),
* 使 i 从 0 到 1023 时依次遍历全部 128×8=1024 个位置 */
oled_setpox(i & 0x7f, i >> 7);
oled_wd(0x00); /* 写入黑色(熄灭) */
}
/* 发送第 19 条命令:0xAF 开显示 */
oled_wc(oled_init_table[18]);
}
/*=====================================================================
* 函数名:oled_showchar
* 功能: 在指定位置显示一个 ASCII 字符(8×16 点阵)
* 参数: x = 字符列号(0~15),对应像素列(x*8 ~ x*8+7)
* y = 字符行号(0~7),对应页地址(y*2 和 y*2+1)
* ch = 要显示的 ASCII 字符(32~126 可打印,其余显示为空格)
* 说明: 8×16 字符占 8 列 × 16 行像素,即横向 8 像素、纵向 2 页
*=====================================================================*/
void oled_showchar(u8 x, u8 y, u8 ch)
{
u8 i;
/* 仅支持可打印 ASCII 字符(空格' ' 到 波浪号'~'),
* 超出范围(如 0~31 控制字符、127~159 扩展字符)显示为空格 */
if(ch < ' ' || ch > '~')
{
ch = ' ';
}
/* 列号/行号越界保护:OLED 字符网格为 16列 × 8行 */ 注5 注6
if(x > 15 || y > 7)
{
return;
}
/* 显示字符上半部分(高 8 位像素):
* x<<3 = x*8,得到像素列地址(0~120)
* y<<1 = y*2,得到该字符所在的第 1 页(页号:0,2,4,...,14) */
oled_setpox(x << 3, y << 1);
for(i = 0; i < 8; i++)
{
oled_wd(OLED_F8x16[ch - 32]); /* 字库前半 8 字节 = 上半部分 */
}
/* 显示字符下半部分(低 8 位像素):
* (y<<1)|1 = 该字符所在的第 2 页(页号:1,3,5,...,15) */
oled_setpox(x << 3, (y << 1) | 1);
for(i = 0; i < 8; i++)
{
oled_wd(OLED_F8x16[ch - 32][i + 8]); /* 字库后半 8 字节 = 下半部分 */
}
}
/*=====================================================================
* 函数名:main
* 功能: 主函数
* 流程: 1. 初始化 OLED 显示屏
* 2. 在循环中依次显示两组 ASCII 字符(后者覆盖前者)
*
* 第一组:ASCII 32~95(空格 ~ 下划线)
* 第二组:ASCII 96~159(反引号 ~ 扩展字符,其中 127~159 显示为空格)
*
* 位置映射(两组相同):
* c & 0x0f -> 列号(0~15)
* c >> 4 -> 行号(0~3),即占据 OLED 上半区域的 4 行字符
*=====================================================================*/
void main(void)
{
u8 c;
/* 初始化 OLED */
oled_init();
while(1) 注7
{
/* 第一组:显示 ASCII 字符 32~95(空格到 '_')
* 64 个字符,位置按 16列 × 4行 排列(占据上半屏) */
for(c = 0; c < 64; c++)
{
/* c&0x0f = 列号(0~15),c>>4 = 行号(0~3) */
oled_showchar(c & 0x0f, c >> 4, c + 32);
}
/* 第二组:显示 ASCII 字符 96~159('`' 到扩展字符)
* 同样的 64 个字符位置,字符内容从 96 起
* 注意:其中 96~126 为可打印字符,127~159 会被替换为空格 */
for(c = 0; c < 64; c++)
{
oled_showchar(c & 0x0f, c >> 4, c + 96);
}
}
}
/*=====================================================================*/
以下是一些想法
注1:关于初始化指令列表
在初始化程序中,使用的是循环发送的方式,发送每一条指令,
对于89c52来说,只是单纯发送每个数据,这样写需要额外占用一个数组空间。
但是这种方式可以让初始化函数看起来很清爽,而且也比较方便控制指令的管理。
而且在使用DMA发送数据的方式的时候很好用。
由于在初始化的时候没有对寻址方式 0x20 进行配置,所以屏幕上电之后会使用默认值“0x20, 0x02”的页地址模式(这一点,后面会再提到)。
关于地址模式,可以查看OLED教学贴的相关章节 https://www.stcaimcu.com/forum.php?mod=redirect&goto=findpost&ptid=6840&pid=59743
另外,0xd5 指令设置屏幕刷新率的地方,0x80是震荡频率=8,如果需要用手机或者摄像头拍摄屏幕的话,可以把这个值设高一点儿,比如0xE0,这样拍出来可以减少因为频率不同步而产生的条纹闪烁。
还有就是,定义数组如果有内容,所以可以不写19,比如添加了地址模式设置的指令,字节数量就会改变,在初始化函数调用的时候,可以用 sizeof 命令直接获取数组的字节数 sizeof( oled_init_table )
注2:关于函数名和参数里的 wd 和 wc
这种过于简练的写法对于初次看这个程序代码的人来说可能会懵一下。
通过控制字节 0x00 和 0x40,可以判断出 wd 是 write_data,wc是 write_command 的缩写
建议写成比较直观的名称,比如 wdat 和 wcmd,这样更便于理解。
而且过于精简的话,时间久了自己再看可能也会迷糊。
注3:关于定位函数
函数内容没有问题,但是函数名有个问题
设置位置,set_possition,可以写成setpos。
而英文单词 “pox” 的意思是“痘”,这就很暴露长痘的年纪了~~~~
注4:初始化函数的工作过程
初始化函数中,先执行了前面18条配置指令,然后对屏幕进行了清屏的操作,最后执行第19条指令。
这个操作顺序在逻辑上完全正确,可以保证屏幕开启显示时是绝对的黑屏。
不过由于单片机速度足够快,所以也可以一次性发送完19条指令,最后进行清屏,屏幕上电时的花屏会立刻被清空。
这样初始化函数能更好看一些。
另外,也可以把清屏的那几行封装成独立的一个函数,比如oled_clear,然后再直接调用这个函数。
因为在用OLED屏幕的时候,清屏也是一个会比较频繁用到的功能,直接调用函数可以方便很多。
不过建议使用两层for循环的方式
void oled_clear(void) /* 兼容水平地址模式和页地址模式 */
{
u8 i, j; /* i是列号(0~127),j是行号(0~7)*/
for( j = 0; j < 8; j++ ) /* 遍历每一行page */
{
oled_setpox( 0, j ); /* 每次定位到一行的最左边 */
for(i = 0; i < 128; i++)
{
oled_wd(0x00); /* 每一行写入128个0(熄灭全屏) */
}
}
}
理由是原先的清屏代码是在1024次循环里,虽然每次都通过(i & 0x7f, i >> 7)定位到每一个最小单元然后赋值,但是导致了清屏过程中需要调用1024次定位函数,很浪费时间。
而使用两层这种方法的话,定位函数只需要调用八次,把初始的显示位置定位到每一行的最左边,然后再连续填充128个数据就可以了。
而且这种方法兼容页地址模式和水平地址模式
如果初始化的时候配置了水平地址模式(添加上 0x20,0x00),那么也可以只用一次定位函数,将起始坐标定位在屏幕左上角(0,0),然后连续填充1024次0x00就可以了。
void oled_clear(void) /* 适用于水平地址模式 */
{
u16 i;
oled_setpox( 0, 0 ); /* 定位到屏幕左上角 */
for( i = 0; i < 1024; i++ )
{
oled_wd(0x00); /* 连续写入1024个0(熄灭全屏) */
}
}
另外,如果将来指令数组 oled_init_table 的内容有所调整,包含字节数不再是19,可以直接用 sizeof 命令直接获取字节数量
for(i = 0; i < sizeof( oled_init_table ); i++)
{
oled_wc(oled_init_table[ i ]);
}
// 如果想单独调用最后一条指令,也可以再for循环的时候用 sizeof( oled_init_table )-1,清屏之后再调用一次 oled_wc(oled_init_table[ sizeof( oled_init_table )-1 ]);
注5:显示字符的坐标问题
根据 oled_setpox(x << 3, y << 1); 和 oled_setpox(x << 3, (y << 1) | 1); 这两行定位语句可以看出,
程序是以8×16像素为单位的小格子,将屏幕划分成了竖向4行,每行16个的小格子来用的。
于是就产生了问题
在边界设置的地方
if(x > 15 || y > 7),意思是
x轴有16个有效坐标(0,8,16,24,32,40,48,56,64,72,80,88,96,104,112,120)
y轴有8个效坐标(0,1,2,3,4,5,6,7)
就跟后面的定位坐标产生了混乱的表达。
这里的 y>7 ,如果理解成 OLED屏幕上的 0~7 的八个 page 的话,
那么就给y分别赋值0~7,看一下真值表
显而易见,
只有当y = 0, 1, 2, 3 的时候,
“ y << 1”和“ (y << 1) | 1”才能正确对应屏幕上的(0, 2, 4, 6)和(1, 3, 5, 7)行
而当y = 4, 5, 6, 7 的时候,page坐标的定位就溢出128×64的屏幕范围了。
溢出的后果就是,根据OLED屏幕的寻址方式设置,
由于在初始化的时候没有对寻址方式 0x20 进行配置,所以用的是默认的页地址模式,
所以当page的值大于7之后,会从0开始重新计算,
如果y坐标写成了大于4的值,就会从屏幕第一行开始重新显示,从而将y=0时的内容覆盖掉。
所以边界保护的那行,y坐标的条件应该写成 “y > 3”才对。
当然,如果用的是128×128的OLED屏幕,那么 y>7 就没问题了。
注6:显示字符坐标的建议
依旧是根据边界设置的那行想到的
if(x > 15 || y > 7)
在带字库的那种LCD12864显示屏上,将屏幕划分成16×4来用,是由它的硬件结构造成的,只能那么用。
但是对于OLED屏幕来说,竖向上有8个page,横向上的每个page里,都有128列,
也就是说,OLED12864屏幕上实际有 128*8 个最小控制单元
那么如果只是当成16×4来用,就太浪费了。
举个栗子,如果要在屏幕上居中显示“STC89C52RC”,有10个字符,那么只需要两边各空出3个格子就可以了;
而换成“STC32G144K246”的话,有13个字符,那么两边的空位就只能是左1右2或者左2右1,没办法完美居中。
但是如果不用 x<<3的限制,而是直接用列号x,就可以自由安排字符在水平方向上的位置了,
要居中显示“STC32G144K246”,只需要两边各空出12列,让第一个字符在 x=12 的位置显示,之后的字符依次 +8 就可以了。
同理,y坐标也用 y 和 y+1 来表示,则可以充分利用起八个page的位置。
比如,假如需要显示的内容有三行
在 y=0 显示“STC89C52RC”,
在 y=3 显示“STC32G144K246”
在 y=6 显示“Hello World !”,
每串字符之间都有一个空行page,就可以看起来没有那么拥挤。
注7:main函数调用shouchar问题
程序在 while(1) 里使用了两个for循环,来显示出全部96个ASCII字符。
第一屏显示64个,第二屏显示之后的32个,
根据shouchar函数里的边界保护,
if(ch < ' ' || ch > '~')
{
ch = ' ';
}
c 的值超出96个之后的都显示成空格,这个操作很漂亮。
但是有个问题,两屏显示之间没有延时间隔,这样的结果就是,第一屏显示之后,立刻被第二屏覆盖掉,然后又被第一屏覆盖掉,然后再被第二屏覆盖掉……如此循环。从而屏幕上的内容会乱闪,看不清每一屏的内容。
所以在每个for循环之后,都需要添加一个延时,比如延时1000毫秒。
延时函数可以用ISP工具生成,然后封装成一个函数,比如
void delay_ms( u16 ms ) //@11.0592MHz
{
u8 data i, j;
while( ms-- )
{
_nop_();
i = 2;
j = 199;
do
{
while (--j);
} while (--i);
}
}
然后添加在对应位置
while(1)
{
for(c = 0; c < 64; c++)
{
/* 第一屏内容 */
}
delay_ms(1000);
for(c = 0; c < 64; c++)
{
/* 第二屏内容 */
}
delay_ms(1000);
}
这样就可以肉眼可见地交替显示两屏内容了。
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\
另外,还可以只用一个for循环,运行96次来显示
while (1)
{
for( c=0; c<96; c++ ) //遍历96个字符的序号
{
if( c != 0 && c % 64 == 0 ) // 第一屏c=0之前不清屏,之后每显示完64个字符,延时1秒后清屏,
{
delay_ms( 1000 );
oled_clear();
}
/*
将指定序号的字符显示在指定位置上
对于y坐标,( c >> 4 ) % 4,
先用 c>>4 将序号按8个一组分组,得到行号
再用 %4 与4取模,将超出屏幕的行号转换成小于4的行号
*/
OLED_ShowChar( c & 0x0f, ( c >> 4 ) % 4, c+32 ); //
}
/* 96个字符全部显示完之后,延时1秒,清屏,进入下一轮循环重新显示 */
delay_ms( 1000 );
oled_clear();
}
通过将c的值换算成对应格子的坐标,让大于3的y坐标从0开始重新计数,
并且也不用花时间显示96个字符之后的空格了
|
|