基于串口通信的自动送片系统实现
在前面,我们演示了如何实现手工单片扫描的功能实现。在现实中,这种工作模式是无法提高什么工作效率的,甚至还不如医生自己拿到显微镜下去人工数。只有实现整个流程的自动化才能真正体现出系统的效率。因此,我们需要为扫描仪装上自动上片系统。
自动上片系统的软硬件简介
抛去硬件实现的细节不谈,所谓自动上片系统,本质上就是单片机控制的一个机械手,它实现将玻片从片仓中取出放到载物台上,以及从载物台取走玻片放回片仓中的操作——核心就是这两个操作。它有一个很孱弱的单片机,控制步进电机运动,同时和主机通信。通信协议一般是基于串口的二进制消息。一般来说,这种系统的二进制消息定义也十分简单。比如我们使用的上片机,其接口定义如下:
下行消息(上位机->下位机)
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的支持:
具体的内容读者可以去阅读QSerialPort
的文档。这里只列出对我们来说比较重要的几点内容:
QSerialPort
是QIODevice
的派生类,和其他的IO类设备一样,使用QSerialPort
要先打开,当使用完毕之后要关闭。
QSerialPort
写内容比较简单,就是write()
函数。一般用户不会关心写操作的细节。真正容易出问题的是读串口
QSerialPort
提供的读操作也和其他QIODevice
一样,可以使用read()
,readLine()
,readData()
,readAll()
。每个函数有自己的使用场景,但是根本问题并不在这里,而是何时才有数据,如何判定数据完备。
- 我们说过,串口是一个动态设备,数据的到来是串行的,串口驱动设备不管是以中断还是轮询方式,都是被某个数据到达的电平触发的,驱动会将收到的数据存放在缓冲区中,而应用程序调用
read
之类的函数,不过是从缓冲区中获得数据。而数据并不保证是完整的”包“。QSerialPort
使用QIODevice
的signal
,readReady()
来指示有数据到达,同时也重写了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> ¶ms) { 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); } }
|
剩下的工作就很无聊了,批量扫描暂时就先写到这里。下一章继续回到界面上面去,继续优化单张扫描的功能。