C语言嵌入式开发软件架构篇:硬件驱动模块开发

什么是硬件驱动模块

在嵌入式系统里,硬件驱动模块就像一座桥梁,一端连接着硬件设备,另一端连接着上层软件 ,在整个系统中起着承上启下的作用。简单来说,硬件驱动模块是一段程序代码,专门负责与硬件设备进行交互,实现对硬件设备的控制和管理。​
我们日常使用的各种电子设备,比如手机、智能手表、工业控制器等嵌入式设备,其内部硬件种类繁多,像处理器、传感器、显示屏、通信模块等。每种硬件都有其独特的工作方式和控制方法,如果没有硬件驱动模块,上层软件想要直接控制这些硬件,几乎是不可能完成的任务。​
以智能手表中的加速度传感器为例,硬件驱动模块负责与这个传感器进行通信,读取它检测到的加速度数据,并将这些数据转换成上层软件能够理解的格式。上层的健康监测软件,通过调用驱动提供的接口,获取加速度数据,进而分析用户的运动步数、运动强度等信息。要是没有对应的硬件驱动模块,传感器检测到的数据就无法被正确读取和处理,智能手表的运动监测功能也就无法实现。​

为何要用C语言开发硬件驱动模块


高效的硬件操作能力​

C 语言具备高效的硬件操作能力,它允许开发者直接操作硬件寄存器和内存。以 GPIO(通用输入输出)驱动开发为例,我们经常需要控制引脚的电平状态,以此来实现对外部设备的控制,如点亮或熄灭 LED 灯、读取按钮的状态等。在 C 语言中,可以通过定义指向 GPIO 寄存器的指针,直接对寄存器进行读写操作,从而准确地控制引脚电平。假设 GPIO 端口的寄存器地址为 0x40010800,可以定义一个指向该寄存器的指针:volatile unsigned int *GPIO_REG = (volatile unsigned int *)0x40010800;,这里的volatile关键字很重要,它告诉编译器不要对该变量进行优化,因为硬件寄存器的值可能随时被外部硬件改变。之后就可以通过这个指针来控制引脚电平,比如将引脚设置为高电平:*GPIO_REG |= (1 << pin_number);,其中pin_number表示要控制的引脚编号。这种直接操作寄存器的方式,避免了复杂的函数调用和中间层的开销,代码执行效率极高,能够快速响应硬件的需求。

良好的可移植性​

C 语言拥有良好的可移植性, 采用C 语言开发的硬件驱动模块能够在不同的硬件平台上复用,降低了开发成本和工作量。无论在何种硬件平台上,只要遵循 C 语言标准,这些函数和数据类型的行为都是一致的 。同时,C 语言的语法简洁、灵活,不依赖于特定的硬件平台或操作系统,这为代码的跨平台移植提供了便利。​
例如,在开发 UART(通用异步收发传输器)驱动时,不同型号的单片机可能在硬件寄存器的地址、配置方式上存在差异,但我们可以利用 C 语言的条件编译指令(如#ifdef、#endif)和宏定义,根据不同的硬件平台来配置和初始化 UART 模块。以 STM32 和 MSP430 这两款常见的单片机为例,虽然它们的 UART 寄存器地址和配置方式不同,但我们可以通过编写如下的 C 语言代码框架,实现相似的 UART 驱动功能:​

#ifdef STM32​
// STM32平台的UART初始化代码​
void uart_init() {​
// 配置STM32的UART寄存器,如设置波特率、数据位、停止位等​
// 假设使用USART1​
RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);​
USART_InitTypeDef USART_InitStructure;​
USART_InitStructure.USART_BaudRate = 9600;​
USART_InitStructure.USART_WordLength = USART_WordLength_8b;​
USART_InitStructure.USART_StopBits = USART_StopBits_1;​
USART_InitStructure.USART_Parity = USART_Parity_No;​
USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None;​
USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;​
USART_Init(USART1, &USART_InitStructure);​
USART_Cmd(USART1, ENABLE);​
}​
#elif defined(MSP430)​
// MSP430平台的UART初始化代码​
void uart_init() {​
// 配置MSP430的UART寄存器,如设置波特率、数据位、停止位等​
// 假设使用UCA0​
UCA0CTL1 |= UCSWRST;​
UCA0CTL1 |= UCSSEL_2; // 选择SMCLK​
UCA0BR0 = 104; // 1MHz 9600​
UCA0BR1 = 0;​
UCA0MCTL = UCBRS_1;​
UCA0CTL1 &= ~UCSWRST;​
UCA0IE |= UCRXIE; // 使能接收中断​
}​
#endif​


在上述代码中,通过#ifdef和#elif defined指令,根据不同的硬件平台(STM32 或 MSP430),编写相应的 UART 初始化代码。这样,在不同的硬件平台上,只需简单地修改条件编译指令和对应的硬件相关代码,就可以复用大部分的驱动逻辑,提高了代码的可移植性。​

硬件驱动模块开发流程

需求与硬件手册

开发人员需要详细阅读硬件手册和数据表,了解硬件的功能、寄存器配置、接口规范等关键信息。理解硬件的内部架构,包括其各个子模块、寄存器和中断机制等。还要确驱动模块需要实现哪些具体功能,以及它需要支持哪些硬件设备。
接下来,就是研读硬件手册。了解硬件接口、寄存器定义、电气特性等。以 SPI 接口的 Flash 存储器驱动开发来说,从硬件手册中,可以了解到 SPI 接口的引脚定义,像 MOSI(主设备输出,从设备输入)、MISO(主设备输入,从设备输出)、SCK(串行时钟)、CS(片选信号)分别连接到微控制器的哪些引脚。同时,还能获取到寄存器的详细信息,比如状态寄存器用于指示 Flash 的工作状态,命令寄存器用于接收各种操作命令(如读数据、写数据、擦除操作等) ,只有深入理解硬件操作信息,才能对硬件进行配置和控制。

编写驱动代码

一、初始化函数编写


初始化函数的作用是对硬件设备进行初始配置,使其处于可工作状态。以 SPI 驱动初始化为例,我们来看一段基于 STM32 的 C 语言代码:​

#include "stm32f10x.h"​
void SPI_InitFunction(void) {​
SPI_InitTypeDef SPI_InitStructure;​
GPIO_InitTypeDef GPIO_InitStructure;​
​
// 使能SPI和GPIO时钟​
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1 | RCC_APB2Periph_GPIOA, ENABLE);​
​
// 配置SPI的GPIO引脚​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_6 | GPIO_Pin_7;​
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;​
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
​
// 配置SPI参数​
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;​
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;​
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;​
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;​
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;​
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;​
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_16;​
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;​
SPI_InitStructure.SPI_CRCCalculation = SPI_CRCCalculation_Disable;​
SPI_Init(SPI1, &SPI_InitStructure);​
​
// 使能SPI​
SPI_Cmd(SPI1, ENABLE);​
}​
​

在这段代码中,首先使能了 SPI1 和 GPIOA 的时钟,然后对 SPI1 的 GPIO 引脚(PA5、PA6、PA7)进行配置,使其工作在复用推挽输出模式。接着,对 SPI1 的工作模式、数据大小、时钟极性、时钟相位、波特率预分频器等参数进行设置,最后使能 SPI1。通过这样的初始化配置,SPI 接口就可以正常工作,为主设备与从设备之间的数据传输做好准备 。​
file_operations 结构体构建:在 Linux 驱动开发中,file_operations 结构体是字符设备驱动的核心部分,它定义了一系列函数指针,用于实现对字符设备的各种操作。当用户程序对设备文件进行操作(如打开、读取、写入等)时,内核会根据 file 结构体中的 file_operations 指针来调用相应的操作函数 。​
以一个简单的字符设备驱动为例,假设我们开发一个用于控制 LED 的字符设备驱动,下面是构建 file_operations 结构体的示例代码:​

#include <linux/module.h>​
#include <linux/kernel.h>​
#include <linux/fs.h>​
#include <linux/init.h>​
#include <linux/cdev.h>​
#include <asm/uaccess.h>​
​
#define LED_MAJOR 250​
#define LED_MINOR 0​
#define LED_DEV_NAME "led_dev"​
​
static dev_t devno;​
static struct cdev led_cdev;​
static struct class *led_class;​
static struct device *led_device;​
​
// 打开设备函数​
static int led_open(struct inode *inode, struct file *filp) {​
// 初始化LED相关操作,如设置GPIO为输出模式等​
printk(KERN_INFO "LED device opened.\n");​
return 0;​
}​
​
// 读取设备函数​
static ssize_t led_read(struct file *filp, char __user *buf, size_t count, loff_t *offt) {​
// 从LED设备读取数据,这里简单返回0​
return 0;​
}​
​
// 写入设备函数​
static ssize_t led_write(struct file *filp, const char __user *buf, size_t count, loff_t *offt) {​
char user_buf[10];​
if (copy_from_user(user_buf, buf, count)) {​
return -EFAULT;​
}​
// 根据写入的数据控制LED,如点亮或熄灭​
if (user_buf[0] == '1') {​
// 点亮LED的操作​
printk(KERN_INFO "LED turned on.\n");​
} else if (user_buf[0] == '0') {​
// 熄灭LED的操作​
printk(KERN_INFO "LED turned off.\n");​
}​
return count;​
}​
​
// 释放设备函数​
static int led_release(struct inode *inode, struct file *filp) {​
printk(KERN_INFO "LED device released.\n");​
return 0;​
}​
​
// file_operations结构体实例化​
static const struct file_operations led_fops = {​
.owner = THIS_MODULE,​
.open = led_open,​
.read = led_read,​
.write = led_write,​
.release = led_release,​
};​
​

在上述代码中,定义了 led_open、led_read、led_write、led_release 等函数,分别用于实现设备的打开、读取、写入和释放操作。然后,将这些函数指针赋值给 file_operations 结构体 led_fops。在驱动初始化时,会将这个结构体注册到内核中,这样用户程序就可以通过设备文件对 LED 设备进行相应的操作 。​

二、中断处理函数编写


下面是一个按键中断处理函数的编写示例,假设使用 STM32 微控制器,按键连接到 PA0 引脚:​

#include "stm32f10x.h"​
​
void EXTI0_IRQHandler(void) {​
if (EXTI_GetITStatus(EXTI_Line0)!= RESET) {​
// 处理按键按下的操作,如读取按键状态、执行相应功能等​
if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0) {​
// 按键按下​
// 在这里添加按键按下后的处理代码,比如控制LED闪烁、发送数据等​
printk(KERN_INFO "Button pressed.\n");​
}​
// 清除中断标志位​
EXTI_ClearITPendingBit(EXTI_Line0);​
}​
}​

在这段代码中,首先判断 EXTI_Line0(对应 PA0 引脚的中断线)是否有中断发生。如果有中断发生,读取 PA0 引脚的输入数据,判断按键是否被按下。如果按键按下,就可以在相应的代码块中添加具体的处理逻辑。最后,清除中断标志位,以便下次能够正确响应中断 。

测试与调试

在完成驱动代码编写后,需要在实际硬件上对驱动进行测试与调试,以确保其能够正常工作。可以使用示波器来监测硬件信号,比如在 SPI 驱动测试中,通过示波器观察 SPI 的时钟信号(SCK)、数据信号(MOSI、MISO),检查信号的波形是否正常,是否符合 SPI 通信协议的规范。
在调试过程中,串口输出调试信息是一种常用的方法。通过在驱动代码中添加打印语句,将关键变量的值、函数的执行状态等信息通过串口输出到上位机,方便我们观察和分析程序的运行情况。例如,在上述 LED 驱动的 write 函数中,添加printk(KERN_INFO "Received data: %c\n", user_buf[0]);语句,这样当有数据写入 LED 设备时,就可以通过串口看到接收到的数据。利用 JTAG/SWD 调试器进行单步调试也是非常有效的手段。调试器可以连接到目标板,通过与目标板上的调试接口(如 JTAG 接口、SWD 接口)通信,实现对程序的单步执行、设置断点、查看寄存器和内存内容等操作。在单步调试过程中,可以逐行执行驱动代码,观察每一步的执行结果,从而定位和解决问题 。

驱动 OLED 显示屏

硬件连接​

选用常见的 0.96 寸 SPI 接口 OLED 显示屏与 STM32 开发板进行连接。OLED 显示屏通过 SPI 总线与 STM32 进行通信。具体接线方式如下:​

编写驱动代码

一、初始化 GPIO 口


OLED 显示屏的控制引脚需要配置为输出模式,以便 STM32 能够向其发送信号。以配置 PA3、PA4、PA5、PA6、PA7 为例,代码如下:​

#include "stm32f10x.h"​
​
void GPIO_Init(void)​
{​
GPIO_InitTypeDef GPIO_InitStructure;​
// 使能GPIOA时钟​
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);​
​
// 配置PA3为推挽输出模式,用于OLED复位​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3;​
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;​
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
​
// 配置PA4为推挽输出模式,用于OLED片选​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
​
// 配置PA5为复用推挽输出模式,用于SPI时钟​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5;​
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
​
// 配置PA6为推挽输出模式,用于数据/命令选择​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
​
// 配置PA7为复用推挽输出模式,用于SPI数据输出​
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7;​
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;​
GPIO_Init(GPIOA, &GPIO_InitStructure);​
}​


二、SPI 初始化


SPI 通信协议用于 OLED 控制,其原理是通过时钟线(SCK)同步数据传输,主机(STM32)通过主输出从输入线(MOSI)向从机(OLED)发送数据,从机通过主输入从出线(MISO)向主机发送数据(在本 OLED 控制中,MISO 未使用) 。初始化参数设置如下:​

void SPI_Init(void)​
{​
SPI_InitTypeDef SPI_InitStructure;​
// 使能SPI1时钟​
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE);​
​
// 配置SPI1为主模式,时钟速率为fPCLK2/8​
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex;​
SPI_InitStructure.SPI_Mode = SPI_Mode_Master;​
SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b;​
SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low;​
SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge;​
SPI_InitStructure.SPI_NSS = SPI_NSS_Soft;​
SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8;​
SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB;​
SPI_InitStructure.SPI_CRCPolynomial = 7;​
SPI_Init(SPI1, &SPI_InitStructure);​
​
// 使能SPI1​
SPI_Cmd(SPI1, ENABLE);​
}​


OLED 指令和数据发送函数:发送指令和数据到 OLED 需要根据 OLED 的通信协议来编写函数 。发送指令时,D/C 引脚置低;发送数据时,D/C 引脚置高 。代码如下:​

// 发送一个字节数据到SPI1​
void SPI_SendByte(uint8_t Byte)​
{​
while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);​
SPI_I2S_SendData(SPI1, Byte);​
}​
​
// 发送OLED指令​
void OLED_WriteCmd(uint8_t Cmd)​
{​
GPIO_ResetBits(GPIOA, GPIO_Pin_6); // D/C = 0,发送命令​
GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS = 0,选中OLED​
SPI_SendByte(Cmd);​
GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS = 1,取消选中​
}​
​
// 发送OLED数据​
void OLED_WriteData(uint8_t Data)​
{​
GPIO_SetBits(GPIOA, GPIO_Pin_6); // D/C = 1,发送数据​
GPIO_ResetBits(GPIOA, GPIO_Pin_4); // CS = 0,选中OLED​
SPI_SendByte(Data);​
GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS = 1,取消选中​
}​
​

OLED 初始化函数:初始化 OLED 显示屏需要按照 OLED 的数据手册,依次发送一系列初始化指令 。常见的初始化流程和关键指令如下:​

void OLED_Init(void)​
{​
// 硬件复位OLED​
GPIO_ResetBits(GPIOA, GPIO_Pin_3);​
delay_ms(100);​
GPIO_SetBits(GPIOA, GPIO_Pin_3);​
delay_ms(100);​
​
// 发送初始化指令​
OLED_WriteCmd(0xAE); // 显示关闭​
OLED_WriteCmd(0xD5); // 设置显示时钟分频比/振荡器频率​
OLED_WriteCmd(0x80);​
OLED_WriteCmd(0xA8); // 设置多路复用率​
OLED_WriteCmd(0x3F);​
OLED_WriteCmd(0xD3); // 设置显示偏移​
OLED_WriteCmd(0x00);​
OLED_WriteCmd(0x40); // 设置显示起始行​
OLED_WriteCmd(0xA1); // 设置段重映射​
OLED_WriteCmd(0xC8); // 设置COM输出扫描方向​
OLED_WriteCmd(0xDA); // 设置COM引脚硬件配置​
OLED_WriteCmd(0x12);​
OLED_WriteCmd(0x81); // 设置对比度控制寄存器​
OLED_WriteCmd(0xCF);​
OLED_WriteCmd(0xD9); // 设置预充电周期​
OLED_WriteCmd(0xF1);​
OLED_WriteCmd(0xDB); // 设置VCOMH​
OLED_WriteCmd(0x30);​
OLED_WriteCmd(0xA4); // 设置输出跟随RAM内容​
OLED_WriteCmd(0xA6); // 设置正常显示​
OLED_WriteCmd(0x8D); // 设置电荷泵使能​
OLED_WriteCmd(0x14);​
OLED_WriteCmd(0xAF); // 显示开启​
}​
​

三、显示函数编写​


要显示字符,需要先获取字符的字模数据,然后将字模数据逐字节发送到 OLED 的显存中 。假设已经有一个字符字模数组Font8x16,显示字符函数如下:​

void OLED_ShowChar(uint8_t x, uint8_t y, char ch)​
{​
uint8_t i, j;​
ch -= 32; // 字符ASCII码偏移​
for (i = 0; i < 8; i++)​
{​
OLED_SetCursor(x, y);​
for (j = 0; j < 8; j++)​
{​
OLED_WriteData(Font8x16[ch * 16 + i] & (1 << (7 - j))? 0xFF : 0x00);​
}​
y++;​
}​
}​


显示字符串时,只需依次调用显示字符函数即可。​

void OLED_ShowString(uint8_t x, uint8_t y, char *str)​
{​
while (*str)​
{​
OLED_ShowChar(x, y, *str);​
x += 8;​
if (x > 120)​
{​
x = 0;​
y += 16;​
}​
str++;​
}​
}​


显示图形:显示图形需要将图形的像素数据逐字节发送到 OLED 的显存中 。假设已经有一个图形数据数组image,显示图形函数如下:​

void OLED_ShowImage(uint8_t x, uint8_t y, uint8_t *image)​
{​
uint8_t i, j;​
for (i = 0; i < 8; i++)​
{​
OLED_SetCursor(x, y + i);​
for (j = 0; j < 128; j++)​
{​
OLED_WriteData(image[i * 128 + j]);​
}​
}​
}​


其中,OLED_SetCursor函数用于设置 OLED 的光标位置,代码如下:​

void OLED_SetCursor(uint8_t x, uint8_t y)​
{​
OLED_WriteCmd(0xB0 + y);​
OLED_WriteCmd((x & 0xF0) >> 4 | 0x10);​
OLED_WriteCmd(x & 0x0F);​
}​
​

调试与优化

一、常见问题


(1)OLED 无显示
原因:硬件连接错误是常见原因之一,可能是 OLED 的电源线、数据线、控制线等与 STM32 开发板连接松动或接错引脚;也可能是 OLED 初始化失败,如初始化指令发送错误、时序不正确等;此外,供电不足也可能导致 OLED 无法正常工作,OLED 的工作电压一般为 3.3V,如果供电电压不稳定或低于正常工作电压,就会出现无显示的情况 。​
解决策略:仔细检查硬件连接,对照硬件连接图,逐一检查每根线的连接是否正确、牢固,可使用万用表测量连接线路是否导通;重新检查 OLED 初始化代码,确保初始化指令按照 OLED 数据手册的要求正确发送,注意指令的顺序和参数设置,也可以在初始化代码中添加一些调试信息,通过串口打印出来,以便查看初始化过程是否正常;检查供电电路,确保 3.3V 电源稳定,可使用电源模块为 OLED 单独供电,或者在电源线上添加滤波电容,减少电源波动。​
(2)显示乱码​
原因:数据传输错误可能导致显示乱码,比如 SPI 通信过程中,数据传输速率过快,超过了 OLED 的处理能力,或者 SPI 通信的时钟极性、时钟相位设置错误,导致数据采样错误;另外,字模数据错误也会造成显示乱码,如果使用的字模库与 OLED 的显示方式不匹配,或者字模数据在存储、读取过程中出现错误,就会出现乱码现象 。​
解决策略:调整 SPI 通信的时钟速率,适当降低数据传输速度,确保 OLED 能够正确接收和处理数据,同时检查 SPI 通信的时钟极性(CPOL)和时钟相位(CPHA)设置,使其与 OLED 的要求一致;检查字模数据,确认字模库的正确性,以及字模数据的读取和传输过程是否正确,可以通过在代码中打印字模数据,或者将字模数据与正确的字模表进行对比,来查找错误 。​
(3)屏幕闪烁​
原因:刷新率不足是导致屏幕闪烁的主要原因,如果 OLED 的刷新频率过低,人眼就会察觉到屏幕闪烁;此外,硬件干扰也可能引起屏幕闪烁,比如周围的电磁干扰、电源纹波等,影响了 OLED 的正常工作 。​
解决策略:优化显示代码,提高 OLED 的刷新频率,例如减少不必要的显示操作,合理安排显示任务,确保能够按时完成一帧画面的更新;加强硬件抗干扰措施,在 OLED 的电源线上添加滤波电容,减少电源纹波;在 OLED 的周围布置屏蔽层,减少电磁干扰,也可以对 SPI 通信线路进行屏蔽处理,提高数据传输的稳定性 。

二、性能优化


(1)优化 SPI 通信速度​
调整时钟分频:根据 OLED 的数据手册,了解其支持的最高 SPI 时钟频率,然后在 STM32 的 SPI 初始化代码中,合理设置 SPI 时钟分频器,以提高 SPI 通信速度 。例如,若 OLED 支持的最高时钟频率为 18MHz,而 STM32 的 APB 总线时钟为 72MHz,可以将 SPI 时钟分频设置为 4,使 SPI 时钟频率达到 18MHz 。​
使用 DMA 传输:DMA(直接内存访问)可以在不占用 CPU 资源的情况下,实现数据的快速传输 。在 SPI 通信中使用 DMA,能够大大提高数据传输效率,减少 CPU 等待时间 。以发送数据为例,配置 DMA 将需要发送的数据从内存直接传输到 SPI 的数据寄存器,CPU 可以在 DMA 传输数据的同时执行其他任务 。具体配置步骤如下:​
使能DMA时钟,例如对于 SPI1 使用 DMA1 通道 4,可通过RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE);使能 DMA1 时钟 。​
配置 DMA 通道,包括设置传输方向(存储器到外设)、数据宽度(8 位或 16 位,根据 SPI 配置)、传输模式(单次传输或循环传输等)、优先级等 。以 STM32F103 为例,配置 SPI1 发送数据的 DMA1 通道 4 代码如下:​

DMA_InitTypeDef DMA_InitStructure;​
DMA_InitStructure.DMA_PeripheralBaseAddr = SPI1_DR; // SPI数据寄存器地址​
DMA_InitStructure.DMA_MemoryBaseAddr = (u32)send_buffer; // 发送数据缓冲区地址​
DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 传输方向:存储器到外设​
DMA_InitStructure.DMA_BufferSize = buffer_size; // 传输数据量​
DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址不自增​
DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 存储器地址自增​
DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; // 外设数据宽度为字节​
DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; // 存储器数据宽度为字节​
DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; // 传输模式:正常模式​
DMA_InitStructure.DMA_Priority = DMA_Priority_High; // 优先级:高​
DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 非存储器到存储器模式​
DMA_Init(DMA1_Channel4, &DMA_InitStructure); // 初始化DMA1通道4​
​

使能 DMA 通道,DMA_Cmd(DMA1_Channel4, ENABLE); ,然后在 SPI 发送数据时,启动 DMA 传输 。​
减少内存占用,优化数据结构:在定义变量和数据结构时,尽量使用合适的数据类型,避免使用过大的数据类型浪费内存 。例如,如果只需要存储 0 - 255 之间的整数,使用uint8_t类型即可,而不要使用int类型 ;对于一些标志位,可以使用位域(bit - fields)来定义,以节省内存空间 。比如定义一个包含多个标志位的结构体:​

struct {​
unsigned int flag1 : 1; // 占1位空间​
unsigned int flag2 : 1; // 占1位空间​
unsigned int counter : 7; // 占7位空间​
} status;​
​

合理分配内存:避免在程序运行过程中频繁地进行动态内存分配和释放,因为动态内存分配函数(如malloc和free)会带来额外的开销,并且可能导致内存碎片化 。如果需要使用较大的内存空间,可以在程序开始时一次性分配,然后在整个程序运行过程中重复使用 ;对于局部变量,尽量在栈上分配,而不是在堆上分配,因为栈的分配和释放速度更快 。​
优化代码结构:仔细检查代码,删除那些不再使用或者重复的代码段,这样不仅可以减少代码体积,还能提高代码的可读性和可维护性 。例如,如果在多个地方定义了相同的功能函数,且这些函数的实现完全一样,可以将其合并为一个函数 。​
减少函数调用开销:对于一些简单的函数,如果在代码中被频繁调用,可以考虑将其定义为内联函数(使用inline关键字修饰),这样在编译时,函数体的代码会直接插入到调用处,避免了函数调用的开销,提高了代码执行效率。不过需要注意的是,内联函数会增加代码体积,所以对于复杂的函数,不建议使用内联 。