• [体感游戏] 1、MPU6050数据采集传输与可视化


    最近在研究体感游戏,到目前为止实现了基于51单片机的MPU6050数据采集、利用蓝牙模块将数据传输到上位机,并利用C#自制串口数据高速采集软件,并且将数据通过自制的折线图绘制模块可视化地展示出来等功能。本文将主要对实现这意见单系统中遇到的问题做一个小结——其中包括:

    1、基于51的MPU6050模块通信简介(入门级)

    2、陀螺仪数据采集与传输及帧格式介绍(小技巧)

    3、基于C#的串口接收函数(C#基本知识)

    4、多线程数据池解决高速串口实时性问题(难点)

    5、折线图可视化模块(程序员基本功)

    关键词:MPU6050 蓝牙 C#串口 多线程 高速串口 折线图绘制


    1、基于51的MPU6050模块通信简介(入门级)

    因为是入门级,就先最简单的介绍如何利用51从MPU6050中读取数据吧(对于想知道卡尔曼滤波、俯角仰角、距离测量、摔倒检测、记步等算法的可能要在接下来介绍)。既然要和MPU6050通信,那么必不可少的是阅读芯片手册,如果您觉得亲自去看又长又多而且都是英文的手册很费时,不仿看看我找的简要版:

    MPU-60X0是全球首例9轴运动处理器。它集成了3轴MEMS陀螺仪,3轴MEMS加速计,以及1个可扩展的数字运动处理器DMP(Digital Motion Processor),可用I2C接口连接一个第三方的数字传感器,比如磁力计。扩展之后就可以通过其I2C或SPI接口输出一个9轴的信号。MPU-60X0也可以通过其I2C接口连接非惯性的数字传感器,比如压力传感器。

       

    MPU-60X0对陀螺仪和加速计分别用了三个16位的ADC,将其测量的模拟量转化为可输出的数字量。为了精确跟踪快速和慢速运动,传感器的测量范围是可控的,陀螺仪可测范围为±250,±500,±1000,±2000°/秒(dps),加速计可测范围为±2,±4,±8,±16g(重力加速度)。

    注:下图是采用串口助手将MPU6050采集的数据显示在上位机上,其中前三列输出为三维的加速度(这里的加速度包括地球本身的重力加速度),后三列为三维的角速度。

    但是这里的输出值并不是真正的加速度和角速度的值,上面说过,MPU是一个16位AD量程可程控的设备,这里设置的加速度传感器的测量量程为正负2g(这里的g为重力加速度),陀螺仪的量程为正负2000°/s。所以要用下面的公式进行转化:

    好了,有了上面的基础知识之后咱们就能尝试用51的I2C总线从MPU6050读取实时的3轴加速度和3轴角速度了。由于51本身不带有I2C总线通信协议,所以我们要自己实现一个I2C通信协议,下面是我从网上找的并稍加修改的一个I2C总线通信的代码:

     1 #include <REG52.H>
     2 #include <INTRINS.H>
     3     
     4 typedef unsigned char  uchar;
     5 typedef unsigned short ushort;
     6 typedef unsigned int   uint;
     7 
     8 //-----------------------------------------
     9 // 定义MPU6050内部地址
    10 //-----------------------------------------
    11 #define    SMPLRT_DIV      0x19    //陀螺仪采样率,典型值:0x07(125Hz)
    12 #define    CONFIG          0x1A    //低通滤波频率,典型值:0x06(5Hz)
    13 #define    GYRO_CONFIG     0x1B    //陀螺仪自检及测量范围,典型值:0x18(不自检,2000deg/s)
    14 #define    ACCEL_CONFIG    0x1C    //加速计自检、测量范围及高通滤波频率,典型值:0x01(不自检,2G,5Hz)
    15 #define    ACCEL_XOUT_H    0x3B
    16 #define    ACCEL_XOUT_L    0x3C
    17 #define    ACCEL_YOUT_H    0x3D
    18 #define    ACCEL_YOUT_L    0x3E
    19 #define    ACCEL_ZOUT_H    0x3F
    20 #define    ACCEL_ZOUT_L    0x40
    21 #define    TEMP_OUT_H      0x41
    22 #define    TEMP_OUT_L      0x42
    23 #define    GYRO_XOUT_H     0x43
    24 #define    GYRO_XOUT_L     0x44    
    25 #define    GYRO_YOUT_H     0x45
    26 #define    GYRO_YOUT_L     0x46
    27 #define    GYRO_ZOUT_H     0x47
    28 #define    GYRO_ZOUT_L     0x48
    29 #define    PWR_MGMT_1      0x6B    //电源管理,典型值:0x00(正常启用)
    30 #define    WHO_AM_I        0x75    //IIC地址寄存器(默认数值0x68,只读)
    31 #define    SlaveAddress    0xD0    //IIC写入时的地址字节数据,+1为读取
    32 
    33 //-----------------------------------------
    34 // I2C总线通信函数
    35 //-----------------------------------------
    36 void  I2C_Start();                  //I2C起始信号
    37 void  I2C_Stop();                   //I2C停止信号
    38 void  I2C_SendACK(bit ack);         //I2C发送应答信号[入口参数:ack (0:ACK 1:NAK)]
    39 bit   I2C_RecvACK();                //I2C接收应答信号
    40 void  I2C_SendByte(uchar dat);      //向I2C总线发送一个字节数据
    41 uchar I2C_RecvByte();               //从I2C总线接收一个字节数据
    42 void  Single_WriteI2C(uchar REG_Address,uchar REG_data);//向I2C设备写入一个字节数据
    43 uchar Single_ReadI2C(uchar REG_Address);                //从I2C设备读取一个字节数据
    44 
    45 //-----------------------------------------
    46 // 通过I2C和MPU6050通信的函数
    47 //-----------------------------------------
    48 void InitMPU6050();                //初始化MPU6050
    49 int GetData(uchar REG_Address);    //合成数据

    如果你没搞过硬件又从未听说过I2C,那么想想socket的握手再看看上面36~43行的有关ACK、Send、Write的函数大概能明白I2C的功能。当我们实现I2C的通信函数之后就可以与带有I2C通信接口的芯片进行通信,那么怎样通信呢?其实很简单——你可以把每个芯片比做为一个巨大的储物柜,储物柜里每个抽屉里存着相应的东西,你想让佣人帮你去拿个东西,只要告诉佣人对应的抽屉号就行了。这里I2C总线相当于这个佣人,每个抽屉相当于芯片中的寄存器,抽屉号相当于寄存器地址。当你想设置芯片的某些属性时是向对应的寄存器内写数据,当想从芯片内获取相关数据时,就要通过I2C向对应的地址写数据然后接收芯片返回的数据。这里的8~31行为MPU-6050芯片内几个常用的寄存器地址,前四个常用来作为设置芯片工作属性,15~28共14个寄存器地址用来获取传感器的3轴加速度、3轴角速度和温度的数据(这里每一种信息都包括H和L两位,是由于8位表示不完该数据,于是分高低两部分)

    这样我们便不难理解InitMPU6050()和GetData(uchar REG_Address)函数:初始化函数是向相应的地址写初始化配置数据(关于0x00x07等意思请参看MPU6050寄存器版说明书),而GetData则是传入想获得数据项的低地址,然后连续读取当前地址数据和下一地址数据合成为想要的项目数据(上面讲了数据分高低部分)。

     1 //-----------------------------------------
     2 //初始化MPU6050
     3 //-----------------------------------------
     4 void InitMPU6050()
     5 {
     6     Single_WriteI2C(PWR_MGMT_1, 0x00);    //解除休眠状态
     7     Single_WriteI2C(SMPLRT_DIV, 0x07);
     8     Single_WriteI2C(CONFIG, 0x06);
     9     Single_WriteI2C(GYRO_CONFIG, 0x18);
    10     Single_WriteI2C(ACCEL_CONFIG, 0x01);
    11 }
    12 //-----------------------------------------
    13 //合成数据
    14 //-----------------------------------------
    15 int GetData(uchar REG_Address)
    16 {
    17     uchar H,L;
    18     H=Single_ReadI2C(REG_Address);
    19     L=Single_ReadI2C(REG_Address+1);
    20     return (H<<8)+L;   //合成数据
    21 }

    2、陀螺仪数据采集与传输及帧格式介绍(小技巧)

    上面我们已经知道单片机如何利用I2C设置MPU6050的工作属性,以及从MPU6050获得3轴加速度和3轴角速度的数据。那么接下来将介绍单片机是如何将数据通过蓝牙发送给上位机的。如下图左半部分,下位机部分包括一个MPU6050、一个单片机、一个电源模块,以及一个蓝牙模块。对于蓝牙模块我不想做过多的讲解(我记得我已经写了不下于3次关于手机、PC等和下位机通信的教程了:(如果是想用安卓手机和蓝牙模块通信来实现遥控功能的话,可以参考:http://www.cnblogs.com/zjutlitao/p/4231635.html;想用笔记本和蓝牙模块通信来实现遥控功能的话可以参考:http://www.cnblogs.com/zjutlitao/p/3886826.html

     

    其实,利用串口蓝牙模块单片机要做的工作和对串口进行的操作一样,对串口写数据则送至蓝牙模块将数据发出,当外部有数据传送过来时,单片机可以用相应的中断捕获该事件,然后接收消息。因此主函数中初始化串口和MPU6050之后就进入循环数据发送状态,在循环中GetData是上面介绍的获得3轴加速度、3轴角速度或温度的值的函数,SendData则是将int类型的值转换为字符串然后一位一位的发送出去,而最开始和最后分别发送一个#和$作为该帧的开始和结束标志位,具体格式如下:

    #    1 2 3 5 4 - 2 1 3 3 2 - 2 1 1 2 5 $

     

    注:符号位要么为'-',要么为空。

     1 //-----------------------------------------
     2 //主程序
     3 //-----------------------------------------
     4 void main()
     5 { 
     6     delay(500);        //上电延时        
     7     init_uart();
     8     InitMPU6050();    //初始化MPU6050
     9     delay(150);
    10     while(1)
    11     {
    12         SeriPushSend('#');//
    13         SendData(GetData(0x3B));    //X轴加速度
    14         SendData(GetData(0x3D));    //Y轴加速度
    15         SendData(GetData(0x3F));    //Z轴加速度
    16         SeriPushSend('$'); //结束
    17         delay(20);
    18     }
    19 }

    3、基于C#的串口接收函数(C#基本知识)

    上面讲到下位机通过串口蓝牙将数据发送给上位机,那么上位机如何接收蓝牙信号呢?其实以我的笔记本为例,因为笔记本内置蓝牙模块,所以无需在上位机上独立安装一个USB-蓝牙模块。而上位机操作蓝牙模块和操作串口几乎一模一样。如下面的C#程序,当点击连接按钮时实例化SerialPort,设置端口号、读超时、然后实例化一个串口数据接收事件句柄(这里PortDataReceived作为数据接收的回调函数)。

     1 //Create a serial port for Connection
     2 SerialPort Connection = new SerialPort();
     3 private void btn_link_Click(object sender, EventArgs e)
     4 {
     5     if (!Connection.IsOpen)
     6     {
     7         //Start
     8         //Status = "正在连接...";
     9         Connection = new SerialPort();
    10         btn_link.Enabled = false;
    11         Connection.PortName = PortList.SelectedItem.ToString();
    12         Connection.Open();
    13         Connection.ReadTimeout = 10000;
    14         Connection.DataReceived += new SerialDataReceivedEventHandler(PortDataReceived);
    15         //Status = "连接成功";
    16         timer1.Start();
    17     }
    18 }

    在PortDataReceived中,只要简单调用Connection.Read(data, 0, length);就能从串口缓冲区读取数据到data中。

    1 private void PortDataReceived(object o, SerialDataReceivedEventArgs e)
    2 {
    3     byte[] data = new byte[length];
    4     int num=Connection.Read(data, 0, length);
    5     datepool.push_back(data,num);//实际接收的不一定是length,之前一直错
    6     Connection.DiscardInBuffer();
    7     Connection.DiscardOutBuffer();
    8 }

    注:本来是每次读取1byte放入数据池,结果出现程序运行速度越来越慢,本以为是上面的数据池设计的有问题,结果把数据池里的线程注释掉改为ask函数来每次需要数据时才获得,但是问题并不在于此;于是想到可能是绘制折线图的函数有问题,但是重查了一遍发现问题不在于此;于是仔细测量每个过程耗时,发现每个模块耗时正常,最后发现是由于串口缓冲区数据积累造成程序变慢,(因为下位机每20ms发送一次20byte的数据给上位机,上位机若一次不接收完所有数据,将会造成每次都有剩余而逐渐变慢),于是直接改成每次接收20byte,问题得到解决。


    4、多线程数据池解决高速串口实时性问题(难点)

    由于下位机10ms发送一次20byte的数据,上位机一方面要做好接收工作,保证数据不拥挤在串口接收缓冲区;另一方面也要实时获取当前从串口读到的最新数据。如果采用传统多线程+锁的机制是可以的,但是当多线程中加入锁势必会影响程序执行效率,通过综合分析该问题最终抽象出一个特殊的数据模型——自动更新的环形栈:

    这样,当采用多线程时,用一个类似于栈的环状栈结构体(实时从串口读数据放入数据池,数据池用p_write标记最新数据存储位置,当外部程序想得到最新数据时,调用ask程序,ask程序从当前p_write向前取40个数据(因为有效数据长度为20,一次取40保证至少有一个有效数据),然后从这40个数据中找出有效信息,赋值给X,Y,Z;然后外部程序可以直接用对象访问X,Y,Z),通过适当调节环的容量达到自我覆盖的效果,同时根据p_write指针可以实时取得最新数据。

     1 /// <summary>
     2 /// 询问当前值
     3 /// </summary>
     4 /// <returns>如果解析到则返回真</returns>
     5 public bool ask()
     6 {
     7     i = 0;//立刻将相应的40个字符复制出来
     8     p_read_from = p_write - 40;
     9     while (i < 40)
    10     {
    11         str[i] = pool[(p_read_from + pool_size) % pool_size];
    12         i++;
    13         p_read_from++;
    14     }
    15     i = 39;
    16     while (i > 18 && str[i] != '$') i--;
    17     if (i == 18) return false;
    18     i--;
    19     data_Z = 0;
    20     for (int j = 4; j > -1; j--)
    21     {
    22         data_Z *= 10;
    23         data_Z += (str[i - j] - '0');
    24     }
    25     if (str[i - 5] == '-') data_Z = -data_Z;
    26     i -= 6;
    27 
    28     data_Y = 0;
    29     for (int j = 4; j > -1; j--)
    30     {
    31         data_Y *= 10;
    32         data_Y += (str[i - j] - '0');
    33     }
    34     if (str[i - 5] == '-') data_Y = -data_Y;
    35     i -= 6;
    36 
    37     data_X = 0;
    38     for (int j = 4; j > -1; j--)
    39     {
    40         data_X *= 10;
    41         data_X += (str[i - j] - '0');
    42     }
    43     if (str[i - 5] == '-') data_X = -data_X;
    44 
    45     X = data_X;
    46     Y = data_Y;
    47     Z = data_Z;
    48     return true;
    49 }
    50 
    51 /// <summary>
    52 /// 将数据输入数据池
    53 /// </summary>
    54 /// <param name="date">数据</param>
    55 /// <param name="length">长度</param>
    56 internal void push_back(byte[] date, int length)
    57 {
    58     for (int i = 0; i < length; i++)
    59     {
    60         pool[p_write++] = date[i];
    61         if (p_write == pool_size) p_write = 0;
    62     }
    63 }

    5、折线图可视化模块(程序员基本功)

    通过上面几步我们已经可以将下位机的陀螺仪3轴的加速度收集过来了,但是如果先将数据收集好,然后再用matlab绘制,我们很难知道哪个动作对应哪个数据,不利于我们观察效果(虽然matlab上自带串口接口,但是LZ就是任性!有一张好看的脸,还是想着靠实力赢得地位,哈哈哈~)。

    如本节小标题括号内所示,在C#里写一个绘制折线图的程序应该属于我们的基本功(我可不是调用相应的绘图接口哦!),其大致思想就是用一个List存储num个数据,当list中的数据少于num个时则不断添加,当list内的数据大于num个时,则从尾部进来一个的同时从头部删除一个(这样才能实现perfect的效果)。

    注:其实中间还出现了一个逻辑错误性小插曲:原初写好之后,本以为能够实现高效数据采集显示,但是仔细观察发现还是有很大延时,但是旁边的数据显示却非常实时。这是为什么呢?查找了一会最终发现问题出在折线图绘制上——本来采用固定的模式(一张图能存放多少数据点就用vector<int>P/Q/R在初始化的时候存放这么多点,然后每次有一个新的数据过来时就会将新数据加到vector后面,同时删除最前面的一个数据,这样做是为了方便初始vector里没有数据绘制折线图错误的问题),可是问题就出在这!咋一看这种思路很好,初始化vector中放num个点,每次新的来到将最前面一个数据冲掉,这样这个vector始终保持着num个点,且最新的在最后面,整个折线图能反应实时情况。但是由于我为了“安全”起见,在vector初始化时多Add几个数据,这样导致vector中的数据量N>折线图一次能呈现的数据量num,所以最新的数据总会在之后出现!当时没有想到是这个原因,就直接改了下DateLineChar函数,实现根据vector大小自动绘制的算法(这样就不用预先在vector中装入一定量的值了)


    6、预告与小结(预知后事如何,请听下回分解)

    上面我只是简单收集了MPU6050的3轴加速度数值,当MPU6050位置固定好之后,我们就能根据数据推测其具体的姿态。例如:

    绿色的z轴方向的加速度先高后低,红色y轴方向加速度先低后高,蓝色x轴方向加速度和y轴类似,但是比y轴幅度变化小,而后半周期数值正负正好相反。那么MPU6050运动过程大致为:在y轴方向上做往返运动,同时在x轴和z轴方向有稍微的偏转。(水平静止放置时z轴为重力加速度,x,y为0)

    绿色的z轴变化不大,红色的y和蓝色的x同步类正弦变化。呵呵,这个运动状态分析起来就不太容易了~不过没关系,接下来我们要进一步获取并计算MPU6050的倾角,甚至是利用卡尔曼滤波计算MPU6050的运动距离,最终达到perfect的运动跟踪效果~

    链接

    51MPU6050采集代码:http://pan.baidu.com/s/1c0yE7Ws

    4月2号总工程:http://pan.baidu.com/s/1hqzSt7Y (我用)

    4月7号总工程:http://pan.baidu.com/s/1pJwq6qZ (我用)

    github:https://github.com/beautifulzzzz/C4plus/tree/master/体感游戏

    预习用1:[芯片][MPU6050] MPU60X0的DMP相关链接

    预习用2:[stm32] MPU6050 HMC5883 Kalman 融合算法移植

  • 相关阅读:
    ZOJ 3332 Strange Country II
    ZOJ 3331 Process the Tasks(双塔DP)
    ZOJ 3326 An Awful Problem(模拟)
    HDU 1796 How many integers can you find(容斥原理)
    HDU 4059 The Boss on Mars(容斥原理)
    HDU 4135 Co-prime(容斥原理)
    HDU 5677 ztr loves substring(回文串加多重背包)
    CodeForces 668B Little Artem and Dance
    CodeForces 667A Pouring Rain
    Java实现 LeetCode 764 最大加号标志(暴力递推)
  • 原文地址:https://www.cnblogs.com/zjutlitao/p/4396653.html
Copyright © 2020-2023  润新知