[PATCH 12/17] tools/arch/x86/pmtctl: Add pmtctl CLI entry point and pager

From: David E. Box

Date: Mon May 25 2026 - 21:49:23 EST


Add the pmtctl command-line frontend, a user-facing binary built on top of
libpmtctl_core for discovering and inspecting Intel PMT telemetry devices.

Add a built-in pager so wide or lengthy telemetry output remains readable
without requiring users to pipe output through an external pager. The pager
is automatically disabled when output is redirected, preserving
scriptability.

This patch establishes the shared CLI infrastructure and global option
handling used by later subcommands. Subsequent patches add the 'list' and
'stat' commands for device enumeration and live telemetry sampling.

Add top-level Makefile integration so a single 'make' invocation builds
both the library and CLI.

Assisted-by: GitHub-Copilot:claude-opus-4.7
Signed-off-by: David E. Box <david.e.box@xxxxxxxxxxxxxxx>
---
tools/arch/x86/pmtctl/Makefile | 114 +++++++++++++++++
tools/arch/x86/pmtctl/include/pmtctl_cli.h | 12 ++
tools/arch/x86/pmtctl/src/main.c | 134 ++++++++++++++++++++
tools/arch/x86/pmtctl/src/pager.c | 140 +++++++++++++++++++++
4 files changed, 400 insertions(+)
create mode 100644 tools/arch/x86/pmtctl/Makefile
create mode 100644 tools/arch/x86/pmtctl/include/pmtctl_cli.h
create mode 100644 tools/arch/x86/pmtctl/src/main.c
create mode 100644 tools/arch/x86/pmtctl/src/pager.c

diff --git a/tools/arch/x86/pmtctl/Makefile b/tools/arch/x86/pmtctl/Makefile
new file mode 100644
index 000000000000..83bca8c312e7
--- /dev/null
+++ b/tools/arch/x86/pmtctl/Makefile
@@ -0,0 +1,114 @@
+# SPDX-License-Identifier: GPL-2.0-only
+
+CC ?= gcc
+
+BUILD ?= release
+
+CPPFLAGS += -Iinclude -Ilib -I../../../include -D_GNU_SOURCE
+CFLAGS += -std=gnu11 -Wall -Wextra
+
+WEXTRA_WARN := -Wpedantic -Wmissing-prototypes -Wstrict-prototypes \
+ -Wunused-function -Wunused-variable -Wunused-parameter \
+ -Wunused-but-set-variable -Wunreachable-code
+
+ifeq ($(BUILD),debug)
+ CFLAGS += -O0 -g3 -fno-omit-frame-pointer
+ CFLAGS += $(WEXTRA_WARN)
+ CPPFLAGS += -DDEBUG
+else ifeq ($(BUILD),release)
+ CFLAGS += -O2 -g0 -DNDEBUG
+else
+ $(error unknown BUILD '$(BUILD)' (use release or debug))
+endif
+
+JANSSON_CFLAGS := $(shell pkg-config --cflags jansson 2>/dev/null)
+JANSSON_LIBS := $(shell pkg-config --libs jansson 2>/dev/null)
+ifeq ($(JANSSON_LIBS),)
+ $(error jansson not found: install libjansson-dev (Debian/Ubuntu) or jansson-devel (Fedora/RHEL))
+endif
+CPPFLAGS += $(JANSSON_CFLAGS)
+LDLIBS := $(JANSSON_LIBS) -lm
+
+SRCDIR := src
+BUILDDIR:= build/$(BUILD)
+TARGET := pmtctl
+LIBDIR := lib
+LIBPMTCTL_CORE := $(BUILDDIR)/lib/libpmtctl_core.a
+LIBPMTCTL_ARTIFACTS := $(LIBPMTCTL_CORE)
+LIBPMTCTL_STAMP := $(BUILDDIR)/lib/.built
+SAMPLE_SRC := samples/libpmtctl_sample.c
+SAMPLE_TARGET := $(BUILDDIR)/samples/libpmtctl_sample
+
+SRC := \
+ $(SRCDIR)/main.c \
+ $(SRCDIR)/pager.c
+
+OBJ := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRC))
+CLEAN_BUILDS := release debug
+
+.PHONY: all clean libpmtctl_core sample FORCE
+
+all: $(TARGET)
+
+$(TARGET): $(OBJ) $(LIBPMTCTL_ARTIFACTS)
+ $(CC) $(CFLAGS) -o $@ $(OBJ) $(LIBPMTCTL_ARTIFACTS) $(LDLIBS)
+
+libpmtctl_core: $(LIBPMTCTL_CORE)
+
+sample: $(SAMPLE_TARGET)
+
+# Build the sample with the public-header search path only, so its
+# `#include "pmtctl.h"` resolves the same way it would for an external
+# consumer linking against the installed libpmtctl_core.
+$(SAMPLE_TARGET): CPPFLAGS += -Iinclude/lib
+$(SAMPLE_TARGET): $(SAMPLE_SRC) $(LIBPMTCTL_ARTIFACTS)
+ @mkdir -p $(dir $@)
+ $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ $< $(LIBPMTCTL_ARTIFACTS) $(LDLIBS)
+
+$(LIBPMTCTL_ARTIFACTS): $(LIBPMTCTL_STAMP)
+
+$(LIBPMTCTL_STAMP): FORCE
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD)
+ @mkdir -p $(dir $@)
+ @touch $@
+
+FORCE:
+
+# Install settings
+PREFIX ?= /usr/local
+DESTDIR ?=
+
+
+.PHONY: install uninstall install-lib install-headers install-pkgconfig uninstall-lib uninstall-headers uninstall-pkgconfig
+
+install: $(TARGET) install-lib install-headers install-pkgconfig
+ install -d $(DESTDIR)$(PREFIX)/bin
+ install -m 0755 $(TARGET) $(DESTDIR)$(PREFIX)/bin
+ @echo "Installed $(TARGET) to $(DESTDIR)$(PREFIX)/bin/"
+
+install-lib:
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) install-lib
+
+install-headers:
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) install-headers
+
+install-pkgconfig:
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) install-pkgconfig
+
+uninstall:
+ rm -f $(DESTDIR)$(PREFIX)/bin/$(TARGET)
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) uninstall-lib
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) uninstall-headers
+ $(MAKE) -C $(LIBDIR) BUILD=$(BUILD) PREFIX=$(PREFIX) DESTDIR=$(DESTDIR) uninstall-pkgconfig
+ @echo "Removed $(DESTDIR)$(PREFIX)/bin/$(TARGET) (if present)"
+
+$(BUILDDIR)/%.o: $(SRCDIR)/%.c
+ @mkdir -p $(BUILDDIR)
+ $(CC) $(CPPFLAGS) $(CFLAGS) -c $< -o $@
+
+clean:
+ @for build_type in $(CLEAN_BUILDS); do \
+ $(MAKE) -C $(LIBDIR) BUILD=$$build_type clean; \
+ rm -rf build/$$build_type; \
+ done
+ rm -rf $(BUILDDIR) $(TARGET)
diff --git a/tools/arch/x86/pmtctl/include/pmtctl_cli.h b/tools/arch/x86/pmtctl/include/pmtctl_cli.h
new file mode 100644
index 000000000000..0b99dfe0ed64
--- /dev/null
+++ b/tools/arch/x86/pmtctl/include/pmtctl_cli.h
@@ -0,0 +1,12 @@
+/* SPDX-License-Identifier: GPL-2.0-only */
+#ifndef PMTCTL_CLI_H
+#define PMTCTL_CLI_H
+
+#include <stdio.h>
+
+#include "lib/pmtctl.h"
+
+FILE *pmtctl_start_pager(const struct pmt_global_opts *gopts);
+void pmtctl_finish_pager(FILE *out);
+
+#endif
diff --git a/tools/arch/x86/pmtctl/src/main.c b/tools/arch/x86/pmtctl/src/main.c
new file mode 100644
index 000000000000..e93b544d9343
--- /dev/null
+++ b/tools/arch/x86/pmtctl/src/main.c
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier: GPL-2.0-only
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <stdbool.h>
+#include <getopt.h>
+
+#include "lib/log.h"
+
+#include "pmtctl_cli.h"
+
+#define PMTCTL_VERSION "2026.05.19"
+#define OPT_DEBUG 256
+
+static struct pmt_global_opts gopts = {
+ .device_selector = NULL,
+ .json_path = NULL,
+ .quiet = false,
+ .debug = false,
+};
+
+static void print_usage(FILE *out)
+{
+ fprintf(out,
+ "Usage: pmtctl [global options] <command> [command options] ...\n"
+ "\n"
+ "Query Intel Platform Monitoring Technology (PMT) metrics.\n"
+ "\n"
+ "Global options:\n"
+ " -h, --help Show this help and exit\n"
+ " -V, --version Show version and exit\n"
+ "\n"
+ " -J, --json-file <path> Metrics JSON file\n"
+ " If omitted, no metric definitions are loaded\n"
+ " or search a default path (e.g. $PMTCTL_JSON_PATH).\n"
+ "\n"
+ " -d, --device <selector> Restrict to a single endpoint.\n"
+ " Can be supplied either before the command (global)\n"
+ " or after the command as a command-local fallback.\n"
+ " Global value takes precedence when both are given.\n"
+ " Selectors: guid=<hex>, ep=<endpoint_name>\n"
+ "\n"
+ " -q, --quiet Suppress non-essential messages\n"
+ " --debug Enable debug logging\n"
+ );
+}
+
+static void print_version(void)
+{
+ printf("pmtctl version %s - David E. Box <david.e.box@xxxxxxxxxxxxxxx>\n", PMTCTL_VERSION);
+}
+
+static int cmd_dispatch(int argc, char **argv)
+{
+ const struct option long_options[] = {
+ { "help", no_argument, 0, 'h' },
+ { "version", no_argument, 0, 'V' },
+ { "json-file", required_argument, 0, 'J' },
+ { "device", required_argument, 0, 'd' },
+ { "quiet", no_argument, 0, 'q' },
+ { "debug", no_argument, 0, OPT_DEBUG },
+ { 0, 0, 0, 0 }
+ };
+ const char *cmd;
+ int option_index = 0;
+ int opt;
+
+ while ((opt = getopt_long(argc, argv, "+hVJ:qd:", long_options, &option_index)) != -1) {
+ switch (opt) {
+ case 'h':
+ print_usage(stdout);
+ return 0;
+ case 'V':
+ print_version();
+ return 0;
+ case 'J':
+ gopts.json_path = optarg;
+ break;
+ case 'd':
+ if (gopts.device_selector)
+ return log_ret(PMTCTL_ERR_CMD_PARSE,
+ "multiple --device options are not allowed");
+ gopts.device_selector = optarg;
+ break;
+ case 'q':
+ gopts.quiet = true;
+ break;
+ case OPT_DEBUG:
+ gopts.debug = true;
+ break;
+ case '?':
+ default:
+ /* getopt_long already printed an error */
+ fprintf(stderr, "Try 'pmtctl --help' for usage.\n");
+ return PMTCTL_ERR_CMD_PARSE;
+ }
+ }
+
+ if (optind >= argc) {
+ fprintf(stderr, "pmtctl: missing command\n");
+ print_usage(stderr);
+ return PMTCTL_ERR_CMD_PARSE;
+ }
+
+ cmd = argv[optind];
+ if (!strcmp(cmd, "--help") || !strcmp(cmd, "help")) {
+ print_usage(stdout);
+ return 0;
+ }
+
+ fprintf(stderr, "pmtctl: unknown command '%s'\n", cmd);
+ fprintf(stderr, "Run 'pmtctl --help' for a list of commands.\n");
+ return PMTCTL_ERR_CMD_PARSE;
+}
+
+int main(int argc, char **argv)
+{
+ int ret = cmd_dispatch(argc, argv);
+
+ pmtctl_cleanup();
+
+ /*
+ * Collapse internal error codes to conventional exit values.
+ * Granular error details are printed to stderr by log_ret().
+ * 0 = success, PMTCTL_EXIT_USER (1) = usage/user error,
+ * PMTCTL_EXIT_SYSTEM (2) = device/system error.
+ */
+ if (ret == 0)
+ return 0;
+ else if (ret > 0)
+ return PMTCTL_EXIT_USER;
+ else
+ return PMTCTL_EXIT_SYSTEM;
+}
diff --git a/tools/arch/x86/pmtctl/src/pager.c b/tools/arch/x86/pmtctl/src/pager.c
new file mode 100644
index 000000000000..a9329293da84
--- /dev/null
+++ b/tools/arch/x86/pmtctl/src/pager.c
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: GPL-2.0-only
+#define LOG_PREFIX "pager"
+#define _XOPEN_SOURCE 700
+#include <errno.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+
+#include "lib/pmtctl.h"
+#include "lib/common.h"
+#include "lib/log.h"
+
+#include "pmtctl_cli.h"
+
+static FILE *pager_fp;
+static pid_t pager_pid;
+
+/* Maximum number of whitespace-separated tokens parsed from $PAGER. */
+#define PAGER_ARGV_MAX 16
+
+static bool env_false(const char *v)
+{
+ return !*v || !strcmp(v, "0") || !strcasecmp(v, "false") || !strcasecmp(v, "no") ||
+ !strcasecmp(v, "off");
+}
+
+FILE *pmtctl_start_pager(const struct pmt_global_opts *gopts)
+{
+ (void)gopts;
+
+ /* Ignore SIGPIPE to handle pager closing early */
+ signal(SIGPIPE, SIG_IGN);
+
+ /* Already using a pager? Just reuse it. */
+ if (pager_fp)
+ return pager_fp;
+
+ /* Don't page if stdout is not a TTY. */
+ if (!isatty(STDOUT_FILENO))
+ return stdout;
+
+ /* Opt-out env: PMTCTL_NOPAGER=1 */
+ const char *nopager = getenv("PMTCTL_NOPAGER");
+ const char *pager_env;
+ int fds[2];
+ pid_t pid;
+ FILE *fp;
+
+ if (nopager && !env_false(nopager))
+ return stdout;
+
+ /* Choose pager: PMTCTL_PAGER > PAGER > "less" */
+ pager_env = getenv("PMTCTL_PAGER");
+ if (!pager_env || !*pager_env)
+ pager_env = getenv("PAGER");
+ if (!pager_env || !*pager_env)
+ pager_env = "less";
+
+ if (pipe(fds) < 0) {
+ log_err(errno, "pipe for pager failed");
+ return stdout;
+ }
+
+ pid = fork();
+ if (pid < 0) {
+ log_err(errno, "fork for pager failed");
+ close(fds[0]);
+ close(fds[1]);
+ return stdout;
+ }
+
+ if (pid == 0) {
+ /* Child: exec pager, reading from pipe */
+ close(fds[1]);
+
+ if (fds[0] != STDIN_FILENO) {
+ if (dup2(fds[0], STDIN_FILENO) < 0)
+ _exit(1);
+ close(fds[0]);
+ }
+
+ /* build argv from pager_env (very simple split on spaces) */
+ char *argv[PAGER_ARGV_MAX];
+ char *cmd = strdup(pager_env);
+ char *tok;
+ int argc = 0;
+
+ if (!cmd)
+ _exit(1);
+
+ tok = strtok(cmd, " ");
+ while (tok && argc < (int)ARRAY_SIZE(argv) - 1) {
+ argv[argc++] = tok;
+ tok = strtok(NULL, " ");
+ }
+ argv[argc] = NULL;
+
+ execvp(argv[0], argv);
+ /* If exec fails */
+ _exit(1);
+ }
+
+ /* Parent */
+ close(fds[0]);
+ fp = fdopen(fds[1], "w");
+ if (!fp) {
+ log_err(errno, "fdopen for pager failed");
+ close(fds[1]);
+ /* We spawned a child; best effort wait and fall back */
+ int status;
+ (void)waitpid(pid, &status, 0);
+ return stdout;
+ }
+
+ pager_pid = pid;
+ pager_fp = fp;
+ return pager_fp;
+}
+
+void pmtctl_finish_pager(FILE *out)
+{
+ if (!pager_fp || out != pager_fp) {
+ /* No pager or not ours */
+ return;
+ }
+
+ fclose(pager_fp);
+ pager_fp = NULL;
+
+ if (pager_pid > 0) {
+ int status;
+ (void)waitpid(pager_pid, &status, 0);
+ pager_pid = 0;
+ }
+}
--
2.43.0