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

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

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

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

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

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

    1. int argc = 9;
    2. char *argv[] = {
    3. "send_ether",
    4. "-i",
    5. "enp0s8",
    6. "-t",
    7. "0a:00:27:00:00:00",
    8. "-T",
    9. "0x1024",
    10. "-d",
    11. "Hello, world!",
    12. };

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

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

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

    /_src/c/ethernet/send_ether.c

    1. /**
    2. * struct for storing command line arguments.
    3. **/
    4. struct arguments {
    5. // name of iface through which data is sent
    6. char const *iface;
    7. // destination MAC address
    8. char const *to;
    9. // data type
    10. unsigned short type;
    11. // data to send
    12. char const *data;
    13. };

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

    /_src/c/ethernet/send_ether.c

    1. /**
    2. * opt_handler function for GNU argp.
    3. **/
    4. static error_t opt_handler(int key, char *arg, struct argp_state *state) {
    5. struct arguments *arguments = state->input;
    6. switch(key) {
    7. case 'd':
    8. arguments->data = arg;
    9. break;
    10. case 'i':
    11. arguments->iface = arg;
    12. break;
    13. case 'T':
    14. if (sscanf(arg, "%hx", &arguments->type) != 1) {
    15. return ARGP_ERR_UNKNOWN;
    16. }
    17. break;
    18. case 't':
    19. arguments->to = arg;
    20. break;
    21. default:
    22. return ARGP_ERR_UNKNOWN;
    23. }
    24. return 0;
    25. }

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

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

    /_src/c/ethernet/send_ether.c

    1. /**
    2. * Parse command line arguments given by argc, argv.
    3. *
    4. * Arguments
    5. * argc: the same with main function.
    6. *
    7. * argv: the same with main function.
    8. *
    9. * Returns
    10. * Pointer to struct arguments if success, NULL if error.
    11. **/
    12. static struct arguments const *parse_arguments(int argc, char *argv[]) {
    13. // docs for program and options
    14. static char const doc[] = "send_ether: send data through ethernet frame";
    15. static char const args_doc[] = "";
    16. // command line options
    17. static struct argp_option const options[] = {
    18. // Option -i --iface: name of iface through which data is sent
    19. {"iface", 'i', "IFACE", 0, "name of iface for sending"},
    20. // Option -t --to: destination MAC address
    21. {"to", 't', "TO", 0, "destination mac address"},
    22. // Option -T --type: data type
    23. {"type", 'T', "TYPE", 0, "data type"},
    24. // Option -d --data: data to send, optional since default value is set
    25. {"data", 'd', "DATA", 0, "data to send"},
    26. { 0 }
    27. };
    28. static struct argp const argp = {
    29. options,
    30. opt_handler,
    31. args_doc,
    32. doc,
    33. 0,
    34. 0,
    35. 0,
    36. };
    37. // for storing results
    38. static struct arguments arguments = {
    39. .iface = NULL,
    40. .to = NULL,
    41. //default data type: 0x0900
    42. .type = 0x0900,
    43. // default data, 46 bytes string of 'a'
    44. // since for ethernet frame data is 46 bytes at least
    45. .data = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
    46. };
    47. argp_parse(&argp, argc, argv, 0, 0, &arguments);
    48. return &arguments;
    49. }

    解析函数执行以下步骤:

    • 定义程序文档 doc 以及位置参数文档 args_doc ,用于参数解析失败时输出程序用法提示用户;
    • 定义命令行选项配置,总共 4 个选项,配置的每个字段含义见表格-2;
    • 申明结构体 argp 用于存放先前定义的各种配置;
    • 申明结构体 arguments 用于存放解析结构,并填充默认值;
    • 调用库函数 argp_parse 进行解析,参数请参考 文档;

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

    以太网帧

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

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

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

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

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

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

    以太网宏定义

    1. #define MAX_ETHERNET_DATA_SIZE 1500
    2. #define ETHERNET_HEADER_SIZE 14
    3. #define ETHERNET_DST_ADDR_OFFSET 0
    4. #define ETHERNET_SRC_ADDR_OFFSET 6
    5. #define ETHERNET_TYPE_OFFSET 12
    6. #define ETHERNET_DATA_OFFSET 14
    7. #define MAC_BYTES 6

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

    void mac_ntoa(unsigned char n, char a)

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

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

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

    IS_HEX(c)

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

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

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

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

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

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

    HEX(c)

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

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

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

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

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

    int mac_aton(const char a, unsigned char n)

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

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

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

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

    int mac_aton(const char a, unsigned char n)

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

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

    获取网卡地址

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

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

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

    show_mac 程序源码如下:

    /_src/c/ethernet/show_mac.c

    1. /**
    2. * FileName: show_mac.c
    3. * Author: Chen Yanfei
    4. * @contact: fasionchan@gmail.com
    5. * @version: $Id$
    6. *
    7. * Description:
    8. *
    9. * Changelog:
    10. *
    11. **/
    12. #include <net/if.h>
    13. #include <stdio.h>
    14. #include <string.h>
    15. #include <sys/ioctl.h>
    16. #include <sys/socket.h>
    17. /**
    18. * Convert binary MAC address to readable format.
    19. *
    20. * Arguments
    21. * n: binary format, must be 6 bytes.
    22. *
    23. * a: buffer for readable format, 18 bytes at least(`\0` included).
    24. **/
    25. void mac_ntoa(unsigned char *n, char *a) {
    26. // traverse 6 bytes one by one
    27. for (int i=0; i<6; i++) {
    28. // format string
    29. char *format = ":%02x";
    30. // first byte without leading `:`
    31. if(0 == i) {
    32. format = "%02x";
    33. }
    34. // format current byte
    35. a += sprintf(a, format, n[i]);
    36. }
    37. }
    38. int main(int argc, char *argv[]) {
    39. // create a socket, any type is ok
    40. int s = socket(AF_INET, SOCK_STREAM, 0);
    41. if (-1 == s) {
    42. perror("Fail to create socket");
    43. return 1;
    44. }
    45. // fill iface name to struct ifreq
    46. struct ifreq ifr;
    47. strncpy(ifr.ifr_name, argv[1], 15);
    48. // call ioctl to get hardware address
    49. int ret = ioctl(s, SIOCGIFHWADDR, &ifr);
    50. if (-1 == ret) {
    51. perror("Fail to get mac address");
    52. return 2;
    53. }
    54. // convert to readable format
    55. char mac[18];
    56. mac_ntoa((unsigned char *)ifr.ifr_hwaddr.sa_data, mac);
    57. // output result
    58. printf("IFace: %s\n", ifr.ifr_name);
    59. printf("MAC: %s\n", mac);
    60. return 0;
    61. }

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

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

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

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

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

    如上,主要步骤包括:

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

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

    /_src/c/ethernet/send_ether.c

    1. /**
    2. * Fetch MAC address of given iface.
    3. *
    4. * Arguments
    5. * iface: name of given iface.
    6. *
    7. * mac: buffer for binary MAC address, 6 bytes at least.
    8. *
    9. * s: socket for ioctl, optional.
    10. *
    11. * Returns
    12. * 0 if success, -1 if error.
    13. **/
    14. int fetch_iface_mac(char const *iface, unsigned char *mac, int s) {
    15. // value to return, 0 for success, -1 for error
    16. int value_to_return = -1;
    17. // create socket if needed(s is not given)
    18. bool create_socket = (s < 0);
    19. if (create_socket) {
    20. s = socket(AF_INET, SOCK_DGRAM, 0);
    21. if (-1 == s) {
    22. return value_to_return;
    23. }
    24. }
    25. // fill iface name to struct ifreq
    26. struct ifreq ifr;
    27. strncpy(ifr.ifr_name, iface, 15);
    28. // call ioctl to get hardware address
    29. int ret = ioctl(s, SIOCGIFHWADDR, &ifr);
    30. if (-1 == ret) {
    31. goto cleanup;
    32. }
    33. // copy MAC address to given buffer
    34. memcpy(mac, ifr.ifr_hwaddr.sa_data, MAC_BYTES);
    35. // success, set return value to 0
    36. value_to_return = 0;
    37. cleanup:
    38. // close socket if created here
    39. if (create_socket) {
    40. close(s);
    41. }
    42. return value_to_return;
    43. }

    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. * Bind socket with given iface.
    3. *
    4. * Arguments
    5. * s: given socket.
    6. *
    7. * iface: name of given iface.
    8. *
    9. * Returns
    10. * 0 if success, -1 if error.
    11. **/
    12. int bind_iface(int s, char const *iface) {
    13. // fetch iface index
    14. int if_index = fetch_iface_index(iface, s);
    15. if (-1 == if_index) {
    16. return -1;
    17. }
    18. // fill iface index to struct sockaddr_ll for binding
    19. struct sockaddr_ll sll;
    20. bzero(&sll, sizeof(sll));
    21. sll.sll_family = AF_PACKET;
    22. sll.sll_ifindex = if_index;
    23. sll.sll_pkttype = PACKET_HOST;
    24. // call bind system call to bind socket with iface
    25. int ret = bind(s, (struct sockaddr *)&sll, sizeof(sll));
    26. if (-1 == ret) {
    27. return -1;
    28. }
    29. return 0;
    30. }

    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. * Send data through given iface by ethernet protocol, using raw socket.
    3. *
    4. * Arguments
    5. * iface: name of iface for sending.
    6. *
    7. * to: destination MAC address, in binary format.
    8. *
    9. * type: protocol type.
    10. *
    11. * data: data to send, ends with '\0'.
    12. *
    13. * s: socket for ioctl, optional.
    14. *
    15. * Returns
    16. * 0 if success, -1 if error.
    17. **/
    18. int send_ether(char const *iface, unsigned char const *to, short type,
    19. char const *data, int s) {
    20. // value to return, 0 for success, -1 for error
    21. int value_to_return = -1;
    22. // create socket if needed(s is not given)
    23. bool create_socket = (s < 0);
    24. if (create_socket) {
    25. s = socket(PF_PACKET, SOCK_RAW | SOCK_CLOEXEC, 0);
    26. if (-1 == s) {
    27. return value_to_return;
    28. }
    29. }
    30. // bind socket with iface
    31. int ret = bind_iface(s, iface);
    32. if (-1 == ret) {
    33. goto cleanup;
    34. }
    35. // fetch MAC address of given iface, which is the source address
    36. unsigned char fr[6];
    37. ret = fetch_iface_mac(iface, fr, s);
    38. if (-1 == ret) {
    39. goto cleanup;
    40. }
    41. // construct ethernet frame, which can be 1514 bytes at most
    42. unsigned char frame[1514];
    43. // fill destination MAC address
    44. memcpy(frame + ETHERNET_DST_ADDR_OFFSET, to, MAC_BYTES);
    45. // fill source MAC address
    46. memcpy(frame + ETHERNET_SRC_ADDR_OFFSET, fr, MAC_BYTES);
    47. // fill type
    48. *((short *)(frame + ETHERNET_TYPE_OFFSET)) = htons(type);
    49. // truncate if data is to long
    50. int data_size = strlen(data);
    51. if (data_size > MAX_ETHERNET_DATA_SIZE) {
    52. data_size = MAX_ETHERNET_DATA_SIZE;
    53. }
    54. // fill data
    55. memcpy(frame + ETHERNET_DATA_OFFSET, data, data_size);
    56. int frame_size = ETHERNET_HEADER_SIZE + data_size;
    57. ret = sendto(s, frame, frame_size, 0, NULL, 0);
    58. if (-1 == ret) {
    59. goto cleanup;
    60. }
    61. // set return value to 0 if success
    62. value_to_return = 0;
    63. cleanup:
    64. // close socket if created here
    65. if (create_socket) {
    66. close(s);
    67. }
    68. return value_to_return;
    69. }

    函数主要逻辑如下:

    • 创建套接字,类型为 SOCK_RAW ( 26 行);
    • 调用 bind_iface 函数绑定发送网卡( 33 行);
    • 分配 char 数组用于填充待发送数据帧( 47 行);
    • 根据字段偏移量填充数据帧,数据必要时截断( 48 - 64 行);
    • 计算数据帧总长度( 66 行);
    • 调用 sendto 系统调用发送数据帧( 68 行);整个程序代码有点长,就不在这里贴了,请在 GitHub 上查看: 。

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

    struct ethernet_frame

    1. /**
    2. * struct for an ethernet frame
    3. **/
    4. struct ethernet_frame {
    5. // destination MAC address, 6 bytes
    6. unsigned char dst_addr[6];
    7. // source MAC address, 6 bytes
    8. unsigned char src_addr[6];
    9. // type, in network byte order
    10. unsigned short type;
    11. // data
    12. unsigned char data[MAX_ETHERNET_DATA_SIZE];
    13. };

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

    fill ethernet frame

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

    总结

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

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

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

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

    参考文献