ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

GD32F470跑LVGL内存不够?EXMC外挂SDRAM避坑指南

GD32F470跑LVGL内存不够?EXMC外挂SDRAM避坑指南 GD32F470 出厂就是冲着“大单片机”来的240MHz 的 Cortex-M4、最高 3MB Flash、512KB SRAM听着很能打。可真正把手放到 LVGL 上就会发现最卡脖子的不是 CPU 主频而是内存。我最早用 GD32F470 跑 LVGL 时也踩过这个坑界面稍微复杂一点控件一多字体一上链接器直接报内存不足。后来换到了 800x480 的屏内部 SRAM 连一块完整帧缓冲都放不下。折腾了一周之后我决定老老实实把 EXMC 外的 SDRAM 挂上问题才彻底解决。这篇文章就是我那次项目的实操记录。我会从内存需求算账讲起把 EXMC 和 SDRAM 初始化的每一步说清楚再讲怎么把 LVGL 的缓冲区和动态堆都搬到 SDRAM 里最后附上我调试过程中踩过的坑和排查方法。如果你也在用 GD32F470 跑 LVGL这篇文章应该能帮你少走不少弯路。1. 先算一笔内存账LVGL 到底会吃掉多少内存1.1 LVGL 的三块主要内存开销接触 LVGL 的新手很容易犯一个错误就是盯着代码文件大小看内存占用。实际上 LVGL 运行时的内存占用远不止你写的那些控件代码它主要由三部分组成。第一部分是帧缓冲。LVGL 在刷新屏幕时需要一块内存作为“画布”。如果用全屏帧缓冲内存大小就是 屏幕宽度乘以屏幕高度乘以每像素字节数。以 800x480、RGB565 颜色格式为例一帧就是 800 * 480 * 2 750KB 左右。如果你还想用双缓冲来避免撕裂那就要翻倍到 1.5MB。这个数字已经超过 GD32F470 内部几乎所有可以连续使用的 SRAM 区域了。第二部分是 LVGL 运行时堆。LVGL 在创建控件、样式、图片描述符、动画路径时都需要动态分配内存。一个简单的按钮可能只占几百字节但一个复杂的仪表盘界面控件树、样式表、图片缓存加起来很容易吃掉几百 KB。如果用了 PNG/JPEG 解码解码中间缓冲区又是一笔额外开销。第三部分是比较隐蔽的就是字体和图片的存储缓冲。LVGL 的字体虽然已经做了很多压缩但在渲染时仍然要解出位图数据放到内存里中文字库尤其明显。全量中文字体光渲染缓存就可能要到几十 KB 到上百 KB。把这三部分加起来一个稍微正经一点的 800x480 界面1MB 内存是最低需求如果做双缓冲或者动画2MB 都不一定够。GD32F470 内部 SRAM 虽然有 512KB但要留给系统栈、RTOS 任务栈、关键变量和通信缓冲区能拿出来给 LVGL 的非常有限。这时候外部 SDRAM 几乎是必选项。1.2 什么情况下必须上 SDRAM并不是所有项目都需要外部 SDRAM。如果屏幕尺寸小于 480x272并且采用 LVGL 的单缓冲局部刷新模式两块缓冲区各只占屏幕高度的 10% 到 20%内部 SRAM 勉强能扛住。加上 LVGL 自身的动态堆总共占用控制在 200KB 以内GD32F470 的 SRAM 还是可以应付的。但是一旦满足以下任何一个条件内部 SRAM 就基本没戏了屏幕分辨率达到 800x480 或更高且需要整屏帧缓冲需要双缓冲来保证动画播放不撕裂、不闪烁界面控件多动态创建和销毁频繁堆内存需求超过 200KB需要加载大量图片或字体且不想在渲染时频繁从 Flash 读盘我自己判断的标准很简单如果 LVGL 的缓冲配置加上堆配置总需求超过 300KB就不要犹豫直接上 SDRAM。因为就算你强行把缓冲限制得很小运行时的性能也会非常难受动画掉帧、控件闪烁、滑动不流畅这些问题会浪费你更多调试时间。1.3 为什么选择 EXMC 而不是 SPI 接口的串行 SRAM有人可能会问既然只是内存不够能不能用 SPI 接口的串行 SRAM 或者 PSRAM技术上确实可行比如常见的 PSRAM 芯片通过 QSPI 接口也能扩展内存。但用在实际项目里我强烈不推荐这种方案。原因在于带宽。PSRAM 基于串行接口即使开启 Quad 模式读取一个 32 位数据也要多个时钟周期而 EXMC 的并行 SDRAM 接口是 16 位并行总线一次读写就是 16 位数据配合突发模式效率更高。LVGL 的渲染过程是典型的内存密集型操作每画一个像素都要读写帧缓冲对内存带宽的要求非常高。实测下来同样的 LVGL 界面在 800x480 分辨率下PSRAM 方案的刷新率只有 SDRAM 方案的 60% 左右动画效果差距非常明显。另一个考虑是成本。一颗 32MB 的 SDRAM 芯片比如 W9825G6KH市场价格远低于同容量的串行 PSRAM。而且 GD32F470 芯片本身已经内置了支持 SDRAM 的 EXMC 控制器不用白不用。综合性能和成本EXMC 挂 SDRAM 是 GD32F470 跑 LVGL 最合理的方案。2. 关于 EXMC 和 SDRAM你需要知道的核心硬件概念2.1 EXMC 在 GD32F470 里的角色EXMC全称 External Memory Controller外部存储器控制器。你可以把它理解成一个多功能的内存总线桥用来连接 MCU 内核和外部存储器。GD32F470 的 EXMC 支持 NOR Flash、SRAM、PSRAM、NAND Flash以及 SDRAM。它内部划分了多个 Bank每个 Bank 对应一段可寻址的地址空间。在 GD32F470 上SDRAM 通常挂载在 EXMC 的 SDRAM Bank 区域。当你向这个地址区域写入数据时EXMC 控制器会自动把内核的写操作转换为 SDRAM 所需的命令时序包括行激活、列读写、预充电、刷新等。也就是说你不用手动一根根去拉 SDRAM 的引脚只需要配置好 EXMC 的寄存器之后就可以把 SDRAM 当成一段普通的内存地址来做读写。从软件角度看SDRAM 一旦初始化成功就是一段连续可寻址的内存。你可以定义一个指针指向 SDRAM 的起始地址然后像操作数组一样操作它。关键就在于EXMC 初始化时要把时序参数和 SDRAM 的型号参数完全匹配否则 SDRAM 根本无法正常工作。2.2 SDRAM 内部结构行、列、Bank很多人对 SDRAM 的理解停留在“就是一块大内存”这个说法对了一半。SDRAM 内部并不是一个扁平的地址空间而是一个三维结构Bank、行、列。地址访问的顺序也不同于普通 SRAM先发送 Bank 地址和行地址激活这一行然后再发送列地址读取或写入对应的数据。这就是 SDRAM 比 SRAM“麻烦”的根本原因。CPU 读写 SRAM 时给出地址直接读写SDRAM 则需要控制器执行一系列命令包括行激活Active、列读写Read/Write、预充电Precharge等。好在这些工作全部由 EXMC 硬件完成软件层面你只需要配置好时序参数让控制器知道这个 SDRAM 芯片需要多长的等待时间。从选型角度看常见的小容量 SDRAM 芯片为 4M x 16bit x 4bank也就是 32MB 容量。以我使用的 W9825G6KH 为例它内部有 4 个 Bank每个 Bank 有 4096 行每行 256 列数据宽度 16 位。这些参数需要在 EXMC 配置中明确写清楚否则地址译码就会错乱。2.3 时序参数到底怎么算以 W9825G6KH 为例SDRAM 芯片不像普通逻辑芯片你得严格满足它的时序要求。拿 W9825G6KH 数据手册举例你需要关注的几个关键时序参数包括tRCDRAS 到 CAS 延迟行地址激活后到可以发送列地址命令之间的时间tRP预充电时间发送预充电命令后到下一次行激活命令之间的时间tRC行周期时间两次行激活命令之间的最小间隔tRAS行激活持续时间行必须保持激活状态的最短时间tWR写入恢复时间写命令结束后到预充电命令之间的时间tRFC自动刷新周期时间两次自动刷新命令之间的间隔这些参数在 SDRAM 芯片数据手册中都有明确值你需要把它们换算成 EXMC 时钟的周期数。举个例子假设 EXMC 的 SDRAM 时钟频率为 32MHz时钟周期就是 31.25ns。如果 W9825G6KH 的 tRCD 典型值是 20ns那么换算成时钟周期数就是 20 / 31.25 0.64向上取整为 1 个时钟周期。这里特别提醒不同品牌 SDRAM 芯片的 tRCD、tRP 等参数可能略有差异即使是同一型号也有速度等级之分。常见的有 -6166MHz 速度等级、-7143MHz 速度等级不同后缀对应的 CL 和 tRAS 值都不同。一定要以你手里芯片数据手册为准不要照抄网上的配置。2.4 SDRAM 的复位与初始化流程虽然我们说的是“初始化 SDRAM”但在 EXMC 层面这个初始化过程实际上包含两个阶段第一阶段是 EXMC 外设本身的配置第二阶段是向 SDRAM 芯片发送一系列命令让它进入正常工作状态。SDRAM 芯片上电后不会立刻可用需要经历一个完整的初始化序列等待电源稳定和时钟稳定通常需要至少 100us 到 200us发送预充电全部命令PALL连续发送若干个自动刷新命令通常至少 2 次有些芯片手册要求 8 次发送加载模式寄存器命令配置突发长度、CAS 延迟等参数切换到正常工作模式这个序列是 JEDEC 标准规定的基本是所有 SDRAM 芯片的通用流程。问题在于很多初学者把这段初始化序列漏掉了直接配置完 EXMC 就去读写地址结果读回的数据完全不对。我在后续章节会一步步说明这段代码怎么写。3. 初始化 W9825G6KH 的完整代码实操3.1 配置 EXMC 引脚复用GD32F470 的 EXMC 接口占用大量的 GPIO 引脚包括数据线、地址线、控制线。连接 SDRAM 时一般需要以下引脚数据线EXMC_D0 到 EXMC_D15共 16 根地址线EXMC_A0 到 EXMC_A12共 13 根行选通EXMC_SDRAS列选通EXMC_SDCAS写使能EXMC_SDWE片选EXMC_SDNE0 或 SDNE1时钟EXMC_SDCLK时钟使能EXMC_SDCKEBank 地址EXMC_BA0、EXMC_BA1这些引脚不是随意选的必须按芯片的 GPIO 复用表来接。我在项目里使用的是 GD32F470I-EVAL 开发板SDRAM 芯片 W9825G6KH 直接挂在 EXMC 的 Bank0 上。初始化引脚复用的时候核心代码大致如下static void exmc_gpio_config(void) { // 打开相关 GPIO 和 EXMC 时钟 rcu_periph_clock_enable(RCU_GPIOC); rcu_periph_clock_enable(RCU_GPIOD); rcu_periph_clock_enable(RCU_GPIOE); rcu_periph_clock_enable(RCU_GPIOF); rcu_periph_clock_enable(RCU_GPIOG); rcu_periph_clock_enable(RCU_GPIOH); rcu_periph_clock_enable(RCU_EXMC); // EXMC 数据线 D0-D15对应 GPIO 引脚复用为 AF12 gpio_af_set(GPIOD, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOE, GPIO_AF_12, GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOH, GPIO_AF_12, GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOI, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3); // 地址线 A0-A12 和控制线同样复用为 AF12 gpio_af_set(GPIOF, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15); gpio_af_set(GPIOG, GPIO_AF_12, GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_8 | GPIO_PIN_15); // 配置为高速推挽模式注意 EXMC 总线对 GPIO 驱动能力有要求 gpio_mode_set(GPIOD, GPIO_MODE_AF, GPIO_PUPD_NONE, GPIO_PIN_0 | GPIO_PIN_1); gpio_output_options_set(GPIOD, GPIO_OTYPE_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0 | GPIO_PIN_1); // ... 其他引脚同理 }重点说一下引脚复用的坑EXMC 的引脚复用号是 AF12这是 GD32F470 芯片固定的不要想当然地用 AF0 或 AF1。此外GPIO 的输出速度建议配置为最高的 50MHz 或等效高速档否则 SDRAM 在高频访问时信号质量会变差。很多静态初始化看起来没问题但跑一段时间就随机出错往往就是 GPIO 驱动强度不够。3.2 配置 EXMC SDRAM 控制寄存器引脚配置完成后就开始配置 EXMC 本身。GD32 标准固件库通常提供了 EXMC SDRAM 相关的结构体和功能函数。我把配置拆成两步控制参数和时序参数。控制参数要告诉 EXMC 你的 SDRAM 是什么结构。W9825G6KH 是 4 个 Bank、12 位行地址、8 位列地址、16 位数据总线。CAS 延迟设为 2 还是 3取决于 SDRAM 芯片速度等级和 EXMC 时钟频率的匹配。我这边 EXMC 时钟约 32MHzW9825G6KH 的速度等级完全支持 CAS Latency 2。static void exmc_sdram_config(void) { exmc_sdram_parameter_struct sdram_init {0}; // SDRAM 挂在 Bank0 sdram_init.sdram_bank EXMC_SDRAM_BANK0; // 列地址位宽 8 位 sdram_init.column_bits EXMC_SDRAM_COLUMN_BITS_8; // 行地址位宽 12 位 sdram_init.row_bits EXMC_SDRAM_ROW_BITS_12; // 内部 Bank 数量为 4 sdram_init.bank_bits EXMC_SDRAM_BANK_BITS_2; // 数据总线宽度 16 位 sdram_init.data_width EXMC_SDRAM_DATA_WIDTH_16; // CAS 延迟为 2 个时钟周期 sdram_init.cas_latency EXMC_SDRAM_CAS_LATENCY_2; // 读突发使能 sdram_init.read_burst EXMC_SDRAM_READ_BURST_ENABLE; // 写突发关闭SDRAM 芯片通常不支持写突发 sdram_init.write_burst EXMC_SDRAM_WRITE_BURST_DISABLE; }注意一个容易忽略的点写突发建议关闭。很多 SDRAM 芯片的写突发是可选功能但如果模式寄存器里没有正确配置打开写突发会导致写入的数据随机错乱。我调试时在这里卡了两天最后逐一排查才发现是配置里把写突发打开了芯片根本不支持数据写进去再读出来全是错的。时序参数这块我直接对照 W9825G6KH 的数据手册来填static void exmc_sdram_timing_config(void) { exmc_sdram_timing_parameter_struct sdram_timing {0}; // 以下时间单位均为 SDRAM 时钟周期 sdram_timing.load_to_active_delay 2; // tRCD 2 sdram_timing.exit_selfrefresh_delay 7; // tXSR一般为 72ns 以上按 32MHz 算取 7 sdram_timing.selfrefresh_time 4; // tRAS 4 sdram_timing.row_cycle_delay 7; // tRC 7 sdram_timing.write_recovery_time 2; // tWR 2 sdram_timing.row_precharge_delay 2; // tRP 2 sdram_timing.row_active_delay 4; // tRCD 4和 load_to_active_delay 对应 }这里特别要提一下时序参数的取值和 EXMC 时钟频率是强相关的。如果你把 EXMC 的 SDRAM 时钟调到 54MHz 甚至更高前面的周期数都必须重新计算。不要拿别人工程的参数直接套用哪怕芯片型号一样只要 EXMC 时钟不同结果就是错的。我一般会把参数列表和 EXMC 时钟频率二选一要么固定时钟频率要么固定时序参数把它俩写成一个配置头文件方便后续调整。3.3 发送 SDRAM 初始化命令序列完成 EXMC 寄存器配置后就要按照 JEDEC 标准流程对 SDRAM 芯片发送初始化命令。这段代码是整个初始化过程中最关键的部分漏一步都会导致 SDRAM 无法工作。static void exmc_sdram_init_sequence(void) { exmc_sdram_command_parameter_struct sdram_cmd {0}; // 等待电源稳定至少延时 100us保险起见我加了 1ms delay_ms(1); // 1. 预充电全部让所有 Bank 的行都关闭 sdram_cmd.command EXMC_SDRAM_CMD_PRECHARGE_ALL; sdram_cmd.bank_select EXMC_SDRAM_BANK0; sdram_cmd.auto_refresh_number 0; sdram_cmd.mode_register_content 0; exmc_sdram_command_config(sdram_cmd); // 2. 连续发送 8 次自动刷新命令 sdram_cmd.command EXMC_SDRAM_CMD_AUTOREFRESH; sdram_cmd.auto_refresh_number 8; exmc_sdram_command_config(sdram_cmd); // 3. 加载模式寄存器 // 模式寄存器内容突发长度 1、CAS2、顺序突发、正常操作 // 具体 bit 的换算以芯片数据手册为准 sdram_cmd.command EXMC_SDRAM_CMD_LOAD_MODE_REGISTER; sdram_cmd.mode_register_content 0x2200 | (2 4) | 0; exmc_sdram_command_config(sdram_cmd); // 4. 切换到正常工作模式 sdram_cmd.command EXMC_SDRAM_CMD_NORMAL_MODE; sdram_cmd.auto_refresh_number 0; exmc_sdram_command_config(sdram_cmd); }模式寄存器的值是需要重点解释的。W9825G6KH 的模式寄存器中低 3 位是突发长度000 表示 1第 4 到第 6 位是 CAS 延迟010 表示 2011 表示 3第 7 位是测试模式必须为 0第 8、9 位是突发类型00 表示顺序突发。这个值不是随便写的必须和 EXMC 侧的 CAS 配置保持一致。我在代码注释里写的是(2 4)最终效果就是 CAS2、突发长度1。这里有个细节值得注意模式寄存器里的突发长度建议设为 1。虽然 EXMC 侧的读突发使能了但 SDRAM 单次读操作仍然只读取一个数据。读突发使能这个配置影响的是 EXMC 控制器层面是否允许连续读取和 SDRAM 芯片内部的模式寄存器是两回事。很多人在这一步搞混导致结果不是性能下降就是数据混乱。3.4 配置自动刷新周期SDRAM 依靠电容存储数据电容会漏电所以必须定期自动刷新。这是 SDRAM 和 SRAM 最本质的区别。所以只初始化还不够还必须配置 EXMC 的自动刷新周期否则数据过一会儿就丢了。刷新周期的计算方式假设 SDRAM 要求 64ms 内完成 4096 次刷新那么两次刷新命令之间的最大间隔就是 64ms / 4096 15.625us。把 15.625us 换算成 EXMC SDRAM 时钟周期数如果 SDRAM 时钟是 32MHz周期 31.25ns则刷新计数器 15.625us / 31.25ns 500。这个值就是写入刷新周期寄存器的数值。// 刷新周期寄存器 SDRAM 时钟周期数不是真实时间值 // 500 对应 32MHz 时钟下的约 15.625us exmc_sdram_refresh_count_config(500);注意这个 500 不是一个固定的推荐值它跟你设置的 EXMC SDRAM 时钟频率直接相关。如果你把 SDRAM 时钟调到 16MHz那刷新计数值就要变成 250。有些人代码能跑但屏幕偶尔闪一下、数据偶发错误排查半天最后发现是刷新周期配得太紧或者太松。3.5 写一段读写验证代码初始化完成后必须先用一段简单的读写测试验证 SDRAM 是否真的可用再往下接 LVGL。不要直接跳过测试去跑复杂系统否则出了问题根本分不清是 SDRAM 初始化的问题还是 LVGL 配置的问题。static int board_sdram_test(void) { uint32_t* ptr (uint32_t*)SDRAM_BASE_ADDR; uint32_t i; // 按 32 位写入再读回地址覆盖 1MB 范围 for (i 0; i 256 * 1024; i) { ptr[i] i ^ 0xA5A5A5A5; } for (i 0; i 256 * 1024; i) { if (ptr[i] ! (i ^ 0xA5A5A5A5)) { return -1; } } // 再用 16 位方式验证每个字节位防止高 8 位数据线虚焊 uint16_t* p16 (uint16_t*)SDRAM_BASE_ADDR; for (i 0; i 512 * 1024; i) { p16[i] (uint16_t)(i * 3); } for (i 0; i 512 * 1024; i) { if (p16[i] ! (uint16_t)(i * 3)) { return -2; } } return 0; }用 32 位和 16 位两种方式分别读写是为了排查数据线的焊接和连接问题。如果 32 位验证通过但 16 位验证失败多半是高 8 位数据线存在虚焊或 PCB 走线问题。如果只有某个地址段出错那可能是地址线错位或 Bank 地址配置不对。这一步只花几分钟但能帮你避开后面几天的大坑。4. 把 SDRAM 接入 LVGL 的具体方案4.1 内存布局内部 SRAM 和外部 SDRAM 怎么分工SDRAM 初始化成功后我建议从软件架构上规划一下内存布局。不能把所有东西都简单粗暴地塞到 SDRAM 里毕竟 SDRAM 的访问速度明显慢于内部 SRAM而且代码执行最好放在 Flash 里系统栈放在内部 SRAM 里CPU 频繁访问的关键数据也留在 SRAM。我的分配方案是内部 SRAM系统栈、RTOS 内核对象、高频访问的全局变量、中断服务函数使用的缓冲区外部 SDRAMLVGL 的帧缓冲、LVGL 的动态堆、大块图片解码缓冲、日志缓冲这样分工的原因是SDRAM 虽然容量大但每次访问都要经过 EXMC 总线延迟比内部 SRAM 高。LVGL 的帧缓冲和堆对延迟没有那么敏感即使慢一点也是可接受的。但 RTOS 的任务切换、中断处理这些操作对延迟极其敏感一旦放到了 SDRAM 上系统响应会明显变差严重时甚至会因为访问冲突导致任务卡死。4.2 在链接脚本里预留 SDRAM 地址区间要把 SDRAM 用作内存池首先得确定 SDRAM 在 CPU 地址空间中的起始地址。在 GD32F470 的数据手册里EXMC 的 SDRAM Bank0 起始地址通常是某个特定的地址区间比如 0x60000000 开始。在代码里可以直接用宏定义#define SDRAM_BASE_ADDR (0x60000000UL) #define SDRAM_SIZE (32UL * 1024 * 1024)如果你不需要让编译器自动在 SDRAM 里分配变量就不需要修改链接脚本直接通过指针和地址宏来管理 SDRAM。但如果你希望声明一个数组就能让编译器把它分配到 SDRAM就需要修改分散加载文件增加一个执行区域。这个方法的优点是你可以直接在 C 代码里定义一个大数组然后用这个数组的地址作为 LVGL 的内存池。比如// 在分散加载文件中把 .sdram_buf 段分配到 EXMC SDRAM Bank0 __attribute__((section(.sdram_buf))) static uint8_t sdram_heap[4 * 1024 * 1024];使用分散加载文件要注意启动顺序。SDRAM 必须在 main 函数的一开始就完成初始化一旦在 SDRAM 区域定义了大数组main 函数里访问这个数组之前SDRAM 必须已经就绪。否则程序在链接器构建的数据段拷贝阶段就会访问 SDRAM而此时 SDRAM 还没初始化系统直接死机。这个坑非常隐蔽很多人的现象是程序卡在启动文件里根本进不了 main。4.3 LVGL 内存池配置LVGL 本身支持自定义内存分配。通过 lv_conf.h 或者 lv_init 之前调用接口可以指定 LVGL 使用哪个内存区域。我建议直接把 LVGL 的动态堆指向 SDRAM。在 lv_conf.h 里LV_MEM_CUSTOM 要打开然后把自定义的 malloc/free 指向我们自己的分配函数或者使用 LVGL 自带的内存池但通过lv_mem_init指定内存池地址和大小。// lv_conf.h 中的简化配置 #define LV_MEM_CUSTOM 1 #define LV_MEM_CUSTOM_INCLUDE lvgl_mem_port.h #define LV_MEM_CUSTOM_ALLOC lvgl_port_malloc #define LV_MEM_CUSTOM_FREE lvgl_port_free #define LV_MEM_CUSTOM_REALLOC lvgl_port_realloc对应的实现可以很简单// lvgl_mem_port.c void* lvgl_port_malloc(size_t size) { // sdram_heap 是定义在 SDRAM 区域的静态数组 return sdram_alloc(size); // 自己实现的简单分配器或者使用 malloc }如果你使用 RTOS并且 RTOS 的堆也是在 SDRAM 里那么 LVGL 直接调用系统 malloc 也能访问 SDRAM。但要注意多个任务同时调用 malloc/free 需要加锁否则内存分配器内部状态会被破坏。我在项目里是给 LVGL 单独分配了一整块 SDRAM 内存池用 LVGL 自带的 lv_mem 管理避免和其他模块的内存分配互相干扰。4.4 LVGL 帧缓冲区域的分配帧缓冲是 LVGL 最吃内存的地方。如果使用单缓冲全屏模式可以这样分配#define LCD_H_RES 800 #define LCD_V_RES 480 #define LCD_COLOR_DEPTH 16 static uint8_t lv_buf_full[LCD_H_RES * LCD_V_RES * 2] __attribute__((section(.sdram_buf))); void lvgl_port_init(void) { static lv_disp_draw_buf_t draw_buf; static lv_disp_drv_t disp_drv; lv_init(); lv_disp_draw_buf_init(draw_buf, lv_buf_full, NULL, LCD_H_RES * LCD_V_RES); lv_disp_drv_init(disp_drv); disp_drv.draw_buf draw_buf; disp_drv.flush_cb board_lcd_flush; disp_drv.hor_res LCD_H_RES; disp_drv.ver_res LCD_V_RES; lv_disp_drv_register(disp_drv); }这个做法需要 750KB 的连续内存GD32F470 内部 SRAM 做不到SDRAM 就没有问题。如果你要做双缓冲就再增加一个同样大小的数组static uint8_t lv_buf_1[LCD_H_RES * LCD_V_RES * 2] __attribute__((section(.sdram_buf))); static uint8_t lv_buf_2[LCD_H_RES * LCD_V_RES * 2] __attribute__((section(.sdram_buf)));这里再提醒一句LVGL 的 flush_cb 不允许把数据从帧缓冲直接拷贝到屏幕控制器时不经过验证。有些开发板 TFT 控制器带有自己的显存比如 ILI9341、ST7789 这类 SPI 屏它们内部有几KB到几百KB的GRAM。这种情况下LVGL 的帧缓冲和 TFT 控制器内部的显存是两份数据flush_cb 里必须完成真正的数据搬运。如果 SDRAM 里的帧缓冲内容没有正确同步到 LCD 控制器的显存屏幕上的画面就会是花的或者不更新。4.5 FreeRTOS 环境下的 LVGL 任务设计如果项目里有 FreeRTOSLVGL 通常跑在一个独立任务里。这个任务不能分配太小的栈因为 LVGL 的渲染函数调用链比较深局部变量也多。我一般给 LVGL 任务分配 4KB 的栈放在内部 SRAM 里。void lvgl_task(void* arg) { lvgl_port_init(); // 初始化 LVGL 和帧缓冲 while (1) { uint32_t tick xTaskGetTickCount(); lv_timer_handler(); // LVGL 的定时器和渲染处理 vTaskDelayUntil(tick, pdMS_TO_TICKS(5)); } }FreeRTOS 环境下需要注意 LVGL 的线程安全问题。LVGL 默认并不是线程安全的如果你在多个任务里调用 lv_ 开头的 API就必须加锁保护。最简单的方式是写两个钩子函数在 lv_timer_handler 进入和退出时加互斥锁。void lvgl_port_lock(void) { // 获取 LVGL 专用的互斥量 xSemaphoreTake(lvgl_mutex, portMAX_DELAY); } void lvgl_port_unlock(void) { xSemaphoreGive(lvgl_mutex); }还有一点FreeRTOS 的 SysTick 或 tick 中断如果和 LVGL 的 tick 用同一个时基需要注意最高优先级中断不能访问 SDRAM否则会阻塞 EXMC 总线导致其他任务访问 SDRAM 时被卡死。我在项目里把 SDRAM 的访问限制在普通任务上下文尽量避免在中断服务函数里直接读写 SDRAM。5. 实战中的性能表现与高频问题排查5.1 实测性能LVGL 在 SDRAM 上的表现我搭建的测试环境是 GD32F470 主频 240MHzEXMC SDRAM 时钟约 32MHzW9825G6KH 32MB SDRAM屏幕为 800x480 RGB 接口 LCDLVGL 8.3 版本开启 16bit 色深。在单缓冲全屏模式下简单界面的刷新率能达到 30fps 到 40fps。如果开启局部刷新只刷新变化区域性能会更好大多数菜单操作基本感觉不到延迟。双缓冲模式的动画非常流畅但内存占用会翻倍需要评估 32MB SDRAM 是否足够以及 EXMC 总线带宽是否扛得住。实测下来SDRAM 的访问延迟确实比内部 SRAM 高但对 LVGL 这种应用影响并不大。LVGL 的渲染瓶颈通常在于像素填充量而不是内存访问延迟。只要你不把频繁循环调用的关键算法放在 SDRAM 里的数组上体验不会有问题。5.2 高频坑花屏、闪屏、数据错乱调试 SDRAM LVGL 的过程我总结了几个高频问题基本覆盖了大多数人的踩坑点。第一花屏且固定区域错乱。这种问题绝大多数是地址线连接错误或 EXMC 配置中的行/列地址位数不匹配。例如把 12 位行地址的芯片配置成了 11 位地址译码错位写进去的数据会随机落在错误位置。可以用一个小循环反复在连续地址写入 0x55AA再用逻辑分析仪观察地址线变化通常能定位。第二画面闪烁或随机出现错乱点。先看自动刷新周期配置再看 EXMC 时序是否余量不足。SDRAM 对刷新非常敏感刷新周期配得太大电容电荷流失数据就会丢失。地址线和数据线信号质量差也会导致随机数据错误这时候可以用示波器检查 SDRAM 时钟和数据的边沿对齐情况。如果信号质量问题明显可以给 SDCLK 加上适当的串阻或者降低 SDRAM 时钟频率。第三屏幕亮但画面内容不更新。检查 LVGL 的 flush_cb 是否真的把帧缓冲数据发给了 LCD 控制器以及 DMA 传输完成的回调是否触发。如果使用的是 DMA 搬运要特别注意 DMA 访问的地址必须物理连续SDRAM 的 Bank0 地址区间是连续的理论上没问题但 DMA 配置时容易把地址写错。5.3 调试代码的正交策略我建议在做 SDRAM 初始化时尽量保持“先单测、再系统”的思路。不要在第一次跑 LVGL 的时候才去调试 SDRAM这样会同时面对两个未知变量很难定位问题。我的复现步骤是先跑一段裸机 SDRAM 读写验证确认读写正确用简单的 LED 翻转程序验证 SDRAM 在中断和主循环中都稳定再嵌入 LVGL但先用一个简单的静态界面比如纯色背景加一个按钮最后才加快全界面和动画每一步都在上一个基础上叠加。如果出了问题回退到上一步可以非常快速地判断是不是这一层引入的问题。不要想着一口气把所有东西都写好再调试那会把自己逼疯。5.4 常见问题速查表我整理了这段时间遇到频率最高的问题和对应的排查方向做成表格方便查阅。现象可能原因排查方向写入 SDRAM 后读回全是 0xFFEXMC 初始化配置错误、SDRAM 未进入正常模式、片选错误检查初始化命令序列是否完整特别是 PALL 和自动刷新固定地址范围数据错乱行/列地址位数配置错误、地址线接错对照数据手册确认 row/column 位宽检查原理图接线屏幕随机花点/闪屏自动刷新周期不对、CL 配置和模式寄存器不一致重新计算刷新计数值对齐 CAS 延迟LVGL 控件颜色异常色深配置不一致可能 LCD 是 RGB565 而 LVGL 配成 RGB888统一 LV_COLOR_DEPTH 和 LCD 驱动程序卡在启动文件进不了 mainSDRAM 区段的数组在 SDRAM 初始化前被访问确认分散加载文件中的 SDRAM 段是否被系统启动代码拷贝多个任务同时调用 LVGL API 导致死机LVGL 线程不安全并发访问破坏内部状态给 LVGL 访问加互斥锁6. 我实际用下来的几点心得做完这个项目之后我的体会是GD32F470 这颗芯片本身能力很强但想把 LVGL 跑得舒服内存规划一定要提前做而不是等代码写完了才想办法。EXMC 挂 SDRAM 这套方案是当前在 GD32F470 上跑大界面最成熟、性价比最高的路径。如果你第一次上手建议先买一块焊好 SDRAM 的核心板比如 GD32F470I-EVAL 或者市面上常见的 SDRAM 底板开发板避免自己手焊 SDRAM 出问题。W9825G6KH 这类 TSSOP 封装的 SDRAM 引脚间距比较小手工焊接容易虚焊而 SDRAM 对时序极其敏感虚焊导致的数据错误会浪费大量时间排查。另外EXMC 的引脚占用非常夸张一旦接上 16 位 SDRAM几乎会把 GPIO 的可用引脚吃掉一大半。在画板子的阶段就要规划好哪些外设还需要引脚不要把 SDRAM 引脚和其他外设冲突。最后再分享一个小技巧如果你的 LVGL 界面更新频率要求不是特别高但内存依然紧张可以不要用全屏帧缓冲而是用 LVGL 标准的局部刷新模式只分配两小块缓冲。这样做 SDRAM 的占用会从 1.5MB 降到几百 KB甚至让你的 32MB SDRAM 有其他富余空间去做图片缓存。SDRAM 是拿来用的不是拿来炫的把每一块内存用得其所才是把这个方案做好的关键。
返回列表