☰
从零实现一个C语言单文件命令行文本统计工具
2026/10/28 13:37:26 网站建设 项目流程

“caveman 这个词,搁在程序员圈子里,十有八九会先想到 Caveman Debugging,就是那种不挂断点、不跟调用栈,哪儿不对劲就在哪儿塞 printf,粗暴但高效的原始人式调试法。我这个项目直接叫 caveman,并不是自嘲代码写得野蛮,而是想把同一种气质搬进整个开发过程:能靠标准库完成的事,绝不引第三方依赖;能用数组想清楚的问题,绝不先塞一棵红黑树进去;能一个命令跑完拿到结果,绝不花一晚上折腾构建脚本。这个工具最终长成一个单文件 C 程序,编译后只有一个可执行文件,干的事也简单——输入一段文本或指定文件,输出它的字符数、行数、总词数,然后列出出现频率最高的若干单词。

也许有人觉得,这活计用 Python 三五行就写完了,折腾 C 图什么?我后面会仔细讲原因,但先给个结论:写小工具最重要的不是最短代码,而是你完全清楚它每一步在做什么,出问题的时候能直接钻进去把问题揪出来。这个项目适合两类人:一类是写业务代码久了,想找个周末项目找找手感的老开发;另一类是刚学完 C 语言基本语法,想用一个小而完整的项目验证自己是不是真的会写程序的新手。它不涉及操作系统底层,也不用懂框架,但文件读写、内存分配、数据结构、编码边界这些基本功全都能碰到。”

1. 项目定位:为什么要把“原始人”思路做成一个工具

1.1 从 Caveman Debugging 到 Caveman 哲学

Caveman Debugging 这个叫法带点自嘲,但细想一下,它其实是把“最短反馈回路”贯彻到了极致。断点调试需要编译器配合、需要 IDE、需要你熟悉一系列快捷键,而 printf 这一招在任何环境、任何语言里都成立,只要你能在怀疑的位置输出一行中间值,问题范围立刻就能缩小。写这个项目时,我有意把这条原则扩大到整个设计里。所谓 Caveman 哲学,对我而言就三条:不加不必要依赖,不写看不懂的抽象,不让环境配置成为跑通代码的门槛。听起来很怂,却特别能扛事。

这个工具的起点其实是一次线上日志分析。接口报错后,我要在一份几十万行的访问日志里统计各个响应码出现的次数,再找出几个高频报错路径。手头有 awk 吗?有。但那次我需要把统计逻辑调来调去,又想在脚本之外留一个小工具长期复用,索性用 C 写了一个。刚开始只想做响应码统计,后来把字符数、行数、词频都加进来,变成了现在这个通用统计器。整个过程没引任何第三方库,连 makefile 都是后来才补的,编译靠一行 gcc,这就是我想要的效果。

1.2 需求边界:什么该做,什么打死都不碰

做这种自用小工具最忌讳需求膨胀。我先列一个明确清单:输入方面,支持一个文件路径作为参数,也支持从标准输入读数据,这样能接管道;输出方面,包含字符数、行数、单词数三项基础指标,外加一个可按数量指定的高频词列表,默认输出 10 个词;编码方面要求支持 UTF-8,因为日常工作里中文文本很常见;运行环境方面,只要能找到 C 编译器就能编译,不依赖任何动态库。

同时我给自己画了几条红线。不做图形界面,命令行工具没必要;不做实时监控,不在文件变化时自动输出;不做多线程,单文件小工具把单线程跑稳就够了;不做插件机制,也不搞可配置的脚本语言。这些功能听起来都很酷,但每加一个都会让项目从“一眼能看穿”变成“某个角落藏着惊喜”,那恰恰和 caveman 这个名字背道而驰。边界定清楚之后,整个项目的代码量、测试范围和排错路径都变得好预测,这是我在一开始没有意识到的收益。

2. 核心设计与难点拆解

2.1 单词到底怎么定义,统计才不会翻车

别小看“统计单词数”这件事,它是整个项目里最容易产生口径分歧的地方。我的实用定义是:先用 isspace 把连续空白字符作为分隔,得到一段候选 token,再把 token 首尾不属于字母或数字的字符修剪掉,剩下的内容才算一个词。举例来说,hello,会被修剪成hello,(123)会被修剪成123,don't这种中间带撇号的词保留原样,不强行拆分。对纯英文日志和文本,这个定义足够接近直觉。

中文文本的情况需要单独说明。中文词语之间没有空格,用这套规则会把一整句话当成一个极长的 token,修剪标点后仍然是一个句子级“词”。这在严格中文分词面前显得很不专业,但对一个统计器来说算合理取舍。真要支持中文分词就得引入词库和更复杂的算法,超出 caveman 的初衷了。如果你要统计中文文章的词频,我会建议在预处理阶段先用现成分词工具把文本变成空格分隔的形式,再喂给这个工具,分工明确,两边都不委屈。

2.2 数据结构选型:为什么偏偏用“有序数组+二分查找”

词频统计最常见的方案是哈希表,查找近乎 O(1),看起来是标准答案。但我一开始就否掉了它:在这个项目里,我可以不引第三方库,这意味着哈希函数、冲突策略、扩容机制全都要自己实现,代码很快就超过项目其他部分,而且哈希表遍历输出时还需要额外排序,并没有省掉事情。反过来看,一次统计分析中不同词的总数通常在几万到几十万这个量级,有序数组配合二分查找完全扛得住。

我选择的结构就是一块动态数组,每个元素保存一个词指针和出现次数,按字典序保持有序。查询时二分查找,找到就把次数加一;找不到就找一个合适插入位置,把后面的元素整体后移再插入。插入的 memmove 在最坏情况下是 O(n),但单词统计场景里,绝大多数查询都是命中现有词,真正的插入次数远比处理次数少,而且 memmove 对缓存非常友好。扩容策略也很简单:初始容量 64 条,用完就翻倍。当唯一词数量到几十万时,这个结构依然能在可接受时间内完成任务,真到那个量级再去考虑哈希表也不迟。

2.3 UTF-8 下统计“字符数”的正确姿势

第三个难点是字符数统计。很多人会想当然按字节数去数,但一个 UTF-8 中文汉字占 3 个字节,统计工具如果报出 3000 字节,用户却期待 1000 个汉字,这个偏差就非常闹心。UTF-8 的设计有一套很干净的规则:除了 ASCII 的 0x00 到 0x7F,多字节字符的后续字节都以 10 开头,也就是二进制的高两位为 10,范围在 0x80 到 0xBF;而每个字符的首字节则不走这个范围。

利用这个规则,统计字符数可以做到极简:按字节遍历整个缓冲区,遇到一个“不是 0x80 到 0xBF 的字节”,就认为是一个新字符的开始,计数值加一。用 C 写出来就是if ((c & 0xC0) != 0x80) count++,其中 c 必须先转成 unsigned char,否则有符号位扩展的坑等着你。这个办法对合法 UTF-8 文本非常准确,对 GBK 之类的编码当然不适用,但这不是工具的锅,输入编码本来就是边界条件。值得注意的是,这套方法不去判断首字节的合法范围,遇到非法字节时会把每个非续字节都当字符,对防御式工具来说,这反而是可接受的宽松处理。

2.4 整体数据流设计:一次读一行,绝不把整个文件压在内存里

把基础指标统计和词频统计组合起来时,我坚持了一个原则:流式处理。程序一条条读取文件内容,每读到一行就完成行数累加、字符数统计、单词切割和词频表更新,这一行处理完就不再保留。这样几十兆的文件也能用很小的常驻内存跑完,内存里只有行缓冲和词频表。读文件用标准库的 fgets 已经从内核拷贝到用户态并做了一次缓存,效率上是稳的。

整体流程可以概括成四步。第一步,确定输入来源,有命令行参数就打开对应文件,没有参数就用标准输入;第二步,进入主循环,循环内读一行、统计字符数、行数、切割单词;第三步,单词逐个交给词频表,命中就加次数,未命中就插入新词条;第四步,全部读完以后对词频表按照次数倒序排序,输出前 N 条,收工。这个流程没有魔法,每一步都能在代码里直接勾到,也正因如此,后面排起错来非常痛快。

3. 从零到一:caveman 的具体实现

3.1 编译命令与项目骨架

项目本身没有任何构建系统,连 makefile 都是后知后觉补的,一个源文件 caveman.c,编译就一行命令。之所以这么干,是因为我判断这个项目规模很小、依赖为零,引入 CMake 或 Meson 这类构建工具只会增加理解和迁移成本,让项目失去即拿即走的轻便感。编译时我会打开常见警告和基本优化,加上-Wall -Wextra和-O2,告警信息是发现低级错误的第一道防线,优化则让纯计算型任务跑得更利落。

gcc -std=c99 -Wall -Wextra -O2 -o caveman caveman.c

项目骨架包括一个词频结构体、动态数组的全局变量、三四个静态函数,main 函数做了参数解析和输入源选择,结构非常简单,任何人都能在十分钟里读完整个文件。结构体只在内部使用,函数也都是静态的,对外只暴露一个可执行文件,这种极简组织方式和项目本身的定位是对得上的。

3.2 主循环:读文件、统计行数和字符数

主循环我直接用 fgets,缓冲够用的话,它每次返回一行,性能不错。每读一行,行数加一,字符数用前文说的 UTF-8 扫描规则累加。字符统计的代码长这样:

static size_t utf8_chars(const char *s) { size_t n = 0; const unsigned char *p = (const unsigned char *)s; while (*p) { if ((*p & 0xC0) != 0x80) n++; p++; } return n; }

这里唯一需要注意的就是强制转换成const unsigned char *。直接对 char * 做位运算时,如果一个字节的符号位是 1,右操作数的符号扩展会污染判断结果,这类问题在 debugger 里看非常奇怪,但转换成无符号字符后立刻就清晰了。主循环里有行数、字符数、单词数的统计调用,词频更新放在单词切割之后,这三件事互不干扰,顺序可以先数再切再查表。

需要提醒的是,fgets 会把结尾的换行符留在缓冲里,字符统计是逐字节扫描,因此它会计入这个换行符。我在测试时踩过这个坑,后面会展开说。你如果希望字符数和编辑器里的显示保持严格一致,可以在扫描到行尾的\n时不累加,但我最后保留了对文件实际字节内容的统计口径,因为日志分析场景里原始字节数反而更有参考价值。

3.3 单词切割与修剪逻辑

单词切割我用了最简单的扫描法:从左到右找第一个非空白字符作为起点,继续扫描到空白字符作为终点,在这个区间里做首尾修剪,最后把修剪后的结果交给词频表。代码可以这样写:

char *p = line; while (*p) { while (*p && isspace((unsigned char)*p)) p++; if (!*p) break; char *start = p; while (*p && !isspace((unsigned char)*p)) p++; trim_token(start, p); }

trim_token 的逻辑是让 start 向后移动、让 end 向前移动,跳过所有非字母数字字符,最后在 end 位置写入字符串结束符。这一刀切下去会修改行缓冲,但我本来就不需要保留原始行了,所以没有任何副作用。这里有一个容易忽略的点:判断字符是否是字母或数字,必须用isalnum((unsigned char)c),直接传 char 进去在扩展字符集上属于未定义行为,我用-Wall那一段已经吃过这个亏。这一步修完之后,"hello,"、"world!"都会变成干净的hello和world,词频统计结果看起来就舒服多了。

3.4 词频表的查、插、扩

词频表就是这个项目的核心数据结构。我先定义结构体:

typedef struct { char *word; int count; } WordFreq; static WordFreq *freq = NULL; static size_t freq_len = 0; static size_t freq_cap = 0;

查询用二分查找,找不到的时候返回应该插入的位置,插入前检查容量,不够就翻倍并 realloc。这一段的实现没什么玄机,但有个细节我要专门提一下:realloc 的返回值一定要先存到临时变量,确认不为 NULL 后再赋给全局指针。我曾经图省事直接freq = realloc(freq, new_cap * sizeof(WordFreq)),一旦分配失败,原来的指针就丢了,程序直接变成内存泄露现场。扩容代码的正确形态是:

if (freq_len == freq_cap) { size_t new_cap = freq_cap ? freq_cap * 2 : 64; WordFreq *nf = realloc(freq, new_cap * sizeof(WordFreq)); if (!nf) { perror("realloc"); exit(1); } freq = nf; freq_cap = new_cap; }

插入时用 memmove 把从插入位置到结尾的元素统一往后搬一格,再把新词条摆进去。一个需要留神的地方是字符串的生命周期。fgets 的行缓冲在循环里复用,直接把 buffer 里的地址交给 freq 表是危险的,下一行数据会把旧内容盖掉。因此插入词条前必须用 strdup 或者 malloc+strcpy 把词复制一份,词表里存的是独立副本,不会被读取循环吞掉。

3.5 排序与输出

全部输入处理完后,词频表按 count 字段降序排序。C 标准库的 qsort 是不稳定排序,两个词出现次数相同的时候,它们的先后顺序没有保证。为了让输出结果可复现,我在比较函数里加了第二排序条件:次数相同的词再按字典序排,这样同一份文件跑多少次结果都一样。比较函数写起来也直白:

static int cmp_freq(const void *a, const void *b) { const WordFreq *fa = a, *fb = b; if (fa->count != fb->count) return fb->count - fa->count; return strcmp(fa->word, fb->word); }

输出时按前 N 条遍历,每行打印“单词 次数”。N 默认 10,支持用-n参数覆盖,比如./caveman -n 20 access.log。参数的解析我刻意写得很简单:遍历 argv,遇到-n就把后面的值用 atoi 转成整数;剩下的参数认为是文件路径。没有引入 getopt_long,也没有支持缩写参数,因为这套极简解析已经覆盖我在真实使用时的全部需求。

3.6 测试样例:烤一遍才算数

写完不测试等于没写。我准备了几组样例来验证。第一组是英文短文本,echo "hello world hello" | ./caveman,预期行数 1,字符数应该是 11 还是 12 取决于你期望的口径,这一点我放在下一节详细讲;单词数 2,hello出现 2 次,world出现 1 次。第二组是中文文本,验证字符数是否按汉字个数统计,而不是按字节数。第三组是空文件,期望字符数、行数、词数都是 0。第四组是文件末尾没有换行符的情况,用来对齐“行数”的统计口径。

跑第一组的时候我就发现字符数输出多了一个。用 Caveman Debugging 的老办法在扫描函数前后加 printf,把行缓冲内容原样打出来,才发现echo默认输出的内容末尾带着换行符,而 fgets 读进来的行里带着它,UTF-8 扫描把它也算成了一个字符。严格来说这不算 bug,但意味着我要在输出里明确“字符数包含换行符”。如果希望和编辑器里看到的字符数一致,可以在换行符处做特殊处理,最后我选择了保留原样并在文档里说清楚口径,因为对日志分析场景来说,计入换行符反而更符合实际文件字节语义。

4. 实测中的问题与排查技巧

4.1 行数统计跟 wc 对不齐

工具写好之后,我第一件事就是拿它跟系统自带的 wc 对比。对比完心里一凛:wc -l 和 caveman 在文件最后一行没有换行符时结果不一致。比如文件内容是abc三个字符、没有结尾换行,wc -l 统计的是换行符数量,结果是 0;caveman 用 fgets 按行读,能看到这一行,行数就记成了 1。

这个问题本质是“行”的两种约定:一种叫换行符数量,一种叫物理行数量。编辑器、git diff 和多数人直觉更接近后一种。我的取舍是维持 caveman 当前逻辑,默认统计物理行,并在帮助文本里明确说明。如果想严格对齐 wc,也很简单:读完全部内容后检查最后一个字符是不是\n,不是就减一。我把这个开关做成了可选逻辑,比如./caveman --wc-like -l,实际用下来发现它比我想象中更有用,因为线上脚本里很多人已经默认拿 wc 的数据做基准了。

4.2 超长行直接把程序带偏

我最初偷懒用了定长缓冲区char line[4096]。在大部分日志上运行没事,直到我把它丢给一份 JSON 堆成一行的大文件,发现输出的行数和词数完全不对。原因太典型了:fgets 遇到超过缓冲区剩余空间的长行时,不是报错,而是截断并返回当前可读的部分,剩余内容会留待下一次 fgets 继续读取。于是物理上的一行被切成了逻辑上的多行,行数统计直接爆炸。

解决办法是放弃定长缓冲,改用动态循环读取。我用了一个更原始的方案:不用 fgets,而是逐字符用 fgetc 读,见到换行符或 EOF 才完成一行的收集。因为 fgetc 每次只读一个字节,逻辑上永远不会截断,代价只是多几次函数调用,配合标准库内置缓冲,性能损失完全在接受范围内。核心代码大概长这样:

static char *read_line(FILE *fp) { size_t cap = 256, len = 0; char *buf = malloc(cap); int c; while ((c = fgetc(fp)) != EOF && c != '\n') { if (len + 1 >= cap) { cap *= 2; buf = realloc(buf, cap); } buf[len++] = (char)c; } if (c == EOF && len == 0) { free(buf); return NULL; } buf[len] = '\0'; return buf; }

换掉之后,无论多少 MB 的一行都不会再拆错,内存消耗也只在行太长时才会增长,平常几 KB 的行根本触发不了扩容。这个 bug 让我意识到,写工具时不应该对输入规模抱有侥幸,任何“理论上不存在的超长行”都一定会在你上线后第二天出现。

4.3 内存越界和泄露的排查方法

词频表扩容是我踩坑重灾区。我前面提到 realloc 返回值直接赋给原指针的问题,这里再多说一层:一旦 realloc 失败,旧指针会丢失,程序内存直接泄露,而且不会立即崩溃,等你发现时已经无法判断是哪一次调用造成的问题。我现在的习惯是,所有 realloc 必须走临时变量模式。另外,初始容量设太小会频繁触发扩容,设太大又浪费内存,我用了 64 的初始值,实测在绝大多数日志场景下扩容三四次就到稳定状态,开销可以忽略。

排查这类问题,我强烈建议测试阶段用 AddressSanitizer 编译一次:

gcc -std=c99 -Wall -Wextra -fsanitize=address,undefined -g -o caveman_dbg caveman.c

这会让程序在越界访问、内存泄漏、未定义行为出现时立刻打印出具体文件和行号。我拿这个版本跑过前面几组样例,还真抓到过一次:往词频表插入第一个词的时候,freq_len 已经变成 1,但 freq_cap 还是 0,扩容判断写成了if (freq_len >= freq_cap)后程序读到了空指针指向的内存块。这种 bug 用肉眼检查很难发现,交给工具定位就快多了。

4.4 大文件性能:从两秒优化到一秒

我用一份约 50MB 的 Nginx 日志测了耗时。最初版本用 fgetc 逐字符读取并逐个处理,跑完整轮统计大约 2.3 秒;改成 fgets 按行读取、行内用指针扫描单词后,时间落到 1.2 秒左右。差别不算天壤之别,但对每天要跑几十个文件的脚本来说,省下的时间很可观。

进一步看瓶颈,主要是两个地方:词频表的二分查找加插入,以及输出前的排序。前者的优化空间很小,除非换成哈希表;后者的优化空间看数据分布。实际场景里高频词常常集中在少数词上,而低频词数量庞大,排序全表其实是在给几十万个只出现一两次的词排序,非常不划算。可以换个思路:只输出前 N 名,用大小为 N 的最小堆维护全局 top N,复杂度从 O(k log k) 降到 O(k log N)。但我没有在 caveman 里做这个,原因还是那个:保持代码一眼能看懂,等真的有性能瓶颈再上优化,不为想象中的负载写复杂代码。

5. 后续扩展方向与个人经验

5.1 这个工具还能往哪长

caveman 现在的可执行文件已经能应付日常使用了,但它还有几个便宜的扩展空间。第一个是忽略大小写,英文词频统计时Hello和hello应该算同一个词,做法是在 trim 之后统一转小写,一行代码的事。第二个是支持读取多个文件,处理逻辑不变,只是把文件参数遍历一遍,统计结果跨文件合并,适合分析多份日志。第三个是输出格式简单化,比如只输出 JSON 行,给后面的脚本解析用,我的做法是加一个-j参数,打印{"word":"...","count":3}这样的结构,脚本那边用 jq 就能继续处理。

这些都是低成本的加法,不会破坏单文件、零依赖的初衷。我也守住了一个原则:所有新功能必须用同样简单的代码实现,控制不住复杂度就直接不写,宁缺毋滥。因为小工具一旦变复杂,它的维护成本会快速超过它省下的时间,那这个项目就失去存在意义了。

5.2 三个我亲测有用的心得

第一个心得:caveman debugging 不是低级做法,而是追求反馈速度的工程直觉。我在实现过程中遇到字符数偏差、词频错乱,最有效的手段就是在怀疑的函数出入口塞一句 printf,看中间值是否合理,比对着代码干想快得多。这个项目本身就是这个方法论的产品。

第二个心得:边界条件比算法更值得花时间。绝大多数 bug 都发生在文件末尾没有换行、行超过缓冲区、字符超出 ASCII、内存分配失败这些“正常路径不是问题”的地方,把这些情况理清楚,工具的质量会立刻上一个台阶。写测试用例时别只准备 happy path,空文件、超长行、无换行结尾各来一份,能帮你省下大量线上排查的时间。

第三个心得:工具项目的价值不取决于代码量,而取决于你在使用时对它的信任度。一个能读懂的、能随时改的小工具,比一个功能丰富但内部像黑箱的大工具更让我安心。caveman 这个名字持续提醒我,遇到问题时优先选择看得见、摸得着、出错了能立刻定位的简单方案,很多看起来很“高级”的复杂度其实是自己加给自己的。

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

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

立即咨询