Linux 常用命令归档

前言

本篇文章用于记录一些Linux系统中的常用操作命令。Linux的命令系统是十分强大和复杂的,为了能够熟练进行系统操作,在这里归档一些常用命令以及常用参数,便于随时查看。如果需要更多更细节的一些操作命令,请自行查阅手册。


文件与目录操作命令

  • pwd:显示当前所处路径

    没有任何参数,单纯用来看自己当前处于什么位置。

    1
    2
    user:~/work$ pwd
    /home/Yukirin/work
  • cd:切换工作目录

    1
    2
    3
    4
    5
    6
    cd /home/user/project   # 进入绝对路径
    cd project # 进入相对路径
    cd .. # 返回上一级
    cd ../.. # 返回上两级
    cd ~ # 回到家目录(等价于 cd)
    cd - # 回到上一次所在目录

    这里要注意一下:cd - 不是进入上一级目录,而是类似于出栈撤回的操作。例如当前处于家目录,使用cd /user/project/subdir 跳转后,使用 cd - 回到的不是 /user/project,而是跳转之前的家目录。

  • ls:列出当前目录下内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ls                  # 简单列出
    ls -l # 长格式:权限、链接数、所有者、大小、时间、名字
    ls -a # 显示隐藏文件(以 . 开头)
    ls -h # 人类可读大小(配合 -l)
    ls -lh # 最常用组合
    ls -alh # 显示所有文件 + 长格式 + 易读大小
    ls -t # 按修改时间排序(新的在前)
    ls -r # 反向排序
    ls -S # 按大小排序(大的在前)
    ls -R # 递归列出子目录
    ls -d */ # 只列出目录本身,不展开内容
    ls -i # 显示 inode 号

    实际开发中,最常用的组合是 ls -alh ,能够应对90%的使用场景。在一些Linux的发行版(例如我这个Ubuntu 26.04)会将该命令重定义为 ll,敲命令时直接使用ll更方便。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Yukirin@DESKTOP-8BFBE1K:~$ ll
    total 76
    -rw------- 1 Yukirin Yukirin 5426 Sep 25 16:51 .bash_history
    -rw-r--r-- 1 Yukirin Yukirin 220 Sep 18 18:54 .bash_logout
    ....

    Yukirin@DESKTOP-8BFBE1K:~$ ls -alh
    total 76K
    -rw------- 1 Yukirin Yukirin 5.3K Sep 25 16:51 .bash_history
    -rw-r--r-- 1 Yukirin Yukirin 220 Sep 18 18:54 .bash_logout
    ....

    可使用type命令检查是否有绑定,如果没有,自己绑定一个就可以了。

    1
    2
    Yukirin@DESKTOP-8BFBE1K:~$ type ll
    ll is aliased to `ls -alF'

    我这里定义为 -alF ,给文件名加了类型标记。你也可以自己组合参数进行定义。

    这里讲讲关于 ls -alh 打印出来的各项的含义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    -rw-rw-r--  1  Yukirin Yukirin  153  Sep 26 15:52  file1.txt
    │ │ │ │ │ │ │
    │ │ │ │ │ │ └─ 文件名
    │ │ │ │ │ └─ 最近一次修改时间
    │ │ │ │ └─ 大小(字节)
    │ │ │ └─ 所属组
    │ │ └─ 所有者
    │ └─ 硬链接计数
    └─ 权限
  • tree:树状图显示目录结构

    1
    2
    3
    4
    5
    6
    tree                # 递归显示
    tree -L 1 # 只显示 1 层
    tree -L 2 # 显示 2 层
    tree -d # 只显示目录
    tree -a # 显示隐藏文件
    tree -h # 显示大小

    tree命令可能并没有自带,如果使用tree提示找不到该命令,那么使用下列命令进行安装

    1
    sudo apt install tree

    tree命令效果如下,在查看文件和目录时还是很方便的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    Yukirin@DESKTOP-8BFBE1K:~$ tree
    .
    ├── C_Test
    │   └── new.c
    ├── Untitled
    ├── WSL-Neovim排查记录.md
    ├── snap
    │   └── glow
    │   ├── 90
    │   ├── common
    │   └── current -> 90
  • mkdir:目录创建

    1
    2
    3
    4
    mkdir test                  # 创建单个目录
    mkdir dir1 dir2 dir3 # 创建多个目录
    mkdir -p a/b/c # 递归创建多级目录
    mkdir -m 755 test # 创建时指定权限

    这里讲一下:父目录不存在,递归创建时必须带上 -p 参数。直接使用 mkdir dir1/dir2/dir3 是创建不了的

    1
    2
    3
    4
    5
    6
    7
    8
    Yukirin@DESKTOP-8BFBE1K:~$ mkdir dir1/dir2/dir3
    mkdir: No such file or directory

    Yukirin@DESKTOP-8BFBE1K:~$ mkdir -p dir1/dir2/dir3
    Yukirin@DESKTOP-8BFBE1K:~$ tree
    ├── dir1
    │   └── dir2
    │   └── dir3

    关于创建时指定权限:这里的 755 是八进制权限码,每一位对应一类用户的使用权限。

    7-5-5 三位分别代表:

    位置 对应对象 英文
    第 1 位 文件所有者 user (u)
    第 2 位 所属组 group (g)
    第 3 位 其他人 others (o)

    3位数字的各自计算方法:

    数字 权限 含义
    0 --- 无权限
    1 --x 只可执行
    2 -w- 只可写
    3 -wx 可写+执行
    4 r-- 只可读
    5 r-x 可读+执行
    6 rw- 可读+可写
    7 rwx 全部权限

    所以755在这里表示:

    1
    2
    3
    7        5        5
    rwx r-x r-x
    所有者 组 其他人

    需要时直接查表算就行了。其实mkdir在指定权限时也支持符号写法:

    1
    2
    mkdir -m u=rwx,g=rx,o=rx test    # 等价于 755
    mkdir -m a=rwx test # a=所有人,等价于 777

    这些写法都是等价的,符号写法规律很明显,不用查表算数字,我比较喜欢这种方法;不过数字写法速度快,背下常规几种组合之后直接用就行。

  • rmdir:空目录删除

    1
    2
    rmdir test                  # 删除空目录
    rmdir -p a/b/c # 递归删除空目录(a/b/c 都为空时)

    这个命令只能删除空目录,若目录内部有文件将删除失败。删除带文件的目录应该用下文的rm -r 命令,rmdir不是很常用,了解一下。

  • touch:文件创建

    1
    2
    3
    4
    5
    6
    touch file.txt              # 创建空文件
    touch file1 file2 # 创建多个
    touch -c file.txt # 文件不存在时不创建
    touch -a file.txt # 只更新访问时间
    touch -m file.txt # 只更新修改时间
    touch -t 202609260830 file # 指定时间戳:YYYYMMDDhhmm

    这里讲一个有意思的点:touch原意其实并不是创建文件,touch的英文是触摸,原本设计的目的是修改文件的时间戳。不过当touch的目标文件不存在时,会先创建一个新文件。后续慢慢的就都使用该命令来创建文件了。

  • rm:删除文件或目录

    1
    2
    3
    4
    5
    6
    rm file.txt                 # 删除文件
    rm -i file.txt # 删除前询问
    rm -f file.txt # 强制删除,不询问
    rm -r dir # 递归删除目录
    rm -rf dir # 递归强制删除(危险!)
    rm -v file.txt # 显示删除过程
    1
    2
    3
    4
    5
    Yukirin@DESKTOP-8BFBE1K:~/work$ rm -i a.out
    rm: remove regular file 'a.out'? n
    Yukirin@DESKTOP-8BFBE1K:~/work$ ll
    total 36
    -rwxrwxr-x 1 Yukirin Yukirin 16608 Sep 25 16:41 a.out*

    这里要提醒一下:rm -rf / 是删除整个root跟目录的自杀式命令,永远不要执行。现在的发行版基本都会拒绝rm -rf /这个命令,但是最好自己在上层加入一些防误操作保护,不要依赖内部实现。

    -v参数会在终端打印删除了什么。如果需要知道具体删除了哪些文件,可以带上该参数。

    1
    2
    3
    Yukirin@DESKTOP-8BFBE1K:~/work$ rm -v -i a.out
    rm: remove regular file 'a.out'? y
    removed 'a.out'

    可以给rm命令加上别名,带上确认与反馈机制,防止误操作。

    1
    alias rm='rm -i'

    上面我们提到,rm -r 可以用于删除带文件的目录,日常使用删除目录的命令基本都是使用这个。

    这里关于 rm -rf 的使用要注意一下:rm -rfv dir1/dir2/dir3 执行这行命令之后只会删除dir3以及dir3的所有子级目录!而不是摘掉dir1/dir2/dir3整棵树,dir1/dir2 都还在。如果想要摘掉 dir1/dir2/dir3 整棵树应该使用 rm -rfv dir1。这个地方不弄清楚很容易用错。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    Yukirin@DESKTOP-8BFBE1K:~$ tree dir1/
    dir1/
    ├── dir2
    │   ├── dir3
    │   └── nana.lua
    └── lala.lua

    2 directories, 3 files
    Yukirin@DESKTOP-8BFBE1K:~$ rm -rfv dir1/
    removed 'dir1/dir2/nana.lua'
    removed 'dir1/dir2/dir3'
    removed directory 'dir1/dir2'
    removed 'dir1/lala.lua'
    removed directory 'dir1/'
    Yukirin@DESKTOP-8BFBE1K:~$ find dir1
    find: ‘dir1’: No such file or directory
  • cp:复制文件和目录

    1
    2
    3
    4
    5
    6
    7
    8
    cp file1 file2              # 复制文件
    cp file1 dir/ # 复制到目录
    cp -r dir1 dir2 # 递归复制目录
    cp -a dir1 dir2 # 归档复制,保留权限、时间、链接
    cp -p file1 file2 # 保留权限和时间
    cp -i file1 file2 # 覆盖前询问
    cp -u file1 file2 # 仅当源更新时才复制
    cp -v file1 file2 # 显示过程
    1
    2
    3
    4
    Yukirin@DESKTOP-8BFBE1K:~/dir1$ cp -vp /home/Yukirin/work/tcp_client.c ../dir1/
    '/home/Yukirin/work/tcp_client.c' -> '../dir1/tcp_client.c'
    Yukirin@DESKTOP-8BFBE1K:~/dir1$ ls /home/Yukirin/dir1/
    dir2 tcp_client.c

    -i参数可以在即将发生覆盖行为时提醒用户。cp默认覆盖是静默无声的,某些情况下可能会导致误冲刷重要文件,所以建议使用别名将 cp -i 封装起来使用。

    Ps:-r 只做递归复制,属性和链接等等不做保留、而 -a 不仅可以递归复制,同时还保留权限、所有者、时间戳、符号链接、硬链接、扩展属性等,尽量做到一模一样。你可以这么理解:-r 只管内容一样,-a 则是原封不动整个文件包括属性一起打包带走。所以做备份时首选 -a 。

    1
    2
    3
    Yukirin@DESKTOP-8BFBE1K:~/dir1$ cp -iv tcp_client.c tcp_back.c
    cp: overwrite 'tcp_back.c'? y
    'tcp_client.c' -> 'tcp_back.c'
  • mv:移动文件/重命名

    1
    2
    3
    4
    5
    6
    7
    mv file1 file2              # 重命名
    mv file1 dir/ # 移动到目录
    mv dir1 dir2 # 重命名目录
    mv -i file1 file2 # 覆盖前询问
    mv -f file1 file2 # 强制覆盖
    mv -v file1 file2 # 显示过程
    mv -u file1 file2 # 仅当源更新时才移动

    mv的用法和参数与cp是一样的。cp是复制,mv是剪切,知道这个就好。

  • file:查看文件类型

    1
    2
    3
    file file.txt               # 文本文件
    file image.png # PNG 图片
    file /bin/ls # ELF 可执行文件

    例如:

    1
    2
    Yukirin@DESKTOP-8BFBE1K:~/dir1$ file tcp_back.c
    tcp_back.c: C source, ASCII text
  • stat:查看文件详细数据

    1
    stat file.txt

    能够看到文件大小、权限、访问时间、修改时间等等。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Yukirin@DESKTOP-8BFBE1K:~/dir1$ stat tcp_back.c
    File: tcp_back.c
    size: 1739 Blocks: 8 IO Block: 4096 regular file
    Device: 8,48 Inode: 79667 Links: 1
    Access: (0664/-rw-rw-r--) Uid: ( 1000/ Yukirin) Gid: ( 1000/ Yukirin)
    Access: 2026-09-26 14:11:45.248891782 +0800
    Modify: 2026-09-26 12:02:43.217786645 +0800
    Change: 2026-09-26 12:02:43.217786645 +0800
    Birth: 2026-09-26 12:01:09.162633570 +0800
  • ln:创建链接

    1
    2
    3
    4
    5
    ln file1 file2              # 硬链接
    ln -s file1 file2 # 软链接(符号链接)
    ln -sf file1 file2 # 强制创建软链接,覆盖已有
    ln -si file1 file2 # 覆盖前询问
    ln -sv file1 file2 # 显示过程

    创建链接分为硬链接和软链接。使用 ln 命令默认创建硬链接;使用 ln -s 创建软连接。

    软链接有点类似Windows桌面的快捷方式,是一个独立的小文件,其内容是指向目标的路径。软连接有自己的inode,如果软连接指向的目标被删除,那么访问软连接时会报:No such file or directory 错误。这是符合常理的,我们在Windows中若快捷方式失活(通常是指向的源文件已被删除),访问也是会报找不到文件错误的。

    硬链接inode是相同的,举个例子:假如原件为 source1,使用 ln 命令为其创建硬链接 source2、 source3,那么source1、source2、source3的inode全都是相同的。这点区别于软链接,我们上面提到过,软链接是一个独立小文件,有自己的inode。

    由于共享inode,硬链接相当于是给原数据上了份保险:上面示例中我们删除掉source1,源数据并不会受损,而这个特性是软链接做不到的。

    这个特性你可以这么理解:我们删除数据时并不是从底层直接销毁数据,而是销毁其指向该部分内存的指针。我们创建了两个硬链接,就相当于多维护了两个指向该块数据的指针。当我们删除source1时,由于2和3仍指向该块数据,所以我们仍然能够获取这块内存,在上层体现出来的就是未被删掉。只有当我们将1、2、3全部删除时,这块数据才是真正丢了,体现在上层就是数据被删除。这就是硬链接的一个特定和优势。

    这里要注意硬链接的两个限制:

    • 不能跨文件系统(因为 inode 只在同一文件系统内唯一)
    • 不能对目录创建硬链接

    软链接没有这个限制。

    上文一直在提到 inode,这里讲讲 inode 是怎么回事:inode全称为 index node(索引节点),是Unix文件系统用来描述一个文件的数据结构。你可以把它理解为一个结构体,内部是所指文件的元数据——例如权限、文件大小、所有者、所属组、链接计数等。其内部还维护有指向文件数据块的指针。

    总的来说 inode = 描述数据的元数据结构体 + 指向数据块的索引。


文件查看

  • cat:打印输出整个文件

    1
    2
    3
    4
    5
    6
    7
    8
    cat file.txt                # 输出全部内容
    cat -n file.txt # 显示行号(所有行)
    cat -b file.txt # 显示行号(跳过空行)
    cat -s file.txt # 连续空行压缩为一行
    cat -A file.txt # 显示不可见字符($ 表示行尾,^I 表示 Tab)
    cat file1 file2 # 拼接多个文件输出
    cat file1 file2 > merged # 合并写入新文件
    cat > file.txt # 从键盘输入,Ctrl+D 结束

    我们这里有如下两个示例文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    file1.txt
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.
    It is often used for typing practice and font testing.

    file2.txt
    Linux 的 cat 命令全称是 concatenate,意思是连接。
    它最常见的用途是查看文件内容,也可以把多个文件拼在一起。
    配合重定向符号,还能快速创建或追加文件。

    使用 cat file1 file2 拼接输出可以得到:

    1
    2
    3
    4
    5
    6
    7
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ cat file1.txt file2.txt
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.
    It is often used for typing practice and font testing.
    Linux 的 cat 命令全称是 concatenate,意思是连接。
    它最常见的用途是查看文件内容,也可以把多个文件拼在一起。
    配合重定向符号,还能快速创建或追加文件。

    将拼接的输出创建为一个新文件 file3.txt

    1
    2
    3
    4
    5
    6
    7
    8
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ cat file1.txt file2.txt > file3.txt
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ cat file3.txt
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.
    It is often used for typing practice and font testing.
    Linux 的 cat 命令全称是 concatenate,意思是连接。
    它最常见的用途是查看文件内容,也可以把多个文件拼在一起。
    配合重定向符号,还能快速创建或追加文件。

    使用 cat > 文件 命令可以创建一个新文件,并且文件内容为你在终端输入的内容。输入完毕后 Ctrl+D 落盘。

    一般小文件使用cat方便快速查看和编辑,对于大文件不要使用cat,会刷屏。如果不慎刷屏使用 Ctrl+C 强行终止本轮调用。

  • less:分页查看

    1
    2
    3
    4
    5
    less file.txt               # 打开文件
    less +F file.txt # 实时跟踪(类似 tail -f)
    less -N file.txt # 显示行号
    less -S file.txt # 不换行,超长行横向滚动
    less -i file.txt # 搜索时忽略大小写

    分页查看用于日常查看大文件,内部支持简单的文本搜索功能,可以当作是一个简易的记事本。

    进入分页模式后有如下几个常用命令:

    按键 功能
    空格 / PageDown 下一页
    b / PageUp 上一页
    g 跳到文件头
    G 跳到文件尾
    /pattern 向下搜索
    ?pattern 向上搜索
    n 下一个匹配
    N 上一个匹配
    q 退出
    F 进入实时跟踪模式(Ctrl+C 退出)

    注意:这里less的默认搜索是大小写敏感的,大小写输入错误是匹配不到的;如果不需要大小写敏感,可以使用less -I 打开文件。参数搜索栏支持基本的正则表达式,以下的形式都是可以的:

    1
    2
    3
    4
    /^Error          # 以 Error 开头的行
    /error$ # 以 error 结尾的行
    /foo.*bar # foo 后跟任意字符再到 bar
    /[0-9]\+ # 一个或多个数字(注意 + 号要转义)

    搜索特殊字符时要记得转义,例如搜索/本身要用:/\/。此处给一个文本中搜索IP地址的示例:

    1
    /[0-9]\+\.[0-9]\+\.[0-9]\+\.[0-9]\+
  • head:查看文件开头

    1
    2
    3
    4
    5
    head file.txt               # 默认前 10 行
    head -n 20 file.txt # 前 20 行
    head -n -5 file.txt # 除了最后 5 行,全部显示
    head -c 100 file.txt # 前 100 字节
    head -q file1 file2 # 多文件时不显示文件名

    使用演示示例如下(还是以上文 file1.txt file2.txt 为例):

    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
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ cat file1.txt
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.
    It is often used for typing practice and font testing.

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ head -n 1 file1.txt
    The quick brown fox jumps over the lazy dog.

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ head -n -1 file1.txt
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ head -c 30 file1.txt
    The quick brown fox jumps over

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ head file1.txt file2.txt
    ==> file1.txt <==
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.
    It is often used for typing practice and font testing.

    ==> file2.txt <==
    Linux 的 cat 命令全称是 concatenate,意思是连接。
    它最常见的用途是查看文件内容,也可以把多个文件拼在一起。
    配合重定向符号,还能快速创建或追加文件。

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ head -n 2 file1.txt file2.txt
    ==> file1.txt <==
    The quick brown fox jumps over the lazy dog.
    This sentence contains every letter of the alphabet.

    ==> file2.txt <==
    Linux 的 cat 命令全称是 concatenate,意思是连接。
    它最常见的用途是查看文件内容,也可以把多个文件拼在一起。
  • tail:查看文件尾部

    1
    2
    3
    4
    5
    6
    7
    tail file.txt               # 默认后 10 行
    tail -n 20 file.txt # 后 20 行
    tail -n +5 file.txt # 从第 5 行开始,显示到末尾
    tail -c 100 file.txt # 后 100 字节
    tail -f log.txt # 实时跟踪新增内容(最常用!)
    tail -F log.txt # 跟踪文件,文件被删除重建后继续跟踪
    tail -f -n 100 log.txt # 先显示后 100 行,再实时跟踪

    该命令用法与head是一样的,此处不做过多阐述。这里讲讲什么叫实时跟踪:tail -f或 tail -F 可以启用实时跟踪功能。实时跟踪指的是,文件内容在增长时,终端自动跟随显示最新的追加内容。

    例如我们使用 tail -f log.txt 监控日志文件,该条命令被执行后终端立即被挂起。当应用程序产生新日志并且写入到 log.txt 中时,终端会立即显示出来。

    -f / -F 两个参数都是开启文件跟踪,区别在于-f跟踪的是文件描述符,当文件移走后(例如该文件日志已满,需要mv到别的地方,并在原地重新创建新日志文件),-f跟踪的是旧文件,看不到新文件。

    而-F跟踪的是文件名,所以旧文件移走并原地创建新文件后能够继续跟踪,而不会跟丢。日常看日志用 -f,如果日志会被轮转,移走重建,用 -F 更稳妥。

  • wc:统计文件行数、字数、字节数

    1
    2
    3
    4
    5
    6
    7
    wc file.txt                 # 输出:行数 单词数 字节数
    wc -l file.txt # 只统计行数(最常用)
    wc -w file.txt # 只统计单词数
    wc -c file.txt # 只统计字节数
    wc -m file.txt # 只统计字符数
    wc -L file.txt # 最长行的长度
    ls | wc -l # 统计当前目录文件数

    示例如下:

    1
    2
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ wc file3.txt
    6 34 362 file3.txt

    输出从左至右分别为:该文件内容行数、单词数、总字节数。

    这里讲讲 wc -c 与 wc -m 的区别:-c统计的是字节,-m统计的是字符。在全英文文本的情况下,二者是等价的,但是对于中文或其它语言等多字节字符时就完全不同。举个例子:文件1内容为:“Hello,World”,通过wc -c 或 wc -m 统计出来都是 11 个字节/字符;文件2内容为:“你好世界”,由于中文字符为宽字符,UTF8中通常占3个字节,因此 wc -c 统计出来应为12个字节,wc -m 统计出来应为4个字符。这就是二者的差别。

    wc -m 的解析依赖当前的locale编码来判断一个字符是几个字节,如果locale没设置对那么 -m 计算出的字符数就是不准确的,这里要注意。

  • du:查看文件及目录的磁盘占用空间

    1
    2
    3
    4
    5
    6
    du file.txt                 # 文件占用(块单位)
    du -h file.txt # 人类可读(KB/MB/GB)
    du -sh dir/ # 目录总大小(最常用)
    du -sh * # 当前目录下每个文件/目录大小
    du -h --max-depth=1 # 只显示 1 层深度
    du -ah dir/ # 包含所有文件

    这里在使用时可能你会发现一个统计问题(还是以上述file1/2/3.txt为例):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ ls -alh
    -rw-rw-r-- 1 Yukirin Yukirin 153 Sep 26 15:52 file1.txt
    -rw-rw-r-- 1 Yukirin Yukirin 209 Sep 26 15:52 file2.txt
    -rw-rw-r-- 1 Yukirin Yukirin 362 Sep 26 16:05 file3.txt

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ du -sh *
    4.0K file1.txt
    4.0K file2.txt
    4.0K file3.txt

    Yukirin@DESKTOP-8BFBE1K:~/docTest$ cd ..
    Yukirin@DESKTOP-8BFBE1K:~$ du -sh docTest/
    16K docTest/

    发现了吗?我们文件大小为 153/209/362,通过磁盘占用统计出来竟然是4Kb?这里要强调一下:du的占用统计是从磁盘的角度来计算的,而磁盘是由簇和扇区构成的,簇是整个文件系统最小的分配单位,而簇又由多个扇区构成。目前我们的几个文件都只有几百字节,大小是很小的,但是依然需要占用一个簇和其中一个扇区,所以统计出来才为4Kb。这也侧面说明我们当前这个系统的文件系统最小扇区为4.0Kb。

  • df:查看磁盘分区使用情况

    1
    2
    3
    4
    df -h                       # 人类可读(最常用)
    df -h /home # 只看指定挂载点
    df -i # 查看 inode 使用情况
    df -T # 显示文件系统类型

    这里要分清 du/df 的区别:du是针对单个文件以及目录进行磁盘占用统计,而df是从宏观上针对整个磁盘进行统计。

  • hexdump:十六进制查看

    1
    2
    3
    hexdump -C file.bin         # 标准十六进制 + ASCII(最常用)
    hexdump -C -n 64 file.bin # 只看前 64 字节
    hexdump -C -s 128 file.bin # 从偏移 128 开始

    这是Linux用来以16进制查看文件的原生工具,在平时分析一些协议时也很常用。默认情况下每行输出16字节,半字分组。最左侧是偏移量,中间是数据内容:

    1
    2
    3
    4
    5
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ hexdump file3.txt
    0000000 6854 2065 7571 6369 206b 7262 776f 206e
    0000010 6f66 2078 756a 706d 2073 766f 7265 7420
    0000020 6568 6c20 7a61 2079 6f64 2e67 540a 6968
    ....

    不过这种方式不太直观,可以使用 hexdump -C 将其拆成标准16进制格式。

    1
    2
    3
    4
    Yukirin@DESKTOP-8BFBE1K:~/docTest$ hexdump -C file3.txt
    00000000 54 68 65 20 71 75 69 63 6b 20 62 72 6f 77 6e 20 |The quick brown |
    00000010 66 6f 78 20 6a 75 6d 70 73 20 6f 76 65 72 20 74 |fox jumps over t|
    00000020 68 65 20 6c 61 7a 79 20 64 6f 67 2e 0a 54 68 69 |he lazy dog..Thi|
    1
    2
    3
    4
    5
    6
    00000000  54 68 65 20 71 75 69 63  6b 20 62 72 ...         |Hello World.|
    │ │ │ │
    │ │ │ └─ ASCII 显示
    │ │ └─ 后8个字节
    │ └─ 前8个字节
    └─ 偏移量(十六进制!)

    不过hexdump不只可以看16进制,也可以其它进制的方式进行查看。其命令参数如下所示:

    选项 作用
    -C 规范 hex + ASCII
    -b 每字节显示为八进制
    -c 每字节显示为字符(转义形式)
    -d 每字节显示为十进制
    -o 每字节显示为八进制(两位)
    -x 每字节显示为十六进制
    -n N 只显示前 N 字节
    -s N 跳过前 N 字节再显示
    -v 不省略重复行(默认连续相同行会显示 *)
  • xxd:十六进制查看

    1
    2
    3
    4
    xxd file.bin                # 十六进制 + ASCII
    xxd -l 64 file.bin # 前 64 字节
    xxd -s 128 file.bin # 从偏移 128 开始
    xxd -r hex.txt > file.bin # 反向:十六进制文本还原为二进制

    这个命令与上面的hexdump是高度重合的。区别在于,hexdump是系统原装的,而xxd是vim编辑器的附件工具包。如果你没有装Vim的话可能没有这个工具。xxd有反色显示,我个人比较喜欢xxd的观感,所以常用xxd。

    xxd还有个hexdump做不到的特性,那就是能够进行反向还原:将16进制重新还原回2进制。

    1
    2
    xxd file.bin > dump.txt        # 导出为文本
    xxd -r dump.txt > back.bin # 从文本还原为二进制

    hexdump没有这个功能,所以在做二进制<->文本转换时使用xxd。

  • grep:文本搜索

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    grep "error" log.txt            # 搜索包含 error 的行
    grep -i "error" log.txt # 忽略大小写
    grep -n "error" log.txt # 显示行号
    grep -v "error" log.txt # 反向:不包含 error 的行
    grep -c "error" log.txt # 统计匹配行数
    grep -r "error" dir/ # 递归搜索目录
    grep -rn "error" dir/ # 递归 + 行号
    grep -w "error" log.txt # 只匹配整词
    grep -A 3 "error" log.txt # 显示匹配行及后 3 行
    grep -B 3 "error" log.txt # 显示匹配行及前 3 行
    grep -C 3 "error" log.txt # 前后各 3 行
    grep -E "err|warn" log.txt # 扩展正则(或)
    grep -l "error" *.txt # 只显示匹配的文件名

    grep是Linux中最常用的搜索工具。使用场景十分广泛,例如我要在某个文件或某几个文件中找内容:

    1
    2
    grep "error" app.log
    grep -n "TODO" *.c # -n 显示行号

    再或者配合管道,从输出中过滤出需要的内容:

    1
    2
    3
    ps aux | grep nginx         # 找 nginx 进程
    ls -alh | grep ".txt" # 只看 txt 文件
    df -h | grep "/dev/sda" # 只看某个磁盘

    grep还有个很强大的地方是支持基本正则表达式(BRE)和拓展正则表达式(ERE),如果会用正则的话那么grep将非常高效和强大:

    1
    2
    3
    4
    5
    grep "^Error" app.log          # 以 Error 开头
    grep "done$" app.log # 以 done 结尾
    grep -E "error|fail|warn" app.log # 匹配多个词之一
    grep -E "[0-9]{3}-[0-9]{4}" file # 电话格式
    grep "colou\?r" file # 注意 ? 号转义

查找与定位

嵌入式内存管理: 内存堆

引言

日常开发中,内存的管理自然是不可或缺的一环。例如 Ethernet 中要发送数据包要开辟发送缓冲区,接收数据包时要开辟接收缓冲区,并且使用完毕后需要及时归还,以免出现内存泄漏。这些操作均离不开一个可靠的内存管理机制。

标准C中已经给我们提供了一套由用户自行管理的内存分配机制,也就是我们常说的堆内存heap(动态分配机制)。但是由标准C提供的这一套机制在嵌入式开发中风险是比较高的,其中主要来自以下几个方面:

  • 不可重入特性:如果被竞争访问有很大风险会造成堆结构损坏,堆链断裂。在一些中间件的改造中(例如 FreeRTOS heap_3) 可能解决了这个问题,但是这仅仅只是取决于一些三方的实现。
  • 容易造成碎片化:标准C实现是没有前后相邻块合并吞食的,这也就意味着在大量分配与释放的操作下,产生微小难以利用的内存碎片的概率很大,导致程序不稳定。
  • 内存位置由链接器强行绑定:标准 C 的堆只有一个——就是由链接器分配的那块。如果想要管理例如 CCM_SRAM这类内存,那么标准C这套管理方法完全无法使用。

因此在很多情况下,嵌入式中的动态内存分配是被完全禁止的。这就需要我们手动打造一套自身的嵌入式专用的内存管理机制。


什么是内存堆?

常规的内存管理机制有 内存池、内存堆 等实现,内存堆其本质就是:在局部开辟一块巨大的静态内存数组,然后在这块数组之上,自行实现一套分配、释放、合并的规则。

1
2
/* 后续的所有内存堆逻辑都是基于该块内存空间上展开. */
static uint8_t __attribute__((aligned(ALIGNED))) ucHeap[HEAP_SIZE];

和内存池区别在于内存堆能够精细把握块粒度,而内存池只能够固定大小分配。


内存堆思想

内存堆的实现有多种变体,这里只阐述我常用的 First Fit(首次适配) + 前后合并 的设计模式。

这里需要先了解一下内存堆是怎么向用户划分空间的基本流程:

  • 用户调用 alloc() 向管理器请求一块内存。
  • 内存堆内部开始遍历所有内存块,直到找到第一块内存空间 > 请求空间的空闲内存。
  • 将该块空间取出,将用户所需部分取出标记使用后返回给用户;多余部分返回给内存堆中。
  • 用户使用完毕后,将空间返回内存堆中,合并前后可能的空闲块,并标记空间空闲。
块头结构

要进行管理,自然离不开对应的数据结构。块头结构就类似于”身份证“,每一块空闲或是正在被使用的内存块都需要维护一个该结构,以便管理逻辑能够正确找到它。其内部包含的元素通常需要含有如下几个:

  • 块大小(BlockSize):这一块空间有多大?用于计算下一块的地址以及与用户申请空间做判断。
  • next、prev 双向指针:双向指针是必须的,用于前后合并以及内存堆遍历时的正向索引。
  • 使用标志(used):这块内存目前是正在被使用还是空闲?要弄清楚这个状态我们就必须维护一个使用标记。这个标记也是我们后续分配以及前后合并时的一个重要参考。

综上所述我们可以拿出如下一个数据结构:

1
2
3
4
5
6
typedef struct heapCtrl {
uint32_t blockSize;
uint32_t used;
struct heapCtrl *next;
struct heapCtrl *prev;
} heapCtrl_t;

这里先记住这个结构,后续我会画图来进行阐述,这个结构尤其重要!

(Ps:这里有个细节要说明一下:尽管used的含义只是布尔型变量,但是此处要使用u32,目的在于结构体内存对齐,便于后续的地址计算。 在MCU中非对齐地址访问风险是很大的!轻则降低效率,重则直接硬件错误)


快速索引

接下来我们还需要一个快速索引(不知道这么叫是否合适,但是作用确实是提高效率),这个索引我来打个比方:在TCP/IP 的大部分实现中,网络层在查ARP表时往往会全局维护一个最近一次命中的IP地址。这样当我下一次向相同IP发包时,就不需要再去遍历ARP缓存表,而是直接一次命中!这就是我们所谓的缓存命中。

同理,我们在此处的索引也是这个道理:假如我们每次请求内存都从数组头开始逐块遍历,那其实效率是比较低的。我们维护一个变量专门指向当前最低地址的空闲块,那么后续遍历直接从该块开始向后遍历,就能提高不少效率。

这个全局索引我们这么来设计:

1
static void *minFree_List = NULL;
  • 分配时,从该地址开始后向遍历(该地址前面全是已经使用了的,没必要跑一遍)。
  • 释放时,若新空闲块地址 < 索引,则更新索引。

分配策略

这里来谈谈分配策略。我在之前已经说过,内存堆的实现不是唯一的。在这里,内存堆关于分配算法的实现主要有以下几类:首次适配(First Fit)、最佳适配(Best Fit)。其中

  • 首次适配:从头开始,找到第一块够大的就行。这是嵌入式里面的经典实现,FreeRTOS的heap_4以及lwIP的内存堆实现采用的都是首次适配的方法。这个方法速度快,而且实现简单,缺点就是在低地址空间中容易形成碎片。
  • 最佳适配:遍历整个链表,找“刚好够用且最小”的。这个方法优势很明显,碎片少;但是缺点也显而易见,时间复杂度更大,而且会产生更小的碎片。在FreeRTOS的heap_2中使用的就是该方式(目前heap_2已被heap_4全面替代)。

我们本文讲解与使用的正是 首次适配(First Fit)的方式。


分裂与合并

分裂

这是整个内存堆分配与回收的流程。上述我已经简述了分配的一个基本流程,此处不再赘述。直接上图能更加形象的呈现整个流程:

  1. 刚初始化时,整个内存堆都是空闲的。此时内存结构如下图所示:

其中,heapCTRL为内存块控制头,由于刚初始化没有产生过分配行为,因此只有一个控制头控制着

(HEAP_SIZE - sizeof(heapCTRL))大小的空闲内存块。

  1. 我们向初始化后的内存堆请求一块Size大小的空间。内存堆状态如下

看到了吗?我们请求一块Size大小的空间,首次分配从整个Free Space中划出来了一块Size大小返回给用户,同时检查剩余空间是否足以容纳下一个控制头(heapCTRL)以及最小数据块(Minimum_Heap_Size)。如果能够容纳,那么剩下部分被打上控制头后返回给内存堆中,供下一次分配使用。

这里提一嘴为什么需要设置Minimum_Heap_Size门槛:这是为了防止内存被割得太碎进而无法有效利用。如果当前遍历到的这块空闲空间,划分后剩下部分已经不足以维持一个控制头和最小数据块,那就索引整个分配给用户,不再切割了。

由于已经发生分配行为,因此我们的minFree_List也得更新到最低地址的空闲块,下次分配行为直接从minFree_List处开始,不再从头开始遍历。

(Ps:这里要提一下,我们展示的合并与分配链表操作其物理地址在空间上必须是连续的!使用链表结构只是为了更加清晰。你别把所有空闲块都串在一条链表上,而后对逻辑地址进行操作,那么整个就全错了!)

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
/* 判断分配后该块剩余部分是否仍能组成最小单位内存块. */
/* 若可以则表示需要切分,split标志置1. */
if ( (ALIGNx((mark->blockSize - need)) >= MIN_HEAP + sizeof(heapCtrl_t)) )
split = 1;

if ( split )
{
/* 新块切分流程. */
heapCtrl_t *new = (heapCtrl_t *)(((uint8_t *)mark + sizeof(heapCtrl_t)) + need);
/* 新块块大小(不含控制头). */
new->blockSize = (mark->blockSize - need - sizeof(heapCtrl_t));
/* 将新块链入. */
new->next = mark->next;
new->prev = mark;
mark->next = new;
if ( new->next != NULL )
new->next->prev = new;
/* 更新新块状态与旧块 块大小(blockSize). */
new->used = 0;
mark->blockSize = need;
}
mark->used = 1; /* 标记旧块已被使用. */
/* 更新minFree_List. */
if ( minFree_List == (void *)mark )
{
heapCtrl_t *q = mark->next;
while ( q != NULL && q->used )
q = q->next;
minFree_List = (void *)q;
}

上面是该段过程的一个参考实现。关键的部分已经给出注释,此处不再赘述。


合并

涉及分配,自然也要涉及到归还。归还不是直接将已使用的块的used标志置0,这么做固然可以,但是久而久之会在低地址空间产生大量的细小内存碎片难以利用,更加暴露我们的First Fit的短板。所以需要想一个办法尽量减少微小内存碎片的产生。

我们的做法是在内存归还时检查其前后“邻居”,如果“邻居”都是空闲状态,那么为什么我们不将其合并成一整块呢?

来看下面:

我们请求分配一块SizeP大小的空间,管理逻辑从minFree_List开始后向遍历,发现n1部分的空间并不满足需求,因此向后继续找到了n2部分,n2满足需求,于是便从n2中取出SizeP + sizeof(heapCTRL)大小的部分用于返回给用户以及新块的控制头建立。对应图中的 ①—>② 这个过程。

假如我们SizeP部分暂时不需要了,需要归还呢?流程是怎么样的呢?

释放时,我们可以先将SizeP(即要归还部分)置空闲状态。也就是由 ① –> ② 状态。

此时,逻辑后向检查发现:n2部分依然是空闲的,因此将n2向SizeP合并,保留SizeP控制头,调整前后指针逻辑。

而前向检查,前面一块相邻内存已经被占用,因此不能进行前向合并。

具体见下面参考示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/* 后向检查与合并. */
while ( p->next && !p->next->used )
{
p->blockSize += (p->next->blockSize + sizeof(heapCtrl_t));
if ( p->next->next )
p->next->next->prev = p;
p->next = p->next->next;
}

/* 前向检查与合并. */
while ( p->prev && !p->prev->used )
{
p->prev->blockSize += (p->blockSize + sizeof(heapCtrl_t));
p->prev->next = p->next;
if ( p->next )
p->next->prev = p->prev;

p = p->prev;
}
/* 状态置空闲. */
p->used = 0;
/* 检查并更新 minFree_List. */
if ( (minFree_List == NULL) || p < (heapCtrl_t *)minFree_List )
minFree_List = (void *)p;

本文我们展示的是释放时立即合并,其实还有一种合并方法就是 惰性合并。

所谓惰性合并就是指:在内存分配时直接进行合并。例如分配时,切下的空闲块后续仍是空闲,那就立即执行合并而不是等到释放时在进行合并。本质都是一样的,区别在于合并的时机不同,实现不同。


总结

这里详细给一下内存堆与内存池的对比:

维度 内存池(Pool) 内存堆(Heap)
块大小 固定 可变
分配 O(1) 取链表头 O(n) 搜索 + 分裂
释放 O(1) 插回链表 O(1) 插回 + 合并
碎片 无 有,必须管理
块头开销 1 个指针 大小 + 标志 + 链表指针(8~16 字节)
适用 RTOS 栈、消息队列、固定结构 不定长字符串、动态数组、协议解析

内存堆与内存池在LwIP中的运用是十分广泛的,想要更加深入了解的话可以具体去看一看其内存管理模块的源码实现。

文章上述的参考示例均是在裸机环境下定义的,如果要引入OS,需要考虑线程保护。

ISO-TP 通信流程(下)

在上篇中(ISO-TP 通信流程(上))我们详细介绍了 iso-tp 中的基础通信数据元(PDU)以及寻址模式,这些内容指导了我们 CAN 报文的组装方式。在下篇中,我们将在前述的基础上,详细讲讲 iso-tp 的状态机流转过程,帧是怎么发送的,时序上有哪些要求。

这里先给两个总状态机流转图:

(一) iso-tp 接收流程状态机(源自 ISO 15765-2:2016)

(二) iso-tp 发送流程状态机

后续我们将以该两图为基础,详细拆解其各个状态。


发送状态机

单帧传输

iso-tp 是具备单帧路由机制的。当待发数据量 <= 7字节(普通寻址模式/Classic CAN为例) / <= 6字节(拓展寻址/Classic CAN 为例)时,我们一个报文就可以将所有的数据全部打包带走,没有必要启动多帧传输。协议栈内部在满足上述条件后,会直接走单帧发送路径。以下是 SF 单帧传输的状态流程:

协议栈内部判定该包数据长度能够在该寻址模式下,通过一个 CAN 报文全部打包带走时,便会将待发送数据路由给单帧发送机制。单帧发送机制会构建 SF 类型的报文,向总线发出,并且在提交发送请求后会立即开启硬件定时计时器 N_As。此时连接控制块状态为 TX_SF 。

当 CAN 控制器成功发送出该帧报文时,将关闭定时器,并且复位状态为 IDLE 空闲状态。以此就完成了一次 SF 单帧的通信。当硬件因为某些原因没有发送出报文进而导致定时器超时后,协议栈应该通知上层,向上层上报错误,由上层来决定是否重启通信。


多帧传输

iso-tp 中,可靠的多帧重组传输机制才是协议的一大亮点。多帧传输是由 FF + CF + FC 共同构成的一个稳定的传输状态机,以下是多帧传输下的状态机流转图:

当协议栈发现要传输的数据量大于单帧所能承载的容量时,此时便会触发多帧传输。

其具体流程为:

  • 构建 FF 帧,用于启动一次多帧传输。

将 PCI + FF_DL + 首段数据(此处示例为 <= 4095 的情况,若 > 4095 且为 Classic CAN,因长度字段占据大量空间,FF 通常不携带有效数据,所有数据均从后续 CF 开始发送。) 打包进CAN报文中,并正确填充 TA/SA/TAType 等信息,提交硬件发送,并启动 N_Bs(流控超时计时器)与 N_As(硬件计时器)。此时状态由 IDLE 转为 TX_FF,当硬件发送确认后,N_As被关闭,状态转为 TX_WAIT_FC,等待接收方发回流控FC。
此时 N_Bs 计时器依然运行,用于监控接收方发回流控这一操作是否超时。

  • 收到接收方发回的 FC ,更新本次传输流控参数,停止 N_Bs 定时器。

收到 FC 之后,若收到的是 WAIT 流控,发送方不应停止 N_Bs,而是重启该定时器继续等待下一个 FC。其它情况下协议栈应该停掉并重置 N_Bs 计时器,同时将收到的 FC 中的流控参数(STmin/BS)以及流控状态提取出来。若对端返回 OVFLW 状态,表示缓冲区不足,应当终止本次传输。
在后续通信中,发送方将严格遵循 FC 中的 STmin(两个CF之间最小帧间隔)与 BS(块大小)的约束进行 CF 的发送。

这里有些细节要强调一下:

  1. STmin/BS 是允许均为0的,代表全速发送,要注意接收方处理速度能否跟上。
  2. 关于 FC 流控机制,标准里面给出了两种解释,详见下图:

iso-tp 允许接收方在同一个多帧传输过程中动态调整流控参数。也就是说,流控机制分为了静态流控与动态流控,不同的工程背景可以选用不同的流控机制。

动态流控:接收方在同一个多帧传输的不同流控帧(FC)中,可以改变 BS 和 STmin 的值(比如第一次 FC 告诉你 BS=5,下一次告诉你 BS=3)。也就是说,iso-tp 允许服务器根据负载动态掌控流控,如果服务器是分段消息的接收方,那么可以自由选择保持 BS/STmin 不变,或者在同一个多帧传输中改变这些值。

例如服务器 CPU 负载变高时,可以将流控缩小甚至关闭(发送Wait流控),以免缓冲区溢出。当空闲时再打开流控接收分段数据。

静态流控:在同一次多帧传输的所有 FC 帧中,BS 和 STmin 要保持不变。也就是说该次通信的流控取决于第一次发送给对方的流控,后续均基于这个流控参数的基础上进行通信。

这个流程中,流控主导权在接收方,发送方被迫适应。

在我的轻量化实现中,对于这个部分我是统一采用静态流控的。因为常规 MCU 之间的通信大部分情境下都不会使用到该功能,为此在发送方引入一个解析功能没必要。

  • 根据流控参数发送连续帧 CF。

从发送缓冲区中取出下一段待发数据,打包成 CF 帧向总线发出,并启动N_As等待控制器成功发到总线上后响应。在整个 CF 发送期间状态机都应该保持 TX_CF 状态。

同时,SN 序列码要递增,这是协议栈判断是否帧错位的重要依据。上一篇文章我们已经阐述了 SN 序列码的作用,此处仅简单提一下要点即可:一定要注意 SN 码是模16循环!因为 SN 的占位只有4位大小,如果忘记循环那么通讯将在第一次 SN 码触发临界时断裂,尤其要注意!

由于流控的作用,每帧 CF 之间需要间隔 STmin。且如果当前BS块发完(仅在 BS > 0 条件下)的话,需要回到 TX_WAIT_FC 状态,等待接收方通过流控 CTS 下发下一次的发送许可。

CF 发送的整体流程如下图所示:

图片内容展示了多帧传输 + 流控场景下的数据收发示意图。重点理解 BS与STmin的概念。

  • 数据发送完毕,传输结束

当最后一包 CF 发出后,协议栈内部判断读指针已经来到待发数据末尾,说明当前整个缓冲区数据已被发出。此时发送方将状态从 TX_CF 切换为 IDLE 空闲状态,通知上层传输完成(可通过回调等形式)并清扫连接内部的相关字段,为下一次通信做准备。

值得注意的是,iso-tp 与 IP 协议类似,只尽最大努力路由数据,至于对端是否接收到是不会管的。没有应用层的 ACK 确认机制(对端是否完全正确接收与组装不会发一帧报文来通知你)。


接收状态机

单帧接收

当协议栈接收到一帧 iso-tp 报文后,会取出 PCI 信息字节的高四位:帧类型识别码。若识别码为 0(即下图中 N_PCItype = 0),则表示该帧为 SF 单帧,走单帧处理流程。

  • 检查接收的CAN报文数据段是否 <= 8字节.(Classic CAN)

若数据长度 ≤ 8:可能是 Classic CAN,也可能是短 CAN FD,但两者 SF 格式一致,直接按 SF_DL 解析即可。此时若 SF_DL = 0,属于非法格式,按错误帧处理。

  • 若数据段 > 8字节,检查是否为 CANFD 的有效 SF 报文.

数据长度 > 8 时,必然是 CAN FD,且数据长度已超过短格式范围,因此必须使用扩展 SF_DL 格式:第一字节低 4 位(原 SF_DL)为 0,真正的长度在第二字节。

对于原4位 SF_DL 不为0的情况,当作错误帧进行处理(忽略)。


多帧接收

当协议栈收到帧类型识别码为 1 的报文时,表示接收到了一帧 FF 首帧。收到 FF 即表示一次多帧通信即将开始,接下来就是完整的多帧通信状态机。

  • 收到 FF 后,提取 FF_DL,回复流控。

收到一帧 FF 后,状态变为 RX_FF 。协议栈应提取出该报文中的 FF_DL,检查自身缓冲区是否足以容纳本次通信数据量(流式传输除外),以明确本次通信所要传输的数据量大小;提取出可能存在的由 FF 帧带来的部分数据;同时也要提取出发送方 SA 地址,便于后续回复流控。

所有数据提取后,根据自身设置,构建 FC 流控帧发往对端,同时启动 N_Cr(监控发送方是否按时发来下一个 CF) 与 N_Ar 计时器(监控 FC 是否被硬件成功发出)。FC流控成功被硬件确认后,此时状态变为 RX_CF ,准备接收接下来的 CF 数据包。

  • CF 数据包接收。

这里接收部分需要涉及到 SN 连续性检查,首帧隐含 SN = 0,因此收到的第一个 CF 期望 SN = 1。

若 SN 与期望值匹配 ,数据存入缓冲区,期望 SN 递增(模16循环)。

若 SN 不匹配(期望2却收到3) 丢弃该帧,保持当前状态,等待 N_Cr 超时后回到 Idle 状态并等待下一次 FF。标准规定此时接收方不主动发送错误码,沉默等待即可。

每收到一个 SN 匹配的 CF 都要重置 N_Cr 定时器。

  • 流控参数:BS。

若 BS != 0,则作为接收方,当BS阈值到达时,状态转换为 TX_FC 需要向对端发送一个 FC 报文。这里如同上文所述:分为静态流控与动态流控。具体怎么使用取决于你的工程实现。

  • 接收完成。

当累积的接收数据 == FF_DL 时,此时可认为已经收到完整的数据。协议栈应停掉 N_Cr 定时器,

状态回到 IDLE 空闲,并将完整的数据交付给上层(通过回调或消息队列)。最后打扫控制块中的对应字段,为下一次通信做准备。

整个流程中,对于 N_Cr 超时,接收方应丢弃已重组数据,状态回到 IDLE,并通知接收方上层。接收方不主动请求重传;是否重新发起多帧传输由发送方决定。

发送状态机与接收状态机其实是对称的:发送方在 TX_WAIT_FC 等流控,接收方在 RX_CF 等连续帧;发送方关心 N_Bs(等 FC),接收方关心 N_Cr(等 CF)。两者通过 FC 帧中的 BS 和 STmin 完成节奏协商,共同保证多帧数据在 CAN 总线上的可靠重组。


ISO-TP 定时器汇总

前面多次提到了定时器,这里给出 iso-tp 中的一些相关定时器以及分工表:

定时器 生效方 全称 启动时机 停止时机 超时后果
N_As 发送方 发送方硬件确认超时 每提交一帧(SF/FF/CF)给 CAN 控制器后 收到 CAN 硬件发送完成中断(ACK) 发送方传输中止,向上层报错
N_Ar 接收方 接收方硬件确认超时 每提交一帧(FC)给 CAN 控制器后 收到 CAN 硬件发送完成中断(ACK) 接收方终止接收,向上层报错
N_Bs 发送方 流控等待超时 发出 FF 后;每发完一个 BS 块后 收到 CTS/OVFLW 时停止;收到 WAIT 时重启继续等待。 终止多帧传输,向上层报错,状态回 IDLE
N_Cr 接收方 连续帧等待超时 发出 FC 后(等待第一个 CF);每收到一个有效 CF 后重置 收到满足 FF_DL 的最后一个 CF 丢弃已重组数据,状态回 IDLE,等待下一次 FF
  • N_Ar / N_As

两个都是记录底层硬件发送行为的计时器。区别在于 N_Ar 是接收方等硬件,N_As 是发送方等硬件。

比如对于 N_As :“我发了,但是这帧有没有从总线上真正发出去?”不得而知,只有等硬件确认。

对于 N_Ar:“我回了 FC,但是有没有真正发出去?” 等硬件确认。

在我的实现中,这两个定时器我统一合并为了一个 N_As,并在 CAN 发送完成中断中进行状态推动。

  • N_Cr / N_Bs

这两个是记录软件层行为的计时器。

对于 N_Cr :这是接收方等待 CF 的定时器——“对方软件到底什么时候给我发下一个 CF?”

对于 N_Bs:这是发送方等待接收方发来的 FC 的定时器——“对方软件到底什么时候回我 FC?”

这些定时器交织起来共同组成了一个超时网络,确保协议栈在通信的每一步都不会卡死,能够超时回退。


至此,iso-tp 的核心流程已经完结,从4个PDU到寻址模式,再到协议栈定时器与运行状态机。后续若有关于 iso-tp 的其它内容,将作为拓展单独篇幅叙述。

STM32F4 CCM_RAM(内核耦合内存)

什么是 CCMRAM?

以 STM32F407 为例,平时我们在使用的时候总会听到 128Kb SRAM 以及 192Kb SRAM 的相关型号,

这里,不管是192还是128,其实代表的都是同一个型号。从192Kb的视角来看,其表征的是芯片内部物理上真实存在的全部 SRAM 总容量,其中可拆分为 (128Kb + 64Kb)。

  • 主SRAM(128Kb)

这128Kb的主SRAM是常规内存,可以被DMA以及我们CPU进行访问。其中,128可进一步拆分为(112Kb SRAM1 + 16Kb SRAM2)。此处对于 SRAM1/SRAM2 不详细展开。

  • CCM SRAM(内核耦合内存)

内核耦合内存是一块特殊的SRAM,其地址空间被映射在 Code 区的 0x1000 0000(关于 ARM Cortex-M的地址空间布局,详见:ARM Cortex-M4 地址空间布局)。这块内存最大的特点就是并没有被挂在AHB总线矩阵上,而是直接连接 Cortex 内核的 D-Code 总线。

上图是总线矩阵架构图,从图中可以看出:SRAM1/SRAM2 作为通用内存,DMA与CPU均能够访问,而CCM RAM直连 D-Code ,这也就意味着 只有CPU能够访问这块内存,DMA够不到这个地方!这是CCM RAM与 普通 RAM 的最大区别所在。

CCM 挂在 CPU 私有总线上,AHB 总线矩阵里根本没有它的地址。DMA 控制器作为 AHB 主设备,物理上完全看不到 0x1000 0000 这片区域。如果你把 DMA 的目标缓冲区(比如 ADC 采样数组)定义在 CCM 里,DMA 搬运时总线会直接返回一个错误响应,触发 HardFault。

总的来说:这一块内存最适合放那种 CPU频繁读写,但是却不需要 DMA 参与的数据。


CCMRAM 的使用

在默认内存布局的情况下,CCMRAM是不启用的。也就是说你定义的所有的数据结构均存放到了主 SRAM 中。在这里我们以 Keil 为例,讲讲CCMRAM的使用。

  • 要使用 CCMRAM,首先需要在 Keil 的分散加载文件夹中加入 CCMRAM 区域。打开 Keil 的.sct 分散加载文件,向其中加入以下字段:

    1
    2
    3
    RW_CCMRAM 0x10000000 0x00010000 {
    *(.ccmram)
    }

    加入后,整个 .sct 如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    ; *************************************************************
    ; *** Scatter-Loading Description File generated by uVision ***
    ; *************************************************************

    LR_IROM1 0x08000000 0x00100000 { ; load region size_region
    ER_IROM1 0x08000000 0x00100000 { ; load address = execution address
    *.o (RESET, +First)
    *(InRoot$$Sections)
    .ANY (+RO)
    .ANY (+XO)
    }
    RW_IRAM1 0x20000000 0x00020000 { ; RW data
    .ANY (+RW +ZI)
    }
    RW_CCMRAM 0x10000000 0x00010000 {
    *(.ccmram)
    }
    }

    这里的含义就是:我们手动新定义了一块内存区域,其起始地址为0x10000000,大小为0x00010000(64Kb)。所有定义在 .ccmram 段的数据结构都将会被放到此内存区域中。

    这里要注意,RW_IRAM1 是 .ANY 属性的,如果定义了全局数据结构但是未显示指定段属性,都将被该区域捕获,即放入主 SRAM 中而不是 CCMRAM。

  • 设置好分散加载文件后,定义变量时,为其声明变量属性即可。

    1
    2
    3
    4
    5
    // 放普通变量.
    __attribute__((section(".ccmram"))) uint32_t my_buffer[1024];

    // 放结构体.
    __attribute__((section(".ccmram"))) MyStruct_t my_config;

    这里要声明一点:__attribute__((section(".ccmram"))) 不能够用于修饰函数。你比如以下的行为是绝对错误的。

    1
    __attribute__((section(".ccmram"))) void fast_isr(void) { ... }

    我们前面已经说过,CCMRAM 只连接到了 D-Code 总线,并未连接 I-Code 总线,而 CPU 取指执行是通过 I-Code 进行的。

    当 CPU 执行 BL 跳转指令试图跳转到 0x1000 0000时:

    1. CPU 内核会向 I-Code 总线发出取指请求。
    2. 总线矩阵发现根本找不到 0x1000 0000 这个地址。
    3. CPU 取回的指令错误,执行后触发 Hardfault。

你比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
__attribute__((section(".ccmram"))) void functionB( void );

int
main( void )
{
// ...

functionB();
while(1)
{
// ...
}
}

void
functionB( void )
{
printf( "CCMRAM Run the Code!\n" );
}

上述程序执行到 functionB 后,触发 I-Code 取指将立即触发 BusFault ,若 BusFault 未使能则直接上升至 HardFault。

注:

CCM 里面可以放入常量,虽然没接入 I-Code,但是可以通过 D-Code总线进行读取访问。语法上没问题,但会占用宝贵的 CCM 空间,且没有性能提升(Flash取指已经很快)。


ARM Cortex-M4 地址空间划分

Cortex-M4 地址空间布局

ARM-Cortex M系列的MCU是32位的,因此理论寻址空间也是32位的,达到了4GB(0x0000 0000 ~ 0xFFFF FFFF)。ARM 把这 4GB 按照功能划分成了 5 个固定的分区,不管是哪一家的设备,只要是 Cortex-M 架构,就必须遵从这个设计。然后各自芯片厂商再在此架构下,填充自身的具体设备。

这里以 Cortex-M4 为例,以下是 Cortex-M4 的 DataSheet 地址空间布局概览图:

代码区(Code)

先来看 0x0000 0000 ~ 0x1FFFF FFFF 部分

这里主要是代码区(Code),也是 Flash 与 ROM的映射区,CPU 通过 I-Code/D-Code 总线取指与读常量。

这里首先要注意一下,CPU复位后,强制从0x0000 0000这个位置取向量,但是 0x0000 0000 这个地址只是一个映射地址,并不是一个有效的物理地址。至于 0x0000 0000映射到哪里,取决于你的BOOT0与BOOT1引脚的配置。

  • BOOT0 = 0:映射到 Flash(0x0800 0000)(正常启动流程)。
  • BOOT0 = 1, BOOT1 = 0:映射到 System Memory(0x1FFF_0000)(出厂固化的一个 Bootloader,也就是我们有时候会用到的串口烧录)。
  • BOOT0 = 1, BOOT1 = 1:映射到SRAM(0x2000_0000)(从 SRAM 启动)。

正是有了这种映射行为,我们才可以通过调整 BOOT 引脚来进入不同的程序模式。

由于我们使用的是 Cortex-M4 系列,因此有一个 CCM SRAM也被放到了 Code 区的内部。不过该段 RAM 只连接到了 D-Code 总线,因此可以供CPU快速取数据,但是不能将你的代码放入进去让CPU去取指执行,因为没有连接到 I-Code 总线。

选项字节(Option Bytes)也被放置到了该区域的 (0x1FFF C000)区域,这里了解就好。

其余部分 Reserved 保留。


SRAM区

SRAM区映射在 0x2000 0000 ~ 0x2002 0000( 128Kb )。这里是我们的片上 SRAM ,如果你用调试器监控过全局数据的话,会注意到其地址都是 0x200x xxxx 开头的,正是因为我们SRAM被映射到了这一块地址。我们的 .data、.bss、Heap、Stack 都被划分到了这一块空间中。

这里可以看到,我们的128 Kb SRAM 的来源并不是一整块大的空间,而是物理上焊了两块独立的 SRAM,并且把他们挂到了 AHB 总线的不同端口上。虽然逻辑上看起来是连续的,但是实际上是两块独立的物理设备。

这么做是为了提高访问效率,如果合并为一块用一个端口,那么当 DMA 和 CPU 均需要访问这块空间时,必须得有一个停下来等另一个操作完毕。而如果划分为独立的两块挂在不同的端口上,则二者可以走不同的通道,实现真正的并行访问。

在优化时,可以将需要 DMA 搬运的大容量数据放在SRAM 112Kb中,将需要CPU访问读取的数据放在 SRAM 16 Kb中,可以避免 CPU 访问和 DMA 搬数据时互相堵塞,有效提升运行效率。

其余部分 Reserved 保留。


外设区(Peripherals)

外设区就是用来映射外设寄存器地址的。我们常用的 GPIO、UART、CAN、SPI、DMA 等外设及其寄存器地址都映射在这块区域内。

从上面总概览图已经可以看出,APB1 APB2 AHB 这些总线依次映射在该块区域。

同一条总线上的外设,地址是按顺序连续分配的。比如 APB1 的 TIM2 基址是 0x4000_0000(起始),TIM3 是 0x4000_0400,TIM4 是 0x4000_0800……每个外设占 0x400(1KB)空间,里面存放该外设的所有寄存器。

这里只是展示地址布局,不展开讨论。


外部存储区(FSMC)

外部存储区是 Cortex-M 地址空间中的一块特定区域(0x6000_0000 ~ 0x9FFF_FFFF),由 FSMC 管理。它负责将外部的 SRAM、NOR Flash、NAND Flash 或 LCD 等并行设备,映射到 CPU 的地址空间,使 CPU 可以像访问内部 SRAM 一样,通过简单的指针读写来访问外部设备。

我们以 FSMC 驱动 8080 并口的LCD作为例子:
当你向 0x6000_0000 + 偏移量 写入一个 16 位数据时,FSMC 硬件会自动完成一些时序动作,比如:片选信号或数据电平,或者锁存寄存器等操作。这里我们不展开讨论,只需要知道这块区域的用途即可。


内核外设区(Internal Peripherals)

虽然此处字面翻译为这个,但是还是建议理解为系统私有寄存器映射区。这里是系统的控制空间,你比如我们常用的 NVIC,Systick,VTOR 以及一些调试组件等等,这些的配置全部映射在了这个区域中。

这里需要注意的是:这段空间是由 ARM 划定的固定地盘,也就是不管是哪一家的芯片(GD32/NXP等等),只要是 ARM Cortex-M 的架构,这块区域都是一样的。在进行一些跨平台的芯片移植时,只要双方都是 Cortex-M,那么与这个地址部分相关的一些底层外设的程序是无需修改的。


Cortex-M 内存布局

Flash布局

Cortex-M 架构中,Flash 主要可以细分为图上几类。文本段(.text)中包含可执行代码 (Executable Code)和常量 ,在文本段之后就是只读数据区域 (.rodata),只读数据后紧跟着的就是全局数据(.data)。

被显式初始化的全局数据结构的初始化值就存放于 .data 处,每次上电 __main 都会从该区域将初始化值拷贝到 RAM 中对应的位置,进而全局数据结构可以在一进入用户程序流程就已经被显式赋值。

这里要强调一点就是:并不是所有MCU都是这么布局的,这里只针对 Cortex-M 架构。

  • 为什么中断向量表始终放在最低地址?

这是因为MCU复位后,固定会去 0x0000 0000 寻址 MSP 和 Reset Handler 进行启动流程,因此它第一眼必须看到向量表。在这里,0x0000 0000并不是真实的物理地址,而是经过 CPU 映射后的地址。

硬件复位时,CPU 自动将 Flash的首地址映射到 0x0000_0000。而后强制从 0x0000_0000 取四字节作为 MSP,再取四字节作为 Reset Handler。

  • .data 段的双重身份

在 Flash 中,.data 叫作 LMA (加载地址)。

在 RAM 中,.data 叫作 VMA(运行地址)。

很直白,每次都从 Flash 中加载到 RAM中,记住这一点就好。


RAM 布局

RAM中 .data 段是存放有初始化值的全局数据结构的具体内存位置,在 Flash 中 .data 段存放具体的初始化数值。上电后由启动脚本进行搬运,上述 Flash 处已经叙述得足够详细,此处不再赘述。

.bss 紧随 .data 之后,也是一段连续的内存空间,其主要存放未显式初始化初始化为0的全局数据或静态数据结构。上电流程中,__main 会将该区域清0。

Heap 是堆区,可由启动文件修改其大小。本质就是一块静态巨型数组,采用C标准的动态内存管理算法(malloc/calloc/free等)进行管理。由于会产生内存碎片,且分配时间不确定,不可重入等特性,因此该区域实际上会被修改得很小,基本用不上。

这里要重点关注的是栈(Stack):

Cortex-M 的栈是满递减栈(Full Descending),也就是栈指针初始时在最高地址,每压入一个数据,SP 就减小,向低地址走。把栈放在最高地址,能让它向下生长时拥有最大的可用空间,避免一开始就撞到 .bss。

这种“两头往中间挤”的设计,最大限度地利用了 RAM 空间,但也带来了溢出碰撞的风险。

关于栈溢出:

由于栈的生长方向向下,因此我们假设栈一直往下生长(例如开辟了一个局部超级大数据结构),在实际布局中,空闲区剩不了多少,Heap也很小。此时 SP 会跨过空闲区直接击穿 Heap ,现象自然就是堆区数据结构被破坏,malloc/free 失败。而后侵入 .bss 所在位置,将全局数据进行篡改。

这也是为什么不允许在栈局部开辟大型结构的根本原因。


一些杂记(三)

全局数据结构/静态局部数据结构/普通局部数据结构分别存储在程序的哪些段中?

在进行区分之前,我们先来看看在嵌入式C语言程序中都有哪些核心的内存段划分:

段名 存放内容 物理属性 典型例子
.text 代码段:所有可执行指令、函数体。 Flash(只读) if、while、函数调用等等。
.rodata 只读数据段:常量、字符串字面量。 Flash(只读) const char *p = "Hello"; 中的字符串 "Hello"。
.data 已初始化读写数据:全局/静态变量,且有非零初值。 Flash存初值,启动时拷贝到RAM int a = 5; 中的 5 存在Flash,变量 a 在RAM。
.bss 零初始化数据:全局/静态变量,无初值或初值为0。 RAM(无Flash占用) int b; 或 static int c = 0;。启动时由 __main 循环清零。
Stack 栈(由编译器管理):局部变量、函数调用现场(返回地址、寄存器)。 RAM uint8_t temp;(定义在函数内部的)。
Heap 堆(由用户管理):动态分配的内存。 RAM malloc(size) 申请的空间。注意:如果工程没用到标准 malloc,这个段通常很小甚至为0。
  • 对于全局数据结构:这里要考虑是否初始化为0。看下面的例子:
1
2
3
uint8_t g_verA;
uint8_t g_verB = 50;
static uint16_t g_init = 100;

三个变量均开辟在全局数据区,g_verA 默认初始化为0,g_verB 和 g_init 都进行了对应的初始化。对于 g_verA,由于没有进行显示初始化,因此被放在了 .bss 段,上电后由 __main 进行清零操作,并且在 Flash 中零占用;

g_verB 和 g_init 进行了显式初始化,因此被放入 .data 段中(这里要注意:被放入.data中的只是变量本身,初始化数据是被放入了 flash 中的)。系统上电后,__main 会将 flash 中的对应初始化数据拷贝到 .data 中对应的位置。这也是为什么我们的全局变量能够一进入程序就已经被赋予一个初始化值的原因。

  • 对于普通局部数据结构:是统一放在函数调用栈中的。
1
2
3
4
5
6
void 
funcA( void )
{
struct test myTestStructure;
// ....
}

myTestStructure 这个结构我们是在函数局部空间进行定义的,因此其被压栈存入到函数栈中。只在当前的作用域有效,一旦函数运行周期结束出栈返回,这个变量的内存空间也随即跟着被销毁了。

由于栈大小是固定且通常比较小,因此不要在局部定义大型数据结构(例如大数组或大型结构体),注意堤防栈溢出。

  • 静态局部数据结构:前面我们说过,static 修饰局部数据结构时不改变其作用域,只规定其生命周期。
1
2
3
4
5
6
void 
funcA( void )
{
static struct test myTestStructure;
// ....
}

此处,myTestStructure 虽然定义语句在局部,但是由于 static 的修饰,其真正的内存空间位置是被放在了 .bss 段(未显式进行初始化,因此默认初始化为0)。当程序流程进入 funcA 时,不会压栈 myTestStructure,而是从 RAM 中直接读出该变量,操作完毕后 funcA 返回,myTestStructure 依然有效。

这里要强调一点:.bss 和 .data 段一大区别就是是否在 flash 中有占用。.data段的数据在上电时需要来自 flash 中的初始化值,而 .bss 不需要,因此 .data 在 Flash 中和 RAM 中是双份空间占用!


堆和栈的主要区别

对比维度 栈(Stack) 堆(Heap)
管理方式 自动管理。编译器自动插入入栈/出栈指令,用户不需要也不应该插手。 手动管理。用户必须显式调用 malloc/calloc 申请,用完必须 free。
分配速度 极快。仅涉及栈指针的移动。 较慢。内部涉及搜索算法,需要遍历空闲链表寻找合适大小的内存块。
生命周期 临时。函数执行完,里面的局部数据结构立即被销毁。 长期。只要你不主动 free,内存一直有效(哪怕函数已经退出)。漏 free 就会造成内存泄漏。
内存碎片 无碎片。后进先出(LIFO)机制。 有碎片。多次申请释放会留下许多细小的空洞,无法被进一步使用(外部碎片)。
大小限制 通常很小,由编译器或链接脚本固定。 较大。

这里讲讲标准C和嵌入式C中的关于堆栈相关的一些差异:

  • 堆内存的来源不一样

标准C语言中堆由操作系统通过 brk/sbrk 系统调用动态扩充,堆区(.heap)位于链接器预留的虚拟地址空间,且在程序运行过程中可以向 OS 申请扩大。

但在嵌入式环境下,标准C没有所谓的操作系统层调用。其操作的本质是一块预先在编译期就定义好的巨型静态数组,形如 static uint8_t ucHeap[20 * 1024]; 这个数组被放在 .bss 或 .data 段,所以堆的大小在编译时就彻底固定了,无法动态扩充。

现在很多中间件你比如 FreeRTOS / lwIP 等等,内部都会实现自身的动态内存管理算法而不是直接使用标准C的实现。这些管理算法实现的本质其实也是在一块巨型静态数组上进行切割分配和回收。

  • 栈溢出后果不同

标准C栈通常很大(几 MB),且如果栈溢出,操作系统可能会分配新的内存页或触发段错误,程序会崩溃但是容易排查。

但是在嵌入式中,栈空间很小(通常1~4Kb),栈溢出后会静默踩到相邻的全局数据结构或另一个任务栈,导致程序运行出现 HardFault 。比较难以排查。

  • 使用标准与安全性

嵌入式中, 标准 malloc 实现是不可重入的,不应该在多线程环境中使用。裸机环境下若不得不使用,应保证不在 ISR 中使用。因为 malloc 内部涉及搜索算法,其分配时间是不确定的,而 ISR 要求尽快返回。

同时,不管在任何环境,若必须使用malloc 请一定使用临界区/或互斥量等手段进行保护。因为不可重入性,若在分配过程中被打断并在别处再次调用 malloc 就很可能会造成整个堆管理结构直接损坏。

标准C的堆实现在实际运用中,基本不会被使用到。需要动态内存分配时,应优先考虑适用于嵌入式环境的三方内存分配算法。


一些杂记(二)

C语言中的 static 关键字

static 关键字用于声明一个静态数据,修饰变量或函数时作用各不同,这里简单讲讲:

  • 修饰全局数据结构:这个很简单,就是将被修饰的全局数据结构作用域严格限制在本模块内。例如在 uart.c 中有一个全局数据变量: static UART_HandleTypeDef huart; 在另一个模块 app.c 中就不能够使用 extern 来获取这个变量。对于函数也是一样的,其一旦被 static 修饰,表示这是一个内部独有功能模块,就不能通过.h向外引出给别的模块使用。在日常使用中,秉承接口简洁易用的原则,对于一些只在库内使用的辅助功能 API 就可以打上 static ,没必要暴露给外部。

  • 修饰局部数据结构:修饰局部结构是 static 的一个重点特性,它不限定作用域,而是起到一个数据结构生命周期常驻的作用(我们都知道局部数据结构生命周期只在本次调用,一旦返回返回出栈就被销毁了)。如下示例:

    1
    2
    3
    4
    5
    6
    7
    void 
    funciton_A( void )
    {
    int8_t temp_var = 0;

    printf( "temp=%d", temp_var++ );
    }

    temp_var 作为局部数据结构,在当该函数被调用时创建,当函数退出时出栈被销毁。因此该变量并不具备重入“记忆”功能。我们在外部循环调用function_A得到如下结果:
    temp=0

    temp=0

    temp=0

    temp=0

    ….

    当我们使用 static 修饰 temp_var 时,该变量的内存位置不再是局部,而是位于全局静态数据区,生命周期被提升到了全局。此时我们继续循环调用,就能看到如下结果:

    temp=0

    temp=1

    temp=2

    temp=3

    …

    最后总结一下:对于 static 修饰局部数据的情况,局部数据的作用域是不会发生改变的,改变的是局部数据的生命周期。其存储位置从一开始的局部任务栈变为了全局数据区(.data/.bss)。

    这里要强调下:不是说程序运行到 static 处才把变量放到全局数据区,而是在编译阶段该块内存就已经被开辟到全局数据区了,该变量在函数调用时永远不会被压栈创建或出栈。


C语言中的 sizeof 关键字

sizeof 用于计算一个类型或变量在内存中占用的字节数,其返回值类型为 size_t。这里要注意,sizeof 不是函数调用!sizeof 在运行阶段是看不到的,不存在函数调用的压栈出栈过程。sizeof 计算式是在编译期被展开和计算的。

1
2
3
4
5
6
7
void 
funciton_A( void )
{
uint8_t ver = 0;
sizeof( ver++ );
printf( "ver=%d", ver );
}

上面示例运行后,得到 ver=0,也进一步说明了sizeof 并不属于函数调用,在运行阶段看不见,ver++也没有被执行。

sizeof 只关心类型,你比如:

1
2
3
4
5
6
7
8
9
int arr[10] = {0};
int *ptr = arr;

void
funciton_A( void )
{
printf("%zu\n", sizeof(arr)); // 40 (整个数组大小:10 * 4)
printf("%zu\n", sizeof(ptr)); // 4 或 8 (指针本身的大小,取决于平台位数. 32位MCU为4字节)
}

sizeof 的使用频率是比较高的,这里简单讲几个容易出错的例子:

  • 在计算结构体类型的数据占用空间大小的,要考虑编译器的对齐模式!除了对齐到1字节是不需要填补(padding)字节之外,其余对齐模式大部分情况下均需要考虑结构体对齐!结构体对齐此处举个示例,不做更多解释:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    struct myModule
    {
    char a;
    int b;
    short c;

    } module1;

    void
    funciton_A( void )
    {
    printf( "structure: %d\n", sizeof(module1) ); // 12. 不是 7!(编译器4字节对齐)
    }
  • sizeof 可用于取数组大小,但是当数组通过指针作为函数入参进行透传时,将退化成指针形式,不能直接使用sizeof 取大小。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    int arr[10] = {0};

    void
    funciton_A( void )
    {
    function_B( arr );
    }

    void
    function_B( uint8_t *pArr )
    {
    printf( "ArraySize: %d\n", sizeof(pArr) ); // 4.
    printf( "ArraySize: %d\n", sizeof(arr) ); // 40.
    }
  • C 语言与 C++ 对字符常量定义的类型不同,用 sizeof 取出的大小也不相同。对于 sizeof(‘a’),C语言中将其类型解释为 int ,因此算出来为大小4;而 C++ 中将其类型解释为 char 类型,因此算出来大小是 1。这里要注意。


C语言中的 volatile 关键字

volatile 用于告诉编译器对被修饰的变量不要执行指令重排以及缓存优化,保证每次对该变量的访存操作都必须从实际的物理地址读出。之所以需要该关键词,是因为编译时有多档优化策略,对于一些随时可能由底层硬件改变的变量,若优化过于激进则可能会将该变量值进行缓存,无法及时响应变化导致程序出现问题。

嵌入式中,以下几个场景最好使用 volatile 保证程序的可预测与安全性,尤其是第一种情况,必须使用 volatile 关键字!

  • 外设寄存器地址映射:这种情况必须要加!硬件寄存器的值随时会被底层更改,如果不加 volatile,编译器可能只读一次寄存器值放进通用寄存器 R0,然后就一直用 R0 里的旧值,永远读不到最新结果。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #define     __IO    volatile             /*!< Defines 'read / write' permissions */

    typedef struct
    {
    __IO uint32_t SR; /*!< USART Status register, Address offset: 0x00 */
    __IO uint32_t DR; /*!< USART Data register, Address offset: 0x04 */
    __IO uint32_t BRR; /*!< USART Baud rate register, Address offset: 0x08 */
    __IO uint32_t CR1; /*!< USART Control register 1, Address offset: 0x0C */
    __IO uint32_t CR2; /*!< USART Control register 2, Address offset: 0x10 */
    __IO uint32_t CR3; /*!< USART Control register 3, Address offset: 0x14 */
    __IO uint32_t GTPR; /*!< USART Guard time and prescaler register, Address offset: 0x18 */
    } USART_TypeDef;

    这是ST官方 HAL 抽象层关于 USART 的一个类型定义,可以看到内部对于每个外设寄存器均采用 volatile 保护。

  • 任务上下文与 ISR 共享的全局变量:
    编译器在编译主循环代码逻辑时,是不会考虑 ISR 的。这就会导致一个问题:当优化行为十分激进时,例如有while(flag == 0),其中 flag 只在中断中被修改。当主循环进行编译时,会发现主逻辑代码里面没有修改 flag ,因此为了加速访存,可能会理所当然的将该变量的值读到内部通用寄存器中,后面的循环再也不去内存读真实值了。程序逻辑自然也偏离了我们预期的行为。
    来看下面这个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    int key_pressed = 0; // 没加 volatile!

    void EXTI0_IRQHandler(void)
    {
    key_pressed = 1; // 中断来了,修改了这个值
    }

    int main(void)
    {
    while(1)
    {
    // 等待按键按下
    if (key_pressed == 1)
    {
    printf("Key pressed!\n");
    key_pressed = 0;
    }
    }
    }

    程序在逻辑上似乎没什么问题,但是当编译器优化比较激进时(-O2,或更高),编译器可能会尝试将 key_pressed 这个变量值移入内部寄存器中,以便加速读取。但是一旦被放入寄存器缓存,那么当中断触发时,就算key_pressed已经被中断修改了,但是主循环依然拿到的是寄存器中的缓存旧副本。后续程序运行均在读取旧值,if 语句可能永远进不去。
    而若 key_pressed 使用 volatile 修饰,则编译器不会缓存,每次访存都会强制从具体地址读取。因此中断修改值后,主循环能够正确读取到更新值并触发对应逻辑。

  • RTOS 环境下多任务间的共享变量
    道理与上述 ISR 与主循环的逻辑是一样的。编译器在编译代码时并不知道所谓的什么任务调度器,也不会考虑到当前编译的代码可能会被别的任务打断这种情况,因此在优化比较激进的情况下,也会产生一些非预期的指令重排和变量缓存的情况,进而导致程序跑飞。考虑下面程序:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void Task_Read(void) {
    while(1) {
    // 编译器看到:这里只有读操作,没有写操作
    if (g_data_ready == 1) {

    // .....
    }
    vTaskDelay(1); // 让出 CPU
    }
    }

    示例中,高优化程度的编译器在编译这段代码时,会看到整个 Read 函数 if 之前只读了 g_data_ready ,并没有修改。为了效率会将 g_data_ready 缓存起来,下次判断直接用它来判断,节省了总线的带宽。但是这显然不对,因此需要 volatile 保护。

由于 volatile 的实验要求有些高,不是很容易看见现象,此处我给出一个具体可用的,能直接观察的 volatile 示例:

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
// main.c
uint8_t flag = 0; // 这里没有加 volatile.

void
task1( void *parametre )
{

while ( flag == 0 )
{
// do nothing.
}

printf("Flag detected!\n");
flag = 0;

while(1)
{
printf("Runing!");

vTaskDelay(1000);
}
}

// it.c
extern uint8_t flag;
void
HAL_TIM_PeriodElapsedCallback( TIM_HandleTypeDef *htim )
{
static uint16_t count = 0;
if ( htim->Instance == TIM6 )
{
HAL_IncTick();
if ( (count++) == 500 )
{
count = 0;
flag = 1;
}
}
}

上述示例中,我们在 task1 内部读取 flag 标志,而该标志的修改在 TIM 的 ISR 上下文。此时对于编译器而言,在高优化程度下,首先会检查该变量是否被 volatile 修饰,因为 volatile 是硬约束,编译器无权删/并/挪;之后编译器会检查自身可见的代码路径上是否有任何可能改写该变量的操作:例如函数调用或显示赋值。在 while ( flag == 0 ) 这个窗口下,没有任何函数调用和直接改写操作。而编译器又看不到中断、DMA、别的任务之类的,因此不会将他们视作威胁。

就这样,编译器直接放心大胆的将 flag 进行缓存,所以上述程序运行现象应该是不会打印任何信息。实测也确实是这样(-O3优化等级),可以看到调试器中没有任何输出。

接下来我们将 flag 使用 volatile 修饰:

1
volatile uint8_t flag = 0; 

再次运行,可以看到调试器已经有输出,程序逻辑正常运行

说明 volatile 成功阻止了编译器的优化,强制每次访存必须重读具体地址。

既然 volatile 更安全,那么全局数据都用 volatile 保护不就好了?这个肯定是不可以的,volatile 等于是放弃了编译器的优化,因此程序执行效率会受到影响。


i++ 与 ++i

两个都是C语言中的自增运算符,区别就是一个是前置自增(++i),一个是后置自增(i++)。

后置自增就是先返回当前值,再将变量i自增。这里要注意的一点是:后置自增返回的是保存了旧值的一个临时副本,而后i再自增;前置自增是变量i先自增,再返回。期间不会产生临时数据结构。

因此,i++不能作为左值(因为返回的是临时数据结构),而++i可以作为左值(返回的就是变量本身)。

1
2
3
int i;
i++ = 10; /* 错误! 后置自增不能做左值. */
++i = 10; /* 前置自增由于返回的是变量本身,因此可以作为左值. */

这里讲一个容易入坑的地方,先考虑下面这一段程序:

1
2
3
4
5
6
7
8
9
10
int *p;
int buf[2];

void
function_A( void )
{
p = buf;
*p++ = 10;
*p = 20;
}

这里一定要分清,*p++ 等价于 *(p++),即先取 p 的旧地址解引用,而后 p 自增指向下一个位置。因此buf[0] = 10,buf[1] = 20。由于p++返回的是地址,而我们是对地址做解引用运算,因此不存在左值问题,哪怕它是后置自增运算。

单独使用的情况下(++i/i++独立使用),二者的行为都是做一次自增,效果完全等价。


指针数组与数组指针

二者本质就是一个是数组,一个是指针,这里简单解释一下。

指针数组 int *p[10];:C语言中,[]运算符的优先级是大于 * 的,所以p先和[10]结合表示p是一个深度为10的数组,剩下的 int * 表示数组中每个元素的类型是指向 int 的指针。

因此 int *p[10]; 表示一个存放了10个 int * 的数组,也叫做指针数组,数组内部的元素是指针类型!

数组指针 int (*p)[10];:由于括号存在,*优先与p结合,表明p是一个指针,而后与[10]结合表示指向一个 int [10]的指针。

因此 int (*p)[10]; 表示这是一个指向存放10个 int 类型的数组的指针。

1
2
3
4
5
6
int *p1[10];   // 指针数组.
int (*p2)[10]; // 数组指针.

printf("%zu\n", sizeof(p1)); // 在 32 位机下输出 40 (10 个 int * 指针,每个 4 字节).
printf("%zu\n", sizeof(p2)); // 在 32 位机下输出 4 (仅有一个指针).
printf("%zu\n", sizeof(*p2));// 输出 40 (因为 p2 指向的是 10 个 int 的数组,10*4=40).

以下是指针数组的应用场景之一:

做 AT 命令的串口解析时,通过指针数组构建一个命令表,进行查表匹配

1
2
3
const char *cmd_list[3] = {"AT", "RESET", "READ"};
// cmd_list 是一个数组,里面存了 3 个指向字符串常量的指针。
// 你可以通过 cmd_list[0] 拿到 "AT" 的首地址。

对于数组指针:

假设你有个 3 * 128 的 LCD 缓冲区,可以通过数组指针来逐个操纵每行的数据。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
uint8_t screen_buf[3][128]; // 假设 3 行 128 列

void
process_row( uint8_t (*row)[128] ) // 入参是一个数组指针.
{
// 此时 row 指向 screen_buf[0] 这整一行.
for (int i=0; i<128; i++)
{
(*row)[i] = i; // 操作该行数据.
}
}

// 调用时:
process_row(screen_buf); // 数组名退化为指向第一行的指针(即数组指针).

一些杂记(一)

在 FreeRTOS 中创建一个任务后,任务可能处于哪些状态?请简要叙述每种状态的含义。

FreeRTOS中,常见的任务状态有 就绪态,运行态,阻塞态,挂起态。同时还有一个删除态,这个状态比较特殊。

创建完毕一个任务后,该任务就被挂入就绪列表,处于就绪态:等待调度器进行调度.

当一个处于就绪态的任务被调度器选中并执行时,该任务随即从 就绪态 -> 运行态.。表示该任务目前正被 CPU 执行。需要注意的是,在单核 CPU 中同一时刻只能有一个任务运行。

处于运行态的任务,若发生阻塞调用(例如 延时 / 等待事件或信号量 / 队列操作等),则由 运行态 -> 阻塞态,并被挂入对应的阻塞事件列表;若用户手动调用 Suspend(),则任务进入挂起态,不再参与调度,直到显式被 Resume() 恢复。

处于运行态的任务在时间片耗尽,且同优先级有其余任务就绪的情况下,会发生时间片轮转(可配置),该任务会被放回就绪列表,调度器重新选择另一个处于就绪态的任务执行。

处于阻塞态的任务,若成功等到事件 / 超时,则会从阻塞态重新回到就绪态等待被调度。若解除阻塞的任务优先级最高,则立即发送任务切换。

这里简单聊聊 删除态 :

删除态是 FreeRTOS 的合法状态之一,当调用 vTaskDelete() 删除任务时,任务会进入该状态,同时该任务将被从可能有的 阻塞列表 / 挂起列表 / 就绪列表 中摘除,并挂入删除列表。然后由系统在空闲任务运行时,对其 TCB 控制块以及分配的栈空间进行释放(若未发生动态分配,则不会释放。由用户自行管理)。


FreeRTOS 中二值信号量与互斥信号量有什么主要区别?各适用于什么场景?

二值信号量与互斥量,在 FreeRTOS 中,二者的本质都是队列。与队列的区别在于,队列有数据段,而信号量/互斥量只使用到了队列的控制结构,自身没有数据段,算是一个特殊的队列。

从下图可以看到:普通的队列除了紫色的控制头区域之外,还有队列项,也就是数据区;而 信号量/互斥量 并没有队列项区域,所用到的只有紫色的控制头!

二值信号量常用于任务间同步与对于低频事件的响应。在存在三个及以上任务,且任务优先级呈梯度排列的场景下,不能够用于资源的保护,因为很大概率会造成优先级的翻转。

互斥信号量与二值信号量类似,但是互斥信号量引入了简易的优先级继承机制。同时会记录持有的任务,遵循谁持有就由谁释放的原则。互斥信号量极大程度上缓解了优先级翻转带来的影响,因此互斥量主要用于多线程环境下,资源的访问保护,保证资源的串行化访问。

要注意:ISR中不允许使用互斥量!因为 ISR 并没有任务上下文这一概念。


configUSE_TIME_SLICING 是什么?当多个任务具有相同优先级时,调度器如何工作?

configUSE_TIME_SLICING 是 FreeRTOS_Config.h 配置文件中的一个可配置项,用于配置是否启用优先级轮转调度的。

FreeRTOS 的常用配置就是 : 不同优先级抢占式调度 + 相同优先级轮转式调度。因此该宏在大部分情况下都是默认启用的,除非你需要手动精准把控任务切换,否则保持默认打开即可。

当启用该宏后,对于同等优先级的多个就绪任务,每个任务轮流的享有 CPU 的时间片,在 FreeRTOS 中,一个时间片多长取决于系统 Tick 周期(Tick周期可通过 configTICK_RATE_HZ 配置)。

当执行完一个时间片后,SysTick 中断会触发内核进行调度检查,此时会去检查相同优先级下是否还有其余就绪任务。若有,则将当前任务挂到就绪任务列表末尾,等待下一次调度,同时取当前就绪列表头节点作为下一个待运行的任务。切换完毕后,新任务继续跑一个时间片,再次触发任务切换,以此循环反复。

要注意一点:抢占式调度与时间片轮转二者并不冲突,抢占调度的核心是以优先级为主,哪怕次优先级任务有多个已就绪,但是目前有1个最高优先级独占 CPU ,那么时间片轮转依然不会触发;只有等到最高优先级任务释放CPU,那么次优先级任务才会得到调度,并在这些就绪之间执行时间片轮转政策。


什么是任务通知?与队列和信号量相比,有什么优势和局限性?

任务通知是内嵌在内核任务 TCB 中的一个属性,完全独立于队列和信号量。任务通知更加轻量化,不需要创建额外数据结构,其字段嵌在任务 TCB 中,在任务被创建并分配内存的那一刻就已可用。是一个轻量化的通知系统,并在通信过程中还允许携带一个32位变量,在通知目标对象的同时还能够传递一个值或标志。

典型场景比如中断的下半段处理:

例如我们期望在外部中断中置起一个标志位,然后在专门的任务中处理相关逻辑。例如下面一段示例代码所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ISR 中(中断上下文).
void EXTI0_IRQHandler(void) {
// 清除中断标志位.
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
// 直接通过任务通知唤醒任务(相当于Give).
vTaskNotifyGiveFromISR(xProcessTaskHandle, NULL);
portYIELD_FROM_ISR(pdFALSE);
}

// 任务中(任务上下文).
void vProcessTask(void *pvParameters) {
while(1) {
// 永久阻塞等待通知(相当于Take),拿到后自动清零.
if (ulTaskNotifyTake(pdTRUE, portMAX_DELAY) == pdTRUE) {
// 处理逻辑.
process();
}
}
}

我们在中断中只置位标志,具体的耗时逻辑放到任务中进行处理。在这里我们使用任务通知直接唤醒对应任务,省去了创建二值信号量的开销,并且速度也更快。

任务通知可以携带一个32位变量,同时也可以间接携带大数据结构——指针在32位机中,是4字节32位的,这意味着你可以在任务通知中携带指针,这样可以间接携带大型数据结构。但是这不安全,如果一定要这么做请注意指针所指对象的生命周期!尤其不要传递指向局部数据结构的指针!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 发送方.
uint8_t g_sensor_buffer[32];
// 获取传感器数据...
// 将缓冲区的地址作为通知值发送出去.
xTaskNotify(xConsumerTaskHandle, (uint32_t)g_sensor_buffer, eSetValueWithOverwrite);

// 接收方.
void vConsumerTask(void *pvParameters) {
uint32_t ulReceivedAddr;
while(1) {
xTaskNotifyWait(0, 0, &ulReceivedAddr, portMAX_DELAY);
uint8_t *pData = (uint8_t*)ulReceivedAddr;
// 直接处理该地址的数据.
parse_sensor_data(pData);
}
}

任务通知最大的局限性是:只能实现任务间的点对点通信。不像信号量或者队列,能交互多个任务。并且不像计数信号量或队列,任务通知只能保存一个待处理状态,当通知事件被高频触发时会导致丢事件(新事件覆盖旧事件)!因此对于接收网络包等事件要使用计数信号量或队列,不能够使用任务通知。


关于优先级翻转的一些事,针对优先级翻转 FreeRTOS 怎么进行处理?

首先我们来看一个实例:有三个任务其优先级分别为: 任务A(H),任务B(M),任务C(L),其中任务A和任务C需要访问一段共同的全局数据区域,因此二者之间需要进行访问保护。

先来看第一种情况,假如我们采用二值信号量来进行一个简单的防护,考虑以下流程:

任务A运行阻塞,任务B也处于阻塞状态,此时任务C独占 CPU 运行 –>

任务C获取到信号量,继续执行逻辑 –>

此时任务A阻塞结束,就绪。由于A为最高优先级,因此立即抢占任务C,任务C被挂回就绪列表等待下一次调度 –>

任务A执行到获取信号量,由于信号量已经提前被C获取,因此任务A发生阻塞,此时再度唤醒任务C执行(前面整个流程种任务B始终阻塞) –>

C重新得到运行,请注意!此时任务B解除阻塞,由于B的优先级比C高,因此立即抢占C –>

我们假设任务B执行了一段长耗时逻辑,记作 t1,完毕后任务B重新进入阻塞 –>

任务C重新得到执行,完毕后释放信号量。任务C执行的整个周期我们记作 t2。–>

任务A获取到信号量,解除阻塞,得到执行。

发现端倪没有?我们原意是:任务A应该等到C执行完后立即执行,也就是任务A被阻塞 t2 后得到响应(这是没办法的事,信号量本身就有阻塞语义,这段等待时间算是数据保护的代价,去不掉的)。但是实际呢?我们高优先级任务A被阻塞了整整 t1 + t2 段时间!原本高优先级任务却被最后一个执行!这还只是一个M任务的情况下,若有多个M任务交替就绪的情况下,我们的任务A会被阻塞多久?这严重影响了我们系统的实时性,是绝对不能容忍的。这就是优先级的翻转。

对于这个问题,FreeRTOS 引入了互斥信号量来解决。互斥信号量比起二值信号量,多了一个优先级继承机制:也就是获取该锁的任务,其优先级会被临时提升到所有阻塞在该互斥量上的最高优先级任务的优先级。在本例中,若使用互斥量进行保护,那么任务C会被临时提升到任务A的优先级,此时任务M就算就绪,由于优先级低于C,无法抢占。待任务C执行完毕后,释放锁,自身回到原优先级,任务A或得锁开始运行。在使用互斥量的情景下,我们的高优先级任务阻塞时间被确定到了 t2,优先级翻转问题得以缓解。

互斥量还有一个特性:互斥量内部会记录当前持有者任务的句柄,互斥量的释放只能由原持有者释放。而二值信号量是可以由任何任务进行释放的。也正是因为这个特性,所以互斥量不允许在 ISR 中使用。因为 ISR 不处于任务上下文,没有“任务”这一概念。

这里再说几点:首先优先级翻转是不能够被根除的,一旦使用信号量,且任务有优先级梯度,就闭然会出现高优先级任务被低优先级阻塞这一情况。只能让获取锁的低优先级任务尽快执行完,不被别的任务打断,进而执行我们高优先级的任务,这就是优先级翻转的常规缓解办法。其次,FreeRTOS 提供的只是一个简易继承机制,不存在链式继承。如果你的任务C同时也在等待另一个信号量,而该信号量被任务D(优先级更低)获取,那么当B就绪时,任务C就又会被优先级翻转!连带连累任务A。当然,这种属于设计问题,在进行布局时应尽可能规避这种使用场景。


xQueueSend 和 xQueueSendFromISR 有什么区别?在使用中断向队列发送数据时,需要注意什么?

首先我们需要明白在 FreeRTOS 中,对于队列以及信号量之类的一些操作给出了两套 API,这两套 API 最大的区别就在于是否被打上了 FromISR 的这个标签。例如:xQueuePeekFromISR(), xQueueReceiveFromISR(), vTaskNotifyGiveFromISR() 等等。

对于非 FromISR 后缀的API,如 xQueueSend ,是用在任务上下文的;而对于带FromISR的 API,如xQueueSendFromISR 是用在中断上下文的。任务类型的 API 绝对不可以用在 ISR 中!FromISR API 虽然可以用在任务中,但是这不是规范做法,请不要混用!下面来简单讲讲两类 API 的区别。

  • 首先是阻塞行为,任务 API 提供阻塞超时机制,可以设置一个阻塞超时时间。当队列满(写操作)或队列空(读操作)时,调用该 API 的任务会进入阻塞态,等待队列腾出空间,直到超时或成功写入;而中断 API 移除了阻塞行为,绝对不能够发生阻塞(RTOS中阻塞会触发任务调度,ISR中进行不了任务调度!)。当操作条件不满足时,将立即返回。

  • 唤醒行为差异(延迟切换):
    在 ISR 中,我们操作队列时多半是以下面的形式进行:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void UART_IRQHandler(void) {
    uint8_t data;
    // 从硬件寄存器读取数据...
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(xQueue, &data, &xHigherPriorityTaskWoken);

    // 其他中断处理...
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
    }

    发现了吗?我们需要透传 xHigherPriorityTaskWoken 这个变量进入FromISR API,而后将其传入portYIELD_FROM_ISR()。缘何如此?这就要提到 RTOS 内核的切换机制了,这里简单讲讲:队列/信号量等操作是可能随时唤醒一个高优先级任务的,因此在这些操作结束后我们需要触发一次任务切换来检查下当前是否因为刚刚的操作导致有高优先级任务就绪。
    对于任务型API,内部在队列/信号量操作后,会立即检查是否需要执行切换,若需要则立即切换;而 ISR 中不能进行任务切换!因此在操作完成后,需要将切换标志放进 xHigherPriorityTaskWoken 这个变量中进行存储。后续在 ISR 即将退出时,显示调用 portYIELD_FROM_ISR() 来标记一次切换请求(本质就是请求一次PendSV),而后当所有 ISR 全部执行完成后,在中断后半段再进行任务切换(也就是我们说的延迟切换)。

这里只是以 Queue 队列操作举个例子,实际上所有带 FromISR 后缀的 API 与原型 API 的主要差异都是上述两点。xQueueSend 用于任务,支持阻塞和自动调度;xQueueSendFromISR 用于中断,不支持阻塞,要手动触发切换请求。不要用混了!

音乐
0:000:00