Lecture 5: C 语言入门:数据类型、运算符、作用域与存储期 (Introduction to C: Data Types, Operators, Scope and Storage)

目录 · ← l4 · l6 →

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):类型是编译器决定该生成 ADDMUL 还是浮点库调用 (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+0R4+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 namesizeencodingprecisionmin valuemax value
      bool1 bit **0:false 1:trueexact integer01
      char8 bitssigned integer ***exact integer−128127
      unsigned char8 bitsunsigned integerexact integer0255
      short16 bitssigned integerexact integer−3276832767
      unsigned short16 bitsunsigned integerexact integer065535
      int32 bits *signed integerexact integer−2.1 billion *2.1 billion *
      unsigned int32 bits *unsigned integerexact integer04.3 billion *
      long64 bits *signed integerexact integer−9.2 × 10¹⁸ *9.2 × 10¹⁸ *
      unsigned long64 bits *unsigned integerexact integer01.8 × 10¹⁹ *
      float32 bitsfloating point7 decimal digits−3.4 × 10³⁸3.4 × 10³⁸
      double64 bitsfloating point16 decimal digits−1.8 × 10³⁰⁸1.8 × 10³⁰⁸

      * intlong 的尺寸/范围假定运行在 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。
  • 派生类型 (Derived Type):由基本类型构造出来的类型——指针 (pointer)、数组 (array)、结构体 (struct)。
    • 直观解释:指针像门牌号(内容是一个地址),数组像一排等大紧挨的储物柜(从 0 号开始),结构体像把几件行李捆成一箱(成员依次摆放,可能要垫泡沫)。
    • 底层机制图解:官方参考给出的派生类型表:

      typeoperatorexamplesizedescription
      pointer to Z*int * x;64 bits (8 bytes) §存放某个 Z 类型对象的地址
      array of n Zs[]int x[4];n × sizeof(Z) §§§连续分配的 n 个 Z 类型对象
      structurestructstruct { 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”。这在你想让表达式走浮点运算时非常关键。
    • 底层机制图解:官方参考的后缀表:ulunsigned longuunsigned intllong,无后缀 → int.ffloat.double。 字符字面量 'c' 的类型是 int(值是 ASCII 码);字符串字面量 "hello" 的类型是 char *,并且隐式地在静态内存区分配并初始化一个以 NUL 结尾的字符数组。 允许的转义序列包括 \0(NUL)、\n(换行)、以及 \\(一个反斜杠)。字面量本身分配在临时空间(例如寄存器),但它赋值给的变量不一定。 注意:后缀不会改变被赋值变量的类型——变量类型永远由声明决定。
    • 作用域与存储期:字面量没有名字也没有作用域;字符串字面量所在的字符数组是 static storage duration,程序运行期间一直存在。
  • 运算符 (Operator):C 提供算术、关系、逻辑、位运算、赋值、自增自减、条件、逗号等运算符,用来把变量与字面量组成表达式 (expression)。
    • 直观解释:运算符就是”计算的动作”,麻烦之处是同一符号在不同上下文含义不同——& 在位运算里是”按位与”,在单目位置上是”取地址”。
    • 底层机制图解:ECE 220 关注的六类运算符及其机器码对应:

      类别运算符结果类型与 LC-3 的对应
      算术+ - * / %操作数”较大”的那个类型ADD* 需要循环加或库调用;/% 需要除法子程序
      位运算& \| ~ ^ << >>整数类型ANDNOT+ADD #1XOR、移位循环
      关系< <= == != >= >恒为 int 的 0 或 1条件码 (condition code) N/Z/P + BRnzp
      逻辑&& \|\| !恒为 int 的 0 或 1短路求值 → 多段 BR 跳转
      赋值= += -= *= /= %= &= \|= ^= <<= >>=右值 (right-hand side) 的类型STR:把结果写回左值的地址
      其它++ -- ?: ,见下自增=读+改+写回;?: = BR + 两个赋值块

      必须记住的语义细节:

      1. 整数除法向 0 取整11 / 3 得 3,-11 / 3 得 −3;% 定义为满足 (A / B) * B + (A % B) == A,所以 -11 % 3−2% 的结果不一定非负); 又因为整数除法丢精度,(100 / 8) * 896,不是 100。
      2. 移位<< 相当于乘 2^N,左端溢出的位直接丢失;>> 的行为取决于类型——有符号数做算术右移(复制符号位),无符号数做逻辑右移(左边补 0)。
      3. 关系运算符恒产生 0 或 1;逻辑运算符只看”真/假”,0 是假、非 0 是真,结果也恒为 0 或 1。
      4. 短路求值&& 在左操作数为假时不再算右边,\|\| 在左操作数为真时不再算右边。这是”保护危险操作”的常用手法,例如 if (0 <= dist_sq && walk_p (me, sqrt (dist_sq)))
      5. = 是表达式,其值是右值,所以 A = B = 0; 合法,等价于 A = (B = 0);;左值 (l-value) 必须有地址,A + B = 42; 一定编译失败。
      6. ++ / -- 的前后缀差别只在”取用表达式的值”时才出现:i++ 先读值再自增,++i 先自增再读值;?: 是三目运算符,A = (B > 0 ? C : D); 等价于一段 if/else 赋值;逗号运算符依次求值并取最后一个的值(常用于 for (i = 0, j = 5; i < 3; i++, j--))。
      7. 优先级:不要背表。乘法类 > 加减 > 移位 > 关系 > 相等 > 位与 > 异或 > 位或 > 逻辑与 > 逻辑或 > 条件 > 赋值 > 逗号。只要一眼看不出顺序,就加括号。
    • 作用域与存储期:运算符没有作用域;但”求值顺序”在 C 中大部分未指定 (unspecified)——f(g(), h()) 里哪个先算由编译器决定, 所以不要在同一个表达式里对同一变量既读又写(例如 j = (++i) + (j++)),那属于未定义行为 (undefined behavior, UB)。
  • 隐式转换 (Implicit Conversion)、整型提升 (Integer Promotion) 与截断 (Truncation):不同类型混在一个表达式里时,编译器会按规则”往较大的类型”转。
    • 直观解释:像把两种货币放进同一个收银机:编译器先都换成”大面额”再算,算完塞回你声明的那个小盒子里——塞不进去的部分就被截掉了。
    • 底层机制图解int x; x = 3 + 4.6; 的四步:①把 3int 转成 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 窄的类型(charshortbool)在参与算术前会先提升为 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) 决定每个名字怎么寻址:
      scopeidentifiertypefromoffset
      translate.cthe_numberint32_tR40
      find_absabs_valueint32_tR50
      find_absnumint32_tR54

      表里 “from/offset” 就是机器码的寻址方式:LDR R0,R4,#0 读全局变量,LDR R0,R5,#4 读参数,STR R0,R5,#0 写局部变量。 static 在函数外与函数内含义不同:函数外改变的是作用域(把全局名字收窄到本文件),函数内改变的是存储期(automatic → static)。

    • 作用域与存储期:作用域是”能否用名字访问”,存储期是”这块内存何时存在”,两者互相独立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. 第 1 段用 sizeof 打印每种基本类型与一个结构体的字节数,验证基本类型表(bool 被补齐成 1 字节,struct point 两个 int32_t 共 8 字节)。
  2. 第 2 段对照整数与浮点运算(整数除法向 0 取整、3 + 4.6 被截断);第 3 段让 uint8_t/uint32_t 溢出回绕,并展示整型提升:int16_t 的 32767 加 1 在 int 里得 32768,存回 int16_t 又绕回 −32768。
  3. 第 4、5 段对 a = 120 (0x78)b = 42 (0x2A) 做全部位运算,对比有符号/无符号右移,并复现”有符号无符号混用”陷阱。
  4. 第 6、7 段验证优先级、?:、逗号运算符、”赋值是表达式”,并用带打印的 side_effect 证明短路求值真的跳过了右操作数。
  5. 第 8 段证明 static 局部变量的存储期跨越了它所在的块,而 inner 出了块就不可见。

【底层机制透视】

  • 3 + 4.6 的过程完全是编译期决定的:编译器看到 intdouble,生成”把 3 转成 doublecvtsi2sd)→ 浮点加 → 转回 intcvttsd2si,向 0 截断)”三条指令。它不会报错,也不会警告——自动转换是静默的。
  • uint8_t byte = 255; byte = byte + 1; 中,byte 先被整型提升int 参与加法得 256,再截断回 8 位得 0——所以窄类型的加法”不会提前溢出”,赋值时才回绕。
  • negative >> 2 得到 −30(0xFFFFFFE2)而不是正数,是因为 int 的右移是算术右移,左侧补的是符号位; 同一份位模式用 unsigned int 解释时右移是逻辑右移,所以 0xFFFFFF00u >> 40x0FFFFFF0同一个 >> 运算符,编译器根据类型生成两条不同的指令序列——这正是”类型为编译器提供信息”的最直接证据。
  • 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;int0x00000078120
int b = 42;int0x0000002A42
a & bint0x0000002840
a \| bint0x0000007A122
a ^ bint0x0000005282
~aint0xFFFFFF87−121(2 的补码)
a << 2int0x000001E0480
-120 >> 2int0xFFFFFFE2−30(算术右移)
0xFFFFFF00u >> 4unsigned int0x0FFFFFF04 026 531 824(逻辑右移)
(uint8_t)255 + 1先提升为 int = 256,存回 8 位0x000(回绕);int16_t 32767 + 1 则得 int0x00008000 = 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)bp a + b 看提升后的值;把操作数统一成 int32_tuint32_t
  • 整数除法与截断 (integer division / truncation):期望 1.67 却得到 1,期望 100 却得到 96,因为两个 int 相除仍是 int调试gcc -Wall 不报警,只能自查;用 gdbp 100/8p 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 ./progConditional 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;想让表达式走浮点,至少让一个操作数或常量是浮点类型。
  • 隐式转换静默发生intunsigned 混合、窄类型提升为 intdoubleint 截断,全都不报错——不确定就写显式强制转换
  • static 是双面词:在函数外它收窄作用域到本文件(代替全局变量),在函数内它把存储期变成 static(变量活到程序结束,但仍只在块内可见)。
  • 作用域决定”能否用名字访问”,存储期决定”内存何时存在”:三张表——全局数据(static)、栈(automatic)、堆(dynamic)——就是后面函数、指针、动态分配三讲的舞台。

思考题(带答案)

  1. 下面的程序会打印什么?为什么?(int16_t a = 300; int b = a * a; printf("%d\n", b);答案:打印 90000a 在乘法前被整型提升int300 * 300int 里算,90000 放得下 32 位。 若把 b 声明成 int16_t,90000 会被截断(90000 & 0xFFFF = 24464,按 2 的补码是 −41072),打印出负数——类型决定结果。

  2. 为什么下面这段代码”看起来没问题”却永远不会打印 ok
     unsigned int a = 10;
     int b = -20;
     if (a + b < 0) {
         printf("ok\n");
     }
    

    答案a + b 的公共类型是 unsigned intb 被转换成 4294967276,加上 10 得 4294967286,永远不小于 0。 改为 if ((int)a + b < 0) 或把 a 声明成 int 即可。用 gcc -Wextra -Wsign-compare 可以让编译器提前提示这类混合。

  3. 一个函数里的 static int32_t counter = 0; 与写在所有函数外面的 static int32_t counter = 0; 有什么本质区别? 答案存储期相同(都是 static,位于全局数据区,默认初值 0);区别在作用域:前者块作用域(只有该函数体能用这个名字), 后者文件作用域(本文件内所有函数都能用,别的源文件看不见)。若函数内那个去掉 static,它变成 automatic,每次进函数重新创建、初值不确定、出函数即销毁,counter 就永远数不到 2。