以太网编程实践(C语言)

本节的目标是,实现一个命令 send_ether ,用于通过网卡发送以太网数据帧。 我们将从最基础的知识开始,一步步朝着目标努力。

send_ether 在前面章节已经用过,并不陌生,基本用法如:表格-1。

表格-1 命令行选项
选项 含义
-i –iface 发送网卡名
-t –to 目的MAC地址
-T –type 类型
-d –data 待发送数据

下面是一个命令行执行实例:

$ send_ether -i enp0s8 -t 0a:00:27:00:00:00 -T 0x1024 -d "Hello, world!"

处理命令行参数

我们要解决的第一问题是,如何获取命令行选项。 在 C 语言中,命令行参数通过 main 函数参数 argc 以及 argv 传递:

int main(int argc, char *argv[]);

以上述命令为例,程序 main 函数获得的参数等价于:

int argc = 9;

char *argv[] = {
    "send_ether",
    "-i",
    "enp0s8",
    "-t",
    "0a:00:27:00:00:00",
    "-T",
    "0x1024",
    "-d",
    "Hello, world!",
};

这时,你可能要开始对 argv 进行解析,各种判断 -i-t 啦。 当然了,如果是学习或者编程练习,这样做是可以的,编程的诀窍就是勤练习嘛。

但更推荐的方式是,站在巨人的肩膀上——使用 GNU 提供的 Argp 。 下面以解析 send_ether 参数为例,介绍 Argp 的用法。

首先,定义一个结构体 arguments 用于存放解析结果, 结构体包含 ifacetotype 以及 data 总共 4 个字段:

/_src/c/ethernet/send_ether.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * struct for storing command line arguments.
 **/
struct arguments {
    // name of iface through which data is sent
    char const *iface;

    // destination MAC address
    char const *to;

    // data type
    unsigned short type;

    // data to send
    char const *data;
};

接着,实现一个选项处理函数 opt_handlerArgp 每成功解析出一个命令行选项,将调用该函数进行处理:

/_src/c/ethernet/send_ether.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
 * opt_handler function for GNU argp.
 **/
static error_t opt_handler(int key, char *arg, struct argp_state *state) {
    struct arguments *arguments = state->input;

    switch(key) {
        case 'd':
            arguments->data = arg;
            break;

        case 'i':
            arguments->iface = arg;
            break;

        case 'T':
            if (sscanf(arg, "%hx", &arguments->type) != 1) {
                return ARGP_ERR_UNKNOWN;
            }
            break;

        case 't':
            arguments->to = arg;
            break;

        default:
            return ARGP_ERR_UNKNOWN;
    }

    return 0;
}

其中,参数 key 是命令行选项配置键,一般为短选项值; 参数 arg 是选项参数值(如果有); 参数 state 是解析上下文,从中可以取到存放解析结果的结构体 arguments 。 处理函数逻辑非常简单,根据解析到选项,将参数值存放到 arguments 结构体。

最后,实现一个解析函数 parse_arguments ,接收参数 argc 以及 argv ,返回解析结果: arguments

/_src/c/ethernet/send_ether.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/**
 * Parse command line arguments given by argc, argv.
 *
 *  Arguments
 *      argc: the same with main function.
 *
 *      argv: the same with main function.
 *
 *  Returns
 *      Pointer to struct arguments if success, NULL if error.
 **/
static struct arguments const *parse_arguments(int argc, char *argv[]) {
    // docs for program and options
    static char const doc[] = "send_ether: send data through ethernet frame";
    static char const args_doc[] = "";

    // command line options
    static struct argp_option const options[] = {
        // Option -i --iface: name of iface through which data is sent
        {"iface", 'i', "IFACE", 0, "name of iface for sending"},

        // Option -t --to: destination MAC address
        {"to", 't', "TO", 0, "destination mac address"},

        // Option -T --type: data type
        {"type", 'T', "TYPE", 0, "data type"},

        // Option -d --data: data to send, optional since default value is set
        {"data", 'd', "DATA", 0, "data to send"},

        { 0 }
    };

    static struct argp const argp = {
        options,
        opt_handler,
        args_doc,
        doc,
        0,
        0,
        0,
    };

    // for storing results
    static struct arguments arguments = {
        .iface = NULL,
        .to = NULL,
        //default data type: 0x0900
        .type = 0x0900,
        // default data, 46 bytes string of 'a'
        // since for ethernet frame data is 46 bytes at least
        .data = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    };

    argp_parse(&argp, argc, argv, 0, 0, &arguments);

    return &arguments;
}

解析函数执行以下步骤:

  1. 定义程序文档 doc 以及位置参数文档 args_doc ,用于参数解析失败时输出程序用法提示用户;
  2. 定义命令行选项配置,总共 4 个选项,配置的每个字段含义见表格-2;
  3. 申明结构体 argp 用于存放先前定义的各种配置;
  4. 申明结构体 arguments 用于存放解析结构,并填充默认值;
  5. 调用库函数 argp_parse 进行解析,参数请参考 Argp 文档;
表格-2 选项配置字段含义
字段名 含义
name 选项名,一般为长选项
key 选项键,一般为短选项
arg  
flags 选项标志位,OPTION_ARG_OPTIONAL表示可选
doc 选项文档(用法描述)
group 选项组,这里省略

这样,在 main 函数里,只需要调用 parse_arguments 便可获得解析结果。 如果,用户给出了错误的选项,程序将输出提示信息并退出。 解决方案很完美!

以太网帧

接下来,重温 以太网帧 ,看到下图应该不难回忆:

../_images/97c13f044de260baf0ed8051091dd251.png

以太网帧:目的地址、源地址、类型、数据、校验和

从数学的角度,重新审视以太网帧结构: 每个字段有固定或不固定的 长度 ,单位为字节。 字段开头与帧开头之间的距离称为 偏移量 ,第一个字段偏移量是 0 , 后一个字段偏移量是前一个字段偏移量加长度,依次类推。

各字段 长度 以及 偏移量 列举如下:

表格-3 以太网帧字段长度及偏移量
字段 长度(字节) 偏移量(字节)
目的地址 6 0
源地址 6 6
类型 2 12
数据 46-1500 14

在程序编写中,可能会经常用到这些常量。 如果每次都直接使用数值,很考验记忆能力,出错是迟早的事情。

C 语言中,可以用 宏定义 将这些常量固化下来。 定义一次,无限使用:

以太网宏定义
1
2
3
4
5
6
7
8
9
#define MAX_ETHERNET_DATA_SIZE 1500

#define ETHERNET_HEADER_SIZE 14
#define ETHERNET_DST_ADDR_OFFSET 0
#define ETHERNET_SRC_ADDR_OFFSET 6
#define ETHERNET_TYPE_OFFSET 12
#define ETHERNET_DATA_OFFSET 14

#define MAC_BYTES 6

转换MAC地址

mac_ntoa

函数 mac_ntoaMAC 地址由二进制形式转化成可读形式(冒分十六进制), 形如 08:00:27:c8:04:83

void mac_ntoa(unsigned char *n, char *a)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 *  Convert binary MAC address to readable format.
 *
 *  Arguments
 *      n: binary format, must be 6 bytes.
 *
 *      a: buffer for readable format, 18 bytes at least(`\0` included).
 **/
void mac_ntoa(unsigned char *n, char *a) {
    // traverse 6 bytes one by one
    for (int i=0; i<6; i++) {
        // format string
        char *format = ":%02x";

        // first byte without leading `:`
        if(0 == i) {
            format = "%02x";
        }

        // format current byte
        a += sprintf(a, format, n[i]);
    }
}

参数 n 为二进制形式,长度为 6 字节;参数 a 为存放可读形式的缓冲区,长度至少为 18 字节(包含末尾 \0 字节)。

mac_ntoa 函数体,逐一遍历 MAC 地址 6 个字节,调用 C 库函数 sprintf 将字节十六进制输出到缓冲区。 注意到,除了首字节,需要额外输出前缀冒号 :

mac_aton

可读形式转化为二进制形式稍微有点复杂,因为需要做合法性检查。 08:00:27:c8:04:83 是一个合法的 MAC 地址, 而 08:00:27:c8:04:8g 就不是( g 超出十六进制范围), 08-00-27-c8-04-83 也不是(不是冒号 : 分隔)。

因此,需要先判断一个字符是不是合法的十六进制字符,可以通过一个宏解决:

IS_HEX(c)
1
2
3
4
5
#define IS_HEX(c) ( \
    (c) >= '0' && (c) <= '9' || \
    (c) >= 'a' && (c) <= 'f' || \
    (c) >= 'A' && (c) <= 'F' \
)

十六进制字符必须在 09 之间,或者 af 之间,或者 AF 之间。 宏 IS_HEX 就是上述定义的程序语言表达,看似很长很复杂,其实很简单。

那么,两个字节的可读十六进制如何转换成其表示的原始字节呢? 以 c8 为例,需要转换成字节 0xc8 ,计算方式如下:

0xc8 == 12 * 16 + 8 == (12 << 4) | 8

那么,从字符 c 如何得到数值 12 呢?计算方式如表格-4(有所省略):

表格-4 十六进制字符数值计算方式
字符 数值 计算方式
‘0’ 0 ‘0’ - ‘0’
‘1’ 1 ‘1’ - ‘0’
‘A’ 10 ‘A’ - ‘A’ + 10
‘B’ 11 ‘B’ - ‘A’ + 10
‘a’ 10 ‘a’ - ‘a’ + 10
‘b’ 11 ‘b’ - ‘a’ + 10

现在,可以通过一个宏 HEX 来完成十六进制字符到数值的转换,定义如下:

HEX(c)
1
2
3
4
5
#define HEX(c) ( \
    ((c) >= 'a') ? ((c) - 'a' + 10) : ( \
        ((c) >= 'A') ? ((c) - 'A' + 10) : ((c) - '0') \
    ) \
)

需要注意,需要先判断是否是小写字符,大写字母次之,数字最后,因为三者在 ASCII 表就是这个顺序。 有了宏 HEX 之后,转换不费吹灰之力:

(HEX(high_byte) << 4) | HEX(low_byte)

注意到,这里使用位运算代替乘法以及加法,因为位运算更高效。

做了这么多准备,终于可以操刀 mac_aton 函数了:

int mac_aton(const char *a, unsigned char *n)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
 *  Convert readable MAC address to binary format.
 *
 *  Arguments
 *      a: buffer for readable format, like "08:00:27:c8:04:83".
 *
 *      n: buffer for binary format, 6 bytes at least.
 *
 *  Returns
 *      0 if success, -1 or -2 if error.
 **/
int mac_aton(const char *a, unsigned char *n) {
    for (int i=0; i<6; i++) {
        // skip the leading ':'
        if (i > 0) {
            // unexpected char, expect ':'
            if (':' != *a) {
                return -1;
            }

            a++;
        }

        // unexpected char, expect 0-9 a-f A-f
        if (!IS_HEX(a[0]) || !IS_HEX(a[1])) {
            return -2;
        }

        *n = ((HEX(a[0]) << 4) | HEX(a[1]));

        // move to next place
        a += 2;
        n++;
    }

    return 0;
}

参数 a 是可读形式,形如 08:00:27:c8:04:83 ,至少 18 字节(末尾 \0 ); 参数 n 是用于存储二进制形式的缓冲区,需要 6 字节。

函数体执行 6 次循环,每次处理一个字节。 第一个字节之后,需要检查冒号 : 并跳过。 转换前,先检查高低两个字节是否都是合法十六进制。 转换时,调用刚刚讨论的转换算法,并移动缓冲区。

当然了,用通过 C 库函数,一行代码就可以完成转换过程:

int mac_aton(const char *a, unsigned char *n)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
/**
 *  Convert readable MAC address to binary format.
 *
 *  Arguments
 *      a: buffer for readable format, like "08:00:27:c8:04:83".
 *
 *      n: buffer for binary format, 6 bytes at least.
 *
 *  Returns
 *      0 if success, -1 if error.
 **/
int mac_aton(const char *a, unsigned char *n) {
    int matches = sscanf(a, "%hhx:%hhx:%hhx:%hhx:%hhx:%hhx", n, n+1, n+2,
                         n+3, n+4, n+5);

    return (6 == matches ? 0 : -1);
}

弄清来龙去脉之后,使用库函数是不错的:①开发效率更高;②代码更健壮。 mac_ntoa 函数也可以用一行代码完成,留作读者练习。

获取网卡地址

发送以太网帧,我们需要 目的地址源地址类型 以及 数据目的地址 以及 数据 分别由命令行参数 -t 以及 -d 指定。 那么, 源地址 从哪来呢?

别急, -i 参数不是指定发送网卡名吗?——发送网卡物理地址就是 源地址 ! 现在的问题是,如何获取网卡物理地址?

Linux 下可以通过 ioctl 系统调用获取网络设备信息,request 类型是 SIOCGIFHWADDR 。 下面,写一个程序 show_mac ,演示查询网卡物理地址的方法。 show_mac 需要接收一个参数,以指定待查询网卡名:

$ show_mac enp0s8
IFace: enp0s8
MAC: 08:00:27:c8:04:83

show_mac 程序源码如下:

/_src/c/ethernet/show_mac.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
/**
 * FileName:   show_mac.c
 * Author:     Chen Yanfei
 * @contact:   fasionchan@gmail.com
 * @version:   $Id$
 *
 * Description:
 *
 * Changelog:
 *
 **/

#include <net/if.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/socket.h>


/**
 *  Convert binary MAC address to readable format.
 *
 *  Arguments
 *      n: binary format, must be 6 bytes.
 *
 *      a: buffer for readable format, 18 bytes at least(`\0` included).
 **/
void mac_ntoa(unsigned char *n, char *a) {
    // traverse 6 bytes one by one
    for (int i=0; i<6; i++) {
        // format string
        char *format = ":%02x";

        // first byte without leading `:`
        if(0 == i) {
            format = "%02x";
        }

        // format current byte
        a += sprintf(a, format, n[i]);
    }
}


int main(int argc, char *argv[]) {
    // create a socket, any type is ok
    int s = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == s) {
        perror("Fail to create socket");
        return 1;
    }

    // fill iface name to struct ifreq
    struct ifreq ifr;
    strncpy(ifr.ifr_name, argv[1], 15);

    // call ioctl to get hardware address
    int ret = ioctl(s, SIOCGIFHWADDR, &ifr);
    if (-1 == ret) {
        perror("Fail to get mac address");
        return 2;
    }

    // convert to readable format
    char mac[18];
    mac_ntoa((unsigned char *)ifr.ifr_hwaddr.sa_data, mac);

    // output result
    printf("IFace: %s\n", ifr.ifr_name);
    printf("MAC: %s\n", mac);

    return 0;
}

程序先定义函数 mac_ntoa 用于将 MAC 地址从二进制形式转换成可读形式,浅析网卡地址一节介绍过,不再赘述。

接着是程序入口 main 函数,主体逻辑如下:

  1. 创建一个套接字,类型不限( 47 - 51 行);
  2. 将待查询网卡名填充到 ifreq 结构体( 54 - 55 行);
  3. 调用 ioctl 系统调用查询网卡物理地址( SIOCGIFHWADDR ),内核将物理地址填充到 ifreq 结构体( 58 - 62 行);
  4. ifreq 结构体取出 MAC 地址并转换成可读形式( 65 - 66 行);
  5. 输出结果( 69 - 70 行);

编译

好了,程序编写完成!那么,怎么让程序代码跑起来呢? 对于 C 语言,需要先将源代码编译成可执行程序,方可执行。 Linux 下,可以使用 gcc 来编译代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
fasion@ubuntu:~/lnp$ ls
_build  c  docs  python  README.md
fasion@ubuntu:~/lnp$ cd c/ethernet/
fasion@ubuntu:~/lnp/c/ethernet$ ls
send_ether.c  show_mac.c
fasion@ubuntu:~/lnp/c/ethernet$ gcc -o show_mac show_mac.c
fasion@ubuntu:~/lnp/c/ethernet$ ls
send_ether.c  show_mac  show_mac.c
fasion@ubuntu:~/lnp/c/ethernet$ ./show_mac enp0s8
IFace: enp0s8
MAC: 08:00:27:c8:04:83

如上,主要步骤包括:

  1. 进入源码 show_mac.c 所在目录 c/ethernet/ ( 3 行);
  2. 运行 gcc 命令编译程序, -o 指定生成可执行文件名,( 6 行);
  3. 运行程序 show_mac ( 9 行);

代码复用

更进一步,可以将代码重构成获取网卡地址的通用函数 fetch_iface_mac ,以便在后续的开发中复用:

/_src/c/ethernet/send_ether.c
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/**
 *  Fetch MAC address of given iface.
 *
 *  Arguments
 *      iface: name of given iface.
 *
 *      mac: buffer for binary MAC address, 6 bytes at least.
 *
 *      s: socket for ioctl, optional.
 *
 *  Returns
 *      0 if success, -1 if error.
 **/
int fetch_iface_mac(char const *iface, unsigned char *mac, int s) {
    // value to return, 0 for success, -1 for error
    int value_to_return = -1;

    // create socket if needed(s is not given)
    bool create_socket = (s < 0);
    if (create_socket) {
        s = socket(AF_INET, SOCK_DGRAM, 0);
        if (-1 == s) {
            return value_to_return;
        }
    }

    // fill iface name to struct ifreq
    struct ifreq ifr;
    strncpy(ifr.ifr_name, iface, 15);

    // call ioctl to get hardware address
    int ret = ioctl(s, SIOCGIFHWADDR, &ifr);
    if (-1 == ret) {
        goto cleanup;
    }

    // copy MAC address to given buffer
    memcpy(mac, ifr.ifr_hwaddr.sa_data, MAC_BYTES);

    // success, set return value to 0
    value_to_return = 0;

cleanup:
    // close socket if created here
    if (create_socket) {
        close(s);
    }

    return value_to_return;
}

fetch_iface_mac 函数总共有 3 个参数:

  • iface :指定待查询网卡名;
  • mac :用于存放 MAC 地址的缓冲区,至少 6 字节;
  • s :套接字,可以复用已有实例,避免创建开销;

注解

如果没有现成套接字可用,可以给 s 参数传特殊值 -1 。 函数将创建临时套接字,用完销毁。 这个套路在其他函数封装中也会用到,后续不再赘述。

接下来,看看 fetch_iface_mac 函数体部分,逻辑与 show_main 程序 main 函数类似。 注意到,在函数开头,需要视情况创建临时套接字。 在函数结尾处,需要对临时套接字进行回收。 套接字创建后,后续系统调用如果失败,函数需要提前返回,千万别忘了回收临时套接字! 函数 fetch_iface_mac 中,使用 goto ( 34 行)将程序逻辑跳转到资源回收处,这个套路在 C 语言中也算经典。

好了, fetch_iface_mac 函数开发大功告成!在接下来的开发中,我们将看到 代码复用 的强大威力!

发送以太网帧

Linux 下,发送以太网帧,需要通过原始套接字。 创建一个类型为 SOCK_RAW 的套接字,与发送网卡进行绑定,便可发送数据了。

先来看看套接字如何与发送网卡绑定:

int bind_iface(int s, char const *iface)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/**
 * Bind socket with given iface.
 *
 *  Arguments
 *      s: given socket.
 *
 *      iface: name of given iface.
 *
 *  Returns
 *      0 if success, -1 if error.
 **/
int bind_iface(int s, char const *iface) {
    // fetch iface index
    int if_index = fetch_iface_index(iface, s);
    if (-1 == if_index) {
        return -1;
    }

    // fill iface index to struct sockaddr_ll for binding
    struct sockaddr_ll sll;
    bzero(&sll, sizeof(sll));
    sll.sll_family = AF_PACKET;
    sll.sll_ifindex = if_index;
    sll.sll_pkttype = PACKET_HOST;

    // call bind system call to bind socket with iface
    int ret = bind(s, (struct sockaddr *)&sll, sizeof(sll));
    if (-1 == ret) {
        return -1;
    }

    return 0;
}

bind_iface 函数接收两个参数: s 是待绑定套接字, iface 是发送网卡名。

通过 bind 系统调用将套接字与发送网卡绑定,但不能直接用网卡名,需要先获取网卡序号( ifindex )。 获取网卡序号套路与 获取网卡地址 类似,这里不再赘述。

最后,再来看看 send_ether 函数:

int send_ether(char const *iface, unsigned char const *to, short type, char const *data, int s)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
 *  Send data through given iface by ethernet protocol, using raw socket.
 *
 *  Arguments
 *      iface: name of iface for sending.
 *
 *      to: destination MAC address, in binary format.
 *
 *      type: protocol type.
 *
 *      data: data to send, ends with '\0'.
 *
 *      s: socket for ioctl, optional.
 *
 *  Returns
 *      0 if success, -1 if error.
 **/
int send_ether(char const *iface, unsigned char const *to, short type,
        char const *data, int s) {
    // value to return, 0 for success, -1 for error
    int value_to_return = -1;

    // create socket if needed(s is not given)
    bool create_socket = (s < 0);
    if (create_socket) {
        s = socket(PF_PACKET, SOCK_RAW | SOCK_CLOEXEC, 0);
        if (-1 == s) {
            return value_to_return;
        }
    }

    // bind socket with iface
    int ret = bind_iface(s, iface);
    if (-1 == ret) {
        goto cleanup;
    }

    // fetch MAC address of given iface, which is the source address
    unsigned char fr[6];
    ret = fetch_iface_mac(iface, fr, s);
    if (-1 == ret) {
        goto cleanup;
    }

    // construct ethernet frame, which can be 1514 bytes at most
    unsigned char frame[1514];

    // fill destination MAC address
    memcpy(frame + ETHERNET_DST_ADDR_OFFSET, to, MAC_BYTES);

    // fill source MAC address
    memcpy(frame + ETHERNET_SRC_ADDR_OFFSET, fr, MAC_BYTES);

    // fill type
    *((short *)(frame + ETHERNET_TYPE_OFFSET)) = htons(type);

    // truncate if data is to long
    int data_size = strlen(data);
    if (data_size > MAX_ETHERNET_DATA_SIZE) {
        data_size = MAX_ETHERNET_DATA_SIZE;
    }

    // fill data
    memcpy(frame + ETHERNET_DATA_OFFSET, data, data_size);

    int frame_size = ETHERNET_HEADER_SIZE + data_size;

    ret = sendto(s, frame, frame_size, 0, NULL, 0);
    if (-1 == ret) {
        goto cleanup;
    }

    // set return value to 0 if success
    value_to_return = 0;

cleanup:
    // close socket if created here
    if (create_socket) {
        close(s);
    }

    return value_to_return;
}

函数主要逻辑如下:

  1. 创建套接字,类型为 SOCK_RAW ( 26 行);
  2. 调用 bind_iface 函数绑定发送网卡( 33 行);
  3. 分配 char 数组用于填充待发送数据帧( 47 行);
  4. 根据字段偏移量填充数据帧,数据必要时截断( 48 - 64 行);
  5. 计算数据帧总长度( 66 行);
  6. 调用 sendto 系统调用发送数据帧( 68 行);

整个程序代码有点长,就不在这里贴了,请在 GitHub 上查看: c/ethernet/others/send_ether.v1.c

数据帧封装

我们可以进一步优化,将 以太网帧 封装成一个结构体:

struct ethernet_frame
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/**
 * struct for an ethernet frame
 **/
struct ethernet_frame {
    // destination MAC address, 6 bytes
    unsigned char dst_addr[6];

    // source MAC address, 6 bytes
    unsigned char src_addr[6];

    // type, in network byte order
    unsigned short type;

    // data
    unsigned char data[MAX_ETHERNET_DATA_SIZE];
};

这样一来,帧字段与结构体字段一一对应,更加清晰。 而且,填充以太网帧不需要手工指定偏移量,只需填写结构体相关字段即可:

fill ethernet frame
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
    // construct ethernet frame, which can be 1514 bytes at most
    struct ethernet_frame frame;

    // fill destination MAC address
    memcpy(frame.dst_addr, to, MAC_BYTES);

    // fill source MAC address
    memcpy(frame.src_addr, fr, MAC_BYTES);

    // fill type
    frame.type = htons(type);

    // truncate if data is to long
    int data_size = strlen(data);
    if (data_size > MAX_ETHERNET_DATA_SIZE) {
        data_size = MAX_ETHERNET_DATA_SIZE;
    }

    // fill data
    memcpy(frame.data, data, data_size);

同样,全量代码可以在 GitHub 上查看: c/ethernet/send_ether.c

总结

本节,我们从 处理命令行参数 开始, 重温 以太网帧 , 学习如何 转换MAC地址 以及如何 获取网卡地址 , 一步步实现终极目标: 发送以太网帧

此外,在 编译 小节,我们第一次编译并执行 C 语言程序。 在 代码复用 小节,我们将零散的代码逻辑封装成可复用的通用函数,并涉猎一些 C 语言经典设计方式。 由于篇幅有限,讲解点到即止,但也足以作为一个不错的起点。

下一步

本节以 C 语言为例,演示了以太网编程方法。 如果你对其他语言感兴趣,请按需取用:

订阅更新,获取更多学习资料,请关注我们的 微信公众号

../_images/wechat-mp-qrcode.png

小菜学编程