i2c(或IIC)協議使用兩根線進行通訊(不包括電源正負極),它們分別為:
1、SDA:資料線,IIC 協議允許在單根資料線上進行雙向通訊——這條線既可以傳送資料,也可以接收資料。
2、SCL:時鐘線,注意了,這個時鐘線跟我們平時所說的時鐘沒什麼關係,不要以為這根線是用來接手錶的。其實,這裡所說的“時鐘”,更像是我們看音樂會的時候,站在前面最中央處的那個指揮者,或者說節拍器。它的作用就是協調硬體之間的傳輸節奏,做到步伐一致,不然資料就會亂了。比如,IIC通訊裡面,當時鍾線的電平拉高後,資料線的內容就不能改變,也就是說,SCL高電平時,不能寫資料,但可以讀。當SCL下降為低電平後,才能向資料線(SDA)寫入資料。
IIC 通訊以 Start 訊號開始,以 Stop 訊號結束。
傳送開始訊號的方法:拉高SCL和SDA的電平,在SCL處於高電平的情況下把SDA的電平拉低。
傳送結束訊號的方法:拉高SCL的電平,在SCL處於高電平的情況下,把SDA的電平拉高。
這其中,你會發現規律:無論是開始訊號還是結束訊號,SCL 都處於高電平,前文提過,時鐘線拉高就是固定資料線上的內容,顯然,在開始和結束訊號中,是不能傳資料的。在SDA上,開始訊號和結束訊號剛好相反,Start 時電平拉低,Stop 時電平拉高。下面這張圖是從 IIC 的協議手冊上盜來的。
寫入資料時,主機先把時鐘線SCL拉低,然後寫入一個二進位制位(高電平為1,低電平為0),然後把SCL拉高,此時從機讀取這個二進位制位。接著第二個二進位制位也是這樣,主機拉低SCL,寫SDA,再拉高SCL,從機讀……當傳送完 8 個二進位制(一個位元組)後,在第九個時鐘週期,主機把SDA拉高(有時候需要切換為輸入模式),再拉高SCL,等待從機寫應答;如果主機從SDA上讀到低電平,表示從機有應答(你的紅包我收到了),要是讀到高電平,表示無應答(你啥時候發的紅包?我都沒看到)。
從機向主機傳送資料的過程也一樣,SCL仍然由主機操控,SCL拉低後向SDA寫資料,SCL拉高後就不能寫了,此時主機讀SDA上的資料。通常主機在接收完最後一個位元組後可以不應答(讓SCL和SDA同時高電平),或直接傳送 Stop 訊號終止通訊(畢竟主機權力大,生死予奪都是主機說了算)。
上面的東東看得好像很亂,剛接觸時就是這樣的,見多了就熟悉了。可以大概地總結一下:
1、SCL低電平時,傳送方寫SDA;
2、SCL高電平鎖定SDA,傳送方不能寫,接收方讀;
3、應答訊號:SCL高 + SDA低---> 有應答;SCL高 + SDA高---> 無應答。
其實,我們實際開發中,不瞭解協議時序也沒關係,我們也很少手動去模擬 IIC 通訊過程。尤其是像樹莓派這種帶作業系統的開發板,更不應該手動去模擬,而是直接用現成的庫(或者API)。不管你什麼語言,你都是先向系統傳送指令,然後系統去控制硬體,效率上都無法保證。而且,IIC 協議都是標準化的協議,你每次寫程式都去手動模擬通訊,浪費時間,意義也不大。這好比我們在 Socket 程式設計時一樣,你不可能總去自己寫個協議再來通訊吧。一般都會直接用 TCP 或 UDP 協議。
所以,對於IIC協議也是如此,我們瞭解一下就行了。老週上面在介紹時也是簡略化的,所以你可能看得有點暈,若想深入理解,可以看資料手冊。畢竟老周不可能把手冊上的內容複製過來的,那就是抄襲了。
好,繼續。
IIC 匯流排可以掛多個從機,從機不會主動發起通訊,都是由主機發起通訊的。因此,主機必須知道要跟哪個從機通訊,故掛到匯流排上的從機必須擁有唯一的地址——這就是所謂的器件地址。就像一個內網中的 N 臺電腦一樣,每臺電腦都要給它分配唯一的 IP 地址,這樣你才能知道你正在跟誰說話。哪怕是 UDP 廣播,也是有廣播地址,192.168.1.255。
IIC 器件地址,7位地址最常見,當然也有 10 位的(老周買的各種模組中都沒見到),這個【位】是二進位制位,常用的 7 位就是7個二進位制位。7 位地址格式如下:
低位在右邊,從右到左,我們看到第 1 位是 R/W,表示讀寫位,就是用來告訴從機,我要讀資料還是寫資料。“W”頭頂上有個橫線,表示低電平,即 0 表示寫,1 表示讀。從第二位到第八位就是從機的地址了。所以,現在你知道為啥地址是7位的原因了吧,就是要留一位來確定讀還是寫。
假如某品牌的自動鏟屎機使用 IIC 通訊協議,標籤上告訴你它的從機地址是 0x47,先把它弄成二進位制。
0100 0111
第八位是0,所以有效的值是第一位到第七位,屬7位地址。當主機要向鏟屎機發起通訊時,需要把地址左移一位,變成:
1000 1110
左移後,第二到第七位表示器件地址,就能空出第一位用來放讀寫標誌了。如果要寫資料,就向從機發 1000 1110;要讀資料,就向從機發 1000 1111。
注意,我們在呼叫庫的時候,是不需要左移的,比如我們.NET中用的 System.Device.Gpio 庫,內部會自動進行左移。
好了,基礎知識就介紹到這兒,相信你對 IIC 協議已經有大概的瞭解,下面我們們來看看 System.Device.Gpio 給我們準備了哪些類。
A、名稱空間:System.Device.I2c
B、I2cConnectionSettings 類,用來配置 IIC 通訊的必要引數。其實就兩個:第一個是匯流排ID,一般系統預設的是 1。第二個引數就是從機的地址(不需要左移)。
C、I2cDevice,核心類,用於讀寫資料。這是個抽象類,內部根據不同的系統有各自的實現版本,但我們在呼叫時不用關心是哪個版本。
D、I2cBus,這個一般可以不用,如果硬體上有多個匯流排,可以使用這個類指定使用哪個匯流排。其實樹莓派有兩路 i2c 匯流排的,我們平時用的是 i2c-1,還有一個 i2c-0 是隱藏的,留給攝像頭用的,可以參考官方文件。
i2c_arm Set to "on" to enable the ARM's i2c interface (default "off") i2c_vc Set to "on" to enable the i2c interface usually reserved for the VideoCore processor (default "off") i2c An alias for i2c_arm
“i2c”和“i2c-arm”是同一個東東,只是名字不同罷了,所以,一塊板子上就有 “i2c-arm”和“i2c-vc” 兩路匯流排,“i2c-vc”分配給攝像頭以及視訊相關的介面使用。當然,你也可以拿“i2c-vc”作為常規匯流排用的,要把視訊相關的介面禁用。如果兩路都拿來用了,那麼樹莓派上就有兩個匯流排ID,一個是 0,一個是 1。
另外,也可以使用軟體模擬 i2c,這樣你就可以弄出幾個匯流排出來了——i2c-2、i2c-3、i2c-150 …… 配置如下:
Name: i2c-gpio Info: Adds support for software i2c controller on gpio pins Load: dtoverlay=i2c-gpio,<param>=<val> Params: i2c_gpio_sda GPIO used for I2C data (default "23") i2c_gpio_scl GPIO used for I2C clock (default "24") i2c_gpio_delay_us Clock delay in microseconds (default "2" = ~100kHz) bus Set to a unique, non-zero value if wanting multiple i2c-gpio busses. If set, will be used as the preferred bus number (/dev/i2c-<n>). If not set, the default value is 0, but the bus number will be dynamically assigned - probably 3.
這個只是提一下,必要時可以用上,軟體模擬的介面通訊,效能和效率會相對差一點的。
樹莓派預設是不開啟 i2c 介面的,所以要在配置中將其開啟。
sudo raspi-config
找到介面選項。
選擇 P5 I2C 條目。
然後選擇“YES”。
或者簡單粗暴,修改 /boot/config.txt,加上這一行:
dtparam=i2c_arm=on
儲存退出。
這一次的 IIC 演示例項,老周不使用感測器。主要擔心有同學會誤解,因為很多電子模組/感測器都是通過讀寫暫存器的方式來控制的,於是有同學會以為 IIC 是操作寄存來傳遞資訊的。其實不然,跟 TCP 協議一樣,你可以用 IIC 傳遞任何位元組,只要能用二進位制表示的就沒問題了。
本例老周用一塊 Arduino (讀音:阿嘟伊諾,重音在後面,“伊諾”要讀出來,別讀什麼“阿丟諾”)開發板做為 IIC 從機,型號為 Uno R3(讀音:烏諾,義大利語“第一”的意思,表明這是 Arduino 的首套板子)。然後用樹莓派作為主機,來控制 Arduino。
Arduino 上使用 Wire 庫進行 IIC 通訊。首先要包含 Wire.h 標頭檔案。
#include <Wire.h>
在這個標頭檔案中,注意有這麼一行。
extern TwoWire Wire;
其實標頭檔案中宣告的封裝類名為 TowWire,然後在標頭檔案中用這個類宣告瞭一個變數 Wire,加上 extern 關鍵字使得其他程式碼能訪問到它,只要 include 這個標頭檔案就OK了。Wire 變數的賦值程式碼在 Wire.cpp 檔案中(提前給你例項化一個物件了)。
TwoWire Wire = TwoWire();
這樣佈局程式碼的好處在於:包含 Wire.h 檔案後,你馬上就能用了,直接就可以通過 Wire 變數呼叫 TwoWire 的公共成員了。
Arduino 程式碼一般有兩個特定的函式:
setup:初始化一些設定,比如某某引腳設定為輸出模式。此函式會在程式在燒進板子上時執行一次,然後就不會執行,進入 loop 函式死迴圈。但是,如果你按了復位按鈕,或者斷電了重新上電,就會執行 setup 函式。
loop:這個函式被放在一個 die 迴圈裡,它會無限期地被呼叫,只要程式被燒進開發板上就會永遠地迴圈。
有同學會問:C/C++不是有入口點嗎,main 函式滾哪裡去了?main 函式在 main.cpp 檔案中,編譯時由 Arduino 編譯器自動連結。
int main(void) { …… setup(); for (;;) { loop(); if (serialEventRun) serialEventRun(); } return 0; }
從入口點函式的邏輯中也看到,setup 函式只呼叫了一次,然後 loop 函式死迴圈。
好了,題外話結束,下面我們們回到 Arduino 的專案中,在setup函式中呼叫 Wire.begin 方法,開始 IIC 通訊。
void setup() { // 該從機的地址是 0x15 Wire.begin(0x15); // 註冊函式,當收到主機資料時呼叫 Wire.onReceive(onRecData); // 註冊函式,當主機請求資料時呼叫 Wire.onRequest(onRequestData); }
如果 Arduino 作為 IIC 主機,呼叫 begin 方法時不需要指定地址;此例中 Arduino 充當從機,所以要指定從機地址 0x15(你可以改為其他地址,一般用7位)。樹莓派上的應用會使用地址 0x15 來找到這塊 Uno 板子。
注意這兩行:
Wire.onReceive(onRecData);
Wire.onRequest(onRequestData);
這兩個方法的引數都是指向一個函式的指標,傳遞時直接寫函式名即可。onRecieve 方法註冊一個函式,當收到主機發來的資料時呼叫這個函式;onRepuest 方法註冊一個函式,當主機希望從機傳送資料時呼叫這個函式。
onRecData 和 onRequestData 函式定義如下:
void onRecData(int count) { if (Wire.available()) { // 讀一個位元組 readData = Wire.read(); } } void onRequestData(void) { // 向主機發資料 Wire.write(sendData); }
在這個示例中,主機只向從機發一個位元組,所以引數 count 可以忽略,直接呼叫 Wire.read 讀一個位元組,並儲存在變數 readData 中;傳送資料時呼叫 Wire.write 方法將 sendData 中的內容傳送給主機。在loop迴圈中,根據readData的值生成sendData的內容——根據主機發的命令生成回覆訊息。
void loop() { // 根據主機傳來的資料設定要發給主機的資料 switch (readData) { case 1: strcpy(sendData, "SB"); break; case 2: strcpy(sendData, "NB"); break; case 3: strcpy(sendData, "XB"); break; default: strcpy(sendData, "SB"); break; } }
完整程式碼結構如下;
#include <Wire.h> // 預宣告函式 void onRecData(int); void onRequestData(void); // 從主機讀到的資料 uint8_t readData = 0; // 要發給主機的資料 // 兩個字元 + \0,所以是3位元組 // 但這裡不需要 \0 char sendData[2] = { }; void setup() { // 該從機的地址是 0x15 Wire.begin(0x15); // 註冊函式,當收到主機資料時呼叫 Wire.onReceive(onRecData); // 註冊函式,當主機請求資料時呼叫 Wire.onRequest(onRequestData); } void loop() { …… } void onRecData(int count) { if (Wire.available()) { // 讀一個位元組 readData = Wire.read(); } } void onRequestData(void) { // 向主機發資料 Wire.write(sendData); }
接下來編寫樹莓派上的應用。
dotnet new console -n Myapp -o .
上面命令建立新的控制檯專案,名為Myapp,存放在當前目錄下。
新增 System.Device.Gpio 包的引用。
dotnet add package System.Device.Gpio
前文提到過,預設啟用的 IIC 匯流排是 i2c-1,所以例項化 I2cConnectionSettings 時,Bus ID 是1,從機地址是 0x15。
I2cConnectionSettings settings = new(1, 0x15);
隨後獲取 I2cDevice 物件。
I2cDevice device = I2cDevice.Create(settings);
本例的邏輯為:由使用者從鍵盤輸入數字(1、2、3),然後把這個數字發給從機(Arduino 板子),然後讀取從機回覆的資料。
byte input = 0; //讀取鍵盤輸入 Console.WriteLine("現在開始,輸入 end 可退出"); while (true) { Console.Write("請輸入:"); string sl = Console.ReadLine(); if (sl.Equals("end", StringComparison.InvariantCultureIgnoreCase)) { break; } // 將輸入內容轉為byte if (!byte.TryParse(sl, out input)) { input = 0; } /* //傳送資料 device.WriteByte(input); Thread.Sleep(3); // 接收從機發來的資料 Span<byte> buffer = stackalloc byte[3]; device.Read(buffer); */ // 可以一步到位,寫完就讀 byte[] sendBuf = new byte[] { input }; byte[] recvBuf = new byte[2]; device.WriteRead(sendBuf, recvBuf); string sr = Encoding.Default.GetString(recvBuf); Console.WriteLine("接收到的資料:{0}", sr); } device.Dispose();
可以呼叫 WriteXXX 類似方法寫入要傳送的資料,呼叫 ReadXXX 類似的方法讀入接收到的資料。也可以用 WriteRead 方法,寫入資料後接收資料,一步完成。
接線方法:樹莓派預設的 IIC 引腳為 GPIO 2和3,即板子上的3、5腳;Arduino 的 SDA 引腳為 A4,SCL引腳為 A5(A4和A5為模擬量讀入口,可重用為 IIC 介面),其實 Arduino 還有一路 IIC 介面,位於數字引腳 D13 、GND、AREF後面,就是這裡:
所以,接線圖如下:
也就是,樹莓派的 GPIO 2 接 Arduino 的 A4,樹莓派的 GPIO 3 接 Arduino 的 A5。另外,還要把兩個板子的 GND 連起來(共地),雖然不共地也能通訊,但可能存在被干擾的情況,共地後使用低電平的“0V”有了統一的參考標準,這樣傳遞訊號準確更高。
如果 Arduino 開發板沒有獨立供電,可以把樹莓派的 5V 與 Arduino 的 VIN 連線起來,用樹莓派給 Arduino 供電(VIN的輸入電壓不能高於 5.5V,因為這個引腳沒有保護措施,過壓會炸板子)。
編譯 .NET 應用並上傳到樹莓派,然後執行,輸入不同數字,Arduino 會回覆對應的訊息。
好了,完工,示例程式碼請點選這裡下載。
有人會問,樹莓派有沒有山寨版?有,比如橙子派什麼的,某寶上還有荔枝派。這些板子大多數不貴,但是不太敢買,還是買原裝的好一些。 Arduino 是開源板子,版本也很多(也有山寨的),像 DFRobot 好像也可以,還有很多十幾塊的沒名字的,所以也叫不出什麼版本,只能說山寨了。不過說實話,還是原裝的執行穩定,儘管貴一些。老周當初也是買了幾塊那種十幾塊的,上傳程式經常出錯,裝驅動也頭疼。原版的穩定,起碼用到現在也出過錯,也不用找驅動,Windows 能識別。
所以說嘛,一分價錢一分貨,後來老周乾脆發點血買原裝版本的。