Lecture 6: C 控制结构与基本 I/O (Introduction to C: Control Structures, Basic I/O)

目录 · ← l5 · l7 →

Lecture 6: C 控制结构与基本 I/O (Introduction to C: Control Structures, Basic I/O)

概述

本讲解决两个问题:如何让程序按条件选择、按次数重复,以及如何与人和机器交换数据。为此引入四类控制语句 (if/switchfor/while/do-whilebreak/continuereturn) 与 printf/scanf 的格式说明符 (format specifier)、返回值语义和缓冲 (buffering) 行为。 这些机制的机器码形态都只是 LDR/BR/JSR/RET 的不同编排;而”用库函数做 I/O”这件事本身,又正好引出下一讲的主题——把任务分解成函数。

核心概念与底层机制图解

  • 真值性 (Truthiness):C 的条件判断只看”是不是 0”——0 是假 (false),任何非 0 都是真 (true)
    • 直观解释:像问”箱子里还有货吗”:0 件表示没有,其余都算有。于是 if (x) 就是 if (x != 0)if (!x) 就是 if (x == 0)
    • 底层机制图解:关系运算符 < <= == != >= > 与逻辑运算符 && \|\| ! 恒产生 int 的 0 或 1; 机器码就是”把值装进寄存器 → 条件码 (N/Z/P) 随之更新 → LDR R0,R5,#0 之后的 BRz ELSE_BLOCK 决定走 then 还是 else”。
    • 作用域与存储期:条件表达式的值只是”当下”的临时值,通常留在寄存器里,不跨越语句边界。
  • if / else if / else:二选一或链式多选一的条件执行。
    • 直观解释:像分诊台:先问”有没有生命危险”,否则问”是不是骨折”,再否则按普通号处理——顺序本身就是逻辑
    • 底层机制图解else if 只是”else 里再嵌一个 if“的简写,编译器把它变成一串”测试失败就跳到下一个测试”的 BR。两条书写纪律: ① 始终使用花括号 { }(ECE 220 编码规范);② 比较时把常量写在左边42 == A)——手误成 42 = A 时编译器一定报错,写成 A = 42 则只得到警告、逻辑已变成”恒为真”。
      if (input < 0) {
          printf ("Negative\n");
      } else if (0 == input) {
          printf ("Zero\n");
      } else {
          printf ("Positive\n");
      }
      
    • 作用域与存储期:每个分支都是一个复合语句 (compound statement),即一个块;块内声明的变量是 automatic,离开块即销毁。
  • while 循环:先测后做的迭代,可能一次也不执行。
    • 直观解释:像”只要还有邮件就继续处理”——一开始就没有邮件,就一封也不用处理。
    • 底层机制图解while (<test>) { <body> } 完全等价于 <init><update> 都空着的 for;测试在循环顶部,所以 update 必须由循环体自己完成。 机器码形态是 WHILE_TEST: LDR R0,R5,#-1 / BRzp WHILE_DONE / …循环体与 update… / BRnzp WHILE_TEST,即”每次回到顶部都要重新读一遍控制变量”。
    • 作用域与存储期:循环体是块,块内 automatic 变量每次迭代都重新创建(初值仍是”位”);要跨迭代保存状态就得在循环外声明。
  • do / while 循环:先做后测,至少执行一次
    • 直观解释:像”先上菜,再问客人还要不要”——第一轮一定发生,适合”必须先读一个值才能判断”的场景(如 do { scanf (...) } while (bad);)。
    • 底层机制图解:与 while 的唯一差别是测试被搬到循环体后面:循环体先执行,BRnzp DO_BODY 在条件为真时回到顶部。
    • 作用域与存储期:与 while 相同;控制变量一般声明在循环外,因此具有函数级的 automatic 生存期。
  • for 循环:把初始化、测试、更新写在一行的迭代。
    • 直观解释:像点名册的三栏——”从第 1 号开始、念到第 40 号、每念一个加 1”。
    • 底层机制图解:执行顺序严格是 init → (test → body → update) → (test → body → update) → …,init 只做一次。课程例子(打印 1–1000 中 42 的倍数):
      int N;
      for (N = 1; 1000 >= N; N = N + 1) {
          if (0 == (N % 42)) {
              printf ("%d\n", N);
          }
      }
      

      斐波那契例子(C = A + B; A = B; B = C;)则展示多个变量在循环里”滚动”。

    • 作用域与存储期:C99 允许 for (int32_t i = 0; ...),此时 i 只活在循环内;ECE 220 代码习惯把循环变量声明在函数开头,以便循环结束后还能检查它的值。
  • switch:贯穿 (fall-through)、breakdefault:对一个表达式的多个常量取值做多路分支。
    • 直观解释:像电梯面板——按 3 楼停 3 楼,按 5 楼停 5 楼;default 是”按了无效楼层时停哪儿”。没有 break 就会一路坐到底。
    • 底层机制图解:表达式可以是任意整型表达式,但 case必须是编译期常量;取值少时编译器生成”比较链”,取值密集时生成跳转表 (jump table)——把各 case 的地址放进全局数据区,用 LDR 取出地址再 JSRR 跳过去。
      switch (operator) {
          case '+':                       /* add */
              result = a + b;
              break;                      /* 漏掉它就会继续执行 '-' 的代码 */
          case 1:
          case 2:                         /* 故意贯穿:1 和 2 共用同一段代码 */
              printf ("one or two\n");
              break;
          default:                        /* 兜住其它取值,应放在最后 */
              printf ("unknown\n");
              break;
      }
      
    • 作用域与存储期:整个 switch只有一个块作用域,所有 case 共享;在 case 里声明变量必须加花括号,否则会与其它 case 冲突,还可能”跳过初始化”(未定义行为)。
  • breakcontinue:迭代控制语句,只作用于最内层的循环或 switch
    • 直观解释continue 是”这道题跳过,回到答题流程”;break 是”这场考试不考了,交卷走人”。
    • 底层机制图解continuefor跳到 update,对 while/do-while跳到 test(它们没有 update);break 跳到当前结构之后的第一条语句。
      for (...) {                            while (...) {
          if (skip) continue;  ──▶ update       if (skip) continue;  ──▶ test
          if (done) break;     ──▶ 循环之后      if (done) break;     ──▶ 循环之后
      }                                      }
      

      嵌套循环里的 break 不会跳出外层循环;要跳两层就用标志变量配合外层判断,或把内层封装成函数用 return

    • 作用域与存储期:两者不创建作用域,只是把控制流(LC-3 里就是把 PC)搬到另一处。
  • return 语句:立即结束当前函数并把一个值交回调用者。
    • 直观解释:像”交卷”——交完就不再答题,哪怕后面还有代码没执行。
    • 底层机制图解:LC-3 上 return <expr>; 分四步,与调用约定严格对应:
              LDR     R0,R5,#0        ; ① 求表达式的值(先放 R0)
              STR     R0,R5,#3        ; ② 写进栈帧的"返回值"槽
              LDR     R7,R5,#2        ; ③ 拆栈帧:恢复 R7 与 R5
              LDR     R5,R5,#1
              ADD     R6,R6,#3        ;    弹出局部变量与 linkage(保留返回值)
              RET                     ; ④ PC ← R7,回到调用者
      

      正因如此,return 可以出现在任何位置(包括循环体内),它一次完成”带返回值 + 跳回”两件事。

    • 作用域与存储期return 触发整个栈帧的销毁,该函数所有 automatic 变量在 RET 之后失效,内存会被下一个被调用者复用——绝不能返回指向局部变量的地址
  • 循环设计五步法 (designing loops):课程给出的系统化流程,用来”证明”循环的正确性。
    • 直观解释:动笔前先回答五个问题,像施工前的五张图纸,避免边写边补。
    • 底层机制图解: ```
      1. 重复的任务是什么?(循环体) 1. 每次进入”测试”时什么一定为真?(不变式 invariant)
      2. 迭代何时停止?(可能有多个条件) 3. 迭代结束后做什么?(不同停止条件可能不同)
      3. 第一次迭代前做什么?(init 让不变式首次成立) 5. 迭代之间如何更新?(update 维持不变式) ``` 课程的内存转储例子:任务=”每行打印 12 个内存单元”;不变式=”start 是行首地址且为 12 的倍数”;测试=start >= addr_e;init=start = (addr_s / 12) * 12;update=start = start + 12。技巧:给 addr_e0x10000 造”虚拟副本”,就能用一个循环处理地址回绕 (wrap-around)。
    • 作用域与存储期:不变式涉及的变量必须在循环外声明(要跨迭代保存);只在单次迭代内用到的临时量可以在循环体内声明。
  • printf 与格式说明符:把值按指定格式写到标准输出 (stdout)。
    • 直观解释:格式串是一张”填空表”:普通字符原样输出,% 开头的是空位,后面的表达式按顺序填进去。
    • 底层机制图解:课程 C 编程参考给出的完整表格(printf 类型 / scanf 类型): | specifier | format description | printf type | scanf type | |—|—|—|—| | %c | ASCII character | char | char * | | %d | signed decimal integer | int | int * | | %f | decimal with fractional part | float | float * | | %hd | signed decimal integer | short | short * | | %hhd | signed decimal integer | char | char * | | %hhu | unsigned decimal integer | unsigned char | unsigned char * | | %hhx | hexadecimal | unsigned char | unsigned char * | | %hu | unsigned decimal integer | unsigned short | unsigned short * | | %hx | hexadecimal | unsigned short | unsigned short * | | %ld | signed decimal integer | long | long * | | %lf | decimal with fractional part | double | double * | | %lu | unsigned decimal integer | unsigned long | unsigned long * | | %lx | hexadecimal | unsigned long | unsigned long * | | %p | hexadecimal address | T * | T ** | | %s | ASCII string | char * | char * | | %u | unsigned decimal integer | unsigned int | unsigned int * | | %x | hexadecimal | unsigned int | unsigned int * |

      要点:① 除说明符外字符原样输出,想要空格就自己写(printf("%d%d%d", 12, -34, 56) 打印 12-3456);② 转义序列 \n\\%%; ③ 宽度与填充:%04X = 至少 4 字符宽、右对齐、前导 0 填充(课程打印地址时使用);④ 类型必须严格匹配,printf("%d %f", 10.0, 17) 会按错误的类型解释位模式(系统相关,可能是 0 0.000000),说明符多于实参时会去读”位”(行为未指定);⑤ 返回值是写出的字符个数(出错时 < 0),例如 printf("hello\n") 返回 6(含换行符)。

    • 作用域与存储期:格式串是字符串字面量,位于静态内存区,具有 static 存储期,程序运行期间一直存在。
  • scanf 与”为什么要传地址”:从标准输入 (stdin) 按格式读取并转换数值。
    • 直观解释printf 是”把值拿出来给人看”,只要scanf 是”把值塞进你的盒子”,必须知道盒子在哪,所以传地址 (&)。
    • 底层机制图解scanf ("%d", &A); 的机器码形态是”把 A 在栈帧里的偏移算出来当参数压栈”,scanf 通过这个地址把结果 STR 回去。忘记写 & 会把结果写到地址 0 或垃圾地址上(行为未指定)。其它关键语义:
      1. %d/%f/%s跳过前导空白,所以 5 425\n42 都能被 scanf("%d%d", &A, &B) 正确读入;而 %c 不跳过任何字符,读到的可能是上一行遗留的换行,要读”下一个非空白字符”必须写 " %c"
      2. 格式串里的其它字符必须原样输入:scanf("%d<>%d", &A, &B) 要求输入 5<>42%s 则扫描下一个”单词”并连同结尾 NUL 写入数组,数组必须足够大
      3. 返回值是成功转换的个数,没有成功转换时返回 −1(EOF)。必须检查
        if (2 != scanf ("%d%d", &A, &B)) {
            printf ("Bad input!\n");
            A = 42; B = 10;      /* 使用默认值 */
        }
        

        实测(输入 12 34\nAB\n):读两个整数后 scanf 返回 2;紧接着 scanf("%c", &c1) 返回 1 且 c1 = 0x0A(遗留的换行);改成 scanf(" %c", &c2) 后返回 1 且 c2 = 'A'

    • 作用域与存储期scanf 写入的是调用者栈帧里的自动变量(或静态数据、堆对象),所以必须拿到地址;这也要求那些变量在调用期间一直存活。
  • 缓冲 (Buffering):标准 I/O 不保证每次调用都立刻访问设备,而是先攒在内存缓冲区里。
    • 直观解释:像超市收银台——每位顾客单独结账太慢,先把手推车装满再一起去结,或等收银员喊”下一位”(遇到换行)。
    • 底层机制图解:三种模式——行缓冲(stdout 连终端,遇换行就冲刷)、全缓冲(stdout 被重定向到文件/管道,缓冲区满才写出,通常 4096 字节)、无缓冲(stderr 总是立刻写出)。实测程序与结果:
      printed = printf("1: to stdout\n");      /* 先进缓冲区 */
      fprintf(stderr, "2: to stderr\n");       /* 立刻写出 */
      printf("3: printf returned %d characters for line 1\n", (int)printed);
      fflush(stdout);                          /* 强制冲刷 */
      
      2: to stderr
      1: to stdout
      3: printf returned 13 characters for line 1
      

      先打印的那一行反而后出现——因为 stdout 全缓冲、stderr 无缓冲。调试时 printf 不带 \n 又想知道”执行到哪了”,要么 fflush(stdout);,要么临时改用 fprintf(stderr, ...)

    • 作用域与存储期:stdout/stdin/stderr 及其缓冲区是库在静态存储区维护的对象,从程序启动活到程序退出;正常结束会自动冲刷,被信号杀死或调用 _exit 则可能丢掉缓冲区内容。

代码示例与底层机制分析

代码 (C) — 一元二次方程求解器(课程示例 04-quadratic.c

/* solution of the quadratic equation ax^2+bx+c=0
   Adapted from V. Kindratenko's notes on 30 August 2016.
   Build: gcc -g -std=c99 -Wall -Werror l06_quadratic.c -o l06_quadratic -lm
*/

#include <stdio.h>   /* needed for printf and scanf */
#include <math.h>    /* needed for sqrtf */

int
main()
{
    float a, b, c;   /* quadratic equation coefficients */
    float D;         /* discriminant */
    float x1, x2;    /* solution(s) */

    /* Get equation coefficients. */
    printf ("Enter a, b, and c: ");
    if (3 != scanf ("%f %f %f", &a, &b, &c)) {
        printf ("Three real coefficients are required.\n");
        return 3; /* Program failed. */
    }

    printf ("Solving equation %fx^2+%fx+%f=0.\n", a, b, c);

    /* Compute discriminant. */
    D = b * b - 4 * a * c;

    /* Compute solution. */
    if (0 < D) {           /* Two real roots exist. */
        x1 = (-b + sqrtf (D)) / (2 * a);
        x2 = (-b - sqrtf (D)) / (2 * a);
        printf ("x1=%f, x2=%f\n", x1, x2);
    } else if (0 == D) {   /* Only one root exists. */
        x1 = -b / (2 * a);
        printf ("x=%f\n", x1);
    } else {
        printf ("No real roots exist\n");
    }

    /* End program successfully. */
    return 0;
}

实际编译运行结果(四种输入,均用管道送入):

$ echo "1 -3 2" | ./l06_quadratic
Enter a, b, and c: Solving equation 1.000000x^2+-3.000000x+2.000000=0.
x1=2.000000, x2=1.000000
$ echo "1 2 1" | ./l06_quadratic
Enter a, b, and c: Solving equation 1.000000x^2+2.000000x+1.000000=0.
x=-1.000000
$ echo "1 0 1" | ./l06_quadratic
Enter a, b, and c: Solving equation 1.000000x^2+0.000000x+1.000000=0.
No real roots exist
$ echo "1 2" | ./l06_quadratic ; echo "exit status = $?"
Enter a, b, and c: Three real coefficients are required.
exit status = 3

【代码做什么?】 声明系数 a, b, c、判别式 D 与两根;打印提示后用 scanf("%f %f %f", ...) 读三个实数,返回值不等于 3 就打印错误并 return 3; 计算 D = b*b - 4*a*c;按 D > 0 / D == 0 / D < 0 三分支求两根、求唯一根、报”无实根”;成功返回 0,失败返回 3(echo $? 可见)。

【底层机制透视】

  • scanf 必须传地址(&a),因为它的任务是写入afloat 所以必须用 %f,写成 %lfdouble)会按 8 字节写入并覆盖相邻变量。3 != scanf(...) 一次覆盖三种情形:正常返回 3、非法输入时返回已成功的个数、在第一个字符前遇到文件结束返回 −1。
  • float 作为可变参数传给 printf 时会自动提升为 double,这就是表中 %fprintf 侧写 float、实际却接受 double 的原因;scanf 传地址无法提升,故 %f 对应 float *%lf 对应 double *
  • (2 * a)float,不会踩整数除法陷阱(若系数是 int-b / (2 * a) 会被截断)。LC-3 没有浮点指令,sqrtf 与浮点算术全由库子程序模拟,-lm 就是去数学库找它们。

【内存布局图解】

main 栈帧(x86-64,-O0):高地址到低地址依次是 c、b、a、D、x1、x2,各占 4 字节(float)
        &a = 0x7ffd....f44   &b = 0x7ffd....f48   &c = 0x7ffd....f4c   ← scanf 收到的是地址
        D = 9 - 8 = 1.0f;输入 "1 -3 2" 时 x1 = 2.0f,x2 = 1.0f

【与汇编的对应】(LC-3:三分支结构 + 库调用 + 返回)

        LDR     R0,R5,#-3       ; R0 <- D
        BRp     TWO_ROOTS       ; D > 0 → 两个实根
        BRn     NO_ROOTS        ; D < 0 → 无实根
ONE_ROOT
        LDR     R0,R5,#-2       ; 计算 -b / (2a):float 运算由库子程序完成
        LDR     R1,R5,#-1
        ADD     R1,R1,R1        ; 2*a
        JSR     FLOAT_DIV       ; R0 / R1 → R0
        STR     R0,R5,#-4       ; x1
        BRnzp   QUAD_DONE
TWO_ROOTS
        LDR     R0,R5,#-3       ; 压入唯一实参 D → JSR → 读 R6+0 → 弹栈
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        JSR     SQRTF           ; R0 <- sqrtf (D)
        ADD     R6,R6,#2
NO_ROOTS
        LEA     R0,MSG_NO_ROOTS ; 对应 printf ("No real roots exist\n")
        JSR     PRINT_STRING
QUAD_DONE
        AND     R0,R0,#0        ; return 0:写返回值槽,再拆栈帧
        STR     R0,R5,#3
        LDR     R7,R5,#2
        LDR     R5,R5,#1
        ADD     R6,R6,#6
        RET

代码 (C) — 素数打印器(课程示例 primes.c 的整理版)

/*
 * ECE220 Lecture 6 demo -- a prime number printer.
 * Adapted from the course example primes.c (ECE220 Spring 2018).
 * Build: gcc -g -std=c99 -Wall -Werror l06_primes.c -o l06_primes
 */

#include <stdint.h>
#include <stdio.h>

/* A "static" function is usable only inside this file. */
static int32_t is_prime (int32_t num);
static int32_t divides_evenly (int32_t divisor, int32_t value);

int
main ()
{
    int32_t check;
    int32_t count = 0;

    /* 2 is the smallest prime number. */
    for (check = 2; 1000 > check; check++) {
        if (is_prime (check)) {
            printf ("%d is prime.\n", check);
            count = count + 1;
        }
    }
    printf ("%d primes below 1000.\n", count);

    return 0;
}

static int32_t
is_prime (int32_t num)
{
    int32_t divisor;

    /* Check every possible divisor from 2 to num - 1. */
    for (divisor = 2; num > divisor; divisor++) {
        if (divides_evenly (divisor, num)) {
            return 0;
        }
    }

    return 1;
}

static int32_t
divides_evenly (int32_t divisor, int32_t value)
{
    int32_t multiple;

    /* Integer arithmetic: (value / divisor) * divisor rounds down, so it
       equals value exactly when divisor divides value evenly. */
    multiple = (value / divisor) * divisor;

    return (multiple == value);
}

实际编译运行结果./l06_primes 共 169 行,首尾如下):

2 is prime.
3 is prime.
5 is prime.
7 is prime.
11 is prime.
13 is prime.
...
983 is prime.
991 is prime.
997 is prime.
168 primes below 1000.

【代码做什么?】 mainfor 从 2 遍历到 999,对每个数调用 is_prime,为真就打印并累加 countis_primefor 从 2 试到 num - 1,发现能整除就 return 0(一次结束循环与函数),循环自然结束说明没有因子,return 1divides_evenly(value / divisor) * divisor == value 判断整除;最后打印总数 168。

【底层机制透视】

  • returnis_prime 的”双出口”:只写 break 的话,函数会继续执行到 return 1,把合数判成素数——初学者最常见的逻辑错误。for 的测试在每次迭代之前求值,所以 num = 2 时第一次测试就为假并直接 return 1;写成 num >= divisor 就会把 2 判成合数(边界必须逐个手推)。
  • 用整数除法表达整除(而非 %)是课程的有意选择:(value / divisor) * divisor 直接对应 LC-3 上”调用除法库子程序、再乘回去比较”的机器码序列。
  • static 函数只在本文件可见、不进全局符号表,避免与库或其它文件重名——与上一讲”避免全局变量”是同一思路。

【内存布局图解】

调用链上的三个栈帧(正在检查 num = 5 时),每个函数各持一份副本:
高地址  +--------------------------------+
        | main 的栈帧:check = 5, count = 3 |
        +--------------------------------+
        | is_prime 的栈帧:num 副本 = 5     |   ← 参数是值的拷贝
        |                  divisor = 2     |   ← automatic,每次调用重新创建
        +--------------------------------+
        | divides_evenly 的栈帧:          |
        |   divisor 副本 = 2, value = 5    |
        |   multiple = (5 / 2) * 2 = 4     |
低地址  +--------------------------------+
三份 num/divisor 互不影响——这就是"按值传递"。

【与汇编的对应】(LC-3:for 循环骨架 + “调用函数并取回返回值”的标准五步)

MAIN
        ADD     R6,R6,#-5       ; 2 个局部变量(check, count)+ 3 个 linkage 字
        STR     R5,R6,#2
        ADD     R5,R6,#1        ; R5 -> 局部变量底部(check)
        STR     R7,R5,#2
        AND     R0,R0,#0        ; check = 2
        ADD     R0,R0,#2
        STR     R0,R5,#0
        AND     R0,R0,#0        ; count = 0
        STR     R0,R5,#-1
FOR_TEST
        LDR     R0,R5,#0        ; R0 <- check
        ADD     R1,R0,#-9       ; 与 1000 比较(示意)
        BRzp    FOR_DONE        ; 测试失败 → 跳出循环
        LDR     R0,R5,#0        ; ① 求值并压入实参 check
        ADD     R6,R6,#-1
        STR     R0,R6,#0
        JSR     IS_PRIME        ; ② 调用
        LDR     R0,R6,#0        ; ③ 从栈顶读返回值
        ADD     R6,R6,#2        ; ④ 弹出返回值与实参
        BRz     FOR_UPDATE      ; 返回 0(非素数)→ 跳过打印
        ; ...打印 "%d is prime.\n" 并把 count 加 1
FOR_UPDATE
        LDR     R0,R5,#0        ; check++(for 的 update 部分)
        ADD     R0,R0,#1
        STR     R0,R5,#0
        BRnzp   FOR_TEST        ; 回到测试
FOR_DONE
        AND     R0,R0,#0        ; return 0:写返回值槽、拆栈帧、RET
        STR     R0,R5,#3
        LDR     R7,R5,#2
        LDR     R5,R5,#1
        ADD     R6,R6,#4
        RET

代码 (C) — 循环设计实例:打印空心正方形(课程的 think-pair-share 任务)

/*
 * ECE220 Lecture 6 demo -- loop design: print a hollow square.
 * Adapted from the think-pair-share task in the course lecture
 * "Designing Loops" (531).
 * Build: gcc -g -std=c99 -Wall -Werror l06_square.c -o l06_square
 */
#include <stdint.h>
#include <stdio.h>

/*
 * Function: print_square
 * Description: prints a hollow square of asterisks of the given size
 * Parameters: size -- the side length, in characters, of the square
 * Return Value: 0 on success, -1 if size is not positive
 * Side effects: writes to the display
 */
int32_t
print_square (int32_t size)
{
    int32_t row;
    int32_t col;

    /* Argument checking: reject anything that has no meaning here. */
    if (1 > size) {
        return -1;
    }

    for (row = 0; size > row; row++) {
        for (col = 0; size > col; col++) {
            /* Only the four edges of the square are filled. */
            if (0 == row || 0 == col || row == size - 1
                || col == size - 1) {
                printf ("*");
            } else {
                printf (" ");
            }
        }
        printf ("\n");
    }

    return 0;
}

int
main ()
{
    int32_t size;

    for (size = 1; 6 > size; size++) {
        if (0 != print_square (size)) {
            printf ("size %d rejected\n", (int)size);
        }
    }

    /* The error case: a size that cannot be drawn. */
    printf ("print_square(0) returned %d\n", (int)print_square (0));
    printf ("print_square(-3) returned %d\n", (int)print_square (-3));

    return 0;
}

实际编译运行结果./l06_square,此处只列出 size = 1、2、5 与两个错误返回值):

*
**
**
(size = 3、4 的输出按同样规则展开)
*****
*   *
*   *
*   *
*****
print_square(0) returned -1
print_square(-3) returned -1

【代码做什么?】 print_square 先做参数检查(size < 1 直接 return -1),再用外层 for 走行、内层 for 走列; 只有处于四条边之一时才打印 *,否则打印空格,每行末尾打印换行;main 打印 1–5 的图形并演示两个非法尺寸的返回值。

【底层机制透视】

  • 五步法的落地:① 任务是”打印一整行”;② 内层测试时的不变式是”该行已开头、已打印 col 个字符”;③ 停止条件是 col >= size;④ 每开新行必须重新 col = 0(由 for 的 init 保证);⑤ 更新是 col++,行末再 row++
  • 边界值手推:size == 1rowcol 只取 0,条件 0 == row 成立,输出恰好一行一个星号;size == 2 时四条边条件全成立,输出 2×2 的实心块。若把 col = 0 写到循环外,第二行会从 col = size 开始、什么都不打印。逐字符 printf("*") 每次都要进入库函数,这也是”逐字符打印很慢”的原因。

【内存布局图解】

print_square 的栈帧(size = 5):           输出(每行 5 个字符):
+--------------------------+               row=0: * * * * *   ← row == 0 恒真
| 参数 size       |   5    |               row=1: *       *   ← col == 0 或 col == size-1
+--------------------------+               row=2: *       *
| 局部 row        |  0..4  |               row=3: *       *
+--------------------------+               row=4: * * * * *   ← row == size-1 恒真
| 局部 col        |  0..4  |  ← 每行重新从 0 开始
+--------------------------+
| linkage(R7 / R5 / 返回值)|
+--------------------------+
row、col 是 automatic,函数返回即销毁;size 是参数副本,改它不影响调用者。

【与汇编的对应】(LC-3:双重循环,重点是”每次进内层前重新初始化 col”)

        AND     R1,R1,#0        ; row = 0
ROW_TEST
        LDR     R0,R5,#4        ; size
        NOT     R2,R1
        ADD     R2,R2,#1
        ADD     R2,R2,R0        ; size - row
        BRzp    ROW_DONE        ; <= 0 → 外层结束
        AND     R2,R2,#0        ; col = 0   ← 每开一行都要重新执行!
COL_TEST
        LDR     R0,R5,#4        ; size
        NOT     R3,R2
        ADD     R3,R3,#1
        ADD     R3,R3,R0        ; size - col
        BRzp    COL_DONE
        ADD     R3,R1,#0
        BRz     PRINT_STAR      ; row == 0(其余三条边的判断同理)
        LEA     R0,SPACE_CHAR
        JSR     PRINT_CHAR
        BRnzp   COL_NEXT
PRINT_STAR
        LEA     R0,STAR_CHAR
        JSR     PRINT_CHAR
COL_NEXT
        ADD     R2,R2,#1        ; col++
        BRnzp   COL_TEST
COL_DONE
        LEA     R0,NEWLINE
        JSR     PRINT_CHAR      ; 行末换行
        ADD     R1,R1,#1        ; row++
        BRnzp   ROW_TEST
ROW_DONE

常见错误与调试技巧

  • === 混淆if (A = 42) 恒为真且改写了 A,因为赋值是表达式、其值是右值。调试gcc -std=c99 -Wall -Werror 给出 suggest parentheses around assignment used as truth value;把常量写左边(42 == A)让手误变成编译错误;gdb 里断点后 p A 看它是否被意外改写。
  • 缺少花括号if (x > 0) sum += x; count++;count++ 不在条件内。调试gcc -Wall -Wmisleading-indentation(gcc 6+ 默认开启)会直接报警; clang-format -i file.c 让”缩进骗人”暴露;gdb 单步 next 观察是否真的跳过了那行。
  • switch 漏写 break(贯穿):某个 case 执行完继续执行下一个 case调试gcc -Wall -Wimplicit-fallthrough -Werrorgdb 在各 case 首行设断点并用 bt 看从哪儿跳进来;确实要贯穿时补 /* fall through */ 注释。
  • for 的 init/update 放错位置:把 col = 0 写到外层循环之外,内层只跑一轮。调试:循环体开头打印 rowcolgdbwatch col 在值变化时自动停下,break l06_square.c:41 精确停在某一行。
  • 循环边界差一 (off-by-one)num >= divisor 把 2 判成合数;0 <= i 对无符号 i 是死循环。调试:先手推最小/最大/边界三个输入;gcc -fsanitize=undefined 抓可疑比较;gdbuntil 跑完已知正确的一轮再 p 变量。
  • scanf 忘写 & 或说明符与类型不匹配:忘 & 会写到垃圾地址;%fdouble%dlong 会读写错误宽度。调试gcc -std=c99 -Wall -Werror-Wformat 会检查这对函数;运行时加 -fsanitize=address -gvalgrind --track-origins=yes ./prog 定位非法写入。
  • 不检查 scanf 返回值 / 缓冲让提示”迟到”:前者使变量保持”位”、后续结果不可预测;后者让不带换行的提示在被重定向时最后才出现。调试: 用 printf 'abc\n' \| ./prog 故意喂坏输入,gdb 里在 scanf 之后 p conversions(x86-64 也可 p $eax); 给提示加 \nfflush(stdout);,用 strace -e write ./prog 看真正的写顺序,诊断信息临时改用无缓冲的 fprintf(stderr, ...)

关键要点

  • 0 是假、非 0 是真,关系与逻辑运算符恒产生 0/1;因此 if (x = 42) 不会报语法错误,必须靠”常量写左边 + 始终写花括号”这两条书写纪律来防。
  • 控制结构的角色分工if/switch 做条件分解,for/while/do-while 做迭代分解,break/continue 决定跳到 test、update 还是循环之外,return 一次完成”返回值 + 拆栈帧 + RET”。
  • 写循环前先回答五个问题(任务、不变式、停止条件、结束后的处理、init 与 update)并手推三个边界输入;循环的 bug 几乎都藏在初始化位置与边界比较上。
  • printf/scanf 的说明符必须与类型严格匹配、scanf 必须传地址" %c" 才是”读下一个非空白字符”,"%c" 会读到上一次输入遗留的换行;而返回值是判断 I/O 成功的唯一手段(printf 返回字符数,scanf 返回成功转换个数,EOF 为 −1)——stdout 的缓冲更意味着”你以为已经打印了”不等于”真的写出去了”。

思考题(带答案)

  1. 下面两段循环各执行多少次循环体?(int32_t i = 0; while (i < 3) { i++; } 与”从 i == 3 开始的 do { i--; } while (i > 0);“) 答案:都是 3 次(前者 i 从 0 到 3,后者从 3 减到 0)。若把前者的初值改成 5,while 执行 0 次而 do-while 仍执行 1 次——这就是两者唯一的区别。

  2. 用户依次输入 42、回车、A、回车。下面代码中 c 得到什么?如何修好?
     int32_t n;
     char c;
     scanf ("%d", &n);
     scanf ("%c", &c);
    

    答案c 得到 '\n'(0x0A),因为 %d 把数字后的换行留在输入缓冲区里。修法:改成 scanf (" %c", &c);% 前加空格跳过所有空白),或先清空缓冲区(while ('\n' != getchar ()) { })。

  3. 为什么课程要求把常量写在比较式的左边(42 == size)?举一个能被编译器抓住、而写反后抓不住的例子。 答案42 == size 手误成 42 = size 时编译器立刻报 lvalue required as left operand of assignment(常量不是左值);而 size == 42 手误成 size = 42 完全合法(值是 42、恒为真),通常只得到一条容易忽略的警告。更隐蔽的 if (x = y)(本意是比较)既让条件恒等于 y 的值,又把 x 写坏。