FROM ZERO · C + EMBEDDED

C 语言与嵌入式 · 从零到 51 单片机

假设你完全没写过代码。这份教程从「什么是程序」开始,先把你教成会写 C 的人,再把你教成看得懂寄存器、算得出定时初值、写得出中断程序的人。

用多久:三个星期 · 每天 1.5~2 小时 · 24 天
要什么:电脑一台;第 15 天起需要 51 开发板(没有也能先读完,代码可以照着敲)
快捷键:←/→ 翻页 / 搜索 Ctrl+P 导出 PDF
离线:单个 HTML 文件,双击即开

导学习地图:三个星期怎么走

导读

嵌入式这套东西看起来吓人,其实只有三层:语言 → 计算机底层 → 具体芯片。任何一层缺了,学起来都会变成「背代码」;三层都通了,你就能自己看懂数据手册、自己写驱动。

第一周 · C 语言从零(Day 1–7)
从「什么是程序」开始,学变量、运算符、判断、循环、数组、函数。这一周你写的代码全部在电脑上运行 —— 因为语言基础不该和硬件问题混在一起排查。
第二周 · C 进阶与计算机底层(Day 8–14)
指针、结构体、位运算、宏、多文件工程、内存分区、volatile。这一周你会第一次理解「代码到底变成了什么」:内存、地址、栈、机器码。这是从「会写 C」到「看得懂单片机」的分水岭。
第三周 · 51 单片机实战(Day 15–24)
LED → IO 口 → 流水灯 → 延时 → 按键 → 数码管 → 定时器 → 中断 → 串口 → 综合项目。最后一天把整条链路串起来:你写的 C,是怎么变成引脚上的电压的。

你手上应该有的两份教程,怎么配合用

教程它负责什么什么时候用
本教程
C 语言与嵌入式 · 从零到 51
主干课程:按天推进,从零教你 C,再带你动手做 51 项目每天按顺序读一天
《51 单片机入门前置知识》
你已经有的 268KB 那份
理论参考书:电学常识、二进制、内核架构、IO 内部结构、存储器组织、SFR 速查表本教程每个硬件日末尾会指路,遇到「为什么」时去查
💡 一句话分工

本教程告诉你「今天做什么、怎么写代码」;那份教程告诉你「为什么是这样」。只读一份都有缺口:只读本教程会「会做不会想」,只读那份会「懂原理但没动过手」。

每天怎么学(这是最省时间的顺序)

  1. 先读一遍今天的概念,不要急着看代码 —— 先知道今天要解决什么问题。
  2. 把代码亲手敲一遍(不要复制)。敲错、报错、改对,才是真正在学。
  3. 改一改:把代码里的数字换掉、把条件反过来,看现象怎么变。这一条最重要,它把「抄写」变成「实验」。
  4. 做当天末尾的自测,答不出来就回去看对应小节。
  5. 点「标记为已学完」,进度会存在本机。
⚠️ 三个必须提前说的心理准备

① 卡住是常态。第 15 天之后你会发现「代码明明对,灯就是不亮」—— 嵌入式大概有一半时间花在这上面。这不是你笨,这是这个领域的正常形态。

② 不要跳周。第二周(指针/位运算/内存)是第三周的地基。跳过它直接学 51,你会变成「照着例程抄,改一个数字就不敢动」。

③ 单片机是 8 位机,要习惯「省」。51 只有 128~512 字节 RAM。电脑上随手开的 int a[10000],在这里就是不可能。

三周之后你能做什么

能力具体表现
读写 C不查语法写出一两百行程序,看得懂别人的 C 代码
看懂底层知道 P1 = 0xFE 这一行,最终变成了引脚上的什么电平,以及为什么
查手册配置外设拿到一个没用过的芯片,能对着寄存器说明自己配出定时器、串口、中断
算得清时序能自己算出定时器初值、波特率参数、延时循环次数,而不是抄别人的数字
会排错「灯不亮」时知道按什么顺序查:电源→时钟→复位→程序有没有跑到→端口方向→硬件接线

1Day 1 · 程序是怎么跑起来的

DAY 01 / 24

1.1 什么是程序

程序就是一份写给机器看的操作清单。你写的东西叫「源代码」,CPU 不认识它;CPU 只认识一条条「机器指令」——说白了就是一堆 0 和 1。

所以中间必须有个翻译:

阶段产物谁的活
你写源代码main.c(人看得懂)你
编译main.hex / .exe(机器看得懂)编译器(Keil / GCC)
烧写/运行CPU 一条条执行指令芯片
💡 记住这条链,它贯穿整份教程

你写的 C → 编译器翻译成机器码 → 存进芯片的 ROM → CPU 取出来执行 → 改变引脚电压 → 灯亮。
第 24 天我们会把这条链的每一环都拆开看一遍。

1.2 第一个程序

#include <stdio.h>

int main(void)
{
    printf("Hello, world!\n");
    return 0;
}

逐行解释:

这一行意思
#include <stdio.h>「把标准输入输出库拿进来」——因为下面要用它的 printf。不用这个库就不用写这行。
int main(void)主函数。程序从这里开始执行,也只能有一个。前面的 int 表示它会返回一个整数给系统。
{ }一对花括号圈出「属于 main 的代码」。所有 C 的代码块都用花括号。
printf("...\n");打印一行字。\n 是换行符。末尾的分号表示这一句结束 —— C 里每条语句都要分号。
return 0;告诉系统「我正常结束了」。0 表示成功。

1.3 亲手编译运行一次

先确认你电脑上有 C 编译器(Windows 上装了 MinGW 或 Dev-C++ 都行):

:: 在命令行里检查
gcc --version

:: 编译:把 hello.c 翻译成 hello.exe
gcc hello.c -o hello.exe

:: 运行
hello.exe
:: 输出:Hello, world!
✓ 完整流程就三步

写代码 → 编译 → 运行。单片机上是「写代码 → 编译 → 下载到芯片 → 上电运行」,只多了「下载」这一步,其它一模一样。所以你这一周在电脑上练的东西,一行都不浪费。

1.4 程序是怎样一步步执行的

代码是从上往下一句句执行的。看这个例子:

#include <stdio.h>

int main(void)
{
    printf("第一句\n");
    printf("第二句\n");
    printf("第三句\n");
    return 0;
}
/* 输出 */
第一句
第二句
第三句

注意:C 是「顺序执行」的,除非你用分支和循环改变它(Day 4、Day 5 学)。这一点听起来废话,但当你后面写单片机程序时,「这一句在上一句之后多久执行」会变成关键问题。

1.5 注释:写给人的话

// 这是单行注释,从 // 到本行结尾都被忽略

/* 这是多行注释
   可以写好几行
   编译器完全不管它们 */

int a = 1;   // 也可以写在语句后面
⚠️ 但注释很重要,尤其对单片机

单片机代码里到处是 0x8F、TMOD、0xFD 这种「魔法数字」,三个月后你自己都看不懂。养成习惯:写下一个看不懂的数字时,旁边写一句为什么。

TMOD = 0x01;    // 定时器0,工作方式1(16位定时)
TH0  = 0x3C;    // 50ms 初值高字节(12MHz 晶振)

1.6 常见错误(你一定会遇到)

编译器说什么通常是你干了什么
expected ';' before ...上一行忘了分号。错误报在这一行,问题其实在上一行。
'printf' undeclared忘了 #include <stdio.h>,或者头文件名字打错了。
花括号不匹配的错误(报在文件末尾)少写或多写了一个 { 或 }。数一数。
中文符号导致的奇怪错误把 ; 打成了中文的 ;,或者 " 打成了 "。代码里所有符号必须是英文半角。
🧠 今天的自测

① 说出「你写的 C 代码」到「芯片执行」之间的三个阶段。
② #include <stdio.h> 是干什么的?不写会怎样?
③ 如果 printf("abc\n") 末尾漏了分号,编译器会报错在第几行?
④ 为什么说「这一周在电脑上练的东西,写单片机时一行都不浪费」?

2Day 2 · 变量与数据类型

DAY 02 / 24

2.1 变量:给内存里的一块地方起名字

电脑的内存像一长排储物柜,每个柜子有个编号(地址)。变量就是你给某个柜子起的名字,以后你说「a」,程序就知道去那个柜子拿东西。

int a = 10;    // 要一个柜子,起名 a,放进去 10
a = 20;        // 把 a 里的东西换成 20
printf("%d\n", a);   // 取出 a 里的东西打印:20

「声明变量」要告诉编译器两件事:① 起什么名字 ② 这个柜子要多大(也就是类型)。

2.2 基本数据类型

类型占用能存什么printf 用
char1 字节−128 ~ 127(或一个字符)%c / %d
unsigned char1 字节0 ~ 255%d
int2 或 4 字节整数%d
unsigned int同上非负整数%u
long4 字节更大的整数%ld
float4 字节小数(约 6~7 位有效数字)%f
double8 字节小数(约 15 位)%f
⚠️ 这里藏着一个大坑:int 的宽度不固定!

C 标准只说「int 至少 16 位」,没说具体几字节。在你电脑上 int 是 4 字节,但在 51 单片机上 int 是 2 字节(最大 32767)。

这就是为什么单片机代码里几乎不写 int,而写 unsigned char / unsigned int —— 必须明确知道这个变量占几位,否则溢出、截断的 bug 会层出不穷。第二周会专门讲这个。

2.3 用 sizeof 自己量一量

#include <stdio.h>

int main(void)
{
    printf("char   = %d 字节\n", (int)sizeof(char));
    printf("int    = %d 字节\n", (int)sizeof(int));
    printf("float  = %d 字节\n", (int)sizeof(float));
    printf("double = %d 字节\n", (int)sizeof(double));
    return 0;
}
/* 在 PC(GCC)上运行的结果 */
char   = 1 字节
int    = 4 字节
float  = 4 字节
double = 8 字节

到了 51 上,int 会变成 2。所以永远不要靠猜,用 sizeof 量。

2.4 输入:scanf

#include <stdio.h>

int main(void)
{
    int a, b;
    printf("请输入两个整数:");
    scanf("%d %d", &a, &b);       // 注意每个变量前的 &
    printf("它们的和是 %d\n", a + b);
    return 0;
}
🚨 初学者第一大坑:scanf 忘记 &

scanf("%d", a); ← 少了 &,程序会崩溃或读到乱七八糟的地方。

为什么?并记住这个类比:
· printf("%d", a) 是「把 a 里的东西拿出来给我看」—— 所以写变量名。
· scanf("%d", &a) 是「告诉程序 a 这个柜子在哪,你自己去放」—— 所以要给地址 &a。

& 就是「取地址」(Day 8 会把它讲透)。现在先记成一条规则:scanf 后面每个变量都要加 &,唯一的例外是字符数组(后面会讲)。

2.5 变量命名与初始化

// 起名字的规矩
int led_count;      // ✓ 字母、数字、下划线,不能以数字开头
int LedCount;       // ✓ 另一种风格,都可以
int 2led;           // ✗ 不能数字开头
int led-count;      // ✗ 不能有减号(会被当成减法)
int int;            // ✗ 不能和关键字同名(int/for/if/while...)

// 大小写敏感!
int led, LED;       // 这是两个不同的变量
🚨 第二大坑:变量不初始化就是垃圾值
int a;                  // a 里是什么?谁也不知道
printf("%d\n", a);      // 可能输出 -858993460 之类的怪数

int a = 0;              // ✓ 声明时就给初值,这是好习惯

C 的局部变量不会自动清零,它用的是那块内存上次留下的垃圾。在单片机里这个 bug 特别阴险:程序可能「大多数时候正常,偶尔抽风」。

2.6 常量与字面量

int a = 10;          // 10 是十进制
int b = 0x0A;        // 0x 开头是十六进制,0x0A 就是 10
int c = 012;         // 0 开头是八进制,012 是十进制的 10
char d = 'A';        // 单引号是字符,'A' 其实就是 65
char e[] = "ABC";    // 双引号是字符串
💡 单片机里十六进制是日常

因为一个十六进制位正好对应 4 个二进制位,而一个字节正好 8 位 = 2 个十六进制位。所以 0xFE 一眼就能看出是 1111 1110(8 个引脚里只有第 0 个是 0)。
学单片机必须练到「看到 0x 就能在脑子里展开成二进制」,Day 11 会专门练。

🧠 今天的自测

① 为什么 scanf 要加 & 而 printf 不用?用「柜子」的比喻说一遍。
② int 在 PC 上是几字节?在 51 上是几字节?这件事为什么危险?
③ 0xFF 是十进制的多少?展开成 8 位二进制是什么?
④ int a; printf("%d", a); 会输出什么?为什么?

3Day 3 · 运算符与表达式

DAY 03 / 24

3.1 算术运算符

运算符含义例子结果
+加7 + 310
-减7 - 34
*乘7 * 321
/除7 / 32(不是 2.33!)
%取余7 % 31
🚨 整数除法会「砍掉」小数
int a = 7 / 2;         // a = 3,不是 3.5
float b = 7 / 2;       // b = 3.0!因为 7/2 先按整数算出 3,再转成 float
float c = 7.0 / 2;     // c = 3.5 ✓ 只要有一个是小数,就按小数算
float d = (float)7 / 2; // d = 3.5 ✓ 或者强制转换

这个坑在算平均值、百分比时必踩。判断方法:看 / 两边是不是都是整数。

3.2 取余 % 的妙用

取余在单片机里用得极多,因为它是「循环」和「拆分数字」的核心工具:

// ① 让一个数在 0~7 之间循环(8 个 LED 轮流亮)
i = (i + 1) % 8;      // 0,1,2,...,7,0,1,2,... 永远不越界

// ② 拆出一个三位数的各位数字(数码管显示要用)
int n = 258;
int ge   = n % 10;            // 8
int shi  = n / 10 % 10;       // 5
int bai  = n / 100 % 10;      // 2
✓ 记住这个套路

取某一位 = 先除到那一位,再对 10 取余。比如要百位:n / 100 % 10。
这在 Day 21(数码管)会天天用 —— 因为数码管一次只能显示一位,必须把数字拆开。

3.3 关系运算符(比较)

写法含义
==等于(两个等号!)
!=不等于
> <大于 / 小于
>= <=大于等于 / 小于等于
🚨 第三大坑:= 和 == 搞混
if (a = 5) { ... }    // ✗ 这是赋值!把 5 塞进 a,然后判断 5 是不是真
if (a == 5) { ... }   // ✓ 这才是比较

a = 5 的结果是 5(非 0 就是真),所以这个 if 永远成立,而且悄悄把 a 改成了 5。编译器通常会给个警告,一定要看警告。

3.4 逻辑运算符

运算符含义例子结果
&&并且(两个都真才真)(a>0) && (a<10)a 是 1~9 时为真
||或者(有一个真就真)(a==0) || (a==9)
!取反!(a==0)等价于 a != 0
💡 C 里「真」和「假」到底是什么

0 是假,非 0 都是真。就这一条。所以 if (5)、if (-1)、if ('A') 全都成立。比较运算的结果是 1(真)或 0(假)。

这个「非 0 即真」在后面很有用:判断一个引脚电平、判断一个标志位,都能直接 if (flag)。

3.5 自增自减与复合赋值

int i = 5;

i++;        // 等价于 i = i + 1;  现在 i 是 6
i--;        // i = i - 1;
i += 3;     // i = i + 3;
i -= 3;     // i = i - 3;
i *= 2;     // i = i * 2;
i %= 3;     // i = i % 3;

前加加和后加加的区别

int i = 5, a, b;

a = i++;    // 先把 i 的值给 a(a=5),再让 i 加 1(i=6)
i = 5;      // 复位
b = ++i;    // 先让 i 加 1(i=6),再把 i 的值给 b(b=6)

单独写 i++; 时两者没区别。只有当它和别的运算混在一行时才要小心 —— 建议别混,拆成两行最清楚。

3.6 运算符优先级(会咬人的地方)

高到低大致是:() → ! ~ ++ -- → * / % → + - → < > <= >= → == != → && → || → =

⚠️ 结论:不确定就加括号
// 想表达「a 加 b 再乘 c」,但这样写是错的:
int r = a + b * c;          // 实际是 a + (b*c)
int r = (a + b) * c;        // ✓ 加括号

// 判断「第 3 位是不是 1」——这是 Day 11 的内容,先看个眼熟
if (P1 & 0x08 == 0)         // ✗ == 优先级高于 &,实际是 P1 & (0x08==0)
if ((P1 & 0x08) == 0)       // ✓ 必须加括号

位运算(& | ^ << >>)的优先级比比较运算符还低,所以位运算永远要加括号。这个坑 Day 11 会重点打。

🧠 今天的自测

① 7 / 2 是多少?7.0 / 2 呢?为什么?
② 写出把 258 的百位、十位、个位分别取出来的三行代码。
③ if (a = 0) 和 if (a == 0) 有什么区别?哪个是 bug?
④ if (a & 1 == 0) 为什么不等于 if ((a & 1) == 0)?

4Day 4 · 分支:让程序会判断

DAY 04 / 24

4.1 if:如果……就……

if (条件) {
    // 条件成立(非 0)时执行这里
}
int score = 85;

if (score >= 60) {
    printf("及格\n");
}
💡 花括号可以省,但强烈建议别省
if (a > 0)
    printf("正数\n");        // 只有这一句属于 if

if (a > 0)
    printf("正数\n");
    printf("这句永远执行\n");   // ✗ 它不属于 if!缩进骗了你

C 不看缩进,只看花括号。所以哪怕只有一句,也写上 { }。

4.2 if-else:两条路选一条

if (score >= 60) {
    printf("及格\n");
} else {
    printf("不及格\n");
}

4.3 else if:多条路

if (score >= 90) {
    printf("优秀\n");
} else if (score >= 80) {
    printf("良好\n");
} else if (score >= 60) {
    printf("及格\n");
} else {
    printf("不及格\n");
}
⚠️ 顺序很重要

C 会从上往下检查,命中第一个就跳出整条链。所以如果把 >= 60 写在最前面,那 95 分也会被判成「及格」—— 因为 95 确实 ≥ 60。

规律:条件要按「从严格到宽松」排。

4.4 switch:一个值分多种情况

当你的判断是「某个变量等于 1 做这个、等于 2 做那个」时,switch 比一串 else if 清楚得多:

switch (mode) {
    case 1:
        printf("模式一\n");
        break;              // 每个 case 末尾几乎都要 break
    case 2:
        printf("模式二\n");
        break;
    case 3:
    case 4:                 // 两个 case 共用一段代码:故意不写 break
        printf("模式三四\n");
        break;
    default:                // 都不匹配时走这里
        printf("未知模式\n");
        break;
}
🚨 忘了 break 会「穿透」
switch (n) {
    case 1: printf("A\n");      // 忘了 break
    case 2: printf("B\n"); break;
}
// n=1 时输出:A 然后 B!因为执行完 case 1 会继续往下掉

这叫 fall-through(穿透)。有时候是故意利用(如上例的 case 3/4),但大多数时候是 bug。

4.5 条件运算符(三目)

// 写法: 条件 ? 成立时的值 : 不成立时的值
int max = (a > b) ? a : b;          // 取较大的那个
printf("状态:%s\n", led ? "亮" : "灭");

它适合「二选一取值」这种简单场合。逻辑复杂就别用它,可读性会变差。

4.6 嵌套与综合例子

#include <stdio.h>

int main(void)
{
    int score;
    printf("请输入成绩:");
    scanf("%d", &score);

    if (score < 0 || score > 100) {
        printf("成绩不合法\n");
    } else if (score >= 90) {
        printf("优秀\n");
    } else if (score >= 60) {
        printf("及格\n");
    } else {
        printf("不及格\n");
    }
    return 0;
}
✓ 注意最上面那个「非法输入」的判断

它放在最前面,把 <0 和 >100 先挡掉,后面的判断就只需要考虑 0~100。这是个通用套路:先把异常情况处理掉,剩下的就是正常逻辑。后面写单片机的按键处理、串口收包,都会用到这个思路。

🧠 今天的自测

① 写一段代码:输入一个 1~7 的整数,输出对应的星期几;其它数字输出「无效」。
② switch 里 break 能不能省?什么情况下故意省?
③ 下面哪句「永远执行」?
if (x > 0) printf("a"); printf("b");
④ 为什么 else if 链里的条件要从严格排到宽松?

5Day 5 · 循环:让程序会重复

DAY 05 / 24

5.1 while:条件成立就一直做

int i = 1;
while (i <= 5) {
    printf("%d\n", i);
    i++;                // 千万别忘了改 i,否则死循环
}
/* 输出 1 2 3 4 5 */
🚨 死循环:忘了让条件变化
int i = 1;
while (i <= 5) {
    printf("%d\n", i);      // ✗ i 永远是 1 → 无限打印
}
// 按 Ctrl+C 可以强行中断

写循环时养成习惯:写完立刻检查「这个循环靠什么结束」。

5.2 for:最常用的循环

// for (初始化; 继续条件; 每轮结束做的事)
for (int i = 1; i <= 5; i++) {
    printf("%d\n", i);
}

它和上面的 while 完全等价,但把「起点、终点、步进」都写在了一行里,不容易漏。

三种常见写法

// 从 0 数到 n-1(数组遍历的标准写法)
for (int i = 0; i < n; i++) { ... }

// 从 1 数到 n
for (int i = 1; i <= n; i++) { ... }

// 倒着数
for (int i = 10; i >= 1; i--) { ... }

// 每次加 2(只数奇数)
for (int i = 1; i <= 9; i += 2) { ... }
⚠️ < 还是 <=?差一个就出错

这是初学者最高频的 off-by-one 错误。看数组:如果 int a[5] 的下标是 0~4,那遍历就必须写 i < 5,写成 i <= 5 就越界了(读到了 a[5],那块内存不属于你)。

后面学单片机时,这个错误的表现是「程序莫名其妙重启」或「某个变量自己变了」—— 因为越界写踩坏了别的数据。

5.3 do-while:至少执行一次

int n;
do {
    printf("请输入 1~10 之间的数:");
    scanf("%d", &n);
} while (n < 1 || n > 10);   // 注意末尾有分号

区别在于:while 是先判断再执行(可能一次都不做),do-while 是先执行再判断(至少做一次)。适合「必须问一次,不合格就再问」的场景。

5.4 break 与 continue

for (int i = 1; i <= 10; i++) {
    if (i == 5) break;        // 整个循环立刻结束,i=5 后面的都不做了
    printf("%d ", i);         // 输出 1 2 3 4
}

for (int i = 1; i <= 10; i++) {
    if (i % 2 == 0) continue;  // 跳过本轮剩下的语句,直接进入下一轮
    printf("%d ", i);          // 输出 1 3 5 7 9
}
语句作用
break结束整个循环(在 switch 里是跳出 switch)
continue跳过本轮,进入下一轮
return直接结束整个函数,连循环带函数一起退出

5.5 嵌套循环

// 打印九九乘法表
for (int i = 1; i <= 9; i++) {
    for (int j = 1; j <= i; j++) {
        printf("%d*%d=%-3d", j, i, i * j);
    }
    printf("\n");           // 每打完一行换行
}

两层循环的执行次数是相乘关系:外层 9 次 × 内层最多 9 次 = 81 次。这个「乘法关系」在你以后算程序耗时(单片机主循环能不能跑得过来)时是关键概念。

5.6 循环在单片机里的特殊地位

🧠 单片机的程序长这样
void main(void)
{
    Init();              // 初始化(只做一次)

    while (1) {          // 主循环:永远不退出
        DoSomething();   // 反复执行的工作
    }
}

注意 while (1) —— 这里就是要死循环!因为单片机程序是「一直运行到断电」的,没有「结束」这回事。如果主循环退出了,程序就跑飞了(后面会讲跑飞是什么现象)。

所以你今天学的 while,第三周会变成你每个程序的骨架。

🧠 今天的自测

① 用 for 循环求 1+2+3+…+100。
② int a[5] 遍历时应该写 i<5 还是 i<=5?为什么?
③ break 和 continue 的区别?
④ 为什么单片机的主程序要用 while(1)?它退出了会怎样?

6Day 6 · 数组与字符串

DAY 06 / 24

6.1 为什么需要数组

假设你要存 8 个 LED 的状态。不用数组你得写 8 个变量:

int led0, led1, led2, led3, led4, led5, led6, led7;   // 又长又没法循环
led0 = 1; led1 = 0; ...                                // 想批量操作?做不到

用数组:

int led[8];                 // 一次要 8 个格子,编号 0~7
led[0] = 1;                 // 用下标访问
led[3] = 0;

for (int i = 0; i < 8; i++) {   // 可以循环处理,这是数组最大的价值
    led[i] = 0;
}
💡 数组的本质

一排连续的储物柜,共用一个名字,靠编号(下标)区分。这块连续内存正是它高效的原因 —— 也是单片机里「用数组换时间」各种技巧的基础(查表法)。

6.2 下标的范围(最容易错的地方)

int a[5];       // 要了 5 个格子

a[0] = 1;       // ✓ 第 1 个
a[4] = 1;       // ✓ 最后一个(下标从 0 开始,所以是 4)
a[5] = 1;       // ✗ 越界!不存在 a[5] 这个格子
🚨 C 不检查越界

别的语言越界会报错,C 会安静地往隔壁内存写。后果是:别的变量的值莫名其妙被改了、程序崩溃、或者在你电脑上「看起来没事」但到单片机上就死机。

记住:int a[N] 的合法下标是 0 ~ N-1。

6.3 初始化

// 方式一:声明时全部给出
int a[5] = {10, 20, 30, 40, 50};

// 方式二:只给前几个,剩下的自动补 0
int b[5] = {1, 2};          // 变成 {1, 2, 0, 0, 0}

// 方式三:全部清零的标准写法
int c[5] = {0};             // ✓ 所有元素都是 0

// 方式四:让编译器自己数个数
int d[] = {1, 2, 3};        // 自动是 d[3]
🚨 这里有个大坑:{0} 和 {1} 完全不同
int a[5] = {0};    // ✓ {0,0,0,0,0} —— 全部清零
int b[5] = {1};    // ✗ {1,0,0,0,0} —— 只有第一个是 1!

规则:花括号里的值按顺序赋给前几个元素,剩下的一律补 0。
所以「全部清零」写 {0} 是对的(0 碰巧也是补的值),但「全部设成 1」必须用循环:

for (int i = 0; i < 5; i++) b[i] = 1;   // ✓ 这才是全部设成 1

这个坑非常致命,因为它编译能过、能运行,只是结果全错。

6.4 遍历数组

#include <stdio.h>

int main(void)
{
    int a[5] = {10, 20, 30, 40, 50};
    int sum = 0;

    for (int i = 0; i < 5; i++) {
        printf("a[%d] = %d\n", i, a[i]);
        sum += a[i];
    }
    printf("总和 = %d,平均 = %d\n", sum, sum / 5);
    return 0;
}
/* 输出 */
a[0] = 10
a[1] = 20
a[2] = 30
a[3] = 40
a[4] = 50
总和 = 150,平均 = 30

6.5 二维数组

一排格子不够用时,可以要一个「表格」:

int a[3][4];            // 3 行 4 列,共 12 个格子
a[0][0] = 1;            // 第 0 行第 0 列
a[2][3] = 9;            // 第 2 行第 3 列

// 初始化:外面一层花括号是行,里面一层是列
int b[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};

// 遍历要两层循环
for (int i = 0; i < 2; i++) {
    for (int j = 0; j < 3; j++) {
        printf("%d ", b[i][j]);
    }
    printf("\n");
}

二维数组在单片机里主要用来存「点阵字模」—— 比如 8×8 点阵的一个汉字,就是一个 unsigned char[8](每行一个字节,8 个字节 = 8×8 个点)。

6.6 字符串 = 字符数组

C 里没有字符串类型,字符串就是 char 数组,并且有一个特殊约定:末尾必须有一个 '\0'(值为 0 的字符)表示「到此结束」。

char s1[] = "hello";                      // 编译器自动加 '\0',共 6 字节
char s2[] = {'h','e','l','l','o','\0'};   // 手动写,必须自己加

// 二者等价。内存里长这样:
// 下标:  0    1    2    3    4    5
// 内容: 'h'  'e'  'l'  'l'  'o'  '\0'
🚨 忘了 '\0' 会读出乱码

所有字符串函数(printf("%s")、strlen)都是一路往后读,直到遇到 '\0' 才停。没有结束符,它就会一直读到你内存里的垃圾数据。

char s[3] = {'a','b','c'};    // ✗ 没有结束符
printf("%s\n", s);            // 输出 "abc" 后面跟一串随机字符

char s[4] = {'a','b','c','\0'};   // ✓

6.7 常用字符串操作

要 #include <string.h>:

函数作用例子
strlen(s)求长度(不含 '\0')strlen("hello") → 5
strcpy(d, s)把 s 复制到 d(数组不能直接赋值)strcpy(buf, "abc");
strcmp(a, b)比较内容,相等返回 0if (strcmp(a,"ok")==0)
strcat(d, s)把 s 接到 d 后面strcat(buf, "!");
⚠️ 数组名不是值,不能直接比较或赋值
char a[10] = "hi", b[10];
b = a;                    // ✗ 编译错误
strcpy(b, a);             // ✓ 用函数拷

if (a == "hi")            // ✗ 比的是地址,永远不相等
if (strcmp(a, "hi") == 0) // ✓ 比内容
🧠 今天的自测

① int a[5] 的合法下标范围是多少?a[5] 会怎样?
② int b[5] = {1}; 之后 b[3] 是多少?为什么?
③ 想把 int c[100] 全部设成 1,应该怎么写?
④ char s[] = "abc"; 占几个字节?strlen(s) 返回几?为什么不一样?

7Day 7 · 函数 + 第一周复盘

DAY 07 / 24

7.1 函数:给一段代码起个名字

你已经在用函数了(printf、scanf、strlen)。现在学怎么自己写。

// 语法:  返回类型  函数名(参数列表) { 函数体 }
int add(int a, int b)
{
    int sum = a + b;
    return sum;          // 把结果送回去
}

int main(void)
{
    int x = add(3, 5);   // 调用:传 3 和 5 进去,拿到 8
    printf("%d\n", x);   // 8
    return 0;
}

7.2 为什么要用函数

好处说明
不用重复写同一段逻辑用 5 次,就写一次函数、调 5 次
代码变短main 里只剩流程,一眼看懂在干什么
好改逻辑要改,只改函数内部一处
能复用今天写的 delay(),以后每个项目都能用

7.3 没有返回值:void

void blink(void)          // void 表示"不返回任何值"
{
    printf("闪一下\n");
    // 没有 return,或者写 return; (不带值)
}

int main(void)
{
    blink();              // 直接调用,不用接收返回值
    return 0;
}

(void) 表示「不接收参数」,比空括号 () 更明确。单片机的函数几乎都是 void 名字(void) 这个形状。

7.4 参数是「复制」进去的(这点非常重要)

void change(int x)
{
    x = 100;             // 改的是复制品
}

int main(void)
{
    int a = 5;
    change(a);
    printf("%d\n", a);   // 输出 5!不是 100
    return 0;
}
⚠️ 函数改不了外面的变量

调用 change(a) 时,C 把 a 的值复制一份给 x。函数里改 x,跟外面的 a 毫无关系。

这叫值传递。那如果我就是想让函数修改外面的变量怎么办?—— 要把「地址」传进去。这是 Day 9 的核心,也是指针存在的最大理由。

7.5 函数要「先声明后使用」

// 写法一:把函数定义写在 main 前面(简单,适合小程序)
int add(int a, int b) { return a + b; }

int main(void) { ... }

// 写法二:先声明(也叫原型),定义放后面
int add(int a, int b);      // 只写这一行,末尾有分号

int main(void) { int x = add(1,2); }

int add(int a, int b) { return a + b; }   // 真正的定义

如果函数写在 main 后面又没声明,编译器会报 implicit declaration(隐式声明)警告或错误。第三周你会看到所有 51 程序都把函数声明放最上面,或者放进 .h 文件。

7.6 一个实用的例子:自己写延时

#include <stdio.h>

/* 打印一条分割线,长度由参数决定 */
void line(int n)
{
    for (int i = 0; i < n; i++) {
        printf("-");
    }
    printf("\n");
}

/* 求数组最大值 */
int max_of(int a[], int n)      // 数组作为参数,要另外传长度
{
    int m = a[0];
    for (int i = 1; i < n; i++) {
        if (a[i] > m) m = a[i];
    }
    return m;
}

int main(void)
{
    int data[6] = {3, 9, 2, 7, 5, 8};

    line(30);
    printf("最大值 = %d\n", max_of(data, 6));
    line(30);
    return 0;
}
💡 为什么数组作为参数还要传长度?

因为函数内部 不知道这个数组有多长。数组传进函数时,实际传的是「首地址」,长度信息丢失了。所以必须额外传一个 n。

这也解释了一个现象:你以后看单片机代码,Uart_Send(unsigned char buf[], unsigned char len) 这种「数组 + 长度」成对出现的参数特别多 —— 原因就在这里。

7.7 第一周复盘

七天下来,你应该已经能读写这些:

概念关键点
程序结构#include → main → 语句 + 分号
变量与类型int / char / float;必须初始化;int 宽度不固定
输入输出printf / scanf(scanf 要加 &)
运算符整数除法砍小数;% 取位;= 与 == 的区别
分支if/else if/else、switch(别忘 break)
循环for / while;break 与 continue;单片机用 while(1)
数组下标 0~N−1;{0} 清零、{1} 不是全部设 1
字符串char[] + '\0';strlen/strcpy/strcmp
函数定义/声明/调用;值传递,改不了外部变量
练习 1 成绩统计基础

输入 10 个学生的成绩(0~100),输出:最高分、最低分、平均分、及格人数。

看参考答案
#include <stdio.h>

int main(void)
{
    int score[10];
    int i, max, min, sum = 0, pass = 0;

    for (i = 0; i < 10; i++) {
        scanf("%d", &score[i]);
    }

    max = min = score[0];        // 先把第一个当成最大最小

    for (i = 0; i < 10; i++) {
        if (score[i] > max) max = score[i];
        if (score[i] < min) min = score[i];
        sum += score[i];
        if (score[i] >= 60) pass++;
    }

    printf("最高分:%d\n", max);
    printf("最低分:%d\n", min);
    printf("平均分:%d\n", sum / 10);
    printf("及格人数:%d\n", pass);
    return 0;
}

注意 max = min = score[0]; —— 必须先拿一个真实数据做起点,不能设成 0(否则全是负数时就错了)。这个「用第一个元素初始化最值」的套路,以后天天用。

练习 2 用函数改写进阶

把上一题拆成三个函数:int find_max(int a[], int n)、int find_min(int a[], int n)、int count_pass(int a[], int n),让 main 只负责输入和打印。

看参考答案
#include <stdio.h>

int find_max(int a[], int n)
{
    int m = a[0];
    for (int i = 1; i < n; i++) if (a[i] > m) m = a[i];
    return m;
}

int find_min(int a[], int n)
{
    int m = a[0];
    for (int i = 1; i < n; i++) if (a[i] < m) m = a[i];
    return m;
}

int count_pass(int a[], int n)
{
    int c = 0;
    for (int i = 0; i < n; i++) if (a[i] >= 60) c++;
    return c;
}

int main(void)
{
    int s[10], i, sum = 0;

    for (i = 0; i < 10; i++) scanf("%d", &s[i]);
    for (i = 0; i < 10; i++) sum += s[i];

    printf("最高分:%d\n", find_max(s, 10));
    printf("最低分:%d\n", find_min(s, 10));
    printf("平均分:%d\n", sum / 10);
    printf("及格人数:%d\n", count_pass(s, 10));
    return 0;
}

体会一下:main 现在一眼就能看懂在做什么。这就是函数的价值 —— 它把「怎么做」藏起来,只留下「做什么」。

✓ 第一周过关标准

不看笔记,你能独立写出上面这两道题(哪怕慢一点、要查一两次语法),就可以进第二周了。
如果还写不出来,别急着往下走 —— 第二周的指针会建立在这一周的数组和函数上。

8Day 8 · 指针(一):地址是什么

DAY 08 / 24
🧠 先说结论,减少恐惧

指针被传得很吓人,但它在单片机里只有两个实际用途:
① 让函数能修改外面的变量(Day 9)
② 用地址直接访问硬件寄存器(Day 9)
除此之外你几乎不需要指针。带着这两个目的学,就不会迷路。

8.1 内存与地址

把内存想成一条很长的街,每间房子 1 字节,门牌号就是地址。你声明一个变量,等于占了一间(或几间)房子。

int a = 10;

// a 住在哪?用 & 取地址
printf("a 的值  = %d\n", a);        // 10
printf("a 的地址= %p\n", &a);       // 0x7ffd4c2a 之类
符号读作作用
&a「a 的地址」取出 a 住在哪个门牌号
int *p「p 是一个指针」声明一个专门用来存地址的变量
*p「p 指向的东西」顺着地址去把那间房子里的内容取出来

8.2 第一个指针程序

#include <stdio.h>

int main(void)
{
    int a = 10;
    int *p = &a;              // p 里存的是 a 的地址

    printf("a   = %d\n", a);   // 10
    printf("*p  = %d\n", *p);  // 10   顺着 p 找到 a,取出里面的值

    *p = 20;                   // 顺着 p 找到 a,把值改成 20
    printf("a   = %d\n", a);   // 20   a 被改了!

    return 0;
}
💡 一个符号两种身份

这是初学者最晕的地方:* 在「声明」和「使用」时意思不同。

int *p;      // 声明:* 表示"p 是个指针"
*p = 20;     // 使用:* 表示"p 指向的那块内存"

读代码时的诀窍:看 * 前面有没有类型名。有类型名(int *p)就是声明;没有(*p)就是取值。

8.3 指针变量里存的是什么

int  a = 10;
int  b = 20;
int *p;

p = &a;      // p 指向 a
printf("%d\n", *p);   // 10

p = &b;      // 改成指向 b(指针本身可以被重新赋值)
printf("%d\n", *p);   // 20

p = &a;
*p = *p + 5;         // 把 a 加 5(*p 代替了 a 的位置)
printf("%d\n", a);   // 15

看出来了:*p 和 a 指的是同一块内存。用哪个名字都行。

8.4 为什么单片机离不开指针

先看一个「预告版」例子(Day 9 详解):

/* 把地址 0x80 当成一个可读写的 8 位寄存器 */
*(volatile unsigned char *)0x80 = 0x55;

单片机里,硬件就是用「地址」来访问的。芯片手册上写「P1 口的地址是 0x90」,意思是:往 0x90 这个地址写数据,就改变了那 8 个引脚的电平。

所以你必须会「把某个地址当成变量来用」—— 这正是指针唯一不可替代的地方。

8.5 三种指针事故(一定要知道)

// 事故一:野指针 —— 没初始化的指针
int *p;              // p 里是垃圾值,指向随机地方
*p = 5;              // ✗ 往随机内存写 → 崩溃

int *p = NULL;       // ✓ 先给 NULL(表示"不指向任何地方")

// 事故二:用之前不检查
if (p != NULL) {     // ✓ 好习惯
    *p = 5;
}

// 事故三:返回局部变量的地址
int *bad(void)
{
    int x = 10;
    return &x;       // ✗ x 是局部变量,函数一返回它就没了
}
⚠️ 为什么事故三危险

局部变量住在栈上,函数结束时它的空间就被回收了。你返回它的地址,等于给别人一把「已经拆掉的房子的钥匙」—— 那个位置马上会被下一个函数的数据覆盖,你读到的就是别人的数据。

栈的概念 Day 14 会讲清楚。

🧠 今天的自测

① &a 和 *p 各是什么意思?
② int *p; 和 *p = 5; 里的 * 意思一样吗?
③ 为什么说「单片机离不开指针」?
④ int *p; 之后直接 *p = 5; 会发生什么?正确做法是什么?

9Day 9 · 指针(二):数组、传参与寄存器

DAY 09 / 24

9.1 数组名就是首地址

int a[5] = {10, 20, 30, 40, 50};

printf("%p\n", (void*)a);       // a 本身就是首元素的地址
printf("%p\n", (void*)&a[0]);   // 和上面一样

所以可以这样用:

int *p = a;             // 等价于 int *p = &a[0];

printf("%d\n", a[2]);    // 30
printf("%d\n", *(a+2));  // 30   ← a[i] 本质就是 *(a+i)
printf("%d\n", p[2]);    // 30   ← 指针也能用中括号
printf("%d\n", *(p+2));  // 30
✓ 理解这一条,指针就不神秘了

a[i] 只是 *(a+i) 的简写。「下标」这个操作,本质是「从首地址往后数 i 个,再取值」。

这也解释了为什么 int 数组的 a+1 是往后跳 4 字节(一个 int 的宽度),而不是 1 字节 —— 因为指针的加法会自动乘以类型大小。

9.2 指针传参:让函数能改动外部变量

Day 7 说过,函数参数是值传递,改不了外面。现在有办法了:把地址传进去。

// ✗ 改不了外面
void addOne_bad(int x)
{
    x++;                 // 改的是复制品
}

// ✓ 能改
void addOne(int *x)
{
    (*x)++;              // 顺着地址找到外面的变量,把它加 1
}

int main(void)
{
    int a = 5;

    addOne_bad(a);
    printf("%d\n", a);   // 5    没变

    addOne(&a);          // 传地址,前面加 &
    printf("%d\n", a);   // 6    变了!

    return 0;
}
🚨 括号不能省:(*x)++ 与 *x++
void f(int *x) {
    *x++;      // ✗ 把指针自身往后挪一格,再取值(几乎肯定不是你要的)
    (*x)++;    // ✓ 把指向的那个值加 1
}

因为 ++ 的优先级高于 *。这个坑在单片机代码里很常见,看到 * 和 ++ 挨在一起就条件反射地加括号。

9.3 什么时候真的需要指针传参

两个场景:

// 场景一:函数要"返回"多个值(C 的 return 只能返回一个)
void min_max(int a[], int n, int *pmin, int *pmax)
{
    *pmin = *pmax = a[0];
    for (int i = 1; i < n; i++) {
        if (a[i] < *pmin) *pmin = a[i];
        if (a[i] > *pmax) *pmax = a[i];
    }
}

// 调用时把两个变量的地址传进去
int lo, hi;
min_max(data, 10, &lo, &hi);
// 现在 lo 是最小值,hi 是最大值 —— 一次拿到两个结果
// 场景二:字符串/数组的处理函数(避免复制整个数组)
void upper(char *s)
{
    while (*s != '\0') {
        if (*s >= 'a' && *s <= 'z') *s = *s - 32;   // 小写转大写
        s++;                                        // 指针往后挪一格
    }
}

注意 while (*s != '\0') 这个写法 —— 它靠字符串的结束符来终止循环,所以后面没有长度参数。这是 C 处理字符串的经典写法。

9.4 用指针访问寄存器(单片机的命根子)

现在把 Day 8 的预告讲清楚:

/* 把地址 0x90 当成一个可读写的 8 位寄存器,写入 0xFE */
*(volatile unsigned char *)0x90 = 0xFE;

从里往外拆开读:

片段含义
0x90一个整数,就是寄存器地址
(unsigned char *)0x90把它强制解释成「指向 8 位无符号数的指针」
volatile告诉编译器:这个地址的内容可能被硬件改变,每次都真的去读写,不许优化
最前面的 *往这个地址写入右边的值
💡 那 P1 = 0xFE; 是怎么来的?

芯片头文件里替你写了这一句:

sfr P1 = 0x90;      // 给地址 0x90 起名叫 P1

之后你写 P1 = 0xFE;,编译器就把它翻译成上面那串指针操作。两者是同一件事,只是后者好写好看。

理解这个,你就明白了:你操作的每一个寄存器名字,背后都是一个固定地址。这就是「底层逻辑」的第一层。

9.5 读寄存器

/* 读 P3 口的状态(判断按键) */
unsigned char v = P3;

/* 等价的手写形式 */
unsigned char v = *(volatile unsigned char *)0xB0;

你会发现芯片手册里每个寄存器的「地址」一栏,就是你将来可能用到的数字。有名字的用名字,没名字的(或自定义地址)就用指针。

🧠 今天的自测

① a[3] 和 *(a+3) 是什么关系?
② 为什么函数参数是「值传递」时改不了外面的变量?怎么改才能改到?
③ (*x)++ 和 *x++ 有什么区别?
④ 逐段解释 *(volatile unsigned char *)0x90 = 0xFE; 里每个部分的作用。

10Day 10 · 结构体 / 枚举 / 联合

DAY 10 / 24

10.1 为什么需要结构体

假设你要描述 3 个 LED 的状态:颜色、亮灭、亮度。用数组很难表达「这几个数据属于同一个东西」:

int led_color[3];
int led_on[3];
int led_bright[3];      // 三个数组靠下标硬凑,很容易搞错

结构体让不同类型的数据打包成一个整体:

struct Led {
    int  color;         // 颜色
    int  on;            // 亮/灭
    int  bright;        // 亮度
};

struct Led myled;       // 一个 Led,包含三个成员

myled.color  = 1;
myled.on     = 1;
myled.bright = 80;
printf("%d\n", myled.bright);   // 80

用点 . 访问成员。

10.2 C 里的必写套路:typedef

上面每次都要写 struct Led 很烦。C 程序员统一用 typedef 给它起个别名:

typedef struct {
    int color;
    int on;
    int bright;
} Led;                  // 从这行之后,Led 就是一个类型名

Led a, b;               // ✓ 和 int 一样好用,不用再写 struct
💡 看到 typedef struct 不要慌

格式固定成这个形状就行:

typedef struct {
    成员...
} 类型名;

最后那个才是你以后要用的名字。单片机头文件里 90% 的结构体都长这样。

10.3 结构体数组:管理一组同类对象

Led leds[3];             // 3 个 LED,每个都有自己的三个成员

leds[0].color = 1;  leds[0].on = 1;
leds[1].color = 2;  leds[1].on = 0;
leds[2].color = 3;  leds[2].on = 1;

for (int i = 0; i < 3; i++) {
    printf("LED%d: 颜色=%d 状态=%d\n", i, leds[i].color, leds[i].on);
}

10.4 指针访问成员:箭头 ->

Led led1 = {1, 1, 80};
Led *p = &led1;              // 指向结构体的指针

printf("%d\n", led1.bright);   // 80    变量用点
printf("%d\n", (*p).bright);   // 80    先解引用再取成员,写法啰嗦
printf("%d\n", p->bright);     // 80    箭头是上面那种写法的简写
✓ 一句话记住

变量用 .,指针用 ->。
为什么需要指针访问成员?因为函数传参时(值传递),传结构体会整个复制一遍,又慢又费内存。传指针就只传 4 个字节。

void Led_On(Led *l) { l->on = 1; }   // 传指针,能改到外面
Led_On(&myled);                       // 调用

10.5 结构体映射寄存器(嵌入手法的核心)

这是结构体在单片机里最重要的用途:把地址连续的一堆寄存器,用一个结构体描述出来。

/* 假设某个外设的寄存器从 0x40000000 开始,每个占 4 字节,依次排列 */
typedef struct {
    volatile unsigned int CR;      // 偏移 0x00  控制寄存器
    volatile unsigned int SR;      // 偏移 0x04  状态寄存器
    volatile unsigned int DR;      // 偏移 0x08  数据寄存器
} UART_TypeDef;

/* 把基地址强制转换成这种结构体指针 */
#define UART1  ((UART_TypeDef *)0x40000000)

/* 之后就能用成员名访问寄存器,不用记偏移量 */
UART1->CR = 0x01;         // 实际写到了 0x40000000
UART1->DR = 'A';          // 实际写到了 0x40000008

原理就是 Day 9 学的:结构体成员在内存里依次排列,所以成员名会对应到固定偏移的地址上。

💡 51 上也有对应的写法

51 的 SFR 分散在 0x80~0xFF,不像 32 位机那样整齐,所以 51 一般直接用 sfr 一个个起名字,而不是结构体映射。但「用名字操作固定地址」这个思想是一样的。

10.6 枚举 enum:给数字起名字

typedef enum {
    LED_OFF   = 0,
    LED_ON    = 1,
    LED_BLINK = 2
} LedState;

LedState s = LED_ON;

if (s == LED_BLINK) { ... }

它就是个整数,但比记「0 是灭、1 是亮、2 是闪」清楚得多。单片机里凡是「有限几个状态」的东西(工作模式、错误码),都适合用枚举。

10.7 联合 union:几种看法共用一块内存

typedef union {
    unsigned int  word;       // 当 16 位数看
    unsigned char byte[2];    // 拆成两个 8 位看
} Data16;

Data16 d;
d.word = 0x1234;
printf("%02X %02X\n", d.byte[0], d.byte[1]);   // 34 12

联合的所有成员共用同一块内存。用它可以在「一个整体」和「拆开的部分」之间自由切换 —— 处理传感器数据、串口收包时很方便。

手写也能做(d >> 8 和 d & 0xFF),但 union 更直观、也不用算。

🧠 今天的自测

① 结构体成员用 . 还是 -> 访问?什么时候换?
② typedef struct { ... } Led; 里,以后要用的类型名是哪个?
③ 为什么函数参数传结构体指针,而不直接传结构体?
④ 解释 UART1->DR = 'A'; 最终写到了哪个地址。

11Day 11 · 位运算:单片机的母语

DAY 11 / 24
🎯 这一讲是整个第二周最重要的一天

51 的一个 IO 口有 8 个引脚,但它们共用同一个寄存器。你不可能「只写其中一个引脚」——你只能读回整个字节、改掉其中一位、再整体写回去。

这个「读—改—写」的动作,全靠位运算完成。不会位运算,就等于不会单片机。

11.1 一个字节 = 8 个引脚

先建立直觉。P1 是一个 8 位寄存器,每一位对应一个引脚:

P1.7bit7
P1.6bit6
P1.5bit5
P1.4bit4
P1.3bit3
P1.2bit2
P1.1bit1
P1.0bit0
P1 = 0xFE;        // 二进制 1111 1110 —— 只有 P1.0 是 0,其余是 1
P1 = 0x7F;        // 二进制 0111 1111 —— 只有 P1.7 是 0
P1 = 0x00;        // 全部为 0
P1 = 0xFF;        // 全部为 1

LED 常见接法是「低电平点亮」,所以 P1 = 0xFE 的效果是「P1.0 那个灯亮,其余灭」。Day 16 会讲清为什么。

11.2 六个运算符

运算符名字运算规则例子
&按位与两位都为 1 才是 11100 & 1010 = 1000
|按位或有一位为 1 就是 11100 | 1010 = 1110
^按位异或两位不同才是 11100 ^ 1010 = 0110
~按位取反0 变 1,1 变 0(全部位)~1100 = 0011
<<左移整体左移,右边补 00001 << 2 = 0100
>>右移整体右移,左边补 00100 >> 2 = 0001

11.3 四个必背惯用法(练成肌肉记忆)

这四行你要练到不用想就能写。设 n 是位号(0~7):

/* ① 置位:把第 n 位变成 1,其它位不动 */
reg |= (1 << n);

/* ② 清零:把第 n 位变成 0,其它位不动 */
reg &= ~(1 << n);

/* ③ 翻转:第 n 位取反,其它位不动 */
reg ^= (1 << n);

/* ④ 判断:第 n 位是不是 1 */
if (reg & (1 << n)) { /* 是 1 */ }

为什么「清零」要写成 &= ~(1<<n) 这么绕?

因为 |= 只能把位变成 1,没法把位变成 0。想让第 n 位变 0、其它位不变,就得拿一个「除了第 n 位是 0,其余全是 1」的掩码去与:

1 << 2            = 0000 0100    // 只有第 2 位是 1
~(1 << 2)         = 1111 1011    // 只有第 2 位是 0 ← 这就是我们要的掩码
reg &= ~(1 << 2)  // reg 的第 2 位被清 0,其余原样保留

11.4 手推一遍(一定要自己推一次)

设 P1 初值是 1111 0000(写成 0xF0):

操作计算过程结果
P1 |= (1<<0)1111 0000 | 0000 00011111 0001 = 0xF1
P1 |= (1<<3)1111 0001 | 0000 10001111 1001 = 0xF9
P1 &= ~(1<<7)1111 1001 & 0111 11110111 1001 = 0x79
P1 ^= (1<<4)0111 1001 ^ 0001 00000110 1001 = 0x69

11.5 实战用法

/* 判断按键是否按下(P3.2 接按键,按下时该位为 0) */
if ((P3 & (1 << 2)) == 0) {
    // 按下了
}

/* 一次点亮低 4 个 LED,不影响高 4 位 */
P1 |= 0x0F;

/* 一次熄灭低 4 个 LED,不影响高 4 位 */
P1 &= 0xF0;

/* 取低 4 位的值单独看 */
unsigned char low = P1 & 0x0F;

/* 高低 4 位互换 */
P1 = (P1 << 4) | (P1 >> 4);

/* 两个 8 位数拼成一个 16 位数(传感器数据常见) */
unsigned int v = ((unsigned int)high << 8) | low;

/* 从 16 位数里拆出高、低字节 */
unsigned char h = (unsigned char)(v >> 8);
unsigned char l = (unsigned char)(v & 0xFF);

11.6 四个经典坑

🚨 坑 1:优先级(最常见)
if (P1 & 0x08 == 0)        // ✗ == 优先级高于 &
// 实际被理解成: P1 & (0x08 == 0)  →  P1 & 0  →  永远是 0

if ((P1 & 0x08) == 0)      // ✓ 位运算加括号

位运算的优先级比比较运算符还低。记不住优先级没关系 —— 凡是位运算就加括号。

🚨 坑 2:~ 是全位取反,不是「只反那一位」
unsigned char a = 0x0F;
unsigned char b = ~a;      // 0xF0(8 位下正确)

int c = ~0x0F;             // 在 32 位 int 上是 0xFFFFFFF0,不是 0xF0!

所以掩码最好直接写清楚(0xF0),或者保证类型是无符号的 8 位。

🚨 坑 3:移位数超过类型宽度是未定义行为
unsigned char a = 1 << 8;    // ✗ 8 位类型左移 8 位 —— 结果不可预测
unsigned int  b = 1 << 16;   // ✓ 16 位类型可以移到 15
unsigned long c = 1UL << 31; // ✓ 要做大位号掩码,加 UL 后缀
🚨 坑 4:&& 和 & 别混,|| 和 | 别混
if (a && b)    // 逻辑与:结果是 0 或 1
if (a & b)     // 按位与:结果是两个数按位运算的结果

P1 | 0x01;     // ✗ 这只是算了一下,结果丢掉了!
P1 |= 0x01;    // ✓ 才是"写到 P1"

最后这个错误极其常见:想置位却写成 P1 | 0x01;,编译能过、什么也不发生。引脚毫无反应,你会查半天硬件。

11.7 自测(不看上面,写出这几行的作用)

P1 |= 0x01;
P1 &= ~0x01;
P1 ^= 0x01;
if (P1 & 0x01) { }
x = (a << 8) | b;
P1 |= (1 << 3);
P1 &= ~(1 << 3);
点开看答案

① 把最低位置 1;② 把最低位清 0;③ 把最低位翻转;④ 判断最低位是否为 1;⑤ 把 a 当高字节、b 当低字节拼成 16 位数;⑥ 把第 3 位置 1;⑦ 把第 3 位清 0。

注意 ① 和 ⑥、② 和 ⑦ 是同一件事的两种写法:0x01 就等于 1<<0,0x08 就等于 1<<3。能一眼看出 0x08 是第 3 位,是练位运算的目标。

✓ 十六进制 ↔ 二进制 速记表(背下来会快很多)
十六进制二进制十六进制二进制
0x010000 00010x0F0000 1111
0x020000 00100x100001 0000
0x040000 01000x801000 0000
0x080000 10000xF01111 0000
0xFE1111 11100xFF1111 1111

12Day 12 · 宏与预处理

DAY 12 / 24

12.1 预处理是什么

在真正编译之前,编译器会先做一遍文本处理:把所有 # 开头的指令处理掉,生成一份"展开后的代码",再拿去编译。

指令作用
#include把这个文件的内容原样插进来
#define文本替换
#ifdef / #ifndef / #endif条件编译:满足条件才保留这段代码
💡 理解这一点,后面很多"怪现象"就通了

#define 不是变量、不是常量、也不是函数,它只是「查找替换」。所以它没有类型、不做检查、也不占运行时间。

12.2 常量宏:单片机里最常用

#define LED_PIN     1
#define MAX_SIZE    100
#define CRYSTAL     12000000UL     // 晶振频率 12MHz
#define LED_PORT    P1             // 甚至可以直接替换寄存器名

// 用起来和普通名字一样
LED_PORT = 0xFE;
if (i < MAX_SIZE) { ... }

注意全大写加下划线的命名风格 —— 这不是语法要求,是约定,让人一眼看出「这是个宏」。

12.3 带参数的宏(以及那个著名的括号陷阱)

#define SQUARE(x)   ((x) * (x))
#define MAX(a, b)   ((a) > (b) ? (a) : (b))

int y = SQUARE(3);        // 展开成 ((3) * (3)) = 9
🚨 不加括号的经典事故
#define SQUARE(x)  x * x

SQUARE(2 + 3)    // 你以为:(2+3) * (2+3) = 25
                 // 实际展开:2 + 3 * 2 + 3 = 2 + 6 + 3 = 11  ← 错!

因为宏是文本替换,它不懂运算优先级。规则:每个参数都加括号,整体再加一层括号。

#define SQUARE(x)   ((x) * (x))     // ✓ 双层括号
#define HALF(x)     ((x) / 2)
#define MUL(a,b)    ((a) * (b))

12.4 条件编译

用法一:头文件保护(Day 13 会详细用)

#ifndef __LED_H__
#define __LED_H__

/* 头文件的真正内容 */

#endif

意思是:「如果 __LED_H__ 还没被定义过,就定义它并保留下面的内容;否则整段跳过。」这样同一个头文件被包含多次也只生效一次。

用法二:调试开关(非常实用)

#define DEBUG 1

// 只有 DEBUG 为真时才编译进去
#if DEBUG
    #define LOG(x)   printf(x)
#else
    #define LOG(x)   /* 什么都不做 */
#endif

LOG("init ok\n");     // DEBUG=1 时打印;改成 0 后这行自动消失

单片机 ROM 很小,调试信息不该带到最终版本里。这个技巧能让你「调试时打开、发布时一行改掉」。

12.5 宏 vs 函数:怎么选

宏函数
本质文本替换(编译前)一段可调用的代码
类型检查没有有
运行开销无(就地展开)有压栈/跳转开销
代码体积每用一次就展开一份,会变大只有一份
调试难(看不见展开结果)容易
适合常量、位掩码、简单表达式、条件编译稍复杂的逻辑
💡 单片机的选择习惯

简单的(取位、算掩码、常量)用宏;只要有点逻辑,就用函数。因为宏展开会让代码变大,而 51 的 Flash 只有几 KB。

12.6 单片机里的实战写法

/* 位掩码宏:配合 Day 11 的位运算 */
#define BIT(n)          (1 << (n))

/* 引脚操作宏:把"低电平点亮"这件事封装成一个动作 */
#define LED_ON()        (P1 &= ~BIT(LED_PIN))
#define LED_OFF()       (P1 |=  BIT(LED_PIN))
#define LED_TOGGLE()    (P1 ^=  BIT(LED_PIN))

/* 用起来就像在说人话 */
LED_ON();
LED_TOGGLE();
⚠️ 宏里为什么整体要加括号
#define LED_ON()   P1 &= ~BIT(LED_PIN)     // ✗ 少了最外层括号

// 如果有人这样用:
if (cond)
    LED_ON();
else
    LED_OFF();
// 展开后变成 if (cond) P1 &= ~BIT(...); else ... → 语法错误

加一层括号写成 #define LED_ON() (P1 &= ~BIT(LED_PIN)) 就更安全。宏的括号不嫌多。

🧠 今天的自测

① #define SQUARE(x) x*x 时 SQUARE(1+2) 展开成什么?结果是多少?
② #ifndef / #define / #endif 三行是干什么的?
③ 宏和函数最本质的区别是什么?
④ 写一个宏 BIT(n),让 BIT(3) 等于 8。

13Day 13 · 多文件工程与内存分区

DAY 13 / 24

13.1 你的代码变成了什么(编译四个阶段)

阶段做什么产物
预处理展开 #include、替换 #define、处理条件编译main.i(纯 C 代码)
编译C 代码 → 汇编代码main.s
汇编汇编 → 机器码(目标文件)main.o
链接把多个 .o 和库拼成一个可执行文件,给每个函数/变量定下最终地址main.hex / .exe
💡 「链接」这一步为什么重要

因为你写了 Led_On() 这个函数在 led.c 里,而 main.c 里调用了它。两个文件是分别编译的,谁也不知道对方在哪。链接器的工作就是:把两边对上,给 Led_On 定一个确定地址,把调用处的地址填进去。

如果它找不到,你就会看到 undefined reference to 'Led_On' —— 意思是「你说有这个函数,但我没找到」。这是多文件工程最常见的错误。

13.2 为什么要拆成多个文件

好处说明
好找串口的问题就去 uart.c 找,不用在 2000 行里翻
好改改显示逻辑不会碰到通信代码
能复用led.c + led.h 直接拷到下一个项目
能分工多人各写一个模块

13.3 .h 和 .c 的分工(一句话规则)

✓ 记住这一条就不会乱

.h 放「声明」:告诉别人「我有什么」。
.c 放「定义」:真正实现它是怎么做的。

声明 = 菜单;定义 = 厨房。菜单上写了「宫保鸡丁」,厨房里才真正做菜。

led.h —— 对外接口

#ifndef __LED_H__
#define __LED_H__

#include <reg51.h>

/* 宏 */
#define LED_PORT   P1
#define LED_COUNT  8
#define BIT(n)     (1 << (n))

/* 函数声明(只有一行,末尾分号,没有函数体) */
void Led_Init(void);
void Led_On(unsigned char index);
void Led_Off(unsigned char index);
void Led_Toggle(unsigned char index);
void Led_AllOff(void);

#endif

led.c —— 真正的实现

#include "led.h"

void Led_Init(void)
{
    LED_PORT = 0xFF;              /* 全灭(低电平点亮) */
}

void Led_On(unsigned char index)
{
    LED_PORT &= ~BIT(index);      /* Day 11 的清零写法 */
}

void Led_Off(unsigned char index)
{
    LED_PORT |= BIT(index);
}

void Led_Toggle(unsigned char index)
{
    LED_PORT ^= BIT(index);       /* 翻转 */
}

void Led_AllOff(void)
{
    LED_PORT = 0xFF;
}

main.c —— 只管流程

#include "led.h"

void DelayMs(unsigned int ms);

void main(void)
{
    unsigned char i;

    Led_Init();

    while (1) {
        for (i = 0; i < LED_COUNT; i++) {
            Led_On(i);
            DelayMs(200);
            Led_Off(i);
        }
    }
}

看 main.c —— 它完全不需要知道 LED 接在哪个口、低电平还是高电平点亮。这就是模块化的价值。

13.4 #include 的两种写法

写法去哪里找用在哪
#include <reg51.h>系统/编译器自带目录标准库、芯片头文件
#include "led.h"先找当前目录,再找系统目录你自己写的头文件

13.5 extern 与 static

/* extern:声明"这个变量在别的文件里定义了" */
extern unsigned int g_tick;      // 只是打个招呼,不分配内存
unsigned int g_tick = 0;         // 真正的定义,只能有一处
/* static 修饰全局变量/函数:只在本文件可见 */
static void internal_helper(void) { }    // 别的 .c 文件调不到
static unsigned char buf[16];            // 别的 .c 文件访问不到
💡 static 是一种"封装"

它相当于说:「这个函数/变量是我这个模块的内部实现细节,外面别碰。」
好处是:你以后可以随便改它的名字、改它的逻辑,只要 .h 里的对外接口不变,别的文件都不用动。

13.6 常见错误

错误信息原因怎么改
undefined reference to 'xxx'函数声明了但没实现,或者 .c 没加进工程检查 .c 是否加入工程;检查函数名拼写
multiple definition of 'xxx'同一个变量在多个 .c 里定义了只能在一处定义,其它地方用 extern 声明
头文件里的内容报重复定义忘了头文件保护那三行加上 #ifndef / #define / #endif
redefinition of 'struct xxx'头文件被包含两次同上,加头文件保护
🧠 今天的自测

① 编译的四个阶段是什么?「链接」这一步在解决什么问题?
② .h 里放什么?.c 里放什么?用「菜单和厨房」说一遍。
③ 为什么头文件要加 #ifndef / #define / #endif?
④ extern unsigned int g_tick; 会分配内存吗?

14Day 14 · 内存、栈与 volatile

DAY 14 / 24

今天是第二周的最后一天,我们把「你的变量到底住在哪」讲清楚。理解这个,第三周看单片机就不再有迷雾。

14.1 程序的内存分成四块

区域放什么特点
代码区编译出来的机器指令只读。在单片机上它在 Flash/ROM 里,掉电不丢
静态区全局变量、static 变量、常量程序一开始就分配好,整个运行期都在。掉电丢失
栈(stack)局部变量、函数参数、返回地址函数进来分配、出去回收,自动管理,很小
堆(heap)malloc 出来的内存手动分配释放。单片机里几乎不用(内存太小、容易碎片)
/* 看清楚每一类变量住在哪 */

int global_var;                  // 静态区(全局变量)
static int file_var;             // 静态区(文件内静态)
const unsigned char table[4] = {1,2,3,4};   // 静态区/代码区(只读)

void func(void)
{
    int  local = 1;              // 栈(局部变量)
    static int count = 0;        // 静态区(函数退出后值还在)
    char *p = malloc(10);        // 堆(p 本身在栈上)
    free(p);
}

14.2 栈:函数调用是怎么工作的

每次调用函数,系统就在栈上给它分配一块空间(叫「栈帧」),放它的局部变量和返回地址;函数返回时,这块空间被回收。

void funcB(void)
{
    int b = 2;        // b 在栈上
}

void funcA(void)
{
    int a = 1;        // a 在栈上
    funcB();          // 调用 funcB,栈上又多一层
}

int main(void)
{
    funcA();
    return 0;
}
/* 栈的变化过程(从下往上长) */

调用 main : | main 的局部变量 |
调用 funcA: | main ... | funcA 的 a |
调用 funcB: | main ... | funcA 的 a | funcB 的 b |   ← 栈顶
funcB 返回: | main ... | funcA 的 a |
funcA 返回: | main ... |
🚨 这解释了两件事

① 为什么局部变量不初始化是垃圾值 —— 它用的是上一轮函数留下的数据,没被清过。

② 为什么不能返回局部变量的地址(Day 8 的事故三)—— 函数一返回,它那块栈空间就被回收了,马上会被下一个函数覆盖。你拿到的地址还在,但里面的内容已经变成别人的了。

int *bad(void) {
    int x = 10;
    return &x;      // ✗ x 所在的栈空间已经作废
}
⚠️ 单片机的栈特别小

51 总共只有 128~256 字节的 RAM,其中能当栈用的只是一部分。所以:不要在函数里定义大数组(比如 int buf[100] 一下就要 200 字节,栈直接爆掉)。大数组要定义成全局的(放静态区)。

这是单片机编程和电脑编程最大的习惯差异之一。

14.3 51 特有的存储类型(了解即可)

51 的存储器分好几块,C 编译器用关键字指定变量放哪:

C51 关键字放在哪特点
data内部 RAM 低 128 字节最快(1 个机器周期),但只有 128 字节
idata内部 RAM 全部 256 字节间接寻址,稍慢
xdata外部 RAM容量大但慢(2 个机器周期以上)
code程序存储器(Flash)只读,用来放常量表,省 RAM
/* 数码管段码表放进 Flash,不占 RAM —— 单片机省内存的常规操作 */
unsigned char code seg_table[10] = {
    0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90
};

不加 code 的话,这 10 个字节就白占 RAM 了。51 的 RAM 是按字节省的,这个习惯要养成。

14.4 volatile:嵌入式的第一关键字

编译器的优化逻辑是:「如果一个变量我没看见谁改它,那它的值就不用重新读。」但单片机里有两种它看不见的修改:

  • 硬件改的 —— 寄存器内容会被外设自己改变
  • 中断改的 —— 中断服务函数随时可能插进来改
/* 错误示范:等串口收到数据 */
unsigned char flag = 0;

void Uart_ISR(void) interrupt 4     /* 中断服务函数(Day 22 学) */
{
    flag = 1;                       /* 中断里改 flag */
}

void main(void)
{
    while (flag == 0);              // ✗ 编译器认为 flag 永远是 0
                                    //   于是把整个循环优化掉 → 卡死
}
/* 正确写法 */
volatile unsigned char flag = 0;    /* 加 volatile */

void main(void)
{
    while (flag == 0);              // ✓ 每次都真的去内存读
}
✓ 什么变量必须加 volatile

满足下面任一条就要加:
① 会被中断服务函数修改的变量
② 对应硬件寄存器(值可能被硬件自己改变)
③ 在主循环和中断之间共享的标志位

Day 10 那个 GPIO_TypeDef 里每个成员都写了 volatile,原因就在这里。

⚠️ 这个 bug 的表现特别气人

加了 volatile 就好了、去掉就卡死 —— 而且改一下编译器的优化等级,现象还会变。很多人查一整天以为是硬件问题。记住:「中断里改的变量忘了 volatile」是嵌入式排名第一的玄学 bug。

14.5 第二周复盘

概念一句话
指针存地址的变量;& 取地址、* 取值;a[i] 就是 *(a+i)
指针传参让函数能改外部变量(C 没有引用,只能靠它)
寄存器访问*(volatile unsigned char *)0x90 = 0xFE;;sfr 就是这句话的马甲
结构体把相关数据打包;typedef struct {...} 名字;;指针用 ->
联合 union几种看法共用一块内存(拆字节、拼字节)
位运算|= 置位、&= ~ 清零、^= 翻转、& 判断;永远加括号
宏文本替换;参数和整体都要加括号;用于常量、掩码、条件编译
多文件.h 声明(菜单)、.c 定义(厨房);extern / static
内存四区代码 / 静态 / 栈 / 堆;局部变量在栈上,函数一返回就没了
volatile中断改的、硬件改的变量必须加,否则会被优化掉
✓ 第二周过关标准

你能回答出这三个问题,就可以进第三周了:
① P1 &= ~(1 << 3); 这一行在做什么?
② *(volatile unsigned char *)0x90 = 0xFE; 里每个部分什么意思?
③ 为什么中断里改的变量要加 volatile?

15Day 15 · 单片机是什么

DAY 15 / 24
🎯 欢迎进入第三周

前两周你在电脑上写程序。从今天起,程序要跑到一块 2 厘米见方、售价几块钱的芯片上,去控制真实的电压和电流。

好消息是:C 语言一个字都不用重学。你前 14 天学的东西,在这里全部有效。新增的只是「硬件相关的部分」。

15.1 单片机就是一台最小的电脑

你电脑里有 CPU、内存、硬盘、显卡、网卡…… 单片机把其中最核心的几样塞进了一块芯片:

电脑里的单片机里的51 的具体情况
CPUCPU 内核8 位 CPU,一次处理 1 个字节
硬盘程序存储器(Flash/ROM)4KB~8KB,掉电不丢,你的程序住这儿
内存条数据存储器(RAM)128~256 字节,掉电就丢,变量住这儿
USB/网卡IO 口 + 串口P0~P3 共 32 个引脚,就是它和世界交流的通道
主板总线内部总线连接上面这些部件
✓ 一句话理解单片机

单片机 = 一个只会执行你写的机器码的 8 位 CPU + 一点点存程序的地方 + 一点点存数据的地方 + 一堆可以读写的引脚。

「底层逻辑」说到底就是这一句。后面所有内容都是它的展开。

⚠️ 一个必须先建立的观念:RAM 只有 128 字节

你在电脑上随手写 int a[10000]; 要 40KB —— 在 51 上这比整个 RAM 大 300 倍。
所以单片机编程要精打细算:能用一个字节就不用两个;能用 unsigned char 就不用 int;大数组标 code 放 Flash。

15.2 芯片长什么样:40 个引脚怎么分工

经典 51(如 AT89C51 / STC89C52)是 40 脚 DIP 封装。按功能分成五类,记住分类,不要死记 40 个:

类别数量引脚作用
电源2VCC(40)、GND(20)供电,通常 5V
时钟2XTAL1、XTAL2接晶振,给 CPU 提供心跳
复位1RST(9)高电平复位
IO 口32P0.0~P0.7、P1、P2、P3你和外设打交道的全部通道
控制3EA、ALE、PSEN存储器访问相关,多数情况按标准接法即可

四个 IO 口各自有分工(P3 带串口/中断等复用功能),Day 17 专门讲。

15.3 最小系统:让芯片能跑起来的最少元件

芯片本身不会跑,必须外面配三个东西:

  1. 电源:VCC 接 5V、GND 接地。这是最基本也最容易被忽略的 —— 「灯不亮」的排查第一步永远是量电源。
  2. 晶振 + 两个电容:接在 XTAL1/XTAL2 上,给 CPU 提供时钟。没有时钟,CPU 一步都不走。
  3. 复位电路:RST 脚接一个 10µF 电容到 VCC 和一个 10kΩ 电阻到 GND。上电瞬间电容充电,RST 短暂保持高电平完成复位;再并一个按钮可以用来手动复位。
💡 为什么 EA 脚要接 VCC

EA 是「使用内部还是外部程序存储器」的选择脚。接高电平 = 用片内 Flash(我们要的),接低电平 = 从外部扩展的存储器取指令。开发板上通常直接接 VCC 或经电阻上拉。

15.4 时钟:CPU 的心跳

晶振就是 CPU 的节拍器。三个时间单位的关系(务必记牢):

时钟周期 = 1 / 晶振频率            ← 最小的时间单位
机器周期 = 12 × 时钟周期           ← 标准 51 的"一步"
指令周期 = 1 ~ 4 个机器周期        ← 不同指令耗时不同
晶振时钟周期机器周期特点
12MHz1/12 µs ≈ 0.083µs正好 1µs定时计算好算,推荐入门用
11.0592MHz≈ 0.09µs≈ 1.085µs串口波特率能做到误差为 0,要用串口就选它
✓ 记住这两个数字,第三周全靠它们

12MHz → 1 个机器周期 = 1µs。这个「1µs」是后面算延时、算定时器初值的地基。

为什么 12MHz 要除以 12?因为标准 51 内核把 12 个时钟周期当成一个「机器周期」,也就是它的最小工作节拍。

15.5 上电之后发生了什么

很多人以为「烧进去的程序会自动从某处开始跑」,其实有个固定流程:

  1. 上电,电源稳定。复位电路让 CPU 保持复位状态一小会儿(保证电源稳了再开始)。
  2. 复位结束,CPU 的程序计数器(PC)被置为 0x0000,从那里取第一条指令。
  3. 而 0x0000 处放的是编译器安排的「启动代码」:初始化栈指针、把全局变量搬到 RAM 并清零,然后跳到你的 main 函数。
  4. 你的 main 开始执行 —— Init() 配置各个寄存器,然后进入 while(1) 主循环。
💡 这段流程解释了几个现象

① 全局变量会自动清零(局部变量不会)—— 因为启动代码专门做了这件事。
② 为什么 main 最后要 while(1) —— 因为启动代码只调用它一次,它要是返回了,CPU 就往下跑到未知区域了(叫「程序跑飞」)。
③ 为什么先配寄存器再进循环 —— 初始化只需做一次。

🧠 今天的自测

① 用一句话说清「单片机是什么」。
② 最小系统的三个必需部分是什么?各起什么作用?
③ 12MHz 晶振下,一个机器周期是多少微秒?11.0592MHz 呢?为什么会有这个差别?
④ 为什么全局变量上电后是 0,而局部变量是垃圾值?
⑤ 为什么 main 函数里必须有一个 while(1)?

📖 想深入看理论

打开你那份《51单片机入门前置知识》:第 1 章(单片机到底是什么)、第 5 章(内核架构与 40 引脚逐个讲解)、5.5 节(时钟与复位电路)。那里的讲解比这里细得多,还配了引脚图。

16Day 16 · 点亮第一颗 LED

DAY 16 / 24

今天你会走完完整的一圈:画电路 → 写代码 → 编译 → 下载 → 灯亮。这一圈走通,后面全是它的变体。

16.1 电路:LED 怎么接

推荐接法(灌电流,51 的标准做法):

   +5V
    │
   [LED]         ← 长脚(正极)朝上
    │
   [电阻] 330Ω   ← 限流电阻,必须要有
    │
   P1.0  ──────── 单片机引脚

工作原理:引脚输出 低电平(0)时,电流从 +5V 经 LED、电阻流入引脚 → 灯亮。
引脚输出高电平(1)时,引脚和 +5V 都是 5V,没有电压差 → 没有电流 → 灯灭。

✓ 所以:低电平点亮,高电平熄灭

这就是你以后写代码时的第一直觉:想点亮某个灯,就把对应位清 0。
也解释了 Day 11 那个 P1 = 0xFE(1111 1110,只有 P1.0 是 0)为什么是「P1.0 的灯亮」。

16.2 为什么必须用灌电流接法

因为 51 的 IO 口两个方向的能力差得非常远:

方向叫什么能力能不能点 LED
引脚输出低,电流流入引脚灌电流(sink)约 10~20mA能(LED 只需 3~10mA)
引脚输出高,电流流出引脚拉电流(source)只有几十~几百 µA不能(差了几十倍)

原因在 Day 17 讲:P1/P2/P3 内部是「弱上拉 + 强下拉」,往上拉的力量很弱,往下拉的力量很强。

16.3 算一下限流电阻

LED 不能直接接 5V,必须串电阻,否则电流过大烧灯(甚至烧口)。公式:

R = (VCC − V_LED) / I

VCC  = 5V        (电源电压)
V_LED = 2V       (红色 LED 的正向压降,红/黄约 1.8~2.2V,蓝/白约 3.0~3.4V)
I    = 10mA = 0.01A   (目标电流,LED 正常 3~10mA)

R = (5 − 2) / 0.01 = 300Ω   →  取标准值 330Ω
想要电流算出的电阻常用标准值亮度
5mA600Ω680Ω偏暗,省电
10mA300Ω330Ω正常,推荐
15mA200Ω220Ω偏亮,接近上限
⚠️ 电阻选小了会怎样

电流超过 20mA,一两个灯也许还亮着,但芯片在发热、寿命在缩短;如果 8 个灯同时点亮,总电流超过端口上限(约 50~70mA),可能直接烧掉这个口。

宁可偏大一点(灯暗一点没关系),不要偏小。

16.4 代码:让 P1.0 的灯亮

/*==============================================================
  文件名 : main.c
  功能   : 点亮接在 P1.0 上的 LED
  硬件   : STC89C52RC @ 12MHz,LED 接 P1.0,低电平点亮
==============================================================*/
#include <reg51.h>          /* 芯片寄存器定义,必有 */

void main(void)
{
    P1 = 0xFE;              /* 1111 1110 → P1.0 输出低电平 → 灯亮 */

    while (1) {
        /* 空循环,让程序停在这里 */
    }
}

逐句解释:

这一句意思
#include <reg51.h>把芯片的寄存器定义拿进来。有了它,你才能写 P1 这个名字(否则编译器不认识)
void main(void)主函数。单片机程序从 main 开始执行 —— 和电脑上一样
P1 = 0xFE;核心就这一句。把 8 位数据 1111 1110 写进 P1 寄存器 → P1.0 变低电平 → 灯亮
while (1) { }死循环。程序永远停在这里,不再往下跑(单片机的 main 不能返回)

16.5 从代码到硬件:这一句到底做了什么

① 你写 P1 = 0xFE;
② 编译器知道 P1 是地址 0x90,把它翻译成一条「把 0xFE 写到地址 0x90」的机器指令
③ 链接器把这条指令放进 Flash,并安排好地址
④ 下载:STC-ISP 把这串机器码烧进芯片的 Flash
⑤ CPU 执行到这条指令:把 0xFE 送进 P1 寄存器
⑥ 硬件动作:P1 寄存器第 0 位的输出管导通,把 P1.0 引脚拉到 0V
⑦ 电流流动:+5V → LED → 电阻 → P1.0(0V),电流约 9mA 通过 LED
⑧ 灯亮 💡

这就是「底层逻辑」。你以后每写一行操作寄存器的代码,脑子里都应该能放一遍这条链。

16.6 Keil 建工程 + 下载(简版流程)

  1. 新建工程:Project → New µVision Project,选一个空文件夹,工程名随意。
  2. 选芯片型号:搜索你的型号(如 AT89C52 或 STC89C52)。没有 STC 的话选 AT89C52 也能用,它们寄存器兼容。
  3. 新建源文件:File → New,把上面的代码粘进去,存成 main.c(后缀必须是 .c)。
  4. 把文件加入工程:左侧 Source Group 1 右键 → Add Existing Files,选 main.c。这一步最容易漏,漏了会报「没有可编译的文件」。
  5. 设置晶振频率:Options for Target → Target 页,Xtal 填 12.0(要和板子上实际的晶振一致)。
  6. 勾选生成 HEX:Options for Target → Output 页,勾上 Create HEX File。不勾就没有 HEX,下载工具会找不到文件。
  7. 编译:按 F7(或点 Build)。下面窗口出现 0 Error(s) 才算成功。
  8. 下载:打开 STC-ISP → 选型号和串口号 → 打开 .hex 文件 → 点「下载/编程」→ 给板子断电再上电(STC 需要冷启动)→ 看到「操作成功」。
✓ 灯亮了?恭喜,你已经跑通了整条链路

接下来做三个实验,每个都比看十页教程有用:

① 把 0xFE 改成 0xFD(1111 1101)—— 灯跑到了 P1.1。
② 改成 0x00 —— 8 个灯全亮。
③ 改成 0x7F —— 最高位那盏亮。

能预测出结果再烧录,说明你真的懂 P1 和二进制的关系了。

16.7 灯不亮怎么办(按这个顺序查)

顺序查什么怎么查
1电源万用表量芯片 VCC(40) 和 GND(20) 之间是不是 5V
2程序有没有跑起来量 ALE 脚(30):正常运行时应有约 1/6 晶振频率的方波(12MHz → 约 500kHz)。没波形说明芯片没跑
3晶体和复位晶振是否焊好、RST 是否为低电平(复位时是高)
4HEX 有没有更新看 HEX 文件时间;确认 Keil 里勾了 Create HEX File 且编译 0 错误
5LED 方向长脚(正极)朝 +5V。接反了不亮,但一般不会烧
6接线位置是不是真的接在 P1.0 上?有没有插错排孔?
7代码里的端口代码写的是 P1,线接的却是 P2?
💡 这个顺序本身就是知识点

从「最底层、最不可能错」往「最上层、最容易错」查:先电源、再时钟、再复位、再程序、最后才是接线和代码。

新手常犯的错是从代码开始查 —— 把程序改了十遍,最后发现是排线没插。

🧠 今天的自测

① 为什么 LED 用「低电平点亮」的接法?
② P1 = 0xFD; 是哪个灯亮?把 0xFD 写成二进制再回答。
③ 电源 5V、LED 压降 2V,想要 8mA 电流,电阻该取多少?
④ 灯不亮时,第一步该量什么?第二步呢?
⑤ 完整说出 P1 = 0xFE; 这句话从代码到灯亮的 8 个环节。

📖 想深入看理论

《51单片机入门前置知识》:2.5 节(LED 与限流电阻,含交互式计算器)、6.3 节(驱动能力)、12.2 节(Keil 建工程八步走,比上面细)、12.3 节(STC-ISP 下载五步走)。

17Day 17 · IO 口与寄存器

DAY 17 / 24

昨天你让一个灯亮了。今天要搞清楚:为什么引脚能输出高低电平?为什么它有方向?为什么读之前要先写 1?

17.1 四个口的分工

端口引脚内部结构典型用途
P0P0.0~P0.7开漏,没有内部上拉需要外接上拉电阻才能输出高电平;常用于接外部存储器/驱动芯片
P1P1.0~P1.7准双向,内部弱上拉最"干净"的口,专供用户使用 —— 接 LED、按键首选
P2P2.0~P2.7准双向,内部弱上拉通用 IO;扩展外部存储器时当高 8 位地址
P3P3.0~P3.7准双向,内部弱上拉每个脚都有第二功能:串口、外部中断、定时器输入等
💡 P3 的第二功能(表格背下来,后面天天用)
引脚第二功能用途
P3.0RXD串口接收
P3.1TXD串口发送
P3.2INT0外部中断 0
P3.3INT1外部中断 1
P3.4T0定时器 0 外部计数输入
P3.5T1定时器 1 外部计数输入
P3.6WR外部存储器写
P3.7RD外部存储器读

所以:你要用串口,就不能把 P3.0/P3.1 拿去做别的用。这是新手常见的冲突。

17.2 为什么叫「准双向口」

每一根 P1/P2/P3 的引脚内部大致是这样:

        内部 +5V(很弱的上拉)
           │
         [弱上拉管]        ← 拉高能力弱(几十~几百 µA)
           │
引脚 ──────┼──────→ 读到 CPU
           │
         [强下拉管]        ← 拉低能力强(10~20mA)
           │
          GND
  • 写 0:强下拉管导通 → 引脚被强力拉到 0V → 灌电流能力强(能点灯)
  • 写 1:弱上拉管导通 → 引脚被微弱地拉向 5V → 拉电流很弱(点不亮灯)

「准双向」的意思是:同一根引脚既能输出也能输入,不需要像别的单片机那样配置方向寄存器。但代价是下面这条铁律 ——

🚨 读引脚之前,必须先写 1!

为什么?假设你要读 P1.0 上接的按键。

如果 P1.0 之前被写成了 0,那么强下拉管是导通的,引脚被强行拉到 0V。这时候不管你外面接的按键是按下还是松开,你读到的永远是 0 —— 因为芯片自己在往下拽。

/* 读按键的标准写法 */
P1 = 0xFF;                  /* ① 先写 1,松开所有下拉管 */
if ((P1 & 0x01) == 0) {     /* ② 再读,才能读到外部真实的电平 */
    /* 按键按下 */
}

不过好消息是:上电后所有 IO 口默认就是 0xFF(全 1),所以只要你不曾把这一位写过 0,直接读也是对的。但养成「读之前先写 1」的习惯,能避免很多莫名其妙的 bug。

17.3 P0 的特殊性:开漏 + 需要上拉

P0 和另外三个口不一样,它内部没有弱上拉,是真正的「开漏」结构:

写 0 → 引脚被拉到 0V          ✓
写 1 → 引脚悬空(高阻态)      ← 不是 5V!

所以 P0 想输出高电平,必须外接一个上拉电阻(通常 10kΩ 排阻到 VCC):

   +5V
    │
   [10kΩ]  ← 上拉电阻,P0 必须要有
    │
   P0.0 ──── 外设
⚠️ 结论:接 LED 就用 P1

P1 有内部弱上拉,写 1 就能靠内部拉高(虽然点不亮 LED,但做逻辑电平够了),接线简单。
如果你把 LED 接在 P0 上又没加上拉电阻,会出现「写 1 时灯微亮或乱亮」的怪现象。

17.4 寄存器名字是怎么来的:sfr 与 sbit

Day 9 讲过:P1 = 0xFE; 本质是 *(volatile unsigned char *)0x90 = 0xFE;。那 P1 这个名字是哪里来的?

/* reg51.h 里大致就是这样定义的 */
sfr  P1 = 0x90;          /* 给地址 0x90 起名叫 P1(8 位整体) */
sbit P1_0 = P1^0;        /* 给 P1 的第 0 位起名叫 P1_0(单独一位) */
关键字作用用法
sfr定义一个8 位寄存器的名字P1 = 0xFE;(整体写)
sbit定义某一位的名字LED = 0;(只操作一位)
bit定义一个位变量(放在位寻址区)bit flag; flag = 1;
/* 用 sbit 写代码,可读性会好很多 */
sbit LED1 = P1^0;
sbit KEY1 = P3^2;

LED1 = 0;                /* 点亮 —— 等价于 P1 &= ~(1<<0); */
LED1 = 1;                /* 熄灭 —— 等价于 P1 |= (1<<0); */

if (KEY1 == 0) { }       /* 读按键 —— 不用写位运算 */
✓ 两种写法什么时候用哪个

操作单个引脚 → 用 sbit,代码干净:LED1 = 0;
批量操作、移位、查表 → 用位运算操作整个 P1:P1 &= ~(1<<i);

两者都能用,看哪个表达得更清楚。但位运算必须会,因为很多场合(流水灯、扫描)只能用整体操作。

17.5 电流限制:什么时候必须加驱动

对象限制
单个引脚灌电流 10~20mA;拉电流只有几十~几百 µA
整个端口(8 个脚同时)合计建议不超过 50~70mA(查手册的总 IOL 限制)
整个芯片还有总功耗限制,所有口加起来也要看手册

所以下面这些必须加驱动电路:

负载需要电流怎么驱动
LED(少量)3~10mA直接接,串限流电阻
8 个 LED 全亮80mA已经超端口上限 → 降低每路电流(加大电阻)或用驱动芯片
蜂鸣器20~30mA用三极管(如 S8050)
继电器线圈几十 mA三极管 + 续流二极管(感性负载必须加)
直流电机几百 mA ~ 几 A电机驱动模块(如 L298N)
多个负载—ULN2003 等达林顿管阵列(一片驱动 7 路)
⚠️ 感性负载必须加续流二极管

继电器、电机这类「线圈」负载,在关断的瞬间会产生反向高压,足以击穿三极管或 IO 口。必须在负载两端反接一个二极管(如 1N4148/1N4007)给它一条泄放通路。

🧠 今天的自测

① 四个 IO 口里,哪个最"干净"适合接 LED?哪个必须加上拉电阻?
② 为什么读引脚之前要先写 1?
③ sfr 和 sbit 有什么区别?
④ P3.0 和 P3.1 的复用功能是什么?这对你的引脚分配有什么约束?
⑤ 一个 LED 要 8mA,8 个同时点亮要多少?直接接在 P1 上行不行?

📖 想深入看理论

《51单片机入门前置知识》:6.1~6.5 节(四个口分工、准双向口内部结构、驱动能力、完整实战)。那里有内部结构示意图,能把「弱上拉 + 强下拉」看得更清楚。

18Day 18 · 流水灯与花样灯

DAY 18 / 24

今天把 Day 5(循环)、Day 6(数组)、Day 11(位运算)全部用起来。这是你第一次写「看起来像那么回事」的单片机程序。

18.1 延时函数:先把时间解决掉

流水灯要「一个一个依次亮」,中间必须有停顿。单片机没有 sleep,靠的是空转(执行一堆没用的指令来消耗时间):

/* 粗略毫秒延时(12MHz 晶振)—— 软件延时 */
void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);      /* 空循环,什么都不做 */
}
⚠️ 别纠结这个 120 是怎么来的

它是「凑」出来的:靠实验调出来的一个数,让内层循环大约耗时 1ms。软件延时天生不准 —— 换晶振、改优化等级、改编译器版本,时间都会变。

今天先用它把现象做出来,Day 19 会讲清楚它的误差从哪来,Day 22 会用定时器彻底取代它。

18.2 流水灯:让那个 0 动起来

核心思路:让「唯一的那个 0」在位里从左往右移动。

i=0:  ~(1<<0) = 1111 1110   → 第 0 个灯亮
i=1:  ~(1<<1) = 1111 1101   → 第 1 个灯亮
i=2:  ~(1<<2) = 1111 1011   → 第 2 个灯亮
...
i=7:  ~(1<<7) = 0111 1111   → 第 7 个灯亮
/*========== 流水灯(单向循环) ==========*/
#include <reg51.h>

void DelayMs(unsigned int ms);

void main(void)
{
    unsigned char i;

    while (1) {
        for (i = 0; i < 8; i++) {
            P1 = ~(1 << i);      /* 只有第 i 位是 0,其余是 1 */
            DelayMs(200);
        }
    }
}
💡 为什么用 P1 = ~(1<<i) 而不是 P1 &= ~(1<<i)?

因为流水灯要求「同时只有一个灯亮」。P1 = ... 是整体覆盖,会把上一个灯自动关掉。
而 &= 是「只清这一位,其它位不动」,用在这里会变成「灯一个个点亮,最后全亮」—— 那是另一种效果(跑马灯累积),不是流水灯。

「整体赋值」还是「位操作」,取决于你要的效果。这是位运算实战里最容易混的一点。

18.3 左右来回(Knight Rider 效果)

让灯走到头再走回来。关键是记录一个「方向」:

#include <reg51.h>

void DelayMs(unsigned int ms);

void main(void)
{
    unsigned char i;
    char dir = 1;            /* 1 = 往右,-1 = 往左。注意用 char 才有负数 */

    i = 0;
    while (1) {
        P1 = ~(1 << i);
        DelayMs(150);

        /* 到头了就把方向反过来 */
        if (i == 7) dir = -1;
        if (i == 0) dir =  1;

        i += dir;
    }
}
🚨 为什么 dir 要用 char 不能用 unsigned char

因为要存 -1。unsigned char 的范围是 0~255,没有负数,写 dir = -1 会变成 255,i += dir 立刻跑飞。

凡是可能为负的变量,必须用有符号类型(char / int)。这是 Day 2 讲的类型范围问题第一次真正咬人。

18.4 花样灯:查表法

如果要显示的花样没有规律(比如「两边向中间合拢」),一个个算反而麻烦。直接把每个状态写进数组,然后依次取出来:

#include <reg51.h>

void DelayMs(unsigned int ms);

/* 把每个状态写死在表里,放在 Flash 里省 RAM */
unsigned char code pattern[] = {
    0xFE,   /* 1111 1110  最右一个 */
    0xFC,   /* 1111 1100  右两个 */
    0xF8,   /* 1111 1000  右三个 */
    0xF0,   /* 1111 0000  右四个 */
    0xE0,   /* 1110 0000  右五个 */
    0xC0,   /* 1100 0000  右六个 */
    0x80,   /* 1000 0000  右七个 */
    0x00,   /* 0000 0000  全亮 */
    0x80, 0xC0, 0xE0, 0xF0, 0xF8, 0xFC,   /* 再收回来 */
    0x7F,   /* 0111 1111  最左一个 */
    0x3F, 0x1F, 0x0F, 0x07, 0x03, 0x01   /* 从左边收拢 */
};

void main(void)
{
    unsigned char i;
    unsigned char n = sizeof(pattern);   /* 表里有几个状态 */

    while (1) {
        for (i = 0; i < n; i++) {
            P1 = pattern[i];             /* 直接取出来用 */
            DelayMs(120);
        }
    }
}
✓ 查表法是嵌入式的核心技巧之一

它的思想是:「算起来麻烦、但结果是固定的」东西,不如提前算好存起来。

后面你会反复见到它:数码管段码表(Day 21)、正弦波采样表、七段码译码、LCD 字库…… 全是同一招。

而且 code 关键字让它存在 Flash 里,不占宝贵的 RAM。

18.5 试着改一改(这一步比读代码有用)

改什么看什么现象理解什么
P1 = ~(1<<i) 改成 P1 &= ~(1<<i)灯一个个亮起来且不灭整体赋值 vs 位操作的区别
DelayMs(200) 改成 DelayMs(50)变快延时参数控制速度
把 i 改成从 7 递减到 0反方向流水移位的方向感
每条 P1 = ... 后面加一句 P2 = P1;两个口同步显示寄存器可以互相赋值
把 0xFE 换成 0x55(0101 0101)间隔亮十六进制与引脚的一一对应
🧠 今天的自测

① 流水灯为什么用 P1 = ~(1<<i) 而不是 P1 &= ~(1<<i)?
② 左右来回的程序里,dir 为什么不能用 unsigned char?
③ 查表法适合解决什么类型的问题?关键字 code 起什么作用?
④ 不看答案,写出一个「8 个灯同时亮灭闪烁」的程序。

19Day 19 · 延时:时间从哪来

DAY 19 / 24

昨天你用 DelayMs(200) 让灯慢慢流。今天要搞清楚:这个「200 毫秒」到底准不准?为什么不准?什么时候能用、什么时候不能用?

19.1 软件延时的本质:数时间

单片机的 CPU 一步一步走,每一步花固定的时间。所以「让 CPU 空转 N 步」就等于「等一段时间」。

void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);     /* 空转 */
}

计算逻辑大致是:

一条指令 = 1~4 个机器周期
12MHz 下 1 个机器周期 = 1µs
内层循环 j 跑 120 次 ≈ 1000µs = 1ms
外层再跑 ms 次 → 约 ms 毫秒
✓ 记住这三个数字(第三周的地基)

12MHz 晶振 → 1 个机器周期 = 1µs。
指令周期 = 1~4 个机器周期,也就是 1~4µs。
11.0592MHz 时机器周期 ≈ 1.0851µs。

19.2 为什么它不准(四个误差来源)

来源说明影响
指令周期不等长不同的指令花 1~4 个机器周期,循环里还夹杂判断、跳转只能靠实验凑出那个「120」
编译器优化你把优化等级从 0 改成 8,空循环可能被整体删掉或重排同一个程序换个设置,时间就变了
晶振误差普通晶振本身有 ±20~50ppm 的偏差,还有温度漂移小,但累积久了可观
被中断打断延时期间如果来了中断,CPU 先去处理中断(Day 22)延时会变长,而且不定
⚠️ 最坑的是编译器优化

你写 for (j = 0; j < 120; j++); —— 这个循环什么都不做,一个聪明的编译器会认为「它没有副作用」直接删掉。结果延时瞬间变成 0。

Keil 在低优化等级下不会删,所以很多人「换个工程模板就发现延时不灵了」。这也是为什么稍微正规一点的代码都不用软件延时。

19.3 什么时候能用、什么时候不能用

场景软件延时能用吗为什么
点灯、闪烁能人对灯的快慢不敏感,差 10% 看不出来
流水灯能同上
按键消抖(Day 20)能只要「等十几毫秒」即可,不要求精确
数码管动态扫描(Day 21)勉强能会占满 CPU,而且扫描间隔不匀会闪
串口收发(Day 23)不能波特率要求精确到 1% 以内,差一点就是乱码
精确计时 / 秒表不能误差会累积
同时做几件事不能延时期间 CPU 被完全占住,别的什么都干不了
🧠 核心矛盾

软件延时 = 让 CPU 干等着。等待期间它不能干别的,而且等多久还说不准。

正确做法是:让硬件(定时器)去数时间,数到了通知 CPU 一声,CPU 平时该干嘛干嘛。这就是 Day 22 的定时器 + 中断。

19.4 让延时稍微靠谱一点的两招

/* 招一:用 volatile 阻止编译器乱优化(代价是慢一点) */
void DelayMs(unsigned int ms)
{
    volatile unsigned int i, j;        /* volatile 保证循环真的被执行 */
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);
}

/* 招二:用定时器写"精确延时"(后面 Day 22 的写法,这里先看一眼) */
void DelayMs_Exact(unsigned int ms)
{
    unsigned int i;
    for (i = 0; i < ms; i++) {
        TMOD &= 0xF0;  TMOD |= 0x01;   /* 定时器0,方式1 */
        TH0 = 0xFC;  TL0 = 0x18;       /* 1ms 初值 @12MHz */
        TR0 = 1;                       /* 启动 */
        while (!TF0);                  /* 等溢出标志 */
        TR0 = 0;  TF0 = 0;             /* 停,清标志 */
    }
}

招二虽然精确,但仍然是「等」——CPU 还是被困在 while 里。最好的用法是 Day 22 的中断方式。

19.5 一个能立刻做的实验:测一测你的延时准不准

让一个 LED 每秒翻转一次(500ms 亮、500ms 灭),然后用手机秒表对着一分钟数一数:

#include <reg51.h>

void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);
}

void main(void)
{
    while (1) {
        P1 &= ~0x01;        /* 翻转 P1.0(Day 11 的 ^= 写法也可以) */
        DelayMs(500);
    }
}

如果你数出来一分钟闪了 58 次而不是 60 次,那误差就是 3% —— 这就是软件延时的真实水平。

🧠 今天的自测

① 12MHz 晶振下,一个机器周期是多少微秒?11.0592MHz 呢?
② 软件延时不准的四个来源是什么?哪个最坑?
③ 为什么串口通信不能用软件延时做时序?
④ 「让 CPU 干等」和「让硬件数时间」的区别是什么?

📖 想深入看理论

《51单片机入门前置知识》:8.1 节(时钟:一切时序的源头)、8.5 节(定时精度:误差从哪来)。

20Day 20 · 按键输入与消抖

DAY 20 / 24

到今天为止,单片机一直在「自己演给自己看」。今天它第一次接受你的输入。

20.1 按键怎么接

        +5V(单片机内部弱上拉)
           │
   P3.2 ───┼────── 平时读到 1
           │
         [按键]
           │
          GND      按下时引脚接地 → 读到 0

因为 P1/P2/P3 内部有弱上拉(Day 17),所以按键可以直接一端接引脚、一端接 GND,不需要外接上拉电阻。

状态引脚电平读到的值
没按被内部弱上拉到 5V1
按下被直接接到 GND0
🚨 别忘了 Day 17 那条铁律:读之前先写 1
P3 = 0xFF;                  /* 先松开所有下拉管 */
if ((P3 & 0x04) == 0) {     /* 再读 P3.2 */
    /* 按键按下 */
}

上电后 IO 口默认全是 1,所以只要你不曾把这一位写过 0,直接读也对。但写了 0 之后再读,就会永远读到 0 —— 这种现象的表现是「按键好像一直按着」。

20.2 抖动:一个必须处理的问题

按键内部是机械触点。你按下的一瞬间,金属片会弹跳好几下才稳定接触:

理想情况:  ────────┐            ┌────────
                   └────────────┘
                   按下          松开

真实情况:  ────────┐┌┐┌─┐┌──────┐┌┐──────
                   └┘└┘ └┘      └┘└
                   ← 5~20ms 抖动 →

结果就是:你按一次,程序可能判断成按了七八次。现象是「按一下灯闪了好几下」或「按一下计数加了 5」。

💡 消抖的本质

抖动只持续 5~20ms,而人的手不可能在 20ms 内按两次。所以只要「过 10~20ms 再确认一次」,抖动就被过滤掉了。

20.3 消抖方案一:延时确认法(最简单)

#include <reg51.h>

sbit KEY = P3^2;            /* 用 sbit 给引脚起名字,代码更清楚 */
sbit LED = P1^0;

void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);
}

void main(void)
{
    while (1) {
        if (KEY == 0) {             /* 第一次检测到按下 */
            DelayMs(15);            /* 等 15ms 让抖动过去 */
            if (KEY == 0) {         /* 再确认一次,还是 0 才算真按下 */
                LED = ~LED;         /* 翻转 LED */
                while (KEY == 0);   /* 等松手,否则会连续翻转 */
            }
        }
    }
}

三个关键点:

代码作用删掉会怎样
DelayMs(15)躲开抖动期按一次可能触发多次
if (KEY == 0) 再确认确认是真的按下抖动也会被当成按下
while (KEY == 0);等你松手按住不放会一直翻转,LED 高速闪

20.4 消抖方案二:状态机(不阻塞 CPU)

方案一有个缺点:DelayMs 和 while 期间 CPU 被卡住,别的事都干不了。如果同时还要扫描数码管,就会闪。

正规写法是用「状态记录 + 每次循环检查一次」:

/* 放在主循环里,每轮调用一次,绝不阻塞 */
unsigned char KeyScan(void)
{
    static unsigned char last = 1;      /* 上一次的电平(static:值会保留) */
    static unsigned int  count = 0;     /* 稳定计数 */
    unsigned char now = KEY;

    if (now != last) {                  /* 电平变了 */
        count = 0;
        last = now;                     /* 重新开始计时 */
    } else {
        if (count < 1000) count++;
    }

    /* 连续 3 次读到 0(配合主循环节奏 ≈ 十几毫秒)→ 判定按下 */
    if (count == 3 && now == 0) {
        return 1;                       /* 返回一次"按下"事件 */
    }
    return 0;
}

这段代码用到了 Day 14 学的 static(局部变量值会保留)。它的好处是每次只花几个机器周期,主循环可以继续干别的事。

20.5 按键的三种「事件」

实际产品里的按键通常要区分这些情况:

事件判定方式典型用途
短按按下后很快松开开关灯、切换模式
长按按住超过 1 秒进入设置、恢复出厂
连击短时间内按了多次调快慢、翻页

这三种的判定都靠「记录按下时刻」+「比较时间差」。而「记录时刻」这件事必须用定时器(Day 22)—— 否则时间基准不准,长按的「1 秒」会变成 0.8 秒或 1.3 秒。

20.6 完整实战:一个按键控制一个灯

/*==============================================================
  功能:按一下 P3.2 的按键,P1.0 的 LED 翻转一次
  硬件:LED 接 P1.0(低电平点亮),按键接 P3.2 到 GND
==============================================================*/
#include <reg51.h>

sbit KEY = P3^2;
sbit LED = P1^0;

void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);
}

void main(void)
{
    LED = 1;                    /* 初始熄灭(低电平点亮,所以 1 是灭) */

    while (1) {
        if (KEY == 0) {
            DelayMs(15);            /* 消抖 */
            if (KEY == 0) {
                LED = !LED;         /* 翻转 */
                while (KEY == 0);   /* 等松手 */
            }
        }
    }
}
✓ 试试这几个改动

① 把 DelayMs(15) 改成 DelayMs(1) → 观察是否开始「按一次跳好几次」(抖动漏出来了)。
② 删掉 while (KEY == 0); → 按住不放时灯会高速闪。
③ 把 LED = !LED; 改成 LED = 0; → 变成「按下才亮,松开就灭」(这是另一种做法,不需要松手检测)。

🧠 今天的自测

① 按键不按和按下时,引脚电平分别是什么?为什么可以不加外部上拉电阻?
② 什么是抖动?持续多久?为什么延时 15ms 就能解决?
③ while (KEY == 0); 这一行删掉会出现什么现象?
④ 延时确认法最大的缺点是什么?正规写法怎么改?

📖 想深入看理论

《51单片机入门前置知识》:2.6 节(按键与"抖动")、6.4 节(完整实战:按键控制 LED 含消抖)。

21Day 21 · 数码管与动态扫描

DAY 21 / 24

LED 只能告诉你「亮/灭」,数码管能告诉你「是几」。今天学怎么让单片机显示数字。

21.1 数码管的内部:8 个 LED

一位数码管里面就是 8 个 LED(7 段笔画 a~g + 1 个小数点 dp),它们的正极或负极被接在一起:

   aaa
  f   b
   ggg
  e   c
   ddd  · dp
类型公共端点亮方式段码
共阳(共阳极)8 个 LED 的正极接在一起 → VCC段码给 0 时该段亮数字 0 = 0xC0
共阴(共阴极)8 个 LED 的负极接在一起 → GND段码给 1 时该段亮数字 0 = 0x3F

买数码管一定要看清型号是共阳还是共阴 —— 用错了段码表,显示的会是乱七八糟的图案。

21.2 段码表(共阳,直接背下来)

一个字节的 8 位依次对应 dp g f e d c b a:

dpbit7
gbit6
fbit5
ebit4
dbit3
cbit2
bbit1
abit0
unsigned char code seg_table[10] = {
/*    dp g f e d c b a    */
    0xC0,   /* 1100 0000 → 0 :a~f 亮,g 灭 */
    0xF9,   /* 1111 1001 → 1 :b、c 亮 */
    0xA4,   /* 1010 0100 → 2 */
    0xB0,   /* 1011 0000 → 3 */
    0x99,   /* 1001 1001 → 4 */
    0x92,   /* 1001 0010 → 5 */
    0x82,   /* 1000 0010 → 6 */
    0xF8,   /* 1111 1000 → 7 */
    0x80,   /* 1000 0000 → 8 :全亮 */
    0x90    /* 1001 0000 → 9 */
};
💡 自己验证一下

数字 1 只需要右侧两段 b、c 亮,其余全灭。共阳是「0 亮」,所以 b、c 位写 0,其它写 1:
dp=1, g=1, f=1, e=1, d=1, c=0, b=0, a=1 → 1111 1001 = 0xF9 ✓

会自己推段码,比背表重要。遇到不常见的字符(比如 -、E、L)你得自己算。

共阴的段码就是共阳的按位取反:0 = ~0xC0 = 0x3F,1 = ~0xF9 = 0x06……

21.3 静态显示:简单但费引脚

/* 一位数码管接 P0,共阳,显示 5 */
P0 = seg_table[5];      /* 0x92 */

4 位数码管如果都用静态显示,需要 4 × 8 = 32 根线 —— 而 51 总共只有 32 个 IO,别的什么都别想接了。

21.4 动态扫描:用时间换引脚

🧠 核心思想:视觉暂留

人眼有视觉暂留:一个画面消失后,图像还会在视网膜上保留约 1/16 秒。

所以如果轮流点亮每一位(每位只亮 1~5ms),只要轮一圈的时间短于 20ms,看起来就是 4 位同时在显示。

代价:任何时刻其实只有一位在工作 —— 但人眼看不出来。

4 位数码管的接法(8 根段线共用 + 4 根位选线 = 12 根)

      段线 a~g,dp (8 根,4 位共用)
            │
    ┌───────┼───────┬───────┐
   位1     位2     位3     位4
    │       │       │       │
   位选1   位选2   位选3   位选4  (4 根,决定"现在轮到谁")
/* 扫描的核心:每次只让一位的位选有效,送对应的段码,然后迅速切下一位 */
void Display(unsigned char d1, unsigned char d2,
             unsigned char d3, unsigned char d4)
{
    P0 = seg_table[d1];   /* 送第 1 位的段码 */
    P2 = 0x01;            /* 只选中第 1 位 */
    DelayMs(2);

    P0 = seg_table[d2];
    P2 = 0x02;            /* 只选中第 2 位 */
    DelayMs(2);

    P0 = seg_table[d3];
    P2 = 0x04;
    DelayMs(2);

    P0 = seg_table[d4];
    P2 = 0x08;
    DelayMs(2);
}

在主循环里不停调用 Display(),4 位就同时亮了:

void main(void)
{
    while (1) {
        Display(1, 2, 3, 4);      /* 显示 "1234" */
    }
}

21.5 三个必须知道的细节

问题现象原因与解决
消影数字上能看到别的位的残影切换位选前要先「关段」:P0 = 0xFF;(共阳)再切位,否则上一位的段码会短暂显示在新位上
亮度不均某位暗一点每位点亮时间要相同;如果某位接了更多笔画(如 8 比 1 亮)也会有差异
整体偏暗比静态显示暗很多正常现象 —— 每位只亮 1/4 的时间。要亮就减小限流电阻或缩短扫描周期
闪烁能看到一位一位在跳扫描一圈超过 20ms 了。缩短每位延时,或(更好的)用定时器中断驱动扫描
🚨 消影的标准写法
void Display(unsigned char d1, unsigned char d2,
             unsigned char d3, unsigned char d4)
{
    P0 = 0xFF;            /* ① 先熄灭所有段(共阳写 0xFF 全灭) */
    P2 = 0x01;            /* ② 再选位 */
    P0 = seg_table[d1];   /* ③ 最后送段码 */
    DelayMs(2);
    /* 后三位同理 */
}

顺序很重要:先关段 → 再切位 → 再送段。顺序反了就会有残影。

21.6 把数字拆成各位(用上 Day 3 的取余)

/* 把 0~9999 的数拆成 4 位,送给数码管 */
void ShowNumber(unsigned int n)
{
    unsigned char d1, d2, d3, d4;

    if (n > 9999) n = 9999;

    d1 = n / 1000 % 10;      /* 千位 */
    d2 = n / 100  % 10;      /* 百位 */
    d3 = n / 10   % 10;      /* 十位 */
    d4 = n        % 10;      /* 个位 */

    Display(d1, d2, d3, d4);
}

这就是 Day 3 讲的「先除到那一位,再对 10 取余」第一次派上真正的用场。1000 是四位数的最高位权值,所以从它开始除。

🧠 今天的自测

① 共阳和共阴数码管的区别是什么?数字 0 的段码各是多少?
② 自己推出数字 3 的共阳段码,并说明每一位为什么是 0 或 1。
③ 动态扫描为什么看起来是同时显示的?扫描一圈必须短于多少毫秒?
④ 什么是「消影」?正确的操作顺序是什么?
⑤ 把 258 拆成 4 位数码管的显示值,各是多少?

22Day 22 · 定时器与中断

DAY 22 / 24
🎯 今天是第三周最重要的一天

学完今天,你的程序会从「一步一步干等」变成「同时做几件事」。这是单片机编程真正入门的分界线。

22.1 定时器是什么

定时器就是一个硬件计数器:它自己在那儿数数,数满了(溢出)就举起一面旗子告诉你。

它数的对象:内部机器周期(12MHz 下每个 1µs 数一次)
数到最大值(方式1是 65535)再进一位 → 溢出 → 置位 TF0 标志

所以:「数了多少个数」×「一个数的时间」= 经过的时间。这就是定时器的全部原理。

💡 为什么要设「初值」

如果每次都从 0 数到 65535,那每次固定是 65.54ms,你没法选时长。
所以先往计数器里塞一个初值,让它数剩下的那一段:

初值 = 65536 − 需要数的个数
需要数的个数 = 定时时间 / 机器周期

例:12MHz(机器周期 1µs)想定时 50ms → 需要数 50000 个 → 初值 = 65536 − 50000 = 15536 = 0x3CB0
所以 TH0 = 0x3C; TL0 = 0xB0;(高字节、低字节分开装)

22.2 常用初值速查(12MHz,方式 1)

定时时间需要数初值TH0TL0
1 ms1000645360xFC0x18
10 ms10000555360xD80xF0
20 ms20000455360xB10xE0
50 ms50000155360x3C0xB0
⚠️ 方式 1 单次最长只有 65.54ms

因为 16 位最多数 65536 个数,12MHz 下就是 65536µs ≈ 65.54ms。

想做 1 秒怎么办?「硬件定时 + 软件计数」:定时器每 50ms 溢出一次,在中断里数次数,数满 20 次就是 1 秒。

if (++tick >= 20) {      /* 20 × 50ms = 1s */
    tick = 0;
    /* 这里做每秒一次的事 */
}

22.3 两个控制寄存器:TMOD 与 TCON

不用背,理解每个位的用途,用的时候查表:

TMOD —— 设置工作方式(不可位寻址,要整体写)

位名字作用
7~4定时器 1 的设置我们一般只用定时器 0,这 4 位保持 0
3~2M1 M0工作方式:01 = 方式1(16 位定时,最常用)
1C/T0 = 定时(数内部机器周期);1 = 计数(数外部引脚脉冲)
0GATE0 = 软件启动(我们用的);1 = 由 INT 引脚配合启动
TMOD = 0x01;        /* 定时器0,方式1,定时模式,软件启动 —— 背下来这一行 */

/* 更安全的写法:只改低 4 位,不动定时器 1 的设置 */
TMOD &= 0xF0;
TMOD |= 0x01;

TCON —— 控制启停与标志(可位寻址)

位名字作用
TR0定时器 0 运行控制写 1 启动,写 0 停止
TF0定时器 0 溢出标志溢出时硬件自动置 1;进中断后硬件自动清 0,查询法要手动清
TR1 / TF1定时器 1 的对应位同上
IE0 / IE1外部中断标志Day 22 后半段讲

22.4 写法一:查询法(简单,但仍占着 CPU)

void DelayMs_Exact(unsigned int ms)
{
    unsigned int i;
    for (i = 0; i < ms; i++) {
        TMOD &= 0xF0;  TMOD |= 0x01;
        TH0 = 0xFC;  TL0 = 0x18;    /* 1ms */
        TR0 = 1;                    /* 启动 */
        while (TF0 == 0);           /* 等溢出(CPU 卡在这里) */
        TR0 = 0;                    /* 停止 */
        TF0 = 0;                    /* 手动清标志 */
    }
}

比软件延时精确得多,但 CPU 还是在等。要真正解放 CPU,必须用中断。

22.5 中断:让硬件来通知 CPU

💡 一个生活类比

你在写作业(主程序)。
查询法 = 每写一个字就抬头看一次门口快递到了没有 —— 作业写得很慢。
中断法 = 专心写作业,快递员到了按门铃,你放下笔去签收,签完回来接着写 —— 两件事都不耽误。

中断就是硬件按的那个门铃。

中断相关的寄存器

寄存器位作用
IE
中断允许
EA总开关。EA=0 时所有中断都被禁止
ET0定时器 0 中断允许
ET1 / EX0 / EX1 / ES定时器1 / 外部中断0 / 外部中断1 / 串口 的中断允许
IP
中断优先级
—默认都是低优先级,一般不用管
PT0 等写 1 提升为高优先级
—高优先级可以打断低优先级
⚠️ 两级开关都要开
ET0 = 1;        /* ① 打开"定时器0"这个分开关 */
EA  = 1;        /* ② 打开"总开关" */
/* 只开一个都不行 —— 这是"中断进不去"的头号原因 */

中断服务函数:C51 的特殊语法

void Timer0_ISR(void) interrupt 1      /* interrupt 1 = 定时器0 */
{
    TH0 = 0x3C;                        /* 重新装初值(方式1不会自动重装) */
    TL0 = 0xB0;
    /* 中断里要做的事,要尽量短 */
}
中断号中断源函数写法
0外部中断 0(INT0 / P3.2)interrupt 0
1定时器 0interrupt 1
2外部中断 1(INT1 / P3.3)interrupt 2
3定时器 1interrupt 3
4串口interrupt 4

22.6 完整例子:用定时器让 LED 精确闪烁

/*==============================================================
  功能:P1.0 的 LED 精确每 1 秒翻转一次(亮 1s,灭 1s)
        主循环完全空闲 —— CPU 不再被延时占用
  硬件:LED 接 P1.0;晶振 12MHz
==============================================================*/
#include <reg51.h>

sbit LED = P1^0;

unsigned char tick = 0;        /* 50ms 中断计数,凑 1 秒用 */

void Timer0_Init(void)
{
    TMOD &= 0xF0;
    TMOD |= 0x01;              /* 定时器0,方式1(16位定时) */
    TH0 = 0x3C;                /* 50ms 初值 @12MHz */
    TL0 = 0xB0;
    ET0 = 1;                   /* 允许定时器0中断 */
    EA  = 1;                   /* 打开总中断 */
    TR0 = 1;                   /* 启动定时器 */
}

/* 定时器0 中断服务函数:每 50ms 进来一次 */
void Timer0_ISR(void) interrupt 1
{
    TH0 = 0x3C;                /* 重新装初值 */
    TL0 = 0xB0;

    tick++;
    if (tick >= 20) {          /* 20 × 50ms = 1s */
        tick = 0;
        LED = !LED;            /* 每秒翻转一次 */
    }
}

void main(void)
{
    LED = 1;
    Timer0_Init();

    while (1) {
        /* 主循环是空的!CPU 在这里可以干任何别的事 */
    }
}
✓ 对比一下 Day 19 的软件延时
软件延时定时器 + 中断
精度差(±5% 以上)很准(只差晶振本身误差)
CPU 占用100%(一直在空转)几乎为 0
能不能同时干别的不能能
上电后 LED 时间不准常见不会

现在你可以在这个空的主循环里加按键扫描、数码管扫描 —— 它们互不干扰。这就是「同时做几件事」的秘密。

22.7 中断里必须遵守的四条纪律

纪律为什么
① 要短中断执行期间主程序停着。你在里面 DelayMs(100),主循环就被冻 100ms
② 不要用软件延时同上。需要计时就再开一个定时器
③ 和主循环共享的变量要加 volatile否则编译器会把它优化掉(Day 14 讲的玄学 bug)
④ 方式 1 必须手动重装初值方式 1 不会自动重装。忘了装 → 下次定时变成 65.54ms,时间全乱
🚨 最容易犯的错:忘了重装初值
void Timer0_ISR(void) interrupt 1
{
    /* 忘了 TH0/TL0 重装 */
    tick++;
    if (tick >= 20) { tick = 0; LED = !LED; }
}
/* 现象:第一次 1 秒正常,之后变成 1.31 秒一次(65.54ms × 20) */

因为第一次是从 0x3CB0 数到溢出(50ms),之后每次都是从 0 数到溢出(65.54ms)。时间悄悄变成了原来的 1.31 倍。

想彻底避免,可以用方式 2(8 位自动重装),但只有 8 位,最长 256µs。所以方式 1 手动重装还是主流写法。

22.8 外部中断(顺带掌握)

除了定时器,51 还有两个外部中断,接在 P3.2(INT0)和 P3.3(INT1)上。适合处理「按键立刻响应」这类需求:

void ExtInt0_Init(void)
{
    IT0 = 1;        /* 1 = 下降沿触发(按下瞬间触发一次);0 = 低电平触发 */
    EX0 = 1;        /* 允许外部中断0 */
    EA  = 1;        /* 总中断 */
}

void ExtInt0_ISR(void) interrupt 0
{
    /* 按键一按下就立刻执行这里,不用等主循环轮询 */
    LED = !LED;
}
💡 下降沿 vs 低电平触发

IT0 = 1(下降沿):只在电平从 1 变 0 的那一瞬间触发一次。适合按键。
IT0 = 0(低电平):只要引脚是低电平就一直触发。适合外部器件持续发出的信号。

注意:外部中断不能替代消抖 —— 抖动的每个下降沿都会触发一次。中断里还是要做消抖判断。

🧠 今天的自测

① 定时器的「初值」是干什么的?12MHz 下定时 20ms,初值是多少?
② TMOD = 0x01; 这一行配置了什么?
③ 中断进不去,最可能是什么原因?
④ 中断服务函数末尾(或开头)为什么必须重装 TH0/TL0?忘了会怎样?
⑤ 为什么中断里不能写 DelayMs(100)?
⑥ 用定时器中断重写 Day 19 那个「每秒闪一次」的程序,主循环里还能加什么?

📖 想深入看理论

《51单片机入门前置知识》:第 8 章(时钟与定时器,含初值计算器)、第 9 章(中断系统:中断源、中断向量表、IE/IP、响应过程、外部中断两种触发方式、常见故障速查)。

23Day 23 · 串口通信

DAY 23 / 24

今天让单片机学会「说话」—— 把数据发给电脑,你就能在串口助手上看到它的想法。这是嵌入式调试最重要的手段。

23.1 为什么要串行

想让单片机把 8 位数据传给电脑,可以并行(8 根线同时传),也可以串行(一根线一位一位传)。

并行串行
线数8 根(数据)+ 控制线2 根(TX、RX)+ 地线
速度快慢一些
距离短(几厘米就要考虑干扰)长(几米到几十米)
51 上的引脚要占用整个 P0 或 P2P3.0 和 P3.1 两根

51 的串口用 P3.0(RXD,接收)和 P3.1(TXD,发送)—— 这就是 Day 17 讲的 P3 第二功能。

23.2 一帧数据长什么样(8N1 是最常用的格式)

空闲(高电平)
  │
  └─┐ 起始位   ┌─── 8 个数据位(低位先发)───┐  ┌ 停止位
    └─0───────┤ b0 b1 b2 b3 b4 b5 b6 b7 ├──1──┘
               └──────────────────────────┘

"8N1" 的意思:8 个数据位、N = 无校验位、1 个停止位
部分作用
起始位一个 0,告诉对方「我要开始发了」
数据位8 个位,低位先发
停止位一个 1,表示这一帧结束

发一个字节实际上要发 10 位(1 起始 + 8 数据 + 1 停止)。所以 9600bps 的波特率,每秒实际能传 960 字节。

23.3 波特率:为什么必须用 11.0592MHz 晶振

波特率 = 每秒传多少个位。收发两端必须一致,否则收到的全是乱码。

51 的串口波特率由定时器 1 的溢出率决定:

波特率 = (2^SMOD / 32) × f_osc / (12 × (256 − TH1))

反推 TH1:

晶振目标波特率算出的 TH1实际波特率误差
11.0592MHz96000xFD (253)9600.000% ✓
12MHz96000xFD (253)10416.678.51% ✗
✓ 结论(这是 51 最著名的一个常识)

要用串口,就换 11.0592MHz 晶振。因为 11.0592 = 9600 × 1152,能被各种标准波特率整除,算出来误差为 0。

而 12MHz 虽然定时器算起来漂亮(1µs 一个机器周期),但 9600bps 误差 8.5%,串口助手上会是乱码。

串口允许的误差上限大约 2~3%,8.5% 远远超标。

23.4 串口控制寄存器 SCON

位名字作用
7~6SM0 SM1工作方式:01 = 方式1(8 位 UART,可变波特率)—— 我们只用这个
5SM2多机通信用,一般 0
4REN1 = 允许接收,0 = 禁止接收
3TB8方式 1 不用
2RB8方式 1 不用
1TI发送完成标志。发完硬件置 1,必须软件清 0
0RI接收完成标志。收到硬件置 1,必须软件清 0
SCON = 0x50;      /* 0101 0000 = 方式1 + 允许接收 —— 背下来这一行 */

23.5 完整的串口初始化

/*==============================================================
  串口初始化:9600bps,8N1,允许接收,中断方式
  晶振:11.0592MHz
==============================================================*/
void Uart_Init(void)
{
    SCON = 0x50;        /* 方式1,允许接收 */

    TMOD &= 0x0F;       /* 清掉定时器1的设置,保留定时器0 */
    TMOD |= 0x20;       /* 定时器1,方式2(8位自动重装)—— 串口专用 */

    TH1 = 0xFD;         /* 9600bps @11.0592MHz */
    TL1 = 0xFD;         /* 方式2自动重装,TL1 也要给初值 */

    TR1 = 1;            /* 启动定时器1(它负责产生波特率) */

    ES  = 1;            /* 允许串口中断 */
    EA  = 1;            /* 总中断 */
}
💡 为什么定时器 1 要用方式 2

方式 2 是 8 位自动重装:溢出后自动把 TH1 的值重新装回 TL1,不需要程序干预。这样每次溢出周期稳定,波特率才准。

这也是 Day 22 讲的「方式1要手动重装」的反面案例 —— 用对了方式,就不用操心重装。

23.6 发送与接收

发送一个字节(查询法)

void Uart_SendByte(unsigned char dat)
{
    SBUF = dat;             /* 把数据放进发送缓冲器,硬件自动开始发 */
    while (TI == 0);        /* 等发完(TI 由硬件置 1) */
    TI = 0;                 /* 软件清标志,为下一次做准备 */
}

void Uart_SendString(char *s)
{
    while (*s != '\0') {    /* 用 Day 6 学的字符串结束符 */
        Uart_SendByte(*s);
        s++;
    }
}

接收(中断法,推荐)

volatile unsigned char rx_data = 0;
volatile bit rx_flag = 0;               /* volatile!中断改的变量 */

void Uart_ISR(void) interrupt 4
{
    if (RI) {                           /* 是接收引起的 */
        RI = 0;                         /* 必须手动清 */
        rx_data = SBUF;                 /* 读走数据 */
        rx_flag = 1;                    /* 通知主循环 */
    }
    /* TI 产生的也可以在这里清,如果用中断方式发送的话 */
}

void main(void)
{
    Uart_Init();

    while (1) {
        if (rx_flag) {
            rx_flag = 0;
            Uart_SendByte(rx_data);     /* 收到什么就回什么(回显) */
        }
    }
}
🚨 串口中断的两个必做动作

① 判断是 RI 还是 TI —— 发送和接收共用中断号 4,进来要分清。
② 手动清标志 —— 忘了清 RI,程序会不停进中断,主循环根本得不到执行,看起来像死机。

23.7 printf 重定向:最舒服的调试方式

与其一直用 Uart_SendString,不如让 printf 直接输出到串口。原理是 C51 库的 printf 最终会调用 putchar,我们把它替换掉:

#include <stdio.h>

char putchar(char c)            /* C51 库的 printf 会调用这个函数 */
{
    SBUF = c;
    while (TI == 0);
    TI = 0;
    return c;
}

void main(void)
{
    Uart_Init();

    printf("系统启动\r\n");                 /* 现在 printf 输出到串口了 */
    printf("温度 = %d C\r\n", 25);
    printf("十六进制 = 0x%02X\r\n", 0xAB);   /* 调试寄存器特别有用 */

    while (1);
}
✓ 有了 printf,调试方式彻底变了

以前「灯不亮」只能猜;现在可以:

printf("P1 = 0x%02X\r\n", P1);       /* 端口实际电平是多少 */
printf("tick = %d\r\n", tick);       /* 中断进了几次 */
printf("进中断了\r\n");               /* 有没有进到某个分支 */

这是嵌入式最有效的排错手段:把程序内部的状态「打印」出来看,比盯着电路板猜快十倍。

注意 \r\n:串口助手通常需要一个回车一个换行才能正确换行,只写 \n 有时会显示成阶梯状。

23.8 接线与调试

单片机           USB-TTL 模块          电脑
P3.1 (TXD) ────  RXD
P3.0 (RXD) ────  TXD
GND        ────  GND          ← 必须共地!
                         USB ──── 串口助手
⚠️ 两个最常见的接线错误

① TXD 接 TXD —— 名字相同反而要交叉接:单片机的 TXD 接模块的 RXD。
② 忘了共地 —— 只有 TX/RX 两根线、没有 GND,通信必然失败。信号需要共同的电压参考点。

现象最可能的原因
完全没反应TX/RX 接反;没共地;波特率不对;串口没选对
全是乱码波特率不匹配;晶振不是 11.0592MHz;Keil 里 Xtal 设置和实际不符
偶尔错一两个字符波特率误差偏大(接近极限);接线太长有干扰
能收不能发 / 能发不能收REN 没置 1;或 TI/RI 标志没清导致中断一直触发
🧠 今天的自测

① 「8N1」是什么意思?发一个字节实际上要发几位?
② 为什么串口应用要用 11.0592MHz 晶振?用 12MHz 会怎样?
③ SCON = 0x50; 配置了什么?
④ 串口中断里「判断 RI/TI」和「清标志」为什么都不能漏?
⑤ printf 是怎么被重定向到串口的?

📖 想深入看理论

《51单片机入门前置知识》:第 10 章(串口通信与常见总线:UART 帧格式、SCON 详解、波特率计算器、完整收发程序、SPI/I²C 对比)。

24Day 24 · 综合项目 + 底层逻辑总串

DAY 24 / 24

最后一天做两件事:把学过的东西拼成一个完整的作品,然后把整条链路的底层逻辑串一遍。

24.1 综合项目:按键计数的秒表

🎯 项目需求

用上了你三周学的全部东西:

  • 定时器中断:每 50ms 一次,累加出「秒」
  • 按键:K1 显示/暂停计时,K2 清零
  • 数码管:动态扫描显示秒数(4 位)
  • 串口:每次秒数变化时上报给电脑
  • volatile:中断与主循环共享的变量
  • 多文件:模块划分(这里为方便阅读放在一个文件)
/*==============================================================
  文件 : main.c
  功能 : 秒表 —— 数码管显示秒数,按键控制,串口上报
  硬件 : STC89C52RC @ 11.0592MHz
         P1.0      LED(状态指示)
         P3.2      K1  开始/暂停
         P3.3      K2  清零
         P0        数码管段码(共阳)
         P2.0~P2.3 数码管位选
         P3.0/P3.1 串口(接 USB-TTL)
==============================================================*/
#include <reg51.h>
#include <stdio.h>

sbit LED  = P1^0;
sbit KEY1 = P3^2;
sbit KEY2 = P3^3;

/* ---------- 全局变量 ---------- */
volatile unsigned char tick  = 0;     /* 50ms 计数 */
volatile unsigned char sec   = 0;     /* 秒 */
volatile bit           running = 0;   /* 是否在计时 */

/* ---------- 数码管段码表(共阳,放 Flash) ---------- */
unsigned char code seg_table[10] = {
    0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90
};

/* ---------- 毫秒延时(只用于按键消抖) ---------- */
void DelayMs(unsigned int ms)
{
    unsigned int i, j;
    for (i = 0; i < ms; i++)
        for (j = 0; j < 120; j++);
}

/* ---------- printf 重定向到串口 ---------- */
char putchar(char c)
{
    SBUF = c;
    while (TI == 0);
    TI = 0;
    return c;
}

/* ---------- 串口初始化:9600 8N1 ---------- */
void Uart_Init(void)
{
    SCON = 0x50;
    TMOD &= 0x0F;
    TMOD |= 0x20;          /* 定时器1 方式2 产生波特率 */
    TH1 = 0xFD;
    TL1 = 0xFD;
    TR1 = 1;
    /* 这里不用串口中断,用查询方式发 */
}

/* ---------- 定时器0 初始化:50ms @11.0592MHz ---------- */
void Timer0_Init(void)
{
    /* 11.0592MHz 机器周期 = 12/11.0592 = 1.0851us,50ms 需数 46080 个 */
    /* 初值 = 65536 - 46080 = 19456 = 0x4C00 */
    TMOD &= 0xF0;
    TMOD |= 0x01;
    TH0 = 0x4C;
    TL0 = 0x00;
    ET0 = 1;
    EA  = 1;
    TR0 = 1;
}

/* ---------- 定时器0 中断:每 50ms ---------- */
void Timer0_ISR(void) interrupt 1
{
    TH0 = 0x4C;                     /* 重装初值,别忘了! */
    TL0 = 0x00;

    if (!running) return;           /* 暂停时不计数 */

    tick++;
    if (tick >= 20) {               /* 20 × 50ms = 1 秒 */
        tick = 0;
        sec++;
        if (sec > 99) sec = 0;
    }
}

/* ---------- 数码管动态扫描(显示秒数) ---------- */
void Display(unsigned char value)
{
    unsigned char d1, d2;

    d1 = value / 10;                /* 十位 */
    d2 = value % 10;                /* 个位 */

    /* 第 1 位(十位) */
    P0 = 0xFF;                      /* 先关段(消影) */
    P2 = 0x01;                      /* 选位 */
    P0 = seg_table[d1];             /* 送段码 */
    DelayMs(2);

    /* 第 2 位(个位) */
    P0 = 0xFF;
    P2 = 0x02;
    P0 = seg_table[d2];
    DelayMs(2);
}

/* ---------- 按键扫描(带消抖,不阻塞) ---------- */
unsigned char KeyScan(void)
{
    static unsigned char last1 = 1, last2 = 1;
    unsigned char now1 = KEY1, now2 = KEY2;

    if (now1 != last1) { last1 = now1; if (now1 == 0) return 1; }
    if (now2 != last2) { last2 = now2; if (now2 == 0) return 2; }
    return 0;
}

/* ---------- 主程序 ---------- */
void main(void)
{
    unsigned char key;

    Uart_Init();
    Timer0_Init();

    LED = 1;
    printf("秒表就绪,按 K1 开始\r\n");

    while (1) {
        /* ① 扫描按键 */
        key = KeyScan();

        if (key == 1) {                 /* K1:开始/暂停 */
            DelayMs(15);                /* 消抖 */
            if (KEY1 == 0) {
                running = !running;
                LED = !running;         /* 计时中灯亮 */
                printf(running ? "开始计时\r\n" : "暂停\r\n");
                while (KEY1 == 0);      /* 等松手 */
            }
        }

        if (key == 2) {                 /* K2:清零 */
            DelayMs(15);
            if (KEY2 == 0) {
                sec = 0;
                tick = 0;
                printf("已清零\r\n");
                while (KEY2 == 0);
            }
        }

        /* ② 刷新数码管(每次主循环都刷,保证不闪) */
        Display(sec);

        /* ③ 每秒上报一次(靠比较秒数变化) */
        {
            static unsigned char last_sec = 255;
            if (sec != last_sec) {
                last_sec = sec;
                printf("计时: %d 秒\r\n", sec);
            }
        }
    }
}
⚠️ 注意这个项目里的定时器初值变了

前面 Day 22 用的是 12MHz(50ms → 0x3CB0)。这个项目为了串口能用,晶振是 11.0592MHz,机器周期变成 1.0851µs,所以初值也变了:

需要数的个数 = 50000µs / 1.0851µs = 46080
初值 = 65536 - 46080 = 19456 = 0x4C00   →  TH0 = 0x4C, TL0 = 0x00

换晶振必须重算初值,这是很多人移植代码时踩的坑:程序能跑,但时间全错。

🚨 顺便指出上面代码里一个真实存在的缺陷

这段代码里 KeyScan() 和后面的 DelayMs(15) 消抖重复了,而且 while (KEY1 == 0); 会阻塞 —— 阻塞期间数码管就不扫描了,会看到轻微闪烁。

正确的做法是只保留状态机消抖(Day 20 方案二),把「等松手」也改成状态判断。留这个缺陷在这里,是想让你体会:功能能跑 ≠ 代码没问题。你可以试着改掉它,这是很好的练习。

24.2 底层逻辑总串:从一行 C 到引脚上的电压

这是整份教程的收束。你写的每一行操作寄存器的代码,都走这条链:

① 你写下 P1 = 0xFE; —— 人看得懂的文本
② 预处理把 #include <reg51.h> 展开,于是编译器知道 P1 是地址 0x90 的寄存器
③ 编译把它翻译成一条机器指令:MOV 0x90, #0xFE(把立即数 0xFE 送进地址 0x90)
④ 汇编 + 链接把这条指令变成二进制码,放进 Flash 的某个地址(比如 0x0100)
⑤ 下载:STC-ISP 通过串口把这串二进制写进芯片的 Flash
⑥ 上电复位:PC(程序计数器)被置为 0x0000,启动代码初始化栈、清零全局变量,然后跳到 main
⑦ CPU 取指—译码—执行:从 Flash 读出 0x0100 处的指令,译码,然后执行「把 0xFE 写到 0x90」
⑧ 寄存器动作:P1 这个 8 位寄存器的第 0 位变成 0,其余 7 位是 1
⑨ 引脚驱动:P1.0 对应的强下拉 MOS 管导通(Day 17 的「强下拉」),把引脚拉到 0V;其余 7 个引脚的弱上拉管导通,被拉到接近 5V
⑩ 电流流动:+5V → LED → 330Ω 电阻 → P1.0(0V)。电流约 (5−2)/330 ≈ 9mA
⑪ 灯亮 💡
✓ 这就是「底层逻辑」的全部

你写的每一行 C,最终都变成「往某个地址写某个字节」,进而变成「某几个引脚上的电压」。

理解了这条链,你就能:
· 看数据手册时知道「寄存器地址」意味着什么
· 遇到「代码对了但没现象」时,知道该在哪一环检查
· 换一款芯片时,只需重新查它的寄存器地址表

24.3 三周知识地图

周你学会的它在底层对应什么
第一周
C 从零
变量与类型内存里的格子(RAM / 寄存器)
运算符CPU 的算术逻辑单元能做的运算
分支与循环CPU 的比较和跳转指令
数组与函数连续内存 + 栈帧
第二周
C 与底层
指针地址 —— 访问寄存器的唯一途径
结构体数据布局(成员按顺序占地址)
位运算操作寄存器某一位的唯一方法
内存分区 / volatile栈、静态区、编译器优化陷阱
第三周
51 实战
最小系统 / 时钟机器周期 = 一切时间的基准
IO 口 / 准双向弱上拉 + 强下拉 → 灌电流点灯
位运算实战读—改—写单个引脚
按键 / 消抖机械抖动 5~20ms
数码管 / 扫描视觉暂留 + 分时复用
定时器 / 中断 / 串口硬件替你数时间、按门铃、收发数据

24.4 排错方法论(比任何具体知识都值钱)

  1. 从底层往上层查:电源 → 时钟 → 复位 → 程序有没有跑 → 寄存器值 → 接线 → 代码逻辑。永远不要一上来就改代码。
  2. 先确认「芯片在跑」:量 ALE 脚(第 30 脚)应有约 1/6 晶振频率的方波(11.0592MHz → 约 1.8MHz)。没波形 = 芯片根本没运行,代码再改也没用。
  3. 把内部状态打印出来:有了 Day 23 的 printf 重定向,把 P1、标志位、计数器的值打出来看,比猜快十倍。
  4. 一次只改一个变量:同时改三处,现象变了你也不知道是哪一处生效。
  5. 相信现象,不要相信推断:你觉得「这里肯定是 1」,就用 printf 打出来验证。
  6. 回到最小可工作版本:代码越改越乱时,退回上一个能跑通的版本,再一小步一小步加。

24.5 下一步学什么

方向内容什么时候需要
先补齐 51 的外设PWM 调光、I²C(EEPROM、OLED)、SPI、AD 转换、红外解码想让项目更完整时
换更强的芯片STM32(32 位 ARM):寄存器更多、有库函数、有 DMA、有 RTOS51 的资源不够用时
学 RTOSFreeRTOS:任务、信号量、消息队列程序变得复杂、裸机 while(1) 管不过来时
学硬件自己画原理图、画 PCB(立创EDA / Altium)想把自己的设计做成实物时
学通信协议Modbus、CAN、MQTT做工业设备、物联网时
💡 一个忠告

不要急着换芯片。把 51 玩透(哪怕是做个完整的电子钟、温度计、红外遥控解码),你对「单片机是怎么工作的」的理解会比「用 STM32 调库点灯」深得多。

51 的价值恰恰在于它够简单 —— 简单到你能看见每一层的全貌。这种「看得见底层」的经验,换到任何芯片上都有用。

🎉 三周结束

现在回头看第 1 天那句「你写的 C → 编译器翻译成机器码 → 存进芯片的 ROM → CPU 取出来执行 → 改变引脚电压 → 灯亮」—— 你是不是已经能自己把它展开成 11 个环节了?

如果你能,这份教程的目的就达到了。剩下的就是在项目里把这些东西用熟。