1. 项目概述
在嵌入式系统开发中,图形用户界面(GUI)的实现一直是个挑战。LVGL(Light and Versatile Graphics Library)作为一款开源的轻量级图形库,因其丰富的组件和跨平台特性而广受欢迎。本文将详细介绍如何在ZYNQ SoC平台上,使用Vitis 2023.2工具链,将LVGL 9.4移植到4线SPI接口的显示屏上。
与传统的RGB接口显示屏相比,4线SPI显示屏具有IO占用少的优势。通过本文介绍的自定义IP核方案,我们可以在480x320分辨率下实现稳定的30fps刷新率,这在许多对显示性能要求不高的场景中完全能够替代RGB接口。
2. 硬件准备与方案设计
2.1 硬件组件清单
实现这个项目需要以下硬件组件:
- ZYNQ开发板:至少需要12个可用IO,推荐使用XC7Z010或XC7Z020系列芯片的开发板
- 4线SPI显示屏:本方案使用3.5寸显示屏,驱动芯片为ST7796S,电容触摸驱动为FT6336
- 转接板:根据开发板和显示屏接口设计,确保信号电平匹配
实际开发中,我使用的是RIGUKE家的开发板,并自行设计了一块转接板。通过激光切割制作的透明亚克力外壳,整个装置非常紧凑美观。
2.2 SPI显示屏工作原理
4线SPI显示屏内部自带显存,可以自动维持上一帧的数据。其控制信号包括:
- SCK:时钟信号
- MOSI:主出从入数据线
- DC:数据/命令选择线
- CS:片选信号
关键时序特点:
- 通过DC线区分发送的是命令(低电平)还是数据(高电平)
- 支持8位和16位数据写入
- 不需要读操作即可实现正常显示
2.3 方案选型对比
2.3.1 使用ZYNQ自带SPI外设
优点:
- 实现简单,与单片机开发类似
- 无需PL部分开发
缺点:
- 每个SPI包间隔CS会变高一个周期,无法连续发送
- 无法使用DMA发送矩形块内存
- 刷新率受限,难以达到30fps
2.3.2 使用自定义IP核方案
优点:
- 可实现无间隔连续发送
- 支持DMA块传输
- 可达到480x320@30fps的刷新率
- 资源利用率优化
缺点:
- 需要开发PL部分逻辑
- 实现复杂度较高
基于性能考虑,本文选择第二种方案进行详细讲解。
3. PL部分设计与实现
3.1 自定义IP核架构设计
自定义IP核的顶层架构由三个核心组件构成:
-
AXI Lite接口控制的寄存器组:
- 配置DMA地址、数据尺寸、图像宽度等参数
- 提供控制字寄存器用于启动传输
-
AXI Full接口实现的快速内存读取模块:
- 专用于读取连续的行数据
- 支持突发传输优化
-
自动运行的DMA2D模块:
- 根据寄存器配置控制内存读取
- 实现多行数据的块状读取

3.2 工作流程详解
- ZYNQ PS端在DDR内存中分配LVGL显示缓冲区
- 将待刷新矩形区域的参数写入寄存器组:
- 起始地址
- 宽度
- 行数
- 行偏移值
- DMA2D模块启动多次行读取操作,数据写入FIFO
- SPI发送模块持续从FIFO读取数据
- 自动区分8/16位指令或数据并完成SPI传输
- 系统保持无间隔连续发送,直至FIFO数据全部传输完毕
- 传输完成后发送中断信号通知PS端
3.3 关键模块实现细节
3.3.1 AXI-REG模块
该模块包含7个32位寄存器,基地址为XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR,寄存器定义如下:
| 地址偏移 | 功能描述 |
|---|---|
| +0 | 控制字寄存器 |
| +4 | 混合缓冲区起始地址 |
| +8 | 混合缓冲区长度 |
| +12 | 颜色缓冲区地址 |
| +16 | 水平长度 |
| +20 | 跳跃值(行像素数量*每个颜色占用的byte数) |
| +24 | 总行数 |
控制字寄存器位定义:
- 31bit:写1开启传输,传输结束自动变为0
- 30bit:使能混合缓冲区
- 29bit:使能颜色缓冲区
- [28:0]:保留
3.3.2 BLOCK-TRANSFER模块
该模块负责高效地从DDR内存读取数据并写入FIFO,主要考虑以下约束条件:
- AXI总线单次读取的最大长度为256
- 单次读取操作不能跨越4K边界
- 当FIFO接近满状态时需暂停读取
解决方案:
-
数据分组处理:
- 不足32个数据:单次读取全部
- 超过32个数据:先读取非对齐部分,再按32个一组进行块传输
- 例如200个数据:1次8个 + 6次32个
-
两段式状态机设计:
- 第一段:数据分组和准备
- 第二段:实际AXI读取和FIFO写入
-
FIFO管理:
- 配置程控满信号阈值为最大容量-32
- 只有当FIFO剩余空间≥32时才发起写入
3.4 Vivado工程搭建步骤
- 创建工程并添加ZYNQ处理器IP
- 配置ZYNQ处理器:
- 启用两个AXI总线接口
- 配置PL到PS的中断
- 添加AXI互联与复位IP
- 导入并添加自定义IP
- 配置FIFO IP:
- 选择独立时钟域
- 设置适当深度(推荐1024)
- 启用程控满信号
- 配置时钟:
- 主时钟(通常100MHz)
- SPI时钟(根据显示屏要求,通常20-40MHz)
- 连接所有IP并验证设计
- 添加约束文件并生成bitstream
实际开发中,SPI信号的时序约束非常重要。建议添加如下约束:
tcl复制create_clock -period 12.500 -name ext_sck set_output_delay -clock [get_clocks ext_sck] -max 5.000 [get_ports -filter { NAME =~ "*mosi*" && DIRECTION == "OUT" }] set_output_delay -clock [get_clocks ext_sck] -min -5.000 [get_ports -filter { NAME =~ "*mosi*" && DIRECTION == "OUT" }]
4. PS部分软件设计
4.1 Vitis工程配置
- 创建工作区并创建platform工程
- 导入Vivado生成的.xsa文件
- 选择FreeRTOS作为操作系统
- 修改FreeRTOS tick rate为1000(默认为100,对于GUI应用太慢)
- 构建platform
4.2 链接脚本修改
关键修改点:
-
将DDR内存分区为两个区域:
- ps7_ddr_0:用于常规数据和堆栈
- ps7_ddr_user:专用于显示缓冲区
-
增加堆栈大小:
c复制_STACK_SIZE = DEFINED(_STACK_SIZE) ? _STACK_SIZE : 0x80000; _HEAP_SIZE = DEFINED(_HEAP_SIZE) ? _HEAP_SIZE : 0x80000; -
添加专用显示缓冲段:
c复制.lv_buf (NOLOAD) : { . = ALIGN(64); __lv_buf_start = .; KEEP(*(.lv_buf)) KEEP(*(.lv_buf.*)) __lv_buf_end = .; . = ALIGN(64); } > ps7_ddr_user
这样可以在代码中通过特定段属性声明缓冲区,确保其地址对齐和连续性:
c复制uint16_t lv_buffer1[480 * 320] __attribute__ ((section(".lv_buf"), aligned(64)));
uint16_t lv_buffer2[480 * 320] __attribute__ ((section(".lv_buf"), aligned(64)));
uint16_t cmd_buffer[128] __attribute__((section(".lv_buf"), aligned(64)));
4.3 CMake配置调整
-
启用C++支持:
cmake复制enable_language(C CXX ASM) set(CMAKE_CXX_STANDARD 17) -
添加LVGL构建选项:
cmake复制set(LV_CONF_PATH ${CMAKE_CURRENT_SOURCE_DIR}/lvgl/lv_conf.h) set(LV_CONF_BUILD_DISABLE_EXAMPLES ON) add_subdirectory(lvgl) -
启用NEON优化:
cmake复制set(USER_COMPILE_OTHER_FLAGS "-mfpu=neon")
4.4 LVGL配置关键参数
-
颜色深度:
c复制#define LV_COLOR_DEPTH 16 -
内存配置:
c复制#define LV_MEM_SIZE (128 * 1024U) #define LV_DRAW_LAYER_SIMPLE_BUF_SIZE (32 * 1024) -
刷新率:
c复制#define LV_DEF_REFR_PERIOD 33 // ~30fps -
NEON加速:
c复制#define LV_USE_DRAW_SW_ASM LV_DRAW_SW_ASM_NEON -
性能监控:
c复制#define LV_USE_SYSMON 1 #define LV_SYSMON_GET_IDLE lv_timer_get_idle
5. 关键代码实现
5.1 显示屏初始化
c复制static uint16_t lcd_init_seq[] = {
// Sleep out
0x0011,
// MADCTL, COLMOD
0x0036, 0x0148,
0x003A, 0x0155,
// ... 其他初始化命令
};
void lcd_init() {
// 将初始化序列写入cmd_buffer
for(int i=0; i<sizeof(lcd_init_seq)/sizeof(lcd_init_seq[0]); i++) {
cmd_buffer[i] = lcd_init_seq[i];
}
Xil_DCacheFlushRange((uintptr_t)cmd_buffer, sizeof(lcd_init_seq));
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+4, (uintptr_t)cmd_buffer);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+8, sizeof(lcd_init_seq)/2);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR, 0xC0000000); // 启动传输
}
5.2 矩形区域刷新函数
c复制void print_window(uint32_t x1, uint32_t y1, uint32_t x2, uint32_t y2, UINTPTR addr) {
// 参数检查与修正
if (x1 >= tft_w) x1 = tft_w - 1;
if (y1 >= tft_h) y1 = tft_h - 1;
if (x2 >= tft_w) x2 = tft_w - 1;
if (y2 >= tft_h) y2 = tft_h - 1;
if (x2 < x1) x2 = x1;
if (y2 < y1) y2 = y1;
uint32_t width = (x2 - x1) + 1;
uint32_t height = (y2 - y1) + 1;
uint32_t jump = tft_w * 2; // 按像素单位的跳跃值
// 构造窗口+写存储器命令序列
uint32_t k = 0;
cmd_buffer[k++] = 0x002A; // CASET
cmd_buffer[k++] = 0x0100 | ((x1 >> 8) & 0xFF);
cmd_buffer[k++] = 0x0100 | (x1 & 0xFF);
cmd_buffer[k++] = 0x0100 | ((x2 >> 8) & 0xFF);
cmd_buffer[k++] = 0x0100 | (x2 & 0xFF);
cmd_buffer[k++] = 0x002B; // PASET
cmd_buffer[k++] = 0x0100 | ((y1 >> 8) & 0xFF);
cmd_buffer[k++] = 0x0100 | (y1 & 0xFF);
cmd_buffer[k++] = 0x0100 | ((y2 >> 8) & 0xFF);
cmd_buffer[k++] = 0x0100 | (y2 & 0xFF);
cmd_buffer[k++] = 0x002C; // RAMWR
// 刷新Cache
Xil_DCacheFlushRange((uintptr_t)cmd_buffer, k * sizeof(uint16_t));
uint32_t row_bytes = width * sizeof(uint16_t);
uint32_t line_stride_bytes = tft_w * sizeof(uint16_t);
uint8_t* row_ptr = (uint8_t*)addr;
for (uint32_t r = 0; r < height; ++r) {
Xil_DCacheFlushRange((uintptr_t)(row_ptr + r * line_stride_bytes), row_bytes);
}
// 配置DMA参数
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+4, (uintptr_t)cmd_buffer);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+8, k);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+12, (uintptr_t)addr);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+16, width);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+20, jump);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR+24, height);
Xil_Out32(XPAR_ADVANCED_DMA2D_TOP_0_BASEADDR, 0xE0000000); // 启动传输
}
5.3 FreeRTOS中断处理
在Vitis 2023.2的统一构建平台中,FreeRTOS会接管中断控制器,传统的直接注册中断方式可能失效。以下是解决方案:
c复制void setup_fpga_irq(uint32_t intr_id, irq_trigger_t trig, uint8_t priority,
Xil_InterruptHandler handler, void *callback_ref) {
const XScuGic_Config *cfg = XScuGic_LookupConfig(XPAR_XSCUGIC_0_BASEADDR);
if (cfg == NULL) return;
const UINTPTR dist = cfg->DistBaseAddress;
const UINTPTR cpu = cfg->CpuBaseAddress;
// 配置边沿或电平触发
const UINTPTR icfgr_addr = dist + GIC_DIST_ICFGR_OFFSET(intr_id);
const u32 icfgr_shift = (intr_id & 0x0FU) << 1U;
u32 icfgr = Xil_In32(icfgr_addr);
icfgr &= ~(0x3U << icfgr_shift);
icfgr |= ((trig == IRQ_EDGE ? 0x2U : 0x0U) << icfgr_shift);
Xil_Out32(icfgr_addr, icfgr);
// 设置优先级和目标CPU
const UINTPTR prio_addr = dist + GIC_DIST_IPRIORITY_OFFSET(intr_id);
const UINTPTR target_addr = dist + GIC_DIST_ITARGETS_OFFSET(intr_id);
const u32 prio_shift = (intr_id & 0x03U) << 3U;
u32 prio_reg = Xil_In32(prio_addr);
prio_reg = (prio_reg & ~(0xFFU << prio_shift)) | (((u32)priority & 0xFFU) << prio_shift);
Xil_Out32(prio_addr, prio_reg);
u32 target = Xil_In32(target_addr);
target = (target & ~(0xFFU << prio_shift)) | (0x01U << prio_shift); // CPU0
Xil_Out32(target_addr, target);
// 注册处理函数并启用中断
XScuGic_RegisterHandler(cpu, intr_id, handler, callback_ref);
XScuGic_EnableIntr(dist, intr_id);
}
6. 性能优化与问题排查
6.1 性能优化技巧
-
NEON加速:
- 在CMake中启用
-mfpu=neon选项 - 在LVGL配置中启用
LV_USE_DRAW_SW_ASM_NEON - 实测可将moving wallpaper演示的帧率从29fps提升到45fps
- 在CMake中启用
-
双缓冲机制:
- 使用
lv_buffer1和lv_buffer2双缓冲 - 当一帧正在通过DMA传输时,LVGL可以渲染下一帧
- 使用
-
部分刷新:
- 只刷新屏幕上发生变化的区域
- 通过
print_window函数的区域参数控制
-
SPI时钟优化:
- 在满足显示屏时序要求的前提下尽可能提高SPI时钟
- 通过约束文件确保时序收敛
6.2 常见问题与解决方案
-
显示颜色异常:
- 检查RGB565的颜色位顺序
- 确认SPI传输的字节序
- 验证初始化序列中的颜色格式设置
-
刷新率不达标:
- 检查SPI实际时钟频率
- 确认是否使用了DMA和块传输
- 分析LVGL的渲染性能
-
DMA传输失败:
- 确认内存缓冲区是否64字节对齐
- 检查Cache一致性,确保调用了
Xil_DCacheFlushRange - 验证AXI总线配置是否正确
-
FreeRTOS卡死:
- 确保中断优先级设置正确
- 不要直接修改FreeRTOS接管的中断控制器
- 使用提供的中断注册函数
-
LVGL显示卡顿:
- 增加
LV_MEM_SIZE - 优化绘制回调函数
- 启用NEON加速
- 增加
7. 实测效果与总结
通过上述方案,我们在480x320分辨率的SPI显示屏上实现了稳定的30fps刷新率,完全满足一般嵌入式GUI应用的需求。以下是实测数据:
| 测试项目 | 性能指标 |
|---|---|
| 全屏刷新时间 | ~33ms |
| Moving Wallpaper Demo | 45fps |
| 内存占用 | 约1.5MB |
| CPU利用率(空闲) | <10% |
在实际开发中,有几点特别值得注意:
-
Cache一致性:DMA传输前必须调用
Xil_DCacheFlushRange,否则会出现显示异常。 -
内存对齐:显示缓冲区必须64字节对齐,这对DMA性能至关重要。
-
中断处理:在统一构建平台中,FreeRTOS对中断控制器的管理方式发生了变化,需要特别注意。
-
时序约束:SPI信号的时序约束必须正确设置,否则在高频率下可能出现数据错误。
这个方案不仅适用于ST7796S驱动的显示屏,通过调整初始化序列和通信协议,可以适配各种SPI接口的显示屏。自定义IP核的设计也提供了很大的灵活性,可以根据具体需求调整FIFO深度、突发长度等参数来优化性能。