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编码顺序紧密排列的,且起始编码为0xA1A1(GB2312区位的起始) // 每个汉字占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坐标是y+1,写入后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,是否每个图标都需要32x32?16x16(32字节)是否够用?能否复用图标(如“警告”图标同时用于高温和低温)?
- 策略二:使用压缩算法。对于单色位图,行程编码(RLE)是非常高效的压缩方式。连续相同的像素(比如大片空白)可以被压缩成
(计数值, 像素值)。在显示前,需要用STM32进行实时解压。这会增加一些CPU开销,但能显著节省Flash。这对于复杂Logo或开机动画非常有用。 - 策略三:外置存储器。如果图片真的很多很大(比如多帧动画),可以考虑使用外置SPI Flash或SD卡来存储图片数据,需要时再加载到STM32的内存中进行显示。这属于进阶方案,涉及到文件系统和动态内存管理。
在我的一个电池供电的传感器项目中,为了显示4种不同的信号强度图标(每张16x16)和一个公司Logo(32x32),我选择了策略一和策略二的结合:将图标精简到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[i+1]}; 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),可以利用这个特性实现软滚动或减少闪烁,但对于STM32+SSD1306这种架构,硬件上通常没有真正的双缓冲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 i=0; i<sizeof(ImageList)/sizeof(ImageList[0]); i++) { if(ImageList[i].id == id) return &ImageList[i]; } return NULL; }GetImageById(ICON_WIFI)就能拿到资源,代码可读性和可维护性大大提升。
从点亮屏幕到显示字符,再到构建一个包含中文、图标和动态数据的完整界面,我们一步步拆解了其中的每一个技术环节。这些知识不仅仅适用于STM32和OLED,其核心思想——字库处理、图片取模、数据索引、混合显示、资源管理——是任何嵌入式图形显示的通用基础。希望这篇长文能成为你嵌入式GUI开发路上的一块扎实的垫脚石。在实际动手时,别忘了最重要的环节:调试。利用好OLED的OLED_Fill()或画点函数,帮你可视化地确认坐标和数据的对应关系,能节省大量时间。
