现代C:标准库:字符、字符串处理与数学计算

📅 2026/7/15 16:18:16
现代C:标准库:字符、字符串处理与数学计算
引言在上一个模块中我主要围绕着 C 语言的七大关键语法介绍了它们在机器指令层面的实现细节。而接下来走出语法从微观到宏观我们将开始进一步探索 C 语法之外那些可以用来支撑大型 C 项目构建的特性和技术。因此在这一模块中我会介绍和 C 语言标准库、工程化以及性能优化等相关的内容。C 标准库是除 C 基本核心语法外C 语言的另一个重要组成部分。C 标准库中提供了众多的类型、函数和宏可供我们直接在程序中使用。这些“构建单元”的功能涵盖了多个方面从简单的文本字符处理到复杂的线程操作、内存管理等。在继续后面的内容之前你可以先粗略浏览下图以对 C 标准库提供的基本功能有个大致的印象。C 语言中的字符和字符串在 C 语言中字符用单引号表示字符串用双引号表示。比如在下面这段代码中便定义有两个变量它们分别存放了一个字符类型和一个字符串类型的值。char c a; const char* str Hello, geek!;下面我们就来分别看看与这两种类型有关的重要特性。字符在 C 语言标准中不同于其他整数类型比如 int字符类型 char 并没有被规定默认的符号性而其具体符号性则是由编译器和所在平台决定的。虽然在大多数情况下编译器会选择将默认情况下的 char 类型视为有符号整数类型但考虑到程序的可用性与可移植性在实际编码中还是建议显式指定出所定义字符变量的符号性。C 标准中之所以这样规定主要源于其历史原因。比较有代表性的一种说法是C 语言在设计之初参考了它的“前辈” B 语言。B 语言于 1969 年由 Ken Thompson 在贝尔实验室开发。作为一种无类型语言它的字符类型仅用于存放字符而不作数学运算之用。因此并不需要特别区分符号性。所以在 C 语言中作者也没有为字符类型规定默认的符号性。另外有关字符类型的另一个重要特征是C 语言保证 char 类型只占用一个字节大小因此在使用 sizeof 运算符计算该类型大小时将永远得到结果 1。但事实上并不是所有计算机体系都使用 8 位的字节大小。对于某些较为古老的计算机体系一个字节可能对应大于或小于 8 位。对于某些特殊的业务需求和功能场景你可以通过访问标准库中定义的常量 CHAR_BIT 来检查当前体系下一个字符类型所占用的位数该常量的使用方式如下所示#include limits.h #include stdio.h int main(void) { printf(char type has %lu byte.\n, sizeof(char)); // ...1. printf(char type has %d bits., CHAR_BIT); // ...8. }不过需要注意的是自 C89 以来的标准中规定CHAR_BIT 的值不能小于 8因此对于单个字节数小于 8 位的体系架构该常量并不能反映真实情况。最后介绍一个编码方面的特性。C 语言中的 char 字符类型变量在按照字符类型打印时比如使用 printf 函数配合 “%c” 占位符会使用 ASCII 编码来对数字值进行字符映射。这意味着一个存储有整数 65 的字符类型变量在将其打印输出时会在命令行中直接得到字符 “A”。当然你也需要确保命令行客户端的编码设置为 UTF-8以与 ASCII 编码相兼容。除了 char 类型以外C 语言还在 C90 和 C11 标准中新增了可用于宽字符的类型诸如 wchar_t、char16_t、char32_t 等。其中wchar_t 类型的大小由具体的编译器实现决定。而 char16_t 和 char32_t 类型其值则固定占用对应的 16 和 32 位。字符串在 C 语言中我们可以通过下面这两种方式来定义字符串类型的变量一种是指针形式另一种是数组形式。当然这里示例代码中我们定义的是只读字符串// read-only string. const char strA[] Hello, geek!; const char* strB Hello , geek!;其中由双引号括起来的部分一般称为“字符串字面量”。C 标准中规定连续出现的字符串字面量之间如果仅由空格分隔则会将它们视为一个整体。所以 strA 与 strB 这两个字符串的内容是完全一样的。本质上这两种方式都展示出了字符串在 C 语言中的具体表现形式即“带有终止符的字符数组”。关于上述字符串在内存中的布局形式我们可以用下面这张图来直观地理解一下。可以看到的是字符串数据被存放在连续的内存段上且每个字符串数据的最后都以空字符\0作为表示结束的终止符。所以我们说字符串并不是单纯的字符数组而是带有隐式定义时会被自动加上终止符的字符数组。虽然通过上面这两种方式都可以定义字符串但实际上不同的定义方式在不同情况下可能会对程序运行产生不同的影响。这里你可以先思考下假设我们有如下这段代码它在运行时会有什么问题吗如果有那应该怎样改正#include string.h #include stdio.h int main (void) { /* get the first token */ const char* token strtok(Hello, geek!, geek); printf(%s, token); return 0; }上面这段代码在不同平台上运行可能会得到不同的结果。比如在 Linux 上你可能会得到名为 “Segmentation fault” 的系统错误。如果进一步用 LLDB 进行调试你会发现错误原因是“signal SIGSEGV: address access protected”翻译过来就是访问了受保护的地址。那接下来我们一起看看为什么会这样。上面这段代码在不同平台上运行可能会得到不同的结果。比如在 Linux 上你可能会得到名为 “Segmentation fault” 的系统错误。如果进一步用 LLDB 进行调试你会发现错误原因是“signal SIGSEGV: address access protected”翻译过来就是访问了受保护的地址。那接下来我们一起看看为什么会这样。以本小节开头的代码为例使用指针形式定义的字符串 strB 实际上直接引用了 .rodata 中该字符串的所在位置即字符指针 strB 的值为该位置对应的内存地址。而使用数组形式定义的字符串 strA 则是将该字符串的数据从 .rodata 中复制到了其他地方strA 的值为复制后该字符串第一个字符的所在地址。我们可以通过下面这段代码来验证这个结论。// string.c #include stdio.h int main(void) { const char strA[] Hello, geek!; const char* strB Hello, geek!; printf(%p\n%p, strA, strB); /** Output: 0x7ffee84d3d0b 0x4006c8 */ }上面的代码中我们使用 “%p” 格式符来打印变量 strA 与 strB 这两个指针的值。可以看到当在 Linux 下执行这段代码时变量 strA 与 strB 分别对应两个完全不同长度的地址参考代码后的注释。此时我们可以通过如下命令来查看当前进程的 VAS 分布情况。pgrep string | xargs -I {} cat /proc/{}/maps命令执行后会得到如下图所示结果可以看到以字符数组形式定义的字符串其对应变量 strA 的数据实际上会从 .rodata 中被复制到当前进程 VAS 的栈内存中。而当程序运行脱离 strA 所在的作用域时该数组对应的值将会被释放。反观以指针形式定义的字符串 strB通过执行以下命令我们也可以证实其指针所指向的位置为 .rodata。objdump -s string | grep .rodata -A 10该命令会直接打印出当前程序 .rodata 的相关情况。可以看到最右侧解码后的 ASCII 字符串 “Hello, geek!” 正对应着值为 0x4006c8 的起始地址。最后我们总结一下。使用数组和指针形式定义的字符串其底层的数据引用方式会有所区别。其中数组方式会将字符串数据从 .rodata 中拷贝到其他位置比如栈内存因此修改这些数据不会改变存在于原始 .rodata 中的副本。而使用常量指针形式定义的数组该指针会直接引用位于 .rodata 中的字符串数据。因此我们需要注意的一个问题是当使用非 const 指针引用字符串时通过该指针修改字符串的值可能会影响到其他使用指针形式引用相同字符串的代码位置上的实际字符串值。当然在 C 标准中这种修改方式本身是一种未定义行为其产生的具体影响将由编译器和操作系统决定。但大多数情况下该行为都会产生诸如 “Segmentation fault” 以及 “Bus error” 之类的系统错误。C 标准库中的字符、字符串处理C 标准库中提供了众多的函数可供我们直接对字符和字符串数据进行处理这里我选择性地介绍其中的一些常见用例。对于这些 C 标准库函数的更详细的使用方法你可以在这里查阅相关文档。统计字符串长度#include string.h #include stdio.h int main(void) { const char str[10] Hi; printf(%zu\n, strlen(str)); // 2. }这里我们直接使用标准库提供的 strlen 函数该函数不会计入字符串中多余的终止符。拼接字符串#include string.h #include stdio.h #define STRLEN 14 int main(void) { char strA[STRLEN] Hello,; char strB[] world!; strncat(strA, strB, STRLEN - strlen(strA) - 1); printf(%s\n, strA); }在这个例子中我们选择使用 strncat 函数来进行字符串拼接。该函数相较于 strcat 函数可以更好地控制被拼接字符串的长度以防被拼接字符串过长导致的拼接字符串数组溢出。这里需要注意在计算 strncat 函数的第三个参数也就是被拼接字符串长度这个参数时需要为字符串最后的终止符预留出 1 个字节的空间。拷贝字符串#include string.h #include stdio.h int main(void) { char strA[] aaaaaa; char strB[] bbbbbbb; printf(%s\n, strncpy(strA, strB, strlen(strA))); // bbbbbb. }拷贝字符串函数 strncpy 的用法与 strncat 基本相同我们可以控制其第三个参数来决定将多少字符拷贝到目的字符串的数组中。这里我给你留下一个小问题如果把 strncpy 函数中第三个参数使用的 strlen 函数更换成 sizeof那么程序运行会得到什么结果为什么你可以在评论区和我交流讨论。格式化字符串#include stdio.h #define LEN 128 int main(void) { char dest[LEN]; const char strA[] Hello, ; sprintf(dest, %sworld!, strA); printf(%s\n, dest); }函数 sprintf 可用于格式化字符串其第二个参数的形式与 printf 函数的第一个参数一致只是后者会将内容输出到命令行中而 sprintf 会将格式化后的字符串保存到通过其第一个参数传入的数组中。字符的判断与转换在 C 标准库头文件 ctype.h 中包含有众多可用于字符判断和转换的函数这些函数自身的名称直接说明了它们的具体功能使用方式十分简单。具体你可以参考下面这个实例。#include ctype.h #include stdio.h int main(void) { char c a; printf(%d\n, isalnum(c)); // 1. printf(%d\n, isalpha(c)); // 1. printf(%d\n, isblank(c)); // 0. printf(%d\n, isdigit(c)); // 0. printf(%c\n, toupper(c)); // A. }C 标准库中的数学运算上面我介绍了 C 标准库中与字符和字符串处理相关函数的使用方式除此之外C 标准库中还提供了与数学运算有关的工具函数基本上你都可以通过引入 math.h 和 stdlib.h 这两个头文件来使用。这些函数的使用方式都十分简单你可以在这里找到对它们用法的详细说明。不过有一点要注意在编译时你可能需要为链接器指定 “-lm” 参数以链接所需的数学库。在下面这个简单的例子中我们使用了标准库中的求绝对值函数。#include math.h #include stdio.h int main(void) { long double num -10.1; printf(%.1Lf\n, fabsl(num)); }为了减少编码的工作量我们也可以使用这些函数对应的泛型版本这样就不需要根据传入的参数来手动选择合适的版本比如这里的 fabsl 函数它的后缀 “l” 表示 “long”。比如对于所有浮点类型的数字值我们可以直接使用名为 fabs 的宏函数。该宏在展开时会自动为你匹配对应的类型精确版本。不过为了使用这些泛型宏我们需要将原来的头文件 math.h 替换为 tgmath.h如下代码所示#include tgmath.h #include stdio.h int main(void) { long double num -10.1; printf(%.1Lf\n, fabs(num)); }关于这些标准库函数的实现方式如果我们进一步来看会发现并非所有函数都是按照相应的数学算法来实现计算过程的。这里用一个常见的数学运算“求平方根”举例子通常来说我们可以使用牛顿迭代法以软件算法的形式计算一个数的平方根值。但实际上当我们以 musl 这个 C 标准库为例进一步查看其某版本的实现时可以看到它在 i386 架构下直接使用了 FPU浮点运算单元提供的机器指令 fsqrt 来计算平方根的值而并没有使用软件算法。在某种程度上这可以极大提升计算性能。如下图所示因此为了最大程度地利用硬件带来的计算性能优势在准备自行编写相应算法时可以先看看能否直接利用标准库提供的函数来完成你的工作。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。今天我主要介绍了 C 语言标准库中与字符、字符串处理以及数学运算有关的内容。首先我介绍了 C 语言中字符和字符串类型变量的定义方式。字符以单引号形式定义而字符串以双引号形式定义。字符串的不同定义方式还可能对程序的运行细节带来影响。其中以字符数组形式定义的字符串包含有原始字符串在 .rodata 中的拷贝而以指针形式定义的字符串变量则直接引用了 .rodata 中的字符串数据且其值通常无法在程序运行时被动态修改。接下来我快速介绍了 C 标准库中与字符、字符串处理相关的一些函数的使用方式。最后我介绍了 C 标准库中与数学运算相关的函数math.h 头文件中包含有这些函数的精确类型版本而 tgmath.h 头文件中则提供了对应的泛型宏函数版本。这些函数的一个重要特征是某些常用的数学运算会被直接映射到对应的机器指令和使用纯软件算法的实现相比这通常可以获得更高的性能。