从客户端读取数据

《Head First C 中文版》第11章 网络与套接字 第478页 read_in() 函数:

int read_in(int socket, char *buf, int len)
{
  char *s = buf;
  int slen = len;
  int c = recv(socket, s, slen, 0);
  while ((c > 0) && (s[c-1] != '\n')) {
    s += c; slen -= c;
    c = recv(socket, s, slen, 0);
  }
  if (c < 0)
    return c;
  else if (c == 0)
    buf[0] = '\0';
  else
    s[c-1] = '\0'; // <---- 用\0替换\r。
  return len - slen;
}

书上这个 read_in() 函数倒数第3行说 用\0替换\r,实际并未成功,因为 s[c-1] 是读取的最后一个字符,通常是 \n 而不是 \r

紧接着的 return len - slen; 也不正确。read_in() 函数应返回读取的字符数,考虑最简单的情形:假设函数体第3行的 recv(socket, s, slen, 0); 一次性收到了所有的数据,则 while 循环体不会被执行,此时 slen == len,则最后的 return len - slen; 显然有误。

即使需要多次调用 recv() 函数接收数据,最后的 return len - slen; 也不会返回正确的字符数,看看后面的测试例子就知道。

测试

为了能够方便地测试 read_in() 函数,我们使用了管道,主进程 fork() 一个子进程,子进程向管道发送数据,主进程从管道接收数据。

send() 函数打印拟发送的字符数和字符串,然后向管道写数据。

recv() 函数从管道读取数据,然后打印收到的字符数和字符串。

prints() 函数用于打印非 \0 结尾的字符串,并把 \r 等非图形字符转义打印,便于看得清楚。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <ctype.h>
#include <sys/types.h>

void error(const char *msg)
{
  fprintf(stderr, "%s: %s\n", msg, strerror(errno));
  exit(1);
}

void prints(const char *s, int len)
{
  int i;
  printf(" [");
  for (i = 0; i < len; i++) {
    switch (s[i]) {
      case '\\': printf("\\\\"); break;
      case '\r': printf("\\r");  break;
      case '\n': printf("\\n");  break;
      case '\0': printf("\\0");  break;
      default  :
        if (isprint(s[i])) putchar(s[i]);
        else printf("\\x%02x", s[i] & 0xff);
    }
  }
  printf("]\r\n");
}

int send(int socket, const char * buf, int len, int flag)
{
  printf("send   :%3i bytes", len);
  prints(buf, len);
  return write(socket, buf, len);
}

int say(int socket, const char *s)
{
  int r = send(socket, s, strlen(s), 0);
  if (r == -1) fprintf(stderr, "say: %s\n", strerror(errno));
  return r;
}

int recv(int socket, char * buf, int len, int flag)
{
  int n = read(socket, buf, len);
  printf("recv   :%3i bytes", n);
  prints(buf, n);
  return n;
}

int read_in(int socket, char *buf, int len)
{
  char *s = buf;
  int slen = len;
  int c = recv(socket, s, slen, 0);
  while ((c > 0) && (s[c-1] != '\n')) {
    s += c; slen -= c;
    c = recv(socket, s, slen, 0);
  }
  if (c < 0)
    return c;
  else if (c == 0)
    buf[0] = '\0';
  else
    s[c-1] = '\0'; // <---- 用\0替换\r。
  return len - slen;
}

int main()
{
  int fd[2];
  if (pipe(fd) == -1) error("Can't create the pipe");
  pid_t pid = fork();
  if (pid == -1) error("Can't fork process");
  if (!pid) {     // 这里你在子进程中。
    close(fd[0]); // 子进程不会读取管道,所以我们关闭读取端
    const char *ss[] = { "Who'", "s t", "here?\r\n" };
    int i;
    for (i = 0; i < 3; i++) say(fd[1], ss[i]);
    return 0;
  }
  close(fd[1]);   // 父进程不需要向管道写数据,关闭写入端
  char buf[255];
  int len = read_in(fd[0], buf, sizeof(buf));
  printf("read_in:%3i bytes", len);
  if (len != -1) prints(buf, strlen(buf));
  else puts(" []");
}

编译并运行,结果如下(你的机器上 recv 执行次数可能会有所不同):

$ gcc -std=c99 a.c && ./a.exe
send   :  4 bytes [Who']
send   :  3 bytes [s t]
send   :  7 bytes [here?\r\n]
recv   :  4 bytes [Who']
recv   : 10 bytes [s there?\r\n]
read_in:  4 bytes [Who's there?\r]

可见 \r 并没有被移除,read_in() 函数的返回值也不是读取的字符数。

修正

只需修改 read_in() 函数的最后几行就能解决这个问题:

int read_in(int socket, char *buf, int len)
{
  char *s = buf;
  int slen = len;
  int c = recv(socket, s, slen, 0);
  while ((c > 0) && (s[c-1] != '\n')) {
    s += c; slen -= c;
    c = recv(socket, s, slen, 0);
  }
  if (c < 0)
    return c;
  else if (c == 0)
    buf[0] = '\0';
  else if (c > 1 && s[c-2] == '\r')
    s[c-2] = '\0'; // <---- 用\0替换\r。
  else
    s[c-1] = '\0';
  return strlen(buf);
}

再次编译并运行,现在得到正确的结果(你的机器上 recv 执行次数可能会有所不同):

$ gcc -std=c99 a.c && ./a.exe
send   :  4 bytes [Who']
send   :  3 bytes [s t]
send   :  7 bytes [here?\r\n]
recv   :  4 bytes [Who']
recv   : 10 bytes [s there?\r\n]
read_in: 12 bytes [Who's there?]

补充

上一小节修正程序 else if (c > 1 && s[c-2] == '\r') s[c-2] = '\0'; ,其实有一个很微妙的 bug,考虑一种极端情形,如果行末的 "\r\n" 是由 recv() 分两次读取的,这时 c == 1'\n' 前面的 '\r' 是上一次 recv() 读取的,就无法正确删除 '\r'

当然,如果输入是由书中所说的 telnet 提供的,行末的 "\r\n" 总是一起来的,这种极端情形应该几百万年也不会出现一次。即使是由上面第二小节的测试程序故意分两次发送行末的 "\r\n"recv() 多半也是一次就接收了,正如前面的测试结果所展现的。

也就是说,这种微妙的 bug 可能一辈子也遇不到一次,这是最难发现也最难调试的 bug 。如果是火箭发射之类关键程序,潜伏的 bug 是最可怕的,平时一直都不会出问题,总是运行正常,关键时刻出问题就是灾难性的。曾有报道“俄罗斯3颗卫星发射失败 或因运载火箭程序错误”。

言归正传,知道哪里有 bug ,解决起来是相当简单的:记录到目前为止所读取的字符数,然后用 buf[n-2] 代替 s[c-2] 判断是否 '\r' 并删除之(如果是的话)。顺便也就得到了函数应该返回的值,不必调用因扫描整个字符串而比较耗时的 strlen() 函数了。

唠叨了这么多,有耐心看到这儿的童鞋,能否在评论中吱一声,说说您的看法。最后说一句,修正后程序的局部变量 slen 不再需要了,直接用传入的参数 len 代之可也。C 语言的函数参数是传值的,程序中对 len 的修改不会影响到调用函数(当然,指针变量需另外讨论,这也是 C 语言初学者的经典话题了)。

int read_in(int socket, char *buf, int len)
{
  char *s = buf;
  int c = recv(socket, s, len, 0);
  int n = c;
  while ((c > 0) && (s[c-1] != '\n')) {
    s += c; len -= c;
    n += c = recv(socket, s, len, 0);
  }
  if (c < 0) return c;
  else if (c == 0) buf[n=0] = '\0';
  else if (n > 1 && buf[n-2] == '\r') buf[n-=2] = '\0';
  else buf[--n] = '\0';
  return n;
}

按照黄兄的建议重构:取消变量 s ,改 do ... while 避免用到两次 recv() 函数。

int read_in(int socket, char *buf, int len)
{
  int n = 0, c;
  do
    c = recv(socket, buf + n, len, 0);
  while ((c > 0) && (buf[(n+=c) - 1] != '\n'));
  if (c < 0) return c;
  else if (c == 0) buf[n=0] = '\0';
  else if (n > 1 && buf[n-2] == '\r') buf[n-=2] = '\0';
  else buf[--n] = '\0';
  return n;
}