最近在研究體感游戲,到目前為止實現了基於51單片機的MPU6050數據采集、利用藍牙模塊將數據傳輸到上位機,並利用C#自制串口數據高速采集軟件,並且將數據通過自制的折線圖繪制模塊可視化地展示出來等功能。本文將主要對實現這意見單系統中遇到的問題做一個小結——其中包括:
關鍵詞:MPU6050 藍牙 C#串口 多線程 高速串口 折線圖繪制
因為是入門級,就先最簡單的介紹如何利用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)函數:初始化函數是向相應的地址寫初始化配置數據(關於0x00\0x07等意思請參看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 }
上面我們已經知道單片機如何利用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 }
上面講到下位機通過串口藍牙將數據發送給上位機,那么上位機如何接收藍牙信號呢?其實以我的筆記本為例,因為筆記本內置藍牙模塊,所以無需在上位機上獨立安裝一個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,問題得到解決。
由於下位機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 }
通過上面幾步我們已經可以將下位機的陀螺儀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 融合算法移植