oldbeginner 发表于 2013-12-8 13:17:39

开源PLC学习笔记16续(FREEMODBUS eMBPoll侦听)——2013_12_8

本帖最后由 oldbeginner 于 2013-12-8 20:25 编辑

FreeModbus果然不是免费的,要想理解它就要花很多精力。看了这么多,基本上明白,如果应用中,只是用modbus控制几个线圈或寄存器,还是不要用freemodbus,用笔记15中的例子就非常简单和实用。举一反三,如果觉得论文写得一目了然,导师看不上眼,那么控制几个线圈时就使用freemodbus。


笔记16中使用的两个例子目前看来不适合入门,现在改为下面这个例子,avr mega128可以利用PROTEUS和modbus poll进行通信。
http://www.cnblogs.com/worldsing/category/502885.html


这个例子改得比较简练,和笔记16中我想做的差不多,这样就很省力气和时间,直接理解这个例子作为入门还是不错的。



理解FREEMODBUS的突破口就是eMBPoll函数了

http://www.cnblogs.com/worldsing/category/502885.html
直接摘录
eMbPoll()的作用是FreeMod协议通信过程中不断查询事件对列有无完速数据桢,并进行地址和CRC验证,最后运行和回复主机。

改进说明:

1、eMbPoll()调用一次即可运行功能码和回复主机;

2、省去独立的接收函数peMBFrameReceiveCur( &ucRcvAddress, &ucMBFrame, &usLength ); 而直接操作,(其实里面对算出数据桢的启始位置、和长度);

3、省去发送函数peMBFrameSendCur( ucMBAddress, ucMBFrame, usLength ); 而直接操作;

4、省去返回值,因为调用处没有使用;

5、对功能的遍历i改成unsigned char类型,省去ucRcvAddress和eMBErrorCode    eStatus = MB_ENOERR; 变量,

6、功能兼容原版本。

oldbeginner 发表于 2013-12-8 14:18:51

本帖最后由 oldbeginner 于 2013-12-8 19:32 编辑

void eMBPoll( void )
{
      变量定义();

      桢事件判断();

                最小桢判断();

                CRC判断();

                地址判断();

                        执行功能码();

                        回复主机();

                              错误码判断();
      
                              报文发送();
}

*******************************
    变量定义();
static UCHAR   *ucMBFrame;
static UCHAR    ucFunctionCode;
static USHORT   usLength;
static eMBException eException;
eMBEventType    eEvent;
UCHAR i;
USHORT usCRC16;

变量名字前面加uc的意思应该是,uchar变量,MB表示modbus,所以ucMBFrame表示 uchar变量的modbus帧,前面加*,是一个指针。

ucFunctionCode应该是uchar变量的功能码,应该类似散转函数,只不过freemodbus使用了大量#ifdef .....#endif等,各种标志位来回绕几圈,现在还不清楚怎么调用。

usLength应该是ushort变量的帧长度。

static eMBException eException(ModBusRTU.h);
需要查看eMBException的定义,
    typedef enum
{
    MB_EX_NONE = 0x00,
    MB_EX_ILLEGAL_FUNCTION = 0x01,
    MB_EX_ILLEGAL_DATA_ADDRESS = 0x02,
    MB_EX_ILLEGAL_DATA_VALUE = 0x03,
    MB_EX_SLAVE_DEVICE_FAILURE = 0x04,
    MB_EX_ACKNOWLEDGE = 0x05,
    MB_EX_SLAVE_BUSY = 0x06,
    MB_EX_MEMORY_PARITY_ERROR = 0x08,
    MB_EX_GATEWAY_PATH_FAILED = 0x0A,
    MB_EX_GATEWAY_TGT_FAILED = 0x0B
} eMBException;

e表示enum,MB同上,Exception表示异常状况,共有10种异常状况,暂不展开(原程序也没有解释,估计不太重要)。

上面四种变量都是static型,只能用在ModBusRTU.c中。

然后,
eMBEventType    eEvent;
查看定义(ModBusRTU.h),
typedef enum
{
    EV_READY,                   /*!< Startup finished. */
    EV_FRAME_RECEIVED,          /*!< Frame received. */
    EV_EXECUTE,               /*!< Execute function. */
    EV_FRAME_SENT               /*!< Frame sent. */
} eMBEventType;

这个变量好理解,可以用来表示状态,共有4种状态。

UCHAR i;
执行功能代码时,要用到的循环变量。

USHORT usCRC16;
ushort类型的检验码。
      
在avr中 USHORT (unsigned short) 是16位的。
*****************************************************

桢事件判断();
if(xMBPortEventGet( &eEvent) == TRUE ){    
      if(eEvent == EV_FRAME_RECEIVED){

调用了函数
xMBPortEventGet
查看一下定义(ModBusPort.c),
//出队列
BOOL xMBPortEventGet( eMBEventType * eEvent ){

    BOOL xEventHappened = FALSE;
    if( xEventInQueue ){
      *eEvent = eQueuedEvent;
      xEventInQueue = FALSE;
      xEventHappened = TRUE;
    }
    return xEventHappened;
}

另外还要查看
static eMBEventType eQueuedEvent;
static BOOL   xEventInQueue;

首先理解xEventInQueue,x表示返回类型布尔值,直译:队列中的事件。如果它为真,则函数返回值为真。


xEventInQueue赋值的函数有两个,
//对列初始化
BOOL xMBPortEventInit( void ) {
    xEventInQueue = FALSE;
    return TRUE;
}

//进入队列
BOOL xMBPortEventPost( eMBEventType eEvent ){
    xEventInQueue = TRUE;
    eQueuedEvent = eEvent;
    return TRUE;
}

因为初始化只一次,所以表示进入队列的函数在某个地方应该被调用了,否则出队列返回值一直为false。

再看xMBPortEventGet中的

*eEvent = eQueuedEvent;
eQueuedEvent是eMBEventType类型,直译:队列中的事件。

//进入队列
BOOL xMBPortEventPost( eMBEventType eEvent ){
    xEventInQueue = TRUE;
    eQueuedEvent = eEvent;
    return TRUE;
}

当从事件队列中提取的事件为EV_FRAME_RECEIVED时,表示接收帧状态,就会继续执行。
if(eEvent == EV_FRAME_RECEIVED),即
eQueuedEvent == EV_FRAME_RECEIVED


********************************************************************




oldbeginner 发表于 2013-12-8 15:18:08

oldbeginner 发表于 2013-12-8 14:18
void eMBPoll( void )
{
      变量定义();


最小桢判断();

      if(usRcvBufferPos < MB_SER_PDU_SIZE_MIN)                        //最小桢判断
      return;

首先查看usRcvBufferPos 定义(ModBusRTU.c),
static volatile USHORT usRcvBufferPos;

变量定义中含有volatile,就应该马上想到该变量是多任务共享的(笔记12)或是中断修改的标志位。
us表示ushort变量,Receive Buffer Position,直译:接收缓存位置(接收数组下标),是用来定位接收的缓存。

另外
#define MB_SER_PDU_SIZE_MIN   4       /*!< Minimum size of a Modbus RTU frame. */

帧最小长度为何是4,地址,命令和CRC校验,正好4个字节。

所以最小帧判断, if ( usRcvBufferPos < 4 ) return;

********************************************************************

CRC判断();

      if(usMBCRC16((UCHAR *)ucRTUBuf, usRcvBufferPos ) != 0)          //CRC判断
      return;

调用了函数usMBCRC16 (ModBusRTU.c),查看一下

USHORT usMBCRC16( UCHAR * pucFrame, USHORT usLen )
{
    UCHAR         ucCRCHi = 0xFF;
    UCHAR         ucCRCLo = 0xFF;
    USHORT          iIndex;

    while( usLen-- ){
      iIndex = ucCRCLo ^ *( pucFrame++ );
      ucCRCLo = ( UCHAR )( ucCRCHi ^ aucCRCHi );
      ucCRCHi = aucCRCLo;
    }
    return ( USHORT )( ucCRCHi << 8 | ucCRCLo );
}

和以前处理CRC函数一样,不再展开,只使用。
暂时只需要知道该函数判断是否CRC正确,用到了两个参数,一个是刚理解过的usRcvBufferPos ,另一个是ucRTUBuf,
找到定义,
volatile UCHARucRTUBuf;
其中,
#define MB_SER_PDU_SIZE_MAX   256   /*!< Maximum size of a Modbus RTU frame. */

即volatile UCHAR ucRTUBuf;
就是接收缓存(数组)

******************************************************************************

地址判断();

      if(IS_VALID_ADD){                                             //地址
      
      ucMBFrame = (UCHAR *) &ucRTUBuf;
      
      usLength = (USHORT)( usRcvBufferPos - MB_SER_PDU_PDU_OFF - MB_SER_PDU_SIZE_CRC);

      ucFunctionCode = ucMBFrame;

      eException = MB_EX_ILLEGAL_FUNCTION;

有点长,主要是名称太长了,这样的长名字会让我想到安娜·阿尔卡迪耶夫娜·卡列尼娜;不太熟悉,作者是列夫·尼古拉耶维奇·托尔斯泰。

IS_VALID_ADD是一个宏,
查看定义,
#define IS_VALID_ADD      ((ucRTUBuf == ucMBAddress) ||      \
                           (ucRTUBuf == MB_ADDRESS_BROADCAST))

#define MB_SER_PDU_ADDR_OFF   0       /*!< Offset of slave address in Ser-PDU. */

ucRTUBuf == ucMBAddress 或 ucRTUBuf == MB_ADDRESS_BROADCAST
现在大致可以看出,就是判断从机地址,或者广播地址
ucRTUBuf == 从机地址 或 ucRTUBuf == 广播地址

从机地址在ModBusRTU.c中定义,
static UCHAR    ucMBAddress;
在主函数中被代入数值,

上图表示从机设备地址为0x01。

广播地址
#define MB_ADDRESS_BROADCAST    ( 0 )   /*! Modbus broadcast address. */


ucRTUBuf == 1 或 ucRTUBuf == 0
就是有效地址

ucMBFrame = (UCHAR *) &ucRTUBuf;
其中
#define MB_SER_PDU_PDU_OFF      1       /*!< Offset of Modbus-PDU in Ser-PDU. */

ucMBFrame = (UCHAR *) &ucRTUBuf;
应该是
ucMBFrame 指向 命令码;

usLength = (USHORT)( usRcvBufferPos - MB_SER_PDU_PDU_OFF - MB_SER_PDU_SIZE_CRC);
其中,
#define MB_SER_PDU_SIZE_CRC   2       /*!< Size of CRC field in PDU. */
即,
usLength = (USHORT)( usRcvBufferPos - 1 -2 )


ucFunctionCode = ucMBFrame;
其中,
#define MB_PDU_FUNC_OFF   0   /*!< Offset of function code in PDU. */
即,
ucFunctionCode = ucMBFrame;
即,
ucFunctionCode = 命令码,
真绕

eException = MB_EX_ILLEGAL_FUNCTION;
赋值。

************************************************************************************

shenarlon 发表于 2013-12-8 15:49:28

{:victory:}支持

oldbeginner 发表于 2013-12-8 16:05:52

本帖最后由 oldbeginner 于 2013-12-8 16:29 编辑

oldbeginner 发表于 2013-12-8 15:18
最小桢判断();

      if(usRcvBufferPos < MB_SER_PDU_SIZE_MIN)                        //最小桢判 ...
执行功能码();

      for(i = 0; i < MB_FUNC_HANDLERS_MAX; i++ ){                  //执行功能码
          if( xFuncHandlers.ucFunctionCode == 0 ){
            return;
          }
          else if( xFuncHandlers.ucFunctionCode == ucFunctionCode ){
            eException = xFuncHandlers.pxHandler( ucMBFrame, &usLength );
            break;                              
          }
      }

其中ModBusConfig.h定义了
#define MB_FUNC_HANDLERS_MAX                  ( 16 )//使用的功能码数量
即,
for(i = 0; i < 16 ; i++ ) {

再看xFuncHandlers是什么?它的生成方法,我是第一次看到,为了简短,这里只列出2种#if情况
先看定义
typedef struct
{
    UCHAR         ucFunctionCode;
    pxMBFunctionHandler pxHandler;
} xMBFunctionHandler;
其中,
typedefeMBException(*pxMBFunctionHandler) (UCHAR* pucFrame, USHORT * pusLength );
第二个成员变量是执行异常处理的指针。

再看该结构数组的生成,
/* 定义功能码与功能处理函数列表 */
static xMBFunctionHandler xFuncHandlers = {
#if MB_FUNC_READ_INPUT_ENABLED > 0
    {MB_FUNC_READ_INPUT_REGISTER, eMBFuncReadInputRegister},
#endif
#if MB_FUNC_READ_HOLDING_ENABLED > 0
    {MB_FUNC_READ_HOLDING_REGISTER, eMBFuncReadHoldingRegister},
#endif
};

先看第一个#if
#define MB_FUNC_READ_INPUT_ENABLED            (1 ) //读输入寄存器功能
这样的话,
#if总是成立,
{MB_FUNC_READ_INPUT_REGISTER, eMBFuncReadInputRegister}就会成为数组成员,

其中,
#define MB_FUNC_READ_INPUT_REGISTER         (4 )
在ModBusFun.c中,
eMBException eMBFuncReadInputRegister( UCHAR * pucFrame, USHORT * usLen )
eMBFuncReadInputRegister是一个函数,暂不展开。
功    能: 0x04(4) 读取输入寄存器。

xMBFunctionHandler 数组的成员变量是一个结构体,结构体的第一个变量是命令码,后一个变量是命令码对应的函数名。

这样看来,应该和开源PLC的函数散转同一功能,感觉开源PLC的写法更好理解。

****************************
          if( xFuncHandlers.ucFunctionCode == 0 ){
            return;
          }
如果第一个变量对应的命令码是0,则不执行。

**********************************
          else if( xFuncHandlers.ucFunctionCode == ucFunctionCode ){
            eException = xFuncHandlers.pxHandler( ucMBFrame, &usLength );
            break;                              
          }

再回顾一下,
ucFunctionCode = ucMBFrame;
而 ucMBFrame = (UCHAR *) &ucRTUBuf;

即,
xFuncHandlers.ucFunctionCode == ucRTUBuf;
即,
接收缓存的命令码和功能码数组第一个变量名一样的话,就调用
xFuncHandlers.pxHandler( ucMBFrame, &usLength );
pxHandle是第二个变量名,代入时换成实际的函数名称,例如
eMBFuncReadInputRegister( ucMBFrame, &usLength );
这样就完成了函数调用。

(太绕了,看了几天才第一次理解到散转函数,可读性较差,真心怀疑FREEMODBUS是好程序还是坏程序)

oldbeginner 发表于 2013-12-8 17:23:13

本帖最后由 oldbeginner 于 2013-12-8 19:33 编辑

oldbeginner 发表于 2013-12-8 16:05
执行功能码();

      for(i = 0; i < MB_FUNC_HANDLERS_MAX; i++ ){                  //执行功能码 ...
回复主机();

if(IS_NOT_BROADCAST){
查看定义
#define IS_NOT_BROADCAST(ucRTUBuf != MB_ADDRESS_BROADCAST)

#define IS_NOT_BROADCAST(ucRTUBuf != 0)
只要地址不是0。

********************************************************

错误码处理();

      if( eException != MB_EX_NONE ){                            //错误码         
          usLength = 0;
          ucMBFrame = ( UCHAR )( ucFunctionCode | MB_FUNC_ERROR );
          ucMBFrame = eException;
      }
首先,
eException是执行功能后的返回值,
eException = xFuncHandlers.pxHandler( ucMBFrame, &usLength );
如果返回值是MB_EX_NONE,表示正常。

否则修改ucMBFrame,
          ucMBFrame = ( UCHAR )( ucFunctionCode | MB_FUNC_ERROR );
          ucMBFrame = eException;


***********************************************

报文发送();

      if(eRcvState == STATE_RX_IDLE){                            //发送
          pucSndBufferCur = ( UCHAR * ) ucMBFrame - 1;
          pucSndBufferCur = ucMBAddress;
          usSndBufferCount = usLength + 1;      
          usCRC16 = usMBCRC16( ( UCHAR * ) pucSndBufferCur, usSndBufferCount );
          ucRTUBuf = ( UCHAR )( usCRC16 & 0xFF );
          ucRTUBuf = ( UCHAR )( usCRC16 >> 8 );   
          eSndState = STATE_TX_XMIT;
          vMBPortSerialEnable( FALSE, TRUE );
          }

其中,
extern volatile eMBRcvState eRcvState;
接收状态标志位,
利用笔记16中的状态图来理解


只有当eRcvState处于 STATE_RX_IDLE 状态时,才能发送。

发送的函数比较好理解,就是生成报文并发送。

首先,
static volatile UCHAR *pucSndBufferCur;
p表示指针,uc表示uchar,Send Buffer Current ,直译就是发送数组目前下标。

          pucSndBufferCur = ( UCHAR * ) ucMBFrame - 1;
因为ucMBFram指向功能码,-1就是指向地址,即 pucSndBufferCur指向ucBufRTU。

          pucSndBufferCur = ucMBAddress;
          usSndBufferCount = usLength + 1;      
          usCRC16 = usMBCRC16( ( UCHAR * ) pucSndBufferCur, usSndBufferCount );
          ucRTUBuf = ( UCHAR )( usCRC16 & 0xFF );
          ucRTUBuf = ( UCHAR )( usCRC16 >> 8 );   
生成报文,和笔记15类似。

         eSndState = STATE_TX_XMIT;
设定发送状态,


最后调用 vMBPortSerialEnable( FALSE, TRUE );
查看,
/* ------------------------------- 串口操作 -----------------------------------*/
//串口收发控制
void vMBPortSerialEnable( BOOL xRxEnable, BOOL xTxEnable ){

    ENTER_CRITICAL_SECTION();
    if( xRxEnable ){
      USART_RX_ENABLE();
      RS485SWITCH_TO_RECEIVE();
    }
    else{
       USART_RX_DISABLE();
       RS485SWITCH_TO_SEND();
    }
    if( xTxEnable ){
       USART_TX_ENABLE();
    }
    else{
       USART_TX_DISABLE();
    }
    EXIT_CRITICAL_SECTION();
}
在笔记16中专门理解过,复习一下
执行的是
if( xTxEnable ){
       USART_TX_ENABLE();

再查看
#define USART_TX_ENABLE()   SetBit(UCSR1B, UDRIE1)
其中,
#define SetBit(port, bitn)         (port |=(1<<(bitn)))

************************************
发送应该是通过串口中断进行的,
//发送中断子程序
#pragma vector = USART1_UDRE_vect
__interrupt void TX1_isr( void ){

xMBRTUTransmitFSM();
}

IAR中定义中断函数的格式是
/////////////////////////////////
#pragma vector=中断向量
__interrupt void 中断服务程序(void)
{
//中断处理程序
}
********************************************

//发送状态机由发送中断调用
void xMBRTUTransmitFSM( void )
{
    assert( eRcvState == STATE_RX_IDLE );
    switch ( eSndState )
    {
    case STATE_TX_IDLE:
      vMBPortSerialEnable( TRUE, FALSE );
      break;
    case STATE_TX_XMIT:
      if( usSndBufferCount != 0 ){
            xMBPortSerialPutByte( ( CHAR )*pucSndBufferCur );
            pucSndBufferCur++;
            usSndBufferCount--;
      }
      else{
            xMBPortEventPost( EV_FRAME_SENT );
            vMBPortSerialEnable( TRUE, FALSE );
            eSndState = STATE_TX_IDLE;
      }
      break;
    }
}

关注
    case STATE_TX_XMIT:
      if( usSndBufferCount != 0 ){
            xMBPortSerialPutByte( ( CHAR )*pucSndBufferCur );
            pucSndBufferCur++;
            usSndBufferCount--;
      }
因为设置了标志位STATE_TX_XMIT,调用了函数xMBPortSerialPutByte,直到待发送数为0 。

其中,
#define xMBPortSerialPutByte(ucByte)((UART_DR) = (ucByte))   

这样就完成了eMBPoll的第一遍理解。
***************************

jetli 发表于 2013-12-8 17:52:15

{:handshake:}{:victory:}{:lol:}

PCBBOY1991 发表于 2013-12-8 18:06:44

来膜拜楼主~{:loveliness:}

oldbeginner 发表于 2013-12-8 22:42:01

本帖最后由 oldbeginner 于 2013-12-8 22:56 编辑

oldbeginner 发表于 2013-12-8 17:23
回复主机();

if(IS_NOT_BROADCAST){








oldbeginner 发表于 2013-12-9 12:46:17

oldbeginner 发表于 2013-12-8 22:42


第二遍理解eMBPoll









yy8047 发表于 2013-12-9 12:54:12

有意思,顶一个

oldbeginner 发表于 2013-12-9 15:55:06

oldbeginner 发表于 2013-12-9 12:46
第二遍理解eMBPoll

复习发送报文的过程




oldbeginner 发表于 2013-12-9 16:46:49

oldbeginner 发表于 2013-12-8 16:05
执行功能码();

      for(i = 0; i < MB_FUNC_HANDLERS_MAX; i++ ){                  //执行功能码 ...

调度器,感觉好理解。
http://www.amobbs.com/thread-3757688-1-1.html

直接摘录
所有的C函数都可以看作是状态机,哪怕是一个最简单的函数,也可以看成是一个只有一个状态的
状态机。因此,最简单的状态机原型可以是大家常用的:
void FSMExample(void);

为了方便控制状态机的启动和关闭,我修改了最基本的原型:
BOOL FSMExample(void);

这就是最简单的双态状态机:
当函数返回TRUE,表明该状态机仍然“希望”处于运行状态;
当函数返回FALSE,表明该状态机已经完成,希望终止。

根据这一规定,如果通过函数指针把所有的状态机连接起来,就可以形成下面的简单调度器:
typedef struct Process
{
    BOOL (*Proc)(void);                                 //返回False,自动关闭任务
    volatile BOOL IfProcAlive;
}PROCESS;

typedef BOOL (*PROC_FUNCTION)(void);

void Process_Task(void)
{
    static uint8 n = 0;
   
    if (ProcPCB.IfProcAlive)                           //处理进程
    {
      ProcPCB.IfProcAlive = (*ProcPCB.Proc)();
    }      
   
    n ++;
    if (n >= g_cCOSPROCCounter)
    {
      n = 0;
      //g_cScheduleTest = MIN(g_cScheduleTest + 1,254);
    }
}

这个调度器很简单,就是检测一个注册了的顶层状态机其Alive状态是否为TRUE,
如果为TRUE,就调用;同时在调用后将状态机的返回值重新赋给ALive属性,这样
状态机就可以通过在函数中返回TRUE或者FALSE来控制自己的运行状态。

oldbeginner 发表于 2013-12-10 16:05:38

oldbeginner 发表于 2013-12-9 15:55
复习发送报文的过程

进行了很多尝试,终于理解FREEMODBUS大概思路。

理解freemodbus后,虽然只是刚学习单片机几个月,但感觉freemodbus滥用状态机(比如最关键状态是完整的帧收到了,然后再开始处理数据并反馈主机,顺序执行好好的,非要假设主机在从机发送的时候又送来命令;这种处理应该放在主机上,而不是从机的职责),把整体结构搞得复杂难懂,明明不是操作系统,非得往上靠。

从机就应该把重点放在从机的职责上(接收命令执行,然后反馈),而不是老想着主机发命令超过我的处理能力怎么办,这种想一劳永逸的从机不仅让自己的结构复杂了好多倍,导致犯错的机会也增加好多倍,本来想节约时间的通用程序反而成了麻烦制造机。

对Freemodbus的反思:

1、有很多人移植,而且热烈讨论,并没有怀疑它本身定位的问题,让我一开始误认为FREEMODBUS是一贴灵药。

2、Freemodbus的定位问题,它适合谁使用?适合哪种情况下使用?目前根本找不到相关信息。我的理解是,Freemodbus定位不准确,完全是按照主机的方式在处理。

3、很多不必要的信息,增加了复杂度。比如Errorcode问题,因为modbus协议很成熟,从机不厌其烦地判断是地址错误还是什么类型错误的?都有MBPoll这类软件帮助调试,从机完全没有必要增加这类信息。

4、最关键的状态就是字符串接收完毕,这点在程序中并没有体现,反而搞出了很多状态和它并列,而其它状态根本不是从机应该关注的,这些状态要用主机来解决。

5、这种大而全,有点向操作系统靠拢,不适合具体的实际应用。

4058665 发表于 2013-12-17 12:57:32

free modbus 划分的文件比较多   目的是为了支持各种传输方式      用了接口的概念
在51里面使用的确比较蛋疼         
可以把不相关的功能都去掉      整合起来看    实时性很不错的

yylwt 发表于 2013-12-17 14:05:09

楼主威武{:biggrin:}

JACK847070222 发表于 2013-12-24 20:54:31

顶一下~谢谢

xinqingwuyu 发表于 2014-1-12 11:33:26

学习了!

努安达 发表于 2014-3-19 11:07:40

好东西,学习了!

chuchuliuq 发表于 2014-5-9 14:06:36

支持一下啊,感觉高大上啊
页: [1]
查看完整版本: 开源PLC学习笔记16续(FREEMODBUS eMBPoll侦听)——2013_12_8