nuxt.js用nginx配置

Example of deployment process which I use in my Nuxt.js projects.
I usually have 3 components running per project: admin-panel SPA, nuxt.js renderer and JSON API.

This manual is relevant for VPS such as DigitalOcean.com or Vultr.com. It’s easier to use things like Now for deployment but for most cases VPS gives more flexebillity needed for projects bigger than a landing page.

UPD: This manual now compatible with nuxt@2.3. For older versions deployment, see revision history.


Let’s assume that you have entered fresh installation of Ubuntu instance via SSH. Let’s rock:

1.Initial setup.

Depending on size of project you have two options: make non-root user or go on with root. I’d suggest you to go with root, which is easier (except nginx part). If you have chosen non-root, check out this tutorial.

2.Install Node.js.

Using nvm or directly:

curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash -
sudo apt-get install -y nodejs

3.Directory structure

It’s actually up to you, depending on type of your project, but I usually use this structure:

  • user dir (ex., /root)
    • config.yml (pm2 config)
    • app (project name)
      • client (nuxt.js files)
      • server (API server files)
      • public (static content)

Fill server dir with your API code, in my case it’s nodejs + koa2 + mongoose stack. Place at least favicon and robots.txt to public dir.

4.Upload nuxt.js bundle.

Run npm run build on your local machine. I don’t recommend to build nuxt.js on production server, because it eats lots of memory and causes up to minute of downtime. Take package.json, nuxt.config.js and .nuxt dir and copy them via SFTP (or pull from git) to client dir.

Note that there is a special Nuxt package for running production renderer without useless overhead - nuxt-start. Intall it as dependency with the same version as your Nuxt package. Move to your client directory on the server and install production dependencies (in most cases you only need nuxt-start package): npm i -—production.

5.Set up PM2

PM2 is a process manager for node.js. Install PM2 and create config file in your user root dir: config.yml. See config example below. Don’t forget to run pm2 save && pm2 startup, so your processes will recover on server restart.

6.Set up Nginx

Install nginx:

sudo apt-get update
sudo apt-get install nginx

(If you use root user, you have to set right permissions for project dirs to make it work with static files. Or just change user: www-data to user: root in /etc/nginx/nginx.conf.)
Then edit config file /etc/nginx/sites-available/default, see config example below. Don’t forget to sudo nginx -s reload after every nginx config modification.

If you have already connected domain to your project, it’s super easy to set up https (and http2). See this tutorial for installing certbot.

7.Fire it up!

Move to dir that contains your pm2 config file and run pm2 start config.yml –-env production. Yay, everything should work now, but it doesn’t. Run pm2 logs to see errors. This manual is complicated for a newbie and imperfect itself, so you will probably have to try several times. But it’s worth it.


Contributions to this manual are appreciated.

pm2 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(Don't forget to remove comments)
---
apps:
- name: client # process name. You will use it to make commands such as pm2 restart client
script: node_modules/nuxt-start/bin/nuxt-start.js # path to nuxt-start renderer from root nuxt dir. Don't forget to install nuxt-start dependency
cwd: /root/app/client # absolute path to nuxt dir
max_memory_restart: "250M" # in case nuxt renderer eats all memory, it will be restarted
args: "start" # command to skip build and start renderer
env_production:
PORT: 3000 # local port. Same port should be set in nginx config
NODE_ENV: production # in case of enviroment variables usage

# API server, if you have one
- name: server
script: app.js
cwd: /root/app/server
env_production:
PORT: 3001
NODE_ENV: production

nginx 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
server {
listen 80;
listen 443 ssl http2;

# set path to your project dir
set $root_path /root/app;

# your domain
server_name domain.com;

# static content directory
root $root_path/public;

# proxy to nuxt renderer.
location / {
proxy_pass http://localhost:3000;
}

# entry point for API server, if you have one
location /api {
proxy_pass http://localhost:3001;
client_max_body_size 3m;
}

# entry point for SPA admin page, if you have one
location /admin {
try_files /admin/index.html =404;
}

# serve nuxt bundle with max cache life. Only compatible with nuxt 2.*. For 1.*, remove last 'client' from alias
location ~^\/_nuxt(.*)$ {
alias $root_path/client/.nuxt/dist/client/$1;
gzip on;
gzip_comp_level 6;
gzip_vary on;
gzip_types text/css application/json application/javascript text/javascript application/x-font-ttf font/opentype;
expires max;
}

# serve static content
location ~* \.(js|jpg|jpeg|txt|png|css|pdf|ico|map)$ {
gzip_static on;
expires 30d;
}

# refirect from /path/ to /path
rewrite ^/(.*)/$ /$1 permanent;
}

# redirect for domain aliases
server {
server_name www.domain.com;
return 301 https://$host$request_uri;
}

# placeholder if user requests your servers' IP.
server {
listen 80 default_server;
listen [::]:80 default_server;

root /var/www/html;

server_name _;

location / {
try_files $uri =404;
}
}

nginx配置文件解析

location 匹配命令解释

参数 解释
空 location 后没有参数直接跟着 标准 URI,表示前缀匹配,代表跟请求中的 URI 从头开始匹配。
= 用于标准 URI 前,要求请求字符串与其精准匹配,成功则立即处理,nginx 停止搜索其他匹配。
^~ 用于标准 URI 前,并要求一旦匹配到就会立即处理,不再去匹配其他的那些个正则 URI,一般用来匹配目录
~ 用于正则 URI 前,表示 URI 包含正则表达式, 区分大小写
~* 用于正则 URI 前, 表示 URI 包含正则表达式, 不区分大小写
@ @ 定义一个命名的 location,@ 定义的 locaiton 名字一般用在内部定向,例如 error_page, try_files 命令中。它的功能类似于编程中的 goto。

location 匹配顺序

nginx 有两层指令来匹配请求 URI 。第一个层次是 server 指令,它通过域名、ip 和端口来做第一层级匹配,当找到匹配的 server 后就进入此 server 的 location 匹配。

location 的匹配并不完全按照其在配置文件中出现的顺序来匹配,请求 URI 会按如下规则进行匹配:

  1. 先精准匹配 = ,精准匹配成功则会立即停止其他类型匹配;
  2. 没有精准匹配成功时,进行前缀匹配。先查找带有 ^~ 的前缀匹配,带有 ^~ 的前缀匹配成功则立即停止其他类型匹配,普通前缀匹配(不带参数 ^~ )成功则会暂存,继续查找正则匹配;
  3. = 和 ^~ 均未匹配成功前提下,查找正则匹配 ~ 和 ~* 。当同时有多个正则匹配时,按其在配置文件中出现的先后顺序优先匹配,命中则立即停止其他类型匹配;
  4. 所有正则匹配均未成功时,返回步骤 2 中暂存的普通前缀匹配(不带参数 ^~ )结果

以上规则简单总结就是优先级从高到低依次为(序号越小优先级越高):

1
2
3
4
5
6
location =    # 精准匹配
location ^~ # 带参前缀匹配
location ~ # 正则匹配(区分大小写)
location ~* # 正则匹配(不区分大小写)
location /a # 普通前缀匹配,优先级低于带参数前缀匹配。
location / # 任何没有匹配成功的,都会匹配这里处理

按理说明

  1. 前缀匹配下,返回最长匹配的 location,与 location 所在位置顺序无关
1
2
3
4
5
6
7
8
9
server {
server_name website.com;
location /doc {
return 702;
}
location /docu {
return 701;
}
}

参考说明:https://segmentfault.com/a/1190000022315733

node 安装 pm2

  1. pm2 安装
    npm install pm2 -g

  2. pm2 的配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
module.exports = {
apps: [
{
name: "lunbuka-m-h5", // 这个name就是pm2启动时需要的name
exec_mode: "cluster",
instances: "max", // Or a number of instances
script: "./node_modules/nuxt/bin/nuxt.js",
args: "start --mode prod",
out_file: "/data/pm2/logs/lunbuka-m-h5_out.log",
error_file: "/data/pm2/logs/lunbuka-m-h5_error.log",
combine_logs: true,
merge_logs: true,
cwd: "/data/web/kalunbu-m-h5", // absolute path to nuxt dir
max_memory_restart: "512M" // in case nuxt renderer eats all memory, it will be restarted
}
]
};

配置文件更改后,需要 kill pm2, 重新启动才能生效

  1. 日志文件分割
    pm2 install pm2-logrotate

    常用命令
    pm2 set pm2-logrotate:max_size 10K
    pm2 set pm2-logrotate:retain 30
    pm2 set pm2-logrotate:compress false
    pm2 set pm2-logrotate:dateFormat YYYY-MM-DD_HH-mm-ss
    pm2 set pm2-logrotate:workerInterval 30
    pm2 set pm2-logrotate:rotateInterval 0 0 * * *
    pm2 set pm2-logrotate:rotateModule true

  2. pm2 常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# Fork mode
pm2 start app.js --name my-api # Name process

# Cluster mode
pm2 start app.js -i 0 # Will start maximum processes with LB depending on available CPUs
pm2 start app.js -i max # Same as above, but deprecated.
pm2 scale app +3 # Scales `app` up by 3 workers
pm2 scale app 2 # Scales `app` up or down to 2 workers total

# Listing

pm2 list # Display all processes status
pm2 jlist # Print process list in raw JSON
pm2 prettylist # Print process list in beautified JSON

pm2 describe 0 # Display all informations about a specific process

pm2 monit # Monitor all processes

# Logs

pm2 logs [--raw] # Display all processes logs in streaming
pm2 flush # Empty all log files
pm2 reloadLogs # Reload all logs

# Actions

pm2 stop all # Stop all processes
pm2 restart all # Restart all processes

pm2 reload all # Will 0s downtime reload (for NETWORKED apps)

pm2 stop 0 # Stop specific process id
pm2 restart 0 # Restart specific process id

pm2 delete 0 # Will remove process from pm2 list
pm2 delete all # Will remove all processes from pm2 list

# Misc

pm2 reset <process> # Reset meta data (restarted time...)
pm2 updatePM2 # Update in memory pm2
pm2 ping # Ensure pm2 daemon has been launched
pm2 sendSignal SIGUSR2 my-app # Send system signal to script
pm2 start app.js --no-daemon
pm2 start app.js --no-vizion
pm2 start app.js --no-autorestart

参考:https://pm2.keymetrics.io/docs/usage/quick-start/

ubuntu空间满,启动修复

  1. 电脑开机
  2. 按住 shift
  3. 进入 Recovery 模式
  4. 加载后可以看到“Recovery Menu”菜单,该菜单有如下选项:“resume”——正常载入系统;“clean”——尝试释放空闲空间;“dpkg”——修复受损的包;“failsafeX”——运行缺失保护的图形模块;“fsck”——检查文件系统;“grub”——更新 grub 加载器;“network”——允许网络连接;“root”——停用 root shell prompt;“system-summary”——系统摘要。
  5. 由于空间不足可以选择 clean 释放空间进入系统
    或者
    选择 root 输入 mount -o remount,rw /命令获得读写权限,然后就可以删除系统中没用的文件,然后 exit 退出命令,选择 resume 进入系统

git线下环境bundle介绍

这在许多场景中都很有用,有可能网络中断了,但又希望将我们的提交传给合作者们,我们可能不在办公网中并且出于安全考虑没有接入内网的权限,可能无线、有线网卡坏掉了,可能现在没有共享服务器的权限,然而我们又希望通过邮件将更新发送给别人,却不希望通过 format-patch 的方式传输 40 个提交。这些情况下 git bundle 就会很有用,bundle 命令会将 git push 命令所传输的所有内容打包成一个二进制文件,可以将这个文件通过邮件或者闪存传给其他人,然后解包到其他的仓库中。
来看看一个简单的例子,假设有一个包含两个提交的仓库:

如果想把这个仓库发送给其他人但没有其他仓库的权限,或者就是懒得新建一个仓库,就可以用 git bundle create 命令来打包:

然后就会有一个名为 repo.bundle 的文件,该文件包含了所有重建该仓库 master 分支所需的数据。在使用 bundle 命令时,需要列出所有希望打包的引用或者提交的区间,如果希望这个仓库可以在别处被克隆,应该像例子中那样增加一个 HEAD 引用。我们可以将这个 repo.bundle 文件通过邮件或者 U 盘传给别人。
另一方面,假设别人传给我们一个 repo.bundle 文件并希望在这个项目上工作,可以从这个二进制文件中克隆出一个目录,就像从一个 URL 克隆一样:

如果在打包时没有包含 HEAD 引用,还需要在命令后指定一个 -b master 或者其他被引入的分支,否则 Git 不知道应该检出哪一个分支。
现在假设提交了 3 个修订,并且要用邮件或者 U 盘将新的提交放在一个包里传回去:

首先我们需要确认我们希望被打包的提交区间,和网络协议不太一样,网络协议会自动计算出所需传输的最小数据集,而我们需要手动计算。当然可以像上面那样将整个仓库打包,但最好仅仅打包变更的部分,就是我们刚刚在本地做的 3 个提交。
为了实现这个目标,我们需要计算出差别,有很多种方式去指明一个提交区间,我们可以使用 “origin/master…master”或者“master ^origin/master”之类的方法来获取那 3 个在 master 分支而不在原始仓库中的提交。可以用 log 命令来测试:

这样就获取到我们希望被打包的提交列表,让我们将这些提交打包,可以用 git bundle create 命令,加上想用的文件名,以及要打包的提交区间:

现在在目录下会有一个 commits.bundle 文件,如果把这个文件发送给我们的合作者,她可以将这个文件导入到原始的仓库中, 即使在这期间已经有其他的工作提交到这个仓库中。
当她拿到这个包时,她可以在导入到仓库之前查看这个包里包含了什么内容,bundle verify 命令可以检查这个文件是否是一个合法的 Git 包,是否拥有共同的祖先来导入:

如果打包工具仅仅把最后两个提交打包,而不是三个,原始的仓库是无法导入这个包的, 因为这个包缺失了必要的提交记录。这时候 verify 的输出类似:

而我们的第一个包是合法的,所以可以从这个包里提取出提交。如果想查看这边包里可以导入哪些分支,同样有一个命令可以列出这些顶端:

verify 子命令同样可以知道哪些顶端,该功能的目的是查看哪些是可以被拉入的,所以可以使用 fetch 或者 pull 命令从包中导入提交。这里我们要从包中取出 master 分支到我们仓库中的 other-master 分支:

可以看到已经将提交导入到 other-master 分支,以及在这期间我们自己在 master 分支上的提交:

因此,当在没有合适的网络或者可共享仓库的情况下,git bundle 很适合用于共享或者网络类型的操作。

ubuntu管理多版本Python

在 repo init 和 repo sync 时,需要 python3.6+,在编译 android 源码时,又需要 Python2.7,所以需要系统中同时存在 Python 的 2+和 3+版本。

(1)安装 Python2+版本:
在 Ubuntu 中会自带,不需要再次安装。

(2)安装 Python3.6+版本:
下载 Python3+源代码:
Python 官方下载地址:https://www.python.org/downloads/
解压源码包:
tar -zxvf Python-3.8.5.tgz
安装编译源码的依赖库:
sudo apt install zlibc zlib1g-dev
./configure --prefix=/usr/local

3)切换 Python2+和 Python3+版本:
怎样能快速的切换 Python2+和 Python3+呢,可以使用如下方式配置:
使用 update-alternatives 命令处理 Linux 系统中软件版本的切换,使其多版本共存。
向系统注册 python3.8.2:
sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3.8 2

向系统注册 python2.7:
sudo update-alternatives --install /usr/bin/python python /usr/bin/python2.7 10

查看 python 所有配置:
sudo update-alternatives --display python

切换 python 的配置:
sudo update-alternatives --config python

问题 1:
当出现 marshal data too short 时,说明当前系统使用的版本号有问题,需要切换版本(使用上面命令即可):

如果切换版本后,再执行 repo sync 后仍然报错,可以删掉 python 生成的文件后再重新执行:
rm -rf __pycache__

ubuntu扩展分区空间

最近学习 ASOP,发现 Ubuntu 空间不够用,通过如下操作,完成分区空间扩展。
扩展后:

1.在 vmware 中选择虚拟机设置,输入你想扩展到的大小,最后点击”应用”。

2.安装 gparted 分区管理软件
apt-get install gparted

3.打开软件,将 extend 分区删掉,不删的话无法重置 sda1 主分区大小

4.重置 sda1 的大小。点击 箭头调转大小,然后点击“调整”

  1. 再次将新建一个一个 extend 分区, 然后在 extend 分区下在建一个 swap 分区

  2. 点击上方绿色的对号保存即可

  3. 通过上述 6 步操作,新增的空间将到 sda1 中;如果方块有灰色块,如下图,表示有未分配空间。

    需要执行如下操作:
    sudo resize2fs /dev/sda1
    即可,完成空间扩展操作

    See the man page for resize2fs (which is the command-line tool gparted will use to grow an ext2, ext3, and ext4 filesystem) for more details about resizing those filesystems.
    https://unix.stackexchange.com/questions/289099/how-to-grow-filesystem-to-use-unallocated-space-in-partition

如何写一个健壮且高效的串口接收程序?

学单片机的大概最先、最常写的通信程序应该就是串口程序了,但是如何写出一个健壮且高效的串口接收程序呢?接下来鱼鹰将根据多年的开发经验教你如何编写串口接收程序。

1、传入参数指针

2、互斥锁释放顺序

3、数据帧检查

4、串口空闲

5、通信吞吐量

为了更好的理解接下来的知识点,鱼鹰将设计一个串口框架,让道友心中有一个参考方向。

本篇重点在于解决如何写一个健壮、高效的串口接收数据,发送与接收处理过程略讲。

帧格式

先聊聊帧格式,一般来说,一个数据帧有以下几部分内容:

帧头

帧头用于分辨一个数据帧的起始,这个帧头必须足够特殊才行,因为它是分辨一个帧的起始,那么什么样的帧头是足够特殊的数据呢?保证这个数据在一个帧内最好只出现一次的数据,那就是帧头,比如 0x55、0xAA 之类的。而且最好有两个字节以上,这样帧头才更加独一无二。

但是数据域内的数据你是没办法保障不包含和帧头一样的数据。

那么如果不凑巧,除了帧头外其他部分也有这样的两个字节的帧头,那会出现什么问题?

几乎不会出现问题。因为一般来说数据都是一帧一帧发送的,只要你前面的数据帧传输正确,那么即使下一帧的数据中有和帧头一样的数据(包括帧头)也没有问题,因为帧头判断已经在开始就判断成功了,就不会继续判断后面的数据是否是帧头了。

那么为什么说是几乎,因为如果上一帧数据接收错误,那么程序必须再找一次帧头才行(单字节接收时是如此,采用空闲中断的话就不需要这么麻烦),这就导致找帧头的时候在帧头数据之外寻找了,很可能这些数据就有帧头。

但是即使帧头数据之外的假帧头真的存在,也没关系,还有第二重保障,那就是校验,即使找到了一个错误的帧头,那么数据校验这一关也很难过去,所以放宽心。

如果校验也凑巧通过了,那还有第三重保障:帧尾。应该到不了这里吧,毕竟这比中彩票还难。

又要上一帧数据接收错误,还要当前帧除了帧头之外还有帧头,另外你还能跳过校验的检查(还有功能字、长度信息的检查),太难了。所以只要通过了这些检查,你就可以认为这个数据帧是可用的了。所以一帧数据接收错误,导致的问题最多只是丢失了这帧数据,对后续接收是不会有影响的(前提是你这个接收程序设计的足够好),发送端在发送超时后再发送一次即可,所以重发机制很重要。

事实上,如果你采用串口空闲中断,帧头、帧尾都可以不用,但一般来说,帧头都会保留,帧尾可以不需要,这是为了当单片机没有串口空闲中断时考虑,当然也可能有其他考虑,所以帧头得保留。

功能字

功能字主要用于说明该数据帧的功能,当然也可以作为函数指针的索引,一个索引值代表了一个具体功能,据此可找到对应的功能函数。

比如,设计一个函数指针数组,通过功能字进行索引,进而跳转到对应的功能函数中处理。

特别注意的是,设计功能字的时候,要考虑兼容性,对数据帧的功能进行划分,不要想到一个算一个,功能字也不要随便安排,不然在以后增加数据帧的时候会很麻烦。

比如说,只有一个字节的功能字,前四位作为一个大类,后四位作为大类中具体类。这样就可以将系统数据通信帧分为 16 个大类,每个大类下有 16 个可用的具体类,当你增加功能字的时候,就可以根据你的设计来确定属于哪个大类了,然后再插入进去。这样在管理、维护这些通信数据时你会发现很方便。

这个思想其实在 ARM 内核的中断系统和设计 uCOS II 任务优先级的时候都有,而鱼鹰在设计项目的通信协议的时候就是运用了这些思想。

(图片来源于《权威指南》)

长度

长度信息也是一个非常关键的数据,别小看了它,因为它,鱼鹰用了将近一个星期的时间才把一个 HardFaul 问题解决了,虽然这个程序 bug 不是我写的(鱼鹰一直用的是串口空闲接收方式,这个 bug 自然而然就跳过了),但确实很容易出错。

因为它是决定了你这个数据域长度的关键信息(一般长度信息代表数据域的长度,而不包含其它部分长度),也是这个数据帧的长度信息(加上固定字节长度就是帧长度了),更是接收程序还要接收多少数据的关键信息(对于空闲中断接收方式不算关键,这里的不关键是指不会造成程序异常问题)。

比如说你的程序刚好将帧头、帧尾、功能字判断完毕,然后中断程序因为种种原因导致没有及时接收串口数据,那么你可能得到的就是错误的数据,然后这个错误的长度数据就可能导致你的栈帧或者全局变量被破坏(单字节接收情况下就可能出现,因为鱼鹰碰到过),这是很严重的事情。所以在接收数据域的数据之前一定一定要判断这个长度信息(空闲中断除外)是否合法,不合法的话及时扔掉这帧数据,开始下一帧的数据检查。

所以为了保证及时接收数据,最好采用 DMA 传输。

数据域

这个没啥好说的,就是整个帧你真正需要发送的数据。而为了让你的发送函数能接收各种类型的数据,那么把参数类型设置为 void * 会是不错的选择。

校验

一个数据在接收过程中可能会被干扰,导致接收到错误的数据,那么如何保证这帧数据的完整与准确性呢,就在校验这一关了。

校验有很多方式,和校验、CRC 校验等(奇偶校验是针对一个字节的,不是数据帧)。

和校验算法简单,CPU 运算量小,累加最后只取最低字节即可(注意不是高字节,想想为什么),或者保存累加和的变量就是一个字节空间,这样就不需要额外操作了。

CRC 校验,这个算法复杂,理解起来比较困难,但一般来说可以直接拿来用,因为它是对每一位(bit)进行校验,所以纠错率很高,几乎不存在发现不了的数据错误,但正因为对每一位进行检查,所以 CPU 运算量较大,但是有的单片机是可以硬件计算 CRC 校验值的(比如 stm32)。不过现在 CPU 运算速度都挺快的,软件运算也是可以接受的。

那么该怎么校验呢?是从帧头开始到数据域部分,还是说直接校验数据部分?其实都可以,区别就是运算量问题,不过问题不大(最好是从头开始校验,以保证整帧数据的准确性)。

帧尾

前面说了,帧尾在空闲中断中可以不用,RXNE 中断接收时其实也可以不用,当然也可以加上,好处就是当你用串口助手查看数据流时,可以观察出一帧数据是否发送完整了。

最后再说说为什么在数据域前面设计四个字节大小,除了协议本身需要外,还有一个原因就是强制类型转化需要,我们知道,一般来说,赋值时都有字节对齐的限制(实际上有的 CPU 可以不对齐进行赋值),stm32 是 32 位的,那么四字节对齐是最合适的,这样就可以直接将我们收到的数据转化为需要的数据类型了。

传输过程

聊完了帧格式,再从大的方向看串口的传输过程:

当发送端发送第一帧数据包时,接收端通过某种方式接收(串口接收非空 RXNE 中断、串口空闲 IDLE 中断),为了让串口能够触发空闲中断,必须在发送端两个发送帧之间插入一段空闲时间(就是在此时间内不发数据,红色部分),保证空闲中断的准确触发。

同理,为了让发送端也能正常接收接收端的数据,也需要控制接收端的发送,不能在返回一帧数据时立马发送下一帧数据,不然触发不了发送端的空闲中断。

事实上,有些程序员设计的发送、接收过程比这个简单一些。即只有当接收端接收到一帧数据并返回一帧数据之后,发送端才能继续发送数据,这样一来,我们只需要控制好接收端的频率,就可以控制整个通信过程,也能控制通信频率。

但为什么还要设计成第一种传输情况呢?这是为了充分利用串口,增大数据吞吐率(这个后面再说)。

另外,不知道你是否观察到图中的每个数据帧占用的时间是不一样的,这是因为每个数据帧不可能都是一样长的,它们是不定长的数据包,所以你的定时不能从发送开始定时,而是从发送完成后开始定时控制空闲时间。

软件设计

上面所有的内容都是设定一些条件、需求,那么该如何实现软件设计呢?毕竟说的再多,如果不能实现这些,又有什么用呢?talk is cheap, show me the code。

下图设计了三个数据帧:GetVision(),GetSN(),GetMsg()。

GetVision()用于获取硬件版本号、软件版本号。

GetSN()用于获取产品序列号,用于识别唯一设备。

GetMsg()用于获取消息,可以获取各种传感器数据,事实上,如果数据量多的话,根据传感器的不同,会根据需要设计各种不同的数据帧(功能字不同)。

在软件设计上一般都会对这些函数设计一个统一的函数类型,用函数指针数组统一管理。

既要统一,又要体现差异性,函数参数就显得很有必要了。

这里设计了两个参数,一个是 void* (无类型指针),一个是 length(长度)。

无类型指针主要是用于传递数据域的数据地址,而数据域的数据可能是整型、浮点型、结构体、枚举、联合体等,为了保证传入的各种数据类型在不通过强制转化情况下都能兼容,设计为 void * 就显得很有必要了。

实际上为了显得更专业性,加上 const 修饰会是不错的选择,因为这可以保证缓存数据不被修改(事实上只能保证不被程序员修改,而不能保证程序本身,这个后面会解释)。

长度,长度参数是一个很关键的参数,为了保证长度的准确性,建议使用 sizeof 获取。

有人觉得 sizeof 好像一个函数,会不会导致效率低下啊,毕竟每次通信都要计算一次长度,那你就大特大错了。事实上,只要你的类型定义定义好了(不管是内置的类型定义还是自定义的结构体、枚举、联合体),编译器都能确定 sizeof 最终的数据长度,根本不存在计算过程。

用 sizeof 的两个好处:

1、可以忽略字节对齐问题(不同平台不能忽略,比如 window 和单片机通信)。因为编译器为了数据读写效率更高,一般会对数据进行地址对齐,这样一来手工计算一个数据类型的长度变得麻烦(当然你可以说使用某些手段让数据不进行对齐,这个另说),而 sizeof 将智能且准确计算数据大小。

2、当你使用 sizeof 时,兼容性更强,也显得更专业。程序修修改改很正常,一个数据结构改来改去也很正常,特别是开发初期更是如此。但是不管你怎么改,只要在编译器看来是固定长度的数据类型,那么 sizeof 就能在链接程序前计算出来;并且即使你后来加了数据不对齐的限制(加了这个限制后,很可能数据大小变小),也能准确计算。别问为什么,就是这么任性。

所以为了减小出错的可能性、减少劳动量,sizeof 是不错的选择。

当接收到数据地址和长度信息后,就可以进行发送了。

因为只有数据域的数据,为了组成一帧完整的数据,就必须加入打包过程。加上数据帧头、功能字、数据长度、校验等数据。

当一帧数据打包好之后,就可以进行发送了,发送可以采用循环查询发送,也可使用发送空 TXE 中断,当然还是建议使用 DMA 发送,这样你可以还没等它发送完就可处理其它事情了。

以上就是发送过程,接收过程也是同理,根据功能字来调用相应的函数进行回复。

事实上,如果只是数据的传输过程,完全可以使用一个发送函数实现数据的特异性传输,这样就可以减少一层数据传递,但是有些通信帧不只是数据的传输,可能在接收、发送时作一些其他处理(比如清除、设置某些标志位),所以需要再增加一层,用于进行差异性处理。

以上就是本篇内容的基础内容了,你以为快完了?你错了,现在只是刚开始而已,鱼鹰写本篇笔记的最终目的还在后面。这只是前菜,正文才刚开始。

串口接收遇到的那些问题

以下内容不会用太多的笔墨描述如何写发送、接收函数,而是重点关注串口接收过程中可能遇到的一些问题,如果说描述到了发送、接收函数,别会错意,顺带的。

以下大部分问题都是因为采用 RXNE(接收不为空)中断方式导致的问题,只有一个问题是鱼鹰从前没有考虑到,也是 IDLE + DMA 方式不可忽略的问题。

这就是为什么鱼鹰建议采用 IDLE + DMA 的原因,不仅是因为效率问题,更因为它能避免很多问题,当然水平足够高的话,采用 RXNE 也是完全(“完全”就未必,里面有一个问题是 RXNE 方式难以避免的问题)没有问题。

事实上,即使鱼鹰采用 RXNE 方式接收数据,也能避免以下大部分的问题,因为鱼鹰的基础足够扎实,会在一开始编写代码的时候自然而然避免一些问题的出现。

但是看完以下内容后,相信各位道友写出一个高效且健壮的串口接收程序根本不是问题,因为这就是所谓的经验啊。

传入参数指针

前面鱼鹰已经提到了需要一个指针作为函数的参数,这里说说这个指针问题。

我们知道,为了维护方便,也是为了节省空间,一般都会将类似的功能整合成一个函数,比如串口经常要用的发送、接收功能,但是所发送的数据内存空间可能就处于五湖四海了,他们通过指针来指向将要发送的数据。

为了节省 RAM 空间或者其它不为人知的原因,传递给发送函数的指针就是实际发送数据的地址,并且在计算校验值的时候也是直接使用这个地址进行校验计算,然后采用循环查询的方式发送数据,这样一来,就不必拷贝一个数据的副本进行发送,而是直接从数据源的地址进行发送,节省了部分 RAM 空间。

但是这样真的好吗?

你是否考虑过在计算数据帧校验值的时候,数据源改变了的问题呢?

比如说你采用和校验,数据一开始是 0x55,计算数据帧的校验和值为 tx_sum,然后被中断程序或者 DMA 修改了这个数据源,变成了 0xAA,此时你再使用这个数据地址进行发送,接收端接收到了 0xAA,接收端计算校验和的时候是 rx_sum,那么 rx_sum 必然不等于 tx_sum,然后接收端就认为该数据帧是错误的,然后丢失这帧数据,而这种情况是比较少见的,但确实是会偶尔出现接收错误的情况(当时发现这个问题时始终不得其解,明明我发送的是这个校验值,为什么你计算的校验值是另一个?开始怀疑是校验函数的问题,但其他数据帧计算时没有问题,只有一种数据帧会出现问题,然后鱼鹰怀疑是数据源的问题,是的,鱼鹰很快就怀疑数据源的问题,但当时验证时,只改了校验那部分地址,发送时的地址还是使用源地址,导致问题还是没解决,过了好久之后才发现这个发送地址没改,囧。所以说,即使你的思路是对的,但如果你解决时错了,问题也很严重)。

如果说接收端(从机)具有重发机制,那么问题不是很大,丢失一帧数据而已,再重发就是,但事实是,一般串口设计成主从模式,主机会在没有接收到从机的应答数据时会进行重发,但是从机一般不会主动重发数据的,它无法判断主机是否成功接收,而从机一般会在成功发送完数据后开始清除一些标志位(比如键盘按键数据清空,不然主机下次获取按键信息时还是同一个按键数据),事实上这个动作必须在对方成功接收才能进行(否则这次按键信息就丢失了),从这个角度来看,我们必须设计一个机制用于判断主机是否成功接收。

I2C、CAN 总线都有应答信号,但这是这些是总线自带的特性,我们不可能在接收到一个字节后发送一个应答信号给主机,那么是否有其他办法呢。

人们很容易想到的一个办法就是在主机收到正确数据后,主动发送一帧专用数据帧用于清除这个标志(这个帧和普通帧一样,所以可以确保主机数据能准确送达从机,因为如果超时没有送达,会触发重发机制)。这样只要在获取完这帧数据后,再额外的发送一帧数据用于对方确认即可,从机接收到后,即可开始着手清除一些标志位。

但这样会有一个问题,因为这种特殊的需要从机确认的数据包(其他类型数据不需要确认是因为如果主机没有正确收到数据还可以继续获取,获取的数据是一样并没有关系,但这种需要从机确认,一旦从机认为发送成功了,数据就被清除了这种情况就需要确认,典型的就是按键信息了),我们需要额外处理并占用发送带宽。这是鱼鹰不愿忍受的。

那么是否有更好的办法?

或许我们可以从 USB 协议中获得启发(这是在写这篇笔记的时候想到的,当时写按键板代码的时候没有想到过,但因为当时测试时传输成功率 100%,所以就放弃处理这种情况了)。

USB 协议是典型的主从机制,主机不主动获取数据,从机是无法主动发送数据的。那么从机是如何确定对方成功接收数据了呢?

一个 bit 的翻转位。

每当主机成功发送一帧数据后再发送下一帧数据时,就会翻转这个位,从机就可以根据这个位判断主机是属于重发数据(重发数据表示主机接收失败了)还是新数据了,这样从机就能从下一帧数据确定上一帧数据是否成功发送了。

而主机发送的数据是由从机发送应答包确定的,和上面的串口协议类似,所以这个方向的数据是没有问题的。

那么我们该如何重新设计这个协议呢?可以尽可能的不改变原来协议的情况下实现吗?

或许可以从功能字出发。

为了保证对功能字的原有定义保持基本不变,使用最高 bit 作为这个特殊的位,这个 bit 开始是 0,之后主机每接收一个从机应答数据就进行翻转,如果因为没有接收到从机的应答数据,就会使用相同的翻转位重复发送;而从机也根据这个 bit 来确定自己的上一帧数据对方是否接收(对比上一帧数据的翻转位),如果主机没有成功接收,就不清除标志位(之后主机会重发数据再次获取),否则清除标志位,。

因为是鱼鹰刚想到的,就不多说了,仅提供一个思路。

现在回到指针那块的问题。

现在已经知道,如果你在计算校验和与发送的过程中出现源数据改变的情况,就会导致数据帧校验失败,那么有什么解决办法?

如果说你坚持使用查询方式发送来节省部分空间,那么只要在计算校验值之前拷贝一份源数据,然后用这份数据计算并发送即可。

另一种方法就是,直接把整帧数据拷贝到一个数据缓存中,使用 DMA 发送。

现在还有一个问题,那就是如果我想发送一个数据域为空的数据该怎么发送?

一般来说,在使用指针的时候,不会使用 NULL 空指针,但是在数据为空的情况下,就需要使用 NULL 指针了,并长度设置为 0,这个时候在检查指针的时候,不能看到空指针就退出函数,还要判断长度信息,当长度为 0 时在打包时就不拷贝源数据,但最终还是要发送数据帧的(当时别人写的代码将指针和长度判断同时放到了 for 循环的条件里面,鱼鹰觉得效率太低,导致修改代码是没考虑指针为空的情况,所以导致了一个小 bug)。

互斥锁释放顺序

现在考虑第二个问题:互斥锁释放顺序问题。

如果没有采用队列方式接收数据,而是主机发送完成后等待接收从机数据后再发送下一帧数据,那么该如何处理互斥锁的问题?

我们知道为了保证数据的同步,保证在接收到一帧数据进行处理时,不能被新的数据帧冲掉,这时就要加入一个互斥锁,表示我正在处理数据,下面的数据我接收不了,这样就能保证你正在处理的数据不会被新来的数据修改掉,从而进行正确的处理。

那么这个标志位(互斥锁)该什么时候清掉(释放掉)呢?

一般来说标志位,一般越早清掉越好,比如外部中断标志位,进入中断后,一般首先会清理标志位,这样即使你正在处理本次中断的程序,那么即使这时再来了中断,也不会丢失中断信息(有悬起标志位),这样就可以在处理完这次中断后,立马进行下一次中断的处理了(前提是优先级足够高)。

但是如果你清理太早或者清理太晚会怎样?

比如说你在接收到一帧数据后(数据帧所有检查完成),开始设置标志位,当主程序查询到这个标志时(一般数据处理不会放在中断中),如果马上开始清除这个标志……嗯,一般来说不会有问题。

那么什么时候会出现问题?当你的主程序查询到这这个标志时开始清除标志,然后处理、返回数据给主机,如果此时主机超时重发数据时,,因为这个时候你虽然在处理数据,但是因为你的标志位已经被清除了,所以接收程序就会开始往接收缓存区存数据了,当你存完之后再回到数据处理那里,你的缓存区可能就不是你想要的数据了。

可能你会说,既然是重发,那么数据应该是相同的吧?好吧,你赢了,鱼鹰编不下去了,这种情况(有重发机制)下清理太早好像是不会出现问题,但你怎么知道对方是采用副本进行重发数据的呢,如果重发时它又从源数据中拷贝后再进行重发会出现什么问题?比如时间信息,开始第一帧数据是 11:59,CPU 刚把 11 拷贝到用户空间,被串口中断程序打断,导致下一帧接收的数据是 12:00,此时回到主程序继续拷贝,拷贝 00,数据的完整性被破坏,这样导致的结果就是 11:00,而实际上时间是 12:00,这就是你打断数据处理过程的后果。

现在再说说清理太晚会怎么样。

当你的主程序查询到这个标志后,暂时不清除,而是等到从机发送完应答数据之后再清除标志,此时因为从机采用查询方式(查询方式表明从机发送完最后一个字节后后开始清除标志位,也就代表了主机就差最后一个字节没有接收了,这样发送和清除之间间隔时间较短,而采用 DMA 方式的话,发送和清除的间隔时间更短,因为可能 DMA 还没开始发送第一帧数据,清除工作就已经完成了),或者因为其他原因(比如中断处理)导致发送和清除之间的时间很长这种特殊情况,这样可能主机已经开始下发下一帧数据了,但是因为此时标志还没有清除,不能接收数据,所以主机这一帧数据就这样丢失了。

那么这个清除标志位最合适的时机是什么时候?

你锁定资源利用完的时候。

现在来看看,这个互斥锁锁定的是什么资源?对,就是接收缓存,那么接收缓存什么时候用完?当然是在数据处理完成之时。也就是说在数据处理完之前、发送数据之前清除最合适。

这样就不会因为处理其他事情而导致清除操作过晚而丢失下一帧数据了,因为此时主机还没收到从机上传的数据,也就不会马上开始下一帧数据的传输了。

数据帧检查

你是否会对接收的数据进行检查?如果不进行检查会发生什么?

我们知道一帧数据中,每个部分都有各自的含义,甚至有些部分可能在某些数据帧中不存在,比如数据域部分,我们需要根据长度信息来判断数据域部分是否存在。

但是你能保证你所接收的数据都是准确的吗?你能确保在工作环境下不会因为各种干扰导致数据长度信息由 0x05 变成 0x85(最高位翻转)吗?,如果出现了会导致什么后果?

假设你采用 RXNE 中断方式来一个、一个字节的接收数据,分析如下:

因为是单字节接收数据,所以你需要把所有接收的数据当成数据流,根据帧头信息来确定帧的开始,一旦确定帧头信息之后,你就可以根据接下来的一系列数据保证一帧数据的结束,同时开始新帧的接收……

初看这个接收流程没有问题,但是真的如此吗?

但是就像前面所说,你能保证你的数据没有问题吗?如果说你接收到一个长度信息,本来是 0x05,但是最终接收的数据是 0x85,这就意味着你接下来的数据域的长度是 0x85,根据你的接收流程,你需要再接收 0x85 个字节之后,才能判断校验字节是否正确。

可能你会说,虽然你的长度信息由 0x05 变成了 0x85,之后接收校验过程肯定是失败的,那么这帧数据就会被接收程序丢弃,从而导致接收程序进入重新寻找帧头的流程,这个过程不是挺正常的吗?按理说上述异常情况是能被接收流程处理掉的。

那么首先确认一点,上述异常能被接收流程处理吗?

答案是能!

既然上述异常是能被接收状态机处理的,那么还会有什么问题?

问题就出在这个错误数据本身!

因为你是根据错误数据来决定接下来需要接收多少数据,而一般来说,接收缓存大小设置为最大帧的长度,那么就出现一个问题,你的缓存够你接收 0x85 个字节吗?

如果说你开辟的接收缓存空间很大,足够接收这么多数据,那么就算遇上以上情况,也是没有任何问题,但是万一你比较节省资源,缓存不够大会出现什么情况?

这就涉及到内存分配问题:

你的串口缓存一般在 Data 区域,一旦你接收的数据超出了你开辟的空间,那么必然导致缓存空间溢出!

那么缓存空间溢出会导致什么危害?

我们通过上图可以知道,一旦缓存溢出,必然导致该缓存周围的数据出现异常(数据被篡改),如果你的其它代码刚好需要这个数据作为重要参考,而你在使用的时候又没有对这个数据的有效性进行检查,那么可能导致另一个灾难性后果,而这个后果又导致了其他后果,从而导致雪崩效应。

而你修复这个 bug 时,你以为修复了,但你只修复了表面,真正内在 bug 还存在!

所以,千万别太相信内存中的数据,每一个数据的输入都要进行严格检查,这个数据可以错误,但是不能导致程序崩溃!

所以千万别写能篡改别人数据的代码,这是很危险的事情,也是很难解决的 bug,因为你不知道它会在什么时候篡改哪里的数据!

再假如你的接收缓存放在栈中了呢(稍微有 C 语言常识的程序员都不会把串口接收缓存放在栈中,但鱼鹰偏偏遇到过这种代码,而为了解决这个 bug 整整花了一星期,这还是在 bug 复现率高的情况下)?

根据前面的图可知,栈一般存放在高地址,并且一般栈生长方向为向低地址生长。如果出现上述情况(接收的数据大于开辟的栈缓存空间)会发生什么?

栈帧被破坏!

灰色部分因为接收的数据太多,导致原本存在的栈数据被串口的接收的数据修改了(注意篡改的数据可能不是连续的,因为每一次进入时,开辟的那部分栈空间可能都不在同一个地址),假如这个数据是保存返回寄存器 LR 的,那么必然导致返回错误,极可能触发 HardFault 中断!

那么有什么办法解决栈被破坏的问题?

最有效的方式鱼鹰觉得是使用 ITM,如果无法在线调试,可以尝试 DMA 循环传输 PC 指针值(但是如何得到这个值?毕竟这个寄存器本身是没有地址概念的)到一块内存中,这样就可以得到最后正常执行的代码地址,从而定位错误代码的位置。

如果单片机不支持这些功能呢?鱼鹰现有的知识体系好像无法解决,只能佛系调 bug 了(看和 bug 之间的缘分),囧。

前面说了由于外部工作环境导致数据长度信息错误从而出现数组溢出这种情况,如果说你保证工作环境非常好,不可能出现这种干扰,是否还会出现问题?

当然会!

前面分析了外在原因,现在分析内在原因,你的接收程序能保证及时接收发送端发送过来的数据吗?如果不及时接收数据会出现什么问题?

我们知道,一个系统一般都有很多中断需要处理,如果说你的接收程序的中断优先级不是最高的,那么很可能出现接收程序无法及时接收的情况,即 RXNE 中断来临时,因更高优先级中断需要处理,而且处理时间较长,那么就会出现当前接收的字节因为没有接收完成而被后续的数据冲掉,即出现 ORE(溢出错误)。

这样会导致什么问题?

数据域信息(也可能是校验值等数据)当成了长度信息(为什么只讨论长度,而不讨论功能字之类的数据,难道他们不会出现 ORE 的情况吗?),这样一来,如果这个数据很大,接收程序就会以为接下来还需要接收很多数据才能完成一帧的接收,导致后果和前面分析的数据干扰一样严重。

那么采用 RXNE 接收方式时该怎么解决这种问题?

检查长度信息的合理性,只要长度信息不会导致缓存溢出即可。

但是上面的解决方案会导致什么问题?

因为你的程序设计问题(采用 RXNE 接收导致不能及时处理),使得原本能接收的数据无法及时处理(DMA 可以及时处理),最终使得当前这一帧数据无法正常接收(如果错误的长度信息够大的话,还有可能接下来很多帧数据都无法接收),这你能接受吗?

但是采用 DMA 为什么就不会有上述问题,除了 DMA 能自动接收数据提高效率之外,还有一点就是它不根据接收的数据来判断接下来还需要接收多少数据,而是根据设定的接收数据长度来接收的(如果加入 IDLE 中断,可以提前结束接收工作),这就避免了上述的缓存溢出和接收不及时问题。

最后再分析上述接收的另一个问题,那就是一帧数据中可能出现没有数据域的情况,这种情况该怎么处理?

只要根据长度信息分开处理即可。如果不对没有数据域的情况分开处理,那么你接收的下一个数据直接就是校验值,而你的接收流程却认为这是数据域的数据,必然导致校验失败。

现在总结使用 RXNE 方式接收的几个问题:

1、缓存溢出。

缓存溢出有两种可能,第一种就是环境干扰导致长度信息出错,从而出现缓存溢出情况;第二种情况就是因为接收不及时,导致数据错位,如果刚好是长度信息错误,并且这个长度信息太大,而你的代码未对长度进行检查,那么也会出现缓存溢出 bug,而这种 bug 一旦出现,很难发现。所以在代码中对数据的合理性检查是非常有必要的一件事。

2、中断及时处理。

如果中断不及时处理,会导致数据错位,轻则丢失至少一帧数据,重则缓存溢出!

3、状态机是否需要接收数据部分。

由于数据帧有可能没有数据域的情况,所以必须区别处理,保证代码接收的准确性,否则有可能把校验值当成数据了,这样必然无法通过校验,这一帧数据必然会丢失!

串口空闲

前面一直提到串口空闲,也大概明白串口的作用,但是一些细节问题还是需要好好说一下的。

第一个问题,如何清除串口空闲中断标志位?

很多人会使用 USART_ClearFlag 标准库函数进行清除,但是当你跳转到该函数原型时,你会看到如下说明:

你会看到很多标志位是无法通过该函数清除的。

那么该如何清除 IDLE 标志呢?其实上面的注释已经进行了说明。

PE、FE、NE、ORE、IDLE 标志位的清除是通过一个软件序列进行清除的:首先通过 USART_GetFlagStatus 读取 USART_SR 寄存器的值,然后通过 USART_ReceiveData 函数读取 USART_DR 的值即可。

那么这里就有一个问题,是否这些标志问题的清除都要单独编写清除序列呢?

答案是否定的。

因为这些标志位都是由同一种序列进行清除的,所以只要一个清除序列就会把所有的标志位都进行清除了(同样一旦执行了这个序列,也就意味着你无法再通过 USART_SR 寄存器获得标志位了)。

为了保证获取标志位,我们可以在清除序列之前把 USART_SR 寄存器的值保存到副本中,然后再读取 USART_DR 寄存器的值保存到副本来实现清除功能,注意该序列应该无条件执行(不在某个判断语句中)。这样后续我们就可以使用这个 USART_SR 的副本判断哪一个标志置位了,同样也可以使用 USART_DR 的副本获取串口数据,而为了实现以上效果,USART_GetFlagStatus 这个函数就不合适了,只能直接操作寄存器去实现。

第二个问题,在线调试时对空闲中断会有影响吗?

我们知道,KEIL 能够将一个结构体的数据全部读取出来,而库函数将串口模块的所有寄存器都封装在一个结构体中,这样就会出现一个问题,如果你的窗口是实时刷新的,当你使用 KEIL 读取串口模块寄存器的时候(不管是使用 peripheral 窗口还是 Watch 窗口),就会出现先读取 SR 再读取 DR 的情况, 这样就有可能出现 KEIL 和单片机 CPU 读取这两个寄存器冲突的情况。

如果全速运行时,KEIL 先执行了这个序列(通过调试器读取这两个寄存器的值),单片机 CPU 再读取 SR 寄存值,必然是无法读取到正确标志位的,因为这些标志位已经被 KEIL 的读取序列清除了(这个情况鱼鹰确实碰到过,当时明明下发了数据,但是单片机无法获取标志位),所以在调试串口时,注意不要让 KEIL 去读取这些寄存器(即关闭这些窗口,只有在必须的情况下才开启),防止出现莫名其妙的情况。

第三个问题,空闲中断能准确触发吗?

如果从接收端考虑的话,如果触发了空闲中断,那么必然满足了条件才触发的,而不是意外触发的(嗯,我们要相信 STM32),但从发送端考虑的话,有可能出现一帧数据断续发送,导致一帧数据触发多次空闲中断,所以如果是简单的 DMA+空闲中断方式接收是很有问题的(空闲出现就认为一帧结束了,就会把一帧数据当成两帧处理,这样肯定无法通过数据检查的)。

那么先来分析为什么会出现一帧数据多次触发空闲中断情况。我们知道 linux、windows 系统并不是实时系统,当应用程序需要发送一帧数据时,可能并没有连续发送,而是发送完一个字节后去处理其他事情后才发送下一个字节,这样一来,如果耽误的时间够长,就会触发串口的空闲中断,从而一帧数据当成两帧处理了。

那有什么方法可以解决呢?鱼鹰提供两种解决思路。

第一种,使用两个缓存空间,一个缓存空间专门用于接收串口数据,将接收到的数据存放到另一个缓存,这个缓存采用字节队列的方式进行管理,应用程序从缓存队列中一个字节一个字节的取出数据进行处理(注意检查数据有效性),这样就能保证及时处理。但是因为空闲中断不再可靠,所以空闲中断不再作为判断一帧数据结束的依据(根据长度信息判断),而是只在空闲中断中将已接收数据复制到字节队列缓存中,这样就可以处理意外的空闲中断。

第二种,还是一个缓存空间,还是 DMA+空闲方式处理,但是需要增加额外的条件。就是当进入空闲中断后,不再直接处理,而是获取当前接收时刻,然后在处理数据的时候根据这个时刻来判断是否达到足够的空闲时间,只有在进入空闲中断后并达到一定延时之后才认为一帧数据结束了,这样可以避免一些非常短的空闲时间。

以上问题是就是鱼鹰以前使用空闲中断从未考虑的问题,鱼鹰并不知道使用空闲中断还可能出现误触发的情况,但是既然知道了,就要想办法解决。但是为什么以前使用空闲中断时没有出现通信问题呢?

事实上不是没有问题,而是有可能把分散的一帧数据的两部分直接丢弃了而已,因为有重发机制,所以即使丢弃一帧数据,也能通信正常,而且这种一帧数据分散成两部分的概率还是挺低的,ubuntu(linux 系统)下大概千分之三左右的样子。

第四个问题,如果单片机没有空闲中断又该如何做?

当我们使用 RXNE 的同时其实我们也可以使用空闲中断,这样也能确定一帧数据的结束(但是要注意前面的误触发问题)。但是如果有些低端单片机(如 51 )没有空闲中断又该怎么办?

其实我们可以从 stm32 的空闲中断得到相应的启发。

所谓空闲中断,就是当串口接收到数据后,在应该接收数据的时刻,发送方并没有发送数据,所以串口模块置位空闲标志位,从而引起空闲中断。

那么我们是否可以软件模拟串口模块的这个功能,从而确定一帧数据的结束呢?

答案是肯定的(前提是每一帧数据之间有空闲时间)。

我们可以使用一个定时器,定时器向上计数。当接收到一个字节数据后,初始化计数器并启动定时器,这样一旦有一段时间没有接收到串口数据(也就不再初始化计数器),那么定时器溢出,进入溢出中断,而这个溢出中断就类似于串口的空闲中断(在溢出中断中关闭定时器以达到清除空闲中断标志的作用),这样就达到了串口空闲中断的效果(和前面问题的第二种解决方案类似)。

通信吞吐量

在以上分析过程中,都是采用主机发送,从机接收后再回复主机的方式进行通信,虽然通信正常,但实际上效率比较低下,单位时间传输的数据量较少,如下图所示:

红色部分就是必要的空闲时间,可以看到左右两张图的通信频率是有差异的,右图中从机必须等待前一帧数据发送完毕才能处理数据,而左图可以在接收当前帧时处理上一帧数据,类似 CPU 的指令执行流水线。

(图片来源于《权威指南》)

我们也可以将串口接收分为二级流水线:接收、处理,如此一来,我们最少需要两个缓存空间,当一个缓存在接收时,另一个缓存就进行数据处理。发送端可能不等接收端发送完应答数据,它就已经开始发送下一帧数据了,只要相邻两帧数据保证一定发送间隔,就能正常触发中断。

同理,因为接收端也不再慢悠悠的等待接收数据,而是可能有好几帧数据等着它处理,所以为了确保发送端能正常触发空闲中断,也需要控制发送间隔。

为了最大程度利用串口,我们可以使用队列管理很多缓存空间(当只有两个缓存时,可以直接使用异或运算进行缓存切换),比如 uCOS II 中我们可以利用系统的内存管理服务和队列服务实现有效管理,并且当有非常紧急的通信任务时,还可以插入到队头优先处理。

但是增大吞吐量时,比如对重发机制和从机数据的确认有一定影响,需要考虑清楚。

如果要用一句话总结本篇笔记内容,那就是使用 空闲中断+DMA+队列+内存管理+定时控制 方式接收串口数据会是不错的选择。

本文转载自: 鱼鹰谈单片机
免责声明:本文为转载文章,转载此文目的在于传递更多信息,版权归原作者所有。

51单片机采用中断进行串口通信

所谓中断方式,就是串口收/发标志位出发中断后,在中断中执行既定操作,可通过函数调用来实现。

接收数据时: 等待中断->然后在中断中接收数据

发送数据时: 发送数据->等待中断->然后在中断中发送数据

具体步骤如下:

确定 T1 的工作方式(编程 TMOD 寄存器);
计算 T1 的初值,装载 TH1、TL1;
启动 T1(编程 TCON 中的 TR1 位);
确定串行口控制(编程 SCON 寄存器);
串行口在中断方式工作时,要进行中断设置(编程 IE、IP 寄存器)。
注:SCON 是一个特殊功能寄存器,用以设定串行口的工作方式、接收/发送控制以及设置状态标志:

有关波特率的计算方法

在串行通信中,收发双方对发送或接收数据的速率要有约定。通过软件可对单片机串行口编程为四种工作方式,其中方式 0 和方式 2 的波特率是固定的,而方式 1 和方式 3 的波特率是可变的,由定时器 T1 的溢出率来决定。

串行口的四种工作方式对应三种波特率。由于输入的移位时钟的来源不同,所以,各种方式的波特率计算公式也不相同。

1
2
3
4
方式0的波特率 = fosc/12
方式2的波特率 =(2SMOD/64)· fosc
方式1的波特率 =(2SMOD/32)·(T1溢出率)
方式3的波特率 =(2SMOD/32)·(T1溢出率)

当 T1 作为波特率发生器时,最典型的用法是使 T1 工作在自动再装入的 8 位定时器方式(即方式 2,且 TCON 的 TR1=1,以启动定时器)。这时溢出率取决于 TH1 中的计数值。

T1 溢出率 = fosc /{12×[256 -(TH1)]}

注:PCON 中只有一位 SMOD 与串行口工作有关, SMOD(PCON.7) 波特率倍增位。在串行口方式 1、2 和 3 时,波特率与 SMOD 有关,当 SMOD=1 时,波特率提高一倍。复位时,SMOD=0。
在单片机的应用中,常用的晶振频率为:12MHz 和 11.0592MHz。所以,选用的波特率也相对固定。常用的串行口波特率以及各参数的关系如表所示。

80C51 串行口的工作方式

方式 1 是 10 位数据的异步通信口。TXD 为数据发送引脚,RXD 为数据接收引脚,传送一帧数据的格式如图所示。其中 1 位起始位,8 位数据位,1 位停止位。

(1) 方式 1 输出
(2) 方式 1 输入

用软件置 REN 为 1 时,接收器以所选择波特率的 16 倍速率采样 RXD 引脚电平,检测到 RXD 引脚输入电平发生负跳变时,则说明起始位有效,将其移入输入移位寄存器,并开始接收这一帧信息的其余位。接收过程中,数据从输入移位寄存器右边移入,起始位移至输入移位寄存器最左边时,控制电路进行最后一次移位。当 RI=0,且 SM2=0(或接收到的停止位为 1)时,将接收到的 9 位数据的前 8 位数据装入接收 SBUF,第 9 位(停止位)进入 RB8,并置 RI=1,向 CPU 请求中断。

定时/计数器的结构与原理

定时/计数器的实质是加 1 计数器(16 位),由高 8 位和低 8 位两个寄存器组成。TMOD 是定时/计数器的工作方式寄存器,确定工作方式和功能;TCON 是控制寄存器,控制 T0、T1 的启动和停止及设置溢出标志。

加 1 计数器输入的计数脉冲有两个来源,一个是由系统的时钟振荡器输出脉冲经 12 分频后送来;一个是 T0 或 T1 引脚输入的外部脉冲源。每来一个脉冲计数器加 1,当加到计数器为全 1 时,再输入一个脉冲就使计数器回零,且计数器的溢出使 TCON 中 TF0 或 TF1 置 1,向 CPU 发出中断请求(定时/计数器中断允许时)。如果定时/计数器工作于定时模式,则表示定时时间已到;如果工作于计数模式,则表示计数值已满。

可见,由溢出时计数器的值减去计数初值加 1 才是计数器的计数值。

设置为定时器模式时,加 1 计数器是对内部机器周期计数(1 个机器周期等于 12 个振荡周期,振荡周期也叫时钟周期,时钟周期即晶振的单位时间发出的脉冲数,如 12MHZ=12×10 的 6 次方,即每秒发出 12000000 个脉冲信号,那么发出一个脉冲的时间就是时钟周期,即 1/12 微秒;如 11.0592MHZ=11.0592×10 的 6 次方,即每秒发出 11059200 个脉冲信号,那么发出一个脉冲的时间就是时钟周期,即 1/11.0592 微秒)。计数值 N 乘以机器周期 Tcy 就是定时时间 t 。

定时/计数器的控制

80C51 单片机定时/计数器的工作由两个特殊功能寄存器控制。TMOD 用于设置其工作方式;TCON 用于控制其启动和中断申请。

工作方式寄存器 TMOD

工作方式寄存器 TMOD 用于设置定时/计数器的工作方式,低四位用于 T0,高四位用于 T1。其格式如下:

M1M0:工作方式设置位。定时/计数器有四种工作方式,由 M1M0 进行设置:

控制寄存器 TCON

TCON 的高 4 位用于控制定时/计数器的启动和中断申请。其格式如下:

  1. TF1(TCON.7):T1 溢出中断请求标志位。T1 计数溢出时由硬件自动置 TF1 为 1。CPU 响应中断后 TF1 由硬件自动清 0。T1 工作时,CPU 可随时查询 TF1 的状态。所以,TF1 可用作查询测试的标志。TF1 也可以用软件置 1 或清 0,同硬件置 1 或清 0 的效果一样。
  2. TR1(TCON.6):T1 运行控制位。TR1 置 1 时,T1 开始工作;TR1 置 0 时,T1 停止工作。TR1 由软件置 1 或清 0。所以,用软件可控制定时/计数器的启动与停止。
  3. TF0(TCON.5):T0 溢出中断请求标志位,其功能与 TF1 类同。
  4. TR0(TCON.4):T0 运行控制位,其功能与 TR1 类同。

定时器 1 的工作方式 2

方式 2 为自动重装初值的 8 位计数方式。

计数个数与计数初值的关系为:X = 2^8 - N

其中:X 为要装的初值 N 为要定时/记数的次数。

注:工作方式 2 特别适合于用作较精确的脉冲信号发生器。所以在进行串口通信时一般选用定时器 1 工作在方式 2 这种经典模式。

程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76

/*51单片机采用中断方式的串口通信程序分析:
接收数据时 等待中断->然后在中断中接收数据
发送数据时 发送数据->等待中断->然后在中断中发送数据
具体步骤如下:
确定T1的工作方式(编程TMOD寄存器);
计算T1的初值,装载TH1、TL1;
启动T1(编程TCON中的TR1位);
确定串行口控制(编程SCON寄存器);
串行口在中断方式工作时,要进行中断设置(编程IE、IP寄存器)。
*/

#include <reg52.h>
#define uchar unsigned char

uchar Temp,RIflag,TIflag;

//串口初始化函数

void serialportinit() {

TMOD=0x20;//设置定时器1为工作方式2 8位自动重装载 作用是产生波特率
TH1=0xfd;//设置波特率位9600bps
TL1=0xfd;
TR1=1;//开启定时器1

//设置串口工作在方式1
//方式1: 8位异步收发 波特率可变(由定时器控制) 收发一帧的数据为10位 一个起始位(0)8个数据位 1个停止位(1)
//先发送或接收最低位
SCON=0x50;//等价于 SM0=0; SM1=1; REN=1;

//SM0=0;
//SM1=1;
//REN=1;//允许串行接收位 允许串行口接收数据
PCON=0x00;//SMOD=0 波特率不加倍

EA=1;//开总中断
ES=1;//开串口中断 注意:如果使用查询方式进行串口通信时,要把串口中断ES关闭、
}



//定义数据发送函数
void sentTemp() {

SBUF=Temp;//把Temp接收的数据再发送到发送缓冲器SBUF中

//注意:51单片机内部有两个物理上独立的接收、发送缓冲器SBUF(属于特殊功能寄存器) 两个缓冲器共用一个特殊功能寄存器 字节地址(99H?
while(!TI);//等待从机向主机发送数据完成
TI=0;//若发送完成 把发送中断标志位软件清0 因为TI必须由软件清零
}

void main() {
serialportinit();

while(1) {

if(RIflag==1) {
ES=0;//关串口中断
RIflag=0;//接收标志位清0
sentTemp();//调用数据发送函数
ES=1;//开串口中断
}
}
}

//串口中断服务函数
void serialportint() interrupt 4 { //串口中断函数

if(RI) {
RI=0;//接收中断标志位RI必须由软件清0
Temp=SBUF;//把接收缓冲器中收到的数据赋值给led
P1=Temp;//通过开发板监测是否接收到主机发送的数据
RIflag=1;//接收标志位置1 表示从机接收收据完成
}
}

嵌入式基本概念

  1. 中断
    CPU 在处理某一事件 A 时,发生的另外某一事件 B 请求 CPU 去处理(产生了中断),随后 CPU 暂时中断当前正在执行的任务,去对事件 B 进行处理,CPU 处理完事件 B 后再返回之前中断的位置继续执行原来的事件 A,这一过程总称为中断。

  2. 单片机中断源
    单片机一共支持 5 个中断源,其中 2 个外部中断源,3 个内部中断源
    (1)外部中断 0,由 INT0(P3.2 引脚)输入。

(2)外部中断 1,由 INT1(P3.3 引脚)输入。

(3)定时/计数器 0 溢出中断(T0)请求。

(4)定时/计数器 0 溢出中断(T1)请求。

(5)串行口发送/接收中断请求。

  1. 中断服务函数格式
1
2
void 函数名(void)interrupt 中断号
void Int0(void) interrupt 0
  1. 单片机片内定时计数器的主要区别
    1)模式控制寄存器 TMOD 的 D2 或是 D6 位来控制。D2 或 D6 位为 0 时,选择定时工作方式;为 1 时选择计数工作方式。

2)在定时方式时,计数脉冲输入信号由内部时钟提供;计数方式时计数脉冲来自于相应的外部输入引脚。

3)定时器是对单片机的机器周期数进行计数;计数器对外部输入脉冲信号计数,当信号产生由 0 到 1 的跳变时计数器的值加一。

模式控制寄存器的 D1D0 或是 D5D4 位用来选择定时/计数器的四种工作模式,00 表示模式 0,01 表示模式 1,10 表示模式 2,11 表示模式 3。这 4 中操作模式各有特点及优势,可以根据需要选择合适的操作模式。