以太网编程实践(C语言)
本节的目标是,实现一个命令 send_ether
,用于通过网卡发送以太网数据帧。我们将从最基础的知识开始,一步步朝着目标努力。
send_ether
在前面章节已经用过,并不陌生,基本用法如:表格-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
用于存放解析结果,结构体包含 iface
、 to
、 type
以及 data
总共 4
个字段:
/**
* 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_handler
。Argp 每成功解析出一个命令行选项,将调用该函数进行处理:
/**
* 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
:
/**
* 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;
}
解析函数执行以下步骤:
- 定义程序文档
doc
以及位置参数文档args_doc
,用于参数解析失败时输出程序用法提示用户; - 定义命令行选项配置,总共
4
个选项,配置的每个字段含义见表格-2; - 申明结构体
argp
用于存放先前定义的各种配置; - 申明结构体
arguments
用于存放解析结构,并填充默认值; - 调用库函数
argp_parse
进行解析,参数请参考 Argp 文档;
字段名 | 含义 |
---|---|
name | 选项名,一般为长选项 |
key | 选项键,一般为短选项 |
arg | |
flags | 选项标志位,OPTION_ARG_OPTIONAL表示可选 |
doc | 选项文档(用法描述) |
group | 选项组,这里省略 |
这样,在 main
函数里,只需要调用 parse_arguments
便可获得解析结果。如果,用户给出了错误的选项,程序将输出提示信息并退出。解决方案很完美!
以太网帧
接下来,重温 以太网帧 ,看到下图应该不难回忆:
以太网帧:目的地址、源地址、类型、数据、校验和
从数学的角度,重新审视以太网帧结构:每个字段有固定或不固定的 长度 ,单位为字节。字段开头与帧开头之间的距离称为 偏移量 ,第一个字段偏移量是 0
,后一个字段偏移量是前一个字段偏移量加长度,依次类推。
各字段 长度 以及 偏移量 列举如下:
字段 | 长度(字节) | 偏移量(字节) |
---|---|---|
目的地址 | 6 | 0 |
源地址 | 6 | 6 |
类型 | 2 | 12 |
数据 | 46-1500 | 14 |
在程序编写中,可能会经常用到这些常量。如果每次都直接使用数值,很考验记忆能力,出错是迟早的事情。
在 C
语言中,可以用 宏定义 将这些常量固化下来。定义一次,无限使用:
#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_ntoa
将 MAC
地址由二进制形式转化成可读形式(冒分十六进制),形如 08:00:27:c8:04:83
:
void mac_ntoa(unsigned char n, char a)
/**
* 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
也不是(不是冒号 :
分隔)。
因此,需要先判断一个字符是不是合法的十六进制字符,可以通过一个宏解决:
#define IS_HEX(c) ( \
(c) >= '0' && (c) <= '9' || \
(c) >= 'a' && (c) <= 'f' || \
(c) >= 'A' && (c) <= 'F' \
)
十六进制字符必须在 0
和 9
之间,或者 a
和 f
之间,或者 A
和 F
之间。宏 IS_HEX
就是上述定义的程序语言表达,看似很长很复杂,其实很简单。
那么,两个字节的可读十六进制如何转换成其表示的原始字节呢?以 c8
为例,需要转换成字节 0xc8
,计算方式如下:
0xc8 == 12 * 16 + 8 == (12 << 4) | 8
那么,从字符 c
如何得到数值 12
呢?计算方式如表格-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
来完成十六进制字符到数值的转换,定义如下:
#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)
/**
* 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)
/**
* 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
程序源码如下:
/**
* 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
函数,主体逻辑如下:
- 创建一个套接字,类型不限(
47
-51
行); - 将待查询网卡名填充到
ifreq
结构体(54
-55
行); - 调用
ioctl
系统调用查询网卡物理地址(SIOCGIFHWADDR
),内核将物理地址填充到ifreq
结构体(58
-62
行); - 从
ifreq
结构体取出MAC
地址并转换成可读形式(65
-66
行); - 输出结果(
69
-70
行);
编译
好了,程序编写完成!那么,怎么让程序代码跑起来呢?对于 C
语言,需要先将源代码编译成可执行程序,方可执行。Linux
下,可以使用 gcc 来编译代码:
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
如上,主要步骤包括:
- 进入源码
show_mac.c
所在目录c/ethernet/
(3
行); - 运行 gcc 命令编译程序,
-o
指定生成可执行文件名,(6
行); - 运行程序
show_mac
(9
行);
代码复用
更进一步,可以将代码重构成获取网卡地址的通用函数 fetch_iface_mac
,以便在后续的开发中复用:
/**
* 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)
/**
* 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)
/**
* 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;
}
函数主要逻辑如下:
- 创建套接字,类型为
SOCK_RAW
(26
行); - 调用
bind_iface
函数绑定发送网卡(33
行); - 分配
char
数组用于填充待发送数据帧(47
行); - 根据字段偏移量填充数据帧,数据必要时截断(
48
-64
行); - 计算数据帧总长度(
66
行); - 调用
sendto
系统调用发送数据帧(68
行);整个程序代码有点长,就不在这里贴了,请在GitHub
上查看:c/ethernet/others/send_ether.v1.c 。
数据帧封装
我们可以进一步优化,将 以太网帧 封装成一个结构体:
/**
* 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];
};
这样一来,帧字段与结构体字段一一对应,更加清晰。而且,填充以太网帧不需要手工指定偏移量,只需填写结构体相关字段即可:
// 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
语言为例,演示了以太网编程方法。如果你对其他语言感兴趣,请按需取用:
订阅更新,获取更多学习资料,请关注我们的 微信公众号 :