wenmo8 发布的文章

首先,这个问题考察的是你对线程池 execute 方法和 submit 方法的理解,在 Java 线程池的使用中,我们可以通过 execute 方法或 submit 方法给线程池添加任务,但如果线程池中的程序在执行时,遇到了未处理的异常会怎么呢?接下来我们一起来看。

1.execute方法

execute 方法用于提交一个不需要返回值的任务给线程池执行,它接收一个 Runnable 类型的参数,并且不返回任何结果。

它的使用示例代码如下:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecuteDemo {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(5);
        
        // 使用 execute 方法提交任务
        executor.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("Task running in " + Thread.currentThread().getName());
                try {
                    // 模拟任务执行
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    System.err.println("Task was interrupted");
                }
                System.out.println("Task finished");
            }
        });

        // 关闭线程池
        executor.shutdown();
    }
}

2.submit方法

submit 方法用于提交一个需要返回值的任务(Callable 对象),或者不需要返回值但希望获取任务状态的任务(Runnable 对象,但会返回一个 Future 对象)。

它接收一个 Callable 或 Runnable 类型的参数,并返回一个 Future 对象,通过该对象可以获取任务的执行结果或检查任务的状态。

2.1 提交Callable任务

示例代码如下:

import java.util.concurrent.Callable;  
import java.util.concurrent.ExecutionException;  
import java.util.concurrent.ExecutorService;  
import java.util.concurrent.Executors;  
import java.util.concurrent.Future;  
  
public class SubmitCallableDemo {  
    public static void main(String[] args) {  
        // 创建一个固定大小的线程池  
        ExecutorService executorService = Executors.newFixedThreadPool(2);  
  
        // 提交一个 Callable 任务给线程池执行  
        Future<String> future = executorService.submit(new Callable<String>() {  
            @Override  
            public String call() throws Exception {  
                Thread.sleep(2000); // 模拟任务执行时间  
                return "Task's execution result";  
            }  
        });  
  
        try {  
            // 获取任务的执行结果  
            String result = future.get();  
            System.out.println("Task result: " + result);  
        } catch (InterruptedException | ExecutionException e) {  
            e.printStackTrace();  
        }  
  
        // 关闭线程池  
        executorService.shutdown();  
    }  
}

2.2 提交Runnable任务

提交 Runnable 任务并获取 Future 对象,示例代码如下:

import java.util.concurrent.ExecutorService;  
import java.util.concurrent.Executors;  
import java.util.concurrent.Future;  
  
public class SubmitRunnableDemo {  
    public static void main(String[] args) {  
        // 创建一个固定大小的线程池  
        ExecutorService executorService = Executors.newFixedThreadPool(2);  
  
        // 提交一个 Runnable 任务给线程池执行,并获取一个 Future 对象  
        Future<?> future = executorService.submit(new Runnable() {  
            @Override  
            public void run() {  
                System.out.println("Task is running in thread: " + Thread.currentThread().getName());  
            }  
        });  
  
        // 检查任务是否完成(这里只是为了示例,实际使用中可能不需要这样做)  
        if (future.isDone()) {  
            System.out.println("Task is done");  
        } else {  
            System.out.println("Task is not done yet");  
        }  
  
        // 关闭线程池  
        executorService.shutdown();  
    }  
}

3.遇到未处理异常

线程池遇到未处理的异常执行行为和添加任务的方法有关,
也就是说 execute 方法和 submit 方法在遇到未处理的异常时执行行为是不一样的

3.1 execute方法遇到未处理异常

示例代码如下:

import java.util.concurrent.*;

public class ThreadPoolExecutorExceptionTest {
    public static void main(String[] args)  {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                1,
                1,
                1000,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(100));
        // 添加任务一
        executor.execute(() -> {
            String tName = Thread.currentThread().getName();
            System.out.println("线程名:" + tName);
            throw new RuntimeException("抛出异常");
        });
        // 添加任务二
        executor.execute(() -> {
            String tName = Thread.currentThread().getName();
            System.out.println("线程名:" + tName);
            throw new RuntimeException("抛出异常");
        });
    }
}

以上程序的执行结果如下:

从上述结果可以看出,线程池中的核心和最大线程数都为 1 的情况下,到遇到未处理的异常时,执行任务的线程却不一样,这说明了:
当使用 execute 方法时,如果遇到未处理的异常,会抛出未捕获的异常,并将当前线程进行销毁

3.2 submit方法遇到未处理异常

然而,当我们将线程池的添加任务方法换成 submit() 之后,执行结果又完全不同了,以下是示例代码:

import java.util.concurrent.*;

public class ThreadPoolExecutorExceptionTest {
    public static void main(String[] args)  {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                1,
                1,
                1000,
                TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(100));
        // 添加任务一
       Future<?> future = executor.submit(() -> {
            String tName = Thread.currentThread().getName();
            System.out.println("线程名:" + tName);
            throw new RuntimeException("抛出异常");
        });
        // 添加任务二
        Future<?> future2 =executor.submit(() -> {
            String tName = Thread.currentThread().getName();
            System.out.println("线程名:" + tName);
            throw new RuntimeException("抛出异常");
        });
        try {
            future.get();
        } catch (Exception e) {
            System.out.println("遇到异常:"+e.getMessage());
        }
        try {
            future2.get();
        } catch (Exception e) {
            System.out.println("遇到异常:"+e.getMessage());
        }
    }
}

以上程序的执行结果如下:

从上述结果可以看出,submit 方法遇到未处理的异常时,并
将该异常封装在 Future 的 get 方法中,而不会直接影响执行任务的线程,这样线程就可以继续复用了

小结

线程池在遇到未处理的异常时,不同添加任务的方法的执行行为是不同的:

  • execute 方法
    :遇到未处理的异常,线程会崩溃,并打印异常信息。
  • submit 方法
    :遇到未处理的异常,线程本身不会受到影响(线程可以复用),只是将异常信息封装到返回的对象 Future 中。

课后思考

为什么遇到未处理的异常时,execute 方法中的线程会崩溃,而 submit 方法中的线程却可以复用?

本文已收录到我的面试小站
www.javacn.site
,其中包含的内容有:Redis、JVM、并发、并发、MySQL、Spring、Spring MVC、Spring Boot、Spring Cloud、MyBatis、设计模式、消息队列等模块。

前端部分(Vue 3 + Element Plus)

1. 修改
MPS002HList.vue
(主生产计划列表)

a. 添加查询表单

在模板中添加查询表单,包含产品料号、品名、规格和年月的输入项。

<template>
  <div>
    <!-- 查询表单 -->
    <el-form :inline="true" :model="filters" class="demo-form-inline">
      <el-form-item label="产品料号">
        <el-input v-model="filters.bo_no" placeholder="请输入产品料号"></el-input>
      </el-form-item>
      <el-form-item label="品名">
        <el-input v-model="filters.item_name" placeholder="请输入品名"></el-input>
      </el-form-item>
      <el-form-item label="规格">
        <el-input v-model="filters.item_spec" placeholder="请输入规格"></el-input>
      </el-form-item>
      <el-form-item label="年月">
        <el-date-picker
v
-model="filters.mps_ym"type="month"placeholder="选择年月"format="yyyy-MM"value-format="yyyy-MM" /> </el-form-item> <el-form-item> <el-button type="primary" @click="fetchMpsList">查询</el-button> <el-button @click="resetFilters">重置</el-button> </el-form-item> </el-form> <!-- 生产计划列表 --> <el-table :data="mpsList" style="width: 100%" v-loading="loading"> <el-table-column prop="mps_no" label="单号" width="180"> <template #default="{ row }"> <el-button type="text" @click="showMpsDetails(row.mps_no)">{{ row.mps_no }}</el-button> </template> </el-table-column> <el-table-column prop="mps_date" label="单据时间" width="180" /> <el-table-column prop="fa_no_name" label="厂别" width="180" /> <el-table-column prop="bo_no" label="产品料号" width="180" /> <el-table-column prop="bo_no_name" label="品名" width="180" /> <el-table-column prop="bo_no_spec" label="规格" width="180" /> <el-table-column prop="mps_ym" label="年月" width="100" /> <el-table-column prop="mps_qty" label="数量" width="100" /> </el-table> <!-- 分页 --> <el-pagination
v
-if="mpsList.length"background
:current
-page="page":page-size="pageSize"layout="total, prev, pager, next":total="total"@current-change="handlePageChange" /> <!-- 详情对话框 --> <el-dialog :visible.sync="showDetails" width="80%"> <template #header> <h3>主生产计划详情</h3> </template> <MPS002HDetail :mps_no="selectedMpsNo" /> </el-dialog> </div> </template>

b. 修改脚本部分


setup
函数中,添加
filters
数据,并修改
fetchMpsList
函数以包含查询参数。

<script>import { ref, onMounted } from'vue';
import { getMPS002 } from
'@/api/mpsApp/MPS002HModel';
import MPS002HDetail from
'./MPS002HDetail.vue';

export
default{
components: { MPS002HDetail },
setup() {
const mpsList
=ref([]);
const page
= ref(1);
const pageSize
= ref(10);
const total
= ref(0);
const loading
= ref(false);
const showDetails
= ref(false);
const selectedMpsNo
= ref(null);

const filters
=ref({
bo_no:
'',
item_name:
'',
item_spec:
'',
mps_ym:
'',
});

const fetchMpsList
= async () =>{
loading.value
= true;try{
const params
={
page: page.value,
page_size: pageSize.value,
bo_no: filters.value.bo_no,
item_name: filters.value.item_name,
item_spec: filters.value.item_spec,
mps_ym: filters.value.mps_ym,
};
const response
=await getMPS002(params);
mpsList.value
=response.data.results;
total.value
=response.data.count;
}
catch(error) {
console.error(
'Error fetching MPS002 list:', error);
}
finally{
loading.value
= false;
}
};

const resetFilters
= () =>{
filters.value
={
bo_no:
'',
item_name:
'',
item_spec:
'',
mps_ym:
'',
};
fetchMpsList();
};

const showMpsDetails
= (mps_no) =>{
selectedMpsNo.value
=mps_no;
showDetails.value
= true;
};

const handlePageChange
= (newPage) =>{
page.value
=newPage;
fetchMpsList();
};

onMounted(fetchMpsList);
return{
mpsList,
page,
pageSize,
total,
loading,
showDetails,
selectedMpsNo,
filters,
fetchMpsList,
resetFilters,
showMpsDetails,
handlePageChange,
};
},
};
</script>

2. 修改
MPS002D1List.vue
(物料需求明细列表)

a. 添加查询表单

<template>
  <div>
    <!-- 查询表单 -->
    <el-form :inline="true" :model="filters" class="demo-form-inline">
      <el-form-item label="料号">
        <el-input v-model="filters.item_no" placeholder="请输入料号"></el-input>
      </el-form-item>
      <el-form-item label="品名">
        <el-input v-model="filters.item_name" placeholder="请输入品名"></el-input>
      </el-form-item>
      <el-form-item label="规格">
        <el-input v-model="filters.item_spec" placeholder="请输入规格"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="fetchMpsD1List">查询</el-button>
        <el-button @click="resetFilters">重置</el-button>
      </el-form-item>
    </el-form>

    <!-- 物料需求明细列表 -->
    <el-table :data="mpsD1List" style="width: 100%" v-loading="loading">
      <el-table-column prop="item_no" label="料号" width="180" />
      <el-table-column prop="item_name" label="品名" width="180" />
      <el-table-column prop="item_spec" label="规格" width="180" />
      <el-table-column prop="item_qty" label="需求数量" width="180" />
      <!-- 添加更多列 -->
    </el-table>

    <!-- 分页 -->
    <el-pagination
v
-if="mpsD1List.length"background
:current
-page="page":page-size="pageSize"layout="total, prev, pager, next":total="total"@current-change="handlePageChange" /> </div> </template>

b. 修改脚本部分

<script>import { ref, onMounted } from'vue';
import { getMPS002D1 } from
'@/api/mpsApp/MPS002D1Model';

export
default{
setup() {
const mpsD1List
=ref([]);
const page
= ref(1);
const pageSize
= ref(10);
const total
= ref(0);
const loading
= ref(false);

const filters
=ref({
item_no:
'',
item_name:
'',
item_spec:
'',
});

const fetchMpsD1List
= async () =>{
loading.value
= true;try{
const params
={
page: page.value,
page_size: pageSize.value,
item_no: filters.value.item_no,
item_name: filters.value.item_name,
item_spec: filters.value.item_spec,
};
const response
=await getMPS002D1(params);
mpsD1List.value
=response.data.results;
total.value
=response.data.count;
}
catch(error) {
console.error(
'Error fetching MPS002D1 list:', error);
}
finally{
loading.value
= false;
}
};

const resetFilters
= () =>{
filters.value
={
item_no:
'',
item_name:
'',
item_spec:
'',
};
fetchMpsD1List();
};

const handlePageChange
= (newPage) =>{
page.value
=newPage;
fetchMpsD1List();
};

onMounted(fetchMpsD1List);
return{
mpsD1List,
page,
pageSize,
total,
loading,
filters,
fetchMpsD1List,
resetFilters,
handlePageChange,
};
},
};
</script>

后端部分(Django REST Framework)

为了支持前端的查询功能,需要在后端的视图中添加筛选功能。

1. 修改
MPS002HModel
的视图

from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import MPS002HModel
from .serializers import MPS002HSerializer

class MPS002HViewSet(viewsets.ModelViewSet):
queryset
= MPS002HModel.objects.all().order_by('-mps_date')
serializer_class
=MPS002HSerializer
filter_backends
=[DjangoFilterBackend, filters.SearchFilter]
filterset_fields
= ['mps_ym']
search_fields
= ['bo_no__item_no', 'bo_no__item_name', 'bo_no__item_spec']

说明

  • filter_backends
    :使用
    DjangoFilterBackend

    SearchFilter
    ,可以实现精确过滤和模糊搜索。
  • filterset_fields
    :精确过滤的字段,这里包括
    mps_ym
  • search_fields
    :模糊搜索的字段,包括关联的
    bo_no
    (产品料号)的
    item_no

    item_name

    item_spec

2. 修改
MPS002D1Model
的视图

from rest_framework import viewsets, filters
from django_filters.rest_framework import DjangoFilterBackend
from .models import MPS002D1Model
from .serializers import MPS002D1Serializer

class MPS002D1ViewSet(viewsets.ModelViewSet):
queryset
=MPS002D1Model.objects.all()
serializer_class
=MPS002D1Serializer
filter_backends
=[DjangoFilterBackend, filters.SearchFilter]
search_fields
= ['item_no__item_no', 'item_no__item_name', 'item_no__item_spec']

说明

  • search_fields
    :对于物料需求明细,可以根据
    item_no
    (料号)、
    item_name
    (品名)、
    item_spec
    (规格)进行模糊搜索。

3. 安装和配置
django-filter

如果还没有安装
django-filter
,需要先安装:

pip install django-filter

并在
settings.py
中添加:

REST_FRAMEWORK ={'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend']
}

更新序列化器

确保您的序列化器包含必要的字段,以便前端能够正确接收数据。

MPS002HSerializer

from rest_framework import serializers
from .models import MPS002HModel

class MPS002HSerializer(serializers.ModelSerializer):
fa_no_name
= serializers.CharField(source='fa_no.fa_name', read_only=True)
bo_no_name
= serializers.CharField(source='bo_no.item_name', read_only=True)
bo_no_spec
= serializers.CharField(source='bo_no.item_spec', read_only=True)
bo_no
= serializers.CharField(source='bo_no.item_no', read_only=True)

class Meta:
model
=MPS002HModel
fields
= ['id', 'mps_no', 'mps_date', 'fa_no', 'fa_no_name', 'bo_no', 'bo_no_name', 'bo_no_spec', 'mps_ym', 'mps_qty']

MPS002D1Serializer

from rest_framework import serializers
from .models import MPS002D1Model

class MPS002D1Serializer(serializers.ModelSerializer):
item_name
= serializers.CharField(source='item_no.item_name', read_only=True)
item_spec
= serializers.CharField(source='item_no.item_spec', read_only=True)
item_no
= serializers.CharField(source='item_no.item_no', read_only=True)

class Meta:
model
=MPS002D1Model
fields
= ['id', 'mps_no', 'item_no', 'item_name', 'item_spec', 'item_qty', 'rmk']

更新 API 请求

1. 更新前端 API 调用

在您的 API 请求文件中,确保查询参数能够正确传递。

MPS002HModel
API

import request from '@/utils/request';
const baseUrl
= '/mpsApp/MPS002HModel/';

export
functiongetMPS002(params) {returnrequest({
url: baseUrl,
method:
'get',
params,
});
}

MPS002D1Model
API

import request from '@/utils/request';
const baseUrl
= '/mpsApp/MPS002D1Model/';

export
functiongetMPS002D1(params) {returnrequest({
url: baseUrl,
method:
'get',
params,
});
}


总结

通过上述步骤,我们实现了:

  • 前端
    :在
    MPS002HModel

    MPS002D1Model
    的列表页面中添加了查询表单,可以根据指定的字段进行筛选,并将查询条件传递给后端。

  • 后端
    :在 Django REST Framework 的视图中,使用
    django-filter

    SearchFilter
    实现了对指定字段的精确过滤和模糊搜索。

  • 序列化器
    :更新了序列化器,以便在返回数据时包含关联字段的信息,如品名和规格。

  • API 请求
    :确保查询参数能够正确地通过前端 API 请求发送到后端。

这样,用户就可以在前端界面上根据产品料号、品名、规格和年月对主生产计划列表进行查询,也可以在物料需求明细列表中根据料号、品名、规格进行筛选。

原创文章,欢迎转载,转载请注明出处,谢谢。


0. 前言

回顾下
上一讲
的内容。主线程 m0 蓄势待发,准备干活。g0 为 m0 提供了执行环境,P 和 m0 绑定,为 m0 提供活,也就是 goroutine。那么问题来了,活呢?哪里有活给 m0 干?

这一讲我们将介绍 m0 执行的第一个活,也就是 main goroutine。main gouroutine 就是执行 main 函数的 goroutine,有别于用
go
关键字创建的 goroutine,它们在执行过程中有一些区别(后续会讲)。

1. main goroutine 创建

接着上一讲的内容,调度器初始化之后,执行到
asm_amd64.s/rt0_go:352

TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0
		...
	    // create a new goroutine to start program
352 	MOVQ	$runtime·mainPC(SB), AX		// entry
353 	PUSHQ	AX
354 	CALL	runtime·newproc(SB)
355 	POPQ	AX

// dlv 进入到指令执行处
dlv exec ./hello
Type 'help' for list of commands.
(dlv) b /usr/local/go/src/runtime/asm_amd64.s:352
Breakpoint 1 set at 0x45433c for runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:352
(dlv) c
(dlv) si
> runtime.rt0_go() /usr/local/go/src/runtime/asm_amd64.s:353 (PC: 0x454343)
Warning: debugging optimized function
		asm_amd64.s:349 0x454337        e8e4290000      call $runtime.schedinit
        asm_amd64.s:352 0x45433c*       488d05659d0200  lea rax, ptr [rip+0x29d65]
=>      asm_amd64.s:353 0x454343        50              push rax

结合 CPU 执行指令和 Go plan9 汇编代码一起分析。

首先,将
$runtime·mainPC(SB)
地址传给 AX 寄存器,CPU 执行的指令是
mov qword ptr [rsp+0x8], rax
。使用
regs
可以看到 rax 的值,也就是
$runtime·mainPC(SB)
的地址:

(dlv) regs
    Rip = 0x0000000000454343
    Rsp = 0x00007ffd58324080
    Rax = 0x000000000047e0a8        // rax = $runtime.mainPC(SB) = [rsp+0x8]

那么
$runtime.mainPC(SB)
的地址指的是什么呢?我们看
$runtime.mainPC(SB)
的定义:

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

$runtime.mainPC(SB)
是一个为了执行
runtime.main
的函数值。

继续执行
PUSH AX

runtime.mainPC(SB)
放到栈上。注意,这里的栈是 g0 栈,也就是主线程 m0 运行的栈。

接着往下走:

=>      asm_amd64.s:354 0x45ca64        e8f72a0000              call $runtime.newproc
        asm_amd64.s:355 0x45ca69        58                      pop rax

调用
$runtime.newproc
函数,
newproc
就是创建 goroutine 的函数。我们使用
go
关键字创建的 goroutine 都经编译器转换最终调用到
newproc
创建 goroutine。可想而知,这个函数是非常重要的。

进入这个函数我们的操作还是在 g0 栈。

// Create a new g running fn.
// Put it on the queue of g's waiting to run.
// The compiler turns a go statement into a call to this.
func newproc(fn *funcval) {
	gp := getg()                            // gp = g0
	pc := getcallerpc()                     // 获取调用者的指令地址,也就是调用 newproc 时由 call 指令压栈的函数返回地址
	systemstack(func() {
		newg := newproc1(fn, gp, pc)        // 创建 g
		...
	})
}

newproc
调用
newproc1
创建 goroutine,分别介绍传入
newproc1
的参数
fn

gp

pc

首先
fn
是包含
runtime.main
的函数值,打印
fn
如下:

(dlv) print fn
(*runtime.funcval)(0x47e0a8)
*runtime.funcval {fn: 4386432}

可以看到,
fn
是一个指向结构体
funcval
的地址(也就是前面介绍的
$runtime.mainPC(SB)
,地址
0x47e0a8
),该结构体内装的
fn
才是实际执行的
runtime.main
函数的地址:

type funcval struct {
	fn uintptr
	// variable-size, fn-specific data here
}

第二个参数 gp 等于 g0,g0 为主线程 m0 提供运行时环境,pc 是调用 newproc 时由 call 指令压栈的函数返回地址。

参数讲完了,在看下
systemstack
函数。
systemstack
会将
goroutine
运行的
fn
调用到系统栈(g0 栈)运行,这里 m0 已经在 g0 栈上运行了,不用调用。如果不是 g0 栈的 goroutine,比如 m0 运行 g1 栈,则
systemstack
会先将 g1 栈切到 g0 栈,接着运行完 fn 在返回到 g1 栈。详细内容可以参考
这里

现在进入
newproc1(fn, gp, pc)
查看
newproc1
是如何创建新 goroutine 的。

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	mp := acquirem()						// acquirem 获取当前 goroutine 绑定的线程,这里是 m0
	pp := mp.p.ptr()						// 获取该线程绑定的 P,这里 pp = allp[0]

	// 从 P 的本地队列 gFree 或者全局 gFree 队列中获取空闲的 goroutine,如果拿不到则返回 nil
	// 这里是创建 main goroutine 阶段,无空闲的 goroutine
	newg := gfget(pp)		
	if newg == nil {
		newg = malg(stackMin)				// malg 创建新的 goroutine
		casgstatus(newg, _Gidle, _Gdead)	// 创建的 goroutine 初始状态是 _Gidle,这里更新 goroutine 状态为 _Gdead
		allgadd(newg) 						// 增加新 goroutine 到全局变量 allgs
	}
	...
}

首先调用
gfget
获取当前线程 P 或全局空闲队列中空闲的 goroutine,如果没有则调用
malg(stackMin)
创建新 goroutine。
malg(stackMin)
中的
stackMin
等于 2048,也就是 2K。查看
malg
做了什么:

func malg(stacksize int32) *g {
	newg := new(g)													// new 创建 g
	if stacksize >= 0 {												// stacksize = 2048
		stacksize = round2(stackSystem + stacksize)					// stackSystem = 0, stacksize = 2048
		systemstack(func() {
			newg.stack = stackalloc(uint32(stacksize))				// 调用 stackalloc 获得新 goroutine 的栈,新 goroutine 的栈大小为 2K
		})
		newg.stackguard0 = newg.stack.lo + stackGuard
		newg.stackguard1 = ^uintptr(0)
		// Clear the bottom word of the stack. We record g
		// there on gsignal stack during VDSO on ARM and ARM64.
		*(*uintptr)(unsafe.Pointer(newg.stack.lo)) = 0
	}
	return newg
}

malg
创建一个新的 goroutine,并且 goroutine 的栈大小为 2KB。

接着调用
casgstatus
更新 goroutine 的状态为
_Gdead
。然后调用
allgadd
函数将创建的 goroutine 和全局变量
allgs
关联:

func allgadd(gp *g) {
	lock(&allglock)																// allgs 是全局变量,给全局变量加锁
	allgs = append(allgs, gp)													// 将 newg:gp 添加到 allgs
	if &allgs[0] != allgptr {													// allgptr 是一个指向 allgs[0] 的指针,这里是 nil
		atomicstorep(unsafe.Pointer(&allgptr), unsafe.Pointer(&allgs[0]))		// allgptr = &allgs[0]
	}
	atomic.Storeuintptr(&allglen, uintptr(len(allgs)))							// 更新全局变量 allglen = len(allgs)
	unlock(&allglock)															// 解锁
}

继续往下看
newproc1
的执行过程:

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	...
	totalSize := uintptr(4*goarch.PtrSize + sys.MinFrameSize) 	// extra space in case of reads slightly beyond frame
	totalSize = alignUp(totalSize, sys.StackAlign)
	sp := newg.stack.hi - totalSize								// sp 是栈顶指针

	// 设置 newg.sched 的所有成员为 0,后续要对它们重新赋值
	memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
	newg.sched.sp = sp											
	newg.stktopsp = sp

	// newg.sched.pc 表示当 newg 被调度起来运行时从这个地址开始执行指令
	newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
	newg.sched.g = guintptr(unsafe.Pointer(newg))
	gostartcallfn(&newg.sched, fn)

这段代码主要是给
newg.sched
赋值,
newg.sched
的结构体如下:

type gobuf struct {
	sp   uintptr				// goroutine 的 栈顶指针
	pc   uintptr				// 执行 goroutine 的指令地址
	g    guintptr				// goroutine 地址
	ctxt unsafe.Pointer			// 包装 goroutine 执行函数的结构体 funcval 的地址
	ret  uintptr				// 返回地址
	lr   uintptr
	bp   uintptr
}

newg.sched
主要的成员如注释所示,线程通过该结构体就能知道要从哪里运行代码。

在赋值
newg.sched
时,这段代码很有意思:

newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum

它是将
goexit
函数的地址 + 1 在传给
newg.sched.pc
,查看此时
newg.sched.pc
的值:

  4530:         newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
=>4531:         newg.sched.g = guintptr(unsafe.Pointer(newg))
(dlv) print newg.sched
runtime.gobuf {sp: 824633976800, pc: 4540513, g: 0, ctxt: unsafe.Pointer(0x0), ret: 0, lr: 0, bp: 0}
(dlv) print unsafe.Pointer(4540513)
unsafe.Pointer(0x454861)

实际是将
0x454861
传给了
newg.sched.pc
,我们先不管这个
0x454861
,接着往下看。调用
gostartcallfn(&newg.sched, fn)
函数:

func gostartcallfn(gobuf *gobuf, fv *funcval) {
	var fn unsafe.Pointer
	if fv != nil {
		fn = unsafe.Pointer(fv.fn)								// 将 funcval.fn 赋给 fn,实际是 runtime.main 的地址值
	} else {
		fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
	}
	gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp												// 取 g1 的栈顶指针
	sp -= goarch.PtrSize										// 栈顶指针向下减 1 个字节
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc					// 减的 1 个字节空间用来放 abi.FuncPCABI0(goexit) + sys.PCQuantum
	buf.sp = sp													// 将减了 1 个字节的 sp 作为新栈顶
	buf.pc = uintptr(fn)										// 重新将 pc 指向 fn
	buf.ctxt = ctxt												// 将 buf.ctxt 指向 funcval
}

看到这里我们明白了,为什么要加一层
goexit
并且将栈顶指针往下减 1 作为新栈顶了。因为新栈顶在返回时会执行到
goexit
,这也是调度器希望每个 goroutine 都要做的,在执行完执行
goexit
才能真正退出。

好了我们回到
newproc1
继续往下看:

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	...
	newg.parentGoid = callergp.goid					// newg 的 父 id,newg.parentGoid = 0
	newg.gopc = callerpc							// 调用者的 pc
	
	newg.startpc = fn.fn							// newg.startpc = funcval.fn = &runtime.main

	...
	casgstatus(newg, _Gdead, _Grunnable)			// 更新 newg 的状态为 _Grunnable
	newg.goid = pp.goidcache						// 通过 goidcache 获得新的 newg.goid,这里 main goroutine 的 goid 是 1

	...
	releasem(mp)
	return newg
}

至此我们的新的 goroutine 就创建出来了。回顾下,首先给新 goroutine 申请 2KB 的栈空间,接着在新 goroutine 中创建执行 goroutine 的环境
newg.sched
,线程根据
newg.sched
就可以运行 goroutine。最后,设置 goroutine 的状态为 _Grunnable,表示 goroutine 状态就绪可以运行了。

我们根据上述分析画出内存分布如下图:

image

2. 小结

到这里创建 main goroutine 的逻辑基本介绍完了。下一讲,将继续介绍 main gouroutine 是怎么运行起来的。


std::optional

  1. 背景
    在编程时,我们经常会遇到可能会返回/传递/使用一个确定类型对象的场景。也就是说,这个对象可能有一个确定类型的值也可能没有任何值。因此,我们需要一种方法来模拟类似指针的语义:指针可以通过 nullptr来表示没有值。解决方法是定义该对象的同时再定义一个附加的 bool类型的值作为标志来表示该对象是否有值。std::optional<>提供了一种类型安全的方式来实现这种对象。
  2. 占用内存大小
    可选对象所需的内存等于内含对象的大小加上一个 bool类型的大小。因此,可选对象一般比内含对象大一个字节(可能还要加上内存对齐的空间开销)。可选对象不需要分配堆内存,并且对齐方式和内含对象相同。
#include <iostream>
#include <optional>

// 定义一个没有默认构造函数的类
class MyClass {
public:
    explicit MyClass(int value) : data(value) {}
    ~MyClass() {}

    int getData() const {
        return data;
    }

private:
    int data;
};

// 输出 std::optional 是否包含值
void check_optional_value(std::optional<MyClass>& opt) {
    if (opt) {
        std::cout << "Value present: " << opt->getData() << std::endl;
    } else {
        std::cout << "No value present." << std::endl;
    }
}

int main() {
    // 创建一个没有值的 std::optional<MyClass>
    std::optional<MyClass> opt1;
    check_optional_value(opt1);

    // 创建一个有值的 std::optional<MyClass>
    std::optional<MyClass> opt2{MyClass(42)};
    check_optional_value(opt2);

    // 尝试通过 emplace 添加值
    opt1.emplace(24);
    check_optional_value(opt1);

    // 尝试通过 operator= 添加值
    opt1 = MyClass(56);
    check_optional_value(opt1);

    return 0;
}

输出:
Size of i: 4 bytes
Size of St8optionalIiE: 8 bytes
Size of 7MyClass: 4 bytes
Size of St8optionalI7MyClassE: 8 bytes

然而,可选对象并不是简单的等价于附加了bool标志的内含对象。例如,在没有值的情况下,将不会调用内含对象的构造函数(通过这种方式,没有默认构造函数的内含类型也可以处于有效的默认状态)。

3.语义
和 std::variant<>、std::any一样,可选对象有值语义。也就是说,拷贝操作会被实现为深拷贝:将创建一个新的独立对象,新对象在自己的内存空间内拥有原对象的标记和内含值(如果有的话)的拷贝。拷贝一个无内含值的 std::optional<>的开销很小,但拷贝有内含值的 std::optional<>的开销约等于拷贝内含值的开销。另外,std::optional<>对象也支持 move语义。

4.应用
(1)std::optional<>模拟了一个可以为空的任意类型的实例。它可以被用作成员、参数、返回值等。
下面的示例程序展示了将 std::optional<>用作返回值的一些功能:

#include <optional>
#include <string>
#include <iostream>

// 如果可能的话把string转换为int:
std::optional<int> asInt(const std::string& s)
{
    try {
        return std::stoi(s);
    }
    catch (...) {
        return std::nullopt;
    }
}

int main()
{
    for (auto s : {"42", "  077", "hello", "0x33"}) {
        // 尝试把s转换为int,并打印结果:
        std::optional<int> oi = asInt(s);
        if (oi.has_value()) {
            std::cout << "convert '" << s << "' to int: " << oi.value() << "\n";
        }
        else {
            std::cout << "can't convert '" << s << "' to int\n";
        }
    }
}

(2) 另一个使用 std::optional<>的例子是传递可选的参数和设置可选的数据成员:

#include <optional>
#include <string>
#include <iostream>

class Name
{
private:
    std::string first;
    std::optional<std::string> middle;
    std::string last;
public:
    Name (std::string f, std::optional<std::string> m, std::string l)
          : first{std::move(f)}, middle{std::move(m)}, last{std::move(l)} {
    }
    friend std::ostream& operator << (std::ostream& strm, const Name& n) {
        strm << n.first << ' ';
        if (n.middle) {
            strm << *n.middle << ' ';
        }
        return strm << n.last;
    }
};

int main()
{
    Name n{"Jim", std::nullopt, "Knopf"};
    std::cout << n << '\n';

    Name m{"Donald", "Ervin", "Knuth"};
    std::cout << m << '\n';
}

5.std::optional<>类型和操作
(1)std::optional<>类型标准库在头文件
中以如下方式定义了 std::optional<>类:
namespace std {
template class optional;
}
另外还定义了下面这些类型和对象:
• std::nullopt_t类型的 std::nullopt,作为可选对象无值时候的“值”。
• 从 std::exception派生的 std::bad_optional_access异常类,当无值时候访问值将会抛出该异常。
可选对象还使用了 头文件中定义的 std::in_place对象(类型是 std::in_place_t)来支持用多个参数初始化可选对象(见下文)。
(2)std::optional<>的操作
表std::optional的操作列出了 std::optional<>的所有操作:

#include <iostream>
#include <optional>
#include <variant>
#include <vector>
#include <set>
#include <map>
#include <string>
#include <cmath>
#include <functional>
#include <cassert>
#include <complex>


// 使用命名空间简化代码
using namespace std::string_literals;

// 示例 1:构造 std::optional
void construct_optional() {
    std::optional<int> o1; // 不含有值
    assert(!o1.has_value());

    std::optional<int> o2(std::nullopt); // 显式表示不含有值
    assert(!o2.has_value());

    std::optional o3{42}; // 推导出 std::optional<int>
    assert(o3.has_value());
    assert(*o3 == 42);

    std::optional o4{"hello"}; // 推导出 std::optional<const char*>
    assert(o4.has_value());
    assert(*o4 == "hello");

    std::optional o5{"hello"s}; // 推导出 std::optional<std::string>
    assert(o5.has_value());
    assert(*o5 == "hello");

    // 用多个参数初始化可选对象
    std::optional<std::complex<double>> o6{std::in_place, 3.0, 4.0};
    assert(o6.has_value());
    assert(o6->real() == 3.0 && o6->imag() == 4.0);

    // 使用 std::make_optional
    auto o13 = std::make_optional(3.0); // std::optional<double>
    assert(o13.has_value());
    assert(*o13 == 3.0);

    auto o14 = std::make_optional("hello"); // std::optional<const char*>
    assert(o14.has_value());
    assert(*o14 == "hello");

    auto o15 = std::make_optional<std::complex<double>>(3.0, 4.0);
    assert(o15.has_value());
    assert(o15->real() == 3.0 && o15->imag() == 4.0);
}

// 示例 2:访问值
void access_optional_value() {
    std::optional<std::pair<int, std::string>> o{std::make_pair(42, "hello")};
    assert(o.has_value());
    assert(o->first == 42);
    assert(o->second == "hello");

    std::optional<std::string> o2{"hello"};
    assert(o2.has_value());
    assert(*o2 == "hello");

    // 当没有值时访问会导致未定义行为
    o2 = std::nullopt;
    assert(!o2.has_value());
    // std::cout << *o2 << std::endl; // 未定义行为
}

// 示例 3:使用 value_or
void use_value_or() {
    std::optional<std::string> o{"hello"};
    std::cout << o.value_or("NO VALUE") << std::endl; // 输出 "hello"

    o = std::nullopt;
    std::cout << o.value_or("NO VALUE") << std::endl; // 输出 "NO VALUE"
}

// 示例 4:比较
void compare_optionals() {
    std::optional<int> o0;
    std::optional<int> o1{42};
    assert(o0 == std::nullopt);
    assert(!(o0 == 42));
    assert(o0 < 42);
    assert(!(o0 > 42));
    assert(o1 == 42);
    assert(o0 < o1);
    assert(!(o0 > o1));

    std::optional<unsigned> uo;
    assert(uo < 0);
    assert(uo < -42);

    std::optional<bool> bo;
    assert(bo < false);

    std::optional<int> o2{42};
    std::optional<double> o3{42.0};
    assert(o2 == 42);
    assert(o3 == 42);
    assert(o2 == o3);
}

// 示例 5:修改值
void modify_optional_value() {
    std::optional<std::complex<double>> o; // 没有值
    std::optional<int> ox{77}; // optional<int>,值为77
    o = 42; // 值变为 complex(42.0, 0.0)
    assert(o.has_value());
    assert(o->real() == 42.0 && o->imag() == 0.0);

    o = std::complex<double>{9.9, 4.4}; // 值变为 complex(9.9, 4.4)
    assert(o.has_value());
    assert(o->real() == 9.9 && o->imag() == 4.4);

    o = ox; // OK,因为 int 转换为 complex<double>
    assert(o.has_value());
    assert(o->real() == 77.0 && o->imag() == 0.0);

    o = std::nullopt; // o 不再有值
    assert(!o.has_value());

    o.emplace(5.5, 7.7); // 值变为 complex(5.5, 7.7)
    assert(o.has_value());
    assert(o->real() == 5.5 && o->imag() == 7.7);

    o.reset(); // o 不再有值
    assert(!o.has_value());

    o = std::complex<double>{88.0, 0.0}; // OK:值变为 complex(88.0, 0.0)
    assert(o.has_value());
    assert(o->real() == 88.0 && o->imag() == 0.0);

    o = std::complex<double>{1.2, 3.4}; // OK:值变为 complex(1.2, 3.4)
    assert(o.has_value());
    assert(o->real() == 1.2 && o->imag() == 3.4);
}

// 示例 6:使用 lambda 初始化 set
void initialize_set_with_lambda() {
    auto sc = [](int x, int y) {
        return std::abs(x) < std::abs(y);
    };

    std::optional<std::set<int, decltype(sc)>> o8{std::in_place,
                                                   std::initializer_list<int>{4, 8, -7, -2, 0, 5},
                                                   sc};
    assert(o8.has_value());
    assert(o8->size() == 6);
}

int main() {
    construct_optional();
    access_optional_value();
    use_value_or();
    compare_optionals();
    modify_optional_value();
    initialize_set_with_lambda();
    return 0;
}

6.注意
(1)value()和 value_or()
value()和 value_or()之间有一个需要考虑的差异:4 value_or()返回值,而 value()返回引用。这意味着如下调用:
std::cout << middle.value_or("");
和:
std::cout << o.value_or("fallback");
都会暗中分配内存,而 value()永远不会。
然而,当在临时对象 (rvalue)上调用 value_or()时,将会移动走内含对象的值并以值返回,而不是调用拷贝函数构造。这是唯一一种能让 value_or()适用于 move-only的类型的方法,因为在左值 (lvalue)上调用的 value_or()的重载版本需要内含对象可以拷贝。
因此,上面例子中效率最高的实现方式是:
std::cout << o ? o‐>c_str() : "fallback";
而不是:
std::cout << o.value_or("fallback");
value_or()是一个能够更清晰地表达意图的接口,但开销可能会更大一点。
(2)bool 类型或原生指针的可选对象
将可选对象用作 bool值时使用比较运算符会有特殊的语义。如果内含类型是 bool或者指针类型的话这可能导致令人迷惑的行为。例如:
std::optional
ob{false}; // 值 为false
if (!ob) ... // 返 回false
if (ob == false) ... // 返 回true
std::optional<int*> op{nullptr};
if (!op) ... // 返 回false
if (op == nullptr) ... // 返 回true

大家好!我是付工。

2012年开始接触Modbus协议,至今已经有10多年了,从开始的懵懂,到后来的顿悟,再到现在的开悟,它始终岿然不动,变化的是我对它的认知和理解。

今天跟大家聊聊关于Modbus协议的那些事。

发展历史

Modbus于1979年诞生,已经历经了40多年。

Modbus诞生在一个特定的时期。1969年,第一台PLC的发明,解决了数字电路代替传统继电器控制的问题,10年之后,Modbus的发明,主要用于解决PLC之间通信的问题。

这些年,它凭借了免费开放、简单易懂等特点,广泛应用在工业自动化领域的各种产品中。

莫迪康当初发明Modbus时,主要针对的是串口设备,即ModbusRTU和ModbusASCII协议,后来施耐德在其基础上发明了针对以太网设备的ModbusTCP。

Modbus协议的诞生与发展,是工业自动化领域技术进步的必然结果,各种工业设备之间的数据交互,必然需要一个高效可靠的协议来支持。

即使1979年莫迪康没有发明Modbus,也许1989年恩迪康也会发明一个Nodbus出来。

协议基础

Modbus协议可以说是所有协议的基础,学习上位机开发自然也离不开它。

我认为,学习Modbus有两个层面,第一个是应用层面,第二个是报文层面。

应用层面可以让我们借助开源通信库很轻松实现设备通信;而报文层面,可以让我们自己写通信库。

可能有人会这么问,既然有开源通信库了,我们是不是就可以不用学Modbus协议报文,直接用现成的通信库?

初期也许可以,但是从长远角度来看,既然选择了上位机这条路,未来必然还会遇到各种各样的协议,而Modbus协议恰恰是一个非常好的学习和练手的机会。

我们把它当作一个跳板,我们学习它,不仅仅是为了使用它,也为理解其他协议奠定一个扎实的基础。所以初学时,一定不要错过这个机会,否则,你会折返跑的。

存储区分类

我喜欢站在协议制定者角度,并结合身边的一些事物来介绍Modbus协议。

首先我们要明确,协议的目的是为了实现数据交互。

么,我们先从【数据】入手,数据必然需要一个载体,自然就有了存储区的概念,这个存储区类似于我们电脑的硬盘。

硬件要分区,存储区也要分类。

至于如何分类,首先我们想到根据数据类型来分,但是不可能每个数据类型分一个,那样太多了,我们将布尔和非布尔分开,因此就有了线圈和寄存器的概念。

在电气回路中,接触器和中继都是靠线圈得电和失电来控制,因此用线圈来表示布尔,而寄存器在PLC中也是用来存储数据的,因此用寄存器来表示非布尔,一个寄存器就表示一个Word。

Modbus更类似于日系和国产PLC,线圈存储区类似于X、Y、M存储区,寄存器存储区类似于D、W、H存储区。

X和Y同样是线圈存储区,X表示的是输入,Y表示的是输出,输入意味着该存储区数据由外部设备接入,是只读的,输出表示输出给外部设备,是可读可写的。

因此,Modbus的线圈和寄存器存储区,还需要按照读写特性,进一步细分,因此形成了Modbus的4个存储区,如下表所示:

序号 读写 存储类型 存储区名称
1 只读 线圈 输入线圈
2 读写 线圈 输出线圈
3 只读 寄存器 输入寄存器
4 读写 寄存器 保持型寄存器


存储区代号

存储区名称是一个完整称呼,实际应用的时候会比较麻烦,因此我们会给这些存储区取一个代号,这个和PLC是一样的,PLC我们只说X区、Y区、D区,只不过PLC使用的是字母作为代号,而Modbus使用的是数字,于是便有了存储区代号表:

存储区名称 存储区代号
输入线圈 1区
输出线圈 0区
输入寄存器 3区
保持型寄存器 4区
这个存储区代号中是没有2区的,这个其实没有理由,也许就是莫迪康单纯的不喜欢2这个数字,这一点和我们国人一样。

Modbus地址

任何一个存储区都是有范围的,比如西门子的M区只有8192个字节,三菱的D区有8000个字,高端系列有18000个字,我们电脑硬盘也是,以前500G很大了,现在动辄1T、2T,都终究有个范围,因此Modbus的存储区也是有范围的,不可能无限大。

Modbus协议是这么规定的,每个存储区最多可能存放65536个线圈或寄存器,这个范围已经很大了。存储区地址是从0开始的,那么对于每个存储区来说,地址范围则从0到65535。

存储区名称 存储区地址
输入线圈 0-65535
输出线圈 0-65535
输入寄存器 0-65535
保持型寄存器 0-65535

这时候会遇到一个问题,比如你跟别人说地址100,别人是不知道是哪个存储区的100,因为每个存储区都有100,那么如何解决这个问题呢?

我们来看下PLC是如何定义的,首先看一个PLC的变量地址,比如D100,这个D100是由D+100组合而成,D是存储区代号,100是地址偏移量,这样的地址模型就直接包含了存储区,这里的D100我们可以理解为绝对地址,而后面的地址偏移量100可以理解为相对地址。

所谓绝对地址,就是通过地址名称,就能明确知道是什么存储区的第几个位置的数据,而相对地址就是地址偏移量,因此绝对地址是唯一的,而相对地址,每个存储区都有。

Modbus仍然遵守这个公式:绝对地址=存储区代号+相对地址。

Modbus和PLC有两个地方不同:

1、PLC的存储区代号是字母,所以可以直接拼接,但是Modbus的存储区代号是数字,如果直接拼接,会导致地址混乱,比如4区的第10个地址,叫410,而0区的410地址也是410,因此必须要保证总长度固定,相对地址始终占5位,不足补0,于是便有了下面的表格,该表格只是当前理解下的表格,并不是最终正确的表格:

2、Modbus协议规定:以保持型寄存器存储区为例,第一个地址不是400000,而是400001,这个是由Modbus规约决定的,其他存储区也是同样的道理。
因此正确的Modbus存储区范围如下表所示:

前面提到过,65536是一个非常大的范围,在实际使用中,我们可能根本用不到这么多地址。于是为了使用方便,还有一种短地址模型,即5位地址模型,前面的称为长地址模型,即6位地址模型,短地址模型存储区范围如下表所示:

直到这里,我们才看到了熟悉的40001,40001这个地址是这样逐步演变出来的。

功能码

我们回到原点,协议的目的是为了实现数据交互。

前面一直在围绕【数据】,下面围绕【交互】说明。

交互即读写。

我们已经有了4个不同的存储区,那么我们对这些存储区的读写,必然会产生很多不同的行为,比如读取输出线圈和写入输出线圈,即为2种不同的行为。我们给这些行为取个代号,即为功能码。

功能码就是Modbus读写行为的代号。

那么会有多少种不同的行为呢?

读取和写入是2种不同的动作,而对象即为4个存储区,排列组合即为2*4=8个,但是输入线圈和输入寄存器是不能写入的,因此8-2=6,如下图所示:

序号 具体行为
1 读取输入线圈
2 读取输出线圈
3 读取输入寄存器
4 读取保持寄存器
5 写入输出线圈
6 写入保持型寄存器

Modbus协议规定:对写入输出线圈和写入保持型寄存器进行细分,分为单个写入和多个连续写入,因此前面的6种行为又变成了8种形成,同时给每个行为取个代号,即形成了我们常说的8大功能码,如下图所示:

功能码 功能说明
0x01 读取输出线圈
0x02 读取输入线圈
0x03 读取保持寄存器
0x04 读取输入寄存器
0x05 写入单个线圈
0x06 写入单个寄存器
0x0F 写入多个线圈
0x10 写入多个寄存器
Modbus协议除了这8种常用的读写功能码,还有一些用于诊断异常的功能码,但是一般很少使用,了解即可。

协议分类

Modbus协议是一个统称,有三个协议家族,分别是ModbusRTU、ModbusASCII和ModbusTCP。

我们常说A和B之间进行Modbus通信,这句话是不严谨的,应当明确指出具体使用哪种通信协议。

一般情况下,ModbusRTU和ModbusASCII用于串行通信,ModbusTCP用于以太网通信,但是这并不是绝对的,因为Modbus协议只是一种应用层的协议,并没有指定物理层,比如,ModbusRTU协议也可以使用在以太网中进行数据传输。

如果准确划分,应该有7种不同的通信方式,我们实际主要使用ModbusRTU和ModbusTCP,其他的使用较少。

报文格式

针对ModbusRTU、ModbusASCII、ModbusTCP这三种不同的协议,在学习时,并不需要学习三次,只要把某一种弄明白,其他两种很容易上手,一般我们以ModbusRTU作为入口,先学习ModbusRTU协议,ModbusASCII了解即可,再学习ModbusTCP协议,下面分别对这三种协议的报文格式进行说明:

1、ModbusRTU的通用报文格式如下:第一部分:从站地址,占1个字节

第二部分:功能码,占1个字节

第三部分:数据部分,占N个字节

第四部分:校验部分,CRC校验,占2个字节

2、ModbusASCII的通用报文格式如下:

第一部分:开始字符(:)

第二部分:从站地址,占2个字节

第三部分:功能码,占2个字节

第四部分:数据部分,占N个字节

第五部分:校验部分,LRC校验,占2个字节

第六部分:结束字符(CR LF)

3、ModbusTCP的通用报文格式如下:

第一部分:事务处理标识符,占2个字节

第二部分:协议标识符,占2个字节

第三部分:长度,占2个字节

第四部分:单元标识符,占1个字节

第五部分:功能码,占1个字节

第六部分:数据部分,占N个字节

具体报文内容通过后面的文章进行阐述。

Modbus学习成本很低,因为协议是公开免费的,而且有很丰富的调试工具,甚至可以在不购买任何硬件的情况下,把Modbus协议学得很透彻。

当然如果有条件,购买一些硬件配合学习,效果更佳。