
1. 为什么操作符是C语言的分水岭先说几句大实话很多初学者学C语言背完了数据类型、printf、scanf一到操作符就开始飘。为什么因为操作符看起来太简单了——加减乘除谁不会但真正拉开的差距恰恰就在这里。我记得带过的一个实习生能把指针、结构体讲得头头是道结果在代码里写了个if (a b 0)的表达式编译不报错运行结果却完全不符合预期。查了半天才发现是和的优先级问题的优先级高于所以这行代码实际执行的是if (a (b 0))而不是if ((a b) 0)。这种错误编译器不会给你任何提示它就是静悄悄地返回一个错误的逻辑结果让你在茫茫代码里找得怀疑人生。操作符练习的核心价值不在于你记住了多少个符号而在于你建立起了几种思维优先级思维一个表达式里有多种操作符到底先算谁结合性思维优先级相同的时候是从左往右还是从右往左副作用思维、--、赋值这些操作会改变变量的值这个改变发生在什么时候类型转换思维整型提升、隐式转换操作符两侧的类型不一样时会发生什么这篇文章不打算讲教科书式的操作符手册那东西网上随便一搜一大把。我想做的是把这些年实际写代码、刷题、看别人踩坑总结出来的东西配合可以直接上手的练习题一次性给你捋清楚。适合正在学C语言基础的学生、准备计算机二级或考研的、以及刚入行写嵌入式或底层代码的开发者。2. 优先级和结合性一张表看懂但看懂和用对是两码事2.1 先背表再谈理解C语言操作符大概有四十多个优先级分为15级。你不需要背每一级但必须记住几个关键分界点。我把最常用的整理成一张表按优先级从高到低排列优先级操作符结合性说明最高()[]-.左到右函数调用、下标、成员访问↓!~---*(类型)sizeof右到左单目操作符↓*/%左到右乘除取余↓-左到右加减↓左到右移位↓左到右关系↓!左到右相等性↓左到右位与↓^左到右位异或↓|左到右位或↓左到右逻辑与↓||左到右逻辑或↓?:右到左三目最低-*/^|右到左赋值这张表你必须印在脑子里。但是注意背会了优先级不等于会用因为实际代码里还有结合性和求值顺序的问题。2.2 经典易错题和的优先级之战来看一道几乎所有C语言教材都会收录的经典题也是笔试面试的高频题int a 5; int b a a; printf(a %d, b %d\n, a, b);请问输出是什么如果你回答a 7, b 10那你就掉进坑里了。这段代码在C语言标准里属于未定义行为undefined behavior因为a在两个子表达式中都修改了a的值而C标准规定在两个序列点之间同一个标量对象的修改次数不能超过一次。所以这段代码的输出依赖编译器实现不同的编译器、不同的优化级别结果都可能不同。在gcc下实测结果往往是a 7, b 11或a 7, b 10具体取决于优化选项。这种题如果出现在试卷上标准的答案应该是未定义行为不能确定。这里的教训是不要在实际代码里写这种聪明的表达式。笔试考的是你懂不懂未定义行为的概念而不是让你真的算出某个数。2.3 结合性的实战意义右到左到底怎么理解结合性真正起作用的时候是在同优先级操作符连续出现时。比如int a 3; int b 4; int c 5; int result a - b - c; // 左到右(a - b) - c -6 int result2 a b c; // 右到左a (b c)a、b、c都变成5这里最容易出问题的是赋值操作符的右结合性。链式赋值大家写得多了知道它是从右往左的但三目运算符的右结合性很多人就蒙了。int a 1, b 2, c 3; int max a b ? a : b c ? b : c;这个表达式怎么理解因为?:是右结合的所以它实际上相当于int max a b ? a : (b c ? b : c);先判断a b不成立则执行(b c ? b : c)即b c不成立取c最终结果为3。如果你从左往右结合那逻辑就完全错了。2.4 用括号比献祭脑子划算说了这么多我给你一个实战建议复杂表达式别吝啬括号。不是要你背不住优先级才加括号而是为了让读代码的人不用去查表。你写的代码首先是给人看的其次才是给编译器看的。我现在评审代码的时候看到超过两层优先级的表达式没加括号一律打回。这不是教条这是无数次调试到深夜换来的血泪教训。3. 位操作符练习笔试高频区和嵌入式实战区的交汇点3.1 位操作符其实是性价比最高的考点位操作符一共就6个按位与、|按位或、^按位异或、~按位取反、左移、右移。知识点不多但考法极其丰富而且在实际开发中尤其是嵌入式、协议解析、状态标志位管理用得非常多。我见过太多学生把和、|和||混为一谈。它们最大的区别和||是逻辑运算符结果只有0或1并且有短路求值和|是按位运算作用于每一个二进制位。3.2 从题目里练出肌肉记忆先来看一道经典的题目用位操作实现两个变量值的交换不允许用临时变量int a 3; // 二进制 011 int b 5; // 二进制 101 a a ^ b; // a 110 (6) b a ^ b; // b 110 ^ 101 011 (3) a a ^ b; // a 110 ^ 011 101 (5)三次异或完成交换。原理是异或的自反性x ^ y ^ y x。这个题目考得很频繁但我要提醒你面试时展示这个技巧没问题生产代码里不要这么写。因为它的可读性太差了而且编译器在优化temp a; a b; b temp;这种写法时生成的机器码和你用异或折腾出来的没什么区别甚至更清晰。再看一个嵌入式开发里天天用到的场景给寄存器的某一位置1或清0。// 将 value 的第 3 位从0开始计数置1 value | (1 3); // 将 value 的第 5 位清0 value ~(1 5); // 判断第 7 位是否为1 if (value (1 7)) { // 第7位是1 }这套操作在单片机寄存器配置、状态位检测里出现频率极高。你在数据手册上经常看到bit3置1使能某某功能翻译成C代码就是REG | (1 3)。3.3 让人头疼的移位运算左移还好右移要小心左移相对简单高位丢弃低位补0。相当于乘以2的n次方在不溢出的前提下。右移就有讲究了。对于无符号数右移是逻辑右移高位补0对于有符号负数右移是算术右移高位补符号位即负数补1正数补0。这是C标准留给实现定义的但是在几乎所有主流编译器上有符号数的右移都是算术右移。来一题练练手unsigned char x 0x80; // 二进制 1000 0000 char y 0x80; // 注意char是否带符号由编译器决定 unsigned char r1 x 1; // 结果是 0x40 (0100 0000)逻辑右移 char r2 y 1; // 如果char是signed0x80是负数(-128)算术右移后结果是0xC0 (-64) printf(r1 0x%02X, r2 0x%02X\n, r1, (unsigned char)r2);这段代码能完美演示逻辑右移和算术右移的区别。写底层驱动、协议解析时如果你的数据类型带符号移位前一定要想清楚你要的是哪种右移。实在不确定就先把变量转成无符号类型再移。3.4 位操作笔试题从易到难过一遍我整理了几道不同类型的位操作练习你们可以先自己在编译器里跑一遍再看分析第1题判断一个整数n是不是2的幂次方int isPowerOfTwo(int n) { return n 0 (n (n - 1)) 0; }思路2的幂次方在二进制表示中只有一个1。n (n - 1)能消掉最右边的那个1如果消完变成0说明只有一个1。第2题统计一个整数二进制表示中1的个数int countOnes(int n) { int count 0; while (n) { n n (n - 1); count; } return count; }同样是利用n (n - 1)消掉最右边1的特性循环几次就说明有几个1。这个算法比逐位判断要高效面试官很喜欢问。第3题不用加减乘除做加法int addWithoutArithmetic(int a, int b) { while (b ! 0) { int carry (a b) 1; // 进位 a a ^ b; // 无进位加 b carry; } return a; }思路异或忽略进位与操作找出产生进位的位置左移一位得到进位值再循环相加。这道题对新手来说很烧脑但一步步画二进制推演一遍对理解位操作会有质的提升。4. 表达式求值里的暗坑整型提升、隐式转换与未定义行为4.1 整型提升为什么char会偷偷变成int先看一道题unsigned char a 200; unsigned char b 100; unsigned char c a b; printf(c %u\n, c);直觉上200 100 300但unsigned char最大只能表示255。实际输出是多少是44。原因有两个步骤首先a和b在参与运算时会被整型提升为int类型200 100 300这一步没问题。然后把300赋值回unsigned char发生了截断300的二进制是1 0010 1100截断到低8位就是0010 1100也就是44。这告诉我们类型提升发生在运算之前截断发生在赋值之后。涉及不同类型或不同精度的运算C语言有一套完整的隐式转换规则简单记就是小类型转大类型、有符号转无符号当两者冲突时情况复杂需要查usual arithmetic conversions。4.2 有符号和无符号混用的噩梦这是C语言最经典的坑无数人在这里翻车int a -1; unsigned int b 1; if (a b) { printf(a b\n); } else { printf(a b\n); }你觉得输出是什么-1 1显然成立对吧错了输出是a b。原因当int和unsigned int比较时int会被隐式转换为unsigned int。-1转换成无符号数变成了4294967295即UINT_MAX当然大于1。这种bug在生产代码里极其隐蔽尤其是写循环条件时for (unsigned int i 10; i 0; i--) { // 死循环因为 i 永远不会小于 0 }无符号数的i--到0之后再减会变成UINT_MAX循环条件i 0永远成立。这种错误一旦出现程序表现是卡死排查起来非常痛苦。练习建议写一个小程序专门测试有符号和无符号混用的各种场景——加减乘除、大小比较、循环条件。每次结果和你想的不一样都要停下来追究原因。4.3 短路求值和||的偷懒不是坏事逻辑与和逻辑或||有一个非常重要的特性短路求值short-circuit evaluation。int *ptr getPtr(); if (ptr ! NULL *ptr 42) { // 只有当 ptr 不为NULL时才会解引用 *ptr }左侧为假右侧就不执行了。上面这个写法是C语言最常用的防御性编程手段之一。如果不短路*ptr就可能空指针崩溃。但短路求值有时候也会带来惊喜int a 0; int b 1; if (a b) { // 什么都不做 } printf(a %d, b %d\n, a, b);a后置自增先取a的旧值0参与逻辑判断结果为假b根本不执行。所以输出是a 1, b 1b没有自增。这种题考得很细理解短路求值后就能做对。4.4 求值顺序printf参数和各种看似正常的陷阱C标准规定函数参数的求值顺序是未指定行为unspecified behavior也就是说编译器先算哪个参数都可以。int i 1; printf(%d %d\n, i, i);这个输出依赖编译器。可能是1 2可能是2 1也可能更离谱。标准没有规定先求哪个参数而且修改同一个变量的行为在两个序列点之间同样是未定义行为。还有int arr[5] {1, 2, 3, 4, 5}; int i 0; arr[i] i;这行代码arr[i]和i哪个先求值未指定。结果可能是arr[0] 0也可能是arr[1] 0。任何依赖这种顺序的代码都应该被视为错误代码不管它碰巧在你编译器上运行结果是什么。练习的时候遇到这类题正确的应对方式是不仅能解释各种可能还要明确指出这是未定义/未指定行为不能依赖。这一句话说出来面试官就知道你是真懂C语言而不是靠刷题背答案。5. 一套可复现的操作符练习排查链路预测、编译、分析、修正5.1 练习方法别光看要动手操作符这个东西光看不练等于白学。我建议采用预测-验证四步法来做练习预测看到一段代码先写下你认为的输出结果并写出依据优先级、结合性、类型转换等。验证用编译器实际编译运行记录真实输出。分析如果预测与结果不符逐行打断点或加printf找出是哪一步的推断出了问题。修正把错误的原因记下来用自己的话表达清楚并考虑如何改写代码以避免歧义。下面我准备了一套综合练习覆盖算术、关系、逻辑、位、赋值、逗号、sizeof等各种操作符。你先把输出结果写下来最好写在纸上然后再去运行比对。5.2 综合练习题完整程序段#include stdio.h int main() { int a 10; int b 3; float f 3.14; char ch A; // 题1整数除法与取余 printf(题1: %d %d %d\n, a / b, a % b, -a / b); // 题2浮点数与整数混合运算 printf(题2: %.2f %.2f\n, a f, a / b); // 题3逗号表达式 int x (a, b 2, a b); printf(题3: x%d, a%d, b%d\n, x, a, b); // 题4sizeof的特殊性 int y sizeof(int); int z sizeof(a b); printf(题4: y%zu, z%zu\n, y, z); // 题5逻辑与位运算的差异 printf(题5: %d %d\n, 3 5, 3 5); // 题6三目运算符嵌套 int max (a b) ? (a 15 ? a : 15) : (b 15 ? b : 15); printf(题6: max%d\n, max); // 题7赋值表达式的值 int n; int p (n 7) 3; printf(题7: p%d, n%d\n, p, n); // 题8关系表达式与字符 printf(题8: %d %d\n, ch A, ch a); // 题9自增在数组下标中的副作用 int arr[5] {10, 20, 30, 40, 50}; int i 0; int val arr[i]; printf(题9: val%d, i%d\n, val, i); return 0; }5.3 逐题分析与易错点说明题110 / 3是整数除法结果是3不是3.33。10 % 3结果是1。-a / b即-10 / 3C语言中整数除法向零截断结果是-3不是-4。这里注意C语言标准C99之后规定整数除法向0取整但取余运算结果的符号与被除数相同。-10 % 3的结果是-1不是1也不是2。题2a f中int和double混合a转换为double结果是13.14。a / b中虽然a和b都是整数但除法结果赋值给double类型的临时变量注意这里a / b是先做整数除法得到3再转换成3.00所以输出是3.00。很多人误以为除以整数会自动得到浮点数不会必须先有一个操作数是浮点类型。题3逗号表达式从左到右依次执行a使 a 从10变成11b 2使 b 从3变成5整个表达式的值是最后一个子表达式的值a b 16。输出是x16, a11, b5。逗号运算符的优先级是所有操作符中最低的所以int x (a, b 2, a b);如果没有括号就变成了(int x a), b 2, a b;这是完全不同的含义x的值会变成10a自增前的值。题4sizeof是操作符不是函数sizeof(int)的括号其实是可以省略的当操作数是类型时不能省是表达式时可以省。sizeof(a b)在编译期就会计算不会真的执行a b。结果取决于你的系统32位系统int是4字节64位Linux上int也是4字节size_t类型用%zu格式化输出。还有个经典的坑sizeof(a)不会改变a的值因为表达式根本不会被求值。题53 53的二进制是0115是101按位与结果是001即1。3 5两个非零值逻辑与结果为1。输出是1 1看起来一样但意义完全不同。如果把值换成4和84 8结果是04 8结果是1。这就是位运算和逻辑运算的本质区别。题6嵌套三目右结合性。先判断a b此时a11、b5为真所以走第一个分支(a 15 ? a : 15)a11不大于15取15。所以max是15。题7赋值表达式n 7的值是7所以p 7 3 10n是7。赋值操作符兼备做动作和出结果两种属性这是C语言灵活的地方也是容易写出晦涩代码的地方。题8ch A比较的是字符编码ASCII中A是65相等结果为1。ch a即65 97结果为1。字符在参与比较时其实就是整数。题9arr[i]会先取出arr[0]的值10然后i变为1。所以val 10, i 1。这是后置自增的核心语义先用后加。5.4 练习后的复盘方法如果你做完这套题错误超过两个说明你对操作符的某些细节还没有形成直觉。我的建议是把每道错题的预测结果和实际结果对比写在笔记本上用一句话总结错误原因。不要只记答案要记推理链。比如题2我错了因为整数除法先算完再转浮点推理链是int/int - int - 转double。三天之后重新做一遍如果还错同一个地方才说明真正没掌握。这比我见过的大多数刷题方法有效因为你是在主动回忆而不是被动复习。6. 按场景分组的专项练习从笔试到嵌入式再到日常开发6.1 笔试面试向30秒内给出答案的黄金标准BAT、华为、字节这些公司的C语言笔试题操作符部分是送分题但送分的前提是你有肌肉记忆。以下是我整理的几类高频题型的黄金解法类型一给表达式求值看到带多个操作符的表达式第一件事画括号按优先级从高到低逐层套。永远不要从左往右读。int a 5, b 3, c 2; int r a b c ? 1 : 0;先处理b c得1然后a 1得0三目取0。所以r是0。这种题熟练之后应该在10秒内解完。类型二找出代码中的bugif (x 1 0) { // 判断x是否为偶数 }这是个无数人写过的错误代码。的优先级高于所以实际是x (1 0)即x 0恒为0if永远不成立。正确写法是if ((x 1) 0)。类型三sizeof 陷阱void func(char str[100]) { printf(%zu, sizeof(str)); // 输出多少 }数组作为函数参数时会退化成指针所以sizeof(str)是指针的大小在64位系统上是8而不是100。同理int arr[10]; printf(%zu, sizeof(arr)); // 40 (假设int是4字节) printf(%zu, sizeof(arr) / sizeof(arr[0])); // 10数组长度6.2 嵌入式向寄存器操作与位域辨析嵌入式开发者用操作符最密集的场景就是寄存器操作。我举一个实际的例子以GPIO配置寄存器为例简化的写法不同芯片有差异// 假设寄存器地址 #define GPIO_CTRL ((volatile unsigned int *)0x40021000) // 清零bit[3:2] *GPIO_CTRL ~(0x3 2); // 设置bit[3:2]为10二进制 *GPIO_CTRL | (0x2 2);这几行代码里包含了取地址、解引用、强制类型转换、移位、按位与、按位或、赋值等多种操作符。要理解它你需要知道(volatile unsigned int *)0x40021000是一个指针常量指向固定的内存地址。*(...)解引用访问该地址。~(0x3 2)生成一个掩码把bit3和bit2清为0。0x2 2得到二进制1000置位bit3。如果这部分代码写错了优先级搞错寄存器会被写成完全错误的值硬件行为不可预测。这种错误在开发板上调试时非常难发现因为编译器不会报警硬件只是不工作。另外volatile在这里也至关重要。如果去掉它编译器可能把 ~...优化成先读一次、再写一次而寄存器值可能在两次操作之间被硬件改变导致丢位。这也是为什么嵌入式代码里操作符和限定符往往是配套出现的。6.3 日常开发向可读性优先的操作符使用规范我在实际带团队的时候会明令几条操作符使用规范这里分享给你参考逻辑运算和位运算分开写不混用。if ((status FLAG) FLAG)必须加括号不要写成if (status FLAG FLAG)。判断布尔值不要写 1或! 0。直接if (flag)或if (!flag)就够了flag 1这种写法在flag被赋值为非1的值时会误导。自增自减不要嵌在复杂表达式里。arr[i] i offset这种代码一律改成多行先i再单独赋值。三目运算符最多嵌套一层超过一层就用if-else。移位操作的位数不能大于或等于类型宽度。比如int是32位1 32是未定义行为就算你期望结果是0也不要这么写。这些规范不是为了限制你而是为了让你成为更好的合作者。代码写出来是团队共同维护的资产不是个人炫技的作品。7. 常见操作符错误自查表运行时踩坑的定位清单最后我整理了一份操作符相关的运行时错误自查表。当你的程序输出不符合预期时按这个顺序逐项排查大部分灵异事件都能找到原因。现象可能原因排查方向条件判断与预期相反和混用优先级错误给每个逻辑段加括号打印中间值循环提前退出或死循环无符号整数与0比较i--在无符号类型下回绕检查循环变量类型改成有符号或改用i 0作为条件整数值突然变大 / 变小有符号与无符号混用导致隐式转换用-Wall编译观察警告除法结果不是小数两个整数相除结果被截断除法的操作数至少一个转成浮点类型数组越界访问后置/前置自增在下标中造成偏移错误单独使用自增不要嵌入下标移位后值不对有符号负数右移移位位数超过类型宽度先转无符号再移位检查移位位数寄存器值配置不对按位运算优先级错误与 混用对照这张表你可以把之前写过的练习、作业、小项目的bug拿出来重新分析一遍看看到底是哪种操作符问题在作祟。8. 最后再分享一个提升练习效果的小技巧操作符确实是C语言里最需要做题反思的知识点。要说有什么捷径我的经验是用最小复现法训练遇到一个易错表达式不要只在脑子里推演把它独立写成一个几行的最小程序在VSCode或C-Free里跑一遍打断点观察变量值的变化。跑完再改一个条件比如把int改成unsigned int把前置改成后置再看结果变不变。这样来回几次你对这个表达式的理解就不再是背下来的结论而是真正看到它在内存层面是怎么运作的。另外提一句现在很多在线判题平台和C语言练习网站都有专门的操作符分类题库你可以用那些题做日常训练但一定记住不要只追求刷题数量每一道题都要能讲清楚为什么是这个结果。讲不清楚过两天还是会忘。如果你练完这篇文章里的题或者自己写了什么有意思的操作符练习代码欢迎一起交流。操作符这个东西看起来小用好了是真功夫。