手写 Unix wc 克隆:用 C 语言找回系统编程手感
2026/8/30 4:09:54 网站建设 项目流程

最近后台有位读者问了一个问题:很久没写 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 做对比验证。这种“写完立刻有标准答案”的反馈,是学习阶段最珍贵的资源。

这篇文章的读者,我假设是下面三类人:

  1. 学过 C 但是工作后长期写业务代码,想重新捡起系统编程的人。
  2. 正在教 C 语言、需要给学生设计课后项目的人。
  3. 想理解 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.txt
  • wc.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 -cwc -m对同一份中文文本会输出不同的数字。

我们的第一版 clone 只实现-c字节数统计,不实现-m。原因是正确处理多字节字符需要用到locale和宽字符函数,复杂度会明显上升。把-m作为延伸练习,留给想深入的同学。

4.5 最长行统计

统计最长行,需要维护两个变量:

  • current_line_len:当前行的长度;
  • max_line_len:到目前为止最长行的长度。

每读到一个字节:

  • 如果不是\ncurrent_line_len++
  • 如果是\n,比较current_line_lenmax_line_len,更新最大值,然后把current_line_len重置为 0。

容易漏掉的情况是文件末尾没有换行符。此时循环结束前,需要再比较一次current_line_lenmax_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.txt

this is a test的长度是 14,所以预期输出是14 test.txt

测试管道输入:

echo "hello world hello" | ./mywc echo "hello world hello" | wc

预期输出都是:

1 3 18

6.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 charisspace的参数直接写成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 之后,下面这些练习可以进一步提升水平:

  1. 实现 -m 字符数统计。需要调用setlocale,并使用mbrtowc或宽字符函数。这个练习会逼你理解 UTF-8 编码和 locale 的概念。
  2. 把逐字节读取改为缓冲读取fgetc底层有缓冲,所以性能其实不差。但如果你想更深入地理解系统调用,可以用openread系统调用配合固定大小缓冲区自己实现读取。
  3. 增加与系统 wc 的随机对比脚本。随机生成不同内容的文件,包括空文件、无换行结尾的文件、只有换行的文件、包含 Tab 的文件,然后循环对比 mywc 和系统 wc 的输出。
  4. 实现cathead的 clone。有了读写文件的基础,实现其他 Unix 小工具会变得非常顺手。

9. 总结

很久没有写 C 之后,用一个小项目重新建立手感,是我觉得最靠谱的方式。wc 这个命令看起来简单,真正实现一遍,你才会发现里面藏着EOF处理、字符类型、状态机、参数解析、多文件聚合这些真实问题。

如果你也想尝试,建议不要直接复制上面的代码。可以先看完思路,把编辑器打开,自己从空文件开始写。写完以后再和文章里的代码对比,找出差异,想想为什么别人的写法和你不一样。这个过程才是真正练到东西的地方。

等你把mywc完整跑通,可以同步做一件事:换个关键词,用同样的方法再去写一个head或者tail。你会发现,很多 Unix 工具的骨架是相似的,你已经掌握了入门的那把钥匙。

下一步,把-m字符统计加上试试。这是这个项目里最有挑战性,也最能让你理解编码与 locale 的练习。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询