新闻详情

新闻详情

首页 / 资讯中心 / 详情

C语言字符串与字符数组:存储、修改与段错误全解析

发布时间:2026/9/30 10:28:40来源:尧图网络
C语言字符串与字符数组:存储、修改与段错误全解析
上周在技术群里看到一位读者贴了段代码说是运行到一半程序直接崩溃。代码本身很简单char *p hello; p[0] H; printf(%s\n, p);编译没报错跑起来却报了 Segmentation fault。群里七嘴八舌有人说要检查内存有人说要换编译器最后还是有个哥们点了一句“你这是字符串字面量是只读的不能改。”很多人第一次接触C语言时都会在字符串和字符数组这个问题上栽跟头。表现形式差不多用起来却天差地别。初学阶段写个小程序可能察觉不到差异一旦进入数据结构、文件处理、网络通讯这些较深的方向字符串与字符数组的概念模糊往往就是各种诡异bug的根源。这篇文章不打算讲教科书式的定义而是把这两个概念掰开揉碎从存储位置到初始化方式从sizeof结果到函数传参行为逐一对比说清楚它们到底哪里像、哪里不像。适合刚开始学C语言的新手也适合那些已经写了一段时间C代码但偶尔还是被字符串问题绊一下的同学。1. 先认识字符串和字符数组的本质定义1.1 字符串字面量与字符数组的直观区别先说最基本的。C语言里没有真正的字符串类型字符串是靠字符数组加一个结束标志\0来模拟的。所以很多教材喜欢说“字符串就是字符数组”这句话看起来没错但其实藏了不少陷阱。举两个最常见的写法char str1[] hello; char *str2 hello;第一行定义了一个字符数组数组名为 str1编译器在初始化的过程中看到后面的hello会分配 6 个字节的空间依次存放h,e,l,l,o,\0。第二行定义的是一个指向字符的指针名为 str2hello这个字符串字面量在编译完成后会放到只读的常量区str2 只保存了这块区域的起始地址。两者的区别最直观体现就是“能不能改”。str1 这个数组是实实在在的内存空间你可以修改它的元素内容str2 指向的字符串字面量是只读的一旦尝试写入就会触发未定义行为表现通常是段错误。我记得大二时第一次上机实验课间有同学写了char *p I love C; p[0]c;来练习大小写转换结果程序一直崩溃搞了半天都不明白为什么。这类问题在大学C语言问答区几乎每天都能看到好几条问来问去都是同一个根源没分清这两种写法的本质区别。1.2 存储区域完全不同栈、静态区与只读常量区要彻底理解上面的差异得往更深一层看——内存视角。C程序运行时的内存布局大致分几块栈区、堆区、全局/静态区、字符串常量区和代码区。不同区域的读写权限和生命周期是不一样的。字符数组的处境取决于它定义在哪函数内部直接定义的char str[] hello数据在栈上生命周期到函数返回为止内容可读可写。函数外部或加了static的char str[] hello数据在全局/静态区程序启动后一直存在内容仍然可读可写。而char *str hello这个 str 指针本身可能在栈上也可能在全局区但它指向的内存是字符串常量区这块区域在大多数平台上都是只读的。你尝试str[0]H等于往只读页面写数据操作系统直接送给你的就是一个段错误。用生活化的类比来说字符数组像你租下的一间房子里面家具怎么摆、墙刷什么颜色你有权决定字符串字面量则像广场中央的雕塑你可以看它、描述它、拿相机拍它但你不能上去把雕塑的胳膊换个角度。也正因如此编译器在优化时可能让所有内容相同的字符串字面量共享同一份存储两个指针变量如果都指向hello最终可能指向同一个地址。这是标准允许的优化在对比两个字符串指针时尤其要小心。1.3 数组名与指针到底是不是一回事有一句流传很广的话“数组名就是指针”。这句话在初学阶段拿来做类比都容易出问题。严谨的说法是数组名在大多数表达式中会隐式转换为指向其首个元素的指针但数组名本身并不是指针变量。它没有独立的存储空间不能被赋值不能自增自减。看这段代码char arr[] hello; char *p hello; printf(arr size %lu\n, sizeof(arr)); // 输出 6 printf(p size %lu\n, sizeof(p)); // 输出 864位系统或 432位系统结果差异很大正因为arr是数组名sizeof取的是整个数组包括\0而p是指针变量sizeof取的是指针本身的大小。只这一个点就能看出“数组名就是指针”这句话的不严谨之处。那为什么很多人还说数组名会退化呢准确说法是“按值传参时数组会退化为指针”。函数参数列表中写的char arr[]会被解释为char *arr二者在函数内是等价的。这是C语言为了效率设计的折中方案也是后面要展开讨论的重点。2. 核心差异初始化、赋值与可变性2.1 三种初始化方式的底层逻辑实际操作中字符串和字符数组相关的初始化方式大概可以归纳为几种// 方式一字符逐个给定数组大小由编译器推断 char a[] {h, e, l, l, o, \0}; // 方式二用字符串字面量初始化字符数组 char b[] hello; // 方式三用指针指向字符串字面量 char *c hello;方式一和方式二的本质相同数组占用的空间都由编译器确定只是写法不同。方式二看起来简洁实际是语法糖编译器在幕后展开成方式一的操作。方式三则完全不同。c 保存的是字符串字面量hello的首地址而且这个字符串字面量位于只读常量区。所以无论你把 c 声明在哪里都不能通过 c 修改内容。这里有必要重点提醒一下C标准规定“试图修改字符串字面量是未定义行为”。这意味着在标准意义上程序不一定崩溃但结果无法预期。在有些嵌入式平台或古老编译器上它可能“正常”执行但这不代表你该这么做。未定义行为之所以叫“未定义”就是因为它今天能跑、明天换了编译器或系统环境就崩给你看。很多隐藏极深的bug都是这么来的今天看起来没问题发布给用户之后在别人的机器上炸了排查起来特别费劲。所以我的建议很简单想修改字符串内容就老老实实定义成字符数组只想读取和传递再用指针指向字符串字面量。2.2 为什么数组名不能赋值指针可以再举一个stack overflow上经典的问题。初学者往往会写char arr[10]; arr hello; // 编译错误然后很不解都是变量凭什么不能赋值答案是数组名不是可修改的左值。数组类型在表达式求值时退化为指针但这个指针是临时生成的没有自己的存储空间自然不能被赋值。你能做的是逐个元素赋值或者用strcpychar arr[10]; strcpy(arr, hello); // 正确换成指针变量就不一样了char *p; p hello; // 正确把字符串字面量地址赋给指针变量这里的本质区别是指针变量是有空间的你可以随时让它指向别的地方数组名没有独立空间它代表的就是那块连续内存本身你不能把一整块内存从一个变量“搬”到另一个变量上。这就好比门牌号是街道管理处在规划时定死的你不能把“101室”这个门牌号改成“102室”但你可以让一张指向某个房间的指示牌写下不同的房号。指针就是那张指示牌数组名是门牌号本身。2.3 sizeof和strlen一对让人上头的组合讲到长度计算很多人会脱口而出“用strlen”。但在确定数组容量时用strlen可能会掉进坑里。再强调一遍核心区别char str[] hello; sizeof(str); // 结果为 6包含末尾的 \0 strlen(str); // 结果为 5不包含 \0字符数组用sizeof算出的是数组占用的总字节数。字符串字面量初始化时编译器自动在末尾补一个\0所以总长度是实际字符数加一。但如果换成指针char *p hello; sizeof(p); // 结果为 864位平台是指针本身大小 strlen(p); // 结果为 5运行时刻从 p 指向的地址开始数到 \0 前的字符个数这里要强调sizeof在编译时计算strlen在运行时扫描。strlen依赖字符串以\0结束如果内存缓冲区没有被清零它可能一直往后扫描直到碰上某个字节恰好是0为止结果就是一个很大甚至离谱的数字。这也是很多缓冲区溢出漏洞的根源之一。初学阶段我建议你在确定缓冲区大小时用sizeof在计算逻辑上的字符长度时用strlen。二者经常混用但含义完全不同。char buf[32] hello; size_t len strlen(buf); // 逻辑长度 5 size_t cap sizeof(buf); // 实际容量 32还要注意 C 语言特有的char类型到底是有符号还是无符号在不同的平台上有差异在计算长度和做字符分类比较时会影响行为不过这属于另一个话题了。3. 实操验证从代码层面观察字符串与字符数组的差异3.1 实验一尝试修改内容引发的崩溃环境用了 Ubuntu 22.04 自带的 gcc没开特殊优化选项默认输出。写一个最小程序验证只读区域的写入问题#include stdio.h int main(void) { char arr[] hello; char *ptr hello; arr[0] H; // 正常数组内容可修改 printf(arr %s\n, arr); ptr[0] H; // 未定义行为常见表现是段错误 printf(ptr %s\n, ptr); return 0; }编译提示只有一个警告就是关于字符串字面量的修改但编译能通过。运行到ptr[0] H;那行时直接 Segmentation fault。代码中的arr和ptr保存的内容看起来是一样的但归属不同。前者属于当前函数的栈帧读改写都行后者挂在只读常量区只许看不许碰。3.2 实验二sizeof输出的强烈反差继续用同一段代码做展示#include stdio.h #include string.h int main(void) { char arr[] hello; char *ptr hello; printf(sizeof(arr) %lu\n, sizeof(arr)); printf(strlen(arr) %lu\n, strlen(arr)); printf(sizeof(ptr) %lu\n, sizeof(ptr)); printf(strlen(ptr) %lu\n, strlen(ptr)); return 0; }在我机器上的输出是sizeof(arr) 6 strlen(arr) 5 sizeof(ptr) 8 strlen(ptr) 5关键在于sizeof(arr)是6因为数组是完整的对象sizeof(ptr)是8因为这是指针变量。很多同学在写strcpy或fgets时习惯写sizeof(ptr)来控制长度结果传入的不过是8根本不是真实缓冲区大小运气好没崩运气不好缓冲区就溢出了。3.3 实验三函数参数中数组名的退化这段代码用来验证函数形参中数组名与指针的等价性#include stdio.h void func(char arr[]) { printf(in func, sizeof(arr) %lu\n, sizeof(arr)); } int main(void) { char array[32] hello; printf(in main, sizeof(array) %lu\n, sizeof(array)); func(array); return 0; }输出结果in main, sizeof(array) 32 in func, sizeof(arr) 8同一个数组在main里用sizeof是32到了函数里只剩8。原因就是函数形参char arr[]会被解释成char *arr所以函数体里的sizeof预期的是指针大小。再看值传递时的退化实际传入的是首个元素的地址。既然只传了地址函数内部就不知道自己来自多大的数组。这也解释了为什么标准库函数比如strcpy、strcat都需要额外传入容量参数或者依赖约定好的结束符。这是C语言设计的取舍。C不想像 Java 或 Python 那样在数组对象里附带长度信息它只提供最朴素的连续内存模型。这样效率高也为上层提供了极大的灵活性代价就是要程序员自己维护好边界。C语言之所以能几十年不衰和这种极简又极灵活的内存模型密切相关也正因如此它依然是计算机相关专业第一门编程语言的热门选择对底层的理解打下的基础对整个职业生涯都很有帮助。4. 实际场景二维字符数组与指针数组的取舍4.1 二维字符数组的用法与限制学完单个字符串接下来几乎不可避免地会遇到一组字符串的场景。最常见的第一反应是用二维字符数组char weekdays[7][4] { Mon, Tue, Wed, Thu, Fri, Sat, Sun };在内存中这是一块连续的 7*428 字节区域每一行固定4字节。如果有一行字符串长度超过了3个字符就没有空间放\0运行时调用printf(%s, weekdays[i])就可能一直打印到越界位置直到遇到一个字节为0才停下来。处理这种问题时我习惯于这样计算容量单个字符串的最大有效字符数加一就是每行所需的字节数。例如要存放“Wednesday”含有9个字符一行至少需要10字节。二维数组的列数必须按最大的那个来预留否则会“装不下”。但这样会浪费大量空间因为短字符串用不了那么多列。还有一个容易踩的坑静态初始化时数组的列数如果留大了未初始化的位置自动全零这没问题但如果是动态填充的数据必须自己保证每一行末尾都有\0。4.2 指针数组的用法与限制另一种常见表示是“指针数组”每个元素是一个指针可以指向字符串常量或字符数组首地址const char *colors[] {red, green, blue};这里的每个字符串字面量也位于只读常量区colors数组中存的是地址。好处是方便只存了指针不关心字符串具体长度坏处是如果你程序运行中要修改字符串内容这些字符串必须来自可写的字符数组或动态分配的内存。举例说明char *items[3]; items[0] apple; // 指向字符串字面量只读 char tmp[16] banana; items[1] tmp; // 指向栈上数组可写对于用户输入的临时字符串把可写缓冲区地址存入指针数组很常见。比如读取多行文本每行的缓冲区都是独立的局部数组需要存到全局变量的时候可以做动态拷贝。使用指针数组要注意一个陷阱如果所有字符串确实来自字符串字面量且你可能在不同编译器上编译记得在声明时加const修饰避免后续无意中通过指针修改内容。没加 const 时代码里的items[0][0] A会被编译器允许然后运行时直接崩溃加了 const 后编译阶段就能帮你抓住这个错误。4.3 不同场景下的选择建议具体到开发项目选择二维数组还是指针数组主要看几个维度。如果字符串集合基本固定且长度差距不大用二维字符数组更合适。优点是内存紧凑操作简单排序时交换元素可以使用临时字符串变量。缺点是列数需要按最大值预留浪费空间。如果字符串数量不确定、长度差异很大或者在运行期频繁增删用指针数组配合动态内存分配更灵活。排序时可以只交换指针效率更高不用整串拷贝。代价是更多的内存分配管理责任容易引起内存泄漏。字符串排序是很多人面试练习的第一课。经典解法是读入一组字符串、按字典序输出、用qsort或者手写冒泡排序。如果用二维数组交换元素时需要strcpy到临时数组再复制回去用指针数组交换时只需交换两个指针变量性能差距在数据量大时非常明显。// 指针数组排序时交换两个元素 char *tmp arr[i]; arr[i] arr[j]; arr[j] tmp;这样交换一个指针代价是常量级的。而二维数组版本里交换是三次strcpy。记住这个差别就能理解为什么很多C开源项目里乱七八糟的字符串列表都会用指针数组来实现。5. 常见错误与排查技巧实录5.1 字符串常量被修改导致的段错误前面已经写过例子但再强调一次这是初学者最容易踩的段错误没有之一。表面上看代码逻辑没问题实际上是对只读内存做了写操作。快速排查的方法是打开编译器的警告选项。gcc加-Wall -Wextra时对这类问题通常会提示warning: assignment to char from char * makes integer from pointer without a cast或类似的信息但注意这不一定能在编译期定位。更彻底的办法是用工具比如打开-fsanitizeaddress重新编译运行asan会给出准确的错误位置包括哪一行、哪条指令触发了非法读写。对于不熟悉这款工具的同学我强烈建议日常练习和项目开发时就一直开着这个选项它能挽救你无数次。也可以做一个简单的自查拿到一段崩溃代码先用眼睛找有没有通过指针直接改字符串内容的操作特别是char *s ...后面紧跟s[i] ...的模式。就算不是这个模式风控思路也是先怀疑它。5.2 数组边界越界导致尾部信息被覆盖字符串缓冲区溢出是另一个高频问题。常见原因是循环中用strlen作为终止条件却忘了留\0的位置char buf[5]; for (int i 0; i 5; i) { buf[i] a; } buf[5] \0; // 越界写入数组大小是5索引范围应该只到4buf[5]已经踩到了数组外的内存。这种写入不会立刻报错但可能破坏相邻变量。调试过程中你会发现某些变量莫名其妙地“变天”却找不到写它的代码很可能就是这一类越界写入搞的鬼。更隐蔽的越界来自字符串操作函数的误用。例如strncpy不少文档说它“安全”但它有一个著名的坑如果源字符串长度大于等于 n它不会自动补\0复制的字符数填满目标缓冲区后就没有结束标志了。后面再用printf或strlen时就会读到缓冲区后面未知的内容。正确的做法是手动保证buf[n - 1] \0char buf[32]; strncpy(buf, src, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0;当然更好的做法是用snprintf实现单次安全的格式化写入。比如动态生成路径时我经常用snprintf(fullpath, sizeof(fullpath), %s/%s, dir, name);snprintf会保证最多写入size-1个字符并向末尾添加\0缓冲区边界的风险从根本上被处理掉了。5.3 返回值指向局部数组导致悬垂指针另一种常见的迷惑行为是函数返回局部数组名然后主调函数拿到一个“看着很正常”的地址char *get_name(void) { char name[16]; snprintf(name, sizeof(name), Alice); return name; // 警告返回了局部数组的地址 }name是栈上分配的空间函数返回后空间就会被回收地址内容随时可能被其他函数覆盖。多数编译器会给出警告但不会阻止编译。运行结果时对时错如果后面的调用没有立刻使用这块地址可能碰巧还能读到旧值一旦中间有其他函数调用内容就变成乱码或垃圾数据。我在实战中处理这种需求通常有三种方案调用方传入缓冲区函数把结果写到缓冲区里void get_name(char *out, size_t size) { snprintf(out, size, Alice); }返回static局部变量的地址但要注意单线程或明确生命周期约束因为同一时间只有一个结果可用const char *get_name(void) { static char name[16]; snprintf(name, sizeof(name), Alice); return name; }返回动态分配内存用malloc申请空间并约定调用方负责free。这种方式最灵活但也最容易被遗忘释放导致内存泄漏。5.4 字符串赋值和比较的语义误用字符串是数组所以不能直接用来给数组整体赋值也不能用来比较字符串内容是否为相等这两个问题在初学者中非常普遍。char a[] hello; char b[] hello; if (a b) { // 比较的是两个数组首元素的地址不相等 // 不会进入 } if (strcmp(a, b) 0) { // 正确方式比较内容 }使用strcmp时要留意两个完全相同的字符串内容返回 0返回大于0表示第一个字符串字典序更大。初学者容易混淆这个返回值语义写成if (strcmp(a, b))结果正好把内容相等当成了不相等。另外在比较之前要确保两端都以\0结尾。若涉及未知长度的外部数据最好先用strnlen或snprintf限制读取的字节数。在安全敏感程序中比如处理网络协议的字符串、文件名过滤等场景这个细节能避免很严重的问题。6. 实际开发中关于字符串处理的几条经验6.1 字符串固定缓冲区的最佳容量习惯我在自己的工程实践里有一个坚持了很多年的习惯所有字符串缓冲区定义时都要求保留1给\0比如某个字段最长是 8 个字符那就不写char buf[8]而是char buf[9]或char buf[16]。使用strncpy或snprintf时填入的容量写实际数组大小让你后续维护的人一眼就能看出来留了几字节的余量。警惕strlen(src) sizeof(dst) - 1的边界条件。很多业务逻辑都需要先检查长度再做拷贝不要跳过这一步。6.2 常用工具函数我自己实现的一套简单封装虽然C标准库提供了strcpy、strcat、strcmp、sprintf等接口但在实际项目中我一般会用一些更安全的封装。拿最常见的“字符串拼接”来说原生写法是strcpy(buf, part1); strcat(buf, part2);每次strcat都要扫描整个已有字符串找末尾位置在循环拼接时效率并不理想。更合理的做法是维护一个“当前写指针”char buf[256]; char *p buf; size_t remaining sizeof(buf); int n snprintf(p, remaining, %s, part1); p n; remaining - n; n snprintf(p, remaining, %s, part2); p n; remaining - n;snprintf返回值如果小于传入的容量表示没有截断剩余空间不足时再动态扩容或直接报错。我通常把这段逻辑抽成一个函数内部维护好长度和边界外部调用方只管往里塞字符串片段。我收藏的一个简化写法是int append_str(char *buf, size_t size, const char *src) { size_t len strlen(buf); if (len strlen(src) 1 size) { return -1; // 空间不足 } strcpy(buf len, src); return 0; }调用前要确保buf是合法字符串。不要把这个函数用在并发场景如果需要并发得加锁或者让缓冲区归单线程所有。这种工具虽小但能给团队省掉不少低级bug。6.3 关于字符数组与字符串的一个思考习惯当你拿到一个字符串处理的bug第一件事不是翻代码逻辑而是先问自己三个问题这个字符串是常量还是变量如果是常量别尝试改它。这段缓冲区是谁分配的栈上、全局区还是堆上生命周期覆盖到哪里这个函数拿到参数后有没有按“数组退化为指针”的规则来理解参数大小只要顺着这三个方向查一遍绝大多数字符串相关的问题都能定位到。我在带实习生时经常说C语言里最难的部分不是语法而是“内存归谁管、生命周期有多长、能不能被修改”这几个问题。这些东西在看代码的时候可以被快速提出来但真正形成底层直觉还是需要上手调试和踩坑积累。写在最后的几句个人体会从第一次被char *p hello; p[0]H折磨到后来写了大量嵌入式项目里的小型字符串工具我越来越确定一件事字符串与字符数组的差异不是概念悬空而是C语言内存模型的直接投影。搞懂它们不只是为了通过二级C语言考试也不只是为了应付面试里的八股题。字符串几乎渗透在C语言的每个方向从操作系统的文件名解析到嵌入式命令行的输入处理到协议栈里拼包解包再到文件读写时的一行一行处理全都在跟这两个概念打交道。回头再看文章开头那个段错误报错的代码只是典型的字符串常量修改。如果你能在脑子里快速把这个代码和“只读常量区”这个概念挂上钩很多问题就能瞬间想明白。希望这篇分享能帮你少走几步弯路。如果还有想不通的地方或者有别的字符串相关的经典坑欢迎随时交流。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Spring Boot + Vue家庭维修系统:源码部署与前后端联调实战 2026/9/30 11:02:34

Spring Boot + Vue家庭维修系统:源码部署与前后端联调实战

最近在帮别人整理一套“基于Spring Boot Vue的Web家庭设备维修服务系统”,光是看标题就知道,这不是一个只能跑个登录页的玩具项目,而是包含用户下单、维修工接单、管理员派单、服务评价、维修进度跟踪等完整业务流程的企业级教学项目。很多人…

阅读更多 →
上海 PE 收缩膜源头工厂推荐:上海睿越塑料,深耕长三角多行业包装 2026/9/30 11:02:27

上海 PE 收缩膜源头工厂推荐:上海睿越塑料,深耕长三角多行业包装

长三角地区水饮、食品、家具、日化等产业密集,PE 收缩膜作为外包装刚需,采购时优先选择本地源头工厂,既能保障交付时效、降低物流成本,又能方便上门验厂、及时响应产线调试需求。在上海众多塑料包装生产企业中,上海睿越…

阅读更多 →
TVA类人智眼实操指南(10):小样本学习与现场“自我进化” 2026/9/30 11:02:26

TVA类人智眼实操指南(10):小样本学习与现场“自我进化”

前沿技术探索:TVA智能体(简称TVA)TVA智能体(亦称“AI智能体视觉”)是依托Transformer架构与“因式智能体”理论构建的新型工业视觉系统,也是当前最具代表性的具身视觉技术之一。它有机融合深度强化学习&…

阅读更多 →
TVA类人智眼实操指南(18):为什么不用几万块的显卡也能跑得飞快? 2026/9/30 11:02:26

TVA类人智眼实操指南(18):为什么不用几万块的显卡也能跑得飞快?

前沿技术探索:TVA智能体(简称TVA)TVA智能体(亦称“AI智能体视觉”)是依托Transformer架构与“因式智能体”理论构建的新型工业视觉系统,也是当前最具代表性的具身视觉技术之一。它有机融合深度强化学习&…

阅读更多 →
人永远都不够用,事永远都没人做!二三十人的公司,都开始转不动... 2026/9/30 11:02:19

人永远都不够用,事永远都没人做!二三十人的公司,都开始转不动...

你是不是也有这种体会:招聘从来没停,但总感觉缺人手。三十来个员工,人人都喊忙,新增任务根本派不下去。客户消息积压无人回应,周报反复催促才能收齐,一份报价单流转四人依旧没人拍板;新人入职三…

阅读更多 →
VMware 仅主机(Host-Only)模式:虚拟机 ↔ 物理机互通完整教程 2026/9/30 11:02:12

VMware 仅主机(Host-Only)模式:虚拟机 ↔ 物理机互通完整教程

文章目录一、前置检查(Windows宿主机)二、配置IP,保证同网段方式1:DHCP自动获取(最简单)方式2:静态IP(推荐,IP固定,适合端口映射/文件共享)三、连…

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉