创建一个基于套接字的bufferevent实例-echo程序的实现

本文详细介绍了libevent库中的bufferevent机制,特别是基于套接字的bufferevent实例创建、回调函数设置、事件处理及水位设置等关键概念。并通过一个简单的回显程序示例,展示了如何使用bufferevent进行数据的读写操作,以及如何解决读写事件不同步的问题。
<span style="font-family: 微软雅黑; font-size: 14px; orphans: 2; widows: 2; background-color: rgb(255, 255, 255);">     在libevent的官方文档中指出,bufferevent有四种,分别如下:</span>
     (1)基于套接字的bufferevent
     (2)异步IObufferevent
     (3)过滤型bufferevent
     (4)成对的bufferevent
  在本文中,我们只介绍基于套接字的bufferevent,这种类型的bufferevent是使用最多的。剩下的三种类型我们将在后面的文章中陆续介绍。
  另外我们还需提到一点,在官方文档中提到当前版本的libevent的bufferevent只支持面向流的协议。至于什么是面向流的协议在此处也不提及,后面的章节会介绍使用bufferevent机制读写串口,到时我们再正式的谈谈流到底是什么。
  言归正传,在这里为什么我们要先介绍怎样创建一个基于套接字的bufferevent实例呢?是因为这种类型的bufferevent使用最多,并且libevent提供了很多的支持。
1、创建套接字bufferevent
  创建套接字bufferevent的函数bufferevent_socket_new。该函数在event2/bufferevent.h中声明。
  struct bufferevent *bufferevent_socket_new(struct event_base *base,evutil_socket_t fd,enum bufferevent_options options);
  base是event_base,options是表示bufferevent选项的位掩码,fd表示一个用户指定的套接字描述符。函数成功返回一个bufferevent,失败返回NULL。
  刚才提到的options选项是一个枚举类型,其成员如下:
  BEV_OPT_CLOSE_ON_FREE:释放buffervent并关闭底层套接口。
  BEV_OPT_THREADSAFE:自动为bufferevent分配锁,主要目的是为了在多线程环境下安全的使用bufferevent。这里需要注意,libevent不是线程安全的,这是其设计上的一个缺陷,但并不影响我们在多线程环境下使用它,前提是要了解libevent中的那些结构是全局的,需要我们枷锁处理的。至于linevent哪些结构是全局的以及其在多线程环境下的使用,我们在后面的章节会介绍。
  BEV_OPT_DEFER_CALLBACKS:设置这个标志时,bufferevent延迟所有回调。
  BEV_OPT_UNLOCK_CALLBACKS:默认情况下,如果设置bufferevent为线程安全的,则bufferevent会在调用用户提供的回调函数时进行锁定。如果设置了这个选项会让libevent在执行回调的时候不进行加锁。
2、为bufferevent设置回调函数
  一个bufferevent只可以设置三个回调函数,一个写入回调,一个读取回调,一个事件回调。当bufferevent的输出buffer满足某个条件时,调用写回调函数。当bufferevent的输入buffer满足某个条件时,调用读回调函数。当套接口上某个事件发生时,调用事件回调函数。
  该函数在event2/bufferevent.h中声明。其原型如下:
  void bufferevent_setcb(struct bufferevent *bufev,bufferevent_data_cb readcb,bufferevent_data_cb writecb,bufferevent_event_cb eventcb,void *cbarg);
  其中函数指针类型bufferevent_data_cb和bufferevent_event_cb eventcb的定义如下:
  typedef void (*bufferevent_data_cb) (struct bufferevent *bev,void *ctx);
  typedef void (*bufferevent_event_cb)(struct bufferevent *bev,short events,void *ctx);
  通过bufferevent_setcb可以设置用户自己编写的读/写/事件回调函数,但是必须遵循其相应的接口类型。比如说读/写回调函数必须是返回类型为void,参数列表为(struct bufferevent *bev,void *ctx)的形式。函数bufferevent_setcb的最后一个参数是用户传递的(如果不想设置,可以传递NULL),对于所有的回调函数共享的一个指针变量,如果其中某一个回调函数修改了,那么在其它函数中也生效。
  2.1 启/停bufferevent上的读写事件
  void bufferevent_enable(struct bufferevent *bufev,short events);
  void bufferevent_disable(strcut bufferevent *bufev,short events);
  short bufferevent_get_enabled(short bufferevent *bufev);
  bufferevent_enable用来启用bufferevent上的读/写事件。bufferevent_disable用来禁用bufferevent上的读/写事件。bufferevent_get_enabled用来返回该bufferevent上启用的事件。
  2.2 设置读写事件的“水位”
  所谓的“水位”是针对bufferevent的输入buffer,输出buffer而言的。“水位”是触发读写事件回调函数的触发器。当上面提到的某个buffer达到某个“水位”时,相应的回调函数将会触发。
  2.2.1 设置读事件“水位”

读事件水位设置示意图
  读取低水位线是相对于bufferevent的输入buffer而言的,数据源输入buffer中的数据不断的被写入到bufferevent的输入缓冲区中。当bufferevent的输入buffer中的数据的个数(字节数)>低水位线时才触发读事件回调函数。其目的是只有当攒够了一定量的数据的时候才进行读取操作,减少了读bufferevent输入buffer的次数。默认情况下读取低水位线为0,也就是说bufferevent的输入buffer只要有一个字节的数据也会触发读事件的回调函数。读取高水位线是相对于bufferevent的输入buffer而言的,数据源输入buffer中的数据不断地被写入到bufferevent的输入缓冲区中,当bufferevent的输入缓冲区中数据的个数(字节数)超过了指定的高水位线后,数据源输入buffer中的数据将不会被写入到bufferevent中,直到bufferevent中的数据被抽取降到高水位线以下。默认情况下,此值为无限大,也就是说数据源中的数据一直被不断地写入到bufferevent的输入buffer中。
2.2.2 设置写事件“水位”

写事件水位设置示意图
  写入低水位线是相对于bufferevent的输出buffer而言的。bufferevent的输出buffer中的数据不断被写入到数据源的输出buffer中。当bufferevent的输出buffer中数据的个数(字节数)< 低水位线时触发用户设定的写回调函数,此时用户可以将自己定义的缓冲区的数据写入到bufferevent的输出buffer中。默认情况下,低水位线为0,表示只有当bufferevent的输出buffer为空时才调用用户的写回调函数。
  写入高水位线对于用户而言是没有意义的。它在bufferevent用作另外一个bufferevent的底层传输端口时有特殊意义。我们在成对的bufferevent中介绍。
  说了这么多,那到底怎么设置bufferevent读写的高低水位呢?libevent提供了函数bufferevent_setwatermark。其原型定义在event2/bufferevent.h中。
void bufferevent_setwatermark(struct bufferevent *bufev,short events,size_t lowmark,size_t highmark);
  通过bufferevent_setwatermark的参数events可以单独设置读取水位,或者写入水位,或者俩者都同时进行设置。对于高水位用0表示无限。
  下面我们使用基于套接口bufferevent创建一个回显程序echo。
  echo由两部分组成:客户端和服务器端。客户端通过标准输入(stdin)输入一行文本发送到服务器端,然后服务器端将接收到的文本再返回给客户端。
  服务器端tcpserv程序代码清单如图1所示:
#include     "../unp.h"

int main(int argc, char **argv) {
     int                         listenfd, connfd;
     pid_t                    childpid;
     socklen_t               clilen;
     struct sockaddr_in     cliaddr, servaddr;
     void                    sig_chld(int);

     listenfd = Socket(AF_INET, SOCK_STREAM, 0);

     bzero(&servaddr, sizeof(servaddr));
     servaddr.sin_family      = AF_INET;
     servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
     servaddr.sin_port        = htons(SERV_PORT);

     Bind(listenfd, (SA *) &servaddr, sizeof(servaddr));

     Listen(listenfd, LISTENQ);

     Signal(SIGCHLD, sig_chld);     /* must call waitpid() */

     for ( ; ; ) {
          clilen = sizeof(cliaddr);
          if ( (connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
               if (errno == EINTR)
                    continue;          /* back to for() */
               else
                    err_sys("accept error");
          }

          if ( (childpid = Fork()) == 0) {     /* child process */
               Close(listenfd);     /* close listening socket */
               str_echo(connfd);     /* process the request */
               exit(0);
          }
          Close(connfd);               /* parent closes connected socket */
     }
}
 图2 服务器端tcpserv程序代码清单
  服务器端程序没有使用libevent框架,它的功能是监听套接口,如果一旦有连接请求到来,就创建一个子进程然后回射用户发送过来的数据。我们主要介绍客户端tcpcli程序。
  服务器端程序运行如下:
  
  客户端tcpcli程序代码清单如图2所示:
#include <event2/event.h>
#include <event2/bufferevent.h>
#include <event2/buffer.h>
#include <event2/util.h>
#include "../unp.h"
struct info {
     const char *name;
     size_t total_drained;
};

char recvline[1024] = {'a','b','\n','\0'};
void read_callback(struct bufferevent *bev,void *ctx) {
     struct info *inf =(struct info *) ctx;
    
     struct evbuffer *input = bufferevent_get_input(bev);
     size_t len = evbuffer_get_length(input);
     if(len) {
          inf->total_drained += len;
          evbuffer_drain(input,len);
          fprintf(stderr,"we received [%lu] bytes from %s\n",(unsigned long)len,inf->name);
     }
}
void write_callback(struct bufferevent *bev,void *ctx) {
     char     sendline[MAXLINE];
     struct evbuffer *output = bufferevent_get_output(bev);

     if(Fgets(sendline, MAXLINE, stdin) != NULL) {
          fprintf(stderr,"we sent [%s]\n", sendline);
          bufferevent_write(bev,sendline,strlen(sendline));
     }
}
void event_callback(struct bufferevent *bev,short events,void *ctx) {
     struct info *inf = (struct info *)ctx;
     struct evbuffer *input = bufferevent_get_input(bev);
     int finished = 0;

     if(events & BEV_EVENT_EOF) {
          if(events & BEV_EVENT_READING) {
            size_t len = evbuffer_get_length(input);
            fprintf(stderr,"Got a close from %s.we drained %lu bytes from it,and have %lu left.\n",inf->name,(unsigned long)inf->total_drained,(unsigned long)len);
          } else if(events & BEV_EVENT_WRITING) {
            fprintf(stderr,"writing error\n");
         }
          finished = 1;
     }
     if(events & BEV_EVENT_ERROR) {
        if(events & BEV_EVENT_READING) {
          fprintf(stderr,"Got an error from %s:%s\n",inf->name,evutil_socket_error_to_string(EVUTIL_SOCKET_ERROR()));
        } else if(events & BEV_EVENT_WRITING) {
          fprintf(stderr,"writing error\n");
        }
        finished = 1;
     }
     if(events & BEV_EVENT_CONNETTED) {
            bufferevent_write(bev,recvline,strlen(recvline));//向服务器写入一行
     }
     if(finished) {
          free(ctx);
          bufferevent_free(bev);
     }
}

int main(int argc, char **argv) {
     int     sockfd;
     struct sockaddr_in     servaddr;
     struct event_base *base;
     struct     bufferevent *bev;
     struct info* info1;
     info1 = (struct info*)malloc(sizeof(struct info));
     info1->name="buffer1";
     info1->total_drained = 0;
     if (argc != 2)
          err_quit("usage: tcpcli <IPaddress>");

     sockfd = Socket(AF_INET, SOCK_STREAM, 0);
     bzero(&servaddr, sizeof(servaddr));
     servaddr.sin_family = AF_INET;
     servaddr.sin_port = htons(SERV_PORT);
     Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    
     base = event_base_new();
     bev = bufferevent_socket_new(base,-1,BEV_OPT_CLOSE_ON_FREE|BEV_OPT_DEFER_CALLBACKS);//创建一个套接口类型的bufferevent
     bufferevent_setwatermark(bev,EV_READ|EV_WRITE,0,0);//设置读写的数位
     bufferevent_setcb(bev, read_callback,write_callback,event_callback,info1);//设置bufferevent的读/写/事件回调函数
     bufferevent_enable(bev,EV_READ|EV_WRITE);//使能读写
     if(bufferevent_socket_connect(bev,(SA*)&servaddr,sizeof(servaddr)) < 0) {//连接到服务器127.0.0.1
          bufferevent_free(bev);
          return -1;
     }
     fprintf(stderr,"connec ok!\n");
     event_base_dispatch(base);
     exit(0);
}
 图2 客户端tcpcli程序代码清单
  我们可以这样运行tcpcli客户端程序:
  ./tcpcli 127.0.0.1
  tcpcli程序中像创建套接口的Socket函数是在系统提供的socket函数的基础上进行了封装,其定义在unp.h中。
  下面我们来分析一下这段代码。
  结构体struct info用来记录我们从bufferevent的输入buffer中读取多少数据。可以通过name给bufferevent指定名称。total_drained表明读取的数据的个数(字节数)。
  函数read_callback是我们定义的读回调函数。首先我们通过函数bufferevent_get_input获取bufferevent的输入buffer。函数bufferevent_get_input定义在event2/bufferevent.h中。其原型如下:
  struct evbuffer *bufferevent_get_input(struct bufferevent *bufev);
  struct evbuffer *bufferevent_get_output(struct bufferevent *bufev);
  然后我们调用函数evbuffer_get_length获取输入buffer中数据的个数(字节数)。函数evbuffer_get_length定义在event2/buffer.h中,其原型如下:
  size_t evbuffer_get_length(const struct evbuffer *buf);
  如果bufferevent的输入buffer中有数据我们就将其数据的个数(字节数)累加到total_drained。
  函数write_callback是我们定义的写回调函数。首先我们获取bufferevent的输出缓冲区。然后从标准输入读取一行,发送到服务器端。
  函数event_callback是我们定义的事件回调函数。首先读取bufferevent的输入缓冲区,然后检测某个事件是否发生。其中参数events是libevent初始化的,如果发生了某个事件那么就会被设置到events中。bufferevent事件分为两类:读操作事件和写操作事件。每一类又定义了具体的事件。
  BEV_EVENT_READING:读取操作时发生了某事件。
  BEV_EVENT_ERITING:写入操作时发生某事件。
  BEV_EVENT_ERROR:操作时发生错误。
  BEV_EVENT_TIMEOUT:发生超时。
  BEV_EVENT_EOF:遇到文件结束符。
  BEV_EVENT_CONNECTED:请求的连接过程已经完成。
  比如说当读取操作时发生了超时,那么events参数将被设置为BEV_EVENT_READING|BEV_EVENT_TIMEOUT。如果写操作发生了超时,那么events参数将被设置为BEV_EVENT_WRITING|BEV_EVENT_TIMEOUT。
  在main函数中我们首先初始化了struct info,然后创建了套接字sockfd和bufferevent,调用bufferevent_socket_connect连接到服务器端。在上面的介绍中我们提到,基于套接口的bufferevent是使用最多的,并且livevent提供了在其上提供了很多的现成的封装。函数bufferevent_socket_connect就是libevent提供的对系统函数connect的封装,调用此函数后如果套接口连接到服务器,libevent将会设置BEV_EVENT_CONNECTED事件,然后调用事件回调函数。如果不使用此函数,而是使用系统的connect函数,连接上服务器后libevent将会设置写入事件和BEV_EVENT_CONNECTED事件,将会触发写回调函数。
  一旦连接建立,也就是说BEV_EVENT_CONNECTED事件产生将会调用函数bufferevent_write向套接口发送a,b,\n三个字符。函数bufferevent_write定义在event2/bufferevent.h中,其原型如下:
  int bufferevent_write(struct bufferevent *bufev,const void *data,size_t size);
  服务器端接收到a,b,\n三个字符,然后回射到客户端。
  
  但是此时客户端程序并没有打印出a,b,\n而是阻塞在写回调函数的Fgets,等待用户输入。那么现在我们在客户端输入一行。
  我们输入了mill\n后,在屏幕上打印出we received [3] bytes from buffer1。不对啊,我们输入的是mill\n一共5个字符,怎么说收到了三个字符呢?我们来分析一下出现这个情况的原因。

图3 客户端和服务器端交互图
    首先是客户端发起连接,三路握手结束之后,发送a,b,\n三个字符。发送完之后首先触发的是写回调函数?为什么不是先触发读回调函数呢?因为发送完数据之后,bufferevent的输出buffer被清空了,变成了0,写回调函数被触发。读回调函数的触发条件是bufferevent的输入buffer有数据,因为发送数据有延时,所以读回调函数的触发要滞后于写回调函数。这就导致了数据的不同步。

  那既然是先触发的写回调函数,那么我们在写回调进行读操作,在读回调进行写操作是不是就能同步了呢?我们更改代码如下:
void read_callback(struct bufferevent *bev,void *ctx) {
     fprintf(stderr,"in read call back\n");
     char     sendline[MAXLINE];
     struct evbuffer *output = bufferevent_get_output(bev);

     if(Fgets(sendline, MAXLINE, stdin) != NULL) {
          fprintf(stderr,"we sent [%s]\n", sendline);
          bufferevent_write(bev,sendline,strlen(sendline));
     }
}
void write_callback(struct bufferevent *bev,void *ctx) {
     fprintf(stderr,"in write call back");
     struct info *inf =(struct info *) ctx;
    
     struct evbuffer *input = bufferevent_get_input(bev);
     size_t len = evbuffer_get_length(input);
     if(len) {
          inf->total_drained += len;
          evbuffer_drain(input,len);
          fprintf(stderr,"we received [%lu] bytes from %s\n",(unsigned long)len,inf->name);
     }
}
图4 交换了功能的读写回调函数
  在更改的代码中我们让写回调函数读取bufferevent的输入buffer,在读回调函数进行写入bufferevent的输出buffer。这样我们期望的结果是libevent首先调用写回调函数,执行完相应的代码,再执行读回调函数。然而执行的结果让我们大出意外。执行结果如下:

  这是为什么呢?其实上面的这种想法是错误。因为写回调的触发条件时bufferevent的输出buffer空了。但是并不一定输入buffer中有数据了。所以你在写回调触发的时候去读取数据并不一定成功。所以写回调函数首先被触发但是输入buffer中没有数据,返回。随后写回调触发,调用Fgets函数阻塞在用户输入。
  还有什么好的办法吗?或许这种方法可行。我们定义一个全局的变量static int is_read_ok,按如图5所示方式操作它:
图5 echo读写同步时序图
  程序代码清单如下:
static int is_read_ok = 0;
void read_callback(struct bufferevent *bev,void *ctx) {
     fprintf(stderr,"in read call back\n");
     struct info *inf =(struct info *) ctx;
    
     struct evbuffer *input = bufferevent_get_input(bev);
     size_t len = evbuffer_get_length(input);
     if(len) {
          is_read_ok = 1;
          inf->total_drained += len;
          evbuffer_drain(input,len);
          fprintf(stderr,"we received [%lu] bytes from %s\n",(unsigned long)len,inf->name);
     }
}
void write_callback(struct bufferevent *bev,void *ctx) {
     fprintf(stderr,"in write call back");
     char     sendline[MAXLINE];
     struct evbuffer *output = bufferevent_get_output(bev);
     if(is_read_ok  == 1) {
        if(Fgets(sendline, MAXLINE, stdin) != NULL) {
            fprintf(stderr,"we sent [%s]\n", sendline);
            bufferevent_write(bev,sendline,strlen(sendline));
       }
       is_read_ok = 0;
   }
}
图6 使用全局变量控制读写的代码
  运行结果如下:
  
  从运行结果中我们可以看出,写回调函数被触发了,但是没有执行任何动作(因为is_read_ok=0)。读回调函数读取了bufferevent的输入bufer并将is_read_ok设置为1,但是为什么写回调函数不会再被执行了呢?因为上面的代码违背了事件驱动程序的基本流程。事件驱动程序是事件驱动的,一个事件的发生会调用相应的处理函数,那么下一次驱动的发生要么是系统设置,要么是在处理函数中设置。这也是为什么我们在connet一连接上就调用bufferevent_write函数先进行一次写入,该操作启动了整个事件迭代的开始。而上面的代码中由于我们在一开始的写回调中没有进行任何操作,没有为下一次读事件的发生设定条件,就导致了事件链的中断。
  看来这种方法还是不行。好好想想echo程序的本质就是写-读-写-读。既然把读写分开存在写回调函数先触发的问题,那干脆不用写回调函数,直接在写入函数中处理,按照读-写-读的方式进行处理。修改后的代码如下:
  
#include <event2/event.h>
#include <event2/bufferevent.h>
#include <event2/buffer.h>
#include <event2/util.h>
#include "../unp.h"
struct info {
     const char *name;
     size_t total_drained;
};

char recvline[1024] = {'a','b','\n','\0'};
void read_callback(struct bufferevent *bev,void *ctx) {
     struct info *inf =(struct info *) ctx;
     char     sendline[MAXLINE];
     struct evbuffer *input = bufferevent_get_input(bev);
     struct evbuffer *output = bufferevent_get_output(bev);
     size_t len = evbuffer_get_length(input);
     if(len) {
          inf->total_drained += len;
          evbuffer_drain(input,len);
          fprintf(stderr,"we received [%lu] bytes from %s\n",(unsigned long)len,inf->name);
     }
     if(Fgets(sendline, MAXLINE, stdin) != NULL) {
          fprintf(stderr,"we sent [%s]\n", sendline);
          bufferevent_write(bev,sendline,strlen(sendline));
     }
}

void event_callback(struct bufferevent *bev,short events,void *ctx) {
     struct info *inf = (struct info *)ctx;
     struct evbuffer *input = bufferevent_get_input(bev);
     int finished = 0;

     if(events & BEV_EVENT_EOF) {
          if(events & BEV_EVENT_READING) {
            size_t len = evbuffer_get_length(input);
            fprintf(stderr,"Got a close from %s.we drained %lu bytes from it,and have %lu left.\n",inf->name,(unsigned long)inf->total_drained,(unsigned long)len);
          } else if(events & BEV_EVENT_WRITING) {
            fprintf(stderr,"writing error\n");
         }
          finished = 1;
     }
     if(events & BEV_EVENT_ERROR) {
        if(events & BEV_EVENT_READING) {
          fprintf(stderr,"Got an error from %s:%s\n",inf->name,evutil_socket_error_to_string(EVUTIL_SOCKET_ERROR()));
        } else if(events & BEV_EVENT_WRITING) {
          fprintf(stderr,"writing error\n");
        }
        finished = 1;
     }
     if(events & BEV_EVENT_CONNETTED) {
            bufferevent_write(bev,recvline,strlen(recvline));//向服务器写入一行
     }
     if(finished) {
          free(ctx);
          bufferevent_free(bev);
     }
}

int main(int argc, char **argv) {
     int     sockfd;
     struct sockaddr_in     servaddr;
     struct event_base *base;
     struct     bufferevent *bev;
     struct info* info1;
     info1 = (struct info*)malloc(sizeof(struct info));
     info1->name="buffer1";
     info1->total_drained = 0;
     if (argc != 2)
          err_quit("usage: tcpcli <IPaddress>");

     sockfd = Socket(AF_INET, SOCK_STREAM, 0);
     bzero(&servaddr, sizeof(servaddr));
     servaddr.sin_family = AF_INET;
     servaddr.sin_port = htons(SERV_PORT);
     Inet_pton(AF_INET, argv[1], &servaddr.sin_addr);
    
     base = event_base_new();
     bev = bufferevent_socket_new(base,-1,BEV_OPT_CLOSE_ON_FREE|BEV_OPT_DEFER_CALLBACKS);//创建一个套接口类型的bufferevent
     bufferevent_setwatermark(bev,EV_READ|EV_WRITE,0,0);//设置读写的数位
     bufferevent_setcb(bev, read_callback,NULL,event_callback,info1);//设置bufferevent的读/写/事件回调函数
     bufferevent_enable(bev,EV_READ|EV_WRITE);//使能读写
     if(bufferevent_socket_connect(bev,(SA*)&servaddr,sizeof(servaddr)) < 0) {//连接到服务器127.0.0.1
          bufferevent_free(bev);
          return -1;
     }
     fprintf(stderr,"connec ok!\n");
     event_base_dispatch(base);
     exit(0);
}
图7 只使用读回调操作的echo客户端代码
  客户端执行结果如下:
  
  服务器端执行结果如下:
  
 一个简单的echo程序被我搞的这么复杂,真是越来越觉得自己在程序设计方面缺少灵性了。一开始看到bufferevent上的例子,就怀疑怎么这些例子怎么没有同时使用读写回调的?那我就写个同时使用读写回调的回显程序吧,没想到会有这样的陷阱。其实想想也是,有的东西你不去探索是不知道它会出现什么问题的。如果我一开始就只使用读回调去做的话,这其中的奥妙我也不能知道。或许这就是阿Q精神吧。
  在下一节我们看看如何用用bufferevent操作串口。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值