网络字节序

Demon.Lee 2022年10月13日 1,085次浏览

本文代码实践环境:

macOS 12.6 ARM64
Apple clang version 14.0.0 (clang-1400.0.29.201)

如果不是看网络相关的书,我真的不知道网络上传输的字节顺序与在本机上运行时会有可能不同。写到这里,突然有些感慨,“知道自己不知道”确实是一种境界。在网络上有对各种热点事件的解读,但当事人背后往往有很多隐情是不为人知的。大众在局部信息的基础上就下各种判断不仅非常武断,也是对当事人的一种伤害。

邓宁-克鲁格心理效应(来源:网络)

回到正题,后来在阮一峰老师的公众号上看到《字节序探析:大端与小端的比较》这篇文章,让我一下子对字节序有了更深的理解。故笔者想通过这篇文章,对字节序进行一个简单梳理,算是对曾经模糊的记忆做一次刷新。

字节序

学习的套路好像都有些雷同,我们先从概念谈起,即什么是字节序?

维基百科[1]上的定义是这样的:

字节顺序,又称端序尾序(英语:Endianness),在计算机科学领域中,指电脑内存中或在数字通信链路中,组成多字节的字字节的排列顺序。

再看 CSAPP(《深入理解计算机系统(原书第3版)》)中的阐述:

对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及在内存中如何排列这些字节。在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。

由此,我们可以推导以下内容:

  • 单字节对象不存在字节序问题;
  • 多字节对象才可能存在字节序问题;
  • 字节序其实就是某个多字节的数据对象在内存中的排列顺序。

可能还是有些抽象,我们举个例子,占用 4 字节的整数 0x12345678 在内存中是如何存储的?

这里我们假设存储的起始地址为 0x100,那么一般有两种方式:


我们将高位的 1 称为最高有效字节,与此对应,将 8 称为最低有效字节,如上图所示。

某些机器选择在内存中按照从最高有效字节到最低有效字节的顺序存储,我们将其称为大端法(big endian)。与此相反,另外一些机器,选择从最低有效字节到最高有效字节的顺序存储,就被称为小端法(little endian)。

一般来说(也不是绝对),Intel 兼容机都是使用小端序,而 IBM 和 Oracle 的大多数机器则是使用大端序。当然,像现在智能手机上使用的 ARM 处理器是支持双端序的,即可以手动调整配置将其设为大端序,也可以改为小端序。不过现实情况是,像 Android 或 iOS 系统目前只支持小端序,所以一旦处理器与操作系统匹配后,其字节序也就固定了下来。

下图来自《UNIX 环境高级编程(第3版)》,供参考:

OS CPU ARCH Byte Order
FreeBSD 8.0 Intel Pentium little endian
Linux 3.2.0 Intel Core i5 little endian
Mac OS X 10.6.8 Intel Core 2 Duo little endian
Solaris 10 Sun SPARC big endian

关于大端和小端的来源,最早出自于 Jonathan Swift 的《格利佛游记》,说的是某国民众为鸡蛋应该从大头吃起还是小头吃起而争论不休,甚至大打出手。更多内容可以阅读 CSAPP 上的旁注或维基百科上的说明,笔者这里就不赘述了。

好,说了这么多,我们来运行一个示例直观感受一下:

typedef unsigned char* byte_pointer;

void show_bytes(byte_pointer start, size_t len) {
    size_t i;
    for (i = 0; i < len; ++i) {
        printf(" %.2x", start[i]);
    }
    printf("\n");
}

void show_int(int x) {
    show_bytes((byte_pointer) &x, sizeof(int));
}

int main() {
    show_int(0x12345678);
    show_int(0x12345);
    return 0;
}

输出结果:

 78 56 34 12
 45 23 01 00

很显然,笔者当前机器上用的小端序。

到这里,相信读者和笔者一样,有很多疑问,为何要区分大小端?统一用一种不行?另外,这两种表示方式又有哪些区别?

大端序 vs 小端序

关于前面提到的问题,阮一峰老师的文章中都有提到(多年以前他对字节序也做过简单总结[2]),并且给出了示例,笔者这里就直接引用其结论。

1、之所以区分大小端,是因为各自有不同的使用场景。计算机内部处理时,都是低位地址开始的,所以小端序更适合运算,这也是为什么小端序占多。而从前面的图示,我们也可以看到,大端序可读性则更好。

2、大端序的优势

  • 可读性
  • 检查正负号:因为高位是符号位,一次处理就够了
  • ……

3、小端序的优势

  • 检查奇偶性:最低位决定奇偶
  • 比较大小:按低位排序,很快比出大小
  • 乘法:向右进位,左边位不需要动
  • 类型转换:如 32 位整数强转为 16 位,无需移动指针
  • ……

但 CPU 真是按照上面说的逻辑进行处理的吗?在原文博客的留言下[3],笔者看到这样一些评论:

*Donald* 说:

这篇文章可能是不懂计算机原理的人写的。从第二点到第八点都是人类的思维。计算机里面数据在寄存器里面移动,然后在逻辑运算单元里面计算,与字节序没有关系。这些优缺点对于计算机来说都不存在。

*fafa1234* 说:

引用Jade的发言:

那你知道逻辑运算单元里面如何计算吗? 谁给寄存器和逻辑单元发指令吗?
相信评论时的你不知道或者知道一点点皮毛,也讲不清楚明白。

其实他指出的问题是有道理的,博主可能是为了方便大家理解(用十进制来打比方,方便所有人去理解,但我们实际上知道计算机并不 care 数字的进制是什么,它只是用一组 bits 去表达这个数字,所以可以看做是二进制的。)。但是诸如判断奇数偶数,符号这些实际上对计算机是不存在的,cpu 内部固定采用比如说 little-endian。比如判断一个数字是否为奇数或者偶数,只需要判断 x & 1 的结果是不是 0 就可以知道了,是并不存在需要从左或者从右去看这个数字的。
big-endian 通常又称为“网络序”,因为很多数字在拼成数据包的时候,使用的是 big-endian。字节序是一个数字的 bits 在存储层面方面的概念,存在字节序的情况,通常是这个数字或者这个字符的 code point 由多个字节组成,那么在表达这个数字的时候,把这个数字看做一堆 bits,bits 按照它所在的位有一个重要性,越靠“左”(权重越大)的 bit 我们叫做它越重要,那么如果我们把最重要的那些 bits 放在地址值小的字节里,这就叫 big-endian。反之,就叫 little-endian。

由于笔者也不了解 CPU 内部是如何进行数据操作的,所以也无法给出正确的答案,但这不并妨碍我们来理解大小端序的差异。随着学习的不断深入,将来也许能解答这个问题。这里我们暂时不去过度纠结,只要了解到,不同 CPU 体系下,内存中的存储方式有差异即可,正如 CSAPP 中写的那样:

令人吃惊的是,在哪种字节顺序是合适的这个问题上,人们表现得非常情绪化。

……

就像鸡蛋的问题一样,选择何种字节顺序没有技术上的理由,因此争论沦为关于社会政治论题的争论。只要选择了一种规则并且始终如一地坚持,对于哪种字节顺序的选择都是任意的。

在一台计算机上处理数据,无论是大端序还是小端序,都没什么问题。但如果涉及到网络上多台机器呢?

网络字节序

针对网络上的数据交换,为防止出现困惑,便约定了统一的字节序:big endian,这就是网络字节序(Network Byte Order)。

所以,在进行网络传输之前,要将数据转换为统一的大端序格式。以下是 C 标准类库下常用的几个转换函数:

unsigned short htons(unsigned short);

unsigned short ntohs(unsigned short);

unsigned long htonl(unsigned long);

unsigned long ntohl(unsigned long);

我们通过 man htons 可以看到相关概述:


起初看这些函数,笔者也是一头雾水,但明白其中的套路后就简单了:

  • 函数中的 h 代表 host ,即主机字节序,而 n 则代表 network ,即网络字节序;
  • 函数中的 s 代表 short ,即短整型,而 l 自然就是 long
  • htons 的意思就是将短整型的主机字节序转换成网络字节序。

下面笔者扩展一下前面的示例,用程序实践一把:

void test_byte_order_convert() {
    int a = 0x12345678;
    int aa = htonl(a);
    int aaa = ntohl(aa);
    printf(" a:[%x], aa:[%x], aaa:[%x]\n", a, aa, aaa);

    show_int(a);
    show_int(aa);
    show_int(aaa);
}

int main() {
    test_byte_order_convert();

    return 0;
}

输出结果:

 a:[12345678], aa:[78563412], aaa:[12345678]
 78 56 34 12
 12 34 56 78
 78 56 34 12

前面也提到了,一般只在网络传输时需要关注字节序,所以这些函数的使用场景主要集中在网络编程领域。如果读者看过笔者之前写 Socket 相关的文章(比如《Socket 与 TCP 三次握手》),就能看到下面类似的代码:

struct sockaddr_in server_addr;
memset(&server_addr, '\0', sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr(argv[1]);
server_addr.sin_port = htons(atoi(argv[2]));

可以看到,sockaddr_in 结构体(用于描述 Socket 格式地址)中的 IP 和端口分别使用了转换函数:inet_addrhtonshtons 前面已提到,而 inet_addr 也是?我们运行一个小示例:

// typedef unsigned int  __uint32_t;
// typedef __uint32_t    in_addr_t; 

void test_addr_convert() {
    const char *ip = "10.0.1.100";
    in_addr_t addr = inet_addr(ip);
    printf(" %x\n", addr); // ox6401000a
    show_int(addr); // 0a 00 01 64
}

int main() {
    test_addr_convert();

    return 0;
}

从结果(即代码示例中的注释部分)可以看到,inet_addr 将我们日常使用的 IP 字符串(使用 . 分割)转成了 4 字节的整型数字,而且顺序变了,原来低位的 100(10进制) 变成了高位的 64(16进制)。这就是按照大端序返回的结果。

我们继续通过 man inet_addr 来进一步了解:

DESCRIPTION
The routines inet_aton(), inet_addr() and inet_network() interpret character strings representing numbers expressed in the Internet standard ‘.’ notation.

The inet_pton() function converts a presentation format address (that is, printable form as held in a character string) to network format (usually a struct in_addr or some other internal binary representation, in network byte order). It returns 1 if the address was valid for the specified address family, or 0 if the address was not parseable in the specified address family, or -1 if some system error occurred (in which case errno will have been set). This function is presently valid for AF_INET and AF_INET6.


All Internet addresses are returned in network order (bytes ordered from left to right). All network numbers and local address parts are returned as machine byte order integer values.

除了 inet_addr 之外,相关的函数还有很多,注意上面最后一段话:所有互联网地址都按网络顺序返回(字节从左到右排序)。所有网络号码和本地地址部分都以主机字节顺序的整数值返回。

细心的读着可能会发现,这里只针对网络地址进行了转换,那通过 read/write 等函数读写网络数据时怎么没有发现转换的痕迹?

文件编码

回答上面的问题之前,我们先通过一段程序来看看字符写入文件中的编码情况:

void test_byte_io() {
    // "abcdef" --> 0x616263646566
    // "好" --> oxe5a5bd (utf-8)
    const char *s = "abcdef好\n";
    int i = 0x12345678;
    show_bytes((byte_pointer) s, strlen(s));
    show_int(i);

    const char *filename = "./byte-io.txt";
    FILE *stream = fopen(filename, "wb");
    if (NULL == stream) {
        fprintf(stderr, "open file error\n");
        return;
    }

    size_t len = 1;
    size_t write_len = fwrite(s, strlen(s), len, stream);
    if (len != write_len) {
        fprintf(stderr, "write string error\n");
    }
    printf("len(s)==%lu, write_len==%lu\n", strlen(s), write_len);

    write_len = fwrite(&i, sizeof(i), len, stream);
    if (len != write_len) {
        fprintf(stderr, "write int error\n");
    }
    printf("sizeof(i)==%lu, write_len==%lu\n", sizeof(i), write_len);
    fclose(stream);
}

int main() {
    test_byte_io();

    return 0;
}

输出结果为:

 61 62 63 64 65 66 e5 a5 bd 0a
 78 56 34 12
len(s)==10, write_len==1
sizeof(i)==4, write_len==1

使用 MacVim 打开 byte-io.txt:


再使用 16 进制查看(菜单栏点击“工具 - 转换成十六进制”):


从输出结果可以看到:

  • 字符串 abcdef好\n 按照顺序逐个写入,其中汉字 占用三个字节(默认为 UTF-8 编码),三个字节的顺序没有变化;
  • 数字 0x12345678 是按照二进制的方式写入的,文件中的顺序为小端序,也就是内存中顺序。

通过 file 命令可以看到文件的格式:

➜  Downloads > cat byte-io.txt            
abcdef好
xV4%                                                                                                                                  
➜  Downloads > file -I byte-io.txt 
byte-io.txt: application/octet-stream; charset=binary
➜  Downloads >

上面这么看,还不够清晰,我们可以使用文本编辑器,将 abcdef好 分别保存为 UTF-16BE,UTF-16LE 以及 UTF-16 三种格式:

➜  Downloads > file -I UTF-16.txt
UTF-16.txt: text/plain; charset=utf-16le
➜  Downloads > file -I UTF-16BE.txt
UTF-16BE.txt: application/octet-stream; charset=binary
➜  Downloads > file -I UTF-16LE.txt
UTF-16LE.txt: application/octet-stream; charset=binary
➜  Downloads >

UTF-16 默认使用小端序保存,我们使用 16 进制查看三个文件:


597d 字的 Unicode 编码,在不同编码字符集下,其顺序是不同的,在 UTF-16BE 中是 597d ,而在 UTF-16LE 中是 7d59 。而 UTF-16 与 UTF-16LE 的差异在于开头多了一个 FFFE ,这其实是 Unicode 规范定义:每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用 FEFF 表示大端序,FFFE 表示小端序。

好,到这里,我们可能开始有一点感觉了:文本字符的写入是一个个处理的,整体的顺序不会改变,而每个字符本身的顺序依赖具体的编码。如果一个字符被编码后有多个字节,就有可能出现顺序的不同,比如上面示例中的 UTF-16。但使用 UTF-8 编码不会有字节序问题,这又是为什么呢?

关于 ASCII、UNICODE,UTF-8,UTF-16 等不同的编码方案,且听笔者下回分解。

回到开头的问题,网络传输时的数据,需要程序员进行转换吗?想一想就头大,下面是《TCP/IP 网络编程》中给出的答案:

实际上没必要,这个过程是自动的。除了向 sockaddr_in 结构体变量填充数据外,其他情况无需考虑字节序问题。

起初笔者对这句话的理解是,内核层面帮我们把这件事给干了。但事情好像没这么简单,如果发送的消息是一个拥有多种不同类型的结构体,还是需要考虑字节序的:

struct {
    u_int32_t message_length;
    char buf[256];
} message;

像上面的 message 结构体,根据前面的分析,我们已经知道,字符数组无需处理,但 4 字节的整型 message_length 则要考虑字节序。

下面通过一个简单的示例程序来做一个验证(完整代码已上传 GitHub):

// tcp_byte_order_client.c

#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <stdio.h>
#include <arpa/inet.h>
#include <unistd.h>
#include "common/log.h"
#include "tcp_byte_order.h"

void send_data(int socket_fd, int byte_order_flag) {
    struct message msg;
    bzero(&msg, sizeof(msg));
    strcpy(msg.buf, "abcdef好");
    msg.message_length = strlen(msg.buf);
    // 字节序处理
    if (byte_order_flag) {
        msg.message_length = htonl(msg.message_length);
    }
    display_message(&msg);

    unsigned int total_length = strlen(msg.buf) + sizeof(msg.message_length);
    ssize_t n_written = send(socket_fd, &msg, total_length, 0);
    fprintf(stdout, "send into buffer: %ld\n", n_written);
    if (n_written != total_length) {
        error_handling(stderr, "client send message failed");
    }
}

int main(int argc, char **argv) {
    if (argc < 4) {
        error_handling(stderr, "Usage: socket_client <IP> <Port> <ByteOrderFlag>");
    }

    struct sockaddr_in serv_addr;
    bzero(&serv_addr, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(atoi(argv[2]));
    //serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    inet_pton(AF_INET, argv[1], &serv_addr.sin_addr);

    int socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (socket_fd < 0) {
        error_handling(stderr, "create socket failed");
    }

    int connect_rt = connect(socket_fd, (struct sockaddr *) &serv_addr, sizeof(serv_addr));
    if (connect_rt < 0) {
        error_handling(stderr, "connect to server failed");
    }
    send_data(socket_fd, atoi(argv[3]));
    close(socket_fd);

    return 0;
}
// tcp_byte_order_server.c

#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <errno.h>
#include "common/log.h"
#include "tcp_byte_order.h"

size_t readn(int fd, void *buffer, size_t length) {
    char *ptr = buffer;
    size_t count = length;
    ssize_t read_num;

    while (count > 0) {
        read_num = read(fd, ptr, count);
        if (read_num == 0) {
            // End-of-File,表示 socket 关闭
            break;
        } else if (read_num < 0) {
            if (EINTR == errno) {
                // 非阻塞情况,继续 read
                continue;
            }
            error_logging(stderr, "read from socket error");
            return -1;
        }
        count -= read_num;
        ptr += read_num;
    }
    return (length - count);
}

void read_data(int sockfd, int byte_order_flag) {
    struct message msg;
    bzero(&msg, sizeof(msg));

    size_t read_len_expect = sizeof(msg.message_length);
    size_t read_len_actual = readn(sockfd, &msg.message_length, read_len_expect);
    if (read_len_actual != read_len_expect) {
        error_logging(stderr, "read msg.message_length error");
        return;
    }

    // 字节序处理
    if (byte_order_flag) {
        msg.message_length = ntohl(msg.message_length);
    }
    printf("msg.message_length: %d\n", msg.message_length);

    read_len_actual = readn(sockfd, &msg.buf, msg.message_length);
    if (read_len_actual != msg.message_length) {
        error_logging(stderr, "read msg.buf error");
        return;
    }

    display_message(&msg);
}

int main(int argc, char **argv) {
    if (argc < 3) {
        error_handling(stderr, "Usage: socket_server <Port> <ByteOrderFlag>");
    }
    struct sockaddr_in client_addr, server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(atoi(argv[1]));
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_fd < 0) {
        error_handling(stderr, "create socket error");
    }

    if (bind(listen_fd, (struct sockaddr *) &server_addr, sizeof(server_addr)) == -1) {
        error_handling(stderr, "bind error");
    }

    if (listen(listen_fd, 1024) == -1) {
        error_handling(stderr, "listen error");
    }

    socklen_t client_len;
    int clientfd;
    for (int i = 0; i < 10; i++) {
        client_len = sizeof(client_addr);
        clientfd = accept(listen_fd, (struct sockaddr *) &client_addr, &client_len);
        read_data(clientfd, atoi(argv[2]));
        close(clientfd);
    }
    close(listen_fd);

    return 0;
}

这里用的编译器 gcc(macOS 上指代的其实是 clang),默认使用 UTF-8 对代码文件进行编译[4]

➜  build git:(main) > gcc -v
Apple clang version 14.0.0 (clang-1400.0.29.201)
Target: x86_64-apple-darwin21.6.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin
➜  build git:(main) >

场景一: 服务端使用默认的小端序处理,客户端先使用默认的小端序发,再使用大端序发(172.21.110.42 是本机 IP)

# tcp_byte_order_server

➜  bin git:(main) > ./tcp_byte_order_server 12399 0
msg.message_length: 9
msg.buf=[abcdef好], msg.length=[9]
msg.message_length: 150994944
[2022-10-13 17:39:00]: read msg.buf error: (null)
# tcp_byte_order_client

# 小端序发
➜  bin git:(main) > ./tcp_byte_order_client 172.21.110.42 12399 0
msg.buf=[abcdef好], msg.length=[9]
send into buffer: 13
➜  bin git:(main) >
# 大端序发
➜  bin git:(main) > ./tcp_byte_order_client 172.21.110.42 12399 1
msg.buf=[abcdef好], msg.length=[150994944]
send into buffer: 13
➜  bin git:(main) > 

从结果可以看到,由于本机默认就是小端序,所以用小端序发没有问题,用大端序发则会出错,数据需要进行转码。

场景二: 服务端使用大端序接收处理,客户端同上

# tcp_byte_order_server

➜  bin git:(main) > ./tcp_byte_order_server 12399 1
msg.message_length: 150994944
[2022-10-13 18:57:28]: read msg.buf error: (null)
msg.message_length: 9
msg.buf=[abcdef好], msg.length=[9]
# tcp_byte_order_client

# 小端序发
➜  bin git:(main) > ./tcp_byte_order_client 172.21.110.42 12399 0
msg.buf=[abcdef好], msg.length=[9]
send into buffer: 13
➜  bin git:(main) >
# 大端序发
➜  bin git:(main) > ./tcp_byte_order_client 172.21.110.42 12399 1
msg.buf=[abcdef好], msg.length=[150994944]
send into buffer: 13
➜  bin git:(main) > 

客户端输出与场景一没有区别,但服务端则正好相反,这也是符合我们预期的。

由于我们不知道对方机器的字节序,所以不管当前机器是什么字节序,统一按照网络字节序处理才是正解。

细心的读者可能会有下面两个问题:

1)如果本机就是大端序,接收的数据也是大端序,再调用对应的函数转换,不会出错吗?

2)日常开发过程中,比如 http 协议,怎么从来没有关注过字节序问题呢?

其实,上面列举的 4 个常用转换函数(htonl 等)都是宏定义,如果本机是大端序,其宏定义就会设为不处理:

#elif __DARWIN_BYTE_ORDER == __DARWIN_BIG_ENDIAN

#define ntohl(x)        ((__uint32_t)(x))
#define ntohs(x)        ((__uint16_t)(x))
#define htonl(x)        ((__uint32_t)(x))
#define htons(x)        ((__uint16_t)(x))

// ...
// ...

#else   /* __DARWIN_BYTE_ORDER == __DARWIN_LITTLE_ENDIAN */

#include <libkern/_OSByteOrder.h>

#define ntohs(x)        __DARWIN_OSSwapInt16(x)
#define htons(x)        __DARWIN_OSSwapInt16(x)

#define ntohl(x)        __DARWIN_OSSwapInt32(x)
#define htonl(x)        __DARWIN_OSSwapInt32(x)

// ...
// ...

而之所以我们日常开发过程中没有去关注字节序问题,笔者的理解是,这些非功能性需求在应用层协议中被框架处理掉了,也就是参数的序列化与反序列化。

题图:来自 blog.litespeedtech.com


  1. https://zh.wikipedia.org/wiki/字节序 ↩︎

  2. https://www.ruanyifeng.com/blog/2016/11/byte-order.html ↩︎

  3. https://www.ruanyifeng.com/blog/2022/06/endianness-analysis.html ↩︎

  4. 这是另外一个话题了,暂时不表。 ↩︎