函数返回指针的四条死路:三条炸,一条最隐蔽
char*a(void){charbuf[32]benjamin;returnbuf;/* gcc 默认就警告address of local variable returned */}char*b(void){staticcharout[32]benjamin;returnout;/* 不报警告能跑但埋伏在后面 */}char*c(void){char*pmalloc(32);memcpy(p,benjamin,9);returnp;/* 也不报警告埋伏换了个地方 */}三个函数都编译通过前两个甚至只差一个 static。返回指针的路上编译器的警告只够吓退第一条后面三条一条比一条安静。上一篇讲过free 之后的堆内存是块没人认领的地悬空指针读旧值是赛跑结果。这一次进函数内部看指针从 return 交出来的四条死路与两条活路。一、死路一栈上的地址出函数就作废a()返回的是栈上数组buf的地址。局部变量活在它的栈帧里函数返回栈帧弹出那 32 个字节的官方租约就到期了。作废之后去哪儿了物理上哪儿也没去。字节还在原地只是这块栈空间已经被宣告自由下一次函数调用会在同一位置重建栈帧新帧的局部变量直接压在旧地址上。所以a()的返回值有时还能读出 “benjamin”——又是赛跑结果和篇 8 的悬空堆内存一个道理只是这里的下一个住客来得格外快你几乎做任何事打个 printf、调个库函数都会踩过那块栈。读出旧值算运气读出垃圾算正常最难受的是第三种新住客写进了结构相似的数据程序不崩、逻辑看似通只是某些值偶尔不对。这类 bug 的案发现场可能离案发时间隔了几百万次调用栈上不留任何痕迹。图函数返回后栈帧弹出下次调用在同一位置重建旧数据被直接覆盖顺带校准一个流行误区返回return hi不是这条死路。字面量本体在静态存储区篇 7 拆过C11 §6.4.5不随栈帧出入返回后一直有效。返回 static 缓冲虽也永远有效但内容可能被下一次调用覆盖return hi则连内容都稳如泰山是唯一无需附加条件的零风险返回靠的不是运气是存储位置。图四条死路与两条活路三条踩了就炸一条最隐蔽活路全靠写清所有权二、死路二static 不是解药是更深的坑很多人被栈吓过之后学聪明了加个 static变量进入静态存储区生命周期贯穿整个程序返回值永远有效。b()就是这么写的而且它确实永远有效。问题出在共享两个字上。static 缓冲全程序只有一份每一次调用都写同一块地char*upper(constchar*s){staticcharout[64];size_ti0;for(;s[i]i63;i)out[i](char)(s[i]as[i]z?s[i]-32:s[i]);out[i]\0;returnout;}printf(%s %s\n,upper(hello),upper(world));/* 打印 WORLD WORLD 或 HELLO HELLO两个一样反正不是你要的 */单看每次调用都对组合起来全错。实参求值顺序标准未规定C11 §6.5.2.2但无所谓无论谁先跑两次调用写的是同一块 staticprintf 真正打印时缓冲里只剩后跑那次的 “WORLD”或 “HELLO”。单线程都埋着这种雷多线程更不用说两个线程同时写一块无锁缓冲比覆盖更疯的输出都可能发生。这不是自研代码才有的坑标准库里有现成教材。asctime返回的字符串躺在静态缓冲里下一次调用asctime或ctime上一次的结果就被覆盖POSIX 明文允许strtok更进一步把解析进度存在静态存储里两处代码交替解析就会互相搅乱位置所以它线程不安全。glibc 提供strtok_r、C11 附录 K 提供strtok_s让调用方自带进度指针等于官方承认了这个设计的代价。图两次调用写同一块 static先到者的结果在第二次调用时消失自研接口的对应规约返回静态缓冲的函数文档必须写明下次调用失效调用方要长期持有拿到手就立刻拷贝。三、死路三与死路四所有权不明指向入参内部c()返回堆内存不炸也不覆盖看起来最体面。它的埋伏在接口之外调用方拿到指针第一件事该问这块内存归我管吗我要 free 吗没人回答。猜着不 free 的内存泄漏程序在长跑第 N 天被 OOM 杀掉猜着 free 的而这块内存还被第二个地方引用着free 完别处就是悬空指针double free 当场崩。同一个函数两种猜法两种死法。这是踩了就炸的第三条。死路四更绕返回的不是新内存是入参的内部。char*field_after(char*line){char*commastrchr(line,,);returncomma?comma1:NULL;/* 返回值是 line 的一段 */}返回值指向line中间某处。调用方按函数返回堆内存的惯性对它free砍掉的却是整条lineline悬空之前从它切出来的所有字段指针集体遭殃。一次 free多个受害者。分寸要钉准返回入参内部指针本身不是错strchr/strstr就是这么干的string.h的惯例一贯是不分配、不转移所有权。错在接口没说清返回值指向谁的内存、要不要 free。strtok是两宗罪集一身的教材解析进度藏进静态区死路二返回值又指向你的字符串内部死路四所以它才值得官方专门补救。四、两条活路把所有权写进接口活路一堆所有权移交。strdup 模式返回堆内存文档写明返回值由调用方 free所有权随指针一起过户/* 返回堆内存调用方负责 freeC23 已将 strdup 收编进标准库 */char*dup_name(constchar*s){size_tnstrlen(s)1;char*pmalloc(n);if(pNULL)returnNULL;/* 失败路径也要有约定 */memcpy(p,s,n);returnp;}活路二输出参数。调用方自己提供缓冲区函数只写不分配指针根本不出函数返回值改报错误码Linux 内核的接口大多长这样/* 调用方给缓冲函数只写返回 0 成功负数错误码 */intget_name(char*out,size_tcap){if(outNULL||cap9)return-1;memcpy(out,benjamin,9);/* 8 字符 \0长度跟着缓冲走 */return0;}标准库的现成范本是strftime缓冲、容量都由调用方给函数想写也写不越界。两条活路怎么选小而固定的数据时间戳、状态码字符串用输出参数栈上就地解决大小运行期才知道、或者需要跨层传递的数据用堆所有权移交。共同点只有一个分配和释放的责任必须有一条清晰的、写在注释里的主线。写在最后返回栈地址是死路帧销毁即悬空读旧值是赛跑不是承诺gcc 默认警告看见就修。static 返回值永远有效但随时被覆盖连续调用互相踩、多线程互相抢要长期持有拿到就拷贝。asctime/strtok是标准库的现成教材。返回堆内存必须写清所有权谁分配谁释放注释即契约strdup模式C23 已收编是最简洁的移交失败返回 NULL 也要写进约定。返回入参内部指针时声明不要 freestrchr惯例合法误会才是坑拿不准就改用输出参数 错误码调用方给缓冲函数只写指针不出函数。return hi永远合法字面量在静态存储区与栈帧无关——这是唯一无需附加条件的零风险返回指针。死路都绕开了还有一类代码把改指针本身写得战战兢兢删个链表头节点都要if特判它是不是头。Linus 眼里这是二流写法。下一篇《删除链表头节点Linus 只用了一个二级指针》看他怎么用一个间接指针消掉全部特例。同系列文章会陆续发布关注后即可收到更新。回复「交流」我很乐意分享我的学习经验和工作经验直接来聊。感谢阅读。转载请注明出处与作者。