目录第八阶段预处理和模块化一、预处理是什么二、#include三、#define1. 定义常量2. 定义位掩码3. 带参数的宏4. 多语句宏四、条件编译1. #ifdef2. #ifndef3. #if、#elif、#else4. 功能开关5. 暂时屏蔽代码五、头文件保护六、.h 和 .c 的分工1.h 文件对外接口2.c 文件具体实现七、extern八、static 文件内封装1. 局部变量是否需要记住上次的值2. 文件中的全局变量其他文件是否需要访问3. 函数它是不是模块的公开接口4.STM32实例5.不要滥用局部 static九、多文件工程如何连接十、模块接口设计第八阶段预处理和模块化一、预处理是什么C 代码并不是直接编译的大致过程是C 代码并不是直接编译的大致过程是 源代码 ↓ 预处理处理#include、#define、条件编译 ↓ 编译转换成汇编代码 ↓ 汇编生成.o目标文件 ↓ 链接合并所有.o和库文件 ↓ 生成.axf/.elf ↓ 生成.hex/.bin 预处理发生在正式编译之前本质上以文本处理为主。二、#include#include的作用是把另一个文件的内容引入当前位置STM32 工程常见写法#include stm32f10x.h #include Delay.h #include OLED.h #include LED.h #include LED.c // 错误习惯.c文件应当分别编译模块之间通过.h文件建立联系。三、#define1. 定义常量#define LED_PIN GPIO_Pin_13 #define BUFFER_SIZE 128 #define PI 3.1415926fuint8_t buffer[BUFFER_SIZE]; 预处理阶段会进行文本替换 uint8_t buffer[128]; 宏本身通常不占用独立运行内存。实例#include stdio.h /* 1. 定义常量 */ #define LED_PIN 13 #define UART_RATE 115200 int main(void) { /* 测试 1常量 */ printf(LED_PIN %d\n, LED_PIN); printf(UART_RATE %d\n, UART_RATE); }2. 定义位掩码#define MOTOR_ENABLE (1U 0) #define MOTOR_ALARM (1U 1) 使用 status | MOTOR_ENABLE;实例#include stdio.h /* 2. 定义位掩码 */ #define BIT0 (1u 0) // 0000 0001 #define BIT3 (1u 3) // 0000 1000 #define BIT7 (1u 7) // 1000 0000 int main() { /* 测试 位掩码 */ unsigned char reg 0x00; // 模拟一个 8 位寄存器 reg | BIT3; // 打开第 3 位 - 0000 1000 reg | BIT7; // 打开第 7 位 - 1000 1000 // 所以此时 reg 的二进制是1000 1000十六进制 0x88。 /*%02X 是什么意思 %X 以十六进制大写打印 2 最少占 2 位字符宽度。 0 如果不够 2 位前面补 0。 reg 现在是 0x88已经是两位了所以直接输出0x88 */ printf(Register value 0x%02X\n, reg); /*第二句检查第 0 位是不是开着 BIT0 是define BIT0 (1u 0为二进制 0000 0001 按位与上第0位 只要第 0 位是 1结果才是非 0真现在是 0所以条件为假。输出OFF */ printf(Check BIT0: %s\n, (reg BIT0) ? ON : OFF); }3. 带参数的宏#define SQUARE(x) ((x) * (x)) 使用 result SQUARE(5); 展开后 result ((5) * (5)); 宏参数和整个表达式都应该加括号 #define SQUARE(x) ((x) * (x)) // 推荐 #define SQUARE(x) x * x // 容易出错 例如错误宏 #define SQUARE(x) x * x result SQUARE(1 2); 展开成 result 1 2 * 1 2; // 结果为5不是9 还要避免 SQUARE(i); 因为展开后 i 会执行两次。复杂逻辑更适合使用函数或 static inline 函数宏不是函数是文本替换1 #define SQUARE(x) ((x) * (x)) 你在代码里写 result SQUARE(5); 编译器在编译前就会把这句话原封不动地替换成 result ((5) * (5)); 宏不是函数不会先计算 x5 再传进去而是直接抄字符串。这一点一定要刻在脑子里。 2 为什么参数和整个表达式都要加括号#define SQUARE(x) ((x) * (x)) ❌ 错误写法 #define SQUARE(x) x * x SQUARE(1 2) 错误宏展开 result 1 2 * 1 2; C语言里 *乘比 加优先级高所以实际计算顺序是 result 1 (2 * 1) 2; 但我们想要的是 (1 2) * (1 2) 3 * 3 9 3. 最隐蔽的坑副作用Side Effect SQUARE(i); 假设 i 3展开正确宏((i) * (i)) 这里 i 的意思是“先用当前值再自增1”。但它出现了两次于是 第一次用 i3然后 i 变成 4 第二次用 i4然后 i 变成 5 计算结果可能是 3 * 4 12但 C 标准里这是未定义行为不同编译器结果可能不同甚至崩溃 你本来只想让 i 加 1 然后求平方3 * 39i 变成 4结果 i 加了两次数也算错了。 结论永远不要给宏传递带有自增、自减、赋值等有副作用的表达式。 4.嵌入式里的正解用函数或 static inline 宏这么容易写错为啥嵌入式还用因为宏没有函数调用的开销不用压栈、跳转在资源紧张的单片机上很快。 现代编译器给了我们更好的平衡方案 ①普通函数最安全 int square(int x) { return x * x; } ②static inline 函数 static inline int square(int x) { return x * x; } inline 建议编译器把函数体直接展开类似宏的速度 static 限制只在当前文件可见避免重复定义 有类型检查括号不用你操心 传 square(i) 也只会算一次 i 在 STM32 / ESP32 开发里复杂一点的计算我都推荐用 static inline 代替宏。4. 多语句宏#define LED_FLASH() \ do \ { \ LED_On(); \ Delay_ms(100); \ LED_Off(); \ } while (0) 不过初学阶段优先写成普通函数更容易调试和进行类型检查。解析一在 #define 里如果宏体里不止一条语句比如又要开灯、又要延时、又要关灯就叫多语句宏。 #define LED_FLASH() \ do \ { \ LED_On(); \ Delay_ms(100); \ LED_Off(); \ } while (0) 这里面有三条语句 LED_On(); Delay_ms(100); LED_Off(); 因为跨了多行每行末尾用了 反斜杠告诉预处理器“下一行还是宏的一部分”。 二、为什么不能直接用 { } 包起来 很多初学者会天然写成 #define LED_FLASH() { LED_On(); Delay_ms(100); LED_Off(); } 看起来没问题但一放进 if 里就炸了。 ❌ 错误示范 if (flag) LED_FLASH(); else do_something(); 宏展开后变成 if (flag) { LED_On(); Delay_ms(100); LED_Off(); }; else do_something(); 注意看} 后面有一个分号——这个分号被当成了一条独立的空语句导致 else 找不到对应的 if编译直接报错。 正确套路do { ... } while(0) 把宏写成 do { LED_On(); Delay_ms(100); LED_Off(); } while (0) 展开后 if (flag) do { LED_On(); Delay_ms(100); LED_Off(); } while (0); else do_something(); do ... while(0) 在语法上是一个完整的语句类似于循环语句后面必须跟分号。 因此它和 if / else 搭配时不会多出诡异的分号。 同时它保证宏里的多条语句被当成一个整体执行不会被 if 只“吃掉”第一条。 这就是多语句宏必须用 do { ... } while(0) 的根本原因 do { ... } while (0) 先无条件执行一次 {} 里的所有语句 再判断 while (0) —— 因为条件是假所以不再循环 do { LED_On(); // 第一句开灯 Delay_ms(100); // 第二句延时 LED_Off(); // 第三句关灯 } while (0); 三句统统执行效果是灯亮 → 等 100ms → 灯灭。 用do while 它的使命是把多语句宏伪装成一条正常语句让它在 if/else 里不翻车。四、条件编译条件编译决定某段代码是否参与编译。1.#ifdef#define DEBUG #ifdef DEBUG printf(System started\n); #endif定义了DEBUG调试代码才会被编译。在嵌入式中这通常被称为“编译开关”。它的核心作用是决定某段代码是否被交给编译器去编译。 define DEBUG // 第一步定义了一个叫 DEBUG 的开关此时开关处于“开启”状态 ifdef DEBUG // 第二步编译器检查“哎DEBUG 这个开关定义了吗” endif // 结束检查 如果你注释掉第一行 #define DEBUG那么 printf 就不会被编译程序里根本不存在这段代码。 如果你保留了 #define DEBUG那么 printf 就会被编译进固件里程序运行时会打印信息。 为什么要玩这个“开关” 在写单片机程序时硬件资源非常宝贵Flash 和 RAM 很小。 假设你写了几千行代码其中有 10 行是用来打印调试信息的。 如果不使用条件编译这 10 行代码永远留在芯片里白白占用宝贵的 Flash 空间。 使用条件编译等你要发布正式版时只需要删掉 #define DEBUG 这一行重新编译。这 10 行代码就会像变魔术一样从芯片里彻底消失完全不占地方。 条件编译不仅能用来“打开”调试信息还能用来“关闭”某些功能。 条件编译最核心的用法 用宏定义来控制代码的“生死”。在接下来的学习中你会经常看到 #ifdef 配合 #include 来管理不同芯片的头文件实例① #include stdio.h #define DEBUG // 把这行注释掉再试一次 int main(void) { #ifdef DEBUG printf( 调试信息程序正在运行...\n); #else printf( 调试信息未执行DEBUG语句\n); #endif return 0; } 打印调试信息程序正在运行... ② #include stdio.h // #define DEBUG // 把这行注释掉再试一次 int main(void) { #ifdef DEBUG printf( 调试信息程序正在运行...\n); #else printf( 调试信息未执行DEBUG语句\n); #endif return 0; } 打印调试信息未执行DEBUG语句2.#ifndef与#ifdef 刚好是相反的逻辑但在嵌入式开发中极其常用。#ifdef如果定义了If Defined→ 执行代码。#ifndef如果没有定义If Not Defined→ 执行代码。#ifndef DEBUG /* 非调试版本代码 */ #endif表示没有定义DEBUG时才编译。实例#include stdio.h // #define DEBUG // 先别取消注释跑一次再取消注释再跑一次 int main(void) { #ifndef DEBUG printf( 未定义DEBUG打印本行\n); #else printf( 定义DEBUG打印else内容\n); #endif return 0; } 输出内容 未定义DEBUG打印本行 #include stdio.h #define DEBUG // 先别取消注释跑一次再取消注释再跑一次 int main(void) { #ifndef DEBUG printf( 未定义DEBUG打印本行\n); #else printf( 定义DEBUG打印else内容\n); #endif return 0; } 输出内容定义DEBUG打印else内容3.#if、#elif、#else#define BOARD_VERSION 2 #if BOARD_VERSION 1 #define LED_PIN GPIO_Pin_0 #elif BOARD_VERSION 2 #define LED_PIN GPIO_Pin_13 #else #error Unsupported board version #endif 1. 为什么要用 #if看图说话 你发的这张图里写得很清楚“同一份程序可以适配不同版本的硬件”。 举个最实际的例子 假设你做了一个产品既卖给了客户A用的是 V1 版电路板LED 接在 Pin 0又卖给了客户B用的是 V2 版电路板LED 接在 Pin 13。 如果只用 #ifdef你得写两套代码。 但用 #if你只要改一个数字编译器就会自动帮你把代码里的引脚改成对的。同一份程序可以适配不同版本的硬件。这一节的内容非常硬核也是真正能让你写出“万能代码”的秘诀。简单来说#ifdef只是问“有没有这个开关”而#if是问“这个开关的数值是多少”。实例先听懂#if 是干什么的 之前学的 #ifdef 和 #ifndef 只能判断“有没有定义”这个开关。 而 #if 厉害在它能判断“定义的值等于多少”就像程序里的 if 语句但它是编译前就定好的。 #if 表达式如果表达式为真编译这段 #elif否则如果增加分支 #else否则 #error如果前面都没中直接让编译器报错罢工防止乱填参数 #include stdio.h #define SPEED 2 int main(void) { #if SPEED 1 printf(低速模式\n); #elif SPEED 2 printf(中速模式\n); #elif SPEED 3 printf(高速模式\n); #else #error SPEED 只能填 1、2 或 3 #endif return 0; } 输出中速模式4. 功能开关#define ENABLE_MOTOR 1 #define ENABLE_SENSOR 0 #if ENABLE_MOTOR Motor_Init(); #endif #if ENABLE_SENSOR Sensor_Init(); #endif 如果宏可能被定义成 0应该使用 #if ENABLE_SENSOR 而不是 #ifdef ENABLE_SENSOR 因为即使写成 #define ENABLE_SENSOR 0它依然属于“已经定义”。 1. 它是什么为什么要用它 在写大型程序尤其是单片机、嵌入式时我们通常希望一份代码能搞定所有事情而不是为了做个新功能就重新拷贝一份代码去修改。 这时候就需要一个“总控制台”。 我想用电机把开关拨到 1。 我想用传感器把开关拨到 1。 我不想用蓝牙了把开关拨到 0。 #if 配合 #define 定义的 1 或 0就是这个控制台。 2. 必须注意的“坑” 这部分最重要的就是图中红字强调的陷阱 #ifdef (或者 #if defined)它只看有没有这个名字。 只要你写了 #define ENABLE_SENSOR 0#ifdef 一看“哦有这个名字” - 它就认为条件成立了。 #if它是真真正正去看数值。 只有写成 #define ENABLE_SENSOR 1#if 一看“值是 1真” - 条件才成立。 如果是 0它就是假。 结论当你想把某个功能彻底关掉变成 0时绝对不能用 #ifdef必须用 #if。实例#include stdio.h // 这里是控制台改数字试试看 #define LIGHT_ON 1 // 1开灯0关灯 #define AIR_CONDITIONER 0 // 1开空调0关空调 // int main(void) { printf( 智能家居系统启动 ); // 灯光控制 #if LIGHT_ON printf([系统] 灯光已开启); #else printf([系统] 灯光已关闭); #endif // 空调控制 #if AIR_CONDITIONER printf([系统] 空调已开启); #else printf([系统] 空调已关闭); #endif printf( 状态更新完毕 ); return 0; } 打印 智能家居系统启动 [系统] 灯光已开启[系统] 空调已关闭 状态更新完毕 #include stdio.h // 这里是控制台改数字试试看 #define LIGHT_ON 0 // 1开灯0关灯 #define AIR_CONDITIONER 1 // 1开空调0关空调 // int main(void) { printf( 智能家居系统启动 ); // 灯光控制 #if LIGHT_ON printf([系统] 灯光已开启); #else printf([系统] 灯光已关闭); #endif // 空调控制 #if AIR_CONDITIONER printf([系统] 空调已开启); #else printf([系统] 空调已关闭); #endif printf( 状态更新完毕 ); return 0; } 智能家居系统启动 [系统] 灯光已关闭[系统] 空调已开启 状态更新完毕 5. 暂时屏蔽代码#if 0 //这部分代码不会参与编译 #endif五、头文件保护假设main.c间接包含了两次LED.h头文件内容可能重复展开。标准写法是 #ifndef LED_H #define LED_H void LED_Init(void); void LED_On(void); void LED_Off(void); #endif 第一次包含时 LED_H 未定义 → 进入头文件 → 定义 LED_H → 加载头文件内容 第二次包含时 LED_H 已定义 → 跳过头文件内容 工程中建议使用更独特的名字 #ifndef BSP_LED_H #define BSP_LED_H /* 头文件内容 */ #endif六、.h和.c的分工1.h文件对外接口通常放置 函数声明 类型声明 结构体和枚举 必须公开的宏 必须公开的 extern 变量声明 #ifndef BSP_LED_H #define BSP_LED_H typedef enum { LED_OFF 0, LED_ON } LED_State_t; void LED_Init(void); void LED_Write(LED_State_t state); void LED_Toggle(void); #endif2.c文件具体实现通常放置 函数定义 私有变量 私有函数 私有宏 硬件实现细节 #include LED.h #include stm32f10x.h #define LED_GPIO GPIOC #define LED_PIN GPIO_Pin_13 static LED_State_t s_ledState LED_OFF; void LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin LED_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(LED_GPIO, GPIO_InitStructure); LED_Write(LED_OFF); } void LED_Write(LED_State_t state) { s_ledState state; if (state LED_ON) { GPIO_ResetBits(LED_GPIO, LED_PIN); // PC13低电平点亮 } else { GPIO_SetBits(LED_GPIO, LED_PIN); } } void LED_Toggle(void) { if (s_ledState LED_ON) { LED_Write(LED_OFF); } else { LED_Write(LED_ON); } } main.c 不需要知道 PC13 是高电平还是低电平点亮 #include stm32f10x.h #include Delay.h #include LED.h int main(void) { LED_Init(); while (1) { LED_Toggle(); Delay_ms(500); } } 这就是模块封装调用者只关心功能不关心内部硬件细节七、externextern用来声明“这个变量定义在其他文件”。例如在 SystemState.c 中定义 #include stdint.h uint8_t g_SystemReady 0; 在 SystemState.h 中声明 #ifndef SYSTEM_STATE_H #define SYSTEM_STATE_H #include stdint.h extern uint8_t g_SystemReady; #endif 其他文件使用 #include SystemState.h void Function(void) { g_SystemReady 1; } 要区分 extern uint8_t g_SystemReady; // 声明不分配对象存储 uint8_t g_SystemReady 0; // 定义真正创建变量 整个工程只能有一个实际定义否则链接时会出现“重复定义”。 不推荐直接在头文件中写 uint8_t g_SystemReady 0; // 容易造成多个定义 工程设计中应尽量少暴露全局变量。更好的方式是提供函数 void System_SetReady(uint8_t ready); uint8_t System_IsReady(void);八、static文件内封装在文件作用域使用static表示变量或函数只允许当前.c文件使用。static uint8_t s_ledState; 其他 .c 文件无法通过 extern 访问它。 私有函数也可以使用 static static void LED_SetHardwareLevel(uint8_t level) { /* 内部实现 */ } 这样可以明确区分 头文件中的普通函数声明 → 模块公开接口 .c中的static函数 → 模块私有实现 注意函数内部的 static 是另一种含义 void Function(void) { static uint32_t count; } 这里表示 count 具有静态存储期函数退出后不会销毁。1. 局部变量是否需要记住上次的值普通局部变量 void Function(void) { int count 0; count; } 每次进入函数都会重新创建并初始化函数退出后失效。因此每次调用后 count 都是 1。 加入 static void Function(void) { static int count 0; count; } count 只初始化一次函数退出后仍然存在 第1次调用1 第2次调用2 第3次调用3 决策规则 需要在多次函数调用之间保留数据 → 加 static 每次进入函数都应该重新开始 → 不加 static 适合使用的场景 uint32_t GetCallCount(void) { static uint32_t count 0; count; return count; } 不适合使用的场景 int Add(int a, int b) { static int result; // 没必要 result a b; return result; } 这里 result 每次都会重新计算不需要保存上一次的值普通局部变量更合适。2. 文件中的全局变量其他文件是否需要访问假设在 LED.c 中定义 uint8_t LED_State; 这个变量具有外部链接其他 .c 文件可以通过 extern 访问 extern uint8_t LED_State; 如果加上 static static uint8_t LED_State; 它只能在 LED.c 内部访问其他文件看不到。 决策规则 只在当前.c文件中使用 → 加 static 确实需要被其他.c访问 → 不加 static 实际工程中文件级变量建议默认加 static /* LED.c */ static uint8_t s_ledState; 然后通过函数操作 void LED_SetState(uint8_t state) { s_ledState state; } uint8_t LED_GetState(void) { return s_ledState; } 这样比直接公开全局变量更可靠因为其他模块不能随意修改内部状态。3. 函数它是不是模块的公开接口普通函数 void LED_On(void) { } 其他 .c 文件可以通过头文件中的声明调用它 /* LED.h */ void LED_On(void); 如果函数只服务于 LED.c 内部就应该加 static static void LED_WritePin(uint8_t level) { } 决策规则 需要被其他.c调用 → 不加 static并在.h中声明 只在当前.c内部调用 → 加 static不放到.h中 例如 /* LED.c */ static void LED_WritePin(uint8_t level) { if (level ! 0U) { GPIO_SetBits(GPIOC, GPIO_Pin_13); } else { GPIO_ResetBits(GPIOC, GPIO_Pin_13); } } void LED_On(void) { LED_WritePin(0); } void LED_Off(void) { LED_WritePin(1); } 这里 LED_On、LED_Off → 公开接口不加static LED_WritePin → 内部实现加static4.STM32实例/* Key.c */ #include Key.h static uint8_t s_lastKey; static uint16_t s_debounceCount; static uint8_t Key_ReadPin(void) { return GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1); } void Key_Init(void) { s_lastKey 0; s_debounceCount 0; } uint8_t Key_GetNum(void) { uint8_t currentKey Key_ReadPin(); /* 按键处理 */ return currentKey; }分类如下对象是否加static原因s_lastKey是只属于Key.cs_debounceCount是只属于Key.cKey_ReadPin是内部辅助函数Key_Init否需要由main.c调用Key_GetNum否需要由其他模块调用currentKey否只在一次函数调用中临时使用5.不要滥用局部 staticstatic局部变量会让函数带有隐藏状态uint8_t Filter(uint8_t input) { static uint8_t lastValue; /* 使用lastValue */ }这可能带来几个问题函数无法轻易恢复初始状态。多个设备不能共用同一个状态。递归或重复进入时可能出错。中断和主程序同时调用时可能产生竞争。FreeRTOS 多任务调用时不一定安全。如果不同设备需要独立状态更适合使用结构体 typedef struct { uint8_t lastValue; } Filter_t; uint8_t Filter_Run(Filter_t *filter, uint8_t input) { filter-lastValue input; return filter-lastValue; }九、多文件工程如何连接假设工程包含 main.c LED.c LED.h Delay.c Delay.h 编译器分别处理 main.c → main.o LED.c → LED.o Delay.c → Delay.o 链接器再把它们组合 main.o LED.o Delay.o 库文件 ↓ 最终程序 main.c 只看到 LED.h 中的声明 void LED_On(void); 链接时链接器会去 LED.o 中寻找 LED_On 的真正实现。 常见错误 找不到头文件 → include路径或文件名有问题 函数未声明 → 没有包含正确的.h文件 undefined reference / Undefined symbol → 有声明但对应.c未加入工程或没有实现 multiple definition → 同一个全局变量或函数被定义了多次十、模块接口设计一个模块应该尽量做到模块只负责一类功能。公开接口尽量少。硬件细节放在.c中。私有变量和函数使用static。不轻易公开可写全局变量。接口名称带模块前缀。参数和返回值含义明确。错误应通过返回值报告。例如串口模块 #ifndef BSP_SERIAL_H #define BSP_SERIAL_H #include stdint.h #include stddef.h void Serial_Init(uint32_t baudRate); void Serial_SendByte(uint8_t data); void Serial_SendArray(const uint8_t *data, size_t length); #endif接口使用统一前缀Serial_Init Serial_SendByte Serial_SendArray带错误处理的接口typedef enum { SENSOR_OK 0, SENSOR_ERROR_TIMEOUT, SENSOR_ERROR_NOT_FOUND } Sensor_Status_t; Sensor_Status_t Sensor_Init(void); Sensor_Status_t Sensor_Read(float *temperature); 调用者可以判断结果 if (Sensor_Read(temperature) ! SENSOR_OK) { /* 故障处理 */ }
C语言基础回顾(2026/7/20)
目录第八阶段预处理和模块化一、预处理是什么二、#include三、#define1. 定义常量2. 定义位掩码3. 带参数的宏4. 多语句宏四、条件编译1. #ifdef2. #ifndef3. #if、#elif、#else4. 功能开关5. 暂时屏蔽代码五、头文件保护六、.h 和 .c 的分工1.h 文件对外接口2.c 文件具体实现七、extern八、static 文件内封装1. 局部变量是否需要记住上次的值2. 文件中的全局变量其他文件是否需要访问3. 函数它是不是模块的公开接口4.STM32实例5.不要滥用局部 static九、多文件工程如何连接十、模块接口设计第八阶段预处理和模块化一、预处理是什么C 代码并不是直接编译的大致过程是C 代码并不是直接编译的大致过程是 源代码 ↓ 预处理处理#include、#define、条件编译 ↓ 编译转换成汇编代码 ↓ 汇编生成.o目标文件 ↓ 链接合并所有.o和库文件 ↓ 生成.axf/.elf ↓ 生成.hex/.bin 预处理发生在正式编译之前本质上以文本处理为主。二、#include#include的作用是把另一个文件的内容引入当前位置STM32 工程常见写法#include stm32f10x.h #include Delay.h #include OLED.h #include LED.h #include LED.c // 错误习惯.c文件应当分别编译模块之间通过.h文件建立联系。三、#define1. 定义常量#define LED_PIN GPIO_Pin_13 #define BUFFER_SIZE 128 #define PI 3.1415926fuint8_t buffer[BUFFER_SIZE]; 预处理阶段会进行文本替换 uint8_t buffer[128]; 宏本身通常不占用独立运行内存。实例#include stdio.h /* 1. 定义常量 */ #define LED_PIN 13 #define UART_RATE 115200 int main(void) { /* 测试 1常量 */ printf(LED_PIN %d\n, LED_PIN); printf(UART_RATE %d\n, UART_RATE); }2. 定义位掩码#define MOTOR_ENABLE (1U 0) #define MOTOR_ALARM (1U 1) 使用 status | MOTOR_ENABLE;实例#include stdio.h /* 2. 定义位掩码 */ #define BIT0 (1u 0) // 0000 0001 #define BIT3 (1u 3) // 0000 1000 #define BIT7 (1u 7) // 1000 0000 int main() { /* 测试 位掩码 */ unsigned char reg 0x00; // 模拟一个 8 位寄存器 reg | BIT3; // 打开第 3 位 - 0000 1000 reg | BIT7; // 打开第 7 位 - 1000 1000 // 所以此时 reg 的二进制是1000 1000十六进制 0x88。 /*%02X 是什么意思 %X 以十六进制大写打印 2 最少占 2 位字符宽度。 0 如果不够 2 位前面补 0。 reg 现在是 0x88已经是两位了所以直接输出0x88 */ printf(Register value 0x%02X\n, reg); /*第二句检查第 0 位是不是开着 BIT0 是define BIT0 (1u 0为二进制 0000 0001 按位与上第0位 只要第 0 位是 1结果才是非 0真现在是 0所以条件为假。输出OFF */ printf(Check BIT0: %s\n, (reg BIT0) ? ON : OFF); }3. 带参数的宏#define SQUARE(x) ((x) * (x)) 使用 result SQUARE(5); 展开后 result ((5) * (5)); 宏参数和整个表达式都应该加括号 #define SQUARE(x) ((x) * (x)) // 推荐 #define SQUARE(x) x * x // 容易出错 例如错误宏 #define SQUARE(x) x * x result SQUARE(1 2); 展开成 result 1 2 * 1 2; // 结果为5不是9 还要避免 SQUARE(i); 因为展开后 i 会执行两次。复杂逻辑更适合使用函数或 static inline 函数宏不是函数是文本替换1 #define SQUARE(x) ((x) * (x)) 你在代码里写 result SQUARE(5); 编译器在编译前就会把这句话原封不动地替换成 result ((5) * (5)); 宏不是函数不会先计算 x5 再传进去而是直接抄字符串。这一点一定要刻在脑子里。 2 为什么参数和整个表达式都要加括号#define SQUARE(x) ((x) * (x)) ❌ 错误写法 #define SQUARE(x) x * x SQUARE(1 2) 错误宏展开 result 1 2 * 1 2; C语言里 *乘比 加优先级高所以实际计算顺序是 result 1 (2 * 1) 2; 但我们想要的是 (1 2) * (1 2) 3 * 3 9 3. 最隐蔽的坑副作用Side Effect SQUARE(i); 假设 i 3展开正确宏((i) * (i)) 这里 i 的意思是“先用当前值再自增1”。但它出现了两次于是 第一次用 i3然后 i 变成 4 第二次用 i4然后 i 变成 5 计算结果可能是 3 * 4 12但 C 标准里这是未定义行为不同编译器结果可能不同甚至崩溃 你本来只想让 i 加 1 然后求平方3 * 39i 变成 4结果 i 加了两次数也算错了。 结论永远不要给宏传递带有自增、自减、赋值等有副作用的表达式。 4.嵌入式里的正解用函数或 static inline 宏这么容易写错为啥嵌入式还用因为宏没有函数调用的开销不用压栈、跳转在资源紧张的单片机上很快。 现代编译器给了我们更好的平衡方案 ①普通函数最安全 int square(int x) { return x * x; } ②static inline 函数 static inline int square(int x) { return x * x; } inline 建议编译器把函数体直接展开类似宏的速度 static 限制只在当前文件可见避免重复定义 有类型检查括号不用你操心 传 square(i) 也只会算一次 i 在 STM32 / ESP32 开发里复杂一点的计算我都推荐用 static inline 代替宏。4. 多语句宏#define LED_FLASH() \ do \ { \ LED_On(); \ Delay_ms(100); \ LED_Off(); \ } while (0) 不过初学阶段优先写成普通函数更容易调试和进行类型检查。解析一在 #define 里如果宏体里不止一条语句比如又要开灯、又要延时、又要关灯就叫多语句宏。 #define LED_FLASH() \ do \ { \ LED_On(); \ Delay_ms(100); \ LED_Off(); \ } while (0) 这里面有三条语句 LED_On(); Delay_ms(100); LED_Off(); 因为跨了多行每行末尾用了 反斜杠告诉预处理器“下一行还是宏的一部分”。 二、为什么不能直接用 { } 包起来 很多初学者会天然写成 #define LED_FLASH() { LED_On(); Delay_ms(100); LED_Off(); } 看起来没问题但一放进 if 里就炸了。 ❌ 错误示范 if (flag) LED_FLASH(); else do_something(); 宏展开后变成 if (flag) { LED_On(); Delay_ms(100); LED_Off(); }; else do_something(); 注意看} 后面有一个分号——这个分号被当成了一条独立的空语句导致 else 找不到对应的 if编译直接报错。 正确套路do { ... } while(0) 把宏写成 do { LED_On(); Delay_ms(100); LED_Off(); } while (0) 展开后 if (flag) do { LED_On(); Delay_ms(100); LED_Off(); } while (0); else do_something(); do ... while(0) 在语法上是一个完整的语句类似于循环语句后面必须跟分号。 因此它和 if / else 搭配时不会多出诡异的分号。 同时它保证宏里的多条语句被当成一个整体执行不会被 if 只“吃掉”第一条。 这就是多语句宏必须用 do { ... } while(0) 的根本原因 do { ... } while (0) 先无条件执行一次 {} 里的所有语句 再判断 while (0) —— 因为条件是假所以不再循环 do { LED_On(); // 第一句开灯 Delay_ms(100); // 第二句延时 LED_Off(); // 第三句关灯 } while (0); 三句统统执行效果是灯亮 → 等 100ms → 灯灭。 用do while 它的使命是把多语句宏伪装成一条正常语句让它在 if/else 里不翻车。四、条件编译条件编译决定某段代码是否参与编译。1.#ifdef#define DEBUG #ifdef DEBUG printf(System started\n); #endif定义了DEBUG调试代码才会被编译。在嵌入式中这通常被称为“编译开关”。它的核心作用是决定某段代码是否被交给编译器去编译。 define DEBUG // 第一步定义了一个叫 DEBUG 的开关此时开关处于“开启”状态 ifdef DEBUG // 第二步编译器检查“哎DEBUG 这个开关定义了吗” endif // 结束检查 如果你注释掉第一行 #define DEBUG那么 printf 就不会被编译程序里根本不存在这段代码。 如果你保留了 #define DEBUG那么 printf 就会被编译进固件里程序运行时会打印信息。 为什么要玩这个“开关” 在写单片机程序时硬件资源非常宝贵Flash 和 RAM 很小。 假设你写了几千行代码其中有 10 行是用来打印调试信息的。 如果不使用条件编译这 10 行代码永远留在芯片里白白占用宝贵的 Flash 空间。 使用条件编译等你要发布正式版时只需要删掉 #define DEBUG 这一行重新编译。这 10 行代码就会像变魔术一样从芯片里彻底消失完全不占地方。 条件编译不仅能用来“打开”调试信息还能用来“关闭”某些功能。 条件编译最核心的用法 用宏定义来控制代码的“生死”。在接下来的学习中你会经常看到 #ifdef 配合 #include 来管理不同芯片的头文件实例① #include stdio.h #define DEBUG // 把这行注释掉再试一次 int main(void) { #ifdef DEBUG printf( 调试信息程序正在运行...\n); #else printf( 调试信息未执行DEBUG语句\n); #endif return 0; } 打印调试信息程序正在运行... ② #include stdio.h // #define DEBUG // 把这行注释掉再试一次 int main(void) { #ifdef DEBUG printf( 调试信息程序正在运行...\n); #else printf( 调试信息未执行DEBUG语句\n); #endif return 0; } 打印调试信息未执行DEBUG语句2.#ifndef与#ifdef 刚好是相反的逻辑但在嵌入式开发中极其常用。#ifdef如果定义了If Defined→ 执行代码。#ifndef如果没有定义If Not Defined→ 执行代码。#ifndef DEBUG /* 非调试版本代码 */ #endif表示没有定义DEBUG时才编译。实例#include stdio.h // #define DEBUG // 先别取消注释跑一次再取消注释再跑一次 int main(void) { #ifndef DEBUG printf( 未定义DEBUG打印本行\n); #else printf( 定义DEBUG打印else内容\n); #endif return 0; } 输出内容 未定义DEBUG打印本行 #include stdio.h #define DEBUG // 先别取消注释跑一次再取消注释再跑一次 int main(void) { #ifndef DEBUG printf( 未定义DEBUG打印本行\n); #else printf( 定义DEBUG打印else内容\n); #endif return 0; } 输出内容定义DEBUG打印else内容3.#if、#elif、#else#define BOARD_VERSION 2 #if BOARD_VERSION 1 #define LED_PIN GPIO_Pin_0 #elif BOARD_VERSION 2 #define LED_PIN GPIO_Pin_13 #else #error Unsupported board version #endif 1. 为什么要用 #if看图说话 你发的这张图里写得很清楚“同一份程序可以适配不同版本的硬件”。 举个最实际的例子 假设你做了一个产品既卖给了客户A用的是 V1 版电路板LED 接在 Pin 0又卖给了客户B用的是 V2 版电路板LED 接在 Pin 13。 如果只用 #ifdef你得写两套代码。 但用 #if你只要改一个数字编译器就会自动帮你把代码里的引脚改成对的。同一份程序可以适配不同版本的硬件。这一节的内容非常硬核也是真正能让你写出“万能代码”的秘诀。简单来说#ifdef只是问“有没有这个开关”而#if是问“这个开关的数值是多少”。实例先听懂#if 是干什么的 之前学的 #ifdef 和 #ifndef 只能判断“有没有定义”这个开关。 而 #if 厉害在它能判断“定义的值等于多少”就像程序里的 if 语句但它是编译前就定好的。 #if 表达式如果表达式为真编译这段 #elif否则如果增加分支 #else否则 #error如果前面都没中直接让编译器报错罢工防止乱填参数 #include stdio.h #define SPEED 2 int main(void) { #if SPEED 1 printf(低速模式\n); #elif SPEED 2 printf(中速模式\n); #elif SPEED 3 printf(高速模式\n); #else #error SPEED 只能填 1、2 或 3 #endif return 0; } 输出中速模式4. 功能开关#define ENABLE_MOTOR 1 #define ENABLE_SENSOR 0 #if ENABLE_MOTOR Motor_Init(); #endif #if ENABLE_SENSOR Sensor_Init(); #endif 如果宏可能被定义成 0应该使用 #if ENABLE_SENSOR 而不是 #ifdef ENABLE_SENSOR 因为即使写成 #define ENABLE_SENSOR 0它依然属于“已经定义”。 1. 它是什么为什么要用它 在写大型程序尤其是单片机、嵌入式时我们通常希望一份代码能搞定所有事情而不是为了做个新功能就重新拷贝一份代码去修改。 这时候就需要一个“总控制台”。 我想用电机把开关拨到 1。 我想用传感器把开关拨到 1。 我不想用蓝牙了把开关拨到 0。 #if 配合 #define 定义的 1 或 0就是这个控制台。 2. 必须注意的“坑” 这部分最重要的就是图中红字强调的陷阱 #ifdef (或者 #if defined)它只看有没有这个名字。 只要你写了 #define ENABLE_SENSOR 0#ifdef 一看“哦有这个名字” - 它就认为条件成立了。 #if它是真真正正去看数值。 只有写成 #define ENABLE_SENSOR 1#if 一看“值是 1真” - 条件才成立。 如果是 0它就是假。 结论当你想把某个功能彻底关掉变成 0时绝对不能用 #ifdef必须用 #if。实例#include stdio.h // 这里是控制台改数字试试看 #define LIGHT_ON 1 // 1开灯0关灯 #define AIR_CONDITIONER 0 // 1开空调0关空调 // int main(void) { printf( 智能家居系统启动 ); // 灯光控制 #if LIGHT_ON printf([系统] 灯光已开启); #else printf([系统] 灯光已关闭); #endif // 空调控制 #if AIR_CONDITIONER printf([系统] 空调已开启); #else printf([系统] 空调已关闭); #endif printf( 状态更新完毕 ); return 0; } 打印 智能家居系统启动 [系统] 灯光已开启[系统] 空调已关闭 状态更新完毕 #include stdio.h // 这里是控制台改数字试试看 #define LIGHT_ON 0 // 1开灯0关灯 #define AIR_CONDITIONER 1 // 1开空调0关空调 // int main(void) { printf( 智能家居系统启动 ); // 灯光控制 #if LIGHT_ON printf([系统] 灯光已开启); #else printf([系统] 灯光已关闭); #endif // 空调控制 #if AIR_CONDITIONER printf([系统] 空调已开启); #else printf([系统] 空调已关闭); #endif printf( 状态更新完毕 ); return 0; } 智能家居系统启动 [系统] 灯光已关闭[系统] 空调已开启 状态更新完毕 5. 暂时屏蔽代码#if 0 //这部分代码不会参与编译 #endif五、头文件保护假设main.c间接包含了两次LED.h头文件内容可能重复展开。标准写法是 #ifndef LED_H #define LED_H void LED_Init(void); void LED_On(void); void LED_Off(void); #endif 第一次包含时 LED_H 未定义 → 进入头文件 → 定义 LED_H → 加载头文件内容 第二次包含时 LED_H 已定义 → 跳过头文件内容 工程中建议使用更独特的名字 #ifndef BSP_LED_H #define BSP_LED_H /* 头文件内容 */ #endif六、.h和.c的分工1.h文件对外接口通常放置 函数声明 类型声明 结构体和枚举 必须公开的宏 必须公开的 extern 变量声明 #ifndef BSP_LED_H #define BSP_LED_H typedef enum { LED_OFF 0, LED_ON } LED_State_t; void LED_Init(void); void LED_Write(LED_State_t state); void LED_Toggle(void); #endif2.c文件具体实现通常放置 函数定义 私有变量 私有函数 私有宏 硬件实现细节 #include LED.h #include stm32f10x.h #define LED_GPIO GPIOC #define LED_PIN GPIO_Pin_13 static LED_State_t s_ledState LED_OFF; void LED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Pin LED_PIN; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(LED_GPIO, GPIO_InitStructure); LED_Write(LED_OFF); } void LED_Write(LED_State_t state) { s_ledState state; if (state LED_ON) { GPIO_ResetBits(LED_GPIO, LED_PIN); // PC13低电平点亮 } else { GPIO_SetBits(LED_GPIO, LED_PIN); } } void LED_Toggle(void) { if (s_ledState LED_ON) { LED_Write(LED_OFF); } else { LED_Write(LED_ON); } } main.c 不需要知道 PC13 是高电平还是低电平点亮 #include stm32f10x.h #include Delay.h #include LED.h int main(void) { LED_Init(); while (1) { LED_Toggle(); Delay_ms(500); } } 这就是模块封装调用者只关心功能不关心内部硬件细节七、externextern用来声明“这个变量定义在其他文件”。例如在 SystemState.c 中定义 #include stdint.h uint8_t g_SystemReady 0; 在 SystemState.h 中声明 #ifndef SYSTEM_STATE_H #define SYSTEM_STATE_H #include stdint.h extern uint8_t g_SystemReady; #endif 其他文件使用 #include SystemState.h void Function(void) { g_SystemReady 1; } 要区分 extern uint8_t g_SystemReady; // 声明不分配对象存储 uint8_t g_SystemReady 0; // 定义真正创建变量 整个工程只能有一个实际定义否则链接时会出现“重复定义”。 不推荐直接在头文件中写 uint8_t g_SystemReady 0; // 容易造成多个定义 工程设计中应尽量少暴露全局变量。更好的方式是提供函数 void System_SetReady(uint8_t ready); uint8_t System_IsReady(void);八、static文件内封装在文件作用域使用static表示变量或函数只允许当前.c文件使用。static uint8_t s_ledState; 其他 .c 文件无法通过 extern 访问它。 私有函数也可以使用 static static void LED_SetHardwareLevel(uint8_t level) { /* 内部实现 */ } 这样可以明确区分 头文件中的普通函数声明 → 模块公开接口 .c中的static函数 → 模块私有实现 注意函数内部的 static 是另一种含义 void Function(void) { static uint32_t count; } 这里表示 count 具有静态存储期函数退出后不会销毁。1. 局部变量是否需要记住上次的值普通局部变量 void Function(void) { int count 0; count; } 每次进入函数都会重新创建并初始化函数退出后失效。因此每次调用后 count 都是 1。 加入 static void Function(void) { static int count 0; count; } count 只初始化一次函数退出后仍然存在 第1次调用1 第2次调用2 第3次调用3 决策规则 需要在多次函数调用之间保留数据 → 加 static 每次进入函数都应该重新开始 → 不加 static 适合使用的场景 uint32_t GetCallCount(void) { static uint32_t count 0; count; return count; } 不适合使用的场景 int Add(int a, int b) { static int result; // 没必要 result a b; return result; } 这里 result 每次都会重新计算不需要保存上一次的值普通局部变量更合适。2. 文件中的全局变量其他文件是否需要访问假设在 LED.c 中定义 uint8_t LED_State; 这个变量具有外部链接其他 .c 文件可以通过 extern 访问 extern uint8_t LED_State; 如果加上 static static uint8_t LED_State; 它只能在 LED.c 内部访问其他文件看不到。 决策规则 只在当前.c文件中使用 → 加 static 确实需要被其他.c访问 → 不加 static 实际工程中文件级变量建议默认加 static /* LED.c */ static uint8_t s_ledState; 然后通过函数操作 void LED_SetState(uint8_t state) { s_ledState state; } uint8_t LED_GetState(void) { return s_ledState; } 这样比直接公开全局变量更可靠因为其他模块不能随意修改内部状态。3. 函数它是不是模块的公开接口普通函数 void LED_On(void) { } 其他 .c 文件可以通过头文件中的声明调用它 /* LED.h */ void LED_On(void); 如果函数只服务于 LED.c 内部就应该加 static static void LED_WritePin(uint8_t level) { } 决策规则 需要被其他.c调用 → 不加 static并在.h中声明 只在当前.c内部调用 → 加 static不放到.h中 例如 /* LED.c */ static void LED_WritePin(uint8_t level) { if (level ! 0U) { GPIO_SetBits(GPIOC, GPIO_Pin_13); } else { GPIO_ResetBits(GPIOC, GPIO_Pin_13); } } void LED_On(void) { LED_WritePin(0); } void LED_Off(void) { LED_WritePin(1); } 这里 LED_On、LED_Off → 公开接口不加static LED_WritePin → 内部实现加static4.STM32实例/* Key.c */ #include Key.h static uint8_t s_lastKey; static uint16_t s_debounceCount; static uint8_t Key_ReadPin(void) { return GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1); } void Key_Init(void) { s_lastKey 0; s_debounceCount 0; } uint8_t Key_GetNum(void) { uint8_t currentKey Key_ReadPin(); /* 按键处理 */ return currentKey; }分类如下对象是否加static原因s_lastKey是只属于Key.cs_debounceCount是只属于Key.cKey_ReadPin是内部辅助函数Key_Init否需要由main.c调用Key_GetNum否需要由其他模块调用currentKey否只在一次函数调用中临时使用5.不要滥用局部 staticstatic局部变量会让函数带有隐藏状态uint8_t Filter(uint8_t input) { static uint8_t lastValue; /* 使用lastValue */ }这可能带来几个问题函数无法轻易恢复初始状态。多个设备不能共用同一个状态。递归或重复进入时可能出错。中断和主程序同时调用时可能产生竞争。FreeRTOS 多任务调用时不一定安全。如果不同设备需要独立状态更适合使用结构体 typedef struct { uint8_t lastValue; } Filter_t; uint8_t Filter_Run(Filter_t *filter, uint8_t input) { filter-lastValue input; return filter-lastValue; }九、多文件工程如何连接假设工程包含 main.c LED.c LED.h Delay.c Delay.h 编译器分别处理 main.c → main.o LED.c → LED.o Delay.c → Delay.o 链接器再把它们组合 main.o LED.o Delay.o 库文件 ↓ 最终程序 main.c 只看到 LED.h 中的声明 void LED_On(void); 链接时链接器会去 LED.o 中寻找 LED_On 的真正实现。 常见错误 找不到头文件 → include路径或文件名有问题 函数未声明 → 没有包含正确的.h文件 undefined reference / Undefined symbol → 有声明但对应.c未加入工程或没有实现 multiple definition → 同一个全局变量或函数被定义了多次十、模块接口设计一个模块应该尽量做到模块只负责一类功能。公开接口尽量少。硬件细节放在.c中。私有变量和函数使用static。不轻易公开可写全局变量。接口名称带模块前缀。参数和返回值含义明确。错误应通过返回值报告。例如串口模块 #ifndef BSP_SERIAL_H #define BSP_SERIAL_H #include stdint.h #include stddef.h void Serial_Init(uint32_t baudRate); void Serial_SendByte(uint8_t data); void Serial_SendArray(const uint8_t *data, size_t length); #endif接口使用统一前缀Serial_Init Serial_SendByte Serial_SendArray带错误处理的接口typedef enum { SENSOR_OK 0, SENSOR_ERROR_TIMEOUT, SENSOR_ERROR_NOT_FOUND } Sensor_Status_t; Sensor_Status_t Sensor_Init(void); Sensor_Status_t Sensor_Read(float *temperature); 调用者可以判断结果 if (Sensor_Read(temperature) ! SENSOR_OK) { /* 故障处理 */ }