2024年4月

jhsdb

jhsdb(Java HotSpot Debugger)是在JDK 9中引入的命令行实用程序,它是服务性代理(Serviceability Agent)工具的一部分。服务性代理是一个框架,允许对运行中的Java虚拟机(JVM)或崩溃后的核心转储进行深入分析。引入jhsdb的目的有几个:

  1. 统一的调试工具:在JDK 9之前,开发者需要使用不同的工具,如jmapjstackjinfo等来执行各种调试任务。jhsdb将这些工具统一到一个命令行界面下,使得在不切换多个工具的情况下更容易执行广泛的调试和诊断任务。
  2. 增强的事后诊断jhsdb提供了分析核心转储和实时进程的能力,这对事后诊断至关重要。当JVM崩溃时,开发者需要了解崩溃时JVM的状态,这一点尤其有用。
  3. 高级分析能力:使用jhsdb,开发者可以执行高级分析,如检查堆、分析内存使用情况和获取线程堆栈跟踪。它还允许使用内置的命令行调试器(CLD)来调试Java进程,这是一个类似于gdb的基于文本的界面。
  4. 跨平台一致性jhsdb旨在在不同平台上保持一致的工作,为在各种操作系统上进行诊断提供了标准化的方法。
  5. 与HotSpot JVM集成:由于jhsdb是为HotSpot JVM量身定制的,它可以利用HotSpot的内部特性来提供有关JVM内部的详细信息,这对于性能调优和解决复杂问题非常宝贵。
  6. 可访问性:通过包含在JDK中,jhsdb可以立即供所有Java开发者使用,无需额外下载或安装。
  7. 可脚本化:作为命令行工具,jhsdb可以轻松地编写脚本并集成到自动化的调试和诊断工作流中,这对于持续集成和部署管道非常有益。

总的来说,JDK 9中引入jhsdb的目的是通过提供一个强大而多功能的JVM诊断和调试工具来改善开发者体验,从而增强分析和解决Java应用程序中复杂问题的能力。

实践

linux 系统现在有一个正在运行的 Java 程序, 进程号是 256487, 却遇到了下面的错误

$ jhsdb jmap  --pid 256487
Attaching to process ID 256487, please wait...
ERROR: ptrace(PTRACE_ATTACH, ..) failed for 256487: Operation not permitted
Error attaching to process: sun.jvm.hotspot.debugger.DebuggerException: Can't attach to the process: ptrace(PTRACE_ATTACH, ..) failed for 256487: Operation not permitted
sun.jvm.hotspot.debugger.DebuggerException: sun.jvm.hotspot.debugger.DebuggerException: Can't attach to the process: ptrace(PTRACE_ATTACH, ..) failed for 256487: Operation not permitted

这是因为 ptrace 权限的问题. 查看 /proc/sys/kernel/yama/ptrace_scope 可以看到里面的值是 1, 改成 0 就好了

$ cat /proc/sys/kernel/yama/ptrace_scope
1
$ echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
0

$ jhsdb jmap  --pid 256487 # as pmap output
Attaching to process ID 256487, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11.0.22+7-post-Ubuntu-0ubuntu222.04.1
0x0000563f4efe6000    14K    /usr/lib/jvm/java-11-openjdk-amd64/bin/java
0x00007fe2671ba000    44K    /usr/lib/jvm/java-11-openjdk-amd64/lib/libzip.so
0x00007fe2671c5000    66K    /usr/lib/x86_64-linux-gnu/libresolv.so.2
0x00007fe2671d9000    22K    /usr/lib/x86_64-linux-gnu/libkeyutils.so.1.9
0x00007fe2671e0000    50K    /usr/lib/x86_64-linux-gnu/libkrb5support.so.0.1
0x00007fe2671ee000    18K    /usr/lib/x86_64-linux-gnu/libcom_err.so.2.1
0x00007fe2671f4000    178K    /usr/lib/x86_64-linux-gnu/libk5crypto.so.3.1
0x00007fe267223000    808K    /usr/lib/x86_64-linux-gnu/libkrb5.so.3.3
0x00007fe2672ee000    330K    /usr/lib/x86_64-linux-gnu/libgssapi_krb5.so.2.2
0x00007fe267342000    178K    /usr/lib/x86_64-linux-gnu/libtirpc.so.3.0.0
0x00007fe267370000    91K    /usr/lib/x86_64-linux-gnu/libnsl.so.2.0.1
0x00007fe26738a000    54K    /usr/lib/x86_64-linux-gnu/libnss_nis.so.2.0.0
0x00007fe267399000    42K    /usr/lib/x86_64-linux-gnu/libnss_compat.so.2
0x00007fe2673b8000    39K    /usr/lib/jvm/java-11-openjdk-amd64/lib/libjimage.so
0x00007fe2673c1000    213K    /usr/lib/jvm/java-11-openjdk-amd64/lib/libjava.so
0x00007fe2673ef000    14K    /usr/lib/x86_64-linux-gnu/librt.so.1
0x00007fe2674f4000    122K    /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
0x00007fe267514000    918K    /usr/lib/x86_64-linux-gnu/libm.so.6
0x00007fe2675fb000    2207K    /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30
0x00007fe267827000    22708K    /usr/lib/jvm/java-11-openjdk-amd64/lib/server/libjvm.so
0x00007fe268afc000    2168K    /usr/lib/x86_64-linux-gnu/libc.so.6
0x00007fe268d25000    80K    /usr/lib/jvm/java-11-openjdk-amd64/lib/jli/libjli.so
0x00007fe268d38000    106K    /usr/lib/x86_64-linux-gnu/libz.so.1.2.11
0x00007fe268d54000    67K    /usr/lib/jvm/java-11-openjdk-amd64/lib/libverify.so
0x00007fe268d67000    235K    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2

$ jhsdb -h
    clhsdb           command line debugger
    hsdb             ui debugger
    debugd --help    to get more information
    jstack --help    to get more information
    jmap   --help    to get more information
    jinfo  --help    to get more information
    jsnap  --help    to get more information

$ jhsdb jmap --help

$ jhsdb jmap --binaryheap --pid 274471
Attaching to process ID 274471, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11.0.22+7-post-Ubuntu-0ubuntu222.04.1
heap written to heap.bin

Java 如何创建一个 fatal error

之前在写另外一篇文章的时候, 讨论到如果遇到 fatal error, 就能通过 JVM 启动参数 -XX:OnError 来触发执行定制的命令, 以及用 -XX:ErrorFile 来产生 hs_err_pid*.log. 那么什么是 fatal error 呢?

我们知道Java 的错误分为 ErrorException 可是即便抛出一个 Error 并不能触发上面的2个参数, 所以文档里指的 fatal error 肯定是更为严重的错误.

你以前可能看到过 hs_err_pid*.log, 可是真要故意创建一个 fatal error 的时候, 却犯难了.

如何创建一个 fatal error

有趣的是, 但我问 chatGPT 如何创建这种 fatal error 场景的时候, 它提示我: This content may violate our usage policies. 竟然拒绝回答. 当我告诉它, 我只是在做一个本地实验的时候, 它告诉我通过下面3种方法能造成 fatal error:

  1. 调用 native C 代码, 读取错误地址的代码, 造成 Segment fault;
  2. 错误使用 JVM 参数, 如设置 -XX:CompileThreshold 特小, 导致编译死循环;
  3. 手动触发特定 Error, 如 OutOfMemoryError.

试了上面的第三项, 不论是通过消耗内存真造成 OOM 还是直接抛出 OutOfMemoryError 都无法触发.

谷歌搜索了一下, 也没找到我这种想触发的人, 都是遇到这种问题的人.

思路 从JDK 源代码找到可能触发的场景

搜索 hs_err_pid site:openjdk.java.net 可以看到几个相关的issue, 比如 JDK-8220786JDK-8220787 都是讨论把 hs-err 从定向到 stdout 或 stderr 去.

接着继续搜索 github 上的 JDK 源码, 找到下面的代码:
https://github.com/openjdk/jdk/blob/4e5c25ee43d4ec31ed5160fd93a2fd15e35182f8/src/hotspot/share/utilities/vmError.cpp#L1827

fd_log = prepare_log_file(ErrorFile, "hs_err_pid%p.log", true, buffer, sizeof(buffer));

从这里我们看到了如何写入以及什么时候才能写入 hs_err_pid%p.log 的逻辑. 这个代码位于: vmError.cpp VMError::report_and_die 方法, 并且这里面有好几个重载的该方法.

接下来, 我们只要在该 repo 里面搜索什么时候调用这个 report_and_die 方法的, 就能找到我们寻找的触发条件.

其中一处是关于在 linux 平台触发的在 https://github.com/openjdk/jdk/blob/4e5c25ee43d4ec31ed5160fd93a2fd15e35182f8/src/hotspot/os/posix/signals_posix.cpp#L649, 这个方法是: JVM_HANDLE_XXX_SIGNAL, 在其注释中提到 This routine may recognize any of the following kinds of signals: SIGBUS, SIGSEGV, SIGILL, SIGFPE, SIGQUIT, SIGPIPE, SIGXFSZ, SIGUSR1 .

所以, 我们可以使用 linux signal 来测试.

使用 SIGSEGV 测试

在 linux 机器上写一个 main 函数里面让线程 sleep 的代码, 然后启动它. 同时开另外一个窗口, 对它发送 SIGSEGV 信号, 观察到:

$ java -XX:OnError="touch /tmp/eric.txt"  ErrorExample 
$ kill -11 <pid>

发现 /tmp/eric.txt 确实产生了. 同时观察到在当前目录产生了 hs_err_pid702969.log 等日志文件. 虽然Java 进程奔溃会默认产生 core dump, 但是却没有找到 core dump.

为什么没有产生 core dump

JVM 有个启动参数 -XX:+CreateCoredumpOnCrash 默认是打开的, 所以, 理论上当 crash 的时候, 会产生core dump.

$ java -XX:+PrintFlagsFinal -version | grep Core
     bool CreateCoredumpOnCrash                    = true                                      {product} {default}

检查了 ulimit -c 确认是 unlimited, 同时搜索了全磁盘, 没有找到对应的 core dump.
如果查看上面提到的 hs_err_pid702969.log, 就会发现里面这么写的:

# Core dump will be written. Default location: Core dumps may be processed with "/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E" (or dumping to /home/supra/work/java/error/core.702969)

这里内容提及了 core dump, 以及 apport 和可能放置 core dump 的路径, 但该路径的core dump 却不存在.

apport 是什么

apport 是一款系统程序, 它能拦截应用在 crash 时候的 core dump 数据, 并且生成详细的report. 对于绝大多数用户来说, 遇到程序crash生成 core dump 并不能带来什么价值, 如果把 core dump 转成一份可以被用户或者admin 能读取的固定格式报告, 将会非常有用, apport 就是这么一种程序, 官方网站: https://wiki.ubuntu.com/Apport

如果你使用桌面版本的 Ubuntu 发行版, 在程序crash 的时候, 你可能会看到一个弹窗, 问你要不要发送crash report 给 ubuntu 官方, 它就是 apport 的结果.

通常它产生的 crash report 在 /var/crash/ 文件夹.

为什么 core dump 被 apport 接管了?

查看 core dump 的官方文档: https://man7.org/linux/man-pages/man5/core.5.html.
可以看到: /proc/sys/kernel/core_pattern 是一个内核参数,用于控制核心转储文件的生成方式。当一个进程崩溃时,内核会查看 core_pattern 的值来决定如何处理核心转储。

core_pattern 可以包含以下几种类型的值:

  1. 静态文件路径:如果 core_pattern 包含一个普通的文件路径(不包含管道符 |),核心转储文件将被写入该路径指定的位置。路径中可以包含一些特殊的格式化字符(如 %p 表示进程 ID),这些字符会被替换为相应的值,以便为每个核心转储生成唯一的文件名。
  2. 管道命令:如果 core_pattern 的值以管道符 | 开头,后面跟随的是一个命令,那么内核会执行该命令,并将核心转储数据作为标准输入传递给它。这允许将核心转储数据发送到一个自定义的处理程序,如 Apport 或 Systemd-coredump,而不是直接写入文件系统。

如: /var/coredumps/core.%e.%p|/usr/share/apport/apport %p %s %c %d %P %u %g -- %E

我实验用的 linux 版本就是上面的带管道的例子, 所以, 它使用 apport 生成了 crash dump, 我去 /var/crash/ 目录, 就看到了 crash report _usr_lib_jvm_java-11-openjdk-amd64_bin_java.1000.crash.

现在如何产生 core dump

  1. 使用 -XX:OnError="gdb %p"
  2. 修改 /proc/sys/kernel/core_pattern 为 core dump 文件模版(去除管道).
$ java -XX:OnError="gcore %p"  ErrorExample
$ ls -alh | grep core
$ core.720444

总结

  1. -XX:OnError 决定当致命错误是干啥
  2. -XX:ErrorFile 决定了 hs_err 日志的文件名, 还有另外两个类似的参数决定是不是写到 stdout 和 stderr.
  3. -XX:+CreateCoredumpOnCrash 决定了崩溃时默认产生core dump, core dump 文件名或怎么处理根据 /proc/sys/kernel/core_pattern 来定.

Java JDPA

JDPA 是一个框架, 它定义了 debugger 和 debuggee 是如何交互的, 两端交互的API 和 协议, 并不包含具体的实现. 它包含3部分.

  1. debugger 端的API: JDI (Java Debug Interface)
  2. debuggee 端的API: JVM TI
  3. 连接两端的协议: JDWP

JDPA.png

jdb

jdb 是 JDK 自带的命令行 debugger 端, 是 JDI 的实现.

  1. 它能连接本地和远程的目标 Java 应用, 执行 debug 命令.
  2. 它能连接本地和远程的 core dump 文件, 并获取信息.
  3. 它能启动本地要debug 的Java 应用, 并且debug.

    jdb -connect sun.jvm.hotspot.jdi.SAPIDAttachingConnector:pid=9302
    jdb -connect com.sun.jdi.SocketAttach:port=12306
    jdb -connect sun.jvm.hotspot.jdi.SACoreAttachingConnector:javaExecutable=$JAVA_HOME/bin/java,core=core.20441
    远程的 core dump 需要 jsadebugd. 

jdb 本地启动一个app 的背后

下面本地启动一个需要debug的app, 然后观察 jdb 是如何跟这个app交互的.

$ jdb FreeMemoryExample # 启动 jdb 
$ stop at FreeMemoryExample:8 # 设置断点
$ run # 启动应用. 

打开另外一个窗口, 然后观察如下:

$ jps #查看当前机器的 java 进程
3790574 TTY
3790626 FreeMemoryExample
3793734 Jps #jps 本身

$ sudo lsof -T -p  3790574 | grep 5598 #查看进程 3790574 里面连接的 tcp, 它的55985连到本地 55888
jdb     3790574 supra    6u  IPv6           37707266       0t0      TCP suprabox:55985->localhost:55888
$ sudo lsof -T -p 3790626 | grep 5598 #查看进程 3790626 里面连接的 tcp, 它的55888连到本地 55985
java    3790626 supra    4u  IPv4           37703541       0t0      TCP localhost:55888->suprabox:55985
$ ps aux | grep 3790626 #查看 3790626 的启动命令
supra    3790626  0.1  0.2 7021672 41712 pts/0   Sl+  00:06   0:01 /usr/lib/jvm/java-11-openjdk-amd64/bin/java -Xdebug -Xrunjdwp:transport=dt_socket,address=suprabox:55985,suspend=y FreeMemoryExample
$ ps aux | grep 3790574 #查看 3790574 的启动命令
supra    3790574  0.2  0.4 7619496 65040 pts/0   Sl+  00:06   0:02 jdb FreeMemoryExample

所以, 当运行 jdb FreeMemoryExample 的时候, 其实它调用java 进程另外启动了需要被 debug 的程序,并且建立了 tcp socket 通信.

被启动的进程使用了参数: -Xdebug -Xrunjdwp:transport=dt_socket,address=suprabox:55985,suspend=y

添加 debug 参数启动应用, 并连接

只要在对应的 Java 进程启动的时候, 添加类似如下参数, 就能启动debug 监听端口, 等待准备被连接:

$ java -agentlib:jdwp=transport=dt_socket,server=y,address=12306 FreeMemoryExample #启动应用端
# 更多参数参看: https://docs.oracle.com/javase/8/docs/technotes/guides/jpda/conninv.html

$ jdb -connect com.sun.jdi.SocketAttach:port=12306 # 启动jdb 并连接
 stop at FreeMemoryExample:8  # 设置断点
 run # 开始运行
 locals #显示本地变量
 where # 显示栈

使用 JDB debug Java 应用程序

写了很多年Java 程序, 很少有机会去使用 JDB debug Java 程序. 因为本地都是使用 IDE 里面的工具, 对于没有桌面的生产环境, 基本都是加入打日志的新代码, 或者使用 Btrace/Bytesman 进行注入. 今天我们将使用 Jdb 在生产环境debug 一段代码.

debug 一段简单的代码

下面是使用 MBean 获取系统剩余空闲内存的代码, 我们将用 jdb 去debug 它.

import java.lang.management.ManagementFactory;
import com.sun.management.OperatingSystemMXBean;

public class FreeMemoryExample {
    @SuppressWarnings("restriction")
    public static void main(String[] args) {
        OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
        long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();
        System.out.println("Free Physical Memory Size: " + freePhysicalMemorySize + " bytes");
    }
}

下面编译这段代码并且进入 debug 运行:

$ javac FreeMemoryExample.java
$ jdb FreeMemoryExample
nitializing jdb ...
> stop at FreeMemoryExample:8
Deferring breakpoint FreeMemoryExample:8.
It will be set after the class is loaded.
> run
run FreeMemoryExample
Set uncaught java.lang.Throwable
Set deferred uncaught java.lang.Throwable
>
VM Started: Set deferred breakpoint FreeMemoryExample:8

Breakpoint hit: "thread=main", FreeMemoryExample.main(), line=8 bci=7
8            long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();

上面的运行过程中, 我们把断点设置在 FreeMemoryExample 的第八行. 然后执行 run 命令, 启动程序, 然后程序停在了第8行, 并且打印了该行代码.

执行 list 子命令, 显示当前行的前后几行代码.

main[1] list
4    public class FreeMemoryExample {
5        @SuppressWarnings("restriction")
6        public static void main(String[] args) {
7            OperatingSystemMXBean osBean = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean();
8 =>         long freePhysicalMemorySize = osBean.getFreePhysicalMemorySize();
9            System.out.println("Free Physical Memory Size: " + freePhysicalMemorySize + " bytes");
10        }
11    }

执行 threads 子命令, 显示所有线程

main[1] threads
Group system:
  (java.lang.ref.Reference$ReferenceHandler)0x16c Reference Handler running
  (java.lang.ref.Finalizer$FinalizerThread)0x16d  Finalizer         cond. waiting
  (java.lang.Thread)0x16e                         Signal Dispatcher running
Group main:
  (java.lang.Thread)0x1                           main              running (at breakpoint)
Group InnocuousThreadGroup:
  (jdk.internal.misc.InnocuousThread)0x198        Common-Cleaner    cond. waiting

执行 locals 子命令, 显示所有本地变量

main[1] locals
Local variable information not available.  Compile with -g to generate variable information

可以看到这次并没有显示本地变量, 是因为在编译的时候没有加 -g 参数. javac 关于 -g 参数的说明如下:

  -g                           Generate all debugging info
  -g:{lines,vars,source}       Generate only some debugging info
  -g:none                      Generate no debugging info

所以 -g 就是添加 debugging 信息的. 所以, 我们重新编译一下, 然后继续上面的 locals 子命令

main[1] locals
Method arguments:
args = instance of java.lang.String[0] (id=831)
Local variables:
osBean = instance of com.sun.management.internal.OperatingSystemImpl(id=832)

这次我们可以看到 main 函数的参数 argsosBean 变量.

添加方法进入断点

我们可以看到下面讲要执行 OperatingSystemImpl.getFreePhysicalMemorySize, 我们进入这个方法的段点, 并通过执行 cont 执行到该断点.

main[1] stop in com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize
Set breakpoint com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize
main[1] cont
>
Breakpoint hit: "thread=main", com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize(), line=243 bci=0

执行 where 查看当前的栈

main[1] where
  [1] com.sun.management.internal.OperatingSystemImpl.getFreePhysicalMemorySize (OperatingSystemImpl.java:243)
  [2] FreeMemoryExample.main (FreeMemoryExample.java:8)

通过 help 子命令, 我们可以看到所有的子命令.

如何通过 jdb 连接一个正在运行的程序

上面的例子都是通过 jdb 启动想要调试的程序, 那么如何调试另一个独立的程序呢? 我们先看一下 IDE 是如何做的, 首先, 我们本地使用 Eclipse 打开刚才的程序, 设置断点, 然后启动程序. 这个时候, 应用停在断点处. 我们查看这个应用是如何启动的.

$ jps
57184 FreeMemoryExample
69238 Eclipse
57225 Jps
$ jcmd 57184 VM.command_line
57184:
VM Arguments:
jvm_args: -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:63529 -javaagent:/Users/xiatian/work/tools/eclipse/Eclipse.app/Contents/Eclipse/configuration/org.eclipse.osgi/223/0/.cp/lib/javaagent-shaded.jar -Dfile.encoding=UTF-8
java_command: FreeMemoryExample

可以看到 Eclipse 启动程序的时候, 添加了 -agentlib:jdwp=transport=dt_socket,suspend=y,address=localhost:63529 这就是启动调试的关键. 它相当于在应用端开启了一个端口在 63529 监听端口并可以完成某些命令, 然后 jdb 通过连接找个端口, 使用 jdwp 协议与之通信, 完成 jdb 的各种命令.

连接器 是Java调试器(JDB)与被调试的Java程序之间通信的接口。它允许JDB与正在运行的Java程序建立连接,并在程序执行时进行交互式的调试操作。

jdbconnectors 子命令能显示当前 jdb 支持的连接器, 如:

Connector: com.sun.jdi.ProcessAttach  Transport: local
Connector: com.sun.jdi.RawCommandLineLaunch  Transport: dt_socket
Connector: com.sun.jdi.SocketAttach  Transport: dt_socket

ref:

IEEE 754 浮点表示

IEEE 754 定义了计算机如何表示和存储非整数的数据, 如: 12.084, 0.3, -0.0043. 对于整数, 我们只要把十进制的整数转换成二进制, 并且在最前面设置它的正数/负数的符号, 就很容易的存储.

浮点数转成二进制

浮点数转成二进制分成2部份: 整数部分和小数部份.

  1. 对于整数部分, 直接转成二进制, 比如 12(十进制) = 1100 (二进制).
  2. 对于小数部份, 0.5(十进制) = 0.1(二进制), 0.25(十进制) = 0.01(二进制), 0.125(十进制) = 0.001(二进制), 所以可以看到对于小数部份是通过把1整除的方式获得的. 所以0.375(十进制) = 1/4 + 1/8 = 0.25 + 0.125 = 0.01(二进制) + 0.001(二进制) = 0.011(二进制).

所以:

12.125
   12 = 8 + 4 = 1100
   0.125 = 1/8 = 0.001
12.125 = 1100.001

科学计数法

科学记数法是一种记数的方法。 把一个数表示成a与10的n次幂相乘的形式(1≤|a|<10,a不为分数形式,n为整数),这种记数法叫做科学记数法。
例如:12.125 = 1.2125×10^1, 19971400000000=1.99714×10^13, 0.00345 = 3.45×10^-3.
所以它把一个数分成大于等于1小于10的科学计数部份和指数部分.

对于二进制, 同样适用科学计数法, 只不过二进制表示成科学计数法整数部分除了0之外, 只能有1. 比如:

12.125 = 1100.001 = 1.100001×2^3
0.375 = 0.011 = 1.1×2^-2

IEEE754 浮点表示

IEEE754 规定了单精度(32位)浮点数和双精度(64位)浮点数以及其他浮点数. 我们这里主要看单精度, 其它原理类似.

对于32位的浮点数, 32位分成 1位符号位(正负) + 8位指数 + 23位 科学计数数值.
符号位 0 表示正, 1 表示负.
比如:
12.125 = 1100.001 = 1.100001×2^3, 它的符号位0(正数), 指数部分是3 (二进制11), 科学计数数值是1.100001(二进制).
-0.375 = 0.011 = 1.1×2^-2, 它的符号位数1(负数), 指数部分是-2(-10二进制), 科学计数数值是1.1(二进制).

需要进一步明确的地方:

  1. 8位指数从 00000000 ~ 11111111, 即0 ~ 255, 但是这样对于是负数的指数无法表示, 所以需要调整这个值从0 ~ 255表示成-127 ~ 128. 即0表示 0-127 = -127(指数), 127 - 127 = 0(指数为0), 255 - 127 = 128. 但实际使用的时候, -127128被用作特殊值处理, 实际可能的值只能是 -126 ~ 127.
  2. 对于数值部分, 因为是科学计数法, 所以除了0之外, 其它时候都是1, 所以这个1可以去掉, 所以23位全部用来表示点之后的部份. 即 1.001101 只要使用 001101 它, 1.101 只要使用 101. 0表示成 23个0.

所以:
12.125 = 1100.001 = 1.100001×2^3 -> 符号是0, 指数部分原本是3,转成127+3=130,即二进制 10000010, 科学计数部分去掉点之前的1, 即是100001.
-0.375 = 0.011 = 1.1×2^-2 -> 符号是1, 指数部分是-2, 转成127-2=125, 即二进制 1111101, 科学计数部分去掉点之前的1, 即是 1.

对于指数部分不够8位只要前面补0, 对于科学计数部分, 由于是小数点后的, 所以后面补0. 于是:
12.125 = 1100.001 = 1.100001×2^3 => 0 10000010 10000100000000000000000`.
-0.375 = 0.11 = 1.1×2^-1 => 1 01111101 1000000000000000000000

验证

让AI写了一段 C 代码, 来验证一把:

#include <stdio.h>

int main() {
    float num1 = 12.125f;
    float num2 = -0.375f;

    // 将浮点数的二进制表示转换为字节表示
    unsigned char *bytePtr1 = (unsigned char *)&num1;
    unsigned char *bytePtr2 = (unsigned char *)&num2;

    printf("12.125 的二进制表示为:\n");
    for (int i = sizeof(float) - 1; i >= 0; i--) {
        for (int j = 7; j >= 0; j--) {
            printf("%d", (bytePtr1[i] >> j) & 1);
        }
        printf(" ");
    }
    printf("\n");

    printf("-0.375 的二进制表示为:\n");
    for (int i = sizeof(float) - 1; i >= 0; i--) {
        for (int j = 7; j >= 0; j--) {
            printf("%d", (bytePtr2[i] >> j) & 1);
        }
        printf(" ");
    }
    printf("\n");

    return 0;
}

跑一台机器

12.125 的二进制表示为:
01000001 01000010 00000000 00000000
-0.375 的二进制表示为:
10111110 11000000 00000000 00000000