2025年1月

前言

高阶组件HOC
在React社区是非常常见的概念,但是在Vue社区中却是很少人使用。主要原因有两个:1、Vue中一般都是使用SFC,实现HOC比较困难。2、HOC能够实现的东西,在Vue2时代
mixins
能够实现,在Vue3时代
Composition API
能够实现。如果你不知道HOC,那么你平时绝对没有场景需要他。但是如果你知道HOC,那么在一些特殊的场景使用他就可以很优雅的解决一些问题。

欧阳也在找工作,坐标成都求内推!

什么是高阶组件HOC

HOC使用场景就是
加强原组件

HOC实际就是一个函数,这个函数接收的参数就是一个组件,并且返回一个组件,返回的就是加强后组件。如下图:
hoc


Composition API
出现之前HOC还有一个常见的使用场景就是提取公共逻辑,但是有了
Composition API
后这种场景就无需使用HOC了。

高阶组件HOC使用场景

很多同学觉得有了
Composition API
后,直接无脑使用他就完了,无需费时费力的去搞什么HOC。那如果是下面这个场景呢?

有一天产品找到你,说要给我们的系统增加会员功能,需要让系统中的几十个功能块增加会员可见功能。如果不是会员这几十个功能块都显示成引导用户开通会员的UI,并且这些功能块涉及到几十个组件,分布在系统的各个页面中。

如果不知道HOC的同学一般都会这样做,将会员相关的功能抽取成一个名为
useVip.ts
的hooks。代码如下:

export function useVip() {
  function getShowVipContent() {
    // 一些业务逻辑判断是否是VIP
    return false;
  }

  return {
    showVipContent: getShowVipContent(),
  };
}

然后再去每个具体的业务模块中去使用
showVipContent
变量判断,
v-if="showVipContent"
显示原模块,
v-else
显示引导开通会员UI。代码如下:

<template>
  <Block1
    v-if="showVipContent"
    :name="name1"
    @changeName="(value) => (name1 = value)"
  />
  <OpenVipTip v-else />
</template>

<script setup lang="ts">
import { ref } from "vue";
import Block1 from "./block1.vue";
import OpenVipTip from "./open-vip-tip.vue";
import { useVip } from "./useVip";

const { showVipContent } = useVip();
const name1 = ref("block1");
</script>

我们系统中有几十个这样的组件,那么我们就需要这样去改几十次。非常麻烦,如果有些模块是其他同事写的代码还很容易改错!!!

而且现在流行搞SVIP,也就是光开通VIP还不够,需要再开通一个SVIP。当你后续接到SVIP需求时,你又需要去改这几十个模块。
v-if="SVIP"
显示某些内容,
v-else-if="VIP"
显示提示开通SVIP,
v-else
显示提示开通VIP。

上面的这一场景使用hooks去实现,虽然能够完成,但是因为入侵了这几十个模块的业务逻辑。所以容易出错,也改起来比较麻烦,代码也不优雅。

那么有没有一种更好的解决方案,让我们可以不入侵这几十个模块的业务逻辑的实现方式呢?

答案是:
高阶组件HOC

HOC的一个用途就是对组件进行增强,并且不会入侵原有组件的业务逻辑,在这里就是使用HOC判断会员相关的逻辑。如果是会员那么就渲染原本的模块组件,否则就渲染引导开通VIP的UI

实现一个简单的HOC

首先我们要明白Vue的组件经过编译后就是一个对象,对象中的
props
属性对应的就是我们写的
defineProps
。对象中的setup方法,对应的就是我们熟知的
<script setup>
语法糖。

比如我使用
console.log(Block1)
将上面的
import Block1 from "./block1.vue";
给打印出来,如下图:
console

这个就是我们引入的Vue组件对象。

还有一个冷知识,大家可能不知道。如果在setup方法中返回一个函数,那么在Vue内部就会认为这个函数就是实际的render函数,并且在setup方法中我们天然的就可以访问定义的变量。

利用这一点我们就可以在Vue3中实现一个简单的高阶组件HOC,代码如下:

import { h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    setup() {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent ? h(BaseComponent) : h(OpenVipTip);
      };
    },
  };
}

在上面的代码中我们将会员相关的逻辑全部放在了
WithVip
函数中,这个函数接收一个参数
BaseComponent
,他是一个Vue组件对象。


setup
方法中我们return了一个箭头函数,他会被当作render函数处理。

如果
showVipContent
为true,就表明当前用户开通了VIP,就使用
h
函数渲染传入的组件。

否则就渲染
OpenVipTip
组件,他是引导用户开通VIP的组件。

此时我们的父组件就应该是下面这样的:

<template>
  <EnhancedBlock1 />
</template>

<script setup lang="ts">
import Block1 from "./block1.vue";
import WithVip from "./with-vip.tsx";

const EnhancedBlock1 = WithVip(Block1);
</script>

这个代码相比前面的hooks的实现就简单很多了,只需要使用高阶组件
WithVip
对原来的
Block1
组件包一层,然后将原本使用
Block1
的地方改为使用
EnhancedBlock1
。对原本的代码基本没有入侵。

上面的例子只是一个简单的demo,他是不满足我们实际的业务场景。比如子组件有
props

emit

插槽
。还有我们在父组件中可能会直接调用子组件expose暴露的方法。

因为我们使用了HOC对原本的组件进行了一层封装,那么上面这些场景HOC都是不支持的,我们需要添加一些额外的代码去支持。

高阶组件HOC实现props和emit

在Vue中属性分为两种,一种是使用
props

emit
声明接收的属性。第二种是未声明的属性
attrs
,比如class、style、id等。

在setup函数中props是作为第一个参数返回,
attrs
是第二个参数中返回。

所以为了能够支持props和emit,我们的高阶组件
WithVip
将会变成下面这样:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,  // 新增代码
    setup(props, { attrs, slots, expose }: SetupContext) {  // 新增代码
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(BaseComponent, {
              ...props, // 新增代码
              ...attrs, // 新增代码
            })
          : h(OpenVipTip);
      };
    },
  };
}


setup
方法中接收的第一个参数就是
props
,没有在props中定义的属性就会出现在
attrs
对象中。

所以我们调用h函数时分别将
props

attrs
透传给子组件。

同时我们还需要一个地方去定义props,props的值就是直接读取子组件对象中的
BaseComponent.props
。所以我们给高阶组件声明一个props属性:
props: BaseComponent.props,

这样props就会被透传给子组件了。

看到这里有的小伙伴可能会问,那emit触发事件没有看见你处理呢?

答案是:我们无需去处理,因为父组件上面的
@changeName="(value) => (name1 = value)"
经过编译后就会变成属性:
:onChangeName="(value) => (name1 = value)"
。而这个属性由于我们没有在props中声明,所以他会作为
attrs
直接透传给子组件。

高阶组件实现插槽

我们的正常子组件一般还有插槽,比如下面这样:

<template>
  <div class="divider">
    <h1>{{ name }}</h1>
    <button @click="handleClick">change name</button>
    <slot />
    这里是block1的一些业务代码
    <slot name="footer" />
  </div>
</template>

<script setup lang="ts">
const emit = defineEmits<{
  changeName: [name: string];
}>();

const props = defineProps<{
  name: string;
}>();

const handleClick = () => {
  emit("changeName", `hello ${props.name}`);
};

defineExpose({
  handleClick,
});
</script>

在上面的例子中,子组件有个默认插槽和name为
footer
的插槽。此时我们来看看高阶组件中如何处理插槽呢?

直接看代码:

import { SetupContext, h } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
              },
              slots // 新增代码
            )
          : h(OpenVipTip);
      };
    },
  };
}

插槽的本质就是一个对象里面拥有多个方法,这些方法的名称就是每个具名插槽,每个方法的参数就是插槽传递的变量。这里我们只需要执行
h
函数时将
slots
对象传给h函数,就能实现插槽的透传(如果你看不懂这句话,那就等欧阳下篇插槽的文章写好后再来看这段话你就懂了)。

我们在控制台中来看看传入的
slots
插槽对象,如下图:
slots

从上面可以看到插槽对象中有两个方法,分别是
default

footer
,对应的就是默认插槽和footer插槽。

大家熟知h函数接收的第三个参数是children数组,也就是有哪些子元素。但是他其实还支持直接传入
slots
对象,下面这个是他的一种定义:

export function h<P>(
  type: Component<P>,
  props?: (RawProps & P) | null,
  children?: RawChildren | RawSlots,
): VNode

export type RawSlots = {
  [name: string]: unknown
  // ...省略
}

所以我们可以直接把slots对象直接丢给h函数,就可以实现插槽的透传。

父组件调用子组件的方法

有的场景中我们需要在父组件中直接调用子组件的方法,按照以前的场景,我们只需要在子组件中expose暴露出去方法,然后在父组件中使用ref访问到子组件,这样就可以调用了。

但是使用了HOC后,中间层多了一个高阶组件,所以我们不能直接访问到子组件expose的方法。

怎么做呢?答案很简单,直接上代码:

import { SetupContext, h, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      // 新增代码start
      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );
      // 新增代码end

      return () => {
        return showVipContent
          ? h(
              BaseComponent,
              {
                ...props,
                ...attrs,
                ref: innerRef,  // 新增代码
              },
              slots
            )
          : h(OpenVipTip);
      };
    },
  };
}

在高阶组件中使用
ref
访问到子组件赋值给
innerRef
变量。然后expose一个
Proxy
的对象,在get拦截中让其直接去执行子组件中的对应的方法。

比如在父组件中使用
block1Ref.value.handleClick()
去调用
handleClick
方法,由于使用了HOC,所以这里读取的
handleClick
方法其实是读取的是HOC中expose暴露的方法。所以就会走到
Proxy
的get拦截中,从而可以访问到真正子组件中expose暴露的
handleClick
方法。

那么上面的Proxy为什么要使用
has
拦截呢?

答案是在Vue源码中父组件在执行子组件中暴露的方法之前会执行这样一个判断:

if (key in target) {
  return target[key];
}

很明显我们这里的
Proxy
代理的原始对象里面什么都没有,执行
key in target
肯定就是false了。所以我们可以使用
has
去拦截
key in target
,意思是只要访问的方法或者属性是子组件中
expose
暴露的就返回true。

至此,我们已经在HOC中覆盖了Vue中的所有场景。但是有的同学觉得
h
函数写着比较麻烦,不好维护,我们还可以将上面的高阶组件改为tsx的写法,
with-vip.tsx
文件代码如下:

import { SetupContext, ref } from "vue";
import OpenVipTip from "./open-vip-tip.vue";

export default function WithVip(BaseComponent: any) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const showVipContent = getShowVipContent();
      function getShowVipContent() {
        // 一些业务逻辑判断是否是VIP
        return true;
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return showVipContent ? (
          <BaseComponent {...props} {...attrs} ref={innerRef}>
            {slots}
          </BaseComponent>
        ) : (
          <OpenVipTip />
        );
      };
    },
  };
}

一般情况下h函数能够实现的,使用
jsx
或者
tsx
都能实现(除非你需要操作虚拟DOM)。

注意上面的代码是使用
ref={innerRef}
,而不是我们熟悉的
ref="innerRef"
,这里很容易搞错!!

compose函数

此时你可能有个新需求,需要给某些模块显示不同的折扣信息,这些模块可能会和上一个会员需求的模块有重叠。此时就涉及到多个高阶组件之间的组合情况。

同样我们使用HOC去实现,新增一个
WithDiscount
高阶组件,代码如下:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(BaseComponent: any, item: string) {
  return {
    props: BaseComponent.props,
    setup(props, { attrs, slots, expose }: SetupContext) {
      const discountInfo = ref("");

      onMounted(async () => {
        const res = await getDiscountInfo(item);
        discountInfo.value = res;
      });

      function getDiscountInfo(item: any): Promise<string> {
        // 根据传入的item获取折扣信息
        return new Promise((resolve) => {
          setTimeout(() => {
            resolve("我是折扣信息1");
          }, 1000);
        });
      }

      const innerRef = ref();
      expose(
        new Proxy(
          {},
          {
            get(_target, key) {
              return innerRef.value?.[key];
            },
            has(_target, key) {
              return innerRef.value?.[key];
            },
          }
        )
      );

      return () => {
        return (
          <div class="with-discount">
            <BaseComponent {...props} {...attrs} ref={innerRef}>
              {slots}
            </BaseComponent>
            {discountInfo.value ? (
              <div class="discount-info">{discountInfo.value}</div>
            ) : null}
          </div>
        );
      };
    },
  };
}

那么我们的父组件如果需要同时用VIP功能和折扣信息功能需要怎么办呢?代码如下:

const EnhancedBlock1 = WithVip(WithDiscount(Block1, "item1"));

如果不是VIP,那么这个模块的折扣信息也不需要显示了。

因为高阶组件接收一个组件,然后返回一个加强的组件。利用这个特性,我们可以使用上面的这种代码将其组合起来。

但是上面这种写法大家觉得是不是看着很难受,一层套一层。如果这里同时使用5个高阶组件,这里就会套5层了,那这个代码的维护难度就是地狱难度了。

所以这个时候就需要
compose
函数了,这个是React社区中常见的概念。它的核心思想是将多个函数从右到左依次组合起来执行,前一个函数的输出作为下一个函数的输入。

我们这里有多个HOC(也就是有多个函数),我们期望执行完第一个HOC得到一个加强的组件,然后以这个加强的组件为参数去执行第二个HOC,最后得到由多个HOC加强的组件。

compose
函数就刚好符合我们的需求,这个是使用
compose
函数后的代码,如下:

const EnhancedBlock1 = compose(WithVip, WithDiscount("item1"))(Block1);

这样就舒服多了,所有的高阶组件都放在第一个括弧里面,并且由右向左去依次执行每个高阶组件HOC。如果某个高阶组件HOC需要除了组件之外的额外参数,像
WithDiscount
这样处理就可以了。

很明显,我们的
WithDiscount
高阶组件的代码需要修改才能满足
compose
函数的需求,这个是修改后的代码:

import { SetupContext, onMounted, ref } from "vue";

export default function WithDiscount(item: string) {
  return (BaseComponent: any) => {
    return {
      props: BaseComponent.props,
      setup(props, { attrs, slots, expose }: SetupContext) {
        const discountInfo = ref("");

        onMounted(async () => {
          const res = await getDiscountInfo(item);
          discountInfo.value = res;
        });

        function getDiscountInfo(item: any): Promise<string> {
          // 根据传入的item获取折扣信息
          return new Promise((resolve) => {
            setTimeout(() => {
              resolve("我是折扣信息1");
            }, 1000);
          });
        }

        const innerRef = ref();
        expose(
          new Proxy(
            {},
            {
              get(_target, key) {
                return innerRef.value?.[key];
              },
              has(_target, key) {
                return innerRef.value?.[key];
              },
            }
          )
        );

        return () => {
          return (
            <div class="with-discount">
              <BaseComponent {...props} {...attrs} ref={innerRef}>
                {slots}
              </BaseComponent>
              {discountInfo.value ? (
                <div class="discount-info">{discountInfo.value}</div>
              ) : null}
            </div>
          );
        };
      },
    };
  };
}

注意看,
WithDiscount
此时只接收一个参数
item
,不再接收
BaseComponent
组件对象了,然后直接return出去一个回调函数。

准确的来说此时的
WithDiscount
函数已经不是高阶组件HOC了,
他return出去的回调函数才是真正的高阶组件HOC
。在回调函数中去接收
BaseComponent
组件对象,然后返回一个增强后的Vue组件对象。

至于参数
item
,因为闭包所以在里层的回调函数中还是能够访问的。这里比较绕,可能需要多理解一下。

前面的理解完了后,我们可以再上一点强度了。来看看
compose
函数是如何实现的,代码如下:

function compose(...funcs) {
  return funcs.reduce((acc, cur) => (...args) => acc(cur(...args)));
}

这个函数虽然只有一行代码,但是乍一看,怎么看怎么懵逼,欧阳也是!!
我们还是结合demo来看:

const EnhancedBlock1 = compose(WithA, WithB, WithC, WithD)(View);

假如我们这里有
WithA

WithB

WithC

WithD
四个高阶组件,都是用于增强组件
View

compose中使用的是
...funcs
将调用
compose
函数接收到的四个高阶组件都存到了
funcs
数组中。

然后使用reduce去遍历这些高阶组件,注意看执行
reduce
时没有传入第二个参数。

所以第一次执行reduce时,
acc
的值为
WithA

cur
的值为
WithB
。返回结果也是一个回调函数,将这两个值填充进去就是
(...args) => WithA(WithB(...args))
,我们将第一次的执行结果命名为
r1

我们知道reduce会将上一次的执行结果赋值为acc,所以第二次执行reduce时,
acc
的值为
r1

cur
的值为
WithC
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r1(WithC(...args))
。同样我们将第二次的执行结果命名为
r2

第三次执行reduce时,此时的
acc
的值为
r2

cur
的值为
WithD
。返回结果也是一个回调函数,同样将这两个值填充进行就是
(...args) => r2(WithD(...args))
。同样我们将第三次的执行结果命名为
r3
,由于已经将数组遍历完了,最终reduce的返回值就是
r3
,他是一个回调函数。

由于
compose(WithA, WithB, WithC, WithD)
的执行结果为
r3
,那么
compose(WithA, WithB, WithC, WithD)(View)
就等价于
r3(View)

前面我们知道
r3
是一个回调函数:
(...args) => r2(WithD(...args))
,这个回调函数接收的参数
args
,就是需要增强的基础组件
View
。所以执行这个回调函数就是先执行
WithD
对组件进行增强,然后将增强后的组件作为参数去执行
r2

同样
r2
也是一个回调函数:
(...args) => r1(WithC(...args))
,接收上一次
WithD
增强后的组件为参数执行
WithC
对组件再次进行增强,然后将增强后的组件作为参数去执行
r1

同样
r1
也是一个回调函数:
(...args) => WithA(WithB(...args))
,将
WithC
增强后的组件丢给
WithB
去执行,得到增强的组件再丢给
WithA
去执行,最终就拿到了最后增强的组件。

执行顺序就是
从右向左
去依次执行高阶组件对基础组件进行增强。

至此,关于
compose
函数已经讲完了,这里对于Vue的同学可能比较难理解,建议多看两遍。

总结

这篇文章我们讲了在Vue3中如何实现一个高阶组件HOC,但是里面涉及到了很多源码知识,所以这是一篇运用源码的实战文章。如果你理解了文章中涉及到的知识,那么就会觉得Vue中实现HOC还是很简单的,反之就像是在看天书。

还有最重要的一点就是
Composition API
已经能够解决绝大部分的问题,只有少部分的场景才需要使用高阶组件HOC,
切勿强行使用HOC
,那样可能会有炫技的嫌疑。如果是防御性编程,那么就当我没说。

最后就是我们实现的每个高阶组件HOC都有很多重复的代码,而且实现起来很麻烦,心智负担也很高。那么我们是不是可以抽取一个
createHOC
函数去批量生成高阶组件呢?这个就留给各位自己去思考了。

还有一个问题,我们这种实现的高阶组件叫做
正向属性代理
,弊端是每代理一层就会增加一层组件的嵌套。那么有没有方法可以解决嵌套的问题呢?

答案是
反向继承
,但是这种也有弊端如果业务是setup中返回的render函数,那么就没法重写了render函数了。

关注公众号:【前端欧阳】,给自己一个进阶vue的机会

另外欧阳写了一本开源电子书
vue3编译原理揭秘
,看完这本书可以让你对vue编译的认知有质的提升。这本书初、中级前端能看懂,完全免费,只求一个star。

前言

sql优化是一个大家都比较关注的热门话题,无论你在面试,还是工作中,都很有可能会遇到。

如果某天你负责的某个线上接口,出现了性能问题,需要做优化。那么你首先想到的很有可能是优化sql语句,因为它的改造成本相对于代码来说也要小得多。

那么,如何优化sql语句呢?

这篇文章从15个方面,分享了sql优化的一些小技巧,希望对你有所帮助。

(我最近开源了一个基于 SpringBoot+Vue+uniapp 的商城项目,欢迎访问和star。)[
https://gitee.com/dvsusan/susan_mall
]

1 避免使用select *

很多时候,我们写sql语句时,为了方便,喜欢直接使用
select *
,一次性查出表中所有列的数据。

反例:

select * from user where id=1;

在实际业务场景中,可能我们真正需要使用的只有其中一两列。查了很多数据,但是不用,白白浪费了数据库资源,比如:内存或者cpu。

此外,多查出来的数据,通过网络IO传输的过程中,也会增加数据传输的时间。

还有一个最重要的问题是:
select *
不会走
覆盖索引
,会出现大量的
回表
操作,而从导致查询sql的性能很低。

那么,如何优化呢?

正例:

select name,age from user where id=1;

sql语句查询时,只查需要用到的列,多余的列根本无需查出来。

2 用union all代替union

我们都知道sql语句使用
union
关键字后,可以获取排重后的数据。

而如果使用
union all
关键字,可以获取所有数据,包含重复的数据。

反例:

(select * from user where id=1) 
union 
(select * from user where id=2);

排重的过程需要遍历、排序和比较,它更耗时,更消耗cpu资源。

所以如果能用union all的时候,尽量不用union。

正例:

(select * from user where id=1) 
union all
(select * from user where id=2);

除非是有些特殊的场景,比如union all之后,结果集中出现了重复数据,而业务场景中是不允许产生重复数据的,这时可以使用union。

3 小表驱动大表

小表驱动大表,也就是说用小表的数据集驱动大表的数据集。

假如有order和user两张表,其中order表有10000条数据,而user表有100条数据。

这时如果想查一下,所有有效的用户下过的订单列表。

可以使用
in
关键字实现:

select * from order
where user_id in (select id from user where status=1)

也可以使用
exists
关键字实现:

select * from order
where exists (select 1 from user where order.user_id = user.id and status=1)

前面提到的这种业务场景,使用in关键字去实现业务需求,更加合适。

为什么呢?

因为如果sql语句中包含了in关键字,则它会优先执行in里面的
子查询语句
,然后再执行in外面的语句。如果in里面的数据量很少,作为条件查询速度更快。

而如果sql语句中包含了exists关键字,它优先执行exists左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。如果匹配上,则可以查询出数据。如果匹配不上,数据就被过滤掉了。

这个需求中,order表有10000条数据,而user表有100条数据。order表是大表,user表是小表。如果order表在左边,则用in关键字性能更好。

总结一下:

  • in
    适用于左边大表,右边小表。
  • exists
    适用于左边小表,右边大表。

不管是用in,还是exists关键字,其核心思想都是用小表驱动大表。

4 批量操作

如果你有一批数据经过业务处理之后,需要插入数据,该怎么办?

反例:

for(Order order: list){
   orderMapper.insert(order):
}

在循环中逐条插入数据。

insert into order(id,code,user_id) 
values(123,'001',100);

该操作需要多次请求数据库,才能完成这批数据的插入。

但众所周知,我们在代码中,每次远程请求数据库,是会消耗一定性能的。而如果我们的代码需要请求多次数据库,才能完成本次业务功能,势必会消耗更多的性能。

那么如何优化呢?

正例:

orderMapper.insertBatch(list):

提供一个批量插入数据的方法。

insert into order(id,code,user_id) 
values(123,'001',100),(124,'002',100),(125,'003',101);

这样只需要远程请求一次数据库,sql性能会得到提升,数据量越多,提升越大。

但需要注意的是,不建议一次批量操作太多的数据,如果数据太多数据库响应也会很慢。批量操作需要把握一个度,建议每批数据尽量控制在500以内。如果数据多于500,则分多批次处理。

5 多用limit

有时候,我们需要查询某些数据中的第一条,比如:查询某个用户下的第一个订单,想看看他第一次的首单时间。

反例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc;

根据用户id查询订单,按下单时间排序,先查出该用户所有的订单数据,得到一个订单集合。 然后在代码中,获取第一个元素的数据,即首单的数据,就能获取首单时间。

List<Order> list = orderMapper.getOrderList();
Order order = list.get(0);

虽说这种做法在功能上没有问题,但它的效率非常不高,需要先查询出所有的数据,有点浪费资源。

那么,如何优化呢?

正例:

select id, create_date 
 from order 
where user_id=123 
order by create_date asc 
limit 1;

使用
limit 1
,只返回该用户下单时间最小的那一条数据即可。

此外,在删除或者修改数据时,为了防止误操作,导致删除或修改了不相干的数据,也可以在sql语句最后加上limit。

例如:

update order set status=0,edit_time=now(3) 
where id>=100 and id<200 limit 100;

这样即使误操作,比如把id搞错了,也不会对太多的数据造成影响。

6 in中值太多

对于批量查询接口,我们通常会使用
in
关键字过滤出数据。比如:想通过指定的一些id,批量查询出用户信息。

sql语句如下:

select id,name from category
where id in (1,2,3...100000000);

如果我们不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。

这时该怎么办呢?

select id,name from category
where id in (1,2,3...100)
limit 500;

可以在sql中对数据用limit做限制。

不过我们更多的是要在业务代码中加限制,伪代码如下:

public List<Category> getCategory(List<Long> ids) {
   if(CollectionUtils.isEmpty(ids)) {
      return null;
   }
   if(ids.size() > 500) {
      throw new BusinessException("一次最多允许查询500条记录")
   }
   return mapper.getCategoryList(ids);
}

还有一个方案就是:如果ids超过500条记录,可以分批用多线程去查询数据。每批只查500条记录,最后把查询到的数据汇总到一起返回。

不过这只是一个临时方案,不适合于ids实在太多的场景。因为ids太多,即使能快速查出数据,但如果返回的数据量太大了,网络传输也是非常消耗性能的,接口性能始终好不到哪里去。

7 增量查询

有时候,我们需要通过远程接口查询数据,然后同步到另外一个数据库。

反例:

select * from user;

如果直接获取所有的数据,然后同步过去。这样虽说非常方便,但是带来了一个非常大的问题,就是如果数据很多的话,查询性能会非常差。

这时该怎么办呢?

正例:

select * from user 
where id>#{lastId} and create_time >= #{lastCreateTime} 
limit 100;

按id和时间升序,每次只同步一批数据,这一批数据只有100条记录。每次同步完成之后,保存这100条数据中最大的id和时间,给同步下一批数据的时候用。

通过这种增量查询的方式,能够提升单次查询的效率。

8 高效的分页

有时候,列表页在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理。

在mysql中分页一般用的
limit
关键字:

select id,name,age 
from user limit 10,20;

如果表中数据量少,用limit关键字做分页,没啥问题。但如果表中数据量很多,用它就会出现性能问题。

比如现在分页参数变成了:

select id,name,age 
from user limit 1000000,20;

mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,这个是非常浪费资源的。

那么,这种海量数据该怎么分页呢?

优化sql:

select id,name,age 
from user where id > 1000000 limit 20;

先找到上次分页最大的id,然后利用id上的索引查询。不过该方案,要求id是连续的,并且有序的。

还能使用
between
优化分页。

select id,name,age 
from user where id between 1000000 and 1000020;

需要注意的是between要在唯一索引上分页,不然会出现每页大小不一致的问题。

9 用连接查询代替子查询

mysql中如果需要从两张以上的表中查询出数据的话,一般有两种实现方式:
子查询

连接查询

子查询的例子如下:

select * from order
where user_id in (select id from user where status=1)

子查询语句可以通过
in
关键字实现,一个查询语句的条件落在另一个select语句的查询结果中。程序先运行在嵌套在最内层的语句,再运行外层的语句。

子查询语句的优点是简单,结构化,如果涉及的表数量不多的话。

但缺点是mysql执行子查询时,需要创建临时表,查询完毕后,需要再删除这些临时表,有一些额外的性能消耗。

这时可以改成连接查询。 具体例子如下:

select o.* from order o
inner join user u on o.user_id = u.id
where u.status=1

10 join的表不宜过多

根据阿里巴巴开发者手册的规定,join表的数量不应该超过
3
个。

反例:

select a.name,b.name.c.name,d.name
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id
inner join d on d.c_id = c.id
inner join e on e.d_id = d.id
inner join f on f.e_id = e.id
inner join g on g.f_id = f.id

如果join太多,mysql在选择索引的时候会非常复杂,很容易选错索引。

并且如果没有命中中,nested loop join 就是分别从两个表读一行数据进行两两对比,复杂度是 n^2。

所以我们应该尽量控制join表的数量。

正例:

select a.name,b.name.c.name,a.d_name 
from a 
inner join b on a.id = b.a_id
inner join c on c.b_id = b.id

如果实现业务场景中需要查询出另外几张表中的数据,可以在a、b、c表中
冗余专门的字段
,比如:在表a中冗余d_name字段,保存需要查询出的数据。

不过我之前也见过有些ERP系统,并发量不大,但业务比较复杂,需要join十几张表才能查询出数据。

所以join表的数量要根据系统的实际情况决定,不能一概而论,尽量越少越好。

11 join时要注意

我们在涉及到多张表联合查询的时候,一般会使用
join
关键字。

而join使用最多的是left join和inner join。

  • left join
    :求两个表的交集外加左表剩下的数据。
  • inner join
    :求两个表交集的数据。

使用inner join的示例如下:

select o.id,o.code,u.name 
from order o 
inner join user u on o.user_id = u.id
where u.status=1;

如果两张表使用inner join关联,mysql会自动选择两张表中的小表,去驱动大表,所以性能上不会有太大的问题。

使用left join的示例如下:

select o.id,o.code,u.name 
from order o 
left join user u on o.user_id = u.id
where u.status=1;

如果两张表使用left join关联,mysql会默认用left join关键字左边的表,去驱动它右边的表。如果左边的表数据很多时,就会出现性能问题。

要特别注意的是在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。

12 控制索引的数量

众所周知,索引能够显著的提升查询sql的性能,但索引数量并非越多越好。

因为表中新增数据时,需要同时为它创建索引,而索引是需要额外的存储空间的,而且还会有一定的性能消耗。

阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在
5
个以内,并且单个索引中的字段数不超过
5
个。

mysql使用的B+树的结构来保存索引的,在insert、update和delete操作时,需要更新B+树索引。如果索引过多,会消耗很多额外的性能。

那么,问题来了,如果表中的索引太多,超过了5个该怎么办?

这个问题要辩证的看,如果你的系统并发量不高,表中的数据量也不多,其实超过5个也可以,只要不要超过太多就行。

但对于一些高并发的系统,请务必遵守单表索引数量不要超过5的限制。

那么,高并发系统如何优化索引数量?

能够建联合索引,就别建单个索引,可以删除无用的单个索引。

将部分查询功能迁移到其他类型的数据库中,比如:Elastic Seach、HBase等,在业务表中只需要建几个关键索引即可。

13 选择合理的字段类型

char
表示固定字符串类型,该类型的字段存储空间的固定的,会浪费存储空间。

alter table order 
add column code char(20) NOT NULL;

varchar
表示变长字符串类型,该类型的字段存储空间会根据实际数据的长度调整,不会浪费存储空间。

alter table order 
add column code varchar(20) NOT NULL;

如果是长度固定的字段,比如用户手机号,一般都是11位的,可以定义成char类型,长度是11字节。

但如果是企业名称字段,假如定义成char类型,就有问题了。

如果长度定义得太长,比如定义成了200字节,而实际企业长度只有50字节,则会浪费150字节的存储空间。

如果长度定义得太短,比如定义成了50字节,但实际企业名称有100字节,就会存储不下,而抛出异常。

所以建议将企业名称改成varchar类型,变长字段存储空间小,可以节省存储空间,而且对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

我们在选择字段类型时,应该遵循这样的原则:

  1. 能用数字类型,就不用字符串,因为字符的处理往往比数字要慢。
  2. 尽可能使用小的类型,比如:用bit存布尔值,用tinyint存枚举值等。
  3. 长度固定的字符串字段,用char类型。
  4. 长度可变的字符串字段,用varchar类型。
  5. 金额字段用decimal,避免精度丢失问题。

还有很多原则,这里就不一一列举了。

14 提升group by的效率

我们有很多业务场景需要使用
group by
关键字,它主要的功能是去重和分组。

通常它会跟
having
一起配合使用,表示分组后再根据一定的条件过滤数据。

反例:

select user_id,user_name from order
group by user_id
having user_id <= 200;

这种写法性能不好,它先把所有的订单根据用户id分组之后,再去过滤用户id大于等于200的用户。

分组是一个相对耗时的操作,为什么我们不先缩小数据的范围之后,再分组呢?

正例:

select user_id,user_name from order
where user_id <= 200
group by user_id

使用where条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。

其实这是一种思路,不仅限于group by的优化。我们的sql语句在做一些耗时的操作之前,应尽可能缩小数据范围,这样能提升sql整体的性能。

15 索引优化

sql优化当中,有一个非常重要的内容就是:
索引优化

很多时候sql语句,走了索引,和没有走索引,执行效率差别很大。所以索引优化被作为sql优化的首选。

索引优化的第一步是:检查sql语句有没有走索引。

那么,如何查看sql走了索引没?

可以使用
explain
命令,查看mysql的执行计划。

例如:

explain select * from `order` where code='002';

结果:

通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:

如果你想进一步了解explain的详细用法,可以看看我的另一篇文章《
explain | 索引优化的这把绝世好剑,你真的会用吗?

说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

下面说说索引失效的常见原因:

如果不是上面的这些原因,则需要再进一步排查一下其他原因。

此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

没错,有时候mysql会选错索引。

必要时可以使用
force index
来强制查询sql走某个索引。

至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

快速搭建 K8s 集群

角色 ip
k8s-master-01 192.168.111.170
k8s-node-01 192.168.111.171
k8s-node-02 192.168.111.172

服务器需要连接互联网下载镜像

软件 版本
Docker 24.0.0(CE)
Kubernetes 1.28

初始化配置

关闭防火墙

systemctl stop firewalld && systemctl disable firewalld

关闭Selinux

sed -i 's/enforcing/disabled/' /etc/selinux/config

setenforce 0

关闭Swap

sed -ri 's/.*swap.*/#&/' /etc/fstab

swapoff -a

根据规划设置主机名

hostnamectl set-hostname k8s-master-01
hostnamectl set-hostname k8s-node-01
hostnamectl set-hostname k8s-node-02

网络桥段

vi /etc/sysctl.conf

net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-arptables = 1
net.ipv4.ip_forward=1
net.ipv4.ip_forward_use_pmtu = 0

# 生效命令
sysctl --system 

# 查看效果
sysctl -a|grep "ip_forward"

确保网络桥接的数据包经过Iptables处理,防止网络丢包

cat > /etc/sysctl.d/k8s.conf << EOF
net.bridge.bridge-nf-call-ip6tables = 1
net.bridge.bridge-nf-call-iptables = 1
EOF

sysctl --system  # 生效

同步时间

# 安装软件
yum -y install ntpdate

# 向阿里云服务器同步时间
ntpdate time1.aliyun.com

# 删除本地时间并设置时区为上海
rm -rf /etc/localtime && ln -s /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# 查看时间
date -R || date

开启 IPVS

yum -y install ipset ipvsdm

cat > /etc/sysconfig/modules/ipvs.modules << EOF
#!/bin/bash
modprobe -- ip_vs
modprobe -- ip_vs_rr
modprobe -- ip_vs_wrr
modprobe -- ip_vs_sh
modprobe -- nf_conntrack
EOF

# 赋予权限并执行
chmod 755 /etc/sysconfig/modules/ipvs.modules && bash /etc/sysconfig/modules/ipvs.modules &&lsmod | grep -e ip_vs -e  nf_conntrack_ipv4

# 重启电脑,检查是否生效
reboot

命令补全

yum -y install bash-completion bash-completion-extras

source /etc/profile.d/bash_completion.sh

配置 HOSTS

cat <<EOF >>/etc/hosts
192.168.111.170		k8s-master-01
192.168.111.171 	k8s-node-01
192.168.111.172 	k8s-node-02
EOF

cat <<EOF >>/etc/hosts
192.168.111.173 k8s-tool-01 harbor.liuyuncen.com
EOF

安装docker

下载源

yum install -y wget

wget https://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo -O /etc/yum.repos.d/docker-ce.repo

安装 docker

# yum list docker-ce --showduplicates | sort -r

yum -y install docker-ce-24.0.0

systemctl enable docker && systemctl start docker

设置Cgroup驱动

cat > /etc/docker/daemon.json << EOF
{
  "exec-opts": ["native.cgroupdriver=systemd"]
}
EOF

systemctl daemon-reload && systemctl restart docker

# 查看设置状态
docker info

安装 cri-docker 驱动 (Docker与Kubernetes通信的中间程序):

# wget https://github.com/Mirantis/cri-dockerd/releases/download/v0.3.2/cri-dockerd-0.3.2-3.el7.x86_64.rpm

rpm -ivh cri-dockerd-0.3.2-3.el7.x86_64.rpm

指定依赖镜像地址为国内镜像地址:

vim /usr/lib/systemd/system/cri-docker.service

ExecStart=/usr/bin/cri-dockerd --container-runtime-endpoint fd:// --pod-infra-container-image=registry.aliyuncs.com/google_containers/pause:3.9

systemctl daemon-reload 
systemctl enable cri-docker && systemctl start cri-docker

部署 K8s 集群

添加阿里云 yum 源

cat > /etc/yum.repos.d/kubernetes.repo << EOF
[kubernetes]
name=Kubernetes
baseurl=https://mirrors.aliyun.com/kubernetes/yum/repos/kubernetes-el7-x86_64
enabled=1
gpgcheck=0
repo_gpgcheck=0
gpgkey=https://mirrors.aliyun.com/kubernetes/yum/doc/yum-key.gpg https://mirrors.aliyun.com/kubernetes/yum/doc/rpm-package-key.gpg
EOF

安装kubeadm 官方提供的集群搭建工具,kubelet 守护进程 和kubectl 管理集群工具(实际在 master 安装即可)

yum install -y kubelet-1.28.0 kubeadm-1.28.0 kubectl-1.28.0

systemctl enable kubelet
# 这里只是设置开机启动,直接起也起不来

提前把所有镜像都拉下来

docker load -i calico.v3.25.1.tar

只需要 k8s-master-01 节点执行
初始化Master节点

kubeadm init \
  --apiserver-advertise-address=192.168.126.170 \
  --image-repository registry.aliyuncs.com/google_containers \
  --kubernetes-version v1.28.0 \
  --service-cidr=10.96.0.0/12 \
  --pod-network-cidr=10.244.0.0/16 \
  --cri-socket=unix:///var/run/cri-dockerd.sock

初始化信息

mkdir -p $HOME/.kube
cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
chown $(id -u):$(id -g) $HOME/.kube/config

只需要 k8s-node-01 、k8s-node-02 节点执行

--cri-socket=unix:///var/run/cri-dockerd.sock
这一句要补充在最后面

kubeadm join 192.168.111.170:6443 --token y8hujn.777t21thlk6v6hy0 --discovery-token-ca-cert-hash sha256:f8df8dfe6cb7ad5347f92b6c58f552df8982c7dce540b266c22f971e49f55684 --cri-socket=unix:///var/run/cri-dockerd.sock
kubeadm join 192.168.126.170:6443 --token puuo90.pqgbmu1d32x3vsq4 --discovery-token-ca-cert-hash sha256:3600ff21c5a3742fee0455d920f345ea0ab9c99f0356acf7675f2ee728448bff --cri-socket=unix:///var/run/cri-dockerd.sock

使用kubectl工具查看节点状态: kubectl get nodes 由于网络插件还没有部署,节点会处于“NotReady”状态

这里使用Calico作为Kubernetes的网络插件,负责集群中网络通信。创建Calico网络组件的资源

kubectl create -f tigera-operator.yaml
kubectl create -f custom-resources.yaml 

执行完成后,等待几分钟,执行

kubectl  get pods -n calico-system -o wide

问题排查,发现 nodes 无法启动,原因是下载不了镜像

kubectl describe po calico-kube-controllers-85955d4f5b-dbhhr -n calico-system
kubectl describe po calico-node-2sdxr -n calico-system
kubectl describe po calico-node-65gw4 -n calico-system
kubectl describe po calico-node-xqvnf -n calico-system

kubectl describe po calico-typha-7cd7bb8d58-lqmxj -n calico-system
kubectl describe po calico-typha-7cd7bb8d58-zwjd4 -n calico-system

kubectl describe po csi-node-driver-d9vkx -n calico-system
kubectl describe po csi-node-driver-nl26v -n calico-system
kubectl describe po csi-node-driver-qzljb -n calico-system
systemctl daemon-reload && systemctl restart docker
docker pull registry.cn-beijing.aliyuncs.com/yuncenliu/cni:v3.25.1-calico
docker tag registry.cn-beijing.aliyuncs.com/yuncenliu/cni:v3.25.1-calico docker.io/calico/pod2daemon-flexvol:v3.25.1

docker pull registry.cn-beijing.aliyuncs.com/yuncenliu/pod2daemon-flexvol:v3.25.1-calico
docker tag registry.cn-beijing.aliyuncs.com/yuncenliu/pod2daemon-flexvol:v3.25.1-calico docker.io/calico/cni:v3.25.1

重启containerd (有用)

systemctl restart containerd

删除失败的 pods

kubectl  get pods -n kube-system | grep calico-node-bvvhc   | awk '{print$1}'| xargs kubectl delete -n kube-system pods
docker save -o calico.v3.25.1.tar calico/kube-controllers:v3.25.1 calico/node:v3.25.1 calico/pod2daemon-flexvol:v3.25.1 calico/cni:v3.25.1 

docker load -i calico.v3.25.1.tar

全部启动成功,节点也全都在线 kubectl get nodes

安装页面

所有节点先下拉镜像

docker load -i k8s-dashboard-v2.7.0.tar

创建 Dashboard ,在 master 节点执行

kubectl apply -f kubernetes-dashboard.yaml
kubectl get pods -n kubernetes-dashboard -o wide

执行完成后,任意
https://任意node:30001
访问,例如:
https://192.168.111.170:30001/#/login

创建用户

kubectl create serviceaccount dashboard-admin -n kubernetes-dashboard

用户授权

kubectl create clusterrolebinding dashboard-admin --clusterrole=cluster-admin --serviceaccount=kubernetes-dashboard:dashboard-admin

获取用户 token

kubectl create token dashboard-admin -n kubernetes-dashboard

将 token 粘贴到浏览器的 token输入栏中(默认还是选第一项)点击登录即可

本文介绍了linux内核态EtherCAT主站igh 中EoE的具体实现,希望对你有所帮助。

1.IgH 框架概述

详见本博客博文
【原创】EtherCAT主站IgH解析(一)--主站初始化、状态机与EtherCAT报文
,不再赘述。

2. IgH EOE机制

【原创】浅谈EtherCAT主站EOE(上)-EOE网络
中已经对EOE有了整体的认识,EOE是与操作系统EtherNet相关的,这意味着不同的主站、在不同的操作系统下具体实现是不一样的,接下来我们看看linux内核态中的igh主站EOE是如何实现的。

2.1 EoE服务规范

按照ETG官方文档中对EoE应用服务的定义,EoE主站需要提供如下服务:

  • 初始化EoE请求(Initiate EoE),暂不清楚其作用和应用场合。

request:EOE frameType 0x02

response: EOE frameType 0x03

  • EoE帧传输请求(EoE Fragment),用于传输主站与从站的标准以太网数据,
    只有请求,没有响应。

request:EOE frameType 0x00

  • 设置IP参数请求(Set IP Parameter),设置从站的IP地址、网关等配置信息,请求-响应模式。

request:EOE frameType 0x02

response: EOE frameType 0x03

  • 设置MAC过滤器请求(Set MAC Filter),请求-响应模式。暂不清楚其作用和应用场合。

request:EOE frameType 0x04

response: EOE frameType 0x05

关于EOE服务的数据结构规范参考ETG100.6.

2.1 EoE虚拟网络设备

首先IgH主站运行在Linux内核态,所以上篇文章
【原创】浅谈EtherCAT主站EOE(上)-EOE网络
中的情况之一。

主站完成从站扫描后,EtherCAT主站根据SII信息中邮箱协议的支持情况,为支持EoE的从站创建虚拟网络设备,注册到linux网络设备驱动中,用于和EoE从站的数据交互,在Linux看来就是多了很多虚拟以太网网卡,每个网络接口对于一个ethercat eoe从站。

image-20210523100431887

2.1.1 EoE Virtual Network Interfaces

创建虚拟网络设备接口时,其接口名称规定如下:

eoe<MASTER>[as]<SLAVE>

[as]
a表示使用别名,
<SLAVE>
则为站点别名;s表示使用别名,
<SLAVE>
则为站点地址,
<MASTER>
表示主站index。

2.1.2 EoE Virtual Network MAC Address

Eoe Mac地址派生自唯一的mac。

现在,EoE MAC地址从可用网络接口的链接列表的第一个全局唯一MAC地址的NIC部分派生,或者从EtherCAT主站使用的MAC地址派生,或者linux随机生成。

EoE MAC地址将采用
02:NIC:NIC:NIC:RP:RP
的格式,其中
NIC
来自唯一的MAC地址,而RP是EoE从站的环位置。

image-202305220916361262

2.2.3 Linux网络数据处理流程

再介绍igh eoe具体实现之前,有必要先介绍Linux网络数据处理,以Linux 应用TCP通信为例,根据上图,简要说明一个网络数据如何到达网络设备驱动层的:

  1. VFS 层:应用通过write socket文件描述符发送数据,
    write
    系统调用找到
    struct file
    ,根据里面的
    file_operations
    的定义,调用
    sock_write_iter
    函数。
    sock_write_iter
    函数调
    sock_sendmsg
    函数。

  2. Socket 层:从
    struct file
    里面的
    private_data
    得到
    struct socket
    ,根据里面
    ops
    的定义,调用
    inet_sendmsg
    函数。

  3. Sock 层:从
    struct socket
    里面的
    sk
    得到
    struct sock
    ,根据里面
    sk_prot
    的定义,调用
    tcp_sendmsg
    函数。

  4. TCP 层:
    tcp_sendmsg
    函数会调用
    tcp_write_xmit
    函数,
    tcp_write_xmit
    函数会调用
    tcp_transmit_skb
    ,在这里实现了 TCP 层面向连接的逻辑;完成TCP头添加传递给IP层。

  5. IP 层:扩展
    struct sock
    ,得到
    struct inet_connection_sock
    ,根据里面
    icsk_af_ops
    的定义,调用
    ip_queue_xmit
    函数。

  6. IP 层:
    ip_route_output_ports
    函数里面会调用
    fib_lookup
    查找路由表。FIB 全称是 Forwarding Information Base,转发信息表,也就是路由表。填写 IP 层的头。还要做的一件事情就是通过
    iptables
    规则。完成IP头添加后,IP 层接着调用
    ip_finish_output
    进行 MAC 层处理。

  7. MAC 层需要 ARP 获得 MAC 地址,因而要调用
    ___neigh_lookup_noref
    查找属于同一个网段的邻居,它会调用
    neigh_probe
    发送 ARP。

  8. 有了 MAC 地址,添加MAC头后,就可以调用
    dev_queue_xmit
    发送二层网络包了,它会调用
    __dev_xmit_skb
    会将请求放入队列,最后到达设备层。

  9. 设备层:网络包的发送会触发一个软中断
    NET_TX_SOFTIRQ
    来处理队列中的数据。

  10. 在软中断处理函数中,会将网络包从队列上拿下来,调用网络设备的传输函数
    ndo_xmit_frame
    ,将网络包发到设备的队列上去。

到此上层应用发送的网络数据包已到达EOE虚拟网络设备驱动中,如何将上层应用的网络数据报通过EtherCAT EOE 发送给从站,这需要主站来实现,具体实现机制后面详细分析。接收从站应用网络数据包是一个相反的过程。

2.2 EoE request实现

对于
Set IP Parameter request

Set MAC Filter request
类似IgH
SDO upload/download
请求机制,简述如下。

  1. 通过
    ioctl()
    向应用提供
    Set IP Parameter

    Set MAC Filter
    请求的接口。
  2. 各参数通过
    ioctl
    传递到内核后,根据参数找到需要操作的从站对象,构建eoe request对象
    eoe_request_t
    ,插入到从站对象
    eoe_requests
    链表中,然后阻塞等待内核线程执行状态机处理完成。

image-20210523100708125

  1. 状态机方面,类似slave状态机下处理sdo下载请求的子状态机
    fsm_coe
    ,在slave状态机下新添加处理eoe请求的子状态机
    fsm_eoe

  2. 内核线程执行slave状态机过程中,slave状态机的READY状态函数判断从站对象的
    eoe_requests
    链表是否有挂起的请求需要处理(链表非空),有则初始化并执行子状态机
    fsm_eoe
    来处理链表上的request。

  3. fsm_eoe
    状态机执行,与从站邮箱通信完成
    Set IP Parameter
    后,唤醒阻塞的用户态应用。

  4. 内核线程继续执行slave状态机的下一个状态。

image-20210523100731954

Fsm_eoe状态机状态状态转换如下:

  • SET_IP_START

    设置Ip参数入口,检查邮箱EOE支持情况,构造设置 ip 请求箱帧,
    发送数据帧,SET_IP_REQUEST状态。

  • SET_IP_REQUEST
    : 设置IP参数请求帧已接收

    发送查询邮箱数据帧,检查邮箱是否有数据,转到SET_IP_CHECK状态。

  • SET_IP_CHECK
    :查询邮箱数据帧已接收,如果从站邮箱有数据,则发送取邮箱数据帧,转到SET_IP_RESPONSE状态。

  • SET_IP_RESPONSE

    邮箱数据帧已接收
    ,若邮箱数据是set IP request 响应数据,检查结果,转到结束或出错。

若不是响应数据,则继续发送查询邮箱数据帧,跳转到SET_IP_CHECK继续检查。

image-20210523100806879

Fsm_eoe状态转换、与从站邮箱数据交互如图所示:

image-20210523100813622

2.3 EoE Frament实现

5.2中,linux网络应用数据经过协议栈层层封装,调用网络设备的传输函数
ndo_xmit_frame
,将网络包发到EOE虚拟网络设备后,主站需要一种机制将EOE虚拟网络设备中的网络数据包通过EtherCAT数据帧发送到从站。

同时,主站需要查询邮箱,将从站TCP/IP应用需要发送的标准以太网数据通过邮箱协议读取,解析并提交给linux网络协议栈接收处理。

image-20210523100925825

2.3.1.EoE Handler

虚拟EoE接口、EOE帧请求状态机和相关功能封装在
ec_eoe_t
类中,称为EoE handler。每个EOE虚拟网卡对应一个EoE handler。主站扫描从站过程中,根据从站对邮箱协议支持情况,为EoE从站创建虚拟设备,同时创建EOE handler,并将EOE handler放到EoE虚拟设备的私有数据中。

主站使用链表
eoe_handlers
来管理主站下的EoE handler,创建后的EoE handler插入该链表中,方便后续内核线程遍历处理。

image-20210523100959641

2.3.2 EoE 状态机

每个EOE数据(以太网数据包)通过邮箱分段发送和接收,根据邮箱的交互特性,则每个EOE handler需要一个状态机来完成EOE数据收发工作。

  • RX_START
    :EOE状态机起始状态,
    发送邮箱查询数据帧
    查询从站邮箱是否新数据。发送后跳转
    RX_CHECK
    状态。

  • RX_CHECK

    邮箱查询数据帧已接收
    ,若从站邮箱没有数据,转到
    TX_START
    开始写邮箱数据。

若邮箱有新数据,
发送读取邮箱数据帧
读取邮箱数据,转到
RX_FETCH

  • RX_FETCH

    读取邮箱数据帧已接收,
    如果邮箱数据不是“EoE Fragment request”,结束读流程转到发送流程TX_START。

如果接受的邮箱数据是一个”EOE Fragment request”如果是一个起始帧,则分配一个新的socket_buffer,否则填充到已有的socket_buffer的正确的位置,转到
RX_START
继续接收。

如果是最后一个帧,将socket_buffer提交到linux网络协议栈处理,完成一个接收流程,开始发送流程,转到
TX_START
状态。

  • TX_START
    :开始一个发送序列,检查
    tx_ring
    是否有网络数据包需要发送,如果没有,转到
    RX_START
    开始一个新的接收序列。

检查一个skb是否发送完毕,若完全发送成功,将其从
tx_ring
删除,通知linux可以继续往网络设备下发数据包。开始发送一个新的skb的第一个EOE帧,转到
TX_SENT
.

  • TX_SENT:
    根据WKC检查第一个EOE帧是否发送成功,第一个EOE帧发送成则转到
    TX_SENT
    发送下一个帧

    直到整个skb发送完。

如果skb的最后一帧发送成功,转到RX_START 开始一个新的接收序列。


image-20210523101024212

2.3.3 EoE 线程

每个EOE虚拟网卡对应一个EoE handler,多个从站就需要多个handler,且Linux协议层下发的skb远大于邮箱大小,每个skb需要多次邮箱通信才能完成一个skb发送,若将所有handler放到
idle_thead

operat_thead
去处理,不仅严重影响其他数据交互的及时性,而且每几个周期只能发送或接收一个EoE片段,这导致数据速率非常低,因为在应用程序周期之间的时间内未执行EoE状态机,EoE数据速率将取决于应用程序任务的周期。

为解决这些问题,故在主站中单独使用一个内核线程
eoe_thead
来处理所有
EoE handler
,在从站邮箱空闲时进行EOE数据发送,保证了带宽的恒定。

image-20210523101124033

添加eoe线程后带来一些其他问题,如从站邮箱并发操作、xenomai实时应用与eoe并发访问主站发送等,需要一定机制解决,这在后面章节4.5介绍。

EOE线程每周期处理一个状态,所以EOE线程的运行周期决定EOE通讯延迟,即与操作系统调度强相关。

2.3.4 TX Ring

为提高各EOE数据吞吐量,充分利用EtherCAT剩余带宽,为每个虚拟网络设备添加
skb_buff
发送缓冲区队列
tx_ring
。每次内核通过接口的
hard_start_xmit()
回调进行发送一个skb时,将这个skb插入
tx_ring
中,再由
eoe_thead
执行EoE handler处理发送;EoE Handler 处理过程中根据
tx_ring
的容量情况通知linux协议栈暂停/开始向该EoE虚拟设备发送数据,以控制流量。

对于接收,EtherCAT总线带宽100Mbps,考虑到是多个从站共同使用、且带宽不完全由EoE占用,上层linux能及时处理,暂不需要。

2.3.5 EoE 处理流程简述

  1. 协议栈处理后的网络数据包skb,通过EOE虚拟设备驱动提供的回调函数
    ndo_xmit_frame()
    插入EoE handler的
    tx_ring
    中,如果该队列已满,则通过调用
    netif_stop_queue()
    来通知上层暂停新skb的下发。

image-20210523101230636

  1. EoE线程循环处理主站下处于激活状态的
    EoE Handler
    ,即执行状态机EoE状态机,解析或填充datagram。

image-20210523101241431

  1. 执行完所有的
    EoE handler
    一个状态后,状态机已构建好EoE通信数据报,调用eoe发送回调函数,将这些数据报对象,插入eoe发送链表
    ext_datagram_queue
    .

  2. 先将
    ext_datagram_queue
    上的数据报转移到主发送链表
    datagram_queue
    ,再调用主发送函数
    ecrt_master_send()
    发送,发送过程与2.4节数据报发送流程一致。

2.4. 共享资源访问控制

若Ether CAT主站仅使用邮箱的CoE功能,CoE功能相关状态机由内核线程
idle_thead
(或
operat_thead
)执行。在此基础上实现EOE功能需要添加内核线程eoe_thread来处理EoE数据,因为两个线程都需要访问主站总线、从站邮箱,所以总线和从站邮箱会成为两个内核线程的共享资源,对它们的访问必须按顺序进行。

2.4.1 主站

多任务下,主站总线是共享资源,必须按顺序对它进行访问。

IDLE阶段

主站IDLE阶段,内核EoE线程、
idle_thead
都需要调用主站发送函数
ecrt_master_send()
访问总线,这个阶段总线顺序访问由master对象中的信号量
io_sem
来保证。

Operation阶段

应用请求主站后,主站处于operation状态,此时
idle_thead
退出,所以总线便成为
EoE线程

周期应用程序
之间的共享资源,这时需要根据具体的操作系统来解决。

  1. 对于普通linux应用,使用普通内核信号量即可,

    ioctl
    系统调用发送的操作前后使用io_sem来保证

  2. 对于Xeomai和RTAI应用,需要区分内核态和用户态:

  • 对于
    用户态应用
    ,若用按普通linux的方式,使用普通内核信号量,会导致实时任务切换到非实时域,影响周期任务实时性。正确的解决方式为有两种:

方法一、将内核线创建为xenomai内核线程,且使用xenomai内核锁来避免域切换(整个协议栈使用xenomai实现,工作量较大)

方法二、将EOE处理循环通过ioctl导出到用户空间,由xenomai用户线程来驱动EOE的处理。

  • 对于
    内核态应用
    ,使用IgH已有的方式,
    内核应用必须负责提供适当的锁定机制和回调函数
    ,如果另一个实例想要访问主站,则它必须通过回调请求总线访问,如下图所示。

image-20210523101346293

风险:周期应用与EOE线程对主站总线的顺序访问,可能加大周期数据的抖动。

2.4.2 slave邮箱

对于邮箱,若不按顺访问,可能出现如下情况

假如主站先后成功对同一个从站下发COE和EoE请求,但从站先将COE响应还是EoE响应写到邮箱未知,若此时邮箱内为EoE响应数据可读,由于读取报文前后的不确定性,可能造成CoE 状态机读取了EoE响应数据,CoE状态机处理时检测到数据类型错误而丢弃。而EoE读取失败,两个状态机均无法正常执行。

为此解决办法是
只让一个状态机去取邮箱数据
,slave对象中添加一个标志表示邮箱有没有被使用,使用mutex或原子变量来保护该变量,当状态机要读邮箱时先判断该标识,若邮箱已被其他状态机使用,先跳转到下一个状态或重试。

image-20210523101417823

这只能保证只有一个线程中的状态机取数据,但还没解决邮箱数据与执行读的状态机不一致导致数据丢失的问题,所以还要解决数据丢失问题:

a. 抽象邮箱数据为ec_mbox_data_t,为每个从站状态机添加从站支持的协议对应的邮箱数据对象ec_mbox_data_t,如下。

image-20210523101428985

b. 主
站接收到
EtherCAT
数据帧解析子报文时,加入报文解析步骤

,解析报文,看是否符合邮箱数据格式,若符合根据邮箱数据类型分别拷贝到对应的ec_mbox_data_t对象中,如接收的报文满足COE邮箱数结构,则将报文数据保存到mbox_coe_data中。这样不论该数据是哪个状态机读取的,该数据都会得到保存。

image-20210523101439719

c. 修改邮箱相关状态机,状态机读邮箱数据时,若邮箱已被占用,直接跳转下一个状态,先检查对应的
ec_mbox_data_t
有无数据,有则处理,没有则回到上check状态重试。

风险:每个子报文数据都需要判断是否为邮箱数据,并解析,增加CPU资源、一周期内主站时间消耗增加;

接收过程中,目前从站对象、数据报之间的查找关系通过遍历链表来实现,随着从站数据量增加,时间复杂度呈线性增长,是否考虑通过其他数据结构优化。

3. EOE虚拟网络设备管理

3.1 Linux Bridge介绍

主站为每个EOE从站创建了虚拟网络设备,有了虚拟网卡,我们很自然就会联想到让网卡接入到交换机里,来实现多个容器间的相互连接。而Linux Bridge就是 Linux 系统下的虚拟化交换机,虽然它是以“网桥”(Bridge)而不是“交换机”(Switch)为名,但在使用过程中,你会发现 Linux Bridge 看起来像交换机,功能使用起来像交换机、程序实现起来也像交换机,所以它实际就是一台虚拟交换机。
Linux Bridge 是在 Linux Kernel 2.2 版本开始提供的二层转发工具,由brctl命令创建和管理。Linux Bridge 创建以后,就能够接入任何位于二层的网络设备,无论是真实的物理设备(比如 eth0),还是虚拟的设备(比如 veth 或者 tap),都能与 Linux Bridge 配合工作。当有二层数据包(以太帧)从网卡进入 Linux Bridge,它就会根据数据包的类型和目标 MAC 地址,按照如下规则转发处理:

  • 如果数据包是广播帧,转发给所有接入网桥的设备。如果数据包是单播帧,且 MAC 地址在地址转发表中不存在,则洪泛(Flooding)给所有接入网桥的设备,并把响应设备的接口与 MAC 地址学习(MAC Learning)到自己的 MAC 地址转发表中。
  • 如果数据包是单播帧,且 MAC 地址在地址转发表中已存在,则直接转发到地址表中指定的设备。

如果数据包是此前转发过的,又重新发回到此 Bridge,说明冗余链路产生了环路。由于以太帧不像 IP 报文那样有 TTL 来约束,所以一旦出现环路,如果没有额外措施来处理的话,就会永不停歇地转发下去。那么对于这种数据包,就需要交换机实现生成树协议(Spanning Tree Protocol,STP)来交换拓扑信息,生成唯一拓扑链路以切断环路。

刚刚提到的这些名词,比如二层转发、泛洪、STP、MAC 学习、地址转发表,等等,都是物理交换机中已经非常成熟的概念了,它们在 Linux Bridge 中都有对应的实现,所以我才说,Linux Bridge 不仅用起来像交换机,实现起来也像交换机。
不过,它与普通的物理交换机也还是有一点差别的,普通交换机只会单纯地做二层转发,
Linux Bridge 却还支持把发给它自身的数据包,接入到主机的三层协议栈中

对于通过brctl命令显式接入网桥的设备,Linux Bridge 与物理交换机的转发行为是完全一致的,它也不允许给接入的设备设置 IP 地址,因为网桥是根据 MAC 地址做二层转发的,就算设置了三层的 IP 地址也没有意义。然而,Linux Bridge 与普通交换机的区别是,除了显式接入的设备外,它自己也无可分割地连接着一台有着完整网络协议栈的 Linux 主机,因为 Linux Bridge 本身肯定是在某台 Linux 主机上创建的,我们可以看作是 Linux Bridge 有一个与自己名字相同的隐藏端口,隐式地连接了创建它的那台 Linux 主机。
因此,Linux Bridge 允许给自己设置 IP 地址,这样就比普通交换机多出了一种特殊的转发情况:如果数据包的目的 MAC 地址为网桥本身,并且网桥设置了 IP 地址的话,那该数据包就会被认为是收到发往创建网桥那台主机的数据包,这个数据包将不会转发到任何设备,而是直接交给上层(三层)协议栈去处理。这时,网桥就取代了物理网卡 eth0 设备来对接协议栈,进行三层协议的处理。

主站为每个EOE从站创建了虚拟网络设备,这些虚拟网络设备需要连接外网,否则上位机或其他网络设备无法与其连接,在linux下,其连接外网有
桥接

NAT
两种方式,两种方式如下图所示。

image-20210523101515791

image-20210523101522580

Linux中使用虚拟网桥实现图中虚拟交换机的功能,创建网桥
eoebr0
,eoe虚拟网卡都连到
eoebr0
上,与上图中不同的是,
物理网卡不连接到eoebr0,通过设置 net.ipv4.ip_forward = 1,开启物理机的转发功能,同时为网桥设置ip地址,作为所有EOE虚拟网卡的网关。

这样其中物理网卡eth0收到数据包,根据数据包的目的ip地址将数据包发往网桥
eoebr0

eoebr0
再根据路由表继续发送数据包到EOE虚拟网卡,最后有EtherCAT master 通过EOE完成数据包到从站的传输。从站发送的EOE数据包一个相反的过程。

注: Linux内核需启用相关模块,以便支持桥接、路由。

3.2 桥接

综上,创建一个包含所有EoE接口的网桥,网桥内从站EOE处于同一网段:


image-20210523101548671

  1. 创建网桥设备
    br0
$sudo brctl addbr br0
  1. 配置网桥设备
    br0
    IP
$sudo ip addr add 192.168.100.1/24 dev br0

$sudo ifconfig br0 192.168.57.1/24
  1. 向br0中添加网卡
$sudo brctl addif br0 eoe0s0
$sudo brctl addif br0 eoe0s1
  1. 设置eoe虚拟网卡IP并up
$sudo ifconfig eoe0s0 0.0.0.0 up
$sudo ifconfig eoe0s1 0.0.0.0 up
  1. 设置从站eoe Ip参数
$sudo ethercat ip addr 192.168.100.2/24 link 00:11:22:33:44:04 default 192.168.100.1 name xxx0 -p 0

$sudo ethercat ip addr 192.168.100.3/24 link 00:11:22:33:44:05 default 192.168.100.1 name xxx1 -p 1
  1. Up
    br0
$sudo ip link set br0 up
  1. Down
    br0
#ip link set br0 down

  1. br0
    中删除网卡
#brctl delif br0 eoe0s0
#brctl delif br0 eoe0s1
  1. 删除网桥
    br0
#brctl delbr br0

上面的示例允许使用通过EoE连接到EtherCAT总线的子网
192.168.100.0/24
访问IPv4节点。

但是,示例使用配置命令临时配置,如果总线拓扑发生更改,则会重新创建EoE接口,并且必须再次将其添加到网桥。 因此,需使用Linux网络配置文件来保存配置,或使用
udev
脚本、应用监测网络热拔插等动态配置方式,以便自动添加出现的EoE设备。

image-20230522091636126

3.3 路由

为每个EoE接口创建一个IP子网:

$ip addr add 192.168.200.1/24 dev eoe0s0
$ip addr add 192.168.201.1/24 dev eoe0s1
$echo 1 > /proc/sys/net/ipv4/ip_forward

如果它们能够在不同的EoE接口IP网络之间进行通信,则必须在连接到EoE从站的IP节点上正确设置默认网关。

如果必须设置从站IP地址和其他参数(而不是主站EoE接口),则可以通过命令行工具
ethercat ip
来实现。

ethercat ip addr 192.168.100.3/24 link 00:11:22:33:44:05 default 192.168.100.1 name xxxx -p 1

EOE虚拟网卡仅负责传输linux网络协议栈下发的数据包,可通过设置linux iptables来过滤不相关的数据包。

4. 总结

EtherCAT EoE协议与普通以太网EtherNet强相关,所以EtherCAT 主站EoE实现方式与具体的操作系统强相关,本文介绍了linux内核态igh的EoE实现方式。

一、通信协议概述

1.串行通信与并行通信

  • 串行通信
    (serial communication):数据通过单根数据线一位一位地传输;成本低但速度慢;适用于远距离传输,用于计算机与外设之间,如UART、
    \(I^2C\)
    、SPI
  • 并行通信
    (parallel communication):通过多根数据线同时将数据的所有位一次传输完成;成本高但传送速度快、效率高;适用于近距离传输,用于计算机内部,如CPU数据总线、存储器译码电路

2.同步通信和异步通信

  • 同步通信
    :发送方发送数据后,需要等待收到接收方发回的响应后再发送下一个数据包。同步通信是阻塞模式;
  • 异步通信
    :发送方发送数据后,不需要等待接收方发回的相应就可以发送下一个数据包。异步通信是非阻塞模式

3.数据流传输模式

  • 单工通信
    :只支持信号在
    一个方向
    上传输(正向或反向),任何时候不能改变信号的传输方向。此种方式适用于数据收集系统,例如计算机和打印机之间的通信是单工模式,因为只有计算机向打印机传输数据,而没有相反方向的数据传输。
  • 半双工通信
    :半双工通信允许信号在
    两个方向
    上传输,但某一时刻只允许信号在一个信道上单向传输,因此,半双工通信实际上是一种
    可切换方向的单工通信
    ,传统的对讲机使用的就是半双工通信方式,由于对讲机传送及接受使用相同的频率,不允许同时进行,通信双方需要通过一定的协议或者机制来协调通信方向
  • 全双工通信
    :允许数据同时在
    两个方向
    上传输,即有两个信道,因此允许同时进行双向传输,全双工通信是两个单工通信方式的结合,要求收发双方都有独立的接受和发送能力。全双工通信效率高,控制简单,但造价高。计算机之间的通信是全双工方式,一般的电话、手机也是全双工的系统,因为在讲话时可以听到对方的声音。

4.通信的基本组成

  • 发出起始信号;
  • 寻址;
  • 数据传输;
  • 发出终止信号

5.不同通信协议的特点

二、UART串口通信

1.概念

通用异步收发传输器
(Universal Asynchronous Receiver/Transmitter,UART):将数据由串行通信与并行通信之间做传输转换的异步全双工串行接口,作为并行输入转为串行输出的芯片,通常集成于其他通讯接口的连接上,其工作原理是将数据一位接一位地进行传输;
异步:没有共同的时钟

2.数据组成

  • 起始位
    :先发出一个逻辑
    “0”
    的信号,表示传输字符的开始。
  • 数据位
    :紧随起始位之后,数据位的个数可以是5、6、7、8等,构成一个字符,通常采用ASCII码,从
    最低位
    开始传送,靠时钟定位
  • 奇偶校验位
    :数据为加上这一位后,使得“1”的位数应为偶数(偶校验)或奇数(奇校验),以此来检验数据传输的正确性
  • 停止位
    :是一个字符数据的结束标志,可以是1位、1.5位、2位的高电平,由于数据是在传输线上定时的,并且每一个设备有其自己的时钟,很可能在通信中两台设备间出现了小小的不同步,因此停止位不仅仅是表示传输的结束,并且提供计算机校正时钟同步的机会。停止位的位数越多,不同始终同步的容忍程度越大,但是数据传输率同时也越慢。
  • 空闲位
    :处于逻辑“1”状态,表示当前线路上没有数据传送

3.工作原理

  • 发送数据过程
    :空闲状态,线路处于高电位;当收到发送数据指令后,拉低线路一个数据位的时间T,接着数据
    按低位到高位
    (小端传输)依次发送,数据发送完毕后,接着发送奇偶校验位和停止位,一帧数据发送结束;

Q:UART通信中,接收方如何识别数据位还是奇偶校验位?
A:在UART通信中,数据帧中数据为的长度是预先设置好的,这个长度在通信开始之前需要由发送方和接收方共同约定好。选择合适的数据位长度可以提高通信效率。例如,在只需传输ASCII字符的情况下,选择7位数据位可以节省带宽;而在需要传输复杂数据的情况下,选择8位数据位可以提供更大的数据传输能力

  • 接收数据过程
    :空闲状态,线路处于高电位;当检测到线路的下降沿(线路电位由高电位变为低电位)时说明线路有数据传输,按照约定的波特率从低位到高位接受数据,数据接受完毕后,开始接收并比较奇偶校验位是否正确,如果正确则通知后续设备准备接收数据或存入缓存

Q:如何区分1->0和11->00?
A:UART波特率发生器:
波特率
是衡量数据传输速率的指标,表示每秒传送数据的字符数,单位是bps,UART的接收和发送是按照相同的波特率进行收发的,发送方与接收方之间的波特率只能相差约10%

Q:发送方和接收方没有共同的时钟,如何保证数据传输的正确性?
A:波特率发生器产生的时钟频率不是波特率时钟频率,而是波特率时钟频率的
16倍
,即每个数据帧
有16个时钟采样
,取
中间的采样值

以传输8位数据为例,当检测到数据的下降沿时,表明线路上有数据进行传输,这时计数器CNT开始计数,当计数器为24=16(起始位)+8(第0位数据中间采样值)时,采样的值为第0位数据;当计数器的值为40时,采样的值为第1位数据,依此类推,进行后面6个数据的采样。如果需要进行奇偶校验,则当计数器的值为152时,采样的值即为奇偶校验位;当计数器的值为168时,采样的值为“1”表示停止位,一帧数据接收完成。

Q:为什么UART无需寻址?
A:UART是一种简单的点对点通信协议,通常用于两个设备之间的直接通信,在这种通信模式下,发送端和接收端是固定的,不需要识别多个设备或节点

三、
\(I^2C\)
串口通信

1.概念

\(I^2C\)
(Inter-Integrated Circuit)总线是PHILIPS公司开发的两线式串行总线,用于连接微控制器及其外围设备。是微电子通信控制领域广泛采用的一种总线标准,他是同步通信的一种特殊形式,具有接口线少,控制方式简单,器件封装形式小,通信速率较高等优点。

2.
\(I^2C\)
总线

\(I^2C\)
总线是一种
多主机总线
,连接在IIC总线上的器件分为主机和从机:主机有权发起和结束一次通信,而从及只能被主机呼叫;
当总线上有多个主机同时启用总线时,IIC也具备
冲突检测和仲裁
的功能来防止错误产生;
每个连接到IIC总线上的器件都有一个
唯一的地址(7bit)
,且每个器件都可以作为主机也可以作为从机(同一时刻只能有一个主机),总线上的器件增加和删除不影响其他器件正常工作
IIC总线在通信时总线上发送数据的器件为发送器,接受数据的器件为接收器;
\(I^2C\)
总线只有两根
双向
信号线,一根是
数据线SDA
,另一根是
时钟线SCL

IIC总线通过上拉电阻接正电源,当总线空闲时,两根线均为
高电平
,连到总线上的任一器件输出的低电平,都将使总线的信号变低,即各器件的SDA以及SCL都是
线“与”关系

线与关系
:多个输出端连接到同一条线上。如果任何一个输出端被拉低,那么整条线的电压就会被拉低。只有当所有连接到这条线上的输出端都保持高电平时,整条线才会是高电平。

3.通信过程

  • 主机发送起始信号启用总线
  • 主机发送从机地址进行寻址
  • 被寻址的从机发送应答信号回应总机
  • 发送器发送一个字节数据
  • 接收器发送应答信号回应发送器
  • ......(循环步骤4、5)
  • 通信完成后主机发送停止信号释放总线

4.通信原理

4.1 起始信号/终止信号
  • SCL线为高电平期间,SDA线由

    电平向

    电平的变化表示
    起始信号
  • SCL线为高电平器件,SDA线由

    电平向

    电平的变化表述
    终止信号

起始和终止信号都是由主机发出的,在起始信号产生后,总线就处于被占用的状态;在终止信号产生后,总线就处于空闲状态

4.2 寻址方式
  • IIC总线上传送的数据是广义的,既包括地址,又包括真正的数据
  • IIC总线通信时每个字节为8位长度,该数据的高7位是从机地址,最低位表示后续字节的传送方向:0表示主机发送数据,1表示主机接收数据
4.3 数据传输与应答
  • 数据发送
    :数据传送时,先传送最高位,后传送低位
  • 从机应答

    • 发送器发送完一个字节数据后,每个从机将主机发送的地址与其子集的地址进行比较
      • 如果地址匹配,则从机通过将SDA线
        拉低一位
        返回一个ACK位;
      • 如果主机的地址与从机的的地址不匹配,则从机将SDA线
        拉高
    • 若由于某种原因从机不对主机寻址信号应答时(如从机正在进行实时性的处理工作而无法接收总线上的数据),它必须将数据线置于
      高电平
      (高电平表示不应答主机的请求),而由主机产生一个终止信号以结束总线的数据传送
    • 接收器件收到一个完整的数据字节后,有可能需要完成一些其他工作,如处理内部中断服务等,这时接收器件可以将SCL线拉成低电平,从而使主机处于等待状态。直到接收器件准备好接收下一个字节时,再释放SCL线使之成为高电平,从而使数据传送可以继续进行
    • 如果从机对主机进行了应答,但在数据传送一段时间后无法继续接收更多的数据,从机可以通过对无法接收的第一个数据字节的“非应答”通知主机,主机则应发出终止信号以结束数据的继续传送
    • 当主机接收数据时,它收到最后一个数据字节后,必须向从机发出一个结束传送的信号,这个信号是由对从机的“非应答”来实现的。然后,从机释放SDA线,以允许主机产生终止信号。
    • 传输完每个数据帧之后,接收设备将另一个ACK位返回给发送方,已确认已经成功接收到该数据帧;随后主机将SCL切换为高电平,然后再将SDA切换为高电平,从而向从机发送终止信号
  • 主机向从机发送数据,数据传送方向在整个传送过程中不变

主机在第一个字节后,立即从从机读取数据

在传送过程中,当需要改变传送方向时,
起始信号

从机地址
都被重复产生一次,但两次读/写方向位正好相反

4.4 信号同步

IIC总线进行数据传送时,时钟信号为高电平期间,数据线上的数据必须保持稳定,只有在时钟线上的信号为
低电平期间
,数据线上的高电平或低电平才允许变化

四、SPI串口通信

1.概念

串行外围设备接口
(Serial Peripheral interface,SPI)是一种高速的,
全双工,同步
的通信总线,并且在芯片的管脚上只占用四根线,节约了芯片的管脚,同时为PCB的布局上节省空间,提供方便。主要应用在EEPROM,FLASH,实时时钟,AD转换器还有数字信号处理器和数字信号解码器之间
SPI分为主、从两种模式,一个SPI通讯系统有且只有一个主设备,以及一个或多个从设备,
提供时钟的为主设备(Master),接收时钟的为从设备(Slave)
。在实际应用中,MCU一般作为主SPI设备,带SPI接口的外围器件作为从设备

2.SPI信号线

  • MOSI
    :SPI总线主机输出/从机输入(SPI Bus Master Output/Slave Input)该引脚在从模式下发送数据,在主模式下接收数据
  • MISO
    :SPI总线主机输入/从机输出(SPI Bus Master Input/Slave Output)该引脚在主模式下发送数据,在从模式下接收数据
  • SCLK
    :时钟信号,由主设备产生
  • SS/CS
    :从设备片选信号,由主设备控制,它的功能是用来作为”片选引脚“,也就是选择指定的从设备,让主设备可以单独地与特定从设备通讯,避免数据线上的冲突

3.数据传输

SPI主机和从机都有一个串行移位寄存器
,主机通过向它的SPI串行寄存器写入一个字节来发起一次传输。

  • 首先拉低对应SS信号线,表示与该设备进行通信
  • 主机通过发送SCLK时钟信号,来告诉从机写数据或者读数据
  • 主机(Master)将要发送的数据写到发送数据缓存区(Menory),缓存区经过移位寄存器(0~7),串行移位寄存器通过MOSI信号线将字节一位一位的移出去传送给从机,,同时MISO接口接收到的数据经过移位寄存器一位一位的移到接收缓存区。
  • 从机(Slave)也将自己的串行移位寄存器(0~7)中的内容通过MISO信号线返回给主机。同时通过MOSI信号线接收主机发送的数据,这样,两个移位寄存器中的内容就被交换。

一个从设备要想能够接收到主设备发过来的控制信号,必须在此之前能够被主设备进行访问。所以主设备必须首先通过SS/CS pin对从设备进行片选,把想要访问的从设备选上

4.SPI的4种模式

依据
时钟极性

时钟相位
可以分为四类

  • 时钟极性(CPOL)配置SCLK的电平处于什么状态时是空闲态
    • CPOL=0:空闲态时为低电平(上升沿触发工作状态)
    • CPOL=1:空闲态时为高电平(下降沿时触发工作状态)
  • 时钟相位(CPHA)配置数据采样是在第几个边沿
    • CPHA=0:在第一个边沿采样数据,第二个边沿交换数据
    • CPHA=1:在第一个边沿交换数据,第二个边沿采样数据