
这套 STM32 入门课程我从头跟过一遍前面几课还在折腾 GPIO 点灯到 4-4 这一课画风就变了主题直接落到一块 0.96 寸、I2C 接口的 OLED 屏上。上半部分把底层驱动和字符显示讲清楚屏幕能输出一句固定的英文就算通关下半部分继续补完数字、中文、自定义图形这些功能。很多人学完上半就急着拿去做项目我反而觉得下半更值得认真看——名字叫“快速上手下”实际解决的是从“显示几个字”到“显示一个动态界面”的关键跨越。这篇文章就把下半部分讲透。包括 SSD1306 内部显示内存的结构、数字显示函数的长度陷阱、中文取模方式、自定义图片数组以及一个能直接抄的 ADC 电压显示加进度条界面。适合刚看完上半部分、准备把 OLED 真正用进项目的同学也适合那些屏幕能亮但一显示变量就出各种奇怪问题的朋友。1. 上下两节的分工先点亮屏幕再做“像样的界面”1.1 上半节留下的“能亮不能用”问题OLED 课程的上半节节奏很紧凑先讲 I2C 总线时序再初始化 SSD1306 屏幕最后封装出显示字符和字符串的函数。到这一步你已经可以在屏幕上输出 HelloWorld也能切换两行显示。但如果你做过实际项目就会立刻意识到这离“能用”还差得远。一个嵌入式界面里百分之八十的内容不是静态字符串而是不断变化的数字传感器的电压值、定时器的计数值、串口收到的数据长度、电池剩余电量。上半节结束时的代码还没办法把整数变量漂亮地打印到屏幕上更别说显示十六进制数、带符号数、负数或者中文菜单。下半部分补的正是这些高频接口。1.2 先弄懂屏幕的十六列四行坐标模型学习下半部分之前一个必须建立的概念是这块屏幕的字符坐标。0.96 寸 OLED 的分辨率是 128x64即横向 128 个像素点、纵向 64 行像素。而课程里用的 ASCII 字符每个占 8 像素宽、16 像素高宽度方向正好 128/816 个字符位高度方向正好 64/164 排字符。所以库函数里几乎所有函数都有两个参数Line 取值 1 到 4表示第几排Column 取值 1 到 16表示第几列。这个模型贯穿始终写多了自然就记住不用每次翻手册。注意Line 和 Column 是从 1 开始计数但 SSD1306 底层寄存器是从 0 开始所以函数内部真正操作时会把参数减一。我见过不少同学把 Column 从 0 开始传进去结果字符整体偏左一列还有人把 Line 和 Column 顺序写反屏幕怎么刷新都乱。坐标模型一旦建立后面所有函数都围绕它转没有第二个标准。2. 动手写函数前务必看懂 SSD1306 的显示 RAM 结构2.1 页、列和字节的映射关系如果不理解 SSD1306 内部怎么存储图像数据下半部分的字库数组、图片取模、局部刷新这些操作全都会变成“照着抄但不知道在抄什么”。屏幕实际上有一块 128x64 bit 的显存也就是 128 列、64 行每一行 128 个点共 64 行。这块显存被硬件分成 8 个 page每个 page 对应 8 行像素也就是 page0 管第 0 到 7 行page1 管第 8 到 15 行以此类推。每个 page 有 128 个字节每个字节的 8 个 bit 从上到下对应这个 page 里的 8 个像素点。简化理解就是一排 8 行像素、128 列宽是一个 page一个字节表示这个 page 中某一列的 8 个点。所以往 OLED 发一个字节数据屏幕上会亮起一列 8 个像素而不是一个点。这让数据组织非常方便字模就是这么来的。2.2 地址模式决定了写入数据的方向SSD1306 支持页地址模式、水平地址模式和垂直地址模式。课程默认使用的是页地址模式原因很简单显示字符时一页一页往下写最符合字模的组织方式。在页地址模式下写完一个字节后列地址自动加一加到第 127 列时自动回到第 0 列但 page 地址不会变。所以写满一整行 128 字节后必须手动切换 page否则数据就会覆盖回开头。这个细节在动态刷新和画图时特别容易踩坑。以 16x16 的中文字模为例一个汉字占两列、两个 page也就是四块 16 字节的数据。如果地址模式没设对写出来就会花屏甚至整体错位。2.3 I2C 时序中的控制字节这部分在上半节讲过但下半节所有新功能全都依赖它。SSD1306 的 I2C 写入每次通信先发从机地址再发一个控制字节最后才是真正的数据。控制字节 0x00 表示后面跟的是命令0x40 表示后面跟的是显示数据。写命令和写数据必须分开封装因为初始化、设置坐标、打开充电泵全是命令而字模、图片数据全是数据。如果控制字节用错屏幕要么完全不亮要么显示内容变成一团乱码。我调试时经常用逻辑分析仪抓 I2C 信号如果发现连续发了一堆 0x00多半就是把数据当命令发或者地址模式配错了。这种问题光看代码很难发现抓一次总线波形就一目了然。3. 从 ShowChar 到 ShowString字模表究竟在查什么3.1 一张表解决 ASCII 码到点阵的转换很多刚开始学 OLED 的同学会好奇为什么库文件里会有一个巨大的数组里面全是看不懂的十六进制数。这个数组就是字模表里面按顺序存放了可见 ASCII 字符的点阵数据。每个 ASCII 字符占 8x16 像素即 2 个 page 的数据。一个字模就是 16 个字节前 8 个字节是上半部分的 8 列像素后 8 个字节是下半部分的 8 列像素。表里的顺序通常从空格符开始也就是 ASCII 0x20 开始往后排列。所以 ShowChar 的原理非常简单就是用字符的 ASCII 码减去 0x20得到一个偏移量再乘上 16 字节在字模表里找到对应的数据逐字节发送到 OLED。这不是什么高深技巧却解决了显示问题。3.2 字符串函数只是字符函数的循环ShowString 比 ShowChar 多做的就一件事遍历字符串把每个字符依次调用 ShowChar遇到串尾的 \0 就停止。逻辑虽然简单但有一个地方必须注意传入的字符串不能在屏幕上超出第 16 列否则数据会写到屏幕外面去产生不可预期的显示效果。课程里的字符串函数通常不做越界判断因为设计者默认使用者会自己控制长度。我做项目时习惯在调用前先自己估算列宽或者封装一个能自动截断的版本虽然多花几行代码但至少不会因为一个超长字符串把整个屏幕显示搞乱。3.3 为什么数字显示不能直接用 ShowChar理论上一串数字字符也可以用 ShowString 显示但有个致命问题用户拿到手的是一个整数变量比如 int a 12345而 ShowString 需要字符串不能直接吃整数。要在嵌入式环境里把整数转换成字符串常用的方案是 sprintf但标准库的 sprintf 体积大在小容量芯片上很占资源。课程里的思路是直接写一个 ShowNum 函数把整数的每一位拆出来转换成对应的字符再一个一个显示。这样就绕开了大型格式化函数也让学生更理解数据到底是怎么变成屏幕上的符号的。下面是简化版的思路void OLED_ShowNum(uint8_t line, uint8_t column, uint32_t num, uint8_t len) { uint8_t i; uint8_t temp[12]; for (i 0; i len; i) { temp[len - 1 - i] num % 10 0; num / 10; } for (i 0; i len; i) { OLED_ShowChar(line, column i, temp[i]); } }这只是一个演示逻辑真实库里还要处理 len 不足、数字位数超出 len 等情况。但核心思路就是取余数得到最低位整除去掉最低位循环 len 次把每一位存进数组再逐字符输出。4. 数字显示函数里的“长度”陷阱很多人栽在这里4.1 Length 参数不是为了好看而是为了“擦掉旧内容”课程里的 ShowNum 都会有第四个参数 Length比如 OLED_ShowNum(1, 1, 42, 3)。这个参数的意思是这个数字固定占 3 个字符宽度不够 3 位就往左补空格。为什么一定要补空格因为 OLED 的像素一旦点亮不会自动熄灭。如果你先显示 12345过一秒要显示 6函数只改了第一位后面的 2345 还亮着屏幕上就会出现“6345”这种诡异结果或者数字后面拖着一串残留。所以正确做法是每次显示同一个位置的数字Length 必须保持一致。比如一个计数值最大 9999那就永远用 Length4。这样后面的位数不够时用空格覆盖旧数字才能被彻底清掉。4.2 ShowSignedNum、ShowHexNum、ShowBinNum 的定位课程下半节还会补三个兄弟函数带符号数、十六进制、二进制。它们的区别只是在 ShowNum 基础上改了进制转换和符号处理。ShowSignedNum 专门处理负数先判断最高位为 1 就显示负号然后取反加一求绝对值。ShowHexNum 是把整数每 4 位拆成一个十六进制字符满 10 就变成 A 到 F。ShowBinNum 则是把每一位逐个拆成 0 和 1 的字符。这几个函数单独看都很简单但组合起来能做的事情却很多。调试时我经常在屏幕一行显示十进制、一行显示十六进制结合串口日志对照数据排查问题效率立刻高不少。4.3 一个值得养成的习惯更新数据和刷新位置分开用这些数字函数的时候我吃过一次亏定时器中断里直接调用 OLED_ShowNum 刷新数据结果屏幕出现肉眼可见的闪烁加卡顿。原因很简单OLED 的 I2C 通信在中断里执行单次显示就要几十个字节频繁中断其他任务自然被拖慢。后来我把显示逻辑改成“数据更新放一边显示刷新放另一边”主循环里检测数据变化标志有变化才去刷新对应位置的数字。这样 I2C 通信次数大幅减少画面稳定CPU 占用也低。如果你是非阻塞式任务复杂的项目尽量把 OLED 显示放在低优先级循环里不要让高频中断直接驱动屏幕。这是能明显提升整体稳定性的习惯。5. 中文显示和自定义图片取模设置对了就是另一个世界5.1 中文显示的难点在于“取模”不在单片机本身很多同学学到这节课最兴奋因为终于能在屏幕上显示中文了。但中文显示的实际难点不是单片机侧而是 PC 侧的取模工具配置。16x16 点阵的中文字模每个汉字要占用 32 字节。这 32 字节怎么排列最常用的取模方式是从上到下、从左到右每个字节代表 8 个像素一个 16x16 汉字正好拆成 32 个字节。取模工具里常见的选项有“逐行式”“逐列式”“横向取模”“纵向取模”还有“高位在前”“低位在前”。这些选项必须和库函数里读取数据的方式严格一致否则显示出来的汉字会翻转 90° 或者完全乱掉。5.2 课程用到的字模提取方法课程中中文显示函数通常是 OLED_ShowChinese(Line, Column, Number)这里的 Number 不是汉字本身而是这个汉字在自定义字模数组里的索引。也就是说你先把需要的汉字一个个用取模工具生成数组放进程序里然后给每个汉字编号显示时直接传编号。这样写的好处是显示几个固定汉字很快坏处是不够灵活想显示任意文本就很麻烦。我实际做产品时用过一个稍微进阶的方案保存汉字点阵到外部 Flash通过索引动态加载显示菜单时按字符串查表。但这是后话初学者先把固定编号方案跑通理解“点阵数组”和“索引”这两个核心概念后面走这条路才不慌。5.3 图片数组和汉字数组本质是一样的下半节课程末通常会用一个 OLED_ShowImage 函数来显示完整的图片或 logo。它传入的是一整块点阵数组宽多少个像素、高多少个像素以及对应的数组。显示图片的原理和显示汉字一模一样只是尺寸从 16x16 变成任意宽度高度。取模工具同样可以生成 BMP 图片的 C 语言数组选对“单色位图”和取模方式就能把一张 128x64 的图片塞进代码里。这里要提醒的是容量。一张 128x64 的单色图片点阵大小是 128x64/81024 字节。如果芯片 Flash 只有 16KB几张图就可能把空间占满。所以用图片要规划容量能用小图就别放满屏图。6. 让屏幕动起来一个 ADC 电压显示和进度条的小项目6.1 目标与接线把下半节课的各种函数用在同一个项目里检验效果最直接。我这里做了一个非常容易复现的小系统用单片机的 ADC 采集一个电位器的电压OLED 第一行显示“电压”加数值第二行显示一个竖直的进度条表示电压百分比。硬件只需要一块 OLED、一个电位器以及几根杜邦线。接线按照课程使用的引脚方案SCL 接一个时钟引脚SDA 接一个数据引脚OLED 的 VCC 和 GND 分别接 3.3V 和地。不同开发板引脚编号不一样用哪两个引脚都可以关键是代码里的宏定义要对应上。6.2 布局和核心代码思路布局大概是这样第一行左边放标题字符串右边放电压值第二行放进度条。进度条不能直接用课程内置函数画但可以借助 OLED_ShowImage 或者自己写一个画矩形分段的函数。我的做法是每 10% 对应一个 8x16 的小方块图形进度到多少就显示几个方块没到的部分用空格覆盖。这一段代码逻辑就是根据 ADC 值计算需要显示几个方块然后循环调用 ShowImage 或直接写显存数据。核心刷新部分长这样uint16_t adc ADC_GetValue(); uint8_t percent (uint32_t)adc * 100 / 4096; OLED_ShowString(1, 1, Vol:); OLED_ShowNum(1, 5, adc * 3300 / 4096, 4); OLED_ShowChar(1, 9, m); OLED_ShowChar(1, 10, V); OLED_ShowString(1, 12, ); for (i 0; i 10; i) { if (i percent / 10) { OLED_ShowImage(2, 1 i * 2, 16, 16, g_solidBlock); } else { OLED_ShowString(2, 1 i * 2, ); } }这个循环每次更新整个进度条区域逻辑简单但会频繁调用 I2C 发送函数。实际运行时我加了一个简单的“数据变化超过一个阈值才刷新”的判断电位器不动时屏幕不更新既不闪烁也省电。6.3 为什么要做这种搬砖级练习有人觉得这种演示项目太简陋不如直接上框架上图形库。但我的真实体验是OLED 这种小屏项目最好的练习方式就是拿能摸到的函数拼一个能跑的界面。你只有亲手把“坐标布局”“长度覆盖”“刷新频率”这几个问题踩一遍后面用任何图形库都特别快。框架和图形库隐藏了很多细节屏幕一旦出问题你根本不知道是硬件、I2C 时序、还是坐标算错。而在这个小项目里每一个显示都是你自己用底层函数拼出来的哪里错都能马上定位这才是学习下半部分最有价值的落地方式。7. 把常见坑一次性踩平来自实战的四条个人经验7.1 屏幕闪烁不一定是硬件问题多数是重复全屏刷新屏幕闪烁会有几种表现整屏闪烁、局部数字残影、刷新时出现上下两个半屏对不齐。整屏闪烁最常见的原因是主循环里不断调用 OLED_Clear 再重新显示全屏内容。正确做法是尽量不清空全屏而是只更新变化的位置。下半部分的函数设计本来就是面向局部更新的一个数字变了就重画一个数字一行菜单变了就重画一行没有必要每次都把整块 GDDRAM 清一遍再写一遍。7.2 引脚选择要考虑调试接口的冲突用软件模拟 I2C 时一些初学者会习惯性选择单片机上靠近的 GPIO结果发现程序烧进去后调试器连不上。原因很可能是选到了被调试功能占用的引脚。不同型号芯片上某些引脚默认用作 SWD 调试或者 JTAG 功能要在代码里先把复用关系配置成 GPIO或者干脆避开这些引脚。我看到过不少人在 OLED 上花了两小时排查最后只是把 SCL 换了个引脚就全好。7.3 SSD1306 初始化顺序不能随意调换下半节不再重复初始化但项目里复制代码时很容易把初始化顺序改乱。SSD1306 有一个比较严格的流程先关显示然后设置显示时钟分频、复用率、显示偏移打开 charge pump设置内存地址模式最后清屏并开显示。有人为了省事把开显示的指令提前发送结果屏幕点不亮或者亮度异常。还有些人漏掉了 charge pump 那条指令屏幕一样不亮。遇到“初始化后没画面”这类问题先把官方驱动序列一字不差恢复多半就好了。7.4 取模和编码问题比你想的更常见中文显示乱码半个字上下错位图片完全花掉这类问题九成出在取模设置或者文件编码上。如果你确认点阵数组没问题还要看源文件本身是否以 UTF-8 保存工程是否有对应的中文编码设置。嵌入式开发环境里如果源文件里直接写了中文字符串编码不对就输出乱码。更稳妥的做法是不同工程统一用英文注释中文字模只存在数组里显示时通过索引调用不直接在代码里写汉字字符串这样就能彻底避开编码坑。我的经验是遇到显示方面的疑难杂症先不急着改代码先交叉验证“硬件能否点亮”“点阵数据是否正确”“地址模式是否匹配”“刷新策略是否合理”这四个环节。多数问题都卡在这四个环节里而不是单片机本身太复杂。跟着课程把下半部分彻底吃透之后再回头看这些坑几乎每个都有预料。