LCD驱动标准库移植HAL库(3)
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 下载设置:
- 打开 Options for Target → Debug → ST-Link Settings → Flash Download。
- 勾选
Reset and Run,让下载完成后自动复位并运行用户程序。 - 在
Pack页取消Enable。这一项与具体 Keil、器件包和调试配置有关,视频按当前环境关闭了它。
原始笔记一处写“勾选 Reset and Run”,下一处又写“去掉勾选”,前后矛盾;视频画面和讲解均是 勾选 Reset and Run。这个设置解决的是“下载后是否自动运行”,能省去手动按复位键;它不是 LCD 花屏的算法修复,也不能让错误的缓冲区长度自动变正确。
1.3 代码继续修改
第二次修改才是本节真正生效的修复:
- 不再使用
color1[1],直接以&color作为发送地址。 - 每次
HAL_SPI_Transmit()的发送长度固定为2,因为一个 RGB565 颜色占 2 字节。 - 增加
for (uint32_t i = 0; i < num1; i++),把同一颜色发送num1次。 - 保留
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。测试现象如下:
| 传入颜色 | 宏值 | 视频现象 | 结论 |
|---|---|---|---|
RED | 0xF800 | 实际看起来是蓝色 | 16 位颜色的两个字节发送次序不符合 LCD 接收次序 |
BLACK | 0x0000 | 正常显示黑色 | 两个字节都是 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 标准库移植的核心不是把每个上层绘图函数都改写一遍,而是替换共用的底层通道,并对少数绕过底层单字节接口的批量函数单独处理。
本节实际完成或确认的内容为:
LCD_Fill():从错误的连续批量读取改为重复发送同一个 2 字节颜色。LCD_DrawPoint():源码本身没有改,但它调用的LCD_Address_Set()、LCD_WR_DATA()和LCD_Writ_Bus()已经走 HAL。LCD_ShowPicture():直接使用HAL_SPI_Transmit()分块发送图片数组,实机图片显示正确。- 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.c | SPI、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 显示驱动阶段重构。