深度掌握C语言的重点模块

深度掌握C语言的重点模块

1.位操作与寄存器编程

位运算符

|(按位或OR):有1则为1。(常用于置1)

&(按位与AND):全1才为1,有0则为0。(常用于清0或掩码)

~(按位 取反NOT):0变1,1变0.

^(按位异或XOR):相同为0,不同为1。(常用于翻转)

<<(左移):将二进制整体向左移动,右边补0。比如1<<3,就是把0001变成1000(也就是十进制的8)。

配置寄存器的操作(操作特定位)

铁律:改变你想要改变的位,绝不能影响其他位!

假设我们有一个8位的寄存器REG=0b10100101,我们要操作它的第n位(从右往左数,最右边是第0位)

1.将特定位置1(Set Bit)

公式:REG |= (1U << n)

原理:1U(U是Unsigned的意思) << n会生成一个只有第n位是1,其它全是0的数。将它与REG进行“或”运算,根据“有1则1”的原则,REG的第n位必然变成1,而其它位因为和0进行或运算,保持原样

2.将特定位清0(Clear Bit)

公式:REG &= ~(1U << n)

原理:

1.1U << n:先生成只有第n位为1的数

2.~(1U << n):取反,得到一个只有第n位是0,其它全是1的数(掩码)

3.把这个数跟REG进行“与”运算。根据“有0则0”的原则,第n位必然变成0;而其他位因为和1进行与运算,保持原样。

3.将特定位翻转(Toggle Bit)

公式:REG ^= (1U << n)

原理:利用异或的特性。某一位与1异或,原来的1会变成0,原来的0会变成1

指针强转与寄存器映射

代码:

#defineGPIoA_ODR(*(volatileuint32_t*)0X40020014)

假设你查阅芯片的数据手册,手册上说:“GPIOA的输出数据寄存器地址在0X40020014,它是一个32位的寄存器。”

编译器只认识代码,不知道什么是地址,我们要用C语言去读写这个具体的物理地址。

1.0x40020014仅仅是一个十六进制的整数

2.(uint32_t*)0x40020014使用强制类型转换,告诉编译器:“请把这个数字当成一个内存指针,它指向一个32位的无符号整数数据”

3.(volatile uint32_t*)0x40020014加上volatile,告诉编译器:“这个地址里的值受硬件控制,随时会变,千万别做缓存优化,每次都要老老实实去这个地址读写!”

4.**(volatile unit32_t*)0x40020014在最前面加个*(解引用运算符),意思是:“获取这个指针指向的具体内容/空间”。

5.用#define GPIOA_ODR给它起个好记的名字

这就完成了映射!以后你在代码里写GPIOA_ODR = 0xFF;,C语言就会精准地向内存地址0x40020014写入0xFF,从而控制真实的硬件引脚

补充说明:

给寄存器的某一位写1–>拉高(Set/High)–>引脚输出3.3V电压(相当于提高电源正极)

给寄存器的某一位写0–>拉低(Clear/Low)–>引脚输出0V电压(先当与链接电源负极/地)

练习代码:

#defineGPIOA_ODR(*(valatileuniit32_t*)0x40020014)#defineSET_PIN(pin)(GPIOA_ODR|=(1U<<pin))#defineCLEAR_PIN(pin)(GPIOA_ODR&=~(1U<<pin))

点亮GPIOA的第5号引脚的LED:

SET_PIN(5);

编译展开成:

(*(volatileunit32_t*)0x40020014)|=(1U<<5);

这样硬件电路检测到寄存器第5位变成1,输出高电平,LED亮起!其他引脚状态完全不受影响

熄灭GPIOA的第5号引脚的LED:

CLEAR_PIN(5);

编译展开成:

(*(volatileunit32_t*)0x40020014)&=~(1U<<pin)

这样硬件电路检测到寄存器第5位变成0,输出低电平,LED熄灭!其他引脚状态完全不受影响

2.结构体位域与寄存器映射

为什么学习寄存器位域?

假设我们要配置一个GPIO引脚,它不仅仅有“高/低”电平,它的配置寄存器可能长这样(一个32位的今存器被拆成了多个小段):

第0-1位:控制模式(00=输入,01=输出,10=复用…)

第2位:控制输出类型(0=推挽,1=开漏)

第3-4位:控制输出速度(00=低速,01=中速…)

如果用上面的方法,想把模式设为01,速度设为01,代码要这样写:

REG=(REG&~0x1D)|(0x01<<0)|(0x01<<3);

(REG & ~0x1D)意思是先把REG要修改的区域进行擦除(变成0)

(0x01 << 0)和 (0x01 << 3) 意思是逐步用擦除后的板子进行修改成我们想要的样子

但是这个方法有个缺陷:过于冗长不容易看懂,而且非常容易算错位移的数量
所以我们要让代码“说人话”,C语言提供了一个高级武器——位域(Bit-field)

位域

typedefstruct{unit32_tMODER:2;unit32_tOTYPER:1;unit32_tOSPEEDR:2;}GPIO;

在普通的C语言结构体中,声明一个unit32_t就会直接占用32个bit(4个字节)。但是,在变量名后面加上冒号和数字(例如:2),它告诉编译器:"虽然我声明的是32位的数据类型,但MODER这个变量只需要占用2个bit的空间,OTYPER占用1个bit,OSPEEDR占用2个bit“。

编译器非常聪明,他就会把这些变量像拼积木一样紧紧地打包在一起:

内存的第0-1位被分配给了MODER

内存的第2位被分配给了OTYPER

内存的第3-4位被分配给了OSPEEDR

这就是位域

寄存器映射

结构体定义好了,怎么让它和真实的物理地址挂钩呢?

假设GPIO的端口地址是0x40020000,

//1.将物理地址强转为指向该结构体的指针GPIO*gpio=(GPIO*)0x40020000;//2.直接向操作面向对象里的属性一样,去配置硬件gpio->MODER=1;//设置为输出模式(底层自动变成01)gpio->OTYPER=0;//设置为推挽输出(底层自动变成0)gpio->OSPEEDR=1;//设置为中速(底层自动变成01)

当编译器看到

gpio->OSPEEDR=1;gpio->OTYPER=0;gpio->OSPEEDR=1

时,那些繁琐的&,|,~,<<运算并没有消失,它们只是被编译器接管了。但是你不用自己手动去写了,它会按照你的要求帮你生成你想要修改成的样子

注意:当你要写入的数所占用的位数大于所分配的位数,那么就只会写入低位

总结

优点:极大提高了代码的可读性和可维护性。

需要注意的坑:C语言标准并没有规定位域在内存中是从左往右排还是从右往左排(这取决于编译器和大小端模式)。因此,跨平台移植含有位域的代码时需要特别小心。不过在特定的单片机开发环境(如ARM GCC + STM32)下,排列顺序是确定的。

3.函数指针与回调机制

函数指针:

函数指针:里面存的是一段代码(函数)在内存中的起始地址。有了函数指针,函数就不再是只能被“写死”调用的 死板东西了,它可以像普通变量一样被赋值、被当作参数传递、被存进数组里。

typedefvoid(*ISR_Callback)(void);ISR_Callback timer_callbacks[10];

typedef:意思是我们要给某种复杂的数据类型起一个“别名”

(左边的)void:规定看这个指针只能指向没有返回值的函数

(*ISR_Callback):这里的ISR_Callback这就是我们起的新名字。 *表示它是一个指针。

(右边的)(void):规定了这个指针只能指向不需要传递参数的函数

ISR_Callback timer_callbacks[10];这个是一个ISR_Callback类型的数组

回调机制(Callback)

有这样一个场景:单片机里有一个硬件定时器(Timer)。时间一到,硬件就会强行打断CPU,去执行一个底层写死的函数(中断服务函数)

普通的写法(强耦合):直接在底层的中断函数里写业务逻辑:

voidHardware_Timer_INtertupt(void){LED_Toggle();//翻转LED灯}

缺点:如果有一天需求变了:”定时器时间到了不闪灯了,改为控制电机转动。“,你必须要去修改底层的驱动代码!底层驱动和上层应用深深地绑死在了一起(强耦合),代码根本无法复用

高手的写法(回调机制解耦):底层驱动维护数组。它不知道时间到了该干嘛,它只负责“查字典”。

voidHardware_Timer_Interrupt(void){//检查第0个坑位有没有登记函数,如果有,就执行它if(timer_callbacks[0]=NULL){timer_callbacks[0]();}}

上层的应用代码,可以随便改:

voidMy_Motor_Control(void){//控制电机转动的代码...}intmain(){//注册回调函数timer_callbacks[0]=My_Motor_Control;while(1){}}

我只要把函数指针数组对应位置的地址改成我要执行的函数地址就可以做到“解耦”

4.volatile与内存屏障

Volatileunit32_t*status_reg;_asm_volatile("dmb");//数据内存屏障

volatile和内存屏障这两个概念存在的原因是:编译器和CPU太聪明了,聪明到有时会“自作主张”

假设你写了这样一段等待硬件准备就绪的代码:

unit32_t*status_reg=(unit32_t*)0x40001000;//假设这是硬件寄存器while(*status_reg==0){//苦苦等待硬件把这个寄存器变成1}printf("硬件准备好了!");

这时我们聪明的C编译器看到死循环里没有任何代码修改*status_reg的值,他就觉得每次都去物理内存里面读取太慢太傻了,就自作主张把第一次读到的值缓存到CPU内部超快的寄存器里面,以后一直判断缓存的值。这样我们后面硬件在1ms后悄悄把物理内存里的位置的值改变了,但是CPU还在比对的是寄存器里面的值(旧数据),就会导致程序司机,永远跳不出循环。

Volatile

在变量前加上它,就是对编译器下达了最高指令:不要对这个变量做任何缓存优化!不要自作聪明!每次用到它,必须老老实实去真实的物理内存地址给我读取!

内存屏障

_asm_volatile("dmb");//数据内存屏障

CPU硬件的自作聪明

现在的高级处理器内部有复杂的流水线和缓冲机制。为了追求极致的速度,CPU有时会打乱代码执行的顺序(乱序执行)

举例:

1.往一块内存里准备好了大量的数据

2.你往控制寄存器里写了一个1,命令DMA(直接内存访问控制器,也就是硬件搬运工)开始把数据搬到网卡发送出去

如果CPU开启了优化,它可能先执行了第2步(启动硬件),然后再慢吞吞地把第一步的数据写入内存。结果DMA搬走的全部是错误数据(乱码)。

_asm_:告诉C编译器,括号里的字符串是汇编指令,不是C语言,请你原封不动地把它翻译成机械码

volatile:这里放在回避那指令的后面,作用是防止代码位置被优化

dmb(Data memory Barrier)的作用:它就像是在代码中间竖起的一度坚不可摧的墙

volatile(“dmb”);是一句嵌入的汇编指令。她严厉地警告CPU:”在这堵墙之前的所有内存读写操作,必须全部真真实实地落地完成,才能开始执行这堵墙之后的指令!绝不允许跨界乱序!“

总结:asmvolatile(“dmb”);就是一堵墙,意思就是以这堵墙为分界线,必须要等这堵墙上面的内存读写完毕才能对这堵墙下面的内容进行操作

练习

1.编写环形缓冲区

用处:想象一下你在用串口接收数据。硬件接收数据的速度很快,而且是补丁时的(可能突然发来一长串)。但是你的CPU主程序可能正在忙别的事情,来不及立刻处理这些数据。如果不是缓冲区,新来的数据就会把老数据覆盖掉,导致丢包。如果是普通的数据当作缓冲区,存满之后你就得帮整个数组往前挪,极其耗费CPU算力

环形缓冲区:本质上还是普通的一维数组,但是我们在逻辑上把它首尾相连,当成一个“圆环”。我们需要两个指针:

写指针:记录下一个存入数据的位置

读指针:记录下一个读取数据的位置

#include <stdio.h> #include <stdbool.h> #include <stdint.h> #define BUFFER_SIZE 256//定义环形数组的大小,必须是2的次方 #define BUFFER_MASK (BUFFER_SIZE-1) typedef struct { uint8_t buffer[BUFFER_SIZE];//这里用uint8_t是为了模仿硬件层面“一字节一字节”地接收和发送数据 uint16_t head;//索引用uint16,是因为uint16的最大值是65535,甚至可以用uint32 uint16_t tail; }RingBuffer_t; //1.初始化缓冲区 void RingBuffer_Init(RingBuffer_t* rb) { rb->head = 0; rb->tail = 0; } //2.判断缓冲区是否为空 bool RingBuffer_IsEmpty(RingBuffer_t *rb) { return (rb->head == rb->tail); } //3.判断缓冲区是否为满 bool RingBuffer_IsFull(RingBuffer_t* rb) { uint16_t next_head = (rb->head + 1) & BUFFER_MASK;//任何超过BUFFER_MASK的高位都会被砍掉,只保留最低的8位,且速度比除法快很多 return (next_head == rb->tail); } //4.存入一个字节的数据 bool RingBuffer_Write(RingBuffer_t *rb,uint8_t data) { if (RingBuffer_IsFull(rb)) { return false; } rb->buffer[rb->head] = data; rb->head = (rb->head + 1) & BUFFER_MASK; return true; } //5.读出一个字节的数据 bool RingBuffer_Read(RingBuffer_t* rb, uint8_t* data) { if (RingBuffer_IsEmpty(rb)) { return false; } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) & BUFFER_MASK; return true; }

2.自定义内存管理器(内存池)

malloc和free会导致两个问题:

1.内存碎片化:由于申请的空间大小不规律,导致内存里全是七零八落的小空隙,可能剩余的总的内存很大,但你想要一块连续的较大字节空间时,系统找不到了,直接崩溃

2.时间不确定性:malloc寻找合适空闲内存块的时间是不确定的。有时候很快,有时候要找半天。在要求“必须在1ms内做出反应”的航空或医疗设备中,这是致命的

解决方案:

既然随意大小的分配会产生碎片,那我就只允许固定大小的分配

核心思想:

1.系统一开机,我们就申请一大块连续的内存(比如一个大数组)

2.把这块大内存,切成大小完全相同的N个小块(比如每个块固定32字节,一共切出8块)

3.任何人来要内存,不管要多少,直接扔给他一个完整的32字节的块。要是还回去,我就把这块收回来

4.绝对不会产生碎片,且分配速度块如闪电

#include<stdio.h>#include<stdbool.h>#include<stdint.h>#defineBLOCK_SIZE32//每个内存块的大小#defineBLOCK_COUNT8//内存块的数量typedefstruct{//内存仓库uint8_tmemory_pool[BLOCK_COUNT][BLOCK_SIZE];//一个8位的变量,它的每一位代表上面一个块的状态uint8_tbitmap;}MemoryPool_t;MemoryPool_t my_pool;//初始化函数voidMemory_Init(void){my_pool.bitmap=0;//初始化状态:8个全是0,代表上面每一个块都空闲}//申请一个固定大小的内存块void*Memory_Alloc(void){for(inti=0;i<BLOCK_COUNT;i++){if(((my_pool.bitmap>>i)&1U)==0){//找到的空闲块标记为占用my_pool.bitmap|=(1U<<1);//返回这个空闲内存块的地址returnmy_pool.memory_pool[i];}}//如果内存块全满了,返回NULLreturnNULL;}//释放之前申请的内存块,归还给内存池voidMemory_Free(void*ptr){if(ptr==NULL){return;}//将ptr强转成uint8_t*类型进行后续计算uint8_t*byte_ptr=(uint8_t*)ptr;//找到内存池的起始地址uint8_t*base_ptr=&my_pool.memory_pool[0][0];//算出当前地址距离内存池的起始地址多少字节,除于块大小,得出所处是第几块intindex=(byte_ptr-base_ptr)/BLOCK_SIZE;//判断块序号是否合法if(index>=0&&index<BLOCK_SIZE){//将bitmap的第index位清0(标记为空闲,归还给系统)my_pool.bitmap&=~(1U<<index);}}

3.printf重定向

在电脑上运行C语言中的Printf函数,这句话会打印在黑色的控制台窗口里。这是因为,电脑系统的标准输出默认就是显示器

但是单片机就是一块芯片,它没有屏幕,没有控制台。如果直接调用printf,系统会懵掉,不懂你要显示在哪里,结果往往就是程序卡死

重定向的意思就是“偷梁换柱”:拦截printf的底层输出,告诉它别去找什么屏幕了,把格式化好的每一个字符都通过硬件的串口(URTA)一根根线发给我的电脑

底层原理:不管是哪种标准的C语言标准库,printf内部都做好了非常复杂的字符串拼接、数字转字符(把%d变成数字)等工作。但不管上面怎么折腾,当它最终要把字符一个一个发送出去的时候,都会统一调用最底层的一个基础函数:

在Keil(MDK)环境下,这个最底层的函数叫fputc

在GCC环境下,这个最底层的函数叫_write

标准库里的这个基础函数是一个“弱函数(weak)”,只要我们在自己的代码里重写了这个函数,编译器就会用我们写的,而抛弃系统默认的。

#include<stdio.h>//假设底层硬件提供的:通过串口发送1个字节的函数//如果用的是STM32HAL库,这个动作可以替换为HAL_UART_Transmit)externvoidUART_SendByte(uint8_tbyte);//重写C标准库的底层的字符输出函数//以后你在代码里每次调用printf("abc");//printf在底层就会连续3次调用这个fput,分别把‘a'、’b'、‘c'当作参数传进来intfputc(intch,FILE*f){//1.我们拦截到了要打印的字符ch//2.强行把它塞进硬件串口发送出去UART_SendByte((Uint8_t)ch);//3.按照C标准,必须返回刚刚发送出去的字符returnch;}//只要写了在这段代码,你以后在程序里就可以肆无忌惮地使用printf

如果使用的是GCC编译器,比如STM32CubeIDE,你想需要重写的是_write函数,逻辑是一模一样的

int——write(intfile,char*ptr,intlen){for(inti=0;i<len;i++){UART_SendByte(ptr[i]);//连续发送一串字符}returnlen;}