0%

1.什么是编码解码

编码:利用特定的算法,对原始内容进行处理,生成运算后的内容,形成另一种数据的表现形式,可以根据算法,再还原回来,这种操作称之为编码。
解码:利用编码使用的算法的逆运算,对经过编码的数据进行处理,还原出原始数据,这种操作称之为解码。

2.什么是Base64编码算法

将任意的字节数组数据,通过Base64算法,生成只有(大小写英文、数字、+、/)(一共64个字符)内容表示的字符串数据。即将任意的内容转换为可见的字符串形式。

3.为什么需要Base64编码

Base64算法最开始是被用于解决电子邮件数据传输问题。以前发送邮件只支持可见字符的传送,但ASCII码中,有一部分不支持直接显示。由此,需要有一个方法将不可见的字符转换为可见的字符,便产生了Base64编码算法。

4.Base64算法的实现

特点:

  • 将数据按照 3个字节一组的形式进行处理,每三个字节在编码之后被转换为4个字节。即:如果一个数据有6个字节,可编码后将包含6/3*4=8个字节
  • 当数据的长度无法满足3的倍数的情况下,最后的数据需要进行填充操作,即补“=” ,这里“=”是填充字符,不要理解为第65个字符。因此我们经常看见base64编码的字符串结尾有几个”=”号

示例:

image-20221212143753149

image-20221212143830525

上面示例将原始数据的每三个字节分为一组,按位进行分割为每6位一个字节的形式,进行转换,形成新的4个字节。空缺的两个bit用0填补。
这四个字节才通过Base64编码表进行映射,形成最后实际的Base64编码结果。
如果原始数据最后无法凑成3个字节,则以“=”填充。

注:上图中的1234567表示bit的位置,不是bit的值。

将字符串”ABC”进行Base64编码流程如下。

1.使用ASCII编码方式将字符串”ABC”转换成二进制数据 01000001 | 01000010 | 01000011
2.将步骤1的二进制数据进行分组,每个分组6bit 010000 | 010100 | 001001 | 000011
3.将步骤2的4组6bit二进制编码数据进行补位(高位补0),变成4组8bit二进制 00010000 | 00010100 | 00001001 | 00000011
4.将步骤3中的4组8bit转换成十进制。16 | 20 | 9 | 3
5.以步骤4的十进制数据为索引,去Base64编码映射表中寻找对应的字符。16在编码表中映射的字符是Q,20映射的字符是U,9映射的字符是J,3映射的字符是D。
所以,字符串”ABC”经过Base64编码后的数据是”QUJD”。

Base64编码索引表
26个字母 * 2(大小写) + 10个数字 + 2个运算符号 = 64个字符
image-20221212143820653

5.应用场景

http协议中,GET方式和POST方式是Http请求常用的两种方式,GET方法只能传递ASCII数据,但某些情况下会要求使用GET来传递二进制数据。
此时用Base64对Get传输的二进制数据,编码成ASCII数据,例如,客户端和服务器传递JSON数据时,经常会进行base64编解码。

注意:

  • Base64是编码算法,不是加密算法,只是用来编码二进制的字节数组,形成可视的ASCII字符串。
  • Base64编码实际上是把原数据的3个字节映射成了4个字节,所以相比于原数据长度,编码后的长度会增加1/3。会降低传输效率。

背景

树莓派4B自带蓝牙和Wifi, 无需外接 USB dongle;
蓝牙最常见的应用是近距离传输数据,比如蓝牙传文件,蓝牙音箱等。正好家里有个普通的usb供电的便携音箱;

本文用树莓派蓝牙+普通音箱,实现简单的蓝牙音箱。

首先需要了解Linux音频系统的整体框架:
image-20221208194352559

大致分为三个部分:

  • kernel/driver层的ALSA驱动框架
  • 蓝牙音频协议栈:A2DP, 这是使蓝牙具有传输音频流能力的基石; Linux官方的bluez包实现了A2DP
  • 音频应用层, Linux最常用的音频服务器是Pulse Audio

怎样理解这三层:可以类比Linux网络层:
ALSA 类似网络驱动框架
A2DP 类似TCP/UDP层
PulseAudio 类似HTTP层的服务器,类比Apache

而蓝牙连接类似http连接和会话;
声卡(输入、输出)类似网卡(Ethernet和wifi),音频设备(音箱,麦克风)类似具体的网口设备

深入了解 ALSA 音频驱动和 A2DP 蓝牙音频协议,参考:
Advanced Linux Sound Architecture (ALSA) project homepage
A2DP Spec

本文的环境
树莓派4B, 系统: ubuntu-server raspberry pi版本
音箱:usb供电,音频线
安卓手机:用于配对树莓派的蓝牙音频服务

连接示意图

 Audio source (i.e. smartphone) 
                |
                v
 (((  Wireless Bluetooth Channel  )))
                |
                v
  Raspberry PI (with A2DP service)
                |
                v
         Audio Interface
                |
                v
             Speakers

使用alsa-utils测试音频设备

首先测试Linux上如何使用普通音箱
将音箱USB连到树莓派USB, 音频线连到音频接口

查看音频设备

ALSA在应用层提供了alsa-utils包,其含有arecord、aplay等工具来查看和使用音频设备。

apt-get install alsa-utils

查看声卡列表:

cat /proc/asound/cards

可以看到当前有两张声卡

card 0是树莓派的bcm2835集成声卡,card 1 是另外接的USB麦克风

注意区分声卡和音频设备,一个声卡可以管理多个音频设备,类似于”总线”和”设备”的关系。

音频设备可以细分为输入和输出两种:例如音箱是播放音频,属于输出;麦克风是录入音频,属于输入。下面分别查看这两类设备。

查看音频输入设备:

arecord -l

查看音频输出设备:

aplay -l

使用音频设备

(1)测试音频输出:

aplay test.wav -D plughw:CARD=0,DEV=0

音频设备用 CARD 和 DEV 指定,来自于前文aplay -l查看音频设备的输出
测试音频(wav格式)可以在此下载:ape8.cn

(2)测试音频输入:

使用arecord录制音频输入
-f 录制音频格式。例如 cd 表示 (16 bit little endian, 44100, stereo)
-d 录制时间,单位秒
-c 输入通道的个数,如果是麦克风阵列可能有多通道
-D 使用的设备:-D hw:1,0 表示使用 card 1 下的device 0设备

测试如下:

arecord -f cd -d 5 -c 1 -D hw:1,0 > test.pcm

然后播放此音频:

aplay test.pcm

蓝牙服务相关配置

蓝牙协议栈和服务的安装

首先确保系统软件是最新:

sudo apt-get update
sudo apt-get upgrade

安装 bluez,pulseaudio 等蓝牙基础组件,对于树莓派还要安装pi-bluetooth

sudo apt-get install pi-bluetooth bluez bluez-tools pulseaudio pulseaudio-module-bluetooth

bluez 是Linux官方的蓝牙协议栈,其内部实现 A2DP 蓝牙音频协议,参考bluez.org

PulseAudio 是Linux音频服务器, 其最主要的作用是:
PulseAudio clients can send audio to “sinks” and receive audio from “sources”

参考PulseAudio/About

简单说明下蓝牙的发送、接收的概念:
蓝牙的Source端为发送码流的端,Sink端为接收码流的端;可类比生产者和消费者模型

启动音频服务

PulseAudio服务需要创建用户名和用户组,示例如下:

sudo usermod -G bluetooth -a ubuntu

启动服务器

pulseaudio --start

启动蓝牙配对

蓝牙首次连接需要配对,使用 bluez 的 bluetoothctl工具

参考:How to Manage Bluetooth Devices on Linux Using bluetoothctl

bluetoothctl //进入蓝牙配置模式,会显示用户为[bluetooth]#
[bluetooth]# list //列出树莓派的蓝牙控制器列表
[bluetooth]# agent on //注册蓝牙代理
[bluetooth]# default-agent //使用默认代理
[bluetooth]# discoverable on //树莓派的蓝牙可被其他设备发现
[bluetooth]# scan on //开始扫描可连接蓝牙设备

此后选择要连接的蓝牙设备,手机蓝牙打开,scan on列表找到手机的 MAC地址 进行连接配对。
手机的MAC可在设置->系统信息查看

[bluetooth]# pair <dev> //配对设备,首次需要密码
[bluetooth]# trust <dev> //信任该设备,此后可以自动配对无需密码
[bluetooth]# connect <dev> //建立连接

现在可以退出 bluetoothctl模式,然后测试蓝牙音频播放:

[bluetooth]# quit
aplay test.wav

关于蓝牙的agent,参考bluetoothctl - What is a bluetooth agent?

设置自动配对连接

为了避免每次pair都要指定设备,可以配置蓝牙打开时,自动pair上次的设备。

编辑PulseAudio配置文件 /etc/pulse/default.pa

# automatically switch to newly-connected devices
load-module module-switch-on-connect

编辑bluez配置文件 /etc/bluetooth/main.conf

[Policy]
AutoEnable=true

系统重启后只需要重启PulseAudio服务:

pulseaudio --start

调试过程

找不到蓝牙controller

最开始bluetoothctl list显示的蓝牙控制器列表是空的,我一度怀疑买了假的raspi-4B

原因是树莓派需要安装专门的蓝牙包 pi-bluetooth,参考rpi-4b-bluetooth-unavailable-on-ubuntu

树莓派很多功能都要求系统有定制包,大多数硬件失效都是定制包未安装。

蓝牙连接正常,播放没声音

首先确认音频设备物理连接是否正常;

然后确认PulseAudio音频服务是否正常,检查服务状态和配置文件;

pacmd info
pactl info

问题仍没有解决,仔细听似乎有很小的声音,检测音量配置:

pacmd list-sinks //找到sink设备,即音箱
pacmd set-sink-volume <sink> <value> //设置音量,value取值 [0, 65536] 代表标准音量 0~100%

参考:adjust max possible volume in pulseaudio

此时播放音乐可以听到但声音极小;
检查音箱的线控音量调节,调到最大;
此时蓝牙音乐只有正常音箱大概 30% 的播放音量。

原因是树莓派的USB供电驱动能力有限,同一音箱,在PC-USB供电下30%的音量大小等同于树莓派上100%的音量大小。

自此蓝牙播放音量可以达到正常水平,需要更高音量和音质建议220V供电的音箱。

参考内容

Ubuntu音频设备检测
Make-RPi-bluetooth-speaker-part-1
actuino/bt_speaker-raspberry_pi-zero_w
A2DP audio streaming using Raspberry PI

选型

  • 为什么用树莓派4:

资料多遇到容易解决问题;
性能较强适合作为终端服务器;
自带WIFI, BT5.0,GPIO 方便拓展开发IOT相关项目;
适配系统丰富,基本PC上linux版本树莓派都有对应版本

  • 为什么用USB摄像头:

为了快速实现,Linux对USB设备支持非常好,USB设备基本都是免驱;
USB摄像头支持高分辨率,带麦克风,满足其他项目拓展应用;
当然CSI接口摄像头也有优势,同等条件下其CPU占用率比USB低;不过本地测试中CPU并不是USB摄像头性能瓶颈
关于CSI和USB 摄像头区别:CSI摄像头 vs USB摄像头

  • 树莓派用什么系统:

看个人喜好,我用的ubuntu server的树莓派版本,软件源基本最新;

  • 用什么云服务器:

看个人喜好和价格;云服务器最大价值在于公网IP
我目前用的Aliyun + CentOS7 系统

系统实拍:
image-20221208194427802

树莓派系统安装

准备:电源,网线,SD卡
安装步骤:

  • 1.下载ubuntu server for raspi

注意一定要下载raspi版本的镜像,普通ubuntu server版本安装完不能直接使用SSH
Install Ubuntu on a RaspberryPi

  • 2.Win32DiskImager写.img镜像到SD卡,作为系统盘

参考:使用win32DiskImager为树莓派4B安装系统

  • 3.SSH 登陆

ubuntu server for raspi系统装机启动后,连接网线到主机局域网后就可以SSH登陆
树莓派连到主机网段路由器的LAN口,树莓派系统默认开了dhcp, 用Advanced IP Scanner扫描树莓派IP

树莓派4b:mac地址“dc-a6-32”开头
image-20221208194446543

SSH 软件看个人喜好,putty, SecureCRT, Xshell都可以,我个人使用的SecureCRT

ubuntu server for raspi系统的SSH会话初始化如下:
新建会话-> SSH2链接-> 树莓派ip -> 账户名(默认ubuntu)
初始密码:ubuntu,登陆成功后需要重设密码。

wifi配置方式参考 树莓派安装ubuntu server, 无显示屏和键盘

  • 4.固定树莓派IP

DHCP方式每次启动树莓派IP可能不一样,有两种方式固定IP

USB摄像头测试

  • 首先主机win10上验证摄usb像头功能正常

设备管理器禁用笔记本原装摄像头驱动,搜索相机-> 打开视频,视频流应该正常

  • 在树莓派上验证摄像头设备

usb摄像头设备既是usb设备又是v4l2设备,应该挂载在/dev/videoX

ls /dev/video*
ls /dev | grep video

image-20221208194510165
插拔摄像头确认usb摄像头对应设备是video0

树莓派安装mjpg-streamer

mjpg-streamer的作用是将摄像头采集的YUV/JPEG数据,封装成流服务,其他设备可以通过http方式获取图片或视频流。
mjpg-streamer属于应用层实现流媒体服务端,其底层调用的是Linux V4L2框架接口。

安装过程:

  1. 依赖库安装

    sudo apt-get install subversion libjpeg8-dev imagemagick libv4l-dev cmake git

  2. 安装mjpg-streamer

    git clone https://github.com/jacksonliam/mjpg-streamer.git
    cd mjpg-streamer/mjpg-streamer-experimental/
    make all
    sudo make install

局域网测试mjpg-streamer

mjpg-streamer/mjpg-streamer-experimental目录下有测试脚本:start.sh
环境变量添加依赖库路径:

export LD_LIBRARY_PATH="$(pwd)" 

运行示例:

./mjpg_streamer -i "./input_uvc.so" -o "./output_http.so -w ./www" 

其YUV/MJPEG的输入使用 input_uvc.so, 输出流到 http依赖于 output_http.so,-w ./www 表示http客户端访问时返回www文件夹下的资源,即对应的浏览器页面。

可以自定义参数,参考:

mjpg_streamer -i "input_uvc.so --help"

修改start.sh的自定义启动语句如下:

./mjpg_streamer -i "./input_uvc.so -n -f 30 -r 640x480 -d /dev/video0"  -o " ./output_http.so -w ./www"

-n 用于跳过一些ioctrl请求,我的摄像头如果不用-n,有一些ioctrl会返回错误,尽管不影响流传输功能,还是跳过。
-f 设置fps,如果有卡顿考虑降低该值
-r 分辨率,1080P摄像头可以支持到1920x1080
-d 设备名,默认/dev/video0

一般USB摄像头支持直接输出压缩后的MJPEG格式图像,有的只支持YUV格式图像;
摄像头优先使用MJPEG格式,因为不用mjpg-streamer软件边采集边做压缩,减少CPU使用

启动信息:
image-20221208194525192

此时流服务已运行,在局域网任意设备用浏览器访问树莓派ip:流服务端口即可获取www目录的网页资源
192.168.0.105是我树莓派固定ip, 8080是mjpg-streamer服务默认端口
image-20221208194549504
局域网下即使是1080p 30fps也非常流畅,看不出卡顿

公网服务器搭建反向代理

反向代理的概念

正向代理和反向代理的概念图:

image-20221208194609572

正向代理:代理的是客户端,例如GFW禁止某用户直接访问目标服务器的8080端口,但没有禁止访问正向代理服务器,客户端访问正向代理服务器,代理服务将用户请求转发给目标服务器,实现“蛙跳式”访问。对于目标服务器来说,正向代理服务器才是其客户端,用户ip对其是不可见的。
反向代理:代理的是服务端,应用于以下场景:

  • 出于安全考虑,目标服务器不直接暴露其ip和端口,用户通过访问反向代理服务器来间接访问目标服务器
  • 保证系统稳定性:反向代理服务器可以代理多个目标服务器,当用户请求量大时作为负载均衡(负载均衡和反向代理的区别); 支持目标服务器作为集群管理,当某个目标服务器失效时将请求转发到其他服务器, 参考centos7下apache2.4反向代理

对于本项目,树莓派的mjpg-streamer进程是真正提供流媒体服务的目标服务器,阿里云公网服务器上安装apache服务,实现反向代理。

安装apache服务

Apache实现http web服务器;没有apache, 客户浏览器页面没办法访问对应服务。
阿里云主机 cent-OS 7 上的安装过程:

//安装Apache
yum install httpd
//设置服务器开机自动启动Apache
systemctl enable httpd.service
//启动Apache
systemctl start httpd.service
//重启
systemctl restart httpd.service
//停止
systemctl stop httpd.service    

启动apache后,直接访问阿里云ip,默认端口 80 即为 apache 进程端口,得到如下页面说明服务正常
image-20221208194622426

配置apache为反向代理

apache相关配置路径在/etc/httpd的几个conf目录
image-20221208194635517

vim /etc/httpd/conf/httpd.conf 查看关键内容如下:

Listen 80 //监听80端口
Include conf.modules.d/*.conf //包含module.d目录的所有conf
DocumentRoot "/var/www/html" //默认返回该目录的html资源
IncludeOptional conf.d/*.conf //包含conf.d目录的所有conf

/etc/httpd/conf.modules.d目录下的00-proxy.conf是针对代理的配置项,其中有大量LoadModule加载proxy模块。
配置内容是XML格式,在此自定义反向代理,追加以下内容:

<VirtualHost *:80>
    ProxyRequests off
    <Proxy raspi>
        Order allow,deny
        Allow from all
    </Proxy>
    ProxyPass /raspi http://127.0.0.1:9020
    ProxyPassReverse /raspi http://127.0.0.1:9020
</VirtualHost>

含义:
<VirtualHost *:80> 定义一个虚拟主机,表示任意命名,端口80
ProxyRequests off 关闭正向代理
<Proxy raspi>定义一个代理对象,可以命名为
,这里命名为raspi因为后端服务是raspi流服务
ProxyPassProxyPassReverse 内容要完全一样,ProxyPassReverse /raspi http://127.0.0.1:9020 表示用户访问/raspi资源实际访问的是本地(apache所在云主机)的9020端口。

注意阿里云端口要支持外部可访问,需要在控制台配置安装组,参考:阿里云服务器开放端口教程

我个人的配置是直接(1~65535)全部端口打开(不推荐,有风险)
image-20221208194652256

配置完毕重启apache服务

配置SSH反向隧道

树莓派的mjpg-streamer服务如何连接到阿里云的apache服务?
使用SSH连通。关于SSH,参考SSH (Secure Shell) Home Page

前文的SecureCRT登陆树莓派就是使用SSH2协议,下面将树莓派的mjpg-streamer服务端口通过SSH反向隧道连接到apache的代理端口

ssh -fN -R <阿里云apache代理端口>:<树莓派localhost>:<树莓派mjpg-streamer服务端口> <阿里云服务器用户名>@<服务器IP>

例如 ssh -fN -R 9020:localhost:8080 root@47.100.221.149
image-20221208194724640

输入服务器的登录密码完成通道建立,在阿里云可以查看:
image-20221208194733933

验证公网可访问 mjpg-streamer 服务

  • 1.验证树莓派到apache的视频流通道:

    1. 阿里云服务器启动apache
    2. 树莓派建立SSH反向隧道
    3. 树莓派启动mjpg-streamer
    4. 在阿里云curl访问本地的代理端口

    curl 127.0.0.1:9020/?action=stream

如果有大量数据输出,说明连接没问题

  • 2.验证apache到客户端浏览器的反向代理通道:

使用处于任意网络的设备的浏览器,访问:

http://云服务器IP / Apache代理名 / ?action=stream

本文中配置对应的输入是:47.100.221.149/raspi/?action=stream,注意?action=stream不能掉,直接访问/raspi得到的是静态页面,跳转不到action=stream的页面
image-20221208194745374

直接访问 SSH 通道的 9020 端口支持主页面访问和跳转到action=stream页面:
image-20221208194754703

自此验证完毕公网可访问树莓派的视频流服务

性能测试与优化

实测发现mjpg-streamer启动时使用 640x480分辨率, 30fps,MJPEG格式,延迟卡顿严重

树莓派 ping 阿里云延迟很小:
image-20221208194803656
可能是阿里云带宽不足以支撑大数据量,只能降低分辨率和帧率

我的阿里云服务器只有 3M 带宽,计算一下合适的配置:

3 * 1M/8 = 3 * 128KB = 384KB

理论上当分辨率 640x480 = 300KB, fps 要设置为 1 才几乎无延迟

测试一: 分辨率=640x480, fps=5
结果:初始延迟在 1s 以内,之后延迟增加到几秒;

测试二: 分辨率=640x480, fps=1
结果:初始延迟在 0.5s 左右,半小时后延迟也稳定在1s以内,效果明显比 fps=5 好;
测试符合理论预期,分辨率 和 FPS 要满足带宽

延迟的测试方法:手机计时,网页视频显示,放一起拍照,时间差即视频延迟
以下显示都在1分47秒,延迟小于 1s
image-20221208194813177

注意: 树莓派长时间运行发热较明显,需要配散热片。

1.GDB简介

官网文档:
GDB: The GNU Project Debugger

关于GDB的原理:
GDB实现原理和使用范例
GDB工作原理和内核实现
GDB的基本工作原理

其他教程:GDB调试教程

几个重点:

  • 多种运行方式:gdb启动程序再调试(独立功能程序),gdb attach进程再调试(服务端程序),gdb加载core dump调试(离线调试)
  • GDB的本质是“截获”被调试程序,attach用ptrace截获了OS和应用程序之间的通信, 端点本质是trap中断,截获了CPU正常取指执行流程

本文源码:cursorhu/SimpleMultiThread/4.gdb_thread/

2.多线程程序的GDB调试

待调试代码:

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>

int g_mydata = 0;
std::mutex g_mutex;

void thread_func1()
{
    while (true)
    {
        g_mutex.lock();
        ++g_mydata;
        if(g_mydata == 1024)
            g_mydata = 0;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2()
{
    while (true)
    {
        g_mutex.lock();
        std::cout << "g_mydata = " << g_mydata << ", ThreadID = " << std::this_thread::get_id() << std::endl;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);
    t1.join();
    t2.join();
    return 0;
}

编译:

g++ -g -std=c++11 cppthread.cpp -o cppthread -lpthread

-g: 带debug信息,gdb要用
-lpthread:链接pthread库。当应用直接调用POSIX/pthread接口,或Linux环境中运行多线程都需要

attach方式调试

(1)后台运行并获取PID

  • GDB调试已运行的程序,cppthread线程写成死循环,后台运行。
  • ps -ef | grep NAME 获取PID
  • pstree可以查看线程关系
  • LWP:轻量级进程,是用户线程和内核的中间接口。用户级线程连接LWP上便具有内核线程的所有属性。因此可以认为LWP ID对应线程ID

1

(2)gdb attach,管控进程
2

(3)查看所有线程信息
*表示当前在1号线程,注意这个ID是GDB attach后分配的,真实线程ID参考LWP
3

(4)查看线程backtrace
4

(5)切换线程
5

(6)单步调试线程

  • 注意,GDB调试时是支持线程切换的,等同正常执行多线程,也可以禁用切换:set scheduler-locking on。本示例有mutex锁,未见到切换
  • next: 单步(一步),next n: 单步n步
  • watch 变量,可见next 6后g_mydata + 1
  • watch会自动隐式的加断点,后文会看到断点信息

6

(7)断点

  • break i: 在代码i行加断点, break func:在函数加断点
  • clear i: 清除i行的断点, delete id: 清除指定id的断点
  • 注意看watch引入了一个断点11
  • continue:继续执行,通常配合断点使用

7

(8)线程外调试+多断点
两个工作线程都加断点
8
一次运行
9
继续运行
10

  • Thread2和Thread3即工作线程,Thread1为主线程
  • Thread1会切到工作线程,LWP=6080或6081
  • 两次运行,Thread1切到的LWP不一样

GDB显示主线程切到哪个工作线程,实际是CPU当前在执行哪个工作线程,因此两次运行到断点时,当前执行线程分别是Thread2和Thread3,主线程实际是阻塞的。

GDB直接运行程序

用GDB运行程序的调试方式:

 gdb < prog_name >

(1)运行
11

(2)加断点和执行
12

(3)查看变量值
p 变量:打印变量,和watch相比不会加隐含的断点
13

14

core dump文件方式调试

有关core dump
Linux 下如何产生core文件(core dump设置)
Understand and configure core dumps on Linux
C++中段错误的常见情况
coredump问题原理探究(Linux版)

下面修改前面的程序,制造core dump
(1)数组越界
cppthread_dump_array.cpp:

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>
#include <cstring>

int g_mydata = 0;
std::mutex g_mutex;

char test_dump_buf[10] = {0};

void thread_func1()
{
    while (true)
    {
        g_mutex.lock();
        ++g_mydata;
        char c;
        sprintf(&c, "%d", g_mydata);
        std::strcat(test_dump_buf, &c); //持续追加g_mydata字符串
        if(g_mydata == 1024)
            g_mydata = 0;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2()
{
    while (true)
    {
        g_mutex.lock();
        std::cout << "g_mydata = " << g_mydata << ", ThreadID = " << std::this_thread::get_id() << std::endl;
        std::cout << "test_dump_buf: " << test_dump_buf << std::endl;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    return 0;
}

运行结果:
15
并未发生段错误,更不谈core dump。因为CPP对数组没有越界限制,这是个“合法”行为

(2)使用空指针
cppthread_dump_nullptr.cpp:

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>
#include <unistd.h> //for linux sleep()

std::mutex g_mutex;

class Foo 
{
    public:
        Foo(int m)
        {
            m_data = m;
        }
        ~Foo(){}
        void printval() 
        {
            std::cout << "m_data = " << m_data << std::endl;
        }
        void increase()
        {
            ++m_data;
        }
        int getval()
        {
            return m_data;
        }
        void resetval()
        {
            m_data = 0;
        }
        
    private:
        int m_data;
};

void thread_func1(Foo& p)
{
    while (true)
    {
        g_mutex.lock();
        p.increase();
        if(p.getval() == 1024)
            p.resetval();
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2(Foo& p)
{
    while (true)
    {
        g_mutex.lock();
        p.printval();
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{    
    Foo *pFoo = new Foo(0);

    std::thread t1(thread_func1, std::ref(*pFoo)); //std::ref用于std::thread传入参数,以引用的形式
    std::thread t2(thread_func2, std::ref(*pFoo));

    //t1.join();  //这里故意不join
    //t2.join();
    sleep(10);  //sleep等一下thread1,2
    
    delete pFoo; 
    pFoo = NULL;  //这时thread1,2还没执行完,形成了使用空指针的条件
            
    return 0;
}

运行结果:
16

下面gdb调试这个core dump

  • 设置core dump文件大小限制为不受限
    17
    最好写入配置文件
    18

  • gdb加载程序和core dump文件
    19
    可见siganl 6发生,使进程终止

  • 看dump位置,bt或where都可以
    20

  • 看所有线程的栈
    21

  • 直接看dump附近的代码

结论:根据bt/where, dump发生时,主进程在在执行67行:

image-20221205154207720

根据源码,具体是工作线程调用对象的方法时发生
再看dump打印,正好要打印m_data = 10的时候dump,结合main中sleep(10)和工作线程sleep(1),dump原因是main把对象指针置空了,而两个工作线程还在调用对象的方法,位置是thread_func2的p.printval()处

一个示例:调试死锁

最常见的死锁是双重加锁,和双重delete的道理一样,代码复杂了,层层调用的情况下容易出现
示例代码cppthread_deadlock.cpp:
线程函数和其调用的类方法都加锁了,形成死锁

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>
#include <unistd.h> //for linux sleep()

std::mutex g_mutex;

class Foo 
{
    public:
        Foo(int m)
        {
            m_data = m;
        }
        ~Foo(){}
        void printval() 
        {
            std::cout << "m_data = " << m_data << std::endl;
        }
        void increase()
        {
            g_mutex.lock(); //故意制造双重加锁
            ++m_data;
            g_mutex.unlock();
        }
        int getval()
        {
            return m_data;
        }
        void resetval()
        {
            m_data = 0;    
        }
        
    private:
        int m_data;
};

void thread_func1(Foo& p)
{
    while (true)
    {
        g_mutex.lock();
        p.increase();
        if(p.getval() == 1024)
            p.resetval();
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2(Foo& p)
{
    while (true)
    {
        g_mutex.lock();
        p.printval();
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{    
    Foo *pFoo = new Foo(0);

    std::thread t1(thread_func1, std::ref(*pFoo)); //std::ref用于std::thread传入参数,以引用的形式
    std::thread t2(thread_func2, std::ref(*pFoo));

    t1.join();
    t2.join();
    
    delete pFoo;
    pFoo = NULL;
            
    return 0;
}

调试:

  • 直接运行方式,要run起来才有线程

23

  • backtrace可见两个线程都停止于lock_wait(),其中thread2回溯看到死锁代码在45行

24

对于互斥锁推荐用RAII机制的std::lock_guard<mutex> lockGuard(m),能避免忘记unlock情况。但在此示例中,lock_guard也会双重加锁。

相关资料

线程池的概念和相关示例可以参考:
C++实现线程池
基于C++11实现线程池的工作原理

本代码相关的C++基础,参考:
c++拷贝构造函数详解
智能指针shared_ptr的用法
深入解析条件变量

其他相关文章
jorion/c++11 多线程(X)
jorionwen/threadtest

线程池示例

调用线程池

#include "TaskPool.h"
#include <chrono>

int main()
{
    TaskPool threadPool;
    threadPool.init(); //初始化线程对象队列

    Task* task = NULL;
    for (int i = 0; i < 10; ++i)
    {
        task = new Task();
        threadPool.addTask(task); //初始化任务对象队列,调度线程时会取出执行
    }
    
    std::this_thread::sleep_for(std::chrono::seconds(2));

    threadPool.stop(); //等待所有工作线程结束

    return 0; //析构
}

线程池的方法

#include "TaskPool.h"

TaskPool::TaskPool() : m_bRunning(false)
{

}

TaskPool::~TaskPool()
{
    removeAllTasks();
}

void TaskPool::init(int threadNum/* = 5*/)
{
    if (threadNum <= 0)
        threadNum = 5;

    m_bRunning = true;

    for (int i = 0; i < threadNum; ++i)
    {
        std::shared_ptr<std::thread> spThread;
        //shared_ptr.reset带参数是初始化,指向new出的thread对象
        //bind绑定了thread对象和其执行函数threadFunc
        spThread.reset(new std::thread(std::bind(&TaskPool::threadFunc, this))); 
        m_threads.push_back(spThread); //thread对象入队
    }
}

void TaskPool::threadFunc() //thread对象唤醒时执行
{
    std::shared_ptr<Task> spTask;
    while (true)
    {
        std::unique_lock<std::mutex> guard(m_mutexList); //RAII实现,作用域结束自动解锁
        while (m_taskList.empty())
        {                 
            if (!m_bRunning)
                break;
            
            //如果获得了互斥锁,但是条件不合适的话,pthread_cond_wait会释放锁,不往下执行。
            //当发生变化后,条件合适,pthread_cond_wait将直接获得锁。
            m_cv.wait(guard);
        }

        if (!m_bRunning)
            break;

        spTask = m_taskList.front(); //取m_taskList的task对象
        m_taskList.pop_front(); //更新m_taskList

        if (spTask == NULL)
            continue;

        spTask->doIt(); //执行task
        spTask.reset(); //shared_ptr.reset不带参数,指向对象的计数-1
    }

    std::unique_lock<std::mutex> guard(m_mutexList); //为了打印的原子性,再加锁
    {
        std::cout << "Exit thread, threadID: " << std::this_thread::get_id() << std::endl;
    }
    
}

void TaskPool::stop()
{
    m_bRunning = false;
    m_cv.notify_all(); //唤醒所有等待条件变量的线程

    //等待所有线程退出
    for (auto& iter : m_threads)
    {
        if (iter->joinable())   //该线程是否可join
            iter->join();       //主线程等待该线程
    }
}

void TaskPool::addTask(Task* task)
{
    std::shared_ptr<Task> spTask;
    spTask.reset(task); //shared_ptr初始化,指向task

    {
        std::lock_guard<std::mutex> guard(m_mutexList);       
        //m_taskList.push_back(std::make_shared<Task>(task));
        m_taskList.push_back(spTask); //Task对象入队
        std::cout << "Add a Task." << std::endl;
    }
    
    m_cv.notify_one(); //唤醒随机一个等待条件变量的线程
}

void TaskPool::removeAllTasks()   //析构时调用
{
    {
        std::lock_guard<std::mutex> guard(m_mutexList);
        for (auto& iter : m_taskList)
        {
            iter.reset();
        }
        m_taskList.clear();
    }
}

类定义

#include <thread>
#include <mutex>
#include <condition_variable>
#include <list>
#include <vector>
#include <memory>
#include <iostream>
#include <functional> //for visual studio build

class Task
{
public:
    virtual void doIt()
    {
        std::cout << "Do a task..." << std::endl;
    }

    virtual ~Task()
    {
        //为了看到一个task的销毁,这里刻意补上其析构函数
        std::cout << "A task destructed..." << std::endl;
    }
};

class TaskPool final
{
public:
    TaskPool();
    ~TaskPool();
    TaskPool(const TaskPool& rhs) = delete;     //delete: 禁用某函数(C++11),阻止拷贝和赋值构造,C++98用private
    TaskPool& operator=(const TaskPool& rhs) = delete;

public:
    void init(int threadNum = 5);   //默认初始化
    void stop();

    void addTask(Task* task);
    void removeAllTasks();

private:
    void threadFunc();

private:
    std::list<std::shared_ptr<Task>>            m_taskList;
    std::mutex                                  m_mutexList;
    std::condition_variable                     m_cv;
    bool                                        m_bRunning;
    std::vector<std::shared_ptr<std::thread>>   m_threads;
};

运行

image-20221205152435385

本文讲解并发环境中的几个线程同步示例
线程同步,即多个线程如何协调,谁先谁后
本文基于Linux/POSIX API
本系列源码:cursorhu/SimpleMultiThread

生产者消费者模式

生产者/消费者模式是并发环境常见的模式,简单地讲,通过中介缓冲,支持多组任务并发执行,避免任务间发生通信阻塞。
参考:生产者/消费者模式的理解及实现

常用的实现方式

信号量实现

关于LInux信号量:Linux信号量

示例:

#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <list>
#include <semaphore.h>
#include <iostream>

class Task
{
public:
    Task(int taskID)
    {
        this->taskID = taskID;
    }
    
    void doTask()
    {
        std::cout << "handle a task, taskID: " << taskID << ", threadID: " << pthread_self() << std::endl; 
    }
    
private:
    int taskID;
};

pthread_mutex_t  mymutex;
std::list<Task*> tasks;
sem_t            mysemaphore;

void* consumer_thread(void* param)
{    
    Task* pTask = NULL;
    while (true)
    {
        struct timespec ts;
        ts.tv_sec = 3;
        ts.tv_nsec = 0;
        
        if (sem_timewait(&mysemaphore, &ts) != 0)
        {
            if (errno == ETIMEOUT)
            {
                std::cout << "ETIMEOUT" << std::endl;
            }
            continue;
        }
        
        if (tasks.empty())
            continue;
        
        pthread_mutex_lock(&mymutex);    
        pTask = tasks.front();
        tasks.pop_front();
        pthread_mutex_unlock(&mymutex);
        
        pTask->doTask();
        delete pTask;
    }
    
    return NULL;
}

void* producer_thread(void* param)
{
    int taskID = 0;
    Task* pTask = NULL;
    
    while (true)
    {
        pTask = new Task(taskID);
            
        pthread_mutex_lock(&mymutex);
        tasks.push_back(pTask);
        std::cout << "produce a task, taskID: " << taskID << ", threadID: " << pthread_self() << std::endl; 
        
        pthread_mutex_unlock(&mymutex);
        
        //释放信号量,通知消费者线程
        sem_post(&mysemaphore);
        
        taskID ++;

        //休眠1秒
        sleep(1);
    }
    
    return NULL;
}

int main()
{
    pthread_mutex_init(&mymutex, NULL);
    //初始信号量资源计数为0
    sem_init(&mysemaphore, 0, 0);

    //创建5个消费者线程
    pthread_t consumerThreadID[5];
    for (int i = 0; i < 5; ++i)
    {
        pthread_create(&consumerThreadID[i], NULL, consumer_thread, NULL);
    }
    
    //创建一个生产者线程
    pthread_t producerThreadID;
    pthread_create(&producerThreadID, NULL, producer_thread, NULL);

    pthread_join(producerThreadID, NULL);
    
    for (int i = 0; i < 5; ++i)
    {
        pthread_join(consumerThreadID[i], NULL);
    }
    
    sem_destroy(&mysemaphore);
    pthread_mutex_destroy(&mymutex);

    return 0;
}

说明几点:

  • 信号量和锁一样,全局的
  • sem_post和sem_wait是P(), V()操作的具体实现,即计数+1,-1

条件变量实现

关于条件变量(cv):深入解析条件变量(condition variables)
条件变量同锁一起使用使得线程可以以一种无竞争的方式等待任意条件的发生。所谓无竞争就是,条件改变这个信号会发送到所有等待这个信号的线程。

示例:

#include <pthread.h>
#include <errno.h>
#include <unistd.h>
#include <list>
#include <semaphore.h>
#include <iostream>

class Task
{
public:
    Task(int taskID)
    {
        this->taskID = taskID;
    }
    
    void doTask()
    {
        std::cout << "handle a task, taskID: " << taskID << ", threadID: " << pthread_self() << std::endl; 
    }
    
private:
    int taskID;
};

pthread_mutex_t  mymutex;
std::list<Task*> tasks;
pthread_cond_t   mycv;

void* consumer_thread(void* param)
{    
    Task* pTask = NULL;
    while (true)
    {
        pthread_mutex_lock(&mymutex);
        while (tasks.empty())
        {                
            //如果获得了互斥锁,但是条件不合适的话,pthread_cond_wait会释放锁,不往下执行。
            //当发生变化后,条件合适,pthread_cond_wait将直接获得锁。
            pthread_cond_wait(&mycv, &mymutex);
        }
        
        pTask = tasks.front();
        tasks.pop_front();

        pthread_mutex_unlock(&mymutex);
        
        if (pTask == NULL)
            continue;

        pTask->doTask();
        delete pTask;
        pTask = NULL;        
    }
    
    return NULL;
}

void* producer_thread(void* param)
{
    int taskID = 0;
    Task* pTask = NULL;
    
    while (true)
    {
        pTask = new Task(taskID);
            
        pthread_mutex_lock(&mymutex);
        tasks.push_back(pTask);
        std::cout << "produce a task, taskID: " << taskID << ", threadID: " << pthread_self() << std::endl; 
        
        pthread_mutex_unlock(&mymutex);
        
        //释放条件信号,通知消费者线程
        pthread_cond_signal(&mycv);
        
        taskID ++;

        //休眠1秒
        sleep(1);
    }
    
    return NULL;
}

int main()
{
    pthread_mutex_init(&mymutex, NULL);
    pthread_cond_init(&mycv, NULL);

    //创建5个消费者线程
    pthread_t consumerThreadID[5];
    for (int i = 0; i < 5; ++i)
    {
        pthread_create(&consumerThreadID[i], NULL, consumer_thread, NULL);
    }
    
    //创建一个生产者线程
    pthread_t producerThreadID;
    pthread_create(&producerThreadID, NULL, producer_thread, NULL);

    pthread_join(producerThreadID, NULL);
    
    for (int i = 0; i < 5; ++i)
    {
        pthread_join(consumerThreadID[i], NULL);
    }
    
    pthread_cond_destroy(&mycv);
    pthread_mutex_destroy(&mymutex);

    return 0;
}

读写锁实现

关于读写锁,参考:Linux:使用读写锁使线程同步

示例:

#include <pthread.h>
#include <unistd.h>
#include <iostream>

int resourceID = 0;
pthread_rwlock_t myrwlock;

void* read_thread(void* param)
{    
    while (true)
    {
        //请求读锁
        pthread_rwlock_rdlock(&myrwlock);

        std::cout << "read thread ID: " << pthread_self() << ", resourceID: " << resourceID << std::endl;
                
        //使用睡眠模拟读线程读的过程消耗了很久的时间
        sleep(1);
                
        pthread_rwlock_unlock(&myrwlock);
    }
    
    return NULL;
}

void* write_thread(void* param)
{
    while (true)
    {
        //请求写锁
        pthread_rwlock_wrlock(&myrwlock);

        ++resourceID;
        std::cout << "write thread ID: " << pthread_self() << ", resourceID: " << resourceID << std::endl;
                
        //使用睡眠模拟读线程读的过程消耗了很久的时间
        sleep(1);
                
        pthread_rwlock_unlock(&myrwlock);
    }
    
    return NULL;
}

int main()
{
    pthread_rwlock_init(&myrwlock, NULL);

    //创建5个请求读锁线程
    pthread_t readThreadID[5];
    for (int i = 0; i < 5; ++i)
    {
        pthread_create(&readThreadID[i], NULL, read_thread, NULL);
    }
    
    //创建一个请求写锁线程
    pthread_t writeThreadID;
    pthread_create(&writeThreadID, NULL, write_thread, NULL);

    pthread_join(writeThreadID, NULL);
    
    for (int i = 0; i < 5; ++i)
    {
        pthread_join(readThreadID[i], NULL);
    }
    
    pthread_rwlock_destroy(&myrwlock);

    return 0;
}

背景

多线程概述:应用层的多线程的目的就是让每一个任务(例如一系列函数调用)都认为自己独占CPU资源,即宏观上,多个任务可以同时执行(实际可能是轮转的串行执行)。
代码实现:线程库可以由编程语言的标准库或者操作系统的库实现,具体包含的头文件如下:

  • C/C++ : < thread >
  • POSIX(Portable Operating System Interface of UNIX, Linux环境使用较多) :< pthread.h >
  • Windows OS : < windows.h >

具体环境使用哪个库,有不同的观点,参考
c++多线程编程主要用pthread还是c++11中的thread类?
即使是同一环境,也有不同封装层次的API
CreateThread()与_beginthread()的区别详细解析

主线程与工作线程:
一般应用程序都有主要的执行流程,例如C/C++的main入口函数,主要执行流程是在进程中执行的,也可以认为main是线程,独占了进程的全部资源,称为主线程。如果在该进程执行时,创建多个线程,用于并行处理其他任务,称为工作线程。

本文讲不同风格的线程创建\销毁,和访问共享数据的锁操作
本系列源码:cursorhu/SimpleMultiThread

Windows风格多线程

(1)双线程打印

#include <iostream>   
#include <windows.h>   
using namespace std;
 
DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;
    for (int i = 0; i < 10; i++)
        cout << s << endl;
    return 0;
}
 
int main()
{
    std::string s1 = "Work thread";
    std::string s2 = "Main thread";
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    CloseHandle(hThread);
    
    return 0;
}

主线程和工作线程都运行Print(),各线程的栈空间保存自己的局部数据。
windows API使用CreateThread和CloseHandle创建线程、释放线程句柄,说明如下

HANDLE CreateThread(
    LPSECURITY_ATTRIBUTES lpThreadAttributes,//SD:线程安全相关的属性,常置为NULL
    SIZE_T dwStackSize,//initialstacksize:新线程的初始化栈的大小,可设置为0
    LPTHREAD_START_ROUTINE lpStartAddress,//threadfunction:被线程执行的回调函数,也称为线程函数
    LPVOID lpParameter,//threadargument:传入线程函数的参数,不需传递参数时为NULL
    DWORD dwCreationFlags,//creationoption:控制线程创建的标志
    LPDWORD lpThreadId//threadidentifier:传出参数,用于获得线程ID,如果为NULL则不返回线程ID
    )
 
/*
lpThreadAttributes:指向SECURITY_ATTRIBUTES结构的指针,决定返回的句柄是否可被子进程继承,如果为NULL则表示返回的句柄不能被子进程继承。
dwStackSize:设置初始栈的大小,以字节为单位,如果为0,那么默认将使用与调用该函数的线程相同的栈空间大小。
任何情况下,Windows根据需要动态延长堆栈的大小。
lpStartAddress:指向线程函数的指针,函数名称没有限制,但是必须以下列形式声明:
DWORD WINAPI 函数名 (LPVOID lpParam) ,格式不正确将无法调用成功。
lpParameter:向线程函数传递的参数,是一个指向结构的指针,不需传递参数时,为NULL。
dwCreationFlags:控制线程创建的标志,可取值如下:
(1)CREATE_SUSPENDED(0x00000004):创建一个挂起的线程(就绪状态),直到线程被唤醒时才调用
(2)0:表示创建后立即激活。
(3)STACK_SIZE_PARAM_IS_A_RESERVATION(0x00010000):dwStackSize参数指定初始的保留堆栈的大小,
如果STACK_SIZE_PARAM_IS_A_RESERVATION标志未指定,dwStackSize将会设为系统预留的值
lpThreadId:保存新线程的id
返回值:函数成功,返回线程句柄,否则返回NULL。如果线程创建失败,可通过GetLastError函数获得错误信息。
*/
 
BOOL WINAPI CloseHandle(HANDLE hObject);        //关闭一个被打开的对象句柄
/*可用这个函数关闭创建的线程句柄,如果函数执行成功则返回true(非0),如果失败则返回false(0),
如果执行失败可调用GetLastError.函数获得错误信息。
*/

LPVOID 与 std::string类型的转换,需要用char*类型作中介,LPVOID接受buffer数组类型的转换
注意CloseHandle只是释放句柄资源,线程的资源释放是其函数执行完毕自动销毁的。

2次的运行结果

image-20221205152628474

可见,两个线程是随机切换的,导致如下现象:

  • Print()内的cout << s<<endl之间线程被切换,导致没有换行+双重换行。
  • 存在工作线程没执行完,主线程就执行完导致main return,整个进程销毁的情况。

改进如下:

#include <iostream>   
#include <windows.h>   
using namespace std;
 
DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;
    for (int i = 0; i < 10; i++)
        cout << s;
    return 0;
}
 
int main()
{
    std::string s1 = "Work thread\n";
    std::string s2 = "Main thread\n";
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    CloseHandle(hThread);
    Sleep(100);
    return 0;
}

使用以下方法解决上述问题

  • 主线程完成Print后,休眠100s,这个时间足够工作线程完成,Sleep结束后,main进程执行完毕

  • 把换行放到字符串中,使该字符串的完整打印成为不可被中途切换的操作,即原子操作

输出如下:
image-20221205152645608

如果Print有很多句打印,又不希望中途切换线程,如何做?

  • 互斥锁可以实现“大块代码的原子操作”
  • 锁是全局变量,因为主线程main和工作线程Print都能看到全局变量,而看不到对方的局部变量

代码如下:

#include <iostream>   
#include <windows.h>   
using namespace std;
 
HANDLE hMutex = NULL;//互斥锁的句柄

DWORD WINAPI Print(LPVOID lpParamter)
{
    std::string s = (char*)lpParamter;

    for (int i = 0; i < 10; i++)
    {
        WaitForSingleObject(hMutex, INFINITE);//请求锁
        cout << s << endl;
        ReleaseMutex(hMutex);//释放锁
    }

    return 0;
}
 
int main()
{
    std::string s1 = "Work thread";
    std::string s2 = "Main thread";

    hMutex = CreateMutex(NULL, FALSE, NULL); //创建互斥锁
    HANDLE hThread = CreateThread(NULL, 0, Print, (LPVOID)s1.c_str(), 0, NULL);
    Print((LPVOID)s2.c_str());
    
    CloseHandle(hThread);
    CloseHandle(hMutex);//销毁互斥锁
    
    return 0;
}

运行结果:
image-20221205152654439
关于windows的互斥锁:

互斥量:
        采用互斥对象机制。互斥锁,像一个物件,这个物件只能同时被一个线程持有。 只有拥有互斥对象的线程才有访问公共资源的权限,因为互斥对象只有一个,所以能保证公共资源不会同时被多个线程访问。互斥不仅能实现同一应用程序的公共资源安全共享,还能实现不同应用程序的公共资源安全共享。
        一、创建 创建互斥锁的方法是调用函数CreateMutex: CreateMutex(&sa, bInitialOwner, szName);第一个参数是一个指向SECURITY_ATTRIBUTES结构体的指针,一般的情况下,可以是nullptr。 第二个参数类型为BOOL,表示互斥锁创建出来后是否被当前线程持有。 第三个参数类型为字符串(const TCHAR*),是这个互斥锁的名字,如果是nullptr,则互斥锁是匿名的。 例: HANDLE hMutex = CreateMutex(nullptr, FALSE, nullptr);上面的代码创建了一个匿名的互斥锁,创建出来后,当前线程不持有这个互斥锁。

         二、持有 WaitForSingleObject函数可以让一个线程持有互斥锁。用法: WaitForSingleObject(hMutex, dwTimeout);这个函数的作用比较多。这里只介绍第一个参数为互斥锁句柄时的作用。 它的作用是等待,直到一定时间之后,或者,其他线程均不持有hMutex。第二个参数是等待的时间(单位:毫秒),如果该参数为INFINITE,则该函数会一直等待下去。

        三、释放 用ReleaseMutex函数可以让当前线程“放开”一个互斥锁(不持有它了),以便让其他线程可以持有它。用法 ReleaseMutex(hMutex)

         四、销毁 当程序不再需要互斥锁时,要销毁它。 CloseHandle(hMutex)

         五、命名互斥锁 如果CreateMutex函数的第三个参数传入一个字符串,那么所创建的锁就是命名的。当一个命名的锁被创建出来以后,当前进程和其他进程如果试图创建相同名字的锁,CreateMutex会返回原来那把锁的句柄,并且GetLastError函数会返回ERROR_ALREADY_EXISTS。这个特点可以使一个程序在同一时刻最多运行一个实例

C++风格多线程

双线程分别实现计算和打印

#include <thread>
#include <chrono>
#include <mutex>
#include <iostream>

int g_mydata = 1;
std::mutex g_mutex;

void thread_func1()
{
    while (g_mydata < INT_MAX)
    {
        g_mutex.lock();
        ++g_mydata;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

void thread_func2()
{
    while (g_mydata < INT_MAX)
    {
        g_mutex.lock();
        std::cout << "g_mydata = " << g_mydata << ", ThreadID = " << std::this_thread::get_id() << std::endl;
        g_mutex.unlock();
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
}

int main()
{
    std::thread t1(thread_func1);
    std::thread t2(thread_func2);

    t1.join();
    t2.join();

    return 0;
}

几点说明:

  • C++使用< thread >调用线程库
  • std::thread t(thread_func)创建一个thread对象,传入参数为thread_fun,即线程内执行的函数
  • t.join()的含义是,线程t执行完毕后,join函数才能返回,主线程才能继续向后执行,宏观上就是,主线程被t线程阻塞在join函数处,这也许就是join的含义,t线程“加入”主线程的队伍,主线程必须原地等待t准备好了(执行完了)才能继续向后走。
  • 由于全局数据g_mydata和打印语句都不是原子操作,要保证完整操作,需要加锁,库定义在< mutex >
  • 为什么要sleep? 注意两个工作线程都while循环操作,sleep是手动使当前线程休眠,操作系统会轮换到其他active状态的线程执行,如果不sleep, 一个线程一直执行再被OS切换,间隔可能很久。< chrono >库用于时间
  • INT_MAX是C++定义的int类最大值,2^31-1

运行结果:
image-20221205152705047

POSIX/Linux风格

逻辑同上节,代码如下

#include <pthread.h>
#include <iostream>
#include <unistd.h>
#include <limits.h> //for INT_MAX

int g_mydata = 1;
pthread_mutex_t m;

void* thread_function1(void* args)
{
    while (g_mydata < INT_MAX)
    {
        pthread_mutex_lock(&m);
        ++g_mydata;
        pthread_mutex_unlock(&m);
        sleep(1);
    }
    
    return NULL;
} 

void* thread_function2(void* args)
{
    while (g_mydata < INT_MAX)
    {    
        pthread_mutex_lock(&m);
        std::cout << "g_mydata = " << g_mydata << ", ThreadID: " << pthread_self() << std::endl;
        pthread_mutex_unlock(&m);
        sleep(1);
    }
    
    return NULL;
} 

int main()
{
    pthread_mutex_init(&m, NULL);
    
    pthread_t threadIDs[2];    
    pthread_create(&threadIDs[0], NULL, thread_function1, NULL);
    pthread_create(&threadIDs[1], NULL, thread_function2, NULL);
    
    for(int i = 0; i < 2; ++i)
    {
        pthread_join(threadIDs[i], NULL);
    }
    
    pthread_mutex_destroy(&m);

    return 0;
}

win32应用程序使用pthread,需要配置pthread dll库,下载地址和配置方法:
pthreads-win32
VS2013 配置pthread

pthread的几个锁,参考:
linux线程互斥量pthread_mutex_t使用简介

最近在ChromeOS上做一些shell script测试用例开发,ChromeOS基于Debian9,但没有Ubuntu那种GNOME的gedit编辑器,更不谈安装Linux版VSCode,正好借此机会练习一下之前一直不熟悉的vim编辑器。

ChromeOS不方便截图,所以本文以ubuntu上的linux0.11代码为例,整理vim最常用的操作。

关于Linux上的文本编辑器基础概念,可以参考<Linux命令行与shell脚本编程大全.第3版>

1. 三种编辑模式

我将vim归为三种编辑模式:

  • 文本编辑模式
    文本编辑模式是默认模式,vim编辑器会将按键解释成命令。在任意模式按esc进入此默认模式。

  • 文本插入模式
    文本插入模式, vim会将你在当前光标位置输入的每个键都插入到缓冲区,即文本输入字符。在普通模式下按下”i 键” 进入(含义:insert)

  • 命令行模式
    命令行模式和shell命令行类似,在普通模式下按下”: 键”进入(形似shell terminal的冒号)

怎么知道当前处于哪种模式?
vim左下角是状态行,以下是三种模式的状态示例:

  • vim init/main.c默认进入文本编辑模式,下面显示文件名和行号

输入i, 进入文本插入模式,下面显示insert状态

按esc退出文本编辑,再输入: 进入命令行模式,例如输入:wq保存文件

还有一种visual模式是复制粘贴时会用到:

下面讲文本编辑模式和命令行模式的常用命令
主要分为几类场景:

  • 光标移动
  • 增删改查
  • 文件保存

光标移动类:

操作 作用
gg 移到第一行 (gg重来)
G 移到最后一行 (记为大G)
PageUp/PageDown 翻页
:行号 光标移动到指定行(属于命令行模式)

增删改查类:

操作 作用
i 进入insert模式,在当前光标的左侧输入
a 追加文本(append),在当前光标的右侧输入
o 插入空行,在空行光标处可输入
dd 删除当前行 (记为双击delete)
dw 删除当前词(记为delete word)
delete键,或x键 删除当前字符,注意,Backspace在vim没有删除的作用!
v+方向键选中+y 复制选中的文本,v: visual,可视光标选中的文本范围, y: yank 复制
yw 复制当前词
yy 复制当前行
p 在复制之后,粘贴文本(paste),注意粘贴内容来自vim缓冲区,而不是外部剪切板的
dw/dd + p 剪切,d操作删除的文本位于缓冲区,可以直接用p粘贴
/字符串 (当前文件内)查找字符串,按n查找下一个
:s/old/new/g (当前文件内)全局查找和替换
u 撤销上一步

文件保存类:

操作 作用
:q! 不保存文件退出
:wq 保存文件退出

2.多文件编辑

下面讲多个文本的常用命令
主要分为几类场景:

  • 多文本搜索
  • 多文件编辑

多文本搜索类:
参考# Vim Search and Replace With Examples
本文只以quickfix方式为例:

操作 作用
:vimgrep pattern ** 搜索当前目录和子目录的包含指定pattern的文件,vimgrep可缩写为vim, ** 表示递归子目录
:vimgrep pattern **/*.c 同上,只搜索.c文件
:copen 搜索完后使用此命令打开文件列表,才能用光标选择
:cn (cnext) 和 :cp (cprev) 上下选择搜索文件列表

示例:搜索linux0.11下的所有包含main的.c文件

quickfix list即文件列表,copen后可方向键选择打开文件

  • 多文件编辑
    打开多个文件,分隔并列显示
  1. 用vim打开文件后,命令行输入:vs newfile,竖排并列打开新文件(vs是vertical split缩写,竖排分隔)
  2. 特殊用法::vs ./可以打开当前路径下的所有文件列表
  3. 在窗口间切换:ctrl + ww
  4. 关闭文件只需要先切换到窗口再:q!
  5. 调整竖排的窗口比例:
    先按ctrl+w选择窗口模式,再按<>+-调整。< 左移,> 右移,+ 上移, - 下移。

示例:实现类似IDE的界面,左侧是文件列表,下侧是查找栏,右侧文件内容

打开多个文件,不并列显示
直接:open file打开新文件, 用 :bn 和 :bN (buffer next)切换文件,

多文件之间复制粘贴
vim的多个文件直接可以直接用 y + p 命令复制粘贴,因为共用vim环境的缓冲区

退出所有文件
:qall!:wqall

3.类似IDE的跳转功能

推荐cscope插件,具体参考## The Vim/Cscope tutorial

关键步骤:

  • 建立cscope.vim
    http://cscope.sourceforge.net/cscope_maps.vim 另存到文件~/.vim/plugin/cscope_maps.vim
  • 源码目录建立cscope.out
    cscope -R 建立符号索引,ctrl+D 退出
  • 打开某符号的代码
    例如 vim -t main 打开main所在文件
  • 查找函数的定义和调用
    如果光标已经在函数上,用 “ctrl +\“ 再输入s,查找所有调用、定义该函数的列表,输入索引号回车
    更推荐用cscope的命令行,:cs f s 函数名 是一样的结果,且光标不需要位于函数上。参数含义 f: find, s: symbol
  • 跳转回之前的位置
    ctrl + t

4.vim配置文件修改配色,行号

在有的Linux服务器上,Vim默认深蓝色亮瞎眼,修改配色为流行的Molokai.

效果对比:

默认配色看不清注释内容
image-20221206143528332
Molokai配色
image-20221206143701354

配置过程:

默认的配色方案:

ls /usr/share/vim/vim74/colors

下载molokai配色文件,拷贝到vim配色文件目录

cd ~
git clone git@github.com:tomasr/molokai.git
cd molokai/colors
cp molokai.vim /usr/share/vim/vim74/colors

在home下创建.vimrc用于配色详细设置

cd ~
vim .vimrc

.vimrc设置如下:

  set t_Co=256
  set background=dark
  set ts=4
  set nu!
  syntax on
  colorscheme molokai

:wq保存后即生效
如果要全局用户通用,vim /etc/vimrc

前言

MFC(Microsoft Foundation Classes)是微软在win32 API上,用C++封装的GUI框架,在现在,MFC相比其他的GUI框架有些过时,可以参考:
很多人说 C++ 的 MFC 已经过时了,那新入门的人到底应该学什么?
不同环境的选择:

  • 跨平台: QT
  • C#: WPF
  • Web:React,Vue,Electron

既然如此,为何本文用MFC?
1.部分功能从老MFC项目移植,且VS环境能快速上手
2.技术本身不会过时,过时的是应用场景,GUI回调式的交互机制,以及Win32线程和进程的使用都是通用的技术。这是写本文的原因

本文源码:cursorhu/myMFCForAutoRWTest

GUI界面:
1

初识MFC项目

VS新建MFC项目,例如“myMFC”,目录结构如下
2
myMFC.cpp是VS自动创建的MFC项目入口,其主要功能是:创建一个窗口实例,注册会话对象(Dialog)
界面的交互一定是分层的

  • 对用户的是控件层,即各种按钮,输入输出框等可见可操作的东西
  • 处理数据的是逻辑层,例如从输入框输入,底层保存该字符串,点击运行,底层开始执行对应函数

在MFC中,会话对象就是处理底层逻辑的类对象,其方法定义在myMFCDlg.cpp
也是开发的主要内容

MFC入口

下面介绍myMFC.cpp的MFC入口:

BOOL CmyMFCApp::InitInstance()
{
    // 如果一个运行在 Windows XP 上的应用程序清单指定要
    // 使用 ComCtl32.dll 版本 6 或更高版本来启用可视化方式,
    //则需要 InitCommonControlsEx()。  否则,将无法创建窗口。
    INITCOMMONCONTROLSEX InitCtrls;
    InitCtrls.dwSize = sizeof(InitCtrls);
    // 将它设置为包括所有要在应用程序中使用的
    // 公共控件类。
    InitCtrls.dwICC = ICC_WIN95_CLASSES;
    InitCommonControlsEx(&InitCtrls);

    CWinApp::InitInstance();
    
    AfxEnableControlContainer();

    // 创建 shell 管理器,以防对话框包含
    // 任何 shell 树视图控件或 shell 列表视图控件。
    CShellManager *pShellManager = new CShellManager;

    // 激活“Windows Native”视觉管理器,以便在 MFC 控件中启用主题
    CMFCVisualManager::SetDefaultManager(RUNTIME_CLASS(CMFCVisualManagerWindows));

    // 标准初始化
    // 如果未使用这些功能并希望减小
    // 最终可执行文件的大小,则应移除下列
    // 不需要的特定初始化例程
    // 更改用于存储设置的注册表项
    // TODO: 应适当修改该字符串,
    // 例如修改为公司或组织名
    SetRegistryKey(_T("应用程序向导生成的本地应用程序"));

    CmyMFCDlg dlg;
    m_pMainWnd = &dlg;
    INT_PTR nResponse = dlg.DoModal();
    if (nResponse == IDOK)
    {
        // TODO: 在此放置处理何时用
        //  “确定”来关闭对话框的代码
    }
    else if (nResponse == IDCANCEL)
    {
        // TODO: 在此放置处理何时用
        //  “取消”来关闭对话框的代码
    }
    else if (nResponse == -1)
    {
        TRACE(traceAppMsg, 0, "警告: 对话框创建失败,应用程序将意外终止。\n");
        TRACE(traceAppMsg, 0, "警告: 如果您在对话框上使用 MFC 控件,则无法 #define _AFX_NO_MFC_CONTROLS_IN_DIALOGS。\n");
    }

    // 删除上面创建的 shell 管理器。
    if (pShellManager != nullptr)
    {
        delete pShellManager;
    }

#if !defined(_AFXDLL) && !defined(_AFX_NO_MFC_CONTROLS_IN_DIALOGS)
    ControlBarCleanUp();
#endif

    return FALSE;
}

只需要关注这几句

CmyMFCDlg dlg;
m_pMainWnd = &dlg;
INT_PTR nResponse = dlg.DoModal();

CmyMFCDlg类是在myMFCDlg.cpp定义的,即底层逻辑类。m_pMainWnd是myMFC.cpp的CmyMFCApp类(继承win32 API)的成员,表示主窗口,这两句就是把会话对象注册到窗口类,这样窗口运行时可以回调会话对象的方法。dlg.DoModal()是运行会话窗口,运行哪个会话?其调用者CmyMFCDlg类对象dlg。

MFC逻辑层

VS自动创建myMFC项目的会话逻辑层,myMFCDlg.cpp
几个自动生成的方法如下,这里为了作为示例,加了自定义的类成员m_src, m_dst和方法OnBnClickedButtonsrc,OnBnClickedButtondst
(1)会话类构造函数

CmyMFCDlg::CmyMFCDlg(CWnd* pParent /*=nullptr*/)
    : CDialogEx(IDD_MYMFC_DIALOG, pParent)
    , m_src(_T("")) //初始化为空串,_T是兼容不同编码的转换
    , m_dst(_T(""))
{
    m_hIcon = AfxGetApp()->LoadIcon(IDR_MAINFRAME);
}

(2)界面和类成员数据关联

void CmyMFCDlg::DoDataExchange(CDataExchange* pDX)
{
    CDialogEx::DoDataExchange(pDX);
    DDX_Text(pDX, IDC_EDIT_src, m_src); //关联m_src和IDC_EDIT_src控件,该控件是界面输入框
    DDX_Text(pDX, IDC_EDIT_dst, m_dst);
}

(3)界面和类方法的关联

BEGIN_MESSAGE_MAP(CmyMFCDlg, CDialogEx)
    ON_WM_SYSCOMMAND()
    ON_WM_PAINT()
    ON_WM_QUERYDRAGICON()
    ON_BN_CLICKED(IDC_BUTTON_src, &CmyMFCDlg::OnBnClickedButtonsrc) //关联IDC_BUTTON_src按钮和OnBnClickedButtonsrc方法
    ON_BN_CLICKED(IDC_BUTTON_dst, &CmyMFCDlg::OnBnClickedButtondst)
END_MESSAGE_MAP()

类在头文件的定义:

class CmyMFCDlg : public CDialogEx
{
// 构造
public:
    CmyMFCDlg(CWnd* pParent = nullptr);    // 标准构造函数

// 对话框数据
#ifdef AFX_DESIGN_TIME
    enum { IDD = IDD_MYMFC_DIALOG };
#endif

    protected:
    virtual void DoDataExchange(CDataExchange* pDX);    // DDX/DDV 支持

// 实现
protected:
    HICON m_hIcon;

    // 生成的消息映射函数
    virtual BOOL OnInitDialog();
    afx_msg void OnSysCommand(UINT nID, LPARAM lParam);
    afx_msg void OnPaint();
    afx_msg HCURSOR OnQueryDragIcon();
    DECLARE_MESSAGE_MAP()
    
public:
    CString m_src; //CString: MFC的字符串类型
    CString m_dst;
    afx_msg void OnBnClickedButtonsrc(); //afx_msg: MFC的方法对应的消息响应类型
    afx_msg void OnBnClickedButtondst();
};

在VS环境下,这些变量和方法的定义都不需要写代码,在控件资源视图直接配置即可。

界面资源层

注意项目文件有个Resource.h,包含界面相关的资源,如每个按钮有个ID,这个不要手动配置,在编辑UI控件时自动生成

//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ 生成的包含文件。
// 供 myMFC.rc 使用
//
#define IDM_ABOUTBOX                    0x0010
#define IDD_ABOUTBOX                    100
#define IDS_ABOUTBOX                    101
#define IDD_MYMFC_DIALOG                102
#define IDR_MAINFRAME                   128
#define IDC_BUTTON_src                  1000
#define IDC_BUTTON_dst                  1001

myMFC.rc是UI的资源文件,打开就是UI界面
3
4
可以看到界面的按钮,右键查看属性,可以修改标题和控件ID,会映射到Resource.h。双击按钮,myMFCDlg.cpp会自动创建方法CmyMFCDlg::OnBnClickedButtondst(),头文件自动加方法声明。
5

前文的Dlg.cpp中的控件ID, dlg类的方法,变量,从一开始就可以从资源界面配置,自动生成:

  • 在资源界面选按钮或其他控件
  • 右键配置控件ID
  • 右键添加值变量或控件变量
  • 双击添加方法

6
关于值变量和控件变量:
值变量用于关联界面和类成员,值变量就是类成员名,例如点击dst按钮调用其方法后,获得的路径,会写入m_dst值变量
7
控件变量代表控件本身,用于底层逻辑中,直接调用控件的方法,例如控件变量叫dst_ctrl,可以在某个方法中ctrl_dst.SetWindowText(_T(""))清空界面的字符串
8

简单拷贝校验的实现

实现从src目录拷贝所有文件到dst目录,并比较拷贝前后的文件是否一致

获取文件路径

两个路径选择按钮和对应的编辑框显示路径,一个Start按钮
9

button src的方法:

void CmyMFCDlg::OnBnClickedButtonsrc()
{
    CString SrcPath;
    SrcPath = GetFolderPath(); //获取文件夹路径
    ctrl_src.SetWindowText(SrcPath); //显示获取的路径字符串
    m_src = SrcPath; //保存路径到会话对象的变量
}

GetFolderPath打开一个目录框,让用户选择:
SHBrowseForFolder是win32 API,专用于打开目录

CString CmyMFCDlg::GetFolderPath(void)
{
    CString strPath;
    BROWSEINFO bInfo;
    ZeroMemory(&bInfo, sizeof(bInfo));
    bInfo.hwndOwner = m_hWnd;
    bInfo.lpszTitle = _T("Select Folder: ");
    bInfo.ulFlags = BIF_RETURNONLYFSDIRS;

    LPITEMIDLIST lpDlist;                    
    lpDlist = SHBrowseForFolder(&bInfo); //win32 API, 打开目录    
    if (lpDlist != NULL)                        
    {
        TCHAR chPath[255];                    
        SHGetPathFromIDList(lpDlist, chPath);
        strPath = chPath;                    
    }
    return strPath;
}

如果是打开文件,用CFileDialog

CString CmyMFCDlg::GetFilePath(void)
{
    CFileDialog mFileDlg(TRUE, NULL, NULL,
        OFN_HIDEREADONLY | OFN_OVERWRITEPROMPT | OFN_ALLOWMULTISELECT | OFN_NOCHANGEDIR,
        _T("All Files(*.*)|*.*||"), AfxGetMainWnd());
    CString str(" ", 10000);
    mFileDlg.m_ofn.lpstrFile = str.GetBuffer(10000);
    mFileDlg.m_ofn.lpstrTitle = _T("Select File");
    str.ReleaseBuffer();
    mFileDlg.DoModal();
    POSITION mPos = mFileDlg.GetStartPosition();
    CFileStatus status;
    CString strPath;
    while (mPos != NULL)
    {
        strPath = mFileDlg.GetNextPathName(mPos);
        CFile::GetStatus(strPath, status);
    }
    return strPath;
}

不管哪一种,效果如下
10
选择完后,路径会在编辑框显示,这就是控件语句ctrl_src.SetWindowText(SrcPath)的效果
11

拷贝和比较

拷贝函数如下,只需关注几个函数:

  • CFileFind类的CFileFind(), FindNextFile(), GetFilePath(), GetFilePath(),这些都是afx.h定义,属于MFC库的类
  • CopyFile(), 执行拷贝,这个也是继承自MFC类

代码:

BOOL CmyMFCDlg::ModeTestCopyFileFromSRCtoDST(CString SRC, CString DST, CString& StrResult)
{

    CFileFind ff, ff_DST;
    CString SRCDir = SRC;                 //source folder path
    CString DSTDir = DST;
    UINT copyFileResult = 0;
    int i = 0;

    BOOL bmakedir = MakeDirectory(DSTDir);

    if (SRCDir.Right(1) != _T("\\"))
        SRCDir += _T("\\");
    SRCDir += _T("*.*");

    if (DSTDir.Right(1) != _T("\\"))
        DSTDir += _T("\\");


​ SetLastError(0);
​ CString DST_tmp = DSTDir + _T(“.“);
​ BOOL res_DST = ff_DST.FindFile(DST_tmp);
​ if (res_DST == 0)
​ {
​ StrResult.Format(_T(“Access DST folder error, error code is %d. “), GetLastError());
​ }
​ BOOL res = ff.FindFile(SRCDir);

​ while (res)
​ {
​ res = ff.FindNextFile();
​ if (!ff.IsDirectory() && !ff.IsDots())
​ {
​ CString DSTFildPath;
​ CString SRCFilePath = ff.GetFilePath();
​ DSTFildPath = DSTDir + ff.GetFileName();
​ copyFileResult = CopyFile(ff.GetFilePath(), DSTFildPath, FALSE);

Sleep(2000);

            if (copyFileResult == 0)
            {
                DWORD ErrCode = GetLastError();
                StrResult.Format(_T("CopyFile failed! The ErrCode is %d. "), ErrCode);

                for (i = 0; i < 10; i++)
                {
                    copyFileResult = CopyFile(ff.GetFilePath(), DSTFildPath, FALSE);
                    Sleep(2000);
                    if (copyFileResult == 0)
                    {
                        ErrCode = GetLastError();
                        StrResult.Format(_T("Retry CopyFile failed! The ErrCode is %d. "), ErrCode);
                    }
                    else
                    {
                        break;
                    }
                }

                if (copyFileResult == 0)
                {
                    ff.Close();
                    return FALSE;
                }
            }
        }
        else if (ff.IsDirectory() && !ff.IsDots())
        {
            CString DSTFildPath;
            DSTFildPath = DSTDir + ff.GetFileName();
            copyFileResult = ModeTestCopyFileFromSRCtoDST(ff.GetFilePath(), DSTFildPath, StrResult);
            if (copyFileResult == 0)
                break;
        }
    }

    ff.Close();
    if (copyFileResult == 0)
        return FALSE;
    else
        return TRUE;
}

比较两个路径的文件:
其方法是,文件读到buffer, 再用memcmp比较buffer, 其FindNextFile也是如何从目录搜索到文件的关键方法

BOOL CmyMFCDlg::ModeTestCompareFilesBetweenSRCandDST(CString SRC, CString DST, CString& StrResult)
{
    CFileFind ff;
    CString SRCDir = SRC;
    CString DSTDir = DST;
    BOOL bRes = TRUE;
    HANDLE hSrcFile, hDstFile;
    DWORD dwSRCFile, dwDSTFile, dwCB;

    if (SRCDir.Right(1) != _T("\\"))
        SRCDir += _T("\\");
    SRCDir += _T("*.*");

    if (DSTDir.Right(1) != _T("\\"))
        DSTDir += _T("\\");
    hSrcFile = hDstFile = NULL;
    BYTE* pSrcBuffer = new BYTE[M_BUFSIZE];
    BYTE* pDstBuffer = new BYTE[M_BUFSIZE];
    memset(pSrcBuffer, 0, M_BUFSIZE);
    memset(pSrcBuffer, 0, M_BUFSIZE);

    BOOL res = ff.FindFile(SRCDir);

    while (res)
    {
        res = ff.FindNextFile();
        if (!ff.IsDirectory() && !ff.IsDots())
        {
            CString DSTFilePath;
            DSTFilePath = DSTDir + ff.GetFileName();
            CString SRCFilePath = ff.GetFilePath();

            if (hSrcFile)
            {
                CloseHandle(hSrcFile);
                hSrcFile = NULL;
            }

            if (hDstFile)
            {
                CloseHandle(hDstFile);
                hSrcFile = NULL;
            }

            hSrcFile = CreateFile(SRCFilePath, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
            if (hSrcFile == INVALID_HANDLE_VALUE)
            {
                StrResult.Format(_T("\n Create Source file failed!! Error code = %d \n"), GetLastError());
                bRes = FALSE;
                break;
            }

            hDstFile = CreateFile(DSTFilePath, GENERIC_READ, 0, NULL, OPEN_EXISTING, 0, NULL);
            if (hDstFile == INVALID_HANDLE_VALUE)
            {
                StrResult.Format(_T("\n Create Destination file failed!! Error code = %d \n"), GetLastError());
                bRes = FALSE;
                break;
            }

            LARGE_INTEGER SrcFileSize, DstFileSize;

            dwSRCFile = GetFileSizeEx(hSrcFile, &SrcFileSize);
            dwDSTFile = GetFileSizeEx(hDstFile, &DstFileSize);

            if (SrcFileSize.LowPart != DstFileSize.LowPart)
            {
                StrResult.Format(_T("\n Compare file is different!! Src Length = %d, Dest Length = %d \n"), SrcFileSize.LowPart, DstFileSize.LowPart);
                bRes = FALSE;
                break;
            }

            while (SrcFileSize.LowPart > 0)
            {
                BOOL bCmpResult;
                bCmpResult = ReadFile(hSrcFile, pSrcBuffer, M_BUFSIZE, &dwCB, NULL);
                if (bCmpResult == 0)
                {
                    bRes = FALSE;
                    break;
                }
                bCmpResult = ReadFile(hDstFile, pDstBuffer, M_BUFSIZE, &dwCB, NULL);
                if (bCmpResult == 0)
                {
                    bRes = FALSE;
                    break;
                }
                bCmpResult = memcmp(pSrcBuffer, pDstBuffer, dwCB);

                if (bCmpResult != 0)
                {
                    bRes = FALSE;
                    CString DiffByte;

                    CString PostCmpErrorStr;
                    CString SrcDumpData, DstDumpData;
                    StrResult.Format(_T("\n Fatal_Error: Src Data from %d to %d.\n"), (DstFileSize.LowPart - SrcFileSize.LowPart), (DstFileSize.LowPart - SrcFileSize.LowPart + dwCB));

                    PostCmpErrorStr = _T("SourceFilePath: ") + SRCFilePath + _T(" To \r\n") + _T("DstFilePath: ") + DSTFilePath;
                    StrResult = PostCmpErrorStr + _T("  has compare error! \r\n");
                    //HugoPostMessageAndShowSD1(PostCmpErrorStr,1);
                    //HugoPostMessageAndShowSD2(PostCmpErrorStr,1);

                    ::MessageBox(
                        NULL,
                        (LPCWSTR)L"Compare error happened!!",
                        (LPCWSTR)L"Fatal Error!",
                        MB_OK
                    );

                    break;
                }
                SrcFileSize.LowPart -= dwCB;
            }

            if (bRes == FALSE)
                break;
            else
                ReadFile(hDstFile, pDstBuffer, 512, &dwCB, NULL);
        }
        else if (ff.IsDirectory() && !ff.IsDots())
        {
            CString DSTFildPath;
            DSTFildPath = DSTDir + ff.GetFileName();
            bRes = ModeTestCompareFilesBetweenSRCandDST(ff.GetFilePath(), DSTFildPath, StrResult);
            if (bRes == FALSE)
                break;
        }
    }

    if (hSrcFile)
    {
        CloseHandle(hSrcFile);
        hSrcFile = NULL;
    }

    if (hDstFile)
    {
        CloseHandle(hDstFile);
        hSrcFile = NULL;
    }

    if (bRes == FALSE)
    {
        delete[]pSrcBuffer;
        delete[]pDstBuffer;
        ff.Close();
        return FALSE;
    }
    else
    {
        delete[]pSrcBuffer;
        delete[]pDstBuffer;
        ff.Close();
        return TRUE;
    }
}

关于CString的格式化输出:MFC中CString.Format的详细用法
关于CFile文件操作:MFC——文件操作(CFile)

开始按钮

一般操作顺序:选择src和dst,再点击Start按钮
start按钮的方法调用已保存的m_src和m_dst路径,传入拷贝和比较,再输出结果即可,大致流程如下

void CmyMFCDlg::OnBnClickedButtonrun()
{
    //读入所有界面数据
    UpdateData(true);
    
    BOOL ret;
    CString outStr;
    
    ret = ModeTestCopyFileFromSRCtoDST(m_src, m_dst, outStr);
    if (!ret)
            MessageBox(NULL, _T(outStr), _T("ERROR"), MB_OK);
    
    ret = ModeTestCompareFilesBetweenSRCandDST(m_src, m_dst, outStr);
    if (!ret)
            MessageBox(NULL, _T(outStr), _T("ERROR"), MB_OK);
}

这里用messagebox输出结果,即弹窗,弹窗是阻塞式的。也可以用编辑框,写文件输出。
关于messagebox,参考:MessageBox function (winuser.h)
关于updateData:MFC中UpdateData()函数的使用
以上完成一个简单的文件拷贝和比较功能

多线程文件拷贝和写日志

将简单拷贝扩展,支持:

  • 多线程拷贝和比较,每个线程完成简单拷贝比较的功能
  • 在每个工作线程,输出打印到界面文本框,同时写到同一个日志文件
  • 界面主线程需要等待所有工作线程完成后,输出测试完成信息到文本框和日志

线程列表获取各自路径

add和delete配置几个工作线程,每个线程配置其src和dst路径

12

13

14

这种动态增删的列表,在资源界面新建listbox类型变量和方法:

CListBox m_rwlist;
afx_msg void OnLbnSelchangeListrwlist();

Add和Delete对应的方法:

void CmyMFCDlg::OnBnClickedButtonadd()
void CmyMFCDlg::OnBnClickedButtondelete()

Add和Delete的方法控制listbox变量m_rwlist,选中任意m_rwlist后又会调用其方法OnLbnSelchangeListrwlist,获取每个线程各自的src、dst。

按键控制m_rwlist的实现:

void CmyMFCDlg::OnBnClickedButtonadd()
{
    CString Threadtest = _T("TestThread");
    UINT ThreadCount = m_rwlist.GetCount();
    if (ThreadCount == 0)
    {
        m_rwlist.AddString(_T("TestThread1"));
    }
    else if (ThreadCount < MAX_THREAD_COUNT)
    {
        CString ThreadNum;
        ThreadNum.Format(_T("%d"), ThreadCount + 1);
        Threadtest = Threadtest + ThreadNum;
        m_rwlist.AddString(Threadtest);
    }
    else if (ThreadCount == MAX_THREAD_COUNT)
    {
        CString str;
        str.Format(_T("Only support %d threads at most!!"), MAX_THREAD_COUNT);
        MessageBox(str);
    }
    m_rwlist.SetCurSel(ThreadCount);
    if (ThreadCount < MAX_THREAD_COUNT)
        totalThreadCount++;
}

void CmyMFCDlg::OnBnClickedButtondelete()
{
    UINT ThreadCount = m_rwlist.GetCount();
    if (ThreadCount != 0)
    {
        m_rwlist.DeleteString(ThreadCount - 1);
        m_rwlist.SetCurSel(0);
    }
    if (ThreadCount > 0)
        totalThreadCount--;
}

线程列表m_rwlist的方法读取路径到会话对象成员变量:

void CmyMFCDlg::OnLbnSelchangeListrwlist()
{
    UpdateData(true); //update true: 从界面读入值到变量(使上次编辑生效)
    if (m_rwlist.GetCount() != 0)
    {
        UINT selectNum = m_rwlist.GetCurSel();
        RWTestParamArray[selectNum].ThreadNum = m_rwlist.GetCount();
        RefreshRWParam(RWTestParamArray, selectNum);
    }
}

void CmyMFCDlg::RefreshRWParam(TabDialogRWTestParam(&Array)[MAX_THREAD_COUNT], UINT CSel)
{
    ctrl_src.SetWindowText(Array[CSel].SRCFolder_Path);
    ctrl_dst.SetWindowText(Array[CSel].DSTFolder_Path);

    UpdateData(false); //update false: 把变量写入到界面(实时显示)
}

真正读入路径的是dst、src按钮的方法:

void CmyMFCDlg::OnBnClickedButtonsrc()
{
    CString SrcPath;
    UINT ThreadCSelNum = m_rwlist.GetCurSel();
    SrcPath = GetFolderPath();
    ctrl_src.SetWindowText(SrcPath);
    RWTestParamArray[ThreadCSelNum].SRCFolder_Path = SrcPath;
}

void CmyMFCDlg::OnBnClickedButtondst()
{
    CString DstPath;
    UINT ThreadCSelNum = m_rwlist.GetCurSel();
    DstPath = GetFolderPath();
    ctrl_dst.SetWindowText(DstPath);
    RWTestParamArray[ThreadCSelNum].DSTFolder_Path = DstPath;
}

线程数组定义在会话类,存储每个工作线程要用的数据

TabDialogRWTestParam RWTestParamArray[MAX_THREAD_COUNT];
typedef struct TabRWParam
{
    CString SRCFolder_Path;
    CString DSTFolder_Path;
    UINT ThreadNum;
    UINT TestTimes;
}TabDialogRWTestParam;

创建线程

创建线程参考MS文档:beginthread、_beginthreadex
关注2点:

  • 传入线程内要执行的函数,和参数(可为NULL)
  • 返回线程句柄,如果是多个线程则是个数组

创建线程的部分代码:

void CmyMFCDlg::RunModeTestInstance()
{
    ....
    
    //线程内除了对象,还需要知道自己是哪个线程,因此打包this和ThreadCount
    pTransParam ThreadTransPArray[MAX_THREAD_COUNT];

    for (int i = 0; i < totalThreadCount; i++)
    {
        ThreadTransPArray[i] = new(TransParam);
        ThreadTransPArray[i]->i = i;
        ThreadTransPArray[i]->translpParam = this;

        unsigned int rwThreadID;

        //hThread defined as global data
        hThread[i] = (HANDLE)_beginthreadex(
            NULL,
            0,
            DoThreadProc,
            ThreadTransPArray[i],
            0,
            &rwThreadID);

        if (hThread[i] == NULL)
            MessageBox(_T("CreateThread Fail!!"), MB_OK);
        
        ....
        
        //release resource
        for (int i = 0; i < totalThreadCount; i++)
        {
            delete ThreadTransPArray[i];
            ThreadTransPArray[i] = NULL;
            CloseHandle(hThread[i]);
        }
    }

由于要在线程内打印当前是哪个线程,这个从Dlg对象的this指针是获取不到的,因此把this指针和线程id打包结构体,传入DoThreadProc线程函数,结构体如下

typedef struct transParam
{
    LPVOID translpParam;
    int i;
}TransParam, *pTransParam;

#define MAX_THREAD_COUNT 6

注意使用完后释放线程句柄和其他相关资源

主线程和工作线程的通信:Message机制

先明白几点:

  • 所有工作线程都共享主线程(界面线程)的数据,即会话类对象的成员
  • 界面控件的操作函数,都是主线程独有的,工作线程不能调用
  • 主线程如果要等待工作线程,一般会阻塞

问题:
如何将工作线程的打印输出到主线程界面控件?

Windows消息机制可以解决工作线程和主线程通信问题,简单的讲,主线程有消息队列,工作线程可以发送消息到消息队列中,主线程用FIFO原则处理队列中的消息,在阻塞等待动作线程时,也支持消息队列的处理。
关于消息队列:windows消息机制(MFC)

(1)工作线程函数

unsigned int WINAPI DoThreadProc(void *threadTransParam)
{
    pTransParam pTrans = (pTransParam)threadTransParam;
    CString strResult;
    BOOL res = 0;

    CmyMFCDlg* pDlg = (CmyMFCDlg *)pTrans->translpParam;
    int thread_id = pTrans->i;

    for (int i = 0; i < (int)pDlg->rwtime; i++)
    {
        
        res = pDlg->ModeTestCopyFileFromSRCtoDST(pDlg->RWTestParamArray[thread_id].SRCFolder_Path, pDlg->RWTestParamArray[thread_id].DSTFolder_Path, strResult);

        res = pDlg->ModeTestCompareFilesBetweenSRCandDST(pDlg->RWTestParamArray[thread_id].SRCFolder_Path, pDlg->RWTestParamArray[thread_id].DSTFolder_Path, strResult);
        if (res)
        {
            criticalSec.Lock();
            ::PostMessage(pDlg->GetSafeHwnd(), WM_USER_MSG, WPARAM(thread_id + 1), LPARAM(i + 1));
            criticalSec.Unlock();
        }
    }

    return res;
}

几点说明:

  • 线程函数要用WINAPI实现,不属于会话类内的方法,因此需要this指针显式调用
  • rwtime是测试次数,每个线程执行多次拷贝比较
  • PostMessage是发布消息到主线程消息队列,可以传参:WPARAM和LPARAM
  • 由于不确定PostMessage是不是线程安全,这里加了锁:CCriticalSection类型的criticalSec

(2)消息处理函数
来看message处理函数:

LRESULT CmyMFCDlg::OnMsg(WPARAM wp, LPARAM lp)
{
    strAppend.Format(_T("Thread %d src:%s ---> des:%s, Copy&Compare Pass: test loop: %d \n"), wp, RWTestParamArray[wp-1].SRCFolder_Path, RWTestParamArray[wp-1].DSTFolder_Path, lp);
    ShowLogInEditBox(); //字符串显示到界面
    return 0;
}

主界面字符串显示函数

/* call by message handler, for multiple child thread*/
void CmyMFCDlg::ShowLogInEditBox()
{
    CString str;
    UINT i;

    /*message 队列只在主线程内处理,无需加锁*/
    //criticalSec.Lock();
        WriteLogFile(this->strAppend); //only write append str
    //criticalSec.Unlock();

    this->GetDlgItemText(IDC_EDIT_logbox, str);
    str += this->strAppend; //update old+append str
    str += "\r\n"; //这里换行没用,要在控件设置中允许换行

    this->SetDlgItemText(IDC_EDIT_logbox, str);

    i = ((CEdit*)GetDlgItem(IDC_EDIT_logbox))->GetLineCount();
    ((CEdit*)GetDlgItem(IDC_EDIT_logbox))->LineScroll(++i, 0); //定位到下一行

}

写日志的相关方法如下:

BOOL CmyMFCDlg::CreateLogFile()
{
    CString strName;
    SYSTEMTIME st;

    GetLocalTime(&st);
    strName.Format(_T("UtilityLogFile_%4d-%d-%d_%d-%d-%d.log"), st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);

    if (!m_File.Open(strName, (CFile::modeCreate | CFile::modeReadWrite), 0))
    {
        ::AfxMessageBox(_T("Create Utility Log File Error!!"));
        return FALSE;
    }

    m_logCreated = 1;
    return TRUE;
}

void CmyMFCDlg::WriteLogFile(CString str)
{
    BOOL CreateRes = TRUE;

    if (m_logCreated == 0)
        CreateRes = CreateLogFile();

    if (CreateRes)
    {
        str += _T("\r\n");
        int length = str.GetLength();
        length *= 2;
        m_File.Write(str, length);
        m_File.Flush();
    }
}

void CmyMFCDlg::CloseLogFile()
{
    if (m_logCreated == 1)
    {
        m_File.Close();
        m_logCreated = 0;
    }
}

注意message处理函数的关键点:

  • 只在主线程中处理,不存在其他线程操作,无临界区问题。因此上述的窗口输出,日志文件写入都是线程安全的。

编辑框作为输出要注意几点:

  • 换行要在设置里配置,字符串换行没用
  • 设置输出滚动显示

效果如下:
15

(3)线程同步
日志完成的输出是主线程等待所有工作线程函数返回后才执行,如何实现?
参考:WaitForMultipleObject与MsgWaitForMultipleObjects用法
我们的需求是主线程在阻塞等待时要处理消息,因此用MsgWaitForMultipleObjects方法。
代码如下:

//wait all child threads return

    /*
    //主线程阻塞,不能处理消息
    DWORD dwWaitResult = WaitForMultipleObjects(
        totalThreadCount,
        hThread,
        TRUE,
        INFINITE);
    */
    //主线程阻塞,但不阻塞消息
    int nWaitCount = totalThreadCount;
    int nExitThreadCount = 0;      //标记已经有几个线程退出了
    BOOL bWaitAll = FALSE;        //不等待所有线程完成,实时处理。如果TRUE, 会阻塞到所有线程完成
    DWORD result;
    MSG msg;

    while (TRUE)
    {
        /*该函数等待:多个线程的完成信号,或其他消息信号,有任意一种就返回
        *返回值为[WAIT_OBJECT_0, WAIT_OBJECT_0 + nWaitCount - 1]表示对应下标的线程已完成
        *返回值为WAIT_OBJECT_0 + nWaitCount表示有其他信号,如线程内发送的message
        *WAIT_OBJECT_0值为0
        */
        result = MsgWaitForMultipleObjects(nWaitCount, hThread, bWaitAll, INFINITE, QS_ALLINPUT);

        if (result == WAIT_OBJECT_0 + nWaitCount) //表示收到消息
        {
            while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) //处理所有已入队的消息
            {
                TranslateMessage(&msg); //message translat and format, add into message queue
                DispatchMessage(&msg); //call message handler
            }
        }
        else if (result >= WAIT_OBJECT_0 && result < WAIT_OBJECT_0 + nWaitCount) //表示收到了线程结束信号
        {
            nExitThreadCount++;
            if (nExitThreadCount < totalThreadCount)
            {
                /*必须更新hThread,否则已退出的线程一直被检测到*/
                int nIndex = result - WAIT_OBJECT_0; //退出线程的index
                hThread[nIndex] = hThread[nWaitCount - 1]; //更新等待列表:hThread, 交换退出的成员和尾部成员
                hThread[nWaitCount - 1] = NULL;

                nWaitCount--; //更新要等待的线程数
            }
            else
            {
                break; //等待的所有线程都已完成
            }
        }
    }

    //All threads returned

MsgWaitForMultipleObjects的MS说明文档:MsgWaitForMultipleObjects function (winuser.h)
返回值的含义是重点,这个文档说的很隐晦:
16

大意是:等待n个线程

  • 如果返回的值i是属于0~n-1,说明第i个工作线程结束了
  • 如果返回值是n,不是线程结束,而是收了到消息,例如工作线程内发送的消息。

因此代码逻辑是:
1.如果有消息,就处理消息
关于message的peek,translate和dispatch:
PeekMessage使用方法
消息循环中TranslateMessage和Dispatch函数的作用

2.如果有线程结束,要更新线程句柄数组,只保留未等待到的线程;
当所有线程都等待到,退出等待循环

以上完成了主线程和多个工作线程的同步机制

再进一步:调用其他进程

现需求如下:
有多个功能的FW需要测试,要求测试工具遍历每个FW, 调用其他的程序,更新到磁盘固件后,做之前的多进程读写比较流程
重点关注如何调用其他程序。假设FW更新程序是FirwmareUpdateTool.exe,接受FW相关的参数
需要实现:

  • 界面接收参数
  • 调用其他程序,传参,且注意与主线程的同步

代码:

BOOL CmyMFCDlg::DoUpdateFirmware(CString filename)
{
    TCHAR szFilePath[MAX_PATH + 1] = { 0 };
    GetModuleFileName(NULL, szFilePath, MAX_PATH);
    (_tcsrchr(szFilePath, _T('\\')))[1] = 0;

    CString strToolPath(szFilePath);
    strToolPath = strToolPath + _T("FirwmareUpdateTool.exe");
    CString strPath;
    strPath.Format(_T("%s %s %s %d"), strToolPath.GetBuffer(0), m_str_VendorID.GetBuffer(0), filename.GetBuffer(0), m_SlotID);
    
    strAppend = strPath;
    ShowLogInEditBox();

    if (!PathFileExists(strToolPath))
    {
        strAppend.Format(_T("The %s is not exist!"), strToolPath.GetBuffer(0));
        ShowLogInEditBox();
        MessageBox(strAppend, MB_OK);
        return FALSE;
    }

    STARTUPINFO si = { sizeof(STARTUPINFO) };//在产生子进程时,子进程的窗口相关信息
    PROCESS_INFORMATION pi;                  //子进程的ID/线程相关信息
    memset(&pi, 0, sizeof(PROCESS_INFORMATION));
    DWORD returnCode = -1;              //用于保存子程进的返回值;

    BOOL bRet = CreateProcess(              //调用失败,返回0;调用成功返回非0;
        NULL,                               //一般都是空;(另一种批处理情况:此参数指定"cmd.exe",下一个命令行参数 "/c otherBatFile")
        strPath.GetBuffer(0),              //命令行参数         
        NULL,                               //_In_opt_    LPSECURITY_ATTRIBUTES lpProcessAttributes,
        NULL,                               //_In_opt_    LPSECURITY_ATTRIBUTES lpThreadAttributes,
        FALSE,                              //_In_        BOOL                  bInheritHandles,
        CREATE_NEW_CONSOLE,                 //新的进程使用新的窗口。
        NULL,                               //_In_opt_    LPVOID                lpEnvironment,
        NULL,                               //_In_opt_    LPCTSTR               lpCurrentDirectory,
        &si,                                //_In_        LPSTARTUPINFO         lpStartupInfo,
        &pi);                               //_Out_       LPPROCESS_INFORMATION lpProcessInformation

    if (bRet)
    {
        while (TRUE) //这里也是为了输出打印和日志而等待进程,同时也阻塞了主线程
        {
            DWORD result;
            MSG msg;
            result = MsgWaitForMultipleObjects(1, &pi.hProcess, FALSE, INFINITE, QS_ALLINPUT);
            if (result == (WAIT_OBJECT_0))
            {
                //获取子进程的返回值
                GetExitCodeProcess(pi.hProcess, &returnCode);
                CloseHandle(pi.hThread);
                CloseHandle(pi.hProcess);
                break;
            }
            else
            {
                PeekMessage(&msg, NULL, 0, 0, PM_REMOVE);
                DispatchMessage(&msg);
            }
        }
        strAppend.Format(_T("%s returnCode : %d "), strToolPath.GetBuffer(0), returnCode);
        ShowLogInEditBox();
    }
    else
    {
        strAppend.Format(_T("Start the %s failed!"), strToolPath.GetBuffer(0));
        ShowLogInEditBox();
        MessageBox(strAppend, MB_OK);
    }

    if (!returnCode)
    {
        return TRUE;
    }
    return FALSE;
}

CreateProcess创建进程,执行第三方程序
MsgWaitForMultipleObjects等待第三方进程返回,阻塞了当前主进程

小结

本文涉及的知识点:

  • 界面控件与底层类的数据交互
  • MFC的文件,字符串操作
  • 线程创建和线程同步
  • 线程通信:消息机制
  • 进程创建与同步

Markdown语法

标题

#
##
### 

无序列表

- line 
或者
* line

有序列表

1
2
1. line
2. line

转义字符

有的文字或代码和markdown解析有冲突
如$, @等
在这些字符前加转义字符即可:$, @

tab缩进

markdown本身不支持tab缩进,有以下方法:
1.可以用全角输入+2个空格实现缩进
2.输入&emsp,就是全角空格符号
3.输入>

插入链接

[标题](URL)

表格

|  表头   | 表头  |
|  ----  | ----  |
| 单元格  | 单元格 |
| 单元格  | 单元格 |

Typora使用

导出和打印

有的markdown文本内容中带换行,而Typora阅读时也有换行,照成换行混乱

如果要打印,导出pdf的换行也混乱

解决办法是导出HTML(without style),然后打印