A.4 文件与目录操作
无论是编写一个递归统计目录用量并按最后修改时间排序的运维工具,还是为长期驻留的服务实现一套掉电不丢数据的配置文件持久化模块,在 Windows 体系下,几乎所有涉及底层存储的交互最终都会汇聚到核心 API——CreateFileW。相较于 POSIX 简洁的 open,Windows 将文件交互抽象得更加精细:它引入了强制生效的共享模式(Share Mode),用以裁定并发访问的合法性;创建与打开策略也不再是 O_CREAT、O_EXCL 与 O_TRUNC 的位掩码叠加,而是归纳为五种语义互斥的处置常数。
我们将从 CreateFileW 繁复参数的工程推导展开,深入剖析非缓冲读写、大文件 64 位精准定位与截断的系统行为;揭示 Windows 开发者极为熟悉的「文件正在被另一进程使用」报错背后的双向冲突仲裁机理;随后解析 1601 年纪元 FILETIME 时间戳与属性元数据的提取、基于三件套的目录枚举、长路径与命名空间规范化,并最终落地为基于临时文件与刷新改名的原子保存范式,以及内核强制生效的字节范围锁。
1. CreateFileW 的参数
函数有七个参数,常用的有五个:
HANDLE CreateFileW(LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode,
LPSECURITY_ATTRIBUTES lpSecurityAttributes,
DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes,
HANDLE hTemplateFile);2
3
4
lpFileName 是宽字符串,理由见 A.2。lpSecurityAttributes 决定句柄能否被子进程继承,以及新建文件时用哪个安全描述符(A.3、A.16),不需要时传 NULL。hTemplateFile 用来复制另一个文件的属性和扩展属性,绝大多数程序传 NULL。这也是 CreateFileW 不只用来打开磁盘上的文件:控制台、串口、物理磁盘、管道都用它打开,靠的正是这组参数,A.12 的命名管道客户端就是一例。
1.1 访问权限与共享模式
dwDesiredAccess 说明这次要做什么:GENERIC_READ 读,GENERIC_WRITE 写,两者或起来是读写。还有三个更细的权限常用:DELETE 允许删除或改名这个文件,FILE_READ_ATTRIBUTES 只读属性,FILE_WRITE_ATTRIBUTES 只改属性。只要查属性,传 0 也能拿到一个句柄,ReadFile 在这个句柄上会失败。
dwShareMode 说明「我允许别人以什么方式同时打开它」:传 0 表示独占,别人连读都不行;FILE_SHARE_READ、FILE_SHARE_WRITE、FILE_SHARE_DELETE 可以按位或起来。它约束的是之后打开这个文件的其他句柄,不是自己这个句柄能做什么。这一层的完整规则在第 3 节,那里也是「文件正在使用」这句话的出处。
1.2 创建处置
dwCreationDisposition 取五个值之一,决定文件不存在或已存在时怎么办:
| 取值 | 文件不存在 | 文件已存在 | POSIX 对应 |
|---|---|---|---|
CREATE_NEW | 新建 | 失败,错误 80 | O_CREAT | O_EXCL |
CREATE_ALWAYS | 新建 | 清空为零字节 | O_CREAT | O_TRUNC |
OPEN_EXISTING | 失败,错误 2 | 打开,内容不变 | 不带 O_CREAT |
OPEN_ALWAYS | 新建 | 打开,内容不变 | O_CREAT |
TRUNCATE_EXISTING | 失败,错误 2 | 清空为零字节 | O_TRUNC |
CREATE_ALWAYS 和 TRUNCATE_EXISTING 的区别只在文件不存在时:前者顺手建一个,后者报错。打开目录、串口、控制台这类设备必须用 OPEN_EXISTING,因为设备谈不上「创建」。
disposition.c 把五种取值在两种情形下各试一遍,结果如下:
PS> gcc -std=c17 -Wall -Wextra disposition.c -o disposition.exe
PS> ./disposition.exe
disposition file result
CREATE_NEW missing opened last error 0
CREATE_ALWAYS missing opened last error 0
OPEN_EXISTING missing failed error 2
OPEN_ALWAYS missing opened last error 0
TRUNCATE_EXISTING missing failed error 2
CREATE_NEW existing failed error 80
CREATE_ALWAYS existing opened last error 183
OPEN_EXISTING existing opened last error 0
OPEN_ALWAYS existing opened last error 183
TRUNCATE_EXISTING existing opened last error 02
3
4
5
6
7
8
9
10
11
12
最后错误码一列有两处值得记住。CREATE_ALWAYS 和 OPEN_ALWAYS 在文件已存在时返回成功,却把最后错误设成 183(ERROR_ALREADY_EXISTS);所以判断成败只能看返回值是不是 INVALID_HANDLE_VALUE,不能看 GetLastError。反过来,失败时错误码 2 是「找不到文件」,80 是「文件已存在」,两者都不是共享冲突。错误码在 A.5 统一讲。
POSIX 的 open(path, O_CREAT | O_EXCL | O_WRONLY) 对应哪个创建处置?
1.3 属性与标志
dwFlagsAndAttributes 一个参数兼管两件事:新文件的属性,以及打开方式的标志。属性常用的有 FILE_ATTRIBUTE_NORMAL(没有其他属性时用,必须单独出现)、FILE_ATTRIBUTE_READONLY、FILE_ATTRIBUTE_HIDDEN。标志常用的有下面几个。
FILE_FLAG_DELETE_ON_CLOSE 让文件在最后一个句柄关闭时自动删除,适合临时文件。delete_on_close.c 创建这样一个文件,写一点内容,然后查一次属性、关闭、再查一次:
PS> gcc -std=c17 -Wall -Wextra delete_on_close.c -o delete_on_close.exe
PS> ./delete_on_close.exe
while open: file exists = 1
after close: file exists = 02
若文件已被别的句柄打开,调用要成功,那些句柄必须是用 FILE_SHARE_DELETE 打开的:同一个文件先被以共享模式 0 或 READ|WRITE 打开时,带这个标志的第二次打开都失败、错误 32。完整程序在 code/系统编程/拓展A_Win32系统编程/A_4_文件与目录操作/delete_on_close.c。
FILE_FLAG_WRITE_THROUGH 让写操作不经过中间缓存,直接写到磁盘上。它管的是这个句柄的每次写,不改文件其他部分的缓存状态;要把整个文件缓冲区都刷下去,仍然用第 2 节的 FlushFileBuffers。
FILE_FLAG_BACKUP_SEMANTICS 是打开目录的前提。本机实测:不带这个标志打开 . 得到 INVALID_HANDLE_VALUE、错误 5;带上就成功,得到的句柄能取属性和时间,但 ReadFile 会失败、错误 1。目录句柄的这种用途在 A.16 检查目录的安全描述符时还会出现;只是想知道目录的属性,用第 4 节的 GetFileAttributesExW 更省事,不必先打开它。
FILE_FLAG_OVERLAPPED 把句柄变成异步句柄,读写要带 OVERLAPPED 结构,留到 A.14 讲。
2. 读写、定位与大小
2.1 读写与文件末尾
BOOL ReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nNumberOfBytesToRead,
LPDWORD lpNumberOfBytesRead, LPOVERLAPPED lpOverlapped);
BOOL WriteFile(HANDLE hFile, LPCVOID lpBuffer, DWORD nNumberOfBytesToWrite,
LPDWORD lpNumberOfBytesWritten, LPOVERLAPPED lpOverlapped);2
3
4
第四个参数接收实际读写的字节数,返回值只表示这次调用有没有出错。一次能请求的字节数由 DWORD 决定,上限 4 GiB 减 1;实际一次读多少还受文件系统与缓存影响,处理大文件要分块循环,不能假设一次读满。
同步句柄读到文件末尾时,ReadFile 返回 TRUE,字节数为 0。这一点和 POSIX 的 read 返回 0 含义相同,区别在返回值:Win32 用「成功且读到 0 字节」表示结束,没有单独的错误码。重叠句柄上同样的情形是失败、错误 38(ERROR_HANDLE_EOF),见 A.14。
WriteFile 对磁盘文件通常一次写完请求的字节数,对管道和设备可能只写一部分。工程写法是循环到写完为止,和主线第 2 章 write_all 的思路一样。
2.2 定位、大小与截断
文件位置用 SetFilePointerEx 移动,参数是 LARGE_INTEGER,64 位,不再有 32 位接口把大文件截成两段的问题:
LARGE_INTEGER off;
off.QuadPart = (LONGLONG)index * RECORD_SIZE;
SetFilePointerEx(h, off, NULL, FILE_BEGIN);2
3
第三个参数可以接收移动后的新位置,不需要时传 NULL。第四个参数是基准:FILE_BEGIN 从文件头算,FILE_CURRENT 从当前位置,FILE_END 从文件尾算。文件大小用 GetFileSizeEx 取,结果同样在 LARGE_INTEGER 里。不要用 GetFileSize,它只返回低 32 位,高位要通过一个指针参数带回来,两个值谁配谁很容易写反。
SetEndOfFile 把文件截断到当前文件位置:位置在中间,尾部数据被丢掉;位置超过原来的末尾,文件变长,新增的部分读出来是 0。本机实测,写入 10 字节后定位到 4 再调用,大小为 4;再定位到 8 调用,大小为 8,读回来是 30 31 32 33 00 00 00 00。这是 ftruncate 的对应物,像它一样需要写权限,只读句柄上调用失败、错误 5。
FlushFileBuffers 把系统缓存里的数据写到磁盘,对应 fsync。它要求句柄带 GENERIC_WRITE,只读句柄上调用失败、错误 5。写完重要数据、改名之前必须调用它,否则掉电后可能只剩下空文件,第 7 节会用到。
2.3 定长记录的例子
定长记录文件把每条记录放在固定的偏移上,第 i 条的位置就是 i 乘记录长度,改一条只要定位、读出、写回,几次调用就完成,不必扫描前面的内容,也不必重写后面的记录。records.c 建一个五条记录的文件,改掉第三条的分值:
#define RECORD_SIZE 64
typedef struct {
int id;
char name[48];
double score;
} record;
_Static_assert(sizeof(record) == RECORD_SIZE, "record must be exactly 64 bytes");
static BOOL read_record(HANDLE h, int index, record *rec) {
LARGE_INTEGER off;
DWORD got = 0;
off.QuadPart = (LONGLONG)index * RECORD_SIZE;
if (!SetFilePointerEx(h, off, NULL, FILE_BEGIN)) {
return FALSE;
}
return ReadFile(h, rec, sizeof *rec, &got, NULL) && got == sizeof *rec;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
_Static_assert 把记录长度锁死:结构体因为对齐被编译器悄悄加长时,编译就失败,而不是等到读出来的数据错位才发现。完整文件在 code/系统编程/拓展A_Win32系统编程/A_4_文件与目录操作/records.c。
PS> gcc -std=c17 -Wall -Wextra records.c -o records.exe
PS> ./records.exe
record 3 before: id=3 name=student-03 score=70.0
record 3 updated
1: student-01 60.0
2: student-02 65.0
3: student-03 99.5
4: student-04 75.0
5: student-05 80.0
ReadFile at end: ok=1, 0 bytes
SetEndOfFile at 3 records: ok=1, size 192 bytes2
3
4
5
6
7
8
9
10
11
末尾两行是本页的两个实测点:定位到文件尾再读,返回成功、0 字节;定位到第 3 条记录的末尾再 SetEndOfFile,文件缩成 192 字节。
同样的读写用 CRT 的 fseek/fread 也能做,差别在缓冲。标准库自己攒一块缓冲区,fseek 要同步它,随机访问时反而绕了一圈;Win32 的定位直接落到文件对象的位置上。随机访问密集的程序直接用 SetFilePointerEx。
在 CRT 描述符与句柄之间转换
已经用 fopen 打开的流,可以用 _get_osfhandle(fileno(fp)) 取出底层的 HANDLE,临时交给只接受句柄的 Win32 函数;反过来,_open_osfhandle((intptr_t)h, _O_BINARY) 把一个句柄包装成 CRT 描述符,之后交给 fclose 关闭。转换之后所有权跟着走:_open_osfhandle 得到的描述符关闭时也会关闭那个句柄,不要再调一次 CloseHandle。
2.4 换行由谁转换
Win32 的读写是字节级的,WriteFile 写进去什么,磁盘上就是什么。CRT 才做换行转换:以文本模式 "w" 打开的文件,写到文件或管道时会把每个 \n 换成 \r\n;以 "wb" 打开就不换。
eol.c 用三种方式写同样的四个字节,再读回来打印十六进制:
PS> gcc -std=c17 -Wall -Wextra eol.c -o eol.exe
PS> ./eol.exe
text.txt 61 0D 0A 62 0D 0A (6 bytes)
binary.txt 61 0A 62 0A (4 bytes)
raw.txt 61 0A 62 0A (4 bytes)2
3
要做字节精确的读写,比如自己定义的文件格式、校验和、网络报文,一律用 "rb"/"wb",或者干脆用 Win32 的 ReadFile/WriteFile。用 printf 往重定向的输出里写也一样会转换,这是 A.12 转发管道内容时坚持用 WriteFile 的原因。
同步句柄上 ReadFile 读到文件末尾时返回什么?
3. 共享模式与「文件正在使用」
删不掉一个文件、资源管理器提示「文件正在另一程序中打开」,最常见的原因就在 CreateFileW 的第二个参数:别的进程打开它时,没有允许删除。
共享模式在打开时检查,且是强制的
每次 CreateFileW 打开一个已存在的文件,系统都拿这次请求的访问权限和共享模式,与已有句柄的访问权限和共享模式逐项比对。新请求的访问权限必须落在已有句柄允许的共享范围里,已有句柄已经获得的访问权限也必须落在新请求允许的共享范围里,两个方向都满足才能打开,否则失败并返回 ERROR_SHARING_VIOLATION(32)。
第二个方向容易漏掉:一个只读的进程打开日志文件,如果写日志的进程持有写权限,那么只读进程也必须传 FILE_SHARE_WRITE,否则打不开。它自己不写,但它的共享模式必须允许对方继续写。
share_demo.c 用四种持有方式各试一次:
PS> gcc -std=c17 -Wall -Wextra share_demo.c -o share_demo.exe
PS> ./share_demo.exe
== holder: GENERIC_READ|GENERIC_WRITE, share 0 ==
DeleteFileW: ok=0 err=32
open for read, share 0x3: failed error 32
after the handle is closed:
DeleteFileW: ok=1 err=0
== holder: GENERIC_READ|GENERIC_WRITE, share READ|WRITE ==
DeleteFileW: ok=0 err=32
open for read, share 0x3: ok error 0
== holder: GENERIC_WRITE, share READ|DELETE (a log writer) ==
open for read, share 0x1: failed error 32
open for read, share 0x3: ok error 0
DeleteFileW: ok=1 err=0
== holder: GENERIC_READ|GENERIC_WRITE, share READ|DELETE ==
DeleteFileW: ok=1 err=0
GetFileAttributesW: 0xffffffff
open for read, share 0x3: failed error 2
read through the surviving handle: ok=1, 5 bytes2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
第一段是独占打开:别人的读、写、删除全被挡在门外,句柄一关就恢复正常。第二段允许读写共享,但没允许删除,所以删除仍然失败。
第三段是日志文件的典型情形。写者用 GENERIC_WRITE 加 FILE_SHARE_READ | FILE_SHARE_DELETE 打开,读者只传 FILE_SHARE_READ 时打不开,因为写者拥有写权限而读者的共享模式没允许写;读者改成 FILE_SHARE_READ | FILE_SHARE_WRITE 就能打开。日志轮转时,写者被允许删除,所以 DeleteFileW 成功。
第四段展示允许删除之后的行为。删除成功,文件名立刻从目录里消失:GetFileAttributesW 返回 INVALID_FILE_ATTRIBUTES,再按名字打开得到错误 2。但原来的句柄还能读到完整内容,空间要等最后一个句柄关闭才释放。这与 POSIX 的 unlink 效果一致:名字先消失,打开着的文件照样能用,最后一个引用关闭时文件才真正删除。主线第 3 章讲过这个模型,Windows 只是把它变成要显式申请的权限。
由此得到两条工程写法。日志、缓存这类要允许别人查看和轮转的文件,用 FILE_SHARE_READ | FILE_SHARE_DELETE 打开;确实要独占的文件,共享模式传 0,但要清楚这会让备份工具、杀毒软件、编辑器都打不开它,失败方拿到的是错误 32。调试时看到错误 32,先查是谁打开着它,Process Explorer 的句柄视图和 Sysinternals 的 handle.exe 能直接列出。
DeleteFileW 返回 FALSE、GetLastError() 为 32,最可能的原因是什么?
4. 文件属性与时间
取属性最省事的是 GetFileAttributesExW,一次调用同时拿到属性位、大小和三个时间,不需要打开文件:
WIN32_FILE_ATTRIBUTE_DATA fad;
if (!GetFileAttributesExW(path, GetFileExInfoStandard, &fad)) {
/* 失败:错误 2 表示路径不存在 */
}2
3
4
5
结构里的 dwFileAttributes 是属性位,常用的几位是 FILE_ATTRIBUTE_READONLY(0x1)、HIDDEN(0x2)、SYSTEM(0x4)、DIRECTORY(0x10)、ARCHIVE(0x20)。大小拆成 nFileSizeHigh 和 nFileSizeLow 两个 32 位字段,拼成 64 位才是真实大小。三个时间是 ftCreationTime、ftLastAccessTime、ftLastWriteTime。
FILETIME 是自 1601 年 1 月 1 日 UTC 起的 100 纳秒计数,两个 32 位字段拼成一个 64 位整数。起点选在 1601 年,是因为它正好是公历 400 年闰年周期的开端,按日期做加减时不必单独处理闰年。它与 Unix 时间相差 116444736000000000 个 100 纳秒单位,也就是 11644473600 秒。换算成 Unix 秒:
#define FILETIME_UNIX_EPOCH 116444736000000000ULL
v.LowPart = ft->dwLowDateTime;
v.HighPart = ft->dwHighDateTime;
unix_seconds = (v.QuadPart - FILETIME_UNIX_EPOCH) / 10000000ULL;2
3
4
5
显示给用户时要转成本地时间。NTFS 在磁盘上存的是 UTC,所以先 FileTimeToSystemTime 得到 UTC 的年月日时分秒,再 SystemTimeToTzSpecificLocalTime 按当前时区换算,后者会正确处理夏令时。不要用 FileTimeToLocalFileTime,它按当前是否处于夏令时统一加减,对历史时间会差一小时。
fileinfo.c 打印一个文件的大小、属性位、三个时间与对应的 Unix 秒:
PS> gcc -std=c17 -Wall -Wextra -municode fileinfo.c -o fileinfo.exe
PS> ./fileinfo.exe records.dat
size 192 bytes
attributes 0x00000020 ARCHIVE
created 2026-10-21 04:12:18 unix 1792527138
accessed 2026-10-21 04:13:55 unix 1792527235
written 2026-10-21 04:13:55 unix 17925272352
3
4
5
创建时间与读写时间不同,是因为文件建好之后又被改过。三个时间的含义与 stat 的 st_ctime、st_atime、st_mtime 并不一一对应:st_ctime 在 POSIX 上是 inode 状态改变的时间,Windows 的创建时间是文件诞生的时间。只读属性会挡住写入:本机实测,用 SetFileAttributesW 设成 FILE_ATTRIBUTE_READONLY 后,写打开返回错误 5,DeleteFileW 也返回错误 5;要删掉它,先把属性改回去。
目录也走同一套接口:GetFileAttributesExW 对目录返回带 DIRECTORY 位的属性,三个时间同样有效。
5. 遍历目录
目录没有「一次性列出全部条目」的接口,用一组三件套:FindFirstFileExW 打开一次搜索,FindNextFileW 逐条取,FindClose 收尾。
h = FindFirstFileExW(pattern, FindExInfoBasic, &fd, FindExSearchNameMatch, NULL,
FIND_FIRST_EX_LARGE_FETCH);
if (h == INVALID_HANDLE_VALUE) {
return; /* 目录不存在时错误 3 */
}
do {
/* fd.cFileName、fd.dwFileAttributes、fd.nFileSizeLow/High */
} while (FindNextFileW(h, &fd));
FindClose(h);2
3
4
5
6
7
8
9
模式写成 目录\*,不能以反斜杠结尾,C:\Windows\ 这样的参数直接失败。搜索结果里一定有 . 和 .. 两条,递归时不跳过会原地打转;空目录也会返回这两条。两条信息来自文件系统,FindFirstFileExW 不做排序,出来的顺序与资源管理器按名字排好的顺序不同,需要顺序就自己收集后排序。
FindExInfoBasic 表示不取 8.3 短文件名,FIND_FIRST_EX_LARGE_FETCH 让系统一次向文件系统多取一些条目,目录里文件多时快不少。两者都不改变结果内容。FindNextFileW 在最后一条之后返回 FALSE,错误为 18(ERROR_NO_MORE_FILES),这是循环的正常结束条件,与出错用同一个返回值区分。
duw.c 是一个 mini du:递归统计文件数、目录数与总字节数。核心就是上面那段循环加一层递归,完整文件在 code/系统编程/拓展A_Win32系统编程/A_4_文件与目录操作/duw.c。对一个有五个文件、三个子目录的小工程运行:
PS> gcc -std=c17 -Wall -Wextra -municode duw.c -o duw.exe
PS> ./duw.exe proj
5 files, 3 dirs, 2124 bytesPS> (Get-ChildItem -Recurse -File .\proj | Measure-Object -Property Length -Sum).Sum
2124两个数一致,说明遍历没有漏条目也没有重复计数。proj 的目录结构是:
proj/
├── Makefile
├── README.md
├── docs/
│ └── img/
│ └── logo.bin
└── src/
├── helper.c
└── main.c递归时路径要自己拼,_snwprintf 加 \ 再拼子目录名;长度超过 MAX_PATH 时拼接会被截断,第 6 节的长路径问题在这里先出现一次。目录要被别的线程或进程监视时用 ReadDirectoryChangesW,那是 Windows 上的 inotify,本页不展开。
与 opendir/readdir 对照:那边同样返回 . 和 ..,同样要跳过;readdir 每次返回一个 dirent,目录项类型在 d_type 里,Windows 把文件名、属性、大小、时间一次性放在 WIN32_FIND_DATAW 里,少一次 stat 调用。两边都不保证顺序。
6. 路径
路径有三种开头:盘符加冒号加反斜杠(C:\work)、UNC 名(\\server\share)、单个反斜杠(当前盘根目录)。只有盘符没有反斜杠的 C:tmp.txt 表示「C 盘当前目录下的 tmp.txt」,和 C:\tmp.txt 不是一回事,写程序时几乎总是想要后者。
Win32 的文件函数接受正斜杠,内部会转成反斜杠。本机实测,GetFullPathNameW(L"C:/x/y.txt") 返回 C:\x\y.txt,C:\work\sub\..\a.txt 归一化成 C:\work\a.txt。.. 和 . 也在这一步解开,所以拿到的是一条没有相对成分的绝对路径。这一点在拼接路径、比较路径之前很有用:先把两条路径都过一遍 GetFullPathNameW 再比。
文件名不区分大小写,但系统保留你创建时用的大小写。本机实测,先建 CaseTest.TXT,再用 casetest.txt 能正常打开。要在代码里比较两个文件名,用 CompareStringOrdinal(a, -1, b, -1, TRUE),最后一个参数 TRUE 表示忽略大小写,返回 CSTR_EQUAL(2)即相等。不要用 wcscmp,也不要用 _wcsicmp:前者区分大小写,后者受区域设置影响。
MAX_PATH 是 260,包含结尾的空字符。超过这个长度的路径,普通 API 调用失败、错误 3(ERROR_PATH_NOT_FOUND)。加 \\?\ 前缀可以放宽到约 32767 个字符,但前缀会关掉所有字符串处理:正斜杠不再转换,. 和 .. 不再解释,路径必须是绝对路径。本机实测,一条 281 个字符的路径不加前缀打开失败、错误 3,加上前缀成功;同一路径把反斜杠换成正斜杠再加前缀,打开失败、错误 2。绝对长度的具体数值随当前目录变化,pathdemo.c 把实测的那条打印了出来。
要让程序默认支持长路径,除了注册表里的 LongPathsEnabled 设为 1,程序还必须在清单里声明 longPathAware。本机这个注册表值已经是 1,但用 MinGW 编译出来的程序没有清单,所以前面那次不加前缀的打开仍然失败。清单的嵌入方法见 A.2,那里用 windres 把清单编进资源。
pathdemo.c 把上面几件事跑了一遍:
PS> gcc -std=c17 -Wall -Wextra pathdemo.c -o pathdemo.exe
PS> ./pathdemo.exe
C:\work\sub\..\a.txt -> C:\work\a.txt (len 13)
C:/x/y.txt -> C:\x\y.txt (len 10)
\\server\share\dir\f.txt -> \\server\share\dir\f.txt (len 24)
open casetest.txt while CaseTest.TXT exists: 1
CompareStringOrdinal ignores case: 2 (CSTR_EQUAL is 2)
long path length 281, MAX_PATH is 260 (length depends on the current directory)
open without prefix: 0, error 3
open with prefix: 1, error 02
3
4
5
6
7
8
保留名要避开:CON、PRN、AUX、NUL、COM1 到 COM9、LPT1 到 LPT9 在 Win32 里表示设备,NUL 是空设备,写进去的字节被丢弃。用这些名字创建文件,不同版本的系统表现并不一致,有的把它当设备,有的在子目录里当普通文件,所以干脆不要用。\\?\ 前缀下这些名字才总是普通文件名。
7. 创建、删除、移动与原子替换
7.1 目录与文件的基本操作
建目录用 CreateDirectoryW,删空目录用 RemoveDirectoryW,删文件用 DeleteFileW。几个实测结果值得先知道:
CreateDirectoryW在目录已存在时返回FALSE、错误 183,不是 80;RemoveDirectoryW在目录非空时返回FALSE、错误 145(ERROR_DIR_NOT_EMPTY),要先删除里面的内容;DeleteFileW对不存在的文件返回FALSE、错误 2;对目录返回FALSE、错误 5;CopyFileW的第三个参数bFailIfExists为TRUE时目标已存在会失败、错误 80,为FALSE时直接覆盖;MoveFileExW目标已存在且没带MOVEFILE_REPLACE_EXISTING时失败、错误 183;跨卷移动没带MOVEFILE_COPY_ALLOWED时失败、错误 17(ERROR_NOT_SAME_DEVICE)。
同一卷上,MoveFileExW 的替换就是一次改名:别的进程按名字打开,要么拿到旧文件,要么拿到新文件,不会拿到一个不存在的名字。跨卷时它必须先复制再删除,得显式加上 MOVEFILE_COPY_ALLOWED 才会去做,这时整个操作分成几步,中途失败会留下一个副本。所以替换配置文件的临时文件必须放在目标文件所在的目录里。
7.2 原子地保存配置文件
直接以 CREATE_ALWAYS 覆盖写,文件在写第一个字节之前就已经被清空。写一半崩溃、进程被杀,磁盘上留下的是空文件或者半截内容。这和主线第 3 章 O_TRUNC 的后果一模一样。
正确的顺序是:在目标目录建临时文件,写全部内容,FlushFileBuffers,关闭,再用 MoveFileExW 把临时文件改名成目标文件。
HANDLE h = CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL);
WriteFile(h, data, len, &written, NULL);
/* 这时文件已经被清空;写到一半失败,旧内容就没了 */
CloseHandle(h);2
3
4
5
HANDLE h = CreateFileW(tmp, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL);
WriteFile(h, data, len, &written, NULL);
FlushFileBuffers(h); /* 数据先落盘 */
CloseHandle(h);
MoveFileExW(tmp, path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH);2
3
4
5
6
atomic_save.c 把右边的顺序写成一个函数,先保存一次,再覆盖保存一次,最后用 CREATE_ALWAYS 打开但不写,演示旧内容立刻消失:
PS> gcc -std=c17 -Wall -Wextra atomic_save.c -o atomic_save.exe
PS> ./atomic_save.exe
first save:
app.cfg: 24 bytes
mode = fast
retries = 3
second save replaces the file:
app.cfg: 35 bytes
mode = safe
retries = 5
level = 2
plain overwrite, CREATE_ALWAYS and no write yet:
app.cfg: 0 bytes
files left in the directory:
app.cfg 0 bytes2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
最后一段就是错误写法的后果:只是以 CREATE_ALWAYS 打开、还没写任何字节,文件已经是 0 字节。真实的崩溃发生在写了一半的时候,结果同样是一个不完整的文件。
函数里有两个细节。FlushFileBuffers 必须放在改名之前,否则改名完成、数据还在缓存里时掉电,得到的是一个名字正确、内容为空的新文件。临时文件名带上进程 ID,避免两个实例同时保存时互相覆盖;失败分支里删除临时文件,不在目录里留垃圾。MOVEFILE_WRITE_THROUGH 让函数等到改名真正落到磁盘才返回。
ReplaceFileW 是同一件事的另一种写法,它多保留一些东西:原文件的创建时间、访问控制列表、加密与压缩属性、备用数据流,替换后都还在。本机实测,MoveFileExW 替换后目标的创建时间变成临时文件的创建时间,ReplaceFileW 替换后保持不变。它还有一个实际的好处:目标文件正被别的进程以 FILE_SHARE_DELETE 打开时,MoveFileExW 失败、错误 5,ReplaceFileW 成功;对方只给了 FILE_SHARE_READ 时两者都失败。正在运行的程序的配置文件、被监视的日志,用它更稳。
ReplaceFileW 也要求替换文件与被替换文件在同一卷,第三个参数可以指定一个备份文件名,原子地保留旧内容。
备用数据流、硬链接与符号链接
同一个文件名可以带多个数据流,写作 文件:流名,普通读取只看到主数据流,内容藏在备用数据流(alternate data stream)里。CreateHardLinkW 建硬链接,CreateSymbolicLinkW 建符号链接,后者默认需要管理员权限或开发者模式。这三样本页都不展开,用到时查 Microsoft Learn 的对应页面。
8. 字节范围锁
多个进程要改同一个文件的同一段时,共享模式太粗:它只能让整文件互斥。要只锁住某一段,用 LockFileEx:
OVERLAPPED ov = {0};
/* 从偏移 0 起锁 4 个字节,排他,冲突时立刻返回 */
if (!LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, 0,
4, 0, &ov)) {
/* 错误 33:已有别人锁住这一段 */
}
UnlockFileEx(h, 0, 4, 0, &ov);2
3
4
5
6
7
8
偏移放在 OVERLAPPED 的 Offset 和 OffsetHigh 里,对 64 位文件要两个都填;长度拆成低 32 位和高 32 位两个参数。LOCKFILE_EXCLUSIVE_LOCK 表示排他锁,不加就是共享锁;LOCKFILE_FAIL_IMMEDIATELY 表示拿不到立刻返回,不加则一直等到别人解锁。
Windows 的字节范围锁是强制的
锁由 I/O 管理器在每次读写时检查。别的进程、别的句柄读写落在被锁范围内的字节,调用直接失败并返回 ERROR_LOCK_VIOLATION(33),不会读到旧数据,也不会阻塞;读写范围外的字节不受影响。POSIX 的 fcntl 记录锁是建议性的,只对主动查锁的进程有效,不查锁的 read/write 照常进行。
lock_demo.c 用两个句柄演示:
PS> gcc -std=c17 -Wall -Wextra lock_demo.c -o lock_demo.exe
PS> ./lock_demo.exe
LockFileEx(0-3, exclusive): 1, error 0
while the range is locked:
read 0-3: failed, error 33
read 4-7: ok, 4 bytes
UnlockFileEx(0-3): 1
after unlock:
read 0-3: ok, 4 bytes2
3
4
5
6
7
两个句柄在同一个进程里,第二个句柄读被锁的范围同样失败:锁属于句柄,不属于进程,文档明确写了这一点。换成两个进程结果一样,本机另外写了一个小程序验证,另一个进程读 0 到 3 字节得到错误 33,读 4 到 7 字节正常。持有锁的句柄自己读写被锁范围不受影响。
共享锁之间可以共存,都可以读;排他锁与任何锁冲突。两个进程同时想拿同一段的排他锁,后到的那个拿到错误 33,这正是「同一时刻只有一个写者」的实现方式。锁在句柄关闭或进程结束时由系统释放,但文档建议显式解锁再退出,否则刚结束的进程留下的锁可能还挡住别人一小段时间。
LockFileEx 的等待行为与句柄有关:同步句柄上不给 LOCKFILE_FAIL_IMMEDIATELY 时会阻塞到锁可用,重叠句柄上则返回 ERROR_IO_PENDING,完成时事件有信号。重叠句柄的细节见 A.14。
进程 A 用 LockFileEx 锁住字节 0 到 3,进程 B 用 ReadFile 读这几个字节,结果是什么?
9. 习题
9.1 巩固
编译运行本页的 disposition.c,回答三个问题:
- 输出里
CREATE_ALWAYS existing一行的last error 183说明什么?为什么不能拿GetLastError()判断CreateFileW是否成功? - 把
disposition.c里判断成败的表达式改成if (GetLastError() != 0),重新运行,指出哪些行会给出错误结论。 CREATE_NEW在文件已存在时返回错误 80,OPEN_EXISTING在文件不存在时返回错误 2。如果程序要「文件存在就打开、不存在就新建,但绝不覆盖已有内容」,应该用哪个处置?
写一对程序 holder.exe 与 probe.exe,验证共享模式的检查是双向的。
要求:
holder.exe 共享模式以GENERIC_READ | GENERIC_WRITE创建并打开share.dat,写入一行,然后等 10 秒再退出;共享模式取 0 或READ|WRITE,由命令行参数决定;probe.exe 访问方式 共享模式尝试打开share.dat,打印成功与否和错误码,两种组合:GENERIC_READ+FILE_SHARE_READ,GENERIC_READ+FILE_SHARE_READ|FILE_SHARE_WRITE;- 在 PowerShell 里先后启动两个程序,记录四种组合的结果,画出 2×2 的表格;
- 解释为什么
holder持有写权限时,只读的probe也必须传FILE_SHARE_WRITE。
验收:holder 用共享模式 0 时,probe 的两种组合都失败、错误 32;holder 用 READ|WRITE 时,第一种组合失败、第二种成功。
给 records.c 增加两个函数:
/* 按 id 线性扫描,找到返回下标,找不到返回 -1 */
int find_record(HANDLE h, int id);
/* 在文件末尾追加一条记录 */
BOOL append_record(HANDLE h, const record *rec);2
3
4
要求:
find_record从第 0 条开始逐条read_record,读到文件末尾(ReadFile返回 TRUE 且 0 字节)时返回 -1;append_record先SetFilePointerEx到FILE_END,再写一条;不要用FILE_APPEND_DATA打开句柄,保持现有句柄不变;- 写一个测试流程:建 5 条记录,追加第 6 条,用
GetFileSizeEx确认大小从 320 变成 384,再用find_record查 id 为 3 和 99 的记录,结果分别是 2 和 -1。
把 duw.c 扩展成 dus.exe 目录:除了总数,还按一级子目录分别统计字节数,并按字节数从大到小输出。
要求:
- 对参数目录下的每个子目录递归统计,输出三列:子目录名、文件数、字节数;
- 结果按字节数降序排列,用
qsort排序一个结构体数组; - 直接位于参数目录下的文件单独归为一行
(files); - 各行字节数之和必须等于总计,程序最后打印总计并与
duw.exe的输出对照。
验收:对第 5 节的 proj 目录,src、docs、(files) 三行的字节数之和等于 2124,且与 duw.exe proj 打印的总数一致。
写 finfo.exe 文件...:接受多个文件名,按最后修改时间从新到旧输出一行一个文件,格式为 时间 大小 属性 文件名。
要求:
- 时间用
GetFileAttributesExW取ftLastWriteTime,用CompareFileTime比较,转换成本地时间显示; - 属性列只打印实际置位的位:
R(只读)、H(隐藏)、S(系统)、A(归档)、D(目录); - 用
attrib +r +h 文件给一个文件加上属性和隐藏,再运行程序,确认属性列变化; - 对加过只读属性的文件尝试
CreateFileW写打开和DeleteFileW,记录错误码,解释为什么都是 5。
9.2 编程
写 openlong.exe 路径:把用户给的路径规范化,打印规范化结果与长度;如果超过 MAX_PATH,自动加 \\?\ 前缀重试。
要求:
- 用
GetFullPathNameW规范化,打印结果和wcslen长度; - 长度不超过 260 时直接
CreateFileW打开(OPEN_EXISTING、GENERIC_READ);超过时先不加前缀试一次,失败后加前缀再试,两条路径各打印一次结果; - 对一条超过 260 个字符的路径验收:不加前缀失败、错误 3,加前缀成功;对
C:/x/../y.txt验收:打印出的规范化路径里没有..和正斜杠; - 把文件名换成小写再去打开一个大小写混合的文件,确认仍然成功,并用
CompareStringOrdinal打印比较结果。
改造 atomic_save.c,让保存失败时不仅保住原文件,还能留下一份备份。
要求:
- 替换之前先用
CopyFileW把当前目标复制成目标.bak,复制失败不算保存失败(第一次保存时目标还不存在); - 临时文件写入后调用
FlushFileBuffers,再关闭,再用MoveFileExW替换; - 用另一个进程把目标文件以「不带
FILE_SHARE_DELETE」的共享模式打开,制造替换失败,检查:函数返回失败、原文件内容不变、临时文件已被删除、.bak里有旧内容; - 把第 3 步的打开方式改成带
FILE_SHARE_DELETE,再分别试MoveFileExW和ReplaceFileW,记录哪一个成功、哪一个失败,并说明原因。
验收:第 3 步之后目录里只有原文件和 .bak,没有残留的 .tmp 文件。
用字节范围锁实现「同一时刻只有一个写入者」的协议,两个程序配合。
要求:
lockhold.exe 文件 秒数以GENERIC_READ|GENERIC_WRITE打开文件,排他锁住第 0 到 3 字节,持续指定秒数后退出,退出前不调用UnlockFileEx;locktry.exe 文件用LOCKFILE_FAIL_IMMEDIATELY尝试排他锁同一段,成功就写入一个序号并解锁,失败就打印错误码;- 先启动
lockhold,5 秒内连续运行三次locktry,每次都应失败并打印 33;lockhold退出后再运行,应当成功; - 把
lockhold的锁改成共享锁,再让两个locktry用共享锁同时持有,确认两个共享锁能共存,此时任何排他锁都失败; - 读 4 到 7 字节,确认锁的范围之外不受影响。
9.3 综合与挑战
写一个目录报告工具 dirreport.exe 目录 输出文件,把本页的几块内容合起来。
要求:
- 用
FindFirstFileExW递归遍历目录,统计每个扩展名(没有扩展名归为(none))的文件数与总字节数,按字节数降序排列; - 遍历时用
GetFileAttributesExW取每个文件的修改时间,报告里另起一节列出最近修改的 5 个文件; - 报告先写入同目录下的临时文件,
FlushFileBuffers后用MoveFileExW原子替换成输出文件;运行期间用一个.lock文件上的排他锁保证同时只有一个实例,第二个实例启动时打印提示并返回 1; - 输出文件的编码统一为 UTF-8,路径按第 6 节的方式规范化后再写进报告;
- 验收:对一个至少有 100 个文件的目录运行,报告里各扩展名的字节数之和等于
duw.exe打印的总字节数;同时启动两个实例,第二个立即退出;中途按 Ctrl+C 结束,输出文件仍然是上一次的完整内容。
提示:dirreport 自己创建的那个 .lock 文件要在正常结束时删除,判断「另一个实例在运行」只看锁是否拿到,不看文件是否存在。
10. 参考资料
- Microsoft Learn,CreateFileW:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilew
- Microsoft Learn,Creating and Opening Files:https://learn.microsoft.com/en-us/windows/win32/fileio/creating-and-opening-files
- Microsoft Learn,Naming Files, Paths, and Namespaces:https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file
- Microsoft Learn,Maximum Path Length Limitation:https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation
- Microsoft Learn,File Times:https://learn.microsoft.com/en-us/windows/win32/sysinfo/file-times
- Microsoft Learn,GetFileAttributesExW:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-getfileattributesexw
- Microsoft Learn,FindFirstFileExW:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-findfirstfileexw
- Microsoft Learn,DeleteFileW:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-deletefilew
- Microsoft Learn,MoveFileExW:https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-movefileexw
- Microsoft Learn,ReplaceFileW:https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-replacefilew
- Microsoft Learn,LockFileEx:https://learn.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-lockfileex
- Microsoft Learn,_get_osfhandle:https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/get-osfhandle
- Johnson M. Hart,Windows System Programming,第 4 版,第 2、3 章,Addison-Wesley,ISBN 978-0-321-65774-9
- 本教程,文件描述符与非缓冲 I/O
- 本教程,文件系统:目录与权限