一般很多人做电子钟都是那种纯数字显示的,这种比较容易实现,而且51黑电子论坛也有很多例子借鉴,重复去实现也没有什么新意。我花了2天的时间,写程序实现一个像样的电子钟表盘的制作,再加上DS3231时钟芯片之后,就实现一个电子钟的制作。
接下来,我就详细讲解一下电子钟盘的实现过程。
钟表是由圆形的钟盘和三条指针构成,要在OLED上实现绘制,需要用到画点函数、取点函数、画线函数、画圆函数、刷新函数。那首先讲一下OLED的图形驱动部分。
一般我们要操作OLED显示内容,都是直接将往OLED的某行某页写数据,这种方法简单直接,效率比较高。但是,对于像素的细致操作是很不友好的。比如无法通过SPI读取某个像素点的状态,那就无法实现部分自定义像素点的反转。为了更方便操作像素点,这里用使用了一个1024字节的大数组来作为OLED的显存,每个字节可以操控8个像素点,总共可以操控1024×8=8192个像素点,刚好对应0.96寸OLED的128×64的分辨率。实现显存的好处是方便修改操作像素点,每个像素点的状态都一目了然。修改好显存的内容后,直接将显存的数据传到OLED就可以刷新画面了。显存数组的定义如下:
uint8 OLED_DISPLAY[8][128]; //显示缓冲数组(总共可以表示8192个像素点)
为了方便定位和操作像素点,使用坐标轴的思想,引入x轴和y轴,其中x轴的范围为0-127(128个像素点),y轴的范围为0-63(64个像素点)。画点函数实现如下:
/**************************************************************************************************
*@函数 OLED_SetPixel
*
*@简述 OLED设置坐标像素点状态
*
*@输入参数 x - x坐标
* y - y坐标
* PixelValue - 像素点状态(1:填充,0:清空)
*
*@参数 无
*
*输出参数
*
*无
*
*@返回 无
*说明:规定OLED显示区域左上角顶点处为坐标原点(0,0),
* x坐标增长方向:向右→
* y坐标增长方向:向下↓
* 坐标原点(0,0)对应OLED_DISPLAY[0][0],即第零页第一个像素点
* 坐标(127,63)为OLED屏幕又下角顶点
**************************************************************************************************
*/
void OLED_SetPixel(int32 x, int32 y, int32 PixelValue)
{
int32 pos,bx,temp=0;
pos = y/8;//计算y坐标所在页
bx = y%8;//计算位偏移
temp = 1<<bx;
if(PixelValue)
{
OLED_DISPLAY[pos][x]|=temp;
}
else
{
OLED_DISPLAY[pos][x]&=~temp;
}
}
很容易看出,OLED_SetPixel函数操作的对象就是显存数组,要点亮某个点,就是将显存的某个字节某个位置1。当然,由于只是修改显存的内容,还没有将显存更新到OLED中,所以不会在OLED不会显示点亮某个点。有画点函数,那肯定也要有取点函数。取点函数,实际上就是读取显存的某个字节某个位的状态。取点函数的实现如下所示:
/**************************************************************************************************
*@函数 OLED_GetPixel
*
*@简述 获取坐标像素点状态
*
*@输入参数 x - x坐标
* y - y坐标
*
*@参数 无
*
*输出参数
*
*无
*
*@返回 PixelValue - 像素点状态
**************************************************************************************************
*/
int32 OLED_GetPixel(int32 x, int32 y)
{
int32 pos,bx,temp=0;
pos = y/8;//计算y坐标所在页
bx = y%8;//计算位偏移
temp = 1<<bx;
if(OLED_DISPLAY[pos][x] & temp)
{
return 1;
}
else
{
return 0;
}
}
接下来是刷新函数,刷新函数是将显存的数据传输到OLED中,以便实现OLED画面的更新。刷新函数和实现如下所示:
/**************************************************************************************************
*@函数 OLED_Refresh_Display
*
*@简述 OLED更新显示
*
*@输入参数
*
*@参数 无
*
*输出参数
*
*无
*
*@返回 无
**************************************************************************************************
*/
void OLED_Refresh_Display(void)
{
uint8 *value;
value = (uint8*)OLED_DISPLAY;//二级指针转为一级指针
OLED_WR_Byte (0xb0,OLED_CMD);//开始页:0
OLED_WR_Byte (0x00,OLED_CMD); //开始列低地址为0
OLED_WR_Byte (0x10,OLED_CMD); //开始列高地址为0
OLED_WR_Bytes(value,1024);
}
先设定好起始页和起始列地址,然后一次性将1024个字节写入到OLED中。由于OLED_DISPLAY为二维指针,需要强制转成一维指针才能传入OLED_WR_Bytes。接下来是画线函数,这里的画线函数涉及到了计算机图形学的内容,采用Bresenham直线算法思想。画线函数的实现如下:
/**************************************************************************************************
*@函数 OLED_DrawLine
*
*@简述 OLED画一条线
*
*@输入参数 iStartX - 起点x坐标
* iStartY - 起点y坐标
* iEndX - 终点x坐标
* iEndY - 终点y坐标
* fill - 填充(0:不填充,1:填充)
*
*@参数 无
*
*输出参数
*
*无
*
*@返回 无
**************************************************************************************************
*/
void OLED_DrawLine(int16 iStartX, int16 iStartY, int16 iEndX, int16 iEndY, int16 fill)
{
/*----------------------------------*/
/* Variable Declaration */
/*----------------------------------*/
int16 iDx, iDy;
int16 iIncX, iIncY;
int16 iErrX = 0, iErrY = 0;
int16 iDs;
int16 iCurrentPosX, iCurrentPosY;
/*----------------------------------*/
/* Initialize */
/*----------------------------------*/
iErrX = 0;
iErrY = 0;
iDx = iEndX - iStartX; //X轴差值
iDy = iEndY - iStartY; //Y轴差值
iCurrentPosX = iStartX;
iCurrentPosY = iStartY;
if(iDx > 0) //X轴差值大于0
{
iIncX = 1;
}
else
{
if(iDx == 0) //X轴差值等于0
{
iIncX = 0;
}
else //X轴差值小于0
{
iIncX = -1;
iDx = -iDx; //iDx取反
}
}
if(iDy > 0) //Y轴差值大于0
{
iIncY = 1;
}
else
{
if(iDy == 0) //Y轴差值等于0
{
iIncY = 0;
}
else //Y轴差值小于0
{
iIncY = -1;
iDy = -iDy;
}
}
if(iDx > iDy) //斜率小于45°
{
iDs = iDx;
}
else //斜率大于等于45°
{
iDs = iDy;
}
/*----------------------------------*/
/* Process */
/*----------------------------------*/
for(uint8 i = 0; i <= iDs+1; i++)
{
OLED_SetPixel(iCurrentPosX,iCurrentPosY, fill);//当前位置画点
iErrX += iDx; //X轴偏移
if(iErrX > iDs)
{
iErrX -= iDs;
iCurrentPosX += iIncX;
}
iErrY += iDy; //Y轴偏移
if(iErrY > iDs)
{
iErrY -= iDs;
iCurrentPosY += iIncY;
}
}
}
Bresenham直线算法实现直线的绘制只用到了简单的加法运算,计算机可以快速生成直线。这里不花篇幅讲解Bresenham直线算法,感兴趣的可以百度查询。OLED_DrawLine函数只要传入起点、终点和填充状态就可以绘制一条实线或者空线。钟表盘指针的显现和消除就可以用这个函来实现。 接下来是画圆函数,画圆函数是基于中点画圆法思想实现的,画圆函数实现如下所示:
/**************************************************************************************************
*@函数 OLED_DrawCircle
*
*@简述 OLED画圆
*
*@输入参数 uiCx - 圆心x坐标
* uiCy - 圆心y坐标
* uiRadius - 半径
* eEdgeColor - 边缘填充(0:不填充,1:填充)
* eFillColor - 圆内区域填充(0:不填充,1:填充)
*
*@参数 无
*
*输出参数
*
*无
*
*@返回 无
**************************************************************************************************
*/
void OLED_DrawCircle(uint8 uiCx, uint8 uiCy, uint8 uiRadius, uint8 eEdgeColor, uint8 eFillColor)
{
/*----------------------------------*/
/* Variable Declaration */
/*----------------------------------*/
uint8 uiPosXOffset, uiPosYOffset;
int16 uiPosXOffset_Old, uiPosYOffset_Old;
int16 iXChange, iYChange, iRadiusError;
/*----------------------------------*/
/* Initialize */
/*----------------------------------*/
uiPosXOffset = uiRadius;
uiPosYOffset = 0;
uiPosXOffset_Old = 0xFFFF;
uiPosYOffset_Old = 0xFFFF;
iXChange = 1 - 2 * uiRadius;
iYChange = 1;
iRadiusError = 0;
/*----------------------------------*/
/* Process */
/*----------------------------------*/
if(uiRadius < 1) //半径小于1
{
OLED_SetPixel(uiCx, uiCy, eEdgeColor);
}
else
{
while(uiPosXOffset >= uiPosYOffset)
{
if((uiPosXOffset_Old != uiPosXOffset) || (uiPosYOffset_Old != uiPosYOffset) )
{
// Fill the circle 填充圈圈
if((uiRadius > 1) && (eFillColor != 2) && (uiPosXOffset_Old != uiPosXOffset))
{
OLED_DrawLine(uiCx-uiPosXOffset, uiCy-uiPosYOffset+1, uiCx-uiPosXOffset, uiCy+uiPosYOffset-1, eFillColor);
OLED_DrawLine(uiCx+uiPosXOffset, uiCy-uiPosYOffset+1, uiCx+uiPosXOffset, uiCy+uiPosYOffset-1, eFillColor);
uiPosXOffset_Old = uiPosXOffset;
}
OLED_DrawLine(uiCx-uiPosYOffset, uiCy-uiPosXOffset+1, uiCx-uiPosYOffset, uiCy+uiPosXOffset-1, eFillColor);
OLED_DrawLine(uiCx+uiPosYOffset, uiCy-uiPosXOffset+1, uiCx+uiPosYOffset, uiCy+uiPosXOffset-1, eFillColor);
uiPosYOffset_Old = uiPosYOffset;
// Draw edge.
OLED_SetPixel(uiCx+uiPosXOffset, uiCy+uiPosYOffset, eEdgeColor);
OLED_SetPixel(uiCx-uiPosXOffset, uiCy+uiPosYOffset, eEdgeColor);
OLED_SetPixel(uiCx-uiPosXOffset, uiCy-uiPosYOffset, eEdgeColor);
OLED_SetPixel(uiCx+uiPosXOffset, uiCy-uiPosYOffset, eEdgeColor);
OLED_SetPixel(uiCx+uiPosYOffset, uiCy+uiPosXOffset, eEdgeColor);
OLED_SetPixel(uiCx-uiPosYOffset, uiCy+uiPosXOffset, eEdgeColor);
OLED_SetPixel(uiCx-uiPosYOffset, uiCy-uiPosXOffset, eEdgeColor);
OLED_SetPixel(uiCx+uiPosYOffset, uiCy-uiPosXOffset, eEdgeColor);
}
uiPosYOffset++;
iRadiusError += iYChange;
iYChange += 2;
if ((2 * iRadiusError + iXChange) > 0)
{
uiPosXOffset--;
iRadiusError += iXChange;
iXChange += 2;
}
}
}
}
这个画圆函数也只是用到简单的加法运算,不涉及到浮点运算,不依赖于<math.h>数学库。除了可以画圆圈的边缘线,还可以填充圆形内部区域。可以用画圆函数绘制钟表盘的外形。
上面介绍了OLED图形驱动的几个基本图形操作函数,接下来就利用上面的函数来制作电子钟表盘。电子钟表盘的制作难点是指针的位置处理,两点可以确定一条直线,指针的一端是转轴,另一端指向刻度点的方向。转轴的坐标是保持不变的,要实现指针的转动,需要将指针的另一端指向一下一个刻度点的坐标。钟表有60个刻度点,只要找出60个刻度点对应的坐标,就能实现指针的转动。圆是上下左右对称的,只要找出四分之一圆区域的15个刻度点,就不难算出其他45个刻度点。现在的问题是如何计算出这15个刻度点的坐标?
很容易想到联立直线方程和圆方向就可以交点计算出来。直线方程有15条,每条倾斜角度相差6°,直线斜率可以用tanθ表示,将查表后记录到数组中方便查询:
uint32 angle_tan[] = {
//0° 6° 12° 18° 24° 30° 36° 42° 48° 54° 60° 66° 72° 78° 84° 90°
0,1051,2126,3249,4452,5773,7265,9004,11106,13763,17320,22460,30776,47046,95143,0xffffffff
};//tanθ扩大1000倍
通过调用OLED_DrawCircle函数,可以将圆形绘制出来,然后调用OLED_GetPixel函数,遍历显存的数据,可以把构成圆形的像素点的坐标找出来。只要将像素点的坐标带入到直线方程,就能确定哪个像素点可以作为刻度点。直线点斜式方程y=kx+b,为了消除b常量方便计算,将圆心定为坐标原点。那么直线方程就是正比例函数y=kx,其中斜率k可用tanθ表示。寻找刻度点的实现如下所示:
/**************************************************************************************************
*@函数 clock_calculate_coordinate
*
*@简述 计算针轨迹右下部数字15-30的坐标
*
*@输入参数 clock_hand - 类别:时针、分针、秒针
* length - 指针长度
*
*@参数 无
*
*@返回
**************************************************************************************************
*/
static void clock_calculate_coordinate(uint8 clock_hand,uint8 length)
{
uint8 x,y;
uint8 rad = 0;
uint16 *coordinate_array;
double value = 0;
double angel = 0;
double cal_x,cal_y;
double all_value;
OLED_Clear_Ram();//清显存
OLED_DrawCircle(CIRCLE_X,CIRCLE_Y,length,1,0);//绘制轨迹
rad = length;
switch(clock_hand) //判断指针类型
{
case SECOND_HAND: //秒针
coordinate_array = second_coordinate;
break;
case MINUTE_HAND: //分针
coordinate_array = minute_coordinate;
break;
case HOUR_HAND: //时针
coordinate_array = hour_coordinate;
break;
default:
break;
}
for(uint8 k = 0; k < 16; k++) //计算轨迹右下部15-30的坐标
{
angel = angle_tan[k];
all_value = 977889999;
for(uint8 i = CIRCLE_X - 1; i <= CIRCLE_X + rad; i++)
{
for(uint8 j = CIRCLE_Y - 1; j <= CIRCLE_Y + rad; j++)
{
if(OLED_GetPixel(i,j)) //扫描针的轨迹
{
cal_x = i-CIRCLE_X;
cal_y = j-CIRCLE_Y;
value =cal_y*10000 - angel*cal_x;
if(value < 0)//负数处理
{
value = -value;
}
if(all_value - value> 0) //寻找最合适的坐标
{
all_value = value;
x = i;
y = j;
}
}
}
}
coordinate_array[k] = y; //记录y坐标
coordinate_array[k] <<= 8;
coordinate_array[k] |= x; //记录x坐标
}
OLED_Clear_Ram();//清显存
}
选择计算数字15-30的坐标的原因主要是这个区域坐标x和坐标y都是正数,比较好处理。程序分别计算了不同长度的秒针、分针和时针的刻度点坐标,后面就可以根据刻度点来实现秒针、分针和时针的转动。接下来是根据计算保持的刻度点坐标,计算出剩下45个刻度点坐标。区域指圆形的四个扇形区域,钟表上就是0-15、15-30、30-45、45-60这四个区域。对传入的数字先做区域标记,再转成15-30之间的数,这样方便对应刻度点坐标。再根据区域,将刻度点坐标做对称换算即可。
下面动图测试了一下秒针,可以看到可以360°旋转。说明刻度点坐标都在圆形边沿。
圆形主要是显示秒针跑的轨迹而特意绘制出来的,实际在计算出15个坐标后,可以把圆形轨迹删除。分针和时针也是类似的操作,只是轨迹是不同半径的圆而已。可以通过修改如下定义的宏:
#define SECOND_HAND_LENGTH 28 //秒针长度
#define MINUTE_HAND_LENGTH 27 //分针长度
#define HOUR_HAND_LENGTH 18 //时针长度
再根据秒针、时针和分针的简单关系,程序实现如下:
/**************************************************************************************************
*@函数 clock_show_time
*
*@简述 表盘显示时间函数
*
*@输入参数 time - 时间结构体
* state - 状态
*
*@参数 无
*
*输出参数 无
*
*@返回
**************************************************************************************************
*/
void clock_show_time(clock_time time,uint8 state)
{
uint8 x,y;
if(time.hours > 12) //如果为24小时
{
time.hours -= 12;//转为12小时制
}
if(state)
{
OLED_ShowChar(CIRCLE_X-5,2,'1',12); //数字1
OLED_ShowChar(CIRCLE_X,2,'2',12); //数字2
OLED_ShowChar(CIRCLE_X-2,50,'6',12);//数字6
OLED_ShowChar(CIRCLE_X+24,25,'3',12);//数字3
OLED_ShowChar(CIRCLE_X-29,25,'9',12);//数字9
OLED_DrawCircle(CIRCLE_X,CIRCLE_Y,2,1,1);//绘制转轴
}
clock_get_coordinate(time.hours*5+time.minutes/12,HOUR_HAND,&x,&y);
OLED_DrawLine(CIRCLE_X,CIRCLE_Y,x,y,state); //绘制时针
clock_get_coordinate(time.minutes,MINUTE_HAND,&x,&y);
OLED_DrawLine(CIRCLE_X,CIRCLE_Y,x,y,state); //绘制分针
clock_get_coordinate(time.seconds,SECOND_HAND,&x,&y);
OLED_DrawLine(CIRCLE_X,CIRCLE_Y,x,y,state); //绘制秒针
}
clock_show_time可以根据所传入的时间结构体来绘制三条指针,参数state是绘制和擦除指针用的。下面是时钟运行函数:
/**************************************************************************************************
*@函数 clock_run
*
*@简述 运行表盘函数
*
*@输入参数 time - 时间结构体
*
*@参数 无
*
*输出参数 无
*
*@返回
**************************************************************************************************
*/
void clock_run(clock_time time)
{
clock_show_time(pre_time,0);//擦除前一次时间轨迹
clock_show_time(time,1);//显示当前时间
pre_time = time;//记录最新时间
}
定义一个全局结构体变量pre_time 来记录上次时间。每次更新时间前,会先把上次的指针轨迹擦除,再绘制新的指针轨迹。下面的动图是秒针、分针和时针的运行过程。
外围有12个小点,分别代表数字1-数字12,其中4个小点被数字挡住了。要生成这12个小点,也是比较简单。先将其看成指针的轨迹来生成计算坐标,再从60个坐标中取出数字1-12数字这12个坐标点,把12个坐标点画上,就可以了。效果如下图所示:
钟表盘的制作基本算完成了,再加上DS3231时钟芯片,就可以实时显示时间了。效果如下图所示:
|