【.NET 与树莓派】i2c(IIC)通信
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的输入电压不能高于 12V,因为这个引脚没有保护措施,过压会炸板子)。
编译 .NET 应用并上传到树莓派,然后运行,输入不同数字,Arduino 会回复对应的消息。
好了,完工,示例代码请点击这里下载。
有人会问,树莓派有没有山寨版?有,比如橙子派什么的,某宝上还有荔枝派。这些板子大多数不贵,但是不太敢买,还是买原装的好一些。 Arduino 是开源板子,版本也很多(也有山寨的),像 DFRobot 好像也可以,还有很多十几块的没名字的,所以也叫不出什么版本,只能说山寨了。不过说实话,还是原装的运行稳定,尽管贵一些。老周当初也是买了几块那种十几块的,上传程序经常出错,装驱动也头疼。原版的稳定,起码用到现在也出过错,也不用找驱动,Windows 能识别。
所以说嘛,一分价钱一分货,后来老周干脆放点血买原装版本的。