ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

char、char*、char**本质都是整数?一文彻底搞懂C指针与内存模型

2026/10/1 9:10:08 拓冰建站 浏览量
char、char*、char**本质都是整数?一文彻底搞懂C指针与内存模型 先说结论char、char*、char** 这三个东西本质上都是“整数”只是它们被赋予了不同的解读方式。char 是 1 字节的整数char* 是存放内存地址的整数指针char** 是存放“存放地址的地址”的整数二级指针。搞不懂它们C 语言的字符串、函数传参、指针数组就永远隔着一层纱。这篇从底层内存模型讲起带你一步步把这三个概念彻底打通。这篇内容适合三类人刚学 C/C 被指针折磨的新生、自学编程卡在字符串处理的初学者、以及用 C 写嵌入式或 GIS 二次开发但总是对指针“知其然而不知其所以然”的开发者。读完你能明白char 到底存什么、char* 为什么能指向字符串、char** 到底在什么场景下非用不可以及面试里常考的“指针作为函数参数为什么不能改变实参”这类问题。1. 内容整体设计与思路拆解1.1 先搞清楚一个事实char 其实是个整数很多教材一上来就教“char 是字符类型”这其实是误人子弟的简化说法。C 语言标准明确规定char 是占用 1 字节8 位的整数类型它要么是有符号的取值范围 -128 到 127要么是无符号的取值范围 0 到 255具体取决于编译器实现。所谓的“字符”本质上就是用一个整数编号去对应某个符号这个编号规则就是 ASCII 码表。举个例子大写字母 A 对应的 ASCII 十进制值是 65二进制是 01000001。当你在代码里写char c A;时编译器做的事情就是把整数 65 放进变量 c 的内存单元里。当你用printf(%c, c)输出时printf 按照“把这个整数当作 ASCII 码对应的符号”来显示当你用printf(%d, c)输出时它直接显示整数 65。同一个值两种解读方式这就是 C 语言的灵活性所在。理解这一点特别重要因为 char 的“字符”属性是后天赋予的先天上它就是 1 字节整数。所以 char 完全可以当小型整数用比如存储 0 到 255 的计数器、标志位、RGB 颜色分量等。在 GIS 开发中栅格数据的每个像元值如果是 8 位无符号整型底层用的就是 unsigned char——它只是恰好被用来表示地理空间数据跟“字符”没半点关系。1.2 指针的本质内存地址就是“房间号”理解了 char 是整数后再来看 char*。指针也是一个整数只不过这个整数的“含义”是内存地址。你可以把内存想象成一栋巨大的公寓楼每个房间字节都有一个编号这个编号就是地址。指针变量里存的就是这个房间号。char* 表示“这个地址指向的房间里存放的是一个 char 类型的数据”。关键在于指针本身有固定的字节数32 位系统上是 4 字节64 位系统上是 8 字节它和“指向的 char”是两码事。打个比方char* 是一张写着门牌号的纸条纸条本身是 8 字节64 位系统而门牌号指向的房间里只有 1 字节的数据。char** 就更有意思了。它表示“这个地址指向的房间里存放的是一个 char* 类型的指针”。换句话说char** 是一张写着“另一张纸条所在位置”的纸条。第一次接触确实容易晕但只要抓住“指针就是地址地址就是整数”这个核心一切都能拆解。1.3 为什么要分这么多层——直接从代码场景反推也许你会问直接用 char 不就行了吗为什么要搞出 char* 和 char** 这么绕的东西答案很简单因为实际编程中数据往往不是孤立存在的。如果你要处理一个字符用 char如果你要处理一串字符字符串C 语言没有原生字符串类型只能用一个连续的内存区域存放多个 char然后用首地址来标识这个区域——这就是 char* 存在的意义如果有一串字符串呢比如命令行参数char *argv[]、一个函数指针数组、或者要在函数里修改调用者的指针变量——就需要存放“字符串首地址”的数组这个数组名本身又是个地址于是出现了 char**。所以三者的关系是层层递进的char 是数据本身char* 是数据的地址char** 是“存放地址的地址”。理解了“需求驱动设计”就不会觉得这是人为制造出来的复杂度了。1.4 与 Java、GIS 中 char 的横向对比既然热词里反复出现“Java char 是什么数据类型”“GIS 里面 char float int time”这里也顺手理清。Java 里的 char 是 16 位 Unicode 字符类型取值范围 0 到 65535它和 C 的 char 最大的区别在于Java 的 char 明确就是字符不是用来当整数用的且占用 2 字节。而 GIS地理信息系统开发中常见的 char 则有两种语境一种是像 C/C 或 Python 的 ctypes 里那样表示 1 字节整数另一种是在文件格式如 Shapefile中定义字段类型时char 表示定长字符串比如char[10]表示最多 10 个字符的文本。float 是单精度浮点数int 是 4 字节整数time 在 GIS 里通常指日期时间字段它们和 char 是“数据类型家族”里的不同成员各自解决不同的问题。这种横向对比有助于建立完整的知识框架同样是 “char” 这个单词在不同语言不同场景下含义完全不同千万别死记硬背一个定义走天下。2. 核心细节解析与实操要点2.1 char 的内存模型与常见操作先写一段代码看看 char 在内存中到底长什么样#include stdio.h int main() { char a A; char b 65; char c -1; unsigned char d 255; printf(a %c, 整数值 %d\n, a, a); printf(b %c, 整数值 %d\n, b, b); printf(c %d\n, c); printf(d %u\n, d); return 0; }这段代码在绝大多数编译器上输出如下a A, 整数值 65 b A, 整数值 65 c -1 d 255看到没a A和b 65在内存里就是一模一样的二进制01000001。c 在有符号 char 下是 -1在无符号 char 下则是 255同一个字节11111111两种解释。这正是 char 作为“整数”的核心特点字节不变解释权归你。实操中要注意如果你用 char 存中文那注定要出问题因为一个汉字在 UTF-8 编码下占 3 个字节GBK 编码占 2 个字节char 根本存不下。存中文字符串应该用char[]数组而不是单个 char。另外一个常见坑有些编译器默认 char 是无符号的比如某些 ARM 嵌入式编译器有些默认是有符号的比如 x86 上的 GCC。跨平台写代码时如果你依赖 char 的符号性做判断就埋了雷。例如char x 0x80; if (x 0) { // 在有符号 char 的平台上会进来 // 在无符号 char 的平台上不会进来 }这种代码就是典型的“看编译器心情”。要跨平台稳定直接用signed char或unsigned char明确声明。2.2 char* 的三种存在形态变量、数组、字符串常量char* 之所以让新手头疼是因为它有三种完全不同的使用场景表面看起来却长得一样。第一种指向单个 char 变量的指针。char ch Z; char *p ch; printf(%c\n, *p); // 输出 Z这里 p 存放的是 ch 的地址*p是解引用取出 ch 的值。这种用法在实际项目中最少主要用来理解指针的基本操作。第二种指向 char 数组首元素用来遍历字符串。char str[] hello; char *p str; // 数组名 str 自动转换为指向首元素的指针 while (*p ! \0) { printf(%c, *p); p; }这是最核心的用途。注意char str[] hello会在栈上分配 6 个字节5 个字符加一个结尾的\0而char *p str只是把首地址给了 p。此时 p 和 str 用法几乎一样都能用下标访问str[1] p[1]。这里有一个重要的知识点数组名不是指针变量它是一个“常量地址”不能做str这样的自增操作但指针变量可以。很多人以为数组名就是指针其实数组名在表达式中会“退化”为指针但是sizeof(str)返回整个数组大小比如 6而sizeof(p)返回指针大小64 位系统上 8这就暴露了两者的本质区别。第三种指向字符串常量。char *p hello; p[0] H; // 危险多数平台会崩溃这里的hello是字符串字面量存储在只读数据段。p 指向的是一块只读内存试图修改它属于未定义行为。很多初学者在这里踩坑用char *接收字符串常量然后想原地改内容结果段错误。正确的做法是需要修改字符串内容就用char str[]不需要修改就用const char *。2.3 char**什么时候非用不可二级指针最常见的使用场景有三个逐一说明。场景一在函数内部修改外部指针变量的值。#include stdio.h #include stdlib.h #include string.h void allocate_memory_bad(char *p) { p (char *)malloc(100); strcpy(p, hello); } void allocate_memory_good(char **p) { *p (char *)malloc(100); strcpy(*p, world); } int main() { char *ptr NULL; allocate_memory_bad(ptr); printf(bad 结果: %s\n, ptr ? ptr : NULL); // 输出 NULL allocate_memory_good(ptr); printf(good 结果: %s\n, ptr ? ptr : NULL); // 输出 world free(ptr); return 0; }原理很简单C 语言函数参数是值传递allocate_memory_bad(ptr)传入的是 ptr 的“复印件”函数内部修改复印件原件纹丝不动。而allocate_memory_good(ptr)传入的是 ptr 的地址也就是 char**函数内部*p修改的是原件。要想改一个指针的值必须把指针的地址传过去——这个“多一层间接”思想就是二级指针的核心价值。场景二处理指针数组比如命令行参数。#include stdio.h int main(int argc, char *argv[]) { // 这里的 argv 类型等价于 char **argv for (int i 0; i argc; i) { printf(参数 %d: %s\n, i, argv[i]); } return 0; }argv是一个数组每个元素都是char *指向一个字符串。在函数参数列表中char *argv[]和char **argv完全等价。这种结构在 main 函数、各种 CLI 工具解析里是标配。场景三二维字符数组的动态分配。注意区分两种东西char array[3][20]是真正的二维数组内存在栈上连续分配而char **p可以通过malloc逐行分配每行的内存不一定连续。#include stdio.h #include stdlib.h #include string.h int main() { char **p (char **)malloc(3 * sizeof(char *)); for (int i 0; i 3; i) { p[i] (char *)malloc(20); sprintf(p[i], 第%d行, i); printf(%s\n, p[i]); } for (int i 0; i 3; i) { free(p[i]); } free(p); return 0; }这种“指针数组”的内存模型相对复杂p 自己是一个变量它指向一块连续的内存区域这块区域里存了 3 个 char*每个 char* 又各自指向一块存有 20 字节的内存。画个草图就是p (char**) ──▶ [p[0]] ──▶ 第0行\0... [p[1]] ──▶ 第1行\0... [p[2]] ──▶ 第2行\0...理解这个图char** 就彻底拿下了第一层指向“指针数组”第二层指向“具体字符串”。正是因为多了一层才能在二维结构里自由控制每一个字串的长度这也是动态二维数组比 C 原生二维数组更适合处理“每行长度不一”的场景的原因。2.4 char、char*、char** 在 GIS 场景的具体应用结合热词里的 GIS展开说几个贴切的例子帮助理解这些概念在真实业务中的位置。GIS 栅格数据遥感影像、DEM 数字高程模型的像元类型通常包括1 字节无符号整型unsigned char适合存储 0-255 的灰度值或分类编码、2 字节整型short、4 字节整型int、4 字节浮点型float、8 字节双精度型double。如果你用 C 读取 GeoTIFF 或 IMG 格式的栅格底层缓冲区经常是unsigned char *buffer然后根据数据类型把这块内存按不同方式解析。比如 DEM 的每个像元是 4 字节 float你就可以把unsigned char*强转为float*来访问因为内存本质上就是字节序列类型只是解读方式。这个思路和 char 是“可重新解读的整数”一脉相承。矢量数据方面Shapefile 的属性表字段类型常见的有C字符型对应 C 语言的 char 数组、N数值型、F浮点型、D日期型。读取 DBF 文件时一个字段可能是一个char[10]数组要提取里面内容就需要用char*指向这个数组然后做字符串处理。而在 GIS 二次开发中很多 SDK 的 API 接口定义都是char**用于返回一个字符串列表比如图层字段名列表。这时候如果不理解二级指针调接口就只会“抄示例”换个场景就抓瞎。3. 实操过程与核心环节实现3.1 从零搭建一套完整演示代码纸上谈兵没意思直接写一套可以完整运行的代码覆盖 char、char*、char** 的全部核心操作。在任意 C 编译器GCC、Clang、MSVC下都能直接跑。#include stdio.h #include stdlib.h #include string.h // 函数通过二级指针修改外部指针的指向 void safe_assign(char **dest, const char *src) { // 先把原来的内存释放掉避免泄漏 if (*dest ! NULL) { free(*dest); *dest NULL; } *dest (char *)malloc(strlen(src) 1); if (*dest NULL) { fprintf(stderr, 内存分配失败\n); exit(1); } strcpy(*dest, src); } int main() { // ---------- 第一部分char ---------- printf( char 基础 \n); char a A; char b 66; printf(a %c, 整数表示 %d\n, a, a); printf(b %c, 整数表示 %d\n, b, b); printf(char 大小 %zu 字节\n, sizeof(char)); printf(char* 大小 %zu 字节\n, sizeof(char *)); printf(char** 大小 %zu 字节\n, sizeof(char **)); // ---------- 第二部分char* ---------- printf(\n char* 基础 \n); char str[] hello; char *p str; printf(数组方式访问: %c%c%c%c%c\n, str[0], str[1], str[2], str[3], str[4]); printf(指针方式访问: %c%c%c%c%c\n, p[0], p[1], p[2], p[3], p[4]); while (*p ! \0) { printf(%c, *p); p; } printf(\n); p str; // 复位指针 // ---------- 第三部分char** ---------- printf(\n char** 基础 \n); char *name1 Alice; char *name2 Bob; char *name3 Charlie; // 方式一用指针数组 二级指针遍历 char *name_array[] {name1, name2, name3}; char **pp name_array; for (int i 0; i 3; i) { printf(name[%d] %s, 字符串长度 %zu\n, i, pp[i], strlen(pp[i])); } // 方式二在函数里通过二级指针修改指针 char *dynamic_str NULL; safe_assign(dynamic_str, 动态分配的第一版); printf(\n第一次分配: %s\n, dynamic_str); safe_assign(dynamic_str, 动态分配的第二版); printf(第二次分配: %s\n, dynamic_str); free(dynamic_str); dynamic_str NULL; // 方式三模拟二维字符串数组每行长度不固定 printf(\n 二级指针动态二维数组 \n); char **grid (char **)malloc(4 * sizeof(char *)); const char *cities[] {北京, Shanghai, NYC, Tokyo}; for (int i 0; i 4; i) { grid[i] (char *)malloc(strlen(cities[i]) 1); strcpy(grid[i], cities[i]); printf(城市 %d: %s\n, i, grid[i]); } for (int i 0; i 4; i) { free(grid[i]); } free(grid); return 0; }这段代码覆盖了指针大小的验证32 位下三者是 4/4/464 位下是 1/8/8、数组与指针的等价访问、通过二级指针在函数内修改外部指针、动态分配二维数组。建议实际敲一遍在断点处查看变量的内存地址比看十篇文章都管用。3.2 图解内存布局一张图讲透三级跳理解这三个概念最关键的是画内存图。自己在纸上画“房间号 房间内容”即可不需要花哨工具。我描述一遍你跟着画画三条横线代表三块内存区域。区域一是变量 achar 类型房间地址假设是 0x100房间内容是01000001对应的 ASCII 是 A。区域二是变量 pchar* 类型房间地址假设是 0x200房间里存的内容是0x100即指向区域一。注意 p 自己有地址 0x200 这件事非常重要。区域三是变量 ppchar** 类型房间地址假设是 0x300房间里存的内容是0x200即指向 p。画完后你会发现无论嵌套多少层指针内存模型都只是“房间里放地址”这个模式的重复。所谓二级指针就是“房间里的内容指向的是另一个放地址的房间”。真正的数据存在最底层的那间房里。用代码验证地址关系char value X; char *ptr value; char **pptr ptr; printf(value 的地址: %p\n, value); printf(ptr 里存的地址: %p\n, (void *)ptr); printf(ptr 自己的地址: %p\n, ptr); printf(pptr 里存的地址: %p\n, (void *)pptr);运行结果中第 1 行和第 2 行是同一个地址第 3 行和第 4 行是同一个地址。这就直接证明了ptr里面存的是value的地址pptr里面存的是ptr的地址。3.3 sizeof、strlen、数组名与指针的细节对照写码时最容易混淆的就是 sizeof 和 strlen 的区别、数组名和指针的区别。整理一个对照表方便查阅表达式含义64 位系统结果sizeof(char)单个 char 占的字节数1sizeof(char*)指针变量占的字节数8sizeof(char**)二级指针变量占的字节数8char s[] hello; sizeof(s)数组总字节数含结尾 \06char s[] hello; strlen(s)字符串有效字符数不含 \05char *p hello; sizeof(p)指针类型大小与字符串长度无关8char *p hello; strlen(p)从 p 指向的位置开始数到 \0 的字符数5关键点sizeof 是“编译期算子”它看的是类型定义不关心运行时内容strlen 是“运行期函数”它必须逐个字节扫描到\0。所以char*的sizeof永远是 864 位而 strlen 才给出字符串长度。这个区分在写代码时非常关键因为很多人错误地用 sizeof 去算字符串长度导致缓冲区分配过大或过小。另外注意char s[] hello和char *s hello虽然打印出来都是 hello但前者是数组修改 s[0] 合法后者是指向常量区的指针修改 s[0] 属于未定义行为。这也是面试官百问不厌的考点。3.4 简化记忆法把指针层级和“快递代收点”类比如果上面的技术讲解还觉得绕用生活场景再捋一遍。char 是“一封写了字的信”。信纸上有 1 个字符的内容。char* 是“写着信的存放地址的纸条”。纸条本身是一个指针按照纸条上的地址找到信箱打开信箱才能看到信。也可以理解成快递单号你不需要关心包裹在哪个仓库只需要凭单号就能找到它。char** 是“写着快递单号存放位置的纸条”。你先按照地址找到一个抽屉抽屉里有一张写着快递单号的纸条再凭这个单号才能找到真正的包裹。所以操作层级就是char 直接操作内容char* 先解引用一次再操作内容char** 先解引用两次再操作内容。解引用次数等于 * 的个数。这个类比在调试时特别好用。看到代码报Segmentation Fault先想想是不是解引用次数多了或少了如果char**只用了一次*就去访问数据拿到的其实是个地址硬当作内容打印自然会崩。4. 常见问题与排查技巧实录4.1 经典面试题为什么函数里修改指针实参不生效这道题考了无数人。代码长这样void change(char *s) { s new; } int main() { char *str old; change(str); printf(%s\n, str); // 输出 old return 0; }原因还是那句话C 语言值传递。change 函数收到的参数是 str 的拷贝这个拷贝和 str 各占各的内存。函数里修改拷贝对原变量 str 没有影响。要修改实参本身必须传入实参的地址change(str)此时形参类型是char**函数里通过*s new来修改原指针的指向。这就是为什么“想通过函数修改一个指针变量本身的值必须用二级指针”。把这个问题再延伸一层如果想在函数里修改一个 int 变量呢用int*想修改一个char*变量呢用char**想修改一个char**变量呢用char***。规律非常明显——想改什么就传什么的地址。4.2 常见崩溃原因野指针、越界、重复释放用 char 系列指针最容易踩的坑集中在下面几个方面第一未初始化的指针野指针。char *p;然后直接strcpy(p, hello)p 指向哪里没谁知道大概率崩溃。正确做法是初始化指针时要么赋值一个有效地址要么置为 NULL。第二缓冲区越界。分配了 5 个字节往里写了 10 个字符。char buf[5]; strcpy(buf, hello world);看似编译没报错运行可能也没报错但已经破坏了栈上相邻的数据只为时报一个莫名其妙的错。解决方法是优先用snprintf、strncpy等带长度限制的函数。第三重复 free。多次调用free(p)同一指针或者 free 一个还在使用的指针都会导致难以定位的问题。建议 free 后立即把指针置 NULL养成习惯。第四内存泄漏。malloc 了忘记 free尤其在函数里 malloc 一串指针数组后只 free 了第一层。二级指针的动态分配一定要“逆着分配顺序释放”先释放每个子串再释放指针数组本身。写一个小总结表方便排查症状可能原因排查方向段错误Segmentation Fault野指针、越界、空指针解引用查指针是否初始化、是否越界输出乱码char 按有符号/无符号误读、编码不匹配查类型声明是否匹配、数据源编码函数内修改后外部没变化值传递修改了拷贝改用指针或二级指针内存越写越崩缓冲区分配过小改成 strlen1 分配程序无缘无故多占内存malloc 没有对应的 free用 valgrind 检测字符串修改时崩溃指向了字符串常量区改用数组存储4.3 调试技巧分享用 printf 和 gdb 快速定位指针问题碰到指针相关问题第一件事不是读代码而是打印地址。我调试指针问题时最喜欢干的是用 printf 把每一层地址打出来printf(data 地址: %p\n, (void *)data); printf(ptr 值: %p\n, (void *)ptr); printf(ptr 地址: %p\n, (void *)ptr); printf(pptr 值: %p\n, (void *)pptr);三层地址一打印立刻能看出哪一层是空的、哪一层指向了不该指向的位置。如果是 NULL0x0说明这一层还没赋值。如果地址很怪比如 0x1、0xffffffff可能是野指针。gdb 里也常用p *ptr打印指针指向的内容p ptr打印指针自己的地址bt看崩溃时的调用栈。配合watch监视某个地址内容的变化能快速定位是谁改坏了一块内存。4.4 关于“char 在 GIS 里和 float、int、time 的关系”的补充说明有些读者可能是因为 GIS 开发摸到这篇文章的这里再补充几句。在 GIS 开发中数据类型的核心逻辑是“这个字段的数据在计算机里怎么存、怎么算”。int 存整数比如人口数、楼层数float 存带小数的数值比如经纬度、面积char 存字符或短字符串比如行政区代码、地类代码time 存时间比如采集时间、更新时间。它们之间的选择原则也很直白能存整数就别用 float因为浮点有精度问题定长短字符串用 char 数组变长文本就该用字符串类型时间字段在底层数据库或文件格式里往往存的是整数或字符串但读取到代码里要用专门的时间结构体去解析。理解了 char 是 1 字节整数之后再看这些字段类型的本质就通了所有类型在内存里都是字节序列类型只是告诉编译器怎么解读和操作这些字节。5. 写在最后的几点实操心得先说一个每次带新人都会强调的习惯对指针做任何操作前先问自己“这个指针指向的内存在哪里、生命周期到什么时候”。很多人写出悬挂指针dangling pointer就是因为只想着“怎么赋值”没想“内存是谁的、谁负责释放”。如果字符串是从函数里 malloc 出来的调用者用完必须 free如果字符串是某个静态区的常量绝不能在函数里试图修改它。把内存所有权理清楚指针错误能减少八成。再说一个小技巧写代码时给变量名加上类型语义。我自己习惯这样命名char 类型变量用 c 开头如 cGradechar* 用 p 开头如 pNamechar** 用 pp 开头如 ppArgv。虽然很多人觉得麻烦但当你面对一个几百行的函数时光看变量名就知道它是数据还是地址还是地址的地址排查问题的速度会快很多。另外入门阶段不要害怕写测试代码。刚开始学指针多写几个故意崩溃的程序反而学得更快。比如故意对 NULL 指针解引用故意越界写内存故意重复 free观察控制台输出的错误信息。这种“反向验证”的学习方法比背概念有用得多。等你把错误信息都混眼熟了以后遇到问题基本一眼就能判断是哪类错误。最后这篇只是入门级的梳理。实际工程里还会遇到函数指针、指向多维数组的指针、还有各种 const 修饰组合const char*、char* const、const char**等复杂程度会几何级增长。但万变不离其宗你只要记住“指针变量存的是地址地址指向的东西是什么类型就用对应的指针类型想改谁就传谁的地址”不管多复杂的指针声明都能一层层剥开。先收藏着遇到问题再回来翻比一口气死记硬背有效得多。