PiCom & PiFTP:RaspberryPi 的 GPIO 通信协议。






4.33/5 (2投票s)
PiCom是一个协议/C++类,用于处理通过GPIO引脚连接的两个树莓派之间的通信。PiFTP是一个文件传输协议,它使用PiCom作为接口通过GPIO发送文件。
引言
不久前,我写了一个程序,允许两个人通过GPIO引脚聊天。该协议一次只允许发送一位,并使用莫尔斯电码对字符进行编码。这当然意味着要发送的内容仅限于所使用的莫尔斯电码实现所支持的字符集。尽管如此,它运行良好。我手头只有一个树莓派,所以我不得不通过将树莓派连接到自身来测试和调试。当一切正常时,我达到了惊人的约50位/秒的传输速率。没错:每秒位,而不是字节。不久之后,我的一个朋友提出将他的树莓派连接到我的树莓派,以测试该协议的预期用途。由于某种原因,竟然达到了惊人的25kb/s!这种显著提高的原因对我来说仍然不清楚。我原本预期速度提高大约两倍,但这简直令人难以置信...
受这些结果的启发,我决定扩展协议,允许一次发送更多的位(仅受GPIO引脚数量的限制),并且独立于字符编码。结果是PiCom
,PiChat
已在此基础上成功重新实现。更有趣的是,它允许实现PiFTP
,一个基于PiCom
的文件传输协议,这也是本文的主题。
我不会假设您阅读过我之前关于PiChat
的文章。本文的某些文本/代码部分可能直接从那篇文章中复制,下一节(背景)也是如此。
背景
大多数GPIO教程都使用Python库来控制引脚。虽然这完全没问题,但我选择使用C和C++的WiringPi库在C++中构建我的应用程序。这主要是因为我对C++比Python更熟悉,这意味着我真的敢展示我的代码。我可能太害怕用我写的任何Python代码来做这件事...在树莓派上用C或C++编写程序的缺点是您必须编译它,这在700Mhz的ARM芯片上可能需要很长时间。另一个优点是,至少根据这个网站,WiringPi库允许比Python更快的通信。希望这能带来更高的传输速率。
在我的树莓派上,我使用的是我从这里下载的最小Debian(Raspbian)镜像。然后我升级到Jessie,以便能够在GCC 4.9中充分利用C++11(Wheezy自带4.6)。
PiCom
接下来将是PiCom协议及其C++实现的描述。您可以使用它来实现需要GPIO通信的自己的应用程序。
协议
Lines
暂时不需要任何代码,让我们先讨论协议。PiCom协议考虑了两方,一个发送方(A)和一个监听方(B),通过可能具有多个通道的**线**相互连接。除了消息线,每条线都有输入和输出变体。A的输入连接到B的输出,反之亦然。
- SRI/SRO:发送接收输入/输出
- 用于通知对方当前活动。当A将SRO设置为高电平时,B将在SRI上读取高电平,表示A即将发送内容。
- SLI1/SLO1, SLI2/SLO2:同步线输入/输出
- 用于同步。
- ML1, ML2, ... , MLn:消息线
- 此线既用于输出(发送)也用于输入(接收),并且可以包含多个通道。通道的数量决定了每次迭代发送的位数。传输速率通常与通道数量呈线性关系。
同步
该协议高度依赖于可靠的同步方法。为了传输信息,两个树莓派必须相互通信,表示它们已准备好发送/读取下一位信息。同步线SLI1、SLI2、SLO1和SLO2仅用于此目的。
每个树莓派跟踪两个变量:lineSelect
和 syncVal[2]
。前者决定使用哪条同步线,并在对同步函数的后续调用之间在SLI(O)1和SLI(O)2之间交替。后者决定同步值。它是一个包含两个布尔标志的数组,在后续调用中也会交替(true/false)。同步时,设备A将等待适当的线假定适当的值,该值必须由B设置,反之亦然。通过以这种方式交替线和值,可以确保多个(快速后续)调用得到适当处理。
下方的示意图显示了在对同步函数的后续调用中使用了哪些线和值
Call Line Value 1 1 0 2 2 0 3 1 1 4 2 1 5 1 0 ...
同步函数归结为两个简单的语句
- 将当前值写入当前输出线(SLO1或SLO2)。
- 等待当前输入线(SLI1或SLI2)取当前值。
发送
既然我们有了一个可靠的同步方案,我们就可以尝试制定信息发送方式。在这个上下文中,信息是任何比特流,可以代表任何东西,与PiChat的先前实现不同。信息以**块**的形式发送,即在消息线的多个通道上并行发送的多个比特包。可以并行发送的比特数取决于可用的消息线通道数,并且在设备A和B上可能不相同。因此,并且因为比特流不必是块大小的精确倍数,发送设备必须通信在即将到来的传输中将使用多少条消息线。因此,发送算法的粗略描述如下:
- 将SRO设置为高电平,表示将有消息传入。
- 将消息线配置为输出。
- 计算可以并行发送的比特数。
- 将此数字通知接收设备。
- 在ML1、ML2、...、MLn上发送比特。
- 重复直到所有比特都已发送。
- 将SRO设置为低电平,表示这是整个消息。
这些步骤中的每一个都需要仔细的同步才能按预期工作。这将在实现中显而易见。
接收
接收算法与发送算法交织在一起。假设接收设备不断监控其SRI通道,并且一旦该通道变为高电平(这是发送方的第1步),算法就会触发。
- 将消息线配置为输入。
- 监听预期的比特数,并检查本地设置是否支持此数量。
- 在ML1、ML2、...、MLn上监听比特,并将它们附加到结果向量中。
- 重复直到SRI变为低电平。
代码
类接口
PiCom协议的实现由下面的公共类接口概括。应用程序可以继承PiCom,或者只是将其作为成员包含以使用其功能。继承PiCom的类还继承受保护的成员,允许子类实现额外功能,可能覆盖现有的发送和监听方法。
class PiCom
{
// private members
public:
explicit PiCom(std::string const &pinFile);
virtual ~PiCom() = default;
enum LineName
{
SRI, SRO,
SLI1, SLO1,
SLI2, SLO2,
ML,
N_LINES
};
void send(std::vector<bool> const &bitstream);
std::vector<bool> listen();
protected:
void sync(int num = 1, int timeout = TIMEOUT);
bool wait(int line, int val = 1, int timeout = TIMEOUT);
bool wait(int line, int channel, int val, int timeout);
void reset();
// WiringPi wrappers
void write(int line, bool value);
void write(int line, int channel, bool value);
bool read(int line);
bool read(int line, int channel);
void mode(int line, int mode);
void mode(int line, int channel, int mode);
void pull(int line, int mode);
void pull(int line, int channel, int mode);
};
构造函数接受一个**引脚文件**的文件名,即一个特定格式的文件,它将线路(SRI、SRO等)链接到遵循WiringPi引脚编号约定的引脚号。例如,一个引脚文件可能看起来像这样:
SRO = 0 SLO1 = 2 SLO2 = 3 SRI = 8 SLI1 = 9 SLI2 = 12 ML1 = 7 ML2 = 13
负责解析此类文件的解析器非常简单。它只会找到每行上的等号(忽略空行),从左右两侧去除空格,并将结果传递给调用此解析器的PiCom对象。此类文件无法添加注释。请注意,此特定设置使用2条消息线(ML1、ML2)。这意味着,在发送时,每次迭代可以并行发送2位。
sync()
sync()
成员遵循上述描述,在行和值之间交替。它使用2个(私有)数据成员来跟踪当前行和值:d_syncLineSelect
和 d_syncVal
(所有数据成员都以 d_
开头,如 Stroustroup 的 m_
)
void PiCom::sync(int num, int timeout)
{
static int const in[2] = {SLI1, SLI2};
static int const out[2] = {SLO1, SLO2};
for (int i = 0; i != num; ++i)
{
d_syncLineSelect ^= 1; // new line
int s = (d_syncVal[d_syncLineSelect] ^= 1); // new value for this line
write(out[d_syncLineSelect], s);
if (!wait(in[d_syncLineSelect], s, timeout))
throw Exception<TimeOut>("connection timed out");
}
}
send()
send
成员实现了发送协议。由于消息线既用于传输数据也用于接收数据,它们首先必须使用 wiringPi 包装函数配置为输出。
// Configure message lines
int nML = channels(ML); // number of Message Line channels
for (int c = 0; c != nML; ++c)
mode(ML, c, OUTPUT); // set each channel to output
一个名为 bitstream
的 std::vector<bool>
已传递给 send()
,它将使用可用的 nML
通道以 nML
位的数据块发送。
int n = bitstream.size();
int idx = 0;
while (idx < n)
{
int chunkSize = (n - idx >= nML) ? nML : (n - idx);
// Tell receiver how many channels to read from (= #iterations)
write(SRO, 1);
for (int i = 0; i != chunkSize; ++i)
sync(2);
write(SRO, 0);
sync(2);
// Start sending chunks
write(SRO, 1);
while ((idx + chunkSize) <= static_cast<int>(bitstream.size()))
{
for (int i = 0; i != chunkSize; ++i)
write(ML, i, bitstream[idx++]);
sync(2);
}
write(SRO, 0);
sync(2);
}
sync(2);
每次对sync()
的调用都有一个非常具体的目的,但我很清楚这个目的并非总是容易从这段代码中单独看到。此外,这段代码中每次对sync()
的调用都包含参数“2”,这意味着它实际上是2次连续的同步(等同于sync(); sync();
)。这背后的原因是监听设备必须监控SRI(连接到发送设备的SRO)以检测变化。单个同步屏障将不足够,因为无法保证监听设备有机会在SRI上的正确值再次改变之前读取它。第一个屏障可以被视为确保值已写入的保险策略,而第二个则确保值已读取。
第一个循环执行 chunkSize
次,让监听设备有机会在 SRI 变为低电平之前计算迭代次数。当块大小已通信时,比特会重复写入消息线,直到剩余的比特数小于块大小。当发生这种情况时,外层循环重复,并重新计算块大小。当所有比特都已发送时,外层循环中断,SRO(另一侧的 SRI)保持低电平。这也意味着通信的块大小为 0,这向监听者指示消息已完全发送。
listen()
listen()
成员通过在一个无限循环中等待 SRI 通道变为高电平来启动。它应该在类似守护进程的应用程序中调用,或者由一个单独的线程调用(就像 PiChat 中那样,应用程序同时监听和发送)。否则,程序将停止,直到它从发送方接收到输入。
wait(SRI, 0, 1, -1);
与 send()
一样,listen()
必须配置消息线。只是这一次,它们被设置为用于输入而不是输出。
for (int channel = 0; channel != nML; ++channel)
{
mode(ML, channel, INPUT);
pull(ML, channel, PUD_DOWN);
}
下面的循环与发送算法的主循环并行运行。
while (true)
{
// Listen for chunksize
int chunkSize = 0;
while (true)
{
sync();
if (!read(SRI))
break;
++chunkSize;
sync();
}
sync();
if (chunkSize == 0)
break;
else if (chunkSize > nML)
throw Exception<LineError>("Not enough message-lines available");
// Listen for chunks
while (true)
{
sync(); // wait for all writes
if (!read(SRI))
break;
for (int i = 0; i != chunkSize; ++i)
ret.push_back(read(ML, i));
sync();
}
sync();
}
只要SRI保持高电平,第一个循环就会执行。迭代次数被计数,从而有效地计算出块大小。然后检查这个数字是否有意义。如果它的值为0,循环就会中断;如果该值超过消息线的数量,则会抛出异常。如果一切正常,则进入下一个循环以重复收集所有比特。
PiFTP
PiFTP是使用PiCom在两个Pi之间提供接口的应用程序示例。我将只展示公共类接口及其实现。
class PiFTP
{
PiCom d_piCom;
// ... other private members
public:
PiFtp(std::string const &pinfile);
void send(std::string const &fname); // 1
void send(std::string const &source, std::string const &dest); // 2
void listen();
};
send()
send成员对1个和2个输入字符串都进行了重载。
- 目标文件名与源文件名相同。
- 目标文件名与源文件名不同。
在下面的实现中,计时代码被省略。发送算法只执行3个重要动作:
- 将目标字符串作为比特向量发送(相对于监听程序运行的路径)。
- 将文件作为比特向量发送(使用私有接口提供的转换函数
file2bits
)。 - 重置接口,以确保程序后续调用正常工作。
void PiFtp::send(string const &fname)
{
send(fname, fname);
}
void PiFtp::send(string const &source, string const &dest)
{
// timing-code
d_picom.send(string2bits(dest));
d_picom.send(file2bits(source));
d_picom.reset();
// timing-code
}
listen()
void PiFTP::listen()
{
vector<bool> fname = d_picom.listen();
vector<bool> content = d_picom.listen();
try
{
bits2file(bits2string(fname), content);
}
catch (Exception<NoSuchFile> const &ex)
{
cerr << ex.what() << '\n';
}
d_picom.reset();
}
监听器首先监听文件名,然后监听内容。当两者都到达时,它根据指定位置的比特流构建一个文件。如果失败,则抛出异常。接口被重置,以确保同步线恢复到初始状态。
结果
正如我之前所说,我只能在一台树莓派上测试代码,这迫使我将它连接到自己。这导致了非常差的性能,但至少我能够看到一些缩放行为。不幸的是,我的Model B的17个GPIO引脚只允许2个消息线通道(2x4同步线+2x2 SRx线)。然而,我确实注意到,当我从1个通道切换到2个通道时,传输速率翻倍了。
很快,当另一台树莓派可用时,我将能够扩大规模。一有机会,我就会尝试生成一些漂亮的性能图。
历史
10月13日,初稿