【单片机】利用双缓冲机制保护全局变量不受中断影响以及环形队列
volatile uint8_t buffer_index = 0; uint32_t buffer[2] = {0}; void main() { while (1) { uint8_t current_index = buffer_index; process_data(buffer[current_index & 0x01]); // 读非活跃缓冲区 } } void ISR() { buffer_index ^= 1; // 切换缓冲区 buffer[buffer_index & 0x01] = read_sensor(); // 写新缓冲区 }以上方法只用于简单的主循环读、中断写的情况,且读的过程中只能有1次中断写,如果复杂点可以采用循环队列或者读写锁(中断不能阻塞)之类(如果主循环和中断同时读或者同时写是不能用循环队列的,会冲突)。
volatile uint8_t buffer_index = 0; uint32_t buffer[2] = {0}; void main() { while (1) { uint8_t current_index = buffer_index ^ 1; buffer[current_index & 0x01] = read_sensor(); // 写新缓冲区 buffer_index ^= 1; // 切换缓冲区 } } void ISR() { process_data(buffer[buffer_index & 0x01]); // 读非活跃缓冲区 }以上方法只用于简单的主循环写、中断读的情况,如果复杂点可以采用循环队列或者临界区之类(如果主循环和中断同时读或者同时写是不能用循环队列的,会冲突)。
想要无锁特性有一个大前提——SPSC,单生产者 - 单消费者。
在ARM处理器中:
- 绝大多数指令都可以被中断打断;
- 某些特殊指令(如异常返回指令)不会被中断打断,中断会延迟处理;
- 可以临时禁用中断,确保关键代码段不会被中断打断;
- 使用
#include <stdatomic.h>;
补充:
在全局变量小于等于数据总线大小时,对变量的读/写是原子操作。两个线程的一读一写或同时写没问题,不会有高优先级的操作被覆盖的情况。
但类似“a++”属于“读-改-写”是非原子操作会被中断打断。两个线程的一读一写没问题,但同时写会有高优先级写结果被覆盖丢失的情况。
全局变量大于数据总线大小时,属于非原子操作,两个线程的一读一写可以用双缓冲保护,同时写只能用锁处理。https://www.kdocs.cn/l/cjss6awiCP49
补充:
环形队列是通过牺牲一个存储单元来区分队满和队空。一定是先改内容再改指针,和冻结数据先写flash再改EE一样。如果先改指针,就会出现,写的内容还没完成就被读走了。
最小代码示例:
/******************************************************************************* ** 函数名称: ** 功 能:顺序队操作函数 ** 修改日志: *******************************************************************************/ void initUartQueue(TYPE_UART_QUEUE *qu) { qu->front = qu->rear = 0; } //对空回1,队不空回0 int isUartQueueEmpty(TYPE_UART_QUEUE *qu) { if (qu->front == qu->rear) { return 1; } else return 0; } //对满回1,队不满回0 int isUartQueueFull(TYPE_UART_QUEUE *qu) { if ((qu->rear + 1) % sample_maxsize == qu->front) { return 1; } else return 0; } //往队列里写数据成功回1,失败回0 int enUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { if ((qu->rear + 1) % sample_maxsize == qu->front) return 0; qu->data[qu->rear] = *x; //先改存储单元 qu->rear = (qu->rear + 1) % sample_maxsize; //后改读写指针 return 1; } //从队列里读数据成功回1,失败回0 int deUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { if (qu->front == qu->rear) return 0; *x = qu->data[qu->front]; //先改存储单元 qu->front = (qu->front + 1) % sample_maxsize; //后改读写指针 return 1; }取模算法提速补充:
// 当 SIZE 是 2 的幂次时 x % SIZE == x & (SIZE - 1)第二种提速方式就是比大小,https://www.kdocs.cn/l/cvs9putAw8Cs
节约存储补充:
设定一个回绕点,回绕点是元素数量的倍数(2倍、3倍。。),队头队尾一直加,直到回绕点清零,这样通过头尾差值判断队满还是队空,差值最大不超过元素总数。最小代码里面回绕点等于元素数量,所以需要牺牲一个存储单元。
元素数量属于小模,定位存储位置。回绕点属于大模,判断队满队空,队头队尾的计算。如果SIZE是2的幂次时,队头队尾变量的天然的回绕点是SIZE的整倍数,无需特殊处理,只需要++即可(这种一定是队头队尾变量可以达到2^32,如果是24位定时器,算超时时间是绝对不能省模的)。
优化后的代码(kfifo)示例:
#includehttps://github.com/DNedic/lfbb<stdint.h> // 环形FIFO队列结构体(缓冲区解耦+32位指针自然回绕,适配UART数据缓存) typedef struct { unsigned char *data; // 数据缓冲区指针(外部独立分配,需为2的幂次大小) uint32_t front; // 队头指针:指向待读取数据的位置 uint32_t rear; // 队尾指针:指向待写入数据的位置 uint32_t size; // 缓冲区有效大小(必须为2的幂次,如16/32/64/128) } TYPE_UART_QUEUE; /** * @brief 初始化UART队列 * @param qu: 队列结构体指针 * @param buf: 外部分配的缓冲区指针 * @param size: 缓冲区大小(必须>0且为2的幂次) * @retval 1: 初始化成功,0: 初始化失败(指针空/大小不合法) */ int initUartQueue(TYPE_UART_QUEUE *qu, unsigned char *buf, uint32_t size) { // 校验1:队列/缓冲区指针非空 if (qu == NULL || buf == NULL) { return 0; } // 校验2:缓冲区大小为2的幂次且大于0(位运算寻址的必要条件) if (!(size > 0 && (size & (size - 1)) == 0)) { return 0; } qu->data = buf; qu->size = size; qu->front = 0; qu->rear = 0; return 1; } /** * @brief 判断队列是否为空 * @param qu: 队列结构体指针 * @retval 1: 队空/指针空,0: 队列非空 */ int isUartQueueEmpty(TYPE_UART_QUEUE *qu) { if (qu == NULL) { return 1; // 空指针直接视为队空,避免非法解引用 } return (qu->front == qu->rear) ? 1 : 0; } /** * @brief 判断队列是否为满(100%利用缓冲区,无牺牲单元) * @param qu: 队列结构体指针 * @retval 1: 队满/指针空,0: 队列未满 */ int isUartQueueFull(TYPE_UART_QUEUE *qu) { if (qu == NULL) { return 1; // 空指针直接视为队满,避免非法解引用 } // 无符号差值判断,避免32位指针溢出导致的逻辑错误 return ((qu->rear - qu->front) == qu->size) ? 1 : 0; } /** * @brief 入队操作:写入1字节数据 * @param qu: 队列结构体指针 * @param x: 待写入数据的指针 * @retval 1: 入队成功,0: 入队失败(队满/指针空) */ int enUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { // 双指针非空防护,避免空指针解引用 if (qu == NULL || x == NULL) { return 0; } if (isUartQueueFull(qu)) { return 0; } // 位运算替代取模(x%size = x&(size-1)),提升单片机执行效率 qu->data[qu->rear & (qu->size - 1)] = *x; qu->rear++; // 32位无符号指针自然溢出回绕,无需手动取模 return 1; } /** * @brief 出队操作:读取1字节数据 * @param qu: 队列结构体指针 * @param x: 存储读取数据的指针 * @retval 1: 出队成功,0: 出队失败(队空/指针空) */ int deUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { // 双指针非空防护,避免空指针解引用 if (qu == NULL || x == NULL) { return 0; } if (isUartQueueEmpty(qu)) { return 0; } // 位运算快速映射物理缓冲区地址,中断中执行无压力 *x = qu->data[qu->front & (qu->size - 1)]; qu->front++; // 32位无符号指针自然溢出回绕,无需手动取模 return 1; }节约存储第二种方法补充,冻结记录算法常用(不能用在跨执行流的情况,比如中断):
环形队列中没有队头,只有队尾,增加一个记录数,通过记录数的大小判断队满队空。
/******************************************************************************* ** 函数名称: ** 功 能:顺序队操作函数 ** 修改日志: *******************************************************************************/ void initUartQueue(TYPE_UART_QUEUE *qu) { qu->rear = 0; // 队尾指针初始化为0 qu->count = 0; // 元素计数初始化为0,队列为空 } //对空回1,队不空回0 int isUartQueueEmpty(TYPE_UART_QUEUE *qu) { if (qu->count == 0) { return 1; } else return 0; } //对满回1,队不满回0 int isUartQueueFull(TYPE_UART_QUEUE *qu) { if (qu->count == sample_maxsize) { return 1; } else return 0; } //往队列里写数据成功回1,失败回0 int enUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { if (qu->count == sample_maxsize) return 0; qu->data[qu->rear] = *x; //先改存储单元:将数据写入队尾空位置 qu->rear = (qu->rear + 1) % sample_maxsize; //后改队尾指针:循环后移 qu->count++; // 元素计数+1,同步实际元素数量 return 1; } //从队列里读数据成功回1,失败回0 int deUartQueue(TYPE_UART_QUEUE *qu, unsigned char *x) { if (qu->count == 0) return 0; // 先改存储单元:通过rear和count推导队头,读取队头数据 int front = (qu->rear - qu->count + sample_maxsize) % sample_maxsize; *x = qu->data[front]; qu->count--; // 元素计数-1,同步实际元素数量(无需修改rear指针) return 1; }实际使用中,冻结记录存在norFlash,往往需要多分配一个扇区,用于擦除。“sample_maxsize”等于所有扇区可以存的总条数,“qu->count”最大值等于需要记录的总条数。“qu->count”在每次写数据时会加一但是不能超过需要记录的总条数,读数据的时候不会减一。
补充:结合双缓冲和环形队列,可以有一个LFBB,无锁双区缓冲区(Lock-Free Bipartite Buffer)。
普通环形缓冲区只能对外提供1字节1字节的数据,由于回绕无法提供一段连续内存。双区缓冲区将1段内存分成A区和B区(只是逻辑上区分,且不等长,比“乒乓”高级),如果A区剩余空间不够则用B区,B区剩余空间不够则用A区,总能提供一段连续内存给DMA之类的用。双缓冲的设计更加适合DMA发送场景。
代码示例:
https://github.com/DNedic/lfbb
补充:DMA中的“乒乓”是第一次在A缓存,下一次在B缓存。不等于“半完成中断”,你可以收数据收到一半触发DMA半完成中断。
补充:
被Linux内核用C写的kfifo无锁设计惊艳到了~
