本教程将使用赤菟开发板上的LCD和IMU两个模块,实现IMU数据的读取和显示,以及碎屏效果的模拟。
简单来说,静置时显示这样:
晃动时显示这样:
磕碰时显示这样:
开发流程将会分为以下3部分进行讲解:
- IMU模块所使用的API;
- LCD模块所使用API的修改(扩展其功能);
- 代码的整体工作流程及其他杂项。
1 IMU模块及其驱动API的介绍
在赤菟开发板上有一个惯性传感器(Inertia Measurement Unit,IMU),型号为MPU6050,是一个位于LCD显示屏与TF卡插槽之间的黑色小方块,通过IIC总线与处理器相连。
(此处应当有图1-1)
沁恒微电子及MRS IDE提供了IIC总线的API,因此直接使用ch32v30x_i2c.h中提供的接口进行开发是可行的。但为了开发及应用的简单方便,赤菟也针对MPU6050这一传感器提供了封装好的API以供使用,相关的函数被存放在IIC.h、IIC.c、MPU6050.h以及MPU6050.c文件当中,使用时只需#include "MPU6050.h",然后根据自身要求稍作修改即可,避免了重复造轮子的麻烦。
接下来简单介绍一下相关函数的功能以及使用方法。
u8 MPU_Init(void);
首先是IMU的初始化函数,其中对IIC总线进行了初始化,并向MPU6050发送了一系列的初始化指令,并在最后对工作状态进行检验。
u8 MPU_Write_Len(u8 addr,u8 reg,u8 len,u8 *buf);
u8 MPU_Read_Len(u8 addr,u8 reg,u8 len,u8 *buf);
u8 MPU_Write_Byte(u8 reg,u8 data);
u8 MPU_Read_Byte(u8 reg);
这四条函数是对MPU6050的总线读写操作,均由API中的其他函数调用,一般情况下外部代码不会用到它们,但如果希望能添加更多的功能,那么这几个函数将会带来很大方便。
u8 MPU_Set_Gyro_Fsr(u8 fsr);
u8 MPU_Set_Accel_Fsr(u8 fsr);
这两条函数用于设置传感器的最大量程(Full-Scale Range,FSR),角速度传感器(Gyroscope)可设为±250°/s、±500°/s、±1000°/s或±2000°/s,而加速度传感器(Accelerometer)则可设为±2g、±4g、±8g或±16g。
u8 MPU_Set_Rate(u16 rate);
u8 MPU_Set_LPF(u16 lpf);
这两个函数用于设置MPU6050的数据采样率、以及数据的低通滤波器(Low-Pass Filter,LPF)参数。传感器的采样率支持4Hz-1kHz,一般不考虑功耗的情况下直接拉满到1kHz就行;而数字低通滤波器用于对输出数据进行简单的滤波,能够一定程度上减少数据噪声,一般设为采样率的1/2。
short MPU_Get_Temperature(void);
u8 MPU_Get_Gyroscope(u16 *gx,u16 *gy,u16 *gz);
u8 MPU_Get_Accelerometer(u16 *ax,u16 *ay,u16 *az);
最后这三个函数用于获取传感器温度、三轴角速度、以及三轴加速度。输出的数据是未经处理的原始数据,范围-32768至+32767,需要根据之前设置的FSR进行转换。转换过程可以在外部代码中单独进行,也可以集成进API当中,根据需求修改即可。默认的X轴正方形为开发板右侧,Y轴正方向为开发板上侧,Z轴正方向为开发板背侧,属于左手系而不是通常的右手系,使用时需要注意。
这一API的功能较为基础,不过这几个简单的功能已经足够我们在这个项目中的使用了。MPU6050的应用非常广泛,其作为一个功能强大的六轴惯性传感器,拥有非常多的待开发用法,若有兴趣可以参照MPU6050的用户手册进一步扩充API的功能。
2 LCD模块驱动API的功能扩展
之前在《用赤菟玩贪吃蛇》一文中已经简单介绍过LCD模块的使用了,但是其中所使用的LCD驱动API较为基础,难以满足我们碎屏时显示效果的需求,因此教程的这一部分将会在原API的基础上进行一些修改,来达成更好的显示效果。
2.1 透明文本框(无底色的文字显示)
原API的lcd_show_char函数(以及调用该函数的lcd_show_string、lcd_show_num)会在文字刷新的范围中显示一个背景色,完全覆盖掉原有的内容。在显示提示信息时,这样的文字背景会显得很突兀。(下图中用品红色强调了一下文字的背景范围)
而我们所希望的显示效果则是下图这样的,文字显示于其他内容的上方,但并不会盖住下方的内容。(下图中用绿色文字强调了图层关系,中央的绿色文字覆盖了蛛网裂痕,但是不会有缺失的方形区域)
原函数中使用了lcd_address_set函数指定字符在LCD上的位置,随后取font.h中定义的字形记为temp,并根据字形,依次向LCD发送每个像素点的颜色。由于程序不知道该区域中原先的显示内容,因此只能用背景色BACK_COLOR填充字体之外的区域。
lcd_address_set(x, y, x + size / 2 - 1, y + size - 1); //(x,y,x+8-1,y+16-1)
/* fast show char */
for (pos = 0; pos < size * (size / 2) / 8; pos++)
{
temp = asc2_1608[(u16)data * size * (size / 2) / 8 + pos];
for (t = 0; t < 8; t++)
{
if (temp & 0x80)
colortemp = FORE_COLOR;
else
colortemp = BACK_COLOR;
lcd_write_half_word(colortemp);
temp <<= 1;
}
}
而根据lcd_address_set函数可见,函数首先通过0x2A、0x2B指令指定了显示刷新范围,随后通过0x2C指令开始显示内容的传输,即写入LCD的帧缓存。
void lcd_address_set(u16 x1, u16 y1, u16 x2, u16 y2)
{
lcd_write_cmd(0x2a);
lcd_write_data(x1 >> 8);
lcd_write_data(x1);
lcd_write_data(x2 >> 8);
lcd_write_data(x2);
lcd_write_cmd(0x2b);
lcd_write_data(y1 >> 8);
lcd_write_data(y1);
lcd_write_data(y2 >> 8);
lcd_write_data(y2);
lcd_write_cmd(0x2C);
}
而在写帧缓存指令(0x2C)之外,LCD控制芯片ST7789同样提供了读帧缓存指令(0x2E)。据此,我们可以添加一个新的函数,在指定刷新范围后发送读取指令,随后根据时序读出该范围中的显示内容并存入缓存(读取时每个像素占3字节,RGB666格式,此处16*8的字形需占用384字节):
void LCD_AddressSetRead(u16 x1, u16 y1, u16 x2, u16 y2)
{
LCD_WriteCmd(0x2a);
LCD_WriteData(x1 >> 8);
LCD_WriteData(x1);
LCD_WriteData(x2 >> 8);
LCD_WriteData(x2);
LCD_WriteCmd(0x2b);
LCD_WriteData(y1 >> 8);
LCD_WriteData(y1);
LCD_WriteData(y2 >> 8);
LCD_WriteData(y2);
LCD_WriteCmd(0x2E);
}
由于芯片暂时不支持malloc操作,因此将所需的缓存区域硬编码进程序代码,字符显示流程执行完毕后会自动释放:
u8 font_buff[384] = {0};
if(!bkground)
{
LCD_AddressSetRead(x, y, x + size / 2 - 1, y + size - 1);
LCD_ReadData();
for (pos = 0; pos < size * (size / 2) * 3; pos++)
{
font_buff[pos] = LCD_ReadData();
}
}
随后我们只要按需求选择用背景色覆盖原内容,还是用透明背景保留原内容了:
LCD_AddressSetWrite(x, y, x + size / 2 - 1, y + size - 1);//(x,y,x+8-1,y+16-1)
/* fast show char */
for (pos = 0; pos < size * (size / 2) / 8; pos++)
{
temp_fontData = asc2_1608[(u16)data * size * (size / 2) / 8 + pos];
for (t = 0; t < 8; t++)
{
if (temp_fontData & 0x80) colortemp = FORE_COLOR;
else
{
if(bkground) colortemp = BACK_COLOR;
else colortemp = LCD_ConvertColor(font_buff[(pos*8+t)*3]<<16 | font_buff[(pos*8+t)*3+1]<<8 | font_buff[(pos*8+t)*3+2]);
}
LCD_WriteHalfWord(colortemp);
temp_fontData <<= 1;
}
}
2.2 图片显示时的图层叠加(而非覆盖)
与文字的显示一样,在显示碎屏特效时,我们也不希望特效图片将原先的显示内容全部覆盖,因此与透明文本框的思路类似,我们也可以通过读取原有显示内容的方法,来达成图层叠加的效果(叠加时需要使用黑色背景的图片)。
不过与文字不同,图片的分辨率远高于单个字符,完整的240x240位图将会占用172.8kB的RAM,而CH32V307芯片总共只有64kB的SRAM空间(尽管可通过将Flash存储配置为RAM来扩展到128kB),因此将整个屏幕内容一次性读取并放入缓存是不可能的。
当然,这里的解决方法也很简单,既然不能一次性处理,那我们就分批次处理,每次读取一行,分240次完成,这样就只需要720字节的缓存即可。
u8 buff[720] = {0};
for ( row = 0; row < height; row++ )
{
// read original area
LCD_AddressSetRead(x, y+row, x+width-1, y+row);
LCD_ReadData();
for( col = 0; col < width * 3; col++ )
{
buff[col] = LCD_ReadData();
}
// calculate overlay
// ……
在读取完成后,逐像素地叠加缓存内容buff和要显示的图片p,需要注意RGB565(图片)和RGB666(缓存)之间格式的转换,并在叠加后判断有无数据溢出。最后再将处理好的这一行内容写入至LCD帧缓存即可。
// calculate overlay
for( col = 0; col < width; col++ )
{
u8 pixel_high, pixel_low = 0;
if (!flip) { // no flip
switch(rotate)
{
case 0x00: // 0deg
pixel_high = p[2*(width*row+col)];
pixel_low = p[2*(width*row+col)+1];
break;
case 0x01: // 90deg
pixel_high = p[2*(width*col+(239-row))];
pixel_low = p[2*(width*col+(239-row))+1];
break;
case 0x02: // 180deg
pixel_high = p[2*(width*(239-row)+(239-col))];
pixel_low = p[2*(width*(239-row)+(239-col))+1];
break;
case 0x03: // 270deg
pixel_high = p[2*(width*(239-col)+row)];
pixel_low = p[2*(width*(239-col)+row)+1];
break;
default:
pixel_high = 0;
pixel_low = 0;
}
}
else { // vertical flip (source file)
switch(rotate)
{
case 0x00: // 0deg
pixel_high = p[2*(width*(239-row)+col)];
pixel_low = p[2*(width*(239-row)+col)+1];
break;
case 0x01: // 90deg
pixel_high = p[2*(width*(239-col)+(239-row))];
pixel_low = p[2*(width*(239-col)+(239-row))+1];
break;
case 0x02: // 180deg
pixel_high = p[2*(width*row+(239-col))];
pixel_low = p[2*(width*row+(239-col))+1];
break;
case 0x03: // 270deg
pixel_high = p[2*(width*col+row)];
pixel_low = p[2*(width*col+row)+1];
break;
default:
pixel_high = 0;
pixel_low = 0;
}
}
//R
tempR = buff[3*col] + (pixel_high&0xF8);
if ( tempR > 0xFF ) buff[3*col] = 0xFF;
else buff[3*col] = tempR;
//G
tempG = buff[3*col+1] + (((pixel_high&0x07)<<5)|((pixel_low&0xE0)>>3));
if ( tempG > 0xFF ) buff[3*col+1] = 0xFF;
else buff[3*col+1] = tempG;
//B
tempB = buff[3*col+2] + ((pixel_low&0x1F)<<3);
if ( tempB > 0xFF ) buff[3*col+2] = 0xFF;
else buff[3*col+2] = tempB;
}
// write new area
LCD_AddressSetWrite( x, y+row, x+width-1, y+row );
for( col = 0; col < width; col++ )
{
LCD_WriteHalfWord( LCD_ConvertColor( buff[3*col]<<16 | buff[3*col+1]<<8 | buff[3*col+2] ) );
}
该步骤中使用的图片可以仿照font.h文件的做法,在将图片通过Image2Lcd等工具转换为c数组后,存入image.h文件,并在main文件中使用#include "image.h"调用。
2.3 LCD背光亮度调节
赤菟开发板使用芯片的PB14引脚驱动LCD模块的背光,该引脚可连接到TIM1的通道2反相输出(CH2N)获取PWM信号。原API中提供了一个半成品的PWM初始化,但是其中错漏颇多,要等后续更新一下。这里我们可以选择自己动手把PWM功能修好。
首先在lcd_fsmc_init函数中找到以下代码:
RCC_AHBPeriphClockCmd(RCC_AHBPeriph_FSMC,ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB|RCC_APB2Periph_GPIOD|RCC_APB2Periph_GPIOE,ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0|GPIO_Pin_1|GPIO_Pin_4|GPIO_Pin_5|GPIO_Pin_8|GPIO_Pin_9|GPIO_Pin_10|GPIO_Pin_14|GPIO_Pin_15;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOD, &GPIO_InitStructure);
将RCC_APB2PeriphClockCmd中的RCC_APB2Periph_GPIOG一项删去,因为芯片上并没有GPIOG;然后将此处的PB14初始化代码删除,因为FSMC并不需要用到该引脚。
随后按照《CH32V307教程 [第三集] [时钟]》当中的指示,修改TIM1_PWMOut_Init函数。
最后将lcd_init函数中最后的GPIO_SetBits(GPIOB,GPIO_Pin_14)函数替换为TIM1_PWMOut_Init函数即可。
在完成初始化之后,我们可以通过这样的一个函数来调整LCD的亮度(此处以计数值arr=100为例,若使用了其他的计数值,则根据计数值设定亮度的最大值即可):
void LCD_SetBrightness(u8 brightness)
{
if (brightness > 100) brightness = 100;
TIM_SetCompare2( TIM1, brightness );
}
3 整体流程和其他杂项
既然轮子已经造好了,接下来就是简单的搭积木了。
首先完成各项外设和器件的初始化,此处选择了±1000°/s和±16g作为IMU的量程范围:
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
Delay_Init();
USART_Printf_Init(115200);
// report clock rate
u32 SystemCoreClock_MHz = SystemCoreClock / 1000000;
u32 SystemCoreClock_kHz = (SystemCoreClock % 1000000) / 1000;
printf("System Clock Speed = %d.%03dMHz\r\n", SystemCoreClock_MHz,SystemCoreClock_kHz);
LCD_Init();
LCD_SetBrightness(75);
crack_repair_flag = 1;
IMU_Init();
IMU_SetGyroFsr(0x02);
IMU_SetAccelFsr(0x03);
IMU_SetRate(1000);
IMU_SetLPF(500);
随后利用TIM6和TIM7设置两个定时器中断,分别为1kHz和30Hz。1kHz的时钟信号tick用于传感器数据的采集以及判断,同时负责time的计时(计时单位为ms),而30Hz的信号refresh_flag用于LCD的刷新。由于LCD的刷新相对比较耗时,若不断刷新的话会挤占IMU采样的时间,导致错过大量数据,因此建议限制LCD刷新率,或将IMU的采样放在中断回调函数当中执行。定时器的使用方法同样可以参考《CH32V307教程 [第三集] [时钟]》。
以上是程序的初始化部分,接下来是主循环的内容。
在收到tick信号后,程序需要完成IMU数据的采集、警告信息的判断、碎屏阈值的判断。
首先采集IMU的六轴数据(此处的采集函数以float格式输出转换后的真实值),并根据三轴分量计算总加速度与总角速度。由于处理器不支持math.h中提供的sqrt函数,因此这里直接将浮点数乘以100后转换为整数,对整数计算平方根(对整数计算平方根的速度远快于浮点数)。
tick = 1; time = 0; refresh_flag = 1;
Button_INT_Init(); // wakeup button
Tick_TIM_Init( 1000-1, SystemCoreClock_MHz-1 ); Tick_TIM_INT_Init(); // 1kHz tick & time
Refresh_TIM_Init( 33333-1, SystemCoreClock_MHz-1 ); Refresh_TIM_INT_Init(); // 30Hz refresh_flag
printf("Display: Timer & IMU data\r\n");
printf("\r\n");
所使用的整数平方根函数如下(Digit-by-digit algorithm):
//square root function for int32
uint32_t sqrt32(uint32_t n)
{
uint32_t c = 0x8000;
uint32_t g = 0x8000;
for(;;)
{
if(g*g > n)
g ^= c;
c >>= 1;
if(c == 0)
return g;
g |= c;
}
return 0;
}
随后根据总加速度判断是否触发警告。此处的warning_flag是在显示刷新时显示警告信息的标志位,而两个时间戳time_stamp_trigger和time_stamp_record是为了在触发警告后让警告数据在屏幕上停留一段时间以便阅读。
// IMU warning
if (acc_total_100x > acc_threshold_warning_100x)
{
time_stamp_trigger = time; warning_flag = 1;
if ( (acc_total_100x > acc_record_100x) || (time - time_stamp_record > 1000) )
{
time_stamp_record = time;
acc_record_100x = acc_total_100x;
}
}
else if (time - time_stamp_trigger >= 1000)
{
warning_flag = 0;
acc_record_100x = 0;
}
之后再根据总加速度判断是否触发碎屏效果。这里的代码添加了判断加速度方向的功能,并将加速度的方向作为碎屏信号crack_flag以备使用(尽管并没有实装碎屏效果的旋转功能)。
// IMU glass crack
if ( ( acc_total_100x > acc_threshold_crack_100x ) )
{
// direction record (for crack rotation)
if ( abs(acc[0]) > abs(acc[1]) )
{
if ( acc[0] > 0 ) { // east
crack_flag = 0x10;
if ( acc[1] > 0 ) crack_flag |= 0x02; // east-north
else crack_flag |= 0x08; // east-south
}
else { // west
crack_flag = 0x40;
if ( acc[1] > 0 ) crack_flag |= 0x02; // west-north
else crack_flag |= 0x08; // west-south
}
}
else
{
if ( acc[1] > 0 ) { // north
crack_flag = 0x20;
if ( acc[0] > 0 ) crack_flag |= 0x01; // north-east
else crack_flag |= 0x04; // north-west
}
else { // south
crack_flag = 0x80;
if ( acc[0] > 0 ) crack_flag |= 0x01; // south-east
else crack_flag |= 0x04; // south-west
}
}
}
在完成数据采集以及警告和碎屏阈值的判断后,将tick信号清零,以备下一次触发。
在收到显示刷新信号refresh_flag后,代码就可以根据warning_flag、crack_flag等标志位显示对应的内容,包括加速度与角速度的实时分量、总量,警告标志、计时信息等。这部分算是UI设计,建议可以尽量自由发挥。
在显示数据时,需要注意编译器不支持对小数直接进行格式化(即不支持%+6.1f或%5e这样的格式化参数),因此需要将小数转换为整数部分+小数部分的两个整数进行显示,举例如下:
int32_t acc10x = (int)(acc[i] * 10);
if (acc10x > 9999) acc10x = 9999;
if (acc10x < -9999) acc10x = -9999;
LCD_ShowString(16, 16*(i+1), 16, TRUE, "%c:%+4d.%01d", 'X'+i, acc10x/10, abs(acc10x%10));
LCD_ShowString(84, 16*(i+1), 16, TRUE, "m/s2");
在刷新流程的最后,如果收到了碎屏效果的标志位crack_flag,那么我们就可以利用修改过的LCD驱动API来显示碎屏效果以及提示信息,然后将暂停显示的标志位crack_halt_flag置1,在按下修复键之前暂停屏幕的刷新,避免新的数据刷到碎屏效果的上方(修复键利用中断进行处理,在中断回调函数中将各个标志位恢复,方法可参考《CH32V307教程 [第二集] [GPIO]》)。
// IMU glass cracking display
// display after warning message
if (crack_flag)
{
switch(crack_flag)
{
case 0x12: // east-north
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 0, TRUE, img_crack );
break;
case 0x18: // east-south
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 0, FALSE, img_crack );
break;
case 0x42: // west-north
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 2, FALSE, img_crack );
break;
case 0x48: // west-south
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 2, TRUE, img_crack );
break;
case 0x21: // north-east
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 1, FALSE, img_crack );
break;
case 0x24: // north-west
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 1, TRUE, img_crack );
break;
case 0x81: // south-east
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 3, TRUE, img_crack );
break;
case 0x84: // south-west
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 3, FALSE, img_crack );
break;
default:
LCD_OverlayImage( 0, 0, LCD_W, LCD_H, 0, FALSE, img_crack );
}
LCD_SetColor(BLACK, CYAN);
LCD_ShowString(16, 156, 16, FALSE, "Press to repair");
LCD_ShowString(64, 152, 24, FALSE, "Wake_Up");
crack_halt_flag = 1;
}
// clear flag
refresh_flag = 0;
这样我们就做好了一个能用来吓小朋友的碎屏模拟器了…(笑)