PiChat:GPIO(Raspberry)Pi2Pi 聊天协议。






4.67/5 (3投票s)
在 C++ 中设计和实现 Pi2Pi 聊天协议。
引言
树莓派(Raspberry Pi)是一款很棒的小型设备,大多数人可能都用它来运行 XBMC 作为媒体中心。我有一个正是为此目的而闲置的设备,但我从未强迫自己将其完全设置好。然后我突然想到,我还可以尝试玩转 GPIO 引脚。本文介绍了我最新的项目,该项目旨在让两个 Pi 之间通过 GPIO 引脚进行通信(聊天):在 Pi 1 中输入一条文本消息,通过 GPIO 引脚发送到另一个 Pi,然后在屏幕上显示出来。
我想设计一个每次只能发送 1 位数据的协议。在这种协议中,最自然的编码方式是一种莫尔斯电码,但我的程序允许使用任何可以编码字符串到比特流(当然也可以解码)的策略。当然,我本可以选择一次发送 8 位数据,这样每次就可以发送一个 ASCII 字符,但我出于两个原因没有这样做。
- 我只有 1 个 Pi,所以在测试时,我必须让它与自己对话。这意味着我需要为我想发送的每一位数据都设置输入和输出。这だけで,我的 Pi Model B 提供的 17 个 GPIO 引脚就占用了 16 个。正如本文后续内容会清楚地表明的那样,我还需要一些引脚用于同步。根本放不下……
- 一次发送整个字符有什么挑战?莫尔斯电码酷多了!
背景
大多数 GPIO 教程都使用 Python 库来控制引脚。虽然这完全没问题,但我选择使用 C++ 和 WiringPi 库来构建我的应用程序。这主要是因为我更熟悉 C++ 而不是 Python,这意味着我敢于展示 C++ 代码。而用我写的任何 Python 代码,我可能会太害怕而不敢展示……用 C 或 C++ 为 Pi 编写程序有一个缺点,那就是你需要编译它,这对于 800Mhz 的 ARM 芯片来说可能需要相当长的时间。
在我的 Pi 上,我使用的是一个最小化的 Debian(Raspbian)镜像,我从这里下载的。然后我升级到了 Jessie,以便能够充分利用 GCC 4.9 中的 C++11(Wheezy 自带的是 4.6)。
最终结果将封装在一个名为PiChat
的类中,但我不会立即分享整个接口。这只会分散人们对有趣内容的注意力。下面的代码片段将是该类的(部分)成员函数,因此会有PiChat::
前缀。
通信协议
我首先需要设计的是一种可以从一个 Pi 向另一个 Pi 发送比特的协议。设计这种协议的挑战在于,你永远不能假设两个 Pi 处于代码的相同位置。与任何多处理器应用程序一样,我们需要能够进行同步。同步最终成为主要问题,但现在让我们保持简单。只需假设我们有一个sync()
函数,可以确保两个设备在返回时是同步的。现在我们可以考虑我们的发送和监听函数应该是什么样子了。在下面的内容中,MLI(Message Line In)是接收比特的引脚名称,而 MLO(Message Line Out)是发送比特的引脚名称。
发送
下面代码片段中的预代码将确保有一个std::vector<bool>
可用(称之为code
),其中包含编码后的消息,形式为比特流。我们所要做的就是逐个发送比特。
void PiChat::send(string const &str)
{
// Pre-code
write(SRO, 1); // notify the receiver
for (bool bit: code)
{
write(MLO, bit); // set the bit on MLO
sync(); // wait for receiver
sync(); // wait for receiver to have read the bit
}
write(SRO, 0); // notify the receiver that we stopped sending
sync();
// Post-code
}
第一行将 SRO(Send Receive Out)通道设置为高电平。该通道连接到另一端的 SRI(Send Receive In)通道,用于通知另一设备我们想要发送数据。这段代码的特别之处在于双重同步。第一次同步是为了确保发送方已正确设置输出,而第二次同步是为了确保接收方已读取输入。
监听
请记住,前面的代码片段与此代码片段同时执行,由 SRI 上的信号触发。一个空的std::vector<bool>
将用于存储代码,代码将在后代码中解码。
std::string PiChat::listen()
{
// Pre-code
while (true)
{
sync(); // wait for the bit to be set on MLI
bool bit = read(MLI); // read the bit
code.push_back(bit); // store it
if (!read(SRI)) // still sending? If so, there's more data coming
break;
sync(); // data has been read, ready for next bit
}
// Post-code (decrypt and store in 'message')
}
眼尖的读者现在会注意到这两个代码片段中的同步差异。诀窍在于,当发送方跳出循环时,接收方已经开始了下一个迭代,并处于第一个同步点等待。因此,发送循环之外必须有一个同步。当接收方现在检查 SRI 时,它会发现发送方已停止发送:现在可以解密代码了!
同步 (1)
是时候揭示sync()
的内部工作原理了,它到目前为止一直是一个黑箱。为了使其正常工作,我们需要额外的通信通道,我们称之为同步线。每个设备都有两条同步线:一条用于输入,一条用于输出,我们称之为 SLI(Sync Line In)和 SLO(Sync Line Out)。下面是我第一次实现,它没有成功!但是,我认为这是解释我最终做法的最佳方式,所以请耐心看下去。
其思想是在 SLO 上设置一个值,然后等待 SLI 显示相同的值。这个值在 0 和 1 之间交替,以确保后续的同步是可区分的。很简单,对吧?这里是代码。
void PiChat::sync()
{
static bool syncVal = 0;
syncVal = !syncVal;
write(SLO, syncVal);
if (!wait(SLI, syncVal))
throw Exception<TimeOut>("connection timed out");
}
静态变量syncVal
会在后续调用中被记住,在写入 SLO 之前在 0 和 1 之间交替。当值写入输出时,输入 SRI 在wait()
函数中被监控,该函数一旦 SRI 变为等于syncVal
的值就会返回。实际上,wait()
接受第三个参数,指定以秒为单位的超时间隔,默认为 2 秒。当wait()
失败时会抛出异常。
wait()
的实现很简单,使用了传统的 C 时间函数gettimeofday()
。是的,我知道,C++11 有一个很棒的<chrono>
工具来以现代方式完成这个任务。现在,实现看起来是这样的。
bool PiChat::wait(int pin, int val, int timeout)
{
timeval t0, t1, diff;
gettimeofday(&t0, NULL);
while (read(pin) != val)
{
if (timeout > 0)
{
gettimeofday(&t1, NULL);
timersub(&t1, &t0, &diff);
if (diff.tv_sec >= timeout)
return false;
}
}
return true;
}
同步 (2)
正如我之前提到的,上面的方法不起作用!原因在于后续的同步。如果你仔细看了,你会知道同步值是交替的,所以一个设备可能会这样做:
- SLI 处于状态 0
- ...
- 将 SLO 设置为 1,等待 SLI 变为 1
- ...
- 将 SLO 设置为 0,等待 SLI 变为 0
- ...
我观察到的效果是,其中一个设备在设置 SLO 先为 1 然后又改回 0 的速度非常快,以至于另一个设备根本没有注意到变化。它卡在了第 3 步,等待 SLI 变为 0,而实际上它已经变成了 0。我认为这可能是因为我在单个 Pi 上测试所有这些,迫使它同时处理两个应用程序。但为了确定,我想在不引入延迟的情况下解决这个问题。我找到的解决方案是一个额外的同步通道。
每个设备现在有 4 个同步通道,而不是 2 个(SLI、SLO):SLI1、SLO1、SLI2 和 SLO2。通过交替同步值和同步通道,可以保证双方都观察到每一次变化。实现现在更改为以下内容。
void PiChat::sync()
{
static int lineSelect = 0;
static bool syncVal[2] = {0, 0};
static Pin const syncLineIn[2] = {SLI1, SLI2};
static Pin const syncLineOut[2] = {SLO1, SLO2};
lineSelect = !lineSelect; // alternate lines
int s = (syncVal[lineSelect] = !syncVal[lineSelect]); // alternate values
write(syncLineOut[lineSelect], s);
if (!wait(syncLineIn[lineSelect], s))
throw Exception<TimeOut>("connection timed out");
}
解析引脚和读/写
你可能已经注意到上面代码片段中的其他两个黑箱:read()
和write()
。它们接受像 SLI 和 MLO 这样的引脚编号,这些编号已经被映射到实际的引脚编号。实际上,它们已经被映射到 WiringPi 的引脚编号约定,你可以在这里阅读所有关于它的信息。read()
和write()
所做的只是应用映射。
void PiChat::write(Pin pin, bool value)
{
digitalWrite(d_pins[pin], value);
}
bool PiChat::read(Pin pin)
{
return digitalRead(d_pins[pin]);
}
d_pins
(d_
表示数据成员,如 Stroustrup 的m_
)中的值当然不是硬编码的!它们是在运行时从一个引脚文件中读取的。这样你就可以按照自己的意愿设置你的 Pi,并在文件中指定详细信息。这意味着需要解析文件,我用我的小型Parser
类来完成这个任务,它只是读取行,查找=
号,并尝试提取等号左右两侧的字符串转换为某种类型(它是一个类模板,所以只要为它定义了operator>>(std::istream, T)
,就可以是任何类型)。在我们的引脚文件的情况下,结果是std::map<std::string, int>
。然后将 map 中的字符串与一组硬编码的字符串进行比较,这些字符串代表我们的通道("SRI"、"SRO" 等(这些是字符串))。最终结果是std::vector<int>
,可以由 Pin 枚举(SRI、SRO 等(这些是 enum 常量))的成员索引。我不会详细说明,但你可以随时询问。
一个引脚文件可能看起来像这样:
SRO = 8
SLO1 = 9
SLO2 = 12
MLO = 7
SRI = 0
SLI1 = 2
SLI2 = 13
MLI = 3
莫尔斯电码
由于协议一次只允许发送一位数据,因此已经优化的莫尔斯电码似乎相当合理。然而,PiChat
类接口接受任何从抽象类EncoderBase
派生的策略,该策略提供encode()
和decode()
成员,用于将std::string
转换为std::vector<bool>
,反之亦然。
在我实现的莫尔斯编码器类(恰当地命名为MorseEncoder
)中,我使用了标准的莫尔斯信号,其中点(dit)由单个 1 表示,长划(dah)由两个 1 表示。点(1)和长划(11)被称为**单位**。单位之间用单个 0 分隔,多个单位组成一个**字符**,用双 0 分隔。多个字符组成**单词**,用三重 0 分隔。例如,消息“SOS SOS”在莫尔斯电码中是:“...---... ...---...”。编码为 0 和 1。
101010011011011001010100010101001101101100101010
为了简单起见,我只使用了 26 个字母加上 10 个数字。当然,这可以根据需要任意扩展到任何字符集。同样,我不会在这里讨论 MorseEncoder 的实现,但其实现可在存档中找到,我很乐意回答任何有关它的问题!
整合
我想我们差不多可以完成这个项目了,设置一个提示符,等待用户输入他们想要发送的文本。但是,它也应该监听传入的消息……
这就需要线程,而线程已在 C++11 中标准化。我们只需要 2 个线程。
void PiChat::prompt()
{
d_running = true;
thread sendThread(&PiChat::waitForUserInput, this);
thread receiveThread(&PiChat::waitForMessage, this);
sendThread.join();
receiveThread.join();
}
如你所见,第一个线程(sendThread
)等待用户输入,然后尝试发送。另一个线程(receiveThread
)等待来自另一方的消息。一个可能出现的小问题是,当两个用户同时尝试发送消息时。这实际上在send()
和listen()
函数的预代码中处理了,我省略了这部分。一个共享在这两个线程之间的标志被设置,以指示是否正在发送/接收消息。如果是这种情况,另一个线程将简单地等待直到消息行再次空闲。
最后,两个线程的代码。
void PiChat::waitForUserInput()
{
while (true)
{
try
{
cout << ">> " << flush;
string message;
getline(cin, message);
if (cin.eof())
break;
else if (message.empty())
continue;
send(message);
}
catch (Exception<TimeOut> const &e)
{
throw; // catch in main() and terminate
}
catch (exception const &e)
{
error(e.what());
}
}
cout << endl;
d_running = false;
}
void PiChat::waitForMessage()
{
while (d_running)
{
while (d_running && !read(SRI)) // wait until the other starts to send
{}
if (d_running)
{
try
{
string message = listen();
cout << "\n\t\t" << message << "\n>> " << flush;
}
catch (Exception<TimeOut> const &e)
{
throw; // catch in main and terminate
}
catch (exception const &e)
{
error(e.what());
}
}
}
}
全部代码已上传并在存档中可用!我使用 GCC 4.8 和 4.9 都进行了编译。我知道 4.6 版本存在一些问题,因为该版本尚未实现某些 C++11 语法特性。已包含一个 makefile,以方便你的使用。另外,不要忘记你首先需要安装 WiringPi 库!
请求
我想我很快就能在 2 个 Pi 上测试我的程序了,但如果有人真的尝试将两个 Pi 连接起来进行测试,请告诉我是否有效!我知道它在单个 Pi 上有效,而且我很有信心它在两个独立的 Pi 上也会有效,但你永远不知道,直到你知道为止,你知道吗?
感谢阅读!
历史
2014 年 8 月 20 日:初稿
2014 年 8 月 22 日:修复了一些拼写错误,将图片上传到 codeproject