发送命令序列并等待响应 [英] Sending a sequence of commands and wait for response

查看:231
本文介绍了发送命令序列并等待响应的处理方法,对大家解决问题具有一定的参考价值,需要的朋友们下面随着小编来一起学习吧!

问题描述

我必须更新连接到串行端口的设备上的固件和设置。
由于这是通过一系列命令完成的,我发送一个命令,并等待,直到我回答一个答案。在回答者(多行)中,我搜索一个字符串,指示操作是否成功完成。

  Serial-> write (boot,1000); 
Serial-> waitForKeyword(boot successful);
Serial-> sendFile(image.dat);
...

所以我已经创建了一个新的线程这个阻塞的读/写方法。在线程内我使用waitForX()函数。
如果我调用watiForKeyword()它会调用readLines(),直到它检测到关键字或timesout

  bool waitForKeyword const QString& keyword)
{
QString str;

//读取所有行
while(serial-> readLines(10000))
{
//检查每行
while = serial-> getLine())!=)
{
//找到!
if(str.contains(keyword))
return true;
}
}
// timeout
return false;
}

readLines()读取所有可用的内容并将其分成行,在一个QStringList内,并得到一个字符串我调用getLine(),返回列表中的第一个字符串并删除它。

  bool SerialPort :: readLines(int waitTimeout)
{
if(!waitForReadyRead(waitTimeout))
{
qDebug()< 超时读数< endl;
return false;
}

QByteArray data = readAll();
while(waitForReadyRead(100))
data + = readAll();

char * begin = data.data();
char * ptr = strstr(data,\r\\\
);

while(ptr!= NULL)
{
ptr + = 2;
buffer.append(begin,ptr - begin);
emit readyReadLine(buffer);
lineBuffer.append(QString(buffer)); // store line in Qstringlist
buffer.clear();

begin = ptr;
ptr = strstr(begin,\r\\\
);
}
// rest
buffer.append(begin,-1);
return true;
}

问题是如果我通过终端发送一个文件来测试应用程序readLines )只会读取文件的小部分(5行左右)。因为这些行不包含关键字。该函数将再次运行,但这次它等待超时,readLines只是立即返回false。
有什么问题?如果这是正确的方法...有谁知道如何发送一个sequenze的命令,并等待响应每次?

QStateMachine 来简化这个过程。让我们回想一下你希望这样的代码看起来如何:

  Serial-> write(boot,1000); 
Serial-> waitForKeyword(boot successfully);
Serial-> sendFile(image.dat);

让我们把它放在一个类中,该类对于程序员可能处于的每个状态都有显式的状态成员。还将具有将给定操作附加到状态的动作生成器发送 expect 等。

  // https://github.com/KubaO/stackoverflown/tree/master/questions/comm-commands-32486198 
#include < QtWidgets>
#include< private / qringbuffer_p.h>

[...]

类程序员:public StatefulObject {
Q_OBJECT
AppPipe m_port {nullptr,QIODevice :: ReadWrite,this};
State s_boot {& m_mach,s_boot},
s_send {& m_mach,s_send};
FinalState s_ok {& m_mach,s_ok},
s_failed {& m_mach,s_failed};
public:
Programmer(QObject * parent = 0):StatefulObject(parent){
connectSignals();
m_mach.setInitialState(& s_boot);
send(& s_boot,& m_port,boot\\\
);
expect(& s_boot,& m_port,boot successful,& s_send,1000,& s_failed);
send(& s_send,& m_port,:HULLOTHERE\\\
:00000001FF\\\
);
expect(& s_send,& m_port,load successful,& s_ok,1000,& s_failed);
}
AppPipe和amp; pipe(){return m_port; }
};

这是程序员完全功能完整的代码!完全异步,非阻塞,并且它也处理超时。



可以让基础设施实时生成状态,必须手动创建所有状态。代码小得多,如果你有明确的状态,IMHO更容易理解。只有对于具有50-100 +状态的复杂通信协议,才能清除显式命名状态。



AppPipe 是一个简单的进程间双向管道,可以用作真实串行端口的替代品:

  //参见http://stackoverflow.com/a/32317276/1329652 
///一个简单的点对点进程内管道。另一个端点可以存在于任何
///线程中。
class AppPipe:public QIODevice {
[...]
};

StatefulObject 拥有一个状态机,用于监视状态机进度的基本信号以及用于将信号与状态连接的 connectSignals 方法:

  class StatefulObject:public QObject {
Q_OBJECT
Q_PROPERTY(bool running READ isRunning NOTIFY runningChanged)
protected:
QStateMachine m_mach {this};
StatefulObject(QObject * parent = 0):QObject(parent){}
void connectSignals(){
connect(& m_mach,& QStateMachine :: runningChanged,this,& :: runningChanged);
for(auto state:m_mach.findChildren< QAbstractState *>())
QObject :: connect(state,& QState :: entered,this,[this,state] {
emit stateChanged(state-> objectName());
});
}
public:
Q_SLOT void start(){m_mach.start(); }
Q_SIGNAL void runningChanged(bool);
Q_SIGNAL void stateChanged(const QString&);
bool isRunning()const {return m_mach.isRunning(); }
};

FinalState 是Qt 3风格的简单命名状态包装器。它们允许我们声明状态并一次给出一个名称。

  template< class S> struct NamedState:S {
NamedState(QState * parent,const char * name):S(parent){
this-> setObjectName(QLatin1String(name));
}
};
typedef NamedState< QState>州;
typedef NamedState< QFinalState>最终状态;

动作生成器也很简单。动作生成器的含义是在输入给定状态时做某事。作用的状态总是作为第一个参数。第二个参数和后续参数特定于给定的操作。有时,动作也可能需要目标状态,例如。如果成功或失败。

  void send(QAbstractState * src,QIODevice * dev,const QByteArray& data){
QObject :: connect(src,& QState :: entered,dev,[dev,data] {
dev-> write(data);
}
}

QTimer * delay(QState * src,int ms,QAbstractState * dst){
auto timer = new QTimer(src);
timer-> setSingleShot(true);
timer-> setInterval(ms);
QObject :: connect(src,& QState :: entered,timer,static_cast< void(QTimer :: *)()>(& QTimer :: start));
QObject :: connect(src,& QState :: exited,timer,& QTimer :: stop);
src-> addTransition(timer,SIGNAL(timeout()),dst);
return timer;
}

void expect(QState * src,QIODevice * dev,const QByteArray& data,QAbstractState * dst,
int timeout = 0,QAbstractState * dstTimeout = nullptr)
{
addTransition(src,dst,dev,SIGNAL(readyRead()),[dev,data] {
return hasLine(dev,data);
}
if(timeout)delay(src,timeout,dstTimeout);
}

hasLine 简单地检查对于给定针可从设备读取的所有线。这对于这个简单的通信协议工作正常。如果你的沟通更多的参与,你需要更复杂的机械。有必要读所有的线,即使你找到你的针。这是因为该测试是从 readyRead 信号中调用的,在该信号中,您必须读取满足所选条件的所有数据。这里,标准是数据形成一行。

  static bool hasLine(QIODevice * dev,const QByteArray& needle ){
auto result = false;
while(dev-> canReadLine()){
auto line = dev-> readLine();
if(line.contains(needle))result = true;
}
return result;
}

使用默认API添加保护转换对状态有点麻烦,所以我们将包装它以使其更容易使用,并保持动作生成器可读:

  template< typename F> 
class GuardedSignalTransition:public QSignalTransition {
F m_guard;
protected:
bool eventTest(QEvent * ev)Q_DECL_OVERRIDE {
return QSignalTransition :: eventTest(ev)&& m_guard();
}
public:
GuardedSignalTransition(const QObject * sender,const char * signal,F&& amp; guard):
QSignalTransition(sender,signal),m_guard(std: :move(guard)){}
};

template< typename F> static GuardedSignalTransition< F> *
addTransition(QState * src,QAbstractState * target,
const QObject * sender,const char * signal,F&& amp; guard){
auto t = new GuardedSignalTransition sender,signal,std :: move(guard));
t-> setTargetState(target);
src-> addTransition(t);
return t;
}

就是这样 - 如果你有一个真正的设备,这就是你需要的。由于我没有您的设备,我将创建另一个 StatefulObject 来模拟假定的设备行为:

  class Device:public StatefulObject {
Q_OBJECT
AppPipe m_dev {nullptr,QIODevice :: ReadWrite,this};
State s_init {& m_mach,s_init},
s_booting {& m_mach,s_booting},
s_firmware {& m_mach,s_firmware};
FinalState s_loaded {& m_mach,s_loaded};
public:
Device(QObject * parent = 0):StatefulObject(parent){
connectSignals();
m_mach.setInitialState(& s_init);
expect(& s_init,& m_dev,boot,& s_booting);
delay(& s_booting,500,& s_firmware);
send(& s_firmware,& m_dev,boot successful\\\
);
expect(& s_firmware,& m_dev,:00000001FF,& s_loaded);
send(& s_loaded,& m_dev,load successful\\\
);
}
Q_SLOT void stop(){m_mach.stop(); }
AppPipe& pipe(){return m_dev; }
};

现在让我们来看看吧。我们将有一个带有文本浏览器的窗口,显示通信的内容。下面是用于启动/停止编程器或设备的按钮,以及指示仿真设备和编程器的状态的标签:



  int main(int argc, char ** argv){
using Q = QObject;
QApplication app {argc,argv};
设备dev;
Programmer prog;

QWidget w;
QGridLayout grid {& w};
QTextBrowser comms;
QPushButton devStart {Start Device},devStop {Stop Device},
progStart {Start Programmer};
QLabel devState,progState;
grid.addWidget(& comms,0,0,1,3);
grid.addWidget(& devState,1,0,1,2);
grid.addWidget(& progState,1,2);
grid.addWidget(& devStart,2,0);
grid.addWidget(& devStop,2,1);
grid.addWidget(& progStart,2,2);
devStop.setDisabled(true);
w.show();

我们将连接设备和程序员的 AppPipe s。我们还将可视化程序员发送和接收的内容:

  dev.pipe()addOther(& prog.pipe ()); 
prog.pipe()。addOther(& dev.pipe());
Q :: connect(& prog.pipe(),&AppPipe :: hasOutgoing,& comms,[&](const QByteArray& data){
comms.append(formatData & gt;,blue,data));
});
Q :: connect(& prog.pipe(),&AppPipe :: hasIncoming,& comms,[&](const QByteArray& data){
comms.append(formatData & lt;,green,data));
});

最后,我们将连接按钮和标签:

  Q :: connect(& devStart,& QPushButton :: clicked& dev,& Device :: start); 
Q :: connect(& devStop,& QPushButton :: clicked& dev,& Device :: stop);
Q :: connect(& dev,& Device :: runningChanged,& devStart,& QPushButton :: setDisabled);
Q :: connect(& dev,& Device :: runningChanged,& devStop,& QPushButton :: setEnabled);
Q :: connect(& dev,& Device :: stateChanged,& devState,& QLabel :: setText);
Q :: connect(& progStart,& QPushButton :: clicked& prog,& Programmer :: start);
Q :: connect(& prog,& Programmer :: runningChanged,& progStart,& QPushButton :: setDisabled);
Q :: connect(& prog,& Programmer :: stateChanged,& progState,& QLabel :: setText);
return app.exec();
}

#includemain.moc

程序员设备可以住在任何线程。我把它们留在主线程,因为没有理由把它们移出,但是你可以把它们放入一个专用的线程,或者每个进入自己的线程,或者与其他对象共享的线程等等。它是完全透明的,因为 AppPipe 支持线程间的通信。如果使用 QSerialPort 而不是 AppPipe ,也会是这种情况。所有重要的是每个 QIODevice 的实例只能从一个线程使用。一切都通过信号/槽连接发生。



例如。如果你想让 Programmer 住在一个专用的线程中,你可以在 main 中添加以下内容:

  //修复QThread破坏
struct Thread:QThread {〜Thread(){quit等待(); }};

线程progThread;
prog.moveToThread(& progThread);
progThread.start();

一个小帮手格式化数据,使其更容易阅读:

  static QString formatData(const char * prefix,const char * color,const QByteArray& data){
auto text = QString :: fromLatin1 ).toHtmlEscaped();
if(text.endsWith('\\\
'))text.truncate(text.size() - 1);
text.replace(QLatin1Char('\\\
'),QString :: fromLatin1(< br />%1).arg(QLatin1String(prefix)));
return QString :: fromLatin1(< font color = \%1 \>%2%3< / font>< br />)
.arg(QLatin1String (color))。arg(QLatin1String(prefix))。arg(text);
}


I have to update firmware and settings on a device connected to a serial port. Since this is done by a sequence of commands, I send a command and wait until I recive an answer. Inside the answere (many lines) I search for a string that indicates if the operation is finished successfully.

Serial->write("boot", 1000);
Serial->waitForKeyword("boot successful");
Serial->sendFile("image.dat");
…

So I’ve created a new Thread for this blocking read/write method. Inside the thread I make use of the waitForX() functions. If I call watiForKeyword() it will call readLines() until it detects the keyword or timesout

bool waitForKeyword(const QString &keyword)
{
    QString str;

    // read all lines
    while(serial->readLines(10000))
    {
        // check each line
        while((str = serial->getLine()) != "")
        {
            // found!
            if(str.contains(keyword))
                return true;
        }
    }
    // timeout
    return false;
}

readLines() reads everything available and separates it into lines , each line is placed inside a QStringList and to get a string I call getLine() which returns the first string in the list and deletes it.

bool SerialPort::readLines(int waitTimeout)
{
if(!waitForReadyRead(waitTimeout))
{
    qDebug() << "Timeout reading" << endl;
    return false;
}

QByteArray data = readAll();
while (waitForReadyRead(100))
    data += readAll();

char* begin = data.data();
char* ptr = strstr(data, "\r\n");

while(ptr != NULL)
{
    ptr+=2;
    buffer.append(begin, ptr - begin);
    emit readyReadLine(buffer);
    lineBuffer.append(QString(buffer)); // store line in Qstringlist
    buffer.clear();

    begin = ptr;
    ptr = strstr(begin, "\r\n");
}
// rest
buffer.append(begin, -1);
return true;
}

The problem is if I send a file via terminal to test the app readLines() will only read a smale part of the file ( 5 Lines or so). Since these lines do not contain the keyword. the function will run once again, but this time it dosnt wait for timeout, readLines just return false immediately. Whats wrong ? Also I'm not shure if this is the right approach... Does anyone know how to send a sequenze of commands and wait for a response each time?

解决方案

Let's use QStateMachine to make this simple. Let's recall how you wished such code would look:

Serial->write("boot", 1000);
Serial->waitForKeyword("boot successful");
Serial->sendFile("image.dat");

Let's put it in a class that has explicit state members for each state the programmer could be in. We'll also have action generators send, expect, etc. that attach given actions to states.

// https://github.com/KubaO/stackoverflown/tree/master/questions/comm-commands-32486198
#include <QtWidgets>
#include <private/qringbuffer_p.h>

[...]

class Programmer : public StatefulObject {
   Q_OBJECT
   AppPipe m_port { nullptr, QIODevice::ReadWrite, this };
   State      s_boot   { &m_mach, "s_boot" },
              s_send   { &m_mach, "s_send" };
   FinalState s_ok     { &m_mach, "s_ok" },
              s_failed { &m_mach, "s_failed" };
public:
   Programmer(QObject * parent = 0) : StatefulObject(parent) {
      connectSignals();
      m_mach.setInitialState(&s_boot);
      send  (&s_boot, &m_port, "boot\n");
      expect(&s_boot, &m_port, "boot successful", &s_send, 1000, &s_failed);
      send  (&s_send, &m_port, ":HULLOTHERE\n:00000001FF\n");
      expect(&s_send, &m_port, "load successful", &s_ok, 1000, &s_failed);
   }
   AppPipe & pipe() { return m_port; }
};

This is fully functional, complete code for the programmer! Completely asynchronous, non-blocking, and it handles timeouts, too.

It's possible to have infrastructure that generates the states on-the-fly, so that you don't have to manually create all the states. The code is much smaller and IMHO easier to comperehend if you have explicit states. Only for complex communication protocols with 50-100+ states would it make sense to get rid of explicit named states.

The AppPipe is a simple intra-process bidirectional pipe that can be used as a stand-in for a real serial port:

// See http://stackoverflow.com/a/32317276/1329652
/// A simple point-to-point intra-process pipe. The other endpoint can live in any
/// thread.
class AppPipe : public QIODevice {
  [...]
};

The StatefulObject holds a state machine, some basic signals useful for monitoring the state machine's progress, and the connectSignals method used to connect the signals with the states:

class StatefulObject : public QObject {
   Q_OBJECT
   Q_PROPERTY (bool running READ isRunning NOTIFY runningChanged)
protected:
   QStateMachine m_mach  { this };
   StatefulObject(QObject * parent = 0) : QObject(parent) {}
   void connectSignals() {
      connect(&m_mach, &QStateMachine::runningChanged, this, &StatefulObject::runningChanged);
      for (auto state : m_mach.findChildren<QAbstractState*>())
         QObject::connect(state, &QState::entered, this, [this, state]{
            emit stateChanged(state->objectName());
         });
   }
public:
   Q_SLOT void start() { m_mach.start(); }
   Q_SIGNAL void runningChanged(bool);
   Q_SIGNAL void stateChanged(const QString &);
   bool isRunning() const { return m_mach.isRunning(); }
};

The State and FinalState are simple named state wrappers in the style of Qt 3. They allow us to declare the state and give it a name in one go.

template <class S> struct NamedState : S {
   NamedState(QState * parent, const char * name) : S(parent) {
      this->setObjectName(QLatin1String(name));
   }
};
typedef NamedState<QState> State;
typedef NamedState<QFinalState> FinalState;

The action generators are quite simple, too. The meaning of an action generator is "do something when a given state is entered". The state to act on is always given as the first argument. The second and subsequent arguments are specific to the given action. Sometimes, an action might need a target state as well, e.g. if it succeeds or fails.

void send(QAbstractState * src, QIODevice * dev, const QByteArray & data) {
   QObject::connect(src, &QState::entered, dev, [dev, data]{
      dev->write(data);
   });
}

QTimer * delay(QState * src, int ms, QAbstractState * dst) {
   auto timer = new QTimer(src);
   timer->setSingleShot(true);
   timer->setInterval(ms);
   QObject::connect(src, &QState::entered, timer, static_cast<void (QTimer::*)()>(&QTimer::start));
   QObject::connect(src, &QState::exited,  timer, &QTimer::stop);
   src->addTransition(timer, SIGNAL(timeout()), dst);
   return timer;
}

void expect(QState * src, QIODevice * dev, const QByteArray & data, QAbstractState * dst,
            int timeout = 0, QAbstractState * dstTimeout = nullptr)
{
   addTransition(src, dst, dev, SIGNAL(readyRead()), [dev, data]{
      return hasLine(dev, data);
   });
   if (timeout) delay(src, timeout, dstTimeout);
}

The hasLine test simply checks all lines that can be read from the device for a given needle. This works fine for this simple communications protocol. You'd need more complex machinery if your communications were more involved. It is necessary to read all the lines, even if you find your needle. That's because this test is invoked from the readyRead signal, and in that signal you must read all the data that fulfills a chosen criterion. Here, the criterion is that the data forms a full line.

static bool hasLine(QIODevice * dev, const QByteArray & needle) {
   auto result = false;
   while (dev->canReadLine()) {
      auto line = dev->readLine();
      if (line.contains(needle)) result = true;
   }
   return result;
}

Adding guarded transitions to states is a bit cumbersome with the default API, so we will wrap it to make it easier to use, and to keep the action generators above readable:

template <typename F>
class GuardedSignalTransition : public QSignalTransition {
   F m_guard;
protected:
   bool eventTest(QEvent * ev) Q_DECL_OVERRIDE {
      return QSignalTransition::eventTest(ev) && m_guard();
   }
public:
   GuardedSignalTransition(const QObject * sender, const char * signal, F && guard) :
      QSignalTransition(sender, signal), m_guard(std::move(guard)) {}
};

template <typename F> static GuardedSignalTransition<F> *
addTransition(QState * src, QAbstractState *target,
              const QObject * sender, const char * signal, F && guard) {
   auto t = new GuardedSignalTransition<F>(sender, signal, std::move(guard));
   t->setTargetState(target);
   src->addTransition(t);
   return t;
}

That's about it - if you had a real device, that's all you need. Since I don't have your device, I'll create another StatefulObject to emulate the presumed device behavior:

class Device : public StatefulObject {
   Q_OBJECT
   AppPipe m_dev { nullptr, QIODevice::ReadWrite, this };
   State      s_init     { &m_mach, "s_init" },
              s_booting  { &m_mach, "s_booting" },
              s_firmware { &m_mach, "s_firmware" };
   FinalState s_loaded   { &m_mach, "s_loaded" };
public:
   Device(QObject * parent = 0) : StatefulObject(parent) {
      connectSignals();
      m_mach.setInitialState(&s_init);
      expect(&s_init, &m_dev, "boot", &s_booting);
      delay (&s_booting, 500, &s_firmware);
      send  (&s_firmware, &m_dev, "boot successful\n");
      expect(&s_firmware, &m_dev, ":00000001FF", &s_loaded);
      send  (&s_loaded,   &m_dev, "load successful\n");
   }
   Q_SLOT void stop() { m_mach.stop(); }
   AppPipe & pipe() { return m_dev; }
};

Now let's make it all nicely visualized. We'll have a window with a text browser showing the contents of the communications. Below it will be buttons to start/stop the programmer or the device, and labels indicating the state of the emulated device and the programmer:

int main(int argc, char ** argv) {
   using Q = QObject;
   QApplication app{argc, argv};
   Device dev;
   Programmer prog;

   QWidget w;
   QGridLayout grid{&w};
   QTextBrowser comms;
   QPushButton devStart{"Start Device"}, devStop{"Stop Device"},
               progStart{"Start Programmer"};
   QLabel devState, progState;
   grid.addWidget(&comms, 0, 0, 1, 3);
   grid.addWidget(&devState, 1, 0, 1, 2);
   grid.addWidget(&progState, 1, 2);
   grid.addWidget(&devStart, 2, 0);
   grid.addWidget(&devStop, 2, 1);
   grid.addWidget(&progStart, 2, 2);
   devStop.setDisabled(true);
   w.show();

We'll connect the device's and programmer's AppPipes. We'll also visualize what the programmer is sending and receiving:

   dev.pipe().addOther(&prog.pipe());
   prog.pipe().addOther(&dev.pipe());
   Q::connect(&prog.pipe(), &AppPipe::hasOutgoing, &comms, [&](const QByteArray & data){
      comms.append(formatData("&gt;", "blue", data));
   });
   Q::connect(&prog.pipe(), &AppPipe::hasIncoming, &comms, [&](const QByteArray & data){
      comms.append(formatData("&lt;", "green", data));
   });

Finally, we'll connect the buttons and labels:

   Q::connect(&devStart, &QPushButton::clicked, &dev, &Device::start);
   Q::connect(&devStop, &QPushButton::clicked, &dev, &Device::stop);
   Q::connect(&dev, &Device::runningChanged, &devStart, &QPushButton::setDisabled);
   Q::connect(&dev, &Device::runningChanged, &devStop, &QPushButton::setEnabled);
   Q::connect(&dev, &Device::stateChanged, &devState, &QLabel::setText);
   Q::connect(&progStart, &QPushButton::clicked, &prog, &Programmer::start);
   Q::connect(&prog, &Programmer::runningChanged, &progStart, &QPushButton::setDisabled);
   Q::connect(&prog, &Programmer::stateChanged, &progState, &QLabel::setText);
   return app.exec();
}

#include "main.moc"

The Programmer and Device could live in any thread. I've left them in the main thread since there's no reason to move them out, but you could put both into a dedicated thread, or each into its own thread, or into threads shared with other objects, etc. It's completely transparent since AppPipe supports communications across the threads. This would also be the case if QSerialPort was used instead of AppPipe. All that matters is that each instance of a QIODevice is used from one thread only. Everything else happens via signal/slot connections.

E.g. if you wanted the Programmer to live in a dedicated thread, you'd add the following somewhere in main:

  // fix QThread brokenness
  struct Thread : QThread { ~Thread() { quit(); wait(); } };

  Thread progThread;
  prog.moveToThread(&progThread);
  progThread.start();

A little helper formats the data to make it easier to read:

static QString formatData(const char * prefix, const char * color, const QByteArray & data) {
   auto text = QString::fromLatin1(data).toHtmlEscaped();
   if (text.endsWith('\n')) text.truncate(text.size() - 1);
   text.replace(QLatin1Char('\n'), QString::fromLatin1("<br/>%1 ").arg(QLatin1String(prefix)));
   return QString::fromLatin1("<font color=\"%1\">%2 %3</font><br/>")
         .arg(QLatin1String(color)).arg(QLatin1String(prefix)).arg(text);
}

这篇关于发送命令序列并等待响应的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持IT屋!

查看全文
登录 关闭
扫码关注1秒登录
发送“验证码”获取 | 15天全站免登陆