
讲指针讲到第13天最常见的问题往往不是“指针怎么取地址”而是围绕两个点const到底修饰谁、字符串处理函数为什么动不动就崩。这两个知识点彼此独立但在实际工程里总是纠缠在一起——因为你处理字符串时经常会用到const限定符来保护缓冲区而字符串函数一不留神就会把栈搞乱。这篇就把这两块一次讲透顺便把最常见的崩溃场景和排查思路都整理出来。这篇内容适合三类人看正在啃C语言指针的初学者、刚接触嵌入式开发想搞明白指针细节的同学以及偶尔被char*和const逼疯的“老手”。看完你至少能回答const int *p和int *const p到底差在哪、strcpy为什么危险、字符串字面量为什么不能乱改、遇到段错误怎么从指针角度快速找原因。1. 先看底层逻辑const究竟在限定谁四种指针组合一次讲清1.1 判断技巧看const离哪个成分近很多人记不住const和指针的各种组合是因为他们在背结论而不是看机制。C语言的规则其实很简单const修饰的是它左边最近的那个类型或标识符如果左边什么都没有就修饰右边的类型。基于这个规则所有指针const组合都能现场推导出来不用背。先看最典型的两组const int *p; // p指向一个const int*p只读p本身可以指向别处 int const *p; // 和上面完全等价const修饰int int *const p; // p本身是constp不能再指向别处但*p可以修改判断口诀就一句话const在左边修饰的是指向的数据const在右边修饰的是指针本身。const int *pconst离int近意思是你通过p读到的那个int是只读的但p这个变量本身是一个普通指针你可以让p指向其他int变量。int *const pconst离p近意思是p这个指针变量不可改但通过p访问的int数据完全可以修改。第三种const int *const p数据和指针都不可改等价于int const *const p。我用一张表把这四种情况列清楚声明写法能否修改指针换指向能否修改指向的数据典型使用场景const int *p可以不可以读取只读缓冲区、遍历字符串字面量int const *p可以不可以等同于上一种个人风格差异int *const p不可以可以固定指向寄存器地址或固定数组const int *const p不可以不可以保护不可变全局配置表、表项本身只读这个判断方法在写指针数组、函数指针、二级指针时同样适用。比如const char *strs[]里数组每个元素是指向const char的指针字符串内容不能改而char *const strs[]表示指针数组本身的每个元素不可改但每个指针指向的字符串内容可以改。这两种声明在项目里经常被搞混一旦选错编译警告能绕晕你。1.2 为什么项目里离不开const指针以及强制转换的隐患const结合指针最大的价值在于“代码自文档化”和“编译器帮你查错”。最经典的场景就是函数形参。// 声明这种签名一眼就能看出函数不会修改传入的缓冲区 size_t my_strlen(const char *s); void print_msg(const char *fmt);只要参数是const char *调用者就知道不会改我传进去的数据同时编译器也会拦截那些试图修改传入内容的代码。这种设计在现代C库、嵌入式固件、协议栈里到处都是。嵌入式场景里还有一个高频组合volatile uint32_t *const reg_addr。const限定的是指针本身告诉编译器“寄存器地址固定不变”volatile限制编译器不要乱优化对这个地址的读写。比如操作STM32的外设寄存器这种写法能有效避免程序被优化得莫名其妙。这里必须提醒一个反模式const不是绝对的铁壁。通过强制类型转换可以把const丢掉const char *msg dont touch; char *p (char *)msg; // 绕过了const保护 p[0] D; // 未定义行为很可能段错误这种做法在C标准里是未定义行为尤其在字符串字面量分配到只读数据段时一写就崩。就算编译运行看起来正常换编译器、换优化级别、换平台随时可能翻车。项目代码里不要写这种东西除非你明确知道那块内存本身是可写区域并且你有极充分的理由。2. 字符串处理函数的底层模型绕过“不及时补\0就出事”的坑2.1 字符串的存储本质以及sizeof和strlen的区别在C语言里字符串其实就是一块连续内存加上末尾的一个\0。所有字符串函数都依靠这个结束符判断终点。没有结束符strlen会沿着内存一直找下去直到某个字节恰好是0为止——轻则读越界重则造成不可预期的数据污染或崩溃。先看一个经典错误char buf[5]; strcpy(buf, hello); // hello实际占6字节含\0buf只有5字节strcpy会把源串的6个字节全部拷贝到目标地址第6字节溢出了buf边界覆盖到相邻内存。这类问题在栈上表现为相邻变量被莫名奇妙改写在嵌入式系统里可能会覆盖到别的全局变量甚至函数返回地址。关于数组大小理解sizeof和strlen的差异能避免很多误判char str[] abc; // 数组大小是4a b c \0 sizeof(str); // 4包含结尾0 strlen(str); // 3只统计到\0之前的字符用字符指针声明时情况又不一样const char *s abc; sizeof(s); // 864位系统上指针自身大小 strlen(s); // 3因为s只是一个指针sizeof返回的是指针本身的字节长度而不是字符串的长度。计算缓冲区大小时必须以strlen(src) 1为准多出的1字节就是给\0的。2.2 strlen、strcpy、strncpy、strcat、sprintf五个高频函数逐个拆解strlen只负责数到\0为止。它默认你传进来的指针一定指向合法且能访问的内存并且这块内存一定有一个0结尾。如果指针未初始化就往里传它就会像没头苍蝇一样乱闯。strcpy拷贝到目标缓冲区时会把源串的\0一起带上。目标必须至少能容纳strlen(src) 1个字节。这个“1”是初学者最容易漏掉的。strncpy它看起来比strcpy安全但有个反直觉的坑当第三个参数n小于等于源字符串长度时strncpy不会自动补\0。结果就是目标缓冲区末尾没有结束符后续一旦用printf(%s, buf)或strlen(buf)就会越界读取。标准补救方式是在目标数组最后手动补一个0。char buf[16]; strncpy(buf, a very long string, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0; // 手动补0保证字符串正常终止这也是很多规范里要求“使用strncpy之后必须手动设置最后一个字节为0”的原因。strcat内部逻辑是先找到目标串的\0然后把源串内容从那个位置开始复制。危险在于它同样不会检查目标缓冲区剩余空间。如果目标已经快满了再strcat就会越界。建议改用snprintf或者自己写一个带长度检查的连接函数。sprintf把格式化内容写入缓冲区也不做长度检查。很多人会用char buf[100]; sprintf(buf, value%d, x);一旦格式化结果超过100字节照样悄悄越界。C99提供了snprintf正确用法是char buf[100]; snprintf(buf, sizeof(buf), value%d, x);它在输出达到缓冲区上限时自动截断并且在末尾补\0工程上应当优先选择。还有一个经常遇到的fgets。它比gets安全得多但有个小尾巴——它会保留换行符。用完之后通常要手动把结尾的\n删掉char line[128]; if (fgets(line, sizeof(line), stdin)) { line[strcspn(line, \n)] \0; // 去掉末尾换行 }3. 字符指针的两种身份字符串字面量只读字符数组可改3.1 字符串字面量的“只读陷阱”在C语言里直接写在代码里的字符串字面量比如hello是存放在只读区域还是可写栈区标准并没有硬性规定但绝大多数编译器都把它们放在只读数据段。这就引出了那一道经典送分送命题char *p hello; p[0] H; // 未定义行为常见结果之一是段错误因为p指向的是只读区域的字符串字面量修改它就是在改只读内存。正确写法是把字符串放进可写的字符数组里char arr[] hello; arr[0] H; // 合法因为这4字节存在栈上不少人在单片机或者嵌入式环境下也遇到过这种问题尤其是用Keil MDK调试时反汇编能看到字符串字面量放在.rodata段修改这种地址的内容要么被忽略要么触发硬件异常。所以一个很实用的习惯是只要不打算修改字符串内容统统用const char *接收字符串字面量。这样写代码别人一看就知道是只读用途编译器也会帮你拦截意外修改。3.2 const在函数传参中的兼容规则函数参数里const char *和char *之间有一条方向性规则char *可以隐式转换成const char *反过来不行。void take_const(const char *s); // 你传char*进来完全没问题 void take_char(char *s); // 你传const char*进来会编译警告/错误这种设计是有道理的允许从char *到const char *的安全收缩因为把可变数据当成只读数据使用不会破坏任何东西但不允许从const char *到char *因为那等于把一个承诺只读的东西交给一个可能修改它的函数破坏const语义。实际项目中如果某个库函数声明的是char *参数而你又只有一个const char *实例不要硬着头皮强制转换。正确的做法是先拷贝到本地字符数组再调用函数const char *src hello; char tmp[16]; strncpy(tmp, src, sizeof(tmp) - 1); tmp[sizeof(tmp) - 1] \0; take_char(tmp);这个兼容规则在指针数组、二级指针场景里也一样char **不能直接传给const char **因为中间隔着两层指针时会引入“绕过const”的漏洞。这也是C语言const设计里最微妙的部分碰到编译器报错时不妨想想是不是这里绕了一层。4. 常见错误与调试实录string相关段错误的快速排查4.1 从“灵异现象”定位到指针问题的三个真实案例我见过太多初学者遇到程序崩溃时一脸懵其实崩溃的根源多数逃不过指针未初始化、数组越界、字符串结束符缺失这三类。分享几个代表性的场景。场景一字符串逆序作业。不少人写交换循环时用for(i0; ilen; i)结果把每个字符交换两遍字符串又变回原样或者处理边界时len-1-i在空字符串时变成负数把无关内存改掉。这类问题的排查思路是先用纸笔画一下一个5字符的字符串首尾交换需要循环几次答案是len/2次不是len次。场景二scanf(%s, buf)输入过长导致缓冲区溢出。很多小练习平台用这个写法输入稍长一点就把栈上的其他变量覆盖掉程序行为变得“毫无逻辑”。更稳的写法是char buf[32]; scanf(%31s, buf); // 明确限制最大读取字符数或者直接改用fgets从源头限制输入长度。场景三局部字符指针未初始化就调用strcpy。指针变量是自动变量时初始值是随机垃圾值直接strcpy等于往一个未知地址写数据大概率段错误。排查时先打印指针地址看一下是不是0x0或者明显不合法的地址char *dst; printf(dst %p\n, (void *)dst); // 很可能是垃圾值 strcpy(dst, hello); // 崩溃正确做法是先给dst分配空间要么用数组要么用malloc并检查返回值。4.2 常见问题速查表现象可能原因排查建议段错误退出码139指针未初始化、字符串越界、访问只读区先打印指针值再用gdb看崩溃调用栈变量被“灵异”修改数组越界或字符串溢出覆盖相邻变量检查strcpy/strcat/sprintf目标缓冲区大小printf输出乱码或尾部带垃圾字符串缺少\0手动在目标缓冲区末尾补0编译警告“discards const”把const char传给char形参拷贝到本地可写数组后再传修改字符串字面量后崩溃对只读段内存执行写入使用char数组存储字符串再修改调试字符串和指针问题时我最常用的手段就两个一是打印指针值和关键变量的地址确认“它到底指向哪、是不是我要的那块内存”二是用调试器的Watch窗口盯着指针变量单步走一遍拷贝过程。这些手段在纯软件的gdb里适用在STM32开发环境的调试器里也一样适用。给单片机调试一个额外提示如果用的是MDK这类IDE可以在Watch窗口直接添加字符串首地址的指针变量然后按字符数组方式查看内存内容确认每个字节是否符合预期。这样做可以很直观地判断是否发生了越界写入或者结束符丢失。最后分享一点个人体会指针、const和字符串处理这坨东西光看语法是记不住的真正让我彻底搞懂的是自己在调试器里一步步看数据变化。有一回排查一个“函数返回后局部变量被篡改”的bug折腾了半天才发现是前一个函数里strcpy多写了一个字节把下一个函数的栈帧污染了。从那以后我写任何涉及字符串的代码第一件事就是算清楚目标缓冲区到底能装多少字节、\0放在哪个位置。如果你现在还在被const和字符串函数折磨建议你打开调试器写几段小代码分别验证字符串字面量地址能不能写、strncpy不补0的后果是什么、指针数组用const限定元素还是指针本身。自己动手验证一遍比背十遍书都有用。这套东西一旦通了后面学函数指针、二级指针、链表、协议栈解析都会轻松一大截。