LCD驱动标准库移植HAL库(3)

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 0 Likes: 0 Comments: 0

205.3.4 LCD驱动标准库移植HAL库(3)

/*
 * 本节目标:修正第 204 课遗留的 LCD_Fill() 越界发送问题,并回顾 LCD 驱动的 HAL 移植调用链。
 * 收录边界:以下均为本节视频最终保留或在回顾中实际展示的业务代码;片段分属 lcd.c、lcd_init.c 和 main.c,不能单独编译。
 * 版本边界:严格保留课堂最终实现,包括无花括号的 for 循环和 LCD_ShowPicture() 的指针递增写法,不提前改成后续 LVGL 版本。
 */

/* lcd.c:本节最终修正后的区域填充函数。 */
void LCD_Fill(u16 xsta, u16 ysta, u16 xend, u16 yend, u16 color) /* 用同一种 RGB565 颜色填充指定矩形。 */
{                                                      /* 区域填充函数开始。 */
    u16 t = 1;                                         /* t 是分块循环标志;非零表示仍有像素需要处理。 */
    u32 num, num1;                                     /* num 保存剩余像素数,num1 保存本轮要处理的像素数。 */
    num = (xend - xsta) * (yend - ysta);               /* 用区域宽乘区域高,计算待填充的像素总数。 */
    LCD_Address_Set(xsta, ysta, xend - 1, yend - 1);   /* 设置包含式写入窗口,所以终点坐标各减 1。 */
    LCD_CS_Clr();                                      /* 拉低片选,在整个区域写入期间持续选中 LCD。 */
    while (t)                                          /* 按原驱动保留的分块结构处理全部像素。 */
    {                                                  /* 一轮分块开始。 */
        if (num > 65534)                               /* 剩余像素多于 65534 个时先处理一整块。 */
        {                                              /* 大数据块分支开始。 */
            num -= 65534;                              /* 从剩余像素数中扣除本轮数量。 */
            num1 = 65534;                              /* 本轮需要重复写入 65534 个像素。 */
        }                                              /* 大数据块分支结束。 */
        else                                           /* 剩余像素不超过上限时处理最后一块。 */
        {                                              /* 最后一块分支开始。 */
            t = 0;                                     /* 清除循环标志,使本轮结束后退出 while。 */
            num1 = num;                                /* 本轮处理全部剩余像素。 */
        }                                              /* 最后一块分支结束。 */
        for (uint32_t i = 0; i < num1; i++)            /* 每个像素执行一次发送;课堂源码没有给 for 加花括号。 */
            HAL_SPI_Transmit(&hspi1, (uint8_t *)&color, 2, 100); /* 从 color 地址发送 2 字节,重复写入同一颜色。 */
    }                                                  /* 一轮分块结束。 */
    LCD_CS_Set();                                      /* 拉高片选,结束本次区域填充。 */
}                                                      /* 区域填充函数结束。 */

/* lcd.c:课堂回顾的画点函数;函数本身未改,但会走已经迁移到 HAL 的底层接口。 */
void LCD_DrawPoint(u16 x, u16 y, u16 color)            /* 在坐标 (x, y) 写入一个 RGB565 像素。 */
{                                                      /* 画点函数开始。 */
    LCD_Address_Set(x, y, x, y);                       /* 把 LCD 写入窗口限制为一个像素。 */
    LCD_WR_DATA(color);                                /* 通过底层写数据接口发送该像素颜色。 */
}                                                      /* 画点函数结束。 */

/* lcd_init.c:课堂沿调用链查看的两个底层接口。 */
void LCD_WR_DATA(u16 dat)                              /* 向 LCD 写入一个 16 位数据值。 */
{                                                      /* 16 位数据写函数开始。 */
    LCD_Writ_Bus(dat >> 8);                            /* 先发送高 8 位。 */
    LCD_Writ_Bus(dat);                                 /* 再发送低 8 位;传给 u8 参数时只保留低字节。 */
}                                                      /* 16 位数据写函数结束。 */

void LCD_Address_Set(u16 x1, u16 y1, u16 x2, u16 y2)  /* 设置 LCD 后续显存写入的矩形窗口。 */
{                                                      /* 地址窗口设置函数开始。 */
    LCD_WR_REG(0x2A);                                  /* 写列地址设置命令。 */
    LCD_WR_DATA(x1);                                   /* 写起始列坐标。 */
    LCD_WR_DATA(x2);                                   /* 写结束列坐标。 */
    LCD_WR_REG(0x2B);                                  /* 写行地址设置命令。 */
    LCD_WR_DATA(y1);                                   /* 写起始行坐标。 */
    LCD_WR_DATA(y2);                                   /* 写结束行坐标。 */
    LCD_WR_REG(0x2C);                                  /* 写显存写入命令,随后到来的数据将作为像素数据。 */
}                                                      /* 地址窗口设置函数结束。 */

/* lcd.c:课堂再次确认的图片显示函数;它直接批量发送图片字节流。 */
void LCD_ShowPicture(u16 x, u16 y, u16 length, u16 width, const u8 pic[]) /* 在指定区域显示 RGB565 图片数组。 */
{                                                      /* 图片显示函数开始。 */
    u8 t = 1;                                          /* t 为分块循环标志。 */
    u32 num = length * width * 2, num1;                /* RGB565 每像素 2 字节,计算图片总字节数。 */
    LCD_Address_Set(x, y, x + length - 1, y + width - 1); /* 设置与图片尺寸一致的写入窗口。 */
    LCD_CS_Clr();                                      /* 拉低片选,开始连续写入图片数据。 */
    while (t)                                          /* 循环发送全部图片字节。 */
    {                                                  /* 一轮图片分块开始。 */
        if (num > 65534)                               /* 剩余数据超过单块上限时先发送一整块。 */
        {                                              /* 完整块分支开始。 */
            num -= 65534;                              /* 扣除本轮发送的 65534 字节。 */
            num1 = 65534;                              /* 本轮发送长度为 65534 字节。 */
        }                                              /* 完整块分支结束。 */
        else                                           /* 剩余数据不超过上限时处理最后一块。 */
        {                                              /* 最后一块分支开始。 */
            t = 0;                                     /* 标记本轮为最后一轮。 */
            num1 = num;                                /* 本轮发送全部剩余字节。 */
        }                                              /* 最后一块分支结束。 */
        HAL_SPI_Transmit(&hspi1, pic, num1, 100);      /* 从 pic 当前位置发送 num1 个连续字节。 */
        pic += 65534;                                  /* 按课堂源码把图片指针移到下一整块的起点。 */
    }                                                  /* 一轮图片分块结束。 */
    LCD_CS_Set();                                      /* 拉高片选,结束图片写入。 */
}                                                      /* 图片显示函数结束。 */

/* main.c:视频末尾用于验证 LCD_Fill() 的最终测试颜色。 */
LCD_Fill(0, 0, LCD_W, LCD_H, BLACK);                   /* 先把全屏填成黑色;随后主程序还会继续显示图片和文字。 */

一、LCD驱动标准库移植HAL库

本节承接第 204 课。上节已经把单字节写总线、区域填充和图片发送从标准库接口迁移到了 HAL,但 LCD_Fill() 错把一个只有 2 字节的颜色缓冲区当成了整块像素数据,造成越界读取,程序虽然能够编译、下载,LCD 却不能正常显示。本节先经历一次无效尝试,再改成“每个像素发送一次 2 字节颜色”,最终恢复图片、文字和小企鹅等演示内容。

需要区分两个结论:课堂最终代码已经解决“从短缓冲区批量越界读取”的致命问题,足以完成本阶段演示;但它仍有 RGB565 字节序和执行效率方面的局限,课程暂时保留,后续 LVGL 显示接口会重新组织刷屏过程。

1.1 函数问题修改

问题仍位于 LCD_Fill()。旧代码的关键变量是:

  • color:函数参数,类型为 u16,只占 2 字节,保存一个 RGB565 像素。
  • color1[1]:只有 1 个 u16 元素的数组,总大小同样只有 2 字节;下标中的 1 是元素个数,不是 2 个元素,也不是 4 字节。
  • num1:本轮需要填充的像素数,不是源缓冲区已经拥有的字节数。

视频中的第一次尝试把调用改成了近似下面的形式:

HAL_SPI_Transmit(&hspi1, (uint8_t *)color1, 4 * num1, 100);

这个尝试随后实测失败。其问题不只是“4 应该改成 2”:HAL_SPI_Transmit() 的 Size 表示从 pData 起始地址连续读取并发送多少个数据单元;当前 SPI 配置为 SPI_DATASIZE_8BIT,所以这里一个数据单元就是 1 字节。color1 实际只有 2 字节,却要求 HAL 连续读取 4 * num1 字节,第二个字节之后就已经越过数组边界。

另外,HAL 函数原型中的 Size 是 uint16_t。当 num1 = 65534 时,4 * num1 = 262136,传入 uint16_t 还会发生截断;因此这条语句既存在源缓冲区越界,也不能可靠表达所需发送长度。

视频提到数组名可以不写取地址符,这一点只需精确理解为:在函数实参中,color1 通常会转换成指向首元素的 u16 *,而 &color1 是指向整个数组的 u16 (*)[1];两者数值地址通常相同,但类型不同。强制转换成 uint8_t * 后都指向同一首地址,去掉 & 并不会扩大缓冲区,更不会修复越界。

原标准库 DMA 也不是“一次发送 4 字节”。本项目的 MYDMA_Config1() 明确配置为:

  • 外设和内存数据宽度均为 HalfWord,即每次搬运 16 位、2 字节;
  • DMA_MemoryInc_Disable,内存地址不递增;
  • cndtr = num1,把同一个 16 位颜色重复搬运 num1 次。

所以原 DMA 的语义是“重复发送同一个 2 字节颜色”,不是从 color1 后面连续读取 2 * num1 或 4 * num1 字节。HAL 阻塞发送不会自动复刻“源地址不递增”的 DMA 行为,必须显式循环。

1.2 烧写屏幕显示测试

第一次修改后,视频编译、下载并按下复位键,屏幕仍然显示异常,顶部还保留花屏,说明 4 * num1 方案无效。

随后视频调整了 Keil 下载设置:

  1. 打开 Options for Target → Debug → ST-Link Settings → Flash Download。
  2. 勾选 Reset and Run,让下载完成后自动复位并运行用户程序。
  3. 在 Pack 页取消 Enable。这一项与具体 Keil、器件包和调试配置有关,视频按当前环境关闭了它。

原始笔记一处写“勾选 Reset and Run”,下一处又写“去掉勾选”,前后矛盾;视频画面和讲解均是 勾选 Reset and Run。这个设置解决的是“下载后是否自动运行”,能省去手动按复位键;它不是 LCD 花屏的算法修复,也不能让错误的缓冲区长度自动变正确。

1.3 代码继续修改

第二次修改才是本节真正生效的修复:

  1. 不再使用 color1[1],直接以 &color 作为发送地址。
  2. 每次 HAL_SPI_Transmit() 的发送长度固定为 2,因为一个 RGB565 颜色占 2 字节。
  3. 增加 for (uint32_t i = 0; i < num1; i++),把同一颜色发送 num1 次。
  4. 保留 t、num、num1 和外层 while 分块框架。

这里的计数关系是:

[ \text{本轮字节数}=\text{本轮像素数}\times\text{每像素字节数}=num1\times2 ]

但实现方式不再是“一次调用从连续缓冲区取 2 * num1 字节”,而是调用 num1 次、每次从同一个 color 地址取 2 字节。这正好对应原 DMA 禁止内存地址递增时的“重复源数据”行为。

课堂源码的 for 后没有花括号,因此它只控制紧随其后的那一条 HAL_SPI_Transmit()。这在当前代码中语义正确,但初学者后续若要在循环体内再增加语句,必须补上 {},否则新增语句不会重复执行。

还要注意,改成逐像素发送后,每次长度永远是 2,已经不会碰到 HAL uint16_t Size 的上限。外层按 65534 分块不再是技术必需条件,只是为了尽量保留原函数结构,课堂没有继续化简。

1.4 屏幕显示测试

重新编译并烧录后,屏幕恢复了预期演示流程:先短暂显示整屏填充色,再显示全屏图片,随后清成白色,最后显示文字和多只小企鹅。这个结果说明两条主要数据路径都已经能够工作:

  • LCD_Fill() 不再因连续读取短缓冲区而把栈上的无关数据送到 LCD。
  • LCD_ShowPicture() 能按图片数组中的连续字节正确分块发送,因此背景图和小企鹅图案正常。

全屏为 320×480 像素时,一次填充包含:

[ 320\times480=153600\text{ 个像素}=307200\text{ 字节} ]

当前 LCD_Fill() 会执行 153600 次阻塞式 HAL_SPI_Transmit()。它在功能上可用,却会产生非常大的函数调用与状态检查开销,所以下载后刷屏和整段演示都比较慢。若追求性能,应准备一段包含重复颜色的连续缓冲区后分块发送,或使用适配好的 DMA;这属于优化方案,不是本节视频实际代码。

1.5 编译并烧写屏幕显示测试

基础显示恢复后,视频继续更换 main.c 中传给 LCD_Fill() 的颜色宏做验证。课堂先试了 GRED,再改成 RED,最后改成 BLACK。测试现象如下:

传入颜色宏值视频现象结论
RED0xF800实际看起来是蓝色16 位颜色的两个字节发送次序不符合 LCD 接收次序
BLACK0x0000正常显示黑色两个字节都是 0x00,交换次序也不改变数值
图片数组连续 u8 字节流背景图和小企鹅正常图片数据已经按 LCD 所需字节顺序生成,直接发送不会经过 u16 的内存字节序

视频末尾保留的是 BLACK 测试。由于主程序紧接着还会显示图片,整屏黑色只会短暂出现,不能把后续图片误认为填充失败。

1.6 颜色显示问题

“代码传 RED,屏幕却显示蓝色”的直接原因可以由项目源码交叉验证出来。

首先,颜色宏采用 RGB565:

RED  = 0xF800
BLUE = 0x001F

其次,STM32F103 以小端方式保存多字节整数。u16 color = 0xF800 在内存中的地址顺序是:

低地址 → 0x00  0xF8 ← 高地址

本工程 SPI 配置为 8 位数据宽度。把 &color 强制转换为 uint8_t * 后调用 HAL,HAL 会按照内存地址顺序先发送 0x00,再发送 0xF8。而项目原有的 LCD_WR_DATA() 明确先执行 LCD_Writ_Bus(dat >> 8),再发送低字节,说明 LCD 这条 8 位接口所需顺序是高字节在前:

期望发送:0xF8 0x00  → RGB565 的 0xF800(红色)
实际发送:0x00 0xF8  → LCD 组合为 0x00F8(蓝色分量占主导)

SPI_FIRSTBIT_MSB 只规定每一个字节内部先发最高位,不会替程序交换两个字节的先后顺序。因此即使 SPI 配置为 MSB First,仍需要软件按“高字节、低字节”组织 RGB565 字节流。

这也解释了为何黑色显示正常:0x0000 的两个字节相同,交换后仍为 0x0000。白色 0xFFFF 同理也不受影响。图片正常则是因为 pic 本来就是 u8 数组,取模工具生成时已经确定了数组中的字节顺序,LCD_ShowPicture() 只是原样发送。

从原理上修复时,应把颜色预先整理为 {color >> 8, color & 0xFF} 的两字节序列再重复发送,或者采用等价的 16 位字节交换及批量缓冲方案。不过视频没有在本节实施这项修改,而是用 BLACK 完成阶段性验证,并说明后续接入 LVGL 时会重写填充接口,所以不能把该改进写进本节顶部的课堂源码。

1.7 颜色显示问题解决

本小节标题沿用原笔记,但要准确理解“解决”的范围:课堂解决的是 LCD_Fill() 的非法批量读取,使显示流程恢复;RED 变蓝的字节序问题只被规避,没有在本节彻底修复。

视频把下面两处旧代码注释掉:

// u16 color1[1], t = 1;
// color1[0] = color;

随后单独保留 u16 t = 1;。原因是 color1 已经不再作为发送源,而 t 仍被外层 while (t) 使用,不能一并删除。顶部源码只保留最终有效语句,没有把已经停用的旧变量伪装成现行实现。

这版实现还应记住两个边界:

  • 正确性方面:不再越界读取,但直接发送 u16 的内存表示仍有字节序问题。
  • 性能方面:每像素调用一次阻塞 HAL,适合课堂验证,不适合高帧率全屏刷新。

1.8 改动内容总结

三节 LCD 标准库移植的核心不是把每个上层绘图函数都改写一遍,而是替换共用的底层通道,并对少数绕过底层单字节接口的批量函数单独处理。

本节实际完成或确认的内容为:

  1. LCD_Fill():从错误的连续批量读取改为重复发送同一个 2 字节颜色。
  2. LCD_DrawPoint():源码本身没有改,但它调用的 LCD_Address_Set()、LCD_WR_DATA() 和 LCD_Writ_Bus() 已经走 HAL。
  3. LCD_ShowPicture():直接使用 HAL_SPI_Transmit() 分块发送图片数组,实机图片显示正确。
  4. Keil 下载设置:勾选 Reset and Run,让下载后自动启动;它独立于显示算法。

可以把调用关系概括为:

LCD_DrawPoint / LCD_DrawLine / 字符与数字绘制
                    │
                    ├── LCD_Address_Set ── LCD_WR_REG ──┐
                    │                                   │
                    └── LCD_WR_DATA ────────────────────┤
                                                        ▼
                                                 LCD_Writ_Bus
                                                        │
                                                        ▼
                                                HAL_SPI_Transmit

LCD_Fill ────────────────────────────────────── HAL_SPI_Transmit
LCD_ShowPicture ─────────────────────────────── HAL_SPI_Transmit

上半部分通过统一底层接口间接受益;LCD_Fill() 和 LCD_ShowPicture() 为了批量数据传输而直接调用 HAL,所以需要分别检查长度、缓冲区和字节序。

1.9 修改函数二

课堂从 LCD_DrawPoint() 开始追踪调用链。画点函数只有两个动作:先用 LCD_Address_Set(x, y, x, y) 把写入窗口设成一个像素,再用 LCD_WR_DATA(color) 写入颜色。

继续进入底层可见:

  • LCD_Address_Set() 通过 LCD_WR_REG() 写 0x2A、0x2B、0x2C 等命令,并通过 LCD_WR_DATA() 写坐标。
  • LCD_WR_REG()、LCD_WR_DATA() 最终都调用 LCD_Writ_Bus()。
  • LCD_Writ_Bus() 已在第 204 课把旧的 SPI1_ReadWriteByte() 换成 HAL_SPI_Transmit()。

因此,LCD_DrawPoint() 虽然没有发生文本级修改,它的实际 SPI 执行路径已经迁移到 HAL。这就是接口封装的价值:上层只依赖稳定接口,底层实现替换后,大量调用者无需逐个改动。

1.10 修改函数三

LCD_DrawLine() 同样没有在本节重写。它根据起点和终点逐步计算坐标,并不断调用 LCD_DrawPoint() 落点;所以其调用链最终仍会到达已经修改的 HAL SPI 总线函数。

同类的圆、矩形、字符、字符串和数字绘制函数,只要最终通过 LCD_DrawPoint()、LCD_WR_DATA() 或 LCD_Writ_Bus() 写屏,就会间接使用新的 HAL 路径。判断“一个函数是否完成移植”时,不能只搜索函数体里有没有 HAL_,还要查看它依赖的下层接口。

但这种复用不会自动修复所有语义问题。例如 LCD_WR_DATA() 会主动按高字节、低字节发送,所以单点、文字等路径的 RGB565 顺序正确;本节 LCD_Fill() 绕过了它,直接发送 &color,才出现红蓝异常。调用链分析既能说明哪些函数已随底层迁移,也能定位哪些直连路径需要单独审查。

1.11 修改函数四

课堂随后回到 LCD_ShowPicture()。图片函数与画点不同,它不对每个像素调用 LCD_WR_DATA(),而是把 pic[] 中已经排好顺序的字节连续送入 SPI,因此必须直接改造批量发送部分。

这里 num = length * width * 2 的单位是字节,num1 也是本轮字节数;这与 LCD_Fill() 中 num、num1 表示像素数不同。虽然变量名相同,单位不能混用。图片函数之所以能一次发送 num1 字节,是因为 pic[] 确实包含这么多连续有效数据,而填色函数只有一个 2 字节颜色,不能照搬。

实机背景图和小企鹅都能正常显示,证明课堂分块发送的主要路径有效。不过顶部源码中的 pic += 65534 会在最后一轮结束后仍执行一次;下一轮不会再解引用它,所以课堂运行结果不受影响,但按严格 C 语言规则,最后形成超出数组末尾一个元素之外的指针并不严谨。更稳妥的实现应只在还有下一块时递增,或按本轮实际长度管理偏移。本节为忠实记录视频,没有擅自改写。

1.12 修改函数五

至此,LCD 显示部分的标准库到 HAL 迁移可以按文件归纳:

文件/配置三节课程中的作用
lcd.c修改 LCD_Fill() 和 LCD_ShowPicture() 等直接批量发送路径;其他绘图函数沿共用接口间接迁移
lcd_init.cSPI、GPIO 初始化改由 CubeMX/HAL 提供;修改 LCD_Writ_Bus(),并把旧延时改为 HAL_Delay()
lcd_init.h 等头文件GPIO 操作宏和 HAL 头文件依赖适配到当前工程
spi.c、gpio.c由 CubeMX 生成并初始化 SPI1、片选/复位/背光等 GPIO
Keil Target 设置配置 ST-Link 下载后的复位运行行为

本节结束状态是:屏幕能够依次显示填色、背景图、白屏、文字和小企鹅;LCD_Fill() 的越界读取已修正;图片批量发送可用;RED 填充的高低字节顺序问题仍存在,课堂以 BLACK 暂时规避;逐像素阻塞发送效率较低,等待后续 LVGL 显示驱动阶段重构。