本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

点阵显示的内存预算:
一次把 SRAM 算清楚

我曾把一块 LED 单元板控制卡的“显示错乱”当成驱动问题查了很久:行选、OE 极性、移位顺序都翻了一遍, 最后发现根因是一道加法——64×64 点阵、128 个汉字、双页缓存,需要 128 KB, 而芯片只有 192 KB SRAM,还要放 RTOS 堆、任务栈、行缓冲和取模缓冲。 这篇笔记把那笔账从头算一遍,也写清楚“用产品规格规避内存不足”为什么可行、又为什么不干净。

点阵显示 字库取模 内存预算 双缓冲 SRAM 已发布

一、一个“显示错乱”的 bug,根因是算术

那块控制卡(完整记录见《4 行 × 8 字 LED 广告屏控制卡》) 驱动的是 4 行 × 8 字的 LED 单元板,主控是 HC32F460(Cortex-M4 + FPU), 片上 512 KB Flash、188 KB + 4 KB = 192 KB SRAM,跑 FreeRTOS, FreeRTOS 堆开的是 16 × 1024 字节;汉字点阵由一个 SPI 接口的字库芯片提供, 最多管理 10 个显示页,还有跑马灯和自动亮度。

问题出现在把字体从 32×32 换成 64×64、并且一页文字超过 96 个汉字之后: 屏幕上开始出现错乱——有的字只显示了一半,有的几行内容串在一起,有的角落里残留着上一次的显示内容。 我当时的排查顺序完全是按“驱动问题”走的:行选信号、OE 极性、移位寄存器的时序、扫描节拍, 一圈查下来没有任何问题。真正让我改变方向的是一条很不起眼的线索: 把字号调回 48×48,一切又恢复正常。

顺着“和字号强相关”这条线去翻内存,根因很快就露出来了:负责缓存整页点阵的那个数组太短。 它只有 6192 个单元,而一页 64×64、128 个汉字的数据需要 8192 个单元、 约 64 KB;如果“当前页 + 预加载页”同时在内存里,就是 128 KB。 更关键的是:往这个数组里写数据之前,代码里没有任何边界检查。 越界写出去的数据直接踩在相邻的变量上——屏幕上看到的“错乱”,本质上是内存被写坏了。

从这件事之后,我对点阵显示的理解变了:能不能显示,往往不是驱动问题,而是算术问题。 下面我把这笔账从头算一遍,用的全是这个工程里真实存在的常量。

二、从字号到字节数:点阵数据到底占多少

点阵字模的存储方式很朴素:一位对应一个点。所以一个宽 w、 高 h 的汉字,按行打包后占 w × h / 8 字节 (宽是 8 的整数倍时正好整除;遇到不等宽字体,就按每行单独向上取整)。 把常见字号代进去,一页的占用立刻就有量级了——下表按“一页 128 个汉字”计算:

字号每字点数每字字节一页 128 字双页缓存
16×1625632 B4 KB8 KB
24×2457672 B9 KB18 KB
32×321024128 B16 KB32 KB
48×482304288 B36 KB72 KB
64×644096512 B64 KB128 KB

这张表里最后一行,就是那条源码注释里的结论:64×64、128 个汉字,一页 64 KB、双页 128 KB。 数字一点都不抽象——一行 64 点就是 64 bit,也就是 8 字节;128 个字、每个字 64 行, 一共 8192 行,乘起来 64 KB。注释里说的“8192 个单元”,说的就是这 8192 行。

字号不是抽象概念,它直接等于字节数

在这个工程里,“字号”是一等参数。字库芯片(SPI 接口,配一个定时器中断)自己带成品字库, 固件里给出的汉字字号宏从 MIN_8X16_16X16、MED_16X24_24X24、 MAX_24X32_32X32、PLUS_32X48_48X48 一直到 ULTRA_48X64_64X64,一共五档;ASCII 的字号更多,从 5×7 到 12×24 有二十多种编号, 还按方体、不等宽、时钟体、宋体、黑体分成不同字型家族。 取模时先从字库芯片把点阵读进一个 ZK_BUFFER_LEN = 4608 的中间缓冲, 再折算到页面缓存里。

这里有两个细节我后来复盘时觉得很重要:

  • 取址分三步算。字库驱动的注释里写着,之所以把地址计算拆成三步, 是担心 8 位单片机做乘法会溢出——也就是说这段驱动是从更小的平台上移植过来的。
  • 缓存数组的 6192 这个数字,带着同样的历史痕迹。它是按当时的产品规格 (小字号、少字数)定下来的;后来字号一路加到 64×64,数组却没有跟着长大。 常量是会过期的:定的时候它对,改需求的时候没人回头再看它一眼。

我现在看任何一个“缓存数组”,都会先问一句:这个长度是怎么来的? 如果答不上来,它就不是一个设计参数,而是一颗定时炸弹。

横轴为点阵字号(从 16×16 到 64×64),纵轴为单页所需显存,曲线随字号平方增长,画一条水平线表示“可用上限”
图 1 · 字号与显存占用的关系曲线图

三、双缓冲与多页:为什么内存需求会翻倍

LED 单元板是扫描成像的:一行一行点亮,靠视觉暂留让人看到完整画面。 这就带来一个硬约束——正在被扫描的那份数据,不能在扫描到它的时候被改写。 如果直接改,观众会看到“半新半旧”的一帧,也就是常说的撕裂。 所以显存必须至少分成两份:一份正在送显,一份正在准备,送完一轮再切换指针。

这个工程里显存是按行缓冲组织的:line_bufa[4][128]、line_bufb、 line_bufc、line_bufd 四块,每块还各有一个 *2 的备份组, 再通过 pline_bufa 这一类指针指向“当前要写的那一组”, 用 save_buf_name / read_buf_name / buf_busy_state1/2 / timer_idle_state 这几个状态量做乒乓切换。光这一级就是 8 块 × (4 × 128) = 4096 个单元。

页面缓存也一样是双份的:负责文字预加载的结构里,点阵缓存存在两组—— 一组是“当前正在显示的页”,另一组是“下一帧要显示的页”。这是为了让翻页和跑马灯不卡顿, 属于体验上的必要投入。但它的代价很明确:

缓冲层级作用份数相对乘数
取模缓冲(ZK_BUFFER_LEN = 4608)暂存刚从字库芯片读出的单字/单行点阵1 份1×
页面点阵缓存(cache_buf[6192])整页点阵,供送显直接取用2 份(当前页 + 预加载页)2×
行缓冲(4 × 128 × 4 块 + 备份组)送显用的行数据,避免扫描时被改写8 块8 块并行

除了显示,这一层还有一组和“空间换算”有关的常量容易被忽略:视口用 currentline、max_viewport_column(一条屏最多显示 8 个汉字)、 viewport_shift_count(注释写的是“1 个屏移位 16 次,n 个屏移位 n×16”)、 canvas_used_column 来描述。这类常量把“一屏有多宽”折算成了“要移多少次”, 它们同样会跟着字号一起变——字号翻倍,同一个视口能放的字数和移位的次数都要重算。

我在这件事上的总结只有一句:每一级为了“平滑”而加的缓冲,本质上都是乘 2 的关系。 加一级双缓冲,预算翻倍;再加一级预加载,再翻一倍。所以在动笔之前, 先把“必须同时驻留几份”数清楚,比事后优化有效得多。

四、把预算算到底:SRAM、RTOS 堆、栈、缓存一起排队

现在把已知的常量摆到一张表里。下面每一行的依据都来自工程的配置或源码注释, 能算出确切数字的就算到底,只能算“单元数”的就如实标出来:

项目依据占用
片内 SRAM 总量IRAM1 0x2F000(188 KB)+ IRAM2 0x1000(4 KB)192 KB
FreeRTOS 堆configTOTAL_HEAP_SIZE = 16 × 102416 KB
静态任务栈256 字 × 4 个 + 512 字 × 3 个 = 2560 字(Cortex-M4 上 1 字 = 4 字节)10 KB
页面点阵缓存(2 份)cache_buf[6192] × 2 份;按注释“8192 单元 = 64 KB”反推 1 单元 = 8 字节约 96.8 KB
行缓冲(显存)4 × 128 × 8 块4096 个单元
取模中间缓冲ZK_BUFFER_LEN = 46084608 个单元
页面内容字段struct_page::content[260];页面队列 4 组 × 10 项(若全部驻留内存)约 10.4 KB
而 64×64 / 128 字 / 双页这个需求本身512 B × 128 × 2128 KB

把能算清的先加起来:96.8 + 16 + 10 = 122.8 KB,已经吃掉 192 KB 的六成四。 再叠上 4096 个单元的行缓冲、4608 个单元的取模缓冲、4 组 × 10 项的页面队列, 以及通信解析时的临时分配(板子上的 JSON 解析器走的是那 16 KB 堆)、 系统的栈和一堆零散全局变量——剩下的空间根本容不下“再来 128 KB 的整页双缓存”。

更要紧的是数量级:128 KB 的需求和 192 KB 的总量,本来就是同一个数量级。 这意味着它不是“优化一下就好”的问题,而是需求超出了器件本身。 我见过太多把这类问题当成“代码写得不够精简”来处理的讨论, 方向从一开始就偏了——应该先承认装不下,再谈怎么办。

顺带说两个我算这笔账时才注意到的点

  • 栈是按“字”算的。 配置里写的 256、512 是“字”(word), Cortex-M4 上 1 字 4 字节,所以一个 512 字的任务栈是 2 KB。 我一开始看到“512”觉得很大,换算过来也就刚够一个带打印的任务用。 凡是看到裸数字,先确认单位。
  • 吃掉 RAM 的从来不是 RTOS。 同一批项目里另一块手持设备的板子, 堆只开到了 10 × 1024 字节。在这类产品里,16 KB 的堆已经算宽裕配置; 真正的大头是按页、按屏、按份数申请的显示与取模缓冲。 把内存紧张归罪于“上了 RTOS”,通常是没有算过账。
一根竖条代表全部 SRAM,按用途自下而上切分:点阵缓存、行缓冲、RTOS 堆、任务栈、剩余可用,各段标注占比
图 2 · 显存预算的堆叠条形图

五、三种解法:缩规格、改结构、外扩存储

账算清之后,路其实只有三条:把需求缩小、把结构改掉、把存储扩出去。 这个工程实际走的是第一条——把产品最大字号限制在 48×48 / 128 字, 64×64 的情况下限制在 96 字。三条路的取舍我整理成下表:

解法具体做法代价什么时候合适
缩规格 限制最大字号与每页字数:48×48 / 128 字;64×64 / 96 字 产品能力被锁死;约束只写在文档和注释里,代码层没有边界检查 已在产、改动风险最小的止血方案
改结构 不缓存整页点阵,改成按行/按区取模、边取边送;把“整页双份”降成“一行双份”;页面点阵移到外部 Flash,内存里只留一个窗口 取模次数上升,要保证字库读取速度跟得上扫描节拍;逻辑复杂度明显上升 板上已有外部 Flash、扫描速率不高、愿意动固件架构
外扩存储 外挂 SRAM / PSRAM,或直接换 SRAM 更大的型号 改板、成本、引脚占用、PCB 重画 下一代硬件
换方案:把显示外包 用一块自带显存和点阵字库的串口屏,主控只往屏的变量地址里写数值 显示排版能力受屏侧固件限制;屏侧逻辑与主控逻辑的职责边界必须划清 显示内容以“数值 + 固定界面”为主,不需要任意排版

“改结构”为什么值得投入

我给这条路的判断依据是一个数量级:行缓冲的最小规模是 4 行 × 128 列 = 512 位 = 64 字节。 如果能把“必须同时驻留”的量从整页(64 KB)压到一行(64 字节), 中间差的是三个数量级。缩规格换来的只是一个字号档位, 改结构换来的是一个数量级的余量——这就是我认为后者更值得投入的原因。

而且这个工程本来就有一颗 SPI 接口的外部 Flash,页内容(文字、时段、亮度等)都放在里面, 页大小 0x100。所以“外扩”未必是加器件,而是把已有的 Flash 用起来: 点阵只在内存里留一个窗口,其余按需预取。这里有一个我还没实测过的约束需要注意—— SPI Flash 的随机读延迟和取模速度必须跟得上扫描节拍。如果扫描要求每毫秒就要一行新数据, 而一次读取要几百微秒,那就得提前预取若干行,窗口大小由“扫描速率 × 预取深度”倒推。 这一段我只做到方案层面的判断,没有实测数据,所以只能说到这里。

“把显示外包”是我在另一块板子上的选择

另一块资源更紧张的板子(Cortex-M0+、64 KB Flash、8 KB SRAM 的人机界面主控板), 我干脆没有算这笔账:显示交给一块自带显存和点阵字库的串口屏, 主控只负责把氧浓度、温度、湿度、气压、海拔这些数值写进屏的变量地址, SRAM 里连一页点阵都不用放。代价是排版能力被屏侧固件限死, 而且“屏侧逻辑”和“主控逻辑”的边界必须写清楚——我在那块板子上就因为这条边界没划清, 踩过一个“设置改完、重新上电不生效”的坑。细节记在 《制氧机触控屏主控板》那篇实验记录里。

六、为什么“用产品规格规避”可行但不干净

先说它为什么可行:改一行限制、写一条产品说明,错乱立刻止住; 不动架构、不动硬件、不动通信协议,现场影响面最小。 在一个已经在产的产品上,这往往就是唯一能被接受的选择。我自己当时也是这么处理的。

但它不干净,原因有四条,每一条我都能在自己的代码里找到对应的痕迹:

  1. 约束住在人的记忆里,而不是代码里。 数组前面没有边界检查,越界那条路依然通着,只是“当前配置走不到”。 换句话说,代码从来没有学会拒绝,只是暂时没被逼到那一步。
  2. 约束之间互相耦合,而且说不清楚。 48×48 能放 128 字,64×64 只能放 96 字——这不是一条能写进产品说明的规则。 用户只会觉得“为什么换个更大的字,反而少放字”。规格表上写不出来的约束, 实际上等于没有约束。
  3. 约束和常量之间没有机器可校验的联系。 谁把缓存数组改小一点、谁加一个新字型,约束就悄悄失效, 而编译器和运行期都不会有任何提示。这条最危险,因为它不需要有人犯错, 只需要有人“正常地”改一处代码。
  4. 故障现象离真实原因太远。 “显示错乱”的第一反应是查时序,“字只显示一半”的第一反应是查行选。 真正的根因在内存里,排查成本极高——我在这上面花掉的时间, 比写整套显示驱动的时间还多。

所以我的结论是:产品规格可以当闸门,不能当设计。 闸门也得有,但至少要在代码里把它变成一条“可检查的式子”, 让越界的时候明确被拒绝,而不是默默把内存写坏。

七、我后来会怎么写这类代码

如果让我重写这一段,我会分三层来做:单位换算定义在一处、 编译期能拦住的就编译期拦、运行期拦不住的要明确降级或拒绝。

第一层 + 第二层:单位换算与编译期断言

下面的片段是我按个人理解重写的最小示例:先把“一个缓存单元多少字节”“一个字模多少字节” 定义成唯一的来源,再用一个典型的编译期断言把配置挡在编译阶段。

/* ---------- 1) 单位换算:一处定义,处处引用 ---------- */
#define CANVAS_UNIT_BYTES   8u      /* 1 个缓存单元 = 一行 64 点 = 8 字节 */
#define CACHE_UNITS         6192u
#define CACHE_BYTES         (CACHE_UNITS * CANVAS_UNIT_BYTES)   /* 49536 */

/* 一个字模按位打包占多少字节:宽 × 高 / 8 */
#define GLYPH_BYTES(w, h)   (((w) * (h)) / 8u)

/* ---------- 2) 编译期断言:配置越界就别想编过 ---------- */
#define PAGE_GLYPHS_MAX     128u

/* 48×48 / 128 字:288 × 128 = 36864 ≤ 49536,这一行编得过 */
typedef char cache_fits_48x48[
    (GLYPH_BYTES(48, 48) * PAGE_GLYPHS_MAX <= CACHE_BYTES) ? 1 : -1];

/* 64×64 / 128 字:512 × 128 = 65536 > 49536,取消注释就会编译报错
typedef char cache_fits_64x64[
    (GLYPH_BYTES(64, 64) * PAGE_GLYPHS_MAX <= CACHE_BYTES) ? 1 : -1];
 */

这段代码的价值不在于“技术含量”,而在于它把一条只写在注释里的规则, 变成了构建过程会替你检查的东西。当年那条注释提醒的是人,而人总会有不看注释的一天。

第三层:运行期降级要有明确的返回值和顺序

字号也可能是运行期才拿到的(比如由上位机下发)。这时不能“检查一下就继续”, 而要给出一个明确的降级顺序:先压每页字数,压到下限还不行就拒绝,并且把决定返回给上层。

/* ---------- 3) 运行期降级:字号到手之后再决定能放多少字 ---------- */
#define PAGE_GLYPHS_MIN     16u

typedef enum {
    FONT_OK = 0,        /* 原样可用 */
    FONT_DOWNGRADED,    /* 装不下,字数已被压缩 */
    FONT_REJECTED       /* 压到下限也装不下,拒绝这次配置 */
} font_result_t;

font_result_t page_cache_plan(unsigned short w, unsigned short h,
                              unsigned short want, unsigned short *allow)
{
    unsigned long need = (unsigned long)GLYPH_BYTES(w, h) * (unsigned long)want;

    if (need <= CACHE_BYTES) {              /* 装得下,照原样用 */
        *allow = want;
        return FONT_OK;
    }

    /* 装不下:先按缓存容量反算能放多少字(64×64 时是 49536 / 512 = 96) */
    *allow = (unsigned short)(CACHE_BYTES / GLYPH_BYTES(w, h));

    if (*allow < PAGE_GLYPHS_MIN) {
        *allow = 0;
        return FONT_REJECTED;               /* 明确拒绝,而不是悄悄越界 */
    }
    return FONT_DOWNGRADED;                 /* 降级了,就把这个决定告诉上位机 */
}

这套写法有个额外的好处:把“一页能放多少字”变成一个函数之后, 产品规格、通信协议文档、上位机的输入校验都可以引用同一个来源。 96 这个数字不再是注释里的一句提醒,而是 49536 ÷ 512 算出来的结果—— 和当年那条“64×64 最多 96 字”的结论完全一致。区别只是: 一个是我算出来的,一个是别人写给我看的。

还有一件我想改的事:把双缓冲的“份数”也做成显式的编译期常量, 任何新增的缓存都必须声明自己有几份。这样“再加一级缓冲”这种决定, 一定会在预算表上留下痕迹,而不是等到屏幕上出现错乱才被发现。

八、几条我反复用到的经验

  1. 先算字节,再写驱动。 任何缓存,在写第一行代码之前先算出它最大要多大; 算不出来就说明需求还没想清楚。
  2. 让常量有来源。 6192 这种数字必须能回答“它为什么是 6192”。 答不上来的常量,就是一颗定时炸弹。
  3. 每一级缓冲都是乘 2。 送显缓冲、预加载缓存、双页缓存, 每加一级都要重新过一遍预算,别用“应该还够吧”来判断。
  4. 编译期能拦住的,不要留到运行期;运行期拦不住的,要有明确的降级和拒绝。 越界写内存是最糟的失败方式:它不一定立刻崩,但一定会把别的地方弄坏。
  5. 产品规格是闸门,不是设计。 写进文档的限制, 最好同时在代码里有一条可检查的式子,否则约束只存在于人的记忆里。
  6. 现象会骗人。 “和字号相关”的显示故障,先怀疑内存,再怀疑时序。 顺序反过来,会浪费掉大量时间。
  7. 单位要当场确认。 栈深度按“字”算、气压按 hPa 算、 氧浓度按 0.1% 算——这些单位写错一次,后面的所有结论都会错, 而且错得很隐蔽。

参考资料与说明

  • 点阵字库芯片公开数据手册中关于字号档位、字型分类与取模地址计算的说明(本文涉及的 GT5SL24K4W、GT20L16S1Y 等型号仅用于说明技术方案)。
  • HC32F460 系列 MCU 公开用户手册中关于 SRAM 分区、Flash 分区与总线/GPIO 寄存器的说明。
  • FreeRTOS 官方文档中关于堆管理方案与任务栈深度(以“字”为单位)的说明。
  • 文中所有数字来自个人学习项目中读到的工程配置与源码注释。其中“一个缓存单元 = 8 字节” 是我按注释里“8192 单元 = 64 KB”反推得到的,不是直接从结构体定义读出来的; 如有出入,以原始定义为准。
  • 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 文中芯片与工具型号仅用于说明技术方案,与相关厂商无隶属或授权关系。