Linux内核NIC网卡驱动实战案例分析

以下Linux 内核模块实现了一个虚拟网络设备驱动程序,其作用和意义如下:


1. 作用

(1)创建虚拟网络设备对
  • 驱动程序动态创建了两个虚拟网络设备(nic_dev[0]和nic_dev[1]),模拟物理网卡的功能。这两个设备可以像真实网卡一样配置 IP 地址、启用 / 禁用接口,并参与网络通信。
(2)模拟数据包的环回传输
  • 当数据包通过其中一个虚拟设备发送时(如eth0),驱动程序的nic_hw_xmit函数会直接将数据包传递给另一个虚拟设备(如eth1)的接收函数nic_rx,实现数据包在两个虚拟设备之间的 "环回" 传输。这种模拟避免了对物理网络硬件的依赖。
(3)提供网络协议栈接口
  • 驱动程序实现了网络设备的核心操作(如打开、关闭、发送数据包、验证 MAC 地址等),通过net_device_ops结构体与 Linux 内核网络协议栈无缝对接。这使得用户空间的网络工具(如ping、ifconfig)可以像操作真实网卡一样操作虚拟设备。
(4)支持基本网络功能
  • 驱动程序支持设置 MAC 地址、修改 MTU、校验和计算等基本网络功能,能够满足简单网络通信的需求。

2. 意义

(1)简化网络开发与测试
  • 虚拟网络设备为开发者提供了一个无需物理硬件的测试环境。例如,可以在同一台主机上通过这两个虚拟设备测试网络协议(如 IP、TCP)的实现,验证数据包的路由、转发和处理逻辑。
(2)降低开发成本
  • 无需真实网卡和网络环境,减少了硬件依赖,降低了开发和调试的成本。开发者可以在隔离的虚拟环境中复现网络问题,提高开发效率。
(3)演示网络驱动原理
  • 驱动程序的代码结构清晰,展示了 Linux 内核网络驱动的基本框架(如设备注册、数据包收发、统计信息维护等),适合作为学习网络驱动开发的示例。
(4)支持特殊网络场景
  • 虚拟设备对可以用于模拟网桥、隧道或其他虚拟网络拓扑,满足特定场景下的网络需求(如容器网络、网络虚拟化)。

一、nic.c

cpp 复制代码
#include <linux/module.h>
#include <linux/netdevice.h>
#include <linux/etherdevice.h>
#include <linux/inetdevice.h>
#include <linux/ip.h>
#include <linux/skbuff.h>

MODULE_AUTHOR("jerry");
MODULE_DESCRIPTION("Kernel module for nic");
MODULE_LICENSE("GPL");

//定义以太网帧的最大缓冲区大小,用于驱动中发送(TX)和接收(RX)缓冲区的内存分配。标准以太网帧的最大长度为 1518 字节(14 字节头部 + 1500 字节数据 + 4 字节 FCS 校验)
//但在某些场景下(如包含 VLAN 标签、QinQ 封装或其他扩展头部),以太网帧的总长度会超过 1518 字节。
#define MAX_ETH_FRAME_SIZE 1792
//定义调试信息输出的默认控制位掩码,用于启用或禁用不同级别的日志输出,这里所有 16 位均为 1,表示 启用所有调试信息
#define DEF_MSG_ENABLE 0xffff

struct nic_priv {
    //发送的数据放在tx里
    unsigned char *tx_buf;
    unsigned int tx_len;
    unsigned char *rx_buf;
    unsigned int rx_len;
    u32 msg_enable;
};

static struct net_device *nic_dev[2];

static int nic_open(struct net_device *dev);
static int nic_stop(struct net_device *dev);
static netdev_tx_t nic_start_xmit(struct sk_buff *skb, struct net_device *dev);
static int nic_validate_addr(struct net_device *dev);
static int nic_change_mtu(struct net_device *dev, int new_mtu);
static int nic_set_mac_addr(struct net_device *dev, void *addr);

//这个 dump 函数的功能是将缓冲区 buffer 中的以太网头部、IP 头部以及负载的前 4 个字节以十六进制字符串的形式打印出来,用于调试网络数据包的内容。
static void dump(unsigned char *buffer){   //参数 unsigned char *buffer:指向要转储(打印)的数据包缓冲区,通常包含以太网帧、IP 数据包等数据。
    unsigned char *p;  //用于操作字符数组 sbuf 的指针,指向当前字符串拼接的位置。
    //每个字节需要用 2 个十六进制字符表示(例如 0A),因此缓冲区大小为 2 * (以太网头部长度 + IP 头部长度)。
    unsigned char sbuf[2*(sizeof(struct ethhdr) + sizeof(struct iphdr))];
    int i;

    //将指针 p 指向 sbuf 的起始位置,准备开始拼接字符串
    p = sbuf;
    //打印以太网头部(Ethernet Header)
    for (i = 0; i < sizeof(struct ethhdr); i++) {
        //将 buffer 中第 i 个字节格式化为两位大写十六进制字符串(例如 0A),并拼接到 sbuf 中
        p += sprintf(p, "%02X", buffer[i]);
    }
    printk("eth %s\n", sbuf);

    //打印 IP 头部(IP Header)
    p = sbuf;
    for (i = 0; i < sizeof(struct iphdr); i++) {
        p += sprintf(p, "%02X", buffer[sizeof(struct ethhdr) + i]);
    }
    printk("iph %s\n", sbuf);

    //打印负载前 4 个字节(Payload)
    p = sbuf;
    for (i = 0; i < 4; i++) {
        p += sprintf(p, "%02X", buffer[sizeof(struct ethhdr) + sizeof(struct iphdr) + i]);
    }
    printk("payload %s\n", sbuf);
}

//nic_rx 函数的主要功能是处理网络设备接收到的数据包。它会为接收到的数据包分配一个 sk_buff(socket buffer)结构体,
//将数据包内容复制到 sk_buff 中,设置 sk_buff 的相关属性,更新网络设备的统计信息,最后将 sk_buff 传递给上层网络协议栈进行进一步处理
/*
struct net_device *dev:指向网络设备结构体 net_device 的指针,代表接收数据包的网络设备。通过这个指针可以访问该网络设备的各种属性和操作函数。
int len:表示接收到的数据包的长度,以字节为单位。
unsigned char *buf:指向接收到的数据包数据的指针,存储着实际的数据包内容。
*/
static void nic_rx(struct net_device *dev,int len,unsigned char *buf){
    //存储接收到的数据包
    struct sk_buff *skb;
    struct nic_priv *priv = netdev_priv(dev);
    netif_info(priv, hw, dev, "%s(#%d), rx:%d\n",
        __func__, __LINE__, len);
    //调用 dev_alloc_skb 函数为接收到的数据包分配一个 sk_buff 结构体,分配的大小为 len + 2 字节,多分配 2 字节可能是为了预留一些空间用于后续操作
    skb = dev_alloc_skb(len + 2);
    if (!skb) {
        netif_err(priv, rx_err, dev,
                  "%s(#%d), rx: low on mem - packet dropped\n",
                  __func__, __LINE__);
        dev->stats.rx_dropped++;
        return;
    }
    skb_reserve(skb, 2);
    //此时此刻网卡接收到的数据已经从buf复制到skb中,包括协议类型
    memcpy(skb_put(skb, len), buf, len);
    skb->dev = dev;
    //但是这里解析并赋值是为了将协议类型单独提出来,防止每次都需要解析
    skb->protocol = eth_type_trans(skb, dev);
    //表示不需要对该数据包进行校验和计算
    skb->ip_summed = CHECKSUM_UNNECESSARY;
    //将网络设备的统计信息中的接收数据包数量加 1
    dev->stats.rx_packets++;
    //将网络设备的统计信息中的接收字节数增加 len
    dev->stats.rx_bytes += len;
    //调用 netif_rx 函数将处理好的 sk_buff 传递给上层网络协议栈进行进一步处理,比如 IP 层、TCP 层等
    netif_rx(skb);
}

//当使用ifconfig eth2 192.168.186.138 up 的时候会调用到这个函数
//该命令是:为指定的网络接口(eth2)分配一个静态的 IPv4 地址(192.168.186.138),并且激活该网络接口
static int nic_open(struct net_device *dev) {
    struct nic_priv *priv = netdev_priv(dev);
    priv->tx_buf = kmalloc(MAX_ETH_FRAME_SIZE, GFP_KERNEL);
    if (!priv->tx_buf) {
        return -ENOMEM;
    }
    priv->rx_buf = kmalloc(MAX_ETH_FRAME_SIZE, GFP_KERNEL);
    if (!priv->rx_buf) {
        kfree(priv->tx_buf); // 释放已分配的 tx_buf
        return -ENOMEM;
    }
    netif_start_queue(dev);
    return 0;
}

//ifconfig eth2 down
int nic_stop(struct net_device *dev){
    struct nic_priv *priv = netdev_priv(dev);
    kfree(priv->tx_buf);
    kfree(priv->rx_buf);
    netif_stop_queue(dev);
    return 0;
}

//nic_hw_xmit 函数的主要功能是模拟网络设备的硬件传输过程。模拟发送(不是真的发送只是组织好数据包,并直接自己接收),目的是降低成本,便于调试等
//重新计算 IP 头部校验和,更新设备的发送统计信息,最后将修改后的数据包模拟为接收到的数据包,调用 nic_rx 函数进行处理。
static void nic_hw_xmit(struct net_device *dev) {
    struct nic_priv *priv = netdev_priv(dev);
    //声明一个指向 iphdr 结构体的指针 iph,用于指向 IP 头部
    struct iphdr *iph;
    //声明两个指向 32 位无符号整数的指针 saddr 和 daddr,分别用于存储源 IP 地址和目的 IP 地址
    u32 *saddr, *daddr;

    //检查发送缓冲区中的数据包长度 priv->tx_len 是否小于以太网头部长度和 IP 头部长度之和。
    if (priv->tx_len < sizeof(struct ethhdr) + sizeof(struct iphdr)) {
        netif_info(priv, hw, dev, "%s(#%d), too short\n",
                   __func__, __LINE__);
        return ;
    }
    //打印信息
    dump(priv->tx_buf);
    iph = (struct iphdr*)(priv->tx_buf + sizeof(struct ethhdr));
    saddr = &iph->saddr;
    daddr = &iph->daddr;
    netif_info(priv, hw, dev, "%s(#%d), orig, src:%pI4, dst:%pI4, len:%d\n",
        __func__, __LINE__, saddr, daddr, priv->tx_len);

    //将 IP 头部的校验和字段 iph->check 置为 0
    iph->check = 0;
    //调用 ip_fast_csum 函数重新计算 IP 头部的校验和,并将结果赋值给 iph->check
    iph->check = ip_fast_csum((unsigned char*)iph, iph->ihl);
    //dev->stats.tx_packets ++;
    dev->stats.tx_packets ++;
    //dev->stats.tx_bytes += priv->tx_len;
    dev->stats.tx_bytes += priv->tx_len;

    //调用 nic_rx 函数,将修改后的数据包模拟为接收到的数据包,传递给另一个网络设备进行处理
    nic_rx(nic_dev[(dev == nic_dev[0] ? 1 : 0)], priv->tx_len, priv->tx_buf);
}

//该函数是网络设备驱动的核心发送函数,负责将内核传递的 skb 数据包转换为硬件可发送的格式,并触发实际的发送操作
/*
struct sk_buff *skb:套接字缓冲区(Socket Buffer),包含待发送的数据包。
struct net_device *dev:当前网络设备结构体,代表数据包要从哪个设备发送。
*/
netdev_tx_t nic_start_xmit(struct sk_buff *skb,struct net_device *dev){
    struct nic_priv *priv = netdev_priv(dev);
    netif_info(priv, drv, dev, "%s(#%d), orig, src:%pI4, dst:%pI4\n",
           __func__, __LINE__, &(ip_hdr(skb)->saddr), &(ip_hdr(skb)->daddr));
    priv->tx_len = skb->len;
    if (likely(priv->tx_len < MAX_ETH_FRAME_SIZE)) {
        if (priv->tx_len < ETH_ZLEN) {
            memset(priv->tx_buf, 0, ETH_ZLEN);
            priv->tx_len = ETH_ZLEN;
        }
        //函数将 skb 中的数据复制到驱动的发送缓冲区 priv->tx_buf,并计算硬件校验和(如 CRC)。这一步是为了让硬件可以直接发送数据,无需再次计算校验和
        skb_copy_and_csum_dev(skb, priv->tx_buf);
        //数据包数据已复制到 tx_buf,不再需要 skb,调用 dev_kfree_skb_any 释放 skb 内存,避免内存泄漏
        dev_kfree_skb_any(skb);
    }else {  //如果数据包长度超过 MAX_ETH_FRAME_SIZE,直接释放 skb,增加设备统计中的发送丢弃计数(tx_dropped),并返回 NETDEV_TX_OK
        dev_kfree_skb_any(skb);
        dev->stats.tx_dropped++;
        return NETDEV_TX_OK;
    }
    //调用模拟的发送函数
    nic_hw_xmit(dev);
    return NETDEV_TX_OK;
}

//设置网络设备的 MAC 地址
static int nic_set_mac_addr(struct net_device *dev, void *addr) {
    // 获取设备的私有数据结构指针
    struct nic_priv *priv = netdev_priv(dev);
    // 打印调试信息:函数名、行号、私有数据指针
    netif_info(priv, drv, dev, "%s(#%d), priv:%p\n", __func__, __LINE__, priv);
    // 调用内核的通用以太网 MAC 地址设置函数
    return eth_mac_addr(dev, addr);
}

//验证网络设备的 MAC 地址是否有效
int nic_validate_addr(struct net_device *dev){
    // 获取设备的私有数据结构指针
    struct nic_priv *priv = netdev_priv(dev);
    // 打印调试信息:函数名、行号、私有数据指针
    netif_info(priv, drv, dev, "%s(#%d), priv:%p\n", __func__, __LINE__, priv);
    // 调用内核的通用以太网 MAC 地址验证函数
    return eth_validate_addr(dev);
}

//修改网络设备的 MTU(Maximum Transmission Unit,最大传输单元)
static int nic_change_mtu(struct net_device *dev, int new_mtu) {
    struct nic_priv *priv = netdev_priv(dev);
    netif_info(priv, drv, dev, "%s(#%d), priv:%p, mtu%d\n",
               __func__, __LINE__, priv, new_mtu);
    
    // 直接设置新的MTU值并返回0(成功)
    dev->mtu = new_mtu;
    return 0;
}

netmap,将网卡内容映射到内存中,可以从用户空间直接拿到数据

//为网络数据包创建以太网头部,包含了源 MAC 地址、目的 MAC 地址以及协议类型等信息
static int nic_header_create (struct sk_buff *skb, struct net_device *dev,
    unsigned short type, const void *daddr,
    const void *saddr, unsigned int len) {
    /*
    struct sk_buff *skb:指向 sk_buff 结构体的指针,sk_buff 是 Linux 内核中用于存储网络数据包的结构体,它包含了数据包的数据和相关的元信息。
    struct net_device *dev:指向 net_device 结构体的指针,代表当前发送数据包的网络设备。
    unsigned short type:表示以太网帧的协议类型,例如 ETH_P_IP 表示 IPv4 协议,ETH_P_IPV6 表示 IPv6 协议。
    const void *daddr:指向目的 MAC 地址的指针,如果为 NULL,则需要使用其他默认地址。
    const void *saddr:指向源 MAC 地址的指针,如果为 NULL,则使用当前网络设备的 MAC 地址。
    unsigned int len:数据包的长度。    
    */
    struct nic_priv *priv = netdev_priv(dev);
    //将预留空间的起始地址强制转换为 struct ethhdr 类型的指针,并赋值给 eth 指针,这样就可以通过 eth 指针来操作以太网头部
    struct ethhdr *eth = (struct ethhdr*)skb_push(skb, ETH_HLEN);
    struct net_device *dst_netdev;
    //输出当前函数名和行号的调试信息。其中 priv 是网络设备的私有数据,drv 表示调试信息的类别,dev 是当前网络设备,__func__ 是当前函数名,__LINE__ 是当前行号
    netif_info(priv, drv, dev, "%s(#%d)\n",
                __func__, __LINE__);
    //根据当前网络设备 dev 来确定目的网络设备。如果 dev 是 nic_dev[0],则目的网络设备是 nic_dev[1];反之,如果 dev 是 nic_dev[1],则目的网络设备是 nic_dev[0]
    dst_netdev = nic_dev[(dev == nic_dev[0] ? 1 : 0)];
    //将传入的协议类型 type 转换为网络字节序后,赋值给以太网头部的 h_proto 字段,表示该以太网帧所承载的协议类型
    eth->h_proto = htons(type);
    //复制源MAC地址和目的MAC地址
    memcpy(eth->h_source, saddr ? saddr : dev->dev_addr, dev->addr_len);
    memcpy(eth->h_dest, dst_netdev->dev_addr, dst_netdev->addr_len);
    //返回当前网络设备的硬件头部长度,通常为 14 字节。这个返回值可以让调用者知道添加的以太网头部的长度
    return dev->hard_header_len;
}


static const struct header_ops nic_header_ops = {
    .create = nic_header_create,
};

static struct net_device_ops nic_device_ops = {
    .ndo_open = nic_open,
    .ndo_stop = nic_stop,
    .ndo_start_xmit = nic_start_xmit,
    .ndo_set_mac_address = nic_set_mac_addr,
    .ndo_validate_addr = nic_validate_addr,
    .ndo_change_mtu = nic_change_mtu,
};


static struct net_device *nic_alloc_netdev(void){
    //alloc_etherdev:内核函数,用于分配一个以太网设备(struct net_device)的内存,参数是私有数据区域的大小
    struct net_device *netdev = alloc_etherdev(sizeof(struct nic_priv));
    if (!netdev) {
        pr_err("%s(#%d): alloc dev failed", __func__, __LINE__);
        return NULL;
    }
    //设置随机 MAC 地址
    eth_hw_addr_random(netdev);
    //绑定网络设备操作函数,:将设备的操作函数表绑定到新分配的网络设备,定义其行为(例如如何处理数据包发送、打开/关闭设备等)
    netdev->netdev_ops = &nic_device_ops;
    //表示该设备需要 ARP(地址解析协议)
    netdev->flags &= ~IFF_NOARP;
    //启用硬件校验和功能,告诉内核网络协议栈,设备可以自行处理 IP/TCP/UDP 等协议的校验和计算,无需软件参与
    netdev->features |= NETIF_F_HW_CSUM;
    //设置头部操作函数,nic_header_create 用于在发送数据包时构造以太网头部
    return netdev;
}

//加载设备
static int __init nic_init(void){
    int ret = 0;
    struct nic_priv *priv;

    pr_info("%s(#%d): install module\n", __func__, __LINE__);

    //1.malloc net_device
    nic_dev[0] = nic_alloc_netdev();
    if (!nic_dev[0]) {
        printk("%s(#%d): alloc netdev[0] failed\n", __func__, __LINE__);
        return -ENOMEM;
    }

    nic_dev[1] = nic_alloc_netdev();
    if (!nic_dev[1]) {
        printk("%s(#%d): alloc netdev[1] failed\n", __func__, __LINE__);
        goto alloc_2nd_failed;
    }

    //2.register
    ret = register_netdev(nic_dev[0]);
    if (ret) {
        printk("%s(#%d): reg net driver failed. ret: %d\n", __func__, __LINE__, ret);
        goto reg1_failed;
    }

    ret = register_netdev(nic_dev[1]);
    if (ret) {
        printk("%s(#%d): reg net driver failed. ret:%d\n", __func__, __LINE__, ret);
        goto reg2_failed;
    }

    //初始化两个网络设备的私有数据结构,具体来说是为每个设备的 msg_enable 字段赋值为 DEF_MSG_ENABLE(即 0xffff)
    /*
    在 Linux 内核里,struct net_device 结构体代表一个网络设备。
    为了让驱动程序能够存储与特定网络设备相关的私有数据,内核采用了一种特殊的存储方式。
    当使用 alloc_etherdev 等函数分配网络设备时,
    会额外分配一块内存空间用于存储私有数据,这块空间紧跟在 struct net_device 结构体之后。
    */
    priv = netdev_priv(nic_dev[0]);      // 获取 nic_dev[0] 的私有数据指针
    priv->msg_enable = DEF_MSG_ENABLE;   // 设置 msg_enable 为 0xffff

    priv = netdev_priv(nic_dev[1]);      // 获取 nic_dev[1] 的私有数据指针
    priv->msg_enable = DEF_MSG_ENABLE;   // 设置 msg_enable 为 0xffff

    return 0;

reg2_failed:
    unregister_netdev(nic_dev[0]);
reg1_failed:
    free_netdev(nic_dev[1]);
alloc_2nd_failed:
    free_netdev(nic_dev[0]);
    
    return ret;
}

//卸载设备
static void __exit nic_exit(void){
    int i = 0;
    pr_info("%s(#%d): remove module\n", __func__, __LINE__);
    for (i = 0;i < ARRAY_SIZE(nic_dev);i ++) {
        unregister_netdev(nic_dev[i]);
        free_netdev(nic_dev[i]);
    }
}

module_init(nic_init);
module_exit(nic_exit);
    

二、Makefile

bash 复制代码
#!/bin/bash

ccflags_y += -O2

ifneq ($(KERNELRELEASE),)
obj-m := nic.o
else
KERNELDIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

default:
	$(MAKE) -C $(KERNELDIR) M=$(PWD) modules 
endif

clean:
	rm -rf *.o *.ko *.mod.c

depend .depend dep:
	$(CC)  -M *.c > .depend 

三、编译插入模块

1.make

2.insmod插入模块

四、运行

1.查看网卡 ifconfig -a ,发现新增两个设备eth0和eth1

2.配置虚拟网卡的ip

bash 复制代码
ip link set eth0 down
ip link set eth0 up

ip link set eth1 down
ip link set eth1 up

ip addr add 192.168.1.1/24 dev eth0
ip addr add 192.168.1.2/24 dev eth1

3.再次查看网卡

4.执行ping操作

ping 192.168.1.1

ping 192.168.1.2

5.卸载模块rmmod

rmmod nic.ko

五、心得解读

在使用这两个虚拟网卡执行ping命令(比如ping 192.168.1.2,这个是eth1),首先会进行目标ip匹配,此时eth(192.168.1.1/24)和eth1(192.168.1.2/24)都符合,此时路由决策都匹配,则会使用先启用的接口或者默认主接口(这里是eth0),因此选择eth0发送数据包。

当 eth0 发送 ICMP 请求到 192.168.1.2(即 eth1 的 IP)时,驱动通过 nic_hw_xmit直接调用 eth1 的 nic_rx,模拟 eth1 接收到该数据包。nic_rx 提交的 数据包内容 是 eth0 发送的 ICMP 请求, 内核协议栈识别到该数据包是发送给 eth1 的本地 IP,会触发 ICMP 响应生成 (Echo Reply)。在nic_rx函数的最后使用netif_rx(skb);提交给内核协议栈处理,内核协议栈会生成响应并回发,具体流程如下:
(1)ICMP 请求阶段

bash 复制代码
// eth0 发送请求
nic_start_xmit(eth0) -> nic_hw_xmit(eth0) -> nic_rx(eth1)

// eth1 接收请求
nic_rx(eth1) -> 协议栈生成响应 -> nic_start_xmit(eth1) -> nic_hw_xmit(eth1) -> nic_rx(eth0)
(2)ICMP 响应阶段
bash 复制代码
// eth1 发送响应
nic_start_xmit(eth1) -> nic_hw_xmit(eth1) -> nic_rx(eth0)

// eth0 接收响应
nic_rx(eth0) -> 协议栈处理 -> 用户空间 `ping` 进程收到响应。

0voice · GitHub

相关推荐
且从容.23 分钟前
ubuntu安装flink
linux·ubuntu·flink
爱和冰阔落2 小时前
【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
linux·运维·服务器·网络
程序员沉梦听雨2 小时前
WSL2的安装与使用
linux·wsl
微小的鱼2 小时前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng2 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
严肃小白兔3 小时前
Cloudflare 更新 API Token 管理:从最小权限到自动化凭据安全
网络·安全·自动化
Android系统攻城狮3 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
Linux-lucky15 小时前
45-Linux学习之旅之防火墙与WEB加固
linux·前端·网络·学习·ubuntu
禁默15 小时前
启元 Q1 二次开发实践:Ubuntu 部署与仿真验证,从 PrimeConsole 到 ROS 2 Humble + RViz
linux·运维·ubuntu
skywalk816316 小时前
把 去也网站 从 Ubuntu 搬到 FreeBSD:一次 Service Jail + nginx 的部署实录
linux·运维·nginx·ubuntu·freebsd