
做嵌入式这些年中文和英文显示的项目做了不少结果客户甩过来一个需求在 1.8 寸 TFT LCD 上显示阿拉伯语欢迎辞还要带滚动动态效果。当时我第一反应是“这不就是改个字符串吗”真上手才发现阿拉伯语这种从右往左写、字母有四种形态、还会连写的文字在低端 MCU 上根本不是“多存一张字库”能解决的。这篇文章就给你一条我实际跑通的路线PC 端把阿拉伯语渲染成位图MCU 端只负责裁剪、滚动和切换。5 步就能把功能落地代码也一起贴出来照着抄能少踩不少坑。这个项目本身不大但涉及几个嵌入式开发里常见的点ST7735 屏幕初始化、RGB565 数据取模、非阻塞动态显示逻辑、阿拉伯语 RTL 连字的预处理。如果你也在做 TFT LCD 显示、多语言人机界面或者只是想把一段复杂文字在单片机上“动起来”这篇实战记录应该能帮到你。下文默认你用过 STM32CubeMX、看得懂 SPI 时序但哪怕刚入门照着接线和代码也能跑起来。先把方案说清楚这次用的不是“在 MCU 上实时排版阿拉伯语”的硬核路线而是把复杂字形处理放到 PC单片机专心做动态显示。这套架构在资源紧张的 MCU 上一直很稳也是工业小屏很常见的做法。1. 项目背景与整体方案选型1.1 阿拉伯语在低端 LCD 上为什么难搞阿拉伯语属于“复杂文字”体系书写方向是从右到左RTL28 个基础字母会按词首、词中、词尾、独立四种形态切换大多数相邻字母还要连笔。这和中文里“每个字符一个方块字形”的逻辑完全不同。如果真想在下位机实时输入任意阿拉伯语再显示最低限度也要有一套阿拉伯语整形库把 Unicode 字符串转成带连写形态的字符序列然后查阿拉伯语字库渲染出来。对 STM32F103 这种 64KB RAM 的芯片来说这套东西光跑起来就不轻松更别说还要解决 LCD 动态刷新的实时性。我见过两种硬啃方案一种是在 MCU 端集成 HarfBuzz另一种是上 LVGL 的阿拉伯语扩展。如果你做的是 Linux 级嵌入式系统内存动辄 64MB 以上那没问题。但放在 128x160 的 SPI 小屏上这会变成“扛着服务器上战场”编译体积、运行内存、开发成本全都划不来。更实用的思路是把文字渲染工作放到 PC用成熟的排版引擎把阿拉伯语文本画成位图再用取模工具把位图塞到 Flash 里。1.2 三种方案对比我为什么选“PC预渲染”方案实时显示任意文本Flash/RAM 开销改显示内容的成本适用场景A. PC 预渲染位图不支持运行时输入极小图片占 FlashRAM 几十字节每次加文字需重新生成图片提示语、标语、固定菜单小内存 MCUB. 设备端整形字库支持字库和整形状态机占 Flash 较大只需改字符串内存充裕必须运行时可变的场景C. LVGL 等 GUI 的 Arabic 支持支持依赖 GUI 和字体开销最大相对简单复杂交互界面有触摸、多页面需求我的选型结论很直接项目只显示几句固定阿拉伯语还要跑在 STM32F103 上方案 A 是唯一能让工期可控的选择。你如果后续要做触摸多级菜单再考虑 B/C 也不迟但这篇全部按方案 A 走。1.3 整体架构一句话整个链路分两段PC 段Python Pillow 渲染阿拉伯语文本为 PNG再转 RGB565 格式生成 C 头文件。MCU 段STM32 驱动 ST7735 屏幕从 Flash 读取图片数组通过局部窗口刷新实现滚动/切换动画。这个设计把最耗资源的“字形整形”从 MCU 上完全摘掉MCU 只做两件事按地址读像素、按窗口刷屏。所以后面每一步都围绕“图片怎么生成”和“图片怎么流畅刷出来”展开。2. 第1步开发环境、接线与ST7735驱动2.1 硬件接线配置我用的是一块常见的 STM32F103C8T6 最小系统板屏幕是 1.8 寸 TFT LCD 模块驱动芯片 ST7735S分辨率 128x160。接线规划如下LCD 引脚STM32 引脚说明VCC3.3V模块供电不要接 5VGNDGND共地CSPA4SPI 片选RESETPA2复位低有效A0/DCPA3数据/命令选择SDA/MOSIPA7SPI 数据线SCKPA5SPI 时钟LED 背光PA1背光控制也可直接接 3.3V注意 SPI 的 MISOPA6可以不接因为我们只写屏、不读屏。不读寄存器会少一个排查手段但驱动代码简单不少很多项目都这么干。下载程序建议用一个 USB 转串口模块串口 ISP 下载就行工程编译后直接用 FlyMcu 这类工具烧录。2.2 CubeMX 的 SPI 初始化打开 STM32CubeMX选择芯片型号后要做这几项配置SPI1 设为 Full-Duplex Master但因为我们不用 MISO甚至可以在 CubeMX 里只启 MOSI/SCK代码逻辑不受影响。参数项8 Bit 数据位MSB FirstCPOL0CPHA0这就是 SPI Mode 0。分频选 1672MHz 时钟除以 16 得到 4.5MHz对 ST7735 来说足够稳。有朋友喜欢拉到 9MHz我实测在杜邦线连接下很容易花屏没必要追求那点速度。PA1、PA2、PA3、PA4 设为 GPIO OutputMode 设为 Push-PullMaximum speed 选 High。生成工程时记得选 Copy all used libraries into project folder否则换电脑编译容易缺文件。GPIO 初始化完成后把这几个引脚分别写成宏定义后面驱动代码里会反复用。SPI 的底层发送直接用 HAL 库的HAL_SPI_Transmit就行。2.3 最简 ST7735 驱动代码所有 LCD 驱动最后都落到“画点”而画点本质上是“设置窗口 发像素数据”。别被几千行的驱动文件吓住ST7735 初始化寄存器配对了功能 API 可以精简到三个函数。先看 lcd.h#ifndef LCD_H #define LCD_H #include stdint.h #include stm32f1xx_hal.h #define LCD_WIDTH 128 #define LCD_HEIGHT 160 #define LCD_CS_0() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define LCD_CS_1() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define LCD_DC_0() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET) #define LCD_DC_1() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET) #define LCD_RST_0() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET) #define LCD_RST_1() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET) void LCD_Init(void); void LCD_SetAddrWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1); void LCD_DrawImage(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, const uint8_t *data); void LCD_DrawImageSlice(uint16_t src_x, uint16_t dst_x, uint16_t dst_y, uint16_t width, uint16_t height, uint16_t img_width, const uint8_t *data); void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color); #endif再写 lcd.c 的核心部分#include lcd.h #include string.h extern SPI_HandleTypeDef hspi1; static void LCD_WriteCmd(uint8_t cmd) { LCD_DC_0(); LCD_CS_0(); HAL_SPI_Transmit(hspi1, cmd, 1, HAL_MAX_DELAY); LCD_CS_1(); } static void LCD_WriteDataBuffer(const uint8_t *buf, uint16_t len) { LCD_DC_1(); LCD_CS_0(); HAL_SPI_Transmit(hspi1, (uint8_t *)buf, len, HAL_MAX_DELAY); LCD_CS_1(); } void LCD_SetAddrWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); LCD_WriteDataBuffer((const uint8_t[]){ (x0 8) 0xFF, x0 0xFF }, 2); LCD_WriteCmd(0x2B); LCD_WriteDataBuffer((const uint8_t[]){ (y0 8) 0xFF, y0 0xFF }, 2); LCD_WriteCmd(0x2C); // 余下的 x1/y1 这里简化了完整写法应继续发 x1、y1 }要注意0x2A命令后面应该跟四个字节起始 x、结束 x 的高低位。上面为了篇幅简写了实际使用我建议这样写void LCD_SetAddrWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { uint8_t buf[4]; LCD_WriteCmd(0x2A); buf[0] (x0 8) 0xFF; buf[1] x0 0xFF; buf[2] (x1 8) 0xFF; buf[3] x1 0xFF; LCD_WriteDataBuffer(buf, 4); LCD_WriteCmd(0x2B); buf[0] (y0 8) 0xFF; buf[1] y0 0xFF; buf[2] (y1 8) 0xFF; buf[3] y1 0xFF; LCD_WriteDataBuffer(buf, 4); LCD_WriteCmd(0x2C); }初始化序列我直接给一套我自己验证过的寄存器参数不同模组之间会有差异但 ST7735S 基本通用static void LCD_InitRegs(void) { LCD_RST_0(); HAL_Delay(50); LCD_RST_1(); HAL_Delay(120); LCD_WriteCmd(0x01); HAL_Delay(10); // Soft Reset LCD_WriteCmd(0x11); HAL_Delay(120); // Sleep Out LCD_WriteCmd(0xB1); LCD_WriteDataBuffer((const uint8_t[]){ 0x05, 0x3C, 0x3C }, 3); LCD_WriteCmd(0xB2); LCD_WriteDataBuffer((const uint8_t[]){ 0x05, 0x3C, 0x3C }, 3); LCD_WriteCmd(0xB3); LCD_WriteDataBuffer((const uint8_t[]){ 0x05, 0x3C, 0x3C, 0x05, 0x3C, 0x3C }, 6); LCD_WriteCmd(0xB4); LCD_WriteDataBuffer((const uint8_t[]){ 0x03 }, 1); LCD_WriteCmd(0xC0); LCD_WriteDataBuffer((const uint8_t[]){ 0xA2, 0x02, 0x84 }, 3); LCD_WriteCmd(0xC1); LCD_WriteDataBuffer((const uint8_t[]){ 0xC5 }, 1); LCD_WriteCmd(0xC2); LCD_WriteDataBuffer((const uint8_t[]){ 0x0A, 0x00 }, 2); LCD_WriteCmd(0xC3); LCD_WriteDataBuffer((const uint8_t[]){ 0x8A, 0x2A }, 2); LCD_WriteCmd(0xC4); LCD_WriteDataBuffer((const uint8_t[]){ 0x8A, 0xEE }, 2); LCD_WriteCmd(0xC5); LCD_WriteDataBuffer((const uint8_t[]){ 0x0E }, 1); LCD_WriteCmd(0x36); // 显示方向 LCD_WriteDataBuffer((const uint8_t[]){ 0xC8 }, 1); LCD_WriteCmd(0x3A); // 像素格式 LCD_WriteDataBuffer((const uint8_t[]){ 0x05 }, 1); // 16bit/pixel LCD_WriteCmd(0x29); // Display ON } void LCD_Init(void) { LCD_InitRegs(); LCD_Fill(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1, 0xFFFF); }画图和填充函数void LCD_DrawImage(uint16_t x0, uint16_t y0, uint16_t w, uint16_t h, const uint8_t *data) { LCD_SetAddrWindow(x0, y0, x0 w - 1, y0 h - 1); LCD_WriteDataBuffer(data, (uint16_t)w * h * 2); } void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t count (uint32_t)(x1 - x0 1) * (y1 - y0 1); uint8_t tmp[32]; uint16_t i; LCD_SetAddrWindow(x0, y0, x1, y1); for (i 0; i 32; i 2) { tmp[i] color 8; tmp[i 1] color 0xFF; } while (count) { uint16_t n (count 16) ? 16 : (uint16_t)count; LCD_WriteDataBuffer(tmp, n * 2); count - n; } }这些函数够用了。注意LCD_DrawImage的数据格式是 RGB565 大端字节序也就是每像素先高字节后低字节和普通的uint16_t数组在 STM32 内存里的字节序不一样。所以后面 PC 端取模的时候我直接生成uint8_t大端数组从源头规避字节序问题。3. 第2步PC端生成阿拉伯语位图3.1 谁来做阿拉伯语整形答案是PC阿拉伯语“连字 右到左”的处理交给 PC 端 Pillow 库来做是最省事的方式。Pillow 底层如果带 Raqm 支持就能正确处理阿拉伯语字符的上下文形态变换和 RTL 排版。MCU 端不需要理解阿拉伯语只需要把 PC 画好的像素显示出来。这一步最核心的坑是渲染出来的阿拉伯语不能是“拆开的字母”。如果你在 PC 上生成的图片里字母都是独立状态、没有连笔那 MCU 端怎么折腾都没用。所以生成完图片先肉眼检查一遍确认 PNG 视觉上符合阿拉伯语书写习惯。3.2 Pillow 渲染脚本与关键坑我用的字体是Amiri-Regular.ttf一款常见的阿拉伯语开源字体风格比较接近印刷体。其它如Noto Naskh Arabic也可以。先写一段脚本来渲染词语from PIL import Image, ImageDraw, ImageFont from PIL import features # 先检查 Pillow 是否支持 Raqm支持才能正确渲染阿拉伯语 print(raqm:, features.check(raqm)) phrases [ مرحبا بالعالم, # 你好世界 شكرا جزيلا, # 非常感谢 أهلا وسهلا, # 欢迎 ] font_path Amiri-Regular.ttf font_size 32 img_w, img_h 128, 40 def render_arabic(text, font_path, font_size, img_w, img_h): font ImageFont.truetype(font_path, font_size) img Image.new(RGB, (img_w, img_h), (255, 255, 255)) draw ImageDraw.Draw(img) # 用 textbbox 算出文字区域实现右对齐 bbox draw.textbbox((0, 0), text, fontfont, directionrtl, languagear) text_w bbox[2] - bbox[0] text_h bbox[3] - bbox[1] x img_w - text_w - 2 y (img_h - text_h) // 2 draw.text((x, y), text, fontfont, fill(0, 0, 0), directionrtl, languagear) return img for i, text in enumerate(phrases): img render_arabic(text, font_path, font_size, img_w, img_h) img.save(far_{i}.png)需要提醒一句directionrtl和languagear这两个参数只在 Pillow 编译了 Raqm 支持时才会生效。如果你的环境不支持最终 PNG 里字母形态和顺序就可能是错的。验证方法简单脚本开头打印的raqm如果是True就没问题。3.3 转成 RGB565 并输出 C 头文件图片颜色信息要转成 ST7735 能识别的 RGB565。每个像素 16 位高 5 位红、中间 6 位绿、低 5 位蓝。注意我用的是大端字节序所以一像素先输出高字节再输出低字节。def image_to_rgb565_bytes(img): img_rgb img.convert(RGB) out bytearray() for r, g, b in img_rgb.getdata(): c ((r 3) 11) | ((g 2) 5) | (b 3) out.append(c 8) out.append(c 0xFF) return bytes(out) def write_c_header(png_files, img_w, img_h, out_path): with open(out_path, w, encodingutf-8) as f: f.write(#ifndef AR_WORDS_H\n#define AR_WORDS_H\n\n) f.write(#include stdint.h\n\n) f.write(#define AR_IMG_W %d\n#define AR_IMG_H %d\n\n % (img_w, img_h)) for idx, png in enumerate(png_files): img Image.open(png) data image_to_rgb565_bytes(img) name phrase_%d % idx f.write(const uint8_t %s[] {\n % name) for i in range(0, len(data), 16): chunk data[i:i16] f.write( , .join(0x%02X % b for b in chunk) ,\n) f.write(};\n\n) f.write(typedef struct {\n) f.write( const uint8_t *data;\n) f.write( uint16_t w;\n) f.write( uint16_t h;\n) f.write(} ar_img_t;\n\n) f.write(const ar_img_t ar_imgs[] {\n) for idx in range(len(png_files)): f.write( { phrase_%d, AR_IMG_W, AR_IMG_H },\n % idx) f.write(};\n\n) f.write(#define AR_IMG_COUNT (sizeof(ar_imgs)/sizeof(ar_imgs[0]))\n) f.write(#endif\n) png_files [ar_0.png, ar_1.png, ar_2.png] write_c_header(png_files, 128, 40, ar_words.h)生成的ar_words.h就是前面 MCU 代码要包含的头文件。每张图 128x40x2 字节约 10KB三张图也就 30KBSTM32F103C8 的 64KB Flash 放得下。如果以后想加词重新跑一遍脚本就行MCU 工程代码不用改。4. 第3步动态显示的滚动与切换逻辑4.1 图片资源如何组织动态显示我选的是“右侧滑入 - 停留 - 左侧滑出 - 下一张”的效果。这种效果不用真做 3D 动画只需要在水平方向移动图像窗口。图片组织方式为typedef struct { const uint8_t *data; uint16_t w; uint16_t h; } ar_img_t; const ar_img_t ar_imgs[] { { phrase_0, AR_IMG_W, AR_IMG_H }, { phrase_1, AR_IMG_W, AR_IMG_H }, { phrase_2, AR_IMG_W, AR_IMG_H }, }; #define AR_IMG_COUNT (sizeof(ar_imgs)/sizeof(ar_imgs[0]))轮流显示这一组图片不需要额外分配 RAM 作为屏幕缓冲数据全部在 Flash 里。4.2 用状态机实现“滑入-停留-滑出”我不建议直接在循环里写for死刷会阻塞其它任务。更稳的是写一个非阻塞状态机用HAL_GetTick()做时间基准。状态就三个SCROLL_IN图片从屏幕右侧往左移动直到 x0。HOLD停在屏幕中央保持 3 秒。SCROLL_OUT图片继续往左移动直到完全滑出屏幕。每个状态里的位置更新我放在Display_Task函数里主循环不断调用它。滚动速度我用每 16ms 移动 2 像素换算下来每秒约 125 像素视觉上比较顺滑。再快会出现明显跳动毕竟是 SPI 刷新不是并口屏。4.3 滚动绘制函数的实现要支持滚动就需要从整张图片中截取一部分画到屏幕。完整图片可能有一部分超出屏幕边界所以我们定义一个“画切片”的函数void LCD_DrawImageSlice(uint16_t src_x, uint16_t dst_x, uint16_t dst_y, uint16_t width, uint16_t height, uint16_t img_width, const uint8_t *data) { uint16_t y; uint8_t line[256]; if (width 0 || height 0) return; LCD_SetAddrWindow(dst_x, dst_y, dst_x width - 1, dst_y height - 1); for (y 0; y height; y) { const uint8_t *p data ((y * img_width src_x) * 2); memcpy(line, p, width * 2); LCD_WriteDataBuffer(line, width * 2); } }基于这个函数写一个带裁剪的绘制入口void DrawImageScrolled(int16_t pos, int16_t y, const uint8_t *img, uint16_t w, uint16_t h) { int16_t visible_x pos; int16_t src_x; int16_t dst_x; int16_t draw_w; if (visible_x LCD_WIDTH || visible_x w 0) return; src_x (visible_x 0) ? -visible_x : 0; dst_x (visible_x 0) ? 0 : visible_x; draw_w w - src_x; if (draw_w LCD_WIDTH - dst_x) draw_w LCD_WIDTH - dst_x; LCD_DrawImageSlice((uint16_t)src_x, (uint16_t)dst_x, (uint16_t)y, (uint16_t)draw_w, h, w, img); }这样一张 128 宽、40 高的阿拉伯语图在pos128时完全在屏幕右侧pos0时正对屏幕pos-128时完全滑出。整个动画就是把pos从 128 减到 -128 的过程。5. 第4步完整代码示例与关键点注释5.1 main.c 里的主逻辑下面是 main.c 里的核心代码。工程生成后main函数里只要初始化 LCD、调一次显示初始化然后循环执行Display_Task就行#include lcd.h #include ar_words.h #define INFO_Y 60 #define SPEED_PX 2 #define HOLD_MS 3000 #define TICK_MS 16 typedef enum { SCROLL_IN, HOLD, SCROLL_OUT } disp_state_t; static disp_state_t state SCROLL_IN; static uint8_t cur_img 0; static int16_t anim_x 128; static uint32_t last_tick 0; static uint32_t hold_start 0; static void RenderFrame(void) { LCD_Fill(0, INFO_Y, LCD_WIDTH - 1, INFO_Y AR_IMG_H - 1, 0xFFFF); if (state SCROLL_IN || state SCROLL_OUT) { DrawImageScrolled(anim_x, INFO_Y, ar_imgs[cur_img].data, ar_imgs[cur_img].w, ar_imgs[cur_img].h); } } void Display_Task(void) { uint32_t now HAL_GetTick(); switch (state) { case SCROLL_IN: if (now - last_tick TICK_MS) { last_tick now; anim_x SPEED_PX; if (anim_x 0) { anim_x 0; state HOLD; hold_start now; } RenderFrame(); } break; case HOLD: if (now - hold_start HOLD_MS) { state SCROLL_OUT; last_tick now; } break; case SCROLL_OUT: if (now - last_tick TICK_MS) { last_tick now; anim_x - SPEED_PX; if (anim_x -AR_IMG_W) { cur_img (cur_img 1) % AR_IMG_COUNT; anim_x LCD_WIDTH; state SCROLL_IN; } RenderFrame(); } break; } }主循环记得保证Display_Task一直被调用int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_SPI1_Init(); LCD_Init(); LCD_Fill(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1, 0xFFFF); while (1) { Display_Task(); } }看到这里你可能发现HOLD状态没有主动调用RenderFrame。因为滑入结束后画面已经停在屏幕上了停留期间不需要重绘等 3 秒后自动进入滑出状态。这样能减少 SPI 传输也避免画面闪烁。5.2 编译后 Size 与内存表现使用 Keil AC5 编译开启-O2优化后我这边的结果大致是Code 约 10KB 左右主要花在驱动和 HAL 库上。RO Data 包含 3 张阿拉伯语图片约 30KB。RW 和 ZI 很小因为图片都在 FlashRAM 只占几个状态变量。如果你用 CLion ARM GCC结果也差不多。需要注意的是不要把图片数组定义成局部变量或者全局变量存在 RAM否则 20KB RAM 根本不够只能老老实实加const修饰。这一点我在最初验收时踩过坑改成const后内存占用直接降了一个数量级。5.3 关于“完整代码”的使用说明严格说上面的代码并不是一份可以直接新建工程粘贴的完整文件因为 STM32 的 SPI、GPIO 底层由 CubeMX 生成不同 IDE 和库版本路径不一样。我提供的核心部分是三个LCD 驱动、图片数据格式、动态显示状态机。你把它们对应补进自己的main.c、lcd.c、lcd.h和生成的头文件里就能跑起来。如果要更接近“开箱即用”可以把 SPI 发送改为寄存器直操作不依赖 HAL 库代码量会更少但可读性不如 HAL。对学习阶段的朋友先用 HAL 把整体逻辑跑通再根据项目需要优化。6. 第5步调试、常见问题与优化6.1 白屏和花屏速查这类 SPI 小屏调试过程中最高频的问题就是白屏和花屏。我整理成速查表直接对着排查现象可能原因处理方法上电全白复位时序不对检查 RST 引脚是否正常拉低再拉高至少保持 50ms 低电平上电全黑背光没点亮查 LED 引脚接 3.3V 或让 PA1 输出高电平随机花屏SPI 速率过高把 CubeMX 里 SPI 分频调到 16 或 32用杜邦线时尤其要降速颜色错乱RGB565 字节序反了检查取模脚本是否输出“高字节在前”显示区域错位0x36方向寄存器不对不同模组的0x36值不一样常见是 0xC8、0x68、0x08逐个试屏幕偏蓝/偏红电源不稳加 100uF 电解电容在 LCD 供电处3.3V 和 GND 之间数据偶尔错位接线松动杜邦线改短或干脆焊到转接板上6.2 阿拉伯语字形或顺序不对怎么办这个问题和 MCU 端代码没有关系问题一定出在 PC 端生成图片那一步。上屏后如果看到字母是分开的、没有连写或者顺序和期望相反先打开生成的 PNG 看一眼如果 PNG 本身就不对MCU 不可能把它显示对。常见原因有三类当前 Pillow 没有 Raqm 支持导致阿拉伯语没有被整形。脚本里打印features.check(raqm)返回False就要换安装方式或换渲染方案。字体文件不包含阿拉伯语字形。用普通拉丁字体渲染出图大概率是空白的或者变成方框。右对齐算法写错导致第一个字母跑到了最左边。检查文字框的计算是否把text_w减掉了。我的习惯是先在 PC 上把三张 PNG 拼到一张大图里肉眼确认再转数组不然后面反复烧录调试很浪费时间。6.3 RAM不够与画面闪烁的优化思路如果你要显示更多阿拉伯语短句Flash 足够大通常不是瓶颈但 SPI 刷新整屏会很慢动画会卡。几个优化方向供参考只刷新变化的区域。滚动显示时只刷文字条所在的 40 像素高区域不整屏清空。上面的RenderFrame就是这么做的。显示区域做成窗口裁剪减少 SPI 字节数。每次只发可见列的像素不要每次都把整张图发出去。如果追求细腻动画可以开一个小的行缓冲把一行像素准备好后一次性发送比每像素发送快得多。代码里LCD_DrawImageSlice已经用了行缓冲思路。如果单张图超过 128 像素宽建议生成长条形图片滚动时只取当前窗口。这样画面更顺滑但取模逻辑要自己按源图坐标偏移。还有一个容易被忽略的小坑ST7735 的0x2C命令之后如果连续发送大量像素芯片内部会按之前设置的窗口自动换行。这意味着我们可以一条命令写完一个矩形区域但如果中途又发0x2A/0x2B窗口就变了。所以批量绘制时尽量一次写完不要打断。最后分享我自己的一个小经验给阿拉伯语文字生成位图时左右至少留 2 像素内边距不要贴着图片边缘。阿拉伯语字母的起笔和收笔有时会带装饰线贴边之后上屏很容易被裁掉看起来像字缺了一角。这个小问题我第一次做的时候完全没注意最后是放大 PNG 才发现是取模边界裁过头了。这个项目如果后续要扩展最自然的做法就是把图片生成脚本接到一个简单的网页或者批处理工具上内容更新时点一下生成按钮C 头文件自动替换MCU 工程编译烧录就完事。对固定提示语场景来说这套流程已经非常够用了。