简介:这是一份以STM32微控制器驱动SSD1322控制芯片OLED屏为核心的完整嵌入式工程源码包,适合正在做显示屏驱动、灰度图形显示或SPI/8080并口通信开发的STM32开发者参考。资源压缩包共76个文件,约285KB,主要包含32个h头文件、31个c源文件、8个s启动汇编文件,以及Keil工程文件、删除工程多余文件脚本和说明文档等,便于直接导入MDK5工程查看、编译和二次修改。内容覆盖GPIO与时钟初始化、SSD1322命令配置、像素灰度数据传输、屏幕刷新以及文本图形绘制等关键环节,同时包含十六级灰度控制逻辑、STM32F10x标准外设库、系统延时和主程序框架,目录划分清晰,适合结合数据手册逐模块阅读。目前已有1080人学习下载,特别适合希望快速理解SSD1322底层驱动逻辑、减少调试时间的中级嵌入式开发者。
1. SSD1322不是SSD1306:STM32驱动OLED要从显存模型重新理解
拿到一块写着 SSD1322 的 OLED 屏,很多人习惯性把以前 SSD1306 的 I2C 驱动改个地址就往上怼,结果无一例外是白屏、花屏、镜像三选一。SSD1322 是 16 级灰阶单色驱动芯片,常见面板分辨率能到 480x128,连 0.91 寸那种 128x32 小屏常用的 SSD1306 也跟它不是一路货——前者是 1bit 每像素,后者是 4bit 每像素,显存组织、SPI 时序、初始化命令完全是另一套。这篇就按 STM32 + HAL 库的常见流程,把 SSD1322 的最小工程、初始化参数、像素打包、C/C++ 封装和排错方法逐项说清楚。刚点亮过小尺寸 OLED 的可以照着做,手上有 480x128 工业屏的也能拿去对参数。
2. 用STM32 HAL库点亮SSD1322的最小SPI工程
2.1 查屏先查三个东西:接口模式、像素格式、行数
SSD1322 本身支持 4 线 SPI、8 位 6800/8080 并口两种主流接口,模块做出来是哪一种,由 PCB 上的 BS0/BS1 电阻决定。别一上来就接线,先把屏的丝印和卖家给的原理图翻出来,确认三件事。
第一是接口模式。4 线 SPI 只有 SCLK、SDIN、DC、CS 加 RES 五根线,并口则是一大把数据线。如果模块被配置成 8 位并口而你只接 SPI 四根线,芯片根本不会理你,现象就是全黑屏,连初始化命令都进不去。
第二是像素格式。SSD1322 的 GDDRAM 物理上是每像素 4bit(16 级灰阶),但 SPI 写入时可以按"每字节一个像素"或"每字节两个像素"两种方式送数。这个由 Remap 命令寄存器的 nibble remap 位决定,常见模块默认是 packed 模式,也就是一个字节的高 4 位是一个像素、低 4 位是另一个像素。画点函数必须按这个来算地址,否则会出现颜色错乱或者图形重复。
第三是行数。SSD1322 最多驱动 128 行,但市面上也有 64 行的面板。MUX 寄存器的值是"行数减一",128 行写 0x7F,64 行写 0x3F。这个值错了,屏幕会出现内容偏移或沿固定行分裂。
想先用 Proteus 仿真省点硬件时间的话,提醒一句:Proteus 里 OLED 模型大多是 SSD1306,SSD1322 的模型很少,仿真通过一上真屏照样踩时序坑,不如直接真板上用逻辑分析仪抓信号。
2.2 CubeMX里SPI和GPIO到底怎么配
用 STM32CubeMX 生成基础工程,配置如下:
- SPI1 或 SPI2 选"Transmit Only Master",因为 SSD1322 的 SPI 只收数据,不需要 MISO。
- 波特率分频按 APB 时钟算,F103 上 SPI 最高 18MHz,F405 可以到 37.5MHz。先按 9MHz 起步,稳定后再拉高。
- CPOL=0、CPHA=0,也就是 SPI Mode 0,这是绝大多数 SSD1322 模块例程用的采样方式。
- DC、CS、RES 三个引脚都配成 GPIO Output,推挽输出,默认电平拉高。
CS 建议用软件 GPIO 控制,不要用 SPI 硬件的 NSS。原因很简单:SSD1322 的 CS 是低有效,一条 SPI 总线上如果以后还要挂别的设备,软件 CS 才能保证互不干扰。即使总线上只有这一片屏,用 GPIO 控 CS 也方便逻辑分析仪抓包时分段看。
CubeMX 生成代码后,记得把 SPI 句柄的时钟使能了再进主循环,HAL 库默认初始化顺序是MX_GPIO_Init()在MX_SPI1_Init()之前,但两个都在main()里且都在while(1)之前,顺序对驱动没有影响。
2.3 最小命令和数据收发函数
SSD1322 的 4 线 SPI 里,DC 引脚决定当前字节是命令还是数据:DC 低电平是命令,高电平是数据。每个字节的传输都要 CS 拉低、DC 定方向、SPI 发送。最基础的收发函数这样写:
static void ssd1322_write_cmd(uint8_t cmd) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_RESET); /* DC低 = 命令 */ HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); } static void ssd1322_write_data(uint8_t data) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); /* DC高 = 数据 */ HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }逻辑说明:HAL_SPI_Transmit 的四个参数依次是 SPI 句柄、发送缓冲区指针、数据长度和超时时间。每次只发一个字节,超时用 HAL_MAX_DELAY 即可,等 SPI 状态机结束再返回,保证下一个字节不会挤占总线。
参数说明:CS 和 DC 的顺序有讲究,先把 CS 拉低再定 DC,让 DC 电平在 SCLK 第一个上升沿之前稳定下来。有些现成代码把 DC 放在 CS 前面,一般也能跑,但在高速 SPI 下不严谨。SCLK 是 HAL 库 SPI 外设自动产生的,写函数里不用管。
注意:这两个函数只发一个字节,如果初始化序列是几百字节的数据,尽量拼成数组后一次性发,减少 GPIO 翻转次数。特别是之后要刷整屏时,绝不能一个像素一次 SPI_Transmit,那会被 HAL 库调度开销拖到只有几帧。
3. SSD1322初始化序列逐条拆解:灰度、偏置、对比度参数
3.1 一组能跑通的初始化命令,以及每条命令在干什么
SSD1322 上电后默认处于锁定状态,只有解除命令锁才能修改关键寄存器。下面这组序列是从常见 480x128 模块初始化流程里整理出来的,直接按顺序发送即可点亮:
static const uint8_t ssd1322_init_seq[] = { 0xFD, 0x12, /* 解锁命令锁,必须最先发 */ 0xA8, 0x7F, /* MUX: 128行 (行数-1=127) */ 0xA0, 0x53, /* Remap: 列重映射+nibble remap,见3.2节 */ 0xA2, 0x00, /* 显示偏移量0 */ 0xB1, 0x22, /* 相位1/相位2长度 */ 0xB3, 0x51, /* 显示时钟分频和振荡频率 */ 0xAB, 0x01, /* 使能内部VDD稳压器 */ 0xB6, 0x04, /* 第二次预充电周期 */ 0xBB, 0x17, /* 预充电电压 */ 0xBE, 0x05, /* VCOMH 电压 */ 0xC1, 0x3F, /* 对比度电流 */ 0xA4, /* 正常显示,A5是全亮测试,A6是反显 */ 0xAF /* 打开显示 */ }; void ssd1322_init(void) { RES_GPIO_Port->BSRR = RES_Pin; /* 先拉高 */ HAL_Delay(10); RES_GPIO_Port->BRR = RES_Pin; /* 拉低复位 */ HAL_Delay(10); RES_GPIO_Port->BSRR = RES_Pin; /* 拉高释放 */ HAL_Delay(50); for (uint16_t i = 0; i < sizeof(ssd1322_init_seq); i++) { ssd1322_write_cmd(ssd1322_init_seq[i]); } }逻辑说明:0xFD 0x12 是解锁命令;0xA8 0x7F 设 MUX,必须等于物理行数减一;0xA0 是 Remap,决定列扫描方向、行扫描方向和像素打包方式;0xB1、0xB3、0xBB、0xBE 这四条分别是相位时间、时钟分频、预充电电压和 VCOMH,直接决定屏幕亮度和均匀度;0xC1 是整体对比度电流,从 0x00 到 0xFF 可调。
参数说明:里面最容易出错的是 0xA0 0x53。bit0 和 bit1 控制列方向和段重映射,bit4 位置控制 nibble remap。0x53 是很多模块的开箱值,但如果你用的是 128x128 或者定制的 480x128 屏,屏厂参考代码里 0xA0 后面可能跟的是 0x5B 甚至 0x13。同一片屏不同批次方向也可能反,画出来左右镜像时优先改这条。
3.2 亮度四件套的配合方法
SSD1322 的亮度用一条命令调不出理想效果,要靠下面四个寄存器配合:
| 命令 | 典型值 | 作用 | 调亮度时的策略 |
|---|---|---|---|
| 0xAB | 0x01 | 内部 VDD 稳压器开关 | 模块供电正常就别动,外接屏要核对规格 |
| 0xBB | 0x17 | 预充电电压 | 偏低时屏幕发暗,偏高会看到竖条纹 |
| 0xBE | 0x05 | VCOMH 电压 | 影响对比度和残影,调高能压灰阶噪声 |
| 0xC1 | 0x3F | 对比度电流 | 第一个该动的地方,直接乘系数 |
常见的调亮度做法是:先把 0xC1 从 0x3F 往上加到 0x7F,不够再调 0xBB。VCOMH 0xBE 一般固定在 0x05 附近,动它之前先确认面板规格书给的范围。记住 0xBB 和 0xBE 调得太大不会变白,而是变花,OLED 的"白"是灰阶电流堆出来的,不是电压堆出来的。
测试 0xA5 全亮命令有个技巧:发 0xAF 开显示之后,立刻发 0xA5,如果整屏均匀发亮,说明偏置和驱动电压基本正常;如果只有局部亮或亮度不均,问题大概率在 0xBB/0xBE,而不是你后面要画的像素数据。
3.3 用0x15/0x75/0x5C定位显存写入窗口
SSD1322 没有像 SSD1306 那样的自动页递增模式,允许你通过设置窗口来限定写入范围。三条命令配合使用:
void ssd1322_set_window(uint8_t col_start, uint8_t col_end, uint8_t row_start, uint8_t row_end) { ssd1322_write_cmd(0x15); ssd1322_write_data(col_start); ssd1322_write_data(col_end); ssd1322_write_cmd(0x75); ssd1322_write_data(row_start); ssd1322_write_data(row_end); ssd1322_write_cmd(0x5C); /* 写显存命令,之后连续发数据 */ }逻辑说明:0x15 是列起始与结束地址,0x75 是行起始与结束地址。列地址的单位不是像素,而是字节——在 packed 模式下,一个字节对应两个像素,所以 480 列面板的列地址范围是 0x00 到 0xEF,共 240 字节。行地址范围 0x00 到 0x7F,对应 128 行。0x5C 之后发送的所有数据字节都会被写入当前窗口,每写一字节内部列地址自动加一。
参数说明:写完整个窗口的最后一个字节后,内部地址停在窗口末尾。如果还想再写,要重新发 0x15/0x75/0x5C,不能默认地址自动回到起点。这就是全屏刷新必须每帧重设窗口的原因。想只刷某个局部,比如做一个指针式电子钟只更新时间数字区域,把窗口坐标收缩到那一小块,刷新数据量可以降一个数量级。
4. 从画点到刷新:SSD1322帧缓冲设计与按行刷屏
4.1 4bit像素的画点计算
SSD1322 每个像素 4bit,灰度值 0 到 15。如果模块工作在 packed 模式,一字节两像素,画点函数需要根据 x 坐标的奇偶决定写高 4 位还是低 4 位:
#define SCREEN_WIDTH 480 #define SCREEN_HEIGHT 128 #define BYTES_PER_ROW (SCREEN_WIDTH / 2) /* 480像素 -> 240字节 */ void ssd1322_set_pixel(uint8_t *fb, uint16_t x, uint16_t y, uint8_t gray) { if (x >= SCREEN_WIDTH || y >= SCREEN_HEIGHT) return; if (gray > 15) gray = 15; uint16_t byte_index = y * BYTES_PER_ROW + (x >> 1); if (x & 1) { fb[byte_index] = (fb[byte_index] & 0xF0) | gray; /* 低4位 */ } else { fb[byte_index] = (fb[byte_index] & 0x0F) | (gray << 4); /* 高4位 */ } }逻辑说明:y 乘以 240 得到行首字节偏移,x 右移一位得到本行列对中的字节序号。x 为偶数时写高 4 位并保留原有低 4 位,x 为奇数时写低 4 位并保留高 4 位。这个函数只操作内存,不直接写屏,画完一整帧或一行后再整块搬给 SSD1322。
注意:如果你把 Remap 的 nibble 方向设反了,画出来的效果是横向两个像素互换,会出现左右镜像中的一种特殊形态——文字变成镜面但每个字内部的笔画顺序没乱。遇到这种情况不要改画点函数,回去改 0xA0 的 nibble remap 位。
4.2 全屏缓冲要30KB:F103放不下怎么办
480x128 的屏,4bit 每像素,全屏显存计算:480 x 128 / 2 = 30720 字节,约 30KB。这是压垮很多人的第一根稻草——STM32F103C8 只有 20KB SRAM,连一块全屏缓冲都塞不下。常见屏幕的缓冲占用如下:
| 面板分辨率 | packed 模式缓冲 | F103C8 是否够用 |
|---|---|---|
| 480x128 | 30KB | 不够 |
| 128x128 | 8KB | 勉强够,需省着用 |
| 256x64 | 8KB | 够 |
| 128x64 | 4KB | 够 |
处理方式有两种。第一种叫全量帧缓冲,适合 STM32F4 这种 SRAM 充足的,直接在内存里维护整帧,画完一次刷完,代码简单。第二种叫行缓冲或区域缓冲,适合 F103,只留一行或一个局部窗口的缓冲。
做行缓冲时画图逻辑会反过来:不是先画完再刷,而是边画边刷。自定义一个局部坐标系,先把图形光栅化到 240 字节行缓冲,然后立刻设置窗口为当前行,发给 SSD1322。
4.3 按行刷新与DMA后台传输
按行刷新的核心是每次都把窗口压缩成一行,然后只传一行数据:
void ssd1322_flush_row(const uint8_t *row_buf, uint8_t row) { ssd1322_set_window(0x00, BYTES_PER_ROW - 1, row, row); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); HAL_SPI_Transmit(&hspi1, (uint8_t *)row_buf, BYTES_PER_ROW, HAL_MAX_DELAY); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }逻辑说明:先用 0x15/0x75 把窗口锁定在指定的一行,然后 CS 拉低、DC 拉高,一次性发送 240 字节。这个函数每次只刷一行。全屏刷新就是循环 128 次,总耗时约等于 240 字节 x 128 行除以 SPI 速率。
实际传输时间在 18MHz SPI 下大约 13 到 14 毫秒,理论帧率能到 70fps 左右,但这是纯硬件传输时间。轮询方式下 CPU 会全程被占用,stm32f1 那点主频还要做图形计算的话,帧率会掉到 30fps 以下。更常用的做法是把 HAL_SPI_Transmit 换成 HAL_SPI_Transmit_DMA,发送期间 CPU 去算下一行,用 DMA 完成回调置一个标志位,算好一行就等上一行传完再发:
volatile uint8_t spi_busy = 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { spi_busy = 0; } } void ssd1322_flush_row_dma(const uint8_t *row_buf, uint8_t row) { while (spi_busy); /* 等上一行发完 */ spi_busy = 1; ssd1322_set_window(0x00, BYTES_PER_ROW - 1, row, row); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)row_buf, BYTES_PER_ROW); }参数说明:spi_busy 是标志位,DMA 回调里清零。row_buf 必须保持有效直到发送完成,不能是函数里的局部变量,否则 DMA 读到一半栈被覆盖。这个方案里 CPU 开销最大的只剩窗口命令那几个字节的 GPIO 翻转,整屏 128 行都能在后台跑完。
5. 用C++重写STM32上的SSD1322驱动:模板与封装
5.1 把SPI句柄和引脚抽象成类成员
C 语言的驱动写久了会发现一个问题:如果项目里有两片屏,或者同一驱动要在 F103 和 F405 两个工程里复用,C 代码就得有一堆_port和_pin参数到处传。C++ 封装的价值在这里体现——把 SPI 句柄、DC/CS/RES 引脚、屏幕分辨率全部放进一个类,实例化时传参:
class SSD1322 { public: SSD1322(SPI_HandleTypeDef &spi, GPIO_TypeDef *dc_port, uint16_t dc_pin, GPIO_TypeDef *cs_port, uint16_t cs_pin, GPIO_TypeDef *res_port, uint16_t res_pin) : spi_(spi), dc_port_(dc_port), dc_pin_(dc_pin), cs_port_(cs_port), cs_pin_(cs_pin), res_port_(res_port), res_pin_(res_pin) {} void init() { /* 复位时序 + 初始化命令序列,同第3章 */ } void setContrast(uint8_t level) { writeCmd(0xC1); writeData(level); } void setWindow(uint8_t col_start, uint8_t col_end, uint8_t row_start, uint8_t row_end) { writeCmd(0x15); writeData(col_start); writeData(col_end); writeCmd(0x75); writeData(row_start); writeData(row_end); writeCmd(0x5C); } void writePixels(const uint8_t *data, uint16_t len) { HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_RESET); HAL_GPIO_WritePin(dc_port_, dc_pin_, GPIO_PIN_SET); HAL_SPI_Transmit(&spi_, const_cast<uint8_t *>(data), len, HAL_MAX_DELAY); HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_SET); } private: void writeCmd(uint8_t cmd) { HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_RESET); HAL_GPIO_WritePin(dc_port_, dc_pin_, GPIO_PIN_RESET); HAL_SPI_Transmit(&spi_, &cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_SET); } void writeData(uint8_t data) { HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_RESET); HAL_GPIO_WritePin(dc_port_, dc_pin_, GPIO_PIN_SET); HAL_SPI_Transmit(&spi_, &data, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(cs_port_, cs_pin_, GPIO_PIN_SET); } SPI_HandleTypeDef &spi_; GPIO_TypeDef *dc_port_; uint16_t dc_pin_; GPIO_TypeDef *cs_port_; uint16_t cs_pin_; GPIO_TypeDef *res_port_; uint16_t res_pin_; };逻辑说明:构造函数只保存引用和指针,不做任何硬件访问。SPI 外设的初始化由 HAL 的 MX_SPI1_Init() 完成,那是在 main() 里执行的,而全局对象的构造发生在 main() 之前。如果你把 init() 放进构造函数,初始化顺序一定是错的。
使用方式:SSD1322 display(hspi1, DC_GPIO_Port, DC_Pin, CS_GPIO_Port, CS_Pin, RES_GPIO_Port, RES_Pin);这一行把硬件细节全部收进对象,后续所有调用都从display.这个作用域走。
5.2 用模板参数固定分辨率和像素格式
C++ 封装的第二个好处是模板可以省掉大量运行时分支。像素打包方式、窗口上限、行缓冲大小在编译期就定死了,不会有任何 if 判断:
template <uint16_t Width, uint16_t Height, bool NibblePacked = true> class SSD1322T { public: static constexpr uint16_t kBytesPerRow = NibblePacked ? (Width / 2) : Width; static constexpr uint16_t kMaxColByte = kBytesPerRow - 1; static constexpr uint16_t kMaxRow = Height - 1; void fill(uint8_t gray) { /* 用 kBytesPerRow 生成一整行原始数据,再逐行刷 */ } };使用时SSD1322T<480, 128> display(...)和SSD1322T<128, 128> display(...)是两个完全独立的类型,编译器会给每一套参数生成特化代码,不会出现"先判断分辨率再走分支"的开销。constexpr 常量直接参与数组定义,比宏定义多一层类型检查。
5.3 C++驱动最常见的三个坑
第一个坑是全局对象。前面提过构造函数在 main() 之前执行,访问外设全是未初始化状态,所以驱动对象通常只做参数存储,init() 单独调用。
第二个坑是异常和 iostream。SSD1322 驱动里用不到这些,编译时关掉能省大量 flash:GCC 工具链加-fno-exceptions -fno-rtti,MDK 的 AC6 则在 C/C++ 选项里关掉 Exceptions。千万别为了调试方便在驱动里引入 cout,那个重定向工作量和占用的 flash 足够你手写十个 printf。
第三个坑是模板代码膨胀。每实例化一种分辨率,SSD1322T 的成员函数都会生成一份拷贝,两片不同大小的屏同时在用,代码量接近翻倍。F103 的 flash 比较紧张时,用普通类加成员变量比用模板稳。
6. 白屏、花屏、镜像:SSD1322驱动调试三板斧
6.1 白屏先分驱动还是面板
板子刚焊好,屏上电全黑,第一步不是改初始化序列,而是发 0xA5 全亮命令。在 0xAF 开显示后追加一条ssd1322_write_cmd(0xA5);,如果整屏均匀发亮,说明 SSD1322 的电源、偏置、行驱动列驱动都在工作,问题出在显存数据或 Remap 配置。如果 0xA5 都没反应,检查 RES 引脚有没有正常脉冲、CS 有没有被别的东西拉死、SPI 波特率是否超过芯片上限。
6.2 花屏查Remap,镜像查列起始地址
花屏分两类。一类是画面破成几段,通常是 MUX 行数不对,128 行的屏写了 0x3F。另一类是图形整体错位但有规律,比如每隔 64 行重复一次,这多半是 0xA2 显示偏移和 0x15 列窗口不匹配。左右镜像优先看 0xA0 的列扫描方向位,上下颠倒则看行扫描方向位。每次只改一个 bit,开 0xA4 恢复到正常模式再对比,别同时调三四个参数。
6.3 逻辑分析仪看时序比看代码更省时间
SPI 模式下最隐蔽的问题不是数据内容,而是 DC 信号时序。逻辑分析仪抓 CS、DC、SCLK 三条线,重点看 CS 拉低到 DC 稳定之间有没有足够建立时间,以及 CS 是否在整个传输期间保持低电平。如果你的 ST-Link 出现在设备管理器里是个带黄色感叹号的 Virtual COM Port,那是 PC 端驱动问题,和屏幕无关,先把下载链路修好再查屏。每次换一片新的 SSD1322 模块,把 0xA5 全亮测试作为第一项,再逐条加回初始化命令,哪一步开始花,问题就在哪条命令上。
本文还有配套的精品资源,点击获取