LCD驱动标准库移植HAL库(2)
204.3.3 LCD驱动标准库移植HAL库(2)
/*
* 本节目标:继续把中景园 LCD 标准库驱动迁移到 HAL 工程,替换旧延时、单字节 SPI、填色 DMA 和图片 DMA 发送路径。
* 收录边界:只列出本节实际改写并保留下来的有效代码;这些片段分别属于 lcd_init.c 和 lcd.c,不能单独编译。
* 版本边界:LCD_Fill() 严格保留第 204 课结束时的视频写法,即使该写法存在越界读取问题,也不提前抄入第 205 课的修正。
* 课堂结果:代码最终能够编译和烧录,但 LCD 显示异常,所以本节结束时迁移还没有成功。
*/
/* lcd_init.c:要使用 CubeMX 生成的 hspi1 句柄,必须包含 spi.h。 */
#include "spi.h" /* 引入 HAL SPI 类型、接口声明以及 hspi1 的 extern 声明。 */
/* lcd_init.c / LCD_Init():本节把旧工程中的 delay_ms() 改成 HAL_Delay()。 */
LCD_RES_Clr(); /* 拉低 RES,令 LCD 控制器进入硬件复位状态。 */
HAL_Delay(100); /* 保持复位低电平 100 ms;课堂代码由 delay_ms(100) 替换而来。 */
LCD_RES_Set(); /* 拉高 RES,释放 LCD 控制器的硬件复位。 */
HAL_Delay(100); /* 释放复位后等待 100 ms,让控制器内部状态稳定。 */
LCD_BLK_Set(); /* 拉高 BLK,打开 LCD 模块背光。 */
HAL_Delay(100); /* 背光打开后继续等待 100 ms。 */
LCD_WR_REG(0x11); /* 向 LCD 写入 Sleep Out 指令,使控制器退出休眠。 */
HAL_Delay(120); /* 按原驱动时序等待 120 ms,再继续写初始化寄存器。 */
/* lcd_init.c:把标准库的 SPI1_ReadWriteByte(dat) 换成 HAL 阻塞发送接口。 */
void LCD_Writ_Bus(u8 dat) /* 定义 LCD 单字节总线写函数;函数名 Writ 是课程源码原有拼写。 */
{ /* 单字节写函数开始。 */
LCD_CS_Clr(); /* 拉低 CS1,选中 LCD 显示控制器。 */
HAL_SPI_Transmit(&hspi1, &dat, 1, 100); /* 通过 SPI1 发送 dat 所在地址的 1 个字节,最长等待 100 ms。 */
LCD_CS_Set(); /* 拉高 CS1,结束本次 LCD 传输。 */
} /* 单字节写函数结束。 */
/* lcd.c:下面严格保留第 204 课结束时的 LCD_Fill() 活动代码。 */
void LCD_Fill(u16 xsta, u16 ysta, u16 xend, u16 yend, u16 color) /* 用同一种 RGB565 颜色填充指定矩形区域。 */
{ /* 区域填充函数开始。 */
u16 color1[1], t = 1; /* color1 只有一个 16 位元素;t 控制分块循环。 */
u32 num, num1; /* num 保存剩余像素数,num1 保存本轮计划发送的像素数。 */
color1[0] = color; /* 把本次要填充的 16 位颜色值放入唯一的数组元素。 */
num = (xend - xsta) * (yend - ysta); /* 用区域宽乘区域高,计算需要写入的像素总数。 */
LCD_Address_Set(xsta, ysta, xend - 1, yend - 1); /* 设置 LCD 写入窗口;终点坐标按包含式范围减 1。 */
LCD_CS_Clr(); /* 拉低 CS1,使随后像素数据连续送往 LCD。 */
while (t) /* 只要像素还没分完块,就继续处理。 */
{ /* 一轮分块处理开始。 */
if (num > 65534) /* 剩余像素数超过单块上限时,先处理 65534 个像素。 */
{ /* 大数据块分支开始。 */
num -= 65534; /* 从剩余像素数中扣除本轮计划处理的数量。 */
num1 = 65534; /* 把本轮像素数限制为 65534。 */
} /* 大数据块分支结束。 */
else /* 剩余像素已经不超过单块上限时,进入最后一块。 */
{ /* 最后一块分支开始。 */
t = 0; /* 清零循环标志,使本轮结束后退出 while。 */
num1 = num; /* 最后一轮发送全部剩余像素。 */
} /* 最后一块分支结束。 */
HAL_SPI_Transmit(&hspi1, (uint8_t *)&color1, 2 * num1, 100); /* 课堂原样:试图从 color1 连续发送 2×像素数字节;此处会越界读取。 */
} /* 本轮分块处理结束。 */
LCD_CS_Set(); /* 拉高 CS1,结束本次区域填充传输。 */
} /* 区域填充函数结束;本节版本尚不能正确完成填色。 */
/* lcd.c:把 LCD_ShowPicture() 原有的标准库 SPI+DMA 数据发送改为 HAL 阻塞发送。 */
void LCD_ShowPicture(u16 x, u16 y, u16 length, u16 width, const u8 pic[]) /* 在指定位置显示字节数组保存的图片。 */
{ /* 图片显示函数开始。 */
u8 t = 1; /* 用非零值表示仍有图片数据需要分块发送。 */
u32 num = length * width * 2, num1; /* RGB565 每像素占 2 字节,所以图片总字节数为长×宽×2。 */
LCD_Address_Set(x, y, x + length - 1, y + width - 1); /* 设置图片对应的 LCD 写入窗口。 */
LCD_CS_Clr(); /* 拉低 CS1,选中 LCD 并保持连续传输。 */
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 个字节,最长等待 100 ms。 */
pic += 65534; /* 按课堂源码把指针移到下一整块数据的起点。 */
} /* 一轮图片分块结束。 */
LCD_CS_Set(); /* 拉高 CS1,结束图片数据传输。 */
} /* 图片显示函数结束。 */
本课是 LCD 标准库驱动迁移的第 2 部分,紧接第 203 课。第 203 课已经让 CubeMX/HAL 接管 GPIO 和 SPI 初始化,本课继续替换 delay_ms()、SPI1_ReadWriteByte()以及 LCD_Fill()、LCD_ShowPicture()中的标准库 SPI+DMA 发送代码。
必须先明确本节的验收结果:编译通过、下载成功,但屏幕显示不正确,不能据此宣布移植完成。 视频结束时老师也判断需要继续排查;第 205 课开头才重新分析并修改 LCD_Fill()。配套工程 12LCD_Display中的 lcd.c已经是后续修正过的版本,因此上方代码按第 204 课视频画面收录,没有把后续的 for循环提前放进来。
一、修改延时函数
旧标准库工程在 lcd_init.c里使用自定义 delay_ms(),HAL 工程则已经由 HAL_Init()建立系统时基,所以本节将驱动中的毫秒延时统一换成 HAL_Delay():
| 初始化阶段 | 旧调用 | 本节调用 | 作用 |
|---|---|---|---|
| RES 拉低后 | delay_ms(100) | HAL_Delay(100) | 保持硬件复位 |
| RES 拉高后 | delay_ms(100) | HAL_Delay(100) | 等待控制器退出复位 |
| 打开背光后 | delay_ms(100) | HAL_Delay(100) | 保留原驱动等待时间 |
写入 0x11 后 | delay_ms(120) | HAL_Delay(120) | 等待 Sleep Out 完成 |
HAL_Delay(n)在当前默认 HAL 时基下表示阻塞等待约 n毫秒。阻塞期间 CPU 不继续执行当前线程中的其他业务;这里处于一次性的 LCD 初始化阶段,所以沿用阻塞等待比较直观。
视频中的“delete 毫秒”“hell delay”“LCD black set”都是转录错误,准确名称分别是 delay_ms()、HAL_Delay()和 LCD_BLK_Set()。修改后还应逐项复查上一课配置的 GPIO 端口、引脚和高低电平:RES、DC、BLK、CS1 中任意一个映射错误,都可能表现为背光亮但没有正确图像。
二、修改SPI发送函数
2.1 寻找SPI发送相关接口函数
本节在 HAL 驱动头文件 stm32f1xx_hal_spi.h末尾的 I/O 操作接口中找到:
HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi,
const uint8_t *pData,
uint16_t Size,
uint32_t Timeout);
HAL_SPI_Init()负责初始化 SPI,HAL_SPI_Transmit()才是这里需要的阻塞发送接口,二者用途不能混淆。所谓“阻塞发送”,是指函数会轮询 SPI 状态并等待数据发完或超时,返回前 CPU 一直停留在这次调用中。
2.2 修改LCD_Writ_Bus函数
LCD_Writ_Bus()位于 lcd_init.c,是上层 LCD 命令和数据写入最终共用的单字节出口。旧工程的核心调用是:
SPI1_ReadWriteByte(dat) 标准库工程的自定义 SPI 收发封装
↓ 替换为
HAL_SPI_Transmit(...) HAL 工程的阻塞发送接口
函数仍保留原来的片选时序:先 LCD_CS_Clr()选中屏幕,发送一个字节,再 LCD_CS_Set()释放屏幕。LCD_WR_REG()会在调用它之前把 DC 拉低,LCD_WR_DATA8()和 LCD_WR_DATA()则在数据状态下复用它,所以替换这个底层出口能够覆盖大量初始化寄存器写入。
2.3 调用HAL_SPI_Transmit函数
本节调用为:
HAL_SPI_Transmit(&hspi1, &dat, 1, 100);
四个实参的含义如下:
| 实参 | 含义 | 为什么这样传 |
|---|---|---|
&hspi1 | SPI1 句柄地址 | 接口需要 SPI_HandleTypeDef *,所以对句柄变量取地址 |
&dat | 待发送缓冲区首地址 | dat是一个 u8变量,取地址后才能让接口读取它 |
1 | 发送 1 个数据单元 | 本工程 hspi1.Init.DataSize为 SPI_DATASIZE_8BIT,一个数据单元就是 1 字节 |
100 | 超时值 | 当前默认毫秒时基下,最多等待约 100 ms |
函数返回 HAL_OK、HAL_BUSY、HAL_ERROR或 HAL_TIMEOUT等状态,但课堂代码没有接收返回值。编译通过只能说明调用形式成立;如果硬件状态异常或发生超时,不检查返回值就无法从程序中直接知道发送是否成功。
2.4 包含SPI头文件及结构体声明
CubeMX 在 spi.c中真正定义:
SPI_HandleTypeDef hspi1;
并在 spi.h中用 extern声明:
extern SPI_HandleTypeDef hspi1;
因此 lcd_init.c和 lcd.c凡是直接使用 hspi1,都需要包含 spi.h。extern不会再创建一份变量,它只是告诉当前 C 文件:“这个句柄定义在其他编译单元,链接时去那里找”。如果遗漏头文件,编译器便不知道 hspi1的类型和声明。
2.5 例题1:修改LCD初始化中的SPI发送
把 LCD_Writ_Bus()替换完成后,调用链变成:
LCD_Init()
├─ LCD_WR_REG(command)
│ └─ LCD_Writ_Bus(command)
│ └─ HAL_SPI_Transmit(&hspi1, &dat, 1, 100)
└─ LCD_WR_DATA8(data)
└─ LCD_Writ_Bus(data)
└─ HAL_SPI_Transmit(&hspi1, &dat, 1, 100)
再次编译时出现的错误不在 LCD_Writ_Bus(),而在 lcd.c里的 LCD_Fill()。这说明初始化阶段的单字节出口已经能通过编译,但显示驱动中仍残留标准库 SPI 和 DMA 符号,必须继续沿调用链处理,不能为了暂时消除错误而跳过整个填充函数。
2.6 例题2:修改LCD显示函数中的SPI发送
LCD_Fill()原本不是“发送一个 color 就结束”,而是要把同一个 16 位 RGB565 颜色重复写入矩形区域内的每一个像素。旧代码先算出:
像素总数 num = (xend - xsta) × (yend - ysta)
然后把像素分成至多 65534 个一组,通过 16 位 SPI + DMA 重复发送 color1[0]。迁移时不能只看函数名相似就机械替换,因为旧 DMA 和新 HAL 阻塞接口读取内存的方式并不相同,具体差异见下一节。
三、理解发送相关代码
标准库版 LCD_Fill()的有效流程是:
设置 LCD 显示窗口
↓
CS1 拉低,选中显示控制器
↓
SPI 切换到 16 位数据帧
↓
DMA1 通道 3:内存 → SPI1->DR
↓
等待 DMA 传输完成
↓
CS1 拉高,并恢复 8 位 SPI
旧工程的 MYDMA_Config1()有三个决定性配置:
| DMA 配置 | 实际效果 |
|---|---|
| 内存宽度为 HalfWord | 每次从内存读取 16 位,也就是一个 RGB565 颜色 |
| 外设宽度为 HalfWord | 每次向 SPI 数据寄存器写入 16 位 |
DMA_MemoryInc_Disable | 每次都从同一个 color1[0]地址读取,不向后移动 |
因此,旧 DMA 的 num1表示“把同一个 16 位颜色重复发送多少次”。即使 color1只有一个元素,DMA 也不会越界,因为其内存地址被明确配置为不递增。
HAL 工程则把 SPI1配置为 SPI_DATASIZE_8BIT。本工程的 HAL 驱动在发送一个字节后会执行等价于“缓冲区指针加 1”的操作,再读取下一个地址。数据通路可以简化为:
内存缓冲区 pData
│ 每发送 1 字节,地址向后移动 1 字节
▼
HAL_SPI_Transmit()
▼
SPI1 数据寄存器 DR
▼
PA7 / MOSI ─────► LCD
PA5 / SCK ─────► 为每一位数据提供时钟
PA3 / CS1 ─────► 低电平时选中 LCD
3.1 第204课中 LCD_Fill() 的关键错误
课堂写下:
HAL_SPI_Transmit(&hspi1, (uint8_t *)&color1, 2 * num1, 100);
这里“16 位颜色占 2 字节,所以长度乘 2”只完成了单位换算,却忽略了缓冲区本身只有 2 字节。HAL_SPI_Transmit()不会像旧 DMA 的“内存地址不递增”模式那样自动重复 color1[0],它会从 color1后面继续读 RAM。因此当 num1 > 1时,函数会越过数组边界,把相邻内存中的无关内容当成像素数据发送。
此外,HAL_SPI_Transmit()的 Size形参是 uint16_t。当 num1 = 65534时:
2 × 65534 = 131068
这个值也超出了 16 位无符号数的最大值 65535,传参时会发生截断。以 320×480 全屏填色为例,总像素数为 153600,前两块都会同时遇到“源缓冲区越界”和“Size 超范围”问题。
这类错误通常不会被编译器判定为语法错误,所以视频中能够得到 0 Error(s),也能够生成并下载 HEX,但运行结果仍然错误。配套工程后来把填充改成每次只从 color地址发送 2 字节,并通过循环重复 num1次;这是第 205 课的内容,不能倒填成第 204 课已经完成的代码。
3.2 为什么图片发送可以直接使用 pic
LCD_ShowPicture()与纯色填充不同。图片数组本来就按顺序保存了所有像素字节,因此:
图片总字节数 = length × width × 2
num从一开始就是字节数,不是像素数;pic也确实指向一段连续、足够长的图片数据。所以该处传 pic和 num1,不需要再乘 2:
HAL_SPI_Transmit(&hspi1, pic, num1, 100);
以 320×480 的 RGB565 图片为例,总数据量为:
320 × 480 × 2 = 307200 字节
课堂代码把它分成 4 个 65534 字节整块和最后 45064 字节。使用 65534 而不是更大的数,既不超过 uint16_t Size的范围,也保持偶数边界,不会把一个 2 字节像素拆在奇数字节位置。
四、寻找发送接口
4.1 例题1:LCD串行数据写入函数
单字节命令和数据统一走 LCD_Writ_Bus()。准确接口名是 HAL_SPI_Transmit(),原笔记中的“right bus”“router”等均是对 LCD_Writ_Bus()或调用链的误识别。
LCD_Writ_Bus()每发送一个字节都会拉低再拉高 CS,调用简单但 GPIO 切换开销较大;LCD_Fill()和 LCD_ShowPicture()发送大块像素时则在整个数据块外统一控制 CS,避免每个字节都重新片选。
4.2 例题2:LCD写入数据函数
必须先判断“数量变量”的单位,再决定是否乘 2:
| 场景 | 数据源 | num1的单位 | 发送长度 |
|---|---|---|---|
LCD_Fill() | 一个 16 位颜色值 | 像素/颜色个数 | 每个颜色 2 字节,但还必须真正重复提供该颜色,不能从单元素数组后面连续读 |
LCD_ShowPicture() | 完整的 u8图片数组 | 字节数 | 直接发送 num1字节 |
强制转换 (uint8_t *)&color1只改变编译器看待该地址的指针类型,并不会扩大数组、复制数据或改变内存内容。把一个 2 字节对象“转换成字节指针”与“拥有 2 * num1字节的有效缓冲区”是两回事。
4.3 例题3:LCD显示范围设置与数据传输
LCD_Address_Set(xsta, ysta, xend - 1, yend - 1)先告诉 LCD 接下来要写入的矩形窗口,随后连续到达的像素会依次填入该窗口。这里终点减 1,是因为调用者传入的 xend/yend被当作开区间终点,而 LCD 地址窗口使用包含式终点。
分块变量的职责应分清:
num:还没有安排发送的数据量;num1:当前这一块的数据量;t:是否还需要下一轮;65534:本课选用的单块上限。
视频把旧 SPI、DMA配置与等待代码注释掉时,保留了像素数计算、窗口设置和分块循环,因为这些属于显示业务;被移除的是与标准库外设实现绑定的部分。选择注释范围时必须核对花括号,少注释或多注释一个括号都会破坏控制结构。
4.4 例题4:硬件SPI发送数据修改
旧代码直接操作 SPI1->CR1、调用 SPI_Cmd()并把 DMA 目标设为 SPI1->DR,这些都依赖标准库及其寄存器配置。本节将这些路径停用,改用 CubeMX 已初始化的 hspi1和 HAL 接口。
这里不是把“硬件 SPI”改成“软件 SPI”。两种代码最终仍然使用 STM32 的 SPI1硬件外设;改变的是软件驱动层:从标准外设库加自定义 DMA封装,迁移为 HAL 的阻塞式 SPI发送。
4.5 例题5:图形绘制函数
重新编译后,剩余错误定位到约 511 行的 LCD_ShowPicture()。该函数原先也使用 MYDMA_Config()、SPI_I2S_DMACmd()、MYDMA_Enable()和 DMA完成标志,必须把整条旧 DMA路径一起停用,而不是只替换其中某一个函数名。
虽然标题写“图形绘制”,视频实际处理的是图片数组的批量发送函数 LCD_ShowPicture(),不是画点、画线或画圆函数。
4.6 例题6:硬件SPI发送数据修改为库函数SPI
pic作为数组形参进入函数后可作为首元素地址传给 HAL_SPI_Transmit();它的元素类型是 u8,与接口要求的 const uint8_t *兼容。num已经在初始化时乘过 2,所以当前块长度只传 num1,不能再次乘 2。
每发送完一个完整块,pic += 65534把读取位置推进到下一块。最后一轮执行后循环立即结束,后续不会再读取这个已经推进的指针。课堂还把旧的 SPI1_ReadWriteByte(0xff)停用:那一字节是原 DMA版本为触摸响应保留的收尾操作;当前已经不走该 DMA路径,是否仍需要它应留待触摸迁移和实机现象验证,不能凭猜测保留或删除。
五、烧录代码,查看效果
视频最后的实际结果如下:
| 检查项 | 结果 | 能说明什么 |
|---|---|---|
| Keil 编译 | 0 Error(s), 4 Warning(s) | 语法和链接通过,但不证明缓冲区长度正确 |
| 生成 HEX | 成功 | 工程能够产出可烧录文件 |
| ST-Link 下载 | Erase、Program、Verify 均成功 | 程序已经写入 MCU Flash |
| 按下复位 | 程序重新运行 | 排除了“下载后未启动”这一表面因素 |
| LCD 显示 | 画面不正确 | 迁移仍有运行时逻辑问题 |
四个警告主要来自 LCD_CS_Clr()、LCD_CS_Set()等宏把字面量 0/1传给参数类型为 GPIO_PinState的 HAL_GPIO_WritePin(),Keil 报告枚举与其他类型混用。它们不是本次屏幕异常的直接证据,但警告不应等同于“可以永远忽略”。
遇到“能编译、能下载、屏幕却不对”时,应沿数据通路逐层排查:
- 确认
Reset and Run或手动复位已经让新程序真正运行; - 检查 RES、DC、BLK、CS1 的端口、引脚和有效电平;
- 检查 SPI 模式、数据位宽、时钟频率和 MSB/LSB 配置;
- 检查
pData指向的缓冲区是否至少具有Size对应的有效长度; - 检查长度单位究竟是字节、16 位数据还是像素;
- 对比标准库 DMA 的“地址是否递增”语义,不能只按总字节数机械替换;
- 必要时使用逻辑分析仪观察 CS、SCK、MOSI 和 DC,确认命令与像素数据是否按预期发出。
本课最终应记住的不是“把报红代码换成 HAL 函数就完成了”,而是:移植必须同时保持接口、数据宽度、缓冲区长度、地址递增方式和片选时序的语义等价。 第 204 课只完成了第一轮替换并暴露出运行问题,第 205 课才继续修正 LCD_Fill()并验证显示效果。