基于LVGL的嵌入式显示界面编程:在STM32上快速开发工业触控HMI
工业HMI不是“点灯加按钮”那么简单:参数面板、报警页、趋势曲线、启停确认,都要稳定刷屏且不卡触摸。STM32上跑LVGL最稳的路线是:先裁lv_conf、再接显示flush、再挂触摸输入、最后UI逻辑放单一线程改对象。
一、lv_conf裁剪
先关掉用不到的模块,Flash/RAM立刻松出来:
#define LV_USE_OS 0 // 裸机先0,FreeRTOS再改
#define LV_MEM_SIZE (32 * 1024)
#define LV_COLOR_DEPTH 16 // RGB565
#define LV_USE_TICK_CUSTOM 0
#define LV_THEME_DEFAULT_INIT 0
字体只留HMI用到的:ASCII+数字+必要汉字,别把demo字体全开。不需要文件系统、GPU、输入法就关掉。
二、显示缓冲:部分缓冲最实用
320×240全缓冲要320×240×2=150KB,小STM32扛不住。工业屏常用“若干行”部分缓冲:
#define BUF_ROWS 20
static lv_color_t buf1[LCD_W*BUF_ROWS];
static lv_color_t buf2[LCD_W*BUF_ROWS];
static lv_disp_draw_buf_t draw_buf;
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LCD_W*BUF_ROWS);
lv_disp_drv_t drv; lv_disp_drv_init(&drv);
drv.hor_res = LCD_W; drv.ver_res = LCD_H;
drv.draw_buf = &draw_buf;
drv.flush_cb = disp_flush;
drv.full_refresh = 0;
lv_disp_drv_register(&drv);
单缓冲省RAM但刷的时候CPU等;双缓冲配合DMA才真正并行。RAM够就buf2开着,不够就buf2传NULL。
三、flush回调:DMA完成再flush_ready
LVGL把脏矩形区域交给你,你搬到屏上对应窗口。SPI屏先设地址窗口,再发数据:
static volatile int dma_done = 0;
void disp_flush(lv_disp_drv_t*drv, const lv_area_t*area, lv_color_t*color_p){
lcd_set_window(area->x1, area->y1, area->x2, area->y2);
dma_done = 0;
lcd_spi_dma_send((uint8_t*)color_p,
(area->x2-area->x1+1)*(area->y2-area->y1+1)*2);
}
void DMA_Cplt_IRQ(void){
dma_done = 1;
lv_disp_flush_ready(&disp_drv); // 必须等DMA真发完
}
最大坑:在flush里调完HAL_SPI_Transmit就立刻flush_ready,DMA还在发,LVGL已经复用缓冲→花屏撕裂。
主循环里也别死等DMA,裸机可在while(!dma_done);,RTOS交给定时器任务。
四、触摸输入:read回调只回报状态
电容屏(FT5x06/CST816)用INT脚进EXTI,主循环/定时读I2C,别每次lv_timer_handler都扫I2C。
void touch_read(lv_indep_drv_t*d, lv_indev_data_t*data){
static int16_t lx=0, ly=0;
if(tp_irq_flag){
tp_irq_flag=0;
uint16_t x,y;
if(ts_read_point(&x,&y)){
lx=x; ly=y;
data->state = LV_INDEV_STATE_PRESSED;
}
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
data->point.x = lx; data->point.y = ly;
}
注册:
lv_indev_drv_t idrv; lv_indev_drv_init(&idrv);
idrv.type = LV_INDEV_TYPE_POINTER;
idrv.read_cb = touch_read;
lv_indev_drv_register(&idrv);
坐标偏移用lv_indev_set_read_cb里做线性校准,X/Y反了、镜像了都在这一层修,别改UI布局。
五、心跳与主循环
LVGL靠1ms心跳跑动画和长按:
void SysTick_Handler(void){
HAL_IncTick();
lv_tick_inc(1);
}
裸机:
while(1){
lv_timer_handler();
HAL_Delay(5);
hmi_periodic_refresh();
}
FreeRTOS下单独建ui_task,别在多个任务里同时改lv_obj——LVGL不是线程安全,要改就放一个任务,别的任务用队列传数据。
六、工业HMI控件套路
启停按钮、状态灯、数值标签、报警条、曲线图:
lv_obj_t* btn = lv_btn_create(lv_scr_act());
lv_obj_set_size(btn,120,48);
lv_obj_align(btn,LV_ALIGN_TOP_LEFT,10,10);
lv_obj_t* lb = lv_label_create(btn);
lv_label_set_text(lb,"启动");
lv_obj_add_event_cb(btn, btn_cb, LV_EVENT_CLICKED, NULL);
void btn_cb(lv_event_t*e){
if(lv_event_get_code(e)!=LV_EVENT_CLICKED) return;
run_state = !run_state;
HAL_GPIO_WritePin(MOTOR_GPIO,GPIO_PIN_SET, run_state);
lv_label_set_text(lb, run_state?"停止":"启动");
lv_obj_set_style_bg_color(btn, run_state?lv_color_hex(0x2e7d32)
:lv_color_hex(0xb71c1c),0);
}
参数刷新别在中断里改label,主循环从队列取:
if(xQueueReceive(hmi_q,&val,0)==pdTRUE){
lv_label_set_text_fmt(temp_lb,"温度 %.1f℃", val.temp);
lv_bar_set_value(press_bar, val.bar, LV_ANIM_ON);
}
报警用lv_obj_add_state切LV_STATE_USER_1样式,别删了重建。
七、落坑清单
缓冲只开单块还开DMA→花屏
flush里提前flush_ready→撕裂
触摸每次轮询都读I2C→总线被占、响应抖
多任务直接改lv_obj→对象损坏
lv_tick_inc没接→动画不动、长按失效
字体全开→Flash爆
坐标没校准→点按钮偏一格
全屏full_refresh小RAM芯片→卡顿
八、落地顺序
lv_conf裁到最小,lv_init能过
显示部分缓冲+SPI DMA,静态画面不花
lv_tick_inc+主循环lv_timer_handler
触摸read回调,点按钮有反应
建启停/状态/报警页
传感器数据走队列进UI
FreeRTOS下单开ui_task
STM32工业HMI用LVGL的核心就三句:显示缓冲和DMA完成解耦、触摸只回报状态不忙读、UI对象归单一上下文改。剩下都是控件拼装和事件回调,SquareLine画完导出代码也能用,但驱动层这三处不稳,界面再漂亮也上不了产线。





