最近后台有位读者问了一个问题:很久没写 C 语言了,想用一个周末重新找找手感,但身边所有人都在用 AI 生代码,还有必要自己手写一个小工具吗?
这个问题很真实。我的建议很直接:有必要,而且要写就写 Unix 命令的 clone,首选就是 wc。
为什么是 wc?因为它在命令行里看起来只是一个“统计单词数”的小命令,但一旦你想把它的行为完整复刻出来,就会遇到参数解析、文件读取、流式处理、字符边界、多文件汇总这些真实的系统编程问题。它足够小,一个文件能装下;又足够深,能把你丢掉的 C 手感一次性找回来。
这篇文章不讲 AI 怎么生成代码,也不劝你彻底抛弃 AI。我只做一件事:完整走一遍“手写 Unix wc clone”的思路和代码。读完你会理解 wc 是怎么统计行数、单词数、字节数和最长行的,并拿到一份可以自己编译运行的完整示例。
1. 为什么在 AI 时代还要手写一个 wc 克隆
先给判断:AI 可以替你写出一个“看起来能跑”的 wc,但它替不了你建立 C 语言的手感。
这里说的“手感”,不是玄学,而是几个非常具体的能力:
- 拿到一个需求,能不能想到用状态机而不是一堆 if 去处理字符边界;
- 看 fgetc 返回值时,知不知道必须用 int 接收,而不是 char;
- 写完文件读取逻辑,会不会下意识地检查每个 fopen 的返回值;
- 面对多文件输入,能不能设计出清晰的结构体来聚合统计结果。
这些能力,靠看别人代码是看不会的,靠 AI 生成更是看不出来。只有当你亲手把代码敲出来、编译、跑出错误结果、再回头调试,才会真正理解每一行背后的原因。
wc 还是极好的练习素材。它不像写一个 HTTP server 那样需要引入大量框架概念,也不像刷算法题那样脱离真实系统。它本身就是 Unix 工具链的一员,你写完以后可以直接拿系统自带的 wc 做对比验证。这种“写完立刻有标准答案”的反馈,是学习阶段最珍贵的资源。
这篇文章的读者,我假设是下面三类人:
- 学过 C 但是工作后长期写业务代码,想重新捡起系统编程的人。
- 正在教 C 语言、需要给学生设计课后项目的人。
- 想理解 Unix 命令内部原理,而不只是会用命令行的开发者。
如果你属于其中一类,这篇文章适合你。
2. wc 命令的功能拆解:它到底统计了什么
在动手写代码之前,必须先搞清楚 wc 命令的行为。很多人以为 wc 只是“统计单词数”,其实它默认输出三列:行数、单词数、字节数。
GNU wc 的常用参数如下:
| 参数 | 含义 | 示例输出 |
|---|---|---|
| 默认 | 输出行数、单词数、字节数 | 1 3 18 file.txt |
| -l | 只统计行数 | 1 file.txt |
| -w | 只统计单词数 | 3 file.txt |
| -c | 只统计字节数 | 18 file.txt |
| -m | 统计字符数,多字节编码下与 -c 不同 | 12 file.txt |
| -L | 输出最长行的长度 | 10 file.txt |
先看一个最简单的例子:
echo "hello world" | wc输出是:
1 2 12为什么是1 2 12?
1:因为 echo 在输出末尾加了一个换行符,所以只有 1 行。2:因为 "hello" 和 "world" 之间有一个空格,被分成两个单词。12:因为 "hello" 5 个字节,空格 1 个字节,"world" 5 个字节,换行符 1 个字节,合计 12 字节。
这里最关键的一个概念是:wc 里的“单词”和我们自然语言里的“单词”不一样。
在 wc 的实现中,单词被定义为“由空白字符分隔的连续非空白字符序列”。注意,是“空白字符”而不是“空格”。换行符\n、制表符\t、回车符\r、空格 等都属于空白字符。
这意味着:
"hello,world"是一个单词,因为中间没有空白字符;"hello, world"是两个单词,因为逗号后有一个空格;- 行首缩进、行尾换行都不会产生额外的单词。
很多第一次写 wc clone 的人,会在单词统计上出错,原因是他们用“遇到空格就 word++”的思路去统计。这种思路在连续多个空格、Tab 缩进、行首空格的情况下都会报告错误。正确的做法是维护一个状态标志,这个我们马上讲。
3. 环境准备与项目规划
我们的目标是写一个最小可用的 wc clone,不追求完整复刻 GNU wc 的所有细节,但核心行为要对得上。
3.1 运行环境
推荐环境:
- Linux 或 macOS 终端。
- Windows 下建议使用 WSL,因为 Windows 自带的 CMD 和 PowerShell 对 Unix 文本处理支持不完整,体验会打折扣。
- 编译器使用 gcc 或 clang 均可。版本不用刻意追求最新,只要能支持 C99 标准就行。
确认编译器可用:
gcc --version clang --version如果输出了一大段版本信息,说明环境没有问题。
3.2 项目文件结构
我们把这个项目命名为mywc,文件结构如下:
mywc/ ├── Makefile ├── wc.h ├── wc.c └── test.txtwc.h:头文件,放结构体定义和函数声明。wc.c:主程序,放实现逻辑。Makefile:构建脚本,用于编译和测试。test.txt:测试文件,准备几行文本用于验证。
为什么要单独拆一个头文件?因为我们要定义一个结构体Counts来存放统计结果。在 C 项目里,结构体定义放在头文件,源文件 include 之后就可以使用,这是最基本的工程规范,也方便以后扩展其他功能。
4. 核心实现思路:状态机与流式统计
这是整篇文章最核心的部分。理解了这一节,代码写起来只是体力活。
4.1 按字节读取,而不是一次性读入内存
GNU wc 选择的是流式处理:一个字节一个字节地读,每读到一个字节就更新统计状态。这种做法的好处是:
- 内存占用固定,不管文件是 1KB 还是 1GB;
- 天然支持从管道读取数据,因为管道本身就是一个流;
- 实现简单,不需要考虑内存分配失败的问题。
C 语言里最直接的按字节读取函数是fgetc:
int ch; while ((ch = fgetc(fp)) != EOF) { // 处理 ch }注意这里ch的类型必须是int,不能是char。原因是fgetc返回int,一方面是为了能返回EOF(通常是一个负数,比如 -1),另一方面是要能容纳所有可能的字节值。如果声明成char,在某些平台char是无符号类型,EOF会被当成一个普通字节,循环就永远不会结束。
这是一个很经典的 C 语言陷阱,初学者几乎都会踩一次。
4.2 行数统计
行数统计最简单:每当读到一个\n,行数加 1。
需要注意文件最后一行的情况。如果一个文件的内容是:
hello world最后一个world后面没有换行符。按照 POSIX 的语义,行的定义是“以换行符结尾的字符串”,所以这个文件的真实行数仍然是 1(只有hello\n这一行算作一行,world不算,因为后面没有换行符)。
GNU wc 确实是这样统计的:行数等于换行符的个数,而不是“最后有多少行文本”。我们的 clone 就采用同样的规则。
4.3 单词数统计:状态机
单词数的统计需要引入一个布尔变量in_word。它表示“当前是否正处在一个单词内部”。
基本规则是:
- 初始状态:
in_word = 0,表示当前不在单词内部。 - 每读到一个字节:
- 如果它是空白字符,将
in_word置为 0; - 如果它不是空白字符,且当前
in_word == 0,说明我们刚刚从一个空白边界进入了一个新单词,单词数加 1,同时将in_word置为 1。
- 如果它是空白字符,将
这个逻辑用伪代码表示就是:
in_word = 0 循环读取 ch: 如果 ch 是空白字符: in_word = 0 否则: 如果 in_word == 0: words++ in_word = 1为什么不能用“遇到空格就加一”?
看这个输入:
hello world如果按“遇到空格就加一”来统计,那么遇到连续 4 个空格,就会加 4 次,结果变成 5 个单词,这显然是错的。
而状态机只在“从空白切换非空白”的时刻计数,连续空格不会产生重复计数,行首空格、行尾空格也都能正确处理。
新手容易在这里犯的错误是:把in_word = 1放在了words++外面,导致同一个单词被多次计数。写代码时一定要把“进入单词”这个动作一次性完成。
4.4 字节数统计
字节数最直接:每读到一个字节,bytes++。
这里有一个容易混淆的概念:字节数(-c)和字符数(-m)。
在纯 ASCII 文本里,两者相等。但如果在 UTF-8 编码下写一个中文字符“你”,它占 3 个字节,但只算 1 个字符。所以wc -c和wc -m对同一份中文文本会输出不同的数字。
我们的第一版 clone 只实现-c字节数统计,不实现-m。原因是正确处理多字节字符需要用到locale和宽字符函数,复杂度会明显上升。把-m作为延伸练习,留给想深入的同学。
4.5 最长行统计
统计最长行,需要维护两个变量:
current_line_len:当前行的长度;max_line_len:到目前为止最长行的长度。
每读到一个字节:
- 如果不是
\n,current_line_len++; - 如果是
\n,比较current_line_len和max_line_len,更新最大值,然后把current_line_len重置为 0。
容易漏掉的情况是文件末尾没有换行符。此时循环结束前,需要再比较一次current_line_len和max_line_len,否则最后一行如果没有换行结尾,它的长度就不会被统计进去。
5. 完整代码实现:wc.h / wc.c / Makefile
讲完核心思路,下面给出完整代码。
5.1 头文件 wc.h
// 文件路径:mywc/wc.h #ifndef MYWC_H #define MYWC_H typedef struct { long lines; long words; long bytes; long max_line_len; } Counts; #endif // MYWC_H这里用#ifndef防止头文件被重复包含。这是 C 头文件的标准写法,虽然不是必须的,但值得养成习惯。
5.2 主程序 wc.c
// 文件路径:mywc/wc.c #include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> #include "wc.h" static int opt_lines = 0; static int opt_words = 0; static int opt_bytes = 0; static int opt_maxline = 0; static long total_lines = 0; static long total_words = 0; static long total_bytes = 0; static long total_max = 0; static void print_counts(const char *name, const Counts *c) { if (opt_lines) printf("%8ld ", c->lines); if (opt_words) printf("%8ld ", c->words); if (opt_bytes) printf("%8ld ", c->bytes); if (opt_maxline) printf("%8ld ", c->max_line_len); if (name != NULL) printf("%s", name); printf("\n"); } static void count_stream(FILE *fp, Counts *c) { int ch; int in_word = 0; long current_line_len = 0; memset(c, 0, sizeof(*c)); while ((ch = fgetc(fp)) != EOF) { c->bytes++; if (ch == '\n') { c->lines++; if (current_line_len > c->max_line_len) { c->max_line_len = current_line_len; } current_line_len = 0; in_word = 0; continue; } current_line_len++; if (isspace(ch)) { in_word = 0; } else if (!in_word) { in_word = 1; c->words++; } } if (current_line_len > c->max_line_len) { c->max_line_len = current_line_len; } } static int count_file(const char *path, Counts *c) { FILE *fp; if (strcmp(path, "-") == 0) { fp = stdin; } else { fp = fopen(path, "rb"); if (fp == NULL) { perror(path); return -1; } } count_stream(fp, c); if (fp != stdin) { fclose(fp); } return 0; } static void usage(const char *prog) { fprintf(stderr, "Usage: %s [-lwcL] [file...]\n", prog); exit(EXIT_FAILURE); } int main(int argc, char **argv) { int i, j; int show_all = 1; for (i = 1; i < argc && argv[i][0] == '-'; i++) { const char *arg = argv[i]; if (strcmp(arg, "--") == 0) { i++; break; } if (strcmp(arg, "-") == 0) { break; } for (j = 1; arg[j]; j++) { switch (arg[j]) { case 'l': opt_lines = 1; show_all = 0; break; case 'w': opt_words = 1; show_all = 0; break; case 'c': opt_bytes = 1; show_all = 0; break; case 'L': opt_maxline = 1; show_all = 0; break; default: usage(argv[0]); } } } if (show_all) { opt_lines = 1; opt_words = 1; opt_bytes = 1; } Counts c; int file_count = 0; if (i == argc) { count_stream(stdin, &c); print_counts(NULL, &c); } else { for (; i < argc; i++) { if (count_file(argv[i], &c) == 0) { print_counts(argv[i], &c); total_lines += c.lines; total_words += c.words; total_bytes += c.bytes; if (c.max_line_len > total_max) { total_max = c.max_line_len; } file_count++; } } if (file_count > 1) { Counts total; total.lines = total_lines; total.words = total_words; total.bytes = total_bytes; total.max_line_len = total_max; print_counts("total", &total); } } return 0; }5.3 Makefile
# 文件路径:mywc/Makefile CC = gcc CFLAGS = -Wall -Wextra -std=c99 -g TARGET = mywc all: $(TARGET) $(TARGET): wc.c wc.h $(CC) $(CFLAGS) -o $@ wc.c clean: rm -f $(TARGET) test: $(TARGET) echo "hello world hello" | ./mywc注意 Makefile 中的缩进必须是 Tab,不能用空格代替,这是新手最容易卡住的地方。
5.4 代码关键逻辑解释
参数解析部分
show_all是一个很微妙的变量。它的作用是:当用户没有指定任何选项时,默认输出行数、单词数、字节数;但只要用户指定了任意选项,就不再输出默认的三列,而是只输出用户指定的列。
例如:
./mywc -l file.txt输出的只有行数那一列,而不是行数、单词数、字节数三个全有。
-lw这种组合选项也是能处理的,因为内层循环遍历了参数字符串的每一个字符。
--用于结束选项解析,后面的内容即使以-开头也当成文件名。这是 Unix 命令的通用约定。
统计函数部分
count_stream是核心。我特意把换行处理放在了空白判断之前,并且使用continue跳过后续逻辑。原因是换行符本身也属于空白字符,如果不先处理换行,就会出现这样的问题:统计当前行长度时,把\n也算进了这一行的长度里,导致-L的值总是多 1。
在单词统计上,我把in_word = 0放在换行处理里也重复了一次。其实走到isspace判断时,\n也会被判定为空白,所以in_word同样会被清零。保留这个重复是为了让逻辑更直观:读到换行,行数加 1,当前行结束,单词边界重置。
文件读取部分
fopen第二个参数用了"rb",这是二进制模式。在 Linux 上,"r"和"rb"没有区别;但在 Windows 上,文本模式会把\r\n自动转换为\n,导致统计到的字节数比文件实际字节数少。既然我们想做的是一个统计工具,就应该保持原样读取文件内容,所以用二进制模式更稳妥。
文件名为-时,按惯例表示标准输入。这个设计让工具可以直接参与管道操作,比如:
cat file.txt | ./mywc -多文件汇总
如果用户传入了多个文件,file_count > 1时会在最后输出一个total行,把各文件的行数、单词数、字节数分别相加,最长行取最大值。
这个行为基本对齐 GNU wc。要注意的是,我的实现是边读边打印,如果一个文件打开失败,错误信息会先打印出来,但之前成功统计的文件结果仍然会输出。这符合直觉,也和系统 wc 的行为一致。
6. 运行结果与验证
代码写完,最关键的是验证。没有验证的程序只能算“能编译”,不能算“能工作”。
6.1 编译
make clean make如果没有任何警告,说明代码通过了最基本的编译检查。-Wall -Wextra开了以后如果有明显问题,编译器会提示。
6.2 准备测试文件
创建test.txt:
hello world this is a test C programming这个文件共 3 行,每行 2 个单词,所以单词总数是 6。注意:这里的“单词”是按空白分隔的,所以test和后面的换行不影响数量。
手动计算字节数:
- 第一行
hello world11 个字符加 1 个换行,共 12 字节。 - 第二行
this is a test14 个字符加 1 个换行,共 15 字节。 - 第三行
C programming14 个字符,结尾没有换行,共 14 字节。
合计 41 字节。
6.3 运行对比
./mywc test.txt wc test.txt预期输出:
3 6 41 test.txt 3 6 41 test.txt两行只要数字一致,就说明基础统计逻辑是对的。
再测试-L:
./mywc -L test.txt wc -L test.txtthis is a test的长度是 14,所以预期输出是14 test.txt。
测试管道输入:
echo "hello world hello" | ./mywc echo "hello world hello" | wc预期输出都是:
1 3 186.4 批量对比脚本
更好的方式是写一个循环,对目录下所有文本文件同时跑mywc和系统wc,再用diff比较。
for f in *.txt; do ./mywc "$f" > /tmp/mywc.out wc "$f" > /tmp/wc.out if diff -q /tmp/mywc.out /tmp/wc.out > /dev/null; then echo "$f OK" else echo "$f MISMATCH" diff /tmp/mywc.out /tmp/wc.out fi done如果输出全是OK,恭喜你,第一版 clone 通过了基础验证。
如果某个文件出现MISMATCH,不要急着怀疑系统 wc,大概率是你的状态机在处理某个特殊字符时出了问题。这时可以加一个测试文件,专门包含连续空格、Tab、行首缩进、无换行结尾等情况。
7. 常见问题与排查思路
在写这个项目的过程中,有几个问题非常容易遇到。我整理成表格,方便排查。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 单词数明显偏大 | 把“遇到空格”直接理解成“单词加一” | 在循环里打印每个字节的 ASCII 值和in_word状态 | 用状态机,只在从空白切换到非空白时计数 |
| 文件不打不开但程序没报错 | 没有检查fopen返回值 | 在fopen后加一步判空 | 返回NULL时用perror打印错误信息 |
| 字节数在 Windows 下比实际少 | 使用文本模式打开文件,\r\n被转换 | 确认fopen模式 | 改为二进制模式"rb" |
编译期警告array subscript has type char | 把isspace的参数直接写成char类型 | 查看警告输出的具体行 | 用int类型接收fgetc返回值 |
-L结果总是比预期多 1 | 把换行符也算进了当前行长度 | 观察current_line_len的递增逻辑 | 遇到\n时先更新最长行并重置长度,再进入后续判断 |
参数-lw不能被识别 | 只处理了单个选项,没处理组合选项 | 打印解析到的每个字符 | 内层循环遍历参数字符串的每个字符 |
多个文件时total结果不对 | 没有用全局变量累计各文件的统计值 | 检查total_lines等变量的累加位置 | 在多文件分支里用全局变量累计 |
| 程序读超大文件时内存占用高 | 用fread把整个文件读入内存 | 检查代码里是否有大数组或malloc | 改用流式逐字节读取 |
其中“单词数明显偏大”是最常见的问题。
举个例子,如果输入是"hello world",中间有 4 个空格。如果你写的是:
if (ch == ' ') { words++; }那你会得到 4 个单词,这明显是错的。正确输出是 2。
另一种常见错误是把统计逻辑写成:
if (!isspace(ch)) { words++; }意思是“只要不是空白就加一”,这会把hello这个单词拆成 5 次计数,输出 6 而不是 2。
正确的思路一定要回到状态机:单词数不是“非空白字符的个数”,而是“进入非空白状态的次数”。
8. 手写 C 项目的工程建议
代码能跑只是第一步。作为一个想在工程上进步的开发者,还可以从下面几个方向打磨这个项目。
8.1 保持代码风格一致
C 语言没有强制缩进规范,但你应该选一种风格并坚持。比如:
- 函数名用
snake_case; - 结构体类型名首字母大写;
- 全局变量用
g_前缀或者用static限制在文件内部; - 缩进统一为 4 个空格,不要混用 Tab 和空格。
代码是给人读的,编译器无所谓,但下一个维护代码的人(很可能就是三个月后的你)会感谢你。
8.2 不要省略错误处理
很多 C 语言教程为了展示核心逻辑,会忽略fopen返回值。但实际项目中,文件打不开是常态。出现这种情况时,至少应该用perror打印错误信息,并让程序返回非零退出码。
一个好的改进是:即使某个文件打开失败,也要继续处理后续文件,而不是整个程序立即退出。GNU wc 就是这么做的。
8.3 让 Makefile 支持测试
在上面代码中,我加了一个test目标。它的作用是快速验证程序能不能跑通一个基础用例。这个习惯在自动化测试环境下尤其有用。
你还可以把test扩展成更完整的脚本:
test: $(TARGET) ./mywc test.txt ./mywc -l test.txt ./mywc -w test.txt ./mywc -L test.txt printf 'no newline' | ./mywc printf 'no newline' | wc这样每次改完代码,直接运行make test就能看到回归结果。
8.4 延伸练习
完成第一版 wc clone 之后,下面这些练习可以进一步提升水平:
- 实现 -m 字符数统计。需要调用
setlocale,并使用mbrtowc或宽字符函数。这个练习会逼你理解 UTF-8 编码和 locale 的概念。 - 把逐字节读取改为缓冲读取。
fgetc底层有缓冲,所以性能其实不差。但如果你想更深入地理解系统调用,可以用open、read系统调用配合固定大小缓冲区自己实现读取。 - 增加与系统 wc 的随机对比脚本。随机生成不同内容的文件,包括空文件、无换行结尾的文件、只有换行的文件、包含 Tab 的文件,然后循环对比 mywc 和系统 wc 的输出。
- 实现
cat或head的 clone。有了读写文件的基础,实现其他 Unix 小工具会变得非常顺手。
9. 总结
很久没有写 C 之后,用一个小项目重新建立手感,是我觉得最靠谱的方式。wc 这个命令看起来简单,真正实现一遍,你才会发现里面藏着EOF处理、字符类型、状态机、参数解析、多文件聚合这些真实问题。
如果你也想尝试,建议不要直接复制上面的代码。可以先看完思路,把编辑器打开,自己从空文件开始写。写完以后再和文章里的代码对比,找出差异,想想为什么别人的写法和你不一样。这个过程才是真正练到东西的地方。
等你把mywc完整跑通,可以同步做一件事:换个关键词,用同样的方法再去写一个head或者tail。你会发现,很多 Unix 工具的骨架是相似的,你已经掌握了入门的那把钥匙。
下一步,把-m字符统计加上试试。这是这个项目里最有挑战性,也最能让你理解编码与 locale 的练习。