1 前言
应用层的程序比较简单,不像底层驱动包含的知识太多,会C语言就能看懂。官方的程序为了兼容设备分了很多层,有很多用不上的代码,所以我删除了一些代码,又重写了一部分代码的逻辑。
简化后的代码只需要搞清楚spi传递参数的流程、oled屏幕点亮的逻辑、显示线段和圆的逻辑、显示中文的逻辑就行了。
2 应用层dc和rst引脚的重写
这里重写了rst和dc引脚的的驱动,所以应用层的控制逻辑也对于修改了。如下是新的控制代码。
2.1 根据引脚编号配置宏
//gpio引脚
#define OLED_RST_PIN 51 //复位引脚
#define OLED_DC_PIN 34 //数据和命令切换引脚
2.2 控制引脚初始化硬件。
static void oled_reset(void)
{
dev_digital_write(OLED_RST_PIN, '1'); // 启动复位
delay_ms(100);
dev_digital_write(OLED_RST_PIN, '0'); // 关闭复位
delay_ms(100);
dev_digital_write(OLED_RST_PIN, '1'); // 启动复位
}
2.3 打开驱动传递参数
追踪dev_digital_write函数,跳转至操控驱动传输数据的函数。这里使用系统调用的方式打开驱动文件,向内核传递参数,由内核驱动控制引脚的电平变化。
int dev_digital_write(uint16_t pin, uint8_t value)
{
int fd, ret = -1;
do
{
if (pin != OLED_RST_PIN && pin != OLED_DC_PIN)
{
break;
}
if ((fd = open(pin == OLED_RST_PIN ? "/dev/gpiodevrst" : "/dev/gpiodevdc", O_RDWR)) < 0)
{
printf("打开文件失败\r\n");
break;
}
if (write(fd, &value, sizeof(value)) < 0)
{
printf("dc写数据失败\r\n");
break;
}
if (fd > 0)
{
close(fd);
}
ret = 0;
} while (0);
return ret;
}
上面就是简化后的控制dc和rst的流程了,对比sysfs操作代码量和操作逻辑都简化很多。
3 spi传输数据
应用层的spi传输数据的整个流程逻辑很清晰合理,没必要更改,我只做了注释和逻辑梳理。
3.1 定义spi的变量
使用spi传输数据前要配置spi的传输速率、模式、发送时间间隔等参数。如下是根据需求配置的各类参数。
struct spi_ioc_transfer tr; // 官方配的spi传输数据结构体
// /***************************
// *
// * struct spi_ioc_transfer
// *__u64 tx_buf; 写数据缓冲
// *__u64 rx_buf; 读数据缓冲
// *__u32 len; 缓冲的长度
// *__u32 speed_hz; 通信的时钟频率
// *__u16 delay_usecs; 两个spi_ioc_transfer之间的延时
// *__u8 bits_per_word; 字长(比特数)
// *__u8 cs_change; 是否改变片选
// *__u32 pad;
// *********************************/
#define SPI_CPHA 0x01
#define SPI_CPOL 0x02
#define mySPI_MODE_0 (0 | 0)
#define mySPI_MODE_1 (0 | SPI_CPHA)
#define mySPI_MODE_2 (SPI_CPOL | 0)
#define mySPI_MODE_3 (SPI_CPOL | SPI_CPHA)
typedef enum{
SPI_MODE0 = mySPI_MODE_0, // 00
SPI_MODE1 = mySPI_MODE_1, // 01
SPI_MODE2 = mySPI_MODE_2, // 10
SPI_MODE3 = mySPI_MODE_3 // 11
} SPIMode;
// 定义spi属性
typedef struct SPIstruct{
uint16_t SCLK_PIN;
uint16_t MOSI_PIN;
uint16_t MISO_PIN;
uint16_t CS0_PIN;
uint16_t CS1_PIN;
uint32_t speed;
uint16_t mode;
uint16_t delay;
int fd;
} HARDWARE_SPI;
typedef enum
{
SPI_CS_MODE_LOW = 0, // 片选0
SPI_CS_MODE_HIGH = 1, // 片选1
SPI_CS_MODE_NONE = 3 // 没有片选,自己控制
} SPIChipSelect;
HARDWARE_SPI hardware_spi;
static uint8_t bits = 8;
#define SPI_CS_HIGH 0x04 // 芯片选高
// #define SPI_LSB_FIRST
// #define SPI_3WIRE
//#define SPI_LOOP
#define SPI_NO_CS 0x40 // 单个设备占用一条SPI总线,没有芯片选择
#define SPI_READY 0x80 // 从机拉低以停止数据传输
3.2 spi传输数据的方式
使用ioctl往内核传递参数时,内核需要知道这个参数的作用是什么才能往下配置,所以标志位的需求诞生了。如下所示ioctl的第二个参数是官方定义的标志位,有如下几种:
SPI_IOC_WR_MODE:设置 SPI 设备的模式,包括时钟极性(CPOL)和相位(CPHA)等。
SPI_IOC_RD_MODE:读取 SPI 设备的当前模式。
SPI_IOC_WR_BITS_PER_WORD:设置 SPI 设备的每个数据传输的位数。
SPI_IOC_RD_BITS_PER_WORD:读取 SPI 设备的每个数据传输的位数。
SPI_IOC_WR_MAX_SPEED_HZ:设置 SPI 设备的最大时钟频率。
SPI_IOC_RD_MAX_SPEED_HZ:读取 SPI 设备的最大时钟频率。
3.3 spi配置函数
3.3.1 设置spi的模式
/****************************************
*
* 设置spi模式
* SPI_MODE0 = SPI_MODE_0, //00
* SPI_MODE1 = SPI_MODE_1, //01
* SPI_MODE2 = SPI_MODE_2, //10
* SPI_MODE3 = SPI_MODE_3, //11
*
* return:
* return 1 success
* return -1 failed
*
* SPI_IOC_WR_MODE 设置总线的极性和相位
* **************************************/
int dev_spi_mode(SPIMode mode)
{
hardware_spi.mode &= 0xfc; // 清除低2位数
hardware_spi.mode |= mode; // 设置模式,看上面宏值,只有2位有效
// 写到驱动中
if (ioctl(hardware_spi.fd, SPI_IOC_WR_MODE, &hardware_spi.mode) == -1)
{
DEV_SPI_DEBUG("设置spi模式失败\r\n");
return -1;
}
return 1;
}
3.3.2 spi片选
设置spi的片选,这里就一个设备,应该走SPI_NO_CS分支。
int dev_spi_chipselect(SPIChipSelect cs_mode)
{
if (cs_mode == SPI_CS_MODE_HIGH)
{
hardware_spi.mode |= SPI_CS_HIGH;
hardware_spi.mode &= ~SPI_NO_CS;
DEV_SPI_DEBUG("片选拉高\r\n");
}
else if (cs_mode == SPI_CS_MODE_LOW)
{
hardware_spi.mode &= ~SPI_CS_HIGH;
hardware_spi.mode &= ~SPI_NO_CS;
}
else if (cs_mode == SPI_CS_MODE_NONE)
{
hardware_spi.mode |= SPI_NO_CS;
}
if (ioctl(hardware_spi.fd, SPI_IOC_WR_MODE, &hardware_spi.mode) == -1)
{
DEV_SPI_DEBUG("不能设置spi的模式\r\n");
return -1;
}
return 1;
}
3.3.3 spi传输频率
设置spi的传输频率。
int dev_spi_set_speed(uint32_t speed)
{
uint32_t speed1 = hardware_spi.speed;
hardware_spi.speed = speed;
// 写频率
if (ioctl(hardware_spi.fd, SPI_IOC_WR_MAX_SPEED_HZ, &speed) == -1)
{
DEV_SPI_DEBUG("不能设置最大频率\r\n");
hardware_spi.speed = speed1; // 设置故障频率不变
return -1;
}
// 读频率
if (ioctl(hardware_spi.fd, SPI_IOC_RD_MAX_SPEED_HZ, &speed) == -1)
{
DEV_SPI_DEBUG("不能获取最大频率\r\n");
hardware_spi.speed = speed1; // 设置故障频率不变
return -1;
}
hardware_spi.speed = speed;
tr.speed_hz = hardware_spi.speed; //(目前看只是赋值,没看到应用)
return 1;
}
3.3.4 spi传输数据时间间隔
设置spi发送数据的时间间隔。
void dev_spi_set_data_interval(uint16_t us)
{
hardware_spi.delay = us;
tr.delay_usecs = hardware_spi.delay;//(这里未直接发送数据,判断在其他位置发送,验证)
}
3.3.5 配置spi的读写位
在此函数中调用上面的配置函数,同时配置spi传输数据时每次传输的位数。
/****************************************
*
* 设置spi参数,spi设备名称,spi模式,传输频率
*
* SPI_IOC_WR_BITS_PER_WORD //写 每字多少位
* SPI_IOC_RD_BITS_PER_WORD //读每字多少位
* SPI_IOC_RD_MAX_SPEED_HZ //读 最大速率
* SPI_IOC_WR_MAX_SPEED_HZ //写 最大速率
* **************************************/
void dev_hardware_spi_beginset(char *spi_device, SPIMode mode, uint32_t speed) // 设置spi驱动参数
{
int ret = 0;
hardware_spi.mode = 0;
if ((hardware_spi.fd = open(spi_device, O_RDWR)) < 0)
{
perror("打开spi驱动失败\n");
exit(1);
}
else
{
DEV_SPI_DEBUG("打开驱动:%s\r\n", spi_device);
}
ret = ioctl(hardware_spi.fd, SPI_IOC_WR_BITS_PER_WORD, &bits);
// 设置spi总线写每字多少位,宏命令,+数值bits
if (ret == -1)
{
DEV_SPI_DEBUG("不能设置spi写每字的位数\r\n");
}
ret = ioctl(hardware_spi.fd, SPI_IOC_RD_BITS_PER_WORD, &bits);
// 写 每字多少位
if (ret == -1)
{
DEV_SPI_DEBUG("不能设置spi读每字的位数\r\n");
}
dev_spi_mode(mode);
dev_spi_chipselect(SPI_CS_MODE_LOW);
dev_spi_set_speed(speed);
dev_spi_set_data_interval(0);
}
下面是我实际使用时的spi配置参数。
dev_hardware_spi_beginset("/dev/spidev0.0",mySPI_MODE_3,10000000);
3.4 spi点亮屏幕
至此spi参数的配置基本完成。可以调用spi初始化oled屏幕了。
3.4.1 硬件初始化屏幕
调用写函数通过驱动往oled屏幕传递初始化命令。
//节选其中一个命令展示
oled_write_reg(0xae); // 关闭显示
3.4.2 传递命令及引脚编号
跳转至写函数。根据屏幕驱动设定,发送命令或数据前需要配置dc引脚的电平,然后屏幕才能知道接收的数据是命令还是点亮屏幕的坐标。dc引脚的函数上文已有不再赘述。可以看到命令被dev_spi_transfer_byte函数进一步传递。
static void oled_write_reg(uint8_t reg)
{
dev_digital_write(OLED_DC_PIN, '0'); // 给dc引脚置零,切换为输入命令
dev_spi_transfer_byte(reg); // 使用ioctl传输数据
}
3.4.3 向驱动传递数据
追踪dev_spi_transfer_byte函数,这个就是应用层往内核传递数据的最终函数了。很简单调用上面官方定义的tr结构体填充要往内核传递的数据,使用ioctl往内核传递。
uint8_t dev_spi_transfer_byte(uint8_t buf)
{
uint8_t rbuf[1];
tr.len = 1;
tr.tx_buf = (unsigned long)&buf; // 发送缓冲区
tr.rx_buf = (unsigned long)rbuf; // 接受缓冲区
// ioctl 操作,传输数据
if (ioctl(hardware_spi.fd, SPI_IOC_MESSAGE(1), &tr) < 1)
//上文的时间间隔通过这个tr结构体传输到内核
// cmd SPI_IOC_MESSAGE(N) 其中N是spi_ioc_transfer结构数组元素个数
// 这里只定义了一个结构体tr,所以是1
// 这个SPI_IOC_MESSAGE(1) 命令,表示要传输一个 SPI 消息。
// 全双工工作, SPI 驱动执行一次数据传输,
// 并将发送缓冲区(tr.tx_buf)中的数据发送到 SPI 设备。
// 同时,SPI 驱动也会将接收到的数据存储在接收缓冲区(tr.rx_buf)中。
{
DEV_SPI_DEBUG("can't send spi message\r\n");
}
// printf("硬件写数据成功\r\n");
return rbuf[0];
}
4 清屏
上述流程完成后就可以点亮屏幕了,点亮前软件清屏,若不清屏可能会出现雪花屏,影响后面的参数实现。同时也可以把传输的数据设置成0xff,这样可以看屏幕和点亮函数是否有问题。
遇到这个问题的时候没保存图片,网上随便找的一张凑合看吧。
void oled_clear()
{
uint16_t width, height, column, i, j;
width = oled_width; // 值是128列
height = (oled_height % 8 == 0) ? (oled_height / 8) : (oled_height / 8 + 1);
oled_write_reg(0xb0); // 设置行的起始地址,使用命令设置显示位置的目标起始位置是b0-b7
for (j = 0; j < height; j++)
{
oled_write_reg(0xb0 + j);
for (i = 0; i < width; i++)
{
column = 0 + i;
oled_write_reg(0x00 + (column & 0x0f)); // 设置列的低起始地址
oled_write_reg(0x10 + (column >> 4)); // 设置列的高起始地址
oled_write_data(0x01); // 将对应的列置成0
}
}
}
5 画图
5.1 画点
若无问题,进入下一步实现精确点亮某一个oled屏幕像素点。因为整体代码太长,且都用不上,所以只放置了核心代码。
画点函数。核心思想是点亮一个像素是1X1的矩阵,点亮4个像素是2X2的矩阵,那么就可以减少绘制其他图像宽度等方面的代码复杂度。其实现的原理很简单。双层for循环遍历整个128X64的整个屏幕的像素点。color表示点亮与否,黑色是灭白色是亮;然后将每个像素点的坐标和状态传递给paint_set_pixel函数,由此函数做进一步处理。
int16_t xdir_num, ydir_num;
if (dot_style == DOT_FILL_AROUND)
{
for (xdir_num = 0; xdir_num < dot_pixel ; xdir_num++)
{
// printf("xdir_num[%d],ydir_num[%d]\r\n",xdir_num,ydir_num);
for (ydir_num = 0; ydir_num < dot_pixel; ydir_num++)
{
if (xpoint + xdir_num - dot_pixel < 0 || ypoint + ydir_num - dot_pixel < 0)
{
break;
}
// 这里循环是,因为控制的像素若是矩阵,一次只能操控一个像素点,
// 若想操控矩阵,需要多次循环,如2*2矩阵
// a= xpoint + xdir_num - dot_pixel;
// b=ypoint + ydir_num - dot_pixel;
// printf("a[%d],b[%d]\r\n",a,b);
paint_set_pixel(xpoint + xdir_num , ypoint + ydir_num, color);//2
//paint_set_pixel(xpoint + xdir_num - dot_pixel, ypoint + ydir_num - dot_pixel, color);//2
}
}
}
paint_set_pixel函数的作用是将每个函数的状态写到一个具体的具体的数组中,它的长度是1024个且是char类型的,正好表示整个屏幕的每一个像素点。向数组中写入数据。定义地址addr和显示数据rdata,地址计算方式如上文所述,rdata是8位的数据,代表一列;这时black是0、white是1,根据具体的上面函数的遍历可以知道每一点是0/1,这时即可对rdata精确的赋值。
void paint_set_pixel(uint16_t xpoint, uint16_t ypoint, uint16_t color)
{
uint32_t addr = 0;
uint8_t rdata = 0;
addr = x + ((y/8)*128);
rdata = paint.image[addr];
if (color == BLACK)
{
paint.image[addr] = rdata & ~(0x80 >> (y % 8));
}
else
{
paint.image[addr] = rdata | (0x80 >> (y % 8));
printf("22222rdata[%d],x[%d],y[%d],addr[%d]\r\n",paint.image[addr],x,y,addr);
}
}
每画完一组图像,即可调用oled_display函数向驱动传输数据。
void oled_display(uint8_t *image)
{
uint16_t width, height, column, temp, i, j;
width = oled_width; // 值是128列
height = (oled_height % 8 == 0) ? (oled_height / 8) : (oled_height / 8 + 1);
oled_write_reg(0xb0); // 设置行的起始地址,使用命令设置显示位置的目标起始位置是b0-b7
for (j = 0; j < height; j++)
{
oled_write_reg(0xb0 + j);
for (i = 0; i < width; i++)
{
column = 0 + i;
oled_write_reg(0x00 + (column & 0x0f)); // 设置列的低起始地址
oled_write_reg(0x10 + (column >> 4)); // 设置列的高起始地址
temp = image[i + j* width ]; // 在其他位置定义并分配空间,越界最后处理
temp = reverse(temp);//翻转函数,作用解决画圆出现两个半圆翻转的问题
oled_write_data(temp);
}
}
}
如下是点亮以下5个坐标的情况:
(0,0)、(127,0)、(63,0)、(63,127)、(6,19)
5.2 画线
我看官方给出的算法画线段只有直线,没有斜线。这里给出了Bresenham算法(任意斜率)。核心思想就是看下一点的实际坐标距离屏幕上两点坐标哪个近,选择近的那一点;循环往复直到尽头。具体算法我开了单章讲解,有兴趣可以看看。
void paint_draw_slope_line(uint16_t xstart, uint16_t ystart, uint16_t xend, uint16_t yend,
uint16_t color, DOT_PIXEL line_width, LINE_STYLE line_style){
uint16_t temp = 0;
uint16_t xpoint = xstart;
uint16_t ypoint = ystart;
//算出2点间距离,确保值为非负值(abs函数是获取绝对值)
int dx = (int)xend - (int)xstart >= 0 ? xend - xstart : xstart - xend; // 算出两点距离
int dy = (int)yend - (int)ystart >= 0 ? yend - ystart : ystart - yend;
if(dy>dx)//为真证明斜率绝对值大于1,主要以y轴方向递增
{
temp = xstart; //x,y值互换
xstart = ystart;
ystart = temp;
temp = 0;
}
if(xstart >xend)//为真,说明起点大于终点,交换方向
{
temp = xstart;
xstart = xend;
xend = temp;
temp = 0;
temp = ystart;
ystart = yend;
yend = temp;
temp = 0;
}
uint16_t delta_x = xend -xstart;//上面换算判断,这步xend一定大于xstart
uint16_t delta_y = (int)yend - (int)ystart >= 0 ? yend - ystart : ystart - yend;
uint16_t error = 0; //误差量
uint16_t delta_error = delta_x/delta_y;//斜率
uint16_t yk = ystart;
uint16_t y_step = 0;
if(ystart < yend)
{
y_step = 1;
}else{
y_step = -1;
}
for(uint16_t xk = xstart; xk <= xend; xk++)
{
if(dy>dx)
{
paint_draw_point(yk, xk, color, line_width, DOT_FILL_AROUND);
}else{
paint_draw_point(xk, yk, color, line_width, DOT_FILL_AROUND);
}
error = error +delta_error;
if(error >= 0.5)
{
yk=yk+y_step;
error = error -1;
}
}
}
如下是使用新算法绘制的直线和斜线:
5.3 画圆
画圆一样是使用Bresenham画圆算法,核心思想是绘制八分之一圆,剩余部分通过对称获取点的坐标信息。具体算法思想我开了单章讲解,可以看看。
void paint_draw_circle(uint16_t x_center, uint16_t y_center, uint16_t radius,
uint16_t color, DOT_PIXEL line_width, DRAW_FILL draw_fill)
{
if (x_center > 128 || y_center > 64)
{
Debug("超出正常显示范围\r\n");
return;
}
// 画一个圆从(0,r)处作为一个起点
int16_t xcurrent, ycurrent;
xcurrent = 0;
ycurrent = radius;
// 累计误差,判断下一个点的位置
int16_t esp = 3 - (radius << 1);
// 画一个空心圆
while (xcurrent <= ycurrent)
{
paint_draw_point(x_center + xcurrent, y_center + ycurrent, color, line_width, DOT_FILL_AROUND); // 1
paint_draw_point(x_center - xcurrent, y_center + ycurrent, color, line_width, DOT_FILL_AROUND); // 2
paint_draw_point(x_center - ycurrent, y_center + xcurrent, color, line_width, DOT_FILL_AROUND); // 3
paint_draw_point(x_center - ycurrent, y_center - xcurrent, color, line_width, DOT_FILL_AROUND); // 4
paint_draw_point(x_center - xcurrent, y_center - ycurrent, color, line_width, DOT_FILL_AROUND); // 5
paint_draw_point(x_center + xcurrent, y_center - ycurrent, color, line_width, DOT_FILL_AROUND); // 6
paint_draw_point(x_center + ycurrent, y_center - xcurrent, color, line_width, DOT_FILL_AROUND); // 7
paint_draw_point(x_center + ycurrent, y_center + xcurrent, color, line_width, DOT_FILL_AROUND); // 0
if (esp <0)
{
esp += 4 * xcurrent + 6;
}
else
{
esp += 10 + 4 * (xcurrent - ycurrent);
ycurrent--;
}
xcurrent++;
}
}
如下是绘制的圆:
5.4 显示字符
显示中文、英文、数字等,这里觉得官方的方式就很好用,他将常用字符取模后制作成字库文件,检测字符串及字库编号,显示不同尺寸的中英文。显示原理就是对比字符,如果解析的ascii码小于126证明是数字、字母、符合,大于等于126则是汉字,因为汉字是双字节表示,所以对比是两个变量。
void paint_draw_string_cn(uint16_t xstart, uint16_t ystart, const char *pstring, cFONT *font,
uint16_t color_foreground, uint16_t color_background)
{
const char *p_text = pstring;
int x = xstart, y = ystart;
int i, j, num;
// 在EPD上逐个字符发送字符串
while (*p_text != 0)
{
if (*p_text <= 0x7F) // ascii <126
{
for (num = 0; num < font->size; num++)
{
if (*p_text == font->table[num].index[0])
{
const char *ptr = &font->table[num].matrix[0];
for (j = 0; j < font->Height; j++)
{
for (i = 0; i < font->Width; i++)
{
if (WHITE == FONT_BACKGROUND) // 加快扫描速度
{
if (*ptr & (0x80 >> (i % 8)))
{
paint_set_pixel(x + i, y + j, color_foreground);
}
else
{
paint_set_pixel(x + i, y + j, color_background);
}
}
else
{
if (*ptr & (0x80 >> (i % 8)))
{
paint_set_pixel(x + i, y + j, color_background);
}
else
{
paint_set_pixel(x + i, y + j, color_foreground);
}
}
if (i % 8 == 7) // (等于7这里存疑,后续测试)
{
ptr++;
}
}
if (font->Width % 8 != 0)
{
ptr++;
}
}
break;
}
}
// 指向下一个字符
p_text += 1;
// 将列的位置减16
x += font->ASCII_Width;
}
else
{
for (num = 0; num < font->size; num++)
{
if ((*p_text == font->table[num].index[0]) && (*(p_text + 1) == font->table[num].index[1]))
{
const char *ptr = &font->table[num].matrix[0];
for (j = 0; j < font->Height; j++)
{
for (i = 0; i < font->Width; i++)
{
if (WHITE == FONT_BACKGROUND)
{
if (*ptr & (0x80 >> (i % 8)))
{
paint_set_pixel(x + i, y + j, color_foreground);
}
else
{
paint_set_pixel(x + i, y + j, color_background);
}
}
else
{
if (*ptr & (0x80 >> (i % 8)))
{
paint_set_pixel(x + i, y + j, color_background);
}
else
{
paint_set_pixel(x + i, y + j, color_foreground);
}
}
if (i % 8 == 7)
{
ptr++;
}
}
if (font->Width % 8 != 0)
{
ptr++;
}
}
break;
}
}
// 指向下一个字符
p_text += 3;
// 将列位置减16
x += font->Width;
}
}
}
如下是显示的中英文字符:
5.5 最终调用
最后所有操作完成后,还有分配空间的问题;其实就是创建了一个1024字节大小的空间用来存储图片数据使用。最后分析发现对于这个空间的使用是基于char类型数组使用的。所以我觉得直接定义成一个char类型的数组更合理。
// 1.创建一个新的图片缓存
uint8_t *blackimage;
uint16_t imagesize = ((oled_height % 8 == 0) ? (oled_height / 8) : (oled_height / 8 + 1)) * (oled_width);
// 分配空间,1个字节表示8个像素点,128*8= 1024
// 空间是char类型的,步长为1步
if ((blackimage = (uint8_t *)malloc(imagesize)) == NULL)
{
printf("申请内存失败\r\n");
return -1;
}
6 总结
至此整个移植过程就算完成了。中间肯定有些瑕疵,但所有功能都实现了,也算是这个目标完成了。修改完的代码我都放到我gitee上的仓库上了,有需要的可以自取。