ATtiny10驱动OLED:1KB闪存下的嵌入式图形显示极限实践
1. 项目概述:当最小MCU遇见OLED
在嵌入式开发的世界里,我们常常追求“小而美”的极致。你是否想过,用一颗仅有6个引脚、1KB闪存、32字节RAM的ATtiny10微控制器,去驱动一块128x64分辨率的OLED显示屏?这听起来像是一场“蚂蚁撼大树”的挑战,但恰恰是这种极致的资源压榨,最能体现嵌入式开发的精髓和乐趣。这个项目,就是一次在资源极度受限的微型MCU上,实现图形化显示的硬核实践。
ATtiny10是Microchip(原Atmel)旗下AVR系列中体积最小、成本最低的成员之一,它通常用于最简单的逻辑控制、传感器读取。而OLED显示屏,尤其是SSD1306驱动的0.96英寸型号,以其高对比度、低功耗和无需背光的特性,成为许多便携设备的首选。将两者结合,意味着我们要在几乎没有“内存”和“算力”的空间里,搭建起一套完整的显示驱动逻辑。这不仅仅是让屏幕亮起来,更是在探索微型MCU应用的边界,为超低成本、微型化的显示设备(如一次性医疗传感器标签、微型玩具、极简状态指示器)提供了可行的技术路径。如果你对底层硬件通信、帧缓冲优化和极限编程感兴趣,那么这个项目将是一场绝佳的学习之旅。
2. 核心挑战与设计思路拆解
2.1 资源瓶颈的量化分析
在开始写第一行代码之前,我们必须清醒地认识到面临的硬件限制。ATtiny10的核心参数是这场游戏的规则书:
- 程序存储器(Flash):1KB。这大约相当于一篇短文的大小,我们需要把所有的驱动代码、字体数据、逻辑程序都塞进去。
- 静态内存(SRAM):32字节。这是真正的“寸土寸金”。一个128x64的单色位图缓冲区需要1024字节(128 * 64 / 8),这远超其容量。因此,传统的“全帧缓冲”方案被彻底否决。
- 通用I/O引脚:4个(PB0-PB3)。我们需要用它们来实现与OLED屏的通信,可能还要接个按键或传感器,引脚资源极其紧张。
- 工作频率:默认1MHz,最高可超频至12MHz(需注意电压)。通信时序的精确控制需要仔细计算。
面对这些限制,我们的设计思路必须做出根本性调整:
- 放弃全帧缓冲:这是最关键的决定。我们不能在MCU内存里存储整个屏幕的图像数据。取而代之的是“即时渲染”或“分段渲染”策略,即需要显示什么,就实时计算并发送什么数据到屏幕。
- 采用软件模拟I2C:ATtiny10没有硬件I2C外设。我们将使用两个GPIO引脚,通过精确的延时循环,用代码“模拟”出I2C的时序。这能最大程度节省硬件资源,但会对代码空间和时序控制提出更高要求。
- 极度精简的字体与图形:不能使用标准的16x16甚至8x16点阵字库,因为它们太占空间。我们需要自定义超小字体(如5x7像素),或者仅显示数字和少量字母,并将字模数据以紧凑的形式存储在Flash中。
- 功能高度特化:这个系统不可能运行一个复杂的图形用户界面。它的功能应该是单一的,比如显示一个数值、一个简单的动画图标或几行固定的文本。代码逻辑必须直截了当,避免任何动态内存分配和复杂的函数调用开销。
2.2 通信协议与引脚分配策略
OLED模块最常用的接口是I2C和SPI。对于ATtiny10,I2C通常是更优选择,因为它只需要两根线(SDA, SCL),加上电源和地,总共4根线,刚好匹配我们可用的GPIO数量。
假设我们使用I2C接口的SSD1306模块:
- VCC:接3.3V或5V(视模块而定)。
- GND:接地。
- SCL:时钟线。我们分配给PB2。
- SDA:数据线。我们分配给PB1。
- OLED模块的地址:通常是0x3C或0x3D(取决于模块配置)。
这样,PB0和PB3两个引脚还能空出来,可以连接一个按键用于切换显示内容,或者连接一个模拟传感器(如光敏电阻)进行数据采集。在软件模拟I2C时,我们需要将这两个引脚设置为推挽输出模式,并通过拉高拉低来模拟时序,同时要处理好SDA线的输入输出方向切换(用于读取应答信号)。
3. 软件模拟I2C驱动实现详解
3.1 底层时序模拟与端口操作
由于没有硬件外设,我们必须从最底层的位操作开始构建I2C总线。I2C协议的基础是起始(S)、停止(P)、应答(ACK)和数据位传输。在ATtiny10上,我们需要用循环延时来实现微秒级的精确控制。
首先,定义引脚和基本延时函数(使用内部1MHz时钟,一个_delay_us循环大约对应1微秒):
#include <avr/io.h> #include <util/delay.h> #define SDA_PIN PB1 #define SCL_PIN PB2 #define SDA_PORT PORTB #define SCL_PORT PORTB #define SDA_DDR DDRB #define SCL_DDR DDRB // 设置引脚方向:1为输出,0为输入 #define SDA_OUT() (SDA_DDR |= (1<<SDA_PIN)) #define SDA_IN() (SDA_DDR &= ~(1<<SDA_PIN)) // 读取引脚电平 #define SDA_READ() (PINB & (1<<SDA_PIN)) // 简单的微秒延时,实际需校准 void I2C_Delay(void) { _delay_us(5); // 粗略延时,在1MHz下调整以满足I2C标准模式(~100kHz)或低速模式 } // 产生起始条件:SCL高时,SDA从高变低 void I2C_Start(void) { SDA_OUT(); SDA_PORT |= (1<<SDA_PIN); // SDA高 SCL_PORT |= (1<<SCL_PIN); // SCL高 I2C_Delay(); SDA_PORT &= ~(1<<SDA_PIN); // SDA拉低 I2C_Delay(); SCL_PORT &= ~(1<<SCL_PIN); // SCL拉低,为传输数据做准备 I2C_Delay(); }这里的关键在于时序的对称性和稳定性。I2C_Delay函数的时间决定了通信速率。对于ATtiny10在1MHz下,_delay_us(5)可能太快,容易导致通信失败。一个更可靠的做法是使用简单的空循环来产生更长的延时,或者通过示波器测量调整循环次数,使SCL周期大约为10微秒(对应100kHz)。在资源允许的情况下,可以编写一个可调整的延时函数,方便在不同主频下移植。
3.2 数据字节发送与命令/数据写入框架
发送一个字节需要循环8次,从最高位(MSB)开始。每个比特位传输期间,必须确保SCL为低电平时改变SDA,在SCL为高电平时保持SDA稳定。
// 发送一个字节,并读取应答位 uint8_t I2C_WriteByte(uint8_t data) { uint8_t i, ack; SDA_OUT(); for (i=0; i<8; i++) { if (data & 0x80) // 判断最高位 SDA_PORT |= (1<<SDA_PIN); else SDA_PORT &= ~(1<<SDA_PIN); data <<= 1; // 左移,准备下一位 I2C_Delay(); SCL_PORT |= (1<<SCL_PIN); // 拉高SCL,数据被采样 I2C_Delay(); SCL_PORT &= ~(1<<SCL_PIN); // 拉低SCL,准备下一个比特 I2C_Delay(); } // 读取应答位 SDA_IN(); // 将SDA设置为输入,释放总线(外部上拉电阻会将其拉高) I2C_Delay(); SCL_PORT |= (1<<SCL_PIN); // 第9个时钟脉冲 I2C_Delay(); ack = SDA_READ(); // 读取SDA电平,0为应答,1为非应答 SCL_PORT &= ~(1<<SCL_PIN); I2C_Delay(); SDA_OUT(); // 重新将SDA设置为输出,以便后续控制 SDA_PORT &= ~(1<<SDA_PIN); // 确保SDA输出低,为停止或下一次开始做准备 return ack; // 返回0表示成功收到ACK }有了字节发送函数,我们就可以构建向OLED发送命令或数据的函数。SSD1306规定,在发送I2C地址字节(0x3C)后,紧跟着一个控制字节:0x00表示后续是命令流,0x40表示后续是数据流。
void OLED_WriteCommand(uint8_t cmd) { I2C_Start(); I2C_WriteByte(0x3C << 1); // I2C地址 + 写位(0) I2C_WriteByte(0x00); // 控制字节:命令 I2C_WriteByte(cmd); I2C_Stop(); } void OLED_WriteData(uint8_t data) { I2C_Start(); I2C_WriteByte(0x3C << 1); I2C_WriteByte(0x40); // 控制字节:数据 I2C_WriteByte(data); I2C_Stop(); }注意:为了节省代码空间,在实际项目中,
OLED_WriteCommand和OLED_WriteData函数可能会被合并或内联,因为它们的结构高度相似。频繁的I2C_Start和I2C_Stop也会产生开销。一种优化策略是使用“复合传输”:一次起始信号后,发送地址、控制字节,然后连续发送多个命令或数据,最后以一个停止信号结束。但这需要仔细处理SSD1306的状态机,确保它处于正确的接收模式。
4. SSD1306 OLED初始化与基础绘图
4.1 关键初始化命令序列解析
SSD1306上电后处于一个未知状态,必须通过一系列命令进行初始化,设置驱动电路参数、寻址模式、对比度等。以下是一个针对128x64显示屏的典型初始化序列,每个命令都需要深入理解其含义:
void OLED_Init(void) { _delay_ms(100); // 等待OLED电源稳定,至关重要! OLED_WriteCommand(0xAE); // 关闭显示(睡眠模式) OLED_WriteCommand(0xD5); // 设置显示时钟分频比/振荡器频率 OLED_WriteCommand(0x80); // 建议值,分频比=1,频率=~1000kHz OLED_WriteCommand(0xA8); // 设置多路复用比率(MUX Ratio) OLED_WriteCommand(0x3F); // 63 (64MUX for 128x64) OLED_WriteCommand(0xD3); // 设置显示偏移(Display Offset) OLED_WriteCommand(0x00); // 无偏移 OLED_WriteCommand(0x40); // 设置显示起始行(Display Start Line)为0 OLED_WriteCommand(0xA1); // 设置段重映射(Segment Re-map)为0xA1(列地址127映射到SEG0) OLED_WriteCommand(0xC8); // 设置COM扫描方向(COM Output Scan Direction)为0xC8(从COM63到COM0) OLED_WriteCommand(0xDA); // 设置COM引脚硬件配置 OLED_WriteCommand(0x12); // 0x12 for 64行, 顺序模式, 禁止左右COM反置 OLED_WriteCommand(0x81); // 设置对比度控制 OLED_WriteCommand(0x7F); // 对比度值, 可调范围0-0xFF OLED_WriteCommand(0xA4); // 禁用整个显示开启(Resume to RAM content display) OLED_WriteCommand(0xA6); // 设置正常显示(非反色) OLED_WriteCommand(0xD9); // 设置预充电周期(Pre-charge Period) OLED_WriteCommand(0xF1); // 建议值, Phase1 = 15 DCLKs, Phase2 = 1 DCLK OLED_WriteCommand(0xDB); // 设置VCOMH反压电平(VCOMH Deselect Level) OLED_WriteCommand(0x40); // 建议值, ~0.77 * VCC OLED_WriteCommand(0xAF); // 开启显示 }初始化心得:
- 上电延时:
_delay_ms(100)必不可少。OLED模块的电源电路和内部稳压器需要时间稳定,过早发送命令会导致初始化失败,屏幕无显示或花屏。 - 命令顺序:有些命令之间存在依赖关系。例如,通常先关闭显示(0xAE)再进行其他配置,最后再开启显示(0xAF),可以避免配置过程中出现闪烁或乱码。
- 关键参数:
0xA8(多路复用)和0xDA(COM引脚配置)必须与你的屏幕物理规格(64行)匹配,设置错误会导致显示压缩、拉伸或完全错乱。 - 功耗权衡:对比度(0x81)和预充电周期(0xD9)直接影响屏幕亮度和功耗。在电池供电场景下,可以适当降低对比度以节能。
4.2 页寻址模式与像素点绘制原理
由于没有帧缓冲,我们必须理解SSD1306的内存组织方式。它内部有一个1024字节的GDDRAM(Graphic Display Data RAM),对应128列 x 64行。但寻址方式不是线性的,而是采用“页寻址模式”(Page Addressing Mode)。
在页寻址模式下:
- 屏幕垂直方向被分为8个“页”(Page),每页8行像素。页地址从0到7。
- 每页有128列,列地址从0到127。
- 向GDDRAM写入的一个字节数据,对应一列中从上到下(在当前页内)的8个像素点。字节的最高位(MSB)对应页内的最上一行(低位行号),最低位(LSB)对应页内的最下一行(高位行号)。
例如,要在屏幕坐标 (X, Y) 处画一个点:
- 计算页地址:
page = Y / 8 - 计算在该页内的位位置:
bit = Y % 8 - 发送命令,将GDDRAM指针定位到该页和该列:
OLED_WriteCommand(0xB0 + page); // 设置页地址 OLED_WriteCommand(0x00 + (X & 0x0F)); // 设置列地址低4位 OLED_WriteCommand(0x10 + ((X >> 4) & 0x0F)); // 设置列地址高4位 - 此时,如果直接写入一个数据字节,会覆盖该列当前页的8个像素。为了只修改一个点而不影响其他7个,我们需要执行“读-改-写”操作。但SSD1306的I2C接口通常不支持读回GDDRAM数据!这是一个巨大的限制。
因此,在ATtiny10这种无法存储完整显存的MCU上,实现任意点绘制非常困难且低效。更实用的策略是面向“字符”或“固定图形”进行渲染。我们预先知道要显示什么,然后按列连续发送该字符或图形的所有数据字节,SSD1306的列地址会自动递增(如果设置了自动递增模式)。这避免了频繁的定位和读回操作。
5. 极简字库设计与字符显示实现
5.1 自定义5x7像素字体与数据存储优化
为了在1KB的Flash中生存,我们必须设计极度紧凑的字模。5x7像素是经典的小字体,每个字符用5列x7行表示(通常最下面一行留空或用于下行字母,实际有效像素5x7)。每个字符需要5个字节(每列一个字节,字节中只使用低7位)。
我们可以将字模数据存储在程序存储器(Flash)中,使用PROGMEM关键字(在AVR-GCC中),防止其占用宝贵的RAM。
#include <avr/pgmspace.h> // 定义字符集(例如:数字0-9, 冒号, 空格) const uint8_t Font5x7[][5] PROGMEM = { {0x3E, 0x51, 0x49, 0x45, 0x3E}, // '0' {0x00, 0x42, 0x7F, 0x40, 0x00}, // '1' {0x42, 0x61, 0x51, 0x49, 0x46}, // '2' // ... 其他数字和字符 {0x36, 0x49, 0x49, 0x49, 0x36}, // '8' {0x26, 0x49, 0x49, 0x49, 0x3E}, // '9' {0x00, 0x36, 0x36, 0x00, 0x00}, // ':' };存储优化技巧:
- 仅包含必要字符:如果只显示时间“12:30”,那么就只存储‘1’, ‘2’, ‘3’, ‘0’, ‘:’这5个字符的字模。
- 使用查找表:定义一个简单的函数,将ASCII字符映射到字模数组的索引。对于非连续字符(如数字、冒号),可以用
switch-case或小型查找表实现。 - 压缩算法(高级):如果空间紧张到极致,可以考虑使用游程编码(RLE)或位打包来压缩字模,在显示时动态解压。但这会消耗CPU周期,需要权衡。
5.2 字符显示函数与自动换行处理
显示一个字符的函数需要完成以下步骤:设置起始位置,然后连续发送该字符的5个字节字模数据。
void OLED_PutChar(uint8_t x, uint8_t page, char c) { uint8_t i, index; // 简单的字符映射, 假设我们只有数字和冒号 if (c >= '0' && c <= '9') { index = c - '0'; } else if (c == ':') { index = 10; // 假设冒号在数组索引10 } else { return; // 不支持的字符, 可以显示空格或跳过 } // 设置起始位置 OLED_WriteCommand(0xB0 + page); // 设置页 OLED_WriteCommand(0x00 + (x & 0x0F)); // 列地址低4位 OLED_WriteCommand(0x10 + ((x >> 4) & 0x0F)); // 列地址高4位 // 发送字模数据 for (i = 0; i < 5; i++) { OLED_WriteData(pgm_read_byte(&(Font5x7[index][i]))); } // 发送一列空白作为字符间距 OLED_WriteData(0x00); }显示字符串:基于OLED_PutChar,我们可以构建一个显示字符串的函数。这里需要考虑自动换行,当一行显示满(比如128像素,每个字符占6像素)后,切换到下一页(page+1)。
void OLED_PutString(uint8_t x, uint8_t page, const char *str) { uint8_t startX = x; while (*str) { if (*str == '\n') { // 处理换行符 page++; x = startX; str++; continue; } if (x > 122) { // 128 - 6 = 122, 接近行尾时换行 page++; x = startX; if (page > 7) break; // 超出屏幕底部 } OLED_PutChar(x, page, *str); x += 6; // 5列字模 + 1列间距 str++; } }实操心得:
pgm_read_byte是读取Flash数据的关键。直接使用数组名Font5x7[index][i]会从RAM读取,而我们的数据在Flash中,这会导致读到的数据是随机的,屏幕显示乱码。务必确保使用pgm_read_byte来访问PROGMEM数据。
6. 动态内容显示:以数字时钟为例
6.1 系统时钟源与低功耗定时考量
要让时钟走起来,我们需要一个可靠的时间基准。ATtiny10内部有一个8MHz的RC振荡器,但默认被8分频,系统时钟为1MHz。我们可以利用其内置的8位定时器/计数器(TC0)来产生周期性中断,作为“心跳”。
首先,配置定时器,使其每1毫秒产生一次溢出中断(假设1MHz系统时钟,预分频器设置为64):
- 定时器时钟 = 系统时钟 / 预分频 = 1,000,000 / 64 = 15625 Hz。
- 定时器每计数一次需要 1 / 15625 ≈ 64 微秒。
- 要产生1ms中断,需要计数值 = 0.001 / (64e-6) ≈ 15.6。我们取整数16,那么实际中断周期为 16 * 64us = 1.024 ms,误差可以接受。
#include <avr/interrupt.h> volatile uint32_t system_ms = 0; // 系统运行毫秒数, 使用volatile防止优化 void Timer0_Init(void) { TCCR0B = (1 << CS01) | (1 << CS00); // 预分频器 = 64 TIMSK0 = (1 << TOIE0); // 使能定时器溢出中断 sei(); // 开启全局中断 } ISR(TIM0_OVF_vect) { static uint8_t counter = 0; TCNT0 = 256 - 16; // 重装初值, 使溢出周期为16个计数 counter++; if (counter >= 64) { // 64 * 1.024ms ≈ 65.5ms, 近似为65ms counter = 0; system_ms += 65; // 累积系统时间 } }低功耗设计:如果项目是电池供电,在不需要更新显示时,可以让ATtiny10进入休眠模式(Idle或Power-down),定时器中断唤醒MCU更新显示,然后再进入休眠。这能极大降低平均功耗。ATtiny10的功耗可以降到微安级别。
6.2 时间变量处理与屏幕局部更新策略
我们定义时分秒变量,并在主循环中根据system_ms更新它们。
uint8_t hours = 12, minutes = 30, seconds = 0; void update_time(void) { static uint32_t last_update = 0; if (system_ms - last_update >= 1000) { // 每秒更新一次 last_update = system_ms; seconds++; if (seconds >= 60) { seconds = 0; minutes++; if (minutes >= 60) { minutes = 0; hours++; if (hours >= 24) hours = 0; } } // 触发显示更新 refresh_display = 1; } }局部更新策略:这是节省时间和能量的关键。我们不应该每秒清空整个屏幕再重绘所有内容。对于数字时钟,只有变化的数字需要更新。
- 将时间格式化为字符串,例如“12:30:00”。
- 与上一次显示的时间字符串进行比较。
- 只找出发生变化的字符位置。
- 调用
OLED_PutChar函数,只更新那些位置上的字符。
例如,从“12:30:00”变为“12:30:01”,只有最后一个字符从‘0’变为了‘1’。我们只需要在秒的个位位置重新绘制‘1’即可。这避免了发送大量重复的、未变化的显示数据,显著提高了效率,也减少了屏幕刷新带来的闪烁感。
char old_time[9] = " "; // 初始化为空格 char new_time[9]; // 在需要更新显示时 sprintf(new_time, "%02d:%02d:%02d", hours, minutes, seconds); // 注意:sprintf可能占用较多资源, 在ATtiny10上需谨慎使用或自己实现简单格式化 for (uint8_t i = 0; i < 8; i++) { if (new_time[i] != old_time[i]) { OLED_PutChar(20 + i * 6, 2, new_time[i]); // 假设从(20, page2)开始显示 old_time[i] = new_time[i]; } }踩坑记录:
sprintf是标准库函数,它会链接浮点支持等很多代码,很容易就撑爆ATtiny10的1KB Flash。绝对不要使用。必须自己编写轻量级的整数转字符串函数。例如,一个将0-99的整数转换为两位数字符串的函数,只需要简单的除法和取余操作,代码量极小。
7. 项目优化与扩展可能性
7.1 代码空间与执行效率的极致优化
当项目接近完成,发现Flash空间不足时,以下“外科手术”式的优化手段可能救命:
- 编译器优化等级:使用
-Os(优化大小)甚至-ffunction-sections -fdata-sections -Wl,--gc-sections来链接器垃圾回收,移除未使用的函数和数据。 - 函数内联与宏:对于非常短小且频繁调用的函数(如
I2C_Delay,SDA_HIGH等),使用static inline或宏定义来消除调用开销。但需注意,过度内联会增加代码体积。 - 查表代替计算:如果某些计算(如字模索引映射)很耗时,可以考虑用小的查找表代替,但这会占用Flash空间,需要权衡。
- 精简初始化序列:并非所有SSD1306初始化命令都是必须的。可以尝试精简命令序列,只保留最核心的(如设置MUX、COM扫描方向、开启显示等)。但风险是不同批次的屏幕可能需要完整的序列才能稳定工作。
- 汇编语言关键路径:对于最关键的时序循环(如I2C位操作),可以用内联汇编重写,确保时序绝对精确且代码最紧凑。这是高级技巧,需要对AVR汇编有深入了解。
7.2 功能扩展思路:传感器集成与交互
尽管资源有限,我们仍然可以扩展一些简单功能:
- 按键切换:使用空闲的PB0或PB3引脚,接一个按键并启用内部上拉电阻。通过检测引脚电平变化(使用外部中断或轮询)来切换显示模式,例如在时间、温度、电池电压之间循环显示。
- 模拟传感器读取:ATtiny10有一个ADC(模数转换器)。可以连接一个热敏电阻或光敏电阻,测量其分压,转换成温度或光照度值,并显示在OLED上。ADC会消耗一定电流,读取后应及时关闭。
- 简单动画:通过交替显示两到三帧简单的位图(比如一个跳动的小点,一个简单的进度条),可以实现基本的动画效果。这需要预先计算好每一帧的显示数据,并存储在Flash中。
- 省电模式:如果显示内容不常更新,可以配置定时器以更低的频率中断(如每秒一次),在中断唤醒后更新显示,然后立即让MCU进入休眠模式(
SLEEP_MODE_PWR_DOWN)。OLED屏幕本身也可以通过命令(0xAE)关闭显示来省电。
8. 常见问题排查与调试心得
8.1 硬件连接与电源问题
屏幕完全不亮:
- 首要检查:电源和地线是否接反、接实?OLED模块的VCC是接3.3V还是5V?很多模块是3.3V逻辑电平,但VCC可以接5V(模块上有LDO稳压)。用万用表测量模块供电引脚电压。
- 检查复位:有些模块有RESET引脚,需要在上电后拉低再拉高进行硬件复位。如果悬空,内部可能未正确复位。
- I2C上拉电阻:SCL和SDA线是否需要外部上拉电阻(通常4.7kΩ到10kΩ)?模块内部可能已经集成,如果没有,信号线无法被拉高,通信会失败。
屏幕亮但无显示(白屏或花屏):
- 初始化序列:这是最常见的原因。确保发送了完整的、正确的初始化命令序列,并且命令和数据字节没有混淆(控制字节0x00和0x40)。
- 通信速率:软件I2C的延时可能太快。尝试大幅增加
I2C_Delay中的延时,将通信速率降到100kHz以下甚至更低。用逻辑分析仪或示波器观察SCL/SDA波形是最直接的调试方法。 - 地址错误:尝试0x3C和0x78(0x3C<<1)两种地址格式。有些库使用7位地址(0x3C),有些使用8位地址(包含读写位)。我们的代码使用的是
(0x3C << 1),即左移一位后发送,最低位是0(写)。
8.2 软件逻辑与显示异常
显示乱码或错位:
- 字模数据:首先怀疑字模数据本身是否正确。可以用一个简单的测试程序,在固定位置显示一个已知字符(比如‘0’),检查其5个字节的数据是否与预期相符。
- PROGMEM读取:绝对要检查是否使用了
pgm_read_byte来读取字模。这是新手最容易踩的坑,直接读取会得到随机数。 - 坐标计算:检查
OLED_PutChar函数中的页(page)和列(x)计算是否正确。页地址是0xB0 + page, page范围0-7。列地址分高低4位发送。 - COM扫描方向:初始化命令
0xC8或0xC0决定了行扫描顺序。如果上下颠倒,尝试换成另一个。
显示内容残留或刷新不正常:
- 局部更新错误:如果采用局部更新,确保在更新一个字符前,正确设置了GDDRAM指针。在连续发送多个字符时,SSD1306的列地址会自动递增,但跨字符或换行时,需要重新设置页和列地址。
- 清屏操作:在首次显示或切换模式时,最好发送清屏命令。清屏可以通过发送命令
0x20,0x00(设置水平寻址模式),然后连续发送1024个0x00数据来实现。但这会消耗大量时间和带宽。在ATtiny10上,更经济的做法是只清除需要显示的区域。
程序跑飞或大小超限:
- 看门狗:在复杂的循环或长时间操作中,启用看门狗定时器(WDT)防止程序死锁。
- 栈溢出:ATtiny10的RAM极小,要避免大的局部数组和深度的函数递归调用。使用全局变量或静态变量。
- 编译信息:时刻关注编译器的输出信息,特别是程序段(.text)和数据段(.data)的大小。如果接近或超过1024字节,就必须进行上述的代码优化。
调试这样的极限项目,逻辑分析仪是无可替代的工具。它能清晰地展示I2C总线上的每一个起始、地址、应答和数据位,让你能精确判断是命令发送错误,还是时序不符合规范。在没有硬件调试工具的情况下,“灯闪调试法”——用空闲的GPIO引脚输出高低电平来标记程序执行到哪个阶段——是最原始但有效的方法。
