摘要:把 Zephyr RTOS 和 LVGL 一起移植到兆易创新 GD32F503V-EVAL 评估板,驱动 ILI9320 240×320 电阻屏,最终跑起 lv_demo_widgets() 演示,并支持 XPT2046 触摸。本文把从点亮 LED 到点亮 GUI 的完整路径、关键坑点、代码组织方式整理出来,供同样想在国产 Cortex-M33 上跑 Zephyr + LVGL 的朋友参考。
关键字:GD32F503、Zephyr、LVGL、ILI9320、EXMC、MIPI-DBI、8080-16bit、XPT2046
一、硬件与软件环境
1.1 硬件平台
项目说明
| MCU | GD32F503VGT6,Cortex-M33,最高 252 MHz,512 KB Flash,128 KB SRAM |
| 开发板 | GD32F503V-EVAL |
| LCD | ILI9320,240×320,8080 16-bit 并行接口,接在 EXMC 的 NE0/Bank0 |
| 触摸屏 | XPT2046 电阻屏控制器,软件 SPI(bit-bang) |
| 调试器 | J-Link + Ozone / GDB |
| LED | PC7 / PC8 / PC9 三颗流水灯 |
| 按键 | PA0(WAKEUP)、PA1(USER)、PC13(TAMPER) |
1.2 软件版本
项目版本
| Zephyr | 4.4.0 |
| Zephyr SDK | 1.0.1 |
| HAL | GigaDevice SPL gd32f50x V1.0.4 |
| LVGL | Zephyr modules 内置版本(与 v4.4.0 配套) |
| 构建工具 | west / CMake / Ninja |
二、先看一下最终效果
【GD32F503eval Zephyr LVGL展示】 https://www.bilibili.com/video/BV1Z9MP6cET5/?share_source=copy_web&vd_source=46ffd4c7b1be3ec2b63adcc32d97b26b
资源占用(Release + -Os 大致结果):
Flash: 约 277 KB RAM: 约 106 KB(含 LVGL 约 38 KB 局部缓冲 + 48 KB 堆 + Zephyr 运行栈)
对于 512 KB Flash / 128 KB SRAM 的 GD32F503 来说,LVGL 完整 demo 刚刚能塞进 SRAM,后续如果要加更多业务逻辑,建议把 LV_Z_VDB_SIZE 调小或改用单缓冲。
三、整体架构:分层而不是一口吞
整个移植我把它拆成 5 个阶段,每个阶段只验证一件事,这样出问题容易定位。
┌─────────────────────────────────────────────┐ │ Phase E: XPT2046 触摸 → lv_indev_t │ ├─────────────────────────────────────────────┤ │ Phase D: LVGL + display_driver_api │ │ (drivers/display/display_ili9320.c)│ ├─────────────────────────────────────────────┤ │ Phase C: LCD panel vtable + app painter │ │ (src/panel/panel_*.c) │ ├─────────────────────────────────────────────┤ │ Phase B: MIPI-DBI 控制器 + EXMC 底层 │ │ (drivers/mipi_dbi/mipi_dbi_gd32_exmc.c) ├─────────────────────────────────────────────┤ │ Phase A: Zephyr blinky + GPIO 驱动跑通 │ │ (修正 AHB1 基址、GPIO 双寄存器等) │ └─────────────────────────────────────────────┘
这个分层思路是这次移植能走通的关键:先让 Zephyr 内核在 GD32F503 上跑起来,再让 LCD 控制器硬件通,再让面板协议通,再让 Zephyr display API 通,最后才接 LVGL。很多人一上来就同时调显示屏和 GUI,两个子系统都在报错,很难判断 bug 在哪一层。
四、让 Zephyr 在 GD32F503 上跑起来
这个阶段的目标只是把 samples/basic/blinky 跑起来,让三颗 LED 流水闪。但 GD32F50x 在 Zephyr 里的支持当时并不完整,遇到了 7 个坑。最核心的 3 个写在这里:
4.1 AHB1 总线基址错误导致 BusFault
Zephyr dts 里把 AHB1 基址写成了 0x40010000,而 GigaDevice SPL 头文件 gd32f50x.h 里定义的是 0x40018000。RCU 等挂在 AHB1 上的外设地址因此全部错 0x8000,一打开 GPIO 时钟就 BusFault。
经验:SoC vendor 的 CMSIS 头文件是外设地址的权威来源,Devicetree 注释里手算的式子要回头核对 vendor 头。
4.2 GPIO 双寄存器兼容层把 PC8/PC9 配成输入
GD32F4xx 的 GPIO 是 CTL0(pin 0-7)和 CTL1(pin 8-15)两个寄存器;GD32F50x 是单个 32-bit GPIO_CTL 管 16 个 pin。如果 driver 按 F4xx 的逻辑写 pin -= 8,PC8 的 mode 会被写到 PC0 的位上,PC8/PC9 永远是输入模式。
经验:同一个 vendor 的不同系列,GPIO 寄存器布局可能完全不一样。F50x 需要走 GD32_HAS_AF_PINMUX 分支,用单寄存器 RMW。
4.3 宏冲突与编译警告
gd32f50x.h 里的 BIT(x) 没有 #ifndef 保护,和 Zephyr sys/util_macro.h 冲突;SPL 里某些函数局部变量没初始化触发 -Wmaybe-uninitialized。这些都是 vendor HAL 进入 Zephyr 时的常见适配问题。
经验:vendor HAL 里 BIT、REG32、BITS 这类基础设施宏进入 Zephyr 时必须加 #ifndef 保护;对确实不会被 Zephyr 调到的 SPL 函数,可以选择性初始化局部变量消警告。
五、LCD 控制器与面板框架化
5.1 8080 并行屏用 MIPI-DBI 框架驱动
GD32F503 的 EXMC(外部存储器控制器)可以配成 NOR/PSRAM 模式来驱动 8080 接口 LCD。我实现了一个 Zephyr mipi_dbi 控制器:mipi_dbi_gd32_exmc.c。
它的核心是把 LCD 映射成两个 AHB 地址:
0x60000000:命令口(RS/DC=0)
0x61000000:数据口(RS/DC=1)
通过 EXMC 的地址线某一位来区分命令/数据,这样 mipi_dbi_command_write() 和 mipi_dbi_write_display() 最终都变成一次 AHB 写,不需要额外的 GPIO 翻转。
5.2 面板 vtable 抽象
在 app 层先做了 struct lcd_panel vtable:
struct lcd_panel {
enum lcd_panel_id id;
const char *name;
uint16_t width, height;
int (*init)(const struct device *, struct mipi_dbi_config *);
int (*set_window)(const struct device *, struct mipi_dbi_config *,
uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1);
};当时支持了 ILI9320 / ILI9341 / ILI9486 / ST7789V 四种面板。这个抽象不是最终形态,但它让我们在 app 层就能验证:
EXMC 时序是否正确;
面板初始化序列是否正确;
set_window 和像素填充方向是否正确。
5.3 这一阶段最隐蔽的两个坑
坑 1:8080-16bit 的字节序合同
mipi_dbi_gd32_exmc.c 的 command_write 实现里要求 caller 把每个 16-bit 参数按大端字节序打包进 data_buf,driver 内部用 sys_get_be16 解包后再写总线。原因是:
在 LE Cortex-M 上,一个 uint16_t val = 0x0173 内存里是 [0x73, 0x01];
如果直接把 &val 传给 driver,sys_get_be16 会把它读成 0x7301;
8080-16bit 面板读到的就是 0x7301,显示使能位等关键 bit 全错。
正确做法:caller 用 sys_cpu_to_be16() 把每个参数先 BE-pack,再传 buffer。
uint16_t be = sys_cpu_to_be16(val); mipi_dbi_command_write(dev, cfg, cmd, (const uint8_t *)&be, 2);
坑 2:面板方向与 fill_buf 顺序
ILI9320 的 R0x03 控制 GRAM 地址计数器方向。vendor 给的值是 0x1020(H-decrement / V-decrement),这意味着面板会把 fill_buf[0] 放到窗口最右列。如果 app 层 lcd_char() 还是按 MSB→fill_buf[0] 的方式填充,字符就会左右镜像。
经验:面板方向位(ID/SS/GS/MADCTL)和 host 的 buffer 布局是一个整体约定。要么改面板方向让 host 按自然顺序写,要么 host 按面板方向倒着写,两个里只能选一个,都做就是 double-flip。
六、把 LVGL 接到标准 display API 上
6.1 为什么必须写 kernel display driver
Zephyr 的 LVGL module 在 modules/lvgl/include/lvgl_zephyr.h 里有硬检查:
#if DT_ZEPHYR_DISPLAYS_COUNT == 0 #error Could not find "zephyr,display" chosen property...
也就是说,没有注册 display_driver_api 的 kernel 设备,LVGL module 直接编译报错。想在 app 层自己注册 lv_display_t + 自定义 flush_cb 这条路走不通。
所以我新增了一个 kernel-tree driver:drivers/display/display_ili9320.c。
6.2 新增的文件
文件作用
| zephyr/drivers/display/display_ili9320.c | 实现 display_driver_api:init / write / blanking / get_capabilities |
| zephyr/drivers/display/Kconfig.ili9320 | CONFIG_ILI9320 开关 |
| zephyr/dts/bindings/display/ilitek,ili9320.yaml | Devicetree binding |
| boards/gd/gd32f503v_eval/gd32f503v_eval.dts | 加 ili9320 子节点 + chosen { zephyr,display = &ili9320; } |
| app/prj.conf | CONFIG_LVGL=y 等 |
6.3 ILI9320 初始化序列的处理
vendor SPL 里的 lcd_init() 有 37 个寄存器。我把这个序列原样搬进 display_ili9320.c,并遵循 BE-pack 合同:
static int ili9320_transmit(const struct device *dev, uint8_t cmd, uint16_t val)
{
const uint16_t be = sys_cpu_to_be16(val);
return mipi_dbi_command_write(cfg->mipi_dev, &data->dbi_config,
cmd, (const uint8_t *)&be, sizeof(be));
}唯一不是 vendor-verbatim 的是 R0x03:vendor 是 0x1020,我改成 0x1030。为什么?
因为 LVGL 的 buffer 是自然 LTR-TTB(从左到右、从上到下)布局,而 0x1020 让 GRAM 计数器 H-decrement / V-decrement。配合 R0x01 的 SS 位和 R0x60 的 GS 位,三组翻转最终要合成一个"净 LTR-TTB"。实验下来 0x1030(H+/V+)配合 vendor 的 R0x01=0x0100、R0x60=0x2700,正好是 LVGL 想要的净方向。
经验:vendor 序列里的方向位不是孤立看的,要把它和 LVGL buffer 顺序、面板物理安装方向一起当成一个系统来调。
6.4 真正隐蔽的 off-by-one
ili9320_set_window() 里,窗口寄存器 R0x50~R0x53 是闭区间。我的 caller 已经按"inclusive end"传 (x + width - 1, y + height - 1),但 set_window 内部又减了一次 1,导致每个 band 都少写 1 行。 symptoms 是:屏幕底部出现黑色横条,字符整体往右下方斜。
经验:面板 driver 的 set_window 协议必须明确是 half-open [x0, x1) 还是 inclusive [x0, x1]。ILI9320 的硬件寄存器是 inclusive,所以 caller 传 inclusive end 时,driver 内部不要再减。
6.5 不要 driver-side 反转
看到镜像时,第一反应是在 driver 里加 per-row pixel reversal。结果加了之后还是镜像——因为 vendor 的 SS/GS/ID 已经把方向翻好了,driver 再翻一次就是 double-flip。最终做法是:driver 里不做任何像素顺序反转,直接 pass-through LVGL buffer。
经验:方向问题要一层一层隔离。先确认 EXMC 没有交换字节,再确认 MIPI-DBI 合同正确,再确认 panel 方向位组合,最后再考虑 driver 是否要做补偿。越底层越不应该做"智能"反转。
七、XPT2046 触摸接入 LVGL
7.1 软件 SPI bit-bang
XPT2046 接的是:
SCK = PC10
MOSI = PC12
MISO = PC11
CS = PE4
PEN = PE5
没有走 Zephyr SPI controller,而是用一个软件 SPI bit-bang 驱动。原因有两个:
这个触摸屏在评估板上和 LCD 共用一组 GPIO,Zephyr 的 SPI driver 可能没有为这个特定接线做 overlay;
数据率很低(几十 Hz 采样),bit-bang 完全够用,代码更可控。
实现上把 vendor SPL 的 touch_panel.c 里的时序原样搬过来,只是把 GPIO 操作换成 Zephyr gpio_pin_set / gpio_pin_get。
7.2 注册为 LVGL 输入设备
static void touch_indev_read_cb(lv_indev_t *indev, lv_indev_data_t *data)
{
uint16_t x = 0U, y = 0U;
if (touch_xpt2046_read(&x, &y) != 0) {
/* EVAL 上触摸读数方向与 LCD 差 180°,双轴镜像校准 */
x = TOUCH_LCD_W - 1U - x;
y = TOUCH_LCD_H - 1U - y;
last_touch.point.x = x;
last_touch.point.y = y;
last_touch.state = LV_INDEV_STATE_PRESSED;
} else {
last_touch.state = LV_INDEV_STATE_RELEASED;
}
*data = last_touch;
}注意这里有两个坐标系:
XPT2046 读出来的 AD 值经过 vendor 标定后,原点在面板某个角;
LCD 的 LTR-TTB 原点在左上角。
因为评估板上的触摸屏和 LCD 是反向安装的,所以我在 read_cb 里做了双轴镜像。如果你的板子安装方向不同,这两行可以去掉。
八、关键代码组织(app 目录)
app/gd32f503_blink/ ├── CMakeLists.txt # app 构建 + LVGL demos 源文件 ├── prj.conf # Zephyr / LVGL / 显示 / 触摸 Kconfig ├── src/ │ ├── main.c # LED、按键、LCD display 初始化、LVGL demo │ ├── font_8x16.h # 自带 ASCII 字模(Phase C app painter 用) │ ├── panel/ # Phase C 的 panel vtable(保留未引用) │ │ ├── panel.h │ │ ├── panel_ili9320.c │ │ ├── panel_ili9341.c │ │ ├── panel_ili9486.c │ │ ├── panel_st7789v.c │ │ ├── panel_internal.c │ │ └── panel_internal.h │ ├── touch_xpt2046.c # XPT2046 软件 SPI 驱动 │ └── touch_xpt2046.h └── docs/ ├── gd32f503_zephyr_port.md # Phase A 详细记录 ├── phase_c_lcd_panel_bugfixes.md ├── phase_d_lvgl_bugfixes.md └── lvgl_port_experience.md # 本文
Kernel tree 里改动的文件(在 zephyr 工作区):
zephyr/ ├── drivers/mipi_dbi/mipi_dbi_gd32_exmc.c # EXMC 8080 控制器 ├── drivers/display/display_ili9320.c # ILI9320 display driver ├── drivers/display/Kconfig.ili9320 ├── drivers/display/Kconfig ├── drivers/display/CMakeLists.txt ├── dts/bindings/display/ilitek,ili9320.yaml ├── dts/arm/gd/gd32f50x/gd32f50x.dtsi # 修正 AHB1 基址 ├── drivers/gpio/gpio_gd32.c # 配合 F50x 单寄存器 └── soc/gd/gd32/gd32f50x/Kconfig # select GD32_HAS_AF_PINMUX
九、prj.conf 关键项说明
# 显示控制器 + MIPI-DBI CONFIG_MIPI_DBI=y CONFIG_MIPI_DBI_GD32_EXMC=y CONFIG_MIPI_DBI_GD32_EXMC_MEM_BARRIER=y CONFIG_DISPLAY=y # LVGL 主体 CONFIG_LVGL=y CONFIG_LV_Z_AUTO_INIT=y CONFIG_LV_Z_RUN_LVGL_ON_WORKQUEUE=y CONFIG_LV_Z_LVGL_MUTEX=y # 内存 CONFIG_LV_Z_MEM_POOL_SYS_HEAP=y CONFIG_LV_Z_MEM_POOL_SIZE=49152 # 48 KB 堆,给 widgets + styles CONFIG_LV_Z_VDB_SIZE=18 # 渲染缓冲,约占 38 KB CONFIG_LV_Z_LVGL_WORKQUEUE_STACK_SIZE=16384 CONFIG_MAIN_STACK_SIZE=8192 # demo CONFIG_LV_USE_DEMO_WIDGETS=y
LV_Z_VDB_SIZE=18 是 Zephyr LVGL module 里以"1/10 屏幕"为单位的缓冲大小,18 对应约 60% 屏幕面积,实际 RAM 占用要看具体实现。这个值可以根据 SRAM 余量调整。
十、移植 checklist(可直接照做)
如果要在 GD32F50x 或其他国产 Cortex-M 上跑 Zephyr + LVGL,建议按这个顺序:
先让 Zephyr 最小系统跑起来:blinky 能闪,串口能输出,再往下做。
核对 AHB/APB 基址:用 vendor CMSIS 头文件和 DTS 对一遍,错一个地址就是 BusFault。
核对 GPIO 寄存器布局:不同系列差异很大,单寄存器/双寄存器、AFIO/SYSCFG 都要确认。
让 EXMC / FSMC / 8080 控制器先通:可以用 vendor SPL 的初始化代码做参考,但用 Zephyr driver 框架封装。
先点亮屏幕,再跑 LVGL:在 app 层做简单的 rect fill / 字符显示,确认方向、颜色、字节序都对。
写 kernel display driver:LVGL module 强制要求 zephyr,display chosen 节点。
严格遵循 MIPI-DBI 字节序合同:command 参数 BE-pack,framebuffer LE-pass-through。
set_window 协议要明确 inclusive / half-open:ILI9320 是 inclusive,不要重复减 1。
方向位要系统调:R0x03 / R0x01 / R0x60 / MADCTL 这些方向位和 LVGL buffer 顺序一起看,不要 driver 里再加反转。
触摸最后接:先确认 LCD 显示正确,再接入 XPT2046,避免显示和触摸两个变量同时调试。
十一、几点心得
11.1 为什么分层做很重要
如果同时调 LCD 硬件、面板协议、LVGL、触摸,出了问题根本没法定位。分阶段后,每一阶段的验收标准都很清楚:
Phase A:LED 流水闪;
Phase B:能写单个像素到屏上;
Phase C:能显示字符和色条;
Phase D:lv_demo_widgets() 正常显示;
Phase E:触摸能滑动切页。
每一阶段不通过,绝不进入下一阶段。
11.2 vendor SPL 的价值和陷阱
vendor SPL 的初始化序列、时序、标定参数是宝藏,但直接复制要小心:
宝藏:LCD init 序列、EXMC timing、XPT2046 校准值,这些不用自己推导;
陷阱:SPL 是阻塞式、裸机思维,和 Zephyr 的设备模型、多线程、中断模型不兼容。可以借鉴时序和寄存器值,但不能原样搬进 app。
11.3 Zephyr 设备模型不是负担
一开始我也想过绕过 display_driver_api,直接在 app 层操作 mipi_dbi。但 LVGL module 的硬检查让我必须写 kernel driver。回过头看,这个约束反而让架构更清晰:
硬件细节封装在 driver 里;
app 只调用标准 display_write();
以后换 ILI9341 / ST7789V,只要换个 driver,app 不用改。
11.4 调试 8080 屏的几件利器
Ozone Watch:直接看 RCU.APB2EN、GPIOx.CTL、EXMC.SNCTL/SNTCFG,比 printk 快;
反汇编 + objdump:BusFault 时 PC 指向的指令反汇编出来,能迅速定位到寄存器读写;
全屏 flood:用纯色先刷一遍,确认底层通不通;
字符测试:色条对了不代表方向对了,字符最容易暴露镜像/错位问题。
结语
GD32F503 + Zephyr + LVGL 这条路径走下来,最大的感受是:国产 MCU 跑现代 RTOS + GUI 是可行的,但 vendor HAL 和 Zephyr 设备模型之间的缝隙需要一层一层填补。不要指望一次性把所有功能堆起来点亮,分阶段、每层有明确验收、遇到方向/字节序/off-by-one 问题时用最小测试用例隔离,效率会高很多。
希望这篇记录对同样想在这条路上走的同学有帮助。如果有问题欢迎在楼下讨论。
我要赚赏金
