上一篇博客中,我完成了C++ IDE初步工作,包括代码的高亮、折叠、跳转以及补全等工作。但是作为IDE来说功能还有点不够,就我个人而言作为IDE来说它还需要具备一键编译运行和调试功能。这篇文章就来记录我是如何实现上述功能的
编译运行
我使用的演示项目比较简单,它的文件结构如下:
.
├── include
│ └── head.h
└── src├── add.cpp├── div.cpp├── main.cpp├── mult.cpp└── sub.cpp
它分为两个目录分别保存头文件和源文件。其中头文件只有一个定义各个接口函数,而接口函数的实现就放到各自定义的cpp文件中。这里使用加减乘除的四则运算的实现来作为演示。
这里我分别演示一下Make文件和CMake构建的项目是如何实现一键编译运行的。
Make构建的项目
针对上前面介绍的简单项目,我们可以写出如下的Makefile
# 编译器设置
# 定义项目根目录
ROOT_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
CXX := g++
CXXFLAGS := -Iinclude -Wall -Wextra -pedantic -std=c++11 -MMD -MP
LDFLAGS :=
EXE_OUTPUT := $(ROOT_DIR)bin
TARGET := $(EXE_OUTPUT)/app$(info TARGET = $(TARGET))# 源文件和对象文件设置
SRC_DIR := src
SRCS := $(wildcard $(SRC_DIR)/*.cpp)
OBJ_DIR := $(ROOT_DIR)build/obj
OBJS := $(patsubst $(SRC_DIR)/%.cpp,$(OBJ_DIR)/%.o,$(SRCS))
DEPS := $(OBJS:.o=.d)# 默认目标(第一个目标)
all: $(TARGET)# 链接生成可执行文件
$(TARGET): $(OBJS)@mkdir -p $(@D)$(CXX) $(LDFLAGS) $^ -o $@# 编译源文件并生成依赖
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.cpp | $(OBJ_DIR)$(CXX) $(CXXFLAGS) -c $< -o $@# 创建对象文件目录
$(OBJ_DIR):mkdir -p $@# 包含自动生成的依赖关系
-include $(DEPS)# 清理生成的文件
clean:rm -rf $(TARGET) build.PHONY: all clean
上面我们定义了头文件路径为include 目录,并且规定了中间文件生成在 build/obj
中,最后定义了生成可执行程序在 bin/app
中
对于编译来说,Emacs内置了 compile
命令,它会自动执行 make -k
命令,但是如果我在使用Emacs的过程中切换到了其他目录的话,还需要特别指定Makefile 所在的路径,对我来说我希望能在尽可能少输入参数的情况下完成同样的操作,不太希望每次都指定项目根目录,好在之前配置的projectile
插件帮助我们识别出来了项目的根目录。所以这里可以使用 projectile-compile-project
来自动指定根目录并编译。
从上面的截图可以看到,flycheck 提示了几个错误,这是因为项目没有生成 compile_commands.json
文件,所以lsp服务器无法跨文件分析,导致找不到头文件。原始的make 命令并不支持生成 compile_commands.json
文件,我们可以通过 bear
命令来完成这个工作,它的用法比较简单,只需要使用 bear -- <your-build-command>
即可, 对于使用make编译的项目来说 <your-build-command>
代表的就是 make
命令。
我们需要考虑的一个问题是,如何将bear加入到编译命令中,也就是将它自动生成的 make -k
给替换掉,第二个问题是如果当前目录在其他目录下,如何保证 compile_commands.json
永远生成在根目录下
Emacs中有一个变量 compile-command
保存了编译的命令,如果我们使用Emacs自带的compile来编译可以通过修改它来实现,而 projectile-compile-project
则是通过变量 projectile-project-compilation-cmd
来保存编译命令,默认是nil,对于使用 projectile
我们通过修改这个变量的值从而修改编译时使用的命令。另外既然 projectile
可以得到项目的根目录,我们就可以利用这个插件来获取项目的根目录,有了这些信息通过一个函数就可以重新生成一个编译命令
(defun my/general-compile-command()(concat "bear --output " projectile-project-root "compile_commands.json" " -- make -k"))
这个函数的代码非常简单,通过 projectile-project-root 来获取项目的根目录,然后通过字符串拼接的方式来得到编译命令
生成 compile_commands.json
成功之后,我们重启 lsp
服务后可以看到错误都消失了,只有两个警告了
了解了编译的一些情况,下面来看看如何在Emacs中执行生成的可执行程序。
Emacs中可以使用 shell-command
来执行可执行 shell
命令。例如我们可以在项目的根目录下执行 shell-command ./bin/app
。很明显如果每次都指定程序的路径是非常麻烦的事,我希望能有一个命令或者函数来自动执行可执行程序。但是Makefile构建的项目比较古老也灵活,Makefile中没有一个固定的方式或者写法来指定可执行程序的生成路径,也就是说没有一个通用的方式来根据Makefile获取可执行文件的路径。一种折中的方案就是针对每个项目都定义一个 execuable-path
的变量来指定可执行程序的路径,然后再通过elisp代码来根据这个变量执行程序
(defun my/run-program()(interactive)(shell-command (concat projectile-project-root executable-path)))
我们可以针对每个项目单独设置一个 executable-path
变量。Emacs会读取项目根目录中的 .dir-locals.el 文件,并且将文件中定义的变量作为项目的局部变量,所以我们只需要在该文件中定义好 executable-path
就可以了。我们可以通过命令 add-dir-local-variable
来往该文件中添加一个局部变量,也可以自己手写该文件实现这一操作
添加完变量之后,项目根目录中的 .dir-locals.el
文件内容如下
;;; Directory Local Variables -*- no-byte-compile: t -*-
;;; For more information see (info "(emacs) Directory Variables")((c++-mode . ((executable-path . "bin/app"))))
在重启Emacs之后,执行这个函数就可以做到一键运行了
有了这些,我希望能将它们有机的组合起来,也就是说按下某个快捷键,这里我暂时定义为 <F7>
。它直接同时执行编译和运行的操作。通过 C-<F7>
来完成重编译的操作。
;; 重新编译
(defun my/rebuild-program ()(interactive)(let ((root (file-name-as-directory (projectile-project-root))))(shell-command (concat "make clean -C " root))(setq compile-command (concat "bear --output " root "compile_commands.json" " -- make -k -C " root))(compile compile-command)));; 绑定快捷键
(setq compilation-read-command nil) ;; 取消编译时确定命令行
(evil-define-key 'normal c++-mode-map (kbd "<f7>") #'projectile-compile-project)
(evil-define-key 'normal c++-mode-map (kbd "C-<f7>") #'my/rebuild-program)))
这里的代码比较简单,对于编译来说只需要将之前执行的 projectile-compile-project
绑定到对应的快捷键;对于重编译,我通过函数 my/rebuild-program
来完成。这个函数主要操作是先执行 make clean
命令然后重新执行 make
。
在正式绑定快捷键之前,有一句 (setq compilation-read-command nil)
。projectile-compile-project
和 compile
命令都是交互式命令,执行时会首先显示对应的编译命令,需要用户手动执行回车确认命令,这句代码的意思是不取消它们需要确认的步骤,直接执行命令。
本来我打算在重编译函数中也采用 projectile-compile-project
但是它这个交互式我一直取消不了,所以这里我直接采用 compile
指定根目录的方式来完成这个操作。
如果想要绑定一键运行的操作也可以采用这个思路,将快捷键绑定到 my/run-program
函数中,这个函数也可以添加一个编译命令确保执行的是最新代码生成的可执行程序
CMake工程
CMakeLists.txt 文件内容如下:
cmake_minimum_required(VERSION 3.15)
set(CMAKE_CXX_STANDARD 11)
project(test)# aux_source_directory(${PROJECT_SOURCE_DIR} source)
file(GLOB source ${CMAKE_SOURCE_DIR}/src/*.cpp)
include_directories(${CMAKE_SOURCE_DIR}/include)
set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin)
add_executable(app ${source})
这个CMakeLists.txt 文件中主要定义了编译使用到的源文件、头文件目录路径和生成的exe路径
emacs 中有一个名为 cmake-ide
的包,它用于读取cmake配置中的各项参数并将参数传递到对应的包中,虽然用它可以很方便的针对cmake配置,但是它依赖rtags,并且没有支持lsp-mode。所以这里就淘汰它,还是想办法自己实现
针对cmake来说,要生成 compile_commands.json
比较简单,我们可以在命令行中使用
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=1
也可以在cmake配置文件中,project命令之后添加
set (CMAKE_EXPORT_COMPILE_COMMANDS ON)
这里我采用将命令写到cmake文件中的方式。
对于cmake 编译的过程主要由两个部分组成,首先是cmake构建项目生成Makefile,然后使用make 命令编译项目。我们要实现自动编译也需要模拟这两个命令。
与上面类似,这里我只需要将 my/general-compile-command
函数做少许改动即可
(defun my/cmake-general-compile-command ()(concat "cmake -B " (projectile-project-root) "build -DCMAKE_BUILD_TYPE=Debug " (projectile-project-root)" && ln -sf " (projectile-project-root) "build/compile_commands.json " (projectile-project-root) "compile_commands.json"" && cmake --build " (projectile-project-root) "build --config Debug"))
这个函数生成的命令主要完成三个工作,将构建编译生成的临时文件放到 build 目录下;因为生成的 compile_commands.json
文件也一起放在了 build
目录中,所以我加一个软链接到项目根目录的操作;最后就是执行编译操作了。
至于重编译则于上面的步骤相似,cmake一般我习惯删除存放临时文件的build
目录然后重新执行cmake构建。所以这里还是模拟这个过程
(defun my/cmake-rebuild-program ()(interactive)(let ((root (file-name-as-directory (projectile-project-root))))(shell-command (concat "rm -rf " root "build"))(setq compile-command (my/cmake-general-compile-command))(compile compile-command)))
至于运行程序,我们还是可以采用上面介绍的指定程序生成路径的方式。也就是不管使用cmake或者Makefile 构建的工程都可以使用上面定义的 my/run-program
函数来运行程序
调试
作为IDE的一个重要或者说基础的功能,调试功能是必不可少的。
emacs 自身支持使用gdb进行调试,我们可以执行 M-x gdb
来启动一个调试示例,这个时候我们一边通过gdb的调试命令来控制程序语句的执行一边观看代码的上下文。但是目前流行的方式是使用 dap 来调试程序,至于什么是dap,我在配置vim的时候已经介绍过了,这里就不再赘述了
emacs 中有一个名为dap-mode 的插件通过这个插件可以实现dap相关的功能。因为在介绍vim配置的时候我使用的是vscode中的 cpptools插件,这里我打算也使用它来作为dap的调试后端,可以通过cpptools官方仓库 进行下载
接着需要安装lldb-vscode,它是针对vscode的一个插件,我们可以在 官方仓库 中找到对应的下载包。
下载完成之后可以直接解压到对应目录,这里我解压到 ~/.emacs.d/cpptools 目录中。此时对应的调试后端程序为 ~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7
。我们需要赋予它可执行的权限。
在这些工作都做好之后,可以使用下面的代码来安装dap-mode
(use-package dap-mode:ensure t:after (lsp-mode):config(dap-auto-configure-mode) ; 可选:启用自动配置(setq dap-cpptools-debug-program '("~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7")))
我们可以通过命令 dap-debug-edit-template
创建一个调试的模板。对给出的模板做一些简单的修改
(dap-register-debug-template"cpptools::Run Configuration"(list :type "cppdbg":request "launch":name "cpptools::Run Configuration":MIMode "gdb":program "${workspaceFolder}/bin/hello":cwd "${workspaceFolder}":environment []:miDebuggerPath "/usr/bin/gdb"))
我们执行一下这个代码就会向Emacs注册一个调试的模板。接着直接调用 dap-debug
即可启动调试。
虽然我们可以将上述注册的代码放到主配置文件中,但是其中的一些关键的字段,例如程序的位置,使用的环境变量,以及对应的调试参数都无法做到所有程序都统一,所以这里我觉得还是需要的时候直接修改就好了。
dap-mode
的一些命令如下:
dap-debug
和dap-continue
: 启动调试或者运行到下一个断点处dap-next
: 执行下一句代码dap-step-in
: 执行下一行代码并进入函数内部dap-step-out
: 执行到函数返回dap-breakpoint-toggle
: 创建或者删除端点
我们可以对这些命令进行键位绑定
(use-package dap-mode:ensure t:after (lsp-mode):config(dap-auto-configure-mode) ; 可选:启用自动配置(require 'dap-cpptools)(setq dap-cpptools-debug-program '("~/.emacs.d/cpptools/extension/debugAdapters/bin/OpenDebugAD7"))(evil-define-key 'normal dap-mode-map (kbd "<f10>") #'dap-next)(evil-define-key 'normal dap-mode-map (kbd "<f9>") #'dap-breakpoint-toggle)(evil-define-key 'normal dap-mode-map (kbd "<f5>") #'dap-debug))
这样我们可以使用上述快捷键来进行调试操作
总结
这篇文章花了好长时间才弄出来,主要是我对于emacs和lisp语言不太熟悉,中间在尝试编写一键运行和配置dap时耗费了大量的时候。最终我还是成功了,至少我完成我当初想要的一些ide的基本功能,当然在使用上还是比不过vscode,但是在折腾中总能找到一丝乐趣。
本文中的配置仅仅经过我自己机器的检验,本来想弄的更加灵活更加接近vscode的体验,有一些我自己想要的功能还没加上,仅仅做了一个可用的玩具。但是我没有想到什么办法,而且这篇文章已经憋了好久了,再不写点东西出来我感觉马上就要放弃了,我想先弄点东西出来给自己一个激励,让我有动力继续深入学习一下Emacs的其他内容。等我多学了一点Emacs多写了一点elisp代码之后可能会对调试和编译方面的代码做一个大的更新。
最后如果有读者觉得这篇文章写的有那么一点帮助,那将是我的荣幸,感谢读者在百忙之中能读完本文。