Lecture 6: C 控制结构与基本 I/O (Introduction to C: Control Structures, Basic I/O)
Lecture 6: C 控制结构与基本 I/O (Introduction to C: Control Structures, Basic I/O)
概述
本讲解决两个问题:如何让程序按条件选择、按次数重复,以及如何与人和机器交换数据。为此引入四类控制语句 (if/switch、for/while/do-while、break/continue、return) 与 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”。 - 作用域与存储期:条件表达式的值只是”当下”的临时值,通常留在寄存器里,不跨越语句边界。
- 直观解释:像问”箱子里还有货吗”:0 件表示没有,其余都算有。于是
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)、break与default:对一个表达式的多个常量取值做多路分支。- 直观解释:像电梯面板——按 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冲突,还可能”跳过初始化”(未定义行为)。
- 直观解释:像电梯面板——按 3 楼停 3 楼,按 5 楼停 5 楼;
break与continue:迭代控制语句,只作用于最内层的循环或switch。- 直观解释:
continue是”这道题跳过,回到答题流程”;break是”这场考试不考了,交卷走人”。 - 底层机制图解:
continue对for会跳到 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. 每次进入”测试”时什么一定为真?(不变式 invariant)
- 迭代何时停止?(可能有多个条件) 3. 迭代结束后做什么?(不同停止条件可能不同)
- 第一次迭代前做什么?(init 让不变式首次成立) 5. 迭代之间如何更新?(update 维持不变式) ``` 课程的内存转储例子:任务=”每行打印 12 个内存单元”;不变式=”
start是行首地址且为 12 的倍数”;测试=start >= addr_e;init=start = (addr_s / 12) * 12;update=start = start + 12。技巧:给addr_e加0x10000造”虚拟副本”,就能用一个循环处理地址回绕 (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 或垃圾地址上(行为未指定)。其它关键语义:%d/%f/%s会跳过前导空白,所以5 42与5\n42都能被scanf("%d%d", &A, &B)正确读入;而%c不跳过任何字符,读到的可能是上一行遗留的换行,要读”下一个非空白字符”必须写" %c"。- 格式串里的其它字符必须原样输入:
scanf("%d<>%d", &A, &B)要求输入5<>42;%s则扫描下一个”单词”并连同结尾 NUL 写入数组,数组必须足够大。 - 返回值是成功转换的个数,没有成功转换时返回 −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),因为它的任务是写入;a是float所以必须用%f,写成%lf(double)会按 8 字节写入并覆盖相邻变量。3 != scanf(...)一次覆盖三种情形:正常返回 3、非法输入时返回已成功的个数、在第一个字符前遇到文件结束返回 −1。float作为可变参数传给printf时会自动提升为double,这就是表中%f在printf侧写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.
【代码做什么?】 main 用 for 从 2 遍历到 999,对每个数调用 is_prime,为真就打印并累加 count; is_prime 用 for 从 2 试到 num - 1,发现能整除就 return 0(一次结束循环与函数),循环自然结束说明没有因子,return 1; divides_evenly 用 (value / divisor) * divisor == value 判断整除;最后打印总数 168。
【底层机制透视】
return是is_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 == 1时row、col只取 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 -Werror;gdb在各case首行设断点并用bt看从哪儿跳进来;确实要贯穿时补/* fall through */注释。for的 init/update 放错位置:把col = 0写到外层循环之外,内层只跑一轮。调试:循环体开头打印row、col;gdb里watch col在值变化时自动停下,break l06_square.c:41精确停在某一行。- 循环边界差一 (off-by-one):
num >= divisor把 2 判成合数;0 <= i对无符号i是死循环。调试:先手推最小/最大/边界三个输入;gcc -fsanitize=undefined抓可疑比较;gdb的until跑完已知正确的一轮再p变量。 scanf忘写&或说明符与类型不匹配:忘&会写到垃圾地址;%f配double、%d配long会读写错误宽度。调试:gcc -std=c99 -Wall -Werror的-Wformat会检查这对函数;运行时加-fsanitize=address -g或valgrind --track-origins=yes ./prog定位非法写入。- 不检查
scanf返回值 / 缓冲让提示”迟到”:前者使变量保持”位”、后续结果不可预测;后者让不带换行的提示在被重定向时最后才出现。调试: 用printf 'abc\n' \| ./prog故意喂坏输入,gdb里在scanf之后p conversions(x86-64 也可p $eax); 给提示加\n或fflush(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 的缓冲更意味着”你以为已经打印了”不等于”真的写出去了”。
思考题(带答案)
下面两段循环各执行多少次循环体?(
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 次——这就是两者唯一的区别。- 用户依次输入
42、回车、A、回车。下面代码中c得到什么?如何修好?int32_t n; char c; scanf ("%d", &n); scanf ("%c", &c);答案:
c得到'\n'(0x0A),因为%d把数字后的换行留在输入缓冲区里。修法:改成scanf (" %c", &c);(%前加空格跳过所有空白),或先清空缓冲区(while ('\n' != getchar ()) { })。 - 为什么课程要求把常量写在比较式的左边(
42 == size)?举一个能被编译器抓住、而写反后抓不住的例子。 答案:42 == size手误成42 = size时编译器立刻报lvalue required as left operand of assignment(常量不是左值);而size == 42手误成size = 42完全合法(值是 42、恒为真),通常只得到一条容易忽略的警告。更隐蔽的if (x = y)(本意是比较)既让条件恒等于y的值,又把x写坏。
