CRASH COURSE · C FOR MCU

C 语言速成 · 从 C++ 到 51 单片机

你已经学完 ACM Day 1–7(输入输出、循环、一维数组、桶计数、二维数组)。这份教程不重讲语法,只做两件事:砍掉 C++ 独有物、补上单片机必需的 C 特性。

怎么用:左侧点任意一讲即可,每讲底部有「标记为已学完」,进度存在本机浏览器
快捷键:←/→ 切换上一讲/下一讲 / 跳到搜索 Ctrl+P 打印
离线:单个 HTML 文件,双击即开,不需要联网

0写在前面:做减法,再做加法

第 0 讲 / 11

C 和 C++ 不是两门语言的关系,而是「C++ 是 C 加上一大堆东西」。你学 C 不是从零开始,而是把已经会的东西换个写法,再补几个 C++ 里没有、单片机里却天天用的东西。

一、为什么单片机用 C 而不是 C++

原因说明
编译器支持51 单片机最常用的 Keil C51、SDCC 都是 C 编译器;ARM 的 GCC 虽然能编 C++,但工程模板、库、教程几乎全是 C
代码体积51 只有 4KB~8KB Flash、128~512 字节 RAM。C++ 的异常、RTTI、模板实例化会吃掉大量空间
可控性单片机要精确控制「这一句编译成几条指令、占用几个字节」,C 的行为更贴近硬件
生态芯片厂商给的例程、数据手册示例、寄存器头文件全是 C

二、你要「砍掉」的(C++ 独有,C 里没有)

C++ 的东西C 里怎么办
cin / coutscanf / printf(第 1 讲)
stringchar 数组 + string.h(第 5 讲)
vector / stack / queue / sortC 标准库没有容器和算法;单片机里也不用(内存太小),自己写数组+循环
class / 继承 / 多态用 struct + 函数(第 6 讲)
引用 int&用指针 int*(第 4 讲)——这是最容易混的一处
函数重载 / 默认参数C 不允许同名函数,只能起不同名字
模板 / namespace没有。using namespace std; 这句话在 C 里不存在
new / deletemalloc / free;单片机通常完全不用动态内存

三、你要「加上」的(单片机必需)

新东西为什么单片机离不开它
位运算 & | ^ ~ << >>点灯、配置寄存器、读引脚,全是按位操作
stdint.h 的 uint8_t 等嵌入式里「这个变量到底几位」必须确定,不能靠编译器心情
指针用地址直接访问寄存器
volatile中断里改的变量,不加它编译器会把你的代码优化没
宏 #define寄存器地址、位掩码,芯片头文件里到处是宏
多文件工程(.h/.c)真实单片机项目都是 main.c + 一堆模块
💡 一句话总结这份教程的路线

第 1–2 讲换掉输入输出和类型 → 第 3 讲拿下位运算(最重要)→ 第 4–6 讲补齐指针/字符串/结构体 → 第 7–8 讲学会组织工程和嵌入式关键字 → 第 9–11 讲速查、避坑、练习。

1第一个 C 程序与输入输出

第 1 讲 / 11

同一个程序,两种写法

左边是你现在写的,右边是 C 版本 —— 除了输入输出,其余一模一样:

C++(你现在的写法)C(单片机里的写法)
// ===== C++ 版 =====
#include <iostream>
using namespace std;
int main(){
    int a, b;
    cin >> a >> b;
    cout << a + b << endl;
    return 0;
}
// ===== C 版 =====
#include <stdio.h>          // 注意:不是 iostream,也没有 using namespace
int main(void){
    int a, b;
    scanf("%d %d", &a, &b);   // 每个变量前面要加 & 取地址
    printf("%d\n", a + b);
    return 0;
}
🚨 最常见的错误:scanf 忘记 &

scanf("%d", a); 会把 a 的值当成地址传进去,程序直接崩溃或读到莫名其妙的地方。正确写法是 scanf("%d", &a);

记忆方法:scanf 要「把数据送到变量的地址上」,所以要取地址 &;printf 只是「读出变量的值来打印」,所以不加。

唯一例外:读字符串数组名 scanf("%s", s); 不加 &,因为数组名本身就是地址。

printf / scanf 格式符对照表

类型printfscanf说明
int%d%d最常用
long long%lld%lldC 里没有 long long 的简写
unsigned int%u%u无符号用 u
float%f%fprintf 里 float 会自动提升成 double
double%f / %.2f%lfscanf 必须写 %lf,写 %f 会读错
char%c%c单个字符
char[]%s%s字符串(遇空格停止)
十六进制%x / %X%x单片机看寄存器常用它
八进制%o%o看芯片手册偶尔遇到
百分号本身%%—想打印 % 要写两个

输出格式控制

printf("%d\n", 42);          // 42
printf("%5d|\n", 42);        //    42|   宽度 5,右对齐
printf("%-5d|\n", 42);       // 42   |   左对齐
printf("%05d\n", 42);        // 00042   补前导零
printf("%.2f\n", 3.14159);   // 3.14    保留两位小数
printf("%x\n", 255);         // ff      十六进制(单片机调试常看)
printf("你输入的是 %d\n", a);  // 中文也能直接输出

单片机里的 printf 是要「改造」的

在 PC 上 printf 输出到屏幕,在 51 单片机上它默认没有出口。标准做法是:把 printf 的底层输出函数 putchar 重定向到串口。这段代码你在 Day 5(串口通信)会直接用到:

/* 51 单片机:把 putchar 重定向到串口,之后 printf 就能发到电脑串口助手 */
#include <stdio.h>

void UartInit(void);        // 串口初始化(略,见 Day 5)
void UartSendByte(unsigned char b);

char putchar(char c)        // Keil C51 的库会调用这个函数
{
    UartSendByte((unsigned char)c);
    return c;
}

void main(void)
{
    UartInit();
    printf("Hello MCU\r\n");     // 电脑串口助手就能看到
    printf("温度 = %d C\r\n", 25);
    while (1);
}
💡 PC 上写 C 时的一个小差别

Keil C51 的 main 习惯写 void main(void),因为单片机程序永不返回(最后一定是 while(1);)。而在 PC 上(洛谷、PTA)必须写 int main(void) 并 return 0;。两种都对,看平台。

scanf 的坑

// 坑 1:%d 和 %ld / %lld 必须和变量类型匹配
long long n;
scanf("%d", &n);        // ✗ 错!读进来的值不对
scanf("%lld", &n);      // ✓ 对

// 坑 2:格式串里的空格和换行
scanf("%d %d", &a, &b);  // ✓ scanf 会把任意多个空白(空格/回车/Tab)都吃掉

// 坑 3:读字符时前面的回车会被吃掉
scanf("%c", &ch);        // 如果前面刚读完数字,这里读到的可能是 '\n'
scanf(" %c", &ch);       // ✓ 前面加个空格,跳过空白字符

2数据类型与 stdint.h

第 2 讲 / 11

为什么单片机必须用 stdint.h

C 标准只规定了「int 至少 16 位」这种下限,没有规定确切长度。同一个 int,在 51 上是 16 位,在 PC 上是 32 位。写单片机时这是灾难 —— 你以为它在存 0~65535,结果只存了 8 位。

解决办法就是 stdint.h,它给出长度确定的类型名:

#include <stdint.h>

uint8_t   a;   // 无符号  8 位   0 ~ 255
int8_t    b;   // 有符号  8 位   -128 ~ 127
uint16_t  c;   // 无符号 16 位   0 ~ 65535
int16_t   d;   // 有符号 16 位   -32768 ~ 32767
uint32_t  e;   // 无符号 32 位   0 ~ 4294967295
int32_t   f;   // 有符号 32 位

// 只要看到 u 开头就是 unsigned,看到 int 开头就是有符号,后面的数字就是位数
类型位数范围单片机里的典型用途
uint8_t80 ~ 255一个 IO 口的 8 个引脚、一个寄存器
int8_t8−128 ~ 127温度偏差、传感器原始值
uint16_t160 ~ 65535定时器计数值、ADC 采样
uint32_t320 ~ 42 亿毫秒级时间戳

PC 上常见的类型(洛谷用)

类型位数(PC/GCC)printf 格式符
char8%c(当字符)/ %d(当整数)
int32%d
unsigned int32%u
long long64%lld
float32%f
double64%f(printf)/ %lf(scanf)
⚠️ 这直接关系到你踩过的坑

你在 P1047 里写过 int arr[a],而且用 i <= a 去访问它 —— 越界了。C 对数组越界不做任何检查,它只会安静地读写隔壁的内存。这也是为什么 C 里数组大小必须自己心里有数。

正确写法是用常量开数组,并保证下标范围清楚:int arr[10005];

sizeof:自己量一量

#include <stdio.h>
int main(void){
    printf("char        = %d\n", (int)sizeof(char));         // 1
    printf("int         = %d\n", (int)sizeof(int));          // PC 上 4,51 上 2
    printf("long long   = %d\n", (int)sizeof(long long));    // 8
    printf("double      = %d\n", (int)sizeof(double));       // 8
    printf("int[10]     = %d\n", (int)sizeof(int[10]));      // 40
    return 0;
}

注意 sizeof 返回的是 size_t(无符号),所以打印时要强制转成 int,否则会出警告。

无符号数的坑

unsigned int a = 0;
a = a - 1;              // 不是 -1,而是 4294967295(绕回去了)
printf("%u\n", a);

// 循环变量尤其危险:条件永远成立
for (unsigned int i = 10; i >= 0; i--)   // ✗ 死循环!i 永远 >= 0
    printf("%u\n", i);

51 单片机的类型(Keil C51)

Keil C51 类型位数说明
unsigned char8最常用,等价 uint8_t
unsigned int16等价 uint16_t
unsigned long32等价 uint32_t
bit151 特有:可以定义一个单独的位变量
sbit151 特有:把一个引脚定义成单独的名字
sfr851 特有:给特殊功能寄存器起名字
/* 51 里你会反复看到这三行,它们都在头文件 reg51.h 里 */
sfr P1 = 0x90;          // 把地址 0x90 的寄存器命名为 P1
sbit LED = P1^0;        // 把 P1 的第 0 位命名为 LED

LED = 0;                // 直接操作那一位,不用位运算

3位运算(单片机最核心的技能)

第 3 讲 / 11
🎯 这一讲比其它所有讲都重要

单片机的本质工作就是「往某个寄存器的某几位写 0 或 1」。点灯、配置串口波特率、设置定时器模式,全都是位运算。你在 ACM Day 2 的流水灯 P1 = ~(1<<i) 已经见过它了,这一讲把它讲透。

六个运算符

运算符名字例子(8 位)结果
&按位与0b1100 & 0b10100b1000(两位都是 1 才为 1)
|按位或0b1100 | 0b10100b1110(有一位是 1 就为 1)
^按位异或0b1100 ^ 0b10100b0110(不同才为 1)
~按位取反~0b11000b11110011(全部位都翻转)
<<左移1 << 30b1000(等于 ×2³)
>>右移0b1000 >> 31(等于 ÷2³)

四个必背惯用法

这四行你要练到不用想就能写出来:

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

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

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

/* ④ 读取:判断第 n 位是 0 还是 1 */
if (reg & (1 << n)) { /* 第 n 位是 1 */ }
💡 为什么清零要写 &= ~(1<<n) 这么绕?

因为 |= 只能把位变 1,没法把位变 0。想清零就得「用一个除了第 n 位是 0、其它位全是 1 的掩码去与」。1<<n 是「只有第 n 位是 1」,~(1<<n) 取反后就是「只有第 n 位是 0」—— 正好是我们要的掩码。

手推一遍:把 P1 的第 3 位点亮

P1 初值          = 0b0000 0000
1 << 3           = 0b0000 1000
P1 |= (1<<3)     → 0b0000 1000    // 第 3 位亮了,其它位没动

// 再把第 5 位也点亮(不影响第 3 位)
P1 |= (1<<5)     → 0b0010 1000

// 现在把第 3 位关掉
~(1<<3)          = 0b1111 0111
P1 &= ~(1<<3)    → 0b0010 0000    // 第 3 位灭了,第 5 位还在

你学过的流水灯,现在能解释清楚了

/* Day 2 的流水灯:8 个 LED 依次点亮 */
#include <reg51.h>
#include <intrins.h>

void delay(unsigned int ms);

void main(void)
{
    unsigned char i;
    while (1) {
        for (i = 0; i < 8; i++) {
            P1 = ~(1 << i);   // 只有第 i 位是 0,其余是 1
            delay(200);
        }
    }
}
i1<<i~(1<<i)效果(LED 低电平点亮)
00000 00011111 1110第 0 个灯亮
10000 00101111 1101第 1 个灯亮
20000 01001111 1011第 2 个灯亮
………只有一个 0 在往左移动,像水一样流过去

这就是「流水」的来历:那个唯一的 0 在 8 位里移动。

更多单片机里的实际用法

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

/* 一次清掉低 4 位,保留高 4 位 */
reg &= 0xF0;

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

/* 取低 4 位的值 */
unsigned char low = reg & 0x0F;

/* 交换高低 4 位 */
reg = (reg << 4) | (reg >> 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);
🚨 位运算的四个经典坑

① 优先级:& | ^ 的优先级低于 ==。所以
if (reg & 1 << 2 == 0) 会被理解成 reg & (1 << (2==0)),完全不是你的意思。所有位运算都加括号:if ((reg & (1<<2)) == 0)。

② ~ 是全位取反:~0x0F 在 int 上是 0xFFFFFFF0,不是 0xF0。所以常写成 0xF0 直接当掩码,而不是靠 ~ 推。

③ 移位数不能超过类型宽度:1 << 32 在 32 位 int 上是未定义行为。想做 32 位掩码要用 1UL << n。

④ && 和 & 不要混:a && b 是「逻辑与」(结果只有 0/1),a & b 才是按位与。

自测

不看上面,说出这几行的作用:

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

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

4指针

第 4 讲 / 11

指针是 C 最出名也最容易劝退的地方。但对你来说它其实只有两件实际用途:

  1. 让函数能修改外面的变量(C 没有引用,只能靠指针)
  2. 用地址直接访问硬件寄存器(单片机的命根子)

两个符号:& 和 *

符号名字含义
&x取地址「x 住在哪个门牌号」—— 得到一个指针
*p解引用「去 p 指的那个门牌号里,把东西取出来/放进去」
#include <stdio.h>
int main(void){
    int a = 10;
    int *p = &a;        // p 存放 a 的地址

    printf("%d\n", a);    // 10        —— a 的值
    printf("%p\n", (void*)p); // 0x7ffd... —— a 的地址
    printf("%d\n", *p);   // 10        —— 顺着地址取出来的值

    *p = 20;              // 通过指针修改 a
    printf("%d\n", a);    // 20
    return 0;
}
💡 一句话理解

int *p = &a; 读作:「p 是一个指针,它指向 a 的地址」。
声明时的 * 表示「p 是个指针」,使用时的 *p 表示「p 指向的那个东西」。同一个符号两种身份,这是初学者最晕的点。

用途一:让函数修改外部变量(替代 C++ 的引用)

这是你从 C++ 过来最容易写错的地方:

// ===== C++ 的写法(C 里不支持)=====
void addOne(int &x) { x++; }      // ✗ C 没有引用
int a = 5; addOne(a);             // a 变成 6

// ===== C 的写法:传指针 =====
void addOne(int *x) { (*x)++; }   // ✓ 注意括号不能省!
int a = 5; addOne(&a);            // a 变成 6
🚨 *x++ 和 (*x)++ 完全不同

因为 ++ 的优先级高于 *:*x++ 是「先把指针往后挪一格,再取值」,而我们要的是「把指向的值加一」,必须写 (*x)++。这个坑在单片机代码里非常常见。

用途二:直接访问寄存器(单片机核心)

芯片手册会告诉你「某个功能对应的寄存器在地址 0x80」,代码就是:

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

/* 拆开看,从里往外读:*/
// 0x80                   一个整数
// (unsigned char *)0x80  把它当成「指向 8 位无符号数的指针」
// volatile               告诉编译器:这个值随时可能自己变,别优化
// 最前面的 *             往这个地址里写 0x55

/* 读出寄存器的值 */
unsigned char v = *(volatile unsigned char *)0x80;

你可能会问:那 P1 = 0x55; 是怎么来的?就是芯片头文件里替你把上面那句写成了 sfr P1 = 0x80;,让你能直接写名字。所以两种写法是同一件事。

指针与数组的关系

int a[5] = {10, 20, 30, 40, 50};
int *p = a;          // 数组名本身就是首元素的地址,不用写 &a[0]

printf("%d\n", a[2]);    // 30
printf("%d\n", *(a+2));  // 30   —— a[i] 本质就是 *(a+i)
printf("%d\n", p[2]);    // 30   —— 指针也能用下标

所以 a[i] 只是 *(a+i) 的语法糖。理解这一点,第 5 讲的字符串就通了。

指针的坑

// 坑 1:野指针 —— 没初始化的指针指向随机地方
int *p;          // p 里是垃圾值
*p = 5;          // ✗ 程序崩溃
int *p = NULL;   // ✓ 先给个 NULL

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

// 坑 3:返回局部变量的地址
int *bad(void) {
    int x = 10;
    return &x;   // ✗ x 是局部变量,函数返回后它就没了
}

5数组与字符串

第 5 讲 / 11

你学过的数组在 C 里一模一样,唯一区别:C 没有 string,字符串就是 char 数组。

字符串 = char 数组 + 结束符 '\0'

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

// 长度
printf("%d\n", (int)sizeof(s1));   // 6  —— 数组占几个字节(含 '\0')
printf("%d\n", (int)strlen(s1));   // 5  —— 字符串有几个字符(不含 '\0')
写法含义什么时候用
sizeof(s)数组总共占多少字节数组(编译期就知道大小)
strlen(s)从开头数到 '\0' 有几个字符字符串(运行时才知道长度)
🚨 忘记 '\0' 会出大问题

C 的所有字符串函数(strlen、printf("%s")、strcmp)都是一路往后数,直到遇到 '\0' 才停。如果你的字符数组没有 '\0',它们会一直读下去,读到内存里的垃圾,输出一堆乱码甚至崩溃。

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

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

string.h 常用函数

C 没有 s.length(),这些是你要改用函数的地方:

C++ 写法C 写法说明
s.length()strlen(s)长度(不含 '\0')
s1 = s2strcpy(s1, s2)数组不能直接赋值,必须用函数拷
s1 == s2strcmp(s1,s2) == 0比较内容;== 在 C 里比的是地址,永远不等
s1 += s2strcat(s1, s2)拼接(要求 s1 后面有空间)
toupper(c)toupper(c)ctype.h 里,这个一样
#include <stdio.h>
#include <string.h>

int main(void){
    char a[20] = "hello";
    char b[20] = "world";

    printf("%d\n", (int)strlen(a));      // 5

    strcpy(a, b);                        // a 变成 "world"
    printf("%s\n", a);

    strcat(a, "!");                      // a 变成 "world!"
    printf("%s\n", a);

    if (strcmp(a, "world!") == 0)        // 比较内容要用 strcmp
        printf("相等\n");

    return 0;
}
⚠️ 你踩过的坑在这里会再出现一次

你在 P1047 写 int arr[a] 越界访问了数组外的内存。C 里字符串更容易越界 —— strcpy、strcat、sprintf 完全不检查目标够不够大。目标数组必须开得足够宽,否则就是往别人家里倒垃圾。

char s[5];
strcpy(s, "hello world");   // ✗ 目标只有 5 字节,写进去 12 字节 → 内存被踩坏
char s[20];
strcpy(s, "hello world");   // ✓

读整行字符串

// scanf("%s") 遇到空格就停 —— 只能读一个单词
scanf("%s", s);              // 输入 "hello world" 只会读到 "hello"

// 想读整行(含空格),用 fgets
fgets(s, sizeof(s), stdin);  // ✓ 连空格一起读,最多读 sizeof(s)-1 个字符
// 注意 fgets 会把行尾的 '\n' 也读进来,通常要手动去掉:
s[strcspn(s, "\n")] = '\0';

单片机里几乎不用读整行(数据来自串口和传感器),但你在洛谷做题会用得上。

二维数组(你 Day 7 学过,语法完全一样)

int a[3][4];                  // 3 行 4 列
a[1][2] = 5;

// 初始化
int b[2][3] = { {1,2,3}, {4,5,6} };

6结构体 / typedef / 枚举 / 联合

第 6 讲 / 11

你在 ACM Day 11 学过 C++ 的 struct。C 里它几乎一样,但有两个必须记住的差别。

差别一:定义变量时必须写 struct 关键字(除非用 typedef)

// C++ 写法
struct Point { int x, y; };
Point p;                  // ✓ C++ 可以直接用 Point

// C 里这样写会报错
struct Point { int x, y; };
Point p;                  // ✗ C 必须写 struct Point p;
struct Point p;           // ✓ 但要一直带着 struct 很烦

所以 C 代码里到处都是 typedef —— 给类型起个别名:

typedef struct Point {
    int x;
    int y;
} Point;                  // 现在 Point 就是一个类型名了

Point p;                  // ✓ 和 C++ 一样好写
p.x = 3; p.y = 4;
💡 看到这种写法不要慌

typedef struct 名字 { ... } 别名; —— 后面那个才是你以后要用的类型名。单片机头文件里 90% 的结构体都是这个格式。
常见变体是名字和别名相同:typedef struct Point { ... } Point;,省得想两个名字。

差别二:C 的结构体里不能有函数

// C++ 可以
struct Led {
    int pin;
    void on() { /* ... */ }   // ✗ C 不允许
};

// C 的做法:结构体只放数据,操作写成独立的函数
typedef struct {
    unsigned char pin;
    unsigned char state;
} Led;

void Led_On(Led *l)  { l->state = 1; }
void Led_Off(Led *l) { l->state = 0; }

这就是你在单片机代码里看到的 XXX_Init()、XXX_Send() 这种命名风格的由来 —— 假装是「对象的方法」,实际是普通函数。

箭头 -> :指针访问成员

Point p = {3, 4};
Point *ptr = &p;

printf("%d\n", p.x);        // 3   —— 变量用点
printf("%d\n", (*ptr).x);   // 3   —— 先解引用再取成员(写法啰嗦)
printf("%d\n", ptr->x);     // 3   —— 箭头是上面那种写法的简写

记住:变量用 .,指针用 ->。

单片机实战:用结构体映射一组寄存器

这是嵌入式里最重要的结构体用法。把连续地址的一堆寄存器,用一个结构体描述出来:

/* STM32 风格:一个 GPIO 端口有 7 个 32 位寄存器,地址连续排列 */
typedef struct {
    volatile uint32_t MODER;    // 偏移 0x00
    volatile uint32_t OTYPER;   // 偏移 0x04
    volatile uint32_t OSPEEDR;  // 偏移 0x08
    volatile uint32_t PUPDR;    // 偏移 0x0C
    volatile uint32_t IDR;      // 偏移 0x10
    volatile uint32_t ODR;      // 偏移 0x14
} GPIO_TypeDef;

/* 把基地址强制转换成这种结构体指针 */
#define GPIOA  ((GPIO_TypeDef *)0x40020000)

/* 之后就能像访问成员一样访问寄存器 */
GPIOA->MODER |= (1 << 10);   // 配置 PA5
GPIOA->ODR   |= (1 << 5);    // 点亮 PA5

原理就是第 4 讲的指针:结构体成员在内存里是依次排列的,所以 GPIOA->OTYPER 自然就落在 0x40020004 上。

枚举 enum

// 给一组状态起名字,比裸数字可读得多
typedef enum {
    LED_OFF = 0,
    LED_ON  = 1,
    LED_BLINK = 2
} LedState;

LedState s = LED_ON;

C 的 enum 本质就是整数,可以直接和 int 混用(这点比 C++ 宽松)。

联合 union(单片机专属技巧)

联合的所有成员共用同一块内存。用它可以在「一个 16 位数」和「两个 8 位数」之间自由切换 —— 处理传感器数据时特别好用:

typedef union {
    uint16_t word;          // 整体当 16 位看
    uint8_t  bytes[2];      // 拆成两个 8 位看
} Data16;

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

不用 union 的话,你就得手写 (v >> 8) & 0xFF 和 v & 0xFF —— 第 3 讲学过,但 union 更直观。

7函数、宏与多文件工程

第 7 讲 / 11

函数:和 C++ 基本一样,除了没有重载/默认参数/引用

// 函数声明(放在使用之前,或者放在 .h 里)
int add(int a, int b);

// 函数定义
int add(int a, int b) {
    return a + b;
}

// 没有返回值就写 void
void Led_On(void);      // (void) 表示"不接收参数",比空括号更严谨
⚠️ C 里不能有同名函数
// C++ 允许(重载)
int  max(int a, int b);
double max(double a, double b);   // ✗ C 报重复定义

// C 的做法:起不同名字
int    max_int(int a, int b);
double max_double(double a, double b);

宏 #define

// ① 常量宏 —— 单片机里到处都是
#define LED_PIN     1
#define MAX_SIZE    100
#define PI          3.1415926

// ② 带参数的宏 —— 必须给每个参数和整体都加括号!
#define SQUARE(x)   ((x) * (x))
#define MAX(a, b)   ((a) > (b) ? (a) : (b))

// ③ 位掩码宏(配合第 3 讲的位运算)
#define BIT(n)      (1 << (n))
#define LED_ON()    (P1 &= ~BIT(LED_PIN))
#define LED_OFF()   (P1 |=  BIT(LED_PIN))
🚨 宏不加括号的经典事故
#define SQUARE(x)  x * x

SQUARE(2+3)   // 展开成 2+3 * 2+3 = 2+6+3 = 11  ← 错!应该是 25

#define SQUARE(x)  ((x) * (x))     // ✓ 每个参数和整体都加括号
SQUARE(2+3)   // 展开成 ((2+3) * (2+3)) = 25  ✓

宏是文本替换,不是函数调用 —— 它不理解运算优先级。所以括号能加就加,宁可多加。

#include:尖括号和双引号的区别

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

多文件工程:单片机项目的标准结构

真实项目绝不会把所有代码塞进 main.c。典型结构:

project/
├── main.c          // 主程序:初始化 + 主循环
├── led.c           // LED 模块的实现
├── led.h           // LED 模块对外暴露的接口
├── uart.c / uart.h // 串口模块
└── timer.c / timer.h

led.h —— 只放「别人需要知道的东西」:

#ifndef __LED_H__          // 头文件保护,防止被重复包含
#define __LED_H__

#include <reg51.h>

// 宏定义
#define LED_PORT   P1
#define LED_COUNT  8

// 函数声明(只有声明,没有实现)
void Led_Init(void);
void Led_On(unsigned char index);
void Led_Off(unsigned char index);
void Led_Blink(unsigned char index, unsigned int ms);

#endif

led.c —— 放实现:

#include "led.h"

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

void Led_On(unsigned char index) {
    LED_PORT &= ~(1 << index);     // 第 3 讲的位运算
}

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

main.c —— 只关心流程:

#include "led.h"
#include "uart.h"

void delay(unsigned int ms);

void main(void)
{
    Led_Init();
    Uart_Init();

    while (1) {
        Led_On(0);
        delay(500);
        Led_Off(0);
        delay(500);
    }
}
💡 头文件保护那三行是干什么的

#ifndef __LED_H__ / #define __LED_H__ / #endif 的意思是:「如果这个头文件还没被包含过,才包含它」。因为 main.c 可能同时 #include "led.h" 和 #include "uart.h",而 uart.h 里也可能包含了 led.h —— 没有保护的话 led.h 的内容会被重复展开,导致「重复定义」错误。

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 里声明的接口来用它。

8const / volatile / static

第 8 讲 / 11

这三个关键字在 PC 上不太要紧,在单片机上是决定程序能不能跑起来的东西。

volatile —— 嵌入式第一关键字

🚨 不加 volatile,编译器会把你的代码"优化没"

编译器的优化逻辑是:「如果一个变量我没看见谁改它,那它的值就不用重新读」。但单片机里有两种它看不见的修改:硬件改的(寄存器)和中断改的(中断服务函数)。

// 错误示范:等待串口收到数据
unsigned char flag = 0;

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

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

void Uart_ISR(void) interrupt 4
{
    flag = 1;
}

void main(void)
{
    while (flag == 0);             // ✓ 每次都真的去内存里读 flag
}

规则:凡是满足下面任一条的变量,都要加 volatile:

  • 会被中断服务函数修改
  • 对应硬件寄存器(值可能被硬件自己改变)
  • 在多任务/主循环与中断之间共享

这也是为什么第 6 讲那个 GPIO_TypeDef 里每个成员都写了 volatile —— 那些地址的内容是硬件在改,不是程序在改。

const —— 只读

// 常量:放在 Flash 里,不占 RAM(单片机 RAM 很宝贵)
const unsigned char seg_code[10] = {0xC0,0xF9,0xA4,0xB0,0x99,
                                    0x92,0x82,0xF8,0x80,0x90};

// const 指针的两种位置,含义不同
const char *p1;        // p1 指向的内容不能改,p1 自己能改
char * const p2 = buf; // p2 自己不能改,指向的内容能改
const char * const p3 = "abc";  // 都不能改

单片机里把大数组(数码管段码表、字库)标成 const 是省 RAM 的常规操作。51 只有 128~512 字节 RAM,一张 10 字节的表都要省。

static 的两种用法

// 用法一:修饰全局变量/函数 → 只在本文件可见(第 7 讲讲过)
static unsigned char internal_buf[16];

// 用法二:修饰函数内部的变量 → 只初始化一次,函数退出后值还在
void Count(void)
{
    static unsigned int times = 0;   // 只初始化一次
    times++;
    printf("被调用了 %u 次\n", times);
}
// 第 1 次调用输出 1,第 2 次输出 2 …… 而不是每次都输出 1
⚠️ static 局部变量在单片机里的注意点

它不占栈、占静态存储区(永久占用 RAM)。51 的 RAM 很小,滥用 static 局部数组会很快耗尽内存。另外它是不可重入的 —— 如果这个函数会被中断调用,两次调用会共用同一个变量,容易出 bug。

三者的速记

关键字一句话什么时候必须写
volatile「这个值随时可能被别人改,每次都去内存读」中断共享变量、硬件寄存器
const「只读,别改」查表数组、常量、函数参数
static全局:只在本文件可见
局部:只初始化一次,值保留
模块私有函数、计数器

9C++→C 速查对照表

第 9 讲 / 11

写题或写单片机代码时遇到「这个在 C 里怎么写」,回来查这张表。

基础

功能C++C
头文件#include <iostream>#include <stdio.h>
命名空间using namespace std;无(删掉这行)
输入整数cin >> a;scanf("%d", &a);
输出整数cout << a;printf("%d", a);
输出换行cout << endl;printf("\n");
读整行getline(cin, s);fgets(s, sizeof(s), stdin);
mainint main(){...}int main(void){...}

类型

功能C++C
确定宽度整数int(通常 32 位)uint8_t / uint16_t / uint32_t
布尔bool / true / falseint / 1 / 0;或 #include <stdbool.h> 后可用 bool
字符串string s;char s[100];
类型别名using ll = long long;typedef long long ll;

数组与容器

功能C++C
定长数组int a[100];一样
动态数组vector<int> v;int *v = malloc(n * sizeof(int));(单片机一般不用)
长度v.size()自己用变量记,或 strlen(仅字符串)
排序sort(a, a+n);自己写冒泡/快排,或 qsort
栈/队列stack / queue自己用数组 + 下标模拟
清零memset(a,0,sizeof a);一样

函数与结构

功能C++C
引用传参void f(int &x)void f(int *x),调用 f(&a),内部 (*x)++
函数重载支持不支持,起不同名字
默认参数支持不支持
结构体定义变量Point p;typedef struct{...} Point; 然后 Point p;
结构体带方法支持不支持,写成独立函数
类/继承/多态支持没有
模板支持没有
动态内存new / deletemalloc / free

C 独有(C++ 里也能用,但单片机里天天见)

东西说明
printf / scanf格式化输入输出,单片机串口调试的主力
位运算惯用法|= 置位、&= ~ 清零、^= 翻转
volatile中断/寄存器变量必须加
sbit / sfr / bit51 单片机特有的位和寄存器定义
interrupt 关键字51 的中断服务函数:void f(void) interrupt 4
头文件保护#ifndef / #define / #endif

10易错清单(含你踩过的坑)

第 10 讲 / 11

一、你已经踩过的两个坑,在 C 里更危险

坑 1:int arr[a] = {1}; —— 只初始化了第一个元素(P1047)

你在 P1047 里写了 int arr[a]={1},以为所有元素都是 1,实际只有 arr[0] 是 1,其余全是 0。而且数组开成 a 却用 i<=a 访问,越界了。

// C 里正确的写法
int arr[10005];
for (int i = 0; i <= a; i++) arr[i] = 1;   // 逐个赋值

// 或者用 memset(只能填 0 或 -1 这种"字节重复"的值)
#include <string.h>
int arr[10005];
memset(arr, 0, sizeof(arr));   // 全 0
记忆

{1} 只给第一个赋值;想批量设成非 0 值,只能循环。memset 按字节填充,所以只适合 0 和 −1。

坑 2:变量没初始化就是随机值(P1085 的 int c;)

你在 P1085 里写了 int c; 然后 cout<<c;。虽然那次碰巧没触发,但 C/C++ 的局部变量不会自动清零,里面是栈上的垃圾值。

int c;              // ✗ 垃圾值
printf("%d\n", c);  // 输出什么完全看运气

int c = 0;          // ✓ 声明时就给初值
好习惯

所有变量声明时就初始化。单片机里尤其重要 —— 未初始化的变量可能导致外设配置错误,现象是「灯乱闪」,极难排查。

二、C 语言通用易错清单

坑错误示例正确
scanf 忘记 &scanf("%d", a);scanf("%d", &a);
double 用 %fscanf("%f", &d);scanf("%lf", &d);
数组越界int a[10]; a[10]=1;下标 0~9,a[10] 已经是第 11 个
忘记 '\0'char s[3]={'a','b','c'};char s[4]={'a','b','c','\0'};
= 和 ==if (a = 5)if (a == 5)
整数除法5 / 2 得 2要小数就写 5.0 / 2
位运算不加括号if (a & 1 == 0)if ((a & 1) == 0)
宏参数不加括号#define S(x) x*x#define S(x) ((x)*(x))
无符号数倒着循环for(unsigned i=n;i>=0;i--)用有符号 int,或改成 i>0
strcpy 目标太小char s[5]; strcpy(s,"hello");目标数组要留够空间
用 == 比字符串if (s1 == s2)if (strcmp(s1,s2)==0)
忘记 volatileunsigned char flag;volatile unsigned char flag;(中断里改的)

三、单片机特有的"现象 → 原因"排查表

现象最可能的原因
程序卡在 while 里出不来标志位没加 volatile,被编译器优化掉了(第 8 讲)
灯全亮/全灭,不受控端口初值没设;或 |= 和 &= ~ 用反了
改了寄存器但没效果寄存器指针没加 volatile;或没先使能对应的时钟/功能
中断进不去没开总中断 EA = 1;;或没开对应的分中断
数值算出来不对类型宽度不对(用了 int 而不是 uint16_t);或溢出了
偶尔死机、现象随机数组越界踩坏了别的变量;或中断里改了主循环正在用的变量

11练习:把做过的题用 C 重写

第 11 讲 / 11

最好的巩固方式:把你已经 AC 过的题,用 C 再写一遍。这样你只需要专注在「输入输出换写法」上,不用分心算法。

P1047 校门外的树入门

就用你之前那题的逻辑(数组标记),只是换成 C 的输入输出。

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

int main(void)
{
    int arr[10005];
    int a, b, i, k, y = 0;

    scanf("%d %d", &a, &b);

    for (i = 0; i <= a; i++) {
        arr[i] = 1;              // 0..a 每个位置都种树
    }

    for (k = 0; k < b; k++) {
        int m, n;
        scanf("%d %d", &m, &n);
        for (i = m; i <= n; i++) {
            arr[i] = 0;          // 区间内移除
        }
    }

    for (i = 0; i <= a; i++) {
        if (arr[i]) y++;
    }

    printf("%d\n", y);
    return 0;
}

对比一下你原来的 C++ 版本,区别只有:#include、scanf 带 &、printf、main(void)。逻辑一个字没变。

P1085 不高兴的津津入门

这题顺便把第 10 讲的「变量必须初始化」用上。

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

int main(void)
{
    int best = 0;      // 目前最大总时长
    int day  = 0;      // 对应的星期几,0 表示不会不高兴
    int i;

    for (i = 1; i <= 7; i++) {
        int a, b, sum;
        scanf("%d %d", &a, &b);
        sum = a + b;

        // 严格大于:并列时保留更早的一天
        if (sum > 8 && sum > best) {
            best = sum;
            day  = i;
        }
    }

    printf("%d\n", day);
    return 0;
}
自编 位运算热身单片机预备

读入一个 0~255 的整数 v,输出四行:

  • 把最低位(第 0 位)置 1 后的值
  • 把最低位清 0 后的值
  • 把第 3 位翻转后的值
  • 第 5 位是 0 还是 1
输入
40
输出
41
40
32
1
看参考答案
#include <stdio.h>

int main(void)
{
    int v;
    scanf("%d", &v);

    printf("%d\n", v |  (1 << 0));   // 置位:最低位变 1
    printf("%d\n", v & ~(1 << 0));   // 清零:最低位变 0
    printf("%d\n", v ^  (1 << 3));   // 翻转:第 3 位取反

    if (v & (1 << 5))               // 读位:第 5 位
        printf("1\n");
    else
        printf("0\n");

    return 0;
}

手推:40 = 32 + 8 = 0b00101000,从右往左第 0~7 位是 0 0 0 1 0 1 0 0。
|1 → 00101001 = 41(最低位本来 0,置成 1)
&~1 → 00101000 = 40(最低位本来就 0,没变化)
^8 → 00100000 = 32(第 3 位权值是 8,翻转后 40 减 8)
第 5 位权值是 32,而 40 里正好含 32,所以这一位是 1 → 输出 1

💡 下一步

做完这三题,你的 C 就已经够用了 —— 可以回到 51 单片机教程 Day 1(装工具链、点亮 LED)开始真正的嵌入式学习。那时候你会发现:位运算(第 3 讲)、指针访问寄存器(第 4 讲)、volatile(第 8 讲)、多文件工程(第 7 讲),这四样就是你后面每天都要用的东西。