C进阶
static struct
{
uint8_t buf[ATK_MW8266D_UART_RX_BUF_SIZE]; /* 接收缓冲区 */
struct
{
uint16_t len : 15; /* 当前接收长度(15 位) */
uint16_t finsh : 1; /* 接收完成标志(1 位) */
} sta; /* 状态位域 */
} g_uart_rx_frame = {0}; /* 接收帧结构体初始化 */预处理,编译,汇编,链接
第1步:预处理(Preprocessing)纯文本替换游戏,没有任何"编译"发生
预处理的本质:宏展开、头文件展开、条件编译(#ifdef)的裁剪、注释删除,纯粹是文本层面的操作,编译器这时候压根不懂什么是"变量类型"、"函数",只是个高级版的查找替换。头文件被展开了,几千行的内容
gcc -o main.i -E main.c
SEEKEND宏被替换成了2
第2步:编译(Compilation)—— 从C代码变成汇编代码
gcc -o main.s -S main.c这一步是真正的"编译":语法检查、语义检查(类型对不对、函数有没有声明)、生成对应的CPU指令。那些东西看到的汇编就是这一步产出来的。这时候还是纯文本文件,能用文本编辑器直接打开看。

第3步:汇编(Assembly)—— 从汇编代码变成机器码(二进制目标文件)
gcc -o main.o -c main.c用nm看这个目标文件里有哪些符号(函数名、变量名)
nm main.o
关键点来了:printf标记为U(undefined),因为printf的真正实现在libc库里,这个.o文件里只有一个"我要用这个符号,麻烦谁把它的地址填进来"的占位——这正是为什么这一步还不能直接运行,main.o是不完整的,缺一块。
第4步:链接(Linking)—— 把所有目标文件+库函数拼起来,补全所有地址
1.动态
gcc -o exe main.c我的系统默认用的是动态链接,printf这些函数压根没有在链接阶段被真正解析进你的可执行文件里,它们的地址是运行时才由动态链接器去找的。这也是为什么两份nm结果长得几乎一模一样。

确实是动态链接

libc.so.6就是标准C库的共享库(动态库)——printf、open、read的真正实现代码都在这个文件里,不在你的main里。

运行你的程序时,操作系统先加载main,发现里面调printf的地方是空的,于是加载/lib64/ld-linux-x86-64.so.2这个动态链接器,由它在程序真正开始跑之前,去libc.so.6里找到printf真正的地址,临时"打补丁"填进去——这个过程叫运行时重定位,跟之前理解的"链接阶段把地址焊死"是两码事
2.静态
链接把所有东西焊死
gcc -static -o main_static main.c文件体积暴涨50倍,因为这次链接器是真刀真枪地把printf、open、read……整个libc用到的那部分代码原样拷贝塞进了main_static这个文件里,不再是"运行时找libc.so要",而是"编译期直接焊死在自己身上"


static
总结建议: 在驱动开发中,除了要在 .h 暴露给外面的变量(必须声明为 extern)外,其他的中间缓存变量都应无脑加上 static。
在单片机开发中,全局变量名前面加 static 相当于给它穿了一件“隐身衣”。
- 封装性(防止重名冲突):如果不加 static,这个变量就是一个“公有全局变量”。如果你的工程里有另一个驱动文件(比如蓝牙模块)也定义了一个 g_uart_rx_frame,编译器在链接阶段就会报 Symbol multiply defined 的错误。加了 static 后,这个变量只在当前 .c 文件可见。其他文件看不见它,你可以在不同的驱动模块里随心所欲地命名而不担心干扰。这叫“模块化编程”。
- 固定内存地址:static 全局变量存放在内存的 .bss(初始值为 0 时)或 .data(手动初值时)区。生命周期是永久的,从程序启动直到掉电。这确保了在串口中断、定时器轮询等异步场景下,数据的存储位置始终固定,不会像函数内部的局部变量(住在 Stack 栈区)那样随函数结束而销毁。
二级指针
二级指针就是一个普通变量,只不过它存的是 另一个指针 (一级指针)的地址,解引用是 存的另一个指针 (一级指针)的指向
使用场景:
- 函数中修改指针本身:如果要在函数内让传入的指针指向新的内存,需要传二级指针
void alloc(int **pp) {
*pp = malloc(sizeof(int));
//二级指针解引用,更改一级指针的指向
}
int *p = NULL;//一级指针
alloc(&p); // p现在指向新分配的内存- 二维数组/指针数组:
char **argv就是字符串数组(指针的数组) - 链表操作:修改头指针时传二级指针
核心理解:一级指针操作变量的值,二级指针操作一级指针的指向。
volatile
volatile在寄存器映射中的作用是阻止编译器优化对寄存器的读写。
举个例子:
volatile uint32_t *status_reg = (uint32_t*)0x40001000;
while(*status_reg & 0x01 == 0); // 等待就绪位如果不加volatile,编译器发现循环体没改*status_reg,会把读取优化到循环外面,导致死循环。加了volatile后,每次循环都会真正去读硬件寄存器的值。
编译器看不到内存里具体的值,感知不到外界对内存里的变化。它当前文件里发现这个*status_reg 没有改动的代码,自作聪明把这个值放在CPU寄存器里,以后都从CPU里面拿走,优化成了while(1),就成死循环了。
位域联合体
struct {
uint16_t len : 15; /* 当前接收长度,占据 15 位 */
uint16_t finsh : 1; /* 接收完成标志,占据 1 位 */
} sta;- 正常情况下: 一个 uint16_t 是 16 个 bit(2 字节)。
- 冒号写法: 它告诉编译器:“我要把这 16 个 bit 切割开用”。len 占用了低 15 位:它的最大值是
215−1=32767215−1=32767。finsh 占用了最高 1 位:它只有 0 或 1。 - 物理体积: 整个 sta 结构体一共只占 2 个字节(16 位)。
老写法
#define TX_ENABLE_BIT (1 << 0)
#define RX_ENABLE_BIT (1 << 1)
#define PARITY_MASK (0x3 << 2)
uint8_t reg = 0;
reg |= TX_ENABLE_BIT; // 打开发送
reg |= RX_ENABLE_BIT; // 打开接收
reg = (reg & ~PARITY_MASK) | (2 << 2); // 设置校验位,还得先清零再设置,容易漏掉&~这一步改进
reg.bits.tx_enable = 1;
reg.bits.rx_enable = 1;
reg.bits.parity = 2; // 编译器自动帮你处理移位和清零,不用记住每个bit在哪一位大例子
typedef union {
uint16_t reg; // 整体访问,比如直接写入硬件地址
struct {
uint16_t pin_mode : 2; // bit0-1: 输入/输出/复用/模拟
uint16_t pin_speed : 2; // bit2-3: 速度等级
uint16_t pull_updown: 2; // bit4-5: 上拉/下拉/浮空
uint16_t output_type: 1; // bit6: 推挽/开漏
uint16_t reserved : 9; // bit7-15: 保留
} bit;
} GPIO_CONFIG_Reg;
// 假设这个寄存器映射到真实硬件地址0x40020000(用之前学过的volatile+指针强转)
#define GPIOA_CONFIG (*(volatile GPIO_CONFIG_Reg *)0x40020000)
void gpio_init() {
GPIOA_CONFIG.reg = 0; // 整体清零,一条指令搞定
GPIOA_CONFIG.bit.pin_mode = 1; // 设成输出模式,不影响其他位
GPIOA_CONFIG.bit.output_type = 0; // 推挽输出
GPIOA_CONFIG.bit.pin_speed = 3; // 最高速度
}
void gpio_quick_backup_restore() {
uint16_t saved = GPIOA_CONFIG.reg; // 整体备份
GPIOA_CONFIG.bit.pin_mode = 0; // 临时改成输入模式做点什么
// ... 读取输入 ...
GPIOA_CONFIG.reg = saved; // 恢复原配置,一条指令
}用来在编译时计算结构体成员的大小,不需要创建实际的对象
if (len > sizeof(((FlashRecord *)0)->data)) len = sizeof(((FlashRecord *)0)->data);第一步:(FlashRecord *)0
意思: 把地址 0 强制转换为 FlashRecord 指针
- 不是真的在地址 0 创建对象
- 只是"假装"有一个
FlashRecord在地址 0
类比:
第二步:((FlashRecord *)0)->data
((FlashRecord *)0)->data
意思: 访问这个"假想的"结构体的 data 成员
- 指向地址 3(因为 flag 占 1 字节,len 占 2 字节)
data是一个数组char[253]
第三步:sizeof(...)
意思: 计算这个成员的大小
sizeof(char[253])= 253 字节- 编译器在编译时就能确定,不需要运行代码
为什么要这样做?
场景: 你已经有了结构体定义,想获取某个成员的大小
❌ 老土方法(不推荐)
// 方法 1: 创建一个临时实例(浪费栈空间) FlashRecord rec; int max_len = sizeof(rec.data); // ✗ 占用栈空间 // 方法 2: 硬编码(容易出错,维护困难) int max_len = 253; // ✗ 如果改了结构体要改这里
✅ 正确方法(推荐)
优点:
- 编译时计算,零运行时开销
- 结构体改了,这里自动适应
- 代码清晰,表明意图(获取 data 成员的大小)
// 写法2:更常见更清爽的做法,直接定义个宏
#define MEMBER_SIZE(type, member) sizeof(((type *)0)->member)
if (len > MEMBER_SIZE(FlashRecord, data))
len = MEMBER_SIZE(FlashRecord, data);#与##
#(字符串化):把宏参数转为字符串字面量。
#define STR(x) #x
STR(hello) // → "hello"##(连接/粘贴):把两个token拼接成一个。
#define CONCAT(a, b) a##b
CONCAT(var, 1) // → var1实际应用:
#define REG(name) (*(volatile uint32_t*)name##_ADDR)
REG(GPIOA) // → (*(volatile uint32_t*)GPIOA_ADDR)
#define ASSERT_MSG(cond) do { if(!(cond)) printf("Assert: " #cond); } while(0)嵌入式中常用于寄存器宏生成、调试断言、代码生成。
逗号运算符
逗号运算符(,)的规则很简单:从左到右依次求值,整个表达式的值是最后一个操作数的值,前面的操作数只是"顺带执行副作用"用的。
比较难理解的例子
#define SWAP(a, b, t) (t = a, a = b, b = t)
int x = 1, y = 2, tmp;
int result = SWAP(x, y, tmp); // x=2, y=1, result = tmp的旧值(1),因为逗号表达式返回最后一个tmp=(A,B,C); A,B的操作不过是顺带的,把C操作完了,整个表达式返回的是C的值
比较容易理解的
#define LOG_AND_RETURN(msg, code) (printf("%s\n", msg), code)
int func() {
return LOG_AND_RETURN("error occurred", -1);
// 展开成: return (printf("error occurred\n"), -1);
// 先打印,再返回-1
}#define ASSERT_LOG(cond, msg) \
((cond) ? (void)0 : (printf("Assert failed: %s\n", msg), abort()))#define DBG_EVAL(expr) (printf(#expr " = "), printf("%d\n", (expr)), (expr))
int result = DBG_EVAL(3 + 4);
// 先打印 "3 + 4 = ",再打印 "7",最后result还是拿到7条件 cond 为真时什么都不做,为假时打印错误信息并终止程序
#define ASSERT_LOG(cond, msg) \
((cond) ? (void)0 : (printf("Assert failed: %s\n", msg), abort()))attribute
__attribute__是GCC(以及Clang,兼容GCC语法)提供的编译器扩展关键字,用来给变量、函数、类型附加额外的"元信息",告诉编译器做特殊处理
说明:
__attribute__((属性名))
__attribute__((属性名(参数)))
__attribute__((属性1, 属性2, ...)) // 可以逗号分隔写多个放的位置一般紧跟在声明后面(变量名后、函数括号后、结构体}后)。
用法:
1.内存对齐控制(常用)
struct Frame {
uint8_t cmd;
uint32_t timestamp;
} __attribute__((packed)); // 取消对齐填充,紧凑排列
struct Buffer {
uint8_t data[64];
} __attribute__((aligned(32))); // 强制32字节对齐,常用于DMA/cache line2.unused —— 消除"未使用变量/函数"的警告
void isr_handler(int __attribute__((unused)) irq_num) {
// irq_num参数在这个中断处理函数里没用到,但函数签名要求必须有
// 加了unused,编译器不会报 "unused parameter" 警告
handle_interrupt();
}
static int __attribute__((unused)) debug_flag = 0; // 调试用变量,正式发布时不删但不想看警告3.weak —— 弱符号,允许被其他文件的同名函数覆盖
STM32/裸机启动文件的经典套路——启动文件给所有中断向量先定义一份"弱"的默认实现(通常是死循环占位),用户在自己的驱动文件里正常定义同名函数,链接器优先选非weak的版本,不用改动启动文件就能实现中断处理函数。
// startup.c 里的默认中断处理函数
void __attribute__((weak)) UART_IRQHandler(void) {
while(1); // 默认死循环,占个位置
}
// 用户在自己的 uart_driver.c 里写同名函数,会自动覆盖上面那个weak版本
void UART_IRQHandler(void) {
// 真正的中断处理逻辑
}4.section —— 强制把变量/函数放到指定的内存段(常用)
// 常见于Bootloader/Flash分区场景,你之前也接触过Flash相关的代码
const uint32_t app_version __attribute__((section(".app_version"))) = 0x00010001;
void isr_vector_table(void) __attribute__((section(".isr_vector")));assert宏 Release版本
assert是调试用的断言宏:
#include <assert.h>
assert(ptr != NULL); // 条件为假时终止程序并打印信息- 条件为假时调用
abort()终止程序 - 定义
NDEBUG宏后assert被完全移除(零开销)
例子
#include <assert.h>
#include <stdio.h>
void set_speed(int *ptr, int speed) {
assert(ptr != NULL); // 断言
*ptr = speed;
}
int main() {
int speed = 100;
set_speed(&speed, 50); // 正常,ptr非空
printf("speed = %d\n", speed);
set_speed(NULL, 50); // 触发断言!
return 0;
}运行到set_speed(NULL, 50)时,程序会打印类似:
speed = 50
test.c:6: set_speed: Assertion `ptr != NULL' failed.
Aborted (core dumped)注意信息很详细:文件名、行号、函数名、断言表达式的原始文本全都打印出来了——这就是为什么assert比if (ptr == NULL) return;更适合快速调试定位问题,一眼就知道哪个文件哪一行出的问题,不用自己写打印语句。
底层
if (!(ptr != NULL)) {
fprintf(stderr, "%s:%d: %s: Assertion `%s' failed.\n", __FILE__, __LINE__, __func__, "ptr != NULL");
abort();
}在release版本下,在编译的时候会把断言优化掉,不会直接终止程序。
思路:记录错误日志(方便事后分析),然后走一条尽量安全的兜底逻辑(比如返回错误码、进入安全模式),而不是直接终止整个程序。
为什么这样设计:嵌入式设备(比如工业控制器、汽车ECU)Release版本直接abort()是很危险的——如果设备正在控制电机、刹车、阀门,程序突然abort死机,可能比"带着小bug继续跑"更糟糕。
Release版本处理:
- 编译时加
-DNDEBUG,所有assert变成空操作 - 或者自定义assert宏,Release版记录日志而非终止
嵌入式中的实践:Debug版用assert快速定位bug,Release版用错误码+日志替代。
const
嵌入式中 const 全局变量会被放到 Flash(.rodata段) 节省 RAM
1. 修饰变量——只读,编译期就会拦截修改
const int a = 10;
a = 20; // 编译报错!error: assignment of read-only variable 'a'2. 修饰指针——用你之前问过的"从右往左读"最直观
const int *p —— 指向的值不能改(const修饰的是int,不是p)指针常量:指向常量的指针
int x = 10, y = 20;
const int *p = &x;
*p = 100; // 错误!不能通过p修改它指向的值
p = &y; // 正确!p本身是个普通指针变量,可以指向别的地方
printf("%d\n", *p); // 20int * const p —— 指针本身不能改(const离p最近,锁死p这个指针变量)常量指针:该指针指向的位置不能动
int x = 10, y = 20;
int * const p = &x;
*p = 100; // 正确!可以通过p修改它指向的值
printf("%d\n", x); // 100
p = &y; // 错误!p被const锁死,不能再指向别处const int * const p —— 两者都锁死
int x = 10;
const int * const p = &x;
*p = 100; // 错误
p = &y; // 错误
// p唯一能做的就是只读访问 *p
printf("%d\n", *p); // 10,只能读记忆口诀const修饰离它最近的那个东西——挨着*p前面就是"指向的内容不能改",挨着p本身(即*和p之间没有别的类型词隔开)就是"指针不能改"。
3. 修饰函数参数——承诺不改传入数据,编译器帮你把关
void print_string(const char *s) {
printf("%s\n", s);
s[0] = 'X'; // 编译报错!不能通过s修改指向的字符串内容
}
int main() {
char buf[] = "hello";
print_string(buf);
}实际价值:调用者一看函数签名const char *s就知道"这个函数只会读我的数据,不会偷偷改",不用去翻函数实现就能放心传参——strlen、strcpy的源参数都是这么声明的:
size_t strlen(const char *s); // 只读,不改
char *strcpy(char *dest, const char *src); // dest会被改,src只读,一看签名就懂4. 修饰函数返回值——防止调用者意外改坏内部数据
typedef struct {
char name[32];
} Device;
Device dev = {"UART1"};
const char* get_device_name() {
return dev.name; // 返回内部数据的指针,但用const保护,不让外部随便改
}
int main() {
const char *name = get_device_name();
printf("%s\n", name); // 正常读取: UART1
name[0] = 'X'; // 编译报错!外部不能通过这个返回值篡改内部的dev.name
}5. C++成员函数const——承诺不修改对象状态,编译器强制检查
class Sensor {
private:
int value;
public:
Sensor(int v) : value(v) {}
int getValue() const { // 承诺:这个函数不会修改成员变量
return value;
}
void setValue(int v) { // 没有const,说明这个函数会修改状态
value = v;
}
};编译器真的会检查你的承诺:
class Sensor {
private:
int value;
public:
int getValue() const {
value = 100; // 编译报错!const函数里不能修改成员变量
return value;
}
};const对象只能调用const成员函数——这是const最有实际约束力的地方:
void printSensor(const Sensor &s) { // 传进来的是const引用
printf("%d\n", s.getValue()); // 正确!getValue是const函数,const对象能调
s.setValue(50); // 错误!setValue不是const函数,const对象不能调
}
int main() {
Sensor sen(10);
printSensor(sen);
}这就是为什么"只读取不修改"的函数一定要加const——不加的话,别人传一个const Sensor&进你的函数时,压根编译不过,你的接口直接没法用在只读场景。
memcpy 的三层策略
- 字节拷贝:处理首尾不对齐的部分,一次拷贝 1 字节
- 字对齐拷贝:地址对齐后,按 4 字节(32位)或 8 字节(64位) 为单位拷贝,利用 CPU 一次读写一个字的能力,效率大幅提升
- 大块拷贝/SIMD 优化:数据量大时使用 NEON/SSE 等 SIMD 指令,一次拷贝 16/32 字节
用真实地址演示三层策略怎么运作
假设:
src地址 = 0x1003 (不是4的倍数,未对齐!)
dest地址 = 0x2001 (也不对齐)
拷贝长度 = 99字节第1层:字节拷贝——先啃掉"头部不对齐"的部分
// 目标:把src从0x1003挪到下一个4字节对齐地址0x1004
// 0x1004 - 0x1003 = 1,所以先拷1个字节
*dest = *src; // 逐字节,拷1字节
dest++; src++; // src现在指向0x1004,对齐了!dest指向0x2002拷完这1个字节后,src来到了0x1004(4的倍数,对齐),剩余 99 - 1 = 98 字节要拷。
第2层:字对齐拷贝——中间大段用4字节/8字节整存整取
CPU单次访问的头,是地址总线的倍数。数据在头那里才方便CPU操作
// 98字节里,能凑多少个完整的4字节? 98 / 4 = 24个,余2字节
int *src_word = (int*)src; // src现在是0x1004,正好是int指针的合法对齐地址
int *dest_word = (int*)dest;
for (int i = 0; i < 24; i++) {
dest_word[i] = src_word[i]; // 一条指令搬4字节,而不是4条指令各搬1字节!
}
src += 24 * 4; // src前进96字节,现在指向 0x1004+96 = 0x1064
dest += 24 * 4; // dest同理前进96字节这一步就是效率暴涨的关键——CPU执行mov eax, [src] 这一条汇编指令,总线一次就能把4字节全部读进寄存器,再一条mov [dest], eax写出去,两条指令搬完4字节,比逐字节的8条指令(4次读+4次写)快了4倍。
拷完这24组后,98 - 96 = 2字节还剩着没拷。
第3层:尾部剩余字节——凑不成整字的部分,退回逐字节拷贝
// 剩下2字节,不够凑一个int(4字节),只能老老实实一个个拷
*dest = *src; dest++; src++; // 第98字节
*dest = *src; dest++; src++; // 第99字节完整拼起来对应"三层策略"
void *my_memcpy(void *dest, const void *src, size_t n) {
char *d = (char*)dest;
const char *s = (const char*)src;
// === 第1层:字节拷贝,处理头部不对齐 ===
while (((uintptr_t)s % 4 != 0) && n > 0) {
*d++ = *s++;
n--;
}
// === 第2层:字对齐拷贝,中间大段4字节一次搬 ===
int *d4 = (int*)d;
const int *s4 = (const int*)s;
while (n >= 4) {
*d4++ = *s4++;
n -= 4;
}
d = (char*)d4;
s = (const char*)s4;
// === 第3层:尾部剩余字节,逐字节收尾 ===
while (n > 0) {
*d++ = *s++;
n--;
}
return dest;
}用你的99字节例子跑一遍这段代码:
- 头部:拷1字节(凑齐对齐),n从99变98
- 中间:24次循环,每次4字节,n从98变2
- 尾部:拷2字节,n从2变0
3次不同粒度的操作,加起来正好覆盖99字节,而且中间那24次是"批发价"。
流程:先按字节处理到对齐边界 → 中间大段用字/SIMD 拷贝 → 尾部剩余字节再逐字节处理。
另外 memcpy 不处理内存重叠,重叠场景要用 memmove(它会判断拷贝方向)。在嵌入式中还可以用 DMA 做大块内存拷贝。
// 伪代码,具体寄存器配置因芯片而异(比如STM32的DMA)
DMA_Config dma;
dma.src_addr = (uint32_t)src_buffer;
dma.dest_addr = (uint32_t)dest_buffer;
dma.length = 1024 * 1024; // 1MB
DMA_Start(&dma);
// CPU发起这个请求后就可以去干别的事了,DMA控制器(独立于CPU的硬件电路)
// 会在总线上直接把数据从src地址硬件级别地搬到dest地址,全程不占用CPU的运算资源
while (!DMA_IsComplete()) {
// CPU可以在这里跑别的任务,甚至进入低功耗模式等中断唤醒
}
