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

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

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);

四个实参的含义如下:

实参含义为什么这样传
&hspi1SPI1 句柄地址接口需要 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 报告枚举与其他类型混用。它们不是本次屏幕异常的直接证据,但警告不应等同于“可以永远忽略”。

遇到“能编译、能下载、屏幕却不对”时,应沿数据通路逐层排查:

  1. 确认 Reset and Run或手动复位已经让新程序真正运行;
  2. 检查 RES、DC、BLK、CS1 的端口、引脚和有效电平;
  3. 检查 SPI 模式、数据位宽、时钟频率和 MSB/LSB 配置;
  4. 检查 pData指向的缓冲区是否至少具有 Size对应的有效长度;
  5. 检查长度单位究竟是字节、16 位数据还是像素;
  6. 对比标准库 DMA 的“地址是否递增”语义,不能只按总字节数机械替换;
  7. 必要时使用逻辑分析仪观察 CS、SCK、MOSI 和 DC,确认命令与像素数据是否按预期发出。

本课最终应记住的不是“把报红代码换成 HAL 函数就完成了”,而是:移植必须同时保持接口、数据宽度、缓冲区长度、地址递增方式和片选时序的语义等价。 第 204 课只完成了第一轮替换并暴露出运行问题,第 205 课才继续修正 LCD_Fill()并验证显示效果。