当输入网址敲下回车的那一刻浏览器为我们做了什么呢?不妨今天就来看看用 C 语言怎么做到下载一个 HTML 页面的。

当我们在浏览器输入网址按下回车,浏览器向服务器发送一些数据。

GET / HTTP/1.1
Host: localhost

譬如说上面这样的,服务器端的程序接收到了之后就会做出一些回应,有可能是 HTML 页面,或者说其他的数据。

那怎么用 C 语言来写个程序发送这个数据呢?在 Unix 里面一切都是文件,每个进程都有一张表来记录打开的文件,这张表记录了文件的指针和一个数字。

descriptor table from "head first c

那其实向网络的另一端发送数据就是往某个文件里头写数据。我们用 C 语言往文件里头写数据读数据就行了咯。我们怎么知道要往什么地方写,从什么地方读呢?

getaddrinfo() 获取域名的地址

既然要向服务器发送数据,得需要知道服务器的地址。我们需要知道 IP 地址啦,端口,协议什么的。到了今天这些当然不需要手动来填了。我们有一个函数 getaddrinfo() ,它会帮我们填好IP,端口那些参数。

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int
getaddrinfo(const char *hostname,           // e.g. "www.example.com" or IP
            const char *servname,           // e.g. "http" or port number
            const struct addrinfo *hints, 
            struct addrinfo **res);

后面两个参数一个叫做 hints,一个叫做 res。顾名思义,我们要给它一点提示,暗示它应该怎么去拿到地址信息。然后会把回应的 response 放到 res 里面去。

不妨先看一个 showip 的例子。这个程序根据域名,去查询这个域名对应的 IP 地址是多少,然后我们把 IP 地址输出。当然计算机里面的东西都是二进制的,我们需要调用一些函数来帮我们把数据转换成需要的样子。函数的用法都可以用 man 命令去查询。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <string.h>
#include <arpa/inet.h>

int main(int argc, char const *argv[])
{
    struct addrinfo hints, *res;

    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    int status = getaddrinfo("www.example.com", "http", &hints, &res);

    if (status != 0) {
        fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(status));
        return 1;
    }

    char ipstr[INET6_ADDRSTRLEN];
    for (struct addrinfo *p = res; p != NULL; p = p->ai_next) {
        void *addr = NULL;
        if (p->ai_family == AF_INET) {
            struct sockaddr_in *sa = (struct sockaddr_in *)p->ai_addr;
            addr = &(sa->sin_addr);
        } else {
            struct sockaddr_in6 *sa = (struct sockaddr_in6 *)p->ai_addr;
            addr = &(sa->sin6_addr);
        }
        inet_ntop(p->ai_family, addr, ipstr, INET6_ADDRSTRLEN);
        printf("%s\n", ipstr);
    }
    freeaddrinfo(res);
    return 0;
}

getaddrinfo() 会把一个包含地址信息的链表交给 res,res 是链表的头。然后我们可以写一个循环去遍历链表,输出得到的 IP 地址。我们的重点不是这个,重点是 getaddrinfo() 是个好帮手,能让我们不必再手动填结构的数据了。

socket() 获取文件描述符

socket 中文叫做套接字,在 Unix/Linux 里头其实就是一个文件描述符(file descriptor),而所谓的文件描述符就是一个数字。套接字是双向的数据流,你可以往里头写数据,也可以从这里读数据。

#include <sys/socket.h>

int
socket(int domain, int type, int protocol);

我们用 getaddrinfo() 得到的 res 来填这三个参数就好了。譬如可能是这样的。

int s;
struct addrinfo hints, *res;

getaddrinfo("www.example.com", "http", &hints, &res);

s = socket(res->ai_family, res->ai_socktype, res->ai_protocol);

socket() 返回的是一个 int,这个数字就是文件描述符。得到了文件描述符那就能对文件描述符所代表的数据流写数据了。

connect() 把套接字连接到远程端口

#include <sys/types.h>
#include <sys/socket.h>

int
connect(int socket, const struct sockaddr *address, socklen_t address_len);

把套接字连接到远程端口我们就可以对套接字读数据写数据了。

struct addrinfo hints, *res;
int sockfd;

// 首先用 getaddrinfo() 加载地址的结构

memset(&hints, 0, sizeof hints);
hints.ai_family = AF_UNSPEC;
hints.ai_socktype = SOCK_STREAM;

getaddrinfo("www.example.com", "http", &hints, &res);

// 套接字

sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);

// 连接!

connect(sockfd, res->ai_addr, res->ai_addrlen);

connect() 第一个参数是 socket() 返回的那个文件描述符,然后后面的 address 和 address_len 用 res 里头的成员来填。

send() 和 recv()

有了 socket 就要用 send() 和 recv() 来发送数据和接收数据了。socket 是一个文件描述符,既然是一个文件描述符那为什么不直接用 read() 和 write() 呢?当然可以用,但是 send() 和 recv() 能更好的控制数据传输。

#include <sys/socket.h>

ssize_t
send(int socket, const void *buffer, size_t length, int flags);

ssize_t
recv(int socket, void *buffer, size_t length, int flags);

连上网络服务器后至少需要发送三样东西

GET / HTTP/1.0
Host: www.example.com

最后有个空行。那来看看最终的程序的样子吧。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    struct addrinfo hints, *res;
    int sockfd, recvd;

    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    getaddrinfo("www.example.org", "http", &hints, &res);

    sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    connect(sockfd, (struct sockaddr *)res->ai_addr, res->ai_addrlen);

    send(sockfd, "GET / HTTP/1.0\r\n"
                 "Host: www.example.com\r\n\r\n", 47, 0);

    char buffer[1024];
    while ((recvd = recv(sockfd, buffer, sizeof(buffer) -1, 0)) != 0) {
        buffer[recvd] = '\0';
        printf("%s", buffer);
    }
    freeaddrinfo(res);
    close(sockfd);
    return 0;
}

send() 函数的第二个参数就是要发送的数据,每行以 \r\n 结尾,意思就是回车换行。recv() 函数可能不能一次性接收完所有的数据,所以我们应该写个循环来不断的从网络上接收数据。recv() 函数会返回接收了多少个字节的数据,在接收的数据后面加个 \0 让它成为一个 C 语言的字符串,然后用 printf() 输出。

必须记得要检查是否发生错误,这里没处理只是想让这个程序看起来不那么可怕。下面是处理了错误的版本。

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    struct addrinfo hints, *p, *res;
    int sockfd, recvd, status;

    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;

    if ((status = getaddrinfo("www.example.org", "http", &hints, &res)) != 0) {
        fprintf(stderr, "getaddrinfo error: %s\n", gai_strerror(status));
        exit(1);
    }

    for (p = res; p != NULL; p = p->ai_next) {
        sockfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol);
        if (sockfd == -1) {
            perror("socket");
            continue;
        }
        if (connect(sockfd, (struct sockaddr *)p->ai_addr, p->ai_addrlen) == -1) {
            perror("connect failed. retrying...");
            continue;
        }
        break;
    }
    freeaddrinfo(res);

    if (p == NULL) {
        fprintf(stderr, "failed!");
        exit(1);
    }

    if (send(sockfd, "GET / HTTP/1.0\r\n"
                 "Host: www.example.com\r\n\r\n", 47, 0) == -1) {
        perror("send");
        exit(1);
    }

    char buffer[1024];
    while ((recvd = recv(sockfd, buffer, sizeof(buffer) -1, 0)) != 0) {
        buffer[recvd] = '\0';
        printf("%s", buffer);
    }
    close(sockfd);
    return 0;
}