UNIX 下的网络编程相对简单,使用 C 语言实现。

本指南假设你已经对 C 语言、UNIX 和网络有一个良好的基础了解。

一个简单的客户端

首先,我们来看看最简单的操作之一:初始化流连接并接收来自远程服务器的消息。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
 
#define MAXRCVLEN 500
#define PORTNUM 2300
 
int main(int argc, char *argv[])
{
   char buffer[MAXRCVLEN + 1]; /* +1 以便我们可以添加 null 终止符 */
   int len, mysocket;
   struct sockaddr_in dest; 
 
   mysocket = socket(AF_INET, SOCK_STREAM, 0);
  
   memset(&dest, 0, sizeof(dest));                /* 清零结构体 */
   dest.sin_family = AF_INET;
   dest.sin_addr.s_addr = htonl(INADDR_LOOPBACK); /* 设置目标 IP 地址 - 本地地址,127.0.0.1 */ 
   dest.sin_port = htons(PORTNUM);                /* 设置目标端口号 */
 
   connect(mysocket, (struct sockaddr *)&dest, sizeof(struct sockaddr_in));
  
   len = recv(mysocket, buffer, MAXRCVLEN, 0);
 
   /* 我们需要自己添加接收到数据的 null 终止符 */
   buffer[len] = '\0';
 
   printf("Received %s (%d bytes).\n", buffer, len);
 
   close(mysocket);
   return EXIT_SUCCESS;
}

这是一个非常简单的客户端;在实践中,我们会检查每个函数的调用是否失败,然而为了简洁性,错误检查在这里被省略了。

正如你所看到的,代码主要围绕 dest 展开,dest 是一个 sockaddr_in 类型的结构体。这个结构体存储了我们要连接的机器的信息。

mysocket = socket(AF_INET, SOCK_STREAM, 0);

socket() 函数告诉操作系统我们需要一个用于网络流连接的套接字文件描述符;这些参数的意义目前暂时不需要过多关注。

memset(&dest, 0, sizeof(dest));                /* 清零结构体 */
dest.sin_family = AF_INET;
dest.sin_addr.s_addr = inet_addr("127.0.0.1"); /* 设置目标 IP 地址 */ 
dest.sin_port = htons(PORTNUM);                /* 设置目标端口号 */

接下来是有趣的部分:

  • 第一行使用 memset() 函数将结构体清零。
  • 第二行设置地址族。这应该与传递给 socket() 的第一个参数相同;对于大多数用途,AF_INET 就足够了。
  • 第三行设置我们要连接的机器的 IP 地址。dest.sin_addr.s_addr 是一个以大端格式存储的整数,但我们无需了解这一点,因为 inet_addr() 函数会将字符串格式的 IP 地址转换为大端格式的整数。
  • 第四行设置目标端口号。htons() 函数将端口号转换为大端格式的短整型。如果你的程序仅运行在使用大端格式的机器上,那么 dest.sin_port = 21 也可以正常工作。然而,为了可移植性,建议始终使用 htons()

完成了所有的预备工作后,我们可以实际进行连接并使用它:

connect(mysocket, (struct sockaddr *)&dest, sizeof(struct sockaddr_in));

这告诉操作系统使用 mysocket 套接字创建与 dest 指定机器的连接。

len = recv(mysocket, buffer, MAXRCVLEN, 0);

这一行从连接中接收最多 MAXRCVLEN 字节的数据,并将其存储到 buffer 字符串中。recv() 返回接收到的字节数。需要注意的是,接收到的数据不会自动在缓冲区中添加 null 终止符,因此我们需要手动添加:buffer[len] = '\0'

到此为止,就完成了接收数据的基本步骤!

发送数据

学习如何接收数据后,下一步就是学习如何发送数据。如果你已经理解了前面的部分,这其实很简单。你只需要使用 send() 函数,send() 函数的参数与 recv() 函数相同。如果在前面的例子中,buffer 包含了我们想要发送的文本,并且其长度存储在 len 中,我们可以写出如下代码:

send(mysocket, buffer, len, 0);

send() 返回实际发送的字节数。需要记住的是,send() 由于各种原因,可能无法一次性发送所有字节,因此需要检查其返回值是否与尝试发送的字节数相等。在大多数情况下,未发送的字节可以通过重新发送未发送的数据来解决。

一个简单的服务器

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <sys/socket.h>
 
#define PORTNUM 2300
 
int main(int argc, char *argv[])
{
    char* msg = "Hello World !\n";
  
    struct sockaddr_in dest; /* 连接到我们的机器的信息 */
    struct sockaddr_in serv; /* 服务器信息 */
    int mysocket;            /* 用于监听传入连接的套接字 */
    socklen_t socksize = sizeof(struct sockaddr_in);

    memset(&serv, 0, sizeof(serv));           /* 清零结构体 */
    serv.sin_family = AF_INET;                /* 设置连接类型为 TCP/IP */
    serv.sin_addr.s_addr = htonl(INADDR_ANY); /* 设置我们的地址为任意接口 */
    serv.sin_port = htons(PORTNUM);           /* 设置服务器端口号 */    

    mysocket = socket(AF_INET, SOCK_STREAM, 0);
  
    /* 将服务器信息绑定到套接字 mysocket */
    bind(mysocket, (struct sockaddr *)&serv, sizeof(struct sockaddr));

    /* 开始监听,允许最多 1 个待处理连接 */
    listen(mysocket, 1);
    int consocket = accept(mysocket, (struct sockaddr *)&dest, &socksize);
  
    while(consocket)
    {
        printf("Incoming connection from %s - sending welcome\n", inet_ntoa(dest.sin_addr));
        send(consocket, msg, strlen(msg), 0); 
        close(consocket);
        consocket = accept(mysocket, (struct sockaddr *)&dest, &socksize);
    }

    close(mysocket);
    return EXIT_SUCCESS;
}

表面上看,这与客户端非常相似。第一个重要的区别是,我们不是创建一个包含要连接的机器信息的 sockaddr_in,而是创建一个包含服务器信息的 sockaddr_in,然后我们使用 bind() 将其绑定到套接字。这使得操作系统知道,接收到的目标端口上的数据应该由我们指定的套接字处理。

接下来,listen() 函数告诉程序开始监听并使用指定的套接字。listen() 的第二个参数允许我们指定最大连接队列的大小。每当有连接到服务器时,它会被添加到队列中。我们通过 accept() 函数从队列中获取连接。如果队列中没有等待的连接,程序会一直等待直到接收到连接。accept() 函数返回另一个套接字,这个套接字本质上是一个“会话”套接字,只能用于与从队列中获取的连接通信。原始套接字 (mysocket) 继续监听指定端口上的进一步连接。

一旦我们有了“会话”套接字,我们就可以像客户端那样使用它,通过 send()recv() 来处理数据传输。

需要注意的是,这个服务器一次只能接受一个连接;如果你想同时处理多个客户端连接,你将需要使用 fork() 创建多个子进程,或者使用线程来处理这些连接。

有用的网络函数

int gethostname(char *hostname, size_t size);

该函数的参数是一个字符数组的指针和数组的大小。如果可能,它会查找主机名并将其存储在该数组中。如果失败,返回 -1。

struct hostent *gethostbyname(const char *name);

该函数获取域名的信息并将其存储在 hostent 结构体中。hostent 结构体中最有用的部分是 (char**) h_addr_list 字段,它是一个以 null 终止的数组,包含与该域名相关联的所有 IP 地址。h_addr 字段是指向 h_addr_list 数组中第一个 IP 地址的指针。如果失败,返回 NULL。

常见问题

无状态连接怎么办?

如果你不想在程序中利用 TCP 的特性,而是希望使用 UDP 连接,那么你只需要在调用 socket() 时将 SOCK_STREAM 替换为 SOCK_DGRAM,并以相同的方式使用返回的套接字。需要记住的是,UDP 不保证数据包的交付和交付顺序,因此检查数据的接收情况非常重要。

如果你希望利用 UDP 的特性,你可以使用 sendto()recvfrom(),它们与 send()recv() 类似,但你需要提供额外的参数来指定你要与之通信的主机。

如何检查错误?

socket()recv()connect() 等函数在失败时都会返回 -1,并使用 errno 提供详细错误信息。

最后修改: 2025年01月12日 星期日 13:31