内核园地 - 内核中的网络编程

作者:Pradeep Padala

所有 Linux 发行版都提供了广泛的网络应用程序,从提供各种服务的守护进程(例如 WWW、邮件和 SSH)到访问其中一项或多项服务的客户端程序。 这些程序以用户模式编写,并使用内核提供的系统调用来执行各种操作,例如网络读取和写入。 虽然这是编写程序的传统方法,但还有另一种有趣的方法来开发这些应用程序,即在内核中实现它们。 TUX Web 服务器就是一个很好的例子,它在内核中运行并提供静态内容。 在本文中,我们将解释在内核中编写网络应用程序的基础知识及其优缺点。 作为一个例子,我们将解释一个内核 FTP 客户端的实现。

内核内实现方案的优缺点

为什么有人想在内核中实现应用程序? 以下是一些优点:

  • 当用户空间程序发出系统调用时,用户空间/内核空间转换会带来一些开销。 通过在内核中编写所有功能,我们可以提高性能。

  • 发送或接收数据包的任何应用程序的数据都会从用户模式复制到内核模式,反之亦然。 通过在内核中实现网络应用程序,可以减少此类开销,并通过不将数据复制到用户模式来提高效率。

  • 在特定的研究和高性能计算环境中,需要以极高的速度实现数据传输。 内核应用程序在这种情况下可以发挥作用。

另一方面,内核内实现方案也有一些缺点:

  • 安全是内核中的一个主要问题,并且大量的用户模式应用程序不适合直接在内核中运行。 因此,在设计内核应用程序时需要格外小心。 例如,在内核中读取和写入文件通常是一个坏主意,但大多数应用程序都需要某种文件 I/O。

  • 由于内存限制,大型应用程序无法在内核中实现。

网络编程基础

网络编程通常使用套接字完成。 套接字充当两个进程之间的通信端点。 在本文中,我们将介绍使用 TCP/IP 套接字的网络编程。

服务器程序创建套接字,绑定到众所周知的端口,侦听并接受来自客户端的连接。 服务器通常设计为接受来自客户端的多个连接——它们要么派生一个新进程来服务于每个客户端请求(并发服务器),要么在接受更多连接之前完全服务于一个请求(迭代服务器)。 另一方面,客户端程序创建套接字以连接到服务器并交换信息。

FTP 客户端-服务器交互

让我们快速了解一下如何在用户模式下实现 FTP 客户端和服务器。 在本文中,我们只讨论主动 FTP。 主动 FTP 和被动 FTP 之间的差异与我们在此处对网络编程的讨论无关。

套接字编程基础

以下是 FTP 客户端和服务器设计的简要说明。 服务器程序使用以下方法创建一个套接字socket()系统调用。 然后它使用bind()绑定到众所周知的端口,并使用listen()系统调用等待来自客户端的连接。 然后服务器使用accept()接受来自客户端的传入请求,并派生一个新进程(或线程)来服务于每个传入客户端请求。

客户端程序使用以下方法创建一个控制套接字socket()接下来调用connect()来建立与服务器的连接。 然后,它使用socket()创建一个单独的套接字进行数据传输,并使用bind()绑定到非特权端口(>1024)。 客户端现在listen()s 在此端口上,用于从服务器进行数据传输。 服务器现在有足够的知识来处理来自客户端的数据传输请求。 最后,客户端使用accept()接受来自服务器的连接以发送和接收数据。 对于发送和接收数据,客户端和服务器使用write()read()sendmsg()recvmsg()系统调用。 客户端发出close()在所有打开的套接字上断开与服务器的连接。 图 1 总结了这一点。

Kernel Korner - Network Programming in the Kernel

图 1. FTP 协议使用两个套接字:一个用于控制消息,一个用于数据。

FTP 命令

以下是我们使用的一些 FTP 命令的列表。 因为我们的程序只提供该协议的基本实现,所以我们只讨论相关的命令。

  • 客户端发送一个USER <用户名>\r\n命令到服务器以开始身份验证过程。

  • 要发送密码,客户端使用PASS 密码\r\n'.

  • 在某些情况下,客户端发送 PORT 命令以通知服务器其首选的数据传输端口。 在这种情况下,客户端发送PORT <a1,a2,a3,a4,p1,p2>\r\n。 FTP 的 RFC 要求 a1–a4 构成客户端的 32 位 IP 地址,p1–p2 构成 16 位端口号。 例如,如果客户端的 IP 地址为 10.10.1.2,并且它选择端口 12001 进行数据传输,则客户端发送PORT 10,10,1,2,46,225.

  • 某些 FTP 客户端默认请求以二进制格式传输数据,而其他客户端则明确要求服务器启用以二进制模式传输数据。 此类客户端发送一个TYPE I\r\n命令到服务器以请求此操作。

图 2 是一个图表,显示了一些 FTP 命令以及服务器的响应。

Kernel Korner - Network Programming in the Kernel

图 2. 客户端通过控制连接发出 FTP 命令以设置文件传输。

内核中的套接字编程

在内核中编写程序与在用户空间中执行相同的操作不同。

我们将解释一些与在内核中编写网络应用程序有关的问题。 请参阅 Greg Kroah-Hartman 的文章“您永远不应该在内核中做的事情”(请参阅在线资源)。 首先,让我们检查一下用户空间中的系统调用如何完成其任务。 例如,看一下socket()系统调用

sockfd = socket(AF_INET,SOCK_STREAM,0);

当程序执行系统调用时,它通过中断陷入内核并将控制权交给内核。 除其他事项外,内核执行各种任务,例如保存寄存器的内容、更改地址空间边界以及检查系统调用参数的错误。 最终,内核中的sys_socket()函数负责创建指定地址和系列类型的套接字,查找未使用的文件描述符并将此数字返回给用户空间。 通过浏览内核的代码,我们可以跟踪此函数所遵循的路径(图 3)。

Kernel Korner - Network Programming in the Kernel

图 3. 系统调用的幕后:当用户空间执行 socket() 时,内核会执行必要的内务处理,然后返回一个新的文件描述符。

FTP 客户端的设计

我们现在解释内核 FTP 客户端的设计和实现。 在阅读本文时,请按照 Linux Journal FTP 站点提供的代码进行操作(请参阅资源)。 此客户端的主要功能以内核模块的形式编写,该模块动态添加一个系统调用,用户空间程序可以调用该系统调用来启动 FTP 客户端进程。 该模块仅允许 root 用户使用 FTP 读取文件。 调用此模块中系统调用的用户空间程序应谨慎使用。 例如,很容易想象当 root 运行时会导致灾难性结果

./a.out 10.0.0.1 10.0.0.2 foo_file /dev/hda1/*

并使用从 10.0.0.1 下载的文件覆盖 /dev/hda1。

导出 sys_call_table

我们首先需要配置 Linux 内核,以允许我们通过内核模块动态添加新的系统调用。 从版本 2.6 开始,符号sys_call_table不再由内核导出。 为了使我们的模块能够动态添加系统调用,我们需要将以下行添加到内核源的 arch/i386/kernel/i386_ksyms.c 中(假设您正在使用奔腾级机器)

extern void *sys_call_table;
EXPORT_SYMBOL(sys_call_table);

在重新编译内核并将机器启动到内核中后,我们就可以运行 FTP 客户端了。 有关编译内核的详细信息,请参阅内核重建 HOWTO(请参阅资源)。

模块基础

让我们首先检查模块的代码。 在本文的代码片段中,为了清楚起见,我们省略了错误检查和其他不相关的细节。 完整的代码可从 LJ FTP 站点获得(请参阅资源)

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

/* For socket etc */
#include <linux/net.h>
#include <net/sock.h>
#include <linux/tcp.h>
#include <linux/in.h>
#include <asm/uaccess.h>
#include <linux/file.h>
#include <linux/socket.h>
#include <linux/smp_lock.h>
#include <linux/slab.h>

...

int ftp_init(void)
{

    printk(KERN_INFO FTP_STRING
    "Starting ftp client module\n");
    sys_call_table[SYSCALL_NUM] = my_sys_call;
    return 0;
}

void ftp_exit(void)
{
    printk(KERN_INFO FTP_STRING
    "Cleaning up ftp client module, bye !\n");
    sys_call_table[SYSCALL_NUM] = sys_ni_syscall;
}

...

程序以习惯性的包含指令开始。 在头文件中值得注意的是 linux/kernel.h(用于 KERN_ALERT)和 linux/slab.h(包含 kmalloc() 的定义)以及 linux/smp_lock.h(定义内核锁定例程)。 系统调用由内核中与用户空间中相同的名称的函数处理,但前缀为sys_。 例如,内核中的sys_socket函数处理socket()系统调用的任务。 在此模块中,我们使用系统调用号 223 作为我们的新系统调用。 此方法并非万无一失,并且无法在 SMP 机器上运行。 在卸载模块时,我们将注销我们的系统调用。

系统调用

该模块的主力是执行 FTP 读取的新系统调用。 系统调用采用一个结构作为参数。 该结构是不言自明的,如下所示

struct params {
    /* Destination IP address */
    unsigned char destip[4];
    /* Source IP address */
    unsigned char srcip[4];
    /* Source file - file to be downloaded from
       the server */
    char src[64];
    /* Destination file - local file where the
       downloaded file is copied */
    char dst[64];
    char user[16]; /* Username */
    char pass[64]; /* Password */
};

系统调用如下所示。 我们将在接下来的几个段落中解释相关的详细信息

asmlinkage int my_sys_call
(struct params __user *pm)
{
    struct sockaddr_in saddr, daddr;
    struct socket *control= NULL;
    struct socket *data = NULL;
    struct socket *new_sock = NULL;

    int r = -1;
    char *response = kmalloc(SNDBUF, GFP_KERNEL);
    char *reply = kmalloc(RCVBUF, GFP_KERNEL);

    struct params pmk;

    if(unlikely(!access_ok(VERIFY_READ,
                 pm, sizeof(pm))))
        return -EFAULT;
    if(copy_from_user(&pmk, pm,
       sizeof(struct params)))
        return -EFAULT;
    if(current->uid != 0)
        return r;

    r = sock_create(PF_INET, SOCK_STREAM,
                    IPPROTO_TCP, &control);

    memset(&servaddr,0, sizeof(servaddr));
    servaddr.sin_family = AF_INET;
    servaddr.sin_port = htons(PORT);
    servaddr.sin_addr.s_addr =
        htonl(create_address(128, 196, 40, 225));

    r = control->ops->connect(control,
             (struct sockaddr *) &servaddr,
             sizeof(servaddr), O_RDWR);
    read_response(control, response);
    sprintf(temp, "USER %s\r\n", pmk.user);
    send_reply(control, temp);
    read_response(control, response);
    sprintf(temp, "PASS %s\r\n", pmk.pass);
    send_reply(control, temp);
    read_response(control, response);

我们首先声明指向几个socket结构的指针。kmalloc()malloc()的内核等效项,用于为我们的字符数组分配内存。 该数组的响应和回复将包含对服务器的响应和回复。

第一步是将参数从用户模式读取到内核模式。 这通常使用access_okverify_read/verify_write调用完成。access_ok检查用户空间指针是否有效以进行引用。verify_read用于从用户模式读取数据。 对于读取简单的变量,如charint,使用__get_user.

现在我们有了用户指定的参数,下一步是创建一个控制套接字并建立与 FTP 服务器的连接。sock_create()为我们执行此操作——它的参数与我们传递给用户级别socket()系统调用的参数类似。 现在使用所有必要的信息(地址族、目标端口和服务器的 IP 地址)填充struct sockaddr_in变量servaddr。 每个socket结构都有一个成员,该成员是指向类型为struct proto_ops的结构的指针。 此结构包含指向可以在套接字上执行的所有操作的函数指针列表。 我们使用此结构的connect()函数来建立与服务器的连接。 我们的函数read_response()send_reply()在客户端和服务器之间传输数据(这些功能将在稍后解释)

r = sock_create(PF_INET, SOCK_STREAM,
                IPPROTO_TCP, &data);
memset(&claddr,0, sizeof(claddr));
claddr.sin_family = AF_INET;
claddr.sin_port = htons(EPH_PORT);
clddr.sin_addr.s_addr= htonl(
                       create_address(srcip));
r = data->ops->bind(data,
         (struct sockaddr *)&claddr,
         sizeof (claddr));
r = data->ops->listen(data, 1);

现在,创建一个数据套接字以在客户端和服务器之间传输数据。 我们填写另一个struct sockaddr_in变量claddr包含有关客户端的信息——协议族、客户端将绑定到的本地非特权端口,以及当然,IP 地址。 接下来,套接字被绑定到临时端口 EPH_PORT。 该函数listen()让内核知道该套接字可以接受传入的连接

a = (char *)&claddr.sin_addr;
p = (char *)&claddr.sin_port;

send_reply(control, reply);
read_response(control, response);

strcpy(reply, "RETR ");
strcat(reply, src);
strcat(reply, "\r\n");

send_reply(control, reply);
read_response(control, response);

如前所述,向 FTP 服务器发出 PORT 命令,以告知其用于数据传输的端口。 此命令通过控制套接字发送,而不是通过数据套接字发送

new_sock = sock_alloc();
new_sock->type = data->type;
new_sock->ops = data->ops;

r = data->ops->accept(data, new_sock, 0);
new_sock->ops->getname(new_sock,
    (struct sockaddr *)address, &len, 2);

现在,客户端已准备好从服务器接收数据。 我们创建一个新的套接字,并为其分配相同的类型ops作为我们的数据套接字。 该accept()函数提取监听队列中的第一个挂起连接,并创建一个具有与数据相同的连接属性的新套接字。 因此,创建的新套接字处理客户端和服务器之间的所有数据传输。 该getname()函数获取套接字另一端的地址。 上述代码段中的最后三行仅用于打印有关服务器的信息

if((total_written = write_to_file(pmk.dst,
            new_sock, response)) < 0)
    goto err3;

函数write_to_file处理在内核中打开文件并将来自套接字的数据写回文件。 写入套接字的工作方式如下

void send_reply(struct socket *sock, char *str)
{
    send_sync_buf(sock, str, strlen(str),
                  MSG_DONTWAIT);
}


int send_sync_buf
(struct socket *sock, const char *buf,
 const size_t length, unsigned long flags)
{
    struct msghdr msg;
    struct iovec iov;
    int len, written = 0, left = length;
    mm_segment_t oldmm;

    msg.msg_name     = 0;
    msg.msg_namelen  = 0;
    msg.msg_iov      = &iov;
    msg.msg_iovlen   = 1;
    msg.msg_control  = NULL;
    msg.msg_controllen = 0;
    msg.msg_flags    = flags;

    oldmm = get_fs(); set_fs(KERNEL_DS);

repeat_send:
    msg.msg_iov->iov_len = left;
    msg.msg_iov->iov_base = (char *) buf +
                                written;

    len = sock_sendmsg(sock, &msg, left);
    ...
    return written ? written : len;
}

send_reply()函数调用send_sync_buf(),它通过调用sock_sendmsg()来完成发送消息的实际工作。 该函数sock_sendmsg()接受指向struct socket、要发送的消息和消息长度的指针。 该消息由结构msghdr表示。 这个结构体中一个重要的成员是iov(io 向量)。 iovector 有两个成员,iov_baseiov_len:

struct iovec
{
    /* Should point to message buffer */
    void *iov_base;
    /* Message length */
    __kernel_size_t iov_len;
};

这些成员填充了适当的值,并且sock_sendmsg()被调用以发送消息。

set_fs用于设置 FS 寄存器以指向内核数据段。 这允许sock_sendmsg()在内核数据段中找到数据,而不是在用户空间数据段中。 宏get_fs保存 FS 的旧值。 在调用sock_sendmsg()之后,将恢复 FS 的已保存值。

从套接字读取的工作方式类似

int read_response(struct socket *sock, char *str)
{
        ...
        len = sock_recvmsg(sock, &msg,
                max_size, 0);
        ...
        return len;
}

read_response()函数类似于send_reply()。 在填充msghdr结构后,它使用sock_recvmsg()从套接字读取数据并返回读取的字节数。

用户空间程序

现在,让我们看一下一个用户空间程序,该程序调用我们的系统调用来传输文件。 我们解释调用新系统调用的相关细节

...
#define __NR_my_sys_call 223
_syscall1(long long int, my_sys_call,
          struct params *, p);

int main(int argc, char **argv)
{
  struct params pm;
  /* fill pm with appropriate values */
  ...
  r =  my_sys_call(&pm);
  ...
}

#define __NR_my_sys_call 223为我们的系统调用分配一个数字。_syscall1()是一个宏,用于为系统调用创建存根。 它显示了我们的系统调用期望的类型和参数数量。 有了这些,my_sys_call可以像任何其他系统调用一样被调用。 运行程序后,使用源文件和目标文件的正确值,将来自远程 FTP 服务器的文件下载到客户端计算机上。 这是示例运行的记录

# make
make -C /lib/modules/2.6.9/build SUBDIRS=/home/ppadala/ftp modules
make[1]: Entering directory `/home/ppadala/linux-2.6.9'
  CC [M]  /home/ppadala/ftp/ftp.o
  Building modules, stage 2.
  MODPOST
  CC      /home/ppadala/ftp/ftp.mod.o
  LD [M]  /home/ppadala/ftp/ftp.ko
make[1]: Leaving directory `/home/ppadala/linux-2.6.9'
# gcc do_ftp.c
# ./a.out <local host's IP address> 152.2.210.80 /README /tmp/README anonymous anon@cs.edu
Connection from 152.2.210.80
return = 215 (length of file copied)

结论

我们已经看到了内核中 FTP 客户端的基本实现。 本文解释了内核中套接字编程的各种问题。 感兴趣的读者可以遵循这些想法来编写各种网络应用程序,例如 HTTP 客户端甚至内核中的 Web 服务器。 内核应用程序(例如 TUX Web 服务器)用于高性能内容服务,非常适合需要高速数据传输的环境。 必须仔细注意此类应用程序的设计、实现和安全问题。

本文资源: /article/8453

Pradeep Padala 是密歇根大学的博士生。 他的主要兴趣在于分布式系统,特别是调度和容错。 他是 NCurses Programming HOWTO 的作者,并为各种开源项目做出了贡献。 有关他的更多信息可以在他的网站 www.eecs.umich.edu/~ppadala 上找到。

Ravi Parimi 拥有计算机工程硕士学位,目前在加利福尼亚州硅谷工作。 他的主要兴趣在于操作系统、网络和互联网安全。 他自 1998 年以来一直在使用 Linux,并渴望成为一名内核黑客。 在空闲时间,他追求吠陀研究和国际象棋。

加载 Disqus 评论