Lecture 5: C 语言入门:数据类型、运算符、作用域与存储期 (Introduction to C: Data Types, Operators, Scope and Storage)
Lecture 5: C 语言入门:数据类型、运算符、作用域与存储期 (Introduction to C: Data Types, Operators, Scope and Storage)
概述
本讲回答一个自底向上 (bottom-up) 的核心问题:为什么高级语言需要”类型”,以及类型信息如何决定机器码的生成。 我们引入 C 的数据类型表(基本类型与派生类型)、字面量 (literal) 与后缀 (suffix)、六大类运算符及其优先级与短路求值 (short-circuit evaluation)、 隐式转换 (implicit conversion) 与整型提升 (integer promotion),以及作用域 (scope) 与存储期 (storage duration) 这两把”变量住在哪里、活多久”的尺子。 这些概念直接承接 ECE 120 的 LC-3 汇编与内存映射 (memory map):类型是编译器决定该生成 ADD、MUL 还是浮点库调用 (library call) 的唯一依据, 作用域与存储期则决定一个变量落在全局数据区 (global data)、栈 (stack) 还是堆 (heap)——这正是后续函数、指针、动态内存分配三讲的共同地基。
核心概念与底层机制图解
- C 是”高级汇编” (C as a high-level assembly language):C 与典型 ISA 之间有一层透明的映射 (transparent mapping),人容易理解、编译器也容易生成。
- 直观解释:写汇编时你自己决定”哪个数据放哪个寄存器、哪块内存”;写 C 时你只给变量起个符号名 (symbolic name) 并声明类型,由编译器决定存放位置——像把”手工点名分座位”换成”报人数让系统自动排座”。
- 底层机制图解:编译器分前端 (front end,语言相关) 与后端 (back end,ISA 相关) 两半,中间的接口叫中间表示 (intermediate representation, IR)。 这样 10 种语言 × 10 种 ISA 只需写 10 + 10 = 20 个模块,而不是 100 个编译器。
C 源文件 + 头文件 │ C 预处理器 (preprocessor):展开 #include / #define ▼ 预处理后的源码 ──前端──▶ IR ──后端──▶ LC-3 / x86-64 汇编 │ 汇编器 (assembler) ▼ 目标文件 (.o) │ 链接器 (linker) ▼ 可执行文件 + 动态链接库gcc默认把预处理器、汇编器、链接器一路跑完;只想看中间产物时用-E(预处理)、-S(汇编)、-c(目标文件)。 - 作用域与存储期:这一层映射是”编译期”概念——变量的位置在编译时就被静态决定(写成
R5+0、R4+2这样的偏移), 运行期改变的只有这些位置里的位 (bits),而不是位置本身。例外是后面要学的动态分配 (dynamic allocation),它的位置在运行期才确定。
- 数据类型 (Data Type):类型告诉编译器三件事——需要多少位 (size)、这些位用哪种编码 (encoding) 解释、以及该生成哪条指令去运算。
- 直观解释:同一串 32 位
0xFFFFFF88,当 2 的补码 (2’s complement) 是 −120,当无符号数 (unsigned) 是 4 294 967 176;类型就是贴在内存上的标签,决定这串位被当成什么。 底层机制图解:ECE 220 官方 C 编程参考给出的基本类型表(与
res_c_prog_ref一致):type name size encoding precision min value max value bool1 bit ** 0:false 1:true exact integer 0 1 char8 bits signed integer *** exact integer −128 127 unsigned char8 bits unsigned integer exact integer 0 255 short16 bits signed integer exact integer −32768 32767 unsigned short16 bits unsigned integer exact integer 0 65535 int32 bits * signed integer exact integer −2.1 billion * 2.1 billion * unsigned int32 bits * unsigned integer exact integer 0 4.3 billion * long64 bits * signed integer exact integer −9.2 × 10¹⁸ * 9.2 × 10¹⁸ * unsigned long64 bits * unsigned integer exact integer 0 1.8 × 10¹⁹ * float32 bits floating point 7 decimal digits −3.4 × 10³⁸ 3.4 × 10³⁸ double64 bits floating point 16 decimal digits −1.8 × 10³⁰⁸ 1.8 × 10³⁰⁸ *
int与long的尺寸/范围假定运行在 EWS 实验机的 64 位处理器上;在 32 位、16 位平台(例如 LC-3)上可能更小。 **bool只需 1 位,但编译器会把它补齐 (pad) 到 1 字节(或机器的可寻址单位 addressability)。 ***char更常见的用途是保存 ASCII 字符,此时可以把整数值看成它对应的 ASCII 字符。LC-3 是 16 位可寻址机器,一个字 (word) = 16 位,所以课程里的 C 代码常用
<stdint.h>提供的与 ISA 无关的类型 (ISA-independent integer types):int8_t / uint8_t(8 位)、int16_t / uint16_t(16 位)、int32_t / uint32_t(32 位)、int64_t / uint64_t(64 位)。 除了main与部分库函数调用,ECE 220 一律建议用这些名字,因为它们的位数在任何平台上都一样。- 作用域与存储期:类型本身没有作用域与存储期,但类型决定了变量名在机器码里如何被翻译成偏移:
int32_t在 LC-3 上占 2 个内存字,int16_t占 1 个字,于是symbol_table中相邻变量的偏移相差 1 或 2。
- 直观解释:同一串 32 位
- 派生类型 (Derived Type):由基本类型构造出来的类型——指针 (pointer)、数组 (array)、结构体 (struct)。
- 直观解释:指针像门牌号(内容是一个地址),数组像一排等大紧挨的储物柜(从 0 号开始),结构体像把几件行李捆成一箱(成员依次摆放,可能要垫泡沫)。
底层机制图解:官方参考给出的派生类型表:
type operator example size description pointer to Z *int * x;64 bits (8 bytes) § 存放某个 Z 类型对象的地址 array of n Zs []int x[4];n × sizeof(Z) §§§ 连续分配的 n 个 Z 类型对象 structure structstruct { int x, y; } vector;各成员大小之和 §§ 依次分配的成员对象集合 § 假定 64 位处理器;§§ 编译器可能插入填充字节 (padding),使结构体比预期更大;§§§ 例外:数组作为函数参数时会被立刻转换成指向首元素的指针,见下。
int32_t arr[4]; /* 4 个 32 位元素,共 16 字节 */ 地址(假设) 0x1000 0x1004 0x1008 0x100C +--------+--------+--------+--------+ arr: \| 10 \| 20 \| 30 \| 40 \| +--------+--------+--------+--------+ ^ arr 在表达式里退化为 &arr[0];int32_t *p = arr; 后 p 里就是 0x1000数组参数退化是官方参考中明确写出的例外:
void foo(int x[4]);会被编译器立刻转换成void foo(int * x);。 所以在被调用者里sizeof(x)得到的是指针大小(ECE 220 实验机上是 8 字节),而不是数组大小——数组长度必须另开一个参数传进去。- 作用域与存储期:派生类型变量的存储期由声明位置决定。函数内
int32_t arr[4];是 automatic,随栈帧建立与销毁; 文件外层的static int32_t arr[4];是 static,落在全局数据区,随程序存在。
- 字面量 (Literal) 与后缀 (Suffix):字面量是直接写在代码里的常量;后缀用来强制指定这个常量的类型。
- 直观解释:后缀像单位标签:写
42是”整数 42”,写42.0f是”单精度浮点 42”,写42ul是”无符号长整数 42”。这在你想让表达式走浮点运算时非常关键。 - 底层机制图解:官方参考的后缀表:
ul→unsigned long,u→unsigned int,l→long,无后缀 →int,.f→float,.→double。 字符字面量'c'的类型是int(值是 ASCII 码);字符串字面量"hello"的类型是char *,并且隐式地在静态内存区分配并初始化一个以 NUL 结尾的字符数组。 允许的转义序列包括\0(NUL)、\n(换行)、以及\\(一个反斜杠)。字面量本身分配在临时空间(例如寄存器),但它赋值给的变量不一定。 注意:后缀不会改变被赋值变量的类型——变量类型永远由声明决定。 - 作用域与存储期:字面量没有名字也没有作用域;字符串字面量所在的字符数组是 static storage duration,程序运行期间一直存在。
- 直观解释:后缀像单位标签:写
- 运算符 (Operator):C 提供算术、关系、逻辑、位运算、赋值、自增自减、条件、逗号等运算符,用来把变量与字面量组成表达式 (expression)。
- 直观解释:运算符就是”计算的动作”,麻烦之处是同一符号在不同上下文含义不同——
&在位运算里是”按位与”,在单目位置上是”取地址”。 底层机制图解:ECE 220 关注的六类运算符及其机器码对应:
类别 运算符 结果类型 与 LC-3 的对应 算术 + - * / %操作数”较大”的那个类型 ADD;*需要循环加或库调用;/、%需要除法子程序位运算 & \| ~ ^ << >>整数类型 AND、NOT+ADD #1、XOR、移位循环关系 < <= == != >= >恒为 int的 0 或 1条件码 (condition code) N/Z/P+BRnzp逻辑 && \|\| !恒为 int的 0 或 1短路求值 → 多段 BR跳转赋值 = += -= *= /= %= &= \|= ^= <<= >>=右值 (right-hand side) 的类型 STR:把结果写回左值的地址其它 ++ -- ?: ,见下 自增=读+改+写回; ?:=BR+ 两个赋值块必须记住的语义细节:
- 整数除法向 0 取整:
11 / 3得 3,-11 / 3得 −3;%定义为满足(A / B) * B + (A % B) == A,所以-11 % 3得 −2(%的结果不一定非负); 又因为整数除法丢精度,(100 / 8) * 8得 96,不是 100。 - 移位:
<<相当于乘 2^N,左端溢出的位直接丢失;>>的行为取决于类型——有符号数做算术右移(复制符号位),无符号数做逻辑右移(左边补 0)。 - 关系运算符恒产生 0 或 1;逻辑运算符只看”真/假”,0 是假、非 0 是真,结果也恒为 0 或 1。
- 短路求值:
&&在左操作数为假时不再算右边,\|\|在左操作数为真时不再算右边。这是”保护危险操作”的常用手法,例如if (0 <= dist_sq && walk_p (me, sqrt (dist_sq)))。 =是表达式,其值是右值,所以A = B = 0;合法,等价于A = (B = 0);;左值 (l-value) 必须有地址,A + B = 42;一定编译失败。++/--的前后缀差别只在”取用表达式的值”时才出现:i++先读值再自增,++i先自增再读值;?:是三目运算符,A = (B > 0 ? C : D);等价于一段if/else赋值;逗号运算符依次求值并取最后一个的值(常用于for (i = 0, j = 5; i < 3; i++, j--))。- 优先级:不要背表。乘法类 > 加减 > 移位 > 关系 > 相等 > 位与 > 异或 > 位或 > 逻辑与 > 逻辑或 > 条件 > 赋值 > 逗号。只要一眼看不出顺序,就加括号。
- 整数除法向 0 取整:
- 作用域与存储期:运算符没有作用域;但”求值顺序”在 C 中大部分未指定 (unspecified)——
f(g(), h())里哪个先算由编译器决定, 所以不要在同一个表达式里对同一变量既读又写(例如j = (++i) + (j++)),那属于未定义行为 (undefined behavior, UB)。
- 直观解释:运算符就是”计算的动作”,麻烦之处是同一符号在不同上下文含义不同——
- 隐式转换 (Implicit Conversion)、整型提升 (Integer Promotion) 与截断 (Truncation):不同类型混在一个表达式里时,编译器会按规则”往较大的类型”转。
- 直观解释:像把两种货币放进同一个收银机:编译器先都换成”大面额”再算,算完塞回你声明的那个小盒子里——塞不进去的部分就被截掉了。
- 底层机制图解:
int x; x = 3 + 4.6;的四步:①把3从int转成double;②两个double相加得7.6;③把和转回int,截断成7;④把7的位写进x。 更隐蔽的是有符号与无符号混用:unsigned a = 10; int b = -20; if (a + b < 0) { /* 不会进来! */ printf ("ok"); }a + b先把b转成unsigned,结果按无符号解释是 4 294 967 286,不可能小于 0。加上显式强制转换((int)a) + b < 0才会得到 −10。 整型提升:比int窄的类型(char、short、bool)在参与算术前会先提升为int,所以int16_t big = 32767; big + 1得到的是int的 32768,而把它存回int16_t又会绕回 −32768。 - 作用域与存储期:转换只发生在表达式求值期间,产生临时值 (temporary);临时值通常放在寄存器或编译器安排的溢出槽 (spill slot) 里,不改变原变量的类型与存储。
- 作用域 (Scope):一个名字在程序的哪一段里”看得见”。
- 直观解释:作用域像办公室的权限范围:全局变量是”全公司都能改的公告板”,文件作用域是”本部门内部备忘录”,块作用域是”会议室白板,散会就擦掉”。
- 底层机制图解:C 允许三种作用域:
- 文件作用域 (file scope):写在所有函数外面并用
static修饰,如static int my_var;,只在本文件可见; - 函数 / 块作用域 (function / block scope):写在花括号里面,即局部变量 (local variable),只在那个块里能用;
- 全局作用域 (global scope):写在所有函数外面且不加
static,全程序可见(本讲建议避免)。 编译器内部维护一张符号表 (symbol table) 决定每个名字怎么寻址:
scope identifier type from offset translate.c the_numberint32_tR40 find_abs abs_valueint32_tR50 find_abs numint32_tR54 表里 “from/offset” 就是机器码的寻址方式:
LDR R0,R4,#0读全局变量,LDR R0,R5,#4读参数,STR R0,R5,#0写局部变量。static在函数外与函数内含义不同:函数外改变的是作用域(把全局名字收窄到本文件),函数内改变的是存储期(automatic → static)。 - 文件作用域 (file scope):写在所有函数外面并用
- 作用域与存储期:作用域是”能否用名字访问”,存储期是”这块内存何时存在”,两者互相独立:
static局部变量的名字只在块内可见,存储却从程序开始活到结束。
- 存储期 (Storage Duration) 与内存映射 (Memory Map):变量在内存里”活多久、住在哪一段”。
- 直观解释:把内存想成一栋楼:低层是物业(操作系统),中层是办公楼(代码与全局数据),高层是员工储物柜(栈,从顶往下用),中层与高层之间还有一片”临时租用的仓库”(堆)。
底层机制图解:C 有三种存储类别 (storage class):
存储类别 何时创建/销毁 存放位置 LC-3 中的指针 static 程序开始到程序结束 全局数据区 (global data) R4指向该区域顶部automatic 进入块时创建,离开块时销毁 栈 (stack) R6指向栈顶,R5是帧指针dynamic 按需创建与销毁(没有名字,必须由程序自己跟踪地址) 堆 (heap) 由程序自己保存地址 LC-3 内存映射(地址用十六进制,LC-3 共 2¹⁶ 个 16 位字):
高地址 +--------------------------+ xFFFF \| 内存映射 I/O (MMIO) \| xFE00 - xFFFF ← 系统空间 +--------------------------+ \| 栈 stack(向低地址增长) | R6 → 栈顶,R5 → 当前帧 | main / foo / ... 的数据 | 每调用一个函数就长出一帧 +--------------------------+ \| 堆 heap(动态分配) \| malloc 一类调用管理,向下生长 +--------------------------+ \| 全局数据 global data | R4 → 区域顶部 | 静态变量、字符串字面量 | +--------------------------+ \| 代码 code \| x3000 起(惯例) +--------------------------+ \| 中断/异常向量表 | x0100 - x01FF | 陷阱向量表 (trap vector) | x0000 - x00FF 低地址 +--------------------------+栈与堆从两端向中间生长;一旦相撞:在 LC-3、嵌入式 ISA 或操作系统内部是静默的数据损坏 (silent data corruption),多数 ISA 的用户程序则由硬件检测并让程序崩溃。 全局数据里的变量是程序镜像 (program image) 的一部分,默认初值为 0;自动变量的初值是”位 (bits)”——可能有 0,也可能没有。
作用域与存储期:三者的组合由”在哪声明 + 是否
static“决定:声明位置 + 是否 static → (作用域, 存储期) 函数/块之外 + 不加 static → (全局作用域, static) 函数/块之外 + 加 static → (文件作用域, static) 函数/块之内 + 不加 static → (块作用域, automatic) 函数/块之内 + 加 static → (块作用域, static) dynamic 存储期必须靠显式分配(malloc)获得,稍后一讲
- 为什么要避免全局变量 (Why Avoid Globals):全局变量让”名字管理”变成噩梦,收益极小、代价极大。
- 直观解释:想象 1 000 000 行程序、20 个程序员共用一个命名空间,还要和库代码的名字共存——你不可能记得住哪些名字已被用过。
- 底层机制图解:全局名字不能被”局部化”,链接器 (linker) 必须把它们放进同一个符号表,一个名字冲突就是一次链接错误,而意外同名(你的
count与库里的count)可能导致静默的行为改变。 相比之下,文件作用域的static名字不进全局符号表,不会与其他文件冲突。 - 作用域与存储期:全局变量是 static 存储期 + 全局作用域,因此它的每一次读写都跨越了模块边界,把”局部推理”变成”全局推理”。
代码示例与底层机制分析
代码 (C) — 类型、运算符、转换、作用域与存储期总览:
/*
* ECE220 Lecture 5 demo -- types, sizes, operators, conversion, scope.
* Build: gcc -g -std=c99 -Wall -Werror l05_types.c -o l05_types
*/
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
/* file scope + static storage duration: exists for the whole program */
static int32_t file_wide_counter = 0;
/* a function whose local has static storage duration */
static int32_t how_many_calls(void)
{
static int32_t call_count = 0; /* block scope, static duration */
call_count = call_count + 1;
file_wide_counter = file_wide_counter + 1;
return call_count;
}
/* proof that short-circuit evaluation really skips work */
static int32_t side_effect(const char *name)
{
printf(" [side_effect(%s) ran]\n", name);
return 1;
}
struct point {
int32_t x;
int32_t y;
};
int main(void)
{
/* ---------- 1. sizes and encodings ---------- */
printf("sizeof(bool)=%zu sizeof(char)=%zu sizeof(short)=%zu\n",
sizeof(bool), sizeof(char), sizeof(short));
printf("sizeof(int)=%zu sizeof(long)=%zu sizeof(int32_t)=%zu\n",
sizeof(int), sizeof(long), sizeof(int32_t));
printf("sizeof(float)=%zu sizeof(double)=%zu sizeof(void *)=%zu\n",
sizeof(float), sizeof(double), sizeof(void *));
printf("sizeof(struct point)=%zu\n", sizeof(struct point));
/* ---------- 2. integer vs floating point arithmetic ---------- */
printf("11/3=%d -11/3=%d -11%%3=%d (100/8)*8=%d\n",
11 / 3, -11 / 3, -11 % 3, (100 / 8) * 8);
printf("11.0/3=%f 3+4.6=%f (int)(3+4.6)=%d\n",
11.0 / 3, 3 + 4.6, (int)(3 + 4.6));
/* ---------- 3. wrapping of narrow and unsigned values ---------- */
{
uint8_t byte = 255;
uint32_t word = 0;
int16_t big = 32767; /* largest int16_t */
int promoted = big + 1; /* computed as int -> 32768 */
int16_t back = (int16_t)promoted; /* conversion back: wraps */
byte = byte + 1; /* wraps around */
word = word - 1; /* wraps around */
printf("uint8 255+1=%u uint32 0-1=%u\n", (unsigned)byte, word);
printf("int16 32767+1 as int=%d, stored back as int16=%d\n",
promoted, (int)back);
}
/* ---------- 4. bitwise operators ---------- */
{
int a = 120; /* 0x00000078 */
int b = 42; /* 0x0000002A */
unsigned int u = 0xFFFFFF00u;
int negative = -120; /* 0xFFFFFF88 */
printf("a&b=%d a|b=%d a^b=%d ~a=%d a<<2=%d\n",
a & b, a | b, a ^ b, ~a, a << 2);
printf("negative>>2=%d u>>4=0x%08X\n", negative >> 2, u >> 4);
}
/* ---------- 5. signed / unsigned comparison trap ---------- */
{
unsigned int a = 10;
int b = -20;
if (a + b < 0) {
printf("a+b < 0 is true\n");
} else {
printf("a+b < 0 is false (a+b printed with %%u is %u)\n", a + b);
}
if ((int)a + b < 0) {
printf("with an explicit (int) cast the test is true\n");
}
}
/* ---------- 6. precedence, ternary, comma, assignment ---------- */
{
int x = 2 + 3 * 4; /* * binds tighter than + */
int y = (2 + 3) * 4; /* parentheses win */
int z = 10 - 4 - 3; /* left associative -> 3 */
int big = 42;
int small = 0;
int t = (big > 8 ? 8 : big);
int i;
int j;
printf("2+3*4=%d (2+3)*4=%d 10-4-3=%d ternary=%d\n", x, y, z, t);
for (i = 0, j = 5; i < 3; i++, j--) {
printf("comma-update: i=%d j=%d\n", i, j);
}
small = big = 0; /* assignment is an expression */
printf("after small = big = 0: small=%d big=%d\n", small, big);
}
/* ---------- 7. short-circuit evaluation ---------- */
printf("left side of || is true:\n");
if (1 || side_effect("first ||")) {
printf(" result of || is true\n");
}
printf("left side of && is false:\n");
if (0 && side_effect("first &&")) {
printf(" never printed\n");
} else {
printf(" result of && is false\n");
}
/* ---------- 8. scope and storage duration ---------- */
{
int32_t inner = 7; /* block scope, automatic duration */
int32_t first;
int32_t second;
/* one statement per call: argument evaluation order is unspecified */
first = how_many_calls();
second = how_many_calls();
printf("call 1 -> %d, call 2 -> %d\n", (int)first, (int)second);
printf("file-wide counter=%d, inner=%d\n",
(int)file_wide_counter, (int)inner);
}
printf("the static local survived its block: next call -> %d\n",
how_many_calls());
return 0;
}
实际编译运行结果(gcc -g -std=c99 -Wall -Werror l05_types.c -o l05_types && ./l05_types,本机 gcc 12.2.0 / x86-64 Linux):
sizeof(bool)=1 sizeof(char)=1 sizeof(short)=2
sizeof(int)=4 sizeof(long)=8 sizeof(int32_t)=4
sizeof(float)=4 sizeof(double)=8 sizeof(void *)=8
sizeof(struct point)=8
11/3=3 -11/3=-3 -11%3=-2 (100/8)*8=96
11.0/3=3.666667 3+4.6=7.600000 (int)(3+4.6)=7
uint8 255+1=0 uint32 0-1=4294967295
int16 32767+1 as int=32768, stored back as int16=-32768
a&b=40 a|b=122 a^b=82 ~a=-121 a<<2=480
negative>>2=-30 u>>4=0x0FFFFFF0
a+b < 0 is false (a+b printed with %u is 4294967286)
with an explicit (int) cast the test is true
2+3*4=14 (2+3)*4=20 10-4-3=3 ternary=8
comma-update: i=0 j=5
comma-update: i=1 j=4
comma-update: i=2 j=3
after small = big = 0: small=0 big=0
left side of || is true:
result of || is true
left side of && is false:
result of && is false
call 1 -> 1, call 2 -> 2
file-wide counter=2, inner=7
the static local survived its block: next call -> 3
【代码做什么?】
- 第 1 段用
sizeof打印每种基本类型与一个结构体的字节数,验证基本类型表(bool被补齐成 1 字节,struct point两个int32_t共 8 字节)。 - 第 2 段对照整数与浮点运算(整数除法向 0 取整、
3 + 4.6被截断);第 3 段让uint8_t/uint32_t溢出回绕,并展示整型提升:int16_t的 32767 加 1 在int里得 32768,存回int16_t又绕回 −32768。 - 第 4、5 段对
a = 120 (0x78)、b = 42 (0x2A)做全部位运算,对比有符号/无符号右移,并复现”有符号无符号混用”陷阱。 - 第 6、7 段验证优先级、
?:、逗号运算符、”赋值是表达式”,并用带打印的side_effect证明短路求值真的跳过了右操作数。 - 第 8 段证明
static局部变量的存储期跨越了它所在的块,而inner出了块就不可见。
【底层机制透视】
3 + 4.6的过程完全是编译期决定的:编译器看到int与double,生成”把3转成double(cvtsi2sd)→ 浮点加 → 转回int(cvttsd2si,向 0 截断)”三条指令。它不会报错,也不会警告——自动转换是静默的。uint8_t byte = 255; byte = byte + 1;中,byte先被整型提升成int参与加法得 256,再截断回 8 位得 0——所以窄类型的加法”不会提前溢出”,赋值时才回绕。negative >> 2得到 −30(0xFFFFFFE2)而不是正数,是因为int的右移是算术右移,左侧补的是符号位; 同一份位模式用unsigned int解释时右移是逻辑右移,所以0xFFFFFF00u >> 4得0x0FFFFFF0。 同一个>>运算符,编译器根据类型生成两条不同的指令序列——这正是”类型为编译器提供信息”的最直接证据。a + b < 0中,b被转换成unsigned int(转换规则:有符号与同宽无符号相遇时,转成无符号), 于是 −20 变成 4294967276,加 10 得 4294967286(打印出来正是4294967286),自然不小于 0。static int32_t call_count;放在函数里:它不占栈帧,而是落在全局数据区并默认为 0,名字只在该函数体内可见——”作用域 ≠ 存储期”的活教材。for (i = 0, j = 5; i < 3; i++, j--)的初始化与更新部分各是一个逗号表达式:先算左边、再算右边,整个表达式的值是右边那个。
【内存布局图解】
编译器为这个程序安排的内存(示意;LC-3 中以 R4/R5/R6 + 偏移表示):
高地址 +---------------------------------------------+
| 栈 main 的栈帧:inner / first / second / i / |
| j / x / y / z / big / small / t / a / b | ← automatic,离开块即销毁
+---------------------------------------------+
| 堆 heap(本讲未使用 malloc) |
+---------------------------------------------+
| 全局数据区:file_wide_counter = 2(static) |
| how_many_calls.call_count = 3 | ← 块作用域,但 static 存储期
| 字符串字面量 " [side_effect(%s) ran]\n" |
+---------------------------------------------+
| 代码 code(main、how_many_calls、...) |
低地址 +---------------------------------------------+
【手工位级推演 (hand trace):这些位到底落在哪里】
| 语句 | 表达式的类型 | 位模式(按该类型编码) | 存进变量后的解释 |
|---|---|---|---|
int a = 120; | int | 0x00000078 | 120 |
int b = 42; | int | 0x0000002A | 42 |
a & b | int | 0x00000028 | 40 |
a \| b | int | 0x0000007A | 122 |
a ^ b | int | 0x00000052 | 82 |
~a | int | 0xFFFFFF87 | −121(2 的补码) |
a << 2 | int | 0x000001E0 | 480 |
-120 >> 2 | int | 0xFFFFFFE2 | −30(算术右移) |
0xFFFFFF00u >> 4 | unsigned int | 0x0FFFFFF0 | 4 026 531 824(逻辑右移) |
(uint8_t)255 + 1 | 先提升为 int = 256,存回 8 位 | 0x00 | 0(回绕);int16_t 32767 + 1 则得 int 的 0x00008000 = 32768,存回 int16_t 是 −32768 |
【与汇编的对应】(LC-3:把上表中的关键表达式翻译出来)
; 假设 a 在 R5+0,b 在 R5-1,结果写到 R5-2(都是 int16_t 以适配 LC-3)
; ---- a & b ---- 类型是"有符号整数",用 AND
LDR R0,R5,#0 ; R0 <- a
LDR R1,R5,#-1 ; R1 <- b
AND R0,R0,R1 ; 按位与,与类型的有符号/无符号无关
STR R0,R5,#-2
; ---- ~a(按位取反)与 -a(取负)是两回事:~a 是 NOT,-a 是 NOT 后 ADD #1 ----
LDR R0,R5,#0
NOT R0,R0 ; ~a:每位取反
STR R0,R5,#-2
; ---- a << 2 ---- 左移 2 位 = 自己加自己两次
LDR R0,R5,#0
ADD R0,R0,R0 ; << 1
ADD R0,R0,R0 ; << 2
STR R0,R5,#-2
; ---- 短路求值 if (0 && side_effect(...)) ----
; 编译器只生成"求值左操作数 -> 若为假则跳到 else"的代码,
; 右操作数的代码块根本不会被跳进去:
AND R0,R0,#0 ; 左操作数 0
BRz ELSE_BLOCK ; 结果为假,直接跳过右操作数的求值代码
JSR SIDE_EFFECT ; 这行永远不会被执行
ELSE_BLOCK
; ...
; ---- 右移:int 补符号位(算术右移),unsigned 补 0(逻辑右移)----
代码 (C) — 求值顺序未指定(第二例,短):
/*
* ECE220 Lecture 5 demo -- argument evaluation order is unspecified.
* Build: gcc -g -std=c99 -Wall -Werror l05_eval_order.c -o l05_eval_order
*/
#include <stdint.h>
#include <stdio.h>
static int32_t trace[4];
static int32_t produced = 0;
static int32_t label(int32_t tag)
{
trace[produced] = tag;
produced = produced + 1;
return tag;
}
static int32_t combine(int32_t first, int32_t second, int32_t third)
{
return first * 100 + second * 10 + third;
}
int main(void)
{
int32_t i;
int32_t packed;
/* The C standard fixes no order for the arguments of a call; gcc on
x86-64 evaluates them right to left. The value of the expression is
the same either way, but the side effects are observable. */
packed = combine(label(1), label(2), label(3));
printf("combine=%d, argument evaluation order:", (int)packed);
for (i = 0; i < produced; i++) {
printf(" %d", (int)trace[i]);
}
printf("\n");
return 0;
}
实际编译运行结果:
combine=123, argument evaluation order: 3 2 1
【代码做什么?】 label() 每次被调用就记录自己的编号并返回该编号。printf 打印出的顺序是 3 2 1,说明实参被从右往左求值; 而 combine 的结果仍然是 123——”哪个先算”不影响表达式的值,只影响副作用 (side effect) 的发生顺序。
【底层机制透视】 C 标准只规定函数调用实参的值都被算出来(且不能互相干扰),却没有规定先后;x86-64 的 System V 调用约定把前几个实参放进寄存器, gcc 为图方便从最右边的实参开始填,于是出现 3 2 1。LC-3 的调用约定同样”从右往左压栈”——这不是巧合:它让第一个参数总是落在固定偏移上(下一讲详解), 也正是 printf 这类可变参数函数 (variable-argument function) 能工作的前提。
【内存布局图解】
栈上(gcc 12.2.0 -O0,x86-64):combine 的三个实参各占调用者栈帧里的一个槽
高地址 +----------------+
| return address |
+----------------+
| third = 3 | ← 先求值,先占位(最右边的实参)
+----------------+
| second = 2 |
+----------------+
| first = 1 | ← 最后求值(最左边的实参)
低地址 +----------------+
trace[] 在全局数据区,按求值顺序记录:3, 2, 1
【与汇编的对应】
; 从右往左求值 = 先算第三个实参、最后算第一个实参:
LEA R1,THIRD_VAL ; 最右边的实参
JSR LABEL
STR R0,R6,#-1 ; 压入第三个实参的副本
LEA R1,SECOND_VAL
JSR LABEL
STR R0,R6,#-2 ; 压入第二个实参的副本
LEA R1,FIRST_VAL
JSR LABEL
STR R0,R6,#-3 ; 第一个实参的副本(地址最低)
JSR COMBINE
; 第一个实参永远在最"新"的位置(地址最低)——这就是"右到左压栈"的机器码形态。
常见错误与调试技巧
- 有符号与无符号混用 (signed/unsigned mixing):
if (a + b < 0)永不成立、0 <= i对无符号i恒真,原因是混合表达式整体转成无符号。调试:gcc -g -std=c99 -Wall -Werror -Wextra -Wsign-compare file.c -o file;在gdb里用p (unsigned int)b、p a + b看提升后的值;把操作数统一成int32_t或uint32_t。 - 整数除法与截断 (integer division / truncation):期望
1.67却得到1,期望100却得到96,因为两个int相除仍是int。 调试:gcc -Wall不报警,只能自查;用gdb的p 100/8与p 100.0/8对比;代码里让一个操作数带小数点,或写(double)sum / count。 - 优先级猜错 (operator precedence):例如
int x = 1 << 2 + 3;想得到 40,实际+先算。调试:$ gcc -g -std=c99 -Wall -Werror prec_err.c -o prec_err prec_err.c: In function ‘main’: prec_err.c:2:26: error: suggest parentheses around ‘+’ inside ‘<<’ [-Werror=parentheses] 2 \| int main(void){int x = 1 << 2 + 3; printf("%d\n", x); return 0;} \| ^~ cc1: all warnings being treated as errors这种代码在 ECE 220 的
-Werror策略下根本编译不过——这是特性,不是障碍。养成”看不清就加括号”的习惯。 - 忘记初始化局部变量 (uninitialized automatic variable):局部变量初值是”位”,可能为 0 也可能不是,打印出天文数字常常就是这个原因。调试:
valgrind --track-origins=yes ./prog报Conditional jump or move depends on uninitialised value(s);gcc 的-Wmaybe-uninitialized -O2也能抓;习惯上声明时就初始化。 - 有符号溢出与未定义行为 (signed overflow / UB):
int32_t最大值加 1 是 UB,编译器可以任意处理(例如把整个循环优化掉)。 调试:gcc -g -std=c99 -O1 -fsanitize=undefined -fno-sanitize-recover=all file.c -o file,运行时会精确报出溢出所在的行;-ftrapv让溢出直接触发陷阱 (trap)。修法:改用uint32_t(回绕是良好定义的)或在做加法前先检查边界。 - 同一表达式里既读又写同一变量,或忘记包含头文件:
j = (++i) + (j++) + (++j) - (i--);是 UB(不同编译器结果不同); 调用未声明的函数会得到implicit declaration of function,返回值被默认当成int、参数被默认自动转换,在 64 位机器上很容易崩。 调试:前者靠代码审查 +gdb单步next观察子表达式顺序;后者用gcc -std=c99 -Wall -Werror直接拦住,gcc -E file.c \| less确认#include真的被展开,nm -u file.o查看还有哪些未解析符号。
关键要点
- 类型是给编译器的说明书:它决定变量的宽度、编码与解释方式,从而决定生成
ADD、算术右移还是浮点库调用;同一串位在不同类型下可以是完全不同的值。 - 整数与浮点是两个世界:整数运算会截断、会回绕,
/与%向 0 取整,(100/8)*8 == 96;想让表达式走浮点,至少让一个操作数或常量是浮点类型。 - 隐式转换静默发生:
int与unsigned混合、窄类型提升为int、double转int截断,全都不报错——不确定就写显式强制转换。 static是双面词:在函数外它收窄作用域到本文件(代替全局变量),在函数内它把存储期变成 static(变量活到程序结束,但仍只在块内可见)。- 作用域决定”能否用名字访问”,存储期决定”内存何时存在”:三张表——全局数据(static)、栈(automatic)、堆(dynamic)——就是后面函数、指针、动态分配三讲的舞台。
思考题(带答案)
下面的程序会打印什么?为什么?(
int16_t a = 300; int b = a * a; printf("%d\n", b);) 答案:打印90000。a在乘法前被整型提升为int,300 * 300在int里算,90000 放得下 32 位。 若把b声明成int16_t,90000 会被截断(90000 & 0xFFFF = 24464,按 2 的补码是 −41072),打印出负数——类型决定结果。- 为什么下面这段代码”看起来没问题”却永远不会打印
ok?unsigned int a = 10; int b = -20; if (a + b < 0) { printf("ok\n"); }答案:
a + b的公共类型是unsigned int,b被转换成 4294967276,加上 10 得 4294967286,永远不小于 0。 改为if ((int)a + b < 0)或把a声明成int即可。用gcc -Wextra -Wsign-compare可以让编译器提前提示这类混合。 - 一个函数里的
static int32_t counter = 0;与写在所有函数外面的static int32_t counter = 0;有什么本质区别? 答案:存储期相同(都是 static,位于全局数据区,默认初值 0);区别在作用域:前者块作用域(只有该函数体能用这个名字), 后者文件作用域(本文件内所有函数都能用,别的源文件看不见)。若函数内那个去掉static,它变成 automatic,每次进函数重新创建、初值不确定、出函数即销毁,counter就永远数不到 2。
