news 2026/9/30 6:09:43

C语言结构体成员访问:点号与箭头的本质区别及实战用法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言结构体成员访问:点号与箭头的本质区别及实战用法

说实话,C语言里结构体变量的两种引用方式“.”和“->”,是我见过最让新手头疼的语法点之一。不少同学一开始觉得,反正都是访问成员,为什么C语言非要搞出两套写法?等真上手写代码,敲错一个符号,编译器满屏报错,看半天也不知道错在哪。这篇就把这两种引用方式从头到尾拆开讲清楚,结合内存布局、编译原理、实际场景和调试经验,讲明白到底什么时候用点,什么时候用箭头。

先回答文章标题最核心的问题:“.”和“->”本质上是一回事吗?不是。它们只是“看起来都在访问成员”,但操作对象的类型完全不同。一个作用于结构体变量本身,一个作用于结构体变量的指针。这个差别,是理解两种写法全部行为的关键,也是熟练写出高质量C代码的基础。

1. 先搞清楚:点号和箭头到底差在哪

1.1 从结构体变量和结构体指针的定义说起

结构体本身是一个复合数据类型,相当于你自定义了一个“图纸”。真正在内存里占地方的是结构体变量,也就是按照这张图纸生产出来的“实物”。

typedef struct { char name[32]; int age; float score; } Student; Student stu1; // 结构体变量,内存已分配,栈上占据一块连续空间 Student *pStu; // 结构体指针,目前只分配了一个存放地址的变量 pStu = &stu1; // 让指针指向 stu1

这个例子里,Student是类型,stu1是变量,pStu是指针变量。stu1在栈上分配了实实在在的内存空间,name、age、score这些成员都有自己的位置;而pStu变量本身只有4个字节(32位平台)或者8个字节(64位平台),里面存的是stu1的起始地址,不是成员数据本身。

很多人容易忽略一个关键点:指针变量本身也是一个变量,它也有自己的内存地址,只不过它内存里存的不是普通数据,而是另一个变量的地址。这就像你手里拿着一张写有门牌号的纸条,门牌号可以带你去找到房子,但纸条本身不是房子。

1.2 两种引用的本质:直接寻址与间接寻址

C语言标准里,.运算符的精确名称叫“成员选择”运算符(member access operator),它要求左操作数是一个结构体或联合体类型的“对象”,也就是变量本身;->运算符叫“间接成员选择”运算符(indirect member access operator),它要求左操作数是一个指向结构体或联合体的“指针”。

从最底层的寻址逻辑来看:

  • stu1.age:编译器在编译期已经知道了age相对于结构体首地址的偏移量,直接拿stu1的地址加上偏移量,就得到age的内存地址。
  • pStu->age:编译器会做两步操作。第一步,从pStu变量里取出它存储的目标地址;第二步,在这个目标地址的基础上加上age的偏移量,再访问那块内存。

也就是说,.是直接从已知的对象地址出发找成员,->是先通过指针找到目标对象的地址,再从这个地址出发找成员,多了一次“间接跳转”的过程。这也是为什么->在语义上被称为“间接”访问。

可以用一个非常生活化的类比来理解:你想找办公室里的打印机。如果办公室就在你面前,你推门进去拿就行,这是.;如果只知道办公室在3楼某个房间,你得先走到3楼,找到房间号,再进门拿打印机,先找地址再访问,这是->。

1.3 内存视角:变量和指针在内存里长什么样

为了直观,我把上面的代码对应的内存布局画出来(这张图后面的很多细节都要用到):

假设 stu1 的起始地址是 0x1000,pStu 自己的地址是 0x2000 地址 0x1000 (stu1的存储区) +----------------------+ | name[32] 偏移 0 | +----------------------+ | age 偏移 32 | +----------------------+ | score 偏移 36 | +----------------------+ 地址 0x2000 (pStu自己的存储区) +----------------------+ | 0x1000 | <- pStu的内容是 stu1 的地址 +----------------------+

编译器在处理stu1.age时,相当于做了一次加法:0x1000 + 32,得到0x1020,然后从0x1020这个地址读写4个字节的整数。在处理pStu->age时,第一步先读取地址0x2000处的内容,得到0x1000;第二步再计算0x1000 + 32,得到0x1020,然后访问这块内存。

// 这两种写法的结果是完全一致的 printf("age = %d\n", stu1.age); // 直接访问 printf("age = %d\n", pStu->age); // 间接访问 printf("age = %d\n", (*pStu).age); // 解引用后再直接访问,和 -> 等价

最后那行(*pStu).age是理解->的关键。它先对指针做解引用,得到指针指向的那个结构体变量本身,然后再用.去访问成员。C语言标准里明确说明:pStu->age和(*pStu).age是等价的。之所以日常都用->,纯粹是因为写起来更简洁、可读性更好,毕竟带上一对括号,代码会显得很啰嗦。

2. 为什么C语言不允许混用:运算符原理与常见混淆点

2.1 编译器的类型检查逻辑与典型报错

既然->和(*p).等价,那能不能顺手把.用在指针上,或者把->用在普通变量上?答案是不能,编译器会直接报错。这背后体现的是C语言的一个基本设计哲学:操作数类型必须和运算符的要求严格匹配。

int main(void) { Student stu1; Student *pStu = &stu1; // pStu 是指针,不能用 . // pStu.age = 18; // 错误!编译报错 // stu1 是普通变量,不能用 -> // stu1->age = 18; // 错误!编译报错 return 0; }

第一种错误的典型报错信息是:

error: request for member 'age' in something not a structure or union

第二种错误的典型报错信息是:

error: invalid type argument of '->' (have 'Student')

很多初学者看到这段报错就懵了:明明pStu指向的就是stu1,为什么编译器不自动帮我转换一下?关键在于,如果C编译器自动把pStu.age理解成(*pStu).age,那pStu到底是指向对象的指针,还是个包含成员的对象,语言就失去了明确的边界。C语言的哲学是“可以隐式做的事情尽量少,要做也要能做清楚”,这种涉及指针和对象语义转换的操作,歧义太大,编译器宁可报错也不猜。

从另一个角度说,这种严格性是好事。它逼着程序员在写代码的每一处都明确自己操作的对象到底是什么:你是在直接操作一个结构体实体,还是在通过地址间接操作。这个“明确性”在大型工程里价值巨大,它让代码的可读性和可维护性都显著提升。

2.2 优先级和结合方向:那些带坑的写法

第一次写(*pStu).age的人,十个里有八个会先写成*pStu.age,然后收获一条编译错误。这就要说到运算符优先级了。

在C语言的运算符优先级表里,[]、()、.、->这老哥几个是同一档,优先级最高,结合方向从左往右;而*(解引用运算符)属于单目运算符,优先级比它们低一级。所以:

  • *pStu.age会被解析成*(pStu.age),编译器先试图访问pStu的age成员,但pStu是个指针,于是报错。
  • (*pStu).age先对pStu解引用,得到stu1这个结构体变量,再用.访问成员,完全正确。
  • pStu->age用箭头一写,连括号都不用,这就是箭头的第二个好处——省括号、防手滑。

类似的坑还有自增运算符。代码里出现pStu->age++,编译器会按优先级规则解析成(pStu->age)++,意思是先取age成员,然后对age这个值做自增;而(pStu++)->age则是先自增指针,让指针指向下一个结构体对象,再去访问它的age成员。这两种写法结果是天差地别的,一个是改成员的值,一个是改指针本身指向的对象。

再看一个关于取地址的写法:&pStu->age。第一次看到这个表达式,很多人会犹豫:它到底是取pStu的地址,还是取age成员的地址?根据优先级规则,->优先于&,所以它实际是&(pStu->age),也就是取age这个成员的内存地址,类型是int *。这个写法在底层编程、寄存器操作里非常常见,一定要记住。

2.3 易混淆写法对照:*p.age、(*p).age、&p->age

把容易混淆的写法集中对比一下,这是我觉得最实用的一张表,建议保存下来:

写法实际含义是否合法
pStu->age等价于(*pStu).age,访问指针指向对象的成员合法
(*pStu).age解引用后访问成员合法,就是太啰嗦
*pStu.age被解析为*(pStu.age),pStu没有age成员编译错误
pStu.age对指针用点操作符编译错误
stu1->age对普通变量用箭头操作符编译错误
&pStu->age解析为&(pStu->age),取的是age成员的地址合法
&pStu.age解析为&(pStu.age),和上面一样,但pStu是指针编译错误
&stu1.age解析为&(stu1.age),取stu1的age成员地址合法

经常在代码评审里看到有人指着&pStu->age问“这个&是不是取pStu的地址”,我都会让他去查一下优先级表再回来讨论。这种细节属于“基本功中的基本功”,写错一个点,编译器要么报错,要么给你一个完全不是本意的地址值,问题大了。

还有个经典的嵌入式笔试技巧:怎么在不定义变量的情况下算出结构体某个成员的偏移量。答案是用0地址:

// 16位平台上,下面表达式的值就是 age 的偏移量,即 32 int offset = (int)&((Student *)0)->age;

原理是:强制把0地址转换成Student *类型的指针,然后用->访问其age成员,再取地址,得到的结果就是age相对结构体首地址的偏移量。这个写法在实现通用容器、序列化、协议解析时非常有用。不过它依赖指针和整数之间的强制转换,写的时候要注意平台安全性,一般作为技巧看待即可。

3. 真实代码场景里怎么选:从函数传参到链表操作

3.1 函数传参场景:传变量还是传指针

实际写代码,函数传参是“.”和“->”出现分歧最大的地方。考虑一个场景:你写了一个函数,要打印学生的信息。

// 值传递:整个结构体被拷贝一份 void printStudent1(Student s) { printf("%s %d %.1f\n", s.name, s.age, s.score); } // 指针传递:只拷贝一个地址 void printStudent2(const Student *ps) { printf("%s %d %.1f\n", ps->name, ps->age, ps->score); }

第一种方式,函数形参s是实参的完整拷贝。如果结构体很小还好,一旦结构体里躺着char buf[1024]、几个嵌套结构体,一次调用就要拷贝几千字节,性能和栈开销都很难看。第二种方式,形参ps是一个指针,只占4或8字节,函数内部用->访问成员,开销小得多。

那什么时候用变量、什么时候用指针?我的经验是:

  • 只读访问,并且结构体不大,可以用值传递,安全省心。
  • 只读访问,结构体很大,用const修饰的指针。
  • 需要修改调用者的结构体内容,必须用指针,函数内通过->直接改到原变量。

看一个修改数据的例子:

void setStudentAge(Student *ps, int newAge) { ps->age = newAge; // 等价于 (*ps).age = newAge,但箭头明显更清晰 } int main(void) { Student stu1; setStudentAge(&stu1, 20); printf("%d\n", stu1.age); // 输出20 return 0; }

如果这里用值传递,函数改的只是拷贝出来的那份数据,调用者的结构体纹丝不动,这种bug排查起来非常隐蔽。

这里还有个容易踩的小坑:在函数内部拿到结构体指针形参后,下意识想写ps.name,编译报错了才反应过来。我的习惯是:在函数开头先看一眼形参类型,凡是带*的,里面访问成员一律用->,写完再回头检查一遍。别嫌啰嗦,这个习惯在写长函数时能省不少编译报错的时间。

3.2 动态内存分配、链表和寄存器映射里的必然选择

当你使用malloc从堆上分配结构体时,返回的是地址,所以你手里拿到的必然是一个指针。这种情况下,箭头是唯一顺手的选择:

Student *p = (Student *)malloc(sizeof(Student)); if (p == NULL) { // 处理分配失败 return -1; } p->age = 21; strcpy(p->name, "Tom"); p->score = 92.5; // 使用完别忘了释放 free(p);

再来看链表,几乎每个C语言学习者都会写。链表节点的定义里天生就带着自引用指针:

typedef struct Node { int data; struct Node *next; } Node; // 遍历链表 Node *head = createList(); for (Node *cur = head; cur != NULL; cur = cur->next) { printf("%d\n", cur->data); }

cur是指针,访问data和next都只能用->。原因不用多说,cur本身不是Node变量,cur的内存里只存了某个节点的地址。

嵌入式方向的同学对箭头应该更亲切。很多单片机开发包里,外设寄存器就是一组映射到固定地址的结构体:

typedef struct { volatile uint32_t MODER; volatile uint32_t OTYPER; volatile uint32_t OSPEEDR; volatile uint32_t ODR; // ... } GPIO_TypeDef; #define GPIOA ((GPIO_TypeDef *)0x48000000U) // 操作寄存器 GPIOA->ODR = 0x0001;

这里GPIOA本质上是一个指向固定地址的指针常量,所以访问寄存器成员就必须用箭头。用“.”的话,编译器会直接告诉你GPIOA不是结构体或联合体。这也解释了为什么很多嵌入式代码里箭头高频出现——外设访问天然是基于地址的。

3.3 结构体数组、嵌套结构体与成员指针

结构体数组是很容易把新手绕晕的场景。先定义一个数组:

Student class[3]; class[0].age = 18; class[1].age = 19; class[2].age = 20;

数组元素是结构体变量,所以访问成员用点号没问题。但如果你写class->age,在语法上也是合法的。为什么?因为class作为数组名,在绝大多数表达式中都会隐式转换成指向首元素的指针,也就是&class[0],所以class->age等价于(&class[0])->age,即class[0].age。

这个隐式转换经常被忽略,导致不少人看到class->age以为class是指针,其实数组名和指针在不少场景下确实行为一致,但本质不同:数组名不是可修改的左值,而指针可以重新赋值。

再来看嵌套结构体,这也是指针和点号混用的混乱区:

typedef struct { int year; int month; int day; } Date; typedef struct { char name[32]; Date birthday; } Person; Person p; Person *pp = &p; p.birthday.year = 2000; // 成员是普通结构体变量,一直用 . pp->birthday.year = 2001; // pp是指针,用 -> 取到birthday,birthday是变量,继续用 . // pp->birthday->year; // 错误!birthday不是指针,不能用->

关键点在于:->只负责“从指针拿到成员”这一层动作,拿到的成员如果本身是结构体变量,后续继续访问要用.;如果成员本身是指针,后续访问才用->。一层一层拆开看,思路就清晰了。

还有成员是指针的情况:

typedef struct { char *name; // name是一个指针,指向堆上的字符串 } Teacher; Teacher t; t.name = (char *)malloc(32); // t是变量,用.访问name成员,然后对指向的内存赋值 strcpy(t.name, "Alice"); Teacher *tp = &t; tp->name[0] = 'B'; // tp->name 等价于 t.name,拿到char*指针,然后[0]访问字符串首字符

这里tp->name[0]实际上要做两步理解:第一步tp->name拿到name这个指针的值,第二步name[0]通过下标访问指针指向的内存。很多人把这种写法误以为tp->name本身就是字符串,其实name的类型是char *,不是数组,name[0]访问的是指针指向的第一个字符。

还有一种很常考的坑:结构体成员里有数组时,用点号和箭头访问的语义也不一样。看例子:

typedef struct { char id[8]; } Card; Card c; Card *pc = &c; strcpy(c.id, "123"); // c是变量,用.访问数组 strcpy(pc->id, "456"); // pc是指针,用->访问数组,效果等同于直接操作c.id

pc->id的类型是char [8],在表达式里会退化成char *,所以可以传给strcpy。这一点理解清楚后,就不会在代码里写出pc->id = "abc"这种错误的赋值方式——数组不能直接赋值,只能用strcpy或逐元素赋值。

4. 常见问题与调试技巧实录

4.1 常见编译错误速查表

把日常最常遇到的错误集中列一个表,方便大家对照排查。下面这个表的每一行都是我在实际项目和帮别人调代码时见过的真实报错:

错误写法典型报错原因正确写法
pStu.agerequest for member 'age' in something not a structure or union对指针用点操作符pStu->age
stu1->ageinvalid type argument of '->' (have 'Student')对普通变量用箭头操作符stu1.age
*pStu.agerequest for member 'age' in something not a structure or union点号优先级高于星号(*pStu).age或pStu->age
&pStu.agerequest for member 'age' in something not a structure or union点号优先级高于取地址&(pStu->age)
ps.name(形参是指针)request for member 'name' in something not a structure or union形参类型是指针ps->name

看到这些报错,先不要慌,第一反应去看运算符左边那个表达式的类型:是结构体变量就用.,是结构体指针就用->。判断方法很简单:

  • 如果左边是xxx.yyy这种样式,xxx必须是一个结构体/联合体变量。
  • 如果左边是xxx->yyy这种样式,xxx必须是一个指向结构体/联合体的指针。

写代码时也可以走一个固定的心法:看到变量名,脑子里马上问一句“它是对象还是地址”。对象用点,地址用箭头。这个反射练出来之后,这一类报错基本就跟你说拜拜了。

4.2 调试器里查看结构体内容的实用技巧

报错解决了,接下来更常见的需求是:程序能编译能跑,但结构体的值不对,想看看当前结构体里到底存了什么。不同调试环境有不同操作,这里挑最常用的两个场景讲。

第一个场景,Keil MDK调试模式。这也是嵌入式开发最常用的IDE之一。进入Debug模式后:

  1. 打开菜单 View -> Watch Windows -> Watch 1,把Watch窗口调出来。
  2. 如果想看结构体变量stu1,直接在Name列输入stu1,回车。Watch窗口会展开所有成员,显示name、age、score的当前值。
  3. 如果想看结构体指针pStu,在Name列输入pStu,回车。你不会直接看到成员值,而是看到一个地址。这个地址就是指针指向的目标地址。点击该项前面的加号,Keil会尝试展开它指向的结构体内容。
  4. 还有一种更硬核的办法:用Memory窗口。打开 View -> Memory Windows -> Memory 1,在Address框输入&stu1或者pStu的当前值,内存窗口就会按十六进制列出对应地址的原始字节。

这里有一个非常容易踩的坑:在Debug模式下,如果pStu没有初始化,或者指向的内存已经被释放,你在Watch窗口里展开pStu时会看到Cannot access memory之类的提示。这不是调试器坏了,而是指针本身就是一个野指针或者悬空指针。遇到这种情况,优先排查指针的赋值和生命周期,而不是在显示层面纠结。

第二个场景,Linux环境下用GDB调试。命令行操作更直观:

// 编译,带调试信息 gcc -g -o demo demo.c // 调试 gdb ./demo // 在main函数打断点,运行 (gdb) break main (gdb) run // 打印各种变量 (gdb) print stu1 (gdb) print pStu (gdb) print *pStu (gdb) print pStu->age

print pStu看到的是地址,print *pStu才能看到它指向的结构体内容,这两个命令在调试时特别容易混淆。如果嫌展开太深麻烦,还可以用print pStu->age直接打印成员。

另外推荐一个非常实用的GDB技巧:用ptype查看结构体类型定义,方便在调试时确认成员名和类型:

(gdb) ptype Student type = struct Student { char name[32]; int age; float score; }

这个命令在你记不清结构体成员名的时候,能省去来回翻头文件的时间。

4.3 两个容易写错的隐藏陷阱

陷阱一:自增运算作用于指针还是成员。

pStu->age++ // 实际是 (pStu->age)++,对age成员自增 (pStu++)->age // 实际是让pStu先指向下一个结构体,再访问其age成员

如果本意是让指针从结构体数组的第一个元素移到第二个,再访问第二个元素的age,结果写成了pStu->age++,程序不会报错,但值会变得莫名其妙。这种bug在代码审查里非常难发现,因为编译和运行都不报错,只有逻辑结果不符合预期。我的建议是:所有自增自减操作紧跟箭头或点号时,一律显式加括号,明确表达你的意图。

陷阱二:const位置带来的权限差别。

void func(const Student *ps) { // ps指向的内容是只读的 // ps->age = 10; // 编译错误!不能通过const指针修改成员 printf("%d\n", ps->age); // 读取没问题 } void func2(Student *const ps) { // ps本身不能改变指向,但可以修改指向的内容 ps->age = 10; // 合法 // ps++; // 编译错误!不能修改指针本身 }

const Student *ps表示“指向const Student的指针”,通俗说就是“指向的内容不能改”;Student *const ps表示“Student类型的const指针”,通俗说就是“指针本身不能改”。这两个声明在面试题里出现频率极高,实际写代码时也要分清楚,不然会在维护别人代码时被绕晕。

还有一个我见过不少次的问题:函数形参是void *,拿到后强制转换成结构体指针,然后马上用箭头访问成员:

void callback(void *arg) { Student *ps = (Student *)arg; printf("%d\n", ps->age); }

这种写法本身没问题,是现代C代码里回调函数的常见模式。问题在于,转换前要确保传入的arg确实指向一个合法的Student对象,否则箭头一访问就是未定义行为。调试时如果发现回调函数里一访问就崩溃,优先怀疑传进来的指针类型和预期不符。

5. 一点个人经验

最后说点个人体会。我做嵌入式开发这些年来,“.”和“->”的使用已经快变成肌肉记忆了,但回头看,当年确实在这上面栽过不少跟头,尤其是把指针顺手写成点号、或者被优先级绕进去的时候。后来我给自己定了一个简单的规则:拿到一个变量,先看它的声明里有没有星号。

声明里带星号,说明它是指针,访问成员就用->;声明里不带星号,说明它是普通变量,访问成员就用.。函数形参同理。这个规则简单到有点“笨”,但真的很管用。

另一个建议是,学结构体引用方式时,别死记硬背语法,一定自己动手画一画内存布局图。拿一张纸,画出变量的地址、指针的内容、成员的偏移量,再手动推一遍pStu->age和(*pStu).age到底是怎么找到内存的。等你把“地址”和“对象”这两个概念彻底区分开,不仅点号和箭头不会再搞混,后面学链表、树、指针数组、函数指针回调也都会顺很多。

如果你现在正在学C语言或者刚接触嵌入式,这篇文章里提到的那些报错信息、调试方法,建议收藏起来,等你真遇到时再翻出来对照一遍,会比当时就背下来更有效果。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:08:36

机器视觉光源设计:从照亮物体到构造图像特征

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:07:24

Web性能测试赛题实战:JMeter并发压测、指标建模与报告分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:07:11

MIPI LP RX本质解析:不是低功耗模式,而是D-PHY初始化信号通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:05:39

掌握Android Logcat:从日志写入到高效排查问题的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:05:04

Linux PID 0/1/2:进程树、内核线程与容器实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华