第320篇 嵌入式C编程实践
2026/9/1 7:54:05 网站建设 项目流程

上篇聊了ARM架构的基础。这篇聊嵌入式C编程的实践——在资源受限的MCU上写C代码,跟桌面或服务器开发有很多不同。这些差异不是语法层面的,而是思维方式的。

嵌入式C编程的核心约束是:内存有限(几十KB到几百KB)、没有操作系统(或只有RTOS)、硬件直接可控、可靠性要求极高。这些约束决定了你的编码方式。

内存管理

嵌入式中最重要的一条规则:避免动态内存分配。malloc/free在嵌入式中是危险的——内存碎片可能导致运行时分配失败,分配时间不确定影响实时性,内存泄漏在长期运行的系统中是致命的。

替代方案是静态分配。程序启动时分配好所有需要的内存,运行时只做读写操作。

// 静态环形缓冲区(不用malloc) #define BUF_SIZE 256 typedef struct { uint8_t data[BUF_SIZE]; volatile uint16_t head; // volatile因为ISR和主循环都访问 volatile uint16_t tail; } RingBuffer; static RingBuffer rx_buf; // 静态分配 void ring_push(RingBuffer *buf, uint8_t byte) { buf->data[buf->head] = byte; buf->head = (buf->head + 1) % BUF_SIZE; } uint8_t ring_pop(RingBuffer *buf) { uint8_t byte = buf->data[buf->tail]; buf->tail = (buf->tail + 1) % BUF_SIZE; return byte; }

如果需要"动态"的行为,可以用内存池(memory pool)。预先分配一组固定大小的块,需要时从空闲列表中取一个,释放时还回去。没有碎片问题,分配时间是O(1)常数。FreeRTOS的heap_2(不定长但无碎片回收)和heap_4(定长块管理)就是这种思路。实际项目中,我见过最优雅的做法是:在链接脚本中定义一个内存区域,启动时按固定块大小(比如32/64/128/256字节)划分为多个池,每个池管理一种尺寸。申请时根据大小选池,完全确定性。

位操作

嵌入式编程中位操作无处不在——配置寄存器、解析协议、控制GPIO,都需要精确地操作单个位。

// 常用位操作宏 #define BIT(n) (1U << (n)) #define SET_BIT(reg, n) ((reg) |= BIT(n)) #define CLR_BIT(reg, n) ((reg) &= ~BIT(n)) #define GET_BIT(reg, n) (((reg) >> (n)) & 1U) #define TOG_BIT(reg, n) ((reg) ^= BIT(n)) // 设置STM32的PA5为输出模式 SET_BIT(GPIOA->MODER, 10); // MODER5[1:0] = 01 CLR_BIT(GPIOA->MODER, 11);

位域(bit field)是C语言提供的语法糖,可以直接定义结构体中的位字段。但位域的内存布局是编译器相关的——不同编译器可能把位域从左到右或从右到左排列。在需要精确控制内存布局的场景(比如通信协议解析),建议用位操作而不是位域。

字节序(endianness)在通信中很重要。ARM默认是小端(little-endian),但很多通信协议(Modbus、以太网)也是小端,而有些协议(CAN的部分字段、USB描述符)是大端。跨平台通信时要明确做字节序转换。

中断安全的编程

中断和主循环共享数据时,必须保证数据一致性。最简单的方法是临界区——在访问共享数据时临时禁止中断。

// 临界区保护共享数据 static volatile uint32_t tick_count; // 在ISR中 void SysTick_Handler(void) { tick_count++; } // 在主循环中读取 uint32_t get_tick(void) { uint32_t tick; __disable_irq(); // 进入临界区 tick = tick_count; __enable_irq(); // 退出临界区 return tick; }

临界区要尽量短——禁止中断期间所有中断都被挂起,时间太长会影响中断响应。对于多字节数据(比如32位计数器),如果CPU是32位的,单次读写本身就是原子的,不需要临界区保护。但16位CPU读32位数据需要两条指令,中间可能被中断打断,必须保护。

在RTOS中,临界区通常用portENTER_CRITICAL()和portEXIT_CRITICAL()来管理,它们会保存和恢复中断状态,支持嵌套。

还有一种更精细的方法:只禁止特定优先级的中断。ARM的BASEPRI寄存器可以设置一个优先级阈值——只有优先级高于阈值的中断才能响应。FreeRTOS的configMAX_SYSCALL_INTERRUPT_PRIORITY就是利用这个机制:低于阈值的中断不受RTOS管理(可以一直运行),高于阈值的被RTOS控制。这样既保护了临界区,又不影响高优先级中断的实时性。

对于只读不写的共享数据(比如ISR写入、主循环读取的计数器),可以用原子操作代替临界区。ARM提供了LDREX/STREX指令(Load-Exclusive/Store-Exclusive),可以实现无锁的原子读写。C11的stdatomic.h封装了这些指令:

#include <stdatomic.h> static atomic_uint_fast32_t counter; // ISR中 atomic_fetch_add(&counter, 1); // 主循环中 uint32_t val = atomic_load(&counter);

这种方式不需要禁止中断,对实时性的影响更小。但要注意:原子操作只保证单个变量的原子性,如果需要同时修改多个变量,还是得用临界区。

volatile和const的正确使用

volatile告诉编译器不要优化对变量的访问。在嵌入式中,以下情况必须用volatile:外设寄存器、ISR中修改的全局变量、硬件DMA写入的缓冲区、多线程共享的标志。

const在嵌入式中有特殊的含义。const全局变量通常被放在Flash中(不占RAM),这对RAM有限的MCU很重要。函数指针表(比如中断向量表、状态机的处理函数表)用const修饰,可以节省宝贵的RAM空间。const的正确使用是嵌入式代码审查中经常被忽略的点——很多开发者把所有数组都放在RAM里,结果RAM不够用,其实大量的查找表、配置数据完全可以放在Flash中。

// const函数指针表放在Flash中 typedef void (*StateHandler)(void); static const StateHandler handlers[] = { handle_idle, handle_running, handle_error, }; // 在ARM上,这个数组存储在Flash中,不占RAM

面试要点

为什么嵌入式中不用malloc。三个原因:内存碎片(长时间运行后可能分配失败)、不确定性(分配时间不可预测,影响实时性)、泄漏风险(嵌入式系统通常运行数月甚至数年不重启)。替代方案:静态分配、内存池、栈分配。

volatile和原子操作的区别。volatile保证每次读写都访问内存(不被编译器优化掉),但不保证原子性。原子操作保证操作的不可分割性(不被中断打断),但不一定阻止编译器优化。在嵌入式中,两者经常需要同时使用——volatile atomic_uint32_t。

栈溢出的检测。嵌入式中没有MMU保护,栈溢出会破坏其他数据,很难发现。检测方法:在栈底填充特殊值(比如0xDEADBEEF),定期检查是否被覆盖。FreeRTOS的configCHECK_FOR_STACK_OVERFLOW就是这个原理。实际项目中还可以用MPU(Memory Protection Unit)设置栈边界,溢出时触发HardFault而不是悄悄破坏数据。

结构体对齐的问题。ARM处理器对非对齐访问的性能惩罚很大(有些外设甚至要求对齐访问)。定义结构体时,按成员大小从大到小排列可以减少padding。用__attribute__((packed))可以取消对齐,但访问速度会下降,需要权衡。

给你的建议

嵌入式C编程的功力需要长期积累。建议多读优秀的开源项目代码——STM32的HAL库虽然臃肿但接口设计有参考价值,FreeRTOS的代码简洁优雅值得学习,CMSIS库展示了怎么写可移植的硬件抽象层。

写代码时养成好习惯:所有全局变量考虑是否需要volatile,所有共享数据考虑是否需要临界区保护,所有内存分配考虑是否可以用静态方式。这些习惯会在面试中体现出来。


上一篇:第319篇 嵌入式ARM架构与编程

下一篇预告:第321篇 嵌入式通信协议SPI/I2C/UART/CAN

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询