基于串口通信的自动送片系统实现

在前面,我们演示了如何实现手工单片扫描的功能实现。在现实中,这种工作模式是无法提高什么工作效率的,甚至还不如医生自己拿到显微镜下去人工数。只有实现整个流程的自动化才能真正体现出系统的效率。因此,我们需要为扫描仪装上自动上片系统。

自动上片系统的软硬件简介

抛去硬件实现的细节不谈,所谓自动上片系统,本质上就是单片机控制的一个机械手,它实现将玻片从片仓中取出放到载物台上,以及从载物台取走玻片放回片仓中的操作——核心就是这两个操作。它有一个很孱弱的单片机,控制步进电机运动,同时和主机通信。通信协议一般是基于串口的二进制消息。一般来说,这种系统的二进制消息定义也十分简单。比如我们使用的上片机,其接口定义如下:

下行消息(上位机->下位机)

1
2
3
4
5
6
7
8
9
字段    长度	值	        说明
TAG 2 0xEB90
LEN 1 0x00-0xFF 从DIR到CRC的长度
DIR 1 取值:
0x00: 下行
0x01: 上行
CMD 1 命令字.
PARAM VAR 可选内容
CRC 2 CRC16/IBM格式. 校验内容从LEN到PARAM

上行消息(上位机 <- 下位机):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
字段    长度	值	说明
TAG 2 0xEB90
LEN 1 0x00-0xFF 从DIR到CRC的长度
DIR 1 取值:
0x00: 下行
0x01: 上行
CMD 1 命令字.
STATUS 1 取值:
0x00: 成功
0x01: 失败
0x02: 收到
ERROCDE 1 具体错误码 定义详细错误原因。见2.2错误码定义
PARAM VAR 可选内容
CRC 2 CRC16/IBM格式. 校验内容从LEN到PARAM

命令定义的简单而又粗糙,但是对于这么简单的系统,也足够用了。

命令-执行模式

上位机给下位机的命令执行模式如下所示:

1
2
3
4
sequenceDiagram
上位机 ->> 下位机 : 命令
下位机 ->> 上位机 : 命令收到确认(status=0x02)
下位机 ->> 上位机 : 执行结果(成功或失败, status=0x00|0x01)

上位机发送命令给下位机,下位机收到命令后立即返回接受确认消息(status=0x02),然后执行命令并返回执行结果。即,上位机发送的命令,一定会收到两条响应消息。

下位机给上位机也会主动发送状态消息,这种消息不需要上位机确认,下位机要么会连续定时发送,要么发送条件不再成立而不再发送。

下位机是一个单任务状态机,一个时刻只支持执行一条命令,如果前一条命令未执行完毕而又收到另一条命令,则新命令会被立即返回失败。如下所示:

1
2
3
4
5
6
7
sequenceDiagram
上位机 ->> 下位机 : 命令1
下位机 ->> 上位机 : 命令1收到确认(status=0x02)
上位机 ->> 下位机 : 命令2
下位机 ->> 上位机 : 命令2收到确认
下位机 ->> 上位机 : 命令2执行失败(`status=0x01`)
下位机 ->> 上位机 : 命令1执行结果(成功或失败, status=0x00|0x01)

基于这个约定,上位机的命令执行模式也就很简单了,基本上也是单纯的发送命令-等待响应-发送下一条命令的模式。

串口通信,很容易被一般上层软件,尤其是普通前端开发人员容易忽略的一点是,它传递的是无格式的字节流,需要应用层自己去做分包,而不是想当然的是一个完整的数据包。这一点很容易被习惯于HTTP开发的人忽略。另外一点是,这种设备通常会工作与电磁环境不良的场景下(存在大量的步进电机等设备),通信被干扰产生误码的比率比较高,因此,一方面串口通信不能过度追求高速率,另一方面也需要包含完整性校验的内容。

总体来说,利用串口,实际上是需要开发者从数据链路层开始考虑,而不是只要考虑应用层就可以了。

Qt对串口的支持:QSerialPort

Qt库为串口提供了支持,QSerialPort。它不在Qt的缺省安装列表中,如果读者在安装Qt的时候不注意,会很容易忽略掉。如下图,需要展开Additional Libraries项,从中勾选它。

另外,还需要在pro文件中加上Serial Port的支持:

1
QT += serialport

具体的内容读者可以去阅读QSerialPort的文档。这里只列出对我们来说比较重要的几点内容:

  • QSerialPortQIODevice的派生类,和其他的IO类设备一样,使用QSerialPort要先打开,当使用完毕之后要关闭。
  • QSerialPort写内容比较简单,就是write()函数。一般用户不会关心写操作的细节。真正容易出问题的是读串口
  • QSerialPort提供的读操作也和其他QIODevice一样,可以使用read()readLine()readData()readAll()。每个函数有自己的使用场景,但是根本问题并不在这里,而是何时才有数据,如何判定数据完备。
  • 我们说过,串口是一个动态设备,数据的到来是串行的,串口驱动设备不管是以中断还是轮询方式,都是被某个数据到达的电平触发的,驱动会将收到的数据存放在缓冲区中,而应用程序调用read之类的函数,不过是从缓冲区中获得数据。而数据并不保证是完整的”包“。QSerialPort使用QIODevicesignalreadReady()来指示有数据到达,同时也重写了waitForReadyRead()函数,后者会阻塞直到readReady()被发出,相当于帮助用户实现了slot函数。但是因为传递的是二进制数据流,完整性仍然需要自己实现。

串口通信基础设置

首先实现支持串口通信的基础设置类。我们在类FeederApiBase中实现消息协议的编解码支持。对我们来说一点有利的是,单片机的字节序也是小字节序,这样我们省去了来回转换字节序的麻烦。

1
2
3
4
5
6
7
8
9
class FRAMEWORKS_EXPORT FeederApiBase
{
public:
... ...
static quint16 crc16(const quint8 *buf, int len);
static QByteArray makeDownMsg(quint8 cmd, const QList<quint8>& params);
static QByteArray makeUpResp(quint8 cmd, quint8 status, quint8 errcode, QList<quint8>& params);
static QByteArray getMessage(EMsgDir dir, QByteArray& buffer);
}

FeederApiBase的核心功能是上面几个函数,crc16()用于计算IBM格式CRC16的值,这个是标准算法,就不再赘述,makeDownMsg()用于生成上位机发给上片机的下行消息, makeUpResp()用于生成上片机发给上位机的上行消息——因为我们要进行单元测试,所以需要单元测试桩函数来构造消息,同时也需要在软件测试中模拟上片机的行为。而getMessage()则用于从数据流中解析上下行消息。

构造消息的逻辑很简单,就简单地根据消息格式来生成就是了。我们使用了QDateStream来实现,注意的一点是,QDateStream默认生成数据流的时候使用的是大字节序,我们需要手工改为小字节序。使用QDateStream可以节省很多的麻烦,如果不使用,我们就只能使用C的方式,直接一个字节一个字节地写入到缓冲区中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
QByteArray FeederApiBase::makeDownMsg(quint8 cmd, const QList<quint8> &params)
{
QByteArray msg;
QDataStream stream(&msg, QIODevice::WriteOnly);
stream.setByteOrder(QDataStream::ByteOrder::LittleEndian);
stream << quint8(0x90) << quint8(0xEB)
<< quint8(params.size()+4)
<< (quint8)(0x00)
<< (quint8)(cmd)
;
for(const auto& p: params)
stream << p;

auto crc = crc16((quint8 *)(msg.data()+2), msg.size()-2);
stream << crc;
return msg;
}

上行消息构建与之类似,我们就不多说了。

getMessage()用于从缓冲区中提取一条消息出来,如果提取不出来,它返回空QByteArray。所谓缓冲区是会被外部更新的,当getMessage()被调用时,缓冲区中可能不是完备的消息,消息和消息之间有干扰,可能消息本身有误码,可能只有半条消息,也可能有超过一条消息等等。这个函数会每次从缓冲区头部开始搜索,并返回找到的第一条消息。如果是非法的情况,就会抛弃缓冲区。

我们使用QByteArray来作为缓冲区的承载,在有通信密集的情况下,这种做法和实现并不是很高效的选择。但是我们这种消息间隔以秒和分钟计算的场景,这一点根本不是问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
QByteArray FeederApiBase::getMessage(EMsgDir dir, QByteArray &buffer)
{
QByteArray msg;
bool left = true;
int length = buffer.length();
quint8 *data = (quint8 *)(buffer.data());
int head = 0;
quint8 MIN_LENGTH = (dir==EMsgDir::eDownMsg) ? MIN_DOWN_LENGTH : MIN_UP_LENGTH;
while(msg.isEmpty() && left == true)
{
while(head<length-2 && (data[head]!=0x90 || data[head+1]!=0xEB ))
{
head ++;
}
if(head > length-MIN_LENGTH)
{
left = false;
break;
}
quint8 msg_len = data[head+LEN_OFFSET];
if(msg_len > length-head-3)
{
left = false;
break;
}
if(data[head+DIR_OFFSET]!=static_cast<quint8>(dir))
{
head += DIR_OFFSET;
continue;
}
auto crc_val = crc16(data+head+LEN_OFFSET, msg_len-1);
if(crc_val != *(quint16 *)(data+head+LEN_OFFSET+msg_len-1))
{
head += (LEN_OFFSET+msg_len+1); // 跳到下一个
continue;
}
msg = buffer.mid(head+CMD_OFFSET, msg_len-3);
head += (DIR_OFFSET + msg_len);
}
buffer = buffer.mid(head, -1);
return msg;
}

函数每次会先从头搜索消息头(0x90,0xEB),如果不是,就抛弃。当找到后,再依次检查消息长度够不够,消息方向对不对,内容的CRC校验和对不对。如果都满足,就将消息体内容拷贝出来返回,并更新缓冲区的内容。在更高层次,我们可以将这个函数作为基础使用。

我们可以先通过单元测试验证一下正确性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void FeederApiTester::test_getMsg_01()
{
...
{
TRACE() << "测试解析粘连且有噪音的内容:";
auto buffer = QByteArray::fromHex("40 90 eb 04 00 0b 00 06 17 90 eb 04 00 01 80 01 89");
TRACE() << "解析消息: " << buffer.toHex(' ');
int length = buffer.size();
auto msg = FeederApiBase::getMessage(FeederApiBase::EMsgDir::eDownMsg, buffer);
TRACE() << "解析结果: " << msg.toHex(' ');
QCOMPARE(msg, QByteArray::fromHex("0B"));
QCOMPARE(buffer, QByteArray::fromHex("17 90 eb 04 00 01 80 01 89"));
TRACE() << "继续解析: " << buffer.toHex(' ');
msg = FeederApiBase::getMessage(FeederApiBase::EMsgDir::eDownMsg, buffer);
TRACE() << "解析结果: " << msg.toHex(' ');
QCOMPARE(msg, QByteArray::fromHex("01"));
}
}

代码中,90 eb 04 00 0b 00 06是一条消息,而90 eb 04 00 01 80 01是另一条消息,他们的前后和中间都夹杂了干扰,测试是否能够正确识别出两条消息来。

上片机仿真桩FeederServerStub

首先我们实现上片机的仿真桩实现FeederServerStub

1
2
3
4
5
6
7
8
9
10
class FRAMEWORKS_EXPORT FeederServerStub : public QObject
{
Q_OBJECT
public:
explicit FeederServerStub(const QString& name, QObject *parent = nullptr);
void start();
private:
void onReadData();
...
};

它的构造函数有一个参数,指定串口的名字。一个公共接口start()用于启动它:

1
2
3
4
5
void FeederServerStub::start()
{
FeederApiBase::initPort(&_impl->_port, _impl->_name);
connect(&_impl->_port, &QSerialPort::readyRead, this, &FeederServerStub::onReadData);
}

FeederApiBase::initPort()是一个便捷函数,它配置串口的参数并打开串口。

然后我们将readRead()事件和slot函数onReadData()相关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
void FeederServerStub::onReadData()
{
_impl->_recvBuffer.append(_impl->_port.readAll());
QByteArray msg;
do{
msg = FeederApiBase::getMessage(FeederApiBase::EMsgDir::eDownMsg, _impl->_recvBuffer);
BREAK_IF(msg.isEmpty());
auto cmd = msg[0];
auto resp1 = FeederApiBase::makeReceivedResp(cmd);
_impl->_port.write(resp1);
if(_impl->_busy)
{
auto resp2 = FeederApiBase::makeFailResp(cmd, ERROR_FLAG_BUSY);
_impl->_port.write(resp2);
}
else
{
_impl->_busy = true;
switch(cmd)
{
case FeederApiBase::Command::InitCmd:
case FeederApiBase::Command::ResetCmd:
case FeederApiBase::Command::LoadCmd:
case FeederApiBase::Command::RetCmd:
case FeederApiBase::Command::UnlockCmd:
case FeederApiBase::Command::SlotCmd:
onSimpleCmd(msg);
break;
case FeederApiBase::Command::ProbCmd:
onProb();
break;
default:
break;
}
}
} while(!msg.isEmpty());
}

onReadData()每次会利用QSerialPort::readAll()将当前串口接受的内容全部追加到接受内容缓冲区_revcBuffer中,然后调用FeederApiBase::getMessage()从中提取消息。每次onReadData()被触发时,缓冲区里面可能一条完整的消息也没有,也可能有不止一条。所以每次需要将处理包在一个do...while()驯悍冲。它本身还是一个简单的状态机,利用类属性_busy来记录当前是否正在命令处理中。如果是,那么收到的命令会返回失败;否则,就进行处理。在这个桩中,我们只实现了最简单的正常的场景:所有命令都能够执行成功。比如onSimpleCmd()函数就模仿这一点,它启动定时器延时一段时间,来模仿电机行为,然后返回成功消息:

1
2
3
4
5
6
7
8
void FeederServerStub::onSimpleCmd(const QByteArray& msg)
{
QTimer::singleShot(std::chrono::seconds(2), [this,cmd=msg[0]](){
auto resp = FeederApiBase::makeSuccessResp(cmd);
send(resp);
_impl->_busy = false;
});
}

另一个函数onProb()与之类似,它实现的是一个比较复杂的返回消息,需要在消息中返回数据而已。这里就不再多说了。

上位机基本驱动

接下来我们实现上位机的上片机控制端。我们定义类FeederApi,它代表一条上位机命令的生命周期的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class FRAMEWORKS_EXPORT FeederApi : public QObject
{
Q_OBJECT
public:
explicit FeederApi(QSerialPort* port, quint8 cmd, const QByteArray& params, QObject *parent = nullptr);
~FeederApi();
int request();
...
private slots:
void onReadReady();
void onError(QSerialPort::SerialPortError error);
void onTimeout();
signals:
void exit(int);
private:
struct Implementation;
QScopedPointer<Implementation> _impl;
};

这是一个简化的类,它有一个公共接口request(),表示发出一条命令,并阻塞等待执行完毕——这是很符合我们的工作场景的,以取片为例,上位机发出命令后,就是要等待上片机操作完毕之后才能继续后面的处理。而几个slot函数则分别关联了相应的signal

我们先看request()函数。它先关联了串口的readyRead()errorOccured()信号处理,以及它自己的定时器的超时处理函数,然后构造并发送消息,并启动定时器_timer,用于处理消息超时的情况。然后创建了一个QEventLoop的实例,并将FeederApi::exit()关联到QEventLoop::quit上面,用于正常情况下的事件循环的退出处理。我们在前面也曾使用过QEventLoop。它是一个事件循环类,当调用exec()后,调用者函数会被“阻塞”,直到EventLoop中的循环结束,因此,它也经常被用于在不造成界面阻塞的前提下同步等待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int FeederApi::request()
{
connect(_impl->_port, &QSerialPort::readyRead, this, &FeederApi::onReadReady);
connect(_impl->_port, &QSerialPort::errorOccurred, this, &FeederApi::onError );
connect(&_impl->_timer,&QTimer::timeout, this, &FeederApi::onTimeout);

auto msg = FeederApiBase::makeDownMsg(_impl->_cmd, _impl->_cmdParms);
_impl->_port->write(msg)
_impl->_timer.start(_impl->_timeout);
QEventLoop loop;
connect(this, &FeederApi::exit, &loop, &QEventLoop::exit);
int rc = loop.exec();
_impl->_timer.stop();
disconnect(_impl->_port, &QSerialPort::readyRead, this, &FeederApi::onReadReady);
disconnect(_impl->_port, &QSerialPort::errorOccurred, this, &FeederApi::onError );
return rc;
}

然后是它的onReadReady()函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void FeederApi::onReadReady()
{
_impl->_recvBuffer = _impl->_port->readAll();
while(true)
{
auto msg = FeederApiBase::getMessage(FeederApiBase::EMsgDir::eUpMsg, _impl->_recvBuffer);
BREAK_IF(msg.isEmpty());
if(msg[CMD_INDEX] == _impl->_cmd )
{
if(msg[STATUS_INDEX] == CMD_RECEIVED)
{
;
}
else if(msg[STATUS_INDEX]==CMD_SUCCESS)
{
_impl->_result = msg;
emit exit(CMD_SUCCESS);
}
else if (msg[STATUS_INDEX]==CMD_FAIL)
{
_impl->_result = msg;
emit exit(CMD_FAIL);
}
else
{
_impl->_result = msg;
emit exit(CMD_FAIL);
}
}
// 处理其他可能打断上报的消息
...
}

}

这里也是一个简化的状态机,它忽略了RECEIVED消息的处理——对我们来说,这种消息的定义的确有点多余,虽说可以识别出是通信断了还是下位机挂了,但是一般来说这种场景下,并不需要这么细致的处理,不管是哪种情况,在现实中,除了下电复位,都没有第二种选择。然后,根据消息的成功与否,在exit()中携带不同的返回值。而这个返回值就是loop.exec()的返回值。此外,我们还将解析出来的消息保存到了_result里面,

接下来我们测试一下这种机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void FeederApiTester::test_sendCmd_03()
{
FeederServerStub feeder("COM4");
feeder.start();

QSerialPort pd;
QVERIFY2(FeederApiBase::initPort(&pd, "COM3"), "Failed to open COM3");

{
FeederApi api(&pd, FeederApiBase::Command::InitCmd, QByteArray());
auto r = api.request();
TRACE() << "上位机收到了结果: " << r;
QCOMPARE(r, 0);
}

{
FeederApi api(&pd, FeederApiBase::Command::ResetCmd, QByteArray());
auto r = api.request();
TRACE() << "重置命令结束.";
QCOMPARE(r, 0);
}
}

剩下的工作就很无聊了,批量扫描暂时就先写到这里。下一章继续回到界面上面去,继续优化单张扫描的功能。