65.9K
CodeProject 正在变化。 阅读更多。
Home

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

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.67/5 (3投票s)

2014年8月22日

CPOL

10分钟阅读

viewsIcon

17058

downloadIcon

202

在 C++ 中设计和实现 Pi2Pi 聊天协议。

引言

树莓派(Raspberry Pi)是一款很棒的小型设备,大多数人可能都用它来运行 XBMC 作为媒体中心。我有一个正是为此目的而闲置的设备,但我从未强迫自己将其完全设置好。然后我突然想到,我还可以尝试玩转 GPIO 引脚。本文介绍了我最新的项目,该项目旨在让两个 Pi 之间通过 GPIO 引脚进行通信(聊天):在 Pi 1 中输入一条文本消息,通过 GPIO 引脚发送到另一个 Pi,然后在屏幕上显示出来。

我想设计一个每次只能发送 1 位数据的协议。在这种协议中,最自然的编码方式是一种莫尔斯电码,但我的程序允许使用任何可以编码字符串到比特流(当然也可以解码)的策略。当然,我本可以选择一次发送 8 位数据,这样每次就可以发送一个 ASCII 字符,但我出于两个原因没有这样做。

  1. 我只有 1 个 Pi,所以在测试时,我必须让它与自己对话。这意味着我需要为我想发送的每一位数据都设置输入和输出。这だけで,我的 Pi Model B 提供的 17 个 GPIO 引脚就占用了 16 个。正如本文后续内容会清楚地表明的那样,我还需要一些引脚用于同步。根本放不下……
  2. 一次发送整个字符有什么挑战?莫尔斯电码酷多了!

背景

大多数 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)

正如我之前提到的,上面的方法不起作用!原因在于后续的同步。如果你仔细看了,你会知道同步值是交替的,所以一个设备可能会这样做:

  1. SLI 处于状态 0
  2. ...
  3. 将 SLO 设置为 1,等待 SLI 变为 1
  4. ...
  5. 将 SLO 设置为 0,等待 SLI 变为 0
  6. ...

我观察到的效果是,其中一个设备在设置 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_pinsd_表示数据成员,如 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

© . All rights reserved.