ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

char、char*、char**区别详解:从内存布局看懂指针本质

char、char*、char**区别详解:从内存布局看懂指针本质 1. 先别急着背定义你知道 char 在内存里到底长什么样吗我记得自己刚学 C 语言那会儿最崩溃的一句话就是char 是字符型char* 是字符指针char** 是二级指针。背是背下来了但做题该错还是错代码该崩还是崩。后来才明白指针这东西文字定义永远背不熟必须回到内存里去看它到底是个什么东西。所以这篇入门级文章我不打算上来就给你罗列三条干巴巴的区别而是带着你从内存分配的角度把 char、char*、char** 这三个家伙一层一层剥开。先说清楚这篇文章适合谁。如果你是刚接触 C 或 C 的新手看到指针就头皮发麻或者已经学过一段时间但始终搞不清字符串、字符数组和二级指针之间的关系那这篇文章就是给你写的。我会尽量少用教科书式的术语多用内存格子门牌号箭头这类生活化的说法把每个概念落到实处。等你看完这篇文章至少能做到拿到一个带 char 和星号的声明能在草稿纸上画出它的内存布局能说清楚它占几个字节、存的是什么东西、解引用一次拿到什么。为什么要纠结这三个类型因为它们在 C/C 这门语言里太基础了。基础到你只要写代码就避不开单字符变量用 char字符串用 char*字符串数组、函数参数里想修改指针本身就得用 char**。搞不懂它们你对指针的理解就永远是飘着的。而指针恰恰是 C/C 区别于其他语言的核心能力也是面试、考试、实际开发里的分水岭。很多人说 C 语言难难就难在指针但指针一旦通了后面学数据结构、学内存管理都会顺很多。那我们就从最底层的 char 开始把地基打牢。1.1 一个 char 变量在内存里就是一个小格子在 C/C 里char 通常占用 1 字节8 个二进制位这也是 C 标准里最小的可寻址存储单元。你可以把它想象成一个只有 8 格的小储物柜每一格能存 0 或 18 格合起来能表示 0 到 255 一共 256 种组合。那这 256 个组合对应什么呢正好对应 ASCII 字符表。比如你写char ch A;这一行代码发生的事情是编译器在内存里找一个空闲的字节往这个字节里写入二进制01000001也就是十进制的 65。为什么要存 65因为 ASCII 表规定了大写字母 A 的编号就是 65。所以你看char 本质上就是一个占 1 字节的整数A 只是这个整数 65 在人类世界的显示形式。你可以用%c把它当字符打印用%d把它当整数打印结果一个是 A一个是 65但内存里的二进制完全一样。这一点一定要想透因为后面很多坑都是从这里来的。比如有人说char 只能存字符这个说法不算错但不严谨。char 可以存字符也可以在小范围内当整数用。很多老代码里会拿 char 做循环变量、做小范围计数器虽然现在不推荐了容易溢出和符号出问题但你能看到这种用法存在就是因为 char 背后就是整数。再补充一个细节char、signed char、unsigned char 在 C 标准里是三种不同的类型。char 到底是有符号还是无符号不是由标准规定的而是由编译器实现决定的。x86 平台上大多数编译器把 char 默认成 signed char但部分嵌入式平台比如某些 ARM 架构的芯片可能会把 char 默认成 unsigned char。这就导致一个很隐蔽的问题如果你拿 char 去存 ASCII 值小于 128 的字符两种都无所谓但你要拿 char 存大于 127 的数值或者特殊扩展字符那就得小心了——在 signed 平台上跑可能是个负数在 unsigned 平台上又是一个正数程序行为会出现差异。所以工程上如果你想明确用 char 做小范围整数运算最好直接写成 signed char 或 unsigned char别裸写一个 char 让它跟着平台随缘。1.2 字符数组一堆连续的小格子单个 char 只能存一个字符不够用怎么办那就申请一排连续的小格子这排小格子就是字符数组。最常见的就是字符串的存储形式char str[6] hello;这里有个新手特别容易忽略的点数组长度为什么是 6不是 5因为字符串 hello 有 5 个可见字符但 C 语言规定字符串末尾必须有一个字符串结束标志\0ASCII 码为 0 的空字符。也就是说hello 在内存里实际是 6 个字节h、e、l、l、o、\0。没有这个\0编译器就不知道字符串在哪里结束各种字符串函数strlen、strcpy 等就会一直往后读内存直到碰上一个碰巧为 0 的字节轻则读到脏数据重则越界崩溃。那字符数组和 char 指针有什么关系别急这正是下一节要讲的重点。我先把结论放在这里数组名在绝大多数表达式中会退化成一个指向首元素的指针。也就是说str这个名字在很多场景下等价于一个char*类型的值它存的是数组第一个元素str[0]的地址。这就是为什么你能写printf(%s, str)因为%s期望的是一个指向字符串首个字符的指针而你传进去的数组名正好退化成了这个指针。所以你看char 和 char* 之间的桥梁其实就是数组名退化成指针这个机制。一个字符串在内存里是一串连续的小格子而你要操作这串格子靠的是记住第一个格子的地址。谁帮你去记这个地址char*这个箭头。2. char*它不是字符串它是一根箭头很多新手对char*最大的误解是把char*和字符串画等号。严格来说char*是一个指向 char 类型数据的指针变量它存的不是字符串本身而是字符串第一个字符所在的内存地址。你可以把它理解成一根箭头箭头指向的地方才是真正的字符数据。这个区分特别重要因为它在以下几个地方直接决定了你的代码会不会崩sizeof的结果天差地别能不能通过这个指针去修改内容传参时拷贝的是地址还是数据2.1 指针变量本身一个装着门牌号的盒子咱们用生活化的类比再走一遍。假设内存是一栋大公寓楼每个字节是一个房间。char ch A 就好比在 0x100 这个房间里住了个 65 号住户A。现在你写char* p ch;这一步做了什么它相当于在另一个房间假设是 0x200里放了一张便签便签上写的是0x100。p这个变量的内存位置是 0x200它里面存的值是 0x100而不是字符 A 本身。你想通过 p 找到 A就得顺着便签去 0x100 房间看这个顺着找的动作就是解引用printf(%c, *p); // 输出 A这里*p表示读取 p 指向的那个房间里的内容。那么p本身存在哪里存在 0x200。那p取出来的是谁是 0x200也就是 p 自己的房间号。好了如果你把这个p再存到一个新变量里那个新变量是什么类型就是char**。你看二级指针就这么顺理成章地出现了。回到char*本身。它只是一个指针变量在常见的 64 位系统上无论它指向什么类型指针变量本身都是 8 个字节32 位系统上是 4 个字节。这 8 个字节存的是一个内存地址。所以当你写出char* q;这个 q 在没初始化之前里面是一堆随机值谁也不知道它箭头指哪。你要是贸然*q A轻则段错误重则把别的程序的数据改坏。这就是新手最常踩的第一个大坑未初始化的野指针。后面我也会专门讲。2.2 字符串字面量与 char*这里有个 const 的坑现在来看字符串字面量。你写char* str hello;的时候真正发生的事是编译器在程序的只读数据区通常叫 rodata 段放了一串字节h e l l o \0然后把首字符地址返回给 str。问题在于这串字节是只读的。你要是试图通过 str 去修改它比如str[0] H;在大多数现代编译器/操作系统上会导致运行时崩溃或者未定义行为。但char arr[] hello;就不一样。虽然看起来都是把字符串给了变量但数组版本是在栈上开辟了一块 6 字节的空间把字符串内容逐字节拷贝进去这块空间是可读可写的。所以arr[0] H;是合法的。这两者的区别用一句话记就是char*指向一个只读的字符串常量时你只有读的权限char[]自己拷贝了一份数据这块数据归你想怎么改都行。在规范上C 语言其实要求字符串字面量的类型是char[]但实际内容却是 const 修饰的东西这就造成了一个历史包袱而 C 里字符串字面量类型被直接规定成了const char[]。所以你看 C 代码里很多人写const char* str hello;这才是更规范、更不容易出错的写法。如果你在 C 里直接写char* str hello;编译器甚至会直接警告甚至报错。那什么时候用char*去操作一个可变的字符串呢正常做法是让指针指向一个你自己分配的数组char buf[128]; char* p buf; // p 指向 buf 的首地址 strcpy(p, world);这里的 p 就是你手里的那根箭头指向的地盘是你自己的你可以自由修改。这也是后续学习动态内存分配的基础char* dynamic_str (char*)malloc(64 * sizeof(char)); if (dynamic_str ! NULL) { strcpy(dynamic_str, dynamic); } free(dynamic_str);malloc 返回一个 void*在 C 里可以隐式转换成 char*在 C 里需要显式强转。这里可以看到动态内存分配的返回值和字符串函数几乎都依赖char*这个类型这是它使用最广泛的场景之一。3. char**一个指向箭头的箭头到底什么时候用到了char**很多新手就开始懵了。其实只要想明白一件事char*和char**的关系不过是char和char*的关系的又一次重复。如果一个char*变量是一根箭头里面存着字符的地址那char**就是一根指向箭头的箭头里面存着那根箭头变量的地址。还是用便签类比char 是房间里住的人char* 是一张写着房间号的便签char** 是一张写着便签放在哪个抽屉里的备忘。3.1 二级指针的内存布局来写个最简单的例子char ch A; char* p ch; char** pp p;你猜这三行代码在内存里是什么样子ch 在地址 0x100里面存 65。p 在地址 0x200里面存 0x100。pp 在地址 0x300里面存 0x200。画出来就是内存地址 内容 说明 0x100 65 ch 本身存的是字符 A 的 ASCII 码 0x200 0x100 p 存的是 ch 的地址 0x300 0x200 pp 存的是 p 的地址所以pp是二级指针类型是char***pp拿到的是 0x200 地址处的内容也就是 0x100等价于p**pp拿到的是 0x100 地址处的内容也就是 65等价于ch这个链条很清晰每一次解引用就沿着箭头往下走一层。你可能会问谁会闲着没事这么绕一圈有实际意义吗有而且很常见。3.2 场景一在函数里修改调用者的指针这是二级指针最经典的应用场景。假设你在函数里要分配一块内存并且希望调用者拿到的指针变量也被更新。试试看void wrong_alloc(char* ptr) { ptr (char*)malloc(100 * sizeof(char)); } int main() { char* buf NULL; wrong_alloc(buf); // 问题buf 还是 NULL return 0; }为什么不行因为 C 语言函数参数是值传递。wrong_alloc(buf)实际上是把 buf 这个变量里的值也就是 NULL0拷贝了一份传给形参 ptr。函数内部修改的是 ptr 这个局部变量让它指向 malloc 出的内存但这不会影响主函数的 buf。函数一返回ptr 这个局部变量就销毁了malloc 出来的内存地址也丢了——内存泄漏buf 依然是 NULL。要解决这个问题就得把 buf 自己的地址传进去也就是在调用处写buf那形参的类型就是char**void right_alloc(char** ptr) { *ptr (char*)malloc(100 * sizeof(char)); } int main() { char* buf NULL; right_alloc(buf); // 现在 buf 指向了 malloc 出来的内存 free(buf); return 0; }这里*ptr malloc(...)实际上就是往主函数 buf 变量的内存位置写入 malloc 返回的地址。因为函数拿到了 buf 的地址所以能通过这个地址去修改 buf 的内容。这就是二级指针存在的核心意义当你想让被调函数修改调用者的指针变量本身时就必须传指针的地址即二级指针。同样的道理也适用于链表的头节点操作、树节点的插入删除等。很多数据结构的教科书里函数参数动不动就是Node**新手看得一头雾水归根到底就是这个原因传一级指针只能改指针指向的内容传二级指针才能改指针本身的值。3.3 场景二指针数组与二维字符串另一个常见场景是字符串列表。如果你有一组字符串比如一堆文件名、配置项最直观的写法是char* names[3]; names[0] Alice; names[1] Bob; names[2] Charlie;这里names是一个数组每个元素是一个char*。数组名names在表达式中退化为什么类型对——char**。因为首元素类型是char*所以指向首元素的指针就是char**。这个数组和char**的关系可以这样理解names 是一片连续的内存区域区域里存了 3 个 8 字节的指针值分别指向三个不同的字符串常量区。char**箭头指向这片区域的起始位置也就是第一个元素names[0]的地址。如果你想把这样一个指针数组传给函数去统一处理函数形参就可以写成char** arr。比如写一个简单的打印函数void print_all(char** arr, int count) { for (int i 0; i count; i) { printf(%s\n, arr[i]); } } int main() { char* names[3] {Alice, Bob, Charlie}; print_all(names, 3); // 数组名退化成了 char** return 0; }再进一步动态创建二维字符串列表在不知道有多少个字符串、每个字符串多长的情况下就需要先malloc一批char*再给每个char*分配存储空间。这整个过程里char**就是那个指向指针数组首地址的句柄是整片动态字符串矩阵的入口。所以你看二级指针并不是炫技它就是指针数组和在函数中修改指针这两个场景的客观需要。4. 区别与联系一张对照表加一条层层包裹的线索前面三章已经把每个类型单独讲透了。这一章把三者放到一起对比帮你把零散的知识钉在一个框架里。4.1 对照表声明、含义、内存占用与应用场景声明含义自己占多少字节64位系统解引用一次拿到什么解引用两次拿到什么典型用途char c一个字符变量1 字节--存单个字符、小范围整数char* p指向字符的指针8 字节char 值-字符串、动态字符数据、函数参数char** pp指向字符指针的指针8 字节char* 值char 值指针数组、修改调用者的指针这张表对应的就是我在前面反复强调的那句话变量自己占多大空间和它指向的对象占多大空间是两码事。char*不管指向的是 1 字节的字符还是 100 字节的字符串它自己永远是 8 字节64 位系统char**自己也是 8 字节。4.2 从 char 到 char* 再到 char**一层套一层的俄罗斯套娃三者之间的联系本质是一个层层包裹的关系char是那个真正存放字符数据的小盒子。char*是一根指向小盒子的箭头。箭头本身也是一个变量也有自己的内存位置。char**是一根指向箭头的箭头。因为你有了箭头的内存位置你就能在这根原箭头上做文章——比如把原箭头擦掉重新指向别的盒子。实际编程中几乎不存在为了好玩而刻意用二级指针的场景。二级指针的出现往往是因为你确实需要修改一个已有指针变量的值或者你面对的本来就是一个装着指针的数组。从数据结构的视角看链表里用struct Node**做插入操作、哈希表里用const char**保存键列表本质上都跳不出这两个模型。所以你不需要背一堆二级指针的魔法用法只需要抓住一条当你见到char**第一反应应该是这里可能要改一个 char* 变量的值或者这里有一个 char* 数组。还有一种理解方式是从多级指针的通用规律来看T**就是从T*再取一次地址。把 T 换成 char 就是char**把 T 换成 int 就是int**规律完全一样。二级指针学通了三级甚至更多级指针对你来说就只是一个机械重复的再套一层没什么新鲜的。4.3 数组名退化和 运算符之间的微妙关系这里有一个非常考验理解的细节也经常被人搞混。char* arr[3]里的arr退化成char**那么arr呢注意arr的类型是指向整个数组的指针在 C 语法里要写成char* (*p)[3]这是一种数组指针pointer to array它的值虽然和arr退化的地址数值一样但类型完全不同。如果你试图用一个char**去接arr编译器会警告类型不匹配。为什么会这样因为 C 的类型系统是严格的地址的数值一样不代表类型一样更不代表你能对它们做相同的操作。比如arr[1]是取数组第二个元素的地址而声明为char** p arr的话p[1]会按照跳过 8 字节去取下一个char*这看起来很合理但在语法层面arr的类型是数组指针不是char**。所以规范的写法是char* (*parr)[3] arr;这种细节对入门阶段来说可能有点超前但我建议你有印象因为很多老手在这里也会栽跟头。这里也顺便回应了题目里的联系二字char、char*、char** 之间不是三个孤立的概念而是通过取地址 和解引用 *这两个操作互相转换、层层包装的关系。5. 实战演练从单字符到动态字符串列表把前面讲的串起来理论部分告一段落。这一章我用三个由易到难的完整示例把 char、char*、char** 全部揉在一起让你看看它们到底怎么配合工作。5.1 入门示例用 char 读入并统计字符个数#include stdio.h int main() { char ch; int letters 0; int digits 0; printf(请输入一串字符以回车结束); while ((ch getchar()) ! \n) { if ((ch a ch z) || (ch A ch Z)) { letters; } else if (ch 0 ch 9) { digits; } } printf(字母个数%d数字个数%d\n, letters, digits); return 0; }这个程序没有用任何指针完全靠char完成。它的核心价值在于让你直观地体会到char 变量在循环里一次只拿一个字符字符本质上就是一个可比较的整数。*p也好**pp也好最终落脚到的是一个具体的字符值——也就是这里的ch。5.2 进阶示例用 char* 遍历和修改自己的字符串#include stdio.h int main() { char content[] hello world; char* cursor content; // cursor 指向数组首元素 // 统计并修改所有空格为下划线 int spaces 0; while (*cursor ! \0) { if (*cursor ) { *cursor _; spaces; } cursor; // 指针向后移动一个字节 } printf(处理后的字符串%s\n, content); printf(空格数量%d\n, spaces); return 0; }这段代码虽然短信息量不小content是一个字符数组在栈上占了 12 个字节hello world 11 个可见字符加 1 个\0。char* cursor content利用了数组名退化为指针的规则。*cursor解引用拿到当前字符。cursor让指针挪到下一个字节这依赖于 char 是 1 字节大小所以指针加 1 正好跳到下一个字符。这里能清楚看到char*的灵活性你可以拿着它遍历字符串、判断每个字符、甚至修改它指向的内容。但前提是内容本身可写所以这里的 content 是数组而非字符串字面量。5.3 综合示例用 char** 实现可动态增长的字符串列表最后来一个三级跳把指针和动态内存分配都用上。要求从键盘读入若干行字符串每行存为一个字符串最后统一打印。#include stdio.h #include stdlib.h #include string.h int main() { char buf[256]; char** lines NULL; // 指向指针数组的首地址 int capacity 0; int count 0; printf(逐行输入空行结束\n); while (1) { // 注意不考虑超长行的情况方便演示 if (fgets(buf, sizeof(buf), stdin) NULL) { break; } // 去掉末尾换行符 size_t len strlen(buf); if (len 0 buf[len - 1] \n) { buf[len - 1] \0; } if (buf[0] \0) { break; } // 空间不足时扩容 if (count capacity) { int new_capacity capacity 0 ? 4 : capacity * 2; char** temp (char**)realloc(lines, new_capacity * sizeof(char*)); if (temp NULL) { perror(realloc); break; } lines temp; capacity new_capacity; } // 为每一行单独分配存储空间 lines[count] (char*)malloc((len 1) * sizeof(char)); if (lines[count] NULL) { perror(malloc); break; } strcpy(lines[count], buf); count; } printf(\n总共读入 %d 行\n, count); for (int i 0; i count; i) { printf(第 %d 行%s\n, i 1, lines[i]); } // 释放先释放每行再释放指针数组 for (int i 0; i count; i) { free(lines[i]); } free(lines); return 0; }这个程序里lines就是char**。我们来拆解一下它每一层在干什么lines自己是一个char*类型变量或者更准确地说是一个指向char*数组首元素的指针。开始是 NULL。realloc扩容时重新分配了一块连续内存里面每个元素都是一个char*8 字节这些char*将来分别指向每一行字符串。lines[count] malloc(...)这里lines[count]等价于*(lines count)它访问到的是指针数组里的第 count 个元素这个元素本身是一个char*所以可以直接被赋值成 malloc 返回的地址。最终释放时要先把每个char*指向的字符串内存释放掉再把lines这个指针数组本身的内存释放掉。顺序不能反法则就是先里后外和套娃一层层拆开的逻辑完全一致。如果你在函数里封装这段动态扩容逻辑比如void add_line(char*** lines, int* count, int* capacity, const char* text)那形参还得再加一层***因为在函数里要修改调用者的lines这个二重指针本身。这进一步验证了前面的规律想修改某个指针变量的值就要把那个指针变量的地址传进去多一级星号。不过这个是后话入门阶段能把char**用明白已经很不错了。5.4 一个小测试检验你是不是真的理解了三层关系不看代码执行结果先自己判断下面这段程序会输出什么#include stdio.h int main() { char letter X; char* ptr letter; char** pptr ptr; **pptr Y; printf(%c\n, letter); printf(%c\n, *ptr); return 0; }想明白了再看答案会连续输出两个 Y。因为**pptr Y实际上就是从 pptr 出发先解引用拿到 ptr 的值再解引用拿到 letter 的地址然后往里写入字符 Y。这条链路走通之后你对 char、char*、char** 的掌握就基本过关了。6. 新手最容易踩的 4 个坑每一个我都见过最后聊点实战中实实在在踩过的坑。这些东西教科书上不会强调但基本每个初学者都会经历。我把最常见的几种列出来你遇到类似现象时可以快速对照。6.1 试图修改字符串字面量char* p hello; p[0] H; // 崩溃因为 p 指向只读区这种代码在不少老式教材里甚至出现过但在现代平台上十有八九直接段错误。牢记如果初始化指针时指向一个字符串字面量默认这个字符串是只读的。想修改字符串内容就用char arr[] hello;让编译器给你拷贝一份。这也是我反复强调char* 不等于字符串的原因——你得知道这个字符串到底住在哪里、有没有写权限。6.2 sizeof 结果和直觉不符char str[] hello; char* p str; printf(%zu\n, sizeof(str)); // 6整个数组的大小含结尾的 \0 printf(%zu\n, sizeof(p)); // 8指针自身的大小 printf(%zu\n, strlen(str)); // 5不包含 \0如果你分不清数组和指针这三个值会对不上号。sizeof(str)是数组一共占多少字节sizeof(p)是指针变量自己占多少字节strlen(str)是走到\0之前有多少个字符。在写代码时如果误把sizeof(p)当成 p 指向的字符串长度就会在分配内存时少算一大截引发缓冲区溢出。这个坑在编码时非常隐蔽尤其当你把 char* 和 char[] 混着用时一定要分清楚。6.3 用一级指针在函数里修改指针失败前面已经详细讲过void my_malloc_fake(char* p) { p (char*)malloc(32); }这是典型的函数里改了没用的代码。新手写完发现主函数里还是 NULL就一脸蒙。解决方式就是上二级指针。这个坑的通用化表述是C 语言参数默认是按值传参函数对形参的修改不会影响实参。你希望修改实参变量本身的值就必须传实参的地址。记住这一条你就能解释很多看似奇怪的现象不只是 char*int、结构体指针也都一样。6.4 忘记给二级指针分配指针数组这一层直接赋值有同学知道要用char**但这样写char** words; words[0] (char*)malloc(20); // 错words 还没有指向合法的内存原因很简单words 只是一个指针变量它没有指向任何已分配的内存块。你要用它当指针数组用得先给它分配一块足以容纳多个char*的内存char** words (char**)malloc(3 * sizeof(char*)); words[0] (char*)malloc(20);这个错误的本质是我要在 words[0] 对应位置写一个地址但 words 本身没有初始化等于往随机的地址写数据不崩才怪。凡是char**配合动态分配都要先分配外层指针数组再分配内层每个字符串。释放时反过来先 free 内层再 free 外层。这个顺序背下来基本就不会因为char**发生内存泄漏或 double free 了。写在最后的一点个人体会我见过不少同学在学指针的时候喜欢死记指针就是地址二维数组就是二级指针这种结论。但在我看来这些说法太容易误导人了。真正靠谱的学习方式就是像我上面这样遇到一个指针先在草稿纸上画出它占几个字节、它的值是多少、它指向的对象是什么。画多了你自然就能一眼看出char*和char**的区别了。尤其是遇到参数到底该传一级还是二级指针的问题你只要问自己一句我要不要改变这个指针变量本身的值答案立刻就出来了根本不用背规则。入门阶段不用追求一次把所有细节都吃透。先抓住char 是 1 字节数据char* 是存地址的箭头char** 是指向箭头的箭头这个主框架再通过写代码、调试、看内存地址去加深理解。等你哪一天盯着调试器里那一堆十六进制地址不再觉得头晕反而觉得每一层都清清楚楚的时候指针这道坎就算真正迈过去了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进