关于文件的处理,在实际应用尤其是跨平台时会遇到各种各样的问题,主要包括如下几个问题:

  • 非 ASCII 字符的编码问题:UTF-8?(这个问题太大了,因此放在另一篇专门的笔记中)
  • 换行符的问题:LF or CRLF?
  • 制表符问题:tab or space?
  • 二进制数据的大小端问题

本文将主要对这些问题进行简要整理。

换行符问题

不同平台上文本文件的换行符是不一样的,具体来说:

  • windows:CRLF \r\n
  • Linux:LF \n
  • macOS:早期是 CR \r,现在已经改成 LF \n

现在只有 windows 的 CRLF 是个异类了。其实严格来说\r的含义是回车,回到行首;\n的含义是换行,换到下一行。

换行符不一致会带来很多问题:例如 Linux 的文本文件在 windows 上,可能出现无法换行,连续显示一大段的效果;例如 windows 的文本在 Linux 上可能出现大量的^M,这就是\r并没有被理解为换行符的一部分。 原生的一些文本处理程序,可能无法兼容其它系统上的换行符,例如 windows 自带的记事本只支持 CRLF,bash 脚本只支持 LF。 现代的文本编辑器和代码编辑器(比如 VScode,notepad3,typora 等)都支持处理不同的换行符,并且支持一键修改换行符。 在 Git 上也有相应的换行符处理程序。网络传输也可能会自动对换行符进行处理。

对于非 windows 系统,这两者没啥区别,对于 windows 系统,要求使用哪一种方式写入就要以相同的方式读取,否则容易引发错误。 对于 windows 上的 C/C++,最明显的一个影响就是默认使用文本格式和使用二进制格式读写文件的区别:

  • 使用文本格式打开文件,读取文件时会将所有的\r\n转换成\n;写入文件时会将\n转换成\r\n写入。
  • 使用二进制格式,不会对换行符进行任何自作聪明的调整

例如下面的测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <fstream>
#include <iostream>

int main(int argc, char *argv[]) {
std::ofstream outFile("a.txt", std::ios::out | std::ios::binary);
outFile << "hello\nhello\rhello\r\nhello";
outFile.close();

std::ofstream outFile2("b.txt", std::ios::out);
outFile2 << "hello\nhello\rhello\r\nhello";
outFile2.close();

return 0;
}

运行后写入的两个文本分别为

这里使用向下的箭头代表 LF,使用向左的箭头代表 CR,使用向左向下的箭头代表 CRLF。

Git 在 Windows 上也会进行换行符的转换,包括如下的选项:

  • core.autocrlf
    • true:从文件系统提交到 Git 数据库时,将 CRLF 转换为 LF;从 Git 数据库检出时,将 LF 替换为 CRLF
    • input:从文件系统提交到 Git 数据库时,将 CRLF 转换为 LF;从 Git 数据库检出时不做任何处理
    • false:完全不会对文本文件的换行符进行任何处理
  • core.safecrlf:true 开启换行符的检查,包括检查是否会因为换行符转换而导致数据损坏,不允许混用换行符的文本文件提交到 Git 数据库中

制表符问题

这里其实涉及到两个问题:

  • tab 显示几个空格的长度?
  • tab 是否用空格替代?

关于这两个问题,不同情形下的默认行为各异,首先是关于显示宽度的问题:

  • 在早期为了节约屏幕空间通常使用两格的显示宽度
  • 对于某些缩进层级过多的文件也会采用两格的显示宽度
  • 大多数情况下都是 4 格的显示宽度
  • vim 默认的甚至是 8 格的显示宽度

注意显示宽度并不是固定的,而是一个动态的策略:实际显示宽度至少为 1,并且保证 tab 之后的字符一定会处于显示宽度的整数倍的位置,如下图(这里的点代表空格)

鉴于不同的编辑器呈现 tab 的方式各异,为了统一显示效果,很多编辑器会选择使用固定数目的连续空格自动替换掉 tab,虽然按下的是 tab 键,但是存储在文件中的始终是连续空格,这可以保证显示的一致性。但是用空格直接替换 tab 也存在一些问题:某些软件对配置文件的格式解析依赖 tab,最典型的例子是 make 的 Makefile,这种配置文件绝对不能使用空格替换 tab,否则解析错误!

大小端问题

这个问题不是文本文件的,而是二进制数据的问题,但是也放在这里了。

在 int,double 等多字节的数据类型中,存在字节序的问题:例如 int 通常为 4 字节 32 比特,存储一个正整数 1,完整的二进制内容为

1
00000000-00000000-00000000-00000001

这里将四个字节记作\(A_1 A_2 A_3 A_4\),可以发现它们在逻辑上是天然有序的,称最左边的\(A_1\)高序字节(最高权重字节,MSB),最右边的\(A_4\)低序字节(最低权重字节,LSB)。那么问题来了:

  • 如果在内存中存储一个 int 类型的数据,地址为 p,那么 p 指向的是\(A_1\)还是\(A_4\)?p+1 指向\(A_2\)或者\(A_3\)吗?读取也是一样的问题。
  • 如果程序通过某个字节流,先后获取了代表一个 int 类型的四个字节\(B_1 B_2 B_3 B_4\),那么到底是\(A_1=B_1\),还是\(A_1 = B_4\)

数据的读取和写入在内存中都是以字节为整体,从低地址到高地址进行的,数据的地址就是存储范围中最低的地址。多字节数据在内存中的存储存在两种方案:

  • 小端序:在内存中低地址存储低序字节,高地址存储高序字节,对于这里 int 类型的数据 1,变量地址为 p,那么 p 指向\(A_4\)(具体为 00000001),后面的 p+1,p+2,p+3 指向的字节都是 0,依次对应\(A_3,A_2,A_1\)
  • 大端序:在内存中低地址存储高序字节,高地址存储低序字节,对于这里 int 类型的数据 1,变量地址为 p,那么 p 指向\(A_1\),后面的 p+1,p+2 依次指向\(A_2,A_3\),它们实际都是 0,p+3 指向字节\(A_4\)(具体为 00000001)。

这里有一段检查大小端序,以及检查 int 的真实内存数据的示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>

int main(int argc, char *argv[]) {
if (int b=1;(*(char *)&b) == 1)
printf("Little Endian\n");
else
printf("Big Endian\n");

int a = 1 << 9;
printf("a=%d, [%d %d %d %d]\n",
a, (*((char *)&a+0)),(*((char *)&a+1)),(*((char *)&a+2)),(*((char *)&a+3)));

return 0;
}

这里 a=512 的二进制形式应当为

1
00000000-00000000-00000010-00000000

在 windows 上程序输出a=512, [0 2 0 0],并且显示为小端序。

再关注一下文件字节流的读写,首先将 int 数据 512 以二进制形式写入文件中,直接十六进制打开文件,内容为

1
00 02 00 00

与内存中的顺序一致,接下来二进制逐个字节读取,可以发现文件中的顺序和内存中的顺序一致,也是小端序。由此推断,本地的标准输入输出流也是类似的处理。

测试代码如下

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
#include <fstream>
#include <iostream>

int main() {
int data = 512;

FILE *file = fopen("binary_data.bin", "wb");
if (file == NULL) {
perror("file open error");
return 1;
}

size_t bytesWritten = fwrite(&data, sizeof(int), 1, file);
if (bytesWritten != 1) { perror("file write error"); }
fclose(file);

FILE *file2 = fopen("binary_data.bin", "rb");
if (file2 == NULL) {
perror("file2 open error");
return 1;
}

unsigned char byte;
size_t bytesRead;
while ((bytesRead = fread(&byte, 1, 1, file2)) == 1) {
printf("read: %02x\n", byte);
}
if (ferror(file2)) { perror("file2 read error"); }
fclose(file);

return 0;
}

在常见的 x64 和 arm 机器都是采用小端序,但是在某些情况下可能会默认大端序:

  • Java 默认采用大端序,这是为了跨平台一致性,以及便于网络传输数据;
  • cpp,python 等通常不会指定字节序,采用哪种字节序取决于机器自身;
  • 网络传输中,信息是以字节流的形式传递,字节传输的先后顺序直接对应着它们在内存缓冲区中的先后顺序。通常采用大端序,因此在传输多字节数据类型时,可能需要进行大小端的转换,这里的转换都是具体针对某个数据类型的。如果确认双方机器都是小端序,也可以不进行字节序转换,不做任何转换也不会有错误。(socket 层面不会进行大小端的自动处理,这需要用户层自行负责)

可以手撸一个 4 字节数据的大小端转换函数,这里的数据类型都是无符号的,采用 unsigned int32 作为输入输出

1
2
3
4
5
6
7
8
// 颠倒一下内存中4个字节的内容
// 将大端序整数转换为小端序,或者将小端序整数转换为大端序
uint32_t demo(uint32_t value) {
return ((value & 0xFF) << 24) |
((value & 0xFF00) << 8) |
((value & 0xFF0000) >> 8) |
((value & 0xFF000000) >> 24);
}

解决办法

基于我的现实需求,针对这几个问题采用如下的解决办法:

  • 编码:尽可能使用 UTF-8 编码,但是有的 windows 本地文件也容许 GBK 编码,不允许其它编码
  • tab:尽可能使用 4 格空格代替制表符,但是有的情况比如 makefile 仍然使用 tab(否则 make 语法错误)
  • 换行符:尽可能使用 LF,除了 bat,pwsh 脚本,以及 VS 项目配置文件保持使用 CRLF,在 git 配置文件中设置core.autocrlf=inputcore.safecrlf=true