C语言性能提升的3个技巧(新手必看)

张开发
2026/5/4 5:53:50 15 分钟阅读
C语言性能提升的3个技巧(新手必看)
由于 C 语言是最接近汇编语言的编程语言相比其他更高级的编程语言通常使用 C语言编写的程序可以获得最好的运行速度。但是也正因为 C 语言有优越的性能表现程序员在使用 C 语言的过程中往往会无视一些低效的代码。这些低效的代码在开发环境中很难察觉但是当它们成为调用热点时可能就会对程序的整体性能产生明显的影响。这里的调用热点是指在程序运行过程中某个或某些函数会被频繁调用也就是说在给定统计周期内这个或这些函数被调用的次数远远超过其他函数。如果构成调用热点的函数恰好未经充分优化则大概率会形成程序的性能瓶颈。为避免这种情况在日常编码中C 程序员应该时刻牢记以下 3 个技巧。避免做无用功常见的无用功出现在如下两个场景中局部变量尤其是数组的初始化以及多余的函数调用。比如下面的代码void foo(void) { char buf[64] {}; memset(buf, 0, sizeof(buf)); strcpy(buf, foo); ... }以上代码最终的执行结果是将foo字符串复制到 buf 中。然而以上代码执行了多次不必要的初始化工作本质上上述两个操作是重复的只需要保留一个即可。从最终生成的汇编代码角度看使用编程语言提供的赋值语句进行初始化的执行效率要更高些。从这段代码的最终执行结果看以上两个操作都是多余的因为 strcpy() 函数会将字符串常量foo最后的空终止字符\0一并复制到 buf 中。故而以上代码段只需要保留如下两行void foo(void) { char buf[64]; strcpy(buf, foo); ... }更进一步地我们可以将这段代码简化为一条赋值语句void foo(void) { char buf[64] foo; ... }根据 C语言语法以上语句会将字符串常量 foo 连同最后的空终止字符一并复制到 buf 中而无须调用 strcpy() 函数这样便可省去调用函数的开销。读到这里读者可能会有疑问如此低效的代码在实际项目中应该不多见吧现实情况是这类代码极可能出自“有丰富经验”的老手。究其原因他们被可能的内存使用错误搞怕了于是不问青红皂白只要程序中用到数组就调用 memset() 重置一下。另一种常见的无用功便是执行一些不必要的额外检查。下面的代码实现了一个 bar() 函数#include static int bar(const char* str, size_t length) { if (str NULL) return 0; if (length 0) length strlen(str); while (*s length) { ... length--; } ... }该函数接收两个参数一个参数是字符串指针另一个参数是长度。从代码已有的实现看length 限定了该函数需要处理的最大字符个数若给定的 length 为零则意味着处理到字符串尾部。故而在 while 循环中既检查了 *s 的值也检查了 length 的值并在循环的末尾执行了 length--。这段代码的无用功在于当我们测试到 length 为零时调用了 strlen(str) 函数来计算字符串的长度。实际上计算字符串的长度要循环查找空终止字符而其后的 while 循环也要检测空终止字符因此便出现了多余的测试。要优化这一实现只需要在 length 为零时将 SIZE_MAX 宏的值赋给 length 即可——反正 while 循环始终会判断是否到达字符串尾部那就当其长度为最大的可能值好了。这里的 SIZE_MAX 宏定义了 size_t 的最大值SIZE_MAX 宏定义在 stdint.h 头文件中。优化后的实现如下#include static int bar(const char* str, size_t length) { if (str NULL) return 0; if (length 0) length SIZE_MAX; while (*s length) { ... length--; } ... }如此便可免去一次多余的 strlen() 函数调用。避免滥用接口滥用标准 C 库接口或者某些第三方函数库的接口。最为常见的便是滥用 STDIO 接口。比如下面的两个函数调用sprintf(a_buffer, %s%s, a_string, another_string); sscanf(a_string, %d, i);这两个函数调用分别完成了串接两个字符串以及将字符串转换为一个整型值的功能。在内存中执行格式化输入输出的 STDIO 接口如 sprintf() 和 sscanf() 函数首先会调用 fmemopen() 函数构造一个基于内存的 FILE 对象然后调用 fprintf()、fscanf() 等函数完成最终的格式化输入输出功能最后销毁临时构建的 FILE 对象。因此这些接口的开销较大不论从空间复杂度看还是从时间复杂度看都远大于 strcat()、strcpy()、atoi() 等函数。如果只是想完成字符串的串接或者单个字符串转整数的功能大可不必调用 STDIO 接口而应该调用其他的标准库函数如下所示// sprintf(a_buffer, %s%s, a_string, another_string); strcpy(a_buffer, a_string); strcat(a_buffer, another_string); // sscanf(a_string, %d, i); i atoi(a_string);如果对性能仍不满足则可以进一步优化以上字符串串接代码。strcat() 首先会找到 a_buffer 的尾部然后复制 another_string 的内容直到字符串末尾。但实际上strcpy() 函数在其内部实现中一定已经循环到了 a_string 的尾部故而也知道 a_buffer 中字符串的尾部地址。然而strcpy() 函数并没有返回 a_buffer 中指向字符串尾部的指针返回的却是 a_buffer 本身。幸好为满足这一需求标准库特意增加了一个接口 stpcpy()该接口复制字符串并返回指向其尾部的指针#include char *stpcpy(char *dest, const char *src);故而我们可以进一步优化以上用来串接两个字符串的代码// sprintf(a_buffer, %s%s, a_string, another_string); char *p stpcpy(a_buffer, a_string); strcpy(p, another_string);避免滥用内存分配滥用内存分配是导致 C 程序性能低下的常见原因且常常表现为两个截然相反的倾向以常见的串接给定的路径和文件名并读取文件内容的函数为例函数原型如下char *get_file_contents_under_dir(const char *path, const char *fname);其中参数 path 用于指定路径fname 用于指定文件名。该函数需要将 path 和 fname 串接为一个完整路径名然后根据文件长度分配一个缓冲区并读取文件的内容到该缓冲区最后返回缓冲区的地址。单看该函数中用于串接 path 和 fname 生成完整路径名的代码一个简单的实现是调用 asprintf() 函数#define _GNU_SOURCE #include char *get_file_contents_under_dir(const char *path, const char *fname) { char *full_path; if (asprintf(full_path, %s/%s, path, fname) 0) { assert(full_path); } else goto failed; char *buff NULL; /* 略去打开文件并读取其内容的代码。 */ ... free(full_path); return buff; failed: return NULL; }仅仅因为要串接两个字符串就调用 asprintf() 函数显然是“杀鸡用牛刀”。另外asprintf() 并不是标准接口而是 GNU 扩展接口存在一定的平台兼容性问题。为此我们可以做一些调整#include char *get_file_contents_under_dir(const char *path, const char *fname) { char *full_path; full_path malloc(strlen(path) strlen(fname) 2); if (full_path NULL) { goto failed; } strcpy(full_path, path); strcat(full_path, /); strcat(full_path, fname); char *buff; ... free(full_path); return buff; failed: return NULL; }上述调整避免了“杀鸡用牛刀”但考虑到绝大多数情况下一个文件的完整路径名也就几百字节定义一个足够长的局部变量作为缓冲区就可以了完全不必调用 malloc() 等函数从堆中动态分配对应的缓冲区。另外C99 支持变长数组Variable Length ArrayVLA故而我们可以利用变长数组来定义这个缓冲区。进一步调整后的实现如下char *get_file_contents_under_dir(const char *path, const char *fname) { char full_path[strlen(path) strlen(fname) 2]; strcpy(full_path, path); strcat(full_path, /); strcat(full_path, fname); char *contents; ... free(full_path); return contents; failed: return NULL; }然而针对程序中使用变长数组的方法在 path 和 fname 两个字符串的长度之和大于一个内存页page的长度通常为4 KB时系统需要分配一个完整的物理内存页来容纳该缓冲区从而会在某种程度上降低程序的执行效率。另外仍然有少数编译器不支持变长数组或者其内部实现并不是从栈中分配空间而仍然从堆中分配空间只是在函数返回前自动完成了缓冲区的释放而已。因此对此类缓冲区的分配实践中更为有效的办法是根据所要分配的缓冲区大小灵活使用栈空间或者自行分配合适的栈空间大小char *get_file_contents_under_dir(const char *path, const char *fname) { /* 从栈中分配一个覆盖绝大多数路径长度的缓冲区。*/ char stack_buff[1024]; char *full_path; size_t full_path_len strlen(path) strlen(fname) 2; if (full_path_len sizeof(stack_buff)) { /* 如果用于完整路径长度的缓冲区之大小超过预定义的栈缓冲区 则执行动态分配。 */ if ((full_path malloc(full_path_len)) NULL) goto failed; } else full_path stack_buff; strcpy(full_path, path); strcat(full_path, /); strcat(full_path, fname); char *contents; ... /* 如果实际使用的缓冲区不是预定义的栈缓冲区则释放该缓冲区。 */ if (full_path ! stack_buff) free(full_path); return contents; failed: return NULL; }程序中我们并没有使用 PATH_MAX 宏来定义栈缓冲区的大小。这是因为 PATH_MAX 宏的值通常被定义为 4096恰好是一个物理内存页的大小。使用 PATH_MAX 宏会导致额外物理内存页的分配甚至为了容纳最后的终止字符串用的空字符我们通常会如下定义栈缓冲区char stack_buff[PATH_MAX 1];但因为缓冲区超过了 4096 字节stack_buff 需要两个物理内存页来容纳——这显然很不经济。但绝大多数情况下需要处理的完整路径名之长度不会超过 1024 字节故而我们只定义了 1024 字节大小的栈缓冲区。

更多文章