STM32 OLED汉字图片显示与格式化字符串全攻略

📅 2026/7/31 8:54:08
STM32 OLED汉字图片显示与格式化字符串全攻略
1. 项目缘起与核心目标上次我们聊了聊STM32驱动OLED屏最基础的部分也就是点亮屏幕、显示几个英文字符。很多朋友跟着做下来反馈说“屏幕亮了但只能显示字母数字感觉差点意思”。确实一个只能显示“Hello World”的屏幕在项目里的实用性会大打折扣。无论是做一个带中文菜单的智能手表还是一个能显示传感器数据比如温湿度、压力值的小仪表甚至是一个能展示简单状态图标的小设备都离不开对汉字、图片乃至更复杂字符串格式化的支持。所以这一篇我们就来啃这块硬骨头。我们的目标很明确让这块小小的OLED屏不仅能说“英文”还能流利地“说中文”、“画图画”。这不仅仅是调用几个库函数那么简单背后涉及到字库的获取与存储、图片数据的转换与优化、以及如何在单片机有限的资源下高效组织这些数据。我会结合我实际在智能家居传感器节点、便携式调试工具等项目中的应用经验把这里面的门道、踩过的坑以及优化技巧毫无保留地分享给你。无论你是想做一个带中文提示的温湿度计还是一个能显示自定义Logo的开机动画这篇文章都能给你一套可以直接“抄作业”的完整方案。2. 汉字显示从字库到像素的跨越显示英文字符相对简单因为ASCII字符集只有128个每个字符通常用8x16或更小的点阵就能表示我们甚至可以定义一个数组把常用字符都存下来。但汉字是万万不能这么做的GB2312标准就有近7000个汉字全做到程序里单片机的Flash可能就先“撑爆”了。2.1 字库的选型与获取权衡的艺术首先我们必须引入“字库”的概念。字库本质上就是一个巨大的数据库里面存放了每个汉字对应的点阵数据。对于OLED这类单色点阵屏每个汉字就是用一组“0”和“1”来表示“1”代表这个像素点亮“0”代表熄灭。1. 点阵大小是第一个抉择点常见的有12x12, 16x16, 24x24等。12x12太小显示不够清晰24x24很美观但每个字占用的存储空间是16x16的2.25倍。对于大多数STM32F1系列Flash在64KB~128KB的项目16x16点阵是最均衡的选择在清晰度和存储开销上取得了很好的平衡。一个16x16的汉字需要16 * 16 / 8 32字节。如果你需要显示1000个汉字就需要大约31KB的存储空间这在很多项目中是可以接受的。2. 字库来源与制作我们不太可能自己去画每个汉字。通常有两种途径使用现成的字库芯片如GT30L32S4W这是一颗SPI接口的汉字字库芯片内置了海量的字库单片机只需要发送汉字编码它就会返回点阵数据。优点是省事不占用单片机Flash缺点是增加硬件成本和PCB面积多占用一个SPI接口。自制字库文件嵌入程序这是更常用、更考验技巧的方法。我们需要借助PC端的取模软件。这里我强烈推荐“PCtoLCD2002”这个老牌但极其好用的工具。它的操作逻辑非常直观选择模式字符模式。设置参数点阵格式选阴码即1亮0灭取模方式选逐列式、顺向高位在前输出格式选C51格式。这些设置必须和后续我们编写的显示函数严格匹配。输入汉字并取模在文字输入框输入你项目需要用到的所有汉字比如“温度湿度正常警告”然后点击生成字模软件就会在下方生成对应的C语言数组代码。注意取模方式的“逐列式”和“逐行式”是天壤之别。OLED的GRAM显存通常按列组织数据如果你取模方式错了显示出来的汉字会是扭曲、错位的。我早期就因为这个坑调试了一下午。对于大多数OLED驱动芯片如SSD1306“逐列式、顺向高位在前”是标准配置。2.2 字库的存储与索引效率的关键从取模软件得到的是一个巨大的数组比如// 字体宋体, 大小16x16 const unsigned char HzLib[] { /*-- 文字: 温 --*/ 0x40,0x40,0x42,0x4C,0x58,0x40,0x7F,0x48,0x48,0x48,0x48,0x7F,0x40,0x40,0x40,0x00, 0x40,0x40,0x42,0x46,0x4A,0x52,0x42,0x7E,0x42,0x42,0x42,0x42,0x42,0x40,0x40,0x00, /*-- 文字: 度 --*/ 0x00,0x00,0x3F,0x20,0x20,0x3F,0x20,0x20,0x3F,0x20,0x22,0x2C,0x20,0x00,0x00,0x00, 0x00,0x80,0x40,0x3F,0x00,0x00,0x3F,0x41,0x41,0x41,0x41,0x41,0x43,0x40,0x78,0x00, // ... 更多汉字数据 };但这带来了一个问题我们如何根据一个汉字比如“温”快速找到它在数组HzLib中的起始位置呢我们不能每次都遍历整个数组去匹配。这里就需要引入编码索引。在单片机领域最常用的是GB2312/GBK编码。每个汉字对应一个唯一的2字节编码称为机内码。我们可以建立一个“索引表”这个表不存储点阵数据只存储每个汉字编码及其点阵数据在HzLib中的偏移地址。一个更工程化的做法是在取模时就按照汉字编码的顺序比如GBK顺序来排列字模数据。然后我们编写一个函数输入汉字的GBK编码通过计算直接定位到数据位置。核心查找函数逻辑如下// 假设我们的字库HzLib是按GBK编码顺序紧密排列的且起始编码为0xA1A1GB2312区位的起始 // 每个汉字占32字节 uint32_t GetHzMat(uint8_t *code, uint8_t *mat) { uint16_t gbk (code[0] 8) | code[1]; // 合并两个字节得到GBK编码 // 计算该编码在字库中的索引。这里需要知道字库的起始编码。 // 例如如果字库是从GBK编码0xA1A1开始的 uint32_t index (gbk - 0xA1A1) * 32; // 每个汉字32字节 // 检查索引是否超出字库范围 if(index 32 sizeof(HzLib)) { return 0; // 未找到该汉字 } // 将点阵数据复制到mat指向的缓冲区 memcpy(mat, HzLib[index], 32); return 32; // 返回数据长度 }这样当我们从串口、Flash或其他地方收到“温”字的GBK编码0xCEC2时就能飞速地拿到它的32字节点阵数据为显示做好准备。这种“编码索引连续存储”的方式是平衡查找速度和存储空间的经典方法。2.3 汉字显示函数的实现与优化有了点阵数据显示函数就水到渠成了。它的核心任务是将这32字节的数据按照正确的坐标画到OLED的GRAM中。/** * brief 在OLED上显示一个16x16的汉字 * param x: 列起始位置 (0~127) * param y: 页起始位置 (0~7)OLED通常8行为一页 * param gbk_code: 指向汉字GBK编码的指针2字节 * retval 无 */ void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t *gbk_code) { uint8_t i, j; uint8_t chinese_mat[32]; // 存放一个汉字的点阵数据 // 1. 获取字模 if(GetHzMat(gbk_code, chinese_mat) 0) { return; // 获取失败可能字库中无此字 } // 2. 将32字节数据写入OLED // 16x16点阵横向16像素纵向16像素。 // 数据排列前16字节是上半部分前8行后16字节是下半部分后8行 for(j 0; j 2; j) { // 分上下两半部分显示每部分8行 for(i 0; i 16; i) { // 每半部分有16列 // 设置OLED的写坐标 OLED_Set_Pos(x i, y j); // 向OLED的GRAM写入一个字节的数据一列的8个像素 Write_OLED_Data(chinese_mat[j * 16 i]); } } }这里有一个关键细节OLED_Set_Pos函数设置的“页地址”y坐标是以8个像素行为单位的。因为我们的汉字是16行高所以需要分两次写入第一次y坐标是y写入前16个字节对应汉字的上半部分8行第二次y坐标是y1写入后16个字节对应汉字的下半部分8行。这个“分页写入”的逻辑是OLED显示任何超过8像素高度的图形的基础务必理解透彻。实操心得在调试汉字显示时如果出现汉字上下颠倒、左右错位或者乱码请按以下顺序排查1.取模软件设置阴码/阳码、逐列/逐行、顺向/逆向2.字库数据索引编码计算是否正确索引是否越界3.显示函数坐标计算分页逻辑是否正确。我习惯先显示一个简单的汉字如“中”然后根据它的显示效果来逆向调整取模设置这比盲目检查代码要快得多。3. 图片显示将视觉元素嵌入嵌入式世界在UI中图片如图标、Logo、状态图能极大提升用户体验。在单色OLED上显示图片本质就是显示一个单色的位图BMP。3.1 图片预处理从彩色到单色位图你不可能直接把一张JPG或PNG图片丢给STM32。单片机需要的是最原始的、每个像素用1位1bit表示的数据1亮0灭。所以预处理必须在PC上完成。步骤详解准备图片找到或设计你需要的图片最好是轮廓清晰、对比度高的图标或Logo。尺寸要精确比如16x16, 32x32, 64x64等不要超过OLED分辨率128x64。转换为单色BMP使用画图工具如Windows画图、Photoshop、GIMP将图片另存为“单色位图.bmp”。这个步骤会丢弃所有颜色和灰度信息只保留黑白。使用取模软件再次打开PCtoLCD2002这次选择图形模式。点击载入选择刚才生成的单色BMP文件。参数设置至关重要扫描方式选择逐列式输出格式选择C51格式。这里可能还需要调整输出选项比如是否包含图像宽高信息。为了简单起见我们可以让软件只输出像素数据数组图像的宽高我们在代码里硬编码。点击生成字模你就得到了图片的C数组。生成的数组大概长这样/*-- 图像宽度: 32 --*/ /*-- 图像高度: 32 --*/ const unsigned char Image_Logo[] { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, // ... 总共 32 * 32 / 8 128 个字节 };注意一个32x32的图片共有1024个像素。每个字节代表8个像素一列中的8行所以总字节数是32 * 32 / 8 128字节。3.2 通用图片显示函数的设计图片显示函数和汉字显示函数逻辑高度相似但需要更通用因为它要能处理不同尺寸的图片。/** * brief 在OLED上显示一幅位图 * param x: 图片左上角列坐标 (0~127) * param y: 图片左上角页坐标 (0~7) * param width: 图片宽度像素 * param height: 图片高度像素 * param pic: 指向图片点阵数据数组的指针 * retval 无 */ void OLED_ShowPicture(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t *pic) { uint8_t i, j, page; uint32_t index 0; // 计算图片在垂直方向占多少“页”每页8行 uint8_t page_height height / 8; if (height % 8 ! 0) { page_height; // 如果高度不是8的倍数需要多一页来容纳剩余行 } // 双层循环纵向按页写横向按列写 for (page 0; page page_height; page) { for (i 0; i width; i) { // 设置当前写入点的坐标 OLED_Set_Pos(x i, y page); // 从图片数据数组中取出对应的一字节数据并写入 // 数据排列规律先从左到右第一列的上8行、下8行...再到第二列... // 所以索引计算为当前列i 当前页page * 图片宽度 Write_OLED_Data(pic[index]); index; } } }这个函数是核心中的核心。它通过width和height参数适应任意尺寸的图片。内部的双重循环确保了无论图片多大数据都能被准确地、按列按页地填充到OLED的显存中。踩坑实录图片显示错位或拉伸。这个问题十有八九出在取模设置和显示函数的数据索引计算不匹配上。务必确认取模软件的扫描方式和显示函数的写入逻辑一致都是逐列。图片的width和height参数传递正确。图片数据数组的索引计算pic[index]是正确的。一个简单的验证方法是先显示一个纯黑白方格比如棋盘格图案如果显示正确说明流程通了再换复杂图片。3.3 存储空间优化策略让有限的Flash发挥最大价值一张32x32的图标就要128字节多放几张图片Flash很快就捉襟见肘了。我们必须优化。策略一压缩图片尺寸和数量。评估UI是否每个图标都需要32x3216x1632字节是否够用能否复用图标如“警告”图标同时用于高温和低温策略二使用压缩算法。对于单色位图行程编码RLE是非常高效的压缩方式。连续相同的像素比如大片空白可以被压缩成(计数值, 像素值)。在显示前需要用STM32进行实时解压。这会增加一些CPU开销但能显著节省Flash。这对于复杂Logo或开机动画非常有用。策略三外置存储器。如果图片真的很多很大比如多帧动画可以考虑使用外置SPI Flash或SD卡来存储图片数据需要时再加载到STM32的内存中进行显示。这属于进阶方案涉及到文件系统和动态内存管理。在我的一个电池供电的传感器项目中为了显示4种不同的信号强度图标每张16x16和一个公司Logo32x32我选择了策略一和策略二的结合将图标精简到16x16并对Logo进行了简单的RLE压缩最终图形资源占用从原来的(4*32 128) 256字节减少到了(4*32 约70) ≈ 200字节节省了约20%的空间效果显著。4. 字符串的格式化与混合显示打造实用界面单独显示汉字或图片只是基础真正的项目需要将变量如传感器读数、固定汉字、甚至单位符号混合显示成一句完整的话例如“温度25.6℃”。4.1 实现一个简单的格式化输出函数我们不可能为每一句需要显示的话都单独写显示代码。我们需要一个像printf那样的函数但它是作用在OLED上的。我们可以自己实现一个轻量级的版本。/** * brief 在OLED指定位置开始显示格式化字符串支持数字、英文字母、汉字 * param x: 起始列 * param y: 起始页 * param fmt: 格式化字符串例如 温度:%d.%d℃ * param ...: 可变参数对应fmt中的格式符 * retval 无 */ void OLED_Printf(uint8_t x, uint8_t y, const char *fmt, ...) { char str_buf[64]; // 定义一个缓冲区存放最终要显示的字符串 va_list args; uint8_t i 0; uint8_t current_x x; uint8_t current_y y; // 1. 使用标准库函数将格式化字符串和参数组合成完整字符串 va_start(args, fmt); vsprintf(str_buf, fmt, args); va_end(args); // 2. 遍历缓冲区中的每个字符 while(str_buf[i] ! \0) { // 判断当前字符是ASCII字符还是汉字GBK编码首字节大于0x80 if((uint8_t)str_buf[i] 0x80) { // 是汉字提取两个字节 uint8_t gbk_code[2] {str_buf[i], str_buf[i1]}; OLED_ShowChinese(current_x, current_y, gbk_code); current_x 16; // 汉字宽度占16列 i 2; // 跳过两个字节 } else { // 是ASCII字符数字、字母、符号 OLED_ShowChar(current_x, current_y, str_buf[i]); // 使用之前实现的字符显示函数 current_x 8; // ASCII字符宽度占8列 i 1; } // 3. 处理换行如果当前行剩余宽度不足则换到下一行起始位置 if(current_x 120) { // 假设一行最多显示128像素预留一些空间 current_x 0; current_y 2; // 16x16汉字占2页所以换行时y坐标2 // 这里需要检查current_y是否超出屏幕底部 if(current_y 6) { // 最多7页从0开始 break; // 超出屏幕停止显示 } } } }这个OLED_Printf函数是一个强大的工具。现在你只需要一行代码OLED_Printf(0, 0, 温度:%d.%d℃, temp_integer, temp_fraction);它就能自动处理数字到字符串的转换、中英文混合、以及光标当前位置的自动推进极大地提高了开发效率。4.2 处理更复杂的字符串操作有时候我们接收到的字符串可能是完整的句子需要从中提取信息或者进行截取、比较等操作。虽然STM32上不适合运行完整的字符串库但一些基本操作是必要的。例如从传感器数据包中解析出一段包含数字和汉字的字符串“当前电压3.27V”。我们需要提取出数字“3.27”。// 一个简单的字符串解析示例 void ParseVoltageString(const char* rx_buf) { char voltage_str[10]; int index 0; // 找到的位置 char* start strchr(rx_buf, ); if(start ! NULL) { start; // 跳过 // 复制之后直到非数字或小数点之前的内容 while(*start ! \0 (isdigit(*start) || *start .)) { voltage_str[index] *start; start; } voltage_str[index] \0; // 字符串结束符 // 现在voltage_str中就是3.27 // 可以将其转换为浮点数使用 float voltage atof(voltage_str); OLED_Printf(0, 2, 电压:%sV, voltage_str); } }这些字符串处理函数strchr,isdigit,atof通常包含在标准的C库中在Keil或IAR中需要确保勾选了“Use MicroLIB”或类似选项来支持这些函数否则可能会链接错误。重要提示嵌入式系统中的字符串处理。在资源受限的STM32上一定要避免使用scanf、sprintf不带长度限制等可能造成缓冲区溢出的危险函数。尽量使用更安全的版本如snprintf或者像上面例子一样手动解析。同时频繁的字符串操作会产生内存碎片在长期运行的产品中需要关注堆heap的使用情况。5. 项目集成与高级技巧从Demo到产品把汉字、图片、字符串显示都调通后我们就可以搭建一个完整的用户界面了。5.1 构建一个简单的UI框架一个典型的嵌入式UI可能包含以下几个部分状态栏显示在屏幕顶部显示信号强度、电池电量、时间等。可以用图标和数字混合显示。主显示区屏幕中间大部分区域用于显示核心信息如传感器数据、菜单列表等。提示区/脚注屏幕底部用于显示操作提示或错误信息。我们可以为每个区域编写独立的刷新函数。// 刷新状态栏 void Refresh_StatusBar(void) { OLED_ShowPicture(0, 0, 16, 16, Icon_Battery_4bar); // 电池图标 OLED_Printf(20, 0, %02d:%02d, hour, minute); // 时间 // ... 其他状态信息 } // 刷新主显示区 void Refresh_MainArea(void) { switch(current_screen) { case SCREEN_HOME: OLED_Printf(10, 2, 温度: %2.1f℃, temperature); OLED_Printf(10, 4, 湿度: %2.1f%%, humidity); break; case SCREEN_MENU: // 显示菜单列表 break; } } // 主循环中调用 while(1) { if(need_refresh) { OLED_Clear(); // 清屏 Refresh_StatusBar(); Refresh_MainArea(); OLED_Refresh(); // 将GRAM更新到屏幕 need_refresh 0; } // ... 其他任务 }这种分层刷新的思想避免了全屏刷新带来的闪烁也使得代码结构清晰易于维护。5.2 性能优化与闪烁处理直接调用OLED_Clear()清屏再重绘在数据量大时会有明显的闪烁感。优化方法局部刷新只刷新内容发生变化的区域。比如温度值从“25.6”变成“25.7”我们只需要在原来的数字区域用背景色“覆盖”再写入新值即可无需清空整个屏幕。双缓冲如果支持有些OLED驱动芯片支持设置显示起始行Set Display Start Line可以利用这个特性实现软滚动或减少闪烁但对于STM32SSD1306这种架构硬件上通常没有真正的双缓冲GRAM更多的是靠软件优化局部更新逻辑。定时刷新而非连续刷新对于变化不快的传感器数据可以每1秒或500ms刷新一次显示而不是每次循环都刷新。5.3 字体与图片资源的管理进阶当项目变大字体和图片资源众多时一股脑全链接进代码会让编译和查找都很痛苦。好的做法是建立独立的资源文件如font.c/font.h,bmp.c/bmp.h将不同字体、不同尺寸的图标分类存放。使用结构体数组管理为每个图标或字体定义一个结构体包含其数据指针、宽度、高度、名称ID等便于通过ID查找。typedef struct { uint16_t id; const uint8_t *data; uint8_t width; uint8_t height; } ImageResource; const ImageResource ImageList[] { {ICON_BATTERY_0, Img_Bat0, 16, 16}, {ICON_BATTERY_1, Img_Bat1, 16, 16}, {ICON_WIFI, Img_Wifi, 16, 16}, // ... }; const ImageResource* GetImageById(uint16_t id) { for(int i0; isizeof(ImageList)/sizeof(ImageList[0]); i) { if(ImageList[i].id id) return ImageList[i]; } return NULL; }这样在需要显示图标的地方只需要调用GetImageById(ICON_WIFI)就能拿到资源代码可读性和可维护性大大提升。从点亮屏幕到显示字符再到构建一个包含中文、图标和动态数据的完整界面我们一步步拆解了其中的每一个技术环节。这些知识不仅仅适用于STM32和OLED其核心思想——字库处理、图片取模、数据索引、混合显示、资源管理——是任何嵌入式图形显示的通用基础。希望这篇长文能成为你嵌入式GUI开发路上的一块扎实的垫脚石。在实际动手时别忘了最重要的环节调试。利用好OLED的OLED_Fill()或画点函数帮你可视化地确认坐标和数据的对应关系能节省大量时间。