一、课程学习方法
因为有些学员是刚进群,所以这里再把学习方法讲一下。
1. 预习
我们会在每一节晚课之后会通知要预习的章节,学员需要按如下操作观看相关视频。
1.1 打开百问网官网
1.2 点击首页的"深入学习单片机双架构双系统项目实战线上班"
1.3 再点击"单片机双架构双系统项目实战",观看视频
在电脑上、在手机浏览器里、微信或QQ里,都可以打开我们的官网,然后使用超清观看(目前微信小程序还不支持超清观看)。
2. 晚课
预习之后就是晚上上课,晚课注意事项如下:
我们大部分学员都是在上班,有可能没办法参加晚上的课程,因此我们第2天会在官网整理出晚课的图文笔记,在如下类似的位置:
这个图片上4-7、4-8是根据前次晚课录制的补充扩展内容。
并不是每次晚课的第2天都会录制视频,但是图文是肯定会有的。
图里面红框里最后一个文件是我们的一个AI课程、AI回放,基本上就是QQ聊天内容的复现。
3. 遇到问题
如果您在学习中遇到问题可以在我们论坛内对应版本进行提问,论坛使用方法:
3.1 浏览器打开:RTOS在线培训课程 - 最新的 - 问题 - 百问网嵌入式问答社区
3.2 登陆用户名:购买课程时的手机号
初始密码: 100ask (建议修改自己密码)
(如果是最近报班的学员,系统可能没有录入账号,请使用报名登记的手机号注册,再联系班主任开通发帖权限)
3.3 账号有问题的话,联系班主任。
今天我们的主题是指针和链表。
二、指针
前面我录了两节视频(前面图片的4-7、4-8),核心就是变量、变量、变量,它既然能够变,肯定就在内存里。
我来举一个例子:
在代码里面定义一个变量,这个变量必定保存在内存里。
定义一个变量的时候,在内存里必定会分配一块对应的空间。
在上图里面我们定义一个int a,它是4个字节,
如果定义一个字符变量的话,它是一个字节,
如果定义一个结构体的话,结构体可能更大。
但是不管怎样,变量,变变变,它能够变,肯定是能够读能够写,他肯定是在内存里。
我们再加一个指针变量,指针变量它是指针也是变量,既然是变量也是在内存里。
这里又定义了一个int指针,在内存里面也给他分配了一个空间,
对于所有的指针, 不管你是什么类型的指针,它保存的是地址,
在32位处理器里面,地址占用的空间大小必定是4个字节的。
我们说指针这个变量,它保存的是地址,
它是保存一个数值,这个数值是地址,这个数值是32位的。
我们再来看看下面这个图,让这个指针指向某个变量,
所谓指向某一个变量,就是这个指针的值等于那个变量的地址。
在上图里面,假设变量a在内存里面的地址是addr1
P等于&a,就是P的值等于a的地址,就是图中的红字。
现在给变量a赋值,让a等于123。
结合在前面发布的扩展视频,C语言里面,a=123,实际上等于几条汇编指令?
大家现在还不涉及汇编指令,没关系,
这是给大家讲讲这个a = 123,实际上也是要去访问内存中的某个地址
简单来说就等于三条指令:
- R0=123
- R1=变量a的地址
- 把R0的值写到R1所指示的地址去
我们再来看看指针变量P,不是指向了a的地址吗?
那么我怎么通过变量P来操作变量a呢?
int a;
int *p;
p = &a; // p等于变量a的地址
*p = 123; // 操作变量a
我们怎么操作寄存器呢?
这几句指令精简起来就是一样,就变成这样:
unsigned int *p = 某个地址;
使用的时候怎么做呢?
*p = 某个值;
这只是最简单用法,以前的P等于变量a的地址,现在的P等于寄存器的地址。
unsigned int *p = 某个地址;
就是:
unsigned int *p;
p = 某个地址; // p = &a;
三、结构体
下面我们来讲结构体。
我们怎么描述一个人,我们可以用一个结构体:
struct person {
int age;
char name[8];
};
我定义一个结构体变量: struct person wei;
变量、变量、变量, 他能够变, 可读可写,他必定在内存里。
那么我们再定义一个结构体指针呢?
指针、指针、指针里面存放的地址是4字节(32位处理器)。
现在我们定义了两个变量,一个是结构体变量,另外一个是指针变量,在内存里面都有对应的空间。
指针里面存放的永远是地址。
现在我们怎么使用变量wei, 或者指针变量p, 来访问这个结构体呢?
wei.age = 40;
p->age = 40;
这两句指定的效果是完全一样的
- “.” : 我的
- “->”:我指向的
以上就是结构体的基本知识。
我们再来看看怎么去访问下图gpio外设 ?
我们有GPIO模块的地址,可以像这样做:
你看你有7 ~ 8个寄存器,你得定义7 ~ 8个指针,还容易写错。
我们应该使用结构体还是使用结构体指针来表示寄存器?
如果定一个结构体的话,结构体变量它还是变量呀,它就在内存里面分配一大块空间。
而我们要操作的是gpio啊,在内存里面分配一大块空间干嘛?
所以我们用的是结构体指针。
再看看这个图里面:
编号2的地方定义了一个结构体指针,这个指针它是一个变量,它在内存里面必定有一个空间。
这个指针,它的指是一个地址。
在编号3的地方,我们要这个指针,他的值等于GPIO模块的首地址。
在编号4的地方,我们使用这个指针,操作寄存器。
“->”:指向的xxx。
gpioa->ODR = 1; 翻译就是: gpioa指向 的 ODR,等于1。
四、函数指针
我们的函数保存在哪里?
- 保存在flash上面。
保存在flash哪里?
- 总得有个地址吧。
既然有地址,是不是可以设置某个指针?让这个指针的值,等于函数的位置呢?
这个指针,它是函数的指针,也就是函数的位置,在32位处理器里面,它仍然是4个字节。
我们可以使用类比的方法,记忆函数指针:
int a; <===> int add(int a, int b) {return a+b;}
int *p; <===> int (*pf)(int a, int b);
p = &a; <===> pf = &add;
仔细看这个对比,左右两边对比一下
左边定义变量a,右边定义函数add;
左边定义 int指针,右边定义 函数指针;
左边赋值 指针,右边赋值 函数指针;
如图:
记住我们的口诀,
变量变量,可以变,就是可读可写。
可读可写,只能在内存里面。
你定义一个变量的时候,在内存里面必定会分配一块空间.
左边是int指针,右边是函数指针
int指针是变量,函数指针也是变量。
在图里面椭圆形的地方,就是这两个变量。
指针、指针,在32个处理器里面,指针必定是4字节。
不管你是字符指针,in的指针、函数指针,结构体指针通通都是四字节。
以前的int指针,等于某个int变量的地址。
现在的函数指针,它的值等于某个函数的地址。
怎么使用呢,还是用类比的方法:
1. 赋值: pf = add; // add, &add是完全一样的
2. 使用: pf(1,2 ); // 和 (*pf) (1, 2) 是完全一样的
讲那么久的指针,就要用起来了。
在HAL的代码里面,就经常使用的结构体指针,让这个指针等于某一个模块的地址。
先来看看gpio的HAL库,工程文件在下面这个图这里:
看第1个参数,他定义了一个结构体指针。
这个指针他就是一个值,是什么值呢?是某个GPIO模块的首地址。
我们来看别人调用这个函数的时候,必定会传入一个指针,或者说必定会传入一个地址值。
接着看看这个结构体长什么样子:
在这个结构体里面,它定义了很多成员,这些成员跟寄存器一一对应。
现在我们的结构体指针跟HAL库就扯上了关系了。
五、链表
链表的操作实际上并不是很复杂,你只要把指针搞清楚就行。
你看有三个特务,ABC。
A要去找B, A手上得有B的地址
B要去找C, B手上得有C的地址
ABC串起来就变成了一个链。
我们来画图讲解一下
第一,定义了结构体变量A,B,C;
在内存里面就必定有这三个结构体变量所对应的空间
假设在内存里面分配了ABC三个结构体变量,
他们地址分别是addrA, addrB, addrC。
A要去找B, A的手上得有B的地址,执行下面这条语句(注意箭头处):
A.next_addr = &B;
B要去找C, B的手上得有C的地址,执行下条语句:
B.next_addr = &C;
C,他没有下线了,他手上的地址是无效的:
C.next_addr = NULL;
大家看到这个链表,实际上是非常枯燥的。
变量a里面保存有变量B的地址变量,B里面保存有变量C的地址。
我们使用用箭头来表示:
看看蓝色的箭头,只是为了让我们人类更加容易理解而已
它的实质仍然是:
a里面有一个成员,用来保存B的地址
B里面有一个成员,用来保存C的地址
列表的所有的复杂操作,都是从这些基础的知识里面扩展出来,比如双向链表、链表的插入和删除。
列表的本质我再强调一遍:我手里有你家的地址,我才能够找到你。
在C语言里面这个地址怎么表示呀?用指针来表示。
在一个特务组织里面,我有上线的地址,还有下线的地址,这叫做双向链表。
在一个特务组织里面,我只有下线的地址,其他人也都只有下线的地址这叫做单向链表。
现在举一些应用的例子,然后再讲一下插入和删除操作。
我们举一个日常的例子,你们班10个学生,老师说要打印10个学生的信息,你可以用一个数组来做。
你看这个程序,就是你可以输入这10个学生的名字年龄,然后再把它们打印出来。他使用数组来保存这些学生的信息。
如果你这个班级有100个学生怎么办呢?
你得把这个数组大小设置为100。
那如果这个学校还有一些超级班级,比如有1000个学生,也得把这个数组设置成1000项。
也就是说为了支持这些小班级、中班级、超大的班级,你这个程序里面你得把这个数据设置设置的超级大,缺点就是浪费空间。
再比如说,这100个学生里面中间有某一个人转学走了,你这个数组中间就会空出一项,那一项你就标为无效。
再比如说,本来你在班级里面有1000个学生再插班进来一个学生,你这个程序就没有办法处理,问题的根源在于这个数组的容量是定死的。
如果使用链表,就可以这样写:
它的诀窍在于对于每一个学生我都会临时分配一个结构体。
你有10个我就分配10个结构体,你有100个我就分配100个结构体,你有1000个我有1000个,我就分配1000个结构体。
我使用列表可以支持小班级、中班级,超大班级。
如果有人走的话,有人转走了,我可以把列表中那一位给删除掉。
如果有人插进来,我又可以重新分配一个结构体,把这个新的结构体放进链表。
这就是日常生活中的一个例子,在rtos里面,常使用链表来管理任务。后面讲rtos时再来讲具体的任务链表。
六、晚课学员问题
1. 问:
typedef struct
{
int a;
Char b;
Char buffer[100]
} X_x;
X_x w;
int *p =&w;
p指向结构体w的a的地址吗?
w.a,w.b,w.buffer 地址连续吗?
w占用了多少个字节?4+1+100?
答: int *p = &w; 结果没问题,编译有警告。
这个警告是编译器为了让你的程序写得更加规范,为了让你避免各种莫名其妙的问题。
我们可以举一个例子:
char a;
int *p;
p = &a;
*p = 12;
*p = 12;
写4字节,但是变量a只有1字节的空间
我们可以再扩展一下,这样写程序的时候,会出现莫名其妙的问题:
char a;
int *p;
p = &a;
*p = 'A';
这段代码会有警告,但运行起来不会有问题,为什么呢?
为了追求效率,编译器也给char a分配了是4字节的空间
*p = 'A'
, 这个指令会写4个字节,错有错招,没什么后果。
再举个例子:
char a;
char b; // 多了这个变量
int *p;
p = &a;
*p = 'A';
如果a、b挨着存放,赋值变量a,就会符变变量b。
所以说大家写是C程序的时候,任何警告都要引起重视。
这里a、b也不一定是连续存放的,不同的编译器有不同的考虑,比如说优化等级不一样的时候它也不一样。
p指向结构体w的a的地址吗? //是的
w.a,w.b,w.buffer 地址连续吗?//不连续,韦老效率,char b也被分配4字节,只用1字节,浪费3字节。
w占用了多少个字节?4+1+100? //应该是4+4+100
2. 问: 和&在c语言中使用的区别是什么?
答: &: 取地址
* : 用地址,去操作值
3. 问: 结构体指针gpioa的地址是在GPIO_TypeDef *gpioa;分配的。
他内部指向的->CRL,-> CRH 这些在什么时候被分配空间?
如果一直没有赋值GPIO->CRH =1这个内容,是不是它的GPIO->CRH空间一直不存在?
答:
我们去定义一个结构体类型的时候,只是去创建一种数据类型,就像创建char int 这些基本的类型一样。
int a;
才分配空间,int b
才分配空间,int
不分配空间。
因此,struct person wei
才分配空间,struct person
不分配空间。
你只要定义了一个变量,就肯定会分配它的空间。
你只要定义了一个指针变量,就肯定会分配他的空间。
对于这个GPIO结构体:
编号为2的地方,它定义了结构体的变量,在图里面内存中,就分配了那个结构体。
编号为3的地方,他定义了一个结构体的指针,在内存中就分配了那个指针。
不管你用不用,一旦定义了必定会分配。
你看我们想去访问gpio,你在内存里面定义一个结构体有什么意义呢?
我们应该定一个结构体的指针,让这个指针等于GPIO模块的地址。
4. 问: 结构体定义,是保存在flash的吧?
答: 对于这个问题,大家可以反过来想一想。
Flash里面会保存chat这个类型吗?会保存int这个类型吗?会保存各种结构体的类型吗?
这些数据类型只是给C语言用而已,C语言最终要转换成汇编。在汇编里面,根本就没有这些数据类型。
这些数据类型只是给编译器使用的,让编译器来给那些变量分配空间而已。
最终编出来的程序程序里面根本就不含有这些数据类型。
5. 问: 因为我学的没那么深,大小端那里也没听太懂,希望老师整理资料的时候多做一些解释。
答: 我们来插讲一下大小端。
我们说一个变量,它在内存里面必定有对应的空间
我希望你们把这个口诀记到脑子里面去,
变量变量,可以变化,
可以变化,就是可以读,可以写。
可读可写的话,只能在内存里面,
所以说一个变量在内存里面必定有空间。
你看对于这个变量,a它有4个字节,也就是有4个地址
那么我们说:个十百千万,个位保存在哪里?
个位,保存在低地址那里,还是保存在高地址那里?
答案是 都可以!
这就引入了大小字节序。
个位保存在低地址,就是小字节序,也叫做小端
个位保存在高地址,就是大字节序,也叫大端。
我们举个例子:
6. 问: 咱们课程会讲 oled的芯片手册里面的 各个指令吗?感觉看手册很吃力,但是感觉底层驱动非常重要。
答: 不会,本课程重点是RTOS。
7. 问:
答: 首先如果你问的是那些寄存器的话,那些寄存器是GPIO里的寄存器,他们是有初始值的。
上面这个图里面红色方框中就是那些寄存器,那些硬件寄存器当然有初始值了。
不管你的程序怎么写,不管你程序怎么定义变量,跟我GPIO寄存器有什么关系呢?
你写程序,程序是否要操作GPIO寄存器,GPIO寄存器肯定都有初始值。
这个问题的核心在于定义一个变量,这个变量是在内存里面,
而我的硬件寄存器在另外一个GPIO模块上面,他们两个之间没有什么关系。
你使用指针来读写硬件寄存器时,才会去影响到硬件寄存器的值。
8. 问: 函数指针有啥用?目前很少用到。
答: 函数指针用的非常非常多,你应该用的少是因为你们还没有接触到。我来举个使用函数指针的例子,核心就在于让代码更加容易移植。
假设你们公司有一款产品, 要用到两款LCD,你可以在main中这样写代码。
使用一个宏开关,来决定使用哪一个函数。
再看一下,如果我们把这个程序分为两部分,main函数是APP,上面两个函数是驱动。
我是不是换一款屏幕就得重新写一下main函数:重新定义宏,重新编译。
那么有我们有没有办法呢?可以改进一下:
按这种方法,添加了一个新的函数,你使用不同的LCD时,我可以去读取某些引脚来判断你使用哪一个LCD。
在这种情况下,你即使更换了一款LCD,我也可以让这个main自动的去适应它。比前面那个好一点了。
但是,你们的公司这款产品它支持100款LCD,你就得加100个 if 判断,有些祖传代码有几十个上百个这样的判断,我们再怎么改进呢?
来看看这段代码,main函数基本上就不用变
变的只是你上面的驱动程序,你添加100款LCD都没关系,你就在那个数据里面往里面添加LCD就好了。
9. 问: 老师,结构体的自引用,只能自引用指针吗?
答: 自引用,是指自己引用自己吗?我暂且认为你是这样问的。
经常用来表示:链表里只有它一个成员。
if (A.next_addr == &A)
printf("只有一个");
struct spy* next_addr;
怎么理解?
spy*
它是一个指针,它是结构体指针,这个结构体指针就等于某个结构体的地址。
在下面的main函数中, A里面这个指针,等于A自己的地址。
10. 问: 老师你们有没有出单片机裸机课的?
答: 有,我们有HAL库的教程,也有arm架构课程,从0写代码,不用任何HAL库:这属于RTOS训练营中的一部分